2 Commits
Author SHA1 Message Date
slaguru666andClaude Opus 5 7ec7efd29b Replay the scene with the fumble rule; expose panicReliefFor
Ran the east corridor again with fresh dice and every relief attempt routed
through easePanic, fumbles included.

The rule did not fire this time. All three attempts failed cleanly — Psychology
58 against 40, and two POW×5 rolls at 66 and 76 — so nobody was punished for
trying. That is worth reporting as loudly as a hit would have been: two
play-throughs in and a fumbled relief has come up once, which is about right for
something meant to be a memorable disaster rather than a tax.

Braithwaite still broke at 5 on "it said your name" and fled. Marrow went down
in a single round to a special with the entry bar — 19 through armour — against
the 4.5-round average the thirty-run tuning gave, so that was dice, not balance.

Then the question the rule actually raises. Relief rolls are made at your own
panic penalty, so the worse you are the worse your odds AND the wider your fumble
range — and now a fumble adds a point. That is a feedback loop, so I measured it
rather than trusting it, 300 runs per row:

    from 2, POW×5 60%   280 calm ·  0 broke
    from 3, POW×5 60%   227 calm ·  2 broke
    from 4, POW×5 60%   138 calm · 31 broke
    from 4, POW×5 50%    96 calm · 35 broke
    from 4, POW×5 85%   267 calm ·  5 broke

Recovery dominates everywhere. The worst case — overwhelmed, weak POW, no help —
spirals into breaking about one time in eight, which is a bad night rather than a
trap. No change needed.

Also: panicReliefFor and panicAfter were missing from the API, which I only
noticed because measuring the loop needed them and the alternative was
re-implementing the rule in the test — the exact duplication check-rules exists
to forbid. Both exposed.

Version 1.1.3.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-17 23:31:35 +01:00
slaguru666andClaude Opus 5 5aaae186e0 A fumbled relief attempt puts a point on
Trying to steady somebody and making it worse is the most familiar thing on that
list, and until now it was the only outcome that cost nothing.

Done as a NEGATIVE relief rather than a second code path: panicReliefFor returns
-1 on a fumble, and easePanic already subtracts what it is given, so subtracting
a negative raises the track. One number, one direction, no branch that could
drift from the other. The clamp still holds — fumbling at 5 stays at 5.

The chat card had to be told, because otherwise it prints the word "Psychology"
over a number that has just gone UP and the table reads that as a bug rather than
as the rule. It now leads with a red FUMBLED tag.

Guarded three ways: a spot-check in check-rules for the -1 itself, and two
behaviour tests — one that the sign survives easePanic's subtraction and
actually raises the track, one that it cannot push past the top. A test on the
constant alone would have passed even if easePanic had healed on a fumble.

Verified live: 2 → fumble → 3 → success → 2 → special → 0, and a fumble at 5
staying at 5, with the cards reading correctly.

