Files
RingBRP/rules.mjs
T
slaguru666andClaude Opus 5 ca040b7926 The belly is not armoured (R-254)
R-95 made losing a wing change the animal. R-96 took the hide off the
wing so losing one was possible. Separately each was right and each was
measured. Together they gave the party a soft target and deleted it the
moment the party hit it:

  winged     6/20 ranged faces land on armour 0 (a bare wing)
  quadruped  0/20

A service round on borough ground averages 3.75 after halving — 3.75
through a bare wing, 0 through hide 7 — so grounding the creature took
the party's chance of winning from 44.9% to 1.4%, while its own tactics
told the GM that grounding it was the fight they could actually win.

Found by measuring something else. Every aimed-shot configuration made
the party worse, and the most aggressive was the worst: aiming at a wing
at -20% grounds it in 99% of fights and HALVES the win rate to 10.1%.
Buying the objective reliably was the fastest way to lose, which only
makes sense if the objective is a trap. So no aimed-shot rule; the
problem was never the lack of one.

It also retires the "+31 points" I reported for grounding at 1.7.3. That
was win-rate-if-grounded against win-rate-if-not — selection bias, since
the parties that grounded it were the ones shooting well. Forcing the
grounding gives the causal value and it was -43. A correlation of +31
and a causation of -43 out of the same mechanic.

The fix: a heraldic beast is armoured the way it is drawn, across the
back and the flanks. On its belly half that hide is no longer in the
way, so exposedHideFor halves natural armour while system.grounded is
set. Hide 7 becomes 3.

                      flying   grounded   worth
  on borough ground    45.3%     51.7%    +6.4
  off borough ground   90.6%     97.9%    +7.3

Modest deliberately. Grounding should help, not decide — the borough is
still the real answer. Losing its damage modifier was worth +26.5 and
would have made the wing the whole fight.

check-anatomy gains a sixth property: a flyer must not be harder to hurt
on the ground than in the air. The first version probed at a single
4-point round and declared the fix broken, because 4 sits inside the one
band where a bare wing beats a halved hide — below 5 damage the creature
really is better off grounded, above it much worse. Integrating across
1-12 gives 2.83 flying against 3.75 grounded and reads it correctly.
Negative-tested against 1.7.8's behaviour, which it names.

The lethality baseline is unchanged: its simulation never grounds
anything, so system.grounded never comes up there.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-12 22:57:31 +01:00

