Tiers say what they are worth, and the ladder is guarded

Measuring the tier knob showed it works — one creature against the four
agents check-lethality freezes, deaths per fight climb 0.01, 0.04, 0.32,
0.91, 1.43 across the five tiers for a large brute, and monotonically for
hunters and flyers too.

It also showed the labels oversell. "Deadly" at tier 3 kills a third of an
agent and wipes the team three times in a thousand. A GM reaching for a word
like that is owed the figure behind it, and the forge's whole argument is
that its output can be interrogated — the tier list was the one part of it
that could not be. Each tier now carries a measured `worth` line, printed by
`forge list` and under the tier selector in the dialog.

check-forge measures the ladder and fails if it ever goes down, across three
roles and five tiers. Both the labels and the worth lines are claims about
behaviour that any change to the skill bands, the hide base or the
characteristic multiplier could falsify without touching a word. Proved by
weakening tier 4: three roles report the inversion by name.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
slaguru666
2026-09-23 00:42:39 +01:00
co-authored by Claude Opus 5
parent 869dc748a8
commit 3b65877a94
5 changed files with 78 additions and 11 deletions
+1 -1
View File
@@ -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
+17 -5
View File
@@ -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
+8 -2
View File
@@ -4636,7 +4636,8 @@ async function forgeDialog() {
<div class="form-group"><label>${game.i18n.localize("RINGBRP.Forge.Size")}</label>
<select name="size">${opts(SIZE_BANDS, "medium")}</select></div>
<div class="form-group"><label>${game.i18n.localize("RINGBRP.Forge.Tier")}</label>
<select name="tier">${opts(FORGE_TIERS, "3", t => t.label)}</select></div>
<select name="tier">${opts(FORGE_TIERS, "3", t => t.label)}</select>
<p class="notes" data-tierworth></p></div>
<div class="form-group"><label>${game.i18n.localize("RINGBRP.Gen.Species")}</label>
<select name="species">${opts(SPECIES, "baseline")}</select></div>
<div class="form-group"><label>${game.i18n.localize("RINGBRP.Forge.Body")}</label>
@@ -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);
}
+48 -2
View File
@@ -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`);
+4 -1
View File
@@ -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));