diff --git a/docs/NPC_KIT_PLAN.md b/docs/NPC_KIT_PLAN.md new file mode 100644 index 0000000..b2d1960 --- /dev/null +++ b/docs/NPC_KIT_PLAN.md @@ -0,0 +1,198 @@ +# 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: design approved, not yet built. + +--- + +## 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 | — | — | — | natural attacks; draws no issued kit | +| Apex | — | — | — | as above | + +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 nothing.** A revenant does not sign for a riot shield. Anomalous and +Apex keep the `attacks` they have and keep `naturalArmour`. This is a judgement call and +the one most likely to be wrong: the dialog lets a posting and a threat tier be chosen +independently, so "Apex · Containment" is selectable and will produce a creature with +Containment's *skills* and none of its kit. Say the word if you want kit on those too. + +--- + +## 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. + +**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.*