Files
RingBRP/tools/npc-cohort.mjs
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

161 lines
7.3 KiB
JavaScript

/**
* How dangerous is a GENERATED NPC — before and after it was issued its posting's kit.
*
* check-lethality measures the 184 creatures written down in the packs. Nothing measured
* the ones the dialog makes, and the kit change moves those and only those: a Trained NPC
* went from a flat 1 point of armour everywhere to whatever a stab vest stops where a stab
* vest covers, and an Apex now wears a breaching suit over its hide.
*
* Both configurations are built here, from one description of the NPC, so the comparison
* is the change and not two different harnesses:
*
* --now the posting's kit, armour as worn pieces, hide only on creature tiers
* --before the six hardcoded tier weapons, no kit, hide on every tier (pre-8dbe885)
*
* The decision about WHAT a posting issues is not reimplemented: it calls drawKitFor,
* the same function generateNPC calls. Everything else about a generated NPC that this
* has to know — the characteristic dice, the skill bands, which tiers are creatures — is
* imported from postings.mjs for the same reason.
*
* Characteristics are the MEAN of each die expression rather than a roll. A generated NPC
* is a random draw and this is asking what the tier is worth, so the average body is the
* subject; a spread over rolled bodies would mix two questions.
*
* node tools/npc-cohort.mjs --role heavy --tier apex
* node tools/npc-cohort.mjs --tier apex,anomalous --role heavy --runs 2000
* node tools/npc-cohort.mjs --table
*/
import { ROLES, TRADES, THREATS, CHARACTERISTIC_DICE, SPECIES, CREATURE_TIER, drawKitFor }
from "../postings.mjs";
import { WEAPONS, ARMOURS, GEAR, DEVICES, SKILL_CATALOGUE } from "./content.mjs";
import { ROSTER } from "./roster.mjs";
import { measure, seedFor } from "./simulate.mjs";
import { brawlFallbackFor } from "../rules.mjs";
const argv = process.argv.slice(2);
const opt = (name, dflt) => {
const i = argv.indexOf(`--${name}`);
return i === -1 || !argv[i + 1] || argv[i + 1].startsWith("--") ? dflt : argv[i + 1];
};
const RUNS = Number(opt("runs", 2000));
const SEED = Number(opt("seed", 11));
/* The same four agents check-lethality freezes, so a cohort number and a creature number
are on one scale. */
const PARTY_KEYS = ["pc_holloway", "pc_okonkwo", "pc_nkemdirim", "pc_ferriby"];
const party = PARTY_KEYS.map(k => {
const f = ROSTER.find(r => r.key === k);
if (!f) { console.error(`npc-cohort: no roster agent "${k}"`); process.exit(1); }
return f;
});
/* Classification by catalogue membership — the rule drawKitFor is written around. */
const classOf = new Map();
for (const [cls, list] of [["weapon", WEAPONS], ["armour", ARMOURS], ["gear", [...GEAR, ...(DEVICES ?? [])]]]) {
for (const x of list) if (!classOf.has(x.key)) classOf.set(x.key, cls);
}
const classify = k => classOf.get(k) ?? null;
const WEAPON_BY_KEY = new Map(WEAPONS.map(w => [w.key, w]));
const SKILL_KEYS = new Set(SKILL_CATALOGUE.map(s => (s[1] ? `${s[0]}:${s[1]}` : s[0])));
/** The average body a tier stands up, before its own shifts. */
function meanCharacteristics() {
const ch = {};
for (const [k, f] of Object.entries(CHARACTERISTIC_DICE)) {
const m = /^(\d+)d(\d+)(?:\+(\d+))?$/.exec(f);
ch[k] = m ? Math.round(Number(m[1]) * (Number(m[2]) + 1) / 2 + Number(m[3] ?? 0)) : 10;
}
return ch;
}
/** A skills array in the shape simulate.mjs reads, mid-band for the tier. */
const skillEntry = (key, val) => {
const [fam, spec = ""] = key.split(":");
return { fam, spec, val };
};
/**
* One generated NPC as a simulator spec.
*
* `mode` is "now" or "before", and the difference between them is the whole point:
* before, the tier chose the weapon and hide stood in for armour on everybody.
*/
function specFor(roleKey, tierKey, mode) {
const R = ROLES[roleKey] ?? TRADES[roleKey];
const TH = THREATS[tierKey];
if (!R || !TH) return null;
const isCreature = CREATURE_TIER.has(tierKey);
const ch = meanCharacteristics();
if (isCreature) { ch.str += 4; ch.con += 4; ch.pow += 4; ch.cha = Math.max(3, ch.cha - 6); }
const band = Math.round((TH.skill[0] + TH.skill[1]) / 2);
const skills = new Map();
// The posting's core plus two support, the same set generateNPC grants.
for (const k of [...R.core, ...R.support.slice(0, 2)]) if (SKILL_KEYS.has(k)) skills.set(k, band);
const weapons = [];
let armour = [];
if (mode === "before") {
for (const atk of TH.attacks ?? []) { weapons.push(atk.weapon); skills.set(atk.skill, band); }
} else {
const drawn = drawKitFor(roleKey, tierKey, { classify, rand: () => 0.5 });
for (const key of drawn.weapons) {
weapons.push(key);
const w = WEAPON_BY_KEY.get(key);
if (w) skills.set(w.spec ? `${w.fam}:${w.spec}` : w.fam, band);
}
armour = drawn.armour;
if (isCreature) {
for (const atk of TH.attacks ?? []) { weapons.push(atk.weapon); skills.set(atk.skill, band); }
}
}
// R-213: the unarmed fallback, at the reduced figure, for anyone whose only attack is
// a firearm. Applied last, exactly as both construction paths apply it.
if (!skills.has("brawl")) skills.set("brawl", brawlFallbackFor(band));
return {
key: `gen_${tierKey}_${roleKey}`,
name: `${TH.label} · ${R.label}`,
species: "baseline", bodyPlan: "baseline",
ch,
skills: [...skills].map(([k, v]) => skillEntry(k, v)),
weapons,
armour,
// Hide is a body's own. Before the change the four human tiers carried it as a
// stand-in for the armour they were never issued.
naturalArmour: mode === "before" ? TH.armour : (isCreature ? TH.armour : 0)
};
}
const roles = String(opt("role", "heavy")).split(",").filter(Boolean);
const tiers = argv.includes("--table")
? Object.keys(THREATS)
: String(opt("tier", "apex,anomalous")).split(",").filter(Boolean);
const tableRoles = argv.includes("--table") ? ["lead", "heavy", "medic"] : roles;
console.log(`npc-cohort — ${RUNS} runs, seed ${SEED}, party ${PARTY_KEYS.map(k => k.slice(3)).join(", ")}`);
console.log("");
/* measure() returns wipeRate as a FRACTION. The first version of this table printed it
with one decimal and a % in the header, so a 90% wipe rate was published as "0.9" —
a twenty-point balance change read as two-tenths of a point and looked like noise.
check-lethality:100 has always multiplied by 100; this now does the same. */
const pc = x => (x * 100).toFixed(1);
console.log(" tier · posting wipe% was Δ kills/run armour");
for (const roleKey of tableRoles) {
for (const tierKey of tiers) {
const now = specFor(roleKey, tierKey, "now");
const before = specFor(roleKey, tierKey, "before");
if (!now) { console.log(` no such posting/tier: ${tierKey} · ${roleKey}`); continue; }
const a = measure(party, [now], { runs: RUNS, seed: seedFor(SEED, now.key) });
const b = measure(party, [before], { runs: RUNS, seed: seedFor(SEED, before.key) });
const worn = now.armour.length
? now.armour.map(k => `${k} ${ARMOURS.find(x => x.key === k)?.points ?? "?"}`).join(", ")
: "—";
const d = (a.wipeRate - b.wipeRate) * 100;
console.log(` ${now.name.padEnd(32)} ${pc(a.wipeRate).padStart(5)} ${pc(b.wipeRate).padStart(6)} `
+ `${((d >= 0 ? "+" : "") + d.toFixed(1)).padStart(6)} `
+ `${a.deathsPerRun.toFixed(2)} was ${b.deathsPerRun.toFixed(2)} `
+ `hide ${now.naturalArmour}, ${worn}`);
}
}