The supporter's tactics have said this since it was written: "It is a flyer, and a flyer with a hole in a wing is a quadruped: disable either wing and it is on the ground for the rest of the scene, which is the fight the agents can actually win." Playing a full combat against it showed the sentence was decoration. `flightLost` was computed, printed on the NPC sheet as GROUNDED, and read by nothing. Both wings disabled cost 20% off PHYSICAL rolls, and Brawl is a MELEE category, so woundPenaltyFrom returned 0 and a grounded supporter attacked at exactly the 75% and exactly the 1d6+1+2d6 it had in the air. In the played fight it was grounded from round five and went on to kill one agent, knock out a second and leave a third dying. Losing a wing now changes the animal. A plan declares what it becomes — winged says `grounded: "quadruped"` — and hitLocationRoll drops the creature onto that plan the moment a wing is disabled or destroyed. The wings leave its hit chart and the six ranged faces they covered go back to the body: vital hits rise from 3 faces in 20 to 5. Every other wound moves across on the R-94 remap with its severity intact. The wing's own damage is deliberately dropped rather than carried, and cleared in its own update so the remap never sees it. A ruined wing must not arrive as a ruined foreleg — the creature is not lamed, it is earthbound, and the injury has already been paid out as the change of shape. check-anatomy gains a fifth property: a declared landing must exist, must have no wings of its own, must not itself be a flyer, must have room for every wound the flyer was carrying without two collapsing onto one location, and must not heal a disabled location on the way down. Three induced failures confirm it fires; the third — landing somewhere with too few locations — passed at first because I had not stated the capacity property, and the guard was sharpened until it caught it. Measured, and reported honestly: this does NOT deliver the fight the tactics text promises. Over 4,000 simulated fights the win rate moves 12.7% -> 12.1% on borough ground and 85.8% -> 86.3% off it, because grounding only happens in 9% of fights there and arrives late. What actually decides this encounter is ARGENT AND GULES: 11.4% win on borough ground against 85.4% off it. The wing rule is now real and mechanical, but the creature's advice still points the GM at the wrong lever. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
6565 lines
306 KiB
JavaScript
6565 lines
306 KiB
JavaScript
/**
|
||
* 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,
|
||
locationFor, resolveLocationHit, locationsFor, woundPenaltyFrom, remapLocationDamage,
|
||
groundedPlanFor,
|
||
locationDamageReplacement,
|
||
encumbrancePenaltyFor, ENCUMBERED_CATEGORIES, freeCarryFor,
|
||
defencePenaltyFor, defenceTypeAllowed, DEFENCE_STEP,
|
||
defenceOutcomeFor, levelRank, LEVEL_LADDER, skillBaseFrom,
|
||
applyDifficulty, resolveBands, gradeRoll,
|
||
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,
|
||
PANIC_MAX, PANIC_BANDS, PANIC_TRIGGERS, PANIC_RELIEF, PANIC_BREAK, PANIC_AFTER_BREAK,
|
||
panicBandFrom, panicPenaltyFor, panicBreaksAt, panicAfter, panicReliefFor,
|
||
DEVELOPMENT, developmentMarksOn, developmentImproves, developmentGainFor
|
||
} from "./rules.mjs";
|
||
import { LOCATION_TABLES, BODY_ART } from "./anatomy.mjs";
|
||
export { hitPointsFor, majorWoundFor, damageModifierFor, locationMaxHp, reflexRating, awarenessRating,
|
||
locationFor, resolveLocationHit, locationsFor };
|
||
|
||
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 tables and body geometry live in anatomy.mjs so the node-side tools can read
|
||
them too — a portrait generated from the creature's own geometry cannot contradict its
|
||
hit-location table. The engine reads that one definition rather than a copy of it. */
|
||
RINGBRP.locationTables = LOCATION_TABLES;
|
||
RINGBRP.bodyArt = BODY_ART;
|
||
|
||
/** 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" },
|
||
neck: { disabled: "RINGBRP.LocEffect.neck", destroyed: "RINGBRP.LocEffect.neckX" },
|
||
wing: { disabled: "RINGBRP.LocEffect.wing", destroyed: "RINGBRP.LocEffect.wingX" }
|
||
};
|
||
|
||
// 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. */
|
||
/* applyDifficulty, resolveBands and gradeRoll now live in rules.mjs — they are pure
|
||
rules and the lethality harness has to reach them from node, which cannot import this
|
||
file. Re-exported here so every existing caller and import path is unchanged. */
|
||
export { applyDifficulty, resolveBands, gradeRoll };
|
||
|
||
|
||
/** 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)
|
||
};
|
||
}
|
||
|
||
/* locationFor and resolveLocationHit moved to rules.mjs so the lethality harness can
|
||
import them — node cannot load this file. Re-exported below with the rest. */
|
||
|
||
/* -------------------------------------------- */
|
||
/* Documents */
|
||
/* -------------------------------------------- */
|
||
|
||
class RingBRPActor extends Actor {
|
||
|
||
/**
|
||
* Carry wounds across a change of body plan.
|
||
*
|
||
* locationDamage is keyed by location id and prepareDerivedData reads only the keys
|
||
* belonging to the current plan, so switching speciesProfile on a wounded actor left
|
||
* every existing wound in the document unreadable — R-93 all over again, except the
|
||
* orphan here is a limb rather than a flag. Found on a live Barghest that had been
|
||
* shot in the leg and chest as a humanoid: 12 points of damage, exactly its missing
|
||
* hit points, stranded under keys no quadruped location answers to.
|
||
*
|
||
* The remap is a rule and lives in rules.mjs. This only spots the change, applies the
|
||
* result to the same update, and says out loud what it moved — silently rearranging a
|
||
* GM's wounded creature is not something to do without a receipt.
|
||
*/
|
||
async _preUpdate(changed, options, user) {
|
||
const result = await super._preUpdate(changed, options, user);
|
||
if (result === false) return result;
|
||
|
||
const to = changed?.system?.speciesProfile;
|
||
const from = this.system?.speciesProfile || "baseline";
|
||
if (!to || to === from) return result;
|
||
|
||
const current = this.system?.locationDamage ?? {};
|
||
if (!Object.values(current).some(v => (Number(v) || 0) > 0)) return result;
|
||
|
||
const { damage, moved, rescaled } = remapLocationDamage(current, from, to,
|
||
Number(this.system?.hp?.max) || 0);
|
||
|
||
changed.system.locationDamage = locationDamageReplacement(current, damage);
|
||
|
||
if (moved.length) {
|
||
const BAND = { hurt: "", disabled: " (disabled)", destroyed: " (destroyed)" };
|
||
const lines = moved
|
||
.map(m => `${m.from} → ${m.to} ${m.became}${BAND[m.band] ?? ""}`)
|
||
.join(", ");
|
||
ChatMessage.create({
|
||
speaker: ChatMessage.getSpeaker({ actor: this }),
|
||
content: `<div class="ringbrp-chat"><h3>${this.name}: body plan changed</h3>`
|
||
+ `<p>${game.i18n.localize(LOCATION_TABLES[from]?.label ?? from)} → `
|
||
+ `${game.i18n.localize(LOCATION_TABLES[to]?.label ?? to)}</p>`
|
||
+ `<p>Wounds carried across: ${lines}.</p>`
|
||
+ (rescaled > 0
|
||
? `<p class="rb-note">${rescaled} rescaled to keep the same severity — `
|
||
+ `the locations are not the same size on both plans.</p>` : "")
|
||
+ `</div>`,
|
||
whisper: ChatMessage.getWhisperRecipients("GM").map(u => u.id)
|
||
});
|
||
}
|
||
return result;
|
||
}
|
||
|
||
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,
|
||
wings: loc.wings, flightLost: loc.flightLost,
|
||
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,
|
||
// Bleeding from an opened body cavity and bleeding from a Major Wound are
|
||
// two holes in the same person, so they add.
|
||
bleed: loc.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 })];
|
||
}
|
||
}
|
||
// Panic is the short track and it bites on everything, so it sits in the same stack
|
||
// the wound and encumbrance penalties already use — anything that computes a modifier
|
||
// and is not added here is a modifier nothing reads, which is this project's oldest
|
||
// way of shipping a rule that does not exist.
|
||
if (actor) {
|
||
const pan = panicPenaltyFor(actor.system?.panic?.value);
|
||
if (pan) {
|
||
situational = (Number(situational) || 0) + pan;
|
||
flavourLines = [...flavourLines, game.i18n.format("RINGBRP.Flavour.Panicking", { mod: pan })];
|
||
}
|
||
}
|
||
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
|
||
}
|
||
);
|
||
|
||
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") });
|
||
|
||
// Development is REVERSED from Call of Cthulhu: a skill is marked when it FAILS you,
|
||
// not when it works. Nobody learns anything from the lock that opened first time.
|
||
// The condition lives in rules.mjs so the rulebook, the primer and this cannot drift.
|
||
if (skillItem && developmentMarksOn(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 = await new Roll("1d10").evaluate();
|
||
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 = await new Roll("1d20").evaluate();
|
||
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>`
|
||
});
|
||
}
|
||
// A flyer with a hole in a wing is a quadruped.
|
||
//
|
||
// This is the creature's own written rule and it used to be worth nothing: the wing
|
||
// effect set `flightLost`, the sheet printed GROUNDED, and the animal went on
|
||
// attacking at the same skill for the same damage. Losing a wing now drops it to the
|
||
// plan its table names, which takes the wings off its hit chart and hands the six
|
||
// ranged faces they covered back to the body — so the shot that grounds it is also
|
||
// the shot that makes everything after it land somewhere that counts.
|
||
//
|
||
// The wing's own damage is dropped rather than carried. Every other wound moves
|
||
// across (the actor's _preUpdate does that), but a ruined wing must not arrive as a
|
||
// ruined foreleg: the creature is not lamed, it is earthbound, and the injury has
|
||
// already been paid out as the change of shape. Cleared first, in its own update, so
|
||
// the remap never sees it.
|
||
if (loc.kind === "wing" && (res.disabled || res.destroyed)) {
|
||
const grounded = groundedPlanFor(target.system.speciesProfile);
|
||
if (grounded && grounded !== target.system.speciesProfile) {
|
||
const wings = (target.system.locations ?? []).filter(l => l.kind === "wing").map(l => l.id);
|
||
const drop = Object.fromEntries(wings.map(id => [`system.locationDamage.-=${id}`, null]));
|
||
if (Object.keys(drop).length) await target.update(drop);
|
||
await target.update({ "system.speciesProfile": grounded });
|
||
await ChatMessage.create({
|
||
speaker: ChatMessage.getSpeaker({ actor: target }),
|
||
content: `<div class="ringbrp-chat rb-grounded"><strong>${
|
||
game.i18n.format("RINGBRP.Loc.Grounded", {
|
||
name: target.name,
|
||
plan: game.i18n.localize(RINGBRP.locationTables[grounded]?.label ?? grounded) })
|
||
}</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 = await new Roll(`1d${RINGBRP.reaction.die}`).evaluate();
|
||
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.
|
||
*/
|
||
/* The mapping itself lives in rules.mjs as woundPenaltyFrom, so the lethality harness
|
||
applies the same arithmetic to a plain object. This unwraps the actor, nothing more. */
|
||
export function woundPenaltyFor(actor, categoryId) {
|
||
return woundPenaltyFrom(actor?.system?.wounded, categoryId);
|
||
}
|
||
|
||
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;
|
||
}
|
||
|
||
RINGBRP.panicBands = PANIC_BANDS;
|
||
RINGBRP.panicTriggers = PANIC_TRIGGERS;
|
||
RINGBRP.panicRelief = PANIC_RELIEF;
|
||
|
||
export const panicBand = panicBandFrom;
|
||
|
||
/**
|
||
* Take a point of panic. No roll: a die to find out whether you are frightened slows
|
||
* the scene and tells the player how to feel. The die is for getting rid of it.
|
||
*/
|
||
export async function gainPanic(actor, triggerKey, { amount = null, note = "" } = {}) {
|
||
if (!actor) return null;
|
||
const spec = PANIC_TRIGGERS[triggerKey];
|
||
const cost = amount ?? spec?.cost ?? 1;
|
||
const before = Number(actor.system?.panic?.value ?? 0);
|
||
const after = panicAfter(before, cost);
|
||
const log = [...(actor.system?.panic?.log ?? []), { reason: triggerKey, cost, note }];
|
||
await actor.update({ "system.panic.value": after, "system.panic.log": log });
|
||
await panicCard(actor, before, after, game.i18n.localize(String(spec?.label ?? triggerKey ?? "")), note);
|
||
return panicBandFrom(after);
|
||
}
|
||
|
||
/**
|
||
* Put a point back. `level` is the outcome of whatever was rolled — a special takes two
|
||
* — so every relief in PANIC_RELIEF runs through one function and cannot drift.
|
||
*/
|
||
export async function easePanic(actor, reliefKey, level = "success", { note = "" } = {}) {
|
||
if (!actor) return null;
|
||
const spec = PANIC_RELIEF[reliefKey];
|
||
const back = panicReliefFor(level);
|
||
const before = Number(actor.system?.panic?.value ?? 0);
|
||
const after = panicAfter(before, -back);
|
||
await actor.update({ "system.panic.value": after });
|
||
// A fumble comes back as negative relief, so the card has to say so — otherwise it
|
||
// reads "Psychology" over a number that just went up, and the table reads it as a bug.
|
||
await panicCard(actor, before, after, game.i18n.localize(String(spec?.label ?? reliefKey ?? "")),
|
||
note, { backfired: back < 0 });
|
||
return panicBandFrom(after);
|
||
}
|
||
|
||
/** Breaking spends the panic: you come back at 3, because it is not a rest. */
|
||
export async function breakPanic(actor, choice = "freeze") {
|
||
if (!actor) return null;
|
||
const before = Number(actor.system?.panic?.value ?? 0);
|
||
const after = PANIC_AFTER_BREAK;
|
||
await actor.update({ "system.panic.value": after });
|
||
await panicCard(actor, before, after,
|
||
game.i18n.localize("RINGBRP.Panic.Breaks"),
|
||
PANIC_BREAK.includes(choice) ? choice : "freeze");
|
||
return panicBandFrom(after);
|
||
}
|
||
|
||
async function panicCard(actor, before, after, reason, note, { backfired = false } = {}) {
|
||
const band = panicBandFrom(after);
|
||
await ChatMessage.create({
|
||
speaker: ChatMessage.getSpeaker({ actor }),
|
||
content: await rbRenderTemplate("systems/ringbrp/templates/chat-panic.hbs", {
|
||
actorName: actor.name, before, after, reason, note,
|
||
band, breaks: panicBreaksAt(after), penalty: panicPenaltyFor(after), backfired,
|
||
choices: PANIC_BREAK,
|
||
pips: Array.from({ length: PANIC_MAX }, (_, i) => i < after)
|
||
})
|
||
});
|
||
}
|
||
|
||
/**
|
||
* Panic clears when the scene does — and until now there was no way to do that except
|
||
* editing five sheets by hand while the table waited. Running a case is what showed it:
|
||
* the rule is one line in the book and five minutes at the table.
|
||
*/
|
||
export async function clearPanic(actors, { reason = "" } = {}) {
|
||
const list = (Array.isArray(actors) ? actors : [actors]).filter(Boolean);
|
||
if (!list.length) return [];
|
||
const rows = [];
|
||
for (const a of list) {
|
||
const before = Number(a.system?.panic?.value ?? 0);
|
||
if (before) await a.update({ "system.panic.value": 0, "system.panic.log": [] });
|
||
rows.push({ name: a.name, before });
|
||
}
|
||
await ChatMessage.create({
|
||
speaker: { alias: game.i18n.localize("RINGBRP.Panic.Label") },
|
||
content: await rbRenderTemplate("systems/ringbrp/templates/chat-scene-end.hbs",
|
||
{ rows, reason, anyone: rows.some(r => r.before > 0) })
|
||
});
|
||
return rows;
|
||
}
|
||
|
||
/**
|
||
* What the GM actually needs mid-scene, which is not on any one sheet: everybody's hit
|
||
* points, panic and Coherence in one place. Running a case with five agents meant asking
|
||
* "what are you on?" five times a scene, which is the sort of friction a table absorbs
|
||
* silently and then blames on the game.
|
||
*/
|
||
export async function partyState(actors = null) {
|
||
const list = actors ?? (activeCaseFile() ? caseMembers(activeCaseFile())
|
||
: game.actors.filter(a => a.type === "character" && a.hasPlayerOwner));
|
||
const rows = (Array.isArray(list) ? list : [list]).filter(Boolean).map(a => {
|
||
const pv = Number(a.system?.panic?.value ?? 0);
|
||
const cv = Number(a.system?.coherence?.value ?? 10);
|
||
return {
|
||
name: a.name,
|
||
hp: `${a.system?.hp?.value ?? "?"}/${a.system?.hp?.max ?? "?"}`,
|
||
hurt: (a.system?.hp?.value ?? 0) < (a.system?.hp?.max ?? 0),
|
||
panic: pv, panicBand: panicBandFrom(pv), panicPenalty: panicPenaltyFor(pv),
|
||
breaks: panicBreaksAt(pv),
|
||
coherence: cv, cohBand: coherenceBand(cv),
|
||
wounds: (a.system?.wounds ?? []).length
|
||
};
|
||
});
|
||
await ChatMessage.create({
|
||
speaker: { alias: game.i18n.localize("RINGBRP.Party.Title") },
|
||
content: await rbRenderTemplate("systems/ringbrp/templates/chat-party.hbs", { rows })
|
||
});
|
||
return rows;
|
||
}
|
||
|
||
/**
|
||
* 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 = await new Roll(plan.formula).evaluate();
|
||
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} → ${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 = await new Roll(band.damage).evaluate();
|
||
// 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 = await new Roll(formula).evaluate();
|
||
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";
|
||
// locate:false — this loop does its own location roll per round below, and
|
||
// rollWeaponDamage now locates by default. Letting both fire would apply every
|
||
// round of the burst twice.
|
||
const dmg = await rollWeaponDamage({ actor, weapon, level, locate: false });
|
||
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">→</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} → ${after}` +
|
||
(after <= 0 ? ` — ${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 (developmentMarksOn(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;
|
||
}
|
||
|
||
/** Is the development phase open? Safe before init, so the sheet can ask freely. */
|
||
export function developmentOpen() {
|
||
try { return game.settings.get("ringbrp", "developmentPhase") === true; }
|
||
catch { return false; }
|
||
}
|
||
|
||
/**
|
||
* The GM opens the phase at the end of a case. Everybody's sheet grows a button; each
|
||
* player presses their own. Closing it again is the GM saying the case is finished.
|
||
*/
|
||
export async function setDevelopmentPhase(open = true) {
|
||
if (!game.user.isGM) { ui.notifications?.warn(game.i18n.localize("RINGBRP.Dev.GMOnly")); return null; }
|
||
await game.settings.set("ringbrp", "developmentPhase", !!open);
|
||
const marked = game.actors.filter(a => a.type === "character")
|
||
.map(a => ({ name: a.name,
|
||
n: a.items.filter(i => ["skill","power"].includes(i.type) && i.system.experienceTicked).length }))
|
||
.filter(r => r.n > 0);
|
||
await ChatMessage.create({
|
||
speaker: { alias: game.i18n.localize("RINGBRP.Dev.Title") },
|
||
content: await rbRenderTemplate("systems/ringbrp/templates/chat-development.hbs",
|
||
{ open: !!open, rows: marked })
|
||
});
|
||
return !!open;
|
||
}
|
||
|
||
/**
|
||
* 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 = developmentImproves(roll.total, rating);
|
||
const rolled = improved ? (await new Roll(DEVELOPMENT.gain).evaluate()).total : 0;
|
||
// A d100 gain can overshoot the top, so clip it to the skill's ceiling.
|
||
const gain = developmentGainFor(rating, rolled, sk.system.trainingCap);
|
||
const capped = improved && gain < rolled;
|
||
await sk.update({ "system.value": rating + gain, "system.experienceTicked": false });
|
||
results.push({ name: sk.name, rating, roll: roll.total, gain, improved, capped });
|
||
}
|
||
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 ? game.i18n.localize("RINGBRP.Exp.NoGain") :
|
||
r.gain ? `<strong>+${r.gain}</strong> → ${r.rating + r.gain}%${
|
||
r.capped ? ` <em>(${game.i18n.localize("RINGBRP.Exp.Capped")})</em>` : ""}` :
|
||
game.i18n.localize("RINGBRP.Exp.AtCeiling")}</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">←</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 posting or trade and skipped where that already
|
||
// covers it — a former police officer does not un-learn their sidearm to be taught it
|
||
// again at a worse number, and a Registry officer does not lose their Anomaly Lore.
|
||
//
|
||
// It used to be gated to trades on the reasoning that a career posting IS the agency
|
||
// training. Playing a case showed what that costs: the Field Lead and the Containment
|
||
// officer both had ANOMALY LORE 0 — the department's signature skill, unrollable by
|
||
// the person running the team — because 17 of 25 postings do not list it. Everybody
|
||
// the department employs went through the same six weeks. It is a floor now.
|
||
{
|
||
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="">—</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 = 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]}–${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.
|
||
* ============================================================ */
|
||
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.development = {
|
||
open: developmentOpen(),
|
||
marked: this.actor.items.filter(i => ["skill", "power"].includes(i.type)
|
||
&& i.system?.experienceTicked).length
|
||
};
|
||
ctx.panic = { band: panicBandFrom(sys.panic?.value),
|
||
penalty: panicPenaltyFor(sys.panic?.value),
|
||
pips: Array.from({ length: PANIC_MAX }, (_, i) => i < Number(sys.panic?.value ?? 0)) };
|
||
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='development']").forEach(el =>
|
||
el.addEventListener("click", async ev => {
|
||
ev.preventDefault(); ev.stopPropagation();
|
||
if (!developmentOpen()) { ui.notifications?.warn(game.i18n.localize("RINGBRP.Dev.NotOpen")); return; }
|
||
await endOfCase(this.actor);
|
||
this.render(false);
|
||
}));
|
||
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='panic-adjust']").forEach(el =>
|
||
el.addEventListener("click", async ev => {
|
||
ev.preventDefault(); ev.stopPropagation();
|
||
const delta = Number(ev.currentTarget.dataset.delta) || 0;
|
||
if (delta > 0) await gainPanic(this.actor, "wrongThing", { amount: 1 });
|
||
else await easePanic(this.actor, "steadyYourself", "success");
|
||
}));
|
||
root.querySelectorAll("[data-action='panic-break']").forEach(el =>
|
||
el.addEventListener("click", async ev => {
|
||
ev.preventDefault(); ev.stopPropagation();
|
||
await breakPanic(this.actor, ev.currentTarget.dataset.choice ?? "freeze");
|
||
}));
|
||
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.
|
||
*/
|
||
/**
|
||
* Which column of the location table a weapon reads.
|
||
*
|
||
* The tables carry separate melee and ranged spreads — a man with a spear is down
|
||
* among the legs, a man with a rifle is looking at a broadside — and nothing was
|
||
* choosing between them for an ordinary attack, because an ordinary attack never
|
||
* rolled a location at all.
|
||
*/
|
||
export function locationModeFor(weapon) {
|
||
return (weapon?.system?.weaponClass || "melee") === "melee" ? "melee" : "ranged";
|
||
}
|
||
|
||
/** The token this damage is for: the one named, else the one the user has targeted. */
|
||
function damageTargetFor(explicit = null) {
|
||
if (explicit) return explicit;
|
||
const t = [...(game.user?.targets ?? [])][0];
|
||
return t?.actor ?? null;
|
||
}
|
||
|
||
/**
|
||
* Roll a weapon's damage — and, since there is a hit location system, find out WHERE.
|
||
*
|
||
* This used to roll dice, print a number and stop. Every location in the game was
|
||
* derived, armoured, drawn on the sheet and wired to real consequences, and the only
|
||
* things that ever reached it were falling, fire, explosions and automatic fire. A
|
||
* grenade knew which arm it took off; a sword did not. Hit locations were effectively
|
||
* a hazards-only subsystem while the players spent every fight swinging and shooting.
|
||
*
|
||
* So the damage roll now carries through to `hitLocationRoll`, which already owned all
|
||
* of this: the d20, location armour, disable and destroy thresholds, the Major Wound
|
||
* check, hit points, dying and death. Nothing here re-implements any of it.
|
||
*
|
||
* With no target, it does exactly what it did before and says so — the card reports
|
||
* the damage and nothing is touched. That is the fallback, not the intent: target a
|
||
* token and the blow lands somewhere.
|
||
*/
|
||
export async function rollWeaponDamage({ actor = null, weapon, level = "success", band = null,
|
||
bonus = "", target = null, locate = true } = {}) {
|
||
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();
|
||
}
|
||
|
||
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
|
||
});
|
||
|
||
// Where it landed. hitLocationRoll applies the location's own armour, so the damage
|
||
// goes through RAW — handing it an already-reduced number would charge armour twice.
|
||
if (locate) {
|
||
const victim = damageTargetFor(target);
|
||
if (victim) {
|
||
await hitLocationRoll({ target: victim, damage: roll.total,
|
||
mode: locationModeFor(weapon), level });
|
||
} else {
|
||
// Say so rather than failing quietly. A GM who meant to target somebody needs
|
||
// to know the blow went nowhere, and one who did not is told how to make it.
|
||
await ChatMessage.create({
|
||
speaker: ChatMessage.getSpeaker({ actor: actor ?? undefined }),
|
||
content: `<div class="ringbrp-chat rb-chat-flavour rb-noloc">${
|
||
game.i18n.localize("RINGBRP.Loc.NoTarget")}</div>`
|
||
});
|
||
}
|
||
}
|
||
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 development phase. World-scoped and GM-only, because it is a thing the table
|
||
// does together at the end of a case rather than a preference. It is the first game
|
||
// setting this system registers, and it earns that: the players' button has to know
|
||
// whether the GM has opened the phase, and that answer must survive a reload.
|
||
game.settings.register("ringbrp", "developmentPhase", {
|
||
name: "RINGBRP.Dev.SettingName",
|
||
hint: "RINGBRP.Dev.SettingHint",
|
||
scope: "world", config: false, type: Boolean, default: false,
|
||
onChange: open => {
|
||
for (const a of game.actors) a.sheet?.rendered && a.sheet.render(false);
|
||
// getSceneControlButtons fires once at load, so the toggle would keep showing
|
||
// the state it had then if the phase were opened by anything else - the API, or
|
||
// a second GM. Foundry preserves `active` on toggle tools across a render, so
|
||
// setting it and re-rendering is what keeps the button honest.
|
||
const tool = ui.controls?.controls?.tokens?.tools?.ringbrpDevelopment;
|
||
if (tool) { tool.active = !!open; ui.controls.render(); }
|
||
}
|
||
});
|
||
|
||
// 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,
|
||
panicBand, panicPenaltyFor, panicBreaksAt, panicReliefFor, panicAfter,
|
||
gainPanic, easePanic, breakPanic, clearPanic, partyState,
|
||
developmentOpen, setDevelopmentPhase, developmentMarksOn, developmentImproves, developmentGainFor,
|
||
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,
|
||
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, locationModeFor,
|
||
remapLocationDamage, locationDamageReplacement, groundedPlanFor
|
||
};
|
||
});
|
||
|
||
/**
|
||
* 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));
|
||
});
|
||
}
|
||
|
||
// The GM needs a way to open the development phase that is not the console. It rides
|
||
// in the token controls beside the other table-facing tools, GM-only, and reflects the
|
||
// world setting so a reload or a second GM screen shows the true state.
|
||
Hooks.on("getSceneControlButtons", controls => {
|
||
if (!game.user?.isGM) return;
|
||
const group = controls?.tokens;
|
||
if (!group?.tools) return;
|
||
group.tools.ringbrpDevelopment = {
|
||
name: "ringbrpDevelopment",
|
||
order: Object.keys(group.tools).length + 1,
|
||
title: "RINGBRP.Dev.Toggle",
|
||
icon: "fa-solid fa-arrow-trend-up",
|
||
toggle: true,
|
||
active: developmentOpen(),
|
||
onChange: (_event, active) => setDevelopmentPhase(active)
|
||
};
|
||
});
|
||
|
||
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
|
||
});
|
||
}
|
||
}
|
||
});
|