Files
OpenFrontIO/src/client/view/GameView.ts
T
Evan 22c873cf55 perf(client): tick-dispatch timing harness + main-thread tick optimizations (late-game p95 −65%) (#4512)
## Problem

Every 100 ms the main thread's worker `onmessage` callback processes a
full game tick (`gameView.update` → `webglBuilder.update` →
`renderer.tick`). At 60 fps this competes with the 16.7 ms frame budget,
and on the Giant World Map it takes several ms — frame drops on low-end
hardware.

## Harness (`npm run perf:client-tick`)

Headless-Chromium harness that times every worker→main `game_update`
dispatch on the main thread, with structured-clone deserialization
measured separately from the handler body (via a
`Worker.prototype.addEventListener` wrapper installed as a page init
script — no product-code changes). It reports windowed distributions,
captures `.cpuprofile` files at chosen ticks, writes raw samples and an
end-of-run screenshot. `AnalyzeCpuProfile.ts` breaks a profile down by
inclusive time under the dispatch subtree.

Init scripts are passed as **strings**: tsx compiles function-form init
scripts with esbuild `keepNames`, whose injected `__name` helper doesn't
exist in-page and silently kills the game worker setup.

## Baseline (Giant World Map, 400 bots, headless)

Dispatch handler ms — cost **grows with game progression**:

| window | mean | p50 | p95 | max |
|---|---|---|---|---|
| tick 506 | 2.22 | 2.20 | 3.40 | 5.00 |
| tick 1506 | 2.60 | 2.00 | 7.00 | 10.40 |
| tick 2000 | 2.67 | 1.90 | **8.70** | **12.70** |

Deserialization is negligible (0.12 ms mean). CPU profiles attributed
the growing tail to the leaderboard's once-per-second refresh: its
Max-troops column calls `config().maxTroops(p)` for **all ~508
players**, and `PlayerView.units()` scanned **every unit in the game**
per call — O(players × units), growing as units accumulate.

## Round 1 — algorithmic fixes

- **GameView**: new `unitsOwnedBy(smallID)` — an active-units-by-owner
index built lazily at most once per tick. `PlayerView.units()` reads its
own units from it: O(own units) instead of O(all units). Also speeds up
unit display, player panel, and buildables queries.
- **NamePass.updateNames**: reads player state directly from the
caller's map by smallID instead of rebuilding three lookup maps per
tick; skips the slot-assignment sweep once every player has a slot.

After (same map, same spawn tile):

| window | mean | p50 | p95 | max |
|---|---|---|---|---|
| tick 506 | 2.12 | 2.00 | 3.10 | 5.20 |
| tick 1506 | 1.86 | 1.80 | 2.90 | 4.30 |
| tick 2000 | 1.74 | 1.60 | **2.40** | **4.70** |

Late-game p95 −65% (8.7 → 2.4 ms), worst dispatch −63% (12.7 → 4.7 ms),
and per-dispatch cost no longer grows with game progression. The
leaderboard disappeared from the dispatch profile entirely.

## Round 2 — allocation churn + time slicing

Aimed at GC pauses and low-end CPUs; measures flat vs round 1 on a fast
machine, as expected:

- **`FrameData.changedTiles`** is now the plain tile-ref array GameView
already builds instead of a per-tile `{ref, state}` object copy — heavy
battle ticks allocated tens of thousands of objects per tick for a
`state` field that was always 0. `TilePair` removed; `TerritoryPass`
buckets refs synchronously, so the live reference is safe.
- **`UnitView.lastPos`** is only re-sliced when a move actually appended
a position — the unconditional `slice(-1)` allocated an identical
1-element array per unit per tick, including for structures that never
move.
- **`NamePass.updateNames`** refreshes slots round-robin, a quarter per
tick — the full per-player diff pass spreads over ~400 ms, under the
existing 500 ms troop-text cadence; positions lerp continuously. Unnamed
slots and snap passes (seeks) are always processed so nothing pops in
late. Dispatch share: 17% → 13%.

Not sliced on purpose: tile ingest and frame upload need a consistent
per-tick snapshot (stale `GameMap` reads would leak into hover queries,
minimap, attack targeting) — a correctness risk not worth ~1 ms while
the worst dispatch already fits in a quarter of the frame budget.

## Verification

- `npx tsc --noEmit`, eslint clean; full suite green (1929 tests)
- 6 new GameView tests cover the owner index (grouping, inactive
exclusion, ownership capture, death, type filtering, copy semantics);
changedTiles tests updated to the ref-array contract
- Headless end-of-run screenshots verified after each round: leaderboard
Max-troops values, map names + troop counts + flags all render correctly
(including with name slicing active)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-05 13:46:02 -07:00

1271 lines
41 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { Config } from "../../core/configuration/Config";
import {
Cell,
GameUpdates,
PlayerID,
TerrainType,
TerraNullius,
Tick,
Unit,
UnitInfo,
UnitType,
} from "../../core/game/Game";
import { GameMap, TileRef } from "../../core/game/GameMap";
import {
GameUpdateType,
GameUpdateViewData,
SpawnPhaseEndUpdate,
} from "../../core/game/GameUpdates";
import { ATTACK_DELTA_OUTGOING } from "../../core/game/GameUpdateUtils";
import {
MotionPlanRecord,
unpackMotionPlans,
} from "../../core/game/MotionPlans";
import { TerrainMapData } from "../../core/game/TerrainMapLoader";
import { TerraNulliusImpl } from "../../core/game/TerraNulliusImpl";
import { UnitGrid, UnitPredicate } from "../../core/game/UnitGrid";
import { ClientID, GameID, Player, PlayerCosmetics } from "../../core/Schemas";
import { formatPlayerDisplayName } from "../../core/Util";
import { WorkerClient } from "../../core/worker/WorkerClient";
import { computeAllianceClusters } from "../render/frame/derive/AllianceClusters";
import { extractAttackRings } from "../render/frame/derive/AttackRings";
import { extractNukeTelegraphs } from "../render/frame/derive/NukeTelegraphs";
import { computePlayerStatus } from "../render/frame/derive/PlayerStatus";
import { buildRelationMatrix } from "../render/frame/derive/RelationMatrix";
import { RailroadCache } from "../render/frame/RailroadCache";
import { TrailManager } from "../render/frame/TrailManager";
import type { FrameData, NameEntry } from "../render/types";
import { STRUCTURE_TYPES } from "../render/types";
import { PlayerView } from "./PlayerView";
import { UnitView } from "./UnitView";
const TRAIL_TYPES: ReadonlySet<UnitType> = new Set<UnitType>([
UnitType.TransportShip,
UnitType.AtomBomb,
UnitType.HydrogenBomb,
UnitType.MIRV,
UnitType.MIRVWarhead,
]);
type TrainPlanState = {
planId: number;
startTick: number;
speed: number;
spacing: number;
carUnitIds: Uint32Array;
path: Uint32Array;
cursor: number;
usedTilesBuf: Uint32Array;
usedHead: number;
usedLen: number;
lastAdvancedTick: Tick;
};
export class GameView implements GameMap {
private lastUpdate: GameUpdateViewData | null;
private startTick: Tick | null = null;
private smallIDToID = new Map<number, PlayerID>();
private _players = new Map<PlayerID, PlayerView>();
private _units = new Map<number, UnitView>();
/**
* Long-lived state maps (renderer's plain-object shape). Each entry shares
* its identity with the corresponding PlayerView.state / UnitView.state, so
* mutations through either path are visible everywhere.
*/
private _playerStates = new Map<
number,
import("../render/types").PlayerState
>();
private _unitStates = new Map<number, import("../render/types").UnitState>();
/** smallID → team, for the renderer's relation matrix (team games). */
private _teams = new Map<number, string>();
private updatedTiles: TileRef[] = [];
private updatedTerrainTiles: TileRef[] = [];
/**
* Active units grouped by owner smallID, built lazily at most once per
* tick. Keeps per-player unit queries (PlayerView.units) at O(own units)
* instead of O(all units) — the leaderboard asks for every player's units
* in one pass, which is O(players × units) without this.
*/
private _unitsByOwner = new Map<number, UnitView[]>();
private _unitsByOwnerStale = true;
// ── FrameData accumulators (renderer-bound state) ─────────────────────
private trailManager!: TrailManager;
private railroadCache!: RailroadCache;
/** Long-lived NameEntry map for the renderer's NamePass. */
private _names = new Map<string, NameEntry>();
/** Reusable scratch buffers for per-tick deltas. */
private readonly _trailIdsScratch: number[] = [];
/**
* The single long-lived FrameData object. Fields are mutated in place each
* tick by update(). Renderer reads this each frame via frameData().
*/
private _frame: FrameData;
private _structuresDirty = false;
/** True until first populateFrame() — controls full-vs-delta tile upload. */
private _firstPopulate = true;
private _myPlayer: PlayerView | null = null;
// ── populateFrame dirty flags ──────────────────────────────────────────
// The derived structures below only depend on rarely-changing player
// fields, so they're rebuilt only when one of their inputs arrived this
// tick (PlayerUpdates are partial — field presence means "changed").
/** Names: nameData record applied, or a player was added. */
private _namesDirty = true;
/** Relation matrix: allies/embargoes changed, or a player was added. */
private _relationsDirty = true;
/** Alliance clusters: allies changed, or a player was added. */
private _clustersDirty = true;
private unitGrid: UnitGrid;
private unitMotionPlans = new Map<
number,
{
planId: number;
startTick: number;
ticksPerStep: number;
path: Uint32Array;
}
>();
private trainMotionPlans = new Map<number, TrainPlanState>();
private trainUnitToEngine = new Map<number, number>();
private toDelete = new Set<number>();
private _cosmetics: Map<string, PlayerCosmetics> = new Map();
private _map: GameMap;
constructor(
public worker: WorkerClient,
private _config: Config,
private _mapData: TerrainMapData,
private _myClientID: ClientID | undefined,
private _myUsername: string,
private _myClanTag: string | null,
private _gameID: GameID,
humans: Player[],
) {
this._map = this._mapData.gameMap;
this.lastUpdate = null;
this.unitGrid = new UnitGrid(this._map);
this._cosmetics = new Map(
humans.map((h) => [h.clientID, h.cosmetics ?? {}]),
);
for (const nation of this._mapData.nations) {
// Nations don't have client ids, so we use their name as the key instead.
this._cosmetics.set(nation.name, {
flag: nation.flag ? `/flags/${nation.flag}.svg` : undefined,
} satisfies PlayerCosmetics);
}
for (const extra of this._mapData.additionalNations) {
// Only set if not already provided by a manifest nation with the same name.
if (this._cosmetics.has(extra.name)) continue;
this._cosmetics.set(extra.name, {
flag: extra.flag ? `/flags/${extra.flag}.svg` : undefined,
} satisfies PlayerCosmetics);
}
const mapW = this._map.width();
const mapH = this._map.height();
this.trailManager = new TrailManager(mapW, mapH);
this.railroadCache = new RailroadCache(mapW, mapH);
// Long-lived FrameData. Most fields are mutable references to long-lived
// buffers (tileState, trailState, etc.); changedTiles points at this
// tick's updatedTiles. Properties marked `readonly` on FrameData only
// prevent reassignment, not mutation through the reference.
// events: fresh arrays we own; cleared and repopulated each tick.
this._frame = {
tick: 0,
inSpawnPhase: true,
tileState: this._map.tileStateBuffer(),
trailState: this.trailManager.getTrailState(),
railroadState: this.railroadCache.railroadState,
units: this._unitStates,
players: this._playerStates,
names: this._names,
events: {
deadUnits: [],
conquestEvents: [],
bonusEvents: [],
},
changedTiles: null,
railroadDirty: false,
revealedRailTiles: this.railroadCache.revealedRailTiles,
trailDirtyRowMin: 0,
trailDirtyRowMax: -1,
// Derived data — populated each tick by populateFrame(). Empty defaults
// here so the type is satisfied before the first update().
playerStatus: new Map(),
relationMatrix: new Uint8Array(0),
relationSize: 0,
relationsDirty: false,
allianceClusters: new Map(),
nukeTelegraphs: [],
attackRings: [],
structuresDirty: false,
};
}
isOnEdgeOfMap(ref: TileRef): boolean {
return this._map.isOnEdgeOfMap(ref);
}
public updatesSinceLastTick(): GameUpdates | null {
return this.lastUpdate?.updates ?? null;
}
public motionPlans(): ReadonlyMap<
number,
{
planId: number;
startTick: number;
ticksPerStep: number;
path: Uint32Array;
}
> {
return this.unitMotionPlans;
}
private motionPlannedUnitIdsCache: number[] = [];
private motionPlannedUnitIdsDirty = true;
private markMotionPlannedUnitIdsDirty(): void {
this.motionPlannedUnitIdsDirty = true;
}
private rebuildMotionPlannedUnitIdsCacheIfDirty(): void {
if (!this.motionPlannedUnitIdsDirty) {
return;
}
this.motionPlannedUnitIdsDirty = false;
const out = this.motionPlannedUnitIdsCache;
out.length = 0;
for (const unitId of this.unitMotionPlans.keys()) {
out.push(unitId);
}
for (const [engineId, plan] of this.trainMotionPlans) {
out.push(engineId);
for (let i = 0; i < plan.carUnitIds.length; i++) {
const id = plan.carUnitIds[i] >>> 0;
if (id !== 0) out.push(id);
}
}
}
public motionPlannedUnitIds(): number[] {
this.rebuildMotionPlannedUnitIdsCacheIfDirty();
return this.motionPlannedUnitIdsCache;
}
public isCatchingUp(): boolean {
return (this.lastUpdate?.pendingTurns ?? 0) > 1;
}
public update(gu: GameUpdateViewData) {
// Unit set/ownership changes below; rebuild the owner index on demand.
this._unitsByOwnerStale = true;
this.toDelete.forEach((id) => {
this._units.delete(id);
this._unitStates.delete(id);
});
this.toDelete.clear();
this.lastUpdate = gu;
this.updatedTiles = [];
this.updatedTerrainTiles = [];
const packed = this.lastUpdate.packedTileUpdates;
for (let i = 0; i + 1 < packed.length; i += 2) {
const tile = packed[i];
const state = packed[i + 1];
const terrainChanged = this.updateTile(tile, state);
this.updatedTiles.push(tile);
if (terrainChanged) {
this.updatedTerrainTiles.push(tile);
}
}
if (gu.packedMotionPlans) {
const records = unpackMotionPlans(gu.packedMotionPlans);
this.applyMotionPlanRecords(records);
}
if (gu.updates === null) {
throw new Error("lastUpdate.updates not initialized");
}
const spawnPhaseEndUpdate = gu.updates[GameUpdateType.SpawnPhaseEnd][0] as
| SpawnPhaseEndUpdate
| undefined;
if (spawnPhaseEndUpdate) {
this.startTick = spawnPhaseEndUpdate.startTick;
}
const myDisplayName = formatPlayerDisplayName(
this._myUsername,
this._myClanTag,
);
// Name placements arrive only on ticks where the worker recomputed them
// (see GameUpdateViewData.playerNameViewData). Apply to existing alive
// players here; dead players keep their last placement (names freeze at
// death), and new players get theirs via the PlayerView constructor in
// pass 1 below.
if (gu.playerNameViewData !== undefined) {
for (const id in gu.playerNameViewData) {
const pv = this._players.get(id);
if (pv !== undefined && pv.state.isAlive) {
pv.nameData = gu.playerNameViewData[id];
}
}
this._namesDirty = true;
}
// Pass 1: ensure every player exists with up-to-date PlayerState. We need
// all smallIDs registered before pass 2 can translate embargo PlayerIDs.
// PlayerUpdate is now partial: only `id` is guaranteed; everything else
// is present only when its value changed since the last emission.
gu.updates[GameUpdateType.Player].forEach((pu) => {
// First-emission (new player) — must have all static fields populated.
// Subsequent emissions for an existing player carry only changed fields.
const existing = this._players.get(pu.id);
// Replace the local player's name/displayName with their own stored values.
// This way the user does not know they are being censored. clientID is
// static — present only on first emission — so this branch only runs once.
if (pu.clientID !== undefined && pu.clientID === this._myClientID) {
pu.name = this._myUsername;
pu.displayName = myDisplayName;
}
if (pu.smallID !== undefined) {
this.smallIDToID.set(pu.smallID, pu.id);
}
// Derived-data dirty tracking: field presence on a partial update
// means the field changed this tick.
if (pu.allies !== undefined) {
this._relationsDirty = true;
this._clustersDirty = true;
}
if (pu.embargoes !== undefined) {
this._relationsDirty = true;
}
if (existing !== undefined) {
existing.applyUpdate(pu);
const nextNameData = gu.playerNameViewData?.[pu.id];
if (nextNameData !== undefined) {
existing.nameData = nextNameData;
}
} else {
const player = new PlayerView(
this,
pu,
gu.playerNameViewData?.[pu.id],
// First check human by clientID, then check nation by name.
this._cosmetics.get(pu.clientID ?? "") ??
this._cosmetics.get(pu.name!) ??
{},
);
this._players.set(pu.id, player);
this._playerStates.set(pu.smallID!, player.state);
const team = player.team();
if (team !== null) {
this._teams.set(pu.smallID!, team);
}
this._namesDirty = true;
this._relationsDirty = true;
this._clustersDirty = true;
}
});
// Pass 2: translate engine embargoes (Set<PlayerID>) → renderer-format
// smallIDs. Only re-translate when embargoes changed (field present);
// unchanged sets stay at the previously-computed renderer-format list.
gu.updates[GameUpdateType.Player].forEach((pu) => {
if (pu.embargoes === undefined) return;
const player = this._players.get(pu.id);
if (player === undefined) return;
const smallIDs: number[] = [];
for (const otherPlayerID of pu.embargoes) {
const otherPV = this._players.get(otherPlayerID);
if (otherPV !== undefined) {
smallIDs.push(otherPV.smallID());
}
}
player.setEmbargoSmallIDs(smallIDs);
});
// Packed per-player stats: [smallID, tilesOwned, gold, troops] quads for
// every player whose stats changed this tick (the per-tick churn that no
// longer travels in PlayerUpdate objects). Applied after pass 1 so
// first-emission players exist; their quad carries the same values as
// the full update, so double-applying is harmless.
const packedStats = gu.packedPlayerUpdates;
if (packedStats !== undefined) {
for (let i = 0; i + 3 < packedStats.length; i += 4) {
const state = this._playerStates.get(packedStats[i]);
if (state === undefined) continue;
state.tilesOwned = packedStats[i + 1];
state.gold = packedStats[i + 2];
state.troops = packedStats[i + 3];
}
}
// Packed attack troop counts: [ownerSmallID, direction, index, troops]
// quads. The attack arrays themselves are only resent when membership/
// order changes, which is also what keeps these indexes valid — a tick
// either resends an array (fresh troops included) or patches it, never
// both. See packAttackTroopDeltas.
const packedAttacks = gu.packedAttackUpdates;
if (packedAttacks !== undefined) {
for (let i = 0; i + 3 < packedAttacks.length; i += 4) {
const state = this._playerStates.get(packedAttacks[i]);
if (state === undefined) continue;
const attacks =
packedAttacks[i + 1] === ATTACK_DELTA_OUTGOING
? state.outgoingAttacks
: state.incomingAttacks;
const attack = attacks[packedAttacks[i + 2]];
if (attack !== undefined) {
attack.troops = packedAttacks[i + 3];
}
}
}
if (this._myClientID) {
this._myPlayer ??= this.playerByClientID(this._myClientID);
}
for (const unit of this._units.values()) {
unit._wasUpdated = false;
// Only trim when a move appended a position — slicing a ≤1-element
// array would allocate an identical array per unit per tick, and most
// units (structures) never move.
if (unit.lastPos.length > 1) {
unit.lastPos = unit.lastPos.slice(-1);
}
}
gu.updates[GameUpdateType.Unit].forEach((update) => {
let unit = this._units.get(update.id);
const isStructure = STRUCTURE_TYPES.has(update.unitType);
if (unit !== undefined) {
// Structure changes that affect rendering: owner changed (captured),
// level changed, became inactive, or finished construction
// (underConstruction → !underConstruction).
if (
isStructure &&
(unit.state.ownerID !== update.ownerID ||
unit.state.level !== update.level ||
unit.state.isActive !== update.isActive ||
(unit.state.underConstruction &&
!(update.underConstruction ?? false)))
) {
this._structuresDirty = true;
}
unit.update(update);
} else {
unit = new UnitView(this, update);
this._units.set(update.id, unit);
this._unitStates.set(update.id, unit.state);
this.unitGrid.addUnit(unit);
if (isStructure) this._structuresDirty = true;
}
if (!update.isActive) {
this.unitGrid.removeUnit(unit);
} else if (unit.tile() !== unit.lastTile()) {
this.unitGrid.updateUnitCell(unit);
}
if (!unit.isActive()) {
// Wait until next tick to delete the unit.
this.toDelete.add(unit.id());
if (this.unitMotionPlans.delete(unit.id())) {
this.markMotionPlannedUnitIdsDirty();
}
this.clearTrainPlanForUnit(unit.id());
}
});
this.advanceMotionPlannedUnits(gu.tick);
this.rebuildMotionPlannedUnitIdsCacheIfDirty();
this.populateFrame(gu);
}
// ── FrameData population ────────────────────────────────────────────────
/**
* Populate the long-lived FrameData from this tick's updates and current
* state. Runs at the end of update() once all engine-driven mutations are
* complete. Mutates _frame fields in place; never reassigns them.
*/
private populateFrame(gu: GameUpdateViewData): void {
// Reset trail dirty markers for this tick. The trailManager.update() pass
// below repaints rows and re-sets these as it goes.
this.trailManager.clearDirtyRows();
// Railroad events accumulate into the cache; revealedRailTiles is cleared
// at the start of apply().
this.railroadCache.apply(gu);
// Trail update: walk active trail-type units and stamp/decay.
this._trailIdsScratch.length = 0;
for (const u of this._units.values()) {
if (u.isActive() && TRAIL_TYPES.has(u.type())) {
this._trailIdsScratch.push(u.id());
}
}
this.trailManager.update(
this._unitStates as Map<number, import("../render/types").UnitState>,
this._trailIdsScratch,
);
// Names map — rebuilt only when a placement record arrived or a player
// was added (nameData values cannot change between those ticks). Entry
// order is irrelevant for the renderer.
if (this._namesDirty) {
this._namesDirty = false;
this._names.clear();
for (const p of this._players.values()) {
this._names.set(p.id(), {
playerID: p.id(),
x: p.nameData?.x ?? 0,
y: p.nameData?.y ?? 0,
size: p.nameData?.size ?? 0,
});
}
}
// FrameEvents — clear arrays, then re-populate from this tick's updates.
this.buildFrameEvents(gu);
// Update FrameData fields. Derived data is computed once per tick and
// stored directly on _frame (no intermediate copy). The renderer's
// `readonly` modifier on FrameData is just an external API hint —
// not enforced at runtime; we cast off to assign here.
const f = this._frame as {
-readonly [K in keyof FrameData]: FrameData[K];
};
f.tick = gu.tick;
f.inSpawnPhase = this.startTick === null;
f.railroadDirty = this.railroadCache.railroadDirty;
f.trailDirtyRowMin = this.trailManager.dirtyRowMin;
f.trailDirtyRowMax = this.trailManager.dirtyRowMax;
f.playerStatus = computePlayerStatus(this._playerStates, this._unitStates, {
localPlayerSmallID: this._myPlayer?.smallID() ?? 0,
localPlayerID: this._myPlayer?.id() ?? "",
tileState: this._map.tileStateBuffer(),
tick: gu.tick,
allianceDuration: this._config.allianceDuration(),
isTransitiveTarget: (sid) =>
this._myPlayer?.hasTransitiveTarget(sid) ?? false,
doomsdayClockWarnTicks:
this._config.doomsdayClockConfig().warnSeconds * 10,
});
// Relations + clusters depend only on allies/embargoes/teams, which
// change rarely (teams only when a player is added) — recompute only
// when one of those inputs arrived this tick. buildRelationMatrix
// writes into a reusable module-level buffer, so skipping the call
// leaves f.relationMatrix's contents intact. f.relationsDirty lets the
// upload layer skip the GPU push (and the full-map border recompute it
// triggers) on unchanged ticks.
if (this._relationsDirty) {
this._relationsDirty = false;
const rel = buildRelationMatrix(this._playerStates, this._teams);
f.relationMatrix = rel.matrix;
f.relationSize = rel.size;
f.relationsDirty = true;
} else {
f.relationsDirty = false;
}
if (this._clustersDirty) {
this._clustersDirty = false;
f.allianceClusters = computeAllianceClusters(this._playerStates);
}
f.nukeTelegraphs = extractNukeTelegraphs(
this._unitStates,
this._map.width(),
this._myPlayer?.smallID() ?? 0,
// The latest relation matrix — recomputed above when dirty, otherwise
// carried over on the frame from the last rebuild.
f.relationMatrix,
f.relationSize,
);
f.attackRings = this._myPlayer
? extractAttackRings(
this._unitStates,
this._map.width(),
this._myPlayer.smallID(),
)
: [];
f.structuresDirty = this._structuresDirty;
// First populate: signal "full upload required" by nulling changedTiles.
// uploadFrameData() treats null as "no delta info; do a full tile+trail
// upload" — needed because the renderer's GPU buffers are empty.
if (this._firstPopulate) {
f.changedTiles = null;
f.structuresDirty = true; // force initial structure upload
this._firstPopulate = false;
} else {
// Live reference to this tick's changed refs — consumers copy what
// they keep (TerritoryPass buckets them synchronously in the upload).
f.changedTiles = this.updatedTiles;
}
// Reset transient flags for next tick.
this.railroadCache.clearDirty();
this._structuresDirty = false;
}
/** Clear and repopulate _frame.events arrays from this tick's gu.updates. */
private buildFrameEvents(gu: GameUpdateViewData): void {
const ev = this._frame.events;
ev.deadUnits.length = 0;
ev.conquestEvents.length = 0;
ev.bonusEvents.length = 0;
for (const u of gu.updates[GameUpdateType.Unit] ?? []) {
if (u.isActive) continue;
ev.deadUnits.push({
unitType: u.unitType,
pos: u.pos,
reachedTarget: u.reachedTarget,
ownerSmallID: u.ownerID,
});
}
const myID = this._myPlayer?.id();
for (const c of gu.updates[GameUpdateType.ConquestEvent] ?? []) {
if (c.conquerorId !== myID) continue;
const conquered = this._players.get(c.conqueredId);
if (conquered === undefined) continue;
const loc = conquered.nameLocation();
if (loc === undefined) continue;
ev.conquestEvents.push({
x: loc.x,
y: loc.y,
gold: Number(c.gold),
});
}
for (const b of gu.updates[GameUpdateType.BonusEvent] ?? []) {
const player = this._players.get(b.player);
if (player === undefined) continue;
ev.bonusEvents.push({
playerID: b.player,
smallID: player.smallID(),
tile: b.tile,
gold: Number(b.gold),
troops: b.troops,
});
}
}
/** Public accessor: the renderer reads this and uploads to the GPU. */
frameData(): FrameData {
return this._frame;
}
private advanceMotionPlannedUnits(currentTick: Tick): void {
for (const [unitId, plan] of this.unitMotionPlans) {
const unit = this._units.get(unitId);
if (!unit || !unit.isActive()) {
if (this.unitMotionPlans.delete(unitId)) {
this.markMotionPlannedUnitIdsDirty();
}
continue;
}
const oldTile = unit.tile();
const dt = currentTick - plan.startTick;
const stepIndex =
dt <= 0 ? 0 : Math.floor(dt / Math.max(1, plan.ticksPerStep));
const lastIndex = plan.path.length - 1;
const idx = Math.max(0, Math.min(lastIndex, stepIndex));
const newTile = plan.path[idx] as TileRef;
if (newTile !== oldTile) {
unit.applyDerivedPosition(newTile);
this.unitGrid.updateUnitCell(unit);
continue;
}
unit.applyDerivedRest();
// Once a plan is past its final step, `newTile` remains clamped to the last path tile.
// Drop finished plans to avoid repeatedly marking static units as updated each tick.
if (dt > 0 && stepIndex >= lastIndex) {
if (this.unitMotionPlans.delete(unitId)) {
this.markMotionPlannedUnitIdsDirty();
}
}
}
this.advanceTrainMotionPlannedUnits(currentTick);
}
private clearTrainPlanForUnit(unitId: number): void {
const engineId =
this.trainUnitToEngine.get(unitId) ??
(this.trainMotionPlans.has(unitId) ? unitId : null);
if (engineId === null) {
return;
}
const plan = this.trainMotionPlans.get(engineId);
if (!plan) {
this.trainUnitToEngine.delete(unitId);
return;
}
if (this.trainMotionPlans.delete(engineId)) {
this.markMotionPlannedUnitIdsDirty();
}
this.trainUnitToEngine.delete(engineId);
for (let i = 0; i < plan.carUnitIds.length; i++) {
const id = plan.carUnitIds[i] >>> 0;
if (id !== 0) this.trainUnitToEngine.delete(id);
}
}
private advanceTrainMotionPlannedUnits(currentTick: Tick): void {
const staleEngineIds: number[] = [];
for (const [engineId, plan] of this.trainMotionPlans) {
const engine = this._units.get(engineId);
if (!engine || !engine.isActive()) {
staleEngineIds.push(engineId);
continue;
}
const steps = currentTick - plan.lastAdvancedTick;
if (steps <= 0) {
continue;
}
const path = plan.path;
const lastIndex = path.length - 1;
const cap = plan.usedTilesBuf.length;
const pushUsed = (tile: TileRef) => {
if (cap === 0) return;
if (plan.usedLen < cap) {
const idx = (plan.usedHead + plan.usedLen) % cap;
plan.usedTilesBuf[idx] = tile >>> 0;
plan.usedLen++;
} else {
plan.usedTilesBuf[plan.usedHead] = tile >>> 0;
plan.usedHead = (plan.usedHead + 1) % cap;
plan.usedLen = cap;
}
};
const usedGet = (index: number): TileRef | null => {
if (index < 0 || index >= plan.usedLen || cap === 0) return null;
const idx = (plan.usedHead + index) % cap;
return plan.usedTilesBuf[idx] as TileRef;
};
let didMove = false;
for (let step = 0; step < steps; step++) {
const cursor = plan.cursor;
if (cursor >= lastIndex) {
break;
}
for (let i = 0; i < plan.speed && cursor + i < path.length; i++) {
pushUsed(path[cursor + i] as TileRef);
}
plan.cursor = Math.min(lastIndex, cursor + plan.speed);
for (let i = plan.carUnitIds.length - 1; i >= 0; --i) {
const carId = plan.carUnitIds[i] >>> 0;
if (carId === 0) continue;
const car = this._units.get(carId);
if (!car || !car.isActive()) {
continue;
}
const carTileIndex = (i + 1) * plan.spacing + 2;
const tile = usedGet(carTileIndex);
if (tile !== null) {
const oldTile = car.tile();
if (tile !== oldTile) {
car.applyDerivedPosition(tile);
this.unitGrid.updateUnitCell(car);
didMove = true;
}
}
}
const newEngineTile = path[plan.cursor] as TileRef;
const oldEngineTile = engine.tile();
if (newEngineTile !== oldEngineTile) {
engine.applyDerivedPosition(newEngineTile);
this.unitGrid.updateUnitCell(engine);
didMove = true;
}
}
plan.lastAdvancedTick = currentTick;
// Preserve the final-step redraw (plan remains for the tick where motion ends),
// then clear once the train has settled and no longer moves.
// Note: trains are currently deleted at the end of TrainExecution, and the ensuing
// `Unit` update (isActive=false) also clears any associated motion plan records.
// This expiry is defensive to avoid keeping stale plans around if that behavior changes.
if (!didMove && plan.cursor >= lastIndex) {
staleEngineIds.push(engineId);
}
}
for (const engineId of staleEngineIds) {
this.clearTrainPlanForUnit(engineId);
}
}
private applyMotionPlanRecords(records: readonly MotionPlanRecord[]): void {
for (const record of records) {
switch (record.kind) {
case "grid": {
if (record.ticksPerStep < 1 || record.path.length < 1) {
break;
}
const existing = this.unitMotionPlans.get(record.unitId);
if (existing && record.planId <= existing.planId) {
break;
}
const path =
record.path instanceof Uint32Array
? record.path
: Uint32Array.from(record.path);
this.unitMotionPlans.set(record.unitId, {
planId: record.planId,
startTick: record.startTick,
ticksPerStep: record.ticksPerStep,
path,
});
this.markMotionPlannedUnitIdsDirty();
break;
}
case "train": {
if (record.speed < 1 || record.path.length < 1) {
break;
}
const existing = this.trainMotionPlans.get(record.engineUnitId);
if (existing && record.planId <= existing.planId) {
break;
}
if (existing) {
this.clearTrainPlanForUnit(record.engineUnitId);
}
const carUnitIds =
record.carUnitIds instanceof Uint32Array
? record.carUnitIds
: Uint32Array.from(record.carUnitIds);
const path =
record.path instanceof Uint32Array
? record.path
: Uint32Array.from(record.path);
const usedCap = carUnitIds.length * record.spacing + 3;
const usedTilesBuf = new Uint32Array(Math.max(0, usedCap));
this.trainMotionPlans.set(record.engineUnitId, {
planId: record.planId,
startTick: record.startTick,
speed: record.speed,
spacing: record.spacing,
carUnitIds,
path,
cursor: 0,
usedTilesBuf,
usedHead: 0,
usedLen: 0,
lastAdvancedTick: record.startTick,
});
this.markMotionPlannedUnitIdsDirty();
this.trainUnitToEngine.set(record.engineUnitId, record.engineUnitId);
for (let i = 0; i < carUnitIds.length; i++) {
const carId = carUnitIds[i] >>> 0;
if (carId !== 0)
this.trainUnitToEngine.set(carId, record.engineUnitId);
}
break;
}
}
}
}
recentlyUpdatedTiles(): TileRef[] {
return this.updatedTiles;
}
recentlyUpdatedTerrainTiles(): TileRef[] {
return this.updatedTerrainTiles;
}
nearbyUnits(
tile: TileRef,
searchRange: number,
types: UnitType | readonly UnitType[],
predicate?: UnitPredicate,
): Array<{ unit: UnitView; distSquared: number }> {
return this.unitGrid.nearbyUnits(
tile,
searchRange,
types,
predicate,
) as Array<{
unit: UnitView;
distSquared: number;
}>;
}
hasUnitNearby(
tile: TileRef,
searchRange: number,
type: UnitType,
playerId?: PlayerID,
includeUnderConstruction?: boolean,
) {
return this.unitGrid.hasUnitNearby(
tile,
searchRange,
type,
playerId,
includeUnderConstruction,
);
}
anyUnitNearby(
tile: TileRef,
searchRange: number,
types: readonly UnitType[],
predicate: (unit: UnitView) => boolean,
playerId?: PlayerID,
includeUnderConstruction?: boolean,
): boolean {
return this.unitGrid.anyUnitNearby(
tile,
searchRange,
types,
predicate as (unit: Unit | UnitView) => boolean,
playerId,
includeUnderConstruction,
);
}
myClientID(): ClientID | undefined {
return this._myClientID;
}
myPlayer(): PlayerView | null {
return this._myPlayer;
}
player(id: PlayerID): PlayerView {
const player = this._players.get(id);
if (player === undefined) {
throw Error(`player id ${id} not found`);
}
return player;
}
players(): PlayerView[] {
return Array.from(this._players.values());
}
/**
* Recompute every player's theme-derived colors. Call when the active theme
* changes mid-game (e.g. toggling colorblind mode) so existing territories
* re-color; the renderer palette must be refreshed afterwards.
*/
refreshPlayerColors(): void {
for (const p of this._players.values()) {
p.refreshColors();
}
}
playerBySmallID(id: number): PlayerView | TerraNullius {
if (id === 0) {
return new TerraNulliusImpl();
}
const playerId = this.smallIDToID.get(id);
if (playerId === undefined) {
throw new Error(`small id ${id} not found`);
}
return this.player(playerId);
}
playerByClientID(id: ClientID): PlayerView | null {
const player =
Array.from(this._players.values()).filter(
(p) => p.clientID() === id,
)[0] ?? null;
if (player === null) {
return null;
}
return player;
}
hasPlayer(id: PlayerID): boolean {
return false;
}
playerViews(): PlayerView[] {
return Array.from(this._players.values());
}
owner(tile: TileRef): PlayerView | TerraNullius {
return this.playerBySmallID(this.ownerID(tile));
}
ticks(): Tick {
if (this.lastUpdate === null) return 0;
return this.lastUpdate.tick;
}
inSpawnPhase(): boolean {
return this.startTick === null;
}
isSpawnImmunityActive(): boolean {
return (
this.inSpawnPhase() ||
this.ticksSinceStart() < this._config.spawnImmunityDuration()
);
}
isNationSpawnImmunityActive(): boolean {
return (
this.inSpawnPhase() ||
this.ticksSinceStart() < this._config.nationSpawnImmunityDuration()
);
}
elapsedGameSeconds(): number {
return this.ticksSinceStart() / 10;
}
ticksSinceStart(): Tick {
if (this.inSpawnPhase()) {
return 0;
}
return Math.max(0, this.ticks() - this.startTick!);
}
config(): Config {
return this._config;
}
units(...types: UnitType[]): UnitView[] {
if (types.length === 0) {
return Array.from(this._units.values()).filter((u) => u.isActive());
}
return Array.from(this._units.values()).filter(
(u) => u.isActive() && types.includes(u.type()),
);
}
/**
* Active units owned by the given player (smallID). The grouping is built
* lazily at most once per tick; the returned array must not be mutated.
*/
unitsOwnedBy(ownerSmallID: number): readonly UnitView[] {
if (this._unitsByOwnerStale) {
this._unitsByOwnerStale = false;
this._unitsByOwner.clear();
for (const u of this._units.values()) {
if (!u.isActive()) continue;
const sid = u.state.ownerID;
const arr = this._unitsByOwner.get(sid);
if (arr === undefined) {
this._unitsByOwner.set(sid, [u]);
} else {
arr.push(u);
}
}
}
return this._unitsByOwner.get(ownerSmallID) ?? [];
}
unit(id: number): UnitView | undefined {
return this._units.get(id);
}
unitInfo(type: UnitType): UnitInfo {
return this._config.unitInfo(type);
}
/**
* Long-lived map of UnitState records, keyed by unit ID. Mutated in place
* each tick by `update()`. Renderer code reads from this directly — the
* UnitView wrapping each entry shares the same UnitState reference.
*
* Includes inactive units; renderer filters by `state.isActive`.
*/
unitStates(): ReadonlyMap<number, import("../render/types").UnitState> {
return this._unitStates;
}
/**
* Long-lived map of PlayerState records, keyed by smallID. Mutated in place
* each tick by `update()`. Renderer code reads from this directly.
*/
playerStates(): ReadonlyMap<number, import("../render/types").PlayerState> {
return this._playerStates;
}
ref(x: number, y: number): TileRef {
return this._map.ref(x, y);
}
isValidRef(ref: TileRef): boolean {
return this._map.isValidRef(ref);
}
x(ref: TileRef): number {
return this._map.x(ref);
}
y(ref: TileRef): number {
return this._map.y(ref);
}
cell(ref: TileRef): Cell {
return this._map.cell(ref);
}
width(): number {
return this._map.width();
}
height(): number {
return this._map.height();
}
numLandTiles(): number {
return this._map.numLandTiles();
}
isValidCoord(x: number, y: number): boolean {
return this._map.isValidCoord(x, y);
}
isLand(ref: TileRef): boolean {
return this._map.isLand(ref);
}
isImpassable(ref: TileRef): boolean {
return this._map.isImpassable(ref);
}
isOceanShore(ref: TileRef): boolean {
return this._map.isOceanShore(ref);
}
isOcean(ref: TileRef): boolean {
return this._map.isOcean(ref);
}
isShoreline(ref: TileRef): boolean {
return this._map.isShoreline(ref);
}
magnitude(ref: TileRef): number {
return this._map.magnitude(ref);
}
terrainByte(ref: TileRef): number {
return this._map.terrainByte(ref);
}
setWater(ref: TileRef): void {
this._map.setWater(ref);
}
setShorelineBit(ref: TileRef): void {
this._map.setShorelineBit(ref);
}
clearShorelineBit(ref: TileRef): void {
this._map.clearShorelineBit(ref);
}
setOcean(ref: TileRef): void {
this._map.setOcean(ref);
}
setMagnitude(ref: TileRef, value: number): void {
this._map.setMagnitude(ref, value);
}
ownerID(ref: TileRef): number {
return this._map.ownerID(ref);
}
hasOwner(ref: TileRef): boolean {
return this._map.hasOwner(ref);
}
setOwnerID(ref: TileRef, playerId: number): void {
return this._map.setOwnerID(ref, playerId);
}
hasFallout(ref: TileRef): boolean {
return this._map.hasFallout(ref);
}
setFallout(ref: TileRef, value: boolean): void {
return this._map.setFallout(ref, value);
}
isBorder(ref: TileRef): boolean {
return this._map.isBorder(ref);
}
neighbors(ref: TileRef): TileRef[] {
return this._map.neighbors(ref);
}
forEachNeighbor(ref: TileRef, callback: (neighbor: TileRef) => void): void {
this._map.forEachNeighbor(ref, callback);
}
neighbors4(ref: TileRef, out: TileRef[]): number {
return this._map.neighbors4(ref, out);
}
forEachNeighborWithDiag(
ref: TileRef,
callback: (neighbor: TileRef) => void,
): void {
this._map.forEachNeighborWithDiag(ref, callback);
}
isWater(ref: TileRef): boolean {
return this._map.isWater(ref);
}
isShore(ref: TileRef): boolean {
return this._map.isShore(ref);
}
cost(ref: TileRef): number {
return this._map.cost(ref);
}
terrainType(ref: TileRef): TerrainType {
return this._map.terrainType(ref);
}
forEachTile(fn: (tile: TileRef) => void): void {
return this._map.forEachTile(fn);
}
manhattanDist(c1: TileRef, c2: TileRef): number {
return this._map.manhattanDist(c1, c2);
}
euclideanDistSquared(c1: TileRef, c2: TileRef): number {
return this._map.euclideanDistSquared(c1, c2);
}
circleSearch(
tile: TileRef,
radius: number,
filter?: (tile: TileRef, d2: number) => boolean,
): Set<TileRef> {
return this._map.circleSearch(tile, radius, filter);
}
bfs(
tile: TileRef,
filter: (gm: GameMap, tile: TileRef) => boolean,
): Set<TileRef> {
return this._map.bfs(tile, filter);
}
tileState(tile: TileRef): number {
return this._map.tileState(tile);
}
tileStateBuffer(): Uint16Array {
return this._map.tileStateBuffer();
}
updateTile(tile: TileRef, state: number): boolean {
return this._map.updateTile(tile, state);
}
numTilesWithFallout(): number {
return this._map.numTilesWithFallout();
}
gameID(): GameID {
return this._gameID;
}
focusedPlayer(): PlayerView | null {
return this.myPlayer();
}
}