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>
98 lines
5.0 KiB
JavaScript
98 lines
5.0 KiB
JavaScript
/**
|
||
* 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.`);
|