294 lines
13 KiB
JavaScript
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);
|
|
}
|
|
}
|