diff --git a/README.md b/README.md index 833c1df..c22d425 100644 --- a/README.md +++ b/README.md @@ -182,7 +182,7 @@ The build runs all twelve guards before it writes anything: ``` -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 diff --git a/docs/REVIEW_LOG.md b/docs/REVIEW_LOG.md index 5694608..784295e 100644 --- a/docs/REVIEW_LOG.md +++ b/docs/REVIEW_LOG.md @@ -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. diff --git a/package.json b/package.json index 05d4fe5..40fb1b5 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/system.json b/system.json index 23ae6ef..2c96728 100644 --- a/system.json +++ b/system.json @@ -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" diff --git a/tools/playthrough.mjs b/tools/playthrough.mjs new file mode 100644 index 0000000..a254f0f --- /dev/null +++ b/tools/playthrough.mjs @@ -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.`); diff --git a/tools/simulate.mjs b/tools/simulate.mjs index 9d5318e..1c2ce2e 100644 --- a/tools/simulate.mjs +++ b/tools/simulate.mjs @@ -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.