Development: skills improve from what beat you, not what worked

Reversed experience. A skill is marked when it FAILS - failure or fumble - and
never when it works, and the improvement roll is the same reversal: roll OVER
your rating and the skill goes up 1d6. Nobody learns anything from the lock that
opened first time.

The two halves pull against each other on purpose, so ratings converge instead of
running away. Simulated over 2000 campaigns: a 10% and an 85% skill start 75
points apart and are 11 apart after 60 cases.

- rules.mjs owns it: DEVELOPMENT, developmentMarksOn, developmentImproves,
  developmentCeiling, developmentGainFor
- trainingCap has been on the skill schema and the item sheet since it was
  written and nothing has ever read it. Development reads it now: a skill stops
  at its own cap, and everything else stops at DEVELOPMENT.ceiling, because a
  d6 gain could otherwise push a 98% skill past 100
- GM opens the phase from a GM-only toggle in the token controls; it posts a
  card naming every agent and their marked count, and puts a Development button
  on each sheet. Players press their own
- the toggle re-reads the setting on change, since getSceneControlButtons only
  fires at load and the button would otherwise lie if the phase were opened by
  the API or a second GM
- rules journal and player primer rewritten from the tables; the old text
  described the pre-reversal rule on both halves
- 7 spot-checks, 4 behaviour tests; verified end to end in a running Foundry

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
slaguru666
2026-08-18 23:23:03 +01:00
co-authored by Claude Opus 5
parent 99e78f440a
commit 24b1aaa682
54 changed files with 435 additions and 130 deletions
+3 -3
View File
@@ -180,9 +180,9 @@ The build runs all five guards before it writes anything:
<!-- guards:start -->
```
check-rules: OK — 92 rules, 21 files scanned, 3 aliases + 45 constant sets checked, 291 formulas verified
check-rules: OK — 96 rules, 21 files scanned, 3 aliases + 46 constant sets checked, 306 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, 981 keys, no leaf/branch collisions
check-behaviour: OK — 59 behavioural tests
check-lang: OK — en.json, 995 keys, no leaf/branch collisions
check-behaviour: OK — 67 behavioural tests
```
<!-- guards:end -->
+15 -1
View File
@@ -581,6 +581,8 @@
"RINGBRP.Heal.Fumbled": "First Aid fumbled — the patient loses a further hit point.",
"RINGBRP.Exp.Title": "End of case — improvement",
"RINGBRP.Exp.NoGain": "no gain",
"RINGBRP.Exp.Capped": "held at its ceiling",
"RINGBRP.Exp.AtCeiling": "already as far as training takes it",
"RINGBRP.Exp.NoTicks": "No skill earned a tick this case.",
"RINGBRP.Flavour.Gear": "{item}: +{mod}%",
"RINGBRP.Target.Title": "Target",
@@ -979,5 +981,17 @@
"RINGBRP.Panic.SceneEnd": "Scene over",
"RINGBRP.Panic.SceneEndNote": "Panic clears when the scene does. Coherence does not.",
"RINGBRP.Party.Title": "The team, right now",
"RINGBRP.Party.Who": "Agent"
"RINGBRP.Party.Who": "Agent",
"RINGBRP.Dev.Title": "Development",
"RINGBRP.Dev.SettingName": "Development phase open",
"RINGBRP.Dev.SettingHint": "While open, every agent's sheet offers a Development roll for the skills that failed them this case.",
"RINGBRP.Dev.Opened": "The case is closed. Roll for the skills that beat you.",
"RINGBRP.Dev.Closed": "The development phase is closed.",
"RINGBRP.Dev.Marked": "skills marked",
"RINGBRP.Dev.Toggle": "Development phase",
"RINGBRP.Dev.PressIt": "Each agent presses Development on their own sheet.",
"RINGBRP.Dev.GMOnly": "Only the GM can open the development phase.",
"RINGBRP.Dev.Button": "Development",
"RINGBRP.Dev.NotOpen": "The GM has not opened the development phase.",
"RINGBRP.Dev.Tooltip": "Roll to improve the skills that failed you this case. A skill improves if the roll is ANOTHER failure."
}
+5 -5
View File
@@ -1,5 +1,5 @@
2026/08/18-22:37:32.799186 176b97000 Recovering log #3
2026/08/18-22:37:32.799323 176b97000 Level-0 table #5: started
2026/08/18-22:37:32.799504 176b97000 Level-0 table #5: 4591 bytes OK
2026/08/18-22:37:32.799868 176b97000 Delete type=0 #3
2026/08/18-22:37:32.799916 176b97000 Delete type=3 #2
2026/08/18-23:21:42.522827 16edc3000 Recovering log #3
2026/08/18-23:21:42.522857 16edc3000 Level-0 table #5: started
2026/08/18-23:21:42.523016 16edc3000 Level-0 table #5: 4591 bytes OK
2026/08/18-23:21:42.523288 16edc3000 Delete type=0 #3
2026/08/18-23:21:42.523332 16edc3000 Delete type=3 #2
+1 -1
View File
@@ -1 +1 @@
2026/08/18-22:37:15.356757 172877000 Delete type=3 #1
2026/08/18-23:21:17.000759 16ec7f000 Delete type=3 #1
+5 -5
View File
@@ -1,5 +1,5 @@
2026/08/18-22:37:32.827220 176b97000 Recovering log #3
2026/08/18-22:37:32.827303 176b97000 Level-0 table #5: started
2026/08/18-22:37:32.827422 176b97000 Level-0 table #5: 4129 bytes OK
2026/08/18-22:37:32.827654 176b97000 Delete type=0 #3
2026/08/18-22:37:32.827715 176b97000 Delete type=3 #2
2026/08/18-23:21:42.542861 1705e7000 Recovering log #3
2026/08/18-23:21:42.542881 1705e7000 Level-0 table #5: started
2026/08/18-23:21:42.543000 1705e7000 Level-0 table #5: 4129 bytes OK
2026/08/18-23:21:42.543233 1705e7000 Delete type=0 #3
2026/08/18-23:21:42.543262 1705e7000 Delete type=3 #2
+1 -1
View File
@@ -1 +1 @@
2026/08/18-22:37:15.433657 17388f000 Delete type=3 #1
2026/08/18-23:21:17.085840 1704a3000 Delete type=3 #1
+5 -5
View File
@@ -1,5 +1,5 @@
2026/08/18-22:37:32.801021 17638b000 Recovering log #3
2026/08/18-22:37:32.801454 17638b000 Level-0 table #5: started
2026/08/18-22:37:32.801772 17638b000 Level-0 table #5: 31614 bytes OK
2026/08/18-22:37:32.802069 17638b000 Delete type=0 #3
2026/08/18-22:37:32.802111 17638b000 Delete type=3 #2
2026/08/18-23:21:42.523809 16edc3000 Recovering log #3
2026/08/18-23:21:42.523911 16edc3000 Level-0 table #5: started
2026/08/18-23:21:42.524193 16edc3000 Level-0 table #5: 31614 bytes OK
2026/08/18-23:21:42.524392 16edc3000 Delete type=0 #3
2026/08/18-23:21:42.524442 16edc3000 Delete type=3 #2
+1 -1
View File
@@ -1 +1 @@
2026/08/18-22:37:15.358422 17409b000 Delete type=3 #1
2026/08/18-23:21:17.002659 1704a3000 Delete type=3 #1
+5 -5
View File
@@ -1,5 +1,5 @@
2026/08/18-22:37:32.818774 176b97000 Recovering log #3
2026/08/18-22:37:32.819665 176b97000 Level-0 table #5: started
2026/08/18-22:37:32.820075 176b97000 Level-0 table #5: 46857 bytes OK
2026/08/18-22:37:32.820335 176b97000 Delete type=0 #3
2026/08/18-22:37:32.820381 176b97000 Delete type=3 #2
2026/08/18-23:21:42.537220 1705e7000 Recovering log #3
2026/08/18-23:21:42.537462 1705e7000 Level-0 table #5: started
2026/08/18-23:21:42.537820 1705e7000 Level-0 table #5: 46857 bytes OK
2026/08/18-23:21:42.538102 1705e7000 Delete type=0 #3
2026/08/18-23:21:42.538144 1705e7000 Delete type=3 #2
+1 -1
View File
@@ -1 +1 @@
2026/08/18-22:37:15.417310 172877000 Delete type=3 #1
2026/08/18-23:21:17.066464 16ec7f000 Delete type=3 #1
+5 -5
View File
@@ -1,5 +1,5 @@
2026/08/18-22:37:32.784037 17638b000 Recovering log #3
2026/08/18-22:37:32.786085 17638b000 Level-0 table #5: started
2026/08/18-22:37:32.787900 17638b000 Level-0 table #5: 234672 bytes OK
2026/08/18-22:37:32.788307 17638b000 Delete type=0 #3
2026/08/18-22:37:32.788379 17638b000 Delete type=3 #2
2026/08/18-23:21:42.511991 16edc3000 Recovering log #3
2026/08/18-23:21:42.512828 16edc3000 Level-0 table #5: started
2026/08/18-23:21:42.514313 16edc3000 Level-0 table #5: 234672 bytes OK
2026/08/18-23:21:42.514681 16edc3000 Delete type=0 #3
2026/08/18-23:21:42.514785 16edc3000 Delete type=3 #2
+1 -1
View File
@@ -1 +1 @@
2026/08/18-22:37:15.394128 172877000 Delete type=3 #1
2026/08/18-23:21:17.040817 1704a3000 Delete type=3 #1
+5 -5
View File
@@ -1,5 +1,5 @@
2026/08/18-22:37:32.795533 176b97000 Recovering log #3
2026/08/18-22:37:32.795934 176b97000 Level-0 table #5: started
2026/08/18-22:37:32.796065 176b97000 Level-0 table #5: 3295 bytes OK
2026/08/18-22:37:32.796355 176b97000 Delete type=0 #3
2026/08/18-22:37:32.796392 176b97000 Delete type=3 #2
2026/08/18-23:21:42.520180 16edc3000 Recovering log #3
2026/08/18-23:21:42.520206 16edc3000 Level-0 table #5: started
2026/08/18-23:21:42.520334 16edc3000 Level-0 table #5: 3295 bytes OK
2026/08/18-23:21:42.520647 16edc3000 Delete type=0 #3
2026/08/18-23:21:42.520681 16edc3000 Delete type=3 #2
+1 -1
View File
@@ -1 +1 @@
2026/08/18-22:37:15.428065 173083000 Delete type=3 #1
2026/08/18-23:21:17.079222 16f48b000 Delete type=3 #1
+5 -5
View File
@@ -1,5 +1,5 @@
2026/08/18-22:37:32.768723 176b97000 Recovering log #3
2026/08/18-22:37:32.772081 176b97000 Level-0 table #5: started
2026/08/18-22:37:32.777725 176b97000 Level-0 table #5: 560581 bytes OK
2026/08/18-22:37:32.778401 176b97000 Delete type=0 #3
2026/08/18-22:37:32.778494 176b97000 Delete type=3 #2
2026/08/18-23:21:42.501500 16edc3000 Recovering log #3
2026/08/18-23:21:42.503444 16edc3000 Level-0 table #5: started
2026/08/18-23:21:42.506554 16edc3000 Level-0 table #5: 560581 bytes OK
2026/08/18-23:21:42.506965 16edc3000 Delete type=0 #3
2026/08/18-23:21:42.507130 16edc3000 Delete type=3 #2
+1 -1
View File
@@ -1 +1 @@
2026/08/18-22:37:15.379031 173083000 Delete type=3 #1
2026/08/18-23:21:17.025927 16fc97000 Delete type=3 #1
Binary file not shown.
Binary file not shown.
+5 -5
View File
@@ -1,5 +1,5 @@
2026/08/18-22:37:32.804709 175b7f000 Recovering log #3
2026/08/18-22:37:32.804956 175b7f000 Level-0 table #5: started
2026/08/18-22:37:32.805208 175b7f000 Level-0 table #5: 23087 bytes OK
2026/08/18-22:37:32.805486 175b7f000 Delete type=0 #3
2026/08/18-22:37:32.805525 175b7f000 Delete type=3 #2
2026/08/18-23:21:42.526413 16edc3000 Recovering log #3
2026/08/18-23:21:42.526467 16edc3000 Level-0 table #5: started
2026/08/18-23:21:42.526727 16edc3000 Level-0 table #5: 24730 bytes OK
2026/08/18-23:21:42.526971 16edc3000 Delete type=0 #3
2026/08/18-23:21:42.526999 16edc3000 Delete type=3 #2
+1 -1
View File
@@ -1 +1 @@
2026/08/18-22:37:15.424767 17409b000 Delete type=3 #1
2026/08/18-23:21:17.075510 16fc97000 Delete type=3 #1
Binary file not shown.
+5 -5
View File
@@ -1,5 +1,5 @@
2026/08/18-22:37:32.821353 176b97000 Recovering log #3
2026/08/18-22:37:32.821969 176b97000 Level-0 table #5: started
2026/08/18-22:37:32.822328 176b97000 Level-0 table #5: 44887 bytes OK
2026/08/18-22:37:32.822647 176b97000 Delete type=0 #3
2026/08/18-22:37:32.822686 176b97000 Delete type=3 #2
2026/08/18-23:21:42.538839 16f5cf000 Recovering log #3
2026/08/18-23:21:42.539012 16f5cf000 Level-0 table #5: started
2026/08/18-23:21:42.539381 16f5cf000 Level-0 table #5: 44887 bytes OK
2026/08/18-23:21:42.539604 16f5cf000 Delete type=0 #3
2026/08/18-23:21:42.539638 16f5cf000 Delete type=3 #2
+1 -1
View File
@@ -1 +1 @@
2026/08/18-22:37:15.421322 17409b000 Delete type=3 #1
2026/08/18-23:21:17.071063 16ec7f000 Delete type=3 #1
Binary file not shown.
Binary file not shown.
+5 -5
View File
@@ -1,5 +1,5 @@
2026/08/18-22:37:32.806477 1773a3000 Recovering log #3
2026/08/18-22:37:32.807016 1773a3000 Level-0 table #5: started
2026/08/18-22:37:32.807385 1773a3000 Level-0 table #5: 36532 bytes OK
2026/08/18-22:37:32.807654 1773a3000 Delete type=0 #3
2026/08/18-22:37:32.807690 1773a3000 Delete type=3 #2
2026/08/18-23:21:42.527792 16f5cf000 Recovering log #3
2026/08/18-23:21:42.527874 16f5cf000 Level-0 table #5: started
2026/08/18-23:21:42.528257 16f5cf000 Level-0 table #5: 37376 bytes OK
2026/08/18-23:21:42.528527 16f5cf000 Delete type=0 #3
2026/08/18-23:21:42.528572 16f5cf000 Delete type=3 #2
+1 -1
View File
@@ -1 +1 @@
2026/08/18-22:37:15.432064 172877000 Delete type=3 #1
2026/08/18-23:21:17.083980 16fc97000 Delete type=3 #1
Binary file not shown.
+5 -5
View File
@@ -1,5 +1,5 @@
2026/08/18-22:37:32.791576 1773a3000 Recovering log #3
2026/08/18-22:37:32.791785 1773a3000 Level-0 table #5: started
2026/08/18-22:37:32.792018 1773a3000 Level-0 table #5: 7806 bytes OK
2026/08/18-22:37:32.792443 1773a3000 Delete type=0 #3
2026/08/18-22:37:32.792496 1773a3000 Delete type=3 #2
2026/08/18-23:21:42.517325 16edc3000 Recovering log #3
2026/08/18-23:21:42.517371 16edc3000 Level-0 table #5: started
2026/08/18-23:21:42.517580 16edc3000 Level-0 table #5: 7806 bytes OK
2026/08/18-23:21:42.518061 16edc3000 Delete type=0 #3
2026/08/18-23:21:42.518138 16edc3000 Delete type=3 #2
+1 -1
View File
@@ -1 +1 @@
2026/08/18-22:37:15.351270 17409b000 Delete type=3 #1
2026/08/18-23:21:16.994201 1704a3000 Delete type=3 #1
+5 -5
View File
@@ -1,5 +1,5 @@
2026/08/18-22:37:32.823569 176b97000 Recovering log #3
2026/08/18-22:37:32.823738 176b97000 Level-0 table #5: started
2026/08/18-22:37:32.824004 176b97000 Level-0 table #5: 16902 bytes OK
2026/08/18-22:37:32.824268 176b97000 Delete type=0 #3
2026/08/18-22:37:32.824310 176b97000 Delete type=3 #2
2026/08/18-23:21:42.540416 16fddb000 Recovering log #3
2026/08/18-23:21:42.540459 16fddb000 Level-0 table #5: started
2026/08/18-23:21:42.540720 16fddb000 Level-0 table #5: 16902 bytes OK
2026/08/18-23:21:42.540972 16fddb000 Delete type=0 #3
2026/08/18-23:21:42.541026 16fddb000 Delete type=3 #2
+1 -1
View File
@@ -1 +1 @@
2026/08/18-22:37:15.426452 173083000 Delete type=3 #1
2026/08/18-23:21:17.077585 16fc97000 Delete type=3 #1
+5 -5
View File
@@ -1,5 +1,5 @@
2026/08/18-22:37:32.815371 176b97000 Recovering log #3
2026/08/18-22:37:32.816691 176b97000 Level-0 table #5: started
2026/08/18-22:37:32.817466 176b97000 Level-0 table #5: 84039 bytes OK
2026/08/18-22:37:32.817750 176b97000 Delete type=0 #3
2026/08/18-22:37:32.817802 176b97000 Delete type=3 #2
2026/08/18-23:21:42.534997 16edc3000 Recovering log #3
2026/08/18-23:21:42.535341 16edc3000 Level-0 table #5: started
2026/08/18-23:21:42.535924 16edc3000 Level-0 table #5: 84039 bytes OK
2026/08/18-23:21:42.536290 16edc3000 Delete type=0 #3
2026/08/18-23:21:42.536355 16edc3000 Delete type=3 #2
+1 -1
View File
@@ -1 +1 @@
2026/08/18-22:37:15.404367 173083000 Delete type=3 #1
2026/08/18-23:21:17.052004 1704a3000 Delete type=3 #1
+5 -5
View File
@@ -1,5 +1,5 @@
2026/08/18-22:37:32.825592 175b7f000 Recovering log #3
2026/08/18-22:37:32.825723 175b7f000 Level-0 table #5: started
2026/08/18-22:37:32.825837 175b7f000 Level-0 table #5: 3739 bytes OK
2026/08/18-22:37:32.826141 175b7f000 Delete type=0 #3
2026/08/18-22:37:32.826181 175b7f000 Delete type=3 #2
2026/08/18-23:21:42.541794 1705e7000 Recovering log #3
2026/08/18-23:21:42.541832 1705e7000 Level-0 table #5: started
2026/08/18-23:21:42.541980 1705e7000 Level-0 table #5: 3739 bytes OK
2026/08/18-23:21:42.542243 1705e7000 Delete type=0 #3
2026/08/18-23:21:42.542288 1705e7000 Delete type=3 #2
+1 -1
View File
@@ -1 +1 @@
2026/08/18-22:37:15.398341 17409b000 Delete type=3 #1
2026/08/18-23:21:17.045501 1704a3000 Delete type=3 #1
+5 -5
View File
@@ -1,5 +1,5 @@
2026/08/18-22:37:32.793721 17638b000 Recovering log #3
2026/08/18-22:37:32.793870 17638b000 Level-0 table #5: started
2026/08/18-22:37:32.794081 17638b000 Level-0 table #5: 5770 bytes OK
2026/08/18-22:37:32.794365 17638b000 Delete type=0 #3
2026/08/18-22:37:32.794405 17638b000 Delete type=3 #2
2026/08/18-23:21:42.519065 16edc3000 Recovering log #3
2026/08/18-23:21:42.519098 16edc3000 Level-0 table #5: started
2026/08/18-23:21:42.519276 16edc3000 Level-0 table #5: 5770 bytes OK
2026/08/18-23:21:42.519549 16edc3000 Delete type=0 #3
2026/08/18-23:21:42.519584 16edc3000 Delete type=3 #2
+1 -1
View File
@@ -1 +1 @@
2026/08/18-22:37:15.353747 17388f000 Delete type=3 #1
2026/08/18-23:21:16.997485 1704a3000 Delete type=3 #1
+5 -5
View File
@@ -1,5 +1,5 @@
2026/08/18-22:37:32.810562 176b97000 Recovering log #3
2026/08/18-22:37:32.811570 176b97000 Level-0 table #5: started
2026/08/18-22:37:32.812165 176b97000 Level-0 table #5: 68636 bytes OK
2026/08/18-22:37:32.812502 176b97000 Delete type=0 #3
2026/08/18-22:37:32.812546 176b97000 Delete type=3 #2
2026/08/18-23:21:42.531147 1705e7000 Recovering log #3
2026/08/18-23:21:42.531387 1705e7000 Level-0 table #5: started
2026/08/18-23:21:42.531924 1705e7000 Level-0 table #5: 68636 bytes OK
2026/08/18-23:21:42.532247 1705e7000 Delete type=0 #3
2026/08/18-23:21:42.532303 1705e7000 Delete type=3 #2
+1 -1
View File
@@ -1 +1 @@
2026/08/18-22:37:15.412095 17409b000 Delete type=3 #1
2026/08/18-23:21:17.060194 16f48b000 Delete type=3 #1
+5 -5
View File
@@ -1,5 +1,5 @@
2026/08/18-22:37:32.803216 1773a3000 Recovering log #3
2026/08/18-22:37:32.803361 1773a3000 Level-0 table #5: started
2026/08/18-22:37:32.803524 1773a3000 Level-0 table #5: 6939 bytes OK
2026/08/18-22:37:32.803783 1773a3000 Delete type=0 #3
2026/08/18-22:37:32.803833 1773a3000 Delete type=3 #2
2026/08/18-23:21:42.525170 16edc3000 Recovering log #3
2026/08/18-23:21:42.525197 16edc3000 Level-0 table #5: started
2026/08/18-23:21:42.525391 16edc3000 Level-0 table #5: 6939 bytes OK
2026/08/18-23:21:42.525699 16edc3000 Delete type=0 #3
2026/08/18-23:21:42.525727 16edc3000 Delete type=3 #2
+1 -1
View File
@@ -1 +1 @@
2026/08/18-22:37:15.360390 17409b000 Delete type=3 #1
2026/08/18-23:21:17.004916 1704a3000 Delete type=3 #1
+5 -5
View File
@@ -1,5 +1,5 @@
2026/08/18-22:37:32.797239 17638b000 Recovering log #3
2026/08/18-22:37:32.797509 17638b000 Level-0 table #5: started
2026/08/18-22:37:32.797806 17638b000 Level-0 table #5: 14767 bytes OK
2026/08/18-22:37:32.798073 17638b000 Delete type=0 #3
2026/08/18-22:37:32.798122 17638b000 Delete type=3 #2
2026/08/18-23:21:42.521392 16edc3000 Recovering log #3
2026/08/18-23:21:42.521476 16edc3000 Level-0 table #5: started
2026/08/18-23:21:42.521798 16edc3000 Level-0 table #5: 14767 bytes OK
2026/08/18-23:21:42.522090 16edc3000 Delete type=0 #3
2026/08/18-23:21:42.522134 16edc3000 Delete type=3 #2
+1 -1
View File
@@ -1 +1 @@
2026/08/18-22:37:15.355377 172877000 Delete type=3 #1
2026/08/18-23:21:16.999207 1704a3000 Delete type=3 #1
+92 -11
View File
@@ -52,7 +52,8 @@ import {
CIRCUMSTANCE, RANGE_LADDER, rangeBandFrom, MAJOR_WOUND_TABLE,
COHERENCE_BANDS, COHERENCE_COSTS, COHERENCE_RECOVERY, coherenceBandFrom,
PANIC_MAX, PANIC_BANDS, PANIC_TRIGGERS, PANIC_RELIEF, PANIC_BREAK, PANIC_AFTER_BREAK,
panicBandFrom, panicPenaltyFor, panicBreaksAt, panicAfter, panicReliefFor
panicBandFrom, panicPenaltyFor, panicBreaksAt, panicAfter, panicReliefFor,
DEVELOPMENT, developmentMarksOn, developmentImproves, developmentGainFor
} from "./rules.mjs";
export { hitPointsFor, majorWoundFor, damageModifierFor, locationMaxHp, reflexRating, awarenessRating };
@@ -1096,10 +1097,10 @@ export async function skillRoll({
tokenId: actor?.token?.id ?? null } }
}, { rollMode: game.settings.get("core", "rollMode") });
// BRP experience: a skill used SUCCESSFULLY under pressure earns a tick. This
// was stored on every skill and set by nothing, so ticks never appeared and the
// end-of-case improvement roll had nothing to work on.
if (skillItem && ["critical", "special", "success"].includes(level)
// Development is REVERSED from Call of Cthulhu: a skill is marked when it FAILS you,
// not when it works. Nobody learns anything from the lock that opened first time.
// The condition lives in rules.mjs so the rulebook, the primer and this cannot drift.
if (skillItem && developmentMarksOn(level)
&& skillItem.system?.experienceEligible !== false && !skillItem.system?.experienceTicked) {
await skillItem.update({ "system.experienceTicked": true });
}
@@ -3309,7 +3310,7 @@ export async function usePower(actor, power, { pushed = false, difficulty = "ave
// An Art earns its tick the way a skill does. Without this it arrived at roughly
// POW x2 and stayed there for the rest of the character's life while charging
// Coherence per attempt — a trap rather than a temptation.
if (["critical", "special", "success"].includes(res.level) && !power.system?.experienceTicked) {
if (developmentMarksOn(res.level) && !power.system?.experienceTicked) {
await power.update({ "system.experienceTicked": true });
}
@@ -3665,6 +3666,31 @@ export async function restFor(actor, { days = 0, hours = 0, device = "" } = {})
return total > 0 ? heal(actor, total, { source: device || "natural" }) : actor.system.hp.value;
}
/** Is the development phase open? Safe before init, so the sheet can ask freely. */
export function developmentOpen() {
try { return game.settings.get("ringbrp", "developmentPhase") === true; }
catch { return false; }
}
/**
* The GM opens the phase at the end of a case. Everybody's sheet grows a button; each
* player presses their own. Closing it again is the GM saying the case is finished.
*/
export async function setDevelopmentPhase(open = true) {
if (!game.user.isGM) { ui.notifications?.warn(game.i18n.localize("RINGBRP.Dev.GMOnly")); return null; }
await game.settings.set("ringbrp", "developmentPhase", !!open);
const marked = game.actors.filter(a => a.type === "character")
.map(a => ({ name: a.name,
n: a.items.filter(i => ["skill","power"].includes(i.type) && i.system.experienceTicked).length }))
.filter(r => r.n > 0);
await ChatMessage.create({
speaker: { alias: game.i18n.localize("RINGBRP.Dev.Title") },
content: await rbRenderTemplate("systems/ringbrp/templates/chat-development.hbs",
{ open: !!open, rows: marked })
});
return !!open;
}
/**
* End of case. Every ticked skill rolls to improve: succeed against d100 > current
* rating and it goes up 1D6, and the tick clears either way. This is the step that
@@ -3690,18 +3716,23 @@ export async function endOfCase(actor) {
const rating = Number(sk.system.value) || 0;
const roll = await new Roll("1d100").evaluate();
// Roll OVER the current rating: the better you already are, the harder to gain.
const improved = roll.total > rating;
const gain = improved ? (await new Roll("1d6").evaluate()).total : 0;
const improved = developmentImproves(roll.total, rating);
const rolled = improved ? (await new Roll(DEVELOPMENT.gain).evaluate()).total : 0;
// A d100 gain can overshoot the top, so clip it to the skill's ceiling.
const gain = developmentGainFor(rating, rolled, sk.system.trainingCap);
const capped = improved && gain < rolled;
await sk.update({ "system.value": rating + gain, "system.experienceTicked": false });
results.push({ name: sk.name, rating, roll: roll.total, gain, improved });
results.push({ name: sk.name, rating, roll: roll.total, gain, improved, capped });
}
await ChatMessage.create({
speaker: ChatMessage.getSpeaker({ actor }),
content: `<div class="ringbrp-chat"><h3 class="rb-chat-title">${
game.i18n.localize("RINGBRP.Exp.Title")}</h3><ul class="rb-chat-flavour">${
results.map(r => `<li>${r.name}: ${r.roll} vs ${r.rating}% — ${
r.improved ? `<strong>+${r.gain}</strong> &rarr; ${r.rating + r.gain}%` :
game.i18n.localize("RINGBRP.Exp.NoGain")}</li>`).join("")}</ul></div>`
!r.improved ? game.i18n.localize("RINGBRP.Exp.NoGain") :
r.gain ? `<strong>+${r.gain}</strong> &rarr; ${r.rating + r.gain}%${
r.capped ? ` <em>(${game.i18n.localize("RINGBRP.Exp.Capped")})</em>` : ""}` :
game.i18n.localize("RINGBRP.Exp.AtCeiling")}</li>`).join("")}</ul></div>`
});
return results;
}
@@ -4889,6 +4920,11 @@ class RingBRPCharacterSheet extends ActorSheet {
ctx.reaction = this._reactionRail();
ctx.readiness = this._readiness ?? { rating: 0 };
ctx.coherence = { band: coherenceBand(sys.coherence?.value) };
ctx.development = {
open: developmentOpen(),
marked: this.actor.items.filter(i => ["skill", "power"].includes(i.type)
&& i.system?.experienceTicked).length
};
ctx.panic = { band: panicBandFrom(sys.panic?.value),
penalty: panicPenaltyFor(sys.panic?.value),
pips: Array.from({ length: PANIC_MAX }, (_, i) => i < Number(sys.panic?.value ?? 0)) };
@@ -4953,6 +4989,13 @@ class RingBRPCharacterSheet extends ActorSheet {
el.addEventListener("click", ev => this._onRollAttribute(ev)));
root.querySelectorAll("[data-action='roll-characteristic']").forEach(el =>
el.addEventListener("click", ev => this._onRollCharacteristic(ev)));
root.querySelectorAll("[data-action='development']").forEach(el =>
el.addEventListener("click", async ev => {
ev.preventDefault(); ev.stopPropagation();
if (!developmentOpen()) { ui.notifications?.warn(game.i18n.localize("RINGBRP.Dev.NotOpen")); return; }
await endOfCase(this.actor);
this.render(false);
}));
root.querySelectorAll("[data-action='full-skills']").forEach(el =>
el.addEventListener("click", async () => {
const n = await grantFullSkillList(this.actor);
@@ -6176,6 +6219,25 @@ async function resolveLegDialog(expedition) {
Hooks.once("init", () => {
console.log("RingBRP | initialising");
// The development phase. World-scoped and GM-only, because it is a thing the table
// does together at the end of a case rather than a preference. It is the first game
// setting this system registers, and it earns that: the players' button has to know
// whether the GM has opened the phase, and that answer must survive a reload.
game.settings.register("ringbrp", "developmentPhase", {
name: "RINGBRP.Dev.SettingName",
hint: "RINGBRP.Dev.SettingHint",
scope: "world", config: false, type: Boolean, default: false,
onChange: open => {
for (const a of game.actors) a.sheet?.rendered && a.sheet.render(false);
// getSceneControlButtons fires once at load, so the toggle would keep showing
// the state it had then if the phase were opened by anything else - the API, or
// a second GM. Foundry preserves `active` on toggle tools across a render, so
// setting it and re-rendering is what keeps the button honest.
const tool = ui.controls?.controls?.tokens?.tools?.ringbrpDevelopment;
if (tool) { tool.active = !!open; ui.controls.render(); }
}
});
// The figure is drawn in three places — combat, anatomy, and the header thumbnail.
// One partial, so a location cannot be present in one of them and missing from
// another, which is the same rule rules.mjs enforces for the formulas.
@@ -6237,6 +6299,7 @@ Hooks.once("init", () => {
coherenceBand, coherencePenaltyFor, woundPenaltyFor, loseCoherence, regainCoherence, displace,
panicBand, panicPenaltyFor, panicBreaksAt, panicReliefFor, panicAfter,
gainPanic, easePanic, breakPanic, clearPanic, partyState,
developmentOpen, setDevelopmentPhase, developmentMarksOn, developmentImproves, developmentGainFor,
isAcross, setAcross, useTransposed, debrief, activeCaseFile, returnCrossing, caseMembers,
earnedDiscoveries, deviceFunctionStatus, earnDiscovery, useDeviceFunction, pickContactActor,
resolveContact, newEncounter, claimRewards, activeRewardEffects,
@@ -6434,6 +6497,24 @@ for (const hook of [foundry?.applications?.sidebar?.tabs?.ChatLog
});
}
// The GM needs a way to open the development phase that is not the console. It rides
// in the token controls beside the other table-facing tools, GM-only, and reflects the
// world setting so a reload or a second GM screen shows the true state.
Hooks.on("getSceneControlButtons", controls => {
if (!game.user?.isGM) return;
const group = controls?.tokens;
if (!group?.tools) return;
group.tools.ringbrpDevelopment = {
name: "ringbrpDevelopment",
order: Object.keys(group.tools).length + 1,
title: "RINGBRP.Dev.Toggle",
icon: "fa-solid fa-arrow-trend-up",
toggle: true,
active: developmentOpen(),
onChange: (_event, active) => setDevelopmentPhase(active)
};
});
Hooks.on("renderCombatTracker", (app, html) => {
const root = html instanceof HTMLElement ? html : html?.[0];
if (!root || !game.combat) return;
+59
View File
@@ -283,6 +283,65 @@ export const COHERENCE_RECOVERY = { debrief: 1, downtimeWeek: 1, anchorObject: 1
arithmetic.
============================================================ */
/* ============================================================
DEVELOPMENT — you learn from what beat you
============================================================
Call of Cthulhu marks a skill when you SUCCEED with it and improves it later.
This department works the other way round: a skill is marked when it FAILS you.
Nobody learns anything from the lock that opened first time.
The improvement roll itself is the familiar one and is deliberately unchanged: roll
d100 against the current rating and you improve by OVERSHOOTING it. The better you
already are, the less often a case teaches you anything, which is what stops a
long campaign turning everyone into ninety-percenters.
The two halves therefore pull in opposite directions on purpose. A low skill fails
constantly in play (so it gets marked) and then fails its development roll (so it
climbs fast). A high skill rarely fails at all, and when it does it usually passes
the development roll and gains nothing. Skills converge rather than run away.
There is still a hard ceiling, because a d100 gain can overshoot the top: a skill is
held at its own trainingCap if it has one, and at DEVELOPMENT.ceiling otherwise.
============================================================ */
/** A skill is marked by the outcomes that went badly. Nothing else marks it. */
export const DEVELOPMENT = {
marksOn: ["failure", "fumble"],
gain: "1d6",
improveOn: "over",
ceiling: 100
};
/** Did this outcome mark the skill for development? */
export function developmentMarksOn(level) {
return DEVELOPMENT.marksOn.includes(level);
}
/**
* Does this development roll improve the skill? Roll OVER the current rating.
* A rating of 0 always improves; a rating of 100 or better never does.
*/
export function developmentImproves(roll, rating) {
return (Number(roll) || 0) > (Number(rating) || 0);
}
/**
* The highest a skill can be taken to. A skill carrying its own trainingCap is held
* there — that is the only thing trainingCap has ever meant — and everything else
* stops at the point where a d100 can no longer overshoot it.
*/
export function developmentCeiling(trainingCap) {
const cap = Number(trainingCap);
return Number.isFinite(cap) && cap > 0 ? Math.min(cap, DEVELOPMENT.ceiling) : DEVELOPMENT.ceiling;
}
/** What the skill actually becomes: the gain, clipped to the ceiling. */
export function developmentGainFor(rating, rolled, trainingCap) {
const from = Number(rating) || 0;
const to = Math.min(from + (Number(rolled) || 0), developmentCeiling(trainingCap));
return Math.max(0, to - from);
}
export const PANIC_MAX = 5;
export const PANIC_BANDS = [
+11
View File
@@ -1858,3 +1858,14 @@
.rb-scene-list li { display: flex; justify-content: space-between; padding: 2px 0; font-size: 13px; }
.rb-scene-name { color: var(--rb-white); }
.rb-scene-drop { color: var(--rb-yellow); font-variant-numeric: tabular-nums; }
/* Development — the button only exists while the GM has the phase open. */
.rb-cbtn-dev {
color: var(--rb-void); background: var(--rb-yellow); border-color: var(--rb-yellow);
}
.rb-cbtn-dev:hover { background: var(--rb-white); border-color: var(--rb-white); }
.rb-dev-count {
font-family: var(--rb-body); font-size: 11px; font-weight: 700; letter-spacing: .06em;
text-transform: uppercase; color: var(--rb-danger);
}
.rb-dev-open { color: var(--rb-yellow); font-size: 13px; margin: 4px 0; }
+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.4.2",
"version": "1.5.0",
"compatibility": {
"minimum": 13,
"verified": "14.364"
+7
View File
@@ -181,6 +181,13 @@
{{/if}}
<div class="rb-skillbar">
<span class="rb-dim">{{skillCount}} {{localize "RINGBRP.Sheet.SkillsShown"}}</span>
{{#if development.marked}}
<span class="rb-dev-count" title="{{localize 'RINGBRP.Dev.Tooltip'}}">{{development.marked}} {{localize "RINGBRP.Dev.Marked"}}</span>
{{/if}}
{{#if development.open}}
<a class="rb-cbtn rb-cbtn-dev" data-action="development"
title="{{localize 'RINGBRP.Dev.Tooltip'}}">{{localize "RINGBRP.Dev.Button"}}</a>
{{/if}}
<a class="rb-cbtn" data-action="full-skills">{{localize "RINGBRP.Sheet.FullList"}}</a>
</div>
{{#each RINGBRP.categories as |cat key|}}
+19
View File
@@ -0,0 +1,19 @@
<div class="ringbrp-chat rb-dev-card">
<div class="rb-chat-title">{{localize "RINGBRP.Dev.Title"}}</div>
{{#if open}}
<p class="rb-dev-open">{{localize "RINGBRP.Dev.Opened"}}</p>
{{#if rows.length}}
<table class="rb-party-table">
<thead><tr><th>{{localize "RINGBRP.Party.Who"}}</th><th>{{localize "RINGBRP.Dev.Marked"}}</th></tr></thead>
<tbody>
{{#each rows as |r|}}<tr><td class="rb-party-name">{{r.name}}</td><td>{{r.n}}</td></tr>{{/each}}
</tbody>
</table>
<p class="rb-panic-note">{{localize "RINGBRP.Dev.PressIt"}}</p>
{{else}}
<p class="rb-panic-note">{{localize "RINGBRP.Exp.NoTicks"}}</p>
{{/if}}
{{else}}
<p class="rb-panic-note">{{localize "RINGBRP.Dev.Closed"}}</p>
{{/if}}
</div>
+53
View File
@@ -564,6 +564,59 @@ test("no roster agent has Anomaly Lore 0", async () => {
"induction's lowest band is 0, so a probationer still cannot roll it");
});
/* ---------------------------------------------------------------- *
* Development — reversed from Call of Cthulhu
* ---------------------------------------------------------------- */
// The whole point of the house rule: a skill is marked by FAILING, not by working.
// If this ever flips back, characters stop improving at the things they are bad at,
// which is the entire behaviour the rule exists to produce.
test("only failure and fumble mark a skill for development", () => {
for (const good of ["critical", "special", "success"])
assert.equal(RULES.developmentMarksOn(good), false, `${good} marked a skill`);
for (const bad of ["failure", "fumble"])
assert.equal(RULES.developmentMarksOn(bad), true, `${bad} did not mark a skill`);
});
// Roll OVER to improve, so the better you are the less a case teaches you. Together
// with marking-on-failure this is what makes ratings converge instead of running away.
test("development improves on ANOTHER failure, and ties do not count", () => {
assert.equal(RULES.developmentImproves(72, 45), true);
assert.equal(RULES.developmentImproves(30, 45), false);
assert.equal(RULES.developmentImproves(45, 45), false, "a tie improved the skill");
assert.equal(RULES.developmentImproves(1, 0), true, "a rating of 0 could not improve");
assert.equal(RULES.developmentImproves(100, 100), false, "a rating of 100 still improved");
});
test("a d100 gain cannot push a skill past the ceiling", () => {
// 98% + the best possible 1d6 would be 104% without the clip
assert.equal(98 + RULES.developmentGainFor(98, 6, null), RULES.DEVELOPMENT.ceiling);
});
test("a skill with a trainingCap stops there, not at the global ceiling", () => {
assert.equal(60 + RULES.developmentGainFor(60, 6, 62), 62);
assert.equal(RULES.developmentGainFor(62, 6, 62), 0, "already at its cap, gains nothing");
});
test("trainingCap is honoured but cannot raise the ceiling above it", () => {
assert.equal(RULES.developmentCeiling(250), RULES.DEVELOPMENT.ceiling);
assert.equal(RULES.developmentCeiling(null), RULES.DEVELOPMENT.ceiling);
});
test("a skill at the ceiling can still be marked but never gains", () => {
// 96-00 always fails, so even a maxed skill gets marked - it just never improves
assert.ok(RULES.developmentMarksOn("failure"));
assert.equal(RULES.developmentGainFor(RULES.DEVELOPMENT.ceiling, 6, null), 0);
});
test("the gain is a die, not a fixed number", () =>
assert.match(RULES.DEVELOPMENT.gain, /^\d*d\d+$/));
// The two halves must disagree: what marks a skill in play must be an outcome the
// development roll treats as improvement, or the rule reads as two unrelated coins.
test("the marking outcome and the improving outcome are the same idea", () => {
assert.ok(RULES.DEVELOPMENT.marksOn.includes("failure"));
assert.equal(RULES.DEVELOPMENT.improveOn, "over",
"improveOn changed; the rulebook and primer describe rolling over");
});
/* ---------------------------------------------------------------- */
await runAll();
+17
View File
@@ -197,6 +197,23 @@ const checks = [
["panicReliefFor('special')", RULES.panicReliefFor("special"), 2],
["panicReliefFor('failure')", RULES.panicReliefFor("failure"), 0],
["panicReliefFor('fumble')", RULES.panicReliefFor("fumble"), -1],
// Development is REVERSED from the usual: failure marks the skill, not success.
["developmentMarksOn('failure')", RULES.developmentMarksOn("failure"), true],
["developmentMarksOn('fumble')", RULES.developmentMarksOn("fumble"), true],
["developmentMarksOn('success')", RULES.developmentMarksOn("success"), false],
["developmentMarksOn('special')", RULES.developmentMarksOn("special"), false],
["developmentMarksOn('critical')",RULES.developmentMarksOn("critical"), false],
["developmentImproves(72,45)", RULES.developmentImproves(72, 45), true],
["developmentImproves(30,45)", RULES.developmentImproves(30, 45), false],
["developmentImproves(45,45)", RULES.developmentImproves(45, 45), false],
["developmentCeiling(null)", RULES.developmentCeiling(null), 100],
["developmentCeiling(75)", RULES.developmentCeiling(75), 75],
["developmentCeiling(140)", RULES.developmentCeiling(140), 100],
["developmentGainFor(45,6,null)", RULES.developmentGainFor(45, 6, null), 6],
["developmentGainFor(97,6,null)", RULES.developmentGainFor(97, 6, null), 3],
["developmentGainFor(73,6,75)", RULES.developmentGainFor(73, 6, 75), 2],
["developmentGainFor(75,6,75)", RULES.developmentGainFor(75, 6, 75), 0],
// 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],
+26 -1
View File
@@ -16,7 +16,8 @@ import { TRADE_BANDS, INDUCTION } from "../postings.mjs";
import {
hitPointsFor, majorWoundFor, damageModifierFor, defencePenaltyFor,
COHERENCE_BANDS, RANGE_LADDER, CIRCUMSTANCE,
PANIC_BANDS, PANIC_TRIGGERS, PANIC_RELIEF, PANIC_MAX, PANIC_AFTER_BREAK, PANIC_BREAK
PANIC_BANDS, PANIC_TRIGGERS, PANIC_RELIEF, PANIC_MAX, PANIC_AFTER_BREAK, PANIC_BREAK,
DEVELOPMENT
} from "../rules.mjs";
import { readFile } from "node:fs/promises";
@@ -412,6 +413,30 @@ export const PRIMER_PAGES = [
+ P("<strong>Panic clears when the scene does.</strong> Somewhere safe, door shut, "
+ "somebody has made tea: you are back to nought. Coherence does not clear.") },
{ name: "8c · Getting better at things", content:
P("<span class=\"rb-big-number\">You improve at what BEATS you.</span>"
+ "Not at what you are already good at. If you have played this kind of game before, "
+ "this is the opposite way round, and it is deliberate.")
+ H(2, "During the case")
+ P("<strong>Every time a skill fails you, it gets marked.</strong> You do not have to "
+ "do anything &mdash; the sheet marks it. A success marks nothing. Nobody learns "
+ "anything from the lock that opened first time.")
+ H(2, "At the end")
+ P("The GM opens the <strong>development phase</strong> and a button appears on your "
+ "sheet. Press it once. For every marked skill you roll 1D100, and:")
+ TABLE(["The roll", "What happens"], [
["<strong>Over</strong> your current rating &mdash; another failure",
"<strong>the skill goes up ${DEVELOPMENT.gain}</strong>"],
["Equal to or under your rating &mdash; you made it",
"nothing. You knew that already."]
])
+ P("Marks clear either way, and the next case starts clean.")
+ P("<strong>Why it works like that.</strong> A skill you are bad at fails all the "
+ "time, so it gets marked, and then it fails the development roll too, so it climbs "
+ "fast. A skill you are already good at hardly ever fails, and when it does it "
+ "usually passes the development roll and gains nothing. Everybody's weak spots "
+ "close and nobody runs away with a 95%.") },
{ name: "9 · The whole game, in one page", content:
P("You play ordinary adults in the modern world, pulled into an old and quiet "
+ "government department because you stay yourselves when reality goes wrong.")
+24 -5
View File
@@ -29,7 +29,8 @@ import {
crossingArtPoolFor, crossingGrantsArt, crossingCostFor,
REACTION, BLEED, CIRCUMSTANCE, RANGE_LADDER, rangeBandFrom,
MAJOR_WOUND_TABLE, COHERENCE_BANDS, COHERENCE_COSTS, COHERENCE_RECOVERY,
PANIC_BANDS, PANIC_TRIGGERS, PANIC_RELIEF, PANIC_MAX, PANIC_AFTER_BREAK, PANIC_BREAK
PANIC_BANDS, PANIC_TRIGGERS, PANIC_RELIEF, PANIC_MAX, PANIC_AFTER_BREAK, PANIC_BREAK,
DEVELOPMENT
} 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";
@@ -686,10 +687,28 @@ find the brief was, as always, incomplete. They sort it out or they do not. They
back and are debriefed, and the debrief is where coherence is recovered and where the
GM finds out what the players think they did.</p>
<h2>Afterwards</h2>
<p>Every skill you used successfully under pressure is ticked. At the end of the case
each ticked skill rolls to improve: beat your current rating on 1D100 and it goes up
1D6. Ticks clear either way.</p>
<h2>Afterwards — development</h2>
<p><em>This is the opposite way round from most games in this family, and it is meant
to be.</em></p>
<p><strong>A skill is marked when it FAILS you</strong> &mdash; ${DEVELOPMENT.marksOn.join(' or ')} &mdash;
and not when it works. Nobody learns anything from the lock that opened first time.</p>
<p>At the end of a case the GM opens the <strong>development phase</strong>, and each
agent rolls once for every marked skill. <strong>Roll 1D100 OVER your current rating and
it goes up ${DEVELOPMENT.gain}</strong>. If you make the roll you have learned nothing this
time. Marks clear either way.</p>
<p>So the two halves pull against each other on purpose. A skill you are bad at fails
constantly, gets marked, and then fails its development roll and climbs quickly. A skill
you are good at rarely fails at all, and when it does it usually passes the development
roll and gains nothing. Ratings converge instead of running away.</p>
<h3>Running the phase at the table</h3>
<p>The GM opens it with the <strong>Development phase</strong> toggle in the token
controls on the left of the screen, which only GMs see. Opening it posts a card naming
every agent and how many skills each has marked, and puts a <strong>Development</strong>
button on every agent's sheet. Each player presses their own button once; the rolls and
the results go to chat. Marks clear as they are rolled, so a second press does nothing.
Close the toggle when everyone has rolled.</p>
<p><em>A scene has to be open on the canvas for the toggle to respond — that is true of
every control on that bar, not just this one.</em></p>
<p>Cases do not need to connect. The running thread of a campaign is not a plot — it
is the four coherence tracks going down.</p>` },