A Foundry VTT game system: Basic Roleplaying d100 in a drab British agency that deals with crossings, where reality keeps a record of what belongs where and can be made to get it wrong. Contents: - rules.mjs as the single authority for every rule formula, with 40 rules and 132 spot-checked values. Nothing may restate a formula it defines. - 25 postings, each with a key characteristic, trained skills, talents and its own kit; a 175-item catalogue across four eras; 231 generated icons. - Reaction Lanes initiative, re-rolled every round including the first. - Coherence, and the Quiet Arts, which only ever arrive from a crossing gone wrong. - Graded defences: only a critical stops a critical. - Borrowed Authority — access as a record rather than a hacking roll. - A case generator, and a rules journal generated from rules.mjs so the book cannot drift from the code. Built compendia are committed so a remote Foundry needs only the files. node_modules is not; `npm install` restores it for rebuilding content. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
274 lines
20 KiB
JavaScript
274 lines
20 KiB
JavaScript
/**
|
|
* Guard against the single most persistent defect in this project: a rule
|
|
* implemented in two places and changed in one. Five separate bugs came from it,
|
|
* the worst being packs built with double hit points because the builder kept its
|
|
* own drifted copy of the formula.
|
|
*
|
|
* Three properties, each closing a different escape route:
|
|
* 1. NO file in the system except rules.mjs may define a canonical rule. Scanned
|
|
* by discovery, not by a hardcoded file list, so a new file cannot slip past.
|
|
* 2. Every re-export or alias of a canonical name must BE that function, not a
|
|
* same-named copy. Aliases are discovered from the source, not hand-listed.
|
|
* 3. Every function rules.mjs exports must be exercised by at least one check, so
|
|
* adding a rule cannot silently arrive with zero coverage.
|
|
*
|
|
* Run before building packs; build-packs.mjs refuses to build if this fails.
|
|
*/
|
|
import { readFile, readdir } from "node:fs/promises";
|
|
import * as RULES from "../rules.mjs";
|
|
import * as CONTENT from "./content.mjs";
|
|
|
|
const canonical = Object.keys(RULES).filter(k => typeof RULES[k] === "function");
|
|
let failures = 0;
|
|
const fail = m => { console.error(" FAIL " + m); failures++; };
|
|
|
|
/* 1. discover every source file, and let none of them re-define a rule ------- */
|
|
const root = new URL("../", import.meta.url);
|
|
const files = [];
|
|
for (const dir of ["", "tools/"]) {
|
|
for (const name of await readdir(new URL(dir, root))) {
|
|
if (!name.endsWith(".mjs")) continue;
|
|
const rel = dir + name;
|
|
if (rel === "rules.mjs" || rel === "tools/check-rules.mjs") continue;
|
|
files.push(rel);
|
|
}
|
|
}
|
|
for (const rel of files) {
|
|
const src = await readFile(new URL(rel, root), "utf8");
|
|
for (const name of canonical) {
|
|
const defined = new RegExp(`^\\s*(?:export\\s+)?(?:async\\s+)?function ${name}\\s*\\(`, "m").test(src)
|
|
|| new RegExp(`^\\s*(?:export\\s+)?(?:const|let|var) ${name}\\s*=\\s*(?:\\(|function|async)`, "m").test(src);
|
|
if (defined) fail(`${rel} defines ${name}() — it belongs to rules.mjs alone`);
|
|
}
|
|
}
|
|
|
|
/* 2. aliases must be identity-equal ---------------------------------------- *
|
|
* REQUIRED is authoritative and hand-held on purpose. Discovery alone is not
|
|
* enough: the original bug replaced an alias with an arrow function, which a
|
|
* discovery regex simply stops matching — the guard then skips it and reports
|
|
* OK. Absence must fail loudly, so every required alias is asserted by name and
|
|
* discovery may only ADD to the set, never shrink it. */
|
|
const REQUIRED = { hitPoints: "hitPointsFor", majorWound: "majorWoundFor", damageModifier: "damageModifierFor" };
|
|
const contentSrc = await readFile(new URL("tools/content.mjs", root), "utf8");
|
|
const discovered = Object.fromEntries(
|
|
[...contentSrc.matchAll(/^export const (\w+)\s*=\s*(\w+);/gm)]
|
|
.filter(([, , target]) => canonical.includes(target))
|
|
.map(([, alias, target]) => [alias, target]));
|
|
const pairs = { ...discovered, ...REQUIRED };
|
|
for (const [alias, target] of Object.entries(pairs)) {
|
|
if (!(alias in CONTENT)) fail(`content.${alias} is missing — it must re-export rules.${target}`);
|
|
else if (CONTENT[alias] !== RULES[target]) fail(`content.${alias} is not the same function as rules.${target}`);
|
|
}
|
|
|
|
/* 2b. constants are rules too ----------------------------------------------- *
|
|
* Functions were guarded from the start; the REACTION constants were not, and a
|
|
* second object literal for them survived in ringbrp.mjs with its own copy of the
|
|
* divisors. Any non-function export of rules.mjs is now held to the same standard:
|
|
* no other file may write an object literal containing its keys. */
|
|
const constants = Object.keys(RULES).filter(k => typeof RULES[k] === "object" && RULES[k]);
|
|
for (const name of constants) {
|
|
const keys = Object.keys(RULES[name]);
|
|
if (keys.length < 2) continue;
|
|
for (const rel of files) {
|
|
const src = await readFile(new URL(rel, root), "utf8");
|
|
// an object literal that carries two or more of the canonical keys is a copy
|
|
for (const m of src.matchAll(/=\s*\{([^{}]*)\}/g)) {
|
|
const hits = keys.filter(k => new RegExp(`\\b${k}\\s*:`).test(m[1]));
|
|
if (hits.length >= 2) fail(`${rel} re-declares ${name} keys (${hits.join(", ")}) — assign rules.${name} itself`);
|
|
}
|
|
}
|
|
}
|
|
|
|
/* 2c. content sanity: species profiles must exist ---------------------------- *
|
|
* Every pregen shipped with speciesProfile "hominid" — a name left over from the
|
|
* Ringworld setting that is not a key in locationTables. It only ever worked
|
|
* because the sheet falls back to baseline, so three of four agents were quietly
|
|
* drawn and hit-located as something the game does not define. */
|
|
{
|
|
const engineSrc = await readFile(new URL("ringbrp.mjs", root), "utf8");
|
|
const tablesBlock = engineSrc.match(/^RINGBRP\.locationTables = \{([\s\S]*?)^\};/m);
|
|
const profiles = tablesBlock
|
|
? [...tablesBlock[1].matchAll(/^ {2}(\w+):\s*\{/gm)].map(x => x[1]) : [];
|
|
const tpl = JSON.parse(await readFile(new URL("template.json", root), "utf8"));
|
|
const found = [];
|
|
(function walk(o) {
|
|
if (Array.isArray(o)) return o.forEach(walk);
|
|
if (o && typeof o === "object") for (const [k, v] of Object.entries(o)) {
|
|
if (k === "speciesProfile") found.push(v); else walk(v);
|
|
}
|
|
})(tpl);
|
|
if (!profiles.length) fail("could not read locationTables — the species check is not running");
|
|
for (const v of found) {
|
|
if (!profiles.includes(v)) fail(`template.json speciesProfile "${v}" is not a species (have: ${profiles.join(", ")})`);
|
|
}
|
|
}
|
|
|
|
/* 3. behaviour, with mandatory coverage of every exported rule ---------------- */
|
|
const checks = [
|
|
["hitPointsFor(13,10)", RULES.hitPointsFor(13, 10), 12],
|
|
["hitPointsFor(10,19)", RULES.hitPointsFor(10, 19), 15],
|
|
["hitPointsFor(0,0)", RULES.hitPointsFor(0, 0), 1],
|
|
["majorWoundFor(12)", RULES.majorWoundFor(12), 6],
|
|
["majorWoundFor(15)", RULES.majorWoundFor(15), 8],
|
|
["reactionBaseFrom(18,34)", RULES.reactionBaseFrom(18, 34), 18],
|
|
["reactionBaseFrom(10,71)", RULES.reactionBaseFrom(10, 71), 24],
|
|
["reflexRating(18)", RULES.reflexRating(18), 6],
|
|
["awarenessRating(71)", RULES.awarenessRating(71), 8],
|
|
["damageModifierFor(37)", RULES.damageModifierFor(37), "+1d6"],
|
|
["damageModifierFor(5)", RULES.damageModifierFor(5), "-1d6"],
|
|
["damageModifierFor(13)", RULES.damageModifierFor(13), "-1d3"],
|
|
["damageModifierFor(80)", RULES.damageModifierFor(80), "+5d6"],
|
|
["locationMaxHp(12,0.42)", RULES.locationMaxHp(12, 0.42), 6],
|
|
["styleFor('aware')", RULES.styleFor("aware"), "aware"],
|
|
["ceil(1.1)", RULES.ceil(1.1), 2],
|
|
// every point over free carry costs, not just whole steps
|
|
["encumbranceFrom(30,20).over", RULES.encumbranceFrom(30, 20).over, 10],
|
|
["encumbranceFrom(30,20).srLoad", RULES.encumbranceFrom(30, 20).srLoad, 1],
|
|
["encumbranceFrom(10,20).over", RULES.encumbranceFrom(10, 20).over, 0],
|
|
["encumbranceFrom(10,20).skillPenalty", RULES.encumbranceFrom(10, 20).skillPenalty, 0],
|
|
// load is exact, not banded: 3.4 kg over costs 0.3, not a whole point
|
|
["reactionLoadFrom(0.34)", RULES.reactionLoadFrom(0.34), 0.4],
|
|
["reactionLoadFrom(2)", RULES.reactionLoadFrom(2), 2],
|
|
["reactionLoadFrom(0)", RULES.reactionLoadFrom(0), 0],
|
|
["reactionPrintedFrom(24,0.34)", RULES.reactionPrintedFrom(24, 0.34), 23.6],
|
|
// the smallest excess still costs: 100 g over is 0.1, never 0
|
|
["reactionLoadFrom(0.01)", RULES.reactionLoadFrom(0.01), 0.1],
|
|
["reactionPrintedFrom(15,2)", RULES.reactionPrintedFrom(15, 2), 13],
|
|
// R-56: no load may reduce an agent below 1 — last, never never
|
|
["reactionPrintedFrom(15,30)", RULES.reactionPrintedFrom(15, 30), 1],
|
|
// Major Wounds are penalties, not prose. Concussion hits every category via `all`.
|
|
["woundEffectsFor([concussed]).all", RULES.woundEffectsFor([{key:"concussed"}]).all, -20],
|
|
["woundEffectsFor(x2 concussed).all", RULES.woundEffectsFor([{key:"concussed"},{key:"concussed"}]).all, -40],
|
|
["woundEffectsFor([mobility]).movFactor", RULES.woundEffectsFor([{key:"mobility"}]).movFactor, 0.5],
|
|
["woundEffectsFor([manipulation])", RULES.woundEffectsFor([{key:"manipulation"}]).manipulation, -30],
|
|
["woundEffectsFor([deepBleeding])", RULES.woundEffectsFor([{key:"deepBleeding"}]).bleed, 2],
|
|
["woundEffectsFor([crippling])", RULES.woundEffectsFor([{key:"crippling"}]).defenceOnly, true],
|
|
// a missing variant must not quietly cost nothing
|
|
["woundEffectsFor([sensory no variant])", RULES.woundEffectsFor([{key:"sensory"}]).perception, -40],
|
|
["woundEffectsFor([]).all", RULES.woundEffectsFor([]).all, 0],
|
|
// bleeding: the rate and the way to stop it are one authority
|
|
["bleedRateFor([bleeding])", RULES.bleedRateFor([{key:"bleeding"}]), 1],
|
|
["bleedRateFor([deep,bleeding])", RULES.bleedRateFor([{key:"deepBleeding"},{key:"bleeding"}]), 3],
|
|
["bleedRateFor([]) ", RULES.bleedRateFor([]), 0],
|
|
["rate agrees with woundEffectsFor", RULES.bleedRateFor([{key:"deepBleeding"}]) === RULES.woundEffectsFor([{key:"deepBleeding"}]).bleed, true],
|
|
["bleedStoppedBy(bleeding, firstaid)", RULES.bleedStoppedBy("bleeding",{family:"first_aid"}), true],
|
|
["bleedStoppedBy(deep, average firstaid)", RULES.bleedStoppedBy("deepBleeding",{family:"first_aid",difficulty:"average"}), false],
|
|
["bleedStoppedBy(deep, difficult firstaid)",RULES.bleedStoppedBy("deepBleeding",{family:"first_aid",difficulty:"difficult"}), true],
|
|
["bleedStoppedBy(deep, medicine)", RULES.bleedStoppedBy("deepBleeding",{family:"medicine"}), true],
|
|
["bleedStoppedBy(deep, athletics)", RULES.bleedStoppedBy("deepBleeding",{family:"athletics"}), false],
|
|
// range ladder and coherence bands, now shared with the rules journal
|
|
["rangeBandFrom(5).mod", RULES.rangeBandFrom(5, {short:10,medium:30,long:90}).mod, 0],
|
|
["rangeBandFrom(25).mod", RULES.rangeBandFrom(25, {short:10,medium:30,long:90}).mod, -10],
|
|
["rangeBandFrom(60).mod", RULES.rangeBandFrom(60, {short:10,medium:30,long:90}).mod, -30],
|
|
["rangeBandFrom(150).mod", RULES.rangeBandFrom(150, {short:10,medium:30,long:90}).mod, -50],
|
|
["rangeBandFrom(400).mod", RULES.rangeBandFrom(400, {short:10,medium:30,long:90}).mod, null],
|
|
["coherenceBandFrom(10).id", RULES.coherenceBandFrom(10).id, "anchored"],
|
|
["coherenceBandFrom(6).id", RULES.coherenceBandFrom(6).id, "loose"],
|
|
["coherenceBandFrom(4).id", RULES.coherenceBandFrom(4).id, "adrift"],
|
|
["coherenceBandFrom(1).id", RULES.coherenceBandFrom(1).id, "unmoored"],
|
|
["coherenceBandFrom(0).id", RULES.coherenceBandFrom(0).id, "displaced"],
|
|
// a lost location must cost something, for every kind the species tables use
|
|
["locationEffectsFor([]) is inert", RULES.locationEffectsFor([]).all, 0],
|
|
["leg disabled → physical", RULES.locationEffectsFor([{kind:"leg",disabled:true}]).physical, -30],
|
|
["leg destroyed → MOV floor", RULES.locationEffectsFor([{kind:"leg",disabled:true,destroyed:true}]).movFactor, 0.1],
|
|
["arm disabled → hands", RULES.locationEffectsFor([{kind:"arm",disabled:true}]).manipulation, -30],
|
|
["head disabled → unconscious", RULES.locationEffectsFor([{kind:"head",disabled:true}]).unconscious, true],
|
|
["vesh ridge → perception", RULES.locationEffectsFor([{kind:"ridge",disabled:true}]).perception, -30],
|
|
["vesh grasp → hands", RULES.locationEffectsFor([{kind:"grasp",disabled:true}]).manipulation, -30],
|
|
// the Cadence rule: each body lost costs the WHOLE person
|
|
["cadence 1 body lost", RULES.locationEffectsFor([{kind:"unit",disabled:true}]).all, -10],
|
|
["cadence 3 bodies lost", RULES.locationEffectsFor(Array.from({length:3},()=>({kind:"unit",disabled:true}))).all, -30],
|
|
// encumbrance reaches physical skills and leaves the mind alone
|
|
["encumbrancePenaltyFor(10,'phys')", RULES.encumbrancePenaltyFor(10,"phys"), -10],
|
|
["encumbrancePenaltyFor(10,'ranged')", RULES.encumbrancePenaltyFor(10,"ranged"), -10],
|
|
["encumbrancePenaltyFor(10,'manip')", RULES.encumbrancePenaltyFor(10,"manip"), -10],
|
|
["encumbrancePenaltyFor(10,'mental')", RULES.encumbrancePenaltyFor(10,"mental"), 0],
|
|
["encumbrancePenaltyFor(10,'comm')", RULES.encumbrancePenaltyFor(10,"comm"), 0],
|
|
["encumbrancePenaltyFor(0,'phys')", RULES.encumbrancePenaltyFor(0,"phys"), 0],
|
|
// free carry counts strength AND mass, not strength alone
|
|
["freeCarryFor(18,19)", RULES.freeCarryFor(18,19), 37],
|
|
["freeCarryFor(12,13)", RULES.freeCarryFor(12,13), 25],
|
|
["freeCarryFor(0,0)", RULES.freeCarryFor(0,0), 1],
|
|
// defending: the first is free, each one after is harder
|
|
["defencePenaltyFor(0)", RULES.defencePenaltyFor(0), 0],
|
|
["defencePenaltyFor(1)", RULES.defencePenaltyFor(1), -30],
|
|
["defencePenaltyFor(3)", RULES.defencePenaltyFor(3), -90],
|
|
["defenceTypeAllowed('dodge','parry')", RULES.defenceTypeAllowed("dodge","parry"), "dodge"],
|
|
["defenceTypeAllowed('','parry')", RULES.defenceTypeAllowed("","parry"), "parry"],
|
|
// defences are graded: a defence stops its own quality or worse, and only
|
|
// shaves a better blow by as many steps as it was outclassed by
|
|
// access is a record: scope is a hard ceiling, not a difficulty
|
|
["scopeRank('routine')", RULES.scopeRank("routine"), 2],
|
|
["scopeRank(unknown)", RULES.scopeRank("nonesuch"), 0],
|
|
["scopeStanceFor(routine, routine)", RULES.scopeStanceFor("routine","routine"), "in-scope"],
|
|
["scopeStanceFor(routine, privileged)", RULES.scopeStanceFor("routine","privileged"), "stretch"],
|
|
["scopeStanceFor(routine, administrative)", RULES.scopeStanceFor("routine","administrative"), "impossible"],
|
|
["scrutinyFor(2 flags)", RULES.scrutinyFor(2), -40],
|
|
["authorityOutcomeFor(special)", RULES.authorityOutcomeFor("special").trustDelta, 0],
|
|
["authorityOutcomeFor(success)", RULES.authorityOutcomeFor("success").trustDelta, -1],
|
|
["authorityOutcomeFor(fumble) burns", RULES.authorityOutcomeFor("fumble").burned, true],
|
|
["trustAfter(1, -1) floors at nothing", RULES.trustAfter(1,-1), 0],
|
|
["trustAfter(4, +1) is capped", RULES.trustAfter(4,1), 4],
|
|
["dossierTrustFor(3)", RULES.dossierTrustFor(3), 4],
|
|
["dossierTrustFor(0)", RULES.dossierTrustFor(0), 1],
|
|
["dossierScopeFor(2, remit)", RULES.dossierScopeFor(2,true), "routine"],
|
|
["dossierScopeFor(3, no remit)", RULES.dossierScopeFor(3,false), "read"],
|
|
["widenScopeCostFor(0)", RULES.widenScopeCostFor(0), 1],
|
|
["widenScopeCostFor(3)", RULES.widenScopeCostFor(3), 4],
|
|
["mergeStageFor(4) is terminal", RULES.mergeStageFor(4).terminal, true],
|
|
["mergeStageFor(0)", RULES.mergeStageFor(0).step, 0],
|
|
// fists are a fallback, never a peer of the weapon in hand
|
|
["brawlFallbackFor(50)", RULES.brawlFallbackFor(50), 30],
|
|
["brawlFallbackFor(75)", RULES.brawlFallbackFor(75), 45],
|
|
["brawlFallbackFor(30) hits the floor", RULES.brawlFallbackFor(30), 25],
|
|
["brawlFallbackFor(0) hits the floor", RULES.brawlFallbackFor(0), 25],
|
|
// Dodge's base is DEX x 2, and it is a floor rather than a replacement
|
|
["skillBaseFrom('dex*2', DEX 13)", RULES.skillBaseFrom("dex*2",{dex:{value:13}}), 26],
|
|
["skillBaseFrom('dex', DEX 13)", RULES.skillBaseFrom("dex",{dex:{value:13}}), 13],
|
|
["skillBaseFrom('dex*2+5', DEX 13)", RULES.skillBaseFrom("dex*2+5",{dex:{value:13}}),31],
|
|
["skillBaseFrom('' , anything)", RULES.skillBaseFrom("",{dex:{value:13}}), 0],
|
|
["skillBaseFrom(junk) is refused", RULES.skillBaseFrom("dex*2; drop",{dex:{value:13}}), 0],
|
|
["levelRank('critical')", RULES.levelRank("critical"), 4],
|
|
["levelRank(unknown reads as failure)", RULES.levelRank("nonesuch"), 1],
|
|
["defenceOutcomeFor(crit, crit)", RULES.defenceOutcomeFor("critical","critical").turnedAside, true],
|
|
["defenceOutcomeFor(crit, special)", RULES.defenceOutcomeFor("critical","special").landsAt, "success"],
|
|
["defenceOutcomeFor(crit, success)", RULES.defenceOutcomeFor("critical","success").landsAt, "special"],
|
|
["defenceOutcomeFor(crit, failure)", RULES.defenceOutcomeFor("critical","failure").landsAt, "critical"],
|
|
["defenceOutcomeFor(special, success)", RULES.defenceOutcomeFor("special","success").landsAt, "success"],
|
|
["defenceOutcomeFor(special, special)", RULES.defenceOutcomeFor("special","special").turnedAside, true],
|
|
["defenceOutcomeFor(success, success)", RULES.defenceOutcomeFor("success","success").turnedAside, true],
|
|
["defenceOutcomeFor(success, fumble)", RULES.defenceOutcomeFor("success","fumble").landsAt, "success"],
|
|
// the Quiet Arts cost Coherence, and Unmake costs more than the rest
|
|
["powerCostFor('thread')", RULES.powerCostFor("thread"), 1],
|
|
["powerCostFor('thread', pushed)", RULES.powerCostFor("thread",{pushed:true}), 2],
|
|
["powerCostFor('unmake')", RULES.powerCostFor("unmake"), 2],
|
|
["powerCostFor('unmake', pushed)", RULES.powerCostFor("unmake",{pushed:true}), 4],
|
|
["powerCostFor(unknown art)", RULES.powerCostFor("nonesuch"), 1],
|
|
["powerStartingRating(13)", RULES.powerStartingRating(13), 26],
|
|
["powerStartingRating(30) is capped", RULES.powerStartingRating(30), 45],
|
|
["powerStartingRating(0) has a floor", RULES.powerStartingRating(0), 5],
|
|
// an Art arrives only from a crossing that went wrong
|
|
["crossingGrantsArt(success, 10)", RULES.crossingGrantsArt("success", 10), false],
|
|
["crossingGrantsArt(failure, 10)", RULES.crossingGrantsArt("failure", 10), false],
|
|
["crossingGrantsArt(failure, 2)", RULES.crossingGrantsArt("failure", 2), true],
|
|
["crossingGrantsArt(fumble, 10)", RULES.crossingGrantsArt("fumble", 10), true],
|
|
["crossingCostFor(fumble)", RULES.crossingCostFor("fumble"), 2],
|
|
["crossingCostFor(success)", RULES.crossingCostFor("success"), 0],
|
|
// the pool darkens as the agent comes apart
|
|
["crossingArtPoolFor(10)", RULES.crossingArtPoolFor(10).join(","), "thread,echo,witness"],
|
|
["crossingArtPoolFor(4)", RULES.crossingArtPoolFor(4).join(","), "hollow,thread,echo"],
|
|
["crossingArtPoolFor(1)", RULES.crossingArtPoolFor(1).join(","), "unmake,ledger,hollow"]
|
|
];
|
|
for (const [label, got, want] of checks) {
|
|
if (got !== want) fail(`${label} = ${JSON.stringify(got)}, expected ${JSON.stringify(want)}`);
|
|
}
|
|
|
|
const covered = new Set(checks.map(([label]) => label.slice(0, label.indexOf("("))));
|
|
for (const name of canonical) {
|
|
if (!covered.has(name)) fail(`rules.${name}() has no spot-check — every exported rule must be exercised`);
|
|
}
|
|
|
|
if (failures) { console.error(`\ncheck-rules: ${failures} failure(s)`); process.exit(1); }
|
|
console.log(`check-rules: OK — ${canonical.length} rules, ${files.length} files scanned, `
|
|
+ `${Object.keys(pairs).length} aliases + ${constants.length} constant sets checked, ${checks.length} formulas verified`);
|