1.7.1 carried wounds across a change of body plan by moving the raw points. Playing it showed that is the wrong invariant. A location is disabled at its maximum and destroyed at twice it, those maxima come from `frac`, and the plans disagree — an arm is 0.35 of the body and a foreleg 0.42 — so moving the number silently changed the meaning. Measured across the 20 plan changes: 234 severity flips, 138 of them turning a location that was merely disabled into a destroyed one. A destroyed arm arrived as a working-ish foreleg; two disabled arms stacked onto one Vesh grasping limb and arrived destroyed. So severity is now the invariant. A hurt location arrives hurt, a disabled one disabled, a destroyed one destroyed; the points are rescaled to the destination's own maximum to keep that true, and two wounds sharing one location take the worse rather than the sum, because relabelling a creature's anatomy must not destroy a limb that nothing in play destroyed. Correspondence is now stated rather than inferred. Every location gains a plan-independent `slot` (lowerR, upperL, vital, head...), and the remap matches on slot first. The previous version inferred it from `kind` plus table order, which inverted left and right on every crossing: a wounded right arm came back as the opposite hind leg. Where a destination has no matching slot the fallback prefers the same side and the same height, so a wing becomes a foreleg rather than a hind leg and a left wing becomes a LEFT arm. The vesh and the cadence are not bilateral — one grasping limb, five interchangeable units — so their slots carry no side, which is a fix to my own first pass: giving them sided slots was a category error the side guard caught. check-anatomy now proves all four promises over every ordered pair and severity band: nothing is stranded, severity is preserved, left never becomes right and fore never becomes hind between bilateral plans, and no update deletes a wound it is also setting. 432 carried wounds, 0 flips, down from 234. Five induced failures confirm each assertion fires; two of my first attempts at those did NOT fire and were sharpened until they did. Played through in a live world: a church grim filed as a humanoid with a destroyed right arm, a disabled left arm and a hurt chest becomes a dog with a destroyed right foreleg, a disabled left foreleg and hurt forequarters, and converts back to exactly the wounds it started with. Its penalties correctly stop being manipulation and start being movement — a man with two ruined arms drops his weapon, a dog with two ruined forelegs cannot run. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1944 lines
86 KiB
JavaScript
1944 lines
86 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.
|
||
*/
|
||
|
||
// The species anatomies. anatomy.mjs imports nothing and touches nothing, so this stays
|
||
// inside the rule above: it is data the hit-location rules are defined over, and both
|
||
// the engine and the node-side tools read that one copy.
|
||
import { LOCATION_TABLES } from "./anatomy.mjs";
|
||
|
||
export const ceil = Math.ceil;
|
||
|
||
/** Hit points: half of CON + SIZ, standard BRP. */
|
||
export function hitPointsFor(con, siz) {
|
||
return Math.max(1, ceil(((Number(con) || 0) + (Number(siz) || 0)) / 2));
|
||
}
|
||
|
||
/** A Major Wound is a blow of half your hit points or more. */
|
||
export function majorWoundFor(hp) {
|
||
return Math.max(1, ceil((Number(hp) || 0) / 2));
|
||
}
|
||
|
||
/** Damage modifier from STR + SIZ. */
|
||
export function damageModifierFor(total) {
|
||
const t = Number(total) || 0;
|
||
if (t <= 8) return "-1d6";
|
||
if (t <= 16) return "-1d3";
|
||
if (t <= 24) return "0";
|
||
if (t <= 32) return "+1d3";
|
||
if (t <= 40) return "+1d6";
|
||
if (t <= 50) return "+2d6";
|
||
if (t <= 60) return "+3d6";
|
||
if (t <= 70) return "+4d6";
|
||
return `+${4 + ceil((t - 70) / 10)}d6`;
|
||
}
|
||
|
||
/** Marksmanship is the agent's own choice now that home worlds are gone. */
|
||
export function styleFor(chosen = "reflex") { return chosen; }
|
||
|
||
/** A location's hit points, as a fraction of the total pool. */
|
||
export function locationMaxHp(totalHp, frac) {
|
||
return Math.max(1, ceil((Number(totalHp) || 0) * (Number(frac) || 0)));
|
||
}
|
||
|
||
/** Reaction: the two axes and the printed base. */
|
||
export const REACTION = { dexDivisor: 3, readinessDivisor: 10, laneMultiplier: 3, die: 6, modCap: 3 };
|
||
export function reflexRating(dex) { return Math.max(1, ceil((Number(dex) || 0) / REACTION.dexDivisor)); }
|
||
export function awarenessRating(readiness) { return Math.max(1, ceil((Number(readiness) || 0) / REACTION.readinessDivisor)); }
|
||
export function reactionBaseFrom(dex, readiness) {
|
||
return REACTION.laneMultiplier * Math.max(reflexRating(dex), awarenessRating(readiness));
|
||
}
|
||
|
||
/** Encumbrance: whole steps drive MOV and skills, the continuous figure drives Reaction. */
|
||
/**
|
||
* Bleeding, both halves of it.
|
||
*
|
||
* The table printed a rate and a way to stop it, and the game implemented neither:
|
||
* nothing ticked the loss during play, and First Aid healed hit points without ever
|
||
* clearing the wound. So bleeding was inert at the table and unstoppable in
|
||
* simulation — the same rule failing in both directions at once.
|
||
*
|
||
* `perRound` is the loss at the end of each round. `stop` lists what ends it; ANY
|
||
* one entry is enough. A stop entry with a `difficulty` requires the roll to have
|
||
* been made at that difficulty or harder, which is what "a Difficult First Aid"
|
||
* means; entries without one accept any difficulty.
|
||
*/
|
||
export const BLEED = {
|
||
// The table says "a successful First Aid" for bleeding. Medicine is added because
|
||
// the printed rule left a surgeon unable to stop the lesser wound they could treat.
|
||
bleeding: { perRound: 1,
|
||
stop: [{ family: "first_aid" }, { family: "medicine" }] },
|
||
deepBleeding: { perRound: 2,
|
||
stop: [{ family: "first_aid", difficulty: "difficult" },
|
||
{ family: "medicine" }] }
|
||
};
|
||
|
||
const DIFFICULTY_RANK = { easy: 0, average: 1, difficult: 2, formidable: 3 };
|
||
|
||
/** Does this successful roll stop this kind of bleeding? */
|
||
export function bleedStoppedBy(key, { family = "", difficulty = "average" } = {}) {
|
||
const spec = BLEED[key];
|
||
if (!spec) return false;
|
||
return spec.stop.some(s =>
|
||
s.family === family &&
|
||
(s.difficulty === undefined ||
|
||
(DIFFICULTY_RANK[difficulty] ?? 1) >= (DIFFICULTY_RANK[s.difficulty] ?? 1)));
|
||
}
|
||
|
||
/** Total loss per round from every bleeding wound an actor carries. */
|
||
export function bleedRateFor(wounds = []) {
|
||
return (Array.isArray(wounds) ? wounds : [])
|
||
.reduce((n, w) => n + (BLEED[w?.key]?.perRound ?? 0), 0);
|
||
}
|
||
|
||
/**
|
||
* Major Wound consequences, as penalties rather than prose.
|
||
*
|
||
* The table used to be rolled, announced and recorded, and then change nothing: a
|
||
* `manipulation` wound whose own text reads "−30% Manipulation" applied zero,
|
||
* `concussed` ("−20% to all rolls") applied zero, and `mobility` ("MOV halved")
|
||
* left MOV untouched. Only crippling and catastrophic did anything, through the
|
||
* separate isDown check. This turns each row into numbers the roll pipeline reads.
|
||
*
|
||
* Takes the recorded `system.wounds` array; returns penalties to FOLD INTO the
|
||
* location-derived wound state, never to replace it. Wounds stack: two concussions
|
||
* are −40%, and a Major Wound stacks with a shattered limb, because both are true.
|
||
*
|
||
* `all` is applied to every category by woundPenaltyFor. The per-category numbers
|
||
* are additional and specific.
|
||
*
|
||
* Variants: `sensory` and `broken` each split two ways on 1D2, chosen when the
|
||
* wound is rolled and stored on the entry. An absent variant falls back to the
|
||
* harsher reading, so a missing field never quietly costs the target nothing.
|
||
*/
|
||
export function woundEffectsFor(wounds = []) {
|
||
const eff = { all: 0, manipulation: 0, physical: 0, perception: 0,
|
||
communication: 0, movFactor: 1, movFloor: 0, bleed: 0,
|
||
defenceOnly: false, unconscious: false, lostAction: false };
|
||
for (const w of Array.isArray(wounds) ? wounds : []) {
|
||
switch (w?.key) {
|
||
case "concussed": eff.all -= 20; break;
|
||
case "winded": eff.physical -= 20; eff.lostAction = true; break;
|
||
case "bleeding":
|
||
case "deepBleeding": eff.bleed += BLEED[w.key].perRound; break;
|
||
case "manipulation": eff.manipulation -= 30; break;
|
||
case "mobility": eff.physical -= 30; eff.movFactor *= 0.5; break;
|
||
case "sensory":
|
||
// sight also blinds you to what you are shooting at; hearing does not
|
||
eff.perception -= 40;
|
||
if (w.variant === "hearing") eff.communication -= 40;
|
||
else eff.manipulation -= 40;
|
||
break;
|
||
case "broken":
|
||
if (w.variant === "mobility") { eff.physical -= 30; eff.movFactor *= 0.5; }
|
||
else eff.manipulation -= 30;
|
||
break;
|
||
case "crippling": eff.defenceOnly = true; eff.movFloor = 1; eff.movFactor = 0; break;
|
||
case "catastrophic": eff.unconscious = true; eff.defenceOnly = true;
|
||
eff.movFloor = 1; eff.movFactor = 0; break;
|
||
}
|
||
}
|
||
return eff;
|
||
}
|
||
|
||
/**
|
||
* Load on the reaction, exact. One point per 10 kg over free carry, with no
|
||
* staircase — this is the granular rule R-23 asked for, that no weight is ever
|
||
* free and no single kilogramme ever costs a whole point. It replaces the old
|
||
* three-value band, which charged a full point for the first gram over and then
|
||
* nothing more until 10 kg.
|
||
*
|
||
* Rounded to one decimal because the value is printed on a paper sheet and added
|
||
* to a die by hand.
|
||
*/
|
||
export function reactionLoadFrom(srLoad) {
|
||
// Rounded UP, not to nearest. To nearest would make the first half-kilogramme
|
||
// free, and the rule is that no weight is ever free. A tenth of a point is the
|
||
// smallest unit worth writing on a paper sheet, so that is the floor of the cost.
|
||
return Math.ceil((Number(srLoad) || 0) * 10) / 10;
|
||
}
|
||
|
||
/**
|
||
* The number actually printed on the character sheet: the lane base less load.
|
||
* Floored at 1 — R-56, the critical-path invariant. No amount of kit may reduce
|
||
* an agent to acting never; it may only ever reduce them to acting last.
|
||
*/
|
||
export function reactionPrintedFrom(base, srLoad) {
|
||
const b = Number(base) || 0;
|
||
return Math.max(1, Math.round((b - reactionLoadFrom(srLoad)) * 10) / 10);
|
||
}
|
||
|
||
export function encumbranceFrom(carriedKg, freeCarryKg) {
|
||
const over = Math.max(0, (Number(carriedKg) || 0) - (Number(freeCarryKg) || 0));
|
||
const steps = Math.floor(over / 10);
|
||
const rounded = Math.round(over * 10) / 10;
|
||
// `over` and `overKg` are the same number under both names: the engine reads one,
|
||
// the guard's spot-checks the other, and a rename in either used to silently
|
||
// zero the encumbrance penalty. Kept deliberately, not by accident.
|
||
return { over: rounded, overKg: rounded, steps, movPenalty: steps,
|
||
skillPenalty: steps * 5, srLoad: over / 10 };
|
||
}
|
||
|
||
/* ============================================================
|
||
* Rule TABLES. Pure data, no Foundry, so the engine, the pack
|
||
* builder and the rules journal all read the same rows. The
|
||
* journal is generated from these — a number cannot be right in
|
||
* the code and wrong in the book.
|
||
* ============================================================ */
|
||
|
||
/** The situational ladder. Applied after difficulty, capped at +-40 in total. */
|
||
export const CIRCUMSTANCE = [
|
||
{ id: "overwhelming", mod: 30, label: "RINGBRP.Circ.Overwhelming" },
|
||
{ id: "favourable", mod: 20, label: "RINGBRP.Circ.Favourable" },
|
||
{ id: "slightGood", mod: 10, label: "RINGBRP.Circ.SlightGood" },
|
||
{ id: "neutral", mod: 0, label: "RINGBRP.Circ.Neutral" },
|
||
{ id: "slightBad", mod: -10, label: "RINGBRP.Circ.SlightBad" },
|
||
{ id: "unfavourable", mod: -20, label: "RINGBRP.Circ.Unfavourable" },
|
||
{ id: "severe", mod: -30, label: "RINGBRP.Circ.Severe" }
|
||
];
|
||
|
||
/** Range. `mod` null means the shot cannot be attempted at all. */
|
||
export const RANGE_LADDER = [
|
||
{ id: "short", mod: 0, upto: r => r.short },
|
||
{ id: "medium", mod: -10, upto: r => r.medium },
|
||
{ id: "long", mod: -30, upto: r => r.long },
|
||
{ id: "extreme", mod: -50, upto: r => r.long * 2 },
|
||
{ id: "beyond", mod: null, upto: () => Infinity }
|
||
];
|
||
|
||
export function rangeBandFrom(distance, ranges) {
|
||
const d = Number(distance) || 0;
|
||
const r = { short: Number(ranges?.short) || 0, medium: Number(ranges?.medium) || 0,
|
||
long: Number(ranges?.long) || 0 };
|
||
if (!r.short && !r.medium && !r.long) return { band: "short", mod: 0 };
|
||
for (const step of RANGE_LADDER) {
|
||
const limit = step.upto(r);
|
||
if (d <= limit) return { band: step.id, mod: step.mod };
|
||
}
|
||
return { band: "beyond", mod: null };
|
||
}
|
||
|
||
/** 1D10 on a Major Wound. Consequences are in woundEffectsFor. */
|
||
export const MAJOR_WOUND_TABLE = [
|
||
{ min: 1, max: 1, key: "concussed" },
|
||
{ min: 2, max: 2, key: "winded" },
|
||
{ min: 3, max: 3, key: "bleeding" },
|
||
{ min: 4, max: 4, key: "manipulation" },
|
||
{ min: 5, max: 5, key: "mobility" },
|
||
{ min: 6, max: 6, key: "deepBleeding" },
|
||
{ min: 7, max: 7, key: "sensory" },
|
||
{ min: 8, max: 8, key: "broken" },
|
||
{ min: 9, max: 9, key: "crippling" },
|
||
{ min: 10, max: 10, key: "catastrophic" }
|
||
];
|
||
|
||
/** Coherence. 10 is anchored, 0 is displaced and permanent. */
|
||
export const COHERENCE_BANDS = [
|
||
{ min: 8, id: "anchored", label: "RINGBRP.Coh.Anchored", all: 0, comm: 0, note: "RINGBRP.Coh.AnchoredNote" },
|
||
{ min: 5, id: "loose", label: "RINGBRP.Coh.Loose", all: 0, comm: -5, note: "RINGBRP.Coh.LooseNote" },
|
||
{ min: 3, id: "adrift", label: "RINGBRP.Coh.Adrift", all: -5, comm: -10, note: "RINGBRP.Coh.AdriftNote" },
|
||
{ min: 1, id: "unmoored", label: "RINGBRP.Coh.Unmoored", all: -10, comm: -20, note: "RINGBRP.Coh.UnmooredNote",
|
||
returnPenalty: -30 },
|
||
{ min: 0, id: "displaced", label: "RINGBRP.Coh.Displaced", all: -20, comm: -30, note: "RINGBRP.Coh.DisplacedNote" }
|
||
];
|
||
|
||
export const COHERENCE_COSTS = {
|
||
fumbleOnCrossing: { cost: 1, label: "RINGBRP.Coh.Cost.Fumble" },
|
||
witnessedParadox: { cost: 1, label: "RINGBRP.Coh.Cost.Paradox" },
|
||
usedCrossingTech: { cost: 1, label: "RINGBRP.Coh.Cost.Tech" },
|
||
brokeLocalLaw: { cost: 1, label: "RINGBRP.Coh.Cost.Local" },
|
||
metYourself: { cost: 2, label: "RINGBRP.Coh.Cost.Self" },
|
||
killedADuplicate: { cost: 3, label: "RINGBRP.Coh.Cost.Duplicate" },
|
||
overstayed: { cost: 1, label: "RINGBRP.Coh.Cost.Overstay" }
|
||
};
|
||
|
||
export const COHERENCE_RECOVERY = { debrief: 1, downtimeWeek: 1, anchorObject: 1 };
|
||
|
||
/* ============================================================
|
||
PANIC — the short track
|
||
============================================================
|
||
Coherence is the long one: 0-10, campaign-scale, what it costs to keep looking once
|
||
you know what you are looking at, recovered at a debrief. Panic is the other half and
|
||
they must not be confused at the table:
|
||
|
||
Coherence what knowing has cost you weeks 0-10, down
|
||
Panic what is happening to you NOW minutes 0-5, up
|
||
|
||
Panic goes UP. A trigger gives a point with no roll — asking for a die to find out
|
||
whether you are frightened is a die that slows the scene down and tells the player
|
||
how to feel. The roll is to get RID of it, which is the interesting direction and the
|
||
only one worth a check.
|
||
|
||
One free point on purpose: at Panic 1 you are rattled and roll at no penalty. It costs
|
||
the table nothing and it means the first shock of a scene is colour rather than
|
||
arithmetic.
|
||
============================================================ */
|
||
|
||
/* ============================================================
|
||
DEVELOPMENT — you learn from what beat you
|
||
============================================================
|
||
Call of Cthulhu marks a skill when you SUCCEED with it and improves it later.
|
||
This department works the other way round: a skill is marked when it FAILS you.
|
||
Nobody learns anything from the lock that opened first time.
|
||
|
||
The improvement roll itself is the familiar one and is deliberately unchanged: roll
|
||
d100 against the current rating and you improve by OVERSHOOTING it. The better you
|
||
already are, the less often a case teaches you anything, which is what stops a
|
||
long campaign turning everyone into ninety-percenters.
|
||
|
||
The two halves therefore pull in opposite directions on purpose. A low skill fails
|
||
constantly in play (so it gets marked) and then fails its development roll (so it
|
||
climbs fast). A high skill rarely fails at all, and when it does it usually passes
|
||
the development roll and gains nothing. Skills converge rather than run away.
|
||
|
||
There is still a hard ceiling, because a d100 gain can overshoot the top: a skill is
|
||
held at its own trainingCap if it has one, and at DEVELOPMENT.ceiling otherwise.
|
||
============================================================ */
|
||
|
||
/** A skill is marked by the outcomes that went badly. Nothing else marks it. */
|
||
export const DEVELOPMENT = {
|
||
marksOn: ["failure", "fumble"],
|
||
gain: "1d6",
|
||
improveOn: "over",
|
||
ceiling: 100
|
||
};
|
||
|
||
/** Did this outcome mark the skill for development? */
|
||
export function developmentMarksOn(level) {
|
||
return DEVELOPMENT.marksOn.includes(level);
|
||
}
|
||
|
||
/**
|
||
* Does this development roll improve the skill? Roll OVER the current rating.
|
||
* A rating of 0 always improves; a rating of 100 or better never does.
|
||
*/
|
||
export function developmentImproves(roll, rating) {
|
||
return (Number(roll) || 0) > (Number(rating) || 0);
|
||
}
|
||
|
||
/**
|
||
* The highest a skill can be taken to. A skill carrying its own trainingCap is held
|
||
* there — that is the only thing trainingCap has ever meant — and everything else
|
||
* stops at the point where a d100 can no longer overshoot it.
|
||
*/
|
||
export function developmentCeiling(trainingCap) {
|
||
const cap = Number(trainingCap);
|
||
return Number.isFinite(cap) && cap > 0 ? Math.min(cap, DEVELOPMENT.ceiling) : DEVELOPMENT.ceiling;
|
||
}
|
||
|
||
/** What the skill actually becomes: the gain, clipped to the ceiling. */
|
||
export function developmentGainFor(rating, rolled, trainingCap) {
|
||
const from = Number(rating) || 0;
|
||
const to = Math.min(from + (Number(rolled) || 0), developmentCeiling(trainingCap));
|
||
return Math.max(0, to - from);
|
||
}
|
||
|
||
export const PANIC_MAX = 5;
|
||
|
||
export const PANIC_BANDS = [
|
||
{ min: 5, id: "broken", label: "RINGBRP.Panic.Broken", all: -20, breaks: true,
|
||
note: "RINGBRP.Panic.BrokenNote" },
|
||
{ min: 4, id: "overwhelmed", label: "RINGBRP.Panic.Overwhelmed", all: -20, breaks: false,
|
||
note: "RINGBRP.Panic.OverwhelmedNote" },
|
||
{ min: 3, id: "losing", label: "RINGBRP.Panic.Losing", all: -10, breaks: false,
|
||
note: "RINGBRP.Panic.LosingNote" },
|
||
{ min: 2, id: "shaken", label: "RINGBRP.Panic.Shaken", all: -5, breaks: false,
|
||
note: "RINGBRP.Panic.ShakenNote" },
|
||
{ min: 1, id: "rattled", label: "RINGBRP.Panic.Rattled", all: 0, breaks: false,
|
||
note: "RINGBRP.Panic.RattledNote" },
|
||
{ min: 0, id: "steady", label: "RINGBRP.Panic.Steady", all: 0, breaks: false,
|
||
note: "RINGBRP.Panic.SteadyNote" }
|
||
];
|
||
|
||
/** What a point costs. One each, so a GM never has to weigh anything mid-scene. */
|
||
export const PANIC_TRIGGERS = {
|
||
wrongThing: { cost: 1, label: "RINGBRP.Panic.Trig.WrongThing" },
|
||
touchedByIt: { cost: 1, label: "RINGBRP.Panic.Trig.Touched" },
|
||
aBody: { cost: 1, label: "RINGBRP.Panic.Trig.Body" },
|
||
// One point, not two. At two, the end of a hard case reliably broke four agents in
|
||
// five at the same moment — a whole party freezing or bolting on the closing beat
|
||
// reads as arithmetic rather than grief. Playing THROUGH TRAIN end to end did it.
|
||
someoneYouKnew:{ cost: 1, label: "RINGBRP.Panic.Trig.Known" },
|
||
theLightsWent: { cost: 1, label: "RINGBRP.Panic.Trig.Dark" },
|
||
majorWound: { cost: 1, label: "RINGBRP.Panic.Trig.Wounded" },
|
||
itSaidYourName:{ cost: 2, label: "RINGBRP.Panic.Trig.Named" }
|
||
};
|
||
|
||
/**
|
||
* What takes a point off, and what it is rolled against.
|
||
*
|
||
* `self` steadies you; `other` steadies somebody else, which is the whole reason
|
||
* Psychology is worth carrying. `heard` reaches everyone who can hear it: naming a thing
|
||
* correctly is the department's actual work, and it is worth a mechanical reward.
|
||
* A special takes two.
|
||
*/
|
||
export const PANIC_RELIEF = {
|
||
steadyYourself: { skill: null, reach: "self", once: "scene",
|
||
label: "RINGBRP.Panic.Relief.Steady" },
|
||
religion: { skill: "religion", reach: "self", once: "scene",
|
||
label: "RINGBRP.Panic.Relief.Religion" },
|
||
psychology: { skill: "psychology", reach: "other", once: "scene",
|
||
label: "RINGBRP.Panic.Relief.Psychology" },
|
||
occult: { skill: "occult", reach: "heard", once: "encounter",
|
||
label: "RINGBRP.Panic.Relief.Occult" },
|
||
anchorObject: { skill: null, reach: "self", once: "case",
|
||
label: "RINGBRP.Panic.Relief.Anchor" }
|
||
};
|
||
|
||
/** Breaking is a choice, not a table: three bad options, taken fast. */
|
||
export const PANIC_BREAK = ["freeze", "flee", "fixate"];
|
||
|
||
/** Breaking spends the panic. You come back at 3, not at 0 — it is not a rest. */
|
||
export const PANIC_AFTER_BREAK = 3;
|
||
|
||
export function panicBandFrom(value) {
|
||
const v = Math.max(0, Math.min(PANIC_MAX, Number(value) || 0));
|
||
return PANIC_BANDS.find(b => v >= b.min) ?? PANIC_BANDS.at(-1);
|
||
}
|
||
|
||
/** The roll penalty a level of panic carries. Never positive. */
|
||
export function panicPenaltyFor(value) {
|
||
return Math.min(0, panicBandFrom(value).all);
|
||
}
|
||
|
||
/** Does this level break the character this round? */
|
||
export function panicBreaksAt(value) {
|
||
return panicBandFrom(value).breaks === true;
|
||
}
|
||
|
||
/** Clamped, so nothing can push the track past its own ends. */
|
||
export function panicAfter(value, delta) {
|
||
return Math.max(0, Math.min(PANIC_MAX, (Number(value) || 0) + (Number(delta) || 0)));
|
||
}
|
||
|
||
/**
|
||
* A success takes one point, a special or better takes two — and a FUMBLE puts one on.
|
||
*
|
||
* Returned as a negative relief so the whole thing stays one number and one direction:
|
||
* easePanic subtracts what this gives it, so -1 subtracts a negative and the track goes
|
||
* up. Trying to talk somebody down and making it worse is the most familiar thing in
|
||
* this list, and until now it was the only outcome that cost nothing.
|
||
*/
|
||
export function panicReliefFor(level) {
|
||
if (level === "critical" || level === "special") return 2;
|
||
if (level === "success") return 1;
|
||
if (level === "fumble") return -1;
|
||
return 0;
|
||
}
|
||
|
||
export function coherenceBandFrom(value) {
|
||
const v = Math.max(0, Number(value) || 0);
|
||
return COHERENCE_BANDS.find(b => v >= b.min) ?? COHERENCE_BANDS.at(-1);
|
||
}
|
||
|
||
/**
|
||
* What a lost LOCATION does, by anatomical kind.
|
||
*
|
||
* Legs and arms were applied; everything else was localised prose that changed no
|
||
* number. A destroyed head said "unconscious immediately" and left the agent
|
||
* standing; a Vesh's opened sensory ridge promised -30% Perception and gave none;
|
||
* and the Cadence rule — "each disabled body costs the whole person -10% to
|
||
* everything" — did nothing whatever, which is the entire mechanical identity of
|
||
* one of the three playable species.
|
||
*
|
||
* Same shape as woundEffectsFor so the two fold together additively: both are true
|
||
* about the same body at the same time.
|
||
*/
|
||
export function locationEffectsFor(locations = []) {
|
||
const eff = { all: 0, manipulation: 0, physical: 0, perception: 0,
|
||
movFactor: 1, unconscious: false, prone: false, bleed: 0,
|
||
legs: 0, legsDestroyed: 0, arms: 0, vital: false, unitsLost: 0,
|
||
wings: 0, flightLost: false };
|
||
for (const l of Array.isArray(locations) ? locations : []) {
|
||
if (!l?.disabled) continue;
|
||
switch (l.kind) {
|
||
case "leg": case "brace":
|
||
eff.legs++;
|
||
if (l.destroyed) eff.legsDestroyed++;
|
||
eff.physical -= 30;
|
||
break;
|
||
case "arm": case "grasp": case "manipulator":
|
||
eff.arms++;
|
||
eff.manipulation -= 30;
|
||
break;
|
||
case "ridge":
|
||
eff.perception -= 30;
|
||
eff.vital = true;
|
||
break;
|
||
case "head":
|
||
eff.unconscious = true; // "unconscious immediately"
|
||
eff.vital = true;
|
||
break;
|
||
case "body":
|
||
// The printed effect has always read "-30% to all Physical actions and
|
||
// bleeding 1 hit point per round until First Aid", and nothing delivered a
|
||
// point of it. An opened abdomen was decoration on every body plan in the
|
||
// game, the humanoid default included. Destroyed doubles the bleed, which
|
||
// is what bodyX says.
|
||
eff.physical -= 30;
|
||
eff.bleed += l.destroyed ? 2 : 1;
|
||
break;
|
||
case "vital":
|
||
eff.vital = true; // knocked down; the Stamina roll is the GM's call
|
||
break;
|
||
case "neck":
|
||
// A broken neck puts the animal on the ground and keeps it there. This is
|
||
// the location that makes a spear worth aiming rather than swinging.
|
||
eff.unconscious = true;
|
||
eff.vital = true;
|
||
break;
|
||
case "wing":
|
||
// ONE wing is enough. A flyer with a hole in it comes down, and coming down
|
||
// is the whole reason a table would shoot at a wing instead of the body.
|
||
eff.wings++;
|
||
eff.flightLost = true;
|
||
eff.physical -= 10;
|
||
break;
|
||
case "unit":
|
||
eff.unitsLost++;
|
||
eff.all -= 10; // each body lost costs the WHOLE person
|
||
break;
|
||
}
|
||
}
|
||
// One leg gone is a limp; both, or a destroyed one, is the floor. This counts legs
|
||
// rather than assuming two of them, so a quadruped that has lost one of four is
|
||
// lamed and not felled — which is the difference between a horse and a man.
|
||
eff.movFactor = eff.legsDestroyed ? 0.1 : (eff.legs ? 0.5 : 1);
|
||
eff.prone = eff.legsDestroyed > 0 || eff.vital;
|
||
return eff;
|
||
}
|
||
|
||
/**
|
||
* Which skill categories a heavy pack actually impedes.
|
||
*
|
||
* `encumbranceFrom` has computed a skillPenalty since R-23 and NOTHING ever applied
|
||
* it — the comment in skillRoll even says the penalty belongs there. Carrying too
|
||
* much is scoped to the physical categories rather than every skill, because a
|
||
* rucksack does not make you worse at Knowledge, and a rule that says otherwise is
|
||
* a rule nobody will use.
|
||
*/
|
||
/* ---------------------------------------------------------------- hit locations */
|
||
|
||
/**
|
||
* Spec §3.6. Which location a 1D20 finds, for this species and attack type.
|
||
*
|
||
* Moved out of ringbrp.mjs so node can reach it, for exactly the reason applyDifficulty,
|
||
* resolveBands and gradeRoll were moved: the lethality harness could not model hit
|
||
* locations while the only copy lived in the engine, and an unlocated Cadence is not a
|
||
* Cadence — being whittled down body by body is the entire species. The tables come from
|
||
* anatomy.mjs, which is the same one the engine reads.
|
||
*/
|
||
export function locationFor(roll, speciesId = "baseline", mode = "ranged") {
|
||
const table = LOCATION_TABLES[speciesId] ?? LOCATION_TABLES.baseline;
|
||
const key = mode === "melee" ? "melee" : "ranged";
|
||
return table.locations.find(l => roll >= l[key][0] && roll <= l[key][1]) ?? table.locations[0];
|
||
}
|
||
|
||
/**
|
||
* Spec §3.6. Resolve a hit against a location.
|
||
* A blow that meets the location's maximum disables it; twice that destroys it.
|
||
* General hit points still take the damage, so the two systems agree.
|
||
*/
|
||
export function resolveLocationHit({ damage, locationMax, locationTaken = 0, majorWoundThreshold }) {
|
||
const d = Number(damage) || 0;
|
||
const after = locationTaken + d;
|
||
return {
|
||
damage: d,
|
||
locationTaken: after,
|
||
disabled: after >= locationMax,
|
||
// Cumulative, like `disabled` and like the actor's own derived state. This used to
|
||
// test THIS hit's damage alone, so a limb could never be destroyed by repeated
|
||
// wounds and the resolver disagreed with the sheet about the same limb.
|
||
destroyed: after >= locationMax * 2,
|
||
majorWound: d >= (Number(majorWoundThreshold) || Infinity)
|
||
};
|
||
}
|
||
|
||
/** Every location a species has — what a combatant has to keep a tally for. */
|
||
export function locationsFor(speciesId = "baseline") {
|
||
return (LOCATION_TABLES[speciesId] ?? LOCATION_TABLES.baseline).locations;
|
||
}
|
||
|
||
/**
|
||
* WHERE A WOUND GOES WHEN THE BODY CHANGES SHAPE.
|
||
*
|
||
* `locationDamage` is keyed by location id, and prepareDerivedData only reads the keys
|
||
* belonging to the CURRENT body plan. So changing speciesProfile on a wounded actor
|
||
* stranded every wound it had: a Barghest switched from baseline to quadruped kept 5
|
||
* points in `legR` and 7 in `chest`, which no quadruped location is called, and read on
|
||
* the sheet as a dog 12 hit points down with eight pristine locations. Stored, never
|
||
* read — the same defect this system keeps producing.
|
||
*
|
||
* Wounds are carried across by KIND, because kind is what the rules act on: a leg is
|
||
* what you limp on whichever plan names it, a vital is what kills you. Exact-kind
|
||
* matches are claimed first, in table order, so the obvious pairings land before any
|
||
* fallback competes for them; only then do the leftovers walk the fallback chain.
|
||
*
|
||
* That ordering is what makes arms become forelegs. Going baseline -> quadruped the two
|
||
* legs take the two hind legs, and the arms — finding no `arm` and no `grasp` — fall to
|
||
* `leg` and take the forelegs that are left. Coming back, the forelegs become arms. The
|
||
* limb you lost stays the limb you lost.
|
||
*/
|
||
const KIND_FALLBACK = {
|
||
head: ["neck", "ridge", "vital", "body", "unit"],
|
||
neck: ["head", "vital", "body", "unit"],
|
||
vital: ["body", "ridge", "unit"],
|
||
body: ["vital", "unit"],
|
||
leg: ["brace", "arm", "body", "unit"],
|
||
arm: ["grasp", "leg", "brace", "body", "unit"],
|
||
wing: ["arm", "grasp", "leg", "body", "unit"],
|
||
brace: ["leg", "arm", "body", "unit"],
|
||
grasp: ["arm", "leg", "body", "unit"],
|
||
ridge: ["head", "vital", "body", "unit"],
|
||
unit: ["body", "vital", "head", "leg", "arm"]
|
||
};
|
||
|
||
/**
|
||
* Rebuild a locationDamage map for a new body plan.
|
||
*
|
||
* What is preserved is SEVERITY, not the raw number of points: a location that was
|
||
* merely hurt stays hurt, a disabled one stays disabled, a destroyed one stays
|
||
* destroyed. Points alone cannot be preserved because a location's maximum comes from
|
||
* its `frac` and the plans disagree — an arm is 0.35 of the body, a foreleg 0.42 — so
|
||
* carrying the number across rather than the meaning silently healed destroyed limbs
|
||
* one way and destroyed merely-disabled ones the other. 234 such flips across the 20
|
||
* plan changes, 138 of them creating a destroyed location out of one that was not.
|
||
*
|
||
* Returns the new map, the moves it made (each with the band it preserved), and how
|
||
* many wounds had to be rescaled to keep their meaning.
|
||
*/
|
||
/**
|
||
* Build the update that REPLACES a location-damage map.
|
||
*
|
||
* Foundry merges object updates, so the outgoing plan's keys must be deleted by name or
|
||
* the actor carries both plans' wounds at once (R-93). But a deletion and an assignment
|
||
* of the SAME key in one update do not both apply — the deletion wins — and the plans
|
||
* that share ids are exactly where that bites: quadruped and winged share every location
|
||
* but the wings, so a switch between them emitted `-=hindLegR` alongside `hindLegR: 5`
|
||
* and silently threw the wound away. Delete only what the new map does not set.
|
||
*/
|
||
export function locationDamageReplacement(current = {}, next = {}) {
|
||
const upd = { ...next };
|
||
for (const id of Object.keys(current)) if (!(id in next)) upd[`-=${id}`] = null;
|
||
return upd;
|
||
}
|
||
|
||
export function remapLocationDamage(damage = {}, fromId = "baseline", toId = "baseline", totalHp = 0) {
|
||
const from = (LOCATION_TABLES[fromId] ?? LOCATION_TABLES.baseline).locations;
|
||
const to = (LOCATION_TABLES[toId] ?? LOCATION_TABLES.baseline).locations;
|
||
const out = {}, moved = [];
|
||
let rescaled = 0;
|
||
|
||
const wounded = from.filter(l => (Number(damage[l.id]) || 0) > 0);
|
||
if (!to.length || !wounded.length) return { damage: out, moved, rescaled };
|
||
|
||
const used = new Set();
|
||
// Slots carry the side in their name, so a wound that had a side keeps it even when it
|
||
// has to fall back to a different kind — a left wing becomes a LEFT arm, not whichever
|
||
// arm happened to be first in the table.
|
||
const sideOf = slot => (/R$/.test(slot) ? "R" : /L$/.test(slot) ? "L" : "");
|
||
// A wing is a FORE limb, so when it has to fall back to a leg it should become a
|
||
// foreleg, not a hind leg. Slots carry height as well as side; prefer a target that
|
||
// matches both, then side alone, then height alone.
|
||
const highOf = slot => (/^(upper|wing)/.test(slot) ? "upper" : /^lower/.test(slot) ? "lower" : "");
|
||
const pick = (pool, side, high) =>
|
||
(side && high && pool.find(t => sideOf(t.slot) === side && highOf(t.slot) === high))
|
||
|| (side && pool.find(t => sideOf(t.slot) === side))
|
||
|| (high && pool.find(t => highOf(t.slot) === high))
|
||
|| pool[0];
|
||
const freeSlot = slot => to.find(t => t.slot === slot && !used.has(t.id));
|
||
const freeOfKind = (kind, side = "", high = "") =>
|
||
pick(to.filter(t => t.kind === kind && !used.has(t.id)), side, high);
|
||
const anyOfKind = (kind, side = "", high = "") =>
|
||
pick(to.filter(t => t.kind === kind), side, high);
|
||
|
||
const carry = (src, tgt) => {
|
||
const taken = Number(damage[src.id]) || 0;
|
||
const srcMax = locationMaxHp(totalHp, src.frac);
|
||
const tgtMax = locationMaxHp(totalHp, tgt.frac);
|
||
const band = taken >= srcMax * 2 ? "destroyed" : taken >= srcMax ? "disabled" : "hurt";
|
||
const scaled = Math.round((taken / srcMax) * tgtMax);
|
||
// Pinned to the band rather than to the number, because the band is what the rules
|
||
// read. An arm is 0.35 of the body and a foreleg is 0.42, so carrying the raw points
|
||
// across turned a destroyed arm into a merely disabled foreleg and — stacking two
|
||
// limbs onto one Vesh grasp — a disabled arm into a destroyed one.
|
||
const value = band === "destroyed" ? tgtMax * 2
|
||
: band === "disabled" ? Math.min(Math.max(scaled, tgtMax), tgtMax * 2 - 1)
|
||
: Math.max(0, Math.min(scaled, tgtMax - 1));
|
||
used.add(tgt.id);
|
||
// Two wounds landing on one location: the worse one stands. Summing them would let a
|
||
// change of shape destroy a limb that nothing in play had destroyed.
|
||
out[tgt.id] = Math.max(out[tgt.id] ?? 0, value);
|
||
if (value !== taken) rescaled++;
|
||
moved.push({ from: src.id, to: tgt.id, points: taken, became: value, band });
|
||
};
|
||
|
||
// Pass 1 — the same anatomical slot. Stated in the tables rather than inferred, so a
|
||
// right arm becomes a right foreleg and becomes a right arm again coming back. An
|
||
// earlier version matched on kind and list order instead and inverted left and right
|
||
// on every crossing, which round-tripped an arm wound onto the opposite hind leg.
|
||
const pending = [];
|
||
for (const src of wounded) {
|
||
const tgt = freeSlot(src.slot);
|
||
if (tgt) carry(src, tgt); else pending.push(src);
|
||
}
|
||
// Pass 2 — same kind, for a slot the destination simply does not have.
|
||
const noKind = [];
|
||
for (const src of pending) {
|
||
const tgt = freeOfKind(src.kind, sideOf(src.slot), highOf(src.slot));
|
||
if (tgt) carry(src, tgt); else noKind.push(src);
|
||
}
|
||
// Pass 3 — the fallback chain, still only onto unclaimed locations.
|
||
const stillPending = [];
|
||
for (const src of noKind) {
|
||
const side = sideOf(src.slot), high = highOf(src.slot);
|
||
const tgt = (KIND_FALLBACK[src.kind] ?? []).map(k => freeOfKind(k, side, high)).find(Boolean);
|
||
if (tgt) carry(src, tgt); else stillPending.push(src);
|
||
}
|
||
// Pass 4 — everything is claimed, so share. A second head wound is still a head wound.
|
||
for (const src of stillPending) {
|
||
const side = sideOf(src.slot), high = highOf(src.slot);
|
||
carry(src, [src.kind, ...(KIND_FALLBACK[src.kind] ?? [])]
|
||
.map(k => anyOfKind(k, side, high)).find(Boolean) ?? to[0]);
|
||
}
|
||
return { damage: out, moved, rescaled };
|
||
}
|
||
|
||
/**
|
||
* How much a battered body costs a roll of one category.
|
||
*
|
||
* The mapping — `all` against everything, manipulation against anything you need hands
|
||
* for, physical against phys, and so on — lived only inside ringbrp.mjs's
|
||
* woundPenaltyFor, which reads a Foundry actor. The harness needs the same arithmetic
|
||
* over a plain object, and writing the mapping a second time is the one thing this
|
||
* repository does not permit. woundPenaltyFor now unwraps the actor and calls this.
|
||
*
|
||
* Note that melee and ranged both take the MANIPULATION penalty: a lost arm is what
|
||
* stops you fighting, whether the weapon is a knife or a rifle. Dodge is `phys`, so
|
||
* losing a leg is what stops you getting out of the way.
|
||
*/
|
||
export function woundPenaltyFrom(wounded, categoryId) {
|
||
if (!wounded) return 0;
|
||
let pen = Number(wounded.all) || 0;
|
||
if (["manip", "melee", "ranged"].includes(categoryId)) pen += Number(wounded.manipulation) || 0;
|
||
if (categoryId === "phys") pen += Number(wounded.physical) || 0;
|
||
if (categoryId === "percep") pen += Number(wounded.perception) || 0;
|
||
if (categoryId === "comm") pen += Number(wounded.communication) || 0;
|
||
return pen;
|
||
}
|
||
|
||
export const ENCUMBERED_CATEGORIES = ["phys", "melee", "ranged", "manip"];
|
||
|
||
export function encumbrancePenaltyFor(skillPenalty, categoryId) {
|
||
if (!ENCUMBERED_CATEGORIES.includes(categoryId)) return 0;
|
||
return -Math.abs(Number(skillPenalty) || 0);
|
||
}
|
||
|
||
/**
|
||
* What you can carry before it costs you anything.
|
||
*
|
||
* Was STR alone, in kilogrammes, which ignored how big you are: Rakhi at STR 18 and
|
||
* MAS 19 — a large, powerful woman — had a free carry of 18 kg and was twenty
|
||
* kilos over with a soldier's ordinary fighting load. A rule that makes a realistic
|
||
* pack an emergency is a rule the table will quietly drop.
|
||
*
|
||
* STR + MAS is the same pair that sets the damage modifier, so one physical axis
|
||
* decides both how hard you hit and how much you can shoulder. Rakhi's free carry
|
||
* becomes 37 kg and her 38 kg load puts her exactly 1 kg over — heavy, deliberate,
|
||
* and worth 0.1 of a point rather than two.
|
||
*/
|
||
export function freeCarryFor(str, siz) {
|
||
return Math.max(1, (Number(str) || 0) + (Number(siz) || 0));
|
||
}
|
||
|
||
/**
|
||
* Defending. The book says "one offensive action, plus as many defensive reactions
|
||
* as you like at an escalating penalty" — and the escalation was the only part that
|
||
* existed anywhere: `defencesThisRound` was stored on every actor, reset by the
|
||
* round hook, and incremented by nothing, because no defence was ever rolled.
|
||
*
|
||
* The first defence in a round is free. Each one after it is cumulatively harder,
|
||
* so standing in front of three people is possible and stupid.
|
||
*/
|
||
export const DEFENCE_STEP = -30;
|
||
|
||
export function defencePenaltyFor(defencesUsed = 0) {
|
||
return DEFENCE_STEP * Math.max(0, Number(defencesUsed) || 0);
|
||
}
|
||
|
||
/**
|
||
* THE WAY HOME, AND WHAT SPENDS IT.
|
||
*
|
||
* Two play-throughs in a row were decided at the extraction, and both times the
|
||
* mechanism was the GM inventing one. The generator writes a "window closes" clock
|
||
* whose stated cost is *the way home* and whose steps are "the margin narrows", "the
|
||
* return is Difficult", "the return is aimed at −30%" — and nothing in the system
|
||
* ever read it, so a failed extraction had no consequence of its own and whoever was
|
||
* running it had to improvise. Improvising at that moment is how a phase that should
|
||
* cost you the trip home ends up costing two lives.
|
||
*
|
||
* Extraction now spends the clock instead. Getting out badly does not hurt anybody
|
||
* on the causeway; it makes the CROSSING harder, which is where this setting says the
|
||
* cost of a case belongs and is a cost the players can see coming.
|
||
*/
|
||
export const RETURN_CLOCK = {
|
||
max: 3,
|
||
steps: [0, -10, -20, -30],
|
||
difficultFrom: 2 // at two steps the return is Difficult as well
|
||
};
|
||
|
||
export function returnPenaltyFor(steps) {
|
||
const n = Math.max(0, Math.min(RETURN_CLOCK.max, Number(steps) || 0));
|
||
return RETURN_CLOCK.steps[n];
|
||
}
|
||
|
||
export function returnIsDifficult(steps) {
|
||
return (Number(steps) || 0) >= RETURN_CLOCK.difficultFrom;
|
||
}
|
||
|
||
/** What an extraction result does to the way home. Nothing else takes damage. */
|
||
export function extractionCostFor(level) {
|
||
switch (level) {
|
||
case "critical": return { completes: true, clock: -1 }; // a step BACK
|
||
case "special": return { completes: true, clock: 0 };
|
||
case "success": return { completes: true, clock: 0 };
|
||
case "failure": return { completes: true, clock: +1 };
|
||
default: return { completes: false, clock: +1 }; // fumble
|
||
}
|
||
}
|
||
|
||
/* -------------------------------------------- */
|
||
/* Dying, and death */
|
||
/* -------------------------------------------- */
|
||
|
||
/* -------------------------------------------- */
|
||
/* Space combat: STATIONS */
|
||
/* -------------------------------------------- */
|
||
|
||
/**
|
||
* SPACE COMBAT WITHOUT A PILOT MINIGAME.
|
||
*
|
||
* The usual failure is that one player flies and everybody else watches. This does
|
||
* not do that. A craft has STATIONS; every agent takes one, every station uses a
|
||
* skill somebody already has, and the round is one roll each resolved in the normal
|
||
* reaction order. There is no vector arithmetic and no map — range is a BAND, the
|
||
* same ladder the rest of the game uses.
|
||
*
|
||
* The craft has three things that can be spent: HULL (what it can take), SIGNATURE
|
||
* (how findable it is) and HEAT (what it has spent making itself effective). Heat is
|
||
* the pressure: almost every good outcome adds some, and a craft at maximum Heat is
|
||
* blind, loud and slow until it sheds it. That is the interesting decision and it
|
||
* belongs to whoever is on Engines, not to the pilot.
|
||
*/
|
||
export const SPACE_BANDS = ["docked", "knife", "close", "standoff", "distant", "gone"];
|
||
|
||
export function spaceBandRank(band) {
|
||
const i = SPACE_BANDS.indexOf(String(band));
|
||
return i < 0 ? 2 : i;
|
||
}
|
||
|
||
/** Closing and opening move ONE band a round. Nothing crosses the table at once. */
|
||
export function spaceBandAfter(band, delta) {
|
||
const n = Math.max(0, Math.min(SPACE_BANDS.length - 1, spaceBandRank(band) + (Number(delta) || 0)));
|
||
return SPACE_BANDS[n];
|
||
}
|
||
|
||
/**
|
||
* The stations. Each names the skill it uses, what a success does, and what it costs
|
||
* the craft in Heat — so a GM can run the whole subsystem off this table.
|
||
*/
|
||
// `heat` is what the station costs when it ACTS. Only Gunnery is charged for
|
||
// trying, because the shot leaves whether or not it lands — the same honesty as the
|
||
// burst rule. Playtested: with every station heating, one engineer could not keep up
|
||
// and Heat simply climbed to the ceiling by round four, which is a countdown rather
|
||
// than a decision. Looking at something is now free; shooting and running are not.
|
||
export const STATIONS = {
|
||
helm: { id: "helm", skill: "pilot", label: "RINGBRP.Station.helm",
|
||
effect: "band", heat: 1 },
|
||
guns: { id: "guns", skill: "energy_weapon", label: "RINGBRP.Station.guns",
|
||
effect: "damage", heat: 2, heatOnFailure: true },
|
||
sensors: { id: "sensors", skill: "computer_use", label: "RINGBRP.Station.sensors",
|
||
effect: "mark", heat: 0 },
|
||
engines: { id: "engines", skill: "engineering", label: "RINGBRP.Station.engines",
|
||
effect: "heat", heat: -3 },
|
||
damage: { id: "damage", skill: "repair", label: "RINGBRP.Station.damage",
|
||
effect: "hull", heat: 0 },
|
||
signals: { id: "signals", skill: "persuade", label: "RINGBRP.Station.signals",
|
||
effect: "talk", heat: 0 },
|
||
medical: { id: "medical", skill: "first_aid", label: "RINGBRP.Station.medical",
|
||
effect: "crew", heat: 0 },
|
||
anchor: { id: "anchor", skill: "transposition", label: "RINGBRP.Station.anchor",
|
||
effect: "escape", heat: 2 }
|
||
};
|
||
|
||
/** Heat is the whole tension. At the ceiling the craft is blind, loud and slow. */
|
||
export const HEAT = { max: 10, blindFrom: 6, loudFrom: 4, slowFrom: 8 };
|
||
|
||
export function heatEffectsFor(heat) {
|
||
const h = Math.max(0, Number(heat) || 0);
|
||
return {
|
||
heat: h,
|
||
loud: h >= HEAT.loudFrom, // signature climbs; everything can see you
|
||
blind: h >= HEAT.blindFrom, // sensors and gunnery at a penalty
|
||
slow: h >= HEAT.slowFrom, // helm cannot change band at all
|
||
penalty: h >= HEAT.blindFrom ? -20 : (h >= HEAT.loudFrom ? -10 : 0)
|
||
};
|
||
}
|
||
|
||
/**
|
||
* Gunnery at range. Close is easy and dangerous for everybody; distant is a poor
|
||
* bet. Knife range is where a small craft wins and where one bad round ends it.
|
||
*/
|
||
export const SPACE_RANGE_MOD = { docked: 0, knife: 20, close: 10, standoff: -10,
|
||
distant: -30, gone: null };
|
||
|
||
export function spaceRangeModFor(band) {
|
||
const m = SPACE_RANGE_MOD[band];
|
||
return m === undefined ? -10 : m;
|
||
}
|
||
|
||
/** A hit from a station roll. Marked targets are the reason Sensors has a chair. */
|
||
export function spaceHitFor(level, { marked = false } = {}) {
|
||
const base = { critical: 3, special: 2, success: 1, failure: 0, fumble: 0 }[level] ?? 0;
|
||
return base > 0 && marked ? base + 1 : base;
|
||
}
|
||
|
||
/** What a craft is, in the four numbers that matter. */
|
||
export function craftConditionFor({ hull = 0, hullMax = 1, heat = 0 } = {}) {
|
||
const frac = (Number(hull) || 0) / Math.max(1, Number(hullMax) || 1);
|
||
const h = heatEffectsFor(heat);
|
||
if (frac <= 0) return { state: "lost", ...h };
|
||
if (frac <= 0.25) return { state: "failing", ...h };
|
||
if (frac <= 0.6) return { state: "hurt", ...h };
|
||
return { state: "sound", ...h };
|
||
}
|
||
|
||
/* -------------------------------------------- */
|
||
/* Automatic fire, and two weapons */
|
||
/* -------------------------------------------- */
|
||
|
||
/**
|
||
* AUTOMATIC FIRE.
|
||
*
|
||
* Not a stream of separate attacks — that is four rolls and a stalled table. One
|
||
* roll, and the SUCCESS LEVEL decides how much of the burst arrived, which reuses the
|
||
* band the whole system already turns on. Rounds are spent whether or not they hit,
|
||
* because that is the honest part of automatic fire and the part players forget.
|
||
*
|
||
* A burst is harder to place than a single shot and easier to place SOMETHING with,
|
||
* so the modifier is negative and the payoff is hits.
|
||
*/
|
||
export const BURST = {
|
||
short: { id: "short", rounds: 3, mod: -10, label: "RINGBRP.Burst.short" },
|
||
long: { id: "long", rounds: 6, mod: -20, label: "RINGBRP.Burst.long" },
|
||
full: { id: "full", rounds: 10, mod: -30, label: "RINGBRP.Burst.full" }
|
||
};
|
||
|
||
/** How many of a burst connect, by the band the one roll produced. */
|
||
// Hits are CAPPED, not proportional. Ten independent damage rolls, ten hit locations
|
||
// and ten Major Wound checks off one success is not a burst, it is a firing squad —
|
||
// and it made full auto the correct answer to every hard target.
|
||
export const BURST_HITS = { short: [1, 2, 3], long: [2, 4, 5], full: [3, 5, 7] };
|
||
|
||
export function burstHitsFor(level, size) {
|
||
const cap = BURST_HITS[size] ?? BURST_HITS.short;
|
||
switch (level) {
|
||
case "critical": return cap[2];
|
||
case "special": return cap[1];
|
||
case "success": return cap[0];
|
||
default: return 0; // failure and fumble; see BURST_FUMBLE
|
||
}
|
||
}
|
||
|
||
/** However many rounds connect, a burst is ONE wound event for the Major Wound check. */
|
||
export const BURST_ONE_MAJOR_WOUND = true;
|
||
|
||
/** Every round leaves the weapon regardless. This is the cost of the option. */
|
||
export function burstRoundsSpent(size, loaded) {
|
||
const b = BURST[size] ?? BURST.short;
|
||
return Math.min(Number(loaded) || 0, b.rounds);
|
||
}
|
||
|
||
/** A fumbled burst is the interesting one: it is a jam, or it is worse. */
|
||
export const BURST_FUMBLE = ["RINGBRP.Burst.Jam", "RINGBRP.Burst.Empty", "RINGBRP.Burst.Wild"];
|
||
|
||
/**
|
||
* WHAT A WEAPON CAN DO WITH A ROUND.
|
||
*
|
||
* Every weapon in the catalogue has carried a `fireMode` since the packs were first
|
||
* built, and until now precisely nothing read it. A musket could rip a ten-round
|
||
* burst; a weapon marked "rapid" got no benefit from being rapid. The mode is now
|
||
* the gate on the burst rules and it carries its own attack modifier, so choosing
|
||
* a slow weapon is a decision rather than a drawback.
|
||
*
|
||
* The trade runs one way along the ladder. The faster a weapon puts rounds out, the
|
||
* less each individual shot is worth; the slower it is, the more the one shot you
|
||
* get is worth taking properly.
|
||
*
|
||
* slow one shot, then work — and it is fired by somebody who knows that
|
||
* single the ordinary case, and the baseline every modifier is measured from
|
||
* fast the action will give you a second shot if you will accept both being worse
|
||
* rapid bursts, which is the automatic-fire rules above
|
||
* beam no recoil, no lead, no drop — and it can be held on the target
|
||
*
|
||
* `fast` was in the catalogue on five weapons (longbow, sling, javelin, the Webley
|
||
* and the Lee-Enfield) and was not in the system's list of modes at all, so the item
|
||
* sheet offered no matching option and re-saving one of those weapons silently blanked
|
||
* its mode. It is a real rate of fire and it is now a real mode.
|
||
*/
|
||
export const FIRE_MODE = {
|
||
slow: { id: "slow", label: "RINGBRP.Fire.slow.Label", attack: 10, burst: [],
|
||
reloads: true, second: false, sustain: false },
|
||
single: { id: "single", label: "RINGBRP.Fire.single.Label", attack: 0, burst: [],
|
||
reloads: false, second: false, sustain: false },
|
||
fast: { id: "fast", label: "RINGBRP.Fire.fast.Label", attack: 0, burst: [],
|
||
reloads: false, second: true, sustain: false },
|
||
rapid: { id: "rapid", label: "RINGBRP.Fire.rapid.Label", attack: 0, burst: ["short", "long", "full"],
|
||
reloads: false, second: false, sustain: false },
|
||
beam: { id: "beam", label: "RINGBRP.Fire.beam.Label", attack: 5, burst: [],
|
||
reloads: false, second: false, sustain: true }
|
||
};
|
||
|
||
/** A blank mode is a melee weapon or an unclassified one: it behaves as `single`. */
|
||
export function fireModeFor(id) {
|
||
return FIRE_MODE[id] || FIRE_MODE.single;
|
||
}
|
||
|
||
/** Which bursts this weapon can actually produce. Empty means it cannot. */
|
||
export function burstSizesFor(id) {
|
||
return fireModeFor(id).burst;
|
||
}
|
||
|
||
/** The flat modifier for firing this weapon at all, before range and everything else. */
|
||
export function fireModeAttackFor(id) {
|
||
return fireModeFor(id).attack;
|
||
}
|
||
|
||
/**
|
||
* The second shot a fast action will give you. BOTH shots take it, not just the
|
||
* second — the cost of hurrying is that you hurried the first one too, which is
|
||
* what stops this from being a free extra attack for anyone holding a revolver.
|
||
*/
|
||
export const SECOND_SHOT = -20;
|
||
|
||
export function secondShotPenaltyFor(id) {
|
||
return fireModeFor(id).second ? SECOND_SHOT : null;
|
||
}
|
||
|
||
/**
|
||
* SUSTAINED BEAMS.
|
||
*
|
||
* A beam does not fire discrete rounds, so a burst is the wrong shape for it: there
|
||
* is nothing to count hits of. What a beam can do instead is stay on the target,
|
||
* and that buys DAMAGE rather than hits — the one place in this system where a firing
|
||
* choice adds dice instead of adding rolls.
|
||
*
|
||
* It is paid for twice, in accuracy and in charge, because holding a line on a moving
|
||
* person is hard and because the alternative is that everybody holds every shot.
|
||
*/
|
||
export const SUSTAIN = {
|
||
tap: { id: "tap", label: "RINGBRP.Sustain.tap", mod: 0, bonus: "", charge: 1 },
|
||
hold: { id: "hold", label: "RINGBRP.Sustain.hold", mod: -10, bonus: "1d6", charge: 3 },
|
||
lock: { id: "lock", label: "RINGBRP.Sustain.lock", mod: -25, bonus: "2d6", charge: 6 }
|
||
};
|
||
|
||
export function sustainFor(id) {
|
||
return SUSTAIN[id] || SUSTAIN.tap;
|
||
}
|
||
|
||
/** The extra dice a sustained beam adds to its damage formula, or "" for none. */
|
||
export function sustainDamageFor(id) {
|
||
return sustainFor(id).bonus;
|
||
}
|
||
|
||
/**
|
||
* What a sustained beam costs, counted in SHOTS — because `ammo.loaded` is spent one
|
||
* per shot everywhere else in the system, so a cell holding 20 holds twenty shots.
|
||
*
|
||
* This was briefly multiplied by the weapon's `energyPerShot`, which made a locked
|
||
* beam cost 60 from a 20-shot cell and therefore impossible to fire at all. That
|
||
* field is written by the pack builder and read by nothing; the magazine is the
|
||
* authority on what a weapon has left.
|
||
*/
|
||
export function sustainChargeFor(id) {
|
||
return sustainFor(id).charge;
|
||
}
|
||
|
||
/* -------------------------------------------- */
|
||
/* What is bolted to the weapon */
|
||
/* -------------------------------------------- */
|
||
|
||
/**
|
||
* SIGHTS.
|
||
*
|
||
* A sight is not a flat bonus, and making it one is why most systems end up with
|
||
* everybody carrying the same scope. A sight is good at the distance it was ground
|
||
* for and a liability at the others, so the bonus is read off the RANGE BAND the
|
||
* attack is already using — which means it applies itself, with nothing for the
|
||
* player to remember and nothing for them to choose.
|
||
*
|
||
* A telescopic sight is a PENALTY inside ten metres. That is the whole point of it.
|
||
*/
|
||
export const SIGHT = {
|
||
none: { id: "none", label: "RINGBRP.Sight.none",
|
||
bands: { short: 0, medium: 0, long: 0, extreme: 0 }, dark: false },
|
||
irons: { id: "irons", label: "RINGBRP.Sight.irons",
|
||
bands: { short: 5, medium: 5, long: 0, extreme: 0 }, dark: false },
|
||
reflex: { id: "reflex", label: "RINGBRP.Sight.reflex",
|
||
bands: { short: 10, medium: 5, long: 0, extreme: -10 }, dark: false },
|
||
telescopic: { id: "telescopic", label: "RINGBRP.Sight.telescopic",
|
||
bands: { short: -10, medium: 5, long: 15, extreme: 20 }, dark: false },
|
||
thermal: { id: "thermal", label: "RINGBRP.Sight.thermal",
|
||
bands: { short: 5, medium: 5, long: 5, extreme: 0 }, dark: true }
|
||
};
|
||
|
||
export function sightFor(id) {
|
||
return SIGHT[id] || SIGHT.none;
|
||
}
|
||
|
||
/** What this sight is worth at this band. Bands come from the range ladder. */
|
||
export function sightBonusFor(id, band) {
|
||
const b = sightFor(id).bands;
|
||
return Number(b?.[band]) || 0;
|
||
}
|
||
|
||
/** A thermal sight is the only one that removes the dark rather than surviving it. */
|
||
export function sightSeesInDark(id) {
|
||
return !!sightFor(id).dark;
|
||
}
|
||
|
||
/**
|
||
* LIGHT.
|
||
*
|
||
* The department issues lamps because most crossings do not come with the lights on,
|
||
* and until now the dark cost an attack nothing at all. It costs something now, and
|
||
* a carried lamp buys most of it back.
|
||
*
|
||
* What it does not buy back is the second half: a lamp is a position. The rule
|
||
* returns `revealed` so the card can say so, and what the other side does about it
|
||
* is the GM's, not the engine's.
|
||
*/
|
||
export const LIGHTING = {
|
||
lit: { id: "lit", label: "RINGBRP.Lighting.lit", mod: 0 },
|
||
dim: { id: "dim", label: "RINGBRP.Lighting.dim", mod: -10 },
|
||
dark: { id: "dark", label: "RINGBRP.Lighting.dark", mod: -30 },
|
||
pitch: { id: "pitch", label: "RINGBRP.Lighting.pitch", mod: -50 }
|
||
};
|
||
|
||
/* -------------------------------------------- */
|
||
/* First contact */
|
||
/* -------------------------------------------- */
|
||
|
||
/**
|
||
* What each stage of contact is worth.
|
||
*
|
||
* READ's number is FACTS LEARNED. SIGNAL's and OFFER's are STANDING. They live in one
|
||
* table because they are one roll's outcome, and that is exactly why applying "the
|
||
* delta" uniformly is wrong — it would pay a team impression for doing research.
|
||
*/
|
||
// Indexed by LEVEL_LADDER — fumble, failure, success, special, critical — rather than
|
||
// keyed by those five names. That is not decoration: the band names are this system's
|
||
// universal vocabulary, so a table keyed by them is indistinguishable from any other
|
||
// table keyed by them, and the duplicate-rule guard cannot tell a genuine copy from a
|
||
// coincidence. Tying it to the ladder ties it to the one place the order is defined.
|
||
export const CONTACT_OUTCOMES = {
|
||
read: [0, 0, 1, 1, 2],
|
||
signal: [-1, 0, 0, 1, 1],
|
||
offer: [-2, 0, 2, 2, 3]
|
||
};
|
||
|
||
/** The raw number for a stage and a result, whatever that number means. */
|
||
export function contactDeltaFor(stage, level) {
|
||
const row = CONTACT_OUTCOMES[stage];
|
||
if (!row) return 0;
|
||
const i = LEVEL_LADDER.indexOf(String(level));
|
||
return i < 0 ? 0 : row[i];
|
||
}
|
||
|
||
/**
|
||
* How much a contact roll moves STANDING.
|
||
*
|
||
* An offer is the stage that buys standing, so its delta always applies. A fumble at
|
||
* any stage costs you, because insulting somebody is not free — that half of the
|
||
* table was computed and discarded for the life of the social pillar (R-246). A
|
||
* successful read or signal moves nothing: what a read earns is facts.
|
||
*/
|
||
export function impressionDeltaFor(stage, level) {
|
||
const delta = contactDeltaFor(stage, level);
|
||
if (stage === "offer") return delta;
|
||
return delta < 0 ? delta : 0;
|
||
}
|
||
|
||
/* -------------------------------------------- */
|
||
/* What the armour actually covers */
|
||
/* -------------------------------------------- */
|
||
|
||
/**
|
||
* ARMOUR COVERAGE.
|
||
*
|
||
* Every equipped piece used to add its points to every location, which meant a
|
||
* Brodie helmet protected your legs and a cuirass protected your head. With a
|
||
* catalogue that contains sallets, lobster-tail helmets and cuirasses that is not a
|
||
* simplification, it is wrong — and it could not be shown on the sheet without the
|
||
* sheet printing something visibly false.
|
||
*
|
||
* Coverage is declared as a NAMED SET rather than a list of locations, because armour
|
||
* has to fit whatever body is wearing it. The sets expand to location *kinds*, so a
|
||
* cuirass covers the torso of a baseline agent and the trunk of a vesh without either
|
||
* the item or the wearer knowing about the other.
|
||
*
|
||
* `all` is the default, so a piece that declares nothing behaves exactly as every
|
||
* piece did before this rule existed.
|
||
*/
|
||
export const COVERAGE = {
|
||
all: { id: "all", label: "RINGBRP.Coverage.all", kinds: null },
|
||
torso: { id: "torso", label: "RINGBRP.Coverage.torso",
|
||
kinds: ["body", "vital", "ridge", "unit"] },
|
||
torsoArms: { id: "torsoArms", label: "RINGBRP.Coverage.torsoArms",
|
||
kinds: ["body", "vital", "ridge", "unit", "arm", "grasp"] },
|
||
torsoLimbs: { id: "torsoLimbs", label: "RINGBRP.Coverage.torsoLimbs",
|
||
kinds: ["body", "vital", "ridge", "unit", "arm", "grasp", "leg", "brace"] },
|
||
head: { id: "head", label: "RINGBRP.Coverage.head", kinds: ["head"] },
|
||
// Armour that fits an animal rather than a man: barding covers the barrel, the
|
||
// neck and the legs, and reaches no wing. A cuirass does none of this, which is
|
||
// why it is a set of its own rather than another kind bolted onto torsoLimbs.
|
||
barding: { id: "barding", label: "RINGBRP.Coverage.barding",
|
||
kinds: ["body", "vital", "neck", "leg"] }
|
||
};
|
||
|
||
export function coverageFor(id) {
|
||
return COVERAGE[id] || COVERAGE.all;
|
||
}
|
||
|
||
/** Does a piece with this coverage protect a location of this kind? */
|
||
export function armourCoversKind(coverageId, kind) {
|
||
const kinds = coverageFor(coverageId).kinds;
|
||
return kinds === null ? true : kinds.includes(kind);
|
||
}
|
||
|
||
/**
|
||
* The armour on one location: what the body has there anyway, plus every worn piece
|
||
* that reaches it. `pieces` is [{ points, coverage }] — worn only; the caller decides
|
||
* what "worn" means, because that is a question about the actor and not about armour.
|
||
*/
|
||
export function locationArmourFor(base, pieces, kind) {
|
||
let n = Number(base) || 0;
|
||
for (const p of pieces ?? []) {
|
||
if (armourCoversKind(p?.coverage, kind)) n += Number(p?.points) || 0;
|
||
}
|
||
return n;
|
||
}
|
||
|
||
/**
|
||
* SHIELDS.
|
||
*
|
||
* A shield is the only armour that is not simply worn: it is armour you HOLD, and it
|
||
* protects you exactly as much as you are currently holding it up. So it is armour
|
||
* with a switch, and everything interesting about it follows from that switch having
|
||
* a cost.
|
||
*
|
||
* Raised, it adds its points to what it covers and it is the one piece of armour you
|
||
* can also defend with. Lowered — slung, or because both your hands are full — it is
|
||
* weight and nothing else.
|
||
*
|
||
* The cost is that it occupies a hand and you are shooting around your own shield.
|
||
* That is what stops "always raised" from being the only answer: it is obviously
|
||
* right for a containment officer and obviously wrong for a marksman.
|
||
*/
|
||
export const SHIELD = {
|
||
ranged: -20, // your own shooting, from behind your own shield
|
||
coverage: "torsoArms", // what a raised shield presents, unless it says otherwise
|
||
parryFamily: "melee_weapon",
|
||
parrySpec: "shield"
|
||
};
|
||
|
||
/** A shield's points count only while it is up and only while it is still a shield. */
|
||
export function shieldArmourFor(points, { raised = false, broken = false } = {}) {
|
||
return (raised && !broken) ? (Number(points) || 0) : 0;
|
||
}
|
||
|
||
/** Shooting past your own shield. Nothing if it is down. */
|
||
export function shieldRangedPenaltyFor(raised) {
|
||
return raised ? SHIELD.ranged : 0;
|
||
}
|
||
|
||
/**
|
||
* Shields break. `degradation` has been on every armour item since the packs were
|
||
* first built and was read by nothing; this is what it was for.
|
||
*
|
||
* Wear is taken for stopping something that was genuinely trying — an ordinary blow
|
||
* turned aside costs the shield nothing, because otherwise every fight is arithmetic
|
||
* about splinters.
|
||
*/
|
||
// Derived from the ladder every other band decision uses rather than written out as
|
||
// a second table keyed by the same five names — which is both a duplicate vocabulary
|
||
// and, as the guard pointed out, indistinguishable from one.
|
||
export const SHIELD_WEAR_FROM = "success";
|
||
|
||
export function shieldWearFor(attackLevel) {
|
||
return Math.max(0, levelRank(attackLevel) - levelRank(SHIELD_WEAR_FROM));
|
||
}
|
||
|
||
/** At its limit it is firewood: no points, and nothing to parry with. */
|
||
export function shieldBroken(current, max) {
|
||
const c = Number(current) || 0, m = Number(max) || 0;
|
||
if (m <= 0) return false;
|
||
return c >= m;
|
||
}
|
||
|
||
/** A lamp never makes it daylight. This is the best it gets you. */
|
||
export const LIGHT_FLOOR = "dim";
|
||
|
||
/**
|
||
* HOW FAR A LAMP THROWS.
|
||
*
|
||
* A lamp used to light exactly one person: the one holding it. Played out, that put
|
||
* three agents standing inside the same pool of light shooting at −50% while the
|
||
* fourth, an arm's length away, shot at −10%. Nobody would run that at a table.
|
||
*
|
||
* So a lamp has a REACH, and anyone inside it is in the light — including the people
|
||
* it is showing to whatever is out there, which is the half of the rule that costs
|
||
* something. The reach is per item, because a weapon lamp and a roof searchlight are
|
||
* not the same offer.
|
||
*/
|
||
export const LAMP = { reachM: 10 };
|
||
|
||
export function lampReachFor(reachM) {
|
||
const r = Number(reachM);
|
||
return r > 0 ? r : LAMP.reachM;
|
||
}
|
||
|
||
/**
|
||
* Is this person inside that lamp's pool? Distance in metres, as everything else is.
|
||
*
|
||
* An UNKNOWN distance is not "in the light". `Number(null)` is 0, so a missing
|
||
* measurement would otherwise read as standing on top of the lamp and light the whole
|
||
* scene for free — which is precisely the failure this rule exists to correct.
|
||
*/
|
||
export function lampCovers(distanceM, reachM) {
|
||
if (distanceM === null || distanceM === undefined || distanceM === "") return false;
|
||
const d = Number(distanceM);
|
||
if (!Number.isFinite(d) || d < 0) return false;
|
||
return d <= lampReachFor(reachM);
|
||
}
|
||
|
||
export function lightingFor(id) {
|
||
return LIGHTING[id] || LIGHTING.lit;
|
||
}
|
||
|
||
/**
|
||
* The modifier the dark actually applies, after a lamp and after a thermal sight.
|
||
* A sight that sees in the dark beats a lamp, and does not tell anybody where you are.
|
||
*/
|
||
export function lightingModFor(id, { lightSource = false, seesInDark = false } = {}) {
|
||
const here = lightingFor(id);
|
||
if (seesInDark) return { mod: 0, effective: "lit", revealed: false };
|
||
if (!lightSource) return { mod: here.mod, effective: here.id, revealed: false };
|
||
const floor = lightingFor(LIGHT_FLOOR);
|
||
// A lamp cannot make anything worse, and cannot make it better than the floor.
|
||
const eff = here.mod >= floor.mod ? here : floor;
|
||
return { mod: eff.mod, effective: eff.id, revealed: eff.id !== here.id };
|
||
}
|
||
|
||
/**
|
||
* TWO WEAPONS.
|
||
*
|
||
* The off hand is a real second attack and is meant to cost something. Untrained it
|
||
* is a heavy penalty on BOTH hands, because a person waving two knives is worse with
|
||
* each of them. The Ambidextrous talent removes the main-hand penalty entirely and
|
||
* halves the off-hand one; nothing removes the off-hand penalty altogether.
|
||
*
|
||
* The off-hand weapon must be lighter than the main hand, which is the rule that
|
||
* stops two poleaxes.
|
||
*/
|
||
// Reviewed and corrected: the trained profile was 0/−20, which is very nearly a free
|
||
// second attack and therefore a compulsory pick for anyone who can hold two things.
|
||
// Ambidextrous now buys CONSISTENCY, not a free action — both hands at −20 — which is
|
||
// still plainly worth having and is no longer the only sane build.
|
||
export const TWO_WEAPON = { main: -20, off: -40, trainedMain: -20, trainedOff: -20,
|
||
offHandMaxKg: 2.0 };
|
||
|
||
export function twoWeaponPenaltyFor({ trained = false } = {}) {
|
||
return trained
|
||
? { main: TWO_WEAPON.trainedMain, off: TWO_WEAPON.trainedOff }
|
||
: { main: TWO_WEAPON.main, off: TWO_WEAPON.off };
|
||
}
|
||
|
||
/** Can this actually be paired? The off hand takes the lighter thing, and not much. */
|
||
export function canPairWeapons(mainKg, offKg) {
|
||
const m = Number(mainKg) || 0, o = Number(offKg) || 0;
|
||
if (o > TWO_WEAPON.offHandMaxKg) return false;
|
||
return o <= m;
|
||
}
|
||
|
||
/* -------------------------------------------- */
|
||
/* The ways the world kills you */
|
||
/* -------------------------------------------- */
|
||
|
||
/**
|
||
* FALLING.
|
||
*
|
||
* BRP-standard: a die of damage per three metres, to a random location, and armour
|
||
* does not help because the ground is not a weapon. A fall onto something soft or a
|
||
* controlled landing halves the distance BEFORE the dice are counted, which is where
|
||
* Hard Landing and a descent harness earn their keep.
|
||
*/
|
||
export const FALL = { metresPerDie: 3, die: 6, maxDice: 20 };
|
||
|
||
export function fallDamageFor(metres, { soft = false, controlled = false } = {}) {
|
||
// Halving is not cumulative. Soft ground and a controlled descent are the same
|
||
// mitigation twice over, and stacking them turned a lethal drop into a stumble.
|
||
let m = Math.max(0, Number(metres) || 0);
|
||
if (soft || controlled) m /= 2;
|
||
const dice = Math.min(FALL.maxDice, Math.floor(m / FALL.metresPerDie));
|
||
return { dice, formula: dice > 0 ? `${dice}d${FALL.die}` : "0", ignoresArmour: true };
|
||
}
|
||
|
||
/**
|
||
* DROWNING, and anything else that stops you breathing.
|
||
*
|
||
* You hold on for CON rounds if you had warning and half that if you did not. After
|
||
* that it is one point of damage per round and it does not stop, because the water
|
||
* does not get bored. Armour is irrelevant and so is a Major Wound: this is the one
|
||
* track that runs straight to zero.
|
||
*/
|
||
export const ASPHYXIA = { perRound: 1, unpreparedFraction: 0.5 };
|
||
|
||
export function breathRoundsFor(con, { prepared = true } = {}) {
|
||
const c = Math.max(1, Number(con) || 1);
|
||
return Math.max(1, Math.floor(prepared ? c : c * ASPHYXIA.unpreparedFraction));
|
||
}
|
||
|
||
/**
|
||
* FIRE.
|
||
*
|
||
* Fire is not one hit, it is a condition with a size, and the size is what matters.
|
||
* Armour protects on the round you are set alight and never again — sealed suits
|
||
* excepted, which is the entire reason to own one.
|
||
*/
|
||
// `armour` means "worn protection helps on the round you CATCH". It never helps
|
||
// afterwards, because by then the fire is inside it. Only a sealed suit is different
|
||
// and that is the whole reason to sign one out.
|
||
export const FIRE = [
|
||
{ id: "spark", label: "RINGBRP.Fire.spark", damage: "1d3", rounds: 1, armour: true },
|
||
{ id: "clothes", label: "RINGBRP.Fire.clothes", damage: "1d6", rounds: 3, armour: true },
|
||
{ id: "pool", label: "RINGBRP.Fire.pool", damage: "2d6", rounds: 4, armour: true },
|
||
{ id: "engulf", label: "RINGBRP.Fire.engulf", damage: "3d6", rounds: 6, armour: true }
|
||
];
|
||
|
||
export function fireBandFor(id) {
|
||
return FIRE.find(f => f.id === id) ?? FIRE[1];
|
||
}
|
||
|
||
/**
|
||
* EXPLOSIVES.
|
||
*
|
||
* One roll of damage at the centre, halved at each band outward, and cover is worth
|
||
* more than armour — which is the lesson every agency learns and writes down.
|
||
* A charge placed deliberately, with time, hits the centre band automatically.
|
||
*/
|
||
export const BLAST = [
|
||
{ id: "contact", label: "RINGBRP.Blast.contact", share: 1, armour: 0.5 },
|
||
{ id: "near", label: "RINGBRP.Blast.near", share: 0.5, armour: 1 },
|
||
{ id: "far", label: "RINGBRP.Blast.far", share: 0.25, armour: 1 }
|
||
];
|
||
|
||
export function blastBandFor(metres, radius) {
|
||
const m = Math.max(0, Number(metres) || 0), r = Math.max(1, Number(radius) || 1);
|
||
if (m <= r / 2) return BLAST[0];
|
||
if (m <= r) return BLAST[1];
|
||
if (m <= r * 2) return BLAST[2];
|
||
return null; // outside it entirely
|
||
}
|
||
|
||
/**
|
||
* ONE formula, floored ONCE. Rolled x range share x cover, rounded down, and only
|
||
* then does armour come off. Flooring at each step changed the answer depending on
|
||
* the order somebody happened to call these in.
|
||
*/
|
||
export function blastShareFor(rolled, band, cover = "none") {
|
||
if (!band) return 0;
|
||
const f = BLAST_COVER[cover] ?? 1;
|
||
return Math.max(0, Math.floor((Number(rolled) || 0) * band.share * f));
|
||
}
|
||
|
||
/** Cover is the answer to a blast. Armour is only ever half of one. */
|
||
export const BLAST_COVER = { none: 1, partial: 0.5, solid: 0.25, sealed: 0 };
|
||
|
||
export function blastAfterCover(damage, cover = "none") {
|
||
const f = BLAST_COVER[cover] ?? 1;
|
||
return Math.max(0, Math.floor((Number(damage) || 0) * f));
|
||
}
|
||
|
||
/**
|
||
* WHAT DESTROYING A LOCATION ACTUALLY DOES.
|
||
*
|
||
* The printed effect for a destroyed head reads "For anything that keeps its brain
|
||
* there, this is death" — and the code set `unconscious` and stopped. An agent could
|
||
* have every location on their body destroyed, head included, and the sheet would
|
||
* report them as DOWN. Destruction of something you cannot live without is not a
|
||
* penalty, and this is the rule that says so.
|
||
*
|
||
* DISABLED is not destroyed: a disabled head is a concussion and stays a concussion.
|
||
* Only DESTROYED reaches here.
|
||
*/
|
||
export function destructionOutcomeFor(kind, destroyed = false) {
|
||
if (!destroyed) return "none";
|
||
switch (kind) {
|
||
case "head": return "dead"; // whatever keeps its brain there no longer does
|
||
case "neck": return "dead"; // nothing carries a head on a severed neck
|
||
case "ridge": case "vital": return "dying";
|
||
default: return "none"; // limbs AND WINGS are ruinous, not fatal
|
||
}
|
||
}
|
||
|
||
/**
|
||
* WHAT HAPPENS AT ZERO.
|
||
*
|
||
* Nothing did. An agent reduced to 0 hit points was marked down and then lay there
|
||
* indefinitely: bleeding deliberately stops at 0, no rule advanced anything, and the
|
||
* only mention of death in the whole system was flavour text on a destroyed head.
|
||
* A game with hit locations, Major Wounds and bleeding had no way for anyone to die.
|
||
*
|
||
* Hit points are NOT taken below zero — every damage path clamps there, and unpicking
|
||
* that would touch everything. Instead, zero starts a clock:
|
||
*
|
||
* above 2 you are up
|
||
* 2 or below DOWN — out of the fight, still conscious, still yours
|
||
* 0 DYING — unconscious, no actions, no defences, and a count begins
|
||
* count met DEAD
|
||
*
|
||
* The count is the agent's own Major Wound threshold, so a solid Custodian has longer
|
||
* on the floor than a slight one, which is the same number already deciding how much
|
||
* damage they can take standing up. First Aid or Medicine STABILISES: the count stops
|
||
* and they stay at 0, unconscious, until somebody heals them above it.
|
||
*/
|
||
export const DYING = { perRound: 1, minCount: 2, stabiliseWith: ["first_aid", "medicine"] };
|
||
|
||
/** How many rounds this agent has at zero before the count is met. */
|
||
export function dyingLimitFor(majorWound) {
|
||
return Math.max(DYING.minCount, Number(majorWound) || 0);
|
||
}
|
||
|
||
/** The four states, from one place, so nothing has to re-derive them. */
|
||
export function conditionFor({ hp = 0, dyingRounds = 0, majorWound = 0,
|
||
stabilised = false, unconscious = false } = {}) {
|
||
const h = Number(hp) || 0;
|
||
if (h <= 0) {
|
||
if (!stabilised && (Number(dyingRounds) || 0) >= dyingLimitFor(majorWound)) return "dead";
|
||
return "dying";
|
||
}
|
||
if (h <= 2 || unconscious) return "down";
|
||
return "up";
|
||
}
|
||
|
||
/**
|
||
* THE LAST ENTRY.
|
||
*
|
||
* A Custodian's death is a filing question, because everything here is. How well
|
||
* filed they were when they died decides what the record ends up saying, and the
|
||
* badly filed do not get recorded as dead at all — which is the department's
|
||
* problem, and then somebody else's.
|
||
*/
|
||
export const LAST_ENTRY = [
|
||
{ atLeast: 7, id: "recorded", label: "RINGBRP.Death.Entry.recorded" },
|
||
{ atLeast: 4, id: "queried", label: "RINGBRP.Death.Entry.queried" },
|
||
{ atLeast: 1, id: "unclosed", label: "RINGBRP.Death.Entry.unclosed" },
|
||
{ atLeast: 0, id: "transferred", label: "RINGBRP.Death.Entry.transferred" }
|
||
];
|
||
|
||
export function lastEntryFor(coherence) {
|
||
const c = Number(coherence) || 0;
|
||
return LAST_ENTRY.find(e => c >= e.atLeast) ?? LAST_ENTRY[LAST_ENTRY.length - 1];
|
||
}
|
||
|
||
/**
|
||
* HOW HARD A CASE PHASE IS.
|
||
*
|
||
* The old ramp made every phase after the first Difficult — which HALVES the lead's
|
||
* skill — and then stacked a cumulative −5% on top. Measured across two played
|
||
* cases, a realistic lead of 40–55% (Tradecraft is a support skill on most postings
|
||
* that train it at all) faced 20% → 15% → 10% → 5% → 1%, and both cases were decided
|
||
* by Resources running out rather than by anything the players did.
|
||
*
|
||
* The two pressures are now separated, because they were never the same idea:
|
||
*
|
||
* DIFFICULTY is about the phase. Only Containment is Difficult — the step the case
|
||
* is actually about — and it does not also take the ramp. It is hard because it is
|
||
* containment, not because you are tired.
|
||
*
|
||
* The MODIFIER is about attrition. It accumulates as the case wears on, and it
|
||
* stops accumulating, because a fifth phase is not four times worse than a second.
|
||
*/
|
||
export const PHASE_RAMP = { step: -5, cap: -15, hardKinds: ["containment"] };
|
||
|
||
export function phaseRampFor(index, kindId = "") {
|
||
const hard = PHASE_RAMP.hardKinds.includes(String(kindId));
|
||
return {
|
||
difficulty: hard ? "difficult" : "average",
|
||
// A hard phase carries its difficulty INSTEAD of the ramp, never both.
|
||
modifier: hard ? 0 : Math.max(PHASE_RAMP.cap, PHASE_RAMP.step * (Number(index) || 0))
|
||
};
|
||
}
|
||
|
||
/* -------------------------------------------- */
|
||
/* Borrowed Authority */
|
||
/* -------------------------------------------- */
|
||
|
||
/**
|
||
* ACCESS IS A RECORD.
|
||
*
|
||
* There is no hacking skill and no network to enter. What an agent has is a named
|
||
* credential belonging to somebody real — a facilities contractor, a locum, a
|
||
* supplier's engineer — with a scope of things that person could plausibly do, a
|
||
* Trust that erodes as it is used, and a red flag or two describing how the real
|
||
* owner behaves. Inside its scope the credential simply works. Outside it, you are
|
||
* asking a system to believe something about a person, and that is a roll.
|
||
*
|
||
* The point of the design in a game about reality keeping a record of what belongs
|
||
* where: an Authority IS a record, and Coherence can widen it, because reality can
|
||
* be made to remember that the person held that duty. What it costs is that the
|
||
* agent's own record starts merging with the one they borrowed.
|
||
*/
|
||
|
||
/** What a credential could conceivably be asked to do, in order. */
|
||
export const SCOPE_TIERS = ["nothing", "read", "routine", "privileged", "administrative"];
|
||
|
||
export function scopeRank(tier) {
|
||
const i = SCOPE_TIERS.indexOf(String(tier));
|
||
return i < 0 ? 0 : i;
|
||
}
|
||
|
||
/**
|
||
* How an action stands against a credential's scope. This is the rule that stops
|
||
* "I hack anything": two tiers beyond the credential is not hard, it is impossible,
|
||
* and no percentage on any sheet changes that.
|
||
*
|
||
* at or below scope -> it simply works. No roll. 1 Trust.
|
||
* exactly one above -> a stretch. Roll for it.
|
||
* two or more above -> impossible. The identity was never able to do this.
|
||
*/
|
||
export function scopeStanceFor(authorityScope, actionTier) {
|
||
const gap = scopeRank(actionTier) - scopeRank(authorityScope);
|
||
if (gap <= 0) return "in-scope";
|
||
if (gap === 1) return "stretch";
|
||
return "impossible";
|
||
}
|
||
|
||
/** Behaving unlike the person you are claiming to be is what gets noticed. */
|
||
export const RED_FLAG_STEP = -20;
|
||
|
||
export function scrutinyFor(flagsTripped = 0) {
|
||
return RED_FLAG_STEP * Math.max(0, Number(flagsTripped) || 0);
|
||
}
|
||
|
||
/**
|
||
* What a stretched use of a credential does to it. Mirrors the defence ladder: the
|
||
* band is the whole answer, and a fumble is not merely a failure — it burns the
|
||
* credential and puts the person it belongs to in the department's way.
|
||
*/
|
||
export function authorityOutcomeFor(level) {
|
||
switch (level) {
|
||
case "critical": return { works: true, trustDelta: +1, burned: false };
|
||
case "special": return { works: true, trustDelta: 0, burned: false };
|
||
case "success": return { works: true, trustDelta: -1, burned: false };
|
||
case "fumble": return { works: false, trustDelta: -99, burned: true };
|
||
default: return { works: false, trustDelta: -1, burned: false };
|
||
}
|
||
}
|
||
|
||
/** Trust never rises above what the dossier bought, and stops at nothing. */
|
||
export const TRUST_MAX = 4;
|
||
|
||
export function trustAfter(current, delta, max = TRUST_MAX) {
|
||
const t = (Number(current) || 0) + (Number(delta) || 0);
|
||
return Math.max(0, Math.min(Number(max) || TRUST_MAX, t));
|
||
}
|
||
|
||
/**
|
||
* THE DOSSIER — how a credential is obtained.
|
||
*
|
||
* Not a second subsystem and not a stack of flat bonuses: a dossier item does not
|
||
* make the roll easier, it makes the credential better. Each item is a separate
|
||
* scene using a different skill, and at most one may be Computer Use, so assembling
|
||
* access is something the whole team does rather than one specialist.
|
||
*
|
||
* Trust starts at 1 and rises by one per item. Scope starts at "read" and a single
|
||
* item — the one that establishes what the person is actually FOR — may raise it.
|
||
*/
|
||
export const DOSSIER = { maxItems: 3, baseTrust: 1, baseScope: "read", maxComputerUse: 1 };
|
||
|
||
export function dossierTrustFor(itemCount) {
|
||
return Math.max(1, Math.min(TRUST_MAX, DOSSIER.baseTrust + (Number(itemCount) || 0)));
|
||
}
|
||
|
||
/** A dossier that establishes remit widens what the credential covers, once. */
|
||
export function dossierScopeFor(itemCount, establishesRemit = false) {
|
||
const base = scopeRank(DOSSIER.baseScope);
|
||
const up = (establishesRemit && (Number(itemCount) || 0) >= 2) ? 1 : 0;
|
||
return SCOPE_TIERS[Math.min(SCOPE_TIERS.length - 1, base + up)];
|
||
}
|
||
|
||
/**
|
||
* Coherence widens a scope by making reality remember that the person held that
|
||
* duty. The cost rises with how far you have already pushed the record — the first
|
||
* lie is cheap and the fourth is not.
|
||
*/
|
||
export function widenScopeCostFor(timesWidened = 0) {
|
||
return 1 + Math.max(0, Number(timesWidened) || 0);
|
||
}
|
||
|
||
/**
|
||
* The horror, with a number on it. Every time an Authority is pushed past what its
|
||
* Trust can carry, the agent's file and the borrowed one converge one step. This is
|
||
* what the subsystem is actually about and it is deliberately not reversible by
|
||
* spending anything.
|
||
*/
|
||
export const MERGE_STAGES = [
|
||
"RINGBRP.Authority.Merge.0",
|
||
"RINGBRP.Authority.Merge.1",
|
||
"RINGBRP.Authority.Merge.2",
|
||
"RINGBRP.Authority.Merge.3",
|
||
"RINGBRP.Authority.Merge.4"
|
||
];
|
||
|
||
export function mergeStageFor(steps) {
|
||
const n = Math.max(0, Math.min(MERGE_STAGES.length - 1, Number(steps) || 0));
|
||
return { step: n, label: MERGE_STAGES[n], terminal: n >= MERGE_STAGES.length - 1 };
|
||
}
|
||
|
||
/**
|
||
* Fists are a fallback, not a peer of the weapon you are carrying.
|
||
*
|
||
* A generated adversary was given Brawl from the same range as its weapon skills,
|
||
* so an Armed thug was exactly as good with its hands as with the pistol on its
|
||
* hip — and any sensible target-picker chose the fists, which then bounced off
|
||
* armour 2 for 1d3. Brawl is now a fraction of the trained rating, never below the
|
||
* skill's printed base.
|
||
*
|
||
* This does NOT apply to a creature that ATTACKS with Brawl: the anomalous grasp is
|
||
* a trained attack and keeps its full rating. The caller decides which case it is.
|
||
*/
|
||
export const BRAWL_FALLBACK = { fraction: 0.6, floor: 25 };
|
||
|
||
export function brawlFallbackFor(trainedRating) {
|
||
const r = Math.round((Number(trainedRating) || 0) * BRAWL_FALLBACK.fraction);
|
||
return Math.max(BRAWL_FALLBACK.floor, r);
|
||
}
|
||
|
||
/**
|
||
* A skill whose base is written as a formula rather than a flat number.
|
||
*
|
||
* Dodge is the only one, and its base is DEX×2 — which nothing in the system ever
|
||
* worked out. `base.mode` was "formula", `base.value` stayed 0, and every reader
|
||
* took `system.value` at face value, so an agent whose posting did not train Dodge
|
||
* had Dodge 0%: the game's universal defence, unavailable to everybody who had not
|
||
* separately bought it, and missing its floor even for those who had.
|
||
*
|
||
* Deliberately not eval(). Accepts `char`, `char*n`, `char+n` and `char*n+m`.
|
||
*/
|
||
export function skillBaseFrom(formula, characteristics = {}) {
|
||
const m = /^\s*([a-z]+)\s*(?:\*\s*(\d+))?\s*(?:\+\s*(\d+))?\s*$/i.exec(String(formula || ""));
|
||
if (!m) return 0;
|
||
const stat = Number(characteristics?.[m[1].toLowerCase()]?.value
|
||
?? characteristics?.[m[1].toLowerCase()]) || 0;
|
||
return Math.max(0, stat * (m[2] ? Number(m[2]) : 1) + (m[3] ? Number(m[3]) : 0));
|
||
}
|
||
|
||
/**
|
||
* Spec §1. Difficulty is a multiplier on the rating, not a modifier on the roll.
|
||
*
|
||
* These three — applyDifficulty, resolveBands, gradeRoll — are the whole of d100
|
||
* resolution, and they lived in ringbrp.mjs until the lethality harness needed them.
|
||
* They are pure: nothing here touches an Actor, a roll message or any other Foundry
|
||
* global, so there was never a reason for them to sit in the engine except that the
|
||
* engine is where they were first written. check-rules already guards against a rule
|
||
* keeping a second copy of itself in ringbrp.mjs; a rule that lives ONLY there is the
|
||
* same problem one step earlier, because nothing outside Foundry can reach it to check
|
||
* it. ringbrp.mjs imports and re-exports them, so its public surface is unchanged.
|
||
*/
|
||
export function applyDifficulty(rating, difficulty = "average") {
|
||
switch (difficulty) {
|
||
case "easy": return rating * 2;
|
||
case "difficult": return Math.floor(rating / 2);
|
||
case "average":
|
||
default: return rating;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Spec §1 resolution. Returns the full band breakdown without rolling,
|
||
* so sheets can display the odds before the player commits.
|
||
*/
|
||
export function resolveBands(rating, { difficulty = "average", situational = 0, printedBase = 0 } = {}) {
|
||
let effective = applyDifficulty(Number(rating) || 0, difficulty) + (Number(situational) || 0);
|
||
if (printedBase >= 5) effective = Math.max(effective, 5);
|
||
const band = Math.min(100, Math.max(1, effective));
|
||
// `effective` used to be the RAW figure, so a skill beaten down to 0 or below
|
||
// printed "0%" on the chat card while the true chance was 1% — the floor was
|
||
// real but arrived sideways, through the critical band, and the card contradicted
|
||
// the rule the book states. One honest number now: what you actually need.
|
||
return {
|
||
effective: band,
|
||
band,
|
||
critical: ceil(band / 20),
|
||
special: ceil(band / 5),
|
||
fumbleStart: 101 - ceil((101 - band) / 20)
|
||
};
|
||
}
|
||
|
||
/** Spec §1. Fumble is tested first; 96–99 never succeed; 00 always fumbles. */
|
||
export function gradeRoll(roll, bands) {
|
||
if (roll >= bands.fumbleStart) return "fumble";
|
||
if (roll >= 96 && roll <= 99) return "failure";
|
||
if (roll <= bands.critical) return "critical";
|
||
if (roll <= bands.special) return "special";
|
||
if (roll <= bands.effective) return "success";
|
||
return "failure";
|
||
}
|
||
|
||
/**
|
||
* The five outcome bands, worst to best. Exported so nothing has to re-declare
|
||
* the order to compare two rolls against each other.
|
||
*/
|
||
export const LEVEL_LADDER = ["fumble", "failure", "success", "special", "critical"];
|
||
|
||
/** Where a level sits on the ladder. Anything unknown reads as a failure. */
|
||
export function levelRank(level) {
|
||
const i = LEVEL_LADDER.indexOf(String(level));
|
||
return i < 0 ? 1 : i;
|
||
}
|
||
|
||
/**
|
||
* What a defence actually does to the blow it answers.
|
||
*
|
||
* The old rule was binary: any successful dodge or parry stopped anything, so a
|
||
* boarding-axe critical was cancelled outright by an ordinary parry and cost the
|
||
* attacker nothing. That sat badly against every other thing the system says about
|
||
* criticals — maximum damage, armour ignored entirely — and it made the best roll
|
||
* in the game worth no more than the commonest one.
|
||
*
|
||
* Defences are now graded. A defence stops a blow of its own quality or worse
|
||
* outright. Against something better it still helps, but only by as much as it was
|
||
* outclassed by: the blow lands one step worse for every step the defence fell
|
||
* short. So the attack degrades a critical to a special, a special to a success,
|
||
* and a success to nothing — and only a critical defence stops a critical.
|
||
*
|
||
* landing rank = failure + (attack rank − defence rank)
|
||
*
|
||
* A failed defence does nothing at all and the blow lands at its own level.
|
||
*/
|
||
export function defenceOutcomeFor(attackLevel, defenceLevel) {
|
||
const a = levelRank(attackLevel);
|
||
const d = levelRank(defenceLevel);
|
||
// A defence that did not succeed never reduces anything.
|
||
if (d < levelRank("success")) {
|
||
return { turnedAside: false, landsAt: LEVEL_LADDER[a], steps: 0 };
|
||
}
|
||
const steps = a - d;
|
||
if (steps <= 0) return { turnedAside: true, landsAt: null, steps: 0 };
|
||
return { turnedAside: false, landsAt: LEVEL_LADDER[levelRank("failure") + steps], steps };
|
||
}
|
||
|
||
/**
|
||
* You may not mix your defences in a round: having chosen to dodge, you dodge.
|
||
* Returns the type you are allowed to use, given what you have already done.
|
||
*/
|
||
export function defenceTypeAllowed(lock, wanted) {
|
||
return !lock || lock === wanted ? wanted : lock;
|
||
}
|
||
|
||
/**
|
||
* THE QUIET ARTS.
|
||
*
|
||
* A power is not a spell. Crossing is an induced filing error — you persuade the
|
||
* record you are somewhere else — and an Art is the same trick performed small and
|
||
* on purpose: a misfiling you commit deliberately. So an Art costs the one thing
|
||
* that misfiling always costs, which is Coherence, and needs no second resource.
|
||
*
|
||
* Nobody learns one. They arrive from crossings, in the people the crossing
|
||
* changed, which is why the category the agency files them under is the same one
|
||
* headed "the crossing left this in you".
|
||
*/
|
||
export const ARTS = {
|
||
thread: { order: 1, coherence: 1, push: 2 },
|
||
echo: { order: 2, coherence: 1, push: 2 },
|
||
hollow: { order: 3, coherence: 1, push: 3 },
|
||
witness: { order: 4, coherence: 1, push: 2 },
|
||
unmake: { order: 5, coherence: 2, push: 4 },
|
||
ledger: { order: 6, coherence: 2, push: 4 }
|
||
};
|
||
|
||
/** What using an Art costs, ordinarily and when pushed past its reach. */
|
||
export function powerCostFor(artId, { pushed = false } = {}) {
|
||
const a = ARTS[artId];
|
||
if (!a) return pushed ? 2 : 1;
|
||
return pushed ? a.push : a.coherence;
|
||
}
|
||
|
||
/**
|
||
* An Art is governed by POW, and its starting rating is what the crossing left
|
||
* behind rather than anything the agent trained: POW plus a little, capped low.
|
||
* They are dangerous because of what they do, not because of what they roll.
|
||
*/
|
||
export function powerStartingRating(pow) {
|
||
return Math.max(5, Math.min(45, (Number(pow) || 0) * 2));
|
||
}
|
||
|
||
/**
|
||
* COMING BACK.
|
||
*
|
||
* The return is a second crossing and it has to be aimed. R-56 holds: it is never
|
||
* impossible, only dearer — a failure arrives you NEAR your target rather than at
|
||
* it, and a fumble brings you back changed.
|
||
*
|
||
* "Changed" is where the Quiet Arts come from. Nobody learns one; they arrive in
|
||
* people a crossing went wrong for, which is why the pool an agent draws from is
|
||
* decided by how far they had already drifted when it happened. You do not choose
|
||
* what you get, and the worse the state you were in, the worse the thing that
|
||
* follows you home.
|
||
*/
|
||
export const CROSSING = {
|
||
fumbleCoherence: 2, // a botched return costs this on top of everything else
|
||
failCoherence: 1
|
||
};
|
||
|
||
/** Which Arts can arrive, given how anchored the agent was when it went wrong. */
|
||
export function crossingArtPoolFor(coherence) {
|
||
const v = Number(coherence) || 0;
|
||
if (v >= 5) return ["thread", "echo", "witness"]; // still mostly here
|
||
if (v >= 3) return ["hollow", "thread", "echo"]; // adrift
|
||
return ["unmake", "ledger", "hollow"]; // unmoored or worse
|
||
}
|
||
|
||
/**
|
||
* Did the crossing leave something behind? A fumble always does. A failure only
|
||
* does to an agent who was already coming apart.
|
||
*/
|
||
export function crossingGrantsArt(level, coherence) {
|
||
if (level === "fumble") return true;
|
||
if (level === "failure") return (Number(coherence) || 0) <= 2;
|
||
return false;
|
||
}
|
||
|
||
/** What a return of this quality costs in Coherence. */
|
||
export function crossingCostFor(level) {
|
||
if (level === "fumble") return CROSSING.fumbleCoherence;
|
||
if (level === "failure") return CROSSING.failCoherence;
|
||
return 0;
|
||
}
|