Stress test first: 150 characters across every posting, tier and species against fourteen invariants each, then the edges (characteristics at 1/3/21, every location destroyed, twelve pikes carried, hp set to -99). R-227: a destroyed head was a nap. The printed effect has always read "For anything that keeps its brain there, this is death" and the code set unconscious and stopped. destructionOutcomeFor() now says what destruction means. THE WORLD: falling (a die per 3 m, armour does not apply), drowning (CON rounds then a point a round, straight past the Major Wound threshold), fire (a condition with a size; armour helps on the round you catch and never after), explosives (one roll at the centre, shared by band, cover beats armour). AUTOMATIC FIRE: one roll, the success level says how much of the burst arrived, and the rounds are spent either way. TWO WEAPONS: -20/-40, or 0/-20 with the new Ambidextrous talent; the off hand takes the lighter weapon and nothing over 2 kg. BESTIARY: 21 entries in three families — folklore, horror, and what comes back from further along — each with characteristics, natural armour, tactics and a POWER written as a rule with a cost. SPACE: eight stations, each on a skill somebody already has, so the Physician has a chair. Range is a band; the helm moves one a round, in or out. Heat is the pressure and took two rounds of playtest tuning to stop being a countdown. 64 rules, 198 spot-checked formulas, a 12-page rulebook in reading order. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1084 lines
46 KiB
JavaScript
1084 lines
46 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);
|
||
}
|
||
|
||
/**
|
||
* 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. */
|
||
export function burstHitsFor(level, size) {
|
||
const b = BURST[size] ?? BURST.short;
|
||
const third = Math.max(1, Math.floor(b.rounds / 3));
|
||
switch (level) {
|
||
case "critical": return b.rounds; // all of it, and the first ignores armour
|
||
case "special": return Math.max(1, Math.floor(b.rounds * 2 / 3));
|
||
case "success": return third;
|
||
case "fumble": return 0; // and see BURST_FUMBLE
|
||
default: return 0;
|
||
}
|
||
}
|
||
|
||
/** 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"];
|
||
|
||
/**
|
||
* 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.
|
||
*/
|
||
export const TWO_WEAPON = { main: -20, off: -40, trainedMain: 0, 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 } = {}) {
|
||
let m = Math.max(0, Number(metres) || 0);
|
||
if (soft) m /= 2;
|
||
if (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
|
||
}
|
||
|
||
export function blastShareFor(rolled, band) {
|
||
if (!band) return 0;
|
||
return Math.max(0, Math.floor((Number(rolled) || 0) * band.share));
|
||
}
|
||
|
||
/** Cover is the answer to a blast. Armour is only ever half of one. */
|
||
export const BLAST_COVER = { none: 1, partial: 0.5, solid: 0.25, sealed: 0 };
|
||
|
||
export function blastAfterCover(damage, cover = "none") {
|
||
const f = BLAST_COVER[cover] ?? 1;
|
||
return Math.max(0, Math.floor((Number(damage) || 0) * f));
|
||
}
|
||
|
||
/**
|
||
* WHAT DESTROYING A LOCATION ACTUALLY DOES.
|
||
*
|
||
* The printed effect for a destroyed head reads "For anything that keeps its brain
|
||
* there, this is death" — and the code set `unconscious` and stopped. An agent could
|
||
* have every location on their body destroyed, head included, and the sheet would
|
||
* report them as DOWN. Destruction of something you cannot live without is not a
|
||
* penalty, and this is the rule that says so.
|
||
*
|
||
* DISABLED is not destroyed: a disabled head is a concussion and stays a concussion.
|
||
* Only DESTROYED reaches here.
|
||
*/
|
||
export function destructionOutcomeFor(kind, destroyed = false) {
|
||
if (!destroyed) return "none";
|
||
switch (kind) {
|
||
case "head": return "dead"; // whatever keeps its brain there no longer does
|
||
case "ridge": case "vital": return "dying";
|
||
default: return "none"; // limbs are ruinous, not fatal
|
||
}
|
||
}
|
||
|
||
/**
|
||
* WHAT HAPPENS AT ZERO.
|
||
*
|
||
* Nothing did. An agent reduced to 0 hit points was marked down and then lay there
|
||
* indefinitely: bleeding deliberately stops at 0, no rule advanced anything, and the
|
||
* only mention of death in the whole system was flavour text on a destroyed head.
|
||
* A game with hit locations, Major Wounds and bleeding had no way for anyone to die.
|
||
*
|
||
* Hit points are NOT taken below zero — every damage path clamps there, and unpicking
|
||
* that would touch everything. Instead, zero starts a clock:
|
||
*
|
||
* above 2 you are up
|
||
* 2 or below DOWN — out of the fight, still conscious, still yours
|
||
* 0 DYING — unconscious, no actions, no defences, and a count begins
|
||
* count met DEAD
|
||
*
|
||
* The count is the agent's own Major Wound threshold, so a solid Custodian has longer
|
||
* on the floor than a slight one, which is the same number already deciding how much
|
||
* damage they can take standing up. First Aid or Medicine STABILISES: the count stops
|
||
* and they stay at 0, unconscious, until somebody heals them above it.
|
||
*/
|
||
export const DYING = { perRound: 1, minCount: 2, stabiliseWith: ["first_aid", "medicine"] };
|
||
|
||
/** How many rounds this agent has at zero before the count is met. */
|
||
export function dyingLimitFor(majorWound) {
|
||
return Math.max(DYING.minCount, Number(majorWound) || 0);
|
||
}
|
||
|
||
/** The four states, from one place, so nothing has to re-derive them. */
|
||
export function conditionFor({ hp = 0, dyingRounds = 0, majorWound = 0,
|
||
stabilised = false, unconscious = false } = {}) {
|
||
const h = Number(hp) || 0;
|
||
if (h <= 0) {
|
||
if (!stabilised && (Number(dyingRounds) || 0) >= dyingLimitFor(majorWound)) return "dead";
|
||
return "dying";
|
||
}
|
||
if (h <= 2 || unconscious) return "down";
|
||
return "up";
|
||
}
|
||
|
||
/**
|
||
* THE LAST ENTRY.
|
||
*
|
||
* A Custodian's death is a filing question, because everything here is. How well
|
||
* filed they were when they died decides what the record ends up saying, and the
|
||
* badly filed do not get recorded as dead at all — which is the department's
|
||
* problem, and then somebody else's.
|
||
*/
|
||
export const LAST_ENTRY = [
|
||
{ atLeast: 7, id: "recorded", label: "RINGBRP.Death.Entry.recorded" },
|
||
{ atLeast: 4, id: "queried", label: "RINGBRP.Death.Entry.queried" },
|
||
{ atLeast: 1, id: "unclosed", label: "RINGBRP.Death.Entry.unclosed" },
|
||
{ atLeast: 0, id: "transferred", label: "RINGBRP.Death.Entry.transferred" }
|
||
];
|
||
|
||
export function lastEntryFor(coherence) {
|
||
const c = Number(coherence) || 0;
|
||
return LAST_ENTRY.find(e => c >= e.atLeast) ?? LAST_ENTRY[LAST_ENTRY.length - 1];
|
||
}
|
||
|
||
/**
|
||
* HOW HARD A CASE PHASE IS.
|
||
*
|
||
* The old ramp made every phase after the first Difficult — which HALVES the lead's
|
||
* skill — and then stacked a cumulative −5% on top. Measured across two played
|
||
* cases, a realistic lead of 40–55% (Tradecraft is a support skill on most postings
|
||
* that train it at all) faced 20% → 15% → 10% → 5% → 1%, and both cases were decided
|
||
* by Resources running out rather than by anything the players did.
|
||
*
|
||
* The two pressures are now separated, because they were never the same idea:
|
||
*
|
||
* DIFFICULTY is about the phase. Only Containment is Difficult — the step the case
|
||
* is actually about — and it does not also take the ramp. It is hard because it is
|
||
* containment, not because you are tired.
|
||
*
|
||
* The MODIFIER is about attrition. It accumulates as the case wears on, and it
|
||
* stops accumulating, because a fifth phase is not four times worse than a second.
|
||
*/
|
||
export const PHASE_RAMP = { step: -5, cap: -15, hardKinds: ["containment"] };
|
||
|
||
export function phaseRampFor(index, kindId = "") {
|
||
const hard = PHASE_RAMP.hardKinds.includes(String(kindId));
|
||
return {
|
||
difficulty: hard ? "difficult" : "average",
|
||
// A hard phase carries its difficulty INSTEAD of the ramp, never both.
|
||
modifier: hard ? 0 : Math.max(PHASE_RAMP.cap, PHASE_RAMP.step * (Number(index) || 0))
|
||
};
|
||
}
|
||
|
||
/* -------------------------------------------- */
|
||
/* Borrowed Authority */
|
||
/* -------------------------------------------- */
|
||
|
||
/**
|
||
* ACCESS IS A RECORD.
|
||
*
|
||
* There is no hacking skill and no network to enter. What an agent has is a named
|
||
* credential belonging to somebody real — a facilities contractor, a locum, a
|
||
* supplier's engineer — with a scope of things that person could plausibly do, a
|
||
* Trust that erodes as it is used, and a red flag or two describing how the real
|
||
* owner behaves. Inside its scope the credential simply works. Outside it, you are
|
||
* asking a system to believe something about a person, and that is a roll.
|
||
*
|
||
* The point of the design in a game about reality keeping a record of what belongs
|
||
* where: an Authority IS a record, and Coherence can widen it, because reality can
|
||
* be made to remember that the person held that duty. What it costs is that the
|
||
* agent's own record starts merging with the one they borrowed.
|
||
*/
|
||
|
||
/** What a credential could conceivably be asked to do, in order. */
|
||
export const SCOPE_TIERS = ["nothing", "read", "routine", "privileged", "administrative"];
|
||
|
||
export function scopeRank(tier) {
|
||
const i = SCOPE_TIERS.indexOf(String(tier));
|
||
return i < 0 ? 0 : i;
|
||
}
|
||
|
||
/**
|
||
* How an action stands against a credential's scope. This is the rule that stops
|
||
* "I hack anything": two tiers beyond the credential is not hard, it is impossible,
|
||
* and no percentage on any sheet changes that.
|
||
*
|
||
* at or below scope -> it simply works. No roll. 1 Trust.
|
||
* exactly one above -> a stretch. Roll for it.
|
||
* two or more above -> impossible. The identity was never able to do this.
|
||
*/
|
||
export function scopeStanceFor(authorityScope, actionTier) {
|
||
const gap = scopeRank(actionTier) - scopeRank(authorityScope);
|
||
if (gap <= 0) return "in-scope";
|
||
if (gap === 1) return "stretch";
|
||
return "impossible";
|
||
}
|
||
|
||
/** Behaving unlike the person you are claiming to be is what gets noticed. */
|
||
export const RED_FLAG_STEP = -20;
|
||
|
||
export function scrutinyFor(flagsTripped = 0) {
|
||
return RED_FLAG_STEP * Math.max(0, Number(flagsTripped) || 0);
|
||
}
|
||
|
||
/**
|
||
* What a stretched use of a credential does to it. Mirrors the defence ladder: the
|
||
* band is the whole answer, and a fumble is not merely a failure — it burns the
|
||
* credential and puts the person it belongs to in the department's way.
|
||
*/
|
||
export function authorityOutcomeFor(level) {
|
||
switch (level) {
|
||
case "critical": return { works: true, trustDelta: +1, burned: false };
|
||
case "special": return { works: true, trustDelta: 0, burned: false };
|
||
case "success": return { works: true, trustDelta: -1, burned: false };
|
||
case "fumble": return { works: false, trustDelta: -99, burned: true };
|
||
default: return { works: false, trustDelta: -1, burned: false };
|
||
}
|
||
}
|
||
|
||
/** Trust never rises above what the dossier bought, and stops at nothing. */
|
||
export const TRUST_MAX = 4;
|
||
|
||
export function trustAfter(current, delta, max = TRUST_MAX) {
|
||
const t = (Number(current) || 0) + (Number(delta) || 0);
|
||
return Math.max(0, Math.min(Number(max) || TRUST_MAX, t));
|
||
}
|
||
|
||
/**
|
||
* THE DOSSIER — how a credential is obtained.
|
||
*
|
||
* Not a second subsystem and not a stack of flat bonuses: a dossier item does not
|
||
* make the roll easier, it makes the credential better. Each item is a separate
|
||
* scene using a different skill, and at most one may be Computer Use, so assembling
|
||
* access is something the whole team does rather than one specialist.
|
||
*
|
||
* Trust starts at 1 and rises by one per item. Scope starts at "read" and a single
|
||
* item — the one that establishes what the person is actually FOR — may raise it.
|
||
*/
|
||
export const DOSSIER = { maxItems: 3, baseTrust: 1, baseScope: "read", maxComputerUse: 1 };
|
||
|
||
export function dossierTrustFor(itemCount) {
|
||
return Math.max(1, Math.min(TRUST_MAX, DOSSIER.baseTrust + (Number(itemCount) || 0)));
|
||
}
|
||
|
||
/** A dossier that establishes remit widens what the credential covers, once. */
|
||
export function dossierScopeFor(itemCount, establishesRemit = false) {
|
||
const base = scopeRank(DOSSIER.baseScope);
|
||
const up = (establishesRemit && (Number(itemCount) || 0) >= 2) ? 1 : 0;
|
||
return SCOPE_TIERS[Math.min(SCOPE_TIERS.length - 1, base + up)];
|
||
}
|
||
|
||
/**
|
||
* Coherence widens a scope by making reality remember that the person held that
|
||
* duty. The cost rises with how far you have already pushed the record — the first
|
||
* lie is cheap and the fourth is not.
|
||
*/
|
||
export function widenScopeCostFor(timesWidened = 0) {
|
||
return 1 + Math.max(0, Number(timesWidened) || 0);
|
||
}
|
||
|
||
/**
|
||
* The horror, with a number on it. Every time an Authority is pushed past what its
|
||
* Trust can carry, the agent's file and the borrowed one converge one step. This is
|
||
* what the subsystem is actually about and it is deliberately not reversible by
|
||
* spending anything.
|
||
*/
|
||
export const MERGE_STAGES = [
|
||
"RINGBRP.Authority.Merge.0",
|
||
"RINGBRP.Authority.Merge.1",
|
||
"RINGBRP.Authority.Merge.2",
|
||
"RINGBRP.Authority.Merge.3",
|
||
"RINGBRP.Authority.Merge.4"
|
||
];
|
||
|
||
export function mergeStageFor(steps) {
|
||
const n = Math.max(0, Math.min(MERGE_STAGES.length - 1, Number(steps) || 0));
|
||
return { step: n, label: MERGE_STAGES[n], terminal: n >= MERGE_STAGES.length - 1 };
|
||
}
|
||
|
||
/**
|
||
* Fists are a fallback, not a peer of the weapon you are carrying.
|
||
*
|
||
* A generated adversary was given Brawl from the same range as its weapon skills,
|
||
* so an Armed thug was exactly as good with its hands as with the pistol on its
|
||
* hip — and any sensible target-picker chose the fists, which then bounced off
|
||
* armour 2 for 1d3. Brawl is now a fraction of the trained rating, never below the
|
||
* skill's printed base.
|
||
*
|
||
* This does NOT apply to a creature that ATTACKS with Brawl: the anomalous grasp is
|
||
* a trained attack and keeps its full rating. The caller decides which case it is.
|
||
*/
|
||
export const BRAWL_FALLBACK = { fraction: 0.6, floor: 25 };
|
||
|
||
export function brawlFallbackFor(trainedRating) {
|
||
const r = Math.round((Number(trainedRating) || 0) * BRAWL_FALLBACK.fraction);
|
||
return Math.max(BRAWL_FALLBACK.floor, r);
|
||
}
|
||
|
||
/**
|
||
* A skill whose base is written as a formula rather than a flat number.
|
||
*
|
||
* Dodge is the only one, and its base is DEX×2 — which nothing in the system ever
|
||
* worked out. `base.mode` was "formula", `base.value` stayed 0, and every reader
|
||
* took `system.value` at face value, so an agent whose posting did not train Dodge
|
||
* had Dodge 0%: the game's universal defence, unavailable to everybody who had not
|
||
* separately bought it, and missing its floor even for those who had.
|
||
*
|
||
* Deliberately not eval(). Accepts `char`, `char*n`, `char+n` and `char*n+m`.
|
||
*/
|
||
export function skillBaseFrom(formula, characteristics = {}) {
|
||
const m = /^\s*([a-z]+)\s*(?:\*\s*(\d+))?\s*(?:\+\s*(\d+))?\s*$/i.exec(String(formula || ""));
|
||
if (!m) return 0;
|
||
const stat = Number(characteristics?.[m[1].toLowerCase()]?.value
|
||
?? characteristics?.[m[1].toLowerCase()]) || 0;
|
||
return Math.max(0, stat * (m[2] ? Number(m[2]) : 1) + (m[3] ? Number(m[3]) : 0));
|
||
}
|
||
|
||
/**
|
||
* The five outcome bands, worst to best. Exported so nothing has to re-declare
|
||
* the order to compare two rolls against each other.
|
||
*/
|
||
export const LEVEL_LADDER = ["fumble", "failure", "success", "special", "critical"];
|
||
|
||
/** Where a level sits on the ladder. Anything unknown reads as a failure. */
|
||
export function levelRank(level) {
|
||
const i = LEVEL_LADDER.indexOf(String(level));
|
||
return i < 0 ? 1 : i;
|
||
}
|
||
|
||
/**
|
||
* What a defence actually does to the blow it answers.
|
||
*
|
||
* The old rule was binary: any successful dodge or parry stopped anything, so a
|
||
* boarding-axe critical was cancelled outright by an ordinary parry and cost the
|
||
* attacker nothing. That sat badly against every other thing the system says about
|
||
* criticals — maximum damage, armour ignored entirely — and it made the best roll
|
||
* in the game worth no more than the commonest one.
|
||
*
|
||
* Defences are now graded. A defence stops a blow of its own quality or worse
|
||
* outright. Against something better it still helps, but only by as much as it was
|
||
* outclassed by: the blow lands one step worse for every step the defence fell
|
||
* short. So the attack degrades a critical to a special, a special to a success,
|
||
* and a success to nothing — and only a critical defence stops a critical.
|
||
*
|
||
* landing rank = failure + (attack rank − defence rank)
|
||
*
|
||
* A failed defence does nothing at all and the blow lands at its own level.
|
||
*/
|
||
export function defenceOutcomeFor(attackLevel, defenceLevel) {
|
||
const a = levelRank(attackLevel);
|
||
const d = levelRank(defenceLevel);
|
||
// A defence that did not succeed never reduces anything.
|
||
if (d < levelRank("success")) {
|
||
return { turnedAside: false, landsAt: LEVEL_LADDER[a], steps: 0 };
|
||
}
|
||
const steps = a - d;
|
||
if (steps <= 0) return { turnedAside: true, landsAt: null, steps: 0 };
|
||
return { turnedAside: false, landsAt: LEVEL_LADDER[levelRank("failure") + steps], steps };
|
||
}
|
||
|
||
/**
|
||
* You may not mix your defences in a round: having chosen to dodge, you dodge.
|
||
* Returns the type you are allowed to use, given what you have already done.
|
||
*/
|
||
export function defenceTypeAllowed(lock, wanted) {
|
||
return !lock || lock === wanted ? wanted : lock;
|
||
}
|
||
|
||
/**
|
||
* THE QUIET ARTS.
|
||
*
|
||
* A power is not a spell. Crossing is an induced filing error — you persuade the
|
||
* record you are somewhere else — and an Art is the same trick performed small and
|
||
* on purpose: a misfiling you commit deliberately. So an Art costs the one thing
|
||
* that misfiling always costs, which is Coherence, and needs no second resource.
|
||
*
|
||
* Nobody learns one. They arrive from crossings, in the people the crossing
|
||
* changed, which is why the category the agency files them under is the same one
|
||
* headed "the crossing left this in you".
|
||
*/
|
||
export const ARTS = {
|
||
thread: { order: 1, coherence: 1, push: 2 },
|
||
echo: { order: 2, coherence: 1, push: 2 },
|
||
hollow: { order: 3, coherence: 1, push: 3 },
|
||
witness: { order: 4, coherence: 1, push: 2 },
|
||
unmake: { order: 5, coherence: 2, push: 4 },
|
||
ledger: { order: 6, coherence: 2, push: 4 }
|
||
};
|
||
|
||
/** What using an Art costs, ordinarily and when pushed past its reach. */
|
||
export function powerCostFor(artId, { pushed = false } = {}) {
|
||
const a = ARTS[artId];
|
||
if (!a) return pushed ? 2 : 1;
|
||
return pushed ? a.push : a.coherence;
|
||
}
|
||
|
||
/**
|
||
* An Art is governed by POW, and its starting rating is what the crossing left
|
||
* behind rather than anything the agent trained: POW plus a little, capped low.
|
||
* They are dangerous because of what they do, not because of what they roll.
|
||
*/
|
||
export function powerStartingRating(pow) {
|
||
return Math.max(5, Math.min(45, (Number(pow) || 0) * 2));
|
||
}
|
||
|
||
/**
|
||
* COMING BACK.
|
||
*
|
||
* The return is a second crossing and it has to be aimed. R-56 holds: it is never
|
||
* impossible, only dearer — a failure arrives you NEAR your target rather than at
|
||
* it, and a fumble brings you back changed.
|
||
*
|
||
* "Changed" is where the Quiet Arts come from. Nobody learns one; they arrive in
|
||
* people a crossing went wrong for, which is why the pool an agent draws from is
|
||
* decided by how far they had already drifted when it happened. You do not choose
|
||
* what you get, and the worse the state you were in, the worse the thing that
|
||
* follows you home.
|
||
*/
|
||
export const CROSSING = {
|
||
fumbleCoherence: 2, // a botched return costs this on top of everything else
|
||
failCoherence: 1
|
||
};
|
||
|
||
/** Which Arts can arrive, given how anchored the agent was when it went wrong. */
|
||
export function crossingArtPoolFor(coherence) {
|
||
const v = Number(coherence) || 0;
|
||
if (v >= 5) return ["thread", "echo", "witness"]; // still mostly here
|
||
if (v >= 3) return ["hollow", "thread", "echo"]; // adrift
|
||
return ["unmake", "ledger", "hollow"]; // unmoored or worse
|
||
}
|
||
|
||
/**
|
||
* Did the crossing leave something behind? A fumble always does. A failure only
|
||
* does to an agent who was already coming apart.
|
||
*/
|
||
export function crossingGrantsArt(level, coherence) {
|
||
if (level === "fumble") return true;
|
||
if (level === "failure") return (Number(coherence) || 0) <= 2;
|
||
return false;
|
||
}
|
||
|
||
/** What a return of this quality costs in Coherence. */
|
||
export function crossingCostFor(level) {
|
||
if (level === "fumble") return CROSSING.fumbleCoherence;
|
||
if (level === "failure") return CROSSING.failCoherence;
|
||
return 0;
|
||
}
|