Add Occult, Religion and Psychology, and the Panic track

From the first game's list. The three skills are not decoration — they are the
levers on the new track, which is why they arrived together.

PANIC, 0 to 5, up. It is the other half of Coherence and the rules page says so
in the first line, because a table that confuses them will make the wrong call
at the worst moment:

    Coherence   what knowing has cost you       weeks     10-0, down
    Panic       what is happening to you NOW    minutes    0-5, up

A trigger gives a point with NO ROLL. Asking for 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, which is the interesting direction.

    0 steady · 1 rattled · 2 shaken -5% · 3 losing it -10%
    4 overwhelmed -20% · 5 broken

The first point is free on purpose, so the opening shock of a scene costs the
table nothing. At 5 you break — freeze, flee or fixate, choose fast — and come
back at 3, not at 0, because breaking is not a rest.

Relief is where the skills come in. A success takes one point, a special takes
two. Psychology is the only thing that steadies somebody who is NOT you.
Religion steadies you alone and does not care whether you are right. Occult is
the odd one: naming a thing correctly, out loud, helps everybody who can hear —
the department's actual work, and the only relief that reaches a room. Plus
POW×5 to steady yourself, and your anchor object once per case, which is what
that briefing scene was for.

The three skills are placed in six registers rather than left floating: Occult
to the professor and to Registry, Psychology to the doctor and to Interview,
Religion to Antiquities and Pathology.

Guards, because a track nothing reads is this project's oldest failure: the five
new rules carry spot-checks in check-rules, and check-behaviour gains nine tests
— the free first point, monotonic penalties, only 5 breaking, clamping at both
ends, breaking not clearing the track, special taking two, every band/trigger/
relief having a label, and every relief naming a skill the catalogue actually
has.

Verified in a live game: penalty reaches the roll (58 → 58 → 53 → 48 → 38 for
panic 0-4, criticals and specials recomputed with it), the full arc runs 0 → 2 →
4 → eased to 2 → pushed to 5 → breaks → 3 with no errors, the sheet shows the
band, and the rules journal has a Panic page generated from the same tables.

Version 1.1.0.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
slaguru666
2026-08-12 22:55:11 +01:00
co-authored by Claude Opus 5
parent ab619f4b73
commit cbad87ba98
150 changed files with 496 additions and 134 deletions
+92 -1
View File
@@ -50,7 +50,9 @@ import {
ARTS, powerCostFor, powerStartingRating,
CROSSING, crossingArtPoolFor, crossingGrantsArt, crossingCostFor, REACTION,
CIRCUMSTANCE, RANGE_LADDER, rangeBandFrom, MAJOR_WOUND_TABLE,
COHERENCE_BANDS, COHERENCE_COSTS, COHERENCE_RECOVERY, coherenceBandFrom
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
} from "./rules.mjs";
export { hitPointsFor, majorWoundFor, damageModifierFor, locationMaxHp, reflexRating, awarenessRating };
@@ -1030,6 +1032,17 @@ export async function skillRoll({
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) {
@@ -1534,6 +1547,68 @@ export async function loseCoherence(actor, reasonKey, { amount = null, note = ""
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 });
await panicCard(actor, before, after, game.i18n.localize(String(spec?.label ?? reliefKey ?? "")), note);
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) {
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),
choices: PANIC_BREAK,
pips: Array.from({ length: PANIC_MAX }, (_, i) => i < after)
})
});
}
/**
* 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
@@ -4754,6 +4829,9 @@ class RingBRPCharacterSheet extends ActorSheet {
ctx.reaction = this._reactionRail();
ctx.readiness = this._readiness ?? { rating: 0 };
ctx.coherence = { band: coherenceBand(sys.coherence?.value) };
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");
@@ -4847,6 +4925,18 @@ class RingBRPCharacterSheet extends ActorSheet {
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();
@@ -6085,6 +6175,7 @@ Hooks.once("init", () => {
reactionLoadFrom, reactionPrintedFrom,
readinessRating, percentileFaces, circumstanceTotal,
coherenceBand, coherencePenaltyFor, woundPenaltyFor, loseCoherence, regainCoherence, displace,
panicBand, panicPenaltyFor, panicBreaksAt, gainPanic, easePanic, breakPanic,
isAcross, setAcross, useTransposed, debrief, activeCaseFile, returnCrossing, caseMembers,
earnedDiscoveries, deviceFunctionStatus, earnDiscovery, useDeviceFunction, pickContactActor,
resolveContact, newEncounter, claimRewards, activeRewardEffects,