Validate battle reward data

This commit is contained in:
2026-07-05 08:19:03 +09:00
parent be33a15cb6
commit 85500e419a
2 changed files with 119 additions and 8 deletions

View File

@@ -8,6 +8,7 @@ const server = await createServer({
try {
const { battleScenarios } = await server.ssrLoadModule('/src/game/data/battles.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');
@@ -16,6 +17,11 @@ try {
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 knownRewardUnitIds = new Set([
...scenarioEntries.flatMap(([, scenario]) => scenario.units.map((unit) => unit.id)),
...(campaignRecruitUnits ?? []).map((unit) => unit.id)
]);
const errors = [];
scenarioEntries.forEach(([scenarioId, scenario]) => {
@@ -31,7 +37,7 @@ try {
validateUnits(errors, scenario, classKeys, itemKeys, equipmentSlots, context);
validateObjectives(errors, scenario, context);
validateSortie(errors, scenario, classKeys, context);
validateRewards(errors, scenario, scenarioIds, context);
validateRewards(errors, scenario, scenarioIds, itemNames, knownRewardUnitIds, context);
});
if (errors.length) {
@@ -43,7 +49,13 @@ try {
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.`);
const rewardCount = scenarioEntries.reduce(
(total, [, scenario]) => total + scenario.itemRewards.length + campaignRewardLabelCount(scenario.campaignReward),
0
);
console.log(
`Verified ${scenarioEntries.length} battle scenarios, ${unitCount} unit placements, maps, objectives, sortie data, equipment, and ${rewardCount} reward labels.`
);
}
} finally {
await server.close();
@@ -233,17 +245,116 @@ function validateSortie(errors, scenario, classKeys, context) {
});
}
function validateRewards(errors, scenario, scenarioIds, context) {
scenario.itemRewards.forEach((reward, index) => {
assertNonEmptyString(errors, reward, `${context}: item reward ${index}`);
});
function validateRewards(errors, scenario, scenarioIds, itemNames, knownRewardUnitIds, context) {
validateRewardLabels(errors, scenario.itemRewards, `${context}: itemRewards`);
const unlockBattleId = scenario.campaignReward?.unlockBattleId;
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) &&

View File

@@ -8458,7 +8458,7 @@ export const fiftySeventhBattleScenario: BattleScenarioDefinition = {
campaignReward: {
supplies: ['콩 64', '상처약 48', '탁주 24'],
equipment: ['가정 물길 장부 1'],
recruits: ['강유'],
recruits: ['jiang-wei'],
reputation: ['왕평의 지형 판단 1'],
unlockLabel: '기산 후퇴로',
note: