Reading a narrated supporter fight showed it being hit on armR and head. It has wings; it
has never had arms. Three defects, all in the pipeline every published number comes from.
1. It located hits by species, not body plan — simulate.mjs read defender.species where
the game writes spec.bodyPlan ?? spec.species ?? "baseline" into speciesProfile
(build-packs.mjs:294). The barghest, kelpie and church grim were fought on two legs
with arms, and the supporter with no wings, so the fight its tactics call the one the
agents can win could not occur in a measured fight.
2. Armour was one scalar for the whole creature, and the harness's locations had no
armour field. Bare wings, the grounded halving and the vital exemption — R-254 and
R-255 — were invisible to every number. Worn armour was summed the same way, which put
a stab vest on a cleaner's head.
3. Nothing was ever grounded: a wing could be ruined and the creature kept flying.
Fixed in the game's order — locate, then apply what that location carries, every term
imported from rules.mjs. A ruined wing calls groundedPlanFor and the wounds carry across
by severity through remapLocationDamage, the function _preUpdate uses.
A bug of mine no guard would have caught: remapLocationDamage returns { damage, moved,
rescaled } and my first draft passed the whole object where a damage map was expected, so
every wound a creature carried was forgiven the moment it came down. check-lethality would
have passed it — fewer wounds means a longer fight, which reads as a number moving, and
this commit moves numbers. Found by probing a landing by hand.
20 of 47 creatures moved, 18 deadlier and 2 less. The supporter goes 69.3% -> 25.1% wiped,
3.38 -> 2.16 down, second deadliest to fourth: it was being measured as a 30-hit-point
creature in uniform armour 9 that could not be grounded. The small rises elsewhere are the
party's armour no longer covering locations it never protected.
check-focus then caught the page overclaiming, which is what it is for: focus fire still
helps in all 30 but only 28 clear their own noise where 31 of 31 did. The guard was
asserting more than the page needs — the bestiary prints that count from the artifact and
cannot overstate it — so it now checks only what the page asserts outright, and the page
rewrote itself to "in 28 of them".
Both baselines re-recorded. Minor version, not a patch: the published numbers changed.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
112 lines
5.9 KiB
JavaScript
112 lines
5.9 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];
|
||
/* A pinned row records the pack size that was TRIED, not one worth playing: the header
|
||
above promises solo for those, and a tool that contradicts its own documentation is the
|
||
defect this file exists to help find. Solo also matches what check-lethality records. */
|
||
const N = rec?.gain !== undefined ? 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;
|
||
if (e.kind === "grounded") {
|
||
lines.push(` >> ${nameOf(e.defender)} COMES DOWN — it is a ${e.plan} now.`
|
||
+ ` Hide ${e.hide} everywhere, ${e.vital} on the chest, and the wings are gone.`);
|
||
return;
|
||
}
|
||
const a = nameOf(e.attacker), d = nameOf(e.defender);
|
||
if (e.kind === "stopped") {
|
||
lines.push(` ${a} → ${d}: ${e.roll} ${e.level.toUpperCase()} · ${e.location?.id ?? "?"}`
|
||
+ ` · ${e.rolled} vs armour ${e.armour}${e.effectiveArmour !== e.armour ? `→${e.effectiveArmour}` : ""}`
|
||
+ ` — stopped dead`);
|
||
return;
|
||
}
|
||
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.location?.id ?? "?"} · ${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.`);
|