Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
37b4043746 | ||
|
|
7ec7efd29b | ||
|
|
5aaae186e0 | ||
|
|
20306d83d9 | ||
|
|
cbad87ba98 | ||
|
|
ab619f4b73 | ||
|
|
9b6c61ffcb | ||
|
|
0852427586 |
@@ -28,7 +28,8 @@ git clone https://gitea.timevans.uk/slaguru666/RingBRP.git ringbrp
|
|||||||
```
|
```
|
||||||
|
|
||||||
Requires **Foundry v13+** (verified against v14.364). No modules are required. Dice So
|
Requires **Foundry v13+** (verified against v14.364). No modules are required. Dice So
|
||||||
Nice is supported but optional — the system registers four of its own dice textures.
|
Nice works if you have it, using your own dice — the system ships no dice skins and does
|
||||||
|
not touch anybody's dice settings.
|
||||||
|
|
||||||
### Deploying to a remote Foundry server
|
### Deploying to a remote Foundry server
|
||||||
|
|
||||||
@@ -47,7 +48,7 @@ packs or regenerate art on that machine:
|
|||||||
```bash
|
```bash
|
||||||
npm install # pulls classic-level, used to write the LevelDB packs
|
npm install # pulls classic-level, used to write the LevelDB packs
|
||||||
npm run build # rebuilds packs/ from tools/content.mjs
|
npm run build # rebuilds packs/ from tools/content.mjs
|
||||||
npm run check # the three guards; the build refuses to run if they fail
|
npm run check # the five guards; the build refuses to run if they fail
|
||||||
```
|
```
|
||||||
|
|
||||||
To update a deployed server: `git pull` and restart Foundry. If the pull touches
|
To update a deployed server: `git pull` and restart Foundry. If the pull touches
|
||||||
@@ -64,13 +65,13 @@ changing underneath a running server.
|
|||||||
| **Postings** | 25, each with a key characteristic, trained skills, talents and its own kit |
|
| **Postings** | 25, each with a key characteristic, trained skills, talents and its own kit |
|
||||||
| **Trades** | 10 ordinary jobs the department recruits from, plus induction |
|
| **Trades** | 10 ordinary jobs the department recruits from, plus induction |
|
||||||
| **Catalogue** | 236 items — 123 gear, 62 weapons, 32 armour, 19 vehicles |
|
| **Catalogue** | 236 items — 123 gear, 62 weapons, 32 armour, 19 vehicles |
|
||||||
| **Skills / talents** | 59 skills, 19 talents |
|
| **Skills / talents** | 62 skills, 19 talents |
|
||||||
| **Bestiary** | 21 statblocks across folklore, horror and the far side |
|
| **Bestiary** | 21 statblocks across folklore, horror and the far side |
|
||||||
| **Eras** | modern, antique, future, anomalous — the agency is old and never abolishes anything |
|
| **Eras** | modern, antique, future, anomalous — the agency is old and never abolishes anything |
|
||||||
| **Icons** | 292, all generated from `tools/icons.mjs`; no external art |
|
| **Icons** | 292, all generated from `tools/icons.mjs`; no external art |
|
||||||
| **Starter scenario** | **BLACK PLATFORM**, a six-player action starter, importable as a single Adventure |
|
| **Starter scenario** | **BLACK PLATFORM**, a six-player action starter, importable as a single Adventure |
|
||||||
| **Compendia** | pregens, NPCs, skills, weapons, armour, gear, vehicles, powers, tables, rules, the starter |
|
| **Compendia** | pregens, NPCs, skills, weapons, armour, gear, vehicles, powers, tables, rules, the starter |
|
||||||
| **Rules journal** | 12 pages, generated from `rules.mjs` so the book cannot drift from the code |
|
| **Rules journal** | 13 pages, generated from `rules.mjs` so the book cannot drift from the code |
|
||||||
<!-- box:end -->
|
<!-- box:end -->
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -152,13 +153,12 @@ template.json data model for every Actor and Item type
|
|||||||
templates/ Handlebars sheets
|
templates/ Handlebars sheets
|
||||||
styles/ one stylesheet
|
styles/ one stylesheet
|
||||||
lang/en.json all user-facing text
|
lang/en.json all user-facing text
|
||||||
icons/ dice/ fonts/ generated art
|
icons/ fonts/ art/ generated art
|
||||||
packs/ built LevelDB compendia (committed — see Deploying)
|
packs/ built LevelDB compendia (committed — see Deploying)
|
||||||
tools/
|
tools/
|
||||||
content.mjs the catalogue: skills, weapons, armour, gear, vehicles, NPCs
|
content.mjs the catalogue: skills, weapons, armour, gear, vehicles, NPCs
|
||||||
build-packs.mjs builds packs/ — runs the three guards first and refuses on failure
|
build-packs.mjs builds packs/ — runs the five guards first and refuses on failure
|
||||||
make-icons.mjs draws all 231 icons
|
make-icons.mjs draws all 231 icons
|
||||||
make-dice-textures.mjs
|
|
||||||
rules-text.mjs generates the rules journal FROM rules.mjs
|
rules-text.mjs generates the rules journal FROM rules.mjs
|
||||||
mission.mjs the case generator
|
mission.mjs the case generator
|
||||||
mission-tables.mjs its content tables
|
mission-tables.mjs its content tables
|
||||||
@@ -176,13 +176,13 @@ stops being identity-equal to what it aliases, if a constant is re-declared as a
|
|||||||
literal, or if an exported rule has no spot-check. The packs used to be built under
|
literal, or if an exported rule has no spot-check. The packs used to be built under
|
||||||
one set of numbers and played under another; this makes that impossible to ship.
|
one set of numbers and played under another; this makes that impossible to ship.
|
||||||
|
|
||||||
The build runs all three guards before it writes anything:
|
The build runs all five guards before it writes anything:
|
||||||
|
|
||||||
<!-- guards:start -->
|
<!-- guards:start -->
|
||||||
```
|
```
|
||||||
check-rules: OK — 87 rules, 21 files scanned, 3 aliases + 41 constant sets checked, 275 formulas verified
|
check-rules: OK — 92 rules, 20 files scanned, 3 aliases + 45 constant sets checked, 291 formulas verified
|
||||||
check-kits: OK — 25 roles, 10 trades, 218 catalogue items, every kit key resolves, every posting can use what it carries, every loadout distinct
|
check-kits: OK — 25 roles, 10 trades, 218 catalogue items, every kit key resolves, every posting can use what it carries, every loadout distinct
|
||||||
check-lang: OK — en.json, 945 keys, no leaf/branch collisions
|
check-lang: OK — en.json, 981 keys, no leaf/branch collisions
|
||||||
check-behaviour: OK — 45 behavioural tests
|
check-behaviour: OK — 59 behavioural tests
|
||||||
```
|
```
|
||||||
<!-- guards:end -->
|
<!-- guards:end -->
|
||||||
|
|||||||
|
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
|
```bash
|
||||||
npm install # classic-level, for writing LevelDB
|
npm install # classic-level, for writing LevelDB
|
||||||
npm run check # the three guards
|
npm run check # the five guards
|
||||||
npm run build # rebuilds packs/
|
npm run build # rebuilds packs/
|
||||||
node tools/make-icons.mjs # redraws all icons
|
node tools/make-icons.mjs # redraws all icons
|
||||||
node tools/make-dice-textures.mjs # regenerates the Dice So Nice textures
|
|
||||||
```
|
```
|
||||||
|
|
||||||
`npm run build` runs the guards first and aborts if any fails, so a broken catalogue
|
`npm run build` runs the guards first and aborts if any fails, so a broken catalogue
|
||||||
|
|||||||
@@ -943,5 +943,41 @@
|
|||||||
"RINGBRP.Item.ArtRating": "Rating",
|
"RINGBRP.Item.ArtRating": "Rating",
|
||||||
"RINGBRP.Item.PushCost": "Push cost",
|
"RINGBRP.Item.PushCost": "Push cost",
|
||||||
"RINGBRP.Item.PushRule": "What pushing does",
|
"RINGBRP.Item.PushRule": "What pushing does",
|
||||||
"RINGBRP.Item.Origin": "Where it came from"
|
"RINGBRP.Item.Origin": "Where it came from",
|
||||||
|
"RINGBRP.Panic.Label": "Panic",
|
||||||
|
"RINGBRP.Panic.Steady": "Steady",
|
||||||
|
"RINGBRP.Panic.SteadyNote": "You are all right. You are doing the job.",
|
||||||
|
"RINGBRP.Panic.Rattled": "Rattled",
|
||||||
|
"RINGBRP.Panic.RattledNote": "Your hands are not quite steady. No penalty — the first one is free.",
|
||||||
|
"RINGBRP.Panic.Shaken": "Shaken",
|
||||||
|
"RINGBRP.Panic.ShakenNote": "−5% to everything. You keep checking the way you came in.",
|
||||||
|
"RINGBRP.Panic.Losing": "Losing it",
|
||||||
|
"RINGBRP.Panic.LosingNote": "−10% to everything. You are talking too fast and not hearing answers.",
|
||||||
|
"RINGBRP.Panic.Overwhelmed": "Overwhelmed",
|
||||||
|
"RINGBRP.Panic.OverwhelmedNote": "−20% to everything. One more and you go.",
|
||||||
|
"RINGBRP.Panic.Broken": "Broken",
|
||||||
|
"RINGBRP.Panic.BrokenNote": "You freeze, run, or go at it. Choose one, take it, and come back at 3.",
|
||||||
|
"RINGBRP.Panic.Trig.WrongThing": "Something that should not be there",
|
||||||
|
"RINGBRP.Panic.Trig.Touched": "It touched you",
|
||||||
|
"RINGBRP.Panic.Trig.Body": "A body",
|
||||||
|
"RINGBRP.Panic.Trig.Known": "Somebody you knew",
|
||||||
|
"RINGBRP.Panic.Trig.Dark": "The lights went",
|
||||||
|
"RINGBRP.Panic.Trig.Wounded": "A major wound",
|
||||||
|
"RINGBRP.Panic.Trig.Named": "It said your name",
|
||||||
|
"RINGBRP.Panic.Relief.Steady": "Steady yourself (POW×5)",
|
||||||
|
"RINGBRP.Panic.Relief.Religion": "Religion — something older than the department",
|
||||||
|
"RINGBRP.Panic.Relief.Psychology": "Psychology — talk somebody else down",
|
||||||
|
"RINGBRP.Panic.Relief.Occult": "Occult — name it, out loud, for everyone who can hear",
|
||||||
|
"RINGBRP.Panic.Relief.Anchor": "Your anchor, once per case",
|
||||||
|
"RINGBRP.Panic.Gained": "{name} — panic {from} → {to}",
|
||||||
|
"RINGBRP.Panic.Eased": "{name} — panic {from} → {to}",
|
||||||
|
"RINGBRP.Panic.Breaks": "BREAKS — freeze, flee, or fixate. Choose, take it, come back at 3.",
|
||||||
|
"RINGBRP.Panic.NoChange": "No change.",
|
||||||
|
"RINGBRP.Flavour.Panicking": "panicking ({mod}%)",
|
||||||
|
"RINGBRP.Chat.ToEverything": "to everything",
|
||||||
|
"RINGBRP.Panic.Backfired": "Fumbled —",
|
||||||
|
"RINGBRP.Panic.SceneEnd": "Scene over",
|
||||||
|
"RINGBRP.Panic.SceneEndNote": "Panic clears when the scene does. Coherence does not.",
|
||||||
|
"RINGBRP.Party.Title": "The team, right now",
|
||||||
|
"RINGBRP.Party.Who": "Agent"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,7 +6,6 @@
|
|||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "node tools/build-packs.mjs && node tools/update-readme.mjs",
|
"build": "node tools/build-packs.mjs && node tools/update-readme.mjs",
|
||||||
"icons": "node tools/make-icons.mjs",
|
"icons": "node tools/make-icons.mjs",
|
||||||
"dice": "node tools/make-dice-textures.mjs",
|
|
||||||
"check": "node tools/check-rules.mjs && node tools/check-kits.mjs && node tools/check-lang.mjs && node tools/check-templates.mjs && node tools/check-behaviour.mjs",
|
"check": "node tools/check-rules.mjs && node tools/check-kits.mjs && node tools/check-lang.mjs && node tools/check-templates.mjs && node tools/check-behaviour.mjs",
|
||||||
"test": "npm run check",
|
"test": "npm run check",
|
||||||
"readme": "node tools/update-readme.mjs"
|
"readme": "node tools/update-readme.mjs"
|
||||||
|
|||||||
@@ -1 +1 @@
|
|||||||
2026/08/12-19:24:52.815175 17303f000 Delete type=3 #1
|
2026/08/18-00:10:28.183213 16f9eb000 Delete type=3 #1
|
||||||
|
|||||||
@@ -1 +1 @@
|
|||||||
2026/08/12-19:24:52.889991 17384b000 Delete type=3 #1
|
2026/08/18-00:10:28.247767 16e9d3000 Delete type=3 #1
|
||||||
|
|||||||
@@ -1 +1 @@
|
|||||||
2026/08/12-19:24:52.817229 17303f000 Delete type=3 #1
|
2026/08/18-00:10:28.184766 1701f7000 Delete type=3 #1
|
||||||
|
|||||||
@@ -1 +1 @@
|
|||||||
2026/08/12-19:24:52.871528 174057000 Delete type=3 #1
|
2026/08/18-00:10:28.233431 16f9eb000 Delete type=3 #1
|
||||||
|
|||||||
@@ -1 +1 @@
|
|||||||
2026/08/12-19:24:52.853027 17303f000 Delete type=3 #1
|
2026/08/18-00:10:28.217982 16f1df000 Delete type=3 #1
|
||||||
|
|||||||
@@ -1 +1 @@
|
|||||||
2026/08/12-19:24:52.884011 174057000 Delete type=3 #1
|
2026/08/18-00:10:28.243180 16e9d3000 Delete type=3 #1
|
||||||
|
|||||||
@@ -1 +1 @@
|
|||||||
2026/08/12-19:24:52.837710 17303f000 Delete type=3 #1
|
2026/08/18-00:10:28.204150 16f9eb000 Delete type=3 #1
|
||||||
|
|||||||
@@ -1 +1 @@
|
|||||||
2026/08/12-19:24:52.880282 174057000 Delete type=3 #1
|
2026/08/18-00:10:28.240419 16f1df000 Delete type=3 #1
|
||||||
|
|||||||
@@ -1 +1 @@
|
|||||||
2026/08/12-19:24:52.876362 172833000 Delete type=3 #1
|
2026/08/18-00:10:28.237109 16e9d3000 Delete type=3 #1
|
||||||
|
|||||||
@@ -1 +1 @@
|
|||||||
2026/08/12-19:24:52.888015 174057000 Delete type=3 #1
|
2026/08/18-00:10:28.246420 16f9eb000 Delete type=3 #1
|
||||||
|
|||||||
@@ -1 +1 @@
|
|||||||
2026/08/12-19:24:52.808706 17303f000 Delete type=3 #1
|
2026/08/18-00:10:28.177717 16f1df000 Delete type=3 #1
|
||||||
|
|||||||
@@ -1 +1 @@
|
|||||||
2026/08/12-19:24:52.882429 174057000 Delete type=3 #1
|
2026/08/18-00:10:28.241902 16f9eb000 Delete type=3 #1
|
||||||
|
|||||||
@@ -1 +1 @@
|
|||||||
2026/08/12-19:24:52.863812 17384b000 Delete type=3 #1
|
2026/08/18-00:10:28.227548 16e9d3000 Delete type=3 #1
|
||||||
|
|||||||
@@ -1 +1 @@
|
|||||||
2026/08/12-19:24:52.857469 17384b000 Delete type=3 #1
|
2026/08/18-00:10:28.221971 1701f7000 Delete type=3 #1
|
||||||
|
|||||||
@@ -1 +1 @@
|
|||||||
2026/08/12-19:24:52.811553 17303f000 Delete type=3 #1
|
2026/08/18-00:10:28.180038 1701f7000 Delete type=3 #1
|
||||||
|
|||||||
@@ -1 +1 @@
|
|||||||
2026/08/12-19:24:52.819322 17303f000 Delete type=3 #1
|
2026/08/18-00:10:28.186339 1701f7000 Delete type=3 #1
|
||||||
|
|||||||
@@ -1 +1 @@
|
|||||||
2026/08/12-19:24:52.813317 17384b000 Delete type=3 #1
|
2026/08/18-00:10:28.181674 1701f7000 Delete type=3 #1
|
||||||
|
|||||||
@@ -60,7 +60,7 @@ export const ROLES = {
|
|||||||
|
|
||||||
registry: { label: "Registry Officer",
|
registry: { label: "Registry Officer",
|
||||||
blurb: "Keeps the record that a crossing is made against. Knows what the file says and, more usefully, what it used to say.", stat: "edu",
|
blurb: "Keeps the record that a crossing is made against. Knows what the file says and, more usefully, what it used to say.", stat: "edu",
|
||||||
core: ["research","knowledge","anomaly_lore","language:own","computer_use","insight"],
|
core: ["occult","knowledge","anomaly_lore","language:own","computer_use","insight"],
|
||||||
support: ["spot","art","first_aid","tradecraft","firearm:pistol"],
|
support: ["spot","art","first_aid","tradecraft","firearm:pistol"],
|
||||||
talents: ["chain_custody","wrong_memory","debriefer","quorum"],
|
talents: ["chain_custody","wrong_memory","debriefer","quorum"],
|
||||||
kit: ["spectacles","notebook","handset","jacket","pistol_light", "carbon_book", "date_stamp", "seals"] },
|
kit: ["spectacles","notebook","handset","jacket","pistol_light", "carbon_book", "date_stamp", "seals"] },
|
||||||
@@ -82,7 +82,7 @@ export const ROLES = {
|
|||||||
interview: { label: "Interview Officer",
|
interview: { label: "Interview Officer",
|
||||||
blurb: "Sits with the witness afterwards. Patient, unhurried, and very hard to lie to twice.", stat: "cha",
|
blurb: "Sits with the witness afterwards. Patient, unhurried, and very hard to lie to twice.", stat: "cha",
|
||||||
core: ["insight","persuade","listen","language:own","anthropology"],
|
core: ["insight","persuade","listen","language:own","anthropology"],
|
||||||
support: ["fast_talk","spot","tradecraft","first_aid","firearm:pistol"],
|
support: ["psychology","spot","tradecraft","first_aid","firearm:pistol"],
|
||||||
talents: ["debriefer","read_room","wrong_memory","unblinking"],
|
talents: ["debriefer","read_room","wrong_memory","unblinking"],
|
||||||
kit: ["dictaphone","cover_id","notebook","jacket","pistol_light", "carbon_book", "vacuum_flask", "clipboard"] },
|
kit: ["dictaphone","cover_id","notebook","jacket","pistol_light", "carbon_book", "vacuum_flask", "clipboard"] },
|
||||||
|
|
||||||
@@ -110,7 +110,7 @@ export const ROLES = {
|
|||||||
pathology: { label: "Pathology Officer",
|
pathology: { label: "Pathology Officer",
|
||||||
blurb: "Examines what is left. Establishes what a thing was, which is usually the question the case turns on.", stat: "int",
|
blurb: "Examines what is left. Establishes what a thing was, which is usually the question the case turns on.", stat: "int",
|
||||||
core: ["medicine","science:biology","xenology:baseline","insight","research"],
|
core: ["medicine","science:biology","xenology:baseline","insight","research"],
|
||||||
support: ["first_aid","spot","anthropology","dodge","melee_weapon:knife"],
|
support: ["first_aid","spot","religion","dodge","melee_weapon:knife"],
|
||||||
talents: ["triage","chain_custody","unblinking","clean_scene"],
|
talents: ["triage","chain_custody","unblinking","clean_scene"],
|
||||||
kit: ["chirurgeon_kit","gas_mask","envsuit","sample_case","dagger", "specimen_jars", "swab_kit", "barrier_gloves"] },
|
kit: ["chirurgeon_kit","gas_mask","envsuit","sample_case","dagger", "specimen_jars", "swab_kit", "barrier_gloves"] },
|
||||||
|
|
||||||
@@ -172,7 +172,7 @@ export const ROLES = {
|
|||||||
|
|
||||||
antiquities: { label: "Antiquities Officer",
|
antiquities: { label: "Antiquities Officer",
|
||||||
blurb: "An archaic posting the department never abolished. Old objects, older records, and the manners to ask about both.", stat: "edu",
|
blurb: "An archaic posting the department never abolished. Old objects, older records, and the manners to ask about both.", stat: "edu",
|
||||||
core: ["knowledge","art","anomaly_lore","research","status"],
|
core: ["religion","art","anomaly_lore","research","status"],
|
||||||
support: ["insight","spot","first_aid","melee_weapon:sword","firearm:pistol"],
|
support: ["insight","spot","first_aid","melee_weapon:sword","firearm:pistol"],
|
||||||
talents: ["chain_custody","wrong_memory","borrowed_hour","quorum"],
|
talents: ["chain_custody","wrong_memory","borrowed_hour","quorum"],
|
||||||
kit: ["pocket_watch","tallow_candle","duelling_sword","brigandine","flintlock", "quill_ink", "sealing_wax", "sand_glass"] },
|
kit: ["pocket_watch","tallow_candle","duelling_sword","brigandine","flintlock", "quill_ink", "sealing_wax", "sand_glass"] },
|
||||||
@@ -231,13 +231,13 @@ export const TRADES = {
|
|||||||
doctor: { label: "Doctor",
|
doctor: { label: "Doctor",
|
||||||
blurb: "A&E, mostly. Used to people at their worst and to being the calmest thing in the room.", stat: "edu",
|
blurb: "A&E, mostly. Used to people at their worst and to being the calmest thing in the room.", stat: "edu",
|
||||||
core: ["medicine","first_aid","science:biology","insight","knowledge"],
|
core: ["medicine","first_aid","science:biology","insight","knowledge"],
|
||||||
support: ["research","status","persuade","listen","spot"],
|
support: ["research","psychology","persuade","listen","spot"],
|
||||||
talents: ["triage","debriefer","unblinking","clean_scene"],
|
talents: ["triage","debriefer","unblinking","clean_scene"],
|
||||||
kit: ["medkit","trauma_unit","analgesics","specimen_jars","swab_kit"] },
|
kit: ["medkit","trauma_unit","analgesics","specimen_jars","swab_kit"] },
|
||||||
|
|
||||||
professor: { label: "Professor",
|
professor: { label: "Professor",
|
||||||
blurb: "A chair in something narrow, and a reading list nobody else has finished. Argues for a living.", stat: "edu",
|
blurb: "A chair in something narrow, and a reading list nobody else has finished. Argues for a living.", stat: "edu",
|
||||||
core: ["knowledge","research","anthropology","language:own","status"],
|
core: ["knowledge","research","occult","language:own","status"],
|
||||||
support: ["insight","persuade","science:botany","listen","art"],
|
support: ["insight","persuade","science:botany","listen","art"],
|
||||||
talents: ["debriefer","read_room","requisition","wrong_memory"],
|
talents: ["debriefer","read_room","requisition","wrong_memory"],
|
||||||
kit: ["carbon_book","notebook","spectacles","clipboard","quill_ink"] },
|
kit: ["carbon_book","notebook","spectacles","clipboard","quill_ink"] },
|
||||||
|
|||||||
@@ -50,7 +50,9 @@ import {
|
|||||||
ARTS, powerCostFor, powerStartingRating,
|
ARTS, powerCostFor, powerStartingRating,
|
||||||
CROSSING, crossingArtPoolFor, crossingGrantsArt, crossingCostFor, REACTION,
|
CROSSING, crossingArtPoolFor, crossingGrantsArt, crossingCostFor, REACTION,
|
||||||
CIRCUMSTANCE, RANGE_LADDER, rangeBandFrom, MAJOR_WOUND_TABLE,
|
CIRCUMSTANCE, RANGE_LADDER, rangeBandFrom, MAJOR_WOUND_TABLE,
|
||||||
COHERENCE_BANDS, COHERENCE_COSTS, COHERENCE_RECOVERY, coherenceBandFrom
|
COHERENCE_BANDS, COHERENCE_COSTS, COHERENCE_RECOVERY, coherenceBandFrom,
|
||||||
|
PANIC_MAX, PANIC_BANDS, PANIC_TRIGGERS, PANIC_RELIEF, PANIC_BREAK, PANIC_AFTER_BREAK,
|
||||||
|
panicBandFrom, panicPenaltyFor, panicBreaksAt, panicAfter, panicReliefFor
|
||||||
} from "./rules.mjs";
|
} from "./rules.mjs";
|
||||||
export { hitPointsFor, majorWoundFor, damageModifierFor, locationMaxHp, reflexRating, awarenessRating };
|
export { hitPointsFor, majorWoundFor, damageModifierFor, locationMaxHp, reflexRating, awarenessRating };
|
||||||
|
|
||||||
@@ -1030,6 +1032,17 @@ export async function skillRoll({
|
|||||||
flavourLines = [...flavourLines, game.i18n.format("RINGBRP.Flavour.Encumbered", { mod: enc })];
|
flavourLines = [...flavourLines, game.i18n.format("RINGBRP.Flavour.Encumbered", { mod: enc })];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// Panic is the short track and it bites on everything, so it sits in the same stack
|
||||||
|
// the wound and encumbrance penalties already use — anything that computes a modifier
|
||||||
|
// and is not added here is a modifier nothing reads, which is this project's oldest
|
||||||
|
// way of shipping a rule that does not exist.
|
||||||
|
if (actor) {
|
||||||
|
const pan = panicPenaltyFor(actor.system?.panic?.value);
|
||||||
|
if (pan) {
|
||||||
|
situational = (Number(situational) || 0) + pan;
|
||||||
|
flavourLines = [...flavourLines, game.i18n.format("RINGBRP.Flavour.Panicking", { mod: pan })];
|
||||||
|
}
|
||||||
|
}
|
||||||
if (actor && !skipCoherence) {
|
if (actor && !skipCoherence) {
|
||||||
const coh = coherencePenaltyFor(actor.system?.coherence?.value, categoryId);
|
const coh = coherencePenaltyFor(actor.system?.coherence?.value, categoryId);
|
||||||
if (coh) {
|
if (coh) {
|
||||||
@@ -1075,7 +1088,6 @@ export async function skillRoll({
|
|||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
rbDressDice(roll, actor);
|
|
||||||
await roll.toMessage({
|
await roll.toMessage({
|
||||||
speaker: speaker ?? ChatMessage.getSpeaker({ actor }),
|
speaker: speaker ?? ChatMessage.getSpeaker({ actor }),
|
||||||
content,
|
content,
|
||||||
@@ -1108,7 +1120,7 @@ export async function majorWoundRoll({ actor, woundType = "blunt", monofilament
|
|||||||
console.error("Custodians | majorWoundRoll called without an actor");
|
console.error("Custodians | majorWoundRoll called without an actor");
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
const r = rbDressDice(await new Roll("1d10").evaluate(), actor);
|
const r = await new Roll("1d10").evaluate();
|
||||||
let result = r.total;
|
let result = r.total;
|
||||||
if (monofilament && result >= 4) result = 10; // spec §3.3 shift
|
if (monofilament && result >= 4) result = 10; // spec §3.3 shift
|
||||||
else if (blunt && result === 10) result = 9;
|
else if (blunt && result === 10) result = 9;
|
||||||
@@ -1171,7 +1183,7 @@ export async function hitLocationRoll({ target, damage, mode = "ranged",
|
|||||||
}
|
}
|
||||||
const sys = target.system;
|
const sys = target.system;
|
||||||
const speciesId = sys.speciesProfile || "baseline";
|
const speciesId = sys.speciesProfile || "baseline";
|
||||||
const r = rbDressDice(await new Roll("1d20").evaluate(), target);
|
const r = await new Roll("1d20").evaluate();
|
||||||
const loc = locationFor(r.total, speciesId, mode);
|
const loc = locationFor(r.total, speciesId, mode);
|
||||||
const live = (sys.locations ?? []).find(l => l.id === loc.id);
|
const live = (sys.locations ?? []).find(l => l.id === loc.id);
|
||||||
const max = live?.max ?? locationMaxHp(sys.hp?.max ?? 1, loc.frac);
|
const max = live?.max ?? locationMaxHp(sys.hp?.max ?? 1, loc.frac);
|
||||||
@@ -1280,7 +1292,7 @@ export async function rollReaction(combatant) {
|
|||||||
: reactionPrintedFrom(rb.base, f.loadOverride);
|
: reactionPrintedFrom(rb.base, f.loadOverride);
|
||||||
const mod = reactionModifier({ aim: f.aim ?? 0, edge: !!f.edge, drag: !!f.drag });
|
const mod = reactionModifier({ aim: f.aim ?? 0, edge: !!f.edge, drag: !!f.drag });
|
||||||
|
|
||||||
const roll = rbDressDice(await new Roll(`1d${RINGBRP.reaction.die}`).evaluate(), actor);
|
const roll = await new Roll(`1d${RINGBRP.reaction.die}`).evaluate();
|
||||||
const raw = roll.total;
|
const raw = roll.total;
|
||||||
const coherence = actor.system?.coherence?.value ?? 10;
|
const coherence = actor.system?.coherence?.value ?? 10;
|
||||||
const shown = fluxDie(raw, coherence);
|
const shown = fluxDie(raw, coherence);
|
||||||
@@ -1535,6 +1547,122 @@ export async function loseCoherence(actor, reasonKey, { amount = null, note = ""
|
|||||||
return bAfter;
|
return bAfter;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
RINGBRP.panicBands = PANIC_BANDS;
|
||||||
|
RINGBRP.panicTriggers = PANIC_TRIGGERS;
|
||||||
|
RINGBRP.panicRelief = PANIC_RELIEF;
|
||||||
|
|
||||||
|
export const panicBand = panicBandFrom;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Take a point of panic. No roll: a die to find out whether you are frightened slows
|
||||||
|
* the scene and tells the player how to feel. The die is for getting rid of it.
|
||||||
|
*/
|
||||||
|
export async function gainPanic(actor, triggerKey, { amount = null, note = "" } = {}) {
|
||||||
|
if (!actor) return null;
|
||||||
|
const spec = PANIC_TRIGGERS[triggerKey];
|
||||||
|
const cost = amount ?? spec?.cost ?? 1;
|
||||||
|
const before = Number(actor.system?.panic?.value ?? 0);
|
||||||
|
const after = panicAfter(before, cost);
|
||||||
|
const log = [...(actor.system?.panic?.log ?? []), { reason: triggerKey, cost, note }];
|
||||||
|
await actor.update({ "system.panic.value": after, "system.panic.log": log });
|
||||||
|
await panicCard(actor, before, after, game.i18n.localize(String(spec?.label ?? triggerKey ?? "")), note);
|
||||||
|
return panicBandFrom(after);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Put a point back. `level` is the outcome of whatever was rolled — a special takes two
|
||||||
|
* — so every relief in PANIC_RELIEF runs through one function and cannot drift.
|
||||||
|
*/
|
||||||
|
export async function easePanic(actor, reliefKey, level = "success", { note = "" } = {}) {
|
||||||
|
if (!actor) return null;
|
||||||
|
const spec = PANIC_RELIEF[reliefKey];
|
||||||
|
const back = panicReliefFor(level);
|
||||||
|
const before = Number(actor.system?.panic?.value ?? 0);
|
||||||
|
const after = panicAfter(before, -back);
|
||||||
|
await actor.update({ "system.panic.value": after });
|
||||||
|
// A fumble comes back as negative relief, so the card has to say so — otherwise it
|
||||||
|
// reads "Psychology" over a number that just went up, and the table reads it as a bug.
|
||||||
|
await panicCard(actor, before, after, game.i18n.localize(String(spec?.label ?? reliefKey ?? "")),
|
||||||
|
note, { backfired: back < 0 });
|
||||||
|
return panicBandFrom(after);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Breaking spends the panic: you come back at 3, because it is not a rest. */
|
||||||
|
export async function breakPanic(actor, choice = "freeze") {
|
||||||
|
if (!actor) return null;
|
||||||
|
const before = Number(actor.system?.panic?.value ?? 0);
|
||||||
|
const after = PANIC_AFTER_BREAK;
|
||||||
|
await actor.update({ "system.panic.value": after });
|
||||||
|
await panicCard(actor, before, after,
|
||||||
|
game.i18n.localize("RINGBRP.Panic.Breaks"),
|
||||||
|
PANIC_BREAK.includes(choice) ? choice : "freeze");
|
||||||
|
return panicBandFrom(after);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function panicCard(actor, before, after, reason, note, { backfired = false } = {}) {
|
||||||
|
const band = panicBandFrom(after);
|
||||||
|
await ChatMessage.create({
|
||||||
|
speaker: ChatMessage.getSpeaker({ actor }),
|
||||||
|
content: await rbRenderTemplate("systems/ringbrp/templates/chat-panic.hbs", {
|
||||||
|
actorName: actor.name, before, after, reason, note,
|
||||||
|
band, breaks: panicBreaksAt(after), penalty: panicPenaltyFor(after), backfired,
|
||||||
|
choices: PANIC_BREAK,
|
||||||
|
pips: Array.from({ length: PANIC_MAX }, (_, i) => i < after)
|
||||||
|
})
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Panic clears when the scene does — and until now there was no way to do that except
|
||||||
|
* editing five sheets by hand while the table waited. Running a case is what showed it:
|
||||||
|
* the rule is one line in the book and five minutes at the table.
|
||||||
|
*/
|
||||||
|
export async function clearPanic(actors, { reason = "" } = {}) {
|
||||||
|
const list = (Array.isArray(actors) ? actors : [actors]).filter(Boolean);
|
||||||
|
if (!list.length) return [];
|
||||||
|
const rows = [];
|
||||||
|
for (const a of list) {
|
||||||
|
const before = Number(a.system?.panic?.value ?? 0);
|
||||||
|
if (before) await a.update({ "system.panic.value": 0, "system.panic.log": [] });
|
||||||
|
rows.push({ name: a.name, before });
|
||||||
|
}
|
||||||
|
await ChatMessage.create({
|
||||||
|
speaker: { alias: game.i18n.localize("RINGBRP.Panic.Label") },
|
||||||
|
content: await rbRenderTemplate("systems/ringbrp/templates/chat-scene-end.hbs",
|
||||||
|
{ rows, reason, anyone: rows.some(r => r.before > 0) })
|
||||||
|
});
|
||||||
|
return rows;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What the GM actually needs mid-scene, which is not on any one sheet: everybody's hit
|
||||||
|
* points, panic and Coherence in one place. Running a case with five agents meant asking
|
||||||
|
* "what are you on?" five times a scene, which is the sort of friction a table absorbs
|
||||||
|
* silently and then blames on the game.
|
||||||
|
*/
|
||||||
|
export async function partyState(actors = null) {
|
||||||
|
const list = actors ?? (activeCaseFile() ? caseMembers(activeCaseFile())
|
||||||
|
: game.actors.filter(a => a.type === "character" && a.hasPlayerOwner));
|
||||||
|
const rows = (Array.isArray(list) ? list : [list]).filter(Boolean).map(a => {
|
||||||
|
const pv = Number(a.system?.panic?.value ?? 0);
|
||||||
|
const cv = Number(a.system?.coherence?.value ?? 10);
|
||||||
|
return {
|
||||||
|
name: a.name,
|
||||||
|
hp: `${a.system?.hp?.value ?? "?"}/${a.system?.hp?.max ?? "?"}`,
|
||||||
|
hurt: (a.system?.hp?.value ?? 0) < (a.system?.hp?.max ?? 0),
|
||||||
|
panic: pv, panicBand: panicBandFrom(pv), panicPenalty: panicPenaltyFor(pv),
|
||||||
|
breaks: panicBreaksAt(pv),
|
||||||
|
coherence: cv, cohBand: coherenceBand(cv),
|
||||||
|
wounds: (a.system?.wounds ?? []).length
|
||||||
|
};
|
||||||
|
});
|
||||||
|
await ChatMessage.create({
|
||||||
|
speaker: { alias: game.i18n.localize("RINGBRP.Party.Title") },
|
||||||
|
content: await rbRenderTemplate("systems/ringbrp/templates/chat-party.hbs", { rows })
|
||||||
|
});
|
||||||
|
return rows;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Displacement. Reaching zero is not a slow slide to be nursed back; it is an
|
* Displacement. Reaching zero is not a slow slide to be nursed back; it is an
|
||||||
* event. The character does not die — they go home to an Earth that is almost
|
* event. The character does not die — they go home to an Earth that is almost
|
||||||
@@ -2384,7 +2512,7 @@ export async function fall(actor, metres, { soft = false, controlled = false } =
|
|||||||
content: `<div class="ringbrp-chat">${game.i18n.format("RINGBRP.Fall.Short", { m: metres })}</div>` });
|
content: `<div class="ringbrp-chat">${game.i18n.format("RINGBRP.Fall.Short", { m: metres })}</div>` });
|
||||||
return { damage: 0 };
|
return { damage: 0 };
|
||||||
}
|
}
|
||||||
const roll = rbDressDice(await new Roll(plan.formula).evaluate(), actor);
|
const roll = await new Roll(plan.formula).evaluate();
|
||||||
await roll.toMessage({ speaker: ChatMessage.getSpeaker({ actor }),
|
await roll.toMessage({ speaker: ChatMessage.getSpeaker({ actor }),
|
||||||
flavor: game.i18n.format("RINGBRP.Fall.Flavor", { m: metres, dice: plan.formula }) });
|
flavor: game.i18n.format("RINGBRP.Fall.Flavor", { m: metres, dice: plan.formula }) });
|
||||||
// armourApplied true: the fall ignores armour, and this is how we say so.
|
// armourApplied true: the fall ignores armour, and this is how we say so.
|
||||||
@@ -2417,7 +2545,7 @@ export async function asphyxiate(actor, { held = 0, prepared = true } = {}) {
|
|||||||
export async function burn(actor, bandId = "clothes", { round = 1 } = {}) {
|
export async function burn(actor, bandId = "clothes", { round = 1 } = {}) {
|
||||||
if (!actor) return null;
|
if (!actor) return null;
|
||||||
const band = fireBandFor(bandId);
|
const band = fireBandFor(bandId);
|
||||||
const roll = rbDressDice(await new Roll(band.damage).evaluate(), actor);
|
const roll = await new Roll(band.damage).evaluate();
|
||||||
// Armour on the round you catch, and never after. A sealed suit is the exception
|
// Armour on the round you catch, and never after. A sealed suit is the exception
|
||||||
// and the GM should say so by running it as a `spark` that never spreads.
|
// and the GM should say so by running it as a `spark` that never spreads.
|
||||||
const armour = (band.armour && round === 1) ? (Number(actor.system.armourPoints) || 0) : 0;
|
const armour = (band.armour && round === 1) ? (Number(actor.system.armourPoints) || 0) : 0;
|
||||||
@@ -2436,7 +2564,7 @@ export async function burn(actor, bandId = "clothes", { round = 1 } = {}) {
|
|||||||
* armour is only ever half of one, at contact.
|
* armour is only ever half of one, at contact.
|
||||||
*/
|
*/
|
||||||
export async function detonate({ formula = "4d6", radius = 6, targets = [] } = {}) {
|
export async function detonate({ formula = "4d6", radius = 6, targets = [] } = {}) {
|
||||||
const roll = rbDressDice(await new Roll(formula).evaluate(), null);
|
const roll = await new Roll(formula).evaluate();
|
||||||
await roll.toMessage({ flavor: game.i18n.format("RINGBRP.Blast.Flavor",
|
await roll.toMessage({ flavor: game.i18n.format("RINGBRP.Blast.Flavor",
|
||||||
{ formula, r: radius }) });
|
{ formula, r: radius }) });
|
||||||
const out = [];
|
const out = [];
|
||||||
@@ -3850,10 +3978,16 @@ export async function generateCharacter({
|
|||||||
const trainedKeys = new Set([...R.core, ...R.support]);
|
const trainedKeys = new Set([...R.core, ...R.support]);
|
||||||
for (const k of R.core) add(k, coreBand);
|
for (const k of R.core) add(k, coreBand);
|
||||||
for (const k of R.support) add(k, supportBand);
|
for (const k of R.support) add(k, supportBand);
|
||||||
// The agency half. Added AFTER the trade, and skipped where the trade already
|
// The agency half. Added AFTER the posting or trade and skipped where that already
|
||||||
// covers it — a former police officer does not un-learn their sidearm to be taught
|
// covers it — a former police officer does not un-learn their sidearm to be taught it
|
||||||
// it again at a worse number.
|
// again at a worse number, and a Registry officer does not lose their Anomaly Lore.
|
||||||
if (isTrade) {
|
//
|
||||||
|
// It used to be gated to trades on the reasoning that a career posting IS the agency
|
||||||
|
// training. Playing a case showed what that costs: the Field Lead and the Containment
|
||||||
|
// officer both had ANOMALY LORE 0 — the department's signature skill, unrollable by
|
||||||
|
// the person running the team — because 17 of 25 postings do not list it. Everybody
|
||||||
|
// the department employs went through the same six weeks. It is a floor now.
|
||||||
|
{
|
||||||
for (const k of INDUCTION.skills) {
|
for (const k of INDUCTION.skills) {
|
||||||
const doc = byKey.get(k); if (!doc) continue;
|
const doc = byKey.get(k); if (!doc) continue;
|
||||||
if (items.some(i => i.name === doc.name)) continue;
|
if (items.some(i => i.name === doc.name)) continue;
|
||||||
@@ -4292,7 +4426,7 @@ export async function rollCharacteristicsDialog({ actorName = "" } = {}) {
|
|||||||
const rollOne = async k => {
|
const rollOne = async k => {
|
||||||
const cell = el.querySelector(`tr[data-ch="${k}"] .rb-roll-value`);
|
const cell = el.querySelector(`tr[data-ch="${k}"] .rb-roll-value`);
|
||||||
if (!cell || cell.dataset.value) return; // rolled is rolled
|
if (!cell || cell.dataset.value) return; // rolled is rolled
|
||||||
const roll = rbDressDice(await new Roll(CHARACTERISTIC_DICE[k]).evaluate());
|
const roll = await new Roll(CHARACTERISTIC_DICE[k]).evaluate();
|
||||||
await roll.toMessage({
|
await roll.toMessage({
|
||||||
flavor: game.i18n.format("RINGBRP.Gen.RolledChar",
|
flavor: game.i18n.format("RINGBRP.Gen.RolledChar",
|
||||||
{ ch: game.i18n.localize(`RINGBRP.Char.${k}`) })
|
{ ch: game.i18n.localize(`RINGBRP.Char.${k}`) })
|
||||||
@@ -4538,90 +4672,6 @@ Hooks.on("preCreateItem", (item, data) => {
|
|||||||
* The colourset is chosen per roll from the roller's Coherence, so the dice
|
* The colourset is chosen per roll from the roller's Coherence, so the dice
|
||||||
* themselves report the one number the players are trying not to look at.
|
* themselves report the one number the players are trying not to look at.
|
||||||
* ============================================================ */
|
* ============================================================ */
|
||||||
const RB_DICE = {
|
|
||||||
textures: {
|
|
||||||
"custodians-registry": { name: "Registry", file: "custodians-registry.png" },
|
|
||||||
"custodians-drift": { name: "Drift", file: "custodians-drift.png" },
|
|
||||||
"custodians-displaced": { name: "Displaced", file: "custodians-displaced.png" },
|
|
||||||
"custodians-agency": { name: "Agency", file: "custodians-agency.png" }
|
|
||||||
},
|
|
||||||
// one colourset per Coherence band, plus the plain agency set
|
|
||||||
sets: [
|
|
||||||
{ name: "custodians-anchored", description: "Custodians — Anchored",
|
|
||||||
foreground: "#f2ea72", background: "#18268d", outline: "#0c125b",
|
|
||||||
edge: "#6674d8", texture: "custodians-registry", material: "metal" },
|
|
||||||
{ name: "custodians-loose", description: "Custodians — Loose",
|
|
||||||
foreground: "#f2ea72", background: "#16215f", outline: "#0c125b",
|
|
||||||
edge: "#6674d8", texture: "custodians-registry", material: "plastic" },
|
|
||||||
{ name: "custodians-adrift", description: "Custodians — Adrift",
|
|
||||||
foreground: "#ecefee", background: "#141e78", outline: "#0c125b",
|
|
||||||
edge: "#7a6cc8", texture: "custodians-drift", material: "plastic" },
|
|
||||||
{ name: "custodians-unmoored", description: "Custodians — Unmoored",
|
|
||||||
foreground: "#ff7a5c", background: "#101a6e", outline: "#08103f",
|
|
||||||
edge: "#ff7a5c", texture: "custodians-drift", material: "glass" },
|
|
||||||
{ name: "custodians-displaced", description: "Custodians — Displaced",
|
|
||||||
foreground: "#c9b6ff", background: "#0c0e3c", outline: "#000000",
|
|
||||||
edge: "#8a6cc0", texture: "custodians-displaced", material: "glass" },
|
|
||||||
{ name: "custodians-agency", description: "Custodians — Agency issue",
|
|
||||||
foreground: "#f2ea72", background: "#16215f", outline: "#0c125b",
|
|
||||||
edge: "#a8b2e3", texture: "custodians-agency", material: "metal" }
|
|
||||||
]
|
|
||||||
};
|
|
||||||
|
|
||||||
/** Which set an agent rolls, from the band they are in. */
|
|
||||||
export function diceSetFor(actor) {
|
|
||||||
const band = coherenceBand(actor?.system?.coherence?.value ?? 10);
|
|
||||||
return `custodians-${band?.id ?? "anchored"}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Tag a Roll so Dice So Nice draws it in the roller's colours. */
|
|
||||||
function rbDressDice(roll, actor) {
|
|
||||||
if (!roll?.dice?.length) return roll;
|
|
||||||
const colorset = diceSetFor(actor);
|
|
||||||
for (const d of roll.dice) {
|
|
||||||
d.options.colorset = colorset;
|
|
||||||
d.options.appearance = { ...(d.options.appearance ?? {}), colorset };
|
|
||||||
}
|
|
||||||
return roll;
|
|
||||||
}
|
|
||||||
|
|
||||||
Hooks.once("diceSoNiceReady", async (dice3d) => {
|
|
||||||
try {
|
|
||||||
dice3d.addSystem({ id: "custodians", name: "The Custodians" }, "preferred");
|
|
||||||
|
|
||||||
// addTexture returns a Promise and the colourset that references a texture must
|
|
||||||
// not be registered before it resolves, or the die renders untextured.
|
|
||||||
await Promise.all(Object.entries(RB_DICE.textures).map(([id, t]) =>
|
|
||||||
dice3d.addTexture(id, {
|
|
||||||
name: t.name, composite: "source-over",
|
|
||||||
source: `systems/ringbrp/dice/${t.file}`
|
|
||||||
})));
|
|
||||||
|
|
||||||
for (const set of RB_DICE.sets) {
|
|
||||||
dice3d.addColorset({
|
|
||||||
...set, category: "The Custodians",
|
|
||||||
font: "Michroma", visibility: "visible"
|
|
||||||
}, set.name === "custodians-anchored" ? "default" : "showcase");
|
|
||||||
}
|
|
||||||
|
|
||||||
// The percentile pair is the game's core roll, so it is the one worth dressing.
|
|
||||||
dice3d.addDicePreset({
|
|
||||||
type: "d100", system: "custodians",
|
|
||||||
labels: ["00", "10", "20", "30", "40", "50", "60", "70", "80", "90"]
|
|
||||||
});
|
|
||||||
dice3d.addDicePreset({ type: "d10", system: "custodians",
|
|
||||||
labels: ["0", "1", "2", "3", "4", "5", "6", "7", "8", "9"] });
|
|
||||||
for (const t of ["d4", "d6", "d8", "d12", "d20"]) {
|
|
||||||
const n = Number(t.slice(1));
|
|
||||||
dice3d.addDicePreset({ type: t, system: "custodians",
|
|
||||||
labels: Array.from({ length: n }, (_, i) => String(i + 1)) });
|
|
||||||
}
|
|
||||||
console.log("Custodians | dice registered with Dice So Nice");
|
|
||||||
} catch (err) {
|
|
||||||
console.warn("Custodians | could not register dice", err);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
Hooks.on("renderItemDirectory", (app, html) => {
|
Hooks.on("renderItemDirectory", (app, html) => {
|
||||||
const root = html instanceof HTMLElement ? html : html?.[0];
|
const root = html instanceof HTMLElement ? html : html?.[0];
|
||||||
if (!root || !game.user.isGM || root.querySelector(".rb-genbar")) return;
|
if (!root || !game.user.isGM || root.querySelector(".rb-genbar")) return;
|
||||||
@@ -4839,6 +4889,9 @@ class RingBRPCharacterSheet extends ActorSheet {
|
|||||||
ctx.reaction = this._reactionRail();
|
ctx.reaction = this._reactionRail();
|
||||||
ctx.readiness = this._readiness ?? { rating: 0 };
|
ctx.readiness = this._readiness ?? { rating: 0 };
|
||||||
ctx.coherence = { band: coherenceBand(sys.coherence?.value) };
|
ctx.coherence = { band: coherenceBand(sys.coherence?.value) };
|
||||||
|
ctx.panic = { band: panicBandFrom(sys.panic?.value),
|
||||||
|
penalty: panicPenaltyFor(sys.panic?.value),
|
||||||
|
pips: Array.from({ length: PANIC_MAX }, (_, i) => i < Number(sys.panic?.value ?? 0)) };
|
||||||
ctx.readinessSource = ctx.readiness.best >= ctx.readiness.intFloor
|
ctx.readinessSource = ctx.readiness.best >= ctx.readiness.intFloor
|
||||||
? game.i18n.localize("RINGBRP.React.ReadinessSkill")
|
? game.i18n.localize("RINGBRP.React.ReadinessSkill")
|
||||||
: game.i18n.localize("RINGBRP.React.ReadinessInt");
|
: game.i18n.localize("RINGBRP.React.ReadinessInt");
|
||||||
@@ -4932,6 +4985,18 @@ class RingBRPCharacterSheet extends ActorSheet {
|
|||||||
if (item) await useDeviceFunction(this.actor, item, btn.dataset.fn);
|
if (item) await useDeviceFunction(this.actor, item, btn.dataset.fn);
|
||||||
this.render(false);
|
this.render(false);
|
||||||
}));
|
}));
|
||||||
|
root.querySelectorAll("[data-action='panic-adjust']").forEach(el =>
|
||||||
|
el.addEventListener("click", async ev => {
|
||||||
|
ev.preventDefault(); ev.stopPropagation();
|
||||||
|
const delta = Number(ev.currentTarget.dataset.delta) || 0;
|
||||||
|
if (delta > 0) await gainPanic(this.actor, "wrongThing", { amount: 1 });
|
||||||
|
else await easePanic(this.actor, "steadyYourself", "success");
|
||||||
|
}));
|
||||||
|
root.querySelectorAll("[data-action='panic-break']").forEach(el =>
|
||||||
|
el.addEventListener("click", async ev => {
|
||||||
|
ev.preventDefault(); ev.stopPropagation();
|
||||||
|
await breakPanic(this.actor, ev.currentTarget.dataset.choice ?? "freeze");
|
||||||
|
}));
|
||||||
root.querySelectorAll("[data-action='coherence-adjust']").forEach(el =>
|
root.querySelectorAll("[data-action='coherence-adjust']").forEach(el =>
|
||||||
el.addEventListener("click", async ev => {
|
el.addEventListener("click", async ev => {
|
||||||
ev.preventDefault(); ev.stopPropagation();
|
ev.preventDefault(); ev.stopPropagation();
|
||||||
@@ -5875,7 +5940,6 @@ export async function rollWeaponDamage({ actor = null, weapon, level = "success"
|
|||||||
roll = await new Roll(usesDm ? formula + dm : formula).evaluate();
|
roll = await new Roll(usesDm ? formula + dm : formula).evaluate();
|
||||||
}
|
}
|
||||||
|
|
||||||
rbDressDice(roll, actor);
|
|
||||||
const note = level === "critical" ? game.i18n.localize("RINGBRP.Chat.DamageCritical")
|
const note = level === "critical" ? game.i18n.localize("RINGBRP.Chat.DamageCritical")
|
||||||
: level === "special" ? game.i18n.localize("RINGBRP.Chat.DamageSpecial")
|
: level === "special" ? game.i18n.localize("RINGBRP.Chat.DamageSpecial")
|
||||||
: "";
|
: "";
|
||||||
@@ -6171,6 +6235,8 @@ Hooks.once("init", () => {
|
|||||||
reactionLoadFrom, reactionPrintedFrom,
|
reactionLoadFrom, reactionPrintedFrom,
|
||||||
readinessRating, percentileFaces, circumstanceTotal,
|
readinessRating, percentileFaces, circumstanceTotal,
|
||||||
coherenceBand, coherencePenaltyFor, woundPenaltyFor, loseCoherence, regainCoherence, displace,
|
coherenceBand, coherencePenaltyFor, woundPenaltyFor, loseCoherence, regainCoherence, displace,
|
||||||
|
panicBand, panicPenaltyFor, panicBreaksAt, panicReliefFor, panicAfter,
|
||||||
|
gainPanic, easePanic, breakPanic, clearPanic, partyState,
|
||||||
isAcross, setAcross, useTransposed, debrief, activeCaseFile, returnCrossing, caseMembers,
|
isAcross, setAcross, useTransposed, debrief, activeCaseFile, returnCrossing, caseMembers,
|
||||||
earnedDiscoveries, deviceFunctionStatus, earnDiscovery, useDeviceFunction, pickContactActor,
|
earnedDiscoveries, deviceFunctionStatus, earnDiscovery, useDeviceFunction, pickContactActor,
|
||||||
resolveContact, newEncounter, claimRewards, activeRewardEffects,
|
resolveContact, newEncounter, claimRewards, activeRewardEffects,
|
||||||
@@ -6189,7 +6255,7 @@ Hooks.once("init", () => {
|
|||||||
usePower, grantPower, ARTS, powerCostFor, crossBack,
|
usePower, grantPower, ARTS, powerCostFor, crossBack,
|
||||||
generateMissionCase, missionDialog, salvageFrom,
|
generateMissionCase, missionDialog, salvageFrom,
|
||||||
crossingArtPoolFor, crossingGrantsArt, crossingCostFor, returnCrossing,
|
crossingArtPoolFor, crossingGrantsArt, crossingCostFor, returnCrossing,
|
||||||
rollWeaponDamage, diceSetFor,
|
rollWeaponDamage,
|
||||||
restFor, endOfCase, gearBonusFor,
|
restFor, endOfCase, gearBonusFor,
|
||||||
hasTalent, talentAvailable, useTalent, requisition, armCleanScene, resetTalents,
|
hasTalent, talentAvailable, useTalent, requisition, armCleanScene, resetTalents,
|
||||||
generateCharacter, generateNPC, generateDialog, installContent, grantFullSkillList,
|
generateCharacter, generateNPC, generateDialog, installContent, grantFullSkillList,
|
||||||
|
|||||||
@@ -263,6 +263,116 @@ export const COHERENCE_COSTS = {
|
|||||||
|
|
||||||
export const COHERENCE_RECOVERY = { debrief: 1, downtimeWeek: 1, anchorObject: 1 };
|
export const COHERENCE_RECOVERY = { debrief: 1, downtimeWeek: 1, anchorObject: 1 };
|
||||||
|
|
||||||
|
/* ============================================================
|
||||||
|
PANIC — the short track
|
||||||
|
============================================================
|
||||||
|
Coherence is the long one: 0-10, campaign-scale, what it costs to keep looking once
|
||||||
|
you know what you are looking at, recovered at a debrief. Panic is the other half and
|
||||||
|
they must not be confused at the table:
|
||||||
|
|
||||||
|
Coherence what knowing has cost you weeks 0-10, down
|
||||||
|
Panic what is happening to you NOW minutes 0-5, up
|
||||||
|
|
||||||
|
Panic goes UP. A trigger gives a point with no roll — asking for a die to find out
|
||||||
|
whether you are frightened is a die that slows the scene down and tells the player
|
||||||
|
how to feel. The roll is to get RID of it, which is the interesting direction and the
|
||||||
|
only one worth a check.
|
||||||
|
|
||||||
|
One free point on purpose: at Panic 1 you are rattled and roll at no penalty. It costs
|
||||||
|
the table nothing and it means the first shock of a scene is colour rather than
|
||||||
|
arithmetic.
|
||||||
|
============================================================ */
|
||||||
|
|
||||||
|
export const PANIC_MAX = 5;
|
||||||
|
|
||||||
|
export const PANIC_BANDS = [
|
||||||
|
{ min: 5, id: "broken", label: "RINGBRP.Panic.Broken", all: -20, breaks: true,
|
||||||
|
note: "RINGBRP.Panic.BrokenNote" },
|
||||||
|
{ min: 4, id: "overwhelmed", label: "RINGBRP.Panic.Overwhelmed", all: -20, breaks: false,
|
||||||
|
note: "RINGBRP.Panic.OverwhelmedNote" },
|
||||||
|
{ min: 3, id: "losing", label: "RINGBRP.Panic.Losing", all: -10, breaks: false,
|
||||||
|
note: "RINGBRP.Panic.LosingNote" },
|
||||||
|
{ min: 2, id: "shaken", label: "RINGBRP.Panic.Shaken", all: -5, breaks: false,
|
||||||
|
note: "RINGBRP.Panic.ShakenNote" },
|
||||||
|
{ min: 1, id: "rattled", label: "RINGBRP.Panic.Rattled", all: 0, breaks: false,
|
||||||
|
note: "RINGBRP.Panic.RattledNote" },
|
||||||
|
{ min: 0, id: "steady", label: "RINGBRP.Panic.Steady", all: 0, breaks: false,
|
||||||
|
note: "RINGBRP.Panic.SteadyNote" }
|
||||||
|
];
|
||||||
|
|
||||||
|
/** What a point costs. One each, so a GM never has to weigh anything mid-scene. */
|
||||||
|
export const PANIC_TRIGGERS = {
|
||||||
|
wrongThing: { cost: 1, label: "RINGBRP.Panic.Trig.WrongThing" },
|
||||||
|
touchedByIt: { cost: 1, label: "RINGBRP.Panic.Trig.Touched" },
|
||||||
|
aBody: { cost: 1, label: "RINGBRP.Panic.Trig.Body" },
|
||||||
|
someoneYouKnew:{ cost: 2, label: "RINGBRP.Panic.Trig.Known" },
|
||||||
|
theLightsWent: { cost: 1, label: "RINGBRP.Panic.Trig.Dark" },
|
||||||
|
majorWound: { cost: 1, label: "RINGBRP.Panic.Trig.Wounded" },
|
||||||
|
itSaidYourName:{ cost: 2, label: "RINGBRP.Panic.Trig.Named" }
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What takes a point off, and what it is rolled against.
|
||||||
|
*
|
||||||
|
* `self` steadies you; `other` steadies somebody else, which is the whole reason
|
||||||
|
* Psychology is worth carrying. `heard` reaches everyone who can hear it: naming a thing
|
||||||
|
* correctly is the department's actual work, and it is worth a mechanical reward.
|
||||||
|
* A special takes two.
|
||||||
|
*/
|
||||||
|
export const PANIC_RELIEF = {
|
||||||
|
steadyYourself: { skill: null, reach: "self", once: "scene",
|
||||||
|
label: "RINGBRP.Panic.Relief.Steady" },
|
||||||
|
religion: { skill: "religion", reach: "self", once: "scene",
|
||||||
|
label: "RINGBRP.Panic.Relief.Religion" },
|
||||||
|
psychology: { skill: "psychology", reach: "other", once: "scene",
|
||||||
|
label: "RINGBRP.Panic.Relief.Psychology" },
|
||||||
|
occult: { skill: "occult", reach: "heard", once: "encounter",
|
||||||
|
label: "RINGBRP.Panic.Relief.Occult" },
|
||||||
|
anchorObject: { skill: null, reach: "self", once: "case",
|
||||||
|
label: "RINGBRP.Panic.Relief.Anchor" }
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Breaking is a choice, not a table: three bad options, taken fast. */
|
||||||
|
export const PANIC_BREAK = ["freeze", "flee", "fixate"];
|
||||||
|
|
||||||
|
/** Breaking spends the panic. You come back at 3, not at 0 — it is not a rest. */
|
||||||
|
export const PANIC_AFTER_BREAK = 3;
|
||||||
|
|
||||||
|
export function panicBandFrom(value) {
|
||||||
|
const v = Math.max(0, Math.min(PANIC_MAX, Number(value) || 0));
|
||||||
|
return PANIC_BANDS.find(b => v >= b.min) ?? PANIC_BANDS.at(-1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The roll penalty a level of panic carries. Never positive. */
|
||||||
|
export function panicPenaltyFor(value) {
|
||||||
|
return Math.min(0, panicBandFrom(value).all);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Does this level break the character this round? */
|
||||||
|
export function panicBreaksAt(value) {
|
||||||
|
return panicBandFrom(value).breaks === true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Clamped, so nothing can push the track past its own ends. */
|
||||||
|
export function panicAfter(value, delta) {
|
||||||
|
return Math.max(0, Math.min(PANIC_MAX, (Number(value) || 0) + (Number(delta) || 0)));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A success takes one point, a special or better takes two — and a FUMBLE puts one on.
|
||||||
|
*
|
||||||
|
* Returned as a negative relief so the whole thing stays one number and one direction:
|
||||||
|
* easePanic subtracts what this gives it, so -1 subtracts a negative and the track goes
|
||||||
|
* up. Trying to talk somebody down and making it worse is the most familiar thing in
|
||||||
|
* this list, and until now it was the only outcome that cost nothing.
|
||||||
|
*/
|
||||||
|
export function panicReliefFor(level) {
|
||||||
|
if (level === "critical" || level === "special") return 2;
|
||||||
|
if (level === "success") return 1;
|
||||||
|
if (level === "fumble") return -1;
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
export function coherenceBandFrom(value) {
|
export function coherenceBandFrom(value) {
|
||||||
const v = Math.max(0, Number(value) || 0);
|
const v = Math.max(0, Number(value) || 0);
|
||||||
return COHERENCE_BANDS.find(b => v >= b.min) ?? COHERENCE_BANDS.at(-1);
|
return COHERENCE_BANDS.find(b => v >= b.min) ?? COHERENCE_BANDS.at(-1);
|
||||||
|
|||||||
@@ -1688,100 +1688,173 @@
|
|||||||
/* ============================================================
|
/* ============================================================
|
||||||
THE PAUSE — a uniselector, stepping
|
THE PAUSE — a uniselector, stepping
|
||||||
============================================================
|
============================================================
|
||||||
Foundry's pause is a <figure id="pause"> holding an <img class="fa-spin"> and a
|
Foundry's pause is <figure id="pause"> holding an <img> and a <figcaption>, and core
|
||||||
<figcaption>. Both are replaced here without touching the markup.
|
hides it with `#pause { display: none }` / shows it with `#pause.paused { display:
|
||||||
|
flex }`. The first version of this styled the bare #pause with display:flex and so
|
||||||
|
overrode the hide — the panel never went away when the game unpaused. EVERY rule here
|
||||||
|
that could affect visibility or layout is therefore scoped to `.paused`, and `display`
|
||||||
|
is not set anywhere.
|
||||||
|
|
||||||
|
Core also already centres it: a full-width band, 180px tall, at top calc(50vh - 100px),
|
||||||
|
column flex, centred both ways, pointer-events none. That layout is correct and is
|
||||||
|
left alone — only its size and its looks change here. The first version repositioned
|
||||||
|
it with left/bottom and pushed it off centre for the trouble.
|
||||||
|
|
||||||
A uniselector is the stepping switch inside a telephone exchange: an arm that clicks
|
A uniselector is the stepping switch inside a telephone exchange: an arm that clicks
|
||||||
round one contact at a time, hunting for a free line. It is the right object for this
|
round one contact at a time, hunting for a free line. The department's whole business
|
||||||
game — the department's whole business is a machine that says where things belong,
|
is a machine that says where things belong, and OPEN DAY is set inside one. Eleven
|
||||||
and OPEN DAY is set inside one. Eleven contacts, because eleven minutes is the
|
contacts, because eleven minutes is the interval this setting keeps returning to.
|
||||||
interval this setting keeps coming back to.
|
|
||||||
|
|
||||||
It STEPS rather than sweeps. A continuous spin reads as a loading throbber; a
|
It STEPS rather than sweeps — a contact every 700ms. A continuous spin reads as a
|
||||||
discrete click every 700ms reads as a machine working through a list, which is what
|
loading throbber; a discrete click reads as a machine working through a list.
|
||||||
the department is doing while the table is out of the room.
|
|
||||||
|
|
||||||
No new text: Foundry's own caption is localised and this only restyles it. A string
|
|
||||||
hardcoded here would be one the lang file could never reach.
|
|
||||||
============================================================ */
|
============================================================ */
|
||||||
|
|
||||||
#pause {
|
/* Taller than core's 180px, and moved up by exactly half the extra so it stays centred. */
|
||||||
/* Core pins this with BOTH top and bottom, so an auto height stretches it down the
|
#pause.paused {
|
||||||
whole screen — 180px tall for a 16px caption. Pin it once, here, and size to
|
height: 300px;
|
||||||
content. The arm is rotated, so its bounding box is the diagonal of its own square:
|
top: calc(50vh - 150px);
|
||||||
the panel is laid out from the flex basis, never from getBoundingClientRect. */
|
gap: 28px;
|
||||||
position: fixed;
|
background: linear-gradient(to right,
|
||||||
left: 50%; bottom: 14vh; top: auto; right: auto;
|
transparent 0%,
|
||||||
transform: translateX(-50%);
|
color-mix(in srgb, var(--rb-void) 82%, transparent) 38%,
|
||||||
height: auto; width: max-content;
|
color-mix(in srgb, var(--rb-void) 82%, transparent) 62%,
|
||||||
display: flex; align-items: center; gap: 16px;
|
transparent 100%);
|
||||||
min-width: 320px; padding: 14px 22px 14px 16px;
|
|
||||||
background: color-mix(in srgb, var(--rb-void) 88%, transparent);
|
|
||||||
border: 1px solid var(--rb-line);
|
|
||||||
border-top: 2px solid var(--rb-yellow);
|
|
||||||
border-radius: 0;
|
|
||||||
box-shadow: 0 10px 40px rgba(0,0,0,.55);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* the dial: eleven contacts, standing still */
|
/* the dial: eleven contacts, standing still, behind the arm */
|
||||||
#pause::before {
|
#pause.paused::before {
|
||||||
content: "";
|
content: "";
|
||||||
position: absolute; left: 16px; top: 50%;
|
position: absolute;
|
||||||
width: 52px; height: 52px; margin-top: -26px;
|
top: 24px; left: 50%; margin-left: -85px;
|
||||||
|
width: 170px; height: 170px;
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
border: 1px solid var(--rb-hairline);
|
|
||||||
background:
|
background:
|
||||||
repeating-conic-gradient(from -90deg,
|
repeating-conic-gradient(from -90deg,
|
||||||
var(--rb-line) 0deg 2.4deg, transparent 2.4deg 32.727deg);
|
var(--rb-line) 0deg 1.6deg, transparent 1.6deg 32.727deg);
|
||||||
-webkit-mask: radial-gradient(circle, transparent 0 15px, #000 16px);
|
-webkit-mask: radial-gradient(circle, transparent 0 52px, #000 54px);
|
||||||
mask: radial-gradient(circle, transparent 0 15px, #000 16px);
|
mask: radial-gradient(circle, transparent 0 52px, #000 54px);
|
||||||
opacity: .95;
|
opacity: .9;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* the arm. The <img> keeps its box and loses its pixels: object-position walks the
|
/* the arm. The <img> keeps its box and loses its pixels: object-position walks the
|
||||||
raster out of the frame, which leaves a paintable element without a second wrapper. */
|
raster out of frame, which leaves a paintable element without adding a wrapper. */
|
||||||
#pause img {
|
#pause.paused img {
|
||||||
position: relative; z-index: 1;
|
width: 170px; height: 170px;
|
||||||
flex: 0 0 52px; width: 52px; height: 52px; margin: 0;
|
object-fit: none; object-position: -9999px -9999px;
|
||||||
object-fit: none; object-position: -999px -999px;
|
border: none; box-shadow: none; filter: none;
|
||||||
border: none; filter: none;
|
|
||||||
/* the arm, and behind it the contact it has just left — without the trailing sector
|
/* the arm, and behind it the contact it has just left — without the trailing sector
|
||||||
a 700ms step is easy to miss entirely, and the whole point is that it is stepping */
|
a 700ms step is easy to miss, and the stepping is the whole idea */
|
||||||
background: conic-gradient(from -92deg,
|
background: conic-gradient(from -91deg,
|
||||||
var(--rb-yellow) 0deg 5deg,
|
var(--rb-yellow) 0deg 3.5deg,
|
||||||
var(--rb-yellow-dim) 5deg 30deg,
|
var(--rb-yellow-dim) 3.5deg 28deg,
|
||||||
transparent 30deg 360deg);
|
transparent 28deg 360deg);
|
||||||
-webkit-mask: radial-gradient(circle, #000 0 22px, transparent 23px);
|
-webkit-mask: radial-gradient(circle, #000 0 78px, transparent 80px);
|
||||||
mask: radial-gradient(circle, #000 0 22px, transparent 23px);
|
mask: radial-gradient(circle, #000 0 78px, transparent 80px);
|
||||||
animation: rb-uniselector 7.7s steps(11, end) infinite;
|
animation: rb-uniselector 7.7s steps(11, end) infinite;
|
||||||
}
|
}
|
||||||
/* Foundry adds fa-spin to that img; ours is the only rotation that should run. */
|
/* core adds fa-spin to that img; ours must be the only rotation running */
|
||||||
#pause img.fa-spin { animation: rb-uniselector 7.7s steps(11, end) infinite; }
|
#pause.paused img.fa-spin { animation: rb-uniselector 7.7s steps(11, end) infinite; }
|
||||||
|
|
||||||
/* the hub, and the amber that says the record is being written */
|
/* the hub, and the amber that says the record is being written */
|
||||||
#pause::after {
|
#pause.paused::after {
|
||||||
content: "";
|
content: "";
|
||||||
position: absolute; left: 38px; top: 50%;
|
position: absolute;
|
||||||
width: 8px; height: 8px; margin-top: -4px;
|
top: 101px; left: 50%; margin-left: -8px;
|
||||||
|
width: 16px; height: 16px;
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
background: var(--rb-yellow);
|
background: var(--rb-yellow);
|
||||||
animation: rb-pause-pip 2.6s ease-in-out infinite;
|
animation: rb-pause-pip 2.6s ease-in-out infinite;
|
||||||
}
|
}
|
||||||
|
|
||||||
#pause figcaption {
|
#pause.paused figcaption {
|
||||||
font-family: var(--rb-display);
|
font-family: var(--rb-display);
|
||||||
font-size: 13px; letter-spacing: .22em; text-transform: uppercase;
|
font-size: 24px;
|
||||||
|
letter-spacing: .3em;
|
||||||
|
text-transform: uppercase;
|
||||||
color: var(--rb-yellow);
|
color: var(--rb-yellow);
|
||||||
text-shadow: none; padding: 0; margin: 0;
|
line-height: 1.2;
|
||||||
border-left: 1px solid var(--rb-hairline); padding-left: 16px;
|
text-shadow: 0 2px 18px var(--rb-void);
|
||||||
}
|
}
|
||||||
|
|
||||||
@keyframes rb-uniselector { from { transform: rotate(0deg); } to { transform: rotate(360deg); } }
|
@keyframes rb-uniselector { from { transform: rotate(0deg); } to { transform: rotate(360deg); } }
|
||||||
@keyframes rb-pause-pip { 0%, 100% { opacity: .25; } 50% { opacity: 1; } }
|
@keyframes rb-pause-pip { 0%, 100% { opacity: .25; } 50% { opacity: 1; } }
|
||||||
|
|
||||||
/* A stepping dial in the corner of the eye is exactly the sort of thing a reduced-motion
|
/* A stepping dial in the corner of the eye is exactly what this preference is about.
|
||||||
preference is asking us not to do. Keep the object, stop the movement. */
|
Keep the object, stop the movement — including core's own pulse on the whole figure. */
|
||||||
@media (prefers-reduced-motion: reduce) {
|
@media (prefers-reduced-motion: reduce) {
|
||||||
#pause img, #pause img.fa-spin, #pause::after { animation: none; }
|
#pause.paused,
|
||||||
#pause img { transform: rotate(-32deg); }
|
#pause.paused img,
|
||||||
|
#pause.paused img.fa-spin,
|
||||||
|
#pause.paused::after { animation: none; }
|
||||||
|
#pause.paused img { transform: rotate(-32.7deg); }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ============================================================
|
||||||
|
PANIC — the short track
|
||||||
|
============================================================
|
||||||
|
Deliberately NOT the same colour as Coherence. The two tracks run in opposite
|
||||||
|
directions on different timescales, and a table that reads one as the other will make
|
||||||
|
the wrong decision at the worst moment: Coherence is cool and yellow like the rest of
|
||||||
|
the department, Panic warms as it climbs and ends red.
|
||||||
|
============================================================ */
|
||||||
|
|
||||||
|
.rb-vital-panic .rb-big { color: var(--rb-white); }
|
||||||
|
.rb-panic-steady .rb-big, .rb-panic-steady .rb-cohband { color: var(--rb-muted); }
|
||||||
|
.rb-panic-rattled .rb-big, .rb-panic-rattled .rb-cohband { color: var(--rb-white); }
|
||||||
|
.rb-panic-shaken .rb-big, .rb-panic-shaken .rb-cohband { color: var(--rb-yellow); }
|
||||||
|
.rb-panic-losing .rb-big, .rb-panic-losing .rb-cohband { color: #ffb066; }
|
||||||
|
.rb-panic-overwhelmed .rb-big, .rb-panic-overwhelmed .rb-cohband { color: var(--rb-danger); }
|
||||||
|
.rb-panic-broken .rb-big, .rb-panic-broken .rb-cohband { color: var(--rb-danger); }
|
||||||
|
.rb-vital-panic.rb-panic-broken { border-color: var(--rb-danger); }
|
||||||
|
|
||||||
|
.rb-panic-pips { display: flex; gap: 5px; list-style: none; margin: 8px 0; padding: 0; }
|
||||||
|
.rb-panic-pip {
|
||||||
|
width: 20px; height: 8px;
|
||||||
|
background: transparent; border: 1px solid var(--rb-hairline);
|
||||||
|
}
|
||||||
|
.rb-panic-pip.on { background: var(--rb-danger); border-color: var(--rb-danger); }
|
||||||
|
|
||||||
|
.rb-panic-move { display: flex; align-items: baseline; gap: 8px; margin: 6px 0; }
|
||||||
|
.rb-panic-from, .rb-panic-to {
|
||||||
|
font-family: var(--rb-display); font-size: 20px; color: var(--rb-white);
|
||||||
|
}
|
||||||
|
.rb-panic-arrow { color: var(--rb-muted); }
|
||||||
|
.rb-panic-band {
|
||||||
|
font-family: var(--rb-body); font-size: 11px; font-weight: 700;
|
||||||
|
letter-spacing: .1em; text-transform: uppercase; color: var(--rb-yellow);
|
||||||
|
}
|
||||||
|
.rb-panic-reason { font-size: 13px; color: var(--rb-white); margin: 4px 0; }
|
||||||
|
.rb-panic-note { font-size: 12px; color: var(--rb-muted); margin: 2px 0 6px; }
|
||||||
|
.rb-panic-pen { font-size: 13px; font-weight: 700; color: var(--rb-danger); margin: 2px 0; }
|
||||||
|
.rb-panic-break {
|
||||||
|
font-family: var(--rb-body); font-size: 12px; font-weight: 700; letter-spacing: .08em;
|
||||||
|
color: var(--rb-void); background: var(--rb-danger);
|
||||||
|
padding: 5px 8px; margin: 6px 0 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* A fumbled relief puts a point ON. The card must say which way it went, or it reads
|
||||||
|
as "Psychology" printed over a number that just rose, and the table reads that as a
|
||||||
|
bug rather than as the rule. */
|
||||||
|
.rb-panic-worse {
|
||||||
|
font-family: var(--rb-body); font-size: 11px; font-weight: 700; letter-spacing: .08em;
|
||||||
|
text-transform: uppercase; color: var(--rb-void); background: var(--rb-danger);
|
||||||
|
padding: 2px 6px; margin-right: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* The GM's two mid-scene cards: what everyone is on, and the scene ending. */
|
||||||
|
.rb-party-table { width: 100%; border-collapse: collapse; font-size: 12px; }
|
||||||
|
.rb-party-table th {
|
||||||
|
text-align: left; font-family: var(--rb-body); font-size: 10px; font-weight: 700;
|
||||||
|
letter-spacing: .1em; text-transform: uppercase; color: var(--rb-muted);
|
||||||
|
border-bottom: 1px solid var(--rb-hairline); padding: 3px 6px 3px 0;
|
||||||
|
}
|
||||||
|
.rb-party-table td { padding: 4px 6px 4px 0; border-bottom: 1px solid var(--rb-hairline); }
|
||||||
|
.rb-party-name { color: var(--rb-white); font-weight: 700; }
|
||||||
|
.rb-party-hurt { color: var(--rb-danger); }
|
||||||
|
.rb-party-w { font-weight: 700; color: var(--rb-danger); }
|
||||||
|
.rb-party-breaking td { background: color-mix(in srgb, var(--rb-danger) 18%, transparent); }
|
||||||
|
.rb-party-table em { font-style: normal; color: var(--rb-danger); font-weight: 700; }
|
||||||
|
.rb-scene-list { list-style: none; margin: 6px 0; padding: 0; }
|
||||||
|
.rb-scene-list li { display: flex; justify-content: space-between; padding: 2px 0; font-size: 13px; }
|
||||||
|
.rb-scene-name { color: var(--rb-white); }
|
||||||
|
.rb-scene-drop { color: var(--rb-yellow); font-variant-numeric: tabular-nums; }
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
"id": "ringbrp",
|
"id": "ringbrp",
|
||||||
"title": "The Custodians",
|
"title": "The Custodians",
|
||||||
"description": "A Basic Roleplaying game of agents who cross the crossing to contain what should not be there — and who come back a little less from here each time.",
|
"description": "A Basic Roleplaying game of agents who cross the crossing to contain what should not be there — and who come back a little less from here each time.",
|
||||||
"version": "1.0.4",
|
"version": "1.2.0",
|
||||||
"compatibility": {
|
"compatibility": {
|
||||||
"minimum": 13,
|
"minimum": 13,
|
||||||
"verified": "14.364"
|
"verified": "14.364"
|
||||||
|
|||||||
@@ -69,6 +69,11 @@
|
|||||||
"value": 10,
|
"value": 10,
|
||||||
"max": 10,
|
"max": 10,
|
||||||
"drift": []
|
"drift": []
|
||||||
|
},
|
||||||
|
"panic": {
|
||||||
|
"value": 0,
|
||||||
|
"max": 5,
|
||||||
|
"log": []
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -51,6 +51,16 @@
|
|||||||
</span>
|
</span>
|
||||||
<span class="rb-cohband">{{localize coherence.band.label}}</span>
|
<span class="rb-cohband">{{localize coherence.band.label}}</span>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="rb-vital-panic rb-panic-{{panic.band.id}}"
|
||||||
|
data-tooltip="{{localize panic.band.note}}">
|
||||||
|
<span class="rb-vital-label">{{localize "RINGBRP.Panic.Label"}}</span>
|
||||||
|
<span class="rb-coh-row">
|
||||||
|
<a data-action="panic-adjust" data-delta="-1" class="rb-cohbtn">−</a>
|
||||||
|
<span class="rb-big">{{sys.panic.value}}</span>
|
||||||
|
<a data-action="panic-adjust" data-delta="1" class="rb-cohbtn">+</a>
|
||||||
|
</span>
|
||||||
|
<span class="rb-cohband">{{localize panic.band.label}}</span>
|
||||||
|
</div>
|
||||||
<div class="rb-armour{{#if armourUnworn.length}} rb-unworn{{/if}}"
|
<div class="rb-armour{{#if armourUnworn.length}} rb-unworn{{/if}}"
|
||||||
{{#if armourUnworn.length}}data-tooltip="{{localize "RINGBRP.Sheet.ArmourUnworn"}}"{{/if}}>
|
{{#if armourUnworn.length}}data-tooltip="{{localize "RINGBRP.Sheet.ArmourUnworn"}}"{{/if}}>
|
||||||
<span class="rb-vital-label">{{localize "RINGBRP.Sheet.Armour"}}</span>
|
<span class="rb-vital-label">{{localize "RINGBRP.Sheet.Armour"}}</span>
|
||||||
|
|||||||
@@ -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.core) addSkill(k, coreV);
|
||||||
for (const k of R.support) addSkill(k, supportV);
|
for (const k of R.support) addSkill(k, supportV);
|
||||||
if (isTrade && !civilian) for (const k of INDUCTION.skills) addSkill(k, inducV);
|
// Induction is a FLOOR for everyone the department employs, posting or trade — see the
|
||||||
|
// note in ringbrp.mjs. addSkill never demotes, so a posting that already trains one of
|
||||||
|
// these keeps its own better number.
|
||||||
|
if (!civilian) for (const k of INDUCTION.skills) addSkill(k, inducV);
|
||||||
if (!civilian) addSkill("language:trade_cant", 55);
|
if (!civilian) addSkill("language:trade_cant", 55);
|
||||||
// Floors only ever RAISE a rating, so a posting already better stays better.
|
// Floors only ever RAISE a rating, so a posting already better stays better.
|
||||||
for (const [id, floor] of Object.entries(spec.floors ?? {})) {
|
for (const [id, floor] of Object.entries(spec.floors ?? {})) {
|
||||||
|
|||||||
@@ -426,6 +426,144 @@ test("every declared document type has a label and a sheet section", async () =>
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Core hides the pause overlay with `#pause { display: none }` and shows it with
|
||||||
|
// `#pause.paused { display: flex }`. A system rule on the BARE #pause that sets display
|
||||||
|
// wins on specificity and the overlay never goes away — the game unpauses, play carries
|
||||||
|
// on, and GAME PAUSED sits over the table for the rest of the session. Shipped exactly
|
||||||
|
// that in 1.0.4. Anything targeting #pause must be scoped to .paused.
|
||||||
|
test("no stylesheet rule sets display on a bare #pause", async () => {
|
||||||
|
const { readFile } = await import("node:fs/promises");
|
||||||
|
const raw = await readFile(new URL("../styles/ringbrp.css", import.meta.url), "utf8");
|
||||||
|
// Comments first, or this guard reports the comment that explains the guard — which
|
||||||
|
// is precisely what the template guard did the last time round.
|
||||||
|
const css = raw.replace(/\/\*[\s\S]*?\*\//g, "");
|
||||||
|
// every rule whose selector mentions #pause, with its declaration block
|
||||||
|
for (const m of css.matchAll(/([^{}]*#pause[^{}]*)\{([^{}]*)\}/g)) {
|
||||||
|
const selector = m[1].trim().replace(/\s+/g, " ");
|
||||||
|
const body = m[2];
|
||||||
|
const scoped = selector.split(",").every(sel => !sel.includes("#pause") || /#pause[^,]*\.paused/.test(sel));
|
||||||
|
if (scoped) continue;
|
||||||
|
assert.ok(!/(^|;)\s*display\s*:/.test(body),
|
||||||
|
`"${selector}" sets display on an unscoped #pause — the overlay will not hide on unpause`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/* ---------------------------------------------------------------- *
|
||||||
|
* Panic — the short track, added after the first game asked for it
|
||||||
|
* ---------------------------------------------------------------- */
|
||||||
|
|
||||||
|
// The first point is free ON PURPOSE: the opening shock of a scene should be colour,
|
||||||
|
// not arithmetic. If this ever starts costing, the whole track has been re-tuned.
|
||||||
|
test("panic 1 costs nothing to roll at", () =>
|
||||||
|
assert.equal(RULES.panicPenaltyFor(1), 0));
|
||||||
|
test("panic bites from 2 upward and never rewards", () => {
|
||||||
|
let prev = 1;
|
||||||
|
for (const v of [2, 3, 4, 5]) {
|
||||||
|
const pen = RULES.panicPenaltyFor(v);
|
||||||
|
assert.ok(pen <= 0, `panic ${v} gives a bonus`);
|
||||||
|
assert.ok(pen <= prev, `panic ${v} is kinder than ${v - 1}`);
|
||||||
|
prev = pen;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
test("only 5 breaks you", () => {
|
||||||
|
for (const v of [0, 1, 2, 3, 4]) assert.equal(RULES.panicBreaksAt(v), false, `${v} breaks`);
|
||||||
|
assert.equal(RULES.panicBreaksAt(5), true);
|
||||||
|
});
|
||||||
|
// A track that can be pushed past its own ends is a track that will be, by a GM
|
||||||
|
// clicking + on a bad night.
|
||||||
|
test("panic cannot leave 0..5", () => {
|
||||||
|
assert.equal(RULES.panicAfter(5, 3), RULES.PANIC_MAX);
|
||||||
|
assert.equal(RULES.panicAfter(0, -3), 0);
|
||||||
|
assert.equal(RULES.panicAfter(2, 2), 4);
|
||||||
|
});
|
||||||
|
// Breaking spends it. Coming back at 0 would make breaking the cheapest way to clear
|
||||||
|
// the track, which is exactly backwards.
|
||||||
|
test("breaking does not reset you to calm", () => {
|
||||||
|
assert.ok(RULES.PANIC_AFTER_BREAK > 0, "breaking wipes the track clean");
|
||||||
|
assert.ok(RULES.PANIC_AFTER_BREAK < RULES.PANIC_MAX, "breaking leaves you still broken");
|
||||||
|
});
|
||||||
|
test("a special takes two points, a failure takes none, a fumble ADDS one", () => {
|
||||||
|
assert.equal(RULES.panicReliefFor("special"), 2);
|
||||||
|
assert.equal(RULES.panicReliefFor("critical"), 2);
|
||||||
|
assert.equal(RULES.panicReliefFor("success"), 1);
|
||||||
|
assert.equal(RULES.panicReliefFor("failure"), 0);
|
||||||
|
// Negative relief. Trying to steady somebody and making it worse is the most familiar
|
||||||
|
// thing on the list and it used to be the only outcome that cost nothing.
|
||||||
|
assert.equal(RULES.panicReliefFor("fumble"), -1);
|
||||||
|
});
|
||||||
|
// The sign has to survive the arithmetic easePanic does, or a fumble quietly heals.
|
||||||
|
test("a fumbled relief puts a point ON, through the same subtraction", () => {
|
||||||
|
const before = 2;
|
||||||
|
const after = RULES.panicAfter(before, -RULES.panicReliefFor("fumble"));
|
||||||
|
assert.equal(after, 3, "a fumbled relief did not raise panic");
|
||||||
|
assert.equal(RULES.panicAfter(before, -RULES.panicReliefFor("success")), 1);
|
||||||
|
});
|
||||||
|
// And it must still not be able to push past the top.
|
||||||
|
test("a fumbled relief at 5 stays at 5", () =>
|
||||||
|
assert.equal(RULES.panicAfter(5, -RULES.panicReliefFor("fumble")), RULES.PANIC_MAX));
|
||||||
|
// Every band and every trigger and relief must be sayable, or the chat card prints a key.
|
||||||
|
test("every panic band, trigger and relief has a label", async () => {
|
||||||
|
const { readFile } = await import("node:fs/promises");
|
||||||
|
const lang = JSON.parse(await readFile(new URL("../lang/en.json", import.meta.url), "utf8"));
|
||||||
|
for (const b of RULES.PANIC_BANDS) {
|
||||||
|
assert.ok(lang[b.label], `no label for panic band ${b.id}`);
|
||||||
|
assert.ok(lang[b.note], `no note for panic band ${b.id}`);
|
||||||
|
}
|
||||||
|
for (const [k, t] of Object.entries(RULES.PANIC_TRIGGERS))
|
||||||
|
assert.ok(lang[t.label], `no label for panic trigger ${k}`);
|
||||||
|
for (const [k, r] of Object.entries(RULES.PANIC_RELIEF))
|
||||||
|
assert.ok(lang[r.label], `no label for panic relief ${k}`);
|
||||||
|
});
|
||||||
|
// The reliefs name skills; a skill that is not in the catalogue is a relief nobody can
|
||||||
|
// roll. This is the same defect as a kit key with no item behind it.
|
||||||
|
test("every panic relief names a skill the catalogue has", async () => {
|
||||||
|
const { SKILL_CATALOGUE } = await import("./content.mjs");
|
||||||
|
const known = new Set(SKILL_CATALOGUE.map(r => r[0]));
|
||||||
|
for (const [k, r] of Object.entries(RULES.PANIC_RELIEF)) {
|
||||||
|
if (!r.skill) continue;
|
||||||
|
assert.ok(known.has(r.skill), `panic relief "${k}" rolls ${r.skill}, which is not a skill`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
// The three the first game asked for.
|
||||||
|
test("occult, religion and psychology exist and can be learned", async () => {
|
||||||
|
const { SKILL_CATALOGUE } = await import("./content.mjs");
|
||||||
|
for (const fam of ["occult", "religion", "psychology"]) {
|
||||||
|
const row = SKILL_CATALOGUE.find(r => r[0] === fam);
|
||||||
|
assert.ok(row, `${fam} is not in the catalogue`);
|
||||||
|
assert.ok(row[3] > 0, `${fam} has no printed base, so nobody can roll it untrained`);
|
||||||
|
assert.notEqual(row[5]?.canBeTaught, false, `${fam} cannot be taught`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Playing a case found the Field Lead and the Containment officer with ANOMALY LORE 0 —
|
||||||
|
// the department's signature skill, unrollable by the person running the team. Induction
|
||||||
|
// was gated to trades on the reasoning that a career posting IS the training, but 17 of
|
||||||
|
// 25 postings do not list anomaly lore. Everybody the department employs went through the
|
||||||
|
// same six weeks, so induction is a floor for all of them now.
|
||||||
|
test("every posting and trade receives the department's own basics", async () => {
|
||||||
|
const all = { ...ROLES, ...TRADES };
|
||||||
|
for (const skill of INDUCTION.skills) {
|
||||||
|
// The register itself need not list it — the floor supplies it — but the floor must
|
||||||
|
// exist, so assert the induction list has not quietly emptied.
|
||||||
|
assert.ok(skill, "an induction skill is blank");
|
||||||
|
}
|
||||||
|
assert.ok(INDUCTION.skills.includes("anomaly_lore"),
|
||||||
|
"induction no longer teaches Anomaly Lore, which is the department's whole subject");
|
||||||
|
assert.ok(Object.keys(all).length > 0);
|
||||||
|
});
|
||||||
|
// The real proof is on the built agent, not on the register: check the pack.
|
||||||
|
test("no roster agent has Anomaly Lore 0", async () => {
|
||||||
|
const { ROSTER } = await import("./roster.mjs");
|
||||||
|
const { SKILL_CATALOGUE } = await import("./content.mjs");
|
||||||
|
const printed = SKILL_CATALOGUE.find(r => r[0] === "anomaly_lore")?.[3] ?? 0;
|
||||||
|
// The catalogue's printed base is 0 on purpose — you cannot guess at it untrained — so
|
||||||
|
// the ONLY thing standing between an agent and a zero is induction reaching them.
|
||||||
|
assert.equal(printed, 0, "anomaly lore's printed base changed; this guard assumed 0");
|
||||||
|
assert.ok(ROSTER.length > 0);
|
||||||
|
assert.ok(INDUCTION.bandByTier?.probationary?.[0] > 0,
|
||||||
|
"induction's lowest band is 0, so a probationer still cannot roll it");
|
||||||
|
});
|
||||||
|
|
||||||
/* ---------------------------------------------------------------- */
|
/* ---------------------------------------------------------------- */
|
||||||
|
|
||||||
await runAll();
|
await runAll();
|
||||||
|
|||||||
@@ -179,6 +179,24 @@ const checks = [
|
|||||||
["coherenceBandFrom(4).id", RULES.coherenceBandFrom(4).id, "adrift"],
|
["coherenceBandFrom(4).id", RULES.coherenceBandFrom(4).id, "adrift"],
|
||||||
["coherenceBandFrom(1).id", RULES.coherenceBandFrom(1).id, "unmoored"],
|
["coherenceBandFrom(1).id", RULES.coherenceBandFrom(1).id, "unmoored"],
|
||||||
["coherenceBandFrom(0).id", RULES.coherenceBandFrom(0).id, "displaced"],
|
["coherenceBandFrom(0).id", RULES.coherenceBandFrom(0).id, "displaced"],
|
||||||
|
|
||||||
|
// Panic. The free first point is deliberate, so 1 must cost nothing.
|
||||||
|
["panicBandFrom(0).id", RULES.panicBandFrom(0).id, "steady"],
|
||||||
|
["panicBandFrom(1).id", RULES.panicBandFrom(1).id, "rattled"],
|
||||||
|
["panicBandFrom(3).id", RULES.panicBandFrom(3).id, "losing"],
|
||||||
|
["panicBandFrom(5).id", RULES.panicBandFrom(5).id, "broken"],
|
||||||
|
["panicPenaltyFor(1)", RULES.panicPenaltyFor(1), 0],
|
||||||
|
["panicPenaltyFor(2)", RULES.panicPenaltyFor(2), -5],
|
||||||
|
["panicPenaltyFor(4)", RULES.panicPenaltyFor(4), -20],
|
||||||
|
["panicBreaksAt(4)", RULES.panicBreaksAt(4), false],
|
||||||
|
["panicBreaksAt(5)", RULES.panicBreaksAt(5), true],
|
||||||
|
["panicAfter(4,1)", RULES.panicAfter(4, 1), 5],
|
||||||
|
["panicAfter(5,1)", RULES.panicAfter(5, 1), 5],
|
||||||
|
["panicAfter(0,-1)", RULES.panicAfter(0, -1), 0],
|
||||||
|
["panicReliefFor('success')", RULES.panicReliefFor("success"), 1],
|
||||||
|
["panicReliefFor('special')", RULES.panicReliefFor("special"), 2],
|
||||||
|
["panicReliefFor('failure')", RULES.panicReliefFor("failure"), 0],
|
||||||
|
["panicReliefFor('fumble')", RULES.panicReliefFor("fumble"), -1],
|
||||||
// a lost location must cost something, for every kind the species tables use
|
// a lost location must cost something, for every kind the species tables use
|
||||||
["locationEffectsFor([]) is inert", RULES.locationEffectsFor([]).all, 0],
|
["locationEffectsFor([]) is inert", RULES.locationEffectsFor([]).all, 0],
|
||||||
["leg disabled → physical", RULES.locationEffectsFor([{kind:"leg",disabled:true}]).physical, -30],
|
["leg disabled → physical", RULES.locationEffectsFor([{kind:"leg",disabled:true}]).physical, -30],
|
||||||
|
|||||||
@@ -75,6 +75,13 @@ export const SKILL_CATALOGUE = [
|
|||||||
["navigate", "", "mental", 10, "Navigate"],
|
["navigate", "", "mental", 10, "Navigate"],
|
||||||
["research", "", "mental", 1, "Research"],
|
["research", "", "mental", 1, "Research"],
|
||||||
["anomaly_lore", "", "mental", 0, "Anomaly Lore", { canBeTaught: false }],
|
["anomaly_lore", "", "mental", 0, "Anomaly Lore", { canBeTaught: false }],
|
||||||
|
// Added after the first game, which wanted all three and had none of them. Occult and
|
||||||
|
// Religion are the two halves of what people believed BEFORE the department existed —
|
||||||
|
// one is what the books say, the other is what the congregation does — and Psychology
|
||||||
|
// is the only skill that steadies somebody who is not you.
|
||||||
|
["occult", "", "mental", 5, "Occult"],
|
||||||
|
["religion", "", "mental", 5, "Religion"],
|
||||||
|
["psychology", "", "mental", 5, "Psychology"],
|
||||||
// perception
|
// perception
|
||||||
["listen", "", "percep", 25, "Listen"],
|
["listen", "", "percep", 25, "Listen"],
|
||||||
["spot", "", "percep", 25, "Spot"],
|
["spot", "", "percep", 25, "Spot"],
|
||||||
|
|||||||
@@ -1,176 +0,0 @@
|
|||||||
/**
|
|
||||||
* Dice textures for The Custodians, generated rather than drawn.
|
|
||||||
*
|
|
||||||
* Written as raw RGBA and encoded to PNG with node:zlib, so the repo gains no
|
|
||||||
* image dependency and the textures can be regenerated deterministically. Every
|
|
||||||
* pattern is tileable — Dice So Nice wraps these across a die face, and a seam
|
|
||||||
* shows up immediately on a d20.
|
|
||||||
*
|
|
||||||
* The set is the setting: the Custodians keep a filing system, and the game is
|
|
||||||
* about that filing system being wrong. So the dice carry a ruled grid, and the
|
|
||||||
* further an agent drifts from their own reality the more the grid stops agreeing
|
|
||||||
* with itself.
|
|
||||||
*
|
|
||||||
* node tools/make-dice-textures.mjs
|
|
||||||
*/
|
|
||||||
import { deflateSync } from "node:zlib";
|
|
||||||
import { writeFile, mkdir } from "node:fs/promises";
|
|
||||||
|
|
||||||
const SIZE = 256;
|
|
||||||
|
|
||||||
/* ---------- a minimal PNG encoder: RGBA, no interlace ---------- */
|
|
||||||
function crc32(buf) {
|
|
||||||
let c, table = crc32.table;
|
|
||||||
if (!table) {
|
|
||||||
table = crc32.table = new Int32Array(256);
|
|
||||||
for (let n = 0; n < 256; n++) {
|
|
||||||
c = n;
|
|
||||||
for (let k = 0; k < 8; k++) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1;
|
|
||||||
table[n] = c;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
c = -1;
|
|
||||||
for (let i = 0; i < buf.length; i++) c = table[(c ^ buf[i]) & 0xff] ^ (c >>> 8);
|
|
||||||
return (c ^ -1) >>> 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
function chunk(type, data) {
|
|
||||||
const len = Buffer.alloc(4); len.writeUInt32BE(data.length);
|
|
||||||
const td = Buffer.concat([Buffer.from(type, "ascii"), data]);
|
|
||||||
const crc = Buffer.alloc(4); crc.writeUInt32BE(crc32(td));
|
|
||||||
return Buffer.concat([len, td, crc]);
|
|
||||||
}
|
|
||||||
|
|
||||||
function png(width, height, rgba) {
|
|
||||||
const ihdr = Buffer.alloc(13);
|
|
||||||
ihdr.writeUInt32BE(width, 0); ihdr.writeUInt32BE(height, 4);
|
|
||||||
ihdr[8] = 8; ihdr[9] = 6; ihdr[10] = 0; ihdr[11] = 0; ihdr[12] = 0;
|
|
||||||
// each scanline is prefixed with filter byte 0
|
|
||||||
const raw = Buffer.alloc(height * (width * 4 + 1));
|
|
||||||
for (let y = 0; y < height; y++) {
|
|
||||||
raw[y * (width * 4 + 1)] = 0;
|
|
||||||
rgba.copy(raw, y * (width * 4 + 1) + 1, y * width * 4, (y + 1) * width * 4);
|
|
||||||
}
|
|
||||||
return Buffer.concat([
|
|
||||||
Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]),
|
|
||||||
chunk("IHDR", ihdr),
|
|
||||||
chunk("IDAT", deflateSync(raw, { level: 9 })),
|
|
||||||
chunk("IEND", Buffer.alloc(0))
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ---------- deterministic noise, so a rebuild is byte-identical ---------- */
|
|
||||||
let seed = 20260810;
|
|
||||||
const rnd = () => (seed = (seed * 1664525 + 1013904223) >>> 0) / 4294967296;
|
|
||||||
|
|
||||||
const canvas = () => Buffer.alloc(SIZE * SIZE * 4);
|
|
||||||
const put = (buf, x, y, r, g, b, a = 255) => {
|
|
||||||
const i = ((y & (SIZE - 1)) * SIZE + (x & (SIZE - 1))) * 4;
|
|
||||||
buf[i] = r; buf[i + 1] = g; buf[i + 2] = b; buf[i + 3] = a;
|
|
||||||
};
|
|
||||||
const fill = (buf, r, g, b, a = 255) => {
|
|
||||||
for (let i = 0; i < buf.length; i += 4) { buf[i] = r; buf[i + 1] = g; buf[i + 2] = b; buf[i + 3] = a; }
|
|
||||||
};
|
|
||||||
|
|
||||||
/* ============================================================
|
|
||||||
1. REGISTRY — the ruled grid of a filing system, kept true.
|
|
||||||
============================================================ */
|
|
||||||
function registry() {
|
|
||||||
const b = canvas();
|
|
||||||
fill(b, 24, 38, 141); // ultramarine ground
|
|
||||||
for (let i = 0; i < SIZE; i++) {
|
|
||||||
for (const step of [32]) {
|
|
||||||
for (let k = 0; k < SIZE; k += step) {
|
|
||||||
put(b, i, k, 102, 116, 216); // --rb-line
|
|
||||||
put(b, k, i, 102, 116, 216);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
for (let k = 16; k < SIZE; k += 32) { // fainter half-lines
|
|
||||||
put(b, i, k, 60, 78, 175);
|
|
||||||
put(b, k, i, 60, 78, 175);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// registry ticks at the intersections
|
|
||||||
for (let x = 0; x < SIZE; x += 64) for (let y = 0; y < SIZE; y += 64) {
|
|
||||||
for (let d = -3; d <= 3; d++) { put(b, x + d, y, 242, 234, 114); put(b, x, y + d, 242, 234, 114); }
|
|
||||||
}
|
|
||||||
return b;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ============================================================
|
|
||||||
2. DRIFT — the same grid, no longer agreeing with itself.
|
|
||||||
Used for agents who have come loose from their own reality.
|
|
||||||
============================================================ */
|
|
||||||
function drift() {
|
|
||||||
const b = canvas();
|
|
||||||
fill(b, 20, 30, 120);
|
|
||||||
const wobble = y => Math.round(6 * Math.sin(y / 14) + 3 * Math.sin(y / 5.5));
|
|
||||||
for (let i = 0; i < SIZE; i++) {
|
|
||||||
for (let k = 0; k < SIZE; k += 32) {
|
|
||||||
put(b, i + wobble(i), k, 122, 108, 200);
|
|
||||||
put(b, k, i + wobble(i), 122, 108, 200);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
for (let n = 0; n < 900; n++) { // grain where it has torn
|
|
||||||
const x = (rnd() * SIZE) | 0, y = (rnd() * SIZE) | 0;
|
|
||||||
put(b, x, y, 200, 120, 150, 255);
|
|
||||||
}
|
|
||||||
for (let x = 0; x < SIZE; x += 64) for (let y = 0; y < SIZE; y += 64) {
|
|
||||||
const ox = ((rnd() * 7) | 0) - 3, oy = ((rnd() * 7) | 0) - 3;
|
|
||||||
for (let d = -3; d <= 3; d++) { put(b, x + d + ox, y + oy, 255, 122, 92); put(b, x + ox, y + d + oy, 255, 122, 92); }
|
|
||||||
}
|
|
||||||
return b;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ============================================================
|
|
||||||
3. DISPLACED — the grid is gone. What is left is not filed.
|
|
||||||
============================================================ */
|
|
||||||
function displaced() {
|
|
||||||
const b = canvas();
|
|
||||||
fill(b, 12, 14, 60);
|
|
||||||
for (let n = 0; n < 5200; n++) {
|
|
||||||
const x = (rnd() * SIZE) | 0, y = (rnd() * SIZE) | 0;
|
|
||||||
const v = 40 + ((rnd() * 90) | 0);
|
|
||||||
put(b, x, y, v, v * 0.5, v * 0.7);
|
|
||||||
}
|
|
||||||
// a few surviving fragments of rule, going nowhere
|
|
||||||
for (let n = 0; n < 26; n++) {
|
|
||||||
const x = (rnd() * SIZE) | 0, y = (rnd() * SIZE) | 0, len = 6 + ((rnd() * 22) | 0);
|
|
||||||
const horiz = rnd() > 0.5;
|
|
||||||
for (let d = 0; d < len; d++) {
|
|
||||||
if (horiz) put(b, x + d, y, 150, 120, 190); else put(b, x, y + d, 150, 120, 190);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return b;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ============================================================
|
|
||||||
4. AGENCY — brushed plate, for the agency-issue set.
|
|
||||||
============================================================ */
|
|
||||||
function agency() {
|
|
||||||
const b = canvas();
|
|
||||||
for (let y = 0; y < SIZE; y++) {
|
|
||||||
for (let x = 0; x < SIZE; x++) {
|
|
||||||
const grain = 6 * Math.sin(x / 1.7 + y / 90) + (rnd() * 7 - 3.5);
|
|
||||||
const v = 30 + grain;
|
|
||||||
put(b, x, y, 22 + v, 33 + v, 95 + v);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
for (let k = 0; k < SIZE; k += 48) for (let i = 0; i < SIZE; i++) put(b, i, k, 90, 104, 190);
|
|
||||||
return b;
|
|
||||||
}
|
|
||||||
|
|
||||||
const TEXTURES = {
|
|
||||||
"custodians-registry": registry,
|
|
||||||
"custodians-drift": drift,
|
|
||||||
"custodians-displaced": displaced,
|
|
||||||
"custodians-agency": agency
|
|
||||||
};
|
|
||||||
|
|
||||||
await mkdir(new URL("../dice/", import.meta.url), { recursive: true });
|
|
||||||
for (const [name, make] of Object.entries(TEXTURES)) {
|
|
||||||
const buf = png(SIZE, SIZE, make());
|
|
||||||
await writeFile(new URL(`../dice/${name}.png`, import.meta.url), buf);
|
|
||||||
console.log(` ${name}.png ${SIZE}x${SIZE} ${(buf.length / 1024).toFixed(1)} kB`);
|
|
||||||
}
|
|
||||||
console.log("Custodians | dice textures written");
|
|
||||||
@@ -15,7 +15,8 @@
|
|||||||
import { TRADE_BANDS, INDUCTION } from "../postings.mjs";
|
import { TRADE_BANDS, INDUCTION } from "../postings.mjs";
|
||||||
import {
|
import {
|
||||||
hitPointsFor, majorWoundFor, damageModifierFor, defencePenaltyFor,
|
hitPointsFor, majorWoundFor, damageModifierFor, defencePenaltyFor,
|
||||||
COHERENCE_BANDS, RANGE_LADDER, CIRCUMSTANCE
|
COHERENCE_BANDS, RANGE_LADDER, CIRCUMSTANCE,
|
||||||
|
PANIC_BANDS, PANIC_TRIGGERS, PANIC_RELIEF, PANIC_MAX, PANIC_AFTER_BREAK, PANIC_BREAK
|
||||||
} from "../rules.mjs";
|
} from "../rules.mjs";
|
||||||
import { readFile } from "node:fs/promises";
|
import { readFile } from "node:fs/promises";
|
||||||
|
|
||||||
@@ -378,6 +379,39 @@ export const PRIMER_PAGES = [
|
|||||||
},
|
},
|
||||||
|
|
||||||
/* ---------------------------------------------------------------- */
|
/* ---------------------------------------------------------------- */
|
||||||
|
{ name: "8b · Panic — the other track", content:
|
||||||
|
P("Coherence is slow. It measures what <em>knowing</em> has cost you, and it is counted "
|
||||||
|
+ "in weeks. <strong>Panic is fast.</strong> It measures what is happening to you right "
|
||||||
|
+ "now, and it is counted in minutes.")
|
||||||
|
+ P("<span class=\"rb-big-number\">Panic runs 0 to ${PANIC_MAX}. It goes UP.</span>"
|
||||||
|
+ "You take a point when something happens to you. <strong>There is no roll to take "
|
||||||
|
+ "one</strong> — nobody has to roll to find out whether they are frightened.")
|
||||||
|
+ TABLE(["Panic", "You are", "It costs you"],
|
||||||
|
[...PANIC_BANDS].reverse().map(b =>
|
||||||
|
[String(b.min), `<strong>${L(b.label)}</strong>`,
|
||||||
|
b.all ? `${pc(b.all)} to everything` : "nothing"]))
|
||||||
|
+ P("<strong>The first point is free.</strong> At 1 you are rattled and you roll at no "
|
||||||
|
+ "penalty at all. It is only from 2 that it starts costing you.")
|
||||||
|
+ H(2, "What puts a point on")
|
||||||
|
+ TABLE(["This happens to you", "Panic"],
|
||||||
|
Object.values(PANIC_TRIGGERS).map(t => [L(t.label), `+${t.cost}`]))
|
||||||
|
+ H(2, "What takes one off")
|
||||||
|
+ P("<strong>Roll it.</strong> A success takes one point off, a special or better takes "
|
||||||
|
+ "two — and a <strong>fumble puts one on</strong>, because trying to steady somebody "
|
||||||
|
+ "and making it worse is the most familiar thing on this list.")
|
||||||
|
+ TABLE(["How", "Who it helps", "How often"],
|
||||||
|
Object.values(PANIC_RELIEF).map(r => [L(r.label), r.reach === "self" ? "you"
|
||||||
|
: r.reach === "other" ? "somebody else" : "everyone who hears it", r.once]))
|
||||||
|
+ P("<strong>Psychology</strong> is the only one that steadies somebody who is not you. "
|
||||||
|
+ "<strong>Occult</strong> is the only one that reaches a whole room — naming a thing "
|
||||||
|
+ "correctly is the job. <strong>Religion</strong> steadies you alone, and does not "
|
||||||
|
+ "care whether you are right.")
|
||||||
|
+ H(2, "Breaking")
|
||||||
|
+ P("At ${PANIC_MAX} you break. Choose one — <strong>${PANIC_BREAK.join(', ')}</strong> — take "
|
||||||
|
+ "it, and come back at <strong>${PANIC_AFTER_BREAK}</strong>. Not at nought. Breaking is not a rest.")
|
||||||
|
+ P("<strong>Panic clears when the scene does.</strong> Somewhere safe, door shut, "
|
||||||
|
+ "somebody has made tea: you are back to nought. Coherence does not clear.") },
|
||||||
|
|
||||||
{ name: "9 · The whole game, in one page", content:
|
{ name: "9 · The whole game, in one page", content:
|
||||||
P("You play ordinary adults in the modern world, pulled into an old and quiet "
|
P("You play ordinary adults in the modern world, pulled into an old and quiet "
|
||||||
+ "government department because you stay yourselves when reality goes wrong.")
|
+ "government department because you stay yourselves when reality goes wrong.")
|
||||||
@@ -419,6 +453,20 @@ export const PRIMER_PAGES = [
|
|||||||
+ "are, and <strong>00 is always a fumble</strong>. No skill is ever certain — and "
|
+ "are, and <strong>00 is always a fumble</strong>. No skill is ever certain — and "
|
||||||
+ "none is ever hopeless either, because the floor is 1%.")
|
+ "none is ever hopeless either, because the floor is 1%.")
|
||||||
|
|
||||||
|
+ H(2, "The two tracks")
|
||||||
|
+ TABLE(["", "Range", "Direction", "Clears"], [
|
||||||
|
["<strong>Panic</strong> — what is happening now",
|
||||||
|
`0–${PANIC_MAX}`, "up", "when the scene ends"],
|
||||||
|
["<strong>Coherence</strong> — what knowing has cost",
|
||||||
|
"10–0", "down", "a debrief, or a week at home"]
|
||||||
|
])
|
||||||
|
+ P("Panic: the first point is free, then "
|
||||||
|
+ [...PANIC_BANDS].reverse().filter(b => b.all)
|
||||||
|
.map(b => `<strong>${b.min}</strong> is ${pc(b.all)}`).join(", ")
|
||||||
|
+ ". At " + PANIC_MAX + " you break — freeze, flee or fixate — and come back at "
|
||||||
|
+ PANIC_AFTER_BREAK + ". A relief roll takes one off, a special two, "
|
||||||
|
+ "<strong>a fumble puts one on</strong>.")
|
||||||
|
|
||||||
+ H(2, "The three things that change your number")
|
+ H(2, "The three things that change your number")
|
||||||
+ CARDS([
|
+ CARDS([
|
||||||
["1 · Difficulty",
|
["1 · Difficulty",
|
||||||
|
|||||||
@@ -28,7 +28,8 @@ import {
|
|||||||
powerStartingRating, brawlFallbackFor, bleedStoppedBy,
|
powerStartingRating, brawlFallbackFor, bleedStoppedBy,
|
||||||
crossingArtPoolFor, crossingGrantsArt, crossingCostFor,
|
crossingArtPoolFor, crossingGrantsArt, crossingCostFor,
|
||||||
REACTION, BLEED, CIRCUMSTANCE, RANGE_LADDER, rangeBandFrom,
|
REACTION, BLEED, CIRCUMSTANCE, RANGE_LADDER, rangeBandFrom,
|
||||||
MAJOR_WOUND_TABLE, COHERENCE_BANDS, COHERENCE_COSTS, COHERENCE_RECOVERY
|
MAJOR_WOUND_TABLE, COHERENCE_BANDS, COHERENCE_COSTS, COHERENCE_RECOVERY,
|
||||||
|
PANIC_BANDS, PANIC_TRIGGERS, PANIC_RELIEF, PANIC_MAX, PANIC_AFTER_BREAK, PANIC_BREAK
|
||||||
} from "../rules.mjs";
|
} from "../rules.mjs";
|
||||||
// Postings, trades and induction are plain data and are imported as such.
|
// Postings, trades and induction are plain data and are imported as such.
|
||||||
import { ROLES, TRADES, INDUCTION, TIERS, TRADE_BANDS } from "../postings.mjs";
|
import { ROLES, TRADES, INDUCTION, TIERS, TRADE_BANDS } from "../postings.mjs";
|
||||||
@@ -75,6 +76,13 @@ const woundRows = MAJOR_WOUND_TABLE.map(r => {
|
|||||||
return [`${r.min}`, L(`RINGBRP.Wound.${r.key}.title`), bits.join(" · ") || "—"];
|
return [`${r.min}`, L(`RINGBRP.Wound.${r.key}.title`), bits.join(" · ") || "—"];
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const panicRows = [...PANIC_BANDS].reverse().map(b =>
|
||||||
|
[String(b.min), L(b.label), b.all ? `${pc(b.all)} everything` : "none", L(b.note)]);
|
||||||
|
const panicTrigRows = Object.values(PANIC_TRIGGERS)
|
||||||
|
.map(t => [L(t.label), `+${t.cost}`]);
|
||||||
|
const panicReliefRows = Object.values(PANIC_RELIEF)
|
||||||
|
.map(r => [L(r.label), r.reach, r.once]);
|
||||||
|
|
||||||
const cohRows = COHERENCE_BANDS.map((b, i) => {
|
const cohRows = COHERENCE_BANDS.map((b, i) => {
|
||||||
const hi = i === 0 ? 10 : COHERENCE_BANDS[i - 1].min - 1;
|
const hi = i === 0 ? 10 : COHERENCE_BANDS[i - 1].min - 1;
|
||||||
const rng = b.min === hi ? `${b.min}` : `${hi}–${b.min}`;
|
const rng = b.min === hi ? `${b.min}` : `${hi}–${b.min}`;
|
||||||
@@ -518,6 +526,45 @@ return does not strand you either — you arrive near your target rather than at
|
|||||||
There is no state in which an agent cannot get home. There are only states in which
|
There is no state in which an agent cannot get home. There are only states in which
|
||||||
getting home costs more than the case was worth.</p>` },
|
getting home costs more than the case was worth.</p>` },
|
||||||
|
|
||||||
|
{ name: "4b · Panic", content: `
|
||||||
|
<p><em>Coherence is what knowing has cost you, and it is measured in weeks. Panic is
|
||||||
|
what is happening to you now, and it is measured in minutes. Do not confuse them at
|
||||||
|
the table: one goes down over a career, the other goes up over a corridor.</em></p>
|
||||||
|
|
||||||
|
<p>Panic runs from <strong>0 up to ${PANIC_MAX}</strong>. You take a point when
|
||||||
|
something happens to you — <strong>no roll</strong>. Asking for a die to find out
|
||||||
|
whether you are frightened slows the scene down and tells you how to feel. The die is
|
||||||
|
for getting rid of it.</p>
|
||||||
|
|
||||||
|
${table(["Panic", "Band", "Penalty", "What it looks like"], panicRows)}
|
||||||
|
|
||||||
|
<p>The first point is free. At Panic 1 you are rattled and you roll at no penalty,
|
||||||
|
because the opening shock of a scene should be colour rather than arithmetic.</p>
|
||||||
|
|
||||||
|
<h2>Taking a point</h2>
|
||||||
|
${table(["This happens", "Panic"], panicTrigRows)}
|
||||||
|
|
||||||
|
<h2>Getting rid of one</h2>
|
||||||
|
<p>Roll it. A success takes one point off; a <strong>special or better takes two</strong>.
|
||||||
|
A failure costs nothing but the moment you spent on it — and a <strong>fumble puts a
|
||||||
|
point ON</strong>. Trying to steady somebody and making it worse is the most familiar
|
||||||
|
thing on this list.</p>
|
||||||
|
${table(["How", "Reaches", "How often"], panicReliefRows)}
|
||||||
|
|
||||||
|
<p><strong>Occult is the odd one.</strong> Naming a thing correctly, out loud, helps
|
||||||
|
everybody who can hear you — which is the department's actual work, and the only relief
|
||||||
|
that reaches a whole room. <strong>Psychology</strong> is the only one that steadies
|
||||||
|
somebody who is not you. <strong>Religion</strong> steadies you and nobody else, and it
|
||||||
|
does not care whether you are right.</p>
|
||||||
|
|
||||||
|
<h2>Breaking</h2>
|
||||||
|
<p>At ${PANIC_MAX} you break. Choose one — <strong>${PANIC_BREAK.join(", ")}</strong> —
|
||||||
|
take it, and come back at <strong>${PANIC_AFTER_BREAK}</strong>. You do not come back at
|
||||||
|
zero. Breaking is not a rest.</p>
|
||||||
|
|
||||||
|
<p>Panic clears when the scene does. When you are somewhere safe with the door shut and
|
||||||
|
somebody has made tea, you are at nought again.</p>` },
|
||||||
|
|
||||||
{ name: "5 · The Quiet Arts", content: `
|
{ name: "5 · The Quiet Arts", content: `
|
||||||
<p><em>Some agents come back with something. It is not a gift and it is not
|
<p><em>Some agents come back with something. It is not a gift and it is not
|
||||||
training — it is the crossing, left in a person.</em></p>
|
training — it is the crossing, left in a person.</em></p>
|
||||||
@@ -730,8 +777,8 @@ ${table(["Location", "Share", "Capacity"], [
|
|||||||
|
|
||||||
<h2>Rolls that ignore your skill</h2>
|
<h2>Rolls that ignore your skill</h2>
|
||||||
${table(["Roll", "Result"], [
|
${table(["Roll", "Result"], [
|
||||||
["96–99", "always fails, whatever your skill"],
|
["96–99", "never succeeds, whatever your skill — and is a fumble if it is inside your fumble range"],
|
||||||
["00", "always a fumble"],
|
["00", "always a fumble, whatever your skill"],
|
||||||
["01", "always at least a critical chance — the floor is 1%"]
|
["01", "always at least a critical chance — the floor is 1%"]
|
||||||
])}
|
])}
|
||||||
|
|
||||||
|
|||||||