Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
774e95dea8 | ||
|
|
37b4043746 | ||
|
|
7ec7efd29b | ||
|
|
5aaae186e0 | ||
|
|
20306d83d9 | ||
|
|
cbad87ba98 | ||
|
|
ab619f4b73 | ||
|
|
9b6c61ffcb | ||
|
|
0852427586 | ||
|
|
ad771ff2b5 | ||
|
|
53481bdfc1 |
@@ -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 -->
|
||||
|
||||
|
After Width: | Height: | Size: 142 KiB |
|
After Width: | Height: | Size: 94 KiB |
|
After Width: | Height: | Size: 128 KiB |
|
After Width: | Height: | Size: 96 KiB |
|
After Width: | Height: | Size: 67 KiB |
|
After Width: | Height: | Size: 92 KiB |
|
After Width: | Height: | Size: 93 KiB |
|
Before Width: | Height: | Size: 43 KiB |
|
Before Width: | Height: | Size: 15 KiB |
|
Before Width: | Height: | Size: 3.9 KiB |
|
Before Width: | Height: | Size: 1.5 KiB |
|
Before Width: | Height: | Size: 785 B |
|
Before Width: | Height: | Size: 970 B |
|
Before Width: | Height: | Size: 826 B |
@@ -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
|
||||
|
||||
@@ -426,6 +426,13 @@ If there is time — and there usually is, because this ends early — she says
|
||||
|
||||
## Artwork
|
||||
|
||||
**Showing them at the table.** The adventure ships a third journal,
|
||||
**LAST ADMISSION — Show the players**, holding these as eight image pages at player
|
||||
visibility. Right-click a page and *Show Players*: the table gets the picture and
|
||||
nothing else — no heading, no caption, no label naming what they are looking at. The
|
||||
page names are for the GM's sidebar and the players never see them.
|
||||
|
||||
|
||||
**Style line:** flat editorial vector, deep navy and pale yellow with warm off-white,
|
||||
1970s British public-information poster, `--style raw --ar 3:2 --v 6`.
|
||||
|
||||
|
||||
@@ -0,0 +1,394 @@
|
||||
# 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 and they are all alive.*
|
||||
|
||||
---
|
||||
|
||||
## 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.
|
||||
- **Hard rule: at 2:45 on the clock, they are on the train.**
|
||||
- **Content warnings:** entrapment and confinement; a decision that condemns named
|
||||
people including children; period racism and hardship implied but not staged; heights;
|
||||
one death by fire in backstory.
|
||||
|
||||
---
|
||||
|
||||
## 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. **What this is not:** a fight. There is no antagonist. Kell is not possessed, Vane is
|
||||
not a villain, and the train is not malevolent — it is a note being held.
|
||||
|
||||
---
|
||||
|
||||
## 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 and **sixty-one souls aboard**.
|
||||
|
||||
**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.** Step down and you are on ground outside the error. Three
|
||||
departmental attempts are on file; none returned; all three tried to stop it first.
|
||||
- **Stop the train and the note ends** — and everything it holds open ends with it.
|
||||
|
||||
**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, and only if told to |
|
||||
| R10 | Vane signed it, and knows exactly what stopping it costs |
|
||||
| R11 | Sixty-one people, six passes, four hours — and the crossing may not hold |
|
||||
|
||||
---
|
||||
|
||||
## 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 |
|
||||
|
||||
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.
|
||||
|
||||
**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 *(~50 min)*
|
||||
|
||||
> **Goal:** sixty-one people, one man who knows, and a decision that costs either way.
|
||||
|
||||
> 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: *"Are you from the company?"*
|
||||
|
||||
| Where | What |
|
||||
|---|---|
|
||||
| The locomotive | **Marius Kell**, engineer, who has not let go of the regulator |
|
||||
| Tender, car one | Coal that has not run out — the first hard evidence |
|
||||
| Cars two and three | Most of the sixty-one. Organised, rationed, glad to see anyone |
|
||||
| Car four | Freight, the company safe, **the signed survey** |
|
||||
| Car five | **Elias Vane**, alone, watching the line come out from under |
|
||||
|
||||
### The three facts aboard
|
||||
|
||||
1. **The coal has not run out.** `[CUS: Engineering or Spot]`
|
||||
2. **Kell cannot let go** — not *will* not. He worked out on day one that slowing is the
|
||||
end of everything, and he has been right for a hundred and forty-five years. He is not
|
||||
mad and not possessed. He is tired.
|
||||
3. **Vane knows**, and will say so to a direct question.
|
||||
|
||||
### Getting people off
|
||||
|
||||
**Off the moving train is survivable. Off the crossing is not.** A person put off at the
|
||||
top of the grade — the same forty seconds the agents used — lands in 1881 and lives.
|
||||
|
||||
**Sixty-one people. Six passes. Four hours.** They cannot do it in the time they have.
|
||||
That is deliberate: the ending is what they choose to do about the arithmetic.
|
||||
|
||||
### The choices — no roll resolves this
|
||||
|
||||
| Choice | Cost |
|
||||
|---|---|
|
||||
| **Everyone off, then stop it** | Four hours while the crossing degrades. It may not hold. The right answer, and a gamble. |
|
||||
| **Stop it now** | Clean close, clean file. Sixty-one people end. Kell will do it if asked. |
|
||||
| **Children and crew off, then stop it** | The compromise every table finds. It works — and it means choosing, by name, off a manifest they are holding. |
|
||||
| **Leave it running** | Everyone lives today. It fails inside a year with everyone aboard. A real option; the scenario logs it without punishing it. |
|
||||
|
||||
### 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. That is all I did. It took six months of honest railwaymen
|
||||
> repeating it to make it true. If you stop us I would call that fair, and I would ask you
|
||||
> to put the children off first.
|
||||
|
||||
---
|
||||
|
||||
## Epilogue — the register *(~20 min)*
|
||||
|
||||
A Registry room, a form, and somebody writing down sixty-one names.
|
||||
|
||||
| Ending | What the register says |
|
||||
|---|---|
|
||||
| **All off, then stopped** | Sixty-one resettled in 1881, new papers, no way home. Two find their way into the department's files within the decade. |
|
||||
| **Stopped with people aboard** | Closes clean. The file closes clean. Nobody is quite the same about a timetable. |
|
||||
| **Partial** | However many were put off, put off. The rest are a number in a column, and the agent who chose is named. |
|
||||
| **Left running** | Logged, monitored, inherited by whoever holds the case in a year. Defensible. Nobody thanks them. |
|
||||
|
||||
---
|
||||
|
||||
## Cast
|
||||
|
||||
| Who | Wants | Key line |
|
||||
|---|---|---|
|
||||
| **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
|
||||
|
||||
- **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 the people put off in 1881 were saved or exiled.** Both. It should sit badly.
|
||||
|
||||
## GM notes
|
||||
|
||||
- **Play the passengers as people, hard.** The scenario dies the moment they become a
|
||||
number, and it lives on the fact that they are not.
|
||||
- **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.
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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 @@
|
||||
2026/08/12-19:01:54.231003 174603000 Delete type=3 #1
|
||||
2026/08/18-00:25:18.939914 16fcaf000 Delete type=3 #1
|
||||
|
||||
@@ -1 +1 @@
|
||||
2026/08/12-19:01:54.298648 173df7000 Delete type=3 #1
|
||||
2026/08/18-00:25:19.010528 1704bb000 Delete type=3 #1
|
||||
|
||||
@@ -1 +1 @@
|
||||
2026/08/12-19:01:54.232848 174603000 Delete type=3 #1
|
||||
2026/08/18-00:25:18.941763 16f4a3000 Delete type=3 #1
|
||||
|
||||
@@ -1 +1 @@
|
||||
2026/08/12-19:01:54.282484 173df7000 Delete type=3 #1
|
||||
2026/08/18-00:25:18.995451 16f4a3000 Delete type=3 #1
|
||||
|
||||
@@ -1 +1 @@
|
||||
2026/08/12-19:01:54.266480 174603000 Delete type=3 #1
|
||||
2026/08/18-00:25:18.976255 16fcaf000 Delete type=3 #1
|
||||
|
||||
@@ -1 +1 @@
|
||||
2026/08/12-19:01:54.293373 173df7000 Delete type=3 #1
|
||||
2026/08/18-00:25:19.005331 16ec97000 Delete type=3 #1
|
||||
|
||||
@@ -1 +1 @@
|
||||
2026/08/12-19:01:54.252536 174603000 Delete type=3 #1
|
||||
2026/08/18-00:25:18.961584 16ec97000 Delete type=3 #1
|
||||
|
||||
@@ -1 +1 @@
|
||||
2026/08/12-19:01:54.290196 172ddf000 Delete type=3 #1
|
||||
2026/08/18-00:25:19.002226 16f4a3000 Delete type=3 #1
|
||||
|
||||
@@ -1 +1 @@
|
||||
2026/08/12-19:01:54.286378 174603000 Delete type=3 #1
|
||||
2026/08/18-00:25:18.999210 16fcaf000 Delete type=3 #1
|
||||
|
||||
@@ -1 +1 @@
|
||||
2026/08/12-19:01:54.297168 1735eb000 Delete type=3 #1
|
||||
2026/08/18-00:25:19.008595 16f4a3000 Delete type=3 #1
|
||||
|
||||
@@ -1 +1 @@
|
||||
2026/08/12-19:01:54.224954 172ddf000 Delete type=3 #1
|
||||
2026/08/18-00:25:18.934271 16fcaf000 Delete type=3 #1
|
||||
|
||||
@@ -1 +1 @@
|
||||
2026/08/12-19:01:54.291920 1735eb000 Delete type=3 #1
|
||||
2026/08/18-00:25:19.003783 16fcaf000 Delete type=3 #1
|
||||
|
||||
@@ -1 +1 @@
|
||||
2026/08/12-19:01:54.276268 174603000 Delete type=3 #1
|
||||
2026/08/18-00:25:18.985846 16f4a3000 Delete type=3 #1
|
||||
|
||||
@@ -1 +1 @@
|
||||
2026/08/12-19:01:54.270666 173df7000 Delete type=3 #1
|
||||
2026/08/18-00:25:18.980204 16fcaf000 Delete type=3 #1
|
||||
|
||||
@@ -1 +1 @@
|
||||
2026/08/12-19:01:54.227877 174603000 Delete type=3 #1
|
||||
2026/08/18-00:25:18.936682 16f4a3000 Delete type=3 #1
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
MANIFEST-000002
|
||||
@@ -0,0 +1 @@
|
||||
2026/08/18-00:25:18.991213 1704bb000 Delete type=3 #1
|
||||
@@ -1 +1 @@
|
||||
2026/08/12-19:01:54.234705 1735eb000 Delete type=3 #1
|
||||
2026/08/18-00:25:18.943608 16f4a3000 Delete type=3 #1
|
||||
|
||||
@@ -1 +1 @@
|
||||
2026/08/12-19:01:54.229633 174603000 Delete type=3 #1
|
||||
2026/08/18-00:25:18.938385 16fcaf000 Delete type=3 #1
|
||||
|
||||
@@ -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"] },
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -1684,3 +1684,177 @@
|
||||
.rb-dying { border-left: 3px solid var(--rb-danger); padding-left: 8px; }
|
||||
.rb-stable { border-left: 3px solid var(--rb-yellow); padding-left: 8px; }
|
||||
.rb-dead { border-left: 3px solid var(--rb-white); padding-left: 8px; }
|
||||
|
||||
/* ============================================================
|
||||
THE PAUSE — a uniselector, stepping
|
||||
============================================================
|
||||
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. 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 contact every 700ms. A continuous spin reads as a
|
||||
loading throbber; a discrete click reads as a machine working through a list.
|
||||
============================================================ */
|
||||
|
||||
/* 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, behind the arm */
|
||||
#pause.paused::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
top: 24px; left: 50%; margin-left: -85px;
|
||||
width: 170px; height: 170px;
|
||||
border-radius: 50%;
|
||||
background:
|
||||
repeating-conic-gradient(from -90deg,
|
||||
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 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, 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;
|
||||
}
|
||||
/* 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.paused::after {
|
||||
content: "";
|
||||
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.paused figcaption {
|
||||
font-family: var(--rb-display);
|
||||
font-size: 24px;
|
||||
letter-spacing: .3em;
|
||||
text-transform: uppercase;
|
||||
color: var(--rb-yellow);
|
||||
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 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.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; }
|
||||
|
||||
@@ -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.2",
|
||||
"version": "1.3.0",
|
||||
"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",
|
||||
|
||||
@@ -69,6 +69,11 @@
|
||||
"value": 10,
|
||||
"max": 10,
|
||||
"drift": []
|
||||
},
|
||||
"panic": {
|
||||
"value": 0,
|
||||
"max": 5,
|
||||
"log": []
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -51,6 +51,16 @@
|
||||
</span>
|
||||
<span class="rb-cohband">{{localize coherence.band.label}}</span>
|
||||
</div>
|
||||
<div class="rb-vital-panic rb-panic-{{panic.band.id}}"
|
||||
data-tooltip="{{localize panic.band.note}}">
|
||||
<span class="rb-vital-label">{{localize "RINGBRP.Panic.Label"}}</span>
|
||||
<span class="rb-coh-row">
|
||||
<a data-action="panic-adjust" data-delta="-1" class="rb-cohbtn">−</a>
|
||||
<span class="rb-big">{{sys.panic.value}}</span>
|
||||
<a data-action="panic-adjust" data-delta="1" class="rb-cohbtn">+</a>
|
||||
</span>
|
||||
<span class="rb-cohband">{{localize panic.band.label}}</span>
|
||||
</div>
|
||||
<div class="rb-armour{{#if armourUnworn.length}} rb-unworn{{/if}}"
|
||||
{{#if armourUnworn.length}}data-tooltip="{{localize "RINGBRP.Sheet.ArmourUnworn"}}"{{/if}}>
|
||||
<span class="rb-vital-label">{{localize "RINGBRP.Sheet.Armour"}}</span>
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
<div class="ringbrp-chat rb-panic-card">
|
||||
<div class="rb-chat-title">{{localize "RINGBRP.Panic.Label"}}</div>
|
||||
<div class="rb-chat-sub">{{actorName}}</div>
|
||||
|
||||
<ol class="rb-panic-pips">
|
||||
{{#each pips as |on|}}<li class="rb-panic-pip{{#if on}} on{{/if}}"></li>{{/each}}
|
||||
</ol>
|
||||
|
||||
<div class="rb-panic-move">
|
||||
<span class="rb-panic-from">{{before}}</span>
|
||||
<span class="rb-panic-arrow">→</span>
|
||||
<span class="rb-panic-to">{{after}}</span>
|
||||
<span class="rb-panic-band rb-panic-{{band.id}}">{{localize band.label}}</span>
|
||||
</div>
|
||||
|
||||
<p class="rb-panic-reason">{{#if backfired}}<span class="rb-panic-worse">{{localize "RINGBRP.Panic.Backfired"}}</span> {{/if}}{{reason}}{{#if note}} — <em>{{note}}</em>{{/if}}</p>
|
||||
<p class="rb-panic-note">{{localize band.note}}</p>
|
||||
|
||||
{{#if penalty}}<p class="rb-panic-pen">{{penalty}}% {{localize "RINGBRP.Chat.ToEverything"}}</p>{{/if}}
|
||||
{{#if breaks}}<p class="rb-panic-break">{{localize "RINGBRP.Panic.Breaks"}}</p>{{/if}}
|
||||
</div>
|
||||
@@ -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>
|
||||
@@ -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}} → 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>
|
||||
@@ -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 ?? {})) {
|
||||
@@ -461,6 +464,38 @@ function journalDoc(name, key, pages) {
|
||||
return { doc: { _id: id, name, pages: pageDocs.map(p => p._id), categories: [], ...BASE() }, pages: pageDocs, id };
|
||||
}
|
||||
|
||||
/**
|
||||
* A journal of nothing but pictures, for showing to players.
|
||||
*
|
||||
* title.show is FALSE and there is no caption on purpose: when the GM hits Show Players
|
||||
* the table gets the image and nothing else — no heading telling them which room this
|
||||
* is, no label naming the thing they are looking at. The page NAME is for the GM's list
|
||||
* in the sidebar, so they can find the right one in a hurry; the players never see it.
|
||||
*/
|
||||
function imageJournal(name, key, plates) {
|
||||
const id = idFor(`journal:${key}`);
|
||||
const pageDocs = plates.map((p, i) => {
|
||||
// A missing plate is a blank frame on a projector in front of six people.
|
||||
if (!existsSync(path.join(ROOT, p.src))) throw new Error(`${name}: no plate at ${p.src}`);
|
||||
return {
|
||||
_id: idFor(`journal:${key}:${i}`),
|
||||
name: p.name, type: "image",
|
||||
title: { show: false, level: 1 },
|
||||
src: `systems/ringbrp/${p.src}`,
|
||||
image: { caption: "" },
|
||||
text: { format: 1, content: "", markdown: "" },
|
||||
video: { controls: true, volume: 0.5 },
|
||||
system: {}, sort: (i + 1) * 100, ownership: { default: 2 }, flags: {},
|
||||
category: null
|
||||
};
|
||||
});
|
||||
return {
|
||||
doc: { _id: id, name, pages: pageDocs.map(p => p._id), categories: [],
|
||||
...BASE(), ownership: { default: 2 }, flags: { ringbrp: { brand: true } } },
|
||||
pages: pageDocs, id
|
||||
};
|
||||
}
|
||||
|
||||
/* ---------------- writer ---------------- */
|
||||
|
||||
/**
|
||||
@@ -829,6 +864,106 @@ 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", src: "art/scenes/tt_06_car.webp" },
|
||||
{ name: "7 · 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.
|
||||
@@ -842,6 +977,19 @@ function cullandScene() {
|
||||
castDocs.push(actorDoc);
|
||||
}
|
||||
|
||||
// The plates, one per page, for showing at the table. Named for what the GM will be
|
||||
// looking for under pressure, ordered by when it comes up.
|
||||
const showJournal = imageJournal("LAST ADMISSION — Show the players", "la_show", [
|
||||
{ name: "1 · The house, from the drive", src: "art/scenes/la_01_hall.webp" },
|
||||
{ name: "2 · Floor plan — Culland Hall", src: "art/maps/culland-hall.webp" },
|
||||
{ name: "3 · Backstage — the green room", src: "art/scenes/la_06_green.webp" },
|
||||
{ name: "4 · The east corridor", src: "art/scenes/la_03_corridor.webp" },
|
||||
{ name: "5 · The nursery", src: "art/scenes/la_04_nursery.webp" },
|
||||
{ name: "6 · The glasshouse — Old Marrow", src: "art/scenes/la_05_glass.webp" },
|
||||
{ name: "7 · The stairs — the woman standing", src: "art/scenes/la_07_stairs.webp" },
|
||||
{ name: "8 · The car park, afterwards", src: "art/scenes/la_08_carpark.webp" }
|
||||
]);
|
||||
|
||||
const gmJournal = journalDoc("LAST ADMISSION — Run the prologue", "la_gm", L.LA_PAGES);
|
||||
const handouts = journalDoc("LAST ADMISSION — Player handouts", "la_handouts", L.LA_HANDOUTS);
|
||||
handouts.doc.ownership = { default: 2 };
|
||||
@@ -870,8 +1018,9 @@ function cullandScene() {
|
||||
actors: castDocs,
|
||||
items: [],
|
||||
journal: [
|
||||
{ ...gmJournal.doc, pages: gmJournal.pages },
|
||||
{ ...handouts.doc, pages: handouts.pages }
|
||||
{ ...gmJournal.doc, pages: gmJournal.pages },
|
||||
{ ...handouts.doc, pages: handouts.pages },
|
||||
{ ...showJournal.doc, pages: showJournal.pages }
|
||||
],
|
||||
scenes: [cullandScene()], combats: [], tables: [], macros: [], cards: [], playlists: [], folders: [],
|
||||
sort: 0, ...BASE()
|
||||
@@ -879,7 +1028,8 @@ function cullandScene() {
|
||||
|
||||
await writePack("lastadmission", [[`!adventures!${advId}`, adventure]]);
|
||||
console.log(` lastadm LAST ADMISSION — ${castDocs.length} cast, `
|
||||
+ `${L.LA_PAGES.length} GM pages, ${L.LA_HANDOUTS.length} handouts`);
|
||||
+ `${L.LA_PAGES.length} GM pages, ${L.LA_HANDOUTS.length} handouts, `
|
||||
+ `${showJournal.pages.length} plates to show`);
|
||||
}
|
||||
|
||||
// OPEN DAY — the prologue. A much smaller adventure than BLACK PLATFORM: no case 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();
|
||||
|
||||
@@ -179,6 +179,24 @@ const checks = [
|
||||
["coherenceBandFrom(4).id", RULES.coherenceBandFrom(4).id, "adrift"],
|
||||
["coherenceBandFrom(1).id", RULES.coherenceBandFrom(1).id, "unmoored"],
|
||||
["coherenceBandFrom(0).id", RULES.coherenceBandFrom(0).id, "displaced"],
|
||||
|
||||
// Panic. The free first point is deliberate, so 1 must cost nothing.
|
||||
["panicBandFrom(0).id", RULES.panicBandFrom(0).id, "steady"],
|
||||
["panicBandFrom(1).id", RULES.panicBandFrom(1).id, "rattled"],
|
||||
["panicBandFrom(3).id", RULES.panicBandFrom(3).id, "losing"],
|
||||
["panicBandFrom(5).id", RULES.panicBandFrom(5).id, "broken"],
|
||||
["panicPenaltyFor(1)", RULES.panicPenaltyFor(1), 0],
|
||||
["panicPenaltyFor(2)", RULES.panicPenaltyFor(2), -5],
|
||||
["panicPenaltyFor(4)", RULES.panicPenaltyFor(4), -20],
|
||||
["panicBreaksAt(4)", RULES.panicBreaksAt(4), false],
|
||||
["panicBreaksAt(5)", RULES.panicBreaksAt(5), true],
|
||||
["panicAfter(4,1)", RULES.panicAfter(4, 1), 5],
|
||||
["panicAfter(5,1)", RULES.panicAfter(5, 1), 5],
|
||||
["panicAfter(0,-1)", RULES.panicAfter(0, -1), 0],
|
||||
["panicReliefFor('success')", RULES.panicReliefFor("success"), 1],
|
||||
["panicReliefFor('special')", RULES.panicReliefFor("special"), 2],
|
||||
["panicReliefFor('failure')", RULES.panicReliefFor("failure"), 0],
|
||||
["panicReliefFor('fumble')", RULES.panicReliefFor("fumble"), -1],
|
||||
// a lost location must cost something, for every kind the species tables use
|
||||
["locationEffectsFor([]) is inert", RULES.locationEffectsFor([]).all, 0],
|
||||
["leg disabled → physical", RULES.locationEffectsFor([{kind:"leg",disabled:true}]).physical, -30],
|
||||
|
||||
@@ -75,6 +75,13 @@ export const SKILL_CATALOGUE = [
|
||||
["navigate", "", "mental", 10, "Navigate"],
|
||||
["research", "", "mental", 1, "Research"],
|
||||
["anomaly_lore", "", "mental", 0, "Anomaly Lore", { canBeTaught: false }],
|
||||
// Added after the first game, which wanted all three and had none of them. Occult and
|
||||
// Religion are the two halves of what people believed BEFORE the department existed —
|
||||
// one is what the books say, the other is what the congregation does — and Psychology
|
||||
// is the only skill that steadies somebody who is not you.
|
||||
["occult", "", "mental", 5, "Occult"],
|
||||
["religion", "", "mental", 5, "Religion"],
|
||||
["psychology", "", "mental", 5, "Psychology"],
|
||||
// perception
|
||||
["listen", "", "percep", 25, "Listen"],
|
||||
["spot", "", "percep", 25, "Spot"],
|
||||
|
||||
@@ -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");
|
||||
@@ -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",
|
||||
|
||||
@@ -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%"]
|
||||
])}
|
||||
|
||||
|
||||
@@ -0,0 +1,596 @@
|
||||
/**
|
||||
* 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.
|
||||
* Stop the train and the note ends — and everything the note is holding open ends with
|
||||
* it, including sixty-one people, unless they are off first.
|
||||
*
|
||||
* 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 six 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. The train inside it at 11:40 has been "
|
||||
+ "running ever since. <strong>The running is what holds the crossing open.</strong>",
|
||||
"Nobody aboard is dead. Nobody aboard can get off. By their clocks it has been "
|
||||
+ "about nine hours.",
|
||||
"The agents have to <strong>get on a train that cannot stop</strong>, which is the "
|
||||
+ "set-piece, and then decide what to do about it, which is the ending.",
|
||||
"<strong>Nothing modern goes through.</strong> That is the standing order and it is "
|
||||
+ "half the flavour of the night."
|
||||
])
|
||||
+ 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. Play them tired, frightened, bored, and extremely "
|
||||
+ "glad to see somebody new.",
|
||||
"<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, the cars are worse, and the driver is the worst thing on the train."
|
||||
])
|
||||
+ H(2, "Shape")
|
||||
+ TABLE(["Section", "Time", "Ends when"], [
|
||||
["Briefing — the standing order", "20 min", "they have swapped their kit and somebody has complained about it"],
|
||||
["Act One — Mercy Draw, now", "50 min", "they know it is a survey fraud, and they have heard 11:40"],
|
||||
["Act Two — Cordera, 1881", "70 min", "they are on the train"],
|
||||
["Act Three — Aboard", "50 min", "they decide, and live with it"],
|
||||
["Epilogue — 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 & 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")
|
||||
+ 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>.")
|
||||
+ 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> They eat, they sleep in shifts, they have organised "
|
||||
+ "themselves. By their clocks it has been about nine hours.",
|
||||
"<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>Stop the train and the note ends.</strong> Everything it is holding open "
|
||||
+ "ends with it — including everyone still aboard."
|
||||
])
|
||||
+ H(2, "So the case is a choice, not a puzzle")
|
||||
+ P("Get them off first, then stop it. That is the whole job, and there is not time to "
|
||||
+ "do it comfortably: the crossing is degrading, and Registry's read is that it fails "
|
||||
+ "on its own within a year and takes them all with it.")
|
||||
+ 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 understands what "
|
||||
+ "stopping it would mean</strong>. He has not told anyone. 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.</strong> Not the handset, not the torch, not "
|
||||
+ "the vest, not the sidearm you are used to. 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 — which is exactly why it works.")
|
||||
+ H(2, "What the department wants")
|
||||
+ OL([
|
||||
"Establish what is holding the crossing open.",
|
||||
"Get the passengers off.",
|
||||
"Close it.",
|
||||
"Come back with the survey chart, because Registry wants the paperwork."
|
||||
])
|
||||
+ 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 — <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 — 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")
|
||||
+ 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 — 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–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 — that is what the hour was for.")
|
||||
+ 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", content:
|
||||
ART("tt_06_car", "Car three, and a hundred and forty-five years of nine hours")
|
||||
+ P("<strong>Goal:</strong> sixty-one people, one man who knows, and a decision that "
|
||||
+ "costs something whichever way it goes.")
|
||||
+ 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>“Are you from the "
|
||||
+ "company?”</em>")
|
||||
+ H(2, "The train")
|
||||
+ TABLE(["Where", "What is there"], [
|
||||
["<strong>The locomotive</strong>", "Marius Kell, engineer. He has not let go of the regulator since the third of November. He cannot: see below."],
|
||||
["<strong>The tender and car one</strong>", "Coal that has not run out. This is the first hard evidence that the train is being sustained rather than surviving."],
|
||||
["<strong>Cars two and three</strong>", "Most of the sixty-one. Organised, rationed, frightened, and very glad to see somebody new."],
|
||||
["<strong>Car four</strong>", "Freight, and the company safe. The original survey chart is in it, signed."],
|
||||
["<strong>Car five</strong>", "Elias Vane, alone, at the back, watching the line come out from under."]
|
||||
])
|
||||
+ H(2, "The three facts they need aboard")
|
||||
+ OL([
|
||||
"<strong>The coal has not run out.</strong> Nine hours of firing on a tender that "
|
||||
+ "should have emptied in four. <em>[CUS: Engineering or Spot]</em>",
|
||||
"<strong>Kell cannot let go.</strong> Not will not. His hand is not stuck — he "
|
||||
+ "has simply understood, without being told, that the train slowing is the end of "
|
||||
+ "everything, and he has been right for a hundred and forty-five years. "
|
||||
+ "<strong>He is not mad and he is not possessed. He is just very tired.</strong>",
|
||||
"<strong>Vane knows.</strong> He signed the chart. He will say so to anybody who "
|
||||
+ "asks him a question he cannot answer with a lie, and he will explain the standing "
|
||||
+ "wave better than the department can, because he has had the time."
|
||||
])
|
||||
+ H(2, "Getting people off")
|
||||
+ P("<strong>Off the moving train is survivable; off the crossing is not.</strong> A "
|
||||
+ "person put off at the top of the grade — the same forty seconds the agents "
|
||||
+ "used — lands in 1881 and lives. That is the answer, and it takes about six "
|
||||
+ "passes to move sixty-one people, and each pass is roughly forty minutes.")
|
||||
+ P("<strong>Sixty-one people, six passes, four hours.</strong> The players cannot do it "
|
||||
+ "in the time they have. This is deliberate: the ending is what they choose to do "
|
||||
+ "about the arithmetic, not whether they can beat it.")
|
||||
+ H(2, "The choices")
|
||||
+ TABLE(["Choice", "Cost"], [
|
||||
["<strong>Get everyone off, then stop it</strong>", "Four hours aboard while the crossing degrades. The department's read is that it may not hold that long, and if it fails first everyone still aboard goes with it. It is the right answer and it is a gamble."],
|
||||
["<strong>Stop it now</strong>", "The crossing closes clean and the case is a success by every measure Registry keeps. Sixty-one people end. So does Kell, who will do it if asked, and Vane, who will not argue."],
|
||||
["<strong>Get the children and the crew off, then stop it</strong>", "The compromise every table finds. It works. It also means choosing, by name, off a manifest the players are holding."],
|
||||
["<strong>Leave it running</strong>", "Everyone lives, today. Registry logs it, the crossing keeps degrading, and it fails inside a year with everyone still aboard. This is a real option and the scenario does not punish it — it just writes it down."]
|
||||
])
|
||||
+ P("<strong>No roll resolves this.</strong> Say so if a player reaches for dice.")
|
||||
+ H(2, "If they ask Vane what to do")
|
||||
+ 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. That is all I did. It took six months of honest "
|
||||
+ "railwaymen repeating it to make it true. If you stop us I would call that fair, "
|
||||
+ "and I would ask you to put the children off first.")
|
||||
},
|
||||
|
||||
{ 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>Everyone off, then stopped</strong>", "Sixty-one resettled in 1881 with new papers and no way home. Registry counts it a success. Two of them find their way into the department's files within the decade, which is a hook."],
|
||||
["<strong>Stopped with people aboard</strong>", "The crossing closes clean. The file is closed clean. Nobody involved is ever quite the same about a timetable."],
|
||||
["<strong>Partial</strong>", "However many were put off, put off. The rest are a number in a column, and the agent who chose is named in the file."],
|
||||
["<strong>Left running</strong>", "Logged, monitored, and inherited by whoever has the case in a year. Perfectly defensible. Nobody thanks them."]
|
||||
])
|
||||
+ H(2, "Cast")
|
||||
+ TABLE(["Who", "Wants", "Key line"], [
|
||||
["<strong>Marius Kell</strong>, engineer", "to be allowed to stop", "“You tell me when. I'll not do it before you say.”"],
|
||||
["<strong>Elias Vane</strong>, surveyor", "to be asked, at last", "“I signed it. Six months of honest men made it true.”"],
|
||||
["<strong>Hester Bligh</strong>, conductor", "her passengers accounted for", "“Sixty-one aboard. I have counted them every hour since.”"],
|
||||
["<strong>Ada Reyes</strong>, telegraph, Cordera", "an answer up the line", "“It never got to Fallon. I've sent it forty times.”"],
|
||||
["<strong>Mr Bewley</strong>, Registry", "the chart, and the truth written down", "“Bring me the paper. The paper is the case.”"]
|
||||
])
|
||||
+ H(2, "Do not resolve")
|
||||
+ UL([
|
||||
"<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 the people put off in 1881 were saved or exiled.</strong> Both. "
|
||||
+ "That is the ending, and it should sit badly."
|
||||
])
|
||||
+ H(2, "GM notes")
|
||||
+ UL([
|
||||
"<strong>Play the passengers as people, hard.</strong> The scenario dies the moment "
|
||||
+ "they become a number, and it lives entirely on the fact that they are not.",
|
||||
"<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 — PERIOD<br>"
|
||||
+ "<strong>FAR SIDE:</strong> 3–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 & 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.")
|
||||
+ H(2, "Orders")
|
||||
+ OL(["Establish what holds the crossing open.",
|
||||
"Get the passengers off.",
|
||||
"Close it.",
|
||||
"Recover the filed survey. Registry wants the paper."])
|
||||
+ 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.")
|
||||
+ 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 & WESTERN RAIL ROAD CO.</strong><br>"
|
||||
+ "SURVEY OF LINE — MERCY DRAW SECTION — 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 & 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 No.9 out of Cordera, on time, 61 souls and mixed freight</code><br>"
|
||||
+ "<code>11:38 No.9 entering the Draw. Whistle heard. Clear road to Fallon</code><br>"
|
||||
+ "<code>11:45 —</code><br>"
|
||||
+ "<code>11:52 Fallon asks after No.9</code><br>"
|
||||
+ "<code>12:20 Fallon says No.9 has not arrived. Line walked. Nothing found</code><br>"
|
||||
+ "<code>14:00 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 "
|
||||
+ "“working passage”</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>")
|
||||
}
|
||||
];
|
||||
|
||||
/* ---------------------------------------------------------------- */
|
||||
|
||||
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." }
|
||||
];
|
||||
|
||||
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 (−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 }
|
||||
]
|
||||
};
|
||||