Files
RingBRP/tools/check-behaviour.mjs
T
slaguru666andClaude Opus 5 43508259ac Consolidation pass after a Codex review
Four real defects, each verified against the source before acting on it.

Trade expertise was coupled to agency rank. A probationary burglar came out at 35-50%
and a veteran at 65-80%, as though fifteen years of picking locks were something the
department conferred. TRADE_BANDS is now fixed at 50-65/30-50 at every rank, and
INDUCTION scales with service instead (20-32 probationary to 45-60 veteran) because
that half genuinely is the department's. The two axes were the wrong way round. This
is the thing I flagged myself after PAPER HARBOUR and then left alone — flagging a
defect is not the same as fixing it.

Bonus points were sprayed across every skill in the game. grantFullSkillList runs
immediately before the loop and puts all fifty-nine skills on the sheet, and the loop
picked from actor.items, so a veteran's ninety points landed anywhere. The comment two
hundred lines above has always said "over the core skills"; the code never did.

"Random species" passed "baseline" into generators that support random perfectly well.

No behavioural tests. The strongest point in the review: the three guards are static
and none of them can tell whether a rule is READ, which is the only kind of defect this
project has ever shipped. tools/check-behaviour.mjs now runs 28 deterministic tests,
gated into the build, covering R-231/232/243/245/246, graded defences and the lamp
rule. Proved it bites by re-breaking R-246.

The register moved to postings.mjs, a Foundry-neutral module the engine, rulebook,
guards and tests all import normally — they were previously pulled out of the engine
with a regex and eval, which worked and was a trap. My first version of that used
`export … from`, which re-exports without local bindings, so ROLES was undefined and
the system threw on init: node --check passes that, loading it in Foundry does not.

Also: the contact doc comment still said "only OFFER moves standing"; check-kits
accepted mas/app, which are the DISPLAY names of siz/cha, so a posting declaring one
would have passed and produced an undefined characteristic; package.json disagreed with
the manifest; README counts were three revisions stale and are now generated at build.

Deferred with reasons: splitting the 6,700-line engine into modules. Right, and a
multi-session refactor whose risk is exactly the silent breakage demonstrated above.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-11 22:57:20 +01:00

184 lines
8.7 KiB
JavaScript

