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.
This commit is contained in:
2026-09-21 02:23:04 +02:00
parent dc0432a60a
commit b2ab4d76eb
11 changed files with 419 additions and 77 deletions
+6
View File
@@ -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;
+10 -2
View File
@@ -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;
+5 -1
View File
@@ -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";
+160 -51
View File
@@ -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 <svg> 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 --
+40 -17
View File
@@ -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;
}
}
+21
View File
@@ -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;
}
+21 -3
View File
@@ -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,
+36 -3
View File
@@ -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<ethnicity, number> as sorted [ethnicity, roundedNumber] pairs, dropping
// zero entries.
function mapToEntries(map) {
+3
View File
@@ -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() {
+50
View File
@@ -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);
+67
View File
@@ -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);
}
}
}