diff --git a/README.md b/README.md index 9fc3fe1..833c1df 100644 --- a/README.md +++ b/README.md @@ -48,7 +48,7 @@ packs or regenerate art on that machine: ```bash npm install # pulls classic-level, used to write the LevelDB packs npm run build # rebuilds packs/ from tools/content.mjs -npm run check # the eleven guards; the build refuses to run if they fail +npm run check # the twelve guards; the build refuses to run if they fail ``` To update a deployed server: `git pull` and restart Foundry. If the pull touches @@ -157,7 +157,7 @@ icons/ fonts/ art/ generated art packs/ built LevelDB compendia (committed — see Deploying) tools/ content.mjs the catalogue: skills, weapons, armour, gear, vehicles, NPCs - build-packs.mjs builds packs/ — runs the eleven guards first and refuses on failure + build-packs.mjs builds packs/ — runs the twelve guards first and refuses on failure make-icons.mjs draws all 231 icons rules-text.mjs generates the rules journal FROM rules.mjs mission.mjs the case generator @@ -178,11 +178,11 @@ stops being identity-equal to what it aliases, if a constant is re-declared as a literal, or if an exported rule has no spot-check. The packs used to be built under one set of numbers and played under another; this makes that impossible to ship. -The build runs all eleven guards before it writes anything: +The build runs all twelve guards before it writes anything: ``` -check-rules: OK — 110 rules, 36 files scanned, 3 aliases + 46 constant sets checked, 395 formulas verified +check-rules: OK — 110 rules, 37 files scanned, 3 aliases + 46 constant sets checked, 395 formulas verified check-kits: OK — 25 roles, 10 trades, 218 catalogue items, every kit key resolves, every posting can use what it carries, every loadout distinct check-lang: OK — en.json, 1018 keys, no leaf/branch collisions check-templates: OK — 19 templates compile @@ -192,6 +192,7 @@ check-rollable: OK — 78 rolls across 11 scenario files, every one reachable at check-creatures: OK — 102 actor specs across 8 sources, every characteristic, skill, kit key, talent, species and style resolves, no duplicate keys check-anatomy: OK — 5 body plans, 36 locations, d20 complete in both modes, every location drawn, every kind handled and destructible, 20 plan changes carried 432 wounds with their severity intact, 1 flyer(s) with a survivable landing check-lethality: OK — 47 creatures, every one fighting exactly as recorded against the frozen party +check-focus: OK — focus fire measured against 31 packs, helps in all of them, every gain above its own noise check-bestiary: OK — 47 creatures, the document matches the game ``` diff --git a/docs/BESTIARY.md b/docs/BESTIARY.md index ea4e894..9d6ec5f 100644 --- a/docs/BESTIARY.md +++ b/docs/BESTIARY.md @@ -87,12 +87,16 @@ rule for holding an action. Arranging the party so its best weapon fires last wa measured in the simulator and came to nothing distinguishable from noise. **What you can do is kill them one at a time.** Against a pack, every agent putting -their attacks into the same creature beats spreading them — in every fight in this -book whose outcome was ever in doubt, without exception, and by more than the -measurement’s own noise each time. It is worth about twice as much against three or -more enemies as against a pair. Most of that is not the ladder above: a creature that -is dead stops attacking, and one on half its hit points does not attack any less. The -dodge ladder adds a point or two on top of that. +their attacks into the same creature beats spreading them — in **every one** of the +31 fights in this book whose outcome was ever in doubt, without exception, and by more +than the measurement’s own noise each time. It is worth about +**8 points** of win rate against a pair and +**14 points** against three or more; the best case in the book is +6 × The margin, which goes 42.0% to 61.1%. Most of that is not the ladder +above: a creature that is dead stops attacking, and one on half its hit points does not +attack any less. Held at a fixed pack size the dodge ladder is worth a point or two. + +*(1000 fights × 3 seeds per arm, re-measured by `check-focus` on every build.)* The ones that take the most stripping: diff --git a/docs/REVIEW_LOG.md b/docs/REVIEW_LOG.md index 8ed356e..955fae9 100644 --- a/docs/REVIEW_LOG.md +++ b/docs/REVIEW_LOG.md @@ -5579,3 +5579,92 @@ and look within it — and I did not run it until the third time. ladder as the reason, which was right for the wrong reason. It now gives the reason that survives measurement, and states that the effect roughly doubles from two enemies to three or more. + +## R-261 — a real skill is not a route, and the first guard for that reported success + +`check-scenarios` resolves every skill a scenario NAMES against the catalogue. That is a +different question from whether anybody at the table can roll one, and the gap is wide +enough to hide a clue in. + +CLEAN GROUND shipped four commits with three clue routes gated on **Track** (base 10), +**Navigate** (base 10) and **Science (Botany)** — base **1**, so the 1% floor was the whole +of it. Every guard passed at every commit, because all three are perfectly real skills that +resolve perfectly well. Nothing in the build had an opinion about who was holding the dice. + +A desk playtest found it, by auditing the acts against the sheets the scenario actually +casts, before rolling anything. That audit is mechanical, so it is now `check-rollable`. + +**Two tiers.** Corpus-wide, some duty-roster agent must reach **VIABLE** for every skill any +scenario names — a scenario may bring anyone, so a skill no roster agent can roll is a dead +route whoever turns up. Per scenario, a document that **declares** its cast is held to that +cast instead. + +**VIABLE is 25, and it is derived rather than chosen.** It is the commonest base chance in +the catalogue — what an untrained agent brings to Spot, Listen or Brawl — so it is the level +the game itself treats as worth attempting. Below it a clue is not gated, it is buried: at +10 the GM improvises a bridge four times in five, and at 1 the tag is decoration. + +**The first version of this guard passed the bug it was written for.** It inferred the cast +by scraping `pc_` keys out of the prose, which swept up the substitutes named in CLEAN +GROUND's player-count scaling — a declared six became an inferred nine, Sandoval and his +Track 35 came into scope, and the strict tier went green. The cast is now declared in a +`` comment and read off the raw text, because `scenarioText` strips HTML +comments. A guard that guesses its own scope is worse than no guard, because it reports +success and stops anyone looking. + +**Verified load-bearing against the real document, not a fixture.** Re-injecting the +original Track tag into `CLEAN_GROUND.md` fails the guard and names the skill, the base +chance and the declared cast. Worth recording *why the fixture was not enough*: the +corpus-wide tier would never have caught this at all, because **Lindqvist trains Science +(Botany) at 40**. The skill is rollable by the roster and simply not by the six who were +cast, so the first fixture test passed and was reassuring and wrong. Only the declared-cast +tier finds this defect class. + +`check-scenarios` now exports its tag parser behind the `invokedDirectly` pattern +`simulate.mjs` already uses, rather than letting a second copy grow in the new guard — +which is the one thing this repository forbids. `update-readme` caught the omission fairly +on the first attempt, since it cross-checks the advertised list against `npm run check`. + +## R-261 — the focus-fire claim is now an artifact, not a sentence + +R-258 put a tactic on a GM-facing page from an unguarded harness. R-259 found it worth +nothing. R-260 found the replacement right for the wrong reason. Three corrections, each +one landing as prose with nothing checking it — the arrangement that let ddc4f99 describe +a game nobody was playing. + +`tools/focus-baseline.json` now records, per creature, the pack size, the spread-fire and +focus-fire win rates and the gain. `check-focus` re-measures all of it on every build and +compares **exactly**, with the frozen party, the seeds, the run count and the seeding +scheme recorded alongside so numbers taken under different conditions are refused rather +than compared. The bestiary **reads** the artifact instead of restating it, and +check-bestiary refuses a page that has fallen behind. Page, guard and simulator now cannot +disagree; the twelfth guard is the only thing entitled to state the number. + +**The pack size is recorded, not re-chosen.** A fight at 0% or 100% cannot show an effect, +so `--update` picks the size nearest an even fight and writes it down. If the guard chose +again each run, a creature that had changed would quietly move to a different question and +pass. 31 of 47 creatures land in the measurable band; the other 16 are recorded as pinned, +with the rate that pinned them. + +**It also guards the claim, not just the numbers.** The page says focus fire helps in every +fight that is in doubt. `check-focus` fails if that stops being true — if any row's gain +goes to zero, or stops clearing its own seed-to-seed noise. That is a different failure +from drift: drift means re-record, this means rewrite the page. + +**The run count was chosen by evidence.** 1000 fights × 3 seeds costs 8.5s and takes the +whole suite from 2.5s to 13.7s, which is a real tax. I tried 500 to halve it and the +claim-check failed: at 500 runs one row's gain no longer clears its noise, so the page's +"without exception" is not supported by that much sampling. The build pays eight seconds +for a sentence it is entitled to print. Recording twice at 1000 produced byte-identical +files, which is the determinism the exact comparison depends on. + +**Negative-tested four ways**, all firing with the right message: a creature quietly made +nimbler (the redcap from dodge 75 to 85 — spread 40.6% → 27.6%, caught), a baseline taken +under different seeds, a hand-edited page, and the claim itself failing at 500 runs. + +Measured, and now guarded: **8 points** of win rate against a pair, **14** against three or +more, best case 6 × The margin at 42.0% → 61.1%. + +*Note: guard eleven, `check-rollable`, arrived from another session mid-build; this is +twelve. Both were added to `package.json` and the README list without either clobbering +the other, and the README's count word tracked it on its own.* diff --git a/package.json b/package.json index 53f430d..05d4fe5 100644 --- a/package.json +++ b/package.json @@ -10,7 +10,7 @@ "bestiary": "node tools/bestiary.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-creatures.mjs && bun tools/check-anatomy.mjs && bun tools/check-lethality.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-creatures.mjs && bun tools/check-anatomy.mjs && bun tools/check-lethality.mjs && bun tools/check-focus.mjs && bun tools/check-bestiary.mjs", "test": "bun run check", "readme": "bun tools/update-readme.mjs" }, diff --git a/system.json b/system.json index d816457..23ae6ef 100644 --- a/system.json +++ b/system.json @@ -2,7 +2,7 @@ "id": "ringbrp", "title": "The Custodians", "description": "A Basic Roleplaying game of agents who cross the crossing to contain what should not be there — and who come back a little less from here each time.", - "version": "1.7.15", + "version": "1.7.16", "compatibility": { "minimum": 13, "verified": "14.364" diff --git a/tools/bestiary.mjs b/tools/bestiary.mjs index 48c14a4..4d0d4fd 100644 --- a/tools/bestiary.mjs +++ b/tools/bestiary.mjs @@ -33,12 +33,20 @@ import { MONSTER_TAGS, carriesNothingManufactured } from "./make-portraits.mjs"; const ROOT = path.resolve(path.dirname(new URL(import.meta.url).pathname.replace(/^\/([A-Za-z]:)/, "$1")), ".."); const OUT = path.join(ROOT, "docs", "BESTIARY.md"); const BASELINE = path.join(ROOT, "tools", "lethality-baseline.json"); +const FOCUS = path.join(ROOT, "tools", "focus-baseline.json"); const CHECK = process.argv.includes("--check"); const base = existsSync(BASELINE) ? JSON.parse(await readFile(BASELINE, "utf8")) : { creatures: {}, party: [], runs: 0, seed: 0 }; +/* R-261. The focus-fire numbers below are READ, not restated: check-focus re-measures + them on every build and check-bestiary refuses a page that has fallen behind them. The + three previous attempts to say this in prose were wrong twice. */ +const focus = existsSync(FOCUS) + ? JSON.parse(await readFile(FOCUS, "utf8")) + : { creatures: {}, runs: 0, seeds: [] }; + /* ---------------------------------------------------------------- shaping */ const TAG_ORDER = ["FOLKLORE", "HORROR", "FORWARD", ""]; @@ -141,6 +149,29 @@ md += `\n**V** = Vesh, **C** = Cadence. Everything else is baseline-human anatom + `several things that are plainly not human-shaped — see the note at the foot.\n\n---\n\n`; /* --- how a defence is spent, which decides what order the party shoots in --- */ +function focusParagraph() { + const rows = Object.values(focus.creatures ?? {}).filter(r => r.gain !== undefined); + if (!rows.length) return ""; + const mean = a => a.reduce((x, y) => x + y, 0) / a.length; + const pairs = rows.filter(r => r.n === 2), packs = rows.filter(r => r.n >= 3); + const helped = rows.filter(r => r.gain > 0).length; + const clear = rows.filter(r => r.gain > r.noise).length; + const best = rows.reduce((a, b) => (b.gain > a.gain ? b : a)); + const bestKey = Object.keys(focus.creatures).find(k => focus.creatures[k] === best); + const bestName = (NPCS.find(n => n.key === bestKey) ?? {}).name ?? bestKey; + const every = helped === rows.length && clear === rows.length; + return `**What you can do is kill them one at a time.** Against a pack, every agent putting\n` + + `their attacks into the same creature beats spreading them \u2014 in ${every ? "**every one**" : `${helped}`} of the\n` + + `${rows.length} fights in this book whose outcome was ever in doubt${every ? ", without exception" : ""}, and by more\n` + + `than the measurement\u2019s own noise ${every ? "each time" : `in ${clear} of them`}. It is worth about\n` + + `**${mean(pairs.map(r => r.gain)).toFixed(0)} points** of win rate against a pair and\n` + + `**${mean(packs.map(r => r.gain)).toFixed(0)} points** against three or more; the best case in the book is\n` + + `${best.n} \u00d7 ${esc(bestName)}, which goes ${best.spread.toFixed(1)}% to ${best.focus.toFixed(1)}%. Most of that is not the ladder\n` + + `above: a creature that is dead stops attacking, and one on half its hit points does not\n` + + `attack any less. Held at a fixed pack size the dodge ladder is worth a point or two.\n\n` + + `*(${focus.runs} fights \u00d7 ${(focus.seeds ?? []).length} seeds per arm, re-measured by \`check-focus\` on every build.)*\n\n`; +} + { const dodgeOf = spec => (spec.skills ?? []).find(k => k.fam === "dodge")?.val ?? 0; const effAfter = (rating, used) => @@ -178,13 +209,7 @@ md += `\n**V** = Vesh, **C** = Cadence. Everything else is baseline-human anatom + `over a fight every agent takes the opening slot about equally often, and there is no\n` + `rule for holding an action. Arranging the party so its best weapon fires last was\n` + `measured in the simulator and came to nothing distinguishable from noise.\n\n` - + `**What you can do is kill them one at a time.** Against a pack, every agent putting\n` - + `their attacks into the same creature beats spreading them \u2014 in every fight in this\n` - + `book whose outcome was ever in doubt, without exception, and by more than the\n` - + `measurement\u2019s own noise each time. It is worth about twice as much against three or\n` - + `more enemies as against a pair. Most of that is not the ladder above: a creature that\n` - + `is dead stops attacking, and one on half its hit points does not attack any less. The\n` - + `dodge ladder adds a point or two on top of that.\n\n` + + focusParagraph() + `The ones that take the most stripping:\n\n` + hardest.map(x => `- **${esc(x.name)}** — dodge ${x.d}%, ${stripsIn(x.d)} landing hits to strip\n`).join("") + `\nIt runs the other way too: agents defend on the same ladder, which is why a second\n` diff --git a/tools/check-focus.mjs b/tools/check-focus.mjs new file mode 100644 index 0000000..3cfd7ff --- /dev/null +++ b/tools/check-focus.mjs @@ -0,0 +1,195 @@ +/** + * check-focus — the bestiary's advice must keep being true. + * + * R-258 put a tactic on a page a GM reads, measured in a harness nobody guards. R-259 + * found it worth nothing when run through the simulator. R-260 found the replacement + * advice right for the wrong reason. Each time the correction was an assertion in a + * document, which is the same arrangement that let ddc4f99 describe a game nobody was + * playing: a number written down with nothing checking it. + * + * So the claim is recorded as an artifact and re-measured on every build, exactly the + * way check-lethality does it. The bestiary reads the artifact rather than restating it, + * so the page, the guard and the simulator cannot disagree. + * + * node tools/check-focus.mjs compare against the committed baseline + * node tools/check-focus.mjs --update re-record it (after a deliberate change) + * node tools/check-focus.mjs --verbose print every creature, not just drift + * + * WHAT IS MEASURED. For each creature, one pack size, and two arms: every agent picking + * a target at random, versus every agent putting their attacks into the same creature. + * The difference is what focus fire is worth against that creature. + * + * WHY A RECORDED PACK SIZE. A fight the party wins 100% of the time, or loses 100% of + * the time, cannot show an effect of any size — three of the first four packs measured + * in R-259 were pinned like that and said nothing. --update picks, for each creature, + * the size whose spread-fire rate sits nearest an even fight, and RECORDS it. The guard + * then re-measures at that size rather than choosing again, so a creature that changes + * shows up as drift instead of quietly moving to a different question. + */ +import { readFile, writeFile } from "node:fs/promises"; +import { existsSync } from "node:fs"; +import path from "node:path"; +import { NPCS } from "./content.mjs"; +import { ROSTER } from "./roster.mjs"; +import { runFight, seedFor } from "./simulate.mjs"; + +const ROOT = path.resolve(path.dirname(new URL(import.meta.url).pathname.replace(/^\/([A-Za-z]:)/, "$1")), ".."); +const BASELINE = path.join(ROOT, "tools", "focus-baseline.json"); + +const argv = process.argv.slice(2); +const UPDATE = argv.includes("--update"); +const VERBOSE = argv.includes("--verbose"); + +/* The same frozen party as check-lethality, for the same reason and so the two guards + describe the same game. */ +const PARTY_KEYS = ["pc_holloway", "pc_okonkwo", "pc_nkemdirim", "pc_ferriby"]; +/* 1000 rather than 500 on evidence, not preference: at 500 runs one row's gain no longer + clears its own seed-to-seed noise and the claim-check below fails, because the page's + "without exception" is not supported by that much sampling. The build pays 8s for a + sentence it is entitled to print. */ +const RUNS = 1000; +const SEEDS = [11, 4242, 90210]; +const SEED_MODE = "per-creature"; +const SIZES = [2, 3, 4, 5, 6]; +const SCAN_RUNS = 400; +/* A pack whose spread-fire rate falls outside this cannot move enough to measure, and a + row that cannot move is a row that would pass this guard whatever happened to it. */ +const MEASURABLE = [15, 85]; + +const party = PARTY_KEYS.map(k => { + const found = ROSTER.find(r => r.key === k); + if (!found) { + console.error(`check-focus: the frozen party names "${k}", which is not on the roster.`); + process.exit(1); + } + return found; +}); + +/* One generator per arm, derived from the creature's own key, so inserting a creature + cannot perturb its neighbours — R-252, learned the hard way. */ +const rngFor = seed => { let s = seed >>> 0; return () => ((s = (1664525 * s + 1013904223) >>> 0) / 4294967296); }; +const winRate = (foes, targets, seed) => { + let won = 0; + for (let i = 0; i < RUNS; i++) { + if (runFight(rngFor(seed + i * 2654435761), party, foes, { partyTargets: targets }).won) won++; + } + return (100 * won) / RUNS; +}; +const mean = a => a.reduce((x, y) => x + y, 0) / a.length; + +const measureAt = (spec, n) => { + const foes = Array(n).fill(spec); + const seeds = SEEDS.map(s => seedFor(s, spec.key)); + const spread = seeds.map(s => winRate(foes, "random", s)); + const focus = seeds.map(s => winRate(foes, "focus", s)); + return { + n, + spread: Number(mean(spread).toFixed(1)), + focus: Number(mean(focus).toFixed(1)), + gain: Number((mean(focus) - mean(spread)).toFixed(1)), + noise: Number((Math.max(...spread) - Math.min(...spread)).toFixed(1)) + }; +}; + +/* --update only: choose the pack size that makes the question answerable. */ +const pickSize = spec => { + let best = null; + for (const n of SIZES) { + const w = winRate(Array(n).fill(spec), "random", seedFor(SEEDS[0], spec.key), SCAN_RUNS); + if (!best || Math.abs(w - 50) < Math.abs(best.w - 50)) best = { n, w }; + if (w < 5) break; // already hopeless; more of them is worse + } + return best; +}; + +if (UPDATE) { + const creatures = {}; + let measurable = 0; + for (const spec of NPCS) { + const pick = pickSize(spec); + if (pick.w < MEASURABLE[0] || pick.w > MEASURABLE[1]) { + creatures[spec.key] = { n: pick.n, pinned: Number(pick.w.toFixed(1)) }; + continue; + } + creatures[spec.key] = measureAt(spec, pick.n); + measurable++; + } + await writeFile(BASELINE, JSON.stringify({ + note: "Generated by tools/check-focus.mjs --update. Do not edit by hand.", + party: PARTY_KEYS, runs: RUNS, seeds: SEEDS, seedMode: SEED_MODE, + measurableBand: MEASURABLE, creatures + }, null, 2) + "\n", "utf8"); + console.log(`check-focus: baseline recorded — ${measurable} of ${NPCS.length} creatures measurable, ` + + `${RUNS} runs x ${SEEDS.length} seeds, seed ${SEEDS[0]} ${SEED_MODE}`); + process.exit(0); +} + +if (!existsSync(BASELINE)) { + console.error("check-focus: no baseline. Run with --update to record one."); + process.exit(1); +} +const base = JSON.parse(await readFile(BASELINE, "utf8")); + +if (base.runs !== RUNS || base.seeds?.join() !== SEEDS.join() + || base.seedMode !== SEED_MODE || base.party.join() !== PARTY_KEYS.join()) { + console.error("check-focus: FAILED — the baseline was recorded under different conditions " + + `(party ${base.party.join(",")}, ${base.runs} runs, seeds ${base.seeds?.join(",")} ` + + `${base.seedMode ?? "shared"}). Re-record it with --update.`); + process.exit(1); +} + +/* Exact comparison is only honest if the measurement is exact. Prove it, don't assume. */ +{ + const probe = NPCS.find(s => base.creatures[s.key]?.gain !== undefined); + if (probe) { + const a = JSON.stringify(measureAt(probe, base.creatures[probe.key].n)); + const b = JSON.stringify(measureAt(probe, base.creatures[probe.key].n)); + if (a !== b) { + console.error("check-focus: FAILED — the simulation is not deterministic, so an exact " + + `baseline cannot mean anything. ${probe.key} measured twice gave ${a} and ${b}.`); + process.exit(1); + } + } +} + +const problems = [], added = [], removed = []; +let checked = 0, helped = 0, aboveNoise = 0; + +for (const spec of NPCS) { + const was = base.creatures[spec.key]; + if (!was) { added.push(spec.key); continue; } + if (was.pinned !== undefined) continue; // recorded as unmeasurable; nothing to compare + const now = measureAt(spec, was.n); + checked++; + if (now.gain > 0) helped++; + if (now.gain > now.noise) aboveNoise++; + const same = now.spread === was.spread && now.focus === was.focus && now.gain === was.gain; + if (!same) { + problems.push(` ${spec.key}: ${was.n} of them — spread ${was.spread}% -> ${now.spread}%, ` + + `focus ${was.focus}% -> ${now.focus}%, focus fire worth ${was.gain} -> ${now.gain}`); + } else if (VERBOSE) { + console.log(` ${spec.key.padEnd(24)} ${was.n}x spread ${was.spread}% focus ${was.focus}% +${was.gain}`); + } +} +for (const key of Object.keys(base.creatures)) if (!NPCS.some(s => s.key === key)) removed.push(key); + +if (problems.length || added.length || removed.length) { + console.error(`check-focus: FAILED — ${problems.length} creature(s) answer differently than recorded`); + problems.forEach(p => console.error(p)); + if (added.length) console.error(` not in the baseline: ${added.join(", ")} — re-record with --update`); + if (removed.length) console.error(` gone from the bestiary: ${removed.join(", ")} — re-record with --update`); + process.exit(1); +} + +/* The claim the bestiary makes, restated as a test rather than as prose: concentrating a + round's attacks is supposed to help against every pack whose fight is in doubt. If it + ever stops doing that, the page is wrong and should be rewritten, not re-recorded. */ +if (helped !== checked || aboveNoise !== checked) { + console.error(`check-focus: FAILED — focus fire helped ${helped} of ${checked} ` + + `(${aboveNoise} beyond their own noise). docs/BESTIARY.md claims it helps in every ` + + `fight that is in doubt, and that is no longer true.`); + process.exit(1); +} + +console.log(`check-focus: OK — focus fire measured against ${checked} packs, helps in all of them, ` + + `every gain above its own noise`); diff --git a/tools/focus-baseline.json b/tools/focus-baseline.json new file mode 100644 index 0000000..1b5274d --- /dev/null +++ b/tools/focus-baseline.json @@ -0,0 +1,303 @@ +{ + "note": "Generated by tools/check-focus.mjs --update. Do not edit by hand.", + "party": [ + "pc_holloway", + "pc_okonkwo", + "pc_nkemdirim", + "pc_ferriby" + ], + "runs": 1000, + "seeds": [ + 11, + 4242, + 90210 + ], + "seedMode": "per-creature", + "measurableBand": [ + 15, + 85 + ], + "creatures": { + "quiet_neighbours_npc": { + "n": 6, + "spread": 60.5, + "focus": 70.1, + "gain": 9.5, + "noise": 1.5 + }, + "keepers": { + "n": 6, + "spread": 43.2, + "focus": 58, + "gain": 14.8, + "noise": 4.5 + }, + "cleaner": { + "n": 6, + "spread": 54.6, + "focus": 68.5, + "gain": 13.9, + "noise": 6 + }, + "cleaner_marksman": { + "n": 5, + "spread": 46.6, + "focus": 61.5, + "gain": 14.9, + "noise": 1 + }, + "revenant": { + "n": 4, + "spread": 32, + "focus": 49.3, + "gain": 17.3, + "noise": 0.7 + }, + "duplicate": { + "n": 5, + "spread": 46, + "focus": 61.6, + "gain": 15.6, + "noise": 2.7 + }, + "barghest": { + "n": 3, + "spread": 31.1, + "focus": 44, + "gain": 12.9, + "noise": 2 + }, + "kelpie": { + "n": 3, + "spread": 62.8, + "focus": 75.6, + "gain": 12.8, + "noise": 2.7 + }, + "redcap": { + "n": 3, + "spread": 40.6, + "focus": 53.8, + "gain": 13.2, + "noise": 3.3 + }, + "boggart": { + "n": 2, + "pinned": 100 + }, + "churchgrim": { + "n": 3, + "spread": 54.6, + "focus": 67.2, + "gain": 12.5, + "noise": 3.4 + }, + "supporter": { + "n": 2, + "pinned": 0.3 + }, + "hollow_man": { + "n": 2, + "pinned": 100 + }, + "the_tenant": { + "n": 2, + "pinned": 100 + }, + "the_choir": { + "n": 2, + "pinned": 100 + }, + "precedent": { + "n": 3, + "spread": 71.2, + "focus": 81.9, + "gain": 10.7, + "noise": 0.9 + }, + "carrion_file": { + "n": 5, + "spread": 29.9, + "focus": 44.1, + "gain": 14.2, + "noise": 2.6 + }, + "the_understudy": { + "n": 6, + "spread": 57, + "focus": 72.6, + "gain": 15.6, + "noise": 2.5 + }, + "surveyor": { + "n": 2, + "spread": 29.8, + "focus": 36.3, + "gain": 6.5, + "noise": 4.5 + }, + "quarantine_unit": { + "n": 2, + "pinned": 0.1 + }, + "cuckoo_array": { + "n": 2, + "spread": 65.3, + "focus": 73.3, + "gain": 7.9, + "noise": 4.8 + }, + "long_walker": { + "n": 2, + "pinned": 0.9 + }, + "auditor": { + "n": 4, + "spread": 58.7, + "focus": 71.8, + "gain": 13.1, + "noise": 2.1 + }, + "washer_ford": { + "n": 2, + "pinned": 100 + }, + "lantern_man": { + "n": 2, + "pinned": 100 + }, + "grindylow": { + "n": 4, + "spread": 41.9, + "focus": 56.7, + "gain": 14.8, + "noise": 3.2 + }, + "knockers": { + "n": 2, + "pinned": 100 + }, + "spriggan": { + "n": 3, + "spread": 50.4, + "focus": 63.8, + "gain": 13.4, + "noise": 3.4 + }, + "nuckelavee": { + "n": 2, + "pinned": 11 + }, + "hedley_kow": { + "n": 2, + "pinned": 100 + }, + "apple_man": { + "n": 2, + "spread": 45.4, + "focus": 51.9, + "gain": 6.5, + "noise": 2.1 + }, + "the_committee": { + "n": 2, + "pinned": 100 + }, + "night_registry": { + "n": 5, + "spread": 39.5, + "focus": 56.1, + "gain": 16.6, + "noise": 1.2 + }, + "the_locum": { + "n": 5, + "spread": 59.4, + "focus": 71.6, + "gain": 12.2, + "noise": 1.3 + }, + "lost_property": { + "n": 2, + "spread": 65.8, + "focus": 71.7, + "gain": 5.9, + "noise": 1.7 + }, + "the_minutes": { + "n": 2, + "pinned": 100 + }, + "waiting_room": { + "n": 4, + "spread": 29.7, + "focus": 41.6, + "gain": 11.9, + "noise": 5.6 + }, + "the_predecessor": { + "n": 5, + "spread": 32.2, + "focus": 45.5, + "gain": 13.2, + "noise": 3 + }, + "switchboard": { + "n": 6, + "pinned": 99.5 + }, + "the_census": { + "n": 4, + "spread": 34, + "focus": 49, + "gain": 15, + "noise": 2.6 + }, + "the_courier": { + "n": 2, + "spread": 23.7, + "focus": 33.5, + "gain": 9.8, + "noise": 1.4 + }, + "the_relay": { + "n": 2, + "spread": 63.1, + "focus": 70.3, + "gain": 7.1, + "noise": 4.7 + }, + "the_stanchion": { + "n": 2, + "pinned": 6.6 + }, + "the_yield": { + "n": 2, + "spread": 57.2, + "focus": 67.8, + "gain": 10.6, + "noise": 4.8 + }, + "the_overwriter": { + "n": 4, + "spread": 31.7, + "focus": 44, + "gain": 12.3, + "noise": 0.8 + }, + "the_arrears": { + "n": 2, + "spread": 19.3, + "focus": 25.4, + "gain": 6.1, + "noise": 2.6 + }, + "the_margin": { + "n": 6, + "spread": 42, + "focus": 61.1, + "gain": 19.1, + "noise": 5.4 + } + } +} diff --git a/tools/update-readme.mjs b/tools/update-readme.mjs index 56860fc..c59d9a1 100644 --- a/tools/update-readme.mjs +++ b/tools/update-readme.mjs @@ -36,7 +36,7 @@ const runGuard = name => { const GUARDS = ["check-rules.mjs", "check-kits.mjs", "check-lang.mjs", "check-templates.mjs", "check-behaviour.mjs", "check-scenarios.mjs", "check-rollable.mjs", "check-creatures.mjs", "check-anatomy.mjs", - "check-lethality.mjs", "check-bestiary.mjs"]; + "check-lethality.mjs", "check-focus.mjs", "check-bestiary.mjs"]; /* The list above went stale the moment a guard was added without touching this file — which is what the comment above it already warned about, and which happened again