8 Commits
Author SHA1 Message Date
slaguru666andClaude Opus 5 37b4043746 Play a full case, then fix what it found
Ran THE HOLLOW ROAD with five agents — approach, crossing, contact, the thing on
the road, extraction, debrief — with panic and Coherence both live. It ran clean
and it found one thing that mattered.

ANOMALY LORE 0. Marta Holloway, Field Lead, Senior Field Officer, could not roll
the department's signature skill. Nor could Okonkwo. Induction was gated to
TRADES on the reasoning that a career posting is itself the agency training —
but 17 of the 25 postings do not list anomaly lore, its printed base is 0 on
purpose, and nothing else supplies it. So the people who joined the department
straight out of school were the only ones who never received its basic training,
which is exactly backwards.

Induction is a FLOOR for everybody now, posting or trade, in the live generator
and the pack builder both. It never demotes: Bhattacharya keeps Registry's 63,
Holloway goes 0 → 43, Okonkwo 0 → 33. All twenty built agents verified non-zero.

Two GM tools the case showed were missing, both found by feeling the friction
rather than by reading anything:

  - clearPanic(actors). Panic clears when the scene does — one line in the book
    and, until now, five sheets edited by hand while the table waited. It leaves
    Coherence alone, which is the whole point of having two tracks.
  - partyState(). Everybody's hit points, panic and Coherence in one card. With
    five agents the alternative is asking "what are you on?" five times a scene,
    which is friction a table absorbs silently and then blames on the game.

And the player-facing gap: the primer never mentioned panic. Players got a
one-page aide-memoire for a track they now meet in the first scene. It has a
page of its own and a row on the cheat sheet, both generated from the same
tables as the rules.

One thing I reported as a bug and withdraw: Okonkwo's "Firearm (Pistol) 5%" was
me rolling the wrong weapon. He carries a marksman rifle at 53%. check-kits was
right.

Version 1.2.0.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-18 00:10:28 +01:00
slaguru666andClaude Opus 5 7ec7efd29b Replay the scene with the fumble rule; expose panicReliefFor
Ran the east corridor again with fresh dice and every relief attempt routed
through easePanic, fumbles included.

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

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

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

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

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

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

Version 1.1.3.

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

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

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

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

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

Version 1.1.2.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-17 23:25:46 +01:00
slaguru666andClaude Opus 5 20306d83d9 Play a scene with panic; tighten one reference row
Ran LAST ADMISSION's east corridor with the six civilians and real dice. The
track works end to end and, more importantly, the penalty REACHES things:
Ferriby at panic 3 swung at Old Marrow with Brawl 25% printed and 15% effective,
while Sharrock at panic 1 rolled his full 58%. That is the whole point of the
track and it is the part that is usually decoration.

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

One thing I got wrong and one thing worth knowing:

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

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

Version 1.1.1.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-17 21:58:30 +01:00
slaguru666andClaude Opus 5 cbad87ba98 Add Occult, Religion and Psychology, and the Panic track
From the first game's list. The three skills are not decoration — they are the
levers on the new track, which is why they arrived together.

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

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

A trigger gives a point with NO ROLL. Asking for a die to find out whether you
are frightened slows the scene and tells the player how to feel; the die is for
getting rid of it, which is the interesting direction.

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

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

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

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

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

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

Version 1.1.0.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-12 22:55:11 +01:00
slaguru666andClaude Opus 5 ab619f4b73 Remove the dice textures and the whole Dice So Nice path
Deleted: the seven PNGs in dice/, tools/make-dice-textures.mjs, the `npm run
dice` script, the RB_DICE colourset and texture tables, diceSetFor, the
diceSoNiceReady hook, and rbDressDice — which was tagging ten roll sites with
coloursets that no longer existed. 99 lines of module, plus the assets.
README and DEPLOY updated; they still advertised all of it.

Recoverable with `git revert` if it is ever wanted back.

This also explains the 01-that-was-12. The RESULT was never wrong: skillRoll
rolls one 1d100 and grades on roll.total, and the chat card derives the tens and
units it prints from that same total. Checked all 100 values of percentileFaces
and 60 live rolls — the printed faces reconstruct the graded number every time,
with no mismatches.

What disagreed was the ANIMATION. The custom addDicePreset labels registered for
d100 and d10 remapped which physical face means what, so Dice So Nice could land
showing 01 while the roll behind it was 12. Nothing downstream ever read the
animated faces, so no roll was ever adjudicated on the wrong number — but a
table watching the dice had every reason to believe otherwise, which is its own
kind of wrong. Disabling the skins in 1.0.6 already removed it; deleting them
makes it permanent.

Verified in a running Foundry after removal: skill roll, weapon damage, hit
location, major wound and Coherence all resolve and post their cards, no errors,
and game.ringbrp.diceSetFor is gone from the API.

Version 1.0.7.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-12 22:45:52 +01:00
slaguru666andClaude Opus 5 9b6c61ffcb Turn the custom Dice So Nice skins off
Registering them with "preferred" made them the default for everybody in the
world, which overrides whatever dice a player had already chosen — the system
helping itself to a setting that belongs to the user. Off at the author's
request.

One named constant rather than a settings API: this codebase registers no game
settings at all, and adding that surface, its lang keys and its UI to switch one
feature off is more machinery than the job needs. Flip RB_DICE_ENABLED to true
and everything below it still works.

The textures in dice/ and the generator behind `npm run dice` are left alone.
Nothing reads them while this is false, and deleting work in order to turn a
feature off is a poor trade.

Verified in a running Foundry with Dice So Nice active: "custodians" is no
longer among the registered systems, and preferredSystem has gone back to
"standard" rather than ours.

Version 1.0.6.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-12 20:34:22 +01:00
slaguru666andClaude Opus 5 0852427586 Fix the pause: it would not hide, it was off centre, and it was too small
Three faults in 1.0.4, and the first was the serious one.

  1. It never went away. Core hides the overlay with `#pause { display: none }`
     and shows it with `#pause.paused { display: flex }`. 1.0.4 styled the BARE
     #pause with display:flex, which wins on specificity, so unpausing removed
     the class and changed nothing — GAME PAUSED sat over the table for the rest
     of the session. Every rule here is now scoped to `.paused` and `display` is
     not set anywhere.

  2. It was off centre because I repositioned it. Core already centres it: a
     full-width band at top calc(50vh - 100px), column flex, centred both ways,
     pointer-events none. That layout was correct and 1.0.4 fought it with
     left/bottom/transform. It is left alone now; only the height changes, and
     the top moves up by exactly half the extra so the centring holds.

  3. It was small: a 322x116 panel in the corner. It is now core's full-width
     band at 300px with a 170px dial — against core's own 100px — and the
     caption at 24px to match core's.

Guarded: check-behaviour now parses the stylesheet and fails any rule that sets
display on an unscoped #pause. Verified it bites. It strips CSS comments first,
because the first version of the guard reported the comment explaining the
guard — which is exactly what the template guard did last time, so that is twice
now and the lesson is written down properly.

Verified by driving the real toggle: running → display none, box 0x0, no class;
paused → display flex, 341x302, centre X exactly on the viewport centre;
unpaused again → display none, box 0x0.

Version 1.0.5.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-12 19:31:35 +01:00
53 changed files with 820 additions and 382 deletions
+11 -11
View File
@@ -28,7 +28,8 @@ git clone https://gitea.timevans.uk/slaguru666/RingBRP.git ringbrp
``` ```
Requires **Foundry v13+** (verified against v14.364). No modules are required. Dice So Requires **Foundry v13+** (verified against v14.364). No modules are required. Dice So
Nice is supported but optional — the system registers four of its own dice textures. Nice works if you have it, using your own dice — the system ships no dice skins and does
not touch anybody's dice settings.
### Deploying to a remote Foundry server ### Deploying to a remote Foundry server
@@ -47,7 +48,7 @@ packs or regenerate art on that machine:
```bash ```bash
npm install # pulls classic-level, used to write the LevelDB packs npm install # pulls classic-level, used to write the LevelDB packs
npm run build # rebuilds packs/ from tools/content.mjs npm run build # rebuilds packs/ from tools/content.mjs
npm run check # the three guards; the build refuses to run if they fail npm run check # the five guards; the build refuses to run if they fail
``` ```
To update a deployed server: `git pull` and restart Foundry. If the pull touches To update a deployed server: `git pull` and restart Foundry. If the pull touches
@@ -64,13 +65,13 @@ changing underneath a running server.
| **Postings** | 25, each with a key characteristic, trained skills, talents and its own kit | | **Postings** | 25, each with a key characteristic, trained skills, talents and its own kit |
| **Trades** | 10 ordinary jobs the department recruits from, plus induction | | **Trades** | 10 ordinary jobs the department recruits from, plus induction |
| **Catalogue** | 236 items — 123 gear, 62 weapons, 32 armour, 19 vehicles | | **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 | | **Bestiary** | 21 statblocks across folklore, horror and the far side |
| **Eras** | modern, antique, future, anomalous — the agency is old and never abolishes anything | | **Eras** | modern, antique, future, anomalous — the agency is old and never abolishes anything |
| **Icons** | 292, all generated from `tools/icons.mjs`; no external art | | **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 | | **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 | | **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 --> <!-- box:end -->
--- ---
@@ -152,13 +153,12 @@ template.json data model for every Actor and Item type
templates/ Handlebars sheets templates/ Handlebars sheets
styles/ one stylesheet styles/ one stylesheet
lang/en.json all user-facing text lang/en.json all user-facing text
icons/ dice/ fonts/ generated art icons/ fonts/ art/ generated art
packs/ built LevelDB compendia (committed — see Deploying) packs/ built LevelDB compendia (committed — see Deploying)
tools/ tools/
content.mjs the catalogue: skills, weapons, armour, gear, vehicles, NPCs content.mjs the catalogue: skills, weapons, armour, gear, vehicles, NPCs
build-packs.mjs builds packs/ — runs the three guards first and refuses on failure build-packs.mjs builds packs/ — runs the five guards first and refuses on failure
make-icons.mjs draws all 231 icons make-icons.mjs draws all 231 icons
make-dice-textures.mjs
rules-text.mjs generates the rules journal FROM rules.mjs rules-text.mjs generates the rules journal FROM rules.mjs
mission.mjs the case generator mission.mjs the case generator
mission-tables.mjs its content tables mission-tables.mjs its content tables
@@ -176,13 +176,13 @@ stops being identity-equal to what it aliases, if a constant is re-declared as a
literal, or if an exported rule has no spot-check. The packs used to be built under literal, or if an exported rule has no spot-check. The packs used to be built under
one set of numbers and played under another; this makes that impossible to ship. one set of numbers and played under another; this makes that impossible to ship.
The build runs all three guards before it writes anything: The build runs all five guards before it writes anything:
<!-- guards:start --> <!-- guards:start -->
``` ```
check-rules: OK — 87 rules, 21 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-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-lang: OK — en.json, 981 keys, no leaf/branch collisions
check-behaviour: OK — 45 behavioural tests check-behaviour: OK — 59 behavioural tests
``` ```
<!-- guards:end --> <!-- guards:end -->
Binary file not shown.

