512 lines
18 KiB
JavaScript
512 lines
18 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"];
|
|
|
|
// 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();
|
|
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 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;
|
|
}
|
|
|
|
// 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(civ, order);
|
|
case "cancel_train":
|
|
return this._handleCancelTrain(civ, order);
|
|
case "build":
|
|
return this._handleBuild(civ, order);
|
|
case "build_improvement":
|
|
return this._handleBuildImprovement(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), 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(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));
|
|
}
|
|
|
|
// 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(civ, order) {
|
|
const free = !!order.free && this.testing;
|
|
return this.state.requestBuildImprovement(
|
|
civ,
|
|
coordsOf(order.coords),
|
|
String(order.improvement || ""),
|
|
free
|
|
);
|
|
}
|
|
|
|
_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);
|
|
// 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);
|
|
}
|
|
|
|
// 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;
|
|
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 };
|
|
}
|