feat: complete campaign flow and optimize battle assets
This commit is contained in:
293
scripts/verify-battle-save-resume-routing.mjs
Normal file
293
scripts/verify-battle-save-resume-routing.mjs
Normal file
@@ -0,0 +1,293 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user