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:
co-authored by
Claude Opus 5
parent
869dc748a8
commit
3b65877a94
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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));
|
||||
|
||||
Reference in New Issue
Block a user