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>
184 lines
8.7 KiB
JavaScript
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`);
|