From 788e2ce21a68f38c61dee6d077bf74d6781563b3 Mon Sep 17 00:00:00 2001 From: Wickedness Date: Sun, 5 Jul 2026 10:42:46 +0900 Subject: [PATCH] Guard camp event ids --- scripts/verify-camp-reward-data.mjs | 175 +++++++++++++++++++++++++++- 1 file changed, 174 insertions(+), 1 deletion(-) diff --git a/scripts/verify-camp-reward-data.mjs b/scripts/verify-camp-reward-data.mjs index 248964b..aa789ac 100644 --- a/scripts/verify-camp-reward-data.mjs +++ b/scripts/verify-camp-reward-data.mjs @@ -6,6 +6,11 @@ const errors = []; const itemRewardArrays = collectItemRewardArrays(source); itemRewardArrays.forEach(({ body, index, offset }) => validateItemRewards(body, index, offset)); +const campBattleIdKeys = collectCampBattleIdKeys(source); +const dialogueEvents = collectCampEvents(source, 'campDialogues'); +const visitEvents = collectCampEvents(source, 'campVisits'); +validateCampEventCollection(dialogueEvents, 'campDialogues'); +validateCampEventCollection(visitEvents, 'campVisits'); validateNumericProperties('gold', { min: 1, max: 20000 }); validateNumericProperties('bondExp', { min: 0, max: 200 }); validateNumericProperties('rewardExp', { min: 1, max: 200 }); @@ -14,7 +19,9 @@ if (errors.length > 0) { throw new Error(`Camp reward data verification failed:\n${errors.map((error) => `- ${error}`).join('\n')}`); } -console.log(`Verified ${itemRewardArrays.length} camp item reward lists and camp reward numeric fields.`); +console.log( + `Verified ${dialogueEvents.length} camp dialogues, ${visitEvents.length} camp visits, ${itemRewardArrays.length} camp item reward lists, and camp reward numeric fields.` +); function collectItemRewardArrays(text) { return [...text.matchAll(/itemRewards:\s*\[([\s\S]*?)\]/g)].map((match, index) => ({ @@ -56,6 +63,73 @@ function validateNumericProperties(propertyName, { min, max }) { } } +function collectCampBattleIdKeys(text) { + const objectSource = extractConstObject(text, 'campBattleIds'); + return new Set([...objectSource.matchAll(/^\s*(\w+):/gm)].map((match) => match[1])); +} + +function collectCampEvents(text, arrayName) { + const { body, offset } = extractConstArray(text, arrayName); + return collectTopLevelObjects(body).map((event) => ({ + ...event, + offset: offset + event.offset + })); +} + +function validateCampEventCollection(events, collectionName) { + const seenIds = new Map(); + if (events.length === 0) { + errors.push(`${sourcePath}: ${collectionName} has no entries`); + } + + events.forEach((event, index) => { + const context = `${collectionName}[${index}]`; + const idEntries = collectStringProperties(event.body, 'id'); + const eventId = idEntries[0]?.value ?? ''; + if (!eventId.trim()) { + errors.push(`${sourcePath}:${lineNumber(event.offset)} ${context} has no id`); + } else if (seenIds.has(eventId)) { + errors.push( + `${sourcePath}:${lineNumber(event.offset)} ${context} duplicates id "${eventId}" first used at ${sourcePath}:${seenIds.get( + eventId + )}` + ); + } else { + seenIds.set(eventId, lineNumber(event.offset)); + } + + const choiceIds = idEntries.slice(1).map((entry) => entry.value); + const seenChoiceIds = new Set(); + choiceIds.forEach((choiceId, choiceIndex) => { + if (!choiceId.trim()) { + errors.push(`${sourcePath}:${lineNumber(event.offset)} ${context}.choices[${choiceIndex}] has an empty id`); + } + if (seenChoiceIds.has(choiceId)) { + errors.push(`${sourcePath}:${lineNumber(event.offset)} ${context} has duplicate choice id "${choiceId}"`); + } + seenChoiceIds.add(choiceId); + }); + + const availableMatch = /availableAfterBattleIds:\s*\[([\s\S]*?)\]/.exec(event.body); + if (!availableMatch || availableMatch[1].trim().length === 0) { + errors.push(`${sourcePath}:${lineNumber(event.offset)} ${context} has no availableAfterBattleIds`); + } + for (const reference of event.body.matchAll(/campBattleIds\.(\w+)/g)) { + if (!campBattleIdKeys.has(reference[1])) { + errors.push(`${sourcePath}:${lineNumber(event.offset)} ${context} references unknown campBattleIds.${reference[1]}`); + } + } + }); +} + +function collectStringProperties(text, propertyName) { + const pattern = new RegExp(`\\b${propertyName}:\\s*'((?:\\\\'|[^'])*)'`, 'g'); + return [...text.matchAll(pattern)].map((match) => ({ + value: match[1].replace(/\\'/g, "'"), + offset: match.index ?? 0 + })); +} + function parseRewardLabel(reward) { const normalized = String(reward).replace(/\s+/g, ' ').trim(); const match = normalized.match(/^(.*?)(?:\s*[+xX]?\s*(\d+))$/); @@ -72,3 +146,102 @@ function parseRewardLabel(reward) { function lineNumber(offset) { return source.slice(0, offset).split(/\r?\n/).length; } + +function extractConstObject(text, objectName) { + const startPattern = new RegExp(`const\\s+${objectName}\\b[^=]*=\\s*\\{`); + const match = startPattern.exec(text); + if (!match) { + throw new Error(`Cannot find ${objectName}.`); + } + const start = match.index + match[0].length - 1; + const end = findMatchingDelimiter(text, start, '{', '}'); + return text.slice(start, end + 1); +} + +function extractConstArray(text, arrayName) { + const startPattern = new RegExp(`const\\s+${arrayName}\\b[\\s\\S]*?=\\s*\\[`); + const match = startPattern.exec(text); + if (!match) { + throw new Error(`Cannot find ${arrayName}.`); + } + const start = match.index + match[0].length - 1; + const end = findMatchingDelimiter(text, start, '[', ']'); + return { + body: text.slice(start + 1, end), + offset: start + 1 + }; +} + +function collectTopLevelObjects(text) { + const objects = []; + let depth = 0; + let start = -1; + let quote = ''; + let escaped = false; + + for (let index = 0; index < text.length; index += 1) { + const char = text[index]; + if (quote) { + if (escaped) { + escaped = false; + } else if (char === '\\') { + escaped = true; + } else if (char === quote) { + quote = ''; + } + continue; + } + if (char === "'" || char === '"' || char === '`') { + quote = char; + continue; + } + if (char === '{') { + if (depth === 0) { + start = index; + } + depth += 1; + } else if (char === '}') { + depth -= 1; + if (depth === 0 && start >= 0) { + objects.push({ body: text.slice(start, index + 1), offset: start }); + start = -1; + } + } + } + + return objects; +} + +function findMatchingDelimiter(text, start, openChar, closeChar) { + let depth = 0; + let quote = ''; + let escaped = false; + + for (let index = start; index < text.length; index += 1) { + const char = text[index]; + if (quote) { + if (escaped) { + escaped = false; + } else if (char === '\\') { + escaped = true; + } else if (char === quote) { + quote = ''; + } + continue; + } + if (char === "'" || char === '"' || char === '`') { + quote = char; + continue; + } + if (char === openChar) { + depth += 1; + } else if (char === closeChar) { + depth -= 1; + if (depth === 0) { + return index; + } + } + } + + throw new Error(`Cannot find matching ${closeChar} from offset ${start}.`); +}