From 3056abb71a84e658a61a46622db83d0341f5ec5e Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 20 Mar 2026 16:33:11 +0000 Subject: [PATCH 1/4] Initial plan From 47aedf418d5a2a0932b29631ab32e6146960f4fa Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 20 Mar 2026 16:43:33 +0000 Subject: [PATCH 2/4] debug: instrument runtime render recovery paths Co-authored-by: Skigim <217411484+Skigim@users.noreply.github.com> --- src/client/graphics/GameRenderer.ts | 106 ++++++++++++- .../graphics/layers/StructureIconsLayer.ts | 140 ++++++++++++++++++ src/client/graphics/layers/UnitLayer.ts | 112 +++++++++++++- .../layers/StructureIconsLayer.test.ts | 100 ++++++++++++- 4 files changed, 449 insertions(+), 9 deletions(-) diff --git a/src/client/graphics/GameRenderer.ts b/src/client/graphics/GameRenderer.ts index 1937b4a3e6..e98020e1c1 100644 --- a/src/client/graphics/GameRenderer.ts +++ b/src/client/graphics/GameRenderer.ts @@ -229,6 +229,13 @@ export function createRenderer( const structureLayer = new StructureLayer(game, eventBus, transformHandler); const samRadiusLayer = new SAMRadiusLayer(game, eventBus, uiState); + const unitLayer = new UnitLayer(game, eventBus, transformHandler); + const structureIconsLayer = new StructureIconsLayer( + game, + eventBus, + uiState, + transformHandler, + ); const performanceOverlay = document.querySelector( "performance-overlay", @@ -278,11 +285,11 @@ export function createRenderer( new CoordinateGridLayer(game, eventBus, transformHandler), structureLayer, samRadiusLayer, - new UnitLayer(game, eventBus, transformHandler), + unitLayer, new FxLayer(game, eventBus, transformHandler), new UILayer(game, eventBus, transformHandler), new NukeTrajectoryPreviewLayer(game, eventBus, transformHandler, uiState), - new StructureIconsLayer(game, eventBus, uiState, transformHandler), + structureIconsLayer, new DynamicUILayer(game, transformHandler, eventBus), new NameLayer(game, transformHandler, eventBus), new AttackingTroopsOverlay(game, transformHandler, eventBus, userSettings), @@ -319,7 +326,7 @@ export function createRenderer( performanceOverlay, ]; - return new GameRenderer( + const renderer = new GameRenderer( game, eventBus, canvas, @@ -328,9 +335,71 @@ export function createRenderer( layers, performanceOverlay, ); + + const debugApi = ( + globalThis as typeof globalThis & { + __OPENFRONT_RENDER_DEBUG__?: Record & { + enabled?: boolean; + }; + } + ).__OPENFRONT_RENDER_DEBUG__; + const renderDebug = debugApi ?? { enabled: false }; + Object.assign(renderDebug, { + enable() { + renderDebug.enabled = true; + return renderer.captureDebugState(); + }, + disable() { + renderDebug.enabled = false; + return renderer.captureDebugState(); + }, + snapshot() { + const snapshot = renderer.captureDebugState(); + console.info("[RenderDebug] snapshot", snapshot); + return snapshot; + }, + clearMainCanvasOnly() { + renderer.clearMainCanvasOnly(); + return renderer.captureDebugState(); + }, + redrawAll() { + renderer.redraw(); + return renderer.captureDebugState(); + }, + redrawStructureIcons() { + structureIconsLayer.redraw(); + return structureIconsLayer.captureDebugState(); + }, + rebuildStructureIcons(reason = "debug-api") { + return structureIconsLayer.rebuildAllStructuresFromState(reason); + }, + simulateStructureDisruption(options?: unknown) { + return structureIconsLayer.simulateRendererDisruption( + options as Parameters< + StructureIconsLayer["simulateRendererDisruption"] + >[0], + ); + }, + redrawUnits(reason = "debug-api") { + return unitLayer.rebuildAllUnitsFromState(reason); + }, + simulateUnitCanvasReset(options?: unknown) { + return unitLayer.simulateCanvasReset( + options as Parameters[0], + ); + }, + }); + ( + globalThis as typeof globalThis & { + __OPENFRONT_RENDER_DEBUG__?: typeof renderDebug; + } + ).__OPENFRONT_RENDER_DEBUG__ = renderDebug; + + return renderer; } export class GameRenderer { + private static readonly DEBUG_GLOBAL_KEY = "__OPENFRONT_RENDER_DEBUG__"; private context: CanvasRenderingContext2D; private layerTickState = new Map(); private renderFramesSinceLastTick: number = 0; @@ -367,9 +436,11 @@ export class GameRenderer { let rafId = requestAnimationFrame(() => this.renderGame()); this.canvas.addEventListener("contextlost", () => { + this.debugLog("contextlost", this.captureDebugState()); cancelAnimationFrame(rafId); }); this.canvas.addEventListener("contextrestored", () => { + this.debugLog("contextrestored", this.captureDebugState()); this.redraw(); rafId = requestAnimationFrame(() => this.renderGame()); }); @@ -379,10 +450,12 @@ export class GameRenderer { this.canvas.width = window.innerWidth; this.canvas.height = window.innerHeight; this.transformHandler.updateCanvasBoundingRect(); + this.debugLog("resizeCanvas", this.captureDebugState()); //this.redraw() } redraw() { + this.debugLog("redraw", this.captureDebugState()); this.layers.forEach((l) => { if (l.redraw) { l.redraw(); @@ -390,6 +463,21 @@ export class GameRenderer { }); } + clearMainCanvasOnly() { + this.context.clearRect(0, 0, this.canvas.width, this.canvas.height); + this.debugLog("clearMainCanvasOnly", this.captureDebugState()); + } + + captureDebugState() { + return { + canvasWidth: this.canvas.width, + canvasHeight: this.canvas.height, + layerCount: this.layers.length, + renderFramesSinceLastTick: this.renderFramesSinceLastTick, + layerTickStates: this.layerTickState.size, + }; + } + renderGame() { const shouldProfileFrame = FrameProfiler.isEnabled(); if (shouldProfileFrame) { @@ -513,4 +601,16 @@ export class GameRenderer { this.canvas.width = Math.ceil(width / window.devicePixelRatio); this.canvas.height = Math.ceil(height / window.devicePixelRatio); } + + private debugLog(event: string, details?: unknown) { + const debug = ( + globalThis as typeof globalThis & { + [GameRenderer.DEBUG_GLOBAL_KEY]?: { enabled?: boolean }; + } + )[GameRenderer.DEBUG_GLOBAL_KEY]; + if (!debug?.enabled) { + return; + } + console.info(`[RenderDebug][GameRenderer] ${event}`, details); + } } diff --git a/src/client/graphics/layers/StructureIconsLayer.ts b/src/client/graphics/layers/StructureIconsLayer.ts index a3018d5420..f13c65ceff 100644 --- a/src/client/graphics/layers/StructureIconsLayer.ts +++ b/src/client/graphics/layers/StructureIconsLayer.ts @@ -102,6 +102,8 @@ export class StructureIconsLayer implements Layer { private hasHiddenStructure = false; potentialUpgrade: StructureRenderInfo | undefined; + private static readonly DEBUG_GLOBAL_KEY = "__OPENFRONT_RENDER_DEBUG__"; + constructor( private game: GameView, private eventBus: EventBus, @@ -166,6 +168,15 @@ export class StructureIconsLayer implements Layer { this.renderer = renderer; this.rendererInitialized = true; + this.pixicanvas.addEventListener("webglcontextlost", (event) => { + event.preventDefault(); + this.debugLog("webglcontextlost", this.captureDebugState()); + }); + this.pixicanvas.addEventListener("webglcontextrestored", () => { + this.debugLog("webglcontextrestored", this.captureDebugState()); + this.resizeCanvas(); + this.rebuildAllStructuresFromState("webglcontextrestored"); + }); } shouldTransform(): boolean { @@ -195,6 +206,15 @@ export class StructureIconsLayer implements Layer { this.pixicanvas.width = window.innerWidth; this.pixicanvas.height = window.innerHeight; this.renderer.resize(innerWidth, innerHeight, 1); + this.iconsStage?.setSize(this.pixicanvas.width, this.pixicanvas.height); + this.ghostStage?.setSize(this.pixicanvas.width, this.pixicanvas.height); + this.levelsStage?.setSize(this.pixicanvas.width, this.pixicanvas.height); + this.dotsStage?.setSize(this.pixicanvas.width, this.pixicanvas.height); + this.rootStage?.setSize(this.pixicanvas.width, this.pixicanvas.height); + for (const render of this.rendersByUnitId.values()) { + this.computeNewLocation(render); + } + this.debugLog("resizeCanvas", this.captureDebugState()); } } @@ -220,7 +240,94 @@ export class StructureIconsLayer implements Layer { } redraw() { + this.debugLog("redraw", this.captureDebugState()); this.resizeCanvas(); + this.rebuildAllStructuresFromState("redraw"); + } + + captureDebugState() { + return { + rendererInitialized: this.rendererInitialized, + rendersByUnitIdSize: this.rendersByUnitId.size, + seenUnitIdsSize: this.seenUnitIds.size, + dotsStageChildren: this.dotsStage?.children.length ?? 0, + iconsStageChildren: this.iconsStage?.children.length ?? 0, + levelsStageChildren: this.levelsStage?.children.length ?? 0, + ghostStageChildren: this.ghostStage?.children.length ?? 0, + canvasWidth: this.pixicanvas?.width ?? 0, + canvasHeight: this.pixicanvas?.height ?? 0, + }; + } + + rebuildAllStructuresFromState(reason = "manual") { + const before = this.captureDebugState(); + this.debugLog(`rebuildAllStructuresFromState:start:${reason}`, before); + this.clearAllStructureRenders(); + + let rebuilt = 0; + for (const unitView of this.game.units()) { + if ( + unitView.isActive() && + this.structures.has(unitView.type() as PlayerBuildableUnitType) + ) { + this.addNewStructure(unitView); + rebuilt++; + } + } + + const after = this.captureDebugState(); + this.debugLog(`rebuildAllStructuresFromState:end:${reason}`, { + rebuilt, + before, + after, + }); + return after; + } + + simulateRendererDisruption(options?: { + clearStages?: boolean; + preserveTracking?: boolean; + aggressiveResize?: boolean; + rebuildFromState?: boolean; + }) { + const { + clearStages = true, + preserveTracking = true, + aggressiveResize = true, + rebuildFromState = false, + } = options ?? {}; + const before = this.captureDebugState(); + this.debugLog("simulateRendererDisruption:start", { options, before }); + + if (clearStages) { + this.clearStageChildren(this.iconsStage); + this.clearStageChildren(this.levelsStage); + this.clearStageChildren(this.dotsStage); + } + + if (!preserveTracking) { + this.rendersByUnitId.clear(); + this.seenUnitIds.clear(); + } + + if (aggressiveResize && this.renderer) { + this.renderer.resize( + Math.max(1, Math.floor(this.pixicanvas.width / 2)), + Math.max(1, Math.floor(this.pixicanvas.height / 2)), + 1, + ); + this.resizeCanvas(); + } + + this.renderer?.resetState(); + + if (rebuildFromState) { + return this.rebuildAllStructuresFromState("simulateRendererDisruption"); + } + + const after = this.captureDebugState(); + this.debugLog("simulateRendererDisruption:end", { options, before, after }); + return after; } renderLayer(mainContext: CanvasRenderingContext2D) { @@ -852,4 +959,37 @@ export class StructureIconsLayer implements Layer { this.potentialUpgrade = undefined; } } + + private clearAllStructureRenders() { + for (const render of Array.from(this.rendersByUnitId.values())) { + this.deleteStructure(render); + } + this.clearStageChildren(this.iconsStage); + this.clearStageChildren(this.levelsStage); + this.clearStageChildren(this.dotsStage); + this.rendersByUnitId.clear(); + this.seenUnitIds.clear(); + } + + private clearStageChildren(stage?: PIXI.Container) { + if (!stage) { + return; + } + + for (const child of stage.removeChildren()) { + child.destroy(); + } + } + + private debugLog(event: string, details?: unknown) { + const debug = ( + globalThis as typeof globalThis & { + [StructureIconsLayer.DEBUG_GLOBAL_KEY]?: { enabled?: boolean }; + } + )[StructureIconsLayer.DEBUG_GLOBAL_KEY]; + if (!debug?.enabled) { + return; + } + console.info(`[RenderDebug][StructureIconsLayer] ${event}`, details); + } } diff --git a/src/client/graphics/layers/UnitLayer.ts b/src/client/graphics/layers/UnitLayer.ts index e5d72f0e8f..1910b47aaf 100644 --- a/src/client/graphics/layers/UnitLayer.ts +++ b/src/client/graphics/layers/UnitLayer.ts @@ -30,6 +30,7 @@ enum Relationship { } export class UnitLayer implements Layer { + private static readonly DEBUG_GLOBAL_KEY = "__OPENFRONT_RENDER_DEBUG__"; private canvas: HTMLCanvasElement; private context: CanvasRenderingContext2D; private transportShipTrailCanvas: HTMLCanvasElement; @@ -50,6 +51,7 @@ export class UnitLayer implements Layer { // Configuration for unit selection private readonly WARSHIP_SELECTION_RADIUS = 10; // Radius in game cells for warship selection hit zone + private skipIncrementalFrames = 0; constructor( private game: GameView, @@ -65,6 +67,14 @@ export class UnitLayer implements Layer { } tick() { + if (this.skipIncrementalFrames > 0) { + this.skipIncrementalFrames--; + this.debugLog("tick:skipped", { + remainingSkippedFrames: this.skipIncrementalFrames, + }); + return; + } + const updatedUnitIds = this.game .updatesSinceLastTick() @@ -242,6 +252,8 @@ export class UnitLayer implements Layer { } redraw() { + const previousWidth = this.canvas?.width ?? 0; + const previousHeight = this.canvas?.height ?? 0; this.canvas = document.createElement("canvas"); const context = this.canvas.getContext("2d"); if (context === null) throw new Error("2d context not supported"); @@ -256,6 +268,12 @@ export class UnitLayer implements Layer { this.transportShipTrailCanvas.width = this.game.width(); this.transportShipTrailCanvas.height = this.game.height(); + this.debugLog("redraw", { + previousWidth, + previousHeight, + canvasWidth: this.canvas.width, + canvasHeight: this.canvas.height, + }); this.updateUnitsSprites(this.game.units().map((unit) => unit.id())); this.unitToTrail.forEach((trail, unit) => { @@ -273,11 +291,25 @@ export class UnitLayer implements Layer { } private updateUnitsSprites(unitIds: number[]) { - const unitsToUpdate = unitIds - ?.map((id) => this.game.unit(id)) - .filter((unit) => unit !== undefined); + const unresolvedIds: number[] = []; + const unitsToUpdate = + unitIds + ?.map((id) => { + const unit = this.game.unit(id); + if (unit === undefined) { + unresolvedIds.push(id); + } + return unit; + }) + .filter((unit) => unit !== undefined) ?? []; + + this.debugLog("updateUnitsSprites", { + inputIds: unitIds.length, + resolvedUnits: unitsToUpdate.length, + unresolvedIds, + }); - if (unitsToUpdate) { + if (unitsToUpdate.length > 0) { // the clearing and drawing of unit sprites need to be done in 2 passes // otherwise the sprite of a unit can be drawn on top of another unit this.clearUnitsCells(unitsToUpdate); @@ -285,6 +317,66 @@ export class UnitLayer implements Layer { } } + captureDebugState() { + return { + canvasWidth: this.canvas?.width ?? 0, + canvasHeight: this.canvas?.height ?? 0, + trailCanvasWidth: this.transportShipTrailCanvas?.width ?? 0, + trailCanvasHeight: this.transportShipTrailCanvas?.height ?? 0, + selectedUnitId: this.selectedUnit?.id() ?? null, + motionPlannedUnitIds: this.game.motionPlannedUnitIds().length, + totalActiveUnits: this.game.units().length, + skipIncrementalFrames: this.skipIncrementalFrames, + }; + } + + rebuildAllUnitsFromState(reason = "manual") { + this.debugLog(`rebuildAllUnitsFromState:${reason}`, this.captureDebugState()); + this.redraw(); + return this.captureDebugState(); + } + + simulateCanvasReset(options?: { + clearCanvas?: boolean; + clearTrailCanvas?: boolean; + skipNextIncrementalFrames?: number; + rebuildFromState?: boolean; + }) { + const { + clearCanvas = true, + clearTrailCanvas = true, + skipNextIncrementalFrames = 0, + rebuildFromState = false, + } = options ?? {}; + const before = this.captureDebugState(); + this.debugLog("simulateCanvasReset:start", { options, before }); + + if (clearCanvas) { + this.context.clearRect(0, 0, this.canvas.width, this.canvas.height); + } + if (clearTrailCanvas) { + this.unitTrailContext.clearRect( + 0, + 0, + this.transportShipTrailCanvas.width, + this.transportShipTrailCanvas.height, + ); + } + + this.skipIncrementalFrames = Math.max( + this.skipIncrementalFrames, + skipNextIncrementalFrames, + ); + + if (rebuildFromState) { + return this.rebuildAllUnitsFromState("simulateCanvasReset"); + } + + const after = this.captureDebugState(); + this.debugLog("simulateCanvasReset:end", { options, before, after }); + return after; + } + private clearUnitsCells(unitViews: UnitView[]) { unitViews .filter((unitView) => isSpriteReady(unitView)) @@ -618,4 +710,16 @@ export class UnitLayer implements Layer { } } } + + private debugLog(event: string, details?: unknown) { + const debug = ( + globalThis as typeof globalThis & { + [UnitLayer.DEBUG_GLOBAL_KEY]?: { enabled?: boolean }; + } + )[UnitLayer.DEBUG_GLOBAL_KEY]; + if (!debug?.enabled) { + return; + } + console.info(`[RenderDebug][UnitLayer] ${event}`, details); + } } diff --git a/tests/client/graphics/layers/StructureIconsLayer.test.ts b/tests/client/graphics/layers/StructureIconsLayer.test.ts index 7cb8b557cc..6ac024e354 100644 --- a/tests/client/graphics/layers/StructureIconsLayer.test.ts +++ b/tests/client/graphics/layers/StructureIconsLayer.test.ts @@ -1,6 +1,36 @@ -import { describe, expect, test } from "vitest"; -import { shouldPreserveGhostAfterBuild } from "../../../../src/client/graphics/layers/StructureIconsLayer"; +import { describe, expect, test, vi } from "vitest"; +import { + shouldPreserveGhostAfterBuild, + StructureIconsLayer, +} from "../../../../src/client/graphics/layers/StructureIconsLayer"; import { UnitType } from "../../../../src/core/game/Game"; +import { EventBus } from "../../../../src/core/EventBus"; + +function createLayerWithMockGame(units: Array<{ id(): number; type(): UnitType; isActive(): boolean }>) { + const game = { + config: () => ({ + theme: () => ({}), + userSettings: () => ({ structureSprites: () => true }), + }), + units: () => units, + }; + const transformHandler = { + scale: 1, + worldToScreenCoordinates: (cell: { x: number; y: number }) => cell, + }; + return new StructureIconsLayer( + game as never, + new EventBus(), + { + attackRatio: 20, + ghostStructure: null, + overlappingRailroads: [], + ghostRailPaths: [], + rocketDirectionUp: true, + }, + transformHandler as never, + ); +} /** * Tests for StructureIconsLayer edge cases mentioned in comments: @@ -35,4 +65,70 @@ describe("StructureIconsLayer ghost preservation (locked nuke / Enter confirm)", expect(shouldPreserveGhostAfterBuild(UnitType.MIRV)).toBe(false); }); }); + + test("redraw resizes the canvas and rebuilds structures from authoritative state", () => { + const layer = createLayerWithMockGame([]); + const resizeSpy = vi + .spyOn(layer, "resizeCanvas") + .mockImplementation(() => undefined); + const rebuildSpy = vi + .spyOn(layer, "rebuildAllStructuresFromState") + .mockReturnValue({} as ReturnType); + + layer.redraw(); + + expect(resizeSpy).toHaveBeenCalledOnce(); + expect(rebuildSpy).toHaveBeenCalledWith("redraw"); + }); + + test("rebuildAllStructuresFromState drops stale tracked renders and re-adds active structures from game state", () => { + const activeCity = { + id: () => 101, + type: () => UnitType.City, + isActive: () => true, + }; + const inactiveCity = { + id: () => 102, + type: () => UnitType.City, + isActive: () => false, + }; + const activeTransportShip = { + id: () => 103, + type: () => UnitType.TransportShip, + isActive: () => true, + }; + const layer = createLayerWithMockGame([ + activeCity, + inactiveCity, + activeTransportShip, + ]); + const staleDestroy = vi.fn(); + const staleRender = { + unit: { id: () => 7 }, + iconContainer: { destroy: staleDestroy }, + levelContainer: { destroy: staleDestroy }, + dotContainer: { destroy: staleDestroy }, + }; + const seenUnitIds = (layer as any).seenUnitIds as Set; + const rendersByUnitId = (layer as any).rendersByUnitId as Map; + seenUnitIds.add(7); + rendersByUnitId.set(7, staleRender); + (layer as any).iconsStage = { children: [], removeChildren: vi.fn(() => []) }; + (layer as any).levelsStage = { children: [], removeChildren: vi.fn(() => []) }; + (layer as any).dotsStage = { children: [], removeChildren: vi.fn(() => []) }; + const addNewStructureSpy = vi + .spyOn(layer as any, "addNewStructure") + .mockImplementation((unit: { id(): number }) => { + seenUnitIds.add(unit.id()); + rendersByUnitId.set(unit.id(), { unit }); + }); + + layer.rebuildAllStructuresFromState("test"); + + expect(staleDestroy).toHaveBeenCalledTimes(3); + expect(addNewStructureSpy).toHaveBeenCalledTimes(1); + expect(addNewStructureSpy).toHaveBeenCalledWith(activeCity); + expect(Array.from(rendersByUnitId.keys())).toEqual([101]); + expect(Array.from(seenUnitIds)).toEqual([101]); + }); }); From 8b31624143111fae987dbd9c61ce5d99931c9303 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 20 Mar 2026 16:49:17 +0000 Subject: [PATCH 3/4] test: finalize render recovery investigation hooks Co-authored-by: Skigim <217411484+Skigim@users.noreply.github.com> --- src/client/graphics/GameRenderer.ts | 16 +++--- .../graphics/layers/StructureIconsLayer.ts | 18 ++++--- src/client/graphics/layers/UnitLayer.ts | 6 +-- .../layers/StructureIconsLayer.test.ts | 54 +++++++++++++++---- 4 files changed, 66 insertions(+), 28 deletions(-) diff --git a/src/client/graphics/GameRenderer.ts b/src/client/graphics/GameRenderer.ts index e98020e1c1..cfdfc4fa4b 100644 --- a/src/client/graphics/GameRenderer.ts +++ b/src/client/graphics/GameRenderer.ts @@ -373,20 +373,20 @@ export function createRenderer( rebuildStructureIcons(reason = "debug-api") { return structureIconsLayer.rebuildAllStructuresFromState(reason); }, - simulateStructureDisruption(options?: unknown) { + simulateStructureDisruption( + options?: Parameters[0], + ) { return structureIconsLayer.simulateRendererDisruption( - options as Parameters< - StructureIconsLayer["simulateRendererDisruption"] - >[0], + options, ); }, redrawUnits(reason = "debug-api") { return unitLayer.rebuildAllUnitsFromState(reason); }, - simulateUnitCanvasReset(options?: unknown) { - return unitLayer.simulateCanvasReset( - options as Parameters[0], - ); + simulateUnitCanvasReset( + options?: Parameters[0], + ) { + return unitLayer.simulateCanvasReset(options); }, }); ( diff --git a/src/client/graphics/layers/StructureIconsLayer.ts b/src/client/graphics/layers/StructureIconsLayer.ts index f13c65ceff..31b9025e4d 100644 --- a/src/client/graphics/layers/StructureIconsLayer.ts +++ b/src/client/graphics/layers/StructureIconsLayer.ts @@ -206,11 +206,9 @@ export class StructureIconsLayer implements Layer { this.pixicanvas.width = window.innerWidth; this.pixicanvas.height = window.innerHeight; this.renderer.resize(innerWidth, innerHeight, 1); - this.iconsStage?.setSize(this.pixicanvas.width, this.pixicanvas.height); - this.ghostStage?.setSize(this.pixicanvas.width, this.pixicanvas.height); - this.levelsStage?.setSize(this.pixicanvas.width, this.pixicanvas.height); - this.dotsStage?.setSize(this.pixicanvas.width, this.pixicanvas.height); - this.rootStage?.setSize(this.pixicanvas.width, this.pixicanvas.height); + this.resizeStages(); + // Canvas size changes affect screen-space culling and icon placement, so + // recompute every tracked structure location after a resize/reset. for (const render of this.rendersByUnitId.values()) { this.computeNewLocation(render); } @@ -977,10 +975,18 @@ export class StructureIconsLayer implements Layer { } for (const child of stage.removeChildren()) { - child.destroy(); + child.destroy({ children: true }); } } + private resizeStages() { + this.iconsStage?.setSize(this.pixicanvas.width, this.pixicanvas.height); + this.ghostStage?.setSize(this.pixicanvas.width, this.pixicanvas.height); + this.levelsStage?.setSize(this.pixicanvas.width, this.pixicanvas.height); + this.dotsStage?.setSize(this.pixicanvas.width, this.pixicanvas.height); + this.rootStage?.setSize(this.pixicanvas.width, this.pixicanvas.height); + } + private debugLog(event: string, details?: unknown) { const debug = ( globalThis as typeof globalThis & { diff --git a/src/client/graphics/layers/UnitLayer.ts b/src/client/graphics/layers/UnitLayer.ts index 1910b47aaf..bd8aa70a18 100644 --- a/src/client/graphics/layers/UnitLayer.ts +++ b/src/client/graphics/layers/UnitLayer.ts @@ -291,13 +291,13 @@ export class UnitLayer implements Layer { } private updateUnitsSprites(unitIds: number[]) { - const unresolvedIds: number[] = []; + const missingUnitIds: number[] = []; const unitsToUpdate = unitIds ?.map((id) => { const unit = this.game.unit(id); if (unit === undefined) { - unresolvedIds.push(id); + missingUnitIds.push(id); } return unit; }) @@ -306,7 +306,7 @@ export class UnitLayer implements Layer { this.debugLog("updateUnitsSprites", { inputIds: unitIds.length, resolvedUnits: unitsToUpdate.length, - unresolvedIds, + missingUnitIds, }); if (unitsToUpdate.length > 0) { diff --git a/tests/client/graphics/layers/StructureIconsLayer.test.ts b/tests/client/graphics/layers/StructureIconsLayer.test.ts index 6ac024e354..19a5f9854b 100644 --- a/tests/client/graphics/layers/StructureIconsLayer.test.ts +++ b/tests/client/graphics/layers/StructureIconsLayer.test.ts @@ -6,7 +6,23 @@ import { import { UnitType } from "../../../../src/core/game/Game"; import { EventBus } from "../../../../src/core/EventBus"; -function createLayerWithMockGame(units: Array<{ id(): number; type(): UnitType; isActive(): boolean }>) { +type MockStructureUnit = { + id(): number; + type(): UnitType; + isActive(): boolean; +}; + +type StructureIconsLayerTestInternals = { + seenUnitIds: Set; + rendersByUnitId: Map; + iconsStage: { children: unknown[]; removeChildren(): unknown[] }; + levelsStage: { children: unknown[]; removeChildren(): unknown[] }; + dotsStage: { children: unknown[]; removeChildren(): unknown[] }; + addNewStructure(unit: { id(): number }): void; + rebuildAllStructuresFromState(reason?: string): unknown; +}; + +function createStructureIconsLayerWithMockGame(units: MockStructureUnit[]) { const game = { config: () => ({ theme: () => ({}), @@ -67,13 +83,23 @@ describe("StructureIconsLayer ghost preservation (locked nuke / Enter confirm)", }); test("redraw resizes the canvas and rebuilds structures from authoritative state", () => { - const layer = createLayerWithMockGame([]); + const layer = createStructureIconsLayerWithMockGame([]); const resizeSpy = vi .spyOn(layer, "resizeCanvas") .mockImplementation(() => undefined); const rebuildSpy = vi .spyOn(layer, "rebuildAllStructuresFromState") - .mockReturnValue({} as ReturnType); + .mockReturnValue({ + rendererInitialized: false, + rendersByUnitIdSize: 0, + seenUnitIdsSize: 0, + dotsStageChildren: 0, + iconsStageChildren: 0, + levelsStageChildren: 0, + ghostStageChildren: 0, + canvasWidth: 0, + canvasHeight: 0, + }); layer.redraw(); @@ -97,11 +123,12 @@ describe("StructureIconsLayer ghost preservation (locked nuke / Enter confirm)", type: () => UnitType.TransportShip, isActive: () => true, }; - const layer = createLayerWithMockGame([ + const layer = createStructureIconsLayerWithMockGame([ activeCity, inactiveCity, activeTransportShip, ]); + const layerInternals = layer as unknown as StructureIconsLayerTestInternals; const staleDestroy = vi.fn(); const staleRender = { unit: { id: () => 7 }, @@ -109,21 +136,26 @@ describe("StructureIconsLayer ghost preservation (locked nuke / Enter confirm)", levelContainer: { destroy: staleDestroy }, dotContainer: { destroy: staleDestroy }, }; - const seenUnitIds = (layer as any).seenUnitIds as Set; - const rendersByUnitId = (layer as any).rendersByUnitId as Map; + const { seenUnitIds, rendersByUnitId } = layerInternals; seenUnitIds.add(7); rendersByUnitId.set(7, staleRender); - (layer as any).iconsStage = { children: [], removeChildren: vi.fn(() => []) }; - (layer as any).levelsStage = { children: [], removeChildren: vi.fn(() => []) }; - (layer as any).dotsStage = { children: [], removeChildren: vi.fn(() => []) }; + layerInternals.iconsStage = { + children: [], + removeChildren: vi.fn(() => []), + }; + layerInternals.levelsStage = { + children: [], + removeChildren: vi.fn(() => []), + }; + layerInternals.dotsStage = { children: [], removeChildren: vi.fn(() => []) }; const addNewStructureSpy = vi - .spyOn(layer as any, "addNewStructure") + .spyOn(layerInternals, "addNewStructure") .mockImplementation((unit: { id(): number }) => { seenUnitIds.add(unit.id()); rendersByUnitId.set(unit.id(), { unit }); }); - layer.rebuildAllStructuresFromState("test"); + layerInternals.rebuildAllStructuresFromState("test"); expect(staleDestroy).toHaveBeenCalledTimes(3); expect(addNewStructureSpy).toHaveBeenCalledTimes(1); From 0c82597d1b42d91b8d71c753fba37865f494465f Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 20 Mar 2026 17:05:10 +0000 Subject: [PATCH 4/4] refactor: remove temporary render debug API Co-authored-by: Skigim <217411484+Skigim@users.noreply.github.com> --- src/client/graphics/GameRenderer.ts | 106 +----------------- .../graphics/layers/StructureIconsLayer.ts | 103 ++--------------- src/client/graphics/layers/UnitLayer.ts | 105 +---------------- .../layers/StructureIconsLayer.test.ts | 81 ++++++++----- 4 files changed, 65 insertions(+), 330 deletions(-) diff --git a/src/client/graphics/GameRenderer.ts b/src/client/graphics/GameRenderer.ts index cfdfc4fa4b..1937b4a3e6 100644 --- a/src/client/graphics/GameRenderer.ts +++ b/src/client/graphics/GameRenderer.ts @@ -229,13 +229,6 @@ export function createRenderer( const structureLayer = new StructureLayer(game, eventBus, transformHandler); const samRadiusLayer = new SAMRadiusLayer(game, eventBus, uiState); - const unitLayer = new UnitLayer(game, eventBus, transformHandler); - const structureIconsLayer = new StructureIconsLayer( - game, - eventBus, - uiState, - transformHandler, - ); const performanceOverlay = document.querySelector( "performance-overlay", @@ -285,11 +278,11 @@ export function createRenderer( new CoordinateGridLayer(game, eventBus, transformHandler), structureLayer, samRadiusLayer, - unitLayer, + new UnitLayer(game, eventBus, transformHandler), new FxLayer(game, eventBus, transformHandler), new UILayer(game, eventBus, transformHandler), new NukeTrajectoryPreviewLayer(game, eventBus, transformHandler, uiState), - structureIconsLayer, + new StructureIconsLayer(game, eventBus, uiState, transformHandler), new DynamicUILayer(game, transformHandler, eventBus), new NameLayer(game, transformHandler, eventBus), new AttackingTroopsOverlay(game, transformHandler, eventBus, userSettings), @@ -326,7 +319,7 @@ export function createRenderer( performanceOverlay, ]; - const renderer = new GameRenderer( + return new GameRenderer( game, eventBus, canvas, @@ -335,71 +328,9 @@ export function createRenderer( layers, performanceOverlay, ); - - const debugApi = ( - globalThis as typeof globalThis & { - __OPENFRONT_RENDER_DEBUG__?: Record & { - enabled?: boolean; - }; - } - ).__OPENFRONT_RENDER_DEBUG__; - const renderDebug = debugApi ?? { enabled: false }; - Object.assign(renderDebug, { - enable() { - renderDebug.enabled = true; - return renderer.captureDebugState(); - }, - disable() { - renderDebug.enabled = false; - return renderer.captureDebugState(); - }, - snapshot() { - const snapshot = renderer.captureDebugState(); - console.info("[RenderDebug] snapshot", snapshot); - return snapshot; - }, - clearMainCanvasOnly() { - renderer.clearMainCanvasOnly(); - return renderer.captureDebugState(); - }, - redrawAll() { - renderer.redraw(); - return renderer.captureDebugState(); - }, - redrawStructureIcons() { - structureIconsLayer.redraw(); - return structureIconsLayer.captureDebugState(); - }, - rebuildStructureIcons(reason = "debug-api") { - return structureIconsLayer.rebuildAllStructuresFromState(reason); - }, - simulateStructureDisruption( - options?: Parameters[0], - ) { - return structureIconsLayer.simulateRendererDisruption( - options, - ); - }, - redrawUnits(reason = "debug-api") { - return unitLayer.rebuildAllUnitsFromState(reason); - }, - simulateUnitCanvasReset( - options?: Parameters[0], - ) { - return unitLayer.simulateCanvasReset(options); - }, - }); - ( - globalThis as typeof globalThis & { - __OPENFRONT_RENDER_DEBUG__?: typeof renderDebug; - } - ).__OPENFRONT_RENDER_DEBUG__ = renderDebug; - - return renderer; } export class GameRenderer { - private static readonly DEBUG_GLOBAL_KEY = "__OPENFRONT_RENDER_DEBUG__"; private context: CanvasRenderingContext2D; private layerTickState = new Map(); private renderFramesSinceLastTick: number = 0; @@ -436,11 +367,9 @@ export class GameRenderer { let rafId = requestAnimationFrame(() => this.renderGame()); this.canvas.addEventListener("contextlost", () => { - this.debugLog("contextlost", this.captureDebugState()); cancelAnimationFrame(rafId); }); this.canvas.addEventListener("contextrestored", () => { - this.debugLog("contextrestored", this.captureDebugState()); this.redraw(); rafId = requestAnimationFrame(() => this.renderGame()); }); @@ -450,12 +379,10 @@ export class GameRenderer { this.canvas.width = window.innerWidth; this.canvas.height = window.innerHeight; this.transformHandler.updateCanvasBoundingRect(); - this.debugLog("resizeCanvas", this.captureDebugState()); //this.redraw() } redraw() { - this.debugLog("redraw", this.captureDebugState()); this.layers.forEach((l) => { if (l.redraw) { l.redraw(); @@ -463,21 +390,6 @@ export class GameRenderer { }); } - clearMainCanvasOnly() { - this.context.clearRect(0, 0, this.canvas.width, this.canvas.height); - this.debugLog("clearMainCanvasOnly", this.captureDebugState()); - } - - captureDebugState() { - return { - canvasWidth: this.canvas.width, - canvasHeight: this.canvas.height, - layerCount: this.layers.length, - renderFramesSinceLastTick: this.renderFramesSinceLastTick, - layerTickStates: this.layerTickState.size, - }; - } - renderGame() { const shouldProfileFrame = FrameProfiler.isEnabled(); if (shouldProfileFrame) { @@ -601,16 +513,4 @@ export class GameRenderer { this.canvas.width = Math.ceil(width / window.devicePixelRatio); this.canvas.height = Math.ceil(height / window.devicePixelRatio); } - - private debugLog(event: string, details?: unknown) { - const debug = ( - globalThis as typeof globalThis & { - [GameRenderer.DEBUG_GLOBAL_KEY]?: { enabled?: boolean }; - } - )[GameRenderer.DEBUG_GLOBAL_KEY]; - if (!debug?.enabled) { - return; - } - console.info(`[RenderDebug][GameRenderer] ${event}`, details); - } } diff --git a/src/client/graphics/layers/StructureIconsLayer.ts b/src/client/graphics/layers/StructureIconsLayer.ts index 31b9025e4d..f232836125 100644 --- a/src/client/graphics/layers/StructureIconsLayer.ts +++ b/src/client/graphics/layers/StructureIconsLayer.ts @@ -102,8 +102,6 @@ export class StructureIconsLayer implements Layer { private hasHiddenStructure = false; potentialUpgrade: StructureRenderInfo | undefined; - private static readonly DEBUG_GLOBAL_KEY = "__OPENFRONT_RENDER_DEBUG__"; - constructor( private game: GameView, private eventBus: EventBus, @@ -169,13 +167,13 @@ export class StructureIconsLayer implements Layer { this.renderer = renderer; this.rendererInitialized = true; this.pixicanvas.addEventListener("webglcontextlost", (event) => { + // Prevent the browser's default context-loss handling so Pixi can restore + // the WebGL context and we can rebuild structure renders from game state. event.preventDefault(); - this.debugLog("webglcontextlost", this.captureDebugState()); }); this.pixicanvas.addEventListener("webglcontextrestored", () => { - this.debugLog("webglcontextrestored", this.captureDebugState()); this.resizeCanvas(); - this.rebuildAllStructuresFromState("webglcontextrestored"); + this.rebuildAllStructuresFromState(); }); } @@ -212,7 +210,6 @@ export class StructureIconsLayer implements Layer { for (const render of this.rendersByUnitId.values()) { this.computeNewLocation(render); } - this.debugLog("resizeCanvas", this.captureDebugState()); } } @@ -238,94 +235,21 @@ export class StructureIconsLayer implements Layer { } redraw() { - this.debugLog("redraw", this.captureDebugState()); this.resizeCanvas(); - this.rebuildAllStructuresFromState("redraw"); - } - - captureDebugState() { - return { - rendererInitialized: this.rendererInitialized, - rendersByUnitIdSize: this.rendersByUnitId.size, - seenUnitIdsSize: this.seenUnitIds.size, - dotsStageChildren: this.dotsStage?.children.length ?? 0, - iconsStageChildren: this.iconsStage?.children.length ?? 0, - levelsStageChildren: this.levelsStage?.children.length ?? 0, - ghostStageChildren: this.ghostStage?.children.length ?? 0, - canvasWidth: this.pixicanvas?.width ?? 0, - canvasHeight: this.pixicanvas?.height ?? 0, - }; + this.rebuildAllStructuresFromState(); } - rebuildAllStructuresFromState(reason = "manual") { - const before = this.captureDebugState(); - this.debugLog(`rebuildAllStructuresFromState:start:${reason}`, before); + rebuildAllStructuresFromState() { this.clearAllStructureRenders(); - let rebuilt = 0; for (const unitView of this.game.units()) { if ( unitView.isActive() && this.structures.has(unitView.type() as PlayerBuildableUnitType) ) { this.addNewStructure(unitView); - rebuilt++; } } - - const after = this.captureDebugState(); - this.debugLog(`rebuildAllStructuresFromState:end:${reason}`, { - rebuilt, - before, - after, - }); - return after; - } - - simulateRendererDisruption(options?: { - clearStages?: boolean; - preserveTracking?: boolean; - aggressiveResize?: boolean; - rebuildFromState?: boolean; - }) { - const { - clearStages = true, - preserveTracking = true, - aggressiveResize = true, - rebuildFromState = false, - } = options ?? {}; - const before = this.captureDebugState(); - this.debugLog("simulateRendererDisruption:start", { options, before }); - - if (clearStages) { - this.clearStageChildren(this.iconsStage); - this.clearStageChildren(this.levelsStage); - this.clearStageChildren(this.dotsStage); - } - - if (!preserveTracking) { - this.rendersByUnitId.clear(); - this.seenUnitIds.clear(); - } - - if (aggressiveResize && this.renderer) { - this.renderer.resize( - Math.max(1, Math.floor(this.pixicanvas.width / 2)), - Math.max(1, Math.floor(this.pixicanvas.height / 2)), - 1, - ); - this.resizeCanvas(); - } - - this.renderer?.resetState(); - - if (rebuildFromState) { - return this.rebuildAllStructuresFromState("simulateRendererDisruption"); - } - - const after = this.captureDebugState(); - this.debugLog("simulateRendererDisruption:end", { options, before, after }); - return after; } renderLayer(mainContext: CanvasRenderingContext2D) { @@ -969,6 +893,11 @@ export class StructureIconsLayer implements Layer { this.seenUnitIds.clear(); } + /** + * Deep-clears a Pixi stage after renderer disruption or before a full + * state-sourced rebuild. Callers must repopulate the stage immediately after + * this cleanup because all child display objects are destroyed recursively. + */ private clearStageChildren(stage?: PIXI.Container) { if (!stage) { return; @@ -986,16 +915,4 @@ export class StructureIconsLayer implements Layer { this.dotsStage?.setSize(this.pixicanvas.width, this.pixicanvas.height); this.rootStage?.setSize(this.pixicanvas.width, this.pixicanvas.height); } - - private debugLog(event: string, details?: unknown) { - const debug = ( - globalThis as typeof globalThis & { - [StructureIconsLayer.DEBUG_GLOBAL_KEY]?: { enabled?: boolean }; - } - )[StructureIconsLayer.DEBUG_GLOBAL_KEY]; - if (!debug?.enabled) { - return; - } - console.info(`[RenderDebug][StructureIconsLayer] ${event}`, details); - } } diff --git a/src/client/graphics/layers/UnitLayer.ts b/src/client/graphics/layers/UnitLayer.ts index bd8aa70a18..4ec7e64109 100644 --- a/src/client/graphics/layers/UnitLayer.ts +++ b/src/client/graphics/layers/UnitLayer.ts @@ -30,7 +30,6 @@ enum Relationship { } export class UnitLayer implements Layer { - private static readonly DEBUG_GLOBAL_KEY = "__OPENFRONT_RENDER_DEBUG__"; private canvas: HTMLCanvasElement; private context: CanvasRenderingContext2D; private transportShipTrailCanvas: HTMLCanvasElement; @@ -51,7 +50,6 @@ export class UnitLayer implements Layer { // Configuration for unit selection private readonly WARSHIP_SELECTION_RADIUS = 10; // Radius in game cells for warship selection hit zone - private skipIncrementalFrames = 0; constructor( private game: GameView, @@ -67,14 +65,6 @@ export class UnitLayer implements Layer { } tick() { - if (this.skipIncrementalFrames > 0) { - this.skipIncrementalFrames--; - this.debugLog("tick:skipped", { - remainingSkippedFrames: this.skipIncrementalFrames, - }); - return; - } - const updatedUnitIds = this.game .updatesSinceLastTick() @@ -252,8 +242,6 @@ export class UnitLayer implements Layer { } redraw() { - const previousWidth = this.canvas?.width ?? 0; - const previousHeight = this.canvas?.height ?? 0; this.canvas = document.createElement("canvas"); const context = this.canvas.getContext("2d"); if (context === null) throw new Error("2d context not supported"); @@ -268,12 +256,6 @@ export class UnitLayer implements Layer { this.transportShipTrailCanvas.width = this.game.width(); this.transportShipTrailCanvas.height = this.game.height(); - this.debugLog("redraw", { - previousWidth, - previousHeight, - canvasWidth: this.canvas.width, - canvasHeight: this.canvas.height, - }); this.updateUnitsSprites(this.game.units().map((unit) => unit.id())); this.unitToTrail.forEach((trail, unit) => { @@ -291,24 +273,11 @@ export class UnitLayer implements Layer { } private updateUnitsSprites(unitIds: number[]) { - const missingUnitIds: number[] = []; const unitsToUpdate = unitIds - ?.map((id) => { - const unit = this.game.unit(id); - if (unit === undefined) { - missingUnitIds.push(id); - } - return unit; - }) + ?.map((id) => this.game.unit(id)) .filter((unit) => unit !== undefined) ?? []; - this.debugLog("updateUnitsSprites", { - inputIds: unitIds.length, - resolvedUnits: unitsToUpdate.length, - missingUnitIds, - }); - if (unitsToUpdate.length > 0) { // the clearing and drawing of unit sprites need to be done in 2 passes // otherwise the sprite of a unit can be drawn on top of another unit @@ -317,66 +286,6 @@ export class UnitLayer implements Layer { } } - captureDebugState() { - return { - canvasWidth: this.canvas?.width ?? 0, - canvasHeight: this.canvas?.height ?? 0, - trailCanvasWidth: this.transportShipTrailCanvas?.width ?? 0, - trailCanvasHeight: this.transportShipTrailCanvas?.height ?? 0, - selectedUnitId: this.selectedUnit?.id() ?? null, - motionPlannedUnitIds: this.game.motionPlannedUnitIds().length, - totalActiveUnits: this.game.units().length, - skipIncrementalFrames: this.skipIncrementalFrames, - }; - } - - rebuildAllUnitsFromState(reason = "manual") { - this.debugLog(`rebuildAllUnitsFromState:${reason}`, this.captureDebugState()); - this.redraw(); - return this.captureDebugState(); - } - - simulateCanvasReset(options?: { - clearCanvas?: boolean; - clearTrailCanvas?: boolean; - skipNextIncrementalFrames?: number; - rebuildFromState?: boolean; - }) { - const { - clearCanvas = true, - clearTrailCanvas = true, - skipNextIncrementalFrames = 0, - rebuildFromState = false, - } = options ?? {}; - const before = this.captureDebugState(); - this.debugLog("simulateCanvasReset:start", { options, before }); - - if (clearCanvas) { - this.context.clearRect(0, 0, this.canvas.width, this.canvas.height); - } - if (clearTrailCanvas) { - this.unitTrailContext.clearRect( - 0, - 0, - this.transportShipTrailCanvas.width, - this.transportShipTrailCanvas.height, - ); - } - - this.skipIncrementalFrames = Math.max( - this.skipIncrementalFrames, - skipNextIncrementalFrames, - ); - - if (rebuildFromState) { - return this.rebuildAllUnitsFromState("simulateCanvasReset"); - } - - const after = this.captureDebugState(); - this.debugLog("simulateCanvasReset:end", { options, before, after }); - return after; - } - private clearUnitsCells(unitViews: UnitView[]) { unitViews .filter((unitView) => isSpriteReady(unitView)) @@ -710,16 +619,4 @@ export class UnitLayer implements Layer { } } } - - private debugLog(event: string, details?: unknown) { - const debug = ( - globalThis as typeof globalThis & { - [UnitLayer.DEBUG_GLOBAL_KEY]?: { enabled?: boolean }; - } - )[UnitLayer.DEBUG_GLOBAL_KEY]; - if (!debug?.enabled) { - return; - } - console.info(`[RenderDebug][UnitLayer] ${event}`, details); - } } diff --git a/tests/client/graphics/layers/StructureIconsLayer.test.ts b/tests/client/graphics/layers/StructureIconsLayer.test.ts index 19a5f9854b..439b57db71 100644 --- a/tests/client/graphics/layers/StructureIconsLayer.test.ts +++ b/tests/client/graphics/layers/StructureIconsLayer.test.ts @@ -19,7 +19,7 @@ type StructureIconsLayerTestInternals = { levelsStage: { children: unknown[]; removeChildren(): unknown[] }; dotsStage: { children: unknown[]; removeChildren(): unknown[] }; addNewStructure(unit: { id(): number }): void; - rebuildAllStructuresFromState(reason?: string): unknown; + rebuildAllStructuresFromState(): void; }; function createStructureIconsLayerWithMockGame(units: MockStructureUnit[]) { @@ -48,6 +48,19 @@ function createStructureIconsLayerWithMockGame(units: MockStructureUnit[]) { ); } +function createMockRender(destroy: ReturnType, unitId: number) { + return { + unit: { + id() { + return unitId; + }, + }, + iconContainer: { destroy }, + levelContainer: { destroy }, + dotContainer: { destroy }, + }; +} + /** * Tests for StructureIconsLayer edge cases mentioned in comments: * - Locked nuke / AtomBomb / HydrogenBomb: when confirming placement (Enter or key), @@ -89,39 +102,47 @@ describe("StructureIconsLayer ghost preservation (locked nuke / Enter confirm)", .mockImplementation(() => undefined); const rebuildSpy = vi .spyOn(layer, "rebuildAllStructuresFromState") - .mockReturnValue({ - rendererInitialized: false, - rendersByUnitIdSize: 0, - seenUnitIdsSize: 0, - dotsStageChildren: 0, - iconsStageChildren: 0, - levelsStageChildren: 0, - ghostStageChildren: 0, - canvasWidth: 0, - canvasHeight: 0, - }); + .mockImplementation(() => undefined); layer.redraw(); expect(resizeSpy).toHaveBeenCalledOnce(); - expect(rebuildSpy).toHaveBeenCalledWith("redraw"); + expect(rebuildSpy).toHaveBeenCalledOnce(); }); - test("rebuildAllStructuresFromState drops stale tracked renders and re-adds active structures from game state", () => { + test("rebuildAllStructuresFromState removes inactive renders and re-adds active structures", () => { const activeCity = { - id: () => 101, - type: () => UnitType.City, - isActive: () => true, + id() { + return 101; + }, + type() { + return UnitType.City; + }, + isActive() { + return true; + }, }; const inactiveCity = { - id: () => 102, - type: () => UnitType.City, - isActive: () => false, + id() { + return 102; + }, + type() { + return UnitType.City; + }, + isActive() { + return false; + }, }; const activeTransportShip = { - id: () => 103, - type: () => UnitType.TransportShip, - isActive: () => true, + id() { + return 103; + }, + type() { + return UnitType.TransportShip; + }, + isActive() { + return true; + }, }; const layer = createStructureIconsLayerWithMockGame([ activeCity, @@ -130,15 +151,14 @@ describe("StructureIconsLayer ghost preservation (locked nuke / Enter confirm)", ]); const layerInternals = layer as unknown as StructureIconsLayerTestInternals; const staleDestroy = vi.fn(); - const staleRender = { - unit: { id: () => 7 }, - iconContainer: { destroy: staleDestroy }, - levelContainer: { destroy: staleDestroy }, - dotContainer: { destroy: staleDestroy }, - }; + const inactiveDestroy = vi.fn(); + const staleRender = createMockRender(staleDestroy, 7); + const inactiveRender = createMockRender(inactiveDestroy, 102); const { seenUnitIds, rendersByUnitId } = layerInternals; seenUnitIds.add(7); + seenUnitIds.add(102); rendersByUnitId.set(7, staleRender); + rendersByUnitId.set(102, inactiveRender); layerInternals.iconsStage = { children: [], removeChildren: vi.fn(() => []), @@ -155,9 +175,10 @@ describe("StructureIconsLayer ghost preservation (locked nuke / Enter confirm)", rendersByUnitId.set(unit.id(), { unit }); }); - layerInternals.rebuildAllStructuresFromState("test"); + layerInternals.rebuildAllStructuresFromState(); expect(staleDestroy).toHaveBeenCalledTimes(3); + expect(inactiveDestroy).toHaveBeenCalledTimes(3); expect(addNewStructureSpy).toHaveBeenCalledTimes(1); expect(addNewStructureSpy).toHaveBeenCalledWith(activeCity); expect(Array.from(rendersByUnitId.keys())).toEqual([101]);