Played it through in Foundry with the six on their civilian sheets. Five
defects, all of the house's favourite kind — a number written down that nothing
reads.
1. Pollard and Agyeman were armed and armoured as CIVILIANS. The police trade
kit carries a revolver and a stab vest, which is right for a serving
officer and wrong for a British civilian, and it ended the scenario's only
fight in a single round. expandFromRegister's civilian mode now strips
firearms and body armour from trade kit as well as induction and standing
issue, and the build throws if one gets through. Wren Finch keeps her
knife — a knife is not an issued weapon.
2. Old Marrow's shears were a utility knife. The page said 1d8+1; the actor
in the pack held 1d4+2, so a GM running off the token had a different
fight from the one written down. He now carries a billhook — an actual
garden tool that is already in the catalogue — and the page says 1d8+2,
which is what a billhook does.
3. He was too soft to be the fight the scenario promises. At the original
numbers he died in an average of 2.4 rounds and in 6 runs of 20 he was
dead before he ever swung. Retuned over thirty runs to HP 20, armour 2:
about four and a half rounds, somebody hurt in 28 of 30, somebody dropped
in 9 of 30. The verification run took four rounds and left Pollard on
eight hit points.
4. Two stat lines disagreed with the generated sheets — Marrow 16 against 15,
Enderby 13 against 12.
5. Two rolls had no failure written: the body, and Sammy's ankle pull. The
body is now marked "no roll" outright, because there is no better answer
to buy. The pull has a fumble result.
New guard: check-behaviour parses the scenario's own fight line out of the page
HTML and compares it to the actor the build will generate — hit points against
hitPoints(CON,SIZ), armour against naturalArmour, and shear damage against the
catalogue entry for whatever he is actually holding. Verified it bites by
reverting the damage to 1d8+1.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
388 lines
20 KiB
JavaScript
388 lines
20 KiB
JavaScript
/**
|
|
* BEHAVIOURAL REGRESSION TESTS.
|
|
*
|
|
* The other three guards are static: they prove that the tables agree with each other
|
|
* and that no rule is written twice. They cannot prove that a rule is READ, and every
|
|
* defect this project has shipped was of that kind — a value computed and never used,
|
|
* found by playing rather than by reading.
|
|
*
|
|
* Playing found them; this file is what stops them coming back. Every numbered defect
|
|
* that can be reproduced without a browser gets a deterministic test here, named for
|
|
* the finding, so the log stops being the only evidence that any of it works.
|
|
*
|
|
* Deterministic on purpose: R-246 was originally confirmed by rolling a hundred and
|
|
* twenty times waiting for a 99, which is not a test, it is a hope.
|
|
*
|
|
* WHAT THIS FILE CANNOT REACH, and where those things are checked instead:
|
|
* - anything inside generateCharacter, resolveContact or the sheets, because they
|
|
* need `game`, Documents and compendia. Those are verified in a running Foundry
|
|
* and the results recorded in docs/REVIEW_LOG.md.
|
|
* - bonus-point eligibility and random species, for the same reason. Both were
|
|
* confirmed against a live world: an officer's points now land only on trained
|
|
* skills, and twelve rolls of "random" produced baseline/vesh/cadence rather
|
|
* than twelve baselines.
|
|
* Pretending otherwise would make this file look more complete than it is.
|
|
*/
|
|
import { strict as assert } from "node:assert";
|
|
import * as RULES from "../rules.mjs";
|
|
import { ROLES, TRADES, INDUCTION, TIERS, TRADE_BANDS, CHARACTERISTIC_DICE }
|
|
from "../postings.mjs";
|
|
import { STARTER_AUTHORITY, STARTER_GROUPS, STARTER_CASE, 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}`);
|
|
});
|
|
|
|
/* ---------------------------------------------------------------- */
|
|
|
|
await runAll();
|
|
|
|
if (failed) {
|
|
console.error(`check-behaviour: ${failed} failure(s), ${passed} passed`);
|
|
process.exit(1);
|
|
}
|
|
console.log(`check-behaviour: OK — ${passed} behavioural tests`);
|