/** * THE CUSTODIANS — canonical rule functions. * * This module is the single authority for every pure rule formula. It is imported * by the runtime (`ringbrp.mjs`, in the browser) AND by the pack builder * (`tools/content.mjs`, in Node), so the packs can never be built under one rule * and played under another. * * That happened repeatedly. `tools/content.mjs` used to carry its own copies * "mirrored from ringbrp.mjs", and they drifted: Major Wound was `max(9, hp/3)` * in the builder while the engine used `hp/2`, and hit points stayed at `CON+SIZ` * in the builder after the engine halved them. Five separate defects in this * project came from a rule written twice and changed once. * * RULE: if a formula is used in more than one place, it lives here. Nothing in * this file may import from the runtime or touch Foundry globals. */ export const ceil = Math.ceil; /** Hit points: half of CON + SIZ, standard BRP. */ export function hitPointsFor(con, siz) { return Math.max(1, ceil(((Number(con) || 0) + (Number(siz) || 0)) / 2)); } /** A Major Wound is a blow of half your hit points or more. */ export function majorWoundFor(hp) { return Math.max(1, ceil((Number(hp) || 0) / 2)); } /** Damage modifier from STR + SIZ. */ export function damageModifierFor(total) { const t = Number(total) || 0; if (t <= 8) return "-1d6"; if (t <= 16) return "-1d3"; if (t <= 24) return "0"; if (t <= 32) return "+1d3"; if (t <= 40) return "+1d6"; if (t <= 50) return "+2d6"; if (t <= 60) return "+3d6"; if (t <= 70) return "+4d6"; return `+${4 + ceil((t - 70) / 10)}d6`; } /** Marksmanship is the agent's own choice now that home worlds are gone. */ export function styleFor(chosen = "reflex") { return chosen; } /** A location's hit points, as a fraction of the total pool. */ export function locationMaxHp(totalHp, frac) { return Math.max(1, ceil((Number(totalHp) || 0) * (Number(frac) || 0))); } /** Reaction: the two axes and the printed base. */ export const REACTION = { dexDivisor: 3, readinessDivisor: 10, laneMultiplier: 3, die: 6, modCap: 3 }; export function reflexRating(dex) { return Math.max(1, ceil((Number(dex) || 0) / REACTION.dexDivisor)); } export function awarenessRating(readiness) { return Math.max(1, ceil((Number(readiness) || 0) / REACTION.readinessDivisor)); } export function reactionBaseFrom(dex, readiness) { return REACTION.laneMultiplier * Math.max(reflexRating(dex), awarenessRating(readiness)); } /** Encumbrance: whole steps drive MOV and skills, the continuous figure drives Reaction. */ /** * Bleeding, both halves of it. * * The table printed a rate and a way to stop it, and the game implemented neither: * nothing ticked the loss during play, and First Aid healed hit points without ever * clearing the wound. So bleeding was inert at the table and unstoppable in * simulation — the same rule failing in both directions at once. * * `perRound` is the loss at the end of each round. `stop` lists what ends it; ANY * one entry is enough. A stop entry with a `difficulty` requires the roll to have * been made at that difficulty or harder, which is what "a Difficult First Aid" * means; entries without one accept any difficulty. */ export const BLEED = { // The table says "a successful First Aid" for bleeding. Medicine is added because // the printed rule left a surgeon unable to stop the lesser wound they could treat. bleeding: { perRound: 1, stop: [{ family: "first_aid" }, { family: "medicine" }] }, deepBleeding: { perRound: 2, stop: [{ family: "first_aid", difficulty: "difficult" }, { family: "medicine" }] } }; const DIFFICULTY_RANK = { easy: 0, average: 1, difficult: 2, formidable: 3 }; /** Does this successful roll stop this kind of bleeding? */ export function bleedStoppedBy(key, { family = "", difficulty = "average" } = {}) { const spec = BLEED[key]; if (!spec) return false; return spec.stop.some(s => s.family === family && (s.difficulty === undefined || (DIFFICULTY_RANK[difficulty] ?? 1) >= (DIFFICULTY_RANK[s.difficulty] ?? 1))); } /** Total loss per round from every bleeding wound an actor carries. */ export function bleedRateFor(wounds = []) { return (Array.isArray(wounds) ? wounds : []) .reduce((n, w) => n + (BLEED[w?.key]?.perRound ?? 0), 0); } /** * Major Wound consequences, as penalties rather than prose. * * The table used to be rolled, announced and recorded, and then change nothing: a * `manipulation` wound whose own text reads "−30% Manipulation" applied zero, * `concussed` ("−20% to all rolls") applied zero, and `mobility` ("MOV halved") * left MOV untouched. Only crippling and catastrophic did anything, through the * separate isDown check. This turns each row into numbers the roll pipeline reads. * * Takes the recorded `system.wounds` array; returns penalties to FOLD INTO the * location-derived wound state, never to replace it. Wounds stack: two concussions * are −40%, and a Major Wound stacks with a shattered limb, because both are true. * * `all` is applied to every category by woundPenaltyFor. The per-category numbers * are additional and specific. * * Variants: `sensory` and `broken` each split two ways on 1D2, chosen when the * wound is rolled and stored on the entry. An absent variant falls back to the * harsher reading, so a missing field never quietly costs the target nothing. */ export function woundEffectsFor(wounds = []) { const eff = { all: 0, manipulation: 0, physical: 0, perception: 0, communication: 0, movFactor: 1, movFloor: 0, bleed: 0, defenceOnly: false, unconscious: false, lostAction: false }; for (const w of Array.isArray(wounds) ? wounds : []) { switch (w?.key) { case "concussed": eff.all -= 20; break; case "winded": eff.physical -= 20; eff.lostAction = true; break; case "bleeding": case "deepBleeding": eff.bleed += BLEED[w.key].perRound; break; case "manipulation": eff.manipulation -= 30; break; case "mobility": eff.physical -= 30; eff.movFactor *= 0.5; break; case "sensory": // sight also blinds you to what you are shooting at; hearing does not eff.perception -= 40; if (w.variant === "hearing") eff.communication -= 40; else eff.manipulation -= 40; break; case "broken": if (w.variant === "mobility") { eff.physical -= 30; eff.movFactor *= 0.5; } else eff.manipulation -= 30; break; case "crippling": eff.defenceOnly = true; eff.movFloor = 1; eff.movFactor = 0; break; case "catastrophic": eff.unconscious = true; eff.defenceOnly = true; eff.movFloor = 1; eff.movFactor = 0; break; } } return eff; } /** * Load on the reaction, exact. One point per 10 kg over free carry, with no * staircase — this is the granular rule R-23 asked for, that no weight is ever * free and no single kilogramme ever costs a whole point. It replaces the old * three-value band, which charged a full point for the first gram over and then * nothing more until 10 kg. * * Rounded to one decimal because the value is printed on a paper sheet and added * to a die by hand. */ export function reactionLoadFrom(srLoad) { // Rounded UP, not to nearest. To nearest would make the first half-kilogramme // free, and the rule is that no weight is ever free. A tenth of a point is the // smallest unit worth writing on a paper sheet, so that is the floor of the cost. return Math.ceil((Number(srLoad) || 0) * 10) / 10; } /** * The number actually printed on the character sheet: the lane base less load. * Floored at 1 — R-56, the critical-path invariant. No amount of kit may reduce * an agent to acting never; it may only ever reduce them to acting last. */ export function reactionPrintedFrom(base, srLoad) { const b = Number(base) || 0; return Math.max(1, Math.round((b - reactionLoadFrom(srLoad)) * 10) / 10); } export function encumbranceFrom(carriedKg, freeCarryKg) { const over = Math.max(0, (Number(carriedKg) || 0) - (Number(freeCarryKg) || 0)); const steps = Math.floor(over / 10); const rounded = Math.round(over * 10) / 10; // `over` and `overKg` are the same number under both names: the engine reads one, // the guard's spot-checks the other, and a rename in either used to silently // zero the encumbrance penalty. Kept deliberately, not by accident. return { over: rounded, overKg: rounded, steps, movPenalty: steps, skillPenalty: steps * 5, srLoad: over / 10 }; } /* ============================================================ * Rule TABLES. Pure data, no Foundry, so the engine, the pack * builder and the rules journal all read the same rows. The * journal is generated from these — a number cannot be right in * the code and wrong in the book. * ============================================================ */ /** The situational ladder. Applied after difficulty, capped at +-40 in total. */ export const CIRCUMSTANCE = [ { id: "overwhelming", mod: 30, label: "RINGBRP.Circ.Overwhelming" }, { id: "favourable", mod: 20, label: "RINGBRP.Circ.Favourable" }, { id: "slightGood", mod: 10, label: "RINGBRP.Circ.SlightGood" }, { id: "neutral", mod: 0, label: "RINGBRP.Circ.Neutral" }, { id: "slightBad", mod: -10, label: "RINGBRP.Circ.SlightBad" }, { id: "unfavourable", mod: -20, label: "RINGBRP.Circ.Unfavourable" }, { id: "severe", mod: -30, label: "RINGBRP.Circ.Severe" } ]; /** Range. `mod` null means the shot cannot be attempted at all. */ export const RANGE_LADDER = [ { id: "short", mod: 0, upto: r => r.short }, { id: "medium", mod: -10, upto: r => r.medium }, { id: "long", mod: -30, upto: r => r.long }, { id: "extreme", mod: -50, upto: r => r.long * 2 }, { id: "beyond", mod: null, upto: () => Infinity } ]; export function rangeBandFrom(distance, ranges) { const d = Number(distance) || 0; const r = { short: Number(ranges?.short) || 0, medium: Number(ranges?.medium) || 0, long: Number(ranges?.long) || 0 }; if (!r.short && !r.medium && !r.long) return { band: "short", mod: 0 }; for (const step of RANGE_LADDER) { const limit = step.upto(r); if (d <= limit) return { band: step.id, mod: step.mod }; } return { band: "beyond", mod: null }; } /** 1D10 on a Major Wound. Consequences are in woundEffectsFor. */ export const MAJOR_WOUND_TABLE = [ { min: 1, max: 1, key: "concussed" }, { min: 2, max: 2, key: "winded" }, { min: 3, max: 3, key: "bleeding" }, { min: 4, max: 4, key: "manipulation" }, { min: 5, max: 5, key: "mobility" }, { min: 6, max: 6, key: "deepBleeding" }, { min: 7, max: 7, key: "sensory" }, { min: 8, max: 8, key: "broken" }, { min: 9, max: 9, key: "crippling" }, { min: 10, max: 10, key: "catastrophic" } ]; /** Coherence. 10 is anchored, 0 is displaced and permanent. */ export const COHERENCE_BANDS = [ { min: 8, id: "anchored", label: "RINGBRP.Coh.Anchored", all: 0, comm: 0, note: "RINGBRP.Coh.AnchoredNote" }, { min: 5, id: "loose", label: "RINGBRP.Coh.Loose", all: 0, comm: -5, note: "RINGBRP.Coh.LooseNote" }, { min: 3, id: "adrift", label: "RINGBRP.Coh.Adrift", all: -5, comm: -10, note: "RINGBRP.Coh.AdriftNote" }, { min: 1, id: "unmoored", label: "RINGBRP.Coh.Unmoored", all: -10, comm: -20, note: "RINGBRP.Coh.UnmooredNote", returnPenalty: -30 }, { min: 0, id: "displaced", label: "RINGBRP.Coh.Displaced", all: -20, comm: -30, note: "RINGBRP.Coh.DisplacedNote" } ]; export const COHERENCE_COSTS = { fumbleOnCrossing: { cost: 1, label: "RINGBRP.Coh.Cost.Fumble" }, witnessedParadox: { cost: 1, label: "RINGBRP.Coh.Cost.Paradox" }, usedCrossingTech: { cost: 1, label: "RINGBRP.Coh.Cost.Tech" }, brokeLocalLaw: { cost: 1, label: "RINGBRP.Coh.Cost.Local" }, metYourself: { cost: 2, label: "RINGBRP.Coh.Cost.Self" }, killedADuplicate: { cost: 3, label: "RINGBRP.Coh.Cost.Duplicate" }, overstayed: { cost: 1, label: "RINGBRP.Coh.Cost.Overstay" } }; export const COHERENCE_RECOVERY = { debrief: 1, downtimeWeek: 1, anchorObject: 1 }; export function coherenceBandFrom(value) { const v = Math.max(0, Number(value) || 0); return COHERENCE_BANDS.find(b => v >= b.min) ?? COHERENCE_BANDS.at(-1); } /** * What a lost LOCATION does, by anatomical kind. * * Legs and arms were applied; everything else was localised prose that changed no * number. A destroyed head said "unconscious immediately" and left the agent * standing; a Vesh's opened sensory ridge promised -30% Perception and gave none; * and the Cadence rule — "each disabled body costs the whole person -10% to * everything" — did nothing whatever, which is the entire mechanical identity of * one of the three playable species. * * Same shape as woundEffectsFor so the two fold together additively: both are true * about the same body at the same time. */ export function locationEffectsFor(locations = []) { const eff = { all: 0, manipulation: 0, physical: 0, perception: 0, movFactor: 1, unconscious: false, prone: false, legs: 0, legsDestroyed: 0, arms: 0, vital: false, unitsLost: 0 }; for (const l of Array.isArray(locations) ? locations : []) { if (!l?.disabled) continue; switch (l.kind) { case "leg": case "brace": eff.legs++; if (l.destroyed) eff.legsDestroyed++; eff.physical -= 30; break; case "arm": case "grasp": case "manipulator": eff.arms++; eff.manipulation -= 30; break; case "ridge": eff.perception -= 30; eff.vital = true; break; case "head": eff.unconscious = true; // "unconscious immediately" eff.vital = true; break; case "vital": eff.vital = true; // knocked down; the Stamina roll is the GM's call break; case "unit": eff.unitsLost++; eff.all -= 10; // each body lost costs the WHOLE person break; } } // One leg gone is a limp; both, or a destroyed one, is the floor. eff.movFactor = eff.legsDestroyed ? 0.1 : (eff.legs ? 0.5 : 1); eff.prone = eff.legsDestroyed > 0 || eff.vital; return eff; } /** * Which skill categories a heavy pack actually impedes. * * `encumbranceFrom` has computed a skillPenalty since R-23 and NOTHING ever applied * it — the comment in skillRoll even says the penalty belongs there. Carrying too * much is scoped to the physical categories rather than every skill, because a * rucksack does not make you worse at Knowledge, and a rule that says otherwise is * a rule nobody will use. */ export const ENCUMBERED_CATEGORIES = ["phys", "melee", "ranged", "manip"]; export function encumbrancePenaltyFor(skillPenalty, categoryId) { if (!ENCUMBERED_CATEGORIES.includes(categoryId)) return 0; return -Math.abs(Number(skillPenalty) || 0); } /** * What you can carry before it costs you anything. * * Was STR alone, in kilogrammes, which ignored how big you are: Rakhi at STR 18 and * MAS 19 — a large, powerful woman — had a free carry of 18 kg and was twenty * kilos over with a soldier's ordinary fighting load. A rule that makes a realistic * pack an emergency is a rule the table will quietly drop. * * STR + MAS is the same pair that sets the damage modifier, so one physical axis * decides both how hard you hit and how much you can shoulder. Rakhi's free carry * becomes 37 kg and her 38 kg load puts her exactly 1 kg over — heavy, deliberate, * and worth 0.1 of a point rather than two. */ export function freeCarryFor(str, siz) { return Math.max(1, (Number(str) || 0) + (Number(siz) || 0)); } /** * Defending. The book says "one offensive action, plus as many defensive reactions * as you like at an escalating penalty" — and the escalation was the only part that * existed anywhere: `defencesThisRound` was stored on every actor, reset by the * round hook, and incremented by nothing, because no defence was ever rolled. * * The first defence in a round is free. Each one after it is cumulatively harder, * so standing in front of three people is possible and stupid. */ export const DEFENCE_STEP = -30; export function defencePenaltyFor(defencesUsed = 0) { return DEFENCE_STEP * Math.max(0, Number(defencesUsed) || 0); } /** * THE WAY HOME, AND WHAT SPENDS IT. * * Two play-throughs in a row were decided at the extraction, and both times the * mechanism was the GM inventing one. The generator writes a "window closes" clock * whose stated cost is *the way home* and whose steps are "the margin narrows", "the * return is Difficult", "the return is aimed at −30%" — and nothing in the system * ever read it, so a failed extraction had no consequence of its own and whoever was * running it had to improvise. Improvising at that moment is how a phase that should * cost you the trip home ends up costing two lives. * * Extraction now spends the clock instead. Getting out badly does not hurt anybody * on the causeway; it makes the CROSSING harder, which is where this setting says the * cost of a case belongs and is a cost the players can see coming. */ export const RETURN_CLOCK = { max: 3, steps: [0, -10, -20, -30], difficultFrom: 2 // at two steps the return is Difficult as well }; export function returnPenaltyFor(steps) { const n = Math.max(0, Math.min(RETURN_CLOCK.max, Number(steps) || 0)); return RETURN_CLOCK.steps[n]; } export function returnIsDifficult(steps) { return (Number(steps) || 0) >= RETURN_CLOCK.difficultFrom; } /** What an extraction result does to the way home. Nothing else takes damage. */ export function extractionCostFor(level) { switch (level) { case "critical": return { completes: true, clock: -1 }; // a step BACK case "special": return { completes: true, clock: 0 }; case "success": return { completes: true, clock: 0 }; case "failure": return { completes: true, clock: +1 }; default: return { completes: false, clock: +1 }; // fumble } } /* -------------------------------------------- */ /* Dying, and death */ /* -------------------------------------------- */ /* -------------------------------------------- */ /* Space combat: STATIONS */ /* -------------------------------------------- */ /** * SPACE COMBAT WITHOUT A PILOT MINIGAME. * * The usual failure is that one player flies and everybody else watches. This does * not do that. A craft has STATIONS; every agent takes one, every station uses a * skill somebody already has, and the round is one roll each resolved in the normal * reaction order. There is no vector arithmetic and no map — range is a BAND, the * same ladder the rest of the game uses. * * The craft has three things that can be spent: HULL (what it can take), SIGNATURE * (how findable it is) and HEAT (what it has spent making itself effective). Heat is * the pressure: almost every good outcome adds some, and a craft at maximum Heat is * blind, loud and slow until it sheds it. That is the interesting decision and it * belongs to whoever is on Engines, not to the pilot. */ export const SPACE_BANDS = ["docked", "knife", "close", "standoff", "distant", "gone"]; export function spaceBandRank(band) { const i = SPACE_BANDS.indexOf(String(band)); return i < 0 ? 2 : i; } /** Closing and opening move ONE band a round. Nothing crosses the table at once. */ export function spaceBandAfter(band, delta) { const n = Math.max(0, Math.min(SPACE_BANDS.length - 1, spaceBandRank(band) + (Number(delta) || 0))); return SPACE_BANDS[n]; } /** * The stations. Each names the skill it uses, what a success does, and what it costs * the craft in Heat — so a GM can run the whole subsystem off this table. */ // `heat` is what the station costs when it ACTS. Only Gunnery is charged for // trying, because the shot leaves whether or not it lands — the same honesty as the // burst rule. Playtested: with every station heating, one engineer could not keep up // and Heat simply climbed to the ceiling by round four, which is a countdown rather // than a decision. Looking at something is now free; shooting and running are not. export const STATIONS = { helm: { id: "helm", skill: "pilot", label: "RINGBRP.Station.helm", effect: "band", heat: 1 }, guns: { id: "guns", skill: "energy_weapon", label: "RINGBRP.Station.guns", effect: "damage", heat: 2, heatOnFailure: true }, sensors: { id: "sensors", skill: "computer_use", label: "RINGBRP.Station.sensors", effect: "mark", heat: 0 }, engines: { id: "engines", skill: "engineering", label: "RINGBRP.Station.engines", effect: "heat", heat: -3 }, damage: { id: "damage", skill: "repair", label: "RINGBRP.Station.damage", effect: "hull", heat: 0 }, signals: { id: "signals", skill: "persuade", label: "RINGBRP.Station.signals", effect: "talk", heat: 0 }, medical: { id: "medical", skill: "first_aid", label: "RINGBRP.Station.medical", effect: "crew", heat: 0 }, anchor: { id: "anchor", skill: "transposition", label: "RINGBRP.Station.anchor", effect: "escape", heat: 2 } }; /** Heat is the whole tension. At the ceiling the craft is blind, loud and slow. */ export const HEAT = { max: 10, blindFrom: 6, loudFrom: 4, slowFrom: 8 }; export function heatEffectsFor(heat) { const h = Math.max(0, Number(heat) || 0); return { heat: h, loud: h >= HEAT.loudFrom, // signature climbs; everything can see you blind: h >= HEAT.blindFrom, // sensors and gunnery at a penalty slow: h >= HEAT.slowFrom, // helm cannot change band at all penalty: h >= HEAT.blindFrom ? -20 : (h >= HEAT.loudFrom ? -10 : 0) }; } /** * Gunnery at range. Close is easy and dangerous for everybody; distant is a poor * bet. Knife range is where a small craft wins and where one bad round ends it. */ export const SPACE_RANGE_MOD = { docked: 0, knife: 20, close: 10, standoff: -10, distant: -30, gone: null }; export function spaceRangeModFor(band) { const m = SPACE_RANGE_MOD[band]; return m === undefined ? -10 : m; } /** A hit from a station roll. Marked targets are the reason Sensors has a chair. */ export function spaceHitFor(level, { marked = false } = {}) { const base = { critical: 3, special: 2, success: 1, failure: 0, fumble: 0 }[level] ?? 0; return base > 0 && marked ? base + 1 : base; } /** What a craft is, in the four numbers that matter. */ export function craftConditionFor({ hull = 0, hullMax = 1, heat = 0 } = {}) { const frac = (Number(hull) || 0) / Math.max(1, Number(hullMax) || 1); const h = heatEffectsFor(heat); if (frac <= 0) return { state: "lost", ...h }; if (frac <= 0.25) return { state: "failing", ...h }; if (frac <= 0.6) return { state: "hurt", ...h }; return { state: "sound", ...h }; } /* -------------------------------------------- */ /* Automatic fire, and two weapons */ /* -------------------------------------------- */ /** * AUTOMATIC FIRE. * * Not a stream of separate attacks — that is four rolls and a stalled table. One * roll, and the SUCCESS LEVEL decides how much of the burst arrived, which reuses the * band the whole system already turns on. Rounds are spent whether or not they hit, * because that is the honest part of automatic fire and the part players forget. * * A burst is harder to place than a single shot and easier to place SOMETHING with, * so the modifier is negative and the payoff is hits. */ export const BURST = { short: { id: "short", rounds: 3, mod: -10, label: "RINGBRP.Burst.short" }, long: { id: "long", rounds: 6, mod: -20, label: "RINGBRP.Burst.long" }, full: { id: "full", rounds: 10, mod: -30, label: "RINGBRP.Burst.full" } }; /** How many of a burst connect, by the band the one roll produced. */ // Hits are CAPPED, not proportional. Ten independent damage rolls, ten hit locations // and ten Major Wound checks off one success is not a burst, it is a firing squad — // and it made full auto the correct answer to every hard target. export const BURST_HITS = { short: [1, 2, 3], long: [2, 4, 5], full: [3, 5, 7] }; export function burstHitsFor(level, size) { const cap = BURST_HITS[size] ?? BURST_HITS.short; switch (level) { case "critical": return cap[2]; case "special": return cap[1]; case "success": return cap[0]; default: return 0; // failure and fumble; see BURST_FUMBLE } } /** However many rounds connect, a burst is ONE wound event for the Major Wound check. */ export const BURST_ONE_MAJOR_WOUND = true; /** Every round leaves the weapon regardless. This is the cost of the option. */ export function burstRoundsSpent(size, loaded) { const b = BURST[size] ?? BURST.short; return Math.min(Number(loaded) || 0, b.rounds); } /** A fumbled burst is the interesting one: it is a jam, or it is worse. */ export const BURST_FUMBLE = ["RINGBRP.Burst.Jam", "RINGBRP.Burst.Empty", "RINGBRP.Burst.Wild"]; /** * TWO WEAPONS. * * The off hand is a real second attack and is meant to cost something. Untrained it * is a heavy penalty on BOTH hands, because a person waving two knives is worse with * each of them. The Ambidextrous talent removes the main-hand penalty entirely and * halves the off-hand one; nothing removes the off-hand penalty altogether. * * The off-hand weapon must be lighter than the main hand, which is the rule that * stops two poleaxes. */ // Reviewed and corrected: the trained profile was 0/−20, which is very nearly a free // second attack and therefore a compulsory pick for anyone who can hold two things. // Ambidextrous now buys CONSISTENCY, not a free action — both hands at −20 — which is // still plainly worth having and is no longer the only sane build. export const TWO_WEAPON = { main: -20, off: -40, trainedMain: -20, trainedOff: -20, offHandMaxKg: 2.0 }; export function twoWeaponPenaltyFor({ trained = false } = {}) { return trained ? { main: TWO_WEAPON.trainedMain, off: TWO_WEAPON.trainedOff } : { main: TWO_WEAPON.main, off: TWO_WEAPON.off }; } /** Can this actually be paired? The off hand takes the lighter thing, and not much. */ export function canPairWeapons(mainKg, offKg) { const m = Number(mainKg) || 0, o = Number(offKg) || 0; if (o > TWO_WEAPON.offHandMaxKg) return false; return o <= m; } /* -------------------------------------------- */ /* The ways the world kills you */ /* -------------------------------------------- */ /** * FALLING. * * BRP-standard: a die of damage per three metres, to a random location, and armour * does not help because the ground is not a weapon. A fall onto something soft or a * controlled landing halves the distance BEFORE the dice are counted, which is where * Hard Landing and a descent harness earn their keep. */ export const FALL = { metresPerDie: 3, die: 6, maxDice: 20 }; export function fallDamageFor(metres, { soft = false, controlled = false } = {}) { // Halving is not cumulative. Soft ground and a controlled descent are the same // mitigation twice over, and stacking them turned a lethal drop into a stumble. let m = Math.max(0, Number(metres) || 0); if (soft || controlled) m /= 2; const dice = Math.min(FALL.maxDice, Math.floor(m / FALL.metresPerDie)); return { dice, formula: dice > 0 ? `${dice}d${FALL.die}` : "0", ignoresArmour: true }; } /** * DROWNING, and anything else that stops you breathing. * * You hold on for CON rounds if you had warning and half that if you did not. After * that it is one point of damage per round and it does not stop, because the water * does not get bored. Armour is irrelevant and so is a Major Wound: this is the one * track that runs straight to zero. */ export const ASPHYXIA = { perRound: 1, unpreparedFraction: 0.5 }; export function breathRoundsFor(con, { prepared = true } = {}) { const c = Math.max(1, Number(con) || 1); return Math.max(1, Math.floor(prepared ? c : c * ASPHYXIA.unpreparedFraction)); } /** * FIRE. * * Fire is not one hit, it is a condition with a size, and the size is what matters. * Armour protects on the round you are set alight and never again — sealed suits * excepted, which is the entire reason to own one. */ // `armour` means "worn protection helps on the round you CATCH". It never helps // afterwards, because by then the fire is inside it. Only a sealed suit is different // and that is the whole reason to sign one out. export const FIRE = [ { id: "spark", label: "RINGBRP.Fire.spark", damage: "1d3", rounds: 1, armour: true }, { id: "clothes", label: "RINGBRP.Fire.clothes", damage: "1d6", rounds: 3, armour: true }, { id: "pool", label: "RINGBRP.Fire.pool", damage: "2d6", rounds: 4, armour: true }, { id: "engulf", label: "RINGBRP.Fire.engulf", damage: "3d6", rounds: 6, armour: true } ]; export function fireBandFor(id) { return FIRE.find(f => f.id === id) ?? FIRE[1]; } /** * EXPLOSIVES. * * One roll of damage at the centre, halved at each band outward, and cover is worth * more than armour — which is the lesson every agency learns and writes down. * A charge placed deliberately, with time, hits the centre band automatically. */ export const BLAST = [ { id: "contact", label: "RINGBRP.Blast.contact", share: 1, armour: 0.5 }, { id: "near", label: "RINGBRP.Blast.near", share: 0.5, armour: 1 }, { id: "far", label: "RINGBRP.Blast.far", share: 0.25, armour: 1 } ]; export function blastBandFor(metres, radius) { const m = Math.max(0, Number(metres) || 0), r = Math.max(1, Number(radius) || 1); if (m <= r / 2) return BLAST[0]; if (m <= r) return BLAST[1]; if (m <= r * 2) return BLAST[2]; return null; // outside it entirely } /** * ONE formula, floored ONCE. Rolled x range share x cover, rounded down, and only * then does armour come off. Flooring at each step changed the answer depending on * the order somebody happened to call these in. */ export function blastShareFor(rolled, band, cover = "none") { if (!band) return 0; const f = BLAST_COVER[cover] ?? 1; return Math.max(0, Math.floor((Number(rolled) || 0) * band.share * f)); } /** Cover is the answer to a blast. Armour is only ever half of one. */ export const BLAST_COVER = { none: 1, partial: 0.5, solid: 0.25, sealed: 0 }; export function blastAfterCover(damage, cover = "none") { const f = BLAST_COVER[cover] ?? 1; return Math.max(0, Math.floor((Number(damage) || 0) * f)); } /** * WHAT DESTROYING A LOCATION ACTUALLY DOES. * * The printed effect for a destroyed head reads "For anything that keeps its brain * there, this is death" — and the code set `unconscious` and stopped. An agent could * have every location on their body destroyed, head included, and the sheet would * report them as DOWN. Destruction of something you cannot live without is not a * penalty, and this is the rule that says so. * * DISABLED is not destroyed: a disabled head is a concussion and stays a concussion. * Only DESTROYED reaches here. */ export function destructionOutcomeFor(kind, destroyed = false) { if (!destroyed) return "none"; switch (kind) { case "head": return "dead"; // whatever keeps its brain there no longer does case "ridge": case "vital": return "dying"; default: return "none"; // limbs are ruinous, not fatal } } /** * WHAT HAPPENS AT ZERO. * * Nothing did. An agent reduced to 0 hit points was marked down and then lay there * indefinitely: bleeding deliberately stops at 0, no rule advanced anything, and the * only mention of death in the whole system was flavour text on a destroyed head. * A game with hit locations, Major Wounds and bleeding had no way for anyone to die. * * Hit points are NOT taken below zero — every damage path clamps there, and unpicking * that would touch everything. Instead, zero starts a clock: * * above 2 you are up * 2 or below DOWN — out of the fight, still conscious, still yours * 0 DYING — unconscious, no actions, no defences, and a count begins * count met DEAD * * The count is the agent's own Major Wound threshold, so a solid Custodian has longer * on the floor than a slight one, which is the same number already deciding how much * damage they can take standing up. First Aid or Medicine STABILISES: the count stops * and they stay at 0, unconscious, until somebody heals them above it. */ export const DYING = { perRound: 1, minCount: 2, stabiliseWith: ["first_aid", "medicine"] }; /** How many rounds this agent has at zero before the count is met. */ export function dyingLimitFor(majorWound) { return Math.max(DYING.minCount, Number(majorWound) || 0); } /** The four states, from one place, so nothing has to re-derive them. */ export function conditionFor({ hp = 0, dyingRounds = 0, majorWound = 0, stabilised = false, unconscious = false } = {}) { const h = Number(hp) || 0; if (h <= 0) { if (!stabilised && (Number(dyingRounds) || 0) >= dyingLimitFor(majorWound)) return "dead"; return "dying"; } if (h <= 2 || unconscious) return "down"; return "up"; } /** * THE LAST ENTRY. * * A Custodian's death is a filing question, because everything here is. How well * filed they were when they died decides what the record ends up saying, and the * badly filed do not get recorded as dead at all — which is the department's * problem, and then somebody else's. */ export const LAST_ENTRY = [ { atLeast: 7, id: "recorded", label: "RINGBRP.Death.Entry.recorded" }, { atLeast: 4, id: "queried", label: "RINGBRP.Death.Entry.queried" }, { atLeast: 1, id: "unclosed", label: "RINGBRP.Death.Entry.unclosed" }, { atLeast: 0, id: "transferred", label: "RINGBRP.Death.Entry.transferred" } ]; export function lastEntryFor(coherence) { const c = Number(coherence) || 0; return LAST_ENTRY.find(e => c >= e.atLeast) ?? LAST_ENTRY[LAST_ENTRY.length - 1]; } /** * HOW HARD A CASE PHASE IS. * * The old ramp made every phase after the first Difficult — which HALVES the lead's * skill — and then stacked a cumulative −5% on top. Measured across two played * cases, a realistic lead of 40–55% (Tradecraft is a support skill on most postings * that train it at all) faced 20% → 15% → 10% → 5% → 1%, and both cases were decided * by Resources running out rather than by anything the players did. * * The two pressures are now separated, because they were never the same idea: * * DIFFICULTY is about the phase. Only Containment is Difficult — the step the case * is actually about — and it does not also take the ramp. It is hard because it is * containment, not because you are tired. * * The MODIFIER is about attrition. It accumulates as the case wears on, and it * stops accumulating, because a fifth phase is not four times worse than a second. */ export const PHASE_RAMP = { step: -5, cap: -15, hardKinds: ["containment"] }; export function phaseRampFor(index, kindId = "") { const hard = PHASE_RAMP.hardKinds.includes(String(kindId)); return { difficulty: hard ? "difficult" : "average", // A hard phase carries its difficulty INSTEAD of the ramp, never both. modifier: hard ? 0 : Math.max(PHASE_RAMP.cap, PHASE_RAMP.step * (Number(index) || 0)) }; } /* -------------------------------------------- */ /* Borrowed Authority */ /* -------------------------------------------- */ /** * ACCESS IS A RECORD. * * There is no hacking skill and no network to enter. What an agent has is a named * credential belonging to somebody real — a facilities contractor, a locum, a * supplier's engineer — with a scope of things that person could plausibly do, a * Trust that erodes as it is used, and a red flag or two describing how the real * owner behaves. Inside its scope the credential simply works. Outside it, you are * asking a system to believe something about a person, and that is a roll. * * The point of the design in a game about reality keeping a record of what belongs * where: an Authority IS a record, and Coherence can widen it, because reality can * be made to remember that the person held that duty. What it costs is that the * agent's own record starts merging with the one they borrowed. */ /** What a credential could conceivably be asked to do, in order. */ export const SCOPE_TIERS = ["nothing", "read", "routine", "privileged", "administrative"]; export function scopeRank(tier) { const i = SCOPE_TIERS.indexOf(String(tier)); return i < 0 ? 0 : i; } /** * How an action stands against a credential's scope. This is the rule that stops * "I hack anything": two tiers beyond the credential is not hard, it is impossible, * and no percentage on any sheet changes that. * * at or below scope -> it simply works. No roll. 1 Trust. * exactly one above -> a stretch. Roll for it. * two or more above -> impossible. The identity was never able to do this. */ export function scopeStanceFor(authorityScope, actionTier) { const gap = scopeRank(actionTier) - scopeRank(authorityScope); if (gap <= 0) return "in-scope"; if (gap === 1) return "stretch"; return "impossible"; } /** Behaving unlike the person you are claiming to be is what gets noticed. */ export const RED_FLAG_STEP = -20; export function scrutinyFor(flagsTripped = 0) { return RED_FLAG_STEP * Math.max(0, Number(flagsTripped) || 0); } /** * What a stretched use of a credential does to it. Mirrors the defence ladder: the * band is the whole answer, and a fumble is not merely a failure — it burns the * credential and puts the person it belongs to in the department's way. */ export function authorityOutcomeFor(level) { switch (level) { case "critical": return { works: true, trustDelta: +1, burned: false }; case "special": return { works: true, trustDelta: 0, burned: false }; case "success": return { works: true, trustDelta: -1, burned: false }; case "fumble": return { works: false, trustDelta: -99, burned: true }; default: return { works: false, trustDelta: -1, burned: false }; } } /** Trust never rises above what the dossier bought, and stops at nothing. */ export const TRUST_MAX = 4; export function trustAfter(current, delta, max = TRUST_MAX) { const t = (Number(current) || 0) + (Number(delta) || 0); return Math.max(0, Math.min(Number(max) || TRUST_MAX, t)); } /** * THE DOSSIER — how a credential is obtained. * * Not a second subsystem and not a stack of flat bonuses: a dossier item does not * make the roll easier, it makes the credential better. Each item is a separate * scene using a different skill, and at most one may be Computer Use, so assembling * access is something the whole team does rather than one specialist. * * Trust starts at 1 and rises by one per item. Scope starts at "read" and a single * item — the one that establishes what the person is actually FOR — may raise it. */ export const DOSSIER = { maxItems: 3, baseTrust: 1, baseScope: "read", maxComputerUse: 1 }; export function dossierTrustFor(itemCount) { return Math.max(1, Math.min(TRUST_MAX, DOSSIER.baseTrust + (Number(itemCount) || 0))); } /** A dossier that establishes remit widens what the credential covers, once. */ export function dossierScopeFor(itemCount, establishesRemit = false) { const base = scopeRank(DOSSIER.baseScope); const up = (establishesRemit && (Number(itemCount) || 0) >= 2) ? 1 : 0; return SCOPE_TIERS[Math.min(SCOPE_TIERS.length - 1, base + up)]; } /** * Coherence widens a scope by making reality remember that the person held that * duty. The cost rises with how far you have already pushed the record — the first * lie is cheap and the fourth is not. */ export function widenScopeCostFor(timesWidened = 0) { return 1 + Math.max(0, Number(timesWidened) || 0); } /** * The horror, with a number on it. Every time an Authority is pushed past what its * Trust can carry, the agent's file and the borrowed one converge one step. This is * what the subsystem is actually about and it is deliberately not reversible by * spending anything. */ export const MERGE_STAGES = [ "RINGBRP.Authority.Merge.0", "RINGBRP.Authority.Merge.1", "RINGBRP.Authority.Merge.2", "RINGBRP.Authority.Merge.3", "RINGBRP.Authority.Merge.4" ]; export function mergeStageFor(steps) { const n = Math.max(0, Math.min(MERGE_STAGES.length - 1, Number(steps) || 0)); return { step: n, label: MERGE_STAGES[n], terminal: n >= MERGE_STAGES.length - 1 }; } /** * Fists are a fallback, not a peer of the weapon you are carrying. * * A generated adversary was given Brawl from the same range as its weapon skills, * so an Armed thug was exactly as good with its hands as with the pistol on its * hip — and any sensible target-picker chose the fists, which then bounced off * armour 2 for 1d3. Brawl is now a fraction of the trained rating, never below the * skill's printed base. * * This does NOT apply to a creature that ATTACKS with Brawl: the anomalous grasp is * a trained attack and keeps its full rating. The caller decides which case it is. */ export const BRAWL_FALLBACK = { fraction: 0.6, floor: 25 }; export function brawlFallbackFor(trainedRating) { const r = Math.round((Number(trainedRating) || 0) * BRAWL_FALLBACK.fraction); return Math.max(BRAWL_FALLBACK.floor, r); } /** * A skill whose base is written as a formula rather than a flat number. * * Dodge is the only one, and its base is DEX×2 — which nothing in the system ever * worked out. `base.mode` was "formula", `base.value` stayed 0, and every reader * took `system.value` at face value, so an agent whose posting did not train Dodge * had Dodge 0%: the game's universal defence, unavailable to everybody who had not * separately bought it, and missing its floor even for those who had. * * Deliberately not eval(). Accepts `char`, `char*n`, `char+n` and `char*n+m`. */ export function skillBaseFrom(formula, characteristics = {}) { const m = /^\s*([a-z]+)\s*(?:\*\s*(\d+))?\s*(?:\+\s*(\d+))?\s*$/i.exec(String(formula || "")); if (!m) return 0; const stat = Number(characteristics?.[m[1].toLowerCase()]?.value ?? characteristics?.[m[1].toLowerCase()]) || 0; return Math.max(0, stat * (m[2] ? Number(m[2]) : 1) + (m[3] ? Number(m[3]) : 0)); } /** * The five outcome bands, worst to best. Exported so nothing has to re-declare * the order to compare two rolls against each other. */ export const LEVEL_LADDER = ["fumble", "failure", "success", "special", "critical"]; /** Where a level sits on the ladder. Anything unknown reads as a failure. */ export function levelRank(level) { const i = LEVEL_LADDER.indexOf(String(level)); return i < 0 ? 1 : i; } /** * What a defence actually does to the blow it answers. * * The old rule was binary: any successful dodge or parry stopped anything, so a * boarding-axe critical was cancelled outright by an ordinary parry and cost the * attacker nothing. That sat badly against every other thing the system says about * criticals — maximum damage, armour ignored entirely — and it made the best roll * in the game worth no more than the commonest one. * * Defences are now graded. A defence stops a blow of its own quality or worse * outright. Against something better it still helps, but only by as much as it was * outclassed by: the blow lands one step worse for every step the defence fell * short. So the attack degrades a critical to a special, a special to a success, * and a success to nothing — and only a critical defence stops a critical. * * landing rank = failure + (attack rank − defence rank) * * A failed defence does nothing at all and the blow lands at its own level. */ export function defenceOutcomeFor(attackLevel, defenceLevel) { const a = levelRank(attackLevel); const d = levelRank(defenceLevel); // A defence that did not succeed never reduces anything. if (d < levelRank("success")) { return { turnedAside: false, landsAt: LEVEL_LADDER[a], steps: 0 }; } const steps = a - d; if (steps <= 0) return { turnedAside: true, landsAt: null, steps: 0 }; return { turnedAside: false, landsAt: LEVEL_LADDER[levelRank("failure") + steps], steps }; } /** * You may not mix your defences in a round: having chosen to dodge, you dodge. * Returns the type you are allowed to use, given what you have already done. */ export function defenceTypeAllowed(lock, wanted) { return !lock || lock === wanted ? wanted : lock; } /** * THE QUIET ARTS. * * A power is not a spell. Crossing is an induced filing error — you persuade the * record you are somewhere else — and an Art is the same trick performed small and * on purpose: a misfiling you commit deliberately. So an Art costs the one thing * that misfiling always costs, which is Coherence, and needs no second resource. * * Nobody learns one. They arrive from crossings, in the people the crossing * changed, which is why the category the agency files them under is the same one * headed "the crossing left this in you". */ export const ARTS = { thread: { order: 1, coherence: 1, push: 2 }, echo: { order: 2, coherence: 1, push: 2 }, hollow: { order: 3, coherence: 1, push: 3 }, witness: { order: 4, coherence: 1, push: 2 }, unmake: { order: 5, coherence: 2, push: 4 }, ledger: { order: 6, coherence: 2, push: 4 } }; /** What using an Art costs, ordinarily and when pushed past its reach. */ export function powerCostFor(artId, { pushed = false } = {}) { const a = ARTS[artId]; if (!a) return pushed ? 2 : 1; return pushed ? a.push : a.coherence; } /** * An Art is governed by POW, and its starting rating is what the crossing left * behind rather than anything the agent trained: POW plus a little, capped low. * They are dangerous because of what they do, not because of what they roll. */ export function powerStartingRating(pow) { return Math.max(5, Math.min(45, (Number(pow) || 0) * 2)); } /** * COMING BACK. * * The return is a second crossing and it has to be aimed. R-56 holds: it is never * impossible, only dearer — a failure arrives you NEAR your target rather than at * it, and a fumble brings you back changed. * * "Changed" is where the Quiet Arts come from. Nobody learns one; they arrive in * people a crossing went wrong for, which is why the pool an agent draws from is * decided by how far they had already drifted when it happened. You do not choose * what you get, and the worse the state you were in, the worse the thing that * follows you home. */ export const CROSSING = { fumbleCoherence: 2, // a botched return costs this on top of everything else failCoherence: 1 }; /** Which Arts can arrive, given how anchored the agent was when it went wrong. */ export function crossingArtPoolFor(coherence) { const v = Number(coherence) || 0; if (v >= 5) return ["thread", "echo", "witness"]; // still mostly here if (v >= 3) return ["hollow", "thread", "echo"]; // adrift return ["unmake", "ledger", "hollow"]; // unmoored or worse } /** * Did the crossing leave something behind? A fumble always does. A failure only * does to an agent who was already coming apart. */ export function crossingGrantsArt(level, coherence) { if (level === "fumble") return true; if (level === "failure") return (Number(coherence) || 0) <= 2; return false; } /** What a return of this quality costs in Coherence. */ export function crossingCostFor(level) { if (level === "fumble") return CROSSING.fumbleCoherence; if (level === "failure") return CROSSING.failCoherence; return 0; }