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:
co-authored by
Claude Opus 5
parent
104b588e86
commit
5dcaaf519a
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
@@ -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"
|
||||
|
||||
@@ -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
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user