Files
heros_web/scripts/verify-third-camp-preparation-browser.mjs

2709 lines
75 KiB
JavaScript

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;
}