Files
RingBRP/tools/playthrough.mjs
T
slaguru666andClaude Opus 5 5dcaaf519a 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>
2026-09-13 00:30:14 +01:00

98 lines
5.0 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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.`);