/** * 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`);