Optimised snapshot traffic and map rendering

This commit is contained in:
2026-09-20 01:07:03 +02:00
parent b385679e52
commit 34e755d1a4
24 changed files with 403 additions and 30 deletions
+3 -1
View File
@@ -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
View File
@@ -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>
+28 -6
View File
@@ -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
View File
@@ -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;
+4
View File
@@ -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;
+22 -10
View File
@@ -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);
},
+3
View File
@@ -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);
}
},
+20
View File
@@ -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);
},
+25 -2
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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);
+7
View File
@@ -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
+1
View File
@@ -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);
+1
View File
@@ -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() {
+1
View File
@@ -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
+2
View File
@@ -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
+25 -2
View File
@@ -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;
},
+2
View File
@@ -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, []);
+23
View File
@@ -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 {
+42
View File
@@ -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);
+42
View File
@@ -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();
+35
View File
@@ -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 {
+20
View File
@@ -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 {