Files
heros_web/scripts/verify-battle-result-persistence-retry-browser.mjs

1575 lines
40 KiB
JavaScript

import assert from 'node:assert/strict';
import { spawnSync } from 'node:child_process';
import { fileURLToPath } from 'node:url';
import { chromium } from 'playwright';
import {
desktopBrowserContextOptions,
desktopBrowserDeviceScaleFactor,
desktopBrowserViewport
} from './desktop-browser-viewport.mjs';
import {
startVerificationPreview,
stopVerificationPreview
} from './verification-preview-server.mjs';
const rendererNames = ['canvas', 'webgl'];
const renderer =
process.env.VERIFY_BATTLE_RESULT_PERSISTENCE_RENDERER;
if (!renderer) {
for (const requestedRenderer of rendererNames) {
const result = spawnSync(
process.execPath,
[fileURLToPath(import.meta.url)],
{
cwd: process.cwd(),
env: {
...process.env,
VERIFY_BATTLE_RESULT_PERSISTENCE_RENDERER:
requestedRenderer
},
stdio: 'inherit'
}
);
if (result.status !== 0) {
process.exit(result.status ?? 1);
}
}
process.exit(0);
}
assert(
rendererNames.includes(renderer),
`Unsupported battle result persistence renderer "${renderer}".`
);
const battleId = 'first-battle-zhuo-commandery';
const firstPursuitAutoEntryTutorialIds = [
'first-pursuit-camp-auto-enter-scout-tent-scheduled',
'first-pursuit-camp-auto-enter-scout-tent'
];
const canonicalCampaignKey =
'heros-web:campaign-state:slot-1';
const currentCampaignKey = 'heros-web:campaign-state';
const baseUrl =
process.env.VERIFY_BATTLE_RESULT_PERSISTENCE_URL ??
`http://127.0.0.1:${
renderer === 'canvas' ? 41823 : 41824
}/heros_web/`;
const targetUrl = withDebugOptions(baseUrl, renderer);
let serverProcess;
let browser;
try {
serverProcess = await startVerificationPreview({
targetUrl,
explicitUrl: Boolean(
process.env
.VERIFY_BATTLE_RESULT_PERSISTENCE_URL
),
label:
`${renderer} battle result persistence verification`
});
browser = await chromium.launch({
headless:
process.env
.VERIFY_BATTLE_RESULT_PERSISTENCE_HEADLESS !== '0',
args:
renderer === 'webgl'
? [
'--use-angle=swiftshader',
'--enable-unsafe-swiftshader'
]
: []
});
const context = await browser.newContext(
desktopBrowserContextOptions
);
const page = await context.newPage();
page.setDefaultTimeout(30000);
const pageErrors = [];
const consoleErrors = [];
page.on('pageerror', (error) =>
pageErrors.push(error.stack ?? error.message)
);
page.on('console', (message) => {
if (message.type() === 'error') {
consoleErrors.push(message.text());
}
});
await page.goto(targetUrl, {
waitUntil: 'domcontentloaded',
timeout: 90000
});
await waitForDebugApi(page);
await page.evaluate(() => window.localStorage.clear());
await page.reload({
waitUntil: 'domcontentloaded',
timeout: 90000
});
await waitForTitle(page);
await assertDesktopRuntime(page, renderer);
await openBattle(page, battleId);
await waitForBattleReady(page, battleId);
const victoryRecommendation =
await installRecommendationFixture(
page,
battleId
);
assert(
victoryRecommendation.selectedUnitIds.length > 0,
`${renderer}: victory fixture has no selected units`
);
const beforeFailure = await readCampaignStorage(page);
assertCampaignMirrorsEqual(
beforeFailure,
`${renderer}: battle-entry baseline`
);
assert.equal(beforeFailure.current.step, 'first-battle');
assert.equal(
beforeFailure.current.firstBattleReport,
undefined
);
assert.equal(
beforeFailure.current.pendingAftermathBattleId,
undefined
);
assert.equal(
beforeFailure.current.storyCheckpoint,
undefined
);
await installOneShotCanonicalFailure(page);
await page.evaluate(() => {
window.__HEROS_DEBUG__?.forceBattleOutcome('victory');
});
const failedResult = await waitForResultPersistence(
page,
'victory',
'failed'
);
assert.equal(failedResult.battleOutcome, 'victory');
assert.equal(failedResult.phase, 'resolved');
assert.equal(failedResult.resultVisible, true);
assert.equal(
failedResult.resultSettlement.reportPersistence,
'failed'
);
assert.equal(
failedResult.resultSettlement.cta.state,
'persistence-retry'
);
assert.equal(
failedResult.resultSettlement.cta.label,
'저장 다시 시도'
);
assert.equal(
failedResult.resultSettlement.cta.interactive,
true
);
assertFiniteBounds(
failedResult.resultSettlement.cta.bounds
);
assertBoundsInsideViewport(
failedResult.resultSettlement.cta.bounds
);
const failureStats = await readFailureStats(page);
assert.deepEqual(
failureStats,
{
canonicalAttempts: 1,
injectedFailures: 1
},
`${renderer}: the canonical campaign slot must fail exactly once`
);
const afterFailure = await readCampaignStorage(page);
assert.deepEqual(
afterFailure.current,
beforeFailure.current,
`${renderer}: the compatibility campaign mirror changed after the canonical write failed`
);
assert.deepEqual(
afterFailure.slot,
beforeFailure.slot,
`${renderer}: the canonical campaign slot changed after its injected failure`
);
assert.deepEqual(
campaignOutcomeProjection(afterFailure.current),
campaignOutcomeProjection(beforeFailure.current),
`${renderer}: failed persistence leaked report, pending aftermath, or story checkpoint state`
);
await triggerResultContinueButton(page);
const recoveredResult = await waitForResultPersistence(
page,
'victory',
'complete'
);
assert.equal(recoveredResult.resultVisible, true);
assert.equal(
recoveredResult.resultSettlement.reportPersistence,
'complete'
);
assert.notEqual(
recoveredResult.resultSettlement.cta.state,
'persistence-retry'
);
assert.notEqual(
recoveredResult.resultSettlement.cta.label,
'저장 다시 시도'
);
const recoveredFailureStats = await readFailureStats(page);
assert.deepEqual(
recoveredFailureStats,
{
canonicalAttempts: 2,
injectedFailures: 1
},
`${renderer}: retry must recover after the one injected failure`
);
const afterRecovery = await readCampaignStorage(page);
assertCampaignMirrorsEqual(
afterRecovery,
`${renderer}: recovered battle result`
);
const finalState = afterRecovery.current;
assert.equal(finalState.step, 'first-camp');
assert.equal(
finalState.firstBattleReport?.battleId,
battleId
);
assert.equal(
finalState.firstBattleReport?.outcome,
'victory'
);
assert.equal(finalState.pendingAftermathBattleId, battleId);
assert.equal(
finalState.storyCheckpoint?.phase,
'reading'
);
assert.equal(
finalState.storyCheckpoint?.campaignStep,
'first-camp'
);
assert.equal(
typeof finalState.storyCheckpoint?.sequenceId,
'string'
);
assert.equal(
typeof finalState.storyCheckpoint?.beatId,
'string'
);
assert.equal(
finalState.storyCheckpointRevision,
beforeFailure.current.storyCheckpointRevision + 1,
`${renderer}: the failed attempt must not consume an extra story revision`
);
assert.deepEqual(
campaignOutcomeProjection(afterRecovery.current),
campaignOutcomeProjection(afterRecovery.slot),
`${renderer}: report, pending aftermath, and story checkpoint diverged between current and slot`
);
assertSingleSettlement(
beforeFailure.current,
finalState,
battleId,
renderer
);
await completeVisibleResultSettlement(page);
const victoryImprovement =
await openResultImprovementProbe(
page,
'victory'
);
assert.equal(
victoryImprovement.available,
true,
`${renderer}: victory recommendation fixture did not expose its next-sortie improvement action`
);
assert.equal(
victoryImprovement.interactive,
true,
`${renderer}: durable victory result did not enable its improvement action`
);
assertFiniteBounds(victoryImprovement.bounds);
assertBoundsInsideViewport(
victoryImprovement.bounds
);
await triggerResultImprovementButton(page);
await waitForActiveScene(page, 'StoryScene');
const victoryIntentBeforeStory =
await readCampaignStorage(page);
assertCampaignMirrorsEqual(
victoryIntentBeforeStory,
`${renderer}: victory improvement destination`
);
assertPreparationCheckpoint(
victoryIntentBeforeStory.current,
{
mode: 'next-sortie',
sourceBattleId: battleId,
targetBattleId:
'second-battle-yellow-turban-pursuit',
targetCampaignStep: 'second-battle'
},
`${renderer}: victory improvement destination`
);
assert.equal(
victoryIntentBeforeStory.current
.storyCheckpoint
?.continuationIntent,
'sortie-improvement',
`${renderer}: victory improvement and aftermath continuation were not committed together`
);
await completeActiveStoryToCamp(page);
await waitForCamp(page);
const autoEntryFixture = await page.evaluate(
({
currentKey,
canonicalKey,
tutorialIds
}) => {
window.__HEROS_GAME__?.loop?.sleep?.();
const canonicalRaw =
window.localStorage.getItem(canonicalKey);
if (!canonicalRaw) {
throw new Error(
'Canonical campaign save is unavailable.'
);
}
const campaign = JSON.parse(canonicalRaw);
campaign.completedTutorialIds = [
...new Set([
...(campaign.completedTutorialIds ?? []),
...tutorialIds
])
];
campaign.updatedAt = new Date().toISOString();
const serialized = JSON.stringify(campaign);
window.localStorage.setItem(currentKey, serialized);
window.localStorage.setItem(canonicalKey, serialized);
return tutorialIds.every((tutorialId) =>
campaign.completedTutorialIds.includes(tutorialId)
);
},
{
currentKey: currentCampaignKey,
canonicalKey: canonicalCampaignKey,
tutorialIds: firstPursuitAutoEntryTutorialIds
}
);
assert.equal(
autoEntryFixture,
true,
`${renderer}: the sortie-improvement fixture did not complete the separate automatic scout entry`
);
await reloadToTitle(page, renderer);
await triggerTitleContinue(page);
const restoredVictoryPreparation =
await waitForCampImprovement(
page,
{
mode: 'next-sortie',
targetBattleId:
'second-battle-yellow-turban-pursuit'
}
);
assert.equal(
restoredVictoryPreparation
.retrySortieBattleId,
null
);
assert.equal(
restoredVictoryPreparation
.sortieGuidedImprovement
?.targetBattleId,
'second-battle-yellow-turban-pursuit',
`${renderer}: victory reload did not restore the guided next-sortie overlay`
);
const victoryIntentAfterReload =
await readCampaignStorage(page);
assertPreparationCheckpoint(
victoryIntentAfterReload.current,
{
mode: 'next-sortie',
sourceBattleId: battleId,
targetBattleId:
'second-battle-yellow-turban-pursuit',
targetCampaignStep: 'second-battle'
},
`${renderer}: victory improvement reload`
);
await resetToFreshBattle(page, renderer, battleId);
const defeatRecommendation =
await installRecommendationFixture(
page,
battleId
);
assert(
defeatRecommendation.selectedUnitIds.length > 0,
`${renderer}: defeat fixture has no selected units`
);
const beforeDefeatFailure =
await readCampaignStorage(page);
assertCampaignMirrorsEqual(
beforeDefeatFailure,
`${renderer}: defeat battle-entry baseline`
);
assert.equal(
beforeDefeatFailure.current.step,
'first-battle'
);
assert.equal(
beforeDefeatFailure.current.firstBattleReport,
undefined
);
await installOneShotCanonicalFailure(page);
await page.evaluate(() => {
window.__HEROS_DEBUG__?.forceBattleOutcome('defeat');
});
const failedDefeatResult =
await waitForResultPersistence(
page,
'defeat',
'failed'
);
assert.equal(failedDefeatResult.phase, 'resolved');
assert.equal(failedDefeatResult.resultVisible, true);
assert.equal(
failedDefeatResult.resultSettlement.cta.state,
'persistence-retry'
);
assert.equal(
failedDefeatResult.resultSettlement.cta.label,
'저장 다시 시도'
);
assert.equal(
failedDefeatResult.resultSettlement.cta.interactive,
true
);
const failedDefeatActions =
resultActionMap(failedDefeatResult);
assert.equal(
failedDefeatActions.retry?.interactive,
false,
`${renderer}: normal retry must be disabled until the defeat report is durable`
);
assert.equal(
failedDefeatActions.title?.interactive,
false,
`${renderer}: title navigation must be disabled until the defeat report is durable`
);
assertFiniteBounds(failedDefeatActions.retry?.bounds);
assertFiniteBounds(failedDefeatActions.title?.bounds);
assertBoundsInsideViewport(
failedDefeatActions.retry.bounds
);
assertBoundsInsideViewport(
failedDefeatActions.title.bounds
);
const beforeBlockedDefeatNavigation =
await readDefeatFailureProbe(page);
await clickBounds(
page,
failedDefeatActions.retry.bounds
);
await page.waitForTimeout(160);
await clickBounds(
page,
failedDefeatActions.title.bounds
);
await page.waitForTimeout(160);
const afterBlockedResultActions =
await readDefeatFailureProbe(page);
assert.deepEqual(
afterBlockedResultActions,
beforeBlockedDefeatNavigation,
`${renderer}: disabled defeat retry/title buttons escaped the failed result`
);
const improvementProbe =
await openResultImprovementProbe(
page,
'defeat'
);
assert.equal(
improvementProbe.available,
true,
`${renderer}: defeat recommendation fixture did not expose its improvement action`
);
assert.equal(
improvementProbe.interactive,
false,
`${renderer}: formation-improvement navigation must be disabled until the report is durable`
);
assertFiniteBounds(improvementProbe.bounds);
assertBoundsInsideViewport(improvementProbe.bounds);
await clickBounds(page, improvementProbe.bounds);
await page.waitForTimeout(180);
const afterBlockedImprovement =
await readDefeatFailureProbe(page);
assert.deepEqual(
afterBlockedImprovement,
beforeBlockedDefeatNavigation,
`${renderer}: disabled formation improvement escaped the failed defeat result`
);
const afterDefeatFailure =
await readCampaignStorage(page);
assert.deepEqual(
afterDefeatFailure.current,
beforeDefeatFailure.current,
`${renderer}: current save changed after defeat persistence failed`
);
assert.deepEqual(
afterDefeatFailure.slot,
beforeDefeatFailure.slot,
`${renderer}: canonical slot changed after defeat persistence failed`
);
assert.deepEqual(
await readFailureStats(page),
{
canonicalAttempts: 1,
injectedFailures: 1
},
`${renderer}: blocked defeat navigation must not attempt another campaign write`
);
await closeResultImprovementProbe(page);
await triggerResultContinueButton(page);
const recoveredDefeatResult =
await waitForResultPersistence(
page,
'defeat',
'complete'
);
const recoveredDefeatActions =
resultActionMap(recoveredDefeatResult);
assert.equal(
recoveredDefeatActions.retry?.label,
'다시 하기'
);
assert.equal(
recoveredDefeatActions.retry?.interactive,
true,
`${renderer}: normal retry did not recover after the defeat report became durable`
);
assert.equal(
recoveredDefeatActions.title?.interactive,
true,
`${renderer}: title navigation did not recover after the defeat report became durable`
);
assertFiniteBounds(recoveredDefeatActions.retry?.bounds);
assertBoundsInsideViewport(
recoveredDefeatActions.retry.bounds
);
const afterDefeatRecovery =
await readCampaignStorage(page);
assertCampaignMirrorsEqual(
afterDefeatRecovery,
`${renderer}: recovered defeat result`
);
assert.equal(
afterDefeatRecovery.current.step,
'first-battle'
);
assert.equal(
afterDefeatRecovery.current.firstBattleReport?.outcome,
'defeat'
);
assert.equal(
afterDefeatRecovery.current.pendingAftermathBattleId,
undefined
);
assert.equal(
afterDefeatRecovery.current.storyCheckpoint,
undefined
);
assert.equal(
afterDefeatRecovery.current.storyCheckpointRevision,
beforeDefeatFailure.current.storyCheckpointRevision,
`${renderer}: defeat persistence retry must not consume a story revision`
);
assert.equal(
afterDefeatRecovery.current.gold,
beforeDefeatFailure.current.gold,
`${renderer}: defeat recovery must not grant victory gold`
);
assert.deepEqual(
await readFailureStats(page),
{
canonicalAttempts: 2,
injectedFailures: 1
},
`${renderer}: defeat persistence retry must recover after exactly one injected failure`
);
const recoveredDefeatImprovement =
await openResultImprovementProbe(
page,
'defeat'
);
assert.equal(
recoveredDefeatImprovement.available,
true
);
assert.equal(
recoveredDefeatImprovement.interactive,
true,
`${renderer}: recovered defeat did not enable retry improvement`
);
const durableBattleSaveKeys =
await readBattleSaveKeys(page);
assert(
durableBattleSaveKeys.length > 0,
`${renderer}: defeat regression fixture has no older battle save to outrank`
);
await triggerResultImprovementButton(page);
const defeatCamp =
await waitForCampImprovement(
page,
{
mode: 'retry',
targetBattleId: battleId
}
);
assert.equal(
defeatCamp.retrySortieBattleId,
battleId
);
const defeatIntentAtCamp =
await readCampaignStorage(page);
assertPreparationCheckpoint(
defeatIntentAtCamp.current,
{
mode: 'retry',
sourceBattleId: battleId,
targetBattleId: battleId,
targetCampaignStep: 'first-battle'
},
`${renderer}: defeat improvement camp arrival`
);
await reloadToTitle(page, renderer);
await triggerTitleContinue(page);
const restoredDefeatPreparation =
await waitForCampImprovement(
page,
{
mode: 'retry',
targetBattleId: battleId
}
);
assert.equal(
restoredDefeatPreparation
.retrySortieBattleId,
battleId
);
assert(
!restoredDefeatPreparation.activeScenes.includes(
'BattleScene'
),
`${renderer}: an older battle save outranked the explicit retry-preparation destination`
);
const defeatGenerationBeforeLaunch =
(
await readCampaignStorage(page)
).current.battleResumeGeneration;
await launchPreparedBattle(page);
await waitForBattleReady(page, battleId);
const restartedBattle = await page.evaluate(
() => window.__HEROS_DEBUG__?.battle?.()
);
assert.equal(restartedBattle.battleOutcome, null);
assert.equal(restartedBattle.resultVisible, false);
const defeatAfterLaunch =
await readCampaignStorage(page);
assert.equal(
defeatAfterLaunch.current
.sortiePreparationCheckpoint,
undefined,
`${renderer}: retry launch did not consume the durable preparation checkpoint`
);
assert.equal(
defeatAfterLaunch.current
.battleResumeGeneration,
defeatGenerationBeforeLaunch + 1,
`${renderer}: retry launch did not atomically advance its battle generation`
);
assert.equal(pageErrors.length, 0, pageErrors.join('\n'));
const unexpectedConsoleErrors = consoleErrors.filter(
(message) =>
!message.includes(
'Failed to persist the battle result.'
) &&
!message.includes('Injected canonical campaign slot failure')
);
assert.equal(
unexpectedConsoleErrors.length,
0,
unexpectedConsoleErrors.join('\n')
);
console.log(
JSON.stringify(
{
renderer,
viewport: desktopBrowserViewport,
deviceScaleFactor:
desktopBrowserDeviceScaleFactor,
cssZoom: 1,
canonicalFailure: failureStats,
recoveredAttempts: recoveredFailureStats,
failedCta:
failedResult.resultSettlement.cta.label,
reportPersistence:
recoveredResult.resultSettlement
.reportPersistence,
campaignStep: finalState.step,
pendingAftermathBattleId:
finalState.pendingAftermathBattleId,
storyCheckpoint:
finalState.storyCheckpoint,
rewardGold:
finalState.firstBattleReport.rewardGold,
finalGold: finalState.gold,
defeat: {
failedCta:
failedDefeatResult.resultSettlement.cta
.label,
blockedActions: {
retry:
failedDefeatActions.retry.interactive,
title:
failedDefeatActions.title.interactive,
improvement:
improvementProbe.interactive
},
recoveredRetry:
recoveredDefeatActions.retry.interactive,
restoredDestination:
restoredDefeatPreparation
.retrySortieBattleId,
reportOutcome:
afterDefeatRecovery.current
.firstBattleReport.outcome,
finalGold:
afterDefeatRecovery.current.gold
},
pageErrors: pageErrors.length,
consoleErrors: unexpectedConsoleErrors.length
},
null,
2
)
);
} finally {
await browser?.close();
await stopVerificationPreview(serverProcess);
}
function withDebugOptions(url, requestedRenderer) {
const parsed = new URL(url);
parsed.searchParams.set('debug', '1');
parsed.searchParams.set('renderer', requestedRenderer);
parsed.searchParams.set('motion', 'reduced');
return parsed.toString();
}
async function waitForDebugApi(page) {
await page.waitForFunction(
() =>
Boolean(
document.querySelector('canvas') &&
window.__HEROS_GAME__ &&
window.__HEROS_DEBUG__
),
undefined,
{ timeout: 90000 }
);
}
async function waitForTitle(page) {
await page.waitForFunction(
() =>
window.__HEROS_DEBUG__?.activeScenes?.().includes(
'TitleScene'
),
undefined,
{ timeout: 90000 }
);
}
async function openBattle(page, expectedBattleId) {
const started = await page.evaluate(async (requestedBattleId) => {
if (!window.__HEROS_DEBUG__) {
return false;
}
await window.__HEROS_DEBUG__.goToBattle(
requestedBattleId
);
return true;
}, expectedBattleId);
assert.equal(started, true);
}
async function waitForBattleReady(page, expectedBattleId) {
await page.waitForFunction(
(requestedBattleId) => {
const battle = window.__HEROS_DEBUG__?.battle?.();
return (
window.__HEROS_DEBUG__?.activeScenes?.().includes(
'BattleScene'
) &&
battle?.scene === 'BattleScene' &&
battle?.battleId === requestedBattleId &&
battle?.battleOutcome === null &&
['deployment', 'idle'].includes(battle?.phase) &&
battle?.mapBackgroundReady === true &&
battle?.resultVisible === false
);
},
expectedBattleId,
{ timeout: 90000 }
);
}
async function installOneShotCanonicalFailure(page) {
await page.evaluate((canonicalKey) => {
const storagePrototype = Object.getPrototypeOf(
window.localStorage
);
const originalSetItem =
storagePrototype.setItem;
const stats = {
canonicalAttempts: 0,
injectedFailures: 0
};
Object.defineProperty(
window,
'__HEROS_CANONICAL_FAILURE_STATS__',
{
configurable: true,
value: stats
}
);
storagePrototype.setItem = function setItem(
key,
value
) {
if (
this === window.localStorage &&
String(key) === canonicalKey
) {
stats.canonicalAttempts += 1;
if (stats.injectedFailures === 0) {
stats.injectedFailures += 1;
throw new DOMException(
'Injected canonical campaign slot failure',
'QuotaExceededError'
);
}
}
return originalSetItem.call(this, key, value);
};
}, canonicalCampaignKey);
}
async function readFailureStats(page) {
return page.evaluate(() => {
const stats =
window.__HEROS_CANONICAL_FAILURE_STATS__;
return stats
? {
canonicalAttempts: stats.canonicalAttempts,
injectedFailures: stats.injectedFailures
}
: null;
});
}
async function waitForResultPersistence(
page,
outcome,
status
) {
await page.waitForFunction(
({ expectedOutcome, expectedStatus }) => {
const battle = window.__HEROS_DEBUG__?.battle?.();
return (
battle?.battleOutcome === expectedOutcome &&
battle?.phase === 'resolved' &&
battle?.resultVisible === true &&
battle?.resultSettlement?.reportPersistence ===
expectedStatus &&
(
expectedStatus !== 'failed' ||
battle?.resultSettlement?.cta?.interactive ===
true
)
);
},
{
expectedOutcome: outcome,
expectedStatus: status
},
{ timeout: 90000 }
);
return page.evaluate(
() => window.__HEROS_DEBUG__?.battle?.()
);
}
async function readCampaignStorage(page) {
return page.evaluate(
({ currentKey, slotKey }) => {
const read = (key) => {
const raw = window.localStorage.getItem(key);
return raw ? JSON.parse(raw) : null;
};
return {
current: read(currentKey),
slot: read(slotKey)
};
},
{
currentKey: currentCampaignKey,
slotKey: canonicalCampaignKey
}
);
}
function assertCampaignMirrorsEqual(save, label) {
assert(save.current, `${label}: current save is missing.`);
assert(save.slot, `${label}: slot save is missing.`);
assert.deepEqual(
save.current,
save.slot,
`${label}: current and canonical slot differ`
);
}
function campaignOutcomeProjection(state) {
return {
firstBattleReport: state?.firstBattleReport ?? null,
pendingAftermathBattleId:
state?.pendingAftermathBattleId ?? null,
storyCheckpoint: state?.storyCheckpoint ?? null,
storyCheckpointRevision:
state?.storyCheckpointRevision ?? null
};
}
function resultActionMap(battle) {
return Object.fromEntries(
(battle?.resultActions ?? []).map((action) => [
action.kind,
action
])
);
}
function assertSingleSettlement(
baseline,
finalState,
expectedBattleId,
rendererLabel
) {
const report = finalState.firstBattleReport;
assert(report);
const orderRewardGold = report.sortieOrder?.rewardGranted
? {
elite: 180,
mobile: 160,
siege: 200
}[report.sortieOrder.orderId]
: 0;
assert.equal(
finalState.gold,
baseline.gold + report.rewardGold + orderRewardGold,
`${rendererLabel}: gold reflects more than one victory settlement`
);
assert.deepEqual(
Object.keys(finalState.battleHistory),
[expectedBattleId],
`${rendererLabel}: the retry created duplicate battle-history state`
);
assert.equal(
finalState.battleHistory[expectedBattleId]?.outcome,
'victory'
);
assert.equal(
new Set(finalState.claimedSortieOrderRewardIds).size,
finalState.claimedSortieOrderRewardIds.length,
`${rendererLabel}: sortie-order reward claims contain duplicates`
);
assert.equal(
new Set(finalState.roster.map((unit) => unit.id)).size,
finalState.roster.length,
`${rendererLabel}: the retry duplicated roster rewards`
);
}
async function triggerResultContinueButton(page) {
const triggered = await page.evaluate(() => {
const scene =
window.__HEROS_DEBUG__?.scene(
'BattleScene'
);
const button =
scene?.resultContinueButton?.background;
if (
!button?.active ||
!button.input?.enabled
) {
return false;
}
button.emit('pointerdown');
return true;
});
assert.equal(
triggered,
true,
`${renderer}: the visible persistence retry CTA could not be activated.`
);
}
async function clickBounds(page, bounds) {
assertFiniteBounds(bounds);
await page.mouse.click(
bounds.x + bounds.width / 2,
bounds.y + bounds.height / 2
);
}
async function resetToFreshBattle(
page,
expectedRenderer,
expectedBattleId
) {
await page.evaluate(() => window.localStorage.clear());
await page.reload({
waitUntil: 'domcontentloaded',
timeout: 90000
});
await waitForDebugApi(page);
await waitForTitle(page);
await assertDesktopRuntime(page, expectedRenderer);
await openBattle(page, expectedBattleId);
await waitForBattleReady(page, expectedBattleId);
}
async function installRecommendationFixture(
page,
expectedBattleId
) {
return page.evaluate((requestedBattleId) => {
const scene =
window.__HEROS_DEBUG__?.scene('BattleScene');
const battle =
window.__HEROS_DEBUG__?.battle?.();
if (!scene || battle?.battleId !== requestedBattleId) {
throw new Error(
'BattleScene is unavailable for the defeat recommendation fixture.'
);
}
const selectedUnitIds = battle.units
.filter((unit) => unit.faction === 'ally')
.map((unit) => unit.id);
const fallbackRoles = [
'front',
'flank',
'support',
'reserve'
];
const formationAssignments = Object.fromEntries(
selectedUnitIds.map((unitId, index) => [
unitId,
battle.sortieFormationAssignments?.[unitId] ??
fallbackRoles[index] ??
'reserve'
])
);
scene.launchSortieUnitIds = [...selectedUnitIds];
scene.launchSortieFormationAssignments = {
...formationAssignments
};
scene.launchSortieOrderId = 'elite';
scene.launchSortieRecommendation = {
version: 1,
battleId: requestedBattleId,
planId: 'order',
label: '저장 실패 회귀 추천안',
summary:
'패배 결과의 편성 보완 이동 잠금을 검증합니다.',
sortieOrderId: 'elite',
selectedUnitIds: [...selectedUnitIds],
formationAssignments: {
...formationAssignments
},
unitReasons: Object.fromEntries(
selectedUnitIds.map((unitId) => [
unitId,
'패배 결과 저장 경계조건 검증'
])
)
};
scene.saveBattleState?.(1);
return {
selectedUnitIds,
formationAssignments
};
}, expectedBattleId);
}
async function readDefeatFailureProbe(page) {
return page.evaluate(() => ({
activeScenes:
window.__HEROS_DEBUG__?.activeScenes?.() ?? [],
battleOutcome:
window.__HEROS_DEBUG__?.battle?.()?.battleOutcome ??
null,
reportPersistence:
window.__HEROS_DEBUG__?.battle?.()
?.resultSettlement?.reportPersistence ?? null,
campaignCurrent:
window.localStorage.getItem(
'heros-web:campaign-state'
),
campaignSlot:
window.localStorage.getItem(
'heros-web:campaign-state:slot-1'
),
failureStats: {
canonicalAttempts:
window.__HEROS_CANONICAL_FAILURE_STATS__
?.canonicalAttempts ?? null,
injectedFailures:
window.__HEROS_CANONICAL_FAILURE_STATS__
?.injectedFailures ?? null
}
}));
}
async function openResultImprovementProbe(
page,
outcome
) {
return page.evaluate((requestedOutcome) => {
const scene =
window.__HEROS_DEBUG__?.scene('BattleScene');
scene?.showResultFormationReview?.(
requestedOutcome
);
const debug =
window.__HEROS_DEBUG__?.battle?.();
const action =
debug?.sortieEvaluation?.improvementAction;
const button =
scene?.resultFormationReviewView
?.improvementButton;
const bounds = button?.getBounds?.();
return {
available: action?.available === true,
interactive: Boolean(button?.input?.enabled),
bounds: bounds
? {
x: bounds.x,
y: bounds.y,
width: bounds.width,
height: bounds.height
}
: null
};
}, outcome);
}
async function closeResultImprovementProbe(page) {
await page.evaluate(() => {
window.__HEROS_DEBUG__
?.scene('BattleScene')
?.hideResultFormationReview?.();
});
}
async function triggerResultImprovementButton(page) {
const triggered = await page.evaluate(() => {
const scene =
window.__HEROS_DEBUG__
?.scene('BattleScene');
const button =
scene?.resultFormationReviewView
?.improvementButton;
if (
!button?.active ||
!button.input?.enabled
) {
return false;
}
button.emit('pointerdown');
return true;
});
assert.equal(
triggered,
true,
`${renderer}: enabled result improvement action could not be activated`
);
}
async function completeVisibleResultSettlement(page) {
await page.evaluate(() => {
window.__HEROS_DEBUG__
?.scene('BattleScene')
?.completeResultSettlement?.(true);
});
}
async function waitForActiveScene(page, sceneKey) {
await page.waitForFunction(
(requestedSceneKey) =>
window.__HEROS_DEBUG__
?.activeScenes?.()
.includes(requestedSceneKey),
sceneKey,
{ timeout: 90000 }
);
}
async function completeActiveStoryToCamp(page) {
for (let attempt = 0; attempt < 80; attempt += 1) {
const state = await page.evaluate(() => {
const activeScenes =
window.__HEROS_DEBUG__
?.activeScenes?.() ?? [];
if (activeScenes.includes('CampScene')) {
return 'camp';
}
const story =
window.__HEROS_DEBUG__
?.scene('StoryScene');
if (
activeScenes.includes('StoryScene') &&
story &&
!story.transitioning
) {
story.advance?.();
}
return activeScenes.includes('StoryScene')
? 'story'
: 'other';
});
if (state === 'camp') {
return;
}
await page.waitForTimeout(250);
}
throw new Error(
`${renderer}: aftermath story did not reach CampScene`
);
}
async function waitForCamp(page) {
await page.waitForFunction(
() => {
const activeScenes =
window.__HEROS_DEBUG__
?.activeScenes?.() ?? [];
const camp =
window.__HEROS_DEBUG__?.camp?.();
return (
activeScenes.includes('CampScene') &&
camp?.scene === 'CampScene'
);
},
undefined,
{ timeout: 90000 }
);
}
async function reloadToTitle(page, expectedRenderer) {
await page.reload({
waitUntil: 'domcontentloaded',
timeout: 90000
});
await waitForDebugApi(page);
await waitForTitle(page);
await assertDesktopRuntime(
page,
expectedRenderer
);
}
async function triggerTitleContinue(page) {
const triggered = await page.evaluate(() => {
const title =
window.__HEROS_DEBUG__
?.scene('TitleScene');
if (!title) {
return false;
}
title.requestContinueGame?.();
return true;
});
assert.equal(
triggered,
true,
`${renderer}: title Continue could not be activated`
);
}
async function waitForCampImprovement(
page,
{ mode, targetBattleId }
) {
await page.waitForFunction(
({ expectedMode, expectedTargetBattleId }) => {
const activeScenes =
window.__HEROS_DEBUG__
?.activeScenes?.() ?? [];
const camp =
window.__HEROS_DEBUG__?.camp?.();
const checkpoint =
camp?.campaign
?.sortiePreparationCheckpoint;
return (
activeScenes.includes('CampScene') &&
!activeScenes.includes('BattleScene') &&
camp?.scene === 'CampScene' &&
camp?.sortieVisible === true &&
checkpoint?.intent ===
'sortie-improvement' &&
checkpoint?.mode === expectedMode &&
checkpoint?.targetBattleId ===
expectedTargetBattleId
);
},
{
expectedMode: mode,
expectedTargetBattleId: targetBattleId
},
{ timeout: 90000 }
);
return page.evaluate(() => ({
...window.__HEROS_DEBUG__?.camp?.(),
activeScenes:
window.__HEROS_DEBUG__
?.activeScenes?.() ?? []
}));
}
async function readBattleSaveKeys(page) {
return page.evaluate(() =>
Array.from(
{ length: window.localStorage.length },
(_, index) =>
window.localStorage.key(index)
)
.filter(
(key) =>
typeof key === 'string' &&
key.startsWith('heros-web:battle')
)
.sort()
);
}
async function launchPreparedBattle(page) {
const launched = await page.evaluate(() => {
const camp =
window.__HEROS_DEBUG__
?.scene('CampScene');
const checkpoint =
window.__HEROS_DEBUG__
?.camp?.()?.campaign
?.sortiePreparationCheckpoint;
if (!camp || !checkpoint) {
return false;
}
camp.startCampNavigationWithCampaignStep?.(
checkpoint.targetCampaignStep,
'BattleScene',
{
battleId:
checkpoint.targetBattleId,
selectedSortieUnitIds: [
...(camp.selectedSortieUnitIds ?? [])
],
sortieFormationAssignments: {
...(camp.sortieFormationAssignments ?? {})
},
sortieItemAssignments:
JSON.parse(
JSON.stringify(
camp.sortieItemAssignments ?? {}
)
),
sortieOrderId:
camp.currentSortieOrderId?.(),
sortieResonanceBondId:
camp.currentSortieResonanceBondId?.(),
sortieRecommendation:
camp.currentSortieRecommendationSelection?.()
}
);
return true;
});
assert.equal(
launched,
true,
`${renderer}: prepared battle launch could not start`
);
}
function assertPreparationCheckpoint(
state,
expected,
label
) {
const checkpoint =
state?.sortiePreparationCheckpoint;
assert(
checkpoint,
`${label}: missing sortie-preparation checkpoint`
);
assert.deepEqual(
{
intent: checkpoint.intent,
mode: checkpoint.mode,
sourceBattleId:
checkpoint.sourceBattleId,
targetBattleId:
checkpoint.targetBattleId,
targetCampaignStep:
checkpoint.targetCampaignStep
},
{
intent: 'sortie-improvement',
...expected
},
`${label}: restored the wrong preparation destination`
);
}
async function assertDesktopRuntime(page, expectedRenderer) {
const runtime = await page.evaluate(() => {
const canvas = document.querySelector('canvas');
const bounds = canvas?.getBoundingClientRect();
return {
innerWidth: window.innerWidth,
innerHeight: window.innerHeight,
devicePixelRatio: window.devicePixelRatio,
visualViewportScale:
window.visualViewport?.scale ?? 1,
rootCssZoom:
getComputedStyle(document.documentElement).zoom,
rendererType: window.__HEROS_GAME__?.renderer.type,
canvas: canvas
? {
width: canvas.width,
height: canvas.height,
clientWidth: canvas.clientWidth,
clientHeight: canvas.clientHeight,
bounds: bounds
? {
width: bounds.width,
height: bounds.height
}
: null
}
: null
};
});
assert.equal(
runtime.innerWidth,
desktopBrowserViewport.width
);
assert.equal(
runtime.innerHeight,
desktopBrowserViewport.height
);
assert.equal(
runtime.devicePixelRatio,
desktopBrowserDeviceScaleFactor
);
assert.equal(runtime.visualViewportScale, 1);
assert(
runtime.rootCssZoom === '1' ||
runtime.rootCssZoom === 'normal',
`Unexpected CSS zoom ${runtime.rootCssZoom}.`
);
assert.equal(
runtime.rendererType,
expectedRenderer === 'canvas' ? 1 : 2
);
assert.equal(
runtime.canvas?.width,
desktopBrowserViewport.width
);
assert.equal(
runtime.canvas?.height,
desktopBrowserViewport.height
);
assert.equal(
runtime.canvas?.clientWidth,
desktopBrowserViewport.width
);
assert.equal(
runtime.canvas?.clientHeight,
desktopBrowserViewport.height
);
assert.equal(
runtime.canvas?.bounds?.width,
desktopBrowserViewport.width
);
assert.equal(
runtime.canvas?.bounds?.height,
desktopBrowserViewport.height
);
}
function assertFiniteBounds(bounds) {
assert(bounds, 'Interactive bounds are required.');
for (const value of [
bounds.x,
bounds.y,
bounds.width,
bounds.height
]) {
assert(Number.isFinite(value));
}
assert(bounds.width > 0);
assert(bounds.height > 0);
}
function assertBoundsInsideViewport(bounds) {
assert(
bounds.x >= 0 &&
bounds.y >= 0 &&
bounds.x + bounds.width <=
desktopBrowserViewport.width &&
bounds.y + bounds.height <=
desktopBrowserViewport.height,
`Bounds are outside the FHD viewport: ${JSON.stringify(
bounds
)}`
);
}