Validate inventory reward taxonomy
This commit is contained in:
@@ -16,6 +16,7 @@
|
||||
"verify:camp-rewards": "node scripts/verify-camp-reward-data.mjs",
|
||||
"verify:campaign-recruits": "node scripts/verify-campaign-recruit-data.mjs",
|
||||
"verify:equipment-catalog": "node scripts/verify-equipment-catalog-data.mjs",
|
||||
"verify:inventory-rewards": "node scripts/verify-inventory-reward-data.mjs",
|
||||
"verify:story-assets": "node scripts/verify-story-asset-data.mjs",
|
||||
"verify:story-images": "node scripts/verify-story-image-asset-data.mjs",
|
||||
"verify:visual-assets": "node scripts/verify-visual-asset-data.mjs",
|
||||
|
||||
202
scripts/verify-inventory-reward-data.mjs
Normal file
202
scripts/verify-inventory-reward-data.mjs
Normal file
@@ -0,0 +1,202 @@
|
||||
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));
|
||||
}
|
||||
@@ -6,6 +6,7 @@ const checks = [
|
||||
'scripts/verify-camp-reward-data.mjs',
|
||||
'scripts/verify-campaign-recruit-data.mjs',
|
||||
'scripts/verify-equipment-catalog-data.mjs',
|
||||
'scripts/verify-inventory-reward-data.mjs',
|
||||
'scripts/verify-story-asset-data.mjs',
|
||||
'scripts/verify-story-image-asset-data.mjs',
|
||||
'scripts/verify-visual-asset-data.mjs',
|
||||
|
||||
Reference in New Issue
Block a user