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

1052 lines
42 KiB
JavaScript

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 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 queuedGuanTarget = villageNpc(village, 'guan-yu');
await page.mouse.click(queuedGuanTarget.x, queuedGuanTarget.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 === 'guan-yu'
);
});
await waitForVillageInteractionTarget(page, 'guan-yu');
village = await readVillage(page);
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 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');
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');
village = await readVillage(page);
assertNpcPosition(village, 'guan-yu', { x: 850, y: 655 }, 'recruitment Guan Yu');
assertNpcPosition(village, 'zhang-fei', { x: 810, y: 805 }, 'recruitment Zhang Fei');
await page.keyboard.press('e');
await page.waitForFunction(() => (
window.__HEROS_DEBUG__?.village?.()?.dialogue?.sourceNpcId === 'recruiting-clerk'
));
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.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.'
);
const campPlayerBeforeMove = (await readMilitiaCamp(page)).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'));
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 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.dialogue.active,
false,
'The one-time militia camp orientation must not replay on Continue.'
);
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.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, 3);
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');
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, distance-gated dialogue, ` +
'autosave/reload, legacy-save routing, locked oath and command, camp preparation, 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 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) {
await page.waitForFunction(() => {
const story = window.__HEROS_DEBUG__?.scene('StoryScene')?.getDebugState?.();
return window.__HEROS_DEBUG__?.activeScenes?.().includes('StoryScene') && story?.ready === true;
}, undefined, { timeout: 90000 });
}
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 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 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(220);
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));
}