Files
heros_web/scripts/verify-manual-save-ux-browser.mjs

2040 lines
46 KiB
JavaScript

import assert from 'node:assert/strict';
import { spawn } from 'node:child_process';
import { mkdir } from 'node:fs/promises';
import { chromium } from 'playwright';
import {
desktopBrowserContextOptions,
desktopBrowserDeviceScaleFactor,
desktopBrowserViewport
} from './desktop-browser-viewport.mjs';
const battleId = 'first-battle-zhuo-commandery';
const campaignKey = 'heros-web:campaign-state';
const campaignSlotKey = (slot) =>
`${campaignKey}:slot-${slot}`;
const battleBaseKey = `heros-web:battle:${battleId}`;
const battleSlotKey = (slot) =>
`${battleBaseKey}:slot-${slot}`;
const manualSlotKey = (slot) =>
`heros-web:manual-save:slot-${slot}`;
const otherBattleSlotTwoKey =
'heros-web:battle:second-battle-yellow-turban-pursuit:slot-2';
const explicitUrl =
process.env.VERIFY_MANUAL_SAVE_UX_URL;
const requestedRenderer =
process.env.VERIFY_MANUAL_SAVE_UX_RENDERER;
const renderers = requestedRenderer
? [requestedRenderer]
: ['canvas', 'webgl'];
assert(
renderers.every((renderer) =>
['canvas', 'webgl'].includes(renderer)
),
'VERIFY_MANUAL_SAVE_UX_RENDERER must be canvas or webgl.'
);
const serverUrl = new URL(
explicitUrl ??
'http://127.0.0.1:41937/heros_web/'
);
let browser;
let serverProcess;
try {
await mkdir('dist', { recursive: true });
if (!explicitUrl) {
serverProcess = spawn(
process.execPath,
[
'node_modules/vite/bin/vite.js',
'--host',
'127.0.0.1',
'--port',
serverUrl.port,
'--strictPort'
],
{
cwd: process.cwd(),
stdio: 'ignore',
windowsHide: true
}
);
await waitForUrl(serverUrl, 30_000);
}
browser = await chromium.launch({
headless:
process.env.VERIFY_MANUAL_SAVE_UX_HEADLESS !==
'0',
args: [
'--use-angle=swiftshader',
'--enable-unsafe-swiftshader'
]
});
for (const renderer of renderers) {
await verifyRenderer(browser, renderer);
}
console.info(
`Verified separate auto/manual title focus, damaged-record visibility, protected-byte invariance, explicit restore confirmation/cancellation, camp and battle restore routing, exact turn/faction resume, numeric shortcuts, Escape handling, and 1920x1080 ${renderers.join('/')} rendering.`
);
} finally {
await browser?.close();
await stopServerProcess(serverProcess);
}
async function verifyRenderer(
browserInstance,
renderer
) {
const context =
await browserInstance.newContext(
desktopBrowserContextOptions
);
const page = await context.newPage();
await page.emulateMedia({
reducedMotion: 'reduce'
});
page.setDefaultTimeout(30_000);
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());
console.info(
`[${renderer}] browser console error: ${message.text()}`
);
}
});
try {
const reportStep = (step) =>
console.info(`[${renderer}] ${step}`);
const targetUrl = new URL(serverUrl);
targetUrl.searchParams.set('debug', '1');
targetUrl.searchParams.set(
'renderer',
renderer
);
await page.goto(targetUrl.href, {
waitUntil: 'domcontentloaded',
timeout: 90_000
});
await waitForDebugApi(page);
await page.evaluate(() =>
window.localStorage.clear()
);
await page.reload({
waitUntil: 'domcontentloaded',
timeout: 90_000
});
await waitForTitle(page);
await assertDesktopRuntime(
page,
renderer,
'TitleScene'
);
reportStep('empty title');
await verifyEmptyTitleState(
page,
renderer
);
const seededCampaign =
await seedAutomaticCampaign(page);
reportStep('automatic-only title');
await verifyAutoOnlyTitleState(
page,
renderer
);
reportStep('camp manual save');
await openCamp(page);
await assertDesktopRuntime(
page,
renderer,
'CampScene'
);
const campSave =
await verifyCampManualSaveIsolation(
page,
renderer,
seededCampaign
);
reportStep('title camp restore');
await verifyTitleCampRestore(
page,
renderer,
campSave
);
reportStep('battle manual save');
await openBattle(page);
await assertDesktopRuntime(
page,
renderer,
'BattleScene'
);
const battleSave =
await verifyBattleManualSaveIsolation(
page,
renderer
);
reportStep('title battle restore');
await verifyTitleBattleRestore(
page,
renderer,
battleSave
);
reportStep('complete');
assert.deepEqual(
pageErrors,
[],
`${renderer}: page errors:\n${pageErrors.join(
'\n'
)}`
);
assert.deepEqual(
consoleErrors,
[],
`${renderer}: console errors:\n${consoleErrors.join(
'\n'
)}`
);
} finally {
await context.close();
}
}
async function verifyEmptyTitleState(
page,
renderer
) {
const title = await titleState(page);
assert.equal(title.focus.scope, 'main');
assert.equal(title.focus.itemId, 'new-game');
assertTitleMainItems(title, {
continueEnabled: false,
manualEnabled: false
});
const texts = await visibleSceneTexts(
page,
'TitleScene'
);
assert(
texts.includes('자동 저장 이어하기'),
`${renderer}: the empty title did not expose the automatic-continue label.`
);
assert(
texts.includes('수동 기록 불러오기'),
`${renderer}: the empty title did not expose the separate manual-load label.`
);
await page.keyboard.press('ArrowDown');
await waitForTitleFocus(
page,
'main',
'settings'
);
await page.keyboard.press('ArrowUp');
await waitForTitleFocus(
page,
'main',
'new-game'
);
await page.evaluate((key) => {
window.localStorage.setItem(
key,
'{"version":999,"slot":1}'
);
window.__HEROS_GAME__.scene
.getScene('TitleScene')
.scene.restart();
}, manualSlotKey(1));
await page.waitForFunction(() => {
const title =
window.__HEROS_DEBUG__?.title?.();
return (
title?.focus?.scope === 'main' &&
title.focus.items.some(
(item) =>
item.id === 'manual-load' &&
item.enabled === true
)
);
});
await page.keyboard.press('ArrowDown');
await waitForTitleFocus(
page,
'main',
'manual-load'
);
await page.keyboard.press('Enter');
await waitForTitleFocus(
page,
'save-slots',
'close'
);
await waitForVisibleSceneText(
page,
'TitleScene',
'손상된 수동 기록'
);
const damagedLabels =
await visibleSceneTexts(
page,
'TitleScene'
);
assert(
damagedLabels.includes(
'손상된 수동 기록'
),
`${renderer}: a damaged-only manual record cannot be inspected.`
);
await page.keyboard.press('Escape');
await waitForTitleFocus(
page,
'main',
'manual-load'
);
await page.evaluate((key) => {
window.localStorage.removeItem(key);
window.__HEROS_GAME__.scene
.getScene('TitleScene')
.scene.restart();
}, manualSlotKey(1));
await page.waitForFunction(() => {
const title =
window.__HEROS_DEBUG__?.title?.();
return (
title?.focus?.scope === 'main' &&
title.focus.items.some(
(item) =>
item.id === 'manual-load' &&
item.enabled === false
)
);
});
}
async function seedAutomaticCampaign(page) {
const state = await page.evaluate(async () => {
const campaignModule = await import(
'/heros_web/src/game/state/campaignState.ts'
);
const started =
campaignModule.startNewCampaign();
const fixture = structuredClone(started);
fixture.step = 'first-battle';
fixture.activeSaveSlot = 1;
fixture.gold = 431;
fixture.storyCheckpoint = undefined;
fixture.explorationCheckpoint = undefined;
fixture.sortiePreparationCheckpoint =
undefined;
fixture.completedTutorialIds = [
...new Set([
...fixture.completedTutorialIds,
'first-battle-basic-controls'
])
];
const saved =
campaignModule.setCampaignState(fixture);
window.__HEROS_GAME__.scene
.getScene('TitleScene')
.scene.restart();
return saved;
});
await waitForTitle(page);
await waitForTitleFocus(
page,
'main',
'continue'
);
return state;
}
async function verifyAutoOnlyTitleState(
page,
renderer
) {
const title = await titleState(page);
assert.equal(title.focus.itemId, 'continue');
assertTitleMainItems(title, {
continueEnabled: true,
manualEnabled: false
});
await page.keyboard.press('ArrowDown');
const skipped = await waitForTitleFocus(
page,
'main',
'settings'
);
assert.equal(
skipped.focus.itemId,
'settings',
`${renderer}: the disabled manual-load item was not skipped.`
);
await page.keyboard.press('ArrowUp');
await waitForTitleFocus(
page,
'main',
'continue'
);
}
async function openCamp(page) {
await page.evaluate(async () => {
await window.__HEROS_DEBUG__.goToCamp();
});
await page.waitForFunction(
() => {
const state =
window.__HEROS_DEBUG__?.camp?.();
const scene =
window.__HEROS_GAME__?.scene.getScene(
'CampScene'
);
return (
state?.scene === 'CampScene' &&
state?.campaign?.step ===
'first-battle' &&
scene?.scene?.isActive?.() === true &&
scene.children.list.some(
(child) =>
child?.active &&
child?.visible &&
child?.text === '수동 저장'
)
);
},
undefined,
{ timeout: 90_000 }
);
}
async function verifyCampManualSaveIsolation(
page,
renderer,
seededCampaign
) {
const fixture = await page.evaluate(
({
requestedCampaignKey,
requestedCampaignSlotKey,
requestedBattleBaseKey,
requestedBattleSlotKey
}) => {
const battleMarker = JSON.stringify({
kind: 'automatic-battle-sentinel',
revision: 1
});
window.localStorage.setItem(
requestedBattleBaseKey,
battleMarker
);
window.localStorage.setItem(
requestedBattleSlotKey,
battleMarker
);
const keys = [
requestedCampaignKey,
requestedCampaignSlotKey,
requestedBattleBaseKey,
requestedBattleSlotKey
];
return {
keys,
bytes: Object.fromEntries(
keys.map((key) => [
key,
window.localStorage.getItem(key)
])
)
};
},
{
requestedCampaignKey: campaignKey,
requestedCampaignSlotKey:
campaignSlotKey(1),
requestedBattleBaseKey: battleBaseKey,
requestedBattleSlotKey:
battleSlotKey(1)
}
);
fixture.keys.forEach((key) => {
assert.notEqual(
fixture.bytes[key],
null,
`${renderer}: missing automatic fixture byte ${key}.`
);
});
await clickSceneText(
page,
'CampScene',
'수동 저장'
);
await waitForCampSavePanel(page, true);
let labels = await visibleSceneTexts(
page,
'CampScene'
);
assert(
labels.includes('수동 저장 기록'),
`${renderer}: the camp manual-save panel title is missing.`
);
assert(
labels.some((label) =>
label.includes(
'자동 저장과 별도 보관'
)
),
`${renderer}: the camp panel does not explain automatic/manual separation.`
);
await page.screenshot({
path:
`dist/qa-manual-save-camp-${renderer}-1920x1080.png`,
fullPage: true
});
await page.keyboard.press('Escape');
await waitForCampSavePanel(page, false);
await clickSceneText(
page,
'CampScene',
'수동 저장'
);
await waitForCampSavePanel(page, true);
await page.keyboard.press('2');
try {
await page.waitForFunction(
(key) =>
window.localStorage.getItem(key) !== null,
manualSlotKey(2),
{ timeout: 5_000 }
);
} catch (error) {
const diagnostic = await page.evaluate(
(key) => {
const scene =
window.__HEROS_GAME__?.scene.getScene(
'CampScene'
);
return {
active:
scene?.scene?.isActive?.(),
navigationPending:
scene?.navigationPending,
saveSlotObjects:
scene?.saveSlotObjects?.length,
saveSlotConfirmObjects:
scene?.saveSlotConfirmObjects?.length,
stored:
window.localStorage.getItem(key),
texts: scene?.children?.list
?.filter(
(child) =>
child?.active &&
child?.visible &&
typeof child?.text === 'string'
)
.map((child) => child.text)
};
},
manualSlotKey(2)
);
throw new Error(
`${renderer}: camp numeric shortcut did not save: ${JSON.stringify(
diagnostic
)}`,
{ cause: error }
);
}
await waitForCampSavePanel(page, false);
const afterSave = await page.evaluate(
({ keys, requestedManualKey }) => ({
bytes: Object.fromEntries(
keys.map((key) => [
key,
window.localStorage.getItem(key)
])
),
manualRaw:
window.localStorage.getItem(
requestedManualKey
)
}),
{
keys: fixture.keys,
requestedManualKey: manualSlotKey(2)
}
);
assert.deepEqual(
afterSave.bytes,
fixture.bytes,
`${renderer}: camp manual save modified automatic campaign/battle bytes.`
);
assert(afterSave.manualRaw);
const manual = JSON.parse(afterSave.manualRaw);
assert.equal(manual.version, 1);
assert.equal(manual.slot, 2);
assert.equal(manual.context, 'camp');
assert.equal(manual.battle, undefined);
assert.equal(
manual.campaign.step,
'first-battle'
);
assert.equal(
manual.campaign.gold,
seededCampaign.gold
);
const autoMutation = await page.evaluate(
async ({
requestedManualKey,
requestedBattleBaseKey,
requestedBattleSlotKey
}) => {
const manualBefore =
window.localStorage.getItem(
requestedManualKey
);
const campaignModule = await import(
'/heros_web/src/game/state/campaignState.ts'
);
const changed =
campaignModule.getCampaignState();
changed.gold += 87;
const auto =
campaignModule.saveCampaignState(
changed,
1
);
const changedBattle = JSON.stringify({
kind: 'automatic-battle-sentinel',
revision: 2
});
window.localStorage.setItem(
requestedBattleBaseKey,
changedBattle
);
window.localStorage.setItem(
requestedBattleSlotKey,
changedBattle
);
return {
autoGold: auto.gold,
manualBefore,
manualAfter:
window.localStorage.getItem(
requestedManualKey
)
};
},
{
requestedManualKey: manualSlotKey(2),
requestedBattleBaseKey: battleBaseKey,
requestedBattleSlotKey:
battleSlotKey(1)
}
);
assert.equal(
autoMutation.manualAfter,
autoMutation.manualBefore,
`${renderer}: later automatic progress changed the protected camp record bytes.`
);
assert.notEqual(
autoMutation.autoGold,
manual.campaign.gold
);
return {
raw: afterSave.manualRaw,
envelope: manual,
autoSlotOneRaw:
await page.evaluate(
(key) =>
window.localStorage.getItem(key),
campaignSlotKey(1)
)
};
}
async function verifyTitleCampRestore(
page,
renderer,
campSave
) {
await startTitle(page);
await assertDesktopRuntime(
page,
renderer,
'TitleScene'
);
let title = await titleState(page);
assert.equal(title.focus.itemId, 'continue');
assertTitleMainItems(title, {
continueEnabled: true,
manualEnabled: true
});
await page.keyboard.press('ArrowDown');
await waitForTitleFocus(
page,
'main',
'manual-load'
);
await page.keyboard.press('ArrowUp');
await waitForTitleFocus(
page,
'main',
'continue'
);
await page.keyboard.press('ArrowDown');
await waitForTitleFocus(
page,
'main',
'manual-load'
);
await page.keyboard.press('Enter');
title = await waitForTitleFocus(
page,
'save-slots',
'slot-2'
);
await waitForVisibleSceneText(
page,
'TitleScene',
'수동 기록 불러오기 (1~3)'
);
assert.equal(
title.panels.saveSlotMode,
'manual'
);
assert.equal(
focusItem(title, 'slot-1').enabled,
false
);
assert.equal(
focusItem(title, 'slot-2').enabled,
true
);
assert.equal(
focusItem(title, 'slot-3').enabled,
false
);
await page.keyboard.press('1');
title = await titleState(page);
assert.equal(title.panels.saveSlots, true);
assert.equal(title.focus.itemId, 'slot-2');
await page.keyboard.press('3');
title = await titleState(page);
assert.equal(title.panels.saveSlots, true);
assert.equal(title.focus.itemId, 'slot-2');
await page.screenshot({
path:
`dist/qa-manual-save-title-camp-${renderer}-1920x1080.png`,
fullPage: true
});
await page.keyboard.press('Escape');
await waitForTitleFocus(
page,
'main',
'manual-load'
);
const targetFixture = await page.evaluate(
({
requestedCampaignKey,
requestedTargetCampaignKey,
requestedTargetBattleKey,
requestedOtherBattleKey
}) => {
const current = JSON.parse(
window.localStorage.getItem(
requestedCampaignKey
)
);
const occupied = {
...current,
activeSaveSlot: 2,
gold: 9_999,
updatedAt:
'2026-07-29T01:02:03.000Z'
};
window.localStorage.setItem(
requestedTargetCampaignKey,
JSON.stringify(occupied)
);
window.localStorage.setItem(
requestedTargetBattleKey,
'{"stale":"target-battle"}'
);
window.localStorage.setItem(
requestedOtherBattleKey,
'{"stale":"other-battle"}'
);
return {
slotOneRaw:
window.localStorage.getItem(
`${requestedCampaignKey}:slot-1`
),
targetCampaignRaw:
window.localStorage.getItem(
requestedTargetCampaignKey
),
targetBattleRaw:
window.localStorage.getItem(
requestedTargetBattleKey
),
otherBattleRaw:
window.localStorage.getItem(
requestedOtherBattleKey
)
};
},
{
requestedCampaignKey: campaignKey,
requestedTargetCampaignKey:
campaignSlotKey(2),
requestedTargetBattleKey:
battleSlotKey(2),
requestedOtherBattleKey:
otherBattleSlotTwoKey
}
);
await page.evaluate(() => {
const scene =
window.__HEROS_GAME__.scene.getScene(
'TitleScene'
);
window.__MANUAL_SAVE_QA_CONTINUE_SLOT__ =
undefined;
scene.continueGame = (slot) => {
window.__MANUAL_SAVE_QA_CONTINUE_SLOT__ =
slot;
};
});
await page.keyboard.press('Enter');
await waitForTitleFocus(
page,
'save-slots',
'slot-2'
);
await page.keyboard.press('2');
title = await waitForTitleFocus(
page,
'manual-load-confirm',
'cancel'
);
assert.equal(
title.panels.manualLoadConfirm,
true
);
assert.equal(
title.panels.manualLoadConfirmSlot,
2
);
const beforeConfirmation = await page.evaluate(
({
requestedTargetCampaignKey,
requestedTargetBattleKey,
requestedOtherBattleKey,
requestedManualKey
}) => ({
targetCampaignRaw:
window.localStorage.getItem(
requestedTargetCampaignKey
),
targetBattleRaw:
window.localStorage.getItem(
requestedTargetBattleKey
),
otherBattleRaw:
window.localStorage.getItem(
requestedOtherBattleKey
),
manualRaw:
window.localStorage.getItem(
requestedManualKey
)
}),
{
requestedTargetCampaignKey:
campaignSlotKey(2),
requestedTargetBattleKey:
battleSlotKey(2),
requestedOtherBattleKey:
otherBattleSlotTwoKey,
requestedManualKey:
manualSlotKey(2)
}
);
assert.equal(
beforeConfirmation.targetCampaignRaw,
targetFixture.targetCampaignRaw,
`${renderer}: opening the manual-load confirmation changed the target automatic campaign.`
);
assert.equal(
beforeConfirmation.targetBattleRaw,
targetFixture.targetBattleRaw,
`${renderer}: opening the manual-load confirmation changed the target battle autosave.`
);
assert.equal(
beforeConfirmation.otherBattleRaw,
targetFixture.otherBattleRaw,
`${renderer}: opening the manual-load confirmation changed another battle autosave.`
);
assert.equal(
beforeConfirmation.manualRaw,
campSave.raw
);
await page.evaluate(
() =>
new Promise((resolve) =>
window.requestAnimationFrame(() =>
window.requestAnimationFrame(resolve)
)
)
);
await page.screenshot({
path:
`dist/qa-manual-save-title-confirm-${renderer}-1920x1080.png`,
fullPage: true
});
await page.keyboard.press('Escape');
await waitForTitleFocus(
page,
'save-slots',
'slot-2'
);
const afterCancel = await page.evaluate(
({
requestedTargetCampaignKey,
requestedTargetBattleKey,
requestedOtherBattleKey
}) => ({
targetCampaignRaw:
window.localStorage.getItem(
requestedTargetCampaignKey
),
targetBattleRaw:
window.localStorage.getItem(
requestedTargetBattleKey
),
otherBattleRaw:
window.localStorage.getItem(
requestedOtherBattleKey
)
}),
{
requestedTargetCampaignKey:
campaignSlotKey(2),
requestedTargetBattleKey:
battleSlotKey(2),
requestedOtherBattleKey:
otherBattleSlotTwoKey
}
);
assert.deepEqual(
afterCancel,
{
targetCampaignRaw:
targetFixture.targetCampaignRaw,
targetBattleRaw:
targetFixture.targetBattleRaw,
otherBattleRaw:
targetFixture.otherBattleRaw
},
`${renderer}: cancelling manual restore changed automatic save bytes.`
);
await page.keyboard.press('2');
await waitForTitleFocus(
page,
'manual-load-confirm',
'cancel'
);
await page.keyboard.press('ArrowUp');
await waitForTitleFocus(
page,
'manual-load-confirm',
'confirm'
);
await page.keyboard.press('Enter');
await page.waitForFunction(
() =>
window.__MANUAL_SAVE_QA_CONTINUE_SLOT__ ===
2
);
const restored = await page.evaluate(
({
requestedCampaignKey,
requestedTargetCampaignKey,
requestedManualKey,
requestedTargetBattleKey,
requestedOtherBattleKey
}) => ({
baseRaw:
window.localStorage.getItem(
requestedCampaignKey
),
targetRaw:
window.localStorage.getItem(
requestedTargetCampaignKey
),
slotOneRaw:
window.localStorage.getItem(
`${requestedCampaignKey}:slot-1`
),
manualRaw:
window.localStorage.getItem(
requestedManualKey
),
targetBattleRaw:
window.localStorage.getItem(
requestedTargetBattleKey
),
otherBattleRaw:
window.localStorage.getItem(
requestedOtherBattleKey
)
}),
{
requestedCampaignKey: campaignKey,
requestedTargetCampaignKey:
campaignSlotKey(2),
requestedManualKey: manualSlotKey(2),
requestedTargetBattleKey:
battleSlotKey(2),
requestedOtherBattleKey:
otherBattleSlotTwoKey
}
);
assert.equal(
restored.manualRaw,
campSave.raw,
`${renderer}: title restore modified the protected camp record.`
);
assert.equal(
restored.baseRaw,
restored.targetRaw,
`${renderer}: title restore did not make target slot 2 the automatic compatibility lane.`
);
assert.equal(
restored.slotOneRaw,
targetFixture.slotOneRaw,
`${renderer}: restoring slot 2 changed automatic slot 1.`
);
const restoredCampaign = JSON.parse(
restored.targetRaw
);
assert.equal(
restoredCampaign.activeSaveSlot,
2
);
assert.equal(
restoredCampaign.step,
campSave.envelope.campaign.step
);
assert.equal(
restoredCampaign.gold,
campSave.envelope.campaign.gold
);
assert.equal(restored.targetBattleRaw, null);
assert.equal(restored.otherBattleRaw, null);
}
async function openBattle(page) {
await page.evaluate(async (requestedBattleId) => {
await window.__HEROS_DEBUG__.goToBattle(
requestedBattleId
);
}, battleId);
await waitForBattleLoaded(page);
const phase = await page.evaluate(
() =>
window.__HEROS_DEBUG__?.battle?.()?.phase
);
if (phase === 'deployment') {
await page.evaluate(async () => {
const scene =
window.__HEROS_GAME__.scene.getScene(
'BattleScene'
);
await scene.confirmPreBattleDeployment();
});
}
await page.waitForFunction(
(requestedBattleId) => {
const state =
window.__HEROS_DEBUG__?.battle?.();
return (
state?.battleId ===
requestedBattleId &&
state?.phase === 'idle'
);
},
battleId,
{ timeout: 90_000 }
);
}
async function verifyBattleManualSaveIsolation(
page,
renderer
) {
const baseline = await page.evaluate(
({
requestedCampaignKey,
requestedCampaignSlotKey,
requestedBattleSlotKey
}) => {
const scene =
window.__HEROS_GAME__.scene.getScene(
'BattleScene'
);
scene.hideBattleEventBanner?.();
scene.completeFirstBattleTutorial?.(
'skipped'
);
scene.hideSaveSlotPanel?.();
scene.hideTurnEndPrompt?.();
scene.selectedUnit = undefined;
scene.pendingMove = undefined;
scene.targetingAction = undefined;
scene.selectedUsable = undefined;
scene.battleOutcome = undefined;
scene.resultNavigationPending = false;
scene.phase = 'idle';
scene.activeFaction = 'ally';
scene.turnNumber = 4;
scene.battleAutosaveReady = true;
const automaticSaved =
scene.persistBattleAutosave('debug');
const keys = [
requestedCampaignKey,
requestedCampaignSlotKey,
requestedBattleSlotKey
];
return {
automaticSaved,
state:
window.__HEROS_DEBUG__?.battle?.(),
keys,
bytes: Object.fromEntries(
keys.map((key) => [
key,
window.localStorage.getItem(key)
])
)
};
},
{
requestedCampaignKey: campaignKey,
requestedCampaignSlotKey:
campaignSlotKey(2),
requestedBattleSlotKey:
battleSlotKey(2)
}
);
assert.equal(
baseline.automaticSaved,
true,
`${renderer}: could not establish a safe automatic battle checkpoint.`
);
assert.equal(baseline.state.turnNumber, 4);
assert.equal(
baseline.state.activeFaction,
'ally'
);
baseline.keys.forEach((key) => {
assert.notEqual(
baseline.bytes[key],
null,
`${renderer}: missing automatic battle baseline ${key}.`
);
});
await page.evaluate(() => {
window.__HEROS_GAME__.scene
.getScene('BattleScene')
.showSaveSlotPanel('save');
});
await waitForBattleSavePanel(
page,
'save'
);
let labels = await visibleSceneTexts(
page,
'BattleScene'
);
assert(
labels.includes('전투 수동 저장'),
`${renderer}: the battle manual-save title is missing.`
);
assert(
labels.some((label) =>
label.includes(
'자동 저장과 별도 보관'
)
),
`${renderer}: the battle panel does not explain save separation.`
);
await page.screenshot({
path:
`dist/qa-manual-save-battle-save-${renderer}-1920x1080.png`,
fullPage: true
});
await page.keyboard.press('Escape');
await waitForBattleSavePanel(page, null);
await page.evaluate(() => {
window.__HEROS_GAME__.scene
.getScene('BattleScene')
.showSaveSlotPanel('save');
});
await waitForBattleSavePanel(
page,
'save'
);
await page.keyboard.press('3');
await page.waitForFunction(
(key) =>
window.localStorage.getItem(key) !== null,
manualSlotKey(3)
);
await waitForBattleSavePanel(page, null);
const afterSave = await page.evaluate(
({
keys,
requestedManualKey
}) => ({
bytes: Object.fromEntries(
keys.map((key) => [
key,
window.localStorage.getItem(key)
])
),
manualRaw:
window.localStorage.getItem(
requestedManualKey
)
}),
{
keys: baseline.keys,
requestedManualKey: manualSlotKey(3)
}
);
assert.deepEqual(
afterSave.bytes,
baseline.bytes,
`${renderer}: battle manual save modified automatic campaign/battle bytes.`
);
assert(afterSave.manualRaw);
const manual = JSON.parse(afterSave.manualRaw);
assert.equal(manual.version, 1);
assert.equal(manual.slot, 3);
assert.equal(manual.context, 'battle');
assert.equal(manual.battle.battleId, battleId);
assert.equal(manual.battle.turnNumber, 4);
assert.equal(
manual.battle.activeFaction,
'ally'
);
const autoMutation = await page.evaluate(
({
requestedManualKey,
requestedAutoBattleKey
}) => {
const scene =
window.__HEROS_GAME__.scene.getScene(
'BattleScene'
);
const manualBefore =
window.localStorage.getItem(
requestedManualKey
);
scene.turnNumber = 9;
scene.activeFaction = 'ally';
scene.phase = 'idle';
scene.selectedUnit = undefined;
scene.pendingMove = undefined;
scene.targetingAction = undefined;
scene.selectedUsable = undefined;
scene.battleAutosaveReady = true;
const automaticSaved =
scene.persistBattleAutosave('debug');
const autoBattle = JSON.parse(
window.localStorage.getItem(
requestedAutoBattleKey
)
);
scene.turnNumber = 11;
scene.activeFaction = 'enemy';
return {
automaticSaved,
autoTurnNumber:
autoBattle.turnNumber,
manualBefore,
manualAfter:
window.localStorage.getItem(
requestedManualKey
),
live:
window.__HEROS_DEBUG__?.battle?.()
};
},
{
requestedManualKey: manualSlotKey(3),
requestedAutoBattleKey:
battleSlotKey(2)
}
);
assert.equal(autoMutation.automaticSaved, true);
assert.equal(autoMutation.autoTurnNumber, 9);
assert.equal(autoMutation.live.turnNumber, 11);
assert.equal(
autoMutation.live.activeFaction,
'enemy'
);
assert.equal(
autoMutation.manualAfter,
autoMutation.manualBefore,
`${renderer}: a later battle autosave changed the protected battle record bytes.`
);
await page.evaluate(() => {
window.__HEROS_GAME__.scene
.getScene('BattleScene')
.showSaveSlotPanel('load');
});
await waitForBattleSavePanel(
page,
'load'
);
labels = await visibleSceneTexts(
page,
'BattleScene'
);
assert(
labels.includes('수동 기록 불러오기'),
`${renderer}: the battle manual-load title is missing.`
);
assert(
labels.some((label) =>
label.includes('4턴 · 아군')
),
`${renderer}: battle manual-load rows do not expose turn/faction metadata: ${JSON.stringify(
labels.filter((label) =>
label.includes('턴')
)
)}`
);
await page.keyboard.press('1');
assert.equal(
(await battleState(page))
.saveSlotPanelMode,
'load'
);
await page.keyboard.press('2');
assert.equal(
(await battleState(page))
.saveSlotPanelMode,
'load'
);
await page.screenshot({
path:
`dist/qa-manual-save-battle-load-${renderer}-1920x1080.png`,
fullPage: true
});
await page.keyboard.press('Escape');
await waitForBattleSavePanel(page, null);
return {
raw: afterSave.manualRaw,
envelope: manual
};
}
async function verifyTitleBattleRestore(
page,
renderer,
battleSave
) {
await startTitle(page);
await page.keyboard.press('ArrowDown');
await waitForTitleFocus(
page,
'main',
'manual-load'
);
await page.keyboard.press('Enter');
let title = await waitForTitleFocus(
page,
'save-slots',
'slot-2'
);
await waitForVisibleSceneText(
page,
'TitleScene',
'수동 기록 불러오기 (1~3)'
);
assert.equal(
title.panels.saveSlotMode,
'manual'
);
assert.equal(
focusItem(title, 'slot-1').enabled,
false
);
assert.equal(
focusItem(title, 'slot-2').enabled,
true
);
assert.equal(
focusItem(title, 'slot-3').enabled,
true
);
const labels = await visibleSceneTexts(
page,
'TitleScene'
);
assert(
labels.some((label) =>
label.includes('전투 ·') &&
label.includes('4턴')
),
`${renderer}: title manual rows do not identify the battle turn: ${JSON.stringify(
labels
)}`
);
assert(
labels.some((label) =>
label.includes('아군 차례')
),
`${renderer}: title manual rows do not identify the saved faction.`
);
await page.screenshot({
path:
`dist/qa-manual-save-title-battle-${renderer}-1920x1080.png`,
fullPage: true
});
await page.keyboard.press('1');
title = await titleState(page);
assert.equal(title.panels.saveSlots, true);
assert.equal(title.focus.itemId, 'slot-2');
await page.keyboard.press('Escape');
await waitForTitleFocus(
page,
'main',
'manual-load'
);
await page.keyboard.press('Enter');
await waitForTitleFocus(
page,
'save-slots',
'slot-2'
);
await page.keyboard.press('3');
await waitForTitleFocus(
page,
'manual-load-confirm',
'cancel'
);
await page.keyboard.press('ArrowUp');
await waitForTitleFocus(
page,
'manual-load-confirm',
'confirm'
);
await page.keyboard.press('Enter');
await page.waitForFunction(
(expected) => {
const state =
window.__HEROS_DEBUG__?.battle?.();
return (
state?.battleId ===
expected.battleId &&
state?.phase === 'idle' &&
state?.turnNumber ===
expected.turnNumber &&
state?.activeFaction ===
expected.activeFaction
);
},
{
battleId,
turnNumber:
battleSave.envelope.battle.turnNumber,
activeFaction:
battleSave.envelope.battle.activeFaction
},
{ timeout: 90_000 }
);
await assertDesktopRuntime(
page,
renderer,
'BattleScene'
);
const restored = await page.evaluate(
({
requestedCampaignKey,
requestedCampaignSlotKey,
requestedBattleSlotKey,
requestedManualKey
}) => ({
battle:
window.__HEROS_DEBUG__?.battle?.(),
baseRaw:
window.localStorage.getItem(
requestedCampaignKey
),
targetCampaignRaw:
window.localStorage.getItem(
requestedCampaignSlotKey
),
targetBattleRaw:
window.localStorage.getItem(
requestedBattleSlotKey
),
manualRaw:
window.localStorage.getItem(
requestedManualKey
)
}),
{
requestedCampaignKey: campaignKey,
requestedCampaignSlotKey:
campaignSlotKey(3),
requestedBattleSlotKey:
battleSlotKey(3),
requestedManualKey: manualSlotKey(3)
}
);
assert.equal(
restored.manualRaw,
battleSave.raw,
`${renderer}: battle restore modified the protected manual record.`
);
assert.equal(
restored.baseRaw,
restored.targetCampaignRaw,
`${renderer}: battle restore did not promote automatic slot 3.`
);
const restoredCampaign = JSON.parse(
restored.targetCampaignRaw
);
assert.equal(
restoredCampaign.activeSaveSlot,
3
);
assert.equal(
restoredCampaign.step,
battleSave.envelope.campaign.step
);
assert.deepEqual(
JSON.parse(restored.targetBattleRaw),
battleSave.envelope.battle,
`${renderer}: restored automatic battle bytes do not match the protected battle snapshot.`
);
assert.equal(
restored.battle.turnNumber,
battleSave.envelope.battle.turnNumber
);
assert.equal(
restored.battle.activeFaction,
battleSave.envelope.battle.activeFaction
);
await page.screenshot({
path:
`dist/qa-manual-save-battle-restored-${renderer}-1920x1080.png`,
fullPage: true
});
}
async function startTitle(page) {
await page.reload({
waitUntil: 'domcontentloaded',
timeout: 90_000
});
await waitForDebugApi(page);
await waitForTitle(page);
}
async function waitForDebugApi(page) {
await page.waitForFunction(
() =>
Boolean(
window.__HEROS_GAME__ &&
window.__HEROS_DEBUG__
),
undefined,
{ timeout: 90_000 }
);
}
async function waitForTitle(page) {
await page.waitForFunction(
() => {
const title =
window.__HEROS_DEBUG__?.title?.();
const scene =
window.__HEROS_GAME__?.scene.getScene(
'TitleScene'
);
return (
title?.scene === 'TitleScene' &&
scene?.scene?.isActive?.() === true &&
title?.focus?.scope === 'main' &&
title?.panels?.saveSlots === false &&
title?.panels?.manualLoadConfirm ===
false &&
title?.panels?.settings === false &&
title?.panels?.newGameConfirm === false &&
title?.focus?.items?.length > 0
);
},
undefined,
{ timeout: 90_000 }
);
}
async function waitForTitleFocus(
page,
scope,
itemId
) {
try {
await page.waitForFunction(
({ expectedScope, expectedItemId }) => {
const focus =
window.__HEROS_DEBUG__?.title?.()
?.focus;
return (
focus?.scope === expectedScope &&
focus?.itemId === expectedItemId
);
},
{
expectedScope: scope,
expectedItemId: itemId
},
{ timeout: 5_000 }
);
} catch (error) {
const actual = await titleState(page);
throw new Error(
`Timed out waiting for title focus ${scope}/${itemId}; actual ${actual?.focus?.scope}/${actual?.focus?.itemId}.`,
{ cause: error }
);
}
return titleState(page);
}
async function waitForCampSavePanel(
page,
visible
) {
await page.waitForFunction(
(expectedVisible) => {
const scene =
window.__HEROS_GAME__?.scene.getScene(
'CampScene'
);
return (
Boolean(
scene?.saveSlotObjects?.length > 0
) === expectedVisible
);
},
visible,
{ timeout: 10_000 }
);
}
async function waitForBattleLoaded(page) {
await page.waitForFunction(
(requestedBattleId) => {
const state =
window.__HEROS_DEBUG__?.battle?.();
return (
state?.battleId ===
requestedBattleId &&
state?.mapBackgroundReady === true &&
(
state?.combatAssets?.status ===
'ready' ||
state?.combatAssets?.status ===
'degraded'
) &&
['deployment', 'idle'].includes(
state?.phase
)
);
},
battleId,
{ timeout: 90_000 }
);
}
async function waitForBattleSavePanel(
page,
mode
) {
await page.waitForFunction(
(expectedMode) => {
const state =
window.__HEROS_DEBUG__?.battle?.();
return expectedMode === null
? state?.saveSlotPanelVisible ===
false &&
state?.saveSlotPanelMode === null
: state?.saveSlotPanelVisible ===
true &&
state?.saveSlotPanelMode ===
expectedMode;
},
mode,
{ timeout: 10_000 }
);
}
async function titleState(page) {
return page.evaluate(
() => window.__HEROS_DEBUG__.title()
);
}
async function battleState(page) {
return page.evaluate(
() => window.__HEROS_DEBUG__.battle()
);
}
function assertTitleMainItems(
title,
{
continueEnabled,
manualEnabled
}
) {
const ids = title.focus.items.map(
(item) => item.id
);
assert.deepEqual(ids, [
'new-game',
'continue',
'manual-load',
'settings'
]);
assert.equal(
focusItem(title, 'continue').enabled,
continueEnabled
);
assert.equal(
focusItem(title, 'manual-load').enabled,
manualEnabled
);
assert.notEqual(
focusItem(title, 'continue').id,
focusItem(title, 'manual-load').id
);
}
function focusItem(title, id) {
const item = title.focus.items.find(
(candidate) => candidate.id === id
);
assert(
item,
`Expected title focus item ${id}: ${JSON.stringify(
title.focus
)}`
);
return item;
}
async function visibleSceneTexts(
page,
sceneKey
) {
return page.evaluate((requestedSceneKey) => {
const scene =
window.__HEROS_GAME__?.scene.getScene(
requestedSceneKey
);
const texts = [];
const visit = (object, ancestorsVisible) => {
if (!object) {
return;
}
const visible =
ancestorsVisible &&
object.active !== false &&
object.visible !== false &&
object.alpha !== 0;
if (
visible &&
typeof object.text === 'string'
) {
texts.push(object.text);
}
if (Array.isArray(object.list)) {
object.list.forEach((child) =>
visit(child, visible)
);
}
};
scene?.children?.list?.forEach((child) =>
visit(child, true)
);
return texts;
}, sceneKey);
}
async function waitForVisibleSceneText(
page,
sceneKey,
expectedFragment
) {
await page.waitForFunction(
({
requestedSceneKey,
requestedFragment
}) => {
const scene =
window.__HEROS_GAME__?.scene.getScene(
requestedSceneKey
);
let found = false;
const visit = (
object,
ancestorsVisible
) => {
if (!object || found) {
return;
}
const visible =
ancestorsVisible &&
object.active !== false &&
object.visible !== false &&
object.alpha !== 0;
if (
visible &&
typeof object.text === 'string' &&
object.text.includes(
requestedFragment
)
) {
found = true;
return;
}
if (Array.isArray(object.list)) {
object.list.forEach((child) =>
visit(child, visible)
);
}
};
scene?.children?.list?.forEach(
(object) => visit(object, true)
);
return found;
},
{
requestedSceneKey: sceneKey,
requestedFragment: expectedFragment
},
{ timeout: 5_000 }
);
}
async function clickSceneText(
page,
sceneKey,
text
) {
const point = await page.evaluate(
({
requestedSceneKey,
requestedText
}) => {
const scene =
window.__HEROS_GAME__?.scene.getScene(
requestedSceneKey
);
const canvas =
document.querySelector('canvas');
const canvasBounds =
canvas?.getBoundingClientRect();
const candidate =
scene?.children?.list?.find(
(child) =>
child?.active &&
child?.visible &&
child?.text === requestedText &&
child?.input?.enabled
);
const bounds = candidate?.getBounds?.();
if (
!scene ||
!canvasBounds ||
!bounds
) {
return null;
}
return {
x:
canvasBounds.left +
bounds.centerX *
canvasBounds.width /
scene.scale.width,
y:
canvasBounds.top +
bounds.centerY *
canvasBounds.height /
scene.scale.height
};
},
{
requestedSceneKey: sceneKey,
requestedText: text
}
);
assert(
point &&
Number.isFinite(point.x) &&
Number.isFinite(point.y),
`Expected interactive scene text "${text}" in ${sceneKey}.`
);
await page.mouse.click(point.x, point.y);
}
async function assertDesktopRuntime(
page,
renderer,
sceneKey
) {
const runtime = await page.evaluate(
(requestedSceneKey) => {
const canvasBounds =
document
.querySelector('canvas')
?.getBoundingClientRect();
const scene =
window.__HEROS_GAME__?.scene.getScene(
requestedSceneKey
);
return {
viewport: {
width: window.innerWidth,
height: window.innerHeight,
dpr: window.devicePixelRatio,
scale:
window.visualViewport?.scale ?? 1
},
canvas: canvasBounds
? {
x: canvasBounds.x,
y: canvasBounds.y,
width: canvasBounds.width,
height: canvasBounds.height
}
: null,
scene: scene
? {
width: scene.scale.width,
height: scene.scale.height
}
: null
};
},
sceneKey
);
assert.deepEqual(
runtime.viewport,
{
width: desktopBrowserViewport.width,
height:
desktopBrowserViewport.height,
dpr: desktopBrowserDeviceScaleFactor,
scale: 1
},
`${renderer}/${sceneKey}: browser viewport is not explicit 1920x1080 at 100% DPR1.`
);
assert.deepEqual(runtime.canvas, {
x: 0,
y: 0,
width: desktopBrowserViewport.width,
height: desktopBrowserViewport.height
});
assert.deepEqual(runtime.scene, {
width: desktopBrowserViewport.width,
height: desktopBrowserViewport.height
});
}
async function waitForUrl(url, timeoutMs) {
const startedAt = Date.now();
while (Date.now() - startedAt < timeoutMs) {
try {
const response = await fetch(url);
if (response.ok) {
return;
}
} catch {
// The owned Vite server is still starting.
}
await delay(100);
}
throw new Error(
`Timed out waiting for ${url.href}`
);
}
async function stopServerProcess(child) {
if (!child || child.exitCode !== null) {
return;
}
const exited = new Promise((resolve) => {
child.once('exit', resolve);
});
child.kill();
await Promise.race([exited, delay(5_000)]);
if (child.exitCode === null) {
child.kill('SIGKILL');
await Promise.race([
exited,
delay(2_000)
]);
}
}
function delay(ms) {
return new Promise((resolve) =>
setTimeout(resolve, ms)
);
}