Version 1.1.2.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-17 23:25:46 +01:00
28 changed files with 68 additions and 31 deletions
+3 -3
View File
@@ -180,9 +180,9 @@ The build runs all five guards before it writes anything:
<!-- guards:start -->
```
check-rules: OK — 92 rules, 20 files scanned, 3 aliases + 45 constant sets checked, 290 formulas verified
check-rules: OK — 92 rules, 20 files scanned, 3 aliases + 45 constant sets checked, 291 formulas verified
check-kits: OK — 25 roles, 10 trades, 218 catalogue items, every kit key resolves, every posting can use what it carries, every loadout distinct
check-lang: OK — en.json, 976 keys, no leaf/branch collisions
check-behaviour: OK — 55 behavioural tests
check-lang: OK — en.json, 977 keys, no leaf/branch collisions
check-behaviour: OK — 57 behavioural tests
```
<!-- guards:end -->
+2 -1
View File
@@ -974,5 +974,6 @@
"RINGBRP.Panic.Breaks": "BREAKS — freeze, flee, or fixate. Choose, take it, come back at 3.",
"RINGBRP.Panic.NoChange": "No change.",
"RINGBRP.Flavour.Panicking": "panicking ({mod}%)",
"RINGBRP.Chat.ToEverything": "to everything"
"RINGBRP.Chat.ToEverything": "to everything",
"RINGBRP.Panic.Backfired": "Fumbled —"
}
+1 -1
View File
@@ -1 +1 @@
2026/08/17-21:58:29.401379 16e463000 Delete type=3 #1
2026/08/17-23:31:35.181337 1726ef000 Delete type=3 #1
+1 -1
View File
@@ -1 +1 @@
2026/08/17-21:58:29.473673 16fc87000 Delete type=3 #1
2026/08/17-23:31:35.256758 173707000 Delete type=3 #1
+1 -1
View File
@@ -1 +1 @@
2026/08/17-21:58:29.403187 16e463000 Delete type=3 #1
2026/08/17-23:31:35.183232 171ee3000 Delete type=3 #1
+1 -1
View File
@@ -1 +1 @@
2026/08/17-21:58:29.456562 16ec6f000 Delete type=3 #1
2026/08/17-23:31:35.237581 171ee3000 Delete type=3 #1
+1 -1
View File
@@ -1 +1 @@
2026/08/17-21:58:29.438818 16ec6f000 Delete type=3 #1
2026/08/17-23:31:35.219821 173707000 Delete type=3 #1
+1 -1
View File
@@ -1 +1 @@
2026/08/17-21:58:29.468218 16f47b000 Delete type=3 #1
2026/08/17-23:31:35.250188 1726ef000 Delete type=3 #1
+1 -1
View File
@@ -1 +1 @@
2026/08/17-21:58:29.424479 16f47b000 Delete type=3 #1
2026/08/17-23:31:35.204640 173707000 Delete type=3 #1
+1 -1
View File
@@ -1 +1 @@
2026/08/17-21:58:29.464811 16f47b000 Delete type=3 #1
2026/08/17-23:31:35.246285 171ee3000 Delete type=3 #1
+1 -1
View File
@@ -1 +1 @@
2026/08/17-21:58:29.461075 16f47b000 Delete type=3 #1
2026/08/17-23:31:35.242302 172efb000 Delete type=3 #1
Binary file not shown.
+1 -1
View File
@@ -1 +1 @@
2026/08/17-21:58:29.471824 16e463000 Delete type=3 #1
2026/08/17-23:31:35.254558 1726ef000 Delete type=3 #1
+1 -1
View File
@@ -1 +1 @@
2026/08/17-21:58:29.395237 16e463000 Delete type=3 #1
2026/08/17-23:31:35.174562 171ee3000 Delete type=3 #1
+1 -1
View File
@@ -1 +1 @@
2026/08/17-21:58:29.466508 16ec6f000 Delete type=3 #1
2026/08/17-23:31:35.248311 1726ef000 Delete type=3 #1
+1 -1
View File
@@ -1 +1 @@
2026/08/17-21:58:29.449085 16ec6f000 Delete type=3 #1
2026/08/17-23:31:35.230398 172efb000 Delete type=3 #1
+1 -1
View File
@@ -1 +1 @@
2026/08/17-21:58:29.443432 16ec6f000 Delete type=3 #1
2026/08/17-23:31:35.224441 1726ef000 Delete type=3 #1
+1 -1
View File
@@ -1 +1 @@
2026/08/17-21:58:29.397941 16fc87000 Delete type=3 #1
2026/08/17-23:31:35.177864 1726ef000 Delete type=3 #1
+1 -1
View File
@@ -1 +1 @@
2026/08/17-21:58:29.405177 16fc87000 Delete type=3 #1
2026/08/17-23:31:35.185513 171ee3000 Delete type=3 #1
+1 -1
View File
@@ -1 +1 @@
2026/08/17-21:58:29.399814 16fc87000 Delete type=3 #1
2026/08/17-23:31:35.179597 1726ef000 Delete type=3 #1
+8 -4
View File
@@ -1580,7 +1580,10 @@ export async function easePanic(actor, reliefKey, level = "success", { note = ""
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);
// 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);
}
@@ -1596,13 +1599,13 @@ export async function breakPanic(actor, choice = "freeze") {
return panicBandFrom(after);
}
async function panicCard(actor, before, after, reason, note) {
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),
band, breaks: panicBreaksAt(after), penalty: panicPenaltyFor(after), backfired,
choices: PANIC_BREAK,
pips: Array.from({ length: PANIC_MAX }, (_, i) => i < after)
})
@@ -6175,7 +6178,8 @@ Hooks.once("init", () => {
reactionLoadFrom, reactionPrintedFrom,
readinessRating, percentileFaces, circumstanceTotal,
coherenceBand, coherencePenaltyFor, woundPenaltyFor, loseCoherence, regainCoherence, displace,
panicBand, panicPenaltyFor, panicBreaksAt, gainPanic, easePanic, breakPanic,
panicBand, panicPenaltyFor, panicBreaksAt, panicReliefFor, panicAfter,
gainPanic, easePanic, breakPanic,
isAcross, setAcross, useTransposed, debrief, activeCaseFile, returnCrossing, caseMembers,
earnedDiscoveries, deviceFunctionStatus, earnDiscovery, useDeviceFunction, pickContactActor,
resolveContact, newEncounter, claimRewards, activeRewardEffects,
+9 -1
View File
@@ -358,10 +358,18 @@ export function panicAfter(value, delta) {
return Math.max(0, Math.min(PANIC_MAX, (Number(value) || 0) + (Number(delta) || 0)));
}
/** A success takes one point, a special or better takes two. */
/**
* A success takes one point, a special or better takes two — and a FUMBLE puts one on.
*
* Returned as a negative relief so the whole thing stays one number and one direction:
* easePanic subtracts what this gives it, so -1 subtracts a negative and the track goes
* up. Trying to talk somebody down and making it worse is the most familiar thing in
* this list, and until now it was the only outcome that cost nothing.
*/
export function panicReliefFor(level) {
if (level === "critical" || level === "special") return 2;
if (level === "success") return 1;
if (level === "fumble") return -1;
return 0;
}
+9
View File
@@ -1831,3 +1831,12 @@
color: var(--rb-void); background: var(--rb-danger);
padding: 5px 8px; margin: 6px 0 0;
}
/* A fumbled relief puts a point ON. The card must say which way it went, or it reads
as "Psychology" printed over a number that just rose, and the table reads that as a
bug rather than as the rule. */
.rb-panic-worse {
font-family: var(--rb-body); font-size: 11px; font-weight: 700; letter-spacing: .08em;
text-transform: uppercase; color: var(--rb-void); background: var(--rb-danger);
padding: 2px 6px; margin-right: 6px;
}
+1 -1
View File
@@ -2,7 +2,7 @@
"id": "ringbrp",
"title": "The Custodians",
"description": "A Basic Roleplaying game of agents who cross the crossing to contain what should not be there — and who come back a little less from here each time.",
"version": "1.1.1",
"version": "1.1.3",
"compatibility": {
"minimum": 13,
"verified": "14.364"
+1 -1
View File
@@ -13,7 +13,7 @@
<span class="rb-panic-band rb-panic-{{band.id}}">{{localize band.label}}</span>
</div>
<p class="rb-panic-reason">{{reason}}{{#if note}} — <em>{{note}}</em>{{/if}}</p>
<p class="rb-panic-reason">{{#if backfired}}<span class="rb-panic-worse">{{localize "RINGBRP.Panic.Backfired"}}</span> {{/if}}{{reason}}{{#if note}} — <em>{{note}}</em>{{/if}}</p>
<p class="rb-panic-note">{{localize band.note}}</p>
{{#if penalty}}<p class="rb-panic-pen">{{penalty}}% {{localize "RINGBRP.Chat.ToEverything"}}</p>{{/if}}
+14 -2
View File
@@ -482,13 +482,25 @@ test("breaking does not reset you to calm", () => {
assert.ok(RULES.PANIC_AFTER_BREAK > 0, "breaking wipes the track clean");
assert.ok(RULES.PANIC_AFTER_BREAK < RULES.PANIC_MAX, "breaking leaves you still broken");
});
test("a special takes two points, a failure takes none", () => {
test("a special takes two points, a failure takes none, a fumble ADDS one", () => {
assert.equal(RULES.panicReliefFor("special"), 2);
assert.equal(RULES.panicReliefFor("critical"), 2);
assert.equal(RULES.panicReliefFor("success"), 1);
assert.equal(RULES.panicReliefFor("failure"), 0);
assert.equal(RULES.panicReliefFor("fumble"), 0);
// Negative relief. Trying to steady somebody and making it worse is the most familiar
// thing on the list and it used to be the only outcome that cost nothing.
assert.equal(RULES.panicReliefFor("fumble"), -1);
});
// The sign has to survive the arithmetic easePanic does, or a fumble quietly heals.
test("a fumbled relief puts a point ON, through the same subtraction", () => {
const before = 2;
const after = RULES.panicAfter(before, -RULES.panicReliefFor("fumble"));
assert.equal(after, 3, "a fumbled relief did not raise panic");
assert.equal(RULES.panicAfter(before, -RULES.panicReliefFor("success")), 1);
});
// And it must still not be able to push past the top.
test("a fumbled relief at 5 stays at 5", () =>
assert.equal(RULES.panicAfter(5, -RULES.panicReliefFor("fumble")), RULES.PANIC_MAX));
// Every band and every trigger and relief must be sayable, or the chat card prints a key.
test("every panic band, trigger and relief has a label", async () => {
const { readFile } = await import("node:fs/promises");
+1
View File
@@ -196,6 +196,7 @@ const checks = [
["panicReliefFor('success')", RULES.panicReliefFor("success"), 1],
["panicReliefFor('special')", RULES.panicReliefFor("special"), 2],
["panicReliefFor('failure')", RULES.panicReliefFor("failure"), 0],
["panicReliefFor('fumble')", RULES.panicReliefFor("fumble"), -1],
// a lost location must cost something, for every kind the species tables use
["locationEffectsFor([]) is inert", RULES.locationEffectsFor([]).all, 0],
["leg disabled → physical", RULES.locationEffectsFor([{kind:"leg",disabled:true}]).physical, -30],
+3 -1
View File
@@ -546,7 +546,9 @@ ${table(["This happens", "Panic"], panicTrigRows)}
<h2>Getting rid of one</h2>
<p>Roll it. A success takes one point off; a <strong>special or better takes two</strong>.
A failure takes nothing and costs nothing but the moment you spent on it.</p>
A failure costs nothing but the moment you spent on it — and a <strong>fumble puts a
point ON</strong>. Trying to steady somebody and making it worse is the most familiar
thing on this list.</p>
${table(["How", "Reaches", "How often"], panicReliefRows)}
<p><strong>Occult is the odd one.</strong> Naming a thing correctly, out loud, helps