Files
RingBRP/tools/step5-split.mjs
slaguru666andClaude Opus 5 960fb6be27 check-unmarked: the figures a vocabulary cannot name, and a repair to HEAD
eead663 committed a document citing `step5 unsettledFactor` while the module
that would export it was still uncommitted in another session's working tree,
so `check-cited` fails on a fresh checkout of HEAD. step5-split.mjs is included
here to repair that; the key derives Act Four's 1.29 from the raw proportions,
which is the figure R-294 exists about.

check-figures (R-299) reports OK on the "24" it was written to catch. Its own
entry names why: the eight shapes were read off figures that are already cited,
so the vocabulary is learned from the marked figures and cannot contain the
phrasing of the one nobody marked. Line 1272 is a table cell whose measurement
status lives in the header two rows above it, and figuresIn reads one line.

check-unmarked reads structure instead of vocabulary: a bold percentage or
decimal in an opted-in document, and a bold number in a table whose header row
names a measurement. Twelve unmarked figures in CLEAN GROUND; eleven correct
and unheld, one the stale 24 that line 1142 had been citing correctly as 25 for
130 lines. Waivers carry a reason, an empty one is fatal, and every waiver
prints on a green build.

Two guards now cover one question, which is one too many. The right end state
is the structural classes folded in beside check-figures' SHAPES, keeping its
opt-in rule and ratchet. That fold is offered to its author rather than taken.

Twelve string tests on unmarkedIn (88 -> 100), both halves mutation-checked.
Twenty-two guards green.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-13 19:56:38 +01:00

