187 lines
9.0 KiB
JavaScript
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);
|
|
}
|
|
}
|