R-266 said a fighter goes down at half maximum hit points and called it one rule in both directions. conditionFor puts someone down at hp <= 2; majorWoundFor is ceil(hp/2) and feeds only how long the dying last; a location is disabled by resolveLocationHit at its own locationMaxHp capacity. The reason it survived reading: for a 10-point neighbour majorWoundFor is 5 and a leg, abdomen or chest holds exactly 5, and for a 12-point agent both are 6. On the locations that get hit most the invented rule returns the real one's answer, and the narration prints MAJOR WOUND and disabled on the same line. Arms and heads are where they part, and I had not looked at an arm. Found by the scenario session going to rules.mjs to verify a different correction of mine and reading the next function along. Both errors made the fight look easier than it is. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
244 lines
12 KiB
JavaScript
244 lines
12 KiB
JavaScript
/**
|
|
* first-blood — who lands the first disabling blow, and what the fight does afterwards.
|
|
*
|
|
* check-lethality and check-focus record how a fight ENDS. Neither records how it TURNS,
|
|
* and the four-player cut of CLEAN GROUND turns early: one hit that meets a limb's own
|
|
* capacity disables it — 4 or 5 points either way here, `locationMaxHp` — and both sides
|
|
* are made of limbs that small, so whoever lands it first costs the other 30 points off the skills that matter,
|
|
* which thins the return fire, which
|
|
* makes the next disable likelier. Nothing pulls the fight back toward the middle once it
|
|
* has tipped, and that — not anything about round five — is where the scenario's bimodal
|
|
* outcome comes from.
|
|
*
|
|
* R-266 measured that in a scratch harness and another session, correctly, would not put
|
|
* it in a scenario: a figure nobody can re-derive is a claim. So the measurement is
|
|
* recorded as an artifact and re-run on every build, the way check-focus does it, and
|
|
* the reading tool and the guard are the same file so they cannot drift apart.
|
|
*
|
|
* node tools/first-blood.mjs read the configured fights
|
|
* node tools/first-blood.mjs --check compare against the committed baseline
|
|
* node tools/first-blood.mjs --update re-record it (after a deliberate change)
|
|
* node tools/first-blood.mjs --creature redcap --count 3 --runs 500
|
|
* an unrecorded fight, for reading only
|
|
*
|
|
* WHAT IS MEASURED. The first hit of the fight that disables a location, destroys one,
|
|
* or drops a fighter — the three conditions the narration marks — and which side landed
|
|
* it. Then the wipe rate on each side of that, which is what the first blow is worth.
|
|
*
|
|
* WHY TWO CONFIGURATIONS. The cut is the claim; six against six is the control. One
|
|
* fighter is a quarter of the cut and a sixth of the line, so the same blow compounds
|
|
* less, and the difference between the two swings is what makes this a property of the
|
|
* cut rather than of the creature.
|
|
*/
|
|
import { readFile, writeFile } from "node:fs/promises";
|
|
import { existsSync } from "node:fs";
|
|
import path from "node:path";
|
|
import { NPCS } from "./content.mjs";
|
|
import { ROSTER } from "./roster.mjs";
|
|
import { runFight, seedFor, makeRng } from "./simulate.mjs";
|
|
import { castAndCut } from "./declared-cast.mjs";
|
|
|
|
const ROOT = path.resolve(path.dirname(new URL(import.meta.url).pathname.replace(/^\/([A-Za-z]:)/, "$1")), "..");
|
|
const BASELINE = path.join(ROOT, "tools", "first-blood-baseline.json");
|
|
|
|
const argv = process.argv.slice(2);
|
|
const opt = (n, d) => { const i = argv.indexOf(n); return i >= 0 ? argv[i + 1] : d; };
|
|
const UPDATE = argv.includes("--update");
|
|
const CHECK = argv.includes("--check");
|
|
|
|
const RUNS = Number(opt("--runs", 2000));
|
|
const SEEDS = [11, 4242, 90210];
|
|
|
|
/* CLEAN GROUND's own cast, not the frozen party of the other two guards: this measures a
|
|
scenario's encounter, and the whole finding is about what four of these people can do.
|
|
Read from the document by tools/scenario-cast.mjs, which check-attackers reads too. */
|
|
const { line: LINE, cut: CUT } = castAndCut("first-blood");
|
|
|
|
const CONFIGS = [
|
|
{ id: "cut", label: "four of the roster vs three of the column", party: CUT, creature: "quiet_neighbours_npc", count: 3 },
|
|
{ id: "column", label: "six of the roster vs six of the column", party: LINE, creature: "quiet_neighbours_npc", count: 6 }
|
|
];
|
|
|
|
const roster = keys => keys.map(k => {
|
|
const found = ROSTER.find(r => r.key === k || r.key === "pc_" + k);
|
|
if (!found) { console.error(`first-blood: "${k}" is not on the duty roster`); process.exit(1); }
|
|
return found;
|
|
});
|
|
const creature = key => {
|
|
const spec = NPCS.find(n => n.key === key);
|
|
if (!spec) { console.error(`first-blood: no creature keyed "${key}"`); process.exit(1); }
|
|
return spec;
|
|
};
|
|
const mean = a => a.reduce((x, y) => x + y, 0) / a.length;
|
|
const r1 = n => Number(n.toFixed(1));
|
|
|
|
/** One sweep: who struck first, in what round, and how those fights ended. */
|
|
function sweep(party, foes, seed, runs) {
|
|
const by = { party: { n: 0, wiped: 0, rounds: 0 }, column: { n: 0, wiped: 0, rounds: 0 } };
|
|
let undecided = 0;
|
|
for (let i = 0; i < runs; i++) {
|
|
let first = null, firstRound = 0, round = 0;
|
|
const r = runFight(makeRng(seed + i * 2654435761), party, foes, { say: e => {
|
|
if (e.kind === "round") round = e.round;
|
|
if (first) return;
|
|
if (e.kind === "hit" && (e.disabled || e.destroyed || e.defender.hp <= 0)) {
|
|
first = e.defender.side === "enemy" ? "party" : "column";
|
|
firstRound = round;
|
|
}
|
|
}});
|
|
if (!first) { undecided++; continue; }
|
|
const b = by[first];
|
|
b.n++; b.rounds += firstRound;
|
|
if (r.wiped) b.wiped++;
|
|
}
|
|
const decided = by.party.n + by.column.n;
|
|
return {
|
|
firstShare: (100 * by.party.n) / Math.max(decided, 1),
|
|
meanRound: (by.party.rounds + by.column.rounds) / Math.max(decided, 1),
|
|
wipeIfParty: (100 * by.party.wiped) / Math.max(by.party.n, 1),
|
|
wipeIfColumn: (100 * by.column.wiped) / Math.max(by.column.n, 1),
|
|
undecided
|
|
};
|
|
}
|
|
|
|
/** The recorded measurement: three seeds, averaged, with the seed-to-seed spread kept. */
|
|
function measure(cfg, runs = RUNS) {
|
|
const party = roster(cfg.party);
|
|
const foes = Array(cfg.count).fill(creature(cfg.creature));
|
|
const each = SEEDS.map(s => sweep(party, foes, seedFor(s, cfg.id), runs));
|
|
const swings = each.map(e => e.wipeIfColumn - e.wipeIfParty);
|
|
const shares = each.map(e => e.firstShare);
|
|
return {
|
|
party: cfg.party, creature: cfg.creature, count: cfg.count,
|
|
firstShare: r1(mean(shares)),
|
|
meanRound: r1(mean(each.map(e => e.meanRound))),
|
|
wipeIfParty: r1(mean(each.map(e => e.wipeIfParty))),
|
|
wipeIfColumn: r1(mean(each.map(e => e.wipeIfColumn))),
|
|
swing: r1(mean(swings)),
|
|
shareNoise: r1(Math.max(...shares) - Math.min(...shares)),
|
|
swingNoise: r1(Math.max(...swings) - Math.min(...swings)),
|
|
undecided: each.reduce((n, e) => n + e.undecided, 0)
|
|
};
|
|
}
|
|
|
|
/* An ad-hoc fight, for reading rather than recording. Nothing here reaches the baseline. */
|
|
if (!UPDATE && !CHECK && (argv.includes("--creature") || argv.includes("--party") || argv.includes("--count"))) {
|
|
const cfg = {
|
|
id: "ad-hoc", label: "an unrecorded fight",
|
|
party: (opt("--party", CUT.join(","))).split(",").map(x => x.trim()).filter(Boolean),
|
|
creature: opt("--creature", "quiet_neighbours_npc"),
|
|
count: Number(opt("--count", 3))
|
|
};
|
|
const m = measure(cfg, RUNS);
|
|
console.log(`\nfirst fighter out of the fight — ${RUNS} runs x ${SEEDS.length} seeds, unrecorded`);
|
|
console.log(`${cfg.party.length} of the roster vs ${cfg.count} x ${creature(cfg.creature).name ?? cfg.creature}\n`);
|
|
console.log(` party strikes first in ${m.firstShare}% of fights (mean round ${m.meanRound})`);
|
|
console.log(` wipes: ${m.wipeIfParty}% when the party strikes first, ${m.wipeIfColumn}% when it does not `
|
|
+ `— a swing of ${m.swing}`);
|
|
process.exit(0);
|
|
}
|
|
|
|
if (UPDATE) {
|
|
const configs = {};
|
|
for (const cfg of CONFIGS) configs[cfg.id] = { label: cfg.label, ...measure(cfg) };
|
|
await writeFile(BASELINE, JSON.stringify({
|
|
note: "Generated by tools/first-blood.mjs --update. Do not edit by hand.",
|
|
runs: RUNS, seeds: SEEDS, configs
|
|
}, null, 2) + "\n", "utf8");
|
|
console.log(`first-blood: baseline recorded — ${CONFIGS.length} configurations, `
|
|
+ `${RUNS} runs x ${SEEDS.length} seeds`);
|
|
process.exit(0);
|
|
}
|
|
|
|
if (!existsSync(BASELINE)) {
|
|
console.error("first-blood: no baseline. Run with --update to record one.");
|
|
process.exit(1);
|
|
}
|
|
const base = JSON.parse(await readFile(BASELINE, "utf8"));
|
|
|
|
if (!CHECK) {
|
|
console.log(`\nfirst fighter out of the fight — ${base.runs} runs x ${base.seeds.length} seeds`);
|
|
for (const [id, was] of Object.entries(base.configs)) {
|
|
console.log(`\n ${was.label}`);
|
|
console.log(` party strikes first in ${was.firstShare}% of fights (mean round ${was.meanRound})`);
|
|
console.log(` wipes: ${was.wipeIfParty}% when the party strikes first, ${was.wipeIfColumn}% when it does not `
|
|
+ `— a swing of ${was.swing}`);
|
|
}
|
|
console.log(`\n recorded by tools/first-blood.mjs --update; re-checked on every build.\n`);
|
|
process.exit(0);
|
|
}
|
|
|
|
/* ---- --check ------------------------------------------------------------------ */
|
|
|
|
if (base.runs !== RUNS || base.seeds?.join() !== SEEDS.join()) {
|
|
console.error(`first-blood: FAILED — the baseline was recorded under different conditions `
|
|
+ `(${base.runs} runs, seeds ${base.seeds?.join(",")}). Re-record it with --update.`);
|
|
process.exit(1);
|
|
}
|
|
|
|
/* Exact comparison is only honest if the measurement is exact. Prove it, don't assume. */
|
|
{
|
|
const probe = CONFIGS[0];
|
|
const a = JSON.stringify(measure(probe, 200));
|
|
const b = JSON.stringify(measure(probe, 200));
|
|
if (a !== b) {
|
|
console.error("first-blood: FAILED — the simulation is not deterministic, so an exact "
|
|
+ `baseline cannot mean anything. ${probe.id} measured twice gave ${a} and ${b}.`);
|
|
process.exit(1);
|
|
}
|
|
}
|
|
|
|
const now = {}, problems = [];
|
|
for (const cfg of CONFIGS) {
|
|
const was = base.configs[cfg.id];
|
|
if (!was) { problems.push(` ${cfg.id}: not in the baseline — re-record with --update`); continue; }
|
|
if (was.party.join() !== cfg.party.join()) {
|
|
const gone = was.party.filter(k => !cfg.party.includes(k));
|
|
const added = cfg.party.filter(k => !was.party.includes(k));
|
|
problems.push(` ${cfg.id}: the scenario now casts different people — `
|
|
+ (gone.length ? `no longer ${gone.join(", ")}` : "the same six, reordered")
|
|
+ (added.length ? `, now ${added.join(", ")}` : "")
|
|
+ `. The recorded swing describes the old cast; re-record with --update, do not revert the cast.`);
|
|
continue;
|
|
}
|
|
if (was.creature !== cfg.creature || was.count !== cfg.count) {
|
|
problems.push(` ${cfg.id}: recorded against ${was.count} x ${was.creature}, now ${cfg.count} x ${cfg.creature}`);
|
|
continue;
|
|
}
|
|
const m = now[cfg.id] = measure(cfg);
|
|
for (const k of ["firstShare", "meanRound", "wipeIfParty", "wipeIfColumn", "swing"]) {
|
|
if (m[k] !== was[k]) problems.push(` ${cfg.id}: ${k} ${was[k]} -> ${m[k]}`);
|
|
}
|
|
}
|
|
for (const id of Object.keys(base.configs)) {
|
|
if (!CONFIGS.some(c => c.id === id)) problems.push(` ${id}: gone from the tool — re-record with --update`);
|
|
}
|
|
|
|
if (problems.length) {
|
|
console.error(`first-blood: FAILED — the fight no longer turns the way it was recorded`);
|
|
problems.forEach(p => console.error(p));
|
|
process.exit(1);
|
|
}
|
|
|
|
/* The claims, restated as tests rather than as prose. Only what a page would assert: the
|
|
drift comparison above already catches every movement, and a guard that asserts more
|
|
than the finding needs is a guard that fails on news rather than on error — R-261. */
|
|
const cut = now.cut, col = now.column;
|
|
|
|
if (Math.abs(cut.firstShare - 50) > 5) {
|
|
console.error(`first-blood: FAILED — first blood is no longer near even in the cut `
|
|
+ `(${cut.firstShare}% to the party). The finding calls it a coin, and a coin that lands `
|
|
+ `one way ${cut.firstShare}% of the time is not one. Rewrite the finding, don't re-record.`);
|
|
process.exit(1);
|
|
}
|
|
if (cut.swing <= col.swing + cut.swingNoise + col.swingNoise) {
|
|
console.error(`first-blood: FAILED — the first blow is worth ${cut.swing} points in the cut and `
|
|
+ `${col.swing} in the six-a-side line, which is no longer a difference this sampling can see `
|
|
+ `(noise ${cut.swingNoise} and ${col.swingNoise}). The finding says the compounding is a `
|
|
+ `property of the cut; rewrite it rather than re-recording.`);
|
|
process.exit(1);
|
|
}
|
|
|
|
console.log(`first-blood: OK — first blood lands round ${cut.meanRound} and is worth ${cut.swing} points `
|
|
+ `of wipe rate in the cut, ${col.swing} in the line`);
|