12 Commits
Author SHA1 Message Date
slaguru666andClaude Opus 5 c05d488b9d Play THROUGH TRAIN end to end; fix three scenes the dice could not reach
Ran the whole case with five agents — briefing, kit swap, Mercy Draw, the
crossing, Cordera, the water tower, the four minutes, the locomotive, the
brakes, the register. It completed, and it exposed one serious defect and two
smaller ones. All three are the same shape: the scenario put dice in front of
things the fiction says should simply happen.

  1. THE ENDING WAS UNREACHABLE. Convincing Kell to stop was gated on Persuade
     or Status. Only THREE of the ten duty-roster agents have either trained —
     the Field Lead has Persuade 15 and Status 15. Three separate attempts on
     him all failed and the scenario had no way to end; I narrated it as working
     because otherwise there was no ending, which is exactly the improvisation a
     scenario is supposed to prevent.

     It now takes NO ROLL if the party can tell Kell one true thing about his own
     train — the coal, the four minutes, the north bench. He has waited a
     hundred and forty-five years for somebody who knows what is wrong to say
     so. Only a party that knows nothing rolls, at average, and a failure costs
     a pass rather than the ending.

  2. THE PAPERS NEEDED PERSUADE. Showing one side the other's document is the
     move the scenario is proudest of, and it sat behind Persuade at −30%:
     15% became 1%. Both men WANT their paper read — Merritt is proud of his
     letter, Ambrose's whole grievance is that nobody has looked at his writ.
     No roll, between passes.

  3. THE BOARDING SORTED ITSELF BADLY. Athletics runs 25–60% across the roster.
     Putting all five on the water tower left the doctor and the Registry officer
     missing twice each, carried across on the department's plank, passengers in
     their own set-piece, two passes gone. The text now names which route suits
     whom before they choose.

What worked, and is worth recording: the automatic clues absorbed two failed
rolls in Act One without the trail stalling; Standing Order 9 caught Okonkwo
checking a wristwatch in an 1881 street and cost a Coherence and a clock tick;
the fumble-is-a-fall rule fired on a 100 at the tower for five damage; the
exposure table held — one hit across the whole fight, on the agent who left
cover to work on Doyle; and the reset landed exactly as intended, with Doyle
standing up at the top of the car and Nkemdirim's hit staying where it was.

Version 1.4.1.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-18 22:34:23 +01:00
slaguru666andClaude Opus 5 391c6a3679 Rebuild packs cleanly
LevelDB LOG timestamps only — no content change. Committed so the tree matches a
build made with Foundry stopped, which is the state the published archive should
carry.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-18 22:27:52 +01:00
slaguru666andClaude Opus 5 5bc46879c7 THROUGH TRAIN: put the gunfight inside the loop
The loop is no longer just a train going round. It is FOUR MINUTES with a
gunfight in it, and those four minutes have run about two million times.

Two sides, and neither is the villain. Cass Merritt and three hired men have a
signed letter of engagement from the company telling them to surrender the
payroll to nobody "upon any pretext whatsoever, including the presentation of
any paper". Josiah Ambrose and four Cordera miners have a sealed writ of
attachment from a territorial judge naming that same payroll against eleven
weeks of unpaid wages. Both documents are genuine. Neither man has seen the
other's. The company sold the same money twice and is not aboard. Both are
handouts now, and they only mean anything held in the same hand.

The mission is restated everywhere it appears — briefing page, case brief, GM
essentials: CLOSE IT. Closing the crossing does not kill anybody, it lets them
go. The loop ends, 3 November 1881 resumes from 11:44, and everyone aboard walks
forward into the life they would have had — including Ambrose and a miner named
Doyle, who did not survive the real four minutes. The agents are not choosing
who lives; they are choosing to stop holding history still, having met the
people it lands on.

The standing order is louder in three places: NOTHING MODERN GOES THROUGH.

Two things the playtest found, both now written into the text:

  - THE FIGHT WAS A TPK GENERATOR. Modelled with all nine shooters firing at the
    party it wiped them 171 times in 200. The model was wrong — the two sides
    are shooting at EACH OTHER — so the scenario now carries a measured exposure
    table: head down draws nothing, acting from cover costs half a party member
    and no wipes over 300 runs, moving in the open costs one, and going for the
    payroll box wipes more than half of all parties. The GM is told to say that
    last row out loud BEFORE anybody commits to it.

  - THE LOOP RESETS AND THE AGENTS DO NOT. Everyone who belongs to 1881 is back
    up unhurt on the next pass. The agents keep their wounds. Nothing said so,
    and a player who assumes otherwise takes risks that kill them. It is now
    stated in capitals in both documents, with the played pass that prompted it
    — two of four down at 0 hit points, one untouched because he stayed on the
    floor.

Nine cast members, eight handouts, ten plates.

Version 1.4.0.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-18 22:20:00 +01:00
slaguru666andClaude Opus 5 774e95dea8 THROUGH TRAIN — the crossing starter, and two more playtest fixes
A train has been running the same four miles of track since 3 November 1881. It
has never stopped. Sixty-one people are aboard and all of them are alive.

The supernatural is science fiction underneath, which is the point. A railway
company laid track to a filed survey instead of to the ground, then described the
line as being where it was not — ten thousand times, in timetables and waybills
and telegraph orders, for six months, at a constant speed. Reality keeps a record
of what belongs where, and that is the most patient argument there is. The record
gave way with No. 9 inside it, and the RUNNING is what holds the crossing open: a
standing wave sustained by its own motion the way a bow sustains a note.

So the players must board a train that cannot stop, and then find that sixty-one
people at six passes is four hours they do not have. The ending is what they
choose to do about arithmetic they cannot beat, in front of a conductor holding
the manifest. No roll resolves it.

It is also the case that teaches Standing Order 9: NOTHING MODERN GOES THROUGH.
Using a modern object across costs a Coherence and moves the clock; carrying it
costs nothing; anchors are exempt. The catalogue already had the 1881 issue —
Webley, Martini-Henry, oil lantern, brass compass, pocket watch, buff coat,
laudanum — so the constraint needed no new content and gives the table something
to feel in the first twenty minutes.

Ships as docs/scenarios/THROUGH_TRAIN.md and a Foundry Adventure: 7 GM pages, 6
handouts including the survey that started it and a manifest deliberately held
back to Act Three, the case file at Containment 6 / Resources 5 / Clearance 4
with its four legs loaded, four of the people aboard, and 7 plates to show.

Two fixes from playing it:

  - Bhattacharya's RESEARCH was 1%. Placing the new skills last night I swapped
    Occult into Registry's core and took Research out — a records office that
    cannot look anything up. Research is back in core; Antiquities gets its
    Knowledge back the same way.

  - The boarding was unrunnable. At halved skill the set-piece put four agents on
    12–31% and all four missed, which stalls the one scene the scenario is built
    around. It is an average roll now and only a fumble is a fall, with the
    reason written into the text so nobody "fixes" it back. Re-run: 3 of 4 aboard
    on the first pass and one waiting for the next, which is the shape it wanted.

