Files
RingBRP/ringbrp.mjs
T
slaguru666andClaude Opus 5 51bd8e618a Audit every remaining sheet and dialog; add the Culland Hall scene
Opened all of it this time, rather than driving the system through its API.

Two more defects of the same family as the NPC sheet, and in the same place —
the types added last:

  - Talent and Art had no TYPES.Item.* label, so their sheets printed the raw
    key "TYPES.Item.talent" under the name.
  - Neither had a branch in item-sheet.hbs or an isTalent/isPower flag, so the
    Detail tab rendered EMPTY over a document holding category, rule, uses per
    case, Coherence cost, push cost and origin. Eleven of thirteen types were
    handled, which is exactly the shape an eye skips.

Both now have a header statline and a full Detail section, and a guard walks
template.json: every declared type must have a label, a flag set in ringbrp.mjs,
and an {{#if isX}} section in the sheet.

One hardening fix: loseCoherence applies the cost and THEN announces it, so
anything that throws in between leaves the actor quietly poorer with no message.
localize() throws on a non-string, which an unrecognised reason key is.

Everything else came back clean, and it is worth recording what was checked so
the next audit knows what "checked" meant: 13 item types opened, all render; the
expedition sheet (4 tabs); all 7 chat cards driven through their real entry
points — roll, coherence, reaction via Combat.rollAll, hit location, weapon
damage, major wound, and contact through a case file that actually has a group;
all 3 dialogs opened by clicking the controls a player clicks. No errors, no raw
localisation keys anywhere.

Also: Culland Hall as a playable Scene. Drawn as SVG rather than generated,
because a room count, a door position and a label are all load-bearing here and
a generator cannot be trusted with any of them — three label-collision passes in
the browser before it was clean, which is the documented cost of doing it this
way. 2800x2000 at 100px to the square so the Foundry grid lands on the grid
drawn into the image; 5ft squares; no walls, because half-done walls are worse
than none and a GM who wants vision can add them in ten minutes.

Verified in Foundry: the scene imports, activates, and draws.

Version 1.0.2.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-12 19:02:18 +01:00

6422 lines
299 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* RingBRP — The Custodians — Basic Roleplaying, d100 roll-under
* Implements RINGBRP_RULES_SPEC v0.6.
*
* Rule references in comments use the spec's section numbers so the code and the
* document can be audited against each other.
*/
/* -------------------------------------------- */
/* Configuration */
/* -------------------------------------------- */
export const RINGBRP = {};
/* -------------------------------------------- */
/* v13/v14 compatibility shims */
/* -------------------------------------------- */
/** renderTemplate moved under foundry.applications.handlebars in v13. */
// Rule formulas live in ONE place, shared with the pack builder so the packs can
// never be built under a different rule than the game is played by.
import {
ceil, hitPointsFor, majorWoundFor, damageModifierFor, styleFor,
locationMaxHp, reflexRating, awarenessRating, encumbranceFrom, reactionBaseFrom,
reactionLoadFrom, reactionPrintedFrom, woundEffectsFor,
BLEED, bleedStoppedBy, bleedRateFor, locationEffectsFor,
encumbrancePenaltyFor, ENCUMBERED_CATEGORIES, freeCarryFor,
defencePenaltyFor, defenceTypeAllowed, DEFENCE_STEP,
defenceOutcomeFor, levelRank, LEVEL_LADDER, skillBaseFrom,
DYING, dyingLimitFor, conditionFor, lastEntryFor, LAST_ENTRY, destructionOutcomeFor,
RETURN_CLOCK, returnPenaltyFor, returnIsDifficult, extractionCostFor,
FALL, fallDamageFor, ASPHYXIA, breathRoundsFor, FIRE, fireBandFor,
BLAST, blastBandFor, blastShareFor, BLAST_COVER, blastAfterCover,
BURST, burstHitsFor, burstRoundsSpent, BURST_FUMBLE, BURST_ONE_MAJOR_WOUND,
BURST_HITS, LAMP, lampReachFor, lampCovers,
CONTACT_OUTCOMES, contactDeltaFor, impressionDeltaFor,
COVERAGE, coverageFor, armourCoversKind, locationArmourFor,
SHIELD, shieldArmourFor, shieldRangedPenaltyFor, shieldWearFor, shieldBroken,
FIRE_MODE, fireModeFor, burstSizesFor, fireModeAttackFor, secondShotPenaltyFor,
SUSTAIN, sustainDamageFor, sustainChargeFor,
SIGHT, sightFor, sightBonusFor, sightSeesInDark,
LIGHTING, lightingModFor,
SPACE_BANDS, spaceBandRank, spaceBandAfter, STATIONS, HEAT, heatEffectsFor,
spaceRangeModFor, spaceHitFor, craftConditionFor,
TWO_WEAPON, twoWeaponPenaltyFor, canPairWeapons,
brawlFallbackFor, BRAWL_FALLBACK,
SCOPE_TIERS, scopeRank, scopeStanceFor, scrutinyFor, RED_FLAG_STEP,
authorityOutcomeFor, trustAfter, TRUST_MAX, DOSSIER, dossierTrustFor,
dossierScopeFor, widenScopeCostFor, mergeStageFor, MERGE_STAGES,
ARTS, powerCostFor, powerStartingRating,
CROSSING, crossingArtPoolFor, crossingGrantsArt, crossingCostFor, REACTION,
CIRCUMSTANCE, RANGE_LADDER, rangeBandFrom, MAJOR_WOUND_TABLE,
COHERENCE_BANDS, COHERENCE_COSTS, COHERENCE_RECOVERY, coherenceBandFrom
} from "./rules.mjs";
export { hitPointsFor, majorWoundFor, damageModifierFor, locationMaxHp, reflexRating, awarenessRating };
function rbRenderTemplate(path, data) {
const fn = foundry?.applications?.handlebars?.renderTemplate ?? globalThis.renderTemplate;
return fn(path, data);
}
/** Dialog v1 lives at foundry.appv1.api.Dialog in v13+; the global still resolves. */
/**
* Every dialog the system opens carries the sheet's classes, so the callout boxes
* are the same object as the character sheet rather than Foundry's default
* parchment with a serif face sitting in front of it.
*/
function rbDialogClass() {
const Base = foundry?.appv1?.api?.Dialog ?? globalThis.Dialog;
if (!Base) return Base;
if (!rbDialogClass._themed) {
rbDialogClass._themed = class RingBRPDialog extends Base {
static get defaultOptions() {
const opts = super.defaultOptions;
return foundry.utils.mergeObject(opts, {
classes: [...(opts.classes ?? []), "ringbrp-window"]
});
}
};
}
return rbDialogClass._themed;
}
/** TextEditor moved under foundry.applications.ux in v13. */
function rbEnrich(html) {
const TE = foundry?.applications?.ux?.TextEditor?.implementation ?? globalThis.TextEditor;
return TE.enrichHTML(html ?? "");
}
/** Skill categories and their single governing characteristic (spec §2.1). */
RINGBRP.categories = {
melee: { label: "RINGBRP.Category.Melee", stat: "str" },
ranged: { label: "RINGBRP.Category.Ranged", stat: null }, // set by marksmanship style
comm: { label: "RINGBRP.Category.Communication", stat: "cha" },
manip: { label: "RINGBRP.Category.Manipulation", stat: "dex" },
mental: { label: "RINGBRP.Category.Mental", stat: "int" },
percep: { label: "RINGBRP.Category.Perception", stat: "pow" },
phys: { label: "RINGBRP.Category.Physical", stat: "dex" } // v0.5: DEX, not STR
};
/**
* Marksmanship is now a CHOICE, not an accident of birth. Home world and gravity
* were removed with the setting rewrite — everyone comes from the same Earth —
* but the two shooting styles are a good decision with real trade-offs, so they
* survive as something the agent trained into.
*/
RINGBRP.marksmanship = {
reflex: { label: "RINGBRP.Style.Reflex", stat: "dex", shortBonus: 10, longBonus: 0, aim: 20, moveAndFire: 0, secondAttack: true },
deliberate: { label: "RINGBRP.Style.Deliberate", stat: "pow", shortBonus: 0, longBonus: 10, aim: 30, moveAndFire: -20, ignoresArmour: 4 }
};
/** Speed as a band, because the game measures distance in bands everywhere else. */
RINGBRP.speedBands = {
walk: "RINGBRP.Speed.walk", slow: "RINGBRP.Speed.slow", road: "RINGBRP.Speed.road",
cross: "RINGBRP.Speed.cross", fast: "RINGBRP.Speed.fast", air: "RINGBRP.Speed.air",
instant: "RINGBRP.Speed.instant"
};
RINGBRP.difficulties = {
automatic: "RINGBRP.Difficulty.Automatic",
easy: "RINGBRP.Difficulty.Easy",
average: "RINGBRP.Difficulty.Average",
difficult: "RINGBRP.Difficulty.Difficult",
impossible: "RINGBRP.Difficulty.Impossible"
};
/**
* Circumstance. Difficulty (easy/average/difficult) is a property of the TASK and
* multiplies the rating. Circumstance is a property of the SITUATION and is flat,
* because a hand-hold in the dark is worth the same to an expert as to a novice.
* Rule R-63.
*/
// Not a copy — rules.mjs owns the ladder; this is the keyed view the UI wants.
RINGBRP.circumstances = Object.fromEntries(CIRCUMSTANCE.map(c => [c.id, { mod: c.mod, label: c.label }]));
/**
* R-64. At most TWO circumstance modifiers apply to one roll: the single largest
* favourable and the single largest unfavourable. They sum, then clamp at +-40.
* This is what stops a roll accumulating six small bonuses into certainty.
*/
export function circumstanceTotal(mods = []) {
const vals = mods.map(m => typeof m === "string" ? (RINGBRP.circumstances[m]?.mod ?? 0) : (Number(m) || 0));
const best = Math.max(0, ...vals.filter(v => v > 0));
const worst = Math.min(0, ...vals.filter(v => v < 0));
return Math.max(-40, Math.min(40, best + worst));
}
/** Difficulty ladder (spec S1). */
/** Range ladder (spec §4.4). */
RINGBRP.rangeLadder = [
{ band: "short", mod: 0 },
{ band: "medium", mod: -10 },
{ band: "long", mod: -30 },
{ band: "extreme", mod: -50 }
];
/** Cover (spec §4.3). */
RINGBRP.cover = {
none: { label: "RINGBRP.Cover.None", mod: 0 },
light: { label: "RINGBRP.Cover.Light", mod: -10 },
partial: { label: "RINGBRP.Cover.Partial", mod: -30 },
full: { label: "RINGBRP.Cover.Full", mod: -50 }
};
/** woundType → characteristic lost on Major Wound results 9 and 10 (spec §3.3). */
RINGBRP.woundTypeMap = {
edged: "dex",
blunt: "con",
piercing: "str",
energy: "str",
explosive: "con"
};
/** Major Wound consequences, 1d10 (spec §3.3). */
RINGBRP.majorWoundTable = MAJOR_WOUND_TABLE;
/** ============================================================
* HIT LOCATIONS (spec §3.6)
* Taken from the 1984 Creatures Book, which gives each species a
* 1D20 table and each location a FRACTION of total hit points.
* Melee and ranged distributions differ: melee finds limbs because
* they are between you and the target; ranged finds centre of mass.
* ============================================================ */
/* Location fractions are of TOTAL hit points. They were sized against the old
26-point pool; halving HP left a leg at 4 against a sidearm doing 4-6 through
armour, so 64% of hits crippled the limb they struck. Raised so a sidearm
cripples ~40% of the time and a rifle ~55%. */
RINGBRP.locationTables = {
baseline: {
label: "RINGBRP.Species.Baseline",
locations: [
{ id: "legR", label: "RINGBRP.Loc.LegR", melee: [1, 4], ranged: [1, 3], frac: 0.42, kind: "leg" },
{ id: "legL", label: "RINGBRP.Loc.LegL", melee: [5, 8], ranged: [4, 6], frac: 0.42, kind: "leg" },
{ id: "abdomen", label: "RINGBRP.Loc.Abdomen", melee: [9, 11], ranged: [7, 10], frac: 0.42, kind: "body" },
{ id: "chest", label: "RINGBRP.Loc.Chest", melee: [12, 12], ranged: [11, 15], frac: 0.5, kind: "vital"},
{ id: "armR", label: "RINGBRP.Loc.ArmR", melee: [13, 15], ranged: [16, 17], frac: 0.35, kind: "arm" },
{ id: "armL", label: "RINGBRP.Loc.ArmL", melee: [16, 18], ranged: [18, 19], frac: 0.35, kind: "arm" },
{ id: "head", label: "RINGBRP.Loc.Head", melee: [19, 20], ranged: [20, 20], frac: 0.4, kind: "head" }
]
},
/* A VESH is bilaterally ASYMMETRIC: one long grasping limb, one short bracing
limb, and no head at all — the senses run along a dorsal ridge. There is no
single killing shot to the skull because there is no skull. The ridge is the
vital, and it is a long target rather than a small one. */
vesh: {
label: "RINGBRP.Species.Vesh",
locations: [
{ id: "footBrace", label: "RINGBRP.Loc.BraceFoot", melee: [1, 3], ranged: [1, 3], frac: 0.42, kind: "leg" },
{ id: "limbBrace", label: "RINGBRP.Loc.BraceLimb", melee: [4, 8], ranged: [4, 7], frac: 0.42, kind: "brace" },
{ id: "lowTrunk", label: "RINGBRP.Loc.LowerTrunk", melee: [9, 12], ranged: [8, 12], frac: 0.42, kind: "body" },
{ id: "upTrunk", label: "RINGBRP.Loc.UpperTrunk", melee: [13, 15], ranged: [13, 15], frac: 0.42, kind: "body" },
{ id: "limbGrasp", label: "RINGBRP.Loc.GraspLimb", melee: [16, 18], ranged: [16, 17], frac: 0.35, kind: "grasp" },
{ id: "ridge", label: "RINGBRP.Loc.Ridge", melee: [19, 20], ranged: [18, 20], frac: 0.5, kind: "ridge" }
]
},
/* A CADENCE is not one body. It is four to six small bodies that together are
one person, and the location roll picks which body you hit. Killing a body
does not kill the person — it reduces them. There is no vital and no head:
every body carries an equal share, and the person degrades as they are
whittled down. This is why shooting a Cadence feels like nothing is working. */
cadence: {
label: "RINGBRP.Species.Cadence",
locations: [
{ id: "bodyA", label: "RINGBRP.Loc.BodyA", melee: [1, 4], ranged: [1, 4], frac: 0.28, kind: "unit" },
{ id: "bodyB", label: "RINGBRP.Loc.BodyB", melee: [5, 8], ranged: [5, 8], frac: 0.28, kind: "unit" },
{ id: "bodyC", label: "RINGBRP.Loc.BodyC", melee: [9, 12], ranged: [9, 12], frac: 0.28, kind: "unit" },
{ id: "bodyD", label: "RINGBRP.Loc.BodyD", melee: [13, 16], ranged: [13, 16], frac: 0.28, kind: "unit" },
{ id: "bodyE", label: "RINGBRP.Loc.BodyE", melee: [17, 20], ranged: [17, 20], frac: 0.28, kind: "unit" }
]
}
};
/**
* The figure drawn on the combat tab. One entry per species profile, keyed by the
* same location ids as the table above, so a species that has a location gets a
* shape and one that does not simply has none — no separate list to keep in step.
*
* Deliberately blocky: these are readable at sheet size and honest about the
* anatomy. A Vesh is asymmetric with no head; a Cadence is five separate bodies.
*/
RINGBRP.bodyArt = {
baseline: {
viewBox: "0 0 120 196",
parts: {
head: { t: "ellipse", cx: 60, cy: 21, rx: 15, ry: 17 },
chest: { t: "rect", x: 40, y: 42, w: 40, h: 40, rx: 9 },
abdomen: { t: "rect", x: 44, y: 84, w: 32, h: 30, rx: 8 },
armR: { t: "rect", x: 18, y: 45, w: 17, h: 64, rx: 8 },
armL: { t: "rect", x: 85, y: 45, w: 17, h: 64, rx: 8 },
legR: { t: "rect", x: 43, y: 117, w: 15, h: 72, rx: 7 },
legL: { t: "rect", x: 62, y: 117, w: 15, h: 72, rx: 7 }
}
},
/* One long grasping limb, one short bracing limb, a trunk that leans, and the
sensory ridge running down the back instead of a head. */
vesh: {
viewBox: "0 0 120 196",
parts: {
ridge: { t: "path", d: "M44 18 L70 14 L76 96 L52 100 Z" },
upTrunk: { t: "path", d: "M40 34 L70 30 L74 84 L44 88 Z" },
lowTrunk: { t: "path", d: "M44 90 L74 86 L78 132 L48 136 Z" },
limbGrasp: { t: "path", d: "M74 36 L96 44 L104 148 L86 150 L82 60 Z" },
limbBrace: { t: "rect", x: 20, y: 52, w: 16, h: 48, rx: 8 },
footBrace: { t: "path", d: "M46 136 L78 132 L84 188 L58 190 Z" }
}
},
/* Five bodies, one person. No vital, no head, every body an equal share. */
cadence: {
viewBox: "0 0 120 196",
parts: {
bodyA: { t: "ellipse", cx: 34, cy: 52, rx: 20, ry: 24 },
bodyB: { t: "ellipse", cx: 86, cy: 44, rx: 20, ry: 24 },
bodyC: { t: "ellipse", cx: 60, cy: 100, rx: 20, ry: 24 },
bodyD: { t: "ellipse", cx: 28, cy: 148, rx: 20, ry: 24 },
bodyE: { t: "ellipse", cx: 88, cy: 152, rx: 20, ry: 24 }
}
}
};
/** What losing a location does, by anatomical KIND rather than by name,
* so a new species inherits sensible consequences for free. */
RINGBRP.locationEffects = {
leg: { disabled: "RINGBRP.LocEffect.leg", destroyed: "RINGBRP.LocEffect.legX" },
arm: { disabled: "RINGBRP.LocEffect.arm", destroyed: "RINGBRP.LocEffect.armX" },
brace: { disabled: "RINGBRP.LocEffect.brace", destroyed: "RINGBRP.LocEffect.braceX" },
grasp: { disabled: "RINGBRP.LocEffect.grasp", destroyed: "RINGBRP.LocEffect.graspX" },
ridge: { disabled: "RINGBRP.LocEffect.ridge", destroyed: "RINGBRP.LocEffect.ridgeX" },
unit: { disabled: "RINGBRP.LocEffect.unit", destroyed: "RINGBRP.LocEffect.unitX" },
body: { disabled: "RINGBRP.LocEffect.body", destroyed: "RINGBRP.LocEffect.bodyX" },
vital: { disabled: "RINGBRP.LocEffect.vital", destroyed: "RINGBRP.LocEffect.vitalX" },
head: { disabled: "RINGBRP.LocEffect.head", destroyed: "RINGBRP.LocEffect.headX" }
};
// Not a copy — rules.mjs owns the modes; this is the keyed view the item sheet wants,
// with a blank at the front for melee. The hand-written list that used to live here
// was missing `fast`, which five catalogue weapons already carried: the select had no
// matching option, so opening one of those items and saving it blanked its fire mode.
RINGBRP.fireModes = ["", ...Object.keys(FIRE_MODE)];
RINGBRP.sights = SIGHT;
RINGBRP.coverage = COVERAGE;
RINGBRP.lighting = LIGHTING;
RINGBRP.sustain = SUSTAIN;
RINGBRP.weaponClasses = ["melee", "thrown", "bow", "firearm", "energy", "stun", "special"];
RINGBRP.damageTags = ["kinetic", "energy", "monofilament", "disintegrator", "explosive", "blunt", "edged", "stun"];
RINGBRP.traditions = ["tradition_home", "tradition_rival_a", "tradition_rival_b", "tradition_local", "tradition_builder", "tradition_precursor"];
RINGBRP.environments = ["zero_g", "vacuum", "high_altitude"];
RINGBRP.researchStepTypes = ["field", "library", "laboratory", "armchair"];
RINGBRP.facilities = { none: 0, portable: -20, laboratory: 0, builder_grade: 20 };
/** ============================================================
* Combat constants. Initiative is Reaction Lanes — see rules.mjs
* for the formulas and the block above rollReaction for intent.
* ============================================================ */
/** H — handling cost of the declared action. */
/**
* A percentile result read as two dice, tens and units, the way the table reads
* it. Returns the face values so the chat card can draw them.
*/
export function percentileFaces(total) {
const n = Math.max(1, Math.min(100, Number(total) || 0));
const t = n === 100 ? 0 : Math.floor(n / 10) * 10;
const u = n === 100 ? 0 : n % 10;
return { tens: t, units: u, display: n };
}
/**
* Readiness. Acting first is not only how well you handle a weapon — it is
* whether you read the situation before it broke. Drawn from the best of these,
* so being switched-on is rewarded independently of being armed.
*/
RINGBRP.readinessSkills = ["tradecraft", "insight", "spot", "listen"];
/** INT floors readiness, so a clever but untrained character is not flat. */
RINGBRP.readinessIntFactor = 3;
/** The readiness rating: the best awareness skill, floored by INT x 3. */
export function readinessRating(actor) {
let best = 0;
for (const fam of RINGBRP.readinessSkills) {
best = Math.max(best, Number(actor?.skillRating?.(fam)) || 0);
}
const int = Number(actor?.system?.characteristics?.int?.value) || 0;
const intFloor = int * RINGBRP.readinessIntFactor;
return { rating: Math.max(best, intFloor), best, intFloor };
}
/* -------------------------------------------- */
/* Reaction — initiative */
/* -------------------------------------------- */
/**
* REACTION LANES. Replaces the old Strike Rank entirely.
*
* The formulas live in rules.mjs and are NOT restated here - reactionBaseFrom(),
* reflexRating(), awarenessRating() and the REACTION constants are the authority.
* This comment describes intent and measured behaviour only, so that it cannot
* drift out of step with the arithmetic the way the old copy did.
*
* Initiative = current printed base + one die, HIGHEST acts first.
*
* Quick thought fully substitutes for quick hands: your best route sets your
* speed, so a perceptive analyst and a fast shooter reach the same lane by
* different fictions.
*
* Measured on the shipped pregens (bases 24 / 18 / 18 / 15) with a d6 and
* three-point bands: the random share is 35.7%, inside the 30-35% design ceiling.
* Same band is a 50/50 with 16.7% ties, broken by DEX. One band apart wins 83.3%
* (90.9% excluding ties). Two bands apart is 100% - a six-point gap cannot be
* closed by a d6, so a large advantage is decisive rather than merely likely.
* That determinism at the top end is the price of keeping the die visible and
* the share honest; it was chosen over shrinking the die back to a d4.
*
* Nothing here is rolled per weapon. Equipment, talents, cover and supernatural
* effects print ONE of three words - Edge, Drag, Aim - and move a marker.
*/
// Not a copy — the same object rules.mjs exports. A second literal here is how
// the divisors drifted before; check-rules now fails if this stops being identical.
RINGBRP.reaction = REACTION;
/** The four words. Everything that touches initiative uses one of them. */
RINGBRP.reactionModifiers = {
aim: { label: "RINGBRP.React.Aim", values: [0, 1, 2] },
edge: { label: "RINGBRP.React.Edge", value: 1 },
drag: { label: "RINGBRP.React.Drag", value: -1 },
load: { label: "RINGBRP.React.Load", values: [0, -1, -2] }
};
/** The number printed on the paper character sheet. Calculated once. */
export function reactionBase(actor) {
const dex = actor?.system?.characteristics?.dex?.value;
const read = readinessRating(actor).rating;
const reflex = reflexRating(dex);
const aware = awarenessRating(read);
const base = reactionBaseFrom(dex, read);
const load = reactionLoadFrom(actor?.system?.encumbrance?.srLoad);
return {
base, load,
printed: reactionPrintedFrom(base, actor?.system?.encumbrance?.srLoad),
reflex, aware, lane: Math.max(reflex, aware),
from: reflex >= aware ? "reflex" : "awareness"
};
}
/**
* Load on the reaction, exact and to one decimal. This used to be a three-value
* band, which charged a whole point for the first gram over free carry and then
* nothing further until 10 kg. It is now the granular figure the encumbrance rule
* always computed but nothing consumed.
*
* Load is NOT a rail position. It is a property of what the agent is carrying,
* known before the fight, so it is subtracted into the number printed on the
* sheet. The rail is left entirely to situation — aim, edge, drag.
*/
export function loadBand(actor) {
return reactionLoadFrom(actor?.system?.encumbrance?.srLoad);
}
/**
* The situational modifier, capped. This is the clip position on the paper rail.
* Load is deliberately absent: it is carried in the printed base instead, so that
* the rail means only what is happening right now and cannot double-count kit.
*/
export function reactionModifier({ aim = 0, edge = false, drag = false } = {}) {
const cap = RINGBRP.reaction.modCap;
const total = Math.max(0, Math.min(2, Number(aim) || 0))
+ (edge ? 1 : 0) - (drag ? 1 : 0);
return Math.max(-cap, Math.min(cap, total));
}
/**
* Coherence Flux. An additive Flux would have breached the 35% random ceiling, so
* a misfiled agent's die LIES instead. The distribution stays uniform 1..4 — the
* probabilities and the ceiling are untouched — but reality files them early or
* late, which is the setting doing the work rather than a bolted-on penalty.
*/
export function fluxDie(face, coherence) {
const c = Number(coherence ?? 10);
if (c >= 6) return face;
if (c >= 3) return face === 1 ? 4 : (face === 4 ? 1 : face); // outer faces misfile
return 5 - face; // 1<->4, 2<->3
}
/** Spec §1. Difficulty multiplies the rating FIRST, then situational modifiers add. */
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";
}
/** Spec §4.4. Returns {band, mod} or {band:"beyond", mod:null} when impossible. */
export const rangeBandFor = rangeBandFrom;
/** Spec §2.3. Style bonus applies on top of the range modifier. */
/**
* Plain English for a marksmanship style, assembled from its own numbers so the
* sheet can never drift from the rule. Only clauses that actually apply appear.
*/
function styleHintFor(styleId) {
const cfg = RINGBRP.marksmanship[styleId];
if (!cfg) return "";
const F = (k, v) => game.i18n.format(k, v);
const bits = [];
if (cfg.shortBonus) bits.push(F("RINGBRP.Style.Hint.Short", { n: cfg.shortBonus }));
if (cfg.longBonus) bits.push(F("RINGBRP.Style.Hint.Long", { n: cfg.longBonus }));
if (cfg.aim) bits.push(F("RINGBRP.Style.Hint.Aim", { n: cfg.aim }));
if (cfg.secondAttack) bits.push(game.i18n.localize("RINGBRP.Style.Hint.Second"));
if (cfg.moveAndFire) bits.push(F("RINGBRP.Style.Hint.Move", { n: cfg.moveAndFire }));
if (cfg.ignoresArmour) bits.push(F("RINGBRP.Style.Hint.Pierce", { n: cfg.ignoresArmour }));
return bits.join(" · ");
}
export function styleRangeBonus(style, band) {
const cfg = RINGBRP.marksmanship[style];
if (!cfg) return 0;
if (band === "short") return cfg.shortBonus ?? 0;
if (band === "long" || band === "extreme") return cfg.longBonus ?? 0;
return 0;
}
/** Spec §3. Hit points and the floored Major Wound threshold. */
/** Spec §3. Damage after armour never goes below zero. */
export function damageAfterArmour(rolled, armour) {
return Math.max(0, (Number(rolled) || 0) - (Number(armour) || 0));
}
/** Spec R-39. Armour treatment by attack success level. */
export function armourAgainst(level, armour) {
const a = Number(armour) || 0;
if (level === "critical") return 0; // ignores armour entirely
if (level === "special") return Math.floor(a / 2); // counts as half
return a;
}
/**
* Spec R-39 + §3. Applies a resolved attack to a target and reports whether it
* crossed the Major Wound threshold. Returns a plain object; callers decide what
* to do with it, so this stays testable.
*/
export function resolveHit({ level, rolled, armour, majorWoundThreshold }) {
const effArmour = armourAgainst(level, armour);
const damage = damageAfterArmour(rolled, effArmour);
return {
level, rolled, armour, effectiveArmour: effArmour, damage,
majorWound: damage >= (Number(majorWoundThreshold) || Infinity)
};
}
/** Spec §3.6. Which location a 1D20 finds, for this species and attack type. */
export function locationFor(roll, speciesId = "baseline", mode = "ranged") {
const table = RINGBRP.locationTables[speciesId] ?? RINGBRP.locationTables.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: derived data said destroyed, the resolver said not.
destroyed: after >= locationMax * 2,
majorWound: d >= (Number(majorWoundThreshold) || Infinity)
};
}
/* -------------------------------------------- */
/* Documents */
/* -------------------------------------------- */
class RingBRPActor extends Actor {
prepareDerivedData() {
super.prepareDerivedData();
const sys = this.system;
if (this.type === "expedition") return this._prepareExpedition();
if (!sys.characteristics) return;
const ch = sys.characteristics;
const v = (k) => Number(ch[k]?.value) || 0;
// --- Marksmanship style: the agent's own choice ---
sys.marksmanship ??= {};
sys.marksmanship.style = sys.marksmanship.chosenStyle || "reflex";
sys.marksmanship.locked = false;
const styleCfg = RINGBRP.marksmanship[sys.marksmanship.style] ?? RINGBRP.marksmanship.reflex;
sys.marksmanship.stat = styleCfg.stat;
// --- Category bonuses: ceil(stat/2) each (spec §2.1) ---
sys.categoryBonus = {};
for (const [key, cfg] of Object.entries(RINGBRP.categories)) {
const stat = key === "ranged" ? styleCfg.stat : cfg.stat;
sys.categoryBonus[key] = ceil(v(stat) / 2);
}
// --- Hit points and Major Wound (spec §3) ---
const maxHp = hitPointsFor(v("con"), v("siz"));
sys.hp ??= { value: maxHp, max: maxHp };
sys.hp.max = maxHp;
if (sys.hp.value === null || sys.hp.value === undefined) sys.hp.value = maxHp;
sys.hp.value = Math.min(sys.hp.value, maxHp);
sys.majorWound = { value: majorWoundFor(maxHp) };
// --- Attribute rolls (spec §3, ×5) ---
sys.rolls = {
stamina: v("con") * 5,
idea: v("int") * 5,
luck: v("pow") * 5
};
// --- Damage modifier and carry (spec §3.1, §4) ---
sys.damageModifier = damageModifierFor(v("str") + v("siz"));
// STR alone ignored how big you are. rules.mjs owns the formula.
sys.freeCarryKg = freeCarryFor(v("str"), v("siz"));
sys.mov = 10;
// Coherence was added after these actors existed, and template.json only
// fills defaults at creation. Default it here so no actor is ever without it.
sys.coherence ??= { value: 10, max: 10, drift: [] };
sys.coherence.max ??= 10;
sys.coherence.value ??= sys.coherence.max;
sys.coherence.drift ??= [];
sys.coherenceBand = coherenceBand(sys.coherence.value);
// --- Encumbrance from carried items only (spec §4) ---
let carried = 0;
for (const it of this.items) {
const s = it.system;
if (s?.carried === false) continue;
const mass = Number(s?.massKg) || 0;
const qty = Number(s?.quantity) || 1;
carried += mass * qty;
}
sys.carriedKg = Math.round(carried * 10) / 10;
// Two readings of one condition, deliberately (rule R-23 as revised 8 Aug 2026):
// steps floored whole steps -> MOV and skill penalties
// srLoad ungraded, per 10 kg -> so no weight is ever free
// Computed by rules.mjs, not here. This block used to hold its own copy.
sys.encumbrance = encumbranceFrom(carried, sys.freeCarryKg);
sys.mov = Math.max(1, 10 - sys.encumbrance.steps);
// Reaction — the number printed on the paper sheet. MUST come after
// encumbrance: the printed value has load subtracted into it, and deriving it
// earlier read an unset sys.encumbrance and silently printed a load of zero.
const _reflex = reflexRating(v("dex"));
const _aware = awarenessRating(readinessRating(this).rating);
const _base = reactionBaseFrom(v("dex"), readinessRating(this).rating);
sys.reaction = {
reflex: _reflex, aware: _aware,
lane: Math.max(_reflex, _aware),
base: _base,
load: reactionLoadFrom(sys.encumbrance.srLoad),
printed: reactionPrintedFrom(_base, sys.encumbrance.srLoad),
from: _reflex >= _aware ? "reflex" : "awareness"
};
// --- Armour: what is worn, and what each piece actually reaches ---
// Every piece used to add to every location, so a Brodie helmet protected your
// legs. Pieces now declare coverage and the total is computed per location.
const naturalArmour = Number(sys.naturalArmour?.value) || 0;
// A shield is armour you HOLD: its points count only while it is raised and only
// while it is still a shield. Lowered or broken it is weight and nothing else.
const wornArmour = this.items.filter(i => i.type === "armour" && i.system.equipped)
.map(i => {
const isShield = !!i.system.shield;
const broken = isShield && shieldBroken(i.system.degradation?.current,
i.system.degradation?.max);
const pts = Number(i.system.points) || 0;
return { name: i.name, shield: isShield, raised: !!i.system.raised, broken,
points: isShield ? shieldArmourFor(pts, { raised: !!i.system.raised, broken })
: pts,
coverage: i.system.coverage || "all" };
})
.filter(p => p.points > 0 || !p.shield);
sys.wornArmour = wornArmour;
// The shield in hand, if there is one up. Everything else reads this.
{
const up = this.items.find(i => i.type === "armour" && i.system.shield
&& i.system.equipped && i.system.raised
&& !shieldBroken(i.system.degradation?.current, i.system.degradation?.max));
sys.shieldUp = up ? { id: up.id, name: up.name, points: Number(up.system.points) || 0,
wear: Number(up.system.degradation?.current) || 0,
wearMax: Number(up.system.degradation?.max) || 0 } : null;
}
// --- hit locations (spec §3.6) ---
const speciesId = sys.speciesProfile || "baseline";
const table = RINGBRP.locationTables[speciesId] ?? RINGBRP.locationTables.baseline;
const taken = sys.locationDamage ?? {};
sys.speciesLabel = table.label;
sys.locations = table.locations.map(loc => {
const max = locationMaxHp(maxHp, loc.frac);
const t = Number(taken[loc.id]) || 0;
return {
id: loc.id, label: loc.label, kind: loc.kind,
melee: `${loc.melee[0]}${loc.melee[1] > loc.melee[0] ? `-${loc.melee[1]}` : ""}`,
ranged: `${loc.ranged[0]}${loc.ranged[1] > loc.ranged[0] ? `-${loc.ranged[1]}` : ""}`,
max, taken: t, remaining: Math.max(0, max - t),
// The body's own armour here, plus every worn piece that reaches this kind.
armour: locationArmourFor((Number(loc.armour) || 0) + naturalArmour,
wornArmour, loc.kind),
armourFrom: wornArmour.filter(p => armourCoversKind(p.coverage, loc.kind))
.map(p => ({ name: p.name, points: p.points })),
disabled: t >= max,
destroyed: t >= max * 2
};
});
// The header wants ONE number and there is no longer one number. The torso is
// what "how armoured am I" means — it is where most hits land — and the range
// is carried alongside so a sheet can say when the rest of you is not that.
{
const vals = sys.locations.map(l => l.armour);
const torso = sys.locations.find(l => l.kind === "vital")
?? sys.locations.find(l => l.kind === "body");
sys.armourPoints = torso ? torso.armour : (vals[0] ?? naturalArmour);
sys.armourRange = { min: Math.min(...vals, sys.armourPoints),
max: Math.max(...vals, sys.armourPoints) };
sys.armourUneven = sys.armourRange.min !== sys.armourRange.max;
}
// Wound effects were printed and never applied: a destroyed leg said "MOV
// halved" while MOV stayed where it was. Derive the consequences here so the
// penalty text and the numbers agree.
{
// Both tables, folded. A shattered leg and a concussion are separate facts
// about the same agent, so the numbers add rather than one replacing the
// other. Location consequences used to be computed inline here and covered
// only legs and arms — a destroyed head left the agent conscious, an opened
// Vesh ridge cost no Perception, and a Cadence losing bodies lost nothing.
const loc = locationEffectsFor(sys.locations);
const mw = woundEffectsFor(sys.wounds);
sys.wounded = {
legs: loc.legs, legsDestroyed: loc.legsDestroyed, arms: loc.arms,
vital: loc.vital, unitsLost: loc.unitsLost,
movFactor: loc.movFactor * mw.movFactor,
manipulation: loc.manipulation + mw.manipulation,
physical: loc.physical + mw.physical,
perception: loc.perception + mw.perception,
communication: mw.communication,
all: loc.all + mw.all,
bleed: mw.bleed,
defenceOnly: mw.defenceOnly,
unconscious: loc.unconscious || mw.unconscious,
lostAction: mw.lostAction,
prone: loc.prone || mw.defenceOnly
};
sys.mov = Math.max(mw.movFloor || 1, Math.floor(sys.mov * sys.wounded.movFactor));
}
sys.disabledLocations = sys.locations.filter(l => l.disabled).length;
sys.isDown = (sys.hp.value <= 2) || !!sys.wounded?.defenceOnly || !!sys.wounded?.unconscious;
// Up / down / dying / dead, from the one rule, so nothing re-derives it.
const dy = sys.dying ?? { rounds: 0, stabilised: false };
sys.condition = (sys.dying?.dead) ? "dead" : conditionFor({
hp: sys.hp.value, dyingRounds: dy.rounds, majorWound: sys.majorWound?.value,
stabilised: dy.stabilised, unconscious: sys.wounded?.unconscious
});
sys.dyingLimit = dyingLimitFor(sys.majorWound?.value);
}
_prepareExpedition() {
const sys = this.system;
for (const t of ["supply", "power", "integrity"]) {
sys[t] ??= { value: 0, max: 10 };
sys[t].value = Math.max(0, Math.min(sys[t].value, sys[t].max));
}
// R-56 at case scale. An exhausted track used to BLOCK phase resolution, which
// strands a case with no way forward. It now degrades instead: the phase is
// Difficult and costs Containment, but it can always be attempted.
sys.destitute = ["supply", "power", "integrity"].filter(t => sys[t].value <= 0);
sys.inCrisis = sys.destitute.length > 0;
const legs = sys.journey?.legs ?? [];
sys.journey.totalLegs = legs.length;
sys.journey.legsRemaining = legs.filter(l => !l.resolved).length;
}
/**
* A new character starts whole. hp.max is derived from CON + SIZ, so the
* template's placeholder value leaves a fresh actor a few points down until
* something touches it. Fill it in at creation.
*/
async _preCreate(data, options, user) {
await super._preCreate(data, options, user);
if (data.system?.hp?.value !== undefined) return;
const c = this.system?.characteristics ?? {};
const max = (Number(c.con?.value) || 0) + (Number(c.siz?.value) || 0);
if (max > 0) this.updateSource({ "system.hp.value": max, "system.hp.max": max });
}
/**
* R-23's skill penalty was being derived and never applied. Encumbrance costs
* -5% per whole step to the categories that need the body: Physical,
* Manipulation and Melee. Mental and Communication are unaffected.
*/
encumbrancePenaltyFor(categoryId) {
if (!["phys", "manip", "melee"].includes(categoryId)) return 0;
return -(Number(this.system.encumbrance?.skillPenalty) || 0);
}
/** Total rating for a skill, already inclusive of the category bonus (see design note). */
skillRating(familyId, specialisationId = "") {
const it = this.items.find(i => i.type === "skill"
&& i.system.familyId === familyId
&& (!specialisationId || i.system.specialisationId === specialisationId));
return it ? Number(it.system.value) || 0 : null;
}
}
class RingBRPItem extends Item {
prepareDerivedData() {
super.prepareDerivedData();
const sys = this.system;
if (this.type === "skill") {
// A formula base is worked out from the owner's characteristics and acts as a
// FLOOR, not a replacement: Dodge is DEX×2 or whatever training raised it to,
// whichever is higher. Nothing evaluated this before, so Dodge read 0%.
if (sys.base?.mode === "formula" && sys.base.formula && this.actor) {
const floor = skillBaseFrom(sys.base.formula, this.actor.system?.characteristics);
sys.base.value = floor;
sys.value = Math.max(Number(sys.value) || 0, floor);
}
sys.categoryLabel = RINGBRP.categories[sys.categoryId]?.label ?? "";
sys.displayName = sys.specialisationId
? `${this.name}` : this.name;
}
if (this.type === "weapon") {
sys.isRanged = ["firearm", "energy", "bow", "thrown", "stun"].includes(sys.weaponClass);
sys.rangeSummary = sys.isRanged && sys.ranges?.long
? `${sys.ranges.short}/${sys.ranges.medium}/${sys.ranges.long} m` : "";
}
if (this.type === "device") {
sys.functional = (Number(sys.integrity?.value) || 0) >= (Number(sys.integrity?.max) || 0);
}
}
}
/* -------------------------------------------- */
/* Rolling */
/* -------------------------------------------- */
const LEVEL_LABEL = {
critical: "RINGBRP.Result.Critical",
special: "RINGBRP.Result.Special",
success: "RINGBRP.Result.Success",
failure: "RINGBRP.Result.Failure",
fumble: "RINGBRP.Result.Fumble"
};
/**
* What the agent's kit is worth to a given skill. Gear carries `skillBonus`, which
* until now was populated and read by nothing. Only carried gear counts, and the
* best single item applies — bonuses do not stack, or a pack full of medical kits
* would make anyone a surgeon.
*/
/**
* Kit and training are separate sources and they STACK. Within each source only
* the best single item applies — a pack full of medical kits does not make anyone
* a surgeon, and two talents that do the same thing are not additive — but a
* medical kit and Triage are different things and should both count.
*
* This function used to iterate every embedded item, which silently swept talents
* into the gear pool and collapsed the two into one bonus.
*/
export function gearBonusFor(actor, skillFamilyId) {
if (!actor || !skillFamilyId) return { value: 0, source: "" };
const bestOf = pred => {
let best = { value: 0, source: "" };
for (const it of actor.items ?? []) {
if (!pred(it)) continue;
if (it.system?.carried === false) continue;
const b = it.system?.skillBonus;
if (!b || b.skillFamilyId !== skillFamilyId) continue;
const v = Number(b.value) || 0;
if (v > best.value) best = { value: v, source: it.name };
}
return best;
};
const kit = bestOf(i => i.type !== "talent");
const training = bestOf(i => i.type === "talent");
const parts = [kit, training].filter(x => x.value);
return {
value: kit.value + training.value,
source: parts.map(x => x.source).join(" + "),
kit, training
};
}
/**
* The sight actually bolted to this weapon, decided by what the agent is carrying.
*
* The point of grading sights by range band is that they apply themselves: there is
* nothing for the player to select and nothing to forget, because the band is already
* known by the time the modifier is wanted. What this has to get right is FIT — a
* telescopic sight on a service pistol is not a thing, so an optic declares what it
* fits and is matched against the weapon's own specialisation.
*
* Only carried optics count, and the best single one applies: taping three sights to
* a rifle is not three bonuses.
*/
export function sightForWeapon(actor, weapon) {
if (!actor || !weapon?.system?.isRanged) return { id: "none", source: "" };
const spec = weapon.system.skillSpecialisationId || "";
let best = { id: "none", source: "", worth: 0 };
for (const it of actor.items ?? []) {
if (it.system?.carried === false) continue;
const o = it.system?.optic;
const kind = o?.kind;
if (!kind || !SIGHT[kind]) continue;
const fits = o.fits || "any";
if (fits !== "any" && fits !== spec) continue;
// "Best" is the most it is ever worth, which keeps the choice stable across
// bands — a scope does not stop being the scope you fitted at close range.
const worth = Math.max(...Object.values(sightFor(kind).bands));
if (worth > best.worth) best = { id: kind, source: it.name, worth };
}
return { id: best.id, source: best.source };
}
/** Is this agent carrying something lit? Lamps are gear, not a checkbox. */
export function carriedLight(actor) {
let best = { on: false, source: "", reachM: 0 };
for (const it of actor?.items ?? []) {
if (it.system?.carried === false) continue;
if (!it.system?.lightSource) continue;
const reachM = lampReachFor(it.system.lightReachM);
// Two lamps is not twice the light, but the longer one is the one you are using.
if (reachM > best.reachM) best = { on: true, source: it.name, reachM };
}
return best;
}
/** Metres between two tokens, in the scene's own units. Null when it cannot be known. */
function metresBetween(a, b) {
if (!a || !b || !canvas?.ready || !canvas.grid?.size) return null;
const dx = a.center.x - b.center.x, dy = a.center.y - b.center.y;
const px = Math.hypot(dx, dy);
const d = (px / canvas.grid.size) * (canvas.grid.distance || 1);
return Number.isFinite(d) ? d : null;
}
function tokenFor(actor) {
if (!actor) return null;
const t = actor.getActiveTokens?.(true, false) ?? [];
return t[0] ?? actor.token?.object ?? null;
}
/**
* The light this agent is actually standing in — theirs or somebody else's.
*
* A lamp used to light exactly one person, and played out that put three agents
* inside the same pool shooting at −50% while the fourth, an arm's length away, shot
* at −10%. A lamp has a reach now, and anyone inside it is in the light.
*
* Measured from the tokens, because the canvas already knows who is standing where.
* When there are no tokens the distance is genuinely unknown, and an unknown distance
* is NOT lit — the dialog offers the GM the tick instead, rather than this quietly
* lighting a whole scene that nobody has placed.
*/
export function lightOn(actor) {
const own = carriedLight(actor);
if (own.on) return { ...own, own: true, distanceM: 0 };
const me = tokenFor(actor);
if (!me) return { on: false, source: "", reachM: 0, own: false, distanceM: null };
let best = null;
for (const t of canvas.tokens?.placeables ?? []) {
if (t === me || !t.actor) continue;
const lamp = carriedLight(t.actor);
if (!lamp.on) continue;
const d = metresBetween(me, t);
if (!lampCovers(d, lamp.reachM)) continue;
if (!best || d < best.distanceM) {
best = { on: true, source: lamp.name ?? lamp.source, reachM: lamp.reachM,
own: false, distanceM: d, holder: t.actor.name };
}
}
return best ?? { on: false, source: "", reachM: 0, own: false, distanceM: null };
}
/**
* Core skill roll. Everything in the system funnels through here so that the
* band arithmetic exists in exactly one place.
*/
export async function skillRoll({
actor, label, rating, difficulty = "average", situational = 0, printedBase = 0,
flavourLines = [], speaker = null, categoryId = "", skipCoherence = false,
skillItem = null, skillFamilyId = "", weapon = null
} = {}) {
// Gear bonuses are a rule, not a judgement call, so they sit outside the
// +-40% circumstance cap alongside encumbrance and coherence.
const _fam = skillFamilyId || skillItem?.system?.familyId || "";
// Internal callers — case phases, First Aid, Medicine, defences, the crossing —
// pass a family and no item, so the experience tick below could never fire for
// them: the skills the job most uses were the only ones that never improved.
// Resolve the item from the family when the caller did not hand one over.
if (!skillItem && actor && _fam) {
skillItem = actor.items.find(i => i.type === "skill" && i.system?.familyId === _fam) ?? null;
}
const _gear = gearBonusFor(actor, _fam);
if (_gear.value) {
situational = (Number(situational) || 0) + _gear.value;
flavourLines = [...flavourLines, game.i18n.format("RINGBRP.Flavour.Gear",
{ item: _gear.source, mod: _gear.value })];
}
// Coherence is applied HERE, at the single choke point, so that attacks,
// characteristic rolls, Luck, Idea and case phases are all covered. Applying it
// per-caller meant an Unmoored agent was somehow still a perfect shot.
// Wounds: a useless arm really does cost you the shot.
if (actor && categoryId) {
const wound = woundPenaltyFor(actor, categoryId);
if (wound) {
situational = (Number(situational) || 0) + wound;
flavourLines = [...flavourLines, game.i18n.format("RINGBRP.Flavour.Wounded", { mod: wound })];
}
}
// Encumbrance. Computed since R-23 and applied by nothing until now — Rakhi was
// 20 kg over her free carry, carrying a printed -10%, and rolling at full.
if (actor && categoryId) {
const enc = encumbrancePenaltyFor(actor.system?.encumbrance?.skillPenalty, categoryId);
if (enc) {
situational = (Number(situational) || 0) + enc;
flavourLines = [...flavourLines, game.i18n.format("RINGBRP.Flavour.Encumbered", { mod: enc })];
}
}
if (actor && !skipCoherence) {
const coh = coherencePenaltyFor(actor.system?.coherence?.value, categoryId);
if (coh) {
situational = (Number(situational) || 0) + coh;
flavourLines = [...flavourLines, game.i18n.format("RINGBRP.Flavour.Incoherent",
{ mod: coh, band: game.i18n.localize(coherenceBand(actor.system.coherence.value).label) })];
}
}
if (difficulty === "impossible") {
ui.notifications?.warn(game.i18n.localize("RINGBRP.Notify.Impossible"));
return null;
}
if (difficulty === "automatic") {
ui.notifications?.info(game.i18n.localize("RINGBRP.Notify.Automatic"));
return { roll: null, level: "success", automatic: true };
}
const bands = resolveBands(rating, { difficulty, situational, printedBase });
const roll = await new Roll("1d100").evaluate();
const level = gradeRoll(roll.total, bands);
const content = await rbRenderTemplate("systems/ringbrp/templates/chat-roll.hbs",
{
label,
rating,
difficulty,
difficultyLabel: game.i18n.localize(RINGBRP.difficulties[difficulty] ?? ""),
situational,
bands,
roll: roll.total,
level,
levelLabel: game.i18n.localize(LEVEL_LABEL[level]),
faces: percentileFaces(roll.total),
flavourLines,
// A hit offers its own damage roll. The roll used to end at "Success" and
// leave the player to find the weapon on the sheet and click it separately,
// which also lost the success level — so criticals were rolled as ordinary hits.
damage: (weapon && ["critical", "special", "success"].includes(level))
? { label: weapon.system?.damage?.mode === "byBand"
? game.i18n.localize("RINGBRP.Chat.DamageByBand")
: (weapon.system?.damage?.formula || "1d6"),
critical: level === "critical", special: level === "special" }
: null
}
);
rbDressDice(roll, actor);
await roll.toMessage({
speaker: speaker ?? ChatMessage.getSpeaker({ actor }),
content,
flags: { ringbrp: { level, bands, label,
weaponId: weapon?.id ?? null, actorId: actor?.id ?? null,
tokenId: actor?.token?.id ?? null } }
}, { rollMode: game.settings.get("core", "rollMode") });
// BRP experience: a skill used SUCCESSFULLY under pressure earns a tick. This
// was stored on every skill and set by nothing, so ticks never appeared and the
// end-of-case improvement roll had nothing to work on.
if (skillItem && ["critical", "special", "success"].includes(level)
&& skillItem.system?.experienceEligible !== false && !skillItem.system?.experienceTicked) {
await skillItem.update({ "system.experienceTicked": true });
}
// F5: Coherence is charged by the system, not remembered by the GM. A fumble
// while the team is across is the commonest cost in play, so it fires here.
if (level === "fumble" && actor && isAcross()) {
await loseCoherence(actor, "fumbleOnCrossing", { note: label });
}
return { roll: roll.total, level, bands };
}
/** Spec §3.3. Rolls the Major Wound consequence and applies weapon shifts. */
export async function majorWoundRoll({ actor, woundType = "blunt", monofilament = false, blunt = false } = {}) {
// Called positionally by mistake, this rolled and applied a Major Wound to
// nobody. Same class of fault as R-209: fail loudly rather than quietly.
if (!actor) {
console.error("Custodians | majorWoundRoll called without an actor");
return null;
}
const r = rbDressDice(await new Roll("1d10").evaluate(), actor);
let result = r.total;
if (monofilament && result >= 4) result = 10; // spec §3.3 shift
else if (blunt && result === 10) result = 9;
const entry = RINGBRP.majorWoundTable.find(e => result >= e.min && result <= e.max);
const key = entry?.key ?? "concussed";
const lostStat = RINGBRP.woundTypeMap[woundType] ?? "con";
// Two rows of the table split 1D2. That roll used to be left to the GM's memory
// because nothing consumed it; now the penalty depends on it, so it is rolled
// here and stored on the wound.
let variant = null;
if (key === "sensory" || key === "broken") {
const v = await new Roll("1d2").evaluate();
variant = key === "sensory" ? (v.total === 1 ? "sight" : "hearing")
: (v.total === 1 ? "manipulation" : "mobility");
}
const content = await rbRenderTemplate("systems/ringbrp/templates/chat-wound.hbs",
{
raw: r.total,
result,
shifted: result !== r.total,
key,
title: game.i18n.localize(`RINGBRP.Wound.${key}.title`),
text: game.i18n.localize(`RINGBRP.Wound.${key}.text`),
variant,
variantLabel: variant ? game.i18n.localize(`RINGBRP.Wound.variant.${variant}`) : "",
losesCharacteristic: ["crippling", "catastrophic"].includes(key),
lostStat: game.i18n.localize(`RINGBRP.Char.${lostStat}`),
lostAmount: key === "catastrophic" ? "1d6" : "1d3"
}
);
await r.toMessage({ speaker: ChatMessage.getSpeaker({ actor }), content });
if (actor && ["crippling", "catastrophic", "broken", "bleeding", "deepBleeding",
"manipulation", "mobility", "sensory", "concussed", "winded"].includes(key)) {
const wounds = foundry.utils.duplicate(actor.system.wounds ?? []);
wounds.push({ key, variant, stat: lostStat, staminaChecks: 0, id: foundry.utils.randomID() });
await actor.update({ "system.wounds": wounds });
}
return { result, key, variant };
}
/**
* Spec §3.6. Roll 1D20 for a location on the target's species table and
* apply the damage there. Reports disabled/destroyed and the kind-specific
* consequence, which is why a Cadence losing a body is not a Vesh losing its grasp limb.
*/
export async function hitLocationRoll({ target, damage, mode = "ranged",
level = "success", armourApplied = false } = {}) {
if (!target) return null;
// R-209. `Number(undefined) || 0` meant a caller who passed a bad damage value
// got a hit that landed, printed a card, and took nothing off — a six-round
// fight once ran with fourteen connecting blows and no casualties, and nothing
// anywhere said a word. Damage must be a real number or this is a bug upstream.
if (!Number.isFinite(Number(damage))) {
console.error("Custodians | hitLocationRoll got non-numeric damage:", damage);
ui.notifications?.error(game.i18n.localize("RINGBRP.Notify.BadDamage"));
return null;
}
const sys = target.system;
const speciesId = sys.speciesProfile || "baseline";
const r = rbDressDice(await new Roll("1d20").evaluate(), target);
const loc = locationFor(r.total, speciesId, mode);
const live = (sys.locations ?? []).find(l => l.id === loc.id);
const max = live?.max ?? locationMaxHp(sys.hp?.max ?? 1, loc.frac);
// Armour was applied on the resolveHit path and skipped entirely here, so the
// same blow did more damage when it went through the location roll. Location
// armour stacks on the body's, and a critical still bypasses (armourAgainst).
let effective = Number(damage) || 0;
if (!armourApplied) {
// The location already knows its own total — the body's armour there plus every
// worn piece that reaches it. Adding sys.armourPoints on top would count the
// torso's armour a second time on every limb now that coverage exists.
const locArmour = live
? Number(live.armour) || 0
: locationArmourFor((Number(loc.armour) || 0) + (Number(sys.naturalArmour?.value) || 0),
sys.wornArmour ?? [], loc.kind);
effective = Math.max(0, effective - armourAgainst(level, locArmour));
}
const res = resolveLocationHit({
damage: effective, locationMax: max, locationTaken: live?.taken ?? 0,
majorWoundThreshold: sys.majorWound?.value
});
const effects = RINGBRP.locationEffects[loc.kind] ?? RINGBRP.locationEffects.body;
const effectKey = res.destroyed ? effects.destroyed : (res.disabled ? effects.disabled : null);
const content = await rbRenderTemplate("systems/ringbrp/templates/chat-location.hbs", {
roll: r.total, mode,
locationLabel: game.i18n.localize(loc.label),
speciesLabel: game.i18n.localize(RINGBRP.locationTables[speciesId]?.label ?? ""),
damage: res.damage, max, taken: res.locationTaken,
disabled: res.disabled, destroyed: res.destroyed, majorWound: res.majorWound,
effect: effectKey ? game.i18n.localize(effectKey) : null
});
await r.toMessage({ speaker: ChatMessage.getSpeaker({ actor: target }), content });
const dmg = foundry.utils.duplicate(sys.locationDamage ?? {});
dmg[loc.id] = res.locationTaken;
const upd = {
"system.locationDamage": dmg,
"system.hp.value": Math.max(0, (sys.hp?.value ?? 0) - res.damage)
};
// R-224. Hit points clamp at zero, so a body already at zero absorbed further
// damage for free — and a STABILISED agent absorbed it forever, because the count
// was held and there was nothing else left to move. You could empty a magazine
// into someone bleeding out on the floor and the system would not notice. Damage
// to somebody at zero breaks the stabilisation and costs them a round of the
// count they have left.
const wasAtZero = (Number(sys.hp?.value) || 0) <= 0;
const hurtAgain = res.damage > 0;
if (wasAtZero && hurtAgain && !sys.dying?.dead) {
upd["system.dying.stabilised"] = false;
upd["system.dying.rounds"] = (Number(sys.dying?.rounds) || 0) + 1;
}
await target.update(upd);
if (upd["system.dying.rounds"] !== undefined
&& upd["system.dying.rounds"] >= dyingLimitFor(sys.majorWound?.value)) {
await died(target);
return { location: loc, ...res };
}
// Destroying something they cannot live without. The printed effect for a
// destroyed head has always said this is death; now it is.
const outcome = destructionOutcomeFor(loc.kind, res.destroyed);
if (outcome === "dead") {
await ChatMessage.create({
speaker: ChatMessage.getSpeaker({ actor: target }),
content: `<div class="ringbrp-chat rb-dead"><strong>${
game.i18n.format("RINGBRP.Death.LocationLethal", {
name: target.name,
location: game.i18n.localize(loc.label) })}</strong></div>`
});
await died(target);
} else if (outcome === "dying" && (Number(target.system.hp?.value) || 0) > 0) {
await target.update({ "system.hp.value": 0 });
await ChatMessage.create({
speaker: ChatMessage.getSpeaker({ actor: target }),
content: `<div class="ringbrp-chat rb-dying"><strong>${
game.i18n.format("RINGBRP.Death.LocationVital", {
name: target.name,
location: game.i18n.localize(loc.label) })}</strong></div>`
});
}
return { location: loc, ...res, destruction: outcome };
}
/* -------------------------------------------- */
/* Reaction — rolling and ordering */
/* -------------------------------------------- */
/**
* Score a combatant: current base + d4, with the die read through Coherence Flux.
* Modifiers live on the combatant as flags so they survive a reload and so the
* GM can set them once and leave them.
*/
export async function rollReaction(combatant) {
const actor = combatant?.actor;
if (!actor) return null;
const f = combatant.flags?.ringbrp ?? {};
const rb = reactionBase(actor);
// Load is already inside rb.printed. An override replaces it rather than
// stacking, so the GM can wave off a pack without the kit being charged twice.
const load = f.loadOverride ?? rb.load;
const printed = f.loadOverride === undefined
? rb.printed
: reactionPrintedFrom(rb.base, f.loadOverride);
const mod = reactionModifier({ aim: f.aim ?? 0, edge: !!f.edge, drag: !!f.drag });
const roll = rbDressDice(await new Roll(`1d${RINGBRP.reaction.die}`).evaluate(), actor);
const raw = roll.total;
const coherence = actor.system?.coherence?.value ?? 10;
const shown = fluxDie(raw, coherence);
const misfiled = shown !== raw;
// The die is surfaced by the pooled Roll on the chat card, which animates every
// combatant's dice together. Awaiting one animation per combatant here made
// rolling for a group crawl, so it is deliberately not done at this level.
return {
roll,
total: Math.round((printed + mod + shown) * 10) / 10,
base: rb.base, printed, mod, die: shown, rawDie: raw, misfiled,
lane: rb.lane, from: rb.from, load, aim: f.aim ?? 0,
edge: !!f.edge, drag: !!f.drag, coherence
};
}
class RingBRPCombat extends Combat {
/**
* Highest acts first, which is Foundry's default direction — the old system
* needed an inverted comparator and this one does not. Ties: allies choose
* between themselves, opposed combatants are simultaneous, so the fallback is
* only for a stable display order.
*/
_sortCombatants(a, b) {
const x = Number(a.initiative), y = Number(b.initiative);
const ax = Number.isFinite(x), by = Number.isFinite(y);
if (ax && by && x !== y) return y - x;
if (ax !== by) return ax ? -1 : 1;
const dex = c => Number(c.actor?.system?.characteristics?.dex?.value) || 0;
if (dex(a) !== dex(b)) return dex(b) - dex(a);
const list = a.parent?.combatants?.contents ?? b.parent?.combatants?.contents;
return list ? list.indexOf(a) - list.indexOf(b) : 0;
}
/**
* R-215. Reaction is re-rolled every round — except it never rolled for the
* FIRST one. Foundry does not roll initiative when a combat starts; it waits for
* the GM to press Roll All. So round one ran with every initiative null and the
* comparator falling through to its DEX tiebreak, which meant the opening round
* of every fight — the one where the shooting starts — was the single round the
* Reaction system did not order. Roll it as the fight begins.
*/
async startCombat() {
await super.startCombat();
const active = this.combatants.filter(c => {
const a = c.actor; if (!a) return false;
return (Number(a.system?.hp?.value) || 0) > 0 && !a.system?.wounded?.unconscious;
}).map(c => c.id);
if (active.length) await this.rollInitiative(active);
return this;
}
/** One d4 per combatant, one addition. No per-weapon arithmetic. */
async rollInitiative(ids, { updateTurn = true, messageOptions = {} } = {}) {
ids = typeof ids === "string" ? [ids] : ids;
const updates = [];
const lines = [];
for (const id of ids) {
const c = this.combatants.get(id);
if (!c) continue;
const r = await rollReaction(c);
if (!r) continue;
updates.push({ _id: id, initiative: r.total });
await c.update({ "flags.ringbrp.reaction": r });
lines.push({ name: c.name, ...r });
}
if (updates.length) await this.updateEmbeddedDocuments("Combatant", updates);
if (lines.length) {
// A pooled Roll so the dice are real objects in the message: Dice So Nice
// animates them and the chat card shows the faces.
const pool = Roll.fromTerms([
foundry.dice.terms.PoolTerm.fromRolls(lines.map(l => l.roll).filter(Boolean))
]);
await ChatMessage.create({
speaker: { alias: game.i18n.localize("RINGBRP.React.Title") },
content: await rbRenderTemplate("systems/ringbrp/templates/chat-reaction.hbs",
{ lines: lines.sort((a, b) => b.total - a.total), die: RINGBRP.reaction.die }),
rolls: [pool], sound: CONFIG.sounds.dice
});
}
if (updateTurn && this.turns?.length) await this.update({ turn: 0 });
return this;
}
/**
* Reaction is re-rolled EVERY round. Foundry keeps initiative across rounds by
* default, which drew the d6 once per fight and froze the order — and that made
* the whole system inert: the Aim rail could not matter, because "aim one round,
* fire the next" needs the order to recompute; the 35.7% random share was tuned
* as round-to-round variance and became a single draw deciding the engagement;
* and load, wounds and coherence all change mid-fight and were never re-read.
*
* The re-roll happens AFTER super.nextRound() so that end-of-round blood loss has
* already been charged — an agent who bleeds out does not get a fresh initiative.
*/
async nextRound() {
await super.nextRound();
if (!game.user.isGM || this.round <= 1) return this;
await this.resetAll();
// Only those still in the fight are re-rolled. The downed keep a null
// initiative, which the comparator already sorts to the bottom, so a corpse
// stops drawing a d6 every round and stops appearing above the living.
const active = this.combatants.filter(c => {
const a = c.actor; if (!a) return false;
return (Number(a.system?.hp?.value) || 0) > 0 && !a.system?.wounded?.unconscious;
}).map(c => c.id);
if (active.length) await this.rollInitiative(active);
// Aim is spent. It buys position for exactly one round — the round after the
// one you spent aiming — so it is consumed by the roll it paid for rather than
// granting +2 for the rest of the fight. Edge and Drag persist: they describe
// equipment and conditions, which do not evaporate at the round boundary.
const spent = this.combatants.filter(c => c.flags?.ringbrp?.aim);
for (const c of spent) await c.update({ "flags.ringbrp.aim": 0 });
return this;
}
}
/**
* Set a combatant's Reaction modifiers. This is the digital equivalent of moving
* the paperclip along the printed rail: Aim, Edge, Drag and Load, capped at +-3.
*/
export async function setReaction(combatant) {
const actor = combatant?.actor;
if (!actor) return;
const f = combatant.flags?.ringbrp ?? {};
const rb = reactionBase(actor);
const load = f.loadOverride ?? rb.load;
const printed = f.loadOverride === undefined
? rb.printed
: reactionPrintedFrom(rb.base, f.loadOverride);
const content = await rbRenderTemplate("systems/ringbrp/templates/dialog-reaction.hbs", {
name: combatant.name, base: rb.base, printed, lane: rb.lane, from: rb.from,
aim: f.aim ?? 0, edge: !!f.edge, drag: !!f.drag, load,
coherence: actor.system?.coherence?.value ?? 10,
rail: Array.from({ length: 7 }, (_, i) => ({
mod: i - 3, value: Math.round((printed + (i - 3)) * 10) / 10
}))
});
return new Promise(resolve => {
new (rbDialogClass())({
title: game.i18n.format("RINGBRP.React.SetFor", { name: combatant.name }),
content,
buttons: {
ok: {
label: game.i18n.localize("RINGBRP.React.Set"),
callback: async html => {
const el = html[0] ?? html;
await combatant.update({
"flags.ringbrp.aim": Number(el.querySelector("[name=aim]").value) || 0,
"flags.ringbrp.edge": el.querySelector("[name=edge]").checked,
"flags.ringbrp.drag": el.querySelector("[name=drag]").checked
});
if (combatant.combat) await combatant.combat.rollInitiative([combatant.id], { updateTurn: false });
resolve(true);
}
},
cancel: { label: game.i18n.localize("RINGBRP.Dialog.Cancel"), callback: () => resolve(false) }
},
default: "ok", close: () => resolve(false)
}).render(true);
});
}
/* -------------------------------------------- */
/* Coherence — the agent's grip on their own reality */
/* -------------------------------------------- */
/**
* An agent who works the crossing does not come back unchanged. Coherence measures
* how firmly they are still bound to the reality they were born in. It falls when
* they break the rules of a place they do not belong in, and it comes back slowly,
* at home, with people who remember them.
*
* Ten is anchored. Zero is displaced, and displacement is permanent.
*/
RINGBRP.coherenceBands = COHERENCE_BANDS;
/**
* What costs coherence. These are the things that mark an agent as not-from-here.
* A cost is charged once per occurrence; the GM is the arbiter of occurrence.
*/
RINGBRP.coherenceCosts = COHERENCE_COSTS;
/** Recovery only happens at home, and never faster than one step per debrief. */
/**
* F6: the case-file tracks. The stored field names are the original ones so that
* existing actors keep working, but everything user-facing reads as a case.
*/
RINGBRP.caseTracks = {
supply: { label: "RINGBRP.Case.Resources", key: "resources" },
power: { label: "RINGBRP.Case.Clearance", key: "clearance" },
integrity: { label: "RINGBRP.Case.Containment", key: "containment" }
};
RINGBRP.coherenceRecovery = COHERENCE_RECOVERY;
export const coherenceBand = coherenceBandFrom;
/** The penalty coherence imposes on a given skill category. */
/** What a wounded body costs a given skill category. */
/**
* The whole wound penalty for one skill category. `all` comes from the Major Wound
* table (concussion is "-20% to all rolls") and applies everywhere; the specific
* numbers add on top of it. Ranged and melee read the manipulation figure because
* both are done with the hands, which is how the location wounds already worked.
*/
export function woundPenaltyFor(actor, categoryId) {
const w = actor?.system?.wounded;
if (!w) return 0;
let pen = Number(w.all) || 0;
if (["manip", "melee", "ranged"].includes(categoryId)) pen += Number(w.manipulation) || 0;
if (categoryId === "phys") pen += Number(w.physical) || 0;
if (categoryId === "percep") pen += Number(w.perception) || 0;
if (categoryId === "comm") pen += Number(w.communication) || 0;
return pen;
}
export function coherencePenaltyFor(value, categoryId) {
const b = coherenceBand(value);
return categoryId === "comm" ? b.comm : b.all;
}
/**
* Charge a coherence cost and announce it. Returns the new band so a caller can
* react when an agent crosses a threshold.
*/
export async function loseCoherence(actor, reasonKey, { amount = null, note = "" } = {}) {
if (!actor) return null;
const spec = RINGBRP.coherenceCosts[reasonKey];
const cost = amount ?? spec?.cost ?? 1;
const before = Number(actor.system.coherence?.value ?? 10);
const after = Math.max(0, before - cost);
const drift = [...(actor.system.coherence?.drift ?? []),
{ reason: reasonKey, cost, note, at: actor.system.coherence?.drift?.length ?? 0 }];
await actor.update({ "system.coherence.value": after, "system.coherence.drift": drift });
const bBefore = coherenceBand(before), bAfter = coherenceBand(after);
if (after === 0 && before > 0) { await displace(actor); }
await ChatMessage.create({
speaker: ChatMessage.getSpeaker({ actor }),
content: await rbRenderTemplate("systems/ringbrp/templates/chat-coherence.hbs", {
actorName: actor.name, before, after, cost,
// The cost is already applied by this point, so anything that throws here loses
// the announcement and leaves the actor quietly poorer. localize() throws on a
// non-string, which an unrecognised reason will be.
reason: game.i18n.localize(String(spec?.label ?? reasonKey ?? "")), note,
band: bAfter, crossed: bBefore.id !== bAfter.id,
pips: Array.from({ length: Number(actor.system.coherence?.max ?? 10) }, (_, i) => i < after)
})
});
return bAfter;
}
/**
* Displacement. Reaching zero is not a slow slide to be nursed back; it is an
* event. The character does not die — they go home to an Earth that is almost
* right, one true thing changed, and that becomes their new baseline.
* Recovery from zero is therefore impossible: you displace instead.
*/
export async function displace(actor) {
if (!actor) return null;
const c = actor.system.coherence ?? {};
const max = Number(c.max ?? 10);
const record = {
displacedOn: (Number(c.displacements) || 0) + 1,
drift: [...(c.drift ?? [])]
};
await actor.update({
"system.coherence.value": max,
"system.coherence.drift": [],
"system.coherence.displacements": record.displacedOn,
"system.coherence.history": [...(c.history ?? []), record]
});
await ChatMessage.create({
speaker: ChatMessage.getSpeaker({ actor }),
content: `<div class="ringbrp-chat rb-coherence rb-coh-displaced">
<h3 class="rb-chat-title">${game.i18n.localize("RINGBRP.Coh.Displaced")}</h3>
<p>${game.i18n.format("RINGBRP.Coh.DisplacedChat", { name: actor.name })}</p>
<p class="rb-coh-crossed">${game.i18n.localize("RINGBRP.Coh.DisplacedGM")}</p>
</div>`,
whisper: ChatMessage.getWhisperRecipients("GM").map(u => u.id)
});
return coherenceBand(max);
}
/** Recovery happens at home, and never from zero — zero displaces instead. */
export async function regainCoherence(actor, amount = 1) {
if (!actor) return null;
const cur = Number(actor.system.coherence?.value) || 0;
if (cur <= 0) {
ui.notifications?.warn(game.i18n.localize("RINGBRP.Notify.CannotRecoverFromZero"));
return coherenceBand(0);
}
const max = Number(actor.system.coherence?.max ?? 10);
const after = Math.min(max, cur + amount);
await actor.update({ "system.coherence.value": after });
return coherenceBand(after);
}
/**
* Resolve a case file's members to world actors. `members` has been three shapes
* over this project's life — objects, raw ids, and now stable keys — and raw ids
* do not survive a compendium import at all, which made the shipped case file
* look populated while resolving to nothing. Accept all three, prefer the key.
*/
export function caseMembers(caseFile) {
const raw = caseFile?.system?.members ?? [];
const out = [];
for (const entry of raw) {
const key = typeof entry === "string" ? entry : (entry?.key ?? entry?.actorId);
if (!key) continue;
const found = game.actors.get(key)
?? game.actors.find(a => a.getFlag?.("ringbrp", "key") === key)
?? game.actors.find(a => a.name === key);
if (found) out.push(found);
}
return out;
}
/**
* Is the team currently across? Coherence costs only apply on the far side, so
* the system needs to know. Held on the active case file so one GM toggle covers
* the whole team rather than each agent tracking it separately.
*/
export function activeCaseFile() {
return game.actors?.find(a => a.type === "expedition" && a.system?.across !== undefined
&& a.getFlag?.("ringbrp", "active"))
?? game.actors?.find(a => a.type === "expedition");
}
export function isAcross() { return !!activeCaseFile()?.system?.across; }
export async function setAcross(state) {
const cf = activeCaseFile();
if (!cf) return null;
await cf.update({ "system.across": !!state });
ui.notifications?.info(game.i18n.localize(state ? "RINGBRP.Case.NowAcross" : "RINGBRP.Case.NowHome"));
return !!state;
}
/**
* F5: charge the Coherence cost printed on an item. Every piece of transposed
* materiel carries one, and this is what makes "the tool that solves the case is
* the tool that costs you" actually true in play rather than in the bible.
*/
export async function useTransposed(actor, item) {
let cost = Number(item?.system?.coherenceCost ?? item?.system?.coherence) || 0;
if (!actor || !cost) return null;
// Chain of Custody: HALVED, rounding up, never to zero. A flat -1 made every
// 1-cost item free and removed the setting's central tension for that agent;
// halving keeps cheap things costing and makes expensive things bearable.
if (hasTalent(actor, "chain_custody")) {
const before = cost;
cost = Math.max(1, Math.ceil(cost / 2));
if (cost < before) ui.notifications?.info(
game.i18n.format("RINGBRP.Talent.Halved", { name: item.name, from: before, to: cost }));
}
return loseCoherence(actor, "usedTransposed", { amount: cost, note: item.name });
}
/**
* F5: debrief. Recovery is only available at home and only once per debrief, so
* it is a single operation rather than three fiddly ones.
*/
export async function debrief(actor, { downtimeWeeks = 0, withAnchor = false } = {}) {
if (!actor) return null;
if (isAcross()) {
ui.notifications?.warn(game.i18n.localize("RINGBRP.Notify.DebriefAcross"));
return null;
}
// Debriefer: someone else on the team gets you an extra point back.
const debriefer = (activeCaseFile() ? caseMembers(activeCaseFile()) : [])
.find(a => a.id !== actor.id && hasTalent(a, "debriefer"));
let gain = RINGBRP.coherenceRecovery.debrief + (debriefer ? 1 : 0);
gain += Math.max(0, Number(downtimeWeeks) || 0) * RINGBRP.coherenceRecovery.downtimeWeek;
if (withAnchor && actor.items.some(i => i.system?.isAnchor || /anchor/i.test(i.name))) {
gain += RINGBRP.coherenceRecovery.anchorObject;
}
return regainCoherence(actor, gain);
}
/**
* R-56, the critical-path invariant: nothing on the critical path may be made
* unreachable by a result. An Unmoored agent used to be told they "cannot open a
* path home unaided", which is a gate whenever no other agent is available.
*
* The way home is therefore ALWAYS available. Being Unmoored makes it dearer, not
* impossible: another agent may aim the return for you at no penalty, and alone
* you may still aim it at -30%. A failed return does not strand you either — it
* lands you near your target rather than at it, which is a complication the GM
* narrates, never a dead end.
*/
export function returnCrossing(actor, { aimedByAnother = false } = {}) {
const band = coherenceBand(actor?.system?.coherence?.value);
// The classification's own penalty. A generated brief PRINTS this as a mechanical
// promise — "return modifier for the classification: -20%" — and nothing applied
// it, so a Wrong world was no harder to leave than a Near one. Aiming for someone
// else removes the Coherence penalty; it does not make the world less strange.
const cf = activeCaseFile();
const world = Number(cf?.system?.crossingModifier) || 0;
// How badly the extraction went. The generator has always written a "window
// closes" clock costing THE WAY HOME and nothing ever read it; this is it.
const clock = Number(cf?.system?.journey?.returnClock) || 0;
const late = returnPenaltyFor(clock);
const penalty = (aimedByAnother ? 0 : (band.returnPenalty ?? 0)) + world + late;
return {
possible: true, // always. This is the invariant.
penalty, worldPenalty: world,
clock, latePenalty: late, difficult: returnIsDifficult(clock),
aimedByAnother,
band,
note: aimedByAnother ? "RINGBRP.Coh.ReturnAided"
: penalty ? "RINGBRP.Coh.ReturnAlone" : "RINGBRP.Coh.ReturnClean",
onFailure: "RINGBRP.Coh.ReturnNear"
};
}
/**
* The device gate, enforced. Codex flagged twice that `requiredDiscoveryIds` was
* read by nothing, so a locked function could be operated on turn one — and that
* the discovery item ships WITH the case file, so its presence cannot mean it has
* been earned. Both are fixed here: a discovery counts only when `earned`.
*/
export function earnedDiscoveries(scope) {
const out = new Set();
for (const actor of [scope, ...(game.actors ?? [])].filter(Boolean)) {
if (actor.type !== "expedition") continue;
for (const it of actor.items ?? []) {
if (it.type === "discovery" && it.system?.earned) out.add(it.system.discoveryId);
}
}
return out;
}
/** Why a device function cannot be used right now, or null if it can. */
export function deviceFunctionStatus(device, functionId) {
const f = device?.system?.functions?.[functionId];
if (!f) return { usable: false, reason: "RINGBRP.Device.NoSuchFunction" };
const earned = earnedDiscoveries();
const missing = (f.requiredDiscoveryIds ?? []).filter(id => !earned.has(id));
if (missing.length) return { usable: false, reason: "RINGBRP.Device.Locked", missing };
const integ = Number(device.system?.integrity?.value) || 0;
if (integ < (Number(f.requiresIntegrity) || 0))
return { usable: false, reason: "RINGBRP.Device.Damaged",
need: f.requiresIntegrity, have: integ };
const cf = activeCaseFile();
const power = Number(cf?.system?.power?.value ?? 0);
if ((Number(f.powerCost) || 0) > power)
return { usable: false, reason: "RINGBRP.Device.Unpowered", need: f.powerCost, have: power };
return { usable: true, reason: "RINGBRP.Device.Ready" };
}
/**
* Resolve a device function: check the gate, roll the governing skill, charge the
* Clearance and Coherence it costs, and apply the outcome. This is the operation
* the whole case turns on and it existed only as a status check until now.
*/
export async function useDeviceFunction(actor, device, functionId) {
const f = device?.system?.functions?.[functionId];
if (!f) return null;
const st = deviceFunctionStatus(device, functionId);
if (!st.usable) {
ui.notifications?.warn(game.i18n.format(st.reason, { need: st.need, have: st.have }));
return null;
}
const cf = activeCaseFile();
const earned = earnedDiscoveries();
const post = (f.requiredDiscoveryIds ?? []).every(id => earned.has(id))
&& (f.requiredDiscoveryIds ?? []).length > 0;
const rating = actor.skillRating(f.skillFamilyId, f.skillSpecialisationId) ?? 0;
const familiarity = post ? (Number(f.familiarityAfterDiscovery) || 0) : 0;
const difficulty = (post && f.difficultyAfterDiscovery) ? f.difficultyAfterDiscovery : f.difficulty;
const res = await skillRoll({
actor, label: `${device.name} — ${game.i18n.localize(`RINGBRP.Device.Fn.${functionId}`)}`,
rating, printedBase: 15, difficulty,
situational: familiarity,
skillFamilyId: f.skillFamilyId,
flavourLines: familiarity ? [game.i18n.format("RINGBRP.Device.Familiarity", { mod: familiarity })] : []
});
if (!res) return null;
const ok = ["critical", "special", "success"].includes(res.level);
// Clearance: the printed cost on success, the failure cost otherwise.
// `!= null` deliberately, not `!== undefined`: the data model fills absent keys
// with null, so an undefined-check silently read a real cost of 4 as 0.
const cost = ok ? (post && f.powerCostAfterDiscovery != null
? Number(f.powerCostAfterDiscovery) : Number(f.powerCost) || 0)
: (res.level === "fumble"
? Number(f.powerCostOnFumble ?? f.powerCostOnFailure) || 0
: Number(f.powerCostOnFailure) || 0);
if (cf && cost) {
await cf.update({ "system.power.value": Math.max(0, (cf.system.power.value || 0) - cost) });
}
if (Number(f.coherenceCost)) {
await loseCoherence(actor, "usedTransposed", { amount: Number(f.coherenceCost), note: device.name });
}
// Repair moves Integrity; a fumble takes one back.
if (functionId === "repair") {
const cur = Number(device.system.integrity.value) || 0;
const max = Number(device.system.integrity.max) || 3;
const next = res.level === "fumble" ? Math.max(0, cur - 1) : (ok ? Math.min(max, cur + 1) : cur);
if (next !== cur) await device.update({ "system.integrity.value": next });
}
const key = ok ? "effect" : (res.level === "fumble" ? "onFumble" : "onFailure");
await ChatMessage.create({
speaker: ChatMessage.getSpeaker({ actor }),
content: `<div class="ringbrp-chat"><h3 class="rb-chat-title">${device.name}</h3>
<p>${f[key] ?? ""}</p>${cost ? `<p class="rb-chat-flavour">${
game.i18n.format("RINGBRP.Device.Spent", { n: cost })}</p>` : ""}</div>`
});
return { ...res, cost };
}
/** Mark a discovery earned. This is the moment the case turns. */
export async function earnDiscovery(caseFile, discoveryId) {
const it = caseFile?.items?.find(i => i.type === "discovery" && i.system.discoveryId === discoveryId);
if (!it) return null;
await it.update({ "system.earned": true });
await ChatMessage.create({
speaker: ChatMessage.getSpeaker({ actor: caseFile }),
content: `<div class="ringbrp-chat"><h3 class="rb-chat-title">${
game.i18n.localize("RINGBRP.Device.DiscoveryEarned")}</h3><p>${it.name}</p></div>`
});
return it;
}
/* -------------------------------------------- */
/* First contact */
/* -------------------------------------------- */
/**
* The social pillar had data and no engine: standings, impression, taboos and
* desires were all stored and nothing could ever change them. That also stranded
* the R-57 privacy fix, which hides a group's secrets until the team has LEARNED
* them — with no way to learn, players would have seen "nothing learned" forever.
*
* Three stages, in order. READ discovers what the group is; SIGNAL establishes that
* you are talking at all; OFFER is the gift. Only an OFFER can IMPROVE standing — but
* a fumble at any stage worsens it, because insulting somebody is not a thing you get
* to do for free (R-246).
*/
RINGBRP.contactStages = {
read: { label: "RINGBRP.Contact.Read", skills: ["insight", "anthropology", "xenology"] },
signal: { label: "RINGBRP.Contact.Signal", skills: ["language", "persuade", "fast_talk"] },
offer: { label: "RINGBRP.Contact.Offer", skills: ["bargain", "persuade", "insight"] }
};
// Not a copy — rules.mjs owns the table. An OFFER raises standing; a fumble at any
// stage lowers it; READ's number is FACTS LEARNED rather than standing, which is why
// resolveContact asks impressionDeltaFor rather than applying the raw number.
RINGBRP.contactOutcomes = CONTACT_OUTCOMES;
function standingFor(caseFile, groupId) {
return (caseFile.system.standings ?? []).find(s => s.groupId === groupId) ?? null;
}
/**
* A group you have never met has no standing entry, and `standingFor` returned null,
* and `resolveContact` bailed out on null — so FIRST contact with a new group was
* the one thing first contact could not do. The entry is created on demand.
*/
async function ensureStanding(caseFile, groupId) {
const found = standingFor(caseFile, groupId);
if (found) return found;
const fresh = { groupId, value: 0, impression: 0, knownTaboos: [], knownDesires: [],
pidginEstablished: false, offerMadeThisEncounter: false };
await caseFile.update({ "system.standings": [...(caseFile.system.standings ?? []), fresh] });
return standingFor(caseFile, groupId);
}
/**
* Who talks? The GM chooses, but the list is ordered by who is actually best at
* this stage and shows the rating, so the obvious choice is the top one and the
* interesting choice is still available.
*/
async function pickContactActor(caseFile, stage) {
const spec = RINGBRP.contactStages[stage];
const roster = caseMembers(caseFile);
if (!roster.length) {
ui.notifications?.warn(game.i18n.localize("RINGBRP.Notify.NoNavigator"));
return null;
}
const rated = roster.map(a => {
let best = 0, fam = "";
for (const f of spec.skills) {
const r = a.skillRating(f);
if (r !== null && r > best) { best = r; fam = f; }
}
return { actor: a, rating: best, fam };
}).sort((x, y) => y.rating - x.rating);
const content = `<form class="rb-dialog"><div class="form-group">
<label>${game.i18n.localize("RINGBRP.Contact.Who")}</label>
<select name="who">${rated.map((r, i) =>
`<option value="${r.actor.id}" ${i === 0 ? "selected" : ""}>${r.actor.name} — ${r.rating}%${r.fam ? ` (${r.fam})` : ""}</option>`).join("")}
</select></div>
<p class="notes">${game.i18n.localize(spec.label)}</p></form>`;
return new Promise(resolve => {
new (rbDialogClass())({
title: game.i18n.localize(spec.label),
content,
buttons: {
ok: { label: game.i18n.localize("RINGBRP.Dialog.RollButton"),
callback: html => {
const el = html[0] ?? html;
resolve(game.actors.get(el.querySelector("[name=who]").value) ?? null);
} },
cancel: { label: game.i18n.localize("RINGBRP.Dialog.Cancel"), callback: () => resolve(null) }
},
default: "ok", close: () => resolve(null)
}).render(true);
});
}
/**
* Resolve one stage of contact. Returns the roll result plus what the team
* learned, and writes both back to the case file.
*/
export async function resolveContact(caseFile, groupId, stage, actor, { circumstance = "neutral" } = {}) {
const spec = RINGBRP.contactStages[stage];
const group = caseFile.items.find(i => i.type === "group" && i.system.groupId === groupId);
if (!spec || !group) return null;
const st = await ensureStanding(caseFile, groupId);
if (!st) return null;
// best applicable skill the actor actually has
let rating = 0, used = "";
for (const fam of spec.skills) {
const r = actor.skillRating(fam);
if (r !== null && r > rating) { rating = r; used = fam; }
}
// R-44: one OFFER per encounter. Impression cannot be farmed.
if (stage === "offer" && st.offerMadeThisEncounter) {
ui.notifications?.warn(game.i18n.localize("RINGBRP.Contact.OfferSpent"));
return null;
}
const res = await skillRoll({
actor, label: game.i18n.format("RINGBRP.Contact.Roll",
{ stage: game.i18n.localize(spec.label), group: group.name }),
rating, printedBase: 5, categoryId: "comm",
situational: circumstanceTotal([circumstance])
});
if (!res) return null;
const delta = contactDeltaFor(stage, res.level);
const learned = [];
const standings = foundry.utils.deepClone(caseFile.system.standings ?? []);
const rec = standings.find(x => x.groupId === groupId);
// READ is how a team learns a taboo or a desire. One per success level.
if (stage === "read" && delta > 0) {
const taboos = group.system.taboos ?? [], desires = group.system.desires ?? [];
const unknownT = taboos.filter(t => !(rec.knownTaboos ?? []).includes(t.id));
const unknownD = desires.filter(d => !(rec.knownDesires ?? []).includes(d.id));
const extra = (stage === "read" && hasTalent(actor, "read_room")) ? 1 : 0;
for (let i = 0; i < delta + extra; i++) {
if (unknownT.length) { const t = unknownT.shift();
rec.knownTaboos = [...(rec.knownTaboos ?? []), t.id]; learned.push(t.text); }
else if (unknownD.length) { const d = unknownD.shift();
rec.knownDesires = [...(rec.knownDesires ?? []), d.id]; learned.push(d.text); }
}
}
if (stage === "signal" && delta > 0) rec.pidginEstablished = true;
if (stage === "offer") rec.offerMadeThisEncounter = true;
// How much this moves STANDING is a rule, not a branch — see impressionDeltaFor.
const standingDelta = impressionDeltaFor(stage, res.level);
if (standingDelta) {
rec.impression = (Number(rec.impression) || 0) + standingDelta;
rec.value = Math.max(-3, Math.min(3, Number(rec.impression) || 0));
}
rec.encounters = Number(rec.encounters) || 0;
await caseFile.update({ "system.standings": standings });
if (stage === "offer" && delta > 0) await claimRewards(caseFile, groupId);
await ChatMessage.create({
speaker: ChatMessage.getSpeaker({ actor: caseFile }),
content: await rbRenderTemplate("systems/ringbrp/templates/chat-contact.hbs", {
stage: game.i18n.localize(spec.label), group: group.name, actor: actor.name,
level: res.level, levelLabel: game.i18n.localize(LEVEL_LABEL[res.level]),
used, rating, delta, learned,
impression: rec.impression ?? 0, standing: rec.value ?? 0,
pidgin: !!rec.pidginEstablished
})
});
return { ...res, delta, learned, standing: rec.value, impression: rec.impression };
}
/**
* Rewards were an inert array: standing moved and nothing it promised happened.
* When standing rises, every reward at or below the new level is granted once,
* recorded on the standing so it cannot be farmed, and read by the phase resolver.
*/
export async function claimRewards(caseFile, groupId) {
const group = caseFile.items.find(i => i.type === "group" && i.system.groupId === groupId);
const standings = foundry.utils.deepClone(caseFile.system.standings ?? []);
const rec = standings.find(x => x.groupId === groupId);
if (!group || !rec) return [];
const level = Math.max(0, Number(rec.value) || 0);
const claimed = new Set(rec.claimedRewardIds ?? []);
const table = group.system.rewardsByStanding ?? {};
const granted = [];
for (let n = 1; n <= level; n++) {
for (const r of table[String(n)] ?? []) {
if (!r?.id || claimed.has(r.id)) continue;
claimed.add(r.id);
granted.push({ ...r, level: n });
}
}
if (!granted.length) return [];
rec.claimedRewardIds = [...claimed];
rec.activeEffects = [...(rec.activeEffects ?? []), ...granted.filter(g => g.effect).map(g => g.effect)];
await caseFile.update({ "system.standings": standings });
// immediate effects land now; the rest are read when the phase resolves
for (const g of granted) {
if (g.effect?.type === "track") {
const t = g.effect.track, cur = caseFile.system[t]?.value ?? 0, max = caseFile.system[t]?.max ?? 10;
await caseFile.update({ [`system.${t}.value`]: Math.max(0, Math.min(max, cur + (Number(g.effect.delta) || 0))) });
}
}
await ChatMessage.create({
speaker: ChatMessage.getSpeaker({ actor: caseFile }),
content: `<div class="ringbrp-chat"><h3 class="rb-chat-title">${
game.i18n.format("RINGBRP.Contact.Rewards", { group: group.name, level })}</h3>
<ul class="rb-chat-flavour">${granted.map(g => `<li>${g.text}</li>`).join("")}</ul></div>`
});
return granted;
}
/** Effects a group has granted that apply to a given phase. */
export function activeRewardEffects(caseFile, phaseIndex) {
const out = [];
for (const st of caseFile?.system?.standings ?? []) {
for (const e of st.activeEffects ?? []) {
if (e.phase !== undefined && e.phase !== phaseIndex) continue;
if (Array.isArray(e.phases) && !e.phases.includes(phaseIndex)) continue;
out.push(e);
}
}
return out;
}
/** A new encounter clears the one-offer-per-encounter lock. */
export async function newEncounter(caseFile, groupId) {
const standings = foundry.utils.deepClone(caseFile.system.standings ?? []);
const rec = standings.find(x => x.groupId === groupId);
if (!rec) return null;
rec.offerMadeThisEncounter = false;
rec.encounters = (Number(rec.encounters) || 0) + 1;
await caseFile.update({ "system.standings": standings });
return rec.encounters;
}
/** What is being asked of the credential, and how unlike its owner you are being. */
async function promptAuthorityUse(authority) {
const sys = authority.system;
const flags = sys.redFlags ?? [];
const scopeOpts = SCOPE_TIERS.slice(1).map(t => {
const stance = scopeStanceFor(sys.scope, t);
const note = stance === "in-scope" ? game.i18n.localize("RINGBRP.Authority.NoRoll")
: stance === "stretch" ? game.i18n.localize("RINGBRP.Authority.ARoll")
: game.i18n.localize("RINGBRP.Authority.Cannot");
return `<option value="${t}" ${t === sys.scope ? "selected" : ""}>${
game.i18n.localize(`RINGBRP.Scope.${t}`)} — ${note}</option>`;
}).join("");
const flagOpts = flags.map((f, i) =>
`<label class="rb-check"><input type="checkbox" name="flag" value="${i}"/> ${f.text}</label>`).join("");
return new Promise(resolve => {
new (rbDialogClass())({
title: game.i18n.format("RINGBRP.Authority.Using", { name: sys.holderName || authority.name }),
content: `<form class="rb-dialog rb-authuse">
<div class="form-group"><label>${game.i18n.localize("RINGBRP.Authority.Asking")}</label>
<select name="tier">${scopeOpts}</select></div>
${flags.length ? `<div class="form-group"><label>${
game.i18n.localize("RINGBRP.Authority.Tripping")}</label>${flagOpts}</div>`
: `<p class="notes">${game.i18n.localize("RINGBRP.Authority.NoFlags")}</p>`}
</form>`,
buttons: {
go: { label: game.i18n.localize("RINGBRP.Authority.Use"),
callback: html => {
const el = html[0] ?? html;
resolve({
actionTier: el.querySelector("[name=tier]")?.value ?? "routine",
flagsTripped: [...el.querySelectorAll("[name=flag]:checked")].length
});
} },
cancel: { label: game.i18n.localize("RINGBRP.Gen.Cancel"), callback: () => resolve(null) }
},
default: "go",
close: () => resolve(null)
}).render(true);
});
}
/* -------------------------------------------- */
/* Dying, and death */
/* -------------------------------------------- */
/**
* Advance the count on an agent at zero. Called from the same end-of-round hook that
* charges blood loss, so nobody has to remember it — which is the whole reason
* bleeding works and dying did not exist.
*/
export async function tickDying(actor) {
if (!actor) return null;
const sys = actor.system;
if (sys.dying?.dead) return null;
if ((Number(sys.hp?.value) || 0) > 0) return null; // not at zero
if (sys.dying?.stabilised) return null; // held
const rounds = (Number(sys.dying?.rounds) || 0) + DYING.perRound;
const limit = dyingLimitFor(sys.majorWound?.value);
await actor.update({ "system.dying.rounds": rounds });
if (rounds < limit) {
await ChatMessage.create({
speaker: ChatMessage.getSpeaker({ actor }),
content: `<div class="ringbrp-chat rb-dying"><strong>${
game.i18n.format("RINGBRP.Death.Dying", { name: actor.name })}</strong> ${
game.i18n.format("RINGBRP.Death.Count", { n: rounds, of: limit })}</div>`
});
return { state: "dying", rounds, limit };
}
return died(actor);
}
/**
* The last entry.
*
* A Custodian's death is a filing question, because everything here is. How well
* filed they were decides what the record ends up saying — and the badly filed are
* not recorded as dead at all, which becomes somebody's problem later.
*/
export async function died(actor) {
if (!actor || actor.system.dying?.dead) return null;
const coherence = Number(actor.system?.coherence?.value) || 0;
const entry = lastEntryFor(coherence);
await actor.update({
"system.dying.dead": true,
"system.dying.lastEntry": entry.id,
"system.hp.value": 0
});
await ChatMessage.create({
speaker: ChatMessage.getSpeaker({ actor }),
content: `<div class="ringbrp-chat rb-dead">`
+ `<h3 class="rb-chat-title">${game.i18n.format("RINGBRP.Death.Died", { name: actor.name })}</h3>`
+ `<p class="rb-chat-flavour">${game.i18n.format("RINGBRP.Death.Coherence", { n: coherence })}</p>`
+ `<p><strong>${game.i18n.localize("RINGBRP.Death.TheEntry")}</strong> ${game.i18n.localize(entry.label)}</p>`
+ `</div>`
});
return { state: "dead", entry: entry.id };
}
/**
* Stabilise somebody at zero. Stops the count; it does not put them back on their
* feet. They are still at zero and still unconscious until somebody heals them.
*/
export async function stabilise(medic, patient, { familyId = "first_aid",
difficulty = "average" } = {}) {
if (!medic || !patient) return null;
const sys = patient.system;
if (sys.dying?.dead) {
ui.notifications?.warn(game.i18n.localize("RINGBRP.Death.TooLate"));
return null;
}
if ((Number(sys.hp?.value) || 0) > 0) {
ui.notifications?.info(game.i18n.localize("RINGBRP.Death.NotDying"));
return null;
}
if (!DYING.stabiliseWith.includes(familyId)) {
ui.notifications?.warn(game.i18n.format("RINGBRP.Death.WrongSkill",
{ list: DYING.stabiliseWith.join(", ") }));
return null;
}
const res = await skillRoll({
actor: medic, label: game.i18n.format("RINGBRP.Death.Stabilising", { name: patient.name }),
rating: medic.skillRating(familyId) ?? 0, difficulty,
categoryId: "mental", skillFamilyId: familyId, printedBase: 0
});
if (!res) return null;
const ok = ["critical", "special", "success"].includes(res.level);
if (ok) await patient.update({ "system.dying.stabilised": true });
// A fumble over a dying person costs them a round of the count they do not have.
else if (res.level === "fumble") {
const rounds = (Number(sys.dying?.rounds) || 0) + 1;
await patient.update({ "system.dying.rounds": rounds });
if (rounds >= dyingLimitFor(sys.majorWound?.value)) await died(patient);
}
await ChatMessage.create({
speaker: ChatMessage.getSpeaker({ actor: medic }),
content: `<div class="ringbrp-chat ${ok ? "rb-stable" : "rb-dying"}">${
game.i18n.format(ok ? "RINGBRP.Death.Stabilised" : "RINGBRP.Death.NotStabilised",
{ name: patient.name })}</div>`
});
return { ...res, stabilised: ok };
}
/** Healing above zero clears the count. Nobody should have to clear it by hand. */
export async function reviveIfHealed(actor) {
if (!actor) return null;
if ((Number(actor.system?.hp?.value) || 0) <= 0) return null;
if (!(Number(actor.system?.dying?.rounds) || 0) && !actor.system?.dying?.stabilised) return null;
await actor.update({ "system.dying.rounds": 0, "system.dying.stabilised": false });
return true;
}
/* -------------------------------------------- */
/* Space combat: stations */
/* -------------------------------------------- */
/**
* A round at a station. One roll, from a skill the agent already has, and the effect
* is the station's — so the Physician and the Translation Officer have a chair and a
* job, which is the entire point of doing it this way.
*/
export async function manStation(actor, craft, stationId, opts = {}) {
const { target = null, difficulty = "average" } = opts;
if (!actor || !craft) return null;
const st = STATIONS[stationId];
if (!st) { ui.notifications?.warn(game.i18n.localize("RINGBRP.Station.Unknown")); return null; }
const sys = craft.system;
const heat = heatEffectsFor(sys.heat);
const band = sys.band ?? "close";
// Heat is felt by the stations that look and shoot, not by the one shedding it.
const steadied = !!craft.getFlag?.("ringbrp", "steadied");
const heatPen = (["guns", "sensors"].includes(stationId) && !steadied) ? heat.penalty : 0;
const rangePen = stationId === "guns" ? spaceRangeModFor(band) : 0;
if (stationId === "guns" && rangePen === null) {
ui.notifications?.warn(game.i18n.localize("RINGBRP.Station.OutOfRange"));
return null;
}
if (stationId === "helm" && heat.slow) {
ui.notifications?.warn(game.i18n.localize("RINGBRP.Station.TooHot"));
return null;
}
const rating = actor.skillRating(st.skill) ?? actor.skillRating(st.skill, "") ?? 0;
const res = await skillRoll({
actor, label: game.i18n.format("RINGBRP.Station.Label",
{ station: game.i18n.localize(st.label), craft: craft.name }),
rating, difficulty, situational: heatPen + rangePen,
categoryId: "mental", skillFamilyId: st.skill, printedBase: 0,
flavourLines: [
game.i18n.format("RINGBRP.Station.At", { band: game.i18n.localize(`RINGBRP.SpaceBand.${band}`) }),
...(heatPen ? [game.i18n.format("RINGBRP.Station.HeatPen", { mod: heatPen })] : []),
...(rangePen ? [game.i18n.format("RINGBRP.Station.RangePen", { mod: rangePen })] : [])
]
});
if (!res) return null;
const ok = ["critical", "special", "success"].includes(res.level);
const upd = {}, said = [];
// Only Gunnery is charged for trying — the shot leaves either way. Everything else
// costs Heat when it actually does something, so a failed roll is not also a
// punishment, and the engineer has a fighting chance of keeping up.
const heatDelta = ok ? st.heat : (st.heatOnFailure ? Math.max(0, st.heat) : 0);
if (heatDelta) upd["system.heat"] = Math.max(0, Math.min(HEAT.max, (Number(sys.heat) || 0) + heatDelta));
if (ok) {
switch (st.effect) {
case "band": {
// The helm CHOOSES. It only ever closed, so every fight ended up docked and
// stayed there with no way to break off short of the anchor.
const dir = (opts.open ? 1 : -1) * (res.level === "critical" ? 2 : 1);
const next = spaceBandAfter(band, dir);
upd["system.band"] = next;
said.push(game.i18n.format(opts.open ? "RINGBRP.Station.Opened" : "RINGBRP.Station.Closed",
{ band: game.i18n.localize(`RINGBRP.SpaceBand.${next}`) }));
break;
}
case "damage": {
let hits = spaceHitFor(res.level, { marked: !!craft.getFlag("ringbrp", "marked") });
if (target) {
// A braced target takes half, rounded down but never below one.
if (target.getFlag?.("ringbrp", "braced")) {
hits = Math.max(1, Math.floor(hits / 2));
await target.unsetFlag("ringbrp", "braced");
}
const t = target.system;
const left = Math.max(0, (Number(t.hull?.value) || 0) - hits);
await target.update({ "system.hull.value": left });
said.push(game.i18n.format("RINGBRP.Station.Hit",
{ n: hits, name: target.name, left }));
if (left <= 0) said.push(game.i18n.format("RINGBRP.Station.Lost", { name: target.name }));
} else said.push(game.i18n.format("RINGBRP.Station.Would", { n: hits }));
await craft.unsetFlag("ringbrp", "marked");
break;
}
case "mark":
// Sensors chooses. Marking helps the gunner; spoofing helps everybody.
if (opts.mode === "spoof") {
upd["system.signature"] = Math.max(0, (Number(sys.signature) || 0) - 2);
said.push(game.i18n.localize("RINGBRP.Station.Spoofed"));
} else if (opts.mode === "read") {
said.push(game.i18n.localize("RINGBRP.Station.Read"));
} else {
await craft.setFlag("ringbrp", "marked", true);
said.push(game.i18n.localize("RINGBRP.Station.Marked"));
}
break;
case "heat": {
// 2/3/4, not 3/4/5. At the old rate a normal round — guns +2, helm +1, vent 3 —
// was heat-NEUTRAL, so the craft cooled while it fought and the pressure only
// existed when the engineer failed. A shooting round should cost something.
const shed = res.level === "critical" ? 4 : (res.level === "special" ? 3 : 2);
upd["system.heat"] = Math.max(0, (Number(sys.heat) || 0) - shed);
said.push(game.i18n.format("RINGBRP.Station.Shed", { n: shed, now: upd["system.heat"] }));
break;
}
case "hull": {
// Damage control chooses: patch the hull, or brace it so the next hit is
// halved. Patching is useless before anything has been hit; bracing is not.
if (opts.mode === "brace") {
await craft.setFlag("ringbrp", "braced", true);
said.push(game.i18n.localize("RINGBRP.Station.Braced"));
} else {
const mend = res.level === "critical" ? 3 : (res.level === "special" ? 2 : 1);
const max = Number(sys.hull?.max) || Number(sys.hullMax) || 1;
upd["system.hull.value"] = Math.min(max, (Number(sys.hull?.value) || 0) + mend);
said.push(game.i18n.format("RINGBRP.Station.Patched", { n: mend }));
}
break;
}
case "talk":
said.push(game.i18n.localize(res.level === "critical"
? "RINGBRP.Station.TalkedDown" : "RINGBRP.Station.Talking"));
break;
case "crew":
// Medical chooses: put somebody back at their station, or suppress the
// penalty everybody is working under. It has a job before anyone is hurt.
if (opts.mode === "steady") {
await craft.setFlag("ringbrp", "steadied", true);
said.push(game.i18n.localize("RINGBRP.Station.Steadied"));
} else said.push(game.i18n.localize("RINGBRP.Station.Crew"));
break;
case "escape":
said.push(game.i18n.localize(res.level === "critical"
? "RINGBRP.Station.GoneClean" : "RINGBRP.Station.Going"));
upd["system.band"] = "gone";
break;
}
} else if (res.level === "fumble") {
upd["system.heat"] = Math.min(HEAT.max, (Number(sys.heat) || 0) + 2);
said.push(game.i18n.localize("RINGBRP.Station.Fumbled"));
}
if (Object.keys(upd).length) await craft.update(upd);
const after = craftConditionFor({ hull: craft.system.hull?.value,
hullMax: craft.system.hull?.max, heat: craft.system.heat });
await ChatMessage.create({
speaker: ChatMessage.getSpeaker({ actor }),
content: `<div class="ringbrp-chat rb-station"><strong>${
game.i18n.format("RINGBRP.Station.Card", { name: actor.name,
station: game.i18n.localize(st.label) })}</strong>`
+ (said.length ? `<p>${said.join(" ")}</p>` : "")
+ `<p class="rb-chat-flavour">${game.i18n.format("RINGBRP.Station.State",
{ hull: craft.system.hull?.value ?? 0, max: craft.system.hull?.max ?? 0,
heat: craft.system.heat ?? 0,
band: game.i18n.localize(`RINGBRP.SpaceBand.${craft.system.band ?? "close"}`) })}</p>`
+ `</div>`
});
return { ...res, station: st.id, said, condition: after };
}
/** Everybody has a chair. This suggests one, from what they are actually good at. */
export function bestStationFor(actor, taken = []) {
const free = Object.values(STATIONS).filter(st => !taken.includes(st.id));
let best = null, score = -1;
for (const st of free) {
const r = actor.skillRating(st.skill) ?? 0;
if (r > score) { score = r; best = st; }
}
return best ? { station: best.id, skill: best.skill, rating: score } : null;
}
/* -------------------------------------------- */
/* Hazards: the ways the world kills you */
/* -------------------------------------------- */
/** A fall. The ground is not a weapon and armour does not apply. */
export async function fall(actor, metres, { soft = false, controlled = false } = {}) {
if (!actor) return null;
const plan = fallDamageFor(metres, { soft, controlled });
if (!plan.dice) {
await ChatMessage.create({ speaker: ChatMessage.getSpeaker({ actor }),
content: `<div class="ringbrp-chat">${game.i18n.format("RINGBRP.Fall.Short", { m: metres })}</div>` });
return { damage: 0 };
}
const roll = rbDressDice(await new Roll(plan.formula).evaluate(), actor);
await roll.toMessage({ speaker: ChatMessage.getSpeaker({ actor }),
flavor: game.i18n.format("RINGBRP.Fall.Flavor", { m: metres, dice: plan.formula }) });
// armourApplied true: the fall ignores armour, and this is how we say so.
const hl = await hitLocationRoll({ target: actor, damage: roll.total, mode: "melee",
level: "success", armourApplied: true });
return { ...plan, rolled: roll.total, location: hl?.location };
}
/**
* A round of not breathing. Call it once a round while they are under.
* `held` is how many rounds they have already gone without.
*/
export async function asphyxiate(actor, { held = 0, prepared = true } = {}) {
if (!actor) return null;
const limit = breathRoundsFor(actor.system?.characteristics?.con?.value, { prepared });
if (held < limit) {
return { holding: true, held: held + 1, limit };
}
const before = Number(actor.system.hp?.value) || 0;
const after = Math.max(0, before - ASPHYXIA.perRound);
await actor.update({ "system.hp.value": after });
await ChatMessage.create({ speaker: ChatMessage.getSpeaker({ actor }),
content: `<div class="ringbrp-chat rb-dying">${game.i18n.format("RINGBRP.Breath.Losing",
{ name: actor.name, n: ASPHYXIA.perRound })} ${before} &rarr; ${after}</div>` });
if (after <= 0) await tickDying(actor);
return { holding: false, held, limit, hp: after };
}
/** Alight. Armour helps on the first round only, and never after. */
export async function burn(actor, bandId = "clothes", { round = 1 } = {}) {
if (!actor) return null;
const band = fireBandFor(bandId);
const roll = rbDressDice(await new Roll(band.damage).evaluate(), actor);
// Armour on the round you catch, and never after. A sealed suit is the exception
// and the GM should say so by running it as a `spark` that never spreads.
const armour = (band.armour && round === 1) ? (Number(actor.system.armourPoints) || 0) : 0;
const dealt = damageAfterArmour(roll.total, armour);
await roll.toMessage({ speaker: ChatMessage.getSpeaker({ actor }),
flavor: game.i18n.format("RINGBRP.Fire.Flavor",
{ band: game.i18n.localize(band.label), round, of: band.rounds }) });
const hl = await hitLocationRoll({ target: actor, damage: dealt, mode: "melee",
level: "success", armourApplied: true });
return { band, rolled: roll.total, dealt, location: hl?.location,
lastRound: round >= band.rounds };
}
/**
* A blast. One roll at the centre, shared out by band, and COVER is the answer —
* armour is only ever half of one, at contact.
*/
export async function detonate({ formula = "4d6", radius = 6, targets = [] } = {}) {
const roll = rbDressDice(await new Roll(formula).evaluate(), null);
await roll.toMessage({ flavor: game.i18n.format("RINGBRP.Blast.Flavor",
{ formula, r: radius }) });
const out = [];
for (const t of targets) {
const actor = t.actor ?? t;
const band = blastBandFor(t.metres ?? 0, radius);
if (!band) { out.push({ actor, outside: true }); continue; }
let dmg = blastShareFor(roll.total, band);
dmg = blastAfterCover(dmg, t.cover ?? "none");
// Armour counts at contact only, and only half of it.
const ar = Math.floor((Number(actor.system?.armourPoints) || 0) * band.armour);
const dealt = damageAfterArmour(dmg, ar);
const hl = dealt > 0
? await hitLocationRoll({ target: actor, damage: dealt, mode: "ranged",
level: "success", armourApplied: true })
: null;
out.push({ actor, band: band.id, cover: t.cover ?? "none", dealt,
location: hl?.location });
}
return { rolled: roll.total, radius, results: out };
}
/* -------------------------------------------- */
/* Automatic fire */
/* -------------------------------------------- */
/**
* One roll for the whole burst. The band decides how much of it arrived, which is
* the same machinery as everything else here rather than four separate attacks and
* a stalled table.
*/
export async function burstAttack(actor, weapon, target, { size = "short",
difficulty = "average",
situational = 0,
sizeModApplied = false,
flavourLines = [] } = {}) {
if (!actor || !weapon) return null;
const b = BURST[size] ?? BURST.short;
// The mode is the gate, and it is checked HERE rather than only in the dialog, so
// that no caller — a macro, a playtest harness, the API — can have a muzzle-loader
// produce automatic fire.
if (!burstSizesFor(weapon.system?.fireMode).includes(b.id)) {
ui.notifications?.warn(game.i18n.format("RINGBRP.Notify.NoBurst", { name: weapon.name }));
return null;
}
const loaded = Number(weapon.system?.ammo?.loaded) || 0;
if (loaded <= 0) {
ui.notifications?.warn(game.i18n.localize("RINGBRP.Notify.Empty"));
return null;
}
const spent = burstRoundsSpent(size, loaded);
const rating = actor.skillRating(weapon.system.skillFamilyId,
weapon.system.skillSpecialisationId) ?? 0;
const res = await skillRoll({
actor, label: game.i18n.format("RINGBRP.Burst.Label",
{ name: weapon.name, size: game.i18n.localize(b.label) }),
// When the sheet built the stack it already counted the burst's own modifier;
// adding it again here charged the player twice for the same decision.
rating, difficulty, situational: situational + (sizeModApplied ? 0 : b.mod),
categoryId: "ranged", skillFamilyId: weapon.system.skillFamilyId, weapon,
flavourLines: [...flavourLines, game.i18n.format("RINGBRP.Burst.Spends", { n: spent })]
});
if (!res) return null;
// The rounds go whether or not anything is hit. That is automatic fire.
await weapon.update({ "system.ammo.loaded": Math.max(0, loaded - spent) });
if (res.level === "fumble") {
const what = BURST_FUMBLE[Math.floor(Math.random() * BURST_FUMBLE.length)];
await ChatMessage.create({ speaker: ChatMessage.getSpeaker({ actor }),
content: `<div class="ringbrp-chat rb-dying"><strong>${
game.i18n.localize(what)}</strong></div>` });
return { ...res, hits: 0, spent, fumble: what };
}
const hits = burstHitsFor(res.level, size);
if (!hits || !target) return { ...res, hits, spent };
await recordBlow({ attacker: actor, level: res.level, weapon });
const lines = [];
let majorWoundTaken = false;
for (let i = 0; i < hits; i++) {
// A critical burst puts its FIRST round through armour and the rest normally.
const level = (res.level === "critical" && i === 0) ? "critical" : "success";
const dmg = await rollWeaponDamage({ actor, weapon, level });
const hl = await hitLocationRoll({ target, damage: dmg, mode: "ranged", level });
if (hl) lines.push(`${dmg} → ${game.i18n.localize(hl.location.label)}`);
// ONE Major Wound per burst, however many rounds connected. A burst is a single
// wounding event; rolling the table once per round turned automatic fire into a
// machine for producing permanent injuries.
if (hl?.majorWound && !(BURST_ONE_MAJOR_WOUND && majorWoundTaken)) {
await majorWoundRoll({ actor: target });
majorWoundTaken = true;
}
if (target.system?.dying?.dead) break;
}
await ChatMessage.create({ speaker: ChatMessage.getSpeaker({ actor }),
content: `<div class="ringbrp-chat"><strong>${
game.i18n.format("RINGBRP.Burst.Connected", { n: hits, of: spent })}</strong>`
+ `<p class="rb-chat-flavour">${lines.join(" · ")}</p></div>` });
return { ...res, hits, spent, lines };
}
/* -------------------------------------------- */
/* Two weapons */
/* -------------------------------------------- */
/**
* Both hands, one declaration. The off hand is a real attack and is meant to cost
* something; Ambidextrous clears the main hand and halves the off, and nothing
* clears the off hand entirely.
*/
export async function twoWeaponAttack(actor, mainWeapon, offWeapon, target,
{ difficulty = "average" } = {}) {
if (!actor || !mainWeapon || !offWeapon) return null;
if (!canPairWeapons(mainWeapon.system?.massKg, offWeapon.system?.massKg)) {
ui.notifications?.warn(game.i18n.format("RINGBRP.TwoWeapon.CannotPair",
{ off: offWeapon.name, max: TWO_WEAPON.offHandMaxKg }));
return null;
}
// hasTalent returns the ITEM, not a boolean. Truthy either way, but the returned
// report printed "[object Object]" where it meant "true".
const trained = !!hasTalent(actor, "ambidextrous");
const pen = twoWeaponPenaltyFor({ trained });
const out = [];
for (const [hand, weapon, mod] of [["main", mainWeapon, pen.main],
["off", offWeapon, pen.off]]) {
const rating = actor.skillRating(weapon.system.skillFamilyId,
weapon.system.skillSpecialisationId) ?? 0;
const res = await skillRoll({
actor, label: game.i18n.format(`RINGBRP.TwoWeapon.${hand}`, { name: weapon.name }),
rating, difficulty, situational: mod,
categoryId: weapon.system?.isRanged ? "ranged" : "melee",
skillFamilyId: weapon.system.skillFamilyId, weapon,
flavourLines: [game.i18n.format("RINGBRP.TwoWeapon.Penalty",
{ mod, note: game.i18n.localize(trained ? "RINGBRP.TwoWeapon.Trained"
: "RINGBRP.TwoWeapon.Untrained") })]
});
if (!res) break;
out.push({ hand, weapon, ...res });
if (["critical", "special", "success"].includes(res.level) && target) {
await recordBlow({ attacker: actor, level: res.level, weapon });
}
}
return { trained, penalties: pen, attacks: out };
}
/* -------------------------------------------- */
/* Borrowed Authority */
/* -------------------------------------------- */
/**
* Add one item to a credential being assembled.
*
* The dossier is the acquisition procedure, not a second subsystem: each item is a
* separate scene using a DIFFERENT skill, and at most one may be Computer Use, so
* building access is something the whole team does. A dossier item never makes the
* later roll easier — it makes the credential better, which is the distinction that
* keeps three generic successes from beating any target in the game.
*/
export async function addDossierItem(authority, { text = "", familyId = "", actor = null,
establishesRemit = false } = {}) {
if (!authority) return null;
const sys = authority.system;
const dossier = foundry.utils.deepClone(sys.dossier ?? []);
if (dossier.length >= DOSSIER.maxItems) {
ui.notifications?.warn(game.i18n.format("RINGBRP.Authority.DossierFull", { n: DOSSIER.maxItems }));
return null;
}
// One specialist cannot assemble the whole thing alone.
if (familyId === "computer_use"
&& dossier.filter(d => d.familyId === "computer_use").length >= DOSSIER.maxComputerUse) {
ui.notifications?.warn(game.i18n.localize("RINGBRP.Authority.TooMuchComputer"));
return null;
}
if (familyId && dossier.some(d => d.familyId === familyId)) {
ui.notifications?.warn(game.i18n.localize("RINGBRP.Authority.SameSkill"));
return null;
}
const rating = actor?.skillRating?.(familyId) ?? 0;
const res = await skillRoll({
actor, label: game.i18n.format("RINGBRP.Authority.Gathering", { text: text || "—" }),
rating, categoryId: "mental", skillFamilyId: familyId, printedBase: 0
});
if (!res) return null;
if (!["critical", "special", "success"].includes(res.level)) {
await ChatMessage.create({
speaker: ChatMessage.getSpeaker({ actor }),
content: `<div class="ringbrp-chat rb-dossier rb-dossier-miss">${
game.i18n.format("RINGBRP.Authority.ItemFailed", { text })}</div>`
});
return { added: false, ...res };
}
dossier.push({ text, familyId, by: actor?.name ?? "", level: res.level,
establishesRemit: !!establishesRemit });
const remit = sys.establishesRemit || establishesRemit;
await authority.update({
"system.dossier": dossier,
"system.establishesRemit": remit,
"system.trust.value": dossierTrustFor(dossier.length),
"system.trust.max": dossierTrustFor(dossier.length),
"system.scope": dossierScopeFor(dossier.length, remit)
});
await ChatMessage.create({
speaker: ChatMessage.getSpeaker({ actor }),
content: `<div class="ringbrp-chat rb-dossier"><strong>${
game.i18n.format("RINGBRP.Authority.ItemAdded", { text })}</strong><br/>${
game.i18n.format("RINGBRP.Authority.DossierNow", {
n: dossier.length, of: DOSSIER.maxItems,
trust: dossierTrustFor(dossier.length),
scope: game.i18n.localize(`RINGBRP.Scope.${dossierScopeFor(dossier.length, remit)}`)
})}</div>`
});
return { added: true, dossier, ...res };
}
/**
* Use a credential.
*
* Inside its scope there is NO ROLL — that is the whole idea. A facilities
* contractor opening a plant room is not a feat of intrusion, it is Tuesday. What
* costs you is asking the record to believe something one step beyond what the
* person was for, and what makes that hard is behaving unlike them.
*/
export async function useAuthority(actor, authority, { actionTier = "routine",
flagsTripped = 0,
difficulty = "average",
familyId = "computer_use" } = {}) {
if (!actor || !authority) return null;
const sys = authority.system;
if (sys.burned) {
ui.notifications?.warn(game.i18n.localize("RINGBRP.Authority.Burned"));
return null;
}
if (sys.suspect) {
ui.notifications?.warn(game.i18n.localize("RINGBRP.Authority.Suspect"));
return null;
}
// A gatekeeper AI does not make the attempt harder, it raises the bar for what
// counts as routine — which is a different and nastier thing.
const effectiveTier = (sys.aiPosture === "gatekeeper" && scopeRank(actionTier) >= scopeRank("routine"))
? SCOPE_TIERS[Math.min(SCOPE_TIERS.length - 1, scopeRank(actionTier) + 1)]
: actionTier;
const stance = scopeStanceFor(sys.scope, effectiveTier);
if (stance === "impossible") {
await ChatMessage.create({
speaker: ChatMessage.getSpeaker({ actor }),
content: `<div class="ringbrp-chat rb-auth rb-auth-no"><strong>${
game.i18n.format("RINGBRP.Authority.Impossible", {
name: sys.holderName || authority.name,
scope: game.i18n.localize(`RINGBRP.Scope.${sys.scope}`),
tier: game.i18n.localize(`RINGBRP.Scope.${effectiveTier}`) })}</strong></div>`
});
return { stance, works: false };
}
if (stance === "in-scope") {
const left = trustAfter(sys.trust.value, -1, sys.trust.max);
await authority.update({ "system.trust.value": left, "system.suspect": left <= 0 });
await ChatMessage.create({
speaker: ChatMessage.getSpeaker({ actor }),
content: `<div class="ringbrp-chat rb-auth rb-auth-yes"><strong>${
game.i18n.format("RINGBRP.Authority.InScope", { name: sys.holderName || authority.name })}</strong> ${
game.i18n.format("RINGBRP.Authority.TrustLeft", { n: left })}${
left <= 0 ? ` — ${game.i18n.localize("RINGBRP.Authority.NowSuspect")}` : ""}</div>`
});
return { stance, works: true, rolled: false, trust: left };
}
// A stretch. Scrutiny is what the system notices, and it is entirely about
// behaving unlike the person whose name is on the record.
const scrutiny = scrutinyFor(flagsTripped);
const res = await skillRoll({
actor, label: game.i18n.format("RINGBRP.Authority.Stretching", {
name: sys.holderName || authority.name,
tier: game.i18n.localize(`RINGBRP.Scope.${effectiveTier}`) }),
rating: actor.skillRating(familyId) ?? 0,
difficulty, situational: scrutiny, categoryId: "mental", skillFamilyId: familyId,
flavourLines: flagsTripped
? [game.i18n.format("RINGBRP.Authority.Flags", { n: flagsTripped, mod: scrutiny })] : []
});
if (!res) return null;
const out = authorityOutcomeFor(res.level);
let trust = trustAfter(sys.trust.value, out.trustDelta, sys.trust.max);
const upd = { "system.trust.value": trust };
if (out.burned) { upd["system.burned"] = true; upd["system.trust.value"] = 0; trust = 0; }
else if (trust <= 0) upd["system.suspect"] = true;
await authority.update(upd);
// An analyst AI does not stop you. It writes something down.
if (sys.aiPosture === "analyst" && !out.burned) {
const flags = foundry.utils.deepClone(sys.redFlags ?? []);
flags.push({ text: game.i18n.localize("RINGBRP.Authority.Correlated"), known: false });
await authority.update({ "system.redFlags": flags });
}
await ChatMessage.create({
speaker: ChatMessage.getSpeaker({ actor }),
content: `<div class="ringbrp-chat rb-auth ${out.works ? "rb-auth-yes" : "rb-auth-no"}"><strong>${
game.i18n.format(out.works ? "RINGBRP.Authority.Worked" : "RINGBRP.Authority.Refused",
{ name: sys.holderName || authority.name })}</strong> ${
out.burned ? game.i18n.localize("RINGBRP.Authority.BurnedNow")
: game.i18n.format("RINGBRP.Authority.TrustLeft", { n: trust })}${
(!out.burned && trust <= 0) ? ` — ${game.i18n.localize("RINGBRP.Authority.NowSuspect")}` : ""}</div>`
});
return { stance, works: out.works, rolled: true, burned: out.burned, trust, ...res };
}
/**
* Spend Coherence to widen what the record says the person was for.
*
* This is the crossing premise applied to paperwork: reality is made to remember
* that M. Hughes had always been cleared for the switchroom. It works. What it does
* to the agent is the subsystem's actual subject — push a credential past what its
* Trust can carry and the two files converge one step, and nothing in the game
* spends anything to converge them back.
*/
export async function widenScope(actor, authority) {
if (!actor || !authority) return null;
const sys = authority.system;
const at = scopeRank(sys.scope);
if (at >= SCOPE_TIERS.length - 1) {
ui.notifications?.info(game.i18n.localize("RINGBRP.Authority.WidestAlready"));
return null;
}
const cost = widenScopeCostFor(sys.timesWidened);
const have = Number(actor.system?.coherence?.value) || 0;
if (have < cost) {
ui.notifications?.warn(game.i18n.format("RINGBRP.Authority.NotEnoughCoherence", { n: cost }));
return null;
}
await loseCoherence(actor, "misfiled", { amount: cost,
note: game.i18n.format("RINGBRP.Authority.WidenNote", { name: sys.holderName || authority.name }) });
const widened = SCOPE_TIERS[at + 1];
const upd = { "system.scope": widened, "system.timesWidened": (Number(sys.timesWidened) || 0) + 1 };
// Pushed past what the credential's Trust can carry, the records converge.
let merged = null;
if ((Number(sys.trust.value) || 0) <= 0) {
const steps = (Number(sys.mergeSteps) || 0) + 1;
upd["system.mergeSteps"] = steps;
merged = mergeStageFor(steps);
}
await authority.update(upd);
await ChatMessage.create({
speaker: ChatMessage.getSpeaker({ actor }),
content: `<div class="ringbrp-chat rb-auth rb-auth-widen"><strong>${
game.i18n.format("RINGBRP.Authority.Widened", {
name: sys.holderName || authority.name,
scope: game.i18n.localize(`RINGBRP.Scope.${widened}`), cost })}</strong>`
+ (merged ? `<p class="rb-auth-merge">${game.i18n.localize(merged.label)}</p>` : "")
+ `</div>`
});
return { scope: widened, cost, merged };
}
/** A suspect credential can be brought back, once, by a scene and a roll. */
export async function rehabilitate(actor, authority, { familyId = "persuade" } = {}) {
if (!actor || !authority) return null;
const sys = authority.system;
if (sys.burned) {
ui.notifications?.warn(game.i18n.localize("RINGBRP.Authority.BurnedForGood"));
return null;
}
const res = await skillRoll({
actor, label: game.i18n.format("RINGBRP.Authority.Rehabilitating",
{ name: sys.holderName || authority.name }),
rating: actor.skillRating(familyId) ?? 0,
categoryId: "mental", skillFamilyId: familyId, printedBase: 0
});
if (!res) return null;
const ok = ["critical", "special", "success"].includes(res.level);
if (ok) {
await authority.update({ "system.suspect": false,
"system.trust.value": trustAfter(0, 1, sys.trust.max) });
} else if (res.level === "fumble") {
await authority.update({ "system.burned": true });
}
return { ...res, rehabilitated: ok };
}
/* -------------------------------------------- */
/* Healing */
/* -------------------------------------------- */
/**
* The one core rule the audit found wholly absent: a wounded agent could never
* get better by any means the system offered. BRP-standard rates, plus the two
* devices the agency actually issues.
*/
RINGBRP.healing = {
natural: { perDays: 7, amount: 1, label: "RINGBRP.Heal.Natural" },
firstAid: { amount: 3, once: true, label: "RINGBRP.Heal.FirstAid" },
fieldPack: { perHours: 4, amount: 1, label: "RINGBRP.Heal.FieldPack" },
traumaUnit:{ perHours: 2, amount: 1, label: "RINGBRP.Heal.TraumaUnit" }
};
/**
* Restore hit points, and mend location damage proportionally so a disabled limb
* comes back when the body does. Characteristic loss is NOT reversed — that is
* the standing rule and no device here changes it.
*/
export async function heal(actor, amount, { source = "natural", locationId = "" } = {}) {
if (!actor || !(amount > 0)) return null;
// The dead do not heal. Everything else in the medical chain routes through here,
// so this is the one place that has to say so.
if (actor.system?.dying?.dead) {
ui.notifications?.warn(game.i18n.localize("RINGBRP.Death.TooLate"));
return null;
}
const max = Number(actor.system.hp?.max) || 0;
const before = Number(actor.system.hp?.value) || 0;
const after = Math.min(max, before + amount);
const upd = { "system.hp.value": after };
// Location damage heals with the body; a named location heals first.
// R-93. Foundry MERGES object updates, so deleting a key from the clone leaves
// the stored value untouched — a location healed to zero silently kept its
// damage, while a partial heal appeared to work. Fully-mended locations must be
// removed with the "-=" deletion form.
if (after > 0) {
upd["system.dying.rounds"] = 0;
upd["system.dying.stabilised"] = false;
}
const dmg = foundry.utils.deepClone(actor.system.locationDamage ?? {});
let pool = after - before;
const order = locationId ? [locationId, ...Object.keys(dmg).filter(k => k !== locationId)]
: Object.keys(dmg);
for (const id of order) {
if (pool <= 0) break;
const taken = Number(dmg[id]) || 0;
if (taken <= 0) continue;
const mend = Math.min(taken, pool);
const left = taken - mend;
pool -= mend;
if (left > 0) upd[`system.locationDamage.${id}`] = left;
else upd[`system.locationDamage.-=${id}`] = null;
}
await actor.update(upd);
await ChatMessage.create({
speaker: ChatMessage.getSpeaker({ actor }),
content: `<div class="ringbrp-chat"><h3 class="rb-chat-title">${
game.i18n.localize("RINGBRP.Heal.Title")}</h3><div class="rb-chat-line">
<span class="rb-chat-rating">${game.i18n.localize(RINGBRP.healing[source]?.label ?? source)}</span>
<span class="rb-chat-arrow">&rarr;</span>
<span class="rb-chat-eff">+${after - before}</span></div>
<div class="rb-chat-result"><span class="rb-chat-roll">${after}</span>
<span class="rb-chat-level">/ ${max}</span></div></div>`
});
return after;
}
/** First Aid: one attempt per wound, and only the first one counts. */
export async function firstAid(medic, patient, { difficulty = "average" } = {}) {
if (!medic || !patient) return null;
// The medical kit's +20% used to be hardcoded here AND carried on the item as
// skillBonus — two authorities for one number. The item is now the only one.
const res = await skillRoll({
actor: medic, label: game.i18n.localize("RINGBRP.Heal.FirstAid"),
rating: medic.skillRating("first_aid") ?? 0, printedBase: 30, difficulty,
categoryId: "mental", skillFamilyId: "first_aid"
});
if (!res) return null;
// A successful First Aid staunches what it is rated to staunch. This is the half
// of the bleeding rule that was never implemented: the table said "until a
// successful First Aid" and nothing ever cleared the wound.
if (["critical", "special", "success"].includes(res.level)) {
await stopBleeding(patient, { family: "first_aid", difficulty });
}
let heal_ = { critical: 6, special: 4, success: 3, failure: 0, fumble: -1 }[res.level] ?? 0;
if (heal_ > 0 && hasTalent(medic, "triage")) heal_ += 1; // Triage
if (heal_ > 0) return heal(patient, heal_, { source: "firstAid" });
if (heal_ < 0) {
await patient.update({ "system.hp.value": Math.max(0, (patient.system.hp.value || 0) - 1) });
ui.notifications?.warn(game.i18n.localize("RINGBRP.Heal.Fumbled"));
}
return patient.system.hp.value;
}
/**
* End-of-round blood loss. Called from the combat hook, so the clock runs without
* anyone remembering it — the old rule printed a rate that nothing ever applied.
*
* Stops at 0: a bleeding agent goes down and stays down rather than being ground
* into negatives by a rule nobody is watching.
*/
export async function applyBleeding(actor) {
const rate = Number(actor?.system?.wounded?.bleed) || 0;
if (!actor || rate <= 0) return null;
const before = Number(actor.system.hp?.value) || 0;
if (before <= 0) return before;
const after = Math.max(0, before - rate);
await actor.update({ "system.hp.value": after });
await ChatMessage.create({
speaker: ChatMessage.getSpeaker({ actor }),
content: `<div class="ringbrp-chat rb-bleed"><strong>${game.i18n.format("RINGBRP.Heal.Bleeds",
{ name: actor.name, amount: rate })}</strong> ${before} &rarr; ${after}` +
(after <= 0 ? ` &mdash; ${game.i18n.localize("RINGBRP.Heal.BledOut")}` : "") + `</div>`
});
return after;
}
/**
* Stop bleeding with a successful roll. Which rolls qualify is BLEED's business,
* not this function's. Returns the wound keys actually stopped.
*/
export async function stopBleeding(actor, { family = "", difficulty = "average" } = {}) {
const wounds = actor?.system?.wounds ?? [];
const stopped = wounds.filter(w => bleedStoppedBy(w.key, { family, difficulty }));
if (!stopped.length) return [];
const keep = wounds.filter(w => !stopped.includes(w));
await actor.update({ "system.wounds": keep });
await ChatMessage.create({
speaker: ChatMessage.getSpeaker({ actor }),
content: `<div class="ringbrp-chat rb-bleed-stop">${game.i18n.format("RINGBRP.Heal.BleedStopped",
{ name: actor.name })}</div>`
});
return stopped.map(w => w.key);
}
/**
* Medical care — the slower, better answer. Routed through the same BLEED rule as
* First Aid so there is one authority on what stops what.
*/
export async function medicalCare(medic, patient, { difficulty = "average" } = {}) {
if (!medic || !patient) return null;
const res = await skillRoll({
actor: medic, label: game.i18n.localize("RINGBRP.Heal.Medicine"),
rating: medic.skillRating("medicine") ?? 0, printedBase: 0, difficulty,
categoryId: "mental", skillFamilyId: "medicine"
});
if (!res) return null;
if (["critical", "special", "success"].includes(res.level)) {
await stopBleeding(patient, { family: "medicine", difficulty });
return heal(patient, { critical: 6, special: 4, success: 3 }[res.level] ?? 0,
{ source: "medicine" });
}
return patient.system.hp.value;
}
/**
* Defend against an attack. Rolls Dodge or a weapon's parry at the escalating
* penalty, increments the round's counter and locks the type — the three fields
* the data model has carried since the beginning with nothing to write them.
*
* Returns the roll result plus whether the attack was turned aside, so a caller
* can decide what that means: a dodge avoids the blow, a parry stops it.
*/
/** The shield this actor currently has up, as the item — null if none, or if broken. */
function this_shield(actor) {
return actor?.items?.find(i => i.type === "armour" && i.system.shield
&& i.system.equipped && i.system.raised
&& !shieldBroken(i.system.degradation?.current, i.system.degradation?.max)) ?? null;
}
export async function defend(actor, { type = "dodge", weapon = null, difficulty = "average",
against = null } = {}) {
if (!actor) return null;
// What is being answered. A defence that does not know the quality of the blow
// cannot grade itself against it, so fall back to the standing blow recorded by
// the last attack, and to an ordinary success if there is not one.
const incoming = against ?? standingBlow(actor)?.level ?? "success";
const combat = actor.system.combat ?? {};
const used = Number(combat.defencesThisRound) || 0;
// Taking it on the shield IS a parry — it locks with parries and it is not a dodge.
// What differs is the skill it rolls, so the shield is a flag rather than a third
// defence type: passing "shield" through defenceTypeAllowed produced a `kind` the
// branches below did not recognise, and every shield defence quietly rolled Dodge.
const wantsShield = type === "shield";
const asked = wantsShield ? "parry" : type;
const kind = defenceTypeAllowed(combat.defenceTypeLock, asked);
if (kind !== asked) {
ui.notifications?.info(game.i18n.format("RINGBRP.Notify.DefenceLocked",
{ kind: game.i18n.localize(`RINGBRP.Defence.${kind}`) }));
}
// Dodge is its own skill; a parry uses the weapon you are holding — or the shield,
// which is the whole point of a shield and the only armour you can defend with.
let rating = 0, label = "", famId = "", catId = "phys";
const shieldItem = (kind === "parry" && (wantsShield || !weapon))
? this_shield(actor) : null;
if (kind === "parry" && shieldItem) {
rating = actor.skillRating(SHIELD.parryFamily, SHIELD.parrySpec) ?? 0;
label = game.i18n.format("RINGBRP.Defence.ParryWith", { name: shieldItem.name });
famId = SHIELD.parryFamily; catId = "melee";
} else if (kind === "parry" && weapon) {
rating = actor.skillRating(weapon.system.skillFamilyId, weapon.system.skillSpecialisationId) ?? 0;
label = game.i18n.format("RINGBRP.Defence.ParryWith", { name: weapon.name });
famId = weapon.system.skillFamilyId; catId = "melee";
} else {
rating = actor.skillRating("dodge") ?? 0;
label = game.i18n.localize("RINGBRP.Defence.dodge");
famId = "dodge";
}
const penalty = defencePenaltyFor(used);
const res = await skillRoll({
actor, label, rating, difficulty, situational: penalty,
categoryId: catId, skillFamilyId: famId, printedBase: 0,
flavourLines: penalty ? [game.i18n.format("RINGBRP.Flavour.Defending",
{ n: used + 1, mod: penalty })] : []
});
if (!res) return null;
await actor.update({
"system.combat.defencesThisRound": used + 1,
"system.combat.defenceTypeLock": kind
});
const outcome = defenceOutcomeFor(incoming, res.level);
// Shields break. Wear is taken for what the shield ABSORBED, not only for what it
// stopped outright — a shield that turns a critical down to a special has very much
// taken that blow, and it is exactly then that shields splinter. Requiring a clean
// stop made this unreachable: only a critical defence fully stops a critical, so a
// shield would have gone its entire life without a scratch.
if (shieldItem && (outcome.turnedAside || outcome.steps > 0)) {
const wear = shieldWearFor(incoming);
if (wear) {
const cur = (Number(shieldItem.system.degradation?.current) || 0) + wear;
await shieldItem.update({ "system.degradation.current": cur });
const max = Number(shieldItem.system.degradation?.max) || 0;
const broken = shieldBroken(cur, max);
await ChatMessage.create({ speaker: ChatMessage.getSpeaker({ actor }),
content: `<div class="ringbrp-chat ${broken ? "rb-dying" : ""}"><strong>${
game.i18n.format(broken ? "RINGBRP.Shield.Broken" : "RINGBRP.Shield.Wear",
{ name: shieldItem.name, n: cur, max })}</strong></div>` });
if (broken) await shieldItem.update({ "system.raised": false });
}
}
// The blow now stands at whatever survived the defence, so the attacker's damage
// roll uses the reduced level and a stopped blow can no longer roll damage at all.
await amendBlow(outcome.turnedAside ? { stopped: true } : { level: outcome.landsAt });
await ChatMessage.create({
speaker: ChatMessage.getSpeaker({ actor }),
content: `<div class="ringbrp-chat rb-defence ${outcome.turnedAside ? "rb-aside" : "rb-through"}">`
+ `<strong>${game.i18n.format("RINGBRP.Defence.Answered", {
name: actor.name,
defence: game.i18n.localize(`RINGBRP.Level.${res.level}`),
attack: game.i18n.localize(`RINGBRP.Level.${incoming}`) })}</strong> `
+ (outcome.turnedAside
? game.i18n.localize("RINGBRP.Defence.TurnedAside")
: game.i18n.format("RINGBRP.Defence.GetsThrough", {
level: game.i18n.localize(`RINGBRP.Level.${outcome.landsAt}`) }))
+ `</div>`
});
return { ...res, kind, penalty, defencesUsed: used + 1, against: incoming,
landsAt: outcome.landsAt, steps: outcome.steps,
turnedAside: outcome.turnedAside };
}
/* -------------------------------------------- */
/* The standing blow */
/* -------------------------------------------- */
/**
* A defence has to know what it is answering, and the sheet's Dodge and Parry
* buttons had no way of knowing — they were pressed in isolation. Every attack
* roll now leaves a record of itself, and the defender picks it up.
*
* Kept on the active Combat where there is one, so it reaches every client rather
* than only the machine that rolled; a module-level copy covers a brawl nobody
* bothered to start a combat for. It expires so that yesterday's critical cannot
* be answered by today's dodge.
*/
const BLOW_TTL_MS = 120000;
let lastBlow = null;
export async function recordBlow({ attacker, level, weapon = null, targets = [],
bonus = "" } = {}) {
if (!attacker || !["critical", "special", "success"].includes(level)) return null;
// `bonus` is extra damage dice the ATTACK earned rather than the weapon carrying —
// a sustained beam. It rides on the blow because the damage roll happens later and
// separately, and otherwise the choice that paid -25% to accuracy would be lost.
const blow = { attackerId: attacker.id, attackerName: attacker.name, level,
weaponName: weapon?.name ?? null, targets, bonus, at: Date.now() };
lastBlow = blow;
if (game.combat) await game.combat.setFlag("ringbrp", "standingBlow", blow);
return blow;
}
/** Revise the standing blow after a defence has been rolled against it. */
export async function amendBlow(patch = {}) {
const blow = game.combat?.getFlag("ringbrp", "standingBlow") ?? lastBlow;
if (!blow) return null;
const next = { ...blow, ...patch };
lastBlow = next;
if (game.combat) await game.combat.setFlag("ringbrp", "standingBlow", next);
return next;
}
/** The blow this actor is currently answering, if it is still fresh and not theirs. */
export function standingBlow(defender = null) {
const blow = game.combat?.getFlag("ringbrp", "standingBlow") ?? lastBlow;
if (!blow) return null;
if (Date.now() - (Number(blow.at) || 0) > BLOW_TTL_MS) return null;
if (defender && blow.attackerId === defender.id) return null; // not your own swing
return blow;
}
/**
* Use one of the Quiet Arts.
*
* Rolls through the same choke point as everything else, so an Art is subject to
* wounds, load and — pointedly — the agent's own Coherence: the further you have
* drifted, the worse you are at the thing that made you drift. The cost is charged
* whether or not it works, because the misfiling is committed the moment you try.
*
* A FUMBLE is the interesting outcome. The misfiling sticks: something small and
* true about the world changes and the GM never says which. That is a story hook
* with a mechanical anchor, not a fail state.
*/
export async function usePower(actor, power, { pushed = false, difficulty = "average" } = {}) {
if (!actor || !power) return null;
const artId = power.system?.artId || "";
const cost = pushed ? (Number(power.system?.pushCost) || powerCostFor(artId, { pushed: true }))
: (Number(power.system?.coherenceCost) || powerCostFor(artId));
// R-56 applies to the Arts too: an agent at 0 Coherence is Displaced, not gone,
// and must still be able to act. The cost simply cannot take them below zero.
const have = Number(actor.system?.coherence?.value) || 0;
if (have <= 0) {
ui.notifications?.warn(game.i18n.localize("RINGBRP.Notify.NoCoherence"));
return null;
}
const res = await skillRoll({
actor, label: power.name, rating: Number(power.system?.value) || 0,
difficulty, categoryId: "mental", printedBase: 0,
flavourLines: [game.i18n.format(pushed ? "RINGBRP.Power.Pushed" : "RINGBRP.Power.Used",
{ cost })]
});
if (!res) return null;
await loseCoherence(actor, "usedCrossingTech", { amount: cost, note: power.name });
// An Art earns its tick the way a skill does. Without this it arrived at roughly
// POW x2 and stayed there for the rest of the character's life while charging
// Coherence per attempt — a trap rather than a temptation.
if (["critical", "special", "success"].includes(res.level) && !power.system?.experienceTicked) {
await power.update({ "system.experienceTicked": true });
}
if (res.level === "fumble") {
await ChatMessage.create({
speaker: ChatMessage.getSpeaker({ actor }),
content: `<div class="ringbrp-chat rb-misfiled"><strong>${game.i18n.localize("RINGBRP.Power.Misfiled")}</strong>
<p>${game.i18n.localize("RINGBRP.Power.MisfiledText")}</p></div>`
});
}
return { ...res, cost, pushed, artId };
}
/**
* Aim the way home and take it.
*
* This is the roll `returnCrossing` only ever described. R-56 is enforced here and
* not by convention: **there is no outcome that leaves an agent across**. A failure
* arrives them near the target rather than at it; a fumble brings them back
* changed. Being Unmoored makes it dearer and another agent can always aim it for
* you at no penalty.
*
* A crossing that goes wrong is the ONLY source of the Quiet Arts. The pool is
* decided by how far the agent had already drifted when it went wrong, so the worse
* the state they were in, the worse the thing that follows them home.
*/
export async function crossBack(actor, { aimedByAnother = false, aimedBy = null } = {}) {
if (!actor) return null;
const plan = returnCrossing(actor, { aimedByAnother });
const roller = aimedByAnother && aimedBy ? aimedBy : actor;
const res = await skillRoll({
actor: roller,
label: game.i18n.localize("RINGBRP.Cross.Aim"),
rating: roller.skillRating("transposition") ?? 0,
categoryId: "mental", skillFamilyId: "transposition", printedBase: 0,
// At two steps of the window clock the return is Difficult as well, which is
// the third step the generator has always printed and never applied.
difficulty: plan.difficult ? "difficult" : "average",
situational: plan.penalty,
flavourLines: [
game.i18n.localize(plan.note),
...(plan.clock ? [game.i18n.format("RINGBRP.Cross.WindowClock",
{ n: plan.clock, mod: plan.latePenalty })] : [])
]
});
if (!res) return null;
const cost = crossingCostFor(res.level);
if (cost) await loseCoherence(actor, "overstayed", { amount: cost, note: game.i18n.localize("RINGBRP.Cross.Rough") });
// Whatever happened, THIS AGENT is home. That is the invariant, not a courtesy.
// The case file is not cleared here: one agent returning does not end a crossing
// the rest of the team is still standing in. The caller decides when the last
// one is back.
let art = null;
const coherenceNow = Number(actor.system?.coherence?.value) || 0;
if (crossingGrantsArt(res.level, coherenceNow)) {
const pool = crossingArtPoolFor(coherenceNow)
.filter(id => !actor.items.some(i => i.type === "power" && i.system?.artId === id));
if (pool.length) {
const pick = pool[Math.floor(Math.random() * pool.length)];
art = await grantPower(actor, pick, {
origin: game.i18n.format("RINGBRP.Cross.Origin", {
outcome: game.i18n.localize(`RINGBRP.Cross.${res.level}`)
})
});
}
}
await ChatMessage.create({
speaker: ChatMessage.getSpeaker({ actor }),
content: `<div class="ringbrp-chat rb-crossing rb-${res.level}">
<h3 class="rb-chat-title">${game.i18n.localize("RINGBRP.Cross.Title")}</h3>
<p><strong>${game.i18n.localize(`RINGBRP.Cross.${res.level}`)}</strong></p>
<p>${game.i18n.localize(res.level === "failure" || res.level === "fumble"
? plan.onFailure : "RINGBRP.Cross.Clean")}</p>
${cost ? `<p class="rb-dim">${game.i18n.format("RINGBRP.Cross.Cost", { n: cost })}</p>` : ""}
</div>`
});
return { ...res, home: true, cost, art, plan };
}
/* ============================================================
* MISSIONS — generate a whole case, ready to run
* ============================================================ */
/**
* Build a case from the mission framework: a JournalEntry the GM reads and a case
* file Actor the table plays against, with the phases already laid in as legs and
* the tracks set by classification.
*
* The journal is deliberately split brief / truth / opposition / phases / pressure,
* because the brief is the only page a player may see and it must be safe to show.
*/
/**
* Who is on a freshly generated case, in order of how good the guess is.
* Player-assigned characters first — that is the party — then the GM's current
* selection, and the whole world only if neither exists.
*/
function defaultRosterFor() {
const assigned = game.users.contents
.filter(u => u.active !== undefined && !u.isGM && u.character?.type === "character")
.map(u => u.character);
const uniq = [...new Map(assigned.map(a => [a.id, a])).values()];
if (uniq.length) return uniq;
const selected = canvas?.tokens?.controlled?.map(t => t.actor)
.filter(a => a?.type === "character") ?? [];
const uniqSel = [...new Map(selected.map(a => [a.id, a])).values()];
if (uniqSel.length) return uniqSel;
const all = game.actors.filter(a => a.type === "character");
if (all.length > 8) {
ui.notifications?.warn(game.i18n.format("RINGBRP.Mission.RosterGuess", { n: all.length }));
}
return all;
}
export async function generateMissionCase({ seed = null, notify = true, ...opts } = {}) {
const { generateMission, missionPages } = await import("./tools/mission.mjs");
const m = generateMission({ seed, ...opts });
const journal = await JournalEntry.create({
name: `${m.codename} — ${m.classification.name}`,
pages: missionPages(m).map((pg, i) => ({
name: pg.name, type: "text", title: { show: true, level: 1 },
text: { format: 1, content: pg.content }, sort: (i + 1) * 100,
// CONST.DOCUMENT_OWNERSHIP_LEVELS: 2 is OBSERVER, 0 is NONE, -1 is INHERIT.
// These were the wrong way round — the brief was set to NONE and the four
// secret pages to INHERIT, so a GM opening the entry to the table to share the
// brief would have published the truth, the opposition and the whole plot.
// Ownership is declared by the page, not guessed from its index. Adding a
// second player page silently hid it when this read `i === 0`.
ownership: { default: pg.players ? 2 : 0 }
}))
});
const caseFile = await Actor.create({
name: `Case: ${m.codename}`,
type: "expedition",
system: {
integrity: { value: m.tracks.containment, max: 10 },
supply: { value: m.tracks.resources, max: 10 },
power: { value: m.tracks.clearance, max: 10 },
across: false,
journey: {
destinationKnowledge: "rumoured",
currentLeg: 0,
legs: m.phases.map(p => ({
// The phase's kind, stored rather than sniffed out of the terrain string.
// The extraction rule matched on the prose, which worked only because the
// terrain happens to begin "Extraction —".
kind: p.kind,
terrain: `${p.name} — ${p.terrain}`,
difficulty: p.difficulty,
modifier: p.modifier,
costSupply: p.costSupply,
costPower: p.costPower,
days: 1,
explored: false,
resolved: false,
forcedEncounterId: p.revealsGap ? p.reveal : null
}))
},
// The case file itself should answer where, when, and what the job is without
// anyone opening the journal. It used to carry one line of codename and place.
notes: `<p><strong>${m.codename}</strong> — ${m.classification.name}.</p>`
+ `<p><strong>Where:</strong> ${m.place}<br/>`
+ `<strong>When:</strong> ${m.duration}<br/>`
+ `<strong>Job:</strong> ${m.resolution}</p>`
+ `<p><strong>Expect:</strong> about ${m.adversary.count} ${m.adversary.name} (${m.adversary.tier}). `
+ `${m.adversary.leverage}</p>`
+ `<p><strong>Way back:</strong> ${m.phases[m.phases.length - 1].terrain}. `
+ `Transposition per agent`
+ `${m.crossingModifier ? `, ${m.crossingModifier}% for the classification` : ""}.</p>`
+ `<p>${m.anomaly.brief}</p>`,
// A case with nobody assigned cannot resolve a phase: resolveLegDialog needs
// a roster to pick a lead from and bails silently without one. But the old
// fallback was EVERY character in the world, which in a world holding more
// than one party quietly put thirty-six people on a four-agent case. Prefer
// the actors players are actually assigned to, then whatever the GM has
// selected, and only conscript the world as a last resort.
members: (opts.members ?? defaultRosterFor()).map(a => a.id),
standings: [],
// read by returnCrossing; the brief promises it, so the return must charge it
crossingModifier: m.crossingModifier,
// read by salvageFrom(); what era of kit is lying about in a world like this
salvageEra: m.salvageEra
},
// The contact pillar — three stages, standings, learned taboos, and rewards the
// phase resolver already applies — could never fire on a generated case, because
// resolveContact needs a group ITEM on the case file and the generator created
// the actor with no items at all. The people already on the ground now exist.
items: [{
name: m.group.name, type: "group", img: "systems/ringbrp/icons/group.svg",
system: {
description: `<p>${m.group.description}</p>`,
groupId: m.group.id, speciesId: "baseline", population: m.group.population,
taboos: [m.group.taboo],
desires: [m.group.desire],
correctOfferForm: m.group.offerForm,
rewardsByStanding: {
"1": [{ id: "r1_route", text: "They walk you in by a way that avoids the road.",
effect: { type: "cancelModifier", phase: 1 } }],
"2": [{ id: "r2_shelter", text: "You may shelter with them. The next phase costs nothing.",
effect: { type: "waiveCost", phase: 2 } }],
"3": [{ id: "r3_hand", text: "One of them comes with you and knows the ground.",
effect: { type: "skillBonus", value: 20, skillFamilyId: "tradecraft" } }]
}
}
}],
flags: { ringbrp: {
// activeCaseFile() looks for this flag and nothing ever set it, so every
// crossing, debrief and Clean Scene operated on whichever expedition happened
// to be first in the collection — the shipped ILFRACOMBE case, usually.
active: true,
mission: { seed: m.seed, codename: m.codename, gap: m.gap.id,
anomaly: m.anomaly.id, journalId: journal.id } } }
});
// Only one case is the active one.
for (const other of game.actors.filter(a => a.type === "expedition" && a.id !== caseFile.id)) {
if (other.getFlag("ringbrp", "active")) await other.unsetFlag("ringbrp", "active");
}
if (notify) ui.notifications?.info(game.i18n.format("RINGBRP.Mission.Made", { name: m.codename }));
return { mission: m, journal, caseFile };
}
/** A GM dialog: pin what you want, roll the rest. */
export async function missionDialog() {
const T = await import("./tools/mission-tables.mjs");
const opt = (arr, none) => `<option value="">${none}</option>` +
arr.map(x => `<option value="${x.id}">${x.name}</option>`).join("");
const content = `<form class="rb-dialog">
<div class="form-group"><label>${game.i18n.localize("RINGBRP.Mission.Class")}</label>
<select name="classId">${opt(T.CLASSES, game.i18n.localize("RINGBRP.Mission.Roll"))}</select></div>
<div class="form-group"><label>${game.i18n.localize("RINGBRP.Mission.Anomaly")}</label>
<select name="anomalyId">${opt(T.ANOMALIES, game.i18n.localize("RINGBRP.Mission.Roll"))}</select></div>
<div class="form-group"><label>${game.i18n.localize("RINGBRP.Mission.Gap")}</label>
<select name="gapId">${opt(T.GAPS, game.i18n.localize("RINGBRP.Mission.Roll"))}</select>
<p class="notes">${game.i18n.localize("RINGBRP.Mission.GapHint")}</p></div>
<div class="form-group"><label>${game.i18n.localize("RINGBRP.Mission.Adversary")}</label>
<select name="adversaryId">${opt(T.ADVERSARIES, game.i18n.localize("RINGBRP.Mission.Roll"))}</select></div>
<div class="form-group"><label>${game.i18n.localize("RINGBRP.Mission.Seed")}</label>
<input type="number" name="seed" placeholder="${game.i18n.localize("RINGBRP.Mission.SeedHint")}"/></div>
</form>`;
return new Promise(resolve => {
new (rbDialogClass())({
title: game.i18n.localize("RINGBRP.Mission.Title"), content,
buttons: {
make: { label: game.i18n.localize("RINGBRP.Mission.Make"),
callback: async html => {
const el = html[0] ?? html;
const v = n => el.querySelector(`[name=${n}]`)?.value || undefined;
const seed = Number(v("seed"));
resolve(await generateMissionCase({
seed: Number.isFinite(seed) && seed ? seed : null,
classId: v("classId"), anomalyId: v("anomalyId"),
gapId: v("gapId"), adversaryId: v("adversaryId")
}));
} },
cancel: { label: game.i18n.localize("RINGBRP.Dialog.Cancel"), callback: () => resolve(null) }
},
default: "make", close: () => resolve(null)
}).render(true);
});
}
/**
* Grant an Art. There is no other way in: they are not taught, bought or chosen at
* character creation, they are what a crossing left behind. The GM calls this when
* a crossing has cost somebody something and given them this instead.
*/
export async function grantPower(actor, artId, { origin = "", rating = null } = {}) {
if (!actor || !ARTS[artId]) return null;
if (actor.items.some(i => i.type === "power" && i.system?.artId === artId)) {
ui.notifications?.info(game.i18n.localize("RINGBRP.Notify.AlreadyHasArt"));
return null;
}
const pack = game.packs.get("ringbrp.powers");
const proto = pack ? (await pack.getDocuments()).find(d => d.system?.artId === artId) : null;
const pow = Number(actor.system?.characteristics?.pow?.value) || 10;
const data = proto ? proto.toObject() : {
name: game.i18n.localize(`RINGBRP.Art.${artId}.name`), type: "power",
system: { artId, coherenceCost: powerCostFor(artId), pushCost: powerCostFor(artId, { pushed: true }) }
};
data.system.value = rating ?? powerStartingRating(pow);
data.system.origin = origin;
const [made] = await actor.createEmbeddedDocuments("Item", [data]);
await ChatMessage.create({
speaker: ChatMessage.getSpeaker({ actor }),
content: `<div class="ringbrp-chat rb-power-granted">
<strong>${game.i18n.format("RINGBRP.Power.Arrived", { name: made.name })}</strong>
<p>${origin || game.i18n.localize("RINGBRP.Power.ArrivedText")}</p></div>`
});
return made;
}
/**
* SALVAGE. What is lying about in a world like this one.
*
* `salvageEra` was computed by the mission generator and referenced by absolutely
* nothing — the cleanest instance in the project of a value with no reader. It is
* the whole point of crossing to other times: a Hollow world is full of antique
* kit, a Deep one full of things nobody here can manufacture. Now it picks from the
* catalogue by era, which the items have carried since the era rework.
*/
export async function salvageFrom(caseFile, { count = 1 } = {}) {
const cf = caseFile ?? activeCaseFile();
const era = cf?.system?.salvageEra || "modern";
const packs = ["weapons", "armour", "gear"];
const found = [];
for (const name of packs) {
const pack = game.packs.get(`ringbrp.${name}`);
if (!pack) continue;
for (const d of await pack.getDocuments()) {
if ((d.flags?.ringbrp?.era ?? "modern") !== era) continue;
// Weightless entries are not objects. "Punch / kick" is the unarmed pseudo-
// weapon every character has, and it turned up as salvage lying in a filter
// house, which is funny exactly once.
if (!(Number(d.system?.massKg) > 0)) continue;
found.push(d);
}
}
if (!found.length) {
ui.notifications?.info(game.i18n.format("RINGBRP.Salvage.None", { era }));
return [];
}
const picked = [];
for (let i = 0; i < count && found.length; i++) {
picked.push(found.splice(Math.floor(Math.random() * found.length), 1)[0]);
}
await ChatMessage.create({
speaker: { alias: game.i18n.localize("RINGBRP.Salvage.Title") },
content: `<div class="ringbrp-chat"><h3 class="rb-chat-title">${
game.i18n.format("RINGBRP.Salvage.Found", { era: game.i18n.localize(`RINGBRP.Folder.era.${era}`) })
}</h3><ul class="rb-chat-flavour">${
picked.map(d => `<li><strong>${d.name}</strong> — ${d.system?.massKg ?? 0} kg</li>`).join("")}</ul></div>`
});
return picked;
}
/** Time passing at home or in a safe place. */
export async function restFor(actor, { days = 0, hours = 0, device = "" } = {}) {
const rules = RINGBRP.healing;
let total = Math.floor((Number(days) || 0) / rules.natural.perDays) * rules.natural.amount;
const dev = rules[device];
if (dev?.perHours) total += Math.floor((Number(hours) || 0) / dev.perHours) * dev.amount;
return total > 0 ? heal(actor, total, { source: device || "natural" }) : actor.system.hp.value;
}
/**
* End of case. Every ticked skill rolls to improve: succeed against d100 > current
* rating and it goes up 1D6, and the tick clears either way. This is the step that
* closes the episodic loop — a case ends, the agents are a little better, and the
* ticks reset for the next one.
*/
export async function endOfCase(actor) {
if (!actor) return null;
// Talents reset whether or not anything was learned — end of case is end of case.
// This used to sit after the early return, so a character who ticked no skills
// kept every talent spent for the rest of the campaign.
await resetTalents(actor);
// Powers improve on the same footing as skills. They carried an experienceTicked
// field that nothing set and nothing read, so an Art could never get better.
const ticked = actor.items.filter(i => ["skill", "power"].includes(i.type)
&& i.system.experienceTicked);
if (!ticked.length) {
ui.notifications?.info(game.i18n.localize("RINGBRP.Exp.NoTicks"));
return [];
}
const results = [];
for (const sk of ticked) {
const rating = Number(sk.system.value) || 0;
const roll = await new Roll("1d100").evaluate();
// Roll OVER the current rating: the better you already are, the harder to gain.
const improved = roll.total > rating;
const gain = improved ? (await new Roll("1d6").evaluate()).total : 0;
await sk.update({ "system.value": rating + gain, "system.experienceTicked": false });
results.push({ name: sk.name, rating, roll: roll.total, gain, improved });
}
await ChatMessage.create({
speaker: ChatMessage.getSpeaker({ actor }),
content: `<div class="ringbrp-chat"><h3 class="rb-chat-title">${
game.i18n.localize("RINGBRP.Exp.Title")}</h3><ul class="rb-chat-flavour">${
results.map(r => `<li>${r.name}: ${r.roll} vs ${r.rating}% — ${
r.improved ? `<strong>+${r.gain}</strong> &rarr; ${r.rating + r.gain}%` :
game.i18n.localize("RINGBRP.Exp.NoGain")}</li>`).join("")}</ul></div>`
});
return results;
}
/* -------------------------------------------- */
/* NPC targeting */
/* -------------------------------------------- */
/**
* R-43, finally implemented. It was written into three statblocks — "fires at the
* nearest visible agent; ties go to the lowest current hit points" — and lived
* nowhere else, so every GM had to arbitrate it by hand and inconsistently.
*
* Order: nearest visible, then lowest current hit points, then lowest DEX, then
* the GM chooses from what remains. A character who is DOWN is not a legal target
* while any standing target remains — enemies here are not fanatics, they are
* being paid, and shooting the unconscious wastes a round.
*/
export function npcTarget(attackerToken, candidateTokens = []) {
const live = candidateTokens.filter(t => t?.actor);
if (!live.length) return { target: null, reason: "RINGBRP.Target.None", tied: [] };
// visible only; a token with zero HP is down
const isDown = t => (Number(t.actor.system?.hp?.value) || 0) <= 0 || t.actor.system?.isDown;
const visible = live.filter(t => t.visible !== false && !t.document?.hidden);
const pool = visible.length ? visible : live;
const standing = pool.filter(t => !isDown(t));
const legal = standing.length ? standing : pool; // only shoot the downed if nothing stands
const shootingDowned = !standing.length;
const dist = t => {
if (!attackerToken?.center || !t?.center) return 0;
return Math.hypot(t.center.x - attackerToken.center.x, t.center.y - attackerToken.center.y);
};
const narrow = (arr, keyFn) => {
const best = Math.min(...arr.map(keyFn));
return arr.filter(t => keyFn(t) === best);
};
let pick = narrow(legal, dist);
let reason = "RINGBRP.Target.Nearest";
if (pick.length > 1) {
pick = narrow(pick, t => Number(t.actor.system?.hp?.value) || 0);
reason = "RINGBRP.Target.LowestHP";
}
if (pick.length > 1) {
pick = narrow(pick, t => Number(t.actor.system?.characteristics?.dex?.value) || 0);
reason = "RINGBRP.Target.LowestDex";
}
if (pick.length > 1) reason = "RINGBRP.Target.GMChoice";
return {
target: pick[0] ?? null,
tied: pick,
reason: shootingDowned ? "RINGBRP.Target.OnlyDowned" : reason,
distance: pick[0] ? Math.round(dist(pick[0])) : null
};
}
/**
* Morale, also prose until now: "when two of a team of six are down, the rest
* withdraw with their wounded." Expressed as a fraction so it holds for any
* group size.
*/
export function npcShouldWithdraw(groupTokens = [], threshold = 1 / 3) {
const live = groupTokens.filter(t => t?.actor);
if (!live.length) return false;
const down = live.filter(t => (Number(t.actor.system?.hp?.value) || 0) <= 0).length;
return down / live.length >= threshold;
}
/** Announce the choice so the table can see the rule was followed, not fudged. */
export async function declareNpcTarget(attackerToken, candidateTokens) {
const res = npcTarget(attackerToken, candidateTokens);
if (!res.target) return res;
await ChatMessage.create({
speaker: ChatMessage.getSpeaker({ actor: attackerToken?.actor }),
content: `<div class="ringbrp-chat"><h3 class="rb-chat-title">${
game.i18n.localize("RINGBRP.Target.Title")}</h3><div class="rb-chat-line">
<span class="rb-chat-rating">${res.target.name}</span>
<span class="rb-chat-arrow">&larr;</span>
<span class="rb-chat-mod">${game.i18n.localize(res.reason)}</span></div></div>`,
whisper: ChatMessage.getWhisperRecipients("GM").map(u => u.id)
});
return res;
}
/* -------------------------------------------- */
/* Talents */
/* -------------------------------------------- */
/** Does this actor have a given talent, and is it still available? */
export function hasTalent(actor, talentId) {
return (actor?.items ?? []).find(i => i.type === "talent" && i.system?.talentId === talentId) ?? null;
}
export function talentAvailable(talent) {
const per = Number(talent?.system?.usesPerCase) || 0;
if (!per) return true; // unlimited
return (Number(talent.system.usedThisCase) || 0) < per;
}
/**
* Spend a talent. Charges its Coherence cost, increments its per-case usage, and
* announces it. Passive talents have no cost and no limit and are read where they
* apply rather than clicked here.
*/
export async function useTalent(actor, talent) {
if (!actor || !talent) return null;
if (!talentAvailable(talent)) {
ui.notifications?.warn(game.i18n.format("RINGBRP.Talent.Spent", { name: talent.name }));
return null;
}
const cost = Number(talent.system.coherenceCost) || 0;
if (cost && isAcross() === false && talent.system.category === "anomalous") {
// anomalous talents reach for the other side; using them at home is worse
ui.notifications?.info(game.i18n.localize("RINGBRP.Talent.AtHome"));
}
const per = Number(talent.system.usesPerCase) || 0;
if (per) await talent.update({ "system.usedThisCase": (Number(talent.system.usedThisCase) || 0) + 1 });
if (cost) await loseCoherence(actor, "usedTransposed", { amount: cost, note: talent.name });
await ChatMessage.create({
speaker: ChatMessage.getSpeaker({ actor }),
content: `<div class="ringbrp-chat rb-talentcard">
<h3 class="rb-chat-title">${talent.name}</h3>
<p>${talent.system.summary ?? ""}</p>
<p class="rb-chat-flavour">${talent.system.rule ?? ""}</p>
${per ? `<div class="rb-chat-bands"><span>${game.i18n.format("RINGBRP.Talent.UsesLeft",
{ n: per - (Number(talent.system.usedThisCase) || 0) })}</span></div>` : ""}
</div>`
});
return talent;
}
/** Requisition: trade one track against the other. An active, decision-shaped talent. */
export async function requisition(actor, direction = "clearanceToResources") {
const cf = activeCaseFile();
const t = hasTalent(actor, "requisition");
if (!cf || !t || !talentAvailable(t)) return null;
const s = cf.system;
if (direction === "clearanceToResources" && (s.power?.value ?? 0) >= 1) {
await cf.update({ "system.power.value": s.power.value - 1,
"system.supply.value": Math.min(s.supply.max, s.supply.value + 2) });
} else if (direction === "resourcesToClearance" && (s.supply?.value ?? 0) >= 2) {
await cf.update({ "system.supply.value": s.supply.value - 2,
"system.power.value": Math.min(s.power.max, s.power.value + 1) });
} else {
ui.notifications?.warn(game.i18n.localize("RINGBRP.Talent.NoTrade"));
return null;
}
return useTalent(actor, t);
}
/** Clean Scene: arm it, and the next Containment loss this case is cancelled. */
export async function armCleanScene(actor) {
const t = hasTalent(actor, "clean_scene");
if (!t || !talentAvailable(t)) return null;
const cf = activeCaseFile();
if (cf) await cf.setFlag("ringbrp", "cleanSceneArmedBy", actor.id);
return useTalent(actor, t);
}
/** Reset every talent's per-case usage. Called at end of case. */
export async function resetTalents(actor) {
const used = (actor?.items ?? []).filter(i => i.type === "talent" && Number(i.system.usedThisCase) > 0);
for (const t of used) await t.update({ "system.usedThisCase": 0 });
return used.length;
}
/* -------------------------------------------- */
/* Generation */
/* -------------------------------------------- */
/* ---------------- roles, trades and issue ---------------- */
// Moved to postings.mjs so the guards, the rulebook and the tests can import
// them rather than regex them out of this file. Re-exported here because the
// public API and existing macros expect them on the system module.
// `export … from` re-exports WITHOUT creating local bindings, so the engine's own
// references to ROLES were undefined at load. Import for use, then export for the API.
import {
ROLES, TRADES, CHARACTERISTIC_DICE, INDUCTION, STANDING_ISSUE, TIER_ISSUE,
PERSONAL_EFFECTS, TIERS, THREATS, SPECIES, NAME_POOLS, TRADE_BANDS
} from "./postings.mjs";
export {
ROLES, TRADES, CHARACTERISTIC_DICE, INDUCTION, STANDING_ISSUE, TIER_ISSUE,
PERSONAL_EFFECTS, TIERS, THREATS, SPECIES, NAME_POOLS, TRADE_BANDS
};
const rndInt = (lo, hi) => lo + Math.floor(Math.random() * (hi - lo + 1));
const pick = arr => arr[Math.floor(Math.random() * arr.length)];
const pickN = (arr, n) => {
const a = [...arr]; const out = [];
while (out.length < n && a.length) out.push(a.splice(Math.floor(Math.random() * a.length), 1)[0]);
return out;
};
async function rollTotal(formula) { return (await new Roll(formula).evaluate()).total; }
/**
* Build a fully formed, playable agent. EVERY option is optional: called with no
* arguments it produces a complete random character who could be dropped into a
* case immediately. Pass any subset to pin those choices and leave the rest random.
*/
export async function generateCharacter({
name = null, role = null, trade = null, tier = "officer", species = null,
rank = null, extraTalents = [], characteristics = null, toChat = true
} = {}) {
// A TRADE character is built from where they came from plus the induction course,
// rather than from a posting. Everything downstream — kit, talents, bonus points —
// is the same machinery, so `R` is whichever table was asked for.
const isTrade = !!(trade && TRADES[trade]);
// An unrecognised role used to reach `R.stat` as undefined and throw, which turns
// a typo in a macro into a stack trace. Fall back the way tier already does.
const roleKey = isTrade ? trade
: ((role && ROLES[role]) ? role : pick(Object.keys(ROLES)));
const tierKey = TIERS[tier] ? tier : "officer";
const R = isTrade ? TRADES[trade] : ROLES[roleKey];
const T = TIERS[tierKey];
// Baseline is common but not universal; a bare call should sometimes surprise you.
const SP = SPECIES[species ?? pick(["baseline","baseline","baseline","vesh","cadence"])] ?? SPECIES.baseline;
// characteristics: BRP rolls, nudged so the role's key stat is actually good
const ch = {};
// Characteristics rolled by hand at the table are taken exactly as they fell: no
// nudging the key stat, no species shift applied twice. If you rolled it, it is
// yours, and that is the entire point of having rolled it.
if (characteristics) {
for (const k of Object.keys(CHARACTERISTIC_DICE)) {
ch[k] = Math.max(1, Number(characteristics[k]) || 0);
}
} else {
// Exactly the eight the data model defines. This used to roll `app` and `mas`,
// which do not exist, so the Vesh APP shift was modifying a dead field.
for (const [k, f] of Object.entries(CHARACTERISTIC_DICE)) ch[k] = await rollTotal(f);
// Best of the original and two focused rolls: the role's stat is never weak,
// and Containment is strong enough to carry what Containment is issued.
ch[R.stat] = Math.max(ch[R.stat], await rollTotal("2d6+6"), await rollTotal("2d6+6"));
for (const [k, d] of Object.entries(SP.shift ?? {})) ch[k] = Math.max(3, (ch[k] ?? 10) + d);
}
const finalName = name ?? `${pick(NAME_POOLS.GIVEN)} ${pick(NAME_POOLS.FAMILY)}`;
const age = rndInt(26, 54);
const actor = await Actor.create({
name: finalName, type: "character",
img: portraitFor(SP.profile),
// R-214. Foundry defaults actorLink to FALSE, and this never set it — so every
// agent the in-game generator produced was an unlinked token. Put one on the
// map and the damage, the wounds and the Major Wound all landed on the token's
// private copy while the player's sheet stayed pristine. The pack builder has
// always linked characters; this path did not. An agent is one person.
prototypeToken: { name: finalName, actorLink: true, displayName: 20, disposition: 1 },
system: {
characteristics: Object.fromEntries(Object.entries(ch).map(([k, v]) => [k, { value: v }])),
speciesProfile: SP.profile,
rank: rank ?? T.rank,
age: { chronological: age, physiological: age },
biography: buildBiography({ name: finalName, role: R, tier: T, species: SP, age })
}
});
// skills from the compendium, rated by tier
const pack = await game.packs.get("ringbrp.skills").getDocuments();
const byKey = new Map(pack.map(d => [d.system.specialisationId
? `${d.system.familyId}:${d.system.specialisationId}` : d.system.familyId, d]));
const items = [];
const add = (key, range) => {
const doc = byKey.get(key); if (!doc) return;
items.push({ ...doc.toObject(), system: { ...doc.system, value: rndInt(range[0], range[1]) } });
};
// A trade is prior career and does not scale with Custodian rank; a posting does.
const coreBand = isTrade ? TRADE_BANDS.core : T.core;
const supportBand = isTrade ? TRADE_BANDS.support : T.support;
const trainedKeys = new Set([...R.core, ...R.support]);
for (const k of R.core) add(k, coreBand);
for (const k of R.support) add(k, supportBand);
// The agency half. Added AFTER the trade, and skipped where the trade already
// covers it — a former police officer does not un-learn their sidearm to be taught
// it again at a worse number.
if (isTrade) {
for (const k of INDUCTION.skills) {
const doc = byKey.get(k); if (!doc) continue;
if (items.some(i => i.name === doc.name)) continue;
// Induction IS the department's, so it improves with service.
add(k, INDUCTION.bandByTier?.[tierKey] ?? INDUCTION.band);
trainedKeys.add(k);
}
}
// spread the tier's bonus points over the core skills so no two agents are identical
const language = byKey.get("language:trade_cant");
if (language && !items.some(i => i.name === language.name)) {
items.push({ ...language.toObject(), system: { ...language.system, value: rndInt(40, 80) } });
}
// talents: the role's leaning, plus anything the species always has
const talentPack = await game.packs.get("ringbrp.talents").getDocuments();
const wanted = [...pickN(R.talents, T.talents), ...(SP.talents ?? []), ...extraTalents];
for (const key of [...new Set(wanted)]) {
const doc = talentPack.find(d => d.system.talentId === key);
if (doc) items.push(doc.toObject());
}
// kit
const kitDocs = [];
for (const packName of ["weapons", "armour", "gear"]) {
kitDocs.push(...await game.packs.get(`ringbrp.${packName}`).getDocuments());
}
const missingKit = [];
// Standing issue, then the posting's kit, then what seniority adds, then one
// thing that is the agent's own. Duplicates fall out on the name check below.
const issue = [...STANDING_ISSUE, ...R.kit,
...(isTrade ? INDUCTION.kit : []),
...(TIER_ISSUE[tierKey] ?? []), pick(PERSONAL_EFFECTS)];
for (const key of issue) {
const doc = kitDocs.find(d => d.name && keyMatches(d, key));
if (!doc) { missingKit.push(key); continue; }
if (items.some(i => i.name === doc.name)) continue;
items.push({ ...doc.toObject(), system: { ...doc.system, equipped: true, carried: true } });
}
if (missingKit.length) {
console.error(`Custodians | ${R.label}: no catalogue item for kit ${missingKit.join(", ")}`);
ui.notifications?.error(game.i18n.format("RINGBRP.Notify.MissingKit",
{ role: R.label, keys: missingKit.join(", ") }));
}
// A talent that modifies a skill the character does not have is dead on arrival
// — this is the Cover Work / Fast Talk bug, reproduced by the generator until
// now. Grant the skill any granted talent modifies.
for (const it of items.filter(i => i.type === "talent")) {
const fam = it.system?.skillBonus?.skillFamilyId;
if (!fam) continue;
const already = items.some(i => i.type === "skill"
&& (i.system.specialisationId ? `${i.system.familyId}:${i.system.specialisationId}` : i.system.familyId) === fam);
if (already) continue;
const doc = byKey.get(fam);
if (doc) items.push({ ...doc.toObject(), system: { ...doc.system, value: rndInt(T.support[0], T.support[1]) } });
}
// Everyone has hands. Brawl is a skill every character carries and "Punch / kick"
// was issued to none of them, so an unarmed attack could not roll damage.
const punch = kitDocs.find(d => d.flags?.ringbrp?.key === "punch");
if (punch && !items.some(i => i.name === punch.name)) {
items.push({ ...punch.toObject(), system: { ...punch.system, equipped: true, carried: true } });
}
// The anchor and the handset are part of STANDING_ISSUE above — every officer
// carries both, and the anchor is the character hook and the only way back.
// Reserve ammunition, so a reload is a real option rather than a declared one.
for (const it of items.filter(i => i.type === "weapon" && Number(i.system?.ammo?.capacity))) {
it.system = { ...it.system, ammo: { ...it.system.ammo, reserve: (Number(it.system.ammo.capacity) || 0) * 2 } };
}
if (items.length) await actor.createEmbeddedDocuments("Item", items);
await grantFullSkillList(actor); // every skill visible, untrained ones at base
await stowOverload(actor);
// Bonus points, spent after creation so derived values are live — and spent ONLY
// on skills this character actually trains. `grantFullSkillList` runs just above
// and puts all fifty-nine skills on the sheet at their printed base, so picking
// from `actor.items` sprayed an officer's twenty points across skills the posting
// has nothing to do with. The comment here has always said "over the core skills";
// the code has never done it.
let pts = T.bonusPts;
const keyOf = i => i.system.specialisationId
? `${i.system.familyId}:${i.system.specialisationId}` : i.system.familyId;
const own = actor.items.filter(i => i.type === "skill" && trainedKeys.has(keyOf(i)));
while (pts > 0 && own.length) {
const sk = pick(own); const spend = Math.min(pts, rndInt(3, 10));
await sk.update({ "system.value": (Number(sk.system.value) || 0) + spend });
pts -= spend;
}
if (toChat) await announceGenerated(actor, `${T.label} · ${R.label} · ${SP.label}`);
return actor;
}
/**
* Match a compendium item to a generator kit key.
*
* This used to be fuzzy matching on the display name against a hand-maintained
* alias table, and it dropped anything the table had not heard of — silently. A
* Stores Officer issued five things received one, an Overwatch Officer never got
* the rifle they are named for, and `climbing_kit` was mapped to "breaching
* charges" outright. Items now carry their catalogue key in flags, so the match
* is exact; the name pass is only a fallback for anything built before the flag
* existed, and a key that resolves to nothing is now reported rather than lost.
*/
function keyMatches(doc, key) {
const stamped = doc.flags?.ringbrp?.key;
if (stamped) return stamped === key;
return doc.name?.toLowerCase().includes(key.replace(/_/g, " "));
}
/**
* Build an NPC, a monster, or any of the things in between. Same contract: no
* arguments gives you something usable; every knob is available if you want it.
*/
/** Unknown table key: fall back to a sane default and tell the user which. */
function warnKey(kind, given, fallback) {
console.warn(`Custodians | unknown ${kind} "${given}" — using "${fallback}"`);
ui.notifications?.warn(`Unknown ${kind} "${given}" — using "${fallback}".`);
return fallback;
}
export async function generateNPC({
name = null, threat = null, species = null, role = null, toChat = true
} = {}) {
// R-208. The species lookup fell back to baseline on an unknown key, but threat
// and role did not — a caller passing a role the table has never heard of got a
// TypeError on `R.label` rather than an NPC. Fall back consistently, and say so,
// because a silent substitution during a case is worse than a noisy one.
const tKey = THREATS[threat] ? threat : (threat ? warnKey("threat", threat, "trained") : pick(Object.keys(THREATS)));
const TH = THREATS[tKey];
const SP = SPECIES[species ?? pick(["baseline","baseline","baseline","vesh","cadence"])] ?? SPECIES.baseline;
const roleKey = ROLES[role] ? role : (role ? warnKey("role", role, "lead") : pick(Object.keys(ROLES)));
const R = ROLES[roleKey];
const ch = {};
for (const [k, f] of Object.entries({ str: "3d6", con: "3d6", siz: "2d6+6", int: "2d6+6",
pow: "3d6", dex: "3d6", cha: "3d6", edu: "2d6+3" })) ch[k] = await rollTotal(f);
for (const [k, d] of Object.entries(SP.shift ?? {})) ch[k] = Math.max(3, (ch[k] ?? 10) + d);
if (tKey === "anomalous" || tKey === "apex") { ch.str += 4; ch.con += 4; ch.pow += 4; ch.cha = Math.max(3, ch.cha - 6); }
const finalName = name ?? `${pick(NAME_POOLS.NPC_NAMES[tKey] ?? ["Figure"])}`;
const actor = await Actor.create({
name: finalName, type: "npc",
img: portraitFor(SP.profile, true),
// Deliberately NOT linked: one statblock stands up four bodies, and each needs
// its own hit points.
prototypeToken: { name: finalName, actorLink: false, displayName: 20, disposition: -1 },
system: {
characteristics: Object.fromEntries(Object.entries(ch).map(([k, v]) => [k, { value: v }])),
speciesProfile: SP.profile,
naturalArmour: { value: TH.armour },
role: `${TH.label} · ${R.label}`
}
});
const pack = await game.packs.get("ringbrp.skills").getDocuments();
const byKey = new Map(pack.map(d => [d.system.specialisationId
? `${d.system.familyId}:${d.system.specialisationId}` : d.system.familyId, d]));
const items = [];
for (const k of [...R.core, ...pickN(R.support, 2)]) {
const doc = byKey.get(k); if (!doc) continue;
items.push({ ...doc.toObject(), system: { ...doc.system, value: rndInt(TH.skill[0], TH.skill[1]) } });
}
// Attack packages: the weapon and the skill that fires it, together.
const weapons = await game.packs.get("ringbrp.weapons").getDocuments();
for (const atk of TH.attacks ?? []) {
const doc = weapons.find(d => keyMatches(d, atk.weapon));
if (!doc) continue;
items.push({ ...doc.toObject(), system: { ...doc.system, equipped: true } });
const sk = byKey.get(atk.skill);
if (sk && !items.some(i => i.name === sk.name)) {
items.push({ ...sk.toObject(), system: { ...sk.system, value: rndInt(TH.skill[0], TH.skill[1]) } });
}
}
// R-213 again, in the one construction path it was not applied to. The pack
// builder and generateCharacter both issue the unarmed attack; this did not, so
// a generated adversary armed only with a pistol had literally nothing to do at
// close quarters — a whole fight ran with four of them standing there.
//
// This runs AFTER the attack packages on purpose. A creature whose attack IS
// Brawl — the anomalous grasp, the apex — has had its full rating set above and
// keeps it. Only an adversary falling back on its fists gets the reduced figure.
{
const punch = weapons.find(d => d.flags?.ringbrp?.key === "punch");
if (punch && !items.some(i => i.name === punch.name)) {
items.push({ ...punch.toObject(), system: { ...punch.system, equipped: true } });
}
const brawl = byKey.get("brawl");
if (brawl && !items.some(i => i.name === brawl.name)) {
items.push({ ...brawl.toObject(),
system: { ...brawl.system,
value: brawlFallbackFor(rndInt(TH.skill[0], TH.skill[1])) } });
}
}
// Species talents are not optional flavour — a Cadence without Distributed has a
// vital location it should not have, and fights wrongly. They come first.
const talents = await game.packs.get("ringbrp.talents").getDocuments();
for (const key of SP.talents ?? []) {
const doc = talents.find(t => t.system.talentId === key);
if (doc) items.push(doc.toObject());
}
if (TH.talents) {
for (const doc of pickN(talents.filter(t => t.system.category === "anomalous"), TH.talents)) {
if (!items.some(i => i.name === doc.name)) items.push(doc.toObject());
}
}
if (items.length) await actor.createEmbeddedDocuments("Item", items);
if (toChat) await announceGenerated(actor, `${TH.label} · ${SP.label} · ${R.label}`);
return actor;
}
/**
* Everyone is ISSUED a full kit; nobody carries all of it. Anything beyond free
* carry is stowed (carried: false) heaviest-first, so a Containment agent does not
* begin every case permanently slower for owning her own breaching charges.
* Weapon and armour are never stowed — you keep what keeps you alive.
*/
async function stowOverload(actor) {
const free = Number(actor.system.freeCarryKg) || 0;
const isAnchor = i => /anchor/i.test(i.name); // the character hook stays on the person
// A secondary melee weapon is a tool as much as a weapon and can be slung.
const primary = actor.items.find(i => i.type === "weapon" && i.system?.isRanged)
?? actor.items.find(i => i.type === "weapon");
const stowable = actor.items
.filter(i => (["gear", "device"].includes(i.type) || (i.type === "weapon" && i.id !== primary?.id))
&& i.system?.carried !== false && !isAnchor(i))
.sort((a, b) => (Number(b.system.massKg) || 0) - (Number(a.system.massKg) || 0));
const stowed = [];
for (const it of stowable) {
const over = (Number(actor.system.carriedKg) || 0) - free;
if (over <= 0) break;
// Only stow when it actually helps. Shedding a 0.1 kg keepsake to fix a
// 4 kg overload achieves nothing except losing the keepsake.
if ((Number(it.system.massKg) || 0) < Math.min(1, over)) continue;
await it.update({ "system.carried": false });
stowed.push(it.name);
}
return stowed;
}
/** A portrait that varies by species, so a party is not five identical squares. */
function portraitFor(profile, isNpc = false) {
const n = i => `systems/ringbrp/icons/${i}.svg`;
if (isNpc) return n(`portrait_n${rndInt(0, 4)}`);
if (profile === "vesh") return n(`portrait_v${rndInt(0, 4)}`);
if (profile === "cadence") return n(`portrait_c${rndInt(0, 4)}`);
return n(`portrait_b${rndInt(0, 9)}`);
}
/** A short service record. Not literature, but not a blank box either. */
function buildBiography({ name, role, tier, species, age }) {
const origin = ["a coastal town nobody visits twice", "an unremarkable suburb",
"a farm that is now a car park", "a city that has forgotten them", "two rooms above a shop",
"a rig in the North Sea", "a mining settlement", "somewhere that made them careful"];
const recruited = [
"Recruited after a screening test taken for the money.",
"Recruited after being the only witness who described it accurately.",
"Walked in and asked. Nobody has established how they knew where to walk.",
"Transferred from a department that no longer exists.",
"Recruited from the analysis wing and has never stopped writing reports in their head."
];
const anchor = [
"a landline that still rings", "a dog that is getting old", "a brother who does not know",
"a house they have not sold", "a record they cannot play anywhere else",
"a standing Thursday appointment they have never missed"
];
const note = {
lead: "Reads the window times twice. Has been through more thresholds than the roster admits.",
technical: "Can fix it, and would rather understand it than fix it.",
talker: "Notices when the locals are frightened of the wrong thing.",
medic: "Joined to treat agents. Spends most of the work trying to talk to people.",
heavy: "Completely still until they are not."
}[Object.keys(ROLES).find(k => ROLES[k] === role)] ?? "";
return `<p><strong>${role.label}, ${tier.label}.</strong> ${species.label}, ${age}, out of ${pick(origin)}. ${pick(recruited)}</p>`
+ `<p>${note}</p>`
+ `<p><em>Anchor:</em> ${pick(anchor)}.</p>`;
}
async function announceGenerated(actor, subtitle) {
const s = actor.system;
await ChatMessage.create({
speaker: ChatMessage.getSpeaker({ actor }),
content: `<div class="ringbrp-chat"><h3 class="rb-chat-title">${actor.name}</h3>
<div class="rb-chat-line"><span class="rb-chat-rating">${subtitle}</span></div>
<div class="rb-chat-bands">
<span>HP ${s.hp?.value ?? "?"}/${s.hp?.max ?? "?"}</span>
<span>MW ${s.majorWound?.value ?? "?"}</span>
<span>Reaction ${s.reaction?.base ?? "?"}</span>
<span>${actor.items.filter(i => i.type === "skill").length} skills</span>
<span>${actor.items.filter(i => i.type === "talent").length} talents</span>
</div></div>`,
whisper: ChatMessage.getWhisperRecipients("GM").map(u => u.id)
});
}
/**
* Put the whole catalogue into the world's Items directory so it can be browsed,
* edited and dragged onto sheets. Idempotent: run it as often as you like.
*/
export async function installContent({ notify = true } = {}) {
// Every item pack, not just skills and talents: a GM should be able to browse
// and drag anything the game ships with.
const packs = ["skills", "talents", "weapons", "armour", "gear"];
const made = {};
let total = 0;
const existing = new Set(game.items.map(i => `${i.type}:${i.name}`));
// One folder per pack. Installing everything into a single flat list put 120-odd
// items in one alphabetical column, where a pistol sits between two skills and
// nothing can be found. Folders are reused by name across re-installs so running
// this twice does not produce "Weapons" and "Weapons (2)".
const folderFor = async (name, parentId = null) => {
const label = game.i18n.localize(`RINGBRP.Folder.${name}`);
return game.folders.find(f => f.type === "Item" && f.name === label && (f.folder?.id ?? null) === parentId)
?? await Folder.create({ name: label, type: "Item", sorting: "a", folder: parentId });
};
for (const packName of packs) {
const pack = game.packs.get(`ringbrp.${packName}`);
if (!pack) continue;
const docs = await pack.getDocuments();
const toMake = docs.filter(d => !existing.has(`${d.type}:${d.name}`)).map(d => {
existing.add(`${d.type}:${d.name}`);
return d.toObject();
});
if (toMake.length) {
const parent = await folderFor(packName);
// Weapons, armour and gear span centuries now, so they are filed by era
// inside their type. A flintlock and a gauss rifle are both weapons and
// nobody wants them adjacent in one alphabetical column.
const eras = new Set(toMake.map(o => o.flags?.ringbrp?.era).filter(Boolean));
const sub = {};
if (eras.size > 1) {
for (const era of eras) sub[era] = await folderFor(`era.${era}`, parent.id);
}
await Item.createDocuments(toMake.map(o => ({
...o, folder: (sub[o.flags?.ringbrp?.era] ?? parent).id
})));
}
made[packName] = toMake.length;
total += toMake.length;
}
// Anything already installed flat before this change is filed too, so an existing
// world tidies itself instead of keeping two conventions side by side.
const byType = { skill: "skills", talent: "talents", weapon: "weapons",
armour: "armour", gear: "gear", device: "gear" };
const orphans = game.items.filter(i => !i.folder && byType[i.type]);
if (orphans.length) {
const cache = {};
const updates = [];
for (const i of orphans) {
const key = byType[i.type];
cache[key] ??= await folderFor(key);
updates.push({ _id: i.id, folder: cache[key].id });
}
await Item.updateDocuments(updates);
}
if (notify) ui.notifications?.info(game.i18n.format("RINGBRP.Install.Done", { n: total }));
return made;
}
/**
* Give an actor the FULL skill catalogue at printed base, keeping any rating it
* already has. This is how a BRP sheet is meant to read: every skill visible with
* its base chance, so a player can see what they could try, not only what they
* trained. Without it a sheet shows eleven skills across seven categories and
* looks broken.
*/
export async function grantFullSkillList(actor) {
if (!actor) return 0;
const pack = await game.packs.get("ringbrp.skills").getDocuments();
const have = new Set(actor.items.filter(i => i.type === "skill").map(i =>
i.system.specialisationId ? `${i.system.familyId}:${i.system.specialisationId}` : i.system.familyId));
const add = pack.filter(d => {
const k = d.system.specialisationId ? `${d.system.familyId}:${d.system.specialisationId}` : d.system.familyId;
return !have.has(k);
}).map(d => {
const o = d.toObject();
o.system.value = Number(o.system.base?.value ?? o.system.base ?? 0) || 0; // untrained: printed base
return o;
});
if (add.length) await actor.createEmbeddedDocuments("Item", add);
return add.length;
}
/**
* The generation dialog. Opens with everything already chosen at random, so the
* fastest path is: open, press Create. Change anything you care about and leave
* the rest. "Randomise" re-rolls the unlocked choices.
*/
/**
* ROLL THEM YOURSELF.
*
* The generator can produce eight characteristics in a millisecond and nobody at a
* table has ever wanted that. This is the other way: one die roll at a time, each one
* announced to chat so the dice actually fall — Dice So Nice and all — and the number
* lands in front of you before you know what the next one is.
*
* Purely theatre. It returns exactly what the automatic path would have returned, and
* a character rolled this way is taken AS ROLLED: no nudging the key stat upward, no
* species shift applied on top. You rolled it; it is yours.
*/
export async function rollCharacteristicsDialog({ actorName = "" } = {}) {
const keys = Object.keys(CHARACTERISTIC_DICE);
const rows = keys.map(k => `
<tr data-ch="${k}">
<th>${game.i18n.localize(`RINGBRP.Char.${k}`)}</th>
<td class="rb-roll-formula">${CHARACTERISTIC_DICE[k]}</td>
<td class="rb-roll-value" data-value="">&mdash;</td>
<td><a class="rb-cbtn rb-roll-one" data-ch="${k}">${game.i18n.localize("RINGBRP.Gen.RollIt")}</a></td>
</tr>`).join("");
const content = `<form class="rb-dialog rb-rollup">
<p class="notes">${game.i18n.localize("RINGBRP.Gen.RollHint")}</p>
<table class="rb-table rb-rolltable"><tbody>${rows}</tbody></table>
<div class="rb-rollup-foot">
<a class="rb-cbtn rb-roll-rest">${game.i18n.localize("RINGBRP.Gen.RollRest")}</a>
<span class="rb-rollup-count" data-count></span>
</div>
</form>`;
return new Promise(resolve => {
const dlg = new (rbDialogClass())({
title: actorName
? game.i18n.format("RINGBRP.Gen.RollFor", { name: actorName })
: game.i18n.localize("RINGBRP.Gen.RollTitle"),
content,
render: html => {
const el = html[0] ?? html;
const done = () => keys.filter(k => el.querySelector(`[data-ch="${k}"] .rb-roll-value`)?.dataset.value).length;
const refresh = () => {
const n = done();
el.querySelector("[data-count]").textContent =
game.i18n.format("RINGBRP.Gen.RolledCount", { n, of: keys.length });
const ok = el.closest(".window-app")?.querySelector("[data-button=take], .dialog-button.take button, button.take");
if (ok) ok.disabled = n < keys.length;
};
const rollOne = async k => {
const cell = el.querySelector(`tr[data-ch="${k}"] .rb-roll-value`);
if (!cell || cell.dataset.value) return; // rolled is rolled
const roll = rbDressDice(await new Roll(CHARACTERISTIC_DICE[k]).evaluate());
await roll.toMessage({
flavor: game.i18n.format("RINGBRP.Gen.RolledChar",
{ ch: game.i18n.localize(`RINGBRP.Char.${k}`) })
});
cell.dataset.value = String(roll.total);
cell.textContent = String(roll.total);
cell.classList.add("rb-rolled");
refresh();
};
el.querySelectorAll(".rb-roll-one").forEach(a =>
a.addEventListener("click", () => rollOne(a.dataset.ch)));
el.querySelector(".rb-roll-rest").addEventListener("click", async () => {
for (const k of keys) await rollOne(k);
});
refresh();
},
buttons: {
take: { label: game.i18n.localize("RINGBRP.Gen.TakeThem"),
callback: html => {
const el = html[0] ?? html;
const out = {};
for (const k of keys) {
const v = el.querySelector(`tr[data-ch="${k}"] .rb-roll-value`)?.dataset.value;
if (!v) return resolve(null); // incomplete: cancel
out[k] = Number(v);
}
resolve(out);
} },
cancel: { label: game.i18n.localize("RINGBRP.Dialog.Cancel"), callback: () => resolve(null) }
},
default: "take",
close: () => resolve(null)
});
dlg.render(true);
});
}
export async function generateDialog(kind = "character") {
const isPC = kind === "character";
const opts = (obj, sel) => Object.entries(obj).map(([k, v]) =>
`<option value="${k}" ${k === sel ? "selected" : ""}>${v.label}</option>`).join("");
const rnd = a => a[Math.floor(Math.random() * a.length)];
const roleSel = rnd(Object.keys(ROLES));
const tradeSel = rnd(Object.keys(TRADES));
const tierSel = "officer";
const threatSel = rnd(Object.keys(THREATS));
const content = `<form class="rb-dialog rb-gen">
<div class="form-group"><label>${game.i18n.localize("RINGBRP.Gen.Name")}</label>
<input type="text" name="name" placeholder="${game.i18n.localize("RINGBRP.Gen.NameHint")}"/></div>
<div class="form-group"><label>${game.i18n.localize("RINGBRP.Gen.Species")}</label>
<select name="species"><option value="random">${game.i18n.localize("RINGBRP.Gen.RandomEach")}</option>
${opts(SPECIES, "baseline")}</select></div>
${isPC ? `
<div class="form-group rb-source"><label>${game.i18n.localize("RINGBRP.Gen.Source")}</label>
<select name="source">
<option value="posting" selected>${game.i18n.localize("RINGBRP.Gen.SourcePosting")}</option>
<option value="trade">${game.i18n.localize("RINGBRP.Gen.SourceTrade")}</option>
</select></div>` : ""}
<div class="form-group" data-pick="posting"><label>${game.i18n.localize("RINGBRP.Gen.Role")}</label>
<select name="role"><option value="random" selected>${game.i18n.localize("RINGBRP.Gen.RandomEach")}</option>
${opts(ROLES, roleSel)}</select></div>
${isPC ? `
<div class="form-group" data-pick="trade" style="display:none"><label>${game.i18n.localize("RINGBRP.Gen.Trade")}</label>
<select name="trade"><option value="random" selected>${game.i18n.localize("RINGBRP.Gen.RandomEach")}</option>
${opts(TRADES, tradeSel)}</select></div>` : ""}
<div class="rb-rolecard" data-rolecard>
<p class="rb-rolecard-blurb">${game.i18n.localize("RINGBRP.Gen.RoleRandomBlurb")}</p>
</div>
${isPC ? `
<div class="form-group"><label>${game.i18n.localize("RINGBRP.Gen.Tier")}</label>
<select name="tier">${opts(TIERS, tierSel)}</select></div>
`
: `<div class="form-group"><label>${game.i18n.localize("RINGBRP.Gen.Threat")}</label>
<select name="threat"><option value="random" selected>${game.i18n.localize("RINGBRP.Gen.RandomEach")}</option>
${opts(THREATS, threatSel)}</select></div>`}
<div class="form-group"><label>${game.i18n.localize("RINGBRP.Gen.Count")}</label>
<input type="number" name="count" value="1" min="1" max="12"/></div>
${isPC ? `
<div class="form-group">
<label class="rb-check"><input type="checkbox" name="handroll"/> ${game.i18n.localize("RINGBRP.Gen.HandRoll")}</label>
<p class="notes">${game.i18n.localize("RINGBRP.Gen.HandRollHint")}</p>
</div>` : ""}
<p class="notes">${game.i18n.localize("RINGBRP.Gen.Hint")}</p>
</form>`;
// Names for the card, read from the compendium so the dialog cannot print a
// different name for an item than the sheet does.
const kitNames_ = new Map();
for (const pk of ["weapons", "armour", "gear"]) {
for (const d of await game.packs.get(`ringbrp.${pk}`).getDocuments()) {
const k = d.flags?.ringbrp?.key; if (k) kitNames_.set(k, d.name);
}
}
const skillNames = new Map();
for (const d of await game.packs.get("ringbrp.skills").getDocuments()) {
const id = d.system.specialisationId
? `${d.system.familyId}:${d.system.specialisationId}` : d.system.familyId;
skillNames.set(id, d.name);
}
// A dropdown of twenty-five job titles tells you nothing about what any of them
// does. Describe the posting as it is selected, with the numbers that follow from
// it, so the choice is informed rather than a guess at a name.
const roleCard = (key, table = ROLES) => {
const R = table[key];
if (!R) return `<p class="rb-rolecard-blurb">${game.i18n.localize("RINGBRP.Gen.RoleRandomBlurb")}</p>`;
// Real catalogue names, not tidied-up ids: "Quill and ink horn", not "Quill Ink".
const skill = id => skillNames.get(id) ?? id;
const kitNames = R.kit.map(k => kitNames_.get(k) ?? k.replace(/_/g, " "));
return `<p class="rb-rolecard-blurb">${R.blurb ?? ""}</p>
<dl class="rb-rolecard-facts">
<dt>${game.i18n.localize("RINGBRP.Gen.KeyStat")}</dt>
<dd>${R.stat.toUpperCase()}</dd>
<dt>${game.i18n.localize("RINGBRP.Gen.Trains")}</dt>
<dd>${R.core.map(skill).join(" · ")}</dd>
<dt>${game.i18n.localize("RINGBRP.Gen.Draws")}</dt>
<dd>${kitNames.join(" · ")}</dd>
${table === TRADES ? `
<dt>${game.i18n.localize("RINGBRP.Gen.PlusInduction")}</dt>
<dd>${INDUCTION.skills.map(skill).join(" · ")}
<span class="rb-dim">${INDUCTION.band[0]}&ndash;${INDUCTION.band[1]}%</span></dd>
<dt>${game.i18n.localize("RINGBRP.Gen.PlusIssue")}</dt>
<dd>${INDUCTION.kit.map(k => kitNames_.get(k) ?? k.replace(/_/g, " ")).join(" · ")}</dd>` : ""}
</dl>`;
};
return new Promise(resolve => {
new (rbDialogClass())({
title: game.i18n.localize(isPC ? "RINGBRP.Gen.NewAgent" : "RINGBRP.Gen.NewNPC"),
content,
render: html => {
const el = html[0] ?? html;
const card = el.querySelector("[data-rolecard]");
const roleSel_ = el.querySelector("[name=role]");
const tradeSel_ = el.querySelector("[name=trade]");
const source = el.querySelector("[name=source]");
if (!card) return;
const paint = () => {
const asTrade = source?.value === "trade";
el.querySelector('[data-pick="posting"]').style.display = asTrade ? "none" : "";
const tradeRow = el.querySelector('[data-pick="trade"]');
if (tradeRow) tradeRow.style.display = asTrade ? "" : "none";
card.innerHTML = asTrade
? roleCard(tradeSel_?.value, TRADES)
: roleCard(roleSel_?.value, ROLES);
};
roleSel_?.addEventListener("change", paint);
tradeSel_?.addEventListener("change", paint);
source?.addEventListener("change", paint);
paint();
},
buttons: {
create: {
label: game.i18n.localize("RINGBRP.Gen.Create"),
callback: async html => {
const el = html[0] ?? html;
const v = n => el.querySelector(`[name=${n}]`)?.value || null;
const count = Math.max(1, Math.min(12, Number(v("count")) || 1));
// "Random" must be resolved PER ACTOR. Resolving it once and reusing
// the answer turned "make four" into four copies of one character.
const each = n => {
const val = v(n);
return (!val || val === "random") ? null : val;
};
const made = [];
// Party mode: when several PCs are made with the role left random,
// guarantee coverage rather than rolling five Field Leads.
const asTrade = isPC && el.querySelector("[name=source]")?.value === "trade";
const table = asTrade ? TRADES : ROLES;
const fixed = each(asTrade ? "trade" : "role");
const roster = (isPC && !fixed && count > 1)
? pickN(Object.keys(table), Math.min(count, Object.keys(table).length))
: [];
// Rolled by hand, once per character — the whole point is watching each
// one fall, so eight rolls happen before the next agent is started.
const byHand = isPC && el.querySelector("[name=handroll]")?.checked;
for (let i = 0; i < count; i++) {
const name = count > 1 && v("name") ? `${v("name")} ${i + 1}` : v("name");
const which = fixed ?? roster[i] ?? pick(Object.keys(table));
let characteristics = null;
if (byHand) {
characteristics = await rollCharacteristicsDialog({
actorName: name || game.i18n.format("RINGBRP.Gen.Nth", { n: i + 1 }) });
if (!characteristics) break; // cancelled: stop, keep what exists
}
made.push(isPC
? await generateCharacter({ name, characteristics,
role: asTrade ? null : which,
trade: asTrade ? which : null,
tier: each("tier") ?? "officer",
species: each("species") })
: await generateNPC({ name, role: which, threat: each("threat"),
species: each("species") }));
}
if (made.length === 1) made[0].sheet.render(true);
ui.notifications?.info(game.i18n.format("RINGBRP.Gen.Made", { n: made.length }));
resolve(made);
}
},
cancel: { label: game.i18n.localize("RINGBRP.Dialog.Cancel"), callback: () => resolve([]) }
},
default: "create", close: () => resolve([])
}).render(true);
});
}
/** Install button on the Items sidebar, so the catalogue is browsable. */
/**
* Kit dropped onto a character is READIED, not left in the kitbag.
*
* The catalogue packs ship armour and weapons with `equipped: false`, which is
* right for a compendium entry and wrong the moment one lands on an actor: a vest
* dragged onto a sheet contributed 0 armour and a pistol never reached the readied
* strip, both silently. Nothing was broken enough to notice — the item was there,
* the number simply did not move.
*
* Only the FIRST of each kind is readied. Drop a spare vest or a second pistol and
* it stays stowed, because stacking armour points or crowding the strip by accident
* is the opposite failure. Anything that arrives explicitly equipped — pregens,
* generated characters — is left exactly as it is.
*/
Hooks.on("preCreateItem", (item, data) => {
if (!["armour", "weapon"].includes(item.type)) return;
const actor = item.parent;
if (!(actor instanceof Actor)) return;
if (data?.system?.equipped) return; // already readied on purpose
const alreadyWearing = actor.items.some(i => i.type === item.type && i.system?.equipped);
if (alreadyWearing) return;
item.updateSource({ "system.equipped": true, "system.carried": true });
});
/* ============================================================
* DICE — The Custodians
*
* The set is the setting. The agency keeps a filing system and the game is about
* that filing system being wrong, so the dice carry a ruled registry grid — and
* the further an agent drifts from their own reality, the less the grid agrees
* with itself. An Anchored agent rolls on true rule; an Unmoored one rolls on a
* grid that has started to tear; a Displaced one rolls on something that was never
* filed at all.
*
* The colourset is chosen per roll from the roller's Coherence, so the dice
* themselves report the one number the players are trying not to look at.
* ============================================================ */
const RB_DICE = {
textures: {
"custodians-registry": { name: "Registry", file: "custodians-registry.png" },
"custodians-drift": { name: "Drift", file: "custodians-drift.png" },
"custodians-displaced": { name: "Displaced", file: "custodians-displaced.png" },
"custodians-agency": { name: "Agency", file: "custodians-agency.png" }
},
// one colourset per Coherence band, plus the plain agency set
sets: [
{ name: "custodians-anchored", description: "Custodians — Anchored",
foreground: "#f2ea72", background: "#18268d", outline: "#0c125b",
edge: "#6674d8", texture: "custodians-registry", material: "metal" },
{ name: "custodians-loose", description: "Custodians — Loose",
foreground: "#f2ea72", background: "#16215f", outline: "#0c125b",
edge: "#6674d8", texture: "custodians-registry", material: "plastic" },
{ name: "custodians-adrift", description: "Custodians — Adrift",
foreground: "#ecefee", background: "#141e78", outline: "#0c125b",
edge: "#7a6cc8", texture: "custodians-drift", material: "plastic" },
{ name: "custodians-unmoored", description: "Custodians — Unmoored",
foreground: "#ff7a5c", background: "#101a6e", outline: "#08103f",
edge: "#ff7a5c", texture: "custodians-drift", material: "glass" },
{ name: "custodians-displaced", description: "Custodians — Displaced",
foreground: "#c9b6ff", background: "#0c0e3c", outline: "#000000",
edge: "#8a6cc0", texture: "custodians-displaced", material: "glass" },
{ name: "custodians-agency", description: "Custodians — Agency issue",
foreground: "#f2ea72", background: "#16215f", outline: "#0c125b",
edge: "#a8b2e3", texture: "custodians-agency", material: "metal" }
]
};
/** Which set an agent rolls, from the band they are in. */
export function diceSetFor(actor) {
const band = coherenceBand(actor?.system?.coherence?.value ?? 10);
return `custodians-${band?.id ?? "anchored"}`;
}
/** Tag a Roll so Dice So Nice draws it in the roller's colours. */
function rbDressDice(roll, actor) {
if (!roll?.dice?.length) return roll;
const colorset = diceSetFor(actor);
for (const d of roll.dice) {
d.options.colorset = colorset;
d.options.appearance = { ...(d.options.appearance ?? {}), colorset };
}
return roll;
}
Hooks.once("diceSoNiceReady", async (dice3d) => {
try {
dice3d.addSystem({ id: "custodians", name: "The Custodians" }, "preferred");
// addTexture returns a Promise and the colourset that references a texture must
// not be registered before it resolves, or the die renders untextured.
await Promise.all(Object.entries(RB_DICE.textures).map(([id, t]) =>
dice3d.addTexture(id, {
name: t.name, composite: "source-over",
source: `systems/ringbrp/dice/${t.file}`
})));
for (const set of RB_DICE.sets) {
dice3d.addColorset({
...set, category: "The Custodians",
font: "Michroma", visibility: "visible"
}, set.name === "custodians-anchored" ? "default" : "showcase");
}
// The percentile pair is the game's core roll, so it is the one worth dressing.
dice3d.addDicePreset({
type: "d100", system: "custodians",
labels: ["00", "10", "20", "30", "40", "50", "60", "70", "80", "90"]
});
dice3d.addDicePreset({ type: "d10", system: "custodians",
labels: ["0", "1", "2", "3", "4", "5", "6", "7", "8", "9"] });
for (const t of ["d4", "d6", "d8", "d12", "d20"]) {
const n = Number(t.slice(1));
dice3d.addDicePreset({ type: t, system: "custodians",
labels: Array.from({ length: n }, (_, i) => String(i + 1)) });
}
console.log("Custodians | dice registered with Dice So Nice");
} catch (err) {
console.warn("Custodians | could not register dice", err);
}
});
Hooks.on("renderItemDirectory", (app, html) => {
const root = html instanceof HTMLElement ? html : html?.[0];
if (!root || !game.user.isGM || root.querySelector(".rb-genbar")) return;
const bar = document.createElement("div");
bar.className = "rb-genbar";
bar.innerHTML = `<button type="button" class="rb-genbtn">${
game.i18n.localize("RINGBRP.Install.Button")}</button>`;
bar.querySelector("button").addEventListener("click", () => installContent());
(root.querySelector(".directory-header") ?? root).prepend(bar);
});
/** Buttons on the Actors sidebar. One click each; the dialog is pre-randomised. */
Hooks.on("renderActorDirectory", (app, html) => {
const root = html instanceof HTMLElement ? html : html?.[0];
if (!root || !game.user.isGM || root.querySelector(".rb-genbar")) return;
const bar = document.createElement("div");
bar.className = "rb-genbar";
// R-219. missionDialog() was exported, complete, and reachable from nothing — the
// whole case generator existed behind a console call. It sits with the other two
// generators, because a case IS an actor and this is where actors are made.
bar.innerHTML = `<button type="button" class="rb-genbtn" data-kind="character">${
game.i18n.localize("RINGBRP.Gen.NewAgent")}</button>
<button type="button" class="rb-genbtn" data-kind="npc">${
game.i18n.localize("RINGBRP.Gen.NewNPC")}</button>
<button type="button" class="rb-genbtn rb-genbtn-case" data-kind="case">${
game.i18n.localize("RINGBRP.Mission.NewCase")}</button>`;
bar.querySelectorAll("button").forEach(b =>
b.addEventListener("click", async () => {
if (b.dataset.kind !== "case") return generateDialog(b.dataset.kind);
const cf = await missionDialog();
if (cf) cf.sheet?.render(true);
}));
(root.querySelector(".directory-header") ?? root).prepend(bar);
});
/* -------------------------------------------- */
/* Sheets */
/* -------------------------------------------- */
const { ActorSheet, ItemSheet } = foundry.appv1.sheets;
class RingBRPCharacterSheet extends ActorSheet {
static get defaultOptions() {
return foundry.utils.mergeObject(super.defaultOptions, {
classes: ["ringbrp", "sheet", "actor", "character"],
template: "systems/ringbrp/templates/character-sheet.hbs",
// The tab rail takes 152px the body used to have. Paying for it here keeps the
// skills list at two columns and every other tab exactly as roomy as before.
width: 972,
height: 880,
tabs: [{ navSelector: ".sheet-tabs", contentSelector: ".sheet-body", initial: "skills" }]
});
}
async getData(options) {
const ctx = await super.getData(options);
const sys = this.actor.system;
ctx.sys = sys;
ctx.RINGBRP = RINGBRP;
ctx.isCharacter = this.actor.type === "character";
ctx.skillsByCategory = {};
for (const key of Object.keys(RINGBRP.categories)) ctx.skillsByCategory[key] = [];
for (const it of this.actor.items) {
if (it.type !== "skill") continue;
(ctx.skillsByCategory[it.system.categoryId] ??= []).push(it);
}
for (const arr of Object.values(ctx.skillsByCategory)) arr.sort((a, b) => a.name.localeCompare(b.name));
ctx.weapons = this.actor.items.filter(i => i.type === "weapon");
ctx.equippedWeapons = ctx.weapons.filter(w => w.system.equipped);
// A view wrapper rather than the documents themselves: the template needs two
// derived flags and mutating a live Item to carry them is a good way to have
// them turn up somewhere they were never meant to be.
ctx.armours = this.actor.items.filter(i => i.type === "armour").map(i => ({
id: i.id, name: i.name, img: i.img, system: i.system,
raisedNow: !!(i.system.shield && i.system.raised),
brokenNow: !!(i.system.shield && shieldBroken(i.system.degradation?.current,
i.system.degradation?.max))
}));
// Transport is listed but never weighed: a vehicle is not carried, it carries.
// Access the team holds. Kept beside the gear because that is what it is: a
// thing you carry, spend, and can lose.
ctx.authorities = this.actor.items.filter(i => i.type === "authority").map(a => ({
...a, id: a.id, name: a.name, img: a.img, system: a.system,
scopeLabel: game.i18n.localize(`RINGBRP.Scope.${a.system.scope}`),
flagCount: (a.system.redFlags ?? []).length,
dossierCount: (a.system.dossier ?? []).length,
merged: (Number(a.system.mergeSteps) || 0) > 0
}));
ctx.vehicles = this.actor.items.filter(i => i.type === "vehicle").map(v => ({
...v, id: v.id, name: v.name, img: v.img, system: v.system,
speedLabel: game.i18n.localize(`RINGBRP.Speed.${v.system.speedBand}`),
rating: this.actor.skillRating(v.system.skillFamilyId, v.system.skillSpecialisationId) ?? 0
}));
ctx.gear = this.actor.items.filter(i => ["gear", "device"].includes(i.type)).map(it => {
if (it.type !== "device") return it;
const fns = Object.keys(it.system.functions ?? {}).map(id => {
const st = deviceFunctionStatus(it, id);
return { id, label: `RINGBRP.Device.Fn.${id}`, usable: st.usable,
why: game.i18n.format(st.reason, { need: st.need, have: st.have }) };
});
// a plain object so Handlebars can read `functions` without shadowing the item
return Object.assign(Object.create(Object.getPrototypeOf(it)), it, { functions: fns });
});
ctx.wounds = sys.wounds ?? [];
ctx.condition = sys.condition ?? "up";
ctx.conditionLabel = game.i18n.localize(`RINGBRP.Condition.${ctx.condition}`);
ctx.dyingNow = ctx.condition === "dying";
ctx.deadNow = ctx.condition === "dead";
ctx.dyingRounds = Number(sys.dying?.rounds) || 0;
ctx.dyingLimit = sys.dyingLimit ?? 0;
ctx.stabilised = !!sys.dying?.stabilised;
ctx.lastEntry = sys.dying?.lastEntry
? game.i18n.localize(`RINGBRP.Death.Entry.${sys.dying.lastEntry}`) : "";
ctx.styleLabel = game.i18n.localize(RINGBRP.marksmanship[sys.marksmanship?.style]?.label ?? "");
// The dropdown offered a choice with no stated consequence — two words and no
// hint of what either buys you. Built from RINGBRP.marksmanship so the sheet
// cannot describe a style differently from the way the style actually rolls.
ctx.styleHint = styleHintFor(sys.marksmanship?.style);
ctx.styleHints = Object.keys(RINGBRP.marksmanship).map(k => ({
key: k, label: game.i18n.localize(RINGBRP.marksmanship[k].label),
hint: styleHintFor(k), on: k === sys.marksmanship?.style
}));
ctx.notesHTML = await rbEnrich(sys.notes ?? "");
ctx.bioHTML = await rbEnrich(sys.biography ?? "");
// A talent is ACTIVE if it costs something, is limited, or bends an action.
// Everything else is passive and read where it applies, so it gets no button.
ctx.skillCount = this.actor.items.filter(i => i.type === "skill").length;
ctx.talents = this.actor.items.filter(i => i.type === "talent").map(it => {
const per = Number(it.system.usesPerCase) || 0;
const used = Number(it.system.usedThisCase) || 0;
const active = !!(per || Number(it.system.coherenceCost) || it.system.grantsAction);
const available = !per || used < per;
return Object.assign(Object.create(Object.getPrototypeOf(it)), it, {
active, available,
whyUnavailable: available ? "" : game.i18n.format("RINGBRP.Talent.Spent", { name: it.name })
});
});
ctx.talentGroups = [
{ key: "agency", label: "RINGBRP.Talent.Agency", items: ctx.talents.filter(t => t.system.category === "agency") },
{ key: "field", label: "RINGBRP.Talent.Field", items: ctx.talents.filter(t => t.system.category === "field") },
{ key: "species", label: "RINGBRP.Talent.Species", items: ctx.talents.filter(t => t.system.category === "species") },
{ key: "anomalous", label: "RINGBRP.Talent.Anomalous", items: ctx.talents.filter(t => t.system.category === "anomalous") }
];
// Everything currently modifying this agent's rolls. FOUR sources reach the
// same choke point — Major Wounds, lost locations, Coherence and what is being
// carried — and the banner watched one, then two. Totals are summed per
// category so a wound and a coherence band that both hit everything read as one
// honest figure rather than "all rolls -20% · all rolls -10%".
{
const w = sys.wounded ?? {};
const cohBand = coherenceBand(sys.coherence?.value);
const encPen = Number(sys.encumbrance?.skillPenalty) || 0;
const chips = [
...(ctx.wounds ?? []).map(x => game.i18n.localize(`RINGBRP.Wound.${x.key}.title`)),
...(sys.locations ?? []).filter(l => l.disabled)
.map(l => game.i18n.localize(l.label) + (l.destroyed ? " ✕" : ""))
];
const total = {
all: (w.all || 0) + (cohBand?.all || 0),
manipulation: w.manipulation || 0,
physical: (w.physical || 0) - encPen,
perception: w.perception || 0,
communication: (w.communication || 0) + (cohBand?.comm || 0)
};
if (cohBand && (cohBand.all || cohBand.comm)) chips.push(game.i18n.localize(cohBand.label));
if (encPen) chips.push(game.i18n.localize("RINGBRP.Sheet.Overloaded"));
const bits = [];
const add = (key, label) => { if (total[key]) bits.push(`${game.i18n.localize(label)} ${total[key]}%`); };
add("all", "RINGBRP.Sheet.AllRolls");
add("manipulation", "RINGBRP.Category.Manipulation");
add("physical", "RINGBRP.Category.Physical");
add("perception", "RINGBRP.Category.Perception");
add("communication", "RINGBRP.Category.Communication");
if (w.bleed) bits.push(`${game.i18n.localize("RINGBRP.Sheet.Bleeding")} ${w.bleed}/${game.i18n.localize("RINGBRP.Sheet.PerRound")}`);
if (sys.mov < 10) bits.push(`MOV ${sys.mov}`);
ctx.impaired = {
active: bits.length > 0 || chips.length > 0,
chips, summary: bits.join(" · "),
unconscious: !!w.unconscious, defenceOnly: !!w.defenceOnly
};
}
// The figure. Each location is paired with its shape so the drawing and the
// table are the same data — a location cannot appear in one and not the other.
{
const art = RINGBRP.bodyArt[sys.speciesProfile || "baseline"] ?? RINGBRP.bodyArt.baseline;
ctx.bodyMap = {
viewBox: art.viewBox,
parts: (sys.locations ?? []).map(l => {
const shape = art.parts[l.id];
const frac = l.max ? Math.min(1, (l.taken || 0) / l.max) : 0;
return {
...l, shape,
state: l.destroyed ? "destroyed" : l.disabled ? "disabled"
: frac >= 0.5 ? "hurt" : frac > 0 ? "grazed" : "whole",
pct: Math.round(frac * 100)
};
}).filter(pt => pt.shape)
};
ctx.speciesLabel = game.i18n.localize(
RINGBRP.locationTables[sys.speciesProfile || "baseline"]?.label ?? "RINGBRP.Species.Baseline");
}
// Armour carried but not worn reads as "ARMOUR 0" next to a list containing a
// vest, which looks like a bug in the sheet rather than a state of the fiction.
ctx.armourUnworn = this.actor.items.filter(i => i.type === "armour"
&& i.system?.carried !== false && !i.system?.equipped).map(i => i.name);
ctx.powers = this.actor.items.filter(i => i.type === "power")
.sort((a, b) => (ARTS[a.system.artId]?.order ?? 9) - (ARTS[b.system.artId]?.order ?? 9));
ctx.defenceNext = defencePenaltyFor(Number(sys.combat?.defencesThisRound) || 0);
ctx.reaction = this._reactionRail();
ctx.readiness = this._readiness ?? { rating: 0 };
ctx.coherence = { band: coherenceBand(sys.coherence?.value) };
ctx.readinessSource = ctx.readiness.best >= ctx.readiness.intFloor
? game.i18n.localize("RINGBRP.React.ReadinessSkill")
: game.i18n.localize("RINGBRP.React.ReadinessInt");
return ctx;
}
/** What each declarable action would cost this character right now. */
/** The seven-box rail from the paper sheet, rendered on the digital one. */
_reactionRail() {
const rb = reactionBase(this.actor);
// Show the working. "23.6" on its own is unreadable — nobody can tell whether
// it came from being quick or from being alert, nor what the kit is costing.
const rd = readinessRating(this.actor);
const dex = Number(this.actor.system?.characteristics?.dex?.value) || 0;
const enc = this.actor.system?.encumbrance ?? {};
const work = {
dex,
dexDiv: REACTION.dexDivisor,
reflex: rb.reflex,
readiness: rd.rating,
readyDiv: REACTION.readinessDivisor,
aware: rb.aware,
fromTraining: rd.best >= rd.intFloor,
best: rd.best,
intFloor: rd.intFloor,
lane: Math.max(rb.reflex, rb.aware),
mult: REACTION.laneMultiplier,
base: rb.base,
load: rb.load,
overKg: enc.overKg ?? enc.over ?? 0,
carried: this.actor.system?.carriedKg ?? 0,
free: this.actor.system?.freeCarryKg ?? 0,
printed: rb.printed,
die: REACTION.die
};
// The rail is situation only and sits on the printed value, which already has
// load in it. Its resting position is 0, not the load band it used to show.
return {
...rb, work,
coherence: this.actor.system?.coherence?.value ?? 10,
rail: Array.from({ length: 7 }, (_, i) => ({
mod: i - 3,
value: Math.round((rb.printed + (i - 3)) * 10) / 10,
current: (i - 3) === 0
}))
};
}
activateListeners(html) {
super.activateListeners(html);
if (!this.isEditable) return;
const root = html[0] ?? html;
root.querySelectorAll("[data-action='roll-skill']").forEach(el =>
el.addEventListener("click", ev => this._onRollSkill(ev)));
root.querySelectorAll("[data-action='roll-attribute']").forEach(el =>
el.addEventListener("click", ev => this._onRollAttribute(ev)));
root.querySelectorAll("[data-action='roll-characteristic']").forEach(el =>
el.addEventListener("click", ev => this._onRollCharacteristic(ev)));
root.querySelectorAll("[data-action='full-skills']").forEach(el =>
el.addEventListener("click", async () => {
const n = await grantFullSkillList(this.actor);
ui.notifications?.info(n ? `+${n} skills` : game.i18n.localize("RINGBRP.Sheet.AlreadyFull"));
this.render(false);
}));
root.querySelectorAll("[data-action='use-talent']").forEach(el =>
el.addEventListener("click", async ev => {
ev.preventDefault(); ev.stopPropagation();
const btn = ev.currentTarget;
const item = this.actor.items.get(btn.closest("[data-item-id]")?.dataset.itemId);
if (!item) return;
if (btn.classList.contains("rb-disabled")) {
return ui.notifications?.warn(btn.getAttribute("title"));
}
// the two talents with their own procedure route to it; the rest just spend
if (item.system.talentId === "requisition") await requisition(this.actor);
else if (item.system.talentId === "clean_scene") await armCleanScene(this.actor);
else await useTalent(this.actor, item);
this.render(false);
}));
root.querySelectorAll("[data-action='device-fn']").forEach(el =>
el.addEventListener("click", async ev => {
ev.preventDefault(); ev.stopPropagation();
const btn = ev.currentTarget;
if (btn.classList.contains("rb-disabled")) {
return ui.notifications?.warn(btn.getAttribute("title"));
}
const item = this.actor.items.get(btn.closest("[data-item-id]")?.dataset.itemId);
if (item) await useDeviceFunction(this.actor, item, btn.dataset.fn);
this.render(false);
}));
root.querySelectorAll("[data-action='coherence-adjust']").forEach(el =>
el.addEventListener("click", async ev => {
ev.preventDefault(); ev.stopPropagation();
const delta = Number(ev.currentTarget.dataset.delta) || 0;
if (delta < 0) await loseCoherence(this.actor, "brokeLocalLaw", { amount: 1 });
else await regainCoherence(this.actor, 1);
}));
root.querySelectorAll("[data-action='roll-location']").forEach(el =>
el.addEventListener("click", () => this._onRollLocation()));
root.querySelectorAll("[data-action='roll-attack']").forEach(el =>
el.addEventListener("click", ev => this._onRollAttack(ev)));
// Hovering a limb highlights its row and vice versa, so the picture and the
// numbers are obviously the same thing.
const link = (sel, on) => root.querySelectorAll(sel).forEach(el => {
const id = el.dataset.loc; if (!id) return;
el.addEventListener("mouseenter", () => root.querySelectorAll(`[data-loc="${id}"]`)
.forEach(t => t.classList.add(on)));
el.addEventListener("mouseleave", () => root.querySelectorAll(`[data-loc="${id}"]`)
.forEach(t => t.classList.remove(on)));
});
link(".rb-part", "rb-hi");
link(".rb-locrow", "rb-hi");
for (const [action, pushed] of [["power-use", false], ["power-push", true]]) {
root.querySelectorAll(`[data-action='${action}']`).forEach(el =>
el.addEventListener("click", ev => {
const power = this._itemFrom(ev);
if (power) usePower(this.actor, power, { pushed });
}));
}
root.querySelectorAll("[data-action='authority-use']").forEach(el =>
el.addEventListener("click", async ev => {
const auth = this._itemFrom(ev); if (!auth) return;
const ask = await promptAuthorityUse(auth);
if (!ask) return;
await useAuthority(this.actor, auth, ask);
}));
root.querySelectorAll("[data-action='authority-widen']").forEach(el =>
el.addEventListener("click", async ev => {
const auth = this._itemFrom(ev); if (auth) await widenScope(this.actor, auth);
}));
root.querySelectorAll("[data-action='authority-rehab']").forEach(el =>
el.addEventListener("click", async ev => {
const auth = this._itemFrom(ev); if (auth) await rehabilitate(this.actor, auth);
}));
root.querySelectorAll("[data-action='defend']").forEach(el =>
el.addEventListener("click", async ev => {
const type = ev.currentTarget.dataset.type || "dodge";
const weapon = type === "parry"
? this.actor.items.find(i => i.type === "weapon" && i.system.equipped && !i.system.isRanged)
: null;
if (type === "parry" && !weapon) {
return ui.notifications?.warn(game.i18n.localize("RINGBRP.Notify.NoParryWeapon"));
}
const blow = standingBlow(this.actor);
await defend(this.actor, { type, weapon, against: blow?.level ?? null });
}));
root.querySelectorAll("[data-action='roll-damage']").forEach(el =>
el.addEventListener("click", ev => this._onRollDamage(ev)));
root.querySelectorAll("[data-action='item-edit']").forEach(el =>
el.addEventListener("click", ev => this._itemFrom(ev)?.sheet.render(true)));
root.querySelectorAll("[data-action='item-delete']").forEach(el =>
el.addEventListener("click", ev => this._itemFrom(ev)?.delete()));
root.querySelectorAll("[data-action='item-create']").forEach(el =>
el.addEventListener("click", ev => this._onItemCreate(ev)));
root.querySelectorAll("[data-action='wound-clear']").forEach(el =>
el.addEventListener("click", ev => this._onWoundClear(ev)));
root.querySelectorAll("[data-action='toggle-raised']").forEach(el =>
el.addEventListener("click", async ev => {
const it = this._itemFrom(ev);
if (!it) return;
if (shieldBroken(it.system.degradation?.current, it.system.degradation?.max)) {
return ui.notifications?.warn(game.i18n.format("RINGBRP.Notify.ShieldBroken",
{ name: it.name }));
}
// Only one shield goes up. Two shields is not a stance, it is a costume.
if (!it.system.raised) {
for (const other of this.actor.items) {
if (other.id !== it.id && other.type === "armour" && other.system.shield
&& other.system.raised) await other.update({ "system.raised": false });
}
}
await it.update({ "system.raised": !it.system.raised, "system.equipped": true });
}));
root.querySelectorAll("[data-action='toggle-equipped']").forEach(el =>
el.addEventListener("click", ev => {
const it = this._itemFrom(ev);
if (it) it.update({ "system.equipped": !it.system.equipped });
}));
root.querySelectorAll("[data-action='toggle-tick']").forEach(el =>
el.addEventListener("click", ev => {
const it = this._itemFrom(ev);
if (it) it.update({ "system.experienceTicked": !it.system.experienceTicked });
}));
}
_itemFrom(ev) {
// currentTarget is only set while the event is being dispatched. A handler that
// awaits anything and then calls this again gets null and throws — which is
// exactly what happened to every Coherence-costing weapon. Fall back to the
// target, which survives, so a second call after an await still resolves.
const from = ev.currentTarget ?? ev.target;
const id = from?.closest("[data-item-id]")?.dataset.itemId;
return id ? this.actor.items.get(id) : null;
}
async _onItemCreate(ev) {
const type = ev.currentTarget.dataset.type ?? "skill";
const created = await this.actor.createEmbeddedDocuments("Item", [{
name: game.i18n.localize(`RINGBRP.New.${type}`), type
}]);
created[0]?.sheet.render(true);
}
async _onWoundClear(ev) {
const id = ev.currentTarget.closest("[data-wound-id]")?.dataset.woundId;
const wounds = (this.actor.system.wounds ?? []).filter(w => w.id !== id);
await this.actor.update({ "system.wounds": wounds });
}
async _onRollSkill(ev) {
const it = this._itemFrom(ev);
if (!it) return;
const opts = await promptRollOptions({ title: it.name });
if (!opts) return;
// R-23: encumbrance is a named modifier, not a circumstance, so it is not
// subject to the +-40% circumstance cap.
const enc = this.actor.encumbrancePenaltyFor(it.system.categoryId);
const lines = [];
if (enc) lines.push(game.i18n.format("RINGBRP.Flavour.Encumbered",
{ mod: enc, steps: this.actor.system.encumbrance.steps }));
return skillRoll({
actor: this.actor,
label: it.name,
rating: it.system.value,
printedBase: it.system.base?.value ?? 0,
...opts,
situational: (opts.situational ?? 0) + enc,
categoryId: it.system.categoryId,
skillItem: it,
flavourLines: lines
});
}
/**
* Characteristic roll: d100 against the characteristic x5 (spec §3, BRP standard).
* A STR of 15 rolls against 75.
*/
async _onRollCharacteristic(ev) {
const key = ev.currentTarget.closest("[data-characteristic]")?.dataset.characteristic;
if (!key) return;
const value = Number(this.actor.system.characteristics?.[key]?.value) || 0;
const rating = value * 5;
const name = game.i18n.localize(`RINGBRP.Char.${key}`);
const opts = await promptRollOptions({ title: `${name} ×5` });
if (!opts) return;
return skillRoll({
actor: this.actor,
label: game.i18n.format("RINGBRP.Roll.Characteristic", { name, value }),
rating,
...opts
});
}
/** Roll a hit location on this actor's own anatomy, for the GM. */
async _onRollLocation() {
const mode = await new Promise(resolve => {
new (rbDialogClass())({
title: game.i18n.localize("RINGBRP.Loc.Header"),
content: `<p>${game.i18n.localize("RINGBRP.Loc.RollHint")}</p>`,
buttons: {
melee: { label: game.i18n.localize("RINGBRP.Loc.Melee"), callback: () => resolve("melee") },
ranged: { label: game.i18n.localize("RINGBRP.Loc.Ranged"), callback: () => resolve("ranged") }
},
default: "ranged", close: () => resolve(null)
}).render(true);
});
if (!mode) return;
return hitLocationRoll({ target: this.actor, damage: 0, mode });
}
async _onRollAttribute(ev) {
const key = ev.currentTarget.dataset.attribute;
const rating = this.actor.system.rolls?.[key] ?? 0;
const opts = await promptRollOptions({ title: game.i18n.localize(`RINGBRP.Roll.${key}`) });
if (!opts) return;
return skillRoll({
actor: this.actor,
label: game.i18n.localize(`RINGBRP.Roll.${key}`),
rating, ...opts
});
}
async _onRollAttack(ev) {
// Resolve the weapon ONCE, before anything is awaited. Reading it a second time
// after the Coherence charge was how the Redaction lance, the Unmaker and every
// other piece of transposed materiel became unusable: the event had gone stale
// by then and the attack died on a TypeError with nothing shown to the player.
const weapon = this._itemFrom(ev);
if (!weapon) return;
// F5: transposed materiel charges its Coherence cost when it is actually used.
if (Number(weapon.system?.coherence ?? weapon.system?.coherenceCost)) {
await useTransposed(this.actor, weapon);
}
const sys = this.actor.system;
const rating = this.actor.skillRating(weapon.system.skillFamilyId, weapon.system.skillSpecialisationId);
if (rating === null) {
ui.notifications.warn(game.i18n.format("RINGBRP.Notify.NoSkill", { name: weapon.name }));
return;
}
const opts = await promptAttackOptions({ weapon, actor: this.actor });
if (!opts) return;
if (opts.impossible) {
ui.notifications.warn(game.i18n.localize("RINGBRP.Notify.OutOfRange"));
return;
}
// A burst is a different resolution, not a modified one: one roll whose BAND
// decides how much of it arrived. burstAttack owns that, and takes the whole
// modifier stack this dialog built rather than rebuilding its own.
if (opts.burst) {
const target = [...(game.user.targets ?? [])][0]?.actor ?? null;
return burstAttack(this.actor, weapon, target,
{ size: opts.burst, difficulty: opts.difficulty,
situational: opts.situational, sizeModApplied: true, flavourLines: opts.lines });
}
// The second shot a fast action gives you. Both shots are worse, which is the
// whole cost, and it is once per round — which is what `secondAttackUsed` was
// reset for every round without anything ever setting it.
const shots = opts.second ? 2 : 1;
if (opts.second) {
if (this.actor.system.combat?.secondAttackUsed) {
ui.notifications.warn(game.i18n.localize("RINGBRP.Notify.SecondUsed"));
return;
}
await this.actor.update({ "system.combat.secondAttackUsed": true });
}
// Ammunition (spec §4.5). Charged PER SHOT, inside the loop — a fast action that
// fires twice and pays once is the exact dishonesty the burst rules were written
// to avoid, and a Webley firing two rounds off one chamber was what it looked
// like at the table.
const perShot = opts.sustain ? sustainChargeFor(opts.sustain) : 1;
const spend = async () => {
if (!(weapon.system.ammo?.capacity > 0)) return true;
const loaded = weapon.system.ammo.loaded ?? 0;
if (loaded <= 0) {
ui.notifications.warn(game.i18n.localize("RINGBRP.Notify.Empty"));
return false;
}
if (loaded < perShot) {
ui.notifications.warn(game.i18n.localize("RINGBRP.Notify.NotEnoughCharge"));
return false;
}
await weapon.update({ "system.ammo.loaded": loaded - perShot });
return true;
};
let res = null;
for (let i = 0; i < shots; i++) {
if (!await spend()) break;
res = await skillRoll({
actor: this.actor,
label: shots > 1 ? `${weapon.name} (${i + 1}/${shots})` : weapon.name,
rating,
difficulty: opts.difficulty,
situational: opts.situational,
flavourLines: opts.lines,
categoryId: weapon.system?.isRanged ? "ranged" : "melee",
weapon
});
// Leave the blow standing so whoever defends knows what quality it was.
if (res) await recordBlow({ attacker: this.actor, level: res.level, weapon,
bonus: opts.sustain ? sustainDamageFor(opts.sustain) : "",
targets: [...(game.user.targets ?? [])].map(t => t.id) });
}
// A slow weapon costs its reload after the shot, not before it. This is the
// other half of why it is worth +10%: you get one, and then you work for it.
if (opts.reloads && weapon.system.ammo?.capacity > 0) {
const n = Number(weapon.system.ammo.reloadRounds) || 1;
await ChatMessage.create({
speaker: ChatMessage.getSpeaker({ actor: this.actor }),
content: `<div class="ringbrp-chat"><strong>${
game.i18n.format("RINGBRP.Fire.Reloading", { name: weapon.name, n })}</strong></div>`
});
}
return res;
}
async _onRollDamage(ev) {
const weapon = this._itemFrom(ev);
if (!weapon) return;
// This used to roll with no level at all, so it always rolled ordinary damage:
// a critical got neither maximum damage nor its armour bypass, and the whole
// critical rule existed only in the chat card. Use what the attack achieved,
// after any defence has been graded against it.
const blow = standingBlow();
const mine = blow && blow.attackerId === this.actor.id;
if (mine && blow.stopped) {
return ui.notifications?.warn(game.i18n.localize("RINGBRP.Notify.BlowStopped"));
}
return rollWeaponDamage({ actor: this.actor, weapon,
level: mine ? blow.level : "success",
bonus: mine ? (blow.bonus ?? "") : "" });
}
}
class RingBRPNpcSheet extends RingBRPCharacterSheet {
static get defaultOptions() {
return foundry.utils.mergeObject(super.defaultOptions, {
classes: ["ringbrp", "sheet", "actor", "npc"],
template: "systems/ringbrp/templates/npc-sheet.hbs",
width: 700,
height: 760
});
}
}
class RingBRPExpeditionSheet extends ActorSheet {
static get defaultOptions() {
return foundry.utils.mergeObject(super.defaultOptions, {
classes: ["ringbrp", "sheet", "actor", "expedition"],
template: "systems/ringbrp/templates/expedition-sheet.hbs",
width: 720,
height: 700,
tabs: [{ navSelector: ".sheet-tabs", contentSelector: ".sheet-body", initial: "journey" }]
});
}
async getData(options) {
const ctx = await super.getData(options);
const sys = this.actor.system;
ctx.sys = sys;
ctx.RINGBRP = RINGBRP;
ctx.projects = this.actor.items.filter(i => i.type === "project");
ctx.discoveries = this.actor.items.filter(i => i.type === "discovery");
ctx.isGM = game.user.isGM;
// The crossing panel. Going out is a decision; coming back is a roll, and each
// agent makes their own unless somebody aims it for them.
{
const members = caseMembers(this.actor);
const rate = a => a.skillRating("transposition") ?? 0;
const best = members.slice().sort((a, b) => rate(b) - rate(a))[0] ?? null;
ctx.crossing = {
across: !!sys.across,
aimer: best ? { id: best.id, name: best.name.split(" ")[0], rating: rate(best) } : null,
members: members.map(a => {
const plan = returnCrossing(a);
return {
id: a.id, name: a.name.split(" ")[0],
rating: rate(a),
effective: resolveBands(rate(a), { situational: plan.penalty }).effective,
penalty: plan.penalty,
coherence: a.system.coherence?.value ?? 10,
band: game.i18n.localize(plan.band?.label ?? ""),
arts: a.items.filter(i => i.type === "power").length
};
})
};
}
// A group's taboos, desires and correct offer form are the answers to the
// first-contact challenge. Players see only what the expedition has learned;
// the GM sees everything, flagged as GM-only. (Codex round 7, B2.)
ctx.groups = this.actor.items.filter(i => i.type === "group").map(g => {
const st = (this.actor.system.standings ?? []).find(x => x.groupId === g.system.groupId);
const knownT = new Set(st?.knownTaboos ?? []);
const knownD = new Set(st?.knownDesires ?? []);
const all = t => (t ?? []).map(x => ({ ...x }));
const taboos = all(g.system.taboos);
const desires = all(g.system.desires);
return {
id: g.id, name: g.name, system: g.system,
population: g.system.population,
taboos: ctx.isGM ? taboos.map(t => ({ ...t, secret: !knownT.has(t.id) }))
: taboos.filter(t => knownT.has(t.id)),
desires: ctx.isGM ? desires.map(d => ({ ...d, secret: !knownD.has(d.id) }))
: desires.filter(d => knownD.has(d.id)),
offerForm: ctx.isGM ? g.system.correctOfferForm : "",
impression: st?.impression ?? 0, standing: st?.value ?? 0,
pidgin: !!st?.pidginEstablished, offerSpent: !!st?.offerMadeThisEncounter,
nothingKnown: !ctx.isGM && !taboos.some(t => knownT.has(t.id))
&& !desires.some(d => knownD.has(d.id))
};
});
ctx.notesHTML = await rbEnrich(sys.notes ?? "");
// the three tracks, with pip readouts and state
ctx.tracks = ["supply", "power", "integrity"].map(key => {
const t = sys[key] ?? { value: 0, max: 10 };
const pips = Array.from({ length: t.max }, (_, i) => i < t.value);
return {
key, value: t.value, max: t.max, pips,
label: `RINGBRP.Expedition.${key.charAt(0).toUpperCase()}${key.slice(1)}`,
crisis: t.value <= 0,
low: t.value > 0 && t.value <= Math.max(1, Math.floor(t.max * 0.3))
};
});
// what happens next
const legs = sys.journey?.legs ?? [];
ctx.resolvedLegs = legs.filter(l => l.resolved).length;
const idx = sys.journey?.currentLeg ?? 0;
const leg = legs[idx];
if (leg && !leg.resolved) {
ctx.nextLeg = foundry.utils.duplicate(leg);
ctx.nextLeg.index = idx;
ctx.nextLegNegative = Number(leg.modifier) < 0;
const know = sys.journey?.destinationKnowledge ?? "rumoured";
ctx.legDifficulty = game.i18n.localize(
RINGBRP.difficulties[know === "known" ? "average" : "difficult"]);
// best available navigator among the members
// One lookup, one skill preference. This used to resolve members by raw id
// and pick on Navigate, duplicating — and then contradicting — the resolver,
// which left the Resolve Phase button permanently disabled on the sheet
// while the same operation worked perfectly from the API.
const roster = caseMembers(this.actor);
const bestIn = fam => roster.reduce((acc, a) => {
const r = a.skillRating?.(fam);
return (r !== null && r !== undefined && r > (acc?.rating ?? -1))
? { name: a.name, rating: r, skill: fam } : acc;
}, null);
ctx.navigator = bestIn("tradecraft") ?? bestIn("navigate");
// the number the GM actually needs: difficulty applied, then the leg modifier
if (ctx.navigator) {
ctx.rollTarget = Math.max(1, applyDifficulty(ctx.navigator.rating,
know === "known" ? "average" : "difficult") + (Number(leg.modifier) || 0));
}
ctx.blocked = !ctx.navigator; // never blocked by an empty track (R-56)
}
// standings rendered as a -3..+3 scale so the position is visible, not just a number
ctx.standings = (sys.standings ?? []).map(st => {
const g = this.actor.items.find(i => i.type === "group" && i.system.groupId === st.groupId);
const scale = [];
for (let v = -3; v <= 3; v++) {
scale.push({
label: v === 0 ? "0" : (v > 0 ? `+${v}` : `${v}`),
cls: v === st.value ? (v < 0 ? "rb-scale-bad on" : "rb-scale-good on") : ""
});
}
const WORDS = { "-3": "hostile", "-2": "threatened", "-1": "wary", "0": "neutral",
"1": "curious", "2": "welcoming", "3": "allied" };
return {
name: g?.name ?? st.groupId, value: st.value, encounters: st.encounters ?? 0, scale,
display: st.value > 0 ? `+${st.value}` : `${st.value}`,
word: WORDS[String(st.value)] ?? "",
cls: st.value < 0 ? "rb-standing-bad" : (st.value > 0 ? "rb-standing-good" : "")
};
});
return ctx;
}
activateListeners(html) {
super.activateListeners(html);
if (!this.isEditable) return;
const root = html[0] ?? html;
root.querySelectorAll("[data-action='track-adjust']").forEach(el =>
el.addEventListener("click", ev => {
const { track, delta } = ev.currentTarget.dataset;
const cur = this.actor.system[track]?.value ?? 0;
const max = this.actor.system[track]?.max ?? 10;
this.actor.update({ [`system.${track}.value`]: Math.max(0, Math.min(max, cur + Number(delta))) });
}));
root.querySelectorAll("[data-action='resolve-leg']").forEach(el =>
el.addEventListener("click", () => resolveLegDialog(this.actor)));
root.querySelectorAll("[data-action='contact']").forEach(el =>
el.addEventListener("click", async ev => {
const bar = ev.currentTarget.closest("[data-group-id]");
const groupId = bar?.dataset.groupId;
const stage = ev.currentTarget.dataset.stage;
if (!groupId || ev.currentTarget.classList.contains("rb-disabled")) return;
const actor = await pickContactActor(this.actor, stage);
if (!actor) return;
await resolveContact(this.actor, groupId, stage, actor);
this.render(false);
}));
root.querySelectorAll("[data-action='new-encounter']").forEach(el =>
el.addEventListener("click", async ev => {
const groupId = ev.currentTarget.closest("[data-group-id]")?.dataset.groupId;
if (!groupId) return;
const n = await newEncounter(this.actor, groupId);
ui.notifications?.info(game.i18n.format("RINGBRP.Contact.EncounterN", { n }));
this.render(false);
}));
root.querySelectorAll("[data-action='toggle-across']").forEach(el =>
el.addEventListener("click", () => setAcross(!this.actor.system.across)));
const actorFromRow = ev => {
const id = ev.currentTarget.closest("[data-actor-id]")?.dataset?.actorId;
return id ? game.actors.get(id) : null;
};
const aimerFor = () => {
const m = caseMembers(this.actor);
return m.slice().sort((a, b) => (b.skillRating("transposition") ?? 0)
- (a.skillRating("transposition") ?? 0))[0] ?? null;
};
root.querySelectorAll("[data-action='cross-out']").forEach(el =>
el.addEventListener("click", () => setAcross(true)));
root.querySelectorAll("[data-action='cross-return']").forEach(el =>
el.addEventListener("click", async ev => {
const a = actorFromRow(ev); if (a) await crossBack(a);
}));
root.querySelectorAll("[data-action='cross-return-aided']").forEach(el =>
el.addEventListener("click", async ev => {
const a = actorFromRow(ev); if (!a) return;
const aimer = aimerFor();
await crossBack(a, { aimedByAnother: !!aimer && aimer !== a, aimedBy: aimer });
}));
// Bring the whole team back: the best crosser aims for everyone else, which is
// what the fiction says a team does, and then the case is home.
root.querySelectorAll("[data-action='cross-return-all']").forEach(el =>
el.addEventListener("click", async () => {
const members = caseMembers(this.actor);
if (!members.length) return ui.notifications?.warn(game.i18n.localize("RINGBRP.Cross.NoMembers"));
const aimer = aimerFor();
for (const a of members) {
await crossBack(a, { aimedByAnother: !!aimer && aimer !== a, aimedBy: aimer });
}
await setAcross(false);
}));
// R-210. endOfCase() was exported, documented as "the step that closes the
// episodic loop", and called by absolutely nothing — no button, no hook. Every
// skill and Art tick sat on the sheet forever and no character could improve
// through play. Same shape as every other fault this system has had: a rule
// that exists and nothing reaches.
root.querySelectorAll("[data-action='close-case']").forEach(el =>
el.addEventListener("click", async () => {
if (this.actor.system.across) return ui.notifications?.warn(game.i18n.localize("RINGBRP.Case.StillAcross"));
const members = caseMembers(this.actor);
if (!members.length) return ui.notifications?.warn(game.i18n.localize("RINGBRP.Cross.NoMembers"));
for (const a of members) await endOfCase(a);
ui.notifications?.info(game.i18n.localize("RINGBRP.Case.Closed"));
}));
root.querySelectorAll("[data-action='debrief']").forEach(el =>
el.addEventListener("click", async () => {
for (const a of caseMembers(this.actor)) {
await debrief(a, { withAnchor: true });
}
ui.notifications?.info(game.i18n.localize("RINGBRP.Case.DebriefDone"));
}));
root.querySelectorAll("[data-action='item-edit']").forEach(el =>
el.addEventListener("click", ev => {
const id = ev.currentTarget.closest("[data-item-id]")?.dataset.itemId;
this.actor.items.get(id)?.sheet.render(true);
}));
}
}
class RingBRPItemSheet extends ItemSheet {
static get defaultOptions() {
return foundry.utils.mergeObject(super.defaultOptions, {
classes: ["ringbrp", "sheet", "item"],
template: "systems/ringbrp/templates/item-sheet.hbs",
width: 620,
height: 620,
tabs: [{ navSelector: ".sheet-tabs", contentSelector: ".sheet-body", initial: "detail" }]
});
}
async getData(options) {
const ctx = await super.getData(options);
ctx.sys = this.item.system;
ctx.RINGBRP = RINGBRP;
ctx.isSkill = this.item.type === "skill";
ctx.isWeapon = this.item.type === "weapon";
ctx.isArmour = this.item.type === "armour";
ctx.coverageLabel = coverageFor(this.item.system?.coverage).label;
ctx.isDevice = this.item.type === "device";
ctx.isGear = this.item.type === "gear";
ctx.isProject = this.item.type === "project";
ctx.isDiscovery = this.item.type === "discovery";
ctx.isGroup = this.item.type === "group";
ctx.isMaterial = this.item.type === "material";
ctx.isVehicle = this.item.type === "vehicle";
ctx.isAuthority = this.item.type === "authority";
// Talents and Arts were added after this list and never joined it, so their sheets
// rendered an empty Detail tab over real data — category, rule, uses and cost all
// sat in the document with nothing on screen reading them.
ctx.isTalent = this.item.type === "talent";
ctx.isPower = this.item.type === "power";
ctx.descriptionHTML = await rbEnrich(ctx.sys.description ?? "");
if (ctx.isGear) {
ctx.totalMass = Math.round(((Number(ctx.sys.massKg) || 0) * (Number(ctx.sys.quantity) || 1)) * 10) / 10;
ctx.facilityMod = RINGBRP.facilities[ctx.sys.researchFacility] ?? 0;
}
if (ctx.isAuthority) {
ctx.scopeLabel = game.i18n.localize(`RINGBRP.Scope.${ctx.sys.scope}`);
ctx.merge = mergeStageFor(ctx.sys.mergeSteps);
ctx.mergeLabel = game.i18n.localize(ctx.merge.label);
ctx.widenCost = widenScopeCostFor(ctx.sys.timesWidened);
ctx.scopeOptions = SCOPE_TIERS.map(t => ({
key: t, label: game.i18n.localize(`RINGBRP.Scope.${t}`), selected: t === ctx.sys.scope
}));
}
if (ctx.isVehicle) {
ctx.speedLabel = game.i18n.localize(`RINGBRP.Speed.${ctx.sys.speedBand}`);
ctx.operateLabel = ctx.sys.skillSpecialisationId
? `${ctx.sys.skillFamilyId} (${ctx.sys.skillSpecialisationId})`.replace(/_/g, " ")
: ctx.sys.skillFamilyId;
}
if (ctx.isGroup) {
ctx.principalTaboo = ctx.sys.taboos?.[0]?.text ?? "—";
ctx.principalDesire = ctx.sys.desires?.[0]?.text ?? "—";
}
if (ctx.isWeapon) {
const d = ctx.sys.damage ?? {};
ctx.isByBand = d.mode === "byBand";
ctx.isRangedWeapon = !!ctx.sys.isRanged;
const owner = this.item.parent;
const r = owner?.skillRating?.(ctx.sys.skillFamilyId, ctx.sys.skillSpecialisationId);
ctx.attackRating = (r === null || r === undefined) ? null : r;
ctx.damageSummary = ctx.isByBand
? `${d.short || "—"} / ${d.medium || "—"} / ${d.long || "—"}`
: (d.formula || "—");
}
return ctx;
}
activateListeners(html) {
super.activateListeners(html);
if (!this.isEditable) return;
const root = html[0] ?? html;
root.querySelectorAll("[data-action='roll-damage']").forEach(el =>
el.addEventListener("click", () => this._onRollDamage()));
root.querySelectorAll("[data-action='roll-attack-item']").forEach(el =>
el.addEventListener("click", () => this._onRollAttackFromItem()));
}
/** Attack roll from the item sheet — the half of the weapon decision that was missing. */
async _onRollAttackFromItem() {
const actor = this.item.parent;
if (!actor) return ui.notifications.warn(game.i18n.localize("RINGBRP.Notify.NoOwner"));
const rating = actor.skillRating(this.item.system.skillFamilyId, this.item.system.skillSpecialisationId);
if (rating === null) return ui.notifications.warn(
game.i18n.format("RINGBRP.Notify.NoSkill", { name: this.item.name }));
const opts = await promptAttackOptions({ weapon: this.item, actor });
if (!opts) return;
if (opts.impossible) return ui.notifications.warn(game.i18n.localize("RINGBRP.Notify.OutOfRange"));
return skillRoll({ actor, label: this.item.name, rating,
difficulty: opts.difficulty, situational: opts.situational, flavourLines: opts.lines });
}
/** Roll this weapon's damage straight from the item sheet (spec §4.3). */
async _onRollDamage() {
return rollWeaponDamage({ actor: this.item.parent, weapon: this.item });
}
}
/* -------------------------------------------- */
/* Dialogs */
/* -------------------------------------------- */
async function promptRollOptions({ title } = {}) {
const content = await rbRenderTemplate("systems/ringbrp/templates/dialog-roll.hbs", { RINGBRP });
return new Promise(resolve => {
new (rbDialogClass())({
title: title ?? game.i18n.localize("RINGBRP.Dialog.Roll"),
content,
buttons: {
roll: {
label: game.i18n.localize("RINGBRP.Dialog.RollButton"),
callback: html => {
const el = html[0] ?? html;
const circ = el.querySelector("[name=circumstance]")?.value ?? "neutral";
const free = Number(el.querySelector("[name=situational]").value) || 0;
resolve({
difficulty: el.querySelector("[name=difficulty]").value,
circumstance: circ,
situational: circumstanceTotal([circ]) + free
});
}
},
cancel: { label: game.i18n.localize("RINGBRP.Dialog.Cancel"), callback: () => resolve(null) }
},
default: "roll",
close: () => resolve(null)
}).render(true);
});
}
/** Builds the attack modifier stack from range, style, cover and movement (spec §2.3, §4.2–4.4). */
async function promptAttackOptions({ weapon, actor }) {
// The weapon already knows its bands, so asking the player to type a distance and
// then deriving the band from it was making them do arithmetic the sheet had
// already done. The dropdown offers the bands; each option carries a distance
// inside that band, so rangeBandFor stays the one authority on what a range costs.
const r = weapon.system.ranges ?? {};
const bandOptions = [];
if (weapon.system.isRanged && r.long) {
const edges = [
{ id: "short", upto: r.short },
{ id: "medium", upto: r.medium },
{ id: "long", upto: r.long },
{ id: "extreme", upto: r.long * 2 }
];
let from = 0;
for (const e of edges) {
if (!(e.upto > from)) continue;
const probe = e.upto; // a distance that lands in this band
bandOptions.push({
value: probe,
label: game.i18n.localize(`RINGBRP.Range.${e.id}`),
span: `${from + 1}–${e.upto} m`,
mod: rangeBandFrom(probe, r).mod
});
from = e.upto;
}
bandOptions.push({
value: r.long * 2 + 1,
label: game.i18n.localize("RINGBRP.Range.beyond"),
span: `over ${r.long * 2} m`, mod: null
});
}
// What this weapon can do with a round. The mode has been on every weapon in the
// catalogue since the packs were first built and was read by nothing, so a musket
// could rip a ten-round burst and "rapid" bought a weapon precisely nothing.
const modeId = weapon.system.fireMode || "";
const mode = fireModeFor(modeId);
const fire = { options: [], note: "" };
if (weapon.system.isRanged) {
fire.options.push({
value: "single", selected: true, mod: fireModeAttackFor(modeId),
label: game.i18n.localize("RINGBRP.Fire.Shot"), effect: ""
});
for (const size of burstSizesFor(modeId)) {
const b = BURST[size];
fire.options.push({
value: `burst:${size}`, mod: fireModeAttackFor(modeId) + b.mod,
label: game.i18n.localize(b.label),
effect: game.i18n.format("RINGBRP.Fire.Rounds",
{ n: b.rounds, hits: BURST_HITS[size].join("/") })
});
}
if (mode.sustain) {
for (const [id, s] of Object.entries(SUSTAIN)) {
if (id === "tap") continue;
fire.options.push({
value: `sustain:${id}`, mod: fireModeAttackFor(modeId) + s.mod,
label: game.i18n.localize(s.label),
effect: game.i18n.format("RINGBRP.Fire.Adds", { dice: s.bonus })
});
}
}
if (mode.second) {
fire.options.push({
value: "second", mod: fireModeAttackFor(modeId) + secondShotPenaltyFor(modeId),
label: game.i18n.localize("RINGBRP.Fire.Second"),
effect: game.i18n.localize("RINGBRP.Fire.SecondEffect")
});
}
fire.note = game.i18n.localize(`RINGBRP.Fire.${mode.id}.Note`);
if (fire.options.length < 2) fire.options = []; // a single-shot weapon has no choice to offer
}
// Kit applies itself. The sight is decided by what is carried and what it fits,
// and the lamp by whether anything carried is lit — neither is a thing the player
// selects, because both are facts about the pack rather than about the shot.
const sight = sightForWeapon(actor, weapon);
const lamp = lightOn(actor);
const shieldUp = actor.system.shieldUp;
const kit = [];
if (sight.id !== "none") {
const bands = sightFor(sight.id).bands;
kit.push({ name: sight.source, effect: Object.entries(bands)
.filter(([, v]) => v)
.map(([b, v]) => `${v > 0 ? "+" : ""}${v}% ${game.i18n.localize(`RINGBRP.Range.${b}`)}`)
.join(" · ") });
}
if (shieldUp && weapon.system.isRanged) {
kit.push({ name: shieldUp.name, effect: game.i18n.format("RINGBRP.Shield.InTheWay",
{ mod: shieldRangedPenaltyFor(true) }) });
} else if (shieldUp) {
kit.push({ name: shieldUp.name, effect: game.i18n.format("RINGBRP.Shield.Raised",
{ n: shieldUp.points }) });
}
if (lamp.on) {
kit.push({ name: lamp.source, effect: game.i18n.format(
lamp.own ? "RINGBRP.Sight.LampOwn" : "RINGBRP.Sight.LampNear",
{ holder: lamp.holder ?? "", m: Math.round(lamp.distanceM ?? 0), reach: lamp.reachM }) });
}
const content = await rbRenderTemplate("systems/ringbrp/templates/dialog-attack.hbs",
{ RINGBRP, weapon, isRanged: weapon.system.isRanged, ranges: weapon.system.ranges,
bandOptions, fire, kit, inLight: lamp.on, lampReach: lamp.reachM || LAMP.reachM });
return new Promise(resolve => {
new (rbDialogClass())({
title: weapon.name,
content,
buttons: {
roll: {
label: game.i18n.localize("RINGBRP.Dialog.RollButton"),
callback: html => {
const el = html[0] ?? html;
const distance = Number(el.querySelector("[name=distance]")?.value) || 0;
const cover = el.querySelector("[name=cover]")?.value ?? "none";
const moved = el.querySelector("[name=moved]")?.checked ?? false;
const aiming = el.querySelector("[name=aiming]")?.checked ?? false;
const extra = Number(el.querySelector("[name=situational]")?.value) || 0;
const firePick = el.querySelector("[name=fire]")?.value ?? "single";
const light = el.querySelector("[name=lighting]")?.value ?? "lit";
const style = actor.system.marksmanship?.style ?? "reflex";
const styleCfg = RINGBRP.marksmanship[style];
const lines = [];
let mod = extra;
// How the weapon is being fired, before anything about the situation.
const [pick, arg] = firePick.split(":");
const modeMod = fireModeAttackFor(modeId);
if (weapon.system.isRanged && modeMod) {
mod += modeMod;
lines.push(`${game.i18n.localize(mode.label)} +${modeMod}%`);
}
if (pick === "burst") {
mod += BURST[arg].mod;
lines.push(`${game.i18n.localize(BURST[arg].label)} ${BURST[arg].mod}%`);
} else if (pick === "sustain") {
mod += SUSTAIN[arg].mod;
lines.push(`${game.i18n.localize(SUSTAIN[arg].label)} ${SUSTAIN[arg].mod}% `
+ `(+${SUSTAIN[arg].bonus})`);
} else if (pick === "second") {
const sp = secondShotPenaltyFor(modeId);
mod += sp;
lines.push(`${game.i18n.localize("RINGBRP.Fire.Second")} ${sp}%`);
}
let band = "short";
if (weapon.system.isRanged && weapon.system.ranges?.long) {
const rb = rangeBandFor(distance, weapon.system.ranges);
if (rb.mod === null) return resolve({ impossible: true });
band = rb.band;
mod += rb.mod;
lines.push(`${game.i18n.localize(`RINGBRP.Range.${rb.band}`)} ${rb.mod >= 0 ? "+" : ""}${rb.mod}%`);
const sb = styleRangeBonus(style, rb.band);
if (sb) { mod += sb; lines.push(`${game.i18n.localize(styleCfg.label)} +${sb}%`); }
}
// The sight, at the band the shot is actually being taken at. A scope is
// worth +15% at two hundred metres and -10% across a corridor, and the
// whole reason it grades by band is so it can apply itself.
if (sight.id !== "none") {
const sb = sightBonusFor(sight.id, band);
if (sb) {
mod += sb;
lines.push(`${sight.source} ${sb >= 0 ? "+" : ""}${sb}%`);
}
}
// The dark, after the lamp and after a sight that does not need one.
// The tick is authoritative: it arrives already set when the canvas could
// measure it, and is the GM's answer when there are no tokens to measure.
const inLight = el.querySelector("[name=inlight]")?.checked ?? lamp.on;
const lit = lightingModFor(light, { lightSource: inLight,
seesInDark: sightSeesInDark(sight.id) });
if (lit.mod) {
mod += lit.mod;
lines.push(`${game.i18n.localize(LIGHTING[lit.effective].label)} ${lit.mod}%`);
}
if (lit.revealed) lines.push(game.i18n.format("RINGBRP.Sight.Revealed",
{ name: lamp.source || game.i18n.localize("RINGBRP.Sight.SomeLamp") }));
// You are shooting around your own shield. This is what it costs, and it
// is the reason "always raised" is not the only answer.
if (shieldUp && weapon.system.isRanged) {
const sp = shieldRangedPenaltyFor(true);
mod += sp;
lines.push(`${shieldUp.name} ${sp}%`);
}
const cv = RINGBRP.cover[cover]?.mod ?? 0;
if (cv) { mod += cv; lines.push(`${game.i18n.localize(RINGBRP.cover[cover].label)} ${cv}%`); }
if (moved && styleCfg.moveAndFire) {
mod += styleCfg.moveAndFire;
lines.push(`${game.i18n.localize("RINGBRP.Attack.Moved")} ${styleCfg.moveAndFire}%`);
}
if (aiming) {
mod += styleCfg.aim;
lines.push(`${game.i18n.localize("RINGBRP.Attack.Aimed")} +${styleCfg.aim}%`);
if (styleCfg.ignoresArmour) lines.push(game.i18n.format("RINGBRP.Attack.IgnoresArmour", { n: styleCfg.ignoresArmour }));
}
resolve({
difficulty: "average", situational: mod, lines,
// What the caller has to DO about the choice, rather than what it cost.
burst: pick === "burst" ? arg : null,
sustain: pick === "sustain" ? arg : null,
second: pick === "second",
reloads: !!mode.reloads
});
}
},
cancel: { label: game.i18n.localize("RINGBRP.Dialog.Cancel"), callback: () => resolve(null) }
},
default: "roll",
close: () => resolve(null)
}).render(true);
});
}
/**
* Weapon damage, in one place. There were two near-identical copies of this — one
* on the character sheet, one on the item sheet — and neither knew what the attack
* had rolled, so a critical did ordinary damage and nothing offered a damage roll
* after a hit at all.
*
* Success level matters here (spec §4.4): a CRITICAL is maximum weapon damage plus
* a rolled damage modifier and ignores armour; a SPECIAL is normal damage against
* half armour; a success is normal damage against full armour. Armour is applied
* when the hit is resolved, so what changes here is the weapon dice and the note
* the card carries.
*/
export async function rollWeaponDamage({ actor = null, weapon, level = "success", band = null,
bonus = "" } = {}) {
if (!weapon) return null;
const d = weapon.system?.damage ?? {};
let formula = d.formula || "1d6";
if (d.mode === "byBand") {
const b = band ?? await promptDamageBand();
if (!b) return null;
formula = d[b] || d.formula || "1d6";
}
// Dice the ATTACK earned rather than the weapon carrying — a sustained beam.
if (bonus) formula = `${formula} + ${bonus}`;
const dm = actor?.system?.damageModifier;
const usesDm = weapon.system?.appliesDamageModifier && dm && dm !== "0";
let roll;
if (level === "critical") {
// maximum weapon damage, then the damage modifier is still ROLLED
const maxWeapon = await new Roll(formula).evaluate({ maximize: true });
roll = await new Roll(usesDm ? `${maxWeapon.total}${dm}` : `${maxWeapon.total}`).evaluate();
} else {
roll = await new Roll(usesDm ? formula + dm : formula).evaluate();
}
rbDressDice(roll, actor);
const note = level === "critical" ? game.i18n.localize("RINGBRP.Chat.DamageCritical")
: level === "special" ? game.i18n.localize("RINGBRP.Chat.DamageSpecial")
: "";
// This was the one card in the system still wearing Foundry's stock dice styling,
// because it posted the Roll and let the core template dress it. It now renders
// through the module's own card like every other result, and the Roll still rides
// on the message so the dice remain inspectable and Dice So Nice still animates.
const content = await rbRenderTemplate("systems/ringbrp/templates/chat-damage.hbs", {
title: game.i18n.format("RINGBRP.Chat.DamageFlavor", { name: weapon.name }),
level, formula, bonus,
modifier: usesDm ? dm : "",
note,
total: roll.total,
// Wound types have never carried localised labels — the item sheet prints the
// raw key too — so this stays consistent rather than inventing a key namespace.
wound: weapon.system?.woundType || "",
dice: roll.dice.flatMap(d => d.results.filter(r => r.active)
.map(r => ({ result: r.result, faces: d.faces })))
});
await roll.toMessage({
speaker: ChatMessage.getSpeaker({ actor: actor ?? undefined }),
content
});
return roll.total;
}
async function promptDamageBand() {
return new Promise(resolve => {
new (rbDialogClass())({
title: game.i18n.localize("RINGBRP.Dialog.Band"),
content: `<p>${game.i18n.localize("RINGBRP.Dialog.BandHint")}</p>`,
buttons: {
short: { label: game.i18n.localize("RINGBRP.Range.short"), callback: () => resolve("short") },
medium: { label: game.i18n.localize("RINGBRP.Range.medium"), callback: () => resolve("medium") },
long: { label: game.i18n.localize("RINGBRP.Range.long"), callback: () => resolve("long") }
},
default: "short",
close: () => resolve(null)
}).render(true);
});
}
/** Expedition leg resolution (spec §5). */
/** True when any case track is exhausted. Costs more; never stops the case. */
function destitute(cf) { return (cf?.system?.destitute?.length ?? 0) > 0; }
async function resolveLegDialog(expedition) {
const legs = expedition.system.journey?.legs ?? [];
const idx = expedition.system.journey?.currentLeg ?? 0;
const leg = legs[idx];
if (!leg) return ui.notifications.warn(game.i18n.localize("RINGBRP.Notify.NoLeg"));
const members = caseMembers(expedition);
// A case phase is not a march. The agent who leads it is whoever is best at
// reading a developing situation, which is Tradecraft first and Navigate only
// as a fallback for phases that really are about ground.
// Tradecraft FIRST, not "best of either": a case phase is led by whoever reads
// the situation best, and Navigate is only consulted when nobody has Tradecraft.
//
// R-221. That fallback could never fire. Every character carries every skill —
// grantFullSkillList sees to it — so skillRating() returns the printed base rather
// than null, `bestIn("tradecraft")` always found somebody, and Navigate was
// unreachable. A team of four postings that train Navigate and not Tradecraft was
// led by whoever had the highest UNTRAINED Tradecraft, which is the printed 5%,
// and every phase after the first became arithmetically hopeless. TRAINED means
// above the printed base, not merely present.
const trained = (a, fam) => {
const it = a.items.find(i => i.type === "skill" && i.system.familyId === fam
&& !i.system.specialisationId);
if (!it) return null;
const base = Number(it.system.base?.value) || 0;
const val = Number(it.system.value) || 0;
return val > base ? val : null;
};
const bestIn = (fam, requireTrained = true) => members.reduce((acc, a) => {
const r = requireTrained ? trained(a, fam) : a.skillRating(fam);
return (r !== null && r > (acc?.rating ?? -1)) ? { actor: a, rating: r, skill: fam } : acc;
}, null);
// R-226. "Tradecraft first" was too literal. A Containment Officer whose Tradecraft
// had caught a stray bonus point sat at 12% against a base of 5 — technically
// trained — and led every phase ahead of a Rigging Officer on 53% Navigate. The
// preference is meant to say "whoever reads a developing situation best", not
// "anybody with a point in the right column". Tradecraft still wins ties and wins
// when it is genuinely better; it does not win when it is plainly worse.
const tc = bestIn("tradecraft"), nav = bestIn("navigate");
const best = (tc && (!nav || tc.rating >= nav.rating)) ? tc
: (nav ?? tc ?? bestIn("tradecraft", false) ?? bestIn("navigate", false));
if (!best) return ui.notifications.warn(game.i18n.localize("RINGBRP.Notify.NoNavigator"));
// A leg may state its own difficulty; the generator sets one deliberately and
// explains the ramp in a comment. It was written and never read, so the brief
// ("phase 1 is Average") and the phase table disagreed on the same document set.
const know = expedition.system.journey.destinationKnowledge;
let difficulty = leg.difficulty ?? (know === "known" ? "average" : "difficult");
// Rewards a group has granted for this phase.
const rewards = activeRewardEffects(expedition, idx);
const cancelled = rewards.some(e => e.type === "cancelModifier");
const waived = rewards.some(e => e.type === "waiveCost");
const rewardBonus = rewards.filter(e => e.type === "skillBonus"
&& (!e.skillFamilyId || e.skillFamilyId === best?.skill)).reduce((a, e) => a + (Number(e.value) || 0), 0);
if (destitute(expedition)) difficulty = "difficult"; // dearer, never impossible
// Prefer the stored kind; fall back to the prose only for case files generated
// before the kind was recorded.
const isExtraction = leg.kind ? leg.kind === "extraction"
: /^extraction\b/i.test(String(leg.terrain ?? ""));
const res = await skillRoll({
actor: best.actor,
label: game.i18n.format("RINGBRP.Expedition.LegRoll", { n: idx + 1, terrain: leg.terrain ?? "" }),
rating: best.rating,
// R-222. The family was never passed, so skillRoll could not resolve the skill
// item, and a phase led successfully earned no experience tick. This is the same
// fault the internal-callers fix addressed — it resolves the item FROM the
// family, and this caller supplied none. The skill the job most uses was still
// the one that could never improve.
skillFamilyId: best.skill,
categoryId: "mental",
difficulty,
situational: (cancelled ? 0 : Number(leg.modifier) || 0) + rewardBonus,
flavourLines: [
...(cancelled ? [game.i18n.localize("RINGBRP.Contact.ModifierCancelled")] : []),
...(rewardBonus ? [game.i18n.format("RINGBRP.Contact.GuideBonus", { mod: rewardBonus })] : [])
]
});
if (!res) return;
if (leg.forcedEncounterId) {
await ChatMessage.create({
speaker: ChatMessage.getSpeaker({ actor: expedition }),
content: `<div class="ringbrp-chat rb-forced"><h3 class="rb-chat-title">${
game.i18n.localize("RINGBRP.Expedition.ForcedEncounter")}</h3><p>${leg.forcedEncounterId}</p></div>`
});
}
const upd = {};
let cleanScene = expedition.getFlag("ringbrp", "cleanSceneArmedBy") ? 1 : 0;
const take = (track, n) => {
let amount = n;
if (track === "integrity" && cleanScene > 0) { // Clean Scene absorbs one
const absorbed = Math.min(cleanScene, amount);
amount -= absorbed; cleanScene -= absorbed;
ui.notifications?.info(game.i18n.localize("RINGBRP.Talent.CleanSceneUsed"));
}
if (!amount) return;
const cur = upd[`system.${track}.value`] ?? expedition.system[track].value;
upd[`system.${track}.value`] = Math.max(0, cur - amount);
};
const baseSupply = waived ? 0 : Number(leg.costSupply ?? 1);
const basePower = Number(leg.costPower ?? 0);
let advance = true, note = "";
// A full playthrough showed Clearance and Containment never moving once across
// four phases: only Resources was driven by anything. All three tracks now have
// a reason to exist. Containment is what the world notices; Clearance is what
// the agency will still admit to.
switch (res.level) {
case "critical":
note = leg.explored ? "RINGBRP.Expedition.CriticalExplored" : "RINGBRP.Expedition.CriticalSite";
break;
case "special":
note = "RINGBRP.Expedition.Special";
break;
case "success":
take("supply", baseSupply); if (basePower) take("power", basePower);
note = "RINGBRP.Expedition.Success";
break;
case "failure":
take("supply", baseSupply + 1); if (basePower) take("power", basePower);
take("integrity", 1); // the phase went loudly
note = "RINGBRP.Expedition.Failure";
break;
case "fumble":
take("supply", baseSupply); if (basePower) take("power", basePower);
take("integrity", 2); // somebody saw something
take("power", 1); // and the agency heard about it
advance = false;
note = "RINGBRP.Expedition.Fumble";
break;
}
// THE EXTRACTION IS DIFFERENT. Getting out badly does not hurt anybody where they
// stand — it costs the way home, which is the one thing the generator has always
// said it costs and which nothing ever charged. Two play-throughs running were
// decided at this phase by a GM improvising a consequence, and improvising at that
// moment is how a phase that should cost you the trip home costs two lives.
if (isExtraction) {
const ex = extractionCostFor(res.level);
if (ex.clock) {
const now = Number(expedition.system.journey?.returnClock) || 0;
const next = Math.max(0, Math.min(RETURN_CLOCK.max, now + ex.clock));
if (next !== now) {
upd["system.journey.returnClock"] = next;
note = ex.clock > 0 ? "RINGBRP.Expedition.WindowNarrows" : "RINGBRP.Expedition.WindowHeld";
}
}
}
// Running on empty is not a wall. R-56: a depleted track makes the next phase
// dearer, never impossible.
if (destitute(expedition)) take("integrity", 1);
if (!cleanScene && expedition.getFlag("ringbrp", "cleanSceneArmedBy")) {
await expedition.unsetFlag("ringbrp", "cleanSceneArmedBy");
}
if (advance) {
legs[idx].resolved = true;
// `explored` was READ at the critical branch above and written by nothing, so
// every critical reported "site found" and the "you already know this ground"
// result could never occur. Ground the team has crossed is ground they know.
legs[idx].explored = true;
upd["system.journey.legs"] = legs;
upd["system.journey.currentLeg"] = idx + 1;
// Time was recorded per leg, displayed on the sheet, and never accumulated.
upd["system.journey.elapsedDays"] =
(Number(expedition.system.journey?.elapsedDays) || 0) + (Number(leg.days) || 1);
}
await expedition.update(upd);
const clockNow = Number(upd["system.journey.returnClock"]
?? expedition.system.journey?.returnClock) || 0;
ChatMessage.create({
content: `<div class="ringbrp-chat"><h3>${game.i18n.localize("RINGBRP.Expedition.Title")}</h3>
<p>${game.i18n.localize(note)}</p>`
+ (isExtraction && clockNow
? `<p class="rb-chat-flavour">${game.i18n.format("RINGBRP.Expedition.WayHome",
{ mod: returnPenaltyFor(clockNow),
extra: returnIsDifficult(clockNow)
? game.i18n.localize("RINGBRP.Expedition.WayHomeDifficult") : "" })}</p>`
: "")
+ `</div>`
});
}
/* -------------------------------------------- */
/* Hooks */
/* -------------------------------------------- */
Hooks.once("init", () => {
console.log("RingBRP | initialising");
// The figure is drawn in three places — combat, anatomy, and the header thumbnail.
// One partial, so a location cannot be present in one of them and missing from
// another, which is the same rule rules.mjs enforces for the formulas.
const loader = foundry?.applications?.handlebars?.loadTemplates ?? globalThis.loadTemplates;
loader?.(["systems/ringbrp/templates/partials/body-figure.hbs"]);
CONFIG.RINGBRP = RINGBRP;
CONFIG.Actor.documentClass = RingBRPActor;
CONFIG.Item.documentClass = RingBRPItem;
CONFIG.Combat.documentClass = RingBRPCombat;
CONFIG.Combat.initiative = { formula: "0", decimals: 1 };
foundry.documents.collections.Actors.unregisterSheet("core", ActorSheet);
foundry.documents.collections.Actors.registerSheet("ringbrp", RingBRPCharacterSheet, {
types: ["character"], makeDefault: true, label: "RingBRP Explorer Sheet"
});
foundry.documents.collections.Actors.registerSheet("ringbrp", RingBRPNpcSheet, {
types: ["npc"], makeDefault: true, label: "RingBRP Statblock"
});
foundry.documents.collections.Actors.registerSheet("ringbrp", RingBRPExpeditionSheet, {
types: ["expedition"], makeDefault: true, label: "RingBRP Expedition"
});
foundry.documents.collections.Items.unregisterSheet("core", ItemSheet);
foundry.documents.collections.Items.registerSheet("ringbrp", RingBRPItemSheet, {
makeDefault: true, label: "RingBRP Item Sheet"
});
// Handlebars helpers
Handlebars.registerHelper("rbCeilHalf", v => Math.ceil((Number(v) || 0) / 2));
Handlebars.registerHelper("rbTimes5", v => (Number(v) || 0) * 5);
Handlebars.registerHelper("rbEq", (a, b) => a === b);
Handlebars.registerHelper("rbOr", (a, b) => a || b);
Handlebars.registerHelper("rbNot", a => !a);
Handlebars.registerHelper("rbLen", v => (v?.length ?? 0));
Handlebars.registerHelper("rbGt", (a, b) => Number(a) > Number(b));
Handlebars.registerHelper("rbConcat", (...args) => args.slice(0, -1).join(""));
Handlebars.registerHelper("rbBands", (rating) => {
const b = resolveBands(rating);
return `${b.critical} / ${b.special} / ${b.effective} / ${b.fumbleStart}+`;
});
Handlebars.registerHelper("rbGt", (a, b) => Number(a) > Number(b));
Handlebars.registerHelper("rbFixed", (v, n) => (Number(v) || 0).toFixed(Number(n) || 0));
Handlebars.registerHelper("rbSign", v => (Number(v) >= 0 ? `+${v}` : `${v}`));
Handlebars.registerHelper("rbAdd", (a, b) => (Number(a) || 0) + (Number(b) || 0));
// Make the display face available to journals, drawings and the text editor too.
CONFIG.fontDefinitions["Michroma"] = {
editor: true,
fonts: [{ urls: ["systems/ringbrp/fonts/Michroma-Regular.woff2"], weight: 400, style: "normal" }]
};
game.ringbrp = {
RINGBRP, skillRoll, majorWoundRoll, resolveBands, gradeRoll, applyDifficulty,
damageModifierFor, rangeBandFor, styleRangeBonus, hitPointsFor, majorWoundFor,
damageAfterArmour, resolveLegDialog, armourAgainst, resolveHit,
reflexRating, awarenessRating, reactionBase, loadBand, reactionModifier, fluxDie, rollReaction,
reactionLoadFrom, reactionPrintedFrom,
readinessRating, percentileFaces, circumstanceTotal,
coherenceBand, coherencePenaltyFor, woundPenaltyFor, loseCoherence, regainCoherence, displace,
isAcross, setAcross, useTransposed, debrief, activeCaseFile, returnCrossing, caseMembers,
earnedDiscoveries, deviceFunctionStatus, earnDiscovery, useDeviceFunction, pickContactActor,
resolveContact, newEncounter, claimRewards, activeRewardEffects,
defenceOutcomeFor, levelRank, recordBlow, standingBlow, amendBlow,
addDossierItem, useAuthority, widenScope, rehabilitate,
tickDying, died, stabilise, reviveIfHealed, conditionFor, dyingLimitFor,
fall, asphyxiate, burn, detonate, burstAttack, twoWeaponAttack,
sightForWeapon, carriedLight, lightOn, fireModeFor, burstSizesFor, sustainDamageFor,
manStation, bestStationFor, STATIONS, SPACE_BANDS, heatEffectsFor,
craftConditionFor, spaceBandAfter,
fallDamageFor, breathRoundsFor, fireBandFor, blastBandFor, burstHitsFor,
twoWeaponPenaltyFor, canPairWeapons, FIRE, BLAST, BURST,
scopeStanceFor, dossierTrustFor, mergeStageFor, SCOPE_TIERS,
heal, firstAid, medicalCare, applyBleeding, stopBleeding, bleedRateFor, BLEED,
defend, defencePenaltyFor,
usePower, grantPower, ARTS, powerCostFor, crossBack,
generateMissionCase, missionDialog, salvageFrom,
crossingArtPoolFor, crossingGrantsArt, crossingCostFor, returnCrossing,
rollWeaponDamage, diceSetFor,
restFor, endOfCase, gearBonusFor,
hasTalent, talentAvailable, useTalent, requisition, armCleanScene, resetTalents,
generateCharacter, generateNPC, generateDialog, installContent, grantFullSkillList,
ROLES, TRADES, INDUCTION, CHARACTERISTIC_DICE, rollCharacteristicsDialog,
TIERS, THREATS, SPECIES,
npcTarget, npcShouldWithdraw, declareNpcTarget,
setReaction,
locationFor, locationMaxHp, resolveLocationHit, hitLocationRoll
};
});
/**
* Bring gear that predates a field up to date with the catalogue.
*
* A sight and a lamp are supposed to apply themselves, and on a fresh import they
* do. But an agent created before those fields existed carries a Field lamp whose
* `lightSource` is the template default of false, so the lamp sat in the pack doing
* nothing and the dark stayed at its full penalty. "Automatic" that requires the
* player to re-import their kit is not automatic.
*
* The compendium is the authority and items carry `flags.ringbrp.key` (stamped at
* build), so the match is exact rather than by display name — the fuzzy-name match
* this project used before mapped a climbing kit to breaching charges.
*/
async function migrateFittedGear() {
const pack = game.packs.get("ringbrp.gear");
if (!pack) return 0;
const canon = new Map();
for (const doc of await pack.getDocuments()) {
const key = doc.flags?.ringbrp?.key;
if (!key) continue;
if (doc.system?.optic?.kind || doc.system?.lightSource) {
canon.set(key, { optic: doc.system.optic, lightSource: !!doc.system.lightSource,
lightReachM: Number(doc.system.lightReachM) || 0 });
}
}
// Armour that predates coverage defaults to "all", which is what it always was —
// so a helmet on an existing agent would go on protecting their legs until it is
// stamped from the catalogue.
const armourPack = game.packs.get("ringbrp.armour");
const armourCanon = new Map();
for (const doc of (await armourPack?.getDocuments()) ?? []) {
const key = doc.flags?.ringbrp?.key;
if (key && doc.system?.coverage) armourCanon.set(key, doc.system.coverage);
}
if (!canon.size && !armourCanon.size) return 0;
let fixed = 0;
const owners = [...game.actors, ...game.scenes.map(s => s.tokens
.filter(t => !t.actorLink && t.actor).map(t => t.actor)).flat()];
for (const actor of owners) {
const updates = [];
for (const it of actor.items) {
if (it.type === "armour") {
const want = armourCanon.get(it.flags?.ringbrp?.key);
if (want && (it.system.coverage || "all") !== want) {
updates.push({ _id: it.id, "system.coverage": want });
}
continue;
}
if (it.type !== "gear") continue;
const want = canon.get(it.flags?.ringbrp?.key);
if (!want) continue;
const stale = (it.system.optic?.kind ?? "") !== (want.optic?.kind ?? "")
|| !!it.system.lightSource !== want.lightSource
|| (Number(it.system.lightReachM) || 0) !== want.lightReachM;
if (stale) updates.push({ _id: it.id, "system.optic": want.optic,
"system.lightSource": want.lightSource,
"system.lightReachM": want.lightReachM });
}
if (updates.length) {
await actor.updateEmbeddedDocuments("Item", updates);
fixed += updates.length;
}
}
return fixed;
}
/**
* Dress this system's journals in the system's own paper.
*
* Twelve pages of generated rulebook, a player primer and a starter scenario were all
* rendering as Foundry's default parchment — the only part of the game that did not
* look like the game. Scoped by a flag rather than applied to every JournalSheet, so a
* module's journals in the same world are left exactly as they were.
*/
/**
* Dress this system's journals in the system's own paper.
*
* Twelve pages of generated rulebook, a ten-page player primer and a starter scenario
* were all rendering as Foundry's default parchment — the only part of the game that
* did not look like the game. Scoped by a flag rather than applied to every journal, so
* a module's entries in the same world are left exactly as they were.
*
* v14 note: the sheet is an ApplicationV2, so the hook is `renderJournalEntrySheet` and
* `element` is the root node itself rather than a jQuery wrapper. Reaching for `[0]` on
* it silently returns the first CHILD — which is how the first version of this appeared
* to work, added the class to a header button, and changed nothing.
*/
const rbBrandJournal = (doc, el) => {
const mine = doc?.flags?.ringbrp?.brand
|| (typeof doc?.pack === "string" && doc.pack.startsWith("ringbrp."))
|| doc?.compendium?.metadata?.packageName === "ringbrp";
const node = el instanceof HTMLElement ? el : (el?.[0] ?? null);
if (mine && node?.classList) node.classList.add("ringbrp-journal");
};
Hooks.on("renderJournalEntrySheet", (app, el) => rbBrandJournal(app?.document, el));
Hooks.on("renderJournalEntryPageSheet", (app, el) =>
rbBrandJournal(app?.document?.parent, el));
Hooks.once("ready", async () => {
console.log("RingBRP | ready");
if (!game.user.isGM) return;
const fixed = await migrateFittedGear();
if (fixed) {
console.log(`RingBRP | fitted ${fixed} item(s) that predated the sight and lamp fields`);
ui.notifications?.info(game.i18n.format("RINGBRP.Notify.GearFitted", { n: fixed }));
}
});
/**
* Let the GM redeclare a combatant's action from the tracker.
* v14's CombatTracker is an ApplicationV2 with no context-menu hook, so the
* method is patched directly. Both the v2 and v1 entry shapes are supported.
*/
Hooks.once("setup", () => {
const Tracker = CONFIG.ui.combat;
if (!Tracker?.prototype?._getEntryContextOptions) return;
const orig = Tracker.prototype._getEntryContextOptions;
Tracker.prototype._getEntryContextOptions = function () {
const options = orig.call(this) ?? [];
const combatantFrom = li => {
const id = li?.dataset?.combatantId ?? li?.[0]?.dataset?.combatantId;
return (this.viewed ?? game.combat)?.combatants?.get(id) ?? null;
};
const isV1 = options.length > 0 && options[0].name !== undefined;
options.unshift(isV1
? { name: "RINGBRP.React.Set", icon: '<i class="fas fa-stopwatch"></i>',
condition: () => game.user.isGM,
callback: li => { const c = combatantFrom(li); if (c) setReaction(c); } }
: { label: "RINGBRP.React.Set", icon: "fa-solid fa-stopwatch",
visible: () => game.user.isGM,
onClick: (event, li) => { const c = combatantFrom(li); if (c) setReaction(c); } });
return options;
};
});
/** Show the Reaction breakdown on the tracker, and flag a misfiled die. */
/**
* Damage from the attack card. Bound on render rather than inline so the button
* works on reload, and registered under both hook names because v13 renamed the
* chat render hook and passes an element where v12 passed jQuery.
*/
async function _onChatDamage(ev) {
const btn = ev.currentTarget;
const card = btn.closest("[data-message-id]");
const msg = game.messages.get(card?.dataset?.messageId);
const f = msg?.flags?.ringbrp;
if (!f?.weaponId) return;
const actor = f.tokenId ? (canvas.tokens?.get(f.tokenId)?.actor ?? game.actors.get(f.actorId))
: game.actors.get(f.actorId);
const weapon = actor?.items?.get(f.weaponId);
if (!weapon) return ui.notifications?.warn(game.i18n.localize("RINGBRP.Notify.NoWeapon"));
btn.disabled = true; // one damage roll per hit
await rollWeaponDamage({ actor, weapon, level: f.level });
}
// v13 renamed this and warns on every message if the old name is registered, so
// take the new one when it exists rather than registering both.
for (const hook of [foundry?.applications?.sidebar?.tabs?.ChatLog
? "renderChatMessageHTML" : "renderChatMessage"]) {
Hooks.on(hook, (msg, html) => {
const root = html?.[0] ?? html;
if (!root?.querySelectorAll) return;
root.querySelectorAll("[data-action='chat-damage']")
.forEach(el => el.addEventListener("click", _onChatDamage));
});
}
Hooks.on("renderCombatTracker", (app, html) => {
const root = html instanceof HTMLElement ? html : html?.[0];
if (!root || !game.combat) return;
for (const el of root.querySelectorAll("[data-combatant-id]")) {
const c = game.combat.combatants.get(el.dataset.combatantId);
const r = c?.flags?.ringbrp?.reaction;
const init = el.querySelector(".token-initiative");
if (!init || !r) continue;
const sign = r.mod >= 0 ? "+" : "";
init.setAttribute("title",
`${game.i18n.localize("RINGBRP.React.Base")} ${r.base} ${sign}${r.mod} + d4 ${r.die}`
+ (r.misfiled ? ` — ${game.i18n.localize("RINGBRP.React.Misfiled")}` : ""));
const tag = document.createElement("span");
tag.className = "rb-sr-action" + (r.misfiled ? " rb-misfiled" : "");
tag.textContent = r.misfiled
? game.i18n.localize("RINGBRP.React.MisfiledShort")
: `${r.base}${sign}${r.mod}+${r.die}`;
init.appendChild(tag);
}
});
/** Reset per-round combat state (spec §4.3). */
Hooks.on("updateCombat", async (combat, changed) => {
if (!("round" in changed) && !("turn" in changed)) return;
if (!game.user.isGM) return;
// Blood loss, once per round, charged at the top of the NEW round because that is
// the end of the previous one. Entering round 1 must not charge: no round has
// elapsed yet, and starting combat used to cost a bleeding agent a free tick.
if ("round" in changed && changed.round > 1) {
for (const c of combat.combatants) {
if (!c.actor || c.actor.type === "expedition") continue;
await applyBleeding(c.actor);
// Bleeding stops AT zero by design; the count that starts there is what
// carries on. Same hook, so neither has to be remembered.
await tickDying(c.actor);
}
}
for (const c of combat.combatants) {
const a = c.actor;
if (!a || a.type === "expedition") continue;
if (a.system.combat?.defencesThisRound || a.system.combat?.secondAttackUsed) {
await a.update({
"system.combat.defencesThisRound": 0,
"system.combat.defenceTypeLock": "",
"system.combat.secondAttackUsed": false
});
}
}
});