Files
heros_web/scripts/verify-campaign-completion.mjs

187 lines
9.0 KiB
JavaScript

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,
getCampaignState,
loadCampaignState,
resetCampaignState,
setFirstBattleReport
} = await server.ssrLoadModule('/src/game/state/campaignState.ts');
const { battleScenarios } = await server.ssrLoadModule('/src/game/data/battles.ts');
const { campaignRecruitUnits } = await server.ssrLoadModule('/src/game/data/scenario.ts');
const { campaignBattleRouteEntries } = await server.ssrLoadModule('/src/game/state/campaignRouting.ts');
const scenarios = Object.values(battleScenarios);
const routeEntries = campaignBattleRouteEntries();
assert(scenarios.length === 66, `Expected 66 campaign battles, received ${scenarios.length}.`);
assert(routeEntries.length === 66, `Expected 66 campaign battle routes, received ${routeEntries.length}.`);
assert(
JSON.stringify(routeEntries.map((entry) => entry.battleId)) === JSON.stringify(scenarios.map((scenario) => scenario.id)),
'Battle scenario order differs from the independent campaign routing table.'
);
assert(scenarios[0]?.id === 'first-battle-zhuo-commandery', `Unexpected first battle: ${scenarios[0]?.id}`);
assert(scenarios.at(-1)?.id === 'sixty-sixth-battle-wuzhang-final', `Unexpected final battle: ${scenarios.at(-1)?.id}`);
const recruitById = new Map(campaignRecruitUnits.map((unit) => [unit.id, unit]));
const expectedRecruitIds = new Set();
const checkpointBattles = new Set([1, 11, 22, 33, 44, 55, 66]);
let expectedGold = 0;
resetCampaignState();
for (const [index, scenario] of scenarios.entries()) {
const campaignReward = createCampaignRewardSnapshot(scenario, recruitById, battleScenarios);
campaignReward.recruits.forEach((recruit) => expectedRecruitIds.add(recruit.unitId));
const objectives = scenario.objectives.map((objective) => ({
id: objective.id,
label: objective.label,
achieved: true,
status: 'done',
category: objective.kind === 'defeat-leader' || objective.kind === 'pacify-leader' ? 'primary' : 'bonus',
detail: objective.label,
rewardGold: objective.rewardGold,
...(objective.targetTile ? { targetTile: { ...objective.targetTile } } : {})
}));
const rewardGold = scenario.baseVictoryGold + objectives.reduce((sum, objective) => sum + objective.rewardGold, 0);
expectedGold += rewardGold;
setFirstBattleReport({
battleId: scenario.id,
battleTitle: scenario.title,
outcome: 'victory',
turnNumber: Math.max(1, Math.min(scenario.quickVictoryTurnLimit, 8 + (index % 5))),
rewardGold,
defeatedEnemies: scenario.units.filter((unit) => unit.faction === 'enemy').length,
totalEnemies: scenario.units.filter((unit) => unit.faction === 'enemy').length,
objectives,
units: structuredClone(scenario.units),
bonds: structuredClone(scenario.bonds),
itemRewards: [...scenario.itemRewards],
campaignRewards: campaignReward,
completedCampDialogues: [],
completedCampVisits: [],
createdAt: new Date(Date.UTC(2026, 0, 1, 0, 0, index)).toISOString()
});
let state = getCampaignState();
const battleNumber = index + 1;
assert(state.latestBattleId === scenario.id, `Battle ${battleNumber} did not become the latest settlement.`);
assert(state.battleHistory[scenario.id]?.outcome === 'victory', `Battle ${battleNumber} victory was not recorded.`);
assert(Object.keys(state.battleHistory).length === battleNumber, `Battle history stopped at ${Object.keys(state.battleHistory).length}/${battleNumber}.`);
assert(state.step === expectedCampStep(scenario.id), `Battle ${battleNumber} routed to ${state.step}, expected ${expectedCampStep(scenario.id)}.`);
assert(state.roster.every((unit, unitIndex, roster) => roster.findIndex((candidate) => candidate.id === unit.id) === unitIndex), `Battle ${battleNumber} produced duplicate roster entries.`);
assert(Object.values(state.inventory).every((amount) => Number.isInteger(amount) && amount > 0), `Battle ${battleNumber} produced invalid inventory amounts.`);
state = checkpointBattles.has(battleNumber)
? roundTripLegacyCompatibleSave(campaignStorageKey, state, loadCampaignState)
: loadCampaignState(1);
const expectedHistoryIds = scenarios.slice(0, battleNumber).map((candidate) => candidate.id);
assert(
JSON.stringify(Object.keys(state.battleHistory)) === JSON.stringify(expectedHistoryIds),
`Save reload changed campaign order at battle ${battleNumber}.`
);
assert(state.latestBattleId === scenario.id, `Save reload lost the latest battle at ${battleNumber}.`);
assert(state.step === expectedCampStep(scenario.id), `Save reload changed the route at battle ${battleNumber}.`);
}
const finalState = getCampaignState();
const scenarioIds = new Set(scenarios.map((scenario) => scenario.id));
assert(finalState.step === 'sixty-sixth-camp', `Expected the final victory to reach the final camp, received ${finalState.step}.`);
assert(finalState.latestBattleId === 'sixty-sixth-battle-wuzhang-final', `Unexpected final latest battle: ${finalState.latestBattleId}`);
assert(Object.keys(finalState.battleHistory).length === 66, `Expected 66 settlements, received ${Object.keys(finalState.battleHistory).length}.`);
assert(Object.keys(finalState.battleHistory).every((battleId) => scenarioIds.has(battleId)), 'Final history contains an unknown battle id.');
assert(Object.values(finalState.battleHistory).every((settlement) => settlement.outcome === 'victory'), 'Final history contains a non-victory settlement.');
const expectedStoredGold = Math.min(999999, expectedGold);
assert(finalState.gold === expectedStoredGold, `Expected capped gold ${expectedStoredGold} from ${expectedGold}, received ${finalState.gold}.`);
assert([...expectedRecruitIds].every((unitId) => finalState.roster.some((unit) => unit.id === unitId)), 'Final roster is missing a campaign recruit.');
assert(finalState.firstBattleReport?.battleId === finalState.latestBattleId, 'Final report and latest settlement are out of sync.');
const finalSlotRaw = storage.get(`${campaignStorageKey}:slot-1`);
assert(finalSlotRaw === storage.get(campaignStorageKey), 'Base save and active slot diverged after campaign completion.');
assert(JSON.parse(finalSlotRaw).battleHistory['sixty-sixth-battle-wuzhang-final'], 'Serialized final save is missing the last settlement.');
console.log(
`Verified all ${scenarios.length} campaign settlements in route order, every-battle save reloads, seven legacy migrations, ${expectedRecruitIds.size} recruit unlocks, reward accumulation, and the final-camp state.`
);
} finally {
await server.close();
}
function createCampaignRewardSnapshot(scenario, recruitById, battleScenarios) {
const reward = scenario.campaignReward;
return {
supplies: [...(reward?.supplies ?? [])],
equipment: [...(reward?.equipment ?? [])],
reputation: [...(reward?.reputation ?? [])],
recruits: (reward?.recruits ?? []).map((unitId) => {
const unit = scenario.units.find((candidate) => candidate.id === unitId) ?? recruitById.get(unitId);
assert(unit, `${scenario.id} references unknown recruit ${unitId}.`);
return { unitId, name: unit.name };
}),
unlocks: reward?.unlockBattleId
? [{
battleId: reward.unlockBattleId,
title: reward.unlockLabel ?? battleScenarios[reward.unlockBattleId]?.title ?? reward.unlockBattleId
}]
: [],
...(reward?.note ? { note: reward.note } : {})
};
}
function roundTripLegacyCompatibleSave(campaignStorageKey, state, loadCampaignState) {
const legacySnapshot = structuredClone(state);
delete legacySnapshot.sortieFormationAssignments;
delete legacySnapshot.sortieItemAssignments;
delete legacySnapshot.sortieFormationPresets;
delete legacySnapshot.sortieOrderSelection;
delete legacySnapshot.sortieResonanceSelection;
delete legacySnapshot.sortieRecommendationSelection;
delete legacySnapshot.sortieOrderHistory;
delete legacySnapshot.claimedSortieOrderRewardIds;
delete legacySnapshot.reserveTrainingAssignments;
delete legacySnapshot.completedTutorialIds;
delete legacySnapshot.acknowledgedVictoryRewardBattleIds;
delete legacySnapshot.acknowledgedVictoryRewardCategories;
const serialized = JSON.stringify(legacySnapshot);
storage.set(campaignStorageKey, serialized);
storage.set(`${campaignStorageKey}:slot-1`, serialized);
return loadCampaignState(1);
}
function expectedCampStep(battleId) {
const prefix = battleId.split('-battle-')[0];
return `${prefix}-camp`;
}
function assert(condition, message) {
if (!condition) {
throw new Error(message);
}
}