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