Cut the per-broadcast and per-frame cost on a full-sized map

The server sent the whole army on every snapshot and the client re-cloned
the whole icon layer whenever a unit moved, so a settled world with a
standing army drained the broadcast loop and the frame loop alike.

- Ship only the units and city list that changed, keyed by a per-peer
  fingerprint and a serialised hash, and merge those deltas on the client.
- Memoise the battlefield scan on a battle version bumped when a unit
  joins, leaves or changes side.
- Skip the DOM writes for units whose snapshot object is unchanged, and
  cache the unit-view inner nodes.
- Fold units, cities and tile markers onto the nearest world copy instead
  of mirroring the icon layers, which re-cloned thousands of views every
  frame; the small decoration layers still mirror, and only on a change.

Frame work at full zoom-out with units moving fell from ~190ms to ~6.5ms.
This commit is contained in:
2026-09-24 00:33:51 +02:00
parent 822c26f616
commit 1c6f0ae1f1
14 changed files with 393 additions and 89 deletions
+6 -6
View File
@@ -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."
}
];
+62 -32
View File
@@ -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) {
+23 -9
View File
@@ -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`);
}
}
},
+82 -11
View File
@@ -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) {
+11 -1
View File
@@ -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;
}
+47 -3
View File
@@ -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;
}
+10
View File
@@ -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) {
+23 -2
View File
@@ -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;
},
+11 -8
View File
@@ -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);
+43 -1
View File
@@ -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;
+7 -13
View File
@@ -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);
}
+52
View File
@@ -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 };
+12 -2
View File
@@ -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",
+4 -1
View File
@@ -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);