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)); }