import assert from 'node:assert/strict'; import { spawnSync } from 'node:child_process'; import { fileURLToPath } from 'node:url'; import { chromium } from 'playwright'; import { desktopBrowserContextOptions, desktopBrowserDeviceScaleFactor, desktopBrowserViewport } from './desktop-browser-viewport.mjs'; import { startVerificationPreview, stopVerificationPreview } from './verification-preview-server.mjs'; const renderers = ['canvas', 'webgl']; const renderer = process.env.VERIFY_BATTLE_SAVE_GENERATION_RENDERER; if (!renderer) { for (const requestedRenderer of renderers) { const result = spawnSync(process.execPath, [fileURLToPath(import.meta.url)], { cwd: process.cwd(), env: { ...process.env, VERIFY_BATTLE_SAVE_GENERATION_RENDERER: requestedRenderer }, stdio: 'inherit' }); if (result.status !== 0) { process.exit(result.status ?? 1); } } process.exit(0); } assert( renderers.includes(renderer), `Unsupported battle-save generation renderer "${renderer}".` ); const battleId = 'first-battle-zhuo-commandery'; const campaignStep = 'first-battle'; const campaignGeneration = 4; const staleGeneration = 3; const campaignKey = 'heros-web:campaign-state'; const campaignSlotKey = `${campaignKey}:slot-1`; const battleBaseKey = `heros-web:battle:${battleId}`; const battleSlotKey = `${battleBaseKey}:slot-1`; const legacyBattleKey = 'heros-web:first-battle-state'; const battleStorageKeys = [battleSlotKey, battleBaseKey, legacyBattleKey]; const baseUrl = process.env.VERIFY_BATTLE_SAVE_GENERATION_URL ?? `http://127.0.0.1:${renderer === 'canvas' ? 41825 : 41826}/heros_web/`; const targetUrl = withDebugOptions(baseUrl, renderer); let browser; let serverProcess; try { serverProcess = await startVerificationPreview({ targetUrl, explicitUrl: Boolean( process.env.VERIFY_BATTLE_SAVE_GENERATION_URL ), label: `${renderer} battle save generation verification` }); browser = await chromium.launch({ headless: process.env.VERIFY_BATTLE_SAVE_GENERATION_HEADLESS !== '0', args: renderer === 'webgl' ? ['--use-angle=swiftshader', '--enable-unsafe-swiftshader'] : [] }); const context = await browser.newContext(desktopBrowserContextOptions); const page = await context.newPage(); page.setDefaultTimeout(30000); const pageErrors = []; const consoleErrors = []; page.on('pageerror', (error) => pageErrors.push(error.stack ?? error.message)); page.on('console', (message) => { if (message.type() === 'error') { consoleErrors.push(message.text()); } }); await page.goto(targetUrl, { waitUntil: 'domcontentloaded', timeout: 90000 }); await waitForDebugApi(page); await page.evaluate(() => window.localStorage.clear()); await page.reload({ waitUntil: 'domcontentloaded', timeout: 90000 }); await waitForTitle(page); await assertDesktopRuntime(page, renderer); await page.evaluate(async (requestedBattleId) => { await window.__HEROS_DEBUG__?.goToBattle(requestedBattleId); }, battleId); await waitForBattleReady(page); const fixture = await page.evaluate( ({ requestedBattleId, requestedCampaignStep, requestedCampaignGeneration, requestedStaleGeneration, requestedCampaignKey, requestedCampaignSlotKey, requestedBattleBaseKey, requestedBattleSlotKey, requestedLegacyBattleKey, requestedBattleStorageKeys }) => { const scene = window.__HEROS_DEBUG__?.scene('BattleScene'); if ( !scene || typeof scene.createBattleSaveState !== 'function' || typeof scene.saveBattleState !== 'function' ) { throw new Error('BattleScene save internals are unavailable.'); } // Persist a normalized campaign fixture and a fully valid battle-save // shape through the production writer before applying generation markers. scene.saveBattleState(1); const campaignRaw = window.localStorage.getItem(requestedCampaignSlotKey) ?? window.localStorage.getItem(requestedCampaignKey); const battleRaw = window.localStorage.getItem(requestedBattleSlotKey) ?? window.localStorage.getItem(requestedBattleBaseKey); if (!campaignRaw || !battleRaw) { throw new Error('The production save writer did not create the browser fixture.'); } const campaign = JSON.parse(campaignRaw); campaign.step = requestedCampaignStep; campaign.activeSaveSlot = 1; campaign.battleResumeGeneration = requestedCampaignGeneration; campaign.storyCheckpoint = undefined; campaign.explorationCheckpoint = undefined; campaign.updatedAt = new Date().toISOString(); const serializedCampaign = JSON.stringify(campaign); window.localStorage.setItem(requestedCampaignSlotKey, serializedCampaign); window.localStorage.setItem(requestedCampaignKey, serializedCampaign); const baseline = JSON.parse(battleRaw); const markerIndex = baseline.units.findIndex((unit) => unit.id === 'liu-bei'); if (markerIndex < 0) { throw new Error('The first-battle Liu Bei save fixture is unavailable.'); } const baselineMarkerHp = baseline.units[markerIndex].hp; const markerMaxHp = baseline.units[markerIndex].maxHp; const staleMarkerHp = Math.max(1, Math.min(markerMaxHp, baselineMarkerHp - 7)); const validMarkerHp = Math.max(1, Math.min(markerMaxHp, baselineMarkerHp - 3)); if ( staleMarkerHp === baselineMarkerHp || validMarkerHp === baselineMarkerHp || staleMarkerHp === validMarkerHp ) { throw new Error( `Battle marker HP values are not distinguishable: ${JSON.stringify({ baselineMarkerHp, staleMarkerHp, validMarkerHp })}` ); } const stale = structuredClone(baseline); stale.battleId = requestedBattleId; stale.campaignStep = requestedCampaignStep; stale.battleResumeGeneration = requestedStaleGeneration; stale.turnNumber = 8; stale.activeFaction = 'ally'; stale.savedAt = new Date(Date.now() - 60_000).toISOString(); stale.units[markerIndex].hp = staleMarkerHp; const valid = structuredClone(baseline); valid.battleId = requestedBattleId; valid.campaignStep = requestedCampaignStep; valid.battleResumeGeneration = requestedCampaignGeneration; valid.turnNumber = 6; valid.activeFaction = 'ally'; valid.savedAt = new Date().toISOString(); valid.units[markerIndex].hp = validMarkerHp; window.localStorage.removeItem(requestedBattleBaseKey); window.localStorage.removeItem(requestedLegacyBattleKey); window.localStorage.setItem(requestedBattleSlotKey, JSON.stringify(stale)); const storagePrototype = Object.getPrototypeOf(window.localStorage); const originalRemoveItem = storagePrototype.removeItem; const blockedKeys = new Set(requestedBattleStorageKeys); const removalStats = { attempts: 0, blocked: 0, keys: [] }; storagePrototype.removeItem = function removeItem(key) { const normalizedKey = String(key); if (this === window.localStorage && blockedKeys.has(normalizedKey)) { removalStats.attempts += 1; removalStats.blocked += 1; removalStats.keys.push(normalizedKey); throw new DOMException( 'Injected battle-save removal refusal', 'InvalidStateError' ); } return originalRemoveItem.call(this, key); }; Object.defineProperty(window, '__HEROS_BATTLE_GENERATION_FIXTURE__', { configurable: true, value: { baselineMarkerHp, staleMarkerHp, validMarkerHp, valid, removalStats } }); scene.scene.restart({ battleId: requestedBattleId, resumeBattleSaveSlot: 1 }); return { baselineMarkerHp, staleMarkerHp, validMarkerHp, staleTurnNumber: stale.turnNumber, validTurnNumber: valid.turnNumber }; }, { requestedBattleId: battleId, requestedCampaignStep: campaignStep, requestedCampaignGeneration: campaignGeneration, requestedStaleGeneration: staleGeneration, requestedCampaignKey: campaignKey, requestedCampaignSlotKey: campaignSlotKey, requestedBattleBaseKey: battleBaseKey, requestedBattleSlotKey: battleSlotKey, requestedLegacyBattleKey: legacyBattleKey, requestedBattleStorageKeys: battleStorageKeys } ); await page.waitForFunction( ({ expectedBattleId, expectedBaselineHp }) => { const battle = window.__HEROS_DEBUG__?.battle?.(); const fixtureState = window.__HEROS_BATTLE_GENERATION_FIXTURE__; return ( battle?.battleId === expectedBattleId && battle?.mapBackgroundReady === true && ['deployment', 'idle'].includes(battle?.phase) && battle?.turnNumber === 1 && battle?.units?.find((unit) => unit.id === 'liu-bei')?.hp === expectedBaselineHp && fixtureState?.removalStats?.blocked >= 1 ); }, { expectedBattleId: battleId, expectedBaselineHp: fixture.baselineMarkerHp }, { timeout: 90000 } ); const staleProbe = await page.evaluate( ({ requestedBattleSlotKey, requestedStaleGeneration }) => { const scene = window.__HEROS_DEBUG__?.scene('BattleScene'); if (!scene) { throw new Error('BattleScene is unavailable after stale resume rejection.'); } const rejected = scene.readBattleSaveState?.(1, true) === undefined; const loadDisabled = scene.isMapMenuActionDisabled?.('load') === true; scene.showSaveSlotPanel?.('load'); const panelBeforeSelection = window.__HEROS_DEBUG__?.battle?.(); scene.activateSaveSlotPanelSlot?.(1); const panelAfterSelection = window.__HEROS_DEBUG__?.battle?.(); scene.hideSaveSlotPanel?.(); const persisted = JSON.parse( window.localStorage.getItem(requestedBattleSlotKey) ?? 'null' ); const fixtureState = window.__HEROS_BATTLE_GENERATION_FIXTURE__; return { rejected, loadDisabled, panelMode: panelBeforeSelection?.saveSlotPanelMode ?? null, panelStayedOpen: panelAfterSelection?.saveSlotPanelVisible === true, promptMode: panelAfterSelection?.turnPromptMode ?? null, persistedGeneration: persisted?.battleResumeGeneration ?? null, removalStats: { attempts: fixtureState?.removalStats?.attempts ?? 0, blocked: fixtureState?.removalStats?.blocked ?? 0, keys: [ ...(fixtureState?.removalStats?.keys ?? []) ] } }; }, { requestedBattleSlotKey: battleSlotKey, requestedStaleGeneration: staleGeneration } ); assert.equal( staleProbe.rejected, true, `${renderer}: BattleScene accepted a stale generation.` ); assert.equal( staleProbe.loadDisabled, true, `${renderer}: manual Load remained enabled for a stale save.` ); assert.equal(staleProbe.panelMode, 'load'); assert.equal( staleProbe.panelStayedOpen, true, `${renderer}: selecting a stale Load row should be a no-op.` ); assert.equal( staleProbe.promptMode, null, `${renderer}: a stale save reached the Load confirmation prompt.` ); assert.equal( staleProbe.persistedGeneration, staleGeneration, `${renderer}: the removal-refusal fixture was not preserved.` ); assert( staleProbe.removalStats.blocked >= 1, `${renderer}: no stale-save cleanup refusal was exercised.` ); assert( staleProbe.removalStats.keys.every((key) => battleStorageKeys.includes(key) ), `${renderer}: removal refusal escaped the intended battle keys.` ); const beforeControl = await page.evaluate( ({ requestedBattleSlotKey, requestedCampaignGeneration }) => { const scene = window.__HEROS_DEBUG__?.scene('BattleScene'); const fixtureState = window.__HEROS_BATTLE_GENERATION_FIXTURE__; if (!scene || !fixtureState?.valid) { throw new Error('The current-generation control fixture is unavailable.'); } window.localStorage.setItem( requestedBattleSlotKey, JSON.stringify(fixtureState.valid) ); const accepted = scene.readBattleSaveState?.(1, true); const loadDisabled = scene.isMapMenuActionDisabled?.('load') === true; scene.showSaveSlotPanel?.('load'); const panel = window.__HEROS_DEBUG__?.battle?.(); scene.activateSaveSlotPanelSlot?.(1); const confirmation = window.__HEROS_DEBUG__?.battle?.(); return { acceptedGeneration: accepted?.battleResumeGeneration ?? null, acceptedTurnNumber: accepted?.turnNumber ?? null, loadDisabled, panelMode: panel?.saveSlotPanelMode ?? null, confirmationMode: confirmation?.turnPromptMode ?? null, confirmationVisible: confirmation?.turnPromptVisible === true, expectedGeneration: requestedCampaignGeneration }; }, { requestedBattleSlotKey: battleSlotKey, requestedCampaignGeneration: campaignGeneration } ); assert.equal( beforeControl.acceptedGeneration, campaignGeneration, `${renderer}: the current-generation control was not accepted.` ); assert.equal( beforeControl.acceptedTurnNumber, fixture.validTurnNumber ); assert.equal(beforeControl.loadDisabled, false); assert.equal(beforeControl.panelMode, 'load'); assert.equal(beforeControl.confirmationMode, 'load-confirm'); assert.equal(beforeControl.confirmationVisible, true); await page.evaluate(() => { const scene = window.__HEROS_DEBUG__?.scene('BattleScene'); scene?.activateTurnPromptPrimaryAction?.(); }); await page.waitForFunction( ({ expectedBattleId, expectedTurnNumber, expectedMarkerHp }) => { const battle = window.__HEROS_DEBUG__?.battle?.(); return ( battle?.battleId === expectedBattleId && battle?.turnNumber === expectedTurnNumber && battle?.activeFaction === 'ally' && battle?.phase === 'idle' && battle?.units?.find((unit) => unit.id === 'liu-bei')?.hp === expectedMarkerHp ); }, { expectedBattleId: battleId, expectedTurnNumber: fixture.validTurnNumber, expectedMarkerHp: fixture.validMarkerHp }, { timeout: 90000 } ); const restored = await page.evaluate( ({ requestedCampaignKey, requestedCampaignSlotKey }) => { const battle = window.__HEROS_DEBUG__?.battle?.(); const current = JSON.parse( window.localStorage.getItem(requestedCampaignKey) ?? 'null' ); const slot = JSON.parse( window.localStorage.getItem(requestedCampaignSlotKey) ?? 'null' ); return { turnNumber: battle?.turnNumber ?? null, activeFaction: battle?.activeFaction ?? null, markerHp: battle?.units?.find((unit) => unit.id === 'liu-bei')?.hp ?? null, campaignGeneration: current?.battleResumeGeneration ?? null, slotGeneration: slot?.battleResumeGeneration ?? null, campaignStep: current?.step ?? null, slotStep: slot?.step ?? null }; }, { requestedCampaignKey: campaignKey, requestedCampaignSlotKey: campaignSlotKey } ); assert.equal(restored.turnNumber, fixture.validTurnNumber); assert.equal(restored.activeFaction, 'ally'); assert.equal(restored.markerHp, fixture.validMarkerHp); assert.equal(restored.campaignGeneration, campaignGeneration); assert.equal(restored.slotGeneration, campaignGeneration); assert.equal(restored.campaignStep, campaignStep); assert.equal(restored.slotStep, campaignStep); assert.equal(pageErrors.length, 0, pageErrors.join('\n')); assert.equal(consoleErrors.length, 0, consoleErrors.join('\n')); console.log( JSON.stringify( { renderer, viewport: desktopBrowserViewport, deviceScaleFactor: desktopBrowserDeviceScaleFactor, cssZoom: 1, stale: { campaignGeneration, saveGeneration: staleGeneration, requestedTurnNumber: fixture.staleTurnNumber, liveTurnNumber: 1, loadDisabled: staleProbe.loadDisabled, removalRefusal: staleProbe.removalStats }, control: { campaignGeneration, saveGeneration: beforeControl.acceptedGeneration, restoredTurnNumber: restored.turnNumber, restoredMarkerHp: restored.markerHp }, pageErrors: pageErrors.length, consoleErrors: consoleErrors.length }, null, 2 ) ); } finally { await browser?.close(); await stopVerificationPreview(serverProcess); } function withDebugOptions(url, requestedRenderer) { const parsed = new URL(url); parsed.searchParams.set('debug', '1'); parsed.searchParams.set('renderer', requestedRenderer); parsed.searchParams.set('motion', 'reduced'); return parsed.toString(); } async function waitForDebugApi(page) { await page.waitForFunction( () => Boolean( document.querySelector('canvas') && window.__HEROS_GAME__ && window.__HEROS_DEBUG__ ), undefined, { timeout: 90000 } ); } async function waitForTitle(page) { await page.waitForFunction( () => window.__HEROS_DEBUG__?.activeScenes?.().includes('TitleScene'), undefined, { timeout: 90000 } ); } async function waitForBattleReady(page) { await page.waitForFunction( (expectedBattleId) => { const battle = window.__HEROS_DEBUG__?.battle?.(); return ( window.__HEROS_DEBUG__?.activeScenes?.().includes('BattleScene') && battle?.scene === 'BattleScene' && battle?.battleId === expectedBattleId && battle?.battleOutcome === null && ['deployment', 'idle'].includes(battle?.phase) && battle?.mapBackgroundReady === true && battle?.resultVisible === false ); }, battleId, { timeout: 90000 } ); } async function assertDesktopRuntime(page, expectedRenderer) { const runtime = await page.evaluate(() => { const canvas = document.querySelector('canvas'); const bounds = canvas?.getBoundingClientRect(); return { innerWidth: window.innerWidth, innerHeight: window.innerHeight, devicePixelRatio: window.devicePixelRatio, visualViewportScale: window.visualViewport?.scale ?? 1, rootCssZoom: getComputedStyle(document.documentElement).zoom, rendererType: window.__HEROS_GAME__?.renderer.type, canvas: canvas ? { width: canvas.width, height: canvas.height, clientWidth: canvas.clientWidth, clientHeight: canvas.clientHeight, bounds: bounds ? { width: bounds.width, height: bounds.height } : null } : null }; }); assert.equal(runtime.innerWidth, desktopBrowserViewport.width); assert.equal(runtime.innerHeight, desktopBrowserViewport.height); assert.equal(runtime.devicePixelRatio, desktopBrowserDeviceScaleFactor); assert.equal(runtime.visualViewportScale, 1); assert( runtime.rootCssZoom === '1' || runtime.rootCssZoom === 'normal', `Unexpected CSS zoom ${runtime.rootCssZoom}.` ); assert.equal(runtime.rendererType, expectedRenderer === 'canvas' ? 1 : 2); assert.equal(runtime.canvas?.width, desktopBrowserViewport.width); assert.equal(runtime.canvas?.height, desktopBrowserViewport.height); assert.equal(runtime.canvas?.clientWidth, desktopBrowserViewport.width); assert.equal(runtime.canvas?.clientHeight, desktopBrowserViewport.height); assert.equal(runtime.canvas?.bounds?.width, desktopBrowserViewport.width); assert.equal(runtime.canvas?.bounds?.height, desktopBrowserViewport.height); }