Files
RingBRP/docs/NPC_KIT_PLAN.md
T
slaguru666andClaude Opus 5 4dd72c6248 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>
2026-09-23 00:08:12 +01:00

10 KiB
Raw Blame History

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:

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:

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:

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:

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.