1130 lines
34 KiB
JavaScript
1130 lines
34 KiB
JavaScript
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 Bei’s 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);
|
||
}
|