Generated NPCs draw their posting's kit
generateNPC read R.core, R.support and R.label. It never read R.kit, so the
role card's DRAWS FROM STORES promised a Containment officer nine items and
the actor arrived holding a utility knife with a flat naturalArmour standing
in for armour it was never issued.
postings.mjs now owns the decision — drawKitFor(role, tier, {classify}) plus
a draw table per tier — because the engine resolves kit keys against the
compendium and check-generator resolves them against content.mjs, and the two
cannot import each other. Classification comes from catalogue membership and
never from the key: climbing_kit is "Breaching charges" and prybar is "Entry
bar", so anything reading a key's spelling is wrong in that posting first.
The weapon↔skill pairing table is gone. Each weapon item already names the
skill that fires it, so the skill is granted from the weapon, and a generated
NPC cannot hold what it cannot use. THREATS.attacks is now creature-tiers
only; those two keep their natural attacks on top of what they drew.
Armour is an item. naturalArmour drops to 0 on the four human tiers, where
the number was standing in for the item, and stays on the two creature tiers,
where it stacks with drawn armour as locationArmourFor already intends.
check-generator (guard 25) was written first and observed failing on nine
counts against unchanged source. One of its assertions was itself wrong —
it read for the creature-tier restriction after the loop instead of before
it, and so reported the fixed code as broken; re-scoped, it passes on the fix
and still fails against git show HEAD:ringbrp.mjs.
Measured with the new tools/npc-cohort.mjs, 2000 runs, seed 11, against the
party check-lethality freezes. Apex · Containment 68.2% -> 88.5% wipe,
Anomalous · Containment 0.3% -> 19.2%, and Dangerous · Field Lead got weaker
because its posting carries one pistol where the tier used to hand it a
rifle. Full table and three incidental findings in R-310.
Two of those findings are guards, not content. check-rules failed on a
comment that quoted the pattern it hunts for, so comment-only lines are
skipped now. update-readme held the guard list as a hardcoded array whose own
comments recorded it going stale twice; it reads the check script instead,
and so does the count-word rewrite that was separately capped at sixteen.
Not changed, and flagged in R-310: breach_suit and riot_shield declare no
coverage, so they armour every location including the head. The location
counterplay NPC_KIT_PLAN.md relies on does not exist, and that is a balance
decision rather than a side effect of this one.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
8dbe885a34
commit
4dd72c6248
@@ -48,7 +48,7 @@ packs or regenerate art on that machine:
|
|||||||
```bash
|
```bash
|
||||||
npm install # pulls classic-level, used to write the LevelDB packs
|
npm install # pulls classic-level, used to write the LevelDB packs
|
||||||
npm run build # rebuilds packs/ from tools/content.mjs
|
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
|
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 |
|
| **Postings** | 25, each with a key characteristic, trained skills, talents and its own kit |
|
||||||
| **Trades** | 10 ordinary jobs the department recruits from, plus induction |
|
| **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 |
|
| **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 |
|
| **Eras** | modern, antique, future, anomalous — the agency is old and never abolishes anything |
|
||||||
| **Icons** | 292, all generated from `tools/icons.mjs`; no external art |
|
| **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 |
|
| **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)
|
packs/ built LevelDB compendia (committed — see Deploying)
|
||||||
tools/
|
tools/
|
||||||
content.mjs the catalogue: skills, weapons, armour, gear, vehicles, NPCs
|
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
|
make-icons.mjs draws all 231 icons
|
||||||
rules-text.mjs generates the rules journal FROM rules.mjs
|
rules-text.mjs generates the rules journal FROM rules.mjs
|
||||||
mission.mjs the case generator
|
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
|
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.
|
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:
|
||||||
|
|
||||||
<!-- guards:start -->
|
<!-- guards:start -->
|
||||||
```
|
```
|
||||||
check-rules: OK — 112 rules, 49 files scanned, 3 aliases + 46 constant sets checked, 413 formulas verified
|
check-seam: OK — 70 modules scanned, only all-specs.mjs reads NPCS/PREGENS from content.mjs
|
||||||
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-generator: OK — 210 posting × tier pairs draw from their own stores, 129 weapons issued, each with the skill it names
|
||||||
check-lang: OK — en.json, 1018 keys, no leaf/branch collisions
|
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-templates: OK — 19 templates compile
|
||||||
check-behaviour: OK — 76 behavioural tests
|
check-behaviour: OK — 108 behavioural tests
|
||||||
check-scenarios: OK — 14 scenario files, 83 mechanic tags, every skill named resolves against 62 catalogue entries
|
check-scenarios: OK — 30 scenario files, 111 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-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-creatures: OK — 102 actor specs across 8 sources, every characteristic, skill, kit key, talent, species and style resolves, no duplicate keys
|
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-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-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-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-lethality: OK — 184 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-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-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-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-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-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
|
||||||
```
|
```
|
||||||
<!-- guards:end -->
|
<!-- guards:end -->
|
||||||
|
|||||||
@@ -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
|
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.
|
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.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
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
|
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.**
|
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.
|
||||||
|
|||||||
+1
-1
@@ -11,7 +11,7 @@
|
|||||||
"play": "node tools/playthrough.mjs",
|
"play": "node tools/playthrough.mjs",
|
||||||
"mj": "node tools/mj-queue.mjs",
|
"mj": "node tools/mj-queue.mjs",
|
||||||
"simulate": "node tools/simulate.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",
|
"test": "bun run check",
|
||||||
"readme": "bun tools/update-readme.mjs",
|
"readme": "bun tools/update-readme.mjs",
|
||||||
"tokens": "python3 tools/make-tokens.py"
|
"tokens": "python3 tools/make-tokens.py"
|
||||||
|
|||||||
+90
-6
@@ -366,23 +366,46 @@ export const TIERS = {
|
|||||||
* they are granted together or not at all. Previously the tier chose the weapon
|
* 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
|
* 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.
|
* 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 = {
|
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,
|
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,
|
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,
|
dangerous: { label: "Dangerous", hpMul: 1.2, armour: 3, skill: [50, 65], talents: 1,
|
||||||
attacks: [{ weapon: "rifle_med", skill: "firearm:rifle" },
|
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,
|
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,
|
apex: { label: "Apex", hpMul: 2.0, armour: 7, skill: [65, 85], talents: 3,
|
||||||
attacks: [{ weapon: "grasp", skill: "brawl" },
|
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. */
|
/** Species: characteristic shifts and the location profile they use. */
|
||||||
export const SPECIES = {
|
export const SPECIES = {
|
||||||
baseline: { label: "Baseline", profile: "baseline", shift: {} },
|
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"] };
|
apex:["Displaced","The one that came back","Apex"] };
|
||||||
|
|
||||||
export const NAME_POOLS = { GIVEN, FAMILY, NPC_NAMES };
|
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 };
|
||||||
|
}
|
||||||
|
|||||||
+104
-9
@@ -3857,8 +3857,9 @@ export async function resetTalents(actor) {
|
|||||||
// references to ROLES were undefined at load. Import for use, then export for the API.
|
// references to ROLES were undefined at load. Import for use, then export for the API.
|
||||||
import {
|
import {
|
||||||
ROLES, TRADES, CHARACTERISTIC_DICE, INDUCTION, STANDING_ISSUE, TIER_ISSUE,
|
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";
|
} from "./postings.mjs";
|
||||||
|
|
||||||
export {
|
export {
|
||||||
ROLES, TRADES, CHARACTERISTIC_DICE, INDUCTION, STANDING_ISSUE, TIER_ISSUE,
|
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
|
||||||
@@ -4108,7 +4109,11 @@ export async function generateNPC({
|
|||||||
system: {
|
system: {
|
||||||
characteristics: Object.fromEntries(Object.entries(ch).map(([k, v]) => [k, { value: v }])),
|
characteristics: Object.fromEntries(Object.entries(ch).map(([k, v]) => [k, { value: v }])),
|
||||||
speciesProfile: SP.profile,
|
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}`
|
role: `${TH.label} · ${R.label}`
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -4121,16 +4126,66 @@ export async function generateNPC({
|
|||||||
const doc = byKey.get(k); if (!doc) continue;
|
const doc = byKey.get(k); if (!doc) continue;
|
||||||
items.push({ ...doc.toObject(), system: { ...doc.system, value: rndInt(TH.skill[0], TH.skill[1]) } });
|
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.
|
// ---- what the posting carries, at the tier's depth ----
|
||||||
const weapons = await game.packs.get("ringbrp.weapons").getDocuments();
|
//
|
||||||
for (const atk of TH.attacks ?? []) {
|
// The role card in generateDialog has always printed this kit as DRAWS FROM STORES.
|
||||||
const doc = weapons.find(d => keyMatches(d, atk.weapon));
|
// Until now this function did not read `R.kit` at all: it issued whichever of six
|
||||||
if (!doc) continue;
|
// 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 } });
|
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)) {
|
if (sk && !items.some(i => i.name === sk.name)) {
|
||||||
items.push({ ...sk.toObject(), system: { ...sk.system, value: rndInt(TH.skill[0], TH.skill[1]) } });
|
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
|
// 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
|
// 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());
|
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);
|
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}`);
|
if (toChat) await announceGenerated(actor, `${TH.label} · ${SP.label} · ${R.label}`);
|
||||||
return actor;
|
return actor;
|
||||||
}
|
}
|
||||||
@@ -4236,6 +4300,36 @@ function buildBiography({ name, role, tier, species, age }) {
|
|||||||
+ `<p><em>Anchor:</em> ${pick(anchor)}.</p>`;
|
+ `<p><em>Anchor:</em> ${pick(anchor)}.</p>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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(`<span>${arms.join(", ")}</span>`);
|
||||||
|
// 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(`<span>armour ${total}${hide ? ` (hide ${hide})` : ""}${worn.length ? ` — ${worn.join(", ")}` : ""}</span>`);
|
||||||
|
}
|
||||||
|
return `<div class="rb-chat-bands">${parts.join("")}</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
async function announceGenerated(actor, subtitle) {
|
async function announceGenerated(actor, subtitle) {
|
||||||
const s = actor.system;
|
const s = actor.system;
|
||||||
await ChatMessage.create({
|
await ChatMessage.create({
|
||||||
@@ -4248,7 +4342,8 @@ async function announceGenerated(actor, subtitle) {
|
|||||||
<span>Reaction ${s.reaction?.base ?? "?"}</span>
|
<span>Reaction ${s.reaction?.base ?? "?"}</span>
|
||||||
<span>${actor.items.filter(i => i.type === "skill").length} skills</span>
|
<span>${actor.items.filter(i => i.type === "skill").length} skills</span>
|
||||||
<span>${actor.items.filter(i => i.type === "talent").length} talents</span>
|
<span>${actor.items.filter(i => i.type === "talent").length} talents</span>
|
||||||
</div></div>`,
|
</div>
|
||||||
|
${kitLine(actor)}</div>`,
|
||||||
whisper: ChatMessage.getWhisperRecipients("GM").map(u => u.id)
|
whisper: ChatMessage.getWhisperRecipients("GM").map(u => u.id)
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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`);
|
||||||
@@ -64,6 +64,11 @@ for (const rel of files) {
|
|||||||
if (rel.startsWith("tools/check-")) continue;
|
if (rel.startsWith("tools/check-")) continue;
|
||||||
const src = await readFile(new URL(rel, root), "utf8");
|
const src = await readFile(new URL(rel, root), "utf8");
|
||||||
src.split("\n").forEach((line, i) => {
|
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}`);
|
for (const [re, why] of REIMPLEMENTED) if (re.test(line)) fail(`${rel}:${i + 1} ${why}`);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
+27
-26
@@ -35,33 +35,28 @@ const runGuard = name => {
|
|||||||
{ encoding: "utf8" }).split("\n").find(l => l.startsWith(name.replace(".mjs", "")));
|
{ encoding: "utf8" }).split("\n").find(l => l.startsWith(name.replace(".mjs", "")));
|
||||||
} catch { return null; }
|
} catch { return null; }
|
||||||
};
|
};
|
||||||
/* All of them, in the order `npm run check` runs them. This list was four long while the
|
/* All of them, in the order `npm run check` runs them — READ from the script that runs
|
||||||
suite was seven, so the README advertised a subset and silently stopped mentioning
|
them, not retyped beside it.
|
||||||
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"];
|
|
||||||
|
|
||||||
/* The list above went stale the moment a guard was added without touching this file —
|
This was a hardcoded array, and the two comments that used to live here both recorded
|
||||||
which is what the comment above it already warned about, and which happened again
|
it going stale: four long while the suite was seven, then missing check-anatomy. The
|
||||||
with check-anatomy. So it is no longer trusted: the suite of record is the `check`
|
response at the time was to keep the array and add a check that it matched
|
||||||
script in package.json, and this refuses to write a README that advertises a
|
package.json, which turned a silent omission into a loud one — an improvement, and
|
||||||
different set than the one the build actually runs. */
|
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 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 found = [...(pkg.scripts?.check ?? "").matchAll(/(check-[\w-]+\.mjs)/g)].map(m => m[1]);
|
||||||
const missing = actual.filter(g => !GUARDS.includes(g));
|
if (!found.length) {
|
||||||
const phantom = GUARDS.filter(g => !actual.includes(g));
|
console.error("update-readme: FAILED — package.json has no `check` script to read the guards from");
|
||||||
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(", "));
|
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
}
|
}
|
||||||
}
|
return [...new Set(found)];
|
||||||
|
})();
|
||||||
|
|
||||||
/* A guard that fails, or that prints under a different name, returns null here. Dropping
|
/* 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
|
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",
|
const COUNT_WORD = ["zero", "one", "two", "three", "four", "five", "six", "seven",
|
||||||
"eight", "nine", "ten", "eleven", "twelve", "thirteen", "fourteen",
|
"eight", "nine", "ten", "eleven", "twelve", "thirteen", "fourteen",
|
||||||
"fifteen", "sixteen", "seventeen", "eighteen", "nineteen", "twenty",
|
"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];
|
const word = COUNT_WORD[guardLines.length];
|
||||||
if (!word) { console.error(`update-readme: ${guardLines.length} guards has no count word`); process.exit(1); }
|
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,
|
/* The words this rewrites FROM were a second hardcoded list, and a shorter one: it
|
||||||
`${word} guards`);
|
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");
|
await writeFile(readme, counted, "utf8");
|
||||||
console.log(`update-readme: OK — ${items} items, ${Object.keys(ROLES).length} postings, `
|
console.log(`update-readme: OK — ${items} items, ${Object.keys(ROLES).length} postings, `
|
||||||
|
|||||||
Reference in New Issue
Block a user