Recover legacy campaign saves
This commit is contained in:
@@ -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",
|
||||
|
||||
91
scripts/verify-campaign-save-normalization.mjs
Normal file
91
scripts/verify-campaign-save-normalization.mjs
Normal file
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -780,8 +780,11 @@ function ensureCampaignState() {
|
||||
return campaignState;
|
||||
}
|
||||
|
||||
function normalizeCampaignState(state: CampaignState): CampaignState {
|
||||
const normalized = cloneCampaignState(state);
|
||||
function normalizeCampaignState(state: Partial<CampaignState>): 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<CampaignState> | null;
|
||||
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
||||
return undefined;
|
||||
}
|
||||
if (parsed.version !== undefined && parsed.version !== 1) {
|
||||
return undefined;
|
||||
}
|
||||
return normalizeCampaignState(parsed);
|
||||
|
||||
Reference in New Issue
Block a user