R-294: EXPOSURE's tables measured into an artifact, and 58.7% was 74.9%

Desk pass 9 found the scenario's most consequential table held by nothing:
~30 sampled figures, no baseline, catchable only by re-running the exact
command. lethality-baseline.json does not cover them — it measures creatures
SOLO against a frozen four-agent party that is not this cast.

Building the measurer found the figures were also wrong.

simulate.mjs's CLI calls runFight with no options, so every published figure
was measured at the default 40-round ceiling — and runFight does not report
truncation, it scores whoever is standing when the loop stops. Measured at
400:

  line.column6      14.8% -> 15.2%    34/2000 truncated
  line.hollow6col2   7.8% ->  8.3%   129
  line.hollow6col3  28.7% -> 30.1%   264
  cut.hollow6       58.7% -> 74.9%   578   <- 29% of runs never finished

Fourteen points on the single most alarming number in the case, and the one
v0.16 added specifically to warn four-player tables. fight-tail learned this
in R-270 and carries an assertUncensored; EXPOSURE's own tables never got
one. The longest fight at cap 400 is 120 rounds and 400 vs 2000 are
identical, so the cap is comfortable rather than merely sufficient.

tools/pack-tables.mjs measures all 17 configs against the DERIVED cast via
castAndCut, using measure()'s exact discipline — one rng threaded through
every run, not a reseed per run, because reseeding is a different stream and
would not reproduce the published table. It refuses to report or record a
truncated sweep.

tools/check-packs.mjs holds the baseline against the game, so the pair is not
a loop: check-cited holds the prose against the record, this holds the record
against the harness. Hard claims read `now` and never `base`: nothing
truncated, the party equals the declared cast, config floor, and more of the
same creature may not make the party safer. Figures compare exactly, since
the runs are deterministic.

Verified by breaking it: a drifted baseline, CAP lowered to 40, and a removed
config each turn it red, and --update refuses outright rather than recording
a truncated sweep. The removed-config test first passed for a bad reason —
MIN_CONFIGS is a floor and 16 clears it — so a dropped-config check was added
and re-tested on a row in no monotonic chain. All files restored
byte-identical after each probe.

EXPOSURE's three tables and the nine prose figures around them are rebuilt
from the artifact with citation markers; the multi-seed stability claims were
re-measured too (the cut is 74.9/72.7/75.0/73.5 across four seeds, not
58.7/60.5/60.3/59.8). CLEAN GROUND v0.20.

