2709 lines
75 KiB
JavaScript
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;
|
|
}
|