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 manualSaveSlotKey = 'heros-web:manual-save: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 otherBattleBaseKey = 'heros-web:battle:second-battle-yellow-turban-pursuit'; const otherBattleSlotKey = `${otherBattleBaseKey}:slot-1`; const otherBattleCheckpoint = '{"sentinel":"preserve-other-battle-checkpoint"}'; 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, restoreRemovalInterceptor() { storagePrototype.removeItem = originalRemoveItem; } } }); 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.` ); await page.evaluate(() => { window.__HEROS_BATTLE_GENERATION_FIXTURE__ ?.restoreRemovalInterceptor?.(); }); const beforeControl = await page.evaluate( ({ requestedBattleSlotKey, requestedCampaignSlotKey, requestedManualSaveSlotKey, 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 campaign = JSON.parse( window.localStorage.getItem( requestedCampaignSlotKey ) ?? 'null' ); if (!campaign) { throw new Error('The campaign fixture is unavailable for the protected manual control.'); } window.localStorage.setItem( requestedManualSaveSlotKey, JSON.stringify({ version: 1, slot: 1, savedAt: fixtureState.valid.savedAt, context: 'battle', campaign, battle: fixtureState.valid }) ); const accepted = scene.readBattleSaveState?.(1, true); const acceptedManual = scene.readManualBattleSaveState?.(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, acceptedManualTurnNumber: acceptedManual?.turnNumber ?? null, loadDisabled, panelMode: panel?.saveSlotPanelMode ?? null, confirmationMode: confirmation?.turnPromptMode ?? null, confirmationVisible: confirmation?.turnPromptVisible === true, expectedGeneration: requestedCampaignGeneration }; }, { requestedBattleSlotKey: battleSlotKey, requestedCampaignSlotKey: campaignSlotKey, requestedManualSaveSlotKey: manualSaveSlotKey, requestedCampaignGeneration: campaignGeneration } ); assert.equal( beforeControl.acceptedGeneration, campaignGeneration, `${renderer}: the current-generation control was not accepted.` ); assert.equal( beforeControl.acceptedTurnNumber, fixture.validTurnNumber ); assert.equal( beforeControl.acceptedManualTurnNumber, 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?.(); }); try { 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 } ); } catch (error) { const restoreDiagnostics = await page.evaluate( ({ requestedCampaignSlotKey, requestedBattleSlotKey, requestedManualSaveSlotKey }) => ({ battle: window.__HEROS_DEBUG__?.battle?.() ?? null, campaign: JSON.parse( window.localStorage.getItem( requestedCampaignSlotKey ) ?? 'null' ), battleSave: JSON.parse( window.localStorage.getItem( requestedBattleSlotKey ) ?? 'null' ), manualSave: JSON.parse( window.localStorage.getItem( requestedManualSaveSlotKey ) ?? 'null' ) }), { requestedCampaignSlotKey: campaignSlotKey, requestedBattleSlotKey: battleSlotKey, requestedManualSaveSlotKey: manualSaveSlotKey } ); throw new Error( `${renderer}: protected manual restore did not become ready: ${JSON.stringify({ restoreDiagnostics, pageErrors, consoleErrors })}`, { cause: error } ); } 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); const tutorialPendingEventProbe = await page.evaluate(() => { const scene = window.__HEROS_DEBUG__?.scene('BattleScene'); if ( !scene || typeof scene.scheduleFirstBattleTutorial !== 'function' ) { throw new Error( 'The first-battle tutorial resume probe is unavailable.' ); } const originalStart = scene.startFirstBattleTutorial; const originalDelayedCall = scene.time.delayedCall; const originalState = { phase: scene.phase, activeFaction: scene.activeFaction, turnNumber: scene.turnNumber, firstBattleTutorialStep: scene.firstBattleTutorialStep, activeBattleEvent: scene.activeBattleEvent, battleEventQueue: scene.battleEventQueue, battleEventObjects: scene.battleEventObjects }; let tutorialStartCalls = 0; let retryCalls = 0; try { scene.phase = 'idle'; scene.activeFaction = 'ally'; scene.turnNumber = 1; scene.firstBattleTutorialStep = undefined; scene.activeBattleEvent = undefined; scene.battleEventObjects = []; scene.battleEventQueue = [{ key: 'qa-restored-pending-event', title: 'Restored pending event', lines: ['This event must display before tutorial input.'], priority: 'normal', playCue: false }]; scene.startFirstBattleTutorial = () => { tutorialStartCalls += 1; return true; }; scene.time.delayedCall = () => { retryCalls += 1; return { remove() {} }; }; scene.scheduleFirstBattleTutorial(); return { tutorialStartCalls, retryCalls, pendingEventCount: scene.battleEventQueue.length }; } finally { scene.startFirstBattleTutorial = originalStart; scene.time.delayedCall = originalDelayedCall; scene.phase = originalState.phase; scene.activeFaction = originalState.activeFaction; scene.turnNumber = originalState.turnNumber; scene.firstBattleTutorialStep = originalState.firstBattleTutorialStep; scene.activeBattleEvent = originalState.activeBattleEvent; scene.battleEventQueue = originalState.battleEventQueue; scene.battleEventObjects = originalState.battleEventObjects; } }); assert.deepEqual( tutorialPendingEventProbe, { tutorialStartCalls: 0, retryCalls: 1, pendingEventCount: 1 }, `${renderer}: a restored pending battle event was not allowed to finish before the first-battle tutorial resumed.` ); const autosaveContract = await page.evaluate(() => { const scene = window.__HEROS_DEBUG__?.scene('BattleScene'); const battle = window.__HEROS_DEBUG__?.battle?.(); const fixtureState = window.__HEROS_BATTLE_GENERATION_FIXTURE__; fixtureState?.restoreRemovalInterceptor?.(); return { sceneMethod: typeof scene?.persistBattleAutosave === 'function', debugState: battle?.autosave ?? null }; }); assert.equal( autosaveContract.sceneMethod, true, `${renderer}: BattleScene.persistBattleAutosave(reason) is unavailable.` ); assertAutosaveDebugState( autosaveContract.debugState, `${renderer}: initial autosave debug state` ); const actionBaseline = await readAutosaveCheckpoint( page, battleSlotKey ); const actionTrigger = await page.evaluate(() => { const scene = window.__HEROS_DEBUG__?.scene('BattleScene'); const unit = scene?.debugUnitById?.('liu-bei'); if ( !scene || !unit || typeof scene.completeUnitAction !== 'function' ) { throw new Error( 'The ally-action autosave trigger is unavailable.' ); } scene.phase = 'command'; scene.activeFaction = 'ally'; scene.selectedUnit = unit; scene.pendingMove = undefined; scene.targetingAction = undefined; scene.selectedUsable = undefined; scene.completeUnitAction(unit, 'wait'); return { phase: window.__HEROS_DEBUG__?.battle?.()?.phase ?? null, actedUnitIds: [ ...(window.__HEROS_DEBUG__?.battle?.()?.actedUnitIds ?? []) ] }; }); assert( actionTrigger.actedUnitIds.includes('liu-bei'), `${renderer}: the ally action did not complete.` ); await page.waitForFunction( ({ requestedBattleSlotKey, previousSavedAt, previousWriteCount }) => { const persisted = JSON.parse( window.localStorage.getItem(requestedBattleSlotKey) ?? 'null' ); const autosave = window.__HEROS_DEBUG__?.battle?.()?.autosave; return ( persisted?.savedAt && persisted.savedAt !== previousSavedAt && persisted.actedUnitIds?.includes('liu-bei') && autosave?.writeCount === previousWriteCount + 1 && autosave?.lastResult === 'saved' && autosave?.lastReason === 'ally-action' ); }, { requestedBattleSlotKey: battleSlotKey, previousSavedAt: actionBaseline.state.savedAt, previousWriteCount: actionBaseline.autosave.writeCount } ); const actionAutosave = await readAutosaveCheckpoint( page, battleSlotKey ); assertAutosaveWrite( actionBaseline, actionAutosave, { reason: 'ally-action', expectedTurnNumber: fixture.validTurnNumber, expectedActedUnitId: 'liu-bei' }, `${renderer}: ally action autosave` ); assert.equal( actionAutosave.autosave.indicatorVisible, true, `${renderer}: a successful ally-action autosave did not expose visible save feedback.` ); assert.match( actionAutosave.autosave.indicatorText ?? '', /자동 저장 완료/, `${renderer}: the success save indicator was not clearly labeled.` ); const unsafeAutosaves = []; for (const [ unsafeIndex, unsafePhase ] of ['command', 'animating'].entries()) { const beforeUnsafe = await readAutosaveCheckpoint( page, battleSlotKey ); const unsafeTurnNumber = 41 + unsafeIndex; const eventProbe = await page.evaluate( ({ requestedPhase, requestedTurnNumber }) => { const scene = window.__HEROS_DEBUG__?.scene('BattleScene'); const unit = scene?.debugUnitById?.('liu-bei'); if ( !scene || !unit || typeof scene.persistBattleAutosave !== 'function' ) { throw new Error( 'The unsafe pagehide autosave probe is unavailable.' ); } scene.turnNumber = requestedTurnNumber; scene.activeFaction = 'ally'; scene.phase = requestedPhase; scene.selectedUnit = requestedPhase === 'command' ? unit : undefined; scene.pendingMove = undefined; scene.targetingAction = undefined; scene.selectedUsable = undefined; window.dispatchEvent(new Event('pagehide')); const battle = window.__HEROS_DEBUG__?.battle?.(); return { phase: battle?.phase ?? null, turnNumber: battle?.turnNumber ?? null, autosave: battle?.autosave ?? null }; }, { requestedPhase: unsafePhase, requestedTurnNumber: unsafeTurnNumber } ); const afterUnsafe = await readAutosaveCheckpoint( page, battleSlotKey ); assert.equal(eventProbe.phase, unsafePhase); assert.equal( eventProbe.turnNumber, unsafeTurnNumber ); assertAutosaveDebugState( eventProbe.autosave, `${renderer}: ${unsafePhase} pagehide debug state` ); assert.equal( afterUnsafe.raw, beforeUnsafe.raw, `${renderer}: ${unsafePhase} pagehide overwrote the last safe checkpoint.` ); assert.equal( afterUnsafe.autosave.writeCount, beforeUnsafe.autosave.writeCount, `${renderer}: ${unsafePhase} pagehide incremented the autosave write count.` ); assert.equal( afterUnsafe.autosave.skipCount, beforeUnsafe.autosave.skipCount + 1, `${renderer}: ${unsafePhase} pagehide did not record one safe-state skip.` ); assert.equal( afterUnsafe.autosave.failureCount, beforeUnsafe.autosave.failureCount, `${renderer}: ${unsafePhase} pagehide was reported as a failure instead of a skip.` ); assert.equal( afterUnsafe.autosave.lastResult, 'skipped' ); assert.equal( afterUnsafe.autosave.lastReason, 'pagehide' ); assert.equal( afterUnsafe.autosave.lastSavedAt, beforeUnsafe.state.savedAt ); unsafeAutosaves.push({ phase: unsafePhase, turnNumber: unsafeTurnNumber, writeCount: afterUnsafe.autosave.writeCount, skipCount: afterUnsafe.autosave.skipCount, checkpointSavedAt: afterUnsafe.state.savedAt }); } const readinessBaseline = await readAutosaveCheckpoint( page, battleSlotKey ); const readinessTurnNumber = 42; const readinessProbe = await page.evaluate( (requestedTurnNumber) => { const scene = window.__HEROS_DEBUG__?.scene('BattleScene'); if (!scene) { throw new Error( 'The autosave readiness probe is unavailable.' ); } scene.turnNumber = requestedTurnNumber; scene.activeFaction = 'ally'; scene.phase = 'idle'; scene.selectedUnit = undefined; scene.pendingMove = undefined; scene.targetingAction = undefined; scene.selectedUsable = undefined; scene.battleAutosaveReady = false; window.dispatchEvent(new Event('pagehide')); const blocked = window.__HEROS_DEBUG__?.battle?.()?.autosave ?? null; scene.battleAutosaveReady = true; return blocked; }, readinessTurnNumber ); const readinessAfter = await readAutosaveCheckpoint( page, battleSlotKey ); assertAutosaveDebugState( readinessProbe, `${renderer}: pre-initialization pagehide debug state` ); assert.equal( readinessAfter.raw, readinessBaseline.raw, `${renderer}: a pagehide before battle initialization replaced the last safe checkpoint.` ); assert.equal( readinessAfter.autosave.writeCount, readinessBaseline.autosave.writeCount ); assert.equal( readinessAfter.autosave.skipCount, readinessBaseline.autosave.skipCount + 1 ); assert.equal( readinessAfter.autosave.lastResult, 'skipped' ); assert.equal( readinessAfter.autosave.lastReason, 'pagehide' ); const idleBaseline = await readAutosaveCheckpoint( page, battleSlotKey ); const idleTurnNumber = 43; const idleMarkerHp = Math.max( 1, fixture.validMarkerHp - 1 ); await page.evaluate( ({ requestedTurnNumber, requestedMarkerHp }) => { const scene = window.__HEROS_DEBUG__?.scene('BattleScene'); const unit = scene?.debugUnitById?.('liu-bei'); if ( !scene || !unit || typeof scene.persistBattleAutosave !== 'function' ) { throw new Error( 'The idle pagehide autosave probe is unavailable.' ); } scene.turnNumber = requestedTurnNumber; scene.activeFaction = 'ally'; scene.phase = 'idle'; scene.selectedUnit = undefined; scene.pendingMove = undefined; scene.targetingAction = undefined; scene.selectedUsable = undefined; unit.hp = requestedMarkerHp; window.dispatchEvent(new Event('pagehide')); }, { requestedTurnNumber: idleTurnNumber, requestedMarkerHp: idleMarkerHp } ); await page.waitForFunction( ({ requestedBattleSlotKey, previousSavedAt, previousWriteCount, expectedTurnNumber, expectedMarkerHp }) => { const persisted = JSON.parse( window.localStorage.getItem(requestedBattleSlotKey) ?? 'null' ); const autosave = window.__HEROS_DEBUG__?.battle?.()?.autosave; return ( persisted?.savedAt && persisted.savedAt !== previousSavedAt && persisted.turnNumber === expectedTurnNumber && persisted.units?.find( (unit) => unit.id === 'liu-bei' )?.hp === expectedMarkerHp && autosave?.writeCount === previousWriteCount + 1 && autosave?.lastResult === 'saved' && autosave?.lastReason === 'pagehide' ); }, { requestedBattleSlotKey: battleSlotKey, previousSavedAt: idleBaseline.state.savedAt, previousWriteCount: idleBaseline.autosave.writeCount, expectedTurnNumber: idleTurnNumber, expectedMarkerHp: idleMarkerHp } ); const idleAutosave = await readAutosaveCheckpoint( page, battleSlotKey ); assertAutosaveWrite( idleBaseline, idleAutosave, { reason: 'pagehide', expectedTurnNumber: idleTurnNumber, expectedActedUnitId: 'liu-bei', expectedMarkerHp: idleMarkerHp }, `${renderer}: ally idle pagehide autosave` ); assert.equal( idleAutosave.autosave.skipCount, idleBaseline.autosave.skipCount, `${renderer}: a safe idle pagehide was counted as skipped.` ); const rollbackBaseline = await readAutosaveCheckpoint( page, battleSlotKey ); const rollbackBaseRaw = await page.evaluate( (requestedBattleBaseKey) => window.localStorage.getItem(requestedBattleBaseKey), battleBaseKey ); const rollbackProbe = await page.evaluate( ({ requestedBattleSlotKey, requestedBattleBaseKey, requestedOtherBattleSlotKey, requestedOtherBattleBaseKey, requestedOtherBattleCheckpoint }) => { const scene = window.__HEROS_DEBUG__?.scene('BattleScene'); if ( !scene || typeof scene.persistBattleAutosave !== 'function' ) { throw new Error( 'The autosave rollback probe is unavailable.' ); } scene.battleAutosaveReady = true; scene.activeFaction = 'ally'; scene.phase = 'idle'; scene.selectedUnit = undefined; scene.pendingMove = undefined; scene.targetingAction = undefined; scene.selectedUsable = undefined; scene.turnNumber = 44; window.localStorage.setItem( requestedOtherBattleSlotKey, requestedOtherBattleCheckpoint ); window.localStorage.setItem( requestedOtherBattleBaseKey, requestedOtherBattleCheckpoint ); const storagePrototype = Object.getPrototypeOf(window.localStorage); const originalSetItem = storagePrototype.setItem; let injected = false; storagePrototype.setItem = function setItem( key, value ) { if ( this === window.localStorage && String(key) === requestedBattleSlotKey && !injected ) { injected = true; throw new DOMException( 'Injected autosave write refusal', 'QuotaExceededError' ); } return originalSetItem.call(this, key, value); }; let saved; try { saved = scene.persistBattleAutosave('debug'); } finally { storagePrototype.setItem = originalSetItem; } return { saved, injected, slotRaw: window.localStorage.getItem( requestedBattleSlotKey ), baseRaw: window.localStorage.getItem( requestedBattleBaseKey ), otherBattleSlotRaw: window.localStorage.getItem( requestedOtherBattleSlotKey ), otherBattleBaseRaw: window.localStorage.getItem( requestedOtherBattleBaseKey ), autosave: window.__HEROS_DEBUG__?.battle?.()?.autosave ?? null }; }, { requestedBattleSlotKey: battleSlotKey, requestedBattleBaseKey: battleBaseKey, requestedOtherBattleSlotKey: otherBattleSlotKey, requestedOtherBattleBaseKey: otherBattleBaseKey, requestedOtherBattleCheckpoint: otherBattleCheckpoint } ); assert.equal(rollbackProbe.injected, true); assert.equal(rollbackProbe.saved, false); assert.equal( rollbackProbe.slotRaw, rollbackBaseline.raw, `${renderer}: a failed autosave did not restore the canonical slot checkpoint.` ); assert.equal( rollbackProbe.baseRaw, rollbackBaseRaw, `${renderer}: a failed autosave did not restore the slot-one compatibility checkpoint.` ); assert.equal( rollbackProbe.otherBattleSlotRaw, otherBattleCheckpoint, `${renderer}: a failed autosave discarded another battle's canonical checkpoint in the same campaign slot.` ); assert.equal( rollbackProbe.otherBattleBaseRaw, otherBattleCheckpoint, `${renderer}: a failed autosave discarded another battle's compatibility checkpoint in the same campaign slot.` ); assert.equal( rollbackProbe.autosave.writeCount, rollbackBaseline.autosave.writeCount ); assert.equal( rollbackProbe.autosave.skipCount, rollbackBaseline.autosave.skipCount ); assert.equal( rollbackProbe.autosave.failureCount, rollbackBaseline.autosave.failureCount + 1 ); assert.equal( rollbackProbe.autosave.lastResult, 'failed' ); assert.equal( rollbackProbe.autosave.lastReason, 'debug' ); assert.equal( rollbackProbe.autosave.lastSavedAt, rollbackBaseline.state.savedAt ); assert.equal( rollbackProbe.autosave.indicatorVisible, true, `${renderer}: a failed autosave did not expose a persistent visible warning.` ); assert.match( rollbackProbe.autosave.indicatorText ?? '', /자동 저장 실패/, `${renderer}: the failed autosave warning was not clearly labeled.` ); 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, pendingEventBeforeTutorial: tutorialPendingEventProbe }, autosave: { allyAction: { phaseAfterAction: actionTrigger.phase, turnNumber: actionAutosave.state.turnNumber, actedUnitIds: actionAutosave.state.actedUnitIds, savedAt: actionAutosave.state.savedAt, debug: actionAutosave.autosave }, unsafePagehide: unsafeAutosaves, initializationGuard: { turnNumber: readinessTurnNumber, debug: readinessAfter.autosave }, idlePagehide: { turnNumber: idleAutosave.state.turnNumber, markerHp: idleAutosave.state.units.find( (unit) => unit.id === 'liu-bei' )?.hp ?? null, savedAt: idleAutosave.state.savedAt, debug: idleAutosave.autosave }, failedWriteRollback: { slotPreserved: rollbackProbe.slotRaw === rollbackBaseline.raw, basePreserved: rollbackProbe.baseRaw === rollbackBaseRaw, otherBattleSlotPreserved: rollbackProbe.otherBattleSlotRaw === otherBattleCheckpoint, otherBattleBasePreserved: rollbackProbe.otherBattleBaseRaw === otherBattleCheckpoint, debug: rollbackProbe.autosave } }, 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 readAutosaveCheckpoint( page, requestedBattleSlotKey ) { const checkpoint = await page.evaluate( (storageKey) => { const raw = window.localStorage.getItem(storageKey); const battle = window.__HEROS_DEBUG__?.battle?.(); return { raw, state: JSON.parse(raw ?? 'null'), autosave: battle?.autosave ?? null }; }, requestedBattleSlotKey ); assert( checkpoint.raw, `Missing battle autosave checkpoint at ${requestedBattleSlotKey}.` ); assert( checkpoint.state, `Invalid battle autosave checkpoint at ${requestedBattleSlotKey}.` ); assertAutosaveDebugState( checkpoint.autosave, `Autosave debug state for ${requestedBattleSlotKey}` ); return checkpoint; } function assertAutosaveDebugState( autosave, label ) { assert( autosave && typeof autosave === 'object', `${label} is unavailable.` ); for (const field of [ 'writeCount', 'skipCount', 'failureCount' ]) { assert( Number.isInteger(autosave[field]) && autosave[field] >= 0, `${label}.${field} must be a non-negative integer.` ); } assert( [ 'saved', 'skipped', 'failed', null ].includes(autosave.lastResult), `${label}.lastResult is invalid.` ); assert( autosave.lastReason === null || typeof autosave.lastReason === 'string', `${label}.lastReason is invalid.` ); assert( autosave.lastSlot === null || autosave.lastSlot === 1, `${label}.lastSlot is invalid.` ); assert( autosave.lastSavedAt === null || ( typeof autosave.lastSavedAt === 'string' && Number.isFinite( Date.parse(autosave.lastSavedAt) ) ), `${label}.lastSavedAt is invalid.` ); assert( typeof autosave.indicatorVisible === 'boolean', `${label}.indicatorVisible must be boolean.` ); assert( autosave.indicatorText === null || typeof autosave.indicatorText === 'string', `${label}.indicatorText is invalid.` ); } function assertAutosaveWrite( before, after, { reason, expectedTurnNumber, expectedActedUnitId, expectedMarkerHp }, label ) { assert.notEqual( after.raw, before.raw, `${label} did not update the active battle slot.` ); assert.notEqual( after.state.savedAt, before.state.savedAt, `${label} did not refresh savedAt.` ); assert.equal( after.state.turnNumber, expectedTurnNumber, `${label} saved the wrong turn.` ); assert( after.state.actedUnitIds.includes( expectedActedUnitId ), `${label} omitted the completed ally action.` ); if (expectedMarkerHp !== undefined) { assert.equal( after.state.units.find( (unit) => unit.id === 'liu-bei' )?.hp, expectedMarkerHp, `${label} did not persist the live ally state.` ); } assert.equal( after.autosave.writeCount, before.autosave.writeCount + 1, `${label} did not record exactly one write.` ); assert.equal( after.autosave.skipCount, before.autosave.skipCount, `${label} unexpectedly recorded a skip.` ); assert.equal( after.autosave.failureCount, before.autosave.failureCount, `${label} unexpectedly recorded a failure.` ); assert.equal( after.autosave.lastResult, 'saved' ); assert.equal( after.autosave.lastReason, reason ); assert.equal(after.autosave.lastSlot, 1); assert.equal( after.autosave.lastSavedAt, after.state.savedAt ); } 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); }