Files
heros_web/scripts/verify-battle-save-resume-routing.mjs

294 lines
13 KiB
JavaScript

import { readFile } from 'node:fs/promises';
import { createServer } from 'vite';
const browserStorage = createStorage();
globalThis.window = { localStorage: browserStorage };
const server = await createServer({
logLevel: 'error',
server: { middlewareMode: true, hmr: false },
appType: 'custom'
});
try {
const {
battleSaveStorageBaseKey,
battleSaveStorageSlotKey,
campaignBattleResumeStorageKeys,
clearAllCampaignBattleSaves,
clearCampaignBattleSavesForSlot,
clearCampaignBattleResume,
legacyFirstBattleSaveStorageKey,
readBattleSaveStorageCandidate,
readCampaignBattleResume
} = await server.ssrLoadModule('/src/game/state/battleSaveStorage.ts');
const { resetCampaignState, startNewCampaign } = await server.ssrLoadModule('/src/game/state/campaignState.ts');
const firstBattleId = 'first-battle-zhuo-commandery';
const campaign = { step: 'first-battle', activeSaveSlot: 2 };
const storage = createStorage();
const slotKey = battleSaveStorageSlotKey(firstBattleId, 2);
storage.setItem(slotKey, JSON.stringify(createBattleSave({ turnNumber: 4, activeFaction: 'enemy' })));
const directResume = readCampaignBattleResume(campaign, 2, storage);
assert(
directResume?.slot === 2 &&
directResume.battleId === firstBattleId &&
directResume.turnNumber === 4 &&
directResume.activeFaction === 'enemy' &&
directResume.storageKey === slotKey,
`Expected a matching slotted battle save to become a direct-resume candidate: ${JSON.stringify(directResume)}`
);
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.');
storage.setItem(slotKey, JSON.stringify(createBattleSave({ campaignStep: 'second-battle' })));
assert(readCampaignBattleResume(campaign, 2, storage) === undefined, 'A stale campaign step must not resume another battle flow.');
assert(storage.getItem(slotKey) === null, 'A stale campaign-step save must be cleaned safely.');
const slotOneCampaign = { step: 'first-battle', activeSaveSlot: 1 };
const slotOneKey = battleSaveStorageSlotKey(firstBattleId, 1);
const baseKey = battleSaveStorageBaseKey(firstBattleId);
storage.setItem(slotOneKey, 'corrupted-slot-copy');
storage.setItem(baseKey, JSON.stringify(createBattleSave({ turnNumber: 3 })));
const fallbackResume = readCampaignBattleResume(slotOneCampaign, 1, storage);
assert(
fallbackResume?.storageKey === baseKey && fallbackResume.turnNumber === 3 && storage.getItem(slotOneKey) === null,
`A corrupt primary key must be removed before falling back to the compatible base key: ${JSON.stringify(fallbackResume)}`
);
storage.clear();
storage.setItem(slotOneKey, JSON.stringify(createBattleSave({
units: [{ ...createBattleSave().units[0], id: 'unknown-unit' }]
})));
storage.setItem(baseKey, JSON.stringify(createBattleSave({ turnNumber: 5 })));
const fullyValidatedFallback = readBattleSaveStorageCandidate(
firstBattleId,
'first-battle',
1,
storage,
3,
{
mapWidth: 20,
mapHeight: 20,
validUnitIds: new Set(['liu-bei']),
validAllyUnitIds: new Set(['liu-bei']),
validEnemyUnitIds: new Set()
}
);
assert(
fullyValidatedFallback?.storageKey === baseKey &&
fullyValidatedFallback.state.turnNumber === 5 &&
storage.getItem(slotOneKey) === null &&
storage.getItem(baseKey) !== null,
`Full validation must delete only an invalid primary candidate before preserving and loading the valid base fallback: ${JSON.stringify(fullyValidatedFallback)}`
);
storage.clear();
const alternateCompositionSave = createBattleSave({
units: [{ ...createBattleSave().units[0], id: 'guan-yu' }]
});
storage.setItem(slotKey, JSON.stringify(alternateCompositionSave));
const looselyValidatedAlternateComposition = readBattleSaveStorageCandidate(
firstBattleId,
'first-battle',
2,
storage,
3,
{ mapWidth: 20, mapHeight: 20 }
);
assert(
looselyValidatedAlternateComposition?.state.units[0]?.id === 'guan-yu' && storage.getItem(slotKey) !== null,
'Inspecting another slot must preserve a structurally valid save until its campaign composition is loaded.'
);
storage.clear();
storage.setItem(legacyFirstBattleSaveStorageKey, JSON.stringify(createBattleSave({ turnNumber: 2 })));
const legacyResume = readCampaignBattleResume(slotOneCampaign, 1, storage);
assert(
legacyResume?.storageKey === legacyFirstBattleSaveStorageKey && legacyResume.turnNumber === 2,
`The first-battle legacy key must remain resumable for slot 1: ${JSON.stringify(legacyResume)}`
);
const keys = campaignBattleResumeStorageKeys(firstBattleId, 1);
assert(
JSON.stringify(keys) === JSON.stringify([slotOneKey, baseKey, legacyFirstBattleSaveStorageKey]),
`Resume keys must retain slotted, base, then legacy fallback order: ${JSON.stringify(keys)}`
);
assert(clearCampaignBattleResume(slotOneCampaign, 1, storage) === true, 'Explicit resume cleanup must report removed data.');
assert(storage.getItem(legacyFirstBattleSaveStorageKey) === null, 'Explicit cleanup must remove the legacy candidate.');
storage.setItem(slotOneKey, JSON.stringify(createBattleSave()));
const campProgress = { step: 'first-camp', activeSaveSlot: 1 };
assert(readCampaignBattleResume(campProgress, 1, storage) === undefined, 'Camp progress must never consume an old battle save.');
assert(storage.getItem(slotOneKey) !== null, 'Unrelated camp routing must leave battle data untouched.');
const secondBattleKey = battleSaveStorageSlotKey('second-battle-yellow-turban-pursuit', 3);
const unrelatedKey = 'heros-web:unrelated-setting';
storage.setItem(baseKey, JSON.stringify(createBattleSave()));
storage.setItem(secondBattleKey, JSON.stringify(createBattleSave({
battleId: 'second-battle-yellow-turban-pursuit',
campaignStep: 'second-battle'
})));
storage.setItem(unrelatedKey, 'keep');
const clearedCount = clearAllCampaignBattleSaves(storage);
assert(
clearedCount >= 3 &&
storage.getItem(slotOneKey) === null &&
storage.getItem(baseKey) === null &&
storage.getItem(secondBattleKey) === null &&
storage.getItem(unrelatedKey) === 'keep',
`Full cleanup must remove every campaign battle key without touching unrelated settings: ${clearedCount}`
);
browserStorage.setItem(slotOneKey, JSON.stringify(createBattleSave()));
resetCampaignState();
assert(browserStorage.getItem(slotOneKey) === null, 'Campaign reset must clear prior-generation battle saves.');
browserStorage.setItem(secondBattleKey, JSON.stringify(createBattleSave({
battleId: 'second-battle-yellow-turban-pursuit',
campaignStep: 'second-battle'
})));
browserStorage.setItem(slotOneKey, JSON.stringify(createBattleSave()));
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(
clearCampaignBattleSavesForSlot(3, browserStorage) === 1 && browserStorage.getItem(secondBattleKey) === null,
'Slot-scoped cleanup must remove only battle saves belonging to the requested campaign slot.'
);
storage.clear();
const slotTwoFirstBattleKey = battleSaveStorageSlotKey(firstBattleId, 2);
const slotTwoSecondBattleKey = battleSaveStorageSlotKey('second-battle-yellow-turban-pursuit', 2);
storage.setItem(slotTwoFirstBattleKey, JSON.stringify(createBattleSave({ turnNumber: 8 })));
storage.setItem(slotTwoSecondBattleKey, JSON.stringify(createBattleSave({
battleId: 'second-battle-yellow-turban-pursuit',
campaignStep: 'second-battle',
turnNumber: 9
})));
clearCampaignBattleSavesForSlot(2, storage);
storage.setItem(slotTwoFirstBattleKey, JSON.stringify(createBattleSave({ turnNumber: 2 })));
assert(
readCampaignBattleResume({ step: 'first-battle', activeSaveSlot: 2 }, 2, storage)?.turnNumber === 2 &&
readCampaignBattleResume({ step: 'second-battle', activeSaveSlot: 2 }, 2, storage) === undefined &&
storage.getItem(slotTwoSecondBattleKey) === null,
'Replacing a battle slot must preserve only the newly written current battle and remove stale later-battle resumes.'
);
storage.clear();
storage.setItem(slotOneKey, JSON.stringify(createBattleSave({ turnNumber: 7 })));
storage.setItem(baseKey, JSON.stringify(createBattleSave({ turnNumber: 7 })));
storage.setItem(legacyFirstBattleSaveStorageKey, JSON.stringify(createBattleSave({ turnNumber: 7 })));
clearCampaignBattleSavesForSlot(1, storage);
storage.setItem(slotOneKey, JSON.stringify(createBattleSave({ turnNumber: 2 })));
storage.setItem(baseKey, JSON.stringify(createBattleSave({ turnNumber: 2 })));
assert(
storage.getItem(legacyFirstBattleSaveStorageKey) === null &&
readCampaignBattleResume(slotOneCampaign, 1, storage)?.turnNumber === 2,
'Replacing slot 1 must remove its legacy generation before writing matching slot and base compatibility copies.'
);
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');
assert(
/resumeBattleSaveSlot\?: number/.test(battleSceneSource) &&
/this\.time\.delayedCall\(0, \(\) => this\.loadBattleState\(resumeSlot\)\)/.test(battleSceneSource),
'BattleScene must consume the title resume slot after the map and unit views are ready.'
);
assert(
/clearBattleSaveStorageForBattle\(battleScenario\.id, getCampaignState\(\)\.activeSaveSlot\)/.test(battleSceneSource),
'A victory must clear its resumable battle save from the active campaign slot.'
);
assert(
/this\.activeFaction === 'enemy'[\s\S]*?void this\.runEnemyTurn\(\)/.test(battleSceneSource),
'Resuming an enemy-turn save must restart enemy actions instead of leaving the battle stalled.'
);
assert(
/restartBattleFromSave\(slot: number\)[\s\S]*?loadCampaignState\(normalizedSlot\)[\s\S]*?this\.scene\.restart\([\s\S]*?resumeBattleSaveSlot: normalizedSlot/.test(battleSceneSource) &&
/readBattleSaveState\(slot: number, validateCurrentComposition = false\)/.test(battleSceneSource) &&
/readBattleSaveState\(slot, true\)/.test(battleSceneSource),
'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.'
);
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.'
);
assert(
/stats: \{ \.\.\.unit\.stats \}/.test(battleSceneSource) &&
/unit\.stats = \{ \.\.\.savedUnit\.stats \}/.test(battleSceneSource) &&
/progressUnitStatsForLevelUps\(unit\.stats, unit\.classKey, savedLevelUps\)/.test(battleSceneSource),
'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.');
} finally {
await server.close();
}
function createStorage() {
const values = new Map();
return {
getItem(key) {
return values.has(key) ? values.get(key) : null;
},
setItem(key, value) {
values.set(key, String(value));
},
removeItem(key) {
values.delete(key);
},
clear() {
values.clear();
}
};
}
function createBattleSave(patch = {}) {
return {
version: 1,
battleId: 'first-battle-zhuo-commandery',
campaignStep: 'first-battle',
savedAt: '2026-07-19T03:00:00.000Z',
turnNumber: 2,
activeFaction: 'ally',
rosterTab: 'ally',
actedUnitIds: [],
attackIntents: [],
battleLog: ['전투 저장'],
units: [
{
id: 'liu-bei',
level: 1,
exp: 0,
hp: 30,
maxHp: 30,
attack: 10,
move: 4,
x: 1,
y: 1,
direction: 'south',
equipment: {
weapon: { itemId: 'training-sword', level: 1, exp: 0 },
armor: { itemId: 'cloth-armor', level: 1, exp: 0 },
accessory: { itemId: 'grain-pouch', level: 1, exp: 0 }
}
}
],
bonds: [],
...patch
};
}
function assert(condition, message) {
if (!condition) {
throw new Error(message);
}
}