Files
Battle-for-Tismo/server/game_server.js
T
2026-09-17 00:56:59 +02:00

288 lines
9.4 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";
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 };
}