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));