diff --git a/package.json b/package.json index 462f2d9..369aa85 100644 --- a/package.json +++ b/package.json @@ -13,6 +13,7 @@ "measure:performance": "node scripts/measure-performance.mjs", "verify:battle-data": "node scripts/verify-battle-scenario-data.mjs", "verify:campaign-data": "node scripts/verify-campaign-flow-data.mjs", + "verify:campaign-save": "node scripts/verify-campaign-save-normalization.mjs", "verify:camp-rewards": "node scripts/verify-camp-reward-data.mjs", "verify:campaign-recruits": "node scripts/verify-campaign-recruit-data.mjs", "verify:equipment-catalog": "node scripts/verify-equipment-catalog-data.mjs", diff --git a/scripts/verify-campaign-save-normalization.mjs b/scripts/verify-campaign-save-normalization.mjs new file mode 100644 index 0000000..14107f9 --- /dev/null +++ b/scripts/verify-campaign-save-normalization.mjs @@ -0,0 +1,91 @@ +import { createServer } from 'vite'; + +const storage = new Map(); +globalThis.window = { + localStorage: { + getItem(key) { + return storage.has(key) ? storage.get(key) : null; + }, + setItem(key, value) { + storage.set(key, String(value)); + }, + removeItem(key) { + storage.delete(key); + }, + clear() { + storage.clear(); + } + } +}; + +const server = await createServer({ + logLevel: 'error', + server: { middlewareMode: true }, + appType: 'custom' +}); + +try { + const { campaignStorageKey, loadCampaignState } = await server.ssrLoadModule('/src/game/state/campaignState.ts'); + + storage.clear(); + const empty = loadCampaignState(); + assert(empty.version === 1 && empty.step === 'new', `Expected empty storage to create initial campaign state: ${JSON.stringify(empty)}`); + + storage.clear(); + storage.set( + campaignStorageKey, + JSON.stringify({ + updatedAt: '2026-07-01T00:00:00.000Z', + step: 'first-camp', + activeSaveSlot: 1, + gold: 180, + inventory: { bean: 2 }, + completedCampDialogues: ['bond-a', 'bond-a'], + completedCampVisits: ['visit-a', 'visit-a'] + }) + ); + const legacy = loadCampaignState(); + assert(legacy.version === 1, `Expected legacy save without version to normalize to v1: ${JSON.stringify(legacy)}`); + assert(legacy.step === 'first-camp' && legacy.gold === 180, `Expected legacy save progress to survive: ${JSON.stringify(legacy)}`); + assert(Array.isArray(legacy.roster) && Array.isArray(legacy.bonds), `Expected legacy save arrays to be restored: ${JSON.stringify(legacy)}`); + assert( + legacy.completedCampDialogues.length === 1 && legacy.completedCampVisits.length === 1, + `Expected legacy save duplicate completion flags to be deduped: ${JSON.stringify(legacy)}` + ); + assert( + Array.isArray(legacy.selectedSortieUnitIds) && + typeof legacy.sortieFormationAssignments === 'object' && + typeof legacy.sortieItemAssignments === 'object', + `Expected modern sortie fields to be restored: ${JSON.stringify(legacy)}` + ); + + storage.clear(); + storage.set(campaignStorageKey, JSON.stringify({ version: 99, step: 'sixty-sixth-camp' })); + const unsupported = loadCampaignState(); + assert(unsupported.step === 'new', `Expected unsupported future save version to be ignored: ${JSON.stringify(unsupported)}`); + + storage.clear(); + storage.set(campaignStorageKey, JSON.stringify({ version: 99, step: 'sixty-sixth-camp' })); + storage.set( + `${campaignStorageKey}:slot-2`, + JSON.stringify({ + version: 1, + updatedAt: '2026-07-02T00:00:00.000Z', + step: 'second-camp', + activeSaveSlot: 2, + gold: 260 + }) + ); + const fallback = loadCampaignState(); + assert(fallback.step === 'second-camp' && fallback.activeSaveSlot === 2, `Expected valid slotted save fallback: ${JSON.stringify(fallback)}`); + + console.log('Verified campaign save normalization, legacy recovery, unsupported-version rejection, and slotted fallback.'); +} finally { + await server.close(); +} + +function assert(condition, message) { + if (!condition) { + throw new Error(message); + } +} diff --git a/scripts/verify-save-retry-flow.mjs b/scripts/verify-save-retry-flow.mjs index 1713d3f..e27bb69 100644 --- a/scripts/verify-save-retry-flow.mjs +++ b/scripts/verify-save-retry-flow.mjs @@ -18,7 +18,7 @@ try { await waitForTitle(page); await page.screenshot({ path: 'dist/verification-save-title-empty.png', fullPage: true }); - await page.mouse.click(962, 240); + await startTitleDefaultAction(page); await advanceUntilBattle(page, 'first-battle-zhuo-commandery'); await page.screenshot({ path: 'dist/verification-save-new-battle.png', fullPage: true }); @@ -31,7 +31,7 @@ try { await waitForTitle(page); await page.screenshot({ path: 'dist/verification-save-title-continue.png', fullPage: true }); - await page.mouse.click(962, 310); + await startTitleDefaultAction(page); await waitForBattleReady(page, 'first-battle-zhuo-commandery'); const continuedBattle = await page.evaluate(() => window.__HEROS_DEBUG__?.battle()); if (continuedBattle?.battleOutcome !== null || continuedBattle?.resultVisible !== false || continuedBattle?.turnNumber !== 1) { @@ -72,10 +72,7 @@ try { await page.screenshot({ path: 'dist/verification-save-victory-result.png', fullPage: true }); await page.mouse.click(738, 642); - await page.waitForFunction(() => { - const activeScenes = window.__HEROS_DEBUG__?.activeScenes() ?? []; - return activeScenes.includes('CampScene'); - }, undefined, { timeout: 90000 }); + await advanceUntilCamp(page); await page.screenshot({ path: 'dist/verification-save-victory-camp.png', fullPage: true }); const campState = await page.evaluate(() => window.__HEROS_DEBUG__?.camp()); @@ -108,8 +105,14 @@ async function waitForTitle(page) { }, undefined, { timeout: 90000 }); } +async function startTitleDefaultAction(page) { + await waitForTitle(page); + await page.keyboard.press('Enter'); + await page.waitForTimeout(300); +} + async function advanceUntilBattle(page, battleId) { - for (let i = 0; i < 32; i += 1) { + for (let i = 0; i < 180; i += 1) { const battleReady = await page.evaluate((expectedBattleId) => { let state; try { @@ -121,7 +124,7 @@ async function advanceUntilBattle(page, battleId) { state?.scene === 'BattleScene' && state?.battleId === expectedBattleId && state?.battleOutcome === null && - state?.phase === 'idle' && + ['deployment', 'idle'].includes(state?.phase) && state?.mapBackgroundReady === true && state?.resultVisible === false ); @@ -131,11 +134,17 @@ async function advanceUntilBattle(page, battleId) { return; } - await page.keyboard.press('Space'); - await page.waitForTimeout(220); + const activeScenes = await page.evaluate(() => window.__HEROS_DEBUG__?.activeScenes() ?? []); + await page.keyboard.press(activeScenes.includes('TitleScene') ? 'Enter' : 'Space'); + await page.waitForTimeout(240); } - await waitForBattleReady(page, battleId); + const state = await page.evaluate(() => ({ + activeScenes: window.__HEROS_DEBUG__?.activeScenes() ?? [], + battle: window.__HEROS_DEBUG__?.battle(), + camp: window.__HEROS_DEBUG__?.camp() + })); + throw new Error(`Expected to advance into ${battleId}: ${JSON.stringify(state)}`); } async function waitForBattleReady(page, battleId) { @@ -150,7 +159,7 @@ async function waitForBattleReady(page, battleId) { state?.scene === 'BattleScene' && state?.battleId === expectedBattleId && state?.battleOutcome === null && - state?.phase === 'idle' && + ['deployment', 'idle'].includes(state?.phase) && state?.mapBackgroundReady === true && state?.resultVisible === false ); @@ -169,6 +178,34 @@ async function waitForBattleOutcome(page, outcome) { }, outcome, { timeout: 90000 }); } +async function advanceUntilCamp(page) { + for (let i = 0; i < 240; i += 1) { + const state = await page.evaluate(() => ({ + activeScenes: window.__HEROS_DEBUG__?.activeScenes() ?? [], + battle: window.__HEROS_DEBUG__?.battle(), + camp: window.__HEROS_DEBUG__?.camp() + })); + + if (state.activeScenes.includes('CampScene')) { + return; + } + + if (state.activeScenes.includes('BattleScene') && state.battle?.battleOutcome === 'victory' && state.battle?.resultVisible) { + await page.mouse.click(738, 642); + } else { + await page.keyboard.press('Space'); + } + await page.waitForTimeout(250); + } + + const state = await page.evaluate(() => ({ + activeScenes: window.__HEROS_DEBUG__?.activeScenes() ?? [], + battle: window.__HEROS_DEBUG__?.battle(), + camp: window.__HEROS_DEBUG__?.camp() + })); + throw new Error(`Expected victory flow to reach CampScene: ${JSON.stringify(state)}`); +} + async function readCampaignSave(page) { return page.evaluate(() => { const read = (key) => { diff --git a/scripts/verify-static-data.mjs b/scripts/verify-static-data.mjs index 65e88ae..54ccfce 100644 --- a/scripts/verify-static-data.mjs +++ b/scripts/verify-static-data.mjs @@ -2,6 +2,7 @@ import { spawnSync } from 'node:child_process'; const checks = [ 'scripts/verify-campaign-flow-data.mjs', + 'scripts/verify-campaign-save-normalization.mjs', 'scripts/verify-battle-scenario-data.mjs', 'scripts/verify-camp-reward-data.mjs', 'scripts/verify-campaign-recruit-data.mjs', diff --git a/src/game/state/campaignState.ts b/src/game/state/campaignState.ts index 26fa53b..6db06d8 100644 --- a/src/game/state/campaignState.ts +++ b/src/game/state/campaignState.ts @@ -780,8 +780,11 @@ function ensureCampaignState() { return campaignState; } -function normalizeCampaignState(state: CampaignState): CampaignState { - const normalized = cloneCampaignState(state); +function normalizeCampaignState(state: Partial): CampaignState { + const normalized = { + ...createInitialCampaignState(), + ...cloneCampaignState(state as CampaignState) + }; normalized.version = 1; normalized.updatedAt = normalized.updatedAt || new Date().toISOString(); normalized.activeSaveSlot = normalizeSlot(normalized.activeSaveSlot); @@ -837,8 +840,11 @@ function readStoredCampaignState(slot?: number) { } try { - const parsed = JSON.parse(raw) as CampaignState; - if (!parsed || parsed.version !== 1) { + const parsed = JSON.parse(raw) as Partial | null; + if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) { + return undefined; + } + if (parsed.version !== undefined && parsed.version !== 1) { return undefined; } return normalizeCampaignState(parsed);