Left open by R-256. Two rules decided how a hit meets armour — armourAgainst (a critical
ignores it, a special halves it, rounded down) and damageAfterArmour (what is left never
goes below zero). Both lived in ringbrp.mjs, which is the game. Neither lived in
rules.mjs, which is the authority, so the guard that forbids redefining a rule had
nothing to forbid. tools/simulate.mjs applied armour from its own inline copies of both,
as it had since it was written.
They agreed, which is the whole point. Nothing published was wrong and no guard could
have said they were two things rather than one. But every number in BESTIARY.md, every
row of the lethality baseline and every measurement quoted from R-251 onward comes out of
that harness: change how a special hit meets armour and the game changes, the numbers
describing the game do not, and all nine guards still pass. Same shape as ddc4f99, with
no tolerance to blame and no reason it would ever have surfaced.
Both rules now live in rules.mjs. ringbrp.mjs imports and re-exports them because they
are public API at game.ringbrp. The simulator imports them. Verified live in the world
that game.ringbrp.armourAgainst and the rules.mjs export are the same function object,
not two that agree.
Proof it changed nothing: check-lethality replays 47 creatures over 2000 fights each and
every one fights exactly as recorded — no tolerance, no drift — across roughly four
million resolved attacks, all of which now go through the moved rule.
check-rules fails any file outside rules.mjs that halves armour or subtracts it inline,
naming file and line. Negative-tested by restoring the harness's original three lines
verbatim (both patterns fire) and by redefining armourAgainst in ringbrp.mjs, which the
name-based guard now catches because the rule finally lives somewhere it belongs to.
Arriving in rules.mjs also tripped the spot-check requirement immediately: eight new
spot-checks, and what a special does to hide 9 is now written down once.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
664 lines
54 KiB
JavaScript
664 lines
54 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`);
|
||
}
|
||
}
|
||
|
||
/* 1b. and none of them may re-derive how armour works ---------------------- *
|
||
* Redefinition is caught by NAME above, which misses both ways this actually goes
|
||
* wrong. The sheet header wanted the grounded hide and `Math.floor(hide / 2)` is one
|
||
* keystroke cheaper than importing exposedHideFor. And the lethality harness applied
|
||
* armour with its own inline copy of armourAgainst and damageAfterArmour for as long
|
||
* as it existed (R-257) — agreeing with the game the whole time, which is precisely
|
||
* what makes it dangerous: every number in BESTIARY.md and the baseline comes out of
|
||
* that harness, so a rule changed in one place and not the other changes the game
|
||
* while the measurements describing it stay still, and every guard here still passes.
|
||
* The exemption is the check files, where stating the expected value independently is
|
||
* the entire point of a test. */
|
||
{
|
||
const REIMPLEMENTED = [
|
||
[/Math\.(?:floor|round|ceil)\(\s*[^()]*\b(?:hide|naturalArmour|armour|armor)\b[^()]*\/\s*2/i,
|
||
"halves armour inline — armourAgainst() and exposedHideFor() own that"],
|
||
[/Math\.max\(\s*0\s*,[^;]*\b(?:armour|armor)\b[^;]*\)/i,
|
||
"subtracts armour inline — damageAfterArmour() owns that"]
|
||
];
|
||
for (const rel of files) {
|
||
if (rel.startsWith("tools/check-")) continue;
|
||
const src = await readFile(new URL(rel, root), "utf8");
|
||
src.split("\n").forEach((line, i) => {
|
||
for (const [re, why] of REIMPLEMENTED) if (re.test(line)) fail(`${rel}:${i + 1} ${why}`);
|
||
});
|
||
}
|
||
}
|
||
|
||
/* 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 the canonical keys is a copy. "Two or more"
|
||
// alone was too blunt: FIRE_MODE has `slow` and `fast`, and so does the entirely
|
||
// unrelated speedBands vocabulary, so an honest table was reported as a copy of
|
||
// a rule it has nothing to do with. A real drift bug duplicates the WHOLE table
|
||
// — every instance this guard has ever caught did — so require a majority of the
|
||
// rule's keys as well. Partial overlaps are still printed, because a half-copy
|
||
// is worth a human look even when it is not worth failing the build.
|
||
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) continue;
|
||
if (hits.length * 2 >= keys.length) {
|
||
fail(`${rel} re-declares ${name} keys (${hits.join(", ")}) — assign rules.${name} itself`);
|
||
} else {
|
||
console.log(` note ${rel} shares ${hits.length}/${keys.length} key names with `
|
||
+ `${name} (${hits.join(", ")}) — read as coincidence, not a copy`);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
/* 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. */
|
||
{
|
||
// Imported rather than scraped out of the engine source. The tables moved to
|
||
// anatomy.mjs so node-side tools could read them, and this guard — which matched them
|
||
// with a regex against ringbrp.mjs — went blind the moment they did. A guard that
|
||
// parses source text fails silently-shaped when the source is refactored; one that
|
||
// imports the value cannot.
|
||
const { LOCATION_TABLES } = await import("../anatomy.mjs");
|
||
const profiles = Object.keys(LOCATION_TABLES);
|
||
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"],
|
||
|
||
// Panic. The free first point is deliberate, so 1 must cost nothing.
|
||
["panicBandFrom(0).id", RULES.panicBandFrom(0).id, "steady"],
|
||
["panicBandFrom(1).id", RULES.panicBandFrom(1).id, "rattled"],
|
||
["panicBandFrom(3).id", RULES.panicBandFrom(3).id, "losing"],
|
||
["panicBandFrom(5).id", RULES.panicBandFrom(5).id, "broken"],
|
||
["panicPenaltyFor(1)", RULES.panicPenaltyFor(1), 0],
|
||
["panicPenaltyFor(2)", RULES.panicPenaltyFor(2), -5],
|
||
["panicPenaltyFor(4)", RULES.panicPenaltyFor(4), -20],
|
||
["panicBreaksAt(4)", RULES.panicBreaksAt(4), false],
|
||
["panicBreaksAt(5)", RULES.panicBreaksAt(5), true],
|
||
["panicAfter(4,1)", RULES.panicAfter(4, 1), 5],
|
||
["panicAfter(5,1)", RULES.panicAfter(5, 1), 5],
|
||
["panicAfter(0,-1)", RULES.panicAfter(0, -1), 0],
|
||
["panicReliefFor('success')", RULES.panicReliefFor("success"), 1],
|
||
["panicReliefFor('special')", RULES.panicReliefFor("special"), 2],
|
||
["panicReliefFor('failure')", RULES.panicReliefFor("failure"), 0],
|
||
["panicReliefFor('fumble')", RULES.panicReliefFor("fumble"), -1],
|
||
|
||
// Development is REVERSED from the usual: failure marks the skill, not success.
|
||
["developmentMarksOn('failure')", RULES.developmentMarksOn("failure"), true],
|
||
["developmentMarksOn('fumble')", RULES.developmentMarksOn("fumble"), true],
|
||
["developmentMarksOn('success')", RULES.developmentMarksOn("success"), false],
|
||
["developmentMarksOn('special')", RULES.developmentMarksOn("special"), false],
|
||
["developmentMarksOn('critical')",RULES.developmentMarksOn("critical"), false],
|
||
["developmentImproves(72,45)", RULES.developmentImproves(72, 45), true],
|
||
["developmentImproves(30,45)", RULES.developmentImproves(30, 45), false],
|
||
["developmentImproves(45,45)", RULES.developmentImproves(45, 45), false],
|
||
["developmentCeiling(null)", RULES.developmentCeiling(null), 100],
|
||
["developmentCeiling(75)", RULES.developmentCeiling(75), 75],
|
||
["developmentCeiling(140)", RULES.developmentCeiling(140), 100],
|
||
["developmentGainFor(45,6,null)", RULES.developmentGainFor(45, 6, null), 6],
|
||
["developmentGainFor(97,6,null)", RULES.developmentGainFor(97, 6, null), 3],
|
||
["developmentGainFor(73,6,75)", RULES.developmentGainFor(73, 6, 75), 2],
|
||
["developmentGainFor(75,6,75)", RULES.developmentGainFor(75, 6, 75), 0],
|
||
// 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
|
||
// the extraction spends the way home, not anybody's hit points
|
||
["returnPenaltyFor(0)", RULES.returnPenaltyFor(0), 0],
|
||
["returnPenaltyFor(2)", RULES.returnPenaltyFor(2), -20],
|
||
["returnPenaltyFor(9) is capped", RULES.returnPenaltyFor(9), -30],
|
||
["returnIsDifficult(1)", RULES.returnIsDifficult(1), false],
|
||
["returnIsDifficult(2)", RULES.returnIsDifficult(2), true],
|
||
["extractionCostFor(success)", RULES.extractionCostFor("success").clock, 0],
|
||
["extractionCostFor(failure) narrows", RULES.extractionCostFor("failure").clock, 1],
|
||
["extractionCostFor(failure) still out",RULES.extractionCostFor("failure").completes, true],
|
||
["extractionCostFor(fumble) strands", RULES.extractionCostFor("fumble").completes, false],
|
||
["extractionCostFor(critical) buys back",RULES.extractionCostFor("critical").clock, -1],
|
||
// space combat: every station is a skill somebody already has
|
||
["spaceBandRank(close)", RULES.spaceBandRank("close"), 2],
|
||
["spaceBandRank(unknown) is close", RULES.spaceBandRank("nonesuch"), 2],
|
||
["spaceBandAfter(close, +1)", RULES.spaceBandAfter("close",1), "standoff"],
|
||
["spaceBandAfter(docked, -1) floors", RULES.spaceBandAfter("docked",-1), "docked"],
|
||
["spaceRangeModFor(knife)", RULES.spaceRangeModFor("knife"), 20],
|
||
["spaceRangeModFor(distant)", RULES.spaceRangeModFor("distant"), -30],
|
||
["spaceHitFor(special)", RULES.spaceHitFor("special"), 2],
|
||
["spaceHitFor(special, marked)", RULES.spaceHitFor("special",{marked:true}), 3],
|
||
["spaceHitFor(failure)", RULES.spaceHitFor("failure"), 0],
|
||
["heatEffectsFor(7) blinds", RULES.heatEffectsFor(7).blind, true],
|
||
["heatEffectsFor(3) is clear", RULES.heatEffectsFor(3).penalty, 0],
|
||
["heatEffectsFor(9) is slow", RULES.heatEffectsFor(9).slow, true],
|
||
["craftConditionFor(quarter hull)", RULES.craftConditionFor({hull:5,hullMax:20}).state, "failing"],
|
||
["craftConditionFor(gone)", RULES.craftConditionFor({hull:0,hullMax:20}).state, "lost"],
|
||
// the ways the world kills you
|
||
["fallDamageFor(6)", RULES.fallDamageFor(6).formula, "2d6"],
|
||
["fallDamageFor(30)", RULES.fallDamageFor(30).formula, "10d6"],
|
||
["fall halving does not stack", RULES.fallDamageFor(30,{soft:true,controlled:true}).formula, "5d6"],
|
||
["fallDamageFor ignores armour", RULES.fallDamageFor(6).ignoresArmour, true],
|
||
["breathRoundsFor(12)", RULES.breathRoundsFor(12), 12],
|
||
["breathRoundsFor(12) caught out", RULES.breathRoundsFor(12,{prepared:false}), 6],
|
||
["fireBandFor(engulf)", RULES.fireBandFor("engulf").damage, "3d6"],
|
||
["fireBandFor(unknown) is clothes", RULES.fireBandFor("nonesuch").id, "clothes"],
|
||
["blastBandFor(2, r6)", RULES.blastBandFor(2,6).id, "contact"],
|
||
["blastBandFor(11, r6)", RULES.blastBandFor(11,6).id, "far"],
|
||
["blastBandFor(40, r6) is outside", RULES.blastBandFor(40,6), null],
|
||
["blastShareFor(20, far)", RULES.blastShareFor(20,RULES.blastBandFor(11,6)), 5],
|
||
["blastShareFor(20, far, solid)", RULES.blastShareFor(20,RULES.blastBandFor(11,6),"solid"), 1],
|
||
["blastAfterCover(20, solid)", RULES.blastAfterCover(20,"solid"), 5],
|
||
["blastAfterCover(20, sealed)", RULES.blastAfterCover(20,"sealed"), 0],
|
||
// automatic fire: one roll, the band says how much arrived
|
||
["burstHitsFor(critical, full) capped", RULES.burstHitsFor("critical","full"), 7],
|
||
["burstHitsFor(success, short)", RULES.burstHitsFor("success","short"), 1],
|
||
["burstHitsFor(failure, long)", RULES.burstHitsFor("failure","long"), 0],
|
||
["burstRoundsSpent(long, 4 loaded)", RULES.burstRoundsSpent("long",4), 4],
|
||
// The mode is the gate. A musket may not rip a burst however the API is called.
|
||
["burstSizesFor(slow) is empty", RULES.burstSizesFor("slow").length, 0],
|
||
["burstSizesFor(rapid)", RULES.burstSizesFor("rapid").join(","), "short,long,full"],
|
||
["fireModeFor(fast) is a real mode", RULES.fireModeFor("fast").id, "fast"],
|
||
["fireModeFor(blank) falls to single", RULES.fireModeFor("").id, "single"],
|
||
["fireModeAttackFor(slow)", RULES.fireModeAttackFor("slow"), 10],
|
||
["fireModeAttackFor(beam)", RULES.fireModeAttackFor("beam"), 5],
|
||
["fireModeAttackFor(rapid)", RULES.fireModeAttackFor("rapid"), 0],
|
||
// A second shot costs BOTH shots, and only a fast action offers one at all.
|
||
["secondShotPenaltyFor(fast)", RULES.secondShotPenaltyFor("fast"), -20],
|
||
["secondShotPenaltyFor(slow) is none", RULES.secondShotPenaltyFor("slow"), null],
|
||
// A beam buys damage, not hits — the one firing choice that adds dice.
|
||
["sustainFor(lock).mod", RULES.sustainFor("lock").mod, -25],
|
||
["sustainDamageFor(hold)", RULES.sustainDamageFor("hold"), "1d6"],
|
||
["sustainDamageFor(tap) is none", RULES.sustainDamageFor("tap"), ""],
|
||
// Counted in SHOTS, so a 20-shot cell affords three locked beams — not zero.
|
||
["sustainChargeFor(lock) in shots", RULES.sustainChargeFor("lock"), 6],
|
||
["sustainChargeFor(hold) in shots", RULES.sustainChargeFor("hold"), 3],
|
||
["sustainChargeFor(tap) is one shot", RULES.sustainChargeFor("tap"), 1],
|
||
// A scope is a LIABILITY up close. That is the trade that makes sights a choice.
|
||
["sightBonusFor(telescopic, short)", RULES.sightBonusFor("telescopic","short"), -10],
|
||
["sightBonusFor(telescopic, extreme)", RULES.sightBonusFor("telescopic","extreme"), 20],
|
||
["sightBonusFor(reflex, short)", RULES.sightBonusFor("reflex","short"), 10],
|
||
["sightBonusFor(reflex, extreme)", RULES.sightBonusFor("reflex","extreme"), -10],
|
||
["sightBonusFor(none, long)", RULES.sightBonusFor("none","long"), 0],
|
||
["sightFor(nonsense) is none", RULES.sightFor("wat").id, "none"],
|
||
["sightSeesInDark(thermal)", RULES.sightSeesInDark("thermal"), true],
|
||
["sightSeesInDark(telescopic)", RULES.sightSeesInDark("telescopic"), false],
|
||
// R-246. A fumbled greeting cost nothing for the life of the social pillar.
|
||
["impressionDeltaFor(signal, fumble)", RULES.impressionDeltaFor("signal","fumble"), -1],
|
||
["impressionDeltaFor(offer, fumble)", RULES.impressionDeltaFor("offer","fumble"), -2],
|
||
["impressionDeltaFor(signal, success)", RULES.impressionDeltaFor("signal","success"), 0],
|
||
// A READ earns FACTS, not standing — paying impression for it rewards research.
|
||
["impressionDeltaFor(read, success)", RULES.impressionDeltaFor("read","success"), 0],
|
||
["impressionDeltaFor(read, critical)", RULES.impressionDeltaFor("read","critical"), 0],
|
||
["impressionDeltaFor(offer, success)", RULES.impressionDeltaFor("offer","success"), 2],
|
||
["contactDeltaFor(read, critical)", RULES.contactDeltaFor("read","critical"), 2],
|
||
["contactDeltaFor(nonsense)", RULES.contactDeltaFor("read","wat"), 0],
|
||
// A shield is armour with a switch, and the switch is the whole rule.
|
||
["shieldArmourFor(raised)", RULES.shieldArmourFor(3,{raised:true}), 3],
|
||
["shieldArmourFor(lowered)", RULES.shieldArmourFor(3,{raised:false}), 0],
|
||
["shieldArmourFor(broken)", RULES.shieldArmourFor(3,{raised:true,broken:true}),0],
|
||
["shieldArmourFor(default down)", RULES.shieldArmourFor(3), 0],
|
||
["shieldRangedPenaltyFor(raised)", RULES.shieldRangedPenaltyFor(true), -20],
|
||
["shieldRangedPenaltyFor(lowered)", RULES.shieldRangedPenaltyFor(false), 0],
|
||
// An ordinary blow turned aside costs the shield nothing; a critical costs it two.
|
||
["shieldWearFor(critical)", RULES.shieldWearFor("critical"), 2],
|
||
["shieldWearFor(special)", RULES.shieldWearFor("special"), 1],
|
||
["shieldWearFor(success)", RULES.shieldWearFor("success"), 0],
|
||
["shieldWearFor(nonsense)", RULES.shieldWearFor("wat"), 0],
|
||
["shieldWearFor(fumble)", RULES.shieldWearFor("fumble"), 0],
|
||
["shieldBroken(4,4)", RULES.shieldBroken(4,4), true],
|
||
["shieldBroken(3,4)", RULES.shieldBroken(3,4), false],
|
||
["shieldBroken(0,0) never breaks", RULES.shieldBroken(0,0), false],
|
||
// A helmet does not protect your legs. Coverage, and what it adds up to.
|
||
["armourCoversKind(head, head)", RULES.armourCoversKind("head","head"), true],
|
||
["armourCoversKind(head, leg)", RULES.armourCoversKind("head","leg"), false],
|
||
["armourCoversKind(torso, vital)", RULES.armourCoversKind("torso","vital"), true],
|
||
["armourCoversKind(torso, arm)", RULES.armourCoversKind("torso","arm"), false],
|
||
["armourCoversKind(torsoArms, arm)", RULES.armourCoversKind("torsoArms","arm"), true],
|
||
["armourCoversKind(torsoArms, leg)", RULES.armourCoversKind("torsoArms","leg"), false],
|
||
["armourCoversKind(torsoLimbs, leg)", RULES.armourCoversKind("torsoLimbs","leg"), true],
|
||
["armourCoversKind(torsoLimbs, head)", RULES.armourCoversKind("torsoLimbs","head"), false],
|
||
// Unstated coverage is ALL — a piece that says nothing behaves as it always did.
|
||
["armourCoversKind(all, leg)", RULES.armourCoversKind("all","leg"), true],
|
||
["armourCoversKind(undefined, head)", RULES.armourCoversKind(undefined,"head"), true],
|
||
// A torso piece protects a vesh trunk and its ridge without either knowing of the other.
|
||
["armourCoversKind(torso, ridge)", RULES.armourCoversKind("torso","ridge"), true],
|
||
["coverageFor(nonsense) is all", RULES.coverageFor("wat").id, "all"],
|
||
// Helmet 5 + cuirass 6 + natural 1: the head gets 6, the chest 7, a leg only 1.
|
||
["locationArmourFor(head)", RULES.locationArmourFor(1,
|
||
[{points:5,coverage:"head"},{points:6,coverage:"torso"}], "head"), 6],
|
||
["locationArmourFor(chest)", RULES.locationArmourFor(1,
|
||
[{points:5,coverage:"head"},{points:6,coverage:"torso"}], "vital"), 7],
|
||
["locationArmourFor(leg)", RULES.locationArmourFor(1,
|
||
[{points:5,coverage:"head"},{points:6,coverage:"torso"}], "leg"), 1],
|
||
["locationArmourFor(none)", RULES.locationArmourFor(2, [], "arm"), 2],
|
||
// A lamp lights the people NEXT to you, not only the one holding it.
|
||
["lampReachFor(default)", RULES.lampReachFor(), 10],
|
||
["lampReachFor(30) searchlight", RULES.lampReachFor(30), 30],
|
||
["lampReachFor(0) falls to default", RULES.lampReachFor(0), 10],
|
||
["lampCovers(4, reach 6)", RULES.lampCovers(4, 6), true],
|
||
["lampCovers(9, reach 6)", RULES.lampCovers(9, 6), false],
|
||
["lampCovers(10, reach 10) inclusive", RULES.lampCovers(10, 10), true],
|
||
["lampCovers(unknown distance)", RULES.lampCovers(null, 10), false],
|
||
// A lamp buys the dark back to dim, never to daylight, and it shows you.
|
||
["lightingFor(pitch).mod", RULES.lightingFor("pitch").mod, -50],
|
||
["lightingModFor(pitch, no lamp)", RULES.lightingModFor("pitch").mod, -50],
|
||
["lightingModFor(pitch, lamp)", RULES.lightingModFor("pitch",{lightSource:true}).mod, -10],
|
||
["lightingModFor(pitch, lamp) reveals", RULES.lightingModFor("pitch",{lightSource:true}).revealed, true],
|
||
// A lamp in a lit room is not a penalty and does not reveal anything new.
|
||
["lightingModFor(lit, lamp)", RULES.lightingModFor("lit",{lightSource:true}).mod, 0],
|
||
["lightingModFor(lit, lamp) quiet", RULES.lightingModFor("lit",{lightSource:true}).revealed, false],
|
||
// Thermal beats a lamp: it removes the dark AND does not announce you.
|
||
["lightingModFor(dark, thermal)", RULES.lightingModFor("dark",{seesInDark:true}).mod, 0],
|
||
["lightingModFor(dark, thermal) quiet", RULES.lightingModFor("dark",{seesInDark:true}).revealed, false],
|
||
// two weapons
|
||
["twoWeaponPenaltyFor() off hand", RULES.twoWeaponPenaltyFor().off, -40],
|
||
["twoWeaponPenaltyFor(trained) main", RULES.twoWeaponPenaltyFor({trained:true}).main, -20],
|
||
["canPairWeapons(1.2, 0.4)", RULES.canPairWeapons(1.2,0.4), true],
|
||
["canPairWeapons(3, 3) refuses", RULES.canPairWeapons(3,3), false],
|
||
// destroying something they cannot live without
|
||
["destructionOutcomeFor(head, destroyed)", RULES.destructionOutcomeFor("head",true), "dead"],
|
||
["destructionOutcomeFor(head, disabled)", RULES.destructionOutcomeFor("head",false), "none"],
|
||
["destructionOutcomeFor(vital, destroyed)", RULES.destructionOutcomeFor("vital",true), "dying"],
|
||
["destructionOutcomeFor(leg, destroyed)", RULES.destructionOutcomeFor("leg",true), "none"],
|
||
// zero is a clock, not a state: dying, then dead, and the record decides the rest
|
||
["dyingLimitFor(6)", RULES.dyingLimitFor(6), 6],
|
||
["dyingLimitFor(1) has a floor", RULES.dyingLimitFor(1), 2],
|
||
["conditionFor(hp 8)", RULES.conditionFor({hp:8}), "up"],
|
||
["conditionFor(hp 2) is down", RULES.conditionFor({hp:2}), "down"],
|
||
["conditionFor(hp 0) is dying", RULES.conditionFor({hp:0,majorWound:6}), "dying"],
|
||
["conditionFor(count met) is dead", RULES.conditionFor({hp:0,dyingRounds:6,majorWound:6}), "dead"],
|
||
["stabilised never reaches dead", RULES.conditionFor({hp:0,dyingRounds:9,majorWound:6,stabilised:true}), "dying"],
|
||
["lastEntryFor(10)", RULES.lastEntryFor(10).id, "recorded"],
|
||
["lastEntryFor(3)", RULES.lastEntryFor(3).id, "unclosed"],
|
||
["lastEntryFor(0)", RULES.lastEntryFor(0).id, "transferred"],
|
||
// the case ramp: attrition accumulates and stops; only Containment is Difficult
|
||
["phaseRampFor(0) is the way in", RULES.phaseRampFor(0,"approach").difficulty, "average"],
|
||
["phaseRampFor(0) modifier", RULES.phaseRampFor(0,"approach").modifier, 0],
|
||
["phaseRampFor(2) modifier", RULES.phaseRampFor(2,"search").modifier, -10],
|
||
["phaseRampFor(9) is capped", RULES.phaseRampFor(9,"search").modifier, -15],
|
||
["containment is Difficult", RULES.phaseRampFor(3,"containment").difficulty, "difficult"],
|
||
["containment does NOT also ramp", RULES.phaseRampFor(3,"containment").modifier, 0],
|
||
// 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],
|
||
// d100 resolution. Moved out of ringbrp.mjs so node can reach it; these are the
|
||
// spot-checks it never had while it lived in the engine.
|
||
["applyDifficulty(65, average)", RULES.applyDifficulty(65, "average"), 65],
|
||
["applyDifficulty(65, easy) doubles", RULES.applyDifficulty(65, "easy"), 130],
|
||
["applyDifficulty(65, difficult) halves", RULES.applyDifficulty(65, "difficult"), 32],
|
||
["resolveBands(65).critical", RULES.resolveBands(65).critical, 4],
|
||
["resolveBands(65).special", RULES.resolveBands(65).special, 13],
|
||
["resolveBands(65).fumbleStart", RULES.resolveBands(65).fumbleStart, 99],
|
||
// the 1% floor: a skill beaten below zero still has a chance, and it is not zero
|
||
["resolveBands(0) floors the band at 1", RULES.resolveBands(0).band, 1],
|
||
["resolveBands(-40) floors too", RULES.resolveBands(-40).band, 1],
|
||
["resolveBands caps the band at 100", RULES.resolveBands(140).band, 100],
|
||
["gradeRoll(1 of 65) is critical", RULES.gradeRoll(1, RULES.resolveBands(65)), "critical"],
|
||
["gradeRoll(13 of 65) is special", RULES.gradeRoll(13, RULES.resolveBands(65)), "special"],
|
||
["gradeRoll(65 of 65) is success", RULES.gradeRoll(65, RULES.resolveBands(65)), "success"],
|
||
["gradeRoll(66 of 65) is failure", RULES.gradeRoll(66, RULES.resolveBands(65)), "failure"],
|
||
// 96–99 never succeed, however good the rating
|
||
["gradeRoll(97 of 98) still fails", RULES.gradeRoll(97, RULES.resolveBands(98)), "failure"],
|
||
// and 00 always fumbles
|
||
["gradeRoll(100 of 98) fumbles", RULES.gradeRoll(100, RULES.resolveBands(98)), "fumble"],
|
||
["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"],
|
||
// Hit locations. Moved out of ringbrp.mjs so the harness can locate damage; these are
|
||
// the spot-checks they never had while the engine held the only copy. A species that
|
||
// has no head must not be able to return one, which is the whole point of the tables.
|
||
["locationFor(20, baseline, melee) is the head", RULES.locationFor(20, "baseline", "melee").id, "head"],
|
||
["locationFor(20, baseline, ranged) is the head", RULES.locationFor(20, "baseline", "ranged").id, "head"],
|
||
["locationFor(1, baseline, melee) is a leg", RULES.locationFor(1, "baseline", "melee").kind, "leg"],
|
||
// melee finds limbs, ranged finds centre of mass — the same roll, two answers
|
||
["locationFor(11, baseline, melee) is abdomen", RULES.locationFor(11, "baseline", "melee").id, "abdomen"],
|
||
["locationFor(11, baseline, ranged) is chest", RULES.locationFor(11, "baseline", "ranged").id, "chest"],
|
||
// a vesh has no head at any roll; its vital is the ridge
|
||
["locationFor(20, vesh, melee) is the ridge", RULES.locationFor(20, "vesh", "melee").id, "ridge"],
|
||
["locationFor(20, vesh, ranged) is the ridge", RULES.locationFor(20, "vesh", "ranged").id, "ridge"],
|
||
// a cadence is bodies all the way down — no head, no vital, whatever you roll
|
||
["locationFor(20, cadence, melee) is a unit", RULES.locationFor(20, "cadence", "melee").kind, "unit"],
|
||
["locationFor(1, cadence, ranged) is a unit", RULES.locationFor(1, "cadence", "ranged").kind, "unit"],
|
||
["locationFor(20, quadruped, melee) is the head", RULES.locationFor(20, "quadruped", "melee").id, "head"],
|
||
["locationFor(19, quadruped, melee) is the neck", RULES.locationFor(19, "quadruped", "melee").id, "neck"],
|
||
["locationFor(11, quadruped, ranged) forequarters",RULES.locationFor(11, "quadruped", "ranged").id, "foreQtr"],
|
||
["quadruped forequarters are the vital", RULES.locationFor(11, "quadruped", "ranged").kind,"vital"],
|
||
["locationFor(1, quadruped, melee) is a leg", RULES.locationFor(1, "quadruped", "melee").kind, "leg"],
|
||
["locationFor(13, winged, ranged) is a wing", RULES.locationFor(13, "winged", "ranged").kind, "wing"],
|
||
["a winged melee 17 is a wing too", RULES.locationFor(17, "winged", "melee").kind, "wing"],
|
||
["a destroyed neck is death", RULES.destructionOutcomeFor("neck", true), "dead"],
|
||
["a destroyed wing is not", RULES.destructionOutcomeFor("wing", true), "none"],
|
||
// A change of body plan must not strand a wound, and must not change what it meant.
|
||
// Slots make the pairing explicit, so right stays right across every crossing.
|
||
["remapLocationDamage(legR) becomes a hind leg",
|
||
RULES.remapLocationDamage({ legR: 5 }, "baseline", "quadruped", 16).damage.hindLegR, 5],
|
||
["remapLocationDamage(chest) becomes the forequarters",
|
||
RULES.remapLocationDamage({ chest: 7 }, "baseline", "quadruped", 16).damage.foreQtr, 7],
|
||
["remapLocationDamage(armR) becomes the RIGHT foreleg",
|
||
Object.keys(RULES.remapLocationDamage({ armR: 4 }, "baseline", "quadruped", 16).damage)[0], "foreLegR"],
|
||
["remapLocationDamage(foreLegR) becomes the RIGHT arm",
|
||
Object.keys(RULES.remapLocationDamage({ foreLegR: 4 }, "quadruped", "baseline", 16).damage)[0], "armR"],
|
||
["remapLocationDamage(wingL) becomes the left arm",
|
||
Object.keys(RULES.remapLocationDamage({ wingL: 3 }, "winged", "baseline", 16).damage)[0], "armL"],
|
||
["remapLocationDamage(head) becomes the vesh ridge",
|
||
Object.keys(RULES.remapLocationDamage({ head: 4 }, "baseline", "vesh", 16).damage)[0], "ridge"],
|
||
// Severity is the invariant, not the point count: an arm is 0.35 of the body and a
|
||
// foreleg 0.42, so a destroyed arm must arrive destroyed rather than merely disabled.
|
||
["remapLocationDamage(destroyed arm) arrives destroyed",
|
||
RULES.remapLocationDamage({ armR: 12 }, "baseline", "quadruped", 16).damage.foreLegR, 14],
|
||
["remapLocationDamage(disabled arm) arrives disabled, not destroyed",
|
||
RULES.remapLocationDamage({ armR: 6 }, "baseline", "quadruped", 16).damage.foreLegR, 7],
|
||
["remapLocationDamage(hurt arm) arrives still merely hurt",
|
||
RULES.remapLocationDamage({ armR: 5 }, "baseline", "quadruped", 16).damage.foreLegR, 6],
|
||
["remapLocationDamage(two disabled limbs sharing one) takes the worse, not the sum",
|
||
RULES.remapLocationDamage({ armR: 6, armL: 6 }, "baseline", "vesh", 16).damage.limbGrasp, 6],
|
||
["remapLocationDamage(unchanged plan) is identity",
|
||
RULES.remapLocationDamage({ chest: 4 }, "baseline", "baseline", 16).damage.chest, 4],
|
||
["remapLocationDamage(rescaled) says how many it had to rescale",
|
||
RULES.remapLocationDamage({ armR: 6 }, "baseline", "quadruped", 16).rescaled, 1],
|
||
// A flyer with a hole in a wing is a quadruped — the creature's own written rule,
|
||
// which was decoration until losing a wing actually changed the plan.
|
||
["groundedPlanFor(winged) is the quadruped", RULES.groundedPlanFor("winged"), "quadruped"],
|
||
["groundedPlanFor(quadruped) is nothing", RULES.groundedPlanFor("quadruped"), ""],
|
||
["groundedPlanFor(baseline) is nothing", RULES.groundedPlanFor("baseline"), ""],
|
||
["groundedPlanFor(unknown) is nothing", RULES.groundedPlanFor("nonesuch"), ""],
|
||
// Hide does not grow on membrane: a bare location carries only what it is wearing.
|
||
["naturalArmourOn(a hide location) is the full hide",
|
||
RULES.naturalArmourOn({ kind: "vital" }, 7), 7],
|
||
["naturalArmourOn(a bare wing) is nothing",
|
||
RULES.naturalArmourOn({ kind: "wing", bare: true }, 7), 0],
|
||
// Only an explicit `bare` strips the hide. Anything unmarked — or missing — keeps it,
|
||
// so a location this rule has never heard of fails toward armoured, not toward naked.
|
||
["naturalArmourOn(an unmarked location) keeps the hide",
|
||
RULES.naturalArmourOn({ kind: "wing" }, 7), 7],
|
||
["naturalArmourOn(no location at all) keeps the hide",
|
||
RULES.naturalArmourOn(null, 7), 7],
|
||
// On its belly, half the hide is no longer in the way — which is what gives an
|
||
// ordinary round somewhere to land once grounding takes the bare wings off the table.
|
||
// R-257. The harness applied these two from its own copy for as long as it has
|
||
// existed. The numbers below are the ones a special hit against hide 9 produces, and
|
||
// they are now the only statement of them anywhere.
|
||
["armourAgainst(critical) ignores it all", RULES.armourAgainst("critical", 9), 0],
|
||
["armourAgainst(special) counts half", RULES.armourAgainst("special", 9), 4],
|
||
["armourAgainst(special) floors the half", RULES.armourAgainst("special", 5), 2],
|
||
["armourAgainst(success) is the full plate", RULES.armourAgainst("success", 9), 9],
|
||
["armourAgainst(no armour at all)", RULES.armourAgainst("special", 0), 0],
|
||
["damageAfterArmour(through)", RULES.damageAfterArmour(12, 4), 8],
|
||
["damageAfterArmour(stopped) never goes under zero", RULES.damageAfterArmour(3, 9), 0],
|
||
["damageAfterArmour(exactly stopped)", RULES.damageAfterArmour(9, 9), 0],
|
||
["exposedHideFor(flying) is the whole hide", RULES.exposedHideFor(9, false, "leg"), 9],
|
||
["exposedHideFor(grounded) is half of it", RULES.exposedHideFor(9, true, "leg"), 4],
|
||
["exposedHideFor(grounded VITAL) is exempt", RULES.exposedHideFor(9, true, "vital"), 9],
|
||
["exposedHideFor(flying vital) is unchanged", RULES.exposedHideFor(9, false, "vital"), 9],
|
||
["exposedHideFor(grounded, thin hide)", RULES.exposedHideFor(1, true, "leg"), 0],
|
||
["exposedHideFor(no hide at all)", RULES.exposedHideFor(0, true, "leg"), 0],
|
||
// A deletion and an assignment of the same key do not both apply: the deletion wins.
|
||
// quadruped and winged share every location but the wings, so this is where a wound
|
||
// was silently thrown away instead of carried.
|
||
["locationDamageReplacement(overlapping id) keeps the value",
|
||
RULES.locationDamageReplacement({ hindLegR: 5 }, { hindLegR: 5 }).hindLegR, 5],
|
||
["locationDamageReplacement(overlapping id) does not also delete it",
|
||
"-=hindLegR" in RULES.locationDamageReplacement({ hindLegR: 5 }, { hindLegR: 5 }), false],
|
||
["locationDamageReplacement(dropped id) deletes it",
|
||
RULES.locationDamageReplacement({ legR: 5 }, { hindLegR: 5 })["-=legR"], null],
|
||
["one lost wing grounds it",
|
||
RULES.locationEffectsFor([{ kind: "wing", disabled: true }]).flightLost, true],
|
||
["an opened body cavity bleeds, as its text says",
|
||
RULES.locationEffectsFor([{ kind: "body", disabled: true }]).bleed, 1],
|
||
["and destroyed it bleeds double",
|
||
RULES.locationEffectsFor([{ kind: "body", disabled: true, destroyed: true }]).bleed, 2],
|
||
["locationsFor(baseline) has 7", RULES.locationsFor("baseline").length, 7],
|
||
["locationsFor(vesh) has 6", RULES.locationsFor("vesh").length, 6],
|
||
["locationsFor(cadence) has 5", RULES.locationsFor("cadence").length, 5],
|
||
["locationsFor(unknown) falls back to baseline", RULES.locationsFor("nonesuch").length, 7],
|
||
// meeting the maximum disables; twice it destroys; and both are CUMULATIVE
|
||
["resolveLocationHit(4 of 5) holds", RULES.resolveLocationHit({damage:4,locationMax:5}).disabled, false],
|
||
["resolveLocationHit(5 of 5) disables", RULES.resolveLocationHit({damage:5,locationMax:5}).disabled, true],
|
||
["resolveLocationHit(10 of 5) destroys", RULES.resolveLocationHit({damage:10,locationMax:5}).destroyed, true],
|
||
["resolveLocationHit(3 onto 2 of 5) disables", RULES.resolveLocationHit({damage:3,locationMax:5,locationTaken:2}).disabled, true],
|
||
["resolveLocationHit(3 onto 7 of 5) destroys", RULES.resolveLocationHit({damage:3,locationMax:5,locationTaken:7}).destroyed, true],
|
||
// the wound-penalty mapping: all hits everything, manipulation stops you fighting,
|
||
// physical stops you dodging
|
||
["woundPenaltyFrom(all -10, melee)", RULES.woundPenaltyFrom({all:-10}, "melee"), -10],
|
||
["woundPenaltyFrom(manip -30, melee)", RULES.woundPenaltyFrom({manipulation:-30}, "melee"), -30],
|
||
["woundPenaltyFrom(manip -30, ranged)", RULES.woundPenaltyFrom({manipulation:-30}, "ranged"), -30],
|
||
["woundPenaltyFrom(manip -30, phys)", RULES.woundPenaltyFrom({manipulation:-30}, "phys"), 0],
|
||
["woundPenaltyFrom(phys -30, phys)", RULES.woundPenaltyFrom({physical:-30}, "phys"), -30],
|
||
["woundPenaltyFrom(percep -30, percep)", RULES.woundPenaltyFrom({perception:-30}, "percep"), -30],
|
||
["woundPenaltyFrom(nothing)", RULES.woundPenaltyFrom(null, "melee"), 0],
|
||
// 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`);
|