feat: add protected manual save records
This commit is contained in:
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user