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>
758 lines
32 KiB
JavaScript
758 lines
32 KiB
JavaScript
/**
|
||
* 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;
|
||
}
|