Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7ec7efd29b | ||
|
|
5aaae186e0 | ||
|
|
20306d83d9 | ||
|
|
cbad87ba98 | ||
|
|
ab619f4b73 | ||
|
|
9b6c61ffcb | ||
|
|
0852427586 | ||
|
|
ad771ff2b5 | ||
|
|
53481bdfc1 |
@@ -28,7 +28,8 @@ git clone https://gitea.timevans.uk/slaguru666/RingBRP.git ringbrp
|
||||
```
|
||||
|
||||
Requires **Foundry v13+** (verified against v14.364). No modules are required. Dice So
|
||||
Nice is supported but optional — the system registers four of its own dice textures.
|
||||
Nice works if you have it, using your own dice — the system ships no dice skins and does
|
||||
not touch anybody's dice settings.
|
||||
|
||||
### Deploying to a remote Foundry server
|
||||
|
||||
@@ -47,7 +48,7 @@ packs or regenerate art on that machine:
|
||||
```bash
|
||||
npm install # pulls classic-level, used to write the LevelDB packs
|
||||
npm run build # rebuilds packs/ from tools/content.mjs
|
||||
npm run check # the three guards; the build refuses to run if they fail
|
||||
npm run check # the five guards; the build refuses to run if they fail
|
||||
```
|
||||
|
||||
To update a deployed server: `git pull` and restart Foundry. If the pull touches
|
||||
@@ -64,13 +65,13 @@ changing underneath a running server.
|
||||
| **Postings** | 25, each with a key characteristic, trained skills, talents and its own kit |
|
||||
| **Trades** | 10 ordinary jobs the department recruits from, plus induction |
|
||||
| **Catalogue** | 236 items — 123 gear, 62 weapons, 32 armour, 19 vehicles |
|
||||
| **Skills / talents** | 59 skills, 19 talents |
|
||||
| **Skills / talents** | 62 skills, 19 talents |
|
||||
| **Bestiary** | 21 statblocks across folklore, horror and the far side |
|
||||
| **Eras** | modern, antique, future, anomalous — the agency is old and never abolishes anything |
|
||||
| **Icons** | 292, all generated from `tools/icons.mjs`; no external art |
|
||||
| **Starter scenario** | **BLACK PLATFORM**, a six-player action starter, importable as a single Adventure |
|
||||
| **Compendia** | pregens, NPCs, skills, weapons, armour, gear, vehicles, powers, tables, rules, the starter |
|
||||
| **Rules journal** | 12 pages, generated from `rules.mjs` so the book cannot drift from the code |
|
||||
| **Rules journal** | 13 pages, generated from `rules.mjs` so the book cannot drift from the code |
|
||||
<!-- box:end -->
|
||||
|
||||
---
|
||||
@@ -152,13 +153,12 @@ template.json data model for every Actor and Item type
|
||||
templates/ Handlebars sheets
|
||||
styles/ one stylesheet
|
||||
lang/en.json all user-facing text
|
||||
icons/ dice/ fonts/ generated art
|
||||
icons/ fonts/ art/ generated art
|
||||
packs/ built LevelDB compendia (committed — see Deploying)
|
||||
tools/
|
||||
content.mjs the catalogue: skills, weapons, armour, gear, vehicles, NPCs
|
||||
build-packs.mjs builds packs/ — runs the three guards first and refuses on failure
|
||||
build-packs.mjs builds packs/ — runs the five guards first and refuses on failure
|
||||
make-icons.mjs draws all 231 icons
|
||||
make-dice-textures.mjs
|
||||
rules-text.mjs generates the rules journal FROM rules.mjs
|
||||
mission.mjs the case generator
|
||||
mission-tables.mjs its content tables
|
||||
@@ -176,13 +176,13 @@ stops being identity-equal to what it aliases, if a constant is re-declared as a
|
||||
literal, or if an exported rule has no spot-check. The packs used to be built under
|
||||
one set of numbers and played under another; this makes that impossible to ship.
|
||||
|
||||
The build runs all three guards before it writes anything:
|
||||
The build runs all five guards before it writes anything:
|
||||
|
||||
<!-- guards:start -->
|
||||
```
|
||||
check-rules: OK — 87 rules, 21 files scanned, 3 aliases + 41 constant sets checked, 275 formulas verified
|
||||
check-rules: OK — 92 rules, 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-lang: OK — en.json, 945 keys, no leaf/branch collisions
|
||||
check-behaviour: OK — 45 behavioural tests
|
||||
check-lang: OK — en.json, 977 keys, no leaf/branch collisions
|
||||
check-behaviour: OK — 57 behavioural tests
|
||||
```
|
||||
<!-- 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
|
||||
npm install # classic-level, for writing LevelDB
|
||||
npm run check # the three guards
|
||||
npm run check # the five guards
|
||||
npm run build # rebuilds packs/
|
||||
node tools/make-icons.mjs # redraws all icons
|
||||
node tools/make-dice-textures.mjs # regenerates the Dice So Nice textures
|
||||
```
|
||||
|
||||
`npm run build` runs the guards first and aborts if any fails, so a broken catalogue
|
||||
|
||||
@@ -426,6 +426,13 @@ If there is time — and there usually is, because this ends early — she says
|
||||
|
||||
## Artwork
|
||||
|
||||
**Showing them at the table.** The adventure ships a third journal,
|
||||
**LAST ADMISSION — Show the players**, holding these as eight image pages at player
|
||||
visibility. Right-click a page and *Show Players*: the table gets the picture and
|
||||
nothing else — no heading, no caption, no label naming what they are looking at. The
|
||||
page names are for the GM's sidebar and the players never see them.
|
||||
|
||||
|
||||
**Style line:** flat editorial vector, deep navy and pale yellow with warm off-white,
|
||||
1970s British public-information poster, `--style raw --ar 3:2 --v 6`.
|
||||
|
||||
|
||||
@@ -943,5 +943,37 @@
|
||||
"RINGBRP.Item.ArtRating": "Rating",
|
||||
"RINGBRP.Item.PushCost": "Push cost",
|
||||
"RINGBRP.Item.PushRule": "What pushing does",
|
||||
"RINGBRP.Item.Origin": "Where it came from"
|
||||
"RINGBRP.Item.Origin": "Where it came from",
|
||||
"RINGBRP.Panic.Label": "Panic",
|
||||
"RINGBRP.Panic.Steady": "Steady",
|
||||
"RINGBRP.Panic.SteadyNote": "You are all right. You are doing the job.",
|
||||
"RINGBRP.Panic.Rattled": "Rattled",
|
||||
"RINGBRP.Panic.RattledNote": "Your hands are not quite steady. No penalty — the first one is free.",
|
||||
"RINGBRP.Panic.Shaken": "Shaken",
|
||||
"RINGBRP.Panic.ShakenNote": "−5% to everything. You keep checking the way you came in.",
|
||||
"RINGBRP.Panic.Losing": "Losing it",
|
||||
"RINGBRP.Panic.LosingNote": "−10% to everything. You are talking too fast and not hearing answers.",
|
||||
"RINGBRP.Panic.Overwhelmed": "Overwhelmed",
|
||||
"RINGBRP.Panic.OverwhelmedNote": "−20% to everything. One more and you go.",
|
||||
"RINGBRP.Panic.Broken": "Broken",
|
||||
"RINGBRP.Panic.BrokenNote": "You freeze, run, or go at it. Choose one, take it, and come back at 3.",
|
||||
"RINGBRP.Panic.Trig.WrongThing": "Something that should not be there",
|
||||
"RINGBRP.Panic.Trig.Touched": "It touched you",
|
||||
"RINGBRP.Panic.Trig.Body": "A body",
|
||||
"RINGBRP.Panic.Trig.Known": "Somebody you knew",
|
||||
"RINGBRP.Panic.Trig.Dark": "The lights went",
|
||||
"RINGBRP.Panic.Trig.Wounded": "A major wound",
|
||||
"RINGBRP.Panic.Trig.Named": "It said your name",
|
||||
"RINGBRP.Panic.Relief.Steady": "Steady yourself (POW×5)",
|
||||
"RINGBRP.Panic.Relief.Religion": "Religion — something older than the department",
|
||||
"RINGBRP.Panic.Relief.Psychology": "Psychology — talk somebody else down",
|
||||
"RINGBRP.Panic.Relief.Occult": "Occult — name it, out loud, for everyone who can hear",
|
||||
"RINGBRP.Panic.Relief.Anchor": "Your anchor, once per case",
|
||||
"RINGBRP.Panic.Gained": "{name} — panic {from} → {to}",
|
||||
"RINGBRP.Panic.Eased": "{name} — panic {from} → {to}",
|
||||
"RINGBRP.Panic.Breaks": "BREAKS — freeze, flee, or fixate. Choose, take it, come back at 3.",
|
||||
"RINGBRP.Panic.NoChange": "No change.",
|
||||
"RINGBRP.Flavour.Panicking": "panicking ({mod}%)",
|
||||
"RINGBRP.Chat.ToEverything": "to everything",
|
||||
"RINGBRP.Panic.Backfired": "Fumbled —"
|
||||
}
|
||||
|
||||
@@ -6,7 +6,6 @@
|
||||
"scripts": {
|
||||
"build": "node tools/build-packs.mjs && node tools/update-readme.mjs",
|
||||
"icons": "node tools/make-icons.mjs",
|
||||
"dice": "node tools/make-dice-textures.mjs",
|
||||
"check": "node tools/check-rules.mjs && node tools/check-kits.mjs && node tools/check-lang.mjs && node tools/check-templates.mjs && node tools/check-behaviour.mjs",
|
||||
"test": "npm run check",
|
||||
"readme": "node tools/update-readme.mjs"
|
||||
|
||||
@@ -1 +1 @@
|
||||
2026/08/12-19:01:54.231003 174603000 Delete type=3 #1
|
||||
2026/08/17-23:31:35.181337 1726ef000 Delete type=3 #1
|
||||
|
||||
@@ -1 +1 @@
|
||||
2026/08/12-19:01:54.298648 173df7000 Delete type=3 #1
|
||||
2026/08/17-23:31:35.256758 173707000 Delete type=3 #1
|
||||
|
||||
@@ -1 +1 @@
|
||||
2026/08/12-19:01:54.232848 174603000 Delete type=3 #1
|
||||
2026/08/17-23:31:35.183232 171ee3000 Delete type=3 #1
|
||||
|
||||
@@ -1 +1 @@
|
||||
2026/08/12-19:01:54.282484 173df7000 Delete type=3 #1
|
||||
2026/08/17-23:31:35.237581 171ee3000 Delete type=3 #1
|
||||
|
||||
@@ -1 +1 @@
|
||||
2026/08/12-19:01:54.266480 174603000 Delete type=3 #1
|
||||
2026/08/17-23:31:35.219821 173707000 Delete type=3 #1
|
||||
|
||||
@@ -1 +1 @@
|
||||
2026/08/12-19:01:54.293373 173df7000 Delete type=3 #1
|
||||
2026/08/17-23:31:35.250188 1726ef000 Delete type=3 #1
|
||||
|
||||
@@ -1 +1 @@
|
||||
2026/08/12-19:01:54.252536 174603000 Delete type=3 #1
|
||||
2026/08/17-23:31:35.204640 173707000 Delete type=3 #1
|
||||
|
||||
@@ -1 +1 @@
|
||||
2026/08/12-19:01:54.290196 172ddf000 Delete type=3 #1
|
||||
2026/08/17-23:31:35.246285 171ee3000 Delete type=3 #1
|
||||
|
||||
@@ -1 +1 @@
|
||||
2026/08/12-19:01:54.286378 174603000 Delete type=3 #1
|
||||
2026/08/17-23:31:35.242302 172efb000 Delete type=3 #1
|
||||
|
||||
@@ -1 +1 @@
|
||||
2026/08/12-19:01:54.297168 1735eb000 Delete type=3 #1
|
||||
2026/08/17-23:31:35.254558 1726ef000 Delete type=3 #1
|
||||
|
||||
@@ -1 +1 @@
|
||||
2026/08/12-19:01:54.224954 172ddf000 Delete type=3 #1
|
||||
2026/08/17-23:31:35.174562 171ee3000 Delete type=3 #1
|
||||
|
||||
@@ -1 +1 @@
|
||||
2026/08/12-19:01:54.291920 1735eb000 Delete type=3 #1
|
||||
2026/08/17-23:31:35.248311 1726ef000 Delete type=3 #1
|
||||
|
||||
@@ -1 +1 @@
|
||||
2026/08/12-19:01:54.276268 174603000 Delete type=3 #1
|
||||
2026/08/17-23:31:35.230398 172efb000 Delete type=3 #1
|
||||
|
||||
@@ -1 +1 @@
|
||||
2026/08/12-19:01:54.270666 173df7000 Delete type=3 #1
|
||||
2026/08/17-23:31:35.224441 1726ef000 Delete type=3 #1
|
||||
|
||||
@@ -1 +1 @@
|
||||
2026/08/12-19:01:54.227877 174603000 Delete type=3 #1
|
||||
2026/08/17-23:31:35.177864 1726ef000 Delete type=3 #1
|
||||
|
||||
@@ -1 +1 @@
|
||||
2026/08/12-19:01:54.234705 1735eb000 Delete type=3 #1
|
||||
2026/08/17-23:31:35.185513 171ee3000 Delete type=3 #1
|
||||
|
||||
@@ -1 +1 @@
|
||||
2026/08/12-19:01:54.229633 174603000 Delete type=3 #1
|
||||
2026/08/17-23:31:35.179597 1726ef000 Delete type=3 #1
|
||||
|
||||
@@ -60,7 +60,7 @@ export const ROLES = {
|
||||
|
||||
registry: { label: "Registry Officer",
|
||||
blurb: "Keeps the record that a crossing is made against. Knows what the file says and, more usefully, what it used to say.", stat: "edu",
|
||||
core: ["research","knowledge","anomaly_lore","language:own","computer_use","insight"],
|
||||
core: ["occult","knowledge","anomaly_lore","language:own","computer_use","insight"],
|
||||
support: ["spot","art","first_aid","tradecraft","firearm:pistol"],
|
||||
talents: ["chain_custody","wrong_memory","debriefer","quorum"],
|
||||
kit: ["spectacles","notebook","handset","jacket","pistol_light", "carbon_book", "date_stamp", "seals"] },
|
||||
@@ -82,7 +82,7 @@ export const ROLES = {
|
||||
interview: { label: "Interview Officer",
|
||||
blurb: "Sits with the witness afterwards. Patient, unhurried, and very hard to lie to twice.", stat: "cha",
|
||||
core: ["insight","persuade","listen","language:own","anthropology"],
|
||||
support: ["fast_talk","spot","tradecraft","first_aid","firearm:pistol"],
|
||||
support: ["psychology","spot","tradecraft","first_aid","firearm:pistol"],
|
||||
talents: ["debriefer","read_room","wrong_memory","unblinking"],
|
||||
kit: ["dictaphone","cover_id","notebook","jacket","pistol_light", "carbon_book", "vacuum_flask", "clipboard"] },
|
||||
|
||||
@@ -110,7 +110,7 @@ export const ROLES = {
|
||||
pathology: { label: "Pathology Officer",
|
||||
blurb: "Examines what is left. Establishes what a thing was, which is usually the question the case turns on.", stat: "int",
|
||||
core: ["medicine","science:biology","xenology:baseline","insight","research"],
|
||||
support: ["first_aid","spot","anthropology","dodge","melee_weapon:knife"],
|
||||
support: ["first_aid","spot","religion","dodge","melee_weapon:knife"],
|
||||
talents: ["triage","chain_custody","unblinking","clean_scene"],
|
||||
kit: ["chirurgeon_kit","gas_mask","envsuit","sample_case","dagger", "specimen_jars", "swab_kit", "barrier_gloves"] },
|
||||
|
||||
@@ -172,7 +172,7 @@ export const ROLES = {
|
||||
|
||||
antiquities: { label: "Antiquities Officer",
|
||||
blurb: "An archaic posting the department never abolished. Old objects, older records, and the manners to ask about both.", stat: "edu",
|
||||
core: ["knowledge","art","anomaly_lore","research","status"],
|
||||
core: ["religion","art","anomaly_lore","research","status"],
|
||||
support: ["insight","spot","first_aid","melee_weapon:sword","firearm:pistol"],
|
||||
talents: ["chain_custody","wrong_memory","borrowed_hour","quorum"],
|
||||
kit: ["pocket_watch","tallow_candle","duelling_sword","brigandine","flintlock", "quill_ink", "sealing_wax", "sand_glass"] },
|
||||
@@ -231,13 +231,13 @@ export const TRADES = {
|
||||
doctor: { label: "Doctor",
|
||||
blurb: "A&E, mostly. Used to people at their worst and to being the calmest thing in the room.", stat: "edu",
|
||||
core: ["medicine","first_aid","science:biology","insight","knowledge"],
|
||||
support: ["research","status","persuade","listen","spot"],
|
||||
support: ["research","psychology","persuade","listen","spot"],
|
||||
talents: ["triage","debriefer","unblinking","clean_scene"],
|
||||
kit: ["medkit","trauma_unit","analgesics","specimen_jars","swab_kit"] },
|
||||
|
||||
professor: { label: "Professor",
|
||||
blurb: "A chair in something narrow, and a reading list nobody else has finished. Argues for a living.", stat: "edu",
|
||||
core: ["knowledge","research","anthropology","language:own","status"],
|
||||
core: ["knowledge","research","occult","language:own","status"],
|
||||
support: ["insight","persuade","science:botany","listen","art"],
|
||||
talents: ["debriefer","read_room","requisition","wrong_memory"],
|
||||
kit: ["carbon_book","notebook","spectacles","clipboard","quill_ink"] },
|
||||
|
||||
@@ -50,7 +50,9 @@ import {
|
||||
ARTS, powerCostFor, powerStartingRating,
|
||||
CROSSING, crossingArtPoolFor, crossingGrantsArt, crossingCostFor, REACTION,
|
||||
CIRCUMSTANCE, RANGE_LADDER, rangeBandFrom, MAJOR_WOUND_TABLE,
|
||||
COHERENCE_BANDS, COHERENCE_COSTS, COHERENCE_RECOVERY, coherenceBandFrom
|
||||
COHERENCE_BANDS, COHERENCE_COSTS, COHERENCE_RECOVERY, coherenceBandFrom,
|
||||
PANIC_MAX, PANIC_BANDS, PANIC_TRIGGERS, PANIC_RELIEF, PANIC_BREAK, PANIC_AFTER_BREAK,
|
||||
panicBandFrom, panicPenaltyFor, panicBreaksAt, panicAfter, panicReliefFor
|
||||
} from "./rules.mjs";
|
||||
export { hitPointsFor, majorWoundFor, damageModifierFor, locationMaxHp, reflexRating, awarenessRating };
|
||||
|
||||
@@ -1030,6 +1032,17 @@ export async function skillRoll({
|
||||
flavourLines = [...flavourLines, game.i18n.format("RINGBRP.Flavour.Encumbered", { mod: enc })];
|
||||
}
|
||||
}
|
||||
// Panic is the short track and it bites on everything, so it sits in the same stack
|
||||
// the wound and encumbrance penalties already use — anything that computes a modifier
|
||||
// and is not added here is a modifier nothing reads, which is this project's oldest
|
||||
// way of shipping a rule that does not exist.
|
||||
if (actor) {
|
||||
const pan = panicPenaltyFor(actor.system?.panic?.value);
|
||||
if (pan) {
|
||||
situational = (Number(situational) || 0) + pan;
|
||||
flavourLines = [...flavourLines, game.i18n.format("RINGBRP.Flavour.Panicking", { mod: pan })];
|
||||
}
|
||||
}
|
||||
if (actor && !skipCoherence) {
|
||||
const coh = coherencePenaltyFor(actor.system?.coherence?.value, categoryId);
|
||||
if (coh) {
|
||||
@@ -1075,7 +1088,6 @@ export async function skillRoll({
|
||||
}
|
||||
);
|
||||
|
||||
rbDressDice(roll, actor);
|
||||
await roll.toMessage({
|
||||
speaker: speaker ?? ChatMessage.getSpeaker({ actor }),
|
||||
content,
|
||||
@@ -1108,7 +1120,7 @@ export async function majorWoundRoll({ actor, woundType = "blunt", monofilament
|
||||
console.error("Custodians | majorWoundRoll called without an actor");
|
||||
return null;
|
||||
}
|
||||
const r = rbDressDice(await new Roll("1d10").evaluate(), actor);
|
||||
const r = await new Roll("1d10").evaluate();
|
||||
let result = r.total;
|
||||
if (monofilament && result >= 4) result = 10; // spec §3.3 shift
|
||||
else if (blunt && result === 10) result = 9;
|
||||
@@ -1171,7 +1183,7 @@ export async function hitLocationRoll({ target, damage, mode = "ranged",
|
||||
}
|
||||
const sys = target.system;
|
||||
const speciesId = sys.speciesProfile || "baseline";
|
||||
const r = rbDressDice(await new Roll("1d20").evaluate(), target);
|
||||
const r = await new Roll("1d20").evaluate();
|
||||
const loc = locationFor(r.total, speciesId, mode);
|
||||
const live = (sys.locations ?? []).find(l => l.id === loc.id);
|
||||
const max = live?.max ?? locationMaxHp(sys.hp?.max ?? 1, loc.frac);
|
||||
@@ -1280,7 +1292,7 @@ export async function rollReaction(combatant) {
|
||||
: reactionPrintedFrom(rb.base, f.loadOverride);
|
||||
const mod = reactionModifier({ aim: f.aim ?? 0, edge: !!f.edge, drag: !!f.drag });
|
||||
|
||||
const roll = rbDressDice(await new Roll(`1d${RINGBRP.reaction.die}`).evaluate(), actor);
|
||||
const roll = await new Roll(`1d${RINGBRP.reaction.die}`).evaluate();
|
||||
const raw = roll.total;
|
||||
const coherence = actor.system?.coherence?.value ?? 10;
|
||||
const shown = fluxDie(raw, coherence);
|
||||
@@ -1535,6 +1547,71 @@ export async function loseCoherence(actor, reasonKey, { amount = null, note = ""
|
||||
return bAfter;
|
||||
}
|
||||
|
||||
RINGBRP.panicBands = PANIC_BANDS;
|
||||
RINGBRP.panicTriggers = PANIC_TRIGGERS;
|
||||
RINGBRP.panicRelief = PANIC_RELIEF;
|
||||
|
||||
export const panicBand = panicBandFrom;
|
||||
|
||||
/**
|
||||
* Take a point of panic. No roll: a die to find out whether you are frightened slows
|
||||
* the scene and tells the player how to feel. The die is for getting rid of it.
|
||||
*/
|
||||
export async function gainPanic(actor, triggerKey, { amount = null, note = "" } = {}) {
|
||||
if (!actor) return null;
|
||||
const spec = PANIC_TRIGGERS[triggerKey];
|
||||
const cost = amount ?? spec?.cost ?? 1;
|
||||
const before = Number(actor.system?.panic?.value ?? 0);
|
||||
const after = panicAfter(before, cost);
|
||||
const log = [...(actor.system?.panic?.log ?? []), { reason: triggerKey, cost, note }];
|
||||
await actor.update({ "system.panic.value": after, "system.panic.log": log });
|
||||
await panicCard(actor, before, after, game.i18n.localize(String(spec?.label ?? triggerKey ?? "")), note);
|
||||
return panicBandFrom(after);
|
||||
}
|
||||
|
||||
/**
|
||||
* Put a point back. `level` is the outcome of whatever was rolled — a special takes two
|
||||
* — so every relief in PANIC_RELIEF runs through one function and cannot drift.
|
||||
*/
|
||||
export async function easePanic(actor, reliefKey, level = "success", { note = "" } = {}) {
|
||||
if (!actor) return null;
|
||||
const spec = PANIC_RELIEF[reliefKey];
|
||||
const back = panicReliefFor(level);
|
||||
const before = Number(actor.system?.panic?.value ?? 0);
|
||||
const after = panicAfter(before, -back);
|
||||
await actor.update({ "system.panic.value": after });
|
||||
// A fumble comes back as negative relief, so the card has to say so — otherwise it
|
||||
// reads "Psychology" over a number that just went up, and the table reads it as a bug.
|
||||
await panicCard(actor, before, after, game.i18n.localize(String(spec?.label ?? reliefKey ?? "")),
|
||||
note, { backfired: back < 0 });
|
||||
return panicBandFrom(after);
|
||||
}
|
||||
|
||||
/** Breaking spends the panic: you come back at 3, because it is not a rest. */
|
||||
export async function breakPanic(actor, choice = "freeze") {
|
||||
if (!actor) return null;
|
||||
const before = Number(actor.system?.panic?.value ?? 0);
|
||||
const after = PANIC_AFTER_BREAK;
|
||||
await actor.update({ "system.panic.value": after });
|
||||
await panicCard(actor, before, after,
|
||||
game.i18n.localize("RINGBRP.Panic.Breaks"),
|
||||
PANIC_BREAK.includes(choice) ? choice : "freeze");
|
||||
return panicBandFrom(after);
|
||||
}
|
||||
|
||||
async function panicCard(actor, before, after, reason, note, { backfired = false } = {}) {
|
||||
const band = panicBandFrom(after);
|
||||
await ChatMessage.create({
|
||||
speaker: ChatMessage.getSpeaker({ actor }),
|
||||
content: await rbRenderTemplate("systems/ringbrp/templates/chat-panic.hbs", {
|
||||
actorName: actor.name, before, after, reason, note,
|
||||
band, breaks: panicBreaksAt(after), penalty: panicPenaltyFor(after), backfired,
|
||||
choices: PANIC_BREAK,
|
||||
pips: Array.from({ length: PANIC_MAX }, (_, i) => i < after)
|
||||
})
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Displacement. Reaching zero is not a slow slide to be nursed back; it is an
|
||||
* event. The character does not die — they go home to an Earth that is almost
|
||||
@@ -2384,7 +2461,7 @@ export async function fall(actor, metres, { soft = false, controlled = false } =
|
||||
content: `<div class="ringbrp-chat">${game.i18n.format("RINGBRP.Fall.Short", { m: metres })}</div>` });
|
||||
return { damage: 0 };
|
||||
}
|
||||
const roll = rbDressDice(await new Roll(plan.formula).evaluate(), actor);
|
||||
const roll = await new Roll(plan.formula).evaluate();
|
||||
await roll.toMessage({ speaker: ChatMessage.getSpeaker({ actor }),
|
||||
flavor: game.i18n.format("RINGBRP.Fall.Flavor", { m: metres, dice: plan.formula }) });
|
||||
// armourApplied true: the fall ignores armour, and this is how we say so.
|
||||
@@ -2417,7 +2494,7 @@ export async function asphyxiate(actor, { held = 0, prepared = true } = {}) {
|
||||
export async function burn(actor, bandId = "clothes", { round = 1 } = {}) {
|
||||
if (!actor) return null;
|
||||
const band = fireBandFor(bandId);
|
||||
const roll = rbDressDice(await new Roll(band.damage).evaluate(), actor);
|
||||
const roll = await new Roll(band.damage).evaluate();
|
||||
// Armour on the round you catch, and never after. A sealed suit is the exception
|
||||
// and the GM should say so by running it as a `spark` that never spreads.
|
||||
const armour = (band.armour && round === 1) ? (Number(actor.system.armourPoints) || 0) : 0;
|
||||
@@ -2436,7 +2513,7 @@ export async function burn(actor, bandId = "clothes", { round = 1 } = {}) {
|
||||
* armour is only ever half of one, at contact.
|
||||
*/
|
||||
export async function detonate({ formula = "4d6", radius = 6, targets = [] } = {}) {
|
||||
const roll = rbDressDice(await new Roll(formula).evaluate(), null);
|
||||
const roll = await new Roll(formula).evaluate();
|
||||
await roll.toMessage({ flavor: game.i18n.format("RINGBRP.Blast.Flavor",
|
||||
{ formula, r: radius }) });
|
||||
const out = [];
|
||||
@@ -4292,7 +4369,7 @@ export async function rollCharacteristicsDialog({ actorName = "" } = {}) {
|
||||
const rollOne = async k => {
|
||||
const cell = el.querySelector(`tr[data-ch="${k}"] .rb-roll-value`);
|
||||
if (!cell || cell.dataset.value) return; // rolled is rolled
|
||||
const roll = rbDressDice(await new Roll(CHARACTERISTIC_DICE[k]).evaluate());
|
||||
const roll = await new Roll(CHARACTERISTIC_DICE[k]).evaluate();
|
||||
await roll.toMessage({
|
||||
flavor: game.i18n.format("RINGBRP.Gen.RolledChar",
|
||||
{ ch: game.i18n.localize(`RINGBRP.Char.${k}`) })
|
||||
@@ -4538,90 +4615,6 @@ Hooks.on("preCreateItem", (item, data) => {
|
||||
* The colourset is chosen per roll from the roller's Coherence, so the dice
|
||||
* themselves report the one number the players are trying not to look at.
|
||||
* ============================================================ */
|
||||
const RB_DICE = {
|
||||
textures: {
|
||||
"custodians-registry": { name: "Registry", file: "custodians-registry.png" },
|
||||
"custodians-drift": { name: "Drift", file: "custodians-drift.png" },
|
||||
"custodians-displaced": { name: "Displaced", file: "custodians-displaced.png" },
|
||||
"custodians-agency": { name: "Agency", file: "custodians-agency.png" }
|
||||
},
|
||||
// one colourset per Coherence band, plus the plain agency set
|
||||
sets: [
|
||||
{ name: "custodians-anchored", description: "Custodians — Anchored",
|
||||
foreground: "#f2ea72", background: "#18268d", outline: "#0c125b",
|
||||
edge: "#6674d8", texture: "custodians-registry", material: "metal" },
|
||||
{ name: "custodians-loose", description: "Custodians — Loose",
|
||||
foreground: "#f2ea72", background: "#16215f", outline: "#0c125b",
|
||||
edge: "#6674d8", texture: "custodians-registry", material: "plastic" },
|
||||
{ name: "custodians-adrift", description: "Custodians — Adrift",
|
||||
foreground: "#ecefee", background: "#141e78", outline: "#0c125b",
|
||||
edge: "#7a6cc8", texture: "custodians-drift", material: "plastic" },
|
||||
{ name: "custodians-unmoored", description: "Custodians — Unmoored",
|
||||
foreground: "#ff7a5c", background: "#101a6e", outline: "#08103f",
|
||||
edge: "#ff7a5c", texture: "custodians-drift", material: "glass" },
|
||||
{ name: "custodians-displaced", description: "Custodians — Displaced",
|
||||
foreground: "#c9b6ff", background: "#0c0e3c", outline: "#000000",
|
||||
edge: "#8a6cc0", texture: "custodians-displaced", material: "glass" },
|
||||
{ name: "custodians-agency", description: "Custodians — Agency issue",
|
||||
foreground: "#f2ea72", background: "#16215f", outline: "#0c125b",
|
||||
edge: "#a8b2e3", texture: "custodians-agency", material: "metal" }
|
||||
]
|
||||
};
|
||||
|
||||
/** Which set an agent rolls, from the band they are in. */
|
||||
export function diceSetFor(actor) {
|
||||
const band = coherenceBand(actor?.system?.coherence?.value ?? 10);
|
||||
return `custodians-${band?.id ?? "anchored"}`;
|
||||
}
|
||||
|
||||
/** Tag a Roll so Dice So Nice draws it in the roller's colours. */
|
||||
function rbDressDice(roll, actor) {
|
||||
if (!roll?.dice?.length) return roll;
|
||||
const colorset = diceSetFor(actor);
|
||||
for (const d of roll.dice) {
|
||||
d.options.colorset = colorset;
|
||||
d.options.appearance = { ...(d.options.appearance ?? {}), colorset };
|
||||
}
|
||||
return roll;
|
||||
}
|
||||
|
||||
Hooks.once("diceSoNiceReady", async (dice3d) => {
|
||||
try {
|
||||
dice3d.addSystem({ id: "custodians", name: "The Custodians" }, "preferred");
|
||||
|
||||
// addTexture returns a Promise and the colourset that references a texture must
|
||||
// not be registered before it resolves, or the die renders untextured.
|
||||
await Promise.all(Object.entries(RB_DICE.textures).map(([id, t]) =>
|
||||
dice3d.addTexture(id, {
|
||||
name: t.name, composite: "source-over",
|
||||
source: `systems/ringbrp/dice/${t.file}`
|
||||
})));
|
||||
|
||||
for (const set of RB_DICE.sets) {
|
||||
dice3d.addColorset({
|
||||
...set, category: "The Custodians",
|
||||
font: "Michroma", visibility: "visible"
|
||||
}, set.name === "custodians-anchored" ? "default" : "showcase");
|
||||
}
|
||||
|
||||
// The percentile pair is the game's core roll, so it is the one worth dressing.
|
||||
dice3d.addDicePreset({
|
||||
type: "d100", system: "custodians",
|
||||
labels: ["00", "10", "20", "30", "40", "50", "60", "70", "80", "90"]
|
||||
});
|
||||
dice3d.addDicePreset({ type: "d10", system: "custodians",
|
||||
labels: ["0", "1", "2", "3", "4", "5", "6", "7", "8", "9"] });
|
||||
for (const t of ["d4", "d6", "d8", "d12", "d20"]) {
|
||||
const n = Number(t.slice(1));
|
||||
dice3d.addDicePreset({ type: t, system: "custodians",
|
||||
labels: Array.from({ length: n }, (_, i) => String(i + 1)) });
|
||||
}
|
||||
console.log("Custodians | dice registered with Dice So Nice");
|
||||
} catch (err) {
|
||||
console.warn("Custodians | could not register dice", err);
|
||||
}
|
||||
});
|
||||
|
||||
Hooks.on("renderItemDirectory", (app, html) => {
|
||||
const root = html instanceof HTMLElement ? html : html?.[0];
|
||||
if (!root || !game.user.isGM || root.querySelector(".rb-genbar")) return;
|
||||
@@ -4839,6 +4832,9 @@ class RingBRPCharacterSheet extends ActorSheet {
|
||||
ctx.reaction = this._reactionRail();
|
||||
ctx.readiness = this._readiness ?? { rating: 0 };
|
||||
ctx.coherence = { band: coherenceBand(sys.coherence?.value) };
|
||||
ctx.panic = { band: panicBandFrom(sys.panic?.value),
|
||||
penalty: panicPenaltyFor(sys.panic?.value),
|
||||
pips: Array.from({ length: PANIC_MAX }, (_, i) => i < Number(sys.panic?.value ?? 0)) };
|
||||
ctx.readinessSource = ctx.readiness.best >= ctx.readiness.intFloor
|
||||
? game.i18n.localize("RINGBRP.React.ReadinessSkill")
|
||||
: game.i18n.localize("RINGBRP.React.ReadinessInt");
|
||||
@@ -4932,6 +4928,18 @@ class RingBRPCharacterSheet extends ActorSheet {
|
||||
if (item) await useDeviceFunction(this.actor, item, btn.dataset.fn);
|
||||
this.render(false);
|
||||
}));
|
||||
root.querySelectorAll("[data-action='panic-adjust']").forEach(el =>
|
||||
el.addEventListener("click", async ev => {
|
||||
ev.preventDefault(); ev.stopPropagation();
|
||||
const delta = Number(ev.currentTarget.dataset.delta) || 0;
|
||||
if (delta > 0) await gainPanic(this.actor, "wrongThing", { amount: 1 });
|
||||
else await easePanic(this.actor, "steadyYourself", "success");
|
||||
}));
|
||||
root.querySelectorAll("[data-action='panic-break']").forEach(el =>
|
||||
el.addEventListener("click", async ev => {
|
||||
ev.preventDefault(); ev.stopPropagation();
|
||||
await breakPanic(this.actor, ev.currentTarget.dataset.choice ?? "freeze");
|
||||
}));
|
||||
root.querySelectorAll("[data-action='coherence-adjust']").forEach(el =>
|
||||
el.addEventListener("click", async ev => {
|
||||
ev.preventDefault(); ev.stopPropagation();
|
||||
@@ -5875,7 +5883,6 @@ export async function rollWeaponDamage({ actor = null, weapon, level = "success"
|
||||
roll = await new Roll(usesDm ? formula + dm : formula).evaluate();
|
||||
}
|
||||
|
||||
rbDressDice(roll, actor);
|
||||
const note = level === "critical" ? game.i18n.localize("RINGBRP.Chat.DamageCritical")
|
||||
: level === "special" ? game.i18n.localize("RINGBRP.Chat.DamageSpecial")
|
||||
: "";
|
||||
@@ -6171,6 +6178,8 @@ Hooks.once("init", () => {
|
||||
reactionLoadFrom, reactionPrintedFrom,
|
||||
readinessRating, percentileFaces, circumstanceTotal,
|
||||
coherenceBand, coherencePenaltyFor, woundPenaltyFor, loseCoherence, regainCoherence, displace,
|
||||
panicBand, panicPenaltyFor, panicBreaksAt, panicReliefFor, panicAfter,
|
||||
gainPanic, easePanic, breakPanic,
|
||||
isAcross, setAcross, useTransposed, debrief, activeCaseFile, returnCrossing, caseMembers,
|
||||
earnedDiscoveries, deviceFunctionStatus, earnDiscovery, useDeviceFunction, pickContactActor,
|
||||
resolveContact, newEncounter, claimRewards, activeRewardEffects,
|
||||
@@ -6189,7 +6198,7 @@ Hooks.once("init", () => {
|
||||
usePower, grantPower, ARTS, powerCostFor, crossBack,
|
||||
generateMissionCase, missionDialog, salvageFrom,
|
||||
crossingArtPoolFor, crossingGrantsArt, crossingCostFor, returnCrossing,
|
||||
rollWeaponDamage, diceSetFor,
|
||||
rollWeaponDamage,
|
||||
restFor, endOfCase, gearBonusFor,
|
||||
hasTalent, talentAvailable, useTalent, requisition, armCleanScene, resetTalents,
|
||||
generateCharacter, generateNPC, generateDialog, installContent, grantFullSkillList,
|
||||
|
||||
@@ -263,6 +263,116 @@ export const COHERENCE_COSTS = {
|
||||
|
||||
export const COHERENCE_RECOVERY = { debrief: 1, downtimeWeek: 1, anchorObject: 1 };
|
||||
|
||||
/* ============================================================
|
||||
PANIC — the short track
|
||||
============================================================
|
||||
Coherence is the long one: 0-10, campaign-scale, what it costs to keep looking once
|
||||
you know what you are looking at, recovered at a debrief. Panic is the other half and
|
||||
they must not be confused at the table:
|
||||
|
||||
Coherence what knowing has cost you weeks 0-10, down
|
||||
Panic what is happening to you NOW minutes 0-5, up
|
||||
|
||||
Panic goes UP. A trigger gives a point with no roll — asking for a die to find out
|
||||
whether you are frightened is a die that slows the scene down and tells the player
|
||||
how to feel. The roll is to get RID of it, which is the interesting direction and the
|
||||
only one worth a check.
|
||||
|
||||
One free point on purpose: at Panic 1 you are rattled and roll at no penalty. It costs
|
||||
the table nothing and it means the first shock of a scene is colour rather than
|
||||
arithmetic.
|
||||
============================================================ */
|
||||
|
||||
export const PANIC_MAX = 5;
|
||||
|
||||
export const PANIC_BANDS = [
|
||||
{ min: 5, id: "broken", label: "RINGBRP.Panic.Broken", all: -20, breaks: true,
|
||||
note: "RINGBRP.Panic.BrokenNote" },
|
||||
{ min: 4, id: "overwhelmed", label: "RINGBRP.Panic.Overwhelmed", all: -20, breaks: false,
|
||||
note: "RINGBRP.Panic.OverwhelmedNote" },
|
||||
{ min: 3, id: "losing", label: "RINGBRP.Panic.Losing", all: -10, breaks: false,
|
||||
note: "RINGBRP.Panic.LosingNote" },
|
||||
{ min: 2, id: "shaken", label: "RINGBRP.Panic.Shaken", all: -5, breaks: false,
|
||||
note: "RINGBRP.Panic.ShakenNote" },
|
||||
{ min: 1, id: "rattled", label: "RINGBRP.Panic.Rattled", all: 0, breaks: false,
|
||||
note: "RINGBRP.Panic.RattledNote" },
|
||||
{ min: 0, id: "steady", label: "RINGBRP.Panic.Steady", all: 0, breaks: false,
|
||||
note: "RINGBRP.Panic.SteadyNote" }
|
||||
];
|
||||
|
||||
/** What a point costs. One each, so a GM never has to weigh anything mid-scene. */
|
||||
export const PANIC_TRIGGERS = {
|
||||
wrongThing: { cost: 1, label: "RINGBRP.Panic.Trig.WrongThing" },
|
||||
touchedByIt: { cost: 1, label: "RINGBRP.Panic.Trig.Touched" },
|
||||
aBody: { cost: 1, label: "RINGBRP.Panic.Trig.Body" },
|
||||
someoneYouKnew:{ cost: 2, label: "RINGBRP.Panic.Trig.Known" },
|
||||
theLightsWent: { cost: 1, label: "RINGBRP.Panic.Trig.Dark" },
|
||||
majorWound: { cost: 1, label: "RINGBRP.Panic.Trig.Wounded" },
|
||||
itSaidYourName:{ cost: 2, label: "RINGBRP.Panic.Trig.Named" }
|
||||
};
|
||||
|
||||
/**
|
||||
* What takes a point off, and what it is rolled against.
|
||||
*
|
||||
* `self` steadies you; `other` steadies somebody else, which is the whole reason
|
||||
* Psychology is worth carrying. `heard` reaches everyone who can hear it: naming a thing
|
||||
* correctly is the department's actual work, and it is worth a mechanical reward.
|
||||
* A special takes two.
|
||||
*/
|
||||
export const PANIC_RELIEF = {
|
||||
steadyYourself: { skill: null, reach: "self", once: "scene",
|
||||
label: "RINGBRP.Panic.Relief.Steady" },
|
||||
religion: { skill: "religion", reach: "self", once: "scene",
|
||||
label: "RINGBRP.Panic.Relief.Religion" },
|
||||
psychology: { skill: "psychology", reach: "other", once: "scene",
|
||||
label: "RINGBRP.Panic.Relief.Psychology" },
|
||||
occult: { skill: "occult", reach: "heard", once: "encounter",
|
||||
label: "RINGBRP.Panic.Relief.Occult" },
|
||||
anchorObject: { skill: null, reach: "self", once: "case",
|
||||
label: "RINGBRP.Panic.Relief.Anchor" }
|
||||
};
|
||||
|
||||
/** Breaking is a choice, not a table: three bad options, taken fast. */
|
||||
export const PANIC_BREAK = ["freeze", "flee", "fixate"];
|
||||
|
||||
/** Breaking spends the panic. You come back at 3, not at 0 — it is not a rest. */
|
||||
export const PANIC_AFTER_BREAK = 3;
|
||||
|
||||
export function panicBandFrom(value) {
|
||||
const v = Math.max(0, Math.min(PANIC_MAX, Number(value) || 0));
|
||||
return PANIC_BANDS.find(b => v >= b.min) ?? PANIC_BANDS.at(-1);
|
||||
}
|
||||
|
||||
/** The roll penalty a level of panic carries. Never positive. */
|
||||
export function panicPenaltyFor(value) {
|
||||
return Math.min(0, panicBandFrom(value).all);
|
||||
}
|
||||
|
||||
/** Does this level break the character this round? */
|
||||
export function panicBreaksAt(value) {
|
||||
return panicBandFrom(value).breaks === true;
|
||||
}
|
||||
|
||||
/** Clamped, so nothing can push the track past its own ends. */
|
||||
export function panicAfter(value, delta) {
|
||||
return Math.max(0, Math.min(PANIC_MAX, (Number(value) || 0) + (Number(delta) || 0)));
|
||||
}
|
||||
|
||||
/**
|
||||
* A success takes one point, a special or better takes two — and a FUMBLE puts one on.
|
||||
*
|
||||
* Returned as a negative relief so the whole thing stays one number and one direction:
|
||||
* easePanic subtracts what this gives it, so -1 subtracts a negative and the track goes
|
||||
* up. Trying to talk somebody down and making it worse is the most familiar thing in
|
||||
* this list, and until now it was the only outcome that cost nothing.
|
||||
*/
|
||||
export function panicReliefFor(level) {
|
||||
if (level === "critical" || level === "special") return 2;
|
||||
if (level === "success") return 1;
|
||||
if (level === "fumble") return -1;
|
||||
return 0;
|
||||
}
|
||||
|
||||
export function coherenceBandFrom(value) {
|
||||
const v = Math.max(0, Number(value) || 0);
|
||||
return COHERENCE_BANDS.find(b => v >= b.min) ?? COHERENCE_BANDS.at(-1);
|
||||
|
||||
@@ -1684,3 +1684,159 @@
|
||||
.rb-dying { border-left: 3px solid var(--rb-danger); padding-left: 8px; }
|
||||
.rb-stable { border-left: 3px solid var(--rb-yellow); padding-left: 8px; }
|
||||
.rb-dead { border-left: 3px solid var(--rb-white); padding-left: 8px; }
|
||||
|
||||
/* ============================================================
|
||||
THE PAUSE — a uniselector, stepping
|
||||
============================================================
|
||||
Foundry's pause is <figure id="pause"> holding an <img> and a <figcaption>, and core
|
||||
hides it with `#pause { display: none }` / shows it with `#pause.paused { display:
|
||||
flex }`. The first version of this styled the bare #pause with display:flex and so
|
||||
overrode the hide — the panel never went away when the game unpaused. EVERY rule here
|
||||
that could affect visibility or layout is therefore scoped to `.paused`, and `display`
|
||||
is not set anywhere.
|
||||
|
||||
Core also already centres it: a full-width band, 180px tall, at top calc(50vh - 100px),
|
||||
column flex, centred both ways, pointer-events none. That layout is correct and is
|
||||
left alone — only its size and its looks change here. The first version repositioned
|
||||
it with left/bottom and pushed it off centre for the trouble.
|
||||
|
||||
A uniselector is the stepping switch inside a telephone exchange: an arm that clicks
|
||||
round one contact at a time, hunting for a free line. The department's whole business
|
||||
is a machine that says where things belong, and OPEN DAY is set inside one. Eleven
|
||||
contacts, because eleven minutes is the interval this setting keeps returning to.
|
||||
|
||||
It STEPS rather than sweeps — a contact every 700ms. A continuous spin reads as a
|
||||
loading throbber; a discrete click reads as a machine working through a list.
|
||||
============================================================ */
|
||||
|
||||
/* Taller than core's 180px, and moved up by exactly half the extra so it stays centred. */
|
||||
#pause.paused {
|
||||
height: 300px;
|
||||
top: calc(50vh - 150px);
|
||||
gap: 28px;
|
||||
background: linear-gradient(to right,
|
||||
transparent 0%,
|
||||
color-mix(in srgb, var(--rb-void) 82%, transparent) 38%,
|
||||
color-mix(in srgb, var(--rb-void) 82%, transparent) 62%,
|
||||
transparent 100%);
|
||||
}
|
||||
|
||||
/* the dial: eleven contacts, standing still, behind the arm */
|
||||
#pause.paused::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
top: 24px; left: 50%; margin-left: -85px;
|
||||
width: 170px; height: 170px;
|
||||
border-radius: 50%;
|
||||
background:
|
||||
repeating-conic-gradient(from -90deg,
|
||||
var(--rb-line) 0deg 1.6deg, transparent 1.6deg 32.727deg);
|
||||
-webkit-mask: radial-gradient(circle, transparent 0 52px, #000 54px);
|
||||
mask: radial-gradient(circle, transparent 0 52px, #000 54px);
|
||||
opacity: .9;
|
||||
}
|
||||
|
||||
/* the arm. The <img> keeps its box and loses its pixels: object-position walks the
|
||||
raster out of frame, which leaves a paintable element without adding a wrapper. */
|
||||
#pause.paused img {
|
||||
width: 170px; height: 170px;
|
||||
object-fit: none; object-position: -9999px -9999px;
|
||||
border: none; box-shadow: none; filter: none;
|
||||
/* the arm, and behind it the contact it has just left — without the trailing sector
|
||||
a 700ms step is easy to miss, and the stepping is the whole idea */
|
||||
background: conic-gradient(from -91deg,
|
||||
var(--rb-yellow) 0deg 3.5deg,
|
||||
var(--rb-yellow-dim) 3.5deg 28deg,
|
||||
transparent 28deg 360deg);
|
||||
-webkit-mask: radial-gradient(circle, #000 0 78px, transparent 80px);
|
||||
mask: radial-gradient(circle, #000 0 78px, transparent 80px);
|
||||
animation: rb-uniselector 7.7s steps(11, end) infinite;
|
||||
}
|
||||
/* core adds fa-spin to that img; ours must be the only rotation running */
|
||||
#pause.paused img.fa-spin { animation: rb-uniselector 7.7s steps(11, end) infinite; }
|
||||
|
||||
/* the hub, and the amber that says the record is being written */
|
||||
#pause.paused::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
top: 101px; left: 50%; margin-left: -8px;
|
||||
width: 16px; height: 16px;
|
||||
border-radius: 50%;
|
||||
background: var(--rb-yellow);
|
||||
animation: rb-pause-pip 2.6s ease-in-out infinite;
|
||||
}
|
||||
|
||||
#pause.paused figcaption {
|
||||
font-family: var(--rb-display);
|
||||
font-size: 24px;
|
||||
letter-spacing: .3em;
|
||||
text-transform: uppercase;
|
||||
color: var(--rb-yellow);
|
||||
line-height: 1.2;
|
||||
text-shadow: 0 2px 18px var(--rb-void);
|
||||
}
|
||||
|
||||
@keyframes rb-uniselector { from { transform: rotate(0deg); } to { transform: rotate(360deg); } }
|
||||
@keyframes rb-pause-pip { 0%, 100% { opacity: .25; } 50% { opacity: 1; } }
|
||||
|
||||
/* A stepping dial in the corner of the eye is exactly what this preference is about.
|
||||
Keep the object, stop the movement — including core's own pulse on the whole figure. */
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
#pause.paused,
|
||||
#pause.paused img,
|
||||
#pause.paused img.fa-spin,
|
||||
#pause.paused::after { animation: none; }
|
||||
#pause.paused img { transform: rotate(-32.7deg); }
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
PANIC — the short track
|
||||
============================================================
|
||||
Deliberately NOT the same colour as Coherence. The two tracks run in opposite
|
||||
directions on different timescales, and a table that reads one as the other will make
|
||||
the wrong decision at the worst moment: Coherence is cool and yellow like the rest of
|
||||
the department, Panic warms as it climbs and ends red.
|
||||
============================================================ */
|
||||
|
||||
.rb-vital-panic .rb-big { color: var(--rb-white); }
|
||||
.rb-panic-steady .rb-big, .rb-panic-steady .rb-cohband { color: var(--rb-muted); }
|
||||
.rb-panic-rattled .rb-big, .rb-panic-rattled .rb-cohband { color: var(--rb-white); }
|
||||
.rb-panic-shaken .rb-big, .rb-panic-shaken .rb-cohband { color: var(--rb-yellow); }
|
||||
.rb-panic-losing .rb-big, .rb-panic-losing .rb-cohband { color: #ffb066; }
|
||||
.rb-panic-overwhelmed .rb-big, .rb-panic-overwhelmed .rb-cohband { color: var(--rb-danger); }
|
||||
.rb-panic-broken .rb-big, .rb-panic-broken .rb-cohband { color: var(--rb-danger); }
|
||||
.rb-vital-panic.rb-panic-broken { border-color: var(--rb-danger); }
|
||||
|
||||
.rb-panic-pips { display: flex; gap: 5px; list-style: none; margin: 8px 0; padding: 0; }
|
||||
.rb-panic-pip {
|
||||
width: 20px; height: 8px;
|
||||
background: transparent; border: 1px solid var(--rb-hairline);
|
||||
}
|
||||
.rb-panic-pip.on { background: var(--rb-danger); border-color: var(--rb-danger); }
|
||||
|
||||
.rb-panic-move { display: flex; align-items: baseline; gap: 8px; margin: 6px 0; }
|
||||
.rb-panic-from, .rb-panic-to {
|
||||
font-family: var(--rb-display); font-size: 20px; color: var(--rb-white);
|
||||
}
|
||||
.rb-panic-arrow { color: var(--rb-muted); }
|
||||
.rb-panic-band {
|
||||
font-family: var(--rb-body); font-size: 11px; font-weight: 700;
|
||||
letter-spacing: .1em; text-transform: uppercase; color: var(--rb-yellow);
|
||||
}
|
||||
.rb-panic-reason { font-size: 13px; color: var(--rb-white); margin: 4px 0; }
|
||||
.rb-panic-note { font-size: 12px; color: var(--rb-muted); margin: 2px 0 6px; }
|
||||
.rb-panic-pen { font-size: 13px; font-weight: 700; color: var(--rb-danger); margin: 2px 0; }
|
||||
.rb-panic-break {
|
||||
font-family: var(--rb-body); font-size: 12px; font-weight: 700; letter-spacing: .08em;
|
||||
color: var(--rb-void); background: var(--rb-danger);
|
||||
padding: 5px 8px; margin: 6px 0 0;
|
||||
}
|
||||
|
||||
/* A fumbled relief puts a point ON. The card must say which way it went, or it reads
|
||||
as "Psychology" printed over a number that just rose, and the table reads that as a
|
||||
bug rather than as the rule. */
|
||||
.rb-panic-worse {
|
||||
font-family: var(--rb-body); font-size: 11px; font-weight: 700; letter-spacing: .08em;
|
||||
text-transform: uppercase; color: var(--rb-void); background: var(--rb-danger);
|
||||
padding: 2px 6px; margin-right: 6px;
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"id": "ringbrp",
|
||||
"title": "The Custodians",
|
||||
"description": "A Basic Roleplaying game of agents who cross the crossing to contain what should not be there — and who come back a little less from here each time.",
|
||||
"version": "1.0.2",
|
||||
"version": "1.1.3",
|
||||
"compatibility": {
|
||||
"minimum": 13,
|
||||
"verified": "14.364"
|
||||
|
||||
@@ -69,6 +69,11 @@
|
||||
"value": 10,
|
||||
"max": 10,
|
||||
"drift": []
|
||||
},
|
||||
"panic": {
|
||||
"value": 0,
|
||||
"max": 5,
|
||||
"log": []
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -51,6 +51,16 @@
|
||||
</span>
|
||||
<span class="rb-cohband">{{localize coherence.band.label}}</span>
|
||||
</div>
|
||||
<div class="rb-vital-panic rb-panic-{{panic.band.id}}"
|
||||
data-tooltip="{{localize panic.band.note}}">
|
||||
<span class="rb-vital-label">{{localize "RINGBRP.Panic.Label"}}</span>
|
||||
<span class="rb-coh-row">
|
||||
<a data-action="panic-adjust" data-delta="-1" class="rb-cohbtn">−</a>
|
||||
<span class="rb-big">{{sys.panic.value}}</span>
|
||||
<a data-action="panic-adjust" data-delta="1" class="rb-cohbtn">+</a>
|
||||
</span>
|
||||
<span class="rb-cohband">{{localize panic.band.label}}</span>
|
||||
</div>
|
||||
<div class="rb-armour{{#if armourUnworn.length}} rb-unworn{{/if}}"
|
||||
{{#if armourUnworn.length}}data-tooltip="{{localize "RINGBRP.Sheet.ArmourUnworn"}}"{{/if}}>
|
||||
<span class="rb-vital-label">{{localize "RINGBRP.Sheet.Armour"}}</span>
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
<div class="ringbrp-chat rb-panic-card">
|
||||
<div class="rb-chat-title">{{localize "RINGBRP.Panic.Label"}}</div>
|
||||
<div class="rb-chat-sub">{{actorName}}</div>
|
||||
|
||||
<ol class="rb-panic-pips">
|
||||
{{#each pips as |on|}}<li class="rb-panic-pip{{#if on}} on{{/if}}"></li>{{/each}}
|
||||
</ol>
|
||||
|
||||
<div class="rb-panic-move">
|
||||
<span class="rb-panic-from">{{before}}</span>
|
||||
<span class="rb-panic-arrow">→</span>
|
||||
<span class="rb-panic-to">{{after}}</span>
|
||||
<span class="rb-panic-band rb-panic-{{band.id}}">{{localize band.label}}</span>
|
||||
</div>
|
||||
|
||||
<p class="rb-panic-reason">{{#if backfired}}<span class="rb-panic-worse">{{localize "RINGBRP.Panic.Backfired"}}</span> {{/if}}{{reason}}{{#if note}} — <em>{{note}}</em>{{/if}}</p>
|
||||
<p class="rb-panic-note">{{localize band.note}}</p>
|
||||
|
||||
{{#if penalty}}<p class="rb-panic-pen">{{penalty}}% {{localize "RINGBRP.Chat.ToEverything"}}</p>{{/if}}
|
||||
{{#if breaks}}<p class="rb-panic-break">{{localize "RINGBRP.Panic.Breaks"}}</p>{{/if}}
|
||||
</div>
|
||||
@@ -461,6 +461,38 @@ function journalDoc(name, key, pages) {
|
||||
return { doc: { _id: id, name, pages: pageDocs.map(p => p._id), categories: [], ...BASE() }, pages: pageDocs, id };
|
||||
}
|
||||
|
||||
/**
|
||||
* A journal of nothing but pictures, for showing to players.
|
||||
*
|
||||
* title.show is FALSE and there is no caption on purpose: when the GM hits Show Players
|
||||
* the table gets the image and nothing else — no heading telling them which room this
|
||||
* is, no label naming the thing they are looking at. The page NAME is for the GM's list
|
||||
* in the sidebar, so they can find the right one in a hurry; the players never see it.
|
||||
*/
|
||||
function imageJournal(name, key, plates) {
|
||||
const id = idFor(`journal:${key}`);
|
||||
const pageDocs = plates.map((p, i) => {
|
||||
// A missing plate is a blank frame on a projector in front of six people.
|
||||
if (!existsSync(path.join(ROOT, p.src))) throw new Error(`${name}: no plate at ${p.src}`);
|
||||
return {
|
||||
_id: idFor(`journal:${key}:${i}`),
|
||||
name: p.name, type: "image",
|
||||
title: { show: false, level: 1 },
|
||||
src: `systems/ringbrp/${p.src}`,
|
||||
image: { caption: "" },
|
||||
text: { format: 1, content: "", markdown: "" },
|
||||
video: { controls: true, volume: 0.5 },
|
||||
system: {}, sort: (i + 1) * 100, ownership: { default: 2 }, flags: {},
|
||||
category: null
|
||||
};
|
||||
});
|
||||
return {
|
||||
doc: { _id: id, name, pages: pageDocs.map(p => p._id), categories: [],
|
||||
...BASE(), ownership: { default: 2 }, flags: { ringbrp: { brand: true } } },
|
||||
pages: pageDocs, id
|
||||
};
|
||||
}
|
||||
|
||||
/* ---------------- writer ---------------- */
|
||||
|
||||
/**
|
||||
@@ -842,6 +874,19 @@ function cullandScene() {
|
||||
castDocs.push(actorDoc);
|
||||
}
|
||||
|
||||
// The plates, one per page, for showing at the table. Named for what the GM will be
|
||||
// looking for under pressure, ordered by when it comes up.
|
||||
const showJournal = imageJournal("LAST ADMISSION — Show the players", "la_show", [
|
||||
{ name: "1 · The house, from the drive", src: "art/scenes/la_01_hall.webp" },
|
||||
{ name: "2 · Floor plan — Culland Hall", src: "art/maps/culland-hall.webp" },
|
||||
{ name: "3 · Backstage — the green room", src: "art/scenes/la_06_green.webp" },
|
||||
{ name: "4 · The east corridor", src: "art/scenes/la_03_corridor.webp" },
|
||||
{ name: "5 · The nursery", src: "art/scenes/la_04_nursery.webp" },
|
||||
{ name: "6 · The glasshouse — Old Marrow", src: "art/scenes/la_05_glass.webp" },
|
||||
{ name: "7 · The stairs — the woman standing", src: "art/scenes/la_07_stairs.webp" },
|
||||
{ name: "8 · The car park, afterwards", src: "art/scenes/la_08_carpark.webp" }
|
||||
]);
|
||||
|
||||
const gmJournal = journalDoc("LAST ADMISSION — Run the prologue", "la_gm", L.LA_PAGES);
|
||||
const handouts = journalDoc("LAST ADMISSION — Player handouts", "la_handouts", L.LA_HANDOUTS);
|
||||
handouts.doc.ownership = { default: 2 };
|
||||
@@ -870,8 +915,9 @@ function cullandScene() {
|
||||
actors: castDocs,
|
||||
items: [],
|
||||
journal: [
|
||||
{ ...gmJournal.doc, pages: gmJournal.pages },
|
||||
{ ...handouts.doc, pages: handouts.pages }
|
||||
{ ...gmJournal.doc, pages: gmJournal.pages },
|
||||
{ ...handouts.doc, pages: handouts.pages },
|
||||
{ ...showJournal.doc, pages: showJournal.pages }
|
||||
],
|
||||
scenes: [cullandScene()], combats: [], tables: [], macros: [], cards: [], playlists: [], folders: [],
|
||||
sort: 0, ...BASE()
|
||||
@@ -879,7 +925,8 @@ function cullandScene() {
|
||||
|
||||
await writePack("lastadmission", [[`!adventures!${advId}`, adventure]]);
|
||||
console.log(` lastadm LAST ADMISSION — ${castDocs.length} cast, `
|
||||
+ `${L.LA_PAGES.length} GM pages, ${L.LA_HANDOUTS.length} handouts`);
|
||||
+ `${L.LA_PAGES.length} GM pages, ${L.LA_HANDOUTS.length} handouts, `
|
||||
+ `${showJournal.pages.length} plates to show`);
|
||||
}
|
||||
|
||||
// OPEN DAY — the prologue. A much smaller adventure than BLACK PLATFORM: no case file,
|
||||
|
||||
@@ -426,6 +426,115 @@ 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`);
|
||||
}
|
||||
});
|
||||
|
||||
/* ---------------------------------------------------------------- */
|
||||
|
||||
await runAll();
|
||||
|
||||
@@ -179,6 +179,24 @@ const checks = [
|
||||
["coherenceBandFrom(4).id", RULES.coherenceBandFrom(4).id, "adrift"],
|
||||
["coherenceBandFrom(1).id", RULES.coherenceBandFrom(1).id, "unmoored"],
|
||||
["coherenceBandFrom(0).id", RULES.coherenceBandFrom(0).id, "displaced"],
|
||||
|
||||
// Panic. The free first point is deliberate, so 1 must cost nothing.
|
||||
["panicBandFrom(0).id", RULES.panicBandFrom(0).id, "steady"],
|
||||
["panicBandFrom(1).id", RULES.panicBandFrom(1).id, "rattled"],
|
||||
["panicBandFrom(3).id", RULES.panicBandFrom(3).id, "losing"],
|
||||
["panicBandFrom(5).id", RULES.panicBandFrom(5).id, "broken"],
|
||||
["panicPenaltyFor(1)", RULES.panicPenaltyFor(1), 0],
|
||||
["panicPenaltyFor(2)", RULES.panicPenaltyFor(2), -5],
|
||||
["panicPenaltyFor(4)", RULES.panicPenaltyFor(4), -20],
|
||||
["panicBreaksAt(4)", RULES.panicBreaksAt(4), false],
|
||||
["panicBreaksAt(5)", RULES.panicBreaksAt(5), true],
|
||||
["panicAfter(4,1)", RULES.panicAfter(4, 1), 5],
|
||||
["panicAfter(5,1)", RULES.panicAfter(5, 1), 5],
|
||||
["panicAfter(0,-1)", RULES.panicAfter(0, -1), 0],
|
||||
["panicReliefFor('success')", RULES.panicReliefFor("success"), 1],
|
||||
["panicReliefFor('special')", RULES.panicReliefFor("special"), 2],
|
||||
["panicReliefFor('failure')", RULES.panicReliefFor("failure"), 0],
|
||||
["panicReliefFor('fumble')", RULES.panicReliefFor("fumble"), -1],
|
||||
// a lost location must cost something, for every kind the species tables use
|
||||
["locationEffectsFor([]) is inert", RULES.locationEffectsFor([]).all, 0],
|
||||
["leg disabled → physical", RULES.locationEffectsFor([{kind:"leg",disabled:true}]).physical, -30],
|
||||
|
||||
@@ -75,6 +75,13 @@ export const SKILL_CATALOGUE = [
|
||||
["navigate", "", "mental", 10, "Navigate"],
|
||||
["research", "", "mental", 1, "Research"],
|
||||
["anomaly_lore", "", "mental", 0, "Anomaly Lore", { canBeTaught: false }],
|
||||
// Added after the first game, which wanted all three and had none of them. Occult and
|
||||
// Religion are the two halves of what people believed BEFORE the department existed —
|
||||
// one is what the books say, the other is what the congregation does — and Psychology
|
||||
// is the only skill that steadies somebody who is not you.
|
||||
["occult", "", "mental", 5, "Occult"],
|
||||
["religion", "", "mental", 5, "Religion"],
|
||||
["psychology", "", "mental", 5, "Psychology"],
|
||||
// perception
|
||||
["listen", "", "percep", 25, "Listen"],
|
||||
["spot", "", "percep", 25, "Spot"],
|
||||
|
||||
@@ -1,176 +0,0 @@
|
||||
/**
|
||||
* Dice textures for The Custodians, generated rather than drawn.
|
||||
*
|
||||
* Written as raw RGBA and encoded to PNG with node:zlib, so the repo gains no
|
||||
* image dependency and the textures can be regenerated deterministically. Every
|
||||
* pattern is tileable — Dice So Nice wraps these across a die face, and a seam
|
||||
* shows up immediately on a d20.
|
||||
*
|
||||
* The set is the setting: the Custodians keep a filing system, and the game is
|
||||
* about that filing system being wrong. So the dice carry a ruled grid, and the
|
||||
* further an agent drifts from their own reality the more the grid stops agreeing
|
||||
* with itself.
|
||||
*
|
||||
* node tools/make-dice-textures.mjs
|
||||
*/
|
||||
import { deflateSync } from "node:zlib";
|
||||
import { writeFile, mkdir } from "node:fs/promises";
|
||||
|
||||
const SIZE = 256;
|
||||
|
||||
/* ---------- a minimal PNG encoder: RGBA, no interlace ---------- */
|
||||
function crc32(buf) {
|
||||
let c, table = crc32.table;
|
||||
if (!table) {
|
||||
table = crc32.table = new Int32Array(256);
|
||||
for (let n = 0; n < 256; n++) {
|
||||
c = n;
|
||||
for (let k = 0; k < 8; k++) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1;
|
||||
table[n] = c;
|
||||
}
|
||||
}
|
||||
c = -1;
|
||||
for (let i = 0; i < buf.length; i++) c = table[(c ^ buf[i]) & 0xff] ^ (c >>> 8);
|
||||
return (c ^ -1) >>> 0;
|
||||
}
|
||||
|
||||
function chunk(type, data) {
|
||||
const len = Buffer.alloc(4); len.writeUInt32BE(data.length);
|
||||
const td = Buffer.concat([Buffer.from(type, "ascii"), data]);
|
||||
const crc = Buffer.alloc(4); crc.writeUInt32BE(crc32(td));
|
||||
return Buffer.concat([len, td, crc]);
|
||||
}
|
||||
|
||||
function png(width, height, rgba) {
|
||||
const ihdr = Buffer.alloc(13);
|
||||
ihdr.writeUInt32BE(width, 0); ihdr.writeUInt32BE(height, 4);
|
||||
ihdr[8] = 8; ihdr[9] = 6; ihdr[10] = 0; ihdr[11] = 0; ihdr[12] = 0;
|
||||
// each scanline is prefixed with filter byte 0
|
||||
const raw = Buffer.alloc(height * (width * 4 + 1));
|
||||
for (let y = 0; y < height; y++) {
|
||||
raw[y * (width * 4 + 1)] = 0;
|
||||
rgba.copy(raw, y * (width * 4 + 1) + 1, y * width * 4, (y + 1) * width * 4);
|
||||
}
|
||||
return Buffer.concat([
|
||||
Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]),
|
||||
chunk("IHDR", ihdr),
|
||||
chunk("IDAT", deflateSync(raw, { level: 9 })),
|
||||
chunk("IEND", Buffer.alloc(0))
|
||||
]);
|
||||
}
|
||||
|
||||
/* ---------- deterministic noise, so a rebuild is byte-identical ---------- */
|
||||
let seed = 20260810;
|
||||
const rnd = () => (seed = (seed * 1664525 + 1013904223) >>> 0) / 4294967296;
|
||||
|
||||
const canvas = () => Buffer.alloc(SIZE * SIZE * 4);
|
||||
const put = (buf, x, y, r, g, b, a = 255) => {
|
||||
const i = ((y & (SIZE - 1)) * SIZE + (x & (SIZE - 1))) * 4;
|
||||
buf[i] = r; buf[i + 1] = g; buf[i + 2] = b; buf[i + 3] = a;
|
||||
};
|
||||
const fill = (buf, r, g, b, a = 255) => {
|
||||
for (let i = 0; i < buf.length; i += 4) { buf[i] = r; buf[i + 1] = g; buf[i + 2] = b; buf[i + 3] = a; }
|
||||
};
|
||||
|
||||
/* ============================================================
|
||||
1. REGISTRY — the ruled grid of a filing system, kept true.
|
||||
============================================================ */
|
||||
function registry() {
|
||||
const b = canvas();
|
||||
fill(b, 24, 38, 141); // ultramarine ground
|
||||
for (let i = 0; i < SIZE; i++) {
|
||||
for (const step of [32]) {
|
||||
for (let k = 0; k < SIZE; k += step) {
|
||||
put(b, i, k, 102, 116, 216); // --rb-line
|
||||
put(b, k, i, 102, 116, 216);
|
||||
}
|
||||
}
|
||||
for (let k = 16; k < SIZE; k += 32) { // fainter half-lines
|
||||
put(b, i, k, 60, 78, 175);
|
||||
put(b, k, i, 60, 78, 175);
|
||||
}
|
||||
}
|
||||
// registry ticks at the intersections
|
||||
for (let x = 0; x < SIZE; x += 64) for (let y = 0; y < SIZE; y += 64) {
|
||||
for (let d = -3; d <= 3; d++) { put(b, x + d, y, 242, 234, 114); put(b, x, y + d, 242, 234, 114); }
|
||||
}
|
||||
return b;
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
2. DRIFT — the same grid, no longer agreeing with itself.
|
||||
Used for agents who have come loose from their own reality.
|
||||
============================================================ */
|
||||
function drift() {
|
||||
const b = canvas();
|
||||
fill(b, 20, 30, 120);
|
||||
const wobble = y => Math.round(6 * Math.sin(y / 14) + 3 * Math.sin(y / 5.5));
|
||||
for (let i = 0; i < SIZE; i++) {
|
||||
for (let k = 0; k < SIZE; k += 32) {
|
||||
put(b, i + wobble(i), k, 122, 108, 200);
|
||||
put(b, k, i + wobble(i), 122, 108, 200);
|
||||
}
|
||||
}
|
||||
for (let n = 0; n < 900; n++) { // grain where it has torn
|
||||
const x = (rnd() * SIZE) | 0, y = (rnd() * SIZE) | 0;
|
||||
put(b, x, y, 200, 120, 150, 255);
|
||||
}
|
||||
for (let x = 0; x < SIZE; x += 64) for (let y = 0; y < SIZE; y += 64) {
|
||||
const ox = ((rnd() * 7) | 0) - 3, oy = ((rnd() * 7) | 0) - 3;
|
||||
for (let d = -3; d <= 3; d++) { put(b, x + d + ox, y + oy, 255, 122, 92); put(b, x + ox, y + d + oy, 255, 122, 92); }
|
||||
}
|
||||
return b;
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
3. DISPLACED — the grid is gone. What is left is not filed.
|
||||
============================================================ */
|
||||
function displaced() {
|
||||
const b = canvas();
|
||||
fill(b, 12, 14, 60);
|
||||
for (let n = 0; n < 5200; n++) {
|
||||
const x = (rnd() * SIZE) | 0, y = (rnd() * SIZE) | 0;
|
||||
const v = 40 + ((rnd() * 90) | 0);
|
||||
put(b, x, y, v, v * 0.5, v * 0.7);
|
||||
}
|
||||
// a few surviving fragments of rule, going nowhere
|
||||
for (let n = 0; n < 26; n++) {
|
||||
const x = (rnd() * SIZE) | 0, y = (rnd() * SIZE) | 0, len = 6 + ((rnd() * 22) | 0);
|
||||
const horiz = rnd() > 0.5;
|
||||
for (let d = 0; d < len; d++) {
|
||||
if (horiz) put(b, x + d, y, 150, 120, 190); else put(b, x, y + d, 150, 120, 190);
|
||||
}
|
||||
}
|
||||
return b;
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
4. AGENCY — brushed plate, for the agency-issue set.
|
||||
============================================================ */
|
||||
function agency() {
|
||||
const b = canvas();
|
||||
for (let y = 0; y < SIZE; y++) {
|
||||
for (let x = 0; x < SIZE; x++) {
|
||||
const grain = 6 * Math.sin(x / 1.7 + y / 90) + (rnd() * 7 - 3.5);
|
||||
const v = 30 + grain;
|
||||
put(b, x, y, 22 + v, 33 + v, 95 + v);
|
||||
}
|
||||
}
|
||||
for (let k = 0; k < SIZE; k += 48) for (let i = 0; i < SIZE; i++) put(b, i, k, 90, 104, 190);
|
||||
return b;
|
||||
}
|
||||
|
||||
const TEXTURES = {
|
||||
"custodians-registry": registry,
|
||||
"custodians-drift": drift,
|
||||
"custodians-displaced": displaced,
|
||||
"custodians-agency": agency
|
||||
};
|
||||
|
||||
await mkdir(new URL("../dice/", import.meta.url), { recursive: true });
|
||||
for (const [name, make] of Object.entries(TEXTURES)) {
|
||||
const buf = png(SIZE, SIZE, make());
|
||||
await writeFile(new URL(`../dice/${name}.png`, import.meta.url), buf);
|
||||
console.log(` ${name}.png ${SIZE}x${SIZE} ${(buf.length / 1024).toFixed(1)} kB`);
|
||||
}
|
||||
console.log("Custodians | dice textures written");
|
||||
@@ -28,7 +28,8 @@ import {
|
||||
powerStartingRating, brawlFallbackFor, bleedStoppedBy,
|
||||
crossingArtPoolFor, crossingGrantsArt, crossingCostFor,
|
||||
REACTION, BLEED, CIRCUMSTANCE, RANGE_LADDER, rangeBandFrom,
|
||||
MAJOR_WOUND_TABLE, COHERENCE_BANDS, COHERENCE_COSTS, COHERENCE_RECOVERY
|
||||
MAJOR_WOUND_TABLE, COHERENCE_BANDS, COHERENCE_COSTS, COHERENCE_RECOVERY,
|
||||
PANIC_BANDS, PANIC_TRIGGERS, PANIC_RELIEF, PANIC_MAX, PANIC_AFTER_BREAK, PANIC_BREAK
|
||||
} from "../rules.mjs";
|
||||
// Postings, trades and induction are plain data and are imported as such.
|
||||
import { ROLES, TRADES, INDUCTION, TIERS, TRADE_BANDS } from "../postings.mjs";
|
||||
@@ -75,6 +76,13 @@ const woundRows = MAJOR_WOUND_TABLE.map(r => {
|
||||
return [`${r.min}`, L(`RINGBRP.Wound.${r.key}.title`), bits.join(" · ") || "—"];
|
||||
});
|
||||
|
||||
const panicRows = [...PANIC_BANDS].reverse().map(b =>
|
||||
[String(b.min), L(b.label), b.all ? `${pc(b.all)} everything` : "none", L(b.note)]);
|
||||
const panicTrigRows = Object.values(PANIC_TRIGGERS)
|
||||
.map(t => [L(t.label), `+${t.cost}`]);
|
||||
const panicReliefRows = Object.values(PANIC_RELIEF)
|
||||
.map(r => [L(r.label), r.reach, r.once]);
|
||||
|
||||
const cohRows = COHERENCE_BANDS.map((b, i) => {
|
||||
const hi = i === 0 ? 10 : COHERENCE_BANDS[i - 1].min - 1;
|
||||
const rng = b.min === hi ? `${b.min}` : `${hi}–${b.min}`;
|
||||
@@ -518,6 +526,45 @@ return does not strand you either — you arrive near your target rather than at
|
||||
There is no state in which an agent cannot get home. There are only states in which
|
||||
getting home costs more than the case was worth.</p>` },
|
||||
|
||||
{ name: "4b · Panic", content: `
|
||||
<p><em>Coherence is what knowing has cost you, and it is measured in weeks. Panic is
|
||||
what is happening to you now, and it is measured in minutes. Do not confuse them at
|
||||
the table: one goes down over a career, the other goes up over a corridor.</em></p>
|
||||
|
||||
<p>Panic runs from <strong>0 up to ${PANIC_MAX}</strong>. You take a point when
|
||||
something happens to you — <strong>no roll</strong>. Asking for a die to find out
|
||||
whether you are frightened slows the scene down and tells you how to feel. The die is
|
||||
for getting rid of it.</p>
|
||||
|
||||
${table(["Panic", "Band", "Penalty", "What it looks like"], panicRows)}
|
||||
|
||||
<p>The first point is free. At Panic 1 you are rattled and you roll at no penalty,
|
||||
because the opening shock of a scene should be colour rather than arithmetic.</p>
|
||||
|
||||
<h2>Taking a point</h2>
|
||||
${table(["This happens", "Panic"], panicTrigRows)}
|
||||
|
||||
<h2>Getting rid of one</h2>
|
||||
<p>Roll it. A success takes one point off; a <strong>special or better takes two</strong>.
|
||||
A failure costs nothing but the moment you spent on it — and a <strong>fumble puts a
|
||||
point ON</strong>. Trying to steady somebody and making it worse is the most familiar
|
||||
thing on this list.</p>
|
||||
${table(["How", "Reaches", "How often"], panicReliefRows)}
|
||||
|
||||
<p><strong>Occult is the odd one.</strong> Naming a thing correctly, out loud, helps
|
||||
everybody who can hear you — which is the department's actual work, and the only relief
|
||||
that reaches a whole room. <strong>Psychology</strong> is the only one that steadies
|
||||
somebody who is not you. <strong>Religion</strong> steadies you and nobody else, and it
|
||||
does not care whether you are right.</p>
|
||||
|
||||
<h2>Breaking</h2>
|
||||
<p>At ${PANIC_MAX} you break. Choose one — <strong>${PANIC_BREAK.join(", ")}</strong> —
|
||||
take it, and come back at <strong>${PANIC_AFTER_BREAK}</strong>. You do not come back at
|
||||
zero. Breaking is not a rest.</p>
|
||||
|
||||
<p>Panic clears when the scene does. When you are somewhere safe with the door shut and
|
||||
somebody has made tea, you are at nought again.</p>` },
|
||||
|
||||
{ name: "5 · The Quiet Arts", content: `
|
||||
<p><em>Some agents come back with something. It is not a gift and it is not
|
||||
training — it is the crossing, left in a person.</em></p>
|
||||
@@ -730,8 +777,8 @@ ${table(["Location", "Share", "Capacity"], [
|
||||
|
||||
<h2>Rolls that ignore your skill</h2>
|
||||
${table(["Roll", "Result"], [
|
||||
["96–99", "always fails, whatever your skill"],
|
||||
["00", "always a fumble"],
|
||||
["96–99", "never succeeds, whatever your skill — and is a fumble if it is inside your fumble range"],
|
||||
["00", "always a fumble, whatever your skill"],
|
||||
["01", "always at least a critical chance — the floor is 1%"]
|
||||
])}
|
||||
|
||||
|
||||