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 rendererNames = ['canvas', 'webgl']; const renderer = process.env.VERIFY_STORY_CHECKPOINT_RENDERER; const legacyFirstVictoryStoryFixture = { battleId: 'first-battle-zhuo-commandery', battleTitle: '탁현 방어전', rewardGold: 300, alliedUnits: [ { id: 'liu-bei', name: '유비', faction: 'ally', className: '군웅', classKey: 'lord', level: 3, exp: 18, hp: 32, maxHp: 32, attack: 9, move: 4, stats: { might: 76, intelligence: 78, leadership: 84, agility: 70, luck: 88 }, equipment: { weapon: { itemId: 'twin-oath-blades', level: 1, exp: 18 }, armor: { itemId: 'oath-robe', level: 1, exp: 12 }, accessory: { itemId: 'peach-charm', level: 1, exp: 0 } }, x: 1, y: 15 }, { id: 'guan-yu', name: '관우', faction: 'ally', className: '의용 보병', classKey: 'infantry', level: 3, exp: 26, hp: 30, maxHp: 30, attack: 10, move: 3, stats: { might: 96, intelligence: 74, leadership: 91, agility: 72, luck: 68 }, equipment: { weapon: { itemId: 'green-dragon-glaive', level: 1, exp: 26 }, armor: { itemId: 'reinforced-lamellar', level: 1, exp: 10 }, accessory: { itemId: 'war-manual', level: 1, exp: 0 } }, x: 2, y: 15 }, { id: 'zhang-fei', name: '장비', faction: 'ally', className: '창병', classKey: 'spearman', level: 3, exp: 20, hp: 30, maxHp: 30, attack: 10, move: 3, stats: { might: 95, intelligence: 42, leadership: 83, agility: 67, luck: 61 }, equipment: { weapon: { itemId: 'serpent-spear', level: 1, exp: 20 }, armor: { itemId: 'lamellar-armor', level: 1, exp: 8 }, accessory: { itemId: 'bravery-token', level: 1, exp: 0 } }, x: 1, y: 16 } ], bonds: [ { id: 'liu-bei__guan-yu', unitIds: ['liu-bei', 'guan-yu'], title: '도원결의', level: 72, exp: 20, battleExp: 0, description: '같은 적을 노릴 때 공격 피해 보너스와 연계 공격 확률이 오른다.' }, { id: 'liu-bei__zhang-fei', unitIds: ['liu-bei', 'zhang-fei'], title: '의형제', level: 68, exp: 12, battleExp: 0, description: '공격 목표가 겹치면 공명 경험치가 빠르게 쌓인다.' }, { id: 'guan-yu__zhang-fei', unitIds: ['guan-yu', 'zhang-fei'], title: '맹장 공명', level: 64, exp: 8, battleExp: 0, description: '둘이 같은 전선을 압박하면 연계 공격의 기초 확률이 오른다.' } ], pageIds: [ 'first-victory-village', 'first-victory-liu-bei', 'first-victory-guan-yu', 'first-victory-zhang-fei', 'first-victory-next' ] }; if (!renderer) { for (const requestedRenderer of rendererNames) { const result = spawnSync( process.execPath, [fileURLToPath(import.meta.url)], { cwd: process.cwd(), env: { ...process.env, VERIFY_STORY_CHECKPOINT_RENDERER: requestedRenderer }, stdio: 'inherit' } ); if (result.status !== 0) { process.exit(result.status ?? 1); } } process.exit(0); } assert( rendererNames.includes(renderer), `Unsupported story checkpoint renderer "${renderer}".` ); const baseUrl = process.env.VERIFY_STORY_CHECKPOINT_URL ?? `http://127.0.0.1:${ renderer === 'canvas' ? 41819 : 41820 }/heros_web/`; const targetUrl = withDebugOptions(baseUrl, renderer); let serverProcess; let browser; try { serverProcess = await startVerificationPreview({ targetUrl, explicitUrl: Boolean( process.env.VERIFY_STORY_CHECKPOINT_URL ), label: `${renderer} story checkpoint verification` }); browser = await chromium.launch({ headless: process.env.VERIFY_STORY_CHECKPOINT_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, false); await assertDesktopRuntime(page, renderer); const started = await page.evaluate(() => { const title = window.__HEROS_DEBUG__?.scene('TitleScene'); if ( !title || typeof title.startGame !== 'function' ) { return false; } title.startGame(); return true; }); assert.equal( started, true, `${renderer}: new campaign could not be started.` ); const openingPage = await waitForStoryPage(page, 0); assert.equal( openingPage.totalPages, 2, `${renderer}: expected the short two-page opening story.` ); await assertStoryCheckpointMatchesPage( page, openingPage, `${renderer}: opening page` ); const interrupted = await advanceStoryPage(page, 1); const interruptedIdentity = storyPageIdentity(interrupted); await assertStoryCheckpointMatchesPage( page, interrupted, `${renderer}: interrupted page` ); await reloadToTitle(page); await clickContinue(page); const resumed = await waitForStoryPage(page, 1); assert.deepEqual( storyPageIdentity(resumed), interruptedIdentity, `${renderer}: reload and Continue did not restore the exact story page.` ); await assertStoryCheckpointMatchesPage( page, resumed, `${renderer}: exact resumed page` ); const legacyFixture = await injectLegacyIndexCheckpoint( page, 1 ); assert.equal(legacyFixture.pageIndex, 1); assert.equal(legacyFixture.beatId, null); await reloadToTitle(page); await clickContinue(page); const legacyResumed = await waitForStoryPage(page, 1); assert.deepEqual( storyPageIdentity(legacyResumed), interruptedIdentity, `${renderer}: legacy index-only checkpoint did not resume the exact page.` ); const migratedCheckpoint = await assertStoryCheckpointMatchesPage( page, legacyResumed, `${renderer}: migrated legacy page` ); assert.equal( migratedCheckpoint.version, 1, `${renderer}: legacy checkpoint version was not migrated.` ); assert.equal( migratedCheckpoint.beatId, legacyResumed.currentPageId, `${renderer}: legacy checkpoint did not gain a stable beat ID.` ); const tamperResult = await tamperCheckpointAndDispatchPageHide(page); assert.equal( tamperResult.runtimePageIndex, 1, `${renderer}: storage tampering changed the live story page.` ); assert.equal( tamperResult.beforeDispatch.current.pageIndex, 0, `${renderer}: current-save tamper fixture was not installed.` ); assert.equal( tamperResult.beforeDispatch.slot.pageIndex, 0, `${renderer}: slot-save tamper fixture was not installed.` ); assert.equal( tamperResult.afterDispatch.current.pageIndex, 1, `${renderer}: pagehide did not restore the current save to the live page.` ); assert.equal( tamperResult.afterDispatch.slot.pageIndex, 1, `${renderer}: pagehide did not restore the slot save to the live page.` ); assert.equal( tamperResult.afterDispatch.current.beatId, legacyResumed.currentPageId ); assert.equal( tamperResult.afterDispatch.slot.beatId, legacyResumed.currentPageId ); assert.equal( tamperResult.afterDispatch.current.phase, 'reading' ); assert.equal( tamperResult.afterDispatch.slot.phase, 'reading' ); assert.deepEqual( tamperResult.afterDispatch.current, tamperResult.afterDispatch.slot, `${renderer}: pagehide did not keep current and slot checkpoints identical.` ); assert.equal( legacyResumed.isLastPage, true, `${renderer}: the checkpoint completion fixture is not on its final page.` ); await installSceneStartGate(page, 'StoryScene'); await page.keyboard.press('Space'); await waitForSceneStartGate(page); const pendingFinalHandoff = await readCheckpointPhases(page); assertDurableHandoffPending( pendingFinalHandoff, migratedCheckpoint, `${renderer}: final-page handoff cleared before destination ownership` ); await releaseSceneStartGate(page); await waitForVillageReady(page); const consumed = await readCheckpointPhases(page); assertNoStoryCheckpoint( consumed, `${renderer}: completed story checkpoint was not consumed` ); await page.evaluate(() => { window.dispatchEvent(new Event('pagehide')); }); await page.waitForTimeout(100); const afterLatePageHide = await readCheckpointPhases(page); assertNoStoryCheckpoint( afterLatePageHide, `${renderer}: a late pagehide resurrected the completed story checkpoint` ); await reloadToTitle(page); await injectInterruptedStoryHandoff( page, migratedCheckpoint ); const injectedHandoff = await readCheckpointPhases(page); assertDurableHandoffPending( injectedHandoff, injectedHandoff.current, `${renderer}: interrupted handoff fixture was not persisted` ); await reloadToTitle(page); await installSceneStartGate(page, 'TitleScene'); await clickContinue(page); await waitForSceneStartGate(page); const pendingResumedHandoff = await readCheckpointPhases(page); assertDurableHandoffPending( pendingResumedHandoff, pendingResumedHandoff.current, `${renderer}: resumed handoff cleared before destination ownership` ); await releaseSceneStartGate(page); await waitForVillageReady(page); const resumedHandoff = await readCheckpointPhases(page); assertNoStoryCheckpoint( resumedHandoff, `${renderer}: a resumed final-page handoff was replayed or left pending` ); await reloadToTitle(page); const coexistenceFixture = await injectStoryAndMatchingBattleResume(page); await reloadToTitle(page); await clickContinue(page); const storyPreferredOverBattle = await waitForStoryPage( page, coexistenceFixture.pageIndex ); assert.equal( storyPreferredOverBattle.currentPageId, coexistenceFixture.beatId, `${renderer}: a matching-generation battle snapshot displaced the newer story checkpoint.` ); assert.equal( ( await page.evaluate( () => window.__HEROS_DEBUG__ ?.activeScenes?.() ?? [] ) ).includes('BattleScene'), false, `${renderer}: Continue opened BattleScene instead of the durable story checkpoint.` ); await reloadToTitle(page); const legacyVictoryReading = await seedLegacyFirstVictoryCheckpoint( page, { phase: 'reading', pageIndex: 2 } ); await reloadToTitle(page); await clickContinue(page); let legacyVictoryStory = await waitForStoryPage( page, legacyVictoryReading.pageIndex ); assert.equal( legacyVictoryStory.currentPageId, legacyVictoryReading.beatId, `${renderer}: legacy first-victory reading checkpoint did not restore its exact beat.` ); assert.equal( legacyVictoryStory.totalPages, legacyVictoryReading.totalPages, `${renderer}: legacy first-victory story page count changed during resume.` ); for ( let pageIndex = legacyVictoryReading.pageIndex + 1; pageIndex < legacyVictoryReading.totalPages; pageIndex += 1 ) { legacyVictoryStory = await advanceStoryPage( page, pageIndex ); } assert.equal( legacyVictoryStory.isLastPage, true, `${renderer}: legacy first-victory story did not reach its final beat.` ); await installSceneStartGate(page, 'StoryScene'); await page.keyboard.press('Space'); await waitForSceneStartGate(page); const pendingLegacyVictoryHandoff = await readCampaignMirrors(page); assertLegacyFirstVictoryCheckpoint( pendingLegacyVictoryHandoff, { ...legacyVictoryReading, phase: 'handoff', pageIndex: legacyVictoryReading.totalPages - 1, beatId: legacyVictoryStory.currentPageId }, `${renderer}: completed legacy first-victory story` ); await releaseSceneStartGate(page); await waitForTitle(page, true); const completedLegacyVictory = await readCampaignMirrors(page); assertLegacyFirstVictoryTransition( completedLegacyVictory, legacyVictoryReading, `${renderer}: Title-owned legacy first-victory handoff` ); assert.equal( ( await page.evaluate( () => window.__HEROS_DEBUG__ ?.activeScenes?.() ?? [] ) ).includes('StoryScene'), false, `${renderer}: completed legacy first-victory story remained active after Title ownership.` ); await clickContinue(page); await waitForCampStep(page, 'first-camp'); assert.equal( ( await page.evaluate( () => window.__HEROS_DEBUG__ ?.activeScenes?.() ?? [] ) ).includes('StoryScene'), false, `${renderer}: Continue replayed the completed legacy first-victory story instead of opening Camp.` ); await reloadToTitle(page); const legacyVictoryHandoff = await seedLegacyFirstVictoryCheckpoint( page, { phase: 'handoff', pageIndex: 4 } ); await reloadToTitle(page); const reloadedLegacyVictoryHandoff = await readCampaignMirrors(page); assertLegacyFirstVictoryCheckpoint( reloadedLegacyVictoryHandoff, legacyVictoryHandoff, `${renderer}: directly reloaded legacy first-victory handoff` ); await clickContinue(page); await waitForCampStep(page, 'first-camp'); const resumedLegacyVictoryHandoff = await readCampaignMirrors(page); assertLegacyFirstVictoryTransition( resumedLegacyVictoryHandoff, legacyVictoryHandoff, `${renderer}: directly resumed legacy first-victory handoff` ); assert.equal( ( await page.evaluate( () => window.__HEROS_DEBUG__ ?.activeScenes?.() ?? [] ) ).includes('StoryScene'), false, `${renderer}: directly resumed legacy handoff replayed StoryScene.` ); await assertDesktopRuntime(page, renderer); assert.deepEqual( pageErrors, [], `${renderer}: page errors: ${pageErrors.join('\n')}` ); assert.deepEqual( consoleErrors, [], `${renderer}: console errors: ${consoleErrors.join( '\n' )}` ); console.log( JSON.stringify( { renderer, viewport: { ...desktopBrowserViewport, deviceScaleFactor: desktopBrowserDeviceScaleFactor, browserZoom: '100%' }, storyCheckpoint: { exactPageResume: { pageIndex: resumed.pageIndex, currentPageId: resumed.currentPageId, speaker: resumed.currentSpeaker, background: resumed.background, portrait: resumed.portraitTextureKey }, runtimeCurrentSlotPhase: 'reading', legacyIndexMigratedToBeatId: migratedCheckpoint.beatId, pageHideRestoredExactPage: true, finalHandoffStayedUntilDestinationOwnership: true, finalHandoffConsumed: true, resumedHandoffStayedUntilDestinationOwnership: true, interruptedFinalHandoffResumedToDestination: true, storyCheckpointPreferredOverMatchingBattleResume: true, latePageHideResurrectionPrevented: true, legacyFirstVictoryReadingResumedAtBeat: legacyVictoryReading.beatId, legacyFirstVictoryTitleHandoffAdvancedToCamp: true, legacyFirstVictoryDidNotReplay: true, legacyFirstVictoryReloadedHandoffAdvancedToCamp: true }, 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); return parsed.toString(); } async function waitForDebugApi(page) { await page.waitForFunction( () => document.querySelector('canvas') !== null && window.__HEROS_GAME__ !== undefined && window.__HEROS_DEBUG__ !== undefined, undefined, { timeout: 90000 } ); } async function waitForTitle(page, continueEnabled = true) { await page.waitForFunction( (requireContinue) => { const debug = window.__HEROS_DEBUG__; const title = debug?.title?.(); const continueItem = title?.focus?.items?.find( ({ id }) => id === 'continue' ); return ( debug?.activeScenes?.().includes('TitleScene') && title?.scene === 'TitleScene' && title?.navigating === false && title?.focus?.scope === 'main' && (!requireContinue || (continueItem?.enabled === true && Boolean(continueItem.bounds))) ); }, continueEnabled, { timeout: 90000 } ); await afterTwoFrames(page); } async function reloadToTitle(page) { await page.reload({ waitUntil: 'domcontentloaded', timeout: 90000 }); await waitForDebugApi(page); await waitForTitle(page, true); await assertDesktopRuntime(page, renderer); } async function clickContinue(page) { const item = await page.evaluate( () => window.__HEROS_DEBUG__ ?.title?.() ?.focus?.items?.find( ({ id }) => id === 'continue' ) ?? null ); assert.equal( item?.enabled, true, `${renderer}: Continue is unavailable.` ); assertFiniteBounds(item.bounds); assertBoundsInsideViewport(item.bounds); await page.mouse.click( item.bounds.x + item.bounds.width / 2, item.bounds.y + item.bounds.height / 2 ); } async function installSceneStartGate(page, sourceSceneKey) { const installed = await page.evaluate((sceneKey) => { const scene = window.__HEROS_DEBUG__?.scene(sceneKey); const plugin = scene?.scene; if (!plugin || typeof plugin.start !== 'function') { return false; } const originalStart = plugin.start; const gate = { pending: null, release() { if (!gate.pending) { throw new Error( 'The gated scene start has not been requested.' ); } const pending = gate.pending; plugin.start = originalStart; delete window.__HEROS_STORY_START_GATE__; originalStart.call( plugin, pending.key, pending.data ); } }; window.__HEROS_STORY_START_GATE__ = gate; plugin.start = (key, data) => { gate.pending = { key, data }; return plugin; }; return true; }, sourceSceneKey); assert.equal( installed, true, `${renderer}: could not install the destination-ownership gate on ${sourceSceneKey}.` ); } async function waitForSceneStartGate(page) { await page.waitForFunction( () => Boolean( window.__HEROS_STORY_START_GATE__?.pending ), undefined, { timeout: 90000 } ); await afterTwoFrames(page); } async function releaseSceneStartGate(page) { await page.evaluate(() => { const gate = window.__HEROS_STORY_START_GATE__; if (!gate) { throw new Error( 'The destination-ownership gate is unavailable.' ); } gate.release(); }); } async function waitForStoryPage(page, expectedPageIndex) { try { await page.waitForFunction( (pageIndex) => { const debug = window.__HEROS_DEBUG__; const scene = debug?.scene('StoryScene'); const story = scene?.getDebugState?.(); const checkpoint = story?.storyCheckpoint?.persisted; return ( debug?.activeScenes?.().includes('StoryScene') && story?.ready === true && scene?.transitioning === false && story.pageIndex === pageIndex && Boolean(story.currentPageId) && story.backgroundReady === true && story.activeTexture === story.background && checkpoint?.phase === 'reading' && checkpoint?.pageIndex === pageIndex && checkpoint?.beatId === story.currentPageId ); }, expectedPageIndex, { timeout: 90000 } ); } catch (error) { const diagnostic = await page.evaluate(() => ({ activeScenes: window.__HEROS_DEBUG__?.activeScenes?.() ?? [], story: window.__HEROS_DEBUG__ ?.scene('StoryScene') ?.getDebugState?.() ?? null })); throw new Error( `${renderer}: story page ${expectedPageIndex} did not become ready: ` + JSON.stringify(diagnostic), { cause: error } ); } await afterTwoFrames(page); return readStory(page); } async function advanceStoryPage(page, expectedPageIndex) { const before = await readStory(page); assert.equal( before?.pageIndex, expectedPageIndex - 1, `${renderer}: story was not on the preceding page.` ); await page.keyboard.press('Space'); return waitForStoryPage(page, expectedPageIndex); } async function readStory(page) { return page.evaluate( () => window.__HEROS_DEBUG__ ?.scene('StoryScene') ?.getDebugState?.() ?? null ); } function storyPageIdentity(story) { assert(story, 'Story debug state is required.'); return { pageIndex: story.pageIndex, currentPageId: story.currentPageId, currentSpeaker: story.currentSpeaker, currentText: story.currentText, requestedBackground: story.requestedBackground, background: story.background, activeTexture: story.activeTexture, portraitKey: story.portraitKey, portraitTextureKey: story.portraitTextureKey }; } async function readCheckpointPhases(page) { return page.evaluate(() => { const read = (key) => { const raw = window.localStorage.getItem(key); return raw ? JSON.parse(raw) : null; }; const story = window.__HEROS_DEBUG__ ?.scene('StoryScene') ?.getDebugState?.() ?? null; return { story, runtime: story?.storyCheckpoint?.persisted ?? null, current: read('heros-web:campaign-state') ?.storyCheckpoint ?? null, slot: read('heros-web:campaign-state:slot-1') ?.storyCheckpoint ?? null }; }); } async function assertStoryCheckpointMatchesPage( page, story, label ) { const checkpoints = await readCheckpointPhases(page); const runtime = checkpoints.runtime; const current = checkpoints.current; const slot = checkpoints.slot; assert.equal( story?.storyCheckpoint?.enabled, true, label ); for (const [source, checkpoint] of [ ['runtime', runtime], ['current', current], ['slot', slot] ]) { assert.equal( checkpoint?.version, 1, `${label}: ${source} version` ); assert.equal( checkpoint?.sequenceId, story.storyCheckpoint.sequenceId, `${label}: ${source} sequence` ); assert.equal( checkpoint?.beatId, story.currentPageId, `${label}: ${source} beat` ); assert.equal( checkpoint?.pageIndex, story.pageIndex, `${label}: ${source} page` ); assert.equal( checkpoint?.phase, 'reading', `${label}: ${source} phase` ); } assert.deepEqual( current, slot, `${label}: current and slot checkpoints differ.` ); return runtime; } async function injectLegacyIndexCheckpoint( page, pageIndex ) { return page.evaluate((legacyPageIndex) => { const keys = [ 'heros-web:campaign-state', 'heros-web:campaign-state:slot-1' ]; let fixture; for (const key of keys) { const raw = window.localStorage.getItem(key); if (!raw) { throw new Error(`Missing campaign save ${key}`); } const save = JSON.parse(raw); if (!save.storyCheckpoint) { throw new Error( `Missing story checkpoint in ${key}` ); } const legacy = { ...save.storyCheckpoint, version: 0, pageIndex: legacyPageIndex }; delete legacy.beatId; save.storyCheckpoint = legacy; window.localStorage.setItem( key, JSON.stringify(save) ); fixture ??= legacy; } return { pageIndex: fixture.pageIndex, beatId: fixture.beatId ?? null }; }, pageIndex); } async function tamperCheckpointAndDispatchPageHide(page) { return page.evaluate(() => { const story = window.__HEROS_DEBUG__ ?.scene('StoryScene') ?.getDebugState?.(); if ( !story || story.pageIndex !== 1 || !story.storyCheckpoint?.sequenceId ) { throw new Error( 'The live second story page is unavailable.' ); } const keys = [ 'heros-web:campaign-state', 'heros-web:campaign-state:slot-1' ]; const readCheckpoints = () => { const values = keys.map((key) => { const raw = window.localStorage.getItem(key); return raw ? JSON.parse(raw).storyCheckpoint ?? null : null; }); return { current: values[0], slot: values[1] }; }; for (const key of keys) { const raw = window.localStorage.getItem(key); if (!raw) { throw new Error(`Missing campaign save ${key}`); } const save = JSON.parse(raw); save.storyCheckpoint = { ...save.storyCheckpoint, beatId: story.storyCheckpoint.sequenceId, pageIndex: 0, phase: 'reading' }; window.localStorage.setItem( key, JSON.stringify(save) ); } const beforeDispatch = readCheckpoints(); window.dispatchEvent(new Event('pagehide')); return { runtimePageIndex: story.pageIndex, beforeDispatch, afterDispatch: readCheckpoints() }; }); } async function injectInterruptedStoryHandoff( page, checkpoint ) { await page.evaluate((sourceCheckpoint) => { const keys = [ 'heros-web:campaign-state', 'heros-web:campaign-state:slot-1' ]; const savedAt = new Date( Date.now() + 1000 ).toISOString(); for (const key of keys) { const raw = window.localStorage.getItem(key); if (!raw) { throw new Error(`Missing campaign save ${key}`); } const save = JSON.parse(raw); const revision = Math.max( Number(save.storyCheckpointRevision) || 0, Number(sourceCheckpoint.revision) || 0 ) + 1; save.step = 'prologue'; save.completedTutorialIds = []; delete save.explorationCheckpoint; delete save.activeCityStayId; delete save.activeCampVisitId; save.storyCheckpointRevision = revision; save.storyCheckpoint = { ...sourceCheckpoint, revision, campaignStep: 'prologue', phase: 'handoff', savedAt }; save.updatedAt = savedAt; window.localStorage.setItem( key, JSON.stringify(save) ); } }, checkpoint); } async function injectStoryAndMatchingBattleResume( page ) { return page.evaluate(() => { const campaignKeys = [ 'heros-web:campaign-state', 'heros-web:campaign-state:slot-1' ]; const currentRaw = window.localStorage.getItem(campaignKeys[0]); if (!currentRaw) { throw new Error( 'Missing campaign save for the coexistence fixture.' ); } const current = JSON.parse(currentRaw); const revision = Math.max( Number( current.storyCheckpointRevision ) || 0, Number( current.storyCheckpoint?.revision ) || 0 ) + 1; const generation = (Number(current.battleResumeGeneration) || 0) + 1; const savedAt = new Date( Date.now() + 2000 ).toISOString(); const sequenceId = 'second-pursuit-rumor'; const beatId = 'second-guan-yu-vow'; const pageIndex = 2; for (const key of campaignKeys) { const raw = window.localStorage.getItem(key); if (!raw) { throw new Error( `Missing campaign save ${key}` ); } const save = JSON.parse(raw); save.step = 'second-battle'; save.activeSaveSlot = 1; save.battleResumeGeneration = generation; save.storyCheckpointRevision = revision; save.storyCheckpoint = { version: 1, revision, sequenceId, phase: 'reading', beatId, pageIndex, campaignStep: 'second-battle', continuationIntent: 'default', savedAt }; delete save.explorationCheckpoint; delete save.activeCityStayId; delete save.activeCampVisitId; delete save.pendingAftermathBattleId; save.updatedAt = savedAt; window.localStorage.setItem( key, JSON.stringify(save) ); } window.localStorage.setItem( 'heros-web:battle:second-battle-yellow-turban-pursuit:slot-1', JSON.stringify({ version: 1, battleId: 'second-battle-yellow-turban-pursuit', campaignStep: 'second-battle', battleResumeGeneration: generation, savedAt: new Date( Date.now() + 3000 ).toISOString(), turnNumber: 2, activeFaction: 'ally', rosterTab: 'ally', actedUnitIds: [], attackIntents: [], battleLog: ['coexistence fixture'], units: [ { id: 'liu-bei', level: 1, exp: 0, hp: 30, maxHp: 30, attack: 10, move: 4, x: 1, y: 1, direction: 'south', equipment: { weapon: { itemId: 'training-sword', level: 1, exp: 0 }, armor: { itemId: 'cloth-armor', level: 1, exp: 0 }, accessory: { itemId: 'grain-pouch', level: 1, exp: 0 } } } ], bonds: [] }) ); return { sequenceId, beatId, pageIndex, generation }; }); } async function seedLegacyFirstVictoryCheckpoint( page, { phase, pageIndex } ) { const prepared = await page.evaluate( ({ requestedPhase, requestedPageIndex, fixture }) => { if ( !['reading', 'handoff'].includes( requestedPhase ) || !Number.isInteger(requestedPageIndex) || requestedPageIndex < 0 || requestedPageIndex >= fixture.pageIds.length ) { throw new Error( 'Invalid legacy first-victory checkpoint fixture.' ); } const campaignKeys = [ 'heros-web:campaign-state', 'heros-web:campaign-state:slot-1' ]; const currentRaw = window.localStorage.getItem(campaignKeys[0]) ?? window.localStorage.getItem(campaignKeys[1]); if (!currentRaw) { throw new Error( 'Missing campaign save for the legacy first-victory fixture.' ); } const campaign = JSON.parse(currentRaw); const roster = Array.isArray(campaign.roster) && campaign.roster.length > 0 ? structuredClone(campaign.roster) : structuredClone(fixture.alliedUnits); const bonds = Array.isArray(campaign.bonds) && campaign.bonds.length > 0 ? structuredClone(campaign.bonds).map( (bond) => ({ ...bond, battleExp: Number.isFinite(bond.battleExp) ? bond.battleExp : 0 }) ) : structuredClone(fixture.bonds); const revision = Math.max( Number( campaign.storyCheckpointRevision ) || 0, Number( campaign.storyCheckpoint?.revision ) || 0 ) + 1; const savedAt = new Date( Date.now() + 4000 ).toISOString(); const checkpoint = { version: 1, revision, sequenceId: fixture.pageIds[0], phase: requestedPhase, beatId: fixture.pageIds[requestedPageIndex], pageIndex: requestedPageIndex, campaignStep: 'first-victory-story', continuationIntent: 'default', savedAt }; campaign.step = 'first-victory-story'; campaign.activeSaveSlot = 1; campaign.roster = roster; campaign.bonds = bonds; campaign.storyCheckpointRevision = revision; campaign.storyCheckpoint = checkpoint; campaign.firstBattleReport = { battleId: fixture.battleId, battleTitle: fixture.battleTitle, outcome: 'victory', turnNumber: 6, rewardGold: fixture.rewardGold, defeatedEnemies: 1, totalEnemies: 1, objectives: [], units: roster, bonds, itemRewards: [], completedCampDialogues: [], completedCampVisits: [], createdAt: '2026-07-29T00:00:00.000Z' }; delete campaign.explorationCheckpoint; delete campaign.activeCityStayId; delete campaign.activeCampVisitId; delete campaign.pendingAftermathBattleId; campaign.updatedAt = savedAt; return { campaignKeys, serializedCampaign: JSON.stringify(campaign), expected: { phase: requestedPhase, sequenceId: checkpoint.sequenceId, revision: checkpoint.revision, pageIndex: checkpoint.pageIndex, beatId: checkpoint.beatId, totalPages: fixture.pageIds.length, storyCheckpointRevision: revision } }; }, { requestedPhase: phase, requestedPageIndex: pageIndex, fixture: legacyFirstVictoryStoryFixture } ); // Install the fixture in the next document, after the outgoing StoryScene // has received pagehide. This prevents its legitimate final save from // overwriting a storage-only migration fixture and keeps the release build // free from source-module imports or test-only product hooks. const marker = `heros-story-fixture:${renderer}:${phase}:${pageIndex}:` + `${Date.now()}:${Math.random()}`; await page.addInitScript( ({ fixtureMarker, campaignKeys, serializedCampaign }) => { if ( window.sessionStorage.getItem( fixtureMarker ) === 'installed' ) { return; } window.sessionStorage.setItem( fixtureMarker, 'installed' ); for (const key of campaignKeys) { window.localStorage.setItem( key, serializedCampaign ); } }, { fixtureMarker: marker, campaignKeys: prepared.campaignKeys, serializedCampaign: prepared.serializedCampaign } ); return prepared.expected; } async function readCampaignMirrors(page) { return page.evaluate(() => { const read = (key) => { const raw = window.localStorage.getItem(key); return raw ? JSON.parse(raw) : null; }; return { current: read('heros-web:campaign-state'), slot: read( 'heros-web:campaign-state:slot-1' ) }; }); } function assertLegacyFirstVictoryCheckpoint( mirrors, expected, label ) { assert.deepEqual( mirrors.current, mirrors.slot, `${label}: current and canonical slot diverged.` ); for (const [source, campaign] of [ ['current', mirrors.current], ['slot', mirrors.slot] ]) { assert.equal( campaign?.step, 'first-victory-story', `${label}: ${source} campaign step` ); assert.equal( campaign?.storyCheckpoint?.sequenceId, expected.sequenceId, `${label}: ${source} sequence` ); assert.equal( campaign?.storyCheckpoint?.revision, expected.revision, `${label}: ${source} revision` ); assert.equal( campaign?.storyCheckpoint?.phase, expected.phase, `${label}: ${source} phase` ); assert.equal( campaign?.storyCheckpoint?.pageIndex, expected.pageIndex, `${label}: ${source} page` ); assert.equal( campaign?.storyCheckpoint?.beatId, expected.beatId, `${label}: ${source} beat` ); } } function assertLegacyFirstVictoryTransition( mirrors, sourceCheckpoint, label ) { assert.deepEqual( mirrors.current, mirrors.slot, `${label}: current and canonical slot diverged.` ); for (const [source, campaign] of [ ['current', mirrors.current], ['slot', mirrors.slot] ]) { assert.equal( campaign?.step, 'first-camp', `${label}: ${source} did not advance to first-camp` ); assert.equal( campaign?.latestBattleId, 'first-battle-zhuo-commandery', `${label}: ${source} latest battle` ); assert.equal( campaign?.storyCheckpoint, undefined, `${label}: ${source} retained the consumed checkpoint` ); assert.equal( campaign?.storyCheckpointRevision, sourceCheckpoint.revision + 1, `${label}: ${source} did not invalidate the consumed revision` ); assert.equal( campaign?.firstBattleReport?.outcome, 'victory', `${label}: ${source} lost the settled victory report` ); assert.equal( campaign?.pendingAftermathBattleId, undefined, `${label}: ${source} retained a pending aftermath` ); } } async function waitForCampStep(page, expectedStep) { try { await page.waitForFunction( (step) => { const debug = window.__HEROS_DEBUG__; const activeScenes = debug?.activeScenes?.() ?? []; const camp = debug?.camp?.(); return ( activeScenes.includes('CampScene') && !activeScenes.includes('StoryScene') && camp?.scene === 'CampScene' && camp?.campaign?.step === step && Boolean( camp?.campaignObjectiveJournal?.snapshot ) ); }, expectedStep, { timeout: 90000 } ); } catch (error) { const diagnostic = await page.evaluate(() => ({ activeScenes: window.__HEROS_DEBUG__?.activeScenes?.() ?? [], camp: window.__HEROS_DEBUG__?.camp?.() ?? null, title: window.__HEROS_DEBUG__?.title?.() ?? null, story: window.__HEROS_DEBUG__ ?.scene('StoryScene') ?.getDebugState?.() ?? null })); throw new Error( `${renderer}: CampScene did not reach ${expectedStep}: ` + JSON.stringify(diagnostic), { cause: error } ); } await afterTwoFrames(page); await assertDesktopRuntime(page, renderer); } function assertNoStoryCheckpoint(checkpoints, label) { assert.equal(checkpoints.runtime, null, label); assert.equal(checkpoints.current, null, label); assert.equal(checkpoints.slot, null, label); } function assertDurableHandoffPending( checkpoints, expected, label ) { for (const [source, checkpoint] of [ ['current', checkpoints.current], ['slot', checkpoints.slot] ]) { assert.equal( checkpoint?.sequenceId, expected?.sequenceId, `${label}: ${source} sequence` ); assert.equal( checkpoint?.revision, expected?.revision, `${label}: ${source} revision` ); assert.equal( checkpoint?.phase, 'handoff', `${label}: ${source} phase` ); } assert.deepEqual( checkpoints.current, checkpoints.slot, `${label}: current and slot handoffs differ.` ); } async function waitForVillageReady(page) { try { await page.waitForFunction( () => { const debug = window.__HEROS_DEBUG__; const village = debug?.village?.(); return ( debug ?.activeScenes?.() .includes('PrologueVillageScene') && !debug ?.activeScenes?.() .includes('StoryScene') && village?.scene === 'PrologueVillageScene' && village?.ready === true && village?.requiredTexturesReady === true ); }, undefined, { timeout: 90000 } ); } catch (error) { const diagnostic = await page.evaluate(() => ({ activeScenes: window.__HEROS_DEBUG__?.activeScenes?.() ?? [], village: window.__HEROS_DEBUG__?.village?.() ?? null, story: window.__HEROS_DEBUG__ ?.scene('StoryScene') ?.getDebugState?.() ?? null })); throw new Error( `${renderer}: story did not hand off to the village: ` + JSON.stringify(diagnostic), { cause: error } ); } await afterTwoFrames(page); } 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 || '1', rendererType: window.__HEROS_GAME__?.renderer?.type ?? null, canvas: canvas ? { width: canvas.width, height: canvas.height, clientWidth: canvas.clientWidth, clientHeight: canvas.clientHeight, bounds: bounds ? { x: bounds.x, y: bounds.y, 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 ); } function assertFiniteBounds(bounds) { assert(bounds, 'Interactive bounds are required.'); for (const value of [ bounds.x, bounds.y, bounds.width, bounds.height ]) { assert(Number.isFinite(value)); } assert(bounds.width > 0); assert(bounds.height > 0); } function assertBoundsInsideViewport(bounds) { assert( bounds.x >= 0 && bounds.y >= 0 && bounds.x + bounds.width <= desktopBrowserViewport.width && bounds.y + bounds.height <= desktopBrowserViewport.height, `Bounds are outside the FHD viewport: ${JSON.stringify( bounds )}` ); } async function afterTwoFrames(page) { await page.evaluate( () => new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve) ) ) ); }