feat: add protected manual save records
This commit is contained in:
@@ -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",
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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);
|
||||
|
||||
442
scripts/verify-manual-save-storage.mjs
Normal file
442
scripts/verify-manual-save-storage.mjs
Normal file
@@ -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)
|
||||
)
|
||||
);
|
||||
}
|
||||
};
|
||||
}
|
||||
2039
scripts/verify-manual-save-ux-browser.mjs
Normal file
2039
scripts/verify-manual-save-ux-browser.mjs
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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',
|
||||
|
||||
@@ -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<BattleAutosaveResult, 'saved' | 'failed'>,
|
||||
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: {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -3023,7 +3023,7 @@ function ensureCampaignState() {
|
||||
return campaignState;
|
||||
}
|
||||
|
||||
function normalizeCampaignState(state: Partial<CampaignState>): CampaignState {
|
||||
export function normalizeCampaignState(state: Partial<CampaignState>): 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;
|
||||
|
||||
664
src/game/state/manualSaveStorage.ts
Normal file
664
src/game/state/manualSaveStorage.ts
Normal file
@@ -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<StorageSnapshotEntry>(
|
||||
(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<string, unknown> {
|
||||
return (
|
||||
typeof value === 'object' &&
|
||||
value !== null &&
|
||||
!Array.isArray(value)
|
||||
);
|
||||
}
|
||||
|
||||
function cloneManualSaveEnvelope(
|
||||
envelope: ManualSaveEnvelope
|
||||
) {
|
||||
return JSON.parse(
|
||||
JSON.stringify(envelope)
|
||||
) as ManualSaveEnvelope;
|
||||
}
|
||||
Reference in New Issue
Block a user