Files
heros_web/scripts/verify-second-battle-relief-exploration-browser.mjs

1130 lines
34 KiB
JavaScript
Raw Permalink 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 { mkdirSync } from 'node:fs';
import { chromium } from 'playwright';
import { createServer } from 'vite';
import {
desktopBrowserContextOptions,
desktopBrowserDeviceScaleFactor,
desktopBrowserViewport
} from './desktop-browser-viewport.mjs';
const explorationSceneKey = 'CampVisitExplorationScene';
const sourceBattleId = 'second-battle-yellow-turban-pursuit';
const targetBattleId = 'third-battle-guangzong-road';
const visitId = 'second-pursuit-aftermath-relief';
const firstScoutVisitId = 'first-pursuit-scout-tent';
const objectiveIds = [
'reassure-northern-village',
'question-ferry-witness',
'inspect-messenger-trail'
];
const objectiveProgressIds = objectiveIds.map(
(objectiveId) => `${visitId}:objective:${objectiveId}`
);
const expectedActors = {
'guan-yu-village': {
x: 1050,
y: 455,
textureKey: 'exploration-guan-yu'
},
'north-ferry-boatman': {
x: 360,
y: 470,
textureKey: 'exploration-zhuo-villager'
},
'zhang-fei-ferry': {
x: 540,
y: 675,
textureKey: 'exploration-zhang-fei'
},
'jian-yong-records': {
x: 790,
y: 690,
textureKey: 'exploration-jian-yong'
}
};
const rendererFixtures = {
canvas: {
renderer: 'canvas',
expectedRendererType: 1,
firstScoutChoiceId: 'mark-village-relief',
expectedScoutFocus: 'village',
reliefChoiceId: 'restore-northern-village',
rewardItemLabel: '콩',
bondId: 'liu-bei__guan-yu',
effectKind: 'village-supply-line',
expectedEffectText: '다음 전투 · 유비 후방 지원 · 휴대 콩 +1'
},
webgl: {
renderer: 'webgl',
expectedRendererType: 2,
firstScoutChoiceId: 'trace-river-ambush',
expectedScoutFocus: 'river',
reliefChoiceId: 'trace-ferry-messenger',
rewardItemLabel: '상처약',
bondId: 'liu-bei__jian-yong',
effectKind: 'ferry-messenger-intel',
expectedEffectText:
'다음 전투 · 장비 측면 추천 · 전령 마원의 첫 행동 표시'
}
};
const requestedRenderer =
cliOption('renderer') ??
process.env.VERIFY_SECOND_BATTLE_RELIEF_RENDERER ??
'both';
const renderers =
requestedRenderer === 'both'
? ['canvas', 'webgl']
: [requestedRenderer];
renderers.forEach((renderer) => {
assert(
renderer in rendererFixtures,
`Unknown renderer "${renderer}". Use canvas, webgl, or both.`
);
});
const server = await createServer({
logLevel: 'error',
server: { host: '127.0.0.1', port: 0, hmr: false },
appType: 'spa'
});
let browser;
try {
mkdirSync('dist', { recursive: true });
await server.listen();
const address = server.httpServer?.address();
assert(
address && typeof address !== 'string',
'Expected a local Vite verification server.'
);
const baseUrl = `http://127.0.0.1:${address.port}/heros_web/`;
browser = await chromium.launch({
headless: process.env.VERIFY_SECOND_BATTLE_RELIEF_HEADLESS !== '0'
});
for (const renderer of renderers) {
await verifyRenderer(browser, baseUrl, rendererFixtures[renderer]);
}
console.log(
`Second-battle relief browser verification passed for ${renderers.join(
' + '
)} at ${desktopBrowserViewport.width}x${desktopBrowserViewport.height} CSS pixels, DPR ${desktopBrowserDeviceScaleFactor}.`
);
} finally {
await browser?.close();
await server.close();
}
async function verifyRenderer(browser, baseUrl, fixture) {
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());
}
});
try {
const url = new URL(baseUrl);
url.searchParams.set('debug', '1');
url.searchParams.set('renderer', fixture.renderer);
await page.goto(url.toString(), {
waitUntil: 'domcontentloaded',
timeout: 90000
});
await waitForDebugApi(page);
await assertFhdViewport(page, fixture);
const seeded = await seedSecondVictoryCamp(page, fixture);
assert.equal(seeded.step, 'second-camp');
assert.equal(seeded.latestBattleId, sourceBattleId);
assert.equal(seeded.reportBattleId, sourceBattleId);
assert.equal(seeded.settlementOutcome, 'victory');
assert.equal(seeded.firstScoutChoiceId, fixture.firstScoutChoiceId);
assert.equal(seeded.reliefCompleted, false);
await page.evaluate(async () => {
await window.__HEROS_DEBUG__.goToCamp();
});
const campBefore = await waitForCampReliefCta(page);
assert.equal(campBefore.activeTab, 'visit');
assert.equal(campBefore.campaign.step, 'second-camp');
assert.equal(campBefore.campBattleId, sourceBattleId);
assert.equal(
campBefore.secondBattleReliefExploration.mode,
'walkable-relief'
);
assert.equal(
campBefore.secondBattleReliefExploration.explorationInteractive,
true
);
assert(
campBefore.secondBattleReliefExploration.explorationButtonBounds,
'CampScene must expose a clickable second-relief CTA.'
);
await capture(
page,
`dist/verification-second-battle-relief-${fixture.renderer}-camp-cta.png`
);
await clickSceneBounds(
page,
'CampScene',
campBefore.secondBattleReliefExploration.explorationButtonBounds
);
await waitForExplorationReady(page);
let exploration = await readExploration(page);
assert.equal(exploration.locationId, visitId);
assert.equal(exploration.campaignStep, 'second-camp');
assert.equal(
exploration.background.key,
'second-pursuit-village-ferry-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.equal(exploration.requiredTexturesReady, true);
assert.equal(exploration.visit.completed, false);
assert.deepEqual(exploration.visit.completedObjectiveIds, []);
assert.equal(
exploration.visit.nextObjectiveId,
objectiveIds[0]
);
assert.equal(exploration.storyContext.sourceBattleId, sourceBattleId);
assert.equal(exploration.storyContext.sourceBattleTurn, 18);
assert.equal(exploration.storyContext.villageObjectiveAchieved, true);
assert.equal(exploration.storyContext.quickObjectiveAchieved, true);
assert.equal(
exploration.storyContext.scoutReview.choiceId,
fixture.firstScoutChoiceId
);
assert.equal(
exploration.storyContext.scoutReview.focus,
fixture.expectedScoutFocus
);
assert.equal(
exploration.storyContext.scoutReview.outcome,
'confirmed'
);
assert.match(
exploration.storyContext.arrivalLine,
/북쪽 마을|마을/,
'The exploration must carry the second-battle outcome into its arrival context.'
);
assertActorsFixed(exploration, `${fixture.renderer} initial`);
await capture(
page,
`dist/verification-second-battle-relief-${fixture.renderer}-initial.png`
);
const farProgress = [...exploration.visit.completedObjectiveIds];
assert.equal(exploration.dialogue.active, false);
assert.equal(exploration.interaction.canInteract, false);
await page.keyboard.press('e');
await page.waitForTimeout(180);
exploration = await readExploration(page);
assert.equal(
exploration.dialogue.active,
false,
'Pressing E outside the interaction radius must not open dialogue.'
);
assert.deepEqual(
exploration.visit.completedObjectiveIds,
farProgress,
'A remote E press must not record a relief objective.'
);
const beforeKeyboard = { ...exploration.player };
await page.keyboard.down('ArrowLeft');
try {
await page.waitForFunction(
({ startX }) =>
window.__HEROS_DEBUG__
?.campVisitExploration?.()
?.player?.x <=
startX - 35,
{ startX: beforeKeyboard.x },
{ timeout: 5000 }
);
} finally {
await page.keyboard.up('ArrowLeft');
}
exploration = await readExploration(page);
assert(
exploration.player.x < beforeKeyboard.x - 30,
'Arrow-key movement must move Liu Bei in the world.'
);
const beforePointer = { ...exploration.player };
const pointerTarget = {
x: Math.min(1320, beforePointer.x + 120),
y: Math.max(700, beforePointer.y - 45)
};
await clickScenePoint(
page,
explorationSceneKey,
pointerTarget.x,
pointerTarget.y
);
await page.waitForFunction(
({ startX, startY }) => {
const state =
window.__HEROS_DEBUG__?.campVisitExploration?.();
return (
state?.movement?.target !== null &&
Math.hypot(
state.player.x - startX,
state.player.y - startY
) >= 8
);
},
{ startX: beforePointer.x, startY: beforePointer.y },
{ timeout: 5000 }
);
await page.waitForFunction(
() =>
window.__HEROS_DEBUG__?.campVisitExploration?.()?.movement
?.target === null,
undefined,
{ timeout: 8000 }
);
exploration = await readExploration(page);
assert(
Math.hypot(
exploration.player.x - beforePointer.x,
exploration.player.y - beforePointer.y
) >= 40,
'A world pointer click must move Liu Bei toward the selected ground.'
);
assertActorsFixed(exploration, `${fixture.renderer} after movement`);
const earlyChoice = await page.evaluate(
({ key, choiceId }) =>
window.__HEROS_DEBUG__
?.scene(key)
?.debugChooseVisitChoice?.(choiceId) ?? null,
{ key: explorationSceneKey, choiceId: fixture.reliefChoiceId }
);
assert.equal(
earlyChoice,
false,
'The final relief choice must remain locked before all field objectives.'
);
await interactWithActor(page, 'north-ferry-boatman');
exploration = await readExploration(page);
assert.equal(exploration.dialogue.sourceNpcId, 'north-ferry-boatman');
assert.equal(exploration.dialogue.speaker, '현장 기록');
await advanceDialogueUntilClosed(page);
exploration = await readExploration(page);
assert.deepEqual(
exploration.visit.completedObjectiveIds,
[],
'Interacting with the ferry witness before Guan Yu must remain sequence-locked.'
);
await interactWithActor(page, 'guan-yu-village');
exploration = await readExploration(page);
assert.equal(exploration.dialogue.sourceNpcId, 'guan-yu-village');
assert(
exploration.dialogue.totalLines >= 6,
'The first objective dialogue must include intro, battle-result context, scout review, and authored actor lines.'
);
assert.equal(exploration.dialogue.portraitVisible, true);
assert.equal(exploration.dialogue.portraitFrameVisible, true);
await capture(
page,
`dist/verification-second-battle-relief-${fixture.renderer}-intro-dialogue.png`
);
await advanceDialogueUntilClosed(page);
await waitForObjectiveProgress(page, [objectiveIds[0]]);
const partialCampaign = await readCampaign(page);
assert.equal(
partialCampaign.completedCampVisits.filter(
(candidate) => candidate === objectiveProgressIds[0]
).length,
1,
'The first relief objective must be persisted exactly once before reload.'
);
await capture(
page,
`dist/verification-second-battle-relief-${fixture.renderer}-partial.png`
);
await page.reload({
waitUntil: 'domcontentloaded',
timeout: 90000
});
await waitForDebugApi(page);
await assertFhdViewport(page, fixture);
await page.evaluate(async (requestedVisitId) => {
await window.__HEROS_DEBUG__.goToCampVisitExploration(
requestedVisitId
);
}, visitId);
await waitForExplorationReady(page);
exploration = await readExploration(page);
assert.deepEqual(
exploration.visit.completedObjectiveIds,
[objectiveIds[0]]
);
assert.equal(exploration.visit.nextObjectiveId, objectiveIds[1]);
assertActorsFixed(exploration, `${fixture.renderer} resumed`);
await capture(
page,
`dist/verification-second-battle-relief-${fixture.renderer}-resumed.png`
);
await completeActorObjective(
page,
'north-ferry-boatman',
objectiveIds.slice(0, 2)
);
await completeActorObjective(
page,
'zhang-fei-ferry',
objectiveIds
);
exploration = await readExploration(page);
assert.equal(exploration.visit.choiceReady, true);
assert.equal(
exploration.visit.nextObjectiveId,
'set-guangzong-priority'
);
assertActorsFixed(exploration, `${fixture.renderer} objectives complete`);
await interactWithActor(page, 'jian-yong-records');
await advanceDialogueUntilChoice(page);
await page.waitForFunction(
() =>
window.__HEROS_DEBUG__?.campVisitExploration?.()?.choice
?.ready === true
);
exploration = await readExploration(page);
assert.equal(exploration.choice.open, true);
assert.deepEqual(
exploration.choice.choices.map(({ id }) => id),
['restore-northern-village', 'trace-ferry-messenger']
);
const selectedChoice = exploration.choice.choices.find(
(choice) => choice.id === fixture.reliefChoiceId
);
assert(selectedChoice?.interactive);
assert.match(selectedChoice.rewardText, /공명 \+10/);
assert.equal(
selectedChoice.effectText,
fixture.expectedEffectText,
'The selected relief card must state its exact next-battle effect.'
);
await capture(
page,
`dist/verification-second-battle-relief-${fixture.renderer}-choice.png`
);
const beforeReward = await readCampaign(page);
const bondBefore = campaignBond(beforeReward, fixture.bondId);
assert(bondBefore, `Missing seeded bond ${fixture.bondId}.`);
await clickSceneBounds(
page,
explorationSceneKey,
selectedChoice.bounds
);
await page.waitForFunction(
(requestedVisitId) => {
const state =
window.__HEROS_DEBUG__?.campVisitExploration?.();
return (
state?.visit?.id === requestedVisitId &&
state.visit.completed === true &&
state.dialogue.active === true
);
},
visitId
);
exploration = await readExploration(page);
assert.equal(exploration.visit.choiceId, fixture.reliefChoiceId);
assert.equal(exploration.dialogue.sourceNpcId, 'jian-yong-records');
assertActorsFixed(exploration, `${fixture.renderer} completed`);
const afterReward = await readCampaign(page);
assertRewardAppliedOnce(beforeReward, afterReward, bondBefore, fixture);
await capture(
page,
`dist/verification-second-battle-relief-${fixture.renderer}-completed.png`
);
const relevantAfterReward = relevantProgress(afterReward, fixture);
const duplicateResult = await page.evaluate(
({ key, choiceId }) =>
window.__HEROS_DEBUG__
?.scene(key)
?.debugChooseVisitChoice?.(choiceId) ?? null,
{ key: explorationSceneKey, choiceId: fixture.reliefChoiceId }
);
assert.equal(
duplicateResult,
false,
'A completed relief visit must reject duplicate choice rewards.'
);
assert.deepEqual(
relevantProgress(await readCampaign(page), fixture),
relevantAfterReward,
'A duplicate completion attempt must not change rewards or memory.'
);
await advanceDialogueUntilClosed(page);
const teleportedToExit = await page.evaluate((key) =>
window.__HEROS_DEBUG__
?.scene(key)
?.debugTeleportTo?.('camp-exit') ?? false
, explorationSceneKey);
assert.equal(teleportedToExit, true);
await page.keyboard.press('e');
const returnedCamp = await waitForCampReturn(page);
assert.equal(returnedCamp.campaign.step, 'second-camp');
assert.equal(
returnedCamp.secondBattleReliefExploration.completed,
true
);
assert.equal(
returnedCamp.secondBattleReliefExploration.choiceId,
fixture.reliefChoiceId
);
assert.equal(
returnedCamp.campaign.completedCampVisits.filter(
(candidate) => candidate === visitId
).length,
1
);
await capture(
page,
`dist/verification-second-battle-relief-${fixture.renderer}-camp-return.png`
);
await page.evaluate(async (battleId) => {
await window.__HEROS_DEBUG__.goToBattle(battleId);
}, targetBattleId);
const battle = await waitForThirdBattleDeployment(page);
const memory = battle.secondBattleReliefMemory;
assert.equal(memory.active, true);
assert.equal(memory.sourceBattleId, sourceBattleId);
assert.equal(memory.targetBattleId, targetBattleId);
assert.equal(memory.choiceId, fixture.reliefChoiceId);
assert.equal(memory.effectKind, fixture.effectKind);
assert.equal(memory.readiness, 'reinforced');
assert.equal(memory.sourceObjectiveAchieved, true);
assert.equal(memory.openingLines.length, 3);
assert.match(
battle.battleHud?.deploymentPanel?.subtitleText ?? '',
/현장 수습/,
'The deployment panel must surface the relief memory.'
);
if (fixture.effectKind === 'village-supply-line') {
assert.equal(memory.villageSupply.active, true);
assert.equal(memory.villageSupply.carrierUnitId, 'liu-bei');
assert.equal(memory.villageSupply.recommendedRole, 'support');
assert.equal(memory.villageSupply.itemId, 'bean');
assert.equal(memory.villageSupply.carriedCount, 1);
assert.equal(
memory.villageSupply.battleStock,
battle.itemStocks['liu-bei'].bean
);
assert.equal(
memory.villageSupply.battleStock,
3,
'The village choice must add one carried bean to Liu Beis normal stock.'
);
assert.equal(battle.enemyIntentPreviews.length, 0);
} else {
assert.equal(memory.ferryIntel.active, true);
assert.equal(memory.ferryIntel.recommendedUnitId, 'zhang-fei');
assert.equal(memory.ferryIntel.recommendedRole, 'flank');
assert.equal(
memory.ferryIntel.trackedEnemyUnitId,
'guangzong-leader-ma-yuan'
);
assert.equal(memory.ferryIntel.revealTrackedRoute, true);
assert.equal(
memory.ferryIntel.forecastVisibleDuringDeployment,
true
);
assert.equal(
battle.enemyIntentPreviews.length,
1,
'Ferry intelligence must reveal exactly the tracked messenger forecast during deployment.'
);
assert.equal(
battle.enemyIntentPreviews[0].enemyId,
'guangzong-leader-ma-yuan'
);
assert.equal(
memory.ferryIntel.trackedIntent.enemyId,
'guangzong-leader-ma-yuan'
);
}
await capture(
page,
`dist/verification-second-battle-relief-${fixture.renderer}-third-battle-deployment.png`
);
await page.evaluate(() =>
window.__HEROS_GAME__?.scene
.getScene('BattleScene')
?.confirmPreBattleDeployment?.()
);
await page.waitForFunction(
() => {
const state = window.__HEROS_DEBUG__?.battle?.();
return (
state?.battleId === 'third-battle-guangzong-road' &&
state.phase === 'idle' &&
state.activeBattleEvent?.key === 'opening'
);
},
undefined,
{ timeout: 90000 }
);
const openedBattle = await page.evaluate(() =>
window.__HEROS_DEBUG__?.battle?.()
);
const openingEventLines = openedBattle.activeBattleEvent?.lines ?? [];
memory.openingLines.forEach((line) => {
assert(
openingEventLines.includes(line),
`The opening event must display remembered relief line: ${line}; state=${JSON.stringify({
activeBattleEvent: openedBattle.activeBattleEvent,
pendingBattleEvents: openedBattle.pendingBattleEvents,
triggeredBattleEvents: openedBattle.triggeredBattleEvents
})}`
);
});
await capture(
page,
`dist/verification-second-battle-relief-${fixture.renderer}-third-battle-opening.png`
);
assert.deepEqual(
pageErrors,
[],
`${fixture.renderer}: browser page errors: ${JSON.stringify(
pageErrors
)}`
);
assert.deepEqual(
consoleErrors.filter(
(message) =>
!message.includes('The AudioContext was not allowed to start') &&
!message.includes('WebGL')
),
[],
`${fixture.renderer}: browser console errors: ${JSON.stringify(
consoleErrors
)}`
);
} finally {
await context.close();
}
}
async function seedSecondVictoryCamp(page, fixture) {
return page.evaluate(
async ({
battleId,
scoutVisitId,
scoutChoiceId,
requestedVisitId
}) => {
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: 18,
rewardGold: scenario.baseVictoryGold,
defeatedEnemies: scenario.units.filter(
(unit) => unit.faction === 'enemy'
).length,
totalEnemies: scenario.units.filter(
(unit) => unit.faction === 'enemy'
).length,
objectives: scenario.objectives.map((objective) => ({
id: objective.id,
label: objective.label,
achieved: true,
status: 'done',
detail: '달성',
rewardGold: objective.rewardGold
})),
units: scenario.units,
bonds: scenario.bonds.map((bond) => ({
...bond,
battleExp: 0
})),
itemRewards: [],
completedCampDialogues: [],
completedCampVisits: [],
createdAt: '2026-07-27T00:00:00.000Z'
});
let campaign = campaignModule.getCampaignState();
campaign.completedCampVisits = [
...new Set([
...campaign.completedCampVisits,
scoutVisitId
])
];
campaign.campVisitChoiceIds = {
...campaign.campVisitChoiceIds,
[scoutVisitId]: scoutChoiceId
};
if (campaign.firstBattleReport) {
campaign.firstBattleReport.completedCampVisits = [
...new Set([
...campaign.firstBattleReport.completedCampVisits,
scoutVisitId
])
];
}
campaign.dismissedVictoryRewardNoticeBattleIds = [
...new Set([
...campaign.dismissedVictoryRewardNoticeBattleIds,
battleId
])
];
campaignModule.setCampaignState(campaign);
campaignModule.completeCampaignAftermath(battleId);
campaign = campaignModule.getCampaignState();
campaignModule.saveCampaignState(campaign, 1);
return {
step: campaign.step,
latestBattleId: campaign.latestBattleId ?? null,
reportBattleId:
campaign.firstBattleReport?.battleId ?? null,
settlementOutcome:
campaign.battleHistory[battleId]?.outcome ?? null,
firstScoutChoiceId:
campaign.campVisitChoiceIds[scoutVisitId] ?? null,
reliefCompleted:
campaign.completedCampVisits.includes(requestedVisitId)
};
},
{
battleId: sourceBattleId,
scoutVisitId: firstScoutVisitId,
scoutChoiceId: fixture.firstScoutChoiceId,
requestedVisitId: visitId
}
);
}
async function waitForDebugApi(page) {
await page.waitForFunction(
() =>
document.querySelector('canvas') !== null &&
window.__HEROS_GAME__ !== undefined &&
window.__HEROS_DEBUG__ !== undefined,
undefined,
{ timeout: 90000 }
);
}
async function assertFhdViewport(page, fixture) {
const state = await page.evaluate(() => ({
css: {
width: window.innerWidth,
height: window.innerHeight,
dpr: window.devicePixelRatio,
zoom: window.visualViewport?.scale ?? 1
},
rendererType: window.__HEROS_GAME__?.renderer?.type ?? null
}));
assert.deepEqual(state.css, {
width: desktopBrowserViewport.width,
height: desktopBrowserViewport.height,
dpr: desktopBrowserDeviceScaleFactor,
zoom: 1
});
assert.equal(
state.rendererType,
fixture.expectedRendererType,
`${fixture.renderer}: Phaser must use the requested renderer.`
);
}
async function waitForCampReliefCta(page) {
await page.waitForFunction(
(requestedVisitId) => {
const debug = window.__HEROS_DEBUG__;
const camp = debug?.camp?.();
return (
debug?.activeScenes?.().includes('CampScene') &&
camp?.scene === 'CampScene' &&
camp?.campaign?.step === 'second-camp' &&
camp?.secondBattleReliefExploration?.visitId ===
requestedVisitId &&
camp.secondBattleReliefExploration.available === true &&
camp.secondBattleReliefExploration.explorationInteractive ===
true
);
},
visitId,
{ timeout: 90000 }
);
return page.evaluate(() => window.__HEROS_DEBUG__?.camp?.());
}
async function waitForExplorationReady(page) {
await page.waitForFunction(
({ key, requestedVisitId }) => {
const debug = window.__HEROS_DEBUG__;
const state = debug?.campVisitExploration?.();
return (
debug?.activeScenes?.().includes(key) &&
state?.scene === key &&
state.locationId === requestedVisitId &&
state.ready === true
);
},
{ key: explorationSceneKey, requestedVisitId: visitId },
{ timeout: 90000 }
);
}
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 === 'second-camp'
);
},
undefined,
{ timeout: 90000 }
);
return page.evaluate(() => window.__HEROS_DEBUG__?.camp?.());
}
async function waitForThirdBattleDeployment(page) {
await page.waitForFunction(
(battleId) => {
const state = window.__HEROS_DEBUG__?.battle?.();
const assetsReady =
state?.combatAssets?.status === 'ready' ||
state?.combatAssets?.status === 'degraded';
return (
state?.scene === 'BattleScene' &&
state.battleId === battleId &&
state.phase === 'deployment' &&
assetsReady
);
},
targetBattleId,
{ timeout: 90000 }
);
return page.evaluate(() => window.__HEROS_DEBUG__?.battle?.());
}
async function readExploration(page) {
return page.evaluate(() =>
window.__HEROS_DEBUG__?.campVisitExploration?.()
);
}
async function readCampaign(page) {
return page.evaluate(async () => {
const campaignModule = await import(
'/heros_web/src/game/state/campaignState.ts'
);
return campaignModule.getCampaignState();
});
}
async function interactWithActor(page, actorId) {
const teleported = await page.evaluate(
({ key, targetId }) =>
window.__HEROS_DEBUG__
?.scene(key)
?.debugTeleportTo?.(targetId) ?? false,
{ key: explorationSceneKey, targetId: actorId }
);
assert.equal(
teleported,
true,
`Unable to approach relief actor ${actorId}.`
);
await page.waitForTimeout(80);
await page.keyboard.press('e');
await page.waitForFunction(
({ targetId }) =>
window.__HEROS_DEBUG__?.campVisitExploration?.()?.dialogue
?.sourceNpcId === targetId,
{ targetId: actorId },
{ timeout: 5000 }
);
}
async function completeActorObjective(
page,
actorId,
expectedCompletedObjectiveIds
) {
await interactWithActor(page, actorId);
await advanceDialogueUntilClosed(page);
await waitForObjectiveProgress(
page,
expectedCompletedObjectiveIds
);
await page.waitForTimeout(360);
}
async function waitForObjectiveProgress(page, expectedIds) {
await page.waitForFunction(
(requestedIds) => {
const actual =
window.__HEROS_DEBUG__?.campVisitExploration?.()?.visit
?.completedObjectiveIds ?? [];
return JSON.stringify(actual) === JSON.stringify(requestedIds);
},
expectedIds,
{ timeout: 5000 }
);
}
async function advanceDialogueUntilClosed(page) {
for (let attempt = 0; attempt < 16; attempt += 1) {
const state = await readExploration(page);
if (!state?.dialogue?.active) {
return;
}
await page.keyboard.press('e');
await page.waitForTimeout(150);
}
throw new Error(
`Dialogue did not close: ${JSON.stringify(
await readExploration(page)
)}`
);
}
async function advanceDialogueUntilChoice(page) {
for (let attempt = 0; attempt < 12; attempt += 1) {
const state = await readExploration(page);
if (state?.choice?.open) {
return;
}
assert.equal(
state?.dialogue?.active,
true,
`Jian Yong dialogue closed without choices: ${JSON.stringify(
state
)}`
);
await page.keyboard.press('e');
await page.waitForTimeout(150);
}
throw new Error(
`Relief choice did not open: ${JSON.stringify(
await readExploration(page)
)}`
);
}
async function clickScenePoint(page, sceneKey, sceneX, sceneY) {
const point = await page.evaluate(
({ key, x, y }) => {
const scene = window.__HEROS_DEBUG__?.scene(key);
const canvas = document.querySelector('canvas');
const bounds = canvas?.getBoundingClientRect();
if (!scene || !bounds) {
return null;
}
return {
x:
bounds.left +
(x * bounds.width) / scene.scale.width,
y:
bounds.top +
(y * bounds.height) / scene.scale.height
};
},
{ key: sceneKey, x: sceneX, y: sceneY }
);
assert(
point && Number.isFinite(point.x) && Number.isFinite(point.y),
`Unable to map ${sceneKey} point ${JSON.stringify({
x: sceneX,
y: sceneY
})}.`
);
await page.mouse.click(point.x, point.y);
}
async function clickSceneBounds(page, sceneKey, bounds) {
assert(bounds, `${sceneKey} clickable bounds are missing.`);
await clickScenePoint(
page,
sceneKey,
bounds.x + bounds.width / 2,
bounds.y + bounds.height / 2
);
}
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 = exploration.actors.find(
(candidate) => candidate.id === actorId
);
assert(actor, `${label}: missing actor ${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 stay at its authored position.`
);
});
}
function assertRewardAppliedOnce(
before,
after,
bondBefore,
fixture
) {
assert.equal(
after.completedCampVisits.filter(
(candidate) => candidate === visitId
).length,
1
);
assert.equal(
after.firstBattleReport.completedCampVisits.filter(
(candidate) => candidate === visitId
).length,
1
);
assert.equal(
after.campVisitChoiceIds[visitId],
fixture.reliefChoiceId
);
assert.equal(
after.inventory[fixture.rewardItemLabel] ?? 0,
(before.inventory[fixture.rewardItemLabel] ?? 0) + 1
);
objectiveProgressIds.forEach((progressId) => {
assert.equal(
after.completedCampVisits.filter(
(candidate) => candidate === progressId
).length,
1
);
});
const bondAfter = campaignBond(after, fixture.bondId);
assert(bondAfter, `Missing rewarded bond ${fixture.bondId}.`);
assert.equal(
bondProgress(bondAfter) - bondProgress(bondBefore),
10
);
}
function relevantProgress(campaign, fixture) {
const bond = campaignBond(campaign, fixture.bondId);
return {
visitFlags: campaign.completedCampVisits.filter(
(candidate) =>
candidate === visitId ||
objectiveProgressIds.includes(candidate)
),
reportVisitFlags:
campaign.firstBattleReport?.completedCampVisits?.filter(
(candidate) =>
candidate === visitId ||
objectiveProgressIds.includes(candidate)
) ?? [],
choiceId: campaign.campVisitChoiceIds[visitId] ?? null,
rewardAmount:
campaign.inventory[fixture.rewardItemLabel] ?? 0,
bond: bond
? {
level: bond.level,
exp: bond.exp,
battleExp: bond.battleExp
}
: null
};
}
function campaignBond(campaign, bondId) {
return campaign?.bonds?.find((bond) => bond.id === bondId);
}
function bondProgress(bond) {
return bond.level * 100 + bond.exp;
}
async function capture(page, path) {
await page.waitForTimeout(180);
await page.screenshot({ path, fullPage: false });
}
function cliOption(name) {
const prefix = `--${name}=`;
return process.argv
.slice(2)
.find((argument) => argument.startsWith(prefix))
?.slice(prefix.length);
}