Matching the step-5 prose against the derived source before placing markers left five figures unmatched: 21.9, 74.3 and 3.8, twice each. They are the thing against Ashcroft's undiminished 85 -- the contest that never happens, which Act Four prints on purpose to price what THE OFFER sold. Derived now as hadHeRefused, so the counterfactual is held to the rule like everything else and carries a name that cannot be mistaken for an event. Quoting those three as odds a GM meets is what went wrong in two sentences and a table. Markers not placed: CLEAN_GROUND.md is c0's and they are in it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
159 lines
8.7 KiB
JavaScript
159 lines
8.7 KiB
JavaScript
/**
|
||
* 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") };
|
||
|
||
return { ...rows, blended, 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`);
|
||
}
|