Files
RingBRP/rules.mjs
T
slaguru666andClaude Opus 5 5aaae186e0 A fumbled relief attempt puts a point on
Trying to steady somebody and making it worse is the most familiar thing on that
list, and until now it was the only outcome that cost nothing.

Done as a NEGATIVE relief rather than a second code path: panicReliefFor returns
-1 on a fumble, and easePanic already subtracts what it is given, so subtracting
a negative raises the track. One number, one direction, no branch that could
drift from the other. The clamp still holds — fumbling at 5 stays at 5.

The chat card had to be told, because otherwise it prints the word "Psychology"
over a number that has just gone UP and the table reads that as a bug rather than
as the rule. It now leads with a red FUMBLED tag.

Guarded three ways: a spot-check in check-rules for the -1 itself, and two
behaviour tests — one that the sign survives easePanic's subtraction and
actually raises the track, one that it cannot push past the top. A test on the
constant alone would have passed even if easePanic had healed on a fumble.

Verified live: 2 → fumble → 3 → success → 2 → special → 0, and a fumble at 5
staying at 5, with the cards reading correctly.

Version 1.1.2.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-17 23:25:46 +01:00

1584 lines
68 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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 };
/* ============================================================
PANIC — the short track
============================================================
Coherence is the long one: 0-10, campaign-scale, what it costs to keep looking once
you know what you are looking at, recovered at a debrief. Panic is the other half and
they must not be confused at the table:
Coherence what knowing has cost you weeks 0-10, down
Panic what is happening to you NOW minutes 0-5, up
Panic goes UP. A trigger gives a point with no roll — asking for a die to find out
whether you are frightened is a die that slows the scene down and tells the player
how to feel. The roll is to get RID of it, which is the interesting direction and the
only one worth a check.
One free point on purpose: at Panic 1 you are rattled and roll at no penalty. It costs
the table nothing and it means the first shock of a scene is colour rather than
arithmetic.
============================================================ */
export const PANIC_MAX = 5;
export const PANIC_BANDS = [
{ min: 5, id: "broken", label: "RINGBRP.Panic.Broken", all: -20, breaks: true,
note: "RINGBRP.Panic.BrokenNote" },
{ min: 4, id: "overwhelmed", label: "RINGBRP.Panic.Overwhelmed", all: -20, breaks: false,
note: "RINGBRP.Panic.OverwhelmedNote" },
{ min: 3, id: "losing", label: "RINGBRP.Panic.Losing", all: -10, breaks: false,
note: "RINGBRP.Panic.LosingNote" },
{ min: 2, id: "shaken", label: "RINGBRP.Panic.Shaken", all: -5, breaks: false,
note: "RINGBRP.Panic.ShakenNote" },
{ min: 1, id: "rattled", label: "RINGBRP.Panic.Rattled", all: 0, breaks: false,
note: "RINGBRP.Panic.RattledNote" },
{ min: 0, id: "steady", label: "RINGBRP.Panic.Steady", all: 0, breaks: false,
note: "RINGBRP.Panic.SteadyNote" }
];
/** What a point costs. One each, so a GM never has to weigh anything mid-scene. */
export const PANIC_TRIGGERS = {
wrongThing: { cost: 1, label: "RINGBRP.Panic.Trig.WrongThing" },
touchedByIt: { cost: 1, label: "RINGBRP.Panic.Trig.Touched" },
aBody: { cost: 1, label: "RINGBRP.Panic.Trig.Body" },
someoneYouKnew:{ cost: 2, label: "RINGBRP.Panic.Trig.Known" },
theLightsWent: { cost: 1, label: "RINGBRP.Panic.Trig.Dark" },
majorWound: { cost: 1, label: "RINGBRP.Panic.Trig.Wounded" },
itSaidYourName:{ cost: 2, label: "RINGBRP.Panic.Trig.Named" }
};
/**
* What takes a point off, and what it is rolled against.
*
* `self` steadies you; `other` steadies somebody else, which is the whole reason
* Psychology is worth carrying. `heard` reaches everyone who can hear it: naming a thing
* correctly is the department's actual work, and it is worth a mechanical reward.
* A special takes two.
*/
export const PANIC_RELIEF = {
steadyYourself: { skill: null, reach: "self", once: "scene",
label: "RINGBRP.Panic.Relief.Steady" },
religion: { skill: "religion", reach: "self", once: "scene",
label: "RINGBRP.Panic.Relief.Religion" },
psychology: { skill: "psychology", reach: "other", once: "scene",
label: "RINGBRP.Panic.Relief.Psychology" },
occult: { skill: "occult", reach: "heard", once: "encounter",
label: "RINGBRP.Panic.Relief.Occult" },
anchorObject: { skill: null, reach: "self", once: "case",
label: "RINGBRP.Panic.Relief.Anchor" }
};
/** Breaking is a choice, not a table: three bad options, taken fast. */
export const PANIC_BREAK = ["freeze", "flee", "fixate"];
/** Breaking spends the panic. You come back at 3, not at 0 — it is not a rest. */
export const PANIC_AFTER_BREAK = 3;
export function panicBandFrom(value) {
const v = Math.max(0, Math.min(PANIC_MAX, Number(value) || 0));
return PANIC_BANDS.find(b => v >= b.min) ?? PANIC_BANDS.at(-1);
}
/** The roll penalty a level of panic carries. Never positive. */
export function panicPenaltyFor(value) {
return Math.min(0, panicBandFrom(value).all);
}
/** Does this level break the character this round? */
export function panicBreaksAt(value) {
return panicBandFrom(value).breaks === true;
}
/** Clamped, so nothing can push the track past its own ends. */
export function panicAfter(value, delta) {
return Math.max(0, Math.min(PANIC_MAX, (Number(value) || 0) + (Number(delta) || 0)));
}
/**
* A success takes one point, a special or better takes two — and a FUMBLE puts one on.
*
* Returned as a negative relief so the whole thing stays one number and one direction:
* easePanic subtracts what this gives it, so -1 subtracts a negative and the track goes
* up. Trying to talk somebody down and making it worse is the most familiar thing in
* this list, and until now it was the only outcome that cost nothing.
*/
export function panicReliefFor(level) {
if (level === "critical" || level === "special") return 2;
if (level === "success") return 1;
if (level === "fumble") return -1;
return 0;
}
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"];
/**
* WHAT A WEAPON CAN DO WITH A ROUND.
*
* Every weapon in the catalogue has carried a `fireMode` since the packs were first
* built, and until now precisely nothing read it. A musket could rip a ten-round
* burst; a weapon marked "rapid" got no benefit from being rapid. The mode is now
* the gate on the burst rules and it carries its own attack modifier, so choosing
* a slow weapon is a decision rather than a drawback.
*
* The trade runs one way along the ladder. The faster a weapon puts rounds out, the
* less each individual shot is worth; the slower it is, the more the one shot you
* get is worth taking properly.
*
* slow one shot, then work — and it is fired by somebody who knows that
* single the ordinary case, and the baseline every modifier is measured from
* fast the action will give you a second shot if you will accept both being worse
* rapid bursts, which is the automatic-fire rules above
* beam no recoil, no lead, no drop — and it can be held on the target
*
* `fast` was in the catalogue on five weapons (longbow, sling, javelin, the Webley
* and the Lee-Enfield) and was not in the system's list of modes at all, so the item
* sheet offered no matching option and re-saving one of those weapons silently blanked
* its mode. It is a real rate of fire and it is now a real mode.
*/
export const FIRE_MODE = {
slow: { id: "slow", label: "RINGBRP.Fire.slow.Label", attack: 10, burst: [],
reloads: true, second: false, sustain: false },
single: { id: "single", label: "RINGBRP.Fire.single.Label", attack: 0, burst: [],
reloads: false, second: false, sustain: false },
fast: { id: "fast", label: "RINGBRP.Fire.fast.Label", attack: 0, burst: [],
reloads: false, second: true, sustain: false },
rapid: { id: "rapid", label: "RINGBRP.Fire.rapid.Label", attack: 0, burst: ["short", "long", "full"],
reloads: false, second: false, sustain: false },
beam: { id: "beam", label: "RINGBRP.Fire.beam.Label", attack: 5, burst: [],
reloads: false, second: false, sustain: true }
};
/** A blank mode is a melee weapon or an unclassified one: it behaves as `single`. */
export function fireModeFor(id) {
return FIRE_MODE[id] || FIRE_MODE.single;
}
/** Which bursts this weapon can actually produce. Empty means it cannot. */
export function burstSizesFor(id) {
return fireModeFor(id).burst;
}
/** The flat modifier for firing this weapon at all, before range and everything else. */
export function fireModeAttackFor(id) {
return fireModeFor(id).attack;
}
/**
* The second shot a fast action will give you. BOTH shots take it, not just the
* second — the cost of hurrying is that you hurried the first one too, which is
* what stops this from being a free extra attack for anyone holding a revolver.
*/
export const SECOND_SHOT = -20;
export function secondShotPenaltyFor(id) {
return fireModeFor(id).second ? SECOND_SHOT : null;
}
/**
* SUSTAINED BEAMS.
*
* A beam does not fire discrete rounds, so a burst is the wrong shape for it: there
* is nothing to count hits of. What a beam can do instead is stay on the target,
* and that buys DAMAGE rather than hits — the one place in this system where a firing
* choice adds dice instead of adding rolls.
*
* It is paid for twice, in accuracy and in charge, because holding a line on a moving
* person is hard and because the alternative is that everybody holds every shot.
*/
export const SUSTAIN = {
tap: { id: "tap", label: "RINGBRP.Sustain.tap", mod: 0, bonus: "", charge: 1 },
hold: { id: "hold", label: "RINGBRP.Sustain.hold", mod: -10, bonus: "1d6", charge: 3 },
lock: { id: "lock", label: "RINGBRP.Sustain.lock", mod: -25, bonus: "2d6", charge: 6 }
};
export function sustainFor(id) {
return SUSTAIN[id] || SUSTAIN.tap;
}
/** The extra dice a sustained beam adds to its damage formula, or "" for none. */
export function sustainDamageFor(id) {
return sustainFor(id).bonus;
}
/**
* What a sustained beam costs, counted in SHOTS — because `ammo.loaded` is spent one
* per shot everywhere else in the system, so a cell holding 20 holds twenty shots.
*
* This was briefly multiplied by the weapon's `energyPerShot`, which made a locked
* beam cost 60 from a 20-shot cell and therefore impossible to fire at all. That
* field is written by the pack builder and read by nothing; the magazine is the
* authority on what a weapon has left.
*/
export function sustainChargeFor(id) {
return sustainFor(id).charge;
}
/* -------------------------------------------- */
/* What is bolted to the weapon */
/* -------------------------------------------- */
/**
* SIGHTS.
*
* A sight is not a flat bonus, and making it one is why most systems end up with
* everybody carrying the same scope. A sight is good at the distance it was ground
* for and a liability at the others, so the bonus is read off the RANGE BAND the
* attack is already using — which means it applies itself, with nothing for the
* player to remember and nothing for them to choose.
*
* A telescopic sight is a PENALTY inside ten metres. That is the whole point of it.
*/
export const SIGHT = {
none: { id: "none", label: "RINGBRP.Sight.none",
bands: { short: 0, medium: 0, long: 0, extreme: 0 }, dark: false },
irons: { id: "irons", label: "RINGBRP.Sight.irons",
bands: { short: 5, medium: 5, long: 0, extreme: 0 }, dark: false },
reflex: { id: "reflex", label: "RINGBRP.Sight.reflex",
bands: { short: 10, medium: 5, long: 0, extreme: -10 }, dark: false },
telescopic: { id: "telescopic", label: "RINGBRP.Sight.telescopic",
bands: { short: -10, medium: 5, long: 15, extreme: 20 }, dark: false },
thermal: { id: "thermal", label: "RINGBRP.Sight.thermal",
bands: { short: 5, medium: 5, long: 5, extreme: 0 }, dark: true }
};
export function sightFor(id) {
return SIGHT[id] || SIGHT.none;
}
/** What this sight is worth at this band. Bands come from the range ladder. */
export function sightBonusFor(id, band) {
const b = sightFor(id).bands;
return Number(b?.[band]) || 0;
}
/** A thermal sight is the only one that removes the dark rather than surviving it. */
export function sightSeesInDark(id) {
return !!sightFor(id).dark;
}
/**
* LIGHT.
*
* The department issues lamps because most crossings do not come with the lights on,
* and until now the dark cost an attack nothing at all. It costs something now, and
* a carried lamp buys most of it back.
*
* What it does not buy back is the second half: a lamp is a position. The rule
* returns `revealed` so the card can say so, and what the other side does about it
* is the GM's, not the engine's.
*/
export const LIGHTING = {
lit: { id: "lit", label: "RINGBRP.Lighting.lit", mod: 0 },
dim: { id: "dim", label: "RINGBRP.Lighting.dim", mod: -10 },
dark: { id: "dark", label: "RINGBRP.Lighting.dark", mod: -30 },
pitch: { id: "pitch", label: "RINGBRP.Lighting.pitch", mod: -50 }
};
/* -------------------------------------------- */
/* First contact */
/* -------------------------------------------- */
/**
* What each stage of contact is worth.
*
* READ's number is FACTS LEARNED. SIGNAL's and OFFER's are STANDING. They live in one
* table because they are one roll's outcome, and that is exactly why applying "the
* delta" uniformly is wrong — it would pay a team impression for doing research.
*/
// Indexed by LEVEL_LADDER — fumble, failure, success, special, critical — rather than
// keyed by those five names. That is not decoration: the band names are this system's
// universal vocabulary, so a table keyed by them is indistinguishable from any other
// table keyed by them, and the duplicate-rule guard cannot tell a genuine copy from a
// coincidence. Tying it to the ladder ties it to the one place the order is defined.
export const CONTACT_OUTCOMES = {
read: [0, 0, 1, 1, 2],
signal: [-1, 0, 0, 1, 1],
offer: [-2, 0, 2, 2, 3]
};
/** The raw number for a stage and a result, whatever that number means. */
export function contactDeltaFor(stage, level) {
const row = CONTACT_OUTCOMES[stage];
if (!row) return 0;
const i = LEVEL_LADDER.indexOf(String(level));
return i < 0 ? 0 : row[i];
}
/**
* How much a contact roll moves STANDING.
*
* An offer is the stage that buys standing, so its delta always applies. A fumble at
* any stage costs you, because insulting somebody is not free — that half of the
* table was computed and discarded for the life of the social pillar (R-246). A
* successful read or signal moves nothing: what a read earns is facts.
*/
export function impressionDeltaFor(stage, level) {
const delta = contactDeltaFor(stage, level);
if (stage === "offer") return delta;
return delta < 0 ? delta : 0;
}
/* -------------------------------------------- */
/* What the armour actually covers */
/* -------------------------------------------- */
/**
* ARMOUR COVERAGE.
*
* Every equipped piece used to add its points to every location, which meant a
* Brodie helmet protected your legs and a cuirass protected your head. With a
* catalogue that contains sallets, lobster-tail helmets and cuirasses that is not a
* simplification, it is wrong — and it could not be shown on the sheet without the
* sheet printing something visibly false.
*
* Coverage is declared as a NAMED SET rather than a list of locations, because armour
* has to fit whatever body is wearing it. The sets expand to location *kinds*, so a
* cuirass covers the torso of a baseline agent and the trunk of a vesh without either
* the item or the wearer knowing about the other.
*
* `all` is the default, so a piece that declares nothing behaves exactly as every
* piece did before this rule existed.
*/
export const COVERAGE = {
all: { id: "all", label: "RINGBRP.Coverage.all", kinds: null },
torso: { id: "torso", label: "RINGBRP.Coverage.torso",
kinds: ["body", "vital", "ridge", "unit"] },
torsoArms: { id: "torsoArms", label: "RINGBRP.Coverage.torsoArms",
kinds: ["body", "vital", "ridge", "unit", "arm", "grasp"] },
torsoLimbs: { id: "torsoLimbs", label: "RINGBRP.Coverage.torsoLimbs",
kinds: ["body", "vital", "ridge", "unit", "arm", "grasp", "leg", "brace"] },
head: { id: "head", label: "RINGBRP.Coverage.head", kinds: ["head"] }
};
export function coverageFor(id) {
return COVERAGE[id] || COVERAGE.all;
}
/** Does a piece with this coverage protect a location of this kind? */
export function armourCoversKind(coverageId, kind) {
const kinds = coverageFor(coverageId).kinds;
return kinds === null ? true : kinds.includes(kind);
}
/**
* The armour on one location: what the body has there anyway, plus every worn piece
* that reaches it. `pieces` is [{ points, coverage }] — worn only; the caller decides
* what "worn" means, because that is a question about the actor and not about armour.
*/
export function locationArmourFor(base, pieces, kind) {
let n = Number(base) || 0;
for (const p of pieces ?? []) {
if (armourCoversKind(p?.coverage, kind)) n += Number(p?.points) || 0;
}
return n;
}
/**
* SHIELDS.
*
* A shield is the only armour that is not simply worn: it is armour you HOLD, and it
* protects you exactly as much as you are currently holding it up. So it is armour
* with a switch, and everything interesting about it follows from that switch having
* a cost.
*
* Raised, it adds its points to what it covers and it is the one piece of armour you
* can also defend with. Lowered — slung, or because both your hands are full — it is
* weight and nothing else.
*
* The cost is that it occupies a hand and you are shooting around your own shield.
* That is what stops "always raised" from being the only answer: it is obviously
* right for a containment officer and obviously wrong for a marksman.
*/
export const SHIELD = {
ranged: -20, // your own shooting, from behind your own shield
coverage: "torsoArms", // what a raised shield presents, unless it says otherwise
parryFamily: "melee_weapon",
parrySpec: "shield"
};
/** A shield's points count only while it is up and only while it is still a shield. */
export function shieldArmourFor(points, { raised = false, broken = false } = {}) {
return (raised && !broken) ? (Number(points) || 0) : 0;
}
/** Shooting past your own shield. Nothing if it is down. */
export function shieldRangedPenaltyFor(raised) {
return raised ? SHIELD.ranged : 0;
}
/**
* Shields break. `degradation` has been on every armour item since the packs were
* first built and was read by nothing; this is what it was for.
*
* Wear is taken for stopping something that was genuinely trying — an ordinary blow
* turned aside costs the shield nothing, because otherwise every fight is arithmetic
* about splinters.
*/
// Derived from the ladder every other band decision uses rather than written out as
// a second table keyed by the same five names — which is both a duplicate vocabulary
// and, as the guard pointed out, indistinguishable from one.
export const SHIELD_WEAR_FROM = "success";
export function shieldWearFor(attackLevel) {
return Math.max(0, levelRank(attackLevel) - levelRank(SHIELD_WEAR_FROM));
}
/** At its limit it is firewood: no points, and nothing to parry with. */
export function shieldBroken(current, max) {
const c = Number(current) || 0, m = Number(max) || 0;
if (m <= 0) return false;
return c >= m;
}
/** A lamp never makes it daylight. This is the best it gets you. */
export const LIGHT_FLOOR = "dim";
/**
* HOW FAR A LAMP THROWS.
*
* A lamp used to light exactly one person: the one holding it. Played out, that put
* three agents standing inside the same pool of light shooting at −50% while the
* fourth, an arm's length away, shot at −10%. Nobody would run that at a table.
*
* So a lamp has a REACH, and anyone inside it is in the light — including the people
* it is showing to whatever is out there, which is the half of the rule that costs
* something. The reach is per item, because a weapon lamp and a roof searchlight are
* not the same offer.
*/
export const LAMP = { reachM: 10 };
export function lampReachFor(reachM) {
const r = Number(reachM);
return r > 0 ? r : LAMP.reachM;
}
/**
* Is this person inside that lamp's pool? Distance in metres, as everything else is.
*
* An UNKNOWN distance is not "in the light". `Number(null)` is 0, so a missing
* measurement would otherwise read as standing on top of the lamp and light the whole
* scene for free — which is precisely the failure this rule exists to correct.
*/
export function lampCovers(distanceM, reachM) {
if (distanceM === null || distanceM === undefined || distanceM === "") return false;
const d = Number(distanceM);
if (!Number.isFinite(d) || d < 0) return false;
return d <= lampReachFor(reachM);
}
export function lightingFor(id) {
return LIGHTING[id] || LIGHTING.lit;
}
/**
* The modifier the dark actually applies, after a lamp and after a thermal sight.
* A sight that sees in the dark beats a lamp, and does not tell anybody where you are.
*/
export function lightingModFor(id, { lightSource = false, seesInDark = false } = {}) {
const here = lightingFor(id);
if (seesInDark) return { mod: 0, effective: "lit", revealed: false };
if (!lightSource) return { mod: here.mod, effective: here.id, revealed: false };
const floor = lightingFor(LIGHT_FLOOR);
// A lamp cannot make anything worse, and cannot make it better than the floor.
const eff = here.mod >= floor.mod ? here : floor;
return { mod: eff.mod, effective: eff.id, revealed: eff.id !== here.id };
}
/**
* 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;
}