diff --git a/README.md b/README.md index a8da8a3..60695a4 100644 --- a/README.md +++ b/README.md @@ -184,7 +184,7 @@ The build runs all twenty-five guards before it writes anything: ``` check-seam: OK — 75 modules scanned, only all-specs.mjs reads NPCS/PREGENS from content.mjs check-generator: OK — 210 posting × tier pairs draw from their own stores, 129 weapons issued, each with the skill it names -check-forge: OK — 450 creatures derived across 6 roles × 5 sizes × 5 tiers × 3 species, every one valid, armed with something its body can use, and reproducible from its seed; 90 of them forged identically from the built pack and from content.mjs +check-forge: OK — 450 creatures derived across 6 roles × 5 sizes × 5 tiers × 3 species, every one valid, armed with something its body can use, and reproducible from its seed; 90 of them forged identically from the built pack and from content.mjs; the tier ladder climbs across 15 measured fights check-armed: OK — 35 creature(s) hold a weapon they have no skill for, every one on the record; no new ones check-rules: OK — 113 rules, 73 files scanned, 3 aliases + 46 constant sets checked, 417 formulas verified check-kits: OK — 25 roles, 10 trades, 226 catalogue items, every kit key resolves, every posting can use what it carries, every loadout distinct diff --git a/forge.mjs b/forge.mjs index 39b94a2..34b8749 100644 --- a/forge.mjs +++ b/forge.mjs @@ -74,12 +74,24 @@ export const CREATURE_ROLES = { * Tiers. A single multiplier the GM can reason about, which is the whole design brief * for this knob: tier 3 is the ordinary case and every band is stated relative to it. */ +/* `worth` is MEASURED, not asserted: one creature of that tier against the four agents + check-lethality freezes, over the three roles and 400 runs each. The labels alone were + selling something the numbers did not deliver — "Deadly" at tier 3 kills a third of an + agent per fight and wipes the party three times in a thousand, and a GM reaching for a + word like that is owed the figure behind it. The forge's whole argument is that its + output can be interrogated; a tier list that cannot is the one part of it that could + not be. check-forge holds the ladder monotonic so these stay true in direction. */ export const FORGE_TIERS = { - 1: { label: "Nuisance", skill: [25, 40], armourBase: 0, chMul: 0.80 }, - 2: { label: "Dangerous", skill: [35, 50], armourBase: 1, chMul: 0.90 }, - 3: { label: "Deadly", skill: [45, 65], armourBase: 3, chMul: 1.00 }, - 4: { label: "Apex", skill: [60, 78], armourBase: 5, chMul: 1.15 }, - 5: { label: "Legend", skill: [72, 88], armourBase: 7, chMul: 1.30 } + 1: { label: "Nuisance", skill: [25, 40], armourBase: 0, chMul: 0.80, + worth: "a fight the team wins — under a tenth of an agent hurt" }, + 2: { label: "Dangerous", skill: [35, 50], armourBase: 1, chMul: 0.90, + worth: "costs ammunition and nerve, not people" }, + 3: { label: "Deadly", skill: [45, 65], armourBase: 3, chMul: 1.00, + worth: "hurts someone — around a third of an agent down per fight" }, + 4: { label: "Apex", skill: [60, 78], armourBase: 5, chMul: 1.15, + worth: "kills about one agent, and wipes a four-agent team now and then" }, + 5: { label: "Legend", skill: [72, 88], armourBase: 7, chMul: 1.30, + worth: "kills one or two, and a brute at this tier wipes the team two times in five" } }; /* The table that used to be here — which attacks each body plan could make — has moved diff --git a/ringbrp.mjs b/ringbrp.mjs index 773ff5f..fc19255 100644 --- a/ringbrp.mjs +++ b/ringbrp.mjs @@ -4636,7 +4636,8 @@ async function forgeDialog() {
-
+ +

@@ -4690,7 +4691,12 @@ async function forgeDialog() { const holder = el.querySelector("[data-rolecard]"); if (!holder) return; const v = n => el.querySelector(`[name=${n}]`)?.value; - const paint = () => { holder.innerHTML = card(v("role"), v("size"), v("tier"), v("species"), v("body")); }; + const worth = el.querySelector("[data-tierworth]"); + const paint = () => { + holder.innerHTML = card(v("role"), v("size"), v("tier"), v("species"), v("body")); + // What the tier is actually worth, measured, rather than the adjective alone. + if (worth) worth.textContent = FORGE_TIERS[v("tier")]?.worth ?? ""; + }; for (const n of ["role", "size", "tier", "species", "body"]) { el.querySelector(`[name=${n}]`)?.addEventListener("change", paint); } diff --git a/tools/check-forge.mjs b/tools/check-forge.mjs index 812caa4..32a62e9 100644 --- a/tools/check-forge.mjs +++ b/tools/check-forge.mjs @@ -24,6 +24,8 @@ import { forgeCreature, SIZE_BANDS, CREATURE_ROLES, FORGE_TIERS } from "../forge import { validate } from "./creature-schema.mjs"; import { WEAPONS, SKILL_CATALOGUE } from "./content.mjs"; import { LOCATION_TABLES } from "../anatomy.mjs"; +import { ROSTER } from "./roster.mjs"; +import { measure, seedFor } from "./simulate.mjs"; import { SPECIES } from "../postings.mjs"; const WEAPON_BY_KEY = new Map(WEAPONS.map(w => [w.key, w])); @@ -39,8 +41,11 @@ import path from "node:path"; import { fileURLToPath } from "node:url"; const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); +const PARTY_KEYS = ["pc_holloway", "pc_okonkwo", "pc_nkemdirim", "pc_ferriby"]; +const LADDER_RUNS = 400; + const problems = []; -let derived = 0, agreed = 0; +let derived = 0, agreed = 0, ladder = 0; for (const role of Object.keys(CREATURE_ROLES)) { for (const size of Object.keys(SIZE_BANDS)) { @@ -149,6 +154,46 @@ for (const role of Object.keys(CREATURE_ROLES)) { if (!problems.length) agreed = compared; } +/* ---- the tier ladder must climb ---- + * + * "A --tier bump is a single multiplier a GM can reason about" is the design brief for + * that knob, and FORGE_TIERS now prints what each tier is worth in plain words. Both are + * claims about measured behaviour, and neither is worth anything if tier 4 can quietly + * become less dangerous than tier 3 — which any change to the skill bands, the hide base + * or the characteristic multiplier could do without touching a label. + * + * Measured, not reasoned about. Fewer runs than check-lethality because the question is + * direction rather than an exact figure, and deterministic for the same reason that guard + * takes no tolerance: same seed, same party, same numbers. */ +{ + const party = PARTY_KEYS.map(k => ROSTER.find(r => r.key === k)).filter(Boolean); + if (party.length !== PARTY_KEYS.length) { + problems.push(`the frozen party names an agent not on the roster`); + } else { + for (const role of ["brute", "hunter", "flyer"]) { + let prev = null; + for (const tier of [1, 2, 3, 4, 5]) { + const spec = forgeCreature({ role, size: "large", tier, seed: 4, key: `ladder_${role}_${tier}` }, catalogues).spec; + const r = measure(party, [spec], { runs: LADDER_RUNS, seed: seedFor(11, spec.key) }); + const worse = r.deathsPerRun; + if (prev !== null && worse < prev.worth) { + problems.push(`${role}: tier ${tier} kills ${worse.toFixed(2)} agents per fight and ` + + `tier ${prev.tier} kills ${prev.worth.toFixed(2)} — the ladder goes down, so the ` + + `tier labels and their "worth" lines are describing something else`); + } + prev = { tier, worth: worse }; + ladder++; + } + } + } +} + +/* Every tier must say what it is worth, because the dialog prints that line and an empty + one leaves a GM choosing between five adjectives. */ +for (const [k, t] of Object.entries(FORGE_TIERS)) { + if (!t.worth || !t.worth.trim()) problems.push(`FORGE_TIERS.${k} has no "worth" line`); +} + /* A different seed must give a different creature, or the seed is decorative and every forged creature at a tier is the same one wearing a different name. */ { @@ -169,4 +214,5 @@ console.log(`check-forge: OK — ${derived} creatures derived across ` + `${Object.keys(CREATURE_ROLES).length} roles × ${Object.keys(SIZE_BANDS).length} sizes × ` + `${Object.keys(FORGE_TIERS).length} tiers × ${Object.keys(SPECIES).length} species, ` + `every one valid, armed with something its body can use, and reproducible from its seed; ` - + `${agreed} of them forged identically from the built pack and from content.mjs`); + + `${agreed} of them forged identically from the built pack and from content.mjs; ` + + `the tier ladder climbs across ${ladder} measured fights`); diff --git a/tools/forge.mjs b/tools/forge.mjs index 2d42cca..3c3dda8 100644 --- a/tools/forge.mjs +++ b/tools/forge.mjs @@ -94,7 +94,10 @@ if (cmd === "list") { console.log("\nsizes"); for (const [k, b] of Object.entries(SIZE_BANDS)) console.log(` ${k.padEnd(9)} SIZ ${String(b.siz.join("–")).padEnd(7)} ${b.note}`); console.log("\ntiers"); - for (const [k, t] of Object.entries(FORGE_TIERS)) console.log(` ${k} ${t.label.padEnd(10)} skills ${t.skill.join("–")}, hide ${t.armourBase}`); + for (const [k, t] of Object.entries(FORGE_TIERS)) { + console.log(` ${k} ${t.label.padEnd(10)} skills ${String(t.skill.join("–")).padEnd(7)} hide ${t.armourBase}`); + console.log(` ${t.worth}`); + } } else if (cmd === "new") { const { spec } = forgeOrDie(); console.log(asSource(spec));