Optimised snapshot traffic and map rendering
This commit is contained in:
@@ -472,7 +472,9 @@ input:focus, select:focus { border-color: var(--accent); }
|
||||
/* Country names for the far-out political map, drawn as stretched SVG text on
|
||||
their own layer below the city labels. The SVG has no size of its own; the
|
||||
overflow is visible so the rotated text can sit anywhere on the map. */
|
||||
.country-label-layer { pointer-events: none; z-index: 4; }
|
||||
/* No z-index on purpose: the layer is first of the icon layers in the DOM, so
|
||||
it paints beneath the selection, paths, targets, units, cities and labels. */
|
||||
.country-label-layer { pointer-events: none; }
|
||||
.political-label-svg {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
|
||||
+3
-1
@@ -104,11 +104,13 @@
|
||||
<div class="layer" id="layer-roads"></div>
|
||||
<div class="layer" id="layer-borders"></div>
|
||||
<div class="layer" id="layer-fog"></div>
|
||||
<!-- Country names sit under every icon layer: first here, above the
|
||||
ground fill but below the selection, paths, targets and sprites. -->
|
||||
<div class="layer country-label-layer" id="layer-political-labels"></div>
|
||||
<div class="layer" id="layer-highlight"></div>
|
||||
<div class="layer" id="layer-paths"></div>
|
||||
<div class="layer" id="layer-targets"></div>
|
||||
<div class="layer" id="layer-entities"></div>
|
||||
<div class="layer country-label-layer" id="layer-political-labels"></div>
|
||||
<div class="layer city-label-layer" id="layer-labels"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -31,6 +31,10 @@ export class GameScreen {
|
||||
this._cityBuildings = new Map();
|
||||
this._population = new Map();
|
||||
this._tileEthnicity = new Map();
|
||||
this._populationVersion = undefined;
|
||||
this._ethnicityVersion = undefined;
|
||||
this._territoryVersion = undefined;
|
||||
this._regionVersion = undefined;
|
||||
this._tileGdpPenalties = new Map();
|
||||
this._tileBattleGdpDeficits = new Map();
|
||||
this._territoryOwner = new Map();
|
||||
@@ -131,18 +135,36 @@ export class GameScreen {
|
||||
this.protoUnits = state.protos.map(
|
||||
(p) => PROTO_UNITS.find((full) => full.id === p.id) || { id: p.id, name: p.name }
|
||||
);
|
||||
this._population = new Map(state.population.map((e) => [`${e[0]},${e[1]}`, e[2]]));
|
||||
this._tileEthnicity = new Map(
|
||||
(state.tileEthnicity || []).map((e) => [`${e[0]},${e[1]}`, e[2]])
|
||||
);
|
||||
// The big per-tile collections carry a version from the server. Rebuild
|
||||
// their client-side maps only when that version moved; a snapshot with no
|
||||
// versions (direct callers, tests) always rebuilds.
|
||||
const versions = state.versions || null;
|
||||
const versionChanged = (key, current) => !versions || versions[key] !== current;
|
||||
const versionOf = (key) => (versions ? versions[key] : undefined);
|
||||
if (versionChanged("population", this._populationVersion)) {
|
||||
this._populationVersion = versionOf("population");
|
||||
this._population = new Map(state.population.map((e) => [`${e[0]},${e[1]}`, e[2]]));
|
||||
}
|
||||
if (versionChanged("ethnicity", this._ethnicityVersion)) {
|
||||
this._ethnicityVersion = versionOf("ethnicity");
|
||||
this._tileEthnicity = new Map(
|
||||
(state.tileEthnicity || []).map((e) => [`${e[0]},${e[1]}`, e[2]])
|
||||
);
|
||||
}
|
||||
if (versionChanged("territory", this._territoryVersion)) {
|
||||
this._territoryVersion = versionOf("territory");
|
||||
this._territoryOwner = new Map(state.territory.map((e) => [`${e[0]},${e[1]}`, e[2]]));
|
||||
}
|
||||
if (versionChanged("regions", this._regionVersion)) {
|
||||
this._regionVersion = versionOf("regions");
|
||||
this._tileRegion = new Map((state.regions || []).map((e) => [`${e[0]},${e[1]}`, e[2]]));
|
||||
}
|
||||
this._tileGdpPenalties = new Map(
|
||||
(state.tileGdpPenalties || []).map((e) => [`${e[0]},${e[1]}`, e[2]])
|
||||
);
|
||||
this._tileBattleGdpDeficits = new Map(
|
||||
(state.tileBattleGdpDeficits || []).map((e) => [`${e[0]},${e[1]}`, e[2]])
|
||||
);
|
||||
this._territoryOwner = new Map(state.territory.map((e) => [`${e[0]},${e[1]}`, e[2]]));
|
||||
this._tileRegion = new Map((state.regions || []).map((e) => [`${e[0]},${e[1]}`, e[2]]));
|
||||
this._battles = new Set((state.battles || []).map((e) => `${e[0]},${e[1]}`));
|
||||
this._cityBuildings = new Map();
|
||||
for (const city of state.cities) {
|
||||
|
||||
+12
-1
@@ -63,7 +63,13 @@ export class MapView {
|
||||
: $("#layer-political-labels");
|
||||
if (!this.$politicalLabels.length) {
|
||||
this.$politicalLabels = $('<div id="layer-political-labels" class="layer country-label-layer"></div>');
|
||||
this.$world.append(this.$politicalLabels);
|
||||
// Keep the names under the icon layers even when the layer was missing
|
||||
// from the page: insert before the first overlay rather than on top.
|
||||
if (this.$highlight && this.$highlight.length) {
|
||||
this.$politicalLabels.insertBefore(this.$highlight);
|
||||
} else {
|
||||
this.$world.append(this.$politicalLabels);
|
||||
}
|
||||
}
|
||||
|
||||
// The orthographic camera tilts the ground plane, so the terrain and every
|
||||
@@ -106,6 +112,9 @@ export class MapView {
|
||||
this.economicValues = new Map();
|
||||
this.economicRange = { min: 0, max: 0 };
|
||||
this._economicSignatureDone = null;
|
||||
// WebGL fog is rebuilt from the frame loop, coalesced (see `_flushFog`).
|
||||
this._fogDirty = false;
|
||||
this._fogBuiltAt = 0;
|
||||
// The colour scale shown while an economic mode is active.
|
||||
this.$legend = $("#map-legend");
|
||||
this._labelSvg = null;
|
||||
@@ -136,6 +145,8 @@ export class MapView {
|
||||
|
||||
this._territorySignature = "";
|
||||
this._regionsSignature = null;
|
||||
this._territoryVersion = undefined;
|
||||
this._regionsVersion = undefined;
|
||||
this._roadsSignature = null;
|
||||
this._exploredCount = -1;
|
||||
this._visibleSignature = null;
|
||||
|
||||
@@ -5,6 +5,10 @@ import { EDGE_SEGMENTS } from "./distortion.js";
|
||||
|
||||
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;
|
||||
export const DRAG_THRESHOLD = 5;
|
||||
export const ZOOM_MIN = 0.2;
|
||||
export const ZOOM_MAX = 2.0;
|
||||
|
||||
@@ -28,9 +28,9 @@ export const entityMethods = {
|
||||
this.setRoads(state.roads || []);
|
||||
bootLog.step("Mirrored the road network");
|
||||
this.localCiv = state.viewer;
|
||||
this._applyTerritory(state.territory);
|
||||
this._applyTerritory(state.territory, state.versions && state.versions.territory);
|
||||
bootLog.step("Mirrored the territory and borders");
|
||||
this._applyRegions(state.regions || []);
|
||||
this._applyRegions(state.regions || [], state.versions && state.versions.regions);
|
||||
this._applyKnowledge(state.explored, state.visible);
|
||||
bootLog.step("Mirrored the fog of war");
|
||||
this._applyBattles(state.battles);
|
||||
@@ -78,10 +78,17 @@ export const entityMethods = {
|
||||
}
|
||||
},
|
||||
|
||||
_applyTerritory(entries) {
|
||||
const signature = hashEntries(entries);
|
||||
if (signature === this._territorySignature) return;
|
||||
this._territorySignature = signature;
|
||||
_applyTerritory(entries, version = undefined) {
|
||||
if (version !== undefined) {
|
||||
// A server version is exact and free to compare, unlike hashing the whole
|
||||
// territory on every snapshot.
|
||||
if (version === this._territoryVersion) return;
|
||||
this._territoryVersion = version;
|
||||
} else {
|
||||
const signature = hashEntries(entries);
|
||||
if (signature === this._territorySignature) return;
|
||||
this._territorySignature = signature;
|
||||
}
|
||||
|
||||
this.territory = new Map();
|
||||
for (const entry of entries) {
|
||||
@@ -100,10 +107,15 @@ export const entityMethods = {
|
||||
// Mirrors each land tile's region (the city that claims it). The region of a
|
||||
// selected or inspected tile is drawn as a whole, so this only has to refresh
|
||||
// the overlay; nothing is drawn for an unselected map.
|
||||
_applyRegions(entries) {
|
||||
const signature = hashEntries(entries);
|
||||
if (signature === this._regionsSignature) return;
|
||||
this._regionsSignature = signature;
|
||||
_applyRegions(entries, version = undefined) {
|
||||
if (version !== undefined) {
|
||||
if (version === this._regionsVersion) return;
|
||||
this._regionsVersion = version;
|
||||
} else {
|
||||
const signature = hashEntries(entries);
|
||||
if (signature === this._regionsSignature) return;
|
||||
this._regionsSignature = signature;
|
||||
}
|
||||
this.regions = new Map(entries.map((entry) => [key(entry[0], entry[1]), entry[2]]));
|
||||
if (this.glRenderer) this.glRenderer.buildRegionOverlay(this);
|
||||
},
|
||||
|
||||
@@ -82,6 +82,9 @@ export const modeMethods = {
|
||||
this._refreshMapColours();
|
||||
if (this.glRenderer && this.topology) {
|
||||
this.glRenderer.buildKnowledge(this);
|
||||
// This rebuild is current, so a pending coalesced one would be redundant.
|
||||
this._fogDirty = false;
|
||||
this._fogBuiltAt = typeof performance !== "undefined" ? performance.now() : Date.now();
|
||||
this.glRenderer.buildRegionOverlay(this);
|
||||
}
|
||||
},
|
||||
|
||||
@@ -86,6 +86,7 @@ export const motionMethods = {
|
||||
this._applyEntityWrap();
|
||||
this._drawPaths();
|
||||
this._drawStrikeTargets();
|
||||
this._flushFog();
|
||||
this._glRender();
|
||||
},
|
||||
|
||||
@@ -152,6 +153,12 @@ export const motionMethods = {
|
||||
},
|
||||
|
||||
_drawPaths() {
|
||||
// With nothing selected there is nothing to draw; leave the layer alone
|
||||
// rather than emptying and repopulating it every frame.
|
||||
if (this._selectedUnitIds.size === 0) {
|
||||
if (this.$paths[0].firstChild) this.$paths.empty();
|
||||
return;
|
||||
}
|
||||
const fragment = document.createDocumentFragment();
|
||||
for (const [id, motion] of this._unitMotion) {
|
||||
if (!this._selectedUnitIds.has(id)) continue;
|
||||
@@ -172,6 +179,10 @@ export const motionMethods = {
|
||||
previous = next;
|
||||
}
|
||||
}
|
||||
if (!fragment.childNodes.length) {
|
||||
if (this.$paths[0].firstChild) this.$paths.empty();
|
||||
return;
|
||||
}
|
||||
this.$paths.empty()[0].appendChild(fragment);
|
||||
},
|
||||
|
||||
@@ -193,6 +204,11 @@ export const motionMethods = {
|
||||
// layer above the terrain but below the unit and city icons.
|
||||
_drawStrikeTargets() {
|
||||
if (!this.$targets || !this.$targets.length) return;
|
||||
// Strike lines only appear for a selected attacker or target.
|
||||
if (this._selectedUnitIds.size === 0) {
|
||||
if (this.$targets[0].firstChild) this.$targets.empty();
|
||||
return;
|
||||
}
|
||||
const fragment = document.createDocumentFragment();
|
||||
for (const [id, data] of this._unitData) {
|
||||
if (!data.strikeTarget) continue;
|
||||
@@ -226,6 +242,10 @@ export const motionMethods = {
|
||||
ring.style.top = `${b.y}px`;
|
||||
fragment.appendChild(ring);
|
||||
}
|
||||
if (!fragment.childNodes.length) {
|
||||
if (this.$targets[0].firstChild) this.$targets.empty();
|
||||
return;
|
||||
}
|
||||
this.$targets.empty()[0].appendChild(fragment);
|
||||
},
|
||||
|
||||
|
||||
@@ -13,7 +13,13 @@ import { MapTopology, mapToLocal, key, parseKey, HEX_W, HEX_H, COL_STEP } from "
|
||||
import { MapGenerator } from "../../../shared/map_generator.js";
|
||||
import { TerrainStats } from "../../../shared/terrain_stats.js";
|
||||
import { MAP_CONFIG } from "../../../shared/data.js";
|
||||
import { CHUNK_SIZE, CHUNK_MARGIN, CHUNK_CACHE_LIMIT, CAMERA_TILT } from "./constants.js";
|
||||
import {
|
||||
CHUNK_SIZE,
|
||||
CHUNK_MARGIN,
|
||||
CHUNK_CACHE_LIMIT,
|
||||
CAMERA_TILT,
|
||||
FOG_REBUILD_MS,
|
||||
} from "./constants.js";
|
||||
import { terrainLayers, textureRepeat } from "./textures.js";
|
||||
import { bootLog } from "../loading.js";
|
||||
|
||||
@@ -66,11 +72,15 @@ export const terrainMethods = {
|
||||
|
||||
this._territorySignature = "";
|
||||
this._regionsSignature = null;
|
||||
this._territoryVersion = undefined;
|
||||
this._regionsVersion = undefined;
|
||||
this._exploredCount = -1;
|
||||
this._visibleSignature = null;
|
||||
// Visibility is per-world; the snapshot reapplies it right after this.
|
||||
this.explored = new Set();
|
||||
this.visible = new Set();
|
||||
this._fogDirty = false;
|
||||
this._fogBuiltAt = 0;
|
||||
this._chunkDirty = true;
|
||||
this._chunkView = { x: null, y: null, zoom: null };
|
||||
|
||||
@@ -420,7 +430,9 @@ export const terrainMethods = {
|
||||
|
||||
_refreshFog() {
|
||||
if (this.glRenderer) {
|
||||
this.glRenderer.buildKnowledge(this);
|
||||
// Do not rebuild the whole-map fog buffer on every snapshot: mark it and
|
||||
// let the frame loop flush it at a bounded rate.
|
||||
this._fogDirty = true;
|
||||
return;
|
||||
}
|
||||
if (!this._chunks) return;
|
||||
@@ -428,6 +440,17 @@ export const terrainMethods = {
|
||||
if (this._chunkCache) for (const rec of this._chunkCache.values()) rec.fogDirty = true;
|
||||
},
|
||||
|
||||
// Rebuilds the WebGL fog if a visibility change is pending and the last
|
||||
// rebuild is old enough. Called once per frame.
|
||||
_flushFog() {
|
||||
if (!this.glRenderer || !this._fogDirty) return;
|
||||
const now = typeof performance !== "undefined" ? performance.now() : Date.now();
|
||||
if (this._fogBuiltAt && now - this._fogBuiltAt < FOG_REBUILD_MS) return;
|
||||
this._fogDirty = false;
|
||||
this._fogBuiltAt = now;
|
||||
this.glRenderer.buildKnowledge(this);
|
||||
},
|
||||
|
||||
// Installs the road network once. Roads never change once generated, so a
|
||||
// length check is enough to skip rebuilding the on-screen chunks.
|
||||
setRoads(entries) {
|
||||
|
||||
+22
-1
@@ -11,6 +11,9 @@ export class Network {
|
||||
this.localPlayerId = 0;
|
||||
this.players = {};
|
||||
this._handlers = {};
|
||||
// The last full game state, with deltas merged into it. GameScreen always
|
||||
// sees a complete snapshot even though the wire only carries what changed.
|
||||
this._gameState = null;
|
||||
this._loginFailure = "";
|
||||
this._connectResolve = null;
|
||||
this._connectReject = null;
|
||||
@@ -56,6 +59,7 @@ export class Network {
|
||||
this._loginFailure = "";
|
||||
this.localPlayerId = 0;
|
||||
this.isAdmin = false;
|
||||
this._gameState = null;
|
||||
return new Promise((resolve, reject) => {
|
||||
let socket;
|
||||
try {
|
||||
@@ -158,6 +162,22 @@ 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.
|
||||
_mergeGameState(state) {
|
||||
if (!state || !state.delta || !this._gameState) {
|
||||
this._gameState = state;
|
||||
return;
|
||||
}
|
||||
const merged = { ...this._gameState, ...state };
|
||||
if (state.exploredAdded) {
|
||||
merged.explored = (this._gameState.explored || []).concat(state.exploredAdded);
|
||||
delete merged.exploredAdded;
|
||||
}
|
||||
this._gameState = merged;
|
||||
}
|
||||
|
||||
_handleMessage(message) {
|
||||
switch (message.t) {
|
||||
case "assigned":
|
||||
@@ -187,7 +207,8 @@ export class Network {
|
||||
this._emit("civ_assigned", message.civ);
|
||||
break;
|
||||
case "game_state":
|
||||
this._emit("game_state_received", message.state);
|
||||
this._mergeGameState(message.state);
|
||||
this._emit("game_state_received", this._gameState);
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
|
||||
+44
-1
@@ -9,6 +9,11 @@ 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"];
|
||||
|
||||
export class GameServer extends EventEmitter {
|
||||
constructor(network) {
|
||||
super();
|
||||
@@ -24,6 +29,10 @@ export class GameServer extends EventEmitter {
|
||||
this.identityCiv = new Map();
|
||||
this._configured = false;
|
||||
this._stateDirty = false;
|
||||
// Per-peer delta bookkeeping: the versions last sent and how many explored
|
||||
// tiles the viewer already has.
|
||||
this._peerSnapshotVersions = new Map();
|
||||
this._peerExploredSent = new Map();
|
||||
}
|
||||
|
||||
isConfigured() {
|
||||
@@ -265,6 +274,10 @@ export class GameServer extends EventEmitter {
|
||||
}
|
||||
|
||||
onPeerJoined(peerId, playerName = "") {
|
||||
// A fresh connection must receive a full snapshot, never a delta against a
|
||||
// previous peer that happened to reuse the id.
|
||||
this._peerSnapshotVersions.delete(peerId);
|
||||
this._peerExploredSent.delete(peerId);
|
||||
if (!this._configured) return;
|
||||
// A returning player keeps the nation they claimed before.
|
||||
if (this._reclaimIdentity(peerId, playerName)) {
|
||||
@@ -284,6 +297,8 @@ export class GameServer extends EventEmitter {
|
||||
onPeerLeft(peerId) {
|
||||
this.peerCiv.delete(peerId);
|
||||
this.peerNames.delete(peerId);
|
||||
this._peerSnapshotVersions.delete(peerId);
|
||||
this._peerExploredSent.delete(peerId);
|
||||
}
|
||||
|
||||
// Indices of the civilisations no connected peer has claimed.
|
||||
@@ -379,12 +394,40 @@ export class GameServer extends EventEmitter {
|
||||
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.state.viewerSnapshot(shared, civ, statsCache.get(civ)),
|
||||
state: this._deltaState(peerId, snapshot),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 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
|
||||
// snapshot to a peer (and after a reconnect) is always full.
|
||||
_deltaState(peerId, snapshot) {
|
||||
const versions = snapshot.versions;
|
||||
const previous = this._peerSnapshotVersions.get(peerId);
|
||||
if (!previous) {
|
||||
this._peerSnapshotVersions.set(peerId, versions);
|
||||
this._peerExploredSent.set(peerId, (snapshot.explored || []).length);
|
||||
return { ...snapshot, delta: false };
|
||||
}
|
||||
const state = { ...snapshot, delta: true };
|
||||
for (const key of DELTA_COLLECTIONS) {
|
||||
if (previous[key] === versions[key]) delete state[key];
|
||||
}
|
||||
const explored = snapshot.explored || [];
|
||||
const sent = this._peerExploredSent.get(peerId) || 0;
|
||||
if (explored.length > sent) {
|
||||
state.exploredAdded = explored.slice(sent);
|
||||
this._peerExploredSent.set(peerId, explored.length);
|
||||
}
|
||||
delete state.explored;
|
||||
this._peerSnapshotVersions.set(peerId, versions);
|
||||
return state;
|
||||
}
|
||||
}
|
||||
|
||||
function coordsOf(value) {
|
||||
|
||||
+16
-5
@@ -37,6 +37,11 @@ const MIME = {
|
||||
};
|
||||
|
||||
const TICK_MS = 50;
|
||||
// Movement still simulates every tick, but snapshots are broadcast at most this
|
||||
// often. A full snapshot is hundreds of KiB, and sending one per 50ms tick made
|
||||
// every client parse, allocate and repaint at 20Hz; the browser dead-reckons
|
||||
// movement between snapshots, so a lower rate is invisible in play.
|
||||
const SNAPSHOT_INTERVAL_S = 0.1;
|
||||
|
||||
async function serveStatic(request, response) {
|
||||
try {
|
||||
@@ -180,11 +185,13 @@ export function startServer({ port = 27015, bind = "127.0.0.1", log = console.lo
|
||||
});
|
||||
});
|
||||
|
||||
// Fixed-step loop: movement advances every frame, the clock strikes an hour
|
||||
// every SECONDS_PER_HOUR real seconds, and one snapshot is broadcast per frame
|
||||
// in which the state actually changed. Exposed as `step` so tests can drive
|
||||
// the clock deterministically.
|
||||
// Fixed-step loop: movement advances every tick and the clock strikes an hour
|
||||
// every SECONDS_PER_HOUR real seconds. The state is only broadcast once per
|
||||
// SNAPSHOT_INTERVAL_S however often it changed, so a game with moving units
|
||||
// does not flood clients with full snapshots. Exposed as `step` so tests can
|
||||
// drive the clock deterministically.
|
||||
let hourAccumulator = 0;
|
||||
let snapshotAccumulator = 0;
|
||||
function step(seconds) {
|
||||
gameServer.advanceMovement(seconds / SECONDS_PER_HOUR);
|
||||
hourAccumulator += seconds;
|
||||
@@ -192,7 +199,11 @@ export function startServer({ port = 27015, bind = "127.0.0.1", log = console.lo
|
||||
hourAccumulator -= SECONDS_PER_HOUR;
|
||||
gameServer.tickHour();
|
||||
}
|
||||
gameServer.flushIfDirty();
|
||||
snapshotAccumulator += seconds;
|
||||
if (snapshotAccumulator >= SNAPSHOT_INTERVAL_S) {
|
||||
snapshotAccumulator = 0;
|
||||
gameServer.flushIfDirty();
|
||||
}
|
||||
}
|
||||
const timer = setInterval(() => step(TICK_MS / 1000), TICK_MS);
|
||||
|
||||
|
||||
@@ -144,6 +144,13 @@ export class GameState {
|
||||
this._nextPolicyId = 1;
|
||||
this._rebellionCooldown = new Map();
|
||||
this._politicsVersion = 0;
|
||||
// Monotonic counters for the large per-tile collections. Snapshots ship
|
||||
// them so a viewer can tell which collections actually changed and rebuild
|
||||
// only those, and the server can omit the unchanged ones from the wire.
|
||||
this._territoryVersion = 0;
|
||||
this._regionVersion = 0;
|
||||
this._populationVersion = 0;
|
||||
this._ethnicityVersion = 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
|
||||
|
||||
@@ -265,6 +265,7 @@ export const combatMethods = {
|
||||
// GDP per capita each day, and its recovery clock is pushed back to 30 days
|
||||
// after the last day of fighting.
|
||||
_tickBattleTiles() {
|
||||
this._populationVersion += 1;
|
||||
for (const battle of this._battleTiles()) {
|
||||
const k = key(battle.coords.x, battle.coords.y);
|
||||
const population = this.tilePopulation.get(k);
|
||||
|
||||
@@ -498,6 +498,7 @@ export const economyMethods = {
|
||||
for (const k of Array.from(this.tilePopulation.keys())) {
|
||||
this.tilePopulation.set(k, this.tilePopulation.get(k) * (1 + factor));
|
||||
}
|
||||
this._populationVersion += 1;
|
||||
},
|
||||
|
||||
_tickEconomy() {
|
||||
|
||||
@@ -196,6 +196,7 @@ export const orderMethods = {
|
||||
}
|
||||
population = steps.length > 0 ? steps[steps.length - 1].remaining : population;
|
||||
this.tilePopulation.set(k, population);
|
||||
this._populationVersion += 1;
|
||||
this.tileGdpPenalty.set(k, penalty);
|
||||
this._gdpPerCapitaCache.delete(k);
|
||||
// The people driven off the tile are civilians lost to its owner in the war
|
||||
|
||||
@@ -102,6 +102,7 @@ export const politicsMethods = {
|
||||
if (weights.size === 0) weights.set(owner >= 0 ? owner : 0, 1);
|
||||
this.tileEthnicity.set(k, normalizeShares(weights));
|
||||
}
|
||||
this._ethnicityVersion += 1;
|
||||
},
|
||||
|
||||
// Multi-source BFS over every tile (land and sea) from a civilisation's
|
||||
@@ -148,6 +149,7 @@ export const politicsMethods = {
|
||||
if (share > 0.0001) compact.set(eth, share);
|
||||
}
|
||||
this.tileEthnicity.set(k, normalizeShares(compact));
|
||||
this._ethnicityVersion += 1;
|
||||
},
|
||||
|
||||
// The tile's people split by ethnicity, richest population first, each with
|
||||
|
||||
@@ -72,6 +72,15 @@ export const serializationMethods = {
|
||||
queue.map((entry) => ({ ...entry })),
|
||||
])
|
||||
),
|
||||
// Which of the large per-tile collections changed since the last
|
||||
// broadcast, so a viewer can rebuild only those and the server can omit
|
||||
// the rest from the wire (see GameServer._broadcastState).
|
||||
versions: {
|
||||
territory: this._territoryVersion,
|
||||
regions: this._regionVersion,
|
||||
population: this._populationVersion,
|
||||
ethnicity: this._ethnicityVersion,
|
||||
},
|
||||
};
|
||||
},
|
||||
|
||||
@@ -167,32 +176,46 @@ export const serializationMethods = {
|
||||
});
|
||||
},
|
||||
|
||||
// A sparse [x, y] list of every road tile.
|
||||
// A sparse [x, y] list of every road tile. Roads never change once generated,
|
||||
// so the list is serialized once.
|
||||
_serializeRoads() {
|
||||
if (this._serializedRoadsCache) return this._serializedRoadsCache;
|
||||
const result = [];
|
||||
for (const k of this.roads) {
|
||||
const coords = parseKey(k);
|
||||
result.push([coords.x, coords.y]);
|
||||
}
|
||||
this._serializedRoadsCache = result;
|
||||
return result;
|
||||
},
|
||||
|
||||
// A sparse [x, y, cityId] list of every tile's region.
|
||||
// A sparse [x, y, cityId] list of every tile's region, memoised against the
|
||||
// region version so an unchanged world is not re-walked on every broadcast.
|
||||
_serializeRegions() {
|
||||
if (this._serializedRegionsCache &&
|
||||
this._serializedRegionsCache.version === this._regionVersion) {
|
||||
return this._serializedRegionsCache.value;
|
||||
}
|
||||
const result = [];
|
||||
for (const [k, cityId] of this.tileRegion) {
|
||||
const coords = parseKey(k);
|
||||
result.push([coords.x, coords.y, cityId]);
|
||||
}
|
||||
this._serializedRegionsCache = { version: this._regionVersion, value: result };
|
||||
return result;
|
||||
},
|
||||
|
||||
_serializePopulation() {
|
||||
if (this._serializedPopulationCache &&
|
||||
this._serializedPopulationCache.version === this._populationVersion) {
|
||||
return this._serializedPopulationCache.value;
|
||||
}
|
||||
const result = [];
|
||||
for (const [k, value] of this.tilePopulation) {
|
||||
const coords = parseKey(k);
|
||||
result.push([coords.x, coords.y, Math.round(value)]);
|
||||
}
|
||||
this._serializedPopulationCache = { version: this._populationVersion, value: result };
|
||||
return result;
|
||||
},
|
||||
|
||||
|
||||
@@ -65,6 +65,7 @@ export const territoryMethods = {
|
||||
// nation's land. Ties between equally close cities are settled by city order,
|
||||
// so the result is deterministic and identical on both sides of the wire.
|
||||
_rebuildRegionIndex() {
|
||||
this._regionVersion += 1;
|
||||
this.tileRegion = new Map();
|
||||
this._regionByCity = new Map();
|
||||
const cityByCell = new Map();
|
||||
@@ -110,6 +111,7 @@ export const territoryMethods = {
|
||||
},
|
||||
|
||||
_indexTerritory() {
|
||||
this._territoryVersion += 1;
|
||||
this._territoryByCiv = new Map();
|
||||
for (const [k, owner] of this.territory) {
|
||||
if (!this._territoryByCiv.has(owner)) this._territoryByCiv.set(owner, []);
|
||||
|
||||
@@ -40,6 +40,29 @@ function landAtDistance(state, center, distance) {
|
||||
}
|
||||
|
||||
export class GameScreenTest extends TestCase {
|
||||
async test_versioned_collections_are_reused_when_unchanged() {
|
||||
const env = await setupDom();
|
||||
try {
|
||||
const screen = new GameScreen(stubNetwork(), makeConfig());
|
||||
screen.enter();
|
||||
const state = smallState();
|
||||
screen.onState(state.snapshot(0));
|
||||
const population = screen._population;
|
||||
const territory = screen.map.territory;
|
||||
const regions = screen.map.regions;
|
||||
const ethnicity = screen._tileEthnicity;
|
||||
// A second snapshot with no state change carries the same versions, so
|
||||
// none of the big per-tile maps should be rebuilt.
|
||||
screen.onState(state.snapshot(0));
|
||||
this.assert(population === screen._population, "population map is reused");
|
||||
this.assert(territory === screen.map.territory, "territory map is reused");
|
||||
this.assert(regions === screen.map.regions, "region map is reused");
|
||||
this.assert(ethnicity === screen._tileEthnicity, "ethnicity map is reused");
|
||||
} finally {
|
||||
teardownDom(env);
|
||||
}
|
||||
}
|
||||
|
||||
async test_renders_snapshot_and_sends_orders() {
|
||||
const env = await setupDom();
|
||||
try {
|
||||
|
||||
@@ -52,6 +52,48 @@ export class GameServerTest extends TestCase {
|
||||
this.assertEqual(server.state.seed, firstSeed);
|
||||
}
|
||||
|
||||
test_snapshots_are_delta_encoded_after_the_first() {
|
||||
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.assertFalse(full.delta, "the first snapshot to a peer is full");
|
||||
this.assertNotNull(full.territory, "it carries the whole territory");
|
||||
this.assertTrue(Array.isArray(full.explored), "and the explored list");
|
||||
|
||||
server._broadcastState();
|
||||
const delta = network.sent[network.sent.length - 1].message.state;
|
||||
this.assertTrue(delta.delta, "later snapshots are deltas");
|
||||
this.assertNull(delta.territory, "unchanged territory is left out");
|
||||
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.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");
|
||||
|
||||
// A real territory change must ship the array again.
|
||||
server.state._reindexTerritory();
|
||||
server._broadcastState();
|
||||
const after = network.sent[network.sent.length - 1].message.state;
|
||||
this.assertNotNull(after.territory, "changed territory 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());
|
||||
server.state._serializedExplored.delete(0);
|
||||
server._peerExploredSent.set(1, 0);
|
||||
const [fx, fy] = Object.keys(server.state.tiles)[0].split(",").map(Number);
|
||||
server.state._reveal(0, { x: fx, y: fy }, 0);
|
||||
server._broadcastState();
|
||||
const grown = network.sent[network.sent.length - 1].message.state;
|
||||
this.assertNull(grown.explored, "the full explored list is not re-sent");
|
||||
this.assertNotNull(grown.exploredAdded, "only the newly explored tiles are");
|
||||
this.assertGreaterOrEqual(grown.exploredAdded.length, 1, "the tail holds the new tile");
|
||||
}
|
||||
|
||||
test_move_order_for_own_unit_is_accepted() {
|
||||
const { server } = makeServer();
|
||||
server.peerCiv.set(1, 0);
|
||||
|
||||
@@ -114,6 +114,48 @@ export class MapModesTest extends TestCase {
|
||||
}
|
||||
}
|
||||
|
||||
async test_fog_rebuilds_are_coalesced_between_frames() {
|
||||
const env = await setupDom();
|
||||
const map = this.buildMap(env);
|
||||
try {
|
||||
let builds = 0;
|
||||
map.glRenderer = { buildKnowledge: () => { builds += 1; } };
|
||||
map._fogBuiltAt = 0;
|
||||
map._fogDirty = false;
|
||||
map._refreshFog();
|
||||
map._refreshFog();
|
||||
map._refreshFog();
|
||||
map._flushFog();
|
||||
this.assertEqual(builds, 1, "many visibility changes rebuild the fog once");
|
||||
|
||||
map._refreshFog();
|
||||
map._flushFog();
|
||||
this.assertEqual(builds, 1, "a later change waits for the interval");
|
||||
|
||||
map._fogBuiltAt = 0;
|
||||
map._flushFog();
|
||||
this.assertEqual(builds, 2, "the pending change is flushed once due");
|
||||
} finally {
|
||||
teardownDom(env);
|
||||
}
|
||||
}
|
||||
|
||||
async test_country_names_sit_below_every_icon_layer() {
|
||||
const env = await setupDom();
|
||||
try {
|
||||
const order = env.$("#map-world .layer").map((_index, element) => element.id).get();
|
||||
const names = order.indexOf("layer-political-labels");
|
||||
this.assertGreaterOrEqual(names, 0, "the country-name layer exists");
|
||||
for (const id of [
|
||||
"layer-highlight", "layer-paths", "layer-targets", "layer-entities", "layer-labels",
|
||||
]) {
|
||||
this.assertLess(names, order.indexOf(id), `${id} paints above the country names`);
|
||||
}
|
||||
} finally {
|
||||
teardownDom(env);
|
||||
}
|
||||
}
|
||||
|
||||
async test_territory_changes_skip_the_fog_and_region_rebuilds() {
|
||||
const state = smallState();
|
||||
const env = await setupDom();
|
||||
|
||||
@@ -116,6 +116,41 @@ export class NetTest extends TestCase {
|
||||
}
|
||||
}
|
||||
|
||||
async test_game_state_deltas_merge_into_the_last_full_state() {
|
||||
const { env, network, ws } = await this.connected();
|
||||
try {
|
||||
let state = null;
|
||||
network.on("game_state_received", (value) => {
|
||||
state = value;
|
||||
});
|
||||
ws.simulateMessage({
|
||||
t: "game_state",
|
||||
state: { seed: 5, viewer: 0, territory: [[0, 0, 1]], explored: [[0, 0]], units: [] },
|
||||
});
|
||||
this.assertEqual(state.territory.length, 1, "the first state is used whole");
|
||||
|
||||
// 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]] },
|
||||
});
|
||||
this.assertEqual(state.territory.length, 1, "unchanged territory is kept");
|
||||
this.assertSize(state.units, 1, "the changed field is updated");
|
||||
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 full state replaces the base.
|
||||
ws.simulateMessage({
|
||||
t: "game_state",
|
||||
state: { seed: 6, viewer: 0, territory: [], explored: [], units: [] },
|
||||
});
|
||||
this.assertEqual(state.seed, 6, "a full state replaces the base");
|
||||
} finally {
|
||||
teardownDom(env);
|
||||
}
|
||||
}
|
||||
|
||||
async test_disconnected_without_failure_reports_it() {
|
||||
const { env, network, ws } = await this.connected();
|
||||
try {
|
||||
|
||||
@@ -111,6 +111,26 @@ export class ServerLoopTest extends TestCase {
|
||||
this.assertGreater(server.gameServer.state.totalHours, 0, "an hour struck after a second");
|
||||
});
|
||||
}
|
||||
|
||||
async test_snapshots_are_throttled_between_ticks() {
|
||||
await withServer(async (port, server) => {
|
||||
server.gameServer.configureGame({
|
||||
seed: SEED,
|
||||
player_civ: 0,
|
||||
civilisations: ["france", "britain"],
|
||||
});
|
||||
// Count how often the loop actually flushes over one second of 50ms ticks.
|
||||
const flush = server.gameServer.flushIfDirty.bind(server.gameServer);
|
||||
let flushes = 0;
|
||||
server.gameServer.flushIfDirty = () => {
|
||||
flushes += 1;
|
||||
flush();
|
||||
};
|
||||
for (let i = 0; i < 20; i++) server.step(0.05);
|
||||
this.assertLess(flushes, 20, "not one snapshot per tick");
|
||||
this.assertGreaterOrEqual(flushes, 9, "about one snapshot per 100ms");
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export class ServerProtocolTest extends TestCase {
|
||||
|
||||
Reference in New Issue
Block a user