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 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); 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 )}` ); }