/** * 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 */ /* -------------------------------------------- */ /** * 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; }