R-228: Ambidextrous was a compulsory build. 0/-20 gave a full-strength main hand AND a cheap second attack. It now buys consistency, not a free attack: -20 in both hands against -20/-40 untrained. R-229: full auto was a firing squad. Ten independent hits off one critical, each with its own damage, location and Major Wound check. Hits capped at 3/5/7, and a burst is ONE wounding event however many rounds connect. R-230: Engines deleted the pressure it existed to create. Guns +2 and Helm +1 against a vent of 3 made a fighting round heat-neutral, so Heat only mattered when the engineer failed. Venting is now 2/3/4. Also: falling halving no longer stacks (soft + controlled quartered the drop); blast has one formula floored once, so the answer no longer depends on call order. And the structural note — "giving everyone a roll is not the same as giving everyone a decision". Sensors, Damage Control and Medical now have mutually exclusive modes that bite in the engine: mark/spoof/read, patch/brace, restore/steady. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1101 lines
47 KiB
JavaScript
1101 lines
47 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. */
|
||
// Hits are CAPPED, not proportional. Ten independent damage rolls, ten hit locations
|
||
// and ten Major Wound checks off one success is not a burst, it is a firing squad —
|
||
// and it made full auto the correct answer to every hard target.
|
||
export const BURST_HITS = { short: [1, 2, 3], long: [2, 4, 5], full: [3, 5, 7] };
|
||
|
||
export function burstHitsFor(level, size) {
|
||
const cap = BURST_HITS[size] ?? BURST_HITS.short;
|
||
switch (level) {
|
||
case "critical": return cap[2];
|
||
case "special": return cap[1];
|
||
case "success": return cap[0];
|
||
default: return 0; // failure and fumble; see BURST_FUMBLE
|
||
}
|
||
}
|
||
|
||
/** However many rounds connect, a burst is ONE wound event for the Major Wound check. */
|
||
export const BURST_ONE_MAJOR_WOUND = true;
|
||
|
||
/** Every round leaves the weapon regardless. This is the cost of the option. */
|
||
export function burstRoundsSpent(size, loaded) {
|
||
const b = BURST[size] ?? BURST.short;
|
||
return Math.min(Number(loaded) || 0, b.rounds);
|
||
}
|
||
|
||
/** A fumbled burst is the interesting one: it is a jam, or it is worse. */
|
||
export const BURST_FUMBLE = ["RINGBRP.Burst.Jam", "RINGBRP.Burst.Empty", "RINGBRP.Burst.Wild"];
|
||
|
||
/**
|
||
* TWO WEAPONS.
|
||
*
|
||
* The off hand is a real second attack and is meant to cost something. Untrained it
|
||
* is a heavy penalty on BOTH hands, because a person waving two knives is worse with
|
||
* each of them. The Ambidextrous talent removes the main-hand penalty entirely and
|
||
* halves the off-hand one; nothing removes the off-hand penalty altogether.
|
||
*
|
||
* The off-hand weapon must be lighter than the main hand, which is the rule that
|
||
* stops two poleaxes.
|
||
*/
|
||
// Reviewed and corrected: the trained profile was 0/−20, which is very nearly a free
|
||
// second attack and therefore a compulsory pick for anyone who can hold two things.
|
||
// Ambidextrous now buys CONSISTENCY, not a free action — both hands at −20 — which is
|
||
// still plainly worth having and is no longer the only sane build.
|
||
export const TWO_WEAPON = { main: -20, off: -40, trainedMain: -20, trainedOff: -20,
|
||
offHandMaxKg: 2.0 };
|
||
|
||
export function twoWeaponPenaltyFor({ trained = false } = {}) {
|
||
return trained
|
||
? { main: TWO_WEAPON.trainedMain, off: TWO_WEAPON.trainedOff }
|
||
: { main: TWO_WEAPON.main, off: TWO_WEAPON.off };
|
||
}
|
||
|
||
/** Can this actually be paired? The off hand takes the lighter thing, and not much. */
|
||
export function canPairWeapons(mainKg, offKg) {
|
||
const m = Number(mainKg) || 0, o = Number(offKg) || 0;
|
||
if (o > TWO_WEAPON.offHandMaxKg) return false;
|
||
return o <= m;
|
||
}
|
||
|
||
/* -------------------------------------------- */
|
||
/* The ways the world kills you */
|
||
/* -------------------------------------------- */
|
||
|
||
/**
|
||
* FALLING.
|
||
*
|
||
* BRP-standard: a die of damage per three metres, to a random location, and armour
|
||
* does not help because the ground is not a weapon. A fall onto something soft or a
|
||
* controlled landing halves the distance BEFORE the dice are counted, which is where
|
||
* Hard Landing and a descent harness earn their keep.
|
||
*/
|
||
export const FALL = { metresPerDie: 3, die: 6, maxDice: 20 };
|
||
|
||
export function fallDamageFor(metres, { soft = false, controlled = false } = {}) {
|
||
// Halving is not cumulative. Soft ground and a controlled descent are the same
|
||
// mitigation twice over, and stacking them turned a lethal drop into a stumble.
|
||
let m = Math.max(0, Number(metres) || 0);
|
||
if (soft || controlled) m /= 2;
|
||
const dice = Math.min(FALL.maxDice, Math.floor(m / FALL.metresPerDie));
|
||
return { dice, formula: dice > 0 ? `${dice}d${FALL.die}` : "0", ignoresArmour: true };
|
||
}
|
||
|
||
/**
|
||
* DROWNING, and anything else that stops you breathing.
|
||
*
|
||
* You hold on for CON rounds if you had warning and half that if you did not. After
|
||
* that it is one point of damage per round and it does not stop, because the water
|
||
* does not get bored. Armour is irrelevant and so is a Major Wound: this is the one
|
||
* track that runs straight to zero.
|
||
*/
|
||
export const ASPHYXIA = { perRound: 1, unpreparedFraction: 0.5 };
|
||
|
||
export function breathRoundsFor(con, { prepared = true } = {}) {
|
||
const c = Math.max(1, Number(con) || 1);
|
||
return Math.max(1, Math.floor(prepared ? c : c * ASPHYXIA.unpreparedFraction));
|
||
}
|
||
|
||
/**
|
||
* FIRE.
|
||
*
|
||
* Fire is not one hit, it is a condition with a size, and the size is what matters.
|
||
* Armour protects on the round you are set alight and never again — sealed suits
|
||
* excepted, which is the entire reason to own one.
|
||
*/
|
||
// `armour` means "worn protection helps on the round you CATCH". It never helps
|
||
// afterwards, because by then the fire is inside it. Only a sealed suit is different
|
||
// and that is the whole reason to sign one out.
|
||
export const FIRE = [
|
||
{ id: "spark", label: "RINGBRP.Fire.spark", damage: "1d3", rounds: 1, armour: true },
|
||
{ id: "clothes", label: "RINGBRP.Fire.clothes", damage: "1d6", rounds: 3, armour: true },
|
||
{ id: "pool", label: "RINGBRP.Fire.pool", damage: "2d6", rounds: 4, armour: true },
|
||
{ id: "engulf", label: "RINGBRP.Fire.engulf", damage: "3d6", rounds: 6, armour: true }
|
||
];
|
||
|
||
export function fireBandFor(id) {
|
||
return FIRE.find(f => f.id === id) ?? FIRE[1];
|
||
}
|
||
|
||
/**
|
||
* EXPLOSIVES.
|
||
*
|
||
* One roll of damage at the centre, halved at each band outward, and cover is worth
|
||
* more than armour — which is the lesson every agency learns and writes down.
|
||
* A charge placed deliberately, with time, hits the centre band automatically.
|
||
*/
|
||
export const BLAST = [
|
||
{ id: "contact", label: "RINGBRP.Blast.contact", share: 1, armour: 0.5 },
|
||
{ id: "near", label: "RINGBRP.Blast.near", share: 0.5, armour: 1 },
|
||
{ id: "far", label: "RINGBRP.Blast.far", share: 0.25, armour: 1 }
|
||
];
|
||
|
||
export function blastBandFor(metres, radius) {
|
||
const m = Math.max(0, Number(metres) || 0), r = Math.max(1, Number(radius) || 1);
|
||
if (m <= r / 2) return BLAST[0];
|
||
if (m <= r) return BLAST[1];
|
||
if (m <= r * 2) return BLAST[2];
|
||
return null; // outside it entirely
|
||
}
|
||
|
||
/**
|
||
* ONE formula, floored ONCE. Rolled x range share x cover, rounded down, and only
|
||
* then does armour come off. Flooring at each step changed the answer depending on
|
||
* the order somebody happened to call these in.
|
||
*/
|
||
export function blastShareFor(rolled, band, cover = "none") {
|
||
if (!band) return 0;
|
||
const f = BLAST_COVER[cover] ?? 1;
|
||
return Math.max(0, Math.floor((Number(rolled) || 0) * band.share * f));
|
||
}
|
||
|
||
/** Cover is the answer to a blast. Armour is only ever half of one. */
|
||
export const BLAST_COVER = { none: 1, partial: 0.5, solid: 0.25, sealed: 0 };
|
||
|
||
export function blastAfterCover(damage, cover = "none") {
|
||
const f = BLAST_COVER[cover] ?? 1;
|
||
return Math.max(0, Math.floor((Number(damage) || 0) * f));
|
||
}
|
||
|
||
/**
|
||
* WHAT DESTROYING A LOCATION ACTUALLY DOES.
|
||
*
|
||
* The printed effect for a destroyed head reads "For anything that keeps its brain
|
||
* there, this is death" — and the code set `unconscious` and stopped. An agent could
|
||
* have every location on their body destroyed, head included, and the sheet would
|
||
* report them as DOWN. Destruction of something you cannot live without is not a
|
||
* penalty, and this is the rule that says so.
|
||
*
|
||
* DISABLED is not destroyed: a disabled head is a concussion and stays a concussion.
|
||
* Only DESTROYED reaches here.
|
||
*/
|
||
export function destructionOutcomeFor(kind, destroyed = false) {
|
||
if (!destroyed) return "none";
|
||
switch (kind) {
|
||
case "head": return "dead"; // whatever keeps its brain there no longer does
|
||
case "ridge": case "vital": return "dying";
|
||
default: return "none"; // limbs are ruinous, not fatal
|
||
}
|
||
}
|
||
|
||
/**
|
||
* WHAT HAPPENS AT ZERO.
|
||
*
|
||
* Nothing did. An agent reduced to 0 hit points was marked down and then lay there
|
||
* indefinitely: bleeding deliberately stops at 0, no rule advanced anything, and the
|
||
* only mention of death in the whole system was flavour text on a destroyed head.
|
||
* A game with hit locations, Major Wounds and bleeding had no way for anyone to die.
|
||
*
|
||
* Hit points are NOT taken below zero — every damage path clamps there, and unpicking
|
||
* that would touch everything. Instead, zero starts a clock:
|
||
*
|
||
* above 2 you are up
|
||
* 2 or below DOWN — out of the fight, still conscious, still yours
|
||
* 0 DYING — unconscious, no actions, no defences, and a count begins
|
||
* count met DEAD
|
||
*
|
||
* The count is the agent's own Major Wound threshold, so a solid Custodian has longer
|
||
* on the floor than a slight one, which is the same number already deciding how much
|
||
* damage they can take standing up. First Aid or Medicine STABILISES: the count stops
|
||
* and they stay at 0, unconscious, until somebody heals them above it.
|
||
*/
|
||
export const DYING = { perRound: 1, minCount: 2, stabiliseWith: ["first_aid", "medicine"] };
|
||
|
||
/** How many rounds this agent has at zero before the count is met. */
|
||
export function dyingLimitFor(majorWound) {
|
||
return Math.max(DYING.minCount, Number(majorWound) || 0);
|
||
}
|
||
|
||
/** The four states, from one place, so nothing has to re-derive them. */
|
||
export function conditionFor({ hp = 0, dyingRounds = 0, majorWound = 0,
|
||
stabilised = false, unconscious = false } = {}) {
|
||
const h = Number(hp) || 0;
|
||
if (h <= 0) {
|
||
if (!stabilised && (Number(dyingRounds) || 0) >= dyingLimitFor(majorWound)) return "dead";
|
||
return "dying";
|
||
}
|
||
if (h <= 2 || unconscious) return "down";
|
||
return "up";
|
||
}
|
||
|
||
/**
|
||
* THE LAST ENTRY.
|
||
*
|
||
* A Custodian's death is a filing question, because everything here is. How well
|
||
* filed they were when they died decides what the record ends up saying, and the
|
||
* badly filed do not get recorded as dead at all — which is the department's
|
||
* problem, and then somebody else's.
|
||
*/
|
||
export const LAST_ENTRY = [
|
||
{ atLeast: 7, id: "recorded", label: "RINGBRP.Death.Entry.recorded" },
|
||
{ atLeast: 4, id: "queried", label: "RINGBRP.Death.Entry.queried" },
|
||
{ atLeast: 1, id: "unclosed", label: "RINGBRP.Death.Entry.unclosed" },
|
||
{ atLeast: 0, id: "transferred", label: "RINGBRP.Death.Entry.transferred" }
|
||
];
|
||
|
||
export function lastEntryFor(coherence) {
|
||
const c = Number(coherence) || 0;
|
||
return LAST_ENTRY.find(e => c >= e.atLeast) ?? LAST_ENTRY[LAST_ENTRY.length - 1];
|
||
}
|
||
|
||
/**
|
||
* HOW HARD A CASE PHASE IS.
|
||
*
|
||
* The old ramp made every phase after the first Difficult — which HALVES the lead's
|
||
* skill — and then stacked a cumulative −5% on top. Measured across two played
|
||
* cases, a realistic lead of 40–55% (Tradecraft is a support skill on most postings
|
||
* that train it at all) faced 20% → 15% → 10% → 5% → 1%, and both cases were decided
|
||
* by Resources running out rather than by anything the players did.
|
||
*
|
||
* The two pressures are now separated, because they were never the same idea:
|
||
*
|
||
* DIFFICULTY is about the phase. Only Containment is Difficult — the step the case
|
||
* is actually about — and it does not also take the ramp. It is hard because it is
|
||
* containment, not because you are tired.
|
||
*
|
||
* The MODIFIER is about attrition. It accumulates as the case wears on, and it
|
||
* stops accumulating, because a fifth phase is not four times worse than a second.
|
||
*/
|
||
export const PHASE_RAMP = { step: -5, cap: -15, hardKinds: ["containment"] };
|
||
|
||
export function phaseRampFor(index, kindId = "") {
|
||
const hard = PHASE_RAMP.hardKinds.includes(String(kindId));
|
||
return {
|
||
difficulty: hard ? "difficult" : "average",
|
||
// A hard phase carries its difficulty INSTEAD of the ramp, never both.
|
||
modifier: hard ? 0 : Math.max(PHASE_RAMP.cap, PHASE_RAMP.step * (Number(index) || 0))
|
||
};
|
||
}
|
||
|
||
/* -------------------------------------------- */
|
||
/* Borrowed Authority */
|
||
/* -------------------------------------------- */
|
||
|
||
/**
|
||
* ACCESS IS A RECORD.
|
||
*
|
||
* There is no hacking skill and no network to enter. What an agent has is a named
|
||
* credential belonging to somebody real — a facilities contractor, a locum, a
|
||
* supplier's engineer — with a scope of things that person could plausibly do, a
|
||
* Trust that erodes as it is used, and a red flag or two describing how the real
|
||
* owner behaves. Inside its scope the credential simply works. Outside it, you are
|
||
* asking a system to believe something about a person, and that is a roll.
|
||
*
|
||
* The point of the design in a game about reality keeping a record of what belongs
|
||
* where: an Authority IS a record, and Coherence can widen it, because reality can
|
||
* be made to remember that the person held that duty. What it costs is that the
|
||
* agent's own record starts merging with the one they borrowed.
|
||
*/
|
||
|
||
/** What a credential could conceivably be asked to do, in order. */
|
||
export const SCOPE_TIERS = ["nothing", "read", "routine", "privileged", "administrative"];
|
||
|
||
export function scopeRank(tier) {
|
||
const i = SCOPE_TIERS.indexOf(String(tier));
|
||
return i < 0 ? 0 : i;
|
||
}
|
||
|
||
/**
|
||
* How an action stands against a credential's scope. This is the rule that stops
|
||
* "I hack anything": two tiers beyond the credential is not hard, it is impossible,
|
||
* and no percentage on any sheet changes that.
|
||
*
|
||
* at or below scope -> it simply works. No roll. 1 Trust.
|
||
* exactly one above -> a stretch. Roll for it.
|
||
* two or more above -> impossible. The identity was never able to do this.
|
||
*/
|
||
export function scopeStanceFor(authorityScope, actionTier) {
|
||
const gap = scopeRank(actionTier) - scopeRank(authorityScope);
|
||
if (gap <= 0) return "in-scope";
|
||
if (gap === 1) return "stretch";
|
||
return "impossible";
|
||
}
|
||
|
||
/** Behaving unlike the person you are claiming to be is what gets noticed. */
|
||
export const RED_FLAG_STEP = -20;
|
||
|
||
export function scrutinyFor(flagsTripped = 0) {
|
||
return RED_FLAG_STEP * Math.max(0, Number(flagsTripped) || 0);
|
||
}
|
||
|
||
/**
|
||
* What a stretched use of a credential does to it. Mirrors the defence ladder: the
|
||
* band is the whole answer, and a fumble is not merely a failure — it burns the
|
||
* credential and puts the person it belongs to in the department's way.
|
||
*/
|
||
export function authorityOutcomeFor(level) {
|
||
switch (level) {
|
||
case "critical": return { works: true, trustDelta: +1, burned: false };
|
||
case "special": return { works: true, trustDelta: 0, burned: false };
|
||
case "success": return { works: true, trustDelta: -1, burned: false };
|
||
case "fumble": return { works: false, trustDelta: -99, burned: true };
|
||
default: return { works: false, trustDelta: -1, burned: false };
|
||
}
|
||
}
|
||
|
||
/** Trust never rises above what the dossier bought, and stops at nothing. */
|
||
export const TRUST_MAX = 4;
|
||
|
||
export function trustAfter(current, delta, max = TRUST_MAX) {
|
||
const t = (Number(current) || 0) + (Number(delta) || 0);
|
||
return Math.max(0, Math.min(Number(max) || TRUST_MAX, t));
|
||
}
|
||
|
||
/**
|
||
* THE DOSSIER — how a credential is obtained.
|
||
*
|
||
* Not a second subsystem and not a stack of flat bonuses: a dossier item does not
|
||
* make the roll easier, it makes the credential better. Each item is a separate
|
||
* scene using a different skill, and at most one may be Computer Use, so assembling
|
||
* access is something the whole team does rather than one specialist.
|
||
*
|
||
* Trust starts at 1 and rises by one per item. Scope starts at "read" and a single
|
||
* item — the one that establishes what the person is actually FOR — may raise it.
|
||
*/
|
||
export const DOSSIER = { maxItems: 3, baseTrust: 1, baseScope: "read", maxComputerUse: 1 };
|
||
|
||
export function dossierTrustFor(itemCount) {
|
||
return Math.max(1, Math.min(TRUST_MAX, DOSSIER.baseTrust + (Number(itemCount) || 0)));
|
||
}
|
||
|
||
/** A dossier that establishes remit widens what the credential covers, once. */
|
||
export function dossierScopeFor(itemCount, establishesRemit = false) {
|
||
const base = scopeRank(DOSSIER.baseScope);
|
||
const up = (establishesRemit && (Number(itemCount) || 0) >= 2) ? 1 : 0;
|
||
return SCOPE_TIERS[Math.min(SCOPE_TIERS.length - 1, base + up)];
|
||
}
|
||
|
||
/**
|
||
* Coherence widens a scope by making reality remember that the person held that
|
||
* duty. The cost rises with how far you have already pushed the record — the first
|
||
* lie is cheap and the fourth is not.
|
||
*/
|
||
export function widenScopeCostFor(timesWidened = 0) {
|
||
return 1 + Math.max(0, Number(timesWidened) || 0);
|
||
}
|
||
|
||
/**
|
||
* The horror, with a number on it. Every time an Authority is pushed past what its
|
||
* Trust can carry, the agent's file and the borrowed one converge one step. This is
|
||
* what the subsystem is actually about and it is deliberately not reversible by
|
||
* spending anything.
|
||
*/
|
||
export const MERGE_STAGES = [
|
||
"RINGBRP.Authority.Merge.0",
|
||
"RINGBRP.Authority.Merge.1",
|
||
"RINGBRP.Authority.Merge.2",
|
||
"RINGBRP.Authority.Merge.3",
|
||
"RINGBRP.Authority.Merge.4"
|
||
];
|
||
|
||
export function mergeStageFor(steps) {
|
||
const n = Math.max(0, Math.min(MERGE_STAGES.length - 1, Number(steps) || 0));
|
||
return { step: n, label: MERGE_STAGES[n], terminal: n >= MERGE_STAGES.length - 1 };
|
||
}
|
||
|
||
/**
|
||
* Fists are a fallback, not a peer of the weapon you are carrying.
|
||
*
|
||
* A generated adversary was given Brawl from the same range as its weapon skills,
|
||
* so an Armed thug was exactly as good with its hands as with the pistol on its
|
||
* hip — and any sensible target-picker chose the fists, which then bounced off
|
||
* armour 2 for 1d3. Brawl is now a fraction of the trained rating, never below the
|
||
* skill's printed base.
|
||
*
|
||
* This does NOT apply to a creature that ATTACKS with Brawl: the anomalous grasp is
|
||
* a trained attack and keeps its full rating. The caller decides which case it is.
|
||
*/
|
||
export const BRAWL_FALLBACK = { fraction: 0.6, floor: 25 };
|
||
|
||
export function brawlFallbackFor(trainedRating) {
|
||
const r = Math.round((Number(trainedRating) || 0) * BRAWL_FALLBACK.fraction);
|
||
return Math.max(BRAWL_FALLBACK.floor, r);
|
||
}
|
||
|
||
/**
|
||
* A skill whose base is written as a formula rather than a flat number.
|
||
*
|
||
* Dodge is the only one, and its base is DEX×2 — which nothing in the system ever
|
||
* worked out. `base.mode` was "formula", `base.value` stayed 0, and every reader
|
||
* took `system.value` at face value, so an agent whose posting did not train Dodge
|
||
* had Dodge 0%: the game's universal defence, unavailable to everybody who had not
|
||
* separately bought it, and missing its floor even for those who had.
|
||
*
|
||
* Deliberately not eval(). Accepts `char`, `char*n`, `char+n` and `char*n+m`.
|
||
*/
|
||
export function skillBaseFrom(formula, characteristics = {}) {
|
||
const m = /^\s*([a-z]+)\s*(?:\*\s*(\d+))?\s*(?:\+\s*(\d+))?\s*$/i.exec(String(formula || ""));
|
||
if (!m) return 0;
|
||
const stat = Number(characteristics?.[m[1].toLowerCase()]?.value
|
||
?? characteristics?.[m[1].toLowerCase()]) || 0;
|
||
return Math.max(0, stat * (m[2] ? Number(m[2]) : 1) + (m[3] ? Number(m[3]) : 0));
|
||
}
|
||
|
||
/**
|
||
* The five outcome bands, worst to best. Exported so nothing has to re-declare
|
||
* the order to compare two rolls against each other.
|
||
*/
|
||
export const LEVEL_LADDER = ["fumble", "failure", "success", "special", "critical"];
|
||
|
||
/** Where a level sits on the ladder. Anything unknown reads as a failure. */
|
||
export function levelRank(level) {
|
||
const i = LEVEL_LADDER.indexOf(String(level));
|
||
return i < 0 ? 1 : i;
|
||
}
|
||
|
||
/**
|
||
* What a defence actually does to the blow it answers.
|
||
*
|
||
* The old rule was binary: any successful dodge or parry stopped anything, so a
|
||
* boarding-axe critical was cancelled outright by an ordinary parry and cost the
|
||
* attacker nothing. That sat badly against every other thing the system says about
|
||
* criticals — maximum damage, armour ignored entirely — and it made the best roll
|
||
* in the game worth no more than the commonest one.
|
||
*
|
||
* Defences are now graded. A defence stops a blow of its own quality or worse
|
||
* outright. Against something better it still helps, but only by as much as it was
|
||
* outclassed by: the blow lands one step worse for every step the defence fell
|
||
* short. So the attack degrades a critical to a special, a special to a success,
|
||
* and a success to nothing — and only a critical defence stops a critical.
|
||
*
|
||
* landing rank = failure + (attack rank − defence rank)
|
||
*
|
||
* A failed defence does nothing at all and the blow lands at its own level.
|
||
*/
|
||
export function defenceOutcomeFor(attackLevel, defenceLevel) {
|
||
const a = levelRank(attackLevel);
|
||
const d = levelRank(defenceLevel);
|
||
// A defence that did not succeed never reduces anything.
|
||
if (d < levelRank("success")) {
|
||
return { turnedAside: false, landsAt: LEVEL_LADDER[a], steps: 0 };
|
||
}
|
||
const steps = a - d;
|
||
if (steps <= 0) return { turnedAside: true, landsAt: null, steps: 0 };
|
||
return { turnedAside: false, landsAt: LEVEL_LADDER[levelRank("failure") + steps], steps };
|
||
}
|
||
|
||
/**
|
||
* You may not mix your defences in a round: having chosen to dodge, you dodge.
|
||
* Returns the type you are allowed to use, given what you have already done.
|
||
*/
|
||
export function defenceTypeAllowed(lock, wanted) {
|
||
return !lock || lock === wanted ? wanted : lock;
|
||
}
|
||
|
||
/**
|
||
* THE QUIET ARTS.
|
||
*
|
||
* A power is not a spell. Crossing is an induced filing error — you persuade the
|
||
* record you are somewhere else — and an Art is the same trick performed small and
|
||
* on purpose: a misfiling you commit deliberately. So an Art costs the one thing
|
||
* that misfiling always costs, which is Coherence, and needs no second resource.
|
||
*
|
||
* Nobody learns one. They arrive from crossings, in the people the crossing
|
||
* changed, which is why the category the agency files them under is the same one
|
||
* headed "the crossing left this in you".
|
||
*/
|
||
export const ARTS = {
|
||
thread: { order: 1, coherence: 1, push: 2 },
|
||
echo: { order: 2, coherence: 1, push: 2 },
|
||
hollow: { order: 3, coherence: 1, push: 3 },
|
||
witness: { order: 4, coherence: 1, push: 2 },
|
||
unmake: { order: 5, coherence: 2, push: 4 },
|
||
ledger: { order: 6, coherence: 2, push: 4 }
|
||
};
|
||
|
||
/** What using an Art costs, ordinarily and when pushed past its reach. */
|
||
export function powerCostFor(artId, { pushed = false } = {}) {
|
||
const a = ARTS[artId];
|
||
if (!a) return pushed ? 2 : 1;
|
||
return pushed ? a.push : a.coherence;
|
||
}
|
||
|
||
/**
|
||
* An Art is governed by POW, and its starting rating is what the crossing left
|
||
* behind rather than anything the agent trained: POW plus a little, capped low.
|
||
* They are dangerous because of what they do, not because of what they roll.
|
||
*/
|
||
export function powerStartingRating(pow) {
|
||
return Math.max(5, Math.min(45, (Number(pow) || 0) * 2));
|
||
}
|
||
|
||
/**
|
||
* COMING BACK.
|
||
*
|
||
* The return is a second crossing and it has to be aimed. R-56 holds: it is never
|
||
* impossible, only dearer — a failure arrives you NEAR your target rather than at
|
||
* it, and a fumble brings you back changed.
|
||
*
|
||
* "Changed" is where the Quiet Arts come from. Nobody learns one; they arrive in
|
||
* people a crossing went wrong for, which is why the pool an agent draws from is
|
||
* decided by how far they had already drifted when it happened. You do not choose
|
||
* what you get, and the worse the state you were in, the worse the thing that
|
||
* follows you home.
|
||
*/
|
||
export const CROSSING = {
|
||
fumbleCoherence: 2, // a botched return costs this on top of everything else
|
||
failCoherence: 1
|
||
};
|
||
|
||
/** Which Arts can arrive, given how anchored the agent was when it went wrong. */
|
||
export function crossingArtPoolFor(coherence) {
|
||
const v = Number(coherence) || 0;
|
||
if (v >= 5) return ["thread", "echo", "witness"]; // still mostly here
|
||
if (v >= 3) return ["hollow", "thread", "echo"]; // adrift
|
||
return ["unmake", "ledger", "hollow"]; // unmoored or worse
|
||
}
|
||
|
||
/**
|
||
* Did the crossing leave something behind? A fumble always does. A failure only
|
||
* does to an agent who was already coming apart.
|
||
*/
|
||
export function crossingGrantsArt(level, coherence) {
|
||
if (level === "fumble") return true;
|
||
if (level === "failure") return (Number(coherence) || 0) <= 2;
|
||
return false;
|
||
}
|
||
|
||
/** What a return of this quality costs in Coherence. */
|
||
export function crossingCostFor(level) {
|
||
if (level === "fumble") return CROSSING.fumbleCoherence;
|
||
if (level === "failure") return CROSSING.failCoherence;
|
||
return 0;
|
||
}
|