// 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"; const DEFAULT_CIV_IDS = [ "france", "britain", "slovenia", "poland", "switzerland", "ticino", "galicia", "germany", "iran", ]; export class GameServer extends EventEmitter { constructor(network) { super(); this.network = network; this.state = null; this.hostCiv = 0; this.peerCiv = new Map(); this.login = new LoginManager(); this.adminName = ""; this.peerNames = new Map(); this.identityCiv = new Map(); this._configured = false; this._stateDirty = false; } 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.state = new GameState(); this.state.configure(this._loadCivs(setup.civilisations || []), Number(setup.seed || 0)); 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); } if (civs.length === 0) { for (const id of DEFAULT_CIV_IDS) { const civ = CIVILISATIONS.find((c) => c.id === id); if (civ) civs.push(civ); } } return civs; } 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 "train": return this._handleTrain(civ, order); case "build": return this._handleBuild(civ, order); case "demolish": return this._handleDemolish(civ, order); case "government": return this._handleGovernment(civ, order); case "technology": return this._handleTechnology(civ, order); case "declare_war": return this._handleDeclareWar(civ, order); default: return false; } } _handleMove(civ, order) { const unit = this.state.findUnit(Number(order.unit)); if (!unit || unit.civ !== civ) return false; return this.state.requestMove(unit.id, coordsOf(order.coords)); } _handleTrain(civ, order) { const city = this.state.findCity(Number(order.city)); if (!city || city.civ !== civ) return false; return this.state.requestTrain(city.id, Number(order.proto)); } _handleBuild(civ, order) { const city = this.state.findCity(Number(order.city)); if (!city || city.civ !== civ) return false; return this.state.requestBuild(city.id, Number(order.building)); } _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)); } _handleGovernment(civ, order) { return this.state.requestSetGovernment(civ, Number(order.government)); } _handleTechnology(civ, order) { return this.state.requestResearchTechnology(civ, Number(order.technology)); } _handleDeclareWar(civ, order) { const target = Number(order.target !== undefined ? order.target : order.civ); return this.state.requestDeclareWar(civ, target); } onPeerJoined(peerId, playerName = "") { 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); } // 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._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 }); 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; 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)); this.network.send(peerId, { t: "game_state", state: this.state.viewerSnapshot(shared, civ, statsCache.get(civ)), }); } } } function coordsOf(value) { if (Array.isArray(value) && value.length >= 2) { return { x: Number(value[0]), y: Number(value[1]) }; } return { x: -1, y: -1 }; }