diff --git a/scripts/verify-battle-scenario-data.mjs b/scripts/verify-battle-scenario-data.mjs index 0348a4b..8a1abeb 100644 --- a/scripts/verify-battle-scenario-data.mjs +++ b/scripts/verify-battle-scenario-data.mjs @@ -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) && diff --git a/src/game/data/battles.ts b/src/game/data/battles.ts index fed940e..f3edb75 100644 --- a/src/game/data/battles.ts +++ b/src/game/data/battles.ts @@ -8458,7 +8458,7 @@ export const fiftySeventhBattleScenario: BattleScenarioDefinition = { campaignReward: { supplies: ['콩 64', '상처약 48', '탁주 24'], equipment: ['가정 물길 장부 1'], - recruits: ['강유'], + recruits: ['jiang-wei'], reputation: ['왕평의 지형 판단 1'], unlockLabel: '기산 후퇴로', note: