Files
heros_web/scripts/verify-battle-scenario-data.mjs

700 lines
28 KiB
JavaScript

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 { 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');
const { sortieFormationRoles } = await server.ssrLoadModule('/src/game/data/sortieDeployment.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 itemNames = new Set(Object.values(itemCatalog).map((item) => item.name));
const formationRoles = new Set(sortieFormationRoles);
const tacticalGuideEventKeys = new Set([
'leader-wavering',
'ally-danger',
'village-approach',
'village-secured',
'vanguard-broken',
'cavalry-approach',
'enemy-posture'
]);
const knownRewardUnitIds = new Set([
...scenarioEntries.flatMap(([, scenario]) => scenario.units.map((unit) => unit.id)),
...(campaignRecruitUnits ?? []).map((unit) => unit.id)
]);
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`);
validateOpeningObjectiveLines(errors, scenario, context);
validateMap(errors, scenario, terrainKeys, context);
validateUnits(errors, scenario, classKeys, itemKeys, equipmentSlots, 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):`);
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);
const rewardCount = scenarioEntries.reduce(
(total, [, scenario]) => total + scenario.itemRewards.length + campaignRewardLabelCount(scenario.campaignReward),
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 (${secureTerrainObjectiveCount} secure-terrain centers, ${secureTerrainOverrideCount} explicit overrides), sortie data, ${tacticalGuideCount} tactical guides, equipment, and ${rewardCount} reward labels.`
);
}
} 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}"`);
}
validateCharacterProgress(errors, unit, unitContext);
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, 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`);
}
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.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`);
return;
}
scenario.openingObjectiveLines.forEach((line, index) => {
assertNonEmptyString(errors, line, `${context}: openingObjectiveLines[${index}]`);
});
}
function validateTacticalGuide(errors, scenario, eventKeys, context) {
const guide = scenario.tacticalGuide;
if (!guide) {
errors.push(`${context}: tacticalGuide is required`);
return;
}
assertNonEmptyString(errors, guide.summary, `${context}: tacticalGuide summary`);
assertNonEmptyString(errors, guide.route, `${context}: tacticalGuide route`);
assertNonEmptyString(errors, guide.focus, `${context}: tacticalGuide focus`);
if (!Array.isArray(guide.roles) || guide.roles.length === 0) {
errors.push(`${context}: tacticalGuide roles must include at least one role`);
} else {
guide.roles.forEach((role, index) => {
assertNonEmptyString(errors, role, `${context}: tacticalGuide roles[${index}]`);
});
}
if (guide.events === undefined) {
return;
}
if (!guide.events || typeof guide.events !== 'object' || Array.isArray(guide.events)) {
errors.push(`${context}: tacticalGuide events must be an object`);
return;
}
Object.entries(guide.events).forEach(([eventKey, event]) => {
if (!eventKeys.has(eventKey)) {
errors.push(`${context}: tacticalGuide event "${eventKey}" is not supported`);
}
if (!event || typeof event !== 'object' || Array.isArray(event)) {
errors.push(`${context}: tacticalGuide event "${eventKey}" must be an object`);
return;
}
assertNonEmptyString(errors, event.title, `${context}: tacticalGuide event "${eventKey}" title`);
if (!Array.isArray(event.lines) || event.lines.length === 0) {
errors.push(`${context}: tacticalGuide event "${eventKey}" must include at least one line`);
return;
}
event.lines.forEach((line, index) => {
assertNonEmptyString(errors, line, `${context}: tacticalGuide event "${eventKey}" lines[${index}]`);
});
});
}
function validateCharacterProgress(errors, unit, context) {
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}`);
} else if (Number.isInteger(unit.level) && unit.level < 99 && unit.exp >= 100) {
errors.push(`${context}: exp ${unit.exp} should be below 100 before max level`);
} else if (Number.isInteger(unit.level) && unit.level >= 99 && unit.exp > 100) {
errors.push(`${context}: max level exp ${unit.exp} should be at most 100`);
}
}
function validateSortie(errors, scenario, classKeys, formationRoles, terrainRules, context) {
const sortie = scenario.sortie;
if (!sortie) {
return;
}
const unitIds = new Set(scenario.units.map((unit) => unit.id));
const allyUnitIds = new Set(scenario.units.filter((unit) => unit.faction === 'ally').map((unit) => unit.id));
const enemyTiles = new Set(scenario.units.filter((unit) => unit.faction === 'enemy').map((unit) => `${unit.x},${unit.y}`));
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`);
}
if ((sortie.recommendedUnits?.length ?? 0) > sortie.sortieLimit) {
errors.push(`${context}: recommendedUnits count exceeds sortieLimit`);
}
const requiredUnits = sortie.requiredUnits ?? [];
const excludedUnits = sortie.excludedUnits ?? [];
const requiredUnitIds = new Set();
const excludedUnitIds = new Set();
requiredUnits.forEach((unitId) => {
if (requiredUnitIds.has(unitId)) {
errors.push(`${context}: duplicate required unit "${unitId}"`);
}
requiredUnitIds.add(unitId);
});
scenario.defeatConditions.forEach((condition) => {
if (allyUnitIds.has(condition.unitId) && !requiredUnitIds.has(condition.unitId)) {
errors.push(`${context}: allied defeat-condition unit "${condition.unitId}" must be required for sortie`);
}
});
excludedUnits.forEach((unitId) => {
if (excludedUnitIds.has(unitId)) {
errors.push(`${context}: duplicate excluded unit "${unitId}"`);
}
excludedUnitIds.add(unitId);
if (requiredUnitIds.has(unitId)) {
errors.push(`${context}: unit "${unitId}" cannot be both required and excluded`);
}
});
[...requiredUnits, ...excludedUnits].forEach((unitId) => {
if (!unitIds.has(unitId)) {
errors.push(`${context}: sortie references missing unit "${unitId}"`);
} else if (!allyUnitIds.has(unitId)) {
errors.push(`${context}: sortie references non-allied unit "${unitId}"`);
}
});
const recommendedUnitIds = new Set();
const recommendedRoleByUnitId = new Map();
sortie.recommendedUnits.forEach((recommendation) => {
if (recommendedUnitIds.has(recommendation.unitId)) {
errors.push(`${context}: duplicate recommended unit "${recommendation.unitId}"`);
}
recommendedUnitIds.add(recommendation.unitId);
if (!unitIds.has(recommendation.unitId)) {
errors.push(`${context}: recommended unit "${recommendation.unitId}" is missing from units`);
} else if (!allyUnitIds.has(recommendation.unitId)) {
errors.push(`${context}: recommended unit "${recommendation.unitId}" is not allied`);
}
if (excludedUnitIds.has(recommendation.unitId)) {
errors.push(`${context}: excluded unit "${recommendation.unitId}" cannot also be recommended`);
}
if (recommendation.role && !formationRoles.has(recommendation.role)) {
errors.push(`${context}/${recommendation.unitId}: invalid recommendation role "${recommendation.role}"`);
}
if (recommendation.role) {
recommendedRoleByUnitId.set(recommendation.unitId, recommendation.role);
}
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`);
const recommendedClassKeys = Array.isArray(recommendation.classKeys) ? recommendation.classKeys : [];
if (!Array.isArray(recommendation.classKeys) || recommendation.classKeys.length === 0) {
errors.push(`${context}/${recommendation.label}: recommended class must include at least one classKey`);
}
const availableRecommendedUnits = scenario.units.filter(
(unit) => unit.faction === 'ally' && !excludedUnitIds.has(unit.id) && recommendedClassKeys.includes(unit.classKey)
);
if (availableRecommendedUnits.length === 0) {
errors.push(`${context}/${recommendation.label}: recommended class has no available allied unit`);
}
recommendedClassKeys.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 ${sortie.deploymentSlots.length} does not match sortieLimit ${sortie.sortieLimit}`
);
}
const deploymentTiles = new Set();
const deploymentUnitIds = new Set();
sortie.deploymentSlots.forEach((slot) => {
if (!isInBounds(slot, scenario.map)) {
errors.push(`${context}: deployment slot ${slot.x},${slot.y} is outside map`);
return;
}
if (slot.role && !formationRoles.has(slot.role)) {
errors.push(`${context}: deployment slot ${slot.x},${slot.y} has invalid role "${slot.role}"`);
}
const tileKey = `${slot.x},${slot.y}`;
const terrain = scenario.map.terrain[slot.y][slot.x];
if (terrainRules[terrain]?.passable === false) {
errors.push(`${context}: deployment slot ${tileKey} is on impassable terrain "${terrain}"`);
}
if (enemyTiles.has(tileKey)) {
errors.push(`${context}: deployment slot ${tileKey} overlaps an enemy unit`);
}
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}"`);
} else if (slot.unitId && !allyUnitIds.has(slot.unitId)) {
errors.push(`${context}: deployment slot references non-allied unit "${slot.unitId}"`);
}
if (slot.unitId && excludedUnitIds.has(slot.unitId)) {
errors.push(`${context}: excluded unit "${slot.unitId}" cannot have a deployment slot`);
}
if (slot.unitId && !requiredUnitIds.has(slot.unitId) && !recommendedUnitIds.has(slot.unitId)) {
errors.push(`${context}: deployment slot unit "${slot.unitId}" must be required or recommended`);
}
const recommendedRole = slot.unitId ? recommendedRoleByUnitId.get(slot.unitId) : undefined;
if (slot.role && recommendedRole && slot.role !== recommendedRole) {
errors.push(
`${context}: deployment slot unit "${slot.unitId}" role "${slot.role}" does not match recommendation role "${recommendedRole}"`
);
}
if (slot.unitId) {
if (deploymentUnitIds.has(slot.unitId)) {
errors.push(`${context}: duplicate deployment unit slot "${slot.unitId}"`);
}
deploymentUnitIds.add(slot.unitId);
}
});
requiredUnits.forEach((unitId) => {
if (!deploymentUnitIds.has(unitId)) {
errors.push(`${context}: required unit "${unitId}" must have an explicit deployment slot`);
}
});
recommendedUnitIds.forEach((unitId) => {
if (!deploymentUnitIds.has(unitId)) {
errors.push(`${context}: recommended unit "${unitId}" must have an explicit deployment slot`);
}
});
}
function validateRewards(errors, scenario, scenarioIds, itemNames, knownRewardUnitIds, context) {
validateRewardLabels(errors, scenario.itemRewards, `${context}: itemRewards`);
const campaignReward = scenario.campaignReward;
if (campaignReward) {
validateRewardLabels(errors, campaignReward.supplies ?? [], `${context}: campaignReward.supplies`);
validateRewardLabels(errors, campaignReward.equipment ?? [], `${context}: campaignReward.equipment`);
validateRewardLabels(errors, campaignReward.reputation ?? [], `${context}: campaignReward.reputation`, { allowSignedAmount: true });
validateRecruitRewards(errors, campaignReward.recruits ?? [], knownRewardUnitIds, context);
validateEquipmentRewardLabels(errors, campaignReward.equipment ?? [], itemNames, context);
if (campaignReward.unlockLabel !== undefined) {
assertNonEmptyString(errors, campaignReward.unlockLabel, `${context}: campaignReward unlockLabel`);
}
if (campaignReward.note !== undefined) {
assertNonEmptyString(errors, campaignReward.note, `${context}: campaignReward note`);
}
}
const unlockBattleId = campaignReward?.unlockBattleId;
if (unlockBattleId && !scenarioIds.has(unlockBattleId)) {
errors.push(`${context}: campaignReward unlockBattleId "${unlockBattleId}" is not a known battle`);
}
}
function validateRewardLabels(errors, rewards, context, options = {}) {
if (!Array.isArray(rewards)) {
errors.push(`${context} must be an array`);
return;
}
const labels = new Set();
rewards.forEach((reward, index) => {
assertNonEmptyString(errors, reward, `${context}[${index}]`);
if (typeof reward !== 'string') {
return;
}
const parsed = parseRewardLabel(reward);
if (!parsed.label) {
errors.push(`${context}[${index}] has an empty parsed label`);
}
if (!Number.isInteger(parsed.amount) || parsed.amount < 1) {
if (!options.allowSignedAmount || !Number.isInteger(parsed.amount) || parsed.amount === 0) {
errors.push(`${context}[${index}] has invalid amount ${parsed.amount}`);
}
}
if (labels.has(parsed.label)) {
errors.push(`${context}: duplicate reward label "${parsed.label}"`);
}
labels.add(parsed.label);
});
}
function validateRecruitRewards(errors, recruits, knownRewardUnitIds, context) {
const recruitIds = new Set();
recruits.forEach((unitId, index) => {
assertNonEmptyString(errors, unitId, `${context}: campaignReward.recruits[${index}]`);
if (typeof unitId !== 'string') {
return;
}
if (!knownRewardUnitIds.has(unitId)) {
errors.push(`${context}: campaignReward recruit "${unitId}" is not a known battle or campaign recruit unit`);
}
if (recruitIds.has(unitId)) {
errors.push(`${context}: duplicate campaignReward recruit "${unitId}"`);
}
recruitIds.add(unitId);
});
}
function validateEquipmentRewardLabels(errors, equipmentRewards, itemNames, context) {
equipmentRewards.forEach((reward) => {
const { label } = parseRewardLabel(reward);
if (itemNames.has(label) || looksLikeCampaignRecord(label)) {
return;
}
errors.push(`${context}: campaignReward equipment "${reward}" is neither an equipment catalog item nor a campaign record/map label`);
});
}
function parseRewardLabel(reward) {
const normalized = String(reward).replace(/\s+/g, ' ').trim();
const match = normalized.match(/^(.*?)(?:\s*[+xX]?\s*(\d+))$/);
if (!match) {
return { label: normalized, amount: 1 };
}
return {
label: match[1].trim() || normalized,
amount: Number(match[2] ?? 1)
};
}
function looksLikeCampaignRecord(label) {
return /기록|문서|장부|지도|표식|서찰|명단|명부|초안|부적|군령패|통행문서|방화선|말뚝|항복문|물길|병법서|봉화도|외곽도|수위표|성문도|정찰|진군|퇴각|회유|서약|고시문|길잡이|소문|풍문|논의|준비|명성|공적|민심|신뢰|명분|예우|공훈/.test(label);
}
function campaignRewardLabelCount(campaignReward) {
if (!campaignReward) {
return 0;
}
return (
(campaignReward.supplies?.length ?? 0) +
(campaignReward.equipment?.length ?? 0) +
(campaignReward.reputation?.length ?? 0) +
(campaignReward.recruits?.length ?? 0) +
(campaignReward.unlockBattleId ? 1 : 0)
);
}
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`);
}
}