/* * faction-voice-lint.mjs — THE GRAMMAR RULES FOR WRITABLE COPY. * ----------------------------------------------------------------------------- * Side-quest copy is written ONCE and resolved MANY ways: one body reaches three * different boards, because `data/sidequests.json` descriptions carry {faction} / * {issuer} / {enemy} / {enemies} / {system}. Two bug classes survive "looks fine * in the pool" and only appear once a name is dropped in: * * 1. ARTICLE DISAGREEMENT — `an {enemy} hull` reads fine until it resolves to * “an Apex hull”. An article can never be right for every resolution, so NO * article may be written in front of a placeholder * (`bareArticleBeforePlaceholder`), and whatever still disagrees after * resolution is caught by `articleDisagreements`. * 2. DOUBLING — `{enemy} {enemy}` ships “MindWeave MindWeave intercept * profiles”, and any repeated word reads as a generator fault. Checked on * the RESOLVED string, because resolution itself creates the duplicate. * * Shared by dev/sidequests.test.mjs (every test run, no browser) and * dev/jobboard-voice-shot.mjs (the real panel). The point: the copy is audited in * EVERY resolution it can take, not just the one a given seed happened to draw. */ const VOWELS = /^[aeiouAEIOU]/; // Words that OPEN with a consonant sound despite the vowel letter (“a user”, // “a union”) and the silent-H exceptions that keep “an”. const GLIDE = /^(?:one|once|unit|unity|usage|user|utility|union|unified|universal|eulogy)/i; const SILENT_H = /^(?:hour|hourly|honest|honour|heir)/i; /** "a" before a vowel sound / "an" before a consonant sound — the mismatch list. */ export function articleDisagreements(text) { const out = []; for (const m of String(text).matchAll(/\b([Aa]n?)\s+([A-Za-z][A-Za-z0-9'-]*)/g)) { const art = m[1].toLowerCase(); const word = m[2]; if (GLIDE.test(word) || (SILENT_H.test(word) && art === 'an')) continue; if (art === 'a' && VOWELS.test(word)) out.push(m[0]); else if (art === 'an' && !VOWELS.test(word)) out.push(m[0]); } return [...new Set(out)]; } /** Any word repeated back-to-back — including the resolved-faction doubling. */ export function repeatedWords(text) { const out = []; for (const m of String(text).matchAll(/\b([\p{L}][\p{L}'’-]+)(?:\s+)\1\b/gu)) out.push(m[0]); return [...new Set(out)]; } /** An article written directly in front of a placeholder can never be right. */ export function bareArticleBeforePlaceholder(text) { return [...new Set(String(text).match(/\b(?:An?|an?)\s*\{[a-zA-Z]+\}/g) || [])]; } /** Braces that survived resolution (an unknown token ships literally). */ export function unresolvedBraces(text) { return [...new Set(String(text).match(/\{[^}]*\}?/g) || [])]; } /** * Assert one piece of WRITABLE copy against EVERY resolution it can take. * `resolutions` = the fully resolved strings that single written string becomes * (own faction × each possible rival). Throws naming the offending fragment. */ export function assertVoiceClean(what, written, resolutions = []) { const problems = []; const bare = bareArticleBeforePlaceholder(written); if (bare.length) problems.push(`article welded to a placeholder (${bare.join(', ')}) — no resolution can make it read`); for (const r of resolutions) { const bad = articleDisagreements(r); if (bad.length) problems.push(`article disagrees once resolved (${bad.map((x) => `"${x}"`).join(', ')}): ${r}`); const rep = repeatedWords(r); if (rep.length) problems.push(`word doubled once resolved (${rep.map((x) => `"${x}"`).join(', ')}): ${r}`); const braces = unresolvedBraces(r); if (braces.length) problems.push(`placeholder never resolved ${braces.join(', ')}: ${r}`); } if (problems.length) throw new Error(`${what}\n ${problems.join('\n ')}`); }