Version 1.3.0.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-18 00:25:19 +01:00
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
69 changed files with 2382 additions and 384 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
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
@@ -47,7 +48,7 @@ packs or regenerate art on that machine:
```bash
npm install # pulls classic-level, used to write the LevelDB packs
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
@@ -64,13 +65,13 @@ changing underneath a running server.
| **Postings** | 25, each with a key characteristic, trained skills, talents and its own kit |
| **Trades** | 10 ordinary jobs the department recruits from, plus induction |
| **Catalogue** | 236 items — 123 gear, 62 weapons, 32 armour, 19 vehicles |
| **Skills / talents** | 59 skills, 19 talents |
| **Skills / talents** | 62 skills, 19 talents |
| **Bestiary** | 21 statblocks across folklore, horror and the far side |
| **Eras** | modern, antique, future, anomalous — the agency is old and never abolishes anything |
| **Icons** | 292, all generated from `tools/icons.mjs`; no external art |
| **Starter scenario** | **BLACK PLATFORM**, a six-player action starter, importable as a single Adventure |
| **Compendia** | pregens, NPCs, skills, weapons, armour, gear, vehicles, powers, tables, rules, the starter |
| **Rules journal** | 12 pages, generated from `rules.mjs` so the book cannot drift from the code |
| **Rules journal** | 13 pages, generated from `rules.mjs` so the book cannot drift from the code |
<!-- box:end -->
---
@@ -152,13 +153,12 @@ template.json data model for every Actor and Item type
templates/ Handlebars sheets
styles/ one stylesheet
lang/en.json all user-facing text
icons/ dice/ fonts/ generated art
icons/ fonts/ art/ generated art
packs/ built LevelDB compendia (committed — see Deploying)
tools/
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-dice-textures.mjs
rules-text.mjs generates the rules journal FROM rules.mjs
mission.mjs the case generator
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
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 -->
```
check-rules: OK — 87 rules, 21 files scanned, 3 aliases + 41 constant sets checked, 275 formulas verified
check-rules: OK — 92 rules, 21 files scanned, 3 aliases + 45 constant sets checked, 291 formulas verified
check-kits: OK — 25 roles, 10 trades, 218 catalogue items, every kit key resolves, every posting can use what it carries, every loadout distinct
check-lang: OK — en.json, 945 keys, no leaf/branch collisions
check-behaviour: OK — 45 behavioural tests
check-lang: OK — en.json, 981 keys, no leaf/branch collisions
check-behaviour: OK — 59 behavioural tests
```
<!-- guards:end -->
Binary file not shown.

After

Width:  |  Height:  |  Size: 142 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 94 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 128 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 96 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 67 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 92 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 93 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 110 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 81 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 149 KiB

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
npm install # classic-level, for writing LevelDB
npm run check # the three guards
npm run check # the five guards
npm run build # rebuilds packs/
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
+543
View File
@@ -0,0 +1,543 @@
# Crossing starter — The Custodians: THROUGH TRAIN
> *A train has been running the same four miles of track since the third of November,
> 1881. It has never stopped. There are sixty-one people on it, nine of them are shooting
> at each other, and the same four minutes has run about two million times.*
---
## GM ESSENTIALS
- **System:** d100 roll-under. Special under a fifth, critical under a twentieth.
96–99 never succeed; 00 always fumbles; a 60% skill fumbles on 98–00.
- **Panic is live** — 0–5, up. A trigger costs a point with no roll; relief is rolled.
**This is the case where it earns its keep.**
- **Standing Order 9 is live** — nothing modern goes through. Charge for *using*, never
for carrying. Anchors are exempt.
- **Runs with 4–6 agents from the duty roster.** Not the "(before)" sheets — this is the
job, and it wants people who have been trained.
- **The four minutes are lethal.** Webley 1d10, Martini-Henry 2d6+2, against 11–16 hit
points. **Use the exposure table** — unrestricted fire from all nine wipes the party
five times in six.
- **The mission is to CLOSE it**, which releases everyone aboard into their own history —
including the two men who did not survive the real four minutes.
- **Hard rule: at 2:45 on the clock, they are on the train.**
- **Content warnings:** gun violence at close quarters, repeatedly; entrapment and
confinement; bystanders wounded on screen; a decision that lets named people die;
period hardship and labour exploitation; heights.
---
## Overview
- **Setting:** Mercy Draw, Nevada — present day, and 3–4 November 1881.
- **Tone:** Procedural, then unbearable. It is a paperwork error with sixty-one people
inside it.
- **Runtime:** 3h30 play + a 10 minute break.
- **The crossing:** opens at 11:40 every day, for about four minutes, on the grade.
### Design intent
1. **The supernatural is science fiction underneath.** Nobody is dead, nothing is
haunting anybody, and the whole event has a mechanism the players can understand and
explain back. If a table leaves thinking "ghost train", it has been run wrong.
2. **The obstacle is a physical problem, not a mystery.** They will know what is
happening by the end of Act One. Acts Two and Three are about a train that cannot
stop and an arithmetic that cannot be beaten.
3. **Standing Order 9 is the tutorial.** One shot at a time, no radio, a lantern that
runs out. The period kit *is* the difficulty setting, and it is the scene where
players learn what kind of game this is.
4. **The ending is a decision priced on both sides**, with no roll and no correct
answer, taken in front of a woman holding a manifest.
5. **There is a fight, and there is no villain in it.** Two armed parties, both with
genuine paper, both convinced the others are robbers. The players can survive it,
shape it and pull people out of it. They cannot end it — only the brakes end it.
---
## Session Timing
| Section | Time | Cumulative | Checkpoint |
|---|---|---|---|
| Briefing — the standing order | ~20 min | 0:20 | kit swapped, somebody has complained |
| Act One — Mercy Draw, now | ~50 min | 1:10 | they know it is a survey fraud, and they have heard 11:40 |
| *Break* | *10 min* | *1:10* | lands on the whistle |
| Act Two — Cordera, 1881 | ~70 min | 2:20 | they are on the train |
| Act Three — Aboard | ~50 min | 3:10 | they have decided |
| Epilogue — the register | ~20 min | 3:30 | sixty-one names, written down |
**Hard rule:** at 2:45 they are aboard, by the department's wagon if not by their own wit.
---
## Plot Summary (GM truth)
**April 1881.** The Cordera & Western needed four miles of narrow gauge across a dry
wash called **Mercy Draw**. The company surveyor, **Elias Vane**, was told what the chart
had to say before he walked the ground. He filed a survey showing the line on the
**north bench**. The line was laid on the **south bench**, because that is where the
ground allowed it.
Nobody was hurt by that. It is the dullest possible paperwork lie. And for six months
every timetable, waybill, dispatch and telegraph order in the company's operation
described a train running along the north bench.
**Reality keeps a record of what belongs where.** It can be made to get it wrong by
anything patient enough — and a train is the most patient arguer there is: a fixed line,
a constant speed, on the hour, every day, saying the same wrong thing about where it was.
**3 November 1881, 11:40.** The record gave way. Four miles of line came loose from the
date with No. 9 and five cars on it, **sixty-one souls aboard**, and a **gunfight already
three seconds old**.
**The loop is four minutes long and the fight is inside it.** The crossing did not capture
a train; it captured four minutes, and those four minutes contain two armed parties
shooting the length of car three. It has run about two million times.
### The two sides — and neither is the villain
| | Who | What they have | What they believe |
|---|---|---|---|
| **The coats** | Four men hired by the company to see the payroll to Fallon, led by **Cass Merritt** | A signed letter of engagement, and the key to the box | That men who board at a water stop are robbers |
| **The writ men** | Five Cordera miners, eleven weeks unpaid, led by **Josiah Ambrose** | A sealed writ of attachment from a territorial judge, naming that payroll | That they are lawfully seizing wages and being shot at for it |
**The company sold the same money twice and is not aboard.** Both papers are genuine.
Neither man has seen the other's. Nobody in car three is lying and nobody is wrong.
**Do not adjudicate it.** The players can learn enough to see there is nothing to settle.
**The running is what holds it open.** This is the thing the players must arrive at: the
train is not trapped inside the crossing — the train *is* the crossing. A standing wave,
sustained by its own motion the way a bow sustains a note.
- **Nobody is dead.** They eat, sleep in shifts, and have organised a water rota. By
their clocks it has been about nine hours.
- **Nobody can get off while it runs.** Three departmental attempts are on file; none
returned.
- **Stop the train and the loop ends.** This is the mission, and it is not a mercy killing.
### What closing it actually does — read this twice
**Closing the crossing does not kill anybody. It lets them go.** The loop ends, the third
of November 1881 resumes from 11:44, and every person aboard walks forward into the life
they would have had.
**Including the ones who did not survive the real four minutes.** History resumes as
history: Josiah Ambrose and a miner named Doyle died in that gunfight, and when the loop
lets go they die in it. Not because the agents killed them — because that is what
happened, and it has been prevented from happening for a hundred and forty-five years.
**That is the weight of this ending and it is the whole point.** The agents are not
choosing who lives. They are choosing to stop holding history still, and they will have
met the people it lands on.
The department's position is unambiguous: **close it.** A crossing held open by a
repeating event degrades, and when it fails on its own — Registry's read is within a
year — everyone inside is lost outright rather than returned. Waiting is the only option
that is definitely worse.
**Elias Vane is aboard.** He rode his own line to see what he had signed for. He has had
nine hours and a hundred and forty-five years to think about it. He understands the
standing wave better than the department does, he has told nobody, and he will answer any
direct question honestly because he has been waiting a very long time to be asked.
### Timeline
| When | What |
|---|---|
| 12 Apr 1881 | Vane files the survey. North bench. |
| Apr–Oct 1881 | Line laid on the south bench. Six months of paperwork says otherwise. |
| **3 Nov 1881, 11:40** | The record gives way, with No. 9 inside it. |
| 1889 | Rails lifted. The grade is still there. |
| 1974, 1991, 2009 | Three departmental attempts. No survivors. All tried to stop it. |
| Every day, 11:40 | The crossing opens on the grade for about four minutes. |
### Revelations
| # | Revelation |
|---|---|
| R1 | The filed survey puts the line on the north bench |
| R2 | There has never been a railway on the north bench |
| R3 | Six months of paperwork repeated it |
| R4 | The dispatcher's book has no 11:45 entry |
| R5 | The 11:40 sound is a train working *up* a grade — always accelerating |
| R6 | Nobody aboard is dead, and by their clocks it has been nine hours |
| R7 | The running is what holds the crossing open |
| R8 | Coal that should have lasted four hours has lasted a hundred and forty-five years |
| R9 | Kell will stop if told to, out loud, to his face — and only then |
| R10 | Vane signed it, and knows exactly what stopping it costs |
| R11 | The four minutes contain a gunfight, and it repeats exactly |
| R12 | Both sides have genuine paper, and the company sold the money twice |
| R13 | Closing it releases everyone into their own history — including the two who die in it |
---
## Player Experience
You are handed a Webley, a single-shot rifle, an oil lantern and a pocket watch, and told
that the handset stays behind. Then you stand in a dry wash in Nevada at 11:38 in the
morning and listen to a train you cannot see work a grade that has had no rails on it
since 1889. By lunchtime you know it is a survey fraud from 1881. By the evening you are
lying on the top of a water tower waiting for forty seconds of slow track, and after that
you are in a warm carriage that smells of coffee, being asked by a conductor with a
manifest whether you are from the company. She has counted sixty-one every hour for a
hundred and forty-five years. She would like that noted.
---
## The Countdown — the crossing clock (0–6)
Move it on **each pass of the train**, when **somebody uses a modern object across**, and
on a **fumbled boarding attempt**.
| Clock | What |
|---|---|
| 0 | Open. Four minutes at 11:40. |
| 1 | Thinning. The whistle arrives a half-second before it should. |
| 2 | Slipping. −1 Coherence to everyone across, once. |
| 3 | The line flickers — a hundred yards of grade is briefly not there. |
| 4 | The far end stops answering. Fallon's wire goes dead in 1881. |
| 5 | One pass left, and everybody can tell. |
| 6 | It closes, with whoever is aboard. |
---
## Handouts
| ID | Description | When |
|---|---|---|
| TT-H01 | Case brief TT-004 | Briefing |
| TT-H02 | **Standing Order 9** — period crossings | Briefing |
| TT-H03 | Issue list, 1881 | Briefing |
| TT-H04 | **The filed survey** — the line on the north bench | Act One |
| TT-H05 | Working timetable and the dispatcher's book | Act One |
| TT-H06 | **Passenger manifest** — sixty-one souls, nine children | Act Three |
| TT-H07 | **Letter of engagement** — found on Merritt | Act Three, when searched |
| TT-H08 | **Writ of attachment** — found on Ambrose | Act Three, when searched |
The two papers are the ambiguity, and they only mean anything **held in the same hand**.
Give them separately and let the table put them together.
The manifest is deliberately held back to Act Three. It is a list of names, and the
players should not be holding it until the moment it starts to weigh something.
---
## Briefing — the standing order *(~20 min)*
> You will be crossing to November 1881. Nothing you are carrying goes with you. Stores
> has laid out what you may take instead, and I would ask you not to argue with the
> storeman, because he is right and you are in a hurry.
**Nothing modern goes through.** Give both reasons:
1. **It costs you.** An object that does not belong is a thing the far side must also be
wrong about. **Using** a modern item across costs **1 Coherence**, per object, first
time.
2. **It is a beacon.** A crossing is a place where the record is already wrong. It
notices what does not fit, and the case clock moves.
**Charge for using, never for carrying.** A player who smuggles a torch across and never
switches it on has made a good decision and should get away with it.
**The anchor is exempt.** Everybody keeps theirs.
### The 1881 issue
| Instead of | They get |
|---|---|
| service pistol | **Webley revolver** — 1d10, six shots, slow reload |
| carbine | **Martini-Henry** — 2d6+2, **one shot**, then work the lever |
| torch | **oil lantern** and lamp oil, which runs out |
| the handset | **nothing.** Out of contact from the moment they cross. |
| stab vest | **buff coat** or oilcloth coat |
| medkit | **chirurgeon's roll** and **laudanum** |
| phone and GPS | **brass compass**, **pocket watch**, **spyglass** |
---
## Act One — Mercy Draw, now *(~50 min)*
> **Goal:** find the fraud, hear the train, understand it is on a timetable.
Nevada. A dry wash four miles long with a railway grade along the south side. Rails
lifted in 1889; ballast and tie marks still there. Your watch says 11:38.
- **The south bench** — the grade, real and obvious.
- **The north bench** — nothing, and never anything. `[CUS: Spot or Engineering]`
- **The water tower footing**, half a mile up at the top of the grade. *Remember it.*
- **11:40.** A whistle a long way off, then four minutes of a train working a grade,
getting no nearer. **Automatic — nobody rolls for this.**
### Clue trail
| # | Clue | How found | Type |
|---|---|---|---|
| 1 | The filed survey puts the line on the **north** bench | **Automatic** — it is a handout with a line drawn on it | Essential |
| 2 | There has never been a railway on the north bench | **Automatic** to anyone who walks it | Essential |
| 3 | Six months of paperwork all say north bench | `[CUS: Research]`, or Registry provides | Essential |
| 4 | No 11:45 entry in the dispatcher's book | `[CUS: Research]` | Essential |
| 5 | E. Vane, surveyor, is on the manifest | `[CUS: Research]` | Optional |
| 6 | Three previous attempts, no survivors, all tried to stop it | Registry, if asked | Optional |
| 7 | The sound is a train *accelerating* up a grade | `[CUS: Listen or Engineering]` | Optional |
**Fallbacks.** 1 and 2 are the handout and the ground. 3 and 4 arrive from Registry by
telephone at the twenty-minute mark if nobody goes looking — Bewley is thorough and
slightly impatient.
**They cannot board at this end.** Too fast, grade against them. Let them try: a bruise, a
lesson, and the clock moves one.
> **Fast-track at 1:10:** the 11:40 comes round again and the door stays open long enough
> to walk through. That is what degrading looks like.
---
## Act Two — Cordera, 1881 *(~70 min)*
> **Goal:** the far side, the standing order in practice, and the boarding.
The fourth of November, 1881. A town of four hundred that smells of woodsmoke and horses.
Nobody is frightened yet — a train went into the Draw yesterday and has not come out, and
the wire says it never arrived.
- **The company office** — dispatcher, orders on spikes, the original survey in a drawer.
- **The livery** — horses. This matters.
- **Ada Reyes**, telegraph, who has sent the same query forty times and has noticed the
missing 11:45.
- **The Draw**, four miles out. The train passes about every forty minutes.
**Nobody in 1881 can help them stop it, and everybody will help them get near it.** The
company wants its train back. Let the players use that.
### The set-piece — getting aboard
The train cannot stop and cannot be flagged. The only way on is to **match its speed**,
and the only place it is slow is **the top of the grade at the water tower** — about eight
miles an hour, for perhaps forty seconds.
1. **Ride it down.** Horses alongside on the bench, step across. `[CUS: Ride, then
Athletics]` — and the horse needs its own Ride roll to go near the train at all.
2. **Drop from the tower.** Twelve feet onto a moving roof. `[CUS: Athletics — average;
**only a fumble is a fall**]`. **Do not make this difficult** — playtested at halved
skill it put four agents on 12–31% and all four missed, which stalls the one scene the
scenario is built around.
3. **The wagon on the grade.** Four horses, a plank across. Slowest to arrange, safest to
cross, and it needs the company's help.
**The wagon is easier than the tower, and the tower is easier than the horses.** A party
that spends an hour arranging the wagon has bought an easy roll and should get one.
**Say which route suits whom, out loud, before they choose.** Athletics on a duty-roster
sheet runs from about 25% to 60%, and the split is not subtle: the Containment officer and
the criminal take the tower, the Field Lead can, and **the doctor and the Registry officer
should be on the wagon**. Playtested by putting all five on the tower: two missed twice,
had to be carried across on the department's plank, and became passengers in their own
set-piece — at a cost of two passes on the clock.
**Everybody has to get on.** A failed attempt gets the next pass, forty minutes later, and
the clock moves — not a full stop.
**Panic:** boarding is worth a point to anybody who wants one. Offer it; do not force it.
> **At 2:45 they are aboard.** If two attempts have failed, the department's second wagon
> arrives with two more agents and a plank, and the players owe somebody a favour.
---
## Act Three — Aboard, and the four minutes *(~50 min)*
> **Goal:** survive a gunfight nobody started for their benefit, work out that it repeats,
> and close the crossing anyway.
> It is warm. That is the first thing. Sixty-one people in five cars, and it smells of
> bodies and coal smoke and coffee. Somebody has organised a rota for the water. A child
> is asleep across two seats with a coat over her. They look up when you come through the
> door, and one of them says: *"You want to be somewhere else in about six minutes."*
| Where | What |
|---|---|
| The locomotive | **Marius Kell**, who has not let go of the regulator |
| Tender, car one | Coal that has not run out — the first hard evidence |
| Car two | Most of the passengers. They lie down before it starts. They have a drill. |
| **Car three** | **Where the fight happens.** Overturned seats at both ends. |
| Car four | Freight, the payroll box bolted down, the signed survey in the satchel |
| Car five | **Elias Vane**, alone, watching the line come out from under |
### The four minutes
Starts on the whistle for the grade. Run it in rounds. **It is the same four minutes every
time** — same men, same shots, same two passengers hit in the second minute.
| Minute | What happens if nobody interferes |
|---|---|
| 1 | Ambrose's five come forward from car four. Merritt's four are at the front of car three. Somebody shouts *put it down*; both sides think they said it. |
| 2 | First volley. **Two passengers are hit** — a miner named Doyle, and Mrs Danforth's arm. Every time. |
| 3 | Merritt falls back to the car four door and holds it. Ambrose takes the overturned seats. Everybody reloads: a Webley is six, a Martini is one. |
| 4 | Ambrose reaches the box. Merritt shoots him through the door. The whistle sounds, and it resets. |
### HOW MUCH FIRE THE AGENTS DRAW — read before running it
**The two sides are shooting at each other, not at the agents.** Getting this wrong turns
the scene into a massacre. Work out what each agent is *doing* and give them that much
fire and no more.
| The agent is | Shooters | Cover | Measured over 300 runs |
|---|---|---|---|
| **Head down, not acting** | none | — | Untouched. A legitimate choice for a pass. |
| **Acting from cover** | 1 | −30% | **2 of 4 hurt, half a party member down, no wipes.** The intended shape. |
| **Moving in the open** | 2 | −30% | 3 of 4 hurt, one down. One party in 300 wiped. |
| **Going for the payroll box** | 2 | none | **3½ of 4 down. More than half of all parties wiped.** |
**Tell them the last row before they commit, not after.** Everything else is survivable.
**Nobody rolls to hide** — taking cover is a decision, and it works.
### THE LOOP RESETS. THE AGENTS DO NOT.
Everybody who belongs to 1881 is back on their feet at the next pass, unhurt, with no
memory of having been shot. **The agents are not part of the loop and their wounds are
permanent.** Say this before the first volley — a player who thinks the reset will heal
them will take risks that kill them.
A downed agent is *down*, not dead: the dying rules apply, and the only medicine aboard is
a chirurgeon's roll and a bottle of laudanum. First Aid is the whole answer, and somebody
has to stop shooting to give it.
*A playtested pass, four agents all acting from cover: two down at 0 hit points, one on a
quarter, one untouched because he stayed on the floor. That is a bad pass rather than a
typical one — the average is half a party member — but it is possible on the first one.*
### What the players will try
| They try | What it does |
|---|---|
| Shout, show a warrant card, take charge | Nothing. No authority aboard is recognised. `[CUS: Persuade at −30% on the first pass; easier later as they learn names]` |
| Shoot one side | It works, and changes nothing. Everyone is back next pass. **Let that land in silence.** |
| Get the passengers down | Genuinely useful. Doyle and Mrs Danforth can be pulled clear before minute two. |
| Get to the payroll box | The obvious idea, and the one that kills them. See the table. |
| Show one side the other's paper | **The good move.** It does not stop the fight; it is how they learn what they are looking at. |
| Get to the locomotive | Through car two and the tender, under fire in the gap. This is the route to the ending. |
### The paper — the ambiguity, made findable
**Merritt** carries a letter of engagement from the company: see the box to Fallon,
surrender it to no one *upon any pretext whatsoever, including the presentation of any
paper*. **Ambrose** carries a sealed writ of attachment naming that same payroll against
eleven weeks of unpaid wages.
**Both genuine. Neither man has seen the other's.** An agent holding both has the whole
truth: the company sold the same money twice, then put nine armed men on a train with it.
**NO ROLL IS NEEDED TO SEE EITHER PAPER.** Between passes Merritt shows his letter to
anybody who asks — he is proud of it, it is his authority, and he has had a hundred and
forty-five years with nobody to show it to. Ambrose shows the writ to anybody not
currently shooting at him, because having it read is the entire thing he wants. **Do not
put Persuade in front of this.** Playtested with a real team, the Field Lead's Persuade
was 15% and fell to 1% under the crowd penalty — which turned the scene the scenario is
proudest of into an impossible roll.
**It does not stop the fight.** Merritt will not stand down for a judge he has never heard
of while being shot at, and Ambrose has eleven weeks of nothing behind him.
### Closing it
**Kell will stop the train if a person he believes has authority tells him to, out loud,
to his face.** He has waited a hundred and forty-five years to be told. Not on a note, not
shouted from the tender. Getting there means the gap between the cars — `[CUS: Athletics]`.
**Convincing him takes NO ROLL if they can tell him one true thing about his own train.**
That the coal has not run out. That it is the same four minutes. That the line was never
on the north bench. Any of it. The moment somebody who knows what is wrong says so, he
asks them to say when.
**Only a party that can tell him nothing has to roll** — Persuade or Status at average, and
a failure means he wants another pass to think. **Do not gate the ending on Persuade.**
Seven of the ten duty-roster agents have neither Persuade nor Status trained; playtested,
three separate attempts on Kell all failed and the scenario had no way to end.
**When the brakes go on, the loop lets go.** Four minutes finish for the first time since
1881. Doyle and Ambrose do not get up. They never did.
### The choices — no roll resolves this
| Choice | What it costs |
|---|---|
| **Close it as ordered** | The mission, done. Fifty-nine released into their own century. Two men die of wounds taken in 1881, in 1881. |
| **Close it, having pulled Doyle and Mrs Danforth clear** | Works for those two. Nobody can save Ambrose — only stopping the train stops Merritt shooting him, and stopping the train is what kills him. |
| **Try to end the fight first** | Admirable, probably impossible. Every pass spent trying moves the crossing clock; at 6 it closes with everyone inside. |
| **Refuse, leave it running** | Everyone lives, for a value of living four minutes long. It fails inside a year and then nobody is released at all. |
### If they ask Vane
> I filed a chart that said the line was somewhere it was not, because a man told me to
> and I wanted the work. Six months of honest railwaymen repeating it made it true. Now —
> you have seen the middle of the car. Two of those men do not get up when it stops. They
> have not got up two million times and they will not get up now. I would still stop it.
> It is not our place to hold a thing still because we have grown fond of it.
---
## Epilogue — the register *(~20 min)*
A Registry room, a form, and somebody writing down sixty-one names.
| Ending | What the register says |
|---|---|
| **Closed as ordered** | Fifty-nine walk forward into their own century. Ambrose and Doyle die of wounds taken on 3 November 1881, four minutes after they took them. Registry counts it a success and it is one. |
| **Closed, the two passengers pulled clear first** | As above, and Mrs Danforth keeps her arm. |
| **Closed late** | The same ending, more expensively. If the clock reached 6 first, add the agents to the list. |
| **Left running** | Logged, monitored, inherited. Sixty-one people continue to live four minutes. When it fails, nobody is released at all. |
**Registry's note on any ending:** *"The department did not cause these deaths. It ceased
to prevent them. The distinction matters to the file and it is not expected to matter to
the agents."*
---
## Cast
| Who | Wants | Key line |
|---|---|---|
| **Cass Merritt**, hired guard | to see the box to Fallon, as engaged | *"I have a letter. Whatever you have, I have a letter."* |
| **Josiah Ambrose**, miner | eleven weeks of wages, lawfully | *"There is a judge's seal on this. Read it and then shoot me."* |
| **Marius Kell**, engineer | to be allowed to stop | *"You tell me when. I'll not do it before you say."* |
| **Elias Vane**, surveyor | to be asked, at last | *"I signed it. Six months of honest men made it true."* |
| **Hester Bligh**, conductor | her passengers accounted for | *"Sixty-one aboard. I have counted them every hour since."* |
| **Ada Reyes**, telegraph | an answer up the line | *"It never got to Fallon. I've sent it forty times."* |
| **Mr Bewley**, Registry | the chart, and the truth written down | *"Bring me the paper. The paper is the case."* |
---
## Do not resolve
- **Who was in the right.** Both papers are genuine, the company sold the money twice,
and the company is not aboard to answer for it. If a player asks the GM to adjudicate,
do not.
- **Why the coal has not run out.** The wave sustains it; that is as far as anyone gets.
- **What is at the far end of the line.** Nobody aboard has reached it.
- **Whether closing it was a rescue.** Fifty-nine people say yes. Two men do not get to
say.
## GM notes
- **Play the passengers as people, hard** — and the nine men in the fight hardest of all.
- **Never let the players win the fight.** They can survive it, shape it, and pull people
out of it. They cannot end it. Only the brakes end it.
- **Standing Order 9 is the tutorial.** Do not soften it and do not apologise for it.
- **Cut first:** the town, down to the livery and the telegraph office. Never cut the
boarding.
- **If they crack it early,** run the passes — six of them, forty minutes apart — and let
the table feel the arithmetic they are betting on.
---
## Which scenario to run
| | Length | Combat | Sends them across |
|---|---|---|---|
| **LAST ADMISSION** | 2h | yes, one fight | no — prologue, civilians |
| **OPEN DAY** | 2h15 | no | no — prologue, civilians |
| **THROUGH TRAIN** | 3h30 | no | **yes** — the first real case |
| **BLACK PLATFORM** | 3–4h | yes, heavily | no — a modern-day action case |
Run a prologue, then THROUGH TRAIN. BLACK PLATFORM is the one to reach for when a table
wants a fight.
+37 -1
View File
@@ -943,5 +943,41 @@
"RINGBRP.Item.ArtRating": "Rating",
"RINGBRP.Item.PushCost": "Push cost",
"RINGBRP.Item.PushRule": "What pushing does",
"RINGBRP.Item.Origin": "Where it came from"
"RINGBRP.Item.Origin": "Where it came from",
"RINGBRP.Panic.Label": "Panic",
"RINGBRP.Panic.Steady": "Steady",
"RINGBRP.Panic.SteadyNote": "You are all right. You are doing the job.",
"RINGBRP.Panic.Rattled": "Rattled",
"RINGBRP.Panic.RattledNote": "Your hands are not quite steady. No penalty — the first one is free.",
"RINGBRP.Panic.Shaken": "Shaken",
"RINGBRP.Panic.ShakenNote": "−5% to everything. You keep checking the way you came in.",
"RINGBRP.Panic.Losing": "Losing it",
"RINGBRP.Panic.LosingNote": "−10% to everything. You are talking too fast and not hearing answers.",
"RINGBRP.Panic.Overwhelmed": "Overwhelmed",
"RINGBRP.Panic.OverwhelmedNote": "−20% to everything. One more and you go.",
"RINGBRP.Panic.Broken": "Broken",
"RINGBRP.Panic.BrokenNote": "You freeze, run, or go at it. Choose one, take it, and come back at 3.",
"RINGBRP.Panic.Trig.WrongThing": "Something that should not be there",
"RINGBRP.Panic.Trig.Touched": "It touched you",
"RINGBRP.Panic.Trig.Body": "A body",
"RINGBRP.Panic.Trig.Known": "Somebody you knew",
"RINGBRP.Panic.Trig.Dark": "The lights went",
"RINGBRP.Panic.Trig.Wounded": "A major wound",
"RINGBRP.Panic.Trig.Named": "It said your name",
"RINGBRP.Panic.Relief.Steady": "Steady yourself (POW×5)",
"RINGBRP.Panic.Relief.Religion": "Religion — something older than the department",
"RINGBRP.Panic.Relief.Psychology": "Psychology — talk somebody else down",
"RINGBRP.Panic.Relief.Occult": "Occult — name it, out loud, for everyone who can hear",
"RINGBRP.Panic.Relief.Anchor": "Your anchor, once per case",
"RINGBRP.Panic.Gained": "{name} — panic {from} → {to}",
"RINGBRP.Panic.Eased": "{name} — panic {from} → {to}",
"RINGBRP.Panic.Breaks": "BREAKS — freeze, flee, or fixate. Choose, take it, come back at 3.",
"RINGBRP.Panic.NoChange": "No change.",
"RINGBRP.Flavour.Panicking": "panicking ({mod}%)",
"RINGBRP.Chat.ToEverything": "to everything",
"RINGBRP.Panic.Backfired": "Fumbled —",
"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": {
"build": "node tools/build-packs.mjs && node tools/update-readme.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",
"test": "npm run check",
"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-22:33:57.943149 172caf000 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-22:33:58.022518 1734bb000 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-22:33:57.945208 1734bb000 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-22:33:58.004571 1734bb000 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-22:33:57.981172 172caf000 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-22:33:58.016291 1734bb000 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-22:33:57.966535 1724a3000 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-22:33:58.012415 172caf000 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-22:33:58.008698 1724a3000 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-22:33:58.020906 1734bb000 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-22:33:57.937187 172caf000 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-22:33:58.014433 1724a3000 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-22:33:57.991862 1734bb000 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-22:33:57.985971 1724a3000 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-22:33:57.939923 1734bb000 Delete type=3 #1
Binary file not shown.
+1
View File
@@ -0,0 +1 @@
MANIFEST-000002
+1
View File
@@ -0,0 +1 @@
2026/08/18-22:33:57.999339 1724a3000 Delete type=3 #1
Binary file not shown.
+1 -1
View File
@@ -1 +1 @@
2026/08/12-19:24:52.819322 17303f000 Delete type=3 #1
2026/08/18-22:33:57.947467 173cc7000 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-22:33:57.941521 172caf000 Delete type=3 #1
+8 -8
View File
@@ -60,8 +60,8 @@ export const ROLES = {
registry: { label: "Registry Officer",
blurb: "Keeps the record that a crossing is made against. Knows what the file says and, more usefully, what it used to say.", stat: "edu",
core: ["research","knowledge","anomaly_lore","language:own","computer_use","insight"],
support: ["spot","art","first_aid","tradecraft","firearm:pistol"],
core: ["research","occult","knowledge","anomaly_lore","language:own"],
support: ["computer_use","insight","spot","first_aid","tradecraft","firearm:pistol"],
talents: ["chain_custody","wrong_memory","debriefer","quorum"],
kit: ["spectacles","notebook","handset","jacket","pistol_light", "carbon_book", "date_stamp", "seals"] },
@@ -82,7 +82,7 @@ export const ROLES = {
interview: { label: "Interview Officer",
blurb: "Sits with the witness afterwards. Patient, unhurried, and very hard to lie to twice.", stat: "cha",
core: ["insight","persuade","listen","language:own","anthropology"],
support: ["fast_talk","spot","tradecraft","first_aid","firearm:pistol"],
support: ["psychology","spot","tradecraft","first_aid","firearm:pistol"],
talents: ["debriefer","read_room","wrong_memory","unblinking"],
kit: ["dictaphone","cover_id","notebook","jacket","pistol_light", "carbon_book", "vacuum_flask", "clipboard"] },
@@ -110,7 +110,7 @@ export const ROLES = {
pathology: { label: "Pathology Officer",
blurb: "Examines what is left. Establishes what a thing was, which is usually the question the case turns on.", stat: "int",
core: ["medicine","science:biology","xenology:baseline","insight","research"],
support: ["first_aid","spot","anthropology","dodge","melee_weapon:knife"],
support: ["first_aid","spot","religion","dodge","melee_weapon:knife"],
talents: ["triage","chain_custody","unblinking","clean_scene"],
kit: ["chirurgeon_kit","gas_mask","envsuit","sample_case","dagger", "specimen_jars", "swab_kit", "barrier_gloves"] },
@@ -172,8 +172,8 @@ export const ROLES = {
antiquities: { label: "Antiquities Officer",
blurb: "An archaic posting the department never abolished. Old objects, older records, and the manners to ask about both.", stat: "edu",
core: ["knowledge","art","anomaly_lore","research","status"],
support: ["insight","spot","first_aid","melee_weapon:sword","firearm:pistol"],
core: ["religion","art","anomaly_lore","research","status"],
support: ["knowledge","insight","spot","first_aid","melee_weapon:sword","firearm:pistol"],
talents: ["chain_custody","wrong_memory","borrowed_hour","quorum"],
kit: ["pocket_watch","tallow_candle","duelling_sword","brigandine","flintlock", "quill_ink", "sealing_wax", "sand_glass"] },
@@ -231,13 +231,13 @@ export const TRADES = {
doctor: { label: "Doctor",
blurb: "A&E, mostly. Used to people at their worst and to being the calmest thing in the room.", stat: "edu",
core: ["medicine","first_aid","science:biology","insight","knowledge"],
support: ["research","status","persuade","listen","spot"],
support: ["research","psychology","persuade","listen","spot"],
talents: ["triage","debriefer","unblinking","clean_scene"],
kit: ["medkit","trauma_unit","analgesics","specimen_jars","swab_kit"] },
professor: { label: "Professor",
blurb: "A chair in something narrow, and a reading list nobody else has finished. Argues for a living.", stat: "edu",
core: ["knowledge","research","anthropology","language:own","status"],
core: ["knowledge","research","occult","language:own","status"],
support: ["insight","persuade","science:botany","listen","art"],
talents: ["debriefer","read_room","requisition","wrong_memory"],
kit: ["carbon_book","notebook","spectacles","clipboard","quill_ink"] },
+165 -99
View File
@@ -50,7 +50,9 @@ import {
ARTS, powerCostFor, powerStartingRating,
CROSSING, crossingArtPoolFor, crossingGrantsArt, crossingCostFor, REACTION,
CIRCUMSTANCE, RANGE_LADDER, rangeBandFrom, MAJOR_WOUND_TABLE,
COHERENCE_BANDS, COHERENCE_COSTS, COHERENCE_RECOVERY, coherenceBandFrom
COHERENCE_BANDS, COHERENCE_COSTS, COHERENCE_RECOVERY, coherenceBandFrom,
PANIC_MAX, PANIC_BANDS, PANIC_TRIGGERS, PANIC_RELIEF, PANIC_BREAK, PANIC_AFTER_BREAK,
panicBandFrom, panicPenaltyFor, panicBreaksAt, panicAfter, panicReliefFor
} from "./rules.mjs";
export { hitPointsFor, majorWoundFor, damageModifierFor, locationMaxHp, reflexRating, awarenessRating };
@@ -1030,6 +1032,17 @@ export async function skillRoll({
flavourLines = [...flavourLines, game.i18n.format("RINGBRP.Flavour.Encumbered", { mod: enc })];
}
}
// Panic is the short track and it bites on everything, so it sits in the same stack
// the wound and encumbrance penalties already use — anything that computes a modifier
// and is not added here is a modifier nothing reads, which is this project's oldest
// way of shipping a rule that does not exist.
if (actor) {
const pan = panicPenaltyFor(actor.system?.panic?.value);
if (pan) {
situational = (Number(situational) || 0) + pan;
flavourLines = [...flavourLines, game.i18n.format("RINGBRP.Flavour.Panicking", { mod: pan })];
}
}
if (actor && !skipCoherence) {
const coh = coherencePenaltyFor(actor.system?.coherence?.value, categoryId);
if (coh) {
@@ -1075,7 +1088,6 @@ export async function skillRoll({
}
);
rbDressDice(roll, actor);
await roll.toMessage({
speaker: speaker ?? ChatMessage.getSpeaker({ actor }),
content,
@@ -1108,7 +1120,7 @@ export async function majorWoundRoll({ actor, woundType = "blunt", monofilament
console.error("Custodians | majorWoundRoll called without an actor");
return null;
}
const r = rbDressDice(await new Roll("1d10").evaluate(), actor);
const r = await new Roll("1d10").evaluate();
let result = r.total;
if (monofilament && result >= 4) result = 10; // spec §3.3 shift
else if (blunt && result === 10) result = 9;
@@ -1171,7 +1183,7 @@ export async function hitLocationRoll({ target, damage, mode = "ranged",
}
const sys = target.system;
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 live = (sys.locations ?? []).find(l => l.id === loc.id);
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);
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 coherence = actor.system?.coherence?.value ?? 10;
const shown = fluxDie(raw, coherence);
@@ -1535,6 +1547,122 @@ export async function loseCoherence(actor, reasonKey, { amount = null, note = ""
return bAfter;
}
RINGBRP.panicBands = PANIC_BANDS;
RINGBRP.panicTriggers = PANIC_TRIGGERS;
RINGBRP.panicRelief = PANIC_RELIEF;
export const panicBand = panicBandFrom;
/**
* Take a point of panic. No roll: a die to find out whether you are frightened slows
* the scene and tells the player how to feel. The die is for getting rid of it.
*/
export async function gainPanic(actor, triggerKey, { amount = null, note = "" } = {}) {
if (!actor) return null;
const spec = PANIC_TRIGGERS[triggerKey];
const cost = amount ?? spec?.cost ?? 1;
const before = Number(actor.system?.panic?.value ?? 0);
const after = panicAfter(before, cost);
const log = [...(actor.system?.panic?.log ?? []), { reason: triggerKey, cost, note }];
await actor.update({ "system.panic.value": after, "system.panic.log": log });
await panicCard(actor, before, after, game.i18n.localize(String(spec?.label ?? triggerKey ?? "")), note);
return panicBandFrom(after);
}
/**
* Put a point back. `level` is the outcome of whatever was rolled — a special takes two
* — so every relief in PANIC_RELIEF runs through one function and cannot drift.
*/
export async function easePanic(actor, reliefKey, level = "success", { note = "" } = {}) {
if (!actor) return null;
const spec = PANIC_RELIEF[reliefKey];
const back = panicReliefFor(level);
const before = Number(actor.system?.panic?.value ?? 0);
const after = panicAfter(before, -back);
await actor.update({ "system.panic.value": after });
// A fumble comes back as negative relief, so the card has to say so — otherwise it
// reads "Psychology" over a number that just went up, and the table reads it as a bug.
await panicCard(actor, before, after, game.i18n.localize(String(spec?.label ?? reliefKey ?? "")),
note, { backfired: back < 0 });
return panicBandFrom(after);
}
/** Breaking spends the panic: you come back at 3, because it is not a rest. */
export async function breakPanic(actor, choice = "freeze") {
if (!actor) return null;
const before = Number(actor.system?.panic?.value ?? 0);
const after = PANIC_AFTER_BREAK;
await actor.update({ "system.panic.value": after });
await panicCard(actor, before, after,
game.i18n.localize("RINGBRP.Panic.Breaks"),
PANIC_BREAK.includes(choice) ? choice : "freeze");
return panicBandFrom(after);
}
async function panicCard(actor, before, after, reason, note, { backfired = false } = {}) {
const band = panicBandFrom(after);
await ChatMessage.create({
speaker: ChatMessage.getSpeaker({ actor }),
content: await rbRenderTemplate("systems/ringbrp/templates/chat-panic.hbs", {
actorName: actor.name, before, after, reason, note,
band, breaks: panicBreaksAt(after), penalty: panicPenaltyFor(after), backfired,
choices: PANIC_BREAK,
pips: Array.from({ length: PANIC_MAX }, (_, i) => i < after)
})
});
}
/**
* 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
* 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>` });
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 }),
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.
@@ -2417,7 +2545,7 @@ export async function asphyxiate(actor, { held = 0, prepared = true } = {}) {
export async function burn(actor, bandId = "clothes", { round = 1 } = {}) {
if (!actor) return null;
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
// 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;
@@ -2436,7 +2564,7 @@ export async function burn(actor, bandId = "clothes", { round = 1 } = {}) {
* armour is only ever half of one, at contact.
*/
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",
{ formula, r: radius }) });
const out = [];
@@ -3850,10 +3978,16 @@ export async function generateCharacter({
const trainedKeys = new Set([...R.core, ...R.support]);
for (const k of R.core) add(k, coreBand);
for (const k of R.support) add(k, supportBand);
// The agency half. Added AFTER the trade, and skipped where the trade already
// covers it — a former police officer does not un-learn their sidearm to be taught
// it again at a worse number.
if (isTrade) {
// 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 it
// again at a worse number, and a Registry officer does not lose their Anomaly Lore.
//
// 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) {
const doc = byKey.get(k); if (!doc) continue;
if (items.some(i => i.name === doc.name)) continue;
@@ -4292,7 +4426,7 @@ export async function rollCharacteristicsDialog({ actorName = "" } = {}) {
const rollOne = async k => {
const cell = el.querySelector(`tr[data-ch="${k}"] .rb-roll-value`);
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({
flavor: game.i18n.format("RINGBRP.Gen.RolledChar",
{ 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
* 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) => {
const root = html instanceof HTMLElement ? html : html?.[0];
if (!root || !game.user.isGM || root.querySelector(".rb-genbar")) return;
@@ -4839,6 +4889,9 @@ class RingBRPCharacterSheet extends ActorSheet {
ctx.reaction = this._reactionRail();
ctx.readiness = this._readiness ?? { rating: 0 };
ctx.coherence = { band: coherenceBand(sys.coherence?.value) };
ctx.panic = { band: panicBandFrom(sys.panic?.value),
penalty: panicPenaltyFor(sys.panic?.value),
pips: Array.from({ length: PANIC_MAX }, (_, i) => i < Number(sys.panic?.value ?? 0)) };
ctx.readinessSource = ctx.readiness.best >= ctx.readiness.intFloor
? game.i18n.localize("RINGBRP.React.ReadinessSkill")
: game.i18n.localize("RINGBRP.React.ReadinessInt");
@@ -4932,6 +4985,18 @@ class RingBRPCharacterSheet extends ActorSheet {
if (item) await useDeviceFunction(this.actor, item, btn.dataset.fn);
this.render(false);
}));
root.querySelectorAll("[data-action='panic-adjust']").forEach(el =>
el.addEventListener("click", async ev => {
ev.preventDefault(); ev.stopPropagation();
const delta = Number(ev.currentTarget.dataset.delta) || 0;
if (delta > 0) await gainPanic(this.actor, "wrongThing", { amount: 1 });
else await easePanic(this.actor, "steadyYourself", "success");
}));
root.querySelectorAll("[data-action='panic-break']").forEach(el =>
el.addEventListener("click", async ev => {
ev.preventDefault(); ev.stopPropagation();
await breakPanic(this.actor, ev.currentTarget.dataset.choice ?? "freeze");
}));
root.querySelectorAll("[data-action='coherence-adjust']").forEach(el =>
el.addEventListener("click", async ev => {
ev.preventDefault(); ev.stopPropagation();
@@ -5875,7 +5940,6 @@ export async function rollWeaponDamage({ actor = null, weapon, level = "success"
roll = await new Roll(usesDm ? formula + dm : formula).evaluate();
}
rbDressDice(roll, actor);
const note = level === "critical" ? game.i18n.localize("RINGBRP.Chat.DamageCritical")
: level === "special" ? game.i18n.localize("RINGBRP.Chat.DamageSpecial")
: "";
@@ -6171,6 +6235,8 @@ Hooks.once("init", () => {
reactionLoadFrom, reactionPrintedFrom,
readinessRating, percentileFaces, circumstanceTotal,
coherenceBand, coherencePenaltyFor, woundPenaltyFor, loseCoherence, regainCoherence, displace,
panicBand, panicPenaltyFor, panicBreaksAt, panicReliefFor, panicAfter,
gainPanic, easePanic, breakPanic, clearPanic, partyState,
isAcross, setAcross, useTransposed, debrief, activeCaseFile, returnCrossing, caseMembers,
earnedDiscoveries, deviceFunctionStatus, earnDiscovery, useDeviceFunction, pickContactActor,
resolveContact, newEncounter, claimRewards, activeRewardEffects,
@@ -6189,7 +6255,7 @@ Hooks.once("init", () => {
usePower, grantPower, ARTS, powerCostFor, crossBack,
generateMissionCase, missionDialog, salvageFrom,
crossingArtPoolFor, crossingGrantsArt, crossingCostFor, returnCrossing,
rollWeaponDamage, diceSetFor,
rollWeaponDamage,
restFor, endOfCase, gearBonusFor,
hasTalent, talentAvailable, useTalent, requisition, armCleanScene, resetTalents,
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 };
/* ============================================================
PANIC — the short track
============================================================
Coherence is the long one: 0-10, campaign-scale, what it costs to keep looking once
you know what you are looking at, recovered at a debrief. Panic is the other half and
they must not be confused at the table:
Coherence what knowing has cost you weeks 0-10, down
Panic what is happening to you NOW minutes 0-5, up
Panic goes UP. A trigger gives a point with no roll — asking for a die to find out
whether you are frightened is a die that slows the scene down and tells the player
how to feel. The roll is to get RID of it, which is the interesting direction and the
only one worth a check.
One free point on purpose: at Panic 1 you are rattled and roll at no penalty. It costs
the table nothing and it means the first shock of a scene is colour rather than
arithmetic.
============================================================ */
export const PANIC_MAX = 5;
export const PANIC_BANDS = [
{ min: 5, id: "broken", label: "RINGBRP.Panic.Broken", all: -20, breaks: true,
note: "RINGBRP.Panic.BrokenNote" },
{ min: 4, id: "overwhelmed", label: "RINGBRP.Panic.Overwhelmed", all: -20, breaks: false,
note: "RINGBRP.Panic.OverwhelmedNote" },
{ min: 3, id: "losing", label: "RINGBRP.Panic.Losing", all: -10, breaks: false,
note: "RINGBRP.Panic.LosingNote" },
{ min: 2, id: "shaken", label: "RINGBRP.Panic.Shaken", all: -5, breaks: false,
note: "RINGBRP.Panic.ShakenNote" },
{ min: 1, id: "rattled", label: "RINGBRP.Panic.Rattled", all: 0, breaks: false,
note: "RINGBRP.Panic.RattledNote" },
{ min: 0, id: "steady", label: "RINGBRP.Panic.Steady", all: 0, breaks: false,
note: "RINGBRP.Panic.SteadyNote" }
];
/** What a point costs. One each, so a GM never has to weigh anything mid-scene. */
export const PANIC_TRIGGERS = {
wrongThing: { cost: 1, label: "RINGBRP.Panic.Trig.WrongThing" },
touchedByIt: { cost: 1, label: "RINGBRP.Panic.Trig.Touched" },
aBody: { cost: 1, label: "RINGBRP.Panic.Trig.Body" },
someoneYouKnew:{ cost: 2, label: "RINGBRP.Panic.Trig.Known" },
theLightsWent: { cost: 1, label: "RINGBRP.Panic.Trig.Dark" },
majorWound: { cost: 1, label: "RINGBRP.Panic.Trig.Wounded" },
itSaidYourName:{ cost: 2, label: "RINGBRP.Panic.Trig.Named" }
};
/**
* What takes a point off, and what it is rolled against.
*
* `self` steadies you; `other` steadies somebody else, which is the whole reason
* Psychology is worth carrying. `heard` reaches everyone who can hear it: naming a thing
* correctly is the department's actual work, and it is worth a mechanical reward.
* A special takes two.
*/
export const PANIC_RELIEF = {
steadyYourself: { skill: null, reach: "self", once: "scene",
label: "RINGBRP.Panic.Relief.Steady" },
religion: { skill: "religion", reach: "self", once: "scene",
label: "RINGBRP.Panic.Relief.Religion" },
psychology: { skill: "psychology", reach: "other", once: "scene",
label: "RINGBRP.Panic.Relief.Psychology" },
occult: { skill: "occult", reach: "heard", once: "encounter",
label: "RINGBRP.Panic.Relief.Occult" },
anchorObject: { skill: null, reach: "self", once: "case",
label: "RINGBRP.Panic.Relief.Anchor" }
};
/** Breaking is a choice, not a table: three bad options, taken fast. */
export const PANIC_BREAK = ["freeze", "flee", "fixate"];
/** Breaking spends the panic. You come back at 3, not at 0 — it is not a rest. */
export const PANIC_AFTER_BREAK = 3;
export function panicBandFrom(value) {
const v = Math.max(0, Math.min(PANIC_MAX, Number(value) || 0));
return PANIC_BANDS.find(b => v >= b.min) ?? PANIC_BANDS.at(-1);
}
/** The roll penalty a level of panic carries. Never positive. */
export function panicPenaltyFor(value) {
return Math.min(0, panicBandFrom(value).all);
}
/** Does this level break the character this round? */
export function panicBreaksAt(value) {
return panicBandFrom(value).breaks === true;
}
/** Clamped, so nothing can push the track past its own ends. */
export function panicAfter(value, delta) {
return Math.max(0, Math.min(PANIC_MAX, (Number(value) || 0) + (Number(delta) || 0)));
}
/**
* A success takes one point, a special or better takes two — and a FUMBLE puts one on.
*
* Returned as a negative relief so the whole thing stays one number and one direction:
* easePanic subtracts what this gives it, so -1 subtracts a negative and the track goes
* up. Trying to talk somebody down and making it worse is the most familiar thing in
* this list, and until now it was the only outcome that cost nothing.
*/
export function panicReliefFor(level) {
if (level === "critical" || level === "special") return 2;
if (level === "success") return 1;
if (level === "fumble") return -1;
return 0;
}
export function coherenceBandFrom(value) {
const v = Math.max(0, Number(value) || 0);
return COHERENCE_BANDS.find(b => v >= b.min) ?? COHERENCE_BANDS.at(-1);
+136 -63
View File
@@ -1688,100 +1688,173 @@
/* ============================================================
THE PAUSE — a uniselector, stepping
============================================================
Foundry's pause is a <figure id="pause"> holding an <img class="fa-spin"> and a
<figcaption>. Both are replaced here without touching the markup.
Foundry's pause is <figure id="pause"> holding an <img> and a <figcaption>, and core
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
round one contact at a time, hunting for a free line. It is the right object for this
game — the department's whole business is a machine that says where things belong,
and OPEN DAY is set inside one. Eleven contacts, because eleven minutes is the
interval this setting keeps coming back to.
round one contact at a time, hunting for a free line. The department's whole business
is a machine that says where things belong, and OPEN DAY is set inside one. Eleven
contacts, because eleven minutes is the interval this setting keeps returning to.
It STEPS rather than sweeps. A continuous spin reads as a loading throbber; a
discrete click every 700ms reads as a machine working through a list, which is what
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.
It STEPS rather than sweeps — a contact every 700ms. A continuous spin reads as a
loading throbber; a discrete click reads as a machine working through a list.
============================================================ */
#pause {
/* Core pins this with BOTH top and bottom, so an auto height stretches it down the
whole screen — 180px tall for a 16px caption. Pin it once, here, and size to
content. The arm is rotated, so its bounding box is the diagonal of its own square:
the panel is laid out from the flex basis, never from getBoundingClientRect. */
position: fixed;
left: 50%; bottom: 14vh; top: auto; right: auto;
transform: translateX(-50%);
height: auto; width: max-content;
display: flex; align-items: center; gap: 16px;
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);
/* Taller than core's 180px, and moved up by exactly half the extra so it stays centred. */
#pause.paused {
height: 300px;
top: calc(50vh - 150px);
gap: 28px;
background: linear-gradient(to right,
transparent 0%,
color-mix(in srgb, var(--rb-void) 82%, transparent) 38%,
color-mix(in srgb, var(--rb-void) 82%, transparent) 62%,
transparent 100%);
}
/* the dial: eleven contacts, standing still */
#pause::before {
/* the dial: eleven contacts, standing still, behind the arm */
#pause.paused::before {
content: "";
position: absolute; left: 16px; top: 50%;
width: 52px; height: 52px; margin-top: -26px;
position: absolute;
top: 24px; left: 50%; margin-left: -85px;
width: 170px; height: 170px;
border-radius: 50%;
border: 1px solid var(--rb-hairline);
background:
repeating-conic-gradient(from -90deg,
var(--rb-line) 0deg 2.4deg, transparent 2.4deg 32.727deg);
-webkit-mask: radial-gradient(circle, transparent 0 15px, #000 16px);
mask: radial-gradient(circle, transparent 0 15px, #000 16px);
opacity: .95;
var(--rb-line) 0deg 1.6deg, transparent 1.6deg 32.727deg);
-webkit-mask: radial-gradient(circle, transparent 0 52px, #000 54px);
mask: radial-gradient(circle, transparent 0 52px, #000 54px);
opacity: .9;
}
/* 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. */
#pause img {
position: relative; z-index: 1;
flex: 0 0 52px; width: 52px; height: 52px; margin: 0;
object-fit: none; object-position: -999px -999px;
border: none; filter: none;
raster out of frame, which leaves a paintable element without adding a wrapper. */
#pause.paused img {
width: 170px; height: 170px;
object-fit: none; object-position: -9999px -9999px;
border: none; box-shadow: none; filter: none;
/* 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 */
background: conic-gradient(from -92deg,
var(--rb-yellow) 0deg 5deg,
var(--rb-yellow-dim) 5deg 30deg,
transparent 30deg 360deg);
-webkit-mask: radial-gradient(circle, #000 0 22px, transparent 23px);
mask: radial-gradient(circle, #000 0 22px, transparent 23px);
a 700ms step is easy to miss, and the stepping is the whole idea */
background: conic-gradient(from -91deg,
var(--rb-yellow) 0deg 3.5deg,
var(--rb-yellow-dim) 3.5deg 28deg,
transparent 28deg 360deg);
-webkit-mask: radial-gradient(circle, #000 0 78px, transparent 80px);
mask: radial-gradient(circle, #000 0 78px, transparent 80px);
animation: rb-uniselector 7.7s steps(11, end) infinite;
}
/* Foundry adds fa-spin to that img; ours is the only rotation that should run. */
#pause img.fa-spin { animation: rb-uniselector 7.7s steps(11, end) infinite; }
/* core adds fa-spin to that img; ours must be the only rotation running */
#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 */
#pause::after {
#pause.paused::after {
content: "";
position: absolute; left: 38px; top: 50%;
width: 8px; height: 8px; margin-top: -4px;
position: absolute;
top: 101px; left: 50%; margin-left: -8px;
width: 16px; height: 16px;
border-radius: 50%;
background: var(--rb-yellow);
animation: rb-pause-pip 2.6s ease-in-out infinite;
}
#pause figcaption {
#pause.paused figcaption {
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);
text-shadow: none; padding: 0; margin: 0;
border-left: 1px solid var(--rb-hairline); padding-left: 16px;
line-height: 1.2;
text-shadow: 0 2px 18px var(--rb-void);
}
@keyframes rb-uniselector { from { transform: rotate(0deg); } to { transform: rotate(360deg); } }
@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
preference is asking us not to do. Keep the object, stop the movement. */
/* A stepping dial in the corner of the eye is exactly what this preference is about.
Keep the object, stop the movement — including core's own pulse on the whole figure. */
@media (prefers-reduced-motion: reduce) {
#pause img, #pause img.fa-spin, #pause::after { animation: none; }
#pause img { transform: rotate(-32deg); }
#pause.paused,
#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; }
+9 -1
View File
@@ -2,7 +2,7 @@
"id": "ringbrp",
"title": "The Custodians",
"description": "A Basic Roleplaying game of agents who cross the crossing to contain what should not be there — and who come back a little less from here each time.",
"version": "1.0.4",
"version": "1.4.1",
"compatibility": {
"minimum": 13,
"verified": "14.364"
@@ -127,6 +127,13 @@
"type": "JournalEntry",
"system": "ringbrp"
},
{
"name": "throughtrain",
"label": "The Custodians · THROUGH TRAIN (crossing starter)",
"path": "packs/throughtrain",
"type": "Adventure",
"system": "ringbrp"
},
{
"name": "starter",
"label": "The Custodians · BLACK PLATFORM (starter case)",
@@ -184,6 +191,7 @@
"name": "Scenarios",
"sorting": "m",
"packs": [
"throughtrain",
"lastadmission",
"prologue",
"starter",
+5
View File
@@ -69,6 +69,11 @@
"value": 10,
"max": 10,
"drift": []
},
"panic": {
"value": 0,
"max": 5,
"log": []
}
}
},
+10
View File
@@ -51,6 +51,16 @@
</span>
<span class="rb-cohband">{{localize coherence.band.label}}</span>
</div>
<div class="rb-vital-panic rb-panic-{{panic.band.id}}"
data-tooltip="{{localize panic.band.note}}">
<span class="rb-vital-label">{{localize "RINGBRP.Panic.Label"}}</span>
<span class="rb-coh-row">
<a data-action="panic-adjust" data-delta="-1" class="rb-cohbtn">&minus;</a>
<span class="rb-big">{{sys.panic.value}}</span>
<a data-action="panic-adjust" data-delta="1" class="rb-cohbtn">+</a>
</span>
<span class="rb-cohband">{{localize panic.band.label}}</span>
</div>
<div class="rb-armour{{#if armourUnworn.length}} rb-unworn{{/if}}"
{{#if armourUnworn.length}}data-tooltip="{{localize "RINGBRP.Sheet.ArmourUnworn"}}"{{/if}}>
<span class="rb-vital-label">{{localize "RINGBRP.Sheet.Armour"}}</span>
+21
View File
@@ -0,0 +1,21 @@
<div class="ringbrp-chat rb-panic-card">
<div class="rb-chat-title">{{localize "RINGBRP.Panic.Label"}}</div>
<div class="rb-chat-sub">{{actorName}}</div>
<ol class="rb-panic-pips">
{{#each pips as |on|}}<li class="rb-panic-pip{{#if on}} on{{/if}}"></li>{{/each}}
</ol>
<div class="rb-panic-move">
<span class="rb-panic-from">{{before}}</span>
<span class="rb-panic-arrow">&rarr;</span>
<span class="rb-panic-to">{{after}}</span>
<span class="rb-panic-band rb-panic-{{band.id}}">{{localize band.label}}</span>
</div>
<p class="rb-panic-reason">{{#if backfired}}<span class="rb-panic-worse">{{localize "RINGBRP.Panic.Backfired"}}</span> {{/if}}{{reason}}{{#if note}} — <em>{{note}}</em>{{/if}}</p>
<p class="rb-panic-note">{{localize band.note}}</p>
{{#if penalty}}<p class="rb-panic-pen">{{penalty}}% {{localize "RINGBRP.Chat.ToEverything"}}</p>{{/if}}
{{#if breaks}}<p class="rb-panic-break">{{localize "RINGBRP.Panic.Breaks"}}</p>{{/if}}
</div>
+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>
+107 -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.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);
// Floors only ever RAISE a rating, so a posting already better stays better.
for (const [id, floor] of Object.entries(spec.floors ?? {})) {
@@ -861,6 +864,109 @@ function cullandScene() {
};
}
// THROUGH TRAIN — the crossing starter. Unlike the two prologues this one has a real
// case file, because it is the first time the players are sent ACROSS and the case
// tracks are the point.
{
const X = await import("./scenario-throughtrain.mjs");
const castDocs = [];
for (const spec of X.TT_CAST) {
const { actorDoc, itemDocs } = buildActor(spec, "npc");
actorDoc.items = itemDocs;
castDocs.push(actorDoc);
}
// The case file, already at its opening state, with Standing Order 9 in the notes.
const C = X.TT_CASE;
const caseId = idFor(`actor:${C.key}`);
const caseDoc = {
_id: caseId, name: C.name, type: "expedition", img: ICON.caseFile ?? ICON.actor,
system: {
integrityMode: "abstract",
integrity: { value: C.containment, max: 10 },
supply: { value: C.resources, max: 10 },
power: { value: C.clearance, max: 10 },
assets: [], members: [],
journey: {
destinationId: "mercy_draw", destinationName: C.destination,
destinationKnowledge: "briefed", currentLeg: 0, elapsedDays: 0,
siteTableId: "sites", hazardTableId: "hazards", returnClock: 0,
legs: C.legs.map((l, i) => ({
index: i, terrain: l.label, mode: "foot", kind: l.kindId,
difficulty: l.difficulty, modifier: l.mod, days: 0,
explored: true, resolved: false, forcedEncounterId: "",
costSupply: 1, costPower: 0
}))
},
standings: [],
across: false, crossingModifier: 0, salvageEra: "antique",
notes: C.notes
},
items: [], effects: [],
prototypeToken: { name: C.name, displayName: 20, actorLink: true, width: 1, height: 1,
texture: { src: ICON.actor }, sight: { enabled: false }, disposition: 1,
bar1: { attribute: "integrity" }, bar2: { attribute: "supply" }, flags: {} },
...BASE()
};
castDocs.push(caseDoc);
const gmJournal = journalDoc("THROUGH TRAIN — Run the case", "tt_gm", X.TT_PAGES);
const handouts = journalDoc("THROUGH TRAIN — Player handouts", "tt_handouts", X.TT_HANDOUTS);
handouts.doc.ownership = { default: 2 };
handouts.doc.flags = { ringbrp: { brand: true } };
for (const pg of handouts.pages) pg.ownership = { default: 2 };
const showJournal = imageJournal("THROUGH TRAIN — Show the players", "tt_show", [
{ name: "1 · Mercy Draw, now", src: "art/scenes/tt_01_draw.webp" },
{ name: "2 · Stores, and the 1881 issue", src: "art/scenes/tt_02_stores.webp" },
{ name: "3 · The grade on the south bench", src: "art/scenes/tt_03_bench.webp" },
{ name: "4 · Cordera, 1881", src: "art/scenes/tt_04_cordera.webp" },
{ name: "5 · The water tower — boarding", src: "art/scenes/tt_05_boarding.webp" },
{ name: "6 · Inside car three, between passes", src: "art/scenes/tt_06_car.webp" },
{ name: "7 · The four minutes", src: "art/scenes/tt_08_gunfight.webp" },
{ name: "8 · The payroll box, car four", src: "art/scenes/tt_09_payroll.webp" },
{ name: "9 · The gap between the cars", src: "art/scenes/tt_10_couple.webp" },
{ name: "10 · Afterwards, the register", src: "art/scenes/tt_07_after.webp" }
]);
const advId = idFor("adventure:through_train");
const adventure = {
_id: advId,
name: "THROUGH TRAIN — The Mercy Draw Crossing",
img: IC("actor_case") ?? ICON.caseFile ?? ICON.actor,
caption: "The crossing starter. Three and a half hours, and nothing modern goes through.",
description:
"<p><em>A train has been running the same four miles of track since the third of "
+ "November, 1881. It has never stopped. There are sixty-one people on it and they "
+ "are all still alive.</em></p>"
+ "<p>A railway company laid track to a filed survey instead of to the ground, then "
+ "described the line as being where it was not — ten thousand times, for six "
+ "months, at a constant speed. Reality keeps a record of what belongs where, and "
+ "that is the most patient argument there is.</p>"
+ "<p>The running is what holds the crossing open. The agents have to board a train "
+ "that cannot stop, get sixty-one people off it, and decide what to do about the "
+ "arithmetic when they find they cannot.</p>"
+ "<p>Also the case that teaches Standing Order 9: <strong>nothing modern goes "
+ "through</strong>. Seven GM pages, six handouts including the survey that started "
+ "it, the case file, four of the people aboard, and seven plates to show.</p>",
actors: castDocs,
items: [],
journal: [
{ ...gmJournal.doc, pages: gmJournal.pages },
{ ...handouts.doc, pages: handouts.pages },
{ ...showJournal.doc, pages: showJournal.pages }
],
scenes: [], combats: [], tables: [], macros: [], cards: [], playlists: [], folders: [],
sort: 0, ...BASE()
};
await writePack("throughtrain", [[`!adventures!${advId}`, adventure]]);
console.log(` throughtr THROUGH TRAIN — ${castDocs.length} actors, `
+ `${X.TT_PAGES.length} GM pages, ${X.TT_HANDOUTS.length} handouts, `
+ `${showJournal.pages.length} plates`);
}
// LAST ADMISSION — the two-hour prologue, and the one to run by default. Same shape as
// OPEN DAY's adventure and for the same reason: journals and cast only, because the
// player characters are the "(before)" pregens in the Pregens pack.
+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();
+18
View File
@@ -179,6 +179,24 @@ const checks = [
["coherenceBandFrom(4).id", RULES.coherenceBandFrom(4).id, "adrift"],
["coherenceBandFrom(1).id", RULES.coherenceBandFrom(1).id, "unmoored"],
["coherenceBandFrom(0).id", RULES.coherenceBandFrom(0).id, "displaced"],
// Panic. The free first point is deliberate, so 1 must cost nothing.
["panicBandFrom(0).id", RULES.panicBandFrom(0).id, "steady"],
["panicBandFrom(1).id", RULES.panicBandFrom(1).id, "rattled"],
["panicBandFrom(3).id", RULES.panicBandFrom(3).id, "losing"],
["panicBandFrom(5).id", RULES.panicBandFrom(5).id, "broken"],
["panicPenaltyFor(1)", RULES.panicPenaltyFor(1), 0],
["panicPenaltyFor(2)", RULES.panicPenaltyFor(2), -5],
["panicPenaltyFor(4)", RULES.panicPenaltyFor(4), -20],
["panicBreaksAt(4)", RULES.panicBreaksAt(4), false],
["panicBreaksAt(5)", RULES.panicBreaksAt(5), true],
["panicAfter(4,1)", RULES.panicAfter(4, 1), 5],
["panicAfter(5,1)", RULES.panicAfter(5, 1), 5],
["panicAfter(0,-1)", RULES.panicAfter(0, -1), 0],
["panicReliefFor('success')", RULES.panicReliefFor("success"), 1],
["panicReliefFor('special')", RULES.panicReliefFor("special"), 2],
["panicReliefFor('failure')", RULES.panicReliefFor("failure"), 0],
["panicReliefFor('fumble')", RULES.panicReliefFor("fumble"), -1],
// a lost location must cost something, for every kind the species tables use
["locationEffectsFor([]) is inert", RULES.locationEffectsFor([]).all, 0],
["leg disabled → physical", RULES.locationEffectsFor([{kind:"leg",disabled:true}]).physical, -30],
+7
View File
@@ -75,6 +75,13 @@ export const SKILL_CATALOGUE = [
["navigate", "", "mental", 10, "Navigate"],
["research", "", "mental", 1, "Research"],
["anomaly_lore", "", "mental", 0, "Anomaly Lore", { canBeTaught: false }],
// Added after the first game, which wanted all three and had none of them. Occult and
// Religion are the two halves of what people believed BEFORE the department existed —
// one is what the books say, the other is what the congregation does — and Psychology
// is the only skill that steadies somebody who is not you.
["occult", "", "mental", 5, "Occult"],
["religion", "", "mental", 5, "Religion"],
["psychology", "", "mental", 5, "Psychology"],
// perception
["listen", "", "percep", 25, "Listen"],
["spot", "", "percep", 25, "Spot"],
-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 {
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";
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:
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.")
@@ -419,6 +453,20 @@ export const PRIMER_PAGES = [
+ "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 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")
+ CARDS([
["1 · Difficulty",
+50 -3
View File
@@ -28,7 +28,8 @@ import {
powerStartingRating, brawlFallbackFor, bleedStoppedBy,
crossingArtPoolFor, crossingGrantsArt, crossingCostFor,
REACTION, BLEED, CIRCUMSTANCE, RANGE_LADDER, rangeBandFrom,
MAJOR_WOUND_TABLE, COHERENCE_BANDS, COHERENCE_COSTS, COHERENCE_RECOVERY
MAJOR_WOUND_TABLE, COHERENCE_BANDS, COHERENCE_COSTS, COHERENCE_RECOVERY,
PANIC_BANDS, PANIC_TRIGGERS, PANIC_RELIEF, PANIC_MAX, PANIC_AFTER_BREAK, PANIC_BREAK
} from "../rules.mjs";
// Postings, trades and induction are plain data and are imported as such.
import { ROLES, TRADES, INDUCTION, TIERS, TRADE_BANDS } from "../postings.mjs";
@@ -75,6 +76,13 @@ const woundRows = MAJOR_WOUND_TABLE.map(r => {
return [`${r.min}`, L(`RINGBRP.Wound.${r.key}.title`), bits.join(" · ") || "—"];
});
const panicRows = [...PANIC_BANDS].reverse().map(b =>
[String(b.min), L(b.label), b.all ? `${pc(b.all)} everything` : "none", L(b.note)]);
const panicTrigRows = Object.values(PANIC_TRIGGERS)
.map(t => [L(t.label), `+${t.cost}`]);
const panicReliefRows = Object.values(PANIC_RELIEF)
.map(r => [L(r.label), r.reach, r.once]);
const cohRows = COHERENCE_BANDS.map((b, i) => {
const hi = i === 0 ? 10 : COHERENCE_BANDS[i - 1].min - 1;
const rng = b.min === hi ? `${b.min}` : `${hi}–${b.min}`;
@@ -518,6 +526,45 @@ return does not strand you either — you arrive near your target rather than at
There is no state in which an agent cannot get home. There are only states in which
getting home costs more than the case was worth.</p>` },
{ name: "4b · Panic", content: `
<p><em>Coherence is what knowing has cost you, and it is measured in weeks. Panic is
what is happening to you now, and it is measured in minutes. Do not confuse them at
the table: one goes down over a career, the other goes up over a corridor.</em></p>
<p>Panic runs from <strong>0 up to ${PANIC_MAX}</strong>. You take a point when
something happens to you — <strong>no roll</strong>. Asking for a die to find out
whether you are frightened slows the scene down and tells you how to feel. The die is
for getting rid of it.</p>
${table(["Panic", "Band", "Penalty", "What it looks like"], panicRows)}
<p>The first point is free. At Panic 1 you are rattled and you roll at no penalty,
because the opening shock of a scene should be colour rather than arithmetic.</p>
<h2>Taking a point</h2>
${table(["This happens", "Panic"], panicTrigRows)}
<h2>Getting rid of one</h2>
<p>Roll it. A success takes one point off; a <strong>special or better takes two</strong>.
A failure costs nothing but the moment you spent on it — and a <strong>fumble puts a
point ON</strong>. Trying to steady somebody and making it worse is the most familiar
thing on this list.</p>
${table(["How", "Reaches", "How often"], panicReliefRows)}
<p><strong>Occult is the odd one.</strong> Naming a thing correctly, out loud, helps
everybody who can hear you — which is the department's actual work, and the only relief
that reaches a whole room. <strong>Psychology</strong> is the only one that steadies
somebody who is not you. <strong>Religion</strong> steadies you and nobody else, and it
does not care whether you are right.</p>
<h2>Breaking</h2>
<p>At ${PANIC_MAX} you break. Choose one — <strong>${PANIC_BREAK.join(", ")}</strong> —
take it, and come back at <strong>${PANIC_AFTER_BREAK}</strong>. You do not come back at
zero. Breaking is not a rest.</p>
<p>Panic clears when the scene does. When you are somewhere safe with the door shut and
somebody has made tea, you are at nought again.</p>` },
{ name: "5 · The Quiet Arts", content: `
<p><em>Some agents come back with something. It is not a gift and it is not
training — it is the crossing, left in a person.</em></p>
@@ -730,8 +777,8 @@ ${table(["Location", "Share", "Capacity"], [
<h2>Rolls that ignore your skill</h2>
${table(["Roll", "Result"], [
["96–99", "always fails, whatever your skill"],
["00", "always a fumble"],
["96–99", "never succeeds, whatever your skill — and is a fumble if it is inside your fumble range"],
["00", "always a fumble, whatever your skill"],
["01", "always at least a critical chance — the floor is 1%"]
])}
+904
View File
@@ -0,0 +1,904 @@
/**
* THROUGH TRAIN — the crossing starter, as data.
*
* "Through train" is the railway term for a service that passes without stopping. It is
* also the department's case name, and it is the problem: the players must get aboard a
* train that cannot stop, because the running is what holds the crossing open.
*
* The supernatural here is science fiction underneath. Nobody aboard is dead and nothing
* is haunting anybody. In 1881 a railway company laid track to a filed survey rather than
* to the ground, then described the resulting line ten thousand times — timetables,
* waybills, dispatches, telegraph orders — as being somewhere it was not. Reality keeps a
* record of what belongs where, and an argument made at a constant speed on a fixed line
* for six months is the most patient argument there is. On 3 November 1881 the record
* gave way, and the train that was inside it at 11:40 has been running ever since.
*
* It is a standing wave. The motion sustains the error the way a bow sustains a note.
*
* What repeats is not just the train. At 11:40 on 3 November 1881 there was a GUNFIGHT
* aboard over the payroll, and the four minutes that contains it is what the loop is
* made of. It runs again on every pass, the same four minutes, and it has run about two
* million times.
*
* TWO SIDES AND NEITHER OF THEM IS THE VILLAIN. The company's paid guards are protecting
* a payroll they were hired to protect. The men who boarded at the water stop are owed
* that payroll and are carrying a court writ that says so. Both have paper. Both believe
* the others are thieves. The company sold the same money twice and is not aboard.
*
* Closing the crossing does not kill anybody. It RELEASES them: the loop ends, 3 November
* 1881 resumes, and every person aboard goes on to live the life they would have lived —
* including the ones who, on the real third of November, did not survive the next four
* minutes. That is the weight of the ending, and it is a much better weight than the one
* this scenario carried before.
*
* This is also the scenario that teaches the standing order on period crossings: NOTHING
* MODERN GOES THROUGH. The catalogue already carries the 1881 issue — Webley, Martini-
* Henry, oil lantern, brass compass, pocket watch — so the constraint costs the GM
* nothing to enforce and gives the players something to feel.
*
* Full GM document: docs/scenarios/THROUGH_TRAIN.md.
*/
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 ART = (id, alt) =>
`<figure class="rb-plate"><img src="systems/ringbrp/art/scenes/${id}.webp" alt="${alt}"/></figure>`;
/* ---------------------------------------------------------------- */
export const TT_PAGES = [
{ name: "1 · Run this first", content:
ART("tt_01_draw", "Mercy Draw, and the line that should not be there")
+ P("<strong>THROUGH TRAIN</strong> is the crossing starter: the case where the "
+ "department sends agents through a portal and the far side is 1881. Three and a half "
+ "hours. Four to six agents from the duty roster — <strong>not</strong> the civilian "
+ "sheets; this one is the job.")
+ READ("A train has been running the same four miles of track since the third of "
+ "November, 1881. It has never stopped. There are sixty-one people on it and they "
+ "are all still alive.")
+ H(2, "The whole thing in seven lines")
+ OL([
"A railway company laid track to a <strong>filed survey</strong> instead of to the "
+ "ground, then described the line as being where it was not — ten thousand times, "
+ "in timetables and waybills and telegraph orders, for six months.",
"<strong>Reality keeps a record of what belongs where.</strong> An argument made at "
+ "a constant speed on a fixed line is the most patient argument there is.",
"On 3 November 1881 the record gave way. <strong>The running is what holds the "
+ "crossing open</strong>, and the train has not stopped since.",
"<strong>What repeats is four minutes with a GUNFIGHT in it.</strong> At 11:40 two "
+ "armed parties were shooting at each other over the payroll. That fight runs "
+ "again on every pass and has run about two million times.",
"<strong>Two sides, and neither is the villain.</strong> The company's guards are "
+ "protecting money they were hired to protect. The men who boarded are owed that "
+ "money and have a court writ saying so. The company sold it twice and is not "
+ "aboard.",
"The agents must <strong>board a train that cannot stop</strong>, survive four "
+ "minutes of live gunfire, and close the crossing.",
"<strong>Closing it does not kill anybody. It lets them go.</strong> The loop ends, "
+ "1881 resumes, and everyone aboard lives the life they would have lived — "
+ "including the ones who did not survive the real four minutes."
])
+ H(2, "How to run it")
+ UL([
"<strong>It is not a ghost train and nobody is haunting anybody.</strong> Every "
+ "person aboard is a person, on both sides of the gunfight.",
"<strong>The fight is genuinely dangerous and the players are in it.</strong> A "
+ "Webley does 1d10 and a Martini-Henry does 2d6+2 against agents with eleven to "
+ "sixteen hit points. Nobody aboard is aiming at the agents to begin with, and "
+ "everybody will once they move.",
"<strong>Do not resolve who is right.</strong> Both parties have paper, both "
+ "believe the others are robbers, and the players can learn enough to see that "
+ "and not enough to settle it. That is the design, not a gap.",
"<strong>The train cannot stop, and it cannot be flagged down.</strong> Not by "
+ "authority, not by standing on the line, not by a red lamp. Say so early and let "
+ "the table work on the real problem, which is matching its speed.",
"<strong>Let them find the fraud.</strong> The survey chart is the centre of the "
+ "case and it is a piece of paper. A table that reads it properly should feel "
+ "clever, not told.",
"<strong>The standing order has teeth.</strong> Anything modern taken across costs "
+ "a Coherence the first time it is used, and the far side notices. Do not fine "
+ "them for carrying it — charge them when they USE it.",
"<strong>Panic is live and this is where it earns its place.</strong> Boarding is "
+ "frightening; four minutes of gunfire in a wooden box is worse.",
"<strong>Killing everybody does not end it.</strong> The loop replays whatever "
+ "happens. A table that shoots its way through the first pass will meet all the "
+ "same men alive on the next one, which is the moment the scenario lands."
])
+ H(2, "Shape")
+ TABLE(["Section", "Time", "Ends when"], [
["Briefing &mdash; the standing order", "20 min", "they have swapped their kit and somebody has complained about it"],
["Act One &mdash; Mercy Draw, now", "50 min", "they know it is a survey fraud, and they have heard 11:40"],
["Act Two &mdash; Cordera, 1881", "70 min", "they are on the train"],
["Act Three &mdash; Aboard", "50 min", "they decide, and live with it"],
["Epilogue &mdash; the register", "20 min", "the department writes down what happened"]
])
+ P("<strong>Hard rule: at 2:45 on the clock, get them on the train.</strong> If the "
+ "boarding has not happened by then, the department's second wagon arrives and it "
+ "happens.")
},
{ name: "2 · What is actually happening", content:
P("<strong>April 1881.</strong> The Cordera &amp; Western, a narrow-gauge line running "
+ "ore out of the Cordera hills, needed four miles across a dry wash called "
+ "<strong>Mercy Draw</strong>. The company's surveyor, <strong>Elias Vane</strong>, was "
+ "told what the chart had to say before he went out to the ground. He filed a survey "
+ "showing the line on the north bench of the draw. The line was laid on the south "
+ "bench, because that is where the ground allowed it.")
+ P("Nobody was hurt by that. It is a paperwork lie of the dullest possible kind, and "
+ "for six months every timetable, waybill, dispatch and telegraph order in the "
+ "company's operation described a train running on the north bench.")
+ P("<strong>Reality keeps a record of what belongs where.</strong> It is a very good "
+ "record. It can be made to get it wrong by anything patient enough — and a train is "
+ "the most patient arguer there is: a fixed line, a constant speed, on the hour, "
+ "every day, saying the same wrong thing about where it was.")
+ H(2, "3 November 1881, 11:40 — and what was happening at the time")
+ P("The record gave way. Four miles of line came loose from the date, with No. 9 and "
+ "five cars on it and <strong>sixty-one people aboard</strong> &mdash; and a "
+ "<strong>gunfight already three seconds old</strong>.")
+ P("<strong>The loop is four minutes long and the fight is inside it.</strong> That is "
+ "the important structural fact. The crossing did not capture a train; it captured "
+ "four minutes, and those four minutes contain two armed parties shooting at each "
+ "other the length of car three. It has run about two million times.")
+ H(2, "The two sides, and why neither is the villain")
+ TABLE(["", "Who", "What they have", "What they believe"], [
["<strong>The coats</strong>",
"Four men hired by the Cordera &amp; Western to see the payroll to Fallon. Led by <strong>Cass Merritt</strong>.",
"A letter of engagement signed by the company, and the key to the box.",
"That the men who boarded at the water stop are robbers, because that is what men who board at a water stop are."],
["<strong>The writ men</strong>",
"Five miners of the Cordera works, eleven weeks unpaid. Led by <strong>Josiah Ambrose</strong>.",
"A writ of attachment from a territorial judge in Fallon, dated and sealed, naming that payroll.",
"That they are lawfully seizing money owed to them and being shot at for it."]
])
+ P("<strong>The company sold the same money twice and is not aboard.</strong> Both "
+ "pieces of paper are real. Neither party has ever seen the other's. Nobody in car "
+ "three is lying and nobody in car three is wrong.")
+ P("<strong>The players will want to settle it. They cannot.</strong> They can learn "
+ "enough to see there is nothing to settle, which is a different and better thing to "
+ "carry away.")
+ P("<strong>The running is what holds it open.</strong> This is the part that matters "
+ "and the part the players must work out: the train is not trapped inside the "
+ "crossing — the train IS the crossing. It is a standing wave, and the motion sustains "
+ "it the way a bow sustains a note.")
+ UL([
"<strong>Nobody is dead.</strong> Between passes the fight has not started yet: they "
+ "eat, they sleep in shifts, they have organised themselves. By their clocks it has "
+ "been about nine hours &mdash; and four minutes of it, over and over.",
"<strong>Nobody can get off.</strong> Step down and you are on ground that is not "
+ "inside the error. The department has three attempts on file and no survivors.",
"<strong>Nobody can get off while it runs.</strong> Step down between passes and "
+ "you are on ground that is not inside the error. Three departmental attempts are "
+ "on file and none returned.",
"<strong>Stop the train and the loop ends.</strong> This is the mission, and it is "
+ "not a mercy killing."
])
+ H(2, "What closing it actually does — read this twice")
+ P("<strong>Closing the crossing does not kill anybody.</strong> It lets them go. The "
+ "loop ends, the third of November 1881 resumes from 11:44, and every person aboard "
+ "walks forward into the life they would have had.")
+ P("<strong>Including the ones who did not survive the real four minutes.</strong> "
+ "History resumes as history: some of the men in car three died in that gunfight, and "
+ "when the loop lets go they die in it. Not because the agents killed them &mdash; "
+ "because that is what happened, and it has been prevented from happening for a "
+ "hundred and forty-five years.")
+ P("<strong>That is the weight of this ending and it is the whole point.</strong> The "
+ "agents are not choosing who lives. They are choosing to stop holding history still, "
+ "and they will have met the people it lands on.")
+ P("The department's position is unambiguous: <strong>close it</strong>. A crossing "
+ "held open by a repeating event degrades, and when it fails on its own &mdash; "
+ "Registry's read is within a year &mdash; everyone inside is lost outright rather "
+ "than returned. Waiting is the only option that is definitely worse.")
+ H(2, "Elias Vane is on the train")
+ P("He rode his own line on the third of November to see what he had signed for. He "
+ "has had nine hours and a hundred and forty-five years to think about it, he knows "
+ "exactly what he did, and <strong>he is the only person aboard who has worked out "
+ "what closing it would mean</strong>. He has not told anyone, including the men who "
+ "will die when it closes. He will tell the agents, if they ask him a question he "
+ "cannot answer with a lie.")
},
{ name: "3 · Briefing — the standing order", content:
ART("tt_02_stores", "Stores, and the 1881 issue")
+ P("<strong>Goal:</strong> give them the case, take their equipment away, and let them "
+ "feel it. The kit swap is not colour. It is the scene where the players learn what "
+ "kind of game this is.")
+ READ("You will be crossing to November 1881. Nothing you are carrying goes with you. "
+ "Stores has laid out what you may take instead, and I would ask you not to argue "
+ "with the storeman, because he is right and you are in a hurry.")
+ H(2, "The standing order, said plainly")
+ P("<strong>NOTHING MODERN GOES THROUGH. NOTHING.</strong> Not the handset, not the "
+ "torch, not the vest, not the sidearm you are used to, not a lighter, not a "
+ "wristwatch, not a pen with a maker's mark on it. The department does not negotiate "
+ "this and never has. Two reasons, and the GM should give both:")
+ OL([
"<strong>It costs you.</strong> An object that does not belong is a thing the far "
+ "side has to be wrong about too. <em>Using</em> a modern item across costs "
+ "<strong>1 Coherence</strong>, the first time, every item.",
"<strong>It is a beacon.</strong> This is a place where the record is already wrong "
+ "and already irritable. It notices things that do not fit. Using one moves the "
+ "case clock."
])
+ P("<strong>Charge them for USING it, never for carrying it.</strong> A player who "
+ "smuggles a torch through and never switches it on has made a perfectly good "
+ "decision and should get away with it.")
+ H(2, "The 1881 issue")
+ P("All of this is in the compendium already; hand it out as items.")
+ TABLE(["Instead of", "They get"], [
["a service pistol", "<strong>Webley revolver</strong> — 1d10, six shots, and it is slow to reload"],
["a carbine", "<strong>Martini-Henry</strong> — 2d6+2, and ONE shot before you work the lever"],
["a torch", "<strong>oil lantern</strong> — and lamp oil, which runs out"],
["the handset", "<strong>nothing</strong>. You are out of contact from the moment you go through."],
["a stab vest", "<strong>buff coat</strong> or an oilcloth coat"],
["the medkit", "<strong>chirurgeon's roll</strong> and <strong>laudanum</strong>"],
["GPS and a phone clock", "<strong>brass compass</strong>, <strong>pocket watch</strong>, <strong>spyglass</strong>"]
])
+ P("<strong>The anchor is the exception.</strong> Everybody keeps their anchor object. "
+ "It is theirs, it is of no value, and it is the one thing the department will not "
+ "take off them &mdash; which is exactly why it works.")
+ H(2, "The mission, in the department's own words")
+ READ("You are not there to rescue anybody and you are not there to save anybody's "
+ "life. Sixty-one people are being held still inside four minutes of 1881. Your job "
+ "is to close the crossing so that those four minutes are allowed to finish, and "
+ "those people are allowed to go on and live the rest of their lives — or not to, "
+ "exactly as they would have done had this department never existed. Then you come "
+ "home.")
+ OL([
"Establish what is holding the crossing open.",
"<strong>Close it</strong>, so the people aboard are released into their own history "
+ "&mdash; whatever their own history then does to them.",
"Come back with the survey chart. Registry wants the paperwork.",
"<strong>Come back.</strong> If it closes with you inside it you are one more name "
+ "in the register."
])
+ P("<strong>Say the second one out loud at the table.</strong> The players must "
+ "understand before they cross that closing it is the objective and that it is not a "
+ "killing. What they will not understand until they are aboard is that two of the "
+ "people they are about to meet did not survive the four minutes they are standing in.")
+ P("<strong>There is an armed exchange aboard.</strong> Two parties, four minutes, and "
+ "it repeats. Tell them that at the briefing. They should cross knowing they are "
+ "walking into gunfire with a revolver and a single-shot rifle.")
+ P("<strong>Containment 6 · Resources 5 · Clearance 4.</strong> Low clearance on "
+ "purpose: there is nobody in 1881 to show a warrant card to.")
},
{ name: "4 · Act One — Mercy Draw, now", content:
ART("tt_03_bench", "The south bench, and the grade that is still there")
+ P("<strong>Goal:</strong> they find the fraud, they hear the train, and they work out "
+ "that the crossing is on a timetable.")
+ READ("Nevada, and a dry wash four miles long with a railway grade running along the "
+ "south side of it. The rails were lifted in 1889. The ballast is still there, and so "
+ "are the ties, and the whole thing is as quiet as anywhere you have ever stood. "
+ "Your watch says 11:38.")
+ H(2, "The site")
+ UL([
"<strong>The grade on the south bench</strong> — real, obvious, and where the line "
+ "actually ran. Ballast, tie marks, a culvert.",
"<strong>The north bench</strong> — nothing. Never anything. No cutting, no fill, no "
+ "ballast. <em>[CUS: Spot or Engineering — there has never been a railway on this "
+ "side of the draw]</em>",
"<strong>The water tower footing</strong>, half a mile up, at the top of the grade. "
+ "Remember it. It is where Act Two ends.",
"<strong>11:40.</strong> Every day. A locomotive whistle, a long way off and quite "
+ "clear, and then the sound of a train working a grade, for about four minutes, "
+ "getting no nearer. <strong>Automatic. Nobody has to roll for it.</strong>"
])
+ H(2, "The paperwork, which is the case")
+ P("The county records office in Cordera has the company's filed survey. Registry has "
+ "already pulled the timetable and a bundle of waybills.")
+ TABLE(["#", "Clue", "How found", "Type"], [
["1", "The filed survey puts the line on the NORTH bench", "<strong>Automatic</strong> from the handout — it is a drawing with a line on it", "Essential"],
["2", "There has never been a railway on the north bench", "<strong>Automatic</strong> to anyone who walks it", "Essential"],
["3", "Six months of timetables and waybills all describe the north-bench line", "<em>[CUS: Research]</em>, or Registry hands it over", "Essential"],
["4", "The last dispatch is timed 11:38, 3 Nov 1881, and there is no 11:45", "<em>[CUS: Research]</em>", "Essential"],
["5", "Elias Vane, surveyor, is on the passenger manifest", "<em>[CUS: Research]</em> on the manifest handout", "Optional"],
["6", "Three previous departmental attempts, no survivors, all of them tried to STOP it", "Registry, if asked", "Optional"],
["7", "The sound is a train working UP a grade — it is accelerating, always", "<em>[CUS: Listen or Engineering]</em>", "Optional"]
])
+ P("<strong>Fallbacks.</strong> Clue 1 is a handout. Clue 2 is the ground. Clue 3 and 4 "
+ "arrive from Registry by telephone at the twenty-minute mark if nobody has gone "
+ "looking, because Bewley is thorough and slightly impatient.")
+ H(2, "The crossing")
+ P("It opens at <strong>11:40</strong>, on the grade, for about four minutes, and it is "
+ "the train going past. You do not step through a door — you step onto a moving line "
+ "at the moment the line is there. <em>[CUS: Athletics, and a hand from somebody]</em>")
+ P("<strong>They cannot board it here.</strong> At this end it is going too fast and the "
+ "grade is against them. Let them try. It is a bruise and a lesson and it costs the "
+ "case clock one.")
+ H(2, "Pacing note")
+ P("At 1:10 &mdash; <strong>fast-track:</strong> the 11:40 comes round again and this "
+ "time the door stays open long enough to walk through, because the crossing is "
+ "degrading and that is what degrading looks like.")
},
{ name: "5 · Act Two — Cordera, 1881", content:
ART("tt_04_cordera", "Cordera, and a company that has lost a train")
+ P("<strong>Goal:</strong> the far side, the standing order in practice, and the "
+ "boarding.")
+ READ("Sagebrush, and a town of about four hundred people that smells of woodsmoke and "
+ "horses. It is the fourth of November, 1881, and it is the day after. Nobody here "
+ "is frightened yet. They are just puzzled, because a train went into Mercy Draw "
+ "yesterday morning and has not come out, and the wire from the other end says it "
+ "never arrived.")
+ H(2, "What Cordera has")
+ UL([
"<strong>The company office.</strong> A dispatcher, a wall of orders on spikes, and "
+ "the filed survey in a drawer &mdash; the original of the handout.",
"<strong>The livery.</strong> Horses. This matters: horses are how you match a train.",
"<strong>Ada Reyes</strong>, who runs the telegraph and has been sending the same "
+ "query up the line for a day and a half.",
"<strong>The Draw itself</strong>, four miles out, where the train can be HEARD "
+ "going past on the south bench about every forty minutes, and seen if you are "
+ "standing in the right place."
])
+ P("<strong>Nobody in 1881 can help them stop it and everybody will help them get near "
+ "it.</strong> The company wants its train back. That is leverage the agents should "
+ "be allowed to use.")
+ H(2, "The standing order, in practice")
+ P("This is where the kit swap pays. A Webley is six shots and a slow reload. The "
+ "Martini-Henry is one shot at a time. There is no torch, and the lantern needs oil. "
+ "There is no radio, so a party that splits is genuinely out of contact.")
+ P("<strong>If somebody uses something modern:</strong> 1 Coherence, the case clock "
+ "moves, and something in the Draw turns its attention their way. Do not make it a "
+ "telling-off. Make it interesting and expensive.")
+ H(2, "THE SET-PIECE — getting aboard")
+ ART("tt_05_boarding", "The water tower, and four minutes of grade")
+ ART("tt_10_couple", "The gap between the cars")
+ P("The train cannot stop and cannot be flagged. The only way on is to <strong>match "
+ "its speed</strong>, and the only place it is slow is <strong>the top of the grade "
+ "at the water tower</strong>, half a mile up, where it is doing about eight miles an "
+ "hour for perhaps forty seconds.")
+ P("<strong>Three ways, and let them invent a fourth:</strong>")
+ OL([
"<strong>Ride it down.</strong> Horses from the livery, alongside on the bench, and "
+ "step across. <em>[CUS: Ride, then Athletics]</em>. Fast, frightening, and the "
+ "horse will not go near the train without a Ride roll of its own.",
"<strong>Drop from the tower.</strong> Get up the tower and drop onto the cars as "
+ "they pass under. <em>[CUS: Athletics, AVERAGE &mdash; only a fumble is a fall]</em>. "
+ "Twelve feet onto a moving roof. <strong>Do not make this difficult.</strong> "
+ "Playtested at halved skill it put four agents on 12&ndash;31% and all four missed, "
+ "which stalls the one scene the scenario is built around.",
"<strong>The wagon on the grade.</strong> Company wagon, four horses, run it "
+ "alongside on the wash floor and put a plank across. Slowest to set up, safest to "
+ "cross, and it needs the company's cooperation."
])
+ P("<strong>The wagon is easier than the tower and the tower is easier than the "
+ "horses.</strong> A party that spends an hour arranging the wagon has bought itself "
+ "an easy roll and should get one &mdash; that is what the hour was for.")
+ P("<strong>Say which route suits whom, out loud, before they choose.</strong> "
+ "Athletics on a duty-roster sheet runs from about 25% to 60%, and the split is not "
+ "subtle: the Containment officer and the criminal take the tower, the Field Lead "
+ "can, and <strong>the doctor and the Registry officer should be on the wagon</strong>. "
+ "Playtested by putting all five on the tower: two of them missed twice and had to be "
+ "carried by the department's plank, which cost two passes on the clock and made "
+ "them passengers in their own set-piece.")
+ P("<strong>Everybody has to get on.</strong> Anyone left behind is out of the scenario "
+ "and watching from the bench, so give a failed attempt a second chance on the next "
+ "pass rather than a full stop. The train comes round again in about forty minutes, "
+ "and the case clock moves when it does.")
+ P("<strong>Panic:</strong> boarding is worth a point to anybody who wants one — a "
+ "moving train, twelve feet down, and nothing underneath. Do not force it. Offer it.")
+ H(2, "Pacing note")
+ P("<strong>At 2:45 they are on the train, one way or another.</strong> If the boarding "
+ "has failed twice, the department's second wagon arrives with two more agents and a "
+ "plank, and it works, and the players owe somebody a favour.")
},
{ name: "6 · Act Three — Aboard, and the four minutes", content:
ART("tt_06_car", "Car three, between passes")
+ P("<strong>Goal:</strong> survive a gunfight nobody started for their benefit, work "
+ "out that it repeats, and close the crossing anyway.")
+ READ("It is warm. That is the first thing. Sixty-one people in five cars, and it "
+ "smells of bodies and coal smoke and coffee. Somebody has organised a rota for the "
+ "water. A child is asleep across two seats with a coat over her. They look up when "
+ "you come through the door, and one of them says: <em>&ldquo;You want to be "
+ "somewhere else in about six minutes.&rdquo;</em>")
+ H(2, "Between passes — perhaps twenty minutes of quiet")
+ P("The agents arrive in the calm part. Use it. Everyone aboard knows exactly what is "
+ "coming and has had two million rehearsals of it.")
+ TABLE(["Where", "What is there"], [
["<strong>Locomotive</strong>", "Marius Kell, engineer, who has not let go of the regulator."],
["<strong>Tender, car one</strong>", "Coal that has not run out. The first hard evidence that this is sustained, not surviving."],
["<strong>Car two</strong>", "Most of the passengers. They lie down before it starts. They have a drill."],
["<strong>Car three</strong>", "<strong>Where the fight happens.</strong> Overturned seats at both ends. Splinter damage that is not there twenty minutes later."],
["<strong>Car four</strong>", "Freight. The payroll box, bolted down. The signed survey in the company satchel."],
["<strong>Car five</strong>", "Elias Vane, alone, watching the line come out from under."]
])
+ H(2, "THE FOUR MINUTES — the repeating gunfight")
+ ART("tt_08_gunfight", "Car three, at 11:41")
+ P("<strong>It starts when the whistle sounds for the grade and it lasts four "
+ "minutes.</strong> Run it in rounds. It happens on <em>every</em> pass, and it is "
+ "the same four minutes every time: the same men, the same shots, the same two "
+ "passengers hit in the second minute.")
+ TABLE(["Minute", "What happens if nobody interferes"], [
["<strong>1</strong>", "Ambrose's five come forward from car four. Merritt's four are already at the front of car three. Somebody shouts <em>put it down</em>. Both sides think they said it."],
["<strong>2</strong>", "First volley, the length of the car. <strong>Two passengers are hit</strong> — a miner named Doyle, and Mrs Danforth's arm. This happens every single time."],
["<strong>3</strong>", "Merritt's men fall back to the car four door and hold it. Ambrose's take the overturned seats. Reloading — a Webley is six and a Martini is one."],
["<strong>4</strong>", "Ambrose gets to the payroll box. Merritt shoots him through the door. The whistle sounds for the top of the grade, and it all resets."]
])
+ H(2, "HOW MUCH FIRE THE AGENTS DRAW — read this before running the fight")
+ P("<strong>The two sides are shooting at each other, not at the agents.</strong> That "
+ "is the single most important thing to hold on to, and getting it wrong turns this "
+ "scene into a massacre. Do not have nine armed men fire at the party. Work out what "
+ "each agent is DOING and give them that much fire and no more.")
+ TABLE(["What the agent is doing", "Shooters on them", "Cover", "What happens, measured over 300 runs of the four minutes"], [
["<strong>Head down, not acting</strong>", "none", "&mdash;",
"Untouched. Nobody is interested in them. A player may sit out a pass and that is a legitimate choice."],
["<strong>Acting from cover</strong> — talking, dragging somebody clear, working along the seat backs",
"1", "&minus;30%",
"<strong>Two of four hurt, half a party member down, no wipes.</strong> This is the intended shape of the scene."],
["<strong>Moving in the open</strong> — crossing the aisle, up the car",
"2", "&minus;30%",
"Three of four hurt, one down. One party in three hundred is wiped. Properly frightening."],
["<strong>Going for the payroll box</strong> — the one thing both sides will stop everything to prevent",
"2", "none",
"<strong>Three and a half of four down. More than half of all parties are wiped.</strong>"]
])
+ P("<strong>Tell them the last row before they commit to it, not after.</strong> Going "
+ "for the box is a way to die and the players are entitled to know that it looks like "
+ "one. Everything else in this list is survivable.")
+ P("<strong>Nobody has to roll to hide.</strong> Taking cover and staying there is not "
+ "a skill check. It is a decision, and it works.")
+ H(2, "THE LOOP RESETS. THE AGENTS DO NOT.")
+ P("Everybody who belongs to 1881 is back on their feet at the next pass, unhurt, with "
+ "no memory of having been shot. <strong>The agents are not part of the loop and "
+ "their wounds are permanent.</strong> Say this at the table before the first volley: "
+ "a player who thinks the reset will heal them will take risks that kill them.")
+ P("A downed agent is <em>down</em>, not dead &mdash; the dying rules apply as normal, "
+ "and the only medicine aboard is a chirurgeon's roll and a bottle of laudanum. "
+ "First Aid is the whole answer and somebody has to stop shooting to give it.")
+ P("<strong>A playtested pass, four agents, all acting from cover:</strong> two down at "
+ "0 hit points, one on a quarter, one untouched because he stayed on the floor. That "
+ "is a bad pass rather than a typical one &mdash; the average is half a party member "
+ "&mdash; but it is entirely possible on the first one, and it is why the reset rule "
+ "has to be said out loud in advance.")
+ H(2, "The agents are in it")
+ UL([
"<strong>Nobody is aiming at them to begin with.</strong> Two armed parties who have "
+ "never seen them before are firing the length of a wooden box they are standing in.",
"<strong>Everybody aims at them the moment they act.</strong> Cross the aisle, draw, "
+ "shout, or move toward the box, and both sides read them as the other side's "
+ "reinforcements. That is the most likely way an agent dies tonight.",
"<strong>It is lethal.</strong> Webley 1d10, Martini-Henry 2d6+2, against agents on "
+ "eleven to sixteen hit points. <strong>Two hits can kill.</strong> Say so before "
+ "the first round, not after &mdash; and use the exposure table above, because "
+ "unrestricted fire from all nine wipes the party five times out of six.",
"<strong>Cover is real and it is wood.</strong> Overturned seats and the car ends "
+ "are cover; the walls are not. A miss that would have hit the wall goes through it.",
"<strong>Panic:</strong> a point to anyone in car three at minute two, and a point "
+ "to anyone who sees the two passengers go down. That is the fight doing its job."
])
+ H(2, "What the players will try, and what happens")
+ TABLE(["They try", "What it does"], [
["<strong>Shout, show a warrant card, take charge</strong>", "Nothing. There is no authority aboard that either side recognises, and the department's card means nothing in 1881. <em>[CUS: Persuade at −30% for the first pass; it gets easier on later passes as they learn names]</em>"],
["<strong>Shoot one side</strong>", "It works, and it changes nothing. Everyone is back on the next pass. If the players kill men on pass one and meet them alive on pass two, <strong>let that land in silence.</strong>"],
["<strong>Get the passengers down</strong>", "Genuinely useful and completely possible. Doyle and Mrs Danforth can be pulled clear before minute two. They will be hit again next pass unless the loop ends."],
["<strong>Get to the payroll box</strong>", "The obvious idea, and it makes them a target for both sides at once. The box is bolted and the key is on Merritt."],
["<strong>Show one side the other's paper</strong>", "<strong>This is the good move.</strong> See below — it does not stop the fight, but it is how they find out what they are looking at."],
["<strong>Get to the locomotive</strong>", "Through car two and the tender, under fire in the gap between cars. This is the route to the ending."]
])
+ ART("tt_09_payroll", "The payroll box, car four")
+ H(2, "The paper — the ambiguity, made findable")
+ P("<strong>Merritt</strong> carries a letter of engagement from the Cordera &amp; "
+ "Western, signed and sealed, instructing him to see the payroll to Fallon. "
+ "<strong>Ambrose</strong> carries a writ of attachment from a territorial judge, "
+ "dated and sealed, naming that same payroll against eleven weeks of unpaid wages.")
+ P("<strong>Both are genuine. Neither man has seen the other's.</strong> An agent who "
+ "gets both in the same hand has the whole truth of it: the company sold the same "
+ "money twice, and then put four men and five men on the same train with it.")
+ P("<strong>NO ROLL IS NEEDED TO SEE EITHER PAPER.</strong> Between passes, Merritt "
+ "shows his letter to anybody who asks &mdash; he is proud of it, it is his "
+ "authority, and he has had a hundred and forty-five years with nobody to show it "
+ "to. Ambrose shows the writ to anybody who is not currently shooting at him, "
+ "because having it read is the entire thing he wants. <strong>Do not put Persuade "
+ "in front of this.</strong> Playtested with a real team, the Field Lead's Persuade "
+ "was 15% and fell to 1% under the crowd penalty, which turned the scene the "
+ "scenario is proudest of into an impossible roll.")
+ P("<strong>It does not stop the fight.</strong> Merritt will not stand down for a "
+ "piece of paper from a judge he has never heard of while men are shooting at him, "
+ "and Ambrose has eleven weeks of nothing behind him. <strong>Showing them changes "
+ "nothing on this pass and everything about what the players understand.</strong>")
+ H(2, "Closing it")
+ P("<strong>Kell will stop the train if a person he believes has the authority tells "
+ "him to, out loud, to his face.</strong> He has been waiting a hundred and forty-five "
+ "years to be told. He will not do it on a note and he will not do it for a shout "
+ "from the tender.")
+ P("Getting to him means crossing the tender, in the gap between the cars, during or "
+ "between the four minutes. <em>[CUS: Athletics for the gap]</em>")
+ P("<strong>Convincing him takes NO ROLL if they can tell him one true thing about his "
+ "own train.</strong> That the coal has not run out. That it is the same four "
+ "minutes. That the line was never on the north bench. Any of it. He has been "
+ "waiting a hundred and forty-five years for somebody who knows what is wrong to "
+ "come and say so, and the moment they do he will ask them to say when.")
+ P("<strong>Only a party that can tell him nothing has to roll</strong>, and then it "
+ "is Persuade or Status at average, and a failure means he wants another pass to "
+ "think. <em>Do not gate the ending on Persuade.</em> Seven of the ten duty-roster "
+ "agents have neither Persuade nor Status trained; playtested, three separate "
+ "attempts on Kell all failed and the scenario had no way to end.")
+ P("<strong>When the brakes go on, the loop lets go.</strong> Four minutes finish for "
+ "the first time since 1881, and everyone aboard walks forward into their own lives. "
+ "Doyle and Ambrose do not get up. They never did.")
+ H(2, "The choices")
+ TABLE(["Choice", "What it costs"], [
["<strong>Close it as ordered</strong>", "The mission, done. Fifty-nine people released into their own century. Two men die of wounds they took in 1881, in 1881, four minutes after they took them."],
["<strong>Close it, having first pulled Doyle and Mrs Danforth clear</strong>", "The compromise, and it works — for those two. The players cannot save Ambrose; Merritt shoots him at minute four every time and only stopping the train stops that."],
["<strong>Try to end the fight first</strong>", "Admirable and probably impossible in the time. Every pass spent trying moves the crossing clock, and at 6 it closes with everybody inside."],
["<strong>Refuse, and leave it running</strong>", "Everyone lives, today, for a value of living that is four minutes long. Registry logs it. It fails on its own inside a year and then nobody is released at all."]
])
+ P("<strong>No roll resolves the decision.</strong> Say so if a player reaches for dice.")
+ H(2, "If they ask Vane")
+ READ("I filed a chart that said the line was somewhere it was not, because a man told "
+ "me to and I wanted the work. Six months of honest railwaymen repeating it made it "
+ "true. Now — you have seen the middle of the car. Two of those men do not get up "
+ "when it stops. They have not got up two million times and they will not get up "
+ "now. I would still stop it. It is not our place to hold a thing still because we "
+ "have grown fond of it.")
},
{ name: "7 · Epilogue, cast, and what not to resolve", content:
ART("tt_07_after", "The register, afterwards")
+ P("However it ends, the last scene is the same shape: a Registry room, a form, and "
+ "somebody writing down sixty-one names.")
+ H(2, "Outcomes")
+ TABLE(["Ending", "What the register says"], [
["<strong>Closed as ordered</strong>", "Fifty-nine people walk forward into their own century. Josiah Ambrose and a miner named Doyle die of wounds taken on 3 November 1881, four minutes after they took them. The file is closed. Registry counts it a success and it is one."],
["<strong>Closed, with the two passengers pulled clear first</strong>", "As above, and Mrs Danforth keeps her arm. Nobody could save Ambrose; Merritt shoots him at minute four on every pass, and only closing it stops that, and closing it is what kills him."],
["<strong>Closed late, after several passes spent trying to end the fight</strong>", "The same ending, arrived at more expensively. If the crossing clock reached 6 first, add the agents to the list."],
["<strong>Left running</strong>", "Logged, monitored, inherited by whoever holds the case in a year. Sixty-one people continue to live four minutes. When it fails on its own nobody is released at all. Perfectly defensible. Nobody thanks them."]
])
+ P("<strong>Registry's note on any ending:</strong> <em>&ldquo;The department did not "
+ "cause these deaths. It ceased to prevent them. The distinction matters to the file "
+ "and it is not expected to matter to the agents.&rdquo;</em>")
+ H(2, "Cast")
+ TABLE(["Who", "Wants", "Key line"], [
["<strong>Cass Merritt</strong>, hired guard", "to see the box to Fallon, as engaged", "&ldquo;I have a letter. Whatever you have, I have a letter.&rdquo;"],
["<strong>Josiah Ambrose</strong>, miner", "eleven weeks of wages, lawfully", "&ldquo;There is a judge's seal on this. Read it and then shoot me.&rdquo;"],
["<strong>Marius Kell</strong>, engineer", "to be allowed to stop", "&ldquo;You tell me when. I'll not do it before you say.&rdquo;"],
["<strong>Elias Vane</strong>, surveyor", "to be asked, at last", "&ldquo;I signed it. Six months of honest men made it true.&rdquo;"],
["<strong>Hester Bligh</strong>, conductor", "her passengers accounted for", "&ldquo;Sixty-one aboard. I have counted them every hour since.&rdquo;"],
["<strong>Ada Reyes</strong>, telegraph, Cordera", "an answer up the line", "&ldquo;It never got to Fallon. I've sent it forty times.&rdquo;"],
["<strong>Mr Bewley</strong>, Registry", "the chart, and the truth written down", "&ldquo;Bring me the paper. The paper is the case.&rdquo;"]
])
+ H(2, "Do not resolve")
+ UL([
"<strong>Who was in the right.</strong> Both papers are genuine, the company sold "
+ "the money twice, and the company is not aboard to answer for it. If a player asks "
+ "the GM to adjudicate, do not.",
"<strong>Why the coal has not run out.</strong> The standing wave sustains it. That "
+ "is as far as anybody gets, including Vane.",
"<strong>What is at the far end of the line.</strong> Nobody aboard has ever reached "
+ "it. The train works the grade and comes round. Let that stay strange.",
"<strong>Whether closing it was a rescue.</strong> Fifty-nine people say yes. Two "
+ "men do not get to say."
])
+ H(2, "GM notes")
+ UL([
"<strong>Play the passengers as people, hard</strong> &mdash; and the nine men in "
+ "the fight hardest of all. The scenario dies the moment anybody becomes a number.",
"<strong>Never let the players win the fight.</strong> They can survive it, shape "
+ "it, and pull people out of it. They cannot end it. Only the brakes end it.",
"<strong>The standing order is the tutorial.</strong> One shot at a time, no radio, "
+ "an oil lantern that runs out. Do not soften it and do not apologise for it.",
"<strong>Cut first:</strong> the town of Cordera, down to the livery and the "
+ "telegraph office. Never cut the boarding.",
"<strong>If they crack it early</strong>, run the passes: six of them, forty minutes "
+ "apart, and let the table feel the arithmetic they are betting on."
])
}
];
/* ---------------------------------------------------------------- */
export const TT_HANDOUTS = [
{ name: "1 · Case brief", content:
P("<strong>DEPARTMENTAL CUSTODY SERVICE</strong><br>"
+ "<strong>CASE:</strong> TT-004 / MERCY DRAW<br>"
+ "<strong>CLASSIFICATION:</strong> ACROSS &mdash; PERIOD<br>"
+ "<strong>FAR SIDE:</strong> 3&ndash;4 November 1881, Nevada Territory")
+ P("A crossing has stood open in a dry wash in Nevada since 1881. It is held open by a "
+ "narrow-gauge train, No. 9 of the Cordera &amp; Western, which has been running the "
+ "same four miles of track for a hundred and forty-five years and has never stopped.")
+ P("<strong>Sixty-one people are aboard. They are alive.</strong> By their reckoning it "
+ "has been about nine hours.")
+ P("<strong>There is an armed exchange in progress aboard.</strong> Two parties, four "
+ "minutes, and it repeats on every pass. It has repeated approximately two million "
+ "times. Both parties are armed with period weapons and both are lethal.")
+ H(2, "Orders")
+ OL(["Establish what holds the crossing open.",
"<strong>Close it.</strong> The people aboard are then released into their own "
+ "time to live out the remainder of their lives &mdash; or not to, exactly as "
+ "they would have done had this department never existed. That is the objective. "
+ "It is not a rescue and it is not an execution.",
"Recover the filed survey. Registry wants the paper.",
"<strong>Be off it when it closes.</strong>"])
+ P("<strong>You are not authorised to prevent any death that would have occurred on 3 "
+ "November 1881.</strong> You are authorised to stop preventing them.")
+ P("<strong>Containment 6 · Resources 5 · Clearance 4</strong>")
+ P("<em>Three previous attempts are on file. None returned. All three tried to stop "
+ "the train first.</em>")
},
{ name: "2 · Standing Order 9 — period crossings", content:
P("<strong>STANDING ORDER 9</strong><br>CROSSINGS TO A DATED FAR SIDE")
+ P("<strong>NOTHING MODERN GOES THROUGH.</strong> No exceptions are granted for "
+ "convenience, and none has ever been granted for safety. You will be shot at with "
+ "1881 weapons and you will answer with 1881 weapons.")
+ OL([
"An object that does not belong to the far side is a thing the far side must also "
+ "be wrong about. <strong>Using such an object across costs one Coherence, per "
+ "object, the first time.</strong>",
"It is also a beacon. A crossing is a place where the record is already wrong. It "
+ "notices what does not fit.",
"<strong>Carrying is not using.</strong> The department does not search you. It "
+ "simply advises you that the cost falls when the thing is switched on, drawn, "
+ "worn or fired.",
"<strong>Your anchor is exempt.</strong> It is yours, it is of no value, and it is "
+ "the only object the department will not take off you.",
"Stores will issue to the period. Do not argue with the storeman."
])
+ P("<em>Signed for Registry. This order is older than everyone who has to obey it.</em>")
},
{ name: "3 · Issue list — 1881", content:
P("<strong>STORES · ISSUE AGAINST CASE TT-004</strong><br>"
+ "<em>Sign for each item. Sign again when you bring it back.</em>")
+ TABLE(["Item", "Note"], [
["<strong>Webley revolver</strong>", "1d10 · six shots · reloading takes a round you will not have"],
["<strong>Martini-Henry rifle</strong>", "2d6+2 · <strong>one shot</strong>, then work the lever"],
["<strong>Oil lantern</strong> + lamp oil", "it goes out, and it can be seen from a long way off"],
["<strong>Brass compass · pocket watch · spyglass</strong>", "there is nothing else to navigate or time by"],
["<strong>Buff coat</strong> or oilcloth coat", "in place of the vest"],
["<strong>Chirurgeon's roll · laudanum</strong>", "in place of the medkit"],
["<strong>Iron manacles · sealing wax · quill and ink</strong>", "you will need to write things down and lock things up"],
["<strong>Period coin</strong>", "the company will want paying and so will the livery"]
])
+ P("<strong>NOT ISSUED:</strong> handset, torch, stab vest, service pistol, medkit, "
+ "field slate, scanner, anything with a battery in it.")
+ P("<strong>KEEP:</strong> your anchor.")
},
{ name: "4 · The filed survey", content:
P("<strong>CORDERA &amp; WESTERN RAIL ROAD CO.</strong><br>"
+ "SURVEY OF LINE &mdash; MERCY DRAW SECTION &mdash; FILED 12 APRIL 1881")
+ P("<em>A drawing. A dry wash running roughly east to west, benches marked on both "
+ "sides, and the proposed line drawn firmly along the <strong>NORTH</strong> "
+ "bench, with levels and chainages written along it in a neat hand.</em>")
+ P("<strong>Certified a true survey of the ground:</strong><br>"
+ "<strong>E. VANE</strong>, Surveyor to the Company")
+ P("<em>Countersigned, in a different and much faster hand, by a company officer whose "
+ "signature is not legible.</em>")
+ P("<strong>The line was laid on the south bench.</strong> It is still there. You have "
+ "walked on it.")
},
{ name: "5 · Working timetable and last dispatch", content:
P("<strong>CORDERA &amp; WESTERN · WORKING TIMETABLE No. 14</strong><br>"
+ "<em>In effect 1 October 1881</em>")
+ TABLE(["Train", "Cordera", "Mercy Draw (north bench)", "Fallon"], [
["No. 9 · mixed", "10:55", "<strong>11:40</strong>", "13:20"],
["No. 4 · ore", "14:10", "14:55", "16:35"],
["No. 9 · return", "18:00", "18:45", "20:25"]
])
+ P("<em>Every timetable, waybill and dispatch issued by this company for six months "
+ "names the north bench. There is no railway on the north bench and there never "
+ "was.</em>")
+ H(2, "Dispatcher's book · 3 November 1881")
+ P("<code>11:31 &nbsp; No.9 out of Cordera, on time, 61 souls and mixed freight</code><br>"
+ "<code>11:38 &nbsp; No.9 entering the Draw. Whistle heard. Clear road to Fallon</code><br>"
+ "<code>11:45 &nbsp; &mdash;</code><br>"
+ "<code>11:52 &nbsp; Fallon asks after No.9</code><br>"
+ "<code>12:20 &nbsp; Fallon says No.9 has not arrived. Line walked. Nothing found</code><br>"
+ "<code>14:00 &nbsp; Line walked again, both benches. Nothing found</code>")
+ P("<strong>There is no 11:45 entry.</strong> The page is not torn and nothing is "
+ "crossed out. The clerk simply had nothing to write.")
},
{ name: "6 · Passenger manifest", content:
P("<strong>No. 9 · 3 NOVEMBER 1881 · SIXTY-ONE SOULS</strong><br>"
+ "<em>Kept by H. Bligh, Conductor. Counted hourly since.</em>")
+ P("<strong>Crew (4):</strong> M. Kell, engineer · J. Doss, fireman · H. Bligh, "
+ "conductor · T. Ruiz, brakeman")
+ P("<strong>Passengers (57), in part:</strong> E. Vane, surveyor · the Wheelock family "
+ "(6, incl. four children) · Sr. Mary Agnes and two novices · A. Pike, assayer · "
+ "eleven miners returning to Fallon · Mrs C. Danforth and infant · Dr S. Okafor, "
+ "physician · nineteen others named · <strong>four unnamed, entered only as "
+ "&ldquo;working passage&rdquo;</strong>")
+ P("<strong>Children aboard: nine.</strong>")
+ P("<em>The conductor's count has never once come out wrong. She would like that "
+ "noted.</em>")
+ P("<strong>Nine of the sixty-one are aboard for the payroll.</strong> Four in company "
+ "coats. Five miners of the Cordera works. They are not listed together, because "
+ "nobody knew.")
},
{ name: "7 · Letter of engagement — found on Merritt", content:
P("<em>Good paper. Company letterhead, a wax seal, and a hand that has written a "
+ "thousand of these.</em>")
+ P("<strong>CORDERA &amp; WESTERN RAIL ROAD CO.</strong><br>"
+ "Cordera, Nevada Territory &middot; 29 October 1881")
+ P("Mr C. MERRITT,")
+ P("You are engaged by this Company, with three men of your choosing, to attend the "
+ "payroll consignment aboard No. 9 on the third proximo and to see it safe to the "
+ "Company's agent at Fallon.")
+ P("You are to admit no person to the freight car and to surrender the box to no "
+ "person save the said agent, <strong>upon any pretext whatsoever</strong>, "
+ "including the presentation of any paper, order or instrument. The Company will "
+ "answer for any such paper in its own courts and not upon its own train.")
+ P("Your fee is fixed at the sum agreed. The key travels with you.")
+ P("<em>Signed for the Company</em> &mdash; <strong>and the signature is not "
+ "legible.</strong>")
},
{ name: "8 · Writ of attachment — found on Ambrose", content:
P("<em>Cheaper paper, folded small and carried a long time. A court seal, pressed "
+ "properly, and a judge's hand.</em>")
+ P("<strong>IN THE DISTRICT COURT OF THE TERRITORY OF NEVADA</strong><br>"
+ "IN THE MATTER OF <strong>AMBROSE AND OTHERS</strong> against "
+ "<strong>THE CORDERA &amp; WESTERN RAIL ROAD CO.</strong>")
+ P("Upon the sworn complaint of Josiah Ambrose and forty-one others, labourers of the "
+ "Cordera works, that wages for eleven weeks stand unpaid;")
+ P("<strong>IT IS ORDERED</strong> that the sum of the payroll consignment now carried "
+ "by the said Company be <strong>ATTACHED</strong> to the satisfaction of the said "
+ "wages, and that the complainants or their nominees may take and hold the same "
+ "against the accounting of this Court.")
+ P("Dated at Fallon this 1st day of November, 1881.")
+ P("<em>Sealed and signed by the Judge.</em>")
+ P("<strong>Both of these are genuine. Neither man has seen the other's.</strong>")
}
];
/* ---------------------------------------------------------------- */
export const TT_CAST = [
{ key: "tt_kell", name: "Marius Kell",
role: "CIVILIAN, 1881. Engineer. Has not let go of the regulator since 3 November.",
chosenStyle: "deliberate", naturalArmour: 0, species: "baseline",
ch: { str: 14, siz: 13, con: 16, int: 12, pow: 16, dex: 12, cha: 11, edu: 8 },
skills: [{ fam: "engineering", spec: "field_systems", val: 55 },
{ fam: "repair", spec: "mechanical", val: 60 },
{ fam: "listen", spec: "", val: 50 }],
weapons: ["punch"],
tactics: "Fifties, burnt down one side of his face by the firebox door, and very tired. "
+ "HE IS NOT MAD AND HE IS NOT POSSESSED. He worked out on the first day that the "
+ "train slowing is the end of everything, and he has been right for a hundred and "
+ "forty-five years. He will not stop unless somebody with authority tells him to, and "
+ "he will do it the moment they do. He would like that to be a person, out loud, not "
+ "a note. If asked how he is: 'I'm all right. It's only the one job.'" },
{ key: "tt_vane", name: "Elias Vane",
role: "CIVILIAN, 1881. Surveyor. Signed the chart. Knows exactly what he did.",
chosenStyle: "deliberate", naturalArmour: 0, species: "baseline",
ch: { str: 10, siz: 11, con: 11, int: 16, pow: 14, dex: 11, cha: 12, edu: 15 },
skills: [{ fam: "engineering", spec: "field_systems", val: 65 },
{ fam: "navigate", spec: "", val: 60 },
{ fam: "knowledge", spec: "", val: 55 },
{ fam: "insight", spec: "", val: 55 }],
weapons: ["punch"],
tactics: "Rides alone in the last car watching the line come out from under. He has had "
+ "the time to understand the standing wave and he explains it better than the "
+ "department can. HE WILL NOT VOLUNTEER IT — he answers a direct question honestly and "
+ "says nothing otherwise, because he has been waiting a very long time to be asked. "
+ "He wants the children put off first. He does not want to be forgiven." },
{ key: "tt_bligh", name: "Hester Bligh",
role: "CIVILIAN, 1881. Conductor. Sixty-one aboard, counted hourly.",
chosenStyle: "reflex", naturalArmour: 0, species: "baseline",
ch: { str: 11, siz: 11, con: 14, int: 13, pow: 15, dex: 12, cha: 15, edu: 11 },
skills: [{ fam: "persuade", spec: "", val: 60 },
{ fam: "spot", spec: "", val: 55 },
{ fam: "first_aid", spec: "", val: 45 },
{ fam: "status", spec: "", val: 50 }],
weapons: ["punch"],
tactics: "She is the reason there is a water rota and nobody has died. Competent, "
+ "unsentimental, and the person the agents should have to convince of anything. She "
+ "will fight to have every name put off and she will not accept a number. If the "
+ "players choose who goes, SHE is the one who has to be told." },
{ key: "tt_reyes", name: "Ada Reyes",
role: "CIVILIAN, 1881. Telegraph operator, Cordera.",
chosenStyle: "reflex", naturalArmour: 0, species: "baseline",
ch: { str: 10, siz: 10, con: 12, int: 15, pow: 13, dex: 14, cha: 13, edu: 13 },
skills: [{ fam: "computer_use", spec: "", val: 30 },
{ fam: "listen", spec: "", val: 55 },
{ fam: "research", spec: "", val: 45 },
{ fam: "insight", spec: "", val: 45 }],
weapons: ["punch"],
tactics: "Has sent the same query up the line forty times in a day and a half. She has "
+ "the dispatcher's book, she will let a stranger read it if they are civil, and she "
+ "has already noticed that there is no 11:45 entry. Play her as the one local who is "
+ "ahead of the town." },
{ key: "tt_merritt", name: "Cass Merritt",
role: "1881. Hired guard, the coats. Has the company's letter and the key to the box.",
chosenStyle: "reflex", naturalArmour: 1, species: "baseline",
ch: { str: 13, siz: 13, con: 14, int: 12, pow: 13, dex: 14, cha: 12, edu: 9 },
skills: [{ fam: "firearm", spec: "pistol", val: 65 },
{ fam: "brawl", spec: "", val: 50 },
{ fam: "spot", spec: "", val: 50 },
{ fam: "dodge", spec: "", val: 40 }],
weapons: ["punch", "webley"],
tactics: "Forties, a good long coat, entirely unexcited. He was hired to see the payroll "
+ "to Fallon and he has a signed letter saying so. HE IS NOT A VILLAIN and he is not "
+ "greedy — he is a competent man doing the job he was paid for against men he has "
+ "every reason to think are robbers. Falls back to the car four door at minute three "
+ "and holds it. Shoots Ambrose through that door at minute four, every time. He will "
+ "not stand down for a judge's paper he has never seen while he is being shot at, and "
+ "he will listen afterwards, which is too late and is the point." },
{ key: "tt_ambrose", name: "Josiah Ambrose",
role: "1881. Miner, the writ men. Carries a court order for the payroll. Dies at minute four.",
chosenStyle: "reflex", naturalArmour: 0, species: "baseline",
ch: { str: 15, siz: 14, con: 15, int: 13, pow: 15, dex: 12, cha: 14, edu: 8 },
skills: [{ fam: "firearm", spec: "pistol", val: 45 },
{ fam: "brawl", spec: "", val: 55 },
{ fam: "persuade", spec: "", val: 50 },
{ fam: "status", spec: "", val: 40 }],
weapons: ["punch", "webley"],
tactics: "Eleven weeks unpaid, five men behind him, and a writ of attachment from a "
+ "territorial judge in his coat. He believes — correctly — that he is lawfully seizing "
+ "money owed to him and being shot at for it. He gets to the payroll box at minute "
+ "four and Merritt shoots him through the door. HE DIES WHEN THE LOOP LETS GO, and "
+ "nothing the agents do in the four minutes prevents it; only closing the crossing "
+ "does, and closing the crossing is what kills him. Sit with that before you run him." },
{ key: "tt_coat", name: "Company guard",
role: "1881. One of Merritt's three. Use for all of them.",
chosenStyle: "reflex", naturalArmour: 1, species: "baseline",
ch: { str: 12, siz: 12, con: 12, int: 10, pow: 11, dex: 12, cha: 10, edu: 8 },
skills: [{ fam: "firearm", spec: "pistol", val: 45 },
{ fam: "brawl", spec: "", val: 40 },
{ fam: "dodge", spec: "", val: 30 }],
weapons: ["punch", "webley"],
tactics: "Webley, 1d10, six shots and a slow reload. Fires down the length of car three "
+ "and takes cover behind the seat backs. WILL SHOOT AN AGENT who crosses the aisle or "
+ "moves toward the box, because from where he is standing that is what a robber's "
+ "friend looks like. Withdraws to the car four door with Merritt at minute three." },
{ key: "tt_writman", name: "Cordera miner",
role: "1881. One of Ambrose's four. Use for all of them.",
chosenStyle: "reflex", naturalArmour: 0, species: "baseline",
ch: { str: 14, siz: 13, con: 14, int: 10, pow: 12, dex: 11, cha: 11, edu: 6 },
skills: [{ fam: "firearm", spec: "rifle", val: 40 },
{ fam: "brawl", spec: "", val: 50 },
{ fam: "dodge", spec: "", val: 30 }],
weapons: ["punch", "martini"],
tactics: "Martini-Henry, 2d6+2, ONE SHOT then work the lever — which is why the fight "
+ "has the rhythm it has. Not a soldier. Frightened, angry, owed money, and shooting "
+ "at men who are shooting at him. Takes the overturned seats at minute three. Will "
+ "shoot an agent who moves toward Merritt's end of the car." }
];
export const TT_CASE = {
key: "tt_case",
name: "TT-004 · Mercy Draw",
containment: 6, resources: 5, clearance: 4,
destination: "Mercy Draw, Nevada Territory, 3 November 1881",
standings: [],
notes:
"<p><strong>Crossing clock 0 / 6.</strong> Move it on each pass of the train, when "
+ "somebody uses a modern object across, and on a fumbled attempt to board.</p>"
+ "<p>0 Open · 1 Thinning · 2 Slipping (&minus;1 Coherence, once) · 3 The line flickers "
+ "· 4 The far end stops answering · 5 One pass left · 6 It closes, with whoever is "
+ "aboard.</p>"
+ "<p><strong>Standing Order 9 is live:</strong> using any modern item across costs 1 "
+ "Coherence the first time and moves this clock. Carrying costs nothing. Anchors "
+ "are exempt.</p>"
+ "<p><strong>Issued for this case:</strong> Webley revolvers, one Martini-Henry, oil "
+ "lanterns and oil, brass compass, pocket watch, spyglass, buff coats, a chirurgeon's "
+ "roll, laudanum, iron manacles, and period coin.</p>",
legs: [
{ label: "Approach — Mercy Draw, present day", kindId: "approach", difficulty: "average", mod: 0 },
{ label: "Across — Cordera, 1881", kindId: "cordon", difficulty: "average", mod: -5 },
{ label: "Containment — board the train", kindId: "containment", difficulty: "difficult", mod: -10 },
{ label: "Extraction — off the grade, and out", kindId: "extraction", difficulty: "average", mod: -10 }
]
};