Files
heros_web/scripts/verify-inventory-reward-data.mjs

203 lines
7.0 KiB
JavaScript

import { readFileSync } from 'node:fs';
import { createServer } from 'vite';
const campScenePath = 'src/game/scenes/CampScene.ts';
const server = await createServer({
logLevel: 'error',
server: { middlewareMode: true },
appType: 'custom'
});
try {
const { battleScenarios } = await server.ssrLoadModule('/src/game/data/battles.ts');
const { itemCatalog } = await server.ssrLoadModule('/src/game/data/battleItems.ts');
const campSceneSource = readFileSync(campScenePath, 'utf8');
const errors = [];
const scenarioEntries = Object.entries(battleScenarios);
const campSupplyLabels = new Set(collectCampSupplyLabels(campSceneSource));
const equipmentLabels = new Set(Object.values(itemCatalog).map((item) => item.name));
const campVisitRewardLists = collectCampVisitItemRewardLists(campSceneSource);
let checkedRewardCount = 0;
if (campSupplyLabels.size === 0) {
errors.push(`${campScenePath}: no camp supply labels found`);
}
if (equipmentLabels.size === 0) {
errors.push('src/game/data/battleItems.ts: no equipment labels found');
}
scenarioEntries.forEach(([battleId, scenario]) => {
const context = `battleScenarios/${battleId}`;
checkedRewardCount += validateRewardList(errors, scenario.itemRewards, `${context}: itemRewards`, {
allowAnyInventoryCategory: true,
campSupplyLabels,
equipmentLabels
});
const campaignReward = scenario.campaignReward;
if (!campaignReward) {
return;
}
checkedRewardCount += validateRewardList(errors, campaignReward.supplies ?? [], `${context}: campaignReward.supplies`, {
expectedCategory: 'supply',
campSupplyLabels,
equipmentLabels
});
checkedRewardCount += validateRewardList(errors, campaignReward.equipment ?? [], `${context}: campaignReward.equipment`, {
disallowedCategories: ['supply'],
campSupplyLabels,
equipmentLabels
});
checkedRewardCount += validateRewardList(errors, campaignReward.reputation ?? [], `${context}: campaignReward.reputation`, {
expectedCategory: 'record',
allowSignedAmount: true,
campSupplyLabels,
equipmentLabels
});
validateScenarioSupplySnapshot(errors, battleId, scenario, campSupplyLabels);
});
campVisitRewardLists.forEach(({ rewards, lineNumber }) => {
checkedRewardCount += validateRewardList(errors, rewards, `${campScenePath}:${lineNumber} camp visit itemRewards`, {
allowAnyInventoryCategory: true,
campSupplyLabels,
equipmentLabels
});
});
if (errors.length) {
console.error(`Inventory reward data verification failed with ${errors.length} issue(s):`);
errors.slice(0, 80).forEach((error) => console.error(`- ${error}`));
if (errors.length > 80) {
console.error(`- ...and ${errors.length - 80} more`);
}
process.exitCode = 1;
} else {
console.log(
`Verified inventory reward taxonomy for ${checkedRewardCount} reward labels across ${scenarioEntries.length} battles and ${campVisitRewardLists.length} camp visit reward lists.`
);
}
} finally {
await server.close();
}
function validateRewardList(errors, rewards, context, options) {
if (!Array.isArray(rewards)) {
errors.push(`${context} must be an array`);
return 0;
}
const labels = new Set();
rewards.forEach((reward, index) => {
if (typeof reward !== 'string' || reward.trim().length === 0) {
errors.push(`${context}[${index}] must be a non-empty string`);
return;
}
const parsed = parseRewardLabel(reward);
const category = rewardCategory(parsed.label, options.campSupplyLabels, options.equipmentLabels);
if (!parsed.label) {
errors.push(`${context}[${index}] has an empty parsed label`);
}
if (!Number.isInteger(parsed.amount) || parsed.amount === 0 || (!options.allowSignedAmount && parsed.amount < 1)) {
errors.push(`${context}[${index}] has invalid amount ${parsed.amount}`);
}
if (labels.has(parsed.label)) {
errors.push(`${context} has duplicate parsed label "${parsed.label}"`);
}
if (options.expectedCategory && category !== options.expectedCategory) {
errors.push(`${context}[${index}] expected ${options.expectedCategory} reward but found ${category}: "${reward}"`);
}
if (options.disallowedCategories?.includes(category)) {
errors.push(`${context}[${index}] must not use ${category} reward label: "${reward}"`);
}
labels.add(parsed.label);
});
return rewards.length;
}
function validateScenarioSupplySnapshot(errors, battleId, scenario, campSupplyLabels) {
const itemRewardSupplies = labelsByCategory(scenario.itemRewards ?? [], campSupplyLabels);
const campaignSupplies = labelsByCategory(scenario.campaignReward?.supplies ?? [], campSupplyLabels, { includeOnlySupplies: false });
if (!sameStringSet(itemRewardSupplies, campaignSupplies)) {
errors.push(
`battleScenarios/${battleId}: campaignReward.supplies does not match supply labels surfaced in itemRewards (${[
...itemRewardSupplies
].join(', ')} vs ${[...campaignSupplies].join(', ')})`
);
}
}
function labelsByCategory(rewards, campSupplyLabels, options = {}) {
const includeOnlySupplies = options.includeOnlySupplies ?? true;
return new Set(
rewards
.map((reward) => parseRewardLabel(reward).label)
.filter((label) => (includeOnlySupplies ? campSupplyLabels.has(label) : true))
);
}
function rewardCategory(label, campSupplyLabels, equipmentLabels) {
if (campSupplyLabels.has(label)) {
return 'supply';
}
if (equipmentLabels.has(label)) {
return 'equipment';
}
return 'record';
}
function collectCampSupplyLabels(source) {
const match = source.match(/const\s+campSupplies[\s\S]*?=\s*\[([\s\S]*?)\];/);
if (!match) {
return [];
}
return collectStringPropertyValues(match[1], 'label');
}
function collectCampVisitItemRewardLists(source) {
return [...source.matchAll(/itemRewards:\s*\[([\s\S]*?)\]/g)].map((match) => ({
lineNumber: source.slice(0, match.index ?? 0).split(/\r?\n/).length,
rewards: collectSingleQuotedStrings(match[1])
}));
}
function collectStringPropertyValues(source, propertyName) {
const pattern = new RegExp(`${propertyName}:\\s*'((?:\\\\'|[^'])*)'`, 'g');
return [...source.matchAll(pattern)].map((match) => unescapeSingleQuotedString(match[1]));
}
function collectSingleQuotedStrings(source) {
return [...source.matchAll(/'((?:\\'|[^'])*)'/g)].map((match) => unescapeSingleQuotedString(match[1]));
}
function unescapeSingleQuotedString(value) {
return value.replace(/\\'/g, "'");
}
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 };
}
const sign = match[2] === '-' ? -1 : 1;
return {
label: match[1].trim() || normalized,
amount: Number(match[3] ?? 1) * sign
};
}
function sameStringSet(left, right) {
if (left.size !== right.size) {
return false;
}
return [...left].every((value) => right.has(value));
}