Before

Width:  |  Height:  |  Size: 43 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 785 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 970 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 826 B

+1 -2
View File
@@ -33,10 +33,9 @@ Only if you intend to edit the catalogue there:
```bash ```bash
npm install # classic-level, for writing LevelDB npm install # classic-level, for writing LevelDB
npm run check # the three guards npm run check # the five guards
npm run build # rebuilds packs/ npm run build # rebuilds packs/
node tools/make-icons.mjs # redraws all icons node tools/make-icons.mjs # redraws all icons
node tools/make-dice-textures.mjs # regenerates the Dice So Nice textures
``` ```
`npm run build` runs the guards first and aborts if any fails, so a broken catalogue `npm run build` runs the guards first and aborts if any fails, so a broken catalogue
+37 -1
View File
@@ -943,5 +943,41 @@
"RINGBRP.Item.ArtRating": "Rating", "RINGBRP.Item.ArtRating": "Rating",
"RINGBRP.Item.PushCost": "Push cost", "RINGBRP.Item.PushCost": "Push cost",
"RINGBRP.Item.PushRule": "What pushing does", "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 —",
"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"
} }
-1
View File
@@ -6,7 +6,6 @@
"scripts": { "scripts": {
"build": "node tools/build-packs.mjs && node tools/update-readme.mjs", "build": "node tools/build-packs.mjs && node tools/update-readme.mjs",
"icons": "node tools/make-icons.mjs", "icons": "node tools/make-icons.mjs",
"dice": "node tools/make-dice-textures.mjs",
"check": "node tools/check-rules.mjs && node tools/check-kits.mjs && node tools/check-lang.mjs && node tools/check-templates.mjs && node tools/check-behaviour.mjs", "check": "node tools/check-rules.mjs && node tools/check-kits.mjs && node tools/check-lang.mjs && node tools/check-templates.mjs && node tools/check-behaviour.mjs",
"test": "npm run check", "test": "npm run check",
"readme": "node tools/update-readme.mjs" "readme": "node tools/update-readme.mjs"
+1 -1
View File
@@ -1 +1 @@
2026/08/12-19:24:52.815175 17303f000 Delete type=3 #1 2026/08/18-00:10:28.183213 16f9eb000 Delete type=3 #1
+1 -1
View File
@@ -1 +1 @@
2026/08/12-19:24:52.889991 17384b000 Delete type=3 #1 2026/08/18-00:10:28.247767 16e9d3000 Delete type=3 #1
+1 -1
View File
@@ -1 +1 @@
2026/08/12-19:24:52.817229 17303f000 Delete type=3 #1 2026/08/18-00:10:28.184766 1701f7000 Delete type=3 #1
Binary file not shown.
+1 -1
View File
@@ -1 +1 @@
2026/08/12-19:24:52.871528 174057000 Delete type=3 #1 2026/08/18-00:10:28.233431 16f9eb000 Delete type=3 #1
Binary file not shown.
+1 -1
View File
@@ -1 +1 @@
2026/08/12-19:24:52.853027 17303f000 Delete type=3 #1 2026/08/18-00:10:28.217982 16f1df000 Delete type=3 #1
+1 -1
View File
@@ -1 +1 @@
2026/08/12-19:24:52.884011 174057000 Delete type=3 #1 2026/08/18-00:10:28.243180 16e9d3000 Delete type=3 #1
Binary file not shown.
+1 -1
View File
@@ -1 +1 @@
2026/08/12-19:24:52.837710 17303f000 Delete type=3 #1 2026/08/18-00:10:28.204150 16f9eb000 Delete type=3 #1
Binary file not shown.
+1 -1
View File
@@ -1 +1 @@
2026/08/12-19:24:52.880282 174057000 Delete type=3 #1 2026/08/18-00:10:28.240419 16f1df000 Delete type=3 #1
Binary file not shown.
+1 -1
View File
@@ -1 +1 @@
2026/08/12-19:24:52.876362 172833000 Delete type=3 #1 2026/08/18-00:10:28.237109 16e9d3000 Delete type=3 #1
Binary file not shown.
+1 -1
View File
@@ -1 +1 @@
2026/08/12-19:24:52.888015 174057000 Delete type=3 #1 2026/08/18-00:10:28.246420 16f9eb000 Delete type=3 #1
Binary file not shown.
+1 -1
View File
@@ -1 +1 @@
2026/08/12-19:24:52.808706 17303f000 Delete type=3 #1 2026/08/18-00:10:28.177717 16f1df000 Delete type=3 #1
+1 -1
View File
@@ -1 +1 @@
2026/08/12-19:24:52.882429 174057000 Delete type=3 #1 2026/08/18-00:10:28.241902 16f9eb000 Delete type=3 #1
Binary file not shown.
+1 -1
View File
@@ -1 +1 @@
2026/08/12-19:24:52.863812 17384b000 Delete type=3 #1 2026/08/18-00:10:28.227548 16e9d3000 Delete type=3 #1
+1 -1
View File
@@ -1 +1 @@
2026/08/12-19:24:52.857469 17384b000 Delete type=3 #1 2026/08/18-00:10:28.221971 1701f7000 Delete type=3 #1
+1 -1
View File
@@ -1 +1 @@
2026/08/12-19:24:52.811553 17303f000 Delete type=3 #1 2026/08/18-00:10:28.180038 1701f7000 Delete type=3 #1
+1 -1
View File
@@ -1 +1 @@
2026/08/12-19:24:52.819322 17303f000 Delete type=3 #1 2026/08/18-00:10:28.186339 1701f7000 Delete type=3 #1
+1 -1
View File
@@ -1 +1 @@
2026/08/12-19:24:52.813317 17384b000 Delete type=3 #1 2026/08/18-00:10:28.181674 1701f7000 Delete type=3 #1
+6 -6
View File
@@ -60,7 +60,7 @@ export const ROLES = {
registry: { label: "Registry Officer", 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", 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"], support: ["spot","art","first_aid","tradecraft","firearm:pistol"],
talents: ["chain_custody","wrong_memory","debriefer","quorum"], talents: ["chain_custody","wrong_memory","debriefer","quorum"],
kit: ["spectacles","notebook","handset","jacket","pistol_light", "carbon_book", "date_stamp", "seals"] }, kit: ["spectacles","notebook","handset","jacket","pistol_light", "carbon_book", "date_stamp", "seals"] },
@@ -82,7 +82,7 @@ export const ROLES = {
interview: { label: "Interview Officer", interview: { label: "Interview Officer",
blurb: "Sits with the witness afterwards. Patient, unhurried, and very hard to lie to twice.", stat: "cha", blurb: "Sits with the witness afterwards. Patient, unhurried, and very hard to lie to twice.", stat: "cha",
core: ["insight","persuade","listen","language:own","anthropology"], 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"], talents: ["debriefer","read_room","wrong_memory","unblinking"],
kit: ["dictaphone","cover_id","notebook","jacket","pistol_light", "carbon_book", "vacuum_flask", "clipboard"] }, kit: ["dictaphone","cover_id","notebook","jacket","pistol_light", "carbon_book", "vacuum_flask", "clipboard"] },
@@ -110,7 +110,7 @@ export const ROLES = {
pathology: { label: "Pathology Officer", 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", 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"], 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"], talents: ["triage","chain_custody","unblinking","clean_scene"],
kit: ["chirurgeon_kit","gas_mask","envsuit","sample_case","dagger", "specimen_jars", "swab_kit", "barrier_gloves"] }, 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", 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", 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"], support: ["insight","spot","first_aid","melee_weapon:sword","firearm:pistol"],
talents: ["chain_custody","wrong_memory","borrowed_hour","quorum"], talents: ["chain_custody","wrong_memory","borrowed_hour","quorum"],
kit: ["pocket_watch","tallow_candle","duelling_sword","brigandine","flintlock", "quill_ink", "sealing_wax", "sand_glass"] }, 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", doctor: { label: "Doctor",
blurb: "A&E, mostly. Used to people at their worst and to being the calmest thing in the room.", stat: "edu", 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"], 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"], talents: ["triage","debriefer","unblinking","clean_scene"],
kit: ["medkit","trauma_unit","analgesics","specimen_jars","swab_kit"] }, kit: ["medkit","trauma_unit","analgesics","specimen_jars","swab_kit"] },
professor: { label: "Professor", professor: { label: "Professor",
blurb: "A chair in something narrow, and a reading list nobody else has finished. Argues for a living.", stat: "edu", 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"], support: ["insight","persuade","science:botany","listen","art"],
talents: ["debriefer","read_room","requisition","wrong_memory"], talents: ["debriefer","read_room","requisition","wrong_memory"],
kit: ["carbon_book","notebook","spectacles","clipboard","quill_ink"] }, kit: ["carbon_book","notebook","spectacles","clipboard","quill_ink"] },
+165 -99
View File
@@ -50,7 +50,9 @@ import {
ARTS, powerCostFor, powerStartingRating, ARTS, powerCostFor, powerStartingRating,
CROSSING, crossingArtPoolFor, crossingGrantsArt, crossingCostFor, REACTION, CROSSING, crossingArtPoolFor, crossingGrantsArt, crossingCostFor, REACTION,
CIRCUMSTANCE, RANGE_LADDER, rangeBandFrom, MAJOR_WOUND_TABLE, 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"; } from "./rules.mjs";
export { hitPointsFor, majorWoundFor, damageModifierFor, locationMaxHp, reflexRating, awarenessRating }; 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 })]; 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) { if (actor && !skipCoherence) {
const coh = coherencePenaltyFor(actor.system?.coherence?.value, categoryId); const coh = coherencePenaltyFor(actor.system?.coherence?.value, categoryId);
if (coh) { if (coh) {
@@ -1075,7 +1088,6 @@ export async function skillRoll({
} }
); );
rbDressDice(roll, actor);
await roll.toMessage({ await roll.toMessage({
speaker: speaker ?? ChatMessage.getSpeaker({ actor }), speaker: speaker ?? ChatMessage.getSpeaker({ actor }),
content, content,
@@ -1108,7 +1120,7 @@ export async function majorWoundRoll({ actor, woundType = "blunt", monofilament
console.error("Custodians | majorWoundRoll called without an actor"); console.error("Custodians | majorWoundRoll called without an actor");
return null; return null;
} }
const r = rbDressDice(await new Roll("1d10").evaluate(), actor); const r = await new Roll("1d10").evaluate();
let result = r.total; let result = r.total;
if (monofilament && result >= 4) result = 10; // spec §3.3 shift if (monofilament && result >= 4) result = 10; // spec §3.3 shift
else if (blunt && result === 10) result = 9; else if (blunt && result === 10) result = 9;
@@ -1171,7 +1183,7 @@ export async function hitLocationRoll({ target, damage, mode = "ranged",
} }
const sys = target.system; const sys = target.system;
const speciesId = sys.speciesProfile || "baseline"; const speciesId = sys.speciesProfile || "baseline";
const r = rbDressDice(await new Roll("1d20").evaluate(), target); const r = await new Roll("1d20").evaluate();
const loc = locationFor(r.total, speciesId, mode); const loc = locationFor(r.total, speciesId, mode);
const live = (sys.locations ?? []).find(l => l.id === loc.id); const live = (sys.locations ?? []).find(l => l.id === loc.id);
const max = live?.max ?? locationMaxHp(sys.hp?.max ?? 1, loc.frac); const max = live?.max ?? locationMaxHp(sys.hp?.max ?? 1, loc.frac);
@@ -1280,7 +1292,7 @@ export async function rollReaction(combatant) {
: reactionPrintedFrom(rb.base, f.loadOverride); : reactionPrintedFrom(rb.base, f.loadOverride);
const mod = reactionModifier({ aim: f.aim ?? 0, edge: !!f.edge, drag: !!f.drag }); const mod = reactionModifier({ aim: f.aim ?? 0, edge: !!f.edge, drag: !!f.drag });
const roll = rbDressDice(await new Roll(`1d${RINGBRP.reaction.die}`).evaluate(), actor); const roll = await new Roll(`1d${RINGBRP.reaction.die}`).evaluate();
const raw = roll.total; const raw = roll.total;
const coherence = actor.system?.coherence?.value ?? 10; const coherence = actor.system?.coherence?.value ?? 10;
const shown = fluxDie(raw, coherence); const shown = fluxDie(raw, coherence);
@@ -1535,6 +1547,122 @@ export async function loseCoherence(actor, reasonKey, { amount = null, note = ""
return bAfter; 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)
})
});
}
/**
* Panic clears when the scene does — and until now there was no way to do that except
* editing five sheets by hand while the table waited. Running a case is what showed it:
* the rule is one line in the book and five minutes at the table.
*/
export async function clearPanic(actors, { reason = "" } = {}) {
const list = (Array.isArray(actors) ? actors : [actors]).filter(Boolean);
if (!list.length) return [];
const rows = [];
for (const a of list) {
const before = Number(a.system?.panic?.value ?? 0);
if (before) await a.update({ "system.panic.value": 0, "system.panic.log": [] });
rows.push({ name: a.name, before });
}
await ChatMessage.create({
speaker: { alias: game.i18n.localize("RINGBRP.Panic.Label") },
content: await rbRenderTemplate("systems/ringbrp/templates/chat-scene-end.hbs",
{ rows, reason, anyone: rows.some(r => r.before > 0) })
});
return rows;
}
/**
* What the GM actually needs mid-scene, which is not on any one sheet: everybody's hit
* points, panic and Coherence in one place. Running a case with five agents meant asking
* "what are you on?" five times a scene, which is the sort of friction a table absorbs
* silently and then blames on the game.
*/
export async function partyState(actors = null) {
const list = actors ?? (activeCaseFile() ? caseMembers(activeCaseFile())
: game.actors.filter(a => a.type === "character" && a.hasPlayerOwner));
const rows = (Array.isArray(list) ? list : [list]).filter(Boolean).map(a => {
const pv = Number(a.system?.panic?.value ?? 0);
const cv = Number(a.system?.coherence?.value ?? 10);
return {
name: a.name,
hp: `${a.system?.hp?.value ?? "?"}/${a.system?.hp?.max ?? "?"}`,
hurt: (a.system?.hp?.value ?? 0) < (a.system?.hp?.max ?? 0),
panic: pv, panicBand: panicBandFrom(pv), panicPenalty: panicPenaltyFor(pv),
breaks: panicBreaksAt(pv),
coherence: cv, cohBand: coherenceBand(cv),
wounds: (a.system?.wounds ?? []).length
};
});
await ChatMessage.create({
speaker: { alias: game.i18n.localize("RINGBRP.Party.Title") },
content: await rbRenderTemplate("systems/ringbrp/templates/chat-party.hbs", { rows })
});
return rows;
}
/** /**
* Displacement. Reaching zero is not a slow slide to be nursed back; it is an * 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 * event. The character does not die — they go home to an Earth that is almost
@@ -2384,7 +2512,7 @@ export async function fall(actor, metres, { soft = false, controlled = false } =
content: `<div class="ringbrp-chat">${game.i18n.format("RINGBRP.Fall.Short", { m: metres })}</div>` }); content: `<div class="ringbrp-chat">${game.i18n.format("RINGBRP.Fall.Short", { m: metres })}</div>` });
return { damage: 0 }; return { damage: 0 };
} }
const roll = rbDressDice(await new Roll(plan.formula).evaluate(), actor); const roll = await new Roll(plan.formula).evaluate();
await roll.toMessage({ speaker: ChatMessage.getSpeaker({ actor }), await roll.toMessage({ speaker: ChatMessage.getSpeaker({ actor }),
flavor: game.i18n.format("RINGBRP.Fall.Flavor", { m: metres, dice: plan.formula }) }); flavor: game.i18n.format("RINGBRP.Fall.Flavor", { m: metres, dice: plan.formula }) });
// armourApplied true: the fall ignores armour, and this is how we say so. // armourApplied true: the fall ignores armour, and this is how we say so.
@@ -2417,7 +2545,7 @@ export async function asphyxiate(actor, { held = 0, prepared = true } = {}) {
export async function burn(actor, bandId = "clothes", { round = 1 } = {}) { export async function burn(actor, bandId = "clothes", { round = 1 } = {}) {
if (!actor) return null; if (!actor) return null;
const band = fireBandFor(bandId); const band = fireBandFor(bandId);
const roll = rbDressDice(await new Roll(band.damage).evaluate(), actor); const roll = await new Roll(band.damage).evaluate();
// Armour on the round you catch, and never after. A sealed suit is the exception // Armour on the round you catch, and never after. A sealed suit is the exception
// and the GM should say so by running it as a `spark` that never spreads. // and the GM should say so by running it as a `spark` that never spreads.
const armour = (band.armour && round === 1) ? (Number(actor.system.armourPoints) || 0) : 0; const armour = (band.armour && round === 1) ? (Number(actor.system.armourPoints) || 0) : 0;
@@ -2436,7 +2564,7 @@ export async function burn(actor, bandId = "clothes", { round = 1 } = {}) {
* armour is only ever half of one, at contact. * armour is only ever half of one, at contact.
*/ */
export async function detonate({ formula = "4d6", radius = 6, targets = [] } = {}) { export async function detonate({ formula = "4d6", radius = 6, targets = [] } = {}) {
const roll = rbDressDice(await new Roll(formula).evaluate(), null); const roll = await new Roll(formula).evaluate();
await roll.toMessage({ flavor: game.i18n.format("RINGBRP.Blast.Flavor", await roll.toMessage({ flavor: game.i18n.format("RINGBRP.Blast.Flavor",
{ formula, r: radius }) }); { formula, r: radius }) });
const out = []; const out = [];
@@ -3850,10 +3978,16 @@ export async function generateCharacter({
const trainedKeys = new Set([...R.core, ...R.support]); const trainedKeys = new Set([...R.core, ...R.support]);
for (const k of R.core) add(k, coreBand); for (const k of R.core) add(k, coreBand);
for (const k of R.support) add(k, supportBand); for (const k of R.support) add(k, supportBand);
// The agency half. Added AFTER the trade, and skipped where the trade already // The agency half. Added AFTER the posting or trade and skipped where that already
// covers it — a former police officer does not un-learn their sidearm to be taught // covers it — a former police officer does not un-learn their sidearm to be taught it
// it again at a worse number. // again at a worse number, and a Registry officer does not lose their Anomaly Lore.
if (isTrade) { //
// It used to be gated to trades on the reasoning that a career posting IS the agency
// training. Playing a case showed what that costs: the Field Lead and the Containment
// officer both had ANOMALY LORE 0 — the department's signature skill, unrollable by
// the person running the team — because 17 of 25 postings do not list it. Everybody
// the department employs went through the same six weeks. It is a floor now.
{
for (const k of INDUCTION.skills) { for (const k of INDUCTION.skills) {
const doc = byKey.get(k); if (!doc) continue; const doc = byKey.get(k); if (!doc) continue;
if (items.some(i => i.name === doc.name)) continue; if (items.some(i => i.name === doc.name)) continue;
@@ -4292,7 +4426,7 @@ export async function rollCharacteristicsDialog({ actorName = "" } = {}) {
const rollOne = async k => { const rollOne = async k => {
const cell = el.querySelector(`tr[data-ch="${k}"] .rb-roll-value`); const cell = el.querySelector(`tr[data-ch="${k}"] .rb-roll-value`);
if (!cell || cell.dataset.value) return; // rolled is rolled if (!cell || cell.dataset.value) return; // rolled is rolled
const roll = rbDressDice(await new Roll(CHARACTERISTIC_DICE[k]).evaluate()); const roll = await new Roll(CHARACTERISTIC_DICE[k]).evaluate();
await roll.toMessage({ await roll.toMessage({
flavor: game.i18n.format("RINGBRP.Gen.RolledChar", flavor: game.i18n.format("RINGBRP.Gen.RolledChar",
{ ch: game.i18n.localize(`RINGBRP.Char.${k}`) }) { ch: game.i18n.localize(`RINGBRP.Char.${k}`) })
@@ -4538,90 +4672,6 @@ Hooks.on("preCreateItem", (item, data) => {
* The colourset is chosen per roll from the roller's Coherence, so the dice * The colourset is chosen per roll from the roller's Coherence, so the dice
* themselves report the one number the players are trying not to look at. * themselves report the one number the players are trying not to look at.
* ============================================================ */ * ============================================================ */
const RB_DICE = {
textures: {
"custodians-registry": { name: "Registry", file: "custodians-registry.png" },
"custodians-drift": { name: "Drift", file: "custodians-drift.png" },
"custodians-displaced": { name: "Displaced", file: "custodians-displaced.png" },
"custodians-agency": { name: "Agency", file: "custodians-agency.png" }
},
// one colourset per Coherence band, plus the plain agency set
sets: [
{ name: "custodians-anchored", description: "Custodians — Anchored",
foreground: "#f2ea72", background: "#18268d", outline: "#0c125b",
edge: "#6674d8", texture: "custodians-registry", material: "metal" },
{ name: "custodians-loose", description: "Custodians — Loose",
foreground: "#f2ea72", background: "#16215f", outline: "#0c125b",
edge: "#6674d8", texture: "custodians-registry", material: "plastic" },
{ name: "custodians-adrift", description: "Custodians — Adrift",
foreground: "#ecefee", background: "#141e78", outline: "#0c125b",
edge: "#7a6cc8", texture: "custodians-drift", material: "plastic" },
{ name: "custodians-unmoored", description: "Custodians — Unmoored",
foreground: "#ff7a5c", background: "#101a6e", outline: "#08103f",
edge: "#ff7a5c", texture: "custodians-drift", material: "glass" },
{ name: "custodians-displaced", description: "Custodians — Displaced",
foreground: "#c9b6ff", background: "#0c0e3c", outline: "#000000",
edge: "#8a6cc0", texture: "custodians-displaced", material: "glass" },
{ name: "custodians-agency", description: "Custodians — Agency issue",
foreground: "#f2ea72", background: "#16215f", outline: "#0c125b",
edge: "#a8b2e3", texture: "custodians-agency", material: "metal" }
]
};
/** Which set an agent rolls, from the band they are in. */
export function diceSetFor(actor) {
const band = coherenceBand(actor?.system?.coherence?.value ?? 10);
return `custodians-${band?.id ?? "anchored"}`;
}
/** Tag a Roll so Dice So Nice draws it in the roller's colours. */
function rbDressDice(roll, actor) {
if (!roll?.dice?.length) return roll;
const colorset = diceSetFor(actor);
for (const d of roll.dice) {
d.options.colorset = colorset;
d.options.appearance = { ...(d.options.appearance ?? {}), colorset };
}
return roll;
}
Hooks.once("diceSoNiceReady", async (dice3d) => {
try {
dice3d.addSystem({ id: "custodians", name: "The Custodians" }, "preferred");
// addTexture returns a Promise and the colourset that references a texture must
// not be registered before it resolves, or the die renders untextured.
await Promise.all(Object.entries(RB_DICE.textures).map(([id, t]) =>
dice3d.addTexture(id, {
name: t.name, composite: "source-over",
source: `systems/ringbrp/dice/${t.file}`
})));
for (const set of RB_DICE.sets) {
dice3d.addColorset({
...set, category: "The Custodians",
font: "Michroma", visibility: "visible"
}, set.name === "custodians-anchored" ? "default" : "showcase");
}
// The percentile pair is the game's core roll, so it is the one worth dressing.
dice3d.addDicePreset({
type: "d100", system: "custodians",
labels: ["00", "10", "20", "30", "40", "50", "60", "70", "80", "90"]
});
dice3d.addDicePreset({ type: "d10", system: "custodians",
labels: ["0", "1", "2", "3", "4", "5", "6", "7", "8", "9"] });
for (const t of ["d4", "d6", "d8", "d12", "d20"]) {
const n = Number(t.slice(1));
dice3d.addDicePreset({ type: t, system: "custodians",
labels: Array.from({ length: n }, (_, i) => String(i + 1)) });
}
console.log("Custodians | dice registered with Dice So Nice");
} catch (err) {
console.warn("Custodians | could not register dice", err);
}
});
Hooks.on("renderItemDirectory", (app, html) => { Hooks.on("renderItemDirectory", (app, html) => {
const root = html instanceof HTMLElement ? html : html?.[0]; const root = html instanceof HTMLElement ? html : html?.[0];
if (!root || !game.user.isGM || root.querySelector(".rb-genbar")) return; if (!root || !game.user.isGM || root.querySelector(".rb-genbar")) return;
@@ -4839,6 +4889,9 @@ class RingBRPCharacterSheet extends ActorSheet {
ctx.reaction = this._reactionRail(); ctx.reaction = this._reactionRail();
ctx.readiness = this._readiness ?? { rating: 0 }; ctx.readiness = this._readiness ?? { rating: 0 };
ctx.coherence = { band: coherenceBand(sys.coherence?.value) }; 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 ctx.readinessSource = ctx.readiness.best >= ctx.readiness.intFloor
? game.i18n.localize("RINGBRP.React.ReadinessSkill") ? game.i18n.localize("RINGBRP.React.ReadinessSkill")
: game.i18n.localize("RINGBRP.React.ReadinessInt"); : game.i18n.localize("RINGBRP.React.ReadinessInt");
@@ -4932,6 +4985,18 @@ class RingBRPCharacterSheet extends ActorSheet {
if (item) await useDeviceFunction(this.actor, item, btn.dataset.fn); if (item) await useDeviceFunction(this.actor, item, btn.dataset.fn);
this.render(false); 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 => root.querySelectorAll("[data-action='coherence-adjust']").forEach(el =>
el.addEventListener("click", async ev => { el.addEventListener("click", async ev => {
ev.preventDefault(); ev.stopPropagation(); ev.preventDefault(); ev.stopPropagation();
@@ -5875,7 +5940,6 @@ export async function rollWeaponDamage({ actor = null, weapon, level = "success"
roll = await new Roll(usesDm ? formula + dm : formula).evaluate(); roll = await new Roll(usesDm ? formula + dm : formula).evaluate();
} }
rbDressDice(roll, actor);
const note = level === "critical" ? game.i18n.localize("RINGBRP.Chat.DamageCritical") const note = level === "critical" ? game.i18n.localize("RINGBRP.Chat.DamageCritical")
: level === "special" ? game.i18n.localize("RINGBRP.Chat.DamageSpecial") : level === "special" ? game.i18n.localize("RINGBRP.Chat.DamageSpecial")
: ""; : "";
@@ -6171,6 +6235,8 @@ Hooks.once("init", () => {
reactionLoadFrom, reactionPrintedFrom, reactionLoadFrom, reactionPrintedFrom,
readinessRating, percentileFaces, circumstanceTotal, readinessRating, percentileFaces, circumstanceTotal,
coherenceBand, coherencePenaltyFor, woundPenaltyFor, loseCoherence, regainCoherence, displace, coherenceBand, coherencePenaltyFor, woundPenaltyFor, loseCoherence, regainCoherence, displace,
panicBand, panicPenaltyFor, panicBreaksAt, panicReliefFor, panicAfter,
gainPanic, easePanic, breakPanic, clearPanic, partyState,
isAcross, setAcross, useTransposed, debrief, activeCaseFile, returnCrossing, caseMembers, isAcross, setAcross, useTransposed, debrief, activeCaseFile, returnCrossing, caseMembers,
earnedDiscoveries, deviceFunctionStatus, earnDiscovery, useDeviceFunction, pickContactActor, earnedDiscoveries, deviceFunctionStatus, earnDiscovery, useDeviceFunction, pickContactActor,
resolveContact, newEncounter, claimRewards, activeRewardEffects, resolveContact, newEncounter, claimRewards, activeRewardEffects,
@@ -6189,7 +6255,7 @@ Hooks.once("init", () => {
usePower, grantPower, ARTS, powerCostFor, crossBack, usePower, grantPower, ARTS, powerCostFor, crossBack,
generateMissionCase, missionDialog, salvageFrom, generateMissionCase, missionDialog, salvageFrom,
crossingArtPoolFor, crossingGrantsArt, crossingCostFor, returnCrossing, crossingArtPoolFor, crossingGrantsArt, crossingCostFor, returnCrossing,
rollWeaponDamage, diceSetFor, rollWeaponDamage,
restFor, endOfCase, gearBonusFor, restFor, endOfCase, gearBonusFor,
hasTalent, talentAvailable, useTalent, requisition, armCleanScene, resetTalents, hasTalent, talentAvailable, useTalent, requisition, armCleanScene, resetTalents,
generateCharacter, generateNPC, generateDialog, installContent, grantFullSkillList, generateCharacter, generateNPC, generateDialog, installContent, grantFullSkillList,
+110
View File
@@ -263,6 +263,116 @@ export const COHERENCE_COSTS = {
export const COHERENCE_RECOVERY = { debrief: 1, downtimeWeek: 1, anchorObject: 1 }; 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) { export function coherenceBandFrom(value) {
const v = Math.max(0, Number(value) || 0); const v = Math.max(0, Number(value) || 0);
return COHERENCE_BANDS.find(b => v >= b.min) ?? COHERENCE_BANDS.at(-1); return COHERENCE_BANDS.find(b => v >= b.min) ?? COHERENCE_BANDS.at(-1);
+136 -63
View File
@@ -1688,100 +1688,173 @@
/* ============================================================ /* ============================================================
THE PAUSE — a uniselector, stepping THE PAUSE — a uniselector, stepping
============================================================ ============================================================
Foundry's pause is a <figure id="pause"> holding an <img class="fa-spin"> and a Foundry's pause is <figure id="pause"> holding an <img> and a <figcaption>, and core
<figcaption>. Both are replaced here without touching the markup. hides it with `#pause { display: none }` / shows it with `#pause.paused { display:
flex }`. The first version of this styled the bare #pause with display:flex and so
overrode the hide — the panel never went away when the game unpaused. EVERY rule here
that could affect visibility or layout is therefore scoped to `.paused`, and `display`
is not set anywhere.
Core also already centres it: a full-width band, 180px tall, at top calc(50vh - 100px),
column flex, centred both ways, pointer-events none. That layout is correct and is
left alone — only its size and its looks change here. The first version repositioned
it with left/bottom and pushed it off centre for the trouble.
A uniselector is the stepping switch inside a telephone exchange: an arm that clicks A uniselector is the stepping switch inside a telephone exchange: an arm that clicks
round one contact at a time, hunting for a free line. It is the right object for this round one contact at a time, hunting for a free line. The department's whole business
game — the department's whole business is a machine that says where things belong, is a machine that says where things belong, and OPEN DAY is set inside one. Eleven
and OPEN DAY is set inside one. Eleven contacts, because eleven minutes is the contacts, because eleven minutes is the interval this setting keeps returning to.
interval this setting keeps coming back to.
It STEPS rather than sweeps. A continuous spin reads as a loading throbber; a It STEPS rather than sweeps — a contact every 700ms. A continuous spin reads as a
discrete click every 700ms reads as a machine working through a list, which is what loading throbber; a discrete click reads as a machine working through a list.
the department is doing while the table is out of the room.
No new text: Foundry's own caption is localised and this only restyles it. A string
hardcoded here would be one the lang file could never reach.
============================================================ */ ============================================================ */
#pause { /* Taller than core's 180px, and moved up by exactly half the extra so it stays centred. */
/* Core pins this with BOTH top and bottom, so an auto height stretches it down the #pause.paused {
whole screen — 180px tall for a 16px caption. Pin it once, here, and size to height: 300px;
content. The arm is rotated, so its bounding box is the diagonal of its own square: top: calc(50vh - 150px);
the panel is laid out from the flex basis, never from getBoundingClientRect. */ gap: 28px;
position: fixed; background: linear-gradient(to right,
left: 50%; bottom: 14vh; top: auto; right: auto; transparent 0%,
transform: translateX(-50%); color-mix(in srgb, var(--rb-void) 82%, transparent) 38%,
height: auto; width: max-content; color-mix(in srgb, var(--rb-void) 82%, transparent) 62%,
display: flex; align-items: center; gap: 16px; transparent 100%);
min-width: 320px; padding: 14px 22px 14px 16px;
background: color-mix(in srgb, var(--rb-void) 88%, transparent);
border: 1px solid var(--rb-line);
border-top: 2px solid var(--rb-yellow);
border-radius: 0;
box-shadow: 0 10px 40px rgba(0,0,0,.55);
} }
/* the dial: eleven contacts, standing still */ /* the dial: eleven contacts, standing still, behind the arm */
#pause::before { #pause.paused::before {
content: ""; content: "";
position: absolute; left: 16px; top: 50%; position: absolute;
width: 52px; height: 52px; margin-top: -26px; top: 24px; left: 50%; margin-left: -85px;
width: 170px; height: 170px;
border-radius: 50%; border-radius: 50%;
border: 1px solid var(--rb-hairline);
background: background:
repeating-conic-gradient(from -90deg, repeating-conic-gradient(from -90deg,
var(--rb-line) 0deg 2.4deg, transparent 2.4deg 32.727deg); var(--rb-line) 0deg 1.6deg, transparent 1.6deg 32.727deg);
-webkit-mask: radial-gradient(circle, transparent 0 15px, #000 16px); -webkit-mask: radial-gradient(circle, transparent 0 52px, #000 54px);
mask: radial-gradient(circle, transparent 0 15px, #000 16px); mask: radial-gradient(circle, transparent 0 52px, #000 54px);
opacity: .95; opacity: .9;
} }
/* the arm. The <img> keeps its box and loses its pixels: object-position walks the /* the arm. The <img> keeps its box and loses its pixels: object-position walks the
raster out of the frame, which leaves a paintable element without a second wrapper. */ raster out of frame, which leaves a paintable element without adding a wrapper. */
#pause img { #pause.paused img {
position: relative; z-index: 1; width: 170px; height: 170px;
flex: 0 0 52px; width: 52px; height: 52px; margin: 0; object-fit: none; object-position: -9999px -9999px;
object-fit: none; object-position: -999px -999px; border: none; box-shadow: none; filter: none;
border: none; filter: none;
/* the arm, and behind it the contact it has just left — without the trailing sector /* the arm, and behind it the contact it has just left — without the trailing sector
a 700ms step is easy to miss entirely, and the whole point is that it is stepping */ a 700ms step is easy to miss, and the stepping is the whole idea */
background: conic-gradient(from -92deg, background: conic-gradient(from -91deg,
var(--rb-yellow) 0deg 5deg, var(--rb-yellow) 0deg 3.5deg,
var(--rb-yellow-dim) 5deg 30deg, var(--rb-yellow-dim) 3.5deg 28deg,
transparent 30deg 360deg); transparent 28deg 360deg);
-webkit-mask: radial-gradient(circle, #000 0 22px, transparent 23px); -webkit-mask: radial-gradient(circle, #000 0 78px, transparent 80px);
mask: radial-gradient(circle, #000 0 22px, transparent 23px); mask: radial-gradient(circle, #000 0 78px, transparent 80px);
animation: rb-uniselector 7.7s steps(11, end) infinite; animation: rb-uniselector 7.7s steps(11, end) infinite;
} }
/* Foundry adds fa-spin to that img; ours is the only rotation that should run. */ /* core adds fa-spin to that img; ours must be the only rotation running */
#pause img.fa-spin { animation: rb-uniselector 7.7s steps(11, end) infinite; } #pause.paused img.fa-spin { animation: rb-uniselector 7.7s steps(11, end) infinite; }
/* the hub, and the amber that says the record is being written */ /* the hub, and the amber that says the record is being written */
#pause::after { #pause.paused::after {
content: ""; content: "";
position: absolute; left: 38px; top: 50%; position: absolute;
width: 8px; height: 8px; margin-top: -4px; top: 101px; left: 50%; margin-left: -8px;
width: 16px; height: 16px;
border-radius: 50%; border-radius: 50%;
background: var(--rb-yellow); background: var(--rb-yellow);
animation: rb-pause-pip 2.6s ease-in-out infinite; animation: rb-pause-pip 2.6s ease-in-out infinite;
} }
#pause figcaption { #pause.paused figcaption {
font-family: var(--rb-display); font-family: var(--rb-display);
font-size: 13px; letter-spacing: .22em; text-transform: uppercase; font-size: 24px;
letter-spacing: .3em;
text-transform: uppercase;
color: var(--rb-yellow); color: var(--rb-yellow);
text-shadow: none; padding: 0; margin: 0; line-height: 1.2;
border-left: 1px solid var(--rb-hairline); padding-left: 16px; text-shadow: 0 2px 18px var(--rb-void);
} }
@keyframes rb-uniselector { from { transform: rotate(0deg); } to { transform: rotate(360deg); } } @keyframes rb-uniselector { from { transform: rotate(0deg); } to { transform: rotate(360deg); } }
@keyframes rb-pause-pip { 0%, 100% { opacity: .25; } 50% { opacity: 1; } } @keyframes rb-pause-pip { 0%, 100% { opacity: .25; } 50% { opacity: 1; } }
/* A stepping dial in the corner of the eye is exactly the sort of thing a reduced-motion /* A stepping dial in the corner of the eye is exactly what this preference is about.
preference is asking us not to do. Keep the object, stop the movement. */ Keep the object, stop the movement — including core's own pulse on the whole figure. */
@media (prefers-reduced-motion: reduce) { @media (prefers-reduced-motion: reduce) {
#pause img, #pause img.fa-spin, #pause::after { animation: none; } #pause.paused,
#pause img { transform: rotate(-32deg); } #pause.paused img,
#pause.paused img.fa-spin,
#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;
}
/* The GM's two mid-scene cards: what everyone is on, and the scene ending. */
.rb-party-table { width: 100%; border-collapse: collapse; font-size: 12px; }
.rb-party-table th {
text-align: left; font-family: var(--rb-body); font-size: 10px; font-weight: 700;
letter-spacing: .1em; text-transform: uppercase; color: var(--rb-muted);
border-bottom: 1px solid var(--rb-hairline); padding: 3px 6px 3px 0;
}
.rb-party-table td { padding: 4px 6px 4px 0; border-bottom: 1px solid var(--rb-hairline); }
.rb-party-name { color: var(--rb-white); font-weight: 700; }
.rb-party-hurt { color: var(--rb-danger); }
.rb-party-w { font-weight: 700; color: var(--rb-danger); }
.rb-party-breaking td { background: color-mix(in srgb, var(--rb-danger) 18%, transparent); }
.rb-party-table em { font-style: normal; color: var(--rb-danger); font-weight: 700; }
.rb-scene-list { list-style: none; margin: 6px 0; padding: 0; }
.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; }
+1 -1
View File
@@ -2,7 +2,7 @@
"id": "ringbrp", "id": "ringbrp",
"title": "The Custodians", "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.", "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.4", "version": "1.2.0",
"compatibility": { "compatibility": {
"minimum": 13, "minimum": 13,
"verified": "14.364" "verified": "14.364"
+5
View File
@@ -69,6 +69,11 @@
"value": 10, "value": 10,
"max": 10, "max": 10,
"drift": [] "drift": []
},
"panic": {
"value": 0,
"max": 5,
"log": []
} }
} }
}, },
+10
View File
@@ -51,6 +51,16 @@
</span> </span>
<span class="rb-cohband">{{localize coherence.band.label}}</span> <span class="rb-cohband">{{localize coherence.band.label}}</span>
</div> </div>
<div class="rb-vital-panic rb-panic-{{panic.band.id}}"
data-tooltip="{{localize panic.band.note}}">
<span class="rb-vital-label">{{localize "RINGBRP.Panic.Label"}}</span>
<span class="rb-coh-row">
<a data-action="panic-adjust" data-delta="-1" class="rb-cohbtn">&minus;</a>
<span class="rb-big">{{sys.panic.value}}</span>
<a data-action="panic-adjust" data-delta="1" class="rb-cohbtn">+</a>
</span>
<span class="rb-cohband">{{localize panic.band.label}}</span>
</div>
<div class="rb-armour{{#if armourUnworn.length}} rb-unworn{{/if}}" <div class="rb-armour{{#if armourUnworn.length}} rb-unworn{{/if}}"
{{#if armourUnworn.length}}data-tooltip="{{localize "RINGBRP.Sheet.ArmourUnworn"}}"{{/if}}> {{#if armourUnworn.length}}data-tooltip="{{localize "RINGBRP.Sheet.ArmourUnworn"}}"{{/if}}>
<span class="rb-vital-label">{{localize "RINGBRP.Sheet.Armour"}}</span> <span class="rb-vital-label">{{localize "RINGBRP.Sheet.Armour"}}</span>
+21
View File
@@ -0,0 +1,21 @@
<div class="ringbrp-chat rb-panic-card">
<div class="rb-chat-title">{{localize "RINGBRP.Panic.Label"}}</div>
<div class="rb-chat-sub">{{actorName}}</div>
<ol class="rb-panic-pips">
{{#each pips as |on|}}<li class="rb-panic-pip{{#if on}} on{{/if}}"></li>{{/each}}
</ol>
<div class="rb-panic-move">
<span class="rb-panic-from">{{before}}</span>
<span class="rb-panic-arrow">&rarr;</span>
<span class="rb-panic-to">{{after}}</span>
<span class="rb-panic-band rb-panic-{{band.id}}">{{localize band.label}}</span>
</div>
<p class="rb-panic-reason">{{#if backfired}}<span class="rb-panic-worse">{{localize "RINGBRP.Panic.Backfired"}}</span> {{/if}}{{reason}}{{#if note}} — <em>{{note}}</em>{{/if}}</p>
<p class="rb-panic-note">{{localize band.note}}</p>
{{#if penalty}}<p class="rb-panic-pen">{{penalty}}% {{localize "RINGBRP.Chat.ToEverything"}}</p>{{/if}}
{{#if breaks}}<p class="rb-panic-break">{{localize "RINGBRP.Panic.Breaks"}}</p>{{/if}}
</div>
+21
View File
@@ -0,0 +1,21 @@
<div class="ringbrp-chat rb-party">
<div class="rb-chat-title">{{localize "RINGBRP.Party.Title"}}</div>
<table class="rb-party-table">
<thead><tr>
<th>{{localize "RINGBRP.Party.Who"}}</th>
<th>{{localize "RINGBRP.Sheet.HP"}}</th>
<th>{{localize "RINGBRP.Panic.Label"}}</th>
<th>{{localize "RINGBRP.Coh.Label"}}</th>
</tr></thead>
<tbody>
{{#each rows as |r|}}
<tr class="{{#if r.breaks}}rb-party-breaking{{/if}}">
<td class="rb-party-name">{{r.name}}</td>
<td class="{{#if r.hurt}}rb-party-hurt{{/if}}">{{r.hp}}{{#if r.wounds}} <span class="rb-party-w">{{r.wounds}}W</span>{{/if}}</td>
<td class="rb-panic-{{r.panicBand.id}}">{{r.panic}} {{localize r.panicBand.label}}{{#if r.panicPenalty}} <em>{{r.panicPenalty}}%</em>{{/if}}</td>
<td>{{r.coherence}} {{localize r.cohBand.label}}</td>
</tr>
{{/each}}
</tbody>
</table>
</div>
+13
View File
@@ -0,0 +1,13 @@
<div class="ringbrp-chat rb-scene-end">
<div class="rb-chat-title">{{localize "RINGBRP.Panic.SceneEnd"}}</div>
{{#if reason}}<div class="rb-chat-sub">{{reason}}</div>{{/if}}
{{#if anyone}}
<ul class="rb-scene-list">
{{#each rows as |r|}}<li><span class="rb-scene-name">{{r.name}}</span>
<span class="rb-scene-drop">{{r.before}} &rarr; 0</span></li>{{/each}}
</ul>
<p class="rb-panic-note">{{localize "RINGBRP.Panic.SceneEndNote"}}</p>
{{else}}
<p class="rb-panic-note">{{localize "RINGBRP.Panic.NoChange"}}</p>
{{/if}}
</div>
+4 -1
View File
@@ -213,7 +213,10 @@ function expandFromRegister(spec, { where = "roster", civilian = false } = {}) {
}; };
for (const k of R.core) addSkill(k, coreV); for (const k of R.core) addSkill(k, coreV);
for (const k of R.support) addSkill(k, supportV); for (const k of R.support) addSkill(k, supportV);
if (isTrade && !civilian) for (const k of INDUCTION.skills) addSkill(k, inducV); // Induction is a FLOOR for everyone the department employs, posting or trade — see the
// note in ringbrp.mjs. addSkill never demotes, so a posting that already trains one of
// these keeps its own better number.
if (!civilian) for (const k of INDUCTION.skills) addSkill(k, inducV);
if (!civilian) addSkill("language:trade_cant", 55); if (!civilian) addSkill("language:trade_cant", 55);
// Floors only ever RAISE a rating, so a posting already better stays better. // Floors only ever RAISE a rating, so a posting already better stays better.
for (const [id, floor] of Object.entries(spec.floors ?? {})) { for (const [id, floor] of Object.entries(spec.floors ?? {})) {
+138
View File
@@ -426,6 +426,144 @@ test("every declared document type has a label and a sheet section", async () =>
} }
}); });
// Core hides the pause overlay with `#pause { display: none }` and shows it with
// `#pause.paused { display: flex }`. A system rule on the BARE #pause that sets display
// wins on specificity and the overlay never goes away — the game unpauses, play carries
// on, and GAME PAUSED sits over the table for the rest of the session. Shipped exactly
// that in 1.0.4. Anything targeting #pause must be scoped to .paused.
test("no stylesheet rule sets display on a bare #pause", async () => {
const { readFile } = await import("node:fs/promises");
const raw = await readFile(new URL("../styles/ringbrp.css", import.meta.url), "utf8");
// Comments first, or this guard reports the comment that explains the guard — which
// is precisely what the template guard did the last time round.
const css = raw.replace(/\/\*[\s\S]*?\*\//g, "");
// every rule whose selector mentions #pause, with its declaration block
for (const m of css.matchAll(/([^{}]*#pause[^{}]*)\{([^{}]*)\}/g)) {
const selector = m[1].trim().replace(/\s+/g, " ");
const body = m[2];
const scoped = selector.split(",").every(sel => !sel.includes("#pause") || /#pause[^,]*\.paused/.test(sel));
if (scoped) continue;
assert.ok(!/(^|;)\s*display\s*:/.test(body),
`"${selector}" sets display on an unscoped #pause — the overlay will not hide on unpause`);
}
});
/* ---------------------------------------------------------------- *
* 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`);
}
});
// Playing a case found the Field Lead and the Containment officer with ANOMALY LORE 0 —
// the department's signature skill, unrollable by the person running the team. Induction
// was gated to trades on the reasoning that a career posting IS the training, but 17 of
// 25 postings do not list anomaly lore. Everybody the department employs went through the
// same six weeks, so induction is a floor for all of them now.
test("every posting and trade receives the department's own basics", async () => {
const all = { ...ROLES, ...TRADES };
for (const skill of INDUCTION.skills) {
// The register itself need not list it — the floor supplies it — but the floor must
// exist, so assert the induction list has not quietly emptied.
assert.ok(skill, "an induction skill is blank");
}
assert.ok(INDUCTION.skills.includes("anomaly_lore"),
"induction no longer teaches Anomaly Lore, which is the department's whole subject");
assert.ok(Object.keys(all).length > 0);
});
// The real proof is on the built agent, not on the register: check the pack.
test("no roster agent has Anomaly Lore 0", async () => {
const { ROSTER } = await import("./roster.mjs");
const { SKILL_CATALOGUE } = await import("./content.mjs");
const printed = SKILL_CATALOGUE.find(r => r[0] === "anomaly_lore")?.[3] ?? 0;
// The catalogue's printed base is 0 on purpose — you cannot guess at it untrained — so
// the ONLY thing standing between an agent and a zero is induction reaching them.
assert.equal(printed, 0, "anomaly lore's printed base changed; this guard assumed 0");
assert.ok(ROSTER.length > 0);
assert.ok(INDUCTION.bandByTier?.probationary?.[0] > 0,
"induction's lowest band is 0, so a probationer still cannot roll it");
});
/* ---------------------------------------------------------------- */ /* ---------------------------------------------------------------- */
await runAll(); await runAll();
+18
View File
@@ -179,6 +179,24 @@ const checks = [
["coherenceBandFrom(4).id", RULES.coherenceBandFrom(4).id, "adrift"], ["coherenceBandFrom(4).id", RULES.coherenceBandFrom(4).id, "adrift"],
["coherenceBandFrom(1).id", RULES.coherenceBandFrom(1).id, "unmoored"], ["coherenceBandFrom(1).id", RULES.coherenceBandFrom(1).id, "unmoored"],
["coherenceBandFrom(0).id", RULES.coherenceBandFrom(0).id, "displaced"], ["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 // a lost location must cost something, for every kind the species tables use
["locationEffectsFor([]) is inert", RULES.locationEffectsFor([]).all, 0], ["locationEffectsFor([]) is inert", RULES.locationEffectsFor([]).all, 0],
["leg disabled → physical", RULES.locationEffectsFor([{kind:"leg",disabled:true}]).physical, -30], ["leg disabled → physical", RULES.locationEffectsFor([{kind:"leg",disabled:true}]).physical, -30],
+7
View File
@@ -75,6 +75,13 @@ export const SKILL_CATALOGUE = [
["navigate", "", "mental", 10, "Navigate"], ["navigate", "", "mental", 10, "Navigate"],
["research", "", "mental", 1, "Research"], ["research", "", "mental", 1, "Research"],
["anomaly_lore", "", "mental", 0, "Anomaly Lore", { canBeTaught: false }], ["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 // perception
["listen", "", "percep", 25, "Listen"], ["listen", "", "percep", 25, "Listen"],
["spot", "", "percep", 25, "Spot"], ["spot", "", "percep", 25, "Spot"],
-176
View File
@@ -1,176 +0,0 @@
/**
* Dice textures for The Custodians, generated rather than drawn.
*
* Written as raw RGBA and encoded to PNG with node:zlib, so the repo gains no
* image dependency and the textures can be regenerated deterministically. Every
* pattern is tileable — Dice So Nice wraps these across a die face, and a seam
* shows up immediately on a d20.
*
* The set is the setting: the Custodians keep a filing system, and the game is
* about that filing system being wrong. So the dice carry a ruled grid, and the
* further an agent drifts from their own reality the more the grid stops agreeing
* with itself.
*
* node tools/make-dice-textures.mjs
*/
import { deflateSync } from "node:zlib";
import { writeFile, mkdir } from "node:fs/promises";
const SIZE = 256;
/* ---------- a minimal PNG encoder: RGBA, no interlace ---------- */
function crc32(buf) {
let c, table = crc32.table;
if (!table) {
table = crc32.table = new Int32Array(256);
for (let n = 0; n < 256; n++) {
c = n;
for (let k = 0; k < 8; k++) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1;
table[n] = c;
}
}
c = -1;
for (let i = 0; i < buf.length; i++) c = table[(c ^ buf[i]) & 0xff] ^ (c >>> 8);
return (c ^ -1) >>> 0;
}
function chunk(type, data) {
const len = Buffer.alloc(4); len.writeUInt32BE(data.length);
const td = Buffer.concat([Buffer.from(type, "ascii"), data]);
const crc = Buffer.alloc(4); crc.writeUInt32BE(crc32(td));
return Buffer.concat([len, td, crc]);
}
function png(width, height, rgba) {
const ihdr = Buffer.alloc(13);
ihdr.writeUInt32BE(width, 0); ihdr.writeUInt32BE(height, 4);
ihdr[8] = 8; ihdr[9] = 6; ihdr[10] = 0; ihdr[11] = 0; ihdr[12] = 0;
// each scanline is prefixed with filter byte 0
const raw = Buffer.alloc(height * (width * 4 + 1));
for (let y = 0; y < height; y++) {
raw[y * (width * 4 + 1)] = 0;
rgba.copy(raw, y * (width * 4 + 1) + 1, y * width * 4, (y + 1) * width * 4);
}
return Buffer.concat([
Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]),
chunk("IHDR", ihdr),
chunk("IDAT", deflateSync(raw, { level: 9 })),
chunk("IEND", Buffer.alloc(0))
]);
}
/* ---------- deterministic noise, so a rebuild is byte-identical ---------- */
let seed = 20260810;
const rnd = () => (seed = (seed * 1664525 + 1013904223) >>> 0) / 4294967296;
const canvas = () => Buffer.alloc(SIZE * SIZE * 4);
const put = (buf, x, y, r, g, b, a = 255) => {
const i = ((y & (SIZE - 1)) * SIZE + (x & (SIZE - 1))) * 4;
buf[i] = r; buf[i + 1] = g; buf[i + 2] = b; buf[i + 3] = a;
};
const fill = (buf, r, g, b, a = 255) => {
for (let i = 0; i < buf.length; i += 4) { buf[i] = r; buf[i + 1] = g; buf[i + 2] = b; buf[i + 3] = a; }
};
/* ============================================================
1. REGISTRY — the ruled grid of a filing system, kept true.
============================================================ */
function registry() {
const b = canvas();
fill(b, 24, 38, 141); // ultramarine ground
for (let i = 0; i < SIZE; i++) {
for (const step of [32]) {
for (let k = 0; k < SIZE; k += step) {
put(b, i, k, 102, 116, 216); // --rb-line
put(b, k, i, 102, 116, 216);
}
}
for (let k = 16; k < SIZE; k += 32) { // fainter half-lines
put(b, i, k, 60, 78, 175);
put(b, k, i, 60, 78, 175);
}
}
// registry ticks at the intersections
for (let x = 0; x < SIZE; x += 64) for (let y = 0; y < SIZE; y += 64) {
for (let d = -3; d <= 3; d++) { put(b, x + d, y, 242, 234, 114); put(b, x, y + d, 242, 234, 114); }
}
return b;
}
/* ============================================================
2. DRIFT — the same grid, no longer agreeing with itself.
Used for agents who have come loose from their own reality.
============================================================ */
function drift() {
const b = canvas();
fill(b, 20, 30, 120);
const wobble = y => Math.round(6 * Math.sin(y / 14) + 3 * Math.sin(y / 5.5));
for (let i = 0; i < SIZE; i++) {
for (let k = 0; k < SIZE; k += 32) {
put(b, i + wobble(i), k, 122, 108, 200);
put(b, k, i + wobble(i), 122, 108, 200);
}
}
for (let n = 0; n < 900; n++) { // grain where it has torn
const x = (rnd() * SIZE) | 0, y = (rnd() * SIZE) | 0;
put(b, x, y, 200, 120, 150, 255);
}
for (let x = 0; x < SIZE; x += 64) for (let y = 0; y < SIZE; y += 64) {
const ox = ((rnd() * 7) | 0) - 3, oy = ((rnd() * 7) | 0) - 3;
for (let d = -3; d <= 3; d++) { put(b, x + d + ox, y + oy, 255, 122, 92); put(b, x + ox, y + d + oy, 255, 122, 92); }
}
return b;
}
/* ============================================================
3. DISPLACED — the grid is gone. What is left is not filed.
============================================================ */
function displaced() {
const b = canvas();
fill(b, 12, 14, 60);
for (let n = 0; n < 5200; n++) {
const x = (rnd() * SIZE) | 0, y = (rnd() * SIZE) | 0;
const v = 40 + ((rnd() * 90) | 0);
put(b, x, y, v, v * 0.5, v * 0.7);
}
// a few surviving fragments of rule, going nowhere
for (let n = 0; n < 26; n++) {
const x = (rnd() * SIZE) | 0, y = (rnd() * SIZE) | 0, len = 6 + ((rnd() * 22) | 0);
const horiz = rnd() > 0.5;
for (let d = 0; d < len; d++) {
if (horiz) put(b, x + d, y, 150, 120, 190); else put(b, x, y + d, 150, 120, 190);
}
}
return b;
}
/* ============================================================
4. AGENCY — brushed plate, for the agency-issue set.
============================================================ */
function agency() {
const b = canvas();
for (let y = 0; y < SIZE; y++) {
for (let x = 0; x < SIZE; x++) {
const grain = 6 * Math.sin(x / 1.7 + y / 90) + (rnd() * 7 - 3.5);
const v = 30 + grain;
put(b, x, y, 22 + v, 33 + v, 95 + v);
}
}
for (let k = 0; k < SIZE; k += 48) for (let i = 0; i < SIZE; i++) put(b, i, k, 90, 104, 190);
return b;
}
const TEXTURES = {
"custodians-registry": registry,
"custodians-drift": drift,
"custodians-displaced": displaced,
"custodians-agency": agency
};
await mkdir(new URL("../dice/", import.meta.url), { recursive: true });
for (const [name, make] of Object.entries(TEXTURES)) {
const buf = png(SIZE, SIZE, make());
await writeFile(new URL(`../dice/${name}.png`, import.meta.url), buf);
console.log(` ${name}.png ${SIZE}x${SIZE} ${(buf.length / 1024).toFixed(1)} kB`);
}
console.log("Custodians | dice textures written");
+49 -1
View File
@@ -15,7 +15,8 @@
import { TRADE_BANDS, INDUCTION } from "../postings.mjs"; import { TRADE_BANDS, INDUCTION } from "../postings.mjs";
import { import {
hitPointsFor, majorWoundFor, damageModifierFor, defencePenaltyFor, hitPointsFor, majorWoundFor, damageModifierFor, defencePenaltyFor,
COHERENCE_BANDS, RANGE_LADDER, CIRCUMSTANCE COHERENCE_BANDS, RANGE_LADDER, CIRCUMSTANCE,
PANIC_BANDS, PANIC_TRIGGERS, PANIC_RELIEF, PANIC_MAX, PANIC_AFTER_BREAK, PANIC_BREAK
} from "../rules.mjs"; } from "../rules.mjs";
import { readFile } from "node:fs/promises"; import { readFile } from "node:fs/promises";
@@ -378,6 +379,39 @@ export const PRIMER_PAGES = [
}, },
/* ---------------------------------------------------------------- */ /* ---------------------------------------------------------------- */
{ name: "8b · Panic — the other track", content:
P("Coherence is slow. It measures what <em>knowing</em> has cost you, and it is counted "
+ "in weeks. <strong>Panic is fast.</strong> It measures what is happening to you right "
+ "now, and it is counted in minutes.")
+ P("<span class=\"rb-big-number\">Panic runs 0 to ${PANIC_MAX}. It goes UP.</span>"
+ "You take a point when something happens to you. <strong>There is no roll to take "
+ "one</strong> — nobody has to roll to find out whether they are frightened.")
+ TABLE(["Panic", "You are", "It costs you"],
[...PANIC_BANDS].reverse().map(b =>
[String(b.min), `<strong>${L(b.label)}</strong>`,
b.all ? `${pc(b.all)} to everything` : "nothing"]))
+ P("<strong>The first point is free.</strong> At 1 you are rattled and you roll at no "
+ "penalty at all. It is only from 2 that it starts costing you.")
+ H(2, "What puts a point on")
+ TABLE(["This happens to you", "Panic"],
Object.values(PANIC_TRIGGERS).map(t => [L(t.label), `+${t.cost}`]))
+ H(2, "What takes one off")
+ P("<strong>Roll it.</strong> A success takes one point off, a special or better takes "
+ "two — and a <strong>fumble puts one on</strong>, because trying to steady somebody "
+ "and making it worse is the most familiar thing on this list.")
+ TABLE(["How", "Who it helps", "How often"],
Object.values(PANIC_RELIEF).map(r => [L(r.label), r.reach === "self" ? "you"
: r.reach === "other" ? "somebody else" : "everyone who hears it", r.once]))
+ P("<strong>Psychology</strong> is the only one that steadies somebody who is not you. "
+ "<strong>Occult</strong> is the only one that reaches a whole room — naming a thing "
+ "correctly is the job. <strong>Religion</strong> steadies you alone, and does not "
+ "care whether you are right.")
+ H(2, "Breaking")
+ P("At ${PANIC_MAX} you break. Choose one — <strong>${PANIC_BREAK.join(', ')}</strong> — take "
+ "it, and come back at <strong>${PANIC_AFTER_BREAK}</strong>. Not at nought. Breaking is not a rest.")
+ 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: "9 · The whole game, in one page", content: { name: "9 · The whole game, in one page", content:
P("You play ordinary adults in the modern world, pulled into an old and quiet " 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.") + "government department because you stay yourselves when reality goes wrong.")
@@ -419,6 +453,20 @@ export const PRIMER_PAGES = [
+ "are, and <strong>00 is always a fumble</strong>. No skill is ever certain — and " + "are, and <strong>00 is always a fumble</strong>. No skill is ever certain — and "
+ "none is ever hopeless either, because the floor is 1%.") + "none is ever hopeless either, because the floor is 1%.")
+ H(2, "The two tracks")
+ TABLE(["", "Range", "Direction", "Clears"], [
["<strong>Panic</strong> — what is happening now",
`0–${PANIC_MAX}`, "up", "when the scene ends"],
["<strong>Coherence</strong> — what knowing has cost",
"10–0", "down", "a debrief, or a week at home"]
])
+ P("Panic: the first point is free, then "
+ [...PANIC_BANDS].reverse().filter(b => b.all)
.map(b => `<strong>${b.min}</strong> is ${pc(b.all)}`).join(", ")
+ ". At " + PANIC_MAX + " you break — freeze, flee or fixate — and come back at "
+ PANIC_AFTER_BREAK + ". A relief roll takes one off, a special two, "
+ "<strong>a fumble puts one on</strong>.")
+ H(2, "The three things that change your number") + H(2, "The three things that change your number")
+ CARDS([ + CARDS([
["1 · Difficulty", ["1 · Difficulty",
+50 -3
View File
@@ -28,7 +28,8 @@ import {
powerStartingRating, brawlFallbackFor, bleedStoppedBy, powerStartingRating, brawlFallbackFor, bleedStoppedBy,
crossingArtPoolFor, crossingGrantsArt, crossingCostFor, crossingArtPoolFor, crossingGrantsArt, crossingCostFor,
REACTION, BLEED, CIRCUMSTANCE, RANGE_LADDER, rangeBandFrom, 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"; } from "../rules.mjs";
// Postings, trades and induction are plain data and are imported as such. // Postings, trades and induction are plain data and are imported as such.
import { ROLES, TRADES, INDUCTION, TIERS, TRADE_BANDS } from "../postings.mjs"; 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(" · ") || "—"]; 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 cohRows = COHERENCE_BANDS.map((b, i) => {
const hi = i === 0 ? 10 : COHERENCE_BANDS[i - 1].min - 1; const hi = i === 0 ? 10 : COHERENCE_BANDS[i - 1].min - 1;
const rng = b.min === hi ? `${b.min}` : `${hi}–${b.min}`; 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 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>` }, 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: ` { name: "5 · The Quiet Arts", content: `
<p><em>Some agents come back with something. It is not a gift and it is not <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> 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> <h2>Rolls that ignore your skill</h2>
${table(["Roll", "Result"], [ ${table(["Roll", "Result"], [
["96–99", "always fails, whatever your skill"], ["96–99", "never succeeds, whatever your skill — and is a fumble if it is inside your fumble range"],
["00", "always a fumble"], ["00", "always a fumble, whatever your skill"],
["01", "always at least a critical chance — the floor is 1%"] ["01", "always at least a critical chance — the floor is 1%"]
])} ])}