Selectable fire, with the modifiers on the choice Every weapon has carried a fireMode since the packs were first built and nothing read it: a musket could rip a ten-round burst and "rapid" bought a weapon nothing. FIRE_MODE now gates the burst rules and carries its own attack modifier — slow +10% and then you work for the next one, fast a second shot at -20% to both, rapid bursts, beam +5% and it can be held on the target for damage instead of hits. The gate lives in burstAttack too, so no macro can route round it. `fast` was on five catalogue weapons and was not in the system's list of modes at all, so re-saving one of them silently blanked it; the list is derived from the rule now. burstAttack shipped complete and was reachable from nothing. It is the resolution path the selector chooses, and takes the dialog's whole stack rather than charging the player twice for the same burst. Sights and lamps that apply themselves A sight is graded by range band, so a telescopic sight is a -10% liability inside ten metres and worth +20% at extreme, and it only counts if it fits. The band is already known when the modifier is wanted, so nothing is selected and nothing is forgotten. The dark costs something now; a carried lamp buys it back to dim, never to daylight, and the card says it shows them exactly where you are. A migration fits gear that predates the fields, matched on the stamped key rather than by name. The damage card The last card still wearing Foundry's stock dice styling — it posted a bare Roll and let the core template dress it. It renders through the module's own card now, with the Roll still on the message so dice stay inspectable. A rail instead of a strip Eight tabs across an 820px sheet compressed to 11px type. They run down the side now, one line each, with counts as badges; the sheet is 152px wider to pay for it. Also fixed: any weapon with a Coherence cost could not be attacked with AT ALL — the handler re-read the weapon after an await, when currentTarget is already null, and the attack died on a TypeError with nothing shown. And the duplicate-rule guard failed an honest table because FIRE_MODE and speedBands share two key names; it wants a majority now, and prints partial overlaps rather than swallowing them. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
400 lines
31 KiB
JavaScript
400 lines
31 KiB
JavaScript
/**
|
|
* Guard against the single most persistent defect in this project: a rule
|
|
* implemented in two places and changed in one. Five separate bugs came from it,
|
|
* the worst being packs built with double hit points because the builder kept its
|
|
* own drifted copy of the formula.
|
|
*
|
|
* Three properties, each closing a different escape route:
|
|
* 1. NO file in the system except rules.mjs may define a canonical rule. Scanned
|
|
* by discovery, not by a hardcoded file list, so a new file cannot slip past.
|
|
* 2. Every re-export or alias of a canonical name must BE that function, not a
|
|
* same-named copy. Aliases are discovered from the source, not hand-listed.
|
|
* 3. Every function rules.mjs exports must be exercised by at least one check, so
|
|
* adding a rule cannot silently arrive with zero coverage.
|
|
*
|
|
* Run before building packs; build-packs.mjs refuses to build if this fails.
|
|
*/
|
|
import { readFile, readdir } from "node:fs/promises";
|
|
import * as RULES from "../rules.mjs";
|
|
import * as CONTENT from "./content.mjs";
|
|
|
|
const canonical = Object.keys(RULES).filter(k => typeof RULES[k] === "function");
|
|
let failures = 0;
|
|
const fail = m => { console.error(" FAIL " + m); failures++; };
|
|
|
|
/* 1. discover every source file, and let none of them re-define a rule ------- */
|
|
const root = new URL("../", import.meta.url);
|
|
const files = [];
|
|
for (const dir of ["", "tools/"]) {
|
|
for (const name of await readdir(new URL(dir, root))) {
|
|
if (!name.endsWith(".mjs")) continue;
|
|
const rel = dir + name;
|
|
if (rel === "rules.mjs" || rel === "tools/check-rules.mjs") continue;
|
|
files.push(rel);
|
|
}
|
|
}
|
|
for (const rel of files) {
|
|
const src = await readFile(new URL(rel, root), "utf8");
|
|
for (const name of canonical) {
|
|
const defined = new RegExp(`^\\s*(?:export\\s+)?(?:async\\s+)?function ${name}\\s*\\(`, "m").test(src)
|
|
|| new RegExp(`^\\s*(?:export\\s+)?(?:const|let|var) ${name}\\s*=\\s*(?:\\(|function|async)`, "m").test(src);
|
|
if (defined) fail(`${rel} defines ${name}() — it belongs to rules.mjs alone`);
|
|
}
|
|
}
|
|
|
|
/* 2. aliases must be identity-equal ---------------------------------------- *
|
|
* REQUIRED is authoritative and hand-held on purpose. Discovery alone is not
|
|
* enough: the original bug replaced an alias with an arrow function, which a
|
|
* discovery regex simply stops matching — the guard then skips it and reports
|
|
* OK. Absence must fail loudly, so every required alias is asserted by name and
|
|
* discovery may only ADD to the set, never shrink it. */
|
|
const REQUIRED = { hitPoints: "hitPointsFor", majorWound: "majorWoundFor", damageModifier: "damageModifierFor" };
|
|
const contentSrc = await readFile(new URL("tools/content.mjs", root), "utf8");
|
|
const discovered = Object.fromEntries(
|
|
[...contentSrc.matchAll(/^export const (\w+)\s*=\s*(\w+);/gm)]
|
|
.filter(([, , target]) => canonical.includes(target))
|
|
.map(([, alias, target]) => [alias, target]));
|
|
const pairs = { ...discovered, ...REQUIRED };
|
|
for (const [alias, target] of Object.entries(pairs)) {
|
|
if (!(alias in CONTENT)) fail(`content.${alias} is missing — it must re-export rules.${target}`);
|
|
else if (CONTENT[alias] !== RULES[target]) fail(`content.${alias} is not the same function as rules.${target}`);
|
|
}
|
|
|
|
/* 2b. constants are rules too ----------------------------------------------- *
|
|
* Functions were guarded from the start; the REACTION constants were not, and a
|
|
* second object literal for them survived in ringbrp.mjs with its own copy of the
|
|
* divisors. Any non-function export of rules.mjs is now held to the same standard:
|
|
* no other file may write an object literal containing its keys. */
|
|
const constants = Object.keys(RULES).filter(k => typeof RULES[k] === "object" && RULES[k]);
|
|
for (const name of constants) {
|
|
const keys = Object.keys(RULES[name]);
|
|
if (keys.length < 2) continue;
|
|
for (const rel of files) {
|
|
const src = await readFile(new URL(rel, root), "utf8");
|
|
// An object literal that carries 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. */
|
|
{
|
|
const engineSrc = await readFile(new URL("ringbrp.mjs", root), "utf8");
|
|
const tablesBlock = engineSrc.match(/^RINGBRP\.locationTables = \{([\s\S]*?)^\};/m);
|
|
const profiles = tablesBlock
|
|
? [...tablesBlock[1].matchAll(/^ {2}(\w+):\s*\{/gm)].map(x => x[1]) : [];
|
|
const tpl = JSON.parse(await readFile(new URL("template.json", root), "utf8"));
|
|
const found = [];
|
|
(function walk(o) {
|
|
if (Array.isArray(o)) return o.forEach(walk);
|
|
if (o && typeof o === "object") for (const [k, v] of Object.entries(o)) {
|
|
if (k === "speciesProfile") found.push(v); else walk(v);
|
|
}
|
|
})(tpl);
|
|
if (!profiles.length) fail("could not read locationTables — the species check is not running");
|
|
for (const v of found) {
|
|
if (!profiles.includes(v)) fail(`template.json speciesProfile "${v}" is not a species (have: ${profiles.join(", ")})`);
|
|
}
|
|
}
|
|
|
|
/* 3. behaviour, with mandatory coverage of every exported rule ---------------- */
|
|
const checks = [
|
|
["hitPointsFor(13,10)", RULES.hitPointsFor(13, 10), 12],
|
|
["hitPointsFor(10,19)", RULES.hitPointsFor(10, 19), 15],
|
|
["hitPointsFor(0,0)", RULES.hitPointsFor(0, 0), 1],
|
|
["majorWoundFor(12)", RULES.majorWoundFor(12), 6],
|
|
["majorWoundFor(15)", RULES.majorWoundFor(15), 8],
|
|
["reactionBaseFrom(18,34)", RULES.reactionBaseFrom(18, 34), 18],
|
|
["reactionBaseFrom(10,71)", RULES.reactionBaseFrom(10, 71), 24],
|
|
["reflexRating(18)", RULES.reflexRating(18), 6],
|
|
["awarenessRating(71)", RULES.awarenessRating(71), 8],
|
|
["damageModifierFor(37)", RULES.damageModifierFor(37), "+1d6"],
|
|
["damageModifierFor(5)", RULES.damageModifierFor(5), "-1d6"],
|
|
["damageModifierFor(13)", RULES.damageModifierFor(13), "-1d3"],
|
|
["damageModifierFor(80)", RULES.damageModifierFor(80), "+5d6"],
|
|
["locationMaxHp(12,0.42)", RULES.locationMaxHp(12, 0.42), 6],
|
|
["styleFor('aware')", RULES.styleFor("aware"), "aware"],
|
|
["ceil(1.1)", RULES.ceil(1.1), 2],
|
|
// every point over free carry costs, not just whole steps
|
|
["encumbranceFrom(30,20).over", RULES.encumbranceFrom(30, 20).over, 10],
|
|
["encumbranceFrom(30,20).srLoad", RULES.encumbranceFrom(30, 20).srLoad, 1],
|
|
["encumbranceFrom(10,20).over", RULES.encumbranceFrom(10, 20).over, 0],
|
|
["encumbranceFrom(10,20).skillPenalty", RULES.encumbranceFrom(10, 20).skillPenalty, 0],
|
|
// load is exact, not banded: 3.4 kg over costs 0.3, not a whole point
|
|
["reactionLoadFrom(0.34)", RULES.reactionLoadFrom(0.34), 0.4],
|
|
["reactionLoadFrom(2)", RULES.reactionLoadFrom(2), 2],
|
|
["reactionLoadFrom(0)", RULES.reactionLoadFrom(0), 0],
|
|
["reactionPrintedFrom(24,0.34)", RULES.reactionPrintedFrom(24, 0.34), 23.6],
|
|
// the smallest excess still costs: 100 g over is 0.1, never 0
|
|
["reactionLoadFrom(0.01)", RULES.reactionLoadFrom(0.01), 0.1],
|
|
["reactionPrintedFrom(15,2)", RULES.reactionPrintedFrom(15, 2), 13],
|
|
// R-56: no load may reduce an agent below 1 — last, never never
|
|
["reactionPrintedFrom(15,30)", RULES.reactionPrintedFrom(15, 30), 1],
|
|
// Major Wounds are penalties, not prose. Concussion hits every category via `all`.
|
|
["woundEffectsFor([concussed]).all", RULES.woundEffectsFor([{key:"concussed"}]).all, -20],
|
|
["woundEffectsFor(x2 concussed).all", RULES.woundEffectsFor([{key:"concussed"},{key:"concussed"}]).all, -40],
|
|
["woundEffectsFor([mobility]).movFactor", RULES.woundEffectsFor([{key:"mobility"}]).movFactor, 0.5],
|
|
["woundEffectsFor([manipulation])", RULES.woundEffectsFor([{key:"manipulation"}]).manipulation, -30],
|
|
["woundEffectsFor([deepBleeding])", RULES.woundEffectsFor([{key:"deepBleeding"}]).bleed, 2],
|
|
["woundEffectsFor([crippling])", RULES.woundEffectsFor([{key:"crippling"}]).defenceOnly, true],
|
|
// a missing variant must not quietly cost nothing
|
|
["woundEffectsFor([sensory no variant])", RULES.woundEffectsFor([{key:"sensory"}]).perception, -40],
|
|
["woundEffectsFor([]).all", RULES.woundEffectsFor([]).all, 0],
|
|
// bleeding: the rate and the way to stop it are one authority
|
|
["bleedRateFor([bleeding])", RULES.bleedRateFor([{key:"bleeding"}]), 1],
|
|
["bleedRateFor([deep,bleeding])", RULES.bleedRateFor([{key:"deepBleeding"},{key:"bleeding"}]), 3],
|
|
["bleedRateFor([]) ", RULES.bleedRateFor([]), 0],
|
|
["rate agrees with woundEffectsFor", RULES.bleedRateFor([{key:"deepBleeding"}]) === RULES.woundEffectsFor([{key:"deepBleeding"}]).bleed, true],
|
|
["bleedStoppedBy(bleeding, firstaid)", RULES.bleedStoppedBy("bleeding",{family:"first_aid"}), true],
|
|
["bleedStoppedBy(deep, average firstaid)", RULES.bleedStoppedBy("deepBleeding",{family:"first_aid",difficulty:"average"}), false],
|
|
["bleedStoppedBy(deep, difficult firstaid)",RULES.bleedStoppedBy("deepBleeding",{family:"first_aid",difficulty:"difficult"}), true],
|
|
["bleedStoppedBy(deep, medicine)", RULES.bleedStoppedBy("deepBleeding",{family:"medicine"}), true],
|
|
["bleedStoppedBy(deep, athletics)", RULES.bleedStoppedBy("deepBleeding",{family:"athletics"}), false],
|
|
// range ladder and coherence bands, now shared with the rules journal
|
|
["rangeBandFrom(5).mod", RULES.rangeBandFrom(5, {short:10,medium:30,long:90}).mod, 0],
|
|
["rangeBandFrom(25).mod", RULES.rangeBandFrom(25, {short:10,medium:30,long:90}).mod, -10],
|
|
["rangeBandFrom(60).mod", RULES.rangeBandFrom(60, {short:10,medium:30,long:90}).mod, -30],
|
|
["rangeBandFrom(150).mod", RULES.rangeBandFrom(150, {short:10,medium:30,long:90}).mod, -50],
|
|
["rangeBandFrom(400).mod", RULES.rangeBandFrom(400, {short:10,medium:30,long:90}).mod, null],
|
|
["coherenceBandFrom(10).id", RULES.coherenceBandFrom(10).id, "anchored"],
|
|
["coherenceBandFrom(6).id", RULES.coherenceBandFrom(6).id, "loose"],
|
|
["coherenceBandFrom(4).id", RULES.coherenceBandFrom(4).id, "adrift"],
|
|
["coherenceBandFrom(1).id", RULES.coherenceBandFrom(1).id, "unmoored"],
|
|
["coherenceBandFrom(0).id", RULES.coherenceBandFrom(0).id, "displaced"],
|
|
// a lost location must cost something, for every kind the species tables use
|
|
["locationEffectsFor([]) is inert", RULES.locationEffectsFor([]).all, 0],
|
|
["leg disabled → physical", RULES.locationEffectsFor([{kind:"leg",disabled:true}]).physical, -30],
|
|
["leg destroyed → MOV floor", RULES.locationEffectsFor([{kind:"leg",disabled:true,destroyed:true}]).movFactor, 0.1],
|
|
["arm disabled → hands", RULES.locationEffectsFor([{kind:"arm",disabled:true}]).manipulation, -30],
|
|
["head disabled → unconscious", RULES.locationEffectsFor([{kind:"head",disabled:true}]).unconscious, true],
|
|
["vesh ridge → perception", RULES.locationEffectsFor([{kind:"ridge",disabled:true}]).perception, -30],
|
|
["vesh grasp → hands", RULES.locationEffectsFor([{kind:"grasp",disabled:true}]).manipulation, -30],
|
|
// the Cadence rule: each body lost costs the WHOLE person
|
|
["cadence 1 body lost", RULES.locationEffectsFor([{kind:"unit",disabled:true}]).all, -10],
|
|
["cadence 3 bodies lost", RULES.locationEffectsFor(Array.from({length:3},()=>({kind:"unit",disabled:true}))).all, -30],
|
|
// encumbrance reaches physical skills and leaves the mind alone
|
|
["encumbrancePenaltyFor(10,'phys')", RULES.encumbrancePenaltyFor(10,"phys"), -10],
|
|
["encumbrancePenaltyFor(10,'ranged')", RULES.encumbrancePenaltyFor(10,"ranged"), -10],
|
|
["encumbrancePenaltyFor(10,'manip')", RULES.encumbrancePenaltyFor(10,"manip"), -10],
|
|
["encumbrancePenaltyFor(10,'mental')", RULES.encumbrancePenaltyFor(10,"mental"), 0],
|
|
["encumbrancePenaltyFor(10,'comm')", RULES.encumbrancePenaltyFor(10,"comm"), 0],
|
|
["encumbrancePenaltyFor(0,'phys')", RULES.encumbrancePenaltyFor(0,"phys"), 0],
|
|
// free carry counts strength AND mass, not strength alone
|
|
["freeCarryFor(18,19)", RULES.freeCarryFor(18,19), 37],
|
|
["freeCarryFor(12,13)", RULES.freeCarryFor(12,13), 25],
|
|
["freeCarryFor(0,0)", RULES.freeCarryFor(0,0), 1],
|
|
// defending: the first is free, each one after is harder
|
|
["defencePenaltyFor(0)", RULES.defencePenaltyFor(0), 0],
|
|
["defencePenaltyFor(1)", RULES.defencePenaltyFor(1), -30],
|
|
["defencePenaltyFor(3)", RULES.defencePenaltyFor(3), -90],
|
|
["defenceTypeAllowed('dodge','parry')", RULES.defenceTypeAllowed("dodge","parry"), "dodge"],
|
|
["defenceTypeAllowed('','parry')", RULES.defenceTypeAllowed("","parry"), "parry"],
|
|
// defences are graded: a defence stops its own quality or worse, and only
|
|
// shaves a better blow by as many steps as it was outclassed by
|
|
// 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],
|
|
// 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],
|
|
["defenceOutcomeFor(crit, crit)", RULES.defenceOutcomeFor("critical","critical").turnedAside, true],
|
|
["defenceOutcomeFor(crit, special)", RULES.defenceOutcomeFor("critical","special").landsAt, "success"],
|
|
["defenceOutcomeFor(crit, success)", RULES.defenceOutcomeFor("critical","success").landsAt, "special"],
|
|
["defenceOutcomeFor(crit, failure)", RULES.defenceOutcomeFor("critical","failure").landsAt, "critical"],
|
|
["defenceOutcomeFor(special, success)", RULES.defenceOutcomeFor("special","success").landsAt, "success"],
|
|
["defenceOutcomeFor(special, special)", RULES.defenceOutcomeFor("special","special").turnedAside, true],
|
|
["defenceOutcomeFor(success, success)", RULES.defenceOutcomeFor("success","success").turnedAside, true],
|
|
["defenceOutcomeFor(success, fumble)", RULES.defenceOutcomeFor("success","fumble").landsAt, "success"],
|
|
// the Quiet Arts cost Coherence, and Unmake costs more than the rest
|
|
["powerCostFor('thread')", RULES.powerCostFor("thread"), 1],
|
|
["powerCostFor('thread', pushed)", RULES.powerCostFor("thread",{pushed:true}), 2],
|
|
["powerCostFor('unmake')", RULES.powerCostFor("unmake"), 2],
|
|
["powerCostFor('unmake', pushed)", RULES.powerCostFor("unmake",{pushed:true}), 4],
|
|
["powerCostFor(unknown art)", RULES.powerCostFor("nonesuch"), 1],
|
|
["powerStartingRating(13)", RULES.powerStartingRating(13), 26],
|
|
["powerStartingRating(30) is capped", RULES.powerStartingRating(30), 45],
|
|
["powerStartingRating(0) has a floor", RULES.powerStartingRating(0), 5],
|
|
// an Art arrives only from a crossing that went wrong
|
|
["crossingGrantsArt(success, 10)", RULES.crossingGrantsArt("success", 10), false],
|
|
["crossingGrantsArt(failure, 10)", RULES.crossingGrantsArt("failure", 10), false],
|
|
["crossingGrantsArt(failure, 2)", RULES.crossingGrantsArt("failure", 2), true],
|
|
["crossingGrantsArt(fumble, 10)", RULES.crossingGrantsArt("fumble", 10), true],
|
|
["crossingCostFor(fumble)", RULES.crossingCostFor("fumble"), 2],
|
|
["crossingCostFor(success)", RULES.crossingCostFor("success"), 0],
|
|
// the pool darkens as the agent comes apart
|
|
["crossingArtPoolFor(10)", RULES.crossingArtPoolFor(10).join(","), "thread,echo,witness"],
|
|
["crossingArtPoolFor(4)", RULES.crossingArtPoolFor(4).join(","), "hollow,thread,echo"],
|
|
["crossingArtPoolFor(1)", RULES.crossingArtPoolFor(1).join(","), "unmake,ledger,hollow"]
|
|
];
|
|
for (const [label, got, want] of checks) {
|
|
if (got !== want) fail(`${label} = ${JSON.stringify(got)}, expected ${JSON.stringify(want)}`);
|
|
}
|
|
|
|
const covered = new Set(checks.map(([label]) => label.slice(0, label.indexOf("("))));
|
|
for (const name of canonical) {
|
|
if (!covered.has(name)) fail(`rules.${name}() has no spot-check — every exported rule must be exercised`);
|
|
}
|
|
|
|
if (failures) { console.error(`\ncheck-rules: ${failures} failure(s)`); process.exit(1); }
|
|
console.log(`check-rules: OK — ${canonical.length} rules, ${files.length} files scanned, `
|
|
+ `${Object.keys(pairs).length} aliases + ${constants.length} constant sets checked, ${checks.length} formulas verified`);
|