diff --git a/README.md b/README.md index b5f4483..37bd4f3 100644 --- a/README.md +++ b/README.md @@ -48,7 +48,7 @@ packs or regenerate art on that machine: ```bash npm install # pulls classic-level, used to write the LevelDB packs npm run build # rebuilds packs/ from tools/content.mjs -npm run check # the seventeen guards; the build refuses to run if they fail +npm run check # the twenty-three guards; the build refuses to run if they fail ``` To update a deployed server: `git pull` and restart Foundry. If the pull touches @@ -64,9 +64,9 @@ changing underneath a running server. |---|---| | **Postings** | 25, each with a key characteristic, trained skills, talents and its own kit | | **Trades** | 10 ordinary jobs the department recruits from, plus induction | -| **Catalogue** | 236 items — 123 gear, 62 weapons, 32 armour, 19 vehicles | +| **Catalogue** | 244 items — 123 gear, 70 weapons, 32 armour, 19 vehicles | | **Skills / talents** | 62 skills, 19 talents | -| **Bestiary** | 47 statblocks across folklore, horror and the far side | +| **Bestiary** | 184 statblocks across folklore, horror and the far side | | **Eras** | modern, antique, future, anomalous — the agency is old and never abolishes anything | | **Icons** | 292, all generated from `tools/icons.mjs`; no external art | | **Starter scenario** | **BLACK PLATFORM**, a six-player action starter, importable as a single Adventure | @@ -157,7 +157,7 @@ icons/ fonts/ art/ generated art packs/ built LevelDB compendia (committed — see Deploying) tools/ content.mjs the catalogue: skills, weapons, armour, gear, vehicles, NPCs - build-packs.mjs builds packs/ — runs the seventeen guards first and refuses on failure + build-packs.mjs builds packs/ — runs the twenty-three guards first and refuses on failure make-icons.mjs draws all 231 icons rules-text.mjs generates the rules journal FROM rules.mjs mission.mjs the case generator @@ -178,27 +178,32 @@ stops being identity-equal to what it aliases, if a constant is re-declared as a literal, or if an exported rule has no spot-check. The packs used to be built under one set of numbers and played under another; this makes that impossible to ship. -The build runs all seventeen guards before it writes anything: +The build runs all twenty-three guards before it writes anything: ``` -check-rules: OK — 112 rules, 49 files scanned, 3 aliases + 46 constant sets checked, 413 formulas verified -check-kits: OK — 25 roles, 10 trades, 218 catalogue items, every kit key resolves, every posting can use what it carries, every loadout distinct -check-lang: OK — en.json, 1018 keys, no leaf/branch collisions +check-seam: OK — 70 modules scanned, only all-specs.mjs reads NPCS/PREGENS from content.mjs +check-generator: OK — 210 posting × tier pairs draw from their own stores, 129 weapons issued, each with the skill it names +check-rules: OK — 113 rules, 68 files scanned, 3 aliases + 46 constant sets checked, 417 formulas verified +check-kits: OK — 25 roles, 10 trades, 226 catalogue items, every kit key resolves, every posting can use what it carries, every loadout distinct +check-lang: OK — en.json, 1027 keys, no leaf/branch collisions check-templates: OK — 19 templates compile -check-behaviour: OK — 76 behavioural tests -check-scenarios: OK — 14 scenario files, 83 mechanic tags, every skill named resolves against 62 catalogue entries -check-rollable: OK — 82 rolls across 14 scenario files, every one reachable at 25% or better by somebody present -check-creatures: OK — 102 actor specs across 8 sources, every characteristic, skill, kit key, talent, species and style resolves, no duplicate keys -check-powers: OK — 41 powers classified, 2 wired and each one measurably changes its fight, 14 recorded as beyond the harness with a reason, 25 not fight rules +check-behaviour: OK — 108 behavioural tests +check-scenarios: OK — 30 scenario files, 111 mechanic tags, every skill named resolves against 62 catalogue entries +check-rollable: OK — 101 rolls across 10 playable scenarios (records and art sheets are not rolls), every one reachable at 25% or better by somebody present +check-outcomes: OK — 21 beats, 18 state a failure, 6 a fumble, 8 a special; 81.3% of sessions hit a special with nothing written, down from 83.7% recorded +check-creatures: OK — 204 actor specs across 13 sources, every characteristic, skill, kit key, talent, species and style resolves, no duplicate keys +check-powers: OK — 142 powers classified, 2 wired and each one measurably changes its fight and is accounted for in the bestiary, 78 recorded as beyond the harness with a reason, 62 not fight rules check-anatomy: OK — 5 body plans, 36 locations, d20 complete in both modes, every location drawn, every kind handled and destructible, 20 plan changes carried 432 wounds with their severity intact, 1 flyer(s) with a survivable landing -check-lethality: OK — 47 creatures, every one fighting exactly as recorded against the frozen party -check-focus: OK — focus fire measured against 38 packs, helps in all of them, 36 of them by more than their own noise +check-lethality: OK — 184 creatures, every one fighting exactly as recorded against the frozen party +check-focus: OK — focus fire measured against 138 packs, helps in 137 of them, 130 by more than their own noise; no measurable help against The dun cow, named in BESTIARY.md check-firstblood: OK — the first disabling blow is worth 40 points of wipe rate in the four-player cut, 15.4 in the six-a-side line check-attackers: OK — three of the four-player cut disable on 11.1% of their attacks or better, and one of them on 0.1% check-fight-tail: OK — a fight past 15 rounds wipes the party 1.4x as often in the four-player cut and 2.4x at six a side, and nothing was truncated -check-cited: OK — 25 cited figures across 14 scenario files resolve against 3 baselines +check-packs: OK — 17 pack fights, 2000 runs each at seed 11, nothing truncated at 400 rounds, every figure matching the record +check-cited: OK — 187 cited figures across 30 scenario files resolve against 8 baselines +check-figures: 6 figure(s) waived, each on the record: check-handouts: OK — 4 handouts, the two almanac pages identical in structure and columns, every line 41 miles from clean ground, nothing under 11pt -check-bestiary: OK — 47 creatures, the document matches the game +check-bestiary: OK — 184 creatures, the document matches the game ``` diff --git a/docs/NPC_KIT_PLAN.md b/docs/NPC_KIT_PLAN.md index 8a61996..602b82b 100644 --- a/docs/NPC_KIT_PLAN.md +++ b/docs/NPC_KIT_PLAN.md @@ -4,7 +4,10 @@ Generated NPCs arrive with no gear, no armour and one of six weapons, while the that generates them prints the full loadout they were supposed to draw. This closes that gap, and takes the weapon vocabulary from six keys to whatever the posting actually carries. -Status: design approved, not yet built. +Status: **built** — see R-310 in `docs/REVIEW_LOG.md` for the measured cost. +One claim below is wrong and is corrected there: `breach_suit` and `riot_shield` declare no +`covers` field, so they cover every location including the head. The location-based +counterplay this document relies on does not currently exist. --- diff --git a/docs/REVIEW_LOG.md b/docs/REVIEW_LOG.md index 53110d4..42f209e 100644 --- a/docs/REVIEW_LOG.md +++ b/docs/REVIEW_LOG.md @@ -7835,3 +7835,116 @@ With a figure defect planted, `check-behaviour` now runs all 108 tests green and string tests, and it was found because those same tests print their own name — the second time in one evening that the string tests paid for themselves in a way nobody designed. The general rule for this repo: **a module a guard imports must not be a guard that runs.** + +--- + +## R-310 — the card promised nine things and the actor was handed a knife + +**Every one of the 36 postings carries a `kit` array, `generateDialog` prints it on the role +card as DRAWS FROM STORES, and `generateNPC` never read it.** It read `R.core`, `R.support` +and `R.label`, and issued whichever of six hardcoded weapons its tier named. So the card +promised a Containment officer a marksman rifle, a breaching suit, an entry bar, a riot +shield, restraints, breaching charges, bolt croppers, cordon tape and ear defenders, and the +actor that appeared was holding a utility knife with a flat `naturalArmour` of 3 standing in +for the armour it never received. + +**The card was never wrong about the posting.** It looked the kit up and printed it faithfully. +Nothing measured whether the actor agreed with it, which is the only reason this survived +36 postings and six tiers. + +### The shape of the fix + +`postings.mjs` gains `drawKitFor(roleKey, tierKey, { classify })` and one `draw` table per +tier. The posting says what it carries; the tier says how much of it. `generateNPC` calls that +function and resolves the keys against the packs. + +**Classification is by catalogue membership, passed in by the caller, and never inferred from +the key.** Containment's `climbing_kit` is named "Breaching charges" and its `prybar` is +"Entry bar" — the keys have drifted from the names they were coined for, so anything reading +meaning out of a key's spelling is wrong in that one posting before anywhere else. The engine +supplies a classifier built from the compendium packs and `check-generator` supplies one built +from `tools/content.mjs`; the two cannot import each other, and they no longer need to. + +**The weapon↔skill pairing table is gone, because the weapon already names its own skill.** +Every weapon item carries `skillFamilyId` and `skillSpecialisationId`, so for each weapon +issued the generator grants the skill that weapon names. A generated NPC cannot hold something +it cannot use, because the thing it is holding chose the skill. `THREATS.attacks` now applies +only to the two creature tiers, which have no posting to draw from and keep their natural +attacks on top of whatever they drew. + +### The guard was written first and failed + +`tools/check-generator.mjs` was written before any of the above and run against unchanged +source, where it reported nine problems — six missing `draw` tables, the absent decision +function, and the two that name the defect itself: *generateNPC never calls drawKitFor* and +*generateNPC applies THREATS.attacks with no creature-tier restriction above it*. + +**One of its assertions was wrong in a way worth recording.** The creature-tier assertion read +the 400 characters *after* `TH.attacks` looking for the restriction, and the fix puts the +restriction *before* the loop it guards — so it reported the corrected code as broken. Scoped +to the text preceding the loop, it passes on the fix and, checked against `git show +HEAD:ringbrp.mjs`, still fails on the original. A guard is not proven by going green. + +### What the change costs, measured + +`tools/npc-cohort.mjs` builds a generated NPC as a simulator spec in both configurations and +measures both against the four agents `check-lethality` freezes. 2000 runs, seed 11. + +| tier · posting | wipe% now | wipe% before | Δ | PC deaths/run | armour | +|---|---|---|---|---|---| +| Apex · Containment | **88.5** | 68.2 | +20.4 | 2.76 ← 2.44 | hide 7 + suit 7 + shield 3 | +| Apex · Field Lead | 81.4 | 76.0 | +5.3 | 2.66 ← 2.57 | hide 7 + soft armour 3 | +| Apex · Physician | 51.0 | 46.2 | +4.8 | 2.06 ← 1.95 | hide 7 + stab vest 2 | +| Anomalous · Containment | **19.2** | 0.3 | +18.9 | 1.18 ← 0.19 | hide 5 + suit 7 | +| Anomalous · Field Lead | 0.4 | 0.3 | +0.1 | 0.26 ← 0.22 | hide 5 + soft armour 3 | +| Dangerous · Containment | 0.9 | 0.1 | +0.9 | 0.32 ← 0.07 | suit 7 | +| Dangerous · Field Lead | 0.0 | 0.0 | +0.0 | 0.01 ← 0.07 | soft armour 3 | + +**Anomalous · Containment is the number to look at, not the Apex.** It went from 0.3% to 19.2% +— sixty-four times the wipe rate — and it is the one a GM is most likely to stand up casually. +The Apex was already a party-killer at 68%; this makes it worse, and the design said in advance +that it should. + +**Dangerous · Field Lead got weaker, and that is the design working.** It used to be issued the +tier's rifle and baton; it now draws what the posting actually carries, which is one pistol. +PC deaths per run fell from 0.07 to 0.01. A posting with light kit produces a lighter NPC — +which is the point of asking the posting — but it means the tier name no longer predicts the +threat on its own. + +### Three things found while measuring, only one of them the task + +**1 · The stated counterplay does not exist.** `docs/NPC_KIT_PLAN.md` argued the 17 points on an +Apex Containment torso were survivable because "armour is by location" and "`breach_suit` does +not cover everything". It does. `breach_suit` and `riot_shield` declare no `covers` field, and +`buildCombatant` reads `a.covers ?? "all"` — so both cover every location including the head, +and 12 of the 32 armours are in the same position. **Not changed here.** It is a content and +balance decision, one packed spec (`rakhi`) would move with it, and it should be taken +deliberately rather than as a side effect of a generator fix. The design document's claim is +wrong and is marked as such. + +**2 · My own harness published a twenty-point change as two-tenths of a point.** `measure()` +returns `wipeRate` as a fraction. The first table printed it with one decimal under a `wipe%` +header, so Apex · Containment read "0.9" — and the first conclusion drawn from it was that the +armour change barely mattered. `check-lethality:100` has multiplied by 100 since it was +written. **The instrument was wrong in the direction that made the result uninteresting**, +which is the direction that does not get checked. + +**3 · `check-rules` failed on a comment.** The comment explaining why the code below it avoids +the `Math.max(0, … armour …)` shape contained that shape, and the guard scans lines without +regard to whether they execute. Comment-only lines are skipped now, and a planted +`Math.max(0, 10 - armour)` on a code line still trips it. A guard that reads prose as code +makes the honest documentation of a near-miss unwritable. + +### And the list that had gone stale four times + +`update-readme` held the guard list as a hardcoded array. Two comments above that array already +recorded it going stale — four long while the suite was seven, then missing `check-anatomy` — +and the response at the time was to keep the array and add a check that it matched +`package.json`. That converted a silent omission into a loud one, which is an improvement, and +it is still a hand-maintained list of valid values: it stopped the build twice more this week, +for `check-seam` and again for `check-generator`. It reads the `check` script now. The count +words it rewrites *from* were a second, shorter copy of the same list, capped at "sixteen", so +once the README said "seventeen guards" no later run could correct that prose again. + +**The recurring shape, stated once more:** a hand-written list of valid values drifts, and the +drift is invisible from inside the thing holding the list. diff --git a/package.json b/package.json index fee998d..c484945 100644 --- a/package.json +++ b/package.json @@ -11,7 +11,7 @@ "play": "node tools/playthrough.mjs", "mj": "node tools/mj-queue.mjs", "simulate": "node tools/simulate.mjs", - "check": "bun tools/check-seam.mjs && bun tools/check-rules.mjs && bun tools/check-kits.mjs && bun tools/check-lang.mjs && bun tools/check-templates.mjs && bun tools/check-behaviour.mjs && bun tools/check-scenarios.mjs && bun tools/check-rollable.mjs && bun tools/check-outcomes.mjs && bun tools/check-creatures.mjs && bun tools/check-powers.mjs && bun tools/check-anatomy.mjs && bun tools/check-lethality.mjs && bun tools/check-focus.mjs && bun tools/check-firstblood.mjs && bun tools/check-attackers.mjs && bun tools/check-fight-tail.mjs && bun tools/check-packs.mjs && bun tools/detention-tables.mjs --check && bun tools/bellhouse-plans.mjs --check && bun tools/check-cited.mjs && bun tools/check-figures.mjs && bun tools/check-handouts.mjs && bun tools/check-bestiary.mjs", + "check": "bun tools/check-seam.mjs && bun tools/check-generator.mjs && bun tools/check-rules.mjs && bun tools/check-kits.mjs && bun tools/check-lang.mjs && bun tools/check-templates.mjs && bun tools/check-behaviour.mjs && bun tools/check-scenarios.mjs && bun tools/check-rollable.mjs && bun tools/check-outcomes.mjs && bun tools/check-creatures.mjs && bun tools/check-powers.mjs && bun tools/check-anatomy.mjs && bun tools/check-lethality.mjs && bun tools/check-focus.mjs && bun tools/check-firstblood.mjs && bun tools/check-attackers.mjs && bun tools/check-fight-tail.mjs && bun tools/check-packs.mjs && bun tools/detention-tables.mjs --check && bun tools/bellhouse-plans.mjs --check && bun tools/check-cited.mjs && bun tools/check-figures.mjs && bun tools/check-handouts.mjs && bun tools/check-bestiary.mjs", "test": "bun run check", "readme": "bun tools/update-readme.mjs", "tokens": "python3 tools/make-tokens.py" diff --git a/postings.mjs b/postings.mjs index 03c833c..f88b48f 100644 --- a/postings.mjs +++ b/postings.mjs @@ -366,23 +366,46 @@ export const TIERS = { * they are granted together or not at all. Previously the tier chose the weapon * and an unrelated random role chose the skills, so most generated NPCs could not * use what they were holding: eight Apexes in ten had no usable attack. + * + * `draw` is how much of the POSTING's stores the tier takes: the posting says what + * it carries, the tier says how much of it and how good with it. `attacks` is now + * only for the two creature tiers, which have no posting to draw from — and they + * keep their natural attacks on top of whatever they drew, because a revenant of a + * Containment officer is still wearing and holding what it died in. */ export const THREATS = { - bystander: { label: "Bystander", hpMul: 1.0, armour: 0, skill: [15, 30], talents: 0, attacks: [] }, + bystander: { label: "Bystander", hpMul: 1.0, armour: 0, skill: [15, 30], talents: 0, attacks: [], + draw: { weapons: 0, armour: 0, gear: 2 } }, trained: { label: "Trained", hpMul: 1.0, armour: 1, skill: [30, 45], talents: 0, - attacks: [{ weapon: "dagger", skill: "melee_weapon:knife" }] }, + attacks: [{ weapon: "dagger", skill: "melee_weapon:knife" }], + draw: { weapons: 1, armour: 1, gear: 3 } }, armed: { label: "Armed", hpMul: 1.0, armour: 2, skill: [40, 55], talents: 0, - attacks: [{ weapon: "pistol_med", skill: "firearm:pistol" }] }, + attacks: [{ weapon: "pistol_med", skill: "firearm:pistol" }], + draw: { weapons: 1, armour: 1, gear: 3 } }, dangerous: { label: "Dangerous", hpMul: 1.2, armour: 3, skill: [50, 65], talents: 1, attacks: [{ weapon: "rifle_med", skill: "firearm:rifle" }, - { weapon: "baton", skill: "melee_weapon:club" }] }, + { weapon: "baton", skill: "melee_weapon:club" }], + draw: { weapons: 2, armour: 1, gear: 4 } }, anomalous: { label: "Anomalous", hpMul: 1.5, armour: 5, skill: [55, 75], talents: 2, - attacks: [{ weapon: "grasp", skill: "brawl" }] }, + attacks: [{ weapon: "grasp", skill: "brawl" }], + draw: { weapons: 1, armour: 1, gear: 3 } }, apex: { label: "Apex", hpMul: 2.0, armour: 7, skill: [65, 85], talents: 3, attacks: [{ weapon: "grasp", skill: "brawl" }, - { weapon: "unmaker", skill: "energy_weapon:rifle" }] } + { weapon: "unmaker", skill: "energy_weapon:rifle" }], + draw: { weapons: 2, armour: 2, gear: 4 } } }; +/** + * The two tiers that are not people. + * + * Named once, here with the tiers, because four separate decisions turn on it — whether + * the body's own armour is real, whether the tier's natural attacks apply, what the role + * card calls the thing, and how the measurement harness stands one up. Written out at + * each site, the four would drift, and the one that drifted would be the one nobody + * looked at. + */ +export const CREATURE_TIER = new Set(["anomalous", "apex"]); + /** Species: characteristic shifts and the location profile they use. */ export const SPECIES = { baseline: { label: "Baseline", profile: "baseline", shift: {} }, @@ -405,3 +428,64 @@ const NPC_NAMES = { bystander:["Local","Witness","Neighbour","Publican","Warden" apex:["Displaced","The one that came back","Apex"] }; export const NAME_POOLS = { GIVEN, FAMILY, NPC_NAMES }; + +/* ---------------- what stores hands out ---------------- */ + +/** + * What a posting issues to somebody at a given tier. + * + * One place decides this, because it is read from two sides that cannot import each + * other: the engine resolves the keys against the compendium packs, and + * check-generator resolves them against tools/content.mjs. A second copy of the + * decision is the drift this project has paid for five times. + * + * `classify` is supplied by the caller and answers weapon / armour / gear for a key. + * This file must not know the catalogues — and more importantly, nothing may infer a + * key's class from its spelling. Containment's `climbing_kit` is "Breaching charges" + * and its `prybar` is "Entry bar": the key is an identifier that has drifted from the + * name it was coined for, so membership of a catalogue is the only honest answer. + * + * There is no default classifier on purpose. A call without one could only issue + * nothing, which is the exact defect this function exists to end, and it would do it + * silently. + * + * Returns `{ weapons, armour, gear, unknown }`, all arrays of keys. `unknown` is the + * keys the classifier did not recognise; the caller reports them rather than + * swallowing them, because a silent substitution during a case is worse than a noisy + * one. + */ +export function drawKitFor(roleKey, tierKey, { classify, rand = Math.random } = {}) { + if (typeof classify !== "function") { + throw new Error("drawKitFor needs a classify(key) → weapon|armour|gear function"); + } + const R = ROLES[roleKey] ?? TRADES[roleKey]; + const TH = THREATS[tierKey]; + if (!R || !TH) return null; + const d = TH.draw ?? { weapons: 0, armour: 0, gear: 0 }; + + const byClass = { weapon: [], armour: [], gear: [] }; + const unknown = []; + for (const key of R.kit ?? []) { + const c = classify(key); + if (c && byClass[c]) byClass[c].push(key); + else unknown.push(key); + } + // One clamp, named, rather than a Math.max around each count. Written inline it read + // `Math.max(0, d.armour | 0)`, which is the shape check-rules hunts for — an inline + // subtraction of armour from damage. It was a count, not a damage calculation, but a + // guard that has to tell those apart by regex should not be asked to. + const take = n => (Number.isFinite(n) && n > 0 ? Math.trunc(n) : 0); + // Weapons and armour in the order the posting lists them: kits are authored + // primary first, so a Containment officer's first weapon is the rifle the + // posting is built around rather than whichever one a shuffle reached. + const weapons = byClass.weapon.slice(0, take(d.weapons)); + const armour = byClass.armour.slice(0, take(d.armour)); + // Gear at random, so four guards stood up off one statblock are not four + // identical guards. Drawn without replacement, in a copy. + const pool = byClass.gear.slice(); + const gear = []; + for (let n = take(d.gear); n > 0 && pool.length; n--) { + gear.push(...pool.splice(Math.floor(rand() * pool.length), 1)); + } + return { weapons, armour, gear, unknown }; +} diff --git a/ringbrp.mjs b/ringbrp.mjs index b572ab8..25c5501 100644 --- a/ringbrp.mjs +++ b/ringbrp.mjs @@ -3857,8 +3857,9 @@ export async function resetTalents(actor) { // references to ROLES were undefined at load. Import for use, then export for the API. import { ROLES, TRADES, CHARACTERISTIC_DICE, INDUCTION, STANDING_ISSUE, TIER_ISSUE, - PERSONAL_EFFECTS, TIERS, THREATS, SPECIES, NAME_POOLS, TRADE_BANDS + PERSONAL_EFFECTS, TIERS, THREATS, SPECIES, NAME_POOLS, TRADE_BANDS, drawKitFor, CREATURE_TIER } from "./postings.mjs"; + export { ROLES, TRADES, CHARACTERISTIC_DICE, INDUCTION, STANDING_ISSUE, TIER_ISSUE, PERSONAL_EFFECTS, TIERS, THREATS, SPECIES, NAME_POOLS, TRADE_BANDS @@ -4108,7 +4109,11 @@ export async function generateNPC({ system: { characteristics: Object.fromEntries(Object.entries(ch).map(([k, v]) => [k, { value: v }])), speciesProfile: SP.profile, - naturalArmour: { value: TH.armour }, + // Hide, not kit. The four human tiers used this number as a stand-in for armour + // they were never issued; now that they are issued the item, keeping both would + // armour every NPC twice. The two creature tiers keep it, which is what it was + // for, and `locationArmourFor` stacks any drawn armour on top of it. + naturalArmour: { value: CREATURE_TIER.has(tKey) ? TH.armour : 0 }, role: `${TH.label} · ${R.label}` } }); @@ -4121,16 +4126,66 @@ export async function generateNPC({ const doc = byKey.get(k); if (!doc) continue; items.push({ ...doc.toObject(), system: { ...doc.system, value: rndInt(TH.skill[0], TH.skill[1]) } }); } - // Attack packages: the weapon and the skill that fires it, together. - const weapons = await game.packs.get("ringbrp.weapons").getDocuments(); - for (const atk of TH.attacks ?? []) { - const doc = weapons.find(d => keyMatches(d, atk.weapon)); - if (!doc) continue; + // ---- what the posting carries, at the tier's depth ---- + // + // The role card in generateDialog has always printed this kit as DRAWS FROM STORES. + // Until now this function did not read `R.kit` at all: it issued whichever of six + // hardcoded weapons the tier named, so a Containment officer built around a marksman + // rifle was handed a utility knife while the card promised nine items. The card was + // never wrong about the posting; the actor was never sent to the stores. + // + // Classification is by pack membership, decided here and passed into drawKitFor, + // because the keys have drifted from their names — `climbing_kit` is "Breaching + // charges" and `prybar` is "Entry bar", so reading a key's spelling is wrong in the + // Containment kit before anywhere else. + const kitDoc = new Map(), kitClass = new Map(); + for (const [cls, packName] of [["weapon", "weapons"], ["armour", "armour"], ["gear", "gear"]]) { + for (const d of await game.packs.get(`ringbrp.${packName}`).getDocuments()) { + const k = d.flags?.ringbrp?.key; + if (!k || kitClass.has(k)) continue; + kitClass.set(k, cls); kitDoc.set(k, d); + } + } + const weapons = [...kitDoc.values()].filter(d => kitClass.get(d.flags.ringbrp.key) === "weapon"); + const drawn = drawKitFor(roleKey, tKey, { classify: k => kitClass.get(k) ?? null }) ?? { weapons: [], armour: [], gear: [], unknown: [] }; + if (drawn.unknown.length) { + console.warn(`Custodians | ${R.label}: no catalogue item for kit ${drawn.unknown.join(", ")}`); + } + // A weapon names the skill that fires it, so the pairing table is the weapon. An NPC + // cannot end up holding something it has no skill for, because the thing it is + // holding is what chose the skill. + const issueWeapon = doc => { + if (items.some(i => i.name === doc.name)) return; items.push({ ...doc.toObject(), system: { ...doc.system, equipped: true } }); - const sk = byKey.get(atk.skill); + const fam = doc.system?.skillFamilyId, spec = doc.system?.skillSpecialisationId; + if (!fam) return; + const sk = byKey.get(spec ? `${fam}:${spec}` : fam); if (sk && !items.some(i => i.name === sk.name)) { items.push({ ...sk.toObject(), system: { ...sk.system, value: rndInt(TH.skill[0], TH.skill[1]) } }); } + }; + for (const key of drawn.weapons) { const doc = kitDoc.get(key); if (doc) issueWeapon(doc); } + for (const key of drawn.armour) { + const doc = kitDoc.get(key); if (!doc || items.some(i => i.name === doc.name)) continue; + items.push({ ...doc.toObject(), system: { ...doc.system, equipped: true, carried: true } }); + } + for (const key of drawn.gear) { + const doc = kitDoc.get(key); if (!doc || items.some(i => i.name === doc.name)) continue; + items.push({ ...doc.toObject(), system: { ...doc.system, equipped: true, carried: true } }); + } + // Natural attacks, for the two tiers that have them. These are not a posting's + // issue — a grasp and the Unmaker come with the body, so they stack on whatever it + // drew rather than replacing it. The four human tiers have no business with them. + if (CREATURE_TIER.has(tKey)) { + for (const atk of TH.attacks ?? []) { + const doc = weapons.find(d => keyMatches(d, atk.weapon)); + if (!doc || items.some(i => i.name === doc.name)) continue; + items.push({ ...doc.toObject(), system: { ...doc.system, equipped: true } }); + const sk = byKey.get(atk.skill); + if (sk && !items.some(i => i.name === sk.name)) { + items.push({ ...sk.toObject(), system: { ...sk.system, value: rndInt(TH.skill[0], TH.skill[1]) } }); + } + } } // R-213 again, in the one construction path it was not applied to. The pack // builder and generateCharacter both issue the unarmed attack; this did not, so @@ -4164,7 +4219,16 @@ export async function generateNPC({ if (!items.some(i => i.name === doc.name)) items.push(doc.toObject()); } } + // Reserve ammunition, so a reload is a real option for an adversary rather than a + // declared one. generateCharacter has done this since the reload button existed. + for (const it of items.filter(i => i.type === "weapon" && Number(i.system?.ammo?.capacity))) { + it.system = { ...it.system, ammo: { ...it.system.ammo, reserve: (Number(it.system.ammo.capacity) || 0) * 2 } }; + } if (items.length) await actor.createEmbeddedDocuments("Item", items); + // Issued is not carried. A Containment officer drawing four gear items should not + // begin the fight permanently slower for owning her own breaching charges — the same + // reason the PC path calls this. + await stowOverload(actor); if (toChat) await announceGenerated(actor, `${TH.label} · ${SP.label} · ${R.label}`); return actor; } @@ -4236,6 +4300,36 @@ function buildBiography({ name, role, tier, species, age }) { + `

