1284 lines
37 KiB
JavaScript
1284 lines
37 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 sceneKey = 'CampVisitExplorationScene';
|
||
const sourceBattleId = 'first-battle-zhuo-commandery';
|
||
const visitId = 'first-pursuit-scout-tent';
|
||
const choiceId = 'trace-river-ambush';
|
||
const choiceLabel = '강가 매복 흔적을 쫓는다';
|
||
const rewardItemLabel = '상처약';
|
||
const rewardItemText = '상처약 1';
|
||
const bondId = 'liu-bei__jian-yong';
|
||
const expectedActors = {
|
||
'jian-yong': {
|
||
x: 510,
|
||
y: 492,
|
||
textureKey: 'exploration-jian-yong'
|
||
},
|
||
'guan-yu': {
|
||
x: 1015,
|
||
y: 565,
|
||
textureKey: 'exploration-guan-yu'
|
||
},
|
||
'zhang-fei': {
|
||
x: 1270,
|
||
y: 595,
|
||
textureKey: 'exploration-zhang-fei'
|
||
}
|
||
};
|
||
const targetUrl = withDebugOptions(
|
||
process.env.VERIFY_FIRST_PURSUIT_CAMP_EXPLORATION_URL ??
|
||
'http://127.0.0.1:41798/'
|
||
);
|
||
|
||
let serverProcess;
|
||
let browser;
|
||
|
||
try {
|
||
mkdirSync('dist', { recursive: true });
|
||
serverProcess = await ensureLocalServer(targetUrl);
|
||
browser = await chromium.launch({
|
||
headless:
|
||
process.env.VERIFY_FIRST_PURSUIT_CAMP_EXPLORATION_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 assertDesktopViewport(page);
|
||
|
||
const seeded = await seedFirstVictoryCamp(page);
|
||
assert.equal(seeded.step, 'first-camp');
|
||
assert.equal(seeded.latestBattleId, sourceBattleId);
|
||
assert.equal(seeded.reportBattleId, sourceBattleId);
|
||
assert.equal(seeded.reportOutcome, 'victory');
|
||
assert.equal(seeded.settlementOutcome, 'victory');
|
||
assert.equal(seeded.pendingAftermathBattleId, null);
|
||
assert.equal(seeded.completed, false);
|
||
assert.equal(seeded.choiceId, null);
|
||
assert.equal(seeded.rewardItemAmount, 0);
|
||
assert(
|
||
seeded.bond,
|
||
`The first-victory seed must include ${bondId}: ${JSON.stringify(seeded)}`
|
||
);
|
||
|
||
await page.evaluate(async () => {
|
||
await window.__HEROS_DEBUG__.goToCamp();
|
||
});
|
||
await page.waitForFunction(
|
||
({ expectedVisitId }) => {
|
||
const debug = window.__HEROS_DEBUG__;
|
||
const camp = debug?.camp?.();
|
||
const reward =
|
||
camp?.victoryRewardAcknowledgement;
|
||
const guided = reward?.actions?.find(
|
||
(action) => action.id === 'guided'
|
||
);
|
||
return (
|
||
debug?.activeScenes?.().includes('CampScene') &&
|
||
camp?.campaign?.step === 'first-camp' &&
|
||
reward?.visible === true &&
|
||
reward.actions
|
||
.map((action) => action.id)
|
||
.join(',') ===
|
||
'equipment,supplies,guided,sortie,close' &&
|
||
guided?.primary === true &&
|
||
guided?.label === '북문 정찰막 둘러보기' &&
|
||
guided?.interactive === true &&
|
||
camp?.sortieCommand?.guidedKind === 'visit' &&
|
||
camp.sortieCommand.targetId === expectedVisitId &&
|
||
camp.sortieCommand.label === '정찰막 둘러보기' &&
|
||
camp.sortieCommand.bypass?.label === '바로 출진' &&
|
||
camp.sortieCommand.bypass?.interactive === true
|
||
);
|
||
},
|
||
{ expectedVisitId: visitId },
|
||
{ timeout: 90000 }
|
||
);
|
||
const guidedCamp = await page.evaluate(() =>
|
||
window.__HEROS_DEBUG__?.camp?.()
|
||
);
|
||
const guidedReward =
|
||
guidedCamp.victoryRewardAcknowledgement;
|
||
const guidedAction = guidedReward.actions.find(
|
||
(action) => action.id === 'guided'
|
||
);
|
||
assert.equal(
|
||
guidedReward.actions.filter((action) => action.primary)
|
||
.length,
|
||
1
|
||
);
|
||
assert.equal(
|
||
guidedReward.actions.find(
|
||
(action) => action.id === 'sortie'
|
||
)?.label,
|
||
'바로 출진'
|
||
);
|
||
assertBoundsInsideViewport(
|
||
guidedAction.bounds,
|
||
'first-camp guided reward action'
|
||
);
|
||
await captureStableScreenshot(
|
||
page,
|
||
'dist/verification-first-pursuit-camp-guided-reward.png'
|
||
);
|
||
await clickNamedSceneBounds(
|
||
page,
|
||
'CampScene',
|
||
guidedAction.bounds
|
||
);
|
||
await waitForExplorationReady(page);
|
||
await page.waitForTimeout(380);
|
||
|
||
let exploration = await readExploration(page);
|
||
assert.equal(exploration.scene, sceneKey);
|
||
assert.equal(exploration.locationId, visitId);
|
||
assert.equal(exploration.campaignStep, 'first-camp');
|
||
assert.deepEqual(exploration.viewport, desktopBrowserViewport);
|
||
assert.equal(exploration.background.key, 'first-pursuit-camp-background');
|
||
assert.equal(exploration.background.ready, true);
|
||
assert.equal(exploration.background.fallback, false);
|
||
assert.equal(
|
||
exploration.background.sourceWidth,
|
||
desktopBrowserViewport.width
|
||
);
|
||
assert.equal(
|
||
exploration.background.sourceHeight,
|
||
desktopBrowserViewport.height
|
||
);
|
||
assert.deepEqual(exploration.background.bounds, {
|
||
x: 0,
|
||
y: 0,
|
||
width: desktopBrowserViewport.width,
|
||
height: desktopBrowserViewport.height
|
||
});
|
||
assert.equal(exploration.requiredTexturesReady, true);
|
||
assert.deepEqual(
|
||
exploration.requiredTextures,
|
||
[
|
||
{ key: 'exploration-liu-bei', ready: true },
|
||
{ key: 'exploration-jian-yong', ready: true },
|
||
{ key: 'exploration-guan-yu', ready: true },
|
||
{ key: 'exploration-zhang-fei', ready: true }
|
||
]
|
||
);
|
||
assert.equal(exploration.player.textureKey, 'exploration-liu-bei');
|
||
assert.deepEqual(
|
||
{
|
||
x: exploration.player.x,
|
||
y: exploration.player.y
|
||
},
|
||
{ x: 780, y: 720 }
|
||
);
|
||
assert.equal(exploration.movement.speed, 300);
|
||
assert.equal(exploration.interaction.radius, 122);
|
||
assert.equal(exploration.interaction.promptRadius, 164);
|
||
assert.equal(exploration.visit.completed, false);
|
||
assert.equal(exploration.visit.choiceId, null);
|
||
assertActorsFixed(exploration, 'initial camp');
|
||
assertBoundsInsideViewport(
|
||
exploration.background.bounds,
|
||
'exploration background'
|
||
);
|
||
assertBoundsInsideViewport(exploration.player.bounds, 'initial Liu Bei');
|
||
exploration.actors.forEach((actor) =>
|
||
assertBoundsInsideViewport(actor.bounds, `initial ${actor.name}`)
|
||
);
|
||
|
||
const jianYongSource = await readTextureSource(
|
||
page,
|
||
'exploration-jian-yong'
|
||
);
|
||
assert.equal(jianYongSource.exists, true);
|
||
assert.match(
|
||
decodeURIComponent(jianYongSource.url),
|
||
/\/exploration-jian-yong\.webp(?:$|[?#])/,
|
||
`Jian Yong must use his dedicated SD sheet instead of a generic fallback: ${JSON.stringify(jianYongSource)}`
|
||
);
|
||
assert.equal(jianYongSource.width, 3072);
|
||
assert.equal(jianYongSource.height, 768);
|
||
|
||
await captureStableScreenshot(
|
||
page,
|
||
'dist/verification-first-pursuit-camp-exploration-initial.png'
|
||
);
|
||
|
||
const beforeKeyboardMove = exploration.player;
|
||
await page.keyboard.down('ArrowLeft');
|
||
try {
|
||
await page.waitForFunction(
|
||
({ key, startX }) =>
|
||
window.__HEROS_DEBUG__
|
||
?.scene(key)
|
||
?.getDebugState?.()?.player?.x <= startX - 65,
|
||
{ key: sceneKey, startX: beforeKeyboardMove.x },
|
||
{ timeout: 3000 }
|
||
);
|
||
} finally {
|
||
await page.keyboard.up('ArrowLeft');
|
||
}
|
||
await page.waitForTimeout(100);
|
||
exploration = await readExploration(page);
|
||
assert(
|
||
beforeKeyboardMove.x - exploration.player.x >= 65,
|
||
`Expected real keyboard movement to move Liu Bei left: ${JSON.stringify({
|
||
before: beforeKeyboardMove,
|
||
after: exploration.player
|
||
})}`
|
||
);
|
||
assert(
|
||
Math.abs(beforeKeyboardMove.y - exploration.player.y) <= 2,
|
||
`Horizontal keyboard movement must retain Y: ${JSON.stringify({
|
||
before: beforeKeyboardMove,
|
||
after: exploration.player
|
||
})}`
|
||
);
|
||
assert.equal(exploration.player.moving, false);
|
||
assertActorsFixed(exploration, 'after keyboard movement');
|
||
assert(
|
||
nearestActorDistance(exploration) > exploration.interaction.radius &&
|
||
exploration.exit.distance > exploration.interaction.radius,
|
||
`The distant-interaction probe must start outside every interaction radius: ${JSON.stringify({
|
||
player: exploration.player,
|
||
actors: exploration.actors.map(({ id, distance }) => ({
|
||
id,
|
||
distance
|
||
})),
|
||
exit: exploration.exit
|
||
})}`
|
||
);
|
||
|
||
const savesBeforeDistantInteraction = await readCampaignSaves(page);
|
||
await page.keyboard.press('e');
|
||
await page.waitForTimeout(150);
|
||
exploration = await readExploration(page);
|
||
assert.equal(
|
||
exploration.dialogue.active,
|
||
false,
|
||
'Pressing E outside the interaction radius must not open dialogue.'
|
||
);
|
||
assert.equal(exploration.choice.open, false);
|
||
assert.equal(exploration.visit.completed, false);
|
||
assert.equal(exploration.lastNotice, '조금 더 가까이 다가가세요.');
|
||
assertRelevantProgressEqual(
|
||
savesBeforeDistantInteraction,
|
||
await readCampaignSaves(page),
|
||
'distant E interaction'
|
||
);
|
||
|
||
const jianYong = actorById(exploration, 'jian-yong');
|
||
const clickStart = { ...exploration.player };
|
||
const autoInteractionCountBefore =
|
||
exploration.movement.autoInteraction.triggeredCount;
|
||
await clickScenePoint(page, jianYong.x, jianYong.y);
|
||
await page.waitForFunction(
|
||
({ key, actorId, startX, startY }) => {
|
||
const state = window.__HEROS_DEBUG__
|
||
?.scene(key)
|
||
?.getDebugState?.();
|
||
return (
|
||
state?.movement?.targetId === actorId &&
|
||
Math.hypot(
|
||
state.player.x - startX,
|
||
state.player.y - startY
|
||
) >= 10
|
||
);
|
||
},
|
||
{
|
||
key: sceneKey,
|
||
actorId: 'jian-yong',
|
||
startX: clickStart.x,
|
||
startY: clickStart.y
|
||
},
|
||
{ timeout: 5000 }
|
||
);
|
||
await page.waitForFunction(
|
||
({ key, actorId }) => {
|
||
const state = window.__HEROS_DEBUG__
|
||
?.scene(key)
|
||
?.getDebugState?.();
|
||
return (
|
||
state?.movement?.target === null &&
|
||
state?.movement?.lastTargetId === actorId &&
|
||
state?.interaction?.targetId === actorId &&
|
||
state?.interaction?.canInteract === true
|
||
);
|
||
},
|
||
{ key: sceneKey, actorId: 'jian-yong' },
|
||
{ timeout: 30000 }
|
||
);
|
||
await page.waitForFunction(
|
||
(key) => (
|
||
window.__HEROS_DEBUG__
|
||
?.scene(key)
|
||
?.getDebugState?.()?.dialogue?.sourceNpcId === 'jian-yong'
|
||
),
|
||
sceneKey
|
||
);
|
||
exploration = await readExploration(page);
|
||
assert(
|
||
actorById(exploration, 'jian-yong').distance <=
|
||
exploration.interaction.radius,
|
||
`Click movement must stop inside Jian Yong's interaction radius: ${JSON.stringify(exploration.interaction)}`
|
||
);
|
||
assertActorsFixed(exploration, 'after click navigation');
|
||
assert.equal(
|
||
exploration.movement.autoInteraction.triggeredCount,
|
||
autoInteractionCountBefore + 1,
|
||
'Arriving at Jian Yong must open dialogue exactly once.'
|
||
);
|
||
const protectedArrivalLineIndex = exploration.dialogue.lineIndex;
|
||
await page.mouse.click(960, 850);
|
||
await page.waitForTimeout(80);
|
||
exploration = await readExploration(page);
|
||
assert.equal(
|
||
exploration.dialogue.lineIndex,
|
||
protectedArrivalLineIndex,
|
||
'A follow-up click inside the arrival guard must not skip Jian Yong’s first line.'
|
||
);
|
||
assert.equal(
|
||
exploration.dialogue.sourceNpcId,
|
||
'jian-yong',
|
||
'The arrival guard must keep Jian Yong’s newly opened dialogue active.'
|
||
);
|
||
await page.waitForTimeout(160);
|
||
assert.equal(
|
||
(await readExploration(page)).movement.autoInteraction.triggeredCount,
|
||
autoInteractionCountBefore + 1,
|
||
'Jian Yong dialogue must not retrigger on a later update.'
|
||
);
|
||
exploration = await readExploration(page);
|
||
assert.equal(exploration.dialogue.speaker, '간옹');
|
||
assert.equal(exploration.dialogue.portraitVisible, true);
|
||
assert.equal(exploration.dialogue.portraitFrameVisible, true);
|
||
assert.equal(
|
||
exploration.dialogue.portraitTextureKey,
|
||
'portrait-jian-yong-campaign'
|
||
);
|
||
assert.equal(exploration.dialogue.totalLines, 2);
|
||
assertBoundsInsideViewport(
|
||
exploration.dialogue.bounds,
|
||
'Jian Yong dialogue'
|
||
);
|
||
await captureStableScreenshot(
|
||
page,
|
||
'dist/verification-first-pursuit-camp-exploration-dialogue.png'
|
||
);
|
||
|
||
await advanceDialogueUntilChoice(page);
|
||
await page.waitForFunction(
|
||
(key) =>
|
||
window.__HEROS_DEBUG__
|
||
?.scene(key)
|
||
?.getDebugState?.()?.choice?.ready === true,
|
||
sceneKey
|
||
);
|
||
exploration = await readExploration(page);
|
||
assert.equal(exploration.dialogue.active, false);
|
||
assert.equal(exploration.choice.open, true);
|
||
assert.deepEqual(
|
||
exploration.choice.choices.map(
|
||
({ id, label, rewardText, interactive }) => ({
|
||
id,
|
||
label,
|
||
rewardText,
|
||
interactive
|
||
})
|
||
),
|
||
[
|
||
{
|
||
id: choiceId,
|
||
label: choiceLabel,
|
||
rewardText: `공명 +10 · ${rewardItemText}`,
|
||
interactive: true
|
||
},
|
||
{
|
||
id: 'mark-village-relief',
|
||
label: '마을 구호 길을 표시한다',
|
||
rewardText: '공명 +10 · 콩 1',
|
||
interactive: true
|
||
}
|
||
]
|
||
);
|
||
assertBoundsInsideViewport(
|
||
exploration.choice.panelBounds,
|
||
'scout choice panel'
|
||
);
|
||
exploration.choice.choices.forEach((choice) =>
|
||
assertBoundsInside(
|
||
choice.bounds,
|
||
exploration.choice.panelBounds,
|
||
`choice ${choice.id}`
|
||
)
|
||
);
|
||
assertNoOverlappingBounds(
|
||
exploration.choice.choices.map(({ id, bounds }) => ({
|
||
id,
|
||
bounds
|
||
})),
|
||
'scout choices'
|
||
);
|
||
await captureStableScreenshot(
|
||
page,
|
||
'dist/verification-first-pursuit-camp-exploration-choice.png'
|
||
);
|
||
|
||
const savesBeforeChoice = await readCampaignSaves(page);
|
||
const bondBeforeChoice = campaignBond(
|
||
savesBeforeChoice.current,
|
||
bondId
|
||
);
|
||
assert(
|
||
bondBeforeChoice,
|
||
`Missing ${bondId} before the scout choice: ${JSON.stringify(savesBeforeChoice.current?.bonds)}`
|
||
);
|
||
const selectedChoice = exploration.choice.choices.find(
|
||
(choice) => choice.id === choiceId
|
||
);
|
||
assert(selectedChoice, `Missing choice ${choiceId}.`);
|
||
await clickSceneBounds(page, selectedChoice.bounds);
|
||
await page.waitForFunction(
|
||
({ key, expectedVisitId, expectedChoiceId }) => {
|
||
const state = window.__HEROS_DEBUG__
|
||
?.scene(key)
|
||
?.getDebugState?.();
|
||
return (
|
||
state?.visit?.completed === true &&
|
||
state.visit.id === expectedVisitId &&
|
||
state.visit.choiceId === expectedChoiceId &&
|
||
state.dialogue.active === true
|
||
);
|
||
},
|
||
{
|
||
key: sceneKey,
|
||
expectedVisitId: visitId,
|
||
expectedChoiceId: choiceId
|
||
}
|
||
);
|
||
|
||
exploration = await readExploration(page);
|
||
assert.equal(exploration.visit.choiceLabel, choiceLabel);
|
||
assert.deepEqual(exploration.visit.itemRewards, [rewardItemText]);
|
||
assert.equal(exploration.visit.bondExp, 10);
|
||
assert.equal(exploration.dialogue.speaker, '간옹');
|
||
assert.equal(exploration.dialogue.portraitVisible, true);
|
||
assert.equal(
|
||
exploration.dialogue.portraitTextureKey,
|
||
'portrait-jian-yong-campaign'
|
||
);
|
||
assert.equal(actorById(exploration, 'jian-yong').completed, true);
|
||
assertActorsFixed(exploration, 'after scout completion');
|
||
|
||
const savesAfterChoice = await readCampaignSaves(page);
|
||
assertScoutRewardSaved(
|
||
savesBeforeChoice,
|
||
savesAfterChoice,
|
||
bondBeforeChoice
|
||
);
|
||
|
||
await advanceDialogueUntilClosed(page);
|
||
exploration = await readExploration(page);
|
||
assert.equal(exploration.dialogue.active, false);
|
||
assert.equal(exploration.choice.open, false);
|
||
assert.equal(exploration.visit.completed, true);
|
||
assertActorsFixed(exploration, 'completed camp');
|
||
await captureStableScreenshot(
|
||
page,
|
||
'dist/verification-first-pursuit-camp-exploration-completed.png'
|
||
);
|
||
|
||
const duplicateDebugResult = await page.evaluate(
|
||
({ key, expectedChoiceId }) =>
|
||
window.__HEROS_DEBUG__
|
||
?.scene(key)
|
||
?.debugChooseScout?.(expectedChoiceId) ?? null,
|
||
{ key: sceneKey, expectedChoiceId: choiceId }
|
||
);
|
||
assert.equal(
|
||
duplicateDebugResult,
|
||
false,
|
||
'The scene debug choice hook must reject a completed visit.'
|
||
);
|
||
|
||
await page.keyboard.press('e');
|
||
await page.waitForFunction(
|
||
(key) =>
|
||
window.__HEROS_DEBUG__
|
||
?.scene(key)
|
||
?.getDebugState?.()?.dialogue?.sourceNpcId === 'jian-yong',
|
||
sceneKey
|
||
);
|
||
await advanceDialogueUntilClosed(page);
|
||
exploration = await readExploration(page);
|
||
assert.equal(
|
||
exploration.choice.open,
|
||
false,
|
||
'Revisiting Jian Yong after completion must replay the result without reopening choices.'
|
||
);
|
||
assertRelevantProgressEqual(
|
||
savesAfterChoice,
|
||
await readCampaignSaves(page),
|
||
'completed Jian Yong revisit'
|
||
);
|
||
|
||
const exitStart = { ...exploration.player };
|
||
await clickScenePoint(page, exploration.exit.x, exploration.exit.y);
|
||
await page.waitForFunction(
|
||
({ key, exitId, startX, startY }) => {
|
||
const state = window.__HEROS_DEBUG__
|
||
?.scene(key)
|
||
?.getDebugState?.();
|
||
return (
|
||
state?.movement?.targetId === exitId &&
|
||
Math.hypot(
|
||
state.player.x - startX,
|
||
state.player.y - startY
|
||
) >= 10
|
||
);
|
||
},
|
||
{
|
||
key: sceneKey,
|
||
exitId: 'camp-exit',
|
||
startX: exitStart.x,
|
||
startY: exitStart.y
|
||
},
|
||
{ timeout: 5000 }
|
||
);
|
||
await page.waitForFunction(
|
||
({ key, exitId }) => {
|
||
const state = window.__HEROS_DEBUG__
|
||
?.scene(key)
|
||
?.getDebugState?.();
|
||
return (
|
||
state?.movement?.target === null &&
|
||
state?.movement?.lastTargetId === exitId &&
|
||
state?.interaction?.targetId === exitId &&
|
||
state?.interaction?.canInteract === true
|
||
);
|
||
},
|
||
{ key: sceneKey, exitId: 'camp-exit' },
|
||
{ timeout: 30000 }
|
||
);
|
||
await page.keyboard.press('e');
|
||
await waitForCampReturn(page);
|
||
const camp = await page.evaluate(() =>
|
||
window.__HEROS_DEBUG__?.camp?.()
|
||
);
|
||
assert.equal(camp.campaign.step, 'first-camp');
|
||
assert.equal(camp.campBattleId, sourceBattleId);
|
||
assert.equal(camp.firstPursuitScoutMemory.completed, true);
|
||
assert.equal(camp.firstPursuitScoutMemory.choiceId, choiceId);
|
||
assert.equal(
|
||
camp.firstPursuitScoutMemory.activeForNextSortie,
|
||
true
|
||
);
|
||
assert.notEqual(
|
||
camp.sortieCommand.guidedKind,
|
||
'visit',
|
||
'Completing the guided scout visit must advance the primary camp action.'
|
||
);
|
||
assert.equal(
|
||
camp.sortieCommand.bypass,
|
||
null,
|
||
'The direct-sortie bypass must collapse once the seed has no pending guided step.'
|
||
);
|
||
assertRelevantProgressEqual(
|
||
savesAfterChoice,
|
||
await readCampaignSaves(page),
|
||
'CampScene return'
|
||
);
|
||
|
||
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 first-pursuit camp exploration at ${desktopBrowserViewport.width}x${desktopBrowserViewport.height}, ` +
|
||
`DPR ${desktopBrowserDeviceScaleFactor}: dedicated Jian Yong SD art, fixed NPC positions, keyboard and pointer movement, ` +
|
||
'first-victory guided reward routing, distance-gated interaction, face portraits, two real choices, one-time resonance/item rewards, duplicate protection, ' +
|
||
'CampScene return, and current/slot-1 save persistence.'
|
||
);
|
||
} 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',
|
||
process.env.VERIFY_FIRST_PURSUIT_CAMP_EXPLORATION_RENDERER ??
|
||
'canvas'
|
||
);
|
||
return parsed.toString();
|
||
}
|
||
|
||
async function waitForDebugApi(page) {
|
||
await page.waitForFunction(
|
||
() =>
|
||
document.querySelector('canvas') !== null &&
|
||
window.__HEROS_GAME__ !== undefined &&
|
||
window.__HEROS_DEBUG__ !== undefined,
|
||
undefined,
|
||
{ timeout: 90000 }
|
||
);
|
||
}
|
||
|
||
async function seedFirstVictoryCamp(page) {
|
||
return page.evaluate(
|
||
async ({ battleId, expectedVisitId, expectedBondId, itemLabel }) => {
|
||
const campaignModule = await import(
|
||
'/heros_web/src/game/state/campaignState.ts'
|
||
);
|
||
const { battleScenarios } = await import(
|
||
'/heros_web/src/game/data/battles.ts'
|
||
);
|
||
const scenario = battleScenarios[battleId];
|
||
if (!scenario) {
|
||
throw new Error(`Missing battle scenario ${battleId}.`);
|
||
}
|
||
|
||
campaignModule.resetCampaignState();
|
||
campaignModule.setFirstBattleReport({
|
||
battleId: scenario.id,
|
||
battleTitle: scenario.title,
|
||
outcome: 'victory',
|
||
turnNumber: 6,
|
||
rewardGold: scenario.baseVictoryGold,
|
||
defeatedEnemies: scenario.units.filter(
|
||
(unit) => unit.faction === 'enemy'
|
||
).length,
|
||
totalEnemies: scenario.units.filter(
|
||
(unit) => unit.faction === 'enemy'
|
||
).length,
|
||
objectives: [],
|
||
units: scenario.units,
|
||
bonds: scenario.bonds.map((bond) => ({
|
||
...bond,
|
||
battleExp: 0
|
||
})),
|
||
itemRewards: [],
|
||
completedCampDialogues: [],
|
||
completedCampVisits: [],
|
||
createdAt: '2026-07-27T00:00:00.000Z'
|
||
});
|
||
campaignModule.completeCampaignAftermath(scenario.id);
|
||
const campaign = campaignModule.getCampaignState();
|
||
const bond = campaign.bonds.find(
|
||
(candidate) => candidate.id === expectedBondId
|
||
);
|
||
return {
|
||
step: campaign.step,
|
||
latestBattleId: campaign.latestBattleId ?? null,
|
||
reportBattleId:
|
||
campaign.firstBattleReport?.battleId ?? null,
|
||
reportOutcome:
|
||
campaign.firstBattleReport?.outcome ?? null,
|
||
settlementOutcome:
|
||
campaign.battleHistory[battleId]?.outcome ?? null,
|
||
pendingAftermathBattleId:
|
||
campaign.pendingAftermathBattleId ?? null,
|
||
completed:
|
||
campaign.completedCampVisits.includes(expectedVisitId),
|
||
choiceId:
|
||
campaign.campVisitChoiceIds[expectedVisitId] ?? null,
|
||
rewardItemAmount: campaign.inventory[itemLabel] ?? 0,
|
||
bond: bond
|
||
? {
|
||
level: bond.level,
|
||
exp: bond.exp,
|
||
battleExp: bond.battleExp
|
||
}
|
||
: null
|
||
};
|
||
},
|
||
{
|
||
battleId: sourceBattleId,
|
||
expectedVisitId: visitId,
|
||
expectedBondId: bondId,
|
||
itemLabel: rewardItemLabel
|
||
}
|
||
);
|
||
}
|
||
|
||
async function waitForExplorationReady(page) {
|
||
try {
|
||
await page.waitForFunction(
|
||
(key) => {
|
||
const debug = window.__HEROS_DEBUG__;
|
||
const state = debug?.scene(key)?.getDebugState?.();
|
||
return (
|
||
debug?.activeScenes?.().includes(key) &&
|
||
state?.scene === key &&
|
||
state?.ready === true
|
||
);
|
||
},
|
||
sceneKey,
|
||
{ timeout: 90000 }
|
||
);
|
||
} catch (error) {
|
||
const diagnostic = await page.evaluate((key) => ({
|
||
activeScenes:
|
||
window.__HEROS_DEBUG__?.activeScenes?.() ?? [],
|
||
exploration:
|
||
window.__HEROS_DEBUG__?.scene(key)?.getDebugState?.() ??
|
||
null,
|
||
camp: window.__HEROS_DEBUG__?.camp?.() ?? null
|
||
}), sceneKey);
|
||
throw new Error(
|
||
`Camp visit exploration did not become ready: ${JSON.stringify(diagnostic)}`,
|
||
{ cause: error }
|
||
);
|
||
}
|
||
}
|
||
|
||
async function waitForCampReturn(page) {
|
||
await page.waitForFunction(
|
||
() => {
|
||
const debug = window.__HEROS_DEBUG__;
|
||
const camp = debug?.camp?.();
|
||
return (
|
||
debug?.activeScenes?.().includes('CampScene') &&
|
||
!debug?.activeScenes?.().includes('CampVisitExplorationScene') &&
|
||
camp?.scene === 'CampScene' &&
|
||
camp?.campaign?.step === 'first-camp'
|
||
);
|
||
},
|
||
undefined,
|
||
{ timeout: 90000 }
|
||
);
|
||
}
|
||
|
||
async function readExploration(page) {
|
||
return page.evaluate((key) =>
|
||
window.__HEROS_DEBUG__?.scene(key)?.getDebugState?.()
|
||
, sceneKey);
|
||
}
|
||
|
||
async function readTextureSource(page, textureKey) {
|
||
return page.evaluate(
|
||
({ key, requestedTextureKey }) => {
|
||
const scene = window.__HEROS_DEBUG__?.scene(key);
|
||
const texture = scene?.textures?.get(requestedTextureKey);
|
||
const source = texture?.source?.[0];
|
||
const image = source?.image;
|
||
return {
|
||
exists:
|
||
Boolean(scene?.textures?.exists(requestedTextureKey)),
|
||
url:
|
||
image?.currentSrc ??
|
||
image?.src ??
|
||
'',
|
||
width:
|
||
source?.width ??
|
||
image?.naturalWidth ??
|
||
image?.width ??
|
||
null,
|
||
height:
|
||
source?.height ??
|
||
image?.naturalHeight ??
|
||
image?.height ??
|
||
null
|
||
};
|
||
},
|
||
{ key: sceneKey, requestedTextureKey: textureKey }
|
||
);
|
||
}
|
||
|
||
async function advanceDialogueUntilChoice(page) {
|
||
for (let attempt = 0; attempt < 8; attempt += 1) {
|
||
const state = await readExploration(page);
|
||
if (state?.choice?.open) {
|
||
return;
|
||
}
|
||
if (!state?.dialogue?.active) {
|
||
throw new Error(
|
||
`Dialogue closed without opening the scout choice: ${JSON.stringify(state)}`
|
||
);
|
||
}
|
||
await page.keyboard.press('e');
|
||
await page.waitForTimeout(140);
|
||
}
|
||
throw new Error(
|
||
`Scout choice did not open: ${JSON.stringify(await readExploration(page))}`
|
||
);
|
||
}
|
||
|
||
async function advanceDialogueUntilClosed(page) {
|
||
for (let attempt = 0; attempt < 8; attempt += 1) {
|
||
const state = await readExploration(page);
|
||
if (!state?.dialogue?.active) {
|
||
return;
|
||
}
|
||
await page.keyboard.press('e');
|
||
await page.waitForTimeout(140);
|
||
}
|
||
throw new Error(
|
||
`Camp exploration dialogue did not close: ${JSON.stringify(await readExploration(page))}`
|
||
);
|
||
}
|
||
|
||
async function clickScenePoint(page, x, y) {
|
||
const point = await page.evaluate(
|
||
({ key, sceneX, sceneY }) => {
|
||
const scene = window.__HEROS_DEBUG__?.scene(key);
|
||
const canvas = document.querySelector('canvas');
|
||
const canvasBounds = canvas?.getBoundingClientRect();
|
||
if (!scene || !canvasBounds) {
|
||
return null;
|
||
}
|
||
return {
|
||
x:
|
||
canvasBounds.left +
|
||
(sceneX * canvasBounds.width) / scene.scale.width,
|
||
y:
|
||
canvasBounds.top +
|
||
(sceneY * canvasBounds.height) / scene.scale.height
|
||
};
|
||
},
|
||
{ key: sceneKey, sceneX: x, sceneY: y }
|
||
);
|
||
assert(
|
||
point && Number.isFinite(point.x) && Number.isFinite(point.y),
|
||
`Unable to map scene point ${JSON.stringify({ x, y })}.`
|
||
);
|
||
await page.mouse.click(point.x, point.y);
|
||
}
|
||
|
||
async function clickSceneBounds(page, bounds) {
|
||
await clickScenePoint(
|
||
page,
|
||
bounds.x + bounds.width / 2,
|
||
bounds.y + bounds.height / 2
|
||
);
|
||
}
|
||
|
||
async function clickNamedSceneBounds(page, requestedSceneKey, bounds) {
|
||
const point = await page.evaluate(
|
||
({ key, requestedBounds }) => {
|
||
const scene = window.__HEROS_DEBUG__?.scene(key);
|
||
const canvas = document.querySelector('canvas');
|
||
const canvasBounds = canvas?.getBoundingClientRect();
|
||
if (!scene || !canvasBounds) {
|
||
return null;
|
||
}
|
||
return {
|
||
x:
|
||
canvasBounds.left +
|
||
(requestedBounds.x + requestedBounds.width / 2) *
|
||
canvasBounds.width /
|
||
scene.scale.width,
|
||
y:
|
||
canvasBounds.top +
|
||
(requestedBounds.y + requestedBounds.height / 2) *
|
||
canvasBounds.height /
|
||
scene.scale.height
|
||
};
|
||
},
|
||
{ key: requestedSceneKey, requestedBounds: bounds }
|
||
);
|
||
assert(
|
||
point && Number.isFinite(point.x) && Number.isFinite(point.y),
|
||
`Unable to map ${requestedSceneKey} bounds ${JSON.stringify(bounds)}.`
|
||
);
|
||
await page.mouse.click(point.x, point.y);
|
||
}
|
||
|
||
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')
|
||
};
|
||
});
|
||
}
|
||
|
||
function actorById(exploration, actorId) {
|
||
const actor = exploration?.actors?.find(
|
||
(candidate) => candidate.id === actorId
|
||
);
|
||
assert(
|
||
actor,
|
||
`Missing exploration actor ${actorId}: ${JSON.stringify(exploration?.actors)}`
|
||
);
|
||
return actor;
|
||
}
|
||
|
||
function assertActorsFixed(exploration, label) {
|
||
assert.equal(
|
||
exploration.actors.length,
|
||
Object.keys(expectedActors).length,
|
||
`${label}: unexpected actor count.`
|
||
);
|
||
Object.entries(expectedActors).forEach(
|
||
([actorId, expected]) => {
|
||
const actor = actorById(exploration, actorId);
|
||
assert.deepEqual(
|
||
{
|
||
x: actor.x,
|
||
y: actor.y,
|
||
initialX: actor.initialX,
|
||
initialY: actor.initialY,
|
||
moved: actor.moved,
|
||
textureKey: actor.textureKey
|
||
},
|
||
{
|
||
x: expected.x,
|
||
y: expected.y,
|
||
initialX: expected.x,
|
||
initialY: expected.y,
|
||
moved: false,
|
||
textureKey: expected.textureKey
|
||
},
|
||
`${label}: ${actorId} must remain at the authored position.`
|
||
);
|
||
}
|
||
);
|
||
}
|
||
|
||
function nearestActorDistance(exploration) {
|
||
return Math.min(
|
||
...exploration.actors.map(({ distance }) => distance)
|
||
);
|
||
}
|
||
|
||
function campaignBond(campaign, requestedBondId) {
|
||
return campaign?.bonds?.find(
|
||
(bond) => bond.id === requestedBondId
|
||
);
|
||
}
|
||
|
||
function bondProgress(bond) {
|
||
return bond.level * 100 + bond.exp;
|
||
}
|
||
|
||
function assertScoutRewardSaved(before, after, bondBefore) {
|
||
[after.current, after.slot1].forEach((campaign, index) => {
|
||
const saveLabel = index === 0 ? 'current save' : 'slot-1 save';
|
||
assert(campaign, `${saveLabel} is missing.`);
|
||
assert.equal(campaign.step, 'first-camp');
|
||
assert.equal(campaign.latestBattleId, sourceBattleId);
|
||
assert.equal(
|
||
campaign.completedCampVisits.filter(
|
||
(candidate) => candidate === visitId
|
||
).length,
|
||
1,
|
||
`${saveLabel} must contain one completion flag.`
|
||
);
|
||
assert.equal(
|
||
campaign.firstBattleReport.completedCampVisits.filter(
|
||
(candidate) => candidate === visitId
|
||
).length,
|
||
1,
|
||
`${saveLabel} report must contain one completion flag.`
|
||
);
|
||
assert.equal(
|
||
campaign.campVisitChoiceIds[visitId],
|
||
choiceId,
|
||
`${saveLabel} must retain the canonical choice.`
|
||
);
|
||
assert.equal(
|
||
campaign.inventory[rewardItemLabel] ??
|
||
0,
|
||
(before.current?.inventory?.[rewardItemLabel] ?? 0) + 1,
|
||
`${saveLabel} must grant exactly one ${rewardItemLabel}.`
|
||
);
|
||
const bondAfter = campaignBond(campaign, bondId);
|
||
assert(
|
||
bondAfter,
|
||
`${saveLabel} is missing ${bondId}.`
|
||
);
|
||
assert.equal(
|
||
bondProgress(bondAfter) - bondProgress(bondBefore),
|
||
10,
|
||
`${saveLabel} must grant exactly 10 resonance EXP.`
|
||
);
|
||
assert.equal(
|
||
bondAfter.battleExp - bondBefore.battleExp,
|
||
10,
|
||
`${saveLabel} must retain exactly 10 earned resonance EXP.`
|
||
);
|
||
});
|
||
}
|
||
|
||
function relevantProgress(saves) {
|
||
const project = (campaign) => {
|
||
const bond = campaignBond(campaign, bondId);
|
||
return {
|
||
completedCampVisits:
|
||
campaign?.completedCampVisits?.filter(
|
||
(candidate) => candidate === visitId
|
||
) ?? [],
|
||
reportCompletedCampVisits:
|
||
campaign?.firstBattleReport?.completedCampVisits?.filter(
|
||
(candidate) => candidate === visitId
|
||
) ?? [],
|
||
choiceId:
|
||
campaign?.campVisitChoiceIds?.[visitId] ?? null,
|
||
rewardItemAmount:
|
||
campaign?.inventory?.[rewardItemLabel] ?? 0,
|
||
bond: bond
|
||
? {
|
||
level: bond.level,
|
||
exp: bond.exp,
|
||
battleExp: bond.battleExp
|
||
}
|
||
: null
|
||
};
|
||
};
|
||
return {
|
||
current: project(saves.current),
|
||
slot1: project(saves.slot1)
|
||
};
|
||
}
|
||
|
||
function assertRelevantProgressEqual(before, after, label) {
|
||
assert.deepEqual(
|
||
relevantProgress(after),
|
||
relevantProgress(before),
|
||
`${label} must not change scout completion, choice, reward, or resonance.`
|
||
);
|
||
}
|
||
|
||
function assertNoOverlappingBounds(entries, label) {
|
||
for (
|
||
let leftIndex = 0;
|
||
leftIndex < entries.length;
|
||
leftIndex += 1
|
||
) {
|
||
for (
|
||
let rightIndex = leftIndex + 1;
|
||
rightIndex < entries.length;
|
||
rightIndex += 1
|
||
) {
|
||
const left = entries[leftIndex];
|
||
const right = entries[rightIndex];
|
||
const overlapWidth =
|
||
Math.min(
|
||
left.bounds.x + left.bounds.width,
|
||
right.bounds.x + right.bounds.width
|
||
) - Math.max(left.bounds.x, right.bounds.x);
|
||
const overlapHeight =
|
||
Math.min(
|
||
left.bounds.y + left.bounds.height,
|
||
right.bounds.y + right.bounds.height
|
||
) - Math.max(left.bounds.y, right.bounds.y);
|
||
assert(
|
||
overlapWidth <= 0 || overlapHeight <= 0,
|
||
`${label} ${left.id} and ${right.id} overlap: ${JSON.stringify({ left, right })}`
|
||
);
|
||
}
|
||
}
|
||
}
|
||
|
||
function assertBoundsInsideViewport(bounds, label) {
|
||
assertBoundsInside(
|
||
bounds,
|
||
{
|
||
x: 0,
|
||
y: 0,
|
||
width: desktopBrowserViewport.width,
|
||
height: desktopBrowserViewport.height
|
||
},
|
||
label
|
||
);
|
||
}
|
||
|
||
function assertBoundsInside(bounds, container, label) {
|
||
assert(bounds, `${label}: bounds are required.`);
|
||
const epsilon = 0.01;
|
||
assert(
|
||
bounds.x >= container.x - epsilon &&
|
||
bounds.y >= container.y - epsilon &&
|
||
bounds.x + bounds.width <=
|
||
container.x + container.width + epsilon &&
|
||
bounds.y + bounds.height <=
|
||
container.y + container.height + epsilon,
|
||
`${label}: expected bounds inside container, received ${JSON.stringify({ bounds, container })}`
|
||
);
|
||
}
|
||
|
||
async function assertDesktopViewport(page) {
|
||
const viewport = await page.evaluate(() => {
|
||
const canvas = document.querySelector('canvas');
|
||
const bounds = canvas?.getBoundingClientRect();
|
||
return {
|
||
width: window.innerWidth,
|
||
height: window.innerHeight,
|
||
dpr: window.devicePixelRatio,
|
||
visualScale: window.visualViewport?.scale ?? 1,
|
||
canvas: canvas
|
||
? {
|
||
width: canvas.width,
|
||
height: canvas.height,
|
||
clientWidth: canvas.clientWidth,
|
||
clientHeight: canvas.clientHeight,
|
||
bounds: bounds
|
||
? {
|
||
x: bounds.x,
|
||
y: bounds.y,
|
||
width: bounds.width,
|
||
height: bounds.height
|
||
}
|
||
: null
|
||
}
|
||
: null
|
||
};
|
||
});
|
||
assert.equal(viewport.width, desktopBrowserViewport.width);
|
||
assert.equal(viewport.height, desktopBrowserViewport.height);
|
||
assert.equal(viewport.dpr, desktopBrowserDeviceScaleFactor);
|
||
assert.equal(viewport.visualScale, 1);
|
||
assert.equal(viewport.canvas?.width, desktopBrowserViewport.width);
|
||
assert.equal(viewport.canvas?.height, desktopBrowserViewport.height);
|
||
assert.equal(
|
||
viewport.canvas?.clientWidth,
|
||
desktopBrowserViewport.width
|
||
);
|
||
assert.equal(
|
||
viewport.canvas?.clientHeight,
|
||
desktopBrowserViewport.height
|
||
);
|
||
assert.equal(
|
||
viewport.canvas?.bounds?.width,
|
||
desktopBrowserViewport.width
|
||
);
|
||
assert.equal(
|
||
viewport.canvas?.bounds?.height,
|
||
desktopBrowserViewport.height
|
||
);
|
||
}
|
||
|
||
async function captureStableScreenshot(page, path) {
|
||
await page.waitForTimeout(300);
|
||
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 || '41798',
|
||
'--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));
|
||
}
|