41 statblocks carry a POWER in their tactics and simulate.mjs read none of them, so a redcap that ignores the cumulative defence penalty has been measured as a creature that tires -- in check-lethality, in check-focus, and in every figure published about it. The defect was not that the powers were unimplemented, it was that nothing said they were not. powers.mjs classifies all 41: 2 wired, 14 notSimulable with a stated reason, 25 not fight rules. check-powers refuses an unclassified POWER and refuses a notSimulable without a reason -- and it does not test that the harness imports a power, it fights the creature with and without and requires the two to disagree. Moved: the courier 9.4% to 1.0% wiped (it attacks at half while carrying), the redcap 0.7% to 0.9% (small, because these fights rarely spend a second defence). ARGENT AND GULES was wired and then un-wired: it tripled the supporter's wipe rate to 75.2% because the harness has no ground and applied the borough-ground condition unconditionally. Same reason THE PULL is not wired. I had wired one and refused the other on identical facts. Lethality and focus re-recorded, bestiary regenerated. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
663 lines
31 KiB
JavaScript
663 lines
31 KiB
JavaScript
/**
|
|
* The lethality harness — how dangerous is this creature, actually.
|
|
*
|
|
* THROUGH TRAIN Act Three publishes "MEASURED, 400 runs each, four duty-roster agents
|
|
* against the six" and a 24% wipe rate. The starter publishes its own table. Nothing in
|
|
* this repository could produce either number: whatever measured them was never
|
|
* committed, so both are claims rather than results and neither can be re-checked after
|
|
* a rule changes. That is the gap this closes.
|
|
*
|
|
* THE ONE RULE THIS FILE FOLLOWS: it does not know any rules. Every number comes out of
|
|
* rules.mjs — banding, difficulty, graded defences, hit points, major wounds, damage
|
|
* modifiers, Reaction, the dying clock. A harness with its own copy of the combat loop
|
|
* measures a game nobody is playing, and it drifts silently the first time a rule moves.
|
|
* If a rule is missing here, the fix is to export it from rules.mjs, not to write it
|
|
* again. applyDifficulty, resolveBands and gradeRoll were moved out of ringbrp.mjs for
|
|
* exactly this reason — node cannot import the engine, and the engine had the only copy.
|
|
*
|
|
* Deterministic throughout: same creature, same party, same seed, same numbers. That is
|
|
* what lets a published table be verified instead of remembered.
|
|
*
|
|
* node tools/simulate.mjs --creature keepers --party 4 --runs 400
|
|
* node tools/simulate.mjs --creature tt_cordera_man --count 6 --spread
|
|
* node tools/simulate.mjs --creature keepers --party holloway,okonkwo,finch,rahimi
|
|
* node tools/simulate.mjs --creature tt_coat --count 4 --runs 400 --seed 11
|
|
* node tools/simulate.mjs --list
|
|
*
|
|
* WHAT IT MODELS, AND WHAT IT DOES NOT. It runs Reaction order re-rolled every round,
|
|
* attack banding with the 1% floor and the 96-99 rule, one defence per incoming blow at
|
|
* the stacking defence penalty, graded defences, damage by band with armour ignored on a
|
|
* critical and halved on a special, major wounds, and the dying clock.
|
|
*
|
|
* It also, now, models HIT LOCATIONS — 1d20 against the defender's own species table,
|
|
* melee finding limbs and shooting finding centre of mass, each location carrying its
|
|
* own share of the pool, disabled at its maximum and destroyed at twice it. The
|
|
* consequences apply: a destroyed head is unconscious immediately, a ruined arm costs
|
|
* you every attack, a ruined leg costs you every dodge, and a Cadence losing a body
|
|
* costs the whole creature 10% of everything it does.
|
|
*
|
|
* That last one is why this was worth doing. A Cadence has no vital and no head, so it
|
|
* cannot be dropped by a lucky shot and instead degrades toward useless; measured
|
|
* without locations it was simply a person with an odd portrait, and the number the
|
|
* harness printed was about a creature nobody was playing.
|
|
*
|
|
* It still does NOT model bleeding, panic, Coherence, cover, range bands, fire modes or
|
|
* burst. Those all exist in rules.mjs, they all matter, and every one of them makes a
|
|
* fight WORSE for whoever is losing. Read the output as a floor, never as a ceiling.
|
|
*/
|
|
import {
|
|
hitPointsFor, majorWoundFor, damageModifierFor, reactionBaseFrom,
|
|
resolveBands, gradeRoll, defenceOutcomeFor, defencePenaltyFor,
|
|
conditionFor, dyingLimitFor, REACTION,
|
|
locationFor, resolveLocationHit, locationsFor, locationMaxHp,
|
|
locationEffectsFor, woundPenaltyFrom, armourAgainst, damageAfterArmour,
|
|
exposedHideFor, naturalArmourOn, locationArmourFor, groundedPlanFor, remapLocationDamage
|
|
} from "../rules.mjs";
|
|
import { combatPowerFor } from "./powers.mjs";
|
|
import { WEAPONS, ARMOURS, NPCS, PREGENS } from "./content.mjs";
|
|
import { ROSTER } from "./roster.mjs";
|
|
import { expandFromRegister } from "./expand-spec.mjs";
|
|
import { pathToFileURL } from "node:url";
|
|
|
|
const WEAPON = new Map(WEAPONS.map(w => [w.key, w]));
|
|
const ARMOUR = new Map(ARMOURS.map(a => [a.key, a]));
|
|
|
|
/* Readiness is the best of these, floored by INT x 3 — RINGBRP.readinessSkills and
|
|
readinessIntFactor in the engine. Named here because node cannot import them. */
|
|
const READINESS_SKILLS = ["tradecraft", "insight", "spot", "listen"];
|
|
const READINESS_INT_FACTOR = 3;
|
|
|
|
/* ---------------------------------------------------------------- randomness */
|
|
|
|
/**
|
|
* A seed of this creature's own.
|
|
*
|
|
* Every creature used to be measured against seed 11, which made each one independent
|
|
* of its neighbours — inserting a creature perturbs nobody — but gave all forty-seven
|
|
* of them the SAME two hundred dice. One draw's luck was therefore repeated across the
|
|
* whole bestiary: if seed 11 happens to roll cold for the party, every creature in the
|
|
* book looks a little deadlier than it is, and the error points the same way in all of
|
|
* them, so no amount of reading across the table reveals it.
|
|
*
|
|
* Mixing the creature's key into the base seed decorrelates them without giving up
|
|
* anything the guard depends on. It is still deterministic, still reproducible from the
|
|
* base seed alone, and still local: a creature's number is a function of its own key,
|
|
* so adding, removing or reordering the bestiary leaves every other row untouched.
|
|
* Renaming a key does change that creature's dice, which is correct — a renamed key is
|
|
* a different row in the baseline anyway.
|
|
*
|
|
* FNV-1a, because it is four lines and the only property that matters is that different
|
|
* keys land far apart.
|
|
*/
|
|
export function seedFor(base, key = "") {
|
|
let h = 0x811c9dc5;
|
|
for (let i = 0; i < key.length; i++) {
|
|
h ^= key.charCodeAt(i);
|
|
h = Math.imul(h, 0x01000193) >>> 0;
|
|
}
|
|
return ((h ^ (Number(base) >>> 0)) >>> 0);
|
|
}
|
|
|
|
/** mulberry32 — small, fast, and seedable, which is the only property that matters. */
|
|
/* R-265. Exported because anything that cites a measured fight has to draw from the same
|
|
stream to name the same fight. tools/playthrough.mjs had its own LCG, so "seed 2" meant
|
|
one fight there and a different one here, which quietly broke the only thing that tool
|
|
exists for. */
|
|
export function makeRng(seed) {
|
|
let a = seed >>> 0;
|
|
return () => {
|
|
a = (a + 0x6D2B79F5) >>> 0;
|
|
let t = Math.imul(a ^ (a >>> 15), 1 | a);
|
|
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
|
|
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
|
|
};
|
|
}
|
|
|
|
const d = (rng, sides) => 1 + Math.floor(rng() * sides);
|
|
const d100 = rng => d(rng, 100);
|
|
|
|
/** "1d6+2", "2d6", "-1d3", "0" -> { n, sides, mod, sign }. */
|
|
function parseDice(spec) {
|
|
const s = String(spec ?? "0").trim();
|
|
if (!s || s === "0") return { n: 0, sides: 0, mod: 0, sign: 1 };
|
|
const m = s.match(/^([+-])?(\d*)d(\d+)([+-]\d+)?$/i);
|
|
if (!m) {
|
|
const flat = Number(s);
|
|
return { n: 0, sides: 0, mod: Number.isFinite(flat) ? flat : 0, sign: 1 };
|
|
}
|
|
return {
|
|
sign: m[1] === "-" ? -1 : 1,
|
|
n: m[2] ? Number(m[2]) : 1,
|
|
sides: Number(m[3]),
|
|
mod: m[4] ? Number(m[4]) : 0
|
|
};
|
|
}
|
|
|
|
const rollDice = (rng, p) => {
|
|
let t = 0;
|
|
for (let i = 0; i < p.n; i++) t += d(rng, p.sides);
|
|
return p.sign * (t + p.mod);
|
|
};
|
|
/** Maximum, for a critical: "maximum weapon damage, plus a rolled damage modifier". */
|
|
const maxDice = p => p.sign * (p.n * p.sides + p.mod);
|
|
|
|
/* ---------------------------------------------------------------- combatants */
|
|
|
|
function skillMap(spec) {
|
|
const m = new Map();
|
|
for (const s of spec.skills ?? []) m.set(`${s.fam}:${s.spec ?? ""}`, s.val);
|
|
return m;
|
|
}
|
|
|
|
/**
|
|
* A spec written as a job carries no skills and no kit — everything a roster agent can
|
|
* do comes out of the register at build time. Expand it exactly as the build does, or
|
|
* the simulated agent is a civilian with her hands up rather than a Senior Field
|
|
* Officer. A spec that already lists its own skills is a written creature and is used
|
|
* as it stands.
|
|
*/
|
|
function ready(spec) {
|
|
if (spec.skills) return spec;
|
|
if (!spec.role && !spec.trade) return spec;
|
|
return expandFromRegister(spec, { where: "simulate" });
|
|
}
|
|
|
|
/** Everything the loop needs, all of it derived through rules.mjs. */
|
|
export function buildCombatant(rawSpec, { side = "?" } = {}) {
|
|
const spec = ready(rawSpec);
|
|
const ch = spec.ch;
|
|
const skills = skillMap(spec);
|
|
const hp = hitPointsFor(ch.con, ch.siz);
|
|
|
|
const readiness = Math.max(
|
|
(ch.int ?? 0) * READINESS_INT_FACTOR,
|
|
...READINESS_SKILLS.map(k => skills.get(`${k}:`) ?? 0)
|
|
);
|
|
|
|
// Every weapon it can actually use, best FIRST — and "best" is expected damage per
|
|
// attack, not the highest rating. Sorting on rating alone had the Cordera miners
|
|
// punching at brawl 45 instead of drawing the Webley at firearm 35, trading 1d10 for
|
|
// 1d3 because the number was bigger. Nobody chooses a weapon that way.
|
|
// buildActor gives everyone punch, so nobody is ever weaponless — same reason it does.
|
|
const carried = [...new Set(["punch", ...(spec.weapons ?? []), ...(spec.extraWeapons ?? [])])];
|
|
const arms = carried.map(key => {
|
|
const w = WEAPON.get(key);
|
|
if (!w) return null;
|
|
const id = w.spec ? `${w.fam}:${w.spec}` : `${w.fam}:`;
|
|
const rating = skills.get(id) ?? 0;
|
|
const dmg = parseDice(w.dmg);
|
|
const meanDmg = dmg.sign * (dmg.n * (dmg.sides + 1) / 2 + dmg.mod);
|
|
// resolveBands carries the 1% floor and the cap, so even an untrained weapon scores
|
|
// something and nothing has to know the banding rules here.
|
|
const hitChance = resolveBands(rating).band / 100;
|
|
return { w, rating, dmg, usesMod: w.mod !== false, expected: hitChance * meanDmg };
|
|
}).filter(Boolean).sort((a, b) => b.expected - a.expected);
|
|
|
|
/* R-264. Worn armour is PIECES, not a total: a stab vest covers a torso, and summing
|
|
its points into one number put it on the wearer's head. Coverage comes from the
|
|
catalogue exactly as build-packs reads it. */
|
|
const worn = (spec.armour ?? []).map(k => ARMOUR.get(k)).filter(Boolean)
|
|
.map(a => ({ points: Number(a.points) || 0, coverage: a.covers ?? "all" }));
|
|
|
|
/* R-264. The BODY PLAN, not the species — `spec.bodyPlan ?? spec.species ?? "baseline"`
|
|
is what build-packs:294 writes to the actor's speciesProfile, and this harness read
|
|
`species` alone. Four creatures were fought human-shaped: the barghest, the kelpie
|
|
and the church grim on two legs with arms, and the supporter with no wings at all,
|
|
so the one fight its own tactics call winnable could not happen in a measured fight.
|
|
|
|
Every location carries its own share of the hit points, plus the two fields the hide
|
|
rules need: `bare` (a wing carries no hide) and any armour the table gives it. */
|
|
const species = spec.species ?? "baseline";
|
|
const plan = spec.bodyPlan ?? species;
|
|
const mkLocations = (planId, tookBefore = {}) => locationsFor(planId).map(l => {
|
|
const max = locationMaxHp(hp, l.frac);
|
|
const taken = Number(tookBefore[l.id]) || 0;
|
|
return { id: l.id, kind: l.kind, bare: !!l.bare, armour: Number(l.armour) || 0,
|
|
max, taken, disabled: taken >= max, destroyed: taken >= max * 2 };
|
|
});
|
|
const locations = mkLocations(plan);
|
|
|
|
return {
|
|
side, key: spec.key, name: spec.name,
|
|
species,
|
|
/* R-275. What its own statblock says it does in a fight, classified in powers.mjs.
|
|
Null for anybody the harness has no fight rule for, which is most of them. */
|
|
power: combatPowerFor(spec.key),
|
|
hpMax: hp, hp,
|
|
majorWound: majorWoundFor(hp),
|
|
majorWounds: 0,
|
|
dmgMod: parseDice(damageModifierFor((ch.str ?? 0) + (ch.siz ?? 0))),
|
|
reactionBase: reactionBaseFrom(ch.dex, readiness),
|
|
/* Kept as the creature's overall hide for reporting; what a blow actually meets is
|
|
computed per location by armourAt(), because a wing is bare and a grounded belly
|
|
is not the back it was drawn with. */
|
|
naturalArmour: Number(spec.naturalArmour) || 0,
|
|
armour: (Number(spec.naturalArmour) || 0) + worn.reduce((t, p) => t + p.points, 0),
|
|
worn,
|
|
plan, grounded: false, mkLocations,
|
|
dodge: skills.get("dodge:") ?? 0,
|
|
arms,
|
|
locations,
|
|
wounded: locationEffectsFor(locations),
|
|
defencesThisRound: 0,
|
|
dyingRounds: 0,
|
|
startedUp: true
|
|
};
|
|
}
|
|
|
|
/* A destroyed head is "unconscious immediately", which conditionFor has always known how
|
|
to read and this harness never told it — so a headshot used to leave the target
|
|
standing and swinging. */
|
|
const conditionOf = c => conditionFor({
|
|
hp: c.hp, dyingRounds: c.dyingRounds, majorWound: c.majorWounds,
|
|
unconscious: !!c.wounded?.unconscious
|
|
});
|
|
const isFighting = c => conditionOf(c) === "up";
|
|
|
|
/* ---------------------------------------------------------------- one fight */
|
|
|
|
/* R-264. What a blow actually meets at one location: the creature's own hide, less
|
|
whatever the posture and the location take off it, plus every worn piece that reaches
|
|
that kind. Every term comes from rules.mjs — check-rules forbids restating any of it
|
|
here, which is how R-257 ended. */
|
|
function armourAt(c, slot) {
|
|
return locationArmourFor(
|
|
slot.armour + naturalArmourOn(slot, exposedHideFor(c.naturalArmour, c.grounded, slot.kind)),
|
|
c.worn, slot.kind);
|
|
}
|
|
|
|
/* R-264. A flyer with a hole in a wing is a quadruped — the rule the supporter's tactics
|
|
call "the fight the agents can actually win", which no measured fight could reach
|
|
because this harness never changed a creature's body mid-fight. Wounds carry across by
|
|
SEVERITY through remapLocationDamage, the same function the actor's _preUpdate uses, so
|
|
a ruined wing arrives as a ruined foreleg rather than being quietly forgiven. */
|
|
function groundIfWinged(c, slot, say) {
|
|
if (c.grounded || slot.kind !== "wing" || !(slot.disabled || slot.destroyed)) return;
|
|
const landed = groundedPlanFor(c.plan);
|
|
if (!landed || landed === c.plan) return;
|
|
const took = Object.fromEntries(c.locations.filter(l => l.taken > 0).map(l => [l.id, l.taken]));
|
|
/* remapLocationDamage returns { damage, moved, rescaled }, and the first draft of this
|
|
handed the whole object to mkLocations — so every wound the creature was carrying was
|
|
silently forgiven the moment it came down. No guard would have caught it: fewer wounds
|
|
just means a longer fight, which reads as a number moving. The actor's _preUpdate
|
|
destructures it the same way at ringbrp.mjs:462. */
|
|
const { damage: carried } = remapLocationDamage(took, c.plan, landed, c.hpMax);
|
|
c.locations = c.mkLocations(landed, carried);
|
|
c.plan = landed;
|
|
c.grounded = true;
|
|
c.wounded = locationEffectsFor(c.locations);
|
|
say({ kind: "grounded", defender: c, plan: landed,
|
|
hide: exposedHideFor(c.naturalArmour, true, "leg"),
|
|
vital: exposedHideFor(c.naturalArmour, true, "vital") });
|
|
}
|
|
|
|
/* 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;
|
|
|
|
// Where a blow lands depends on how it was thrown: melee finds limbs, because they are
|
|
// between you and the target, and shooting finds centre of mass.
|
|
const mode = arm.w.cls === "melee" ? "melee" : "ranged";
|
|
|
|
// A wounded attacker is a worse attacker. Melee and ranged both take the MANIPULATION
|
|
// 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 roll = d100(rng);
|
|
/* THE CONSIGNMENT: the courier will not fight with the limb that is carrying, so it
|
|
attacks at half rating until the consignment is taken from it. */
|
|
const atkFactor = Number(attacker.power?.attackFactor);
|
|
const rating = atkFactor > 0 && atkFactor < 1
|
|
? Math.floor(arm.rating * atkFactor) : arm.rating;
|
|
const bands = resolveBands(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
|
|
// disabled leg is exactly what stops you getting out of the way.
|
|
let landing = level;
|
|
if (defender.dodge > 0) {
|
|
/* NOT TIRED: a redcap's entry says it ignores the cumulative penalty entirely, and
|
|
until R-275 the harness charged it anyway — so every figure this project has
|
|
published about redcaps described a creature that tires. Wounds still count: the
|
|
power exempts it from spending defences, not from having a ruined leg. */
|
|
const stacking = defender.power?.defenceStacking === "ignores"
|
|
? 0 : defencePenaltyFor(defender.defencesThisRound);
|
|
const penalty = stacking + woundPenaltyFrom(defender.wounded, "phys");
|
|
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) {
|
|
say({ kind: "dodged", attacker, defender, arm, roll, level,
|
|
dodgeRoll: dRoll, dodgeTarget: dBands.effective, dLevel, penalty });
|
|
return;
|
|
}
|
|
landing = outcome.landsAt;
|
|
}
|
|
|
|
// Critical: maximum weapon damage, plus a rolled damage modifier, armour ignored
|
|
// entirely. Special: normal damage, armour counts half rounded down. Success: normal
|
|
// damage, full armour. Straight out of the rules journal's "What a hit does".
|
|
let dmg = landing === "critical" ? maxDice(arm.dmg) : rollDice(rng, arm.dmg);
|
|
if (arm.usesMod) dmg += rollDice(rng, attacker.dmgMod);
|
|
dmg = Math.max(1, dmg); // R-56: a hit that connects always does something
|
|
|
|
/* R-264. WHERE IT LANDS IS DECIDED BEFORE WHAT STOPS IT, because that is the order the
|
|
game resolves in and the two cannot be separated: a wing carries no hide, a grounded
|
|
belly carries half, and a chest carries all of it whatever posture it is in. This
|
|
harness used to apply one armour number for the whole creature and then locate the
|
|
blow afterwards, so the entire design of R-254 and R-255 was invisible to every
|
|
number it produced. It also returned early when armour stopped a blow, before the
|
|
location was rolled at all. */
|
|
const loc = locationFor(d(rng, 20), defender.plan, mode);
|
|
const slot = defender.locations.find(l => l.id === loc.id) ?? defender.locations[0];
|
|
const metArmour = armourAt(defender, slot); // before this blow changes anything
|
|
const armour = armourAgainst(landing, metArmour);
|
|
|
|
/* ARGENT AND GULES halves what the supporter takes from anything the borough owns,
|
|
which is every weapon signed out of Stores — so against the department it always
|
|
applies. Floored, like every other halving in this game. */
|
|
const factor = Number(defender.power?.damageFactor);
|
|
const afterArmour = damageAfterArmour(dmg, armour);
|
|
const taken = factor > 0 && factor < 1 ? Math.floor(afterArmour * factor) : afterArmour;
|
|
if (taken <= 0) {
|
|
say({ kind: "stopped", attacker, defender, arm, roll, level, landing,
|
|
rolled: dmg, armour: metArmour, effectiveArmour: armour,
|
|
location: slot, locationLabel: loc.label });
|
|
return;
|
|
}
|
|
|
|
/* Locate it. 1d20 against the defender's own species table, then the damage goes to
|
|
that location AND to general hit points — the two systems agree rather than
|
|
competing, which is what resolveLocationHit is for.
|
|
|
|
This is where a Cadence stops being a person with a funny picture. Its five bodies
|
|
each carry 0.28 of the pool, it has no vital and no head, so it cannot be dropped by
|
|
one good shot; instead every body that goes costs the whole creature -10% to
|
|
everything, and it degrades toward useless rather than dying. A Vesh, conversely,
|
|
has a ridge for a vital that is a LONG target rather than a small one. None of that
|
|
could be measured before, because damage was never located. */
|
|
const res = resolveLocationHit({
|
|
damage: taken,
|
|
locationMax: slot.max,
|
|
locationTaken: slot.taken,
|
|
majorWoundThreshold: defender.majorWound
|
|
});
|
|
slot.taken = res.locationTaken;
|
|
slot.disabled = res.disabled;
|
|
slot.destroyed = res.destroyed;
|
|
if (res.majorWound) defender.majorWounds++;
|
|
|
|
defender.hp -= taken;
|
|
// 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: metArmour, effectiveArmour: armour, taken,
|
|
location: slot, locationLabel: loc.label, majorWound: res.majorWound,
|
|
disabled: res.disabled, destroyed: res.destroyed });
|
|
|
|
// After the blow is reported, not before it: the wing is ruined by a hit the reader
|
|
// has to have seen first.
|
|
groundIfWinged(defender, slot, say);
|
|
}
|
|
|
|
/** 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", say = null } = {}) {
|
|
const party = partySpecs.map(s => buildCombatant(s, { side: "party" }));
|
|
const enemy = enemySpecs.map(s => buildCombatant(s, { side: "enemy" }));
|
|
const all = [...party, ...enemy];
|
|
|
|
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.
|
|
for (const c of all) {
|
|
c.defencesThisRound = 0;
|
|
c.initiative = c.reactionBase + d(rng, REACTION.die);
|
|
}
|
|
let order = [...all].sort((a, b) => b.initiative - a.initiative);
|
|
|
|
/* R-259. An experiment hook, off by default and never used by the baseline: it
|
|
rearranges the PARTY among the slots initiative already gave them, leaving every
|
|
enemy where it fell. That isolates "what order the agents act in" from "who acts
|
|
before the creature does" — reordering the whole line would measure both at once
|
|
and credit the answer to the wrong one. The initiative dice are still rolled
|
|
either way, so the random stream is identical with the hook on or off. */
|
|
if (partySequence) {
|
|
const slots = order.map((c, i) => [c, i]).filter(([c]) => c.side === "party").map(([, i]) => i);
|
|
const seq = partySequence(order.filter(c => c.side === "party"));
|
|
order = [...order];
|
|
slots.forEach((slot, k) => { order[slot] = seq[k]; });
|
|
}
|
|
|
|
for (const actor of order) {
|
|
if (!isFighting(actor)) continue;
|
|
const foes = (actor.side === "party" ? enemy : party).filter(isFighting);
|
|
if (!foes.length) break;
|
|
/* R-259, second hook, also off by default: "focus" makes the party concentrate on
|
|
one enemy — the most hurt of them — instead of each agent picking at random.
|
|
The die is rolled either way so the stream does not shift. */
|
|
const pick = Math.floor(rng() * foes.length);
|
|
const focus = actor.side === "party" && partyTargets === "focus"
|
|
? foes.reduce((a, b) => (b.hp < a.hp ? b : a))
|
|
: foes[pick];
|
|
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.
|
|
for (const c of all) {
|
|
if (c.hp <= 0 && conditionOf(c) !== "dead") c.dyingRounds++;
|
|
}
|
|
}
|
|
|
|
const standing = party.filter(isFighting).length;
|
|
return {
|
|
rounds: round,
|
|
partyStanding: standing,
|
|
partyDown: party.length - standing,
|
|
partyHurt: party.filter(c => c.hp < c.hpMax).length,
|
|
wiped: standing === 0,
|
|
won: !enemy.some(isFighting) && standing > 0,
|
|
dead: party.filter(c => conditionOf(c) === "dead").length
|
|
};
|
|
}
|
|
|
|
/* ---------------------------------------------------------------- many fights */
|
|
|
|
export function measure(partySpecs, enemySpecs, { runs = 400, seed = 1 } = {}) {
|
|
const rng = makeRng(seed);
|
|
const rounds = [], hurt = [], down = [];
|
|
let wipes = 0, wins = 0, dead = 0;
|
|
|
|
for (let i = 0; i < runs; i++) {
|
|
const r = runFight(rng, partySpecs, enemySpecs);
|
|
rounds.push(r.rounds); hurt.push(r.partyHurt); down.push(r.partyDown);
|
|
if (r.wiped) wipes++;
|
|
if (r.won) wins++;
|
|
dead += r.dead;
|
|
}
|
|
const mean = xs => xs.reduce((a, b) => a + b, 0) / xs.length;
|
|
const median = xs => { const s = [...xs].sort((a, b) => a - b); return s[Math.floor(s.length / 2)]; };
|
|
|
|
return {
|
|
runs, seed,
|
|
partySize: partySpecs.length,
|
|
enemyCount: enemySpecs.length,
|
|
hurtMean: mean(hurt),
|
|
downMean: mean(down),
|
|
roundsMedian: median(rounds),
|
|
wipeRate: wipes / runs,
|
|
winRate: wins / runs,
|
|
deathsPerRun: dead / runs
|
|
};
|
|
}
|
|
|
|
/* ---------------------------------------------------------------- the corpus */
|
|
|
|
const ALL_SPECS = new Map();
|
|
for (const s of [...NPCS, ...PREGENS, ...ROSTER]) ALL_SPECS.set(s.key, s);
|
|
for (const name of ["throughtrain", "starter", "lastadmission", "openday"]) {
|
|
const mod = await import(`./scenario-${name}.mjs`);
|
|
for (const v of Object.values(mod)) {
|
|
if (Array.isArray(v)) for (const s of v) if (s?.ch && s.key) ALL_SPECS.set(s.key, s);
|
|
}
|
|
}
|
|
|
|
/* ---------------------------------------------------------------- cli */
|
|
|
|
/* Only when run directly. Everything above is importable — check-lethality will want
|
|
measure(), and a module that exits the process on import is no use to it. */
|
|
const invokedDirectly = process.argv[1]
|
|
&& import.meta.url === pathToFileURL(process.argv[1]).href;
|
|
|
|
if (invokedDirectly) {
|
|
|
|
const argv = process.argv.slice(2);
|
|
const opt = (n, dflt) => { const i = argv.indexOf(n); return i >= 0 ? argv[i + 1] : dflt; };
|
|
const has = n => argv.includes(n);
|
|
|
|
if (has("--list")) {
|
|
const rows = [...ALL_SPECS.values()].map(s => ` ${s.key.padEnd(24)} ${s.name}`);
|
|
console.log(`${rows.length} specs:\n${rows.sort().join("\n")}`);
|
|
process.exit(0);
|
|
}
|
|
|
|
const creatureKeys = (opt("--creature", "") || "").split(",").map(s => s.trim()).filter(Boolean);
|
|
if (!creatureKeys.length) {
|
|
console.error("usage: node tools/simulate.mjs --creature <key>[,<key>] [--count N] "
|
|
+ "[--party N|name,name] [--runs N] [--seed N]\n"
|
|
+ " node tools/simulate.mjs --creature <key> [--count N] --spread [--party-size N]\n"
|
|
+ " node tools/simulate.mjs --list");
|
|
process.exit(1);
|
|
}
|
|
|
|
const enemySpecs = [];
|
|
for (const k of creatureKeys) {
|
|
const spec = ALL_SPECS.get(k);
|
|
if (!spec) { console.error(`no such creature "${k}" — try --list`); process.exit(1); }
|
|
enemySpecs.push(spec);
|
|
}
|
|
const count = Number(opt("--count", 0));
|
|
if (count > 0) {
|
|
const one = enemySpecs[0];
|
|
enemySpecs.length = 0;
|
|
for (let i = 0; i < count; i++) enemySpecs.push(one);
|
|
}
|
|
|
|
/* The party is duty-roster agents: a number takes the first N, or name them. */
|
|
const partyArg = opt("--party", "4");
|
|
let partySpecs;
|
|
if (/^\d+$/.test(partyArg)) {
|
|
partySpecs = ROSTER.slice(0, Number(partyArg));
|
|
} else {
|
|
partySpecs = partyArg.split(",").map(n => {
|
|
const want = n.trim().toLowerCase();
|
|
const found = ROSTER.find(r => r.key === want || r.key === `pc_${want}`
|
|
|| r.name.toLowerCase().includes(want));
|
|
if (!found) { console.error(`no roster agent "${n}" — try --list`); process.exit(1); }
|
|
return found;
|
|
});
|
|
}
|
|
|
|
/* ------------------------------------------------------------------ spread */
|
|
|
|
/**
|
|
* The same fight against EVERY party the duty roster can field.
|
|
*
|
|
* This exists because a single published number for an encounter turned out to be the
|
|
* wrong instrument. THROUGH TRAIN Act Three advertises one figure — "the party is wiped
|
|
* in 24% of runs" — and the honest answer for that fight is anywhere between 4% and 96%
|
|
* depending on which four agents the players picked at the start of the session. The
|
|
* armed postings walk it; four trades are massacred. A GM reading the single number is
|
|
* reading somebody else's game.
|
|
*
|
|
* Exhaustive rather than sampled: C(16,4) is 1820 parties and the whole sweep takes
|
|
* about half a minute, so the worst case reported IS the worst case rather than an
|
|
* estimate of it. A GM planning a session wants to know the actual floor.
|
|
*/
|
|
if (has("--spread")) {
|
|
const size = Number(opt("--party-size", 4));
|
|
const runs = Number(opt("--runs", 100));
|
|
const seed = Number(opt("--seed", 11));
|
|
|
|
const combos = [];
|
|
(function choose(start, picked) {
|
|
if (picked.length === size) return combos.push([...picked]);
|
|
for (let i = start; i < ROSTER.length; i++) { picked.push(i); choose(i + 1, picked); picked.pop(); }
|
|
})(0, []);
|
|
|
|
process.stderr.write(`measuring ${combos.length} parties of ${size}, ${runs} runs each...\n`);
|
|
const rows = combos.map(idx => {
|
|
const party = idx.map(i => ROSTER[i]);
|
|
const r = measure(party, enemySpecs, { runs, seed });
|
|
return { idx, party, wipe: r.wipeRate, down: r.downMean, hurt: r.hurtMean };
|
|
}).sort((a, b) => a.wipe - b.wipe);
|
|
|
|
const name = p => p.map(x => x.key.replace(/^pc_/, "")).join(", ");
|
|
const pc = x => `${(x * 100).toFixed(1)}%`;
|
|
const mid = rows[Math.floor(rows.length / 2)];
|
|
|
|
/* Each agent's average effect on the wipe rate across every party they appear in,
|
|
against the average of the ones they do not. This is the line a GM actually uses:
|
|
it says who to send, in points of wipe rate, for THIS fight. */
|
|
const effect = ROSTER.map((agent, i) => {
|
|
const inParty = rows.filter(r => r.idx.includes(i));
|
|
const out = rows.filter(r => !r.idx.includes(i));
|
|
const avg = xs => xs.reduce((a, b) => a + b.wipe, 0) / (xs.length || 1);
|
|
return { key: agent.key.replace(/^pc_/, ""), delta: (avg(inParty) - avg(out)) * 100 };
|
|
}).sort((a, b) => a.delta - b.delta);
|
|
|
|
console.log(`
|
|
SPREAD — ${enemySpecs.length} x ${enemySpecs[0].name}
|
|
every party of ${size} the duty roster can field: ${rows.length} of them, ${runs} runs each, seed ${seed}
|
|
|
|
safest ${pc(rows[0].wipe).padStart(6)} wiped ${name(rows[0].party)}
|
|
median ${pc(mid.wipe).padStart(6)} wiped
|
|
hardest ${pc(rows.at(-1).wipe).padStart(6)} wiped ${name(rows.at(-1).party)}
|
|
|
|
Spread of ${(rows.at(-1).wipe - rows[0].wipe) * 100 >= 20 ? "" : "only "}${((rows.at(-1).wipe - rows[0].wipe) * 100).toFixed(0)} points across party choice.
|
|
${(rows.at(-1).wipe - rows[0].wipe) > 0.2 ? " One published number for this fight would be the wrong instrument.\n" : ""}
|
|
each agent's effect on the wipe rate, over every party they are in:
|
|
${effect.map(e => ` ${e.key.padEnd(14)} ${e.delta >= 0 ? "+" : ""}${e.delta.toFixed(1)} points`).join("\n")}
|
|
`);
|
|
process.exit(0);
|
|
}
|
|
|
|
const result = measure(partySpecs, enemySpecs, {
|
|
runs: Number(opt("--runs", 400)),
|
|
seed: Number(opt("--seed", 1))
|
|
});
|
|
|
|
const pct = x => `${(x * 100).toFixed(1)}%`;
|
|
console.log(`
|
|
${enemySpecs.length} x ${enemySpecs[0].name} vs ${partySpecs.length} duty-roster agents
|
|
${partySpecs.map(p => p.name).join(", ")}
|
|
${result.runs} runs, seed ${result.seed}
|
|
|
|
agents hurt ${result.hurtMean.toFixed(2)} of ${result.partySize}
|
|
agents down ${result.downMean.toFixed(2)} of ${result.partySize}
|
|
median rounds ${result.roundsMedian}
|
|
party wiped ${pct(result.wipeRate)}
|
|
party won outright ${pct(result.winRate)}
|
|
deaths per run ${result.deathsPerRun.toFixed(2)}
|
|
`);
|
|
|
|
}
|