390 lines
17 KiB
JavaScript
390 lines
17 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 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}.`);
|
|
}
|