78 lines
3.8 KiB
JavaScript
78 lines
3.8 KiB
JavaScript
/*
|
||
* 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 ')}`);
|
||
}
|