diff --git a/old_territory_layer.txt b/old_territory_layer.txt new file mode 100644 index 000000000..19230ca03 Binary files /dev/null and b/old_territory_layer.txt differ diff --git a/src/client/graphics/layers/TerritoryLayer.ts b/src/client/graphics/layers/TerritoryLayer.ts index dc3a0fd91..7368e5870 100644 --- a/src/client/graphics/layers/TerritoryLayer.ts +++ b/src/client/graphics/layers/TerritoryLayer.ts @@ -1,4 +1,3 @@ -import { PriorityQueue } from "@datastructures-js/priority-queue"; import type { Colord } from "colord"; import type { Theme } from "../../../core/configuration/Config"; import type { EventBus } from "../../../core/EventBus"; @@ -7,7 +6,6 @@ import type { TileRef } from "../../../core/game/GameMap"; import { GameUpdateType } from "../../../core/game/GameUpdates"; import type { GameView } from "../../../core/game/GameView"; import { PlayerView } from "../../../core/game/GameView"; -import { PseudoRandom } from "../../../core/PseudoRandom"; import { AlternateViewEvent, MouseOverEvent } from "../../InputHandler"; import type { TransformHandler } from "../TransformHandler"; import type { Layer } from "./Layer"; @@ -17,19 +15,15 @@ export class TerritoryLayer implements Layer { private canvas: HTMLCanvasElement; private context: CanvasRenderingContext2D; private imageData: ImageData; + private imageData32: Uint32Array; private alternativeImageData: ImageData; + private altData32: Uint32Array; // Used for spawn highlighting private highlightCanvas: HTMLCanvasElement; private highlightContext: CanvasRenderingContext2D; - private tileToRenderQueue: PriorityQueue<{ - tile: TileRef; - lastUpdate: number; - }> = 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(); + }); + }); +}