Files
heros_web/scripts/verify-xuzhou-stay-battle-payoff-browser.mjs

1512 lines
43 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 sourceBattleId = 'seventh-battle-xuzhou-rescue';
const targetBattleId = 'eighth-battle-xiaopei-supply-road';
const informationVisitId = 'city-xuzhou-xiaopei-ledger';
const dialogueId = 'city-xuzhou-liu-mi-supply-trust';
const aidChoiceId = 'city-xuzhou-share-storehouse-duty';
const encourageChoiceId = 'city-xuzhou-trust-mi-zhu-ledger';
const miZhuUnitId = 'mi-zhu';
const trackedEnemyUnitIds = [
'xiaopei-guard-a',
'xiaopei-guard-b'
];
const equipmentOfferId = 'city-xuzhou-iron-spear';
const rendererFixtures = {
canvas: { renderer: 'canvas', expectedRendererType: 1 },
webgl: { renderer: 'webgl', expectedRendererType: 2 }
};
const requestedRenderer =
cliOption('renderer') ??
process.env.VERIFY_XUZHOU_STAY_RENDERER ??
'both';
assert(
['canvas', 'webgl', 'both'].includes(requestedRenderer),
`Unknown renderer "${requestedRenderer}". Use canvas, webgl, or both.`
);
const renderers =
requestedRenderer === 'both'
? ['canvas', 'webgl']
: [requestedRenderer];
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_XUZHOU_STAY_HEADLESS !== '0'
});
for (const renderer of renderers) {
await verifyRenderer(browser, baseUrl, rendererFixtures[renderer]);
}
console.log(
`Xuzhou stay battle payoff browser verification passed for ${renderers.join(
' + '
)} at ${desktopBrowserViewport.width}x${desktopBrowserViewport.height} CSS pixels, ` +
`100% zoom, DPR ${desktopBrowserDeviceScaleFactor}: two exact deployment intent previews, ` +
'aid +4 and encourage +1-turn single-use branches, Mi Zhu absence, no-information baseline, ' +
'Mi Zhu defeat expiry, guard counterplay credit, equipment-safe header/chip layout, ' +
'battle-save restoration, campaign result/history persistence, and choice-aware Mi Zhu story/aftermath pages.'
);
} finally {
await browser?.close();
await server.close();
}
async function verifyRenderer(
browserInstance,
baseUrl,
rendererFixture
) {
const context = await browserInstance.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', rendererFixture.renderer);
await page.goto(url.toString(), {
waitUntil: 'domcontentloaded',
timeout: 90000
});
await page.evaluate(() => window.localStorage.clear());
await page.reload({
waitUntil: 'domcontentloaded',
timeout: 90000
});
await waitForDebugApi(page);
await assertFhdViewport(page, rendererFixture);
await verifyAidBranch(page, rendererFixture.renderer);
await verifyEncourageBranch(page, rendererFixture.renderer);
await verifyMiZhuDefeatedBranch(
page,
rendererFixture.renderer
);
await verifyMiZhuMissingBranch(page, rendererFixture.renderer);
await verifyNoInformationBaseline(page, rendererFixture.renderer);
await assertFhdViewport(page, rendererFixture);
assert.deepEqual(
pageErrors,
[],
`${rendererFixture.renderer}: expected no browser page errors: ${JSON.stringify(
pageErrors.slice(-8)
)}`
);
assert.deepEqual(
consoleErrors.filter(
(message) =>
!message.includes(
'The AudioContext was not allowed to start'
)
),
[],
`${rendererFixture.renderer}: expected no browser console errors: ${JSON.stringify(
consoleErrors.slice(-8)
)}`
);
} finally {
await context.close();
}
}
async function verifyAidBranch(page, renderer) {
const seed = await seedCampaign(page, {
informationCompleted: true,
choiceId: aidChoiceId,
includeMiZhu: true,
includeEquipment: true
});
assert.equal(seed.saved, true, seed.reason);
assert.equal(seed.choiceId, aidChoiceId);
assert.equal(seed.equipmentOfferId, equipmentOfferId);
let battle = await openBattle(page, {
active: true,
choiceId: aidChoiceId,
miZhuDeployed: true
});
assertDeploymentInformation(battle, `${renderer} aid`);
assert.equal(
battle.xuzhouStayBattlePayoff.effect?.bonusHealing,
4
);
assert(
battle.battleHud.deploymentPanel.subtitleText.includes(
'적 의도 2대'
),
`${renderer}: aid deployment subtitle must retain the two revealed enemy intents.`
);
assert(
battle.battleHud.deploymentPanel.subtitleText.includes(
'응급 +4'
),
`${renderer}: aid deployment subtitle must state the +4 payoff.`
);
assert(
battle.battleHud.deploymentPanel.subtitleText.includes(
'철창'
),
`${renderer}: the existing Xuzhou equipment must remain visible beside the stay payoff.`
);
assert(
battle.battleHud.deploymentPanel.subtitleText
.split('\n')
.some((line) => line.startsWith('선택 ')),
`${renderer}: the deployment header must preserve the selected strategy/order line.`
);
assertDeploymentHeaderLayout(
battle.battleHud.deploymentPanel,
`${renderer} aid + equipment`
);
await capture(
page,
screenshotPath(renderer, 'aid-deployment-equipment')
);
battle = await startBattleWithRealClick(page);
assert.equal(
battle.xuzhouStayBattlePayoff.chip.visible,
true
);
assert.equal(
battle.cityEquipmentPreparation.chip.visible,
true
);
const activeChips = [
{
id: 'stay-payoff',
bounds: battle.xuzhouStayBattlePayoff.chip.bounds
},
{
id: 'city-equipment',
bounds: battle.cityEquipmentPreparation.chip.bounds
}
];
assertNoOverlappingBounds(
activeChips,
`${renderer}: active Xuzhou HUD chips`
);
const headerRegions = Object.entries({
title: battle.battleHud.header.titleBounds,
turn: battle.battleHud.header.turnBounds,
speed: battle.battleHud.header.speedBounds,
objective: battle.battleHud.header.objectiveBounds,
defeat: battle.battleHud.header.defeatBounds,
'sortie-order': battle.battleHud.header.sortieOrderBounds
}).filter(([, bounds]) => bounds);
activeChips.forEach((chip) => {
headerRegions.forEach(([id, bounds]) =>
assertNoOverlappingBounds(
[chip, { id, bounds }],
`${renderer}: ${chip.id} must stay below the battle header`
)
);
if (battle.battleHud.quickTabs.visible) {
assertNoOverlappingBounds(
[
chip,
{
id: 'quick-tabs',
bounds: battle.battleHud.quickTabs.bounds
}
],
`${renderer}: ${chip.id} must reserve space above quick tabs`
);
}
});
const roundTrip = await page.evaluate(
({ trackedId, untrackedId, expectedBattleId }) => {
const scene =
window.__HEROS_DEBUG__?.scene('BattleScene');
if (
!scene ||
typeof scene.debugResolveXuzhouStaySupport !==
'function' ||
typeof scene.debugRecordXuzhouStayCounterplay !==
'function' ||
typeof scene.debugRecordXuzhouStayGuardCounterplay !==
'function' ||
typeof scene.createBattleSaveState !== 'function' ||
typeof scene.applyBattleSaveState !== 'function'
) {
throw new Error(
'BattleScene Xuzhou support/counterplay/save hooks are unavailable.'
);
}
const before = structuredClone(
window.__HEROS_DEBUG__.battle()
.xuzhouStayBattlePayoff
);
const first = scene.debugResolveXuzhouStaySupport('aid');
const beforeTracked =
first.runtime.informationCounterplays;
const guard =
scene.debugRecordXuzhouStayGuardCounterplay(
trackedId
);
const tracked = guard.runtime;
const beforeUntracked = tracked.informationCounterplays;
const untracked =
scene.debugRecordXuzhouStayCounterplay(untrackedId);
const fullSave = scene.createBattleSaveState();
if (
fullSave.battleId !== expectedBattleId ||
!fullSave.xuzhouStayBattlePayoff
) {
throw new Error(
'Battle save did not include the Xuzhou stay payoff.'
);
}
const saveSnapshot = structuredClone(
fullSave.xuzhouStayBattlePayoff
);
const detached =
fullSave.xuzhouStayBattlePayoff !== tracked;
scene.xuzhouStayBattlePayoffRuntime.supportAttempts = 0;
scene.xuzhouStayBattlePayoffRuntime.supportUses = 0;
scene.xuzhouStayBattlePayoffRuntime.bonusHealing = 0;
scene.xuzhouStayBattlePayoffRuntime.informationCounterplays = 0;
scene.renderTacticalInitiativeChip();
const reset = structuredClone(
window.__HEROS_DEBUG__.battle()
.xuzhouStayBattlePayoff
);
scene.applyBattleSaveState(structuredClone(fullSave));
scene.renderTacticalInitiativeChip();
const restored = structuredClone(
window.__HEROS_DEBUG__.battle()
.xuzhouStayBattlePayoff
);
const second =
scene.debugResolveXuzhouStaySupport('aid');
scene.renderTacticalInitiativeChip();
const finalState = structuredClone(
window.__HEROS_DEBUG__.battle()
.xuzhouStayBattlePayoff
);
return {
before,
first,
trackedDelta:
tracked.informationCounterplays - beforeTracked,
untrackedDelta:
untracked.informationCounterplays - beforeUntracked,
saveSnapshot,
detached,
reset,
restored,
second,
finalState
};
},
{
trackedId: trackedEnemyUnitIds[0],
untrackedId: 'xiaopei-raider-a',
expectedBattleId: targetBattleId
}
);
assert.equal(
roundTrip.first.cityStayBonusHealing,
4,
`${renderer}: the first aid must gain exactly +4 healing.`
);
assert.equal(roundTrip.first.cityStayBonusBuffTurns, 0);
assert.equal(roundTrip.trackedDelta, 1);
assert.equal(roundTrip.untrackedDelta, 0);
assert.equal(roundTrip.saveSnapshot.supportUses, 1);
assert.equal(roundTrip.saveSnapshot.bonusHealing, 4);
assert.equal(
roundTrip.saveSnapshot.informationCounterplays,
1
);
assert.equal(roundTrip.detached, true);
assert.equal(roundTrip.reset.runtime.supportUses, 0);
assert.equal(roundTrip.reset.runtime.bonusHealing, 0);
assert.equal(
roundTrip.reset.runtime.informationCounterplays,
0
);
assert.deepEqual(
roundTrip.restored.runtime,
roundTrip.saveSnapshot,
`${renderer}: create/apply must restore the exact support-used state.`
);
assert.equal(
roundTrip.second.cityStayBonusHealing,
0,
`${renderer}: the second aid must not receive the one-use bonus.`
);
assert.equal(roundTrip.finalState.runtime.supportUses, 1);
assert.equal(roundTrip.finalState.chip.visible, true);
assertBoundsInsideViewport(
roundTrip.finalState.chip.bounds,
`${renderer}: restored aid payoff chip`
);
await capture(page, screenshotPath(renderer, 'aid-used-restored'));
const result = await forceVictoryAndReadPayoff(page);
assert.equal(result.battle.battleOutcome, 'victory');
assert.equal(result.battle.resultVisible, true);
assert.equal(
result.battle.xuzhouStayBattlePayoff.result.supportUses,
1
);
assert.equal(
result.battle.xuzhouStayBattlePayoff.result.bonusHealing,
4
);
assert.equal(
result.battle.xuzhouStayBattlePayoff.result
.informationCounterplays,
1
);
assert(
result.battle.xuzhouStayBattlePayoff.compactResultText.includes(
'장부 파훼 1'
)
);
assert(
result.battle.xuzhouStayBattlePayoff.compactResultText.includes(
'응급 +4'
)
);
assert.deepEqual(
result.campaign.reportPayoff,
result.battle.xuzhouStayBattlePayoff.result
);
assert.deepEqual(
result.campaign.historyPayoff,
result.battle.xuzhouStayBattlePayoff.result
);
assert(
result.visibleResultTexts.some(
(text) =>
text.includes('장부 파훼 1') &&
text.includes('응급 +4') &&
text.includes('철창')
),
`${renderer}: result subtitle must compactly retain both stay and equipment payoffs: ${JSON.stringify(
result.visibleResultTexts
)}`
);
await capture(page, screenshotPath(renderer, 'aid-result'));
await verifyNarrativeMemoryPages(page, renderer);
}
async function verifyNarrativeMemoryPages(page, renderer) {
const intro = await openXuzhouNarrativePage(page, 'story');
assert.equal(intro.currentPageId, 'eighth-xuzhou-stay-memory-intro');
assert.equal(intro.currentSpeaker, '미축');
assert.equal(intro.portraitKey, 'mi-zhu');
assert(
intro.portraitTextureKey?.startsWith('portrait-mi-zhu'),
`${renderer}: the Xuzhou memory page must render Mi Zhu's face portrait: ${JSON.stringify(intro)}`
);
assert(
intro.currentText.includes('서주에서 함께 살핀 장부') &&
intro.currentText.includes('창고 당번을 함께 나누기로 한 뜻'),
`${renderer}: the pre-battle story must react to the exact shared-storehouse choice: ${JSON.stringify(intro)}`
);
assert.deepEqual(
intro.pageIds.slice(2, 5),
[
'eighth-xiaopei-road',
'eighth-xuzhou-stay-memory-intro',
'eighth-sortie-choice'
],
`${renderer}: the memory page must sit immediately after the Xiaopei road briefing.`
);
assert.equal(intro.xuzhouStayNarrativeMemory?.stage, 'story');
assert.equal(
intro.xuzhouStayNarrativeMemory?.choiceId,
aidChoiceId
);
assert.equal(
intro.xuzhouStayNarrativeMemory?.currentPageApplied,
true
);
assertBoundsInside(
intro.bodyBounds,
intro.dialogPanelBounds,
`${renderer}: Xuzhou intro dialogue body`
);
await capture(
page,
screenshotPath(renderer, 'narrative-intro-aid')
);
const aftermath = await openXuzhouNarrativePage(
page,
'aftermath'
);
assert.equal(
aftermath.currentPageId,
'eighth-xuzhou-stay-memory-aftermath'
);
assert.equal(aftermath.currentSpeaker, '미축');
assert.equal(aftermath.portraitKey, 'mi-zhu');
assert(
aftermath.portraitTextureKey?.startsWith(
'portrait-mi-zhu'
),
`${renderer}: the Xuzhou aftermath must retain Mi Zhu's face portrait: ${JSON.stringify(aftermath)}`
);
assert(
aftermath.currentText.includes('움직임 1곳을 파훼') &&
aftermath.currentText.includes('응급 회복을 4만큼 더 보탰습니다'),
`${renderer}: the aftermath must narrate the historical counterplay and consumed aid bonus: ${JSON.stringify(aftermath)}`
);
assert.deepEqual(
aftermath.pageIds.slice(0, 3),
[
'eighth-victory-supply-road',
'eighth-xuzhou-stay-memory-aftermath',
'eighth-lu-bu-arrival'
],
`${renderer}: the historical result page must sit before Lu Bu's arrival.`
);
assert.equal(
aftermath.xuzhouStayNarrativeMemory?.stage,
'aftermath'
);
assert.equal(
aftermath.xuzhouStayNarrativeMemory
?.informationCounterplays,
1
);
assert.equal(
aftermath.xuzhouStayNarrativeMemory?.supportOutcome,
'used'
);
assert.equal(
aftermath.xuzhouStayNarrativeMemory?.miZhuStatus,
'deployed'
);
assert.equal(
aftermath.xuzhouStayNarrativeMemory?.currentPageApplied,
true
);
assertBoundsInside(
aftermath.bodyBounds,
aftermath.dialogPanelBounds,
`${renderer}: Xuzhou aftermath dialogue body`
);
await capture(
page,
screenshotPath(renderer, 'narrative-aftermath-aid')
);
await page.evaluate(() => {
const game = window.__HEROS_GAME__;
game?.scene.stop('StoryScene');
game?.scene.stop('BattleScene');
game?.scene.start('TitleScene');
});
await page.waitForFunction(() =>
window.__HEROS_DEBUG__?.activeScenes?.().includes(
'TitleScene'
)
);
}
async function openXuzhouNarrativePage(page, stage) {
await page.evaluate(
async ({ requestedStage, battleId }) => {
const [scenarioModule, lazySceneModule] =
await Promise.all([
import('/heros_web/src/game/data/scenario.ts'),
import('/heros_web/src/game/scenes/lazyScenes.ts')
]);
const game = window.__HEROS_GAME__;
if (!game) {
throw new Error('Game instance is unavailable.');
}
game.scene.stop('BattleScene');
game.scene.stop('StoryScene');
await lazySceneModule.startLazySceneFromGame(
game,
'StoryScene',
{
pages: structuredClone(
requestedStage === 'story'
? scenarioModule.eighthBattleIntroPages
: scenarioModule.eighthBattleVictoryPages
),
nextScene: 'TitleScene',
presentationBattleId: battleId,
presentationStage: requestedStage
}
);
},
{
requestedStage: stage,
battleId: targetBattleId
}
);
await page.waitForFunction(
() => {
const story =
window.__HEROS_DEBUG__?.scene(
'StoryScene'
)?.getDebugState?.();
return (
window.__HEROS_DEBUG__?.activeScenes?.().includes(
'StoryScene'
) &&
story?.ready === true
);
},
undefined,
{ timeout: 90000 }
);
for (let index = 0; index < 8; index += 1) {
await page.waitForFunction(
() => {
const scene =
window.__HEROS_DEBUG__?.scene('StoryScene');
return (
typeof scene?.advance === 'function' &&
scene.transitioning === false
);
},
undefined,
{ timeout: 30000 }
);
const current = await readStoryWithPageIds(page);
if (
current.xuzhouStayNarrativeMemory
?.currentPageApplied === true
) {
return current;
}
assert.equal(
current.isLastPage,
false,
`The ${stage} story ended before its Xuzhou memory page: ${JSON.stringify(current)}`
);
const previousPageIndex = current.pageIndex;
await page.evaluate(() =>
window.__HEROS_DEBUG__?.scene(
'StoryScene'
)?.advance?.()
);
await page.waitForFunction(
(before) => {
const story =
window.__HEROS_DEBUG__?.scene(
'StoryScene'
)?.getDebugState?.();
return (
story?.ready === true &&
story.pageIndex > before
);
},
previousPageIndex,
{ timeout: 90000 }
);
}
throw new Error(
`Could not reach the ${stage} Xuzhou memory page.`
);
}
async function readStoryWithPageIds(page) {
return page.evaluate(() => {
const scene =
window.__HEROS_DEBUG__?.scene('StoryScene');
return {
...scene?.getDebugState?.(),
pageIds: (scene?.pages ?? []).map((page) => page.id)
};
});
}
async function verifyEncourageBranch(page, renderer) {
const seed = await seedCampaign(page, {
informationCompleted: true,
choiceId: encourageChoiceId,
includeMiZhu: true,
includeEquipment: false
});
assert.equal(seed.saved, true, seed.reason);
let battle = await openBattle(page, {
active: true,
choiceId: encourageChoiceId,
miZhuDeployed: true
});
assertDeploymentInformation(battle, `${renderer} encourage`);
assert(
battle.battleHud.deploymentPanel.subtitleText.includes(
'격려 +1턴'
)
);
battle = await startBattleWithRealClick(page);
const support = await page.evaluate(() => {
const scene =
window.__HEROS_DEBUG__?.scene('BattleScene');
const first =
scene.debugResolveXuzhouStaySupport('encourage');
const second =
scene.debugResolveXuzhouStaySupport('encourage');
scene.renderTacticalInitiativeChip();
return {
first,
second,
payoff: structuredClone(
window.__HEROS_DEBUG__.battle()
.xuzhouStayBattlePayoff
),
chipTexts: (scene.tacticalInitiativeChipObjects ?? [])
.filter(
(object) =>
object?.type === 'Text' &&
object.active &&
object.visible
)
.map((object) => object.text)
};
});
assert.equal(support.first.cityStayBonusHealing, 0);
assert.equal(support.first.cityStayBonusBuffTurns, 1);
assert.equal(
support.second.cityStayBonusBuffTurns,
0,
`${renderer}: the second encourage must not receive the one-use duration bonus.`
);
assert.equal(support.payoff.runtime.supportUses, 1);
assert.equal(support.payoff.runtime.bonusBuffTurns, 1);
assert(
support.chipTexts.some((text) =>
text.includes('격려 +1턴')
)
);
await capture(page, screenshotPath(renderer, 'encourage-used'));
}
async function verifyMiZhuDefeatedBranch(page, renderer) {
const seed = await seedCampaign(page, {
informationCompleted: true,
choiceId: aidChoiceId,
includeMiZhu: true,
includeEquipment: false
});
assert.equal(seed.saved, true, seed.reason);
let battle = await openBattle(page, {
active: true,
choiceId: aidChoiceId,
miZhuDeployed: true
});
assertDeploymentInformation(
battle,
`${renderer} Mi Zhu defeated`
);
battle = await startBattleWithRealClick(page);
const defeated = await page.evaluate(() => {
const scene =
window.__HEROS_DEBUG__?.scene('BattleScene');
if (
!scene ||
typeof scene.debugSetXuzhouStayMiZhuHp !==
'function'
) {
throw new Error(
'BattleScene Mi Zhu defeat debug hook is unavailable.'
);
}
const payoff = scene.debugSetXuzhouStayMiZhuHp(0);
return {
payoff: structuredClone(payoff),
support:
scene.debugResolveXuzhouStaySupport('aid') ?? null,
chipTexts: (scene.tacticalInitiativeChipObjects ?? [])
.filter(
(object) =>
object?.type === 'Text' &&
object.active &&
object.visible
)
.map((object) => object.text)
};
});
assert.equal(defeated.payoff.effectActive, false);
assert.equal(defeated.payoff.runtime.supportUses, 0);
assert.equal(defeated.support, null);
assert(
defeated.chipTexts.some((text) =>
text.includes('미축 퇴각')
),
`${renderer}: an unused support payoff must visibly expire when Mi Zhu is defeated.`
);
assert(
defeated.payoff.resultText.includes('미축 퇴각'),
`${renderer}: result feedback must preserve the defeat reason.`
);
await capture(
page,
screenshotPath(renderer, 'mi-zhu-defeated-active')
);
}
async function verifyMiZhuMissingBranch(page, renderer) {
const seed = await seedCampaign(page, {
informationCompleted: true,
choiceId: aidChoiceId,
includeMiZhu: false,
includeEquipment: false
});
assert.equal(seed.saved, true, seed.reason);
let battle = await openBattle(page, {
active: true,
choiceId: aidChoiceId,
miZhuDeployed: false
});
assertDeploymentInformation(battle, `${renderer} Mi Zhu missing`);
assert.equal(
battle.xuzhouStayBattlePayoff.effectActive,
false
);
assert.equal(
battle.xuzhouStayBattlePayoff.mechanicsProbe.aid,
null
);
const deploymentTexts = await sceneTextValues(
page,
'sidePanelObjects'
);
assert(
deploymentTexts.some((text) =>
text.includes('미축 미출전')
),
`${renderer}: deployment notice must explicitly state that Mi Zhu is absent.`
);
await capture(
page,
screenshotPath(renderer, 'mi-zhu-missing-deployment')
);
battle = await startBattleWithRealClick(page);
const missing = await page.evaluate(() => {
const scene =
window.__HEROS_DEBUG__?.scene('BattleScene');
return {
support:
scene.debugResolveXuzhouStaySupport('aid') ?? null,
payoff: structuredClone(
window.__HEROS_DEBUG__.battle()
.xuzhouStayBattlePayoff
),
chipTexts: (scene.tacticalInitiativeChipObjects ?? [])
.filter(
(object) =>
object?.type === 'Text' &&
object.active &&
object.visible
)
.map((object) => object.text)
};
});
assert.equal(missing.support, null);
assert.equal(missing.payoff.runtime.supportUses, 0);
assert.equal(missing.payoff.runtime.bonusHealing, 0);
assert.equal(missing.payoff.chip.visible, true);
assert(
missing.chipTexts.some((text) =>
text.includes('미축 미출전')
),
`${renderer}: active payoff chip must preserve the no-sortie explanation.`
);
await capture(
page,
screenshotPath(renderer, 'mi-zhu-missing-active')
);
}
async function verifyNoInformationBaseline(page, renderer) {
const seed = await seedCampaign(page, {
informationCompleted: false,
choiceId: undefined,
includeMiZhu: true,
includeEquipment: false
});
assert.equal(seed.saved, true, seed.reason);
const battle = await openBattle(page, {
active: false,
choiceId: null,
miZhuDeployed: false
});
assert.equal(battle.phase, 'deployment');
assert.equal(battle.xuzhouStayBattlePayoff.active, false);
assert.deepEqual(
battle.enemyIntentPreviews,
[],
`${renderer}: the no-information deployment baseline must reveal no enemy intents.`
);
assert.equal(battle.enemyIntentVisualCount, 0);
assert(
!battle.battleHud.deploymentPanel.subtitleText.includes(
'관청 장부'
)
);
assertDeploymentHeaderLayout(
battle.battleHud.deploymentPanel,
`${renderer} no-information baseline`
);
await capture(
page,
screenshotPath(renderer, 'baseline-no-information')
);
}
async function seedCampaign(page, options) {
return page.evaluate(
async ({
sourceId,
targetId,
visitId,
requestedDialogueId,
selectedChoiceId,
requestedMiZhuId,
requestedOfferId,
informationCompleted,
includeMiZhu,
includeEquipment
}) => {
const [campaignModule, scenarioModule] =
await Promise.all([
import('/heros_web/src/game/state/campaignState.ts'),
import('/heros_web/src/game/data/scenario.ts')
]);
const current = campaignModule.getCampaignState();
const roster = scenarioModule.eighthBattleUnits
.filter((unit) => unit.faction === 'ally')
.filter(
(unit) =>
includeMiZhu || unit.id !== requestedMiZhuId
)
.map((unit) => structuredClone(unit));
const miZhu = roster.find(
(unit) => unit.id === requestedMiZhuId
);
if (includeMiZhu && !miZhu) {
return {
saved: false,
reason: 'The eighth-battle Mi Zhu template is missing.'
};
}
if (includeEquipment && miZhu) {
miZhu.equipment.weapon = {
itemId: 'iron-spear',
level: 1,
exp: 6
};
}
const now = new Date().toISOString();
const selectedSortieUnitIds = roster
.map((unit) => unit.id)
.filter((unitId) =>
[
'liu-bei',
'guan-yu',
'zhang-fei',
'jian-yong',
requestedMiZhuId
].includes(unitId)
);
const next = {
...current,
step: 'seventh-camp',
latestBattleId: sourceId,
roster,
bonds: [],
battleHistory: {
[sourceId]: {
battleId: sourceId,
battleTitle: '서주 구원전',
outcome: 'victory',
turnNumber: 12,
rewardGold: 0,
itemRewards: [],
objectives: [],
units: structuredClone(roster),
bonds: [],
completedAt: now
}
},
completedCampVisits: informationCompleted
? [visitId]
: [],
completedCampDialogues: selectedChoiceId
? [requestedDialogueId]
: [],
campDialogueChoiceIds: selectedChoiceId
? {
[requestedDialogueId]: selectedChoiceId
}
: {},
campVisitChoiceIds: {},
selectedSortieUnitIds,
sortieFormationAssignments: {
'liu-bei': 'support',
'guan-yu': 'front',
'zhang-fei': 'flank',
'jian-yong': 'support',
...(includeMiZhu
? { [requestedMiZhuId]: 'reserve' }
: {})
},
sortieItemAssignments: {},
sortieOrderSelection: {
battleId: targetId,
orderId: 'elite'
},
inventory: {},
cityEquipmentPurchaseCounts:
includeEquipment && includeMiZhu
? { [requestedOfferId]: 1 }
: {}
};
delete next.firstBattleReport;
delete next.sortieResonanceSelection;
delete next.sortieRecommendationSelection;
delete next.pendingAftermathBattleId;
delete next.cityEquipmentEquipIntent;
if (includeEquipment && includeMiZhu) {
next.cityEquipmentPreparation = {
version: 1,
selectionRecordId: 'city-equipment-preparation',
cityStayId: 'xuzhou',
sourceBattleId: sourceId,
targetBattleId: targetId,
offerId: requestedOfferId,
itemId: 'iron-spear',
slot: 'weapon',
price: 620,
purchaseNumber: 1,
unitId: requestedMiZhuId,
previousItemId: 'training-sword'
};
} else {
delete next.cityEquipmentPreparation;
}
const saved = campaignModule.setCampaignState(next);
return {
saved: true,
choiceId:
saved.campDialogueChoiceIds?.[
requestedDialogueId
] ?? null,
informationCompleted:
saved.completedCampVisits.includes(visitId),
selectedSortieUnitIds: [
...saved.selectedSortieUnitIds
],
equipmentOfferId:
saved.cityEquipmentPreparation?.offerId ?? null
};
},
{
sourceId: sourceBattleId,
targetId: targetBattleId,
visitId: informationVisitId,
requestedDialogueId: dialogueId,
selectedChoiceId: options.choiceId,
requestedMiZhuId: miZhuUnitId,
requestedOfferId: equipmentOfferId,
informationCompleted: options.informationCompleted,
includeMiZhu: options.includeMiZhu,
includeEquipment: options.includeEquipment
}
);
}
async function openBattle(page, expected) {
await page.evaluate(async (battleId) => {
await window.__HEROS_DEBUG__.goToBattle(battleId);
}, targetBattleId);
await page.waitForFunction(
({ battleId, expectedState }) => {
const battle = window.__HEROS_DEBUG__?.battle?.();
const payoff = battle?.xuzhouStayBattlePayoff;
return (
window.__HEROS_DEBUG__?.activeScenes?.().includes(
'BattleScene'
) &&
battle?.battleId === battleId &&
battle?.phase === 'deployment' &&
battle?.battleHud?.deploymentPanel?.startButtonBounds &&
payoff?.active === expectedState.active &&
payoff?.choiceId === expectedState.choiceId &&
(expectedState.active === false ||
payoff?.runtime?.miZhuDeployed ===
expectedState.miZhuDeployed)
);
},
{ battleId: targetBattleId, expectedState: expected },
{ timeout: 90000 }
);
return readBattle(page);
}
function assertDeploymentInformation(battle, label) {
assert.equal(battle.battleId, targetBattleId);
assert.equal(battle.phase, 'deployment');
assert.equal(
battle.xuzhouStayBattlePayoff.active,
true,
`${label}: payoff memory must be active.`
);
assert.equal(
battle.xuzhouStayBattlePayoff.informationCompleted,
true
);
assert.deepEqual(
[...battle.xuzhouStayBattlePayoff.trackedEnemyUnitIds].sort(),
[...trackedEnemyUnitIds].sort()
);
const previewIds = battle.enemyIntentPreviews
.map((preview) => preview.enemyId)
.sort();
assert.deepEqual(
previewIds,
[...trackedEnemyUnitIds].sort(),
`${label}: deployment must reveal exactly the two warehouse-raider intents.`
);
assert.equal(battle.enemyIntentPreviews.length, 2);
}
async function startBattleWithRealClick(page) {
await page.waitForFunction(
() => {
const battle = window.__HEROS_DEBUG__?.battle?.();
return (
battle?.phase === 'deployment' &&
['ready', 'degraded'].includes(
battle?.combatAssets?.status
) &&
battle?.battleHud?.deploymentPanel
?.combatAssetsReady === true
);
},
undefined,
{ timeout: 90000 }
);
const battle = await readBattle(page);
await clickSceneBounds(
page,
'BattleScene',
battle.battleHud.deploymentPanel.startButtonBounds
);
await page.waitForFunction(
() => {
const state = window.__HEROS_DEBUG__?.battle?.();
return (
state?.phase === 'idle' &&
state?.battleHud?.deploymentPanel === null &&
state?.xuzhouStayBattlePayoff?.chip?.visible === true
);
},
undefined,
{ timeout: 90000 }
);
return readBattle(page);
}
async function forceVictoryAndReadPayoff(page) {
await page.evaluate(() => {
window.__HEROS_DEBUG__.forceBattleOutcome('victory');
});
await page.waitForFunction(
(battleId) => {
const battle = window.__HEROS_DEBUG__?.battle?.();
return (
battle?.battleId === battleId &&
battle?.battleOutcome === 'victory' &&
battle?.resultVisible === true &&
battle?.resultSettlement?.status === 'complete' &&
battle?.xuzhouStayBattlePayoff?.result
?.supportUses === 1
);
},
targetBattleId,
{ timeout: 90000 }
);
return page.evaluate(async (battleId) => {
const battle = window.__HEROS_DEBUG__.battle();
const scene =
window.__HEROS_DEBUG__?.scene('BattleScene');
const campaignModule = await import(
'/heros_web/src/game/state/campaignState.ts'
);
const campaign = campaignModule.loadCampaignState();
const visibleResultTexts = (scene?.resultObjects ?? [])
.filter(
(object) =>
object?.type === 'Text' &&
object.active &&
object.visible &&
typeof object.text === 'string'
)
.map((object) => object.text);
return {
battle,
visibleResultTexts,
campaign: {
reportBattleId:
campaign.firstBattleReport?.battleId ?? null,
reportPayoff: campaign.firstBattleReport
?.xuzhouStayBattlePayoff
? structuredClone(
campaign.firstBattleReport
.xuzhouStayBattlePayoff
)
: null,
historyBattleId:
campaign.battleHistory?.[battleId]?.battleId ??
null,
historyPayoff: campaign.battleHistory?.[battleId]
?.xuzhouStayBattlePayoff
? structuredClone(
campaign.battleHistory[battleId]
.xuzhouStayBattlePayoff
)
: null
}
};
}, targetBattleId);
}
function assertDeploymentHeaderLayout(panel, label) {
assert(panel, `${label}: deployment panel is required.`);
assertBoundsInsideViewport(
panel.headerBounds,
`${label}: header`
);
assertBoundsInside(
panel.statusBounds,
panel.headerBounds,
`${label}: status`
);
if (panel.preparationSummaryBounds) {
assertBoundsInside(
panel.preparationSummaryBounds,
panel.headerBounds,
`${label}: preparation summary`
);
assertNoOverlappingBounds(
[
{ id: 'status', bounds: panel.statusBounds },
{
id: 'preparation-summary',
bounds: panel.preparationSummaryBounds
}
],
`${label}: header text regions`
);
}
assertBoundsInsideViewport(
panel.noticeBounds,
`${label}: notice`
);
assertBoundsInside(
panel.noticeTextBounds,
panel.noticeBounds,
`${label}: notice text`
);
assertNoOverlappingBounds(
[
{ id: 'header', bounds: panel.headerBounds },
{ id: 'notice', bounds: panel.noticeBounds },
...panel.roleBounds.map((bounds, index) => ({
id: `role-${index + 1}`,
bounds
})),
{ id: 'restore', bounds: panel.restoreButtonBounds },
{ id: 'start', bounds: panel.startButtonBounds }
],
`${label}: primary deployment regions`
);
}
async function sceneTextValues(page, collectionName) {
return page.evaluate((requestedCollectionName) => {
const scene =
window.__HEROS_DEBUG__?.scene('BattleScene');
return (scene?.[requestedCollectionName] ?? [])
.filter(
(object) =>
object?.type === 'Text' &&
object.active &&
object.visible &&
typeof object.text === 'string'
)
.map((object) => object.text);
}, collectionName);
}
async function readBattle(page) {
return page.evaluate(
() => window.__HEROS_DEBUG__?.battle?.()
);
}
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, rendererFixture) {
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,
rendererType: window.__HEROS_GAME__?.renderer?.type,
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.rendererType,
rendererFixture.expectedRendererType
);
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.deepEqual(viewport.canvas?.bounds, {
x: 0,
y: 0,
width: desktopBrowserViewport.width,
height: desktopBrowserViewport.height
});
}
async function clickSceneBounds(page, sceneKey, bounds) {
assertFiniteBounds(bounds, `${sceneKey} click bounds`);
const point = await page.evaluate(
({ requestedSceneKey, requestedBounds }) => {
const scene =
window.__HEROS_DEBUG__?.scene(requestedSceneKey);
const canvas = document.querySelector('canvas');
const canvasBounds = canvas?.getBoundingClientRect();
if (!scene || !canvasBounds) {
return null;
}
return {
x:
canvasBounds.left +
(requestedBounds.x + requestedBounds.width / 2) *
canvasBounds.width /
scene.scale.width,
y:
canvasBounds.top +
(requestedBounds.y + requestedBounds.height / 2) *
canvasBounds.height /
scene.scale.height
};
},
{
requestedSceneKey: sceneKey,
requestedBounds: bounds
}
);
assert(
point && Number.isFinite(point.x) && Number.isFinite(point.y)
);
await page.mouse.click(point.x, point.y);
}
function assertFiniteBounds(bounds, label) {
assert(bounds, `${label}: bounds are required.`);
for (const key of ['x', 'y', 'width', 'height']) {
assert(
Number.isFinite(bounds[key]),
`${label}: ${key} must be finite.`
);
}
assert(bounds.width > 0, `${label}: width must be positive.`);
assert(bounds.height > 0, `${label}: height must be positive.`);
}
function assertBoundsInsideViewport(bounds, label) {
assertBoundsInside(
bounds,
{
x: 0,
y: 0,
width: desktopBrowserViewport.width,
height: desktopBrowserViewport.height
},
label
);
}
function assertBoundsInside(bounds, container, label) {
assertFiniteBounds(bounds, label);
assertFiniteBounds(container, `${label} container`);
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 }
)}`
);
}
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];
assertFiniteBounds(left.bounds, `${label} ${left.id}`);
assertFiniteBounds(right.bounds, `${label} ${right.id}`);
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 }
)}`
);
}
}
}
async function capture(page, path) {
await page.waitForTimeout(200);
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);
}
}
}
function screenshotPath(renderer, stage) {
return `dist/verification-xuzhou-stay-payoff-${renderer}-${stage}.png`;
}
function cliOption(name) {
const prefix = `--${name}=`;
const value = process.argv.find((argument) =>
argument.startsWith(prefix)
);
return value?.slice(prefix.length);
}