import assert from 'node:assert/strict'; import { mkdirSync } from 'node:fs'; import { chromium } from 'playwright'; import { createServer } from 'vite'; import { desktopBrowserContextOptions, desktopBrowserDeviceScaleFactor, desktopBrowserViewport } from './desktop-browser-viewport.mjs'; const explorationSceneKey = 'CampVisitExplorationScene'; const sourceBattleId = 'second-battle-yellow-turban-pursuit'; const targetBattleId = 'third-battle-guangzong-road'; const visitId = 'second-pursuit-aftermath-relief'; const firstScoutVisitId = 'first-pursuit-scout-tent'; const objectiveIds = [ 'reassure-northern-village', 'question-ferry-witness', 'inspect-messenger-trail' ]; const objectiveProgressIds = objectiveIds.map( (objectiveId) => `${visitId}:objective:${objectiveId}` ); const expectedActors = { 'guan-yu-village': { x: 1050, y: 455, textureKey: 'exploration-guan-yu' }, 'north-ferry-boatman': { x: 360, y: 470, textureKey: 'exploration-zhuo-villager' }, 'zhang-fei-ferry': { x: 540, y: 675, textureKey: 'exploration-zhang-fei' }, 'jian-yong-records': { x: 790, y: 690, textureKey: 'exploration-jian-yong' } }; const rendererFixtures = { canvas: { renderer: 'canvas', expectedRendererType: 1, firstScoutChoiceId: 'mark-village-relief', expectedScoutFocus: 'village', reliefChoiceId: 'restore-northern-village', rewardItemLabel: '콩', bondId: 'liu-bei__guan-yu', effectKind: 'village-supply-line', expectedEffectText: '다음 전투 · 유비 후방 지원 · 휴대 콩 +1' }, webgl: { renderer: 'webgl', expectedRendererType: 2, firstScoutChoiceId: 'trace-river-ambush', expectedScoutFocus: 'river', reliefChoiceId: 'trace-ferry-messenger', rewardItemLabel: '상처약', bondId: 'liu-bei__jian-yong', effectKind: 'ferry-messenger-intel', expectedEffectText: '다음 전투 · 장비 측면 추천 · 전령 마원의 첫 행동 표시' } }; const requestedRenderer = cliOption('renderer') ?? process.env.VERIFY_SECOND_BATTLE_RELIEF_RENDERER ?? 'both'; const renderers = requestedRenderer === 'both' ? ['canvas', 'webgl'] : [requestedRenderer]; renderers.forEach((renderer) => { assert( renderer in rendererFixtures, `Unknown renderer "${renderer}". Use canvas, webgl, or both.` ); }); const server = await createServer({ logLevel: 'error', server: { host: '127.0.0.1', port: 0, hmr: false }, appType: 'spa' }); let browser; try { mkdirSync('dist', { recursive: true }); await server.listen(); const address = server.httpServer?.address(); assert( address && typeof address !== 'string', 'Expected a local Vite verification server.' ); const baseUrl = `http://127.0.0.1:${address.port}/heros_web/`; browser = await chromium.launch({ headless: process.env.VERIFY_SECOND_BATTLE_RELIEF_HEADLESS !== '0' }); for (const renderer of renderers) { await verifyRenderer(browser, baseUrl, rendererFixtures[renderer]); } console.log( `Second-battle relief browser verification passed for ${renderers.join( ' + ' )} at ${desktopBrowserViewport.width}x${desktopBrowserViewport.height} CSS pixels, DPR ${desktopBrowserDeviceScaleFactor}.` ); } finally { await browser?.close(); await server.close(); } async function verifyRenderer(browser, baseUrl, fixture) { 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()); } }); try { const url = new URL(baseUrl); url.searchParams.set('debug', '1'); url.searchParams.set('renderer', fixture.renderer); await page.goto(url.toString(), { waitUntil: 'domcontentloaded', timeout: 90000 }); await waitForDebugApi(page); await assertFhdViewport(page, fixture); const seeded = await seedSecondVictoryCamp(page, fixture); assert.equal(seeded.step, 'second-camp'); assert.equal(seeded.latestBattleId, sourceBattleId); assert.equal(seeded.reportBattleId, sourceBattleId); assert.equal(seeded.settlementOutcome, 'victory'); assert.equal(seeded.firstScoutChoiceId, fixture.firstScoutChoiceId); assert.equal(seeded.reliefCompleted, false); await page.evaluate(async () => { await window.__HEROS_DEBUG__.goToCamp(); }); const campBefore = await waitForCampReliefCta(page); assert.equal(campBefore.activeTab, 'visit'); assert.equal(campBefore.campaign.step, 'second-camp'); assert.equal(campBefore.campBattleId, sourceBattleId); assert.equal( campBefore.secondBattleReliefExploration.mode, 'walkable-relief' ); assert.equal( campBefore.secondBattleReliefExploration.explorationInteractive, true ); assert( campBefore.secondBattleReliefExploration.explorationButtonBounds, 'CampScene must expose a clickable second-relief CTA.' ); await capture( page, `dist/verification-second-battle-relief-${fixture.renderer}-camp-cta.png` ); await clickSceneBounds( page, 'CampScene', campBefore.secondBattleReliefExploration.explorationButtonBounds ); await waitForExplorationReady(page); let exploration = await readExploration(page); assert.equal(exploration.locationId, visitId); assert.equal(exploration.campaignStep, 'second-camp'); assert.equal( exploration.background.key, 'second-pursuit-village-ferry-background' ); assert.equal(exploration.background.ready, true); assert.equal(exploration.background.fallback, false); assert.equal( exploration.background.sourceWidth, desktopBrowserViewport.width ); assert.equal( exploration.background.sourceHeight, desktopBrowserViewport.height ); assert.equal(exploration.requiredTexturesReady, true); assert.equal(exploration.visit.completed, false); assert.deepEqual(exploration.visit.completedObjectiveIds, []); assert.equal( exploration.visit.nextObjectiveId, objectiveIds[0] ); assert.equal(exploration.storyContext.sourceBattleId, sourceBattleId); assert.equal(exploration.storyContext.sourceBattleTurn, 18); assert.equal(exploration.storyContext.villageObjectiveAchieved, true); assert.equal(exploration.storyContext.quickObjectiveAchieved, true); assert.equal( exploration.storyContext.scoutReview.choiceId, fixture.firstScoutChoiceId ); assert.equal( exploration.storyContext.scoutReview.focus, fixture.expectedScoutFocus ); assert.equal( exploration.storyContext.scoutReview.outcome, 'confirmed' ); assert.match( exploration.storyContext.arrivalLine, /북쪽 마을|마을/, 'The exploration must carry the second-battle outcome into its arrival context.' ); assertActorsFixed(exploration, `${fixture.renderer} initial`); await capture( page, `dist/verification-second-battle-relief-${fixture.renderer}-initial.png` ); const farProgress = [...exploration.visit.completedObjectiveIds]; assert.equal(exploration.dialogue.active, false); assert.equal(exploration.interaction.canInteract, false); await page.keyboard.press('e'); await page.waitForTimeout(180); exploration = await readExploration(page); assert.equal( exploration.dialogue.active, false, 'Pressing E outside the interaction radius must not open dialogue.' ); assert.deepEqual( exploration.visit.completedObjectiveIds, farProgress, 'A remote E press must not record a relief objective.' ); const beforeKeyboard = { ...exploration.player }; await page.keyboard.down('ArrowLeft'); try { await page.waitForFunction( ({ startX }) => window.__HEROS_DEBUG__ ?.campVisitExploration?.() ?.player?.x <= startX - 35, { startX: beforeKeyboard.x }, { timeout: 5000 } ); } finally { await page.keyboard.up('ArrowLeft'); } exploration = await readExploration(page); assert( exploration.player.x < beforeKeyboard.x - 30, 'Arrow-key movement must move Liu Bei in the world.' ); const beforePointer = { ...exploration.player }; const pointerTarget = { x: Math.min(1320, beforePointer.x + 120), y: Math.max(700, beforePointer.y - 45) }; await clickScenePoint( page, explorationSceneKey, pointerTarget.x, pointerTarget.y ); await page.waitForFunction( ({ startX, startY }) => { const state = window.__HEROS_DEBUG__?.campVisitExploration?.(); return ( state?.movement?.target !== null && Math.hypot( state.player.x - startX, state.player.y - startY ) >= 8 ); }, { startX: beforePointer.x, startY: beforePointer.y }, { timeout: 5000 } ); await page.waitForFunction( () => window.__HEROS_DEBUG__?.campVisitExploration?.()?.movement ?.target === null, undefined, { timeout: 8000 } ); exploration = await readExploration(page); assert( Math.hypot( exploration.player.x - beforePointer.x, exploration.player.y - beforePointer.y ) >= 40, 'A world pointer click must move Liu Bei toward the selected ground.' ); assertActorsFixed(exploration, `${fixture.renderer} after movement`); const earlyChoice = await page.evaluate( ({ key, choiceId }) => window.__HEROS_DEBUG__ ?.scene(key) ?.debugChooseVisitChoice?.(choiceId) ?? null, { key: explorationSceneKey, choiceId: fixture.reliefChoiceId } ); assert.equal( earlyChoice, false, 'The final relief choice must remain locked before all field objectives.' ); await interactWithActor(page, 'north-ferry-boatman'); exploration = await readExploration(page); assert.equal(exploration.dialogue.sourceNpcId, 'north-ferry-boatman'); assert.equal(exploration.dialogue.speaker, '현장 기록'); await advanceDialogueUntilClosed(page); exploration = await readExploration(page); assert.deepEqual( exploration.visit.completedObjectiveIds, [], 'Interacting with the ferry witness before Guan Yu must remain sequence-locked.' ); await interactWithActor(page, 'guan-yu-village'); exploration = await readExploration(page); assert.equal(exploration.dialogue.sourceNpcId, 'guan-yu-village'); assert( exploration.dialogue.totalLines >= 6, 'The first objective dialogue must include intro, battle-result context, scout review, and authored actor lines.' ); assert.equal(exploration.dialogue.portraitVisible, true); assert.equal(exploration.dialogue.portraitFrameVisible, true); await capture( page, `dist/verification-second-battle-relief-${fixture.renderer}-intro-dialogue.png` ); await advanceDialogueUntilClosed(page); await waitForObjectiveProgress(page, [objectiveIds[0]]); const partialCampaign = await readCampaign(page); assert.equal( partialCampaign.completedCampVisits.filter( (candidate) => candidate === objectiveProgressIds[0] ).length, 1, 'The first relief objective must be persisted exactly once before reload.' ); await capture( page, `dist/verification-second-battle-relief-${fixture.renderer}-partial.png` ); await page.reload({ waitUntil: 'domcontentloaded', timeout: 90000 }); await waitForDebugApi(page); await assertFhdViewport(page, fixture); await page.evaluate(async (requestedVisitId) => { await window.__HEROS_DEBUG__.goToCampVisitExploration( requestedVisitId ); }, visitId); await waitForExplorationReady(page); exploration = await readExploration(page); assert.deepEqual( exploration.visit.completedObjectiveIds, [objectiveIds[0]] ); assert.equal(exploration.visit.nextObjectiveId, objectiveIds[1]); assertActorsFixed(exploration, `${fixture.renderer} resumed`); await capture( page, `dist/verification-second-battle-relief-${fixture.renderer}-resumed.png` ); await completeActorObjective( page, 'north-ferry-boatman', objectiveIds.slice(0, 2) ); await completeActorObjective( page, 'zhang-fei-ferry', objectiveIds ); exploration = await readExploration(page); assert.equal(exploration.visit.choiceReady, true); assert.equal( exploration.visit.nextObjectiveId, 'set-guangzong-priority' ); assertActorsFixed(exploration, `${fixture.renderer} objectives complete`); await interactWithActor(page, 'jian-yong-records'); await advanceDialogueUntilChoice(page); await page.waitForFunction( () => window.__HEROS_DEBUG__?.campVisitExploration?.()?.choice ?.ready === true ); exploration = await readExploration(page); assert.equal(exploration.choice.open, true); assert.deepEqual( exploration.choice.choices.map(({ id }) => id), ['restore-northern-village', 'trace-ferry-messenger'] ); const selectedChoice = exploration.choice.choices.find( (choice) => choice.id === fixture.reliefChoiceId ); assert(selectedChoice?.interactive); assert.match(selectedChoice.rewardText, /공명 \+10/); assert.equal( selectedChoice.effectText, fixture.expectedEffectText, 'The selected relief card must state its exact next-battle effect.' ); await capture( page, `dist/verification-second-battle-relief-${fixture.renderer}-choice.png` ); const beforeReward = await readCampaign(page); const bondBefore = campaignBond(beforeReward, fixture.bondId); assert(bondBefore, `Missing seeded bond ${fixture.bondId}.`); await clickSceneBounds( page, explorationSceneKey, selectedChoice.bounds ); await page.waitForFunction( (requestedVisitId) => { const state = window.__HEROS_DEBUG__?.campVisitExploration?.(); return ( state?.visit?.id === requestedVisitId && state.visit.completed === true && state.dialogue.active === true ); }, visitId ); exploration = await readExploration(page); assert.equal(exploration.visit.choiceId, fixture.reliefChoiceId); assert.equal(exploration.dialogue.sourceNpcId, 'jian-yong-records'); assertActorsFixed(exploration, `${fixture.renderer} completed`); const afterReward = await readCampaign(page); assertRewardAppliedOnce(beforeReward, afterReward, bondBefore, fixture); await capture( page, `dist/verification-second-battle-relief-${fixture.renderer}-completed.png` ); const relevantAfterReward = relevantProgress(afterReward, fixture); const duplicateResult = await page.evaluate( ({ key, choiceId }) => window.__HEROS_DEBUG__ ?.scene(key) ?.debugChooseVisitChoice?.(choiceId) ?? null, { key: explorationSceneKey, choiceId: fixture.reliefChoiceId } ); assert.equal( duplicateResult, false, 'A completed relief visit must reject duplicate choice rewards.' ); assert.deepEqual( relevantProgress(await readCampaign(page), fixture), relevantAfterReward, 'A duplicate completion attempt must not change rewards or memory.' ); await advanceDialogueUntilClosed(page); const teleportedToExit = await page.evaluate((key) => window.__HEROS_DEBUG__ ?.scene(key) ?.debugTeleportTo?.('camp-exit') ?? false , explorationSceneKey); assert.equal(teleportedToExit, true); await page.keyboard.press('e'); const returnedCamp = await waitForCampReturn(page); assert.equal(returnedCamp.campaign.step, 'second-camp'); assert.equal( returnedCamp.secondBattleReliefExploration.completed, true ); assert.equal( returnedCamp.secondBattleReliefExploration.choiceId, fixture.reliefChoiceId ); assert.equal( returnedCamp.campaign.completedCampVisits.filter( (candidate) => candidate === visitId ).length, 1 ); await capture( page, `dist/verification-second-battle-relief-${fixture.renderer}-camp-return.png` ); await page.evaluate(async (battleId) => { await window.__HEROS_DEBUG__.goToBattle(battleId); }, targetBattleId); const battle = await waitForThirdBattleDeployment(page); const memory = battle.secondBattleReliefMemory; assert.equal(memory.active, true); assert.equal(memory.sourceBattleId, sourceBattleId); assert.equal(memory.targetBattleId, targetBattleId); assert.equal(memory.choiceId, fixture.reliefChoiceId); assert.equal(memory.effectKind, fixture.effectKind); assert.equal(memory.readiness, 'reinforced'); assert.equal(memory.sourceObjectiveAchieved, true); assert.equal(memory.openingLines.length, 3); assert.match( battle.battleHud?.deploymentPanel?.subtitleText ?? '', /현장 수습/, 'The deployment panel must surface the relief memory.' ); if (fixture.effectKind === 'village-supply-line') { assert.equal(memory.villageSupply.active, true); assert.equal(memory.villageSupply.carrierUnitId, 'liu-bei'); assert.equal(memory.villageSupply.recommendedRole, 'support'); assert.equal(memory.villageSupply.itemId, 'bean'); assert.equal(memory.villageSupply.carriedCount, 1); assert.equal( memory.villageSupply.battleStock, battle.itemStocks['liu-bei'].bean ); assert.equal( memory.villageSupply.battleStock, 3, 'The village choice must add one carried bean to Liu Bei’s normal stock.' ); assert.equal(battle.enemyIntentPreviews.length, 0); } else { assert.equal(memory.ferryIntel.active, true); assert.equal(memory.ferryIntel.recommendedUnitId, 'zhang-fei'); assert.equal(memory.ferryIntel.recommendedRole, 'flank'); assert.equal( memory.ferryIntel.trackedEnemyUnitId, 'guangzong-leader-ma-yuan' ); assert.equal(memory.ferryIntel.revealTrackedRoute, true); assert.equal( memory.ferryIntel.forecastVisibleDuringDeployment, true ); assert.equal( battle.enemyIntentPreviews.length, 1, 'Ferry intelligence must reveal exactly the tracked messenger forecast during deployment.' ); assert.equal( battle.enemyIntentPreviews[0].enemyId, 'guangzong-leader-ma-yuan' ); assert.equal( memory.ferryIntel.trackedIntent.enemyId, 'guangzong-leader-ma-yuan' ); } await capture( page, `dist/verification-second-battle-relief-${fixture.renderer}-third-battle-deployment.png` ); await page.evaluate(() => window.__HEROS_GAME__?.scene .getScene('BattleScene') ?.confirmPreBattleDeployment?.() ); await page.waitForFunction( () => { const state = window.__HEROS_DEBUG__?.battle?.(); return ( state?.battleId === 'third-battle-guangzong-road' && state.phase === 'idle' && state.activeBattleEvent?.key === 'opening' ); }, undefined, { timeout: 90000 } ); const openedBattle = await page.evaluate(() => window.__HEROS_DEBUG__?.battle?.() ); const openingEventLines = openedBattle.activeBattleEvent?.lines ?? []; memory.openingLines.forEach((line) => { assert( openingEventLines.includes(line), `The opening event must display remembered relief line: ${line}; state=${JSON.stringify({ activeBattleEvent: openedBattle.activeBattleEvent, pendingBattleEvents: openedBattle.pendingBattleEvents, triggeredBattleEvents: openedBattle.triggeredBattleEvents })}` ); }); await capture( page, `dist/verification-second-battle-relief-${fixture.renderer}-third-battle-opening.png` ); assert.deepEqual( pageErrors, [], `${fixture.renderer}: browser page errors: ${JSON.stringify( pageErrors )}` ); assert.deepEqual( consoleErrors.filter( (message) => !message.includes('The AudioContext was not allowed to start') && !message.includes('WebGL') ), [], `${fixture.renderer}: browser console errors: ${JSON.stringify( consoleErrors )}` ); } finally { await context.close(); } } async function seedSecondVictoryCamp(page, fixture) { return page.evaluate( async ({ battleId, scoutVisitId, scoutChoiceId, requestedVisitId }) => { 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[battleId]; if (!scenario) { throw new Error(`Missing battle scenario ${battleId}.`); } campaignModule.resetCampaignState(); campaignModule.setFirstBattleReport({ battleId: scenario.id, battleTitle: scenario.title, outcome: 'victory', turnNumber: 18, rewardGold: scenario.baseVictoryGold, defeatedEnemies: scenario.units.filter( (unit) => unit.faction === 'enemy' ).length, totalEnemies: scenario.units.filter( (unit) => unit.faction === 'enemy' ).length, objectives: scenario.objectives.map((objective) => ({ id: objective.id, label: objective.label, achieved: true, status: 'done', detail: '달성', rewardGold: objective.rewardGold })), units: scenario.units, bonds: scenario.bonds.map((bond) => ({ ...bond, battleExp: 0 })), itemRewards: [], completedCampDialogues: [], completedCampVisits: [], createdAt: '2026-07-27T00:00:00.000Z' }); let campaign = campaignModule.getCampaignState(); campaign.completedCampVisits = [ ...new Set([ ...campaign.completedCampVisits, scoutVisitId ]) ]; campaign.campVisitChoiceIds = { ...campaign.campVisitChoiceIds, [scoutVisitId]: scoutChoiceId }; if (campaign.firstBattleReport) { campaign.firstBattleReport.completedCampVisits = [ ...new Set([ ...campaign.firstBattleReport.completedCampVisits, scoutVisitId ]) ]; } campaign.dismissedVictoryRewardNoticeBattleIds = [ ...new Set([ ...campaign.dismissedVictoryRewardNoticeBattleIds, battleId ]) ]; campaignModule.setCampaignState(campaign); campaignModule.completeCampaignAftermath(battleId); campaign = campaignModule.getCampaignState(); campaignModule.saveCampaignState(campaign, 1); return { step: campaign.step, latestBattleId: campaign.latestBattleId ?? null, reportBattleId: campaign.firstBattleReport?.battleId ?? null, settlementOutcome: campaign.battleHistory[battleId]?.outcome ?? null, firstScoutChoiceId: campaign.campVisitChoiceIds[scoutVisitId] ?? null, reliefCompleted: campaign.completedCampVisits.includes(requestedVisitId) }; }, { battleId: sourceBattleId, scoutVisitId: firstScoutVisitId, scoutChoiceId: fixture.firstScoutChoiceId, requestedVisitId: visitId } ); } async function waitForDebugApi(page) { await page.waitForFunction( () => document.querySelector('canvas') !== null && window.__HEROS_GAME__ !== undefined && window.__HEROS_DEBUG__ !== undefined, undefined, { timeout: 90000 } ); } async function assertFhdViewport(page, fixture) { const state = await page.evaluate(() => ({ css: { width: window.innerWidth, height: window.innerHeight, dpr: window.devicePixelRatio, zoom: window.visualViewport?.scale ?? 1 }, rendererType: window.__HEROS_GAME__?.renderer?.type ?? null })); assert.deepEqual(state.css, { width: desktopBrowserViewport.width, height: desktopBrowserViewport.height, dpr: desktopBrowserDeviceScaleFactor, zoom: 1 }); assert.equal( state.rendererType, fixture.expectedRendererType, `${fixture.renderer}: Phaser must use the requested renderer.` ); } async function waitForCampReliefCta(page) { await page.waitForFunction( (requestedVisitId) => { const debug = window.__HEROS_DEBUG__; const camp = debug?.camp?.(); return ( debug?.activeScenes?.().includes('CampScene') && camp?.scene === 'CampScene' && camp?.campaign?.step === 'second-camp' && camp?.secondBattleReliefExploration?.visitId === requestedVisitId && camp.secondBattleReliefExploration.available === true && camp.secondBattleReliefExploration.explorationInteractive === true ); }, visitId, { timeout: 90000 } ); return page.evaluate(() => window.__HEROS_DEBUG__?.camp?.()); } async function waitForExplorationReady(page) { await page.waitForFunction( ({ key, requestedVisitId }) => { const debug = window.__HEROS_DEBUG__; const state = debug?.campVisitExploration?.(); return ( debug?.activeScenes?.().includes(key) && state?.scene === key && state.locationId === requestedVisitId && state.ready === true ); }, { key: explorationSceneKey, requestedVisitId: visitId }, { timeout: 90000 } ); } async function waitForCampReturn(page) { await page.waitForFunction( () => { const debug = window.__HEROS_DEBUG__; const camp = debug?.camp?.(); return ( debug?.activeScenes?.().includes('CampScene') && !debug ?.activeScenes?.() .includes('CampVisitExplorationScene') && camp?.scene === 'CampScene' && camp?.campaign?.step === 'second-camp' ); }, undefined, { timeout: 90000 } ); return page.evaluate(() => window.__HEROS_DEBUG__?.camp?.()); } async function waitForThirdBattleDeployment(page) { await page.waitForFunction( (battleId) => { const state = window.__HEROS_DEBUG__?.battle?.(); const assetsReady = state?.combatAssets?.status === 'ready' || state?.combatAssets?.status === 'degraded'; return ( state?.scene === 'BattleScene' && state.battleId === battleId && state.phase === 'deployment' && assetsReady ); }, targetBattleId, { timeout: 90000 } ); return page.evaluate(() => window.__HEROS_DEBUG__?.battle?.()); } async function readExploration(page) { return page.evaluate(() => window.__HEROS_DEBUG__?.campVisitExploration?.() ); } async function readCampaign(page) { return page.evaluate(async () => { const campaignModule = await import( '/heros_web/src/game/state/campaignState.ts' ); return campaignModule.getCampaignState(); }); } async function interactWithActor(page, actorId) { const teleported = await page.evaluate( ({ key, targetId }) => window.__HEROS_DEBUG__ ?.scene(key) ?.debugTeleportTo?.(targetId) ?? false, { key: explorationSceneKey, targetId: actorId } ); assert.equal( teleported, true, `Unable to approach relief actor ${actorId}.` ); await page.waitForTimeout(80); await page.keyboard.press('e'); await page.waitForFunction( ({ targetId }) => window.__HEROS_DEBUG__?.campVisitExploration?.()?.dialogue ?.sourceNpcId === targetId, { targetId: actorId }, { timeout: 5000 } ); } async function completeActorObjective( page, actorId, expectedCompletedObjectiveIds ) { await interactWithActor(page, actorId); await advanceDialogueUntilClosed(page); await waitForObjectiveProgress( page, expectedCompletedObjectiveIds ); await page.waitForTimeout(360); } async function waitForObjectiveProgress(page, expectedIds) { await page.waitForFunction( (requestedIds) => { const actual = window.__HEROS_DEBUG__?.campVisitExploration?.()?.visit ?.completedObjectiveIds ?? []; return JSON.stringify(actual) === JSON.stringify(requestedIds); }, expectedIds, { timeout: 5000 } ); } async function advanceDialogueUntilClosed(page) { for (let attempt = 0; attempt < 16; attempt += 1) { const state = await readExploration(page); if (!state?.dialogue?.active) { return; } await page.keyboard.press('e'); await page.waitForTimeout(150); } throw new Error( `Dialogue did not close: ${JSON.stringify( await readExploration(page) )}` ); } async function advanceDialogueUntilChoice(page) { for (let attempt = 0; attempt < 12; attempt += 1) { const state = await readExploration(page); if (state?.choice?.open) { return; } assert.equal( state?.dialogue?.active, true, `Jian Yong dialogue closed without choices: ${JSON.stringify( state )}` ); await page.keyboard.press('e'); await page.waitForTimeout(150); } throw new Error( `Relief choice did not open: ${JSON.stringify( await readExploration(page) )}` ); } async function clickScenePoint(page, sceneKey, sceneX, sceneY) { const point = await page.evaluate( ({ key, x, y }) => { const scene = window.__HEROS_DEBUG__?.scene(key); const canvas = document.querySelector('canvas'); const bounds = canvas?.getBoundingClientRect(); if (!scene || !bounds) { return null; } return { x: bounds.left + (x * bounds.width) / scene.scale.width, y: bounds.top + (y * bounds.height) / scene.scale.height }; }, { key: sceneKey, x: sceneX, y: sceneY } ); assert( point && Number.isFinite(point.x) && Number.isFinite(point.y), `Unable to map ${sceneKey} point ${JSON.stringify({ x: sceneX, y: sceneY })}.` ); await page.mouse.click(point.x, point.y); } async function clickSceneBounds(page, sceneKey, bounds) { assert(bounds, `${sceneKey} clickable bounds are missing.`); await clickScenePoint( page, sceneKey, bounds.x + bounds.width / 2, bounds.y + bounds.height / 2 ); } function assertActorsFixed(exploration, label) { assert.equal( exploration.actors.length, Object.keys(expectedActors).length, `${label}: unexpected actor count.` ); Object.entries(expectedActors).forEach(([actorId, expected]) => { const actor = exploration.actors.find( (candidate) => candidate.id === actorId ); assert(actor, `${label}: missing actor ${actorId}.`); assert.deepEqual( { x: actor.x, y: actor.y, initialX: actor.initialX, initialY: actor.initialY, moved: actor.moved, textureKey: actor.textureKey }, { x: expected.x, y: expected.y, initialX: expected.x, initialY: expected.y, moved: false, textureKey: expected.textureKey }, `${label}: ${actorId} must stay at its authored position.` ); }); } function assertRewardAppliedOnce( before, after, bondBefore, fixture ) { assert.equal( after.completedCampVisits.filter( (candidate) => candidate === visitId ).length, 1 ); assert.equal( after.firstBattleReport.completedCampVisits.filter( (candidate) => candidate === visitId ).length, 1 ); assert.equal( after.campVisitChoiceIds[visitId], fixture.reliefChoiceId ); assert.equal( after.inventory[fixture.rewardItemLabel] ?? 0, (before.inventory[fixture.rewardItemLabel] ?? 0) + 1 ); objectiveProgressIds.forEach((progressId) => { assert.equal( after.completedCampVisits.filter( (candidate) => candidate === progressId ).length, 1 ); }); const bondAfter = campaignBond(after, fixture.bondId); assert(bondAfter, `Missing rewarded bond ${fixture.bondId}.`); assert.equal( bondProgress(bondAfter) - bondProgress(bondBefore), 10 ); } function relevantProgress(campaign, fixture) { const bond = campaignBond(campaign, fixture.bondId); return { visitFlags: campaign.completedCampVisits.filter( (candidate) => candidate === visitId || objectiveProgressIds.includes(candidate) ), reportVisitFlags: campaign.firstBattleReport?.completedCampVisits?.filter( (candidate) => candidate === visitId || objectiveProgressIds.includes(candidate) ) ?? [], choiceId: campaign.campVisitChoiceIds[visitId] ?? null, rewardAmount: campaign.inventory[fixture.rewardItemLabel] ?? 0, bond: bond ? { level: bond.level, exp: bond.exp, battleExp: bond.battleExp } : null }; } function campaignBond(campaign, bondId) { return campaign?.bonds?.find((bond) => bond.id === bondId); } function bondProgress(bond) { return bond.level * 100 + bond.exp; } async function capture(page, path) { await page.waitForTimeout(180); await page.screenshot({ path, fullPage: false }); } function cliOption(name) { const prefix = `--${name}=`; return process.argv .slice(2) .find((argument) => argument.startsWith(prefix)) ?.slice(prefix.length); }