diff --git a/package.json b/package.json index 90a9080..4649e66 100644 --- a/package.json +++ b/package.json @@ -14,6 +14,7 @@ "verify:battle-data": "node scripts/verify-battle-scenario-data.mjs", "verify:campaign-data": "node scripts/verify-campaign-flow-data.mjs", "verify:camp-rewards": "node scripts/verify-camp-reward-data.mjs", + "verify:campaign-recruits": "node scripts/verify-campaign-recruit-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-campaign-recruit-data.mjs b/scripts/verify-campaign-recruit-data.mjs new file mode 100644 index 0000000..5ba617e --- /dev/null +++ b/scripts/verify-campaign-recruit-data.mjs @@ -0,0 +1,164 @@ +import { createServer } from 'vite'; + +const server = await createServer({ + logLevel: 'error', + server: { middlewareMode: true }, + appType: 'custom' +}); + +try { + const { battleScenarios } = await server.ssrLoadModule('/src/game/data/battles.ts'); + const { campaignRecruitUnits } = await server.ssrLoadModule('/src/game/data/scenario.ts'); + const { unitClasses } = await server.ssrLoadModule('/src/game/data/battleRules.ts'); + const { itemCatalog, equipmentSlots } = await server.ssrLoadModule('/src/game/data/battleItems.ts'); + + const errors = []; + const recruitTemplates = Array.isArray(campaignRecruitUnits) ? campaignRecruitUnits : []; + const recruitTemplateIds = new Set(recruitTemplates.map((unit) => unit.id)); + const classKeys = new Set(Object.keys(unitClasses)); + const itemKeys = new Set(Object.keys(itemCatalog)); + const recruitRewardRefs = []; + + validateRecruitTemplates(errors, recruitTemplates, classKeys, itemKeys, itemCatalog, equipmentSlots); + + Object.entries(battleScenarios).forEach(([battleId, scenario]) => { + const sameBattleAllies = new Set(scenario.units.filter((unit) => unit.faction === 'ally').map((unit) => unit.id)); + const rewardRecruits = scenario.campaignReward?.recruits ?? []; + + rewardRecruits.forEach((unitId, index) => { + recruitRewardRefs.push({ battleId, unitId }); + if (typeof unitId !== 'string' || unitId.trim().length === 0) { + errors.push(`${battleId}: campaignReward.recruits[${index}] must be a non-empty string`); + return; + } + if (!sameBattleAllies.has(unitId) && !recruitTemplateIds.has(unitId)) { + errors.push( + `${battleId}: recruit "${unitId}" is not an ally in this battle and has no campaignRecruitUnits template` + ); + } + }); + }); + + validateReachability(errors, recruitTemplates, recruitRewardRefs); + + if (errors.length) { + console.error(`Campaign recruit data verification failed with ${errors.length} issue(s):`); + errors.slice(0, 100).forEach((error) => console.error(`- ${error}`)); + if (errors.length > 100) { + console.error(`- ...and ${errors.length - 100} more`); + } + process.exitCode = 1; + } else { + console.log( + `Verified ${recruitTemplates.length} campaign recruit unit templates and ${recruitRewardRefs.length} recruit rewards.` + ); + } +} finally { + await server.close(); +} + +function validateRecruitTemplates(errors, recruitTemplates, classKeys, itemKeys, itemCatalog, equipmentSlots) { + const ids = new Set(); + + recruitTemplates.forEach((unit, index) => { + const context = unit?.id ? `campaignRecruitUnits/${unit.id}` : `campaignRecruitUnits[${index}]`; + if (!unit || typeof unit !== 'object') { + errors.push(`${context}: must be a unit object`); + return; + } + + assertNonEmptyString(errors, unit.id, `${context}: id`); + assertNonEmptyString(errors, unit.name, `${context}: name`); + assertNonEmptyString(errors, unit.className, `${context}: className`); + + if (ids.has(unit.id)) { + errors.push(`${context}: duplicate recruit id "${unit.id}"`); + } + ids.add(unit.id); + + if (unit.faction !== 'ally') { + errors.push(`${context}: campaign recruit faction must be ally, got "${unit.faction}"`); + } + if (!classKeys.has(unit.classKey)) { + errors.push(`${context}: unknown classKey "${unit.classKey}"`); + } + if (!Number.isInteger(unit.level) || unit.level < 1) { + errors.push(`${context}: invalid level ${unit.level}`); + } + if (!Number.isInteger(unit.exp) || unit.exp < 0) { + errors.push(`${context}: invalid exp ${unit.exp}`); + } + if (!Number.isInteger(unit.hp) || !Number.isInteger(unit.maxHp) || unit.hp < 1 || unit.maxHp < 1 || unit.hp > unit.maxHp) { + errors.push(`${context}: invalid hp ${unit.hp}/${unit.maxHp}`); + } + if (!Number.isInteger(unit.attack) || unit.attack < 0) { + errors.push(`${context}: invalid attack ${unit.attack}`); + } + if (!Number.isInteger(unit.move) || unit.move < 1) { + errors.push(`${context}: invalid move ${unit.move}`); + } + if (!Number.isInteger(unit.x) || !Number.isInteger(unit.y)) { + errors.push(`${context}: invalid fallback position ${unit.x},${unit.y}`); + } + + validateStats(errors, unit.stats, context); + validateEquipment(errors, unit.equipment, itemKeys, itemCatalog, equipmentSlots, context); + }); +} + +function validateStats(errors, stats, context) { + const requiredStats = ['might', 'intelligence', 'leadership', 'agility', 'luck']; + if (!stats || typeof stats !== 'object') { + errors.push(`${context}: missing stats`); + return; + } + + requiredStats.forEach((statKey) => { + const value = stats[statKey]; + if (!Number.isInteger(value) || value < 1 || value > 120) { + errors.push(`${context}: invalid stats.${statKey} ${value}`); + } + }); +} + +function validateEquipment(errors, equipment, itemKeys, itemCatalog, equipmentSlots, context) { + if (!equipment || typeof equipment !== 'object') { + errors.push(`${context}: missing equipment`); + return; + } + + equipmentSlots.forEach((slot) => { + const state = equipment[slot]; + if (!state) { + errors.push(`${context}: missing ${slot} equipment`); + return; + } + + if (!itemKeys.has(state.itemId)) { + errors.push(`${context}: unknown ${slot} item "${state.itemId}"`); + } else if (itemCatalog[state.itemId].slot !== slot) { + errors.push(`${context}: ${slot} item "${state.itemId}" belongs to ${itemCatalog[state.itemId].slot}`); + } + if (!Number.isInteger(state.level) || state.level < 1 || state.level > 9) { + errors.push(`${context}: invalid ${slot} level ${state.level}`); + } + if (!Number.isInteger(state.exp) || state.exp < 0) { + errors.push(`${context}: invalid ${slot} exp ${state.exp}`); + } + }); +} + +function validateReachability(errors, recruitTemplates, recruitRewardRefs) { + const rewardedRecruitIds = new Set(recruitRewardRefs.map(({ unitId }) => unitId)); + recruitTemplates.forEach((unit) => { + if (!rewardedRecruitIds.has(unit.id)) { + errors.push(`campaignRecruitUnits/${unit.id}: recruit template is never awarded by campaignReward.recruits`); + } + }); +} + +function assertNonEmptyString(errors, value, context) { + if (typeof value !== 'string' || value.trim().length === 0) { + errors.push(`${context} must be a non-empty string`); + } +} diff --git a/scripts/verify-release-candidate.mjs b/scripts/verify-release-candidate.mjs index 133568c..acead7d 100644 --- a/scripts/verify-release-candidate.mjs +++ b/scripts/verify-release-candidate.mjs @@ -12,6 +12,7 @@ try { runCommand('node', ['scripts/verify-campaign-flow-data.mjs']); runCommand('node', ['scripts/verify-battle-scenario-data.mjs']); runCommand('node', ['scripts/verify-camp-reward-data.mjs']); + runCommand('node', ['scripts/verify-campaign-recruit-data.mjs']); runCommand('node', ['scripts/verify-story-asset-data.mjs']); runCommand('node', ['scripts/verify-story-image-asset-data.mjs']); runCommand('node', ['scripts/verify-visual-asset-data.mjs']);