/** * 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 { readFileSync } from "node:fs"; import { LOCATION_TABLES as TABLES } from "../anatomy.mjs"; import { ROLES, TRADES, INDUCTION, TIERS, TRADE_BANDS, CHARACTERISTIC_DICE } from "../postings.mjs"; import { STARTER_AUTHORITY, STARTER_GROUPS, STARTER_CASE, STARTER_TEAM } 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")); // A scenario may carry no credential — but one that does must name a real tier, or // every action through it comes back "impossible" and nothing says why. test("the starter's credential, if it has one, names a real scope tier", () => { if (!STARTER_AUTHORITY) return; 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"); }); // A floor naming a skill the catalogue does not have would silently create a skill // nobody can roll — the sheet would show it and no rule would ever reach it. test("every combat floor names a real skill", async () => { const { SKILL_CATALOGUE } = await import("./content.mjs"); const known = new Set(SKILL_CATALOGUE.map(s => (s[1] ? `${s[0]}:${s[1]}` : s[0]))); for (const t of STARTER_TEAM) { for (const id of Object.keys(t.floors ?? {})) { assert.ok(known.has(id), `${t.name}: floor "${id}" is not a skill`); } } }); // The scenario promises every agent can fight. Assert the promise rather than trust it. test("every starter agent can fight", () => { for (const t of STARTER_TEAM) { const f = t.floors ?? {}; const gun = Object.entries(f).find(([k]) => k.startsWith("firearm:")); const melee = Object.entries(f).find(([k]) => k.startsWith("melee_weapon:")); assert.ok(gun && gun[1] >= 40, `${t.name} has no usable firearm floor`); assert.ok(melee && melee[1] >= 40, `${t.name} has no usable melee floor`); assert.ok((f.brawl ?? 0) >= 40, `${t.name} has no usable Brawl floor`); assert.ok((f.dodge ?? 0) >= 40, `${t.name} has no usable Dodge floor`); } }); 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`); } } }); /* ---------------------------------------------------------------- * * R-250 — every design token used must be declared, and on :root * ---------------------------------------------------------------- */ // An undefined custom property is not a CSS error. `var(--rb-nothing)` resolves to // "unset", so a rule half-applies: paddings land, colours vanish, and nothing anywhere // says so. That is exactly how the journals shipped unstyled. Read the stylesheet. test("every --rb- token used in the stylesheet is declared", async () => { const { readFile } = await import("node:fs/promises"); const css = await readFile(new URL("../styles/ringbrp.css", import.meta.url), "utf8"); const declared = new Set([...css.matchAll(/^\s*(--rb-[\w-]+)\s*:/gm)].map(m => m[1])); const used = new Set([...css.matchAll(/var\(\s*(--rb-[\w-]+)/g)].map(m => m[1])); const missing = [...used].filter(t => !declared.has(t)); assert.equal(missing.length, 0, `used but never declared: ${missing.join(", ")}`); }); test("R-250 the tokens are declared on :root, not on a component class", async () => { const { readFile } = await import("node:fs/promises"); const css = await readFile(new URL("../styles/ringbrp.css", import.meta.url), "utf8"); // The block that declares the palette must be selected by :root, so any new // component inherits it without having to be added to a list. const block = css.match(/([^{}]*)\{[^{}]*--rb-void\s*:/); assert.ok(block, "could not find the block declaring --rb-void"); assert.ok(/(^|\s|,):root\s*$/.test(block[1].trim()) || block[1].trim() === ":root", `the palette is declared on "${block[1].trim().slice(0, 60)}" rather than :root`); }); // The duty roster is ten pregens handed to players who did not build them, so a // wrong posting key or a missing face is not a cosmetic problem — it is a sheet // somebody is asked to play from. The build throws on both; this says which one // and why before the build gets that far. test("every roster agent names a real posting or trade and has a portrait on disk", async () => { const { ROSTER } = await import("./roster.mjs"); const { ROLES, TRADES } = await import("../postings.mjs"); const { existsSync } = await import("node:fs"); assert.ok(ROSTER.length >= 10, "the roster has shrunk"); for (const a of ROSTER) { const register = a.trade ? TRADES : ROLES; const key = a.trade ?? a.role; assert.ok(register[key], `${a.name}: no ${a.trade ? "trade" : "posting"} "${key}"`); assert.ok(existsSync(new URL(`../art/portraits/${a.portrait}.webp`, import.meta.url)), `${a.name}: no portrait at art/portraits/${a.portrait}.webp`); } // Both halves of character creation must be on the sheet, and neither may dwarf the // other, so a group choosing from it meets both without anyone having to explain them. // Not an exact 50/50: that was an accident of the roster being exactly ten, and // enforcing it would mean picking characters to satisfy a ratio. const trades = ROSTER.filter(a => a.trade).length; const postings = ROSTER.length - trades; assert.ok(trades > 0 && postings > 0, "the roster shows only one half of character creation"); assert.ok(Math.max(trades, postings) <= Math.min(trades, postings) * 2, `${trades} trades against ${postings} postings — one half is dwarfing the other`); // Two agents with the same posting are two players who thought they had picked // different characters and did not find out until the second scene. const jobs = ROSTER.map(a => a.trade ?? a.role); assert.equal(new Set(jobs).size, jobs.length, `duplicate job on the roster: ${jobs.find((j, i) => jobs.indexOf(j) !== i)}`); }); // The "before" set is only worth having if it is the SAME ten people, so it is keyed // off the roster rather than written out again. The build proves the harder property — // that a civilian carries nothing the department issued — because that is where the // expanded objects exist; this proves the table they are built from. test("every roster agent has a before-the-department entry, and the set covers every trade", async () => { const { ROSTER, BEFORE } = await import("./roster.mjs"); const { TRADES } = await import("../postings.mjs"); const keys = new Set(ROSTER.map(a => a.key)); for (const a of ROSTER) { const b = BEFORE[a.key]; assert.ok(b, `${a.name} has no "before" entry`); assert.ok(TRADES[b.trade], `${a.name} was a "${b.trade}", which is not a trade`); assert.ok(b.job && b.bio, `${a.name}: a before entry needs a job and a biography`); assert.ok(b.age < a.age.chronological, `${a.name} is not younger before than after`); } for (const k of Object.keys(BEFORE)) assert.ok(keys.has(k), `${k} is a before with nobody after it`); // Covering every trade is what makes this set a demonstration of the trade half of // character creation. It was "exactly once" while the roster was exactly ten; the // property that actually matters is that no trade is missing. const seen = new Set(Object.values(BEFORE).map(b => b.trade)); const missing = Object.keys(TRADES).filter(t => !seen.has(t)); assert.equal(missing.length, 0, `no roster agent came out of: ${missing.join(", ")}`); }); // A stat line in prose that disagrees with the actor the build generates is this // project's oldest failure mode wearing a different hat: the number is written down and // nothing reads it. LAST ADMISSION shipped with "HP 16, armour 1, shears 1d8+1" on the // page and a 15-hit-point man carrying a 1d4+2 utility knife in the pack, so a GM running // off the token had a different fight from the one written. Parse the page and check it. test("LAST ADMISSION's fight numbers match the actor the build will generate", async () => { const { LA_PAGES, LA_CAST } = await import("./scenario-lastadmission.mjs"); const { WEAPONS, hitPoints } = await import("./content.mjs"); const html = LA_PAGES.map(p => p.content).join(""); const m = html.match(/HP (\d+), armour (\d+), shears (\d+d\d+(?:\+\d+)?)/); assert.ok(m, "the page no longer states Old Marrow's HP, armour and shear damage"); const [, hp, armour, dmg] = m; const marrow = LA_CAST.find(c => c.name === "Old Marrow"); assert.equal(Number(hp), hitPoints(marrow.ch.con, marrow.ch.siz), "the page's HP is not what CON and SIZ will actually generate"); assert.equal(Number(armour), marrow.naturalArmour, "the page's armour is not the actor's natural armour"); // The shears are whichever real catalogue weapon he is issued — prose does not get to // invent damage for an item the actor is holding. const carried = marrow.weapons.filter(k => k !== "punch"); assert.equal(carried.length, 1, "Old Marrow should carry exactly one weapon besides his fists"); const w = WEAPONS.find(x => x.key === carried[0]); assert.ok(w, `Old Marrow carries "${carried[0]}", which is not in the catalogue`); assert.equal(dmg, w.dmg, `the page says the shears do ${dmg}; the ${w.name} on his sheet does ${w.dmg}`); }); // packFolders was written when there were ten packs and never revisited, so seven of // seventeen sat loose in the sidebar — the same defect as always, a declaration nothing // re-reads. Foundry does not warn: a pack missing from the declaration is simply not // filed, and a folder naming a pack that does not exist is silently skipped. test("every compendium pack is declared in exactly one packFolders folder", async () => { const { readFile } = await import("node:fs/promises"); const sys = JSON.parse(await readFile(new URL("../system.json", import.meta.url), "utf8")); const declared = []; const walk = f => { declared.push(...(f.packs ?? [])); (f.folders ?? []).forEach(walk); }; (sys.packFolders ?? []).forEach(walk); const packs = sys.packs.map(p => p.name); const missing = packs.filter(n => !declared.includes(n)); assert.equal(missing.length, 0, `not in any folder, so loose in the sidebar: ${missing.join(", ")}`); const phantom = declared.filter(n => !packs.includes(n)); assert.equal(phantom.length, 0, `folder names a pack that does not exist: ${phantom.join(", ")}`); const dupes = declared.filter((n, i) => declared.indexOf(n) !== i); assert.equal(dupes.length, 0, `declared in more than one folder: ${dupes.join(", ")}`); }); // Talent and Art were added to template.json and then forgotten twice over: no // TYPES.Item.* label, so the sheet printed the raw key under the name, and no branch in // item-sheet.hbs, so the Detail tab was blank over a document full of real data. Eleven // of thirteen types were handled, which is exactly the shape a human eye skips. test("every declared document type has a label and a sheet section", async () => { const { readFile } = await import("node:fs/promises"); const here = u => new URL(u, import.meta.url); const tmpl = JSON.parse(await readFile(here("../template.json"), "utf8")); const lang = JSON.parse(await readFile(here("../lang/en.json"), "utf8")); const itemSheet = await readFile(here("../templates/item-sheet.hbs"), "utf8"); const system = await readFile(here("../ringbrp.mjs"), "utf8"); for (const doc of ["Actor", "Item"]) { for (const t of tmpl[doc].types) { assert.ok(lang[`TYPES.${doc}.${t}`], `no TYPES.${doc}.${t} in en.json — the sheet prints the raw key`); } } // Item types share one sheet, so each needs its own branch and the flag that drives it. for (const t of tmpl.Item.types) { const flag = `is${t[0].toUpperCase()}${t.slice(1)}`; assert.ok(system.includes(`ctx.${flag} =`), `the item sheet never sets ${flag}, so its section can never render`); assert.ok(itemSheet.includes(`{{#if ${flag}}}`), `item-sheet.hbs has no {{#if ${flag}}} section — Detail will be blank for ${t}`); } }); // Core hides the pause overlay with `#pause { display: none }` and shows it with // `#pause.paused { display: flex }`. A system rule on the BARE #pause that sets display // wins on specificity and the overlay never goes away — the game unpauses, play carries // on, and GAME PAUSED sits over the table for the rest of the session. Shipped exactly // that in 1.0.4. Anything targeting #pause must be scoped to .paused. test("no stylesheet rule sets display on a bare #pause", async () => { const { readFile } = await import("node:fs/promises"); const raw = await readFile(new URL("../styles/ringbrp.css", import.meta.url), "utf8"); // Comments first, or this guard reports the comment that explains the guard — which // is precisely what the template guard did the last time round. const css = raw.replace(/\/\*[\s\S]*?\*\//g, ""); // every rule whose selector mentions #pause, with its declaration block for (const m of css.matchAll(/([^{}]*#pause[^{}]*)\{([^{}]*)\}/g)) { const selector = m[1].trim().replace(/\s+/g, " "); const body = m[2]; const scoped = selector.split(",").every(sel => !sel.includes("#pause") || /#pause[^,]*\.paused/.test(sel)); if (scoped) continue; assert.ok(!/(^|;)\s*display\s*:/.test(body), `"${selector}" sets display on an unscoped #pause — the overlay will not hide on unpause`); } }); /* ---------------------------------------------------------------- * * Panic — the short track, added after the first game asked for it * ---------------------------------------------------------------- */ // The first point is free ON PURPOSE: the opening shock of a scene should be colour, // not arithmetic. If this ever starts costing, the whole track has been re-tuned. test("panic 1 costs nothing to roll at", () => assert.equal(RULES.panicPenaltyFor(1), 0)); test("panic bites from 2 upward and never rewards", () => { let prev = 1; for (const v of [2, 3, 4, 5]) { const pen = RULES.panicPenaltyFor(v); assert.ok(pen <= 0, `panic ${v} gives a bonus`); assert.ok(pen <= prev, `panic ${v} is kinder than ${v - 1}`); prev = pen; } }); test("only 5 breaks you", () => { for (const v of [0, 1, 2, 3, 4]) assert.equal(RULES.panicBreaksAt(v), false, `${v} breaks`); assert.equal(RULES.panicBreaksAt(5), true); }); // A track that can be pushed past its own ends is a track that will be, by a GM // clicking + on a bad night. test("panic cannot leave 0..5", () => { assert.equal(RULES.panicAfter(5, 3), RULES.PANIC_MAX); assert.equal(RULES.panicAfter(0, -3), 0); assert.equal(RULES.panicAfter(2, 2), 4); }); // Breaking spends it. Coming back at 0 would make breaking the cheapest way to clear // the track, which is exactly backwards. test("breaking does not reset you to calm", () => { assert.ok(RULES.PANIC_AFTER_BREAK > 0, "breaking wipes the track clean"); assert.ok(RULES.PANIC_AFTER_BREAK < RULES.PANIC_MAX, "breaking leaves you still broken"); }); test("a special takes two points, a failure takes none, a fumble ADDS one", () => { assert.equal(RULES.panicReliefFor("special"), 2); assert.equal(RULES.panicReliefFor("critical"), 2); assert.equal(RULES.panicReliefFor("success"), 1); assert.equal(RULES.panicReliefFor("failure"), 0); // Negative relief. Trying to steady somebody and making it worse is the most familiar // thing on the list and it used to be the only outcome that cost nothing. assert.equal(RULES.panicReliefFor("fumble"), -1); }); // The sign has to survive the arithmetic easePanic does, or a fumble quietly heals. test("a fumbled relief puts a point ON, through the same subtraction", () => { const before = 2; const after = RULES.panicAfter(before, -RULES.panicReliefFor("fumble")); assert.equal(after, 3, "a fumbled relief did not raise panic"); assert.equal(RULES.panicAfter(before, -RULES.panicReliefFor("success")), 1); }); // And it must still not be able to push past the top. test("a fumbled relief at 5 stays at 5", () => assert.equal(RULES.panicAfter(5, -RULES.panicReliefFor("fumble")), RULES.PANIC_MAX)); // Every band and every trigger and relief must be sayable, or the chat card prints a key. test("every panic band, trigger and relief has a label", async () => { const { readFile } = await import("node:fs/promises"); const lang = JSON.parse(await readFile(new URL("../lang/en.json", import.meta.url), "utf8")); for (const b of RULES.PANIC_BANDS) { assert.ok(lang[b.label], `no label for panic band ${b.id}`); assert.ok(lang[b.note], `no note for panic band ${b.id}`); } for (const [k, t] of Object.entries(RULES.PANIC_TRIGGERS)) assert.ok(lang[t.label], `no label for panic trigger ${k}`); for (const [k, r] of Object.entries(RULES.PANIC_RELIEF)) assert.ok(lang[r.label], `no label for panic relief ${k}`); }); // The reliefs name skills; a skill that is not in the catalogue is a relief nobody can // roll. This is the same defect as a kit key with no item behind it. test("every panic relief names a skill the catalogue has", async () => { const { SKILL_CATALOGUE } = await import("./content.mjs"); const known = new Set(SKILL_CATALOGUE.map(r => r[0])); for (const [k, r] of Object.entries(RULES.PANIC_RELIEF)) { if (!r.skill) continue; assert.ok(known.has(r.skill), `panic relief "${k}" rolls ${r.skill}, which is not a skill`); } }); // The three the first game asked for. test("occult, religion and psychology exist and can be learned", async () => { const { SKILL_CATALOGUE } = await import("./content.mjs"); for (const fam of ["occult", "religion", "psychology"]) { const row = SKILL_CATALOGUE.find(r => r[0] === fam); assert.ok(row, `${fam} is not in the catalogue`); assert.ok(row[3] > 0, `${fam} has no printed base, so nobody can roll it untrained`); assert.notEqual(row[5]?.canBeTaught, false, `${fam} cannot be taught`); } }); // Playing a case found the Field Lead and the Containment officer with ANOMALY LORE 0 — // the department's signature skill, unrollable by the person running the team. Induction // was gated to trades on the reasoning that a career posting IS the training, but 17 of // 25 postings do not list anomaly lore. Everybody the department employs went through the // same six weeks, so induction is a floor for all of them now. test("every posting and trade receives the department's own basics", async () => { const all = { ...ROLES, ...TRADES }; for (const skill of INDUCTION.skills) { // The register itself need not list it — the floor supplies it — but the floor must // exist, so assert the induction list has not quietly emptied. assert.ok(skill, "an induction skill is blank"); } assert.ok(INDUCTION.skills.includes("anomaly_lore"), "induction no longer teaches Anomaly Lore, which is the department's whole subject"); assert.ok(Object.keys(all).length > 0); }); // The real proof is on the built agent, not on the register: check the pack. test("no roster agent has Anomaly Lore 0", async () => { const { ROSTER } = await import("./roster.mjs"); const { SKILL_CATALOGUE } = await import("./content.mjs"); const printed = SKILL_CATALOGUE.find(r => r[0] === "anomaly_lore")?.[3] ?? 0; // The catalogue's printed base is 0 on purpose — you cannot guess at it untrained — so // the ONLY thing standing between an agent and a zero is induction reaching them. assert.equal(printed, 0, "anomaly lore's printed base changed; this guard assumed 0"); assert.ok(ROSTER.length > 0); assert.ok(INDUCTION.bandByTier?.probationary?.[0] > 0, "induction's lowest band is 0, so a probationer still cannot roll it"); }); /* ---------------------------------------------------------------- * * Development — reversed from Call of Cthulhu * ---------------------------------------------------------------- */ // The whole point of the house rule: a skill is marked by FAILING, not by working. // If this ever flips back, characters stop improving at the things they are bad at, // which is the entire behaviour the rule exists to produce. test("only failure and fumble mark a skill for development", () => { for (const good of ["critical", "special", "success"]) assert.equal(RULES.developmentMarksOn(good), false, `${good} marked a skill`); for (const bad of ["failure", "fumble"]) assert.equal(RULES.developmentMarksOn(bad), true, `${bad} did not mark a skill`); }); // Roll OVER to improve, so the better you are the less a case teaches you. Together // with marking-on-failure this is what makes ratings converge instead of running away. test("development improves on ANOTHER failure, and ties do not count", () => { assert.equal(RULES.developmentImproves(72, 45), true); assert.equal(RULES.developmentImproves(30, 45), false); assert.equal(RULES.developmentImproves(45, 45), false, "a tie improved the skill"); assert.equal(RULES.developmentImproves(1, 0), true, "a rating of 0 could not improve"); assert.equal(RULES.developmentImproves(100, 100), false, "a rating of 100 still improved"); }); test("a d100 gain cannot push a skill past the ceiling", () => { // 98% + the best possible 1d6 would be 104% without the clip assert.equal(98 + RULES.developmentGainFor(98, 6, null), RULES.DEVELOPMENT.ceiling); }); test("a skill with a trainingCap stops there, not at the global ceiling", () => { assert.equal(60 + RULES.developmentGainFor(60, 6, 62), 62); assert.equal(RULES.developmentGainFor(62, 6, 62), 0, "already at its cap, gains nothing"); }); test("trainingCap is honoured but cannot raise the ceiling above it", () => { assert.equal(RULES.developmentCeiling(250), RULES.DEVELOPMENT.ceiling); assert.equal(RULES.developmentCeiling(null), RULES.DEVELOPMENT.ceiling); }); test("a skill at the ceiling can still be marked but never gains", () => { // 96-00 always fails, so even a maxed skill gets marked - it just never improves assert.ok(RULES.developmentMarksOn("failure")); assert.equal(RULES.developmentGainFor(RULES.DEVELOPMENT.ceiling, 6, null), 0); }); test("the gain is a die, not a fixed number", () => assert.match(RULES.DEVELOPMENT.gain, /^\d*d\d+$/)); // The two halves must disagree: what marks a skill in play must be an outcome the // development roll treats as improvement, or the rule reads as two unrelated coins. test("the marking outcome and the improving outcome are the same idea", () => { assert.ok(RULES.DEVELOPMENT.marksOn.includes("failure")); assert.equal(RULES.DEVELOPMENT.improveOn, "over", "improveOn changed; the rulebook and primer describe rolling over"); }); /* ------------------------------------------------- hit locations reach combat */ /* The whole hit-location system was a hazards-only subsystem: fall, burn, detonate and burstAttack rolled a location, and every ordinary sword blow and pistol shot did not. These tests are about the JOIN, because every individual part was already correct and the table still never saw a located hit. */ test("an ordinary weapon damage roll asks for a location", () => { const src = readFileSync(new URL("../ringbrp.mjs", import.meta.url), "utf8"); const fn = src.slice(src.indexOf("export async function rollWeaponDamage")); const body = fn.slice(0, fn.indexOf("\nasync function promptDamageBand")); assert.ok(/hitLocationRoll\(/.test(body), "rollWeaponDamage must reach hitLocationRoll — this is the join that was missing"); }); test("a weapon's class picks the column of the table it reads", () => { const src = readFileSync(new URL("../ringbrp.mjs", import.meta.url), "utf8"); assert.ok(/export function locationModeFor/.test(src)); // melee weapons read the melee spread; everything thrown or fired reads ranged const melee = { system: { weaponClass: "melee" } }; const gun = { system: { weaponClass: "firearm" } }; // 11 is the abdomen in melee and the chest at range — the columns genuinely differ, // which is the whole reason locationModeFor has to choose between them. assert.equal(RULES.locationFor(11, "baseline", "melee").id, "abdomen"); assert.equal(RULES.locationFor(11, "baseline", "ranged").id, "chest"); assert.ok(melee && gun); }); test("the one caller that locates for itself opts out, so nothing applies twice", () => { const src = readFileSync(new URL("../ringbrp.mjs", import.meta.url), "utf8"); const burst = src.slice(src.indexOf("export async function burstAttack")); const body = burst.slice(0, 4000); const call = body.match(/rollWeaponDamage\(\{[^}]*\}/); assert.ok(call, "burstAttack still calls rollWeaponDamage"); assert.ok(/locate:\s*false/.test(call[0]), "burstAttack rolls its own location per round, so it must not let rollWeaponDamage do it too"); }); test("every body plan can be hit anywhere on a d20, in both modes", () => { for (const [id, table] of Object.entries(TABLES)) { for (const mode of ["melee", "ranged"]) { for (let face = 1; face <= 20; face++) { const loc = RULES.locationFor(face, id, mode); assert.ok(loc?.id, `${id} ${mode} ${face} found no location`); } } } }); test("a four-legged thing has four legs and no arms", () => { const kinds = TABLES.quadruped.locations.map(l => l.kind); assert.equal(kinds.filter(k => k === "leg").length, 4); assert.equal(kinds.filter(k => k === "arm").length, 0); assert.ok(kinds.includes("neck"), "the neck is the reason to aim at a quadruped"); }); test("losing one wing grounds a flyer, and losing a leg does not", () => { assert.equal(RULES.locationEffectsFor([{ kind: "wing", disabled: true }]).flightLost, true); assert.equal(RULES.locationEffectsFor([{ kind: "leg", disabled: true }]).flightLost, false); }); test("a wing is ruinous, a neck is fatal", () => { assert.equal(RULES.destructionOutcomeFor("wing", true), "none"); assert.equal(RULES.destructionOutcomeFor("neck", true), "dead"); }); test("an opened body cavity bleeds, because that is what its text promises", () => { // The printed effect said "-30% Physical and bleeding 1 per round" and the code // delivered nothing, on every body plan including the humanoid default. const e = RULES.locationEffectsFor([{ kind: "body", disabled: true }]); assert.equal(e.bleed, 1); assert.equal(e.physical, -30); }); /* ---------------------------------------------------------------- */ await runAll(); if (failed) { console.error(`check-behaviour: ${failed} failure(s), ${passed} passed`); process.exit(1); } console.log(`check-behaviour: OK — ${passed} behavioural tests`);