# 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.*