diff --git a/package.json b/package.json index 3d92ca1..8f8b272 100644 --- a/package.json +++ b/package.json @@ -11,6 +11,7 @@ "generate:sfx": "node scripts/generate-sfx.mjs", "preview": "vite preview --host 0.0.0.0", "measure:performance": "node scripts/measure-performance.mjs", + "verify:campaign-data": "node scripts/verify-campaign-flow-data.mjs", "verify:flow": "node scripts/verify-flow.mjs", "verify:save-flow": "node scripts/verify-save-retry-flow.mjs", "verify:release": "node scripts/verify-release-candidate.mjs", diff --git a/scripts/verify-campaign-flow-data.mjs b/scripts/verify-campaign-flow-data.mjs new file mode 100644 index 0000000..e451cc4 --- /dev/null +++ b/scripts/verify-campaign-flow-data.mjs @@ -0,0 +1,184 @@ +import { readFileSync } from 'node:fs'; + +const campaignStateSource = readFileSync('src/game/state/campaignState.ts', 'utf8'); +const campaignRoutingSource = readFileSync('src/game/state/campaignRouting.ts', 'utf8'); +const campaignFlowSource = readFileSync('src/game/data/campaignFlow.ts', 'utf8'); +const battlesSource = readFileSync('src/game/data/battles.ts', 'utf8'); + +const battleScenarioIds = extractBattleScenarioIds(battlesSource); +const battleIds = new Set(battleScenarioIds.values()); +const campaignSteps = extractCampaignSteps(campaignStateSource); +const routedBattleSteps = extractObjectStringEntries(campaignRoutingSource, 'battleIdByCampaignStep'); +const battleStepRules = extractCampaignBattleSteps(campaignStateSource); +const sortieFlowKeys = extractSortieFlowKeys(campaignFlowSource, battleScenarioIds); +const sortieFlowCampaignSteps = extractSortieFlowCampaignSteps(campaignFlowSource); +const sortieFlowNextBattleIds = extractSortieFlowNextBattleIds(campaignFlowSource, battleScenarioIds); + +const failures = []; + +for (const [step, battleId] of routedBattleSteps.entries()) { + assert(campaignSteps.has(step), `Routing references unknown campaign step: ${step}`); + assert(battleIds.has(battleId), `Routing step ${step} references unknown battle id: ${battleId}`); +} + +for (const [battleId, rule] of battleStepRules.entries()) { + assert(battleIds.has(battleId), `Campaign battle step rule references unknown battle id: ${battleId}`); + assert(campaignSteps.has(rule.victory), `Battle ${battleId} has unknown victory step: ${rule.victory}`); + assert(campaignSteps.has(rule.retry), `Battle ${battleId} has unknown retry step: ${rule.retry}`); + assert(routedBattleSteps.get(rule.retry) === battleId, `Battle ${battleId} retry step ${rule.retry} does not route back to the same battle`); + assert( + sortieFlowKeys.has(rule.victory) || sortieFlowKeys.has(battleId), + `Battle ${battleId} victory step ${rule.victory} has no sortie flow or battle-id fallback` + ); +} + +for (const [flowKey, campaignStep] of sortieFlowCampaignSteps.entries()) { + assert(campaignSteps.has(campaignStep), `Sortie flow ${flowKey} references unknown campaign step: ${campaignStep}`); +} + +for (const [flowKey, nextBattleId] of sortieFlowNextBattleIds.entries()) { + assert(battleIds.has(nextBattleId), `Sortie flow ${flowKey} references unknown next battle id: ${nextBattleId}`); +} + +for (const step of campaignSteps) { + if (step.endsWith('-battle')) { + assert(routedBattleSteps.has(step), `Battle campaign step has no routing entry: ${step}`); + } +} + +if (failures.length > 0) { + throw new Error(`Campaign flow data verification failed:\n${failures.map((failure) => `- ${failure}`).join('\n')}`); +} + +console.log( + `Verified ${battleIds.size} battle ids, ${campaignSteps.size} campaign steps, ${battleStepRules.size} battle step rules, and ${sortieFlowKeys.size} sortie flows.` +); + +function assert(condition, message) { + if (!condition) { + failures.push(message); + } +} + +function extractBattleScenarioIds(source) { + const ids = new Map(); + const declarationPattern = /export const (\w+BattleScenario)\b[\s\S]*?^\};/gm; + for (const match of source.matchAll(declarationPattern)) { + const [, variableName] = match; + const idMatch = /id:\s*'([^']+)'/.exec(match[0]); + if (idMatch) { + ids.set(variableName, idMatch[1]); + } + } + + const scenarioIdAliases = new Map(); + for (const match of source.matchAll(/export const (\w+BattleScenarioId)\b[^=]*=\s*(\w+BattleScenario)\.id/g)) { + const scenarioId = ids.get(match[2]); + if (scenarioId) { + scenarioIdAliases.set(match[1], scenarioId); + } + } + for (const match of source.matchAll(/export const (\w+BattleScenario)\b[^=]*=\s*battleScenarios\[(\w+BattleScenarioId)\]/g)) { + const scenarioId = scenarioIdAliases.get(match[2]); + if (scenarioId) { + ids.set(match[1], scenarioId); + } + } + return ids; +} + +function extractCampaignSteps(source) { + const unionMatch = /export type CampaignStep\s*=([\s\S]*?);/.exec(source); + if (!unionMatch) { + throw new Error('Cannot find CampaignStep union.'); + } + return new Set([...unionMatch[1].matchAll(/'([^']+)'/g)].map((match) => match[1])); +} + +function extractObjectStringEntries(source, objectName) { + const objectSource = extractConstObject(source, objectName); + const entries = new Map(); + for (const match of objectSource.matchAll(/'([^']+)'\s*:\s*'([^']+)'/g)) { + entries.set(match[1], match[2]); + } + return entries; +} + +function extractCampaignBattleSteps(source) { + const objectSource = extractConstObject(source, 'campaignBattleSteps'); + const entries = new Map(); + const entryPattern = /'([^']+)'\s*:\s*\{\s*victory:\s*'([^']+)'\s*,\s*retry:\s*'([^']+)'\s*\}/g; + for (const match of objectSource.matchAll(entryPattern)) { + entries.set(match[1], { victory: match[2], retry: match[3] }); + } + return entries; +} + +function extractSortieFlowKeys(source, scenarioIds) { + const objectSource = extractConstObject(source, 'sortieFlows'); + const keys = new Set(); + for (const match of objectSource.matchAll(/^\s*'([^']+)'\s*:/gm)) { + keys.add(match[1]); + } + for (const match of objectSource.matchAll(/^\s*\[(\w+BattleScenario)\.id\]\s*:/gm)) { + const battleId = scenarioIds.get(match[1]); + if (battleId) { + keys.add(battleId); + } + } + return keys; +} + +function extractSortieFlowCampaignSteps(source) { + return extractSortieFlowProperty(source, /campaignStep:\s*'([^']+)'/); +} + +function extractSortieFlowNextBattleIds(source, scenarioIds) { + const entries = new Map(); + const propertyEntries = extractSortieFlowProperty(source, /nextBattleId:\s*(\w+BattleScenario)\.id/); + for (const [flowKey, variableName] of propertyEntries.entries()) { + entries.set(flowKey, scenarioIds.get(variableName) ?? variableName); + } + return entries; +} + +function extractSortieFlowProperty(source, propertyPattern) { + const objectSource = extractConstObject(source, 'sortieFlows'); + const entries = new Map(); + const flowPattern = /(?:^\s*'([^']+)'\s*:|^\s*\[(\w+BattleScenario)\.id\]\s*:)\s*\{([\s\S]*?)(?=^\s*(?:'[^']+'\s*:|\[\w+BattleScenario\.id\]\s*:)|^\};)/gm; + const scenarioIds = extractBattleScenarioIds(battlesSource); + for (const match of objectSource.matchAll(flowPattern)) { + const literalKey = match[1]; + const computedKey = match[2] ? scenarioIds.get(match[2]) : undefined; + const flowKey = literalKey ?? computedKey ?? match[2]; + const propertyMatch = propertyPattern.exec(match[3]); + if (flowKey && propertyMatch) { + entries.set(flowKey, propertyMatch[1]); + } + } + return entries; +} + +function extractConstObject(source, objectName) { + const startPattern = new RegExp(`const\\s+${objectName}\\b[^=]*=\\s*\\{`); + const startMatch = startPattern.exec(source); + if (!startMatch) { + throw new Error(`Cannot find object ${objectName}.`); + } + + const startIndex = startMatch.index + startMatch[0].length - 1; + let depth = 0; + for (let index = startIndex; index < source.length; index += 1) { + const char = source[index]; + if (char === '{') { + depth += 1; + } else if (char === '}') { + depth -= 1; + if (depth === 0) { + return source.slice(startIndex, index + 1); + } + } + } + + throw new Error(`Cannot read object ${objectName}.`); +}