Files
RingBRP/tools/check-behaviour.mjs
T
slaguru666andClaude Opus 5 4e28de2a64 Play PAPER WINDOW with the shipped pregens; fix four silent defects
Ran the starter end to end. It closed, and it found four defects in what I shipped an
hour earlier — all in the scenario data, all silent.

R-247: the Borrowed Authority did not work. I wrote scope "operate", which is not one
of the five scope tiers; an unrecognised scope ranks 0 and every action came back
"impossible" with no error. The first scene of the starter is the Borrowed Authority
tutorial. Fixed, guarded in check-kits, and covered by a test that documents the
failure mode.

R-248: the contact subsystem was unreachable in the scenario built around it. The case
file carried a standing for the Returned Clerks and no GROUP ITEM; resolveContact
returns null without one, so READ, SIGNAL and OFFER did nothing. Groups are data now,
and a test asserts every standing on a shipped case file has a group behind it.

R-249: legs wrote `kindId` where resolveLegDialog reads `leg.kind`. It worked only
because the terrain text happens to begin with "Extraction".

Not a defect: phase four went Difficult because Resources hit 0 and destitution makes
every remaining phase Difficult. Best beat in the session.

All four phases failed and the case still closed — Containment 8 to 2, Resources 8 to
0 — which is what the scenario is built to survive. Two of six closing requirements
succeeded and the settlement still completed. The Cancellation Man opened with a
critical against a raised riot shield and went through 10 points of armour, teaching
the shield and graded-defence lessons in one roll.

Also documented: nobody on the starter team can aim the return, because only the Anchor
Officer trains Transposition. The extraction page now says to narrate it rather than
roll six dice that will all fail.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-11 23:39:43 +01:00

249 lines
12 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";
import { STARTER_AUTHORITY, STARTER_GROUPS, STARTER_CASE } from "./scenario-starter.mjs";
let passed = 0, failed = 0;
// Async tests are awaited in order rather than fired off — a failure that lands after
// the summary has printed is a failure nobody sees.
const pending = [];
const test = (name, fn) => {
pending.push(async () => {
try { await fn(); passed++; }
catch (e) { failed++; console.error(` FAIL ${name}\n ${e.message}`); }
});
};
const runAll = async () => { for (const t of pending) await t(); };
/* ---------------------------------------------------------------- *
* 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));
/* ---------------------------------------------------------------- *
* Borrowed Authority — a bad scope fails silently, which is the worst way
* ---------------------------------------------------------------- */
test("an in-scope action is in scope", () =>
assert.equal(RULES.scopeStanceFor("routine", "routine"), "in-scope"));
test("one tier beyond is a stretch", () =>
assert.equal(RULES.scopeStanceFor("routine", "privileged"), "stretch"));
test("two tiers beyond is impossible at any skill", () =>
assert.equal(RULES.scopeStanceFor("routine", "administrative"), "impossible"));
// An unrecognised scope ranks 0, so EVERYTHING becomes impossible and nothing says so.
test("an unrecognised scope makes everything impossible", () =>
assert.equal(RULES.scopeStanceFor("operate", "routine"), "impossible"));
test("the starter's credential names a real scope tier", async () => {
assert.ok(RULES.SCOPE_TIERS.includes(STARTER_AUTHORITY.scope),
`scope "${STARTER_AUTHORITY.scope}" is not a tier`);
});
/* ---------------------------------------------------------------- *
* Shipped case files — a standing with no group is an unreachable subsystem
* ---------------------------------------------------------------- */
// resolveContact looks up a GROUP ITEM and returns null without one, so a case file
// carrying a standing with no matching group has READ, SIGNAL and OFFER silently
// switched off. That is how PAPER WINDOW first shipped, with the contact scene as its
// centrepiece and no error anywhere. Asserted against the scenario DATA rather than the
// built pack, because the pack is a LevelDB that Foundry holds open while it runs — and
// a test that cannot read its subject must fail loudly, not skip quietly.
test("every standing on the starter case file has a group behind it", () => {
const groups = new Set(STARTER_GROUPS.map(g => g.groupId));
for (const id of STARTER_CASE.standings ?? []) {
assert.ok(groups.has(id),
`case file has a standing for "${id}" with no group — READ/SIGNAL/OFFER cannot run`);
}
});
// resolveLegDialog reads `leg.kind`. A case file that writes `kindId` falls through to
// a regex on the terrain prose, which works right up until somebody renames a phase.
test("starter phases name a kind the engine will read", () => {
const kinds = ["approach", "cordon", "containment", "extraction"];
for (const l of STARTER_CASE.legs) {
assert.ok(kinds.includes(l.kindId), `phase "${l.label}" has kind "${l.kindId}"`);
}
assert.equal(STARTER_CASE.legs.at(-1).kindId, "extraction",
"the last phase must be the extraction, or the return clock never moves");
});
test("every starter group is usable by the contact engine", () => {
for (const g of STARTER_GROUPS) {
assert.ok(g.groupId && g.name, "a group needs an id and a name");
assert.ok(g.desires?.length, `${g.groupId}: a group with no desire cannot be offered anything`);
assert.ok(g.correctOfferForm, `${g.groupId}: no correct offer form`);
for (const t of g.taboos ?? []) {
assert.ok(t.id && t.text, `${g.groupId}: a taboo needs an id and text`);
}
}
});
/* ---------------------------------------------------------------- */
await runAll();
if (failed) {
console.error(`check-behaviour: ${failed} failure(s), ${passed} passed`);
process.exit(1);
}
console.log(`check-behaviour: OK — ${passed} behavioural tests`);