Anchor: ${pick(anchor)}.

`; } +/** + * What it is holding and what is stopping bullets, on the card that announces it. + * + * The announcement listed hit points, Major Wound, Reaction and two counts, and said + * nothing about arms or armour. That was survivable while a generated NPC carried one + * hardcoded weapon and a number where its armour should be. Now that it draws its + * posting's kit, an Anomalous Containment officer stands up at nineteen times the wipe + * rate it had, wearing a seven-point breaching suit over five points of hide — and the + * GM who just clicked Generate has no way to see that without opening the sheet. + * A number that large has to be on the card. + */ +function kitLine(actor) { + const eq = i => i.system?.equipped !== false; + const arms = actor.items.filter(i => i.type === "weapon" && eq(i)).map(i => i.name); + const worn = actor.items.filter(i => i.type === "armour" && eq(i)) + .map(i => `${i.name} ${Number(i.system?.points) || 0}`); + const hide = Number(actor.system?.naturalArmour?.value) || 0; + if (!arms.length && !worn.length && !hide) return ""; + const parts = []; + if (arms.length) parts.push(`${arms.join(", ")}`); + // Hide and worn armour STACK per location — locationArmourFor adds every piece + // covering a location to the body's own — so the total is printed, not just the pieces. + const total = hide + actor.items.filter(i => i.type === "armour" && eq(i)) + .reduce((t, i) => t + (Number(i.system?.points) || 0), 0); + if (worn.length || hide) { + parts.push(`armour ${total}${hide ? ` (hide ${hide})` : ""}${worn.length ? ` — ${worn.join(", ")}` : ""}`); + } + return `
${parts.join("")}
`; +} + async function announceGenerated(actor, subtitle) { const s = actor.system; await ChatMessage.create({ @@ -4248,7 +4342,8 @@ async function announceGenerated(actor, subtitle) { Reaction ${s.reaction?.base ?? "?"} ${actor.items.filter(i => i.type === "skill").length} skills ${actor.items.filter(i => i.type === "talent").length} talents - `, + + ${kitLine(actor)}`, whisper: ChatMessage.getWhisperRecipients("GM").map(u => u.id) }); } diff --git a/tools/check-generator.mjs b/tools/check-generator.mjs new file mode 100644 index 0000000..0e3dc2b --- /dev/null +++ b/tools/check-generator.mjs @@ -0,0 +1,155 @@ +/** + * What the role card promises, the generated NPC must be holding. + * + * Every one of the 36 postings carries a `kit` array, and `generateDialog` prints it + * on the role card as DRAWS FROM STORES. `generateCharacter` issues it. `generateNPC` + * did not read it at all, so the card advertised a marksman rifle, a breaching suit + * and seven other things while the actor was handed whichever of six hardcoded + * weapons its tier named. Nothing measured the gap, which is why it survived. + * + * This guard measures it in two halves, because half of it is a decision over pure + * data and half of it is whether the engine bothers to ask: + * + * - `drawKitFor()` in postings.mjs decides what a posting issues at a tier. Pure, + * so it can be asserted here directly. + * - `generateNPC` in ringbrp.mjs must actually call it. A correct decision function + * nothing consumes is exactly the defect being fixed, one layer further in, so + * the source is scanned as well. + * + * bun tools/check-generator.mjs + */ +import { readFile } from "node:fs/promises"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { WEAPONS, ARMOURS, GEAR, DEVICES, SKILL_CATALOGUE } from "./content.mjs"; +import * as POSTINGS from "../postings.mjs"; + +const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); +const { ROLES, TRADES, THREATS } = POSTINGS; +const problems = []; + +/* Classification comes from catalogue membership, never from the spelling of a key. + * Containment's `climbing_kit` is "Breaching charges" and its `prybar` is "Entry bar": + * anything reading meaning out of a key is wrong in that one posting first. */ +const CLASSES = [["weapon", WEAPONS], ["armour", ARMOURS], ["gear", [...GEAR, ...(DEVICES ?? [])]]]; +const classOf = new Map(); +for (const [cls, list] of CLASSES) { + for (const x of list) { + if (classOf.has(x.key) && classOf.get(x.key) !== cls) { + problems.push(`kit key "${x.key}" is in both the ${classOf.get(x.key)} and ${cls} catalogues — ` + + `classification by catalogue membership needs each key to live in exactly one`); + } + classOf.set(x.key, cls); + } +} +const classify = key => classOf.get(key) ?? null; + +const SKILLS = new Set(SKILL_CATALOGUE.map(s => (s[1] ? `${s[0]}:${s[1]}` : s[0]))); +const WEAPON_BY_KEY = new Map(WEAPONS.map(w => [w.key, w])); +/** The skill a weapon names for itself. The pairing table is the weapon. */ +const skillOfWeapon = key => { + const w = WEAPON_BY_KEY.get(key); + if (!w) return null; + return w.spec ? `${w.fam}:${w.spec}` : w.fam; +}; + +/* ---------- 1. the tier tables must say how much a tier draws ---------- */ +for (const [tier, TH] of Object.entries(THREATS)) { + const d = TH.draw; + if (!d) { problems.push(`THREATS.${tier} has no draw table — the tier cannot say how much of the posting's stores it takes`); continue; } + for (const field of ["weapons", "armour", "gear"]) { + if (!Number.isInteger(d[field]) || d[field] < 0) { + problems.push(`THREATS.${tier}.draw.${field} is ${JSON.stringify(d[field])}, not a count`); + } + } +} + +/* ---------- 2. the decision function must exist and be pure ---------- */ +const drawKitFor = POSTINGS.drawKitFor; +if (typeof drawKitFor !== "function") { + problems.push(`postings.mjs does not export drawKitFor() — there is no one place that ` + + `decides what a posting issues at a tier, so the engine and this guard cannot agree on it`); +} + +/* ---------- 3. every posting × tier issues from its own stores, usably ---------- */ +let pairs = 0, issuedWeapons = 0; +if (typeof drawKitFor === "function") { + for (const [roleKey, R] of Object.entries({ ...ROLES, ...(TRADES ?? {}) })) { + const kitSet = new Set(R.kit ?? []); + const kitWeapons = (R.kit ?? []).filter(k => classify(k) === "weapon"); + const kitArmour = (R.kit ?? []).filter(k => classify(k) === "armour"); + for (const [tier, TH] of Object.entries(THREATS)) { + pairs++; + const where = `${tier} · ${roleKey}`; + let drawn; + try { drawn = drawKitFor(roleKey, tier, { classify }); } + catch (e) { problems.push(`${where}: drawKitFor threw ${e.message}`); continue; } + if (!drawn) { problems.push(`${where}: drawKitFor returned nothing`); continue; } + + for (const group of ["weapons", "armour", "gear"]) { + for (const key of drawn[group] ?? []) { + // Assertion 4 of the design: the generator may issue less than the card shows. + // It may never issue something the card never mentioned. + if (!kitSet.has(key)) problems.push(`${where}: issued "${key}" as ${group}, which is not in the posting's kit`); + else if (classify(key) !== group.replace(/s$/, "").replace("armou", "armour")) { + const want = { weapons: "weapon", armour: "armour", gear: "gear" }[group]; + if (classify(key) !== want) problems.push(`${where}: "${key}" issued as ${group} but the catalogues call it ${classify(key)}`); + } + } + } + // Assertion 3: a tier that draws weapons, from a posting that has one, gets one. + const wantW = Math.min(TH.draw?.weapons ?? 0, kitWeapons.length); + const gotW = (drawn.weapons ?? []).length; + if (gotW !== wantW) problems.push(`${where}: draws ${TH.draw?.weapons ?? 0} weapon(s) from a kit holding ${kitWeapons.length}, and received ${gotW}`); + issuedWeapons += gotW; + const wantA = Math.min(TH.draw?.armour ?? 0, kitArmour.length); + if ((drawn.armour ?? []).length !== wantA) problems.push(`${where}: expected ${wantA} armour, received ${(drawn.armour ?? []).length}`); + // Assertion 2: a weapon arrives with the skill it names, or the NPC cannot use it. + for (const key of drawn.weapons ?? []) { + const sk = skillOfWeapon(key); + if (!sk) { problems.push(`${where}: issued weapon "${key}" is in no catalogue`); continue; } + if (!SKILLS.has(sk)) problems.push(`${where}: weapon "${key}" names skill "${sk}", which is not a skill`); + } + // Assertion 1: nothing on the card may be unresolvable. + for (const key of R.kit ?? []) { + if (!classify(key)) problems.push(`${where}: the role card prints "${key}", which is in no catalogue`); + } + } + } +} + +/* ---------- 4. the engine must actually ask ---------- */ +{ + const src = await readFile(path.join(ROOT, "ringbrp.mjs"), "utf8"); + const start = src.indexOf("export async function generateNPC("); + if (start === -1) problems.push(`ringbrp.mjs no longer defines generateNPC — this guard is looking at the wrong function`); + else { + const after = src.slice(start + 10); + const ends = [after.indexOf("\nexport "), after.indexOf("\nasync function "), after.indexOf("\nfunction ")] + .filter(i => i !== -1); + const body = after.slice(0, ends.length ? Math.min(...ends) : after.length); + if (!/drawKitFor\s*\(/.test(body)) { + problems.push(`generateNPC never calls drawKitFor — it builds an NPC without reading the ` + + `posting's kit, so the role card's DRAWS FROM STORES describes stores the actor never visits`); + } + // The natural-attack loop must sit inside a creature-tier restriction. Read the + // text BEFORE the loop, not after it: the condition is upstream of the thing it + // guards, and the first version of this assertion looked downstream and so + // reported the fixed code as broken. + const at = body.indexOf("TH.attacks"); + if (at !== -1 && !/CREATURE_TIER\.has|=== "anomalous"|=== "apex"/.test(body.slice(Math.max(0, at - 400), at))) { + problems.push(`generateNPC applies THREATS.attacks with no creature-tier restriction above it — ` + + `those six hardcoded weapons are for the two tiers that have no posting to draw from, ` + + `and on a human tier they replace the kit rather than adding to it`); + } + } +} + +if (problems.length) { + console.error(`check-generator: FAILED — ${problems.length} problem(s)`); + for (const p of problems.slice(0, 40)) console.error(" " + p); + if (problems.length > 40) console.error(` … and ${problems.length - 40} more`); + process.exit(1); +} +console.log(`check-generator: OK — ${pairs} posting × tier pairs draw from their own stores, ` + + `${issuedWeapons} weapons issued, each with the skill it names`); diff --git a/tools/check-rules.mjs b/tools/check-rules.mjs index e348251..0c89e74 100644 --- a/tools/check-rules.mjs +++ b/tools/check-rules.mjs @@ -64,6 +64,11 @@ for (const rel of files) { if (rel.startsWith("tools/check-")) continue; const src = await readFile(new URL(rel, root), "utf8"); src.split("\n").forEach((line, i) => { + // A comment-only line cannot reimplement a rule, and treating one as if it could + // is not a harmless over-reach: it reported postings.mjs for a comment that QUOTED + // the pattern in order to explain why the code below it avoids that shape. Guards + // that read prose as code make the honest documentation of a near-miss unwritable. + if (/^\s*(?:\/\/|\*|\/\*)/.test(line)) return; for (const [re, why] of REIMPLEMENTED) if (re.test(line)) fail(`${rel}:${i + 1} ${why}`); }); } diff --git a/tools/npc-cohort.mjs b/tools/npc-cohort.mjs new file mode 100644 index 0000000..efa952c --- /dev/null +++ b/tools/npc-cohort.mjs @@ -0,0 +1,160 @@ +/** + * How dangerous is a GENERATED NPC — before and after it was issued its posting's kit. + * + * check-lethality measures the 184 creatures written down in the packs. Nothing measured + * the ones the dialog makes, and the kit change moves those and only those: a Trained NPC + * went from a flat 1 point of armour everywhere to whatever a stab vest stops where a stab + * vest covers, and an Apex now wears a breaching suit over its hide. + * + * Both configurations are built here, from one description of the NPC, so the comparison + * is the change and not two different harnesses: + * + * --now the posting's kit, armour as worn pieces, hide only on creature tiers + * --before the six hardcoded tier weapons, no kit, hide on every tier (pre-8dbe885) + * + * The decision about WHAT a posting issues is not reimplemented: it calls drawKitFor, + * the same function generateNPC calls. Everything else about a generated NPC that this + * has to know — the characteristic dice, the skill bands, which tiers are creatures — is + * imported from postings.mjs for the same reason. + * + * Characteristics are the MEAN of each die expression rather than a roll. A generated NPC + * is a random draw and this is asking what the tier is worth, so the average body is the + * subject; a spread over rolled bodies would mix two questions. + * + * node tools/npc-cohort.mjs --role heavy --tier apex + * node tools/npc-cohort.mjs --tier apex,anomalous --role heavy --runs 2000 + * node tools/npc-cohort.mjs --table + */ +import { ROLES, TRADES, THREATS, CHARACTERISTIC_DICE, SPECIES, CREATURE_TIER, drawKitFor } + from "../postings.mjs"; +import { WEAPONS, ARMOURS, GEAR, DEVICES, SKILL_CATALOGUE } from "./content.mjs"; +import { ROSTER } from "./roster.mjs"; +import { measure, seedFor } from "./simulate.mjs"; +import { brawlFallbackFor } from "../rules.mjs"; + +const argv = process.argv.slice(2); +const opt = (name, dflt) => { + const i = argv.indexOf(`--${name}`); + return i === -1 || !argv[i + 1] || argv[i + 1].startsWith("--") ? dflt : argv[i + 1]; +}; +const RUNS = Number(opt("runs", 2000)); +const SEED = Number(opt("seed", 11)); +/* The same four agents check-lethality freezes, so a cohort number and a creature number + are on one scale. */ +const PARTY_KEYS = ["pc_holloway", "pc_okonkwo", "pc_nkemdirim", "pc_ferriby"]; +const party = PARTY_KEYS.map(k => { + const f = ROSTER.find(r => r.key === k); + if (!f) { console.error(`npc-cohort: no roster agent "${k}"`); process.exit(1); } + return f; +}); + +/* Classification by catalogue membership — the rule drawKitFor is written around. */ +const classOf = new Map(); +for (const [cls, list] of [["weapon", WEAPONS], ["armour", ARMOURS], ["gear", [...GEAR, ...(DEVICES ?? [])]]]) { + for (const x of list) if (!classOf.has(x.key)) classOf.set(x.key, cls); +} +const classify = k => classOf.get(k) ?? null; +const WEAPON_BY_KEY = new Map(WEAPONS.map(w => [w.key, w])); +const SKILL_KEYS = new Set(SKILL_CATALOGUE.map(s => (s[1] ? `${s[0]}:${s[1]}` : s[0]))); + +/** The average body a tier stands up, before its own shifts. */ +function meanCharacteristics() { + const ch = {}; + for (const [k, f] of Object.entries(CHARACTERISTIC_DICE)) { + const m = /^(\d+)d(\d+)(?:\+(\d+))?$/.exec(f); + ch[k] = m ? Math.round(Number(m[1]) * (Number(m[2]) + 1) / 2 + Number(m[3] ?? 0)) : 10; + } + return ch; +} + +/** A skills array in the shape simulate.mjs reads, mid-band for the tier. */ +const skillEntry = (key, val) => { + const [fam, spec = ""] = key.split(":"); + return { fam, spec, val }; +}; + +/** + * One generated NPC as a simulator spec. + * + * `mode` is "now" or "before", and the difference between them is the whole point: + * before, the tier chose the weapon and hide stood in for armour on everybody. + */ +function specFor(roleKey, tierKey, mode) { + const R = ROLES[roleKey] ?? TRADES[roleKey]; + const TH = THREATS[tierKey]; + if (!R || !TH) return null; + const isCreature = CREATURE_TIER.has(tierKey); + + const ch = meanCharacteristics(); + if (isCreature) { ch.str += 4; ch.con += 4; ch.pow += 4; ch.cha = Math.max(3, ch.cha - 6); } + + const band = Math.round((TH.skill[0] + TH.skill[1]) / 2); + const skills = new Map(); + // The posting's core plus two support, the same set generateNPC grants. + for (const k of [...R.core, ...R.support.slice(0, 2)]) if (SKILL_KEYS.has(k)) skills.set(k, band); + + const weapons = []; + let armour = []; + if (mode === "before") { + for (const atk of TH.attacks ?? []) { weapons.push(atk.weapon); skills.set(atk.skill, band); } + } else { + const drawn = drawKitFor(roleKey, tierKey, { classify, rand: () => 0.5 }); + for (const key of drawn.weapons) { + weapons.push(key); + const w = WEAPON_BY_KEY.get(key); + if (w) skills.set(w.spec ? `${w.fam}:${w.spec}` : w.fam, band); + } + armour = drawn.armour; + if (isCreature) { + for (const atk of TH.attacks ?? []) { weapons.push(atk.weapon); skills.set(atk.skill, band); } + } + } + // R-213: the unarmed fallback, at the reduced figure, for anyone whose only attack is + // a firearm. Applied last, exactly as both construction paths apply it. + if (!skills.has("brawl")) skills.set("brawl", brawlFallbackFor(band)); + + return { + key: `gen_${tierKey}_${roleKey}`, + name: `${TH.label} · ${R.label}`, + species: "baseline", bodyPlan: "baseline", + ch, + skills: [...skills].map(([k, v]) => skillEntry(k, v)), + weapons, + armour, + // Hide is a body's own. Before the change the four human tiers carried it as a + // stand-in for the armour they were never issued. + naturalArmour: mode === "before" ? TH.armour : (isCreature ? TH.armour : 0) + }; +} + +const roles = String(opt("role", "heavy")).split(",").filter(Boolean); +const tiers = argv.includes("--table") + ? Object.keys(THREATS) + : String(opt("tier", "apex,anomalous")).split(",").filter(Boolean); +const tableRoles = argv.includes("--table") ? ["lead", "heavy", "medic"] : roles; + +console.log(`npc-cohort — ${RUNS} runs, seed ${SEED}, party ${PARTY_KEYS.map(k => k.slice(3)).join(", ")}`); +console.log(""); +/* measure() returns wipeRate as a FRACTION. The first version of this table printed it + with one decimal and a % in the header, so a 90% wipe rate was published as "0.9" — + a twenty-point balance change read as two-tenths of a point and looked like noise. + check-lethality:100 has always multiplied by 100; this now does the same. */ +const pc = x => (x * 100).toFixed(1); +console.log(" tier · posting wipe% was Δ kills/run armour"); +for (const roleKey of tableRoles) { + for (const tierKey of tiers) { + const now = specFor(roleKey, tierKey, "now"); + const before = specFor(roleKey, tierKey, "before"); + if (!now) { console.log(` no such posting/tier: ${tierKey} · ${roleKey}`); continue; } + const a = measure(party, [now], { runs: RUNS, seed: seedFor(SEED, now.key) }); + const b = measure(party, [before], { runs: RUNS, seed: seedFor(SEED, before.key) }); + const worn = now.armour.length + ? now.armour.map(k => `${k} ${ARMOURS.find(x => x.key === k)?.points ?? "?"}`).join(", ") + : "—"; + const d = (a.wipeRate - b.wipeRate) * 100; + console.log(` ${now.name.padEnd(32)} ${pc(a.wipeRate).padStart(5)} ${pc(b.wipeRate).padStart(6)} ` + + `${((d >= 0 ? "+" : "") + d.toFixed(1)).padStart(6)} ` + + `${a.deathsPerRun.toFixed(2)} was ${b.deathsPerRun.toFixed(2)} ` + + `hide ${now.naturalArmour}, ${worn}`); + } +} diff --git a/tools/update-readme.mjs b/tools/update-readme.mjs index a8f2f48..09cf7ba 100644 --- a/tools/update-readme.mjs +++ b/tools/update-readme.mjs @@ -35,33 +35,28 @@ const runGuard = name => { { encoding: "utf8" }).split("\n").find(l => l.startsWith(name.replace(".mjs", ""))); } catch { return null; } }; -/* All of them, in the order `npm run check` runs them. This list was four long while the - suite was seven, so the README advertised a subset and silently stopped mentioning - every guard added after it was written — including the three that catch the most. */ -const GUARDS = ["check-rules.mjs", "check-kits.mjs", "check-lang.mjs", - "check-templates.mjs", "check-behaviour.mjs", "check-scenarios.mjs", - "check-rollable.mjs", "check-creatures.mjs", "check-powers.mjs", - "check-anatomy.mjs", - "check-lethality.mjs", "check-focus.mjs", "check-firstblood.mjs", - "check-attackers.mjs", "check-fight-tail.mjs", "check-cited.mjs", "check-handouts.mjs", "check-bestiary.mjs"]; +/* All of them, in the order `npm run check` runs them — READ from the script that runs + them, not retyped beside it. -/* The list above went stale the moment a guard was added without touching this file — - which is what the comment above it already warned about, and which happened again - with check-anatomy. So it is no longer trusted: the suite of record is the `check` - script in package.json, and this refuses to write a README that advertises a - different set than the one the build actually runs. */ -{ + This was a hardcoded array, and the two comments that used to live here both recorded + it going stale: four long while the suite was seven, then missing check-anatomy. The + response at the time was to keep the array and add a check that it matched + package.json, which turned a silent omission into a loud one — an improvement, and + still a list of valid values maintained by hand. It went stale twice more (check-seam, + check-generator) and stopped the build both times. + + There is only one suite. It is the `check` script. The non-guard steps in that script + (detention-tables, bellhouse-plans) run with `--check` and are not named check-*, so + this picks out exactly the guards, in build order. */ +const GUARDS = (() => { const pkg = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8")); - const actual = [...(pkg.scripts?.check ?? "").matchAll(/(check-[\w-]+\.mjs)/g)].map(m => m[1]); - const missing = actual.filter(g => !GUARDS.includes(g)); - const phantom = GUARDS.filter(g => !actual.includes(g)); - if (missing.length || phantom.length) { - console.error("update-readme: FAILED — the README's guard list does not match `npm run check`"); - if (missing.length) console.error(" run by the build, not advertised: " + missing.join(", ")); - if (phantom.length) console.error(" advertised, not run by the build: " + phantom.join(", ")); + const found = [...(pkg.scripts?.check ?? "").matchAll(/(check-[\w-]+\.mjs)/g)].map(m => m[1]); + if (!found.length) { + console.error("update-readme: FAILED — package.json has no `check` script to read the guards from"); process.exit(1); } -} + return [...new Set(found)]; +})(); /* A guard that fails, or that prints under a different name, returns null here. Dropping it silently writes a README advertising fewer guards than the build runs and quietly @@ -120,11 +115,17 @@ if (!withGuards) { console.error("update-readme: guard markers missing"); proces const COUNT_WORD = ["zero", "one", "two", "three", "four", "five", "six", "seven", "eight", "nine", "ten", "eleven", "twelve", "thirteen", "fourteen", "fifteen", "sixteen", "seventeen", "eighteen", "nineteen", "twenty", - "twenty-one", "twenty-two", "twenty-three", "twenty-four"]; + "twenty-one", "twenty-two", "twenty-three", "twenty-four", + "twenty-five", "twenty-six", "twenty-seven", "twenty-eight", + "twenty-nine", "thirty"]; const word = COUNT_WORD[guardLines.length]; if (!word) { console.error(`update-readme: ${guardLines.length} guards has no count word`); process.exit(1); } -const counted = withGuards.replace(/\b(zero|one|two|three|four|five|six|seven|eight|nine|ten|eleven|twelve|thirteen|fourteen|fifteen|sixteen) guards\b/g, - `${word} guards`); +/* The words this rewrites FROM were a second hardcoded list, and a shorter one: it + stopped at "sixteen", so once the README said "seventeen guards" no later run could + correct that prose again. Both lists are the same list. */ +const counted = withGuards.replace( + new RegExp(`\\b(${[...COUNT_WORD].sort((a, b) => b.length - a.length).join("|")}) guards\\b`, "g"), + `${word} guards`); await writeFile(readme, counted, "utf8"); console.log(`update-readme: OK — ${items} items, ${Object.keys(ROLES).length} postings, `