/** * 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`);