A reload icon sits beside the ammo count in the readied-weapon strip and the weapons table, for any weapon with a magazine. It tops the weapon up from its reserve (rules.reloadFor, spot-checked), posts the new count and the weapon's reload time to chat, and warns when the weapon is already full or the reserve is empty. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
693 lines
57 KiB
JavaScript
693 lines
57 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],
|
||
["reloadFor(6 cap, 2 loaded, 12)", JSON.stringify(RULES.reloadFor(6,2,12)), '{"loaded":6,"reserve":8,"moved":4}'],
|
||
["reloadFor(6 cap, 0 loaded, 3)", JSON.stringify(RULES.reloadFor(6,0,3)), '{"loaded":3,"reserve":0,"moved":3}'],
|
||
["reloadFor(full)", RULES.reloadFor(6,6,12).moved, 0],
|
||
["reloadFor(no reserve)", RULES.reloadFor(6,1,0).moved, 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"],
|
||
// Opposed rolls. The better band wins; ties go to whoever is being acted upon, which is
|
||
// the same answer defenceOutcomeFor gives a defender; and if nobody reached a success
|
||
// the contest is unsettled rather than won, so it can be tried again.
|
||
["opposedOutcomeFor(crit, success).winner", RULES.opposedOutcomeFor("critical","success").winner, "active"],
|
||
["opposedOutcomeFor(crit, success).margin", RULES.opposedOutcomeFor("critical","success").margin, 2],
|
||
["opposedOutcomeFor(success, crit)", RULES.opposedOutcomeFor("success","critical").winner,"resisting"],
|
||
["opposedOutcomeFor(success, success)", RULES.opposedOutcomeFor("success","success").winner, "resisting"],
|
||
["opposedOutcomeFor(crit, crit)", RULES.opposedOutcomeFor("critical","critical").winner,"resisting"],
|
||
["opposedOutcomeFor(success, success).margin", RULES.opposedOutcomeFor("success","success").margin, 0],
|
||
["opposedOutcomeFor(special, success)", RULES.opposedOutcomeFor("special","success").winner, "active"],
|
||
["opposedOutcomeFor(success, failure)", RULES.opposedOutcomeFor("success","failure").winner, "active"],
|
||
["opposedOutcomeFor(failure, failure).settled", RULES.opposedOutcomeFor("failure","failure").settled, false],
|
||
["opposedOutcomeFor(failure, failure).winner", RULES.opposedOutcomeFor("failure","failure").winner, null],
|
||
["opposedOutcomeFor(fumble, failure).settled", RULES.opposedOutcomeFor("fumble","failure").settled, false],
|
||
["opposedOutcomeFor(success, fumble)", RULES.opposedOutcomeFor("success","fumble").winner, "active"],
|
||
// Both sides' levels travel with the verdict, so a caller can price a fumbled attempt
|
||
// without this file deciding what fumbling costs.
|
||
["opposedOutcomeFor(fumble, failure).activeLevel", RULES.opposedOutcomeFor("fumble","failure").activeLevel, "fumble"],
|
||
["opposedOutcomeFor(fumble, failure).resistingLevel", RULES.opposedOutcomeFor("fumble","failure").resistingLevel, "failure"],
|
||
// The whole contest, each side at its own difficulty. CLEAN GROUND's Act Four beat:
|
||
// the understudy at POWx5 55 against Ashcroft's 85 resisting at Difficult, which is 42.
|
||
["opposedContestFor(...).resisting.bands.effective", RULES.opposedContestFor({rating:55,roll:12},{rating:85,roll:40,difficulty:"difficult"}).resisting.bands.effective, 42],
|
||
["opposedContestFor(...).winner", RULES.opposedContestFor({rating:55,roll:12},{rating:85,roll:40,difficulty:"difficult"}).winner, "resisting"],
|
||
["opposedContestFor(aggressor specials)", RULES.opposedContestFor({rating:55,roll:8},{rating:85,roll:40,difficulty:"difficult"}).winner, "active"],
|
||
["opposedContestFor(both miss).settled", RULES.opposedContestFor({rating:55,roll:90},{rating:85,roll:90,difficulty:"difficult"}).settled, false],
|
||
// 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`);
|