From 34e755d1a425ec4cc5229ebebef1cc38a80a39f2 Mon Sep 17 00:00:00 2001 From: Adrien Jaguenet Date: Sun, 20 Sep 2026 01:07:03 +0200 Subject: [PATCH] Optimised snapshot traffic and map rendering --- client/css/style.css | 4 ++- client/index.html | 4 ++- client/js/game_screen.js | 34 ++++++++++++++++++---- client/js/map_view.js | 13 ++++++++- client/js/map_view/constants.js | 4 +++ client/js/map_view/entities.js | 32 ++++++++++++++------- client/js/map_view/modes.js | 3 ++ client/js/map_view/motion.js | 20 +++++++++++++ client/js/map_view/terrain.js | 27 ++++++++++++++++-- client/js/net.js | 23 ++++++++++++++- server/game_server.js | 45 +++++++++++++++++++++++++++++- server/server.js | 21 ++++++++++---- shared/game_state.js | 7 +++++ shared/game_state/combat.js | 1 + shared/game_state/economy.js | 1 + shared/game_state/orders.js | 1 + shared/game_state/politics.js | 2 ++ shared/game_state/serialization.js | 27 ++++++++++++++++-- shared/game_state/territory.js | 2 ++ tests/game_screen_test.js | 23 +++++++++++++++ tests/game_server_test.js | 42 ++++++++++++++++++++++++++++ tests/map_modes_test.js | 42 ++++++++++++++++++++++++++++ tests/net_test.js | 35 +++++++++++++++++++++++ tests/server_test.js | 20 +++++++++++++ 24 files changed, 403 insertions(+), 30 deletions(-) diff --git a/client/css/style.css b/client/css/style.css index 3845693..8a7d566 100644 --- a/client/css/style.css +++ b/client/css/style.css @@ -472,7 +472,9 @@ input:focus, select:focus { border-color: var(--accent); } /* Country names for the far-out political map, drawn as stretched SVG text on their own layer below the city labels. The SVG has no size of its own; the overflow is visible so the rotated text can sit anywhere on the map. */ -.country-label-layer { pointer-events: none; z-index: 4; } +/* No z-index on purpose: the layer is first of the icon layers in the DOM, so + it paints beneath the selection, paths, targets, units, cities and labels. */ +.country-label-layer { pointer-events: none; } .political-label-svg { position: absolute; left: 0; diff --git a/client/index.html b/client/index.html index 314b39d..ae072ae 100644 --- a/client/index.html +++ b/client/index.html @@ -104,11 +104,13 @@
+ +
-
diff --git a/client/js/game_screen.js b/client/js/game_screen.js index c5479f4..0cc03ed 100644 --- a/client/js/game_screen.js +++ b/client/js/game_screen.js @@ -31,6 +31,10 @@ export class GameScreen { this._cityBuildings = new Map(); this._population = new Map(); this._tileEthnicity = new Map(); + this._populationVersion = undefined; + this._ethnicityVersion = undefined; + this._territoryVersion = undefined; + this._regionVersion = undefined; this._tileGdpPenalties = new Map(); this._tileBattleGdpDeficits = new Map(); this._territoryOwner = new Map(); @@ -131,18 +135,36 @@ export class GameScreen { this.protoUnits = state.protos.map( (p) => PROTO_UNITS.find((full) => full.id === p.id) || { id: p.id, name: p.name } ); - this._population = new Map(state.population.map((e) => [`${e[0]},${e[1]}`, e[2]])); - this._tileEthnicity = new Map( - (state.tileEthnicity || []).map((e) => [`${e[0]},${e[1]}`, e[2]]) - ); + // The big per-tile collections carry a version from the server. Rebuild + // their client-side maps only when that version moved; a snapshot with no + // versions (direct callers, tests) always rebuilds. + const versions = state.versions || null; + const versionChanged = (key, current) => !versions || versions[key] !== current; + const versionOf = (key) => (versions ? versions[key] : undefined); + if (versionChanged("population", this._populationVersion)) { + this._populationVersion = versionOf("population"); + this._population = new Map(state.population.map((e) => [`${e[0]},${e[1]}`, e[2]])); + } + if (versionChanged("ethnicity", this._ethnicityVersion)) { + this._ethnicityVersion = versionOf("ethnicity"); + this._tileEthnicity = new Map( + (state.tileEthnicity || []).map((e) => [`${e[0]},${e[1]}`, e[2]]) + ); + } + if (versionChanged("territory", this._territoryVersion)) { + this._territoryVersion = versionOf("territory"); + this._territoryOwner = new Map(state.territory.map((e) => [`${e[0]},${e[1]}`, e[2]])); + } + if (versionChanged("regions", this._regionVersion)) { + this._regionVersion = versionOf("regions"); + this._tileRegion = new Map((state.regions || []).map((e) => [`${e[0]},${e[1]}`, e[2]])); + } this._tileGdpPenalties = new Map( (state.tileGdpPenalties || []).map((e) => [`${e[0]},${e[1]}`, e[2]]) ); this._tileBattleGdpDeficits = new Map( (state.tileBattleGdpDeficits || []).map((e) => [`${e[0]},${e[1]}`, e[2]]) ); - this._territoryOwner = new Map(state.territory.map((e) => [`${e[0]},${e[1]}`, e[2]])); - this._tileRegion = new Map((state.regions || []).map((e) => [`${e[0]},${e[1]}`, e[2]])); this._battles = new Set((state.battles || []).map((e) => `${e[0]},${e[1]}`)); this._cityBuildings = new Map(); for (const city of state.cities) { diff --git a/client/js/map_view.js b/client/js/map_view.js index 16cf5cb..a3d993a 100644 --- a/client/js/map_view.js +++ b/client/js/map_view.js @@ -63,7 +63,13 @@ export class MapView { : $("#layer-political-labels"); if (!this.$politicalLabels.length) { this.$politicalLabels = $('
'); - this.$world.append(this.$politicalLabels); + // Keep the names under the icon layers even when the layer was missing + // from the page: insert before the first overlay rather than on top. + if (this.$highlight && this.$highlight.length) { + this.$politicalLabels.insertBefore(this.$highlight); + } else { + this.$world.append(this.$politicalLabels); + } } // The orthographic camera tilts the ground plane, so the terrain and every @@ -106,6 +112,9 @@ export class MapView { this.economicValues = new Map(); this.economicRange = { min: 0, max: 0 }; this._economicSignatureDone = null; + // WebGL fog is rebuilt from the frame loop, coalesced (see `_flushFog`). + this._fogDirty = false; + this._fogBuiltAt = 0; // The colour scale shown while an economic mode is active. this.$legend = $("#map-legend"); this._labelSvg = null; @@ -136,6 +145,8 @@ export class MapView { this._territorySignature = ""; this._regionsSignature = null; + this._territoryVersion = undefined; + this._regionsVersion = undefined; this._roadsSignature = null; this._exploredCount = -1; this._visibleSignature = null; diff --git a/client/js/map_view/constants.js b/client/js/map_view/constants.js index 16942be..f798f2b 100644 --- a/client/js/map_view/constants.js +++ b/client/js/map_view/constants.js @@ -5,6 +5,10 @@ import { EDGE_SEGMENTS } from "./distortion.js"; export const RESYNC_HOURS = 0.75; export const PREDICTION_GRACE_HOURS = 2.0; +// Moving units change the visible set constantly. The fog is a whole-map buffer +// in WebGL, so rebuilding it on every snapshot spike built up; the latest one is +// flushed from the frame loop at most this often, which is imperceptible. +export const FOG_REBUILD_MS = 200; export const DRAG_THRESHOLD = 5; export const ZOOM_MIN = 0.2; export const ZOOM_MAX = 2.0; diff --git a/client/js/map_view/entities.js b/client/js/map_view/entities.js index 1e92c4b..f42fe6b 100644 --- a/client/js/map_view/entities.js +++ b/client/js/map_view/entities.js @@ -28,9 +28,9 @@ export const entityMethods = { this.setRoads(state.roads || []); bootLog.step("Mirrored the road network"); this.localCiv = state.viewer; - this._applyTerritory(state.territory); + this._applyTerritory(state.territory, state.versions && state.versions.territory); bootLog.step("Mirrored the territory and borders"); - this._applyRegions(state.regions || []); + this._applyRegions(state.regions || [], state.versions && state.versions.regions); this._applyKnowledge(state.explored, state.visible); bootLog.step("Mirrored the fog of war"); this._applyBattles(state.battles); @@ -78,10 +78,17 @@ export const entityMethods = { } }, - _applyTerritory(entries) { - const signature = hashEntries(entries); - if (signature === this._territorySignature) return; - this._territorySignature = signature; + _applyTerritory(entries, version = undefined) { + if (version !== undefined) { + // A server version is exact and free to compare, unlike hashing the whole + // territory on every snapshot. + if (version === this._territoryVersion) return; + this._territoryVersion = version; + } else { + const signature = hashEntries(entries); + if (signature === this._territorySignature) return; + this._territorySignature = signature; + } this.territory = new Map(); for (const entry of entries) { @@ -100,10 +107,15 @@ export const entityMethods = { // Mirrors each land tile's region (the city that claims it). The region of a // selected or inspected tile is drawn as a whole, so this only has to refresh // the overlay; nothing is drawn for an unselected map. - _applyRegions(entries) { - const signature = hashEntries(entries); - if (signature === this._regionsSignature) return; - this._regionsSignature = signature; + _applyRegions(entries, version = undefined) { + if (version !== undefined) { + if (version === this._regionsVersion) return; + this._regionsVersion = version; + } else { + const signature = hashEntries(entries); + if (signature === this._regionsSignature) return; + this._regionsSignature = signature; + } this.regions = new Map(entries.map((entry) => [key(entry[0], entry[1]), entry[2]])); if (this.glRenderer) this.glRenderer.buildRegionOverlay(this); }, diff --git a/client/js/map_view/modes.js b/client/js/map_view/modes.js index b3065c9..c20c869 100644 --- a/client/js/map_view/modes.js +++ b/client/js/map_view/modes.js @@ -82,6 +82,9 @@ export const modeMethods = { this._refreshMapColours(); if (this.glRenderer && this.topology) { this.glRenderer.buildKnowledge(this); + // This rebuild is current, so a pending coalesced one would be redundant. + this._fogDirty = false; + this._fogBuiltAt = typeof performance !== "undefined" ? performance.now() : Date.now(); this.glRenderer.buildRegionOverlay(this); } }, diff --git a/client/js/map_view/motion.js b/client/js/map_view/motion.js index 1f47f3e..20f42f9 100644 --- a/client/js/map_view/motion.js +++ b/client/js/map_view/motion.js @@ -86,6 +86,7 @@ export const motionMethods = { this._applyEntityWrap(); this._drawPaths(); this._drawStrikeTargets(); + this._flushFog(); this._glRender(); }, @@ -152,6 +153,12 @@ export const motionMethods = { }, _drawPaths() { + // With nothing selected there is nothing to draw; leave the layer alone + // rather than emptying and repopulating it every frame. + if (this._selectedUnitIds.size === 0) { + if (this.$paths[0].firstChild) this.$paths.empty(); + return; + } const fragment = document.createDocumentFragment(); for (const [id, motion] of this._unitMotion) { if (!this._selectedUnitIds.has(id)) continue; @@ -172,6 +179,10 @@ export const motionMethods = { previous = next; } } + if (!fragment.childNodes.length) { + if (this.$paths[0].firstChild) this.$paths.empty(); + return; + } this.$paths.empty()[0].appendChild(fragment); }, @@ -193,6 +204,11 @@ export const motionMethods = { // layer above the terrain but below the unit and city icons. _drawStrikeTargets() { if (!this.$targets || !this.$targets.length) return; + // Strike lines only appear for a selected attacker or target. + if (this._selectedUnitIds.size === 0) { + if (this.$targets[0].firstChild) this.$targets.empty(); + return; + } const fragment = document.createDocumentFragment(); for (const [id, data] of this._unitData) { if (!data.strikeTarget) continue; @@ -226,6 +242,10 @@ export const motionMethods = { ring.style.top = `${b.y}px`; fragment.appendChild(ring); } + if (!fragment.childNodes.length) { + if (this.$targets[0].firstChild) this.$targets.empty(); + return; + } this.$targets.empty()[0].appendChild(fragment); }, diff --git a/client/js/map_view/terrain.js b/client/js/map_view/terrain.js index f9a18ae..f221e3a 100644 --- a/client/js/map_view/terrain.js +++ b/client/js/map_view/terrain.js @@ -13,7 +13,13 @@ import { MapTopology, mapToLocal, key, parseKey, HEX_W, HEX_H, COL_STEP } from " import { MapGenerator } from "../../../shared/map_generator.js"; import { TerrainStats } from "../../../shared/terrain_stats.js"; import { MAP_CONFIG } from "../../../shared/data.js"; -import { CHUNK_SIZE, CHUNK_MARGIN, CHUNK_CACHE_LIMIT, CAMERA_TILT } from "./constants.js"; +import { + CHUNK_SIZE, + CHUNK_MARGIN, + CHUNK_CACHE_LIMIT, + CAMERA_TILT, + FOG_REBUILD_MS, +} from "./constants.js"; import { terrainLayers, textureRepeat } from "./textures.js"; import { bootLog } from "../loading.js"; @@ -66,11 +72,15 @@ export const terrainMethods = { this._territorySignature = ""; this._regionsSignature = null; + this._territoryVersion = undefined; + this._regionsVersion = undefined; this._exploredCount = -1; this._visibleSignature = null; // Visibility is per-world; the snapshot reapplies it right after this. this.explored = new Set(); this.visible = new Set(); + this._fogDirty = false; + this._fogBuiltAt = 0; this._chunkDirty = true; this._chunkView = { x: null, y: null, zoom: null }; @@ -420,7 +430,9 @@ export const terrainMethods = { _refreshFog() { if (this.glRenderer) { - this.glRenderer.buildKnowledge(this); + // Do not rebuild the whole-map fog buffer on every snapshot: mark it and + // let the frame loop flush it at a bounded rate. + this._fogDirty = true; return; } if (!this._chunks) return; @@ -428,6 +440,17 @@ export const terrainMethods = { if (this._chunkCache) for (const rec of this._chunkCache.values()) rec.fogDirty = true; }, + // Rebuilds the WebGL fog if a visibility change is pending and the last + // rebuild is old enough. Called once per frame. + _flushFog() { + if (!this.glRenderer || !this._fogDirty) return; + const now = typeof performance !== "undefined" ? performance.now() : Date.now(); + if (this._fogBuiltAt && now - this._fogBuiltAt < FOG_REBUILD_MS) return; + this._fogDirty = false; + this._fogBuiltAt = now; + this.glRenderer.buildKnowledge(this); + }, + // Installs the road network once. Roads never change once generated, so a // length check is enough to skip rebuilding the on-screen chunks. setRoads(entries) { diff --git a/client/js/net.js b/client/js/net.js index e4845ac..3071fef 100644 --- a/client/js/net.js +++ b/client/js/net.js @@ -11,6 +11,9 @@ export class Network { this.localPlayerId = 0; this.players = {}; this._handlers = {}; + // The last full game state, with deltas merged into it. GameScreen always + // sees a complete snapshot even though the wire only carries what changed. + this._gameState = null; this._loginFailure = ""; this._connectResolve = null; this._connectReject = null; @@ -56,6 +59,7 @@ export class Network { this._loginFailure = ""; this.localPlayerId = 0; this.isAdmin = false; + this._gameState = null; return new Promise((resolve, reject) => { let socket; try { @@ -158,6 +162,22 @@ export class Network { } } + // Folds a delta into the last full state. A message without `delta` replaces + // it. `exploredAdded` carries only the tiles discovered since the last one, so + // the explored list (which only grows) is never re-sent in full. + _mergeGameState(state) { + if (!state || !state.delta || !this._gameState) { + this._gameState = state; + return; + } + const merged = { ...this._gameState, ...state }; + if (state.exploredAdded) { + merged.explored = (this._gameState.explored || []).concat(state.exploredAdded); + delete merged.exploredAdded; + } + this._gameState = merged; + } + _handleMessage(message) { switch (message.t) { case "assigned": @@ -187,7 +207,8 @@ export class Network { this._emit("civ_assigned", message.civ); break; case "game_state": - this._emit("game_state_received", message.state); + this._mergeGameState(message.state); + this._emit("game_state_received", this._gameState); break; default: break; diff --git a/server/game_server.js b/server/game_server.js index 796618f..4534e93 100644 --- a/server/game_server.js +++ b/server/game_server.js @@ -9,6 +9,11 @@ import { LoginManager } from "../shared/login_manager.js"; import { CIVILISATIONS } from "../shared/data.js"; import { dateString } from "../shared/game_clock.js"; +// The large per-tile arrays a delta snapshot may leave out when their version +// has not moved since the last one sent to that peer. The viewer keeps the +// previous copy until it changes. +const DELTA_COLLECTIONS = ["territory", "regions", "population", "tileEthnicity"]; + export class GameServer extends EventEmitter { constructor(network) { super(); @@ -24,6 +29,10 @@ export class GameServer extends EventEmitter { this.identityCiv = new Map(); this._configured = false; this._stateDirty = false; + // Per-peer delta bookkeeping: the versions last sent and how many explored + // tiles the viewer already has. + this._peerSnapshotVersions = new Map(); + this._peerExploredSent = new Map(); } isConfigured() { @@ -265,6 +274,10 @@ export class GameServer extends EventEmitter { } onPeerJoined(peerId, playerName = "") { + // A fresh connection must receive a full snapshot, never a delta against a + // previous peer that happened to reuse the id. + this._peerSnapshotVersions.delete(peerId); + this._peerExploredSent.delete(peerId); if (!this._configured) return; // A returning player keeps the nation they claimed before. if (this._reclaimIdentity(peerId, playerName)) { @@ -284,6 +297,8 @@ export class GameServer extends EventEmitter { onPeerLeft(peerId) { this.peerCiv.delete(peerId); this.peerNames.delete(peerId); + this._peerSnapshotVersions.delete(peerId); + this._peerExploredSent.delete(peerId); } // Indices of the civilisations no connected peer has claimed. @@ -379,12 +394,40 @@ export class GameServer extends EventEmitter { 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.state.viewerSnapshot(shared, civ, statsCache.get(civ)), + state: this._deltaState(peerId, snapshot), }); } } + + // Trims a viewer snapshot down to what changed since the last one sent to this + // peer: the large per-tile arrays are left out while their version is + // unchanged, and only the newly explored tiles are shipped. The first + // snapshot to a peer (and after a reconnect) is always full. + _deltaState(peerId, snapshot) { + const versions = snapshot.versions; + const previous = this._peerSnapshotVersions.get(peerId); + if (!previous) { + this._peerSnapshotVersions.set(peerId, versions); + this._peerExploredSent.set(peerId, (snapshot.explored || []).length); + return { ...snapshot, delta: false }; + } + const state = { ...snapshot, delta: true }; + for (const key of DELTA_COLLECTIONS) { + if (previous[key] === versions[key]) delete state[key]; + } + const explored = snapshot.explored || []; + const sent = this._peerExploredSent.get(peerId) || 0; + if (explored.length > sent) { + state.exploredAdded = explored.slice(sent); + this._peerExploredSent.set(peerId, explored.length); + } + delete state.explored; + this._peerSnapshotVersions.set(peerId, versions); + return state; + } } function coordsOf(value) { diff --git a/server/server.js b/server/server.js index db3a476..0ffbf7f 100644 --- a/server/server.js +++ b/server/server.js @@ -37,6 +37,11 @@ const MIME = { }; const TICK_MS = 50; +// Movement still simulates every tick, but snapshots are broadcast at most this +// often. A full snapshot is hundreds of KiB, and sending one per 50ms tick made +// every client parse, allocate and repaint at 20Hz; the browser dead-reckons +// movement between snapshots, so a lower rate is invisible in play. +const SNAPSHOT_INTERVAL_S = 0.1; async function serveStatic(request, response) { try { @@ -180,11 +185,13 @@ export function startServer({ port = 27015, bind = "127.0.0.1", log = console.lo }); }); - // Fixed-step loop: movement advances every frame, the clock strikes an hour - // every SECONDS_PER_HOUR real seconds, and one snapshot is broadcast per frame - // in which the state actually changed. Exposed as `step` so tests can drive - // the clock deterministically. + // Fixed-step loop: movement advances every tick and the clock strikes an hour + // every SECONDS_PER_HOUR real seconds. The state is only broadcast once per + // SNAPSHOT_INTERVAL_S however often it changed, so a game with moving units + // does not flood clients with full snapshots. Exposed as `step` so tests can + // drive the clock deterministically. let hourAccumulator = 0; + let snapshotAccumulator = 0; function step(seconds) { gameServer.advanceMovement(seconds / SECONDS_PER_HOUR); hourAccumulator += seconds; @@ -192,7 +199,11 @@ export function startServer({ port = 27015, bind = "127.0.0.1", log = console.lo hourAccumulator -= SECONDS_PER_HOUR; gameServer.tickHour(); } - gameServer.flushIfDirty(); + snapshotAccumulator += seconds; + if (snapshotAccumulator >= SNAPSHOT_INTERVAL_S) { + snapshotAccumulator = 0; + gameServer.flushIfDirty(); + } } const timer = setInterval(() => step(TICK_MS / 1000), TICK_MS); diff --git a/shared/game_state.js b/shared/game_state.js index 2bf366f..9dd4536 100644 --- a/shared/game_state.js +++ b/shared/game_state.js @@ -144,6 +144,13 @@ export class GameState { this._nextPolicyId = 1; this._rebellionCooldown = new Map(); this._politicsVersion = 0; + // Monotonic counters for the large per-tile collections. Snapshots ship + // them so a viewer can tell which collections actually changed and rebuild + // only those, and the server can omit the unchanged ones from the wire. + this._territoryVersion = 0; + this._regionVersion = 0; + this._populationVersion = 0; + this._ethnicityVersion = 0; // Approval is expensive to weight over every tile and is read far more // often than politics changes, so both the per-city and per-nation results // are memoised against the politics version (and cleared on territory diff --git a/shared/game_state/combat.js b/shared/game_state/combat.js index c207e4c..0cde00f 100644 --- a/shared/game_state/combat.js +++ b/shared/game_state/combat.js @@ -265,6 +265,7 @@ export const combatMethods = { // GDP per capita each day, and its recovery clock is pushed back to 30 days // after the last day of fighting. _tickBattleTiles() { + this._populationVersion += 1; for (const battle of this._battleTiles()) { const k = key(battle.coords.x, battle.coords.y); const population = this.tilePopulation.get(k); diff --git a/shared/game_state/economy.js b/shared/game_state/economy.js index b376375..9105cb1 100644 --- a/shared/game_state/economy.js +++ b/shared/game_state/economy.js @@ -498,6 +498,7 @@ export const economyMethods = { for (const k of Array.from(this.tilePopulation.keys())) { this.tilePopulation.set(k, this.tilePopulation.get(k) * (1 + factor)); } + this._populationVersion += 1; }, _tickEconomy() { diff --git a/shared/game_state/orders.js b/shared/game_state/orders.js index 8ba9613..aab7602 100644 --- a/shared/game_state/orders.js +++ b/shared/game_state/orders.js @@ -196,6 +196,7 @@ export const orderMethods = { } population = steps.length > 0 ? steps[steps.length - 1].remaining : population; this.tilePopulation.set(k, population); + this._populationVersion += 1; this.tileGdpPenalty.set(k, penalty); this._gdpPerCapitaCache.delete(k); // The people driven off the tile are civilians lost to its owner in the war diff --git a/shared/game_state/politics.js b/shared/game_state/politics.js index ded5403..cecd616 100644 --- a/shared/game_state/politics.js +++ b/shared/game_state/politics.js @@ -102,6 +102,7 @@ export const politicsMethods = { if (weights.size === 0) weights.set(owner >= 0 ? owner : 0, 1); this.tileEthnicity.set(k, normalizeShares(weights)); } + this._ethnicityVersion += 1; }, // Multi-source BFS over every tile (land and sea) from a civilisation's @@ -148,6 +149,7 @@ export const politicsMethods = { if (share > 0.0001) compact.set(eth, share); } this.tileEthnicity.set(k, normalizeShares(compact)); + this._ethnicityVersion += 1; }, // The tile's people split by ethnicity, richest population first, each with diff --git a/shared/game_state/serialization.js b/shared/game_state/serialization.js index 8e0a491..7b6ec55 100644 --- a/shared/game_state/serialization.js +++ b/shared/game_state/serialization.js @@ -72,6 +72,15 @@ export const serializationMethods = { queue.map((entry) => ({ ...entry })), ]) ), + // Which of the large per-tile collections changed since the last + // broadcast, so a viewer can rebuild only those and the server can omit + // the rest from the wire (see GameServer._broadcastState). + versions: { + territory: this._territoryVersion, + regions: this._regionVersion, + population: this._populationVersion, + ethnicity: this._ethnicityVersion, + }, }; }, @@ -167,32 +176,46 @@ export const serializationMethods = { }); }, - // A sparse [x, y] list of every road tile. + // A sparse [x, y] list of every road tile. Roads never change once generated, + // so the list is serialized once. _serializeRoads() { + if (this._serializedRoadsCache) return this._serializedRoadsCache; const result = []; for (const k of this.roads) { const coords = parseKey(k); result.push([coords.x, coords.y]); } + this._serializedRoadsCache = result; return result; }, - // A sparse [x, y, cityId] list of every tile's region. + // A sparse [x, y, cityId] list of every tile's region, memoised against the + // region version so an unchanged world is not re-walked on every broadcast. _serializeRegions() { + if (this._serializedRegionsCache && + this._serializedRegionsCache.version === this._regionVersion) { + return this._serializedRegionsCache.value; + } const result = []; for (const [k, cityId] of this.tileRegion) { const coords = parseKey(k); result.push([coords.x, coords.y, cityId]); } + this._serializedRegionsCache = { version: this._regionVersion, value: result }; return result; }, _serializePopulation() { + if (this._serializedPopulationCache && + this._serializedPopulationCache.version === this._populationVersion) { + return this._serializedPopulationCache.value; + } const result = []; for (const [k, value] of this.tilePopulation) { const coords = parseKey(k); result.push([coords.x, coords.y, Math.round(value)]); } + this._serializedPopulationCache = { version: this._populationVersion, value: result }; return result; }, diff --git a/shared/game_state/territory.js b/shared/game_state/territory.js index 3d6522d..ff1e674 100644 --- a/shared/game_state/territory.js +++ b/shared/game_state/territory.js @@ -65,6 +65,7 @@ export const territoryMethods = { // nation's land. Ties between equally close cities are settled by city order, // so the result is deterministic and identical on both sides of the wire. _rebuildRegionIndex() { + this._regionVersion += 1; this.tileRegion = new Map(); this._regionByCity = new Map(); const cityByCell = new Map(); @@ -110,6 +111,7 @@ export const territoryMethods = { }, _indexTerritory() { + this._territoryVersion += 1; this._territoryByCiv = new Map(); for (const [k, owner] of this.territory) { if (!this._territoryByCiv.has(owner)) this._territoryByCiv.set(owner, []); diff --git a/tests/game_screen_test.js b/tests/game_screen_test.js index 208873b..ffb0d1a 100644 --- a/tests/game_screen_test.js +++ b/tests/game_screen_test.js @@ -40,6 +40,29 @@ function landAtDistance(state, center, distance) { } export class GameScreenTest extends TestCase { + async test_versioned_collections_are_reused_when_unchanged() { + const env = await setupDom(); + try { + const screen = new GameScreen(stubNetwork(), makeConfig()); + screen.enter(); + const state = smallState(); + screen.onState(state.snapshot(0)); + const population = screen._population; + const territory = screen.map.territory; + const regions = screen.map.regions; + const ethnicity = screen._tileEthnicity; + // A second snapshot with no state change carries the same versions, so + // none of the big per-tile maps should be rebuilt. + screen.onState(state.snapshot(0)); + this.assert(population === screen._population, "population map is reused"); + this.assert(territory === screen.map.territory, "territory map is reused"); + this.assert(regions === screen.map.regions, "region map is reused"); + this.assert(ethnicity === screen._tileEthnicity, "ethnicity map is reused"); + } finally { + teardownDom(env); + } + } + async test_renders_snapshot_and_sends_orders() { const env = await setupDom(); try { diff --git a/tests/game_server_test.js b/tests/game_server_test.js index f824c47..4661d1a 100644 --- a/tests/game_server_test.js +++ b/tests/game_server_test.js @@ -52,6 +52,48 @@ export class GameServerTest extends TestCase { this.assertEqual(server.state.seed, firstSeed); } + test_snapshots_are_delta_encoded_after_the_first() { + const { server, network } = makeServer(); + network.peers.add(1); + server.peerCiv.set(1, 0); + + server._broadcastState(); + const full = network.sent[network.sent.length - 1].message.state; + this.assertFalse(full.delta, "the first snapshot to a peer is full"); + this.assertNotNull(full.territory, "it carries the whole territory"); + this.assertTrue(Array.isArray(full.explored), "and the explored list"); + + server._broadcastState(); + const delta = network.sent[network.sent.length - 1].message.state; + this.assertTrue(delta.delta, "later snapshots are deltas"); + this.assertNull(delta.territory, "unchanged territory is left out"); + this.assertNull(delta.regions, "unchanged regions are left out"); + this.assertNull(delta.population, "unchanged population is left out"); + this.assertNull(delta.tileEthnicity, "unchanged ethnicity is left out"); + this.assertNull(delta.explored, "no explored tiles are re-sent"); + this.assertNull(delta.exploredAdded, "and none were added"); + this.assertNotNull(delta.units, "the always-changing fields still ship"); + + // A real territory change must ship the array again. + server.state._reindexTerritory(); + server._broadcastState(); + const after = network.sent[network.sent.length - 1].message.state; + this.assertNotNull(after.territory, "changed territory is sent"); + + // Discovering a new tile ships only the tail of the explored list. Reset + // the viewer's exploration so there is a fresh tile to reveal. + server.state.explored.set(0, new Set()); + server.state._serializedExplored.delete(0); + server._peerExploredSent.set(1, 0); + const [fx, fy] = Object.keys(server.state.tiles)[0].split(",").map(Number); + server.state._reveal(0, { x: fx, y: fy }, 0); + server._broadcastState(); + const grown = network.sent[network.sent.length - 1].message.state; + this.assertNull(grown.explored, "the full explored list is not re-sent"); + this.assertNotNull(grown.exploredAdded, "only the newly explored tiles are"); + this.assertGreaterOrEqual(grown.exploredAdded.length, 1, "the tail holds the new tile"); + } + test_move_order_for_own_unit_is_accepted() { const { server } = makeServer(); server.peerCiv.set(1, 0); diff --git a/tests/map_modes_test.js b/tests/map_modes_test.js index b1e05c9..d5eb574 100644 --- a/tests/map_modes_test.js +++ b/tests/map_modes_test.js @@ -114,6 +114,48 @@ export class MapModesTest extends TestCase { } } + async test_fog_rebuilds_are_coalesced_between_frames() { + const env = await setupDom(); + const map = this.buildMap(env); + try { + let builds = 0; + map.glRenderer = { buildKnowledge: () => { builds += 1; } }; + map._fogBuiltAt = 0; + map._fogDirty = false; + map._refreshFog(); + map._refreshFog(); + map._refreshFog(); + map._flushFog(); + this.assertEqual(builds, 1, "many visibility changes rebuild the fog once"); + + map._refreshFog(); + map._flushFog(); + this.assertEqual(builds, 1, "a later change waits for the interval"); + + map._fogBuiltAt = 0; + map._flushFog(); + this.assertEqual(builds, 2, "the pending change is flushed once due"); + } finally { + teardownDom(env); + } + } + + async test_country_names_sit_below_every_icon_layer() { + const env = await setupDom(); + try { + const order = env.$("#map-world .layer").map((_index, element) => element.id).get(); + const names = order.indexOf("layer-political-labels"); + this.assertGreaterOrEqual(names, 0, "the country-name layer exists"); + for (const id of [ + "layer-highlight", "layer-paths", "layer-targets", "layer-entities", "layer-labels", + ]) { + this.assertLess(names, order.indexOf(id), `${id} paints above the country names`); + } + } finally { + teardownDom(env); + } + } + async test_territory_changes_skip_the_fog_and_region_rebuilds() { const state = smallState(); const env = await setupDom(); diff --git a/tests/net_test.js b/tests/net_test.js index 92bbea6..89b5ad6 100644 --- a/tests/net_test.js +++ b/tests/net_test.js @@ -116,6 +116,41 @@ export class NetTest extends TestCase { } } + async test_game_state_deltas_merge_into_the_last_full_state() { + const { env, network, ws } = await this.connected(); + try { + let state = null; + network.on("game_state_received", (value) => { + state = value; + }); + ws.simulateMessage({ + t: "game_state", + state: { seed: 5, viewer: 0, territory: [[0, 0, 1]], explored: [[0, 0]], units: [] }, + }); + this.assertEqual(state.territory.length, 1, "the first state is used whole"); + + // A delta updates only what it carries and keeps the rest. + ws.simulateMessage({ + t: "game_state", + state: { delta: true, units: [{ id: 1 }], exploredAdded: [[1, 0]] }, + }); + this.assertEqual(state.territory.length, 1, "unchanged territory is kept"); + this.assertSize(state.units, 1, "the changed field is updated"); + this.assertSize(state.explored, 2, "newly explored tiles are appended"); + this.assertEqual(state.explored[1], [1, 0]); + this.assertNull(state.exploredAdded, "the temporary tail is dropped"); + + // A full state replaces the base. + ws.simulateMessage({ + t: "game_state", + state: { seed: 6, viewer: 0, territory: [], explored: [], units: [] }, + }); + this.assertEqual(state.seed, 6, "a full state replaces the base"); + } finally { + teardownDom(env); + } + } + async test_disconnected_without_failure_reports_it() { const { env, network, ws } = await this.connected(); try { diff --git a/tests/server_test.js b/tests/server_test.js index 9595099..65b61b1 100644 --- a/tests/server_test.js +++ b/tests/server_test.js @@ -111,6 +111,26 @@ export class ServerLoopTest extends TestCase { this.assertGreater(server.gameServer.state.totalHours, 0, "an hour struck after a second"); }); } + + async test_snapshots_are_throttled_between_ticks() { + await withServer(async (port, server) => { + server.gameServer.configureGame({ + seed: SEED, + player_civ: 0, + civilisations: ["france", "britain"], + }); + // Count how often the loop actually flushes over one second of 50ms ticks. + const flush = server.gameServer.flushIfDirty.bind(server.gameServer); + let flushes = 0; + server.gameServer.flushIfDirty = () => { + flushes += 1; + flush(); + }; + for (let i = 0; i < 20; i++) server.step(0.05); + this.assertLess(flushes, 20, "not one snapshot per tick"); + this.assertGreaterOrEqual(flushes, 9, "about one snapshot per 100ms"); + }); + } } export class ServerProtocolTest extends TestCase {