c0 argued a baseline for the step-5 split would be a cache of the rule and a guard
over it would mostly assert that arithmetic has not changed. Right objection,
wrong conclusion: do not store it. ARTIFACTS now takes a { derive } entry as well
as a file path -- enumerated on this build, nothing stored, and no --update able
to silence a real disagreement between the document and the game.
tools/step5-split.mjs enumerates all 10,000 pairs through opposedContestFor with
its targets derived: 55 and 60 are the lowest POWx5 in the six and in the cut, 42
is applyDifficulty(85, "difficult"). Two different rules produce those three
numbers -- the accepted row is a named exception, not the lowest of anything -- and
a test fails if anyone unifies them. A tie in "the lowest POW in the room" is
fatal rather than silently resolved; it fired for real in testing.
Proved four ways, including raising Okonkwo's POW to 13: the lowest moves to
Braithwaite, refused.held goes 48.1 to 52.4, and the citation that was correct a
moment earlier fails. The figure follows the rule.
Landed unused -- the markers are c0's to place in their own file.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
151 lines
7.9 KiB
JavaScript
151 lines
7.9 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;
|
||
|
||
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 }
|
||
};
|
||
for (const k of ["refused", "cut", "accepted"]) {
|
||
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"]) {
|
||
const r = s[k];
|
||
console.log(` ${k.padEnd(9)} ${(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(9)} ${"across all games".padEnd(18)} `
|
||
+ `${String(b.taken).padStart(5)} ${String(b.held).padStart(6)} ${String(b.unsettled).padStart(9)}\n`);
|
||
}
|