Add the player primer and cheat sheet, and brand every journal

Ten pages a player reads before session one, in their own compendium at OBSERVER
ownership: nine of setting, one cheat sheet.

The draft called the organisation "the Bureau"; everything else in the game calls it
the department or the agency, so that is what it is called. Thin areas filled rather
than padded: the seven character principles as a list, the real Coherence band table
instead of a paragraph describing one, and "what continuity is not" pulled out of the
middle of a page because it is the most important sentence for a new player.

The cheat sheet's numbers are computed from rules.mjs and lang/en.json at build time.
A hand-typed one would have been the sixth time this project shipped a number that
disagreed with the code, and the one the players were holding.

R-250: the design tokens were never on :root. Journals rendered as default parchment,
and four things were wrong in sequence — v14's hook is renderJournalEntrySheet; its
`element` is the node, so `[0]` returns the first CHILD and put the class on a header
button; there is no .journal-page-content in v14; and then the real one: with the
selector matching, font-size, line-height and padding all applied while every colour
did not, because the palette is declared on .ringbrp-sheet/.ringbrp-chat/.ringbrp-window
and never on :root. A journal inherited no tokens, so every var() silently became
nothing. A rule that HALF applies is not a specificity problem — I spent three attempts
treating it as one, including a version bump on a cache theory that was wrong.

