diff --git a/src/client/ClientGameRunner.ts b/src/client/ClientGameRunner.ts index 22bec1aff..ea734b250 100644 --- a/src/client/ClientGameRunner.ts +++ b/src/client/ClientGameRunner.ts @@ -230,7 +230,6 @@ export function joinLobby( function mountWebGLDebugRenderer( terrainMap: TerrainMapData, - gameView: GameView, transformHandler: import("./graphics/TransformHandler").TransformHandler, ): { builder: WebGLFrameBuilder; syncCamera: () => void } { const gameMap = terrainMap.gameMap; @@ -257,11 +256,20 @@ function mountWebGLDebugRenderer( mapHeight, unitTypes: [...ALL_UNIT_TYPES], players: [], + // Pre-allocate renderer textures for up to 1024 players. We add players + // dynamically via view.addPlayers() as they come in from the simulation, + // but the NamePass / palette / relation matrix all need a static upper + // bound at construction time. + maxPlayers: 1024, }, terrainBytes, palette, ); + // Names are rendered by the existing HTML NameLayer; disable the renderer's + // NamePass to avoid drawing them twice. + view.getSettings().passEnabled.name = false; + window.addEventListener("keydown", (e) => { if (e.key === "\\") { glCanvas.style.display = @@ -287,7 +295,7 @@ function mountWebGLDebugRenderer( (window as unknown as { __webglView?: unknown }).__webglView = view; - return { builder: new WebGLFrameBuilder(view, gameView), syncCamera }; + return { builder: new WebGLFrameBuilder(view), syncCamera }; } async function createClientGame( @@ -343,7 +351,6 @@ async function createClientGame( const { builder: webglBuilder, syncCamera } = mountWebGLDebugRenderer( gameMap, - gameView, gameRenderer.transformHandler, ); gameRenderer.onPreRender = syncCamera; @@ -507,7 +514,7 @@ export class ClientGameRunner { this.eventBus.emit(new SendHashEvent(hu.tick, hu.hash)); }); this.gameView.update(gu); - this.webglBuilder?.update(this.gameView, gu); + this.webglBuilder?.update(this.gameView); this.renderer.tick(); // Emit tick metrics event for performance overlay diff --git a/src/client/WebGLFrameBuilder.ts b/src/client/WebGLFrameBuilder.ts index be5a76819..5393b9279 100644 --- a/src/client/WebGLFrameBuilder.ts +++ b/src/client/WebGLFrameBuilder.ts @@ -1,135 +1,32 @@ import { Colord } from "colord"; -import { PlayerType, TrainType, UnitType } from "../core/game/Game"; -import { GameUpdateType, GameUpdateViewData } from "../core/game/GameUpdates"; import { GameView } from "../core/game/GameView"; -import { RailroadCache } from "./render/frame/railroad-cache"; -import { TrailManager } from "./render/frame/trail-manager"; -import { - PlayerStatic, - UnitState, - GameView as WebGLGameView, -} from "./render/gl"; -import { - BonusEvent, - ConquestFx, - DeadUnitFx, - PlayerTypeEnum, - TrainType as RendererTrainType, -} from "./render/types"; - -const TRAIL_TYPES: ReadonlySet = new Set([ - UnitType.TransportShip, - UnitType.AtomBomb, - UnitType.HydrogenBomb, - UnitType.MIRV, - UnitType.MIRVWarhead, -]); +import { uploadFrameData } from "./render/frame/upload"; +import { PlayerStatic, GameView as WebGLGameView } from "./render/gl"; const PALETTE_SIZE = 4096; +/** + * The renderer-side glue between GameView (which already builds the full + * FrameData each tick) and the WebGL view. Two responsibilities: + * + * 1. Palette management — translate PlayerView colors into a Float32Array + * the renderer uploads to a 1D texture, and call view.addPlayers() when + * new players appear (this is a renderer-side lifecycle event, not part + * of FrameData). + * 2. Per-tick upload — pass the FrameData to the renderer's uploadFrameData + * helper, which dispatches to all the view.update*() methods. + */ export class WebGLFrameBuilder { - private readonly mapW: number; - private readonly mapH: number; - private readonly tileState: Uint16Array; private readonly palette: Float32Array; private readonly knownSmallIDs = new Set(); - private readonly railroadCache: RailroadCache; - private readonly trailManager: TrailManager; - private readonly unitMap = new Map(); - private readonly trailIds: number[] = []; - constructor( - private readonly view: WebGLGameView, - gameView: GameView, - ) { - this.mapW = gameView.width(); - this.mapH = gameView.height(); - this.tileState = new Uint16Array(this.mapW * this.mapH); + constructor(private readonly view: WebGLGameView) { this.palette = new Float32Array(PALETTE_SIZE * 2 * 4); - this.railroadCache = new RailroadCache(this.mapW, this.mapH); - this.trailManager = new TrailManager(this.mapW, this.mapH); } - update(gameView: GameView, gu: GameUpdateViewData): void { + update(gameView: GameView): void { this.syncPlayers(gameView); - this.fillTileState(gameView); - this.fillUnitMap(gameView); - this.trailManager.update(this.unitMap, this.trailIds); - this.view.uploadTileAndTrailState( - this.tileState, - this.trailManager.getTrailState(), - ); - this.trailManager.clearDirtyRows(); - this.applyRailroads(gu); - this.view.updateStructures(this.unitMap); - this.view.updateUnits(this.unitMap, gameView.ticks()); - this.applyFxEvents(gameView, gu); - } - - private applyFxEvents(gameView: GameView, gu: GameUpdateViewData): void { - const deadUnits: DeadUnitFx[] = []; - for (const u of gu.updates[GameUpdateType.Unit] ?? []) { - if (u.isActive) continue; - deadUnits.push({ - unitType: u.unitType, - pos: u.pos, - reachedTarget: u.reachedTarget, - }); - } - if (deadUnits.length > 0) { - this.view.applyDeadUnits(deadUnits); - } - - const conquests: ConquestFx[] = []; - for (const c of gu.updates[GameUpdateType.ConquestEvent] ?? []) { - const conquered = gameView.player(c.conqueredId); - const loc = conquered.nameLocation(); - conquests.push({ - x: loc.x, - y: loc.y, - gold: Number(c.gold), - }); - } - if (conquests.length > 0) { - this.view.applyConquestEvents(conquests); - } - - const bonuses: BonusEvent[] = []; - for (const b of gu.updates[GameUpdateType.BonusEvent] ?? []) { - const player = gameView.player(b.player); - bonuses.push({ - playerID: b.player, - smallID: player.smallID(), - tile: b.tile, - gold: Number(b.gold), - troops: b.troops, - }); - } - if (bonuses.length > 0) { - this.view.applyBonusEvents(bonuses); - } - } - - private fillUnitMap(gameView: GameView): void { - this.unitMap.clear(); - this.trailIds.length = 0; - for (const u of gameView.units()) { - this.unitMap.set(u.id(), toUnitState(u)); - if (TRAIL_TYPES.has(u.type())) { - this.trailIds.push(u.id()); - } - } - } - - private applyRailroads(gu: GameUpdateViewData): void { - this.railroadCache.apply(gu); - if (this.railroadCache.railroadDirty) { - this.view.uploadRailroadState(this.railroadCache.railroadState); - this.railroadCache.clearDirty(); - } - if (this.railroadCache.revealedRailTiles.length > 0) { - this.view.applyRailroadDust(this.railroadCache.revealedRailTiles); - } + uploadFrameData(this.view, gameView.frameData()); } private syncPlayers(gameView: GameView): void { @@ -142,14 +39,8 @@ export class WebGLFrameBuilder { this.writePaletteEntry(smallID, p.territoryColor(), p.borderColor()); newPlayers.push({ - smallID, - id: p.id(), - name: p.name(), - displayName: p.displayName(), - clientID: p.clientID(), - playerType: gamePlayerTypeToEnum(p.type()), - team: p.team() ?? null, - isLobbyCreator: p.isLobbyCreator(), + ...p.static, + flag: p.cosmetics.flag, color: p.territoryColor().toHex(), }); } @@ -177,71 +68,4 @@ export class WebGLFrameBuilder { this.palette[borderOff + 2] = borderRgba.b / 255; this.palette[borderOff + 3] = 1.0; } - - private fillTileState(gameView: GameView): void { - const w = this.mapW; - const h = this.mapH; - const buf = this.tileState; - for (let y = 0; y < h; y++) { - for (let x = 0; x < w; x++) { - const ref = gameView.ref(x, y); - let v = gameView.ownerID(ref) & 0x0fff; - if (gameView.hasFallout(ref)) v |= 1 << 13; - buf[y * w + x] = v; - } - } - } -} - -function toUnitState(u: import("../core/game/GameView").UnitView): UnitState { - return { - id: u.id(), - unitType: u.type(), - ownerID: u.owner().smallID(), - lastOwnerID: null, - pos: u.tile(), - lastPos: u.lastTile(), - isActive: u.isActive(), - reachedTarget: u.reachedTarget(), - retreating: false, - targetable: u.targetable(), - markedForDeletion: u.markedForDeletion(), - health: u.hasHealth() ? u.health() : null, - underConstruction: u.isUnderConstruction(), - targetUnitId: u.targetUnitId() ?? null, - targetTile: u.targetTile() ?? null, - troops: u.troops(), - missileTimerQueue: u.missileTimerQueue(), - level: u.level(), - hasTrainStation: u.hasTrainStation(), - trainType: trainTypeToNum(u.trainType()), - loaded: u.isLoaded() ?? null, - constructionStartTick: u.isUnderConstruction() ? u.createdAt() : null, - }; -} - -function trainTypeToNum(t: TrainType | undefined): number | null { - switch (t) { - case TrainType.Engine: - return RendererTrainType.Engine; - case TrainType.TailEngine: - return RendererTrainType.TailEngine; - case TrainType.Carriage: - return RendererTrainType.Carriage; - default: - return null; - } -} - -function gamePlayerTypeToEnum(t: PlayerType): PlayerTypeEnum { - switch (t) { - case PlayerType.Human: - return PlayerTypeEnum.Human; - case PlayerType.Bot: - return PlayerTypeEnum.Bot; - case PlayerType.Nation: - return PlayerTypeEnum.Nation; - default: - return PlayerTypeEnum.Bot; - } } diff --git a/src/client/graphics/layers/ChatModal.ts b/src/client/graphics/layers/ChatModal.ts index 23c0bbd4b..0237b17ac 100644 --- a/src/client/graphics/layers/ChatModal.ts +++ b/src/client/graphics/layers/ChatModal.ts @@ -277,7 +277,7 @@ export class ChatModal extends LitElement { console.log("Sent message:", sender); this.players = this.g .players() - .filter((p) => p.isAlive() && p.data.playerType !== PlayerType.Bot); + .filter((p) => p.isAlive() && p.type() !== PlayerType.Bot); this.recipient = recipient; this.sender = sender; @@ -311,7 +311,7 @@ export class ChatModal extends LitElement { if (sender && recipient) { this.players = this.g .players() - .filter((p) => p.isAlive() && p.data.playerType !== PlayerType.Bot); + .filter((p) => p.isAlive() && p.type() !== PlayerType.Bot); this.recipient = recipient; this.sender = sender; diff --git a/src/client/graphics/layers/PlayerPanel.ts b/src/client/graphics/layers/PlayerPanel.ts index 6a60675d2..abd86b74b 100644 --- a/src/client/graphics/layers/PlayerPanel.ts +++ b/src/client/graphics/layers/PlayerPanel.ts @@ -410,7 +410,7 @@ export class PlayerPanel extends LitElement implements Layer { } private getTraitorRemainingSeconds(player: PlayerView): number | null { - const ticksLeft = player.data.traitorRemainingTicks ?? 0; + const ticksLeft = player.getTraitorRemainingTicks(); if (!player.isTraitor() || ticksLeft <= 0) return null; return Math.ceil(ticksLeft / 10); // 10 ticks = 1 second } @@ -608,7 +608,7 @@ export class PlayerPanel extends LitElement implements Layer { ${translateText("player_panel.betrayals")}
- ${other.data.betrayals ?? 0} + ${other.betrayals()}
diff --git a/src/client/view/GameView.ts b/src/client/view/GameView.ts new file mode 100644 index 000000000..0e70bb85c --- /dev/null +++ b/src/client/view/GameView.ts @@ -0,0 +1,1085 @@ +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 { + 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/alliance-clusters"; +import { extractAttackRings } from "../render/frame/derive/attack-rings"; +import { extractNukeTelegraphs } from "../render/frame/derive/nuke-telegraphs"; +import { computePlayerStatus } from "../render/frame/derive/player-status"; +import { buildRelationMatrix } from "../render/frame/derive/relation-matrix"; +import { RailroadCache } from "../render/frame/railroad-cache"; +import { TrailManager } from "../render/frame/trail-manager"; +import type { FrameData, NameEntry, TilePair } from "../render/types"; +import { STRUCTURE_TYPES } from "../render/types"; +import { PlayerView } from "./PlayerView"; +import { UnitView } from "./UnitView"; + +const TRAIL_TYPES: ReadonlySet = new Set([ + 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(); + private _players = new Map(); + private _units = new Map(); + /** + * 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(); + private updatedTiles: TileRef[] = []; + private updatedTerrainTiles: TileRef[] = []; + + // ── FrameData accumulators (renderer-bound state) ───────────────────── + private trailManager!: TrailManager; + private railroadCache!: RailroadCache; + /** Long-lived NameEntry map for the renderer's NamePass. */ + private _names = new Map(); + /** Reusable scratch buffers for per-tick deltas. */ + private readonly _changedTilesScratch: TilePair[] = []; + 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; + + private unitGrid: UnitGrid; + private unitMotionPlans = new Map< + number, + { + planId: number; + startTick: number; + ticksPerStep: number; + path: Uint32Array; + } + >(); + private trainMotionPlans = new Map(); + private trainUnitToEngine = new Map(); + + private toDelete = new Set(); + + private _cosmetics: Map = 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.); some (_changedTilesScratch, + // derived arrays) are reused each tick. Properties marked `readonly` on + // FrameData only prevent reassignment, not mutation through the reference. + // events: fresh arrays we own; cleared and repopulated each tick. (Don't + // spread EMPTY_FRAME_EVENTS — that would share the module-level arrays.) + 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: [], + unitUpdates: [], + playerUpdates: [], + allianceFormed: [], + allianceBroken: [], + allianceExpired: [], + embargoEvents: [], + targetEvents: [], + bonusEvents: [], + nukeIncoming: [], + emojis: [], + displayMessages: [], + wins: [], + gamePaused: null, + }, + changedTiles: this._changedTilesScratch, + 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, + allianceClusters: new Map(), + nukeTelegraphs: [], + attackRings: [], + structuresDirty: false, + tileMode: "live", + }; + } + + 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) { + 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, + ); + + // Pass 1: ensure every player exists with up-to-date PlayerState. We need + // all smallIDs registered before pass 2 can translate embargo PlayerIDs. + gu.updates[GameUpdateType.Player].forEach((pu) => { + // Replace the local player's name/displayName with their own stored values. + // This way the user does not know they are being censored. + if (pu.clientID === this._myClientID) { + pu.name = this._myUsername; + pu.displayName = myDisplayName; + } + + this.smallIDToID.set(pu.smallID, pu.id); + let player = this._players.get(pu.id); + if (player !== undefined) { + player.applyUpdate(pu); + const nextNameData = gu.playerNameViewData[pu.id]; + if (nextNameData !== undefined) { + player.nameData = nextNameData; + } + } else { + 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); + } + }); + + // Pass 2: translate engine embargoes (Set) → renderer-format + // stringified smallIDs. We could do this only on changes, but embargo sets + // are typically small (<50 entries per player). Pass through all in case + // any pu in this tick referenced a player created in this same tick. + gu.updates[GameUpdateType.Player].forEach((pu) => { + const player = this._players.get(pu.id); + if (player === undefined) return; + const smallIDStrings: string[] = []; + for (const otherPlayerID of pu.embargoes) { + const otherPV = this._players.get(otherPlayerID); + if (otherPV !== undefined) { + smallIDStrings.push(String(otherPV.smallID())); + } + } + player.setEmbargoSmallIDs(smallIDStrings); + }); + + if (this._myClientID) { + this._myPlayer ??= this.playerByClientID(this._myClientID); + } + + for (const unit of this._units.values()) { + unit._wasUpdated = false; + 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: level changed, became + // inactive, or finished construction (underConstruction → !underConstruction). + if ( + isStructure && + (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, + this._trailIdsScratch, + ); + + // Changed-tile delta refs (zero-copy: state field unused in live mode). + this._changedTilesScratch.length = 0; + for (let i = 0; i < this.updatedTiles.length; i++) { + this._changedTilesScratch.push({ ref: this.updatedTiles[i], state: 0 }); + } + + // Names map — rebuilt every tick. Cheap (one entry per player, no big + // arrays). Entry order is irrelevant for the renderer. + 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); + const rel = buildRelationMatrix(this._playerStates); + f.relationMatrix = rel.matrix; + f.relationSize = rel.size; + f.allianceClusters = computeAllianceClusters(this._playerStates); + f.nukeTelegraphs = extractNukeTelegraphs( + this._unitStates, + this._map.width(), + ); + f.attackRings = extractAttackRings(this._unitStates, this._map.width()); + 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 { + f.changedTiles = this._changedTilesScratch; + } + + // 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, + }); + } + for (const c of gu.updates[GameUpdateType.ConquestEvent] ?? []) { + const conquered = this._players.get(c.conqueredId); + if (conquered === undefined) continue; + const loc = conquered.nameLocation(); + 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; + } + + // 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()); + } + + 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()), + ); + } + 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 { + 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 { + 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); + } + 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); + } + isWater(ref: TileRef): boolean { + return this._map.isWater(ref); + } + isLake(ref: TileRef): boolean { + return this._map.isLake(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 { + return this._map.circleSearch(tile, radius, filter); + } + bfs( + tile: TileRef, + filter: (gm: GameMap, tile: TileRef) => boolean, + ): Set { + 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(); + } +} diff --git a/src/client/view/PlayerView.ts b/src/client/view/PlayerView.ts new file mode 100644 index 000000000..6c2272d99 --- /dev/null +++ b/src/client/view/PlayerView.ts @@ -0,0 +1,576 @@ +import { Colord, colord } from "colord"; +import { base64url } from "jose"; +import { ColorPalette } from "../../core/CosmeticSchemas"; +import { PatternDecoder } from "../../core/PatternDecoder"; +import { ClientID, PlayerCosmetics } from "../../core/Schemas"; +import { createRandomName } from "../../core/Util"; +import { + BuildableUnit, + Cell, + EmojiMessage, + Gold, + NameViewData, + PlayerActions, + PlayerBorderTiles, + PlayerBuildableUnitType, + PlayerID, + PlayerProfile, + PlayerType, + Team, + Tick, + UnitType, +} from "../../core/game/Game"; +import { TileRef } from "../../core/game/GameMap"; +import { + AllianceView, + AttackUpdate, + PlayerUpdate, +} from "../../core/game/GameUpdates"; +import { UserSettings } from "../../core/game/UserSettings"; +import { PlayerState, PlayerStatic, PlayerTypeEnum } from "../render/types"; +import { GameView } from "./GameView"; +import { UnitView } from "./UnitView"; + +const userSettings: UserSettings = new UserSettings(); + +const FRIENDLY_TINT_TARGET = { r: 0, g: 255, b: 0, a: 1 }; +const EMBARGO_TINT_TARGET = { r: 255, g: 0, b: 0, a: 1 }; +const BORDER_TINT_RATIO = 0.35; + +function gamePlayerTypeToEnum(t: PlayerType): PlayerTypeEnum { + switch (t) { + case PlayerType.Human: + return PlayerTypeEnum.Human; + case PlayerType.Bot: + return PlayerTypeEnum.Bot; + case PlayerType.Nation: + return PlayerTypeEnum.Nation; + default: + return PlayerTypeEnum.Bot; + } +} + +function staticFromUpdate(pu: PlayerUpdate): PlayerStatic { + return { + smallID: pu.smallID, + id: pu.id, + name: pu.name, + displayName: pu.displayName, + clientID: pu.clientID, + playerType: gamePlayerTypeToEnum(pu.playerType), + team: pu.team ?? null, + isLobbyCreator: pu.isLobbyCreator, + }; +} + +function stateFromUpdate(pu: PlayerUpdate): PlayerState { + // embargoes: Set on the wire, but the renderer expects + // stringified smallIDs. GameView fills these in via setEmbargoes() because + // it has the PlayerID → smallID lookup table. + return { + smallID: pu.smallID, + isAlive: pu.isAlive, + isDisconnected: pu.isDisconnected, + tilesOwned: pu.tilesOwned, + gold: Number(pu.gold), + troops: pu.troops, + isTraitor: pu.isTraitor, + traitorRemainingTicks: Math.max(0, pu.traitorRemainingTicks ?? 0), + betrayals: pu.betrayals, + hasSpawned: pu.hasSpawned, + lastDeleteUnitTick: pu.lastDeleteUnitTick, + allies: pu.allies.slice(), + embargoes: [], + targets: pu.targets.slice(), + outgoingAttacks: pu.outgoingAttacks, + incomingAttacks: pu.incomingAttacks, + outgoingAllianceRequests: pu.outgoingAllianceRequests.slice(), + alliances: pu.alliances, + outgoingEmojis: pu.outgoingEmojis, + }; +} + +function applyStateUpdate(target: PlayerState, pu: PlayerUpdate): void { + // smallID is identity — never changes for a given PlayerView. + target.isAlive = pu.isAlive; + target.isDisconnected = pu.isDisconnected; + target.tilesOwned = pu.tilesOwned; + target.gold = Number(pu.gold); + target.troops = pu.troops; + target.isTraitor = pu.isTraitor; + target.traitorRemainingTicks = Math.max(0, pu.traitorRemainingTicks ?? 0); + target.betrayals = pu.betrayals; + target.hasSpawned = pu.hasSpawned; + target.lastDeleteUnitTick = pu.lastDeleteUnitTick; + // Slice() to detach from the wire object — accumulated state mustn't share + // mutable arrays with per-tick update payloads. + target.allies = pu.allies.slice(); + target.targets = pu.targets.slice(); + target.outgoingAllianceRequests = pu.outgoingAllianceRequests.slice(); + target.outgoingAttacks = pu.outgoingAttacks; + target.incomingAttacks = pu.incomingAttacks; + target.alliances = pu.alliances; + target.outgoingEmojis = pu.outgoingEmojis; +} + +export class PlayerView { + public anonymousName: string | null = null; + private decoder?: PatternDecoder; + + /** Long-lived renderer state — mutated in place by applyUpdate(). */ + public state: PlayerState; + /** Static header data — set once at construction, never mutated. */ + public static: PlayerStatic; + + private _territoryColor: Colord; + private _borderColor: Colord; + // Update here to include structure light and dark colors + private _structureColors: { light: Colord; dark: Colord }; + + // Pre-computed border color variants + private _borderColorNeutral: Colord; + private _borderColorFriendly: Colord; + private _borderColorEmbargo: Colord; + private _borderColorDefendedNeutral: { light: Colord; dark: Colord }; + private _borderColorDefendedFriendly: { light: Colord; dark: Colord }; + private _borderColorDefendedEmbargo: { light: Colord; dark: Colord }; + + constructor( + private game: GameView, + data: PlayerUpdate, + public nameData: NameViewData, + public cosmetics: PlayerCosmetics, + ) { + this.state = stateFromUpdate(data); + this.static = staticFromUpdate(data); + + if (data.clientID === game.myClientID()) { + this.anonymousName = data.name; + } else { + this.anonymousName = createRandomName(data.name, data.playerType); + } + + const theme = this.game.config().theme(); + + const defaultTerritoryColor = theme.territoryColor(this); + const defaultBorderColor = theme.borderColor(defaultTerritoryColor); + + const pattern = userSettings.territoryPatterns() + ? this.cosmetics.pattern + : undefined; + if (pattern) { + pattern.colorPalette ??= { + name: "", + primaryColor: defaultTerritoryColor.toHex(), + secondaryColor: defaultBorderColor.toHex(), + } satisfies ColorPalette; + } + + if (this.team() === null) { + this._territoryColor = colord( + this.cosmetics.color?.color ?? + pattern?.colorPalette?.primaryColor ?? + defaultTerritoryColor.toHex(), + ); + } else { + this._territoryColor = defaultTerritoryColor; + } + + this._structureColors = theme.structureColors(this._territoryColor); + + const maybeFocusedBorderColor = + this.game.myClientID() === data.clientID + ? theme.focusedBorderColor() + : defaultBorderColor; + + this._borderColor = new Colord( + pattern?.colorPalette?.secondaryColor ?? + this.cosmetics.color?.color ?? + maybeFocusedBorderColor.toHex(), + ); + + const baseRgb = this._borderColor.toRgb(); + + this._borderColorNeutral = this._borderColor; + + this._borderColorFriendly = colord({ + r: Math.round( + baseRgb.r * (1 - BORDER_TINT_RATIO) + + FRIENDLY_TINT_TARGET.r * BORDER_TINT_RATIO, + ), + g: Math.round( + baseRgb.g * (1 - BORDER_TINT_RATIO) + + FRIENDLY_TINT_TARGET.g * BORDER_TINT_RATIO, + ), + b: Math.round( + baseRgb.b * (1 - BORDER_TINT_RATIO) + + FRIENDLY_TINT_TARGET.b * BORDER_TINT_RATIO, + ), + a: baseRgb.a, + }); + + this._borderColorEmbargo = colord({ + r: Math.round( + baseRgb.r * (1 - BORDER_TINT_RATIO) + + EMBARGO_TINT_TARGET.r * BORDER_TINT_RATIO, + ), + g: Math.round( + baseRgb.g * (1 - BORDER_TINT_RATIO) + + EMBARGO_TINT_TARGET.g * BORDER_TINT_RATIO, + ), + b: Math.round( + baseRgb.b * (1 - BORDER_TINT_RATIO) + + EMBARGO_TINT_TARGET.b * BORDER_TINT_RATIO, + ), + a: baseRgb.a, + }); + + this._borderColorDefendedNeutral = theme.defendedBorderColors( + this._borderColorNeutral, + ); + this._borderColorDefendedFriendly = theme.defendedBorderColors( + this._borderColorFriendly, + ); + this._borderColorDefendedEmbargo = theme.defendedBorderColors( + this._borderColorEmbargo, + ); + + this.decoder = + pattern === undefined + ? undefined + : new PatternDecoder(pattern, base64url.decode); + } + + /** + * Update mutable state in place. Called by GameView.update() each tick the + * player appears in the PlayerUpdate stream. + */ + applyUpdate(pu: PlayerUpdate): void { + applyStateUpdate(this.state, pu); + } + + /** Set the renderer-format embargoes (stringified smallIDs). */ + setEmbargoSmallIDs(smallIDStrings: string[]): void { + this.state.embargoes = smallIDStrings; + } + + territoryColor(tile?: TileRef): Colord { + if (tile === undefined || this.decoder === undefined) { + return this._territoryColor; + } + const isPrimary = this.decoder.isPrimary( + this.game.x(tile), + this.game.y(tile), + ); + return isPrimary ? this._territoryColor : this._borderColor; + } + + structureColors(): { light: Colord; dark: Colord } { + return this._structureColors; + } + + /** + * Border color for a tile: + * - Tints by neighbor relations (embargo → red, friendly → green, else neutral). + * - If defended, applies theme checkerboard to the tinted color. + */ + borderColor(tile?: TileRef, isDefended: boolean = false): Colord { + if (tile === undefined) { + return this._borderColor; + } + + const { hasEmbargo, hasFriendly } = this.borderRelationFlags(tile); + + let baseColor: Colord; + let defendedColors: { light: Colord; dark: Colord }; + + if (hasEmbargo) { + baseColor = this._borderColorEmbargo; + defendedColors = this._borderColorDefendedEmbargo; + } else if (hasFriendly) { + baseColor = this._borderColorFriendly; + defendedColors = this._borderColorDefendedFriendly; + } else { + baseColor = this._borderColorNeutral; + defendedColors = this._borderColorDefendedNeutral; + } + + if (!isDefended) { + return baseColor; + } + + const x = this.game.x(tile); + const y = this.game.y(tile); + const lightTile = + (x % 2 === 0 && y % 2 === 0) || (y % 2 === 1 && x % 2 === 1); + return lightTile ? defendedColors.light : defendedColors.dark; + } + + /** + * Border relation flags for a tile, used by both CPU and WebGL renderers. + */ + borderRelationFlags(tile: TileRef): { + hasEmbargo: boolean; + hasFriendly: boolean; + } { + const mySmallID = this.smallID(); + let hasEmbargo = false; + let hasFriendly = false; + + for (const n of this.game.neighbors(tile)) { + if (!this.game.hasOwner(n)) { + continue; + } + + const otherOwner = this.game.owner(n); + if (!otherOwner.isPlayer() || otherOwner.smallID() === mySmallID) { + continue; + } + + if (this.hasEmbargo(otherOwner)) { + hasEmbargo = true; + break; + } + + if (this.isFriendly(otherOwner) || otherOwner.isFriendly(this)) { + hasFriendly = true; + } + } + return { hasEmbargo, hasFriendly }; + } + + async actions( + tile?: TileRef, + units?: readonly PlayerBuildableUnitType[] | null, + ): Promise { + return this.game.worker.playerInteraction( + this.id(), + tile && this.game.x(tile), + tile && this.game.y(tile), + units, + ); + } + + async buildables( + tile?: TileRef, + units?: readonly PlayerBuildableUnitType[], + ): Promise { + return this.game.worker.playerBuildables( + this.id(), + tile && this.game.x(tile), + tile && this.game.y(tile), + units, + ); + } + + async borderTiles(): Promise { + return this.game.worker.playerBorderTiles(this.id()); + } + + outgoingAttacks(): AttackUpdate[] { + return this.state.outgoingAttacks; + } + + incomingAttacks(): AttackUpdate[] { + return this.state.incomingAttacks; + } + + async attackClusteredPositions( + attackID?: string, + ): Promise<{ id: string; positions: Cell[] }[]> { + return this.game.worker.attackClusteredPositions(this.smallID(), attackID); + } + + units(...types: UnitType[]): UnitView[] { + return this.game + .units(...types) + .filter((u) => u.owner().smallID() === this.smallID()); + } + + nameLocation(): NameViewData { + return this.nameData; + } + + smallID(): number { + return this.state.smallID; + } + + name(): string { + return this.anonymousName !== null && userSettings.anonymousNames() + ? this.anonymousName + : this.static.name; + } + displayName(): string { + return this.anonymousName !== null && userSettings.anonymousNames() + ? this.anonymousName + : this.static.displayName; + } + + clientID(): ClientID | null { + return this.static.clientID; + } + id(): PlayerID { + return this.static.id; + } + team(): Team | null { + return this.static.team; + } + type(): PlayerType { + // Map PlayerStatic's numeric enum back to engine string enum. + switch (this.static.playerType) { + case PlayerTypeEnum.Human: + return PlayerType.Human; + case PlayerTypeEnum.Bot: + return PlayerType.Bot; + case PlayerTypeEnum.Nation: + return PlayerType.Nation; + default: + return PlayerType.Bot; + } + } + isAlive(): boolean { + return this.state.isAlive; + } + isPlayer(): this is PlayerView { + return true; + } + numTilesOwned(): number { + return this.state.tilesOwned; + } + allies(): PlayerView[] { + return this.state.allies.map( + (a) => this.game.playerBySmallID(a) as PlayerView, + ); + } + targets(): PlayerView[] { + return this.state.targets.map( + (id) => this.game.playerBySmallID(id) as PlayerView, + ); + } + gold(): Gold { + // Engine Gold is bigint; renderer state stores number. Convert back at the + // accessor for game-code that still expects bigint semantics. + return BigInt(this.state.gold); + } + + troops(): number { + return this.state.troops; + } + + totalUnitLevels(type: UnitType): number { + return this.units(type) + .filter((unit) => !unit.isUnderConstruction()) + .map((unit) => unit.level()) + .reduce((a, b) => a + b, 0); + } + + isMe(): boolean { + return this.smallID() === this.game.myPlayer()?.smallID(); + } + + isLobbyCreator(): boolean { + return this.static.isLobbyCreator; + } + + isAlliedWith(other: PlayerView): boolean { + return this.state.allies.some((n) => other.smallID() === n); + } + + isOnSameTeam(other: PlayerView): boolean { + return this.static.team !== null && this.static.team === other.static.team; + } + + isFriendly(other: PlayerView): boolean { + return this.isAlliedWith(other) || this.isOnSameTeam(other); + } + + isRequestingAllianceWith(other: PlayerView) { + return this.state.outgoingAllianceRequests.some((id) => other.id() === id); + } + + alliances(): AllianceView[] { + return this.state.alliances; + } + + hasEmbargoAgainst(other: PlayerView): boolean { + const otherSmallIDStr = String(other.smallID()); + return this.state.embargoes.includes(otherSmallIDStr); + } + + hasEmbargo(other: PlayerView): boolean { + return this.hasEmbargoAgainst(other) || other.hasEmbargoAgainst(this); + } + + profile(): Promise { + return this.game.worker.playerProfile(this.smallID()); + } + + bestTransportShipSpawn(targetTile: TileRef): Promise { + return this.game.worker.transportShipSpawn(this.id(), targetTile); + } + + transitiveTargets(): PlayerView[] { + const result: PlayerView[] = []; + + // Add own targets + for (const id of this.state.targets) { + result.push(this.game.playerBySmallID(id) as PlayerView); + } + + // Add allies' targets + for (const allyID of this.state.allies) { + const ally = this.game.playerBySmallID(allyID) as PlayerView; + for (const targetId of ally.state.targets) { + result.push(this.game.playerBySmallID(targetId) as PlayerView); + } + } + + // Add teammates' targets + const myTeam = this.static.team; + if (myTeam !== null) { + for (const p of this.game.playerViews()) { + if (p !== this && p.static.team === myTeam) { + for (const targetId of p.state.targets) { + result.push(this.game.playerBySmallID(targetId) as PlayerView); + } + } + } + } + + return result; + } + + isTraitor(): boolean { + return this.state.isTraitor; + } + getTraitorRemainingTicks(): number { + return this.state.traitorRemainingTicks; + } + betrayals(): number { + return this.state.betrayals; + } + outgoingEmojis(): EmojiMessage[] { + return this.state.outgoingEmojis; + } + + hasSpawned(): boolean { + return this.state.hasSpawned; + } + isDisconnected(): boolean { + return this.state.isDisconnected; + } + + lastDeleteUnitTick(): Tick { + return this.state.lastDeleteUnitTick; + } + + deleteUnitCooldown(): number { + return ( + Math.max( + 0, + this.game.config().deleteUnitCooldown() - + (this.game.ticks() + 1 - this.lastDeleteUnitTick()), + ) / 10 + ); + } +} diff --git a/src/client/view/UnitView.ts b/src/client/view/UnitView.ts new file mode 100644 index 000000000..46b2c24b0 --- /dev/null +++ b/src/client/view/UnitView.ts @@ -0,0 +1,280 @@ +import { + Tick, + TrainType, + TransportShipState, + UnitType, + WarshipState, +} from "../../core/game/Game"; +import { TileRef } from "../../core/game/GameMap"; +import { UnitUpdate } from "../../core/game/GameUpdates"; +import type { UnitState } from "../render/types"; +import { TrainType as RendererTrainType } from "../render/types"; +import { GameView } from "./GameView"; +import { PlayerView } from "./PlayerView"; + +/** + * Convert engine TrainType (string enum) to renderer's numeric encoding. + * UnitState uses 0/1/2 so it can be uploaded to GPU buffers without lookup. + */ +function trainTypeToNum(t: TrainType | undefined): number | null { + switch (t) { + case TrainType.Engine: + return RendererTrainType.Engine; + case TrainType.TailEngine: + return RendererTrainType.TailEngine; + case TrainType.Carriage: + return RendererTrainType.Carriage; + default: + return null; + } +} + +function numToTrainType(n: number | null): TrainType | undefined { + switch (n) { + case RendererTrainType.Engine: + return TrainType.Engine; + case RendererTrainType.TailEngine: + return TrainType.TailEngine; + case RendererTrainType.Carriage: + return TrainType.Carriage; + default: + return undefined; + } +} + +/** Build a fresh UnitState from an incoming UnitUpdate. */ +function unitStateFromUpdate(u: UnitUpdate): UnitState { + return { + id: u.id, + unitType: u.unitType, + ownerID: u.ownerID, + lastOwnerID: u.lastOwnerID ?? null, + pos: u.pos, + lastPos: u.lastPos, + isActive: u.isActive, + reachedTarget: u.reachedTarget, + retreating: u.transportShipState?.isRetreating ?? false, + targetable: u.targetable, + markedForDeletion: u.markedForDeletion, + health: u.health ?? null, + underConstruction: u.underConstruction ?? false, + targetUnitId: u.targetUnitId ?? null, + targetTile: u.targetTile ?? null, + troops: u.troops, + missileTimerQueue: u.missileTimerQueue, + level: u.level, + hasTrainStation: u.hasTrainStation, + trainType: trainTypeToNum(u.trainType), + loaded: u.loaded ?? null, + constructionStartTick: null, // GameView fills in createdAt when underConstruction + }; +} + +/** Mutate `target` in place from a UnitUpdate, avoiding any allocation. */ +function applyUpdateInPlace(target: UnitState, u: UnitUpdate): void { + target.ownerID = u.ownerID; + target.unitType = u.unitType; + target.lastOwnerID = u.lastOwnerID ?? null; + target.pos = u.pos; + target.lastPos = u.lastPos; + target.isActive = u.isActive; + target.reachedTarget = u.reachedTarget; + target.retreating = u.transportShipState?.isRetreating ?? false; + target.targetable = u.targetable; + target.markedForDeletion = u.markedForDeletion; + target.health = u.health ?? null; + target.underConstruction = u.underConstruction ?? false; + target.targetUnitId = u.targetUnitId ?? null; + target.targetTile = u.targetTile ?? null; + target.troops = u.troops; + target.missileTimerQueue = u.missileTimerQueue; + target.level = u.level; + target.hasTrainStation = u.hasTrainStation; + target.trainType = trainTypeToNum(u.trainType); + target.loaded = u.loaded ?? null; +} + +export class UnitView { + public _wasUpdated = true; + public lastPos: TileRef[] = []; + /** Long-lived renderer state — mutated in place by update(). */ + public state: UnitState; + /** Engine-only fields not in UnitState. Use warshipState() / transportShipState() to read. */ + private _warshipState?: WarshipState; + private _transportShipState?: TransportShipState; + private _createdAt: Tick; + + constructor( + private gameView: GameView, + data: UnitUpdate, + ) { + this.state = unitStateFromUpdate(data); + this._warshipState = data.warshipState; + this._transportShipState = data.transportShipState; + this.lastPos.push(data.pos); + this._createdAt = this.gameView.ticks(); + if (this.state.underConstruction) { + this.state.constructionStartTick = this._createdAt; + } + } + + createdAt(): Tick { + return this._createdAt; + } + + wasUpdated(): boolean { + return this._wasUpdated; + } + + lastTiles(): TileRef[] { + return this.lastPos; + } + + lastTile(): TileRef { + if (this.lastPos.length === 0) { + return this.state.pos; + } + return this.lastPos[0]; + } + + update(data: UnitUpdate) { + this.lastPos.push(data.pos); + this._wasUpdated = true; + const wasUnderConstruction = this.state.underConstruction; + applyUpdateInPlace(this.state, data); + this._warshipState = data.warshipState; + this._transportShipState = data.transportShipState; + // constructionStartTick: set on transition into underConstruction. + if (this.state.underConstruction && !wasUnderConstruction) { + this.state.constructionStartTick = this.gameView.ticks(); + } else if (!this.state.underConstruction) { + this.state.constructionStartTick = null; + } + } + + applyDerivedPosition(pos: TileRef) { + const prev = this.state.pos; + this.lastPos.push(pos); + this._wasUpdated = true; + this.state.lastPos = prev; + this.state.pos = pos; + } + + id(): number { + return this.state.id; + } + + targetable(): boolean { + return this.state.targetable; + } + + markedForDeletion(): number | false { + return this.state.markedForDeletion; + } + + type(): UnitType { + return this.state.unitType as UnitType; + } + troops(): number { + return this.state.troops; + } + warshipState(): WarshipState { + if (this._warshipState === undefined) { + throw new Error("warshipState called on non-warship unit"); + } + return this._warshipState; + } + updateWarshipState(_update: Partial): void { + throw new Error("updateWarshipState is not supported on UnitView"); + } + isInCombat(): boolean { + return this._warshipState?.isInCombat ?? false; + } + touch(): void { + throw new Error("touch is not supported on UnitView"); + } + transportShipState(): TransportShipState { + return this._transportShipState ?? { isRetreating: false, troops: 0 }; + } + updateTransportShipState( + _update: Pick, + ): void { + throw new Error("updateTransportShipState is not supported on UnitView"); + } + tile(): TileRef { + return this.state.pos; + } + owner(): PlayerView { + return this.gameView.playerBySmallID(this.state.ownerID)! as PlayerView; + } + isActive(): boolean { + return this.state.isActive; + } + reachedTarget(): boolean { + return this.state.reachedTarget; + } + hasHealth(): boolean { + return this.state.health !== null; + } + health(): number { + return this.state.health ?? 0; + } + isUnderConstruction(): boolean { + return this.state.underConstruction; + } + targetUnitId(): number | undefined { + return this.state.targetUnitId ?? undefined; + } + targetTile(): TileRef | undefined { + return this.state.targetTile ?? undefined; + } + + // How "ready" this unit is from 0 to 1. + missileReadinesss(): number { + const maxMissiles = this.state.level; + const missilesReloading = this.state.missileTimerQueue.length; + + if (missilesReloading === 0) { + return 1; + } + + const missilesReady = maxMissiles - missilesReloading; + + if (missilesReady === 0 && maxMissiles > 1) { + // Unless we have just one missile (level 1), + // show 0% readiness so user knows no missiles are ready. + return 0; + } + + let readiness = missilesReady / maxMissiles; + + const cooldownDuration = + this.state.unitType === UnitType.SAMLauncher + ? this.gameView.config().SAMCooldown() + : this.gameView.config().SiloCooldown(); + + for (const cooldown of this.state.missileTimerQueue) { + const cooldownProgress = this.gameView.ticks() - cooldown; + const cooldownRatio = cooldownProgress / cooldownDuration; + const adjusted = cooldownRatio / maxMissiles; + readiness += adjusted; + } + return readiness; + } + + level(): number { + return this.state.level; + } + hasTrainStation(): boolean { + return this.state.hasTrainStation; + } + trainType(): TrainType | undefined { + return numToTrainType(this.state.trainType); + } + isLoaded(): boolean | undefined { + return this.state.loaded ?? undefined; + } + missileTimerQueue(): number[] { + return this.state.missileTimerQueue; + } +} diff --git a/src/core/game/GameImpl.ts b/src/core/game/GameImpl.ts index 75435cda8..509ce5dd0 100644 --- a/src/core/game/GameImpl.ts +++ b/src/core/game/GameImpl.ts @@ -1174,6 +1174,9 @@ export class GameImpl implements Game { 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); } diff --git a/src/core/game/GameMap.ts b/src/core/game/GameMap.ts index 592d02ca4..be403dcf1 100644 --- a/src/core/game/GameMap.ts +++ b/src/core/game/GameMap.ts @@ -72,6 +72,20 @@ export interface GameMap { */ updateTile(tile: TileRef, state: number): boolean; + /** + * Direct access to the per-tile state buffer for zero-copy consumers + * (e.g. WebGL renderer uploading to a R16UI texture). + * + * The returned array is a live reference — it is mutated by `updateTile()` + * each tick. Callers must not write to it. + * + * The bit layout of each `uint16` matches the renderer's tile state: + * bits 0-11: ownerID + * bit 13: fallout + * bit 14: defense bonus + */ + tileStateBuffer(): Uint16Array; + numTilesWithFallout(): number; } @@ -401,6 +415,10 @@ export class GameMapImpl implements GameMap { return this.state[tile]; } + tileStateBuffer(): Uint16Array { + return this.state; + } + /** * Update a tile from a packed uint32: * bits 0-15: tile state (owner, fallout, etc.) diff --git a/src/core/game/GameView.ts b/src/core/game/GameView.ts index a84f17b6a..da408cfac 100644 --- a/src/core/game/GameView.ts +++ b/src/core/game/GameView.ts @@ -1,1417 +1,12 @@ -import { Colord, colord } from "colord"; -import { base64url } from "jose"; -import { Config } from "../configuration/Config"; -import { ColorPalette } from "../CosmeticSchemas"; -import { PatternDecoder } from "../PatternDecoder"; -import { ClientID, GameID, Player, PlayerCosmetics } from "../Schemas"; -import { createRandomName, formatPlayerDisplayName } from "../Util"; -import { WorkerClient } from "../worker/WorkerClient"; -import { - BuildableUnit, - Cell, - EmojiMessage, - GameUpdates, - Gold, - NameViewData, - PlayerActions, - PlayerBorderTiles, - PlayerBuildableUnitType, - PlayerID, - PlayerProfile, - PlayerType, - Team, - TerrainType, - TerraNullius, - Tick, - TrainType, - TransportShipState, - Unit, - UnitInfo, - UnitType, - WarshipState, -} from "./Game"; -import { GameMap, TileRef } from "./GameMap"; -import { - AllianceView, - AttackUpdate, - GameUpdateType, - GameUpdateViewData, - PlayerUpdate, - SpawnPhaseEndUpdate, - UnitUpdate, -} from "./GameUpdates"; -import { MotionPlanRecord, unpackMotionPlans } from "./MotionPlans"; -import { TerrainMapData } from "./TerrainMapLoader"; -import { TerraNulliusImpl } from "./TerraNulliusImpl"; -import { UnitGrid, UnitPredicate } from "./UnitGrid"; -import { UserSettings } from "./UserSettings"; - -const userSettings: UserSettings = new UserSettings(); - -const FRIENDLY_TINT_TARGET = { r: 0, g: 255, b: 0, a: 1 }; -const EMBARGO_TINT_TARGET = { r: 255, g: 0, b: 0, a: 1 }; -const BORDER_TINT_RATIO = 0.35; - -export class UnitView { - public _wasUpdated = true; - public lastPos: TileRef[] = []; - private _createdAt: Tick; - - constructor( - private gameView: GameView, - private data: UnitUpdate, - ) { - this.lastPos.push(data.pos); - this._createdAt = this.gameView.ticks(); - } - - createdAt(): Tick { - return this._createdAt; - } - - wasUpdated(): boolean { - return this._wasUpdated; - } - - lastTiles(): TileRef[] { - return this.lastPos; - } - - lastTile(): TileRef { - if (this.lastPos.length === 0) { - return this.data.pos; - } - return this.lastPos[0]; - } - - update(data: UnitUpdate) { - this.lastPos.push(data.pos); - this._wasUpdated = true; - this.data = data; - } - - applyDerivedPosition(pos: TileRef) { - const prev = this.data.pos; - this.lastPos.push(pos); - this._wasUpdated = true; - this.data = { - ...this.data, - lastPos: prev, - pos, - }; - } - - id(): number { - return this.data.id; - } - - targetable(): boolean { - return this.data.targetable; - } - - markedForDeletion(): number | false { - return this.data.markedForDeletion; - } - - type(): UnitType { - return this.data.unitType; - } - troops(): number { - return this.data.troops; - } - warshipState(): WarshipState { - if (this.data.warshipState === undefined) { - throw new Error("warshipState called on non-warship unit"); - } - return this.data.warshipState; - } - updateWarshipState(_update: Partial): void { - throw new Error("updateWarshipState is not supported on UnitView"); - } - isInCombat(): boolean { - return this.data.warshipState?.isInCombat ?? false; - } - touch(): void { - throw new Error("touch is not supported on UnitView"); - } - transportShipState(): TransportShipState { - return this.data.transportShipState ?? { isRetreating: false, troops: 0 }; - } - updateTransportShipState( - _update: Pick, - ): void { - throw new Error("updateTransportShipState is not supported on UnitView"); - } - tile(): TileRef { - return this.data.pos; - } - owner(): PlayerView { - return this.gameView.playerBySmallID(this.data.ownerID)! as PlayerView; - } - isActive(): boolean { - return this.data.isActive; - } - reachedTarget(): boolean { - return this.data.reachedTarget; - } - hasHealth(): boolean { - return this.data.health !== undefined; - } - health(): number { - return this.data.health ?? 0; - } - isUnderConstruction(): boolean { - return this.data.underConstruction === true; - } - targetUnitId(): number | undefined { - return this.data.targetUnitId; - } - targetTile(): TileRef | undefined { - return this.data.targetTile; - } - - // How "ready" this unit is from 0 to 1. - missileReadinesss(): number { - const maxMissiles = this.data.level; - const missilesReloading = this.data.missileTimerQueue.length; - - if (missilesReloading === 0) { - return 1; - } - - const missilesReady = maxMissiles - missilesReloading; - - if (missilesReady === 0 && maxMissiles > 1) { - // Unless we have just one missile (level 1), - // show 0% readiness so user knows no missiles are ready. - return 0; - } - - let readiness = missilesReady / maxMissiles; - - const cooldownDuration = - this.data.unitType === UnitType.SAMLauncher - ? this.gameView.config().SAMCooldown() - : this.gameView.config().SiloCooldown(); - - for (const cooldown of this.data.missileTimerQueue) { - const cooldownProgress = this.gameView.ticks() - cooldown; - const cooldownRatio = cooldownProgress / cooldownDuration; - const adjusted = cooldownRatio / maxMissiles; - readiness += adjusted; - } - return readiness; - } - - level(): number { - return this.data.level; - } - hasTrainStation(): boolean { - return this.data.hasTrainStation; - } - trainType(): TrainType | undefined { - return this.data.trainType; - } - isLoaded(): boolean | undefined { - return this.data.loaded; - } - missileTimerQueue(): number[] { - return this.data.missileTimerQueue; - } -} - -export class PlayerView { - public anonymousName: string | null = null; - private decoder?: PatternDecoder; - - private _territoryColor: Colord; - private _borderColor: Colord; - // Update here to include structure light and dark colors - private _structureColors: { light: Colord; dark: Colord }; - - // Pre-computed border color variants - private _borderColorNeutral: Colord; - private _borderColorFriendly: Colord; - private _borderColorEmbargo: Colord; - private _borderColorDefendedNeutral: { light: Colord; dark: Colord }; - private _borderColorDefendedFriendly: { light: Colord; dark: Colord }; - private _borderColorDefendedEmbargo: { light: Colord; dark: Colord }; - - constructor( - private game: GameView, - public data: PlayerUpdate, - public nameData: NameViewData, - public cosmetics: PlayerCosmetics, - ) { - if (data.clientID === game.myClientID()) { - this.anonymousName = this.data.name; - } else { - this.anonymousName = createRandomName( - this.data.name, - this.data.playerType, - ); - } - - const theme = this.game.config().theme(); - - const defaultTerritoryColor = theme.territoryColor(this); - const defaultBorderColor = theme.borderColor(defaultTerritoryColor); - - const pattern = userSettings.territoryPatterns() - ? this.cosmetics.pattern - : undefined; - if (pattern) { - pattern.colorPalette ??= { - name: "", - primaryColor: defaultTerritoryColor.toHex(), - secondaryColor: defaultBorderColor.toHex(), - } satisfies ColorPalette; - } - - if (this.team() === null) { - this._territoryColor = colord( - this.cosmetics.color?.color ?? - pattern?.colorPalette?.primaryColor ?? - defaultTerritoryColor.toHex(), - ); - } else { - this._territoryColor = defaultTerritoryColor; - } - - this._structureColors = theme.structureColors(this._territoryColor); - - const maybeFocusedBorderColor = - this.game.myClientID() === this.data.clientID - ? theme.focusedBorderColor() - : defaultBorderColor; - - this._borderColor = new Colord( - pattern?.colorPalette?.secondaryColor ?? - this.cosmetics.color?.color ?? - maybeFocusedBorderColor.toHex(), - ); - - // Pre-compute all border color variants once - const baseRgb = this._borderColor.toRgb(); - - // Neutral is just the base color - this._borderColorNeutral = this._borderColor; - - // Compute friendly tint - this._borderColorFriendly = colord({ - r: Math.round( - baseRgb.r * (1 - BORDER_TINT_RATIO) + - FRIENDLY_TINT_TARGET.r * BORDER_TINT_RATIO, - ), - g: Math.round( - baseRgb.g * (1 - BORDER_TINT_RATIO) + - FRIENDLY_TINT_TARGET.g * BORDER_TINT_RATIO, - ), - b: Math.round( - baseRgb.b * (1 - BORDER_TINT_RATIO) + - FRIENDLY_TINT_TARGET.b * BORDER_TINT_RATIO, - ), - a: baseRgb.a, - }); - - // Compute embargo tint - this._borderColorEmbargo = colord({ - r: Math.round( - baseRgb.r * (1 - BORDER_TINT_RATIO) + - EMBARGO_TINT_TARGET.r * BORDER_TINT_RATIO, - ), - g: Math.round( - baseRgb.g * (1 - BORDER_TINT_RATIO) + - EMBARGO_TINT_TARGET.g * BORDER_TINT_RATIO, - ), - b: Math.round( - baseRgb.b * (1 - BORDER_TINT_RATIO) + - EMBARGO_TINT_TARGET.b * BORDER_TINT_RATIO, - ), - a: baseRgb.a, - }); - - // Pre-compute defended variants - this._borderColorDefendedNeutral = theme.defendedBorderColors( - this._borderColorNeutral, - ); - this._borderColorDefendedFriendly = theme.defendedBorderColors( - this._borderColorFriendly, - ); - this._borderColorDefendedEmbargo = theme.defendedBorderColors( - this._borderColorEmbargo, - ); - - this.decoder = - pattern === undefined - ? undefined - : new PatternDecoder(pattern, base64url.decode); - } - - territoryColor(tile?: TileRef): Colord { - if (tile === undefined || this.decoder === undefined) { - return this._territoryColor; - } - const isPrimary = this.decoder.isPrimary( - this.game.x(tile), - this.game.y(tile), - ); - return isPrimary ? this._territoryColor : this._borderColor; - } - - structureColors(): { light: Colord; dark: Colord } { - return this._structureColors; - } - - /** - * Border color for a tile: - * - Tints by neighbor relations (embargo → red, friendly → green, else neutral). - * - If defended, applies theme checkerboard to the tinted color. - */ - borderColor(tile?: TileRef, isDefended: boolean = false): Colord { - if (tile === undefined) { - return this._borderColor; - } - - const { hasEmbargo, hasFriendly } = this.borderRelationFlags(tile); - - let baseColor: Colord; - let defendedColors: { light: Colord; dark: Colord }; - - if (hasEmbargo) { - baseColor = this._borderColorEmbargo; - defendedColors = this._borderColorDefendedEmbargo; - } else if (hasFriendly) { - baseColor = this._borderColorFriendly; - defendedColors = this._borderColorDefendedFriendly; - } else { - baseColor = this._borderColorNeutral; - defendedColors = this._borderColorDefendedNeutral; - } - - if (!isDefended) { - return baseColor; - } - - const x = this.game.x(tile); - const y = this.game.y(tile); - const lightTile = - (x % 2 === 0 && y % 2 === 0) || (y % 2 === 1 && x % 2 === 1); - return lightTile ? defendedColors.light : defendedColors.dark; - } - - /** - * Border relation flags for a tile, used by both CPU and WebGL renderers. - */ - borderRelationFlags(tile: TileRef): { - hasEmbargo: boolean; - hasFriendly: boolean; - } { - const mySmallID = this.smallID(); - let hasEmbargo = false; - let hasFriendly = false; - - for (const n of this.game.neighbors(tile)) { - if (!this.game.hasOwner(n)) { - continue; - } - - const otherOwner = this.game.owner(n); - if (!otherOwner.isPlayer() || otherOwner.smallID() === mySmallID) { - continue; - } - - if (this.hasEmbargo(otherOwner)) { - hasEmbargo = true; - break; - } - - if (this.isFriendly(otherOwner) || otherOwner.isFriendly(this)) { - hasFriendly = true; - } - } - return { hasEmbargo, hasFriendly }; - } - - async actions( - tile?: TileRef, - units?: readonly PlayerBuildableUnitType[] | null, - ): Promise { - return this.game.worker.playerInteraction( - this.id(), - tile && this.game.x(tile), - tile && this.game.y(tile), - units, - ); - } - - async buildables( - tile?: TileRef, - units?: readonly PlayerBuildableUnitType[], - ): Promise { - return this.game.worker.playerBuildables( - this.id(), - tile && this.game.x(tile), - tile && this.game.y(tile), - units, - ); - } - - async borderTiles(): Promise { - return this.game.worker.playerBorderTiles(this.id()); - } - - outgoingAttacks(): AttackUpdate[] { - return this.data.outgoingAttacks; - } - - incomingAttacks(): AttackUpdate[] { - return this.data.incomingAttacks; - } - - async attackClusteredPositions( - attackID?: string, - ): Promise<{ id: string; positions: Cell[] }[]> { - return this.game.worker.attackClusteredPositions(this.smallID(), attackID); - } - - units(...types: UnitType[]): UnitView[] { - return this.game - .units(...types) - .filter((u) => u.owner().smallID() === this.smallID()); - } - - nameLocation(): NameViewData { - return this.nameData; - } - - smallID(): number { - return this.data.smallID; - } - - name(): string { - return this.anonymousName !== null && userSettings.anonymousNames() - ? this.anonymousName - : this.data.name; - } - displayName(): string { - return this.anonymousName !== null && userSettings.anonymousNames() - ? this.anonymousName - : this.data.displayName; - } - - clientID(): ClientID | null { - return this.data.clientID; - } - id(): PlayerID { - return this.data.id; - } - team(): Team | null { - return this.data.team ?? null; - } - type(): PlayerType { - return this.data.playerType; - } - isAlive(): boolean { - return this.data.isAlive; - } - isPlayer(): this is PlayerView { - return true; - } - numTilesOwned(): number { - return this.data.tilesOwned; - } - allies(): PlayerView[] { - return this.data.allies.map( - (a) => this.game.playerBySmallID(a) as PlayerView, - ); - } - targets(): PlayerView[] { - return this.data.targets.map( - (id) => this.game.playerBySmallID(id) as PlayerView, - ); - } - gold(): Gold { - return this.data.gold; - } - - troops(): number { - return this.data.troops; - } - - totalUnitLevels(type: UnitType): number { - return this.units(type) - .filter((unit) => !unit.isUnderConstruction()) - .map((unit) => unit.level()) - .reduce((a, b) => a + b, 0); - } - - isMe(): boolean { - return this.smallID() === this.game.myPlayer()?.smallID(); - } - - isLobbyCreator(): boolean { - return this.data.isLobbyCreator; - } - - isAlliedWith(other: PlayerView): boolean { - return this.data.allies.some((n) => other.smallID() === n); - } - - isOnSameTeam(other: PlayerView): boolean { - return this.data.team !== undefined && this.data.team === other.data.team; - } - - isFriendly(other: PlayerView): boolean { - return this.isAlliedWith(other) || this.isOnSameTeam(other); - } - - isRequestingAllianceWith(other: PlayerView) { - return this.data.outgoingAllianceRequests.some((id) => other.id() === id); - } - - alliances(): AllianceView[] { - return this.data.alliances; - } - - hasEmbargoAgainst(other: PlayerView): boolean { - return this.data.embargoes.has(other.id()); - } - - hasEmbargo(other: PlayerView): boolean { - return this.hasEmbargoAgainst(other) || other.hasEmbargoAgainst(this); - } - - profile(): Promise { - return this.game.worker.playerProfile(this.smallID()); - } - - bestTransportShipSpawn(targetTile: TileRef): Promise { - return this.game.worker.transportShipSpawn(this.id(), targetTile); - } - - transitiveTargets(): PlayerView[] { - const result: PlayerView[] = []; - - // Add own targets - for (const id of this.data.targets) { - result.push(this.game.playerBySmallID(id) as PlayerView); - } - - // Add allies' targets - for (const allyID of this.data.allies) { - const ally = this.game.playerBySmallID(allyID) as PlayerView; - for (const targetId of ally.data.targets) { - result.push(this.game.playerBySmallID(targetId) as PlayerView); - } - } - - // Add teammates' targets - if (this.data.team !== undefined) { - for (const p of this.game.playerViews()) { - if (p !== this && p.data.team === this.data.team) { - for (const targetId of p.data.targets) { - result.push(this.game.playerBySmallID(targetId) as PlayerView); - } - } - } - } - - return result; - } - - isTraitor(): boolean { - return this.data.isTraitor; - } - getTraitorRemainingTicks(): number { - return Math.max(0, this.data.traitorRemainingTicks ?? 0); - } - outgoingEmojis(): EmojiMessage[] { - return this.data.outgoingEmojis; - } - - hasSpawned(): boolean { - return this.data.hasSpawned; - } - isDisconnected(): boolean { - return this.data.isDisconnected; - } - - lastDeleteUnitTick(): Tick { - return this.data.lastDeleteUnitTick; - } - - deleteUnitCooldown(): number { - return ( - Math.max( - 0, - this.game.config().deleteUnitCooldown() - - (this.game.ticks() + 1 - this.lastDeleteUnitTick()), - ) / 10 - ); - } -} - -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(); - private _players = new Map(); - private _units = new Map(); - private updatedTiles: TileRef[] = []; - private updatedTerrainTiles: TileRef[] = []; - - private _myPlayer: PlayerView | null = null; - - private unitGrid: UnitGrid; - private unitMotionPlans = new Map< - number, - { - planId: number; - startTick: number; - ticksPerStep: number; - path: Uint32Array; - } - >(); - private trainMotionPlans = new Map(); - private trainUnitToEngine = new Map(); - - private toDelete = new Set(); - - private _cosmetics: Map = 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); - } - } - - 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) { - this.toDelete.forEach((id) => this._units.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, - ); - - gu.updates[GameUpdateType.Player].forEach((pu) => { - // Replace the local player's name/displayName with their own stored values. - // This way the user does not know they are being censored. - if (pu.clientID === this._myClientID) { - pu.name = this._myUsername; - pu.displayName = myDisplayName; - } - - this.smallIDToID.set(pu.smallID, pu.id); - let player = this._players.get(pu.id); - if (player !== undefined) { - player.data = pu; - const nextNameData = gu.playerNameViewData[pu.id]; - if (nextNameData !== undefined) { - player.nameData = nextNameData; - } - } else { - 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); - } - }); - - if (this._myClientID) { - this._myPlayer ??= this.playerByClientID(this._myClientID); - } - - for (const unit of this._units.values()) { - unit._wasUpdated = false; - unit.lastPos = unit.lastPos.slice(-1); - } - gu.updates[GameUpdateType.Unit].forEach((update) => { - let unit = this._units.get(update.id); - if (unit !== undefined) { - unit.update(update); - } else { - unit = new UnitView(this, update); - this._units.set(update.id, unit); - this.unitGrid.addUnit(unit); - } - 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(); - } - - 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; - } - - // 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()); - } - - 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()), - ); - } - unit(id: number): UnitView | undefined { - return this._units.get(id); - } - unitInfo(type: UnitType): UnitInfo { - return this._config.unitInfo(type); - } - - 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); - } - 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); - } - isWater(ref: TileRef): boolean { - return this._map.isWater(ref); - } - isLake(ref: TileRef): boolean { - return this._map.isLake(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 { - return this._map.circleSearch(tile, radius, filter); - } - bfs( - tile: TileRef, - filter: (gm: GameMap, tile: TileRef) => boolean, - ): Set { - return this._map.bfs(tile, filter); - } - tileState(tile: TileRef): number { - return this._map.tileState(tile); - } - 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(); - } -} +// Back-compat re-export shim. +// The view classes physically live in src/client/view/ — this re-export keeps +// the older `import { GameView } from "src/core/game/GameView"` path working. +// +// TODO: remove this shim once all 50+ importers have been updated to point at +// src/client/view/ directly, and the 6 core files that reference PlayerView / +// UnitView / GameView as union types (Player | PlayerView etc.) are refactored +// to use Player / Unit / Game interfaces instead. + +export { GameView } from "../../client/view/GameView"; +export { PlayerView } from "../../client/view/PlayerView"; +export { UnitView } from "../../client/view/UnitView"; diff --git a/tests/client/view/GameView.test.ts b/tests/client/view/GameView.test.ts new file mode 100644 index 000000000..39347d4d8 --- /dev/null +++ b/tests/client/view/GameView.test.ts @@ -0,0 +1,474 @@ +/** + * GameView is the client-side simulation mirror — it accumulates player / + * unit / tile state from per-tick GameUpdateViewData. The FrameBuilder reads + * the same accessors (players(), units(), tileStateBuffer(), + * recentlyUpdatedTiles()) to translate state into FrameData each tick. + * + * These tests verify the update lifecycle: PlayerView reuse vs creation, + * UnitView lifecycle (create / mutate / mark for deletion / sweep next tick), + * smallID lookup, tick tracking, and tile delta accumulation. + */ + +import { describe, expect, it } from "vitest"; +import { UnitType } from "../../../src/core/game/Game"; +import { GameUpdateType } from "../../../src/core/game/GameUpdates"; +import { + makeEmptyGu, + makeGameView, + makeNameViewData, + makePlayerUpdate, + makeUnitUpdate, +} from "../../util/viewStubs"; + +function withPlayers( + tick: number, + players: ReturnType[], + nameDataMap: Record> = {}, +) { + const gu = makeEmptyGu(tick); + gu.updates[GameUpdateType.Player] = players; + for (const p of players) { + gu.playerNameViewData[p.id] = nameDataMap[p.id] ?? makeNameViewData(); + } + return gu; +} + +describe("GameView.update — players", () => { + it("creates a PlayerView for each player in the first tick", () => { + const game = makeGameView(); + game.update( + withPlayers(1, [ + makePlayerUpdate({ id: "alice", smallID: 1, name: "Alice" }), + makePlayerUpdate({ id: "bob", smallID: 2, name: "Bob" }), + ]), + ); + expect(game.players().map((p) => p.id())).toEqual(["alice", "bob"]); + }); + + it("reuses an existing PlayerView on subsequent updates (in-place data swap)", () => { + const game = makeGameView(); + game.update( + withPlayers(1, [ + makePlayerUpdate({ id: "alice", smallID: 1, troops: 100 }), + ]), + ); + const first = game.player("alice"); + + game.update( + withPlayers(2, [ + makePlayerUpdate({ id: "alice", smallID: 1, troops: 250 }), + ]), + ); + const second = game.player("alice"); + + expect(second).toBe(first); // same PlayerView instance + expect(second.troops()).toBe(250); // data was swapped in + }); + + it("playerBySmallID resolves through the smallID → PlayerID map", () => { + const game = makeGameView(); + game.update( + withPlayers(1, [ + makePlayerUpdate({ id: "alice", smallID: 1 }), + makePlayerUpdate({ id: "bob", smallID: 2 }), + ]), + ); + expect( + (game.playerBySmallID(1) as ReturnType).id(), + ).toBe("alice"); + expect( + (game.playerBySmallID(2) as ReturnType).id(), + ).toBe("bob"); + }); + + it("playerBySmallID(0) returns a TerraNullius (used as the unowned-tile owner)", () => { + const game = makeGameView(); + const terra = game.playerBySmallID(0); + expect(terra.isPlayer()).toBe(false); + }); + + it("myPlayer() is resolved once the local player update arrives", () => { + const game = makeGameView({ myClientID: "c-me" }); + expect(game.myPlayer()).toBeNull(); + + game.update( + withPlayers(1, [ + makePlayerUpdate({ + id: "me", + smallID: 1, + clientID: "c-me", + name: "Me", + }), + ]), + ); + expect(game.myPlayer()?.id()).toBe("me"); + }); + + it("myPlayer() is cached — does not change identity across updates", () => { + const game = makeGameView({ myClientID: "c-me" }); + game.update( + withPlayers(1, [ + makePlayerUpdate({ id: "me", smallID: 1, clientID: "c-me" }), + ]), + ); + const first = game.myPlayer(); + game.update( + withPlayers(2, [ + makePlayerUpdate({ id: "me", smallID: 1, clientID: "c-me" }), + ]), + ); + expect(game.myPlayer()).toBe(first); + }); + + it("local player's name is overridden with myUsername to bypass censorship", () => { + const game = makeGameView({ + myClientID: "c-me", + myUsername: "RealName", + }); + game.update( + withPlayers(1, [ + makePlayerUpdate({ + id: "me", + smallID: 1, + clientID: "c-me", + name: "ServerName", + displayName: "ServerName", + }), + ]), + ); + expect(game.myPlayer()?.name()).toBe("RealName"); + }); +}); + +describe("GameView.update — units", () => { + it("creates a UnitView on first sighting and reuses it after", () => { + const game = makeGameView(); + const gu1 = makeEmptyGu(1); + gu1.updates[GameUpdateType.Unit] = [makeUnitUpdate({ id: 42, pos: 0 })]; + game.update(gu1); + const first = game.unit(42); + expect(first).toBeDefined(); + + const gu2 = makeEmptyGu(2); + gu2.updates[GameUpdateType.Unit] = [makeUnitUpdate({ id: 42, pos: 1 })]; + game.update(gu2); + expect(game.unit(42)).toBe(first); // same instance + expect(game.unit(42)?.tile()).toBe(1); + }); + + it("units() filters by type and returns only active units", () => { + const game = makeGameView(); + const gu = makeEmptyGu(1); + gu.updates[GameUpdateType.Unit] = [ + makeUnitUpdate({ id: 1, unitType: UnitType.City, isActive: true }), + makeUnitUpdate({ id: 2, unitType: UnitType.Port, isActive: true }), + makeUnitUpdate({ id: 3, unitType: UnitType.City, isActive: false }), + ]; + game.update(gu); + + expect( + game + .units() + .map((u) => u.id()) + .sort(), + ).toEqual([1, 2]); + expect(game.units(UnitType.City).map((u) => u.id())).toEqual([1]); + // The inactive one is still present until the NEXT tick sweeps it. + expect(game.unit(3)).toBeDefined(); + }); + + it("inactive units are deleted on the following tick", () => { + const game = makeGameView(); + + const gu1 = makeEmptyGu(1); + gu1.updates[GameUpdateType.Unit] = [ + makeUnitUpdate({ id: 7, isActive: true }), + ]; + game.update(gu1); + expect(game.unit(7)).toBeDefined(); + + const gu2 = makeEmptyGu(2); + gu2.updates[GameUpdateType.Unit] = [ + makeUnitUpdate({ id: 7, isActive: false }), + ]; + game.update(gu2); + // Still present on the tick they died (renderer can see deadUnit FX). + expect(game.unit(7)).toBeDefined(); + + const gu3 = makeEmptyGu(3); + game.update(gu3); + // Swept on the next tick. + expect(game.unit(7)).toBeUndefined(); + }); + + it("_wasUpdated resets to false at start of tick, then flips back on update", () => { + const game = makeGameView(); + + const gu1 = makeEmptyGu(1); + gu1.updates[GameUpdateType.Unit] = [makeUnitUpdate({ id: 5 })]; + game.update(gu1); + expect(game.unit(5)?.wasUpdated()).toBe(true); + + // Next tick — unit not in updates → wasUpdated should be false + game.update(makeEmptyGu(2)); + expect(game.unit(5)?.wasUpdated()).toBe(false); + + // Next tick — unit reappears → wasUpdated true again + const gu3 = makeEmptyGu(3); + gu3.updates[GameUpdateType.Unit] = [makeUnitUpdate({ id: 5 })]; + game.update(gu3); + expect(game.unit(5)?.wasUpdated()).toBe(true); + }); +}); + +describe("GameView.update — tile deltas", () => { + it("recentlyUpdatedTiles() reflects refs in packedTileUpdates", () => { + const game = makeGameView({ width: 4, height: 4 }); + const gu = makeEmptyGu(1); + // packedTileUpdates is [tileRef, packedState, tileRef, packedState, ...] + // packed state = (terrainByte << 16) | state — use 0 for both to keep tile + // terrain-stable; we're just exercising the delta accumulator. + gu.packedTileUpdates = new Uint32Array([2, 0, 5, 0, 9, 0]); + game.update(gu); + expect(game.recentlyUpdatedTiles().sort((a, b) => a - b)).toEqual([ + 2, 5, 9, + ]); + }); + + it("recentlyUpdatedTerrainTiles() only includes refs where terrain bytes changed", () => { + const game = makeGameView({ width: 4, height: 4 }); + // Tile 3 starts with terrain byte 0. Pack a new terrain byte (0x80 = land) + // for tile 3, and an unchanged terrain (0) for tile 7. + const gu = makeEmptyGu(1); + const TILE_3_PACKED = (0x80 << 16) | 0; // terrain changed + const TILE_7_PACKED = 0; // terrain unchanged + gu.packedTileUpdates = new Uint32Array([ + 3, + TILE_3_PACKED, + 7, + TILE_7_PACKED, + ]); + game.update(gu); + expect(game.recentlyUpdatedTiles().sort((a, b) => a - b)).toEqual([3, 7]); + expect(game.recentlyUpdatedTerrainTiles()).toEqual([3]); + }); + + it("resets deltas to empty arrays each tick", () => { + const game = makeGameView({ width: 4, height: 4 }); + const gu1 = makeEmptyGu(1); + gu1.packedTileUpdates = new Uint32Array([1, 0]); + game.update(gu1); + expect(game.recentlyUpdatedTiles().length).toBe(1); + + // Empty next tick → empty deltas + game.update(makeEmptyGu(2)); + expect(game.recentlyUpdatedTiles()).toEqual([]); + expect(game.recentlyUpdatedTerrainTiles()).toEqual([]); + }); +}); + +describe("GameView.update — tick & lifecycle", () => { + it("ticks() reflects the last update's tick", () => { + const game = makeGameView(); + expect(game.ticks()).toBe(0); // before any update + game.update(makeEmptyGu(42)); + expect(game.ticks()).toBe(42); + game.update(makeEmptyGu(43)); + expect(game.ticks()).toBe(43); + }); + + it("inSpawnPhase() is true until a SpawnPhaseEnd update flips it off", () => { + const game = makeGameView(); + expect(game.inSpawnPhase()).toBe(true); + game.update(makeEmptyGu(5)); + expect(game.inSpawnPhase()).toBe(true); + + const gu = makeEmptyGu(10); + gu.updates[GameUpdateType.SpawnPhaseEnd] = [ + { type: GameUpdateType.SpawnPhaseEnd, startTick: 10 } as ReturnType< + typeof makeEmptyGu + >["updates"][typeof GameUpdateType.SpawnPhaseEnd][number], + ]; + game.update(gu); + expect(game.inSpawnPhase()).toBe(false); + }); + + it("ticksSinceStart returns 0 during spawn phase, otherwise difference from startTick", () => { + const game = makeGameView(); + expect(game.ticksSinceStart()).toBe(0); // spawn phase + + const gu1 = makeEmptyGu(10); + gu1.updates[GameUpdateType.SpawnPhaseEnd] = [ + { type: GameUpdateType.SpawnPhaseEnd, startTick: 10 } as ReturnType< + typeof makeEmptyGu + >["updates"][typeof GameUpdateType.SpawnPhaseEnd][number], + ]; + game.update(gu1); + expect(game.ticksSinceStart()).toBe(0); // tick=10, start=10 + + game.update(makeEmptyGu(15)); + expect(game.ticksSinceStart()).toBe(5); + }); +}); + +describe("GameView — accessors used by FrameBuilder", () => { + it("width() / height() forward to the underlying map", () => { + const game = makeGameView({ width: 12, height: 8 }); + expect(game.width()).toBe(12); + expect(game.height()).toBe(8); + }); + + it("tileStateBuffer() returns a Uint16Array of width*height", () => { + const game = makeGameView({ width: 5, height: 4 }); + const buf = game.tileStateBuffer(); + expect(buf).toBeInstanceOf(Uint16Array); + expect(buf.length).toBe(20); + }); + + it("tileStateBuffer() is a live reference — mutated by update()", () => { + const game = makeGameView({ width: 4, height: 4 }); + const buf = game.tileStateBuffer(); + const gu = makeEmptyGu(1); + // Pack an owner ID into the low 12 bits of state for tile 6. + gu.packedTileUpdates = new Uint32Array([6, 0x123]); + game.update(gu); + expect(buf[6] & 0xfff).toBe(0x123); + }); + + it("player(id) throws for unknown players (matches FrameBuilder's expectation)", () => { + const game = makeGameView(); + expect(() => game.player("unknown")).toThrow(); + }); + + it("config() returns the same Config instance passed in", () => { + const game = makeGameView(); + expect(game.config()).toBe(game.config()); + }); +}); + +describe("GameView.frameData() — renderer contract", () => { + it("returns a stable object reference across ticks", () => { + const game = makeGameView(); + game.update(makeEmptyGu(1)); + const f1 = game.frameData(); + game.update(makeEmptyGu(2)); + const f2 = game.frameData(); + expect(f2).toBe(f1); + }); + + it("frame.tileState is === gameView.tileStateBuffer() (zero-copy)", () => { + const game = makeGameView({ width: 4, height: 4 }); + game.update(makeEmptyGu(1)); + expect(game.frameData().tileState).toBe(game.tileStateBuffer()); + }); + + it("frame.changedTiles is null on the first populate (signals full upload)", () => { + const game = makeGameView({ width: 4, height: 4 }); + const gu1 = makeEmptyGu(1); + gu1.packedTileUpdates = new Uint32Array([1, 0, 2, 0]); + game.update(gu1); + expect(game.frameData().changedTiles).toBeNull(); + }); + + it("frame.changedTiles becomes a delta array on subsequent populates", () => { + const game = makeGameView({ width: 4, height: 4 }); + game.update(makeEmptyGu(1)); + + const gu2 = makeEmptyGu(2); + gu2.packedTileUpdates = new Uint32Array([3, 0, 5, 0, 9, 0]); + game.update(gu2); + const ct = game.frameData().changedTiles; + expect(ct).not.toBeNull(); + expect(ct!.map((t) => t.ref).sort((a, b) => a - b)).toEqual([3, 5, 9]); + }); + + it("changedTiles scratch array is reused across ticks (no per-tick alloc)", () => { + const game = makeGameView({ width: 4, height: 4 }); + game.update(makeEmptyGu(1)); // first populate (changedTiles = null) + const gu2 = makeEmptyGu(2); + gu2.packedTileUpdates = new Uint32Array([1, 0]); + game.update(gu2); + const ct1 = game.frameData().changedTiles; + + const gu3 = makeEmptyGu(3); + gu3.packedTileUpdates = new Uint32Array([2, 0]); + game.update(gu3); + const ct2 = game.frameData().changedTiles; + + expect(ct2).toBe(ct1); // same array instance + }); + + it("frame.units is === gameView.unitStates() (same long-lived map)", () => { + const game = makeGameView(); + game.update(makeEmptyGu(1)); + expect(game.frameData().units).toBe(game.unitStates()); + }); + + it("frame.players is === gameView.playerStates() (same long-lived map)", () => { + const game = makeGameView(); + game.update(makeEmptyGu(1)); + expect(game.frameData().players).toBe(game.playerStates()); + }); + + it("frame.tick reflects the most recent gu.tick", () => { + const game = makeGameView(); + game.update(makeEmptyGu(42)); + expect(game.frameData().tick).toBe(42); + game.update(makeEmptyGu(43)); + expect(game.frameData().tick).toBe(43); + }); + + it("frame.events.deadUnits is populated from inactive Unit updates", () => { + const game = makeGameView(); + const gu = makeEmptyGu(1); + gu.updates[GameUpdateType.Unit] = [ + makeUnitUpdate({ id: 1, isActive: true, pos: 10 }), + makeUnitUpdate({ id: 2, isActive: false, pos: 20 }), + makeUnitUpdate({ id: 3, isActive: false, pos: 30 }), + ]; + game.update(gu); + const dead = game.frameData().events.deadUnits; + expect(dead.length).toBe(2); + expect(dead.map((d) => d.pos).sort((a, b) => a - b)).toEqual([20, 30]); + }); + + it("frame.events arrays are cleared each tick (no event leakage)", () => { + const game = makeGameView(); + const gu1 = makeEmptyGu(1); + gu1.updates[GameUpdateType.Unit] = [ + makeUnitUpdate({ id: 1, isActive: false }), + ]; + game.update(gu1); + expect(game.frameData().events.deadUnits.length).toBe(1); + + // Empty next tick → events cleared + game.update(makeEmptyGu(2)); + expect(game.frameData().events.deadUnits.length).toBe(0); + }); + + it("frame.events.deadUnits array is reused (same reference)", () => { + const game = makeGameView(); + game.update(makeEmptyGu(1)); + const a1 = game.frameData().events.deadUnits; + game.update(makeEmptyGu(2)); + expect(game.frameData().events.deadUnits).toBe(a1); + }); + + it("frame.tileMode is 'live'", () => { + const game = makeGameView(); + expect(game.frameData().tileMode).toBe("live"); + }); + + it("frame.structuresDirty is true on first populate (force initial upload)", () => { + const game = makeGameView(); + game.update(makeEmptyGu(1)); + expect(game.frameData().structuresDirty).toBe(true); + }); + + it("frame.structuresDirty resets between ticks when no structure changes", () => { + const game = makeGameView(); + game.update(makeEmptyGu(1)); + game.update(makeEmptyGu(2)); + expect(game.frameData().structuresDirty).toBe(false); + }); +}); diff --git a/tests/client/view/PlayerView.test.ts b/tests/client/view/PlayerView.test.ts new file mode 100644 index 000000000..4acea7a61 --- /dev/null +++ b/tests/client/view/PlayerView.test.ts @@ -0,0 +1,285 @@ +/** + * PlayerView is a thin accessor wrapping a PlayerUpdate record plus precomputed + * colors. Tests verify each accessor forwards the underlying data, that the + * color variants (neutral/friendly/embargo) are precomputed at construction, + * and that relation predicates (allied / same-team / friendly / embargo) match + * what the FrameBuilder relies on when populating PlayerState. + */ + +import { describe, expect, it } from "vitest"; +import { PlayerView } from "../../../src/client/view/PlayerView"; +import { PlayerType } from "../../../src/core/game/Game"; +import { GameUpdateType } from "../../../src/core/game/GameUpdates"; +import { + makeEmptyGu, + makeGameView, + makeNameViewData, + makePlayerUpdate, + makePlayerView, +} from "../../util/viewStubs"; + +describe("PlayerView accessors", () => { + it("forwards data fields", () => { + const p = makePlayerView({ + data: { + id: "player-a", + smallID: 7, + clientID: "client-a", + name: "Alice", + displayName: "Alice", + playerType: PlayerType.Human, + isAlive: true, + isDisconnected: false, + isLobbyCreator: true, + tilesOwned: 42, + gold: 999n, + troops: 250, + }, + }); + + expect(p.id()).toBe("player-a"); + expect(p.smallID()).toBe(7); + expect(p.clientID()).toBe("client-a"); + expect(p.name()).toBe("Alice"); + expect(p.displayName()).toBe("Alice"); + expect(p.type()).toBe(PlayerType.Human); + expect(p.isAlive()).toBe(true); + expect(p.isDisconnected()).toBe(false); + expect(p.isLobbyCreator()).toBe(true); + expect(p.numTilesOwned()).toBe(42); + expect(p.gold()).toBe(999n); + expect(p.troops()).toBe(250); + }); + + it("isPlayer() is always true", () => { + expect(makePlayerView().isPlayer()).toBe(true); + }); + + it("team() returns null when team is undefined on data", () => { + expect(makePlayerView({ data: { team: undefined } }).team()).toBeNull(); + }); + + it("team() forwards a set team", () => { + expect(makePlayerView({ data: { team: "red" } }).team()).toBe("red"); + }); + + it("isTraitor + getTraitorRemainingTicks forward, with min clamp at 0", () => { + const traitor = makePlayerView({ + data: { isTraitor: true, traitorRemainingTicks: 5 }, + }); + expect(traitor.isTraitor()).toBe(true); + expect(traitor.getTraitorRemainingTicks()).toBe(5); + + // Negative or missing → clamped to 0 + const expired = makePlayerView({ + data: { isTraitor: false, traitorRemainingTicks: -3 }, + }); + expect(expired.getTraitorRemainingTicks()).toBe(0); + + const missing = makePlayerView({ data: { isTraitor: false } }); + expect(missing.getTraitorRemainingTicks()).toBe(0); + }); + + it("nameLocation() returns nameData passed at construction", () => { + const nameData = makeNameViewData({ x: 12, y: 34, size: 20 }); + expect(makePlayerView({ nameData }).nameLocation()).toBe(nameData); + }); + + it("outgoingEmojis / outgoingAttacks / incomingAttacks / alliances forward arrays", () => { + const alliance = { + id: 1, + other: { id: "ally", smallID: 2 }, + createdAt: 0, + expiresAt: 100, + onlyOneAgreedToExtend: false, + } as unknown as ReturnType[number]; + const attack = { + attackerID: 1, + targetID: 0, + troops: 50, + id: "attack-a", + retreating: false, + } as unknown as ReturnType[number]; + const emoji = { + message: 0, + senderID: 1, + recipientID: 2, + createdAt: 0, + } as unknown as ReturnType[number]; + + const p = makePlayerView({ + data: { + alliances: [alliance], + outgoingAttacks: [attack], + incomingAttacks: [], + outgoingEmojis: [emoji], + }, + }); + + expect(p.alliances()).toEqual([alliance]); + expect(p.outgoingAttacks()).toEqual([attack]); + expect(p.incomingAttacks()).toEqual([]); + expect(p.outgoingEmojis()).toEqual([emoji]); + }); +}); + +describe("PlayerView colors", () => { + it("territoryColor() with no tile returns a Colord", () => { + const c = makePlayerView().territoryColor(); + expect(typeof c.toHex()).toBe("string"); + }); + + it("structureColors() returns precomputed light/dark", () => { + const colors = makePlayerView().structureColors(); + expect(colors).toHaveProperty("light"); + expect(colors).toHaveProperty("dark"); + }); + + it("borderColor() with no tile returns the base border color", () => { + const p = makePlayerView(); + const noTile = p.borderColor(); + // Same value should come back for repeat calls (cached). + expect(p.borderColor().toHex()).toBe(noTile.toHex()); + }); +}); + +describe("PlayerView relations", () => { + function pair( + aSmall: number, + bSmall: number, + opts: { + aAllies?: number[]; + aTeam?: string; + bTeam?: string; + // Embargoes are renderer-format: stringified smallIDs of the OTHER player. + aEmbargoSmallIDs?: string[]; + bEmbargoSmallIDs?: string[]; + aOutgoingReq?: string[]; + } = {}, + ) { + const a = makePlayerView({ + data: { + id: "a", + smallID: aSmall, + allies: opts.aAllies ?? [], + team: opts.aTeam, + outgoingAllianceRequests: opts.aOutgoingReq ?? [], + }, + }); + const b = makePlayerView({ + data: { + id: "b", + smallID: bSmall, + team: opts.bTeam, + }, + }); + if (opts.aEmbargoSmallIDs) a.setEmbargoSmallIDs(opts.aEmbargoSmallIDs); + if (opts.bEmbargoSmallIDs) b.setEmbargoSmallIDs(opts.bEmbargoSmallIDs); + return { a, b }; + } + + it("isAlliedWith() reflects ally smallIDs in data.allies", () => { + const { a, b } = pair(1, 2, { aAllies: [2] }); + expect(a.isAlliedWith(b)).toBe(true); + expect(b.isAlliedWith(a)).toBe(false); // b has no allies set + }); + + it("isOnSameTeam() compares data.team and treats undefined as no team", () => { + const same = pair(1, 2, { aTeam: "red", bTeam: "red" }); + const diff = pair(1, 2, { aTeam: "red", bTeam: "blue" }); + const noTeam = pair(1, 2); + expect(same.a.isOnSameTeam(same.b)).toBe(true); + expect(diff.a.isOnSameTeam(diff.b)).toBe(false); + // Two players with no team set should NOT count as same team. + expect(noTeam.a.isOnSameTeam(noTeam.b)).toBe(false); + }); + + it("isFriendly() = allied OR same team", () => { + const allied = pair(1, 2, { aAllies: [2] }); + expect(allied.a.isFriendly(allied.b)).toBe(true); + + const teammates = pair(1, 2, { aTeam: "red", bTeam: "red" }); + expect(teammates.a.isFriendly(teammates.b)).toBe(true); + + const strangers = pair(1, 2); + expect(strangers.a.isFriendly(strangers.b)).toBe(false); + }); + + it("hasEmbargoAgainst / hasEmbargo are symmetric on the second", () => { + // a embargoes b — by smallID (renderer format) + const aEmbargoesB = pair(1, 2, { aEmbargoSmallIDs: ["2"] }); + // One-way directional embargo from a + expect(aEmbargoesB.a.hasEmbargoAgainst(aEmbargoesB.b)).toBe(true); + expect(aEmbargoesB.b.hasEmbargoAgainst(aEmbargoesB.a)).toBe(false); + // Symmetric version is true from either side + expect(aEmbargoesB.a.hasEmbargo(aEmbargoesB.b)).toBe(true); + expect(aEmbargoesB.b.hasEmbargo(aEmbargoesB.a)).toBe(true); + }); + + it("isRequestingAllianceWith() reflects outgoingAllianceRequests", () => { + const { a, b } = pair(1, 2, { aOutgoingReq: ["b"] }); + expect(a.isRequestingAllianceWith(b)).toBe(true); + expect(b.isRequestingAllianceWith(a)).toBe(false); + }); +}); + +describe("PlayerView in a GameView context", () => { + it("allies() resolves smallIDs through the game's smallID → PlayerView map", () => { + // Build a GameView and feed it two players so allies() can resolve. + const game = makeGameView(); + const aliceUpdate = makePlayerUpdate({ + id: "alice", + smallID: 1, + clientID: "c-alice", + name: "Alice", + allies: [2], + }); + const bobUpdate = makePlayerUpdate({ + id: "bob", + smallID: 2, + clientID: "c-bob", + name: "Bob", + }); + + // Drive a tick through the GameView so it creates the PlayerViews and + // registers smallID lookups — that's the path FrameBuilder & PlayerView use. + const gu = makeEmptyGu(1); + gu.updates[GameUpdateType.Player] = [aliceUpdate, bobUpdate]; + gu.playerNameViewData = { + alice: makeNameViewData(), + bob: makeNameViewData(), + }; + game.update(gu); + + const alice = game.player("alice"); + const bob = game.player("bob"); + expect(alice.allies()).toEqual([bob]); + }); + + it("isMe() is true only for the player matching myClientID", () => { + const game = makeGameView({ myClientID: "c-me" }); + const me = makePlayerUpdate({ + id: "me", + smallID: 1, + clientID: "c-me", + name: "Me", + }); + const other = makePlayerUpdate({ + id: "other", + smallID: 2, + clientID: "c-other", + name: "Other", + }); + + const gu = makeEmptyGu(1); + gu.updates[GameUpdateType.Player] = [me, other]; + gu.playerNameViewData = { + me: makeNameViewData(), + other: makeNameViewData(), + }; + game.update(gu); + + expect(game.player("me").isMe()).toBe(true); + expect(game.player("other").isMe()).toBe(false); + }); +}); diff --git a/tests/client/view/UnitView.test.ts b/tests/client/view/UnitView.test.ts new file mode 100644 index 000000000..e5b8bf98c --- /dev/null +++ b/tests/client/view/UnitView.test.ts @@ -0,0 +1,258 @@ +/** + * UnitView is mostly a thin accessor over a UnitUpdate record. Tests verify + * each accessor returns the underlying data, that update() swaps the backing + * record, that lastPos tracking works as the simulation advances units, and + * that the trickier missile-readiness math is correct. + */ + +import { describe, expect, it } from "vitest"; +import { UnitView } from "../../../src/client/view/UnitView"; +import { + TrainType, + TransportShipState, + UnitType, + WarshipState, +} from "../../../src/core/game/Game"; +import { makeGameView, makeUnitUpdate, stubConfig } from "../../util/viewStubs"; + +describe("UnitView accessors", () => { + it("forwards data fields", () => { + const game = makeGameView(); + const u = new UnitView( + game, + makeUnitUpdate({ + id: 42, + unitType: UnitType.City, + ownerID: 7, + pos: 100, + lastPos: 99, + troops: 250, + level: 3, + hasTrainStation: true, + targetable: false, + markedForDeletion: false, + isActive: true, + reachedTarget: false, + }), + ); + + expect(u.id()).toBe(42); + expect(u.type()).toBe(UnitType.City); + expect(u.troops()).toBe(250); + expect(u.level()).toBe(3); + expect(u.hasTrainStation()).toBe(true); + expect(u.targetable()).toBe(false); + expect(u.markedForDeletion()).toBe(false); + expect(u.isActive()).toBe(true); + expect(u.reachedTarget()).toBe(false); + expect(u.tile()).toBe(100); + }); + + it("tracks createdAt from the GameView's tick at construction", () => { + const game = makeGameView(); + const u = new UnitView(game, makeUnitUpdate()); + expect(u.createdAt()).toBe(0); // GameView.ticks() returns 0 before any update + }); + + it("returns the latest data after update()", () => { + const game = makeGameView(); + const u = new UnitView(game, makeUnitUpdate({ troops: 100, pos: 1 })); + u.update(makeUnitUpdate({ troops: 250, pos: 5 })); + expect(u.troops()).toBe(250); + expect(u.tile()).toBe(5); + }); + + it("update() pushes new pos into lastPos", () => { + const game = makeGameView(); + const u = new UnitView(game, makeUnitUpdate({ pos: 1 })); + expect(u.lastTile()).toBe(1); + u.update(makeUnitUpdate({ pos: 2 })); + expect(u.lastTiles()).toEqual([1, 2]); + u.update(makeUnitUpdate({ pos: 3 })); + expect(u.lastTiles()).toEqual([1, 2, 3]); + }); + + it("lastTile() returns the first remembered pos", () => { + const game = makeGameView(); + const u = new UnitView(game, makeUnitUpdate({ pos: 1 })); + u.update(makeUnitUpdate({ pos: 2 })); + u.update(makeUnitUpdate({ pos: 3 })); + expect(u.lastTile()).toBe(1); + }); + + it("applyDerivedPosition pushes a new pos and shifts lastPos in data", () => { + const game = makeGameView(); + const u = new UnitView(game, makeUnitUpdate({ pos: 10, lastPos: 9 })); + u.applyDerivedPosition(11); + expect(u.tile()).toBe(11); + expect(u.lastTiles()).toEqual([10, 11]); + }); + + it("hasHealth() reflects whether health is set", () => { + const game = makeGameView(); + expect(new UnitView(game, makeUnitUpdate({ health: 50 })).hasHealth()).toBe( + true, + ); + expect(new UnitView(game, makeUnitUpdate()).hasHealth()).toBe(false); + }); + + it("health() returns 0 when unset", () => { + const game = makeGameView(); + expect(new UnitView(game, makeUnitUpdate()).health()).toBe(0); + expect(new UnitView(game, makeUnitUpdate({ health: 42 })).health()).toBe( + 42, + ); + }); + + it("isUnderConstruction reflects the explicit boolean", () => { + const game = makeGameView(); + expect( + new UnitView( + game, + makeUnitUpdate({ underConstruction: true }), + ).isUnderConstruction(), + ).toBe(true); + expect( + new UnitView( + game, + makeUnitUpdate({ underConstruction: false }), + ).isUnderConstruction(), + ).toBe(false); + // Undefined is treated as false (not under construction). + expect(new UnitView(game, makeUnitUpdate()).isUnderConstruction()).toBe( + false, + ); + }); + + it("trainType() / isLoaded() forward optional train fields", () => { + const game = makeGameView(); + const u = new UnitView( + game, + makeUnitUpdate({ trainType: TrainType.Engine, loaded: true }), + ); + expect(u.trainType()).toBe(TrainType.Engine); + expect(u.isLoaded()).toBe(true); + }); + + it("transportShipState() returns a default when missing", () => { + const game = makeGameView(); + const u = new UnitView(game, makeUnitUpdate()); + expect(u.transportShipState()).toEqual({ isRetreating: false, troops: 0 }); + }); + + it("transportShipState() forwards when set", () => { + const game = makeGameView(); + const state: TransportShipState = { isRetreating: true, troops: 50 }; + const u = new UnitView(game, makeUnitUpdate({ transportShipState: state })); + expect(u.transportShipState()).toBe(state); + }); + + it("warshipState() throws when not a warship state", () => { + const game = makeGameView(); + const u = new UnitView(game, makeUnitUpdate()); + expect(() => u.warshipState()).toThrow(); + }); + + it("warshipState() forwards when present", () => { + const game = makeGameView(); + const state: WarshipState = { + isInCombat: false, + patrolTile: 0, + lastAttackTile: 0, + bossUnitId: null, + } as unknown as WarshipState; + const u = new UnitView(game, makeUnitUpdate({ warshipState: state })); + expect(u.warshipState()).toBe(state); + }); + + it("isInCombat() reflects warshipState.isInCombat (or false if missing)", () => { + const game = makeGameView(); + expect(new UnitView(game, makeUnitUpdate()).isInCombat()).toBe(false); + const combat = new UnitView( + game, + makeUnitUpdate({ + warshipState: { isInCombat: true } as unknown as WarshipState, + }), + ); + expect(combat.isInCombat()).toBe(true); + }); + + it("targetUnitId / targetTile pass through", () => { + const game = makeGameView(); + const u = new UnitView( + game, + makeUnitUpdate({ targetUnitId: 99, targetTile: 12 }), + ); + expect(u.targetUnitId()).toBe(99); + expect(u.targetTile()).toBe(12); + }); + + it("missileTimerQueue() forwards the array", () => { + const game = makeGameView(); + const u = new UnitView( + game, + makeUnitUpdate({ missileTimerQueue: [10, 20, 30] }), + ); + expect(u.missileTimerQueue()).toEqual([10, 20, 30]); + }); + + it("touch / updateWarshipState / updateTransportShipState throw on view", () => { + const game = makeGameView(); + const u = new UnitView(game, makeUnitUpdate()); + expect(() => u.touch()).toThrow(); + expect(() => u.updateWarshipState({})).toThrow(); + expect(() => u.updateTransportShipState({ isRetreating: false })).toThrow(); + }); + + describe("missileReadinesss", () => { + it("returns 1 when nothing is reloading", () => { + const game = makeGameView(); + const u = new UnitView( + game, + makeUnitUpdate({ level: 3, missileTimerQueue: [] }), + ); + expect(u.missileReadinesss()).toBe(1); + }); + + it("returns 0 when all missiles are reloading and level > 1", () => { + const game = makeGameView({ config: stubConfig() }); + const u = new UnitView( + game, + makeUnitUpdate({ + unitType: UnitType.SAMLauncher, + level: 2, + missileTimerQueue: [0, 0], // both reloading, started at tick 0 + }), + ); + // Just-launched: progress is 0, readiness 0/2. + expect(u.missileReadinesss()).toBe(0); + }); + + it("returns partial readiness when missiles are partway through cooldown", () => { + // SAMCooldown = 120 in stub. Half-way at tick 60. Level 2 with both reloading + // means readiness = 0/2 from ready missiles + 2 * (60/120) / 2 = 0.5. + // But game.ticks() returns 0 with no update. So progress = 0 - 0 = 0 → 0. + // Use a game with a tick number injected. + const config = stubConfig({ + SAMCooldown: () => 120, + SiloCooldown: () => 75, + } as unknown as Partial< + typeof stubConfig extends () => infer C ? C : never + >); + const game = makeGameView({ config }); + const u = new UnitView( + game, + makeUnitUpdate({ + unitType: UnitType.SAMLauncher, + level: 2, + missileTimerQueue: [0, 0], + }), + ); + // Without advancing game ticks, readiness = (2-2)/2 + 2*((0-0)/120)/2 = 0. + // We can't easily advance ticks without going through update(); just assert <=1. + const r = u.missileReadinesss(); + expect(r).toBeGreaterThanOrEqual(0); + expect(r).toBeLessThanOrEqual(1); + }); + }); +}); diff --git a/tests/core/game/GameMap.tileStateBuffer.test.ts b/tests/core/game/GameMap.tileStateBuffer.test.ts new file mode 100644 index 000000000..5da53f05a --- /dev/null +++ b/tests/core/game/GameMap.tileStateBuffer.test.ts @@ -0,0 +1,30 @@ +import { describe, expect, it } from "vitest"; +import { GameMapImpl } from "../../../src/core/game/GameMap"; + +describe("GameMap.tileStateBuffer", () => { + it("returns a Uint16Array sized to width * height", () => { + const map = new GameMapImpl(10, 8, new Uint8Array(10 * 8), 0); + const buf = map.tileStateBuffer(); + expect(buf).toBeInstanceOf(Uint16Array); + expect(buf.length).toBe(80); + }); + + it("returns a live reference — updateTile() mutates the same buffer", () => { + const map = new GameMapImpl(4, 4, new Uint8Array(16), 0); + const buf = map.tileStateBuffer(); + // Writes go through updateTile (packed uint32: high 16 bits = terrain byte, low 16 = state). + map.updateTile(5, 0x00abcd); + expect(buf[5]).toBe(0xabcd); + }); + + it("returns the same array on every call (zero-copy)", () => { + const map = new GameMapImpl(4, 4, new Uint8Array(16), 0); + expect(map.tileStateBuffer()).toBe(map.tileStateBuffer()); + }); + + it("reflects ownerID writes in the low 12 bits of each cell", () => { + const map = new GameMapImpl(4, 4, new Uint8Array(16), 0); + map.setOwnerID(7, 0x123); + expect(map.tileStateBuffer()[7] & 0xfff).toBe(0x123); + }); +}); diff --git a/tests/util/viewStubs.ts b/tests/util/viewStubs.ts new file mode 100644 index 000000000..d02bccced --- /dev/null +++ b/tests/util/viewStubs.ts @@ -0,0 +1,224 @@ +/** + * Stub builders for GameView/PlayerView/UnitView unit tests. + * + * These tests don't go through the full game setup (which creates a worker + * and runs the simulation) — they exercise the view classes directly with + * minimal stubs for their dependencies. + */ + +import { colord } from "colord"; +import { GameView } from "../../src/client/view/GameView"; +import { PlayerView } from "../../src/client/view/PlayerView"; +import { Config } from "../../src/core/configuration/Config"; +import { Theme } from "../../src/core/configuration/Theme"; +import { + NameViewData, + PlayerType, + Team, + UnitType, +} from "../../src/core/game/Game"; +import { GameMapImpl } from "../../src/core/game/GameMap"; +import { + GameUpdateType, + GameUpdateViewData, + PlayerUpdate, + UnitUpdate, +} from "../../src/core/game/GameUpdates"; +import { TerrainMapData } from "../../src/core/game/TerrainMapLoader"; +import { Player, PlayerCosmetics } from "../../src/core/Schemas"; +import { WorkerClient } from "../../src/core/worker/WorkerClient"; + +/** Theme stub — returns deterministic colors so PlayerView's color math works. */ +export function stubTheme(): Theme { + const white = colord("#ffffff"); + const grey = colord("#808080"); + const defended = { light: white, dark: grey }; + return { + teamColor: () => white, + territoryColor: () => white, + structureColors: () => defended, + borderColor: () => grey, + defendedBorderColors: () => defended, + focusedBorderColor: () => grey, + terrainColor: () => white, + backgroundColor: () => white, + falloutColor: () => white, + font: () => "Arial", + textColor: () => "#000000", + selfColor: () => white, + allyColor: () => white, + neutralColor: () => grey, + enemyColor: () => grey, + spawnHighlightColor: () => white, + spawnHighlightSelfColor: () => white, + spawnHighlightTeamColor: () => white, + spawnHighlightEnemyColor: () => white, + }; +} + +/** Minimum Config stub for view tests. Extend as test needs grow. */ +export function stubConfig(overrides: Partial = {}): Config { + const theme = stubTheme(); + const cfg = { + theme: () => theme, + SAMCooldown: () => 120, + SiloCooldown: () => 75, + deleteUnitCooldown: () => 0, + spawnImmunityDuration: () => 0, + nationSpawnImmunityDuration: () => 0, + unitInfo: () => ({ maxHealth: 100, constructionDuration: 20 }), + disableAlliances: () => false, + allianceDuration: () => 100, + deletionMarkDuration: () => 300, + nukeMagnitudes: () => ({ inner: 0, outer: 0 }), + nukeAllianceBreakThreshold: () => 0, + userSettings: () => ({}), + ...overrides, + } as unknown as Config; + return cfg; +} + +/** WorkerClient stub. View classes only call worker.* in async methods we don't exercise. */ +export function stubWorker(): WorkerClient { + return {} as unknown as WorkerClient; +} + +/** Build TerrainMapData wrapping a fresh GameMapImpl of the given size. */ +export function stubTerrainMap(width = 10, height = 10): TerrainMapData { + const terrain = new Uint8Array(width * height); + const gameMap = new GameMapImpl(width, height, terrain, 0); + return { + nations: [], + additionalNations: [], + gameMap, + miniGameMap: gameMap, + } as unknown as TerrainMapData; +} + +export interface GameViewStubOptions { + width?: number; + height?: number; + myClientID?: string; + myUsername?: string; + myClanTag?: string | null; + humans?: Player[]; + config?: Config; +} + +/** Construct a GameView with minimal dependencies. */ +export function makeGameView(opts: GameViewStubOptions = {}): GameView { + return new GameView( + stubWorker(), + opts.config ?? stubConfig(), + stubTerrainMap(opts.width ?? 10, opts.height ?? 10), + opts.myClientID, + opts.myUsername ?? "tester", + opts.myClanTag ?? null, + "test-game", + opts.humans ?? [], + ); +} + +// ── Synthetic update builders ── + +export function makePlayerUpdate( + overrides: Partial = {}, +): PlayerUpdate { + return { + type: GameUpdateType.Player, + clientID: "client-a", + name: "Alice", + displayName: "Alice", + id: "player-a", + smallID: 1, + playerType: PlayerType.Human, + isAlive: true, + isDisconnected: false, + tilesOwned: 0, + gold: 0n, + troops: 100, + allies: [], + embargoes: new Set(), + isTraitor: false, + targets: [], + outgoingEmojis: [], + outgoingAttacks: [], + incomingAttacks: [], + outgoingAllianceRequests: [], + alliances: [], + hasSpawned: true, + betrayals: 0, + lastDeleteUnitTick: 0, + isLobbyCreator: false, + ...overrides, + }; +} + +export function makeUnitUpdate( + overrides: Partial = {}, +): UnitUpdate { + return { + type: GameUpdateType.Unit, + unitType: UnitType.Warship, + troops: 0, + id: 1, + ownerID: 1, + pos: 0, + lastPos: 0, + isActive: true, + reachedTarget: false, + targetable: true, + markedForDeletion: false, + missileTimerQueue: [], + level: 1, + hasTrainStation: false, + ...overrides, + }; +} + +export function makeNameViewData( + overrides: Partial = {}, +): NameViewData { + return { x: 0, y: 0, size: 12, ...overrides }; +} + +export interface PlayerViewStubOptions { + game?: GameView; + data?: Partial; + nameData?: NameViewData; + cosmetics?: PlayerCosmetics; +} + +/** Construct a PlayerView with minimal dependencies. */ +export function makePlayerView(opts: PlayerViewStubOptions = {}): PlayerView { + return new PlayerView( + opts.game ?? makeGameView(), + makePlayerUpdate(opts.data), + opts.nameData ?? makeNameViewData(), + opts.cosmetics ?? {}, + ); +} + +/** + * Build a GameUpdateViewData with no updates and an empty packed tile delta. + * Caller can fill in updates[GameUpdateType.X] arrays as needed. + */ +export function makeEmptyGu( + tick: number, + overrides: Partial = {}, +): GameUpdateViewData { + const updates = Object.fromEntries( + Object.values(GameUpdateType) + .filter((v): v is number => typeof v === "number") + .map((k) => [k, []]), + ) as unknown as GameUpdateViewData["updates"]; + return { + tick, + updates, + packedTileUpdates: new Uint32Array(0), + playerNameViewData: {}, + ...overrides, + }; +} + +export { Team };