Files
RingBRP/rules.mjs
T
slaguru666andClaude Opus 5 89ce557355 Death at zero, and a case brief that answers the useful questions
R-223: nothing happened at 0 hit points. An agent was marked down and lay there
forever; the only mention of death in the system was flavour text on a destroyed
head. Zero now starts a clock whose length is the agent's own Major Wound
threshold, ticked from the same hook that charges blood loss. First Aid or
Medicine stabilises and stops the count; healing above zero clears it; heal()
refuses the dead. On death, Coherence decides what the record says — from a
proper death in service down to not being recorded as dead at all.

Case generator: four phases instead of five (the arc drew two interchangeable
middles that read as padding). The brief now leads with Where / When /
Classification / Phases, states the opposition in full, and has a "How you get
back" section. place and duration were generated and never printed. The same
facts are on the case file itself. 300 seeds checked for object leaks; one found
and fixed (group taboo/desire are objects with .text).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-10 22:13:21 +01:00

758 lines
32 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.
*/
export const ceil = Math.ceil;
/** Hit points: half of CON + SIZ, standard BRP. */
export function hitPointsFor(con, siz) {
return Math.max(1, ceil(((Number(con) || 0) + (Number(siz) || 0)) / 2));
}
/** A Major Wound is a blow of half your hit points or more. */
export function majorWoundFor(hp) {
return Math.max(1, ceil((Number(hp) || 0) / 2));
}
/** Damage modifier from STR + SIZ. */
export function damageModifierFor(total) {
const t = Number(total) || 0;
if (t <= 8) return "-1d6";
if (t <= 16) return "-1d3";
if (t <= 24) return "0";
if (t <= 32) return "+1d3";
if (t <= 40) return "+1d6";
if (t <= 50) return "+2d6";
if (t <= 60) return "+3d6";
if (t <= 70) return "+4d6";
return `+${4 + ceil((t - 70) / 10)}d6`;
}
/** Marksmanship is the agent's own choice now that home worlds are gone. */
export function styleFor(chosen = "reflex") { return chosen; }
/** A location's hit points, as a fraction of the total pool. */
export function locationMaxHp(totalHp, frac) {
return Math.max(1, ceil((Number(totalHp) || 0) * (Number(frac) || 0)));
}
/** Reaction: the two axes and the printed base. */
export const REACTION = { dexDivisor: 3, readinessDivisor: 10, laneMultiplier: 3, die: 6, modCap: 3 };
export function reflexRating(dex) { return Math.max(1, ceil((Number(dex) || 0) / REACTION.dexDivisor)); }
export function awarenessRating(readiness) { return Math.max(1, ceil((Number(readiness) || 0) / REACTION.readinessDivisor)); }
export function reactionBaseFrom(dex, readiness) {
return REACTION.laneMultiplier * Math.max(reflexRating(dex), awarenessRating(readiness));
}
/** Encumbrance: whole steps drive MOV and skills, the continuous figure drives Reaction. */
/**
* Bleeding, both halves of it.
*
* The table printed a rate and a way to stop it, and the game implemented neither:
* nothing ticked the loss during play, and First Aid healed hit points without ever
* clearing the wound. So bleeding was inert at the table and unstoppable in
* simulation — the same rule failing in both directions at once.
*
* `perRound` is the loss at the end of each round. `stop` lists what ends it; ANY
* one entry is enough. A stop entry with a `difficulty` requires the roll to have
* been made at that difficulty or harder, which is what "a Difficult First Aid"
* means; entries without one accept any difficulty.
*/
export const BLEED = {
// The table says "a successful First Aid" for bleeding. Medicine is added because
// the printed rule left a surgeon unable to stop the lesser wound they could treat.
bleeding: { perRound: 1,
stop: [{ family: "first_aid" }, { family: "medicine" }] },
deepBleeding: { perRound: 2,
stop: [{ family: "first_aid", difficulty: "difficult" },
{ family: "medicine" }] }
};
const DIFFICULTY_RANK = { easy: 0, average: 1, difficult: 2, formidable: 3 };
/** Does this successful roll stop this kind of bleeding? */
export function bleedStoppedBy(key, { family = "", difficulty = "average" } = {}) {
const spec = BLEED[key];
if (!spec) return false;
return spec.stop.some(s =>
s.family === family &&
(s.difficulty === undefined ||
(DIFFICULTY_RANK[difficulty] ?? 1) >= (DIFFICULTY_RANK[s.difficulty] ?? 1)));
}
/** Total loss per round from every bleeding wound an actor carries. */
export function bleedRateFor(wounds = []) {
return (Array.isArray(wounds) ? wounds : [])
.reduce((n, w) => n + (BLEED[w?.key]?.perRound ?? 0), 0);
}
/**
* Major Wound consequences, as penalties rather than prose.
*
* The table used to be rolled, announced and recorded, and then change nothing: a
* `manipulation` wound whose own text reads "−30% Manipulation" applied zero,
* `concussed` ("−20% to all rolls") applied zero, and `mobility` ("MOV halved")
* left MOV untouched. Only crippling and catastrophic did anything, through the
* separate isDown check. This turns each row into numbers the roll pipeline reads.
*
* Takes the recorded `system.wounds` array; returns penalties to FOLD INTO the
* location-derived wound state, never to replace it. Wounds stack: two concussions
* are −40%, and a Major Wound stacks with a shattered limb, because both are true.
*
* `all` is applied to every category by woundPenaltyFor. The per-category numbers
* are additional and specific.
*
* Variants: `sensory` and `broken` each split two ways on 1D2, chosen when the
* wound is rolled and stored on the entry. An absent variant falls back to the
* harsher reading, so a missing field never quietly costs the target nothing.
*/
export function woundEffectsFor(wounds = []) {
const eff = { all: 0, manipulation: 0, physical: 0, perception: 0,
communication: 0, movFactor: 1, movFloor: 0, bleed: 0,
defenceOnly: false, unconscious: false, lostAction: false };
for (const w of Array.isArray(wounds) ? wounds : []) {
switch (w?.key) {
case "concussed": eff.all -= 20; break;
case "winded": eff.physical -= 20; eff.lostAction = true; break;
case "bleeding":
case "deepBleeding": eff.bleed += BLEED[w.key].perRound; break;
case "manipulation": eff.manipulation -= 30; break;
case "mobility": eff.physical -= 30; eff.movFactor *= 0.5; break;
case "sensory":
// sight also blinds you to what you are shooting at; hearing does not
eff.perception -= 40;
if (w.variant === "hearing") eff.communication -= 40;
else eff.manipulation -= 40;
break;
case "broken":
if (w.variant === "mobility") { eff.physical -= 30; eff.movFactor *= 0.5; }
else eff.manipulation -= 30;
break;
case "crippling": eff.defenceOnly = true; eff.movFloor = 1; eff.movFactor = 0; break;
case "catastrophic": eff.unconscious = true; eff.defenceOnly = true;
eff.movFloor = 1; eff.movFactor = 0; break;
}
}
return eff;
}
/**
* Load on the reaction, exact. One point per 10 kg over free carry, with no
* staircase — this is the granular rule R-23 asked for, that no weight is ever
* free and no single kilogramme ever costs a whole point. It replaces the old
* three-value band, which charged a full point for the first gram over and then
* nothing more until 10 kg.
*
* Rounded to one decimal because the value is printed on a paper sheet and added
* to a die by hand.
*/
export function reactionLoadFrom(srLoad) {
// Rounded UP, not to nearest. To nearest would make the first half-kilogramme
// free, and the rule is that no weight is ever free. A tenth of a point is the
// smallest unit worth writing on a paper sheet, so that is the floor of the cost.
return Math.ceil((Number(srLoad) || 0) * 10) / 10;
}
/**
* The number actually printed on the character sheet: the lane base less load.
* Floored at 1 — R-56, the critical-path invariant. No amount of kit may reduce
* an agent to acting never; it may only ever reduce them to acting last.
*/
export function reactionPrintedFrom(base, srLoad) {
const b = Number(base) || 0;
return Math.max(1, Math.round((b - reactionLoadFrom(srLoad)) * 10) / 10);
}
export function encumbranceFrom(carriedKg, freeCarryKg) {
const over = Math.max(0, (Number(carriedKg) || 0) - (Number(freeCarryKg) || 0));
const steps = Math.floor(over / 10);
const rounded = Math.round(over * 10) / 10;
// `over` and `overKg` are the same number under both names: the engine reads one,
// the guard's spot-checks the other, and a rename in either used to silently
// zero the encumbrance penalty. Kept deliberately, not by accident.
return { over: rounded, overKg: rounded, steps, movPenalty: steps,
skillPenalty: steps * 5, srLoad: over / 10 };
}
/* ============================================================
* Rule TABLES. Pure data, no Foundry, so the engine, the pack
* builder and the rules journal all read the same rows. The
* journal is generated from these — a number cannot be right in
* the code and wrong in the book.
* ============================================================ */
/** The situational ladder. Applied after difficulty, capped at +-40 in total. */
export const CIRCUMSTANCE = [
{ id: "overwhelming", mod: 30, label: "RINGBRP.Circ.Overwhelming" },
{ id: "favourable", mod: 20, label: "RINGBRP.Circ.Favourable" },
{ id: "slightGood", mod: 10, label: "RINGBRP.Circ.SlightGood" },
{ id: "neutral", mod: 0, label: "RINGBRP.Circ.Neutral" },
{ id: "slightBad", mod: -10, label: "RINGBRP.Circ.SlightBad" },
{ id: "unfavourable", mod: -20, label: "RINGBRP.Circ.Unfavourable" },
{ id: "severe", mod: -30, label: "RINGBRP.Circ.Severe" }
];
/** Range. `mod` null means the shot cannot be attempted at all. */
export const RANGE_LADDER = [
{ id: "short", mod: 0, upto: r => r.short },
{ id: "medium", mod: -10, upto: r => r.medium },
{ id: "long", mod: -30, upto: r => r.long },
{ id: "extreme", mod: -50, upto: r => r.long * 2 },
{ id: "beyond", mod: null, upto: () => Infinity }
];
export function rangeBandFrom(distance, ranges) {
const d = Number(distance) || 0;
const r = { short: Number(ranges?.short) || 0, medium: Number(ranges?.medium) || 0,
long: Number(ranges?.long) || 0 };
if (!r.short && !r.medium && !r.long) return { band: "short", mod: 0 };
for (const step of RANGE_LADDER) {
const limit = step.upto(r);
if (d <= limit) return { band: step.id, mod: step.mod };
}
return { band: "beyond", mod: null };
}
/** 1D10 on a Major Wound. Consequences are in woundEffectsFor. */
export const MAJOR_WOUND_TABLE = [
{ min: 1, max: 1, key: "concussed" },
{ min: 2, max: 2, key: "winded" },
{ min: 3, max: 3, key: "bleeding" },
{ min: 4, max: 4, key: "manipulation" },
{ min: 5, max: 5, key: "mobility" },
{ min: 6, max: 6, key: "deepBleeding" },
{ min: 7, max: 7, key: "sensory" },
{ min: 8, max: 8, key: "broken" },
{ min: 9, max: 9, key: "crippling" },
{ min: 10, max: 10, key: "catastrophic" }
];
/** Coherence. 10 is anchored, 0 is displaced and permanent. */
export const COHERENCE_BANDS = [
{ min: 8, id: "anchored", label: "RINGBRP.Coh.Anchored", all: 0, comm: 0, note: "RINGBRP.Coh.AnchoredNote" },
{ min: 5, id: "loose", label: "RINGBRP.Coh.Loose", all: 0, comm: -5, note: "RINGBRP.Coh.LooseNote" },
{ min: 3, id: "adrift", label: "RINGBRP.Coh.Adrift", all: -5, comm: -10, note: "RINGBRP.Coh.AdriftNote" },
{ min: 1, id: "unmoored", label: "RINGBRP.Coh.Unmoored", all: -10, comm: -20, note: "RINGBRP.Coh.UnmooredNote",
returnPenalty: -30 },
{ min: 0, id: "displaced", label: "RINGBRP.Coh.Displaced", all: -20, comm: -30, note: "RINGBRP.Coh.DisplacedNote" }
];
export const COHERENCE_COSTS = {
fumbleOnCrossing: { cost: 1, label: "RINGBRP.Coh.Cost.Fumble" },
witnessedParadox: { cost: 1, label: "RINGBRP.Coh.Cost.Paradox" },
usedCrossingTech: { cost: 1, label: "RINGBRP.Coh.Cost.Tech" },
brokeLocalLaw: { cost: 1, label: "RINGBRP.Coh.Cost.Local" },
metYourself: { cost: 2, label: "RINGBRP.Coh.Cost.Self" },
killedADuplicate: { cost: 3, label: "RINGBRP.Coh.Cost.Duplicate" },
overstayed: { cost: 1, label: "RINGBRP.Coh.Cost.Overstay" }
};
export const COHERENCE_RECOVERY = { debrief: 1, downtimeWeek: 1, anchorObject: 1 };
export function coherenceBandFrom(value) {
const v = Math.max(0, Number(value) || 0);
return COHERENCE_BANDS.find(b => v >= b.min) ?? COHERENCE_BANDS.at(-1);
}
/**
* What a lost LOCATION does, by anatomical kind.
*
* Legs and arms were applied; everything else was localised prose that changed no
* number. A destroyed head said "unconscious immediately" and left the agent
* standing; a Vesh's opened sensory ridge promised -30% Perception and gave none;
* and the Cadence rule — "each disabled body costs the whole person -10% to
* everything" — did nothing whatever, which is the entire mechanical identity of
* one of the three playable species.
*
* Same shape as woundEffectsFor so the two fold together additively: both are true
* about the same body at the same time.
*/
export function locationEffectsFor(locations = []) {
const eff = { all: 0, manipulation: 0, physical: 0, perception: 0,
movFactor: 1, unconscious: false, prone: false,
legs: 0, legsDestroyed: 0, arms: 0, vital: false, unitsLost: 0 };
for (const l of Array.isArray(locations) ? locations : []) {
if (!l?.disabled) continue;
switch (l.kind) {
case "leg": case "brace":
eff.legs++;
if (l.destroyed) eff.legsDestroyed++;
eff.physical -= 30;
break;
case "arm": case "grasp": case "manipulator":
eff.arms++;
eff.manipulation -= 30;
break;
case "ridge":
eff.perception -= 30;
eff.vital = true;
break;
case "head":
eff.unconscious = true; // "unconscious immediately"
eff.vital = true;
break;
case "vital":
eff.vital = true; // knocked down; the Stamina roll is the GM's call
break;
case "unit":
eff.unitsLost++;
eff.all -= 10; // each body lost costs the WHOLE person
break;
}
}
// One leg gone is a limp; both, or a destroyed one, is the floor.
eff.movFactor = eff.legsDestroyed ? 0.1 : (eff.legs ? 0.5 : 1);
eff.prone = eff.legsDestroyed > 0 || eff.vital;
return eff;
}
/**
* Which skill categories a heavy pack actually impedes.
*
* `encumbranceFrom` has computed a skillPenalty since R-23 and NOTHING ever applied
* it — the comment in skillRoll even says the penalty belongs there. Carrying too
* much is scoped to the physical categories rather than every skill, because a
* rucksack does not make you worse at Knowledge, and a rule that says otherwise is
* a rule nobody will use.
*/
export const ENCUMBERED_CATEGORIES = ["phys", "melee", "ranged", "manip"];
export function encumbrancePenaltyFor(skillPenalty, categoryId) {
if (!ENCUMBERED_CATEGORIES.includes(categoryId)) return 0;
return -Math.abs(Number(skillPenalty) || 0);
}
/**
* What you can carry before it costs you anything.
*
* Was STR alone, in kilogrammes, which ignored how big you are: Rakhi at STR 18 and
* MAS 19 — a large, powerful woman — had a free carry of 18 kg and was twenty
* kilos over with a soldier's ordinary fighting load. A rule that makes a realistic
* pack an emergency is a rule the table will quietly drop.
*
* STR + MAS is the same pair that sets the damage modifier, so one physical axis
* decides both how hard you hit and how much you can shoulder. Rakhi's free carry
* becomes 37 kg and her 38 kg load puts her exactly 1 kg over — heavy, deliberate,
* and worth 0.1 of a point rather than two.
*/
export function freeCarryFor(str, siz) {
return Math.max(1, (Number(str) || 0) + (Number(siz) || 0));
}
/**
* Defending. The book says "one offensive action, plus as many defensive reactions
* as you like at an escalating penalty" — and the escalation was the only part that
* existed anywhere: `defencesThisRound` was stored on every actor, reset by the
* round hook, and incremented by nothing, because no defence was ever rolled.
*
* The first defence in a round is free. Each one after it is cumulatively harder,
* so standing in front of three people is possible and stupid.
*/
export const DEFENCE_STEP = -30;
export function defencePenaltyFor(defencesUsed = 0) {
return DEFENCE_STEP * Math.max(0, Number(defencesUsed) || 0);
}
/* -------------------------------------------- */
/* Dying, and death */
/* -------------------------------------------- */
/**
* WHAT HAPPENS AT ZERO.
*
* Nothing did. An agent reduced to 0 hit points was marked down and then lay there
* indefinitely: bleeding deliberately stops at 0, no rule advanced anything, and the
* only mention of death in the whole system was flavour text on a destroyed head.
* A game with hit locations, Major Wounds and bleeding had no way for anyone to die.
*
* Hit points are NOT taken below zero — every damage path clamps there, and unpicking
* that would touch everything. Instead, zero starts a clock:
*
* above 2 you are up
* 2 or below DOWN — out of the fight, still conscious, still yours
* 0 DYING — unconscious, no actions, no defences, and a count begins
* count met DEAD
*
* The count is the agent's own Major Wound threshold, so a solid Custodian has longer
* on the floor than a slight one, which is the same number already deciding how much
* damage they can take standing up. First Aid or Medicine STABILISES: the count stops
* and they stay at 0, unconscious, until somebody heals them above it.
*/
export const DYING = { perRound: 1, minCount: 2, stabiliseWith: ["first_aid", "medicine"] };
/** How many rounds this agent has at zero before the count is met. */
export function dyingLimitFor(majorWound) {
return Math.max(DYING.minCount, Number(majorWound) || 0);
}
/** The four states, from one place, so nothing has to re-derive them. */
export function conditionFor({ hp = 0, dyingRounds = 0, majorWound = 0,
stabilised = false, unconscious = false } = {}) {
const h = Number(hp) || 0;
if (h <= 0) {
if (!stabilised && (Number(dyingRounds) || 0) >= dyingLimitFor(majorWound)) return "dead";
return "dying";
}
if (h <= 2 || unconscious) return "down";
return "up";
}
/**
* THE LAST ENTRY.
*
* A Custodian's death is a filing question, because everything here is. How well
* filed they were when they died decides what the record ends up saying, and the
* badly filed do not get recorded as dead at all — which is the department's
* problem, and then somebody else's.
*/
export const LAST_ENTRY = [
{ atLeast: 7, id: "recorded", label: "RINGBRP.Death.Entry.recorded" },
{ atLeast: 4, id: "queried", label: "RINGBRP.Death.Entry.queried" },
{ atLeast: 1, id: "unclosed", label: "RINGBRP.Death.Entry.unclosed" },
{ atLeast: 0, id: "transferred", label: "RINGBRP.Death.Entry.transferred" }
];
export function lastEntryFor(coherence) {
const c = Number(coherence) || 0;
return LAST_ENTRY.find(e => c >= e.atLeast) ?? LAST_ENTRY[LAST_ENTRY.length - 1];
}
/**
* HOW HARD A CASE PHASE IS.
*
* The old ramp made every phase after the first Difficult — which HALVES the lead's
* skill — and then stacked a cumulative −5% on top. Measured across two played
* cases, a realistic lead of 40–55% (Tradecraft is a support skill on most postings
* that train it at all) faced 20% → 15% → 10% → 5% → 1%, and both cases were decided
* by Resources running out rather than by anything the players did.
*
* The two pressures are now separated, because they were never the same idea:
*
* DIFFICULTY is about the phase. Only Containment is Difficult — the step the case
* is actually about — and it does not also take the ramp. It is hard because it is
* containment, not because you are tired.
*
* The MODIFIER is about attrition. It accumulates as the case wears on, and it
* stops accumulating, because a fifth phase is not four times worse than a second.
*/
export const PHASE_RAMP = { step: -5, cap: -15, hardKinds: ["containment"] };
export function phaseRampFor(index, kindId = "") {
const hard = PHASE_RAMP.hardKinds.includes(String(kindId));
return {
difficulty: hard ? "difficult" : "average",
// A hard phase carries its difficulty INSTEAD of the ramp, never both.
modifier: hard ? 0 : Math.max(PHASE_RAMP.cap, PHASE_RAMP.step * (Number(index) || 0))
};
}
/* -------------------------------------------- */
/* Borrowed Authority */
/* -------------------------------------------- */
/**
* ACCESS IS A RECORD.
*
* There is no hacking skill and no network to enter. What an agent has is a named
* credential belonging to somebody real — a facilities contractor, a locum, a
* supplier's engineer — with a scope of things that person could plausibly do, a
* Trust that erodes as it is used, and a red flag or two describing how the real
* owner behaves. Inside its scope the credential simply works. Outside it, you are
* asking a system to believe something about a person, and that is a roll.
*
* The point of the design in a game about reality keeping a record of what belongs
* where: an Authority IS a record, and Coherence can widen it, because reality can
* be made to remember that the person held that duty. What it costs is that the
* agent's own record starts merging with the one they borrowed.
*/
/** What a credential could conceivably be asked to do, in order. */
export const SCOPE_TIERS = ["nothing", "read", "routine", "privileged", "administrative"];
export function scopeRank(tier) {
const i = SCOPE_TIERS.indexOf(String(tier));
return i < 0 ? 0 : i;
}
/**
* How an action stands against a credential's scope. This is the rule that stops
* "I hack anything": two tiers beyond the credential is not hard, it is impossible,
* and no percentage on any sheet changes that.
*
* at or below scope -> it simply works. No roll. 1 Trust.
* exactly one above -> a stretch. Roll for it.
* two or more above -> impossible. The identity was never able to do this.
*/
export function scopeStanceFor(authorityScope, actionTier) {
const gap = scopeRank(actionTier) - scopeRank(authorityScope);
if (gap <= 0) return "in-scope";
if (gap === 1) return "stretch";
return "impossible";
}
/** Behaving unlike the person you are claiming to be is what gets noticed. */
export const RED_FLAG_STEP = -20;
export function scrutinyFor(flagsTripped = 0) {
return RED_FLAG_STEP * Math.max(0, Number(flagsTripped) || 0);
}
/**
* What a stretched use of a credential does to it. Mirrors the defence ladder: the
* band is the whole answer, and a fumble is not merely a failure — it burns the
* credential and puts the person it belongs to in the department's way.
*/
export function authorityOutcomeFor(level) {
switch (level) {
case "critical": return { works: true, trustDelta: +1, burned: false };
case "special": return { works: true, trustDelta: 0, burned: false };
case "success": return { works: true, trustDelta: -1, burned: false };
case "fumble": return { works: false, trustDelta: -99, burned: true };
default: return { works: false, trustDelta: -1, burned: false };
}
}
/** Trust never rises above what the dossier bought, and stops at nothing. */
export const TRUST_MAX = 4;
export function trustAfter(current, delta, max = TRUST_MAX) {
const t = (Number(current) || 0) + (Number(delta) || 0);
return Math.max(0, Math.min(Number(max) || TRUST_MAX, t));
}
/**
* THE DOSSIER — how a credential is obtained.
*
* Not a second subsystem and not a stack of flat bonuses: a dossier item does not
* make the roll easier, it makes the credential better. Each item is a separate
* scene using a different skill, and at most one may be Computer Use, so assembling
* access is something the whole team does rather than one specialist.
*
* Trust starts at 1 and rises by one per item. Scope starts at "read" and a single
* item — the one that establishes what the person is actually FOR — may raise it.
*/
export const DOSSIER = { maxItems: 3, baseTrust: 1, baseScope: "read", maxComputerUse: 1 };
export function dossierTrustFor(itemCount) {
return Math.max(1, Math.min(TRUST_MAX, DOSSIER.baseTrust + (Number(itemCount) || 0)));
}
/** A dossier that establishes remit widens what the credential covers, once. */
export function dossierScopeFor(itemCount, establishesRemit = false) {
const base = scopeRank(DOSSIER.baseScope);
const up = (establishesRemit && (Number(itemCount) || 0) >= 2) ? 1 : 0;
return SCOPE_TIERS[Math.min(SCOPE_TIERS.length - 1, base + up)];
}
/**
* Coherence widens a scope by making reality remember that the person held that
* duty. The cost rises with how far you have already pushed the record — the first
* lie is cheap and the fourth is not.
*/
export function widenScopeCostFor(timesWidened = 0) {
return 1 + Math.max(0, Number(timesWidened) || 0);
}
/**
* The horror, with a number on it. Every time an Authority is pushed past what its
* Trust can carry, the agent's file and the borrowed one converge one step. This is
* what the subsystem is actually about and it is deliberately not reversible by
* spending anything.
*/
export const MERGE_STAGES = [
"RINGBRP.Authority.Merge.0",
"RINGBRP.Authority.Merge.1",
"RINGBRP.Authority.Merge.2",
"RINGBRP.Authority.Merge.3",
"RINGBRP.Authority.Merge.4"
];
export function mergeStageFor(steps) {
const n = Math.max(0, Math.min(MERGE_STAGES.length - 1, Number(steps) || 0));
return { step: n, label: MERGE_STAGES[n], terminal: n >= MERGE_STAGES.length - 1 };
}
/**
* Fists are a fallback, not a peer of the weapon you are carrying.
*
* A generated adversary was given Brawl from the same range as its weapon skills,
* so an Armed thug was exactly as good with its hands as with the pistol on its
* hip — and any sensible target-picker chose the fists, which then bounced off
* armour 2 for 1d3. Brawl is now a fraction of the trained rating, never below the
* skill's printed base.
*
* This does NOT apply to a creature that ATTACKS with Brawl: the anomalous grasp is
* a trained attack and keeps its full rating. The caller decides which case it is.
*/
export const BRAWL_FALLBACK = { fraction: 0.6, floor: 25 };
export function brawlFallbackFor(trainedRating) {
const r = Math.round((Number(trainedRating) || 0) * BRAWL_FALLBACK.fraction);
return Math.max(BRAWL_FALLBACK.floor, r);
}
/**
* A skill whose base is written as a formula rather than a flat number.
*
* Dodge is the only one, and its base is DEX×2 — which nothing in the system ever
* worked out. `base.mode` was "formula", `base.value` stayed 0, and every reader
* took `system.value` at face value, so an agent whose posting did not train Dodge
* had Dodge 0%: the game's universal defence, unavailable to everybody who had not
* separately bought it, and missing its floor even for those who had.
*
* Deliberately not eval(). Accepts `char`, `char*n`, `char+n` and `char*n+m`.
*/
export function skillBaseFrom(formula, characteristics = {}) {
const m = /^\s*([a-z]+)\s*(?:\*\s*(\d+))?\s*(?:\+\s*(\d+))?\s*$/i.exec(String(formula || ""));
if (!m) return 0;
const stat = Number(characteristics?.[m[1].toLowerCase()]?.value
?? characteristics?.[m[1].toLowerCase()]) || 0;
return Math.max(0, stat * (m[2] ? Number(m[2]) : 1) + (m[3] ? Number(m[3]) : 0));
}
/**
* The five outcome bands, worst to best. Exported so nothing has to re-declare
* the order to compare two rolls against each other.
*/
export const LEVEL_LADDER = ["fumble", "failure", "success", "special", "critical"];
/** Where a level sits on the ladder. Anything unknown reads as a failure. */
export function levelRank(level) {
const i = LEVEL_LADDER.indexOf(String(level));
return i < 0 ? 1 : i;
}
/**
* What a defence actually does to the blow it answers.
*
* The old rule was binary: any successful dodge or parry stopped anything, so a
* boarding-axe critical was cancelled outright by an ordinary parry and cost the
* attacker nothing. That sat badly against every other thing the system says about
* criticals — maximum damage, armour ignored entirely — and it made the best roll
* in the game worth no more than the commonest one.
*
* Defences are now graded. A defence stops a blow of its own quality or worse
* outright. Against something better it still helps, but only by as much as it was
* outclassed by: the blow lands one step worse for every step the defence fell
* short. So the attack degrades a critical to a special, a special to a success,
* and a success to nothing — and only a critical defence stops a critical.
*
* landing rank = failure + (attack rank − defence rank)
*
* A failed defence does nothing at all and the blow lands at its own level.
*/
export function defenceOutcomeFor(attackLevel, defenceLevel) {
const a = levelRank(attackLevel);
const d = levelRank(defenceLevel);
// A defence that did not succeed never reduces anything.
if (d < levelRank("success")) {
return { turnedAside: false, landsAt: LEVEL_LADDER[a], steps: 0 };
}
const steps = a - d;
if (steps <= 0) return { turnedAside: true, landsAt: null, steps: 0 };
return { turnedAside: false, landsAt: LEVEL_LADDER[levelRank("failure") + steps], steps };
}
/**
* You may not mix your defences in a round: having chosen to dodge, you dodge.
* Returns the type you are allowed to use, given what you have already done.
*/
export function defenceTypeAllowed(lock, wanted) {
return !lock || lock === wanted ? wanted : lock;
}
/**
* THE QUIET ARTS.
*
* A power is not a spell. Crossing is an induced filing error — you persuade the
* record you are somewhere else — and an Art is the same trick performed small and
* on purpose: a misfiling you commit deliberately. So an Art costs the one thing
* that misfiling always costs, which is Coherence, and needs no second resource.
*
* Nobody learns one. They arrive from crossings, in the people the crossing
* changed, which is why the category the agency files them under is the same one
* headed "the crossing left this in you".
*/
export const ARTS = {
thread: { order: 1, coherence: 1, push: 2 },
echo: { order: 2, coherence: 1, push: 2 },
hollow: { order: 3, coherence: 1, push: 3 },
witness: { order: 4, coherence: 1, push: 2 },
unmake: { order: 5, coherence: 2, push: 4 },
ledger: { order: 6, coherence: 2, push: 4 }
};
/** What using an Art costs, ordinarily and when pushed past its reach. */
export function powerCostFor(artId, { pushed = false } = {}) {
const a = ARTS[artId];
if (!a) return pushed ? 2 : 1;
return pushed ? a.push : a.coherence;
}
/**
* An Art is governed by POW, and its starting rating is what the crossing left
* behind rather than anything the agent trained: POW plus a little, capped low.
* They are dangerous because of what they do, not because of what they roll.
*/
export function powerStartingRating(pow) {
return Math.max(5, Math.min(45, (Number(pow) || 0) * 2));
}
/**
* COMING BACK.
*
* The return is a second crossing and it has to be aimed. R-56 holds: it is never
* impossible, only dearer — a failure arrives you NEAR your target rather than at
* it, and a fumble brings you back changed.
*
* "Changed" is where the Quiet Arts come from. Nobody learns one; they arrive in
* people a crossing went wrong for, which is why the pool an agent draws from is
* decided by how far they had already drifted when it happened. You do not choose
* what you get, and the worse the state you were in, the worse the thing that
* follows you home.
*/
export const CROSSING = {
fumbleCoherence: 2, // a botched return costs this on top of everything else
failCoherence: 1
};
/** Which Arts can arrive, given how anchored the agent was when it went wrong. */
export function crossingArtPoolFor(coherence) {
const v = Number(coherence) || 0;
if (v >= 5) return ["thread", "echo", "witness"]; // still mostly here
if (v >= 3) return ["hollow", "thread", "echo"]; // adrift
return ["unmake", "ledger", "hollow"]; // unmoored or worse
}
/**
* Did the crossing leave something behind? A fumble always does. A failure only
* does to an agent who was already coming apart.
*/
export function crossingGrantsArt(level, coherence) {
if (level === "fumble") return true;
if (level === "failure") return (Number(coherence) || 0) <= 2;
return false;
}
/** What a return of this quality costs in Coherence. */
export function crossingCostFor(level) {
if (level === "fumble") return CROSSING.fumbleCoherence;
if (level === "failure") return CROSSING.failCoherence;
return 0;
}