3 Commits
Author SHA1 Message Date
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
slaguru666andClaude Opus 5 20306d83d9 Play a scene with panic; tighten one reference row
Ran LAST ADMISSION's east corridor with the six civilians and real dice. The
track works end to end and, more importantly, the penalty REACHES things:
Ferriby at panic 3 swung at Old Marrow with Brawl 25% printed and 15% effective,
while Sharrock at panic 1 rolled his full 58%. That is the whole point of the
track and it is the part that is usually decoration.

The arc played well. Lights out took four of them to 1. The body took Braithwaite
and Ferriby to 2. Enderby counting took three of them again. Braithwaite fumbled
his Psychology roll trying to talk Colin down, then it said his name — 5, broke,
fled, came back at 3. Renshaw steadied herself on POW×5 and dropped to 1.

One thing I got wrong and one thing worth knowing:

  - I flagged a 99 grading as a fumble as a bug. It is not. A 58% skill fumbles
    on 98-00, and both the primer and the rules page say so in those words. The
    "96-99 never succeed / 00 always fumbles" pair are floor rules, not the whole
    fumble range. Only the quick-reference row was ambiguous — it now says
    "never succeeds ... and is a fumble if it is inside your fumble range".

  - The civilians cannot use two of the three new skills. Occult, Religion and
    Psychology sit at 5% on the "(before)" sheets, so in the prologue the only
    relief that works is POW×5 at 50-85%. That is correct — a site foreman is
    not an occultist — but it means the skills are an AGENT'S tools, and the
    prologue is deliberately the game without them. The agents have them where
    they should: Bhattacharya Occult 63, Lindqvist 58, Renshaw Psychology 40,
    Braithwaite Religion 35.

