Files
Battle-for-Tismo/server/game_server.js
T
adrien c2f131da5c Turned tile improvements into construction sites with their own budgets
Every road, railway and tile work now rises as an economic construction site instead of a queue entry. A site gathers its materials over days from reachable regions, refusing any supplier quoting above the price it was budgeted at (a 1% tolerance); a day that buys nothing stalls the site, raising a red mark and letting the nation raise the budget to current prices or cancel it. The upfront budget covers the build cost plus the market value of its steel and high-tech, energy stays a grid draw, and any money left over stays with the finished work as a maintenance reserve.

That reserve now pays the work's own daily steel and high-tech wear before the treasury is billed, so a well funded work spares the state until it runs dry; the spent money is paid into the nation's regions. Building upgrades run through the same gather-build-maintain machinery, sharing the site's kind flag and target level. The legacy tile-work queue and the market building are gone.

Bundles the in-progress chart sizing for the central bank panel.
2026-09-22 18:08:31 +02:00

619 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",
"migrationGraph",
];
// 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 "increase_site_budget":
return this._handleIncreaseSiteBudget(civ, order);
case "cancel_site":
return this._handleCancelSite(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));
}
_handleIncreaseSiteBudget(civ, order) {
return this.state.requestIncreaseSiteBudget(civ, coordsOf(order.coords));
}
_handleCancelSite(civ, order) {
return this.state.requestCancelSite(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 };
}