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 sourceBattleId = 'third-battle-guangzong-road'; const targetBattleId = 'fourth-battle-guangzong-camp'; const selectionRecordId = 'third-camp-preparation-priority'; const trackedEnemyUnitId = 'guangzong-main-vanguard-a'; const priorityDialogueId = 'guan-zhang-after-guangzong-road'; const priorityDialogueBondId = 'guan-yu__zhang-fei'; const explorationVisitId = 'third-guangzong-sortie-camp'; const explorationActivityMarkerPrefix = `${explorationVisitId}:activity:`; const activeThroughTurn = 3; const priorityFixtures = [ { id: 'information', label: '본영 정보', effectKind: 'marked-vanguard-intel', configuredValue: 10 }, { id: 'equipment', label: '선봉 방호', effectKind: 'prepared-equipment-guard', configuredValue: 8 }, { id: 'companion', label: '동료 공명', effectKind: 'return-bond-opening', configuredValue: 5 } ]; const rendererFixtures = { canvas: { renderer: 'canvas', expectedRendererType: 1 }, webgl: { renderer: 'webgl', expectedRendererType: 2 } }; const requestedRenderer = cliOption('renderer') ?? process.env.VERIFY_THIRD_CAMP_PREPARATION_RENDERER ?? 'both'; assert( ['canvas', 'webgl', 'both'].includes(requestedRenderer), `Unknown renderer "${requestedRenderer}". Use canvas, webgl, or both.` ); const renderers = requestedRenderer === 'both' ? ['canvas', 'webgl'] : [requestedRenderer]; 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_THIRD_CAMP_PREPARATION_HEADLESS !== '0' }); for (const renderer of renderers) { await verifyRenderer( browser, baseUrl, rendererFixtures[renderer] ); } console.log( `Third-camp preparation browser verification passed for ${renderers.join( ' + ' )} at ${desktopBrowserViewport.width}x${desktopBrowserViewport.height} CSS pixels, DPR ${desktopBrowserDeviceScaleFactor}: camp activity routing and persistence, all three battle mechanics, normalized runtime saves, result memory, and turn-4 expiry.` ); } finally { await browser?.close(); await server.close(); } async function verifyRenderer(browser, baseUrl, rendererFixture) { 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', rendererFixture.renderer); await page.goto(url.toString(), { waitUntil: 'domcontentloaded', timeout: 90000 }); await waitForDebugApi(page); await assertFhdViewport(page, rendererFixture); await verifyCampPreparationUi( page, rendererFixture.renderer ); await assertFhdViewport(page, rendererFixture); for (const priority of priorityFixtures) { await verifyBattlePriority( page, rendererFixture, priority ); } await verifyCompanionFormationMismatch( page, rendererFixture.renderer ); assert.deepEqual( pageErrors, [], `${rendererFixture.renderer}: expected no browser page errors: ${JSON.stringify( pageErrors.slice(-8) )}` ); assert.deepEqual( consoleErrors.filter( (message) => !message.includes( 'The AudioContext was not allowed to start' ) ), [], `${rendererFixture.renderer}: expected no browser console errors: ${JSON.stringify( consoleErrors.slice(-8) )}` ); } finally { await context.close(); } } async function verifyCampPreparationUi(page, renderer) { // Let the initial no-save title/story transition finish before replacing // the campaign fixture, otherwise its already queued scene start can race // the first exploration round trip. await page.waitForTimeout(1500); const seeded = await seedThirdVictory(page, { acknowledgeActivities: false, selectPriorityId: null }); assert.equal(seeded.step, 'third-camp'); assert.equal(seeded.latestBattleId, sourceBattleId); assert.equal(seeded.selection, null); assert.deepEqual(seeded.acknowledgedCategories, []); assert.equal(seeded.priorityDialogueCompleted, false); await page.evaluate(async () => { await window.__HEROS_DEBUG__.goToCamp(); }); await waitForCamp(page); await page.evaluate(() => { const scene = window.__HEROS_GAME__?.scene.getScene('CampScene'); if (typeof scene?.showSortiePrep !== 'function') { throw new Error( 'CampScene.showSortiePrep is unavailable.' ); } scene.showSortiePrep(); }); let camp = await waitForPreparationToggle(page); assert.equal(camp.sortieVisible, true); for (const priority of priorityFixtures) { assert.equal( preparationPriority(camp, priority.id).selectable, false, `Opening the sortie briefing must not falsely complete ${priority.id}.` ); } assert.equal(camp.thirdCampExploration.available, true); assert.equal(camp.thirdCampExploration.entered, false); assert.equal(camp.thirdCampExploration.mode, 'explore'); assert.deepEqual( camp.thirdCampExploration.completedActivityIds, [] ); assertFiniteBounds( camp.thirdCampPreparation.toggleButtonBounds, 'camp preparation toggle' ); assertBoundsInsideViewport( camp.thirdCampPreparation.toggleButtonBounds, 'camp preparation toggle' ); camp = await openPreparationBrowser(page); assertPreparationBrowserLayout(camp, 'initial browser'); await capture( page, `dist/verification-third-camp-exploration-${renderer}-locked.png` ); const equipmentLocked = preparationPriority(camp, 'equipment'); assert.equal(equipmentLocked.selectable, false); await clickSceneBounds( page, 'CampScene', equipmentLocked.actionButtonBounds ); let exploration = await waitForThirdCampExploration(page); assert.equal(exploration.locationId, explorationVisitId); assert.equal(exploration.viewport.width, 1920); assert.equal(exploration.viewport.height, 1080); assert.equal( exploration.background.key, 'third-guangzong-sortie-camp-background' ); assert.equal(exploration.background.ready, true); assert.equal(exploration.background.fallback, false); assert.equal(exploration.background.sourceWidth, 1920); assert.equal(exploration.background.sourceHeight, 1080); assert.equal(exploration.requiredTexturesReady, true); assert.deepEqual( exploration.visit.completedActivityIds, [] ); assert.equal( exploration.visit.hudActivityTitle, '자유 준비 0 / 3' ); assert.equal( exploration.visit.hudPriorityLocation, '아직 정하지 않음' ); assert.equal(exploration.visit.selectedPriorityId, null); assert.equal(exploration.storyContext.sourceBattleId, sourceBattleId); assert.equal(exploration.storyContext.targetBattleId, targetBattleId); assert.equal( exploration.storyContext.companionDialogueId, priorityDialogueId ); assert.equal( exploration.storyContext.companionBondId, priorityDialogueBondId ); assert.deepEqual( exploration.actors.map(({ id }) => id).sort(), [ 'companion-guan-yu', 'companion-zhang-fei', 'quartermaster-equipment', 'zou-jing-operations' ] ); assert( exploration.actors.every(({ moved }) => moved === false), 'Third-camp NPCs must begin at stable authored positions.' ); assert.equal(exploration.dialogue.active, true); assert.equal(exploration.dialogue.portraitVisible, true); await advanceExplorationDialogue(page); exploration = await readThirdCampExploration(page); assert.equal(exploration.dialogue.active, false); await capture( page, `dist/verification-third-camp-exploration-${renderer}-arrival.png` ); const exitStarted = await page.evaluate(() => { const scene = window.__HEROS_GAME__?.scene.getScene( 'CampVisitExplorationScene' ); return scene?.debugInteractWith?.('camp-exit') ?? false; }); assert.equal( exitStarted, true, 'The camp exit must remain usable before any optional activity.' ); await page.waitForFunction( () => window.__HEROS_DEBUG__?.campVisitExploration?.() ?.dialogue?.active === true ); await advanceExplorationDialogue(page); try { await waitForCamp(page, 15000); } catch (error) { const state = await page.evaluate(() => ({ exploration: window.__HEROS_DEBUG__?.campVisitExploration?.(), camp: window.__HEROS_DEBUG__?.camp?.(), activeScenes: window.__HEROS_GAME__?.scene .getScenes(true) .map((scene) => scene.scene.key) ?? [] })); throw new Error( `Optional third-camp exit did not return to Camp: ${JSON.stringify( state )}`, { cause: error } ); } await page.evaluate(() => { const scene = window.__HEROS_GAME__?.scene.getScene('CampScene'); scene?.showSortiePrep?.(); }); camp = await waitForPreparationToggle(page); const optionalChecklist = camp.sortieChecklist.find( ({ label }) => label === '출진 우선 준비' ); assert.equal(optionalChecklist?.complete, true); assert( optionalChecklist?.detail.includes('보너스 없이 출진 가능'), 'The sortie checklist must explicitly allow sortie without a preparation bonus.' ); assert.equal(camp.thirdCampPreparation.selectedPriorityId, null); exploration = await openThirdCampExplorationFromCamp(page); assert.equal( exploration.dialogue.active, false, 'The introduction must not repeat after the first recorded entry.' ); exploration = await completeThirdCampNpcActivity( page, 'quartermaster-equipment', 'equipment' ); assert.equal(exploration.visit.completedActivityCount, 1); assert.equal(exploration.visit.selectedPriorityId, null); assert.equal(exploration.visit.selectionConfirmed, false); assert.equal( exploration.visit.hudActivityTitle, '자유 준비 1 / 3' ); assert.equal( exploration.visit.hudPriorityLocation, '아직 정하지 않음' ); assertThirdCampWorldFeedback(exploration, ['equipment']); assert( exploration.campaign.completedCampVisits.includes( `${explorationActivityMarkerPrefix}equipment` ) ); const partialExit = await page.evaluate(() => { const scene = window.__HEROS_GAME__?.scene.getScene( 'CampVisitExplorationScene' ); return scene?.debugInteractWith?.('camp-exit') ?? false; }); assert.equal(partialExit, true); await page.waitForFunction( () => window.__HEROS_DEBUG__?.campVisitExploration?.() ?.dialogue?.active === true ); await advanceExplorationDialogue(page); await waitForCamp(page); await page.reload({ waitUntil: 'domcontentloaded', timeout: 90000 }); await waitForDebugApi(page); await page.waitForTimeout(1500); await page.evaluate(async () => { const game = window.__HEROS_GAME__; for (const scene of game?.scene.getScenes(true) ?? []) { game.scene.stop(scene.scene.key); } await window.__HEROS_DEBUG__.goToCamp(); }); await waitForCamp(page); exploration = await openThirdCampExplorationFromCamp(page); assert.deepEqual( exploration.visit.completedActivityIds, ['equipment'], 'A partial activity must survive a Camp round trip and page reload.' ); assert.equal(exploration.visit.selectedPriorityId, null); assertThirdCampWorldFeedback(exploration, ['equipment']); exploration = await completeThirdCampNpcActivity( page, 'zou-jing-operations', 'information' ); assert.deepEqual( [...exploration.visit.completedActivityIds].sort(), ['equipment', 'information'] ); assert.equal(exploration.visit.selectedPriorityId, null); assertThirdCampWorldFeedback( exploration, ['equipment', 'information'] ); const companionActor = exploration.actors.find( ({ activityId }) => activityId === 'companion' ); assert(companionActor, 'Expected a result-specific companion actor.'); const bondExpBefore = await readCampaignBondExp( page, exploration.storyContext.companionBondId ); const companionStarted = await page.evaluate((actorId) => { const scene = window.__HEROS_GAME__?.scene.getScene( 'CampVisitExplorationScene' ); return scene?.debugInteractWith?.(actorId) ?? false; }, companionActor.id); assert.equal(companionStarted, true); await advanceExplorationDialogue(page); exploration = await readThirdCampExploration(page); assert.equal(exploration.choice.open, true); assert.equal(exploration.choice.mode, 'third-companion'); assert.equal(exploration.choice.sourceNpcId, companionActor.id); assert(exploration.choice.choices.length > 0); await page.waitForFunction( () => window.__HEROS_DEBUG__?.campVisitExploration?.() ?.choice?.ready === true ); exploration = await readThirdCampExploration(page); const companionChoice = exploration.choice.choices[0]; assertFiniteBounds( companionChoice.bounds, 'third-camp companion choice' ); assertBoundsInsideViewport( companionChoice.bounds, 'third-camp companion choice' ); await clickSceneBounds( page, 'CampVisitExplorationScene', companionChoice.bounds ); await page.waitForFunction( () => { const state = window.__HEROS_DEBUG__?.campVisitExploration?.(); return ( state?.visit?.completedActivityCount === 3 ); } ); await advanceExplorationDialogue(page); exploration = await readThirdCampExploration(page); assert.deepEqual( [...exploration.visit.completedActivityIds].sort(), ['companion', 'equipment', 'information'] ); assert.equal( exploration.visit.hudActivityTitle, '자유 준비 3 / 3' ); assert.equal( exploration.visit.hudPriorityLocation, '아직 정하지 않음' ); assertThirdCampWorldFeedback( exploration, ['companion', 'equipment', 'information'] ); assert( exploration.actors.every(({ moved }) => moved === false), 'Talking to a companion must not teleport any camp actor.' ); const bondExpAfter = await readCampaignBondExp( page, exploration.storyContext.companionBondId ); assert( bondExpAfter > bondExpBefore, 'The first companion choice must award its bond experience.' ); await page.waitForFunction( () => { const state = window.__HEROS_DEBUG__?.campVisitExploration?.(); return ( state?.dialogue?.active === false && state?.choice?.open === false && state?.navigationPending === false ); } ); const companionRepeatStarted = await page.evaluate((actorId) => { const scene = window.__HEROS_GAME__?.scene.getScene( 'CampVisitExplorationScene' ); return scene?.debugInteractWith?.(actorId) ?? false; }, companionActor.id); assert.equal(companionRepeatStarted, true); await advanceExplorationDialogue(page); const bondExpAfterRepeat = await readCampaignBondExp( page, exploration.storyContext.companionBondId ); assert.equal( bondExpAfterRepeat, bondExpAfter, 'Repeating the companion activity must not duplicate bond rewards.' ); await capture( page, `dist/verification-third-camp-exploration-${renderer}-complete.png` ); const completedExit = await page.evaluate(() => { const scene = window.__HEROS_GAME__?.scene.getScene( 'CampVisitExplorationScene' ); return scene?.debugInteractWith?.('camp-exit') ?? false; }); assert.equal(completedExit, true); await page.waitForFunction( () => window.__HEROS_DEBUG__?.campVisitExploration?.() ?.dialogue?.active === true ); await advanceExplorationDialogue(page); await waitForCamp(page); await page.evaluate(() => { const scene = window.__HEROS_GAME__?.scene.getScene('CampScene'); scene?.showSortiePrep?.(); }); camp = await waitForPreparationToggle(page); camp = await openPreparationBrowser(page); assertPreparationBrowserLayout(camp, 'all exploration activities'); for (const priority of priorityFixtures) { const availability = preparationPriority(camp, priority.id); assert.equal(availability.selectable, true); assert.equal( availability.evidenceKind, 'exploration-activity', `${priority.id} must be unlocked by its explicit walkable-camp activity marker.` ); assert.equal( availability.evidence.visitId, explorationVisitId ); assert.equal( availability.evidence.activityId, priority.id ); } assert.equal(camp.thirdCampPreparation.selectedPriorityId, null); assert.equal(camp.thirdCampPreparation.confirmed, false); assert.equal( camp.thirdCampPreparation.keyboardFocusPriorityId, 'information' ); await page.keyboard.press('ArrowDown'); await page.waitForFunction( () => window.__HEROS_DEBUG__?.camp?.() ?.thirdCampPreparation?.keyboardFocusPriorityId === 'equipment' ); await page.keyboard.press('Shift+Tab'); await page.waitForFunction( () => window.__HEROS_DEBUG__?.camp?.() ?.thirdCampPreparation?.keyboardFocusPriorityId === 'information' ); await page.keyboard.press('3'); await page.waitForFunction( () => { const preparation = window.__HEROS_DEBUG__?.camp?.()?.thirdCampPreparation; return ( preparation?.selectedPriorityId === 'companion' && preparation.confirmed === true && preparation.ready === true ); } ); camp = await readCamp(page); assertPreparationSelection(camp, 'companion'); await verifyPreparationModalKeyboardIsolation(page); camp = await verifyPreparationStepKeyboardIsolation( page, 'information' ); assertFiniteBounds( camp.thirdCampPreparation.clearButtonBounds, 'preparation clear button' ); await clickSceneBounds( page, 'CampScene', camp.thirdCampPreparation.clearButtonBounds ); await page.waitForFunction( () => { const state = window.__HEROS_DEBUG__?.camp?.(); return ( state?.thirdCampPreparation?.browserOpen === true && state.thirdCampPreparation.selectedPriorityId === null && state.thirdCampPreparation.ready === false ); } ); camp = await readCamp(page); const clearedChecklist = camp.sortieChecklist.find( ({ label }) => label === '출진 우선 준비' ); assert.equal(clearedChecklist?.complete, true); assert( clearedChecklist?.detail.includes('보너스 없이 출진 가능') ); assert.equal( camp.campaign.completedCampVisits.includes( selectionRecordId ), false, 'A mutable preparation selection must not masquerade as a completed camp visit.' ); assert.equal( camp.campaign.completedCampVisits.filter((visitId) => visitId.startsWith(explorationActivityMarkerPrefix) ).length, 3 ); await assertCampaignSelectionPersisted( page, null, 'cleared optional preparation' ); await verifyRetryLockedPreparationCard(page); } async function verifyPreparationModalKeyboardIsolation(page) { await page.evaluate(async () => { const campaignModule = await import( '/heros_web/src/game/state/campaignState.ts' ); const snapshot = campaignModule.getCampaignState(); campaignModule.saveCampaignState(snapshot, 2); campaignModule.saveCampaignState(snapshot, 1); const scene = window.__HEROS_GAME__?.scene.getScene('CampScene'); scene.campaign = campaignModule.getCampaignState(); scene.showSortiePrep(); }); await page.waitForFunction( () => { const state = window.__HEROS_DEBUG__?.camp?.(); return ( state?.campaign?.activeSaveSlot === 1 && state?.thirdCampPreparation?.browserOpen === true && state.thirdCampPreparation.selectedPriorityId === 'companion' ); } ); await openCampSaveSlotPanel(page); await page.keyboard.press('1'); await waitForCampSaveModalClosed(page); let camp = await readCamp(page); assertPreparationSelection(camp, 'companion'); assert.equal( camp.campaign.activeSaveSlot, 1, 'The 1 key must save to slot 1 instead of selecting the first preparation card.' ); await selectPreparationCard(page, 'equipment'); await waitForCampSelection(page, 'equipment'); await openCampSaveSlotPanel(page); await page.keyboard.press('3'); await waitForCampSaveModalClosed(page); camp = await readCamp(page); assertPreparationSelection(camp, 'equipment'); assert.equal( camp.campaign.activeSaveSlot, 3, 'The 3 key must save to slot 3 instead of selecting the third preparation card.' ); await selectPreparationCard(page, 'information'); await waitForCampSelection(page, 'information'); await openCampSaveSlotPanel(page); await page.keyboard.press('2'); await page.waitForFunction( () => { const scene = window.__HEROS_GAME__?.scene.getScene('CampScene'); return ( scene?.saveSlotObjects?.length > 0 && scene?.saveSlotConfirmObjects?.length > 0 && scene?.pendingSaveSlot === 2 ); } ); camp = await readCamp(page); assertPreparationSelection(camp, 'information'); await page.keyboard.press('1'); await page.keyboard.press('3'); const guardedConfirm = await readCampSaveModalState(page); assert.equal(guardedConfirm.slotPanelOpen, true); assert.equal(guardedConfirm.overwriteConfirmOpen, true); assert.equal(guardedConfirm.pendingSaveSlot, 2); assert.equal( guardedConfirm.selectedPriorityId, 'information', 'Number keys owned by the overwrite confirmation must not leak to hidden preparation cards.' ); await page.keyboard.press('Enter'); await waitForCampSaveModalClosed(page); camp = await readCamp(page); assertPreparationSelection(camp, 'information'); assert.equal( camp.campaign.activeSaveSlot, 2, 'Enter must confirm the pending overwrite instead of confirming the focused preparation card.' ); } async function verifyPreparationStepKeyboardIsolation( page, selectedPriorityId ) { let camp = await readCamp(page); assertPreparationSelection(camp, selectedPriorityId); assert.equal(camp.thirdCampPreparation.browserOpen, true); assert.equal(camp.sortiePrimaryAction?.kind, 'next'); assert.equal( camp.sortiePrimaryAction?.nextStep, 'formation' ); assertFiniteBounds( camp.sortiePrimaryAction?.bounds, 'sortie formation action' ); await clickSceneBounds( page, 'CampScene', camp.sortiePrimaryAction.bounds ); await page.waitForFunction( (selectedPriorityId) => { const state = window.__HEROS_DEBUG__?.camp?.(); return ( state?.sortiePrepStep === 'formation' && state?.thirdCampPreparation?.browserOpen === false && state.thirdCampPreparation.selectedPriorityId === selectedPriorityId ); }, selectedPriorityId ); camp = await readCamp(page); assert( camp.thirdCampPreparation.priorities.every( ({ cardBounds, actionButtonBounds }) => cardBounds === null && actionButtonBounds === null ), 'Leaving the briefing must destroy every preparation-card input target.' ); await page.keyboard.press('1'); camp = await readCamp(page); assert.equal(camp.sortiePrepStep, 'formation'); assert.equal( camp.thirdCampPreparation.selectedPriorityId, selectedPriorityId, 'A hidden preparation card must not consume number keys during formation.' ); await page.keyboard.press('Enter'); await page.waitForFunction( (selectedPriorityId) => { const state = window.__HEROS_DEBUG__?.camp?.(); return ( state?.sortiePrepStep === 'loadout' && state?.thirdCampPreparation?.browserOpen === false && state.thirdCampPreparation.selectedPriorityId === selectedPriorityId ); }, selectedPriorityId ); await page.evaluate(() => { const scene = window.__HEROS_GAME__?.scene.getScene('CampScene'); scene.setSortiePrepStep('briefing'); }); await waitForPreparationToggle(page); camp = await openPreparationBrowser(page); assertPreparationSelection(camp, selectedPriorityId); return camp; } async function verifyRetryLockedPreparationCard(page) { await seedThirdVictory(page, { acknowledgeActivities: false, selectPriorityId: null }); await page.evaluate(async (targetBattleId) => { const campaignModule = await import( '/heros_web/src/game/state/campaignState.ts' ); const actionModule = await import( '/heros_web/src/game/state/thirdCampPreparationActions.ts' ); const explorationActionModule = await import( '/heros_web/src/game/state/thirdCampExplorationActions.ts' ); const { battleScenarios } = await import( '/heros_web/src/game/data/battles.ts' ); const scenario = battleScenarios[targetBattleId]; if (!scenario) { throw new Error( `Missing retry scenario ${targetBattleId}.` ); } const entry = explorationActionModule.enterThirdCampExploration(); const activity = explorationActionModule.recordThirdCampExplorationActivity( 'information' ); const selection = actionModule.setThirdCampPreparationPriority( 'information' ); if (!entry.ok || !activity.ok || !selection.ok) { throw new Error( `Failed to seed a legacy preparation selection: ${JSON.stringify( { entry, activity, selection } )}` ); } campaignModule.setFirstBattleReport({ battleId: scenario.id, battleTitle: scenario.title, outcome: 'defeat', turnNumber: 7, rewardGold: 0, defeatedEnemies: 0, totalEnemies: scenario.units.filter( (unit) => unit.faction === 'enemy' ).length, objectives: scenario.objectives.map((objective) => ({ id: objective.id, label: objective.label, achieved: false, status: 'failed', detail: '미달성', rewardGold: 0 })), units: scenario.units, bonds: scenario.bonds.map((bond) => ({ ...bond, battleExp: 0 })), itemRewards: [], campaignRewards: { supplies: [], equipment: [], reputation: [], recruits: [], unlocks: [], note: '' }, completedCampDialogues: [], completedCampVisits: [], createdAt: '2026-07-27T01:00:00.000Z' }); campaignModule.completeCampaignAftermath(targetBattleId); const legacyRetry = campaignModule.getCampaignState(); legacyRetry.thirdCampPreparationSelection = { ...legacyRetry.thirdCampPreparationSelection, confirmed: false }; campaignModule.setCampaignState(legacyRetry); const game = window.__HEROS_GAME__; for (const scene of game?.scene.getScenes(true) ?? []) { game.scene.stop(scene.scene.key); } game.scene.start('CampScene', { retryBattleId: targetBattleId, openSortiePrep: true, skipIntroStory: true }); }, targetBattleId); await page.waitForFunction( (targetBattleId) => { const state = window.__HEROS_DEBUG__?.camp?.(); return ( window.__HEROS_DEBUG__ ?.activeScenes?.() .includes('CampScene') && state?.campaign?.step === 'fourth-battle' && state?.sortieVisible === true && state?.sortiePrepStep === 'briefing' && state?.thirdCampPreparation?.available === true && state.thirdCampPreparation.targetBattleId === targetBattleId && state.thirdCampPreparation.selectedPriorityId === 'information' && state.thirdCampPreparation.confirmed === false && state.thirdCampPreparation.ready === false && state.thirdCampPreparation.browserOpen === false ); }, targetBattleId, { timeout: 90000 } ); let camp = await openPreparationBrowser(page); assert.equal( camp.thirdCampPreparation.selectedPriorityId, 'information' ); assert.equal(camp.thirdCampPreparation.confirmed, false); assert.equal(camp.thirdCampPreparation.ready, false); assert.equal( preparationPriority(camp, 'information').selectable, true ); const locked = preparationPriority(camp, 'equipment'); assert.equal(locked.selectable, false); assert.equal( locked.unavailableReason, 'equipment-change-required' ); const lockedLabels = await page.evaluate(() => { const scene = window.__HEROS_GAME__?.scene.getScene('CampScene'); return ( scene?.children.list .filter( (child) => child?.active && child?.visible && child?.text === '초회 한정' ) .map((child) => child.text) ?? [] ); }); assert.equal( lockedLabels.length, 2, 'Every still-locked CTA in a legacy retry must read "초회 한정".' ); await clickSceneBounds( page, 'CampScene', locked.actionButtonBounds ); await page.waitForTimeout(300); const retryState = await page.evaluate(() => { const scene = window.__HEROS_GAME__?.scene.getScene('CampScene'); return { activeScenes: window.__HEROS_DEBUG__?.activeScenes?.() ?? [], camp: window.__HEROS_DEBUG__?.camp?.(), navigationPending: scene?.navigationPending ?? null }; }); assert.equal(retryState.navigationPending, false); assert.equal( retryState.activeScenes.includes( 'CampVisitExplorationScene' ), false, 'A retry-only locked preparation CTA must not reopen exploration.' ); assert.equal( retryState.camp?.thirdCampPreparation?.browserOpen, true ); assert.equal( retryState.camp?.thirdCampPreparation?.selectedPriorityId, 'information' ); assert.equal( retryState.camp?.thirdCampPreparation?.confirmed, false ); } async function waitForThirdCampExploration(page) { try { await page.waitForFunction( (visitId) => { const state = window.__HEROS_DEBUG__?.campVisitExploration?.(); return ( state?.scene === 'CampVisitExplorationScene' && state?.ready === true && state?.locationId === visitId ); }, explorationVisitId, { timeout: 30000 } ); } catch (error) { const state = await page.evaluate(() => ({ exploration: window.__HEROS_DEBUG__?.campVisitExploration?.(), camp: window.__HEROS_DEBUG__?.camp?.(), activeScenes: window.__HEROS_GAME__?.scene .getScenes(true) .map((scene) => scene.scene.key) ?? [] })); throw new Error( `Third-camp exploration did not become ready: ${JSON.stringify( state )}`, { cause: error } ); } return readThirdCampExploration(page); } async function openThirdCampExplorationFromCamp(page) { let camp = await readCamp(page); if (camp?.sortieVisible) { await page.evaluate(() => { const scene = window.__HEROS_GAME__?.scene.getScene('CampScene'); scene?.hideSortiePrep?.(); }); } camp = await readCamp(page); if (camp?.activeTab !== 'visit') { await page.evaluate(() => { const scene = window.__HEROS_GAME__?.scene.getScene('CampScene'); if (!scene) { return; } scene.activeTab = 'visit'; scene.render(); }); } await page.waitForFunction( () => { const state = window.__HEROS_DEBUG__?.camp?.(); return ( state?.activeTab === 'visit' && state?.thirdCampExploration?.available === true && state.thirdCampExploration.explorationInteractive === true && Boolean( state.thirdCampExploration.explorationButtonBounds ) ); } ); camp = await readCamp(page); await clickSceneBounds( page, 'CampScene', camp.thirdCampExploration.explorationButtonBounds ); const exploration = await waitForThirdCampExploration(page); const activeScenes = await page.evaluate(() => window.__HEROS_GAME__?.scene .getScenes(true) .map((scene) => scene.scene.key) ?? [] ); assert.equal( activeScenes.includes('CampScene'), false, 'The walkable visit must replace CampScene instead of rendering over it.' ); return exploration; } async function readThirdCampExploration(page) { return page.evaluate(() => window.__HEROS_DEBUG__.campVisitExploration() ); } async function advanceExplorationDialogue(page) { for (let step = 0; step < 20; step += 1) { const state = await page.evaluate(() => window.__HEROS_DEBUG__?.campVisitExploration?.() ); if ( state?.active === false || state?.choice?.open || state?.dialogue?.active === false ) { return state; } const advanced = await page.evaluate(() => { const scene = window.__HEROS_GAME__?.scene.getScene( 'CampVisitExplorationScene' ); return scene?.debugAdvanceDialogue?.() ?? false; }); assert.equal( advanced, true, 'Expected the active exploration dialogue to advance.' ); } assert.fail('Exploration dialogue did not finish within 20 advances.'); } async function completeThirdCampNpcActivity( page, actorId, activityId ) { const started = await page.evaluate((targetId) => { const scene = window.__HEROS_GAME__?.scene.getScene( 'CampVisitExplorationScene' ); return scene?.debugInteractWith?.(targetId) ?? false; }, actorId); assert.equal(started, true, `Expected ${actorId} interaction to start.`); await page.waitForFunction( () => window.__HEROS_DEBUG__?.campVisitExploration?.() ?.dialogue?.active === true ); await advanceExplorationDialogue(page); await page.waitForFunction( (expectedActivityId) => window.__HEROS_DEBUG__?.campVisitExploration?.() ?.visit?.completedActivityIds?.includes( expectedActivityId ), activityId ); return readThirdCampExploration(page); } function assertThirdCampWorldFeedback(state, completedIds) { const completed = new Set(completedIds); for (const feedback of state.worldFeedback) { assert.equal( feedback.visible, completed.has(feedback.activityId), `${feedback.activityId} world feedback visibility` ); } } async function readCampaignBondExp(page, bondId) { return page.evaluate(async (targetBondId) => { const campaignModule = await import( '/heros_web/src/game/state/campaignState.ts' ); return ( campaignModule .getCampaignState() .bonds.find(({ id }) => id === targetBondId)?.exp ?? 0 ); }, bondId); } async function verifyBattlePriority( page, rendererFixture, priority ) { const fixtureLabel = `${rendererFixture.renderer}-${priority.id}`; const seeded = await seedThirdVictory(page, { acknowledgeActivities: true, selectPriorityId: priority.id }); assert.deepEqual(seeded.selection, { sourceBattleId, targetBattleId, priorityId: priority.id, confirmed: true }); assert.equal(seeded.memory?.selectionRecordId, selectionRecordId); assert.equal(seeded.memory?.priorityId, priority.id); assert.equal(seeded.memory?.label, priority.label); await page.evaluate(async (battleId) => { await window.__HEROS_DEBUG__.goToBattle(battleId); }, targetBattleId); let battle = await waitForBattlePreparation( page, priority.id ); await assertFhdViewport(page, rendererFixture); assertBattleMemory( battle.thirdCampPreparation, priority, 'deployment' ); assert.equal(battle.phase, 'deployment'); assert.equal( battle.turnNumber, 1, `${fixtureLabel}: each reused BattleScene must initialize at turn 1.` ); assert.equal( battle.activeFaction, 'ally', `${fixtureLabel}: each reused BattleScene must initialize with the allied faction.` ); assert.equal( battle.thirdCampPreparation.presentation.deployment.status, 'deployment' ); assert.equal( battle.thirdCampPreparation.presentation.deployment .selectionRecordId, selectionRecordId ); assert.equal( battle.thirdCampPreparation.presentation.turn .selectionRecordId, selectionRecordId ); assert.equal( battle.thirdCampPreparation.presentation.turn.status, 'active' ); assert.equal( battle.thirdCampPreparation.chip.visible, false, 'The turn chip must stay out of the deployment overlay.' ); if (priority.id === 'information') { assert.equal( battle.thirdCampPreparation.trackedEnemyUnitId, trackedEnemyUnitId ); assert.equal( battle.thirdCampPreparation.deploymentIntent.enabled, true ); assert.equal( battle.thirdCampPreparation.deploymentIntent.visible, true ); assert.equal( battle.thirdCampPreparation.deploymentIntent.enemyId, trackedEnemyUnitId ); assert( battle.thirdCampPreparation.deploymentIntent.forecast, `${fixtureLabel}: the marked vanguard forecast must be visible during deployment.` ); } else { assert.equal( battle.thirdCampPreparation.deploymentIntent.enabled, false ); } assertMechanicsProbe( battle.thirdCampPreparation.mechanicsProbe, priority, `${fixtureLabel} deployment` ); await capture( page, `dist/verification-third-camp-preparation-${fixtureLabel}-deployment.png` ); const consumed = await consumePreparationAndRoundTripSave( page, priority.id ); assertMechanicsProbe( consumed.before.mechanicsProbe, priority, `${fixtureLabel} turn` ); assert.equal(consumed.before.chip.visible, true); assertBoundsInsideViewport( consumed.before.chip.bounds, `${fixtureLabel} active chip` ); assertRuntimeConsumed( consumed.after.runtime, priority, consumed.preventedDamage ); assertRuntimeConsumed( consumed.normalized, priority, consumed.preventedDamage ); assert.deepEqual( consumed.restored.runtime, consumed.after.runtime, `${fixtureLabel}: normalized save progress must restore exactly.` ); assert.deepEqual( consumed.restored.saveSnapshot, consumed.normalized, `${fixtureLabel}: the restored debug save snapshot must match the normalized field.` ); assert.equal( consumed.normalized.selectionRecordId, selectionRecordId ); assert.equal( consumed.normalized.priorityId, priority.id ); assert.equal(consumed.normalized.label, priority.label); assert.equal( consumed.normalized.markedEnemyUnitId, priority.id === 'information' ? trackedEnemyUnitId : undefined ); assert.equal(consumed.deepCloned, true); assert.equal(consumed.after.chip.visible, true); assertBoundsInsideViewport( consumed.after.chip.bounds, `${fixtureLabel} consumed chip` ); const expired = await expirePreparationAtTurnFour(page); assertBattleMemory(expired, priority, 'turn 4'); assert.equal(expired.effectActive, false); assert.equal(expired.presentation.turn.status, 'expired'); assert.equal( expired.presentation.turn.selectionRecordId, selectionRecordId ); assert.equal(expired.presentation.turn.turnNumber, 4); assert.equal(expired.chip.visible, true); assert.match( expired.chip.text, /사용 완료|종료/, 'Turn 4 must show either the already-consumed state or an unused preparation expiry.' ); assertBoundsInsideViewport( expired.chip.bounds, `${fixtureLabel} expired chip` ); assertExpiredMechanicsProbe( expired.mechanicsProbe, priority, fixtureLabel ); assertRuntimeConsumed( expired.runtime, priority, consumed.preventedDamage ); await page.evaluate(() => { window.__HEROS_DEBUG__.forceBattleOutcome('victory'); }); await page.waitForFunction( () => { const state = window.__HEROS_DEBUG__?.battle?.(); return ( state?.battleOutcome === 'victory' && state?.resultVisible === true && state?.thirdCampPreparation?.presentation?.result ?.selectionRecordId === 'third-camp-preparation-priority' ); }, undefined, { timeout: 90000 } ); battle = await readBattle(page); assertBattleMemory( battle.thirdCampPreparation, priority, 'result' ); assert.equal( battle.thirdCampPreparation.presentation.result.status, 'applied' ); assert.equal( battle.thirdCampPreparation.presentation.result .selectionRecordId, selectionRecordId ); assert.equal( battle.thirdCampPreparation.presentation.result.usageCount, 1 ); const resultTextProbe = await page.evaluate(() => { const scene = window.__HEROS_GAME__?.scene.getScene('BattleScene'); return (scene?.resultObjects ?? []) .filter( (object) => object?.type === 'Text' && object.active && object.visible && typeof object.text === 'string' ) .map((object) => object.text); }); assert( resultTextProbe.some((text) => text.includes( battle.thirdCampPreparation.presentation.result.text ) ), `${fixtureLabel}: the visible result subtitle must include the applied camp preparation memory: ${JSON.stringify( resultTextProbe )}` ); await capture( page, `dist/verification-third-camp-preparation-${fixtureLabel}-result.png` ); } async function verifyCompanionFormationMismatch(page, renderer) { await seedThirdVictory(page, { acknowledgeActivities: true, selectPriorityId: 'companion', selectedSortieUnitIds: ['liu-bei', 'guan-yu'] }); await page.evaluate(async () => { await window.__HEROS_DEBUG__.goToCamp(); }); await waitForCamp(page); await page.evaluate(() => { const scene = window.__HEROS_GAME__?.scene.getScene('CampScene'); scene?.showSortiePrep?.(); }); let camp = await waitForPreparationToggle(page); assert.deepEqual( camp.thirdCampPreparation.compatibility, { compatible: false, requiredUnitIds: ['guan-yu', 'zhang-fei'], missingUnitIds: ['zhang-fei'], disabledReason: 'required-units-missing' } ); const checklist = camp.sortieChecklist.find( ({ label }) => label === '출진 우선 준비' ); assert.equal(checklist?.complete, false); assert.match(checklist?.detail ?? '', /장비.*미편성.*미발동/); await capture( page, `dist/verification-third-camp-preparation-${renderer}-companion-mismatch-camp.png` ); await page.evaluate(async (battleId) => { await window.__HEROS_DEBUG__.goToBattle(battleId); }, targetBattleId); await page.waitForFunction( ({ targetBattleId, selectionRecordId }) => { const state = window.__HEROS_DEBUG__?.battle?.(); const preparation = state?.thirdCampPreparation; return ( state?.scene === 'BattleScene' && state.battleId === targetBattleId && state.phase === 'deployment' && preparation?.selectionRecordId === selectionRecordId && preparation.compatibility?.compatible === false ); }, { targetBattleId, selectionRecordId }, { timeout: 90000 } ); let battle = await readBattle(page); assert.equal(battle.thirdCampPreparation.effectActive, false); assert.equal( battle.thirdCampPreparation.presentation.deployment.status, 'incompatible' ); assert.equal( battle.thirdCampPreparation.presentation.turn.status, 'incompatible' ); assert.match( battle.thirdCampPreparation.presentation.deployment.text, /편성되지 않아.*미적용/ ); await page.evaluate(() => { const scene = window.__HEROS_GAME__?.scene.getScene('BattleScene'); scene.phase = 'idle'; scene.renderTacticalInitiativeChip(); }); battle = await readBattle(page); assert.equal(battle.thirdCampPreparation.chip.visible, true); assert.match( battle.thirdCampPreparation.chip.text, /미편성.*미발동/ ); await capture( page, `dist/verification-third-camp-preparation-${renderer}-companion-mismatch-battle.png` ); await page.evaluate(() => { window.__HEROS_DEBUG__.forceBattleOutcome('victory'); }); await page.waitForFunction( () => window.__HEROS_DEBUG__?.battle?.() ?.thirdCampPreparation?.presentation?.result?.status === 'incompatible', undefined, { timeout: 90000 } ); battle = await readBattle(page); assert.match( battle.thirdCampPreparation.resultFeedbackText, /편성되지 않아.*미적용/ ); } async function seedThirdVictory( page, { acknowledgeActivities, selectPriorityId, selectedSortieUnitIds = [ 'liu-bei', 'guan-yu', 'zhang-fei' ] } ) { return page.evaluate( async ({ sourceBattleId, targetBattleId, priorityDialogueId, priorityDialogueBondId, acknowledgeActivities, selectPriorityId, selectedSortieUnitIds }) => { const game = window.__HEROS_GAME__; for (const scene of game?.scene.getScenes(true) ?? []) { game.scene.stop(scene.scene.key); } window.localStorage.clear(); const campaignModule = await import( '/heros_web/src/game/state/campaignState.ts' ); const actionModule = await import( '/heros_web/src/game/state/thirdCampPreparationActions.ts' ); const explorationActionModule = await import( '/heros_web/src/game/state/thirdCampExplorationActions.ts' ); const explorationDataModule = await import( '/heros_web/src/game/data/thirdCampExploration.ts' ); const preparationModule = await import( '/heros_web/src/game/data/thirdCampPreparation.ts' ); const { battleScenarios } = await import( '/heros_web/src/game/data/battles.ts' ); const scenario = battleScenarios[sourceBattleId]; if (!scenario) { throw new Error( `Missing source scenario ${sourceBattleId}.` ); } campaignModule.resetCampaignState(); campaignModule.setFirstBattleReport({ battleId: scenario.id, battleTitle: scenario.title, outcome: 'victory', turnNumber: 22, 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: '달성', category: objective.id === 'fort' || objective.id === 'quick' ? 'bonus' : 'required', rewardGold: objective.rewardGold })), units: scenario.units, bonds: scenario.bonds.map((bond) => ({ ...bond, battleExp: 0 })), itemRewards: [...scenario.itemRewards], campaignRewards: { supplies: [ ...(scenario.campaignReward?.supplies ?? []) ], equipment: [ ...(scenario.campaignReward?.equipment ?? []) ], reputation: [ ...(scenario.campaignReward?.reputation ?? []) ], recruits: [], unlocks: scenario.campaignReward?.unlockBattleId ? [ { battleId: scenario.campaignReward.unlockBattleId, title: scenario.campaignReward.unlockLabel ?? scenario.campaignReward.unlockBattleId } ] : [], note: scenario.campaignReward?.note }, completedCampDialogues: [], completedCampVisits: [], createdAt: '2026-07-27T00:00:00.000Z' }); let campaign = campaignModule.getCampaignState(); campaign.dismissedVictoryRewardNoticeBattleIds = [ ...new Set([ ...campaign.dismissedVictoryRewardNoticeBattleIds, sourceBattleId ]) ]; campaign.selectedSortieUnitIds = [ ...selectedSortieUnitIds ]; campaignModule.setCampaignState(campaign); campaignModule.completeCampaignAftermath(sourceBattleId); if (acknowledgeActivities) { const entry = explorationActionModule.enterThirdCampExploration(); if (!entry.ok) { throw new Error( `Failed to enter third-camp exploration: ${entry.reason}` ); } for (const activityId of ['information', 'equipment']) { const activity = explorationActionModule.recordThirdCampExplorationActivity( activityId ); if (!activity.ok) { throw new Error( `Failed to complete ${activityId}: ${activity.reason}` ); } } const definition = explorationDataModule.resolveThirdCampExplorationDefinition( campaignModule.getCampaignState() ); const companionChoiceId = definition?.companionDialogue.choices[0]?.id; if (!companionChoiceId) { throw new Error( 'Failed to resolve the priority return companion choice.' ); } const companion = explorationActionModule.completeThirdCampCompanionActivity( companionChoiceId ); if (!companion.ok) { throw new Error( `Failed to complete companion activity: ${companion.reason}` ); } } let selectionResult = null; if (selectPriorityId) { selectionResult = actionModule.setThirdCampPreparationPriority( selectPriorityId ); if (!selectionResult.ok) { throw new Error( `Failed to select ${selectPriorityId}: ${selectionResult.reason}` ); } } campaign = campaignModule.loadCampaignState(); const memory = preparationModule.resolveThirdCampPreparationMemory({ campaign, battleId: targetBattleId }); return { step: campaign.step, latestBattleId: campaign.latestBattleId ?? null, selection: campaign.thirdCampPreparationSelection ?? null, acknowledgedCategories: campaign.acknowledgedVictoryRewardCategories?.[ sourceBattleId ] ?? [], priorityDialogueCompleted: campaign.completedCampDialogues.includes( priorityDialogueId ), memory: memory ? { sourceBattleId: memory.sourceBattleId, targetBattleId: memory.targetBattleId, selectionRecordId: memory.selectionRecordId, priorityId: memory.priorityId, label: memory.label, effectKind: memory.effect.kind } : null, selectionChanged: selectionResult?.ok === true ? selectionResult.changed : null }; }, { sourceBattleId, targetBattleId, priorityDialogueId, priorityDialogueBondId, acknowledgeActivities, selectPriorityId, selectedSortieUnitIds } ); } async function consumePreparationAndRoundTripSave( page, priorityId ) { return page.evaluate(async (priorityId) => { const scene = window.__HEROS_GAME__?.scene.getScene('BattleScene'); if ( !scene || typeof scene.renderTacticalInitiativeChip !== 'function' || typeof scene.createBattleSaveState !== 'function' ) { throw new Error( 'BattleScene preparation debug hooks are unavailable.' ); } scene.phase = 'idle'; scene.renderTacticalInitiativeChip(); const before = window.__HEROS_DEBUG__.battle().thirdCampPreparation; const probe = before.mechanicsProbe; let preventedDamage = 0; if (priorityId === 'information') { const attacker = scene.debugUnitById( probe.attackerId ); const defender = scene.debugUnitById( probe.defenderId ); const preview = scene.combatPreview( attacker, defender, 'attack' ); scene.recordThirdCampPreparationCombatUse( preview, false, 0 ); } else if (priorityId === 'equipment') { const attacker = scene.debugUnitById( probe.attackerId ); const defender = scene.debugUnitById( probe.defenderId ); const preview = scene.combatPreview( attacker, defender, 'attack' ); preventedDamage = probe.preventedDamage; scene.recordThirdCampPreparationCombatUse( preview, true, preventedDamage ); } else { scene.recordThirdCampPreparationCompanionAttempt({ bondId: probe.bondId, succeeded: true }); } const after = window.__HEROS_DEBUG__.battle().thirdCampPreparation; const fullState = scene.createBattleSaveState(); const saveModule = await import( '/heros_web/src/game/state/battleSaveState.ts' ); const normalized = saveModule.normalizeBattleSaveState( fullState, { expectedBattleId: fullState.battleId, allowTacticalCommand: true } ); if (!normalized?.thirdCampPreparation) { throw new Error( `Normalized ${priorityId} preparation progress is missing.` ); } const deepCloned = normalized !== fullState && normalized.thirdCampPreparation !== fullState.thirdCampPreparation; scene.resetThirdCampPreparationRuntime(); scene.restoreThirdCampPreparationRuntime( normalized.thirdCampPreparation ); scene.renderTacticalInitiativeChip(); const restored = window.__HEROS_DEBUG__.battle().thirdCampPreparation; return { before, after, normalized: normalized.thirdCampPreparation, restored, preventedDamage, deepCloned }; }, priorityId); } async function expirePreparationAtTurnFour(page) { return page.evaluate(() => { const scene = window.__HEROS_GAME__?.scene.getScene('BattleScene'); scene.turnNumber = 4; scene.phase = 'idle'; scene.renderTacticalInitiativeChip(); return window.__HEROS_DEBUG__.battle() .thirdCampPreparation; }); } function assertBattleMemory(memory, priority, stage) { assert( memory?.active, `${priority.id} ${stage}: preparation memory is missing.` ); assert.equal(memory.sourceBattleId, sourceBattleId); assert.equal(memory.targetBattleId, targetBattleId); assert.equal(memory.selectionRecordId, selectionRecordId); assert.equal(memory.priorityId, priority.id); assert.equal(memory.label, priority.label); assert.equal(memory.effectKind, priority.effectKind); assert.equal(memory.activeThroughTurn, activeThroughTurn); } function assertMechanicsProbe(probe, priority, label) { assert(probe, `${label}: mechanicsProbe is missing.`); assert.equal(probe.kind, priority.effectKind); if (priority.id === 'information') { assert.equal(probe.defenderId, trackedEnemyUnitId); assert.equal( probe.configuredHitBonus, priority.configuredValue ); assert.equal( probe.expectedHitBonus, priority.configuredValue ); assert.equal( probe.hitRate, Math.min( 98, probe.baselineHitRate + priority.configuredValue ), `${label}: +10%p must be applied before the global 98% hit cap.` ); assert.equal( probe.appliedHitBonus, probe.hitRate - probe.baselineHitRate ); return; } if (priority.id === 'equipment') { assert.equal( probe.configuredReductionPercent, priority.configuredValue ); assert.equal( probe.expectedReductionPercent, priority.configuredValue ); const expectedDamage = probe.baselineDamage > 1 ? Math.min( probe.baselineDamage - 1, Math.max( 1, Math.round( probe.baselineDamage * (1 - priority.configuredValue / 100) ) ) ) : probe.baselineDamage; assert.equal(probe.damage, expectedDamage); assert.equal( probe.preventedDamage, probe.baselineDamage - probe.damage ); assert( probe.preventedDamage > 0, `${label}: the one-time 8% guard must prevent at least one point for the deterministic probe.` ); return; } assert.equal(probe.bondId, priorityDialogueBondId); assert.equal(probe.bondAvailable, true); assert.equal( probe.preparationBonus, priority.configuredValue ); assert.equal( probe.expectedPreparationBonus, priority.configuredValue ); assert.equal( probe.effectiveRate, Math.min( 100, probe.baseRate + probe.memoryBonus + priority.configuredValue ) ); } function assertExpiredMechanicsProbe( probe, priority, label ) { assert(probe, `${label}: turn-4 mechanicsProbe is missing.`); if (priority.id === 'information') { assert.equal(probe.configuredHitBonus, 0); assert.equal(probe.appliedHitBonus, 0); assert.equal(probe.hitRate, probe.baselineHitRate); return; } if (priority.id === 'equipment') { assert.equal(probe.configuredReductionPercent, 0); assert.equal(probe.preventedDamage, 0); assert.equal(probe.damage, probe.baselineDamage); return; } assert.equal(probe.preparationBonus, 0); assert.equal( probe.effectiveRate, Math.min(100, probe.baseRate + probe.memoryBonus) ); } function assertRuntimeConsumed( runtime, priority, preventedDamage ) { assert(runtime, `${priority.id}: runtime snapshot is missing.`); assert.equal( runtime.informationConsumed, priority.id === 'information' ); assert.equal( runtime.equipmentConsumed, priority.id === 'equipment' ); assert.equal( runtime.companionAttempts, priority.id === 'companion' ? 1 : 0 ); assert.equal( runtime.companionSuccesses, priority.id === 'companion' ? 1 : 0 ); assert.equal(runtime.usageCount, 1); assert.equal( runtime.preventedDamage, priority.id === 'equipment' ? preventedDamage : 0 ); if ( Object.prototype.hasOwnProperty.call( runtime, 'markedEnemyUnitId' ) && priority.id === 'information' ) { assert.equal( runtime.markedEnemyUnitId, trackedEnemyUnitId ); } else if ( Object.prototype.hasOwnProperty.call( runtime, 'markedEnemyUnitId' ) ) { assert.equal(runtime.markedEnemyUnitId, undefined); } } async function waitForDebugApi(page) { await page.waitForFunction( () => document.querySelector('canvas') !== null && window.__HEROS_GAME__ !== undefined && window.__HEROS_DEBUG__ !== undefined, undefined, { timeout: 90000 } ); } async function waitForCamp(page, timeout = 90000) { await page.waitForFunction( () => { const state = window.__HEROS_DEBUG__?.camp?.(); return ( window.__HEROS_DEBUG__ ?.activeScenes?.() .includes('CampScene') && state?.scene === 'CampScene' && state?.campaign?.step === 'third-camp' && state?.campBattleId === 'third-battle-guangzong-road' ); }, undefined, { timeout } ); return readCamp(page); } async function waitForPreparationToggle(page) { await page.waitForFunction( () => { const state = window.__HEROS_DEBUG__?.camp?.(); return ( state?.sortieVisible === true && state?.sortiePrepStep === 'briefing' && state?.thirdCampPreparation?.available === true && state.thirdCampPreparation.browserOpen === false && Boolean( state.thirdCampPreparation.toggleButtonBounds ) ); }, undefined, { timeout: 30000 } ); return readCamp(page); } async function openCampSaveSlotPanel(page) { const before = await readCampSaveModalState(page); assert.equal( before.slotPanelOpen, false, 'The save-slot panel must begin closed.' ); await page.evaluate(() => { const scene = window.__HEROS_GAME__?.scene.getScene('CampScene'); if (typeof scene?.showCampSaveSlotPanel !== 'function') { throw new Error( 'CampScene.showCampSaveSlotPanel is unavailable.' ); } scene.showCampSaveSlotPanel(); }); await page.waitForFunction( () => { const scene = window.__HEROS_GAME__?.scene.getScene('CampScene'); return ( scene?.saveSlotObjects?.length > 0 && scene?.saveSlotConfirmObjects?.length === 0 ); } ); return readCampSaveModalState(page); } async function waitForCampSaveModalClosed(page) { await page.waitForFunction( () => { const scene = window.__HEROS_GAME__?.scene.getScene('CampScene'); return ( scene?.saveSlotObjects?.length === 0 && scene?.saveSlotConfirmObjects?.length === 0 && scene?.pendingSaveSlot === undefined ); } ); return readCampSaveModalState(page); } async function readCampSaveModalState(page) { return page.evaluate(() => { const scene = window.__HEROS_GAME__?.scene.getScene('CampScene'); const camp = window.__HEROS_DEBUG__?.camp?.(); return { slotPanelOpen: (scene?.saveSlotObjects?.length ?? 0) > 0, overwriteConfirmOpen: (scene?.saveSlotConfirmObjects?.length ?? 0) > 0, pendingSaveSlot: scene?.pendingSaveSlot ?? null, selectedPriorityId: camp?.thirdCampPreparation?.selectedPriorityId ?? null, browserOpen: camp?.thirdCampPreparation?.browserOpen ?? false, activeSaveSlot: camp?.campaign?.activeSaveSlot ?? null }; }); } async function openPreparationBrowser(page) { let lastState = await readCamp(page); for (let attempt = 0; attempt < 2; attempt += 1) { if (!lastState?.thirdCampPreparation?.browserOpen) { if (!lastState?.sortieVisible) { await page.evaluate(() => { const scene = window.__HEROS_GAME__?.scene.getScene('CampScene'); scene.showSortiePrep(); }); } const ready = await waitForPreparationToggle(page); await clickSceneBounds( page, 'CampScene', ready.thirdCampPreparation.toggleButtonBounds ); } await page.waitForFunction( () => { const preparation = window.__HEROS_DEBUG__?.camp?.() ?.thirdCampPreparation; return ( preparation?.browserOpen === true && Boolean(preparation.closeButtonBounds) && preparation.priorities?.length === 3 && preparation.priorities.every( ({ cardBounds, actionButtonBounds }) => Boolean(cardBounds && actionButtonBounds) ) ); }, undefined, { timeout: 30000 } ); await page.waitForTimeout(150); lastState = await readCamp(page); if (lastState?.thirdCampPreparation?.browserOpen) { return lastState; } } throw new Error( `Preparation browser did not remain open: ${JSON.stringify({ sortieVisible: lastState?.sortieVisible, activeTab: lastState?.activeTab, preparation: lastState?.thirdCampPreparation })}` ); } async function selectPreparationCard(page, priorityId) { const camp = await readCamp(page); const priority = preparationPriority(camp, priorityId); assert.equal( priority.selectable, true, `${priorityId}: expected the card to be selectable.` ); await clickSceneBounds( page, 'CampScene', priority.actionButtonBounds ); } async function waitForCampSelection(page, priorityId) { await page.waitForFunction( ({ priorityId, selectionRecordId }) => { const preparation = window.__HEROS_DEBUG__?.camp?.() ?.thirdCampPreparation; return ( preparation?.selectionRecordId === selectionRecordId && preparation?.ready === true && preparation?.selectedPriorityId === priorityId && preparation?.browserOpen === true && preparation?.priorities?.find( ({ id }) => id === priorityId )?.selected === true ); }, { priorityId, selectionRecordId }, { timeout: 30000 } ); return readCamp(page); } async function waitForBattlePreparation(page, priorityId) { try { await page.waitForFunction( ({ targetBattleId, priorityId, selectionRecordId }) => { const state = window.__HEROS_DEBUG__?.battle?.(); const preparation = state?.thirdCampPreparation; return ( state?.scene === 'BattleScene' && state?.battleId === targetBattleId && state?.phase === 'deployment' && ['ready', 'degraded'].includes( state?.combatAssets?.status ) && preparation?.active === true && preparation?.effectActive === true && preparation?.selectionRecordId === selectionRecordId && preparation?.priorityId === priorityId && preparation?.presentation?.deployment ?.selectionRecordId === selectionRecordId && preparation?.presentation?.turn ?.selectionRecordId === selectionRecordId && Boolean(preparation?.mechanicsProbe) && (priorityId !== 'information' || preparation?.deploymentIntent?.visible === true) ); }, { targetBattleId, priorityId, selectionRecordId }, { timeout: 90000 } ); } catch (error) { const diagnostic = await page.evaluate(() => ({ activeScenes: window.__HEROS_DEBUG__?.activeScenes?.() ?? [], battle: window.__HEROS_DEBUG__?.battle?.() ?? null })); throw new Error( `BattleScene did not expose ${priorityId} preparation during deployment: ${JSON.stringify( diagnostic )}`, { cause: error } ); } return readBattle(page); } function assertPreparationBrowserLayout(camp, label) { const preparation = camp.thirdCampPreparation; assert.equal(preparation.available, true); assert.equal(preparation.browserOpen, true); assert.equal(preparation.selectionRecordId, selectionRecordId); assertFiniteBounds( preparation.closeButtonBounds, `${label} close button` ); assertBoundsInsideViewport( preparation.closeButtonBounds, `${label} close button` ); assert.equal(preparation.priorities.length, 3); const cards = preparation.priorities.map((priority) => { assertFiniteBounds( priority.cardBounds, `${label} ${priority.id} card` ); assertFiniteBounds( priority.actionButtonBounds, `${label} ${priority.id} action` ); assertBoundsInsideViewport( priority.cardBounds, `${label} ${priority.id} card` ); assertBoundsInsideViewport( priority.actionButtonBounds, `${label} ${priority.id} action` ); assertBoundsContains( priority.cardBounds, priority.actionButtonBounds, `${label} ${priority.id} action inside card` ); return { id: priority.id, bounds: priority.cardBounds }; }); assertNoOverlappingBounds(cards, `${label} cards`); assertNoOverlappingBounds( preparation.priorities.map((priority) => ({ id: priority.id, bounds: priority.actionButtonBounds })), `${label} action buttons` ); cards.forEach((card) => { assert( !boundsIntersect( card.bounds, preparation.closeButtonBounds ), `${label}: ${card.id} card must not overlap the close button.` ); }); } function assertPreparationSelection(camp, priorityId) { const preparation = camp.thirdCampPreparation; assert.equal(preparation.selectionRecordId, selectionRecordId); assert.equal(preparation.ready, true); assert.equal(preparation.confirmed, true); assert.equal( preparation.selectedPriorityId, priorityId ); assert.equal( preparation.priorities.filter( ({ selected }) => selected ).length, 1 ); assert.equal( preparationPriority(camp, priorityId).selected, true ); } async function assertCampaignSelectionPersisted( page, priorityId, label ) { const persisted = await page.evaluate(() => { const raw = window.localStorage.getItem( 'heros-web:campaign-state' ); return raw ? JSON.parse(raw) : null; }); assert(persisted, `${label}: campaign save is missing.`); if (priorityId) { assert.deepEqual( persisted.thirdCampPreparationSelection, { sourceBattleId, targetBattleId, priorityId, confirmed: true } ); } else { assert.equal( persisted.thirdCampPreparationSelection, undefined ); } assert.equal( persisted.completedCampVisits.includes( selectionRecordId ), false ); assert.equal( persisted.firstBattleReport?.completedCampVisits?.includes( selectionRecordId ), false ); } function preparationPriority(camp, priorityId) { const priority = camp?.thirdCampPreparation?.priorities?.find( ({ id }) => id === priorityId ); assert( priority, `CampScene is missing the ${priorityId} preparation card: ${JSON.stringify( camp?.thirdCampPreparation )}` ); return priority; } async function clickSceneBounds( page, sceneKey, bounds ) { assertFiniteBounds(bounds, `${sceneKey} click target`); const point = await page.evaluate( ({ sceneKey, bounds }) => { const scene = window.__HEROS_GAME__?.scene.getScene(sceneKey); const canvas = document.querySelector('canvas'); const canvasBounds = canvas?.getBoundingClientRect(); if (!scene || !canvasBounds) { return null; } const centerX = bounds.x + bounds.width / 2; const centerY = bounds.y + bounds.height / 2; return { x: canvasBounds.left + (centerX * canvasBounds.width) / scene.scale.width, y: canvasBounds.top + (centerY * canvasBounds.height) / scene.scale.height }; }, { sceneKey, bounds } ); assert( point && Number.isFinite(point.x) && Number.isFinite(point.y), `Unable to map ${sceneKey} bounds to a browser point: ${JSON.stringify( bounds )}` ); await page.mouse.click(point.x, point.y); } async function readCamp(page) { return page.evaluate(() => window.__HEROS_DEBUG__?.camp?.() ); } async function readBattle(page) { return page.evaluate(() => window.__HEROS_DEBUG__?.battle?.() ); } function assertNoOverlappingBounds(entries, label) { for ( let leftIndex = 0; leftIndex < entries.length; leftIndex += 1 ) { for ( let rightIndex = leftIndex + 1; rightIndex < entries.length; rightIndex += 1 ) { const left = entries[leftIndex]; const right = entries[rightIndex]; assert( !boundsIntersect(left.bounds, right.bounds), `${label}: ${left.id} and ${right.id} overlap: ${JSON.stringify( { left, right } )}` ); } } } function boundsIntersect(left, right) { return Boolean( left && right && left.x < right.x + right.width && left.x + left.width > right.x && left.y < right.y + right.height && left.y + left.height > right.y ); } function assertBoundsContains(outer, inner, label) { const epsilon = 0.01; assert( inner.x >= outer.x - epsilon && inner.y >= outer.y - epsilon && inner.x + inner.width <= outer.x + outer.width + epsilon && inner.y + inner.height <= outer.y + outer.height + epsilon, `${label}: ${JSON.stringify({ outer, inner })}` ); } function assertBoundsInsideViewport(bounds, label) { assertFiniteBounds(bounds, label); const epsilon = 0.01; assert( bounds.x >= -epsilon && bounds.y >= -epsilon && bounds.x + bounds.width <= desktopBrowserViewport.width + epsilon && bounds.y + bounds.height <= desktopBrowserViewport.height + epsilon, `${label}: expected bounds inside the 1920x1080 viewport, received ${JSON.stringify( bounds )}` ); } function assertFiniteBounds(bounds, label) { assert(bounds, `${label}: bounds are required.`); assert( ['x', 'y', 'width', 'height'].every((key) => Number.isFinite(bounds[key]) ) && bounds.width > 0 && bounds.height > 0, `${label}: expected positive finite bounds, received ${JSON.stringify( bounds )}` ); } async function assertFhdViewport(page, fixture) { const viewport = await page.evaluate(() => { const canvas = document.querySelector('canvas'); const bounds = canvas?.getBoundingClientRect(); return { width: window.innerWidth, height: window.innerHeight, dpr: window.devicePixelRatio, visualScale: window.visualViewport?.scale ?? 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(viewport.width, desktopBrowserViewport.width); assert.equal( viewport.height, desktopBrowserViewport.height ); assert.equal( viewport.dpr, desktopBrowserDeviceScaleFactor ); assert.equal(viewport.visualScale, 1); assert.equal( viewport.rendererType, fixture.expectedRendererType, `${fixture.renderer}: Phaser did not use the requested renderer.` ); assert.equal( viewport.canvas?.width, desktopBrowserViewport.width ); assert.equal( viewport.canvas?.height, desktopBrowserViewport.height ); assert.equal( viewport.canvas?.clientWidth, desktopBrowserViewport.width ); assert.equal( viewport.canvas?.clientHeight, desktopBrowserViewport.height ); assert.equal( viewport.canvas?.bounds?.width, desktopBrowserViewport.width ); assert.equal( viewport.canvas?.bounds?.height, desktopBrowserViewport.height ); } async function capture(page, path) { await page.waitForTimeout(180); const loopSlept = await page.evaluate(() => { const loop = window.__HEROS_GAME__?.loop; if (!loop || typeof loop.sleep !== 'function') { return false; } loop.sleep(); return true; }); await page.waitForTimeout(40); try { await page.screenshot({ path, fullPage: true }); } finally { if (loopSlept) { await page.evaluate(() => window.__HEROS_GAME__?.loop.wake() ); await page.waitForTimeout(40); } } } function cliOption(name) { const equalsPrefix = `--${name}=`; const equalsOption = process.argv.find((argument) => argument.startsWith(equalsPrefix) ); if (equalsOption) { return equalsOption.slice(equalsPrefix.length); } const index = process.argv.indexOf(`--${name}`); return index >= 0 ? process.argv[index + 1] : undefined; }