GDP per capita had no natural growth, so a nation's economy only moved when population, buildings or war did. The summary stats also offered inconsistent hover text: a native browser tooltip for popularity and none at all for the budget. - Every land tile now gains a constant 0.01% of the GDP per capita it opened with, once a day. Because the amount is fixed the relative growth rate tapers over the years. Each tile's opening figure is snapshotted once and shipped to the browser, so the tile panel and the GDP-per-capita map mode stay in step with the server. - The population, GDP and GDP/capita tooltips show the relative growth: against 1 January 2000 through the first year, then year on year. The monthly captures now keep a rolling thirteen months for the baseline. - Every summary stat explains itself through the same custom bubble. Popularity's native title moved onto it, and Budget forecasts the next calendar month at the current hourly net, as a compact figure such as "+€3.28B/mo" (hoursInNextMonth follows the real calendar). - The tile GDP breakdown shows gains with a plus sign (natural growth, ports) rather than the old minus. Added tests for the constant daily gain, the falling relative rate, the first-year and year-on-year baselines, the snapshot baseline, the budget forecast and the calendar month length.
583 lines
21 KiB
JavaScript
583 lines
21 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. `cityStats` changes with the economy and `visible`
|
|
// with the fog, so both are versioned to keep the 10 Hz broadcast small.
|
|
const DELTA_COLLECTIONS = [
|
|
"territory",
|
|
"regions",
|
|
"population",
|
|
"gdpBaseline",
|
|
"tileEthnicity",
|
|
"cityStats",
|
|
"visible",
|
|
];
|
|
|
|
// 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, 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();
|
|
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 "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 "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);
|
|
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
|
|
);
|
|
}
|
|
|
|
// 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));
|
|
}
|
|
|
|
_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));
|
|
}
|
|
|
|
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);
|
|
// 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 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);
|
|
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 };
|
|
}
|