Files
heros_web/scripts/verify-campaign-flow-data.mjs

260 lines
10 KiB
JavaScript

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 sortieFlowBodies = extractSortieFlowBodies(campaignFlowSource, battleScenarioIds);
const sortieFlowCampaignSteps = extractSortieFlowCampaignSteps(campaignFlowSource);
const sortieFlowNextBattleIds = extractSortieFlowNextBattleIds(campaignFlowSource, battleScenarioIds);
const campaignRewardUnlockBattleIds = extractCampaignRewardUnlockBattleIds(battlesSource, 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 flowKey of sortieFlowKeys) {
const nextBattleId = sortieFlowNextBattleIds.get(flowKey);
const campaignStep = sortieFlowCampaignSteps.get(flowKey);
if (!nextBattleId) {
continue;
}
assert(campaignStep, `Sortie flow ${flowKey} has next battle ${nextBattleId} but no campaign step`);
if (campaignStep) {
assert(
routedBattleSteps.get(campaignStep) === nextBattleId,
`Sortie flow ${flowKey} marks campaign step ${campaignStep} but routes to ${routedBattleSteps.get(campaignStep) ?? 'no battle'} instead of ${nextBattleId}`
);
}
}
for (const flowKey of sortieFlowKeys) {
const nextBattleId = sortieFlowNextBattleIds.get(flowKey);
const campaignStep = sortieFlowCampaignSteps.get(flowKey);
const flowBody = sortieFlowBodies.get(flowKey) ?? '';
if (nextBattleId) {
continue;
}
assert(campaignStep, `Non-battle sortie flow ${flowKey} has no campaign step`);
if (campaignStep) {
assert(
!campaignStep.endsWith('-battle'),
`Non-battle sortie flow ${flowKey} points to battle campaign step ${campaignStep} without nextBattleId`
);
}
assert(/\bpages\s*:/.test(flowBody), `Non-battle sortie flow ${flowKey} must provide story pages`);
assert(/\bunavailableNotice\s*:/.test(flowBody), `Non-battle sortie flow ${flowKey} must provide an unavailableNotice for repeat clicks`);
}
for (const [battleId, unlockBattleId] of campaignRewardUnlockBattleIds.entries()) {
const nextBattleId = sortieFlowNextBattleIds.get(battleId);
assert(battleIds.has(unlockBattleId), `Battle ${battleId} campaign reward unlocks unknown battle id: ${unlockBattleId}`);
assert(nextBattleId, `Battle ${battleId} campaign reward unlocks ${unlockBattleId} but has no sortie flow next battle`);
assert(
nextBattleId === unlockBattleId,
`Battle ${battleId} campaign reward unlock ${unlockBattleId} does not match sortie flow next battle ${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 extractSortieFlowBodies(source, scenarioIds) {
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*:)|^\s*\})/gm;
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];
if (flowKey) {
entries.set(flowKey, match[3]);
}
}
return entries;
}
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 extractCampaignRewardUnlockBattleIds(source, scenarioIds) {
const entries = new Map();
const declarationPattern = /export const (\w+BattleScenario)\b[\s\S]*?^\};/gm;
for (const match of source.matchAll(declarationPattern)) {
const battleId = scenarioIds.get(match[1]);
const unlockMatch = /unlockBattleId:\s*'([^']+)'/.exec(match[0]);
if (battleId && unlockMatch) {
entries.set(battleId, unlockMatch[1]);
}
}
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*:)|^\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}.`);
}