fix: resume campaign progress safely
This commit is contained in:
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user