import { readFileSync } from 'node:fs'; import { createServer } from 'vite'; const sourcePath = 'src/game/scenes/CampScene.ts'; const source = readFileSync(sourcePath, 'utf8'); const errors = []; const server = await createServer({ logLevel: 'error', server: { middlewareMode: true }, appType: 'custom' }); try { const { battleScenarios, defaultBattleScenario } = await server.ssrLoadModule('/src/game/data/battles.ts'); const { isCampaignStep } = await server.ssrLoadModule('/src/game/state/campaignState.ts'); const itemRewardArrays = collectItemRewardArrays(source); itemRewardArrays.forEach(({ body, index, offset }) => validateItemRewards(body, index, offset)); const campBattleIdEntries = collectCampBattleIdEntries(source, defaultBattleScenario.id); const campBattleIdKeys = new Set(campBattleIdEntries.map((entry) => entry.key)); validateCampBattleIdEntries(campBattleIdEntries, battleScenarios); const dialogueEvents = collectCampEvents(source, 'campDialogues'); const visitEvents = collectCampEvents(source, 'campVisits'); const campaignStepReferences = validateCampEventCollection(dialogueEvents, 'campDialogues', campBattleIdKeys, isCampaignStep) + validateCampEventCollection(visitEvents, 'campVisits', campBattleIdKeys, isCampaignStep); validateNumericProperties('gold', { min: 1, max: 20000 }); validateNumericProperties('bondExp', { min: 0, max: 200 }); validateNumericProperties('rewardExp', { min: 1, max: 200 }); if (errors.length > 0) { throw new Error(`Camp reward data verification failed:\n${errors.map((error) => `- ${error}`).join('\n')}`); } console.log( `Verified ${dialogueEvents.length} camp dialogues, ${visitEvents.length} camp visits, ${campBattleIdEntries.length} camp battle ids, ${campaignStepReferences} campaign-step references, ${itemRewardArrays.length} camp item reward lists, and camp reward numeric fields.` ); } finally { await server.close(); } function collectItemRewardArrays(text) { return [...text.matchAll(/itemRewards:\s*\[([\s\S]*?)\]/g)].map((match, index) => ({ body: match[1], index, offset: match.index ?? 0 })); } function validateItemRewards(body, index, offset) { const entries = [...body.matchAll(/'((?:\\'|[^'])*)'/g)].map((match) => match[1].replace(/\\'/g, "'")); if (entries.length === 0) { return; } const labels = new Set(); entries.forEach((reward, rewardIndex) => { const parsed = parseRewardLabel(reward); if (!parsed.label) { errors.push(`${sourcePath}:${lineNumber(offset)} itemRewards[${index}][${rewardIndex}] has an empty parsed label`); } if (!Number.isInteger(parsed.amount) || parsed.amount < 1) { errors.push(`${sourcePath}:${lineNumber(offset)} itemRewards[${index}][${rewardIndex}] has invalid amount ${parsed.amount}`); } if (labels.has(parsed.label)) { errors.push(`${sourcePath}:${lineNumber(offset)} itemRewards[${index}] has duplicate label "${parsed.label}"`); } labels.add(parsed.label); }); } function validateNumericProperties(propertyName, { min, max }) { const pattern = new RegExp(`${propertyName}:\\s*(-?\\d+)`, 'g'); for (const match of source.matchAll(pattern)) { const value = Number(match[1]); if (!Number.isInteger(value) || value < min || value > max) { errors.push(`${sourcePath}:${lineNumber(match.index ?? 0)} ${propertyName} has out-of-range value ${value}`); } } } function collectCampBattleIdEntries(text, defaultBattleScenarioId) { const objectSource = extractConstObject(text, 'campBattleIds'); const objectOffset = text.indexOf(objectSource); return [...objectSource.matchAll(/^\s*(\w+):\s*([^,\n]+)/gm)].map((match) => { const expression = match[2].trim(); return { key: match[1], expression, value: parseCampBattleIdExpression(expression, defaultBattleScenarioId), offset: objectOffset + (match.index ?? 0) }; }); } function parseCampBattleIdExpression(expression, defaultBattleScenarioId) { const stringMatch = /^'([^']+)'$/.exec(expression); if (stringMatch) { return stringMatch[1]; } if (expression === 'defaultBattleScenario.id') { return defaultBattleScenarioId; } return ''; } function validateCampBattleIdEntries(entries, battleScenarios) { const knownBattleIds = new Set(Object.keys(battleScenarios)); const seenBattleIds = new Map(); entries.forEach((entry) => { const context = `campBattleIds.${entry.key}`; if (!entry.value) { errors.push(`${sourcePath}:${lineNumber(entry.offset)} ${context} uses unsupported expression "${entry.expression}"`); return; } if (!knownBattleIds.has(entry.value)) { errors.push(`${sourcePath}:${lineNumber(entry.offset)} ${context} references unknown battle id "${entry.value}"`); } if (seenBattleIds.has(entry.value)) { errors.push( `${sourcePath}:${lineNumber(entry.offset)} ${context} duplicates battle id "${entry.value}" first used by campBattleIds.${seenBattleIds.get( entry.value )}` ); } seenBattleIds.set(entry.value, entry.key); }); } function collectCampEvents(text, arrayName) { const { body, offset } = extractConstArray(text, arrayName); return collectTopLevelObjects(body).map((event) => ({ ...event, offset: offset + event.offset })); } function validateCampEventCollection(events, collectionName, campBattleIdKeys, isCampaignStep) { const seenIds = new Map(); let campaignStepReferenceCount = 0; if (events.length === 0) { errors.push(`${sourcePath}: ${collectionName} has no entries`); } events.forEach((event, index) => { const context = `${collectionName}[${index}]`; const idEntries = collectStringProperties(event.body, 'id'); const eventId = idEntries[0]?.value ?? ''; if (!eventId.trim()) { errors.push(`${sourcePath}:${lineNumber(event.offset)} ${context} has no id`); } else if (seenIds.has(eventId)) { errors.push( `${sourcePath}:${lineNumber(event.offset)} ${context} duplicates id "${eventId}" first used at ${sourcePath}:${seenIds.get( eventId )}` ); } else { seenIds.set(eventId, lineNumber(event.offset)); } const choiceIds = idEntries.slice(1).map((entry) => entry.value); const seenChoiceIds = new Set(); choiceIds.forEach((choiceId, choiceIndex) => { if (!choiceId.trim()) { errors.push(`${sourcePath}:${lineNumber(event.offset)} ${context}.choices[${choiceIndex}] has an empty id`); } if (seenChoiceIds.has(choiceId)) { errors.push(`${sourcePath}:${lineNumber(event.offset)} ${context} has duplicate choice id "${choiceId}"`); } seenChoiceIds.add(choiceId); }); const availableMatch = /availableAfterBattleIds:\s*\[([\s\S]*?)\]/.exec(event.body); if (!availableMatch || availableMatch[1].trim().length === 0) { errors.push(`${sourcePath}:${lineNumber(event.offset)} ${context} has no availableAfterBattleIds`); } for (const reference of event.body.matchAll(/campBattleIds\.(\w+)/g)) { if (!campBattleIdKeys.has(reference[1])) { errors.push(`${sourcePath}:${lineNumber(event.offset)} ${context} references unknown campBattleIds.${reference[1]}`); } } campaignStepReferenceCount += validateAvailableDuringSteps(event, context, isCampaignStep); }); return campaignStepReferenceCount; } function validateAvailableDuringSteps(event, context, isCampaignStep) { const availableDuringMatch = /availableDuringSteps:\s*\[([\s\S]*?)\]/.exec(event.body); if (!availableDuringMatch) { return 0; } const body = availableDuringMatch[1]; const bodyOffset = event.offset + (availableDuringMatch.index ?? 0) + availableDuringMatch[0].indexOf(body); const steps = collectStringLiterals(body, bodyOffset); if (steps.length === 0) { errors.push(`${sourcePath}:${lineNumber(bodyOffset)} ${context} has an empty availableDuringSteps list`); } steps.forEach((step) => { if (!isCampaignStep(step.value)) { errors.push(`${sourcePath}:${lineNumber(step.offset)} ${context} references unknown campaign step "${step.value}"`); } }); return steps.length; } function collectStringProperties(text, propertyName) { const pattern = new RegExp(`\\b${propertyName}:\\s*'((?:\\\\'|[^'])*)'`, 'g'); return [...text.matchAll(pattern)].map((match) => ({ value: match[1].replace(/\\'/g, "'"), offset: match.index ?? 0 })); } function collectStringLiterals(text, offset = 0) { return [...text.matchAll(/'((?:\\'|[^'])*)'/g)].map((match) => ({ value: match[1].replace(/\\'/g, "'"), offset: offset + (match.index ?? 0) })); } function parseRewardLabel(reward) { const normalized = String(reward).replace(/\s+/g, ' ').trim(); const match = normalized.match(/^(.*?)(?:\s*[+xX]?\s*(\d+))$/); if (!match) { return { label: normalized, amount: 1 }; } return { label: match[1].trim() || normalized, amount: Number(match[2] ?? 1) }; } function lineNumber(offset) { return source.slice(0, offset).split(/\r?\n/).length; } function extractConstObject(text, objectName) { const startPattern = new RegExp(`const\\s+${objectName}\\b[^=]*=\\s*\\{`); const match = startPattern.exec(text); if (!match) { throw new Error(`Cannot find ${objectName}.`); } const start = match.index + match[0].length - 1; const end = findMatchingDelimiter(text, start, '{', '}'); return text.slice(start, end + 1); } function extractConstArray(text, arrayName) { const startPattern = new RegExp(`const\\s+${arrayName}\\b[\\s\\S]*?=\\s*\\[`); const match = startPattern.exec(text); if (!match) { throw new Error(`Cannot find ${arrayName}.`); } const start = match.index + match[0].length - 1; const end = findMatchingDelimiter(text, start, '[', ']'); return { body: text.slice(start + 1, end), offset: start + 1 }; } function collectTopLevelObjects(text) { const objects = []; let depth = 0; let start = -1; let quote = ''; let escaped = false; for (let index = 0; index < text.length; index += 1) { const char = text[index]; if (quote) { if (escaped) { escaped = false; } else if (char === '\\') { escaped = true; } else if (char === quote) { quote = ''; } continue; } if (char === "'" || char === '"' || char === '`') { quote = char; continue; } if (char === '{') { if (depth === 0) { start = index; } depth += 1; } else if (char === '}') { depth -= 1; if (depth === 0 && start >= 0) { objects.push({ body: text.slice(start, index + 1), offset: start }); start = -1; } } } return objects; } function findMatchingDelimiter(text, start, openChar, closeChar) { let depth = 0; let quote = ''; let escaped = false; for (let index = start; index < text.length; index += 1) { const char = text[index]; if (quote) { if (escaped) { escaped = false; } else if (char === '\\') { escaped = true; } else if (char === quote) { quote = ''; } continue; } if (char === "'" || char === '"' || char === '`') { quote = char; continue; } if (char === openChar) { depth += 1; } else if (char === closeChar) { depth -= 1; if (depth === 0) { return index; } } } throw new Error(`Cannot find matching ${closeChar} from offset ${start}.`); }