Verify battle scenario data integrity

This commit is contained in:
2026-07-05 07:22:35 +09:00
parent 6b35c780d8
commit fd8891162b
4 changed files with 273 additions and 2 deletions

View File

@@ -0,0 +1,262 @@
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 { unitClasses, terrainRules } = await server.ssrLoadModule('/src/game/data/battleRules.ts');
const { itemCatalog, equipmentSlots } = await server.ssrLoadModule('/src/game/data/battleItems.ts');
const scenarioEntries = Object.entries(battleScenarios);
const scenarioIds = new Set(scenarioEntries.map(([id]) => id));
const classKeys = new Set(Object.keys(unitClasses));
const terrainKeys = new Set(Object.keys(terrainRules));
const itemKeys = new Set(Object.keys(itemCatalog));
const errors = [];
scenarioEntries.forEach(([scenarioId, scenario]) => {
const context = scenarioId;
if (scenario.id !== scenarioId) {
errors.push(`${context}: scenario id does not match battleScenarios key`);
}
assertNonEmptyString(errors, scenario.title, `${context}: title`);
assertNonEmptyString(errors, scenario.victoryConditionLabel, `${context}: victory condition label`);
assertNonEmptyString(errors, scenario.defeatConditionLabel, `${context}: defeat condition label`);
validateMap(errors, scenario, terrainKeys, context);
validateUnits(errors, scenario, classKeys, itemKeys, equipmentSlots, context);
validateObjectives(errors, scenario, context);
validateSortie(errors, scenario, classKeys, context);
validateRewards(errors, scenario, scenarioIds, context);
});
if (errors.length) {
console.error(`Battle scenario 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 {
const unitCount = scenarioEntries.reduce((total, [, scenario]) => total + scenario.units.length, 0);
console.log(`Verified ${scenarioEntries.length} battle scenarios, ${unitCount} unit placements, maps, objectives, sortie data, and equipment.`);
}
} finally {
await server.close();
}
function validateMap(errors, scenario, terrainKeys, context) {
const { map } = scenario;
if (!map || !Number.isInteger(map.width) || !Number.isInteger(map.height) || map.width < 1 || map.height < 1) {
errors.push(`${context}: invalid map dimensions`);
return;
}
if (!Array.isArray(map.terrain) || map.terrain.length !== map.height) {
errors.push(`${context}: terrain row count does not match map height`);
return;
}
map.terrain.forEach((row, y) => {
if (!Array.isArray(row) || row.length !== map.width) {
errors.push(`${context}: terrain row ${y} does not match map width`);
return;
}
row.forEach((terrain, x) => {
if (!terrainKeys.has(terrain)) {
errors.push(`${context}: unknown terrain "${terrain}" at ${x},${y}`);
}
});
});
}
function validateUnits(errors, scenario, classKeys, itemKeys, equipmentSlots, context) {
const unitIds = new Set();
const occupiedTiles = new Map();
const allies = [];
const enemies = [];
scenario.units.forEach((unit) => {
const unitContext = `${context}/${unit.id}`;
if (!unit.id) {
errors.push(`${context}: unit without id`);
return;
}
if (unitIds.has(unit.id)) {
errors.push(`${context}: duplicate unit id "${unit.id}"`);
}
unitIds.add(unit.id);
if (unit.faction === 'ally') {
allies.push(unit);
} else if (unit.faction === 'enemy') {
enemies.push(unit);
} else {
errors.push(`${unitContext}: invalid faction "${unit.faction}"`);
}
if (!classKeys.has(unit.classKey)) {
errors.push(`${unitContext}: unknown classKey "${unit.classKey}"`);
}
if (!Number.isInteger(unit.level) || unit.level < 1) {
errors.push(`${unitContext}: invalid level ${unit.level}`);
}
if (!Number.isInteger(unit.hp) || !Number.isInteger(unit.maxHp) || unit.hp < 1 || unit.maxHp < 1 || unit.hp > unit.maxHp) {
errors.push(`${unitContext}: invalid hp ${unit.hp}/${unit.maxHp}`);
}
if (!Number.isInteger(unit.move) || unit.move < 1) {
errors.push(`${unitContext}: invalid move ${unit.move}`);
}
if (!isInBounds(unit, scenario.map)) {
errors.push(`${unitContext}: position ${unit.x},${unit.y} is outside ${scenario.map.width}x${scenario.map.height}`);
} else {
const tileKey = `${unit.x},${unit.y}`;
const existingUnitId = occupiedTiles.get(tileKey);
if (existingUnitId) {
errors.push(`${context}: units "${existingUnitId}" and "${unit.id}" share tile ${tileKey}`);
}
occupiedTiles.set(tileKey, unit.id);
}
equipmentSlots.forEach((slot) => {
const equipment = unit.equipment?.[slot];
if (!equipment) {
errors.push(`${unitContext}: missing ${slot} equipment`);
return;
}
if (!itemKeys.has(equipment.itemId)) {
errors.push(`${unitContext}: unknown ${slot} item "${equipment.itemId}"`);
}
if (!Number.isInteger(equipment.level) || equipment.level < 1 || equipment.level > 9) {
errors.push(`${unitContext}: invalid ${slot} level ${equipment.level}`);
}
if (!Number.isInteger(equipment.exp) || equipment.exp < 0) {
errors.push(`${unitContext}: invalid ${slot} exp ${equipment.exp}`);
}
});
});
if (!allies.length) {
errors.push(`${context}: scenario has no allied units`);
}
if (!enemies.length) {
errors.push(`${context}: scenario has no enemy units`);
}
}
function validateObjectives(errors, scenario, context) {
const unitIds = new Set(scenario.units.map((unit) => unit.id));
const objectiveIds = new Set();
if (!scenario.leaderUnitId || !unitIds.has(scenario.leaderUnitId)) {
errors.push(`${context}: leaderUnitId "${scenario.leaderUnitId}" is missing from units`);
}
scenario.defeatConditions.forEach((condition) => {
if (!unitIds.has(condition.unitId)) {
errors.push(`${context}: defeat condition references missing unit "${condition.unitId}"`);
}
});
scenario.objectives.forEach((objective) => {
if (!objective.id || objectiveIds.has(objective.id)) {
errors.push(`${context}: duplicate or missing objective id "${objective.id}"`);
}
objectiveIds.add(objective.id);
assertNonEmptyString(errors, objective.label, `${context}/${objective.id}: objective label`);
if (!Number.isFinite(objective.rewardGold) || objective.rewardGold < 0) {
errors.push(`${context}/${objective.id}: invalid rewardGold ${objective.rewardGold}`);
}
if (objective.unitId && !unitIds.has(objective.unitId)) {
errors.push(`${context}/${objective.id}: references missing unit "${objective.unitId}"`);
}
if (objective.targetTile && !isInBounds(objective.targetTile, scenario.map)) {
errors.push(`${context}/${objective.id}: target tile ${objective.targetTile.x},${objective.targetTile.y} is outside map`);
}
});
}
function validateSortie(errors, scenario, classKeys, context) {
const sortie = scenario.sortie;
if (!sortie) {
return;
}
const unitIds = new Set(scenario.units.map((unit) => unit.id));
if (!Number.isInteger(sortie.sortieLimit) || sortie.sortieLimit < 1) {
errors.push(`${context}: invalid sortie limit ${sortie.sortieLimit}`);
}
if ((sortie.requiredUnits?.length ?? 0) > sortie.sortieLimit) {
errors.push(`${context}: requiredUnits count exceeds sortieLimit`);
}
[...(sortie.requiredUnits ?? []), ...(sortie.excludedUnits ?? [])].forEach((unitId) => {
if (!unitIds.has(unitId)) {
errors.push(`${context}: sortie references missing unit "${unitId}"`);
}
});
sortie.recommendedUnits.forEach((recommendation) => {
if (!unitIds.has(recommendation.unitId)) {
errors.push(`${context}: recommended unit "${recommendation.unitId}" is missing from units`);
}
assertNonEmptyString(errors, recommendation.reason, `${context}/${recommendation.unitId}: recommendation reason`);
});
sortie.recommendedClasses.forEach((recommendation) => {
assertNonEmptyString(errors, recommendation.label, `${context}: recommended class label`);
assertNonEmptyString(errors, recommendation.reason, `${context}/${recommendation.label}: recommended class reason`);
recommendation.classKeys.forEach((classKey) => {
if (!classKeys.has(classKey)) {
errors.push(`${context}: recommended class "${recommendation.label}" references unknown classKey "${classKey}"`);
}
});
});
if (sortie.deploymentSlots.length < sortie.sortieLimit) {
errors.push(`${context}: deploymentSlots count is lower than sortieLimit`);
}
const deploymentTiles = new Set();
sortie.deploymentSlots.forEach((slot) => {
if (!isInBounds(slot, scenario.map)) {
errors.push(`${context}: deployment slot ${slot.x},${slot.y} is outside map`);
return;
}
const tileKey = `${slot.x},${slot.y}`;
if (deploymentTiles.has(tileKey)) {
errors.push(`${context}: duplicate deployment slot ${tileKey}`);
}
deploymentTiles.add(tileKey);
if (slot.unitId && !unitIds.has(slot.unitId)) {
errors.push(`${context}: deployment slot references missing unit "${slot.unitId}"`);
}
});
}
function validateRewards(errors, scenario, scenarioIds, context) {
scenario.itemRewards.forEach((reward, index) => {
assertNonEmptyString(errors, reward, `${context}: item reward ${index}`);
});
const unlockBattleId = scenario.campaignReward?.unlockBattleId;
if (unlockBattleId && !scenarioIds.has(unlockBattleId)) {
errors.push(`${context}: campaignReward unlockBattleId "${unlockBattleId}" is not a known battle`);
}
}
function isInBounds(point, map) {
return (
Number.isInteger(point.x) &&
Number.isInteger(point.y) &&
point.x >= 0 &&
point.y >= 0 &&
point.x < map.width &&
point.y < map.height
);
}
function assertNonEmptyString(errors, value, context) {
if (typeof value !== 'string' || value.trim().length === 0) {
errors.push(`${context} must be a non-empty string`);
}
}

View File

@@ -10,6 +10,7 @@ let browser;
try {
runCommand('node', ['scripts/verify-campaign-flow-data.mjs']);
runCommand('node', ['scripts/verify-battle-scenario-data.mjs']);
mkdirSync(screenshotDir, { recursive: true });
serverProcess = await ensurePreviewServer(targetUrl);