1160 lines
33 KiB
JavaScript
1160 lines
33 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__.goToCampVisitExploration();
|
|
});
|
|
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 };
|
|
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: 12000 }
|
|
);
|
|
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');
|
|
|
|
await page.keyboard.press('e');
|
|
await page.waitForFunction(
|
|
(key) => {
|
|
const state = window.__HEROS_DEBUG__
|
|
?.scene(key)
|
|
?.getDebugState?.();
|
|
return (
|
|
state?.dialogue?.active === true &&
|
|
state.dialogue.sourceNpcId === 'jian-yong'
|
|
);
|
|
},
|
|
sceneKey
|
|
);
|
|
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: 12000 }
|
|
);
|
|
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
|
|
);
|
|
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, ` +
|
|
'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 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));
|
|
}
|