orbit/dev/lib/faction-voice-lint.mjs

78 lines
3.8 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/*
* 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 ')}`);
}