fix: resume campaign progress safely

This commit is contained in:
2026-07-29 01:13:57 +09:00
parent 90de2a3559
commit 6f2b6e42a4
31 changed files with 9354 additions and 148 deletions

View File

@@ -22,7 +22,12 @@ try {
readBattleSaveStorageCandidate,
readCampaignBattleResume
} = await server.ssrLoadModule('/src/game/state/battleSaveStorage.ts');
const { resetCampaignState, startNewCampaign } = await server.ssrLoadModule('/src/game/state/campaignState.ts');
const {
createInitialCampaignState,
resetCampaignState,
setCampaignState,
startNewCampaign
} = await server.ssrLoadModule('/src/game/state/campaignState.ts');
const firstBattleId = 'first-battle-zhuo-commandery';
const campaign = { step: 'first-battle', activeSaveSlot: 2 };
@@ -39,6 +44,79 @@ try {
`Expected a matching slotted battle save to become a direct-resume candidate: ${JSON.stringify(directResume)}`
);
const generationCampaign = {
...campaign,
battleResumeGeneration: 4
};
storage.setItem(
slotKey,
JSON.stringify(
createBattleSave({
battleResumeGeneration: 3
})
)
);
assert(
readCampaignBattleResume(
generationCampaign,
2,
storage
) === undefined &&
storage.getItem(slotKey) === null,
'A superseded battle-save generation must be rejected and cleaned.'
);
storage.setItem(
slotKey,
JSON.stringify(
createBattleSave({
battleResumeGeneration: 4,
turnNumber: 6
})
)
);
assert(
readCampaignBattleResume(
generationCampaign,
2,
storage
)?.turnNumber === 6,
'A battle save from the current generation must remain resumable.'
);
const stickyStorage = createStorage({
refuseRemoval: true
});
stickyStorage.setItem(
slotKey,
JSON.stringify(
createBattleSave({
battleResumeGeneration: 3
})
)
);
assert(
readCampaignBattleResume(
generationCampaign,
2,
stickyStorage
) === undefined &&
stickyStorage.getItem(slotKey) !== null,
'A generation mismatch must remain logically blocked when storage refuses physical deletion.'
);
storage.setItem(
slotKey,
JSON.stringify(createBattleSave())
);
assert(
readCampaignBattleResume(
generationCampaign,
2,
storage
) === undefined,
'A legacy generation-zero battle save must not cross a later battle launch generation.'
);
storage.setItem(slotKey, '{broken-json');
assert(readCampaignBattleResume(campaign, 2, storage) === undefined, 'Broken JSON must not become a resume candidate.');
assert(storage.getItem(slotKey) === null, 'Broken slotted battle saves must be removed after validation.');
@@ -50,6 +128,50 @@ try {
const slotOneCampaign = { step: 'first-battle', activeSaveSlot: 1 };
const slotOneKey = battleSaveStorageSlotKey(firstBattleId, 1);
const baseKey = battleSaveStorageBaseKey(firstBattleId);
const generationFilteredStorage = createStorage({
refuseRemoval: true
});
generationFilteredStorage.setItem(
slotOneKey,
JSON.stringify(
createBattleSave({
battleResumeGeneration: 3,
turnNumber: 8
})
)
);
generationFilteredStorage.setItem(
baseKey,
JSON.stringify(
createBattleSave({
battleResumeGeneration: 4,
turnNumber: 5
})
)
);
const generationFilteredCandidate =
readBattleSaveStorageCandidate(
firstBattleId,
'first-battle',
1,
generationFilteredStorage,
3,
{},
{
...slotOneCampaign,
battleResumeGeneration: 4
}
);
assert(
generationFilteredCandidate
?.storageKey === baseKey &&
generationFilteredCandidate.state
.turnNumber === 5 &&
generationFilteredStorage.getItem(
slotOneKey
) !== null,
`A direct BattleScene-style read must reject a stale generation and continue to a current fallback even when physical deletion is refused: ${JSON.stringify(generationFilteredCandidate)}`
);
storage.setItem(slotOneKey, 'corrupted-slot-copy');
storage.setItem(baseKey, JSON.stringify(createBattleSave({ turnNumber: 3 })));
const fallbackResume = readCampaignBattleResume(slotOneCampaign, 1, storage);
@@ -150,11 +272,21 @@ try {
campaignStep: 'second-battle'
})));
browserStorage.setItem(slotOneKey, JSON.stringify(createBattleSave()));
startNewCampaign();
const priorSlotOneCampaign =
createInitialCampaignState();
priorSlotOneCampaign.step = 'first-battle';
priorSlotOneCampaign.activeSaveSlot = 1;
priorSlotOneCampaign.battleResumeGeneration = 7;
setCampaignState(priorSlotOneCampaign);
const newCampaign = startNewCampaign();
assert(
browserStorage.getItem(slotOneKey) === null && browserStorage.getItem(secondBattleKey) !== null,
'Starting a new campaign must clear slot 1 battle data without deleting resumable saves from preserved campaign slots.'
);
assert(
newCampaign.battleResumeGeneration === 8,
`A replacement campaign must advance beyond the overwritten slot generation before best-effort battle cleanup: ${newCampaign.battleResumeGeneration}`
);
assert(
clearCampaignBattleSavesForSlot(3, browserStorage) === 1 && browserStorage.getItem(secondBattleKey) === null,
'Slot-scoped cleanup must remove only battle saves belonging to the requested campaign slot.'
@@ -220,6 +352,18 @@ try {
/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.'
);
assert(
/battleResumeGeneration:\s*campaign\.battleResumeGeneration/.test(
battleSceneSource
),
'Every new battle snapshot must carry its campaign battle-resume generation.'
);
assert(
/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.'
);
assert(
/stats: \{ \.\.\.unit\.stats \}/.test(battleSceneSource) &&
/unit\.stats = \{ \.\.\.savedUnit\.stats \}/.test(battleSceneSource) &&
@@ -227,12 +371,12 @@ try {
'Battle saves must persist new attributes and reconstruct battle-earned attributes for legacy saves.'
);
console.log('Verified direct battle-save resume routing, BattleScene restore hooks, stale cleanup, fallback order, generation cleanup, and non-battle isolation.');
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.');
} finally {
await server.close();
}
function createStorage() {
function createStorage(options = {}) {
const values = new Map();
return {
getItem(key) {
@@ -242,7 +386,9 @@ function createStorage() {
values.set(key, String(value));
},
removeItem(key) {
values.delete(key);
if (!options.refuseRemoval) {
values.delete(key);
}
},
clear() {
values.clear();