Files
heros_web/scripts/verify-battle-save-generation-browser.mjs

1540 lines
45 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 renderers = ['canvas', 'webgl'];
const renderer = process.env.VERIFY_BATTLE_SAVE_GENERATION_RENDERER;
if (!renderer) {
for (const requestedRenderer of renderers) {
const result = spawnSync(process.execPath, [fileURLToPath(import.meta.url)], {
cwd: process.cwd(),
env: {
...process.env,
VERIFY_BATTLE_SAVE_GENERATION_RENDERER: requestedRenderer
},
stdio: 'inherit'
});
if (result.status !== 0) {
process.exit(result.status ?? 1);
}
}
process.exit(0);
}
assert(
renderers.includes(renderer),
`Unsupported battle-save generation renderer "${renderer}".`
);
const battleId = 'first-battle-zhuo-commandery';
const campaignStep = 'first-battle';
const campaignGeneration = 4;
const staleGeneration = 3;
const campaignKey = 'heros-web:campaign-state';
const campaignSlotKey = `${campaignKey}:slot-1`;
const manualSaveSlotKey = 'heros-web:manual-save:slot-1';
const battleBaseKey = `heros-web:battle:${battleId}`;
const battleSlotKey = `${battleBaseKey}:slot-1`;
const legacyBattleKey = 'heros-web:first-battle-state';
const battleStorageKeys = [battleSlotKey, battleBaseKey, legacyBattleKey];
const otherBattleBaseKey =
'heros-web:battle:second-battle-yellow-turban-pursuit';
const otherBattleSlotKey = `${otherBattleBaseKey}:slot-1`;
const otherBattleCheckpoint =
'{"sentinel":"preserve-other-battle-checkpoint"}';
const baseUrl =
process.env.VERIFY_BATTLE_SAVE_GENERATION_URL ??
`http://127.0.0.1:${renderer === 'canvas' ? 41825 : 41826}/heros_web/`;
const targetUrl = withDebugOptions(baseUrl, renderer);
let browser;
let serverProcess;
try {
serverProcess = await startVerificationPreview({
targetUrl,
explicitUrl: Boolean(
process.env.VERIFY_BATTLE_SAVE_GENERATION_URL
),
label:
`${renderer} battle save generation verification`
});
browser = await chromium.launch({
headless: process.env.VERIFY_BATTLE_SAVE_GENERATION_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 page.evaluate(async (requestedBattleId) => {
await window.__HEROS_DEBUG__?.goToBattle(requestedBattleId);
}, battleId);
await waitForBattleReady(page);
const fixture = await page.evaluate(
({
requestedBattleId,
requestedCampaignStep,
requestedCampaignGeneration,
requestedStaleGeneration,
requestedCampaignKey,
requestedCampaignSlotKey,
requestedBattleBaseKey,
requestedBattleSlotKey,
requestedLegacyBattleKey,
requestedBattleStorageKeys
}) => {
const scene = window.__HEROS_DEBUG__?.scene('BattleScene');
if (
!scene ||
typeof scene.createBattleSaveState !== 'function' ||
typeof scene.saveBattleState !== 'function'
) {
throw new Error('BattleScene save internals are unavailable.');
}
// Persist a normalized campaign fixture and a fully valid battle-save
// shape through the production writer before applying generation markers.
scene.saveBattleState(1);
const campaignRaw =
window.localStorage.getItem(requestedCampaignSlotKey) ??
window.localStorage.getItem(requestedCampaignKey);
const battleRaw =
window.localStorage.getItem(requestedBattleSlotKey) ??
window.localStorage.getItem(requestedBattleBaseKey);
if (!campaignRaw || !battleRaw) {
throw new Error('The production save writer did not create the browser fixture.');
}
const campaign = JSON.parse(campaignRaw);
campaign.step = requestedCampaignStep;
campaign.activeSaveSlot = 1;
campaign.battleResumeGeneration = requestedCampaignGeneration;
campaign.storyCheckpoint = undefined;
campaign.explorationCheckpoint = undefined;
campaign.updatedAt = new Date().toISOString();
const serializedCampaign = JSON.stringify(campaign);
window.localStorage.setItem(requestedCampaignSlotKey, serializedCampaign);
window.localStorage.setItem(requestedCampaignKey, serializedCampaign);
const baseline = JSON.parse(battleRaw);
const markerIndex = baseline.units.findIndex((unit) => unit.id === 'liu-bei');
if (markerIndex < 0) {
throw new Error('The first-battle Liu Bei save fixture is unavailable.');
}
const baselineMarkerHp = baseline.units[markerIndex].hp;
const markerMaxHp = baseline.units[markerIndex].maxHp;
const staleMarkerHp = Math.max(1, Math.min(markerMaxHp, baselineMarkerHp - 7));
const validMarkerHp = Math.max(1, Math.min(markerMaxHp, baselineMarkerHp - 3));
if (
staleMarkerHp === baselineMarkerHp ||
validMarkerHp === baselineMarkerHp ||
staleMarkerHp === validMarkerHp
) {
throw new Error(
`Battle marker HP values are not distinguishable: ${JSON.stringify({
baselineMarkerHp,
staleMarkerHp,
validMarkerHp
})}`
);
}
const stale = structuredClone(baseline);
stale.battleId = requestedBattleId;
stale.campaignStep = requestedCampaignStep;
stale.battleResumeGeneration = requestedStaleGeneration;
stale.turnNumber = 8;
stale.activeFaction = 'ally';
stale.savedAt = new Date(Date.now() - 60_000).toISOString();
stale.units[markerIndex].hp = staleMarkerHp;
const valid = structuredClone(baseline);
valid.battleId = requestedBattleId;
valid.campaignStep = requestedCampaignStep;
valid.battleResumeGeneration = requestedCampaignGeneration;
valid.turnNumber = 6;
valid.activeFaction = 'ally';
valid.savedAt = new Date().toISOString();
valid.units[markerIndex].hp = validMarkerHp;
window.localStorage.removeItem(requestedBattleBaseKey);
window.localStorage.removeItem(requestedLegacyBattleKey);
window.localStorage.setItem(requestedBattleSlotKey, JSON.stringify(stale));
const storagePrototype = Object.getPrototypeOf(window.localStorage);
const originalRemoveItem = storagePrototype.removeItem;
const blockedKeys = new Set(requestedBattleStorageKeys);
const removalStats = {
attempts: 0,
blocked: 0,
keys: []
};
storagePrototype.removeItem = function removeItem(key) {
const normalizedKey = String(key);
if (this === window.localStorage && blockedKeys.has(normalizedKey)) {
removalStats.attempts += 1;
removalStats.blocked += 1;
removalStats.keys.push(normalizedKey);
throw new DOMException(
'Injected battle-save removal refusal',
'InvalidStateError'
);
}
return originalRemoveItem.call(this, key);
};
Object.defineProperty(window, '__HEROS_BATTLE_GENERATION_FIXTURE__', {
configurable: true,
value: {
baselineMarkerHp,
staleMarkerHp,
validMarkerHp,
valid,
removalStats,
restoreRemovalInterceptor() {
storagePrototype.removeItem = originalRemoveItem;
}
}
});
scene.scene.restart({
battleId: requestedBattleId,
resumeBattleSaveSlot: 1
});
return {
baselineMarkerHp,
staleMarkerHp,
validMarkerHp,
staleTurnNumber: stale.turnNumber,
validTurnNumber: valid.turnNumber
};
},
{
requestedBattleId: battleId,
requestedCampaignStep: campaignStep,
requestedCampaignGeneration: campaignGeneration,
requestedStaleGeneration: staleGeneration,
requestedCampaignKey: campaignKey,
requestedCampaignSlotKey: campaignSlotKey,
requestedBattleBaseKey: battleBaseKey,
requestedBattleSlotKey: battleSlotKey,
requestedLegacyBattleKey: legacyBattleKey,
requestedBattleStorageKeys: battleStorageKeys
}
);
await page.waitForFunction(
({ expectedBattleId, expectedBaselineHp }) => {
const battle = window.__HEROS_DEBUG__?.battle?.();
const fixtureState = window.__HEROS_BATTLE_GENERATION_FIXTURE__;
return (
battle?.battleId === expectedBattleId &&
battle?.mapBackgroundReady === true &&
['deployment', 'idle'].includes(battle?.phase) &&
battle?.turnNumber === 1 &&
battle?.units?.find((unit) => unit.id === 'liu-bei')?.hp ===
expectedBaselineHp &&
fixtureState?.removalStats?.blocked >= 1
);
},
{
expectedBattleId: battleId,
expectedBaselineHp: fixture.baselineMarkerHp
},
{ timeout: 90000 }
);
const staleProbe = await page.evaluate(
({
requestedBattleSlotKey,
requestedStaleGeneration
}) => {
const scene = window.__HEROS_DEBUG__?.scene('BattleScene');
if (!scene) {
throw new Error('BattleScene is unavailable after stale resume rejection.');
}
const rejected =
scene.readBattleSaveState?.(1, true) === undefined;
const loadDisabled =
scene.isMapMenuActionDisabled?.('load') === true;
scene.showSaveSlotPanel?.('load');
const panelBeforeSelection =
window.__HEROS_DEBUG__?.battle?.();
scene.activateSaveSlotPanelSlot?.(1);
const panelAfterSelection =
window.__HEROS_DEBUG__?.battle?.();
scene.hideSaveSlotPanel?.();
const persisted = JSON.parse(
window.localStorage.getItem(requestedBattleSlotKey) ?? 'null'
);
const fixtureState =
window.__HEROS_BATTLE_GENERATION_FIXTURE__;
return {
rejected,
loadDisabled,
panelMode:
panelBeforeSelection?.saveSlotPanelMode ?? null,
panelStayedOpen:
panelAfterSelection?.saveSlotPanelVisible === true,
promptMode:
panelAfterSelection?.turnPromptMode ?? null,
persistedGeneration:
persisted?.battleResumeGeneration ?? null,
removalStats: {
attempts:
fixtureState?.removalStats?.attempts ?? 0,
blocked:
fixtureState?.removalStats?.blocked ?? 0,
keys: [
...(fixtureState?.removalStats?.keys ?? [])
]
}
};
},
{
requestedBattleSlotKey: battleSlotKey,
requestedStaleGeneration: staleGeneration
}
);
assert.equal(
staleProbe.rejected,
true,
`${renderer}: BattleScene accepted a stale generation.`
);
assert.equal(
staleProbe.loadDisabled,
true,
`${renderer}: manual Load remained enabled for a stale save.`
);
assert.equal(staleProbe.panelMode, 'load');
assert.equal(
staleProbe.panelStayedOpen,
true,
`${renderer}: selecting a stale Load row should be a no-op.`
);
assert.equal(
staleProbe.promptMode,
null,
`${renderer}: a stale save reached the Load confirmation prompt.`
);
assert.equal(
staleProbe.persistedGeneration,
staleGeneration,
`${renderer}: the removal-refusal fixture was not preserved.`
);
assert(
staleProbe.removalStats.blocked >= 1,
`${renderer}: no stale-save cleanup refusal was exercised.`
);
assert(
staleProbe.removalStats.keys.every((key) =>
battleStorageKeys.includes(key)
),
`${renderer}: removal refusal escaped the intended battle keys.`
);
await page.evaluate(() => {
window.__HEROS_BATTLE_GENERATION_FIXTURE__
?.restoreRemovalInterceptor?.();
});
const beforeControl = await page.evaluate(
({
requestedBattleSlotKey,
requestedCampaignSlotKey,
requestedManualSaveSlotKey,
requestedCampaignGeneration
}) => {
const scene = window.__HEROS_DEBUG__?.scene('BattleScene');
const fixtureState =
window.__HEROS_BATTLE_GENERATION_FIXTURE__;
if (!scene || !fixtureState?.valid) {
throw new Error('The current-generation control fixture is unavailable.');
}
window.localStorage.setItem(
requestedBattleSlotKey,
JSON.stringify(fixtureState.valid)
);
const campaign = JSON.parse(
window.localStorage.getItem(
requestedCampaignSlotKey
) ?? 'null'
);
if (!campaign) {
throw new Error('The campaign fixture is unavailable for the protected manual control.');
}
window.localStorage.setItem(
requestedManualSaveSlotKey,
JSON.stringify({
version: 1,
slot: 1,
savedAt: fixtureState.valid.savedAt,
context: 'battle',
campaign,
battle: fixtureState.valid
})
);
const accepted = scene.readBattleSaveState?.(1, true);
const acceptedManual =
scene.readManualBattleSaveState?.(1, true);
const loadDisabled =
scene.isMapMenuActionDisabled?.('load') === true;
scene.showSaveSlotPanel?.('load');
const panel =
window.__HEROS_DEBUG__?.battle?.();
scene.activateSaveSlotPanelSlot?.(1);
const confirmation =
window.__HEROS_DEBUG__?.battle?.();
return {
acceptedGeneration:
accepted?.battleResumeGeneration ?? null,
acceptedTurnNumber:
accepted?.turnNumber ?? null,
acceptedManualTurnNumber:
acceptedManual?.turnNumber ?? null,
loadDisabled,
panelMode: panel?.saveSlotPanelMode ?? null,
confirmationMode:
confirmation?.turnPromptMode ?? null,
confirmationVisible:
confirmation?.turnPromptVisible === true,
expectedGeneration: requestedCampaignGeneration
};
},
{
requestedBattleSlotKey: battleSlotKey,
requestedCampaignSlotKey: campaignSlotKey,
requestedManualSaveSlotKey: manualSaveSlotKey,
requestedCampaignGeneration: campaignGeneration
}
);
assert.equal(
beforeControl.acceptedGeneration,
campaignGeneration,
`${renderer}: the current-generation control was not accepted.`
);
assert.equal(
beforeControl.acceptedTurnNumber,
fixture.validTurnNumber
);
assert.equal(
beforeControl.acceptedManualTurnNumber,
fixture.validTurnNumber
);
assert.equal(beforeControl.loadDisabled, false);
assert.equal(beforeControl.panelMode, 'load');
assert.equal(beforeControl.confirmationMode, 'load-confirm');
assert.equal(beforeControl.confirmationVisible, true);
await page.evaluate(() => {
const scene = window.__HEROS_DEBUG__?.scene('BattleScene');
scene?.activateTurnPromptPrimaryAction?.();
});
try {
await page.waitForFunction(
({
expectedBattleId,
expectedTurnNumber,
expectedMarkerHp
}) => {
const battle = window.__HEROS_DEBUG__?.battle?.();
return (
battle?.battleId === expectedBattleId &&
battle?.turnNumber === expectedTurnNumber &&
battle?.activeFaction === 'ally' &&
battle?.phase === 'idle' &&
battle?.units?.find((unit) => unit.id === 'liu-bei')?.hp ===
expectedMarkerHp
);
},
{
expectedBattleId: battleId,
expectedTurnNumber: fixture.validTurnNumber,
expectedMarkerHp: fixture.validMarkerHp
},
{ timeout: 90000 }
);
} catch (error) {
const restoreDiagnostics = await page.evaluate(
({
requestedCampaignSlotKey,
requestedBattleSlotKey,
requestedManualSaveSlotKey
}) => ({
battle:
window.__HEROS_DEBUG__?.battle?.() ?? null,
campaign: JSON.parse(
window.localStorage.getItem(
requestedCampaignSlotKey
) ?? 'null'
),
battleSave: JSON.parse(
window.localStorage.getItem(
requestedBattleSlotKey
) ?? 'null'
),
manualSave: JSON.parse(
window.localStorage.getItem(
requestedManualSaveSlotKey
) ?? 'null'
)
}),
{
requestedCampaignSlotKey: campaignSlotKey,
requestedBattleSlotKey: battleSlotKey,
requestedManualSaveSlotKey: manualSaveSlotKey
}
);
throw new Error(
`${renderer}: protected manual restore did not become ready: ${JSON.stringify({
restoreDiagnostics,
pageErrors,
consoleErrors
})}`,
{ cause: error }
);
}
const restored = await page.evaluate(
({
requestedCampaignKey,
requestedCampaignSlotKey
}) => {
const battle = window.__HEROS_DEBUG__?.battle?.();
const current = JSON.parse(
window.localStorage.getItem(requestedCampaignKey) ?? 'null'
);
const slot = JSON.parse(
window.localStorage.getItem(requestedCampaignSlotKey) ?? 'null'
);
return {
turnNumber: battle?.turnNumber ?? null,
activeFaction: battle?.activeFaction ?? null,
markerHp:
battle?.units?.find((unit) => unit.id === 'liu-bei')?.hp ?? null,
campaignGeneration:
current?.battleResumeGeneration ?? null,
slotGeneration:
slot?.battleResumeGeneration ?? null,
campaignStep: current?.step ?? null,
slotStep: slot?.step ?? null
};
},
{
requestedCampaignKey: campaignKey,
requestedCampaignSlotKey: campaignSlotKey
}
);
assert.equal(restored.turnNumber, fixture.validTurnNumber);
assert.equal(restored.activeFaction, 'ally');
assert.equal(restored.markerHp, fixture.validMarkerHp);
assert.equal(restored.campaignGeneration, campaignGeneration);
assert.equal(restored.slotGeneration, campaignGeneration);
assert.equal(restored.campaignStep, campaignStep);
assert.equal(restored.slotStep, campaignStep);
const tutorialPendingEventProbe = await page.evaluate(() => {
const scene =
window.__HEROS_DEBUG__?.scene('BattleScene');
if (
!scene ||
typeof scene.scheduleFirstBattleTutorial !==
'function'
) {
throw new Error(
'The first-battle tutorial resume probe is unavailable.'
);
}
const originalStart =
scene.startFirstBattleTutorial;
const originalDelayedCall =
scene.time.delayedCall;
const originalState = {
phase: scene.phase,
activeFaction: scene.activeFaction,
turnNumber: scene.turnNumber,
firstBattleTutorialStep:
scene.firstBattleTutorialStep,
activeBattleEvent: scene.activeBattleEvent,
battleEventQueue: scene.battleEventQueue,
battleEventObjects: scene.battleEventObjects
};
let tutorialStartCalls = 0;
let retryCalls = 0;
try {
scene.phase = 'idle';
scene.activeFaction = 'ally';
scene.turnNumber = 1;
scene.firstBattleTutorialStep = undefined;
scene.activeBattleEvent = undefined;
scene.battleEventObjects = [];
scene.battleEventQueue = [{
key: 'qa-restored-pending-event',
title: 'Restored pending event',
lines: ['This event must display before tutorial input.'],
priority: 'normal',
playCue: false
}];
scene.startFirstBattleTutorial = () => {
tutorialStartCalls += 1;
return true;
};
scene.time.delayedCall = () => {
retryCalls += 1;
return { remove() {} };
};
scene.scheduleFirstBattleTutorial();
return {
tutorialStartCalls,
retryCalls,
pendingEventCount:
scene.battleEventQueue.length
};
} finally {
scene.startFirstBattleTutorial = originalStart;
scene.time.delayedCall = originalDelayedCall;
scene.phase = originalState.phase;
scene.activeFaction = originalState.activeFaction;
scene.turnNumber = originalState.turnNumber;
scene.firstBattleTutorialStep =
originalState.firstBattleTutorialStep;
scene.activeBattleEvent =
originalState.activeBattleEvent;
scene.battleEventQueue =
originalState.battleEventQueue;
scene.battleEventObjects =
originalState.battleEventObjects;
}
});
assert.deepEqual(
tutorialPendingEventProbe,
{
tutorialStartCalls: 0,
retryCalls: 1,
pendingEventCount: 1
},
`${renderer}: a restored pending battle event was not allowed to finish before the first-battle tutorial resumed.`
);
const autosaveContract = await page.evaluate(() => {
const scene = window.__HEROS_DEBUG__?.scene('BattleScene');
const battle = window.__HEROS_DEBUG__?.battle?.();
const fixtureState =
window.__HEROS_BATTLE_GENERATION_FIXTURE__;
fixtureState?.restoreRemovalInterceptor?.();
return {
sceneMethod:
typeof scene?.persistBattleAutosave === 'function',
debugState:
battle?.autosave ?? null
};
});
assert.equal(
autosaveContract.sceneMethod,
true,
`${renderer}: BattleScene.persistBattleAutosave(reason) is unavailable.`
);
assertAutosaveDebugState(
autosaveContract.debugState,
`${renderer}: initial autosave debug state`
);
const actionBaseline = await readAutosaveCheckpoint(
page,
battleSlotKey
);
const actionTrigger = await page.evaluate(() => {
const scene = window.__HEROS_DEBUG__?.scene('BattleScene');
const unit = scene?.debugUnitById?.('liu-bei');
if (
!scene ||
!unit ||
typeof scene.completeUnitAction !== 'function'
) {
throw new Error(
'The ally-action autosave trigger is unavailable.'
);
}
scene.phase = 'command';
scene.activeFaction = 'ally';
scene.selectedUnit = unit;
scene.pendingMove = undefined;
scene.targetingAction = undefined;
scene.selectedUsable = undefined;
scene.completeUnitAction(unit, 'wait');
return {
phase:
window.__HEROS_DEBUG__?.battle?.()?.phase ?? null,
actedUnitIds: [
...(window.__HEROS_DEBUG__?.battle?.()?.actedUnitIds ?? [])
]
};
});
assert(
actionTrigger.actedUnitIds.includes('liu-bei'),
`${renderer}: the ally action did not complete.`
);
await page.waitForFunction(
({
requestedBattleSlotKey,
previousSavedAt,
previousWriteCount
}) => {
const persisted = JSON.parse(
window.localStorage.getItem(requestedBattleSlotKey) ??
'null'
);
const autosave =
window.__HEROS_DEBUG__?.battle?.()?.autosave;
return (
persisted?.savedAt &&
persisted.savedAt !== previousSavedAt &&
persisted.actedUnitIds?.includes('liu-bei') &&
autosave?.writeCount === previousWriteCount + 1 &&
autosave?.lastResult === 'saved' &&
autosave?.lastReason === 'ally-action'
);
},
{
requestedBattleSlotKey: battleSlotKey,
previousSavedAt: actionBaseline.state.savedAt,
previousWriteCount:
actionBaseline.autosave.writeCount
}
);
const actionAutosave = await readAutosaveCheckpoint(
page,
battleSlotKey
);
assertAutosaveWrite(
actionBaseline,
actionAutosave,
{
reason: 'ally-action',
expectedTurnNumber: fixture.validTurnNumber,
expectedActedUnitId: 'liu-bei'
},
`${renderer}: ally action autosave`
);
assert.equal(
actionAutosave.autosave.indicatorVisible,
true,
`${renderer}: a successful ally-action autosave did not expose visible save feedback.`
);
assert.match(
actionAutosave.autosave.indicatorText ?? '',
/자동 저장 완료/,
`${renderer}: the success save indicator was not clearly labeled.`
);
const unsafeAutosaves = [];
for (const [
unsafeIndex,
unsafePhase
] of ['command', 'animating'].entries()) {
const beforeUnsafe = await readAutosaveCheckpoint(
page,
battleSlotKey
);
const unsafeTurnNumber = 41 + unsafeIndex;
const eventProbe = await page.evaluate(
({
requestedPhase,
requestedTurnNumber
}) => {
const scene =
window.__HEROS_DEBUG__?.scene('BattleScene');
const unit = scene?.debugUnitById?.('liu-bei');
if (
!scene ||
!unit ||
typeof scene.persistBattleAutosave !== 'function'
) {
throw new Error(
'The unsafe pagehide autosave probe is unavailable.'
);
}
scene.turnNumber = requestedTurnNumber;
scene.activeFaction = 'ally';
scene.phase = requestedPhase;
scene.selectedUnit =
requestedPhase === 'command' ? unit : undefined;
scene.pendingMove = undefined;
scene.targetingAction = undefined;
scene.selectedUsable = undefined;
window.dispatchEvent(new Event('pagehide'));
const battle =
window.__HEROS_DEBUG__?.battle?.();
return {
phase: battle?.phase ?? null,
turnNumber: battle?.turnNumber ?? null,
autosave: battle?.autosave ?? null
};
},
{
requestedPhase: unsafePhase,
requestedTurnNumber: unsafeTurnNumber
}
);
const afterUnsafe = await readAutosaveCheckpoint(
page,
battleSlotKey
);
assert.equal(eventProbe.phase, unsafePhase);
assert.equal(
eventProbe.turnNumber,
unsafeTurnNumber
);
assertAutosaveDebugState(
eventProbe.autosave,
`${renderer}: ${unsafePhase} pagehide debug state`
);
assert.equal(
afterUnsafe.raw,
beforeUnsafe.raw,
`${renderer}: ${unsafePhase} pagehide overwrote the last safe checkpoint.`
);
assert.equal(
afterUnsafe.autosave.writeCount,
beforeUnsafe.autosave.writeCount,
`${renderer}: ${unsafePhase} pagehide incremented the autosave write count.`
);
assert.equal(
afterUnsafe.autosave.skipCount,
beforeUnsafe.autosave.skipCount + 1,
`${renderer}: ${unsafePhase} pagehide did not record one safe-state skip.`
);
assert.equal(
afterUnsafe.autosave.failureCount,
beforeUnsafe.autosave.failureCount,
`${renderer}: ${unsafePhase} pagehide was reported as a failure instead of a skip.`
);
assert.equal(
afterUnsafe.autosave.lastResult,
'skipped'
);
assert.equal(
afterUnsafe.autosave.lastReason,
'pagehide'
);
assert.equal(
afterUnsafe.autosave.lastSavedAt,
beforeUnsafe.state.savedAt
);
unsafeAutosaves.push({
phase: unsafePhase,
turnNumber: unsafeTurnNumber,
writeCount: afterUnsafe.autosave.writeCount,
skipCount: afterUnsafe.autosave.skipCount,
checkpointSavedAt: afterUnsafe.state.savedAt
});
}
const readinessBaseline = await readAutosaveCheckpoint(
page,
battleSlotKey
);
const readinessTurnNumber = 42;
const readinessProbe = await page.evaluate(
(requestedTurnNumber) => {
const scene =
window.__HEROS_DEBUG__?.scene('BattleScene');
if (!scene) {
throw new Error(
'The autosave readiness probe is unavailable.'
);
}
scene.turnNumber = requestedTurnNumber;
scene.activeFaction = 'ally';
scene.phase = 'idle';
scene.selectedUnit = undefined;
scene.pendingMove = undefined;
scene.targetingAction = undefined;
scene.selectedUsable = undefined;
scene.battleAutosaveReady = false;
window.dispatchEvent(new Event('pagehide'));
const blocked =
window.__HEROS_DEBUG__?.battle?.()?.autosave ??
null;
scene.battleAutosaveReady = true;
return blocked;
},
readinessTurnNumber
);
const readinessAfter = await readAutosaveCheckpoint(
page,
battleSlotKey
);
assertAutosaveDebugState(
readinessProbe,
`${renderer}: pre-initialization pagehide debug state`
);
assert.equal(
readinessAfter.raw,
readinessBaseline.raw,
`${renderer}: a pagehide before battle initialization replaced the last safe checkpoint.`
);
assert.equal(
readinessAfter.autosave.writeCount,
readinessBaseline.autosave.writeCount
);
assert.equal(
readinessAfter.autosave.skipCount,
readinessBaseline.autosave.skipCount + 1
);
assert.equal(
readinessAfter.autosave.lastResult,
'skipped'
);
assert.equal(
readinessAfter.autosave.lastReason,
'pagehide'
);
const idleBaseline = await readAutosaveCheckpoint(
page,
battleSlotKey
);
const idleTurnNumber = 43;
const idleMarkerHp = Math.max(
1,
fixture.validMarkerHp - 1
);
await page.evaluate(
({
requestedTurnNumber,
requestedMarkerHp
}) => {
const scene =
window.__HEROS_DEBUG__?.scene('BattleScene');
const unit = scene?.debugUnitById?.('liu-bei');
if (
!scene ||
!unit ||
typeof scene.persistBattleAutosave !== 'function'
) {
throw new Error(
'The idle pagehide autosave probe is unavailable.'
);
}
scene.turnNumber = requestedTurnNumber;
scene.activeFaction = 'ally';
scene.phase = 'idle';
scene.selectedUnit = undefined;
scene.pendingMove = undefined;
scene.targetingAction = undefined;
scene.selectedUsable = undefined;
unit.hp = requestedMarkerHp;
window.dispatchEvent(new Event('pagehide'));
},
{
requestedTurnNumber: idleTurnNumber,
requestedMarkerHp: idleMarkerHp
}
);
await page.waitForFunction(
({
requestedBattleSlotKey,
previousSavedAt,
previousWriteCount,
expectedTurnNumber,
expectedMarkerHp
}) => {
const persisted = JSON.parse(
window.localStorage.getItem(requestedBattleSlotKey) ??
'null'
);
const autosave =
window.__HEROS_DEBUG__?.battle?.()?.autosave;
return (
persisted?.savedAt &&
persisted.savedAt !== previousSavedAt &&
persisted.turnNumber === expectedTurnNumber &&
persisted.units?.find(
(unit) => unit.id === 'liu-bei'
)?.hp === expectedMarkerHp &&
autosave?.writeCount === previousWriteCount + 1 &&
autosave?.lastResult === 'saved' &&
autosave?.lastReason === 'pagehide'
);
},
{
requestedBattleSlotKey: battleSlotKey,
previousSavedAt: idleBaseline.state.savedAt,
previousWriteCount:
idleBaseline.autosave.writeCount,
expectedTurnNumber: idleTurnNumber,
expectedMarkerHp: idleMarkerHp
}
);
const idleAutosave = await readAutosaveCheckpoint(
page,
battleSlotKey
);
assertAutosaveWrite(
idleBaseline,
idleAutosave,
{
reason: 'pagehide',
expectedTurnNumber: idleTurnNumber,
expectedActedUnitId: 'liu-bei',
expectedMarkerHp: idleMarkerHp
},
`${renderer}: ally idle pagehide autosave`
);
assert.equal(
idleAutosave.autosave.skipCount,
idleBaseline.autosave.skipCount,
`${renderer}: a safe idle pagehide was counted as skipped.`
);
const rollbackBaseline = await readAutosaveCheckpoint(
page,
battleSlotKey
);
const rollbackBaseRaw = await page.evaluate(
(requestedBattleBaseKey) =>
window.localStorage.getItem(requestedBattleBaseKey),
battleBaseKey
);
const rollbackProbe = await page.evaluate(
({
requestedBattleSlotKey,
requestedBattleBaseKey,
requestedOtherBattleSlotKey,
requestedOtherBattleBaseKey,
requestedOtherBattleCheckpoint
}) => {
const scene =
window.__HEROS_DEBUG__?.scene('BattleScene');
if (
!scene ||
typeof scene.persistBattleAutosave !== 'function'
) {
throw new Error(
'The autosave rollback probe is unavailable.'
);
}
scene.battleAutosaveReady = true;
scene.activeFaction = 'ally';
scene.phase = 'idle';
scene.selectedUnit = undefined;
scene.pendingMove = undefined;
scene.targetingAction = undefined;
scene.selectedUsable = undefined;
scene.turnNumber = 44;
window.localStorage.setItem(
requestedOtherBattleSlotKey,
requestedOtherBattleCheckpoint
);
window.localStorage.setItem(
requestedOtherBattleBaseKey,
requestedOtherBattleCheckpoint
);
const storagePrototype =
Object.getPrototypeOf(window.localStorage);
const originalSetItem = storagePrototype.setItem;
let injected = false;
storagePrototype.setItem = function setItem(
key,
value
) {
if (
this === window.localStorage &&
String(key) === requestedBattleSlotKey &&
!injected
) {
injected = true;
throw new DOMException(
'Injected autosave write refusal',
'QuotaExceededError'
);
}
return originalSetItem.call(this, key, value);
};
let saved;
try {
saved = scene.persistBattleAutosave('debug');
} finally {
storagePrototype.setItem = originalSetItem;
}
return {
saved,
injected,
slotRaw: window.localStorage.getItem(
requestedBattleSlotKey
),
baseRaw: window.localStorage.getItem(
requestedBattleBaseKey
),
otherBattleSlotRaw: window.localStorage.getItem(
requestedOtherBattleSlotKey
),
otherBattleBaseRaw: window.localStorage.getItem(
requestedOtherBattleBaseKey
),
autosave:
window.__HEROS_DEBUG__?.battle?.()?.autosave ??
null
};
},
{
requestedBattleSlotKey: battleSlotKey,
requestedBattleBaseKey: battleBaseKey,
requestedOtherBattleSlotKey: otherBattleSlotKey,
requestedOtherBattleBaseKey: otherBattleBaseKey,
requestedOtherBattleCheckpoint:
otherBattleCheckpoint
}
);
assert.equal(rollbackProbe.injected, true);
assert.equal(rollbackProbe.saved, false);
assert.equal(
rollbackProbe.slotRaw,
rollbackBaseline.raw,
`${renderer}: a failed autosave did not restore the canonical slot checkpoint.`
);
assert.equal(
rollbackProbe.baseRaw,
rollbackBaseRaw,
`${renderer}: a failed autosave did not restore the slot-one compatibility checkpoint.`
);
assert.equal(
rollbackProbe.otherBattleSlotRaw,
otherBattleCheckpoint,
`${renderer}: a failed autosave discarded another battle's canonical checkpoint in the same campaign slot.`
);
assert.equal(
rollbackProbe.otherBattleBaseRaw,
otherBattleCheckpoint,
`${renderer}: a failed autosave discarded another battle's compatibility checkpoint in the same campaign slot.`
);
assert.equal(
rollbackProbe.autosave.writeCount,
rollbackBaseline.autosave.writeCount
);
assert.equal(
rollbackProbe.autosave.skipCount,
rollbackBaseline.autosave.skipCount
);
assert.equal(
rollbackProbe.autosave.failureCount,
rollbackBaseline.autosave.failureCount + 1
);
assert.equal(
rollbackProbe.autosave.lastResult,
'failed'
);
assert.equal(
rollbackProbe.autosave.lastReason,
'debug'
);
assert.equal(
rollbackProbe.autosave.lastSavedAt,
rollbackBaseline.state.savedAt
);
assert.equal(
rollbackProbe.autosave.indicatorVisible,
true,
`${renderer}: a failed autosave did not expose a persistent visible warning.`
);
assert.match(
rollbackProbe.autosave.indicatorText ?? '',
/자동 저장 실패/,
`${renderer}: the failed autosave warning was not clearly labeled.`
);
assert.equal(pageErrors.length, 0, pageErrors.join('\n'));
assert.equal(consoleErrors.length, 0, consoleErrors.join('\n'));
console.log(
JSON.stringify(
{
renderer,
viewport: desktopBrowserViewport,
deviceScaleFactor: desktopBrowserDeviceScaleFactor,
cssZoom: 1,
stale: {
campaignGeneration,
saveGeneration: staleGeneration,
requestedTurnNumber: fixture.staleTurnNumber,
liveTurnNumber: 1,
loadDisabled: staleProbe.loadDisabled,
removalRefusal: staleProbe.removalStats
},
control: {
campaignGeneration,
saveGeneration: beforeControl.acceptedGeneration,
restoredTurnNumber: restored.turnNumber,
restoredMarkerHp: restored.markerHp,
pendingEventBeforeTutorial:
tutorialPendingEventProbe
},
autosave: {
allyAction: {
phaseAfterAction:
actionTrigger.phase,
turnNumber:
actionAutosave.state.turnNumber,
actedUnitIds:
actionAutosave.state.actedUnitIds,
savedAt:
actionAutosave.state.savedAt,
debug:
actionAutosave.autosave
},
unsafePagehide:
unsafeAutosaves,
initializationGuard: {
turnNumber: readinessTurnNumber,
debug: readinessAfter.autosave
},
idlePagehide: {
turnNumber:
idleAutosave.state.turnNumber,
markerHp:
idleAutosave.state.units.find(
(unit) => unit.id === 'liu-bei'
)?.hp ?? null,
savedAt:
idleAutosave.state.savedAt,
debug:
idleAutosave.autosave
},
failedWriteRollback: {
slotPreserved:
rollbackProbe.slotRaw ===
rollbackBaseline.raw,
basePreserved:
rollbackProbe.baseRaw ===
rollbackBaseRaw,
otherBattleSlotPreserved:
rollbackProbe.otherBattleSlotRaw ===
otherBattleCheckpoint,
otherBattleBasePreserved:
rollbackProbe.otherBattleBaseRaw ===
otherBattleCheckpoint,
debug: rollbackProbe.autosave
}
},
pageErrors: pageErrors.length,
consoleErrors: consoleErrors.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 waitForBattleReady(page) {
await page.waitForFunction(
(expectedBattleId) => {
const battle = window.__HEROS_DEBUG__?.battle?.();
return (
window.__HEROS_DEBUG__?.activeScenes?.().includes('BattleScene') &&
battle?.scene === 'BattleScene' &&
battle?.battleId === expectedBattleId &&
battle?.battleOutcome === null &&
['deployment', 'idle'].includes(battle?.phase) &&
battle?.mapBackgroundReady === true &&
battle?.resultVisible === false
);
},
battleId,
{ timeout: 90000 }
);
}
async function readAutosaveCheckpoint(
page,
requestedBattleSlotKey
) {
const checkpoint = await page.evaluate(
(storageKey) => {
const raw = window.localStorage.getItem(storageKey);
const battle =
window.__HEROS_DEBUG__?.battle?.();
return {
raw,
state: JSON.parse(raw ?? 'null'),
autosave: battle?.autosave ?? null
};
},
requestedBattleSlotKey
);
assert(
checkpoint.raw,
`Missing battle autosave checkpoint at ${requestedBattleSlotKey}.`
);
assert(
checkpoint.state,
`Invalid battle autosave checkpoint at ${requestedBattleSlotKey}.`
);
assertAutosaveDebugState(
checkpoint.autosave,
`Autosave debug state for ${requestedBattleSlotKey}`
);
return checkpoint;
}
function assertAutosaveDebugState(
autosave,
label
) {
assert(
autosave && typeof autosave === 'object',
`${label} is unavailable.`
);
for (const field of [
'writeCount',
'skipCount',
'failureCount'
]) {
assert(
Number.isInteger(autosave[field]) &&
autosave[field] >= 0,
`${label}.${field} must be a non-negative integer.`
);
}
assert(
[
'saved',
'skipped',
'failed',
null
].includes(autosave.lastResult),
`${label}.lastResult is invalid.`
);
assert(
autosave.lastReason === null ||
typeof autosave.lastReason === 'string',
`${label}.lastReason is invalid.`
);
assert(
autosave.lastSlot === null ||
autosave.lastSlot === 1,
`${label}.lastSlot is invalid.`
);
assert(
autosave.lastSavedAt === null ||
(
typeof autosave.lastSavedAt === 'string' &&
Number.isFinite(
Date.parse(autosave.lastSavedAt)
)
),
`${label}.lastSavedAt is invalid.`
);
assert(
typeof autosave.indicatorVisible === 'boolean',
`${label}.indicatorVisible must be boolean.`
);
assert(
autosave.indicatorText === null ||
typeof autosave.indicatorText === 'string',
`${label}.indicatorText is invalid.`
);
}
function assertAutosaveWrite(
before,
after,
{
reason,
expectedTurnNumber,
expectedActedUnitId,
expectedMarkerHp
},
label
) {
assert.notEqual(
after.raw,
before.raw,
`${label} did not update the active battle slot.`
);
assert.notEqual(
after.state.savedAt,
before.state.savedAt,
`${label} did not refresh savedAt.`
);
assert.equal(
after.state.turnNumber,
expectedTurnNumber,
`${label} saved the wrong turn.`
);
assert(
after.state.actedUnitIds.includes(
expectedActedUnitId
),
`${label} omitted the completed ally action.`
);
if (expectedMarkerHp !== undefined) {
assert.equal(
after.state.units.find(
(unit) => unit.id === 'liu-bei'
)?.hp,
expectedMarkerHp,
`${label} did not persist the live ally state.`
);
}
assert.equal(
after.autosave.writeCount,
before.autosave.writeCount + 1,
`${label} did not record exactly one write.`
);
assert.equal(
after.autosave.skipCount,
before.autosave.skipCount,
`${label} unexpectedly recorded a skip.`
);
assert.equal(
after.autosave.failureCount,
before.autosave.failureCount,
`${label} unexpectedly recorded a failure.`
);
assert.equal(
after.autosave.lastResult,
'saved'
);
assert.equal(
after.autosave.lastReason,
reason
);
assert.equal(after.autosave.lastSlot, 1);
assert.equal(
after.autosave.lastSavedAt,
after.state.savedAt
);
}
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);
}