Files
Battle-for-Tismo/server/game_server.js
T

606 lines
22 KiB
JavaScript

// Authoritative game server, ported from scripts/server/game_server.gd. Owns
// the GameState, turns player orders into state changes and pushes snapshots
// back to every connected client. The `network` adapter supplies getPeerIds()
// and send(peerId, message); everything else is transport-agnostic.
import { EventEmitter } from "node:events";
import { GameState } from "../shared/game_state.js";
import { LoginManager } from "../shared/login_manager.js";
import { CIVILISATIONS } from "../shared/data.js";
import { dateString } from "../shared/game_clock.js";
// The large collections a delta snapshot may leave out when their version has
// not moved since the last one sent to that peer. The viewer keeps the previous
// copy until it changes. `cityStats` changes with the economy and `visible`
// with the fog, so both are versioned to keep the 10 Hz broadcast small.
const DELTA_COLLECTIONS = [
"territory",
"regions",
"population",
"gdpBaseline",
"tileEthnicity",
"cityStats",
"visible",
"resourceGraph",
];
// A wire move order carrying more units than this is split into chunks that
// each run in their own tick. One army move can otherwise run dozens of A*
// searches back to back and stall the simulation.
const MOVE_ORDER_CHUNK = 4;
export class GameServer extends EventEmitter {
constructor(network) {
super();
this.network = network;
this.state = null;
this.hostCiv = 0;
// True for a local test game, where free, instant orders are allowed.
this.testing = false;
this.peerCiv = new Map();
// Peers whose page runs on the local machine. They may use the free, instant
// testing orders even when the game as a whole is not a test game.
this._localPeers = new Set();
this.login = new LoginManager();
this.adminName = "";
this.peerNames = new Map();
this.identityCiv = new Map();
this._configured = false;
this._stateDirty = false;
// Orders wait here between the socket callback and the next simulation
// tick, so a burst from several players is spread over time instead of
// running every pathfinding search back to back on the event loop.
this._orderQueue = [];
// Per-peer delta bookkeeping: the versions last sent, how many explored
// tiles the viewer already has, and which civ those were for (so a civ
// change, whose per-viewer collections differ, forces a full snapshot).
this._peerSnapshotVersions = new Map();
this._peerExploredSent = new Map();
this._peerCivSent = new Map();
}
isConfigured() {
return this._configured;
}
gameList() {
if (!this._configured || !this.state) return [];
return [this.gameSummary()];
}
gameSummary() {
return {
id: this.state.seed,
name: `Game ${this.state.seed}`,
seed: this.state.seed,
players: this.peerNames.size,
civs: this.state.civilisations.map((c) => c.name),
date: dateString(this.state.totalHours),
};
}
authenticate(peerId, playerName, password) {
const name = String(playerName || "").trim();
if (name.length === 0) return { ok: false, reason: "Enter a player name." };
if (String(password || "").length === 0) return { ok: false, reason: "Enter a password." };
if (Array.from(this.peerNames.values()).includes(name)) {
return { ok: false, reason: `${name} is already playing.` };
}
if (!this.login.login(name, password)) {
return { ok: false, reason: `Wrong password for ${name}.` };
}
const isAdmin = this.adminName.length === 0;
if (isAdmin) this.adminName = name;
this.peerNames.set(peerId, name);
return { ok: true, name, admin: name === this.adminName };
}
configureGame(setup) {
if (this._configured) return;
this.hostCiv = Number(setup.player_civ || 0);
this.testing = !!setup.testing;
this.state = new GameState();
this.state.configure(this._loadCivs(setup.civilisations || []), Number(setup.seed || 0));
this.state.testing = this.testing;
this.state.onChanged(() => {
this._stateDirty = true;
});
this._configured = true;
for (const peerId of this.network.getPeerIds()) {
this._assignCiv(peerId, this.peerNames.get(peerId) || "");
}
this._broadcastState();
this.emit("game_configured");
}
_loadCivs(ids) {
const civs = [];
for (const id of ids) {
const civ = CIVILISATIONS.find((c) => c.id === id);
if (civ) civs.push(civ);
}
// An empty list means the project's full roster.
return civs.length === 0 ? CIVILISATIONS.slice() : civs;
}
// Queues an order from the wire. The socket callback must stay cheap: a
// single group move can run hundreds of A* searches, and several players
// sending at once would otherwise freeze the tick loop (and every client's
// snapshot) until the whole burst finished.
enqueueOrder(peerId, order) {
// A large group move becomes several chunks so no single tick runs a whole
// army's pathfinding at once. The chunks share the pace computed for the
// full group, so a split stack still moves at its slowest member's speed.
const type = String(order.t || order.type || "");
if (type === "move" && Array.isArray(order.units) && order.units.length > MOVE_ORDER_CHUNK) {
const civ = this.peerCiv.has(peerId) ? this.peerCiv.get(peerId) : -1;
if (civ >= 0 && this.state && this._ownUnitIds(civ, order, true)) {
const moveable = this.state._moveableUnits(order.units);
const speed = moveable.length > 1
? Math.min(...moveable.map((unit) => this.state._unitSpeed(unit)))
: null;
for (let i = 0; i < order.units.length; i += MOVE_ORDER_CHUNK) {
this._orderQueue.push({
peerId,
order: { ...order, units: order.units.slice(i, i + MOVE_ORDER_CHUNK), speed },
});
}
return;
}
}
this._orderQueue.push({ peerId, order });
}
// Applies queued orders until `budgetMs` is spent; the remainder waits for a
// later tick. At least one order runs per call so the queue always drains.
processOrders(budgetMs = 8) {
if (this._orderQueue.length === 0) return 0;
const deadline = Date.now() + budgetMs;
let processed = 0;
do {
const next = this._orderQueue.shift();
this.handleOrder(next.peerId, next.order);
processed += 1;
} while (this._orderQueue.length > 0 && Date.now() < deadline);
return processed;
}
handleOrder(peerId, order) {
if (!this._configured) return false;
const civ = this.peerCiv.has(peerId) ? this.peerCiv.get(peerId) : -1;
if (civ < 0) return false;
const type = String(order.t || order.type || "");
switch (type) {
case "move":
return this._handleMove(civ, order);
case "attack":
return this._handleAttack(civ, order);
case "schedule":
return this._handleSchedule(civ, order);
case "pillage":
return this._handlePillage(civ, order);
case "train":
return this._handleTrain(peerId, civ, order);
case "cancel_train":
return this._handleCancelTrain(civ, order);
case "build":
return this._handleBuild(peerId, civ, order);
case "build_improvement":
return this._handleBuildImprovement(peerId, civ, order);
case "remove_improvement":
return this._handleRemoveImprovement(civ, order);
case "dig_trench":
return this._handleDigTrench(civ, order);
case "build_tile_improvement":
return this._handleBuildTileImprovement(civ, order);
case "remove_tile_improvement":
return this._handleRemoveTileImprovement(civ, order);
case "embark":
return this._handleEmbark(civ, order);
case "disembark":
return this._handleDisembark(civ, order);
case "demolish":
return this._handleDemolish(civ, order);
case "government":
return this._handleGovernment(civ, order);
case "focus_technology":
return this._handleFocusTechnology(civ, order);
case "declare_war":
return this._handleDeclareWar(civ, order);
case "campaign":
return this._handleCampaign(civ, order);
case "cancel_campaign":
return this._handleCancelCampaign(civ, order);
case "campaign_spending":
return this._handleCampaignSpending(civ, order);
case "policy":
return this._handlePolicy(civ, order);
case "revoke_policy":
return this._handleRevokePolicy(civ, order);
case "set_interest_rate":
return this.state.requestSetInterestRate(civ, Number(order.rate));
case "set_tax_rate":
return this.state.requestSetTaxRate(
civ, order.kind, Number(order.rate), order.resource || null
);
default:
return false;
}
}
_handleMove(civ, order) {
const units = this._ownUnitIds(civ, order, true);
if (!units) return false;
return this.state.requestGroupMove(units, coordsOf(order.coords), order.speed);
}
_handleAttack(civ, order) {
const units = this._ownUnitIds(civ, order);
if (units.length === 0) return false;
const coords = coordsOf(order.coords);
// Aircraft fly to a hostile unit and bomb it on arrival, then return to a
// friendly airport. A ground/naval battery (artillery, warships) instead
// opens a sustained hourly bombardment from where it stands. Anything else
// -- or an attack on a tile with no unit on it, an undefended city say --
// is a charge.
if (units.every((id) => this.state._isAirUnit(this.state.findUnit(id)))) {
return this.state.requestAirStrike(units, coords);
}
const ground = units.filter((id) => !this.state._isAirUnit(this.state.findUnit(id)));
const allRanged =
ground.length > 0 && ground.every((id) => this.state._isStrikeUnit(this.state.findUnit(id)));
if (allRanged && this.state._strikeTargetAt(coords, civ)) {
return this.state.requestBombard(ground, coords);
}
return this.state.requestAttack(units, coords);
}
_handleSchedule(civ, order) {
const units = this._ownUnitIds(civ, order);
if (units.length === 0) return false;
const goals = (Array.isArray(order.goals) ? order.goals : [])
.filter((goal) => Array.isArray(goal) && goal.length >= 2)
.map(coordsOf);
return this.state.requestScheduledMove(units, goals, !!order.append);
}
// The caller's own unit ids. With `strict`, a single id that is unknown or
// owned by another civilisation rejects the whole order (null); otherwise the
// foreign ids are simply dropped.
_ownUnitIds(civ, order, strict = false) {
const ids = Array.isArray(order.units) ? order.units : [order.unit];
const units = [];
for (const id of ids) {
const unit = this.state.findUnit(Number(id));
if (!unit || unit.civ !== civ) {
if (strict) return null;
continue;
}
units.push(unit.id);
}
return units;
}
_handlePillage(civ, order) {
const units = this._ownUnitIds(civ, order, true);
if (!units) return false;
return this.state.requestPillage(units);
}
_handleTrain(peerId, civ, order) {
const city = this.state.findCity(Number(order.city));
if (!city || city.civ !== civ) return false;
const free = !!order.free && this._freeAllowed(peerId);
return this.state.requestTrain(city.id, Number(order.proto), free);
}
_handleCancelTrain(civ, order) {
const city = this.state.findCity(Number(order.city));
if (!city || city.civ !== civ) return false;
return this.state.cancelTrain(city.id, Number(order.index));
}
_handleBuild(peerId, civ, order) {
const city = this.state.findCity(Number(order.city));
if (!city || city.civ !== civ) return false;
const free = !!order.free && this._freeAllowed(peerId);
return this.state.requestBuild(city.id, Number(order.building), free);
}
_handleDemolish(civ, order) {
const city = this.state.findCity(Number(order.city));
if (!city || city.civ !== civ) return false;
return this.state.requestDemolish(city.id, Number(order.building));
}
// Lays a road or railway on a tile the player controls. GameState enforces
// ownership, terrain and cost; the testing shortcut is only honoured locally.
_handleBuildImprovement(peerId, civ, order) {
const free = !!order.free && this._freeAllowed(peerId);
return this.state.requestBuildImprovement(
civ,
coordsOf(order.coords),
String(order.improvement || ""),
free
);
}
// Tears a road or railway off a tile the player controls.
_handleRemoveImprovement(civ, order) {
return this.state.requestRemoveImprovement(civ, coordsOf(order.coords));
}
_handleDigTrench(civ, order) {
const units = this._ownUnitIds(civ, order);
if (units.length === 0) return false;
return this.state.requestDigTrench(units);
}
_handleBuildTileImprovement(civ, order) {
return this.state.requestBuildTileImprovement(
civ,
coordsOf(order.coords),
String(order.improvement || "")
);
}
_handleRemoveTileImprovement(civ, order) {
return this.state.requestRemoveTileImprovement(civ, coordsOf(order.coords));
}
_handleEmbark(civ, order) {
const units = this._ownUnitIds(civ, order);
if (units.length === 0) return false;
return this.state.requestEmbark(units, Number(order.launch));
}
_handleDisembark(civ, order) {
const units = this._ownUnitIds(civ, order);
if (units.length === 0) return false;
return this.state.requestDisembark(units, coordsOf(order.coords));
}
_handleGovernment(civ, order) {
return this.state.requestSetGovernment(civ, Number(order.government));
}
_handleFocusTechnology(civ, order) {
return this.state.requestFocusTechnology(civ, Number(order.technology));
}
_handleDeclareWar(civ, order) {
const target = Number(order.target !== undefined ? order.target : order.civ);
return this.state.requestDeclareWar(civ, target);
}
_handleCampaign(civ, order) {
const observer = Number(order.observer);
const target = Number(order.target);
const direction = Number(order.direction) < 0 ? -1 : 1;
return this.state.requestCampaign(
civ,
observer,
String(order.targetKind || ""),
target,
direction
);
}
_handleCancelCampaign(civ, order) {
return this.state.requestCancelCampaign(civ, Number(order.campaign));
}
_handleCampaignSpending(civ, order) {
return this.state.requestSetCampaignSpending(
civ,
Number(order.campaign),
Number(order.amount)
);
}
_handlePolicy(civ, order) {
return this.state.requestPolicy(civ, String(order.policy || ""), Number(order.ethnicity));
}
_handleRevokePolicy(civ, order) {
return this.state.requestRevokePolicy(civ, Number(order.policy));
}
// Marks a peer as connected from a page on the local machine. Local peers may
// use the free, instant testing orders even in a normal game.
setPeerLocal(peerId, local) {
if (local) this._localPeers.add(peerId);
else this._localPeers.delete(peerId);
}
// Whether a peer may spend the testing shortcut.
_freeAllowed(peerId) {
return this.testing || this._localPeers.has(peerId);
}
onPeerJoined(peerId, playerName = "") {
// A fresh connection must receive a full snapshot, never a delta against a
// previous peer that happened to reuse the id.
this._peerSnapshotVersions.delete(peerId);
this._peerExploredSent.delete(peerId);
this._peerCivSent.delete(peerId);
if (!this._configured) return;
// A returning player keeps the nation they claimed before.
if (this._reclaimIdentity(peerId, playerName)) {
this.network.send(peerId, { t: "game_state", state: this.state.snapshot(this.peerCiv.get(peerId)) });
return;
}
// Otherwise offer every nation no one has claimed yet and let the player
// pick. They stay a spectator (civ -1) until they answer.
this.peerCiv.set(peerId, -1);
const options = this.availableCivs();
this.network.send(peerId, { t: "choose_civ", civs: options });
if (options.length === 0) {
this.network.send(peerId, { t: "game_state", state: this.state.snapshot(-1) });
}
}
onPeerLeft(peerId) {
this.peerCiv.delete(peerId);
this.peerNames.delete(peerId);
this._localPeers.delete(peerId);
// A departed player's queued orders must not land after they are gone.
if (this._orderQueue.length > 0) {
this._orderQueue = this._orderQueue.filter((entry) => entry.peerId !== peerId);
}
this._peerSnapshotVersions.delete(peerId);
this._peerExploredSent.delete(peerId);
this._peerCivSent.delete(peerId);
}
// Indices of the civilisations no connected peer has claimed.
availableCivs() {
if (!this._configured || !this.state) return [];
const used = new Set(this.peerCiv.values());
const options = [];
for (let i = 0; i < this.state.civilisations.length; i++) {
if (used.has(i)) continue;
const civ = this.state.civilisations[i];
options.push({ index: i, id: civ.id, name: civ.name });
}
return options;
}
// Claims one of the offered civilisations for a joining peer. Rejects an
// index that is out of range or already taken by someone else.
claimCiv(peerId, index) {
if (!this._configured || !this.state) return false;
const choice = Number(index);
if (!Number.isInteger(choice) || choice < 0 || choice >= this.state.civilisations.length) {
return false;
}
if (Array.from(this.peerCiv.values()).includes(choice)) return false;
this.peerCiv.set(peerId, choice);
const name = this.peerNames.get(peerId);
if (name) this.identityCiv.set(name, choice);
this.network.send(peerId, { t: "civ_assigned", civ: choice });
this.state.addControlNews(choice, name);
this._broadcastState();
return true;
}
// Re-attaches a known player to the nation they held before, as long as no
// one else has claimed it in the meantime.
_reclaimIdentity(peerId, playerName) {
if (!playerName || !this.identityCiv.has(playerName)) return false;
const choice = this.identityCiv.get(playerName);
if (choice < 0 || choice >= this.state.civilisations.length) return false;
if (Array.from(this.peerCiv.values()).includes(choice)) return false;
this.peerCiv.set(peerId, choice);
this.network.send(peerId, { t: "civ_assigned", civ: choice });
this.state.addControlNews(choice, playerName);
return true;
}
_assignCiv(peerId, playerName = "") {
if (this._configured === false || this.state == null) return;
const used = Array.from(this.peerCiv.values());
let choice = -1;
if (playerName && this.identityCiv.has(playerName)) choice = this.identityCiv.get(playerName);
if (choice < 0 && peerId === this._adminPeerId()) choice = this.hostCiv;
if (choice < 0 || used.includes(choice)) {
choice = -1;
for (let i = 0; i < this.state.civilisations.length; i++) {
if (!used.includes(i)) {
choice = i;
break;
}
}
}
this.peerCiv.set(peerId, choice);
if (playerName && choice >= 0) this.identityCiv.set(playerName, choice);
}
_adminPeerId() {
for (const [peerId, name] of this.peerNames) {
if (name === this.adminName) return peerId;
}
return 0;
}
tickHour() {
if (!this._configured || !this.state) return;
this.state.tickHour();
}
advanceMovement(hours) {
if (!this._configured || !this.state) return;
this.state.advanceMovement(hours);
}
flushIfDirty() {
if (!this._stateDirty) return;
this._stateDirty = false;
this._broadcastState();
}
_broadcastState() {
if (!this.state) return;
// This snapshot reflects the state right now, so anything the change
// listener recorded before it is already included. Clearing first means a
// change raised while serialising still marks the next snapshot dirty.
this._stateDirty = false;
// One economy figure per city for the whole broadcast: the shared city
// stats and every viewer's budget breakdown both need it.
this.state.beginSnapshotCache();
try {
const shared = this.state.serializeShared();
const statsCache = new Map();
for (const peerId of this.network.getPeerIds()) {
const civ = this.peerCiv.has(peerId) ? this.peerCiv.get(peerId) : -1;
if (!statsCache.has(civ)) statsCache.set(civ, this.state.viewerStats(civ));
const snapshot = this.state.viewerSnapshot(shared, civ, statsCache.get(civ));
this.network.send(peerId, {
t: "game_state",
state: this._deltaState(peerId, snapshot),
});
}
} finally {
this.state.endSnapshotCache();
}
}
// Trims a viewer snapshot down to what changed since the last one sent to this
// peer: the large per-tile arrays are left out while their version is
// unchanged, and only the newly explored tiles are shipped. The first
// snapshot to a peer (and after a reconnect) is always full.
_deltaState(peerId, snapshot) {
const versions = snapshot.versions;
const previous = this._peerSnapshotVersions.get(peerId);
// A viewer that changed civilisation sees different per-viewer collections
// (explored, visible), so a delta against the old nation would be wrong.
if (!previous || this._peerCivSent.get(peerId) !== snapshot.viewer) {
this._peerSnapshotVersions.set(peerId, versions);
this._peerExploredSent.set(peerId, (snapshot.explored || []).length);
this._peerCivSent.set(peerId, snapshot.viewer);
return { ...snapshot, delta: false };
}
const state = { ...snapshot, delta: true };
for (const key of DELTA_COLLECTIONS) {
if (previous[key] === versions[key]) delete state[key];
}
const explored = snapshot.explored || [];
const sent = this._peerExploredSent.get(peerId) || 0;
if (explored.length > sent) {
state.exploredAdded = explored.slice(sent);
this._peerExploredSent.set(peerId, explored.length);
}
delete state.explored;
this._peerSnapshotVersions.set(peerId, versions);
this._peerCivSent.set(peerId, snapshot.viewer);
return state;
}
}
function coordsOf(value) {
if (Array.isArray(value) && value.length >= 2) {
return { x: Number(value[0]), y: Number(value[1]) };
}
return { x: -1, y: -1 };
}