166 lines
9.2 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* The Act Four countdown's step 5, enumerated rather than sampled.
*
* `opposedContestFor` returns THREE outcomes and CLEAN GROUND described two for a long
* while: the thing takes the person, the person holds, or the contest settles nothing.
* The held case is the likeliest single result at most tables and went unwritten through
* several versions. Worse, the figures that WERE written drifted — a 74.3% quoted as
* something a GM meets when it is a contest that never happens, and a "nearly quadruples"
* built on the same counterfactual. Both were found by re-deriving them by hand.
*
* THIS FILE EXISTS SO NOBODY HAS TO DO THAT BY HAND AGAIN, and so check-cited can hold the
* prose to it. It is a pure function of rules.mjs and the roster: no seed, no runs, no
* noise. That is why there is no baseline JSON beside it and no `--update` — a stored copy
* would be a cache of the rule, and a cache is a thing that can go stale. The figures are
* recomputed on every build and the document is checked against what the game actually
* does today.
*
* node tools/step5-split.mjs print the split, with its derivation
*/
import { opposedContestFor, applyDifficulty } from "../rules.mjs";
import { ROSTER } from "./roster.mjs";
import { expandFromRegister } from "./expand-spec.mjs";
import { castAndCut } from "./declared-cast.mjs";
/* The thing reaches at 75, not 100. This is the one number here with no source in the
engine — it is a scenario constant — so it is named once, and check-cited asserts the
document still says 75 rather than letting the two drift apart silently. */
export const THING_RATING = 75;
/** POW×5, the resistance an agent brings to step 5. */
const powTimesFive = key => {
const spec = ROSTER.find(r => r.key === `pc_${key}`) ?? ROSTER.find(r => r.key === key);
if (!spec) throw new Error(`step5-split: "${key}" is not on the duty roster`);
const e = expandFromRegister(spec);
return { who: e.name, rating: (e.ch?.pow ?? 0) * 5 };
};
/* A TIE IS FATAL, NOT A COIN TOSS. "The lowest POW in the room" stops being well-defined
the moment two agents share it, and a resolver that quietly takes the first would hold the
prose against an arbitrary pick while the scenario's own instruction had become ambiguous.
The failure there is not the wrong answer, it is answering at all — the same call as the
empty cast marker in R-288. Refuse, name both, and let whoever re-cast the roster decide
what the scenario should say. (c0 raised this; today okonkwo is uniquely 55 and
braithwaite uniquely 60, so it costs nothing until it matters.) */
const lowest = keys => {
const rated = keys.map(powTimesFive).sort((a, b) => a.rating - b.rating);
const tied = rated.filter(r => r.rating === rated[0].rating);
if (tied.length > 1) {
console.error(`step5-split: ${tied.map(t => t.who).join(" and ")} both have POW×5 `
+ `${rated[0].rating}, so "the lowest POW in the room" names two people. The countdown `
+ `cannot be enumerated until the scenario says which, and no figure derived from a `
+ `silent choice between them would be worth printing.`);
process.exit(1);
}
return rated[0];
};
/**
* All 10,000 (active, resisting) roll pairs. Enumerated, not sampled: every pair is
* weighted equally and occurs exactly once, so these are the odds and not an estimate
* of them. Percentages to one decimal, which is how the document writes them.
*/
export function split(activeRating, resistingRating) {
let taken = 0, held = 0, unsettled = 0;
for (let a = 1; a <= 100; a++) {
for (let r = 1; r <= 100; r++) {
const o = opposedContestFor({ rating: activeRating, roll: a },
{ rating: resistingRating, roll: r });
if (!o.settled) unsettled++;
else if (o.winner === "active") taken++;
else held++;
}
}
const pc = n => Math.round(n / 10000 * 1000) / 10;
/* `raw` carries the unrounded proportions because the blend below needs them. Returning
only the rounded figures is what made the first version of this file blend 40.7 and 48.7
into 43.7 when the answer is 43.637 — under a comment claiming it used raw proportions.
A comment that describes what the code ought to do is the defect this project is named
for, and this one lasted about ninety seconds. */
return { taken: pc(taken), held: pc(held), unsettled: pc(unsettled),
raw: { taken: taken / 10000, held: held / 10000, unsettled: unsettled / 10000 } };
}
/**
* The cases that actually occur, which is the distinction the document kept losing.
*
* Act Two decides which branch a table is in. If Ashcroft REFUSES, the countdown never
* reaches for him — it reaches for the lowest POW in the room. If he ACCEPTS, it takes him
* and he resists at Difficult. There is no case in which the thing reaches for a refusing
* Ashcroft, so there is no figure for one.
*
* TWO DIFFERENT RULES PRODUCE THESE THREE NUMBERS, and collapsing them into one derivation
* would give an answer that is right today for the wrong reason. The refused rows are
* "the lowest POW in the room" over a population. The accepted row is not the lowest of
* anything — it is a named exception that takes Ashcroft specifically and drops him to
* Difficult. So `lowest()` is used for the first two and never for the third.
*
* The targets are derived, never written down: change a POW on the roster and these move,
* and the prose has to follow. A literal 55 here would agree with a stale document forever.
*
* There are three table sizes and only two rows, which is not an omission. The scaling note
* drops Pollard at five and Okonkwo as well at four; at five the lowest POW is still
* Okonkwo 55, so the five-player figures are identical to the six-player ones and the
* document is right to print no separate row for them.
*/
export function step5Split() {
const { line, cut } = castAndCut("step5-split");
const six = lowest(line);
const four = lowest(cut);
const ash = powTimesFive("ashcroft");
const accepted = { who: ash.who, rating: applyDifficulty(ash.rating, "difficult") };
/* He refuses on a plain Insight success, so the branch weights are that rating. */
const spec = ROSTER.find(r => r.key === "pc_ashcroft");
const insight = (expandFromRegister(spec).skills ?? [])
.find(s => s.fam === "insight")?.val ?? 0;
/* THE CONTEST THAT NEVER HAPPENS, derived on purpose. If Ashcroft refuses, the countdown
does not reach for him at all — it reaches for the lowest POW in the room — so the thing
never faces his undiminished 85. Act Four nevertheless prints that pair, correctly and
with a warning: it is what THE OFFER sold, and without it the reader cannot see the size
of the trade. It is citable here so the document's counterfactual is held to the rule
like everything else, and named `hadHeRefused` so nobody mistakes it for an event. It is
what quoting these numbers as odds a GM meets did to two sentences and a table. */
const rows = {
refused: { ...six, target: six.rating, who: six.who },
cut: { ...four, target: four.rating, who: four.who },
accepted: { ...accepted, target: accepted.rating, who: accepted.who },
hadHeRefused: { who: ash.who, target: ash.rating }
};
for (const k of ["refused", "cut", "accepted", "hadHeRefused"]) {
Object.assign(rows[k], split(THING_RATING, rows[k].target));
}
/* Blended from the RAW proportions, not from the rounded rows — weighting rounded
percentages is how 43.637 becomes 43.7, and it cost a wrong correction once. */
const w = insight / 100;
const mix = k => Math.round((w * rows.refused.raw[k] + (1 - w) * rows.accepted.raw[k]) * 1000) / 10;
const blended = { taken: mix("taken"), held: mix("held"), unsettled: mix("unsettled") };
/* The factor Act Four quotes for what accepting costs in *unsettled* outcomes. Derived
here rather than left as arithmetic in the prose, because it is the figure that was
wrong for five versions — and because it must come off the RAW proportions: the rounded
rows give 1.28 and the answer is 1.29. Exposing it means check-cited holds the document
to it instead of nobody holding it to anything. */
const unsettledFactor = Math.round(rows.accepted.raw.unsettled / rows.refused.raw.unsettled * 100) / 100;
return { ...rows, blended, unsettledFactor, refusedShare: insight,
acceptedShare: 100 - insight, thing: THING_RATING };
}
if (import.meta.main || process.argv[1]?.endsWith("step5-split.mjs")) {
const s = step5Split();
console.log(`\n The thing reaches at ${s.thing}. Ashcroft refuses on Insight, so `
+ `${s.refusedShare}% of tables are in the first row and ${s.acceptedShare}% the second.\n`);
console.log(" case resists taken held unsettled");
for (const k of ["refused", "cut", "accepted", "hadHeRefused"]) {
const r = s[k];
console.log(` ${k.padEnd(13)} ${(r.who + " " + r.target).padEnd(18)} `
+ `${String(r.taken).padStart(5)} ${String(r.held).padStart(6)} ${String(r.unsettled).padStart(9)}`);
}
const b = s.blended;
console.log(` ${"blended".padEnd(13)} ${"across all games".padEnd(18)} `
+ `${String(b.taken).padStart(5)} ${String(b.held).padStart(6)} ${String(b.unsettled).padStart(9)}\n`);
}