Verify battle scenario data integrity
This commit is contained in:
262
scripts/verify-battle-scenario-data.mjs
Normal file
262
scripts/verify-battle-scenario-data.mjs
Normal 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`);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user