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>
235 lines
10 KiB
Markdown
235 lines
10 KiB
Markdown
# NPC KIT — design
|
||
|
||
Generated NPCs arrive with no gear, no armour and one of six weapons, while the dialog
|
||
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: **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.
|
||
|
||
---
|
||
|
||
## The defect
|
||
|
||
Every one of the 36 postings in `postings.mjs` carries a `kit` array — weapon, armour and
|
||
gear keys:
|
||
|
||
```js
|
||
heavy: { label: "Containment", …
|
||
kit: ["rifle_med","breach_suit","prybar","riot_shield","restraints",
|
||
"climbing_kit","bolt_croppers","cordon_kit","ear_defenders"] }
|
||
```
|
||
|
||
Three places read it. `generateDialog` renders it on the role card as **DRAWS FROM
|
||
STORES** (`ringbrp.mjs:4512`). `generateCharacter` issues it (`ringbrp.mjs:3994`).
|
||
`expand-spec.mjs` issues it for packed agents.
|
||
|
||
`generateNPC` reads `R.core`, `R.support` and `R.label`, and nothing else.
|
||
|
||
So the card in the dialog promises 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 it creates is handed a utility knife. The card is not wrong about the
|
||
posting; it is describing a kit the NPC path drops on the floor.
|
||
|
||
Two consequences follow from the same root:
|
||
|
||
- **Six weapons, total.** `THREATS.attacks` hardcodes `dagger`, `pistol_med`,
|
||
`rifle_med`, `baton`, `grasp`, `unmaker` across all six tiers, against a catalogue of
|
||
70. A Containment officer cannot be issued the rifle the posting is built around,
|
||
because the tier chose the weapon and the tier has never heard of the posting.
|
||
- **Armour is a number, not a thing.** `naturalArmour: { value: TH.armour }` is set on
|
||
every tier including the human ones. There is no item on the sheet, so a breaching suit
|
||
cannot be seen, stripped, burned, or reasoned about by location.
|
||
|
||
The content is not the bottleneck. `tools/content.mjs` already holds 66 skills, 70
|
||
weapons, 35 armours, 132 gear and 22 talents. The NPC path is drinking through a straw.
|
||
|
||
---
|
||
|
||
## Principle
|
||
|
||
**The posting says what it carries. The tier says how much of it, and how good with it.**
|
||
|
||
Nothing below adds a hand-written list of items, because a hand-written list of valid
|
||
values drifts and the drift is invisible — the argument `creature-schema.mjs` already
|
||
makes, and the reason the schema derives its vocabularies from the catalogues rather than
|
||
retyping them. Every item an NPC receives is a key already written in the posting.
|
||
|
||
---
|
||
|
||
## Part 1 · Classify kit keys by pack membership
|
||
|
||
`generateNPC` already opens `ringbrp.weapons` and `ringbrp.skills`. It gains `ringbrp.armour`
|
||
and `ringbrp.gear`, and builds one key → document map per pack from `flags.ringbrp.key`.
|
||
|
||
A kit key resolves to exactly one map, and that is its class. There is no second list
|
||
saying which keys are weapons; the pack it lives in is the answer. A key in no pack is
|
||
skipped with a console warning naming it — the same posture `warnKey` already takes,
|
||
because a silent substitution during a case is worse than a noisy one.
|
||
|
||
Classify by pack, never by reading the key. Containment's `climbing_kit` is named
|
||
**"Breaching charges"**, and three other keys in that one posting differ from their
|
||
display names (`rifle_med` is "Marksman rifle", `breach_suit` is "Breaching suit",
|
||
`prybar` is "Entry bar"). The key is an identifier and has drifted from the name it was
|
||
coined for; anything that infers meaning from the spelling of a key will be wrong here
|
||
first. The card is faithful because it looks the name up rather than prettifying the key.
|
||
|
||
---
|
||
|
||
## Part 2 · The tier draws from the posting's stores
|
||
|
||
`THREATS` gains one field per tier:
|
||
|
||
```js
|
||
draw: { weapons: 1, armour: 1, gear: 3 }
|
||
```
|
||
|
||
| Tier | weapons | armour | gear | note |
|
||
|---|---|---|---|---|
|
||
| Bystander | 0 | 0 | 2 | a publican has pockets, not a sidearm |
|
||
| Trained | 1 | 1 | 3 | |
|
||
| Armed | 1 | 1 | 3 | |
|
||
| Dangerous | 2 | 1 | 4 | a primary and a sidearm |
|
||
| Anomalous | 1 | 1 | 3 | keeps its natural attacks as well |
|
||
| Apex | 2 | 2 | 4 | keeps its natural attacks as well |
|
||
|
||
Weapons are taken in the order the posting lists them, because kits are authored primary
|
||
first. Gear is drawn at random from the posting's gear so that four guards off one
|
||
statblock are not four identical guards.
|
||
|
||
**Creature tiers draw too, and their armour stacks on their hide.** A revenant of a
|
||
Containment officer is still wearing what it died in. Anomalous and Apex keep the
|
||
`attacks` they already have — the grasp, the Unmaker — and gain the posting's weapons on
|
||
top, each with the skill that weapon names.
|
||
|
||
The consequence is in Part 4 and it is large. It was chosen with the number in hand.
|
||
|
||
---
|
||
|
||
## Part 3 · Attacks derive from what was issued
|
||
|
||
This is the part that removes a whole class of drift rather than patching it.
|
||
|
||
Every weapon item already names the skill that fires it:
|
||
|
||
```js
|
||
system: { …, skillFamilyId: w.fam, skillSpecialisationId: w.spec, … }
|
||
```
|
||
|
||
So the weapon↔skill pairing table is unnecessary. For each weapon issued, read those two
|
||
fields and grant that skill at the tier's band. A generated NPC cannot hold something it
|
||
cannot use, because the thing it is holding is what names the skill.
|
||
|
||
`THREATS.attacks` survives for the creature tiers only, where there is no posting kit to
|
||
draw from. The comment above it — written when eight Apexes in ten had no usable attack —
|
||
stays true and now applies to a smaller surface.
|
||
|
||
The existing unarmed fallback (R-213: issue `punch` + `brawl` at a reduced rating to
|
||
anyone whose only attack is a firearm) is unchanged and still runs last.
|
||
|
||
---
|
||
|
||
## Part 4 · Armour becomes an item
|
||
|
||
Issued armour is created equipped, like `buildActor` does it.
|
||
|
||
`naturalArmour` drops to 0 for the four human tiers, because the number was standing in
|
||
for the item and keeping both would armour every NPC twice. It stays for Anomalous and
|
||
Apex, which is what it was for — and for those two it now **stacks with drawn armour**,
|
||
because `locationArmourFor` adds every piece covering a location to the body's own:
|
||
|
||
```js
|
||
export function locationArmourFor(base, pieces, kind) {
|
||
let n = Number(base) || 0;
|
||
for (const p of pieces ?? []) {
|
||
if (armourCoversKind(p?.coverage, kind)) n += Number(p?.points) || 0;
|
||
}
|
||
return n;
|
||
}
|
||
```
|
||
|
||
**Apex · Containment therefore stands at 17 armour on the torso** — 7 hide, 7 breaching
|
||
suit, 3 riot shield raised. The mean damage of the 61 weapons in the catalogue is 6.9,
|
||
and the heaviest ordinary one, the anti-materiel rifle, means 9.0. Nothing but the
|
||
Unmaker (1d10+30) reliably breaks that. This is deliberate: an Apex wearing a posting's
|
||
armour is meant to be a thing you cannot shoot down.
|
||
|
||
The counterplay is already in the rules and should be written into the tactics note of
|
||
any such NPC, because a GM who does not know it has an unkillable object rather than a
|
||
fight:
|
||
|
||
- The shield's 3 counts only while **raised**, which costs a hand and −20 to its own
|
||
shooting (`SHIELD.ranged`). Make it shoot and it lowers.
|
||
- `exposedHideFor` **halves hide on a grounded creature** everywhere except the vital.
|
||
Grounded, the same Apex is 3 + 7 + 3 = 13 on the limbs.
|
||
- Armour is by location. `breach_suit` does not cover everything, and `armourFrom`
|
||
already reports per location which pieces reached it.
|
||
|
||
**Obligation:** Phase 5 runs `tools/simulate.mjs` against Apex · Containment and
|
||
Anomalous · Containment, standing and grounded, and the wipe rates go in the review entry.
|
||
A number this far out of band is not allowed to be an assertion.
|
||
|
||
**This changes how hard generated NPCs are to hurt**, in both directions — a Trained NPC
|
||
goes from a flat 1 to whatever a stab vest stops by location, which is more in the chest
|
||
and nothing in the head. `check-lethality` measures packed creatures, not generated ones,
|
||
so it will not catch this. The honest test is `tools/simulate.mjs` against a generated
|
||
cohort, and the numbers go in the review entry.
|
||
|
||
Overload is not a new problem: `stowOverload` already exists for the PC path and is
|
||
reused, so a Containment officer issued four gear items is not permanently slowed by
|
||
owning her own breaching charges.
|
||
|
||
---
|
||
|
||
## Part 5 · The guard that would have caught this
|
||
|
||
A new `tools/check-generator.mjs`, registered in `npm run check` as guard 24.
|
||
|
||
For every posting × tier it asserts:
|
||
|
||
1. Every weapon key the role card prints is a key that resolves in a pack — the card
|
||
cannot advertise an item that does not exist.
|
||
2. Every weapon issued to the actor arrives with the skill named in its own
|
||
`skillFamilyId`/`skillSpecialisationId`.
|
||
3. A tier whose `draw.weapons` is non-zero receives at least one weapon from the posting's
|
||
kit — the assertion that fails today.
|
||
4. The issued set is a subset of the posting's kit plus the tier's natural attacks. The
|
||
generator may issue less than the card shows; it may never issue something the card
|
||
never mentioned.
|
||
|
||
**Written first, and observed failing against current `ringbrp.mjs`.** A guard that passes
|
||
before the fix is measuring something other than the defect — and this repo has spent
|
||
enough of this month on checks that were green for the wrong reason.
|
||
|
||
---
|
||
|
||
## Order of work
|
||
|
||
| Phase | Deliverable | Why here |
|
||
|---|---|---|
|
||
| 1 | `check-generator.mjs`, failing | Proves the defect is where the design says it is |
|
||
| 2 | Pack classification + `draw` tables | No behaviour change until Phase 3 reads them |
|
||
| 3 | `generateNPC` issues kit; attacks derive from it | The fix. Guard goes green |
|
||
| 4 | Armour as item, `naturalArmour` to 0 on human tiers | Separate because it moves numbers |
|
||
| 5 | Simulated before/after, review entry R-310 | The balance change, measured not asserted |
|
||
|
||
Phases 1–3 are worth shipping alone; 4 is the part that needs numbers behind it.
|
||
|
||
---
|
||
|
||
## What this is not
|
||
|
||
Not the monster builder. `docs/CREATURE_FORGE_PLAN.md` covers that, and its Phases 1, 2
|
||
and 4 are already built — schema and validator, simulator and lethality guard, procedural
|
||
portraits. What is missing there is **Phase 3 (`tools/forge.mjs`, absent)** and **Phase 5
|
||
(the in-Foundry importer, absent)**, which together are the authoring experience being
|
||
asked for. That plan explicitly warned against "scope drift toward a full monster
|
||
designer"; wanting to build monsters in the app is a decision that overrides it, but it
|
||
should be taken knowingly and as its own piece of work, after this one lands.
|
||
|
||
---
|
||
|
||
*Private convention play materials — not for sale or distribution.*
|