Files
heros_web/scripts/verify-prologue-village-browser.mjs

1514 lines
60 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import assert from 'node:assert/strict';
import { spawn } from 'node:child_process';
import { mkdirSync } from 'node:fs';
import { chromium } from 'playwright';
import {
desktopBrowserContextOptions,
desktopBrowserDeviceScaleFactor,
desktopBrowserViewport
} from './desktop-browser-viewport.mjs';
const targetUrl = withDebugOptions(
process.env.VERIFY_PROLOGUE_VILLAGE_URL ?? 'http://127.0.0.1:41796/'
);
let serverProcess;
let browser;
try {
mkdirSync('dist', { recursive: true });
serverProcess = await ensureLocalServer(targetUrl);
browser = await chromium.launch({
headless: process.env.VERIFY_PROLOGUE_VILLAGE_HEADLESS !== '0'
});
const context = await browser.newContext(desktopBrowserContextOptions);
const page = await context.newPage();
page.setDefaultTimeout(30000);
const pageErrors = [];
const consoleErrors = [];
page.on('pageerror', (error) => pageErrors.push(error.stack ?? error.message));
page.on('console', (message) => {
if (message.type() === 'error') {
consoleErrors.push(message.text());
}
});
await page.goto(targetUrl, { waitUntil: 'domcontentloaded', timeout: 90000 });
await page.evaluate(() => window.localStorage.clear());
await page.reload({ waitUntil: 'domcontentloaded', timeout: 90000 });
await waitForDebugApi(page);
await waitForTitle(page);
await assertDesktopViewport(page);
await verifyReducedMotionExploration(page);
await page.evaluate(() => window.localStorage.clear());
await page.reload({ waitUntil: 'domcontentloaded', timeout: 90000 });
await waitForDebugApi(page);
await waitForTitle(page);
await assertDesktopViewport(page);
await clickLegacyTitleControl(page, 962, 240);
await waitForStoryReady(page);
const opening = await readStory(page);
assert.equal(opening.totalPages, 2, 'The playable prologue should retain only the two setup pages before control is handed over.');
assert.equal(opening.nextScene, 'PrologueVillageScene');
assert.equal(opening.currentPageId, 'yellow-turban-chaos');
await page.mouse.click(960, 850);
await page.waitForFunction(() => {
const scene = window.__HEROS_DEBUG__?.scene('StoryScene');
return scene?.getDebugState?.()?.currentPageId === 'liu-bei-resolve' && scene?.transitioning === false;
});
await page.keyboard.press('Space');
await waitForVillageReady(page);
await page.waitForTimeout(380);
let village = await readVillage(page);
assert.equal(village.campaignStep, 'prologue-town');
assertExplorationBackground(village.background, 'prologue-village-background');
assert.equal(village.requiredTexturesReady, true);
assert.deepEqual(
village.requiredTextures.map(({ key, ready }) => ({ key, ready })),
[
{ key: 'exploration-liu-bei', ready: true },
{ key: 'exploration-guan-yu', ready: true },
{ key: 'exploration-zhang-fei', ready: true },
{ key: 'exploration-zhuo-recruiting-clerk', ready: true },
{ key: 'exploration-zhuo-villager', ready: true }
]
);
assert.equal(village.player.textureKey, 'exploration-liu-bei');
assertNpcTextureKeys(village.npcs, {
'zhang-fei': 'exploration-zhang-fei',
'guan-yu': 'exploration-guan-yu',
'recruiting-clerk': 'exploration-zhuo-recruiting-clerk',
'market-villager': 'exploration-zhuo-villager'
}, 'prologue village');
assert.equal(village.viewport.width, desktopBrowserViewport.width);
assert.equal(village.viewport.height, desktopBrowserViewport.height);
assert.equal(village.movement.speed, 300);
assert.equal(village.interaction.radius, 122);
assert.equal(village.npcs.length, 4);
assert.equal(village.objectives.length, 4);
assert.equal(village.completedObjectiveIds.length, 0);
assert.equal(village.exitUnlocked, false);
assert.equal(village.dialogue.active, true, 'The first visit should open one short Liu Bei orientation line.');
assert.equal(village.dialogue.portraitVisible, true);
assert.equal(village.dialogue.portraitTextureKey, 'portrait-liu-bei-yellow-turban');
assertBoundsInsideViewport(village.player.bounds, 'initial player');
village.npcs.forEach((npc) => assertBoundsInsideViewport(npc.bounds, `NPC ${npc.id}`));
await page.mouse.click(960, 850);
await page.waitForFunction(() => window.__HEROS_DEBUG__?.village?.()?.dialogue?.active === false);
await captureStableScreenshot(page, 'dist/verification-prologue-village-initial.png');
const beforeMove = await readVillage(page);
await page.keyboard.down('d');
await page.waitForTimeout(430);
await page.keyboard.up('d');
await page.waitForTimeout(80);
const afterMove = await readVillage(page);
assert(
afterMove.player.x - beforeMove.player.x >= 85,
`Expected direct keyboard movement to advance Liu Bei: ${JSON.stringify({ before: beforeMove.player, after: afterMove.player })}`
);
assert(
Math.abs(afterMove.player.y - beforeMove.player.y) <= 3,
`Expected horizontal movement to retain Y: ${JSON.stringify({ before: beforeMove.player, after: afterMove.player })}`
);
assert.equal(afterMove.player.moving, false);
await page.keyboard.press('e');
await page.waitForTimeout(120);
assert.equal((await readVillage(page)).dialogue.active, false, 'Interaction outside the radius must not open dialogue.');
await teleportTo(page, 'guan-yu');
await page.keyboard.press('e');
await page.waitForFunction(() => (
window.__HEROS_DEBUG__?.village?.()?.dialogue?.sourceNpcId === 'guan-yu'
));
assert.equal(
(await readVillage(page)).dialogue.portraitVisible,
false,
'Narrative context should not impersonate a character portrait.'
);
assert.equal(
(await readVillage(page)).dialogue.portraitFrameVisible,
false,
'Narrative context must hide the empty portrait frame instead of resembling a missing asset.'
);
assert.equal(
(await readVillage(page)).dialogue.totalLines,
1,
'Guan Yu must remain a locked encounter until Liu Bei has met Zhang Fei.'
);
await advanceDialogueUntilClosed(page);
assert(
!(await readVillage(page)).completedObjectiveIds.includes('meet-guan-yu'),
'A locked Guan Yu interaction must not complete its objective.'
);
await teleportTo(page, 'zhang-fei');
await page.keyboard.press('e');
await page.waitForFunction(() => window.__HEROS_DEBUG__?.village?.()?.dialogue?.sourceNpcId === 'zhang-fei');
assert.equal((await readVillage(page)).dialogue.portraitVisible, false);
await page.mouse.click(960, 850);
await page.waitForFunction(() => (
window.__HEROS_DEBUG__?.village?.()?.dialogue?.speaker === '장비'
));
const zhangDialogue = await readVillage(page);
assert.equal(zhangDialogue.dialogue.portraitVisible, true);
assert.equal(zhangDialogue.dialogue.portraitTextureKey, 'portrait-zhang-fei-yellow-turban');
await captureStableScreenshot(page, 'dist/verification-prologue-village-active-dialogue.png');
const dialoguePlayerBefore = (await readVillage(page)).player;
await page.keyboard.down('ArrowRight');
await page.waitForTimeout(360);
await page.keyboard.up('ArrowRight');
const dialoguePlayerAfter = (await readVillage(page)).player;
assert(
Math.abs(dialoguePlayerAfter.x - dialoguePlayerBefore.x) < 1 &&
Math.abs(dialoguePlayerAfter.y - dialoguePlayerBefore.y) < 1,
'Movement must remain blocked while dialogue is open.'
);
await advanceDialogueUntilClosed(page);
village = await readVillage(page);
assert(village.completedObjectiveIds.includes('meet-zhang-fei'));
assert.equal(village.exitUnlocked, false);
const walkingZhangFei = villageNpc(village, 'zhang-fei');
assert.equal(walkingZhangFei.moving, true, 'Zhang Fei should visibly walk toward the tavern after joining.');
assert.match(walkingZhangFei.animationKey, /exploration-zhang-fei-walk-/);
assert(
walkingZhangFei.x > 360 && walkingZhangFei.x < 1000,
`Zhang Fei should move progressively instead of teleporting: ${JSON.stringify(walkingZhangFei)}`
);
const queuedVillagerTarget = villageNpc(village, 'market-villager');
const queuedVillagerAutoCount =
village.movement.autoInteraction.triggeredCount;
await page.mouse.click(
queuedVillagerTarget.x,
queuedVillagerTarget.y
);
await page.waitForFunction(() => {
const state = window.__HEROS_DEBUG__?.village?.();
return (
state?.movement?.queuedIntent?.kind === 'pointer' &&
state?.narrativeMovement?.playerInputDeferred === true
);
});
await waitForVillageNpcPosition(page, 'zhang-fei', { x: 1000, y: 540 });
await page.waitForFunction(() => {
const state = window.__HEROS_DEBUG__?.village?.();
return (
state?.movement?.appliedQueuedIntentCount >= 1 &&
state?.movement?.targetNpcId === 'market-villager'
);
});
await waitForVillageInteractionTarget(page, 'market-villager');
await page.waitForFunction(() => (
window.__HEROS_DEBUG__?.village?.()?.dialogue?.sourceNpcId ===
'market-villager'
));
village = await readVillage(page);
assert.equal(
village.movement.autoInteraction.triggeredCount,
queuedVillagerAutoCount + 1,
'Queued pointer navigation must open the villager dialogue exactly once on arrival.'
);
const protectedVillagerLineIndex = village.dialogue.lineIndex;
await page.mouse.click(960, 850);
await page.waitForTimeout(80);
village = await readVillage(page);
assert.equal(
village.dialogue.lineIndex,
protectedVillagerLineIndex,
'A follow-up click inside the arrival guard must not skip the first villager line.'
);
assert.equal(
village.dialogue.sourceNpcId,
'market-villager',
'The arrival guard must keep the newly opened villager dialogue active.'
);
const tavernZhangFei = villageNpc(village, 'zhang-fei');
assert.equal(tavernZhangFei.moving, false);
assert.equal(tavernZhangFei.animationKey, 'exploration-zhang-fei-idle-east');
await captureStableScreenshot(page, 'dist/verification-prologue-village-dialogue.png');
await page.waitForTimeout(160);
assert.equal(
(await readVillage(page)).movement.autoInteraction.triggeredCount,
queuedVillagerAutoCount + 1,
'Arrival must not retrigger the same villager dialogue on a later update.'
);
await advanceDialogueUntilClosed(page);
await teleportTo(page, 'make-oath');
await page.keyboard.press('e');
await page.waitForFunction(() => window.__HEROS_DEBUG__?.village?.()?.dialogue?.active === true);
const lockedOath = await readVillage(page);
assert.equal(lockedOath.navigationPending, false);
assert.equal(lockedOath.dialogue.totalLines, 1);
await advanceDialogueUntilClosed(page);
assert.equal((await readVillage(page)).exitUnlocked, false, 'The oath must stay locked before all preparation goals are complete.');
await page.reload({ waitUntil: 'domcontentloaded', timeout: 90000 });
await waitForDebugApi(page);
await page.waitForFunction(() => window.__HEROS_DEBUG__?.activeScenes?.().includes('TitleScene'));
await clickLegacyTitleControl(page, 962, 310);
await waitForVillageReady(page);
await page.waitForTimeout(380);
village = await readVillage(page);
assert(village.completedObjectiveIds.includes('meet-zhang-fei'), 'A completed NPC goal must survive reload and Continue.');
assert.equal(village.dialogue.active, false, 'The one-time orientation line must not replay on Continue.');
assertNpcPosition(village, 'zhang-fei', { x: 1000, y: 540 }, 'restored Zhang Fei');
await teleportTo(page, 'recruiting-clerk');
await page.keyboard.press('e');
await page.waitForFunction(() => (
window.__HEROS_DEBUG__?.village?.()?.dialogue?.sourceNpcId === 'recruiting-clerk'
));
assert.equal(
(await readVillage(page)).dialogue.portraitTextureKey,
'portrait-zhuo-recruiting-clerk-yellow-turban'
);
assert.equal(
(await readVillage(page)).dialogue.totalLines,
1,
'The recruiting clerk must remain locked until Guan Yu has joined.'
);
await advanceDialogueUntilClosed(page);
assert(
!(await readVillage(page)).completedObjectiveIds.includes('register-volunteers'),
'A locked recruiting interaction must not complete its objective.'
);
await teleportTo(page, 'guan-yu');
await page.keyboard.press('e');
await page.waitForFunction(() => (
window.__HEROS_DEBUG__?.village?.()?.dialogue?.sourceNpcId === 'guan-yu'
));
await advanceDialogueUntilClosed(page);
village = await readVillage(page);
const walkingGuanYu = villageNpc(village, 'guan-yu');
const walkingZhangToRecruitment = villageNpc(village, 'zhang-fei');
assert.equal(walkingGuanYu.moving, true, 'Guan Yu should walk toward the recruiting office after joining.');
assert.equal(walkingZhangToRecruitment.moving, true, 'Zhang Fei should accompany Guan Yu toward registration.');
assert.match(walkingGuanYu.animationKey, /exploration-guan-yu-walk-/);
assert.match(walkingZhangToRecruitment.animationKey, /exploration-zhang-fei-walk-/);
assert(
walkingGuanYu.x < 1110 && walkingGuanYu.x > 850,
`Guan Yu should move progressively instead of teleporting: ${JSON.stringify(walkingGuanYu)}`
);
assert(
walkingZhangToRecruitment.y > 540 && walkingZhangToRecruitment.y < 805,
`Zhang Fei should move progressively toward registration: ${JSON.stringify(walkingZhangToRecruitment)}`
);
const queuedClerkTarget = villageNpc(village, 'recruiting-clerk');
const queuedClerkAutoCount =
village.movement.autoInteraction.triggeredCount;
await page.mouse.click(queuedClerkTarget.x, queuedClerkTarget.y);
await page.waitForFunction(() => {
const state = window.__HEROS_DEBUG__?.village?.();
return (
state?.movement?.queuedIntent?.kind === 'pointer' &&
state?.narrativeMovement?.playerInputDeferred === true
);
});
await waitForVillageNarrativeMovement(page);
await page.waitForFunction(() => (
window.__HEROS_DEBUG__?.village?.()?.movement?.targetNpcId === 'recruiting-clerk'
));
await waitForVillageInteractionTarget(page, 'recruiting-clerk');
await page.waitForFunction(() => (
window.__HEROS_DEBUG__?.village?.()?.dialogue?.sourceNpcId === 'recruiting-clerk'
));
village = await readVillage(page);
assert.equal(
village.movement.autoInteraction.triggeredCount,
queuedClerkAutoCount + 1,
'Queued pointer navigation must open the recruiting dialogue exactly once.'
);
assertNpcPosition(village, 'guan-yu', { x: 850, y: 655 }, 'recruitment Guan Yu');
assertNpcPosition(village, 'zhang-fei', { x: 810, y: 805 }, 'recruitment Zhang Fei');
await advanceDialogueUntilClosed(page);
village = await readVillage(page);
assert.deepEqual(
[...village.completedObjectiveIds].sort(),
['register-volunteers', 'meet-guan-yu', 'meet-zhang-fei'].sort()
);
assert.equal(village.exitUnlocked, true);
assert.equal(village.oath.visible, true);
const walkingGuanToOath = villageNpc(village, 'guan-yu');
const walkingZhangToOath = villageNpc(village, 'zhang-fei');
assert.equal(walkingGuanToOath.moving, true, 'Guan Yu should walk to the oath grove after registration.');
assert.equal(walkingZhangToOath.moving, true, 'Zhang Fei should walk to the oath grove after registration.');
assert.match(walkingGuanToOath.animationKey, /exploration-guan-yu-walk-/);
assert.match(walkingZhangToOath.animationKey, /exploration-zhang-fei-walk-/);
await waitForVillageNarrativeMovement(page);
village = await readVillage(page);
assertNpcPosition(village, 'guan-yu', { x: 676, y: 437 }, 'oath Guan Yu');
assertNpcPosition(village, 'zhang-fei', { x: 884, y: 437 }, 'oath Zhang Fei');
await captureStableScreenshot(page, 'dist/verification-prologue-village-ready.png');
await teleportTo(page, 'make-oath');
const oathPlayerBefore = (await readVillage(page)).player;
await page.keyboard.press('e');
await page.waitForFunction(() => window.__HEROS_DEBUG__?.village?.()?.dialogue?.sourceNpcId === 'make-oath');
const oathDialogue = await readVillage(page);
assert.equal(oathDialogue.dialogue.totalLines, 4);
assert(
Math.abs(oathDialogue.player.x - oathPlayerBefore.x) < 1 &&
Math.abs(oathDialogue.player.y - oathPlayerBefore.y) < 1,
`Starting the oath should not teleport Liu Bei: ${JSON.stringify({
before: oathPlayerBefore,
after: oathDialogue.player
})}`
);
await advanceDialogueUntilClosed(page);
await waitForStoryReady(page);
const brotherhood = await readStory(page);
assert.equal(brotherhood.totalPages, 1);
assert.equal(brotherhood.currentPageId, 'militia-rises');
assert.equal(brotherhood.nextScene, 'PrologueMilitiaCampScene');
assert.equal(brotherhood.advanceHint, 'militia-camp-exploration');
assert.equal(brotherhood.advanceLabel, '의용군 막사로');
const savedBrotherhood = await readCampaignSave(page);
assert.equal(
savedBrotherhood.step,
'prologue-town',
'The campaign should remain resumable at the brotherhood bridge until the camp starts.'
);
assert(savedBrotherhood.completedTutorialIds.includes('prologue-village-complete'));
assert(!savedBrotherhood.completedTutorialIds.includes('prologue-camp-entered'));
await page.keyboard.press('Space');
await waitForMilitiaCampReady(page);
await page.waitForTimeout(380);
let militiaCamp = await readMilitiaCamp(page);
assert.equal(militiaCamp.campaignStep, 'prologue-camp');
assertExplorationBackground(militiaCamp.background, 'prologue-militia-camp-background');
assert.equal(militiaCamp.requiredTexturesReady, true);
assert.deepEqual(
militiaCamp.requiredTextures.map(({ key, ready }) => ({ key, ready })),
[
{ key: 'exploration-liu-bei', ready: true },
{ key: 'exploration-guan-yu', ready: true },
{ key: 'exploration-zhang-fei', ready: true },
{ key: 'exploration-zhuo-quartermaster', ready: true },
{ key: 'exploration-zou-jing', ready: true },
{ key: 'exploration-shu-infantry', ready: true }
]
);
assert.equal(militiaCamp.player.textureKey, 'exploration-liu-bei');
assertNpcTextureKeys(militiaCamp.npcs, {
'guan-yu': 'exploration-guan-yu',
'zhang-fei': 'exploration-zhang-fei',
quartermaster: 'exploration-zhuo-quartermaster',
'zou-jing': 'exploration-zou-jing',
'nervous-volunteer': 'exploration-shu-infantry'
}, 'prologue militia camp');
assert.equal(militiaCamp.viewport.width, desktopBrowserViewport.width);
assert.equal(militiaCamp.viewport.height, desktopBrowserViewport.height);
assert.equal(militiaCamp.movement.speed, 300);
assert.equal(militiaCamp.interaction.radius, 122);
assert.equal(militiaCamp.npcs.length, 5);
assert.equal(militiaCamp.objectives.length, 4);
assert.equal(militiaCamp.completedObjectiveIds.length, 0);
assert.deepEqual(
militiaCamp.preparationFeedback.map(({ id, completed, worldCueVisible }) => ({
id,
completed,
worldCueVisible
})),
[
{ id: 'review-scout-report', completed: false, worldCueVisible: false },
{ id: 'ready-vanguard', completed: false, worldCueVisible: false },
{ id: 'inspect-arms', completed: false, worldCueVisible: false }
]
);
assert.deepEqual(militiaCamp.optionalDialogues, [{
npcId: 'nervous-volunteer',
tutorialId: 'prologue-camp-reassure-volunteer',
completed: false
}]);
assert.equal(militiaCamp.exitUnlocked, false);
assert.equal(
militiaCamp.dialogue.active,
true,
'The first camp visit should explain why Liu Bei must inspect the camp.'
);
assert.equal(militiaCamp.dialogue.portraitVisible, true);
assert.equal(militiaCamp.dialogue.portraitTextureKey, 'portrait-liu-bei-yellow-turban');
assertBoundsInsideViewport(militiaCamp.player.bounds, 'initial militia camp player');
assertBoundsInsideViewport(militiaCamp.dialogue.bounds, 'initial militia camp dialogue');
militiaCamp.npcs.forEach((npc) => assertBoundsInsideViewport(npc.bounds, `camp NPC ${npc.id}`));
await advanceMilitiaCampDialogueUntilClosed(page);
await captureStableScreenshot(page, 'dist/verification-prologue-militia-camp-initial.png');
await page.keyboard.press('e');
await page.waitForTimeout(120);
assert.equal(
(await readMilitiaCamp(page)).dialogue.active,
false,
'Militia camp interaction outside the radius must not open dialogue.'
);
militiaCamp = await readMilitiaCamp(page);
const pointerZouJing = militiaCamp.npcs.find(
({ id }) => id === 'zou-jing'
);
assert(pointerZouJing, 'Zou Jing must be available as a pointer navigation target.');
const campAutoInteractionBefore =
militiaCamp.movement.autoInteraction.triggeredCount;
const campDetourCountBefore = militiaCamp.movement.detourCount;
await page.mouse.click(pointerZouJing.x, pointerZouJing.y);
await page.waitForFunction(() => {
const state = window.__HEROS_DEBUG__?.militiaCamp?.();
return (
state?.movement?.targetNpcId === 'zou-jing' &&
state?.movement?.detourCount > 0
);
});
await page.waitForFunction(() => (
window.__HEROS_DEBUG__?.militiaCamp?.()?.dialogue?.sourceNpcId ===
'zou-jing'
));
militiaCamp = await readMilitiaCamp(page);
assert(
militiaCamp.movement.detourCount > campDetourCountBefore,
'Clicking Zou Jing from the entrance must route around the central fire.'
);
assert.equal(
militiaCamp.movement.autoInteraction.triggeredCount,
campAutoInteractionBefore + 1,
'Militia camp pointer arrival must open dialogue exactly once.'
);
const protectedCampLineIndex = militiaCamp.dialogue.lineIndex;
await page.mouse.click(960, 850);
await page.waitForTimeout(80);
militiaCamp = await readMilitiaCamp(page);
assert.equal(
militiaCamp.dialogue.lineIndex,
protectedCampLineIndex,
'A follow-up click inside the arrival guard must not skip Zou Jings first line.'
);
assert.equal(
militiaCamp.dialogue.sourceNpcId,
'zou-jing',
'The arrival guard must keep Zou Jings newly opened dialogue active.'
);
await page.waitForTimeout(160);
assert.equal(
(await readMilitiaCamp(page)).movement.autoInteraction.triggeredCount,
campAutoInteractionBefore + 1,
'Militia camp arrival must not retrigger dialogue on a later update.'
);
await advanceMilitiaCampDialogueUntilClosed(page);
await page.reload({ waitUntil: 'domcontentloaded', timeout: 90000 });
await waitForDebugApi(page);
await page.waitForFunction(() =>
window.__HEROS_DEBUG__?.activeScenes?.().includes('TitleScene')
);
await clickLegacyTitleControl(page, 962, 310);
await waitForMilitiaCampReady(page);
await page.waitForTimeout(380);
militiaCamp = await readMilitiaCamp(page);
assert.equal(
militiaCamp.dialogue.active,
false,
'The pointer-route smoke reload must not replay the one-time camp introduction.'
);
const campPlayerBeforeMove = militiaCamp.player;
await page.keyboard.down('ArrowUp');
await page.waitForTimeout(360);
await page.keyboard.up('ArrowUp');
await page.keyboard.down('ArrowLeft');
await page.waitForTimeout(980);
await page.keyboard.up('ArrowLeft');
await page.waitForTimeout(80);
const campAfterMove = await readMilitiaCamp(page);
assert(
campPlayerBeforeMove.y - campAfterMove.player.y >= 70 &&
campPlayerBeforeMove.x - campAfterMove.player.x >= 220,
`Expected keyboard traversal through the militia camp: ${JSON.stringify({
before: campPlayerBeforeMove,
after: campAfterMove.player
})}`
);
assert.equal(
campAfterMove.interaction.targetId,
'quartermaster',
`Expected keyboard traversal to reach the quartermaster: ${JSON.stringify(campAfterMove.interaction)}`
);
assert.equal(campAfterMove.interaction.canInteract, true);
assertBoundsInsideViewport(campAfterMove.interaction.promptBounds, 'militia camp interaction prompt');
await page.keyboard.press('e');
await page.waitForFunction(() => (
window.__HEROS_DEBUG__?.militiaCamp?.()?.dialogue?.sourceNpcId === 'quartermaster'
));
assert.equal(
(await readMilitiaCamp(page)).dialogue.portraitTextureKey,
'portrait-zhuo-quartermaster-yellow-turban'
);
const campDialoguePlayerBefore = (await readMilitiaCamp(page)).player;
assertBoundsInsideViewport(
(await readMilitiaCamp(page)).dialogue.bounds,
'quartermaster dialogue'
);
await page.keyboard.down('ArrowRight');
await page.waitForTimeout(360);
await page.keyboard.up('ArrowRight');
const campDialoguePlayerAfter = (await readMilitiaCamp(page)).player;
assert(
Math.abs(campDialoguePlayerAfter.x - campDialoguePlayerBefore.x) < 1 &&
Math.abs(campDialoguePlayerAfter.y - campDialoguePlayerBefore.y) < 1,
'Militia camp movement must remain blocked while dialogue is open.'
);
await advanceMilitiaCampDialogueUntilClosed(page);
militiaCamp = await readMilitiaCamp(page);
assert(militiaCamp.completedObjectiveIds.includes('inspect-arms'));
const supplyFeedback = militiaCamp.preparationFeedback.find(({ id }) => id === 'inspect-arms');
assert.equal(supplyFeedback?.worldCueVisible, true);
assertBoundsInsideCampMap(supplyFeedback?.worldCueBounds, 'completed supply world cue');
assert(
militiaCamp.preparationFeedback
.filter(({ id }) => id !== 'inspect-arms')
.every(({ worldCueVisible }) => worldCueVisible === false),
'Completing supplies must not reveal unrelated camp preparation cues.'
);
await teleportMilitiaCampTo(page, 'zou-jing');
await page.keyboard.press('e');
await page.waitForFunction(() => (
window.__HEROS_DEBUG__?.militiaCamp?.()?.dialogue?.sourceNpcId === 'zou-jing'
));
assert.equal((await readMilitiaCamp(page)).dialogue.totalLines, 1);
await advanceMilitiaCampDialogueUntilClosed(page);
assert.equal(
(await readMilitiaCamp(page)).exitUnlocked,
false,
'Zou Jing must not release the sortie before every camp inspection is complete.'
);
await teleportMilitiaCampTo(page, 'nervous-volunteer');
await page.keyboard.press('e');
await page.waitForFunction(() => (
window.__HEROS_DEBUG__?.militiaCamp?.()?.dialogue?.sourceNpcId === 'nervous-volunteer'
));
let volunteerDialogue = await readMilitiaCamp(page);
assert.equal(volunteerDialogue.dialogue.totalLines, 2);
assert.equal(
volunteerDialogue.dialogue.portraitTextureKey,
'portrait-zhuo-young-volunteer-yellow-turban'
);
assertBoundsInsideViewport(volunteerDialogue.dialogue.bounds, 'nervous volunteer dialogue');
assert.equal(volunteerDialogue.optionalDialogues[0].completed, false);
assert(
!(await readCampaignSave(page)).completedTutorialIds.includes('prologue-camp-reassure-volunteer'),
'Opening the optional conversation must not record a promise before it is finished.'
);
await page.mouse.click(960, 850);
await page.waitForFunction(() => (
window.__HEROS_DEBUG__?.militiaCamp?.()?.dialogue?.speaker === '유비'
));
assert(
!(await readCampaignSave(page)).completedTutorialIds.includes('prologue-camp-reassure-volunteer'),
'The optional promise must wait until the final line closes.'
);
await captureStableScreenshot(page, 'dist/verification-prologue-militia-camp-volunteer-promise.png');
await advanceMilitiaCampDialogueUntilClosed(page);
volunteerDialogue = await readMilitiaCamp(page);
assert.equal(volunteerDialogue.optionalDialogues[0].completed, true);
assert.deepEqual(
volunteerDialogue.completedObjectiveIds,
['inspect-arms'],
'The optional promise must not change the required preparation count.'
);
assert.equal(volunteerDialogue.exitUnlocked, false);
const savedVolunteerPromise = await readCampaignSaves(page);
assert(
savedVolunteerPromise.current.completedTutorialIds.includes('prologue-camp-reassure-volunteer') &&
savedVolunteerPromise.slot1.completedTutorialIds.includes('prologue-camp-reassure-volunteer'),
'The completed optional promise must persist to both the current save and active slot.'
);
await page.reload({ waitUntil: 'domcontentloaded', timeout: 90000 });
await waitForDebugApi(page);
await page.waitForFunction(() => window.__HEROS_DEBUG__?.activeScenes?.().includes('TitleScene'));
await clickLegacyTitleControl(page, 962, 310);
await waitForMilitiaCampReady(page);
await page.waitForTimeout(380);
militiaCamp = await readMilitiaCamp(page);
assert(
militiaCamp.completedObjectiveIds.includes('inspect-arms'),
'A completed militia camp inspection must survive reload and Continue.'
);
assert.equal(
militiaCamp.preparationFeedback.find(({ id }) => id === 'inspect-arms')?.worldCueVisible,
true,
'A completed supply world cue must survive reload and Continue.'
);
assert.equal(
militiaCamp.dialogue.active,
false,
'The one-time militia camp orientation must not replay on Continue.'
);
assert.equal(
militiaCamp.optionalDialogues[0].completed,
true,
'The optional volunteer promise must survive reload and Continue.'
);
await teleportMilitiaCampTo(page, 'nervous-volunteer');
await page.keyboard.press('e');
await page.waitForFunction(() => (
window.__HEROS_DEBUG__?.militiaCamp?.()?.dialogue?.sourceNpcId === 'nervous-volunteer'
));
assert.equal(
(await readMilitiaCamp(page)).dialogue.totalLines,
1,
'A returning player should hear the volunteer remember the promise instead of repeating it.'
);
await advanceMilitiaCampDialogueUntilClosed(page);
await interactWithMilitiaCampNpc(page, 'guan-yu');
await interactWithMilitiaCampNpc(page, 'zhang-fei');
militiaCamp = await readMilitiaCamp(page);
assert.deepEqual(
[...militiaCamp.completedObjectiveIds].sort(),
['inspect-arms', 'ready-vanguard', 'review-scout-report'].sort()
);
assert(
militiaCamp.preparationFeedback.every(({ completed, worldCueVisible, worldCueBounds }) => {
if (!completed || !worldCueVisible) {
return false;
}
assertBoundsInsideCampMap(worldCueBounds, 'completed camp preparation cue');
return true;
}),
'Every completed preparation must remain visible in the camp world.'
);
assert.equal(
militiaCamp.preparationFeedback.find(({ id }) => id === 'ready-vanguard')?.ambientActorCount,
4,
'Zhang Fei preparation should visibly line up four non-interactive volunteers.'
);
assert.equal(militiaCamp.npcs.length, 5, 'Ambient formation actors must not become dialogue targets.');
assert.equal(militiaCamp.exitUnlocked, true);
await captureStableScreenshot(page, 'dist/verification-prologue-militia-camp-ready.png');
await teleportMilitiaCampTo(page, 'zou-jing');
await page.keyboard.press('e');
await page.waitForFunction(() => (
window.__HEROS_DEBUG__?.militiaCamp?.()?.dialogue?.sourceNpcId === 'zou-jing'
));
const commandCamp = await readMilitiaCamp(page);
assert.equal(commandCamp.dialogue.totalLines, 1);
assert.equal(commandCamp.dialogue.portraitTextureKey, 'portrait-zou-jing-yellow-turban');
assert.deepEqual(
commandCamp.npcs
.filter((npc) => ['guan-yu', 'zhang-fei'].includes(npc.id))
.map((npc) => ({ id: npc.id, x: npc.x, y: npc.y })),
[
{ id: 'guan-yu', x: 650, y: 420 },
{ id: 'zhang-fei', x: 890, y: 420 }
],
'Guan Yu and Zhang Fei should visibly gather at the command tent before speaking.'
);
assertBoundsInsideViewport(commandCamp.dialogue.bounds, 'final command dialogue');
await captureStableScreenshot(page, 'dist/verification-prologue-militia-camp-command.png');
await advanceMilitiaCampDialogueUntilClosed(page);
await page.waitForFunction(() => (
window.__HEROS_DEBUG__?.militiaCamp?.()?.commandChoice?.open === true &&
window.__HEROS_DEBUG__?.militiaCamp?.()?.commandChoice?.ready === true
));
let commandChoice = (await readMilitiaCamp(page)).commandChoice;
assert.equal(commandChoice.open, true);
assert.equal(commandChoice.selectedId, null);
assert.equal(commandChoice.pendingId, null);
assert.equal(commandChoice.confirmEnabled, false);
assert.deepEqual(
commandChoice.cards.map((card) => card.orderId),
['elite', 'mobile', 'siege']
);
assertBoundsInsideViewport(commandChoice.panelBounds, 'first command panel');
commandChoice.cards.forEach((card) => {
assert(card.title.length > 0);
assert(card.condition.length > 0);
assert(card.rewardGold > 0);
assert(card.rewardItems.length > 0);
assertBoundsInsideViewport(card.bounds, `first command card ${card.orderId}`);
});
assertBoundsInsideViewport(commandChoice.confirmBounds, 'first command confirm button');
assertBoundsDoNotOverlap(
commandChoice.cards.map((card) => ({ label: card.orderId, bounds: card.bounds })),
'first command cards'
);
await page.keyboard.press('1');
await page.waitForFunction(() => (
window.__HEROS_DEBUG__?.militiaCamp?.()?.commandChoice?.pendingId === 'elite'
));
commandChoice = (await readMilitiaCamp(page)).commandChoice;
assert.equal(commandChoice.selectedId, null, 'Previewing a command must not persist it before confirmation.');
assert.equal(commandChoice.confirmEnabled, true);
await page.waitForTimeout(160);
await page.keyboard.down('2');
await page.waitForTimeout(80);
await page.keyboard.up('2');
await page.waitForFunction(() => (
window.__HEROS_DEBUG__?.militiaCamp?.()?.commandChoice?.pendingId === 'mobile'
));
commandChoice = (await readMilitiaCamp(page)).commandChoice;
assert.equal(
commandChoice.cards.find((card) => card.orderId === 'mobile')?.pending,
true,
'The player must be able to change the previewed command before confirmation.'
);
const siegeCardBounds = commandChoice.cards.find((card) => card.orderId === 'siege')?.bounds;
assert(siegeCardBounds, 'The siege command card bounds are required for pointer selection.');
await page.mouse.click(
siegeCardBounds.x + siegeCardBounds.width / 2,
siegeCardBounds.y + siegeCardBounds.height / 2
);
await page.waitForFunction(() => (
window.__HEROS_DEBUG__?.militiaCamp?.()?.commandChoice?.pendingId === 'siege'
));
const mobileCardBounds = commandChoice.cards.find((card) => card.orderId === 'mobile')?.bounds;
assert(mobileCardBounds, 'The mobile command card bounds are required for pointer selection.');
await page.mouse.click(
mobileCardBounds.x + mobileCardBounds.width / 2,
mobileCardBounds.y + mobileCardBounds.height / 2
);
await page.waitForFunction(() => (
window.__HEROS_DEBUG__?.militiaCamp?.()?.commandChoice?.pendingId === 'mobile'
));
await captureStableScreenshot(page, 'dist/verification-prologue-militia-camp-command-choice.png');
await page.keyboard.press('Enter');
await page.waitForFunction(() => (
window.__HEROS_DEBUG__?.militiaCamp?.()?.dialogue?.sourceNpcId === 'first-command-mobile'
));
const commandReaction = await readMilitiaCamp(page);
assert.equal(commandReaction.commandChoice.open, false);
assert.equal(commandReaction.dialogue.totalLines, 2);
assert.equal(commandReaction.dialogue.portraitTextureKey, 'portrait-zhang-fei-yellow-turban');
const savedCommand = await readCampaignSave(page);
assert.deepEqual(savedCommand.sortieOrderSelection, {
battleId: 'first-battle-zhuo-commandery',
orderId: 'mobile'
});
await captureStableScreenshot(page, 'dist/verification-prologue-militia-camp-command-reaction.png');
await advanceMilitiaCampDialogueUntilClosed(page);
await waitForStoryReady(page);
const departure = await readStory(page);
assert.equal(departure.totalPages, 2);
assert.equal(departure.currentPageId, 'first-sortie');
assert.equal(departure.nextScene, 'BattleScene');
const savedDeparture = await readCampaignSave(page);
assert.equal(
savedDeparture.step,
'prologue-camp',
'The campaign should remain resumable at the departure story until BattleScene starts.'
);
assert(savedDeparture.completedTutorialIds.includes('prologue-camp-complete'));
assert(!savedDeparture.completedTutorialIds.includes('first-battle-basic-controls'));
assert.deepEqual(savedDeparture.sortieOrderSelection, {
battleId: 'first-battle-zhuo-commandery',
orderId: 'mobile'
});
await page.mouse.click(960, 850);
await page.waitForFunction(() => {
const scene = window.__HEROS_DEBUG__?.scene('StoryScene');
return scene?.getDebugState?.()?.pageIndex === 1 && scene?.transitioning === false;
});
await page.keyboard.press('Space');
await page.waitForFunction(() => {
const battle = window.__HEROS_DEBUG__?.battle?.();
return (
window.__HEROS_DEBUG__?.activeScenes?.().includes('BattleScene') &&
battle?.battleId === 'first-battle-zhuo-commandery' &&
battle?.phase === 'deployment' &&
battle?.mapBackgroundReady === true
);
}, undefined, { timeout: 90000 });
const battleSave = await readCampaignSave(page);
assert.equal(battleSave.step, 'first-battle');
assert(!battleSave.completedTutorialIds.includes('first-battle-basic-controls'));
const firstBattle = await page.evaluate(() => window.__HEROS_DEBUG__?.battle?.());
assert.equal(firstBattle.sortieOrder.selectedId, 'mobile');
assert.equal(firstBattle.sortieOrder.live.orderId, 'mobile');
assert.equal(firstBattle.firstBattlePreparation.eligible, true);
assert.equal(firstBattle.firstBattlePreparation.allComplete, true);
assert.equal(firstBattle.firstBattlePreparation.completedCount, 3);
assert.equal(firstBattle.firstBattlePreparation.summaryVisible, true);
assert.match(
firstBattle.battleHud.deploymentPanel.subtitleText,
/막사 준비 3\/3/,
'The deployment subtitle should retain the completed camp preparation summary.'
);
assert.match(
firstBattle.battleHud.deploymentPanel.subtitleText,
/첫 군령/,
'The preparation summary must not replace the first command selected in camp.'
);
assertBoundsInsideViewport(
firstBattle.firstBattlePreparation.summaryBounds,
'first-battle preparation summary'
);
assert.deepEqual(
firstBattle.firstBattlePreparation.callbacks.map(({ callback, completed }) => ({
callback,
completed
})),
[
{ callback: 'enemy-intel', completed: true },
{ callback: 'deployment-flex', completed: true },
{ callback: 'field-salve', completed: true }
]
);
assert.equal(firstBattle.firstBattlePreparation.enemyIntel.visibleDuringDeployment, true);
assert(firstBattle.firstBattlePreparation.enemyIntel.forecastCount > 0);
assert(firstBattle.firstBattlePreparation.enemyIntel.visualCount > 0);
assert.deepEqual(firstBattle.firstBattlePreparation.deploymentFlex, {
enabled: true,
baseReserveSlotCount: 2,
bonusReserveSlotCount: 2,
activeReserveSlotCount: 4
});
assert.deepEqual(firstBattle.firstBattlePreparation.fieldSalve, {
enabled: true,
carrierUnitId: 'guan-yu',
itemId: 'salve',
count: 1
});
assert.equal(firstBattle.itemStocks['guan-yu'].salve, 1);
assert.deepEqual(firstBattle.firstBattleNarrativeMemory.volunteerPromise, {
completedInCamp: true,
eligible: true,
eventKey: 'first-engagement',
line: '젊은 의병 · 말씀대로 곁의 동료와 보조를 맞추겠습니다.',
eventState: 'waiting',
bannerBounds: null
});
await captureStableScreenshot(page, 'dist/verification-first-battle-preparation.png');
const triggeredFirstEngagement = await page.evaluate(() => {
const scene = window.__HEROS_GAME__?.scene.getScene('BattleScene');
return scene?.debugTriggerFirstEngagementEvent?.();
});
assert.equal(triggeredFirstEngagement, true);
await page.waitForFunction(() => (
window.__HEROS_DEBUG__?.battle?.()?.activeBattleEvent?.key === 'first-engagement'
));
const volunteerEngagement = await page.evaluate(() => window.__HEROS_DEBUG__?.battle?.());
assert.equal(volunteerEngagement.activeBattleEvent.lines.length, 3);
assert.equal(
volunteerEngagement.activeBattleEvent.lines[2],
volunteerEngagement.firstBattleNarrativeMemory.volunteerPromise.line
);
assert.equal(
volunteerEngagement.firstBattleNarrativeMemory.volunteerPromise.eventState,
'active'
);
assertBoundsInsideViewport(
volunteerEngagement.firstBattleNarrativeMemory.volunteerPromise.bannerBounds,
'first-engagement volunteer memory banner'
);
await captureStableScreenshot(page, 'dist/verification-first-battle-volunteer-memory.png');
await page.waitForFunction(() => (
window.__HEROS_DEBUG__?.battle?.()?.firstBattleNarrativeMemory?.volunteerPromise?.eventState === 'completed'
), undefined, { timeout: 10000 });
await page.evaluate(() => {
const scene = window.__HEROS_GAME__?.scene.getScene('BattleScene');
scene?.saveBattleState?.(1);
scene?.loadBattleState?.(1);
});
await page.waitForFunction(
() => window.__HEROS_DEBUG__?.battle?.()?.firstBattlePreparation?.fieldSalve?.count === 1,
undefined,
{ timeout: 30000 }
);
const resumedFirstBattle = await page.evaluate(() => window.__HEROS_DEBUG__?.battle?.());
assert.equal(
resumedFirstBattle.firstBattlePreparation.fieldSalve.count,
1,
'Saving and resuming the first battle must not grant the preparation salve twice.'
);
assert.equal(resumedFirstBattle.itemStocks['guan-yu'].salve, 1);
assert.equal(
resumedFirstBattle.firstBattleNarrativeMemory.volunteerPromise.eventState,
'completed',
'Saving and resuming must not replay an already presented first-engagement memory.'
);
const duplicateEngagement = await page.evaluate(() => {
const scene = window.__HEROS_GAME__?.scene.getScene('BattleScene');
return scene?.debugTriggerFirstEngagementEvent?.();
});
assert.equal(duplicateEngagement, false);
assert.equal(
resumedFirstBattle.triggeredBattleEvents.filter((id) => id === 'first-engagement').length,
1
);
await page.evaluate(() => {
const scene = window.__HEROS_GAME__?.scene.getScene('BattleScene');
scene?.scene.restart({ battleId: 'second-battle-yellow-turban-pursuit' });
});
await page.waitForFunction(() => {
const battle = window.__HEROS_DEBUG__?.battle?.();
return (
battle?.battleId === 'second-battle-yellow-turban-pursuit' &&
battle?.mapBackgroundReady === true
);
}, undefined, { timeout: 90000 });
const secondBattle = await page.evaluate(() => window.__HEROS_DEBUG__?.battle?.());
assert.equal(secondBattle.firstBattlePreparation.eligible, false);
assert.equal(secondBattle.firstBattlePreparation.completedCount, 0);
assert(
secondBattle.firstBattlePreparation.callbacks.every(({ completed }) => completed === false),
'Militia-camp preparation callbacks must not leak into later battles.'
);
assert.equal(secondBattle.firstBattleNarrativeMemory.volunteerPromise.completedInCamp, true);
assert.equal(secondBattle.firstBattleNarrativeMemory.volunteerPromise.eligible, false);
assert.equal(secondBattle.firstBattleNarrativeMemory.volunteerPromise.line, null);
const secondBattleEngagementTriggered = await page.evaluate(() => {
const scene = window.__HEROS_GAME__?.scene.getScene('BattleScene');
return scene?.debugTriggerFirstEngagementEvent?.();
});
assert.equal(secondBattleEngagementTriggered, true);
await page.waitForFunction(() => (
window.__HEROS_DEBUG__?.battle?.()?.activeBattleEvent?.key === 'first-engagement'
));
const secondBattleEngagement = await page.evaluate(() => window.__HEROS_DEBUG__?.battle?.());
assert.equal(
secondBattleEngagement.activeBattleEvent.lines.length,
2,
'The camp promise must not leak into a later battle that reuses the first-engagement event.'
);
assert(
secondBattleEngagement.activeBattleEvent.lines.every((line) => !line.includes('젊은 의병')),
'Later first-engagement text must not mention the prologue volunteer.'
);
await seedLegacyCompletedVillageSave(page, battleSave);
await page.reload({ waitUntil: 'domcontentloaded', timeout: 90000 });
await waitForDebugApi(page);
await page.waitForFunction(() => window.__HEROS_DEBUG__?.activeScenes?.().includes('TitleScene'));
await clickLegacyTitleControl(page, 962, 310);
await waitForStoryReady(page);
const legacyBridge = await readStory(page);
assert.equal(legacyBridge.currentPageId, 'militia-rises');
assert.equal(legacyBridge.nextScene, 'PrologueMilitiaCampScene');
const normalizedLegacySave = await readCampaignSave(page);
assert.equal(normalizedLegacySave.step, 'prologue-town');
assert(normalizedLegacySave.completedTutorialIds.includes('prologue-village-check-supplies'));
assert(normalizedLegacySave.completedTutorialIds.includes('prologue-village-complete'));
assert.deepEqual(pageErrors, [], `Expected no browser page errors: ${JSON.stringify(pageErrors)}`);
assert.deepEqual(
consoleErrors.filter((message) => !message.includes('The AudioContext was not allowed to start')),
[],
`Expected no browser console errors: ${JSON.stringify(consoleErrors)}`
);
console.log(
`Verified the playable prologue village and militia camp at ${desktopBrowserViewport.width}x${desktopBrowserViewport.height}, ` +
`DPR ${desktopBrowserDeviceScaleFactor}: sequential meetings, direct movement, obstacle detours, single-fire arrival dialogue, ` +
'autosave/reload, legacy-save routing, locked oath and command, camp preparation, optional promise memory, ' +
'departure story, and first deployment.'
);
} finally {
await browser?.close();
if (serverProcess && !serverProcess.killed) {
serverProcess.kill();
}
}
function withDebugOptions(url) {
const parsed = new URL(url);
parsed.searchParams.set('debug', '1');
parsed.searchParams.set('renderer', 'canvas');
return parsed.toString();
}
async function verifyReducedMotionExploration(page) {
await page.evaluate(() => {
window.localStorage.setItem(
'heros-web:visual-motion-mode',
'reduced'
);
});
await page.evaluate(async () => {
await window.__HEROS_DEBUG__?.goToVillage();
});
await waitForVillageReady(page);
await page.waitForTimeout(380);
let village = await readVillage(page);
assert.equal(
village.visualMotionReduced,
true,
'The playable village must honor the reduced-motion preference.'
);
assert.equal(
village.player.animationPlaying,
false,
'Reduced motion must keep the idle player on a stable directional frame.'
);
assert(
village.npcs.every((npc) => npc.animationPlaying === false),
`Reduced motion must freeze NPC idle loops: ${JSON.stringify(village.npcs)}`
);
await advanceDialogueUntilClosed(page);
await page.keyboard.down('d');
try {
await page.waitForFunction(() => {
const player = window.__HEROS_DEBUG__?.village?.()?.player;
return (
player?.moving === true &&
player?.animationPlaying === true &&
player?.animationKey?.includes('-walk-')
);
});
} finally {
await page.keyboard.up('d');
}
await page.waitForFunction(() => {
const player = window.__HEROS_DEBUG__?.village?.()?.player;
return player?.moving === false && player?.animationPlaying === false;
});
village = await readVillage(page);
assert.equal(
village.player.animationPlaying,
false,
'Reduced motion must return the player to a stable frame after walking.'
);
}
async function waitForDebugApi(page) {
await page.waitForFunction(() => (
document.querySelector('canvas') !== null &&
window.__HEROS_DEBUG__ !== undefined
), undefined, { timeout: 90000 });
}
async function waitForTitle(page) {
await page.waitForFunction(() => window.__HEROS_DEBUG__?.activeScenes?.().includes('TitleScene'), undefined, {
timeout: 90000
});
}
async function clickLegacyTitleControl(page, x, y) {
await page.mouse.click(x * 1.5, y * 1.5);
}
async function waitForStoryReady(page) {
try {
await page.waitForFunction(() => {
const story = window.__HEROS_DEBUG__?.scene('StoryScene')?.getDebugState?.();
return window.__HEROS_DEBUG__?.activeScenes?.().includes('StoryScene') && story?.ready === true;
}, undefined, { timeout: 90000 });
} catch (error) {
const diagnostic = await page.evaluate(() => ({
activeScenes: window.__HEROS_DEBUG__?.activeScenes?.() ?? [],
story: window.__HEROS_DEBUG__?.scene('StoryScene')?.getDebugState?.() ?? null,
militiaCamp: window.__HEROS_DEBUG__?.militiaCamp?.() ?? null
}));
throw new Error(`Story scene did not become ready: ${JSON.stringify(diagnostic)}`, { cause: error });
}
}
async function waitForVillageReady(page) {
try {
await page.waitForFunction(() => {
const village = window.__HEROS_DEBUG__?.village?.();
return (
window.__HEROS_DEBUG__?.activeScenes?.().includes('PrologueVillageScene') &&
village?.scene === 'PrologueVillageScene' &&
village?.ready === true
);
}, undefined, { timeout: 30000 });
} catch (error) {
const diagnostic = await page.evaluate(() => ({
activeScenes: window.__HEROS_DEBUG__?.activeScenes?.() ?? [],
village: window.__HEROS_DEBUG__?.village?.() ?? null,
story: window.__HEROS_DEBUG__?.scene('StoryScene')?.getDebugState?.() ?? null
}));
throw new Error(`Playable village did not become ready: ${JSON.stringify(diagnostic)}`, { cause: error });
}
}
async function waitForMilitiaCampReady(page) {
try {
await page.waitForFunction(() => {
const militiaCamp = window.__HEROS_DEBUG__?.militiaCamp?.();
return (
window.__HEROS_DEBUG__?.activeScenes?.().includes('PrologueMilitiaCampScene') &&
militiaCamp?.scene === 'PrologueMilitiaCampScene' &&
militiaCamp?.ready === true
);
}, undefined, { timeout: 30000 });
} catch (error) {
const diagnostic = await page.evaluate(() => ({
activeScenes: window.__HEROS_DEBUG__?.activeScenes?.() ?? [],
militiaCamp: window.__HEROS_DEBUG__?.militiaCamp?.() ?? null,
story: window.__HEROS_DEBUG__?.scene('StoryScene')?.getDebugState?.() ?? null
}));
throw new Error(`Playable militia camp did not become ready: ${JSON.stringify(diagnostic)}`, {
cause: error
});
}
}
async function readStory(page) {
return page.evaluate(() => window.__HEROS_DEBUG__?.scene('StoryScene')?.getDebugState?.());
}
async function readVillage(page) {
return page.evaluate(() => window.__HEROS_DEBUG__?.village?.());
}
async function readMilitiaCamp(page) {
return page.evaluate(() => window.__HEROS_DEBUG__?.militiaCamp?.());
}
async function teleportTo(page, targetId) {
const teleported = await page.evaluate((id) => {
const scene = window.__HEROS_GAME__?.scene.getScene('PrologueVillageScene');
return scene?.debugTeleportTo?.(id) ?? false;
}, targetId);
assert.equal(teleported, true, `Expected debug teleport to ${targetId}.`);
await page.waitForTimeout(80);
}
async function teleportMilitiaCampTo(page, targetId) {
const teleported = await page.evaluate((id) => {
const scene = window.__HEROS_GAME__?.scene.getScene('PrologueMilitiaCampScene');
return scene?.debugTeleportTo?.(id) ?? false;
}, targetId);
assert.equal(teleported, true, `Expected militia camp debug teleport to ${targetId}.`);
await page.waitForTimeout(80);
}
async function interactWithMilitiaCampNpc(page, npcId) {
await teleportMilitiaCampTo(page, npcId);
await page.keyboard.press('e');
await page.waitForFunction((expectedNpcId) => (
window.__HEROS_DEBUG__?.militiaCamp?.()?.dialogue?.sourceNpcId === expectedNpcId
), npcId);
await advanceMilitiaCampDialogueUntilClosed(page);
}
function villageNpc(village, npcId) {
const npc = village?.npcs?.find((entry) => entry.id === npcId);
assert(npc, `Expected village NPC ${npcId}: ${JSON.stringify(village?.npcs)}`);
return npc;
}
function assertNpcPosition(village, npcId, expected, label) {
const npc = villageNpc(village, npcId);
assert(
Math.abs(npc.x - expected.x) <= 1 && Math.abs(npc.y - expected.y) <= 1,
`${label}: expected ${JSON.stringify(expected)}, received ${JSON.stringify(npc)}`
);
assert.equal(npc.moving, false, `${label} should be idle after reaching the narrative position.`);
}
async function waitForVillageNpcPosition(page, npcId, expected) {
await page.waitForFunction(({ id, x, y }) => {
const village = window.__HEROS_DEBUG__?.village?.();
const npc = village?.npcs?.find((entry) => entry.id === id);
return Boolean(
npc &&
npc.moving === false &&
Math.abs(npc.x - x) <= 1 &&
Math.abs(npc.y - y) <= 1
);
}, { id: npcId, ...expected }, { timeout: 6000 });
}
async function waitForVillageNarrativeMovement(page) {
await page.waitForFunction(() => {
const movement = window.__HEROS_DEBUG__?.village?.()?.narrativeMovement;
return movement && movement.movingNpcIds.length === 0 && movement.oathGatherPending === false;
}, undefined, { timeout: 6000 });
}
async function waitForVillageInteractionTarget(page, targetId) {
await page.waitForFunction((expectedTargetId) => {
const village = window.__HEROS_DEBUG__?.village?.();
return (
village?.interaction?.targetId === expectedTargetId &&
village?.interaction?.canInteract === true &&
village?.movement?.target === null
);
}, targetId, { timeout: 9000 });
}
async function advanceDialogueUntilClosed(page) {
for (let attempt = 0; attempt < 12; attempt += 1) {
const active = (await readVillage(page))?.dialogue?.active;
if (!active) {
return;
}
await page.mouse.click(960, 850);
await page.waitForTimeout(130);
}
throw new Error(`Dialogue did not close: ${JSON.stringify(await readVillage(page))}`);
}
async function advanceMilitiaCampDialogueUntilClosed(page) {
for (let attempt = 0; attempt < 12; attempt += 1) {
const active = (await readMilitiaCamp(page))?.dialogue?.active;
if (!active) {
return;
}
await page.mouse.click(960, 850);
await page.waitForTimeout(130);
}
throw new Error(`Militia camp dialogue did not close: ${JSON.stringify(await readMilitiaCamp(page))}`);
}
async function seedLegacyCompletedVillageSave(page, sourceState) {
await page.evaluate((state) => {
const legacyState = {
...state,
updatedAt: new Date().toISOString(),
step: 'prologue-town',
roster: [],
bonds: [],
inventory: {},
selectedSortieUnitIds: [],
sortieFormationAssignments: {},
sortieItemAssignments: {},
battleHistory: {},
latestBattleId: undefined,
latestBattleReport: undefined,
completedTutorialIds: [
'prologue-village-entered',
'prologue-village-meet-zhang-fei',
'prologue-village-meet-guan-yu',
'prologue-village-check-supplies',
'prologue-village-complete'
]
};
const serialized = JSON.stringify(legacyState);
window.localStorage.setItem('heros-web:campaign-state', serialized);
window.localStorage.setItem('heros-web:campaign-state:slot-1', serialized);
for (const key of Object.keys(window.localStorage)) {
if (key.startsWith('heros-web:battle:') || key === 'heros-web:first-battle-state') {
window.localStorage.removeItem(key);
}
}
}, sourceState);
}
async function readCampaignSave(page) {
return page.evaluate(() => {
const raw = window.localStorage.getItem('heros-web:campaign-state');
return raw ? JSON.parse(raw) : null;
});
}
async function readCampaignSaves(page) {
return page.evaluate(() => {
const parse = (key) => {
const raw = window.localStorage.getItem(key);
return raw ? JSON.parse(raw) : null;
};
return {
current: parse('heros-web:campaign-state'),
slot1: parse('heros-web:campaign-state:slot-1')
};
});
}
async function assertDesktopViewport(page) {
const viewport = await page.evaluate(() => {
const canvas = document.querySelector('canvas');
const bounds = canvas?.getBoundingClientRect();
return {
innerWidth: window.innerWidth,
innerHeight: window.innerHeight,
devicePixelRatio: window.devicePixelRatio,
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.innerWidth, desktopBrowserViewport.width);
assert.equal(viewport.innerHeight, desktopBrowserViewport.height);
assert.equal(viewport.devicePixelRatio, desktopBrowserDeviceScaleFactor);
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);
}
function assertBoundsInsideViewport(bounds, label) {
assert(bounds, `${label}: bounds are required`);
assert(
bounds.x >= 0 &&
bounds.y >= 0 &&
bounds.x + bounds.width <= desktopBrowserViewport.width &&
bounds.y + bounds.height <= desktopBrowserViewport.height,
`${label}: expected in-viewport bounds, received ${JSON.stringify(bounds)}`
);
}
function assertBoundsInsideCampMap(bounds, label) {
assert(bounds, `${label}: bounds are required`);
assert(
bounds.x >= 0 &&
bounds.y >= 92 &&
bounds.x + bounds.width <= 1488 &&
bounds.y + bounds.height <= 958,
`${label}: expected bounds inside the militia-camp map, received ${JSON.stringify(bounds)}`
);
}
function assertNpcTextureKeys(npcs, expectedTextureKeyByNpcId, sceneLabel) {
assert(Array.isArray(npcs), `${sceneLabel}: expected an NPC debug-state array.`);
const actualMappings = npcs
.map(({ id, textureKey }) => ({ id, textureKey }))
.sort((left, right) => left.id.localeCompare(right.id));
const expectedMappings = Object.entries(expectedTextureKeyByNpcId)
.map(([id, textureKey]) => ({ id, textureKey }))
.sort((left, right) => left.id.localeCompare(right.id));
assert.deepEqual(
actualMappings,
expectedMappings,
`${sceneLabel}: every named NPC must render with its assigned exploration texture.`
);
}
function assertBoundsDoNotOverlap(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];
const overlaps = !(
left.bounds.x + left.bounds.width <= right.bounds.x ||
right.bounds.x + right.bounds.width <= left.bounds.x ||
left.bounds.y + left.bounds.height <= right.bounds.y ||
right.bounds.y + right.bounds.height <= left.bounds.y
);
assert(
!overlaps,
`${label}: ${left.label} overlaps ${right.label}: ${JSON.stringify({ left, right })}`
);
}
}
}
function assertExplorationBackground(background, expectedKey) {
assert(background, `Expected ${expectedKey} debug state.`);
assert.equal(background.key, expectedKey);
assert.equal(background.ready, true);
assert.equal(background.fallback, false);
assert.equal(background.sourceWidth, desktopBrowserViewport.width);
assert.equal(background.sourceHeight, desktopBrowserViewport.height);
assert.equal(background.objectCount, 1);
assert.deepEqual(background.bounds, {
x: 0,
y: 0,
width: desktopBrowserViewport.width,
height: desktopBrowserViewport.height
});
}
async function captureStableScreenshot(page, path) {
await page.waitForTimeout(650);
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);
}
}
}
async function ensureLocalServer(url) {
if (await canReach(url)) {
return undefined;
}
const parsed = new URL(url);
if (!['localhost', '127.0.0.1', '0.0.0.0'].includes(parsed.hostname)) {
throw new Error(`No server responded at ${url}`);
}
const stderr = [];
const child = spawn(
process.execPath,
[
'node_modules/vite/bin/vite.js',
'--host',
'127.0.0.1',
'--port',
parsed.port || '41796',
'--strictPort'
],
{
cwd: process.cwd(),
env: process.env,
stdio: ['ignore', 'pipe', 'pipe'],
windowsHide: true
}
);
child.stderr.on('data', (chunk) => stderr.push(chunk.toString()));
child.stdout.on('data', () => {});
for (let attempt = 0; attempt < 120; attempt += 1) {
if (await canReach(url)) {
return child;
}
await delay(250);
}
child.kill();
throw new Error(`Vite server did not start at ${url}\n${stderr.join('')}`);
}
async function canReach(url) {
try {
const response = await fetch(url, { signal: AbortSignal.timeout(1000) });
return response.ok;
} catch {
return false;
}
}
function delay(ms) {
return new Promise((resolve) => setTimeout(resolve, ms));
}