Files
heros_web/scripts/verify-first-pursuit-camp-exploration-browser.mjs

1379 lines
40 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import assert from 'node:assert/strict';
import { spawn } from 'node:child_process';
import { mkdirSync } from 'node:fs';
import { chromium } from 'playwright';
import {
desktopBrowserContextOptions,
desktopBrowserDeviceScaleFactor,
desktopBrowserViewport
} from './desktop-browser-viewport.mjs';
const sceneKey = 'CampVisitExplorationScene';
const sourceBattleId = 'first-battle-zhuo-commandery';
const visitId = 'first-pursuit-scout-tent';
const autoEntryTutorialId =
'first-pursuit-camp-auto-enter-scout-tent';
const scheduledAutoEntryTutorialId =
'first-pursuit-camp-auto-enter-scout-tent-scheduled';
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(
({ expectedBattleId, markerId }) => {
const parse = (key) => {
const raw = window.localStorage.getItem(key);
return raw ? JSON.parse(raw) : null;
};
const states = [
parse('heros-web:campaign-state'),
parse('heros-web:campaign-state:slot-1')
];
return (
window.__HEROS_DEBUG__
?.activeScenes?.()
.includes('CampScene') &&
states.every(
(campaign) =>
campaign?.completedTutorialIds.includes(
markerId
) &&
campaign.dismissedVictoryRewardNoticeBattleIds.includes(
expectedBattleId
)
)
);
},
{
expectedBattleId: sourceBattleId,
markerId: scheduledAutoEntryTutorialId
},
{ timeout: 90000 }
);
const scheduledCueSaves = await readCampaignSaves(page);
[scheduledCueSaves.current, scheduledCueSaves.slot1].forEach(
(campaign, index) => {
const label = index === 0 ? 'current save' : 'slot-1 save';
assert(
campaign?.completedTutorialIds.includes(
scheduledAutoEntryTutorialId
),
`${label} must durably schedule automatic entry before the cue can be interrupted.`
);
}
);
await page.reload({
waitUntil: 'domcontentloaded',
timeout: 90000
});
await waitForDebugApi(page);
await assertDesktopViewport(page);
await page.evaluate(async () => {
await window.__HEROS_DEBUG__.goToCamp();
});
await page.waitForFunction(
() => {
const autoEntry =
window.__HEROS_DEBUG__?.camp?.()
?.firstPursuitAutoEntry;
return (
autoEntry?.cueVisible === true &&
autoEntry?.freshArrivalEligible === false &&
autoEntry?.scheduledResumeEligible === true &&
autoEntry?.eligible === true
);
},
undefined,
{ timeout: 90000 }
);
const autoEntryCue = await page.evaluate(() =>
window.__HEROS_DEBUG__?.camp?.()
?.firstPursuitAutoEntry
);
assert.equal(autoEntryCue.delayMs, 1900);
assertBoundsInsideViewport(
autoEntryCue.cueBounds,
'resumed first-victory automatic exploration cue'
);
await captureStableScreenshot(
page,
'dist/verification-first-pursuit-camp-auto-entry-cue.png'
);
await waitForExplorationReady(page);
const autoEntrySaves = await readCampaignSaves(page);
[autoEntrySaves.current, autoEntrySaves.slot1].forEach(
(campaign, index) => {
const label = index === 0 ? 'current save' : 'slot-1 save';
assert(campaign, `${label} is missing after automatic entry.`);
assert.equal(campaign.step, 'first-camp');
assert.equal(campaign.activeCampVisitId, visitId);
assert(
campaign.completedTutorialIds.includes(
autoEntryTutorialId
),
`${label} must durably record the one-time automatic entry.`
);
assert(
campaign.completedTutorialIds.includes(
scheduledAutoEntryTutorialId
),
`${label} must retain the durable automatic-entry schedule marker.`
);
assert(
campaign.dismissedVictoryRewardNoticeBattleIds.includes(
sourceBattleId
),
`${label} must settle the first victory before automatic entry.`
);
const acknowledgedCategories =
campaign.acknowledgedVictoryRewardCategories[
sourceBattleId
] ?? [];
assert(
acknowledgedCategories.includes('gold') &&
acknowledgedCategories.includes('reputation'),
`${label} must acknowledge passive settlement rewards before automatic entry.`
);
assert.equal(
campaign.completedCampVisits.includes(visitId),
false,
`${label} must not complete the visit merely by entering it.`
);
}
);
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 Yongs first line.'
);
assert.equal(
exploration.dialogue.sourceNpcId,
'jian-yong',
'The arrival guard must keep Jian Yongs 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 captureStableScreenshot(
page,
'dist/verification-first-pursuit-camp-exploration-completed.png'
);
await page.keyboard.press('Escape');
await waitForCampReturn(page);
let 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),
'automatic CampScene return'
);
const returnedSaves = await readCampaignSaves(page);
[returnedSaves.current, returnedSaves.slot1].forEach(
(campaign, index) => {
const label = index === 0 ? 'current save' : 'slot-1 save';
assert(
campaign.completedTutorialIds.includes(
autoEntryTutorialId
),
`${label} must retain the automatic-entry marker after completion.`
);
assert.equal(
campaign.activeCampVisitId ?? null,
null,
`${label} must clear active exploration after automatic return.`
);
assert.equal(
campaign.explorationCheckpoint ?? null,
null,
`${label} must clear the completed exploration checkpoint after Escape-triggered automatic return.`
);
}
);
await page.reload({
waitUntil: 'domcontentloaded',
timeout: 90000
});
await waitForDebugApi(page);
await assertDesktopViewport(page);
await page.evaluate(async () => {
await window.__HEROS_DEBUG__.goToCamp();
});
await waitForCampReturn(page);
await page.waitForTimeout(400);
const activeScenesAfterReload = await page.evaluate(
() => window.__HEROS_DEBUG__?.activeScenes?.() ?? []
);
assert(
activeScenesAfterReload.includes('CampScene') &&
!activeScenesAfterReload.includes(
'CampVisitExplorationScene'
),
`Reloading a completed first camp must not repeat automatic exploration: ${JSON.stringify(activeScenesAfterReload)}`
);
camp = await page.evaluate(() =>
window.__HEROS_DEBUG__?.camp?.()
);
assert.equal(camp.firstPursuitScoutMemory.completed, true);
assertRelevantProgressEqual(
returnedSaves,
await readCampaignSaves(page),
'non-repeating CampScene reload'
);
await seedSettledLegacyFirstCamp(page);
await page.reload({
waitUntil: 'domcontentloaded',
timeout: 90000
});
await waitForDebugApi(page);
await assertDesktopViewport(page);
await page.evaluate(async () => {
await window.__HEROS_DEBUG__.goToCamp();
});
await waitForCampReturn(page);
await page.waitForTimeout(2400);
const legacyCamp = await page.evaluate(() =>
window.__HEROS_DEBUG__?.camp?.()
);
assert.equal(
legacyCamp.firstPursuitAutoEntry
.freshArrivalEligible,
false,
'A previously settled first-camp save must not be mistaken for a fresh victory arrival.'
);
assert.equal(
legacyCamp.firstPursuitAutoEntry.cueVisible,
false
);
assert.equal(
legacyCamp.firstPursuitScoutMemory.completed,
false,
'The legacy guard must be verified with an unfinished scout visit.'
);
assert(
(await page.evaluate(
() => window.__HEROS_DEBUG__?.activeScenes?.() ?? []
)).includes('CampScene'),
'A settled legacy save must remain in CampScene instead of being pulled into exploration.'
);
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, ` +
'settlement-before-entry, announced one-time automatic first-camp routing, distance-gated interaction, face portraits, two real choices, one-time resonance/item rewards, ' +
'Escape-safe automatic CampScene return, reload non-repetition, settled legacy-save protection, 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 seedSettledLegacyFirstCamp(page) {
await page.evaluate(
async ({ expectedVisitId, markerIds }) => {
const campaignModule = await import(
'/heros_web/src/game/state/campaignState.ts'
);
const campaign =
campaignModule.getCampaignState();
const firstBattleReport =
campaign.firstBattleReport
? {
...campaign.firstBattleReport,
completedCampVisits:
campaign.firstBattleReport.completedCampVisits.filter(
(candidate) =>
candidate !== expectedVisitId
)
}
: undefined;
const campVisitChoiceIds = {
...campaign.campVisitChoiceIds
};
delete campVisitChoiceIds[expectedVisitId];
campaignModule.saveCampaignState({
...campaign,
activeCampVisitId: undefined,
explorationCheckpoint: undefined,
completedCampVisits:
campaign.completedCampVisits.filter(
(candidate) =>
candidate !== expectedVisitId
),
campVisitChoiceIds,
completedTutorialIds:
campaign.completedTutorialIds.filter(
(candidate) =>
!markerIds.includes(candidate)
),
firstBattleReport
});
},
{
expectedVisitId: visitId,
markerIds: [
autoEntryTutorialId,
scheduledAutoEntryTutorialId
]
}
);
}
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 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));
}