import assert from 'node:assert/strict'; import { spawn, spawnSync } from 'node:child_process'; import { mkdirSync } from 'node:fs'; import { fileURLToPath } from 'node:url'; import { chromium } from 'playwright'; import { desktopBrowserContextOptions, desktopBrowserDeviceScaleFactor, desktopBrowserViewport, desktopBrowserViewportBounds } from './desktop-browser-viewport.mjs'; const renderer = process.env.VERIFY_CAMP_EXPLORATION_CHECKPOINT_RENDERER; const rendererNames = ['canvas', 'webgl']; const fixture = Object.freeze({ visitId: 'first-pursuit-scout-tent', battleId: 'first-battle-zhuo-commandery', campaignStep: 'first-camp', actorId: 'jian-yong', movementAnchorId: 'zhang-fei', exitId: 'camp-exit', choiceId: 'trace-river-ambush', itemId: '상처약', itemAmount: 1, bondId: 'liu-bei__jian-yong', bondExp: 10 }); if (!renderer) { for (const requestedRenderer of rendererNames) { const result = spawnSync( process.execPath, [fileURLToPath(import.meta.url)], { cwd: process.cwd(), env: { ...process.env, VERIFY_CAMP_EXPLORATION_CHECKPOINT_RENDERER: requestedRenderer }, stdio: 'inherit' } ); if (result.status !== 0) { process.exit(result.status ?? 1); } } process.exit(0); } assert( rendererNames.includes(renderer), `Unsupported camp exploration checkpoint renderer "${renderer}".` ); const baseUrl = process.env.VERIFY_CAMP_EXPLORATION_CHECKPOINT_URL ?? 'http://127.0.0.1:41808/heros_web/'; const targetUrl = withDebugOptions(baseUrl, renderer); const localSeedUrl = withDebugOptions( 'http://127.0.0.1:41808/heros_web/', renderer ); let serverProcess; let seedServerProcess; let browser; try { mkdirSync('dist', { recursive: true }); serverProcess = await ensureLocalServer(targetUrl); browser = await chromium.launch({ headless: process.env.VERIFY_CAMP_EXPLORATION_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.addInitScript(() => { const marker = 'heros-web:qa:camp-exploration-checkpoint-storage-cleared'; if (window.sessionStorage.getItem(marker) === '1') { return; } window.localStorage.clear(); window.sessionStorage.setItem(marker, '1'); }); await page.goto(targetUrl, { waitUntil: 'domcontentloaded', timeout: 90000 }); await waitForDebugApi(page); let seeded; if (isLocalUrl(targetUrl)) { seeded = await seedFirstCampVisit(page, fixture); } else { const bridge = await createSeedBridge( browser, localSeedUrl, fixture ); seedServerProcess = bridge.serverProcess; seeded = bridge.seeded; await installCampaignSaves(page, bridge.campaignSaves); } assertSeededVisit(seeded); await reloadToTitle(page); assertRuntimeBaseline( await readRuntimeProbe(page, 'TitleScene'), renderer, 'seeded first-camp title' ); await continueToExploration(page); assertRuntimeBaseline( await readRuntimeProbe( page, 'CampVisitExplorationScene' ), renderer, 'first-camp exploration' ); const savedPlayer = await createPlayerCheckpoint(page); const playerResume = await reloadAndContinueToExploration(page); assertExactPlayer( playerResume.player, savedPlayer, `${renderer}: position checkpoint resume` ); assertExactCheckpointPlayer( playerResume.campaign.explorationCheckpoint, savedPlayer, `${renderer}: persisted position checkpoint` ); const dialogueBeforeReload = await openAndAdvanceActorDialogue(page); const dialogueResume = await reloadAndContinueToExploration(page); assertExactPlayer( dialogueResume.player, dialogueBeforeReload.player, `${renderer}: dialogue player resume` ); assertDialogueResume( dialogueResume, dialogueBeforeReload, `${renderer}: actor dialogue resume` ); const choiceBeforeReload = await advanceToChoicePanel(page); const choiceResume = await reloadAndContinueToExploration(page); await waitForChoiceReady(page); const readyChoiceResume = await readExploration(page); assertChoiceResume( readyChoiceResume, choiceBeforeReload, `${renderer}: choice panel resume` ); assertRuntimeBaseline( await readRuntimeProbe( page, 'CampVisitExplorationScene' ), renderer, 'resumed choice panel' ); await page.screenshot({ path: `dist/camp-exploration-checkpoint-choice-${renderer}.png` }); const rewardBefore = rewardSnapshot( await readCampaignStates(page) ); assertRewardNotYetApplied( rewardBefore, `${renderer}: pre-choice reward state` ); const selectedChoice = readyChoiceResume.choice.choices.find( ({ id }) => id === fixture.choiceId ); assert( selectedChoice, `${renderer}: choice ${fixture.choiceId} is unavailable.` ); const choiceBounds = assertFiniteBounds( selectedChoice.bounds, `${renderer}: ${fixture.choiceId} choice` ); assertBoundsContained( choiceBounds, desktopBrowserViewportBounds, `${renderer}: ${fixture.choiceId} choice` ); await clickSceneBounds( page, 'CampVisitExplorationScene', choiceBounds ); await waitForChoiceResponse(page, 0); const rewardAfterChoice = rewardSnapshot( await readCampaignStates(page) ); assertRewardAppliedExactlyOnce( rewardBefore, rewardAfterChoice, `${renderer}: first choice commit` ); const advancedResponse = await page.evaluate(() => window.__HEROS_DEBUG__ ?.scene('CampVisitExplorationScene') ?.debugAdvanceDialogue?.() ?? false ); assert.equal( advancedResponse, true, `${renderer}: could not advance the choice response.` ); await waitForChoiceResponse(page, 1); const responseBeforeReload = await readExploration(page); assert.equal( responseBeforeReload.dialogue.sourceNpcId, fixture.actorId ); assert.equal( responseBeforeReload.campaign.explorationCheckpoint?.overlay ?.dialogueKey, 'first-choice-response' ); const responseResume = await reloadAndContinueToExploration(page); assertChoiceResponseResume( responseResume, responseBeforeReload, `${renderer}: choice response resume` ); const rewardAfterReload = rewardSnapshot( await readCampaignStates(page) ); assert.deepEqual( rewardAfterReload, rewardAfterChoice, `${renderer}: reloading the response duplicated or changed rewards.` ); assertRewardAppliedExactlyOnce( rewardBefore, rewardAfterReload, `${renderer}: post-reload reward state` ); await finishActiveDialogue(page); await exitExplorationNormally(page); await waitForCampReady(page, fixture.campaignStep); assertRuntimeBaseline( await readRuntimeProbe(page, 'CampScene'), renderer, 'normal checkpoint-clearing exit' ); assertCheckpointCleared( await readCampaignStates(page), `${renderer}: normal exit` ); await reloadToTitle(page); await clickContinue(page); await waitForCampReady(page, fixture.campaignStep); assertCheckpointCleared( await readCampaignStates(page), `${renderer}: continue after normal exit` ); assert( !(await page.evaluate(() => window.__HEROS_DEBUG__ ?.activeScenes?.() .includes('CampVisitExplorationScene') )), `${renderer}: a cleared checkpoint reopened exploration.` ); const actionableConsoleErrors = consoleErrors.filter( (message) => !message.includes( 'The AudioContext was not allowed to start' ) ); assert.deepEqual( pageErrors, [], `${renderer}: page errors: ${pageErrors.join('\n')}` ); assert.deepEqual( actionableConsoleErrors, [], `${renderer}: console errors: ${actionableConsoleErrors.join( '\n' )}` ); console.log( JSON.stringify( { renderer, viewport: { ...desktopBrowserViewport, deviceScaleFactor: desktopBrowserDeviceScaleFactor, browserZoom: '100%' }, checkpoint: { exactPlayerResume: savedPlayer, dialogue: { sourceNpcId: dialogueBeforeReload.dialogue.sourceNpcId, lineIndex: dialogueBeforeReload.dialogue.lineIndex }, choicePanelResumed: choiceResume.choice.open === true, response: { sourceNpcId: responseBeforeReload.dialogue.sourceNpcId, lineIndex: responseBeforeReload.dialogue.lineIndex }, rewardCommittedOnce: true, normalExitCleared: true }, pageErrors: pageErrors.length, consoleErrors: actionableConsoleErrors.length }, null, 2 ) ); } finally { await browser?.close(); await stopServerProcess(serverProcess); await stopServerProcess(seedServerProcess); } function withDebugOptions(url, requestedRenderer) { const parsed = new URL(url); parsed.searchParams.set('debug', '1'); parsed.searchParams.set('renderer', requestedRenderer); return parsed.toString(); } function isLocalUrl(url) { return ['localhost', '127.0.0.1', '0.0.0.0'].includes( new URL(url).hostname ); } async function waitForDebugApi(page) { await page.waitForFunction( () => document.querySelector('canvas') !== null && window.__HEROS_GAME__ !== undefined && window.__HEROS_DEBUG__ !== undefined, undefined, { timeout: 90000 } ); } async function seedFirstCampVisit(page, requested) { return page.evaluate(async (seed) => { const campaignModule = await import( '/heros_web/src/game/state/campaignState.ts' ); const { battleScenarios } = await import( '/heros_web/src/game/data/battles.ts' ); const scenario = battleScenarios[seed.battleId]; if (!scenario) { throw new Error( `Missing battle scenario ${seed.battleId}.` ); } campaignModule.resetCampaignState(); campaignModule.setFirstBattleReport({ battleId: scenario.id, battleTitle: scenario.title, outcome: 'victory', turnNumber: 6, rewardGold: scenario.baseVictoryGold, defeatedEnemies: scenario.units.filter( (unit) => unit.faction === 'enemy' ).length, totalEnemies: scenario.units.filter( (unit) => unit.faction === 'enemy' ).length, objectives: [], units: scenario.units, bonds: (scenario.bonds ?? []).map((bond) => ({ ...bond, battleExp: 0 })), itemRewards: [], completedCampDialogues: [], completedCampVisits: [], createdAt: '2026-07-28T00:00:00.000Z' }); campaignModule.completeCampaignAftermath(scenario.id); campaignModule.setActiveCampVisitId(seed.visitId); const campaign = campaignModule.getCampaignState(); const slot = campaignModule.readCampaignSaveState(1); const bond = campaign.bonds.find( ({ id }) => id === seed.bondId ); return { step: campaign.step, latestBattleId: campaign.latestBattleId ?? null, activeCampVisitId: campaign.activeCampVisitId ?? null, slotActiveCampVisitId: slot?.activeCampVisitId ?? null, checkpoint: campaign.explorationCheckpoint ?? null, completedVisitCount: campaign.completedCampVisits.filter( (id) => id === seed.visitId ).length, inventoryItem: campaign.inventory[seed.itemId] ?? 0, bond: bond ? { level: bond.level, exp: bond.exp, battleExp: bond.battleExp } : null }; }, requested); } function assertSeededVisit(seeded) { assert.equal(seeded.step, fixture.campaignStep); assert.equal(seeded.latestBattleId, fixture.battleId); assert.equal( seeded.activeCampVisitId, fixture.visitId ); assert.equal( seeded.slotActiveCampVisitId, fixture.visitId ); assert.equal(seeded.checkpoint, null); assert.equal(seeded.completedVisitCount, 0); assert( seeded.bond, `${renderer}: seed is missing ${fixture.bondId}.` ); } async function createSeedBridge( activeBrowser, seedUrl, requested ) { const localServerProcess = await ensureLocalServer(seedUrl); const context = await activeBrowser.newContext( desktopBrowserContextOptions ); try { const page = await context.newPage(); page.setDefaultTimeout(30000); await page.addInitScript(() => window.localStorage.clear()); await page.goto(seedUrl, { waitUntil: 'domcontentloaded', timeout: 90000 }); await waitForDebugApi(page); const seeded = await seedFirstCampVisit(page, requested); const campaignSaves = await readCampaignSaves(page); assert( Object.keys(campaignSaves).length >= 2, 'Local seed bridge did not create current and slot saves.' ); return { seeded, campaignSaves, serverProcess: localServerProcess }; } catch (error) { await stopServerProcess(localServerProcess); throw error; } finally { await context.close(); } } async function installCampaignSaves(page, campaignSaves) { await page.evaluate((saves) => { Object.keys(window.localStorage) .filter((key) => key.startsWith('heros-web:campaign-state') ) .forEach((key) => window.localStorage.removeItem(key)); for (const [key, value] of Object.entries(saves)) { window.localStorage.setItem(key, value); } }, campaignSaves); } async function reloadToTitle(page) { await page.reload({ waitUntil: 'domcontentloaded', timeout: 90000 }); await waitForDebugApi(page); await page.waitForFunction( () => { 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' && continueItem?.enabled === true && continueItem?.bounds ); }, undefined, { timeout: 90000 } ); await afterTwoFrames(page); } 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}: title continue button is unavailable.` ); const bounds = assertFiniteBounds( item.bounds, `${renderer}: title continue button` ); assertBoundsContained( bounds, desktopBrowserViewportBounds, `${renderer}: title continue button` ); await clickSceneBounds(page, 'TitleScene', bounds); } async function continueToExploration(page) { await clickContinue(page); return waitForExplorationReady(page); } async function reloadAndContinueToExploration(page) { await reloadToTitle(page); return continueToExploration(page); } async function waitForExplorationReady(page) { try { await page.waitForFunction( (visitId) => { const debug = window.__HEROS_DEBUG__; const exploration = debug?.campVisitExploration?.(); return ( debug ?.activeScenes?.() .includes('CampVisitExplorationScene') && exploration?.ready === true && exploration?.locationId === visitId && exploration?.visit?.id === visitId && exploration?.requiredTexturesReady === true ); }, fixture.visitId, { timeout: 90000 } ); } catch (error) { const diagnostic = await page.evaluate(() => ({ activeScenes: window.__HEROS_DEBUG__?.activeScenes?.() ?? [], title: window.__HEROS_DEBUG__?.title?.() ?? null, exploration: window.__HEROS_DEBUG__?.campVisitExploration?.() ?? null })); throw new Error( `${renderer}: exploration did not become ready: ` + JSON.stringify(diagnostic), { cause: error } ); } await afterTwoFrames(page); const exploration = await readExploration(page); assert.equal( exploration.campaignStep, fixture.campaignStep ); return exploration; } async function readExploration(page) { return page.evaluate( () => window.__HEROS_DEBUG__?.campVisitExploration?.() ?? null ); } async function createPlayerCheckpoint(page) { await page.waitForTimeout(450); const teleported = await page.evaluate((targetId) => window.__HEROS_DEBUG__ ?.scene('CampVisitExplorationScene') ?.debugTeleportTo?.(targetId) ?? false, fixture.movementAnchorId ); assert.equal( teleported, true, `${renderer}: could not create the movement checkpoint.` ); await page.keyboard.down('ArrowRight'); await page.waitForTimeout(260); await page.keyboard.up('ArrowRight'); try { await page.waitForFunction( () => { const state = window.__HEROS_DEBUG__?.campVisitExploration?.(); const player = state?.player; const checkpoint = state?.campaign?.explorationCheckpoint; return ( player && player.moving === false && player.direction === 'east' && checkpoint?.overlay === undefined && Math.abs(checkpoint?.player?.x - player.x) <= 0.05 && Math.abs(checkpoint?.player?.y - player.y) <= 0.05 && checkpoint?.player?.direction === player.direction ); }, undefined, { timeout: 10000 } ); } catch (error) { const diagnostic = await readExploration(page); throw new Error( `${renderer}: movement checkpoint did not settle: ` + JSON.stringify({ activeScenes: await page.evaluate( () => window.__HEROS_DEBUG__?.activeScenes?.() ?? [] ), player: diagnostic?.player ?? null, checkpoint: diagnostic?.campaign?.explorationCheckpoint ?? null, movement: diagnostic?.movement ?? null }), { cause: error } ); } const exploration = await readExploration(page); const player = exactPlayer( exploration.campaign.explorationCheckpoint?.player ); assertExactCheckpointPlayer( exploration.campaign.explorationCheckpoint, player, `${renderer}: newly saved player checkpoint` ); return player; } async function openAndAdvanceActorDialogue(page) { const interacted = await page.evaluate((actorId) => window.__HEROS_DEBUG__ ?.scene('CampVisitExplorationScene') ?.debugInteractWith?.(actorId) ?? false, fixture.actorId ); assert.equal( interacted, true, `${renderer}: could not interact with ${fixture.actorId}.` ); await waitForDialogue(page, { sourceNpcId: fixture.actorId, lineIndex: 0, dialogueKey: 'visit-interaction' }); const advanced = await page.evaluate(() => window.__HEROS_DEBUG__ ?.scene('CampVisitExplorationScene') ?.debugAdvanceDialogue?.() ?? false ); assert.equal( advanced, true, `${renderer}: could not advance the actor dialogue.` ); await waitForDialogue(page, { sourceNpcId: fixture.actorId, lineIndex: 1, dialogueKey: 'visit-interaction' }); return readExploration(page); } async function waitForDialogue( page, { sourceNpcId, lineIndex, dialogueKey } ) { await page.waitForFunction( (expected) => { const state = window.__HEROS_DEBUG__?.campVisitExploration?.(); const overlay = state?.campaign?.explorationCheckpoint?.overlay; return ( state?.dialogue?.active === true && state.dialogue.sourceNpcId === expected.sourceNpcId && state.dialogue.lineIndex === expected.lineIndex && overlay?.type === 'dialogue' && overlay.dialogueKey === expected.dialogueKey && overlay.sourceNpcId === expected.sourceNpcId && overlay.lineIndex === expected.lineIndex ); }, { sourceNpcId, lineIndex, dialogueKey }, { timeout: 10000 } ); await afterTwoFrames(page); } function assertDialogueResume(actual, expected, label) { assert.equal(actual.dialogue.active, true, label); assert.equal( actual.dialogue.sourceNpcId, expected.dialogue.sourceNpcId, `${label}: source changed.` ); assert.equal( actual.dialogue.lineIndex, expected.dialogue.lineIndex, `${label}: line changed.` ); assert.equal( actual.dialogue.totalLines, expected.dialogue.totalLines, `${label}: dialogue content changed.` ); const actualOverlay = actual.campaign.explorationCheckpoint?.overlay; const expectedOverlay = expected.campaign.explorationCheckpoint?.overlay; assert.deepEqual( actualOverlay, expectedOverlay, `${label}: checkpoint overlay changed.` ); } async function advanceToChoicePanel(page) { for (let attempt = 0; attempt < 8; attempt += 1) { const state = await readExploration(page); if (state?.choice?.open) { break; } assert.equal( state?.dialogue?.active, true, `${renderer}: dialogue closed before the choice panel opened.` ); const advanced = await page.evaluate(() => window.__HEROS_DEBUG__ ?.scene('CampVisitExplorationScene') ?.debugAdvanceDialogue?.() ?? false ); assert.equal( advanced, true, `${renderer}: failed to advance toward the choice panel.` ); await page.waitForTimeout(80); } await page.waitForFunction( () => { const state = window.__HEROS_DEBUG__?.campVisitExploration?.(); const overlay = state?.campaign?.explorationCheckpoint?.overlay; return ( state?.dialogue?.active === false && state?.choice?.open === true && state.choice.mode === 'visit' && state.choice.choices.length === 2 && overlay?.type === 'choice' && overlay.mode === 'visit' ); }, undefined, { timeout: 10000 } ); await afterTwoFrames(page); return readExploration(page); } async function waitForChoiceReady(page) { await page.waitForFunction( () => { const choice = window.__HEROS_DEBUG__ ?.campVisitExploration?.() ?.choice; return ( choice?.open === true && choice?.ready === true && choice.choices.length === 2 && choice.choices.every( ({ interactive, bounds }) => interactive === true && bounds && Number.isFinite(bounds.x) && Number.isFinite(bounds.y) ) ); }, undefined, { timeout: 10000 } ); await afterTwoFrames(page); } function assertChoiceResume(actual, expected, label) { assert.equal(actual.choice.open, true, label); assert.equal(actual.choice.mode, expected.choice.mode); assert.equal( actual.choice.sourceNpcId, expected.choice.sourceNpcId ); assert.deepEqual( actual.choice.choices.map(({ id }) => id), expected.choice.choices.map(({ id }) => id), `${label}: choice IDs changed.` ); assert.deepEqual( actual.campaign.explorationCheckpoint?.overlay, expected.campaign.explorationCheckpoint?.overlay, `${label}: choice checkpoint changed.` ); for (const choice of actual.choice.choices) { const bounds = assertFiniteBounds( choice.bounds, `${label}: ${choice.id}` ); assertBoundsContained( bounds, desktopBrowserViewportBounds, `${label}: ${choice.id}` ); } } async function waitForChoiceResponse(page, lineIndex) { await waitForDialogue(page, { sourceNpcId: fixture.actorId, lineIndex, dialogueKey: 'first-choice-response' }); const state = await readExploration(page); assert.equal(state.choice.open, false); assert.equal(state.visit.completed, true); assert.equal(state.visit.choiceId, fixture.choiceId); } function assertChoiceResponseResume(actual, expected, label) { assertDialogueResume(actual, expected, label); assert.equal(actual.visit.completed, true, label); assert.equal( actual.visit.choiceId, fixture.choiceId, `${label}: selected choice changed.` ); assert.equal( actual.choice.open, false, `${label}: response reopened the choice panel.` ); } async function finishActiveDialogue(page) { for (let attempt = 0; attempt < 8; attempt += 1) { const state = await readExploration(page); if (!state?.dialogue?.active) { break; } const advanced = await page.evaluate(() => window.__HEROS_DEBUG__ ?.scene('CampVisitExplorationScene') ?.debugAdvanceDialogue?.() ?? false ); assert.equal( advanced, true, `${renderer}: could not finish the response dialogue.` ); await page.waitForTimeout(80); } await page.waitForFunction( () => { const state = window.__HEROS_DEBUG__?.campVisitExploration?.(); return ( state?.dialogue?.active === false && state?.choice?.open === false && state?.campaign?.explorationCheckpoint && state.campaign.explorationCheckpoint.overlay === undefined ); }, undefined, { timeout: 10000 } ); } async function exitExplorationNormally(page) { const startedExit = await page.evaluate((exitId) => window.__HEROS_DEBUG__ ?.scene('CampVisitExplorationScene') ?.debugInteractWith?.(exitId) ?? false, fixture.exitId ); assert.equal( startedExit, true, `${renderer}: normal camp exit did not start.` ); } async function waitForCampReady(page, expectedStep) { try { await page.waitForFunction( (step) => { const debug = window.__HEROS_DEBUG__; const camp = debug?.camp?.(); return ( debug?.activeScenes?.().includes('CampScene') && camp?.scene === 'CampScene' && camp?.campaignObjectiveJournal?.snapshot && camp?.campaign?.step === step ); }, expectedStep, { timeout: 90000 } ); } catch (error) { const diagnostic = await page.evaluate(() => ({ activeScenes: window.__HEROS_DEBUG__?.activeScenes?.() ?? [], camp: window.__HEROS_DEBUG__?.camp?.() ?? null, exploration: window.__HEROS_DEBUG__?.campVisitExploration?.() ?? null })); throw new Error( `${renderer}: CampScene did not become ready: ` + JSON.stringify(diagnostic), { cause: error } ); } await afterTwoFrames(page); } function exactPlayer(player) { assert( player && Number.isFinite(player.x) && Number.isFinite(player.y) && ['north', 'south', 'east', 'west'].includes( player.direction ), `${renderer}: invalid player snapshot ${JSON.stringify( player )}.` ); return { x: player.x, y: player.y, direction: player.direction }; } function assertExactPlayer(actual, expected, label) { assert.deepEqual( exactPlayer(actual), exactPlayer(expected), `${label}: exact x/y/direction changed.` ); } function assertExactCheckpointPlayer( checkpoint, expected, label ) { assert( checkpoint, `${label}: exploration checkpoint is missing.` ); assert.equal(checkpoint.version, 1, label); assert.equal( checkpoint.scene, 'camp-visit-exploration', label ); assert.equal(checkpoint.contextId, fixture.visitId, label); assert.deepEqual( checkpoint.player, expected, `${label}: checkpoint player differs.` ); } async function readCampaignSaves(page) { return page.evaluate(() => Object.fromEntries( Object.keys(window.localStorage) .filter((key) => key.startsWith('heros-web:campaign-state') ) .sort() .map((key) => [ key, window.localStorage.getItem(key) ]) ) ); } async function readCampaignStates(page) { const saves = await readCampaignSaves(page); return Object.fromEntries( Object.entries(saves).map(([key, value]) => [ key, JSON.parse(value) ]) ); } function rewardSnapshot(states) { const entries = Object.entries(states); assert( entries.length >= 2, `${renderer}: expected current and slot campaign saves.` ); return Object.fromEntries( entries.map(([key, state]) => { const bond = state.bonds.find( ({ id }) => id === fixture.bondId ); const reportBond = state.firstBattleReport?.bonds.find( ({ id }) => id === fixture.bondId ); assert( bond && reportBond, `${renderer}: ${key} is missing ${fixture.bondId}.` ); return [ key, { completedVisitCount: state.completedCampVisits.filter( (id) => id === fixture.visitId ).length, reportCompletedVisitCount: state.firstBattleReport.completedCampVisits.filter( (id) => id === fixture.visitId ).length, choiceId: state.campVisitChoiceIds[fixture.visitId] ?? null, inventory: { ...state.inventory }, bond: { level: bond.level, exp: bond.exp, battleExp: bond.battleExp }, reportBond: { level: reportBond.level, exp: reportBond.exp, battleExp: reportBond.battleExp } } ]; }) ); } function assertRewardNotYetApplied(snapshot, label) { for (const [key, state] of Object.entries(snapshot)) { assert.equal( state.completedVisitCount, 0, `${label}: ${key} already completed the visit.` ); assert.equal( state.reportCompletedVisitCount, 0, `${label}: ${key} report already completed the visit.` ); assert.equal( state.choiceId, null, `${label}: ${key} already remembers a choice.` ); } } function assertRewardAppliedExactlyOnce( before, after, label ) { assert.deepEqual( Object.keys(after), Object.keys(before), `${label}: campaign save keys changed.` ); for (const key of Object.keys(before)) { const previous = before[key]; const current = after[key]; assert.equal( current.completedVisitCount, 1, `${label}: ${key} visit completion count mismatch.` ); assert.equal( current.reportCompletedVisitCount, 1, `${label}: ${key} report completion count mismatch.` ); assert.equal( current.choiceId, fixture.choiceId, `${label}: ${key} choice mismatch.` ); assert.equal( current.inventory[fixture.itemId] ?? 0, (previous.inventory[fixture.itemId] ?? 0) + fixture.itemAmount, `${label}: ${key} inventory reward mismatch.` ); for (const [itemId, amount] of Object.entries( current.inventory )) { if (itemId !== fixture.itemId) { assert.equal( amount, previous.inventory[itemId], `${label}: ${key} changed unrelated item ${itemId}.` ); } } assert.deepEqual( current.bond, { level: previous.bond.level, exp: previous.bond.exp + fixture.bondExp, battleExp: previous.bond.battleExp + fixture.bondExp }, `${label}: ${key} campaign bond reward mismatch.` ); assert.deepEqual( current.reportBond, current.bond, `${label}: ${key} report bond diverged.` ); } } function assertCheckpointCleared(states, label) { const entries = Object.entries(states); assert( entries.length >= 2, `${label}: expected current and slot saves.` ); for (const [key, state] of entries) { assert.equal( state.activeCampVisitId ?? null, null, `${label}: ${key} retained the active visit.` ); assert.equal( state.explorationCheckpoint ?? null, null, `${label}: ${key} retained the exploration checkpoint.` ); } } async function clickSceneBounds(page, sceneKey, bounds) { const point = await page.evaluate( ({ key, requestedBounds }) => { const scene = window.__HEROS_DEBUG__?.scene(key); const canvasBounds = document.querySelector('canvas')?.getBoundingClientRect(); if (!scene || !canvasBounds) { return null; } return { x: canvasBounds.left + (requestedBounds.x + requestedBounds.width / 2) * canvasBounds.width / scene.scale.width, y: canvasBounds.top + (requestedBounds.y + requestedBounds.height / 2) * canvasBounds.height / scene.scale.height }; }, { key: sceneKey, requestedBounds: bounds } ); assert( point && Number.isFinite(point.x) && Number.isFinite(point.y), `Unable to map ${sceneKey} bounds ${JSON.stringify( bounds )}.` ); await page.mouse.click(point.x, point.y); } function assertFiniteBounds(bounds, label) { assert( bounds && Number.isFinite(bounds.x) && Number.isFinite(bounds.y) && Number.isFinite(bounds.width) && Number.isFinite(bounds.height) && bounds.width > 0 && bounds.height > 0, `${label}: invalid bounds ${JSON.stringify(bounds)}.` ); return bounds; } function assertBoundsContained(bounds, container, label) { assert( bounds.x >= container.x && bounds.y >= container.y && bounds.x + bounds.width <= container.x + container.width && bounds.y + bounds.height <= container.y + container.height, `${label}: ${JSON.stringify(bounds)} is outside ` + JSON.stringify(container) ); } async function afterTwoFrames(page) { await page.evaluate( () => new Promise((resolve) => { requestAnimationFrame(() => requestAnimationFrame(resolve) ); }) ); } async function readRuntimeProbe(page, sceneKey) { return page.evaluate((key) => { const game = window.__HEROS_GAME__; const scene = game?.scene.getScene(key); const canvas = document.querySelector('canvas'); const canvasBounds = canvas?.getBoundingClientRect(); return { viewport: { width: window.innerWidth, height: window.innerHeight, dpr: window.devicePixelRatio, zoom: window.visualViewport?.scale ?? 1 }, canvas: canvas ? { width: canvas.width, height: canvas.height, bounds: canvasBounds ? { x: canvasBounds.x, y: canvasBounds.y, width: canvasBounds.width, height: canvasBounds.height } : null } : null, renderer: { requested: new URLSearchParams( window.location.search ).get('renderer'), type: game?.renderer?.type ?? null, name: game?.renderer?.constructor?.name ?? null }, scene: scene ? { width: scene.scale.width, height: scene.scale.height } : null }; }, sceneKey); } function assertRuntimeBaseline( runtime, requestedRenderer, label ) { assert.deepEqual( runtime.viewport, { width: desktopBrowserViewport.width, height: desktopBrowserViewport.height, dpr: desktopBrowserDeviceScaleFactor, zoom: 1 }, `${label}: viewport, DPR, or browser zoom mismatch.` ); assert.deepEqual( runtime.canvas, { width: desktopBrowserViewport.width, height: desktopBrowserViewport.height, bounds: { x: 0, y: 0, width: desktopBrowserViewport.width, height: desktopBrowserViewport.height } }, `${label}: canvas did not fill the CSS viewport.` ); assert.deepEqual( runtime.scene, { width: desktopBrowserViewport.width, height: desktopBrowserViewport.height }, `${label}: Phaser scene size mismatch.` ); assert.equal( runtime.renderer.requested, requestedRenderer, `${label}: renderer query mismatch.` ); assert.equal( runtime.renderer.type, requestedRenderer === 'canvas' ? 1 : 2, `${label}: expected ${requestedRenderer}: ` + JSON.stringify(runtime.renderer) ); } async function ensureLocalServer(url) { if (await canReach(url)) { return undefined; } const parsed = new URL(url); if ( !['localhost', '127.0.0.1', '0.0.0.0'].includes( parsed.hostname ) ) { throw new Error(`No server responded at ${url}`); } const stderr = []; const child = spawn( process.execPath, [ 'node_modules/vite/bin/vite.js', '--host', '127.0.0.1', '--port', parsed.port || '41808', '--strictPort' ], { cwd: process.cwd(), env: process.env, stdio: ['ignore', 'pipe', 'pipe'], windowsHide: true } ); child.stderr.on('data', (chunk) => stderr.push(chunk.toString()) ); child.stdout.on('data', () => {}); for (let attempt = 0; attempt < 120; attempt += 1) { if (await canReach(url)) { return child; } await delay(250); } await stopServerProcess(child); throw new Error( `Vite server did not start at ${url}\n${stderr.join('')}` ); } async function canReach(url) { try { const response = await fetch(url, { signal: AbortSignal.timeout(1000) }); return response.ok; } catch { return false; } } function delay(ms) { return new Promise((resolve) => setTimeout(resolve, ms)); } async function stopServerProcess(child) { if (!child || child.exitCode !== null) { return; } const exited = new Promise((resolve) => child.once('exit', resolve) ); child.kill(); await Promise.race([exited, delay(5000)]); if (child.exitCode === null) { child.kill('SIGKILL'); await Promise.race([exited, delay(2000)]); } }