diff --git a/client/js/devlog_data.js b/client/js/devlog_data.js index 8014f68..4d90878 100644 --- a/client/js/devlog_data.js +++ b/client/js/devlog_data.js @@ -1,6 +1,12 @@ // Generated by scripts/generate-devlog.js from `git log`; do not edit. // The pre-commit hook refreshes it so the main menu shows the latest commits. export const DEVLOG = [ + { + "hash": "822c26f", + "date": "2026-09-23", + "subject": "Founded every nation with a standing army and a year of reserves", + "body": "" + }, { "hash": "0d1995c", "date": "2026-09-23", @@ -54,11 +60,5 @@ export const DEVLOG = [ "date": "2026-09-23", "subject": "Halved the test suite's runtime and pruned trivial tests", "body": "" - }, - { - "hash": "aed6ba9", - "date": "2026-09-23", - "subject": "Rolled the central bank's commodity chart over the last two years", - "body": "The index is still 100 at January 2000's prices, but the chart now plots only the last twenty-four monthly samples and its end labels follow the window, so the graph stops growing as the game runs. A test covers the 24-month window and its end labels." } ]; diff --git a/client/js/map_view/entities.js b/client/js/map_view/entities.js index 91c8fc3..5a2aa05 100644 --- a/client/js/map_view/entities.js +++ b/client/js/map_view/entities.js @@ -155,6 +155,8 @@ export const entityMethods = { } el.style.left = `${local.x}px`; el.style.top = `${local.y * CAMERA_TILT}px`; + el._localX = local.x; + el._localY = local.y; container.appendChild(el); this._siteViews.set(`${x},${y}`, el); } @@ -175,6 +177,8 @@ export const entityMethods = { } el.style.left = `${local.x}px`; el.style.top = `${local.y * CAMERA_TILT}px`; + el._localX = local.x; + el._localY = local.y; container.appendChild(el); this._warfareViews.set(`${kind}:${k}`, el); }, @@ -471,52 +475,68 @@ export const entityMethods = { _syncUnits(units) { const seen = new Set(); // Store every unit's authoritative data first, so motion prediction can - // see where all of them are before any single one is advanced. + // see where all of them are before any single one is advanced. The server + // sends a fresh object only for the units that changed, so a reference + // comparison tells us which views actually need re-writing; an idle army + // then costs one dictionary pass instead of thousands of DOM updates. + const changed = new Set(); for (const data of units) { - seen.add(data.id); - this._unitData.set(data.id, data); + const id = data.id; + seen.add(id); + if (this._unitData.get(id) !== data) changed.add(id); + this._unitData.set(id, data); + if (!this._unitViews.has(id)) { + this._unitViews.set(id, this._createUnitView(data)); + } } // One bucket per tile for the whole snapshot: truncation tests every step // of every route against the units ahead, and scanning the whole army for - // each of those steps made a large marching stack quadratic. - const occupancy = this._buildOccupancy(0); + // each of those steps made a large marching stack quadratic. Only a moving + // unit reads it, so a quiet broadcast builds nothing. + const occupancy = changed.size > 0 ? this._buildOccupancy(0) : null; for (const data of units) { const id = data.id; - let view = this._unitViews.get(id); - if (!view) { - view = this._createUnitView(data); - this._unitViews.set(id, view); - } - const proto = this.protoUnits[data.proto]; - const civ = this.civilisations[data.civ]; - view.querySelector(".body").style.background = civ ? civ.primaryColour : "#666"; - view.querySelector(".body img").src = proto ? `assets/${proto.icon}` : "assets/person.png"; - const flag = view.querySelector(".unit-flag"); - if (flag) { - if (civ && civ.flag) { - flag.src = `assets/flags/${civ.flag}`; - flag.style.display = ""; - } else { - flag.style.display = "none"; + const view = this._unitViews.get(id); + if (changed.has(id)) { + const proto = this.protoUnits[data.proto]; + const civ = this.civilisations[data.civ]; + view._body.style.background = civ ? civ.primaryColour : "#666"; + view._bodyImg.src = proto ? `assets/${proto.icon}` : "assets/person.png"; + if (view._flagImg) { + if (civ && civ.flag) { + view._flagImg.src = `assets/flags/${civ.flag}`; + view._flagImg.style.display = ""; + } else { + view._flagImg.style.display = "none"; + } } + view._healthFill.style.width = + `${Math.max(0, Math.min(100, (data.hp / data.maxHp) * 100))}%`; + view.classList.toggle("selected", this._selectedUnitIds.has(id)); + this._syncUnitStatuses(view, data); + this._updateUnitMotion(data, occupancy); } - view.querySelector(".health .fill").style.width = - `${Math.max(0, Math.min(100, (data.hp / data.maxHp) * 100))}%`; - view.classList.toggle("selected", this._selectedUnitIds.has(id)); - this._syncUnitStatuses(view, data); - this._updateUnitMotion(data, occupancy); - // Foreign units are only shown inside currently visible tiles. + // Fog can lift on a unit whose own data did not change, so the cheap + // visibility test always runs; the DOM write is skipped while it holds. const k = key(data.coords[0], data.coords[1]); - view.style.display = data.civ === this.localCiv || this.visible.has(k) ? "" : "none"; + const shown = data.civ === this.localCiv || this.visible.has(k); + if (view._shown !== shown) { + view.style.display = shown ? "" : "none"; + view._shown = shown; + } } + let removed = false; for (const [id, view] of Array.from(this._unitViews)) { if (seen.has(id)) continue; view.remove(); this._unitViews.delete(id); this._unitMotion.delete(id); this._unitData.delete(id); + removed = true; } - this._assignStackOffsets(); + // The stack badges only move when a unit did, so a quiet broadcast leaves + // the layout alone rather than re-walking the whole army. + if (changed.size > 0 || removed) this._assignStackOffsets(); }, // Spreads units sharing a tile around a small circle so every member of a @@ -529,7 +549,7 @@ export const entityMethods = { // pushed to opposite sides of the tile with the battle icon between them. _assignStackOffsets() { for (const view of this._unitViews.values()) { - view.style.visibility = ""; + if (view.style.visibility !== "") view.style.visibility = ""; this._setStackCount(view, 0); } const byTile = new Map(); @@ -561,7 +581,9 @@ export const entityMethods = { // Writes the stack size into the badge on a unit icon, hiding it for 0 or 1. _setStackCount(view, count) { if (!view) return; - const badge = view.querySelector(".unit-count"); + if (view._count === count) return; + view._count = count; + const badge = view._countEl || view.querySelector(".unit-count"); if (!badge) return; if (count > 1) { badge.textContent = String(count); @@ -658,6 +680,14 @@ export const entityMethods = { this.onCityOpened(city.id); }); this.$entities[0].appendChild(view); + // Cache the inner nodes the per-snapshot sync writes to, so a moving army + // is not re-queried with six `querySelector` calls per unit per broadcast. + view._body = body; + view._bodyImg = img; + view._healthFill = fill; + view._flagImg = flag; + view._statusesEl = statuses; + view._countEl = count; return view; }, @@ -665,7 +695,7 @@ export const entityMethods = { // by the snapshot's status ids and the shared catalogue, so a new status // needs only a catalogue entry and an icon file. _syncUnitStatuses(view, data) { - this._syncStatusIcons(view.querySelector(".unit-statuses"), data.statuses || []); + this._syncStatusIcons(view._statusesEl || view.querySelector(".unit-statuses"), data.statuses || []); }, _syncStatusIcons(container, ids) { diff --git a/client/js/map_view/motion.js b/client/js/map_view/motion.js index e38afd8..b0b565b 100644 --- a/client/js/map_view/motion.js +++ b/client/js/map_view/motion.js @@ -101,11 +101,13 @@ export const motionMethods = { this._glRender(); }, - // Places every unit and city at its raw map coordinate; the repeat copies - // (see `_syncWrapCopies`) show it in the neighbouring periods. Only writes a - // coordinate when it actually changed: most of the army is idle on any given - // frame, and rewriting identical left/top values for every unit still forces - // style recalculation, which a high-refresh display pays for every frame. + // Places every unit and city on the copy of the world nearest the camera, so + // a single icon layer is enough however many periods the viewport spans. Only + // writes a coordinate when it actually changed: most of the army is idle on + // any given frame, and rewriting identical left/top values for every unit + // still forces style recalculation, which a high-refresh display pays for + // every frame. A pan changes the folded position only when an entity crosses + // a period boundary, so panning writes almost nothing here. _applyEntityWrap() { for (const [id, motion] of this._unitMotion) { const view = this._unitViews.get(id); @@ -114,24 +116,36 @@ export const motionMethods = { ? this._positionAlongPath(motion) : mapToLocal(motion.coords.x, motion.coords.y); const offset = motion.offset || { x: 0, y: 0 }; + const wrapped = this._wrappedPosition(position); // Sprites stand on the tilted ground, so only the world Y is // foreshortened; the stack offset stays a plain pixel spread. this._setPosition( view, - `${position.x + offset.x}px`, - `${position.y * CAMERA_TILT + offset.y}px` + `${wrapped.x + offset.x}px`, + `${wrapped.y * CAMERA_TILT + offset.y}px` ); } for (const view of this._cityViews.values()) { if (view._localX === undefined) continue; - const left = `${view._localX}px`; + const left = `${view._localX + this._wrapShift(view._localX)}px`; const top = `${view._localY * CAMERA_TILT}px`; this._setPosition(view, left, top); if (view._label) this._setPosition(view._label, left, top); } for (const marker of this._battleViews.values()) { if (marker._localX === undefined) continue; - this._setPosition(marker, `${marker._localX}px`, `${marker._localY * CAMERA_TILT}px`); + this._setPosition(marker, `${marker._localX + this._wrapShift(marker._localX)}px`, + `${marker._localY * CAMERA_TILT}px`); + } + // Construction and tile-improvement markers carry their map position on the + // element so they fold with the units and cities they sit under. + for (const views of [this._siteViews, this._warfareViews]) { + if (!views) continue; + for (const el of views.values()) { + if (el._localX === undefined) continue; + this._setPosition(el, `${el._localX + this._wrapShift(el._localX)}px`, + `${el._localY * CAMERA_TILT}px`); + } } }, diff --git a/client/js/map_view/terrain.js b/client/js/map_view/terrain.js index 38e48ca..23ee4b8 100644 --- a/client/js/map_view/terrain.js +++ b/client/js/map_view/terrain.js @@ -697,18 +697,20 @@ export const terrainMethods = { // ---------------------------------------------------- repeating layers -- // - // A cylindrical world is drawn several times across the viewport: the source - // layers hold one copy at raw map coordinates and each sibling container - // mirrors them at a whole-period offset. Cloning the built nodes (rather than - // folding each one onto the copy nearest the camera) keeps every leg, border - // and marker attached to the copy it belongs to, and repeats tile - // improvements and units in every copy the WebGL ground already draws. + // A cylindrical world is drawn several times across the viewport. The ground + // repeats in the WebGL shader (or in cloned terrain chunks on the DOM + // fallback), and the small decoration layers (highlight, paths, targets, + // resources, country names) are cloned into a whole-period offset. The icon + // layers are NOT cloned: units and cities move every frame, and re-cloning a + // layer of thousands of views to follow them cost more than the whole rest of + // the frame. They are folded onto the copy nearest the camera instead (see + // `_applyEntityWrap`), so each icon is drawn once, over the right ground. _setupWrapCopies() { this._wrapCopies = []; const layers = [ // With WebGL the ground repeats in the shader; the DOM fallback must - // repeat its chunked terrain the same way the icon layers are repeated. + // repeat its chunked terrain in cloned containers too. ...(this.glRenderer ? [] : [ @@ -721,8 +723,6 @@ export const terrainMethods = { { source: this.$paths, interactive: false }, { source: this.$targets, interactive: false }, { source: this.$resources, interactive: false }, - { source: this.$entities, interactive: true }, - { source: this.$labels, interactive: true }, { source: this.$politicalLabels, interactive: false }, ]; for (const { source, interactive } of layers) { @@ -738,19 +738,50 @@ export const terrainMethods = { } this._wrapCopies.push({ source, copies, interactive }); } + // The extra copies are cloned from the source layers, but only when the + // content they mirror actually changed. An observer on the sources lets + // `_syncWrapCopies` skip the clone on a quiet frame; without it a zoomed-out + // cylindrical world re-cloned the whole icon layer (tens of thousands of + // nodes) every frame, which churned gigabytes of DOM against the collector. + const Observer = typeof MutationObserver !== "undefined" + ? MutationObserver + : (typeof window !== "undefined" ? window.MutationObserver : null); + if (Observer) { + if (this._wrapObserver) this._wrapObserver.disconnect(); + this._wrapSourceMap = new Map(); + this._wrapDirty = new Set(); + this._wrapObserver = new Observer(() => {}); + for (const entry of this._wrapCopies) { + const el = entry.source[0]; + this._wrapSourceMap.set(el, entry); + this._wrapObserver.observe(el, { + subtree: true, + childList: true, + attributes: true, + attributeFilter: ["style", "class", "src"], + characterData: true, + }); + } + } else { + this._wrapObserver = null; + } }, // Positions the live layers and mirrors their content into each extra copy. // Runs once per frame; when the viewport fits inside one period (the common // case on a large map) there are no extra copies and the clones are empty. + // A copy is only re-cloned when its source layer changed (tracked by the + // observer set up in `_setupWrapCopies`) or when the period it shows moved. _syncWrapCopies() { if (!this._wrapCopies || this._wrapCopies.length === 0) return; + const dirty = this._drainWrapDirty(); const offsets = this._visibleWrapOffsets(); const primary = offsets[0]; const extra = offsets.slice(1); for (const entry of this._wrapCopies) { const source = entry.source[0]; this._setWrapTransform(source, primary); + const entryDirty = dirty === null || dirty.has(entry); for (let i = 0; i < entry.copies.length; i++) { const copy = entry.copies[i]; const offset = extra[i]; @@ -758,22 +789,62 @@ export const terrainMethods = { if (copy.style.display !== "none") { copy.style.display = "none"; copy.textContent = ""; + copy._wrapBuilt = false; + copy._wrapOffset = undefined; } continue; } copy.style.display = ""; this._setWrapTransform(copy, offset); - this._mirrorLayer(source, copy); + if (entryDirty || !copy._wrapBuilt || copy._wrapOffset !== offset) { + this._mirrorLayer(source, copy); + copy._wrapBuilt = true; + copy._wrapOffset = offset; + } } } + if (this._wrapDirty) this._wrapDirty.clear(); + }, + + // Drains the mutations the observer queued since the last clone and maps each + // record back to the source layer it touched, so only the layers that really + // changed are re-cloned. Returns null when there is no observer, which makes + // the caller fall back to rebuilding every time (the previous behaviour). + _drainWrapDirty() { + if (!this._wrapObserver) return null; + const records = this._wrapObserver.takeRecords(); + if (records.length === 0) return this._wrapDirty; + for (const record of records) { + // The wrap transform we put on the source container itself is our own + // bookkeeping, not a content change, so it must not schedule a re-clone. + if (record.type === "attributes" && record.attributeName === "style" && + this._wrapSourceMap.has(record.target)) { + continue; + } + let node = record.target; + while (node && !this._wrapSourceMap.has(node)) node = node.parentNode; + if (node) { + this._wrapDirty.add(this._wrapSourceMap.get(node)); + } else { + // A record from a node no longer under any source: rebuild them all. + for (const entry of this._wrapCopies) this._wrapDirty.add(entry); + } + } + return this._wrapDirty; }, // The horizontal offset is applied with a transform so panning never costs a // layout pass. Tilted layers keep their ground foreshortening in the same // transform, since an inline transform would otherwise drop the class rule. + // The value is cached on the element: the layer transform changes far less + // often than the frame rate, and writing it re-dirties style on a container + // whose subtree the wrap observer watches. _setWrapTransform(el, offset) { const tilt = el.classList.contains("tilted") ? ` scaleY(${CAMERA_TILT})` : ""; - el.style.transform = `translateX(${offset}px)${tilt}`; + const transform = `translateX(${offset}px)${tilt}`; + if (el._wrapTransform === transform) return; + el._wrapTransform = transform; + el.style.transform = transform; }, _mirrorLayer(source, copy) { diff --git a/client/js/net.js b/client/js/net.js index fb55ad4..d9bda44 100644 --- a/client/js/net.js +++ b/client/js/net.js @@ -154,7 +154,9 @@ 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. + // the explored list (which only grows) is never re-sent in full; likewise + // `unitsUpdated`/`unitsRemoved` carry only the units that changed, so the + // whole army is not re-sent on every broadcast. _mergeGameState(state) { if (!state || !state.delta || !this._gameState) { this._gameState = state; @@ -165,6 +167,14 @@ export class Network { merged.explored = (this._gameState.explored || []).concat(state.exploredAdded); delete merged.exploredAdded; } + if (state.unitsUpdated || state.unitsRemoved) { + const byId = new Map((this._gameState.units || []).map((unit) => [unit.id, unit])); + for (const unit of state.unitsUpdated || []) byId.set(unit.id, unit); + for (const id of state.unitsRemoved || []) byId.delete(id); + merged.units = Array.from(byId.values()); + delete merged.unitsUpdated; + delete merged.unitsRemoved; + } this._gameState = merged; } diff --git a/server/game_server.js b/server/game_server.js index 527e7f5..256ec11 100644 --- a/server/game_server.js +++ b/server/game_server.js @@ -22,6 +22,7 @@ const DELTA_COLLECTIONS = [ ["population", "population"], ["productionBaseline", "productionBaseline"], ["tileEthnicity", "ethnicity"], + ["cities", "cityList"], ["cityStats", "cityStats"], ["visible", "visible"], ["resourceGraph", "resourceGraph"], @@ -61,6 +62,11 @@ export class GameServer extends EventEmitter { 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() { @@ -450,6 +456,7 @@ export class GameServer extends EventEmitter { 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)) { @@ -477,6 +484,7 @@ export class GameServer extends EventEmitter { 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. @@ -576,6 +584,13 @@ export class GameServer extends EventEmitter { 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; @@ -583,7 +598,7 @@ export class GameServer extends EventEmitter { const snapshot = this.state.viewerSnapshot(shared, civ, statsCache.get(civ)); this.network.send(peerId, { t: "game_state", - state: this._deltaState(peerId, snapshot), + state: this._deltaState(peerId, snapshot, unitSignatures), }); } } finally { @@ -593,9 +608,10 @@ export class GameServer extends EventEmitter { // 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 + // 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) { + _deltaState(peerId, snapshot, unitSignatures) { const versions = snapshot.versions; const previous = this._peerSnapshotVersions.get(peerId); // A viewer that changed civilisation sees different per-viewer collections @@ -604,6 +620,7 @@ export class GameServer extends EventEmitter { this._peerSnapshotVersions.set(peerId, versions); this._peerExploredSent.set(peerId, (snapshot.explored || []).length); this._peerCivSent.set(peerId, snapshot.viewer); + this._peerUnitSignatures.set(peerId, unitSignatures); return { ...snapshot, delta: false }; } const state = { ...snapshot, delta: true }; @@ -617,6 +634,22 @@ export class GameServer extends EventEmitter { 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) !== unitSignatures.get(unit.id)) updated.push(unit); + } + const removed = []; + for (const id of sentUnits.keys()) { + if (!unitSignatures.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, unitSignatures); this._peerSnapshotVersions.set(peerId, versions); this._peerCivSent.set(peerId, snapshot.viewer); return state; @@ -629,3 +662,14 @@ function coordsOf(value) { } 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; +} diff --git a/shared/game_state.js b/shared/game_state.js index 060e2e6..cfdc6ba 100644 --- a/shared/game_state.js +++ b/shared/game_state.js @@ -226,6 +226,12 @@ export class GameState { // Per-civ "tiles held by hostile troops", rebuilt the moment a unit is // spawned, moved or killed, or a war begins; read many times a day. this._hostileOccupiedCache = new Map(); + // The memoised battlefield scan (see combat.js `_battleTiles`). The counter + // moves whenever a unit joins or leaves a tile, or changes side, so the + // many reads per snapshot share one walk over the army. + this._battleVersion = 0; + this._battleTilesCache = null; + this._battleIdsCache = null; // Per-(city, mode) trade reachability and the port-access memo. The graph is // expensive to walk (sea lanes run any distance, so a walk floods the ocean), // so it is kept across the day's sourcing passes *and* across days, and only @@ -428,6 +434,10 @@ export class GameState { // which is empty unless a war is on, so a move in peacetime leaves the cached // walks valid. While a war is on, any move may sever a route, so drop it then. _invalidateUnitCaches() { + // A unit joining or leaving a tile can start or end a battle, so the + // memoised battlefield scan goes too. `unit.civ` changes (a capture) bump + // it from the capture helpers instead. + this._battleVersion = (this._battleVersion || 0) + 1; if (this._hostileOccupiedCache) this._hostileOccupiedCache.clear(); if (this._marketAccessCache) this._marketAccessCache.clear(); if (this._tradeGraphCache && this.wars && this.wars.size > 0) { diff --git a/shared/game_state/combat.js b/shared/game_state/combat.js index 992b343..8a7804a 100644 --- a/shared/game_state/combat.js +++ b/shared/game_state/combat.js @@ -112,8 +112,16 @@ export const combatMethods = { }, // Every tile holding military units of two or more hostile civilisations is - // a battlefield: the stacks share the tile and fight there. + // a battlefield: the stacks share the tile and fight there. The scan touches + // every unit, and the snapshot path asks for it many times over (once per + // unit-upkeep read and once per viewer's stats), so the result is memoised + // against `_battleVersion` -- bumped whenever a unit joins or leaves a tile, + // or changes side (see `_invalidateUnitCaches` and the capture helpers). The + // returned arrays and their unit references are read-only to callers. _battleTiles() { + if (this._battleTilesCache && this._battleTilesCache.version === this._battleVersion) { + return this._battleTilesCache.value; + } const byTile = new Map(); for (const unit of this.units) { // Aircraft fly over a battlefield without taking part in the melee. @@ -131,15 +139,22 @@ export const combatMethods = { for (const entry of byTile.values()) { if (entry.civs.size >= 2) battles.push(entry); } + this._battleTilesCache = { version: this._battleVersion, value: battles }; return battles; }, - // The ids of every unit currently trading blows in a battle. + // The ids of every unit currently trading blows in a battle, memoised on the + // same version as `_battleTiles` so the repeated reads across one snapshot + // share one scan. _battleUnitIds() { + if (this._battleIdsCache && this._battleIdsCache.version === this._battleVersion) { + return this._battleIdsCache.value; + } const ids = new Set(); for (const battle of this._battleTiles()) { for (const unit of battle.units) ids.add(unit.id); } + this._battleIdsCache = { version: this._battleVersion, value: ids }; return ids; }, @@ -281,6 +296,9 @@ export const combatMethods = { } } if (changed) { + // Units changed sides, so the battlefield scan and hostile-occupation + // memos are stale. + this._invalidateUnitCaches(); this._visibilityDirty = true; this._emitChanged(); } @@ -289,13 +307,16 @@ export const combatMethods = { // Enemy aircraft caught on a captured city's airfield change allegiance. _captureCityAircraft(city, newCiv) { + let changed = false; for (const unit of this.unitsAt(city.coords)) { const proto = this.unitProto(unit); if (!proto || !proto.air || unit.civ === newCiv) continue; unit.civ = newCiv; unit.homeCityId = city.id; unit.strikeTarget = null; + changed = true; } + if (changed) this._invalidateUnitCaches(); this._visibilityDirty = true; }, diff --git a/shared/game_state/serialization.js b/shared/game_state/serialization.js index 04760b8..b308975 100644 --- a/shared/game_state/serialization.js +++ b/shared/game_state/serialization.js @@ -17,6 +17,7 @@ export const serializationMethods = { // version that lets the server omit it from a delta when nothing moved, so // a city panel is not re-sent on every 10 Hz broadcast. const cityStats = this._serializeCityStats(); + const cities = this._serializeCities(); return { seed: this.seed, // The world-generation config the seed was generated with. The browser @@ -31,7 +32,7 @@ export const serializationMethods = { buildings: this.protoBuildings.map((b) => ({ id: b.id, name: b.name })), governments: this.governments.map((g) => ({ id: g.id, name: g.name })), technologies: this.technologies.map((t) => ({ id: t.id, name: t.name })), - cities: this._serializeCities(), + cities: cities, units: this._serializeUnits(), territory: this._serializedTerritory, // The pre-generated road tiles, as [x, y], and the player-built railway @@ -125,7 +126,8 @@ export const serializationMethods = { ethnicity: this._ethnicityVersion, improvements: this._improvementVersion, warfare: this._tileImprovementVersion, - cityStats: hashCityStats(cityStats), + cityStats: hashSerialized(cityStats), + cityList: hashSerialized(cities), visible: this._visibleVersion, productionBaseline: this._productionBaselineVersion, resourceGraph: this._resourceVersion, @@ -940,12 +942,13 @@ function trainingForViewer(training, cities, viewerCiv) { return filtered; } -// A cheap, stable fingerprint of the serialised city stats. Comparing it lets -// the server treat cityStats like the other versioned collections without -// having to hook every economy-affecting mutation (pillage, battle scars, -// approval drift, research) individually. -function hashCityStats(stats) { - const text = JSON.stringify(stats); +// A cheap, stable fingerprint of a serialised snapshot section. Comparing it +// lets the server treat a collection like the other versioned ones without +// having to hook every mutation that touches it -- for the city stats that is +// pillage, battle scars, approval drift and research; for the city list, a +// capture or a new building level. +function hashSerialized(value) { + const text = JSON.stringify(value); let hash = 2166136261; for (let i = 0; i < text.length; i++) { hash = Math.imul(hash ^ text.charCodeAt(i), 16777619); diff --git a/tests/game_server_test.js b/tests/game_server_test.js index 6c51dd2..9609c41 100644 --- a/tests/game_server_test.js +++ b/tests/game_server_test.js @@ -88,11 +88,14 @@ export class GameServerTest extends TestCase { this.assertNull(delta.population, "unchanged population is left out"); this.assertNull(delta.productionBaseline, "the static growth baseline is left out"); this.assertNull(delta.tileEthnicity, "unchanged ethnicity is left out"); + this.assertNull(delta.cities, "unchanged cities are left out"); this.assertNull(delta.cityStats, "unchanged city stats are left out"); this.assertNull(delta.visible, "unchanged visibility 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"); + this.assertNull(delta.units, "the unchanged army is not re-sent"); + this.assertNull(delta.unitsUpdated, "no unit changed in a quiet broadcast"); + this.assertNull(delta.unitsRemoved, "and none was lost"); // A real territory change must ship the array again. server.state._reindexTerritory(); @@ -112,6 +115,12 @@ export class GameServerTest extends TestCase { const economy = network.sent[network.sent.length - 1].message.state; this.assertNotNull(economy.cityStats, "changed city stats are sent"); + // A city whose own fields moved ships the city list again. + server.state.cities[0].population += 1; + server._broadcastState(); + const cityList = network.sent[network.sent.length - 1].message.state; + this.assertNotNull(cityList.cities, "a changed city list 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()); @@ -126,6 +135,39 @@ export class GameServerTest extends TestCase { this.assertGreaterOrEqual(grown.exploredAdded.length, 1, "the tail holds the new tile"); } + test_only_changed_units_ship_in_a_delta() { + 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.assertNotNull(full.units, "the first snapshot carries the whole army"); + + // A quiet broadcast ships no units at all. + server._broadcastState(); + const quiet = network.sent[network.sent.length - 1].message.state; + this.assertNull(quiet.units, "the army is left out"); + this.assertNull(quiet.unitsUpdated, "and nothing changed"); + + // Damaging one unit ships only that unit. + const unit = server.state.units[0]; + unit.hp -= 1; + server._broadcastState(); + const changed = network.sent[network.sent.length - 1].message.state; + this.assertNull(changed.units, "the whole army is not re-sent"); + this.assertSize(changed.unitsUpdated, 1, "only the damaged unit is"); + this.assertEqual(changed.unitsUpdated[0].id, unit.id, "and it is the right one"); + + // Losing a unit ships its removal. + server.state._destroyUnit(unit); + server._broadcastState(); + const lost = network.sent[network.sent.length - 1].message.state; + this.assertNull(lost.units, "the whole army is not re-sent"); + this.assertSize(lost.unitsRemoved, 1, "the lost unit is reported"); + this.assertEqual(lost.unitsRemoved[0], unit.id, "by id"); + } + test_city_economy_memo_is_scoped_to_one_broadcast() { const { server } = makeServer(); const state = server.state; diff --git a/tests/map_modes_test.js b/tests/map_modes_test.js index 3aa578f..9635db7 100644 --- a/tests/map_modes_test.js +++ b/tests/map_modes_test.js @@ -369,33 +369,27 @@ export class MapModesTest extends TestCase { } } - async test_tile_improvements_repeat_with_the_world() { + async test_tile_improvements_fold_with_the_world() { const state = smallState(); const env = await setupDom(); const map = this.buildMap(env); try { map.applySnapshot(state.snapshot(0)); map.setWarfare([], [[0, 0, "mine", 0, 100]], 1); - // On the single copy the marker sits at its raw map position. + // The marker sits at its raw map position while the camera sits over it. const marker = env.$("#layer-entities .tile-improvement-marker").eq(0); this.assertSize(marker, 1, "the improvement is drawn once"); this.assertApprox(parseFloat(marker[0].style.left), mapToLocal(0, 0).x, 1e-6, "at its raw position, not folded to the camera"); - // Zoom out until the viewport spans more than one period: the marker must - // be repeated in each copy, like the terrain the WebGL ground repeats. + // Pan a whole period east: the marker folds onto the copy nearest the + // camera instead of a clone repeating it in every visible period. map.camera.zoom = 0.5; - map.camera.x = 0; + map.camera.x = map._period; map.camera.y = 0; map.animate(0); - this.assertGreater(map._visibleWrapOffsets().length, 1, "several copies on screen"); - const entry = map._wrapCopies.find((e) => e.source.is("#layer-entities")); - const liveCopy = entry.copies.find((el) => el.style.display !== "none"); - this.assertNotNull(liveCopy, "an extra entity copy is drawn"); - this.assertGreater( - liveCopy.querySelectorAll(".tile-improvement-marker").length, 0, - "the tile improvement repeats in the copy" - ); + this.assertApprox(parseFloat(marker[0].style.left), mapToLocal(0, 0).x + map._period, 1e-6, + "the improvement is folded a whole period nearer the camera"); } finally { teardownDom(env); } diff --git a/tests/map_view_geometry_test.js b/tests/map_view_geometry_test.js index 7734535..22b2c57 100644 --- a/tests/map_view_geometry_test.js +++ b/tests/map_view_geometry_test.js @@ -224,6 +224,58 @@ export class MapViewGeometryTest extends MapViewFixture { } } + async test_icon_layers_fold_and_decoration_layers_mirror() { + const state = smallState(); + const { env, map } = await this.build(state); + try { + // Far enough out that more than one map period fits the viewport. + map.camera.zoom = 0.25; + + // The icon layers are folded onto the nearest copy instead of being + // cloned: re-cloning thousands of moving views every frame was the worst + // cost in the frame. + for (const id of ["layer-entities", "layer-labels"]) { + const mirrored = map._wrapCopies.find((e) => e.source[0].id === id); + this.assertFalse(!!mirrored, `${id} is folded, not mirrored`); + } + + const [id, motion] = map._unitMotion.entries().next().value; + const view = map._unitViews.get(id); + this.assertNotNull(view, "a unit view exists"); + const raw = mapToLocal(motion.coords.x, motion.coords.y); + // Park the camera exactly one period east of the unit. + map.camera.x = raw.x + map._period; + map._applyEntityWrap(); + const offset = motion.offset || { x: 0, y: 0 }; + this.assertApprox(parseFloat(view.style.left), raw.x + map._period + offset.x, 1e-6, + "the unit is folded a whole period nearer the camera"); + + // The decoration layers are still mirrored, and only re-clone when their + // source actually changes. + map.$paths[0].appendChild(env.window.document.createElement("div")); + map._syncWrapCopies(); + const entry = map._wrapCopies.find((e) => e.source.is("#layer-paths")); + this.assertNotNull(entry, "the paths layer is mirrored"); + const copy = entry.copies.find((c) => c.style.display !== "none"); + this.assertNotNull(copy, "a paths copy is shown once several periods fit"); + this.assertSize(copy.children, 1, "the copy mirrors the paths layer"); + + let calls = 0; + const original = map._mirrorLayer.bind(map); + map._mirrorLayer = (source, target) => { + calls += 1; + return original(source, target); + }; + map._syncWrapCopies(); + this.assertEqual(calls, 0, "an unchanged frame does not re-clone"); + map.$paths[0].firstChild.style.left = "123px"; + map._syncWrapCopies(); + this.assertGreater(calls, 0, "a changed layer is re-cloned"); + } finally { + teardownDom(env); + } + } + async test_border_offset_points_towards_the_owner() { const a = { x: 0, y: 0 }; const b = { x: 0, y: 10 }; diff --git a/tests/net_test.js b/tests/net_test.js index f1cca29..da1c752 100644 --- a/tests/net_test.js +++ b/tests/net_test.js @@ -132,14 +132,24 @@ export class NetTest extends TestCase { // 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]] }, + state: { delta: true, unitsUpdated: [{ 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.units, 1, "the changed unit is merged in"); 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 later delta appends new units and drops removed ones. + ws.simulateMessage({ + t: "game_state", + state: { delta: true, unitsUpdated: [{ id: 2 }], unitsRemoved: [1] }, + }); + this.assertSize(state.units, 1, "the removed unit is gone"); + this.assertEqual(state.units[0].id, 2, "and the new one remains"); + this.assertNull(state.unitsUpdated, "the temporary update list is dropped"); + this.assertNull(state.unitsRemoved, "and so is the removal list"); + // A full state replaces the base. ws.simulateMessage({ t: "game_state", diff --git a/tests/server_test.js b/tests/server_test.js index aef2357..833f936 100644 --- a/tests/server_test.js +++ b/tests/server_test.js @@ -193,7 +193,10 @@ export class ServerProtocolTest extends TestCase { client.send({ t: "order", order: { type: "move", unit: unit.id, coords: [goal.x, goal.y] } }); const snapshot = await client.next("game_state"); - const data = snapshot.state.units.find((u) => u.id === unit.id); + // The broadcast is a delta now, so the changed unit rides in `unitsUpdated` + // (or in the full `units` list on a peer's first snapshot). + const units = snapshot.state.unitsUpdated || snapshot.state.units || []; + const data = units.find((u) => u.id === unit.id); this.assertNotNull(data); this.assertEqual(data.path.length >= 2 || data.pathIndex > 0, true, "the unit has a route"); this.assertEqual(data.coords.length, 2);