fix: align battle objective terrain centers

This commit is contained in:
2026-07-13 06:11:02 +09:00
parent b88a2d6ec2
commit a5e3af3421
5 changed files with 377 additions and 19 deletions

View File

@@ -8,6 +8,7 @@ const server = await createServer({
try {
const { battleScenarios } = await server.ssrLoadModule('/src/game/data/battles.ts');
const { secureTerrainCenterOverrides } = await server.ssrLoadModule('/src/game/data/secureTerrainOverrides.ts');
const { campaignRecruitUnits } = await server.ssrLoadModule('/src/game/data/scenario.ts');
const { unitClasses, terrainRules } = await server.ssrLoadModule('/src/game/data/battleRules.ts');
const { itemCatalog, equipmentSlots } = await server.ssrLoadModule('/src/game/data/battleItems.ts');
@@ -47,11 +48,17 @@ try {
validateMap(errors, scenario, terrainKeys, context);
validateUnits(errors, scenario, classKeys, itemKeys, equipmentSlots, context);
validateObjectives(errors, scenario, context);
validateObjectives(errors, scenario, terrainKeys, context);
validateTacticalGuide(errors, scenario, tacticalGuideEventKeys, context);
validateSortie(errors, scenario, classKeys, formationRoles, terrainRules, context);
validateRewards(errors, scenario, scenarioIds, itemNames, knownRewardUnitIds, context);
});
const secureTerrainOverrideCount = validateSecureTerrainOverrides(
errors,
battleScenarios,
secureTerrainCenterOverrides,
terrainKeys
);
if (errors.length) {
console.error(`Battle scenario data verification failed with ${errors.length} issue(s):`);
@@ -67,8 +74,13 @@ try {
0
);
const tacticalGuideCount = scenarioEntries.reduce((total, [, scenario]) => total + (scenario.tacticalGuide ? 1 : 0), 0);
const secureTerrainObjectiveCount = scenarioEntries.reduce(
(total, [, scenario]) =>
total + scenario.objectives.filter((objective) => objective.kind === 'secure-terrain').length,
0
);
console.log(
`Verified ${scenarioEntries.length} battle scenarios, ${unitCount} unit placements, maps, opening briefs, objectives, sortie data, ${tacticalGuideCount} tactical guides, equipment, and ${rewardCount} reward labels.`
`Verified ${scenarioEntries.length} battle scenarios, ${unitCount} unit placements, maps, opening briefs, objectives (${secureTerrainObjectiveCount} secure-terrain centers, ${secureTerrainOverrideCount} explicit overrides), sortie data, ${tacticalGuideCount} tactical guides, equipment, and ${rewardCount} reward labels.`
);
}
} finally {
@@ -171,9 +183,10 @@ function validateUnits(errors, scenario, classKeys, itemKeys, equipmentSlots, co
}
}
function validateObjectives(errors, scenario, context) {
function validateObjectives(errors, scenario, terrainKeys, context) {
const unitIds = new Set(scenario.units.map((unit) => unit.id));
const objectiveIds = new Set();
const secureTerrainCenters = new Map();
if (!scenario.leaderUnitId || !unitIds.has(scenario.leaderUnitId)) {
errors.push(`${context}: leaderUnitId "${scenario.leaderUnitId}" is missing from units`);
@@ -196,12 +209,139 @@ function validateObjectives(errors, scenario, context) {
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)) {
if (objective.kind === 'secure-terrain') {
const center = objective.targetTile;
if (center && Number.isInteger(center.x) && Number.isInteger(center.y)) {
const centerKey = `${center.x},${center.y}`;
const existingObjectiveId = secureTerrainCenters.get(centerKey);
if (secureTerrainCenters.has(centerKey)) {
errors.push(
`${context}/${objective.id}: secure-terrain center ${centerKey} duplicates objective "${existingObjectiveId}"`
);
} else {
secureTerrainCenters.set(centerKey, objective.id);
}
}
validateSecureTerrainObjective(errors, scenario, objective, terrainKeys, `${context}/${objective.id}`);
} else 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 validateSecureTerrainOverrides(errors, battleScenarios, overridesByBattle, terrainKeys) {
if (!overridesByBattle || typeof overridesByBattle !== 'object' || Array.isArray(overridesByBattle)) {
errors.push('secureTerrainCenterOverrides must be an object keyed by battle id');
return 0;
}
let overrideCount = 0;
Object.entries(overridesByBattle).forEach(([battleId, overrides]) => {
const context = `secureTerrainCenterOverrides/${battleId}`;
const scenario = battleScenarios[battleId];
if (!scenario) {
errors.push(`${context}: references unknown battle id`);
}
if (!Array.isArray(overrides)) {
errors.push(`${context}: overrides must be an array`);
return;
}
overrideCount += overrides.length;
const centers = new Set();
overrides.forEach((override, index) => {
const overrideContext = `${context}[${index}]`;
if (!override || typeof override !== 'object' || Array.isArray(override)) {
errors.push(`${overrideContext}: override must be an object`);
return;
}
const { x, y, terrain } = override;
const hasIntegerCenter = Number.isInteger(x) && Number.isInteger(y);
if (!hasIntegerCenter) {
errors.push(`${overrideContext}: center must use integer coordinates, received ${x},${y}`);
} else {
const centerKey = `${x},${y}`;
if (centers.has(centerKey)) {
errors.push(`${overrideContext}: duplicate override center ${centerKey}`);
}
centers.add(centerKey);
}
const hasValidTerrain = typeof terrain === 'string' && terrainKeys.has(terrain);
if (!hasValidTerrain) {
errors.push(`${overrideContext}: unknown terrain "${terrain}"`);
}
if (!scenario || !hasIntegerCenter) {
return;
}
if (!isInBounds({ x, y }, scenario.map)) {
errors.push(`${overrideContext}: center ${x},${y} is outside ${scenario.map.width}x${scenario.map.height} map`);
return;
}
const matchingObjective = hasValidTerrain
? scenario.objectives.find(
(objective) =>
objective.kind === 'secure-terrain' &&
objective.terrain === terrain &&
objective.targetTile?.x === x &&
objective.targetTile.y === y
)
: undefined;
if (hasValidTerrain && !matchingObjective) {
errors.push(`${overrideContext}: center ${x},${y}/"${terrain}" has no matching secure-terrain objective`);
}
const centerTerrain = scenario.map.terrain?.[y]?.[x];
if (hasValidTerrain && centerTerrain !== terrain) {
errors.push(`${overrideContext}: final map center ${x},${y} is "${centerTerrain}", expected "${terrain}"`);
}
});
});
return overrideCount;
}
function validateSecureTerrainObjective(errors, scenario, objective, terrainKeys, context) {
let hasValidTerrain = true;
if (typeof objective.terrain !== 'string' || objective.terrain.trim().length === 0) {
errors.push(`${context}: secure-terrain objective requires a terrain`);
hasValidTerrain = false;
} else if (!terrainKeys.has(objective.terrain)) {
errors.push(`${context}: secure-terrain objective references unknown terrain "${objective.terrain}"`);
hasValidTerrain = false;
}
const center = objective.targetTile;
if (!center || typeof center !== 'object') {
errors.push(`${context}: secure-terrain objective requires a targetTile center`);
return;
}
if (!Number.isInteger(center.x) || !Number.isInteger(center.y)) {
errors.push(`${context}: secure-terrain center must use integer coordinates, received ${center.x},${center.y}`);
return;
}
if (!isInBounds(center, scenario.map)) {
errors.push(
`${context}: secure-terrain center ${center.x},${center.y} is outside ${scenario.map.width}x${scenario.map.height} map`
);
return;
}
if (center.radius !== undefined && (!Number.isInteger(center.radius) || center.radius < 0)) {
errors.push(`${context}: secure-terrain radius must be a non-negative integer, received ${center.radius}`);
}
const terrainRow = scenario.map.terrain?.[center.y];
const centerTerrain = Array.isArray(terrainRow) ? terrainRow[center.x] : undefined;
if (hasValidTerrain && centerTerrain !== undefined && centerTerrain !== objective.terrain) {
errors.push(
`${context}: secure-terrain center ${center.x},${center.y} is "${centerTerrain}", expected "${objective.terrain}"`
);
}
}
function validateOpeningObjectiveLines(errors, scenario, context) {
if (!Array.isArray(scenario.openingObjectiveLines) || scenario.openingObjectiveLines.length === 0) {
errors.push(`${context}: openingObjectiveLines must include at least one line`);