Validate campaign recruit data

This commit is contained in:
2026-07-05 08:28:46 +09:00
parent c4c0e3abed
commit 86d107a7e4
3 changed files with 166 additions and 0 deletions

View File

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

View File

@@ -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']);