R-294: check-cited resolves derived figures against the rule, not a stored copy
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>
This commit is contained in:
co-authored by
Claude Opus 5
parent
90cb251358
commit
fb6e2cf321
@@ -0,0 +1,150 @@
|
||||
/**
|
||||
* 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`);
|
||||
}
|
||||
Reference in New Issue
Block a user