From 5a76eb07adcb623770912089f6dc659ce6ad2dba Mon Sep 17 00:00:00 2001 From: Wickedness Date: Sun, 5 Jul 2026 08:44:17 +0900 Subject: [PATCH] Validate inventory reward taxonomy --- package.json | 1 + scripts/verify-inventory-reward-data.mjs | 202 +++++++++++++++++++++++ scripts/verify-static-data.mjs | 1 + 3 files changed, 204 insertions(+) create mode 100644 scripts/verify-inventory-reward-data.mjs diff --git a/package.json b/package.json index 053a6dd..462f2d9 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/scripts/verify-inventory-reward-data.mjs b/scripts/verify-inventory-reward-data.mjs new file mode 100644 index 0000000..9d2f09a --- /dev/null +++ b/scripts/verify-inventory-reward-data.mjs @@ -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)); +} diff --git a/scripts/verify-static-data.mjs b/scripts/verify-static-data.mjs index dd27946..65e88ae 100644 --- a/scripts/verify-static-data.mjs +++ b/scripts/verify-static-data.mjs @@ -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',