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

439 lines
15 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 per-tile arrays 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.
const DELTA_COLLECTIONS = ["territory", "regions", "population", "tileEthnicity"];
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();
this.login = new LoginManager();
this.adminName = "";
this.peerNames = new Map();
this.identityCiv = new Map();
this._configured = false;
this._stateDirty = false;
// Per-peer delta bookkeeping: the versions last sent and how many explored
// tiles the viewer already has.
this._peerSnapshotVersions = new Map();
this._peerExploredSent = 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;
}
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(civ, order);
case "cancel_train":
return this._handleCancelTrain(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);
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);
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));
}
_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(civ, order) {
const city = this.state.findCity(Number(order.city));
if (!city || city.civ !== civ) return false;
const free = !!order.free && this.testing;
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(civ, order) {
const city = this.state.findCity(Number(order.city));
if (!city || city.civ !== civ) return false;
const free = !!order.free && this.testing;
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));
}
_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);
}
_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));
}
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);
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._peerSnapshotVersions.delete(peerId);
this._peerExploredSent.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;
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),
});
}
}
// 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);
if (!previous) {
this._peerSnapshotVersions.set(peerId, versions);
this._peerExploredSent.set(peerId, (snapshot.explored || []).length);
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);
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 };
}