From 3936b9ab11889b3cb6ec0671c615b3d3d6fdca7c Mon Sep 17 00:00:00 2001 From: 1brucben <1benjbruce@gmail.com> Date: Mon, 23 Feb 2026 02:16:22 +0100 Subject: [PATCH] Add performance benchmarks for TerritoryLayer and fix nukeHandler reference - Introduced a new performance test for the Canvas2D implementation of TerritoryLayer in `TerritoryLayer.perf.test.ts`. - Added a benchmark harness in `territory-layer-bench-harness.ts` to facilitate performance testing of different layer implementations. - Fixed a reference issue in `AIPlayerExecution.ts` by removing the optional chaining operator on `nukeHandler` when accessing `getEffectiveSAMRange`. --- old_territory_layer.txt | Bin 0 -> 43716 bytes src/client/graphics/layers/TerritoryLayer.ts | 293 ++++-- src/core/ai/AIPlayerExecution.ts | 2 +- .../client/layers/TerritoryLayer.perf.test.ts | 54 ++ .../layers/territory-layer-bench-harness.ts | 843 ++++++++++++++++++ 5 files changed, 1098 insertions(+), 94 deletions(-) create mode 100644 old_territory_layer.txt create mode 100644 tests/client/layers/TerritoryLayer.perf.test.ts create mode 100644 tests/client/layers/territory-layer-bench-harness.ts diff --git a/old_territory_layer.txt b/old_territory_layer.txt new file mode 100644 index 0000000000000000000000000000000000000000..19230ca036059b95970988ec09ca398a8bc014c8 GIT binary patch literal 43716 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 = new PriorityQueue((a, b) => { - return a.lastUpdate - b.lastUpdate; - }); - private random = new PseudoRandom(123); + private renderQueue: TileRef[] = []; private theme: Theme; private highlightedTerritory: PlayerView | null = null; @@ -51,11 +45,23 @@ export class TerritoryLayer implements Layer { // 0 = unknown, 1 = false, 2 = true private borderCache: Uint8Array | null = null; private defendedCache: Uint8Array | null = null; - private borderColorsCache = new Map< - string, - { light: Colord; dark: Colord } - >(); - private territoryColorCache = new Map(); + + // Pre-packed RGBA color tables indexed by player smallID + private territoryPacked!: Uint32Array; + private borderPacked!: Uint32Array; + private defLightPacked!: Uint32Array; + private defDarkPacked!: Uint32Array; + private focusedBorderPacked = 0; + private falloutPacked = 0; + private lastPlayerCount = -1; + + // Bitmap for deduplicating tile repaints in renderTerritory + private repaintFlags!: Uint8Array; + + // Per-render-pass cached state + private _cachedMyPlayer: PlayerView | null = null; + private _cachedFocusedSID = -1; + private _cachedHighlightedSID = -1; // Dirty tracking to minimize putImageData calls private isDirty = false; @@ -66,6 +72,8 @@ export class TerritoryLayer implements Layer { // Cached map dimensions to avoid repeated method calls in hot render path private _width: number; private _height: number; + private _widthM1: number; + private _heightM1: number; constructor( private game: GameView, @@ -75,21 +83,80 @@ export class TerritoryLayer implements Layer { this.theme = game.config().theme(); this._width = game.width(); this._height = game.height(); + this._widthM1 = this._width - 1; + this._heightM1 = this._height - 1; } shouldTransform(): boolean { return true; } + private static packRGBA(c: Colord, alpha: number): number { + const { r, g, b } = c.rgba; + return ( + ((alpha & 0xff) << 24) | + ((b & 0xff) << 16) | + ((g & 0xff) << 8) | + (r & 0xff) + ); + } + + private buildColorCache(force = false) { + const players = this.game.playerViews(); + if (!force && players.length === this.lastPlayerCount) return; + this.lastPlayerCount = players.length; + let maxSID = 0; + for (let i = 0; i < players.length; i++) { + const sid = players[i].smallID(); + if (sid > maxSID) maxSID = sid; + } + const size = maxSID + 1; + this.territoryPacked = new Uint32Array(size); + this.borderPacked = new Uint32Array(size); + this.defLightPacked = new Uint32Array(size); + this.defDarkPacked = new Uint32Array(size); + + for (let i = 0; i < players.length; i++) { + const p = players[i]; + const sid = p.smallID(); + this.territoryPacked[sid] = TerritoryLayer.packRGBA( + this.theme.territoryColor(p), + 150, + ); + this.borderPacked[sid] = TerritoryLayer.packRGBA( + this.theme.borderColor(p), + 255, + ); + const def = this.theme.defendedBorderColors(p); + this.defLightPacked[sid] = TerritoryLayer.packRGBA(def.light, 255); + this.defDarkPacked[sid] = TerritoryLayer.packRGBA(def.dark, 255); + } + + this.focusedBorderPacked = TerritoryLayer.packRGBA( + this.theme.focusedBorderColor(), + 255, + ); + this.falloutPacked = TerritoryLayer.packRGBA( + this.theme.falloutColor(), + 150, + ); + } + async paintPlayerBorder(player: PlayerView) { const tiles = await player.borderTiles(); + this._cachedMyPlayer = this.game.myPlayer(); + const fp = this.game.focusedPlayer(); + this._cachedFocusedSID = fp ? fp.smallID() : -1; + this._cachedHighlightedSID = this.highlightedTerritory + ? this.highlightedTerritory.smallID() + : -1; tiles.borderTiles.forEach((tile: TileRef) => { this.paintTerritory(tile, true); // Immediately paint the tile instead of enqueueing }); } tick() { - this.game.recentlyUpdatedTiles().forEach((t) => this.enqueueTile(t)); + this.game.recentlyUpdatedTiles().forEach((t) => this.renderQueue.push(t)); const updates = this.game.updatesSinceLastTick(); const unitUpdates = updates !== null ? updates[GameUpdateType.Unit] : []; unitUpdates.forEach((update) => { @@ -118,7 +185,7 @@ export class TerritoryLayer implements Layer { this.game.ownerID(t) === update.lastOwnerID) && this.game.isBorder(t) ) { - this.enqueueTile(t); + this.renderQueue.push(t); } } } @@ -204,7 +271,7 @@ export class TerritoryLayer implements Layer { if (this.defendedCache) { this.defendedCache[update.tile] = 0; } - this.enqueueTile(update.tile); + this.renderQueue.push(update.tile); }); const focusedPlayer = this.game.focusedPlayer(); @@ -343,10 +410,12 @@ export class TerritoryLayer implements Layer { // Allocate blank ImageData buffers rather than reading back from the canvas. // This avoids expensive GPU->CPU readbacks and the Chrome warning about getImageData. this.imageData = new ImageData(this.canvas.width, this.canvas.height); + this.imageData32 = new Uint32Array(this.imageData.data.buffer); this.alternativeImageData = new ImageData( this.canvas.width, this.canvas.height, ); + this.altData32 = new Uint32Array(this.alternativeImageData.data.buffer); this.context.putImageData( this.alternativeView ? this.alternativeImageData : this.imageData, @@ -368,8 +437,16 @@ export class TerritoryLayer implements Layer { const size = this._width * this._height; this.borderCache = new Uint8Array(size); this.defendedCache = new Uint8Array(size); - this.borderColorsCache.clear(); - this.territoryColorCache.clear(); + this.repaintFlags = new Uint8Array(size); + this.buildColorCache(true); + + // Cache per-pass values for the full redraw + this._cachedMyPlayer = this.game.myPlayer(); + const fp = this.game.focusedPlayer(); + this._cachedFocusedSID = fp ? fp.smallID() : -1; + this._cachedHighlightedSID = this.highlightedTerritory + ? this.highlightedTerritory.smallID() + : -1; this.game.forEachTile((t) => { this.paintTerritory(t); @@ -380,6 +457,13 @@ export class TerritoryLayer implements Layer { const territories = Array.isArray(territory) ? territory : [territory]; const territorySet = new Set(territories); + this._cachedMyPlayer = this.game.myPlayer(); + const fp = this.game.focusedPlayer(); + this._cachedFocusedSID = fp ? fp.smallID() : -1; + this._cachedHighlightedSID = this.highlightedTerritory + ? this.highlightedTerritory.smallID() + : -1; + this.game.forEachTile((t) => { const owner = this.game.owner(t) as PlayerView; if (territorySet.has(owner)) { @@ -451,37 +535,88 @@ export class TerritoryLayer implements Layer { } renderTerritory() { - let numToRender = Math.floor(this.tileToRenderQueue.size() / 10); + const queue = this.renderQueue; + const len = queue.length; + if (len === 0) return; + + // Rebuild color tables so new players are always covered + this.buildColorCache(); + + let numToRender = (len / 10) | 0; if (numToRender === 0 || this.game.inSpawnPhase()) { - numToRender = this.tileToRenderQueue.size(); + numToRender = len; } - while (numToRender > 0) { - numToRender--; - - const entry = this.tileToRenderQueue.pop(); - if (!entry) { - break; + // Cache per-pass values + this._cachedMyPlayer = this.game.myPlayer(); + const fp = this.game.focusedPlayer(); + this._cachedFocusedSID = fp ? fp.smallID() : -1; + this._cachedHighlightedSID = this.highlightedTerritory + ? this.highlightedTerritory.smallID() + : -1; + + const flags = this.repaintFlags; + const w = this._width; + const FLAG_MAIN = 1; + const FLAG_NEIGHBOR = 2; + const repaintList: TileRef[] = []; + + // Collect tiles to repaint with deduplication via bitmap + for (let i = 0; i < numToRender; i++) { + const tile = queue[i]; + + // Invalidate caches for queued tile + this.borderCache![tile] = 0; + this.defendedCache![tile] = 0; + + if (flags[tile] === 0) repaintList.push(tile); + flags[tile] |= FLAG_MAIN; + + // Inline neighbor processing + const x = tile % w; + const y = (tile / w) | 0; + let n: number; + if (x > 0) { + n = tile - 1; + this.borderCache![n] = 0; + if (flags[n] === 0) repaintList.push(n); + flags[n] |= FLAG_NEIGHBOR; } - - const tile = entry.tile; - - // Invalidate border/defended cache for the tile - if (this.borderCache) { - this.borderCache[tile] = 0; + if (x < this._widthM1) { + n = tile + 1; + this.borderCache![n] = 0; + if (flags[n] === 0) repaintList.push(n); + flags[n] |= FLAG_NEIGHBOR; } - if (this.defendedCache) { - this.defendedCache[tile] = 0; + if (y > 0) { + n = tile - w; + this.borderCache![n] = 0; + if (flags[n] === 0) repaintList.push(n); + flags[n] |= FLAG_NEIGHBOR; } - - this.paintTerritory(tile); - for (const neighbor of this.game.neighbors(tile)) { - if (this.borderCache) { - this.borderCache[neighbor] = 0; - } - this.paintTerritory(neighbor, true); + if (y < this._heightM1) { + n = tile + w; + this.borderCache![n] = 0; + if (flags[n] === 0) repaintList.push(n); + flags[n] |= FLAG_NEIGHBOR; } } + + // Remove processed entries + if (numToRender >= len) { + queue.length = 0; + } else { + this.renderQueue = queue.slice(numToRender); + } + + // Paint all unique tiles exactly once + for (let i = 0; i < repaintList.length; i++) { + const tile = repaintList[i]; + const tileFlags = flags[tile]; + flags[tile] = 0; // reset for next pass + const isNeighborOnly = (tileFlags & FLAG_MAIN) === 0; + this.paintTerritory(tile, isNeighborOnly); + } } paintTerritory(tile: TileRef, isBorder: boolean = false) { @@ -491,23 +626,18 @@ export class TerritoryLayer implements Layer { if (!this.game.hasOwner(tile)) { if (this.game.hasFallout(tile)) { - this.paintTile(this.imageData, tile, this.theme.falloutColor(), 150); - this.paintTile( - this.alternativeImageData, - tile, - this.theme.falloutColor(), - 150, - ); + this.imageData32[tile] = this.falloutPacked; + this.altData32[tile] = this.falloutPacked; + this.markDirty(tile); return; } this.clearTile(tile); return; } - const owner = this.game.owner(tile) as PlayerView; - const isHighlighted = - this.highlightedTerritory && - this.highlightedTerritory.id() === owner.id(); - const myPlayer = this.game.myPlayer(); + const sid = this.game.ownerID(tile); + const isHighlighted = this._cachedHighlightedSID === sid; + const myPlayer = this._cachedMyPlayer; + const focusedSID = this._cachedFocusedSID; // Check border cache let isBorderTile = false; @@ -521,16 +651,18 @@ export class TerritoryLayer implements Layer { } if (isBorderTile) { - const playerIsFocused = owner && this.game.focusedPlayer() === owner; + const playerIsFocused = focusedSID >= 0 && focusedSID === sid; if (myPlayer) { + const owner = this.game.owner(tile) as PlayerView; const alternativeColor = this.getDiplomacyColor(owner, myPlayer); - this.paintTile(this.alternativeImageData, tile, alternativeColor, 255); + this.altData32[tile] = TerritoryLayer.packRGBA(alternativeColor, 255); } // Check defended cache let isDefended = false; if (this.defendedCache) { if (this.defendedCache[tile] === 0) { + const owner = this.game.owner(tile) as PlayerView; const defended = this.game.hasUnitNearby( tile, this.game.config().defensePostRange(), @@ -541,6 +673,7 @@ export class TerritoryLayer implements Layer { } isDefended = this.defendedCache[tile] === 2; } else { + const owner = this.game.owner(tile) as PlayerView; isDefended = this.game.hasUnitNearby( tile, this.game.config().defensePostRange(), @@ -550,56 +683,37 @@ export class TerritoryLayer implements Layer { } if (isDefended) { - let borderColors = this.borderColorsCache.get(owner.id()); - if (!borderColors) { - borderColors = this.theme.defendedBorderColors(owner); - this.borderColorsCache.set(owner.id(), borderColors); - } 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); - const borderColor = lightTile ? borderColors.light : borderColors.dark; - this.paintTile(this.imageData, tile, borderColor, 255); + this.imageData32[tile] = lightTile + ? this.defLightPacked[sid] + : this.defDarkPacked[sid]; } else { - const useBorderColor = playerIsFocused - ? this.theme.focusedBorderColor() - : this.theme.borderColor(owner); - this.paintTile(this.imageData, tile, useBorderColor, 255); + this.imageData32[tile] = playerIsFocused + ? this.focusedBorderPacked + : this.borderPacked[sid]; } } else { if (myPlayer) { + const owner = this.game.owner(tile) as PlayerView; const alternativeColor = this.getDiplomacyColor(owner, myPlayer); - this.paintTile( - this.alternativeImageData, - tile, + this.altData32[tile] = TerritoryLayer.packRGBA( alternativeColor, isHighlighted ? 150 : 60, ); } - let territoryColor = this.territoryColorCache.get(owner.id()); - if (!territoryColor) { - territoryColor = this.theme.territoryColor(owner); - this.territoryColorCache.set(owner.id(), territoryColor); - } - this.paintTile(this.imageData, tile, territoryColor, 150); + this.imageData32[tile] = this.territoryPacked[sid]; } - } - paintTile(imageData: ImageData, tile: TileRef, color: Colord, alpha: number) { - const offset = tile * 4; - imageData.data[offset] = color.rgba.r; - imageData.data[offset + 1] = color.rgba.g; - imageData.data[offset + 2] = color.rgba.b; - imageData.data[offset + 3] = alpha; this.markDirty(tile); } clearTile(tile: TileRef) { - const offset = tile * 4; - this.imageData.data[offset + 3] = 0; - this.alternativeImageData.data[offset + 3] = 0; + this.imageData32[tile] = 0; + this.altData32[tile] = 0; this.markDirty(tile); } @@ -617,13 +731,6 @@ export class TerritoryLayer implements Layer { } } - enqueueTile(tile: TileRef) { - this.tileToRenderQueue.push({ - tile: tile, - lastUpdate: this.game.ticks() + this.random.nextFloat(0, 0.5), - }); - } - paintHighlightTile(tile: TileRef, color: Colord, alpha: number) { this.clearTile(tile); const x = this.game.x(tile); diff --git a/src/core/ai/AIPlayerExecution.ts b/src/core/ai/AIPlayerExecution.ts index 0cce22d34..b01a16399 100644 --- a/src/core/ai/AIPlayerExecution.ts +++ b/src/core/ai/AIPlayerExecution.ts @@ -580,7 +580,7 @@ export class AIPlayerExecution implements Execution { const dist = Math.sqrt( this.mg.euclideanDistSquared(bestTile, s.tile()), ); - const ownerRange = this.nukeHandler!.getEffectiveSAMRange( + const ownerRange = this.nukeHandler.getEffectiveSAMRange( s.owner(), ); return `{id=${s.id()} pos=(${ox},${oy}) owner=${s.owner().id()} stack=${s.stackCount()} dist=${dist.toFixed(1)} ownerRange=${ownerRange.toFixed(1)} isActive=${s.isActive()}}`; diff --git a/tests/client/layers/TerritoryLayer.perf.test.ts b/tests/client/layers/TerritoryLayer.perf.test.ts new file mode 100644 index 000000000..2514eb066 --- /dev/null +++ b/tests/client/layers/TerritoryLayer.perf.test.ts @@ -0,0 +1,54 @@ +/** + * @jest-environment jsdom + */ + +/** + * TerritoryLayer (Canvas2D) Performance Benchmark + * ================================================= + * Uses the shared harness to benchmark the current Canvas2D-based + * TerritoryLayer implementation. To benchmark an alternative + * implementation (WebGL, Pixi, OffscreenCanvas, etc.), create a new + * test file that imports the same harness and passes a different factory: + * + * import { runTerritoryBenchSuite } from "./territory-layer-bench-harness"; + * import { MyWebGLTerritoryLayer } from "..."; + * + * runTerritoryBenchSuite("WebGL TerritoryLayer", (game, eventBus, transform) => + * new MyWebGLTerritoryLayer(game, eventBus, transform), + * ); + */ + +// jsdom doesn't provide ImageData — polyfill before any imports that need it +if (typeof globalThis.ImageData === "undefined") { + (globalThis as any).ImageData = class ImageData { + readonly width: number; + readonly height: number; + readonly data: Uint8ClampedArray; + constructor(sw: number, sh: number); + constructor(data: Uint8ClampedArray, sw: number, sh?: number); + constructor( + swOrData: number | Uint8ClampedArray, + shOrSw: number, + maybeH?: number, + ) { + if (swOrData instanceof Uint8ClampedArray) { + this.data = swOrData; + this.width = shOrSw; + this.height = maybeH ?? swOrData.length / 4 / shOrSw; + } else { + this.width = swOrData; + this.height = shOrSw; + this.data = new Uint8ClampedArray(this.width * this.height * 4); + } + } + }; +} + +import { TerritoryLayer } from "../../../src/client/graphics/layers/TerritoryLayer"; +import { runTerritoryBenchSuite } from "./territory-layer-bench-harness"; + +runTerritoryBenchSuite( + "Canvas2D TerritoryLayer", + (game, eventBus, transformHandler) => + new TerritoryLayer(game, eventBus, transformHandler), +); diff --git a/tests/client/layers/territory-layer-bench-harness.ts b/tests/client/layers/territory-layer-bench-harness.ts new file mode 100644 index 000000000..56b9ee118 --- /dev/null +++ b/tests/client/layers/territory-layer-bench-harness.ts @@ -0,0 +1,843 @@ +/** + * TerritoryLayer Performance Benchmark Harness + * ============================================== + * Implementation-agnostic harness for benchmarking any Layer that renders + * territory. Exports mock game state, attack simulation, stats utilities, + * and a `runTerritoryBenchSuite()` function that works with any factory + * producing a `Layer`. + * + * Usage in a test file: + * + * import { runTerritoryBenchSuite } from "./territory-layer-bench-harness"; + * import { MyTerritoryLayer } from "..."; + * + * runTerritoryBenchSuite("MyTerritoryLayer", (game, eventBus, transform) => + * new MyTerritoryLayer(game, eventBus, transform), + * ); + * + * Each implementation gets identical scenarios and the results table is + * printed at the end so you can compare side-by-side. + */ + +import { colord, type Colord } from "colord"; +import type { Layer } from "../../../src/client/graphics/layers/Layer"; +import type { TransformHandler } from "../../../src/client/graphics/TransformHandler"; +import type { EventBus } from "../../../src/core/EventBus"; +import { PlayerType } from "../../../src/core/game/Game"; +import type { TileRef } from "../../../src/core/game/GameMap"; +import { GameUpdateType } from "../../../src/core/game/GameUpdates"; +import type { GameView } from "../../../src/core/game/GameView"; +import { PlayerView } from "../../../src/core/game/GameView"; + +// ═══════════════════════════════════════════════════════════════════════════ +// Map / player constants +// ═══════════════════════════════════════════════════════════════════════════ + +export const MAP_WIDTH = 600; +export const MAP_HEIGHT = 400; +export const TOTAL_TILES = MAP_WIDTH * MAP_HEIGHT; +export const NUM_PLAYERS = 4; + +// ═══════════════════════════════════════════════════════════════════════════ +// Types +// ═══════════════════════════════════════════════════════════════════════════ + +export interface BenchmarkResult { + scenario: string; + samples: number; + /** Mean wall-clock time in ms */ + meanMs: number; + /** Median wall-clock time in ms */ + medianMs: number; + /** 95th percentile in ms */ + p95Ms: number; + /** Standard deviation in ms */ + stdMs: number; + /** Minimum in ms */ + minMs: number; + /** Maximum in ms */ + maxMs: number; + /** Total putImageData calls during measured samples */ + putImageDataCalls: number; + /** Total drawImage calls during measured samples */ + drawImageCalls: number; + /** Sum of dirty-rect pixel areas across all putImageData calls */ + totalDirtyPixels: number; +} + +export interface GpuCounters { + putImageDataCalls: number; + drawImageCalls: number; + totalDirtyPixels: number; +} + +/** Rectangular region of tiles assigned to a player (simple partition). */ +export interface PlayerRegion { + id: string; + smallID: number; + startTile: number; + tileCount: number; +} + +export interface MockGameState { + ownerMap: Int32Array; + borderMap: Uint8Array; + regions: PlayerRegion[]; + players: PlayerView[]; + recentTiles: TileRef[]; + tileOwnerChangedUpdates: { type: number; tile: TileRef }[]; + currentTick: number; +} + +/** + * Factory signature: given the mocked dependencies, return a Layer. + * The factory may also return a cleanup function called after each sample. + */ +export type LayerFactory = ( + game: GameView, + eventBus: EventBus, + transformHandler: TransformHandler, +) => Layer; + +// ═══════════════════════════════════════════════════════════════════════════ +// Stats helpers +// ═══════════════════════════════════════════════════════════════════════════ + +export function computeStats( + label: string, + timings: number[], + gpuMetrics: GpuCounters, +): BenchmarkResult { + const sorted = [...timings].sort((a, b) => a - b); + const n = sorted.length; + const mean = sorted.reduce((s, v) => s + v, 0) / n; + const median = + n % 2 === 0 + ? (sorted[n / 2 - 1] + sorted[n / 2]) / 2 + : sorted[Math.floor(n / 2)]; + const p95 = sorted[Math.min(Math.ceil(n * 0.95) - 1, n - 1)]; + const variance = sorted.reduce((s, v) => s + (v - mean) ** 2, 0) / n; + const std = Math.sqrt(variance); + return { + scenario: label, + samples: n, + meanMs: +mean.toFixed(3), + medianMs: +median.toFixed(3), + p95Ms: +p95.toFixed(3), + stdMs: +std.toFixed(3), + minMs: +sorted[0].toFixed(3), + maxMs: +sorted[n - 1].toFixed(3), + putImageDataCalls: gpuMetrics.putImageDataCalls, + drawImageCalls: gpuMetrics.drawImageCalls, + totalDirtyPixels: gpuMetrics.totalDirtyPixels, + }; +} + +// ═══════════════════════════════════════════════════════════════════════════ +// Canvas / context instrumented mock +// ═══════════════════════════════════════════════════════════════════════════ + +export function resetGpuCounters(c: GpuCounters) { + c.putImageDataCalls = 0; + c.drawImageCalls = 0; + c.totalDirtyPixels = 0; +} + +export function createInstrumentedContext( + width: number, + height: number, + counters: GpuCounters, +): CanvasRenderingContext2D { + return { + putImageData: ( + _imageData: ImageData, + _dx: number, + _dy: number, + dirtyX?: number, + dirtyY?: number, + dirtyW?: number, + dirtyH?: number, + ) => { + counters.putImageDataCalls++; + if (dirtyW !== undefined && dirtyH !== undefined) { + counters.totalDirtyPixels += dirtyW * dirtyH; + } else { + counters.totalDirtyPixels += width * height; + } + }, + drawImage: () => { + counters.drawImageCalls++; + }, + clearRect: () => {}, + fillRect: () => {}, + fillStyle: "", + canvas: { width, height }, + } as unknown as CanvasRenderingContext2D; +} + +/** + * Monkey-patch `document.createElement("canvas")` to return instrumented + * canvases that track GPU-proxy calls. + */ +export function installCanvasMock( + width: number, + height: number, + counters: GpuCounters, +) { + const origCreateElement = document.createElement.bind(document); + jest + .spyOn(document, "createElement") + .mockImplementation((tag: string, options?: ElementCreationOptions) => { + if (tag === "canvas") { + const fakeCanvas = { + width, + height, + getContext: (_id: string, _opts?: any) => + createInstrumentedContext(width, height, counters), + toDataURL: () => "", + style: {}, + } as unknown as HTMLCanvasElement; + return fakeCanvas; + } + return origCreateElement(tag, options); + }); +} + +// ═══════════════════════════════════════════════════════════════════════════ +// Game state mock +// ═══════════════════════════════════════════════════════════════════════════ + +const PLAYER_COLORS: Colord[] = [ + colord("#e63946"), + colord("#457b9d"), + colord("#2a9d8f"), + colord("#e9c46a"), +]; + +export function buildPlayerRegions(): PlayerRegion[] { + const tilesPerPlayer = Math.floor(TOTAL_TILES / NUM_PLAYERS); + const regions: PlayerRegion[] = []; + for (let i = 0; i < NUM_PLAYERS; i++) { + regions.push({ + id: `player-${i}`, + smallID: i + 1, + startTile: i * tilesPerPlayer, + tileCount: tilesPerPlayer, + }); + } + return regions; +} + +export function buildOwnerMap(regions: PlayerRegion[]): Int32Array { + const map = new Int32Array(TOTAL_TILES).fill(-1); + for (const r of regions) { + for (let t = r.startTile; t < r.startTile + r.tileCount; t++) { + map[t] = r.smallID; + } + } + return map; +} + +export function computeBorders( + ownerMap: Int32Array, + w: number, + h: number, +): Uint8Array { + const borders = new Uint8Array(w * h); + for (let t = 0; t < w * h; t++) { + if (ownerMap[t] === -1) continue; + const x = t % w; + const y = Math.floor(t / w); + const oid = ownerMap[t]; + let isBorder = false; + if (x > 0 && ownerMap[t - 1] !== oid) isBorder = true; + if (x < w - 1 && ownerMap[t + 1] !== oid) isBorder = true; + if (y > 0 && ownerMap[t - w] !== oid) isBorder = true; + if (y < h - 1 && ownerMap[t + w] !== oid) isBorder = true; + borders[t] = isBorder ? 1 : 0; + } + return borders; +} + +export function neighborsOf(tile: TileRef, w: number, h: number): Uint32Array { + const x = tile % w; + const y = Math.floor(tile / w); + const result: number[] = []; + if (x > 0) result.push(tile - 1); + if (x < w - 1) result.push(tile + 1); + if (y > 0) result.push(tile - w); + if (y < h - 1) result.push(tile + w); + return new Uint32Array(result); +} + +export function createMockPlayerView( + region: PlayerRegion, + color: Colord, +): PlayerView { + return { + id: () => region.id, + smallID: () => region.smallID, + type: () => PlayerType.Human, + isPlayer: () => true, + isFriendly: () => false, + isAtWarWith: () => false, + isAlliedWith: () => false, + nameLocation: () => ({ + x: ((region.startTile % MAP_WIDTH) + MAP_WIDTH / NUM_PLAYERS / 2) | 0, + y: (Math.floor(region.startTile / MAP_WIDTH) + MAP_HEIGHT / 2) | 0, + }), + borderTiles: () => + Promise.resolve({ borderTiles: [], innerBorderTiles: [] }), + numTilesOwned: () => region.tileCount, + _color: color, + } as unknown as PlayerView; +} + +function createMockTheme() { + return { + territoryColor: (pv: any) => (pv as any)._color ?? colord("#888888"), + borderColor: (pv: any) => + ((pv as any)._color ?? colord("#888888")).darken(0.2), + defendedBorderColors: (pv: any) => ({ + light: ((pv as any)._color ?? colord("#888888")).lighten(0.1), + dark: ((pv as any)._color ?? colord("#888888")).darken(0.3), + }), + focusedBorderColor: () => colord("#ffffff"), + falloutColor: () => colord("#333333"), + selfColor: () => colord("#00ff00"), + allyColor: () => colord("#0000ff"), + enemyColor: () => colord("#ff0000"), + spawnHighlightColor: () => colord("#ffff00"), + }; +} + +export function createMockGameView(state: MockGameState): GameView { + const theme = createMockTheme(); + + const playersBySmallID = new Map(); + const playersById = new Map(); + for (const p of state.players) { + playersBySmallID.set(p.smallID(), p); + playersById.set(p.id(), p); + } + + const game: Partial = { + width: () => MAP_WIDTH, + height: () => MAP_HEIGHT, + config: () => + ({ + theme: () => theme, + defensePostRange: () => 3, + }) as any, + ref: (x: number, y: number) => y * MAP_WIDTH + x, + x: (t: TileRef) => t % MAP_WIDTH, + y: (t: TileRef) => Math.floor(t / MAP_WIDTH), + isValidCoord: (x: number, y: number) => + x >= 0 && x < MAP_WIDTH && y >= 0 && y < MAP_HEIGHT, + hasOwner: (t: TileRef) => state.ownerMap[t] !== -1, + ownerID: (t: TileRef) => state.ownerMap[t], + owner: (t: TileRef) => { + const sid = state.ownerMap[t]; + if (sid === -1) return { isPlayer: () => false } as any; + return playersBySmallID.get(sid) ?? ({ isPlayer: () => false } as any); + }, + isBorder: (t: TileRef) => state.borderMap[t] === 1, + hasFallout: (_t: TileRef) => false, + neighbors: (t: TileRef) => neighborsOf(t, MAP_WIDTH, MAP_HEIGHT), + forEachTile: (fn: (t: TileRef) => void) => { + for (let t = 0; t < TOTAL_TILES; t++) fn(t); + }, + ticks: () => state.currentTick, + inSpawnPhase: () => false, + myPlayer: () => null, + focusedPlayer: () => null, + playerViews: () => state.players, + playerBySmallID: (id: number) => + playersBySmallID.get(id) ?? ({ isPlayer: () => false } as any), + hasUnitNearby: () => false, + recentlyUpdatedTiles: () => state.recentTiles, + updatesSinceLastTick: () => { + const updates: any = {}; + for (const key of Object.values(GameUpdateType)) { + if (typeof key === "number") updates[key] = []; + } + updates[GameUpdateType.TileOwnerChanged] = state.tileOwnerChangedUpdates; + return updates; + }, + }; + + return game as GameView; +} + +export function createMockEventBus(): EventBus { + return { + on: jest.fn(), + off: jest.fn(), + emit: jest.fn(), + } as unknown as EventBus; +} + +export function createMockTransformHandler(): TransformHandler { + return { + screenToWorldCoordinates: () => ({ x: 0, y: 0 }), + } as unknown as TransformHandler; +} + +// ═══════════════════════════════════════════════════════════════════════════ +// Attack simulation +// ═══════════════════════════════════════════════════════════════════════════ + +/** + * Simulate an attack: BFS-flip `count` tiles at the boundary between two + * player regions from `fromSmallID` to `toSmallID`. + * Returns the list of changed tile refs. + */ +export function simulateAttack( + state: MockGameState, + fromSmallID: number, + toSmallID: number, + count: number, +): TileRef[] { + const changed: TileRef[] = []; + const candidates: TileRef[] = []; + for (let t = 0; t < TOTAL_TILES; t++) { + if (state.ownerMap[t] !== fromSmallID) continue; + const ns = neighborsOf(t, MAP_WIDTH, MAP_HEIGHT); + for (let i = 0; i < ns.length; i++) { + if (state.ownerMap[ns[i]] === toSmallID) { + candidates.push(t); + break; + } + } + } + + const visited = new Set(); + const queue = [...candidates]; + for (const c of candidates) visited.add(c); + + while (changed.length < count && queue.length > 0) { + const t = queue.shift()!; + if (state.ownerMap[t] !== fromSmallID) continue; + state.ownerMap[t] = toSmallID; + changed.push(t); + const ns = neighborsOf(t, MAP_WIDTH, MAP_HEIGHT); + for (let i = 0; i < ns.length; i++) { + if (!visited.has(ns[i]) && state.ownerMap[ns[i]] === fromSmallID) { + visited.add(ns[i]); + queue.push(ns[i]); + } + } + } + + // Recompute borders for affected + neighboring tiles + const affectedSet = new Set(changed); + for (const t of changed) { + const ns = neighborsOf(t, MAP_WIDTH, MAP_HEIGHT); + for (let i = 0; i < ns.length; i++) affectedSet.add(ns[i]); + } + for (const t of affectedSet) { + if (state.ownerMap[t] === -1) { + state.borderMap[t] = 0; + continue; + } + const x = t % MAP_WIDTH; + const y = Math.floor(t / MAP_WIDTH); + const oid = state.ownerMap[t]; + let border = false; + if (x > 0 && state.ownerMap[t - 1] !== oid) border = true; + if (x < MAP_WIDTH - 1 && state.ownerMap[t + 1] !== oid) border = true; + if (y > 0 && state.ownerMap[t - MAP_WIDTH] !== oid) border = true; + if (y < MAP_HEIGHT - 1 && state.ownerMap[t + MAP_WIDTH] !== oid) + border = true; + state.borderMap[t] = border ? 1 : 0; + } + + return changed; +} + +// ═══════════════════════════════════════════════════════════════════════════ +// Fresh state builder +// ═══════════════════════════════════════════════════════════════════════════ + +export function freshState(): MockGameState { + const regions = buildPlayerRegions(); + const players = regions.map((r, i) => + createMockPlayerView(r, PLAYER_COLORS[i]), + ); + const ownerMap = buildOwnerMap(regions); + const borderMap = computeBorders(ownerMap, MAP_WIDTH, MAP_HEIGHT); + return { + ownerMap, + borderMap, + regions, + players, + recentTiles: [], + tileOwnerChangedUpdates: [], + currentTick: 0, + }; +} + +// ═══════════════════════════════════════════════════════════════════════════ +// Generic benchmark runner +// ═══════════════════════════════════════════════════════════════════════════ + +function hrtime(): number { + return performance.now(); +} + +interface BenchCtx { + layer: Layer; + renderCtx: CanvasRenderingContext2D; + gpuCounters: GpuCounters; + state: MockGameState; +} + +function runBenchmark( + label: string, + warmup: number, + iterations: number, + setup: () => BenchCtx, + action: (ctx: BenchCtx) => void, +): BenchmarkResult { + const timings: number[] = []; + const totalGpu: GpuCounters = { + putImageDataCalls: 0, + drawImageCalls: 0, + totalDirtyPixels: 0, + }; + const totalRuns = warmup + iterations; + + for (let i = 0; i < totalRuns; i++) { + const ctx = setup(); + resetGpuCounters(ctx.gpuCounters); + + const t0 = hrtime(); + action(ctx); + const t1 = hrtime(); + + if (i >= warmup) { + timings.push(t1 - t0); + totalGpu.putImageDataCalls += ctx.gpuCounters.putImageDataCalls; + totalGpu.drawImageCalls += ctx.gpuCounters.drawImageCalls; + totalGpu.totalDirtyPixels += ctx.gpuCounters.totalDirtyPixels; + } + } + + return computeStats(label, timings, totalGpu); +} + +// ═══════════════════════════════════════════════════════════════════════════ +// Public: run the full benchmark suite for any Layer implementation +// ═══════════════════════════════════════════════════════════════════════════ + +/** + * Registers a Jest `describe` block with 6 standard scenarios for the given + * Layer implementation. Call this from a `*.test.ts` file. + * + * @param suiteName Label for the describe block (e.g. "Canvas2D TerritoryLayer") + * @param factory Creates the Layer under test from mock dependencies. + * @param options Optional tuning knobs. + */ +export function runTerritoryBenchSuite( + suiteName: string, + factory: LayerFactory, + options: { warmup?: number; iterations?: number } = {}, +) { + const WARMUP = options.warmup ?? 3; + const ITERATIONS = options.iterations ?? 10; + + describe(suiteName, () => { + const allResults: BenchmarkResult[] = []; + + // Suppress noisy console.log from implementations (e.g. "redrew territory layer") + const origLog = console.log; + beforeAll(() => { + console.log = (...args: any[]) => { + if ( + typeof args[0] === "string" && + args[0].includes("redrew territory layer") + ) + return; + origLog(...args); + }; + }); + + afterAll(() => { + console.log = origLog; + + // Print comparison table + console.log( + `\n╔══════════════════════════════════════════════════════════════╗`, + ); + console.log(`║ ${suiteName.padEnd(56)} ║`); + console.log( + `╚══════════════════════════════════════════════════════════════╝\n`, + ); + console.table( + allResults.map((r) => ({ + Scenario: r.scenario, + Samples: r.samples, + "Mean (ms)": r.meanMs, + "Median (ms)": r.medianMs, + "P95 (ms)": r.p95Ms, + "Std (ms)": r.stdMs, + "Min (ms)": r.minMs, + "Max (ms)": r.maxMs, + putImageData: r.putImageDataCalls, + drawImage: r.drawImageCalls, + "Dirty px (M)": +(r.totalDirtyPixels / 1_000_000).toFixed(2), + })), + ); + }); + + // ---- helpers ---- + + function makeLayer( + state: MockGameState, + gpuCounters: GpuCounters, + ): BenchCtx { + installCanvasMock(MAP_WIDTH, MAP_HEIGHT, gpuCounters); + const gameView = createMockGameView(state); + const eventBus = createMockEventBus(); + const transformHandler = createMockTransformHandler(); + const layer = factory(gameView, eventBus, transformHandler); + const renderCtx = createInstrumentedContext( + MAP_WIDTH, + MAP_HEIGHT, + gpuCounters, + ); + return { layer, renderCtx, gpuCounters, state }; + } + + function newGpuCounters(): GpuCounters { + return { putImageDataCalls: 0, drawImageCalls: 0, totalDirtyPixels: 0 }; + } + + // ---- Scenario 1: Full redraw ---- + + it("Scenario 1 — Full redraw (baseline)", () => { + const result = runBenchmark( + "1. Full redraw (240k tiles)", + WARMUP, + ITERATIONS, + () => makeLayer(freshState(), newGpuCounters()), + ({ layer }) => { + layer.redraw!(); + }, + ); + allResults.push(result); + expect(result.meanMs).toBeDefined(); + }); + + // ---- Scenario 2: Large single attack (5k tiles) ---- + + it("Scenario 2 — Large single attack (5 000 tiles)", () => { + const result = runBenchmark( + "2. Large attack (5k tiles)", + WARMUP, + ITERATIONS, + () => { + const state = freshState(); + const ctx = makeLayer(state, newGpuCounters()); + ctx.layer.init?.(); + ctx.layer.redraw!(); + resetGpuCounters(ctx.gpuCounters); + + const changed = simulateAttack( + state, + state.regions[0].smallID, + state.regions[1].smallID, + 5_000, + ); + state.recentTiles = changed; + state.tileOwnerChangedUpdates = changed.map((t) => ({ + type: GameUpdateType.TileOwnerChanged, + tile: t, + })); + state.currentTick++; + return ctx; + }, + ({ layer, renderCtx }) => { + layer.tick!(); + layer.renderLayer!(renderCtx); + }, + ); + allResults.push(result); + expect(result.meanMs).toBeDefined(); + }); + + // ---- Scenario 3: Multiple simultaneous attacks (3 × 3k tiles) ---- + + it("Scenario 3 — Multiple simultaneous attacks (3 × 3k tiles)", () => { + const result = runBenchmark( + "3. Multi-attack (3×3k tiles)", + WARMUP, + ITERATIONS, + () => { + const state = freshState(); + const ctx = makeLayer(state, newGpuCounters()); + ctx.layer.init?.(); + ctx.layer.redraw!(); + resetGpuCounters(ctx.gpuCounters); + + const allChanged: TileRef[] = []; + allChanged.push( + ...simulateAttack( + state, + state.regions[0].smallID, + state.regions[1].smallID, + 3_000, + ), + ); + allChanged.push( + ...simulateAttack( + state, + state.regions[1].smallID, + state.regions[2].smallID, + 3_000, + ), + ); + allChanged.push( + ...simulateAttack( + state, + state.regions[2].smallID, + state.regions[3].smallID, + 3_000, + ), + ); + state.recentTiles = allChanged; + state.tileOwnerChangedUpdates = allChanged.map((t) => ({ + type: GameUpdateType.TileOwnerChanged, + tile: t, + })); + state.currentTick++; + return ctx; + }, + ({ layer, renderCtx }) => { + layer.tick!(); + layer.renderLayer!(renderCtx); + }, + ); + allResults.push(result); + expect(result.meanMs).toBeDefined(); + }); + + // ---- Scenario 4: Sustained incremental (200 tiles/tick × 50 ticks) ---- + + it("Scenario 4 — Sustained incremental (200 tiles/tick × 50 ticks)", () => { + const NUM_TICKS = 50; + const TILES_PER_TICK = 200; + + const result = runBenchmark( + "4. Sustained (200/tick × 50)", + WARMUP, + ITERATIONS, + () => { + const state = freshState(); + const ctx = makeLayer(state, newGpuCounters()); + ctx.layer.init?.(); + ctx.layer.redraw!(); + resetGpuCounters(ctx.gpuCounters); + return ctx; + }, + ({ layer, renderCtx, state }) => { + for (let tick = 0; tick < NUM_TICKS; tick++) { + const changed = simulateAttack( + state, + state.regions[0].smallID, + state.regions[1].smallID, + TILES_PER_TICK, + ); + state.recentTiles = changed; + state.tileOwnerChangedUpdates = changed.map((t) => ({ + type: GameUpdateType.TileOwnerChanged, + tile: t, + })); + state.currentTick++; + layer.tick!(); + layer.renderLayer!(renderCtx); + } + }, + ); + allResults.push(result); + expect(result.meanMs).toBeDefined(); + }); + + // ---- Scenario 5: renderLayer only (queue already loaded) ---- + + it("Scenario 5 — renderLayer only (5k tiles queued)", () => { + const result = runBenchmark( + "5. renderLayer only (5k queued)", + WARMUP, + ITERATIONS, + () => { + const state = freshState(); + const ctx = makeLayer(state, newGpuCounters()); + ctx.layer.init?.(); + ctx.layer.redraw!(); + + const changed = simulateAttack( + state, + state.regions[0].smallID, + state.regions[1].smallID, + 5_000, + ); + state.recentTiles = changed; + state.tileOwnerChangedUpdates = changed.map((t) => ({ + type: GameUpdateType.TileOwnerChanged, + tile: t, + })); + state.currentTick++; + ctx.layer.tick!(); + + // Reset — measure only renderLayer + resetGpuCounters(ctx.gpuCounters); + state.recentTiles = []; + state.tileOwnerChangedUpdates = []; + return ctx; + }, + ({ layer, renderCtx }) => { + layer.renderLayer!(renderCtx); + }, + ); + allResults.push(result); + expect(result.meanMs).toBeDefined(); + }); + + // ---- Scenario 6: tick() only (no render) ---- + + it("Scenario 6 — tick() only (5k ownership changes)", () => { + const result = runBenchmark( + "6. tick() only (5k changes)", + WARMUP, + ITERATIONS, + () => { + const state = freshState(); + const ctx = makeLayer(state, newGpuCounters()); + ctx.layer.init?.(); + ctx.layer.redraw!(); + resetGpuCounters(ctx.gpuCounters); + + const changed = simulateAttack( + state, + state.regions[0].smallID, + state.regions[1].smallID, + 5_000, + ); + state.recentTiles = changed; + state.tileOwnerChangedUpdates = changed.map((t) => ({ + type: GameUpdateType.TileOwnerChanged, + tile: t, + })); + state.currentTick++; + return ctx; + }, + ({ layer }) => { + layer.tick!(); + }, + ); + allResults.push(result); + expect(result.meanMs).toBeDefined(); + }); + }); +}