// 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"], ["cities", "cityList"], ["cityStats", "cityStats"], ["visible", "visible"], ["resourceGraph", "resourceGraph"], // `resourceMarket` is small but is priced by the simple economy's global // stock and carries its supply/demand in the hard one, so it re-ships when // the market version moves rather than on every 10 Hz broadcast. ["resourceMarket", "market"], ["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(); // Per-peer fingerprint of every unit last sent, so a delta ships only the // units that changed rather than the whole army. On a large world the unit // list is the bulk of a snapshot and most units are idle on any given // broadcast, so re-sending all of them dominated the loop. this._peerUnitSignatures = 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), { // The model the launcher chose; `configure` coerces an unknown value to // the "simple" default, so the setup may pass anything. economyModel: setup.economyModel, }); 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. This is a hard-economy concept: the simple economy's // scenario is authored as its opening state, so it skips the settle // entirely and `settledDays` stays 0 whatever the cap says. const warmupDays = this.state.isSimpleEconomy() ? 0 : Number(setup.warmupDays || 0); this.settledDays = warmupDays > 0 ? this.state.warmUpToStability({ maxDays: warmupDays }) : 0; // Lay out every nation's opening forces and reserves only after the settle, // so the warm-up cannot spend the year of stores the game is meant to open // with. This is the state the first player joins. this.state.applyStartingScenario(); 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 "sabotage": return this._handleSabotage(civ, order); case "spy_city": return this._handleSpyCity(civ, order); case "spy_comms": return this._handleSpyComms(civ, order); case "launch_satellite": return this._handleLaunchSatellite(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 "unlock_technology": return this._handleUnlockTechnology(peerId, civ, order); case "declare_war": return this._handleDeclareWar(civ, order); case "propose_treaty": return this._handleProposeTreaty(civ, order); case "respond_treaty": return this._handleRespondTreaty(civ, order); case "cancel_treaty": return this._handleCancelTreaty(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": // The simple economy has no central bank and no tax controls: its one // revenue is the per-inhabitant approval tax, so both orders are refused // before they reach the state. if (this.state.isSimpleEconomy()) return false; return this.state.requestSetInterestRate(civ, Number(order.rate)); case "set_tax_rate": if (this.state.isSimpleEconomy()) return false; return this.state.requestSetTaxRate( civ, order.kind, Number(order.rate), order.resource || null ); case "market_trade": // The global market is a simple-economy institution: the hard model's // reference prices are not buyable, so its server has no such order. if (!this.state.isSimpleEconomy()) return false; return this.state.requestMarketTrade( civ, String(order.resource || ""), String(order.side || ""), Number(order.lot) ); 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); // A missile is a one-use rocket: firing it consumes the unit and lands its // warhead where it lands, whether that is conventional or nuclear. if (units.every((id) => this.state._isMissileUnit(this.state.findUnit(id)))) { return this.state.requestMissileStrike(units, coords, { nuclear: !!order.nuclear }); } // 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); } _handleSabotage(civ, order) { const units = this._ownUnitIds(civ, order, true); if (!units) return false; const frame = Number.isInteger(order.frame) ? order.frame : null; return this.state.requestSabotage(units, { frame }); } _handleSpyCity(civ, order) { const units = this._ownUnitIds(civ, order, true); if (!units) return false; return this.state.requestCitySpy(units, coordsOf(order.coords)); } _handleSpyComms(civ, order) { const units = this._ownUnitIds(civ, order, true); if (!units) return false; return this.state.requestCommsSpy(units, coordsOf(order.coords)); } _handleLaunchSatellite(civ, order) { const city = this.state.findCity(Number(order.city)); if (!city || city.civ !== civ) return false; return this.state.requestSatellite(civ, city); } _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)); } // The testing shortcut: grants a technology outright. Honoured only for a // local page or a `--testing` game, exactly like the free build and train // orders; a normal client's `free` flag is ignored. _handleUnlockTechnology(peerId, civ, order) { if (!(!!order.free && this._freeAllowed(peerId))) return false; return this.state.unlockTechnology(civ, Number(order.technology)); } _handleDeclareWar(civ, order) { const target = Number(order.target !== undefined ? order.target : order.civ); return this.state.requestDeclareWar(civ, target); } _handleProposeTreaty(civ, order) { const to = Number(order.to); const type = String(order.treaty || order.treatyType || ""); return this.state.requestProposeTreaty(civ, to, type, order.payload || {}); } _handleRespondTreaty(civ, order) { return this.state.requestRespondTreaty(civ, Number(order.proposal), !!order.accept); } _handleCancelTreaty(civ, order) { return this.state.requestCancelTreaty(civ, Number(order.treatyId)); } _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); this._peerUnitSignatures.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); this._peerUnitSignatures.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(); // One fingerprint pass for the whole broadcast. Units are shared by every // viewer, so their signatures are computed once and each peer is sent only // the units that changed for it since its last snapshot. The signature is // a hash so the per-peer bookkeeping stays a few numbers, not a second // copy of the army. const unitSignatures = new Map(); for (const unit of shared.units) unitSignatures.set(unit.id, hashValue(JSON.stringify(unit))); 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, unitSignatures), }); } } 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, only the newly explored tiles are shipped, and units are sent one // by one as they change rather than as a whole army every broadcast. The first // snapshot to a peer (and after a reconnect) is always full. _deltaState(peerId, snapshot, unitSignatures) { // A viewer does not necessarily see every unit -- a hidden submarine is // omitted from its snapshot -- so the per-peer bookkeeping is keyed to the // units in this viewer's own snapshot. const viewerSignatures = new Map(); for (const unit of snapshot.units || []) { const signature = unitSignatures.get(unit.id); if (signature !== undefined) viewerSignatures.set(unit.id, signature); } 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); this._peerUnitSignatures.set(peerId, viewerSignatures); 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; const sentUnits = this._peerUnitSignatures.get(peerId); if (sentUnits) { const units = snapshot.units || []; const updated = []; for (const unit of units) { if (sentUnits.get(unit.id) !== viewerSignatures.get(unit.id)) updated.push(unit); } const removed = []; for (const id of sentUnits.keys()) { if (!viewerSignatures.has(id)) removed.push(id); } delete state.units; if (updated.length > 0) state.unitsUpdated = updated; if (removed.length > 0) state.unitsRemoved = removed; } this._peerUnitSignatures.set(peerId, viewerSignatures); 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 }; } // A cheap FNV-1a hash of a serialised value, used to tell one broadcast's unit // list from the next without keeping a string per unit. A collision would only // skip one update, and the next change to that unit hashes differently. function hashValue(text) { let hash = 2166136261; for (let i = 0; i < text.length; i++) { hash = Math.imul(hash ^ text.charCodeAt(i), 16777619); } return hash >>> 0; }