Branded: primer, rulebook, starter GM text and handouts. Scoped by flag, so other
modules' journals are untouched. Version 0.9.0.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
slaguru666
2026-08-12 10:19:41 +01:00
co-authored by Claude Opus 5
parent d9bce12c27
commit 7f8355f0d0
51 changed files with 862 additions and 88 deletions
+1 -1
View File
@@ -180,7 +180,7 @@ The build runs all three guards before it writes anything:
<!-- guards:start -->
```
check-rules: OK — 87 rules, 16 files scanned, 3 aliases + 41 constant sets checked, 275 formulas verified
check-rules: OK — 87 rules, 17 files scanned, 3 aliases + 41 constant sets checked, 275 formulas verified
check-kits: OK — 25 roles, 10 trades, 218 catalogue items, every kit key resolves, every posting can use what it carries, every loadout distinct
check-lang: OK — en.json, 927 keys, no leaf/branch collisions
check-behaviour: OK — 38 behavioural tests
+60
View File
@@ -5070,3 +5070,63 @@ species bonus."
Checked afterwards that every localisation key still referenced by the engine or a
template exists; the six apparent misses are Handlebars concatenation prefixes rather
than lookups.
## The player primer, and the journals finally look like the game
Ten pages a player reads before session one, in their own compendium at OBSERVER
ownership: nine of setting and one cheat sheet. Plus the branding that all four
journals in the system had been going without.
**The voice was the first thing to fix.** The draft called the organisation "the
Bureau" throughout. Everything else in the game — twelve rules pages, the starter, 927
localisation strings — calls it *the department* or *the agency*. One name, and it is
not Bureau.
**Where the draft was thin, it got filled rather than padded.** The character section
now carries the seven principles as an ordered list a player can actually work from;
"what it costs" gets the real Coherence band table rather than a paragraph describing
one, and says what the anchor in the kit is for; and the recruitment page separates
*what continuity means in practice* from *what it is not*, because "it is not a
superpower" is the single most important sentence for a new player and it was buried.
**The cheat sheet's numbers are computed, not typed.** Hit points, Major Wound, damage
modifier, the defence penalties, the range ladder and the Coherence bands all come out
of `rules.mjs` and `lang/en.json` at build time. A hand-typed cheat sheet would have
been the sixth time this project shipped a number that disagreed with the code — and it
would have been the one the players were holding. It closes on six questions new
players actually ask, including "am I going to die?" answered honestly.
### R-250. The design tokens were never on `:root`
The journals rendered as Foundry's default parchment, and chasing why took four wrong
guesses that are worth recording, because each one looked right.
1. **The hook name.** v14's journal sheet is an ApplicationV2: the hook is
`renderJournalEntrySheet`, not `renderJournalSheet`.
2. **`element` is the node.** On AppV2 it is the root element, not a jQuery wrapper.
Reaching for `[0]` silently returns the first *child* — which is how the first
version appeared to work, put the class on a header button, and changed nothing.
3. **The page container.** There is no `.journal-page-content` in v14. The prose sits
directly inside `article.journal-entry-page`.
4. **And then the real one.** With the class on the right element and the selector
matching, `font-size`, `line-height` and `padding` from my rule were all applying —
and every colour was not. `--rb-deep` resolved to nothing. The palette is declared
on `.ringbrp-sheet, .ringbrp-chat, .ringbrp-window` and **never on `:root`**, so a
journal carrying a new class inherited no tokens at all. Every `var()` silently
became nothing, which CSS treats as "unset" rather than as an error.
The tell was that the *numeric* properties from the same rule worked while the coloured
ones did not. A rule that half-applies is not a specificity problem, and I spent three
attempts treating it as one — including bumping the system version on a cache theory
that was wrong.
Adding `.ringbrp-journal` to the token block fixed all of it at once.
**Now branded:** the primer, the generated rulebook, the starter's GM text and its
handouts. Scoped by a flag, so another module's journals in the same world are
untouched. The contents rail is the yellow-on-navy of the sheet tabs, read-aloud gets a
spot-colour margin rule, diagrams are monospaced in a bordered block, and the cheat
sheet is a grid of cards so it can be skimmed under pressure rather than read.
Version bumped to 0.9.0 — which, incidentally, is what makes Foundry re-fetch the
stylesheet for anyone updating.
+2 -2
View File
@@ -1,7 +1,7 @@
{
"name": "ringbrp",
"version": "0.8.0",
"description": "The Custodians \u2014 a Basic Roleplaying game of crossings, for Foundry VTT",
"version": "0.9.0",
"description": "The Custodians — a Basic Roleplaying game of crossings, for Foundry VTT",
"main": "ringbrp.mjs",
"scripts": {
"build": "node tools/build-packs.mjs && node tools/update-readme.mjs",
+5 -5
View File
@@ -1,5 +1,5 @@
2026/08/12-00:06:16.855265 1754e3000 Recovering log #3
2026/08/12-00:06:16.855669 1754e3000 Level-0 table #5: started
2026/08/12-00:06:16.855836 1754e3000 Level-0 table #5: 4591 bytes OK
2026/08/12-00:06:16.856081 1754e3000 Delete type=0 #3
2026/08/12-00:06:16.856115 1754e3000 Delete type=3 #2
2026/08/12-10:15:09.925114 17200b000 Recovering log #3
2026/08/12-10:15:09.925148 17200b000 Level-0 table #5: started
2026/08/12-10:15:09.925283 17200b000 Level-0 table #5: 4591 bytes OK
2026/08/12-10:15:09.925570 17200b000 Delete type=0 #3
2026/08/12-10:15:09.925615 17200b000 Delete type=3 #2
+1 -1
View File
@@ -1 +1 @@
2026/08/12-00:05:23.690846 171b57000 Delete type=3 #1
2026/08/12-10:14:43.782384 173eef000 Delete type=3 #1
+5 -5
View File
@@ -1,5 +1,5 @@
2026/08/12-00:06:16.873912 1754e3000 Recovering log #3
2026/08/12-00:06:16.874024 1754e3000 Level-0 table #5: started
2026/08/12-00:06:16.874152 1754e3000 Level-0 table #5: 4129 bytes OK
2026/08/12-00:06:16.874423 1754e3000 Delete type=0 #3
2026/08/12-00:06:16.874466 1754e3000 Delete type=3 #2
2026/08/12-10:15:09.941304 173023000 Recovering log #3
2026/08/12-10:15:09.941329 173023000 Level-0 table #5: started
2026/08/12-10:15:09.941465 173023000 Level-0 table #5: 4129 bytes OK
2026/08/12-10:15:09.941748 173023000 Delete type=0 #3
2026/08/12-10:15:09.941781 173023000 Delete type=3 #2
+1 -1
View File
@@ -1 +1 @@
2026/08/12-00:05:23.732633 17134b000 Delete type=3 #1
2026/08/12-10:14:43.827491 1726cb000 Delete type=3 #1
+5 -5
View File
@@ -1,5 +1,5 @@
2026/08/12-00:06:16.857059 174cd7000 Recovering log #3
2026/08/12-00:06:16.857573 174cd7000 Level-0 table #5: started
2026/08/12-00:06:16.857903 174cd7000 Level-0 table #5: 31614 bytes OK
2026/08/12-00:06:16.858145 174cd7000 Delete type=0 #3
2026/08/12-00:06:16.858196 174cd7000 Delete type=3 #2
2026/08/12-10:15:09.926306 173023000 Recovering log #3
2026/08/12-10:15:09.926421 173023000 Level-0 table #5: started
2026/08/12-10:15:09.926814 173023000 Level-0 table #5: 31614 bytes OK
2026/08/12-10:15:09.927163 173023000 Delete type=0 #3
2026/08/12-10:15:09.927207 173023000 Delete type=3 #2
+1 -1
View File
@@ -1 +1 @@
2026/08/12-00:05:23.692998 170b3f000 Delete type=3 #1
2026/08/12-10:14:43.784415 173eef000 Delete type=3 #1
+5 -5
View File
@@ -1,5 +1,5 @@
2026/08/12-00:06:16.840426 174cd7000 Recovering log #3
2026/08/12-00:06:16.842114 174cd7000 Level-0 table #5: started
2026/08/12-00:06:16.843749 174cd7000 Level-0 table #5: 225945 bytes OK
2026/08/12-00:06:16.844107 174cd7000 Delete type=0 #3
2026/08/12-00:06:16.844201 174cd7000 Delete type=3 #2
2026/08/12-10:15:09.914246 173023000 Recovering log #3
2026/08/12-10:15:09.915233 173023000 Level-0 table #5: started
2026/08/12-10:15:09.916771 173023000 Level-0 table #5: 225945 bytes OK
2026/08/12-10:15:09.917162 173023000 Delete type=0 #3
2026/08/12-10:15:09.917219 173023000 Delete type=3 #2
+1 -1
View File
@@ -1 +1 @@
2026/08/12-00:05:23.708940 170b3f000 Delete type=3 #1
2026/08/12-10:14:43.800859 1726cb000 Delete type=3 #1
+5 -5
View File
@@ -1,5 +1,5 @@
2026/08/12-00:06:16.850448 1754e3000 Recovering log #3
2026/08/12-00:06:16.850937 1754e3000 Level-0 table #5: started
2026/08/12-00:06:16.851096 1754e3000 Level-0 table #5: 3295 bytes OK
2026/08/12-00:06:16.851406 1754e3000 Delete type=0 #3
2026/08/12-00:06:16.851447 1754e3000 Delete type=3 #2
2026/08/12-10:15:09.922245 17200b000 Recovering log #3
2026/08/12-10:15:09.922265 17200b000 Level-0 table #5: started
2026/08/12-10:15:09.922421 17200b000 Level-0 table #5: 3295 bytes OK
2026/08/12-10:15:09.922681 17200b000 Delete type=0 #3
2026/08/12-10:15:09.922713 17200b000 Delete type=3 #2
+1 -1
View File
@@ -1 +1 @@
2026/08/12-00:05:23.725493 171b57000 Delete type=3 #1
2026/08/12-10:14:43.821081 1726cb000 Delete type=3 #1
+5 -5
View File
@@ -1,5 +1,5 @@
2026/08/12-00:06:16.836787 174cd7000 Recovering log #3
2026/08/12-00:06:16.837779 174cd7000 Level-0 table #5: started
2026/08/12-00:06:16.838453 174cd7000 Level-0 table #5: 58792 bytes OK
2026/08/12-00:06:16.838760 174cd7000 Delete type=0 #3
2026/08/12-00:06:16.838812 174cd7000 Delete type=3 #2
2026/08/12-10:15:09.911544 173023000 Recovering log #3
2026/08/12-10:15:09.911759 173023000 Level-0 table #5: started
2026/08/12-10:15:09.912426 173023000 Level-0 table #5: 58792 bytes OK
2026/08/12-10:15:09.912801 173023000 Delete type=0 #3
2026/08/12-10:15:09.912846 173023000 Delete type=3 #2
+1 -1
View File
@@ -1 +1 @@
2026/08/12-00:05:23.699399 170b3f000 Delete type=3 #1
2026/08/12-10:14:43.791044 173eef000 Delete type=3 #1
Binary file not shown.
Binary file not shown.
+1
View File
@@ -0,0 +1 @@
MANIFEST-000004
+5
View File
@@ -0,0 +1,5 @@
2026/08/12-10:15:09.929600 17382f000 Recovering log #3
2026/08/12-10:15:09.929643 17382f000 Level-0 table #5: started
2026/08/12-10:15:09.929903 17382f000 Level-0 table #5: 21613 bytes OK
2026/08/12-10:15:09.930174 17382f000 Delete type=0 #3
2026/08/12-10:15:09.930216 17382f000 Delete type=3 #2
+1
View File
@@ -0,0 +1 @@
2026/08/12-10:14:43.817678 1726cb000 Delete type=3 #1
Binary file not shown.
Binary file not shown.
Binary file not shown.
+5 -5
View File
@@ -1,5 +1,5 @@
2026/08/12-00:06:16.861054 174cd7000 Recovering log #3
2026/08/12-00:06:16.861635 174cd7000 Level-0 table #5: started
2026/08/12-00:06:16.861956 174cd7000 Level-0 table #5: 33473 bytes OK
2026/08/12-00:06:16.862206 174cd7000 Delete type=0 #3
2026/08/12-00:06:16.862237 174cd7000 Delete type=3 #2
2026/08/12-10:15:09.930884 17200b000 Recovering log #3
2026/08/12-10:15:09.930957 17200b000 Level-0 table #5: started
2026/08/12-10:15:09.931274 17200b000 Level-0 table #5: 33484 bytes OK
2026/08/12-10:15:09.931485 17200b000 Delete type=0 #3
2026/08/12-10:15:09.931513 17200b000 Delete type=3 #2
+1 -1
View File
@@ -1 +1 @@
2026/08/12-00:05:23.729349 17134b000 Delete type=3 #1
2026/08/12-10:14:43.825718 1726cb000 Delete type=3 #1
Binary file not shown.
+5 -5
View File
@@ -1,5 +1,5 @@
2026/08/12-00:06:16.846928 1744cb000 Recovering log #3
2026/08/12-00:06:16.847099 1744cb000 Level-0 table #5: started
2026/08/12-00:06:16.847302 1744cb000 Level-0 table #5: 7590 bytes OK
2026/08/12-00:06:16.847547 1744cb000 Delete type=0 #3
2026/08/12-00:06:16.847579 1744cb000 Delete type=3 #2
2026/08/12-10:15:09.920048 17200b000 Recovering log #3
2026/08/12-10:15:09.920101 17200b000 Level-0 table #5: started
2026/08/12-10:15:09.920292 17200b000 Level-0 table #5: 7590 bytes OK
2026/08/12-10:15:09.920533 17200b000 Delete type=0 #3
2026/08/12-10:15:09.920569 17200b000 Delete type=3 #2
+1 -1
View File
@@ -1 +1 @@
2026/08/12-00:05:23.685076 170b3f000 Delete type=3 #1
2026/08/12-10:14:43.775951 172ed7000 Delete type=3 #1
Binary file not shown.
Binary file not shown.
+5 -5
View File
@@ -1,5 +1,5 @@
2026/08/12-00:06:16.870148 1744cb000 Recovering log #3
2026/08/12-00:06:16.870587 1744cb000 Level-0 table #5: started
2026/08/12-00:06:16.870864 1744cb000 Level-0 table #5: 16892 bytes OK
2026/08/12-00:06:16.871211 1744cb000 Delete type=0 #3
2026/08/12-00:06:16.871266 1744cb000 Delete type=3 #2
2026/08/12-10:15:09.938481 172817000 Recovering log #3
2026/08/12-10:15:09.938536 172817000 Level-0 table #5: started
2026/08/12-10:15:09.938839 172817000 Level-0 table #5: 16902 bytes OK
2026/08/12-10:15:09.939182 172817000 Delete type=0 #3
2026/08/12-10:15:09.939235 172817000 Delete type=3 #2
+1 -1
View File
@@ -1 +1 @@
2026/08/12-00:05:23.723650 170b3f000 Delete type=3 #1
2026/08/12-10:14:43.819552 1736e3000 Delete type=3 #1
Binary file not shown.
Binary file not shown.
Binary file not shown.
+5 -5
View File
@@ -1,5 +1,5 @@
2026/08/12-00:06:16.864877 1744cb000 Recovering log #3
2026/08/12-00:06:16.865947 1744cb000 Level-0 table #5: started
2026/08/12-00:06:16.866787 1744cb000 Level-0 table #5: 82274 bytes OK
2026/08/12-00:06:16.867102 1744cb000 Delete type=0 #3
2026/08/12-00:06:16.867176 1744cb000 Delete type=3 #2
2026/08/12-10:15:09.933901 172817000 Recovering log #3
2026/08/12-10:15:09.934300 172817000 Level-0 table #5: started
2026/08/12-10:15:09.935025 172817000 Level-0 table #5: 82299 bytes OK
2026/08/12-10:15:09.935279 172817000 Delete type=0 #3
2026/08/12-10:15:09.935312 172817000 Delete type=3 #2
+1 -1
View File
@@ -1 +1 @@
2026/08/12-00:05:23.720012 170333000 Delete type=3 #1
2026/08/12-10:14:43.811578 1736e3000 Delete type=3 #1
Binary file not shown.
+5 -5
View File
@@ -1,5 +1,5 @@
2026/08/12-00:06:16.872191 173cbf000 Recovering log #3
2026/08/12-00:06:16.872466 173cbf000 Level-0 table #5: started
2026/08/12-00:06:16.872601 173cbf000 Level-0 table #5: 3739 bytes OK
2026/08/12-00:06:16.872838 173cbf000 Delete type=0 #3
2026/08/12-00:06:16.872870 173cbf000 Delete type=3 #2
2026/08/12-10:15:09.940141 17200b000 Recovering log #3
2026/08/12-10:15:09.940161 17200b000 Level-0 table #5: started
2026/08/12-10:15:09.940281 17200b000 Level-0 table #5: 3739 bytes OK
2026/08/12-10:15:09.940565 17200b000 Delete type=0 #3
2026/08/12-10:15:09.940598 17200b000 Delete type=3 #2
+1 -1
View File
@@ -1 +1 @@
2026/08/12-00:05:23.713770 17134b000 Delete type=3 #1
2026/08/12-10:14:43.805210 1726cb000 Delete type=3 #1
+5 -5
View File
@@ -1,5 +1,5 @@
2026/08/12-00:06:16.848619 173cbf000 Recovering log #3
2026/08/12-00:06:16.848765 173cbf000 Level-0 table #5: started
2026/08/12-00:06:16.848948 173cbf000 Level-0 table #5: 5770 bytes OK
2026/08/12-00:06:16.849247 173cbf000 Delete type=0 #3
2026/08/12-00:06:16.849307 173cbf000 Delete type=3 #2
2026/08/12-10:15:09.921227 173023000 Recovering log #3
2026/08/12-10:15:09.921256 173023000 Level-0 table #5: started
2026/08/12-10:15:09.921405 173023000 Level-0 table #5: 5770 bytes OK
2026/08/12-10:15:09.921672 173023000 Delete type=0 #3
2026/08/12-10:15:09.921715 173023000 Delete type=3 #2
+1 -1
View File
@@ -1 +1 @@
2026/08/12-00:05:23.687537 17134b000 Delete type=3 #1
2026/08/12-10:14:43.779038 173eef000 Delete type=3 #1
+5 -5
View File
@@ -1,5 +1,5 @@
2026/08/12-00:06:16.859394 1754e3000 Recovering log #3
2026/08/12-00:06:16.859688 1754e3000 Level-0 table #5: started
2026/08/12-00:06:16.859874 1754e3000 Level-0 table #5: 6939 bytes OK
2026/08/12-00:06:16.860159 1754e3000 Delete type=0 #3
2026/08/12-00:06:16.860190 1754e3000 Delete type=3 #2
2026/08/12-10:15:09.928207 172817000 Recovering log #3
2026/08/12-10:15:09.928248 172817000 Level-0 table #5: started
2026/08/12-10:15:09.928429 172817000 Level-0 table #5: 6939 bytes OK
2026/08/12-10:15:09.928761 172817000 Delete type=0 #3
2026/08/12-10:15:09.928810 172817000 Delete type=3 #2
+1 -1
View File
@@ -1 +1 @@
2026/08/12-00:05:23.694993 17134b000 Delete type=3 #1
2026/08/12-10:14:43.786318 173eef000 Delete type=3 #1
+5 -5
View File
@@ -1,5 +1,5 @@
2026/08/12-00:06:16.852833 173cbf000 Recovering log #3
2026/08/12-00:06:16.853536 173cbf000 Level-0 table #5: started
2026/08/12-00:06:16.853822 173cbf000 Level-0 table #5: 14767 bytes OK
2026/08/12-00:06:16.854096 173cbf000 Delete type=0 #3
2026/08/12-00:06:16.854134 173cbf000 Delete type=3 #2
2026/08/12-10:15:09.923574 17382f000 Recovering log #3
2026/08/12-10:15:09.923739 17382f000 Level-0 table #5: started
2026/08/12-10:15:09.924049 17382f000 Level-0 table #5: 14767 bytes OK
2026/08/12-10:15:09.924348 17382f000 Delete type=0 #3
2026/08/12-10:15:09.924394 17382f000 Delete type=3 #2
+1 -1
View File
@@ -1 +1 @@
2026/08/12-00:05:23.689273 17134b000 Delete type=3 #1
2026/08/12-10:14:43.780736 1726cb000 Delete type=3 #1
+32
View File
@@ -6259,6 +6259,38 @@ async function migrateFittedGear() {
return fixed;
}
/**
* Dress this system's journals in the system's own paper.
*
* Twelve pages of generated rulebook, a player primer and a starter scenario were all
* rendering as Foundry's default parchment — the only part of the game that did not
* look like the game. Scoped by a flag rather than applied to every JournalSheet, so a
* module's journals in the same world are left exactly as they were.
*/
/**
* Dress this system's journals in the system's own paper.
*
* Twelve pages of generated rulebook, a ten-page player primer and a starter scenario
* were all rendering as Foundry's default parchment — the only part of the game that
* did not look like the game. Scoped by a flag rather than applied to every journal, so
* a module's entries in the same world are left exactly as they were.
*
* v14 note: the sheet is an ApplicationV2, so the hook is `renderJournalEntrySheet` and
* `element` is the root node itself rather than a jQuery wrapper. Reaching for `[0]` on
* it silently returns the first CHILD — which is how the first version of this appeared
* to work, added the class to a header button, and changed nothing.
*/
const rbBrandJournal = (doc, el) => {
const mine = doc?.flags?.ringbrp?.brand
|| (typeof doc?.pack === "string" && doc.pack.startsWith("ringbrp."))
|| doc?.compendium?.metadata?.packageName === "ringbrp";
const node = el instanceof HTMLElement ? el : (el?.[0] ?? null);
if (mine && node?.classList) node.classList.add("ringbrp-journal");
};
Hooks.on("renderJournalEntrySheet", (app, el) => rbBrandJournal(app?.document, el));
Hooks.on("renderJournalEntryPageSheet", (app, el) =>
rbBrandJournal(app?.document?.parent, el));
Hooks.once("ready", async () => {
console.log("RingBRP | ready");
if (!game.user.isGM) return;
+149
View File
@@ -36,6 +36,7 @@
.ringbrp-sheet,
.ringbrp-chat,
.ringbrp-journal,
.ringbrp-window {
/* sampled from the 1984 Explorer Book cover */
--rb-void: #0c125b;
@@ -423,6 +424,154 @@
.ringbrp-sheet .rb-equip:hover { color: var(--rb-white); }
.ringbrp-sheet .rb-equip.on { color: var(--rb-yellow); }
/* ============================================================
JOURNALS — the department's own paper
============================================================
Twelve pages of rulebook and a player primer were rendering as Foundry's default
parchment, which is the one place in the system that did not look like the system.
Scoped to .ringbrp-journal, applied by a hook to this system's entries only, so
other modules' journals are left alone. */
.ringbrp-journal .window-content,
.ringbrp-journal .journal-entry-content,
.ringbrp-journal .journal-entry-pages { background: var(--rb-deep); }
/* The contents rail: this is how a player moves through ten pages. */
.ringbrp-journal .journal-sidebar {
background: var(--rb-void); border-right: 1px solid var(--rb-line);
}
.ringbrp-journal .journal-sidebar .toc li.page {
font-family: var(--rb-display); font-size: 10px; letter-spacing: .05em;
text-transform: uppercase; color: var(--rb-muted);
border-bottom: 1px solid var(--rb-hairline);
}
.ringbrp-journal .journal-sidebar .toc li.page:hover { color: var(--rb-white); }
.ringbrp-journal .journal-sidebar .toc li.page.active,
.ringbrp-journal .journal-sidebar .toc li.page.readied {
color: var(--rb-void); background: var(--rb-yellow);
}
.ringbrp-journal .journal-header .title {
font-family: var(--rb-display); color: var(--rb-yellow);
background: transparent; border: none;
}
/* A page. v14 puts the prose straight inside article.journal-entry-page — there is no
.journal-page-content wrapper, which is why the first version of this styled nothing. */
.ringbrp-journal .journal-entry-page {
background: var(--rb-deep);
color: var(--rb-white);
font-family: var(--rb-body);
font-size: 14px;
line-height: 1.55;
padding: 4px 18px 28px;
}
.ringbrp-journal .journal-entry-page h1 {
font-family: var(--rb-display); font-size: 19px; letter-spacing: .06em;
text-transform: uppercase; color: var(--rb-yellow);
border: none; border-bottom: 2px solid var(--rb-yellow); padding-bottom: 8px;
margin: 0 0 18px;
}
.ringbrp-journal .journal-entry-page h2 {
font-family: var(--rb-display); font-size: 13px; letter-spacing: .1em;
text-transform: uppercase; color: var(--rb-yellow);
border: none; border-bottom: 1px solid var(--rb-hairline); padding-bottom: 5px;
margin: 26px 0 12px;
}
.ringbrp-journal .journal-entry-page h3 {
font-family: var(--rb-display); font-size: 11px; letter-spacing: .09em;
text-transform: uppercase; color: var(--rb-white); border: none; margin: 20px 0 8px;
}
.ringbrp-journal .journal-entry-page p { margin: 0 0 12px; }
.ringbrp-journal .journal-entry-page strong { color: var(--rb-yellow); font-weight: 700; }
.ringbrp-journal .journal-entry-page em { color: var(--rb-muted); }
.ringbrp-journal .journal-entry-page a { color: var(--rb-yellow); }
.ringbrp-journal .journal-entry-page ul,
.ringbrp-journal .journal-entry-page ol { margin: 0 0 14px; padding-left: 22px; }
.ringbrp-journal .journal-entry-page li { margin-bottom: 5px; }
.ringbrp-journal .journal-entry-page ul { list-style: none; padding-left: 16px; }
.ringbrp-journal .journal-entry-page ul > li:before {
content: "\25aa"; color: var(--rb-yellow); margin-left: -14px; margin-right: 8px;
}
.ringbrp-journal .journal-entry-page ol > li::marker { color: var(--rb-yellow); font-weight: 700; }
.ringbrp-journal .journal-entry-page table {
width: 100%; border-collapse: collapse; margin: 0 0 16px;
background: var(--rb-void); border: 1px solid var(--rb-line);
}
.ringbrp-journal .journal-entry-page th {
font-family: var(--rb-display); font-size: 10px; letter-spacing: .08em;
text-transform: uppercase; text-align: left; color: var(--rb-void);
background: var(--rb-yellow); padding: 6px 9px; border: none;
}
.ringbrp-journal .journal-entry-page td {
padding: 6px 9px; border-top: 1px solid var(--rb-hairline); vertical-align: top;
}
.ringbrp-journal .journal-entry-page tbody tr:nth-child(even) { background: var(--rb-panel); }
/* Read-aloud: the one thing a GM says in somebody else's voice. */
.ringbrp-journal .rb-readaloud {
margin: 0 0 18px; padding: 12px 16px;
background: var(--rb-void);
border: none; border-left: 3px solid var(--rb-yellow);
color: var(--rb-white); font-size: 15px; line-height: 1.6;
}
.ringbrp-journal .rb-readaloud strong,
.ringbrp-journal .rb-readaloud em { color: var(--rb-yellow); }
/* Maps and diagrams are typed, not drawn. */
.ringbrp-journal .rb-map {
background: var(--rb-void); border: 1px solid var(--rb-line);
color: var(--rb-muted); font-family: var(--rb-mono); font-size: 11px;
line-height: 1.45; padding: 12px 14px; margin: 0 0 16px;
overflow-x: auto; white-space: pre;
}
/* A departmental form: the stamped block a primer page opens with. */
.ringbrp-journal .rb-form {
background: var(--rb-void); border: 1px solid var(--rb-line);
border-top: 3px solid var(--rb-yellow);
padding: 12px 14px; margin: 0 0 20px;
font-family: var(--rb-mono); font-size: 11px; line-height: 1.7;
color: var(--rb-muted); text-transform: uppercase; letter-spacing: .04em;
}
.ringbrp-journal .rb-form b { color: var(--rb-white); font-weight: 700; }
.ringbrp-journal .rb-stamp {
display: inline-block; border: 2px solid var(--rb-danger); color: var(--rb-danger);
font-family: var(--rb-display); font-size: 11px; letter-spacing: .14em;
text-transform: uppercase; padding: 4px 10px; transform: rotate(-2deg);
margin: 4px 0 14px;
}
/* The line a page lands on. */
.ringbrp-journal .rb-closer {
font-family: var(--rb-display); font-size: 13px; letter-spacing: .05em;
color: var(--rb-yellow); text-align: center;
border-top: 1px solid var(--rb-hairline); padding-top: 16px; margin-top: 22px;
}
.ringbrp-journal .rb-two { columns: 260px 2; column-gap: 26px; }
.ringbrp-journal .rb-two > * { break-inside: avoid; }
/* The cheat sheet: a card per rule, so it can be skimmed under pressure. */
.ringbrp-journal .rb-cards {
display: grid; grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
gap: 12px; margin: 0 0 18px;
}
.ringbrp-journal .rb-card {
background: var(--rb-void); border: 1px solid var(--rb-line);
border-left: 3px solid var(--rb-yellow); padding: 10px 12px;
}
.ringbrp-journal .rb-card h4 {
font-family: var(--rb-display); font-size: 10px; letter-spacing: .09em;
text-transform: uppercase; color: var(--rb-yellow); margin: 0 0 6px; border: none;
}
.ringbrp-journal .rb-card p { margin: 0 0 6px; font-size: 13px; }
.ringbrp-journal .rb-card p:last-child { margin-bottom: 0; }
.ringbrp-journal .rb-big-number {
font-family: var(--rb-display); font-size: 22px; color: var(--rb-white);
line-height: 1.15; display: block; margin-bottom: 6px;
}
/* ============================================================
Shields — armour with a switch, so the switch has to be visible
============================================================ */
+8 -1
View File
@@ -2,7 +2,7 @@
"id": "ringbrp",
"title": "The Custodians",
"description": "A Basic Roleplaying game of agents who cross the crossing to contain what should not be there — and who come back a little less from here each time.",
"version": "0.8.0",
"version": "0.9.0",
"compatibility": {
"minimum": 13,
"verified": "14.364"
@@ -113,6 +113,13 @@
"type": "Item",
"system": "ringbrp"
},
{
"name": "primer",
"label": "The Custodians · Read this first (players)",
"path": "packs/primer",
"type": "JournalEntry",
"system": "ringbrp"
},
{
"name": "rules",
"label": "The Custodians · Rules",
+17
View File
@@ -622,6 +622,7 @@ await writePack("armour", ARMOURS.map(a => {
// Handouts are for handing out. A GM who has to change ownership on seven pages
// before the session starts will simply read them aloud instead.
handouts.doc.ownership = { default: 2 };
handouts.doc.flags = { ringbrp: { brand: true } };
for (const p of handouts.pages) p.ownership = { default: 2 };
const advId = idFor("adventure:black_platform");
@@ -656,10 +657,25 @@ await writePack("armour", ARMOURS.map(a => {
+ `${S.STARTER_PAGES.length} GM pages, ${S.STARTER_HANDOUTS.length} handouts`);
}
// The player primer. Its own pack, OBSERVER by default, because it is the one thing
// in the system written to be handed to players before anybody has read a rule.
{
const { PRIMER_PAGES } = await import("./primer-text.mjs");
const j = journalDoc("The Custodians — Read this first", "primer", PRIMER_PAGES);
j.doc.ownership = { default: 2 };
j.doc.flags = { ringbrp: { brand: true } };
for (const pg of j.pages) pg.ownership = { default: 2 };
const entries = [[`!journal!${j.id}`, j.doc]];
for (const pg of j.pages) entries.push([`!journal.pages!${j.id}.${pg._id}`, pg]);
await writePack("primer", entries);
console.log(` primer ${PRIMER_PAGES.length} pages, player-visible`);
}
// the slice journal
{
const { SLICE_PAGES } = await import("./slice-text.mjs");
const j = journalDoc("The Ilfracombe Crossing", "slice", SLICE_PAGES);
j.doc.flags = { ringbrp: { brand: true } };
const entries = [[`!journal!${j.id}`, j.doc]];
for (const p of j.pages) entries.push([`!journal.pages!${j.id}.${p._id}`, p]);
await writePack("slice", entries);
@@ -693,6 +709,7 @@ await writePack("armour", ARMOURS.map(a => {
{
const { RULES_PAGES } = await import("./rules-text.mjs");
const j = journalDoc("The Custodians — Rules", "rules", RULES_PAGES);
j.doc.flags = { ringbrp: { brand: true } };
const entries = [[`!journal!${j.id}`, j.doc]];
for (const p of j.pages) entries.push([`!journal.pages!${j.id}.${p._id}`, p]);
await writePack("rules", entries);
+502
View File
@@ -0,0 +1,502 @@
/**
* THE PLAYER PRIMER — what a new player reads before session one.
*
* Written to be handed out, not studied. Eight pages of setting and one cheat sheet,
* and the cheat sheet's numbers are computed from rules.mjs at build time so the card
* a player holds cannot say one thing while the engine does another. That failure has
* happened five times in this project; a hand-typed cheat sheet would have been the
* sixth, and it would have been the one the players were looking at.
*
* On the voice: the source draft called the organisation "the Bureau". Everything else
* in the game — twelve rules pages, the starter, every localisation string — calls it
* "the department" or "the agency", so that is what it is called here. One name.
*/
// TRADE_BANDS and INDUCTION are register data, not rules — they live in postings.mjs.
import { TRADE_BANDS, INDUCTION } from "../postings.mjs";
import {
hitPointsFor, majorWoundFor, damageModifierFor, defencePenaltyFor,
COHERENCE_BANDS, RANGE_LADDER, CIRCUMSTANCE
} from "../rules.mjs";
import { readFile } from "node:fs/promises";
// Labels come from the language file, the same way the rulebook generator reads them,
// so the primer prints what the sheet prints rather than a prettified lang key.
const LANG = JSON.parse(await readFile(new URL("../lang/en.json", import.meta.url), "utf8"));
const L = key => LANG[key] ?? key;
const P = s => `<p>${s}</p>`;
const H = (n, s) => `<h${n}>${s}</h${n}>`;
const UL = xs => `<ul>${xs.map(x => `<li>${x}</li>`).join("")}</ul>`;
const OL = xs => `<ol>${xs.map(x => `<li>${x}</li>`).join("")}</ol>`;
const TABLE = (head, rows) =>
`<table><thead><tr>${head.map(h => `<th>${h}</th>`).join("")}</tr></thead><tbody>`
+ rows.map(r => `<tr>${r.map(c => `<td>${c}</td>`).join("")}</tr>`).join("")
+ `</tbody></table>`;
const READ = s => `<blockquote class="rb-readaloud">${s}</blockquote>`;
const FORM = s => `<div class="rb-form">${s}</div>`;
const STAMP = s => `<div class="rb-stamp">${s}</div>`;
const CLOSER = s => `<p class="rb-closer">${s}</p>`;
const CARDS = xs => `<div class="rb-cards">${xs.map(([t, b]) =>
`<div class="rb-card"><h4>${t}</h4>${b}</div>`).join("")}</div>`;
const TWO = s => `<div class="rb-two">${s}</div>`;
const pc = n => `${n > 0 ? "+" : ""}${n}%`;
/* Worked from the same functions the sheet uses, so the primer cannot drift. */
const EG = { con: 12, siz: 13, str: 12 };
const egHp = hitPointsFor(EG.con, EG.siz);
const egMw = majorWoundFor(egHp);
const egDm = damageModifierFor(EG.str + EG.siz);
export const PRIMER_PAGES = [
/* ---------------------------------------------------------------- */
{ name: "1 · You live in the 2020s", content:
FORM("Departmental Custody Service · <b>Induction pack</b><br>"
+ "Issued to: <b>new entrant</b> · Retain for the duration of service<br>"
+ "Classification: <b>restricted</b> — not for disclosure outside the department")
+ P("You have a phone, an email address, a National Insurance number and opinions "
+ "about the quickest way through a self-service checkout. You have had jobs, "
+ "relationships, debts, disappointments and plans. You know the world is troubled "
+ "and frequently absurd, but you have never had reason to believe it is "
+ "<em>unreal</em>.")
+ P("<strong>You are not a wizard, a superhero or a chosen anything.</strong> You "
+ "cannot see ghosts merely because they are ghosts. Bullets hurt you. A locked door "
+ "stays locked until somebody opens it. If something with too many teeth reaches "
+ "you, your advantages are whatever you brought, whoever is standing beside you, "
+ "and the chance that somebody made a plan.")
+ P("Until recently you thought that was all there was.")
+ CLOSER("Then the department found you.")
},
/* ---------------------------------------------------------------- */
{ name: "2 · The problem", content:
P("Reality is not as single or as steady as people assume.")
+ P("There are other versions of the world — places where a different decision was "
+ "taken, where history bent another way, where life developed under other "
+ "conditions, or where the rules themselves settled into a shape impossible here. "
+ "Most stay separate. Occasionally two of them touch.")
+ P("The department calls that contact a <strong>crossing</strong>.")
+ P("A crossing might be a door that opens into the wrong building, a road that "
+ "arrives in a town on no map, or a train running between stations in different "
+ "histories. It might be no wider than the gap under a filing cabinet. It might "
+ "last three seconds or stand open for generations.")
+ H(2, "Crossings do not merely join places. They create contradictions.")
+ TWO(UL([
"Two people hold the same birth certificate.",
"A house stands on ground where its foundations were never laid.",
"A dead soldier comes back to a family that remembers burying him.",
"Rain obeying somebody else's physics falls on a field in Yorkshire.",
"A person goes on living while banks, government systems and their own relatives stop being able to remember them."
]))
+ H(2, "The record")
+ P("The department's oldest surviving explanation is that "
+ "<strong>reality keeps a record of what belongs where</strong>.")
+ P("It is not a literal ledger held by a god or a machine. It is the closest language "
+ "anyone has found for how the world behaves. People, places and events have an "
+ "identity: they are recognised by other people, by physical history, and by the "
+ "thousand systems around them.")
+ P("<strong>A crossing introduces something the record cannot file.</strong> Sometimes "
+ "reality rejects the intrusion. Sometimes it alters the intruder. Sometimes it "
+ "alters everything around the intruder until the contradiction goes away. None of "
+ "those is reliably safe.")
+ P("The department uses the theory of crossings because it is useful, not because it "
+ "has been proved. A door into another history is plainly a crossing. A dead woman "
+ "appearing in mirrors, a family curse that outlives the family, or one dream shared "
+ "by six strangers is less easily explained.")
+ P("The old hands avoid the argument. Whatever a thing <em>is</em>, the questions are "
+ "the same:")
+ READ("What does it do? What makes it worse? What is holding it here? And how do we "
+ "stop it reaching anybody else?")
},
/* ---------------------------------------------------------------- */
{ name: "3 · Why the department exists", content:
P("Because crossings have always happened.")
+ P("Its offices today are fluorescent-lit government rooms with grey carpet, "
+ "computers and secure cabinets. Its predecessors worked out of royal "
+ "observatories, monasteries, counting houses, naval offices and rooms behind "
+ "temples. Some job titles are modern. Others have lasted centuries because nobody "
+ "has ever been confident enough to say the old work has stopped being necessary.")
+ P("Governments have renamed it, split it, hidden it inside other departments and "
+ "occasionally tried to abolish it. The work carried on under every arrangement. "
+ "<strong>An organisation can be closed. The next impossible doorway still has to be "
+ "dealt with.</strong>")
+ P("Nobody knows when it began. The archive holds Roman inventories of objects that "
+ "cast shadows the wrong way, medieval payments to wardens of roads that did not "
+ "always reach the same county, and eighteenth-century orders about a lighthouse "
+ "visible only to ships already lost. The oldest documents refer to earlier "
+ "custodians as though <em>they</em> were ancient.")
+ P("So the department has reached an institutional conclusion: <strong>the job did not "
+ "begin with us, and it will not end with us.</strong>")
+ H(2, "What it cannot do")
+ UL(["Permanently repair reality.",
"Find every crossing before somebody walks through one.",
"Destroy everything that arrives — many arrivals are people, and destruction can make a contradiction worse.",
"Tell the public, which would create panic, exploitation and, worst of all, millions of new records confirming that the anomaly exists."])
+ H(2, "What it does instead")
+ UL(["Finds crossings and charts where they go.",
"Contains things that should not be here.",
"Recovers people who have gone somewhere else.",
"Negotiates with whoever lives on the other side.",
"Closes routes that have become dangerous.",
"Leaves alone the arrangements that are safer undisturbed.",
"Alters, suppresses or completes records until reality has one answer it can accept.",
"Comes back tomorrow, when yesterday's solution starts to fail."])
+ CLOSER("This is not a war that can be won. It is maintenance, on an impossible scale.")
},
/* ---------------------------------------------------------------- */
{ name: "4 · What is out there", content:
P("The things the department deals with are not one species and share no single "
+ "origin. Some are hostile. Some are animals behaving normally somewhere humans are "
+ "unprepared. Some are people defending their homes. Some are processes with no "
+ "intention at all. A few are mistakes left by the department's own earlier attempts "
+ "at containment.")
+ P("<em>Agents use rough, unofficial categories. None of them is in the manual.</em>")
+ H(2, "Things that crossed")
+ P("A predator following prey through a temporary opening. A growth spreading through "
+ "concrete because concrete does not exist where it evolved. A person who crossed by "
+ "accident and is dangerous only because our world is poison to them — or because "
+ "their being here is erasing somebody else.")
+ H(2, "Echoes, duplicates and revenants")
+ P("A crossing sometimes repeats a person instead of moving them. Two adults with the "
+ "same childhood, the same fingerprints and twenty years of different memories. A "
+ "dead porter who walks the same platform every night. A rescue beacon transmitted "
+ "by the teammate they watched die.")
+ P("<strong>The department does not assume a duplicate is false or a revenant "
+ "mindless.</strong> Working out what something is — and whether it can be talked "
+ "to — is the job.")
+ H(2, "Wrong places")
+ P("A government office with a thirteenth floor reachable only during fire alarms. A "
+ "village on two maps in different counties that physically moves each winter. A "
+ "service tunnel that gets longer every time somebody is sent to measure it.")
+ P("<strong>Wrong places are the dangerous ones, because people relax indoors.</strong> "
+ "A room with a kettle, a desk and a fire-exit sign goes on feeling ordinary long "
+ "after it has stopped obeying the rules.")
+ H(2, "Objects and procedures")
+ P("A brass date stamp that makes a document true from the date applied. A field "
+ "medical unit that heals a wound by moving it to the nearest blood relative. A road "
+ "sign that creates the place printed on it.")
+ P("Much of this is <strong>transposed materiel</strong>. Agents are trained not to "
+ "operate unfamiliar equipment merely because it has a switch.")
+ H(2, "People with an interest")
+ P("The department is not the only one that knows. Collectors buy things that should "
+ "never be catalogued. Contractors called <strong>Cleaners</strong> remove evidence "
+ "for employers whose names rarely survive an investigation. Rival offices bury "
+ "incidents to protect budgets and careers. People living beside a stable crossing "
+ "may depend on it for food, medicine or faith.")
+ P("They use guns, vehicles, surveillance, lawyers, political weight and correct "
+ "paperwork. <strong>They are usually more immediately dangerous than the "
+ "anomaly.</strong>")
+ H(2, "The dead who do not leave")
+ P("The department does not use <em>ghost</em> as a classification, because the word "
+ "assumes an answer. A haunting might be a surviving person, an impression left on a "
+ "place, somebody displaced from a history where they did not die, or a pattern that "
+ "has learned to do what the dead are expected to do.")
+ P("<strong>The distinction matters.</strong> A surviving person can be reasoned with. "
+ "A recording can only repeat. A predatory pattern may imitate grief because grief "
+ "brings people close enough.")
+ H(2, "Curses and inherited conditions")
+ P("A curse is rarely a beam of hostile energy. It is usually <strong>a rule the world "
+ "has started enforcing</strong>. The eldest child stays in the house. Every owner of "
+ "a certain ring dies before their spouse. Nobody appointed keeper of a particular "
+ "bridge leaves the county alive.")
+ P("These last centuries because generations of observance have made them part of the "
+ "record. Breaking the ritual may end it, release whatever it was holding down, or "
+ "move the obligation onto whoever interfered.")
+ P("<em>This is where well-meaning agents do the most damage. An old practice can look "
+ "cruel or absurd and still be the only thing standing under something worse.</em>")
+ H(2, "Ideas that travel")
+ P("Not everything needs a body. A dream can spread between sleepers. A proof can "
+ "describe a geometry that starts appearing around anyone who understands it. An "
+ "image can gain detail with every copy until it shows the room the copy is being "
+ "viewed in.")
+ P("The department files these as <strong>cognitive anomalies</strong>, which does "
+ "nothing whatever to make them safer. It is also why publicity is feared: attention "
+ "does not only expose an anomaly, it can feed one, define one, or hold one steady.")
+ H(2, "The things behind the stories")
+ P("There is no official category for gods, because an official category implies "
+ "somebody knows what is being categorised. Agents say <strong>mythos case</strong> "
+ "for incidents that repeat the same shapes across cultures that never met: the "
+ "sleeper under the water, the king whose face must not be seen, the black star, the "
+ "messenger who arrives before the disaster.")
+ P("Some intelligences appear to work on a scale that makes individual people hard to "
+ "notice. Their smallest movements can produce religions, extinctions or changes in "
+ "physical law.")
+ P("<strong>They are not necessarily evil.</strong> A storm is not evil. That is little "
+ "comfort to anyone underneath one.")
+ P("These are not solved by learning a creature's statistics and shooting it. Agents "
+ "break the ritual, clear the site, spoil the alignment, close the route, deal with "
+ "an intermediary, or stop the thing becoming present enough to act. Sometimes the "
+ "most that can be done is to reinforce an old containment and leave instructions "
+ "for the next generation.")
+ H(2, "UNRESOLVED")
+ STAMP("Unresolved")
+ P("Some files defeat every model. There are rooms that cannot be entered twice by the "
+ "same route, photographs of agents who never existed, and one recurring Wednesday. "
+ "There are procedures whose purpose has been forgotten, because every attempt to "
+ "write the purpose down removes another person from the archive.")
+ P("<strong>Unresolved does not mean harmless.</strong> It means somebody found a "
+ "procedure that limits the damage without understanding why it works. A Night "
+ "Warden checks the seal. A Registry Officer confirms the names. Somebody replaces "
+ "the bowl of milk, winds the silent clock, reads the number over a secure line, or "
+ "leaves one chair empty.")
+ CLOSER("Some of these duties are older than the explanation for them.")
},
/* ---------------------------------------------------------------- */
{ name: "5 · Why you were recruited", content:
P("Most people cannot work near crossings for long.")
+ P("They look past contradictions. Their memories quietly update to match the latest "
+ "version of events. They are violently sick, or they dissociate, or they accept an "
+ "impossibility as though it had always been ordinary. This is usually protective. "
+ "Human minds are very good at continuing to function.")
+ P("<strong>Yours does something slightly different.</strong>")
+ P("The department calls it <strong>continuity</strong>, and does not know what it is.")
+ P("It runs in families without following any clean pattern of inheritance. Certain "
+ "genetic markers turn up more often among agents and none of them predicts it. "
+ "Older texts describe an unusually <em>well-seated</em> soul. A twentieth-century "
+ "programme argued that consciousness leaves a persistent informational pattern. "
+ "Another department maintains that continuity is simply social: people who are "
+ "strongly remembered resist being rewritten.")
+ P("All of those have been tested. None of them explains everybody.")
+ H(2, "What it means in practice")
+ UL(["When reality changes its account, <strong>you are more likely to remember the previous one</strong>.",
"You can go through a controlled crossing and come back recognisably yourself.",
"You can look at two mutually exclusive facts and know the contradiction is real."])
+ H(2, "What it is not")
+ P("<strong>It is not a superpower.</strong> You are not immune to crossings. You can "
+ "still be hurt, altered, duplicated, forgotten or killed. Continuity does not make "
+ "you stronger, faster or braver, and it does not tell you what an anomaly is or how "
+ "to stop it.")
+ P("It means that when the impossible happens you have a better chance of noticing — "
+ "and of carrying the memory home.")
+ CLOSER("For the department, that is enough to build a career on.")
},
/* ---------------------------------------------------------------- */
{ name: "6 · Who your character is", content:
P("A modern adult whose life was recognisable before the department turned up in it.")
+ P("They might have been a doctor, an engineer, an academic, a police officer, a "
+ "construction worker, an IT specialist, a scientist, a linguist, an entertainer or "
+ "a criminal. They might already have served inside the department in one of its "
+ "field postings. Either way, <strong>what they are good at comes from work, "
+ "education and hard practice — not from destiny.</strong>")
+ P("A doctor is valuable because they are a doctor. A burglar understands locks "
+ "because they spent years opening them. A builder can tell when a building is "
+ "carrying weight wrongly. A police officer can control a dangerous scene. An "
+ "entertainer can hold a room that does not want to listen.")
+ H(2, "The department adds six weeks")
+ P("Induction: how to recognise a crossing, set a cordon, handle evidence, fire the "
+ "issued weapon, survive first contact, and complete the forms that will very likely "
+ "matter more than the gun.")
+ P("<strong>Six weeks does not make anybody an expert.</strong> A surgeon recruited "
+ "last year is still a far better surgeon than they are a Custodian. Your prior "
+ "career sits at <strong>"
+ `${TRADE_BANDS.core[0]}–${TRADE_BANDS.core[1]}%</strong>; what the department taught `
+ `you starts at <strong>${INDUCTION.bandByTier.probationary[0]}–`
+ `${INDUCTION.bandByTier.probationary[1]}%</strong> and climbs with service.`)
+ P("<em>That gap is deliberate, and it is often frightening.</em>")
+ H(2, "Why you stayed")
+ P("You may have come willingly. You may have survived an incident and learned too "
+ "much to be let go. You may have been offered a choice whose other option was not "
+ "really a choice. The department might have lost a conviction, saved a relative, "
+ "bought out a contract, or simply been the first organisation to believe you.")
+ P("<strong>You were not picked for being fearless.</strong> You were picked because "
+ "you can remember, because you have a useful human skill, and because there are "
+ "never enough people for the work waiting tomorrow morning.")
+ H(2, "Seven principles for building one")
+ OL(["<strong>You are from the 2020s.</strong> Your references, assumptions, work and relationships are contemporary.",
"<strong>You had a life before this.</strong> Decide what you did well enough that the department needed it.",
"<strong>You are not superhuman.</strong> Training, teamwork, equipment and preparation keep you alive.",
"<strong>Continuity is not immunity.</strong> You notice changes better than most people. The changes can still happen to you.",
"<strong>You have a reason to stay.</strong> Duty, debt, curiosity, guilt, protection, belonging — or knowing nobody else will do it.",
"<strong>You need the team.</strong> No posting or trade holds every answer. The lowest-ranked person in the room is often the only real expert in it.",
"<strong>Decide what anchors you.</strong> Name a person, an object, a place or a habit that reminds you who you are when the records disagree."])
+ CLOSER("The department does not need heroes. It needs people who will turn up, look "
+ "straight at the impossible, and still remember what they came to do.")
},
/* ---------------------------------------------------------------- */
{ name: "7 · What the job is actually like", content:
P("An operation usually starts with an incomplete briefing.")
+ P("Something has happened. A person has vanished without leaving. An impossible "
+ "object has been recovered. An old containment has failed. Somebody has opened a "
+ "route that was supposed to stay shut. You get an objective, equipment, a cover "
+ "story, and as much authority as the department is willing to admit it sent.")
+ P("<strong>Then you find out what the briefing got wrong.</strong>")
+ P("You may go through records, talk to witnesses, cross into another version of the "
+ "world, walk unfamiliar ground, make contact with whoever lives there, recover "
+ "people or evidence, and contain what is immediately dangerous. Sometimes that is a "
+ "negotiation. Sometimes it is engineering. Sometimes it is holding a corridor with a "
+ "rifle and a shield while somebody else repairs the only way home.")
+ P("Fighting is dangerous and often unavoidable, and <strong>killing is rarely the "
+ "whole answer</strong>. Shoot the creature and the crossing that produced it is "
+ "still open. Beat the Cleaner and the evidence they came for is still lying there. "
+ "Cases close when there is an answer reality can live with.")
+ H(2, "Then you come home")
+ P("You are debriefed. Reports are compared. Equipment goes back, except what was "
+ "lost, broken, or has started behaving differently. The department decides what "
+ "officially happened. You get rest, if the schedule allows it.")
+ CLOSER("Sooner or later, another file arrives.")
},
/* ---------------------------------------------------------------- */
{ name: "8 · What it costs", content:
P("Crossing leaves a mark.")
+ P("The department measures your connection to your own reality as "
+ "<strong>Coherence</strong>. It is not sanity and it is not morality. "
+ "<strong>It is how firmly the world still recognises that you belong here.</strong>")
+ P("Watching a paradox, using transposed equipment, breaking the physics of another "
+ "place, meeting another version of yourself, or coming back badly all cost you some.")
+ H(2, "How it goes")
+ TABLE(["Coherence", "Where you are", "What it costs you"],
COHERENCE_BANDS.map((b, i) => {
const hi = i === 0 ? 10 : COHERENCE_BANDS[i - 1].min - 1;
const eff = [b.all ? `${pc(b.all)} to everything` : "",
b.comm ? `${pc(b.comm)} to talking to people` : ""]
.filter(Boolean).join(", ") || "nothing yet";
return [b.min === hi ? `${b.min}` : `${hi}–${b.min}`,
`<strong>${L(b.label)}</strong>`, eff];
}))
+ P("Early on it is small. People mishear your name. Automatic doors hesitate. You look "
+ "very slightly misplaced in photographs. Later, records disagree about where you "
+ "work, people you love remember conversations differently, and the way home gets "
+ "harder to aim.")
+ H(2, "Getting it back")
+ P("Debriefing, time at home, and contact with people or things that remember you "
+ "correctly. <strong>Recovery is slow, and some of it never entirely comes back.</strong>")
+ P("<em>This is what the anchor in your kit is for. It is one object that remembers "
+ "you as you were, and it is the cheapest medicine the department issues.</em>")
+ H(2, "The Quiet Arts")
+ P("Very occasionally a crossing leaves somebody able to commit a small impossibility "
+ "on purpose. The department calls these the <strong>Quiet Arts</strong>.")
+ P("They are not gifts. They cannot be chosen at recruitment. They always cost more of "
+ "your connection to the world. Most Custodians never get one.")
+ P("<strong>Those who do are not superheroes. They are injured people who have learned "
+ "how to press on the injury.</strong>")
},
/* ---------------------------------------------------------------- */
{ name: "9 · The whole game, in one page", content:
P("You play ordinary adults in the modern world, pulled into an old and quiet "
+ "government department because you stay yourselves when reality goes wrong.")
+ P("You investigate crossings, recover the lost, face hostile people and impossible "
+ "things, negotiate with strangers, contain what can be contained, and decide what "
+ "the official record will say happened. You carry modern tools, professional "
+ "experience, an issued weapon and incomplete information.")
+ P("You can fight. Violence alone rarely closes a case. You can come home, and every "
+ "journey makes home a little less certain.")
+ P("The department has done this for longer than any living government, and expects to "
+ "carry on long after you.")
+ READ("There will always be another file.<br>"
+ "There will always be something here that should not be.<br>"
+ "<strong>Your job is to go there and sort it out.</strong>")
},
/* ---------------------------------------------------------------- *
* The cheat sheet. Numbers computed, not typed.
* ---------------------------------------------------------------- */
{ name: "10 · Cheat sheet — everything you need at the table", content:
FORM("Departmental Custody Service · <b>Field aide-memoire</b><br>"
+ "One page. Keep it beside you. <b>You do not need to read anything else to play.</b>")
+ H(2, "The only rule you must know")
+ P("<span class=\"rb-big-number\">Roll 1d100. Get equal to or under your skill.</span>"
+ "That is the whole engine. Everything else changes that number, or is a consequence "
+ "of the result.")
+ TABLE(["Roll", "Result", "What it means"], [
["01 to a twentieth of your skill", "<strong>Critical</strong>", "the best version of what you wanted"],
["up to a fifth of your skill", "<strong>Special</strong>", "notably better than you asked for"],
["up to your skill", "<strong>Success</strong>", "you do it"],
["over your skill", "Failure", "you do not"],
["the top 5% of the range", "<strong>Fumble</strong>", "you do it wrong, and it costs"]
])
+ P("<strong>The sheet prints those four numbers next to every skill</strong>, so you "
+ "never work them out. A skill of 60 criticals on 01–03, specials to 12, succeeds to "
+ "60 and fumbles on 98–00.")
+ P("Two rules beat everything: <strong>96–99 never succeed</strong>, however good you "
+ "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%.")
+ H(2, "The three things that change your number")
+ CARDS([
["1 · Difficulty",
P("Set by the GM <em>before</em> you roll.")
+ P("<strong>Easy</strong> doubles your skill. <strong>Average</strong> leaves it "
+ "alone. <strong>Difficult</strong> halves it.")],
["2 · Circumstance",
P("The situation, up to " + pc(Math.max(...CIRCUMSTANCE.map(c => c.mod)))
+ " in your favour or " + pc(Math.min(...CIRCUMSTANCE.map(c => c.mod))) + " against.")
+ P("At most <strong>two</strong> apply — the best good one and the worst bad one — "
+ "and they cap at ±40%.")],
["3 · Your condition",
P("Wounds, what you are carrying and how far you have drifted.")
+ P("<strong>All three are already on your sheet and applied for you.</strong>")]
])
+ P("<em>Difficulty first, then everything else. A skill can never drop below 1%.</em>")
+ H(2, "Fighting")
+ CARDS([
["Who goes first",
P("<strong>Reaction</strong>, re-rolled every round including the first. Your "
+ "printed number plus a d6. Highest acts first.")],
["Attacking",
P("Roll the weapon's skill. The dialog asks you the questions — range, cover, "
+ "light, how you are firing — and does the arithmetic.")],
["Defending",
P("<strong>The first defence each round is free.</strong> Each one after that is "
+ pc(defencePenaltyFor(1)) + ", then " + pc(defencePenaltyFor(2)) + ".")],
["Defences are graded",
P("A defence stops a blow of its own quality or worse. Against something better it "
+ "only drags it down a step.")
+ P("<strong>Only a critical stops a critical.</strong>")]
])
+ TABLE(["Range", "To hit"],
RANGE_LADDER.map(r => [L(`RINGBRP.Range.${r.id}`),
r.mod === null ? "<em>cannot be hit at all</em>" : pc(r.mod)]))
+ H(2, "Getting hurt")
+ CARDS([
["Hit points",
P("<span class=\"rb-big-number\">" + egHp + "</span>"
+ "(CON + SIZ) ÷ 2, rounded up. The example agent has CON " + EG.con + " and SIZ "
+ EG.siz + ".")],
["Major Wound",
P("<span class=\"rb-big-number\">" + egMw + "</span>"
+ "Half your hit points in <strong>one blow</strong>. Roll on the table and "
+ "something specific and lasting happens.")],
["Damage modifier",
P("<span class=\"rb-big-number\">" + egDm + "</span>"
+ "STR + SIZ on a ladder. Added to what you hit with.")],
["Zero hit points",
P("<strong>You are dying, not dead.</strong> A clock starts, equal to your Major "
+ "Wound number, and somebody can still reach you.")]
])
+ P("Damage comes off your total <strong>and</strong> the part of you it hit. Armour "
+ "only counts where it actually covers — a helmet does nothing for your legs.")
+ H(2, "Coherence")
+ P("Ten down to zero. How firmly the world still agrees you belong here. It is not "
+ "sanity. Spend it, lose it to paradoxes, get it back slowly at home.")
+ P("<strong>Watch the band, not the number</strong> — the sheet tells you which one you "
+ "are in and what it is costing you.")
+ H(2, "Six things new players ask")
+ TABLE(["", ""], [
["<strong>Can I just shoot it?</strong>",
"Often, yes. It rarely closes the case — the crossing that produced it is still open."],
["<strong>What do I roll?</strong>",
"Say what you are doing. The GM names the skill. It is on your sheet with its four numbers beside it."],
["<strong>I failed. Now what?</strong>",
"The story continues. A failure costs time, resources, position or clarity — it does not stop the case."],
["<strong>Can I help somebody?</strong>",
"Yes, and you should. Assisting is a real action, and most specialist scenes want a second pair of hands."],
["<strong>My skill is 5%. Why?</strong>",
"You are untrained at it. That is honest, not a mistake. Ask whoever <em>is</em> trained."],
["<strong>Am I going to die?</strong>",
"You can. You are much more likely to come home changed, out of pocket, and holding a form you do not want to sign."]
])
+ CLOSER("Roll under. Ask questions. Take the shield.")
}
];