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