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:
@@ -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."
|
||||
}
|
||||
];
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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`);
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
},
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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
@@ -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",
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user