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); } }