Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
99e78f440a | ||
|
|
c05d488b9d | ||
|
|
391c6a3679 | ||
|
|
5bc46879c7 | ||
|
|
774e95dea8 | ||
|
|
37b4043746 | ||
|
|
7ec7efd29b | ||
|
|
5aaae186e0 | ||
|
|
20306d83d9 | ||
|
|
cbad87ba98 | ||
|
|
ab619f4b73 |
@@ -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 — 46 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 |
|
After Width: | Height: | Size: 110 KiB |
|
After Width: | Height: | Size: 81 KiB |
|
After Width: | Height: | Size: 149 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
|
||||
|
||||
@@ -0,0 +1,543 @@
|
||||
# Crossing starter — The Custodians: THROUGH TRAIN
|
||||
|
||||
> *A train has been running the same four miles of track since the third of November,
|
||||
> 1881. It has never stopped. There are sixty-one people on it, nine of them are shooting
|
||||
> at each other, and the same four minutes has run about two million times.*
|
||||
|
||||
---
|
||||
|
||||
## GM ESSENTIALS
|
||||
|
||||
- **System:** d100 roll-under. Special under a fifth, critical under a twentieth.
|
||||
96–99 never succeed; 00 always fumbles; a 60% skill fumbles on 98–00.
|
||||
- **Panic is live** — 0–5, up. A trigger costs a point with no roll; relief is rolled.
|
||||
**This is the case where it earns its keep.**
|
||||
- **Standing Order 9 is live** — nothing modern goes through. Charge for *using*, never
|
||||
for carrying. Anchors are exempt.
|
||||
- **Runs with 4–6 agents from the duty roster.** Not the "(before)" sheets — this is the
|
||||
job, and it wants people who have been trained.
|
||||
- **The four minutes are lethal.** Webley 1d10, Martini-Henry 2d6+2, against 11–16 hit
|
||||
points. **Use the exposure table** — unrestricted fire from all nine wipes the party
|
||||
five times in six.
|
||||
- **The mission is to CLOSE it**, which releases everyone aboard into their own history —
|
||||
including the two men who did not survive the real four minutes.
|
||||
- **Hard rule: at 2:45 on the clock, they are on the train.**
|
||||
- **Content warnings:** gun violence at close quarters, repeatedly; entrapment and
|
||||
confinement; bystanders wounded on screen; a decision that lets named people die;
|
||||
period hardship and labour exploitation; heights.
|
||||
|
||||
---
|
||||
|
||||
## Overview
|
||||
|
||||
- **Setting:** Mercy Draw, Nevada — present day, and 3–4 November 1881.
|
||||
- **Tone:** Procedural, then unbearable. It is a paperwork error with sixty-one people
|
||||
inside it.
|
||||
- **Runtime:** 3h30 play + a 10 minute break.
|
||||
- **The crossing:** opens at 11:40 every day, for about four minutes, on the grade.
|
||||
|
||||
### Design intent
|
||||
|
||||
1. **The supernatural is science fiction underneath.** Nobody is dead, nothing is
|
||||
haunting anybody, and the whole event has a mechanism the players can understand and
|
||||
explain back. If a table leaves thinking "ghost train", it has been run wrong.
|
||||
2. **The obstacle is a physical problem, not a mystery.** They will know what is
|
||||
happening by the end of Act One. Acts Two and Three are about a train that cannot
|
||||
stop and an arithmetic that cannot be beaten.
|
||||
3. **Standing Order 9 is the tutorial.** One shot at a time, no radio, a lantern that
|
||||
runs out. The period kit *is* the difficulty setting, and it is the scene where
|
||||
players learn what kind of game this is.
|
||||
4. **The ending is a decision priced on both sides**, with no roll and no correct
|
||||
answer, taken in front of a woman holding a manifest.
|
||||
5. **There is a fight, and there is no villain in it.** Two armed parties, both with
|
||||
genuine paper, both convinced the others are robbers. The players can survive it,
|
||||
shape it and pull people out of it. They cannot end it — only the brakes end it.
|
||||
|
||||
---
|
||||
|
||||
## Session Timing
|
||||
|
||||
| Section | Time | Cumulative | Checkpoint |
|
||||
|---|---|---|---|
|
||||
| Briefing — the standing order | ~20 min | 0:20 | kit swapped, somebody has complained |
|
||||
| Act One — Mercy Draw, now | ~50 min | 1:10 | they know it is a survey fraud, and they have heard 11:40 |
|
||||
| *Break* | *10 min* | *1:10* | lands on the whistle |
|
||||
| Act Two — Cordera, 1881 | ~70 min | 2:20 | they are on the train |
|
||||
| Act Three — Aboard | ~50 min | 3:10 | they have decided |
|
||||
| Epilogue — the register | ~20 min | 3:30 | sixty-one names, written down |
|
||||
|
||||
**Hard rule:** at 2:45 they are aboard, by the department's wagon if not by their own wit.
|
||||
|
||||
---
|
||||
|
||||
## Plot Summary (GM truth)
|
||||
|
||||
**April 1881.** The Cordera & Western needed four miles of narrow gauge across a dry
|
||||
wash called **Mercy Draw**. The company surveyor, **Elias Vane**, was told what the chart
|
||||
had to say before he walked the ground. He filed a survey showing the line on the
|
||||
**north bench**. The line was laid on the **south bench**, because that is where the
|
||||
ground allowed it.
|
||||
|
||||
Nobody was hurt by that. It is the dullest possible paperwork lie. And for six months
|
||||
every timetable, waybill, dispatch and telegraph order in the company's operation
|
||||
described a train running along the north bench.
|
||||
|
||||
**Reality keeps a record of what belongs where.** It can be made to get it wrong by
|
||||
anything patient enough — and a train is the most patient arguer there is: a fixed line,
|
||||
a constant speed, on the hour, every day, saying the same wrong thing about where it was.
|
||||
|
||||
**3 November 1881, 11:40.** The record gave way. Four miles of line came loose from the
|
||||
date with No. 9 and five cars on it, **sixty-one souls aboard**, and a **gunfight already
|
||||
three seconds old**.
|
||||
|
||||
**The loop is four minutes long and the fight is inside it.** The crossing did not capture
|
||||
a train; it captured four minutes, and those four minutes contain two armed parties
|
||||
shooting the length of car three. It has run about two million times.
|
||||
|
||||
### The two sides — and neither is the villain
|
||||
|
||||
| | Who | What they have | What they believe |
|
||||
|---|---|---|---|
|
||||
| **The coats** | Four men hired by the company to see the payroll to Fallon, led by **Cass Merritt** | A signed letter of engagement, and the key to the box | That men who board at a water stop are robbers |
|
||||
| **The writ men** | Five Cordera miners, eleven weeks unpaid, led by **Josiah Ambrose** | A sealed writ of attachment from a territorial judge, naming that payroll | That they are lawfully seizing wages and being shot at for it |
|
||||
|
||||
**The company sold the same money twice and is not aboard.** Both papers are genuine.
|
||||
Neither man has seen the other's. Nobody in car three is lying and nobody is wrong.
|
||||
**Do not adjudicate it.** The players can learn enough to see there is nothing to settle.
|
||||
|
||||
**The running is what holds it open.** This is the thing the players must arrive at: the
|
||||
train is not trapped inside the crossing — the train *is* the crossing. A standing wave,
|
||||
sustained by its own motion the way a bow sustains a note.
|
||||
|
||||
- **Nobody is dead.** They eat, sleep in shifts, and have organised a water rota. By
|
||||
their clocks it has been about nine hours.
|
||||
- **Nobody can get off while it runs.** Three departmental attempts are on file; none
|
||||
returned.
|
||||
- **Stop the train and the loop ends.** This is the mission, and it is not a mercy killing.
|
||||
|
||||
### What closing it actually does — read this twice
|
||||
|
||||
**Closing the crossing does not kill anybody. It lets them go.** The loop ends, the third
|
||||
of November 1881 resumes from 11:44, and every person aboard walks forward into the life
|
||||
they would have had.
|
||||
|
||||
**Including the ones who did not survive the real four minutes.** History resumes as
|
||||
history: Josiah Ambrose and a miner named Doyle died in that gunfight, and when the loop
|
||||
lets go they die in it. Not because the agents killed them — because that is what
|
||||
happened, and it has been prevented from happening for a hundred and forty-five years.
|
||||
|
||||
**That is the weight of this ending and it is the whole point.** The agents are not
|
||||
choosing who lives. They are choosing to stop holding history still, and they will have
|
||||
met the people it lands on.
|
||||
|
||||
The department's position is unambiguous: **close it.** A crossing held open by a
|
||||
repeating event degrades, and when it fails on its own — Registry's read is within a
|
||||
year — everyone inside is lost outright rather than returned. Waiting is the only option
|
||||
that is definitely worse.
|
||||
|
||||
**Elias Vane is aboard.** He rode his own line to see what he had signed for. He has had
|
||||
nine hours and a hundred and forty-five years to think about it. He understands the
|
||||
standing wave better than the department does, he has told nobody, and he will answer any
|
||||
direct question honestly because he has been waiting a very long time to be asked.
|
||||
|
||||
### Timeline
|
||||
|
||||
| When | What |
|
||||
|---|---|
|
||||
| 12 Apr 1881 | Vane files the survey. North bench. |
|
||||
| Apr–Oct 1881 | Line laid on the south bench. Six months of paperwork says otherwise. |
|
||||
| **3 Nov 1881, 11:40** | The record gives way, with No. 9 inside it. |
|
||||
| 1889 | Rails lifted. The grade is still there. |
|
||||
| 1974, 1991, 2009 | Three departmental attempts. No survivors. All tried to stop it. |
|
||||
| Every day, 11:40 | The crossing opens on the grade for about four minutes. |
|
||||
|
||||
### Revelations
|
||||
|
||||
| # | Revelation |
|
||||
|---|---|
|
||||
| R1 | The filed survey puts the line on the north bench |
|
||||
| R2 | There has never been a railway on the north bench |
|
||||
| R3 | Six months of paperwork repeated it |
|
||||
| R4 | The dispatcher's book has no 11:45 entry |
|
||||
| R5 | The 11:40 sound is a train working *up* a grade — always accelerating |
|
||||
| R6 | Nobody aboard is dead, and by their clocks it has been nine hours |
|
||||
| R7 | The running is what holds the crossing open |
|
||||
| R8 | Coal that should have lasted four hours has lasted a hundred and forty-five years |
|
||||
| R9 | Kell will stop if told to, out loud, to his face — and only then |
|
||||
| R10 | Vane signed it, and knows exactly what stopping it costs |
|
||||
| R11 | The four minutes contain a gunfight, and it repeats exactly |
|
||||
| R12 | Both sides have genuine paper, and the company sold the money twice |
|
||||
| R13 | Closing it releases everyone into their own history — including the two who die in it |
|
||||
|
||||
---
|
||||
|
||||
## Player Experience
|
||||
|
||||
You are handed a Webley, a single-shot rifle, an oil lantern and a pocket watch, and told
|
||||
that the handset stays behind. Then you stand in a dry wash in Nevada at 11:38 in the
|
||||
morning and listen to a train you cannot see work a grade that has had no rails on it
|
||||
since 1889. By lunchtime you know it is a survey fraud from 1881. By the evening you are
|
||||
lying on the top of a water tower waiting for forty seconds of slow track, and after that
|
||||
you are in a warm carriage that smells of coffee, being asked by a conductor with a
|
||||
manifest whether you are from the company. She has counted sixty-one every hour for a
|
||||
hundred and forty-five years. She would like that noted.
|
||||
|
||||
---
|
||||
|
||||
## The Countdown — the crossing clock (0–6)
|
||||
|
||||
Move it on **each pass of the train**, when **somebody uses a modern object across**, and
|
||||
on a **fumbled boarding attempt**.
|
||||
|
||||
| Clock | What |
|
||||
|---|---|
|
||||
| 0 | Open. Four minutes at 11:40. |
|
||||
| 1 | Thinning. The whistle arrives a half-second before it should. |
|
||||
| 2 | Slipping. −1 Coherence to everyone across, once. |
|
||||
| 3 | The line flickers — a hundred yards of grade is briefly not there. |
|
||||
| 4 | The far end stops answering. Fallon's wire goes dead in 1881. |
|
||||
| 5 | One pass left, and everybody can tell. |
|
||||
| 6 | It closes, with whoever is aboard. |
|
||||
|
||||
---
|
||||
|
||||
## Handouts
|
||||
|
||||
| ID | Description | When |
|
||||
|---|---|---|
|
||||
| TT-H01 | Case brief TT-004 | Briefing |
|
||||
| TT-H02 | **Standing Order 9** — period crossings | Briefing |
|
||||
| TT-H03 | Issue list, 1881 | Briefing |
|
||||
| TT-H04 | **The filed survey** — the line on the north bench | Act One |
|
||||
| TT-H05 | Working timetable and the dispatcher's book | Act One |
|
||||
| TT-H06 | **Passenger manifest** — sixty-one souls, nine children | Act Three |
|
||||
| TT-H07 | **Letter of engagement** — found on Merritt | Act Three, when searched |
|
||||
| TT-H08 | **Writ of attachment** — found on Ambrose | Act Three, when searched |
|
||||
|
||||
The two papers are the ambiguity, and they only mean anything **held in the same hand**.
|
||||
Give them separately and let the table put them together.
|
||||
|
||||
The manifest is deliberately held back to Act Three. It is a list of names, and the
|
||||
players should not be holding it until the moment it starts to weigh something.
|
||||
|
||||
---
|
||||
|
||||
## Briefing — the standing order *(~20 min)*
|
||||
|
||||
> You will be crossing to November 1881. Nothing you are carrying goes with you. Stores
|
||||
> has laid out what you may take instead, and I would ask you not to argue with the
|
||||
> storeman, because he is right and you are in a hurry.
|
||||
|
||||
**Nothing modern goes through.** Give both reasons:
|
||||
|
||||
1. **It costs you.** An object that does not belong is a thing the far side must also be
|
||||
wrong about. **Using** a modern item across costs **1 Coherence**, per object, first
|
||||
time.
|
||||
2. **It is a beacon.** A crossing is a place where the record is already wrong. It
|
||||
notices what does not fit, and the case clock moves.
|
||||
|
||||
**Charge for using, never for carrying.** A player who smuggles a torch across and never
|
||||
switches it on has made a good decision and should get away with it.
|
||||
|
||||
**The anchor is exempt.** Everybody keeps theirs.
|
||||
|
||||
### The 1881 issue
|
||||
|
||||
| Instead of | They get |
|
||||
|---|---|
|
||||
| service pistol | **Webley revolver** — 1d10, six shots, slow reload |
|
||||
| carbine | **Martini-Henry** — 2d6+2, **one shot**, then work the lever |
|
||||
| torch | **oil lantern** and lamp oil, which runs out |
|
||||
| the handset | **nothing.** Out of contact from the moment they cross. |
|
||||
| stab vest | **buff coat** or oilcloth coat |
|
||||
| medkit | **chirurgeon's roll** and **laudanum** |
|
||||
| phone and GPS | **brass compass**, **pocket watch**, **spyglass** |
|
||||
|
||||
---
|
||||
|
||||
## Act One — Mercy Draw, now *(~50 min)*
|
||||
|
||||
> **Goal:** find the fraud, hear the train, understand it is on a timetable.
|
||||
|
||||
Nevada. A dry wash four miles long with a railway grade along the south side. Rails
|
||||
lifted in 1889; ballast and tie marks still there. Your watch says 11:38.
|
||||
|
||||
- **The south bench** — the grade, real and obvious.
|
||||
- **The north bench** — nothing, and never anything. `[CUS: Spot or Engineering]`
|
||||
- **The water tower footing**, half a mile up at the top of the grade. *Remember it.*
|
||||
- **11:40.** A whistle a long way off, then four minutes of a train working a grade,
|
||||
getting no nearer. **Automatic — nobody rolls for this.**
|
||||
|
||||
### Clue trail
|
||||
|
||||
| # | Clue | How found | Type |
|
||||
|---|---|---|---|
|
||||
| 1 | The filed survey puts the line on the **north** bench | **Automatic** — it is a handout with a line drawn on it | Essential |
|
||||
| 2 | There has never been a railway on the north bench | **Automatic** to anyone who walks it | Essential |
|
||||
| 3 | Six months of paperwork all say north bench | `[CUS: Research]`, or Registry provides | Essential |
|
||||
| 4 | No 11:45 entry in the dispatcher's book | `[CUS: Research]` | Essential |
|
||||
| 5 | E. Vane, surveyor, is on the manifest | `[CUS: Research]` | Optional |
|
||||
| 6 | Three previous attempts, no survivors, all tried to stop it | Registry, if asked | Optional |
|
||||
| 7 | The sound is a train *accelerating* up a grade | `[CUS: Listen or Engineering]` | Optional |
|
||||
|
||||
**Fallbacks.** 1 and 2 are the handout and the ground. 3 and 4 arrive from Registry by
|
||||
telephone at the twenty-minute mark if nobody goes looking — Bewley is thorough and
|
||||
slightly impatient.
|
||||
|
||||
**They cannot board at this end.** Too fast, grade against them. Let them try: a bruise, a
|
||||
lesson, and the clock moves one.
|
||||
|
||||
> **Fast-track at 1:10:** the 11:40 comes round again and the door stays open long enough
|
||||
> to walk through. That is what degrading looks like.
|
||||
|
||||
---
|
||||
|
||||
## Act Two — Cordera, 1881 *(~70 min)*
|
||||
|
||||
> **Goal:** the far side, the standing order in practice, and the boarding.
|
||||
|
||||
The fourth of November, 1881. A town of four hundred that smells of woodsmoke and horses.
|
||||
Nobody is frightened yet — a train went into the Draw yesterday and has not come out, and
|
||||
the wire says it never arrived.
|
||||
|
||||
- **The company office** — dispatcher, orders on spikes, the original survey in a drawer.
|
||||
- **The livery** — horses. This matters.
|
||||
- **Ada Reyes**, telegraph, who has sent the same query forty times and has noticed the
|
||||
missing 11:45.
|
||||
- **The Draw**, four miles out. The train passes about every forty minutes.
|
||||
|
||||
**Nobody in 1881 can help them stop it, and everybody will help them get near it.** The
|
||||
company wants its train back. Let the players use that.
|
||||
|
||||
### The set-piece — getting aboard
|
||||
|
||||
The train cannot stop and cannot be flagged. The only way on is to **match its speed**,
|
||||
and the only place it is slow is **the top of the grade at the water tower** — about eight
|
||||
miles an hour, for perhaps forty seconds.
|
||||
|
||||
1. **Ride it down.** Horses alongside on the bench, step across. `[CUS: Ride, then
|
||||
Athletics]` — and the horse needs its own Ride roll to go near the train at all.
|
||||
2. **Drop from the tower.** Twelve feet onto a moving roof. `[CUS: Athletics — average;
|
||||
**only a fumble is a fall**]`. **Do not make this difficult** — playtested at halved
|
||||
skill it put four agents on 12–31% and all four missed, which stalls the one scene the
|
||||
scenario is built around.
|
||||
3. **The wagon on the grade.** Four horses, a plank across. Slowest to arrange, safest to
|
||||
cross, and it needs the company's help.
|
||||
|
||||
**The wagon is easier than the tower, and the tower is easier than the horses.** A party
|
||||
that spends an hour arranging the wagon has bought an easy roll and should get one.
|
||||
|
||||
**Say which route suits whom, out loud, before they choose.** Athletics on a duty-roster
|
||||
sheet runs from about 25% to 60%, and the split is not subtle: the Containment officer and
|
||||
the criminal take the tower, the Field Lead can, and **the doctor and the Registry officer
|
||||
should be on the wagon**. Playtested by putting all five on the tower: two missed twice,
|
||||
had to be carried across on the department's plank, and became passengers in their own
|
||||
set-piece — at a cost of two passes on the clock.
|
||||
|
||||
**Everybody has to get on.** A failed attempt gets the next pass, forty minutes later, and
|
||||
the clock moves — not a full stop.
|
||||
|
||||
**Panic:** boarding is worth a point to anybody who wants one. Offer it; do not force it.
|
||||
|
||||
> **At 2:45 they are aboard.** If two attempts have failed, the department's second wagon
|
||||
> arrives with two more agents and a plank, and the players owe somebody a favour.
|
||||
|
||||
---
|
||||
|
||||
## Act Three — Aboard, and the four minutes *(~50 min)*
|
||||
|
||||
> **Goal:** survive a gunfight nobody started for their benefit, work out that it repeats,
|
||||
> and close the crossing anyway.
|
||||
|
||||
> It is warm. That is the first thing. Sixty-one people in five cars, and it smells of
|
||||
> bodies and coal smoke and coffee. Somebody has organised a rota for the water. A child
|
||||
> is asleep across two seats with a coat over her. They look up when you come through the
|
||||
> door, and one of them says: *"You want to be somewhere else in about six minutes."*
|
||||
|
||||
| Where | What |
|
||||
|---|---|
|
||||
| The locomotive | **Marius Kell**, who has not let go of the regulator |
|
||||
| Tender, car one | Coal that has not run out — the first hard evidence |
|
||||
| Car two | Most of the passengers. They lie down before it starts. They have a drill. |
|
||||
| **Car three** | **Where the fight happens.** Overturned seats at both ends. |
|
||||
| Car four | Freight, the payroll box bolted down, the signed survey in the satchel |
|
||||
| Car five | **Elias Vane**, alone, watching the line come out from under |
|
||||
|
||||
### The four minutes
|
||||
|
||||
Starts on the whistle for the grade. Run it in rounds. **It is the same four minutes every
|
||||
time** — same men, same shots, same two passengers hit in the second minute.
|
||||
|
||||
| Minute | What happens if nobody interferes |
|
||||
|---|---|
|
||||
| 1 | Ambrose's five come forward from car four. Merritt's four are at the front of car three. Somebody shouts *put it down*; both sides think they said it. |
|
||||
| 2 | First volley. **Two passengers are hit** — a miner named Doyle, and Mrs Danforth's arm. Every time. |
|
||||
| 3 | Merritt falls back to the car four door and holds it. Ambrose takes the overturned seats. Everybody reloads: a Webley is six, a Martini is one. |
|
||||
| 4 | Ambrose reaches the box. Merritt shoots him through the door. The whistle sounds, and it resets. |
|
||||
|
||||
### HOW MUCH FIRE THE AGENTS DRAW — read before running it
|
||||
|
||||
**The two sides are shooting at each other, not at the agents.** Getting this wrong turns
|
||||
the scene into a massacre. Work out what each agent is *doing* and give them that much
|
||||
fire and no more.
|
||||
|
||||
| The agent is | Shooters | Cover | Measured over 300 runs |
|
||||
|---|---|---|---|
|
||||
| **Head down, not acting** | none | — | Untouched. A legitimate choice for a pass. |
|
||||
| **Acting from cover** | 1 | −30% | **2 of 4 hurt, half a party member down, no wipes.** The intended shape. |
|
||||
| **Moving in the open** | 2 | −30% | 3 of 4 hurt, one down. One party in 300 wiped. |
|
||||
| **Going for the payroll box** | 2 | none | **3½ of 4 down. More than half of all parties wiped.** |
|
||||
|
||||
**Tell them the last row before they commit, not after.** Everything else is survivable.
|
||||
**Nobody rolls to hide** — taking cover is a decision, and it works.
|
||||
|
||||
### THE LOOP RESETS. THE AGENTS DO NOT.
|
||||
|
||||
Everybody who belongs to 1881 is back on their feet at the next pass, unhurt, with no
|
||||
memory of having been shot. **The agents are not part of the loop and their wounds are
|
||||
permanent.** Say this before the first volley — a player who thinks the reset will heal
|
||||
them will take risks that kill them.
|
||||
|
||||
A downed agent is *down*, not dead: the dying rules apply, and the only medicine aboard is
|
||||
a chirurgeon's roll and a bottle of laudanum. First Aid is the whole answer, and somebody
|
||||
has to stop shooting to give it.
|
||||
|
||||
*A playtested pass, four agents all acting from cover: two down at 0 hit points, one on a
|
||||
quarter, one untouched because he stayed on the floor. That is a bad pass rather than a
|
||||
typical one — the average is half a party member — but it is possible on the first one.*
|
||||
|
||||
### What the players will try
|
||||
|
||||
| They try | What it does |
|
||||
|---|---|
|
||||
| Shout, show a warrant card, take charge | Nothing. No authority aboard is recognised. `[CUS: Persuade at −30% on the first pass; easier later as they learn names]` |
|
||||
| Shoot one side | It works, and changes nothing. Everyone is back next pass. **Let that land in silence.** |
|
||||
| Get the passengers down | Genuinely useful. Doyle and Mrs Danforth can be pulled clear before minute two. |
|
||||
| Get to the payroll box | The obvious idea, and the one that kills them. See the table. |
|
||||
| Show one side the other's paper | **The good move.** It does not stop the fight; it is how they learn what they are looking at. |
|
||||
| Get to the locomotive | Through car two and the tender, under fire in the gap. This is the route to the ending. |
|
||||
|
||||
### The paper — the ambiguity, made findable
|
||||
|
||||
**Merritt** carries a letter of engagement from the company: see the box to Fallon,
|
||||
surrender it to no one *upon any pretext whatsoever, including the presentation of any
|
||||
paper*. **Ambrose** carries a sealed writ of attachment naming that same payroll against
|
||||
eleven weeks of unpaid wages.
|
||||
|
||||
**Both genuine. Neither man has seen the other's.** An agent holding both has the whole
|
||||
truth: the company sold the same money twice, then put nine armed men on a train with it.
|
||||
|
||||
**NO ROLL IS NEEDED TO SEE EITHER PAPER.** Between passes Merritt shows his letter to
|
||||
anybody who asks — he is proud of it, it is his authority, and he has had a hundred and
|
||||
forty-five years with nobody to show it to. Ambrose shows the writ to anybody not
|
||||
currently shooting at him, because having it read is the entire thing he wants. **Do not
|
||||
put Persuade in front of this.** Playtested with a real team, the Field Lead's Persuade
|
||||
was 15% and fell to 1% under the crowd penalty — which turned the scene the scenario is
|
||||
proudest of into an impossible roll.
|
||||
|
||||
**It does not stop the fight.** Merritt will not stand down for a judge he has never heard
|
||||
of while being shot at, and Ambrose has eleven weeks of nothing behind him.
|
||||
|
||||
### Closing it
|
||||
|
||||
**Kell will stop the train if a person he believes has authority tells him to, out loud,
|
||||
to his face.** He has waited a hundred and forty-five years to be told. Not on a note, not
|
||||
shouted from the tender. Getting there means the gap between the cars — `[CUS: Athletics]`.
|
||||
|
||||
**Convincing him takes NO ROLL if they can tell him one true thing about his own train.**
|
||||
That the coal has not run out. That it is the same four minutes. That the line was never
|
||||
on the north bench. Any of it. The moment somebody who knows what is wrong says so, he
|
||||
asks them to say when.
|
||||
|
||||
**Only a party that can tell him nothing has to roll** — Persuade or Status at average, and
|
||||
a failure means he wants another pass to think. **Do not gate the ending on Persuade.**
|
||||
Seven of the ten duty-roster agents have neither Persuade nor Status trained; playtested,
|
||||
three separate attempts on Kell all failed and the scenario had no way to end.
|
||||
|
||||
**When the brakes go on, the loop lets go.** Four minutes finish for the first time since
|
||||
1881. Doyle and Ambrose do not get up. They never did.
|
||||
|
||||
### The choices — no roll resolves this
|
||||
|
||||
| Choice | What it costs |
|
||||
|---|---|
|
||||
| **Close it as ordered** | The mission, done. Fifty-nine released into their own century. Two men die of wounds taken in 1881, in 1881. |
|
||||
| **Close it, having pulled Doyle and Mrs Danforth clear** | Works for those two. Nobody can save Ambrose — only stopping the train stops Merritt shooting him, and stopping the train is what kills him. |
|
||||
| **Try to end the fight first** | Admirable, probably impossible. Every pass spent trying moves the crossing clock; at 6 it closes with everyone inside. |
|
||||
| **Refuse, leave it running** | Everyone lives, for a value of living four minutes long. It fails inside a year and then nobody is released at all. |
|
||||
|
||||
### If they ask Vane
|
||||
|
||||
> I filed a chart that said the line was somewhere it was not, because a man told me to
|
||||
> and I wanted the work. Six months of honest railwaymen repeating it made it true. Now —
|
||||
> you have seen the middle of the car. Two of those men do not get up when it stops. They
|
||||
> have not got up two million times and they will not get up now. I would still stop it.
|
||||
> It is not our place to hold a thing still because we have grown fond of it.
|
||||
|
||||
---
|
||||
|
||||
## Epilogue — the register *(~20 min)*
|
||||
|
||||
A Registry room, a form, and somebody writing down sixty-one names.
|
||||
|
||||
| Ending | What the register says |
|
||||
|---|---|
|
||||
| **Closed as ordered** | Fifty-nine walk forward into their own century. Ambrose and Doyle die of wounds taken on 3 November 1881, four minutes after they took them. Registry counts it a success and it is one. |
|
||||
| **Closed, the two passengers pulled clear first** | As above, and Mrs Danforth keeps her arm. |
|
||||
| **Closed late** | The same ending, more expensively. If the clock reached 6 first, add the agents to the list. |
|
||||
| **Left running** | Logged, monitored, inherited. Sixty-one people continue to live four minutes. When it fails, nobody is released at all. |
|
||||
|
||||
**Registry's note on any ending:** *"The department did not cause these deaths. It ceased
|
||||
to prevent them. The distinction matters to the file and it is not expected to matter to
|
||||
the agents."*
|
||||
|
||||
---
|
||||
|
||||
## Cast
|
||||
|
||||
| Who | Wants | Key line |
|
||||
|---|---|---|
|
||||
| **Cass Merritt**, hired guard | to see the box to Fallon, as engaged | *"I have a letter. Whatever you have, I have a letter."* |
|
||||
| **Josiah Ambrose**, miner | eleven weeks of wages, lawfully | *"There is a judge's seal on this. Read it and then shoot me."* |
|
||||
| **Marius Kell**, engineer | to be allowed to stop | *"You tell me when. I'll not do it before you say."* |
|
||||
| **Elias Vane**, surveyor | to be asked, at last | *"I signed it. Six months of honest men made it true."* |
|
||||
| **Hester Bligh**, conductor | her passengers accounted for | *"Sixty-one aboard. I have counted them every hour since."* |
|
||||
| **Ada Reyes**, telegraph | an answer up the line | *"It never got to Fallon. I've sent it forty times."* |
|
||||
| **Mr Bewley**, Registry | the chart, and the truth written down | *"Bring me the paper. The paper is the case."* |
|
||||
|
||||
---
|
||||
|
||||
## Do not resolve
|
||||
|
||||
- **Who was in the right.** Both papers are genuine, the company sold the money twice,
|
||||
and the company is not aboard to answer for it. If a player asks the GM to adjudicate,
|
||||
do not.
|
||||
- **Why the coal has not run out.** The wave sustains it; that is as far as anyone gets.
|
||||
- **What is at the far end of the line.** Nobody aboard has reached it.
|
||||
- **Whether closing it was a rescue.** Fifty-nine people say yes. Two men do not get to
|
||||
say.
|
||||
|
||||
## GM notes
|
||||
|
||||
- **Play the passengers as people, hard** — and the nine men in the fight hardest of all.
|
||||
- **Never let the players win the fight.** They can survive it, shape it, and pull people
|
||||
out of it. They cannot end it. Only the brakes end it.
|
||||
- **Standing Order 9 is the tutorial.** Do not soften it and do not apologise for it.
|
||||
- **Cut first:** the town, down to the livery and the telegraph office. Never cut the
|
||||
boarding.
|
||||
- **If they crack it early,** run the passes — six of them, forty minutes apart — and let
|
||||
the table feel the arithmetic they are betting on.
|
||||
|
||||
---
|
||||
|
||||
## Which scenario to run
|
||||
|
||||
| | Length | Combat | Sends them across |
|
||||
|---|---|---|---|
|
||||
| **LAST ADMISSION** | 2h | yes, one fight | no — prologue, civilians |
|
||||
| **OPEN DAY** | 2h15 | no | no — prologue, civilians |
|
||||
| **THROUGH TRAIN** | 3h30 | no | **yes** — the first real case |
|
||||
| **BLACK PLATFORM** | 3–4h | yes, heavily | no — a modern-day action case |
|
||||
|
||||
Run a prologue, then THROUGH TRAIN. BLACK PLATFORM is the one to reach for when a table
|
||||
wants a fight.
|
||||
@@ -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,5 +1,5 @@
|
||||
2026/08/12-20:33:19.258824 175abb000 Recovering log #3
|
||||
2026/08/12-20:33:19.258964 175abb000 Level-0 table #5: started
|
||||
2026/08/12-20:33:19.259113 175abb000 Level-0 table #5: 4591 bytes OK
|
||||
2026/08/12-20:33:19.259390 175abb000 Delete type=0 #3
|
||||
2026/08/12-20:33:19.259432 175abb000 Delete type=3 #2
|
||||
2026/08/18-22:37:32.799186 176b97000 Recovering log #3
|
||||
2026/08/18-22:37:32.799323 176b97000 Level-0 table #5: started
|
||||
2026/08/18-22:37:32.799504 176b97000 Level-0 table #5: 4591 bytes OK
|
||||
2026/08/18-22:37:32.799868 176b97000 Delete type=0 #3
|
||||
2026/08/18-22:37:32.799916 176b97000 Delete type=3 #2
|
||||
|
||||
@@ -1 +1 @@
|
||||
2026/08/12-20:33:03.500947 17385b000 Delete type=3 #1
|
||||
2026/08/18-22:37:15.356757 172877000 Delete type=3 #1
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
2026/08/12-20:33:19.281748 1772df000 Recovering log #3
|
||||
2026/08/12-20:33:19.282130 1772df000 Level-0 table #5: started
|
||||
2026/08/12-20:33:19.282266 1772df000 Level-0 table #5: 4129 bytes OK
|
||||
2026/08/12-20:33:19.282510 1772df000 Delete type=0 #3
|
||||
2026/08/12-20:33:19.282542 1772df000 Delete type=3 #2
|
||||
2026/08/18-22:37:32.827220 176b97000 Recovering log #3
|
||||
2026/08/18-22:37:32.827303 176b97000 Level-0 table #5: started
|
||||
2026/08/18-22:37:32.827422 176b97000 Level-0 table #5: 4129 bytes OK
|
||||
2026/08/18-22:37:32.827654 176b97000 Delete type=0 #3
|
||||
2026/08/18-22:37:32.827715 176b97000 Delete type=3 #2
|
||||
|
||||
@@ -1 +1 @@
|
||||
2026/08/12-20:33:03.577064 17385b000 Delete type=3 #1
|
||||
2026/08/18-22:37:15.433657 17388f000 Delete type=3 #1
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
2026/08/12-20:33:19.260549 1772df000 Recovering log #3
|
||||
2026/08/12-20:33:19.260715 1772df000 Level-0 table #5: started
|
||||
2026/08/12-20:33:19.261020 1772df000 Level-0 table #5: 31614 bytes OK
|
||||
2026/08/12-20:33:19.261257 1772df000 Delete type=0 #3
|
||||
2026/08/12-20:33:19.261283 1772df000 Delete type=3 #2
|
||||
2026/08/18-22:37:32.801021 17638b000 Recovering log #3
|
||||
2026/08/18-22:37:32.801454 17638b000 Level-0 table #5: started
|
||||
2026/08/18-22:37:32.801772 17638b000 Level-0 table #5: 31614 bytes OK
|
||||
2026/08/18-22:37:32.802069 17638b000 Delete type=0 #3
|
||||
2026/08/18-22:37:32.802111 17638b000 Delete type=3 #2
|
||||
|
||||
@@ -1 +1 @@
|
||||
2026/08/12-20:33:03.503203 174067000 Delete type=3 #1
|
||||
2026/08/18-22:37:15.358422 17409b000 Delete type=3 #1
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
2026/08/12-20:33:19.273026 175abb000 Recovering log #3
|
||||
2026/08/12-20:33:19.273575 175abb000 Level-0 table #5: started
|
||||
2026/08/12-20:33:19.273963 175abb000 Level-0 table #5: 45532 bytes OK
|
||||
2026/08/12-20:33:19.274265 175abb000 Delete type=0 #3
|
||||
2026/08/12-20:33:19.274316 175abb000 Delete type=3 #2
|
||||
2026/08/18-22:37:32.818774 176b97000 Recovering log #3
|
||||
2026/08/18-22:37:32.819665 176b97000 Level-0 table #5: started
|
||||
2026/08/18-22:37:32.820075 176b97000 Level-0 table #5: 46857 bytes OK
|
||||
2026/08/18-22:37:32.820335 176b97000 Delete type=0 #3
|
||||
2026/08/18-22:37:32.820381 176b97000 Delete type=3 #2
|
||||
|
||||
@@ -1 +1 @@
|
||||
2026/08/12-20:33:03.557918 172843000 Delete type=3 #1
|
||||
2026/08/18-22:37:15.417310 172877000 Delete type=3 #1
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
2026/08/12-20:33:19.245248 1762c7000 Recovering log #3
|
||||
2026/08/12-20:33:19.246727 1762c7000 Level-0 table #5: started
|
||||
2026/08/12-20:33:19.248205 1762c7000 Level-0 table #5: 225945 bytes OK
|
||||
2026/08/12-20:33:19.248498 1762c7000 Delete type=0 #3
|
||||
2026/08/12-20:33:19.248546 1762c7000 Delete type=3 #2
|
||||
2026/08/18-22:37:32.784037 17638b000 Recovering log #3
|
||||
2026/08/18-22:37:32.786085 17638b000 Level-0 table #5: started
|
||||
2026/08/18-22:37:32.787900 17638b000 Level-0 table #5: 234672 bytes OK
|
||||
2026/08/18-22:37:32.788307 17638b000 Delete type=0 #3
|
||||
2026/08/18-22:37:32.788379 17638b000 Delete type=3 #2
|
||||
|
||||
@@ -1 +1 @@
|
||||
2026/08/12-20:33:03.540045 172843000 Delete type=3 #1
|
||||
2026/08/18-22:37:15.394128 172877000 Delete type=3 #1
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
2026/08/12-20:33:19.255310 175abb000 Recovering log #3
|
||||
2026/08/12-20:33:19.255554 175abb000 Level-0 table #5: started
|
||||
2026/08/12-20:33:19.255695 175abb000 Level-0 table #5: 3295 bytes OK
|
||||
2026/08/12-20:33:19.255978 175abb000 Delete type=0 #3
|
||||
2026/08/12-20:33:19.256022 175abb000 Delete type=3 #2
|
||||
2026/08/18-22:37:32.795533 176b97000 Recovering log #3
|
||||
2026/08/18-22:37:32.795934 176b97000 Level-0 table #5: started
|
||||
2026/08/18-22:37:32.796065 176b97000 Level-0 table #5: 3295 bytes OK
|
||||
2026/08/18-22:37:32.796355 176b97000 Delete type=0 #3
|
||||
2026/08/18-22:37:32.796392 176b97000 Delete type=3 #2
|
||||
|
||||
@@ -1 +1 @@
|
||||
2026/08/12-20:33:03.569801 17304f000 Delete type=3 #1
|
||||
2026/08/18-22:37:15.428065 173083000 Delete type=3 #1
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
2026/08/12-20:33:19.232393 175abb000 Recovering log #3
|
||||
2026/08/12-20:33:19.235807 175abb000 Level-0 table #5: started
|
||||
2026/08/12-20:33:19.239193 175abb000 Level-0 table #5: 545293 bytes OK
|
||||
2026/08/12-20:33:19.239934 175abb000 Delete type=0 #3
|
||||
2026/08/12-20:33:19.240034 175abb000 Delete type=3 #2
|
||||
2026/08/18-22:37:32.768723 176b97000 Recovering log #3
|
||||
2026/08/18-22:37:32.772081 176b97000 Level-0 table #5: started
|
||||
2026/08/18-22:37:32.777725 176b97000 Level-0 table #5: 560581 bytes OK
|
||||
2026/08/18-22:37:32.778401 176b97000 Delete type=0 #3
|
||||
2026/08/18-22:37:32.778494 176b97000 Delete type=3 #2
|
||||
|
||||
@@ -1 +1 @@
|
||||
2026/08/12-20:33:03.524869 17385b000 Delete type=3 #1
|
||||
2026/08/18-22:37:15.379031 173083000 Delete type=3 #1
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
2026/08/12-20:33:19.263420 1762c7000 Recovering log #3
|
||||
2026/08/12-20:33:19.263582 1762c7000 Level-0 table #5: started
|
||||
2026/08/12-20:33:19.263817 1762c7000 Level-0 table #5: 21613 bytes OK
|
||||
2026/08/12-20:33:19.264044 1762c7000 Delete type=0 #3
|
||||
2026/08/12-20:33:19.264079 1762c7000 Delete type=3 #2
|
||||
2026/08/18-22:37:32.804709 175b7f000 Recovering log #3
|
||||
2026/08/18-22:37:32.804956 175b7f000 Level-0 table #5: started
|
||||
2026/08/18-22:37:32.805208 175b7f000 Level-0 table #5: 23087 bytes OK
|
||||
2026/08/18-22:37:32.805486 175b7f000 Delete type=0 #3
|
||||
2026/08/18-22:37:32.805525 175b7f000 Delete type=3 #2
|
||||
|
||||
@@ -1 +1 @@
|
||||
2026/08/12-20:33:03.565946 17304f000 Delete type=3 #1
|
||||
2026/08/18-22:37:15.424767 17409b000 Delete type=3 #1
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
2026/08/12-20:33:19.275275 175abb000 Recovering log #3
|
||||
2026/08/12-20:33:19.275814 175abb000 Level-0 table #5: started
|
||||
2026/08/12-20:33:19.276182 175abb000 Level-0 table #5: 43953 bytes OK
|
||||
2026/08/12-20:33:19.276489 175abb000 Delete type=0 #3
|
||||
2026/08/12-20:33:19.276529 175abb000 Delete type=3 #2
|
||||
2026/08/18-22:37:32.821353 176b97000 Recovering log #3
|
||||
2026/08/18-22:37:32.821969 176b97000 Level-0 table #5: started
|
||||
2026/08/18-22:37:32.822328 176b97000 Level-0 table #5: 44887 bytes OK
|
||||
2026/08/18-22:37:32.822647 176b97000 Delete type=0 #3
|
||||
2026/08/18-22:37:32.822686 176b97000 Delete type=3 #2
|
||||
|
||||
@@ -1 +1 @@
|
||||
2026/08/12-20:33:03.562330 17385b000 Delete type=3 #1
|
||||
2026/08/18-22:37:15.421322 17409b000 Delete type=3 #1
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
2026/08/12-20:33:19.264705 176ad3000 Recovering log #3
|
||||
2026/08/12-20:33:19.265215 176ad3000 Level-0 table #5: started
|
||||
2026/08/12-20:33:19.265531 176ad3000 Level-0 table #5: 33484 bytes OK
|
||||
2026/08/12-20:33:19.265770 176ad3000 Delete type=0 #3
|
||||
2026/08/12-20:33:19.265802 176ad3000 Delete type=3 #2
|
||||
2026/08/18-22:37:32.806477 1773a3000 Recovering log #3
|
||||
2026/08/18-22:37:32.807016 1773a3000 Level-0 table #5: started
|
||||
2026/08/18-22:37:32.807385 1773a3000 Level-0 table #5: 36532 bytes OK
|
||||
2026/08/18-22:37:32.807654 1773a3000 Delete type=0 #3
|
||||
2026/08/18-22:37:32.807690 1773a3000 Delete type=3 #2
|
||||
|
||||
@@ -1 +1 @@
|
||||
2026/08/12-20:33:03.575038 17304f000 Delete type=3 #1
|
||||
2026/08/18-22:37:15.432064 172877000 Delete type=3 #1
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
2026/08/12-20:33:19.251838 175abb000 Recovering log #3
|
||||
2026/08/12-20:33:19.252367 175abb000 Level-0 table #5: started
|
||||
2026/08/12-20:33:19.252611 175abb000 Level-0 table #5: 7590 bytes OK
|
||||
2026/08/12-20:33:19.252941 175abb000 Delete type=0 #3
|
||||
2026/08/12-20:33:19.252982 175abb000 Delete type=3 #2
|
||||
2026/08/18-22:37:32.791576 1773a3000 Recovering log #3
|
||||
2026/08/18-22:37:32.791785 1773a3000 Level-0 table #5: started
|
||||
2026/08/18-22:37:32.792018 1773a3000 Level-0 table #5: 7806 bytes OK
|
||||
2026/08/18-22:37:32.792443 1773a3000 Delete type=0 #3
|
||||
2026/08/18-22:37:32.792496 1773a3000 Delete type=3 #2
|
||||
|
||||
@@ -1 +1 @@
|
||||
2026/08/12-20:33:03.494044 17304f000 Delete type=3 #1
|
||||
2026/08/18-22:37:15.351270 17409b000 Delete type=3 #1
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
2026/08/12-20:33:19.277727 175abb000 Recovering log #3
|
||||
2026/08/12-20:33:19.278352 175abb000 Level-0 table #5: started
|
||||
2026/08/12-20:33:19.278612 175abb000 Level-0 table #5: 16902 bytes OK
|
||||
2026/08/12-20:33:19.278891 175abb000 Delete type=0 #3
|
||||
2026/08/12-20:33:19.278925 175abb000 Delete type=3 #2
|
||||
2026/08/18-22:37:32.823569 176b97000 Recovering log #3
|
||||
2026/08/18-22:37:32.823738 176b97000 Level-0 table #5: started
|
||||
2026/08/18-22:37:32.824004 176b97000 Level-0 table #5: 16902 bytes OK
|
||||
2026/08/18-22:37:32.824268 176b97000 Delete type=0 #3
|
||||
2026/08/18-22:37:32.824310 176b97000 Delete type=3 #2
|
||||
|
||||
@@ -1 +1 @@
|
||||
2026/08/12-20:33:03.567928 17385b000 Delete type=3 #1
|
||||
2026/08/18-22:37:15.426452 173083000 Delete type=3 #1
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
2026/08/12-20:33:19.268400 175abb000 Recovering log #3
|
||||
2026/08/12-20:33:19.269480 175abb000 Level-0 table #5: started
|
||||
2026/08/12-20:33:19.270081 175abb000 Level-0 table #5: 82299 bytes OK
|
||||
2026/08/12-20:33:19.270432 175abb000 Delete type=0 #3
|
||||
2026/08/12-20:33:19.270471 175abb000 Delete type=3 #2
|
||||
2026/08/18-22:37:32.815371 176b97000 Recovering log #3
|
||||
2026/08/18-22:37:32.816691 176b97000 Level-0 table #5: started
|
||||
2026/08/18-22:37:32.817466 176b97000 Level-0 table #5: 84039 bytes OK
|
||||
2026/08/18-22:37:32.817750 176b97000 Delete type=0 #3
|
||||
2026/08/18-22:37:32.817802 176b97000 Delete type=3 #2
|
||||
|
||||
@@ -1 +1 @@
|
||||
2026/08/12-20:33:03.550640 174067000 Delete type=3 #1
|
||||
2026/08/18-22:37:15.404367 173083000 Delete type=3 #1
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
2026/08/12-20:33:19.280144 176ad3000 Recovering log #3
|
||||
2026/08/12-20:33:19.280500 176ad3000 Level-0 table #5: started
|
||||
2026/08/12-20:33:19.280605 176ad3000 Level-0 table #5: 3739 bytes OK
|
||||
2026/08/12-20:33:19.280818 176ad3000 Delete type=0 #3
|
||||
2026/08/12-20:33:19.280857 176ad3000 Delete type=3 #2
|
||||
2026/08/18-22:37:32.825592 175b7f000 Recovering log #3
|
||||
2026/08/18-22:37:32.825723 175b7f000 Level-0 table #5: started
|
||||
2026/08/18-22:37:32.825837 175b7f000 Level-0 table #5: 3739 bytes OK
|
||||
2026/08/18-22:37:32.826141 175b7f000 Delete type=0 #3
|
||||
2026/08/18-22:37:32.826181 175b7f000 Delete type=3 #2
|
||||
|
||||
@@ -1 +1 @@
|
||||
2026/08/12-20:33:03.544720 17385b000 Delete type=3 #1
|
||||
2026/08/18-22:37:15.398341 17409b000 Delete type=3 #1
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
2026/08/12-20:33:19.253872 1762c7000 Recovering log #3
|
||||
2026/08/12-20:33:19.254006 1762c7000 Level-0 table #5: started
|
||||
2026/08/12-20:33:19.254155 1762c7000 Level-0 table #5: 5770 bytes OK
|
||||
2026/08/12-20:33:19.254377 1762c7000 Delete type=0 #3
|
||||
2026/08/12-20:33:19.254415 1762c7000 Delete type=3 #2
|
||||
2026/08/18-22:37:32.793721 17638b000 Recovering log #3
|
||||
2026/08/18-22:37:32.793870 17638b000 Level-0 table #5: started
|
||||
2026/08/18-22:37:32.794081 17638b000 Level-0 table #5: 5770 bytes OK
|
||||
2026/08/18-22:37:32.794365 17638b000 Delete type=0 #3
|
||||
2026/08/18-22:37:32.794405 17638b000 Delete type=3 #2
|
||||
|
||||
@@ -1 +1 @@
|
||||
2026/08/12-20:33:03.496700 174067000 Delete type=3 #1
|
||||
2026/08/18-22:37:15.353747 17388f000 Delete type=3 #1
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
MANIFEST-000004
|
||||
@@ -0,0 +1,5 @@
|
||||
2026/08/18-22:37:32.810562 176b97000 Recovering log #3
|
||||
2026/08/18-22:37:32.811570 176b97000 Level-0 table #5: started
|
||||
2026/08/18-22:37:32.812165 176b97000 Level-0 table #5: 68636 bytes OK
|
||||
2026/08/18-22:37:32.812502 176b97000 Delete type=0 #3
|
||||
2026/08/18-22:37:32.812546 176b97000 Delete type=3 #2
|
||||
@@ -0,0 +1 @@
|
||||
2026/08/18-22:37:15.412095 17409b000 Delete type=3 #1
|
||||
@@ -1,5 +1,5 @@
|
||||
2026/08/12-20:33:19.262214 175abb000 Recovering log #3
|
||||
2026/08/12-20:33:19.262355 175abb000 Level-0 table #5: started
|
||||
2026/08/12-20:33:19.262536 175abb000 Level-0 table #5: 6939 bytes OK
|
||||
2026/08/12-20:33:19.262774 175abb000 Delete type=0 #3
|
||||
2026/08/12-20:33:19.262806 175abb000 Delete type=3 #2
|
||||
2026/08/18-22:37:32.803216 1773a3000 Recovering log #3
|
||||
2026/08/18-22:37:32.803361 1773a3000 Level-0 table #5: started
|
||||
2026/08/18-22:37:32.803524 1773a3000 Level-0 table #5: 6939 bytes OK
|
||||
2026/08/18-22:37:32.803783 1773a3000 Delete type=0 #3
|
||||
2026/08/18-22:37:32.803833 1773a3000 Delete type=3 #2
|
||||
|
||||
@@ -1 +1 @@
|
||||
2026/08/12-20:33:03.505366 174067000 Delete type=3 #1
|
||||
2026/08/18-22:37:15.360390 17409b000 Delete type=3 #1
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
2026/08/12-20:33:19.257047 1772df000 Recovering log #3
|
||||
2026/08/12-20:33:19.257521 1772df000 Level-0 table #5: started
|
||||
2026/08/12-20:33:19.257788 1772df000 Level-0 table #5: 14767 bytes OK
|
||||
2026/08/12-20:33:19.258070 1772df000 Delete type=0 #3
|
||||
2026/08/12-20:33:19.258099 1772df000 Delete type=3 #2
|
||||
2026/08/18-22:37:32.797239 17638b000 Recovering log #3
|
||||
2026/08/18-22:37:32.797509 17638b000 Level-0 table #5: started
|
||||
2026/08/18-22:37:32.797806 17638b000 Level-0 table #5: 14767 bytes OK
|
||||
2026/08/18-22:37:32.798073 17638b000 Delete type=0 #3
|
||||
2026/08/18-22:37:32.798122 17638b000 Delete type=3 #2
|
||||
|
||||
@@ -1 +1 @@
|
||||
2026/08/12-20:33:03.498976 172843000 Delete type=3 #1
|
||||
2026/08/18-22:37:15.355377 172877000 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,105 +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;
|
||||
}
|
||||
|
||||
/**
|
||||
* The custom Dice So Nice skins are OFF.
|
||||
*
|
||||
* Registering them with "preferred" makes them the default for everybody in the world,
|
||||
* which overrides whatever dice a player had already chosen for themselves — the system
|
||||
* helping itself to a setting that belongs to the user. Turned off at the author's
|
||||
* request; flip this to true to put them back, and everything below still works.
|
||||
*
|
||||
* The textures in dice/ and the generator behind `npm run dice` are left alone. Nothing
|
||||
* reads them while this is false, and deleting work to turn a feature off is a poor
|
||||
* trade.
|
||||
*/
|
||||
const RB_DICE_ENABLED = false;
|
||||
|
||||
Hooks.once("diceSoNiceReady", async (dice3d) => {
|
||||
if (!RB_DICE_ENABLED) return;
|
||||
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;
|
||||
@@ -4854,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");
|
||||
@@ -4947,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();
|
||||
@@ -5890,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")
|
||||
: "";
|
||||
@@ -6186,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,
|
||||
@@ -6204,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,119 @@ 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" },
|
||||
// One point, not two. At two, the end of a hard case reliably broke four agents in
|
||||
// five at the same moment — a whole party freezing or bolting on the closing beat
|
||||
// reads as arithmetic rather than grief. Playing THROUGH TRAIN end to end did it.
|
||||
someoneYouKnew:{ cost: 1, 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);
|
||||
|
||||
@@ -1788,3 +1788,73 @@
|
||||
#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.6",
|
||||
"version": "1.4.2",
|
||||
"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 ?? {})) {
|
||||
@@ -861,6 +864,109 @@ function cullandScene() {
|
||||
};
|
||||
}
|
||||
|
||||
// THROUGH TRAIN — the crossing starter. Unlike the two prologues this one has a real
|
||||
// case file, because it is the first time the players are sent ACROSS and the case
|
||||
// tracks are the point.
|
||||
{
|
||||
const X = await import("./scenario-throughtrain.mjs");
|
||||
|
||||
const castDocs = [];
|
||||
for (const spec of X.TT_CAST) {
|
||||
const { actorDoc, itemDocs } = buildActor(spec, "npc");
|
||||
actorDoc.items = itemDocs;
|
||||
castDocs.push(actorDoc);
|
||||
}
|
||||
|
||||
// The case file, already at its opening state, with Standing Order 9 in the notes.
|
||||
const C = X.TT_CASE;
|
||||
const caseId = idFor(`actor:${C.key}`);
|
||||
const caseDoc = {
|
||||
_id: caseId, name: C.name, type: "expedition", img: ICON.caseFile ?? ICON.actor,
|
||||
system: {
|
||||
integrityMode: "abstract",
|
||||
integrity: { value: C.containment, max: 10 },
|
||||
supply: { value: C.resources, max: 10 },
|
||||
power: { value: C.clearance, max: 10 },
|
||||
assets: [], members: [],
|
||||
journey: {
|
||||
destinationId: "mercy_draw", destinationName: C.destination,
|
||||
destinationKnowledge: "briefed", currentLeg: 0, elapsedDays: 0,
|
||||
siteTableId: "sites", hazardTableId: "hazards", returnClock: 0,
|
||||
legs: C.legs.map((l, i) => ({
|
||||
index: i, terrain: l.label, mode: "foot", kind: l.kindId,
|
||||
difficulty: l.difficulty, modifier: l.mod, days: 0,
|
||||
explored: true, resolved: false, forcedEncounterId: "",
|
||||
costSupply: 1, costPower: 0
|
||||
}))
|
||||
},
|
||||
standings: [],
|
||||
across: false, crossingModifier: 0, salvageEra: "antique",
|
||||
notes: C.notes
|
||||
},
|
||||
items: [], effects: [],
|
||||
prototypeToken: { name: C.name, displayName: 20, actorLink: true, width: 1, height: 1,
|
||||
texture: { src: ICON.actor }, sight: { enabled: false }, disposition: 1,
|
||||
bar1: { attribute: "integrity" }, bar2: { attribute: "supply" }, flags: {} },
|
||||
...BASE()
|
||||
};
|
||||
castDocs.push(caseDoc);
|
||||
|
||||
const gmJournal = journalDoc("THROUGH TRAIN — Run the case", "tt_gm", X.TT_PAGES);
|
||||
const handouts = journalDoc("THROUGH TRAIN — Player handouts", "tt_handouts", X.TT_HANDOUTS);
|
||||
handouts.doc.ownership = { default: 2 };
|
||||
handouts.doc.flags = { ringbrp: { brand: true } };
|
||||
for (const pg of handouts.pages) pg.ownership = { default: 2 };
|
||||
|
||||
const showJournal = imageJournal("THROUGH TRAIN — Show the players", "tt_show", [
|
||||
{ name: "1 · Mercy Draw, now", src: "art/scenes/tt_01_draw.webp" },
|
||||
{ name: "2 · Stores, and the 1881 issue", src: "art/scenes/tt_02_stores.webp" },
|
||||
{ name: "3 · The grade on the south bench", src: "art/scenes/tt_03_bench.webp" },
|
||||
{ name: "4 · Cordera, 1881", src: "art/scenes/tt_04_cordera.webp" },
|
||||
{ name: "5 · The water tower — boarding", src: "art/scenes/tt_05_boarding.webp" },
|
||||
{ name: "6 · Inside car three, between passes", src: "art/scenes/tt_06_car.webp" },
|
||||
{ name: "7 · The four minutes", src: "art/scenes/tt_08_gunfight.webp" },
|
||||
{ name: "8 · The payroll box, car four", src: "art/scenes/tt_09_payroll.webp" },
|
||||
{ name: "9 · The gap between the cars", src: "art/scenes/tt_10_couple.webp" },
|
||||
{ name: "10 · Afterwards, the register", src: "art/scenes/tt_07_after.webp" }
|
||||
]);
|
||||
|
||||
const advId = idFor("adventure:through_train");
|
||||
const adventure = {
|
||||
_id: advId,
|
||||
name: "THROUGH TRAIN — The Mercy Draw Crossing",
|
||||
img: IC("actor_case") ?? ICON.caseFile ?? ICON.actor,
|
||||
caption: "The crossing starter. Three and a half hours, and nothing modern goes through.",
|
||||
description:
|
||||
"<p><em>A train has been running the same four miles of track since the third of "
|
||||
+ "November, 1881. It has never stopped. There are sixty-one people on it and they "
|
||||
+ "are all still alive.</em></p>"
|
||||
+ "<p>A railway company laid track to a filed survey instead of to the ground, then "
|
||||
+ "described the line as being where it was not — ten thousand times, for six "
|
||||
+ "months, at a constant speed. Reality keeps a record of what belongs where, and "
|
||||
+ "that is the most patient argument there is.</p>"
|
||||
+ "<p>The running is what holds the crossing open. The agents have to board a train "
|
||||
+ "that cannot stop, get sixty-one people off it, and decide what to do about the "
|
||||
+ "arithmetic when they find they cannot.</p>"
|
||||
+ "<p>Also the case that teaches Standing Order 9: <strong>nothing modern goes "
|
||||
+ "through</strong>. Seven GM pages, six handouts including the survey that started "
|
||||
+ "it, the case file, four of the people aboard, and seven plates to show.</p>",
|
||||
actors: castDocs,
|
||||
items: [],
|
||||
journal: [
|
||||
{ ...gmJournal.doc, pages: gmJournal.pages },
|
||||
{ ...handouts.doc, pages: handouts.pages },
|
||||
{ ...showJournal.doc, pages: showJournal.pages }
|
||||
],
|
||||
scenes: [], combats: [], tables: [], macros: [], cards: [], playlists: [], folders: [],
|
||||
sort: 0, ...BASE()
|
||||
};
|
||||
|
||||
await writePack("throughtrain", [[`!adventures!${advId}`, adventure]]);
|
||||
console.log(` throughtr THROUGH TRAIN — ${castDocs.length} actors, `
|
||||
+ `${X.TT_PAGES.length} GM pages, ${X.TT_HANDOUTS.length} handouts, `
|
||||
+ `${showJournal.pages.length} plates`);
|
||||
}
|
||||
|
||||
// LAST ADMISSION — the two-hour prologue, and the one to run by default. Same shape as
|
||||
// OPEN DAY's adventure and for the same reason: journals and cast only, because the
|
||||
// player characters are the "(before)" pregens in the Pregens pack.
|
||||
|
||||
@@ -448,6 +448,122 @@ test("no stylesheet rule sets display on a bare #pause", async () => {
|
||||
}
|
||||
});
|
||||
|
||||
/* ---------------------------------------------------------------- *
|
||||
* 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"],
|
||||
|
||||