Files
RingBRP/docs/NPC_KIT_PLAN.md
T
slaguru666andClaude Opus 5 d186d29238 NPC kit: creature tiers draw kit too, and their armour stacks
Anomalous and Apex now draw weapons, armour and gear from the posting on
top of the natural attacks they already carry. Their hide stays, and
locationArmourFor adds worn pieces to it, so Apex · Containment stands at
17 torso armour: 7 hide, 7 breaching suit, 3 riot shield raised. Mean
damage across the 61 weapons is 6.9 and the heaviest ordinary one means
9.0, so nothing but the Unmaker reliably breaks it.

Chosen deliberately with that number in hand. The spec records the
counterplay already in the rules — the shield only counts raised, and
exposedHideFor halves hide on a grounded creature outside the vital — and
obliges Phase 5 to simulate both tiers standing and grounded rather than
assert a lethality band.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-22 22:28:27 +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: 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:

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.