Version 1.1.1.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-17 21:58:30 +01:00
slaguru666andClaude Opus 5 cbad87ba98 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>
2026-08-12 22:55:11 +01:00
69 changed files with 602 additions and 119 deletions
+5 -5
View File
@@ -65,13 +65,13 @@ changing underneath a running server.
| **Postings** | 25, each with a key characteristic, trained skills, talents and its own kit |
| **Trades** | 10 ordinary jobs the department recruits from, plus induction |
| **Catalogue** | 236 items — 123 gear, 62 weapons, 32 armour, 19 vehicles |
| **Skills / talents** | 59 skills, 19 talents |
| **Skills / talents** | 62 skills, 19 talents |
| **Bestiary** | 21 statblocks across folklore, horror and the far side |
| **Eras** | modern, antique, future, anomalous — the agency is old and never abolishes anything |
| **Icons** | 292, all generated from `tools/icons.mjs`; no external art |
| **Starter scenario** | **BLACK PLATFORM**, a six-player action starter, importable as a single Adventure |
| **Compendia** | pregens, NPCs, skills, weapons, armour, gear, vehicles, powers, tables, rules, the starter |
| **Rules journal** | 12 pages, generated from `rules.mjs` so the book cannot drift from the code |
| **Rules journal** | 13 pages, generated from `rules.mjs` so the book cannot drift from the code |
<!-- box:end -->
---
@@ -180,9 +180,9 @@ The build runs all five guards before it writes anything:
<!-- guards:start -->
```
check-rules: OK — 87 rules, 20 files scanned, 3 aliases + 41 constant sets checked, 275 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, 945 keys, no leaf/branch collisions
check-behaviour: OK — 46 behavioural tests
check-lang: OK — en.json, 977 keys, no leaf/branch collisions
check-behaviour: OK — 57 behavioural tests
```
<!-- guards:end -->
+33 -1
View File
@@ -943,5 +943,37 @@
"RINGBRP.Item.ArtRating": "Rating",
"RINGBRP.Item.PushCost": "Push cost",
"RINGBRP.Item.PushRule": "What pushing does",
"RINGBRP.Item.Origin": "Where it came from"
"RINGBRP.Item.Origin": "Where it came from",
"RINGBRP.Panic.Label": "Panic",
"RINGBRP.Panic.Steady": "Steady",
"RINGBRP.Panic.SteadyNote": "You are all right. You are doing the job.",
"RINGBRP.Panic.Rattled": "Rattled",
"RINGBRP.Panic.RattledNote": "Your hands are not quite steady. No penalty — the first one is free.",
"RINGBRP.Panic.Shaken": "Shaken",
"RINGBRP.Panic.ShakenNote": "−5% to everything. You keep checking the way you came in.",
"RINGBRP.Panic.Losing": "Losing it",
"RINGBRP.Panic.LosingNote": "−10% to everything. You are talking too fast and not hearing answers.",
"RINGBRP.Panic.Overwhelmed": "Overwhelmed",
"RINGBRP.Panic.OverwhelmedNote": "−20% to everything. One more and you go.",
"RINGBRP.Panic.Broken": "Broken",
"RINGBRP.Panic.BrokenNote": "You freeze, run, or go at it. Choose one, take it, and come back at 3.",
"RINGBRP.Panic.Trig.WrongThing": "Something that should not be there",
"RINGBRP.Panic.Trig.Touched": "It touched you",
"RINGBRP.Panic.Trig.Body": "A body",
"RINGBRP.Panic.Trig.Known": "Somebody you knew",
"RINGBRP.Panic.Trig.Dark": "The lights went",
"RINGBRP.Panic.Trig.Wounded": "A major wound",
"RINGBRP.Panic.Trig.Named": "It said your name",
"RINGBRP.Panic.Relief.Steady": "Steady yourself (POW×5)",
"RINGBRP.Panic.Relief.Religion": "Religion — something older than the department",
"RINGBRP.Panic.Relief.Psychology": "Psychology — talk somebody else down",
"RINGBRP.Panic.Relief.Occult": "Occult — name it, out loud, for everyone who can hear",
"RINGBRP.Panic.Relief.Anchor": "Your anchor, once per case",
"RINGBRP.Panic.Gained": "{name} — panic {from} → {to}",
"RINGBRP.Panic.Eased": "{name} — panic {from} → {to}",
"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.Panic.Backfired": "Fumbled —"
}
+5 -5
View File
@@ -1,5 +1,5 @@
2026/08/12-22:44:48.920895 172817000 Recovering log #3
2026/08/12-22:44:48.921047 172817000 Level-0 table #5: started
2026/08/12-22:44:48.921223 172817000 Level-0 table #5: 4591 bytes OK
2026/08/12-22:44:48.921517 172817000 Delete type=0 #3
2026/08/12-22:44:48.921554 172817000 Delete type=3 #2
2026/08/17-23:24:47.247472 17253f000 Recovering log #3
2026/08/17-23:24:47.247505 17253f000 Level-0 table #5: started
2026/08/17-23:24:47.247700 17253f000 Level-0 table #5: 4591 bytes OK
2026/08/17-23:24:47.248094 17253f000 Delete type=0 #3
2026/08/17-23:24:47.248137 17253f000 Delete type=3 #2
+1 -1
View File
@@ -1 +1 @@
2026/08/12-22:44:33.287733 16f5db000 Delete type=3 #1
2026/08/17-23:24:31.473921 16e827000 Delete type=3 #1
+5 -5
View File
@@ -1,5 +1,5 @@
2026/08/12-22:44:48.946376 17382f000 Recovering log #3
2026/08/12-22:44:48.946637 17382f000 Level-0 table #5: started
2026/08/12-22:44:48.946788 17382f000 Level-0 table #5: 4129 bytes OK
2026/08/12-22:44:48.947029 17382f000 Delete type=0 #3
2026/08/12-22:44:48.947065 17382f000 Delete type=3 #2
2026/08/17-23:24:47.267497 173d63000 Recovering log #3
2026/08/17-23:24:47.267518 173d63000 Level-0 table #5: started
2026/08/17-23:24:47.267658 173d63000 Level-0 table #5: 4129 bytes OK
2026/08/17-23:24:47.267918 173d63000 Delete type=0 #3
2026/08/17-23:24:47.267949 173d63000 Delete type=3 #2
+1 -1
View File
@@ -1 +1 @@
2026/08/12-22:44:33.362147 16edcf000 Delete type=3 #1
2026/08/17-23:24:31.544854 16e827000 Delete type=3 #1
+5 -5
View File
@@ -1,5 +1,5 @@
2026/08/12-22:44:48.922471 173023000 Recovering log #3
2026/08/12-22:44:48.923346 173023000 Level-0 table #5: started
2026/08/12-22:44:48.923621 173023000 Level-0 table #5: 31614 bytes OK
2026/08/12-22:44:48.923809 173023000 Delete type=0 #3
2026/08/12-22:44:48.923836 173023000 Delete type=3 #2
2026/08/17-23:24:47.248920 173d63000 Recovering log #3
2026/08/17-23:24:47.249014 173d63000 Level-0 table #5: started
2026/08/17-23:24:47.249313 173d63000 Level-0 table #5: 31614 bytes OK
2026/08/17-23:24:47.249730 173d63000 Delete type=0 #3
2026/08/17-23:24:47.249796 173d63000 Delete type=3 #2
+1 -1
View File
@@ -1 +1 @@
2026/08/12-22:44:33.290003 16f5db000 Delete type=3 #1
2026/08/17-23:24:31.475804 17004b000 Delete type=3 #1
Binary file not shown.
Binary file not shown.
+5 -5
View File
@@ -1,5 +1,5 @@
2026/08/12-22:44:48.936635 17200b000 Recovering log #3
2026/08/12-22:44:48.937301 17200b000 Level-0 table #5: started
2026/08/12-22:44:48.937794 17200b000 Level-0 table #5: 45532 bytes OK
2026/08/12-22:44:48.938219 17200b000 Delete type=0 #3
2026/08/12-22:44:48.938275 17200b000 Delete type=3 #2
2026/08/17-23:24:47.261246 17253f000 Recovering log #3
2026/08/17-23:24:47.261511 17253f000 Level-0 table #5: started
2026/08/17-23:24:47.261915 17253f000 Level-0 table #5: 46857 bytes OK
2026/08/17-23:24:47.262265 17253f000 Delete type=0 #3
2026/08/17-23:24:47.262307 17253f000 Delete type=3 #2
+1 -1
View File
@@ -1 +1 @@
2026/08/12-22:44:33.345401 16e5c3000 Delete type=3 #1
2026/08/17-23:24:31.528809 16f033000 Delete type=3 #1
Binary file not shown.
Binary file not shown.
Binary file not shown.
+5 -5
View File
@@ -1,5 +1,5 @@
2026/08/12-22:44:48.907196 173023000 Recovering log #3
2026/08/12-22:44:48.908927 173023000 Level-0 table #5: started
2026/08/12-22:44:48.910392 173023000 Level-0 table #5: 225945 bytes OK
2026/08/12-22:44:48.910874 173023000 Delete type=0 #3
2026/08/12-22:44:48.910934 173023000 Delete type=3 #2
2026/08/17-23:24:47.235329 173d63000 Recovering log #3
2026/08/17-23:24:47.236302 173d63000 Level-0 table #5: started
2026/08/17-23:24:47.238305 173d63000 Level-0 table #5: 234672 bytes OK
2026/08/17-23:24:47.238754 173d63000 Delete type=0 #3
2026/08/17-23:24:47.238827 173d63000 Delete type=3 #2
+1 -1
View File
@@ -1 +1 @@
2026/08/12-22:44:33.327015 16f5db000 Delete type=3 #1
2026/08/17-23:24:31.510747 16f83f000 Delete type=3 #1
Binary file not shown.
+5 -5
View File
@@ -1,5 +1,5 @@
2026/08/12-22:44:48.917345 172817000 Recovering log #3
2026/08/12-22:44:48.917660 172817000 Level-0 table #5: started
2026/08/12-22:44:48.917794 172817000 Level-0 table #5: 3295 bytes OK
2026/08/12-22:44:48.918046 172817000 Delete type=0 #3
2026/08/12-22:44:48.918081 172817000 Delete type=3 #2
2026/08/17-23:24:47.244550 17253f000 Recovering log #3
2026/08/17-23:24:47.244581 17253f000 Level-0 table #5: started
2026/08/17-23:24:47.244685 17253f000 Level-0 table #5: 3295 bytes OK
2026/08/17-23:24:47.244965 17253f000 Delete type=0 #3
2026/08/17-23:24:47.245003 17253f000 Delete type=3 #2
+1 -1
View File
@@ -1 +1 @@
2026/08/12-22:44:33.356982 16edcf000 Delete type=3 #1
2026/08/17-23:24:31.539431 17004b000 Delete type=3 #1
Binary file not shown.
Binary file not shown.
+5 -5
View File
@@ -1,5 +1,5 @@
2026/08/12-22:44:48.896053 17200b000 Recovering log #3
2026/08/12-22:44:48.898901 17200b000 Level-0 table #5: started
2026/08/12-22:44:48.902279 17200b000 Level-0 table #5: 545293 bytes OK
2026/08/12-22:44:48.902701 17200b000 Delete type=0 #3
2026/08/12-22:44:48.902787 17200b000 Delete type=3 #2
2026/08/17-23:24:47.222820 172d4b000 Recovering log #3
2026/08/17-23:24:47.225060 172d4b000 Level-0 table #5: started
2026/08/17-23:24:47.229466 172d4b000 Level-0 table #5: 561098 bytes OK
2026/08/17-23:24:47.229981 172d4b000 Delete type=0 #3
2026/08/17-23:24:47.230132 172d4b000 Delete type=3 #2
+1 -1
View File
@@ -1 +1 @@
2026/08/12-22:44:33.311753 16e5c3000 Delete type=3 #1
2026/08/17-23:24:31.496406 16e827000 Delete type=3 #1
Binary file not shown.
+5 -5
View File
@@ -1,5 +1,5 @@
2026/08/12-22:44:48.925841 17382f000 Recovering log #3
2026/08/12-22:44:48.926073 17382f000 Level-0 table #5: started
2026/08/12-22:44:48.926304 17382f000 Level-0 table #5: 21613 bytes OK
2026/08/12-22:44:48.926557 17382f000 Delete type=0 #3
2026/08/12-22:44:48.926598 17382f000 Delete type=3 #2
2026/08/17-23:24:47.251999 173d63000 Recovering log #3
2026/08/17-23:24:47.252036 173d63000 Level-0 table #5: started
2026/08/17-23:24:47.252324 173d63000 Level-0 table #5: 21613 bytes OK
2026/08/17-23:24:47.252692 173d63000 Delete type=0 #3
2026/08/17-23:24:47.252736 173d63000 Delete type=3 #2
+1 -1
View File
@@ -1 +1 @@
2026/08/12-22:44:33.353318 16edcf000 Delete type=3 #1
2026/08/17-23:24:31.536318 17004b000 Delete type=3 #1
Binary file not shown.
Binary file not shown.
+5 -5
View File
@@ -1,5 +1,5 @@
2026/08/12-22:44:48.939398 17200b000 Recovering log #3
2026/08/12-22:44:48.940505 17200b000 Level-0 table #5: started
2026/08/12-22:44:48.941454 17200b000 Level-0 table #5: 43953 bytes OK
2026/08/12-22:44:48.942198 17200b000 Delete type=0 #3
2026/08/12-22:44:48.942249 17200b000 Delete type=3 #2
2026/08/17-23:24:47.263027 17253f000 Recovering log #3
2026/08/17-23:24:47.263228 17253f000 Level-0 table #5: started
2026/08/17-23:24:47.263611 17253f000 Level-0 table #5: 44887 bytes OK
2026/08/17-23:24:47.263871 17253f000 Delete type=0 #3
2026/08/17-23:24:47.263914 17253f000 Delete type=3 #2
+1 -1
View File
@@ -1 +1 @@
2026/08/12-22:44:33.349699 16f5db000 Delete type=3 #1
2026/08/17-23:24:31.533004 16e827000 Delete type=3 #1
Binary file not shown.
Binary file not shown.
Binary file not shown.
+5 -5
View File
@@ -1,5 +1,5 @@
2026/08/12-22:44:48.927292 172817000 Recovering log #3
2026/08/12-22:44:48.927647 172817000 Level-0 table #5: started
2026/08/12-22:44:48.928175 172817000 Level-0 table #5: 33484 bytes OK
2026/08/12-22:44:48.928522 172817000 Delete type=0 #3
2026/08/12-22:44:48.928579 172817000 Delete type=3 #2
2026/08/17-23:24:47.253858 172d4b000 Recovering log #3
2026/08/17-23:24:47.253959 172d4b000 Level-0 table #5: started
2026/08/17-23:24:47.254345 172d4b000 Level-0 table #5: 36526 bytes OK
2026/08/17-23:24:47.254740 172d4b000 Delete type=0 #3
2026/08/17-23:24:47.254786 172d4b000 Delete type=3 #2
+1 -1
View File
@@ -1 +1 @@
2026/08/12-22:44:33.360431 16e5c3000 Delete type=3 #1
2026/08/17-23:24:31.542959 16f83f000 Delete type=3 #1
Binary file not shown.
Binary file not shown.
Binary file not shown.
+5 -5
View File
@@ -1,5 +1,5 @@
2026/08/12-22:44:48.913614 172817000 Recovering log #3
2026/08/12-22:44:48.914288 172817000 Level-0 table #5: started
2026/08/12-22:44:48.914458 172817000 Level-0 table #5: 7590 bytes OK
2026/08/12-22:44:48.914739 172817000 Delete type=0 #3
2026/08/12-22:44:48.914773 172817000 Delete type=3 #2
2026/08/17-23:24:47.241716 17253f000 Recovering log #3
2026/08/17-23:24:47.241766 17253f000 Level-0 table #5: started
2026/08/17-23:24:47.241955 17253f000 Level-0 table #5: 7806 bytes OK
2026/08/17-23:24:47.242285 17253f000 Delete type=0 #3
2026/08/17-23:24:47.242328 17253f000 Delete type=3 #2
+1 -1
View File
@@ -1 +1 @@
2026/08/12-22:44:33.281949 16edcf000 Delete type=3 #1
2026/08/17-23:24:31.467729 16f033000 Delete type=3 #1
Binary file not shown.
+5 -5
View File
@@ -1,5 +1,5 @@
2026/08/12-22:44:48.943128 17200b000 Recovering log #3
2026/08/12-22:44:48.943292 17200b000 Level-0 table #5: started
2026/08/12-22:44:48.943546 17200b000 Level-0 table #5: 16902 bytes OK
2026/08/12-22:44:48.943901 17200b000 Delete type=0 #3
2026/08/12-22:44:48.943932 17200b000 Delete type=3 #2
2026/08/17-23:24:47.264565 17253f000 Recovering log #3
2026/08/17-23:24:47.264605 17253f000 Level-0 table #5: started
2026/08/17-23:24:47.264863 17253f000 Level-0 table #5: 16902 bytes OK
2026/08/17-23:24:47.265193 17253f000 Delete type=0 #3
2026/08/17-23:24:47.265233 17253f000 Delete type=3 #2
+1 -1
View File
@@ -1 +1 @@
2026/08/12-22:44:33.355279 16e5c3000 Delete type=3 #1
2026/08/17-23:24:31.538092 16f033000 Delete type=3 #1
Binary file not shown.
Binary file not shown.
+5 -5
View File
@@ -1,5 +1,5 @@
2026/08/12-22:44:48.931234 17200b000 Recovering log #3
2026/08/12-22:44:48.932452 17200b000 Level-0 table #5: started
2026/08/12-22:44:48.933139 17200b000 Level-0 table #5: 82299 bytes OK
2026/08/12-22:44:48.933493 17200b000 Delete type=0 #3
2026/08/12-22:44:48.933538 17200b000 Delete type=3 #2
2026/08/17-23:24:47.257336 17253f000 Recovering log #3
2026/08/17-23:24:47.257721 17253f000 Level-0 table #5: started
2026/08/17-23:24:47.258429 17253f000 Level-0 table #5: 84027 bytes OK
2026/08/17-23:24:47.258808 17253f000 Delete type=0 #3
2026/08/17-23:24:47.258862 17253f000 Delete type=3 #2
+1 -1
View File
@@ -1 +1 @@
2026/08/12-22:44:33.337758 16f5db000 Delete type=3 #1
2026/08/17-23:24:31.521202 16e827000 Delete type=3 #1
Binary file not shown.
+5 -5
View File
@@ -1,5 +1,5 @@
2026/08/12-22:44:48.944808 172817000 Recovering log #3
2026/08/12-22:44:48.944973 172817000 Level-0 table #5: started
2026/08/12-22:44:48.945086 172817000 Level-0 table #5: 3739 bytes OK
2026/08/12-22:44:48.945317 172817000 Delete type=0 #3
2026/08/12-22:44:48.945349 172817000 Delete type=3 #2
2026/08/17-23:24:47.266184 173557000 Recovering log #3
2026/08/17-23:24:47.266205 173557000 Level-0 table #5: started
2026/08/17-23:24:47.266343 173557000 Level-0 table #5: 3739 bytes OK
2026/08/17-23:24:47.266641 173557000 Delete type=0 #3
2026/08/17-23:24:47.266687 173557000 Delete type=3 #2
+1 -1
View File
@@ -1 +1 @@
2026/08/12-22:44:33.331276 16e5c3000 Delete type=3 #1
2026/08/17-23:24:31.515307 16f033000 Delete type=3 #1
+5 -5
View File
@@ -1,5 +1,5 @@
2026/08/12-22:44:48.915783 173023000 Recovering log #3
2026/08/12-22:44:48.915922 173023000 Level-0 table #5: started
2026/08/12-22:44:48.916069 173023000 Level-0 table #5: 5770 bytes OK
2026/08/12-22:44:48.916313 173023000 Delete type=0 #3
2026/08/12-22:44:48.916348 173023000 Delete type=3 #2
2026/08/17-23:24:47.243316 173d63000 Recovering log #3
2026/08/17-23:24:47.243351 173d63000 Level-0 table #5: started
2026/08/17-23:24:47.243531 173d63000 Level-0 table #5: 5770 bytes OK
2026/08/17-23:24:47.243775 173d63000 Delete type=0 #3
2026/08/17-23:24:47.243817 173d63000 Delete type=3 #2
+1 -1
View File
@@ -1 +1 @@
2026/08/12-22:44:33.284515 16e5c3000 Delete type=3 #1
2026/08/17-23:24:31.470482 17004b000 Delete type=3 #1
+5 -5
View File
@@ -1,5 +1,5 @@
2026/08/12-22:44:48.924550 17200b000 Recovering log #3
2026/08/12-22:44:48.924684 17200b000 Level-0 table #5: started
2026/08/12-22:44:48.924837 17200b000 Level-0 table #5: 6939 bytes OK
2026/08/12-22:44:48.925120 17200b000 Delete type=0 #3
2026/08/12-22:44:48.925163 17200b000 Delete type=3 #2
2026/08/17-23:24:47.250637 17253f000 Recovering log #3
2026/08/17-23:24:47.250671 17253f000 Level-0 table #5: started
2026/08/17-23:24:47.250835 17253f000 Level-0 table #5: 6939 bytes OK
2026/08/17-23:24:47.251080 17253f000 Delete type=0 #3
2026/08/17-23:24:47.251125 17253f000 Delete type=3 #2
+1 -1
View File
@@ -1 +1 @@
2026/08/12-22:44:33.292038 16fde7000 Delete type=3 #1
2026/08/17-23:24:31.477647 16e827000 Delete type=3 #1
+5 -5
View File
@@ -1,5 +1,5 @@
2026/08/12-22:44:48.918709 173023000 Recovering log #3
2026/08/12-22:44:48.919008 173023000 Level-0 table #5: started
2026/08/12-22:44:48.919255 173023000 Level-0 table #5: 14767 bytes OK
2026/08/12-22:44:48.919627 173023000 Delete type=0 #3
2026/08/12-22:44:48.919674 173023000 Delete type=3 #2
2026/08/17-23:24:47.245857 173d63000 Recovering log #3
2026/08/17-23:24:47.245924 173d63000 Level-0 table #5: started
2026/08/17-23:24:47.246193 173d63000 Level-0 table #5: 14767 bytes OK
2026/08/17-23:24:47.246515 173d63000 Delete type=0 #3
2026/08/17-23:24:47.246567 173d63000 Delete type=3 #2
+1 -1
View File
@@ -1 +1 @@
2026/08/12-22:44:33.286188 16e5c3000 Delete type=3 #1
2026/08/17-23:24:31.472133 16f83f000 Delete type=3 #1
+6 -6
View File
@@ -60,7 +60,7 @@ export const ROLES = {
registry: { label: "Registry Officer",
blurb: "Keeps the record that a crossing is made against. Knows what the file says and, more usefully, what it used to say.", stat: "edu",
core: ["research","knowledge","anomaly_lore","language:own","computer_use","insight"],
core: ["occult","knowledge","anomaly_lore","language:own","computer_use","insight"],
support: ["spot","art","first_aid","tradecraft","firearm:pistol"],
talents: ["chain_custody","wrong_memory","debriefer","quorum"],
kit: ["spectacles","notebook","handset","jacket","pistol_light", "carbon_book", "date_stamp", "seals"] },
@@ -82,7 +82,7 @@ export const ROLES = {
interview: { label: "Interview Officer",
blurb: "Sits with the witness afterwards. Patient, unhurried, and very hard to lie to twice.", stat: "cha",
core: ["insight","persuade","listen","language:own","anthropology"],
support: ["fast_talk","spot","tradecraft","first_aid","firearm:pistol"],
support: ["psychology","spot","tradecraft","first_aid","firearm:pistol"],
talents: ["debriefer","read_room","wrong_memory","unblinking"],
kit: ["dictaphone","cover_id","notebook","jacket","pistol_light", "carbon_book", "vacuum_flask", "clipboard"] },
@@ -110,7 +110,7 @@ export const ROLES = {
pathology: { label: "Pathology Officer",
blurb: "Examines what is left. Establishes what a thing was, which is usually the question the case turns on.", stat: "int",
core: ["medicine","science:biology","xenology:baseline","insight","research"],
support: ["first_aid","spot","anthropology","dodge","melee_weapon:knife"],
support: ["first_aid","spot","religion","dodge","melee_weapon:knife"],
talents: ["triage","chain_custody","unblinking","clean_scene"],
kit: ["chirurgeon_kit","gas_mask","envsuit","sample_case","dagger", "specimen_jars", "swab_kit", "barrier_gloves"] },
@@ -172,7 +172,7 @@ export const ROLES = {
antiquities: { label: "Antiquities Officer",
blurb: "An archaic posting the department never abolished. Old objects, older records, and the manners to ask about both.", stat: "edu",
core: ["knowledge","art","anomaly_lore","research","status"],
core: ["religion","art","anomaly_lore","research","status"],
support: ["insight","spot","first_aid","melee_weapon:sword","firearm:pistol"],
talents: ["chain_custody","wrong_memory","borrowed_hour","quorum"],
kit: ["pocket_watch","tallow_candle","duelling_sword","brigandine","flintlock", "quill_ink", "sealing_wax", "sand_glass"] },
@@ -231,13 +231,13 @@ export const TRADES = {
doctor: { label: "Doctor",
blurb: "A&E, mostly. Used to people at their worst and to being the calmest thing in the room.", stat: "edu",
core: ["medicine","first_aid","science:biology","insight","knowledge"],
support: ["research","status","persuade","listen","spot"],
support: ["research","psychology","persuade","listen","spot"],
talents: ["triage","debriefer","unblinking","clean_scene"],
kit: ["medkit","trauma_unit","analgesics","specimen_jars","swab_kit"] },
professor: { label: "Professor",
blurb: "A chair in something narrow, and a reading list nobody else has finished. Argues for a living.", stat: "edu",
core: ["knowledge","research","anthropology","language:own","status"],
core: ["knowledge","research","occult","language:own","status"],
support: ["insight","persuade","science:botany","listen","art"],
talents: ["debriefer","read_room","requisition","wrong_memory"],
kit: ["carbon_book","notebook","spectacles","clipboard","quill_ink"] },
+95 -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,71 @@ 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 });
// 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)
})
});
}
/**
* 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 +4832,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 +4928,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 +6178,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,
+110
View File
@@ -263,6 +263,116 @@ export const COHERENCE_COSTS = {
export const COHERENCE_RECOVERY = { debrief: 1, downtimeWeek: 1, anchorObject: 1 };
/* ============================================================
PANIC — the short track
============================================================
Coherence is the long one: 0-10, campaign-scale, what it costs to keep looking once
you know what you are looking at, recovered at a debrief. Panic is the other half and
they must not be confused at the table:
Coherence what knowing has cost you weeks 0-10, down
Panic what is happening to you NOW minutes 0-5, up
Panic goes UP. A trigger gives a point with no roll — asking for a die to find out
whether you are frightened is a die that slows the scene down and tells the player
how to feel. The roll is to get RID of it, which is the interesting direction and the
only one worth a check.
One free point on purpose: at Panic 1 you are rattled and roll at no penalty. It costs
the table nothing and it means the first shock of a scene is colour rather than
arithmetic.
============================================================ */
export const PANIC_MAX = 5;
export const PANIC_BANDS = [
{ min: 5, id: "broken", label: "RINGBRP.Panic.Broken", all: -20, breaks: true,
note: "RINGBRP.Panic.BrokenNote" },
{ min: 4, id: "overwhelmed", label: "RINGBRP.Panic.Overwhelmed", all: -20, breaks: false,
note: "RINGBRP.Panic.OverwhelmedNote" },
{ min: 3, id: "losing", label: "RINGBRP.Panic.Losing", all: -10, breaks: false,
note: "RINGBRP.Panic.LosingNote" },
{ min: 2, id: "shaken", label: "RINGBRP.Panic.Shaken", all: -5, breaks: false,
note: "RINGBRP.Panic.ShakenNote" },
{ min: 1, id: "rattled", label: "RINGBRP.Panic.Rattled", all: 0, breaks: false,
note: "RINGBRP.Panic.RattledNote" },
{ min: 0, id: "steady", label: "RINGBRP.Panic.Steady", all: 0, breaks: false,
note: "RINGBRP.Panic.SteadyNote" }
];
/** What a point costs. One each, so a GM never has to weigh anything mid-scene. */
export const PANIC_TRIGGERS = {
wrongThing: { cost: 1, label: "RINGBRP.Panic.Trig.WrongThing" },
touchedByIt: { cost: 1, label: "RINGBRP.Panic.Trig.Touched" },
aBody: { cost: 1, label: "RINGBRP.Panic.Trig.Body" },
someoneYouKnew:{ cost: 2, label: "RINGBRP.Panic.Trig.Known" },
theLightsWent: { cost: 1, label: "RINGBRP.Panic.Trig.Dark" },
majorWound: { cost: 1, label: "RINGBRP.Panic.Trig.Wounded" },
itSaidYourName:{ cost: 2, label: "RINGBRP.Panic.Trig.Named" }
};
/**
* What takes a point off, and what it is rolled against.
*
* `self` steadies you; `other` steadies somebody else, which is the whole reason
* Psychology is worth carrying. `heard` reaches everyone who can hear it: naming a thing
* correctly is the department's actual work, and it is worth a mechanical reward.
* A special takes two.
*/
export const PANIC_RELIEF = {
steadyYourself: { skill: null, reach: "self", once: "scene",
label: "RINGBRP.Panic.Relief.Steady" },
religion: { skill: "religion", reach: "self", once: "scene",
label: "RINGBRP.Panic.Relief.Religion" },
psychology: { skill: "psychology", reach: "other", once: "scene",
label: "RINGBRP.Panic.Relief.Psychology" },
occult: { skill: "occult", reach: "heard", once: "encounter",
label: "RINGBRP.Panic.Relief.Occult" },
anchorObject: { skill: null, reach: "self", once: "case",
label: "RINGBRP.Panic.Relief.Anchor" }
};
/** Breaking is a choice, not a table: three bad options, taken fast. */
export const PANIC_BREAK = ["freeze", "flee", "fixate"];
/** Breaking spends the panic. You come back at 3, not at 0 — it is not a rest. */
export const PANIC_AFTER_BREAK = 3;
export function panicBandFrom(value) {
const v = Math.max(0, Math.min(PANIC_MAX, Number(value) || 0));
return PANIC_BANDS.find(b => v >= b.min) ?? PANIC_BANDS.at(-1);
}
/** The roll penalty a level of panic carries. Never positive. */
export function panicPenaltyFor(value) {
return Math.min(0, panicBandFrom(value).all);
}
/** Does this level break the character this round? */
export function panicBreaksAt(value) {
return panicBandFrom(value).breaks === true;
}
/** Clamped, so nothing can push the track past its own ends. */
export function panicAfter(value, delta) {
return Math.max(0, Math.min(PANIC_MAX, (Number(value) || 0) + (Number(delta) || 0)));
}
/**
* A success takes one point, a special or better takes two — and a FUMBLE puts one on.
*
* Returned as a negative relief so the whole thing stays one number and one direction:
* easePanic subtracts what this gives it, so -1 subtracts a negative and the track goes
* up. Trying to talk somebody down and making it worse is the most familiar thing in
* this list, and until now it was the only outcome that cost nothing.
*/
export function panicReliefFor(level) {
if (level === "critical" || level === "special") return 2;
if (level === "success") return 1;
if (level === "fumble") return -1;
return 0;
}
export function coherenceBandFrom(value) {
const v = Math.max(0, Number(value) || 0);
return COHERENCE_BANDS.find(b => v >= b.min) ?? COHERENCE_BANDS.at(-1);
+52
View File
@@ -1788,3 +1788,55 @@
#pause.paused::after { animation: none; }
#pause.paused img { transform: rotate(-32.7deg); }
}
/* ============================================================
PANIC — the short track
============================================================
Deliberately NOT the same colour as Coherence. The two tracks run in opposite
directions on different timescales, and a table that reads one as the other will make
the wrong decision at the worst moment: Coherence is cool and yellow like the rest of
the department, Panic warms as it climbs and ends red.
============================================================ */
.rb-vital-panic .rb-big { color: var(--rb-white); }
.rb-panic-steady .rb-big, .rb-panic-steady .rb-cohband { color: var(--rb-muted); }
.rb-panic-rattled .rb-big, .rb-panic-rattled .rb-cohband { color: var(--rb-white); }
.rb-panic-shaken .rb-big, .rb-panic-shaken .rb-cohband { color: var(--rb-yellow); }
.rb-panic-losing .rb-big, .rb-panic-losing .rb-cohband { color: #ffb066; }
.rb-panic-overwhelmed .rb-big, .rb-panic-overwhelmed .rb-cohband { color: var(--rb-danger); }
.rb-panic-broken .rb-big, .rb-panic-broken .rb-cohband { color: var(--rb-danger); }
.rb-vital-panic.rb-panic-broken { border-color: var(--rb-danger); }
.rb-panic-pips { display: flex; gap: 5px; list-style: none; margin: 8px 0; padding: 0; }
.rb-panic-pip {
width: 20px; height: 8px;
background: transparent; border: 1px solid var(--rb-hairline);
}
.rb-panic-pip.on { background: var(--rb-danger); border-color: var(--rb-danger); }
.rb-panic-move { display: flex; align-items: baseline; gap: 8px; margin: 6px 0; }
.rb-panic-from, .rb-panic-to {
font-family: var(--rb-display); font-size: 20px; color: var(--rb-white);
}
.rb-panic-arrow { color: var(--rb-muted); }
.rb-panic-band {
font-family: var(--rb-body); font-size: 11px; font-weight: 700;
letter-spacing: .1em; text-transform: uppercase; color: var(--rb-yellow);
}
.rb-panic-reason { font-size: 13px; color: var(--rb-white); margin: 4px 0; }
.rb-panic-note { font-size: 12px; color: var(--rb-muted); margin: 2px 0 6px; }
.rb-panic-pen { font-size: 13px; font-weight: 700; color: var(--rb-danger); margin: 2px 0; }
.rb-panic-break {
font-family: var(--rb-body); font-size: 12px; font-weight: 700; letter-spacing: .08em;
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.0.7",
"version": "1.1.2",
"compatibility": {
"minimum": 13,
"verified": "14.364"
+5
View File
@@ -69,6 +69,11 @@
"value": 10,
"max": 10,
"drift": []
},
"panic": {
"value": 0,
"max": 5,
"log": []
}
}
},
+10
View File
@@ -51,6 +51,16 @@
</span>
<span class="rb-cohband">{{localize coherence.band.label}}</span>
</div>
<div class="rb-vital-panic rb-panic-{{panic.band.id}}"
data-tooltip="{{localize panic.band.note}}">
<span class="rb-vital-label">{{localize "RINGBRP.Panic.Label"}}</span>
<span class="rb-coh-row">
<a data-action="panic-adjust" data-delta="-1" class="rb-cohbtn">&minus;</a>
<span class="rb-big">{{sys.panic.value}}</span>
<a data-action="panic-adjust" data-delta="1" class="rb-cohbtn">+</a>
</span>
<span class="rb-cohband">{{localize panic.band.label}}</span>
</div>
<div class="rb-armour{{#if armourUnworn.length}} rb-unworn{{/if}}"
{{#if armourUnworn.length}}data-tooltip="{{localize "RINGBRP.Sheet.ArmourUnworn"}}"{{/if}}>
<span class="rb-vital-label">{{localize "RINGBRP.Sheet.Armour"}}</span>
+21
View File
@@ -0,0 +1,21 @@
<div class="ringbrp-chat rb-panic-card">
<div class="rb-chat-title">{{localize "RINGBRP.Panic.Label"}}</div>
<div class="rb-chat-sub">{{actorName}}</div>
<ol class="rb-panic-pips">
{{#each pips as |on|}}<li class="rb-panic-pip{{#if on}} on{{/if}}"></li>{{/each}}
</ol>
<div class="rb-panic-move">
<span class="rb-panic-from">{{before}}</span>
<span class="rb-panic-arrow">&rarr;</span>
<span class="rb-panic-to">{{after}}</span>
<span class="rb-panic-band rb-panic-{{band.id}}">{{localize band.label}}</span>
</div>
<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}}
{{#if breaks}}<p class="rb-panic-break">{{localize "RINGBRP.Panic.Breaks"}}</p>{{/if}}
</div>
+87
View File
@@ -448,6 +448,93 @@ test("no stylesheet rule sets display on a bare #pause", async () => {
}
});
/* ---------------------------------------------------------------- *
* Panic — the short track, added after the first game asked for it
* ---------------------------------------------------------------- */
// The first point is free ON PURPOSE: the opening shock of a scene should be colour,
// not arithmetic. If this ever starts costing, the whole track has been re-tuned.
test("panic 1 costs nothing to roll at", () =>
assert.equal(RULES.panicPenaltyFor(1), 0));
test("panic bites from 2 upward and never rewards", () => {
let prev = 1;
for (const v of [2, 3, 4, 5]) {
const pen = RULES.panicPenaltyFor(v);
assert.ok(pen <= 0, `panic ${v} gives a bonus`);
assert.ok(pen <= prev, `panic ${v} is kinder than ${v - 1}`);
prev = pen;
}
});
test("only 5 breaks you", () => {
for (const v of [0, 1, 2, 3, 4]) assert.equal(RULES.panicBreaksAt(v), false, `${v} breaks`);
assert.equal(RULES.panicBreaksAt(5), true);
});
// A track that can be pushed past its own ends is a track that will be, by a GM
// clicking + on a bad night.
test("panic cannot leave 0..5", () => {
assert.equal(RULES.panicAfter(5, 3), RULES.PANIC_MAX);
assert.equal(RULES.panicAfter(0, -3), 0);
assert.equal(RULES.panicAfter(2, 2), 4);
});
// Breaking spends it. Coming back at 0 would make breaking the cheapest way to clear
// the track, which is exactly backwards.
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, 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);
// 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");
const lang = JSON.parse(await readFile(new URL("../lang/en.json", import.meta.url), "utf8"));
for (const b of RULES.PANIC_BANDS) {
assert.ok(lang[b.label], `no label for panic band ${b.id}`);
assert.ok(lang[b.note], `no note for panic band ${b.id}`);
}
for (const [k, t] of Object.entries(RULES.PANIC_TRIGGERS))
assert.ok(lang[t.label], `no label for panic trigger ${k}`);
for (const [k, r] of Object.entries(RULES.PANIC_RELIEF))
assert.ok(lang[r.label], `no label for panic relief ${k}`);
});
// The reliefs name skills; a skill that is not in the catalogue is a relief nobody can
// roll. This is the same defect as a kit key with no item behind it.
test("every panic relief names a skill the catalogue has", async () => {
const { SKILL_CATALOGUE } = await import("./content.mjs");
const known = new Set(SKILL_CATALOGUE.map(r => r[0]));
for (const [k, r] of Object.entries(RULES.PANIC_RELIEF)) {
if (!r.skill) continue;
assert.ok(known.has(r.skill), `panic relief "${k}" rolls ${r.skill}, which is not a skill`);
}
});
// The three the first game asked for.
test("occult, religion and psychology exist and can be learned", async () => {
const { SKILL_CATALOGUE } = await import("./content.mjs");
for (const fam of ["occult", "religion", "psychology"]) {
const row = SKILL_CATALOGUE.find(r => r[0] === fam);
assert.ok(row, `${fam} is not in the catalogue`);
assert.ok(row[3] > 0, `${fam} has no printed base, so nobody can roll it untrained`);
assert.notEqual(row[5]?.canBeTaught, false, `${fam} cannot be taught`);
}
});
/* ---------------------------------------------------------------- */
await runAll();
+18
View File
@@ -179,6 +179,24 @@ const checks = [
["coherenceBandFrom(4).id", RULES.coherenceBandFrom(4).id, "adrift"],
["coherenceBandFrom(1).id", RULES.coherenceBandFrom(1).id, "unmoored"],
["coherenceBandFrom(0).id", RULES.coherenceBandFrom(0).id, "displaced"],
// Panic. The free first point is deliberate, so 1 must cost nothing.
["panicBandFrom(0).id", RULES.panicBandFrom(0).id, "steady"],
["panicBandFrom(1).id", RULES.panicBandFrom(1).id, "rattled"],
["panicBandFrom(3).id", RULES.panicBandFrom(3).id, "losing"],
["panicBandFrom(5).id", RULES.panicBandFrom(5).id, "broken"],
["panicPenaltyFor(1)", RULES.panicPenaltyFor(1), 0],
["panicPenaltyFor(2)", RULES.panicPenaltyFor(2), -5],
["panicPenaltyFor(4)", RULES.panicPenaltyFor(4), -20],
["panicBreaksAt(4)", RULES.panicBreaksAt(4), false],
["panicBreaksAt(5)", RULES.panicBreaksAt(5), true],
["panicAfter(4,1)", RULES.panicAfter(4, 1), 5],
["panicAfter(5,1)", RULES.panicAfter(5, 1), 5],
["panicAfter(0,-1)", RULES.panicAfter(0, -1), 0],
["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],
+7
View File
@@ -75,6 +75,13 @@ export const SKILL_CATALOGUE = [
["navigate", "", "mental", 10, "Navigate"],
["research", "", "mental", 1, "Research"],
["anomaly_lore", "", "mental", 0, "Anomaly Lore", { canBeTaught: false }],
// Added after the first game, which wanted all three and had none of them. Occult and
// Religion are the two halves of what people believed BEFORE the department existed —
// one is what the books say, the other is what the congregation does — and Psychology
// is the only skill that steadies somebody who is not you.
["occult", "", "mental", 5, "Occult"],
["religion", "", "mental", 5, "Religion"],
["psychology", "", "mental", 5, "Psychology"],
// perception
["listen", "", "percep", 25, "Listen"],
["spot", "", "percep", 25, "Spot"],
+50 -3
View File
@@ -28,7 +28,8 @@ import {
powerStartingRating, brawlFallbackFor, bleedStoppedBy,
crossingArtPoolFor, crossingGrantsArt, crossingCostFor,
REACTION, BLEED, CIRCUMSTANCE, RANGE_LADDER, rangeBandFrom,
MAJOR_WOUND_TABLE, COHERENCE_BANDS, COHERENCE_COSTS, COHERENCE_RECOVERY
MAJOR_WOUND_TABLE, COHERENCE_BANDS, COHERENCE_COSTS, COHERENCE_RECOVERY,
PANIC_BANDS, PANIC_TRIGGERS, PANIC_RELIEF, PANIC_MAX, PANIC_AFTER_BREAK, PANIC_BREAK
} from "../rules.mjs";
// Postings, trades and induction are plain data and are imported as such.
import { ROLES, TRADES, INDUCTION, TIERS, TRADE_BANDS } from "../postings.mjs";
@@ -75,6 +76,13 @@ const woundRows = MAJOR_WOUND_TABLE.map(r => {
return [`${r.min}`, L(`RINGBRP.Wound.${r.key}.title`), bits.join(" · ") || "—"];
});
const panicRows = [...PANIC_BANDS].reverse().map(b =>
[String(b.min), L(b.label), b.all ? `${pc(b.all)} everything` : "none", L(b.note)]);
const panicTrigRows = Object.values(PANIC_TRIGGERS)
.map(t => [L(t.label), `+${t.cost}`]);
const panicReliefRows = Object.values(PANIC_RELIEF)
.map(r => [L(r.label), r.reach, r.once]);
const cohRows = COHERENCE_BANDS.map((b, i) => {
const hi = i === 0 ? 10 : COHERENCE_BANDS[i - 1].min - 1;
const rng = b.min === hi ? `${b.min}` : `${hi}–${b.min}`;
@@ -518,6 +526,45 @@ return does not strand you either — you arrive near your target rather than at
There is no state in which an agent cannot get home. There are only states in which
getting home costs more than the case was worth.</p>` },
{ name: "4b · Panic", content: `
<p><em>Coherence is what knowing has cost you, and it is measured in weeks. Panic is
what is happening to you now, and it is measured in minutes. Do not confuse them at
the table: one goes down over a career, the other goes up over a corridor.</em></p>
<p>Panic runs from <strong>0 up to ${PANIC_MAX}</strong>. You take a point when
something happens to you — <strong>no roll</strong>. Asking for a die to find out
whether you are frightened slows the scene down and tells you how to feel. The die is
for getting rid of it.</p>
${table(["Panic", "Band", "Penalty", "What it looks like"], panicRows)}
<p>The first point is free. At Panic 1 you are rattled and you roll at no penalty,
because the opening shock of a scene should be colour rather than arithmetic.</p>
<h2>Taking a point</h2>
${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 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
everybody who can hear you — which is the department's actual work, and the only relief
that reaches a whole room. <strong>Psychology</strong> is the only one that steadies
somebody who is not you. <strong>Religion</strong> steadies you and nobody else, and it
does not care whether you are right.</p>
<h2>Breaking</h2>
<p>At ${PANIC_MAX} you break. Choose one — <strong>${PANIC_BREAK.join(", ")}</strong> —
take it, and come back at <strong>${PANIC_AFTER_BREAK}</strong>. You do not come back at
zero. Breaking is not a rest.</p>
<p>Panic clears when the scene does. When you are somewhere safe with the door shut and
somebody has made tea, you are at nought again.</p>` },
{ name: "5 · The Quiet Arts", content: `
<p><em>Some agents come back with something. It is not a gift and it is not
training — it is the crossing, left in a person.</em></p>
@@ -730,8 +777,8 @@ ${table(["Location", "Share", "Capacity"], [
<h2>Rolls that ignore your skill</h2>
${table(["Roll", "Result"], [
["96–99", "always fails, whatever your skill"],
["00", "always a fumble"],
["96–99", "never succeeds, whatever your skill — and is a fumble if it is inside your fumble range"],
["00", "always a fumble, whatever your skill"],
["01", "always at least a critical chance — the floor is 1%"]
])}