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); } for (const renderer of renderers) { browser = await chromium.launch({ headless: process.env.VERIFY_MANUAL_SAVE_UX_HEADLESS !== '0', args: renderer === 'webgl' ? [ '--use-angle=swiftshader', '--enable-unsafe-swiftshader' ] : [] }); try { await verifyRenderer(browser, renderer); } finally { await browser.close(); browser = undefined; } } 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`, animations: 'disabled', timeout: 90_000 }); 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`, animations: 'disabled', timeout: 90_000 }); 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`, animations: 'disabled', timeout: 90_000 }); 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`, animations: 'disabled', timeout: 90_000 }); 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`, animations: 'disabled', timeout: 90_000 }); 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`, animations: 'disabled', timeout: 90_000 }); 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`, animations: 'disabled', timeout: 90_000 }); } 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) ); }