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

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