npm run check: 20 guards pass.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
slaguru666
2026-09-13 16:32:35 +01:00
co-authored by Claude Opus 5
parent fb6e2cf321
commit a5c4378521
6 changed files with 540 additions and 34 deletions
+45 -33
View File
@@ -9,7 +9,7 @@
| | |
|---|---|
| **Version** | **v0.19.1**, 13 September 2026 |
| **Version** | **v0.20**, 13 September 2026 |
| **Can it be run?** | **Yes, from this document alone.** A GM with this file and the compendium can run it end to end. |
| **Is it convention-ready?** | **Nearly.** The print pack is built (`CLEAN_GROUND_HANDOUTS.html`, held by `check-handouts`). What is missing is a run with human beings, and a slot. |
| **Playtesting** | **Seven desk passes, no human runs.** `_1.md` GM chair (seed 4271) · `_2.md` Act Four stress (9314) · `_3.md` player chair, contrarian (5580) · `_4.md` **the clock, against v0.13's recut timings** (6142) · `_5.md` **the four-player cut, played** (8815) · `_6.md` **the four untested branches, forced** (3319) · `_7.md` **an honest session, nothing forced** (7742) · `_8.md` **the cold read, plus an honest session** (5108). Fifty-two fixes plus three post-passes, and pass 7's finding is now a guard: **`check-outcomes` reads every bracketed mechanic tag in the acts and holds what its beat states.** Pass 7 ran ~3:35 against 3:40 at six players with no cuts taken — the first honest pass inside the clock unaided. |
@@ -45,8 +45,8 @@ Read GM ESSENTIALS in full — this is only what a GM most often discovers too l
2. **Ashcroft cannot fight** — no weapon, no unarmed skill, a 1% attack. That is deliberate
and his player must be told before the game. See his entry in Casting.
3. **The lethal encounter is the column, not the monster in Act Four**, which cannot hurt
anybody — and **at four players the six at the back are lethal on their own** (58.7%
wiped, against 0.0% at six). The six at the back are also the encounter the party is
anybody — and **at four players the six at the back are lethal on their own** (**74.9%**<!-- cite: packs cut.hollow6.wiped -->
wiped, against 0%<!-- cite: packs line.hollow6.wiped --> at six). The six at the back are also the encounter the party is
likeliest to choose, because they are the only figures the scenario says are not people.
EXPOSURE has the measured figures and the reason.
@@ -864,7 +864,7 @@ carelessness — this column has done this before, and it has lost people doing
colleague shoots, and what they shoot into is the column it was standing in. **Read
EXPOSURE's "The fight above is not the fight the table will have" before running this
beat** — six at the back alone wipe nobody, with two of the column joining in they wipe
one party in thirteen, and at four players the six alone wipe better than half. The
one party in twelve, and at four players the six alone wipe three in four. The
Pacing Note says what to cut if it starts.
- **It cannot touch an agent carrying an anchor that is genuinely their own** — that is
its own statblock's clause, and it rules **Ashcroft out of this beat entirely**. Aim it
@@ -990,8 +990,8 @@ whoever it is copying, always.
> **The monster cannot hurt them. The refugees can wipe them.** The understudy is a 1d3
> punch that dies in two rounds — do not buff it. Six of the column kill one party in
> seven; ten kill nine in ten. **The six at the back are never fought alone**: two of the
> column joining in takes that fight from nobody dying to one wipe in thirteen, and **at
> four players the six at the back wipe 58.7% on their own.**
> column joining in takes that fight from nobody dying to one wipe in twelve, and **at
> four players the six at the back wipe 74.9% on their own.**
>
> **A fight also costs the session** — a median 13 rounds, 24 if the column joins, which is
> most of an act. **So there is no version of a fight here that is good for anybody**, and
@@ -1005,7 +1005,18 @@ them.** That is the scenario's whole thesis, and it is not a claim — it is mea
who does not know it will pad Act Four to make it frightening and will be padding the one
encounter in the case that is incapable of being dangerous.
**MEASURED.** `tools/simulate.mjs`, 2000 runs each, seed 11, against the cast of six —
> ⚠ **RE-MEASURED AT v0.20, AND SEVERAL FIGURES MOVED.** Every table below was previously
> measured through `simulate.mjs`'s CLI, which calls `runFight` with no options — so it ran
> at the default **40-round ceiling**, and `runFight` does not report truncation: it scores
> whoever is standing when the loop stops. **Six hollow men against the four-player cut hit
> that ceiling in 578 runs of 2000, and the party's real wipe rate is 74.9%, not the 58.7%
> this document printed for three versions.** Fourteen points, on the most alarming number
> in the case. Everything here is now measured at a 400-round cap where nothing truncates
> (the longest fight is 120 rounds), recorded in `tools/pack-tables-baseline.json`, and held
> by **`check-packs`** against the game and by **`check-cited`** against this prose. The
> figures carry citation markers; they are no longer typed.
**MEASURED.** `tools/pack-tables.mjs`, 2000 runs each, seed 11, cap 400, against the cast of six —
Ashcroft, Bhattacharya, Renshaw, Braithwaite, Pollard, Okonkwo. Taken at commit 6f33525
with a clean tree.
@@ -1025,15 +1036,15 @@ node tools/simulate.mjs --creature the_understudy \
| What they fight | Hurt | Down | Wiped | Deaths/run | Median rounds |
|---|---|---|---|---|---|
| **The understudy, alone** | 0.55 of 6 | 0.01 | **0.0%** | 0.00 | **2** |
| A duplicate of an agent | 0.34 of 6 | 0.04 | 0.0% | 0.00 | 2 |
| One hollow man | 0.28 of 6 | 0.00 | 0.0% | 0.00 | 2 |
| Three hollow men | 1.57 of 6 | 0.01 | 0.0% | 0.00 | 6 |
| **Six hollow men** | 3.48 of 6 | 0.42 | **0.0%** | 0.04 | **13** |
| Three of the column | 1.88 of 6 | 0.40 | 0.1% | 0.16 | 5 |
| **Six of the column** | 4.79 of 6 | 3.03 | **14.8%** | 1.55 | **14** |
| **Ten of the column** | 5.97 of 6 | 5.87 | **90.8%** | 2.75 | **13** |
| Fifteen of the column | 6.00 of 6 | 6.00 | **100.0%** | 2.68 | 7 |
| **The understudy, alone** | 0.55<!-- cite: packs line.understudy.hurt --> of 6 | 0.01<!-- cite: packs line.understudy.down --> | **0%**<!-- cite: packs line.understudy.wiped --> | 0.00<!-- cite: packs line.understudy.deaths --> | **2**<!-- cite: packs line.understudy.median --> |
| A duplicate of an agent | 0.34<!-- cite: packs line.duplicate.hurt --> of 6 | 0.04<!-- cite: packs line.duplicate.down --> | 0%<!-- cite: packs line.duplicate.wiped --> | 0.00<!-- cite: packs line.duplicate.deaths --> | 2<!-- cite: packs line.duplicate.median --> |
| One hollow man | 0.28<!-- cite: packs line.hollow1.hurt --> of 6 | 0<!-- cite: packs line.hollow1.down --> | 0%<!-- cite: packs line.hollow1.wiped --> | 0.00<!-- cite: packs line.hollow1.deaths --> | 2<!-- cite: packs line.hollow1.median --> |
| Three hollow men | 1.57<!-- cite: packs line.hollow3.hurt --> of 6 | 0.01<!-- cite: packs line.hollow3.down --> | 0%<!-- cite: packs line.hollow3.wiped --> | 0.00<!-- cite: packs line.hollow3.deaths --> | 6<!-- cite: packs line.hollow3.median --> |
| **Six hollow men** | 3.48<!-- cite: packs line.hollow6.hurt --> of 6 | 0.42<!-- cite: packs line.hollow6.down --> | **0%**<!-- cite: packs line.hollow6.wiped --> | 0.04<!-- cite: packs line.hollow6.deaths --> | **13**<!-- cite: packs line.hollow6.median --> |
| Three of the column | 1.88<!-- cite: packs line.column3.hurt --> of 6 | 0.4<!-- cite: packs line.column3.down --> | 0.1%<!-- cite: packs line.column3.wiped --> | 0.16<!-- cite: packs line.column3.deaths --> | 5<!-- cite: packs line.column3.median --> |
| **Six of the column** | 4.84<!-- cite: packs line.column6.hurt --> of 6 | 3.07<!-- cite: packs line.column6.down --> | **15.2%**<!-- cite: packs line.column6.wiped --> | 1.59<!-- cite: packs line.column6.deaths --> | **14**<!-- cite: packs line.column6.median --> |
| **Ten of the column** | 5.97<!-- cite: packs line.column10.hurt --> of 6 | 5.86<!-- cite: packs line.column10.down --> | **90.8%**<!-- cite: packs line.column10.wiped --> | 2.74<!-- cite: packs line.column10.deaths --> | **13**<!-- cite: packs line.column10.median --> |
| Fifteen of the column | 6<!-- cite: packs line.column15.hurt --> of 6 | 6<!-- cite: packs line.column15.down --> | 100%<!-- cite: packs line.column15.wiped --> | 2.68<!-- cite: packs line.column15.deaths --> | 7<!-- cite: packs line.column15.median --> |
**Read these as a floor.** The harness does not model bleeding, Panic, Coherence, cover,
range bands or fire modes, and every one of those makes a fight worse for whoever is
@@ -1049,14 +1060,14 @@ not how dangerous the hollow men are. It is how many of the column join in.**
| What they actually fight | Hurt | Down | Wiped | Deaths/run | Median rounds |
|---|---|---|---|---|---|
| Six hollow men, column stands off | 3.48 of 6 | 0.42 | **0.0%** | 0.04 | 13 |
| **+ two of the column** | 5.02 of 6 | 2.78 | **7.8%** | 0.98 | 21 |
| **+ three of the column** | 5.55 of 6 | 4.07 | **28.7%** | 1.56 | 24 |
| + six of the column | 5.99 of 6 | 5.88 | **92.0%** | 2.33 | 16 |
| Six hollow men, column stands off | 3.48<!-- cite: packs line.hollow6.hurt --> of 6 | 0.42<!-- cite: packs line.hollow6.down --> | 0%<!-- cite: packs line.hollow6.wiped --> | 0.04<!-- cite: packs line.hollow6.deaths --> | 13<!-- cite: packs line.hollow6.median --> |
| **+ two of the column** | 5.01<!-- cite: packs line.hollow6col2.hurt --> of 6 | 2.79<!-- cite: packs line.hollow6col2.down --> | **8.3%**<!-- cite: packs line.hollow6col2.wiped --> | 0.97<!-- cite: packs line.hollow6col2.deaths --> | **21**<!-- cite: packs line.hollow6col2.median --> |
| **+ three of the column** | 5.55<!-- cite: packs line.hollow6col3.hurt --> of 6 | 4.14<!-- cite: packs line.hollow6col3.down --> | **30.1%**<!-- cite: packs line.hollow6col3.wiped --> | 1.55<!-- cite: packs line.hollow6col3.deaths --> | **25**<!-- cite: packs line.hollow6col3.median --> |
| + six of the column | 5.99<!-- cite: packs line.hollow6col6.hurt --> of 6 | 5.9<!-- cite: packs line.hollow6col6.down --> | 93.8%<!-- cite: packs line.hollow6col6.wiped --> | 2.31<!-- cite: packs line.hollow6col6.deaths --> | 16<!-- cite: packs line.hollow6col6.median --> |
**Two of forty-one taking a swing moves the encounter from "nobody dies" to one wipe in
thirteen. Three takes it past one in four.** Measured the same way as the table above —
2000 runs, seed 11 — and stable across four seeds: 7.3–7.8% at two, 27.8–29.1% at three.
twelve. Three takes it to nearly one in three.** Measured the same way as the table above —
2000 runs, seed 11 — and stable across four seeds: **8.3–8.8%** at two, **30.1–32.6%** at three.
- **How many join in is the GM's call and it is the most consequential call in the case.**
Ivy holds the line by consent and will spend it to stop a firefight; she is one woman
@@ -1078,8 +1089,9 @@ thirteen. Three takes it past one in four.** Measured the same way as the table
to resolve. In a 45-minute act that is the whole act, and the Close is what pays for it.
If Act Four feels thin, the answer is more doubt, not more bodies.
**Both conditions on that sentence are load-bearing**: two of the column joining takes
it to 7.8%, and at four players six hollow men wipe **58.7%** on their own. See the two
blocks above and below.
it to 8.3%<!-- cite: packs line.hollow6col2.wiped -->, and at four players six hollow men
wipe **74.9%**<!-- cite: packs cut.hollow6.wiped --> on their own. See the two blocks above
and below.
3. **The column is the lethal encounter in this scenario, and it is not in Act Four.**
Three of them is survivable, though somebody dies in about one run in six. **Six wipes
one party in seven. Ten wipes nine parties in ten.** The curve between six and ten is
@@ -1094,21 +1106,21 @@ thirteen. Three takes it past one in four.** Measured the same way as the table
| Four-player cut fights | Hurt | Down | Wiped | Deaths/run |
|---|---|---|---|---|
| Three hollow men | 2.80 of 4 | 0.33 | **0.3%** | 0.03 |
| **Six hollow men** | 3.94 of 4 | 3.40 | **58.7%** | 0.42 |
| **Six hollow men + two of the column** | 4.00 of 4 | 4.00 | **99.9%** | 1.11 |
| Three of the column | 3.25 of 4 | 2.31 | **45.0%** | 0.98 |
| Six of the column | 4.00 of 4 | 3.99 | **99.8%** | 1.46 |
| Three hollow men | 2.8<!-- cite: packs cut.hollow3.hurt --> of 4 | 0.33<!-- cite: packs cut.hollow3.down --> | 0.5%<!-- cite: packs cut.hollow3.wiped --> | 0.03<!-- cite: packs cut.hollow3.deaths --> |
| **Six hollow men** | 3.96<!-- cite: packs cut.hollow6.hurt --> of 4 | 3.59<!-- cite: packs cut.hollow6.down --> | **74.9%**<!-- cite: packs cut.hollow6.wiped --> | 0.42<!-- cite: packs cut.hollow6.deaths --> |
| **Six hollow men + two of the column** | 4<!-- cite: packs cut.hollow6col2.hurt --> of 4 | 4<!-- cite: packs cut.hollow6col2.down --> | **100%**<!-- cite: packs cut.hollow6col2.wiped --> | 1.11<!-- cite: packs cut.hollow6col2.deaths --> |
| Three of the column | 3.25<!-- cite: packs cut.column3.hurt --> of 4 | 2.31<!-- cite: packs cut.column3.down --> | 45.3%<!-- cite: packs cut.column3.wiped --> | 0.98<!-- cite: packs cut.column3.deaths --> |
| Six of the column | 4<!-- cite: packs cut.column6.hurt --> of 4 | 3.99<!-- cite: packs cut.column6.down --> | 99.8%<!-- cite: packs cut.column6.wiped --> | 1.46<!-- cite: packs cut.column6.deaths --> |
⚠ **Read the hollow-man rows first, because they are the ones the party will choose.**
The six at the back are the only figures in the valley the scenario tells the players
are not people, so they are the encounter with the lowest moral cost — **and at four
players they wipe better than half of all parties.** The main table's figure for them
is **0.0%**, and that figure is for six players. A GM running the cut who looks the
players they wipe three parties in four.** The main table's figure for them is
**0%**, and that figure is for six players. A GM running the cut who looks the
hollow men up and finds 0.0% is reading somebody else's table.
**The curve from three to six is 0.3% to 58.7%** — the same near-vertical step this
**The curve from three to six is 0.5%<!-- cite: packs cut.hollow3.wiped --> to 74.9%<!-- cite: packs cut.hollow6.wiped -->** — the same near-vertical step this
section warns about for the column between six and ten, one encounter earlier and at a
smaller table. Stable across four seeds (58.7 / 60.5 / 60.3 / 59.8%).
smaller table. Stable across four seeds (74.9 / 72.7 / 75.0 / 73.5%).
**It is not a hard fight. It is two different fights.** That mean of 2.31 down is the
average of a coin flip and describes about a tenth of outcomes: **23% of the time nobody
+1 -1
View File
@@ -11,7 +11,7 @@
"play": "node tools/playthrough.mjs",
"mj": "node tools/mj-queue.mjs",
"simulate": "node tools/simulate.mjs",
"check": "bun tools/check-rules.mjs && bun tools/check-kits.mjs && bun tools/check-lang.mjs && bun tools/check-templates.mjs && bun tools/check-behaviour.mjs && bun tools/check-scenarios.mjs && bun tools/check-rollable.mjs && bun tools/check-outcomes.mjs && bun tools/check-creatures.mjs && bun tools/check-powers.mjs && bun tools/check-anatomy.mjs && bun tools/check-lethality.mjs && bun tools/check-focus.mjs && bun tools/check-firstblood.mjs && bun tools/check-attackers.mjs && bun tools/check-fight-tail.mjs && bun tools/check-cited.mjs && bun tools/check-handouts.mjs && bun tools/check-bestiary.mjs",
"check": "bun tools/check-rules.mjs && bun tools/check-kits.mjs && bun tools/check-lang.mjs && bun tools/check-templates.mjs && bun tools/check-behaviour.mjs && bun tools/check-scenarios.mjs && bun tools/check-rollable.mjs && bun tools/check-outcomes.mjs && bun tools/check-creatures.mjs && bun tools/check-powers.mjs && bun tools/check-anatomy.mjs && bun tools/check-lethality.mjs && bun tools/check-focus.mjs && bun tools/check-firstblood.mjs && bun tools/check-attackers.mjs && bun tools/check-fight-tail.mjs && bun tools/check-packs.mjs && bun tools/check-cited.mjs && bun tools/check-handouts.mjs && bun tools/check-bestiary.mjs",
"test": "bun run check",
"readme": "bun tools/update-readme.mjs"
},
+1
View File
@@ -53,6 +53,7 @@ const ARTIFACTS = {
"fight-tail": "tools/fight-tail-baseline.json",
"lethality": "tools/lethality-baseline.json",
"outcomes": "tools/outcome-coverage-baseline.json",
"packs": "tools/pack-tables-baseline.json",
"step5": { derive: step5Split, label: "tools/step5-split.mjs, enumerated on this build" }
};
const isDerived = name => typeof ARTIFACTS[name] === "object";
+129
View File
@@ -0,0 +1,129 @@
/**
* EXPOSURE's tables, held against the harness that produced them.
*
* node tools/check-packs.mjs hold the claim
* node tools/check-packs.mjs --list print what it found
* node tools/pack-tables.mjs --update re-record after a deliberate change
*
* check-cited holds the PROSE against the baseline. This holds the BASELINE against the
* game, and without it the pair is a loop: re-record, and the prose can be corrected to
* match a figure nobody has checked came from anywhere.
*
* THE HARD CLAIMS READ `now` AND NEVER `base`, so --update cannot silence them — R-270's
* arrangement in check-fight-tail, which is the only part of that guard that cannot be
* argued with. Chief among them is the ceiling: these tables were published for versions at
* runFight's default 40 rounds, which truncated 578 of 2000 runs in the worst config and
* understated its wipe rate by fourteen points. A guard that could be talked out of that
* check would be worth nothing.
*/
import { readFileSync, existsSync } from "node:fs";
import path from "node:path";
import { measureAll, censoredIn, CONFIGS, BASELINE, CAP, RUNS } from "./pack-tables.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 LIST = process.argv.slice(2).includes("--list");
const MIN_CONFIGS = 14;
const FIELDS = ["hurt", "down", "wiped", "deaths", "median"];
if (!existsSync(BASELINE)) {
console.error(`check-packs: FAILED — no baseline at ${path.relative(ROOT, BASELINE)}. `
+ `Record one with: node tools/pack-tables.mjs --update`);
process.exit(1);
}
const base = JSON.parse(readFileSync(BASELINE, "utf8"));
const now = measureAll();
const problems = [];
const notes = [];
/* ── HARD CLAIMS ───────────────────────────────────────────────────────────────────── */
const censored = censoredIn(now);
if (censored.length) {
problems.push(` ${censored.length} config(s) hit the ${CAP}-round ceiling: ${censored.join(", ")}. `
+ `A truncated fight is scored by whoever is standing when the loop stops, so the wipe rate `
+ `is understated and nothing says so. This is the defect that produced the 58.7% these `
+ `tables carried for three versions when the real figure was 74.9%. Raise CAP, re-record.`);
} else {
notes.push(`nothing truncated at ${CAP} rounds (longest ${Math.max(...CONFIGS.map(c => now[c.party][c.id].longest))})`);
}
if (CONFIGS.length < MIN_CONFIGS) {
problems.push(` only ${CONFIGS.length} configs, and EXPOSURE prints at least ${MIN_CONFIGS} rows. `
+ `A shrunken config list would let this guard report full agreement over a table it is no `
+ `longer measuring.`);
}
const { line, cut } = castAndCut("check-packs");
for (const [which, want] of [["line", line], ["cut", cut]]) {
const got = now.party[which];
if (got.join(",") !== want.join(",")) {
problems.push(` the ${which} party measured here is ${got.join(", ")}, and the scenario declares `
+ `${want.join(", ")}. These figures would be a real measurement of a fight this case does not cast.`);
}
}
if (!problems.length) notes.push(`measured against the declared cast: ${line.join(", ")}`);
/* More of the same creature must not make the party safer. Not a style rule — EXPOSURE's
"the curve between six and ten is almost vertical" is an argument about this ordering,
and a violation means the harness has broken rather than that the document is wrong. */
for (const [which, ids] of [["line", ["hollow1", "hollow3", "hollow6"]], ["line", ["column3", "column6", "column10", "column15"]],
["cut", ["hollow3", "hollow6"]], ["cut", ["column3", "column6"]]]) {
for (let i = 1; i < ids.length; i++) {
const a = now[which][ids[i - 1]], b = now[which][ids[i]];
if (b.wiped < a.wiped) {
problems.push(` ${which}.${ids[i]} (${b.n} of them) wipes ${b.wiped}% and ${which}.${ids[i - 1]} `
+ `(${a.n}) wipes ${a.wiped}%. More of the same creature made the party safer, which is the `
+ `harness misbehaving, not a finding.`);
}
}
}
/* ── AGREEMENT WITH THE RECORD — deterministic, so exact ───────────────────────────── */
let drift = 0;
for (const c of CONFIGS) {
const b = base[c.party]?.[c.id], n = now[c.party][c.id];
if (!b) { problems.push(` ${c.party}.${c.id} is measured here and absent from the baseline. Re-record.`); continue; }
for (const f of FIELDS) {
if (b[f] !== n[f]) {
drift++;
problems.push(` ${c.party}.${c.id}.${f}: the game now says ${n[f]}, the baseline holds ${b[f]}. `
+ `These runs are deterministic at seed ${now.seed}, so this is a real change in the harness `
+ `or the cast, not noise. Re-record with --update once you know which, and fix EXPOSURE's `
+ `prose in the same commit — check-cited will not let you forget.`);
}
}
}
/* The mirror of the check above, and the one a config-list edit needs. Dropping a config
leaves the loop above iterating over what remains and agreeing with itself; coverage
falls and every figure still matches. MIN_CONFIGS is a floor, not a ratchet, so it
cannot see a single row going missing. */
const dropped = [];
for (const which of ["line", "cut"])
for (const id of Object.keys(base[which] ?? {}))
if (!CONFIGS.some(c => c.party === which && c.id === id)) dropped.push(`${which}.${id}`);
if (dropped.length) {
problems.push(` ${dropped.length} config(s) in the record are no longer measured: ${dropped.join(", ")}. `
+ `Coverage fell and every remaining figure still agreed, which is how this guard would go on `
+ `reporting success over a smaller table than the one EXPOSURE prints. Re-record deliberately `
+ `if a row was really retired.`);
}
if (!drift && !dropped.length) notes.push(`${CONFIGS.length} configs x ${FIELDS.length} figures agree exactly with the record`);
if (LIST) {
for (const c of CONFIGS) {
const r = now[c.party][c.id];
console.log(` ${(c.party + "." + c.id).padEnd(20)} ${c.label.padEnd(38)} hurt ${r.hurt} down ${r.down} wiped ${r.wiped}% deaths ${r.deaths} median ${r.median}`);
}
notes.forEach(n => console.log(` ${n}`));
problems.forEach(p => console.log(p));
process.exit(0);
}
if (problems.length) {
console.error("check-packs: FAILED — EXPOSURE's tables no longer describe the fight the game runs");
problems.forEach(p => console.error(p));
process.exit(1);
}
console.log(`check-packs: OK — ${CONFIGS.length} pack fights, ${RUNS} runs each at seed ${now.seed}, `
+ `nothing truncated at ${CAP} rounds, every figure matching the record`);
+213
View File
@@ -0,0 +1,213 @@
{
"note": "Generated by tools/pack-tables.mjs --update. Do not edit by hand.",
"runs": 2000,
"seed": 11,
"cap": 400,
"line": {
"understudy": {
"label": "the understudy, alone",
"n": 1,
"hurt": 0.55,
"down": 0.01,
"wiped": 0,
"deaths": 0,
"median": 2,
"longest": 10,
"capped": 0
},
"duplicate": {
"label": "a duplicate of an agent",
"n": 1,
"hurt": 0.34,
"down": 0.04,
"wiped": 0,
"deaths": 0,
"median": 2,
"longest": 8,
"capped": 0
},
"hollow1": {
"label": "one hollow man",
"n": 1,
"hurt": 0.28,
"down": 0,
"wiped": 0,
"deaths": 0,
"median": 2,
"longest": 10,
"capped": 0
},
"hollow3": {
"label": "three hollow men",
"n": 3,
"hurt": 1.57,
"down": 0.01,
"wiped": 0,
"deaths": 0,
"median": 6,
"longest": 17,
"capped": 0
},
"hollow6": {
"label": "six hollow men",
"n": 6,
"hurt": 3.48,
"down": 0.42,
"wiped": 0,
"deaths": 0.04,
"median": 13,
"longest": 34,
"capped": 0
},
"column3": {
"label": "three of the column",
"n": 3,
"hurt": 1.88,
"down": 0.4,
"wiped": 0.1,
"deaths": 0.16,
"median": 5,
"longest": 25,
"capped": 0
},
"column6": {
"label": "six of the column",
"n": 6,
"hurt": 4.84,
"down": 3.07,
"wiped": 15.2,
"deaths": 1.59,
"median": 14,
"longest": 81,
"capped": 0
},
"column10": {
"label": "ten of the column",
"n": 10,
"hurt": 5.97,
"down": 5.86,
"wiped": 90.8,
"deaths": 2.74,
"median": 13,
"longest": 81,
"capped": 0
},
"column15": {
"label": "fifteen of the column",
"n": 15,
"hurt": 6,
"down": 6,
"wiped": 100,
"deaths": 2.68,
"median": 7,
"longest": 37,
"capped": 0
},
"hollow6col2": {
"label": "six hollow men + two of the column",
"n": 8,
"hurt": 5.01,
"down": 2.79,
"wiped": 8.3,
"deaths": 0.97,
"median": 21,
"longest": 88,
"capped": 0
},
"hollow6col3": {
"label": "six hollow men + three of the column",
"n": 9,
"hurt": 5.55,
"down": 4.14,
"wiped": 30.1,
"deaths": 1.55,
"median": 25,
"longest": 102,
"capped": 0
},
"hollow6col6": {
"label": "six hollow men + six of the column",
"n": 12,
"hurt": 5.99,
"down": 5.9,
"wiped": 93.8,
"deaths": 2.31,
"median": 16,
"longest": 85,
"capped": 0
}
},
"cut": {
"hollow3": {
"label": "three hollow men",
"n": 3,
"hurt": 2.8,
"down": 0.33,
"wiped": 0.5,
"deaths": 0.03,
"median": 13,
"longest": 79,
"capped": 0
},
"hollow6": {
"label": "six hollow men",
"n": 6,
"hurt": 3.96,
"down": 3.59,
"wiped": 74.9,
"deaths": 0.42,
"median": 30,
"longest": 120,
"capped": 0
},
"hollow6col2": {
"label": "six hollow men + two of the column",
"n": 8,
"hurt": 4,
"down": 4,
"wiped": 100,
"deaths": 1.11,
"median": 9,
"longest": 60,
"capped": 0
},
"column3": {
"label": "three of the column",
"n": 3,
"hurt": 3.25,
"down": 2.31,
"wiped": 45.3,
"deaths": 0.98,
"median": 10,
"longest": 43,
"capped": 0
},
"column6": {
"label": "six of the column",
"n": 6,
"hurt": 4,
"down": 3.99,
"wiped": 99.8,
"deaths": 1.46,
"median": 6,
"longest": 21,
"capped": 0
}
},
"party": {
"line": [
"ashcroft",
"bhattacharya",
"renshaw",
"braithwaite",
"pollard",
"okonkwo"
],
"cut": [
"ashcroft",
"bhattacharya",
"renshaw",
"braithwaite"
]
}
}
+151
View File
@@ -0,0 +1,151 @@
/**
* EXPOSURE's tables, measured into an artifact instead of typed into prose.
*
* CLEAN GROUND's most consequential table is the one that decides whether a GM lets a fight
* happen, and until now it was about thirty sampled figures held by nothing. Desk pass 9
* counted the surface: 101 percentages in that document, 67 of them computed statistics, 14
* cited. `lethality-baseline.json` does NOT cover these — it measures each creature SOLO
* against a frozen four-agent party that is not this scenario's cast, and stores
* {wipe, down, rounds}. These are packs of three to fifteen against the declared six, or the
* four-player cut, and they are a different measurement of a different fight.
*
* node tools/pack-tables.mjs print the tables
* node tools/pack-tables.mjs --json the artifact, to stdout
* node tools/pack-tables.mjs --update re-record tools/pack-tables-baseline.json
*
* THE CAP IS THE WHOLE REASON THIS FILE EXISTS AT THE SIZE IT DOES. `simulate.mjs`'s CLI
* calls runFight with no options, so every published figure was measured at the default
* maxRounds of 40 — and runFight does not report truncation, it scores whoever is standing
* when the loop stops. Measured at 400: six hollow men against the four-player cut hit the
* 40-round ceiling in 578 runs of 2000, and the party's true wipe rate is 74.9%, not the
* 58.7% the document printed. Fourteen points, on the single most alarming number in the
* case, produced by a ceiling nobody had looked at. fight-tail learned this in R-270 and
* carried an `assertUncensored`; EXPOSURE's own tables never got one. They have one now.
*
* The longest fight at CAP 400 is 120 rounds, and 400 and 2000 give identical figures, so
* the cap is comfortable rather than merely sufficient.
*/
import { writeFileSync } from "node:fs";
import path from "node:path";
import { NPCS } from "./content.mjs";
import { ROSTER } from "./roster.mjs";
import { runFight, 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")), "..");
export const BASELINE = path.join(ROOT, "tools", "pack-tables-baseline.json");
export const RUNS = 2000;
export const SEED = 11;
/* Same ceiling as fight-tail, for the same reason and by the same argument. */
export const CAP = 400;
const { line: LINE, cut: CUT } = castAndCut("pack-tables");
const agent = k => {
const a = ROSTER.find(r => r.key === `pc_${k}`);
if (!a) { console.error(`pack-tables: the declared cast names "${k}", who is not on the roster.`); process.exit(1); }
return a;
};
const foe = k => {
const c = NPCS.find(n => n.key === k);
if (!c) { console.error(`pack-tables: no creature keyed "${k}" in content.mjs.`); process.exit(1); }
return c;
};
/** Every row EXPOSURE prints, keyed so a citation can address one figure. */
export const CONFIGS = [
{ party: "line", id: "understudy", label: "the understudy, alone", force: [["the_understudy", 1]] },
{ party: "line", id: "duplicate", label: "a duplicate of an agent", force: [["duplicate", 1]] },
{ party: "line", id: "hollow1", label: "one hollow man", force: [["hollow_man", 1]] },
{ party: "line", id: "hollow3", label: "three hollow men", force: [["hollow_man", 3]] },
{ party: "line", id: "hollow6", label: "six hollow men", force: [["hollow_man", 6]] },
{ party: "line", id: "column3", label: "three of the column", force: [["quiet_neighbours_npc", 3]] },
{ party: "line", id: "column6", label: "six of the column", force: [["quiet_neighbours_npc", 6]] },
{ party: "line", id: "column10", label: "ten of the column", force: [["quiet_neighbours_npc", 10]] },
{ party: "line", id: "column15", label: "fifteen of the column", force: [["quiet_neighbours_npc", 15]] },
{ party: "line", id: "hollow6col2", label: "six hollow men + two of the column", force: [["hollow_man", 6], ["quiet_neighbours_npc", 2]] },
{ party: "line", id: "hollow6col3", label: "six hollow men + three of the column", force: [["hollow_man", 6], ["quiet_neighbours_npc", 3]] },
{ party: "line", id: "hollow6col6", label: "six hollow men + six of the column", force: [["hollow_man", 6], ["quiet_neighbours_npc", 6]] },
{ party: "cut", id: "hollow3", label: "three hollow men", force: [["hollow_man", 3]] },
{ party: "cut", id: "hollow6", label: "six hollow men", force: [["hollow_man", 6]] },
{ party: "cut", id: "hollow6col2", label: "six hollow men + two of the column", force: [["hollow_man", 6], ["quiet_neighbours_npc", 2]] },
{ party: "cut", id: "column3", label: "three of the column", force: [["quiet_neighbours_npc", 3]] },
{ party: "cut", id: "column6", label: "six of the column", force: [["quiet_neighbours_npc", 6]] }
];
/**
* measure()'s exact discipline — ONE rng threaded through every run, not a reseed per run.
* Reseeding per run is a different stream and gives different figures; the published table
* came out of the continuous one, and a baseline that cannot reproduce the document is a
* baseline that will be argued with rather than believed.
*/
function sweep(party, foes) {
const rng = makeRng(SEED);
const rounds = [];
let hurt = 0, down = 0, wipes = 0, dead = 0, capped = 0;
for (let i = 0; i < RUNS; i++) {
const r = runFight(rng, party, foes, { maxRounds: CAP });
rounds.push(r.rounds);
hurt += r.partyHurt; down += r.partyDown; dead += r.dead;
if (r.wiped) wipes++;
if (r.rounds >= CAP) capped++;
}
rounds.sort((a, b) => a - b);
return {
hurt: Number((hurt / RUNS).toFixed(2)),
down: Number((down / RUNS).toFixed(2)),
wiped: Number((100 * wipes / RUNS).toFixed(1)),
deaths: Number((dead / RUNS).toFixed(2)),
median: rounds[RUNS >> 1],
longest: rounds[RUNS - 1],
capped
};
}
export function measureAll() {
const parties = { line: LINE.map(agent), cut: CUT.map(agent) };
const out = { note: "Generated by tools/pack-tables.mjs --update. Do not edit by hand.",
runs: RUNS, seed: SEED, cap: CAP, line: {}, cut: {} };
out.party = { line: LINE, cut: CUT };
for (const c of CONFIGS) {
const foes = c.force.flatMap(([k, n]) => Array(n).fill(foe(k)));
out[c.party][c.id] = { label: c.label, n: foes.length, ...sweep(parties[c.party], foes) };
}
return out;
}
/** Nothing may touch the ceiling. A censored run is scored by who is standing when the loop
stops, so a truncated sweep understates the wipe rate and says nothing about it. */
export function censoredIn(m) {
return CONFIGS.filter(c => m[c.party][c.id].capped > 0)
.map(c => `${c.party}.${c.id} (${m[c.party][c.id].capped}/${m.runs})`);
}
if (import.meta.url === `file://${process.argv[1]}`) {
const argv = process.argv.slice(2);
const m = measureAll();
const bad = censoredIn(m);
if (bad.length) {
console.error(`pack-tables: REFUSING to report — ${bad.length} config(s) hit the ${CAP}-round `
+ `ceiling: ${bad.join(", ")}. Raise CAP and re-run; a truncated fight is scored by who is `
+ `standing when the loop stops, which understates the wipe rate silently.`);
process.exit(1);
}
if (argv.includes("--json")) { console.log(JSON.stringify(m, null, 2)); process.exit(0); }
if (argv.includes("--update")) {
writeFileSync(BASELINE, JSON.stringify(m, null, 2) + "\n");
console.log(`pack-tables: recorded ${path.relative(ROOT, BASELINE)} — ${CONFIGS.length} configs, `
+ `${RUNS} runs, seed ${SEED}, cap ${CAP}, nothing truncated`);
process.exit(0);
}
for (const which of ["line", "cut"]) {
console.log(`\n ${which === "line" ? `the declared six — ${LINE.join(", ")}` : `the four-player cut — ${CUT.join(", ")}`}\n`);
console.log(" what they fight hurt down wiped deaths median longest");
for (const c of CONFIGS.filter(x => x.party === which)) {
const r = m[which][c.id];
console.log(` ${c.label.padEnd(38)} ${String(r.hurt).padStart(5)} ${String(r.down).padStart(5)} `
+ `${(r.wiped + "%").padStart(7)} ${String(r.deaths).padStart(7)} ${String(r.median).padStart(7)} ${String(r.longest).padStart(8)}`);
}
}
console.log("");
}