diff --git a/scripts/verify-camp-reward-data.mjs b/scripts/verify-camp-reward-data.mjs index 02cdb2a..46bee1e 100644 --- a/scripts/verify-camp-reward-data.mjs +++ b/scripts/verify-camp-reward-data.mjs @@ -19,11 +19,13 @@ try { const campBattleIdEntries = collectCampBattleIdEntries(source, defaultBattleScenario.id); const campBattleIdKeys = new Set(campBattleIdEntries.map((entry) => entry.key)); validateCampBattleIdEntries(campBattleIdEntries, battleScenarios); + const knownBondsById = collectKnownBondsById(battleScenarios); const dialogueEvents = collectCampEvents(source, 'campDialogues'); const visitEvents = collectCampEvents(source, 'campVisits'); const campaignStepReferences = validateCampEventCollection(dialogueEvents, 'campDialogues', campBattleIdKeys, isCampaignStep) + validateCampEventCollection(visitEvents, 'campVisits', campBattleIdKeys, isCampaignStep); + const dialogueBondReferences = validateCampDialogueBondReferences(dialogueEvents, knownBondsById); validateNumericProperties('gold', { min: 1, max: 20000 }); validateNumericProperties('bondExp', { min: 0, max: 200 }); validateNumericProperties('rewardExp', { min: 1, max: 200 }); @@ -33,7 +35,7 @@ try { } console.log( - `Verified ${dialogueEvents.length} camp dialogues, ${visitEvents.length} camp visits, ${campBattleIdEntries.length} camp battle ids, ${campaignStepReferences} campaign-step references, ${itemRewardArrays.length} camp item reward lists, and camp reward numeric fields.` + `Verified ${dialogueEvents.length} camp dialogues, ${visitEvents.length} camp visits, ${campBattleIdEntries.length} camp battle ids, ${campaignStepReferences} campaign-step references, ${dialogueBondReferences} camp dialogue bonds, ${itemRewardArrays.length} camp item reward lists, and camp reward numeric fields.` ); } finally { await server.close(); @@ -127,6 +129,18 @@ function validateCampBattleIdEntries(entries, battleScenarios) { }); } +function collectKnownBondsById(battleScenarios) { + const bondsById = new Map(); + Object.values(battleScenarios).forEach((scenario) => { + (scenario.bonds ?? []).forEach((bond) => { + if (!bondsById.has(bond.id)) { + bondsById.set(bond.id, bond); + } + }); + }); + return bondsById; +} + function collectCampEvents(text, arrayName) { const { body, offset } = extractConstArray(text, arrayName); return collectTopLevelObjects(body).map((event) => ({ @@ -204,6 +218,42 @@ function validateAvailableDuringSteps(event, context, isCampaignStep) { return steps.length; } +function validateCampDialogueBondReferences(events, knownBondsById) { + let bondReferenceCount = 0; + events.forEach((event, index) => { + const context = `campDialogues[${index}]`; + const bondIdEntries = collectStringProperties(event.body, 'bondId'); + if (bondIdEntries.length === 0) { + return; + } + bondReferenceCount += bondIdEntries.length; + if (bondIdEntries.length > 1) { + errors.push(`${sourcePath}:${lineNumber(event.offset)} ${context} has multiple bondId properties`); + } + + const unitIds = collectStringArrayProperty(event.body, 'unitIds', event.offset); + if (unitIds.length === 0) { + errors.push(`${sourcePath}:${lineNumber(event.offset)} ${context} has bondId but no unitIds`); + } + + bondIdEntries.forEach((entry) => { + const bond = knownBondsById.get(entry.value); + if (!bond) { + errors.push(`${sourcePath}:${lineNumber(event.offset + entry.offset)} ${context} references unknown bondId "${entry.value}"`); + return; + } + if (!sameStringSet(unitIds.map((unit) => unit.value), bond.unitIds ?? [])) { + errors.push( + `${sourcePath}:${lineNumber(event.offset + entry.offset)} ${context} bondId "${entry.value}" units [${( + bond.unitIds ?? [] + ).join(', ')}] do not match dialogue unitIds [${unitIds.map((unit) => unit.value).join(', ')}]` + ); + } + }); + }); + return bondReferenceCount; +} + function collectStringProperties(text, propertyName) { const pattern = new RegExp(`\\b${propertyName}:\\s*'((?:\\\\'|[^'])*)'`, 'g'); return [...text.matchAll(pattern)].map((match) => ({ @@ -219,6 +269,25 @@ function collectStringLiterals(text, offset = 0) { })); } +function collectStringArrayProperty(text, propertyName, eventOffset = 0) { + const pattern = new RegExp(`\\b${propertyName}:\\s*\\[([\\s\\S]*?)\\]`); + const match = pattern.exec(text); + if (!match) { + return []; + } + const arrayBody = match[1]; + const arrayOffset = eventOffset + (match.index ?? 0) + match[0].indexOf(arrayBody); + return collectStringLiterals(arrayBody, arrayOffset); +} + +function sameStringSet(left, right) { + if (left.length !== right.length) { + return false; + } + const rightSet = new Set(right); + return left.every((value) => rightSet.has(value)); +} + function parseRewardLabel(reward) { const normalized = String(reward).replace(/\s+/g, ' ').trim(); const match = normalized.match(/^(.*?)(?:\s*[+xX]?\s*(\d+))$/); diff --git a/src/game/data/scenario.ts b/src/game/data/scenario.ts index 9c55cdf..8acf295 100644 --- a/src/game/data/scenario.ts +++ b/src/game/data/scenario.ts @@ -19157,6 +19157,14 @@ export const xuzhouRecruitBonds: BattleBond[] = [ level: 42, exp: 0, description: '미축은 서주의 민심과 군량을 묶어 유비군이 오래 버틸 수 있게 돕는다.' + }, + { + id: 'zhang-fei__mi-zhu', + unitIds: ['zhang-fei', 'mi-zhu'], + title: '군량과 호통', + level: 40, + exp: 0, + description: '장비의 거친 기세와 미축의 보급 장부가 맞물리면 객장 군영에서도 병사들의 밥줄이 흔들리지 않는다.' } ]; @@ -20189,9 +20197,29 @@ export const mengHuoFinalCaptureBonds: BattleBond[] = [ description: '조운과 마대는 빠른 기동으로 잔당을 끊되, 항복로를 밟지 않는 절제의 선을 함께 지킨다.' } ]; -export const fiftyFourthBattleBonds: BattleBond[] = [...fiftyThirdBattleBonds, ...mengHuoFinalCaptureBonds].map( - cloneBattleBondForScenario -); +export const northernCampaignPrepBonds: BattleBond[] = [ + { + id: 'huang-quan__ma-liang_northern-prep', + unitIds: ['huang-quan', 'ma-liang'], + title: '군량과 고시문', + level: 56, + exp: 0, + description: '황권의 군량 장부와 마량의 고시문이 맞물리면 북벌 출진 전 보급과 민심을 함께 붙들 수 있다.' + }, + { + id: 'wang-ping__ma-dai_northern-prep', + unitIds: ['wang-ping', 'ma-dai'], + title: '산길과 말발굽', + level: 52, + exp: 0, + description: '왕평의 산길 판단과 마대의 기병 회수가 맞물리면 좁은 북방 고개에서도 본대가 길을 잃지 않는다.' + } +]; +export const fiftyFourthBattleBonds: BattleBond[] = [ + ...fiftyThirdBattleBonds, + ...mengHuoFinalCaptureBonds, + ...northernCampaignPrepBonds +].map(cloneBattleBondForScenario); export const northernFirstBattleBonds: BattleBond[] = [ { @@ -20296,6 +20324,14 @@ export const northernThirdBattleBonds: BattleBond[] = [ exp: 0, description: '황권의 보급 장부와 법정의 책략 판단이 합쳐지면 위군의 차단선을 피할 작은 틈을 찾습니다.' }, + { + id: 'ma-liang__jiang-wei', + unitIds: ['ma-liang', 'jiang-wei'], + title: '첫 길을 적는 손', + level: 44, + exp: 0, + description: '마량의 차분한 문서와 강유의 천수 길 판단이 맞물리면 새로 합류한 장수가 촉한군의 길을 빠르게 익힙니다.' + }, { id: 'zhao-yun__ma-dai_northern-third', unitIds: ['zhao-yun', 'ma-dai'], diff --git a/src/game/scenes/CampScene.ts b/src/game/scenes/CampScene.ts index 5b80332..d708edc 100644 --- a/src/game/scenes/CampScene.ts +++ b/src/game/scenes/CampScene.ts @@ -3516,7 +3516,7 @@ const campDialogues: CampDialogue[] = [ title: '낯선 군영의 밥', availableAfterBattleIds: [campBattleIds.fifteenth], unitIds: ['zhang-fei', 'mi-zhu'], - bondId: 'liu-bei__mi-zhu', + bondId: 'zhang-fei__mi-zhu', rewardExp: 18, lines: [ '장비: 원소군 밥은 양은 많아도 속이 편치 않소. 남의 군량을 먹으니 창끝까지 남의 눈치를 보는 기분이오.', @@ -6842,7 +6842,7 @@ const campDialogues: CampDialogue[] = [ availableAfterBattleIds: [campBattleIds.fiftyFourth], availableDuringSteps: ['northern-campaign-prep-camp'], unitIds: ['huang-quan', 'ma-liang'], - bondId: 'zhuge-liang__huang-quan_final-capture', + bondId: 'huang-quan__ma-liang_northern-prep', rewardExp: 70, lines: [ '황권: 북벌은 군량이 먼저 무너지는 싸움이 될 수 있습니다. 익주와 한중의 장부를 따로 보면 길이 끊깁니다.', @@ -6870,7 +6870,7 @@ const campDialogues: CampDialogue[] = [ availableAfterBattleIds: [campBattleIds.fiftyFourth], availableDuringSteps: ['northern-campaign-prep-camp'], unitIds: ['wang-ping', 'ma-dai'], - bondId: 'zhao-yun__ma-dai_final-capture', + bondId: 'wang-ping__ma-dai_northern-prep', rewardExp: 66, lines: [ '왕평: 북쪽 산길은 지도보다 좁고, 좁은 길은 기병에게도 보병에게도 함정이 됩니다.',