c0 argued a baseline for the step-5 split would be a cache of the rule and a guard
over it would mostly assert that arithmetic has not changed. Right objection,
wrong conclusion: do not store it. ARTIFACTS now takes a { derive } entry as well
as a file path -- enumerated on this build, nothing stored, and no --update able
to silence a real disagreement between the document and the game.
tools/step5-split.mjs enumerates all 10,000 pairs through opposedContestFor with
its targets derived: 55 and 60 are the lowest POWx5 in the six and in the cut, 42
is applyDifficulty(85, "difficult"). Two different rules produce those three
numbers -- the accepted row is a named exception, not the lowest of anything -- and
a test fails if anyone unifies them. A tie in "the lowest POW in the room" is
fatal rather than silently resolved; it fired for real in testing.
Proved four ways, including raising Okonkwo's POW to 13: the lowest moves to
Braithwaite, refused.held goes 48.1 to 52.4, and the citation that was correct a
moment earlier fails. The figure follows the rule.
Landed unused -- the markers are c0's to place in their own file.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
930 lines
50 KiB
JavaScript
930 lines
50 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 { 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";
|
|
import { castMarkersIn, castLikeIn } from "./declared-cast.mjs";
|
|
import { tagsIn, classifiedFiles, playableFiles } from "./check-scenarios.mjs";
|
|
import { beatsIn, beatLikeIn } from "./outcome-coverage.mjs";
|
|
import { step5Split, split as step5Of, THING_RATING } from "./step5-split.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. */
|
|
|
|
/* A SCOPE ANCHORED ON A NAME THAT HAS MOVED IS NOT A SMALLER SCOPE, IT IS THE WHOLE FILE.
|
|
`indexOf` returns -1 for a name that is gone, `slice(0, -1)` is everything-but-one-character,
|
|
and the assertion below then passes against 30,825 characters of unrelated code while
|
|
reading like a test about one function — measured, not supposed: renaming the end anchor
|
|
took this body from 3,184 characters to 30,825 and the test stayed green. The other site
|
|
used a 4,000-character window on a function that runs 4,305, so it was both short of its
|
|
own subject and one edit away from covering the next one.
|
|
|
|
Same defect R-285 removed from check-powers, in source rather than prose. So the anchor is
|
|
asserted and the end is structural: the next top-level function, whatever it is called. */
|
|
const NEXT_FN = /\n(?:export\s+)?(?:async\s+)?function\s/;
|
|
function bodyOf(src, decl) {
|
|
const at = src.indexOf(decl);
|
|
assert.notEqual(at, -1, `check-behaviour scopes a test to "${decl}" and ringbrp.mjs no longer `
|
|
+ `contains it. Fix the anchor: unfixed, the test would read the whole file and pass.`);
|
|
const rest = src.slice(at);
|
|
const end = rest.slice(1).search(NEXT_FN);
|
|
return end < 0 ? rest : rest.slice(0, end + 1);
|
|
}
|
|
|
|
test("an ordinary weapon damage roll asks for a location", () => {
|
|
const src = readFileSync(new URL("../ringbrp.mjs", import.meta.url), "utf8");
|
|
const body = bodyOf(src, "export async function rollWeaponDamage");
|
|
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 body = bodyOf(src, "export async function burstAttack");
|
|
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);
|
|
});
|
|
|
|
/* R-255. The hide rules are three functions that only mean anything composed, and the
|
|
composition lives in prepareDerivedData where no test could reach it. So compose them
|
|
here the same way the sheet does — from the real supporter spec and the real tables —
|
|
because a hide that halves correctly in `rules.mjs` and arrives wrong on the sheet is
|
|
this project's signature defect, and check-anatomy's guard 6 is deliberately one-sided
|
|
and would not catch the vital losing its exemption. */
|
|
test("R-255 the supporter's hide reaches each location at the right value, flying and grounded", async () => {
|
|
const { NPCS } = await import("./content.mjs");
|
|
const spec = NPCS.find(n => n.key === "supporter");
|
|
assert.ok(spec, "there is no supporter in the bestiary any more");
|
|
const hide = spec.naturalArmour;
|
|
|
|
// Exactly the expression at ringbrp.mjs:624, with no worn armour on the beast.
|
|
const onSheet = (loc, grounded) =>
|
|
RULES.locationArmourFor((Number(loc.armour) || 0)
|
|
+ RULES.naturalArmourOn(loc, RULES.exposedHideFor(hide, grounded, loc.kind)),
|
|
[], loc.kind);
|
|
|
|
const flying = TABLES[spec.bodyPlan];
|
|
for (const loc of flying.locations) {
|
|
const got = onSheet(loc, false);
|
|
if (loc.kind === "wing") assert.equal(got, 0, `a wing carries no hide, and ${loc.id} shows ${got}`);
|
|
else assert.equal(got, hide, `${loc.id} should show the full hide of ${hide} in the air, not ${got}`);
|
|
}
|
|
|
|
// Grounded, it is a different body plan: the one groundedPlanFor names.
|
|
const landed = TABLES[RULES.groundedPlanFor(spec.bodyPlan)];
|
|
assert.ok(landed, "the winged plan no longer declares where it lands");
|
|
let softened = 0;
|
|
for (const loc of landed.locations) {
|
|
const got = onSheet(loc, true);
|
|
if (loc.kind === "vital") {
|
|
assert.equal(got, hide,
|
|
`the chest faces you in every posture; it should still show ${hide} grounded, not ${got}`);
|
|
} else {
|
|
assert.equal(got, Math.floor(hide / 2),
|
|
`${loc.id} should halve to ${Math.floor(hide / 2)} on the ground, not ${got}`);
|
|
softened++;
|
|
}
|
|
}
|
|
assert.ok(softened >= 4, "grounding softened almost nothing, so it buys the players nothing");
|
|
assert.ok(Math.floor(hide / 2) < 5,
|
|
`grounded hide is ${Math.floor(hide / 2)}, which an ordinary round still cannot beat`);
|
|
});
|
|
|
|
/* ── R-286 / R-288 / R-289: who a scenario casts ─────────────────────────────────────
|
|
ON STRINGS, NOT ON THE SCENARIO. Both of these defects were first shown by editing
|
|
CLEAN_GROUND.md in the shared tree to see what the reader did with it, which is how one
|
|
session came within a `git checkout` of another's uncommitted work, and it is also a worse
|
|
test: it can only hold one shape at a time and it leaves nothing behind. c0 made the point.
|
|
The shapes below are the whole finding, they run on every build, and they cost nothing. */
|
|
|
|
const SIX = "pc_ashcroft, pc_bhattacharya, pc_renshaw, pc_braithwaite, pc_pollard, pc_okonkwo";
|
|
const keysOf = t => castMarkersIn(t).map(m => m.keys);
|
|
|
|
test("a declaration is read, and a mention of one in backticks is not", () => {
|
|
// The real document's shape: the marker, then the warning that quotes it. R-286.
|
|
const doc = `## Casting\n\n<!-- cast: ${SIX} -->\n\nThe \`<!-- cast: -->\` comment above is read by two guards.\n`;
|
|
assert.deepEqual(keysOf(doc), [SIX]);
|
|
});
|
|
|
|
test("the mention above the declaration still reads the declaration", () => {
|
|
/* THE BUG ITSELF. Before R-286 this returned the empty capture, check-rollable fell back to
|
|
the whole duty roster and printed the same OK line. The arrangement was only ever correct
|
|
because the real marker happened to sort first. */
|
|
const doc = `The \`<!-- cast: -->\` comment below is read by two guards.\n\n<!-- cast: ${SIX} -->\n`;
|
|
assert.deepEqual(keysOf(doc), [SIX]);
|
|
});
|
|
|
|
test("two real declarations are both seen, so neither can win by position", () => {
|
|
const doc = `<!-- cast: pc_ashcroft -->\nfiller\n<!-- cast: ${SIX} -->\n`;
|
|
const found = castMarkersIn(doc);
|
|
assert.equal(found.length, 2);
|
|
assert.deepEqual(found.map(m => m.line), [1, 3], "line numbers must survive the code blanking");
|
|
});
|
|
|
|
test("a declaration inside a fenced block is documentation, not a cast", () => {
|
|
assert.deepEqual(keysOf("```md\n<!-- cast: " + SIX + " -->\n```\n"), []);
|
|
});
|
|
|
|
test("a declaration that names nobody is read as present and empty", () => {
|
|
// R-288 needs to tell this from "no marker at all", which falls back on purpose.
|
|
assert.deepEqual(keysOf("<!-- cast: -->"), [""]);
|
|
assert.deepEqual(keysOf("no marker here"), []);
|
|
});
|
|
|
|
test("spacing around the colon does not hide a declaration", () => {
|
|
/* R-289, found by writing these tests. `<!-- cast : ... -->` matched nothing, so the guard
|
|
saw no declaration, widened to the full roster and went green — and R-288 could not catch
|
|
it because nothing was present to be empty. check-cited's three spellings, again. */
|
|
for (const m of [`<!-- cast : ${SIX} -->`, `<!-- Cast: ${SIX} -->`, `<!--cast:${SIX}-->`,
|
|
`<!-- CAST : ${SIX} -->`]) {
|
|
assert.deepEqual(keysOf(m), [SIX], `this spelling reads nothing: ${m}`);
|
|
}
|
|
});
|
|
|
|
test("a cast-shaped marker the reader cannot parse is named, not skipped", () => {
|
|
/* The asymmetric half. Widening the reader fixes the spellings we thought of; this is what
|
|
names the one we did not, instead of silently declaring nobody. */
|
|
for (const m of [`<!-- cast = ${SIX} -->`, `<!-- casting: ${SIX} -->`, `<!-- the cast is ${SIX} -->`]) {
|
|
assert.equal(castMarkersIn(m).length, 0);
|
|
assert.equal(castLikeIn(m).length, 1, `not flagged as cast-shaped: ${m}`);
|
|
}
|
|
assert.deepEqual(castLikeIn(`<!-- cast: ${SIX} -->`), [], "a readable marker is not an oddity");
|
|
assert.deepEqual(castLikeIn("`<!-- cast = x -->`"), [], "prose in backticks is not an oddity");
|
|
});
|
|
|
|
/* ── R-290: two readers of the same tag must not lose one between them ───────────────
|
|
check-scenarios' `tagsIn` decides which beats are checked for skills and reachability;
|
|
outcome-coverage's `beatsIn` decides which are checked for stated outcomes, with
|
|
`beatLikeIn` naming what it could not parse. Two strict readers of one format, written a
|
|
week apart by two sessions.
|
|
|
|
c0 asked the right question when R-290 widened `tagsIn`: if the widening reaches past its
|
|
loose counter, that counter drops to zero for the wrong reason and stops being a canary.
|
|
It does not — the widened pattern is a subset of the loose one — but "does not today" is
|
|
the kind of claim that decays silently, so it is asserted rather than remembered. The
|
|
invariant is the weakest one that keeps the canary alive: a tag `tagsIn` accepts must be
|
|
visible to the other guard, either parsed or named. Never to neither. */
|
|
|
|
test("a tag one reader accepts is never invisible to the other", () => {
|
|
const seen = t => beatsIn(t).length + beatLikeIn(t).length;
|
|
for (const tag of ["[CUS: Spot — what it gets]", "[cus: Spot — what it gets]",
|
|
"[CUS : Spot — x]", "[ CUS: Spot — x]", "[CUS:Spot]"]) {
|
|
assert.equal(tagsIn(tag).length, 1, `tagsIn no longer reads ${tag}`);
|
|
assert.ok(seen(tag) >= 1,
|
|
`${tag} is read by tagsIn and invisible to the outcome guard — widening one reader past `
|
|
+ `the other's counter is how a tag gets checked for reachability and never for whether `
|
|
+ `the beat says what it does`);
|
|
}
|
|
// and the counter still has teeth: shapes the strict readers refuse are still named
|
|
for (const tag of ["[CUS Spot]", "[CUS= Spot]"]) {
|
|
assert.equal(tagsIn(tag).length, 0);
|
|
assert.equal(beatLikeIn(tag).length, 1, `${tag} is refused by both readers and named by neither`);
|
|
}
|
|
});
|
|
|
|
/* ── R-293: a record of a session is not a scenario ──────────────────────────────────
|
|
check-rollable's claim is that every clue a GM can put behind a roll is reachable by
|
|
somebody at the table. The corpus it read held three kinds of document and only one is a
|
|
thing a GM runs, so a desk playtest quoting `[CUS: Anomaly Lore — what a peg is]` to say
|
|
which beat a roll hung off had that quotation held to the claim. Measured, not assumed:
|
|
planting an unreachable skill in a playtest report failed the build before this split. */
|
|
|
|
test("the corpus knows which of its documents are played and which are records", () => {
|
|
const kinds = classifiedFiles();
|
|
assert.ok(kinds.length >= 10, "the corpus has shrunk unexpectedly");
|
|
assert.ok(kinds.every(([, , kind]) => kind === "playable" || kind === "record"),
|
|
"classifiedFiles exits on an unclassified document, so nothing here should be null");
|
|
const playable = playableFiles().map(([l]) => l);
|
|
assert.ok(playable.includes("docs/scenarios/CLEAN_GROUND.md"));
|
|
assert.ok(!playable.some(l => /PLAYTEST/i.test(l)),
|
|
"a desk playtest is a record of a session already played; its quoted beats are not rolls");
|
|
assert.ok(!playable.some(l => /_ART\.md$/.test(l)), "an art prompt sheet has no rolls");
|
|
});
|
|
|
|
/* ── R-294: the step-5 split, derived rather than stored ─────────────────────────────── */
|
|
|
|
test("the step-5 outcomes are exhaustive — every contest lands somewhere", () => {
|
|
/* Asserted on the RAW proportions, because that is where the invariant lives. The first
|
|
version of this test checked the rounded percentages and failed at target 55: 40.7 +
|
|
48.1 + 11.3 is 100.1, three independent round-ups. The code was right and the test was
|
|
wrong, which is worth a comment — the displayed parts of a split need not sum to 100,
|
|
and the document's own table has the same harmless 0.1. */
|
|
for (const target of [42, 55, 60, 75]) {
|
|
const s = step5Of(THING_RATING, target);
|
|
const raw = s.raw.taken + s.raw.held + s.raw.unsettled;
|
|
assert.equal(Math.round(raw * 10000), 10000,
|
|
`every one of the 10,000 pairs must land in exactly one outcome, at target ${target}`);
|
|
const shown = s.taken + s.held + s.unsettled;
|
|
assert.ok(Math.abs(shown - 100) <= 0.2,
|
|
`rounded parts drifted ${shown} from 100 at target ${target} — more than independent `
|
|
+ `rounding explains`);
|
|
}
|
|
});
|
|
|
|
test("the accepted row is a named exception, not the lowest POW in the room", () => {
|
|
/* Two different rules produce these numbers. Collapsing them into one derivation would
|
|
give an answer that is right today for the wrong reason — c0's objection, kept as a
|
|
test so the next person to tidy this cannot quietly unify them. */
|
|
const s = step5Split();
|
|
assert.ok(s.accepted.target < s.refused.target,
|
|
"Ashcroft at Difficult must resist lower than the lowest POW in the room, or the "
|
|
+ "accepted branch is not the exception the scenario describes");
|
|
assert.notEqual(s.accepted.who, s.refused.who);
|
|
});
|
|
|
|
test("the blend uses raw proportions, not the rounded rows", () => {
|
|
/* THE BUG THIS FILE WATCHES FOR, made in this very module and caught by comparing against
|
|
the document: blending 40.7 and 48.7 gives 43.7 where the answer is 43.637 -> 43.6. The
|
|
comment above the code said "raw proportions" while the code used the rounded ones. */
|
|
const s = step5Split();
|
|
const w = s.refusedShare / 100;
|
|
const fromRaw = Math.round((w * s.refused.raw.taken + (1 - w) * s.accepted.raw.taken) * 1000) / 10;
|
|
assert.equal(s.blended.taken, fromRaw, "blended.taken must be computed from the raw proportions");
|
|
const fromRounded = Math.round((w * s.refused.taken + (1 - w) * s.accepted.taken) * 10) / 10;
|
|
if (fromRounded !== fromRaw) {
|
|
assert.notEqual(s.blended.taken, fromRounded,
|
|
"this is the rounded-input blend, which is the defect; if the two ever coincide this "
|
|
+ "assertion goes quiet, and the equality above is the one that still holds");
|
|
}
|
|
});
|
|
|
|
/* ---------------------------------------------------------------- */
|
|
|
|
await runAll();
|
|
|
|
if (failed) {
|
|
console.error(`check-behaviour: ${failed} failure(s), ${passed} passed`);
|
|
process.exit(1);
|
|
}
|
|
console.log(`check-behaviour: OK — ${passed} behavioural tests`);
|