World generation now aims at a fixed share of the ice-free tiles (60%) instead of a minimum continent count, bisecting the water level for it rather than sweeping every step, and still joins the ocean into one body. Ice and tundra are the outermost rows only, scaled down on small maps so the fixtures keep usable land. Cities score their site, coastal plains best and landlocked worst, avoiding tundra and desert. Every nation that holds a coast gets a port city on each island it owns, and any landmass left empty is settled by the nearest nation, so no land starts unowned. A coastal city opens with a port already built. Bundles the in-progress work in the tree: the commodity monthly price series and inflation the central bank's chart reads, and the rolling city-to-city migration graph the population map draws as flow arrows.
607 lines
22 KiB
JavaScript
607 lines
22 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",
|
|
"resourceGraph",
|
|
"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();
|
|
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(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 "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));
|
|
}
|
|
|
|
_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 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 };
|
|
}
|