1998 lines
88 KiB
JavaScript
Raw 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.
*/
// The species anatomies. anatomy.mjs imports nothing and touches nothing, so this stays
// inside the rule above: it is data the hit-location rules are defined over, and both
// the engine and the node-side tools read that one copy.
import { LOCATION_TABLES } from "./anatomy.mjs";
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.
============================================================ */
/* ============================================================
DEVELOPMENT — you learn from what beat you
============================================================
Call of Cthulhu marks a skill when you SUCCEED with it and improves it later.
This department works the other way round: a skill is marked when it FAILS you.
Nobody learns anything from the lock that opened first time.
The improvement roll itself is the familiar one and is deliberately unchanged: roll
d100 against the current rating and you improve by OVERSHOOTING it. The better you
already are, the less often a case teaches you anything, which is what stops a
long campaign turning everyone into ninety-percenters.
The two halves therefore pull in opposite directions on purpose. A low skill fails
constantly in play (so it gets marked) and then fails its development roll (so it
climbs fast). A high skill rarely fails at all, and when it does it usually passes
the development roll and gains nothing. Skills converge rather than run away.
There is still a hard ceiling, because a d100 gain can overshoot the top: a skill is
held at its own trainingCap if it has one, and at DEVELOPMENT.ceiling otherwise.
============================================================ */
/** A skill is marked by the outcomes that went badly. Nothing else marks it. */
export const DEVELOPMENT = {
marksOn: ["failure", "fumble"],
gain: "1d6",
improveOn: "over",
ceiling: 100
};
/** Did this outcome mark the skill for development? */
export function developmentMarksOn(level) {
return DEVELOPMENT.marksOn.includes(level);
}
/**
* Does this development roll improve the skill? Roll OVER the current rating.
* A rating of 0 always improves; a rating of 100 or better never does.
*/
export function developmentImproves(roll, rating) {
return (Number(roll) || 0) > (Number(rating) || 0);
}
/**
* The highest a skill can be taken to. A skill carrying its own trainingCap is held
* there — that is the only thing trainingCap has ever meant — and everything else
* stops at the point where a d100 can no longer overshoot it.
*/
export function developmentCeiling(trainingCap) {
const cap = Number(trainingCap);
return Number.isFinite(cap) && cap > 0 ? Math.min(cap, DEVELOPMENT.ceiling) : DEVELOPMENT.ceiling;
}
/** What the skill actually becomes: the gain, clipped to the ceiling. */
export function developmentGainFor(rating, rolled, trainingCap) {
const from = Number(rating) || 0;
const to = Math.min(from + (Number(rolled) || 0), developmentCeiling(trainingCap));
return Math.max(0, to - from);
}
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" },
// One point, not two. At two, the end of a hard case reliably broke four agents in
// five at the same moment — a whole party freezing or bolting on the closing beat
// reads as arithmetic rather than grief. Playing THROUGH TRAIN end to end did it.
someoneYouKnew:{ cost: 1, 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, bleed: 0,
legs: 0, legsDestroyed: 0, arms: 0, vital: false, unitsLost: 0,
wings: 0, flightLost: false };
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 "body":
// The printed effect has always read "-30% to all Physical actions and
// bleeding 1 hit point per round until First Aid", and nothing delivered a
// point of it. An opened abdomen was decoration on every body plan in the
// game, the humanoid default included. Destroyed doubles the bleed, which
// is what bodyX says.
eff.physical -= 30;
eff.bleed += l.destroyed ? 2 : 1;
break;
case "vital":
eff.vital = true; // knocked down; the Stamina roll is the GM's call
break;
case "neck":
// A broken neck puts the animal on the ground and keeps it there. This is
// the location that makes a spear worth aiming rather than swinging.
eff.unconscious = true;
eff.vital = true;
break;
case "wing":
// ONE wing is enough. A flyer with a hole in it comes down, and coming down
// is the whole reason a table would shoot at a wing instead of the body.
eff.wings++;
eff.flightLost = true;
eff.physical -= 10;
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. This counts legs
// rather than assuming two of them, so a quadruped that has lost one of four is
// lamed and not felled — which is the difference between a horse and a man.
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.
*/
/* ---------------------------------------------------------------- hit locations */
/**
* Spec §3.6. Which location a 1D20 finds, for this species and attack type.
*
* Moved out of ringbrp.mjs so node can reach it, for exactly the reason applyDifficulty,
* resolveBands and gradeRoll were moved: the lethality harness could not model hit
* locations while the only copy lived in the engine, and an unlocated Cadence is not a
* Cadence — being whittled down body by body is the entire species. The tables come from
* anatomy.mjs, which is the same one the engine reads.
*/
export function locationFor(roll, speciesId = "baseline", mode = "ranged") {
const table = LOCATION_TABLES[speciesId] ?? LOCATION_TABLES.baseline;
const key = mode === "melee" ? "melee" : "ranged";
return table.locations.find(l => roll >= l[key][0] && roll <= l[key][1]) ?? table.locations[0];
}
/**
* Spec §3.6. Resolve a hit against a location.
* A blow that meets the location's maximum disables it; twice that destroys it.
* General hit points still take the damage, so the two systems agree.
*/
export function resolveLocationHit({ damage, locationMax, locationTaken = 0, majorWoundThreshold }) {
const d = Number(damage) || 0;
const after = locationTaken + d;
return {
damage: d,
locationTaken: after,
disabled: after >= locationMax,
// Cumulative, like `disabled` and like the actor's own derived state. This used to
// test THIS hit's damage alone, so a limb could never be destroyed by repeated
// wounds and the resolver disagreed with the sheet about the same limb.
destroyed: after >= locationMax * 2,
majorWound: d >= (Number(majorWoundThreshold) || Infinity)
};
}
/**
* What a flyer becomes when a wing goes, or "" if it is not a flyer.
*
* The supporter's tactics have always said "a flyer with a hole in a wing is a
* quadruped ... which is the fight the agents can actually win", and playing it showed
* that sentence was decoration: `flightLost` was computed, printed on the sheet as
* GROUNDED, and read by nothing. Both wings disabled cost the creature 20% off PHYSICAL
* rolls, and its Brawl is a MELEE category, so a grounded supporter attacked at exactly
* the 75% and exactly the 1d6+1+2d6 it had in the air. Now losing a wing actually
* changes the animal.
*/
export function groundedPlanFor(speciesId = "baseline") {
return (LOCATION_TABLES[speciesId] ?? {}).grounded ?? "";
}
/**
* How much of a creature's own hide reaches a given location.
*
* Natural armour used to be added to every location without exception, which put the
* supporter's six points of heraldic hide on its wing membrane. Measured over 20,000
* fights, that was the whole reason its written rule never fired: a wing needed eight
* points past armour six before it would fold, so the beast was grounded in 9% of
* fights and its own tactics — "disable either wing and it is on the ground for the
* rest of the scene" — described something almost nobody ever saw.
*
* Hide does not grow on membrane. A location marked `bare` carries only what it is
* wearing. WORN armour still covers it, so barding a wing works exactly as before;
* it is the animal's own hide that stops at the edge of the wing.
*/
/**
* The hide a creature still has between you and it once it is on the ground.
*
* Built to fix a defect in the two commits before it. R-95 made losing a wing change
* the animal and R-96 made the wing hittable by taking the hide off the membrane —
* and together they handed the party a soft target whose destruction REMOVES the soft
* target. Measured: six of twenty ranged faces land on a bare wing while it flies and
* none do once it is grounded, so putting it on the ground took the party's chance of
* winning from 44.9% to 1.4%. Grounding it was a 43-point mistake, and the creature's
* own tactics called it "the fight the agents can actually win".
*
* A heraldic beast is armoured the way a heraldic beast is drawn: across the back and
* the flanks. Down on its belly it is not, so half the hide stops being between you and
* it. That restores something an ordinary round can get through, which is the thing
* grounding took away, and it does it without pretending a grounded animal is harmless.
*/
export function exposedHideFor(naturalArmour = 0, grounded = false) {
const a = Number(naturalArmour) || 0;
return grounded ? Math.floor(a / 2) : a;
}
export function naturalArmourOn(location, naturalArmour = 0) {
return location?.bare ? 0 : (Number(naturalArmour) || 0);
}
/** Every location a species has — what a combatant has to keep a tally for. */
export function locationsFor(speciesId = "baseline") {
return (LOCATION_TABLES[speciesId] ?? LOCATION_TABLES.baseline).locations;
}
/**
* WHERE A WOUND GOES WHEN THE BODY CHANGES SHAPE.
*
* `locationDamage` is keyed by location id, and prepareDerivedData only reads the keys
* belonging to the CURRENT body plan. So changing speciesProfile on a wounded actor
* stranded every wound it had: a Barghest switched from baseline to quadruped kept 5
* points in `legR` and 7 in `chest`, which no quadruped location is called, and read on
* the sheet as a dog 12 hit points down with eight pristine locations. Stored, never
* read — the same defect this system keeps producing.
*
* Wounds are carried across by KIND, because kind is what the rules act on: a leg is
* what you limp on whichever plan names it, a vital is what kills you. Exact-kind
* matches are claimed first, in table order, so the obvious pairings land before any
* fallback competes for them; only then do the leftovers walk the fallback chain.
*
* That ordering is what makes arms become forelegs. Going baseline -> quadruped the two
* legs take the two hind legs, and the arms — finding no `arm` and no `grasp` — fall to
* `leg` and take the forelegs that are left. Coming back, the forelegs become arms. The
* limb you lost stays the limb you lost.
*/
const KIND_FALLBACK = {
head: ["neck", "ridge", "vital", "body", "unit"],
neck: ["head", "vital", "body", "unit"],
vital: ["body", "ridge", "unit"],
body: ["vital", "unit"],
leg: ["brace", "arm", "body", "unit"],
arm: ["grasp", "leg", "brace", "body", "unit"],
wing: ["arm", "grasp", "leg", "body", "unit"],
brace: ["leg", "arm", "body", "unit"],
grasp: ["arm", "leg", "body", "unit"],
ridge: ["head", "vital", "body", "unit"],
unit: ["body", "vital", "head", "leg", "arm"]
};
/**
* Rebuild a locationDamage map for a new body plan.
*
* What is preserved is SEVERITY, not the raw number of points: a location that was
* merely hurt stays hurt, a disabled one stays disabled, a destroyed one stays
* destroyed. Points alone cannot be preserved because a location's maximum comes from
* its `frac` and the plans disagree — an arm is 0.35 of the body, a foreleg 0.42 — so
* carrying the number across rather than the meaning silently healed destroyed limbs
* one way and destroyed merely-disabled ones the other. 234 such flips across the 20
* plan changes, 138 of them creating a destroyed location out of one that was not.
*
* Returns the new map, the moves it made (each with the band it preserved), and how
* many wounds had to be rescaled to keep their meaning.
*/
/**
* Build the update that REPLACES a location-damage map.
*
* Foundry merges object updates, so the outgoing plan's keys must be deleted by name or
* the actor carries both plans' wounds at once (R-93). But a deletion and an assignment
* of the SAME key in one update do not both apply — the deletion wins — and the plans
* that share ids are exactly where that bites: quadruped and winged share every location
* but the wings, so a switch between them emitted `-=hindLegR` alongside `hindLegR: 5`
* and silently threw the wound away. Delete only what the new map does not set.
*/
export function locationDamageReplacement(current = {}, next = {}) {
const upd = { ...next };
for (const id of Object.keys(current)) if (!(id in next)) upd[`-=${id}`] = null;
return upd;
}
export function remapLocationDamage(damage = {}, fromId = "baseline", toId = "baseline", totalHp = 0) {
const from = (LOCATION_TABLES[fromId] ?? LOCATION_TABLES.baseline).locations;
const to = (LOCATION_TABLES[toId] ?? LOCATION_TABLES.baseline).locations;
const out = {}, moved = [];
let rescaled = 0;
const wounded = from.filter(l => (Number(damage[l.id]) || 0) > 0);
if (!to.length || !wounded.length) return { damage: out, moved, rescaled };
const used = new Set();
// Slots carry the side in their name, so a wound that had a side keeps it even when it
// has to fall back to a different kind — a left wing becomes a LEFT arm, not whichever
// arm happened to be first in the table.
const sideOf = slot => (/R$/.test(slot) ? "R" : /L$/.test(slot) ? "L" : "");
// A wing is a FORE limb, so when it has to fall back to a leg it should become a
// foreleg, not a hind leg. Slots carry height as well as side; prefer a target that
// matches both, then side alone, then height alone.
const highOf = slot => (/^(upper|wing)/.test(slot) ? "upper" : /^lower/.test(slot) ? "lower" : "");
const pick = (pool, side, high) =>
(side && high && pool.find(t => sideOf(t.slot) === side && highOf(t.slot) === high))
|| (side && pool.find(t => sideOf(t.slot) === side))
|| (high && pool.find(t => highOf(t.slot) === high))
|| pool[0];
const freeSlot = slot => to.find(t => t.slot === slot && !used.has(t.id));
const freeOfKind = (kind, side = "", high = "") =>
pick(to.filter(t => t.kind === kind && !used.has(t.id)), side, high);
const anyOfKind = (kind, side = "", high = "") =>
pick(to.filter(t => t.kind === kind), side, high);
const carry = (src, tgt) => {
const taken = Number(damage[src.id]) || 0;
const srcMax = locationMaxHp(totalHp, src.frac);
const tgtMax = locationMaxHp(totalHp, tgt.frac);
const band = taken >= srcMax * 2 ? "destroyed" : taken >= srcMax ? "disabled" : "hurt";
const scaled = Math.round((taken / srcMax) * tgtMax);
// Pinned to the band rather than to the number, because the band is what the rules
// read. An arm is 0.35 of the body and a foreleg is 0.42, so carrying the raw points
// across turned a destroyed arm into a merely disabled foreleg and — stacking two
// limbs onto one Vesh grasp — a disabled arm into a destroyed one.
const value = band === "destroyed" ? tgtMax * 2
: band === "disabled" ? Math.min(Math.max(scaled, tgtMax), tgtMax * 2 - 1)
: Math.max(0, Math.min(scaled, tgtMax - 1));
used.add(tgt.id);
// Two wounds landing on one location: the worse one stands. Summing them would let a
// change of shape destroy a limb that nothing in play had destroyed.
out[tgt.id] = Math.max(out[tgt.id] ?? 0, value);
if (value !== taken) rescaled++;
moved.push({ from: src.id, to: tgt.id, points: taken, became: value, band });
};
// Pass 1 — the same anatomical slot. Stated in the tables rather than inferred, so a
// right arm becomes a right foreleg and becomes a right arm again coming back. An
// earlier version matched on kind and list order instead and inverted left and right
// on every crossing, which round-tripped an arm wound onto the opposite hind leg.
const pending = [];
for (const src of wounded) {
const tgt = freeSlot(src.slot);
if (tgt) carry(src, tgt); else pending.push(src);
}
// Pass 2 — same kind, for a slot the destination simply does not have.
const noKind = [];
for (const src of pending) {
const tgt = freeOfKind(src.kind, sideOf(src.slot), highOf(src.slot));
if (tgt) carry(src, tgt); else noKind.push(src);
}
// Pass 3 — the fallback chain, still only onto unclaimed locations.
const stillPending = [];
for (const src of noKind) {
const side = sideOf(src.slot), high = highOf(src.slot);
const tgt = (KIND_FALLBACK[src.kind] ?? []).map(k => freeOfKind(k, side, high)).find(Boolean);
if (tgt) carry(src, tgt); else stillPending.push(src);
}
// Pass 4 — everything is claimed, so share. A second head wound is still a head wound.
for (const src of stillPending) {
const side = sideOf(src.slot), high = highOf(src.slot);
carry(src, [src.kind, ...(KIND_FALLBACK[src.kind] ?? [])]
.map(k => anyOfKind(k, side, high)).find(Boolean) ?? to[0]);
}
return { damage: out, moved, rescaled };
}
/**
* How much a battered body costs a roll of one category.
*
* The mapping — `all` against everything, manipulation against anything you need hands
* for, physical against phys, and so on — lived only inside ringbrp.mjs's
* woundPenaltyFor, which reads a Foundry actor. The harness needs the same arithmetic
* over a plain object, and writing the mapping a second time is the one thing this
* repository does not permit. woundPenaltyFor now unwraps the actor and calls this.
*
* Note that melee and ranged both take the MANIPULATION penalty: a lost arm is what
* stops you fighting, whether the weapon is a knife or a rifle. Dodge is `phys`, so
* losing a leg is what stops you getting out of the way.
*/
export function woundPenaltyFrom(wounded, categoryId) {
if (!wounded) return 0;
let pen = Number(wounded.all) || 0;
if (["manip", "melee", "ranged"].includes(categoryId)) pen += Number(wounded.manipulation) || 0;
if (categoryId === "phys") pen += Number(wounded.physical) || 0;
if (categoryId === "percep") pen += Number(wounded.perception) || 0;
if (categoryId === "comm") pen += Number(wounded.communication) || 0;
return pen;
}
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"] },
// Armour that fits an animal rather than a man: barding covers the barrel, the
// neck and the legs, and reaches no wing. A cuirass does none of this, which is
// why it is a set of its own rather than another kind bolted onto torsoLimbs.
barding: { id: "barding", label: "RINGBRP.Coverage.barding",
kinds: ["body", "vital", "neck", "leg"] }
};
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 "neck": return "dead"; // nothing carries a head on a severed neck
case "ridge": case "vital": return "dying";
default: return "none"; // limbs AND WINGS 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));
}
/**
* Spec §1. Difficulty is a multiplier on the rating, not a modifier on the roll.
*
* These three — applyDifficulty, resolveBands, gradeRoll — are the whole of d100
* resolution, and they lived in ringbrp.mjs until the lethality harness needed them.
* They are pure: nothing here touches an Actor, a roll message or any other Foundry
* global, so there was never a reason for them to sit in the engine except that the
* engine is where they were first written. check-rules already guards against a rule
* keeping a second copy of itself in ringbrp.mjs; a rule that lives ONLY there is the
* same problem one step earlier, because nothing outside Foundry can reach it to check
* it. ringbrp.mjs imports and re-exports them, so its public surface is unchanged.
*/
export function applyDifficulty(rating, difficulty = "average") {
switch (difficulty) {
case "easy": return rating * 2;
case "difficult": return Math.floor(rating / 2);
case "average":
default: return rating;
}
}
/**
* Spec §1 resolution. Returns the full band breakdown without rolling,
* so sheets can display the odds before the player commits.
*/
export function resolveBands(rating, { difficulty = "average", situational = 0, printedBase = 0 } = {}) {
let effective = applyDifficulty(Number(rating) || 0, difficulty) + (Number(situational) || 0);
if (printedBase >= 5) effective = Math.max(effective, 5);
const band = Math.min(100, Math.max(1, effective));
// `effective` used to be the RAW figure, so a skill beaten down to 0 or below
// printed "0%" on the chat card while the true chance was 1% — the floor was
// real but arrived sideways, through the critical band, and the card contradicted
// the rule the book states. One honest number now: what you actually need.
return {
effective: band,
band,
critical: ceil(band / 20),
special: ceil(band / 5),
fumbleStart: 101 - ceil((101 - band) / 20)
};
}
/** Spec §1. Fumble is tested first; 96–99 never succeed; 00 always fumbles. */
export function gradeRoll(roll, bands) {
if (roll >= bands.fumbleStart) return "fumble";
if (roll >= 96 && roll <= 99) return "failure";
if (roll <= bands.critical) return "critical";
if (roll <= bands.special) return "special";
if (roll <= bands.effective) return "success";
return "failure";
}
/**
* 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;
}