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.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.' ); 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')); 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, distance-gated 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 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)); }