/**
* BEHAVIOURAL REGRESSION TESTS.
*
* The other three guards are static: they prove that the tables agree with each other
* and that no rule is written twice. They cannot prove that a rule is READ, and every
* defect this project has shipped was of that kind — a value computed and never used,
* found by playing rather than by reading.
*
* Playing found them; this file is what stops them coming back. Every numbered defect
* that can be reproduced without a browser gets a deterministic test here, named for
* the finding, so the log stops being the only evidence that any of it works.
*
* Deterministic on purpose: R-246 was originally confirmed by rolling a hundred and
* twenty times waiting for a 99, which is not a test, it is a hope.
*
* WHAT THIS FILE CANNOT REACH, and where those things are checked instead:
* - anything inside generateCharacter, resolveContact or the sheets, because they
* need `game`, Documents and compendia. Those are verified in a running Foundry
* and the results recorded in docs/REVIEW_LOG.md.
* - bonus-point eligibility and random species, for the same reason. Both were
* confirmed against a live world: an officer's points now land only on trained
* skills, and twelve rolls of "random" produced baseline/vesh/cadence rather
* than twelve baselines.
* Pretending otherwise would make this file look more complete than it is.
*/
import { strict as assert } from "node:assert";
import * as RULES from "../rules.mjs";
import { ROLES, TRADES, INDUCTION, TIERS, TRADE_BANDS, CHARACTERISTIC_DICE }
from "../postings.mjs";
let passed = 0, failed = 0;
const test = (name, fn) => {
try { fn(); passed++; }
catch (e) { failed++; console.error(` FAIL ${name}\n ${e.message}`); }
};
/* ---------------------------------------------------------------- *
* Contact — R-246: a fumbled greeting used to cost nothing
* ---------------------------------------------------------------- */
// The engine and this file both call impressionDeltaFor. Re-implementing it here
// would have been the exact duplication check-rules exists to forbid — and the
// first draft of this file did precisely that.
const impressionAfter = (stage, level, before) =>
before + RULES.impressionDeltaFor(stage, level);
test("R-246 a fumbled SIGNAL lowers impression", () =>
assert.equal(impressionAfter("signal", "fumble", 0), -1));
test("R-246 a fumbled OFFER lowers impression by two", () =>
assert.equal(impressionAfter("offer", "fumble", 0), -2));
test("a successful SIGNAL does not raise impression", () =>
assert.equal(impressionAfter("signal", "success", 0), 0));
// READ's number counts FACTS LEARNED. Paying impression for it would reward research
// as though it were diplomacy, which is the bug the narrow fix avoids.
test("a successful READ does not raise impression", () =>
assert.equal(impressionAfter("read", "success", 0), 0));
test("a critical READ does not raise impression", () =>
assert.equal(impressionAfter("read", "critical", 0), 0));
test("an OFFER is the only thing that raises impression", () =>
assert.equal(impressionAfter("offer", "success", 0), 2));
/* ---------------------------------------------------------------- *
* Trades — prior career does not scale with agency rank
* ---------------------------------------------------------------- */
test("trade bands are the same at every rank", () => {
for (const tier of Object.keys(TIERS)) {
assert.deepEqual(TRADE_BANDS.core, [50, 65], `core moved at ${tier}`);
assert.deepEqual(TRADE_BANDS.support, [30, 50], `support moved at ${tier}`);
}
});
test("a trade is not rated on the agency tier bands", () => {
for (const [id, T] of Object.entries(TIERS)) {
assert.notDeepEqual(TRADE_BANDS.core, T.core,
`TRADE_BANDS.core is identical to tier ${id} — the coupling is back`);
}
});
test("induction improves with service", () => {
const order = ["probationary", "officer", "senior", "veteran"];
let prev = -1;
for (const t of order) {
const band = INDUCTION.bandByTier[t];
assert.ok(band, `no induction band for ${t}`);
assert.ok(band[0] > prev, `induction does not rise at ${t}`);
assert.ok(band[1] > band[0], `induction band inverted at ${t}`);
prev = band[0];
}
});
test("induction stays below a trade's own expertise at every rank", () => {
for (const [t, band] of Object.entries(INDUCTION.bandByTier)) {
assert.ok(band[0] <= TRADE_BANDS.core[0],
`induction at ${t} starts above prior career — the halves have inverted`);
}
});
/* ---------------------------------------------------------------- *
* Registers — every posting and trade is well formed
* ---------------------------------------------------------------- */
const CH = new Set(Object.keys(CHARACTERISTIC_DICE));
test("every posting and trade names a real characteristic", () => {
for (const [id, r] of Object.entries({ ...ROLES, ...TRADES })) {
assert.ok(CH.has(r.stat), `${id}: stat "${r.stat}" is not a characteristic key`);
}
});
// "mas" and "app" are the DISPLAY names of siz and cha. A posting declaring one would
// have passed the old hand-written guard and produced an undefined characteristic.
test("display names are not accepted as characteristic keys", () => {
assert.ok(!CH.has("mas") && !CH.has("app"),
"mas/app are labels, not keys — they must never be valid stats");
});
test("every trade has the same shape as a posting", () => {
for (const [id, t] of Object.entries(TRADES)) {
for (const f of ["label", "blurb", "stat", "core", "support", "talents", "kit"]) {
assert.ok(t[f] !== undefined, `trade ${id} has no ${f}`);
}
assert.equal(t.core.length, 5, `trade ${id} does not train five core skills`);
assert.equal(t.support.length, 5, `trade ${id} does not train five support skills`);
}
});
test("induction issues nothing it does not also teach the use of", () => {
// The catalogue check lives in check-kits; this asserts the pairing exists at all.
assert.ok(INDUCTION.skills.includes("firearm:pistol"),
"induction issues a sidearm and must teach it");
});
test("the characteristic dice cover exactly the eight the sheet shows", () => {
assert.equal(Object.keys(CHARACTERISTIC_DICE).length, 8);
for (const f of Object.values(CHARACTERISTIC_DICE)) {
assert.match(f, /^\d+d\d+(\+\d+)?$/, `"${f}" is not a dice formula`);
}
});
/* ---------------------------------------------------------------- *
* Rules that a play-through found, held down here instead
* ---------------------------------------------------------------- */
// R-231/232: the fire mode gates the burst rules.
test("R-231 a slow weapon cannot fire a burst", () =>
assert.deepEqual(RULES.burstSizesFor("slow"), []));
test("R-232 `fast` is a real fire mode", () =>
assert.equal(RULES.fireModeFor("fast").id, "fast"));
test("an unknown fire mode falls back to single, not to undefined", () =>
assert.equal(RULES.fireModeFor("nonsense").id, "single"));
// R-243: a helmet does not protect your legs.
test("R-243 a head piece does not armour a leg", () =>
assert.equal(RULES.locationArmourFor(0, [{ points: 5, coverage: "head" }], "leg"), 0));
test("R-243 unstated coverage still protects everything", () =>
assert.equal(RULES.locationArmourFor(0, [{ points: 5 }], "leg"), 5));
// The lamp rule: an unmeasured distance is not "in the light".
test("an unknown distance is not lit", () =>
assert.equal(RULES.lampCovers(null, 10), false));
test("a lamp lights someone standing inside its reach", () =>
assert.equal(RULES.lampCovers(4, 6), true));
// Shields: armour with a switch, and wear that is actually reachable.
test("a lowered shield is worth nothing", () =>
assert.equal(RULES.shieldArmourFor(4, { raised: false }), 0));
test("a broken shield is worth nothing even raised", () =>
assert.equal(RULES.shieldArmourFor(4, { raised: true, broken: true }), 0));
// R-245: wear must be reachable. A rule that only fires on a clean stop never fires.
test("R-245 a special costs a shield wear", () =>
assert.ok(RULES.shieldWearFor("special") > 0));
test("R-245 an ordinary blow costs a shield nothing", () =>
assert.equal(RULES.shieldWearFor("success"), 0));
// Graded defences: only a critical stops a critical.
test("a success against a critical still lands, one step down", () => {
const o = RULES.defenceOutcomeFor("critical", "success");
assert.equal(o.turnedAside, false);
assert.equal(o.landsAt, "special");
});
test("a critical against a critical turns it aside", () =>
assert.equal(RULES.defenceOutcomeFor("critical", "critical").turnedAside, true));
/* ---------------------------------------------------------------- */
if (failed) {
console.error(`check-behaviour: ${failed} failure(s), ${passed} passed`);
process.exit(1);
}
console.log(`check-behaviour: OK — ${passed} behavioural tests`);