Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5aaae186e0 | ||
|
|
20306d83d9 | ||
|
|
cbad87ba98 |
@@ -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
@@ -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
@@ -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 @@
|
||||
2026/08/12-22:44:33.287733 16f5db000 Delete type=3 #1
|
||||
2026/08/17-23:24:31.473921 16e827000 Delete type=3 #1
|
||||
|
||||
@@ -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 @@
|
||||
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
@@ -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
@@ -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.
@@ -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 @@
|
||||
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
@@ -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
@@ -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
@@ -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 @@
|
||||
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
@@ -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 @@
|
||||
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
@@ -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 @@
|
||||
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
@@ -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 @@
|
||||
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
@@ -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
@@ -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
@@ -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 @@
|
||||
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
@@ -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
@@ -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
@@ -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 @@
|
||||
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
@@ -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 @@
|
||||
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
@@ -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 @@
|
||||
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
@@ -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 @@
|
||||
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
@@ -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 @@
|
||||
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
@@ -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
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
@@ -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"
|
||||
|
||||
@@ -69,6 +69,11 @@
|
||||
"value": 10,
|
||||
"max": 10,
|
||||
"drift": []
|
||||
},
|
||||
"panic": {
|
||||
"value": 0,
|
||||
"max": 5,
|
||||
"log": []
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -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">−</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>
|
||||
|
||||
@@ -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">→</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>
|
||||
@@ -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();
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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
@@ -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%"]
|
||||
])}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user