The simulator can be read now, not just totalled (R-263)

Every guard here reports a number per creature, and a number cannot say why a fight went
the way it did. That gap is what produced R-258 through R-260: each written from a side
harness built to watch a fight, each modelling something slightly different from the game,
two of the three wrong in ways no guard could catch because no guard was involved.

runFight takes an optional `say` sink. It reports what the simulator already decided — the
attack roll and its target, a defence and the penalty it was made at, the landing level
after a dodge downgrades it, damage against armour before and after armourAgainst, the
location, major wounds, disablement, death. It never touches the generator, so a narrated
fight and a silent one are the same fight; check-lethality and check-focus both still
match their baselines exactly with the hook in place.

tools/playthrough.mjs (npm run play) is its consumer, in the same commit deliberately: a
hook with no reader is the exact defect this project keeps finding in its own rules, and
adding one to the measurement pipeline with only a scratchpad file calling it would have
been committing the thing I have spent the session removing. Pack size comes from
focus-baseline.json so the fight you read is the fight check-focus measures; creatures
recorded as pinned are played solo.

Three redcaps, seed 20260913, same seed both ways. Spread fire: wiped in 10 rounds, 3
dead, and all three redcaps still standing — 39 hit points spread three ways so that none
of it finished anything. Focus fire: same opening, diverging at one target choice in round
1, party wins in 20 with two up. The +13.2 points check-focus records, seen once instead
of averaged.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
slaguru666
2026-09-13 00:30:14 +01:00
co-authored by Claude Opus 5
parent 104b588e86
commit 5dcaaf519a
6 changed files with 164 additions and 9 deletions
+1 -1
View File
@@ -182,7 +182,7 @@ The build runs all twelve guards before it writes anything:
<!-- guards:start -->
```
check-rules: OK — 110 rules, 37 files scanned, 3 aliases + 46 constant sets checked, 395 formulas verified
check-rules: OK — 110 rules, 38 files scanned, 3 aliases + 46 constant sets checked, 395 formulas verified
check-kits: OK — 25 roles, 10 trades, 218 catalogue items, every kit key resolves, every posting can use what it carries, every loadout distinct
check-lang: OK — en.json, 1018 keys, no leaf/branch collisions
check-templates: OK — 19 templates compile
+35
View File
@@ -5668,3 +5668,38 @@ tier finds this defect class.
`simulate.mjs` already uses, rather than letting a second copy grow in the new guard —
which is the one thing this repository forbids. `update-readme` caught the omission fairly
on the first attempt, since it cross-checks the advertised list against `npm run check`.
## R-263 — the simulator can be read now, not just totalled
Every guard in this suite reports a number per creature, and a number cannot say WHY a
fight went the way it did. That gap is why R-258, R-259 and R-260 happened: each was
written from a side harness built to watch a fight, each modelled something slightly
different from the game, and two of the three were wrong in a way no guard could catch
because no guard was involved.
`runFight` now takes an optional `say` sink. It reports what the simulator has already
decided — the attack roll and its target, a defence and the penalty it was made at, the
landing level after a dodge downgrades it, damage against armour before and after
`armourAgainst`, the location, major wounds, disablement and death. **It never touches
the generator**, so a narrated fight and a silent one are the same fight. check-lethality
and check-focus both still match their baselines exactly with the hook in place, which is
the proof rather than the claim.
`tools/playthrough.mjs` (`npm run play`) is its consumer, added in the same commit
deliberately: a hook with no reader is precisely the defect this project keeps finding in
its own rules, and adding one to the measurement pipeline with only a scratchpad file
calling it would have been me committing the thing I have spent the session removing. It
takes its pack size from `focus-baseline.json`, so the fight you read is the fight
check-focus measures, and creatures recorded as pinned are played solo where targeting
cannot mean anything.
**What it shows, immediately.** Three redcaps, seed 20260913, played twice from the same
seed. Spread fire: the party is wiped in 10 rounds, 4 down, 3 dead, and **all three
redcaps are still standing at the end** — thirty-nine hit points spread three ways so that
none of it ever finished anything. Focus fire: the same opening, diverging at one target
choice in round 1, and the party wins in 20 with two on their feet. That is the +13.2
points check-focus records for this creature, seen once instead of averaged.
The defence ladder is legible in the log too — `Ben → Redcap A: 26 SUCCESS — dodged
(10 vs 45, -30 for defences already spent)` — a 75% dodge meeting the second attack of the
round at 45.
+1
View File
@@ -8,6 +8,7 @@
"icons": "node tools/make-icons.mjs",
"portraits": "node tools/make-portraits.mjs",
"bestiary": "node tools/bestiary.mjs",
"play": "node tools/playthrough.mjs",
"mj": "node tools/mj-queue.mjs",
"simulate": "node tools/simulate.mjs",
"check": "bun tools/check-rules.mjs && bun tools/check-kits.mjs && bun tools/check-lang.mjs && bun tools/check-templates.mjs && bun tools/check-behaviour.mjs && bun tools/check-scenarios.mjs && bun tools/check-rollable.mjs && bun tools/check-creatures.mjs && bun tools/check-anatomy.mjs && bun tools/check-lethality.mjs && bun tools/check-focus.mjs && bun tools/check-bestiary.mjs",
+1 -1
View File
@@ -2,7 +2,7 @@
"id": "ringbrp",
"title": "The Custodians",
"description": "A Basic Roleplaying game of agents who cross the crossing to contain what should not be there — and who come back a little less from here each time.",
"version": "1.7.16",
"version": "1.7.17",
"compatibility": {
"minimum": 13,
"verified": "14.364"
+97
View File
@@ -0,0 +1,97 @@
/**
* Play one encounter and read what happened, blow by blow.
*
* The lethality and focus guards print a number per creature, and a number cannot tell
* you WHY a fight went the way it did. Three review-log entries in a row (R-258 to R-260)
* were written from side harnesses that agreed with a number while modelling the wrong
* fight. So this narrates the REAL `runFight` — the one the baselines are recorded from —
* through its `say` hook, which reports decisions and never rolls. The narrated fight is
* the same fight the guards measure, and check-lethality and check-focus both prove it by
* still matching their baselines with the hook in place.
*
* npm run play the recorded pack, both targeting modes
* node tools/playthrough.mjs redcap a named creature
* node tools/playthrough.mjs redcap 20260913 a chosen seed
* node tools/playthrough.mjs redcap 20260913 focus one mode only
*
* The pack size comes from focus-baseline.json, so the fight you read is the fight
* check-focus measures. Creatures it records as pinned — no pack size makes them an even
* fight — are played solo, where targeting cannot mean anything anyway.
*/
import { readFileSync } from "node:fs";
import path from "node:path";
import { NPCS } from "./content.mjs";
import { ROSTER } from "./roster.mjs";
import { runFight } from "./simulate.mjs";
const ROOT = path.resolve(path.dirname(new URL(import.meta.url).pathname.replace(/^\/([A-Za-z]:)/, "$1")), "..");
const base = JSON.parse(readFileSync(path.join(ROOT, "tools", "lethality-baseline.json"), "utf8"));
const focus = JSON.parse(readFileSync(path.join(ROOT, "tools", "focus-baseline.json"), "utf8"));
const KEY = process.argv[2] ?? "redcap";
const SEED = Number(process.argv[3] ?? 20260913);
const spec = NPCS.find(n => n.key === KEY);
if (!spec) {
console.error(`playthrough: no creature keyed "${KEY}". Try one of: `
+ NPCS.slice(0, 6).map(n => n.key).join(", ") + ", ...");
process.exit(1);
}
const party = base.party.map(k => ROSTER.find(r => r.key === k || r.key === "pc_" + k));
const rec = focus.creatures[KEY];
const N = rec?.n ?? 1;
/* The same generator shape the guards use, so a seed here names the same fight there. */
const mk = seed => { let s = seed >>> 0; return () => ((s = (1664525 * s + 1013904223) >>> 0) / 4294967296); };
const firstName = c => (c.name ?? "?").split(" ")[0];
const hp = c => `${Math.max(0, c.hp)}/${c.hpMax}`;
function play(mode) {
/* Identical creatures need telling apart or the log is unreadable: A, B, C in the order
they first do something. */
const tag = new Map();
const nameOf = c => {
if (c.side === "party") return firstName(c);
if (!tag.has(c)) tag.set(c, `${spec.name} ${String.fromCharCode(65 + tag.size)}`);
return tag.get(c);
};
const lines = [];
const say = e => {
if (e.kind === "round") {
lines.push(`\nROUND ${e.round} — ${e.enemy.filter(x => x.hp > 0).length} of ${N} still up`);
return;
}
if (e.kind === "turn") return;
const a = nameOf(e.attacker), d = nameOf(e.defender);
if (e.kind === "miss")
lines.push(` ${a} → ${d}: ${e.roll} vs ${e.target} — ${e.level}`);
else if (e.kind === "dodged")
lines.push(` ${a} → ${d}: ${e.roll} ${e.level.toUpperCase()} — dodged`
+ ` (${e.dodgeRoll} vs ${e.dodgeTarget}${e.penalty ? `, ${e.penalty} for defences already spent` : ""})`);
else if (e.kind === "hit")
lines.push(` ${a} → ${d}: ${e.roll} ${e.level.toUpperCase()}${e.landing !== e.level ? ` → lands ${e.landing}` : ""}`
+ ` · ${e.rolled} vs armour ${e.armour}${e.effectiveArmour !== e.armour ? `→${e.effectiveArmour}` : ""}`
+ ` = ${e.taken} · ${d} ${hp(e.defender)}`
+ `${e.majorWound ? " · MAJOR WOUND" : ""}${e.destroyed ? " · DESTROYED" : e.disabled ? " · disabled" : ""}`
+ `${e.defender.hp <= 0 ? " ** DOWN **" : ""}`);
};
const r = runFight(mk(SEED), party, Array(N).fill(spec), { partyTargets: mode, say });
return { r, lines };
}
const MODES = process.argv[4] ? [process.argv[4]] : (N > 1 ? ["random", "focus"] : ["random"]);
for (const mode of MODES) {
const { r, lines } = play(mode);
const how = N === 1 ? "" : mode === "focus" ? " — party CONCENTRATING FIRE"
: " — party picking targets at random";
console.log(`\n${"=".repeat(78)}\n${N} × ${spec.name.toUpperCase()}${how}\n${"=".repeat(78)}`);
console.log(lines.join("\n"));
console.log(`\n → ${r.won ? "PARTY WINS" : r.wiped ? "PARTY WIPED" : "neither side finished it"}`
+ ` in ${r.rounds} rounds · ${r.partyDown} of 4 down · ${r.dead} dead`);
}
if (rec?.gain !== undefined)
console.log(`\nrecorded by check-focus over ${focus.runs} fights × ${focus.seeds.length} seeds:`
+ ` spread ${rec.spread}% · focus ${rec.focus}% · concentrating fire is worth ${rec.gain} points`);
else if (rec?.pinned !== undefined)
console.log(`\ncheck-focus records this one as pinned at ${rec.pinned}% — no pack size makes it`
+ ` an even fight, so it is played solo here.`);
+29 -7
View File
@@ -229,7 +229,11 @@ const isFighting = c => conditionOf(c) === "up";
/* ---------------------------------------------------------------- one fight */
function attack(rng, attacker, defender) {
/* R-263. `say` is an optional narration sink, passed down from runFight and defaulting to
a no-op. It reports what the simulator already decided — it never touches rng, so a
narrated fight and a silent one are the same fight, which is the only way a play-by-play
of this harness is worth reading. check-lethality and check-focus both prove it. */
function attack(rng, attacker, defender, say = () => {}) {
const arm = attacker.arms[0];
if (!arm) return;
@@ -241,8 +245,13 @@ function attack(rng, attacker, defender) {
// penalty — losing an arm is what stops you fighting, whatever is in your hand — and
// the mapping comes from rules.mjs rather than being restated here.
const atkPen = woundPenaltyFrom(attacker.wounded, mode);
const level = gradeRoll(d100(rng), resolveBands(arm.rating, { situational: atkPen }));
if (level === "fumble" || level === "failure") return;
const roll = d100(rng);
const bands = resolveBands(arm.rating, { situational: atkPen });
const level = gradeRoll(roll, bands);
if (level === "fumble" || level === "failure") {
say({ kind: "miss", attacker, defender, arm, roll, target: bands.effective, level });
return;
}
// One defence per incoming blow, at the stacking penalty for each already spent this
// round, PLUS whatever a ruined pair of legs costs — dodge is a `phys` skill, so a
@@ -251,10 +260,16 @@ function attack(rng, attacker, defender) {
if (defender.dodge > 0) {
const penalty = defencePenaltyFor(defender.defencesThisRound)
+ woundPenaltyFrom(defender.wounded, "phys");
const dLevel = gradeRoll(d100(rng), resolveBands(defender.dodge, { situational: penalty }));
const dBands = resolveBands(defender.dodge, { situational: penalty });
const dRoll = d100(rng);
const dLevel = gradeRoll(dRoll, dBands);
defender.defencesThisRound++;
const outcome = defenceOutcomeFor(level, dLevel);
if (outcome.turnedAside) return;
if (outcome.turnedAside) {
say({ kind: "dodged", attacker, defender, arm, roll, level,
dodgeRoll: dRoll, dodgeTarget: dBands.effective, dLevel, penalty });
return;
}
landing = outcome.landsAt;
}
@@ -300,11 +315,16 @@ function attack(rng, attacker, defender) {
// Recomputed from the whole body, not accumulated, because the effects fold together
// rather than stacking per hit — two disabled legs are one condition, not two.
defender.wounded = locationEffectsFor(defender.locations);
say({ kind: "hit", attacker, defender, arm, roll, level, landing,
rolled: dmg, armour: defender.armour, effectiveArmour: armour, taken,
location: slot, locationLabel: loc.label, majorWound: res.majorWound,
disabled: res.disabled, destroyed: res.destroyed });
}
/** One fight to a conclusion. Returns which side stood, and how long it took. */
export function runFight(rng, partySpecs, enemySpecs,
{ maxRounds = 40, partySequence = null, partyTargets = "random" } = {}) {
{ maxRounds = 40, partySequence = null, partyTargets = "random", say = null } = {}) {
const party = partySpecs.map(s => buildCombatant(s, { side: "party" }));
const enemy = enemySpecs.map(s => buildCombatant(s, { side: "enemy" }));
const all = [...party, ...enemy];
@@ -312,6 +332,7 @@ export function runFight(rng, partySpecs, enemySpecs,
let round = 0;
for (; round < maxRounds; round++) {
if (!party.some(isFighting) || !enemy.some(isFighting)) break;
say?.({ kind: "round", round: round + 1, party, enemy });
// Reaction is re-rolled EVERY round, including the first — the opening round is
// ordered like every other one. Load is not modelled, so this is base plus a die.
@@ -345,7 +366,8 @@ export function runFight(rng, partySpecs, enemySpecs,
const focus = actor.side === "party" && partyTargets === "focus"
? foes.reduce((a, b) => (b.hp < a.hp ? b : a))
: foes[pick];
attack(rng, actor, focus);
say?.({ kind: "turn", actor, target: focus });
attack(rng, actor, focus, say ?? undefined);
}
// End of round: anyone at or below zero loses ground on the dying clock.