From 624eb2c219aa25f3c09667ee8d0b1b0d5c5b8c69 Mon Sep 17 00:00:00 2001 From: Wickedness Date: Wed, 29 Jul 2026 08:49:29 +0900 Subject: [PATCH] feat: add protected manual save records --- package.json | 4 +- .../verify-battle-save-generation-browser.mjs | 153 +- scripts/verify-battle-save-resume-routing.mjs | 94 +- scripts/verify-manual-save-storage.mjs | 442 ++++ scripts/verify-manual-save-ux-browser.mjs | 2039 +++++++++++++++++ scripts/verify-static-data.mjs | 1 + src/game/scenes/BattleScene.ts | 308 ++- src/game/scenes/CampScene.ts | 117 +- src/game/scenes/TitleScene.ts | 831 ++++++- src/game/state/campaignState.ts | 6 +- src/game/state/manualSaveStorage.ts | 664 ++++++ 11 files changed, 4458 insertions(+), 201 deletions(-) create mode 100644 scripts/verify-manual-save-storage.mjs create mode 100644 scripts/verify-manual-save-ux-browser.mjs create mode 100644 src/game/state/manualSaveStorage.ts diff --git a/package.json b/package.json index f29c4ef..d02d826 100644 --- a/package.json +++ b/package.json @@ -22,6 +22,8 @@ "verify:narrative-continuity": "node scripts/verify-narrative-continuity.mjs", "verify:campaign-data": "node scripts/verify-campaign-flow-data.mjs", "verify:campaign-save": "node scripts/verify-campaign-save-normalization.mjs", + "verify:manual-save": "node scripts/verify-manual-save-storage.mjs", + "verify:manual-save-ux:browser": "node scripts/verify-manual-save-ux-browser.mjs", "verify:campaign-exploration-checkpoints": "node scripts/verify-campaign-exploration-checkpoint-normalization.mjs", "verify:campaign-story-checkpoints": "node scripts/verify-campaign-story-checkpoint-normalization.mjs", "verify:sortie-preparation-checkpoints": "node scripts/verify-sortie-preparation-checkpoint.mjs", @@ -103,7 +105,7 @@ "verify:title-accessibility:browser": "node scripts/verify-title-accessibility-browser.mjs", "verify:save-flow": "node scripts/verify-save-retry-flow.mjs", "verify:release": "node scripts/verify-release-candidate.mjs", - "verify:local-release": "pnpm run verify:static-data && pnpm run verify:unit-action-assets && pnpm run verify:enemy-intent && tsc --noEmit && pnpm run build && pnpm run verify:unit-action-assets:dist && pnpm run verify:loader-lifecycle && pnpm run verify:interaction-ux && pnpm run verify:turn-end-risk:browser && pnpm run verify:campaign-objectives:browser && pnpm run verify:camp-visit-resume:browser && pnpm run verify:exploration-checkpoints:browser && pnpm run verify:story-checkpoint:browser && pnpm run verify:battle-save-generation:browser && pnpm run verify:battle-result-persistence:browser && pnpm run verify:title-accessibility:browser && pnpm run verify:battle-keyboard:browser && pnpm run verify:city-stays:browser && pnpm run verify:xuzhou-equipment-link:browser && pnpm run verify:xuzhou-stay-payoff:browser && pnpm run verify:prologue-village:browser && pnpm run verify:first-pursuit-camp:browser && pnpm run verify:second-battle-relief:browser && pnpm run verify:third-battle-return:browser && pnpm run verify:third-camp-preparation:browser && pnpm run verify:av-feedback:browser && pnpm run verify:release && pnpm run verify:performance", + "verify:local-release": "pnpm run verify:static-data && pnpm run verify:unit-action-assets && pnpm run verify:enemy-intent && tsc --noEmit && pnpm run build && pnpm run verify:unit-action-assets:dist && pnpm run verify:loader-lifecycle && pnpm run verify:interaction-ux && pnpm run verify:turn-end-risk:browser && pnpm run verify:campaign-objectives:browser && pnpm run verify:camp-visit-resume:browser && pnpm run verify:exploration-checkpoints:browser && pnpm run verify:story-checkpoint:browser && pnpm run verify:battle-save-generation:browser && pnpm run verify:manual-save-ux:browser && pnpm run verify:battle-result-persistence:browser && pnpm run verify:title-accessibility:browser && pnpm run verify:battle-keyboard:browser && pnpm run verify:city-stays:browser && pnpm run verify:xuzhou-equipment-link:browser && pnpm run verify:xuzhou-stay-payoff:browser && pnpm run verify:prologue-village:browser && pnpm run verify:first-pursuit-camp:browser && pnpm run verify:second-battle-relief:browser && pnpm run verify:third-battle-return:browser && pnpm run verify:third-camp-preparation:browser && pnpm run verify:av-feedback:browser && pnpm run verify:release && pnpm run verify:performance", "verify:1.0": "pnpm run verify:local-release && pnpm run verify:flow && pnpm run qa:campaign-complete && pnpm run qa:battle-maps:webgl", "verify:public-deploy": "node scripts/verify-public-deploy.mjs", "qa:representative": "node scripts/qa-representative-battles.mjs", diff --git a/scripts/verify-battle-save-generation-browser.mjs b/scripts/verify-battle-save-generation-browser.mjs index eb2d66d..79c45b1 100644 --- a/scripts/verify-battle-save-generation-browser.mjs +++ b/scripts/verify-battle-save-generation-browser.mjs @@ -43,6 +43,7 @@ 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'; @@ -373,9 +374,16 @@ try { `${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'); @@ -389,7 +397,28 @@ try { 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'); @@ -404,6 +433,8 @@ try { accepted?.battleResumeGeneration ?? null, acceptedTurnNumber: accepted?.turnNumber ?? null, + acceptedManualTurnNumber: + acceptedManual?.turnNumber ?? null, loadDisabled, panelMode: panel?.saveSlotPanelMode ?? null, confirmationMode: @@ -415,6 +446,8 @@ try { }, { requestedBattleSlotKey: battleSlotKey, + requestedCampaignSlotKey: campaignSlotKey, + requestedManualSaveSlotKey: manualSaveSlotKey, requestedCampaignGeneration: campaignGeneration } ); @@ -428,6 +461,10 @@ try { 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'); @@ -437,29 +474,70 @@ try { const scene = window.__HEROS_DEBUG__?.scene('BattleScene'); scene?.activateTurnPromptPrimaryAction?.(); }); - 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 } - ); + 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( ({ @@ -687,6 +765,16 @@ try { }, `${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 [ @@ -1099,6 +1187,16 @@ try { 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')); @@ -1313,6 +1411,15 @@ function assertAutosaveDebugState( ), `${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( diff --git a/scripts/verify-battle-save-resume-routing.mjs b/scripts/verify-battle-save-resume-routing.mjs index 5a026a3..16a0719 100644 --- a/scripts/verify-battle-save-resume-routing.mjs +++ b/scripts/verify-battle-save-resume-routing.mjs @@ -325,6 +325,26 @@ try { const battleSceneSource = await readFile(new URL('../src/game/scenes/BattleScene.ts', import.meta.url), 'utf8'); const campSceneSource = await readFile(new URL('../src/game/scenes/CampScene.ts', import.meta.url), 'utf8'); + const campManualSaveSource = methodSource( + campSceneSource, + 'private saveCampToSlot(', + 'private handleSaveSlotKey(' + ); + const battleManualSaveSource = methodSource( + battleSceneSource, + 'private saveManualBattleState(', + 'private loadManualBattleState(' + ); + const battleManualLoadSource = methodSource( + battleSceneSource, + 'private loadManualBattleState(', + 'private restartBattleFromSave(' + ); + const compatibilityBattleSaveSource = methodSource( + battleSceneSource, + 'private saveBattleState(', + 'private loadBattleState(' + ); assert( /resumeBattleSaveSlot\?: number/.test(battleSceneSource) && /this\.time\.delayedCall\(0, \(\) => this\.loadBattleState\(resumeSlot\)\)/.test(battleSceneSource), @@ -345,12 +365,60 @@ try { 'Cross-slot loads must restart after loading the target campaign, then validate against the rebuilt battle composition.' ); assert( - /saveCampToSlot\(slot: number\)[\s\S]*?clearCampaignBattleSavesForSlot\(slot\)[\s\S]*?saveCampaignState\(getCampaignState\(\), slot\)/.test(campSceneSource), - 'Camp slot overwrites must clear all stale battle resumes before replacing the destination campaign save.' + /writeManualSave\(slot,\s*\{\s*campaign: getCampaignState\(\),\s*context: 'camp'\s*\}\)/.test( + campManualSaveSource + ) && + !campManualSaveSource.includes( + 'clearCampaignBattleSavesForSlot' + ) && + !campManualSaveSource.includes( + 'saveCampaignState(' + ), + 'Camp user saves must write a protected manual record without changing the active autosave campaign or clearing battle resumes.' ); assert( - /saveBattleState\(slot = 1\)[\s\S]*?const state = this\.createBattleSaveState\(\)[\s\S]*?clearCampaignBattleSavesForSlot\(normalizedSlot\)[\s\S]*?localStorage\.setItem\(this\.battleSaveStorageKeyForSlot\(normalizedSlot\)/.test(battleSceneSource), - 'Battle slot overwrites must clear all destination battle resumes before writing the current battle snapshot.' + /writeManualSave\(normalizedSlot,\s*\{\s*campaign: getCampaignState\(\),\s*battle: state,\s*context: 'battle'\s*\}\)/.test( + battleManualSaveSource + ) && + !battleManualSaveSource.includes( + 'clearCampaignBattleSavesForSlot' + ) && + !battleManualSaveSource.includes( + 'saveCampaignState(' + ), + 'Battle user saves must protect campaign and battle in one manual envelope without mutating the active autosave lane.' + ); + assert( + /readManualBattleSaveState\(slot, true\)/.test( + battleManualLoadSource + ) && + /restoreManualSave\(slot\)/.test( + battleManualLoadSource + ) && + /restoredCampaign\.activeSaveSlot/.test( + battleManualLoadSource + ) && + /readBattleSaveState\(resumeSlot, true\)/.test( + battleManualLoadSource + ) && + /resumeBattleSaveSlot: resumeSlot/.test( + battleManualLoadSource + ), + 'Battle manual loads must validate the protected record, restore it into autosave, verify the staged checkpoint, and restart from that lane.' + ); + assert( + /clearCampaignBattleSavesForSlot\(normalizedSlot\)/.test( + compatibilityBattleSaveSource + ) && + /localStorage\.setItem\(this\.battleSaveStorageKeyForSlot\(normalizedSlot\)/.test( + compatibilityBattleSaveSource + ) && + ( + battleSceneSource.match( + /saveBattleState\(/g + )?.length ?? 0 + ) === 1, + 'The legacy direct battle writer may remain for debug compatibility, but no player-facing path may call it.' ); assert( /battleResumeGeneration:\s*campaign\.battleResumeGeneration/.test( @@ -362,7 +430,7 @@ try { /readBattleSaveState\(slot: number, validateCurrentComposition = false\)[\s\S]*?readBattleSaveStorageCandidate\([\s\S]*?savedCampaign\s*\)/.test( battleSceneSource ), - 'BattleScene initial and manual loads must filter candidates through the loaded campaign generation.' + 'BattleScene compatibility autosave loads must filter candidates through the loaded campaign generation.' ); assert( /isSafeBattleAutosaveCheckpoint\(\)[\s\S]*?this\.battleAutosaveReady[\s\S]*?this\.activeFaction === 'ally'[\s\S]*?this\.phase === 'idle'/.test( @@ -386,7 +454,7 @@ try { 'Battle saves must persist new attributes and reconstruct battle-earned attributes for legacy saves.' ); - console.log('Verified direct battle-save resume routing, durable launch-generation matching, BattleScene generation filtering, deletion-failure blocking, monotonic new-campaign replacement, restore hooks, stale cleanup, fallback order, generation cleanup, and non-battle isolation.'); + console.log('Verified direct battle-save resume routing, durable launch-generation matching, protected manual save/load separation, BattleScene generation filtering, deletion-failure blocking, monotonic new-campaign replacement, restore hooks, stale cleanup, fallback order, generation cleanup, and non-battle isolation.'); } finally { await server.close(); } @@ -447,6 +515,20 @@ function createBattleSave(patch = {}) { }; } +function methodSource( + source, + startMarker, + endMarker +) { + const start = source.indexOf(startMarker); + const end = source.indexOf(endMarker, start + startMarker.length); + assert( + start >= 0 && end > start, + `Could not isolate method source between "${startMarker}" and "${endMarker}".` + ); + return source.slice(start, end); +} + function assert(condition, message) { if (!condition) { throw new Error(message); diff --git a/scripts/verify-manual-save-storage.mjs b/scripts/verify-manual-save-storage.mjs new file mode 100644 index 0000000..69376cc --- /dev/null +++ b/scripts/verify-manual-save-storage.mjs @@ -0,0 +1,442 @@ +import assert from 'node:assert/strict'; +import { createServer } from 'vite'; + +const storage = createStorage(); +globalThis.window = { localStorage: storage }; + +const server = await createServer({ + logLevel: 'error', + server: { + middlewareMode: true, + hmr: false + }, + appType: 'custom' +}); + +try { + const { + campaignSaveStorageKey, + campaignStorageKey, + createInitialCampaignState, + setCampaignState + } = await server.ssrLoadModule( + '/src/game/state/campaignState.ts' + ); + const { + manualSaveSlotCount, + manualSaveStorageKeyForSlot, + readManualSave, + listManualSaveSlots, + writeManualSave, + restoreManualSave, + ManualSaveStorageError + } = await server.ssrLoadModule( + '/src/game/state/manualSaveStorage.ts' + ); + const { + battleSaveStorageBaseKey, + battleSaveStorageSlotKey, + legacyFirstBattleSaveStorageKey + } = await server.ssrLoadModule( + '/src/game/state/battleSaveStorage.ts' + ); + + assert.equal(manualSaveSlotCount, 3); + assert.throws( + () => manualSaveStorageKeyForSlot(0), + (error) => + error instanceof ManualSaveStorageError && + error.code === 'invalid-slot' + ); + + const firstBattleId = + 'first-battle-zhuo-commandery'; + const secondBattleId = + 'second-battle-yellow-turban-pursuit'; + const sourceCampaign = + createInitialCampaignState(); + sourceCampaign.step = 'first-battle'; + sourceCampaign.activeSaveSlot = 3; + sourceCampaign.gold = 321; + sourceCampaign.inventory = { + bean: 2, + salve: 1 + }; + sourceCampaign.battleResumeGeneration = 7; + sourceCampaign.updatedAt = + '2026-07-29T01:00:00.000Z'; + setCampaignState(sourceCampaign); + + const battle = createBattleSave({ + battleId: firstBattleId, + campaignStep: 'first-battle', + battleResumeGeneration: 7, + turnNumber: 4, + activeFaction: 'enemy' + }); + const autosaveBeforeManualWrite = + storage.snapshot(); + const written = writeManualSave(2, { + campaign: sourceCampaign, + battle, + context: 'battle' + }); + assert.equal(written.slot, 2); + assert.equal(written.context, 'battle'); + assert.equal( + written.campaign.activeSaveSlot, + 3, + 'Writing a protected manual record must not rewrite its source campaign lane.' + ); + assertStorageEntriesEqual( + storage.snapshot(), + autosaveBeforeManualWrite, + (key) => + key !== manualSaveStorageKeyForSlot(2), + 'Writing a manual save changed an autosave key.' + ); + + const summaries = listManualSaveSlots(); + const slotTwoSummary = summaries.find( + (summary) => summary.slot === 2 + ); + assert.deepEqual( + { + exists: slotTwoSummary?.exists, + status: slotTwoSummary?.status, + context: slotTwoSummary?.context, + gold: slotTwoSummary?.gold, + inventoryCount: + slotTwoSummary?.inventoryCount, + battleId: slotTwoSummary?.battleId, + turnNumber: slotTwoSummary?.turnNumber, + activeFaction: + slotTwoSummary?.activeFaction + }, + { + exists: true, + status: 'valid', + context: 'battle', + gold: 321, + inventoryCount: 3, + battleId: firstBattleId, + turnNumber: 4, + activeFaction: 'enemy' + } + ); + assert.equal( + summaries.find((summary) => summary.slot === 1) + ?.status, + 'empty' + ); + + const protectedSlotTwoRaw = storage.getItem( + manualSaveStorageKeyForSlot(2) + ); + const replacementCampaign = + createInitialCampaignState(); + replacementCampaign.step = 'second-battle'; + replacementCampaign.activeSaveSlot = 2; + replacementCampaign.gold = 9; + replacementCampaign.battleResumeGeneration = 12; + setCampaignState(replacementCampaign); + const firstSlotTwoKey = battleSaveStorageSlotKey( + firstBattleId, + 2 + ); + const secondSlotTwoKey = battleSaveStorageSlotKey( + secondBattleId, + 2 + ); + const unrelatedSlotThreeKey = + battleSaveStorageSlotKey( + secondBattleId, + 3 + ); + storage.setItem( + firstSlotTwoKey, + 'old-first-battle-autosave' + ); + storage.setItem( + secondSlotTwoKey, + 'old-second-battle-autosave' + ); + storage.setItem( + unrelatedSlotThreeKey, + 'unrelated-slot-three' + ); + + const restored = restoreManualSave(2); + assert.equal(restored.activeSaveSlot, 2); + assert.equal(restored.step, 'first-battle'); + assert.equal(restored.gold, 321); + assert.equal( + JSON.parse( + storage.getItem(campaignStorageKey) + ).activeSaveSlot, + 2 + ); + assert.equal( + JSON.parse( + storage.getItem(campaignSaveStorageKey(2)) + ).gold, + 321 + ); + assert.equal( + JSON.parse(storage.getItem(firstSlotTwoKey)) + .turnNumber, + 4 + ); + assert.equal( + storage.getItem(secondSlotTwoKey), + null, + 'Restore retained a stale battle autosave in the destination lane.' + ); + assert.equal( + storage.getItem(unrelatedSlotThreeKey), + 'unrelated-slot-three', + 'Restore changed a battle autosave in another lane.' + ); + assert.equal( + storage.getItem(manualSaveStorageKeyForSlot(2)), + protectedSlotTwoRaw, + 'Restore changed the protected manual record bytes.' + ); + assert.equal( + readManualSave(2)?.campaign.activeSaveSlot, + 3, + 'Restore mutated the campaign stored inside the protected manual envelope.' + ); + + const campCampaign = createInitialCampaignState(); + campCampaign.step = 'first-camp'; + campCampaign.activeSaveSlot = 1; + campCampaign.gold = 144; + writeManualSave(1, { + campaign: campCampaign, + context: 'camp' + }); + assert.equal(readManualSave(1)?.battle, undefined); + assert.throws( + () => + writeManualSave(3, { + campaign: campCampaign, + battle, + context: 'camp' + }), + (error) => + error instanceof ManualSaveStorageError && + error.code === 'invalid-save' + ); + + const slotOneCampaignRaw = + '{"auto":"slot-one-before"}'; + const currentCampaignRaw = + '{"auto":"current-before"}'; + const slotOneBattleKey = + battleSaveStorageSlotKey(firstBattleId, 1); + const slotOneBattleBaseKey = + battleSaveStorageBaseKey(firstBattleId); + const slotOneSecondBattleKey = + battleSaveStorageSlotKey(secondBattleId, 1); + const slotOneSecondBattleBaseKey = + battleSaveStorageBaseKey(secondBattleId); + storage.setItem( + campaignStorageKey, + currentCampaignRaw + ); + storage.setItem( + campaignSaveStorageKey(1), + slotOneCampaignRaw + ); + storage.setItem( + slotOneBattleKey, + 'slot-one-first-before' + ); + storage.setItem( + slotOneBattleBaseKey, + 'base-first-before' + ); + storage.setItem( + legacyFirstBattleSaveStorageKey, + 'legacy-first-before' + ); + storage.setItem( + slotOneSecondBattleKey, + 'slot-one-second-before' + ); + storage.setItem( + slotOneSecondBattleBaseKey, + 'base-second-before' + ); + const beforeFailedRestore = storage.snapshot(); + storage.failNextSet(campaignSaveStorageKey(1)); + + assert.throws( + () => restoreManualSave(1), + (error) => + error instanceof ManualSaveStorageError && + error.code === 'restore-failed' && + error.rollbackSucceeded === true + ); + assert.deepEqual( + storage.snapshot(), + beforeFailedRestore, + 'A failed restore did not roll back every campaign, battle, compatibility, and manual byte.' + ); + + const protectedSlotOneRaw = storage.getItem( + manualSaveStorageKeyForSlot(1) + ); + storage.failNextSet( + manualSaveStorageKeyForSlot(1) + ); + assert.throws( + () => + writeManualSave(1, { + campaign: campCampaign, + context: 'camp' + }), + (error) => + error instanceof ManualSaveStorageError && + error.code === 'write-failed' && + error.rollbackSucceeded === true + ); + assert.equal( + storage.getItem(manualSaveStorageKeyForSlot(1)), + protectedSlotOneRaw, + 'A failed overwrite changed the existing protected manual record.' + ); + + storage.setItem( + manualSaveStorageKeyForSlot(3), + JSON.stringify({ + version: 99, + slot: 3, + savedAt: '2026-07-29T02:00:00.000Z', + context: 'camp', + campaign: campCampaign + }) + ); + assert.equal(readManualSave(3), undefined); + assert.deepEqual( + listManualSaveSlots().find( + (summary) => summary.slot === 3 + ), + { + slot: 3, + exists: true, + status: 'invalid' + }, + 'Unsupported manual-save bytes must remain visibly occupied rather than masquerading as an empty slot.' + ); + + console.log( + 'Verified protected manual-save envelopes, summaries, context/version/slot validation, autosave-write isolation, exact campaign+battle restore, stale battle cleanup, manual-byte invariance, and full rollback after write failure.' + ); +} finally { + await server.close(); + delete globalThis.window; +} + +function createBattleSave(overrides = {}) { + return { + version: 1, + battleId: 'first-battle-zhuo-commandery', + campaignStep: 'first-battle', + battleResumeGeneration: 0, + savedAt: '2026-07-29T01:05:00.000Z', + turnNumber: 1, + activeFaction: 'ally', + rosterTab: 'ally', + actedUnitIds: [], + attackIntents: [], + battleLog: [], + units: [], + bonds: [], + ...overrides + }; +} + +function assertStorageEntriesEqual( + current, + expected, + include, + message +) { + const currentFiltered = Object.fromEntries( + Object.entries(current).filter(([key]) => + include(key) + ) + ); + const expectedFiltered = Object.fromEntries( + Object.entries(expected).filter(([key]) => + include(key) + ) + ); + assert.deepEqual( + currentFiltered, + expectedFiltered, + message + ); +} + +function createStorage() { + const values = new Map(); + const failingSetCounts = new Map(); + return { + get length() { + return values.size; + }, + key(index) { + return [...values.keys()][index] ?? null; + }, + getItem(key) { + return values.has(String(key)) + ? values.get(String(key)) + : null; + }, + setItem(key, value) { + const normalizedKey = String(key); + const remaining = + failingSetCounts.get(normalizedKey) ?? 0; + if (remaining > 0) { + if (remaining === 1) { + failingSetCounts.delete(normalizedKey); + } else { + failingSetCounts.set( + normalizedKey, + remaining - 1 + ); + } + throw new DOMException( + 'Injected manual-save write refusal', + 'QuotaExceededError' + ); + } + values.set(normalizedKey, String(value)); + }, + removeItem(key) { + values.delete(String(key)); + }, + clear() { + values.clear(); + failingSetCounts.clear(); + }, + failNextSet(key) { + const normalizedKey = String(key); + failingSetCounts.set( + normalizedKey, + (failingSetCounts.get(normalizedKey) ?? + 0) + 1 + ); + }, + snapshot() { + return Object.fromEntries( + [...values.entries()].sort(([left], [right]) => + left.localeCompare(right) + ) + ); + } + }; +} diff --git a/scripts/verify-manual-save-ux-browser.mjs b/scripts/verify-manual-save-ux-browser.mjs new file mode 100644 index 0000000..6c03db2 --- /dev/null +++ b/scripts/verify-manual-save-ux-browser.mjs @@ -0,0 +1,2039 @@ +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) + ); +} diff --git a/scripts/verify-static-data.mjs b/scripts/verify-static-data.mjs index 3be454d..51c77f9 100644 --- a/scripts/verify-static-data.mjs +++ b/scripts/verify-static-data.mjs @@ -5,6 +5,7 @@ const checks = [ 'scripts/verify-flow-segment-data.mjs', 'scripts/verify-campaign-flow-data.mjs', 'scripts/verify-campaign-save-normalization.mjs', + 'scripts/verify-manual-save-storage.mjs', 'scripts/verify-campaign-exploration-checkpoint-normalization.mjs', 'scripts/verify-campaign-story-checkpoint-normalization.mjs', 'scripts/verify-sortie-preparation-checkpoint.mjs', diff --git a/src/game/scenes/BattleScene.ts b/src/game/scenes/BattleScene.ts index 1e66269..dd213e1 100644 --- a/src/game/scenes/BattleScene.ts +++ b/src/game/scenes/BattleScene.ts @@ -190,7 +190,6 @@ import { consumeCampaignStoryHandoff, getCampaignState, hasCompletedCampaignTutorial, - listCampaignSaveSlots, loadCampaignState, markCampaignStep, normalizeCampaignSortieRecommendationSnapshot, @@ -221,6 +220,7 @@ import { type CampaignStoryCheckpointWrite } from '../state/campaignStoryCheckpoint'; import { + normalizeBattleSaveState, normalizeBattleSaveSlot, type BattleSaveCityEquipmentContributionState, type BattleSaveCoreResonanceStats, @@ -230,15 +230,24 @@ import { type BattleSaveTacticalCommandRole, type BattleSaveTacticalCommandState, type BattleSaveThirdCampPreparationState, + type BattleSaveValidationOptions, type BattleSaveXuzhouStayBattlePayoffState, type BattleSaveUnitStats } from '../state/battleSaveState'; import { + battleSaveGenerationMatchesCampaign, campaignBattleResumeStorageKeys, clearCampaignBattleSavesForSlot, clearBattleSaveStorageForBattle, readBattleSaveStorageCandidate } from '../state/battleSaveStorage'; +import { + listManualSaveSlots, + ManualSaveStorageError, + readManualSave, + restoreManualSave, + writeManualSave +} from '../state/manualSaveStorage'; import { combatPresentationImportanceReason, combatPresentationModeLabel, @@ -4191,6 +4200,9 @@ export class BattleScene extends Phaser.Scene { private battleAutosaveLastSlot?: number; private battleAutosaveLastSavedAt?: string; private battleAutosaveReady = false; + private battleAutosaveIndicator?: Phaser.GameObjects.Container; + private battleAutosaveIndicatorTimer?: Phaser.Time.TimerEvent; + private battleAutosaveIndicatorText?: string; private readonly handleBattlePageHide = () => { this.persistBattleAutosave('pagehide'); }; @@ -4400,6 +4412,9 @@ export class BattleScene extends Phaser.Scene { this.battleAutosaveLastSlot = undefined; this.battleAutosaveLastSavedAt = undefined; this.battleAutosaveReady = false; + this.battleAutosaveIndicator = undefined; + this.battleAutosaveIndicatorTimer = undefined; + this.battleAutosaveIndicatorText = undefined; } create() { @@ -4432,6 +4447,7 @@ export class BattleScene extends Phaser.Scene { this.hideBattleSoundCaption(); this.pendingDefeatPresentationUnitIds.clear(); this.battleAutosaveReady = false; + this.hideBattleAutosaveIndicator(); this.uninstallBattleAutosaveHandlers(); }); this.resultFormationReviewVisible = false; @@ -22788,10 +22804,10 @@ export class BattleScene extends Phaser.Scene { private mapMenuLabel(action: MapMenuAction) { if (action === 'save') { - return '저장'; + return '수동 저장'; } if (action === 'load') { - return '불러오기'; + return '수동 불러오기'; } if (action === 'speed') { return `속도 ${battleSpeedLabels[this.battleSpeed]}`; @@ -22803,7 +22819,7 @@ export class BattleScene extends Phaser.Scene { } private hasBattleSave() { - return Array.from({ length: campaignSaveSlotCount }, (_, index) => index + 1).some((slot) => Boolean(this.readBattleSaveState(slot))); + return Array.from({ length: campaignSaveSlotCount }, (_, index) => index + 1).some((slot) => Boolean(this.readManualBattleSaveState(slot))); } private handleSaveSlotPanelKey(event: KeyboardEvent) { @@ -22823,7 +22839,7 @@ export class BattleScene extends Phaser.Scene { private activateSaveSlotPanelSlot(slot: number) { const mode = this.saveSlotPanelMode; - if (!mode || (mode === 'load' && !this.readBattleSaveState(slot))) { + if (!mode || (mode === 'load' && !this.readManualBattleSaveState(slot))) { return; } @@ -22846,7 +22862,7 @@ export class BattleScene extends Phaser.Scene { const height = this.battleUiLength(312); const left = this.layout.mapX + this.battleUiLength(118); const top = this.layout.mapY + this.battleUiLength(82); - const campaignSlots = listCampaignSaveSlots(); + const manualSlots = listManualSaveSlots(); const shade = this.add.rectangle(0, 0, this.scale.width, this.scale.height, 0x020406, 0.54); shade.setOrigin(0); @@ -22861,7 +22877,7 @@ export class BattleScene extends Phaser.Scene { panel.setInteractive(); this.saveSlotPanelObjects.push(panel); - const title = this.add.text(left + this.battleUiLength(22), top + this.battleUiLength(18), mode === 'save' ? '전투 저장 슬롯' : '전투 불러오기 슬롯', { + const title = this.add.text(left + this.battleUiLength(22), top + this.battleUiLength(18), mode === 'save' ? '전투 수동 저장' : '수동 기록 불러오기', { fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', fontSize: this.battleUiFontSize(22), color: '#f2e3bf', @@ -22873,7 +22889,9 @@ export class BattleScene extends Phaser.Scene { const guide = this.add.text( left + this.battleUiLength(22), top + this.battleUiLength(50), - mode === 'save' ? '전투와 캠페인을 함께 저장합니다. · 1~3키' : '저장된 전투와 캠페인을 불러옵니다. · 1~3키', + mode === 'save' + ? '자동 저장과 별도 보관 · 1~3 선택' + : '수동 원본 유지 · 해당 전투로 복귀 · 1~3 선택', { fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', fontSize: this.battleUiFontSize(13), @@ -22885,8 +22903,8 @@ export class BattleScene extends Phaser.Scene { for (let slot = 1; slot <= campaignSaveSlotCount; slot += 1) { const rowTop = top + this.battleUiLength(82) + (slot - 1) * this.battleUiLength(62); - const battleSave = this.readBattleSaveState(slot); - const campaignSlot = campaignSlots.find((candidate) => candidate.slot === slot); + const battleSave = this.readManualBattleSaveState(slot); + const manualSlot = manualSlots.find((candidate) => candidate.slot === slot); const disabled = mode === 'load' && !battleSave; const row = this.add.rectangle( left + this.battleUiLength(18), @@ -22900,12 +22918,12 @@ export class BattleScene extends Phaser.Scene { row.setDepth(depth + 1); row.setStrokeStyle( this.battleUiLength(1), - disabled ? 0x53606c : slot === getCampaignState().activeSaveSlot ? palette.gold : palette.blue, + disabled ? 0x53606c : manualSlot?.exists ? palette.gold : palette.blue, disabled ? 0.34 : 0.72 ); this.saveSlotPanelObjects.push(row); - const titleText = this.add.text(left + this.battleUiLength(34), rowTop + this.battleUiLength(8), `슬롯 ${slot}`, { + const titleText = this.add.text(left + this.battleUiLength(34), rowTop + this.battleUiLength(8), '슬롯', { fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', fontSize: this.battleUiFontSize(16), color: disabled ? '#78818a' : '#f2e3bf', @@ -22938,16 +22956,29 @@ export class BattleScene extends Phaser.Scene { const battleSummary = battleSave ? `${this.formatSavedAt(battleSave.savedAt)} · ${battleSave.turnNumber}턴 · ${factionLabels[battleSave.activeFaction]}` - : '전투 저장 없음'; - const campaignSummary = campaignSlot?.exists - ? `${campaignSlot.progressMeta ?? '캠페인'} · ${campaignSlot.progressTitle ?? campaignSlot.battleTitle ?? '진행 중'} · 군자금 ${campaignSlot.gold ?? 0}` - : '캠페인 저장 없음'; - const detail = this.add.text(left + this.battleUiLength(104), rowTop + this.battleUiLength(9), `${battleSummary}\n${campaignSummary}`, { + : manualSlot?.status === 'invalid' + ? '기록을 읽을 수 없음 · 교체 가능' + : manualSlot?.exists + ? manualSlot.context === 'battle' + ? '다른 전투의 수동 기록' + : '군영 수동 기록' + : '비어 있음'; + const campaignSummary = manualSlot?.status === 'invalid' + ? '손상된 기록은 불러오지 않으며 자동 저장은 안전합니다.' + : manualSlot?.exists + ? `${manualSlot.progressMeta ?? '캠페인'} · ${manualSlot.progressTitle ?? manualSlot.battleTitle ?? '진행 중'} · 군자금 ${manualSlot.gold ?? 0}` + : '새 수동 기록으로 사용'; + const detail = this.add.text( + left + this.battleUiLength(104), + rowTop + this.battleUiLength(9), + `${this.truncateUiText(battleSummary, 34)}\n${this.truncateUiText(campaignSummary, 34)}`, + { fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', fontSize: this.battleUiFontSize(12), color: disabled ? '#6d7580' : '#d4dce6', lineSpacing: this.battleUiLength(3) - }); + } + ); detail.setDepth(depth + 2); this.saveSlotPanelObjects.push(detail); @@ -22993,28 +23024,28 @@ export class BattleScene extends Phaser.Scene { } private showSaveBattleConfirm(slot: number) { - const battleSave = this.readBattleSaveState(slot); - const campaignSlot = listCampaignSaveSlots().find((candidate) => candidate.slot === slot); - if (!battleSave && !campaignSlot?.exists) { - this.saveBattleState(slot); + const battleSave = this.readManualBattleSaveState(slot); + const manualSlot = listManualSaveSlots().find((candidate) => candidate.slot === slot); + if (!manualSlot?.exists) { + this.saveManualBattleState(slot); this.hideSaveSlotPanel(); return; } const existingSummary = battleSave ? `기존 전투: ${this.formatSavedAt(battleSave.savedAt)}` - : `기존 캠페인: ${campaignSlot?.progressTitle ?? campaignSlot?.battleTitle ?? '저장 데이터'}`; + : `기존 기록: ${manualSlot.progressTitle ?? manualSlot.battleTitle ?? '수동 저장 데이터'}`; this.hideSaveSlotPanel(); this.showTurnEndPrompt(undefined, { mode: 'save-overwrite-confirm', - title: '전투 저장 확인', - body: `슬롯 ${slot}에 기존 저장이 있습니다.\n${existingSummary}\n덮어쓸까요?`, + title: '수동 기록 교체 확인', + body: `수동 기록 ${slot}에 기존 기록이 있습니다.\n${existingSummary}\n보호된 기록을 교체할까요?`, primaryLabel: '덮어쓰기', secondaryLabel: '취소', primaryAction: () => { this.hideTurnEndPrompt(); - this.saveBattleState(slot); + this.saveManualBattleState(slot); }, secondaryAction: () => { this.hideTurnEndPrompt(); @@ -23024,7 +23055,7 @@ export class BattleScene extends Phaser.Scene { } private showLoadBattleConfirm(slot: number) { - const state = this.readBattleSaveState(slot); + const state = this.readManualBattleSaveState(slot); if (!state) { this.renderSituationPanel('불러올 저장 데이터가 없습니다.'); return; @@ -23033,13 +23064,13 @@ export class BattleScene extends Phaser.Scene { this.hideSaveSlotPanel(); this.showTurnEndPrompt(undefined, { mode: 'load-confirm', - title: '전투 불러오기 확인', - body: `슬롯 ${slot}의 전투를 불러올까요?\n현재 진행은 저장하지 않으면 사라집니다.`, + title: '수동 기록 불러오기', + body: `수동 기록 ${slot}의 전투로 돌아갈까요?\n현재 자동 진행은 교체되지만 수동 원본은 유지됩니다.`, primaryLabel: '불러오기', secondaryLabel: '취소', primaryAction: () => { this.hideTurnEndPrompt(); - this.restartBattleFromSave(slot); + this.loadManualBattleState(slot); }, secondaryAction: () => { this.hideTurnEndPrompt(); @@ -23052,9 +23083,32 @@ export class BattleScene extends Phaser.Scene { return `${battleSaveStorageKey}:slot-${normalizeBattleSaveSlot(slot, campaignSaveSlotCount)}`; } - private readBattleSaveState(slot: number, validateCurrentComposition = false) { - const normalizedSlot = normalizeBattleSaveSlot(slot, campaignSaveSlotCount); - const savedCampaign = readCampaignSaveState(normalizedSlot); + private readManualBattleSaveState(slot: number, validateCurrentComposition = false) { + const record = readManualSave(slot); + if (!record?.battle || record.battle.battleId !== battleScenario.id) { + return undefined; + } + const state = normalizeBattleSaveState( + record.battle, + this.battleSaveValidationOptions(record.campaign, validateCurrentComposition) + ); + if ( + !state || + ( + state.campaignStep !== undefined && + state.campaignStep !== record.campaign.step + ) || + !battleSaveGenerationMatchesCampaign(state, record.campaign) + ) { + return undefined; + } + return state; + } + + private battleSaveValidationOptions( + campaign: CampaignState | undefined, + validateCurrentComposition: boolean + ): BattleSaveValidationOptions { const currentCompositionValidation = validateCurrentComposition ? { validUnitIds: new Set(battleUnits.map((unit) => unit.id)), @@ -23062,39 +23116,95 @@ export class BattleScene extends Phaser.Scene { validEnemyUnitIds: new Set(battleUnits.filter((unit) => unit.faction === 'enemy').map((unit) => unit.id)) } : {}; + return { + allowTacticalCommand: Boolean(battleScenario.sortie), + allowLegacyTacticalCommand: this.isFirstPursuitRoleEffectBattle(), + mapWidth: battleMap.width, + mapHeight: battleMap.height, + ...currentCompositionValidation, + validTacticalCommandRolesByActor: this.tacticalInitiativeActorRolesForCampaign(campaign), + validUsableIds: new Set(Object.keys(usableCatalog)), + validItemIds: new Set( + Object.values(usableCatalog) + .filter((usable) => usable.command === 'item') + .map((usable) => usable.id) + ), + validEquipmentItemIds: new Set(Object.keys(itemCatalog)), + validEquipmentSlotItems: Object.fromEntries( + equipmentSlots.map((equipmentSlot) => [ + equipmentSlot, + new Set(Object.values(itemCatalog).filter((item) => item.slot === equipmentSlot).map((item) => item.id)) + ]) + ), + validTriggeredBattleEventIds: this.validTriggeredBattleEventIds() + }; + } + + private readBattleSaveState(slot: number, validateCurrentComposition = false) { + const normalizedSlot = normalizeBattleSaveSlot(slot, campaignSaveSlotCount); + const savedCampaign = readCampaignSaveState(normalizedSlot); const candidate = readBattleSaveStorageCandidate( battleScenario.id, savedCampaign?.step, normalizedSlot, window.localStorage, campaignSaveSlotCount, - { - allowTacticalCommand: Boolean(battleScenario.sortie), - allowLegacyTacticalCommand: this.isFirstPursuitRoleEffectBattle(), - mapWidth: battleMap.width, - mapHeight: battleMap.height, - ...currentCompositionValidation, - validTacticalCommandRolesByActor: this.tacticalInitiativeActorRolesForCampaign(savedCampaign), - validUsableIds: new Set(Object.keys(usableCatalog)), - validItemIds: new Set( - Object.values(usableCatalog) - .filter((usable) => usable.command === 'item') - .map((usable) => usable.id) - ), - validEquipmentItemIds: new Set(Object.keys(itemCatalog)), - validEquipmentSlotItems: Object.fromEntries( - equipmentSlots.map((slot) => [ - slot, - new Set(Object.values(itemCatalog).filter((item) => item.slot === slot).map((item) => item.id)) - ]) - ), - validTriggeredBattleEventIds: this.validTriggeredBattleEventIds() - }, + this.battleSaveValidationOptions(savedCampaign, validateCurrentComposition), savedCampaign ); return candidate?.state; } + private saveManualBattleState(slot: number) { + try { + const state = this.createBattleSaveState(); + const normalizedSlot = normalizeBattleSaveSlot(slot, campaignSaveSlotCount); + writeManualSave(normalizedSlot, { + campaign: getCampaignState(), + battle: state, + context: 'battle' + }); + this.renderSituationPanel( + `수동 기록 ${normalizedSlot}에 전투를 보관했습니다.\n자동 저장과 별도로 유지됩니다. · ${this.formatSavedAt(state.savedAt)}` + ); + } catch (error) { + this.renderSituationPanel( + error instanceof ManualSaveStorageError && + error.rollbackSucceeded === false + ? '수동 저장과 기존 기록 복원에 실패했습니다.\n제목 화면에서 저장 상태를 확인해 주세요.' + : '수동 기록을 보관하지 못했습니다.\n기존 수동 기록과 자동 저장은 변경하지 않았습니다.' + ); + } + } + + private loadManualBattleState(slot: number) { + const state = this.readManualBattleSaveState(slot, true); + if (!state) { + this.renderSituationPanel('이 전투에서 불러올 수 있는 수동 기록이 없습니다.'); + return; + } + + try { + const restoredCampaign = restoreManualSave(slot); + const resumeSlot = restoredCampaign.activeSaveSlot; + if (!this.readBattleSaveState(resumeSlot, true)) { + throw new Error('The restored manual battle could not be staged for resume.'); + } + this.scene.restart({ + battleId: battleScenario.id, + resumeBattleSaveSlot: resumeSlot + } satisfies BattleSceneData); + } catch (error) { + console.error('[Manual Save] Failed to restore battle state.', error); + this.renderSituationPanel( + error instanceof ManualSaveStorageError && + error.rollbackSucceeded === false + ? '수동 기록 복원에 실패했습니다.\n제목 화면에서 자동 저장 상태를 확인해 주세요.' + : '수동 기록을 불러오지 못했습니다.\n현재 자동 진행과 수동 원본은 변경하지 않았습니다.' + ); + } + } + private restartBattleFromSave(slot: number) { const normalizedSlot = normalizeBattleSaveSlot(slot, campaignSaveSlotCount); if (!this.readBattleSaveState(normalizedSlot)) { @@ -23152,6 +23262,7 @@ export class BattleScene extends Phaser.Scene { return false; } + let attemptedSlot: number | undefined; let previousStorageValues: Array<{ key: string; value: string | null; @@ -23163,6 +23274,7 @@ export class BattleScene extends Phaser.Scene { campaign.activeSaveSlot, campaignSaveSlotCount ); + attemptedSlot = normalizedSlot; const state = this.createBattleSaveState(); const serializedState = JSON.stringify(state); const slotKey = @@ -23202,6 +23314,12 @@ export class BattleScene extends Phaser.Scene { this.battleAutosaveLastResult = 'saved'; this.battleAutosaveLastSlot = normalizedSlot; this.battleAutosaveLastSavedAt = state.savedAt; + if (reason !== 'hidden' && reason !== 'pagehide') { + this.showBattleAutosaveIndicator( + 'saved', + `자동 저장 완료 · 슬롯 ${normalizedSlot}` + ); + } return true; } catch { if (storageMutationStarted) { @@ -23211,10 +23329,86 @@ export class BattleScene extends Phaser.Scene { } this.battleAutosaveFailureCount += 1; this.battleAutosaveLastResult = 'failed'; + if (reason !== 'hidden' && reason !== 'pagehide') { + this.showBattleAutosaveIndicator( + 'failed', + `자동 저장 실패${attemptedSlot ? ` · 슬롯 ${attemptedSlot}` : ''} · 공간 확인` + ); + } return false; } } + private showBattleAutosaveIndicator( + result: Extract, + message: string + ) { + this.hideBattleAutosaveIndicator(); + if (!this.scene.isActive() || !this.layout) { + return; + } + + const width = this.battleUiLength(result === 'failed' ? 250 : 174); + const height = this.battleUiLength(28); + const x = this.layout.mapX + this.layout.mapWidth / 2; + const y = this.layout.mapY + this.layout.mapHeight - this.battleUiLength(24); + const tone = result === 'saved' ? 0x59d18c : 0xd87562; + const background = this.add.rectangle(0, 0, width, height, 0x0b1118, 0.94); + background.setStrokeStyle(this.battleUiLength(1), tone, 0.86); + const label = this.add.text(0, 0, message, { + fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', + fontSize: this.battleUiFontSize(11), + color: result === 'saved' ? '#bff4d5' : '#ffd0c6', + fontStyle: '700', + fixedWidth: width - this.battleUiLength(18), + align: 'center' + }); + label.setOrigin(0.5); + + const indicator = this.add.container(x, y, [background, label]); + indicator.setDepth(68); + this.battleAutosaveIndicator = indicator; + this.battleAutosaveIndicatorText = message; + this.battleAutosaveIndicatorTimer = this.time.delayedCall( + result === 'saved' ? 1200 : 5200, + () => { + if (this.battleAutosaveIndicator !== indicator) { + return; + } + if (isVisualMotionReduced()) { + this.hideBattleAutosaveIndicator(); + return; + } + this.tweens.add({ + targets: indicator, + alpha: 0, + duration: 180, + onComplete: () => { + if ( + this.battleAutosaveIndicator === + indicator + ) { + this.hideBattleAutosaveIndicator(); + } + } + }); + } + ); + } + + private hideBattleAutosaveIndicator() { + this.battleAutosaveIndicatorTimer?.remove(false); + this.battleAutosaveIndicatorTimer = undefined; + if (this.battleAutosaveIndicator) { + this.tweens.killTweensOf( + this.battleAutosaveIndicator + ); + } + this.battleAutosaveIndicator?.destroy(); + this.battleAutosaveIndicator = undefined; + this.battleAutosaveIndicatorText = undefined; + } + private restoreBattleAutosaveStorage( values: ReadonlyArray<{ key: string; @@ -34591,7 +34785,9 @@ export class BattleScene extends Phaser.Scene { lastResult: this.battleAutosaveLastResult ?? null, lastReason: this.battleAutosaveLastReason ?? null, lastSlot: this.battleAutosaveLastSlot ?? null, - lastSavedAt: this.battleAutosaveLastSavedAt ?? null + lastSavedAt: this.battleAutosaveLastSavedAt ?? null, + indicatorVisible: Boolean(this.battleAutosaveIndicator?.active), + indicatorText: this.battleAutosaveIndicatorText ?? null }, deploymentKeyboard: this.deploymentKeyboardDebugState(), combatAssets: { diff --git a/src/game/scenes/CampScene.ts b/src/game/scenes/CampScene.ts index 0a6ed47..5a73a28 100644 --- a/src/game/scenes/CampScene.ts +++ b/src/game/scenes/CampScene.ts @@ -242,7 +242,6 @@ import { getPendingCampaignVictoryRewardNoticeReport, getPendingCampaignVictoryRewardCategories, markCampaignStep, - listCampaignSaveSlots, campaignSaveSlotCount, saveCampaignState, setActiveCampVisitId, @@ -251,7 +250,6 @@ import { setCampaignSortieOrderSelection, setCampaignReserveTrainingAssignment, setCampaignReserveTrainingFocus, - type CampaignSaveSlotSummary, type CampaignBattleSettlement, type CampaignCampVisitId, type CampaignStep, @@ -268,6 +266,12 @@ import { type CampaignVictoryRewardReport, type FirstBattleReport } from '../state/campaignState'; +import { + listManualSaveSlots, + ManualSaveStorageError, + writeManualSave, + type ManualSaveSlotSummary +} from '../state/manualSaveStorage'; import { campaignStoryCheckpointVersion, storyBeatIdForPage, @@ -290,7 +294,6 @@ import { thirdCampPreparationProgress } from '../state/thirdCampPreparationActions'; import { thirdCampExplorationProgress } from '../state/thirdCampExplorationActions'; -import { clearCampaignBattleSavesForSlot } from '../state/battleSaveKeys'; import { releaseTextureSource } from '../render/loaderLifecycle'; import { isVisualMotionReduced } from '../settings/visualMotion'; import { palette } from '../ui/palette'; @@ -13319,7 +13322,7 @@ export class CampScene extends Phaser.Scene { this.addTabButton('장비', 'equipment', 872, 38); this.addTabButton('기록 J', 'progress', 946, 38, 76, 14); } - this.addCommandButton('저장', 1030, 38, 76, () => { + this.addCommandButton('수동 저장', 1030, 38, 76, () => { soundDirector.playSelect(); this.showCampSaveSlotPanel(); }); @@ -13370,8 +13373,7 @@ export class CampScene extends Phaser.Scene { const height = 252; const left = campLegacyCanvasWidth - width - 36; const top = 70; - const activeSlot = this.campaign?.activeSaveSlot ?? getCampaignState().activeSaveSlot; - const slots = listCampaignSaveSlots(); + const slots = listManualSaveSlots(); const shade = this.add.rectangle(0, 0, campLegacyCanvasWidth, campLegacyCanvasHeight, 0x020406, 0.28); shade.setOrigin(0); @@ -13409,22 +13411,26 @@ export class CampScene extends Phaser.Scene { panel.setInteractive(); this.trackCampSaveSlot(panel); - const title = this.add.text(left + 22, top + 16, '군영 저장 슬롯', this.textStyle(22, '#f2e3bf', true)); + const title = this.add.text(left + 22, top + 16, '수동 저장 기록', this.textStyle(22, '#f2e3bf', true)); title.setDepth(depth + 1); this.trackCampSaveSlot(title); const guide = this.add.text( left + 22, top + 48, - '군영 진행과 편성을 슬롯에 저장합니다. · 1~3키 저장 · Esc 닫기', + '자동 저장과 별도 보관 · 1~3 저장 · Esc 닫기', this.textStyle(13, '#aeb7c2') ); guide.setDepth(depth + 1); this.trackCampSaveSlot(guide); for (let slot = 1; slot <= campaignSaveSlotCount; slot += 1) { - const summary = slots.find((candidate) => candidate.slot === slot) ?? { slot, exists: false }; - this.renderCampSaveSlotRow(summary, activeSlot, left + 18, top + 82 + (slot - 1) * 52, width - 36, depth + 1); + const summary = slots.find((candidate) => candidate.slot === slot) ?? { + slot, + exists: false, + status: 'empty' as const + }; + this.renderCampSaveSlotRow(summary, left + 18, top + 82 + (slot - 1) * 52, width - 36, depth + 1); } const close = this.add.text(left + width - 58, top + 18, '닫기', this.textStyle(14, '#d8b15f', true)); @@ -13438,30 +13444,28 @@ export class CampScene extends Phaser.Scene { } private renderCampSaveSlotRow( - summary: CampaignSaveSlotSummary, - activeSlot: number, + summary: ManualSaveSlotSummary, x: number, y: number, width: number, depth: number ) { - const active = summary.slot === activeSlot; - const row = this.add.rectangle(x, y, width, 44, active ? 0x263a2d : 0x172230, active ? 0.98 : 0.94); + const row = this.add.rectangle(x, y, width, 44, 0x172230, 0.94); row.setOrigin(0); row.setDepth(depth); - row.setStrokeStyle(1, active ? palette.gold : palette.blue, active ? 0.86 : 0.62); + row.setStrokeStyle(1, summary.exists ? palette.gold : palette.blue, summary.exists ? 0.72 : 0.52); this.trackCampSaveSlot(row); - const title = this.add.text(x + 14, y + 8, `슬롯 ${summary.slot}`, this.textStyle(15, '#f2e3bf', true)); + const title = this.add.text(x + 14, y + 8, '슬롯', this.textStyle(15, '#f2e3bf', true)); title.setDepth(depth + 1); this.trackCampSaveSlot(title); - const keycap = this.add.rectangle(x + 66, y + 22, 26, 20, active ? 0x314a38 : 0x263647, active ? 0.98 : 0.88); + const keycap = this.add.rectangle(x + 66, y + 22, 26, 20, 0x263647, 0.88); keycap.setDepth(depth + 1); - keycap.setStrokeStyle(1, active ? palette.gold : palette.blue, active ? 0.76 : 0.52); + keycap.setStrokeStyle(1, summary.exists ? palette.gold : palette.blue, summary.exists ? 0.66 : 0.52); this.trackCampSaveSlot(keycap); - const keyText = this.add.text(x + 66, y + 13, String(summary.slot), this.textStyle(11, active ? '#f2e3bf' : '#cbd6e2', true)); + const keyText = this.add.text(x + 66, y + 13, String(summary.slot), this.textStyle(11, '#cbd6e2', true)); keyText.setOrigin(0.5, 0); keyText.setDepth(depth + 2); this.trackCampSaveSlot(keyText); @@ -13469,8 +13473,16 @@ export class CampScene extends Phaser.Scene { const detail = this.add.text( x + 92, y + 7, - summary.exists ? this.campSaveSlotDetail(summary) : '비어 있음', - this.textStyle(12, summary.exists ? '#d4dce6' : '#87919c', summary.exists) + summary.status === 'invalid' + ? '기록을 읽을 수 없음' + : summary.exists + ? this.campSaveSlotDetail(summary) + : '비어 있음', + this.textStyle( + 12, + summary.status === 'invalid' ? '#ffb6a6' : summary.exists ? '#d4dce6' : '#87919c', + summary.exists + ) ); detail.setDepth(depth + 1); this.trackCampSaveSlot(detail); @@ -13478,13 +13490,17 @@ export class CampScene extends Phaser.Scene { const meta = this.add.text( x + 92, y + 24, - summary.exists ? this.campSaveSlotMeta(summary) : '새 저장으로 사용', - this.textStyle(10, summary.exists ? '#9fb0bf' : '#d8b15f', true) + summary.status === 'invalid' + ? '교체하면 새 보호 기록으로 복구됩니다.' + : summary.exists + ? this.campSaveSlotMeta(summary) + : '자동 저장과 별도로 새 기록 보관', + this.textStyle(10, summary.status === 'invalid' ? '#ffcf9a' : summary.exists ? '#9fb0bf' : '#d8b15f', true) ); meta.setDepth(depth + 1); this.trackCampSaveSlot(meta); - const saveLabel = this.add.text(x + width - 50, y + 13, active ? '현재' : '저장', this.textStyle(12, active ? '#a8ffd0' : '#ffdf7b', true)); + const saveLabel = this.add.text(x + width - 50, y + 13, summary.exists ? '교체' : '보관', this.textStyle(12, '#ffdf7b', true)); saveLabel.setOrigin(0.5, 0); saveLabel.setDepth(depth + 1); this.trackCampSaveSlot(saveLabel); @@ -13494,19 +13510,25 @@ export class CampScene extends Phaser.Scene { object.setInteractive({ useHandCursor: true }); object.on('pointerdown', run); }); - row.on('pointerover', () => row.setFillStyle(active ? 0x314a38 : 0x263647, 0.98)); - row.on('pointerout', () => row.setFillStyle(active ? 0x263a2d : 0x172230, active ? 0.98 : 0.94)); + row.on('pointerover', () => row.setFillStyle(0x263647, 0.98)); + row.on('pointerout', () => row.setFillStyle(0x172230, 0.94)); } - private campSaveSlotDetail(summary: CampaignSaveSlotSummary) { + private campSaveSlotDetail(summary: ManualSaveSlotSummary) { + if (summary.status === 'invalid') { + return '기록을 읽을 수 없음'; + } const progressTitle = summary.progressTitle ?? summary.battleTitle ?? '군영 진행'; return this.compactText(`군자금 ${summary.gold ?? 0} · ${progressTitle}`, 28); } - private campSaveSlotMeta(summary: CampaignSaveSlotSummary) { + private campSaveSlotMeta(summary: ManualSaveSlotSummary) { + if (summary.status === 'invalid') { + return '손상된 기록 · 교체하여 복구 가능'; + } const roster = summary.rosterCount ? ` · ${summary.rosterCount}명` : ''; const supplies = summary.inventoryCount ? ` · 보급 ${summary.inventoryCount}` : ''; - return this.compactText(`${summary.progressMeta ?? '진행 저장'} · ${this.formatCampSaveUpdatedAt(summary.updatedAt)}${roster}${supplies}`, 44); + return this.compactText(`${summary.progressMeta ?? '진행 저장'} · ${this.formatCampSaveUpdatedAt(summary.savedAt)}${roster}${supplies}`, 44); } private formatCampSaveUpdatedAt(updatedAt?: string) { @@ -13527,9 +13549,8 @@ export class CampScene extends Phaser.Scene { } private requestSaveCampToSlot(slot: number) { - const activeSlot = this.campaign?.activeSaveSlot ?? getCampaignState().activeSaveSlot; - const summary = listCampaignSaveSlots().find((candidate) => candidate.slot === slot); - if (summary?.exists && summary.slot !== activeSlot) { + const summary = listManualSaveSlots().find((candidate) => candidate.slot === slot); + if (summary?.exists) { this.showCampSaveOverwriteConfirm(summary); return; } @@ -13537,7 +13558,7 @@ export class CampScene extends Phaser.Scene { this.saveCampToSlot(slot); } - private showCampSaveOverwriteConfirm(summary: CampaignSaveSlotSummary) { + private showCampSaveOverwriteConfirm(summary: ManualSaveSlotSummary) { this.hideCampSaveConfirm(); this.pendingSaveSlot = summary.slot; @@ -13574,7 +13595,7 @@ export class CampScene extends Phaser.Scene { detail.setLineSpacing(5); this.trackCampSaveConfirm(detail); - const message = this.add.text(x + 22, y + 96, '현재 군영 진행으로 이 슬롯을 교체합니다.', this.textStyle(12, '#ffdf7b', true)); + const message = this.add.text(x + 22, y + 96, '보호된 수동 기록만 교체하며 자동 저장은 그대로 둡니다.', this.textStyle(12, '#ffdf7b', true)); message.setDepth(depth + 1); this.trackCampSaveConfirm(message); @@ -13619,14 +13640,36 @@ export class CampScene extends Phaser.Scene { this.closeSortieRecommendationBrowserState(); this.closeSortiePresetBrowserState(); this.hideCampSaveSlotPanel(); - clearCampaignBattleSavesForSlot(slot); - this.campaign = saveCampaignState(getCampaignState(), slot); + try { + writeManualSave(slot, { + campaign: getCampaignState(), + context: 'camp' + }); + this.campaign = getCampaignState(); + } catch (error) { + console.error( + '[Manual Save] Failed to write camp state.', + error + ); + if (returnToSortiePrep) { + this.showSortiePrep(); + } else { + this.render(); + } + this.showCampNotice( + error instanceof ManualSaveStorageError && + error.rollbackSucceeded === false + ? '수동 저장과 기존 기록 복원에 실패했습니다. 제목 화면에서 저장 상태를 확인해 주세요.' + : '수동 기록을 보관하지 못했습니다. 기존 기록과 자동 저장은 변경하지 않았습니다.' + ); + return; + } if (returnToSortiePrep) { this.showSortiePrep(); } else { this.render(); } - this.showCampNotice(`슬롯 ${this.campaign.activeSaveSlot}에 군영 진행을 저장했습니다.`); + this.showCampNotice(`수동 기록 ${slot}에 보관했습니다. 자동 저장과 별도로 유지됩니다.`); } private handleSaveSlotKey(event: KeyboardEvent) { diff --git a/src/game/scenes/TitleScene.ts b/src/game/scenes/TitleScene.ts index adf0161..2d21767 100644 --- a/src/game/scenes/TitleScene.ts +++ b/src/game/scenes/TitleScene.ts @@ -32,6 +32,12 @@ import { } from '../data/thirdCampPreparation'; import { battleIdForCampaignStep, isCampCampaignStep } from '../state/campaignRouting'; import { readCampaignBattleResume, type CampaignBattleResume } from '../state/battleSaveStorage'; +import { + listManualSaveSlots, + ManualSaveStorageError, + restoreManualSave, + type ManualSaveSlotSummary +} from '../state/manualSaveStorage'; import { clearCampaignStoryCheckpoint, completeLegacyFirstVictoryStoryHandoff, @@ -42,9 +48,9 @@ import { loadCampaignState, prologueMilitiaCampCampaignTutorialIds, prologueVillageCampaignTutorialIds, + readCampaignSaveState, summarizeCampaignProgress, startNewCampaign, - type CampaignCampVisitId, type CampaignState, type CampaignSaveSlotSummary, type CampaignStoryHandoffReference @@ -55,7 +61,6 @@ import { storySequenceIdForPages, storyCheckpointSequenceMatchesPages } from '../state/campaignStoryCheckpoint'; -import { resolveCampaignObjectiveJournal } from '../state/campaignObjectiveJournal'; import { palette } from '../ui/palette'; import { isLazySceneKey, @@ -67,7 +72,13 @@ const fhdUiScale = 1.5; const ui = (value: number) => value * fhdUiScale; const uiPx = (value: number) => `${Math.round(ui(value))}px`; -type TitleFocusScope = 'main' | 'settings' | 'save-slots' | 'new-game-confirm'; +type TitleFocusScope = + | 'main' + | 'settings' + | 'save-slots' + | 'manual-load-confirm' + | 'new-game-confirm'; +type SaveSlotPanelMode = 'auto' | 'manual'; type TitleSceneData = { storyHandoff?: CampaignStoryHandoffReference; @@ -85,7 +96,12 @@ type TitleFocusItem = { export class TitleScene extends Phaser.Scene { private settingsPanel?: Phaser.GameObjects.Container; private newGameConfirmPanel?: Phaser.GameObjects.Container; + private manualLoadConfirmPanel?: Phaser.GameObjects.Container; + private manualLoadConfirmFeedback?: Phaser.GameObjects.Text; + private pendingManualLoadSlot?: number; private saveSlotPanel?: Phaser.GameObjects.Container; + private saveSlotPanelMode?: SaveSlotPanelMode; + private saveSlotPanelFeedback?: Phaser.GameObjects.Text; private readonly focusItems = new Map(); private focusScope: TitleFocusScope = 'main'; private focusedItemId?: string; @@ -108,7 +124,12 @@ export class TitleScene extends Phaser.Scene { ); this.settingsPanel = undefined; this.newGameConfirmPanel = undefined; + this.manualLoadConfirmPanel = undefined; + this.manualLoadConfirmFeedback = undefined; + this.pendingManualLoadSlot = undefined; this.saveSlotPanel = undefined; + this.saveSlotPanelMode = undefined; + this.saveSlotPanelFeedback = undefined; this.focusItems.clear(); this.focusScope = 'main'; this.focusedItemId = undefined; @@ -403,30 +424,42 @@ export class TitleScene extends Phaser.Scene { private drawMenu(width: number, height: number) { const compact = width < ui(720); const menuX = compact ? width / 2 : width - ui(318); - const menuY = height * (compact ? 0.36 : 0.43); + const menuY = height * (compact ? 0.36 : 0.4); const canContinue = hasCampaignSave(); const campaign = canContinue ? loadCampaignState() : undefined; const isEndingComplete = campaign?.step === 'ending-complete'; - const plateHeight = ui(campaign ? 348 : 252); + const manualSlots = listManualSaveSlots(); + const canLoadManual = manualSlots.some( + (slot) => slot.exists + ); + const plateHeight = ui(campaign ? 454 : 326); - const plate = this.add.rectangle(menuX, menuY, ui(250), plateHeight, 0x111821, 0.66); + const plate = this.add.rectangle(menuX, menuY, ui(268), plateHeight, 0x111821, 0.66); plate.setStrokeStyle(ui(1), palette.gold, 0.45); - this.add.rectangle(menuX, menuY - plateHeight / 2, ui(186), ui(2), palette.gold, 0.7); - this.add.rectangle(menuX, menuY + plateHeight / 2, ui(186), ui(2), palette.gold, 0.36); + this.add.rectangle(menuX, menuY - plateHeight / 2, ui(202), ui(2), palette.gold, 0.7); + this.add.rectangle(menuX, menuY + plateHeight / 2, ui(202), ui(2), palette.gold, 0.36); - this.createMenuButton('new-game', menuX, menuY - ui(70), '새 게임', true, () => this.requestStartGame()); + this.createMenuButton('new-game', menuX, menuY - ui(105), '새 게임', true, () => this.requestStartGame()); this.createMenuButton( 'continue', menuX, - menuY, - isEndingComplete ? '엔딩 보기' : '이어하기', + menuY - ui(35), + isEndingComplete ? '엔딩 보기' : '자동 저장 이어하기', canContinue, () => this.requestContinueGame() ); - this.createMenuButton('settings', menuX, menuY + ui(70), '설정', true, () => this.openSettingsPanel(menuX, menuY)); + this.createMenuButton( + 'manual-load', + menuX, + menuY + ui(35), + '수동 기록 불러오기', + canLoadManual, + () => this.requestManualSaveGame() + ); + this.createMenuButton('settings', menuX, menuY + ui(105), '설정', true, () => this.openSettingsPanel(menuX, menuY)); if (campaign) { - this.drawCampaignSaveBadge(menuX, menuY + ui(132), campaign, isEndingComplete); + this.drawCampaignSaveBadge(menuX, menuY + ui(190), campaign, isEndingComplete); } this.setFocusScope('main', canContinue ? 'continue' : 'new-game'); @@ -442,7 +475,7 @@ export class TitleScene extends Phaser.Scene { const background = this.add.rectangle(0, 0, ui(236), ui(78), 0x21180f, 0.92); background.setStrokeStyle(ui(1), palette.gold, 0.52); - const title = this.add.text(0, -ui(24), isEndingComplete ? '완결 저장' : '진행 저장', { + const title = this.add.text(0, -ui(24), '자동 저장', { fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', fontSize: uiPx(18), color: '#f6e6bd', @@ -489,13 +522,7 @@ export class TitleScene extends Phaser.Scene { return this.compactSaveDetail(`${Object.keys(campaign.battleHistory).length}전 완료 · ${campaign.roster.length}명 합류`); } - if (campaign.activeCampVisitId || campaign.activeCityStayId) { - const progress = summarizeCampaignProgress(campaign); - return this.compactSaveDetail(`↪ ${progress.title}`); - } - - const journal = resolveCampaignObjectiveJournal(campaign); - return this.compactSaveDetail(`◆ ${journal.current.title}`); + return this.compactSaveDetail(summarizeCampaignProgress(campaign).title); } private compactSaveDetail(label: string) { @@ -511,27 +538,11 @@ export class TitleScene extends Phaser.Scene { if (battleResume) { return this.campaignBattleResumeMeta(battleResume); } - if (campaign.activeCampVisitId) { - return this.campVisitResumeMeta(campaign.activeCampVisitId); - } - if (campaign.activeCityStayId) { - const journal = resolveCampaignObjectiveJournal(campaign); - const recent = this.compactResumeLabel(journal.recentCompleted.title, 6); - const current = this.compactResumeLabel(journal.current.title, 7); - return `✓${recent} → ◆${current}`; - } - return `슬롯 ${campaign.activeSaveSlot} · ${this.formatSaveUpdatedAt(campaign.updatedAt)}`; - } - - private campVisitResumeMeta(visitId: CampaignCampVisitId) { - switch (visitId) { - case 'first-pursuit-scout-tent': - return '✓ 탁현 승리 → ◆ 북문 정찰'; - case 'second-pursuit-aftermath-relief': - return '✓ 추격전 승리 → ◆ 현장 수습'; - case 'third-guangzong-sortie-camp': - return '✓ 광종길 승리 → ◆ 출진 준비'; - } + const progressMeta = this.compactResumeLabel( + summarizeCampaignProgress(campaign).meta, + 9 + ); + return `${progressMeta} · ${this.formatSaveUpdatedAt(campaign.updatedAt)}`; } private compactResumeLabel(label: string, maxLength: number) { @@ -541,8 +552,8 @@ export class TitleScene extends Phaser.Scene { } private campaignBattleResumeMeta(resume: CampaignBattleResume) { - const faction = resume.activeFaction === 'ally' ? '아군 차례' : '적군 차례'; - return `슬롯 ${resume.slot} · ${resume.turnNumber}턴 · ${faction}`; + const faction = resume.activeFaction === 'ally' ? '아군' : '적군'; + return `${resume.turnNumber}턴·${faction} · ${this.formatSaveUpdatedAt(resume.savedAt)}`; } private formatSaveUpdatedAt(updatedAt: string) { @@ -566,18 +577,19 @@ export class TitleScene extends Phaser.Scene { enabled: boolean, onSelect: () => void ) { + const compactLabel = Array.from(label).length > 7; const text = this.add.text(x, y, label, { - fontSize: uiPx(30), + fontSize: uiPx(compactLabel ? 23 : 30), color: enabled ? '#f1e3c2' : '#7d8189', fontStyle: '700', - fixedWidth: ui(190), + fixedWidth: ui(216), align: 'center', padding: { top: ui(10), bottom: ui(10) } }); text.setOrigin(0.5); text.setShadow(0, ui(3), '#080a0e', ui(8), true, true); - const marker = this.add.text(x - ui(116), y, '▶', { + const marker = this.add.text(x - ui(132), y, '▶', { fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', fontSize: uiPx(17), color: '#f6e6bd', @@ -585,7 +597,7 @@ export class TitleScene extends Phaser.Scene { }); marker.setOrigin(0.5).setVisible(false); - const hitArea = this.add.zone(x, y, ui(216), ui(58)); + const hitArea = this.add.zone(x, y, ui(236), ui(58)); hitArea.setOrigin(0.5); const render = (focused: boolean) => { marker.setVisible(enabled && focused); @@ -628,6 +640,7 @@ export class TitleScene extends Phaser.Scene { soundDirector.playSelect(); this.closeNewGameConfirmPanel(false); + this.closeManualLoadConfirmPanel(false); this.closeSaveSlotPanel(false); this.clearFocusScope('settings'); @@ -868,11 +881,28 @@ export class TitleScene extends Phaser.Scene { const { width, height } = this.scale; const compact = width < ui(720); const menuX = compact ? width / 2 : width - ui(318); - const menuY = height * (compact ? 0.36 : 0.43); - this.openSaveSlotPanel(menuX, menuY); + const menuY = height * (compact ? 0.36 : 0.4); + this.openSaveSlotPanel(menuX, menuY, 'auto'); } - private openSaveSlotPanel(x: number, y: number) { + private requestManualSaveGame() { + if (!listManualSaveSlots().some((slot) => slot.exists)) { + return; + } + + const { width, height } = this.scale; + const compact = width < ui(720); + const menuX = compact ? width / 2 : width - ui(318); + const menuY = height * (compact ? 0.36 : 0.4); + this.openSaveSlotPanel(menuX, menuY, 'manual'); + } + + private openSaveSlotPanel( + x: number, + y: number, + mode: SaveSlotPanelMode = 'auto', + preferredManualSlot?: number + ) { if (this.saveSlotPanel) { this.closeSaveSlotPanel(); return; @@ -881,30 +911,64 @@ export class TitleScene extends Phaser.Scene { soundDirector.playSelect(); this.closeSettingsPanel(false); this.closeNewGameConfirmPanel(false); + this.closeManualLoadConfirmPanel(false); this.clearFocusScope('save-slots'); + this.saveSlotPanelMode = mode; const panel = this.add.container(x, y + ui(252)); - const backdrop = this.add.rectangle(0, 0, ui(318), ui(280), 0x0e151d, 0.95); + const backdrop = this.add.rectangle(0, 0, ui(360), ui(286), 0x0e151d, 0.95); backdrop.setStrokeStyle(ui(1), palette.gold, 0.58); backdrop.setInteractive(); - const title = this.add.text(0, -ui(116), '이어갈 기록 선택 (1~3)', { + const title = this.add.text( + 0, + -ui(120), + mode === 'manual' + ? '수동 기록 불러오기 (1~3)' + : '자동 저장 선택 (1~3)', + { fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', fontSize: uiPx(22), color: '#f1e3c2', fontStyle: '700', - fixedWidth: ui(266), + fixedWidth: ui(308), align: 'center' - }); + } + ); title.setOrigin(0.5); - const rows = listCampaignSaveSlots().map((slot, index) => this.createSaveSlotRow(slot, -ui(82) + index * ui(58))); + const rows = mode === 'manual' + ? listManualSaveSlots().map((slot, index) => + this.createManualSaveSlotRow( + slot, + -ui(86) + index * ui(56) + ) + ) + : listCampaignSaveSlots().map((slot, index) => + this.createSaveSlotRow( + slot, + -ui(86) + index * ui(56) + ) + ); + + const feedback = this.add.text(0, ui(61), '', { + fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', + fontSize: uiPx(10), + color: '#ff9b86', + fontStyle: '700', + fixedWidth: ui(308), + align: 'center' + }); + feedback.setOrigin(0.5); + feedback.setVisible(false); + this.saveSlotPanelFeedback = feedback; + this.createSettingsButton( panel, 'save-slots', 'close', 0, - ui(96), + ui(94), '닫기', () => { soundDirector.playSelect(); @@ -912,17 +976,17 @@ export class TitleScene extends Phaser.Scene { } ); - const hint = this.add.text(0, ui(120), '↑↓/Tab 이동 · Enter/Space 선택\n숫자키 1~3 · Esc 닫기', { + const hint = this.add.text(0, ui(121), '↑↓/Tab 이동 · Enter/Space 선택\n숫자키 1~3 · Esc 닫기', { fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', fontSize: uiPx(11), color: '#9fb0bf', fontStyle: '700', - fixedWidth: ui(260), + fixedWidth: ui(308), align: 'center' }); hint.setOrigin(0.5); - panel.addAt([backdrop, title, ...rows, hint], 0); + panel.addAt([backdrop, title, ...rows, feedback, hint], 0); panel.setAlpha(0); this.saveSlotPanel = panel; this.tweens.add({ @@ -932,14 +996,26 @@ export class TitleScene extends Phaser.Scene { duration: this.visualMotionReduced ? 0 : 160, ease: 'Sine.easeOut' }); - const activeSlot = loadCampaignState().activeSaveSlot; - this.setFocusScope('save-slots', `slot-${activeSlot}`); + const preferredSlot = mode === 'manual' + ? ( + preferredManualSlot ?? + listManualSaveSlots().find( + (slot) => + slot.exists && + slot.status === 'valid' + )?.slot + ) + : loadCampaignState().activeSaveSlot; + this.setFocusScope( + 'save-slots', + preferredSlot ? `slot-${preferredSlot}` : 'close' + ); } private createSaveSlotRow(slot: CampaignSaveSlotSummary, y: number) { const row = this.add.container(0, y); const enabled = slot.exists; - const background = this.add.rectangle(0, 0, ui(270), ui(48), enabled ? 0x1b2530 : 0x121820, enabled ? 0.9 : 0.56); + const background = this.add.rectangle(0, 0, ui(312), ui(48), enabled ? 0x1b2530 : 0x121820, enabled ? 0.9 : 0.56); background.setStrokeStyle(ui(1), enabled ? palette.gold : 0x56616d, enabled ? 0.42 : 0.2); const campaign = enabled ? loadCampaignState(slot.slot) : undefined; @@ -949,37 +1025,37 @@ export class TitleScene extends Phaser.Scene { : campaign ? readCampaignBattleResume(campaign, slot.slot) : undefined; - const title = this.add.text(-ui(122), -ui(12), `슬롯 ${slot.slot}`, { + const title = this.add.text(-ui(144), -ui(12), `슬롯 ${slot.slot}`, { fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', fontSize: uiPx(16), color: enabled ? '#f6e6bd' : '#7d8189', fontStyle: '700', - fixedWidth: ui(70), + fixedWidth: ui(62), align: 'left' }); title.setOrigin(0, 0.5); - const detail = this.add.text(-ui(42), -ui(12), campaign ? this.campaignSaveDetail(campaign, isEndingComplete, battleResume) : '저장 없음', { + const detail = this.add.text(-ui(74), -ui(12), campaign ? this.campaignSaveDetail(campaign, isEndingComplete, battleResume) : '저장 없음', { fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', fontSize: uiPx(13), color: enabled ? '#d8b15f' : '#747c86', - fixedWidth: ui(154), + fixedWidth: ui(192), align: 'left' }); detail.setOrigin(0, 0.5); - const meta = this.add.text(-ui(42), ui(12), campaign ? this.campaignSaveMeta(campaign, battleResume) : '빈 슬롯', { + const meta = this.add.text(-ui(74), ui(12), campaign ? this.campaignSaveMeta(campaign, battleResume) : '빈 슬롯', { fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', fontSize: uiPx(11), color: enabled ? '#9fb0bf' : '#626b75', - fixedWidth: ui(154), + fixedWidth: ui(192), align: 'left' }); meta.setOrigin(0, 0.5); - const keycap = this.add.rectangle(ui(112), 0, ui(28), ui(28), enabled ? 0x263647 : 0x151c25, enabled ? 0.92 : 0.58); + const keycap = this.add.rectangle(ui(134), 0, ui(28), ui(28), enabled ? 0x263647 : 0x151c25, enabled ? 0.92 : 0.58); keycap.setStrokeStyle(ui(1), enabled ? palette.gold : 0x53606c, enabled ? 0.54 : 0.24); - const keyText = this.add.text(ui(112), 0, String(slot.slot), { + const keyText = this.add.text(ui(134), 0, String(slot.slot), { fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', fontSize: uiPx(15), color: enabled ? '#f2e3bf' : '#737c86', @@ -987,7 +1063,7 @@ export class TitleScene extends Phaser.Scene { }); keyText.setOrigin(0.5); - const marker = this.add.text(-ui(143), 0, '▶', { + const marker = this.add.text(-ui(166), 0, '▶', { fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', fontSize: uiPx(12), color: '#f6e6bd', @@ -1029,8 +1105,276 @@ export class TitleScene extends Phaser.Scene { return row; } + private createManualSaveSlotRow( + slot: ManualSaveSlotSummary, + y: number + ) { + const row = this.add.container(0, y); + const enabled = + slot.exists && slot.status === 'valid'; + const invalid = + slot.exists && slot.status === 'invalid'; + const background = this.add.rectangle( + 0, + 0, + ui(312), + ui(48), + enabled ? 0x1b2530 : invalid ? 0x2a1718 : 0x121820, + enabled ? 0.9 : invalid ? 0.82 : 0.56 + ); + background.setStrokeStyle( + ui(1), + enabled + ? palette.gold + : invalid + ? palette.red + : 0x56616d, + enabled ? 0.42 : invalid ? 0.58 : 0.2 + ); + + const title = this.add.text( + -ui(144), + -ui(12), + `슬롯 ${slot.slot}`, + { + fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', + fontSize: uiPx(16), + color: enabled + ? '#f6e6bd' + : invalid + ? '#ffb09a' + : '#7d8189', + fontStyle: '700', + fixedWidth: ui(62), + align: 'left' + } + ); + title.setOrigin(0, 0.5); + + const detail = this.add.text( + -ui(74), + -ui(12), + this.manualSaveDetail(slot), + { + fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', + fontSize: uiPx(13), + color: enabled + ? '#d8b15f' + : invalid + ? '#ff9b86' + : '#747c86', + fixedWidth: ui(192), + align: 'left' + } + ); + detail.setOrigin(0, 0.5); + + const meta = this.add.text( + -ui(74), + ui(12), + this.manualSaveMeta(slot), + { + fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', + fontSize: uiPx(11), + color: enabled + ? '#9fb0bf' + : invalid + ? '#c88f88' + : '#626b75', + fixedWidth: ui(192), + align: 'left' + } + ); + meta.setOrigin(0, 0.5); + + const keycap = this.add.rectangle( + ui(134), + 0, + ui(28), + ui(28), + enabled ? 0x263647 : 0x151c25, + enabled ? 0.92 : 0.58 + ); + keycap.setStrokeStyle( + ui(1), + enabled + ? palette.gold + : invalid + ? palette.red + : 0x53606c, + enabled ? 0.54 : invalid ? 0.44 : 0.24 + ); + const keyText = this.add.text( + ui(134), + 0, + String(slot.slot), + { + fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', + fontSize: uiPx(15), + color: enabled ? '#f2e3bf' : '#737c86', + fontStyle: '700' + } + ); + keyText.setOrigin(0.5); + + const marker = this.add.text( + -ui(166), + 0, + '▶', + { + fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', + fontSize: uiPx(12), + color: '#f6e6bd', + fontStyle: '700' + } + ); + marker.setOrigin(0.5).setVisible(false); + row.add([ + background, + title, + detail, + meta, + keycap, + keyText, + marker + ]); + + const render = (focused: boolean) => { + marker.setVisible(enabled && focused); + background.setFillStyle( + enabled && focused + ? 0xd8b15f + : enabled + ? 0x1b2530 + : invalid + ? 0x2a1718 + : 0x121820, + enabled && focused + ? 0.22 + : enabled + ? 0.9 + : invalid + ? 0.82 + : 0.56 + ); + background.setStrokeStyle( + ui(focused ? 2 : 1), + enabled + ? palette.gold + : invalid + ? palette.red + : 0x56616d, + enabled && focused + ? 0.9 + : enabled + ? 0.42 + : invalid + ? 0.58 + : 0.2 + ); + }; + + this.registerFocusItem('save-slots', { + id: `slot-${slot.slot}`, + enabled, + marker, + activate: () => this.activateSaveSlot(slot.slot), + render, + bounds: () => background.getBounds() + }); + render(false); + + if (enabled) { + background.setInteractive({ useHandCursor: true }); + background.on('pointerover', () => + this.focusItem( + 'save-slots', + `slot-${slot.slot}`, + true + ) + ); + background.on('pointerdown', () => { + this.focusItem( + 'save-slots', + `slot-${slot.slot}` + ); + this.activateSaveSlot(slot.slot); + }); + } + + return row; + } + + private manualSaveDetail( + slot: ManualSaveSlotSummary + ) { + if (!slot.exists) { + return '저장 없음'; + } + if (slot.status === 'invalid') { + return '손상된 수동 기록'; + } + if (slot.context === 'battle') { + const battleTitle = this.compactResumeLabel( + slot.battleTitle ?? + slot.progressTitle ?? + slot.battleId ?? + '전투', + 9 + ); + return `전투 · ${battleTitle} · ${slot.turnNumber ?? '?'}턴`; + } + return `군영 · ${this.compactResumeLabel( + slot.progressTitle ?? '진행 기록', + 13 + )}`; + } + + private manualSaveMeta(slot: ManualSaveSlotSummary) { + if (!slot.exists) { + return '빈 슬롯'; + } + const savedAt = slot.savedAt + ? this.formatSaveUpdatedAt(slot.savedAt) + : '시각 미상'; + if (slot.status === 'invalid') { + return `불러올 수 없음 · ${savedAt}`; + } + if (slot.context === 'battle') { + const faction = + slot.activeFaction === 'ally' + ? '아군 차례' + : slot.activeFaction === 'enemy' + ? '적군 차례' + : '차례 미상'; + return `${faction} · ${savedAt}`; + } + const progressMeta = this.compactResumeLabel( + slot.progressMeta ?? '군영 진행', + 10 + ); + return `${progressMeta} · ${savedAt}`; + } + private activateSaveSlot(slot: number) { - if (!listCampaignSaveSlots().some((summary) => summary.slot === slot && summary.exists)) { + if (this.saveSlotPanelMode === 'manual') { + const summary = listManualSaveSlots().find( + (candidate) => + candidate.slot === slot && + candidate.exists && + candidate.status === 'valid' + ); + if (!summary) { + return; + } + this.openManualLoadConfirmPanel(summary); + return; + } + + if (!listCampaignSaveSlots().some( + (summary) => + summary.slot === slot && summary.exists + )) { return; } @@ -1038,12 +1382,319 @@ export class TitleScene extends Phaser.Scene { this.continueGame(slot); } + private openManualLoadConfirmPanel( + summary: ManualSaveSlotSummary + ) { + const freshSummary = listManualSaveSlots().find( + (candidate) => + candidate.slot === summary.slot && + candidate.exists && + candidate.status === 'valid' + ); + if (!freshSummary) { + this.showSaveSlotPanelFailure( + '수동 기록을 다시 확인해 주세요.' + ); + return; + } + + const { width, height } = this.scale; + const compact = width < ui(720); + const menuX = compact + ? width / 2 + : width - ui(318); + const menuY = + height * (compact ? 0.36 : 0.4); + const slot = freshSummary.slot; + const automaticSummary = + listCampaignSaveSlots().find( + (candidate) => + candidate.slot === slot + ); + const automaticCampaign = + automaticSummary?.exists + ? readCampaignSaveState(slot) + : undefined; + const automaticProgress = + automaticCampaign + ? summarizeCampaignProgress( + automaticCampaign + ) + : undefined; + + soundDirector.playSelect(); + this.closeSettingsPanel(false); + this.closeNewGameConfirmPanel(false); + this.closeSaveSlotPanel(false); + this.closeManualLoadConfirmPanel(false); + this.clearFocusScope( + 'manual-load-confirm' + ); + this.pendingManualLoadSlot = slot; + + const panel = this.add.container( + menuX, + menuY + ui(246) + ); + const backdrop = this.add.rectangle( + 0, + 0, + ui(360), + ui(278), + 0x0e151d, + 0.98 + ); + backdrop.setStrokeStyle( + ui(1), + palette.gold, + 0.7 + ); + backdrop.setInteractive(); + + const title = this.add.text( + 0, + -ui(112), + '수동 기록 불러오기', + { + fontFamily: + '"Malgun Gothic", "Noto Sans KR", sans-serif', + fontSize: uiPx(22), + color: '#f1e3c2', + fontStyle: '700', + fixedWidth: ui(308), + align: 'center' + } + ); + title.setOrigin(0.5); + + const source = this.add.text( + 0, + -ui(76), + `불러올 기록 ${slot} · ${this.manualSaveDetail( + freshSummary + )}\n${this.manualSaveMeta(freshSummary)}`, + { + fontFamily: + '"Malgun Gothic", "Noto Sans KR", sans-serif', + fontSize: uiPx(13), + color: '#d8b15f', + fontStyle: '700', + fixedWidth: ui(308), + align: 'center', + lineSpacing: ui(2) + } + ); + source.setOrigin(0.5); + + const target = this.add.text( + 0, + -ui(25), + automaticCampaign + ? `교체될 자동 저장 ${slot} · ${this.compactSaveDetail( + automaticProgress?.title ?? + '진행 기록' + )}\n${this.compactResumeLabel( + automaticProgress?.meta ?? + '진행 중', + 12 + )} · ${this.formatSaveUpdatedAt( + automaticCampaign.updatedAt + )}` + : `자동 저장 ${slot} · 저장 없음\n새 복원 지점으로 만듭니다.`, + { + fontFamily: + '"Malgun Gothic", "Noto Sans KR", sans-serif', + fontSize: uiPx(12), + color: automaticCampaign + ? '#ffb98f' + : '#9fb0bf', + fixedWidth: ui(308), + align: 'center', + lineSpacing: ui(2) + } + ); + target.setOrigin(0.5); + + const warning = this.add.text( + 0, + ui(24), + automaticCampaign + ? '확정하면 이어하기 지점이 위 기록으로 바뀝니다.\n다른 자동 저장 슬롯과 수동 원본은 유지됩니다.' + : '수동 원본은 유지되며 자동 저장에 복원 지점을 만듭니다.', + { + fontFamily: + '"Malgun Gothic", "Noto Sans KR", sans-serif', + fontSize: uiPx(12), + color: '#f6e6bd', + fontStyle: '700', + fixedWidth: ui(308), + align: 'center', + lineSpacing: ui(2) + } + ); + warning.setOrigin(0.5); + + const feedback = this.add.text( + 0, + ui(58), + '', + { + fontFamily: + '"Malgun Gothic", "Noto Sans KR", sans-serif', + fontSize: uiPx(10), + color: '#ff9b86', + fontStyle: '700', + fixedWidth: ui(308), + align: 'center' + } + ); + feedback.setOrigin(0.5); + feedback.setVisible(false); + this.manualLoadConfirmFeedback = feedback; + + this.createSettingsButton( + panel, + 'manual-load-confirm', + 'confirm', + 0, + ui(84), + '불러오기', + () => this.confirmManualLoad() + ); + this.createSettingsButton( + panel, + 'manual-load-confirm', + 'cancel', + 0, + ui(118), + '취소', + () => this.cancelManualLoadConfirm() + ); + + panel.addAt( + [ + backdrop, + title, + source, + target, + warning, + feedback + ], + 0 + ); + panel.setAlpha(0); + this.manualLoadConfirmPanel = panel; + this.tweens.add({ + targets: panel, + alpha: 1, + y: menuY + ui(236), + duration: this.visualMotionReduced + ? 0 + : 160, + ease: 'Sine.easeOut' + }); + this.setFocusScope( + 'manual-load-confirm', + 'cancel' + ); + } + + private confirmManualLoad() { + const slot = this.pendingManualLoadSlot; + if (!slot) { + this.closeManualLoadConfirmPanel(); + return; + } + + try { + const restored = restoreManualSave(slot); + if (!restored?.activeSaveSlot) { + throw new Error( + 'The restored manual save has no active slot.' + ); + } + this.closeManualLoadConfirmPanel(false); + this.continueGame( + restored.activeSaveSlot + ); + } catch (error) { + console.error( + 'Failed to restore the manual save.', + error + ); + this.manualLoadConfirmFeedback + ?.setText( + error instanceof + ManualSaveStorageError && + error.rollbackSucceeded === false + ? '복원과 자동 저장 되돌리기에 실패했습니다. 저장 상태를 확인해 주세요.' + : '불러오지 못했습니다. 자동 저장과 수동 원본은 변경되지 않았습니다.' + ) + .setVisible(true); + this.setFocusScope( + 'manual-load-confirm', + 'cancel' + ); + } + } + + private cancelManualLoadConfirm() { + const slot = this.pendingManualLoadSlot; + this.closeManualLoadConfirmPanel(false); + const { width, height } = this.scale; + const compact = width < ui(720); + const menuX = compact + ? width / 2 + : width - ui(318); + const menuY = + height * (compact ? 0.36 : 0.4); + this.openSaveSlotPanel( + menuX, + menuY, + 'manual', + slot + ); + } + + private closeManualLoadConfirmPanel( + restoreFocus = true + ) { + this.clearFocusScope( + 'manual-load-confirm' + ); + this.manualLoadConfirmPanel?.destroy(); + this.manualLoadConfirmPanel = undefined; + this.manualLoadConfirmFeedback = + undefined; + this.pendingManualLoadSlot = undefined; + if (restoreFocus) { + this.setFocusScope( + 'main', + 'manual-load' + ); + } + } + + private showSaveSlotPanelFailure(message: string) { + this.saveSlotPanelFeedback + ?.setText(message) + .setVisible(true); + } + private closeSaveSlotPanel(restoreFocus = true) { + const mode = this.saveSlotPanelMode; this.clearFocusScope('save-slots'); this.saveSlotPanel?.destroy(); this.saveSlotPanel = undefined; + this.saveSlotPanelMode = undefined; + this.saveSlotPanelFeedback = undefined; if (restoreFocus) { - this.setFocusScope('main', 'continue'); + this.setFocusScope( + 'main', + mode === 'manual' + ? 'manual-load' + : 'continue' + ); } } @@ -1105,6 +1756,9 @@ export class TitleScene extends Phaser.Scene { if (this.saveSlotPanel) { return 'save-slots'; } + if (this.manualLoadConfirmPanel) { + return 'manual-load-confirm'; + } if (this.newGameConfirmPanel) { return 'new-game-confirm'; } @@ -1188,6 +1842,12 @@ export class TitleScene extends Phaser.Scene { return; } + if (this.manualLoadConfirmPanel) { + soundDirector.playSelect(); + this.cancelManualLoadConfirm(); + return; + } + if (this.newGameConfirmPanel) { soundDirector.playSelect(); this.closeNewGameConfirmPanel(); @@ -1209,7 +1869,7 @@ export class TitleScene extends Phaser.Scene { const { width, height } = this.scale; const compact = width < ui(720); const menuX = compact ? width / 2 : width - ui(318); - const menuY = height * (compact ? 0.36 : 0.43); + const menuY = height * (compact ? 0.36 : 0.4); this.openNewGameConfirmPanel(menuX, menuY); } @@ -1222,6 +1882,7 @@ export class TitleScene extends Phaser.Scene { soundDirector.playSelect(); this.closeSettingsPanel(false); this.closeSaveSlotPanel(false); + this.closeManualLoadConfirmPanel(false); this.clearFocusScope('new-game-confirm'); const panel = this.add.container(x, y + ui(240)); @@ -1239,14 +1900,19 @@ export class TitleScene extends Phaser.Scene { }); title.setOrigin(0.5); - const message = this.add.text(0, -ui(32), '현재 저장을 덮고\n처음부터 시작합니다.', { + const message = this.add.text( + 0, + -ui(32), + '현재 자동 저장을 새 여정으로 바꿉니다.\n수동 기록은 유지됩니다.', + { fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', fontSize: uiPx(15), color: '#bfc8d3', fixedWidth: ui(236), align: 'center', lineSpacing: ui(4) - }); + } + ); message.setOrigin(0.5); const hint = this.add.text(0, ui(13), '↑↓ 선택 · Enter/Space 결정 · Esc 취소', { @@ -1336,6 +2002,17 @@ export class TitleScene extends Phaser.Scene { panels: { settings: Boolean(this.settingsPanel), saveSlots: Boolean(this.saveSlotPanel), + saveSlotMode: + this.saveSlotPanelMode ?? null, + saveSlotFeedback: + this.saveSlotPanelFeedback?.text ?? null, + manualLoadConfirm: + Boolean(this.manualLoadConfirmPanel), + manualLoadConfirmSlot: + this.pendingManualLoadSlot ?? null, + manualLoadConfirmFeedback: + this.manualLoadConfirmFeedback + ?.text ?? null, newGameConfirm: Boolean(this.newGameConfirmPanel) }, motion: { diff --git a/src/game/state/campaignState.ts b/src/game/state/campaignState.ts index e97a635..83e532f 100644 --- a/src/game/state/campaignState.ts +++ b/src/game/state/campaignState.ts @@ -3023,7 +3023,7 @@ function ensureCampaignState() { return campaignState; } -function normalizeCampaignState(state: Partial): CampaignState { +export function normalizeCampaignState(state: Partial): CampaignState { const normalized = { ...createInitialCampaignState(), ...cloneCampaignState(state as CampaignState) @@ -4913,6 +4913,10 @@ function campaignSaveSlotKey(slot: number) { return `${campaignStorageKey}:slot-${normalizeSlot(slot)}`; } +export function campaignSaveStorageKey(slot: number) { + return campaignSaveSlotKey(slot); +} + function normalizeSlot(slot: number | undefined) { if (!slot || Number.isNaN(slot)) { return 1; diff --git a/src/game/state/manualSaveStorage.ts b/src/game/state/manualSaveStorage.ts new file mode 100644 index 0000000..26a684e --- /dev/null +++ b/src/game/state/manualSaveStorage.ts @@ -0,0 +1,664 @@ +import { battleScenarios, type BattleScenarioId } from '../data/battles'; +import { + normalizeBattleSaveState, + type BattleSaveFaction, + type BattleSaveState +} from './battleSaveState'; +import { + battleSaveGenerationMatchesCampaign, + battleSaveStorageBaseKey, + battleSaveStorageSlotKey, + campaignBattleResumeStorageKeys +} from './battleSaveStorage'; +import { battleIdForCampaignStep } from './campaignRouting'; +import { + campaignSaveSlotCount, + campaignSaveStorageKey, + campaignStorageKey, + normalizeCampaignState, + saveCampaignState, + summarizeCampaignProgress, + type CampaignState +} from './campaignState'; + +export const manualSaveSlotCount = + campaignSaveSlotCount; +export const manualSaveEnvelopeVersion = 1; + +const manualSaveStoragePrefix = + 'heros-web:manual-save:slot-'; + +export type ManualSaveContext = 'camp' | 'battle'; + +export type ManualSaveEnvelope = { + version: typeof manualSaveEnvelopeVersion; + slot: number; + savedAt: string; + context: ManualSaveContext; + campaign: CampaignState; + battle?: BattleSaveState; +}; + +export type ManualSaveWriteInput = { + campaign: CampaignState; + battle?: BattleSaveState; + context: ManualSaveContext; +}; + +export type ManualSaveSlotStatus = + | 'empty' + | 'valid' + | 'invalid'; + +export type ManualSaveSlotSummary = { + slot: number; + exists: boolean; + status: ManualSaveSlotStatus; + savedAt?: string; + updatedAt?: string; + context?: ManualSaveContext; + progressTitle?: string; + progressMeta?: string; + gold?: number; + rosterCount?: number; + inventoryCount?: number; + battleId?: BattleScenarioId; + battleTitle?: string; + turnNumber?: number; + activeFaction?: BattleSaveFaction; +}; + +export type ManualSaveStorageErrorCode = + | 'invalid-slot' + | 'storage-unavailable' + | 'invalid-save' + | 'write-failed' + | 'restore-failed' + | 'rollback-failed'; + +export class ManualSaveStorageError extends Error { + readonly code: ManualSaveStorageErrorCode; + readonly rollbackSucceeded?: boolean; + + constructor( + code: ManualSaveStorageErrorCode, + message: string, + options: ErrorOptions & { + rollbackSucceeded?: boolean; + } = {} + ) { + super(message, options); + this.name = 'ManualSaveStorageError'; + this.code = code; + this.rollbackSucceeded = + options.rollbackSucceeded; + } +} + +type ManualSaveStorageLike = Pick< + Storage, + 'getItem' | 'setItem' | 'removeItem' +>; + +type StorageSnapshotEntry = { + key: string; + value: string | null; +}; + +export function manualSaveStorageKeyForSlot( + slot: number +) { + return `${manualSaveStoragePrefix}${requireManualSaveSlot(slot)}`; +} + +export function readManualSave( + slot: number +): ManualSaveEnvelope | undefined { + const normalizedSlot = requireManualSaveSlot(slot); + const storage = requireBrowserStorage(); + const raw = readStorageValue( + storage, + manualSaveStorageKeyForSlot(normalizedSlot) + ); + return parseManualSaveEnvelope(raw, normalizedSlot); +} + +export function listManualSaveSlots(): + ManualSaveSlotSummary[] { + const storage = requireBrowserStorage(); + return Array.from( + { length: manualSaveSlotCount }, + (_, index) => { + const slot = index + 1; + const raw = readStorageValue( + storage, + manualSaveStorageKeyForSlot(slot) + ); + if (raw === null) { + return { + slot, + exists: false, + status: 'empty' + }; + } + + const save = parseManualSaveEnvelope( + raw, + slot + ); + if (!save) { + return { + slot, + exists: true, + status: 'invalid' + }; + } + + return manualSaveSummary(save); + } + ); +} + +export function writeManualSave( + slot: number, + input: ManualSaveWriteInput +): ManualSaveEnvelope { + const normalizedSlot = requireManualSaveSlot(slot); + const storage = requireBrowserStorage(); + const storageKey = + manualSaveStorageKeyForSlot(normalizedSlot); + const previousRaw = readStorageValue( + storage, + storageKey + ); + const envelope = createManualSaveEnvelope( + normalizedSlot, + input + ); + const serialized = JSON.stringify(envelope); + + try { + storage.setItem(storageKey, serialized); + const persisted = storage.getItem(storageKey); + if ( + persisted !== serialized || + !parseManualSaveEnvelope( + persisted, + normalizedSlot + ) + ) { + throw new Error( + 'The manual save could not be verified after writing.' + ); + } + return cloneManualSaveEnvelope(envelope); + } catch (cause) { + const rollbackSucceeded = + restoreSingleStorageValue( + storage, + storageKey, + previousRaw + ); + throw new ManualSaveStorageError( + rollbackSucceeded + ? 'write-failed' + : 'rollback-failed', + rollbackSucceeded + ? '수동 기록을 저장하지 못했습니다. 기존 기록은 유지되었습니다.' + : '수동 기록 저장과 기존 기록 복원에 실패했습니다.', + { + cause, + rollbackSucceeded + } + ); + } +} + +export function restoreManualSave( + slot: number +): CampaignState { + const normalizedSlot = requireManualSaveSlot(slot); + const storage = requireBrowserStorage(); + const manualRaw = readStorageValue( + storage, + manualSaveStorageKeyForSlot(normalizedSlot) + ); + const manual = parseManualSaveEnvelope( + manualRaw, + normalizedSlot + ); + if (!manual) { + throw new ManualSaveStorageError( + 'invalid-save', + `수동 기록 ${normalizedSlot}을 불러올 수 없습니다.` + ); + } + + const targetKeys = + autosaveStorageKeysForSlot(normalizedSlot); + const snapshot = captureStorageSnapshot( + storage, + targetKeys + ); + + try { + replaceBattleAutosave( + storage, + normalizedSlot, + targetKeys.battle, + manual.battle + ); + return saveCampaignState( + { + ...manual.campaign, + activeSaveSlot: normalizedSlot + }, + normalizedSlot + ); + } catch (cause) { + const rollbackSucceeded = restoreStorageSnapshot( + storage, + snapshot + ); + throw new ManualSaveStorageError( + rollbackSucceeded + ? 'restore-failed' + : 'rollback-failed', + rollbackSucceeded + ? '수동 기록을 불러오지 못했습니다. 기존 자동 저장은 유지되었습니다.' + : '수동 기록 복원과 기존 자동 저장 복구에 실패했습니다.', + { + cause, + rollbackSucceeded + } + ); + } +} + +function createManualSaveEnvelope( + slot: number, + input: ManualSaveWriteInput +) { + if (!isManualSaveContext(input.context)) { + throw new ManualSaveStorageError( + 'invalid-save', + '지원하지 않는 수동 저장 위치입니다.' + ); + } + if ( + input.context === 'battle' !== + Boolean(input.battle) + ) { + throw new ManualSaveStorageError( + 'invalid-save', + input.context === 'battle' + ? '전투 수동 기록에는 전투 상태가 필요합니다.' + : '군영 수동 기록에는 전투 상태를 포함할 수 없습니다.' + ); + } + + const candidate: ManualSaveEnvelope = { + version: manualSaveEnvelopeVersion, + slot, + savedAt: new Date().toISOString(), + context: input.context, + campaign: normalizeCampaignState( + input.campaign + ), + ...(input.battle + ? { + battle: input.battle + } + : {}) + }; + const normalized = normalizeManualSaveEnvelope( + candidate, + slot + ); + if (!normalized) { + throw new ManualSaveStorageError( + 'invalid-save', + '현재 진행은 수동 기록으로 저장할 수 없는 상태입니다.' + ); + } + return normalized; +} + +function parseManualSaveEnvelope( + raw: string | null, + expectedSlot: number +) { + if (raw === null) { + return undefined; + } + try { + return normalizeManualSaveEnvelope( + JSON.parse(raw) as unknown, + expectedSlot + ); + } catch { + return undefined; + } +} + +function normalizeManualSaveEnvelope( + value: unknown, + expectedSlot: number +): ManualSaveEnvelope | undefined { + if ( + !isRecord(value) || + value.version !== manualSaveEnvelopeVersion || + value.slot !== expectedSlot || + !isManualSaveContext(value.context) || + !isValidTimestamp(value.savedAt) || + !isRecord(value.campaign) || + value.campaign.version !== 1 + ) { + return undefined; + } + + const campaign = normalizeCampaignState( + value.campaign as CampaignState + ); + const battle = + value.battle === undefined + ? undefined + : normalizeBattleSaveState(value.battle, { + expectedBattleId: + typeof (value.battle as { + battleId?: unknown; + })?.battleId === 'string' + ? (value.battle as { + battleId: string; + }).battleId + : undefined, + allowTacticalCommand: true, + allowLegacyTacticalCommand: true + }); + + if ( + (value.context === 'battle') !== + Boolean(battle) || + ( + battle && + !battleMatchesCampaign(battle, campaign) + ) + ) { + return undefined; + } + + return { + version: manualSaveEnvelopeVersion, + slot: expectedSlot, + savedAt: value.savedAt, + context: value.context, + campaign, + ...(battle ? { battle } : {}) + }; +} + +function battleMatchesCampaign( + battle: BattleSaveState, + campaign: CampaignState +) { + return ( + !campaign.storyCheckpoint && + !campaign.sortiePreparationCheckpoint && + battle.battleId === + battleIdForCampaignStep(campaign.step) && + battle.campaignStep === campaign.step && + battleSaveGenerationMatchesCampaign( + battle, + campaign + ) + ); +} + +function manualSaveSummary( + save: ManualSaveEnvelope +): ManualSaveSlotSummary { + const progress = summarizeCampaignProgress( + save.campaign + ); + const battleId = save.battle + ?.battleId as BattleScenarioId | undefined; + return { + slot: save.slot, + exists: true, + status: 'valid', + savedAt: save.savedAt, + updatedAt: save.savedAt, + context: save.context, + progressTitle: progress.title, + progressMeta: progress.meta, + gold: save.campaign.gold, + rosterCount: save.campaign.roster.length, + inventoryCount: Object.values( + save.campaign.inventory + ).reduce( + (total, amount) => + total + Math.max(0, amount), + 0 + ), + ...(battleId + ? { + battleId, + battleTitle: + battleScenarios[battleId]?.title, + turnNumber: save.battle?.turnNumber, + activeFaction: + save.battle?.activeFaction + } + : {}) + }; +} + +function autosaveStorageKeysForSlot( + slot: number +) { + const campaign = [ + campaignStorageKey, + campaignSaveStorageKey(slot) + ]; + const battle = [ + ...new Set( + (Object.keys( + battleScenarios + ) as BattleScenarioId[]).flatMap( + (battleId) => + campaignBattleResumeStorageKeys( + battleId, + slot, + campaignSaveSlotCount + ) + ) + ) + ]; + return { + campaign, + battle, + all: [...new Set([...campaign, ...battle])] + }; +} + +function replaceBattleAutosave( + storage: ManualSaveStorageLike, + slot: number, + targetKeys: readonly string[], + battle?: BattleSaveState +) { + targetKeys.forEach((key) => { + storage.removeItem(key); + }); + if (!battle) { + return; + } + + const serialized = JSON.stringify(battle); + storage.setItem( + battleSaveStorageSlotKey( + battle.battleId, + slot, + campaignSaveSlotCount + ), + serialized + ); + if (slot === 1) { + storage.setItem( + battleSaveStorageBaseKey( + battle.battleId + ), + serialized + ); + } +} + +function captureStorageSnapshot( + storage: ManualSaveStorageLike, + keys: { + all: readonly string[]; + } +) { + return keys.all.map( + (key) => ({ + key, + value: readStorageValue(storage, key) + }) + ); +} + +function restoreStorageSnapshot( + storage: ManualSaveStorageLike, + snapshot: readonly StorageSnapshotEntry[] +) { + let restored = true; + const ordered = [ + ...snapshot.filter(({ value }) => value === null), + ...snapshot.filter(({ value }) => value !== null) + ]; + ordered.forEach(({ key, value }) => { + if ( + !restoreSingleStorageValue( + storage, + key, + value + ) + ) { + restored = false; + } + }); + if (!restored) { + return false; + } + return snapshot.every(({ key, value }) => { + try { + return storage.getItem(key) === value; + } catch { + return false; + } + }); +} + +function restoreSingleStorageValue( + storage: ManualSaveStorageLike, + key: string, + value: string | null +) { + try { + if (storage.getItem(key) === value) { + return true; + } + if (value === null) { + storage.removeItem(key); + } else { + storage.setItem(key, value); + } + return storage.getItem(key) === value; + } catch { + return false; + } +} + +function readStorageValue( + storage: ManualSaveStorageLike, + key: string +) { + try { + return storage.getItem(key); + } catch (cause) { + throw new ManualSaveStorageError( + 'storage-unavailable', + '브라우저 저장 공간을 읽을 수 없습니다.', + { cause } + ); + } +} + +function requireBrowserStorage(): + ManualSaveStorageLike { + try { + if ( + typeof window === 'undefined' || + !window.localStorage + ) { + throw new Error( + 'Browser storage is unavailable.' + ); + } + return window.localStorage; + } catch (cause) { + throw new ManualSaveStorageError( + 'storage-unavailable', + '브라우저 저장 공간을 사용할 수 없습니다.', + { cause } + ); + } +} + +function requireManualSaveSlot(slot: number) { + if ( + !Number.isInteger(slot) || + slot < 1 || + slot > manualSaveSlotCount + ) { + throw new ManualSaveStorageError( + 'invalid-slot', + `수동 저장 슬롯은 1~${manualSaveSlotCount}만 사용할 수 있습니다.` + ); + } + return slot; +} + +function isManualSaveContext( + value: unknown +): value is ManualSaveContext { + return value === 'camp' || value === 'battle'; +} + +function isValidTimestamp( + value: unknown +): value is string { + return ( + typeof value === 'string' && + value.length > 0 && + Number.isFinite(Date.parse(value)) + ); +} + +function isRecord( + value: unknown +): value is Record { + return ( + typeof value === 'object' && + value !== null && + !Array.isArray(value) + ); +} + +function cloneManualSaveEnvelope( + envelope: ManualSaveEnvelope +) { + return JSON.parse( + JSON.stringify(envelope) + ) as ManualSaveEnvelope; +}