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 battleSceneSource = readFileSync('src/game/scenes/BattleScene.ts', 'utf8'); const campSceneSource = readFileSync('src/game/scenes/CampScene.ts', 'utf8'); const titleSceneSource = readFileSync('src/game/scenes/TitleScene.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 sortieFlowAfterBattleIds = extractSortieFlowAfterBattleIds(campaignFlowSource, battleScenarioIds); const sortieFlowCampaignSteps = extractSortieFlowCampaignSteps(campaignFlowSource); const sortieFlowNextBattleIds = extractSortieFlowNextBattleIds(campaignFlowSource, battleScenarioIds); const sortieFlowTitleBattleIds = extractSortieFlowTitleBattleIds(campaignFlowSource, battleScenarioIds); const campaignRewardUnlocks = extractCampaignRewardUnlocks(battlesSource, battleScenarioIds); const sortieFlowDisplayFields = ['eyebrow', 'title', 'description', 'rewardHint']; 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}`); const titleBattleId = sortieFlowTitleBattleIds.get(flowKey); assert(titleBattleId, `Sortie flow ${flowKey} has next battle ${nextBattleId} but title is not a battle title reference`); if (titleBattleId) { assert( titleBattleId === nextBattleId, `Sortie flow ${flowKey} title points to ${titleBattleId} instead of next battle ${nextBattleId}` ); } } for (const flowKey of sortieFlowKeys) { const flowBody = sortieFlowBodies.get(flowKey) ?? ''; const afterBattleId = sortieFlowAfterBattleIds.get(flowKey); assert(afterBattleId, `Sortie flow ${flowKey} has no afterBattleId`); if (afterBattleId) { assert(battleIds.has(afterBattleId), `Sortie flow ${flowKey} references unknown afterBattleId: ${afterBattleId}`); if (battleIds.has(flowKey)) { assert(afterBattleId === flowKey, `Sortie flow ${flowKey} afterBattleId points to ${afterBattleId}`); } } assert(/\bpages\s*:/.test(flowBody), `Sortie flow ${flowKey} must provide story pages`); assert( !/\b\w+BattleVictoryPages\b/.test(flowBody), `Sortie flow ${flowKey} must not defer a completed battle's victory story until the next camp sortie` ); for (const fieldName of sortieFlowDisplayFields) { assert(hasVisibleSortieFlowField(flowBody, fieldName), `Sortie flow ${flowKey} must provide a visible ${fieldName}`); } } 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, `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}` ); } assert( /\bpages\s*:\s*\w+BattleIntroPages\b/.test(flowBody), `Battle sortie flow ${flowKey} must contain only the next battle intro pages` ); } 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, unlock] of campaignRewardUnlocks.entries()) { const { battleId: unlockBattleId, label } = unlock; const nextBattleId = sortieFlowNextBattleIds.get(battleId); assert(battleIds.has(unlockBattleId), `Battle ${battleId} campaign reward unlocks unknown battle id: ${unlockBattleId}`); assert(label.length > 0, `Battle ${battleId} campaign reward unlock ${unlockBattleId} must provide a visible unlockLabel`); 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}`); } } const battleVictoryStoryRouteCount = countOccurrences(battleSceneSource, 'pages: battleScenario.victoryPages'); const campSceneDestinationCount = countOccurrences(battlesSource, "nextCampScene: 'CampScene'") - 1; assert( campSceneDestinationCount === battleIds.size, `Every battle scenario must return to the typed CampScene destination after its aftermath (found ${campSceneDestinationCount}/${battleIds.size})` ); assert( battleVictoryStoryRouteCount === 2, `Battle results and victory improvement navigation must both route through the completed scenario's victory pages (found ${battleVictoryStoryRouteCount})` ); assert( battleSceneSource.includes("return { destination: 'victory-story' as const, label: '승리 이야기로' }"), 'Every completed battle result must advertise the victory-story destination before camp' ); assert( /private resultContinueRoute\(outcome = this\.battleOutcome\)\s*\{\s*if \(outcome !== 'victory'\) \{\s*return undefined;/.test(battleSceneSource), 'Defeat results must never expose the victory-story continuation route' ); assert( /if \(outcome === 'victory'\) \{\s*this\.resultContinueButton = this\.addResultButton/.test(battleSceneSource), 'The victory-story result CTA must only be created for victories' ); assert( /if \(outcome === 'victory'\) \{[\s\S]*?pages: battleScenario\.victoryPages,[\s\S]*?return;\s*\}\s*this\.beginResultNavigation\(\(\) => startLazyScene\(this, 'CampScene', campSceneData\)\);/.test(battleSceneSource), 'Victory improvement navigation must show aftermath pages, while defeat improvement navigation must return directly to camp for retry' ); assert( !battleSceneSource.includes('firstBattleVictoryPages'), 'BattleScene must not special-case first-battle aftermath pages' ); assert( countOccurrences(battleSceneSource, 'completedAftermathBattleId: battleScenario.id') === 2, 'Both victory result routes must clear their persisted aftermath marker only when CampScene is reached' ); assert( campaignStateSource.includes('state.pendingAftermathBattleId = battleId as BattleScenarioId') && campaignStateSource.includes('export function completeCampaignAftermath'), 'Campaign saves must persist a pending victory aftermath and expose an explicit completion transition' ); assert( /if \(campaign\.pendingAftermathBattleId\)[\s\S]*?pages: aftermathScenario\.victoryPages,[\s\S]*?presentationStage: 'aftermath'[\s\S]*?if \(campaign\.step === 'ending-complete'\)/.test(titleSceneSource), 'Continue must restore a pending aftermath before routing camp or ending campaign steps' ); assert( /if \(data\?\.completedAftermathBattleId\) \{\s*completeCampaignAftermath\(data\.completedAftermathBattleId\);\s*\}/.test(campSceneSource), 'CampScene must complete the pending aftermath before loading its campaign snapshot' ); 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 countOccurrences(source, value) { return source.split(value).length - 1; } 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 extractSortieFlowTitleBattleIds(source, scenarioIds) { const entries = new Map(); const propertyEntries = extractSortieFlowProperty(source, /title:\s*(\w+BattleScenario)\.title/); for (const [flowKey, variableName] of propertyEntries.entries()) { entries.set(flowKey, scenarioIds.get(variableName) ?? variableName); } return entries; } function extractSortieFlowAfterBattleIds(source, scenarioIds) { const entries = new Map(); const scenarioEntries = extractSortieFlowProperty(source, /afterBattleId:\s*(\w+BattleScenario)\.id/); const literalEntries = extractSortieFlowProperty(source, /afterBattleId:\s*'([^']+)'/); for (const [flowKey, variableName] of scenarioEntries.entries()) { entries.set(flowKey, scenarioIds.get(variableName) ?? variableName); } for (const [flowKey, battleId] of literalEntries.entries()) { entries.set(flowKey, battleId); } return entries; } function extractCampaignRewardUnlocks(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]); const labelMatch = /unlockLabel:\s*'([^']+)'/.exec(match[0]); if (battleId && unlockMatch) { entries.set(battleId, { battleId: unlockMatch[1], label: (labelMatch?.[1] ?? '').trim() }); } } 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 hasVisibleSortieFlowField(flowBody, fieldName) { const fieldPattern = new RegExp( `\\b${fieldName}\\s*:\\s*(?:'([^']*)'|\`([^\`]*)\`|"([^"]*)"|([A-Za-z]\\w*(?:\\.[A-Za-z]\\w*)*))` ); const match = fieldPattern.exec(flowBody); if (!match) { return false; } return (match[1] ?? match[2] ?? match[3] ?? match[4] ?? '').trim().length > 0; } 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}.`); }