feat: add protected manual save records

This commit is contained in:
2026-07-29 08:49:29 +09:00
parent d04accc9e1
commit 624eb2c219
11 changed files with 4458 additions and 201 deletions

View File

@@ -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",

View File

@@ -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(

View File

@@ -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);

View 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)
)
);
}
};
}

File diff suppressed because it is too large Load Diff

View File

@@ -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',

View File

@@ -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: {

View File

@@ -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

View File

@@ -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;

View 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;
}