The trade graph's cache stamp mixed in the tile-improvement version, which a mine, mill or plant rebuilt in place bumps many times a day, so every city's sea-lane flood was thrown away and rebuilt. The stamp now keys on the transport network, the territory and the storage-node set alone: over ten days the rebuilds fall from hundreds to the 45 the world needs, and the daily resource tick from about 700 ms to about 150 ms. The snapshot path is memoised against the counters that actually move it -- the per-civ headline population and GDP, the per-city building and per-nation government/research modifiers, the port and food bonuses, the road and railway tile counts and the ethnic make-up rebuild only when their version changes; the flattened visible set is cached alongside explored; and the HUD upkeep sums without building the labelled budget breakdown. DELTA_COLLECTIONS had drifted from the snapshot's key names (it listed gdpBaseline while the state ships productionBaseline, and tileEthnicity while the version is ethnicity), so the static baseline was re-sent as JSON on every broadcast; the collection/version pairs are now explicit. Together a broadcast falls from roughly 60 ms to 18 ms and the bytes for fifty of them from 57 MB to 24 MB, and 200 in-game hours simulate in about a third of the time. regionTiles caches the per-city owned lists behind the daily economy walks, and supplier reserves read a region's headcount without deriving its GDP; getCityEconomy itself stays uncached outside a snapshot so a simulation read still reflects a direct population edit at once.
628 lines
23 KiB
JavaScript
628 lines
23 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. Each entry pairs the collection with the version key
|
|
// that reports its changes; for `tileEthnicity` the version is named
|
|
// `ethnicity`. A mismatch here silently ships a whole collection every
|
|
// broadcast -- the opening production baseline is static, and shipping its
|
|
// several thousand entries on every 10 Hz snapshot was pure waste.
|
|
const DELTA_COLLECTIONS = [
|
|
["territory", "territory"],
|
|
["regions", "regions"],
|
|
["population", "population"],
|
|
["productionBaseline", "productionBaseline"],
|
|
["tileEthnicity", "ethnicity"],
|
|
["cityStats", "cityStats"],
|
|
["visible", "visible"],
|
|
["resourceGraph", "resourceGraph"],
|
|
["migrationGraph", "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();
|
|
if (setup.mapConfig) this.state.mapConfig = setup.mapConfig;
|
|
this.state.configure(this._loadCivs(setup.civilisations || []), Number(setup.seed || 0));
|
|
this.state.testing = this.testing;
|
|
this.state.onChanged(() => {
|
|
this._stateDirty = true;
|
|
});
|
|
// A fresh world can be settled before the first player joins, so the game
|
|
// opens with its economy and reserves already established rather than at a
|
|
// cold start. The settle runs until commodity prices hold steady (over a
|
|
// seven-day window) instead of for a fixed number of days, capped by the
|
|
// launcher's `warmupDays` so an unsettled world cannot run away. Off when
|
|
// the cap is zero.
|
|
const warmupDays = Number(setup.warmupDays || 0);
|
|
this.settledDays = warmupDays > 0
|
|
? this.state.warmUpToStability({ maxDays: warmupDays })
|
|
: 0;
|
|
this._configured = true;
|
|
for (const peerId of this.network.getPeerIds()) {
|
|
this._assignCiv(peerId, this.peerNames.get(peerId) || "");
|
|
}
|
|
this._broadcastState();
|
|
this.emit("game_configured");
|
|
return this.settledDays;
|
|
}
|
|
|
|
_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 "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));
|
|
}
|
|
|
|
_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, versionKey] of DELTA_COLLECTIONS) {
|
|
if (previous[versionKey] === versions[versionKey]) 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 };
|
|
}
|