1512 lines
43 KiB
JavaScript
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);
|
|
}
|