From b2ab4d76ebe1adc4195c54effec7fd35beb7ece3 Mon Sep 17 00:00:00 2001 From: Adrien Jaguenet Date: Mon, 21 Sep 2026 02:23:04 +0200 Subject: [PATCH] Sped up large battles and trimmed snapshot traffic - The bombardment overlay was rebuilt every animation frame, and each barrage scanned the whole army to find its target (O(strikes x units)). It now redraws at most at the renderer's 24 fps, reuses its SVG/DOM nodes, and resolves garrisons from one per-snapshot tile index, so a high-refresh display no longer makes a big battle worse. - Route truncation had the same shape: every step of every route rescanned every unit. The snapshot now buckets the army by tile once. - Idle units no longer have identical left/top rewritten every frame, and the whole-map fog buffer is rebuilt less often. - Snapshot traffic: cityStats and visible are delta-encoded like territory and population, city stats carry a content-hash version, tile ethnicity is memoised, and a city's economy is derived once per broadcast instead of twice. A per-peer civ change now forces a full snapshot, since the visible set is per-viewer. --- client/js/map_view.js | 6 + client/js/map_view/constants.js | 12 +- client/js/map_view/entities.js | 6 +- client/js/map_view/motion.js | 211 ++++++++++++++++++++++------- server/game_server.js | 57 +++++--- shared/game_state.js | 21 +++ shared/game_state/economy.js | 24 +++- shared/game_state/serialization.js | 39 +++++- shared/game_state/visibility.js | 3 + tests/game_server_test.js | 50 +++++++ tests/map_view_test.js | 67 +++++++++ 11 files changed, 419 insertions(+), 77 deletions(-) diff --git a/client/js/map_view.js b/client/js/map_view.js index 9a93831..e69075a 100644 --- a/client/js/map_view.js +++ b/client/js/map_view.js @@ -152,6 +152,12 @@ export class MapView { this._cityViews = new Map(); this._cityCiv = new Map(); this._battleViews = new Map(); + // Reused nodes for the bombardment overlay, plus the throttle state that + // keeps it from being rebuilt every animation frame (see `_drawStrikeTargets`). + this._targetViews = new Map(); + this._targetSvg = null; + this._strikeTick = null; + this._strikeSignature = ""; // In-flight pathfinding job, cancelled when a newer request supersedes it. this._pathToken = null; this._maxStepLength = 1.0; diff --git a/client/js/map_view/constants.js b/client/js/map_view/constants.js index f798f2b..88bf6cf 100644 --- a/client/js/map_view/constants.js +++ b/client/js/map_view/constants.js @@ -7,8 +7,16 @@ 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; +// flushed from the frame loop at most this often. Each rebuild walks the entire +// map, so the interval is kept well above the render's 24 fps but short enough +// that revealing a newly explored tile is not noticeable. +export const FOG_REBUILD_MS = 300; +// Bombardment arcs, travelling shells and target rings are DOM/SVG overlays, not +// GPU geometry, so they are not covered by the WebGL renderer's own 24 fps +// throttle. Rebuilding them every animation frame scales with the army (and +// runs 2x-3x faster on a high-refresh display), so the overlay is redrawn at +// most this often and immediately whenever its contents change. +export const TARGET_REFRESH_MS = 1000 / 24; 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 86bea84..ddf5985 100644 --- a/client/js/map_view/entities.js +++ b/client/js/map_view/entities.js @@ -403,6 +403,10 @@ export const entityMethods = { seen.add(data.id); this._unitData.set(data.id, 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); for (const data of units) { const id = data.id; let view = this._unitViews.get(id); @@ -427,7 +431,7 @@ export const entityMethods = { `${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); + this._updateUnitMotion(data, occupancy); // Foreign units are only shown inside currently visible tiles. const k = key(data.coords[0], data.coords[1]); view.style.display = data.civ === this.localCiv || this.visible.has(k) ? "" : "none"; diff --git a/client/js/map_view/motion.js b/client/js/map_view/motion.js index 2415481..541f283 100644 --- a/client/js/map_view/motion.js +++ b/client/js/map_view/motion.js @@ -5,7 +5,12 @@ import { mapToLocal, key } from "../../../shared/hex.js"; import { HexPathfinder } from "../../../shared/hex_pathfinder.js"; import { TRANSPORT_BY_ID } from "../../../shared/data/improvements.js"; import { CONTROLLED_SPEED_MULTIPLIER } from "../../../shared/game_state/constants.js"; -import { CAMERA_TILT, RESYNC_HOURS, PREDICTION_GRACE_HOURS } from "./constants.js"; +import { + CAMERA_TILT, + RESYNC_HOURS, + PREDICTION_GRACE_HOURS, + TARGET_REFRESH_MS, +} from "./constants.js"; import { hoursAlong, samePath, pathProgress, makeSegment } from "./utils.js"; // A pathfinding slice runs for this long before handing the thread back, so @@ -18,13 +23,17 @@ export const motionMethods = { // A living hostile civilian standing on the path blocks dead reckoning: the // animation must stop before it. An enemy military unit is the fight itself // and combat is fought on a single tile, so the mover charges onto its tile - // and stops there. Friendly units stack and never block. - _truncateAtOccupant(path, segmentHours, data) { + // and stops there. Friendly units stack and never block. `occupancy` is the + // snapshot's per-tile unit buckets (see `_syncUnits`), so the check does not + // rescan the army for every step of every route. + _truncateAtOccupant(path, segmentHours, data, occupancy = null) { const proto = this.protoUnits[data.proto]; for (let i = 1; i < path.length; i++) { - const occupant = this._unitsAtCoords(path[i], data.id).find( - (other) => other.civ !== data.civ - ); + const occupant = occupancy + ? (occupancy.get(key(path[i].x, path[i].y)) || []).find( + (other) => other.id !== data.id && other.civ !== data.civ + ) + : this._unitsAtCoords(path[i], data.id).find((other) => other.civ !== data.civ); if (!occupant) continue; if (proto && proto.military && this._isMilitaryData(occupant)) { return { path: path.slice(0, i + 1), segmentHours: segmentHours.slice(0, i) }; @@ -37,11 +46,11 @@ export const motionMethods = { return { path, segmentHours }; }, - _updateUnitMotion(data) { + _updateUnitMotion(data, occupancy = null) { const id = data.id; let path = (data.path || []).map((p) => ({ x: p[0], y: p[1] })); let segmentHours = data.segmentHours || []; - const clipped = this._truncateAtOccupant(path, segmentHours, data); + const clipped = this._truncateAtOccupant(path, segmentHours, data, occupancy); path = clipped.path; segmentHours = clipped.segmentHours; const existing = this._unitMotion.get(id); @@ -91,6 +100,10 @@ export const motionMethods = { }, // Repositions every unit and city at the wrapped copy nearest the camera. + // 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. _applyEntityWrap() { for (const [id, motion] of this._unitMotion) { const view = this._unitViews.get(id); @@ -104,23 +117,40 @@ export const motionMethods = { const offset = motion.offset || { x: 0, y: 0 }; // Sprites stand on the tilted ground, so only the world Y is // foreshortened; the stack offset stays a plain pixel spread. - view.style.left = `${wrapped.x + offset.x}px`; - view.style.top = `${wrapped.y * CAMERA_TILT + offset.y}px`; + this._setPosition( + view, + `${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 + this._wrapShift(view._localX); - view.style.left = `${left}px`; - view.style.top = `${view._localY * CAMERA_TILT}px`; - if (view._label) { - view._label.style.left = `${left}px`; - view._label.style.top = `${view._localY * CAMERA_TILT}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; - marker.style.left = `${marker._localX + this._wrapShift(marker._localX)}px`; - marker.style.top = `${marker._localY * CAMERA_TILT}px`; + this._setPosition( + marker, + `${marker._localX + this._wrapShift(marker._localX)}px`, + `${marker._localY * CAMERA_TILT}px` + ); + } + }, + + // Writes a screen position to an element only when it moved. The previous + // value is kept on the element itself, so no extra per-entity bookkeeping is + // needed when units and cities come and go. + _setPosition(el, left, top) { + if (el._posLeft !== left) { + el._posLeft = left; + el.style.left = left; + } + if (el._posTop !== top) { + el._posTop = top; + el.style.top = top; } }, @@ -204,25 +234,54 @@ export const motionMethods = { // glowing shell travels the curve so the direction is unmistakable. They stay // visible so an ongoing barrage cannot be forgotten, and sit on a layer above // the terrain but below the unit and city icons. - _drawStrikeTargets() { + // + // The overlay is rebuilt at most every TARGET_REFRESH_MS and immediately when + // its contents change; the per-strike target lookup goes through one index of + // the units, so the frame cost is O(units + strikes) instead of O(strikes x + // units). The nodes are reused, so an unchanged number of barrages allocates + // nothing. + _drawStrikeTargets(force = false) { if (!this.$targets || !this.$targets.length) return; - const fragment = document.createDocumentFragment(); - const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg"); - svg.setAttribute("class", "target-arcs"); - const now = Date.now(); - let arcs = 0; + const now = typeof performance !== "undefined" ? performance.now() : Date.now(); + const tick = Math.floor(now / TARGET_REFRESH_MS); + // Collect the standing barrages and a signature that changes the moment a + // barrage appears, moves or is selected, so those edits never wait for the + // next tick. + const strikes = []; + let signature = ""; for (const [id, data] of this._unitData) { if (!data.strikeTarget) continue; + strikes.push(id); + signature += `${id}:${data.strikeTarget[0]},${data.strikeTarget[1]};`; + } + signature += `|${[...this._selectedUnitIds].join(",")}`; + if (!force && tick === this._strikeTick && signature === this._strikeSignature) return; + this._strikeTick = tick; + this._strikeSignature = signature; + + const views = this._targetViews; + if (strikes.length === 0) { + this._clearStrikeTargets(); + return; + } + + // One pass over the units resolves the garrison inside every target tile, + // replacing the per-strike scan of the whole army. + const targetUnits = this._targetUnitIndex(); + const ns = "http://www.w3.org/2000/svg"; + const seen = new Set(); + for (const id of strikes) { + const data = this._unitData.get(id); + if (!data) continue; const target = { x: data.strikeTarget[0], y: data.strikeTarget[1] }; - const targetIds = this.unitIdsAt(target); + const targetId = targetUnits.get(key(target.x, target.y)); // The selected attacker or target stands out from the standing barrages // the player is not looking at. const selected = this._selectedUnitIds.has(id) || - targetIds.some((targetId) => this._selectedUnitIds.has(targetId)); + (targetId !== undefined && this._selectedUnitIds.has(targetId)); const fromPoint = this._unitRenderPosition(id); if (!fromPoint) continue; - const targetId = targetIds[0]; - const toPoint = targetId + const toPoint = targetId !== undefined ? this._unitRenderPosition(targetId) : mapToLocal(target.x, target.y); if (!toPoint) continue; @@ -235,34 +294,84 @@ export const motionMethods = { const arc = Math.max(18, distance * 0.4) / CAMERA_TILT; const cx = (a.x + b.x) / 2; const cy = (a.y + b.y) / 2 - arc; - const path = document.createElementNS("http://www.w3.org/2000/svg", "path"); - path.setAttribute("d", `M ${a.x} ${a.y} Q ${cx} ${cy} ${b.x} ${b.y}`); - path.setAttribute("class", selected ? "target-line target-selected" : "target-line"); + let view = views.get(id); + if (!view) { + const path = document.createElementNS(ns, "path"); + const shell = document.createElementNS(ns, "circle"); + shell.setAttribute("class", "target-shell"); + shell.setAttribute("r", "3.5"); + const ring = document.createElement("div"); + const svg = this._ensureTargetSvg(); + svg.appendChild(path); + svg.appendChild(shell); + this.$targets[0].appendChild(ring); + view = { path, shell, ring }; + views.set(id, view); + } + view.path.setAttribute("d", `M ${a.x} ${a.y} Q ${cx} ${cy} ${b.x} ${b.y}`); + view.path.setAttribute("class", selected ? "target-line target-selected" : "target-line"); // Marching dashes flow from the battery towards the target. - path.style.strokeDashoffset = `${-((now / 22) % 18)}px`; - svg.appendChild(path); + view.path.style.strokeDashoffset = `${-((now / 22) % 18)}px`; // A glowing shell rides the curve to show exactly where it is going. const t = (now / 1300) % 1; const mt = 1 - t; - const shell = document.createElementNS("http://www.w3.org/2000/svg", "circle"); - shell.setAttribute("class", "target-shell"); - shell.setAttribute("cx", `${mt * mt * a.x + 2 * mt * t * cx + t * t * b.x}`); - shell.setAttribute("cy", `${mt * mt * a.y + 2 * mt * t * cy + t * t * b.y}`); - shell.setAttribute("r", "3.5"); - svg.appendChild(shell); - arcs += 1; - const ring = document.createElement("div"); - ring.className = selected ? "target-ring target-selected" : "target-ring"; - ring.style.left = `${b.x}px`; - ring.style.top = `${b.y}px`; - fragment.appendChild(ring); + view.shell.setAttribute("cx", `${mt * mt * a.x + 2 * mt * t * cx + t * t * b.x}`); + view.shell.setAttribute("cy", `${mt * mt * a.y + 2 * mt * t * cy + t * t * b.y}`); + view.ring.className = selected ? "target-ring target-selected" : "target-ring"; + view.ring.style.left = `${b.x}px`; + view.ring.style.top = `${b.y}px`; + seen.add(id); } - if (arcs > 0) fragment.insertBefore(svg, fragment.firstChild); - if (!fragment.childNodes.length) { - if (this.$targets[0].firstChild) this.$targets.empty(); - return; + for (const [id, view] of Array.from(views)) { + if (seen.has(id)) continue; + view.path.remove(); + view.shell.remove(); + view.ring.remove(); + views.delete(id); } - this.$targets.empty()[0].appendChild(fragment); + }, + + // The holding every arc. Created lazily and only while there is at least + // one barrage to draw, so an empty map keeps the layer clean. + _ensureTargetSvg() { + if (this._targetSvg && this._targetSvg.parentNode) return this._targetSvg; + const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg"); + svg.setAttribute("class", "target-arcs"); + this.$targets[0].appendChild(svg); + this._targetSvg = svg; + return svg; + }, + + _clearStrikeTargets() { + for (const view of this._targetViews.values()) { + view.path.remove(); + view.shell.remove(); + view.ring.remove(); + } + this._targetViews.clear(); + if (this._targetSvg) { + this._targetSvg.remove(); + this._targetSvg = null; + } + if (this.$targets[0].firstChild) this.$targets.empty(); + }, + + // The first visible unit standing on each tile, built in one pass. Mirrors + // `_buildOccupancy`, but also skips units hidden by the fog, which is what + // the old per-strike `unitIdsAt` scan did. + _targetUnitIndex() { + const index = new Map(); + for (const [id, data] of this._unitData) { + const view = this._unitViews.get(id); + if (view && view.style.display === "none") continue; + const motion = this._unitMotion.get(id); + const tile = motion && motion.path.length >= 2 + ? motion.path[Math.min(pathProgress(motion).index, motion.path.length - 1)] + : { x: data.coords[0], y: data.coords[1] }; + const k = key(tile.x, tile.y); + if (!index.has(k)) index.set(k, id); + } + return index; }, // -------------------------------------------------------- move preview -- diff --git a/server/game_server.js b/server/game_server.js index e0b6daf..3d8f53f 100644 --- a/server/game_server.js +++ b/server/game_server.js @@ -9,10 +9,18 @@ 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"]; +// 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", + "tileEthnicity", + "cityStats", + "visible", +]; // A wire move order carrying more units than this is split into chunks that // each run in their own tick. One army move can otherwise run dozens of A* @@ -38,10 +46,12 @@ export class GameServer extends EventEmitter { // 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 and how many explored - // tiles the viewer already has. + // 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() { @@ -390,6 +400,7 @@ export class GameServer extends EventEmitter { // 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)) { @@ -415,6 +426,7 @@ export class GameServer extends EventEmitter { } this._peerSnapshotVersions.delete(peerId); this._peerExploredSent.delete(peerId); + this._peerCivSent.delete(peerId); } // Indices of the civilisations no connected peer has claimed. @@ -509,16 +521,23 @@ export class GameServer extends EventEmitter { // listener recorded before it is already included. Clearing first means a // change raised while serialising still marks the next snapshot dirty. this._stateDirty = false; - 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), - }); + // 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(); } } @@ -529,9 +548,12 @@ export class GameServer extends EventEmitter { _deltaState(peerId, snapshot) { const versions = snapshot.versions; const previous = this._peerSnapshotVersions.get(peerId); - if (!previous) { + // 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 }; @@ -546,6 +568,7 @@ export class GameServer extends EventEmitter { } delete state.explored; this._peerSnapshotVersions.set(peerId, versions); + this._peerCivSent.set(peerId, snapshot.viewer); return state; } } diff --git a/shared/game_state.js b/shared/game_state.js index 2ad1b33..ff0ea61 100644 --- a/shared/game_state.js +++ b/shared/game_state.js @@ -176,6 +176,7 @@ export class GameState { this._regionVersion = 0; this._populationVersion = 0; this._ethnicityVersion = 0; + this._visibleVersion = 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 @@ -198,12 +199,18 @@ export class GameState { // Memoised nation-wide modifier totals, invalidated by _touchModifiers. this._civModifierCache = new Map(); this._modifiersVersion = 0; + // Per-broadcast memo of `getCityEconomy`, active only while the server is + // assembling one snapshot (see beginSnapshotCache). Kept separate from the + // simulation paths so an hourly tick can never read a stale figure. + this._cityEconomyCache = new Map(); + this._cityEconomyCacheActive = false; // Per-civ factors that map generation picked so every nation starts with // the same population and GDP per capita (see _balanceStartingEconomy). this._populationBalance = new Map(); this._perCapitaBalance = new Map(); this._serializedTerritory = []; this._serializedExplored = new Map(); + this._serializedTileEthnicityCache = null; this._neighbourCache = new Map(); this._visibilityDirty = true; // Month-over-month baseline for the nation summary bar; null until the @@ -221,6 +228,20 @@ export class GameState { for (const fn of this._listeners) fn(); } + // Scopes the per-broadcast city-economy memo. The server brackets one + // `serializeShared` plus every viewer's stats in these calls; outside them + // `getCityEconomy` always derives a fresh figure, so an hourly simulation + // tick can never read a value cached for a snapshot. + beginSnapshotCache() { + this._cityEconomyCache.clear(); + this._cityEconomyCacheActive = true; + } + + endSnapshotCache() { + this._cityEconomyCacheActive = false; + this._cityEconomyCache.clear(); + } + isConfigured() { return this._configured; } diff --git a/shared/game_state/economy.js b/shared/game_state/economy.js index 6165655..037f9db 100644 --- a/shared/game_state/economy.js +++ b/shared/game_state/economy.js @@ -181,6 +181,13 @@ export const economyMethods = { // collected by the region alone: a city's income modifiers never apply to // another city's land, only government and research reach every region. getCityEconomy(city) { + // While a snapshot is being assembled, every caller sees one figure: the + // city stats and the viewer's budget breakdown both need it for the same + // city, and the region walk is the expensive part. + if (this._cityEconomyCacheActive) { + const cached = this._cityEconomyCache.get(city.id); + if (cached) return cached; + } let population = 0; let gdp = 0; for (const coords of this.regionTiles(city)) { @@ -205,7 +212,16 @@ export const economyMethods = { // A region whose people have turned against the government barely pays its // taxes, so approval scales the collected income after every other bonus. const approval = this.taxApprovalMultiplier(city); - return { population, gdp, base, modifier, approval, income: base * (1 + modifier) * approval }; + const result = { + population, + gdp, + base, + modifier, + approval, + income: base * (1 + modifier) * approval, + }; + if (this._cityEconomyCacheActive) this._cityEconomyCache.set(city.id, result); + return result; }, // The tax the whole nation collects in one hour, summed over its regions. @@ -281,8 +297,10 @@ export const economyMethods = { // government and technology sources, and the upkeep of the city's own // buildings. National unit upkeep is not attributed to a region, so it is // left out and the net is the region's own income minus its buildings. - cityBudgetBreakdown(city) { - const economy = this.getCityEconomy(city); + // `economy`, when supplied, is the already-computed `getCityEconomy` result, + // so a caller that needs both does not derive the region twice. + cityBudgetBreakdown(city, economy = null) { + if (!economy) economy = this.getCityEconomy(city); const rate = ECONOMY.budgetGdpRate; const region = { cityId: city.id, diff --git a/shared/game_state/serialization.js b/shared/game_state/serialization.js index 5763187..72005f1 100644 --- a/shared/game_state/serialization.js +++ b/shared/game_state/serialization.js @@ -12,6 +12,10 @@ export const serializationMethods = { }, serializeShared() { + // Built once and shared by every viewer. Its content hash doubles as the + // 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(); return { seed: this.seed, // The world-generation config the seed was generated with. The browser @@ -48,7 +52,7 @@ export const serializationMethods = { tileGdpPenalties: this._serializeTileValues(this.tileGdpPenalty), tileBattleGdpDeficits: this._serializeTileValues(this.tileBattleGdpDeficit), civStats: this._serializeCivStats(), - cityStats: this._serializeCityStats(), + cityStats: cityStats, budgets: this._serializeCivValues(this.budgets), culture: this._serializeCivValues(this.culture), // Each nation's share of the world's cultural output (zero-sum) and the @@ -102,6 +106,8 @@ export const serializationMethods = { ethnicity: this._ethnicityVersion, improvements: this._improvementVersion, warfare: this._tileImprovementVersion, + cityStats: hashCityStats(cityStats), + visible: this._visibleVersion, }, }; }, @@ -319,7 +325,7 @@ export const serializationMethods = { approval: economy.approval, income: economy.income, // The region's own income and building upkeep, for the city budget tab. - budget: this.cityBudgetBreakdown(city), + budget: this.cityBudgetBreakdown(city, economy), // The ethnicity of the region's people, for the circle graph. ethnicMakeup: this.getCityEthnicMakeup(city), }; @@ -509,8 +515,17 @@ export const serializationMethods = { }, // Sparse [x, y, [[ethnicity, share, trend], ...]] for every populated tile, - // where trend is the net migration over the last day. + // where trend is the net migration over the last day. Memoised against the + // ethnicity version and the current migration delta store: the shares only + // move when politics does, so re-walking every populated tile on every 10 Hz + // broadcast was pure waste (the delta wire format drops it anyway). _serializeTileEthnicity() { + const cache = this._serializedTileEthnicityCache; + if (cache && + cache.version === this._ethnicityVersion && + cache.deltas === this._migrationDeltas.tile) { + return cache.value; + } const result = []; for (const [k, shares] of this.tileEthnicity) { const coords = parseKey(k); @@ -526,6 +541,11 @@ export const serializationMethods = { if (entries.length === 0) continue; result.push([coords.x, coords.y, entries]); } + this._serializedTileEthnicityCache = { + version: this._ethnicityVersion, + deltas: this._migrationDeltas.tile, + value: result, + }; return result; }, @@ -606,6 +626,19 @@ function roundTo(value, digits) { return Math.round(value * factor) / factor; } +// 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); + let hash = 2166136261; + for (let i = 0; i < text.length; i++) { + hash = Math.imul(hash ^ text.charCodeAt(i), 16777619); + } + return `${text.length}:${(hash >>> 0).toString(36)}`; +} + // A Map as sorted [ethnicity, roundedNumber] pairs, dropping // zero entries. function mapToEntries(map) { diff --git a/shared/game_state/visibility.js b/shared/game_state/visibility.js index 20f2e17..814ed79 100644 --- a/shared/game_state/visibility.js +++ b/shared/game_state/visibility.js @@ -42,6 +42,9 @@ export const visibilityMethods = { if (owner === undefined || owner < 0) continue; this._reveal(owner, parseKey(k), Math.max(UNIT_VISION, proto.vision)); } + // The visible sets only move here, so this counter lets a delta snapshot + // omit the (large) per-viewer visible list until the next refresh. + this._visibleVersion += 1; }, _revealAll() { diff --git a/tests/game_server_test.js b/tests/game_server_test.js index d944404..55d492b 100644 --- a/tests/game_server_test.js +++ b/tests/game_server_test.js @@ -2,6 +2,7 @@ import { TestCase } from "./framework/test_case.js"; import { GameServer } from "../server/game_server.js"; import { CIVILISATIONS } from "../shared/data.js"; import { adjacentLand, SEED } from "./framework/helpers.js"; +import { key } from "../shared/hex.js"; function fakeNetwork() { return { @@ -70,6 +71,8 @@ export class GameServerTest extends TestCase { 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.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"); @@ -80,6 +83,18 @@ export class GameServerTest extends TestCase { const after = network.sent[network.sent.length - 1].message.state; this.assertNotNull(after.territory, "changed territory is sent"); + // A visibility refresh ships the visible set again. + server.state._refreshVisibility(); + server._broadcastState(); + const vision = network.sent[network.sent.length - 1].message.state; + this.assertNotNull(vision.visible, "changed visibility is sent"); + + // An economy tick ships the city stats again. + server.state._tickPopulation(); + server._broadcastState(); + const economy = network.sent[network.sent.length - 1].message.state; + this.assertNotNull(economy.cityStats, "changed city stats are 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()); @@ -94,6 +109,41 @@ export class GameServerTest extends TestCase { this.assertGreaterOrEqual(grown.exploredAdded.length, 1, "the tail holds the new tile"); } + test_city_economy_memo_is_scoped_to_one_broadcast() { + const { server } = makeServer(); + const state = server.state; + const city = state.cities[0]; + + state.beginSnapshotCache(); + const within = state.getCityEconomy(city); + // While the snapshot is being built the same figure is handed back... + this.assert(state.getCityEconomy(city) === within, "the memo is reused in scope"); + state.tilePopulation.set(key(city.coords.x, city.coords.y), 999999); + this.assert(state.getCityEconomy(city) === within, "and is stable even as data shifts"); + state.endSnapshotCache(); + + // ...but a simulation read afterwards always derives a fresh figure. + this.assertNotEqual(state.getCityEconomy(city), within, "ticks never read a stale figure"); + } + + test_a_civ_change_forces_a_full_snapshot() { + const { server, network } = makeServer(); + network.peers.add(1); + server.peerCiv.set(1, 0); + server._broadcastState(); + server._broadcastState(); + const delta = network.sent[network.sent.length - 1].message.state; + this.assertTrue(delta.delta, "steady state is a delta"); + this.assertNull(delta.visible, "the visible set is omitted while unchanged"); + + // The visible set is per-viewer, so switching nation must resend it. + server.peerCiv.set(1, 1); + server._broadcastState(); + const full = network.sent[network.sent.length - 1].message.state; + this.assertFalse(full.delta, "a civ change resends the whole state"); + this.assertNotNull(full.visible, "including the per-viewer visible set"); + } + test_move_order_for_own_unit_is_accepted() { const { server } = makeServer(); server.peerCiv.set(1, 0); diff --git a/tests/map_view_test.js b/tests/map_view_test.js index 77171ca..7e996c9 100644 --- a/tests/map_view_test.js +++ b/tests/map_view_test.js @@ -886,4 +886,71 @@ export class MapViewTest extends TestCase { teardownDom(env); } } + + async test_snapshot_truncation_uses_one_tile_index() { + const state = smallState(); + const unit = state.units.find((u) => u.civ === 0); + state.requestMove(unit.id, adjacentLand(state, unit)); + const { env, map } = await this.build(state); + try { + let scans = 0; + map._unitsAtCoords = () => { + scans += 1; + return []; + }; + map.applySnapshot(state.snapshot(0)); + this.assertEqual(scans, 0, "route truncation uses the snapshot's tile index"); + } finally { + teardownDom(env); + } + } + + async test_strike_overlay_never_rescans_the_army_per_strike() { + const state = smallState(); + const artilleryProto = state.protoUnits.find((p) => p.id === "artillery"); + for (let i = 0; i < 6; i++) { + const spot = state.landCells.find( + (c) => + !state.unitAt(c) && + !state.cityAt(c) && + state.topology.neighbours(c.x, c.y).some((n) => state._isLand(n)) + ); + const battery = state._spawnUnit(spot, 0, artilleryProto); + const goal = state.topology + .neighbours(spot.x, spot.y) + .find((n) => state._isLand(n) && !state.unitAt(n)); + state._spawnUnit(goal, 1, state.protoUnits[0]); + this.assertTrue(state.requestBombard([battery.id], goal), "the battery opens fire"); + } + const { env, map } = await this.build(state); + try { + // The old overlay called `unitIdsAt` once per barrage, and that scanned + // every unit; the per-snapshot tile index replaces both scans. + let idScans = 0; + map.unitIdsAt = () => { + idScans += 1; + return []; + }; + let occupantScans = 0; + map._unitsAtCoords = () => { + occupantScans += 1; + return []; + }; + map._drawStrikeTargets(true); + this.assertGreater(env.$("#layer-targets .target-line").length, 1, "the barrages are drawn"); + this.assertEqual(idScans, 0, "no per-strike army scan"); + this.assertEqual(occupantScans, 0, "no per-strike occupant scan"); + + // A redraw reuses the existing nodes instead of rebuilding them. + const firstPath = env.$("#layer-targets .target-line").get(0); + map._drawStrikeTargets(true); + this.assertEqual( + env.$("#layer-targets .target-line").get(0), + firstPath, + "the arc nodes are reused across frames" + ); + } finally { + teardownDom(env); + } + } }