commit d0144f744ef2c98b7a2ba873c58d9a2eb09fdb02 Author: slaguru666 <111923774+slaguru666@users.noreply.github.com> Date: Mon Aug 10 18:09:07 2026 +0100 The Custodians v0.6.1 — full system A Foundry VTT game system: Basic Roleplaying d100 in a drab British agency that deals with crossings, where reality keeps a record of what belongs where and can be made to get it wrong. Contents: - rules.mjs as the single authority for every rule formula, with 40 rules and 132 spot-checked values. Nothing may restate a formula it defines. - 25 postings, each with a key characteristic, trained skills, talents and its own kit; a 175-item catalogue across four eras; 231 generated icons. - Reaction Lanes initiative, re-rolled every round including the first. - Coherence, and the Quiet Arts, which only ever arrive from a crossing gone wrong. - Graded defences: only a critical stops a critical. - Borrowed Authority — access as a record rather than a hacking roll. - A case generator, and a rules journal generated from rules.mjs so the book cannot drift from the code. Built compendia are committed so a remote Foundry needs only the files. node_modules is not; `npm install` restores it for rebuilding content. Co-Authored-By: Claude Opus 5 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..698dd6b --- /dev/null +++ b/.gitignore @@ -0,0 +1,9 @@ +# Restorable with `npm install` — the pack builder needs classic-level. +node_modules/ + +# LevelDB runtime locks. The pack DATA is committed on purpose (see README): +# a remote Foundry needs the built compendia, and building them requires Node. +packs/*/LOCK + +# macOS +.DS_Store diff --git a/LICENSE.txt b/LICENSE.txt new file mode 100644 index 0000000..3a0564a --- /dev/null +++ b/LICENSE.txt @@ -0,0 +1,10 @@ +RingBRP system code and data: © 2026 Tim Evans. + +Built on Basic Roleplaying: Universal Game Engine, © Chaosium Inc., used under the ORC License. + +The setting content in the compendia and language file derives from study of Larry Niven's +Known Space fiction and the 1984 Chaosium Ringworld roleplaying game. Ringworld and Known Space +are the intellectual property of Larry Niven; the 1984 game material is © Chaosium Inc. +This system is a private study implementation and is not licensed for distribution as a +Ringworld product. The mechanics layer is deliberately architected to be separable from the +setting layer so that an original setting can replace it. diff --git a/README.md b/README.md new file mode 100644 index 0000000..dc86103 --- /dev/null +++ b/README.md @@ -0,0 +1,169 @@ +# THE CUSTODIANS + +*A Basic Roleplaying game of crossings, as a Foundry VTT system.* + +A drab British government agency deals with **crossings**. Reality keeps a record of +what belongs where, and it can be made to get it wrong. Agents are civil servants: +they carry warrant cards and cordon tape, they file in triplicate, and the thing that +makes them dangerous is that they know how the record works. + +The tone is bureaucratic horror. Most cases close with an arrangement rather than a +fight, and the supernatural is a filing error somebody committed on purpose. + +--- + +## Installing + +**In Foundry:** *Game Systems → Install System → Manifest URL* + +``` +https://gitea.timevans.uk/slaguru666/RingBRP/raw/branch/main/system.json +``` + +**Or by hand:** clone this repository into your Foundry data directory as +`Data/systems/ringbrp`, then restart Foundry. + +```bash +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. + +### Deploying to a remote Foundry server + +The built compendia in `packs/` **are committed on purpose**, so a remote server needs +nothing but the files: + +```bash +cd /path/to/foundrydata/Data/systems +git clone https://gitea.timevans.uk/slaguru666/RingBRP.git ringbrp +# restart Foundry +``` + +`node_modules/` is *not* committed. You only need it if you intend to rebuild the +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 +``` + +To update a deployed server: `git pull` and restart Foundry. If the pull touches +`packs/`, Foundry must be **stopped** first — LevelDB will not tolerate its files +changing underneath a running server. + +--- + +## What is in the box + +| | | +|---|---| +| **Roles** | 25 postings, each with a key characteristic, trained skills, talents and its own kit | +| **Catalogue** | 175 items — 118 gear, 30 weapons, 13 armour, 14 vehicles | +| **Eras** | modern, antique, future, anomalous — the agency is old and never abolishes anything | +| **Icons** | 231, all generated from `tools/icons.mjs`; no external art | +| **Compendia** | pregens, NPCs, skills, weapons, armour, gear, vehicles, powers, tables, rules, a starter case | +| **Rules journal** | 8 pages, generated from `rules.mjs` so the book cannot drift from the code | + +--- + +## The rules, in brief + +**d100 roll-under**, banding into critical / special / success / failure / fumble. +96–99 always fail, 00 always fumbles, and every skill has a 1% floor. + +**Reaction Lanes** replace Strike Rank. Initiative is +`3 × max(⌈DEX/3⌉, ⌈readiness/10⌉)` less carried load, plus a d6, **re-rolled every +round** — including the first, so the opening round is ordered like every other. + +**Coherence** runs 10 → 0 and measures how well-filed an agent is in reality. It bands +into Anchored / Loose / Adrift / Unmoored / Displaced, and spending it is how the +supernatural works. + +**The Quiet Arts** are deliberate misfilings, not spells. They cost Coherence, and +they **only ever arrive from a crossing that goes wrong**. + +**Defences are graded.** A defence stops a blow of its own quality or worse outright; +against something better it only reduces it by as many steps as it was outclassed by. +Only a critical stops a critical. + +**Access is a record.** There is no hacking skill and nothing to break into — see +below. + +--- + +## Borrowed Authority — the access subsystem + +Deliberately not cyberpunk. Nobody jacks into anything. + +What an agent has is a **named credential** belonging to somebody real, with a +**scope** of things that person could plausibly do, a **Trust** that erodes as it is +used, and **red flags** describing how the real owner behaves. + +- **Inside its scope there is no roll.** A facilities contractor opening a plant room + is not intrusion, it is Tuesday. It costs 1 Trust and it works. +- **One tier beyond is a stretch** — roll, at −20 for each red flag you break. The + system is not testing whether you are clever. It is testing whether you are + behaving like the person whose name is on the record. +- **Two tiers beyond is impossible at any skill.** No percentage on any sheet gets a + contractor issuing accounts. +- A **fumble burns** the credential, and somebody real is going to be asked about it. + +A credential is assembled, not found: up to **three dossier items, each a separate +scene using a different skill, at most one of them at a keyboard**. Items make the +credential better, never the roll easier. + +**AI** is a posture on the target rather than an opponent — a *gatekeeper* raises what +counts as routine, an *analyst* writes a new red flag every time you stretch, an +*asset* stands in for a dossier item. + +And you may spend **Coherence** to widen a scope, because reality can be made to +remember that the person always held that duty. It works. It always works. Push a +credential past what its Trust will carry and your file and theirs converge one step, +and nothing in this game spends anything to converge them back. + +--- + +## Repository layout + +``` +ringbrp.mjs the system: sheets, engine, hooks (~4,900 lines) +rules.mjs THE SINGLE AUTHORITY for every rule formula +system.json Foundry manifest +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 +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 + 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 + check-rules.mjs guard: no rule may be defined twice + check-kits.mjs guard: every posting's kit must exist and be usable + check-lang.mjs guard: no localisation key may be both a value and a branch +docs/REVIEW_LOG.md the design and defect log, R-1 onward +``` + +### The one rule this codebase enforces on itself + +**No rule formula may exist in more than one place.** Everything lives in `rules.mjs`, +and `tools/check-rules.mjs` fails the build if a formula is restated, if an alias +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: + +``` +check-rules: 40 rules, 12 files scanned, 132 formulas verified +check-kits: 25 roles, 162 catalogue items, every kit key resolves +check-lang: 738 keys, no leaf/branch collisions +``` diff --git a/dice/custodians-agency.png b/dice/custodians-agency.png new file mode 100644 index 0000000..ede5423 Binary files /dev/null and b/dice/custodians-agency.png differ diff --git a/dice/custodians-displaced.png b/dice/custodians-displaced.png new file mode 100644 index 0000000..bf07cb2 Binary files /dev/null and b/dice/custodians-displaced.png differ diff --git a/dice/custodians-drift.png b/dice/custodians-drift.png new file mode 100644 index 0000000..e4f2089 Binary files /dev/null and b/dice/custodians-drift.png differ diff --git a/dice/custodians-registry.png b/dice/custodians-registry.png new file mode 100644 index 0000000..829dcb1 Binary files /dev/null and b/dice/custodians-registry.png differ diff --git a/dice/texture-shadowsquares.png b/dice/texture-shadowsquares.png new file mode 100644 index 0000000..ce0baf4 Binary files /dev/null and b/dice/texture-shadowsquares.png differ diff --git a/dice/texture-starfield.png b/dice/texture-starfield.png new file mode 100644 index 0000000..01bff07 Binary files /dev/null and b/dice/texture-starfield.png differ diff --git a/dice/texture-threshold.png b/dice/texture-threshold.png new file mode 100644 index 0000000..f02f9c3 Binary files /dev/null and b/dice/texture-threshold.png differ diff --git a/docs/DEPLOY.md b/docs/DEPLOY.md new file mode 100644 index 0000000..ca19129 --- /dev/null +++ b/docs/DEPLOY.md @@ -0,0 +1,66 @@ +# Porting to a remote Foundry server + +## First install + +Stop Foundry, then clone into the data directory: + +```bash +cd /path/to/foundrydata/Data/systems +git clone https://gitea.timevans.uk/slaguru666/RingBRP.git ringbrp +``` + +Start Foundry. **The Custodians** appears under Game Systems. Create a world with it. + +Nothing else is required: `packs/` holds the built compendia, and the system has no +runtime dependencies. `node_modules/` is deliberately absent and is only needed to +*rebuild* content. + +## Updating a deployed server + +```bash +# STOP Foundry first if the update touches packs/ — LevelDB will not tolerate its +# files changing underneath a running server. +cd /path/to/foundrydata/Data/systems/ringbrp +git pull +``` + +Then start Foundry. Existing worlds keep their actors and items; compendium content is +replaced by the pull. + +## Rebuilding content on the server (optional) + +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 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 +cannot be written into the packs. + +## Manifest URL + +For Foundry's *Install System → Manifest URL*: + +``` +https://gitea.timevans.uk/slaguru666/RingBRP/raw/branch/main/system.json +``` + +If the repository is private, Foundry cannot fetch that URL — clone over SSH or an +access token instead, or make the repo public. + +## Version bumps + +`system.json` carries `version`. Foundry only offers an update when that number rises, +so bump it before pushing anything a remote server should pick up. + +## Health check after deploying + +Open any character sheet. If labels read as raw keys (`RINGBRP.Sheet.Style` rather +than "Marksmanship"), the localisation file failed to load — run +`node tools/check-lang.mjs`, which exists precisely because one bad key silently +un-localises the entire system with no error a user would connect to the cause. diff --git a/docs/REVIEW_LOG.md b/docs/REVIEW_LOG.md new file mode 100644 index 0000000..eb52cfe --- /dev/null +++ b/docs/REVIEW_LOG.md @@ -0,0 +1,3616 @@ +# RingBRP — design review log + +## Round 1 — Codex review of RINGBRP_RULES_SPEC_v0.1.md +**Date:** 7 August 2026 · **Reviewer:** Codex (GPT-5.4) via `codex exec` · **Author:** Claude (Opus 5) + +### Verdict headline +> "The draft has a sound BRP chassis, but it currently fails its own Ringworld test. The generic +> five-tier technology rule and generic progress-clock research system replace distinctive +> Ringworld procedures with generic sci-fi mechanics." + +> "Without those changes, this remains competent generic BRP science fiction wearing Ringworld nouns." + +That is exactly the failure mode the spec asked Codex to hunt for, and it found it. + +### Factual errors found — VERIFIED against the source corpus + +Nine of Codex's checkable claims were re-checked by Claude against `~/RingBRP/extracted/`. +**All nine were correct.** Claude's spec was wrong in each case. + +| # | Codex's claim | Source check | Verdict | +|---|---|---|---| +| 1 | 1984 general HP = **CON + MAS**, not an average | Explorer Book: "hit points equal to the total of his CON + his MAS" | Codex right; `R-09` halved it | +| 2 | 1984 has **seven** hit locations (L/R arm, L/R leg, abdomen, chest, head) + general HP = 8 live values | Explorer Book: lists all seven by name | Codex right; spec said "six pools" | +| 3 | POW doubles = **open-ended reroll-and-add** for Earth humans under 100, attributed to Puppeteer breeding | Explorer Book, POW entry | Codex right; spec said "may indicate psi potential" | +| 4 | Default age die is **1D8**; 1D10 reserved for NPCs/special cases | Explorer Book: "roll 1D8 … ordinarily the gamemaster should not allow such ancient explorers" | Codex right; spec said 1D10 | +| 5 | `Ringworld` skill covers **macrostructure and physical relationships only** — explicitly "equivalent to Planetology" — not artefact identification | Explorer Book, RINGWORLD (S) | Codex right; spec's "Cthulhu Mythos slot" was a genericisation | +| 6 | BRP difficulty ladder is **Automatic / Easy (×2) / Average / Difficult (÷2) / Impossible** | BRP:UGE p.113 and glossary | Codex right; spec's +50/+20/−20/−50/×⅕ was invented | +| 7 | BRP: cannot mix Parry and Dodge in one round unless completely defensive; cumulative −30% per extra defence | BRP:UGE Dodge entry | Codex right; `R-19` omitted the restriction | +| 8 | Stunners resolve on **energy output vs target MAS**, not CON vs potency | Technology Book, STUNNERS | Codex right | +| 9 | Variable sword **cannot** cut scrith, GP hull or stasis fields, and cuts dense alloys slowly | Technology Book, variable knife entry | Codex right; spec said "ignores all non-powered armour" | + +Additional errors accepted without independent check (internally consistent and checkable later): +- `R-01` fumble formula does not reproduce BRP. Correct form: + `fumble_start = 101 - ceil((101 - clamp(effective,1,100)) / 20)` + (verified by arithmetic against BRP's own worked example: skill 60 → 98–00.) +- `R-22` rounding: BRP requires `ceil` for HP, Major Wound and Experience Bonus; spec used + `round`/`floor`/`floor`. +- Major Wound is caused by **one blow** meeting the threshold, not by crossing a current-HP boundary. +- `R-22` cross-references `R-09`, which is about age, not HP. Broken reference. +- `R-14` unfamiliar-specialisation transfer is catastrophically broad — would grant + Science (Nuclear Physics) at 60% off Science (Biology) at 80%. +- `R-15` xeno-cap as written needs hidden per-skill learning-source provenance — i.e. it + reintroduces the parallel economy it claimed to delete. +- `R-16` contains 36 rows, not 35; omits Navigate and Research. +- `R-23` encumbrance base should be **STR** kg (spec said MAS), with local-gravity multiplication. +- `R-24`/`R-26` directly contradict each other on whether tier-gap-3 needs research. +- `R-27` advancement omits the Experience Bonus from the improvement roll and mis-states the + over-100% rule. +- `P-3`/`P-5` overstated the diagnosis: xenological activation spent ordinary occupation points, + not a separate currency, and was expressible on a flat sheet. + +### Decision Record verdicts + +| DR | Codex | Note | +|---|---|---| +| DR-01 delete impulse combat | **AGREE** | Add opposed Athletics/Pilot chase exchange for the second-by-second beat | +| DR-02 keep MAS/APP names | **AGREE-WITH-CHANGE** | Canonical engine IDs `siz`/`cha`; MAS/APP as display labels in the setting layer only | +| DR-03 consolidate skills | **AGREE-WITH-CHANGE** | Keep families but require real specialties; add Navigate and Research; publish a finite launch catalogue | +| DR-04 move ranged to Combat | **DISAGREE** | Keep firearms/energy under Perception; bows/thrown under Combat. The 1984 placement was deliberate — marksmanship as concentration, not agility | +| DR-05 xeno table as training cap | **AGREE-WITH-CHANGE** | Cap `Xenology (Species)` only; Xenology then augments other skills | +| DR-06 TECH_TIER_GAP | **DISAGREE** | Replace with familiarity: familiar 0 / analogous −20 / no analogue −50 / Builder locked until a named Discovery, then −20. Materials and weapons get explicit traits | +| DR-07 retain Research | **AGREE-WITH-CHANGE** | Rebuild as 1–5 **typed sequential steps** (field, library, laboratory, analysis). Novel alien subjects require ≥1 field step. Completion grants a Discovery, not automatic skill dice | +| DR-08 ×5 attribute rolls | **AGREE** | Lethality is controlled by HP/damage/armour, not by Idea/Luck/Stamina | +| DR-09 keep Ringworld Lore | **AGREE-WITH-CHANGE** | Keep it, but restore the narrow macrostructure remit | +| DR-10 separate mechanics/setting layers | **AGREE** | Worth the naming overhead | + +### Open Question positions + +| Q | Codex position | +|---|---| +| Q-01 multiplier | ×5, BRP standard | +| Q-02 hit locations | **Pools off; roll a location only when a blow causes a Major Wound**, then apply a concise consequence. Preserves severing, autodocs and variable-sword horror without seven HP tracks | +| Q-03 defence | BRP default: unlimited same-type at cumulative −30%, no mixing parry/dodge, keep missile-dodge restrictions | +| Q-04 encumbrance | STR kg; each 10 kg over: −1 MOV, −5% Physical/Manipulation/Combat; multiply by local gravity | +| Q-05 opposed rolls | Full BRP, including the loser downgrading the winner's degree | +| Q-06 playable species | Human, Kzin, Puppeteer at launch. Native guide as an NPC template; native PCs later with a warning that they change the premise | +| Q-07 automatic effects | Yes — a formal `resolution: automatic` category with explicit immunity/counter tags. Not GM fiat | +| Q-08 scale | **"Yes; this is the largest missing subsystem."** Light expedition procedure: journeys split into 3–6 legs, each with time cost, resource cost and one Navigation/Pilot test. Track exactly three expedition resources: **Supply, Power, Vehicle Integrity** | +| Q-09 Foundry | Skills as embedded Items with `familyId`/`specialisationId`/`categoryId`/`base`/`value`/`trainingCap`/`transferGroup` fields — **do not parse names**. Research as a **shared Project Item on a party/expedition Actor**, not per-PC | +| Q-10 missing | First-contact procedure; Navigate and Research skills; expedition travel; vehicle/starship scale and damage; material traits; technological traditions; the young-Earth POW rule; simweb training; gravity adaptation and zero-g; and actual species/equipment/pursuit data | + +### Over-deletion (put it back) +- The four 1984 research **modes** (field, library, laboratory, armchair/analysis) — especially the + rule that major research generates a field expedition. +- `Ringworld` skill's narrow macrostructural remit. +- Young-Earth open-ended POW; simweb training; local-gravity encumbrance; gravity adaptation. +- Location **consequences** — losing them guts variable swords, autodocs and transplant resistance. +- The shared handling group linking flashlight laser and variable sword. +- Publication and assimilation of xenological discoveries back in Human Space as a downtime reward. + +### Under-deletion (still too complex) +- Full BRP characteristic-based category modifiers — six calculated sheet values. Use BRP's + Simple Skill Category Bonuses, or drop category bonuses entirely. +- `R-14` — recreates root-skill leakage in a worse form. +- 36 skills plus unbounded free-text specialties can exceed the original sheet burden. +- `R-25` research — up to 50 progress points, repeated rolls, two difficulty mechanisms. +- Five universal tech tiers — "an unnecessary taxonomy with little setting truth". +- Pursuit packages plus separate allocation pools. Give each pursuit a fixed package plus at + most three player-selected increases; do not reopen point-buy per career. +- Use BRP's "remove statements" option as the default combat mode: declare and resolve on DEX rank. + +### Foundry blockers +1. `R-24` — contradictory permissions, no defined unlock scope. +2. `R-25` — no step durations, difficulty modifiers, progress floor or ownership. +3. Species, pursuits, equipment, armour, weapons, defects and vehicles are referenced but have + no schemas and no data. + +### Agreed direction for v0.2 +1. Delete `TECH_TIER_GAP` as written; replace with technological-tradition familiarity plus + explicit material/weapon traits. +2. Rebuild Research as typed, expedition-generating steps producing Discoveries. +3. Restore `Ringworld` skill's real remit. +4. Add the expedition/travel procedure (Supply / Power / Vehicle Integrity). +5. Add a first-contact procedure. +6. Fix all arithmetic and BRP-conformance errors listed above. + +### Outstanding disagreements from round 1, needing the human owner's call +- **DR-04** (ranged weapons: Perception vs Combat category) — Claude proposed Combat for BRP + conformity; Codex wants Perception preserved for setting fidelity. Genuine design split. +- **DR-06** (tech gap model) — Claude's tier taxonomy vs Codex's familiarity model. Codex's is + better; noted for adoption unless the owner prefers the simpler tier table. + +--- + +## Round 2 — Codex review of RINGBRP_RULES_SPEC_v0.2.md +**Date:** 7 August 2026 · **Reviewer:** Codex (GPT-5.4) · **Author:** Claude (Opus 5) + +### Headline — the round-1 verdict is reversed + +> **"No, it is no longer generic BRP sci-fi wearing Ringworld nouns."** + +Evidence Codex cited, all mechanical rather than cosmetic: open-ended Earth-human POW expressing +Puppeteer breeding (`R-05a`); age/longevity/multi-pursuit generation producing Known Space +characters (`R-09`, `R-29`); Megastructure Lore with its exact 1%-on-arrival macrostructural +role (`R-17`); Ring distance consuming Power the local civilisation cannot replace (`R-31`); +field expeditions producing Discoveries that unlock specific Builder functions (`R-25`/`R-26`); +publication creating a return-to-Human-Space loop (`R-26a`); scrith, GP hull and stasis fields +changing what weapons can physically do (`R-24c`). + +> "The distinctive spine exists. The problem is now execution, not identity." + +### CRITICAL — v0.2 defeats the owner's own ruling + +`R-12a` made **Simple Category Modifiers the default**, and BRP's simple modifiers use the +**primary characteristic only**. So the default Ranged Combat bonus is `DEX/2` — and **POW and +CON contribute nothing**. The owner ruled that ranged weapons use DEX *and* the Perception +characteristics. The full-modifier table honours that; the default does not. + +Codex's fix, adopted: **Ranged simple bonus = `ceil((DEX + POW + CON) / 6)`**, and by symmetry +Melee = `ceil((STR + DEX) / 4)`. Must be corrected in v0.3. + +### Regression check — round-1 errors + +**FIXED:** fumble formula · difficulty ladder · opposed downgrade · POW rule · HP source and +rounding · broken cross-reference · `R-14` transfer breadth · `R-15` xeno-cap provenance · +`R-17` Ringworld remit · `R-19` parry/dodge exclusivity · `R-23` STR base · `R-24`/`R-26` +contradiction · research architecture and reward · stunner and variable sword · `P-3`/`P-5` +retraction · monofilament transfer group · publication reward. + +**NOT FIXED / NEW PROBLEMS:** +| Issue | Detail | +|---|---| +| `R-12a` | Defeats the owner's ranged-weapon ruling (above). **Highest priority.** | +| `R-27` simweb | Invokes "special failure", but `R-01` has no special-failure band. Map to fumble or the rule is unusable. | +| `R-27` experience | Allows experience on "any skill" while also saying Research is the only route past 100% for Mental skills. Add per-skill `experienceEligible`. | +| `R-25` library hours | Should be **100 hours**, not 200. *(Verified against source: "Normally, the time needed is 100 hours per step.")* | +| `R-25` multi-researcher | "ANY success completes / ANY fumble does not" is self-contradictory. Ruling: the success completes the step, the fumble still imposes its complication. | +| `R-25` new specialisation | "(highest related − 0)" is unclear and far too generous. Start new specialisations at printed base. | +| `R-10` | Promises location consequences but supplies **no table**. Rolling a location then rolling BRP's Major Wound table can contradict itself (location "arm", result "facial scar, lose CHA"). Write the seven-location consequence table. | +| `DR-01` chase | Still no opposed Athletics/Pilot pursuit procedure. Expedition legs are not combat chases. | +| `R-08a` / `R-24d` | Zero-g and vacuum are asserted as "familiarities" with no effects, acquisition rules or schema. | +| `R-30` | Automatic-effect powers have labels but no rule for counter matching, precedence, or where immunity tags live. | +| §8 | Character familiarity state, species, pursuit, weapon, armour, defect and vehicle schemas still absent. Deferring the *data* to v0.3 is fine; deferring the *schemas* is a v0.2 blocker. | + +### The four new subsystems + +**`R-24` Tradition and familiarity — right mechanic, wrong granularity.** +Numbers endorsed: keep 0 / −20 / −50 / locked (at skill 60 that reads 60% / 40% / 10%, a legible +spread). But analogy and unlocking belong to a **function**, not a whole device — a Builder +console can be analogous for *operate*, alien for *diagnose*, impossible for *repair*. Also, one +tradition per device cannot model hybrid Known Space tech. Restructure to +`functions.{operate,diagnose,repair}: {analogueIn, requiredDiscoveryIds}` with `traditionIds` as +an array. + +**`R-25` Research — generates play, calendar-heavy but not table-heavy, internally inconsistent.** +Codex's timing analysis: a four-step solo project at skill 50 averages ~1,600 hours (160 +ten-hour days); three researchers at 50% give an 87.5% per-step success chance, cutting it to +~92 days. Defensible if it is downtime. Needs: per-step `difficulty` (default Average, currently +referenced but never stored), a cap on participating rollers, stated objectives for field steps +(`resolveInPlay: true` is not an objective), and five as the maximum *initial* length with +fumble-inserted setback steps permitted beyond it. + +**`R-31` Expedition — light enough, but costs and skill roles are broken.** +- "−1 supply, −1 power; vehicle legs −1 power instead of −1 supply" is ambiguous. Ruling: foot leg + −1 Supply; vehicle leg −1 Supply **and** −1 Power. People do not stop eating inside vehicles. +- Allowing Pilot *instead of* Navigate lets a good driver find a rumoured destination without + navigating. Navigate governs every overland leg; Pilot mitigates hazards or a failed Navigate. +- Failure currently lets the party take extra time whenever time is cheap, dodging resource + pressure. Make failure cost baseline **plus one resource** and +50% elapsed time. +- Fumble: pay baseline, no progress, −1 Integrity, hazard. +- Critical should find a Site only on an **unexplored** leg, or players farm Sites on repeat runs. +- Recovery has no numbers: one day, one roll — success +2, special +3, critical +4. +- One Integrity track cannot describe several vehicles. Either name it abstract or track per asset. + +**`R-32` First contact — generates a scene, but READ is weak and Standing moves far too fast.** +The maths: at skill 60, only a special-or-better READ reveals the fact that makes OFFER Easy — +**12% of attempts**. The other 48 points of success merely report disposition. That contradicts +the claim that READ is the highest-value action. Fix: success reveals one taboo *or* desire; +special reveals both; critical reveals both plus +20% to a later stage. Remove Standing changes +from READ — silently understanding someone should not make them like you. Cap permanent Standing +change at **±1 per encounter**; +3 should need at least three contacts. And OFFER needs +communication gating: if SIGNAL failed with no shared language, verbal Bargain is unavailable, +though a nonverbal gift or ritual may be attempted at Difficult (Easy if READ found the form). + +### §0.3 — the hit-points pushback + +**Accepted, with a caveat that matters.** Codex agrees HP must halve because the location pools +are gone, and that keeping ~26 HP with no locations would remove the danger. But it rejects the +framing that this is an *equivalent* trade: + +> At HP 13 / Major Wound 7, a 1D20+5 variable sword causes a Major Wound on **95%** of hits and +> deals 13+ damage on **65%**. Against the original 26 general HP its base damage could never +> empty the pool in one hit, though it would devastate a location. v0.2 therefore converts +> frequent mutilation into frequent total incapacitation. + +Ruling: keep the formula, do not sign off the combat balance until signature weapon damage, +armour values and the missing location-consequence table are tested together. + +### Foundry schema failures (12 found) +Highlights: no character familiarity state anywhere; device modelling too coarse for +function-level analogy; Discovery has two competing authorities (`unlocks` is function-specific, +`scope` is tradition-wide) with no canonical matching rule; `holders` contradicts party +ownership and raw Actor UUID arrays break on import/duplicate; numeric `base` cannot express +`DEX×2` or `EDU×5`; projects omit difficulty, remaining hours, researcher caps and setback +links; the scrith material entry silently drops `deep_radar`, `hyperwave` and neutrino +absorption; `ALL` has no tag semantics; no weapon schema exists although materials expect +penetration tags; expedition data lacks leg records, explored status and multiple assets; +combat defence state has no defined owner document. + +### Highest-value change for v0.3 + +> Make v0.3 a **complete vertical-slice playtest**, not a broad data release: one four-leg +> expedition with exact costs and hazards, leading to one native first contact, one three-step +> field research project, and one Builder device with function-level familiarity and Discovery +> data. Run that chain end to end **without GM invention or hand-edited Foundry state.** Fix +> every rule and schema that blocks it. + +> "That single slice will test all four new subsystems — and reveal more than fifty pages of +> untested equipment data." + +### Verification note +Ten of Codex's checkable factual claims have now been re-verified against the source corpus +across two rounds. **All ten held.** The only claim that initially failed to verify — library +research at 100 hours — turned out to be truncated by Claude's scratch deduplication script; +the un-deduped extraction confirms it. + +--- + +## Round 3 — Codex review of RINGBRP_RULES_SPEC_v0.3.md +**Date:** 7 August 2026 · **Reviewer:** Codex (GPT-5.4) · **Author:** Claude (Opus 5) + +### Arithmetic audit — Claude's published table was wrong in four places + +Claude independently recomputed every P(Major Wound) with an exact-enumeration script +(`scratchpad/mw.py`) rather than trusting either party. Codex's recomputation and Claude's agree +exactly. **The v0.3 §3.4 table contained four errors.** + +| Weapon | v0.3 published | Correct (MW 9) | +|---|---|---| +| Club / baseball bat | ~25% | **33.3%** | +| One-handed sword | ~30% | **37.5%** | +| Crossbow | ~25% | **18.8%** | +| Two-handed sword | ~60% | **76.0%** | +| Medium pistol | ~20% | **25.0%** | + +Confirmed correct: punch 0%, dagger 8.3%, heavy pistol 40.0%, heavy rifle 58.3%, shotgun close +74.1%, hand beamer 50.0%, variable sword 85.0%, laser pistol/rifle 100%. + +**The design conclusion survives** — the ladder still reads correctly from fists to lasers — but +the table must be republished with exact figures. + +### Major Wound threshold — floor it at 9 + +Codex: `ceil(HP/3)` gives a light human (HP 23) a threshold of 8, and the step from 9 to 8 +**triples** the dagger result (8.3% → 25.0%) and doubles several others. Verified independently. +Lower HP already makes small characters easier to drop; the extra wound cliff is unearned. + +**Adopted: `MAJOR_WOUND = max(9, ceil(HP/3))`.** + +Also: Claude's claim that 9 is "almost exactly BRP's absolute number" is **false** — BRP's is ≈7. +Nine is justified because it produces the right weapon ladder, not by BRP parity. Rationale text +must be corrected. + +### Marksmanship Style — two dead rules and a trap + +1. **"Reflex: first Dodge ignores the multiple-defence penalty" is a no-op.** The first defence in + a round never carries that penalty. Dead rule. If a benefit is intended, it must be *"the first + Dodge does not increment `defencesThisRound`."* +2. **"Deliberate: may aim across consecutive rounds, maximum +30%" is a no-op.** Deliberate + already reaches +30% from a single round of aiming. Dead rule. +3. **Gravity does not actually drive the choice.** Heavy-gravity characters roll DEX 2D6+6 *and* + POW 2D6+6 — both average 13. There is no mechanical pull toward Deliberate; the link is pure + flavour. Codex: make gravity **determine** the governing characteristic (light = DEX, + heavy = POW, normal = choice) rather than merely suggest it. +4. **Deliberate becomes a trap at high skill.** Codex modelled both styles at weapon 60 and found + a genuine trade (Reflex +31 moving/short; Deliberate +9 long, +7 aimed). But around weapon 90 + the bonuses hit the 95% success ceiling, so Deliberate's advantages stop paying while its −20% + move-and-fire penalty persists — and style is permanent. Fix: give Deliberate something that + survives the ceiling, e.g. *a full round of aim treats long range as medium*, or *an aimed hit + improves one success level*. + +### Major Wound consequence table — not ready + +- Results 1–2 make **20% of all Major Wounds** merely "lose one action". Result 3 is nearly the + same. Too much of the table is nothing. +- Results 4–5 and 8 have the attacker choose arm or leg — that **is** location consequence, so + the "effects, not anatomy" claim is false. Assign 4 to manipulation impairment and 5 to + mobility impairment; remove the choice. +- "Hand unusable" and "two-handed actions at −30%" contradict each other — two-handed use should + be impossible. +- Disabled leg halves MOV but says nothing about Dodge, Athletics, falling or standing. +- Result 7 never says which skills, who picks the sense, or the treatment difficulty and time. +- Result 8's "permanent if untreated" conflicts with the Luck roll, which only governs + characteristic loss. +- Result 9 makes the GM invent the characteristic and never defines "out of the fight". +- **Repeated Stamina rolls have no stopping condition.** At CON 13 a character has only + `0.65³ = 27.5%` chance of still being conscious after three checks. Successful First Aid must + stop the sequence. +- §3.2 says the table fires "at most once per fight"; §3.5 says it fires on every Major Wound. + Contradiction with no state to back either. +- "Permanent" characteristic loss contradicts the later statement that an autodoc reverses it. +- Variable sword shifting only 8–10 → 10 yields amputation on ~28.5% of hits. If the intent is + the source weapon's mutilation rate, shift **4–10 → Catastrophic** (~66.5% of hits). + +### Round-2 carry-over: 20 closed, 11 reopened + +**Closed:** simweb trigger · library hours · mixed research results · new specialisation base · +step difficulty · step cap · field objectives · maxResearchers · leg cost · Navigate vs Pilot · +critical-on-unexplored · READ · Standing cap · OFFER gating · function-level analogy · holders · +experienceEligible · sensor properties · `ALL` removal · chase procedure. + +**Reopened / partial:** gravity enforcement and high-skill balance (`R-12b–d`) · Major Wound table +internals (`R-10`) · which resource failure consumes and who chooses (`R-31`) · recovery-fumble +target (§5 and §8 disagree) · two competing Integrity authorities · `unlocks` cannot resolve +because devices have no `deviceId` · `skill.base` formula grammar undefined · weapon schema +exists but is not buildable · **vacuum still has no effects or acquisition rule** · counter vs +immunity conflated with unrepresented precedence · species/pursuit/equipment/defect/vehicle +schemas **falsely marked closed — still absent**. + +### Schema failures (worst) +No `deviceId`, so `device.cziltang_brone.operate` is a dangling reference · raw unlock paths need +string parsing and are not import-safe (use `{documentId, functionId}`) · derived HP and MW are +stored with no recalculation rule and will drift from CON/MAS · `activeWounds` can encode only +Bleeding · `rateOfFire: "1/3"` is an uninterpreted string with no round semantics, ammo cost or +reload · `shots` conflates capacity with current ammunition · shotgun has two competing damage +authorities · weapon `traditionId` singular vs device `traditionIds` array · armour lacks equipped +state, degradation and damage-type interaction · material `immuneTo` and weapon `penetrationTags` +are different namespaces with no matching rule · device functions lack governing skill, difficulty, +effect, duration and power cost · project steps are not a discriminated union · expedition legs +lack difficulty, duration, cost and table references · these are `system` fragments, not Foundry +documents — no `name`, `type`, `_id`, schema version, migrations or validation. + +### Vertical slice — not buildable + +> "No. The slice in §6 is a requirements list, not runnable content." + +Missing: four actual leg records · the six-entry site and hazard tables · which resource a failure +consumes · Hanging People group statistics · their true taboo, desire, correct offer form and +rewards at Standing +1/+2/+3 · complete READ/SIGNAL/OFFER procedure (v0.3 has only amendment +summaries) · three complete research-step records · the brone's per-function skills, difficulties, +effects and power costs · Machine People raider statistics, ammunition, tactics and encounter +geometry · pregenerated explorers · firearm rate-of-fire and reload rules · executable wound and +treatment rules. + +**Document-level contradiction:** the scope line says unchanged rules "carry over" from v0.2, but +the acceptance test permits *only this document*. A playtest spec must either contain the complete +procedures or formally import a versioned rules core. + +### Verification note +Claude verified the arithmetic independently rather than accepting it. Codex's recomputation was +correct and Claude's published table was not. Across three rounds, every checkable Codex claim +that has been tested has held. + +--- + +## Round 4 — Codex acceptance test of RINGBRP_v0.4_PLAYTEST_SLICE.md +**Date:** 8 August 2026 · **Reviewer:** Codex (GPT-5.4), running §15 as a GM · **Author:** Claude (Opus 5) + +### Verdict + +> **"FAIL. v0.4 is not runnable with zero GM invention."** + +This is the correct and useful outcome of a first acceptance test. The rules survived three rounds +of review; they did not survive contact with actual content. That is what the slice was for. + +Codex played the scenario start to finish, logging every point where it had to invent. It also ran +a 100,000-fight Monte Carlo model of the combat encounter. + +### The three hard blockers + +**1. The scenario gate is mathematically shut.** The control plate — the object the entire research +chain depends on — is a Standing **+3** reward (§11.4). But permanent Standing is capped at **±1 +per encounter** (§6, a cap adopted from Codex's own round-3 advice), and the slice contains exactly +one contact. **+3 is unreachable.** Falling back to the random Site table, the plate requires a +critical Navigate on an unexplored leg followed by a 2 on 1D6: + +> `1 − (1 − 0.02/6)² = ` **0.666%** + +Two rules that were each individually correct combined into a scenario that cannot be completed. + +**2. Combat cannot be resolved consistently.** There are **no general range modifiers anywhere in +the system** — so a pistol at 60 m fires at its full rating, directly contradicting §12.3's "their +weapons are useless at 60 m". Also missing: the hand beamer's range bands, the raiders' gravity +and marksmanship styles, movement distance while firing, DEX-tie resolution, a definition of +"down" for the morale trigger, and a minimum of zero on post-armour damage. + +**3. The device is unusable even on success.** The brone is stored at Integrity 0. Research +unlocks `operate` but nothing repairs it — unlocking and repairing are different states with no +transition. Worse: complying with the raiders (an outcome §12.3 explicitly endorses) costs 3 +Power, leaving 3, and `operate` costs 4. + +### §12.4's Major Wound claim is false + +The slice advertises that the heavy rifle exercises the Major Wound table at **58.3%**. Against the +party's actual armour: + +| Target | P(Major Wound) per hit | +|---|---:| +| Unarmoured, MW 9 (the quoted figure) | 58.3% | +| Dunn / Tomas, armour 4 | **8.3%** | +| Ceyla / Rakhi, armour 6 | **0%** | + +Fix: give the two rifle raiders Deliberate style and have them alternate aim and fire; their aimed +shots ignore 4 points of armour and would genuinely exercise the table. + +### Combat model — neither style dominant, and Claude's suspicion confirmed + +Sustained party output: Reflex pair 5.04 HP/round at entry, 6.44 at short range; Deliberate pair +6.22 HP/round. Deliberate is marginally better at entry, Reflex at close quarters. + +> "Neither style is indispensable... If the beamer is ruled out of range, Reflex becomes clearly +> dominant. **The missing beamer range controls the conclusion.**" + +Claude had independently flagged that the geometry might not test the styles as designed. Confirmed: +the pneumorifle's 200/400/600 bands mean the 60 m engagement is *short* range, where Deliberate's ++10% long-range bonus never triggers. + +Monte Carlo (100,000 fights): party forces morale in 99.3–99.96% of runs, but casualty rate swings +from **14.5% to 81.2%** purely on whether the raiders focus fire or spread — an unstated NPC ruling, +not player tactics. + +### Claude's three self-found defects — all confirmed by Codex + +| Defect | Codex | +|---|---| +| §13.2 applies the facility modifier after the difficulty multiplier, contradicting §1 | Confirmed `[CONTRA]`. §1 gives 26%/20%, the worked example gives 16%/10% | +| §14 familiarity example says "5%" then "1%" | Confirmed `[CONTRA]` — "calls the result effective 5%, then says the floor applies, then says the floor does not apply, finally resolving it at 1%" | +| Physical category governed by STR, not DEX | Not raised by Codex, but stands — it makes Dodge, Athletics and Stealth strength-based, penalising the light-gravity pilot by 4 points | + +### Pregens — verified sound + +Codex independently recomputed every derived value for all four explorers. **All correct**, matching +Claude's own audit exactly. Skills judged "face-plausible" for the stated roles. Two caveats: +legality cannot be audited without an age/pursuit allocation procedure and a printed skill-base +catalogue; and Tomas carries a "minidoc" which §3.5's autodoc list (deskdoc/shipdoc/megadoc) does +not define. + +### Research timing + +Claude's 24.4% combined figure is arithmetically right *under the published (wrong) modifier order*, +but the calendar claim is not: expected **82 days**, not 60–80. Under §1's correct order it is +**49 days**. The cracked-plate penalty is catastrophic — a further −20 under difficulty-first +ordering drives both researchers to ~1%, giving **~1,005 expected research days**. + +> "This is dead time, not play: identical rolls occur after 16–20 day gaps with no intervening +> decisions, threats or resource pressure." + +Fix: replace the cracked penalty with a fixed 100-hour Average Repair step; make the 200 hours a +one-time investment with failed retries costing 40 hours and granting +10% cumulative calibration. + +### Other confirmed contradictions + +- **§1 fumble ordering.** The fumble test runs before the "96–99 always fails" clause, so at + effective values up to 80 some of 96–99 are fumbles rather than failures. Rewrite as: *"96–99 + cannot be an ordinary success; they are failures unless the fumble range includes them."* +- **§5** Pilot may re-roll a failed Navigate "once per leg" without restricting it to vehicle legs — + Dunn can use Pilot (Flycycle) while walking. +- **§5** recovery fumble must damage a *different* track, but rolls 4–6 damage Integrity even when + Integrity was being repaired. +- **§§10, 11.4** contact happens *after* leg 3, so the leg-3 rewards are retroactive. +- Ceyla has no Athletics rating despite hazards and the aperture requiring it. Suggested 32%. +- Site 3 (Ghoul waystation) is a legal random result with no statistics, taboo, desire or rewards. + +### Round-3 closure audit +17 of 22 closed. **Reopened:** result-8 permanence vs the universal Luck roll · full Foundry +documents ("incomplete JSONC specimens, not a creatable slice"). **Partial:** result-9 mapping for +multi-tag weapons · simultaneous wound sequences · the `majorWoundThisCombat` flag has no +mechanical purpose or reset · finite beam cells contradict "no reload". + +### Required for v0.5 +1. Move contact **before** the leg-3 roll; make immediate rewards depend on *impression* within the + encounter while permanent Standing still moves only ±1. +2. Add a general range modifier ladder: short +0, medium −10%, long −30%, extreme −50%, beyond + impossible. Retain the style +10%s. Movement with an offensive action = half MOV (15 m). +3. Give every weapon range bands in its own gear entry. +4. Give NPCs gravity origin and marksmanship style. +5. Make research completion set the brone's repair to Average at −20 familiarity; operation failure + costs 1 Power, not 4. +6. Fix the §1 modifier-order authority and apply it consistently to every worked example. +7. Physical category → DEX. + +--- + +## Round 5 — Codex acceptance test of v0.5 · **FAIL** +**8 August 2026.** 32 of 47 closed · 14 partial · 1 open · **10 newly broken**. + +**The decisive finding: v0.5's reachability claim was arithmetically false.** +Codex computed the informed contact route: READ 58 (special-or-better 12%), climb 48%, +best SIGNAL 29%, OFFER Easy 78%. Result: **P(Impression +2) = 1.697%, P(+3) = 0.174%.** +Modal outcome +1 at 55.1%. The random Site-2 fallback gave 0.499% (0.998% with a guide). +**The control plate remained gated.** Two versions had now failed on the same thing by +different routes. + +Other confirmed defects: 30-item acceptance log including undefined critical/special attack +effects, incomplete action economy, cover that both penalised and blocked, pistol positions +contradicting the bounds route, no rifle aim cadence (without which the Major Wound table never +fires against armour 6), incomplete targeting ties, unpublished printed bases (so §1's 5% floor +could not be applied — and repair's true floor is 5%, not the 1% published), unpublished item +masses (so the eye-storm hazard could not resolve), and the post-compliance Power budget leaving +the brone inoperable after a diagnose. + +Corrected maths Codex supplied and Claude accepted: research **93.5% by day 32, expected 25.9 +days** (not "20–32"); step 3 expected 1.65 attempts; repair is a **reflecting walk to net +Integrity 3**, expected **8.48 attempts / 33.9 hours** (not "three successes, eight attempts"). + +Codex's combat simulation (20,000 fights/case): in cover shooting the rifles first — 50% chance +someone goes down, 0.2% TPK. Out of cover — 88%. Shooting the nearest raider instead of the +rifles — **48% TPK**. The tactical difference is enormous and was undocumented. + +--- + +## Round 6 — v0.6 submitted +**8 August 2026.** + +### The structural fix — `R-37` +> **A scenario-critical object must never sit behind a probability gate.** +> Rewards accelerate; they never gate. + +The dead flyer is now a **fixed location**. Contact determines only speed and cost: +2/+3 +immediate escort, +1 one-day detour, 0 three-day detour and −1 Supply. Contact keeps real weight +through a new reward — researching as the band's guests costs **no Supply**, against ~9 Supply +otherwise across a 45-day project. + +### Also in v0.6 +`R-38` project-hours not researcher-hours · `R-39` critical ignores armour, special halves it · +`R-40` complete action economy (movement is free; cover, reload, aim and First Aid each consume +the action) · `R-41` cover penalises without blocking · `R-42` the immediate Stamina roll is the +first of three; unconsciousness 1D6×10 min · `R-43` targeting: nearest → lowest HP → lowest DEX, +and the down are not legal targets · `R-44` one OFFER per encounter · `R-45` margin defined · +`R-46` climb failure · `R-47` diagnose costs 0 Power after the Discovery and reveals the exterior · +`R-48` fumble Power cost · `R-49` net Integrity 3 · `R-50` abstract rations · `R-51`–`R-52` Ghoul +route completed · `R-53` complication 6 statted · `R-54`–`R-55` stationary Supply and hazards · +printed bases and item masses published. + +### Implementation +The ruleset now exists as a **working Foundry VTT system** at +`~/FoundryVTT/Data/systems/ringbrp` (v0.6.0), verified in a live Foundry v14 instance with zero +load warnings. Rules verified in-engine against the spec: fumble bands 98 at skill 60 and 96 at +20; 96–99 never succeed and 00 always fumbles; difficulty-before-situational (72 Difficult −20 = +16); HP and the floored Major Wound; the range ladder; critical/special/full armour; repair 5% +pre-Discovery and 38% post; diagnose 1%; aimed rifle 70% at 60 m. + +--- + +## Round 6 — Codex review of v0.6 · **FAIL**, but the gate is closed +**8 August 2026.** Of the 30 round-5 acceptance items: **21 closed, 6 partial, 3 open.** + +### R-37 worked — and then the same bug appeared twice more + +> "R-37 closes the control-plate discovery gate. It does not remove every single point of failure +> from the critical path." + +**1. The eye-storm could permanently delete the cziltang brone.** Rakhi's heaviest carried item +*is* the brone, at 12.5 kg — exactly what the hazard takes. Codex's arithmetic: 19.25% per +stationary hazard check, **57.5% cumulative across four**. The scenario would have died on a dice +roll more than half the time during the research phase. + +**2. Power could be exhausted to a dead end.** The rim-wall site was not declared a Power-recovery +installation, and each failed operation costs 1 Power at a 48% failure rate. + +**3. The "exactly enough, once" Power budget was false.** The now-guaranteed flyer yields 1D3 +Power, so the post-compliance budget is **5–7**, not 4. + +**4. The impression table was still wrong.** SIGNAL special (+1) plus OFFER special (+1) is **+2**, +not +3. Third time that arithmetic has been published incorrectly. + +### Code-vs-spec divergences found (14) +Stale RollTable wording · pistol route contradicting the journal · Dodge base 0 vs DEX×2 · Ghoul +weapon mismatch · missing configured expedition/project/discovery · device fields absent from the +schema · rifle cadence and corrected pistol positions stranded in the Foundry journal and never +moved into the written spec. + +--- + +## v0.6.1 — the response + +`R-56` **THE CRITICAL-PATH INVARIANT**, promoted from an ad-hoc fix to a standing rule: + +> *Nothing on the critical path may be destroyed, exhausted or made unreachable by a random +> result.* + +- Hazards never take mission-critical equipment. Rakhi loses the toolkit, not the brone. Non-critical + losses are recoverable on a Spot the next day. +- The rim-wall site **is** a Power-recovery installation. Running dry costs days and Supply, never + the scenario. +- Power budget corrected to 5–7. + +Plus `R-57` cell accounting · `R-58` the impression matrix, correct at last (+3 requires a +critical) · `R-59` rifle cadence and pistol positions moved into the spec · `R-60` the −3 archer +branch given a real procedure instead of a scope exemption · `R-61` scavenger theft procedure. + +**Foundry synced to v0.6.1** and verified live: 9 compendia including a fully configured expedition +Actor carrying the three-step project, the discovery with its structural unlock reference, and the +contact group with its taboo; Dodge base as a formula; hazard tables rewritten; Ghoul hook. + +### The pattern worth keeping +Three versions failed on the same class of defect in three different disguises — a gate, then a +destructible object, then an exhaustible resource. The lesson is not any of the individual fixes. +It is that **a critical path needs an invariant, not a patch**, and that only playing the thing +end to end reveals which disguise it is wearing this time. + +--- + +# Round 7 — Strike Rank (Option 1) implementation + statline audit +*8 Aug 2026. Brief: `reviews/BRIEF_r7_strikerank_statlines.md`. Raw: `reviews/r7_codex_review.md`.* + +Owner chose **Option 1, Deterministic Strike Rank**. Built, then reviewed. Codex returned +seven Part A findings and six Part B findings. Every claim below was checked against the +running system before being accepted or rejected. + +## Verified true and FIXED this round + +`R-57` **The expedition sheet was printing the answers to the first-contact challenge.** +The contacts tab rendered every taboo, every desire and `correctOfferForm` verbatim off the +embedded group item, while the expedition's own `knownTaboos` / `knownDesires` were empty and +`pidginEstablished` was false. The sheet displayed *"A gift of 0.5 kg of salt, passed hand to +hand, with both parties off the ground"* — the exact solution — to anyone who could open it. +Now: players see only what the expedition has actually learned, plus "Nothing learned about +this group yet."; the GM sees everything, marked GM ONLY with a yellow rule. +**This is the most severe defect found in seven rounds** — it silently defeats a scenario +pillar rather than producing a wrong number. Verified from both the GM and the player path. + +`R-58` **The PC-before-NPC tie-break never fired.** It tested `actor.hasPlayerOwner`, which is +false for every pregen in a world with no players yet assigned — so all four PCs were being +treated as NPCs. Now tests `actor.type === "character"`. Verified: Ceyla (DEX 12) correctly +precedes the DEX 12 raiders she ties with. + +`R-59` **Simultaneous combatants sorted alphabetically.** Replaced with insertion order, so the +result is stable and reproducible rather than an artefact of naming. + +`R-60` **Strike Rank went stale, so P-10 did not actually work.** Nothing recomputed initiative +when load, weapon, skill or DEX changed, so "carrying loot changes your action economy +mid-scene" was inert. Now recomputes on actor/item change. Verified live: dropping the 12.5 kg +cziltang brone takes Rakhi from **SR 8 to SR 6** unaided, and picking it up restores it. + +`R-61` **Training modifier applied to actions with no governing skill.** Move, Ready and Operate +Device were each taking the −30% "Untrained" +1. T is now only applied where the action has a +governing skill. + +`R-62` **Melee defaulted to the equipped ranged weapon.** Rakhi declaring melee was scored on her +Pneumorifle. The default weapon is now filtered to match the declared action. + +## Verified true, NOT yet fixed — these need an owner decision or are a separate build + +| # | Finding | Status | +|---|---|---| +| A1 | `E` uses `ceil` per `R-23` "each 10 kg **or part**". Tomas is **1.0 kg** over his free carry and pays a full step of MOV, skill *and* SR. Codex recommends `floor` — rejecting all four of my proposed fixes — which drops the PC mean E from 1.5 to 0.5 and removes the cliff, at the cost of departing from the 1984 rule. | **Owner decision.** Changes `R-23`, which also governs MOV and skill penalties, not just initiative. | +| A6 | `T` spread of −2…+1 equals the entire beam-to-slow weapon spread, so 79→80% skill can outweigh weapon choice. Codex recommends −1/0/+1. Neither 1984 nor BRP:UGE coupled skill to action order; it is wholly our addition. | **Owner decision.** Changes agreed Option 1. | +| A2 | **The declared action is not enforced.** SR is computed from the declaration, but the attack controls never check it and nothing marks the offensive action spent. A combatant can declare Reload, take the Reload ordering, and then fire anyway. SR is currently advisory. | Needs action-spent state. Separate build. | +| A3 | **Aiming is free.** The attack dialog offers an unrestricted "Aiming" checkbox that grants the bonus and armour bypass with no earned aim state. Confirmed at `ringbrp.mjs:1269`. | Interlocks with A2. | +| A4 | `reloadRounds` is duration being spent as ordering. A 3-round reload delays within the round instead of occupying three. There is also no reload resolution that transfers reserve ammunition. | Separate build. | +| B1 | The Discovery gate is not enforced, and cannot be until acquisition is representable: the expedition already **owns** the discovery item whose text says "Not yet earned", so presence ≠ acquired. `unlocks` and `requiredDiscoveryIds` are inverse copies that can disagree. | Needs an `earned` state + one canonical authority. | +| B3 | `material.immuneTo` has no invocation point — the damage button rolls a formula with no target and no material, so it cannot know scrith was struck. Also: spec v0.3 says `penetrationTags`, the code says `damageTags`. | Needs target-aware damage. | +| B4 | Device status is derived as functional only at full Integrity, ignoring per-function `requiresIntegrity`. Needs per-function `locked / damaged / unpowered / ready`, plus a `modifiedByDiscoveryId` distinct from hard prerequisites. | Carried from round 2. | +| B6 | `group` has no shape in spec v0.3 at all — it was invented for the Foundry build. Needs structured `offers[]`, reward IDs, negative-standing consequences, language gating for SIGNAL, and GM-only ownership of the definition. | Design work. | + +## Codex's ranked enforcement cut line for the statlines + +| Priority | Fields | Treatment | +|---|---|---| +| **P0** | `requiredDiscoveryIds`/`unlocks`, `immuneTo`, `materialId` | Hard gate or immunity, explicit GM override | +| **P1** | `skillBonus`, `powerDraw`, `blocksSensors` | Apply and show in the roll; debit resources | +| **P2** | `slowTo`, `neutrinoAbsorption` | Warn only — neither has a quantified meaning, so automating would invent a rule | +| **GM reference** | `correctOfferForm`, `rewardsByStanding` | Never auto-adjudicate prose; hide unrevealed, prompt the GM | + +## Correction to our own published work + +`INITIATIVE_OPTIONS.md` stated `E = floor(...)` and that Rakhi was the only encumbered +character. Both were wrong: the rule is `ceil`, and all four explorers are over their free +carry. The worked example has been replaced with the order the built system actually produces, +verified in Foundry 14.361. Codex also correctly noted the round-7 brief itself repeated the +stale `floor` — the brief was written from the doc, not the code. + +**Scoreboard.** Round 7 is the first round where the most serious finding was an information +leak rather than an arithmetic error, and the first where a defect (`R-57`) would have been +invisible in play until it had already spoiled a session. + +--- + +# Round 8 — encumbrance rework, readiness, dice, playthrough +*8 Aug 2026. Brief: `reviews/BRIEF_r8_encumbrance_readiness.md`. Codex round 8 was +still running at the time of writing; findings will be folded in when it returns.* + +Owner directives implemented, then tested by play rather than by inspection. + +`R-23` **revised.** Encumbrance now has two readings of one condition, deliberately: +`floor(over/10)` whole steps drive MOV and skill penalties as before, while a +continuous `ceil(over)/10` drives Strike Rank. Any overage at all now costs part of +a rank and no single kilogramme costs a whole one. The 1 kg cliff is gone. + +`R-69` **Initiative is no longer only about weapons.** The single skill term split +into two, each narrow: **T** training, the declared action's own skill, and **R** +readiness, the best of Tactics, Insight, Spot and Listen, floored by INT × 3 so a +clever but untrained character is not flat. New **Tactics** skill, Mental, base 5. + +`R-70` **The bands were tuned to a distribution that does not exist.** After +narrowing to −1/0/+1 the thresholds sat at 80 and 30 — but measured across all 102 +skill ratings in the world, weapon skills span **31–59** and top out at 59, so the +−1 band was literally unreachable and both T and R were flat +0 for every character. +Retuned to **55 / 35**, the observed quartiles. Skill now actually moves initiative. +*Found by measuring, not by reading.* + +`R-63`, `R-64` the two-axis modifier system and the ±40% two-modifier cap. +`R-65`–`R-68` defects found by playing: new characters started wounded, "Automatic" +difficulty rolled normally, aiming is free for rapid weapons, packs need a restart. +All detailed in `RULES_COVERAGE_AUDIT.md`. + +**Dice.** Three house sets registered with Dice So Nice — Void (flat), Shadow +Squares and The Arch — in the sheet palette with Michroma numerals, plus matching +in-chat tens/units chips that work whether or not 3D dice are enabled. +One real integration bug: `addTexture` returns a **Promise**, so colorsets +registered in the same tick silently lost their textures. Every call is now awaited. + +**Scoreboard.** Round 8's most valuable finding came from neither AI reviewing code +nor from the specs, but from measuring the actual data (`R-70`) and from creating a +character and playing it (`R-65`, `R-66`, `R-67`). + +# Round 9 follow-up — blockers closed, findings 5/6 and R-56 fixed +*8 Aug 2026. Codex returned FAIL on round 9 with two blockers. Both were correct.* + +`R-71` **The setting reproduced Fringeworthy's premise.** Under-ice gateway, ancient +builders, alternate Earths, and a rare compatible minority named for the fringe — +Codex was right that the combination could not read as coincidence, and that +"fringe-capable" was "Fringeworthy expanded into an adjective". Replaced with +Codex's own suggestion, which is a better idea than the original: **anyone can +cross, crossing is an induced misidentification, and Coherence is the accumulating +error.** No door, no builders, no network, no catalogue, no gifted minority. The +mechanic is now the reason travel works rather than a consequence of it. + +`R-72` **Both species were reskins.** Triune retained the entire Puppeteer design +and Ferren was still recognisably a Kzin. Replaced from the silhouette up: the +**Vesh** is bilaterally asymmetric with no head at all (senses on a dorsal ridge, +so no killing headshot and no face to read), and the **Cadence** is four to six +bodies that are one person, so the location roll picks which body is hit and +killing one reduces rather than kills. + +`R-73` **Displacement was prose only** — zero clamped and sat there, and recovery +would walk a displaced character back to 1. Now an event: resets to full, archives +the drift log, whispers the GM to change one true thing. + +`R-74` **"−X% everything" affected almost nothing** — Coherence was applied in the +skill-tab handler only, so attacks, characteristic rolls, Luck and case phases all +bypassed it. Moved to the single choke point in `skillRoll`. + +`R-75` **Coherence was entirely manual.** Now charged by the system: a fumble while +across, and any use of transposed materiel, both fire automatically; `debrief()` is +one operation and refuses to run while the team is across. Verified: 8 → 7 on a +fumble, 7 → 6 on the lance, blocked across, 6 → 7 at home. + +`R-76` **The case-file remap was naming only.** The phase resolver still chose a +"navigator" by Navigate; it now leads on **Tradecraft** with Navigate as fallback. +`RINGBRP.caseTracks` maps the stored fields to case vocabulary — supply→Resources, +power→Clearance, integrity→Containment — without breaking existing actors. + +`R-77` **R-56 violation confirmed and closed.** "Cannot open a path home unaided" +was a gate whenever no second agent existed. The way home is now never closed: +aided 0%, alone −30%, and a failed return lands you *near* the target rather than +nowhere. Verified: `possible=true` in both cases. + +# Playthrough — The Ilfracombe Crossing, 8 Aug 2026 +*Ran a whole case in the live world: import, four phases, a firefight, the device, +the way home, debrief. Nine defects, every one found by playing rather than reading.* + +`R-78` **The case file shipped with no team.** `members: []`, so the phase resolver +had nobody to lead and Debrief iterated an empty list. The published case was +unusable on import. Now built with its four agents. + +`R-79` **Two consumers, two shapes.** The resolver read `members` as bare ids; the +Debrief handler I wrote read them as objects. Standardised on ids. + +`R-80` **Bulk find/replace corrupted the language file.** The setting rename ran +`" leg" → " phase"` and turned hit locations into **"Right phase"**, `LocEffect.leg` +into "The phase gives way", and a chat title into "case team phase". Seven strings +repaired. The lesson is the one from the earlier case-sensitivity miss: a rename +sweep needs an audit pass with *different* patterns from the ones that made it. + +`R-81` **Two of the three case tracks were inert.** Across a full four-phase case, +Clearance and Containment never moved once — only Resources was driven by anything. +Failure now costs Containment, a fumble costs Containment 2 and Clearance 1. + +`R-82` **R-56 violated at case scale.** An exhausted track set `inCrisis` and the +sheet then *blocked* phase resolution — a case that ran out of Resources could not +continue at all. Now it degrades: phases go Difficult and cost Containment, and can +always be attempted. Verified with Resources 0 and Clearance 0: phase resolved, +`blocked=false`. + +`R-83` **Forced encounters were data nobody read.** Both dramatic beats of the case +— the contact and the ambush — were declared in the phase data and referenced by +zero lines of code. Now posted to chat when the phase resolves. + +`R-84` **The phase lead took the best of Tradecraft or Navigate** rather than +preferring Tradecraft, so Dunn's Navigate 67 beat the Tradecraft it was meant to +showcase. Now Tradecraft first, Navigate only as fallback. + +`R-85` **The device gate was never enforced** — Codex flagged this in rounds 7 and +9 and it was still true. `requiredDiscoveryIds` was read by no code, so the locked +function at the centre of the case could be operated on turn one. Now enforced with +four distinct refusals: locked, damaged, unpowered, ready. + +`R-86` **Presence could not mean acquired.** The discovery item shipped *on* the +case file, so "does the team have it" was true from the start. Discoveries now ship +`earned: false` and `earnDiscovery()` is the moment the case turns. + +**Scoreboard.** Playing found nine defects in one session, including two that made +published content unusable (`R-78`, `R-85`) and one invariant violation (`R-82`) +that the two AI review rounds had both missed at case scale despite catching its +character-scale twin. + +# Second playthrough — BRACKWATER, 8 Aug 2026 +*A deliberately different case: a Hollow world, unexplored phases, the tables, first +contact, and the new species anatomies. Four more defects.* + +`R-87` **First contact had no engine at all.** Standings, impression, taboos, +desires and `pidginEstablished` were all stored, the three-stage READ/SIGNAL/OFFER +procedure was written up in the slice — and **zero code** touched any of it. The +entire social pillar was prose over inert data. + +Worse, it stranded `R-57`. That fix hides a group's secrets until the team has +*learned* them, reading from `knownTaboos` — and nothing in the system could ever +write to `knownTaboos`. Players would have seen "Nothing learned about this group +yet" for the whole campaign, permanently. A privacy fix and a missing feature that +were individually invisible and together made the content unusable. + +Now implemented: three stages with their own skill pools, READ writing learned +taboos and desires back to the standing, SIGNAL establishing pidgin, OFFER the only +stage that moves standing, and `R-44` (one OFFER per encounter) enforced. Verified: +READ learned the taboo and the desire, impression reached 2, a second OFFER in the +same encounter was refused and allowed in the next — and the player view then +correctly showed exactly the one taboo and one desire the team had earned. + +`R-88` **A re-attempted phase re-fires its forced encounter.** A fumbled phase does +not advance, so the same forced encounter posts again. Left as-is for now — noted +rather than fixed, because "the thing you failed to get past is still there" is +arguably correct. + +`R-89` **Setting content survived in the group and slice prose.** The desire was +still "Salt. None in the canopy; the nearest seep is eleven days away", the site was +a "dead flyer", and one heading read "the The Quiet Neighbours" from an earlier +substitution. Four sweeps had passed because each used the patterns of the previous +rename rather than looking for what the *content* was still about. + +`R-90` **Cadence anatomy verified in play**: 26 hit points derived into five bodies +of 6, all `unit` kind, no vital and no head, location rolls distributing evenly. + +**Scoreboard.** Two playthroughs, thirteen defects. The pattern across both is the +same: the mechanics that were *reviewed* held up, and the ones that were only ever +*written down* did not exist. `R-85` and `R-87` are both cases where a document +described a system in detail and no code implemented any of it. + +# Third playthrough — wounds, displacement, healing. 8 Aug 2026 + +`R-91` **Armour was skipped on one of the two damage paths.** `resolveHit` applied +armour; `hitLocationRoll` did not, so the same blow did more damage when it went +through the location roll — the path the sheet button uses. Now applies worn armour +plus any location armour, with the critical bypass preserved. Verified: 7 damage +against armour 4 gives 3 through on a success and 7 on a critical. + +`R-92` **Healing existed nowhere and now exists.** Flagged in the coverage audit and +twice more in play without being fixed: a wounded agent could not recover by any +means the system offered. Implemented BRP-standard natural healing (1 per week), +First Aid (3, +20% with a kit, −1 on a fumble, once per wound), the field medic pack +(1 per 4 hours) and the trauma unit (1 per 2 hours). Location damage mends alongside +hit points. Characteristic loss is still not reversed, which is the standing rule. + +Verified: Dunn at 5/23 → First Aid +3 → 14 days natural +2 → 12 hours trauma unit ++6 → 16/23. + +`R-93` **CLOSED — location mending was silently dropping full heals.** In the same run, +a 2-point heal correctly mended `legR` from 3 to 1, but a subsequent 6-point heal +mended nothing at all despite 4 points of outstanding location damage. The mend loop +in `heal()` was correct; the write was not. **Foundry MERGES object updates**, so +`delete dmg[id]` on a cloned object leaves the stored value untouched — a location +mended to zero silently kept its damage, while a partial mend appeared to work +because it was a decrement rather than a deletion. My guess that +`prepareDerivedData` was rebuilding the object was wrong: that code only reads it. + +Fixed by writing per-key paths and removing cleared locations with Foundry's `-=` +deletion form. Verified against the exact sequence that failed: 6 trauma-unit points +now clear `{legR:1, armL:3}` to `{}`, a full heal empties the object, and a targeted +mend clears the named location first. + +Corrections to my own earlier reporting: `resolveLocationHit` *does* return +`majorWound`, which I initially read as a missing check — the flag was there and my +test harness simply did not print it. + +**Scoreboard.** Three playthroughs, sixteen defects. Two subsystems described in +detail by the design documents turned out to have no implementation at all (the +device gate, first contact), and one core rule had never been written in either +place (healing). + +# Fourth playthrough — the end-of-case loop. 8 Aug 2026 + +`R-94` **Experience was stored and never earned.** Every skill carried +`experienceTicked` and `experienceEligible`, the sheet drew the tick, and nothing in +the system ever set one — so the flags were decorative and no character could ever +improve. A successful roll now ticks its skill automatically, and `endOfCase()` runs +the BRP improvement roll on every ticked skill: roll over the current rating to gain +1D6, ticks clear either way. Verified in play — two skills ticked from actual rolls, +both rolled under and gained nothing, ticks cleared, and a second call correctly +does nothing. + +This is the step that closes the episodic loop the owner asked for: a case ends, the +agents are a little better, and the ticks reset for the next one. + +`R-95` **CLOSED — gear bonuses were prose.** The cover identity pack promised "+20% to Fast +Talk" and the medical kit "+20% to First Aid" in their descriptions, while +`skillBonus` shipped as `{skillFamilyId:"", value:0}` on every item and was read by +no code. Now populated at build time AND read: `gearBonusFor()` applies the best matching +carried item at the `skillRoll` choke point, so every path gets it. Only the single +best item applies — a pack full of medical kits does not make anyone a surgeon — and +dropped gear stops helping immediately. + +`R-97` **The medical kit had two authorities for one number.** `firstAid()` +hardcoded +20% while the item itself carried `skillBonus: +20% first_aid`, so once +the reader existed the bonus would have applied twice. The hardcoded copy is gone +and the item is now the only source. Verified: First Aid 78% → 98%, with two kits +still 98%, and 78% with the kit dropped. + +`R-96` **CLOSED — NPC targeting was prose only.** "Fires at the nearest visible +agent; ties go to the lowest current hit points" was written into three statblocks +and lived nowhere else, so every GM arbitrated it by hand and inconsistently. + +`npcTarget()` now resolves it in the published order — nearest visible, then lowest +current hit points, then lowest DEX, then the GM chooses from what remains — and a +DOWN character is not a legal target while anything is still standing, because +these enemies are being paid rather than fanatical and shooting the unconscious +wastes a round. `declareNpcTarget()` whispers the choice and the reason to the GM, +so the table can see the rule was followed rather than fudged. + +`npcShouldWithdraw()` implements the morale line from the same statblocks — "when +two of a team of six are down, the rest withdraw" — as a fraction, so it holds for +any group size. + +Verified across all eight branches: nearest wins; a range tie falls to hit points; +a hit-point tie falls to DEX; a full tie reports GM choice and returns the tied set; +downed targets are skipped while others stand; the downed become legal only when +nobody stands; hidden tokens are ignored; an empty field returns no legal target. +Morale fires at 2 of 6 and not at 1 of 6. + +**Scoreboard.** Four playthroughs, nineteen defects. Every one of the last three +sessions found at least one subsystem that was fully described in the design +documents and entirely absent from the code. + +# R-98 — the three "unloadable" fields, root cause. 8 Aug 2026 + +Not a Foundry bug, not a LevelDB bug, not a data-model bug. **Foundry was never +being stopped.** + +Every restart this session used `pgrep -f "main.js --dataPath=..."` to find and +kill the server. That pattern matched the *first* instance, which ran as a bare +`node main.js`. After the app was relaunched with `open -a`, it ran as the bundled +Electron binary with entirely different process arguments — so from that point on +`pgrep` matched **nothing**, `kill` was a no-op, and Foundry kept running. + +The check for "did it stop" used the same pattern, so an empty result was read as +"stopped" when it actually meant "not found". A verification that can only confirm +what it is looking for is not a verification. + +Everything else follows: Foundry held its original in-memory compendium for hours, +never re-read the rebuilt packs, and served a `MARKER` value that survived deleting +the entire packs directory — because the process that held it never died. The disk +was correct the whole time. So were the build script, the data model and the packs. + +Two false diagnoses were published along the way and are recorded here so they are +not re-guessed: that `prepareDerivedData` was rebuilding `locationDamage` (it only +reads it), and that concurrent read-write LevelDB opens were corrupting the packs +(a genuine hazard, but not this). + +Correct way to stop Foundry, whatever it was launched as — find it by the port it +is actually serving: + + PID=$(lsof -nP -iTCP:30000 -sTCP:LISTEN -t | head -1) + kill -TERM $PID + # then confirm the PORT is free, not that a name pattern is absent + +Verified after a genuine stop, clean rebuild and fresh start: marker gone, gear +bonuses present, case file members 4, discovery `earned: false`, and the full path +from compendium to actor to roll works — First Aid 78% → 98% with the kit. + +# Fifth playthrough — the import path, now that packs actually load. 8 Aug 2026 + +`R-99` **The shipped case file resolved to nobody.** `R-78` stored `members` as raw +actor ids taken from the compendium. Foundry assigns fresh ids on import, so after +a normal import the case file reported four members and resolved **none** of them — +the phase resolver had no lead and Debrief iterated an empty list. The count looked +right, which is why it survived the earlier playthrough: I checked `members.length` +and never checked that the ids pointed at anything. + +This was only findable once the pack-loading problem (`R-98`) was fixed, because +until then no import reflected the built content at all. + +Fixed by storing **stable keys** rather than ids: every pregen is stamped +`flags.ringbrp.key`, the case file stores those keys, and `caseMembers()` resolves +key → flag → id → name, tolerating all three shapes `members` has had over the +project's life. + +A second defect inside the fix: the flag was initially written before `...BASE()`, +which spreads an empty `flags: {}` and silently overwrote it. Order matters in +object spread and the symptom was an undefined flag with no error. + +Verified from a clean import: members resolve to all four agents, four phases run +with all three tracks moving (a phase-4 fumble took Containment 2 and Clearance 1), +contact resolves, the device gate correctly reports locked, and debrief recovers +Coherence for the whole team. + +**Scoreboard.** Five playthroughs, twenty defects. `R-99` is the third case of a +fix that made the *symptom* look correct while leaving the behaviour broken — +alongside `R-95` (data populated, no reader) and `R-57`/`R-87` (secrets hidden, no +way to learn them). Checking the number rather than the behaviour is now the +single most reliable way I have produced a false pass. + +# Sixth playthrough — the sheets as UI. 8 Aug 2026 +*Everything to date had been driven through the API. This drove the actual DOM.* + +`R-100` **The Resolve Phase button was permanently disabled.** The case-file sheet +computed its own phase lead — resolving `members` by raw id and picking on Navigate +— duplicating logic that `resolveLegDialog` had already been fixed to do via +`caseMembers()` and Tradecraft. So the API path worked perfectly while the button a +GM would actually click was dead, and the sheet reported `navigator: NONE`, +`blocked: true`. + +Two copies of one lookup, one of them fixed. The sheet now calls the shared +resolver. Verified by clicking: `Ceyla Marspont 55% (tradecraft)`, rollTarget 27, +button enabled, phase 0 → 1, two chat cards, tracks moved. + +Fixing it exposed two dangling references to the removed local (`ctx.blocked` and +`ctx.rollTarget`), both of which threw at render — caught immediately because the +sheet failed to open, which is the useful kind of breakage. + +## What the UI pass confirmed working + +Character, NPC, case-file, weapon, gear and skill sheets all render with no console +errors. 45 rollable elements on the character sheet. Clicking a characteristic +opens the roll dialog and posts to chat. The Coherence buttons move the track +(8 → 7). Tab switching works across all six tabs. On the case file: Across/Home +toggles, the six track-adjust buttons work, and Resolve Phase now runs. + +## Two false alarms, both mine + +I reported tab switching as broken; it was fine — I had several sheets open and was +reading `.active` from the wrong one. I reported the NPC sheet as failing to render; +it renders fine, my harness mishandled the element reference. + +**Scoreboard.** Six playthroughs, twenty-one defects. `R-100` is the fourth +duplicated-logic defect (`R-97` medical kit, `R-84` phase lead, `R-76` case tracks) +— every one the same shape: a rule implemented twice, fixed once. Worth a standing +rule of its own: when fixing a lookup, grep for every other place that does it. + +# Seventh playthrough — the journal and the tables. 9 Aug 2026 + +`R-101` **Old-setting vocabulary survived in the journal page titles.** "The Journey +— four legs" and "Combat — the defile" — plus body text — came through every +previous sweep because those sweeps searched for *proper nouns* (Kelnok, cziltang, +scrith) and the surviving words were ordinary ones the conversion had quietly +retired: "legs" for phases, "defile" for the terrain. Renamed to "The Approach — +four phases" and "Combat — the cliff path". + +A capitalised-variant miss inside the same fix: the first pass caught `defile` and +`four legs` but not `Defile` and `Four legs` at the start of a heading. That is the +same case-sensitivity error made earlier in the project, in the same file. + +284 documents now clean against a pattern including the ordinary-word retirees. + +`R-102` **OPEN — table effects are prose.** Drawing from the hazard table returns +its text and changes nothing: no track is debited, no roll is prompted, and there +is no table-effect function anywhere in the API. Confirmed by drawing a hazard and +watching Resources/Clearance/Containment sit unchanged at 6/6/9. Same shape as the +device gate and first contact before them — a mechanic fully specified in content +and implemented nowhere. **Not attempted; no code written.** + +## Duplicated-lookup audit + +Acting on the standing rule from `R-100`, I grepped for every remaining duplicate of +the lookups that have caused four defects. All clean: `system.members` is now only +read through `caseMembers()`, and coherence and gear bonuses are each applied +exactly once, at the `skillRoll` choke point. + +**Scoreboard.** Seven playthroughs, twenty-two defects. The pattern holds: reviewing +finds errors in code that exists; playing finds code that does not exist at all. + +# Round 10 — first playable build, and Codex as an adversarial GM. 9 Aug 2026 + +Built: 18 talents in four categories with a Talents tab; equipment to 27 gear, 18 +weapons, 10 armour; **76 custom flat SVG icons** on every item and actor, replacing +all stock Foundry art; icons wired through the skills, combat, gear and talents +lists. Verified in the running world: 27/27 items on a pregen carry custom art. + +Codex reviewed it as a cooperative-but-adversarial GM and the verdict is harsh and +largely correct. + +`R-103` **My "284 documents clean" claim in `R-101` was false.** The scenario's +opening was still, almost verbatim, the Ringworld case: "two falans", "four hundred +thousand kilometres to antispinward", "starboard rim wall", "spaceport ledge", +"Architect freeway". Every sweep I ran passed because every sweep searched a word +list built from the *previous* sweep — and none of those words was ever on it. The +README still read "Basic Roleplaying on the Great Arch" and so did the engine's own +first line. + +This is the fourth time a rename sweep of mine has passed its own check and failed +someone else's, and the first time I published a false all-clear on the strength of +it. **A sweep that greps for the terms you already thought of cannot tell you +whether you thought of enough terms.** + +Fixed: the case opening is now an actual Custodians brief — a Custodian team missing +eleven days, a beacon still transmitting, something holding a crossing open that +should not be able to. 329 documents now clean against a term list that includes +the megastructure vocabulary I had never checked for. + +`R-104` **Kit and training bonuses were silently colliding.** `gearBonusFor()` +iterated *every* embedded item, so talents were swept into the gear pool and the +"best single item applies" guard then discarded one of them — a medical kit and +Triage collapsed into a single +20 rather than +40. Now two separate pools that +stack, with best-of-one *within* each. Verified: Tomas 78% → 118%. + +## Codex's findings not yet fixed — all confirmed by inspection + +| | | +|---|---| +| **The talents are cosmetic.** No runtime reads `grantsAction`, `usesPerCase`, `usedThisCase` or talent `coherenceCost`. There is no Use control. Only the numeric `skillBonus` does anything, and only by the accident fixed above. | Large build | +| **First contact has no UI.** `resolveContact()` is API-only; the case sheet has no READ / SIGNAL / OFFER buttons, so the scenario's forced contact stalls the session dead. | High | +| **The case cannot be completed through the UI.** No resolve-step control for research projects, no Operate/Diagnose/Repair buttons, no way to earn the Discovery except from the console. | High | +| **Group rewards are inert arrays** — impression moves, nothing it promises happens. | High | +| **The journal contradicts the engine** — it says Navigate governs phases; the engine prefers Tradecraft. | Medium | +| **Ceyla has Cover Work but no Fast Talk skill**, so her signature talent cannot fire. | Medium | +| Research as written is ~44 days plus 34 hours of repair — not one session without a montage procedure that does not exist. | Design | + +**Scoreboard.** Ten rounds, 104 findings. The pattern of this round: everything +*visible* was built and works — sheets, icons, talents on the page — and almost +nothing behind the newest content is wired to a button. Codex's single most useful +sentence: *"the exact first-session stall is 'The Quiet Neighbours intercept you. +Now what button do I press?' There is none."* + +# Round 10b — contact and device controls wired. 9 Aug 2026 +*Answering Codex's two highest-severity findings: the first-session stall and the +uncompletable case.* + +`R-105` **First contact now has controls.** The case-file contacts tab carries +READ / SIGNAL / OFFER / New encounter per group, with live impression, standing, +pidgin and offer-spent state. Choosing who talks opens a picker **ordered by who is +actually best at that stage, with the rating shown**, so the obvious choice is the +default and the interesting choice is still one click away. Verified end to end: +picker listed Tomas 58% (insight) above Ceyla 46% above Dunn 25%, and READ resolved +to chat. OFFER greys out once spent and New encounter restores it. + +`R-106` **Device functions now have buttons.** Operate / Diagnose / Repair appear +under any device on the gear tab, each showing its own live blocker rather than a +generic disabled state — "Locked — the discovery that explains this has not been +made", "Too damaged. Needs Integrity 3; it has 0", "Not enough Clearance. Needs 4; +the case has 2". `useDeviceFunction()` resolves the roll, applies familiarity after +the Discovery, charges Clearance and Coherence, moves Integrity on repair, and +posts the function's own printed outcome text. + +Verified as a chain: Integrity 3 alone stays locked → earning the Discovery unlocks +it → Operate succeeds, charges **4 Clearance and 1 Coherence**, and dropping +Clearance to 2 re-blocks it with the reason stated. + +`R-107` **A cost of 4 was being charged as 0.** `powerCostAfterDiscovery` is absent +from the source data but the data model fills absent keys with **null**, so the +guard `f.powerCostAfterDiscovery !== undefined` passed and `Number(null)` gave 0 — +Operate ran free. Now `!= null`. Caught only because I checked the track moved +rather than that the button worked, which is the rule from `R-99` doing its job. + +Still open from round 10: the talents remain mechanically inert (no Use control, no +reader for `grantsAction` / `usesPerCase` / talent `coherenceCost`), group rewards +are still inert arrays, research projects have no resolve-step control, and the +journal still says Navigate where the engine prefers Tradecraft. + +# Round 10c — talents made mechanical. 9 Aug 2026 +*Codex round 10 finding 4: "a cosmetic talent release, not a mechanical one."* + +`R-108` **Talents now do things.** The split that makes this work is ACTIVE versus +PASSIVE, decided by the data rather than by hand: a talent is active if it costs +Coherence, is limited per case, or bends an action — those get a Use button. +Everything else is passive, has no button, and is read at the point it applies. +That prevents the failure mode where a sheet is covered in buttons that mostly say +"yes, you still have this talent". + +Active, verified: **Echo Sense** charges 1 Coherence (10 → 9). **Clean Scene** is +1/case, arms, refuses a second use, and its flag clears when spent. **Requisition** +trades 1 Clearance for 2 Resources or the reverse, refusing when neither track can +pay. + +Passive, verified: **Chain of Custody** absorbs a transposed cost entirely (a +1-point crossing key now costs Rakhi nothing). **Triage** adds a point to First Aid +(Ceyla 5 → 9 where a plain success gives 3). **Read the Room** learns a second fact +on a successful READ (Tomas learned 2 in one roll). **Debriefer** gives another +agent an extra point at debrief. **First Through** and **Out of Step** bend Strike +Rank to 1 and by −1 respectively. + +`R-109` **`endOfCase` reset talents after its early return**, so a character who +ticked no skills kept every talent spent — permanently, for the rest of a campaign. +Moved above the return: end of case is end of case whether or not anything was +learned. Verified: clean_scene 1/1 → 0/1 with zero ticked skills. + +This also answers Codex's `Chain of Custody` concern in the affirmative — it *does* +reduce a 1-cost item to free, which removes the central tension for that one agent. +Flagged for the owner rather than silently nerfed; the talent is doing exactly what +it says, and whether what it says is right is a design call. + +Still open: group rewards remain inert arrays, research projects have no +resolve-step control, and the journal still says Navigate where the engine prefers +Tradecraft. + +# Round 11 — generators, and Codex's verdict on them. 9 Aug 2026 +*Owner: "press a button and a character is created", with full customisation +still available, plus NPCs, monsters and things in between.* + +Built `generateCharacter()` and `generateNPC()`, both callable with **no arguments +at all**; a dialog that opens pre-randomised so the fastest path is open → Create; +buttons on the Actors sidebar; 5 roles, 4 experience tiers, 6 NPC threat tiers, +3 species, and a count field for 1-12 at once. + +Codex reviewed as an adversarial GM. Verdict: *"Do not ship this as fully formed +character generation. It is a competent statblock seeder."* It was right. + +`R-110` **Most combat NPCs could not use their own weapons.** The threat tier chose +the weapon while an unrelated random PC role chose the skills, so the two never had +to agree. Codex's measured numbers: Trained received no weapon at all, Armed 44%, +Dangerous 20%, **Apex 0%** — eight Apexes in ten had no usable attack. Replaced +`weapons: [...]` with `attacks: [{weapon, skill}]`, granted as one invariant. +Verified 10 of each tier: **60/60 weapons now have their skill**. + +`R-111` **"Make four" produced four copies of one character.** The dialog resolved +"random" ONCE and passed the same role to every actor in the loop, with species and +tier hard-defaulted. Now every choice resolves per actor, "Random (rolled +separately for each)" is an explicit option, and party mode guarantees role +coverage when several PCs are made with the role left random. Verified: a party of +four came out with three distinct top skills instead of four identical sheets. + +`R-112` **The generator was rolling characteristics that do not exist.** It rolled +`app` and `mas`; the data model has `str siz con int pow dex cha edu`. The Vesh +`app: -2` shift was therefore modifying a dead field — species generation was +mechanically lying. Also wrote `origin.homeWorld` where the sheet reads +`origin.world`, so every generated character had a blank home world. + +`R-113` **Two shipped weapons had no skill to roll.** The Collapsible baton needs +`melee_weapon:club` and the Error cutter needs `transposed_weapon`; neither existed +in the 50-skill catalogue. This was a **content bug affecting players**, not just +the generator — anyone handed a baton could not attack with it. Catalogue now 52. + +`R-114` **The Cover Work class of bug, reproduced and then closed.** A generated +talent whose `skillBonus` names a skill the character was never granted is dead on +arrival. The generator now grants that skill. Verified across all five roles: zero +dead talents, zero unusable weapons. Also added: everyone carries a **personal +anchor** (the character hook and the only Coherence recovery route) and an agency +handset, reserve ammunition is stocked at twice capacity, and the Liaison's +duplicated Trade Cant is deduplicated. + +Still open from Codex round 11: Containment starts encumbered by its own issued +kit; the bonus-point spray can produce one absurd skill; blank biography, fixed age +30 and identical portraits; and the gap between Armed and Anomalous where most +Custodians cases actually live. + +# Round 11b — encumbrance, biography and portraits. 9 Aug 2026 + +`R-115` **Everyone is issued a full kit; nobody carries all of it.** Containment +began every case at +0.78 SR for owning its own breaching charges. `stowOverload()` +now marks the heaviest items `carried: false` after kitting, so the kit is *issued* +and the player decides what comes off the vehicle. Containment is now +0.00 to ++0.34 depending on the STR roll, and every other role is exactly 0. + +Two flaws in the first version of that logic, both found by reading the output +rather than trusting it: + +- It stowed **everything** when it could never get under, because heavy armour is + never stowable — including shedding a 0.1 kg keepsake to fix a 4 kg overload. + It now only stows an item when that item is big enough to matter, and gives up + gracefully instead of stripping the character. +- The **personal anchor was being stowed**. It is the character hook and the only + Coherence recovery route, and it weighs 0.1 kg. It is now never stowable, and a + secondary melee weapon is, because an entry bar can be slung. + +The role's key characteristic is now the best of three rolls rather than two, so a +Containment specialist is actually strong enough to carry what Containment is +issued. The residual load on a low-STR Containment agent is the 11 kg breaching +suit, which *should* slow you down and is left alone deliberately. + +`R-116` **Biographies and portraits.** Every generated character gets a short +service record — role and tier, an origin drawn from their home gravity, how they +were recruited, a role-specific observation, and an **anchor**, which ties the +character hook to the Coherence rule rather than leaving it as colour. Ages are +26-54 instead of a fixed 30. 25 new portrait icons: ten baseline, five Vesh (mask), +five Cadence (three-body), five threat. Verified 4/4 distinct across a party, and +species-appropriate. + +A cluster of three Physicians in one sample of four looked like a distribution bug. +It was not — over 24 characters all five roles appeared 2-7 times and all six +anchors 2-6 times. Checked rather than assumed, and left alone. + +# Round 13 — Reaction Lanes replaces Strike Rank. 9 Aug 2026 +*Owner rejected Strike Rank as "too complex for Foundry"; then added that the game +must also run at a physical table with paper sheets, and that the random element +must be no more than 30-35% of the outcome.* + +`R-117` **Strike Rank removed entirely and replaced by Reaction Lanes.** + + Reflex = ceil(DEX / 3) + Awareness = ceil(readiness / 10) + Reaction Base = 2 x max(Reflex, Awareness) + Initiative = Current Base + d4 HIGHEST acts first + +Quick thought fully substitutes for quick hands — `max()` rather than a sum is what +removes the combat-training bias. Verified live: Ceyla reaches base 12 through +awareness, Dunn reaches the same 12 through reflexes, Tomas through awareness, +Rakhi 10. + +**The randomness ceiling drove the die size.** Measured as +`die range / (die range + printed-base range)` over a realistic envelope +(DEX 9-18, readiness 30-60): + +| | share | +|---|---:| +| `3 x Lane + d6` as first proposed | 35.7% — over | +| Dual-Axis `Reflex + Awareness + d6` | 45.5% — well over | +| **`2 x Lane + d4`** | **33.3%** | + +A d6 could never have worked: its range of 5 is too close to the entire printed +spread. Probabilities that follow: trained-and-advantaged versus +untrained-and-dragged is **100%** (the gap is 6, the largest opposed d4 swing is 3), +one band apart is **87.5%**, same band is a genuine **50%**. + +`R-118` **The paper sheet drove the design.** The base is calculated once at +character creation and never again. Beneath it sits a seven-box **modifier rail** +(−3 to +3) filled in once; a clip marks the box in force. Aim, Edge, Drag and Load +move the clip — they never trigger a recalculation. At the table a round is: move +the clip if anything changed, roll one d4, add it to the marked number. **One die, +one addition, no lookup.** The digital sheet renders the same rail. + +`R-119` **Coherence Flux without breaching the ceiling.** An additive ±1/±2 Flux +would have pushed the random share to 45-54%, so Flux **consumes the die instead of +adding to it**: at Coherence 3-5 the outer faces misfile (1 reads as 4, 4 as 1); at +0-2 every face inverts. The distribution stays uniform 1-4, so the ceiling and the +probability tables are untouched — but the die visibly lies, and a misfiled agent +acts at the wrong moment. Verified: Rakhi at Coherence 1 rolled a 3 and it was read +as a 2. + +**Four self-inflicted breakages during the excision**, all caught by reading the +console rather than assuming: removing the Strike Rank block also deleted the +module-level `ceil` helper, `readinessRating()`, and `percentileFaces()`, each of +which other subsystems depended on; and the `game.ringbrp` API object silently +failed to attach because it referenced one of them. A single undefined name in an +object literal takes the whole init hook down with no error attributable to the +system. Restored, and the API block is now verified by script for undefined +identifiers. + +# Round 13b — the invisible die, and the d6. 9 Aug 2026 + +`R-120` **The initiative die was never shown.** `rollReaction()` evaluated a `Roll` +and read `.total`, then posted a plain `ChatMessage` with rendered HTML. Foundry +only hands dice to Dice So Nice when a message actually **carries** the Roll, so +nothing animated and the roll was invisible — the owner reasonably concluded there +was no random element at all. + +Fixed by pooling every combatant's die into one `Roll` via `PoolTerm.fromRolls()` +and attaching it to the chat card with `rolls: [pool]`. Verified: the card now +reports `isRoll: true` with **4 dice objects**, and `showForRoll` fires **once** for +the whole group. + +A first attempt awaited `game.dice3d.showForRoll()` per combatant inside +`rollReaction`. That worked but serialised one full animation per character and +made rolling for a party hang long enough to time out. Removed — the pooled roll on +the message animates them all together. + +`R-121` **d4 → d6 at the owner's request.** Random share rises from **33.3% to +45.5%** of the printed-base spread. That is above the 30-35% ceiling he set +earlier, and he accepted the trade knowingly. Observed spread across the party went +from 12-17 to 14-17 with more movement between rounds. + +`R-122` **OPEN — three of the four agents share Reaction Base 12, and it is not a +bug.** Readiness 51-60 all round to Awareness 6, and Ceyla 55, Dunn 55 and Tomas 58 +are genuinely within three points of one another. The banding is faithfully +reporting that the characters are similar. Options measured: + +| awareness divisor | distinct bases | spread | cost | +|---|---:|---:|---| +| /10 (current) | 2/4 | 2 | — | +| /8 | 3/4 | 4 | awareness begins to dominate DEX | +| /7 | 3/4 | 6 | Dunn's DEX 18 stops mattering | + +No change made: separating them means making awareness beat reflexes more often, +which is a design decision for the owner rather than a defect to fix. + +# Round 14 — pregens differentiated, all content as Items, combat playtested. 9 Aug 2026 + +`R-123` **The pregens now pull the two initiative axes apart.** Three of four shared +Reaction Base 12 because their readiness was 55/55/58 — the mechanic was faithfully +reporting that the characters were nearly identical. Rebuilt so each sits clearly +on one axis: + +| | DEX | reflex | readiness | aware | base | led by | +|---|---:|---:|---:|---:|---:|---| +| Ceyla | 10 | 4 | 71 | 8 | **16** | awareness | +| Dunn | 18 | 6 | 34 | 4 | **12** | reflex | +| Tomas | 14 | 5 | 58 | 6 | **12** | awareness | +| Rakhi | 11 | 4 | 47 | 5 | **10** | awareness | + +Three distinct bases, spread 6. Ceyla is now the slowest on her feet and reliably +the first to act, which is the design working: she has usually worked out what is +happening a beat before it happens. Dunn and Tomas tie at 12 by *different routes* — +reflexes and awareness — which is the point of `max()` rather than a sum. Bios were +rewritten to match the numbers. + +`R-124` **All content installed as Items.** `installContent()` now covers every pack +— skills, talents, weapons, armour, gear and devices — **125 items** browsable and +draggable in the Items directory, not just the 70 skills and talents. + +## Combat playtest — three defects found and fixed + +`R-125` **HP was calibrated for weapons that no longer exist.** `hitPointsFor` was +`CON + SIZ`, double standard BRP, set deliberately when the weapon ladder was the +1984 one (laser rifle 1d10+30). The setting rewrite replaced those with modern +agency issue averaging 3.5-9 damage, and HP was never brought down. Measured +result: **ten rounds of melee with nobody dropped and armour absorbing 51% of all +damage**. Now `(CON + SIZ) / 2` — Dunn 23 → 12, Rakhi 29 → 15. + +Note for the owner: the Major Wound threshold is unchanged at 9, because the floor +was already doing all the work at both scales. But 9 is now 60-75% of a character's +pool rather than 35%, so a Major Wound is a much bigger event than it was. That +follows from your "floors at 9" ruling meeting the new HP scale — worth a look. + +`R-126` **Human NPCs had natural armour.** Cleaners carried `naturalArmour: 2` on +top of a stab vest, an alien-hide leftover from the old setting, making ordinary +contractors better protected (4 AP) than the agents shooting at them (2-3 AP). Set +to 0 for all human NPCs; the Revenant keeps its 4 because it is not a person. + +`R-127` **The compact pistol could not hurt anything.** 1d6 against 4 AP did +**nothing 66% of the time**, average 0.51 through — and it is the sidearm issued to +the Physician and Liaison roles, so those characters simply could not take part in +a firefight. Raised to 1d6+2, still the weakest firearm but functional. + +Also: **Dunn was issued a baton but trained in knife**, so he was swinging at his +untrained 5% club skill. Reissued a utility knife. + +## Result, 10 runs of each + +| | before | after | +|---|---|---| +| Ranged, 2 PCs vs 2 Cleaners | **PC 0/10**, avg 8.4 rounds | **PC 7/10**, avg 8 rounds | +| Melee, 2 PCs vs 2 Keepers | PC 10/10, avg 6.5 | PC 10/10, avg 6 | +| Ranged, full team vs 3 Cleaners | not run | **PC 10/10**, avg 4.4 | + +Armour now absorbs 28-39% rather than 40-51%. A fair fight is genuinely in doubt; +a full team against a smaller force wins in about four rounds. Melee remains +strongly PC-favoured, which is Rakhi: an entry bar with a +1d6 damage modifier +against unarmoured scavengers is meant to be decisive. + +# Round 14b — Major Wound at half hit points. 9 Aug 2026 + +`R-128` **Major Wound is now `ceil(HP / 2)`**, replacing `max(9, HP/3)`. + +The floor of 9 had been doing all the work at both the old and new HP scales, so +once HP halved it became 60-75% of a character's whole pool and a Major Wound +almost never fired. Half HP restores it to what it is meant to be: a blow that +takes you out of the fight, not one that nearly kills you outright. + + Dunn HP 12 → MW 6 Rakhi HP 15 → MW 8 + Tomas HP 13 → MW 7 Ceyla HP 13 → MW 7 + +A duplicate copy of the formula in `tools/content.mjs` still carried the old +`max(9, hp/3)` and was silently disagreeing with the engine — the packs were built +with one rule and played with another. Aligned. + +Dunn's biography also still explained the retired floor ("his threshold would be 8 +by the arithmetic; the floor raises it to 9") and carried a mangled phrase left by +an earlier blanket rename. Rewritten. + +## Measured effect, 10 runs each + +| | result | rounds | majors | +|---|---|---:|---| +| Ranged 2 v 2 | PC 8/10 | 6.9 | 23% of hits | +| Melee 2 v 2 | PC 10/10 | 4.9 | 19% of hits | +| Ranged 4 v 3 | PC 10/10 | 4.2 | 50% of hits | + +Roughly one hit in five now causes a Major Wound in an even fight, and the ranged +2 v 2 improved from 7/10 to 8/10 with a round shaved off. The 50% figure in the +4 v 3 is the team's heavy weapons — Rakhi's marksman rifle and Ceyla's Error lance +— against 2 AP contractors, which is what those weapons are for. + +# Round 14c — wound effects exercised and made real. 9 Aug 2026 +*The hit-location path had never been run inside a fight; every previous playtest +used `resolveHit`, which skips locations entirely.* + +`R-129` **Location sizes had not been rescaled with HP.** Fractions were set +against the old 26-point pool, so halving HP left a leg at 4 hit points against a +sidearm doing 4-6 through armour. Raised and, more importantly, set **explicitly by +location kind** rather than by chained string replacement — an earlier attempt at +this corrupted the values into `0.280` and `0.425` because each replacement ran over +the output of the last. + + leg .42 · body .42 · vital .50 · arm .35 · head .40 · unit .28 + +Measured over 150 clean hits each, against armour 3: + +| weapon | disables the limb | major wound | +|---|---:|---:| +| Compact pistol | 5% | 0% | +| Service pistol | 29% | 23% | +| Marksman rifle | 46% | 43% | +| Entry bar +1d6 | 56% | 53% | + +A sidearm rarely cripples, a rifle often does, a heavy melee weapon usually does. + +`R-130` **My own measurement was wrong first, and it was the R-93 trap again.** +The initial reading was "every hit disables a location, 6 of 6" and then "97-100%". +Both were artefacts: my harness reset with `{"system.locationDamage": {}}`, and +**Foundry merges object updates**, so nothing was ever cleared. Accumulated `taken` +values reached 382 and 434, which of course disabled everything. The fix is the +`-=` deletion form, exactly as recorded in `R-93` — and I did not apply my own +earlier lesson. The system was closer to correct than my test said. + +`R-131` **Wound effects were printed and never applied.** The chat cards were +right — "The arm is useless. Two-handed actions are impossible; −30% Manipulation", +"A vital hit. Knocked down, and a Stamina roll or unconscious" — but nothing read +`disabledLocations`, and a *destroyed* leg left MOV at 10. Now derived in +`prepareDerivedData` and applied at the `skillRoll` choke point alongside +encumbrance and Coherence: + +| state | MOV | effect | +|---|---:|---| +| healthy | 10 | shot at 59% | +| arm destroyed | 10 | shot at **29%** (−30 Manipulation) | +| leg disabled | **5** | −30 Physical | +| leg destroyed | **1** | prone | + +Clean six-round firefight afterwards: 9 hits, 4 crippled limbs, 3 major wounds, +two shots stopped dead by armour, PCs won with Dunn at 7/12 and a battered leg. + +# Round 14d — combat with wound effects live. 9 Aug 2026 + +The penalty is visibly load-bearing. From a narrated firefight, round 5: + + FOE 45%-30= 15% roll 6 success + +— a Cleaner whose arm had been crippled two rounds earlier, now shooting at 15% +instead of 45%. Dunn finished the fight on 1 hit point with MOV 5 and a ruined leg. + +Across 12 runs of each engagement: + +| | result | rounds | majors | +|---|---|---:|---:| +| Ranged 2 v 2 | PC 8/12 | 6.4 | 36 | +| Melee 2 v 2 | PC 12/12 | 5.5 | 22 | +| Ranged 4 v 3 | PC 12/12 | 4.4 | 57 | + +**No death spiral.** Only 8-14 shots per engagement were fired by an already-wounded +combatant, because fights resolve before the penalty compounds. The wound matters +in the moment it lands without dragging the loser through a hopeless tail. + +`R-132` **`destroyed` was tested against a single blow while `disabled` was +cumulative.** `resolveLocationHit` used `d >= max * 2` — this hit's damage — so a +limb could never be destroyed by repeated wounds, only by one enormous strike. The +actor's own derived data meanwhile computed `destroyed` from *cumulative* damage, so +the resolver and the sheet disagreed about the same limb: a location carrying 100 +points of damage against a maximum of 6 reported `destroyed: false` from the +resolver and `destroyed: true` on the sheet. Now cumulative in both places. + +That is the fifth duplicated-rule defect in this project (`R-97`, `R-100`, `R-104`, +`R-128`, and now this) — a rule implemented twice and changed in one place. + +**Caveat on an earlier figure.** The "47% of hits crippled" in the trials counts +repeat hits on a limb that was *already* crippled, because `disabled` correctly +stays true once crossed. The honest per-hit rate is the controlled measurement: +5% for a sidearm, 29% service pistol, 46% marksman rifle, 56% entry bar. + +# Round 15 — the duplicated-rule class, fixed at the root. 9 Aug 2026 + +Five separate defects in this project came from one shape: a rule implemented in +two places and changed in one — `R-97` the medical kit's bonus, `R-100` the phase +lead, `R-104` kit versus training bonuses, `R-128` Major Wound, `R-132` limb +destruction. Fixing them individually was not working, so this round fixes the +cause. + +`R-133` **`tools/content.mjs` carried its own copies of the engine's rules**, under +a comment that said so out loud: *"rule functions mirrored from ringbrp.mjs"*. They +had drifted twice. Major Wound was `max(9, hp/3)` in the builder while the engine +used `hp/2`; and — found live during this audit — **hit points were still +`CON + SIZ` in the builder after the engine had halved them**, so the shipped packs +carried Ceyla at 26 HP and Rakhi at 29 while the game computed 13 and 15. Derived +data masked it on load, which is why playtesting never caught it. + +**Fixed structurally.** New `rules.mjs` is the single authority for every pure rule +formula — hit points, Major Wound, damage modifier, marksmanship, location size, +the two Reaction axes and the base, encumbrance. It imports nothing from the +runtime and touches no Foundry globals, so both sides can use it: `ringbrp.mjs` +imports it in the browser, `tools/content.mjs` imports it in Node. Every local copy +deleted; verified zero re-definitions remain in either file. + +`R-134` **A guard, so the class cannot come back.** `tools/check-rules.mjs`: + +1. fails if anything outside `rules.mjs` re-defines a canonical rule, +2. asserts the builder's aliases *are* the canonical functions rather than copies + with the same name, +3. spot-checks eight formulas against known values. + +`build-packs.mjs` now runs it first and **refuses to build** on failure. Proven by +re-introducing the exact `hitPoints = con + siz` regression: the check failed with +`content.hitPoints is not the same function as rules.hitPointsFor` and exit code 1. + +Verified after rebuild: packs store 13/15/12/13 HP with Major Wounds 7/8/6/7, and +sheet and engine agree on every pregen for the first time. + +`R-135` **Codex found the one the guard missed: constants are rules too.** The first +version of `check-rules.mjs` policed rule *functions* only. `ringbrp.mjs` still held +`RINGBRP.reaction = { dexDivisor: 3, readinessDivisor: 10, laneMultiplier: 2, die: 6, +modCap: 3 }` — a second literal copy of `rules.REACTION`, with its own copy of the +divisors — and two places recomputed `laneMultiplier * max(reflex, aware)` inline +rather than calling `reactionBaseFrom()`. Exactly the defect being fixed, surviving +inside the fix. Now `RINGBRP.reaction = REACTION` by assignment, both call sites go +through the canonical function, and the guard fails on any object literal carrying +two or more keys of a canonical constant set. + +`R-136` **Discovery made the guard weaker, and the regression test caught it.** +Deriving the alias list by scanning for `export const X = Y;` meant that replacing an +alias with the drifted arrow function `(con, siz) => con + siz` stopped matching the +discovery regex — so it was never checked and the guard reported OK. The original bug +passed. Fixed by making a hand-held `REQUIRED` map authoritative, with discovery only +able to ADD: a missing alias now fails as loudly as a wrong one. + +`R-137` **The same drift in prose.** The Reaction comment still described a d4 and +quoted d4 probabilities after the die changed to d6; `README.md` still advertised +`CON + MAS`, `max(9, ceil(HP/3))` and gravity-based marksmanship. Both corrected, and +the code comment no longer restates any formula — it points at `rules.mjs` and carries +only measured behaviour. + +**Guard now fails on four escape routes, each verified by deliberate regression:** +an alias swapped for a drifted copy; an alias deleted; a rule defined in a brand-new +file; a rule added to `rules.mjs` with no spot-check. Plus the constant-literal case. + +**Measured, and worth the owner knowing:** on the shipped pregens the d6 puts the +random share at **45.5%**, above the 30–35% ceiling that was set. One band apart wins +72.2% (81.3% excluding ties). The d6 was chosen deliberately for table feel over the +d4 that hit 33.3%. `REACTION.laneMultiplier: 2 -> 3` returns the share to 35.7% +without touching the die — a one-line change, left undone because it was not asked for. + +`R-138` **laneMultiplier 2 -> 3, at the owner's instruction.** The random share is +now **35.7%**, inside the 30–35% ceiling. Verified live: bases 24 / 18 / 18 / 15. + +Measured on the shipped party over 200,000 rounds, both settings, load applied: + +| | share | Ceyla | Dunn | Tomas | Rakhi | +|---|---:|---:|---:|---:|---:| +| laneMultiplier 2 | 45.5% | 73.1% | 14.8% | 12.1% | 0.0% | +| **laneMultiplier 3** | **35.7%** | **94.8%** | **2.8%** | **2.4%** | **0.0%** | + +The ceiling was bought with order. Cutting the die's share does not spread the lead +around — it concentrates it. Ceyla goes from a strong favourite to a near-certainty, +and the two mid agents lose most of their share of the lead. Same band is still a +50/50 (16.7% ties, broken by DEX) and one band apart is 83.3%, but **two bands apart +is now 100%**: a six-point gap cannot be closed by a d6 at all. + +Rakhi never leads at either setting, so that is not caused by this change. It is her +−2 Load from carrying 38 kg against a free carry of 18. Dropping roughly 20 kg returns +her to 15 and puts her level with Dunn and Tomas — a real decision on the sheet rather +than a fixed disadvantage, which is the intent. + +`R-139` **A third duplicate, found while measuring.** `prepareDerivedData` recomputed +encumbrance inline — its own `Math.floor(over / 10)`, `steps * 5` and `over / 10` — +instead of calling `encumbranceFrom()`. It also wrote the over-weight under the key +`overKg` while `rules.mjs` returned `over`, so simply routing the call through the +canonical function would have silently zeroed every encumbrance penalty, because +`loadBand()` reads `overKg`. Fixed by returning both names from one computation and +saying in the source why both exist. The inline block is gone. + +**Still open.** `srLoad` — the exact fractional cost per kilogramme, written for the +old Strike Rank so no weight was ever free — is computed and displayed but no longer +used: Reaction Lanes reads `loadBand()`, a three-value integer band (0 / −1 / −2). +The granular rule the owner asked for survives in the data and not in play. + +`R-140` **srLoad wired into the rail — the granular rule is in play at last.** The +exact per-kilogramme cost, computed since R-23 and consumed by nothing, now reaches +initiative. The three-value band (`0 / −1 / −2`) is gone. + +**Load is not a rail position.** It is a property of what the agent is carrying, +known before the fight starts, so it is subtracted into the number printed on the +sheet rather than occupying a marker slot. The rail is now situation only — Aim, +Edge, Drag — which is what it should always have meant, and load can no longer be +double-counted by moving the clip. + +| | old band | now | +|---|---:|---:| +| 0.1 kg over | −1 | −0.1 | +| 1 kg | −1 | −0.1 | +| 5 kg | −1 | −0.5 | +| 10 kg | −1 | −1 | +| 20 kg | −2 | −2 | +| 30 kg | −2 | −3 | + +Gentler where it was absurd — the old band charged a full point for the first gram +over and then nothing more for the next 10 kg — and heavier where it was too kind, +since the cap at −2 meant nothing above 20 kg cost anything at all. + +**Rounded up, not to nearest.** To nearest made the first half-kilogramme free, which +verified live: a 1 kg weight added to Dunn moved his printed value not at all. `ceil` +to one decimal makes 100 g over cost 0.1 and nothing cost nothing, which is the rule +as stated. One decimal is the smallest unit worth writing on a paper sheet. + +**Floored at 1** — R-56 applied to encumbrance. No amount of kit reduces an agent to +acting never; it reduces them to acting last. A 300 kg overload prints 1, not −15. + +`R-141` **Ordering bug caught before it shipped.** `prepareDerivedData` derived the +reaction block *before* encumbrance, so the printed value read an unset +`sys.encumbrance` and would have silently printed a load of zero for every actor — +the same silent-wrong class as the double hit points. The block is moved below +encumbrance with the dependency stated in the source. + +Verified live: Ceyla 15.4 kg against a free carry of 12 prints **23.6** (base 24 less +0.4); Rakhi at 38 against 18 prints **13**. Adding 1 kg to Dunn moves him 18 → 17.9 +and removing it restores 18. Turn order sorted correctly on decimals, and +`CONFIG.Combat.initiative.decimals` was already 1. + +# Round 16 — combat played under the new load rule. 9 Aug 2026 + +Two fights, four agents against four Cleaners, opening at 25 m and closing to +contact. Driven through the real engine — `resolveBands`, `gradeRoll`, +`rangeBandFor`, `armourAgainst`, `damageAfterArmour`, `hitLocationRoll`, +`majorWoundRoll`, `woundPenaltyFor` — not a hand simulation. + +**Fight one: total party kill, three Cleaners still standing.** Tomas and Ceyla +both dropped to single critical hits, which ignore armour entirely. Rakhi survived +six rounds of concentrated pistol fire behind a breaching suit and then lost to a +critical. Losing two agents in the first two rounds against equal numbers is not +recoverable. + +**Fight two: agents win in five rounds, three standing, all four Cleaners down.** +The difference was Rakhi dropping her pack. + +`R-142` **The load rule works, and it produced a real decision.** Rakhi begins at +38 kg against a free carry of 18 — Load −2, printing 13, the slowest lane in the +team. In round one she acted **last of eight**, behind all four Cleaners. She spent +round two dropping her pack: 17.4 kg, Load 0, printing 15. From round three she +acted third or better every round and killed a Cleaner with the prybar. + +Measured over 100,000 rounds against a Cleaner printing 12: + +| Rakhi | acts first | tie | +|---|---:|---:| +| carrying 38 kg (prints 13) | 58.6% | 13.9% | +| pack dropped (prints 15) | 83.2% | 8.3% | + +A 24.6 point swing for one action. That is the trade the rule exists to create, and +it is legible at the table: the number on the sheet changes when the pack comes off. + +`R-143` **Armour is the dominant variable in Major Wounds, far more than hit points.** +Frequency of Major Wounds among service-pistol hits that beat armour, 200,000 trials: + +| | armour | MW threshold | hits through | of those, Major Wounds | +|---|---:|---:|---:|---:| +| Ceyla | 6 | 7 | 50% | **0.0%** | +| Rakhi | 7 | 8 | 38% | **0.0%** | +| Dunn | 3 | 6 | 88% | **28.6%** | +| Tomas | 2 | 7 | 100% | **24.9%** | + +A pistol maxes at 10, so 6+ armour makes a Major Wound arithmetically impossible on +a normal success, while 2–3 armour takes one every fourth hit. The pressure valve is +the critical, which ignores armour — and in both fights every agent who went down +went down to a critical. That is a defensible design, but the cliff between armour 3 +and armour 6 is much sharper than the two-point difference on the sheet suggests. + +`R-144` **Major Wounds change no roll.** The most serious finding. `majorWoundRoll` +rolls 1d10, resolves the table, posts a chat card with the full effect text, records +the entry in `system.wounds`, and shows it on the sheet — and stops there. +`woundPenaltyFor` reads `system.wounded`, which is derived **only** from +`locationDamage`, so: + +- a `manipulation` Major Wound, whose own text reads "−30% Manipulation", applies 0 +- a `mobility` Major Wound, "MOV halved", leaves MOV at 10 +- `concussed`, "−20% to all rolls", applies 0 + +Verified directly: with `manipulation` and `mobility` both recorded in +`system.wounds`, every `woundPenaltyFor` category returns 0 and MOV is unchanged. +Only `crippling` and `catastrophic` do anything at all, via the `isDown` check. + +Disabled *locations* do work correctly — a destroyed leg gives `physical −30`, +`movFactor 0.1`, `prone true`, and a destroyed arm gives `manipulation −30`. It is +the Major Wound table specifically that is prose. Same class as `R-102`. + +Observed live in fight two: Ceyla took a manipulation Major Wound in round two and +shot at exactly the same 53% in round three. + +**Not a bug found this round:** I first reported `woundPenaltyFor` as broken because +it returned 0 for `"physical"` and `"manipulation"`. Those are `system.wounded` +field names, not category ids. The ids are `phys`, `manip`, `melee`, `ranged`, +`comm`, `mental`, `percep`, and the function handles all seven correctly. + +`R-145` **Major Wound effects wired into the penalty pipeline — R-144 closed.** The +table is no longer prose. `woundEffectsFor(wounds)` in `rules.mjs` turns each row +into numbers, `prepareDerivedData` folds them into `system.wounded` on top of the +location-derived state, and `woundPenaltyFor` reads the result at the existing +`skillRoll` choke point. No new choke point was added: everything that already +respected a shattered arm now respects a concussion. + +| Wound | Applied | +|---|---| +| concussed | −20% to **every** category, via a new `all` term | +| winded | −20% Physical, `lostAction` flag | +| bleeding / deepBleeding | 1 and 2 hp per round, exposed as `wounded.bleed` | +| manipulation | −30% manipulation, melee and ranged | +| mobility | −30% Physical, MOV halved | +| sensory (sight) | −40% Perception, −40% manipulation/melee/ranged | +| sensory (hearing) | −40% Perception, −40% Communication | +| broken | as manipulation or mobility, by variant | +| crippling | defence only, MOV 1, counts as down | +| catastrophic | unconscious, removed from the fight | + +Three decisions worth recording: + +**They stack, and they stack with locations.** Two concussions are −40%; a +concussion plus a destroyed leg is −50% Physical with MOV 1 and prone. Both facts +are true about the same agent, so the numbers add rather than one overwriting the +other. Verified live. + +**The 1D2 variants are rolled and stored.** `sensory` and `broken` each split two +ways. That roll used to be left to the GM because nothing consumed it; now the +penalty depends on it, so `majorWoundRoll` rolls it, stores it on the wound entry +and prints it on the chat card. **A missing variant falls back to the harsher +reading**, so an old wound saved before this change can never quietly cost nothing. + +**`isDown` now reads the derived flag** rather than re-testing the wound keys, which +was a second place that knew which wounds end a fight. + +Verified through the real `skillRoll`, not a reimplementation: + +``` +healthy Firearm (Pistol) 59% → 59% ≤3 ≤12 ≤59 ≥98 +concussed (−20 all) 59% -20% → 39% Wounded: -20% ≤2 ≤8 ≤39 ≥97 +concussed + manipulation 59% -50% → 9% Wounded: -50% ≤1 ≤2 ≤9 ≥96 +cleared 59% → 59% ≤3 ≤12 ≤59 ≥98 +``` + +Critical, special and fumble bands all move with the penalty, and the chat card +names the cause. In a live fight Tomas took a catastrophic wound at 1 hp and was +correctly removed from the turn order for the rest of the encounter. + +Guard extended to 13 rules and 35 formulas, including the missing-variant fallback. + +**Two harness errors of mine, recorded so they are not re-found as engine bugs:** +`woundPenaltyFor` takes category ids (`phys`, `manip`), not `system.wounded` field +names; and `skillRoll` takes `rating` and `categoryId`, not a `skill` object — +passing one silently rolls against 0%. + +# Round 17 — combat with wounds live, and a correction. 9 Aug 2026 + +`R-146` **Correction: every fight in rounds 15 and 16 ran at double damage.** +`hitLocationRoll` applies the hit points itself (`ringbrp.mjs:929`) as well as +recording the location. My harness subtracted the same damage a second time. The +engine was never wrong; the playtests were. + +What this invalidates: the fight narratives in R-142 and R-144 — the total party +kill, "every agent who went down went down to a critical", and the impression that +Major Wounds rarely fire. What it does **not** touch: the load measurements in R-142 +(initiative does not depend on damage) or the armour arithmetic in R-143 (computed +directly, not from a fight). + +`R-147` **Wounds re-tested at correct damage, and they carry the fight.** Four agents +against two marksmen and two contractors at 30 m, twelve rounds. Eight Major Wounds +fired and every one of them changed what happened next: + +| | | +|---|---| +| Dunn | **catastrophic**, round 1 — unconscious, removed from the order, never acted | +| Tomas | **manipulation** — pistol 43% → 13%, missed everything after and died at 3 hp | +| Marksman | **manipulation** — rifle 55% → 25% | +| Marksman | **broken (manipulation)** — 55% → 25%, MOV 5 | +| Rakhi | **deepBleeding** — 2/round from 10 hp, dead in round 6 | +| Marksman, Cleaner, Cleaner | **bleeding / deepBleeding** — all three bled out | + +**Bleeding decides fights.** Four of the six casualties died of blood loss rather +than the blow that caused it, including Rakhi, who was on 10 hit points when she took +it. Two per round against 12–15 hit points is roughly a five-round death sentence and +there is currently no First Aid step in the loop to stop it — the rule assumes +someone spends an action, and nothing in play forces that choice to be visible. This +is the wound effect with the largest influence on outcomes and it deserves a check. + +**The -30% wounds are decisive rather than flavour.** Tomas at 13% and the marksmen +at 25% were all still standing and effectively disarmed. That is the intent of the +table text, now actually true. + +`R-148` **Stalemate at the tail.** Rounds 9 to 11 produced nothing: Ceyla at 53% +against a marksman at 55%, armour 6 against 1d10+2, both missing or being stopped. +Two roughly equal combatants with good armour and mid skills can circle indefinitely. +Worth a pressure mechanic — a closing clock, or escalating penalties for a stalled +exchange — rather than leaving it to the dice. + +Wound keys not seen this fight — `concussed`, `winded`, `mobility`, `sensory`, +`crippling` — were verified separately against every category and MOV in round 16. + +`R-149` **Bleeding fixed — the rule was missing both halves, failing in opposite +directions.** The table printed a rate and a cure and the game implemented neither: + +- **Nothing ticked the loss.** `wounded.bleed` was derived and displayed, and no + code ever subtracted it. At an actual Foundry table bleeding did *nothing*. +- **Nothing stopped it.** `firstAid` healed hit points and never cleared the wound, + so once my harness applied the loss it ran until the target was dead. + +That is why R-147 read as a death sentence: my simulation supplied the half the +engine was missing and none of the half that ends it. + +**Both halves now implemented.** `BLEED` in `rules.mjs` is the single authority for +the rate and for what stops it; `woundEffectsFor` reads it rather than carrying its +own copies of 1 and 2. + +| | per round | stopped by | +|---|---:|---| +| bleeding | 1 | First Aid, or Medicine | +| deepBleeding | 2 | **Difficult** First Aid, or any successful Medicine | + +`applyBleeding()` runs from the `updateCombat` hook; `stopBleeding()` is called by +`firstAid` on success and by the new `medicalCare()`, both routed through +`bleedStoppedBy` so there is one authority on what qualifies. + +**One deviation from the printed text, deliberately.** The table stops plain +bleeding with First Aid only. Medicine now also stops it, because the rule as +written left a surgeon unable to close the lesser wound they could treat. + +**A free tick, caught in testing.** The hook fired on entering round 1, charging a +bleeding agent before any round had elapsed — 15 hp became 11 by round 2 instead of +13. Now only round 2 and later charge. + +**No rebalancing was needed, and I checked before assuming.** Survival with a medic +retrying each round, 50,000 trials: + +| from | Difficult First Aid (23%) | Medicine-grade (46%) | +|---:|---:|---:| +| 15 hp | 87.6% | 99.3% | +| 12 hp | 78.8% | 97.5% | +| 10 hp | 72.9% | 95.5% | +| 5 hp | 54.5% | 84.3% | + +Bleeding was never too harsh — it was unstoppable. Treatable, it costs the medic an +action every round and still usually works, which is the decision the rule wants. +A single unlucky live run took seven attempts; the distribution says four. + +Verified live: the clock runs on round change, an Average First Aid does **not** +close a deep bleed, a Difficult one does, Athletics does not, plain bleeding closes +on an Average First Aid, and the loss stops at 0 rather than grinding negative. + +Guard now covers 15 rules, 2 constant sets and 44 formulas. + +# Round 18 — bleeding in play, and a defect that outranks it. 9 Aug 2026 + +Fights run on a real `Combat` document this time, so initiative came from +`rollReaction` and blood loss from the engine's own `updateCombat` hook rather than +anything in the harness. + +`R-150` **Bleeding works in play.** Two bleeds fired in the reported fight and both +became attrition clocks rather than announcements: + +``` +R3 Tomas pistol 43% → Marksman 2 critical — 8 dmg (hp 6) + ** BLEEDING ** bleeds 1/round + [engine bleed] Marksman -1 → 5 +R4 [engine bleed] Marksman -1 → 3 ← dead to a critical the next round +R5 Dunn knife 34% → Cleaner 2 critical — 6 dmg (hp 8) + ** DEEPBLEEDING ** bleeds 2/round + [engine bleed] Cleaner -2 → 6 +R6 [engine bleed] Cleaner -2 → 4 +``` + +Both wounded hostiles died at least partly to the bleed. The tick fires once per +round, from the engine, with no harness involvement. The First Aid branch did not +trigger only because both bleeds landed on hostiles; staunching was verified +directly in R-149. + +Wound penalties were visible throughout: Tomas's pistol fell 43% → 23% and Rakhi's +rifle 47% → 27% from concussions, for the whole rest of the fight. + +`R-151` **Initiative is rolled once and frozen for the entire combat.** The larger +find. Foundry keeps `combatant.initiative` across rounds and nothing re-rolls it, so +the d6 is drawn once per fight, not once per round. Verified over four rounds — the +same four numbers, unchanged: + +``` +round 1: Ceyla 25.6 · Dunn 22 · Tomas 19 · Rakhi 15 +round 2: Ceyla 25.6 · Dunn 22 · Tomas 19 · Rakhi 15 +round 3: Ceyla 25.6 · Dunn 22 · Tomas 19 · Rakhi 15 +round 4: Ceyla 25.6 · Dunn 22 · Tomas 19 · Rakhi 15 +``` + +Three reasons this is wrong rather than merely a choice: + +1. **The Aim rail is inert.** "Aim one round, fire the next" for +1/+2 cannot do + anything if the order never recomputes. The entire seven-box rail — Aim, Edge, + Drag — only means something between rounds. It currently means nothing after + round 1. +2. **The measured random share is per round.** 35.7% was tuned as round-to-round + variance. Drawn once, the variance is 0% for the rest of the fight and one draw + decides the whole engagement. +3. **The engine's own description says otherwise** — "current printed base + one + die" per round, and load, wounds and coherence all change mid-fight without ever + being re-read. + +Seen in the fight above: the order was identical for all seven rounds, and the +marksman that Ceyla out-ran in round 1 stayed out-run until it died. + +Not yet fixed — it changes every fight and is the owner's call. + +`R-152` **Not a bug: the Major Wound d10 is uniform.** Three catastrophic results in +one fight looked rigged. 4,000 draws give every row 9.3–11.0%. Variance, not a +weighted table. + +`R-153` **Initiative re-rolls every round — R-151 closed.** `RingBRPCombat.nextRound()` +now resets and re-rolls after `super.nextRound()`. Six consecutive rounds gave six +distinct orders; before the change, four rounds gave the same four numbers. + +``` +r1: Ceyla 28.6 Dunn 21 Tomas 20 Rakhi 16 +r2: Ceyla 25.6 Dunn 22 Tomas 19 Rakhi 19 +r3: Ceyla 25.6 Dunn 23 Tomas 21 Rakhi 16 +r4: Ceyla 27.6 Tomas 24 Dunn 22 Rakhi 19 +r5: Ceyla 27.6 Dunn 22 Tomas 19 Rakhi 18 +r6: Ceyla 28.6 Tomas 24 Dunn 21 Rakhi 15 +``` + +Three consequences deliberately handled rather than left to fall out: + +**Aim is spent, not permanent.** Aim is read by the roll it paid for and then +cleared, so "aim one round, fire the next" costs a round and buys one round. Left +alone it would have granted +2 for the rest of the fight the moment re-rolling made +it matter. Verified: mod 2 applied on the next round, flag 0 after, mod 0 the round +following. Edge and Drag persist — they describe equipment and conditions, which do +not evaporate at a round boundary. + +**The downed are not re-rolled.** Only combatants above 0 hit points and not +unconscious draw a die; the rest keep a null initiative, which the existing +comparator already sorts to the bottom. Before this, a corpse drew a d6 every round +and could sort above the living. Verified: Rakhi bleeds to 0 in round 3 and holds +"—" from then on. + +**The re-roll runs after blood loss**, so an agent who bleeds out in the round +transition does not receive a fresh initiative first. + +**Mid-fight changes now reach the order at last.** Rakhi dropping her pack moved her +printed value 13 → 15 and her very next roll used 15. Under frozen initiative that +decision — the one the whole load rule exists to create — could not affect the fight +it was made in. + +Played through: order churned every round (the marksman ran 24 → 22 → 24 → 19, +Dunn and Tomas swapping repeatedly), while Ceyla still led all five rounds. That is +`R-138` visible in play, not a fault in the re-roll: at laneMultiplier 3 she leads +94.8% of rounds. Re-rolling changes the *middle* of the order every round and leaves +the top almost fixed. + +Also seen: Dunn's manipulation Major Wound took his knife from 34% to **4%**, and it +stayed there for the rest of the fight. + +# Round 19 — Strike Rank residue, and the missing damage roll. 9 Aug 2026 + +`R-154` **Strike Rank survived in three talents and four dead lang keys.** The +mechanic was removed in round 12; its vocabulary was not. Found by sweeping code, +templates, language, packs and the live world: + +| | was | now | +|---|---|---| +| First Through | "declare an action at Strike Rank 1 once per fight" | "act first in the round no matter what anyone rolled" | +| Out of Step | "act as though your Strike Rank were 1 lower" | "+3 Reaction for that round — a full lane" | +| Quorum | "one additional non-combat action, at Strike Rank +2" | "resolved 2 points lower on the Reaction order" | + +**Four language keys were referenced by live code and did not exist.** +`RINGBRP.SR.ReadinessSkill` and `RINGBRP.SR.ReadinessInt` are read every time a +character sheet renders, so the sheet was printing the raw key string. Renamed to +`RINGBRP.React.*` and supplied ("from training" / "from wits"). +`RINGBRP.SR.DeclareHint` and `RINGBRP.SR.Action` belonged to +`templates/dialog-declare.hbs`, the old declare-your-action dialog, which nothing +has opened since Strike Rank was removed. Template deleted. + +Two mentions remain deliberately: the comment above `rollReaction` and a README +row, both of which say Reaction Lanes *replaced* Strike Rank. Those are history, +not rules. + +**A note on the search.** My first pack scan reported zero hits because +`ClassicLevel` could not open the databases while Foundry held the lock, and the +`try/catch` swallowed it — the same shape as `R-98`, a check that reports success +when it has not run. Re-run against the live world, it found eight. + +`R-155` **A successful attack did not offer its damage.** The roll ended at +"Success" and left the player to find the weapon on the sheet and click it +separately — and that path did not know what had been rolled, so **a critical did +ordinary damage**. + +Fixed at the cause, which was again duplication: `_onRollDamage` existed **twice**, +once on the character sheet and once on the item sheet, near-identical and neither +aware of the success level. Both now delegate to one `rollWeaponDamage({ actor, +weapon, level, band })`, and successful attack cards carry a **Roll damage** button +bound to it. + +Success levels now reach the dice (spec §4.4): + +``` +critical → 6, 6, 6, 6, 6, 6 maximum weapon damage, armour ignored +success → 6, 4, 5, 3, 3, 5 normal 1d4+2, full armour +``` + +The damage modifier is still *rolled* on a critical rather than maximised, per the +rule. The card carries the note — "maximum damage, armour ignored" for a critical, +"armour counts half" for a special — so the level is not lost between the two rolls. +The button disables itself once used, so one hit buys one damage roll, and it is +bound on render rather than inline so it still works after a reload. + +Verified live: button present on critical, special and success; absent on failure +and fumble; clicking produces "Utility knife — damage — armour counts half", total 6. + +`R-156` **Kit dropped onto a character was not readied.** The catalogue packs ship +armour and weapons with `equipped: false` — correct for a compendium entry, wrong +the moment one lands on an actor. A vest dragged onto a sheet contributed **0 +armour** and a pistol never reached the READIED strip. Nothing errored: the item was +there, the number simply did not move, which is the hardest kind of defect to notice. + +Fixed with a `preCreateItem` hook. **Only the first of each kind is readied** — +a spare vest or a second pistol stays stowed, because silently stacking armour points +or crowding the strip is the opposite failure. Anything arriving explicitly equipped +(pregens, generated characters) is untouched. + +| dropped | result | +|---|---| +| 1st armour | worn, armourPoints 6 | +| 2nd armour | stowed, armourPoints stays 6 | +| 1st weapon | readied | +| 2nd weapon | stowed | + +`R-157` **"ARMOUR 0" now says why.** Carrying armour you have not put on displayed +as a bare zero beside a list containing a vest, which reads as a broken sheet rather +than a state of the fiction. The box now turns red and prints **NOT WORN**, with a +tooltip pointing at the shield toggle on the Gear tab. Verified rendering as +"ARMOUR 0 NOT WORN". + +This was the reported symptom. The underlying cause was `R-156`; the display change +is so that if it ever happens again — a player unequips deliberately — the sheet +says so instead of looking wrong. + +`R-158` **`generateCharacter` threw on an unknown role.** `ROLES[role]` came back +undefined and the next line read `R.stat`, so a typo in a macro produced a stack +trace instead of a character. It now falls back to a random valid role, the way +`tier` already did. Found while testing the armour paths, not by design. + +Existing actors were checked and are unaffected: Ceyla 6, Dunn 3, Rakhi 7, Tomas 2. + +# Round 20 — legibility. 9 Aug 2026 + +`R-159` **Installed items now file themselves into folders.** `installContent` put +125 items into one flat alphabetical column, where a Service pistol sits between +Science (Physics) and Sleight of Hand and nothing can be found. One folder per pack: +Skills 52 · Talents 18 · Weapons 18 · Armour 10 · Gear & Devices 27, unfiled 0. +Folders are matched by name on re-install so running it twice does not produce +"Weapons (2)", and items already installed flat are swept into place, so an existing +world tidies itself rather than keeping two conventions at once. + +`R-160` **The Reaction number now shows its working.** "23.6" on its own is +unreadable — it does not say whether the agent is quick or merely alert, nor what +the kit is costing. A collapsible breakdown sits under the rail: + +``` +Reflex DEX 10 ÷ 3, rounded up 4 +Awareness Readiness 71 ÷ 10, rounded up 8 + Readiness comes from training: your best of Tradecraft, + Insight, Spot or Listen (71) — wits would only give 51 +Lane the better of the two × 3 24 +Load 15.4 / 12 kg — 3.4 kg over −0.4 +Reaction on the sheet — then add d6 23.6 +``` + +The Readiness line is the part that was genuinely invisible: it is the best of +**Tradecraft, Insight, Spot or Listen**, floored at **INT × 3** so a clever but +untrained agent is never flat. Ceyla is an analyst — DEX 10 gives her Reflex 4, but +Tradecraft 71 gives her Awareness 8, and the better of the two sets her lane. That +is the design working as intended: she is first in the order because she is alert, +not because she is fast. + +Put in the reaction area rather than the character's notes, deliberately — notes are +free text and would go stale the moment a skill or a pack changed, which is the +duplication defect this project keeps re-learning. + +`R-161` **"Error lance" renamed.** The name came from the setting premise — a +crossing is an induced filing error — but it reads as a bug rather than a weapon. +Renamed with its sibling, since "Error cutter" had the same problem and leaving one +would have broken the pair: + +| was | now | +|---|---| +| Error lance | **Redaction lance** | +| Error cutter | **Excision cutter** | + +Both keep the bureaucratic-horror register the setting asks for — the Custodians +correct the record — without naming a weapon after a fault condition. The journal +text was updated with them. + +# Round 21 — the sheet as a thing you read. 9 Aug 2026 + +`R-162` **The species drop-down is gone from Anatomy.** Every character sheet +offered a choice of all three anatomies, which is not a decision anyone makes while +playing — a Baseline is not one option among three, it is what the character is. The +tab now names the species and shows only its locations. The selector moved to Bio, +where identity lives, with a note that changing it changes the anatomy. + +`R-163` **A figure, drawn per species, on the Combat tab.** `RINGBRP.bodyArt` gives +each species profile a shape per location, keyed by the same ids as +`RINGBRP.locationTables` — so the drawing and the table are the same data and a +location cannot appear in one without the other. A species with no art simply draws +nothing rather than drawing a human by mistake. + +The anatomy is honest rather than generic: **Baseline** is a seven-part humanoid; +**Vesh** is asymmetric with one long grasping limb, one short bracing limb, a leaning +trunk and a sensory ridge where a head is not; **Cadence** is five separate bodies, +equal and unranked, which is the point of them. + +Five states, readable at sheet size without reading a number: +whole · grazed (any damage) · hurt (half) · disabled · destroyed (dashed outline). +Hovering a limb highlights its table row and the reverse, so the picture and the +numbers are visibly the same thing. + +`R-164` **The location table was ragged because every column sized itself.** Now +`table-layout: fixed` with a flexible name column, fixed 3.6em numeric columns, +right alignment and `font-variant-numeric: tabular-nums`, so digits line up down the +column instead of drifting with content width. + +`R-165` **Wounds moved onto the Combat tab**, beside the figure, with the full +effect text and the clear button. They remain on their own tab as well — nothing was +taken away, the information was simply put where it is used. + +`R-166` **A wounded agent is now visible on the first tab.** Major Wounds modify +every roll on the Skills tab and were legible only two tabs away. A red bar sits +above the skill list naming each wound and the arithmetic it is applying: + +``` +WOUNDED [Concussed] [Bleeding] all rolls -20% · Manipulation -30% · bleeding 1/round +``` + +It only appears when there is something to say. + +Verified live across all three species: 7 / 6 / 5 parts drawn, states resolving +correctly against real location damage, drop-down absent from Anatomy and present on +Bio, wound bar rendering with the right numbers. + +# Round 22 — the rules go in the box. 9–10 Aug 2026 + +`R-167` **A rules journal, generated from the rules.** `packs/rules` ships +"The Custodians — Rules", seven pages, built by `tools/rules-text.mjs` from +`rules.mjs` and `lang/en.json`. **No number in the book is typed by hand.** Hit +points, Major Wound thresholds, the damage ladder, the reaction formulas, the wound +table and its consequences, coherence bands and costs, the bleed rates, the range +ladder and the encumbrance table are all computed at build time; every wound and band +name is read from the language file, so the book and the interface cannot disagree. +That defect has landed five times in this project — a hand-written rulebook would +have been the sixth. + +Shaped to be learned and then abandoned: + +| Page | | +|---|---| +| 1 · The whole game | the d100 core, the three modifiers, where the numbers come from | +| 2 · Fighting | reaction, the turn, range, what a hit does | +| 3 · Getting hurt | locations, the Major Wound table, bleeding | +| 4 · Coherence | the setting mechanic | +| 5 · A case | the session structure | +| 6 · Reference | tables you look up, never read | +| 7 · A round, worked through | Ceyla's real sheet numbers, start to finish | + +Page 1 says explicitly that it is enough to play, and page 7 walks one round using +figures a reader can check against the shipped pregen. + +`R-168` **Four more rule tables moved into `rules.mjs`** so the journal could be +generated from them at all: the circumstance ladder, the range ladder, the Major +Wound table and the coherence bands, costs and recovery. `ringbrp.mjs` now assigns +rather than re-declares them, `rangeBandFor` and `coherenceBand` are aliases of the +shared functions, and the guard covers all of it — 17 rules, 8 constant sets, 54 +formulas. + +`R-169` **The printed chance was not the real chance.** `resolveBands` returned a raw +`effective`, so a skill beaten below zero printed **0%** on the chat card. The floor +was genuinely there — `gradeRoll` tests the critical band first, so a 1 still +crits — but it arrived sideways, and the card contradicted the rule. `effective` is +now the clamped figure. Verified: rating 1 at Difficult prints 1% and succeeds 1% of +the time; every band from 1 to 100 now prints what it actually does. + +`R-170` **The 96–00 rule was in the engine and in no document.** `gradeRoll` has +always failed 96–99 regardless of skill and fumbled on 00 — correct BRP, and the +reason a 100% skill really succeeds 95% of the time. It appears nowhere in the design +notes and would have appeared nowhere in the book. Now on page 1 and in the reference. + +`R-171` **Correction: my combat harnesses double-counted armour.** `hitLocationRoll` +applies armour itself and takes an `armourApplied` flag for callers that have already +done it. My harnesses deducted armour and did not pass the flag, so every playtest +from round 16 onward understated damage. The engine was right throughout. + +Re-run with armour counted once, the same fight resolves in **4 rounds instead of +7–12**, and Tomas goes down in round one. Combat is materially more decisive than my +earlier reports suggested — worth knowing before any further balance work. + +**Sweeps run this round.** Generators across every role, tier and species (85 checks); +subsystems — wounds, bleeding, healing, coherence, skill rolls, encumbrance edges, +sheets, tables, every pack (25 checks); talents and progression (6 checks). Everything +that failed was my test's assumption rather than the system, except `R-169`, which was +real. `check-rules` is green and gates the build. + +# Round 23 — the table, and a species that does not exist. 10 Aug 2026 + +`R-172` **The hit-location table was unreadable, and `table-layout: fixed` was why.** +The header read `MELEERANGETAKENARMO`, ranges wrapped onto two lines, rows were +different heights and the numbers sat a hand's width from the names they described. + +Three separate causes, all mine: + +1. **Fixed layout sizes every column from the first row** — the header row, where + the font is 0.62rem. `width: 3.6em` therefore resolved to about 39px, too narrow + for "RANGED", so the headers overflowed into each other and the ranges wrapped. +2. **`.rb-loc-cell` was `display: flex` on a ``**, which takes the cell out of + table layout altogether — it had been there since before these columns existed. +3. **The name column was given `width: 99%`**, so on a wide sheet it absorbed 352px + of slack and threw the numbers to the far edge of the panel. + +Now: auto layout, `white-space: nowrap`, content-sized columns, a fixed 22px gutter +after the name, and `width: auto` so the table is only as wide as it needs to be. +Measured in isolation at four container widths — 1400, 900, 640 and 420px — the +table is **297px every time**, every row **20px**, the last column right-aligned to +within a pixel, across Baseline, Vesh, Cadence and a wounded sheet. + +Also: the roll button carried a whole sentence and wrapped to three lines — short +label, sentence moved to the tooltip — and "No active wounds." was centred in a wide +empty box instead of sitting under its heading. + +**A note on verification.** Foundry reports its window as 0×0 in this browser, so +layout cannot be measured in place. The check was done by lifting the real markup +and the real stylesheet into a shadow root at controlled widths — which measures the +shipped CSS rather than a mock-up of it. + +`R-173` **Every packed actor was a species the game does not define.** Ten of them — +all four pregens and all six NPCs — shipped with `speciesProfile: "hominid"`, a name +left from the Ringworld setting. It is not a key in `locationTables`, so the sheet +fell back to Baseline and nobody noticed: the hit locations were right by accident, +and would have been silently wrong the moment a Vesh or a Cadence was built from a +pack. + +Found by accident while restoring test actors, which is not a system I want to rely +on. Fixed in three places: + +- `template.json`'s default is `baseline`, not `hominid`; +- `buildActor` now **writes the profile explicitly** from the content spec rather + than leaning on the template default, so a packed actor always carries a real one; +- `check-rules` fails the build if any `speciesProfile` in `template.json` is not a + key of `locationTables`. Verified by reintroducing "hominid": the build stops with + `speciesProfile "hominid" is not a species (have: baseline, vesh, cadence)`. + +The actor packs were deleted and rebuilt from clean, since the stale value was baked +into the stored documents rather than coming from anything still in the source. +Verified: ten actors, zero invalid profiles. + +# Round 24 — a fight, and what the sheet knew about it. 10 Aug 2026 + +Four agents against a marksman and two contractors, 25 m closing to contact. Real +`Combat` document, armour counted once, initiative re-rolled each round. Agents took +it in four rounds, three standing; Dunn went down in round three. + +The sheet was then read against the fight log, and it agreed on everything it +covered — Ceyla's left leg at 4/6 showing `hurt` on the figure, Dunn's head at 13/5 +showing `destroyed`, his bleed on the wound bar, armour, reaction, MOV. What it +covered was the problem. + +`R-174` **Only legs and arms ever did anything.** The location-effect table is +localised prose for nine anatomical kinds and the derived state implemented two of +them. Three of the remaining seven were live rules doing nothing: + +| Kind | The text promises | It applied | +|---|---|---| +| `head` | "Unconscious immediately" | prone, and nothing else — **the agent stayed up** | +| `ridge` (Vesh) | "−30% to all Perception" | **nothing** | +| `unit` (Cadence) | "each disabled body costs the whole person −10% to everything" | **nothing at all** | + +The Cadence one is the serious one: losing bodies is that species' entire mechanical +identity, and it was costing them nothing. A Cadence was strictly better than a +Baseline — same hit points spread over five bodies with no vital location to hit and +no penalty for losing four of them. + +Found by shooting Dunn in the head and noticing he was still standing. + +**Fixed the same way the Major Wounds were.** `locationEffectsFor(locations)` in +`rules.mjs` turns each kind into numbers; `prepareDerivedData` folds it together with +`woundEffectsFor` rather than computing anything inline. The two stack, because a +shattered leg and a concussion are separate facts about the same body. + +Verified live: + +``` +leg destroyed phys -30 MOV 1 +arm destroyed manip -30 +HEAD destroyed unconscious=true down=true +vesh RIDGE opened percep -30 +cadence 1 body lost all -10 +cadence 3 bodies lost all -30 → a 60% skill rolls at 30% +leg gone + concussed all -20, phys -30, MOV 1 ← both tables stacking +``` + +Guard extended to 18 rules and 63 formulas, including every location kind the three +species tables use, so a kind cannot be added to a species without a consequence. + +**Still prose, deliberately:** `vital` says "knocked down, and a Stamina roll or +unconscious" — the knockdown applies, the Stamina roll is left to the GM, which is +the right place for it. + +# Round 25 — a mixed team, and the banner that was only half looking. 10 Aug 2026 + +Ceyla (Baseline), a generated Vesh and a generated Cadence against three Cleaners, +25 m closing to contact. Chosen so the location effects wired in round 24 would be +exercised by real dice rather than by me setting fields. + +They were. The Cadence took a body and the sheet reported +`all-10 · 1 body lost`; a Cleaner's arm was disabled and his next shot went out at +**5%** — 45 less 30 for the arm, less 10 for range. A marksman's leg went and he +finished the fight at MOV 5 with `phys -30`. Team won in three rounds without a +casualty. + +`R-175` **The Skills-tab banner was only watching one of the two sources.** It keyed +off `wounds.length`, which counts Major Wounds. Lost locations now produce identical +penalties and were invisible on that tab: + +- a Cadence down two bodies sat at **−20% to everything** and the skills page said + nothing; +- a Vesh with an opened ridge and a dead grasp limb was at **−30% Manipulation and + −30% Perception**, likewise silent. + +This is precisely the defect the banner was built to fix, for the other half of the +system. It now reads from a single `impaired` context built from the derived state, +so it shows whatever is actually modifying rolls, whatever produced it: + +``` +Cadence, 2 bodies lost + WOUNDED [First body ✕] [Fifth body ✕] all rolls -20% +Vesh, ridge destroyed and grasp limb gone + WOUNDED [Grasp limb] [Sensory ridge ✕] Manipulation -30% · Perception -30% +Cadence, bodies lost AND concussed and bleeding + WOUNDED [Concussed] [Bleeding] [First body ✕] [Fifth body ✕] + all rolls -40% · bleeding 1/round +Ceyla, head destroyed + UNCONSCIOUS [Head ✕] +Ceyla, unhurt + (no banner) +``` + +The headline changes with the state — **Wounded**, **Out of the fight**, or +**Unconscious** — so the worst news is the first word rather than something to be +inferred from a list of chips. + +# Round 26 — Coherence in a fight, and a rule that had never once applied. 10 Aug 2026 + +Four agents against a Revenant, a marksman and a contractor, opening at 40 m so the +range ladder had work to do. Built around the two things dice had never exercised: +Ceyla firing the Redaction lance, which costs Coherence a shot, and Rakhi spending a +round aiming. + +Both worked. Aim paid out — `Rakhi 21(+2)` in round two, one round after she spent +her action. Coherence ran **10 → 5** across five shots and left Ceyla **Loose** at +the end of the fight, which is the design's whole argument: the tool that solves the +case is the tool that costs you. And the stacking held up — the marksman took a +disabled arm and then a manipulation Major Wound and finished the fight shooting at +**1%**: 55, less 10 for range, less 60 for two −30s. + +`R-176` **Encumbrance's skill penalty had never been applied to anything.** +`encumbranceFrom` has returned a `skillPenalty` since R-23. `prepareDerivedData` +stored it, the sheet displayed it, the rules journal tabulated it, and no roll ever +read it. Rakhi has been 20 kg over her free carry all session, carrying a printed +−10%, and rolling at full. + +The comment above the modifier chain in `skillRoll` says gear bonuses "sit outside +the ±40% circumstance cap **alongside encumbrance and coherence**" — so it was +always meant to be there. Coherence was wired; encumbrance was not. + +Now applied at the same choke point. **Scoped to physical, melee, ranged and +manipulation** — a heavy pack does not make you worse at Knowledge, and a rule that +says otherwise is one nobody will use at the table. That is a decision rather than a +fix, and the rules journal now states the scope explicitly so book and code agree. + +Verified: Rakhi's rifle 47% → **37%** with the card reading `Overloaded: -10%`, and +her Navigate untouched at 31%. + +`R-177` **The banner was watching two of four sources.** Rounds 24 and 25 taught this +lesson twice; here it was a third time. Four things modify rolls through +`skillRoll` — Major Wounds, lost locations, Coherence and load — and the banner knew +about the first two. Ceyla at Coherence 5 was losing 5% Communication with nothing on +the Skills tab; Rakhi was overloaded and silent. + +All four now feed one `impaired` context, and **totals sum per category** rather than +listing each source, so a concussion and an Unmoored band read as one figure: + +``` +Rakhi, overloaded + WOUNDED [Overloaded] Physical -10% · MOV 8 +Ceyla, Unmoored + concussed + arm destroyed + WOUNDED [Concussed] [Right arm ✕] [Unmoored] + all rolls -30% · Manipulation -30% · Communication -20% +``` + +Before the fix that last line read `all rolls -20% · all rolls -10%`. + +**The pattern, stated plainly.** Four rounds running, the defect has not been a wrong +rule but an incomplete reader of one: Major Wounds computed and unapplied, locations +applied for two kinds of nine, the banner watching one source then two, encumbrance +computed and never read. The guard now covers 19 rules and 69 formulas, but it checks +*formulas*, not whether anything consumes them. **A rule that is computed and never +read is the failure mode this project actually has**, and it is worth a dedicated +check: every value `prepareDerivedData` produces should have at least one reader. + +# Round 27 — the callout boxes, the range picker, and what a strong person can lift. 10 Aug 2026 + +`R-178` **Dialogs looked like a different application.** Every callout — attack +options, skill rolls, the reaction rail, the generators — rendered in Foundry's +default parchment with a serif face, sitting on top of a dark blue sheet in +Michroma and Helvetica. `rbDialogClass()` now returns a themed subclass that tags +every system dialog with `ringbrp-window`, and that class was added to the selector +that defines the design tokens, so dialogs inherit the sheet's palette rather than +carrying a copy of it. + +Verified by computed style, not by eye: + +``` +window bg rgb(16, 26, 110) — identical to the sheet's --rb-deep +header rgb(12, 18, 91), yellow text, Michroma +content Helvetica Neue, --rb-white on --rb-deep +select --rb-panel ground, --rb-line border +Roll button --rb-yellow on --rb-void (yellow means the thing that commits) +labels uppercase 10px in --rb-muted +``` + +`R-179` **The range field asked for arithmetic the weapon had already done.** The +dialog printed "Bands: 20 / 60 / 180 m" and then a number box, so the player read +the bands, decided which one they were in, and typed a distance for the system to +convert back into a band. It is now a dropdown of the bands themselves: + +``` +Short 1–20 m +0% +Medium 21–60 m -10% +Long 61–180 m -30% +Extreme 181–360 m -50% +Beyond range over 360 m out of range +``` + +Each option carries a distance inside its band rather than a modifier, so +`rangeBandFrom` remains the single authority on what a range costs — the dropdown +chooses a distance, it does not duplicate the ladder. + +`R-180` **Free carry ignored how big you are.** It was STR in kilogrammes. Rakhi at +STR 18 and MAS 19 — a large, powerful woman — had a free carry of **18 kg** and was +twenty kilos over while carrying 38 kg, which is an ordinary soldier's fighting +load. A rule that treats a realistic pack as an emergency is one the table quietly +drops. + +Now **STR + MAS**, the same pair that sets the damage modifier, so a single physical +axis decides both how hard you hit and how much you can shoulder. + +| | STR + MAS | free | carrying | over, before → after | +|---|---:|---:|---:|---| +| Ceyla | 12 + 13 | 25 | 15.4 | 3.4 → **0** | +| Dunn | 9 + 10 | 19 | 8.2 | 0 → 0 | +| Rakhi | 18 + 19 | **37** | 38 | 20.0 → **1.0** | +| Tomas | 13 + 12 | 25 | 7.2 | 0 → 0 | + +Rakhi keeps a cost for her load — 1 kg over is −0.1 Reaction, so she prints 14.9 +rather than 15 — which is the granular rule doing exactly what it was written for: +a heavy pack is felt, it is not a catastrophe. Rejected `(STR+MAS)×1.5`, which gave +her 56 kg and would have made encumbrance unreachable, and `STR×2`, which ignores +mass — the thing the owner specifically asked to count. + +The rules journal states the new formula, and the guard covers it. + +# Round 28 — the subsystem that was only ever bookkeeping. 10 Aug 2026 + +`R-181` **Defending did not exist.** `system.combat.defencesThisRound`, +`defenceTypeLock` and `secondAttackUsed` are declared in `template.json`, written +into every packed actor by `buildActor`, and reset every round by the combat hook. +Nothing ever incremented them, nothing rolled a defence, and no control existed +anywhere in the interface. The rules journal — which I generated — promised "one +offensive action, plus **as many defensive reactions as you like at an escalating +penalty**". + +So the escalation was the only part of the rule that had ever been built, and it was +being applied to a counter nothing could raise. This is the same failure as the last +four rounds at its largest scale yet: a whole combat subsystem present as +bookkeeping for an event that could not happen. + +**Now implemented.** `defencePenaltyFor` and `defenceTypeAllowed` in `rules.mjs`; +`defend(actor, {type, weapon})` rolls through the same `skillRoll` choke point, so a +defence is subject to wounds, coherence and load like everything else; a Dodge / +Parry control on the Combat tab that shows what the next one will cost. + +| Defence this round | Modifier | +|---|---:| +| 1st | free | +| 2nd | −30% | +| 3rd | −60% | +| 4th | −90% | + +Verified end to end — four attacks on Dunn in one round: + +``` +defence 1: no penalty → 49% failure +defence 2: -30% → 19% failure +defence 3: -60% → 1% failure (the 1% floor holding) +defence 4: -90% → 1% failure +asked to parry after dodging → "dodge" (the type lock) +round tick → 0 defences, lock cleared +sheet: "DEFEND DODGE PARRY first is free" + → "DEFEND DODGE PARRY 1 this round · next at -30%" +``` + +**Played.** Four agents against three contractors at 10 m closing to contact, with +every agent defending and NPCs taking only their free defence. Agents won in seven +rounds **without a casualty** — considerably softer than the same fight without +defences, which is the point: a defence is free the first time and the rule makes +standing in front of three people possible and stupid. + +The escalation never fired in that fight, because with seven combatants nobody was +attacked twice in a round. It needed a forced test to exercise, which is worth +remembering — **a rule that only bites in a corner will not be exercised by ordinary +play, and that is exactly how the last five defects survived.** + +The journal now documents defending, generated from `defencePenaltyFor` so the table +in the book cannot drift from the ladder in the code. Guard at 22 rules and 77 +formulas. + +# Round 29 — weight on the kit, and a dice set that reports the drift. 10 Aug 2026 + +`R-182` **Weapons and armour did not show their weight.** Gear did; the two +categories that account for most of a load did not — so a player deciding what to +drop could see that a notebook weighs 0.3 kg and not that the rifle weighs 3.4. +Weapons gained a WEIGHT column, armour gained the figure beside its points, both +right-aligned with tabular figures so a kit list scans: + +``` +WEAPON | DAMAGE | RANGE | AMMO | WEIGHT +Marksman rifle | 1d10+2 | 20/60/180 m | 8/10 | 3.4 kg +Entry bar | 1d10 | — | — | 3 kg +Breaching suit 7 pts 11 kg +``` + +That matters more now that free carry is STR + MAS and the encumbrance penalty +actually applies: Rakhi is 1 kg over 37, and the sheet finally shows her which +kilogramme to put down. + +`R-183` **A dice set for Dice So Nice, and it is the setting rather than a skin.** + +The agency keeps a filing system and the game is about that filing system being +wrong. So the dice carry a ruled registry grid — and **the colourset is chosen per +roll from the roller's Coherence**, so the dice report the one number the players +are trying not to look at: + +| Coherence | Set | What the die looks like | +|---|---|---| +| 10–8 | Anchored | true rule, lemon numerals on ultramarine, metal | +| 7–5 | Loose | the same grid, dimmer, plastic | +| 4–3 | Adrift | the rule has begun to wander and grain shows through | +| 2–1 | Unmoored | torn registry, coral numerals, glass | +| 0 | Displaced | no grid at all — fragments of rule going nowhere | + +Plus an **Agency issue** set in brushed plate for anything that is not a person. + +Four textures, generated rather than drawn, by `tools/make-dice-textures.mjs` — +raw RGBA encoded to PNG through `node:zlib` with a small built-in encoder, so the +repo gains no image dependency and a rebuild is byte-identical. All four tile +cleanly, which matters on a d20. + +Verified positively rather than by absence of error, since Dice So Nice falls back +silently on an unknown colourset: `dice3d.exports.TEXTURELIST` holds all four +textures, `dice3d.exports.COLORSETS` all six sets, and +`getLoadedDiceSystems()` reports "The Custodians, 7 presets" — d100 and d10 labelled +as the percentile pair, plus d4/d6/d8/d12/d20. Every roll the system makes is +dressed: skills, the reaction die, damage, hit location and the Major Wound d10. + +`R-184` **A deprecation warning of my own making, removed.** The damage-button +handler registered both `renderChatMessageHTML` and `renderChatMessage` for +compatibility, and v13+ warns on every single chat message when the old name is +bound. It now picks the new name when the v13 chat class is present. + +# Round 30 — art, a catalogue that spans centuries, and the Quiet Arts. 10 Aug 2026 + +`R-185` **Icons rebuilt with depth.** A glyph was one silhouette; it is now up to +three layers — mass in near-white, **detail** in a darker tone (the grip and sight on +a pistol, the fuller on a blade, the lens housing on a scanner), and an accent in the +rim colour. The detail layer is the revision: it is what makes a flintlock read as a +flintlock rather than a pistol-shaped blob at 32 px. + +**Era is carried by the tile**, so a glance at an inventory dates a thing before the +name is read: a notched corner and muted ground for antique, a cut corner and a +scanline for recovered, and **a broken rim for anomalous** — the tile does not close +on things that should not exist. 141 icons: 99 modern, 23 antique, 10 recovered, +9 anomalous. + +`R-186` **The catalogue spans time.** Weapons 18 → 30, armour 10 → 13, gear 26 → 45, +filed into era sub-folders on install rather than one alphabetical column: + +``` +Weapons Armour Gear & Devices + └ Antique 6 └ Antique 2 └ Antique 8 + └ Agency issue 21 └ Agency 10 └ Agency issue 29 + └ Recovered 3 └ Recovered 1 └ Recovered 6 + └ Anomalous 2 +``` + +Flintlock, musket, crossbow, duelling sword, boarding axe, brigandine, oil lantern, +tallow candles, brass compass, pocket watch, chirurgeon's roll, wax tablet. Gauss +sidearm and lance-rifle, monofilament blade, survey drone, optic visor, data wafer, +stasis cannister, sealed hardsuit. And two that should not be in a kit list at all: +a **threshold key** that opens a crossing a hand's width, and **the quiet ledger**, +which writes down what happened — sometimes before it has. + +`R-187` **THE QUIET ARTS.** The setting already contained the answer, so the powers +are not an addition to it. Crossing is an *induced filing error* — you persuade the +record you are somewhere else. **An Art is the same trick performed small and on +purpose: a misfiling committed deliberately.** There are therefore no fireballs; +every Art is a sentence about the record. + +| Art | It says | Ordinary | Pushed | +|---|---|---:|---:| +| **Thread** | what was together is still together | −1 | −2 | +| **Echo** | *when* is loosely filed | −1 | −2 | +| **Hollow** | this is not filed here | −1 | −3 | +| **Witness** | the record agrees you belong | −1 | −2 | +| **Unmake** | this was not present | −2 | −4 | +| **Ledger** | read the record — and amend it | −2 | −4 | + +Three design decisions worth recording: + +**No new resource.** The cost is Coherence, because using an Art *is* doing something +that only makes sense somewhere else — precisely what transposed materiel already +charges for. It is levied whether or not the roll succeeds, since the misfiling is +committed the moment you try. + +**Coherence penalises the Arts.** They run through the same `skillRoll` choke point +as everything else, so the further an agent has drifted the worse they are at the +thing that made them drift. Verified: Echo at 30% rolls 30 / 25 / 20 at Coherence +10 / 4 / 1, and at 0 the attempt is refused — there is nothing left to spend. + +**A fumble is not a failure.** The misfiling *sticks*: something small and true about +the world is now different and the GM never says which. A story hook with a +mechanical anchor. + +`R-188` **They only ever arrive from crossings**, per the owner's decision. There is +no path in but `grantPower()`: not taught, not bought, not chosen at creation, not +offered by the generator. Verified — a freshly generated character has none, and a +second grant of the same Art is refused. Each carries an `origin` line naming the +crossing that left it, printed on the sheet: + +``` +Echo 30% −1 [USE] [PUSH −2] + When is loosely filed. + Left by: Ilfracombe, the second crossing. She came back eleven minutes before she left. +``` + +New Item type `power`, its own compendium, an Arts tab, and a rules-journal page +generated from `ARTS` and `powerCostFor` so the book cannot drift. Guard at 24 rules +and 85 formulas — it caught both new rules arriving without coverage and stopped the +build until they had it. + +`R-189` **The return is now a roll, and it is the only door the Arts come through.** +`returnCrossing()` described the modifiers and nothing ever resolved them — the GM +rolled it themselves, so "a crossing that goes wrong" was not an event the system +could observe. `crossBack(actor, {aimedByAnother, aimedBy})` resolves it through the +usual choke point. + +| Return | What happens | Coherence | An Art? | +|---|---|---:|---| +| Success or better | home, where and when you meant | — | no | +| **Failure** | you arrive near your target, not at it | −1 | only if already Unmoored | +| **Fumble** | you come back, and not the same | −2 | **always** | + +**R-56 is enforced in code rather than by convention.** There is no branch that +leaves an agent across: `setAcross(false)` runs on every outcome. Verified over +twenty returns by an Unmoored agent rolling alone — never stranded once. + +**You do not choose the Art, and the pool darkens as you come apart:** + +| Coherence when it went wrong | What can arrive | +|---|---| +| 8+ Anchored or Loose | Thread, Echo, Witness | +| 4–3 Adrift | Hollow, Thread, Echo | +| 2 and below Unmoored | **Unmake, Ledger, Hollow** | + +Which is the trap the setting has been laying since the Coherence track was written: +**the agents most likely to come back with an Art are the ones least able to afford +using it.** Verified live — an anchored fumble produced Witness at 30%; an Unmoored +failure produced Unmake. + +`R-190` **Three of the four pregens could not perform the job's defining act.** +Transposition sat at its printed base of 1% for Ceyla, Rakhi and Tomas; only Dunn had +it at 68. Every return would have failed, making "you arrive near your target" the +normal experience of being a Custodian and quietly reducing the crossing rules to +scenery. + +Defensible as a design — one specialist aims for the team, and `aimedByAnother` +exists precisely for that — but not at 1%: these are people who cross for a living. +Ceyla 32, Rakhi 28, Tomas 35, Dunn unchanged at 68 as the field lead. The team can +now get itself home, and is markedly better off doing it through Dunn. + +Found only because `crossBack` made the roll real. A skill nothing rolls is a skill +nobody notices is wrong — the same shape as R-176 and its four predecessors. + +`R-191` **A crossing panel on the case file.** The sheet had a single `toggle-across` +flag-flip, which is right for going out — the brief names a place and a time and the +margin is the case's first problem — and wrong for coming back, because coming back +is a roll. + +The panel now shows the whole decision at a glance while the team is across: + +``` +CROSSING [ BRING THEM HOME ] +AGENT | SKILL | COH | ALONE | +Ceyla | 32% | 10 | 32% | [RETURN] [AIMED] +Dunn | 68% | 10 | 68% | [RETURN] [AIMED] +Rakhi | 28% | 2 | 1% | [RETURN] [AIMED] +Tomas | 35% | 10 | 35% | [RETURN] [AIMED] +``` + +The **ALONE** column is the argument the panel exists to make: Rakhi, Unmoored at +Coherence 2, aims her own way home at **1%** — 28 less the −30 the band imposes, +floored. AIMED routes her through the best crosser at no penalty. Nobody has to know +the rule to see the cost. + +`R-192` **`crossBack` no longer ends the case.** It cleared `across` on the case file, +so the first agent through the door marked the whole team home while the rest were +still standing in it. It now returns only the agent it was called for, and the caller +decides when the last one is back — which is what "Bring them home" does, looping +every member through the best aimer and then clearing the flag. + +Verified end to end: Dunn aimed for the team, three came home at a cost of 1 +Coherence apiece for rough returns, and **Rakhi failed hers while Unmoored and came +back with Hollow** — the dark pool, arriving exactly as designed, on the agent least +able to afford it. + +# Round 31 — a full case, played end to end. 10 Aug 2026 + +Played as the team's **second** crossing, because Arts only arrive from crossings +that go wrong: Rakhi carried the Hollow she came back with from the first Ilfracombe +return. Brief → cross → four phases → first contact → an Art used → a fight → +return → debrief → end of case. + +**What the case did.** The destination is *rumoured*, so every phase is Difficult: +Tradecraft 71 halves to 35 before the terrain modifier. Three of four phases failed +and the team reached the cove on **Containment 7, Resources 2**. Rakhi hollowed the +regulator case to get it past a watched approach — Hollow 26%, success, −1 Coherence. +Two contractors at the cove went down in two rounds. Dunn aimed everyone home at 68% +and took the only rough return himself. Debrief restored the team to Anchored. + +That is the loop working: the pressure came from the tracks and the Coherence, not +from the dice being unkind. + +`R-193` **First contact could not be made with a group nobody had met.** +`standingFor` returned `null` when a group had no standing entry, and +`resolveContact` bailed on null — so the *first* contact with a new group was the one +thing first contact could not do. Every stage silently returned nothing. It had gone +unnoticed because the shipped case file ships with a standing already on it; the bug +only appears with a group the team encounters for the first time, which is the normal +case in play and never once in testing. + +`ensureStanding()` creates the entry on demand. Verified against a wiped standings +list: signal → success, read → failure, offer → success, standing 2 and impression 2 +recorded. + +`R-194` **The skills the job most uses could never improve.** The experience tick in +`skillRoll` fires only when a `skillItem` is passed. Every internal caller — case +phases, First Aid, Medicine, defences, and the crossing itself — passes a +`skillFamilyId` and no item. So Transposition, Tradecraft, Dodge and First Aid were +structurally incapable of earning a tick, and `endOfCase` reported "0 skills ticked" +for the entire team after a full case. + +`skillRoll` now resolves the item from the family when the caller has not handed one +over. Verified: Ceyla ticked Dodge from a defence, Dunn ticked Transposition from a +return, and at end of case **Transposition improved 68 → 69**. + +Both defects are the same shape as the run since R-176 — a rule that exists but that +nothing reaches — and both were found by *playing the case* rather than by testing a +subsystem. Two harness errors of mine on the way, recorded so they are not re-found +as bugs: the contact stages are `signal` / `read` / `offer`, not approach/pidgin; and +combat rolls driven through `resolveBands` directly bypass `skillRoll`, so they +legitimately do not tick. + +**Still open from this case.** Nobody on the team has Fast Talk above its printed +base of 5, so the Cover Identity pack's +20% lands on a 25% skill. Tomas is the +talker on Persuade 44 and Language (Sign) 42, which works — but the Cover Work +talent, which exists, remains close to dead on the pregens. + +`R-195` **Cover Work moved onto Persuade.** The talent granted +20% Fast Talk, and +nobody in the cast has Fast Talk above its printed base of 5 — so an agency training +talent held by the Case Officer was worth +20% on a 25% skill and effectively did +nothing. This is the surviving half of the "Cover Work / Fast Talk" problem the +generator already guards against for generated characters: the generator grants the +skill a talent modifies, but the hand-written pregens were never revisited. + +It now grants **+20% Persuade**, which Ceyla has at 41 — and which feeds two of the +three contact stages, `signal` and `offer`, so the talent now bears on the work it +is named for. + +Verified live: Persuade 41% rolls at **61%** with the chat card reading +`Cover Work: +20%`. + +**Left alone deliberately:** the Cover identity pack (gear) still grants +20% Fast +Talk and has the same dead-bonus problem. It is a separate item and the instruction +was specific to the talent. + +`R-196` **The Cover identity pack moved onto Persuade too.** Same dead bonus as the +talent: +20% to a Fast Talk nobody trains. Nothing in the content now hangs a bonus +on `fast_talk` at all. + +Both work, and — worth recording — **the shipped party does not stack them.** The +talent sits on Ceyla, the Case Officer; the pack is in Tomas's kit, and Tomas is the +talker. As issued: + +``` +Ceyla · Cover Work talent 41% → 61% "Cover Work: +20%" +Tomas · Cover identity pack 44% → 64% "Cover identity pack: +20%" +``` + +Stacking is possible and the engine handles it cleanly, labelling both sources: + +``` +Ceyla · talent AND pack 41% → 81% "Cover identity pack + Cover Work: +40%" +``` + +**Flagged rather than changed:** +40% is a large bonus and gear bonuses sit outside +the ±40% circumstance cap, so an agent with a full legend rolls at 81% on a base of +41. That is arguably correct — trained cover officer plus a complete documentary +identity should be very good at being believed — but it is the largest single-skill +swing in the game and it is available by moving one item between two members of the +same team. If it wants trimming, the pack at +10 is the lever; the talent is the part +that ought to be worth the most. + +`R-197` **Cover identity pack trimmed to +10.** The stacked total was the largest +single-skill swing in the game — 41 → 81 — and reachable by handing one item to the +agent who has the training. At +10 the ceiling is 71, and the ordering now says the +right thing: **the training is worth twice the paperwork.** + +``` +Ceyla · Cover Work talent 41% → 61% +20% +Tomas · Cover identity pack 44% → 54% +10% +Ceyla · talent AND pack 41% → 71% +30% +``` + +The item description carries the reasoning so a GM reading the sheet knows why it is +the smaller number: *"Paper helps; it is the training that sells it."* + +# Round 32 — case three, and the Arts in anger. 10 Aug 2026 + +Played as the team's **third** crossing, with two Arts already carried: Rakhi's +Hollow from Ilfracombe and a Thread that followed Dunn back from Steepholm. They +started drifted — Coherence 9 / 8 / 7 / 6 — because they have been out twice. + +**What happened.** Dunn threaded the missing team's beacon before setting out and +**failed** — and paid the Coherence anyway, dropping to 5. Two of four phases came +good; the cliff path did not; they reached the cove on Containment 9, Resources 3. +Rakhi tried to hollow the boathouse door and **pushed it** to cover herself as well, +failed, and paid all three: Coherence 7 → 4, **Adrift**, standing in the open. The +fight took four rounds and nobody was hurt. Dunn aimed everyone home; two rough +returns; the debrief pulled them back to 10 / 7 / 7 / 9. + +Nothing followed anybody home this time, which is correct — no return was fumbled, +and the two failures were rolled by Anchored agents. **The Arts are meant to be +rare, and the gate held.** + +`R-198` **An Art could never improve.** The `power` item carries an +`experienceTicked` field. `usePower` never set it, and `endOfCase` filters on +`i.type === "skill"`, so it was never read either — a field with no writer and no +reader, which is this project's signature defect in its purest form. + +The consequence was worse than cosmetic. An Art arrives at roughly POW × 2 — **22% +for Dunn, 26% for Rakhi** — and charges Coherence whether or not it works. Fixed +forever at that rating, it is not a temptation, it is a trap: both Arts used in this +case failed and cost 4 Coherence between them for nothing. + +Now a successful use ticks it and `endOfCase` improves it on the same footing as a +skill. Verified: Hollow ticked on its second attempt and improved **26% → 29%**. +Modelled over ten cases with one success each, a Hollow that arrived at 26 reaches +roughly **59%** — so the Art you keep using is the one that becomes reliable, and +the risk pays back across a campaign instead of never. + +That also repairs the fiction. "The crossing left this in you" now has a second half: +you learn what to do with it. + +# Round 33 — a mission framework, and two agents taking it apart. 10 Aug 2026 + +Built `tools/mission-tables.mjs` (content) and `tools/mission.mjs` (composer), +generating a full case: brief, truth, a named GAP between them, opposition, cast, +phases, clock, Coherence hooks, timeline, debrief question and failure state, as +five journal pages plus a playable case-file Actor. Wired into Foundry as +`generateMissionCase()` and a GM dialog. + +Then handed it to two subagents: one running it as a GM deciding whether it is +runnable on Thursday, one auditing whether every field the generator emits actually +connects to the engine. Both came back with more than I expected. + +## What the GM agent found + +**89.3% of missions contained a defect a GM would have to patch before running.** +The cause was structural: **the composer treated every table as independent when +roughly half of them depend on the anomaly.** + +`R-199` **The brief's objective and the truth's "how it closes" agreed 24.9% of the +time.** Drawn from unrelated tables. A case about a duplicated person ordered the +team to *"Recover the object"*; 12.8% ordered them to find a first team that does not +exist. Worse than wrong: the mission's own `gap` field names a *different* +contradiction, so a GM could not tell the defect from the design. + +**Fixed by making the anomaly the parent key.** Every anomaly now carries +`fits: { resolutions, gaps, adversaries, classes, placeKinds }` and the composer +filters before picking. That single change also killed the Hollow-world cases whose +containment note read *"the town has noticed"* (there is no town — that is the +classification), the `not_hostile` gap applied to an eleven-minute time loop +(*"It runs from them at the first opportunity"* — a loop cannot run), and +`already_contained` on the inheritance anomaly, whose truth is that the family have +held it competently for four generations. + +`R-200` **No phase in which they did the thing they were sent to do.** The spine was +approach → search → extraction, and extraction's verb is *"getting out with it"* — +presuming the *it* had been obtained offscreen. A **Containment** phase is now +compulsory in every mission. The GM's note was exact: this is the difference between +a three-hour session and a ninety-minute one. + +`R-201` **Half of all missions were arithmetically unfinishable.** Phases cost +1+2+2+2 = 7 against a starting Resources of 6 for every non-Near class. Resources are +now derived from the phases that were actually generated, plus slack scaled to the +classification. Verified over 500 seeds: **0 unfinishable budgets.** + +`R-202` **String plumbing.** "has been open for since the spring" (8.8%); a timeline +row reading "since a date nobody will confirm ago" (28.9%); briefs starting +lowercase (21.1%); a hard-coded *"The second phase makes the true scale unmissable"* +printed under heading 3; the same anomaly sign used in three of four phases (100%); +duplicate cast names in a game about duplicates; and "eleven" appearing up to eight +times in one case, which turns the register into a running gag the bible forbids. +`DURATIONS` is split into spans and vague dates, the timeline is relative, signs +cycle, names dedupe. Verified over 500 seeds: **all five string defects at zero.** + +Also cut: a human spokesman for a revenant or a duplicate — entities whose whole +design is that they are singular and do not negotiate — who then lied *"about how +many of them there are"* against a hard-coded count of 1. + +## What the integration agent found + +`R-203` **The journal ownership was inverted, and it leaked the plot.** In Foundry +`2` is OBSERVER, `0` is NONE and `-1` is INHERIT. I had written +`i === 0 ? 0 : -1` — setting the **brief** to NONE and the four secret pages to +INHERIT. A GM opening the entry so the table could read the brief would have +published the truth, the opposition, the phases and the pressure. Now `2` for the +brief and `0` for the rest, verified page by page. + +`R-204` **No case file was ever the active one.** `activeCaseFile()` looks for a flag +that **nothing in the system ever set**, so it fell through to the first expedition +in the collection — the shipped ILFRACOMBE case. Every `setAcross`, `isAcross`, +`debrief` and Clean Scene on a generated case was operating on a different actor, and +the sheet's Across toggle read one case and wrote to another. Generated cases now set +the flag and clear it from any other. + +`R-205` **The whole first-contact pillar was unreachable from generated content.** +`resolveContact` needs a `group` item on the case file; `generateMissionCase` created +the actor with no items at all. Three stages, standings, learned taboos, desires, +correct offer form and the reward effects the phase resolver already applies — all +dead on anything the generator produced. Four GROUPS added, each with a taboo, a +desire, an offer form and standing rewards, and the case is built with one embedded. +Verified live: signal → read → offer, standing 2. + +`R-206` **`leg.difficulty` was written and never read.** The generator sets a +deliberate ramp and explains it in a comment; `resolveLegDialog` derived difficulty +from `destinationKnowledge` instead, so the brief said "phase 1 is Average" and the +phase table disagreed on the same document set. The resolver now honours an explicit +per-leg difficulty when one is given. + +**Still open, and worth doing:** `crossingModifier` is printed in the brief as a +mechanical promise and never applied by `crossBack`; `salvageEra` is computed and +referenced nowhere at all; `leg.explored` is read by the resolver and written by +nothing; `journey.elapsedDays` never accumulates; `tools/generator.mjs` is orphaned +dead code duplicating the live THREATS table. Four more instances of the pattern. + +## What the GM agent said was good, and worth protecting + +The `ANOMALIES` table — *"each row is internally airtight, and each truth is a +genuine inversion of its brief rather than an escalation"* — and the `GAPS` names, +*"ten different session shapes, not ten flavours of one"*. The failure was never the +content. It was a composer that rolled dependent tables independently. + +`R-207` **The four dangling values, cleared.** Each was the project's signature +defect — a value computed and never read — and each is now either wired or gone. + +**`crossingModifier` was a promise the engine did not keep.** The generated brief +printed "return modifier for the classification: −20%" and `returnCrossing` computed +the penalty purely from the agent's Coherence band, so a **Wrong** world was no +harder to leave than a Near one. The case file now carries it and the return charges +it. Verified on a Wrong case: + +``` +Anchored, alone −20% world −20, coherence 0 +Unmoored, alone −50% world −20, coherence −30 +Unmoored, aimed −20% aiming removes the coherence part, not the world +``` + +That last line is the one worth having: another agent can steady *you*, but nobody +can make the place less strange. + +**`salvageEra` was the cleanest dead field in the codebase** — computed by the +generator, referenced nowhere, not even rendered. It is now a mechanic: +`salvageFrom(caseFile)` draws from the catalogue by era, which items have carried +since the era rework. A Wrong world yields the Threshold key and the quiet ledger; a +Hollow one yields lanterns and brigandine. That is what crossing to other times is +*for*, and it was one line from existing. + +**`leg.explored` was read and never written.** The resolver branches on it to choose +between "you already know this ground" and "you find a site", so every critical +reported a site. Ground the team has crossed is now marked explored: 0/5 → 2/5 after +two phases. + +**`journey.elapsedDays` never accumulated.** Recorded per leg, displayed on the +sheet, never summed. Now 0 → 2 after two phases. + +**`tools/generator.mjs` deleted.** Orphaned duplicate of the live THREATS table, +imported by nothing, and stale — its entries used the retired `weapons: [...]` shape +against the live `attacks: [{weapon, skill}]`. Checked before removing and again +after: all six threat tiers still generate, and all six mission adversary tiers and +NPC names still resolve. + +## Play-through: SOFT TENANT (seed 2718) + +Generated a case from the mission framework and ran it end to end — crossing, five +phases, first contact, a fight, the return, debrief, close. Four findings, all of +them the same species as everything before them. + +**R-208 · `generateNPC` guarded one lookup out of three.** An unknown `species` fell +back to baseline; an unknown `threat` or `role` reached `TH.armour`/`R.label` and +threw. Callers got a TypeError instead of an NPC. All three now fall back, and the +substitution is announced rather than silent. + +**R-209 · `hitLocationRoll` treated bad damage as zero damage.** `Number(damage) || 0` +meant a caller passing `undefined` got a hit that rolled a location, printed a chat +card, and took nothing off. A six-round fight ran with fourteen connecting blows and +no casualties, and nothing anywhere said a word — the fight *looked* right in the +log. Damage must now be finite or the hit is refused loudly. `majorWoundRoll` got the +same treatment: called positionally by mistake it rolled and applied a Major Wound to +nobody. + +**R-210 · `endOfCase` was exported and called by nothing.** Documented in its own +comment as "the step that closes the episodic loop", wired to no button and no hook. +Every ticked skill and Art sat on the sheet forever: no character could improve +through play, and the Arts — which already had `experienceTicked` fixed once for +exactly this reason — still could not get better. Now a **Close the case** control on +the case file, guarded against being pressed while the team is still across. First +use: Rakhi's Hollow 26% → 31%. + +**Salvage returned "Punch / kick".** The unarmed pseudo-weapon every character +carries was in the catalogue draw, so it turned up as loot lying in a filter house. +Salvage now requires an item with mass. + +Not defects, recorded because play made them look like defects: +`crossBack` deliberately leaves `system.across` set — one agent returning does not +end a crossing the rest of the team is standing in, and "Bring them home" is what +clears it. Deep bleeding correctly refused an average First Aid and yielded to a +difficult one. Both behaved exactly as written. + +## The establishment: 20 more postings + +Five roles were never the whole agency — they were the five a field team fields. +Twenty more are now on the books, each with its own characteristic, core and +support skills, talent leaning, and a loadout containing at least one thing no +other posting is issued. Two are deliberately archaic (the Antiquities Officer +draws a duelling sword and a flintlock; the Night Warden carries an oil lantern +and a ward plate), because an agency this old has never got round to abolishing +anything. + +Adding them exposed three faults. + +**R-211 · the generator matched kit by guessing at its name.** `keyMatches` was +fuzzy substring matching against a hand-maintained alias table, and anything the +table had not heard of was dropped without a word. The catalogue names diverge +from the keys almost everywhere — `binocs` is "Field glasses", `rope` is "Line and +harness", `geiger` is "Radiation counter", `sealed_cannister` is "Stasis +cannister" — so a Stores Officer issued five things received one, and an Overwatch +Officer never got the rifle the posting is named for. Worse, `climbing_kit` was +mapped to "breaching charges" outright, so the Rigging Officer was handed +explosives instead of a harness and nothing looked wrong. + +Items now carry their catalogue key in `flags.ringbrp.key`, stamped at pack build, +and the match is exact. The name pass survives only as a fallback for documents +built before the flag existed, and an unresolved kit key is now reported instead +of lost. All 25 postings receive their full loadout. + +**R-212 · two weapons named a skill that does not exist.** The boarding axe and the +entrenching tool both declare `melee_weapon:axe`, and the catalogue had no such +skill, so neither could ever be rated above the 5% base — the generator would hand +a Rigging Officer an axe they were permanently incompetent with. Skill added; every +weapon in the catalogue now maps to a real skill, checked. + +**`tools/check-kits.mjs`**, gated into the pack build alongside `check-rules`. Every +kit key must name a real catalogue item, every skill and talent reference must +resolve, no two postings may share a loadout, and it warns when a posting has +nothing issued to it alone. + +## Sheet + +**Characteristics were smaller than the skill ratings below them.** The eight +numbers the entire system derives from read as a caption under the ×5 chips. They +are now the dominant band on the sheet, ruled top and bottom. + +**The marksmanship dropdown offered a choice with no stated consequence** — two +words, Reflex or Deliberate, and nothing anywhere on the sheet saying what either +buys. Both styles are now printed under the control with the chosen one picked +out, assembled from `RINGBRP.marksmanship` itself so the sheet cannot describe a +style differently from the way it rolls. + +## One agent per posting, sheets checked + +Generated a character in each of the twenty new postings and audited every sheet — +rendered DOM, not just the data. All twenty come out clean: eight non-zero +characteristics, both marksmanship rows, 53 skills, 2 talents, 8 hit locations and +a figure on the Combat tab, full loadout issued, no `undefined`, `NaN` or unrendered +Handlebars anywhere. Empty states read properly ("No Arts. They are not learned — +they arrive."). + +Two faults found. + +**R-213 · nobody had hands.** "Punch / kick" is in the weapon catalogue and was +issued to no character, pregen or NPC — every agent carries Brawl and had no item +to roll damage with, so an unarmed attack was unrollable for every character in the +game. It went unnoticed because every playtest so far happened to arm everyone. +Worse, the comment written into the salvage fix a few hours earlier asserted it was +"the unarmed pseudo-weapon every character has", which was simply untrue. Both the +generator and the pack builder now issue it to everyone. + +**Salvaged crossing plate was standard issue for two desk postings.** At 10 AP it is +the strongest armour in the game — better than plate (8) and the sealed hardsuit (9) +— and the Materials Officer and Rigging Officer were both wearing it as routine kit, +making two support specialists the best-armoured people in the agency. Materials now +draws a stab vest, Rigging an impact vest. Salvaged plate is no longer anybody's +standard issue, which is as it should be: it is salvage. + +Checked and not a fault: the Disposal Officer at 9 AP is in a sealed hardsuit, which +is the posting, and pays 12kg for it. + +## A fight with four of the new postings + +Overwatch, Rigging, Antiquities and Stores against three Dangerous adversaries — +chosen to exercise the anti-materiel rifle, the axe skill added yesterday, a +single-shot flintlock, and an agent whose only weapon is the unarmed attack. Twelve +rounds; the team lost, all four down, two adversaries still standing. That is a fair +result for a rifle, an axe, an archaic swordsman and an unarmed storekeeper against +three armed professionals. + +Three faults, all found by playing rather than by reading. + +**R-214 · every generated agent was an unlinked token.** `generateCharacter` never +set `prototypeToken.actorLink`, and Foundry defaults it to false. So an agent put on +the map took damage, wounds and Major Wounds on the token's private copy while the +player's character sheet stayed pristine — the sheet and the body were two different +people. The pack builder has always linked characters (`actorLink: type === +"character"`); the runtime generator, written later, did not. Agents are now linked; +NPCs stay deliberately unlinked, because one statblock stands up four bodies and each +needs its own hit points. Verified at the end of the fight: every agent's token and +sheet agree, and the adversary's token reads 0 while its statblock still reads 12, +ready for the next body. + +**R-215 · Reaction was re-rolled every round except the first.** `nextRound()` re-rolls, +which is the whole design — but Foundry does not roll initiative when a combat +starts, it waits for the GM to press Roll All. Nothing did. So round one ran with +every initiative null and the comparator falling through to its DEX tiebreak: the +opening round of every fight, the one where the shooting starts, was the single round +the Reaction system did not order. `startCombat()` now rolls it. + +**Ten of the twenty new postings carried a weapon they had no skill for.** The +Antiquities Officer drew a flintlock and rolled it at 5%, which is what exposed it; +the Registry, Interview, Pathology, Recording, Access, Disposal, Translation, +Naturalist, Pursuit and Settlements Officers all had the same fault. Every posting's +support list now trains what it is issued, and `check-kits` fails the build if a role +is ever handed a weapon it cannot use. The flintlock is now 40%. + +**Flagged, not changed — a critical attack is cancelled by an ordinary success.** +`defend()` never learns what it is defending against: `turnedAside` is true for any +critical, special or success, so a bare success parry stops a critical entirely. The +rules text as written supports this — a defence either works or it does not — so the +code is not wrong, but it sits badly against everything else the system says about +criticals (maximum damage, armour ignored entirely). In play a boarding-axe critical +was turned aside by a special parry and cost nothing. Grading defences by level +(a defence stops an attack of equal or lower level; a lesser defence only reduces it) +would make criticals matter, but it changes combat maths materially and is a design +decision, not a defect fix. + +## Defences graded by level + +The binary rule is gone. A defence now stops a blow of its own quality or worse +outright; against something better it still helps, but only by as much as it was +outclassed by. + + landing rank = failure + (attack rank − defence rank) + + vs critical vs special vs success vs failed + critical turned aside success special critical + special turned aside turned aside success special + success turned aside turned aside turned aside success + +So only a critical stops a critical, a success parry shaves a critical to a special +rather than cancelling it, and a failed defence does nothing at all. The reduced +level is the result for everything downstream — a critical cut to a special no +longer ignores armour, it merely halves it. + +`defenceOutcomeFor(attackLevel, defenceLevel)` and `levelRank()` live in `rules.mjs` +with the rest, carry ten spot-checks in `check-rules`, and the rules journal builds +its table by calling the function, so the book cannot describe it differently from +the way it resolves. 29 rules, 104 formulas. + +**The standing blow.** A defence has to know what it is answering and the sheet's +Dodge and Parry buttons had no way of knowing — they were pressed in isolation. +Every attack roll now records what it scored; the defender picks it up; the defence +amends it to whatever survived. It is kept on the active Combat so it reaches every +client rather than only the machine that rolled, falls back to a module-level copy +when nobody started a combat, expires after two minutes, and is never offered to the +person who threw it. + +**R-216 · the damage button ignored the attack entirely.** `_onRollDamage` called +`rollWeaponDamage` with no level at all, so it always rolled ordinary damage: a +critical got neither maximum damage nor its armour bypass, and the whole critical +rule existed only in the wording of the chat card. This is why grading defences was +not enough on its own. Damage now rolls at the level the blow actually landed at, +and a blow that was turned aside refuses to roll damage at all. + +Played it: four senior agents against three Dangerous adversaries, twelve rounds, +the agents won. Every cell of the table came up in play, including the one that +prompted the change — Ganger's critical against Marek's success parry, cut to a +special and landing for 6 where it would previously have cost nothing — and its +mirror, a critical parry turning aside a special. + +## Balance check on the graded defence + +Played a standard field team — Field Lead, Containment, Technical, Physician — +against four Armed, and then ran the maths properly: 3000–4000 simulated fights per +configuration, driven by the shipped functions (`resolveBands`, `gradeRoll`, +`defencePenaltyFor`, `defenceOutcomeFor`, `armourAgainst`, `damageAfterArmour`) +rather than a reimplementation. + +**Grading did not make combat more lethal.** My warning when I shipped it was wrong +and is withdrawn. Both sides lose defensive value equally, so it nets out: + + 4 trained agents vs binary graded Δ win + bystander x4 100.0% / 5.8r 100.0% / 5.7r +0.0 + trained x4 100.0% / 7.8r 100.0% / 7.5r +0.0 + armed x4 79.9% / 13.2r 80.7% / 12.2r +0.7 + dangerous x3 35.0% / 12.8r 37.8% / 11.8r +2.8 + anomalous x2 18.6% / 16.4r 20.6% / 15.2r +2.0 + +Agent casualties are unchanged (1.76 → 1.78 at Armed, 3.14 → 3.09 at Dangerous). +Fights are consistently about one round shorter. `DEFENCE_STEP` does not need +touching. + +**R-217 · Dodge was 0% for most of the agency.** Dodge is the only skill whose base +is written as a formula — `dex*2` — and nothing in the system ever evaluated a +formula base. `base.value` stayed 0, `skillRating()` read `system.value` at face +value, and so any posting that did not separately train Dodge had **Dodge 0%**: the +game's universal defence, unavailable to ten of the twenty-five postings, and +missing its DEX×2 floor even for the fifteen that had it. This is what the balance +check was actually for, and it is worth far more than the grading change: + + dodge 0% — as it actually shipped win 52.0% 2.77 of 4 agents lost + dodge 26% — DEX x 2 floor (fixed) win 66.5% 2.34 of 4 agents lost + dodge 47% — a posting that trains it win 79.7% 1.82 of 4 agents lost + +`skillBaseFrom(formula, characteristics)` now lives in `rules.mjs` with five +spot-checks, is applied as a floor rather than a replacement in the skill item's +derived data, and deliberately parses rather than evals. Every posting now shows a +real Dodge: the untrained sit exactly at DEX×2, the trained above it, none at zero. +30 rules, 109 formulas. + +**R-213, third time.** Generated NPCs still had no unarmed attack. The pack builder +and `generateCharacter` were both fixed; `generateNPC` builds its items on a +separate path and was missed, so four adversaries armed only with pistols stood +through an entire close-quarters fight doing nothing. Fixed there too, with the +Brawl skill to go with it. + +**Two observations, not defects.** Threat tiers are steep — Dangerous x3 is winnable +about 38% of the time and costs three of four agents; Anomalous x2 is around 20%. +And an unarmed attack is 1d3 against armour that is routinely 2, so fists are close +to useless against anyone equipped: the tail of the played fight was four people +punching each other for five rounds to no effect. Both are arguably correct, but a +GM should know before pricing an encounter. + +## Two more fights with the Dodge floor in + +Standard field teams including postings that rely on the new DEX x 2 floor, against +Armed adversaries. First fight: a clean win, all three adversaries down, nobody +lost. Second: ten rounds, three of four adversaries down, two agents lost. + +Neither fight happened to land a floor-rated dodge, which is too thin to conclude +anything from, so the effect was measured per incoming blow instead — 200,000 trials +at each rating, attacker at 48%, first defence of the round: + + dodge 0% stopped 1.0% reduced 0.0% through in full 99.0% + dodge 18% stopped 14.7% reduced 3.1% through in full 82.2% + dodge 24% stopped 19.9% reduced 4.0% through in full 76.1% + dodge 30% stopped 24.8% reduced 5.2% through in full 70.0% + dodge 47% stopped 38.9% reduced 8.1% through in full 52.9% + dodge 58% stopped 47.8% reduced 10.1% through in full 42.0% + +0% is what shipped; 18–30% is the floor; 47–58% is a posting that trains it. A +support agent went from taking 99% of blows at full effect to taking 70–82%. The +1.0% at dodge 0% is the 1% floor in `resolveBands` doing its job — a roll of 1 is +still a critical dodge. + +The "reduced" column is the graded rule earning its keep: 3–10% of blows now land +degraded rather than either landing whole or vanishing. + +Two harness faults, mine not the system's, recorded so the next play-through does +not repeat them. Picking a weapon by highest skill rating made adversaries punch +rather than shoot, because `generateNPC` gives Brawl a rating from the same range as +the weapon skills — a fight ran entirely at 1d3 against armour 2. Choosing by +expected damage after the target's armour fixes it. And once Punch / kick is always +available, a weapon-selector that only reloads when it finds nothing to swing will +never reload at all. + +## Brawl is a fallback + +`generateNPC` rated Brawl from the same range as the weapon skills, so an Armed +adversary was exactly as good with its fists as with the pistol on its hip — and any +sensible target-picker chose the fists, which then bounced off armour 2 for 1d3. A +whole fight ran that way. + +`brawlFallbackFor(trained)` in `rules.mjs`: sixty per cent of the trained rating, +never below the skill's printed base of 25. Four spot-checks. 31 rules, 113 formulas. + + tier weapon avg brawl avg + bystander — 25% + trained 39% 25% + armed 44% 28% + dangerous 61% 35% + anomalous 67% 67% <- attacks WITH Brawl + apex 79% 75% <- attacks WITH Brawl + +**The trap in this one.** The block that issues the unarmed attack ran BEFORE the +attack packages, and the package loop will not overwrite a skill that already +exists. Lowering Brawl in place would therefore have crippled exactly the creatures +whose attack IS Brawl — the anomalous grasp and the apex — reducing a 67% attack to +40% and quietly gutting the top two threat tiers. The block now runs after the +packages and only fills Brawl in when nothing else has, so a grasp keeps its full +rating and only an adversary genuinely falling back on its fists takes the +reduction. The three roles that legitimately train Brawl — Containment, Night +Warden, Rigging — are likewise untouched. + +Played it: four field agents against three Dangerous, ten rounds, three agents down +and two of three adversaries down before it came to a last two standing. **35 weapon +attacks, no fist attacks.** The fight opened on the graded rule doing precisely what +it was built for: Whitloe's rifle critical against Mirren's successful dodge, cut to +a special, still 10 to the chest, still fatal. Under the old binary rule that dodge +cancelled it outright. + +## Stores, and saying what a posting is + +**The catalogue is roughly twice the size.** Gear went from 45 entries to 93 — 137 +items across gear, weapons and armour. The new stock is the small equipment an +agency this old actually issues: warrant cards, field dressings, marking chalk, +numbered seals, carbon duplicate books, a date stamp that occasionally stamps the +wrong date, cordon tape citing an Act that does not exist, and a vacuum flask +described in the file as the most requisitioned item in the department's history. +Spread across all four eras — tinderboxes and quill and ink horns, wound foam and +cold light strips, and five more transposed objects priced in Coherence including +the coin that comes back and the attendance register, which is right, and is not +always a list you want. 189 icons regenerated; every catalogue item has one. + +**An agent now leaves creation properly equipped**, 16 to 21 items rather than 8: + +- `STANDING_ISSUE` — anchor, handset, warrant card, dressings, lamp, chalk, + whistle, notebook. Every officer, every posting. +- The posting's own kit, expanded from five items to eight. +- `TIER_ISSUE` — seniority is issued more and trusted with more, from nothing at + Probationary up to seals, master keys, a duplicate book and a trauma unit for a + Case Officer. +- `PERSONAL_EFFECTS` — one thing that is theirs rather than the department's, which + is the reason the anchor works: an agent carrying nothing of their own has nothing + to be filed against. + +Encumbrance holds: a Containment officer comes out at 26.1 kg against a 29 kg free +carry, and `stowOverload` already sheds the rest for anyone heavier. + +**The role dropdown said nothing about the roles.** Twenty-five job titles, no +indication of what any of them did. Each posting now carries a `blurb`, and the +creation dialog paints a card as the posting is selected: what the job is, its key +characteristic, what it trains, and what it draws from stores. The names are read +from the compendium rather than derived from the ids, so the dialog cannot print +"Quill Ink" where the sheet says "Quill and ink horn" — which is exactly what the +first cut of it did. + +## Transport, and the forward stores + +**A `vehicle` item type.** A lorry is not a piece of kit and modelling it as gear +would have meant lying about it in the encumbrance sum, so vehicles are their own +Item type with their own schema: crew, seats, cargo, hull (its own hit points), +armour points (which protect the occupants), range, terrain, and the skill that +works it. Speed is a BAND — walking pace through to no travel time at all — because +the game measures distance in bands everywhere else and a figure in km/h would have +been the only number on the sheet nobody used. Vehicles carry `carried: false`, so a +van in the boot of the character sheet does not weigh the agent down. + +Fourteen of them in their own compendium: the pool car, the Custodian van with the +cage bulkhead, a long-wheelbase utility, a containment lorry, a dispatch motorcycle, +a river launch (most of the department's older sites are drains), a light +helicopter, and — still on the inventory and never struck off — a hand cart and a +pony and trap. Forward of us: the flycycle, a cargo lifter, and a sealed crawler +with eleven days of its own air. And two that came back with us: the stepping frame, +and the carriage that is already parked outside wherever you were going to go. + +**Twenty-five more items in stores.** Transport stores — tow line, jack and sand +plates, jerry can, spares roll, the vehicle log book that is the most audited +document in the department. Then the forward stores, which Registry will not issue +to a probationary officer: stepping discs, a stasis box, a field autodoc, impact +liner, shadow cloth, a descent harness, a sonic probe, a pressure tent, translator +pins, trauma ampoules that bring an agent back from zero once. And three things +filed further out still — a sealed sphere nothing has opened from outside, the +contentment tap the department has destroyed six of, and a maintenance log for a +structure nobody has located, still being written. + +The catalogue is now **175 items**: 118 gear, 14 vehicles, 30 weapons, 13 armour — +103 modern, 26 antique, 34 future, 12 anomalous. 228 icons; every one is drawn. + +## Hacking: three options, and a debate with Codex + +Not built. Three options put to the designer to choose from, after two rounds +against Codex (gpt-5.6). Codex opened with three designs and picked The Access +Dossier; I objected on two grounds it could not have known — that the system already +has a standings subsystem with taboos, desires and per-tier rewards that an +"Authority" maps onto almost exactly, and that the Dossier models the break-in but +not the thing that actually frightens people about modern intrusion, which is access +that persists and detection that arrives weeks later. Codex conceded both, discarded +its own first option as "the rejected run structure reskinned", and moved its pick +to Borrowed Authority with the Dossier demoted to an acquisition procedure rather +than a second subsystem. + +Full transcripts in the session scratchpad. Options as put to the designer: + +1. CORDON & TRACE — staged intrusion against a rising Trace clock. Both of us + rejected this: a live infiltration with an alert meter is the cyberpunk run in a + grey coat, whatever the fiction on top. +2. THE ACCESS DOSSIER — the team assembles a case file across several scenes and + different skills, then one decisive roll buys exactly the declared objective. + Fast, collective, sharply bounded; weak on persistence, and its Posture numbers + need tightening or three generic items beat any target. +3. BORROWED AUTHORITY — access is a named credential with a scope, a Trust value, an + expiry and a red flag. In scope, it simply works. Out of scope, roll. The horror + is that Coherence can widen a scope because reality now remembers the person held + that duty — and below zero Trust, the agent's own records begin merging with the + identity they borrowed. + +## Borrowed Authority, built + +Option 3 with the dossier as acquisition, as chosen. There is no hacking skill and +nothing to break into. + +**The rules** are in `rules.mjs` with nineteen spot-checks: `SCOPE_TIERS`, +`scopeStanceFor`, `scrutinyFor`, `authorityOutcomeFor`, `trustAfter`, +`dossierTrustFor`, `dossierScopeFor`, `widenScopeCostFor`, `mergeStageFor`. +40 rules, 132 formulas. + +- **Inside scope there is no roll.** A facilities contractor opening a plant room is + Tuesday. 1 Trust, and it works. +- **One tier beyond is a stretch** — roll, at −20 per red flag you break. The system + is not testing whether you are clever, it is testing whether you behave like the + person whose name is on the record. +- **Two tiers beyond is impossible at any skill.** That is the rule that stops one + agent quietly owning every system in the setting. +- Trust: critical +1, special free, success −1, failure −1, **fumble burns it** and + puts a real person in the department's way. At 0 the account is flagged until a + scene rehabilitates it. +- **The dossier** is acquisition, not a second subsystem: three items, each a + separate scene with a different skill, at most one at a keyboard. Items make the + credential better, never the roll easier — which is what stops three generic + successes beating any target. +- **AI** is a posture on the target, not an opponent. A *gatekeeper* raises what + counts as routine; an *analyst* writes a new red flag onto the credential every + time it is stretched; an *asset* stands in for a dossier item. +- **Coherence** widens a scope, because reality can be made to remember the person + always held that duty. First costs 1, then 2, then 3. Push past what Trust will + carry and the two files converge a step, and nothing spends anything to converge + them back. + +Played it end to end: a Registry, Interview and Access officer built the credential +for M. Hughes, facilities contractor; the Signals officer opened a ticket and read a +door schedule with no roll at all; failed to alter the schedule at two in the morning +because that is one of the things Hughes never does; was told flatly that a +contractor could never issue accounts; spent Coherence to make the switchroom his +remit, after which the same action needed no roll; ran the credential to 0 Trust, +found it flagged and refused, and had it talked back into service by the Interview +Officer. Coherence 10 to 7, and a circular now arrives at the agent's desk addressed +to M. Hughes. + +**R-218 · one localisation key un-localised the entire system.** Shipping both +`RINGBRP.Authority.Merge` (a string) and `RINGBRP.Authority.Merge.0` made Foundry try +to assign a property to a string while expanding dotted keys. It throws inside the +loader and the loader abandons the WHOLE FILE — every sheet in the system silently +began printing raw keys, with no error a user would ever connect to the cause. The +leaf is renamed, and `tools/check-lang.mjs` now fails the build on any key that is +both a value and a prefix of another, and on duplicate keys, which `JSON.parse` +swallows. diff --git a/fonts/Michroma-Regular.woff2 b/fonts/Michroma-Regular.woff2 new file mode 100644 index 0000000..807ac77 Binary files /dev/null and b/fonts/Michroma-Regular.woff2 differ diff --git a/icon-sheet.html b/icon-sheet.html new file mode 100644 index 0000000..3c14aa8 --- /dev/null +++ b/icon-sheet.html @@ -0,0 +1,8 @@ + +The Custodians — icons + +

The Custodians — 76 icons

actor_case
actor_npc
actor_pc
anchor
baton
beacon
binocs
breach_suit
camera
climbing_kit
cover_id
crossing_regulator
cutter
dagger
discovery
door
envsuit
field_kit
fieldmedic
geiger
grasp
group
handset
hide
impact_vest
jacket
journal
key
lance
machete
mail
material
medkit
notebook
pistol_heavy
pistol_light
pistol_med
plate
power_cell
project
prybar
punch
rations
restraints
rifle_heavy
rifle_med
rope
salvage_plate
sample_case
scanner
shortbow
shotgun
skill_comm
skill_manip
skill_melee
skill_mental
skill_percep
skill_phys
skill_ranged
smg
soft_armour
spare_cells
spear1h
stab_vest
survey_kit
table
talent_agency
talent_field
talent_generic
talent_species
toolkit
torch
trauma_unit
unmaker
ward
water
\ No newline at end of file diff --git a/icons/actor_case.svg b/icons/actor_case.svg new file mode 100644 index 0000000..f19b558 --- /dev/null +++ b/icons/actor_case.svg @@ -0,0 +1,9 @@ + + + + + + + + + \ No newline at end of file diff --git a/icons/actor_npc.svg b/icons/actor_npc.svg new file mode 100644 index 0000000..609e3a7 --- /dev/null +++ b/icons/actor_npc.svg @@ -0,0 +1,9 @@ + + + + + + + + + \ No newline at end of file diff --git a/icons/actor_pc.svg b/icons/actor_pc.svg new file mode 100644 index 0000000..46f9058 --- /dev/null +++ b/icons/actor_pc.svg @@ -0,0 +1,9 @@ + + + + + + + + + \ No newline at end of file diff --git a/icons/analgesics.svg b/icons/analgesics.svg new file mode 100644 index 0000000..e020519 --- /dev/null +++ b/icons/analgesics.svg @@ -0,0 +1,9 @@ + + + + + + + + + \ No newline at end of file diff --git a/icons/anchor.svg b/icons/anchor.svg new file mode 100644 index 0000000..a88656a --- /dev/null +++ b/icons/anchor.svg @@ -0,0 +1,9 @@ + + + + + + + + + \ No newline at end of file diff --git a/icons/assist_sleeve.svg b/icons/assist_sleeve.svg new file mode 100644 index 0000000..2708f5a --- /dev/null +++ b/icons/assist_sleeve.svg @@ -0,0 +1,9 @@ + + + + + + + + + \ No newline at end of file diff --git a/icons/attendance_register.svg b/icons/attendance_register.svg new file mode 100644 index 0000000..e6ff4b6 --- /dev/null +++ b/icons/attendance_register.svg @@ -0,0 +1,10 @@ + + + + + + + + + \ No newline at end of file diff --git a/icons/authority.svg b/icons/authority.svg new file mode 100644 index 0000000..15d6bbf --- /dev/null +++ b/icons/authority.svg @@ -0,0 +1,9 @@ + + + + + + + + + \ No newline at end of file diff --git a/icons/authority_burned.svg b/icons/authority_burned.svg new file mode 100644 index 0000000..d6e2dac --- /dev/null +++ b/icons/authority_burned.svg @@ -0,0 +1,9 @@ + + + + + + + + + \ No newline at end of file diff --git a/icons/authority_flagged.svg b/icons/authority_flagged.svg new file mode 100644 index 0000000..97a03cc --- /dev/null +++ b/icons/authority_flagged.svg @@ -0,0 +1,9 @@ + + + + + + + + + \ No newline at end of file diff --git a/icons/autodoc.svg b/icons/autodoc.svg new file mode 100644 index 0000000..c7f4d1c --- /dev/null +++ b/icons/autodoc.svg @@ -0,0 +1,9 @@ + + + + + + + + + \ No newline at end of file diff --git a/icons/barrier_gloves.svg b/icons/barrier_gloves.svg new file mode 100644 index 0000000..b775d5c --- /dev/null +++ b/icons/barrier_gloves.svg @@ -0,0 +1,9 @@ + + + + + + + + + \ No newline at end of file diff --git a/icons/baton.svg b/icons/baton.svg new file mode 100644 index 0000000..69180ab --- /dev/null +++ b/icons/baton.svg @@ -0,0 +1,9 @@ + + + + + + + + + \ No newline at end of file diff --git a/icons/beacon.svg b/icons/beacon.svg new file mode 100644 index 0000000..153d43f --- /dev/null +++ b/icons/beacon.svg @@ -0,0 +1,9 @@ + + + + + + + + + \ No newline at end of file diff --git a/icons/beacon_hard.svg b/icons/beacon_hard.svg new file mode 100644 index 0000000..5f769b8 --- /dev/null +++ b/icons/beacon_hard.svg @@ -0,0 +1,9 @@ + + + + + + + + + \ No newline at end of file diff --git a/icons/binocs.svg b/icons/binocs.svg new file mode 100644 index 0000000..d549b5b --- /dev/null +++ b/icons/binocs.svg @@ -0,0 +1,9 @@ + + + + + + + + + \ No newline at end of file diff --git a/icons/black_mirror.svg b/icons/black_mirror.svg new file mode 100644 index 0000000..02aca5c --- /dev/null +++ b/icons/black_mirror.svg @@ -0,0 +1,10 @@ + + + + + + + + + \ No newline at end of file diff --git a/icons/blanket.svg b/icons/blanket.svg new file mode 100644 index 0000000..6e9f20e --- /dev/null +++ b/icons/blanket.svg @@ -0,0 +1,9 @@ + + + + + + + + + \ No newline at end of file diff --git a/icons/boarding_axe.svg b/icons/boarding_axe.svg new file mode 100644 index 0000000..d8473c8 --- /dev/null +++ b/icons/boarding_axe.svg @@ -0,0 +1,9 @@ + + + + + + + + + \ No newline at end of file diff --git a/icons/bolt_croppers.svg b/icons/bolt_croppers.svg new file mode 100644 index 0000000..bf5474f --- /dev/null +++ b/icons/bolt_croppers.svg @@ -0,0 +1,9 @@ + + + + + + + + + \ No newline at end of file diff --git a/icons/brass_compass.svg b/icons/brass_compass.svg new file mode 100644 index 0000000..0be98ce --- /dev/null +++ b/icons/brass_compass.svg @@ -0,0 +1,9 @@ + + + + + + + + + \ No newline at end of file diff --git a/icons/breach_suit.svg b/icons/breach_suit.svg new file mode 100644 index 0000000..df62eb5 --- /dev/null +++ b/icons/breach_suit.svg @@ -0,0 +1,9 @@ + + + + + + + + + \ No newline at end of file diff --git a/icons/breather.svg b/icons/breather.svg new file mode 100644 index 0000000..5a6e638 --- /dev/null +++ b/icons/breather.svg @@ -0,0 +1,9 @@ + + + + + + + + + \ No newline at end of file diff --git a/icons/brigandine.svg b/icons/brigandine.svg new file mode 100644 index 0000000..dc4164f --- /dev/null +++ b/icons/brigandine.svg @@ -0,0 +1,9 @@ + + + + + + + + + \ No newline at end of file diff --git a/icons/camera.svg b/icons/camera.svg new file mode 100644 index 0000000..3c59b48 --- /dev/null +++ b/icons/camera.svg @@ -0,0 +1,9 @@ + + + + + + + + + \ No newline at end of file diff --git a/icons/carbine.svg b/icons/carbine.svg new file mode 100644 index 0000000..fce67bc --- /dev/null +++ b/icons/carbine.svg @@ -0,0 +1,9 @@ + + + + + + + + + \ No newline at end of file diff --git a/icons/carbon_book.svg b/icons/carbon_book.svg new file mode 100644 index 0000000..c0e1b4f --- /dev/null +++ b/icons/carbon_book.svg @@ -0,0 +1,9 @@ + + + + + + + + + \ No newline at end of file diff --git a/icons/cell_stack.svg b/icons/cell_stack.svg new file mode 100644 index 0000000..0b65c88 --- /dev/null +++ b/icons/cell_stack.svg @@ -0,0 +1,9 @@ + + + + + + + + + \ No newline at end of file diff --git a/icons/chalk.svg b/icons/chalk.svg new file mode 100644 index 0000000..7ba97c6 --- /dev/null +++ b/icons/chalk.svg @@ -0,0 +1,9 @@ + + + + + + + + + \ No newline at end of file diff --git a/icons/chirurgeon_kit.svg b/icons/chirurgeon_kit.svg new file mode 100644 index 0000000..98990f7 --- /dev/null +++ b/icons/chirurgeon_kit.svg @@ -0,0 +1,9 @@ + + + + + + + + + \ No newline at end of file diff --git a/icons/climbing_kit.svg b/icons/climbing_kit.svg new file mode 100644 index 0000000..b68ad3e --- /dev/null +++ b/icons/climbing_kit.svg @@ -0,0 +1,9 @@ + + + + + + + + + \ No newline at end of file diff --git a/icons/clipboard.svg b/icons/clipboard.svg new file mode 100644 index 0000000..2f5a74d --- /dev/null +++ b/icons/clipboard.svg @@ -0,0 +1,9 @@ + + + + + + + + + \ No newline at end of file diff --git a/icons/cold_light.svg b/icons/cold_light.svg new file mode 100644 index 0000000..ccec842 --- /dev/null +++ b/icons/cold_light.svg @@ -0,0 +1,9 @@ + + + + + + + + + \ No newline at end of file diff --git a/icons/cordon_kit.svg b/icons/cordon_kit.svg new file mode 100644 index 0000000..8aea6c9 --- /dev/null +++ b/icons/cordon_kit.svg @@ -0,0 +1,9 @@ + + + + + + + + + \ No newline at end of file diff --git a/icons/cover_id.svg b/icons/cover_id.svg new file mode 100644 index 0000000..0770066 --- /dev/null +++ b/icons/cover_id.svg @@ -0,0 +1,9 @@ + + + + + + + + + \ No newline at end of file diff --git a/icons/crawler.svg b/icons/crawler.svg new file mode 100644 index 0000000..3b93248 --- /dev/null +++ b/icons/crawler.svg @@ -0,0 +1,9 @@ + + + + + + + + + \ No newline at end of file diff --git a/icons/crossbow.svg b/icons/crossbow.svg new file mode 100644 index 0000000..38898f0 --- /dev/null +++ b/icons/crossbow.svg @@ -0,0 +1,9 @@ + + + + + + + + + \ No newline at end of file diff --git a/icons/crossing_regulator.svg b/icons/crossing_regulator.svg new file mode 100644 index 0000000..05bec00 --- /dev/null +++ b/icons/crossing_regulator.svg @@ -0,0 +1,10 @@ + + + + + + + + + \ No newline at end of file diff --git a/icons/cudgel.svg b/icons/cudgel.svg new file mode 100644 index 0000000..f17267d --- /dev/null +++ b/icons/cudgel.svg @@ -0,0 +1,9 @@ + + + + + + + + + \ No newline at end of file diff --git a/icons/cutter.svg b/icons/cutter.svg new file mode 100644 index 0000000..7126783 --- /dev/null +++ b/icons/cutter.svg @@ -0,0 +1,10 @@ + + + + + + + + + \ No newline at end of file diff --git a/icons/dagger.svg b/icons/dagger.svg new file mode 100644 index 0000000..326f70d --- /dev/null +++ b/icons/dagger.svg @@ -0,0 +1,9 @@ + + + + + + + + + \ No newline at end of file diff --git a/icons/data_wafer.svg b/icons/data_wafer.svg new file mode 100644 index 0000000..373d8a3 --- /dev/null +++ b/icons/data_wafer.svg @@ -0,0 +1,9 @@ + + + + + + + + + \ No newline at end of file diff --git a/icons/date_stamp.svg b/icons/date_stamp.svg new file mode 100644 index 0000000..4ab870f --- /dev/null +++ b/icons/date_stamp.svg @@ -0,0 +1,9 @@ + + + + + + + + + \ No newline at end of file diff --git a/icons/density_scanner.svg b/icons/density_scanner.svg new file mode 100644 index 0000000..668bd62 --- /dev/null +++ b/icons/density_scanner.svg @@ -0,0 +1,9 @@ + + + + + + + + + \ No newline at end of file diff --git a/icons/dictaphone.svg b/icons/dictaphone.svg new file mode 100644 index 0000000..58d187f --- /dev/null +++ b/icons/dictaphone.svg @@ -0,0 +1,9 @@ + + + + + + + + + \ No newline at end of file diff --git a/icons/discovery.svg b/icons/discovery.svg new file mode 100644 index 0000000..4761fe7 --- /dev/null +++ b/icons/discovery.svg @@ -0,0 +1,9 @@ + + + + + + + + + \ No newline at end of file diff --git a/icons/door.svg b/icons/door.svg new file mode 100644 index 0000000..b3351f9 --- /dev/null +++ b/icons/door.svg @@ -0,0 +1,9 @@ + + + + + + + + + \ No newline at end of file diff --git a/icons/door_wedges.svg b/icons/door_wedges.svg new file mode 100644 index 0000000..5323397 --- /dev/null +++ b/icons/door_wedges.svg @@ -0,0 +1,9 @@ + + + + + + + + + \ No newline at end of file diff --git a/icons/dressings.svg b/icons/dressings.svg new file mode 100644 index 0000000..0b6ea1c --- /dev/null +++ b/icons/dressings.svg @@ -0,0 +1,9 @@ + + + + + + + + + \ No newline at end of file diff --git a/icons/duelling_sword.svg b/icons/duelling_sword.svg new file mode 100644 index 0000000..f6e120d --- /dev/null +++ b/icons/duelling_sword.svg @@ -0,0 +1,9 @@ + + + + + + + + + \ No newline at end of file diff --git a/icons/ear_defenders.svg b/icons/ear_defenders.svg new file mode 100644 index 0000000..c537458 --- /dev/null +++ b/icons/ear_defenders.svg @@ -0,0 +1,9 @@ + + + + + + + + + \ No newline at end of file diff --git a/icons/entrenching_tool.svg b/icons/entrenching_tool.svg new file mode 100644 index 0000000..6f1b0b9 --- /dev/null +++ b/icons/entrenching_tool.svg @@ -0,0 +1,9 @@ + + + + + + + + + \ No newline at end of file diff --git a/icons/envsuit.svg b/icons/envsuit.svg new file mode 100644 index 0000000..3b1180c --- /dev/null +++ b/icons/envsuit.svg @@ -0,0 +1,9 @@ + + + + + + + + + \ No newline at end of file diff --git a/icons/evidence_tape.svg b/icons/evidence_tape.svg new file mode 100644 index 0000000..44fc7c1 --- /dev/null +++ b/icons/evidence_tape.svg @@ -0,0 +1,9 @@ + + + + + + + + + \ No newline at end of file diff --git a/icons/field_kit.svg b/icons/field_kit.svg new file mode 100644 index 0000000..093ce54 --- /dev/null +++ b/icons/field_kit.svg @@ -0,0 +1,9 @@ + + + + + + + + + \ No newline at end of file diff --git a/icons/field_net.svg b/icons/field_net.svg new file mode 100644 index 0000000..0eeb58f --- /dev/null +++ b/icons/field_net.svg @@ -0,0 +1,9 @@ + + + + + + + + + \ No newline at end of file diff --git a/icons/field_slate.svg b/icons/field_slate.svg new file mode 100644 index 0000000..417dd26 --- /dev/null +++ b/icons/field_slate.svg @@ -0,0 +1,9 @@ + + + + + + + + + \ No newline at end of file diff --git a/icons/fieldmedic.svg b/icons/fieldmedic.svg new file mode 100644 index 0000000..94eea7c --- /dev/null +++ b/icons/fieldmedic.svg @@ -0,0 +1,9 @@ + + + + + + + + + \ No newline at end of file diff --git a/icons/flintlock.svg b/icons/flintlock.svg new file mode 100644 index 0000000..c09be5a --- /dev/null +++ b/icons/flintlock.svg @@ -0,0 +1,9 @@ + + + + + + + + + \ No newline at end of file diff --git a/icons/flycycle.svg b/icons/flycycle.svg new file mode 100644 index 0000000..0b6a6c4 --- /dev/null +++ b/icons/flycycle.svg @@ -0,0 +1,9 @@ + + + + + + + + + \ No newline at end of file diff --git a/icons/gas_mask.svg b/icons/gas_mask.svg new file mode 100644 index 0000000..37ee0bc --- /dev/null +++ b/icons/gas_mask.svg @@ -0,0 +1,9 @@ + + + + + + + + + \ No newline at end of file diff --git a/icons/gauss_pistol.svg b/icons/gauss_pistol.svg new file mode 100644 index 0000000..09b4fa5 --- /dev/null +++ b/icons/gauss_pistol.svg @@ -0,0 +1,9 @@ + + + + + + + + + \ No newline at end of file diff --git a/icons/gauss_rifle.svg b/icons/gauss_rifle.svg new file mode 100644 index 0000000..4988438 --- /dev/null +++ b/icons/gauss_rifle.svg @@ -0,0 +1,9 @@ + + + + + + + + + \ No newline at end of file diff --git a/icons/geiger.svg b/icons/geiger.svg new file mode 100644 index 0000000..5349571 --- /dev/null +++ b/icons/geiger.svg @@ -0,0 +1,9 @@ + + + + + + + + + \ No newline at end of file diff --git a/icons/general_hull.svg b/icons/general_hull.svg new file mode 100644 index 0000000..5ac90df --- /dev/null +++ b/icons/general_hull.svg @@ -0,0 +1,10 @@ + + + + + + + + + \ No newline at end of file diff --git a/icons/grasp.svg b/icons/grasp.svg new file mode 100644 index 0000000..91eb8b7 --- /dev/null +++ b/icons/grasp.svg @@ -0,0 +1,9 @@ + + + + + + + + + \ No newline at end of file diff --git a/icons/grav_harness.svg b/icons/grav_harness.svg new file mode 100644 index 0000000..b380e94 --- /dev/null +++ b/icons/grav_harness.svg @@ -0,0 +1,9 @@ + + + + + + + + + \ No newline at end of file diff --git a/icons/groundsheet.svg b/icons/groundsheet.svg new file mode 100644 index 0000000..976f5e4 --- /dev/null +++ b/icons/groundsheet.svg @@ -0,0 +1,9 @@ + + + + + + + + + \ No newline at end of file diff --git a/icons/group.svg b/icons/group.svg new file mode 100644 index 0000000..764eb3f --- /dev/null +++ b/icons/group.svg @@ -0,0 +1,9 @@ + + + + + + + + + \ No newline at end of file diff --git a/icons/hand_cart.svg b/icons/hand_cart.svg new file mode 100644 index 0000000..477ba16 --- /dev/null +++ b/icons/hand_cart.svg @@ -0,0 +1,9 @@ + + + + + + + + + \ No newline at end of file diff --git a/icons/handbell.svg b/icons/handbell.svg new file mode 100644 index 0000000..b0a0253 --- /dev/null +++ b/icons/handbell.svg @@ -0,0 +1,10 @@ + + + + + + + + + \ No newline at end of file diff --git a/icons/handset.svg b/icons/handset.svg new file mode 100644 index 0000000..f476217 --- /dev/null +++ b/icons/handset.svg @@ -0,0 +1,9 @@ + + + + + + + + + \ No newline at end of file diff --git a/icons/hardsuit.svg b/icons/hardsuit.svg new file mode 100644 index 0000000..10b22e0 --- /dev/null +++ b/icons/hardsuit.svg @@ -0,0 +1,9 @@ + + + + + + + + + \ No newline at end of file diff --git a/icons/helicopter.svg b/icons/helicopter.svg new file mode 100644 index 0000000..25f3c9f --- /dev/null +++ b/icons/helicopter.svg @@ -0,0 +1,9 @@ + + + + + + + + + \ No newline at end of file diff --git a/icons/hide.svg b/icons/hide.svg new file mode 100644 index 0000000..8ab8618 --- /dev/null +++ b/icons/hide.svg @@ -0,0 +1,9 @@ + + + + + + + + + \ No newline at end of file diff --git a/icons/hull_patch.svg b/icons/hull_patch.svg new file mode 100644 index 0000000..2f0b2f2 --- /dev/null +++ b/icons/hull_patch.svg @@ -0,0 +1,9 @@ + + + + + + + + + \ No newline at end of file diff --git a/icons/impact_suit.svg b/icons/impact_suit.svg new file mode 100644 index 0000000..c10e0f0 --- /dev/null +++ b/icons/impact_suit.svg @@ -0,0 +1,9 @@ + + + + + + + + + \ No newline at end of file diff --git a/icons/impact_vest.svg b/icons/impact_vest.svg new file mode 100644 index 0000000..828cbb7 --- /dev/null +++ b/icons/impact_vest.svg @@ -0,0 +1,9 @@ + + + + + + + + + \ No newline at end of file diff --git a/icons/iron_manacles.svg b/icons/iron_manacles.svg new file mode 100644 index 0000000..0a906bf --- /dev/null +++ b/icons/iron_manacles.svg @@ -0,0 +1,9 @@ + + + + + + + + + \ No newline at end of file diff --git a/icons/jack_plates.svg b/icons/jack_plates.svg new file mode 100644 index 0000000..f2f2b32 --- /dev/null +++ b/icons/jack_plates.svg @@ -0,0 +1,9 @@ + + + + + + + + + \ No newline at end of file diff --git a/icons/jacket.svg b/icons/jacket.svg new file mode 100644 index 0000000..cf3b312 --- /dev/null +++ b/icons/jacket.svg @@ -0,0 +1,9 @@ + + + + + + + + + \ No newline at end of file diff --git a/icons/jerry_can.svg b/icons/jerry_can.svg new file mode 100644 index 0000000..d06ea81 --- /dev/null +++ b/icons/jerry_can.svg @@ -0,0 +1,9 @@ + + + + + + + + + \ No newline at end of file diff --git a/icons/journal.svg b/icons/journal.svg new file mode 100644 index 0000000..4d1731b --- /dev/null +++ b/icons/journal.svg @@ -0,0 +1,9 @@ + + + + + + + + + \ No newline at end of file diff --git a/icons/key.svg b/icons/key.svg new file mode 100644 index 0000000..f89aeff --- /dev/null +++ b/icons/key.svg @@ -0,0 +1,10 @@ + + + + + + + + + \ No newline at end of file diff --git a/icons/lamp_oil.svg b/icons/lamp_oil.svg new file mode 100644 index 0000000..60befd0 --- /dev/null +++ b/icons/lamp_oil.svg @@ -0,0 +1,9 @@ + + + + + + + + + \ No newline at end of file diff --git a/icons/lance.svg b/icons/lance.svg new file mode 100644 index 0000000..1f54a2a --- /dev/null +++ b/icons/lance.svg @@ -0,0 +1,10 @@ + + + + + + + + + \ No newline at end of file diff --git a/icons/land_rover.svg b/icons/land_rover.svg new file mode 100644 index 0000000..2ebb1d2 --- /dev/null +++ b/icons/land_rover.svg @@ -0,0 +1,9 @@ + + + + + + + + + \ No newline at end of file diff --git a/icons/laudanum.svg b/icons/laudanum.svg new file mode 100644 index 0000000..8a22886 --- /dev/null +++ b/icons/laudanum.svg @@ -0,0 +1,9 @@ + + + + + + + + + \ No newline at end of file diff --git a/icons/launch.svg b/icons/launch.svg new file mode 100644 index 0000000..6f496cb --- /dev/null +++ b/icons/launch.svg @@ -0,0 +1,9 @@ + + + + + + + + + \ No newline at end of file diff --git a/icons/lifter.svg b/icons/lifter.svg new file mode 100644 index 0000000..80ddef5 --- /dev/null +++ b/icons/lifter.svg @@ -0,0 +1,9 @@ + + + + + + + + + \ No newline at end of file diff --git a/icons/locator_tags.svg b/icons/locator_tags.svg new file mode 100644 index 0000000..4b6c8ee --- /dev/null +++ b/icons/locator_tags.svg @@ -0,0 +1,9 @@ + + + + + + + + + \ No newline at end of file diff --git a/icons/lock_rakes.svg b/icons/lock_rakes.svg new file mode 100644 index 0000000..44b423e --- /dev/null +++ b/icons/lock_rakes.svg @@ -0,0 +1,9 @@ + + + + + + + + + \ No newline at end of file diff --git a/icons/lodestone.svg b/icons/lodestone.svg new file mode 100644 index 0000000..5e17342 --- /dev/null +++ b/icons/lodestone.svg @@ -0,0 +1,9 @@ + + + + + + + + + \ No newline at end of file diff --git a/icons/long_ledger.svg b/icons/long_ledger.svg new file mode 100644 index 0000000..fdcad37 --- /dev/null +++ b/icons/long_ledger.svg @@ -0,0 +1,10 @@ + + + + + + + + + \ No newline at end of file diff --git a/icons/machete.svg b/icons/machete.svg new file mode 100644 index 0000000..7ce8b5f --- /dev/null +++ b/icons/machete.svg @@ -0,0 +1,9 @@ + + + + + + + + + \ No newline at end of file diff --git a/icons/mail.svg b/icons/mail.svg new file mode 100644 index 0000000..95152e8 --- /dev/null +++ b/icons/mail.svg @@ -0,0 +1,9 @@ + + + + + + + + + \ No newline at end of file diff --git a/icons/master_keys.svg b/icons/master_keys.svg new file mode 100644 index 0000000..257b6d9 --- /dev/null +++ b/icons/master_keys.svg @@ -0,0 +1,9 @@ + + + + + + + + + \ No newline at end of file diff --git a/icons/material.svg b/icons/material.svg new file mode 100644 index 0000000..2c426bd --- /dev/null +++ b/icons/material.svg @@ -0,0 +1,9 @@ + + + + + + + + + \ No newline at end of file diff --git a/icons/medical_ram.svg b/icons/medical_ram.svg new file mode 100644 index 0000000..18ca3d6 --- /dev/null +++ b/icons/medical_ram.svg @@ -0,0 +1,9 @@ + + + + + + + + + \ No newline at end of file diff --git a/icons/medkit.svg b/icons/medkit.svg new file mode 100644 index 0000000..869844e --- /dev/null +++ b/icons/medkit.svg @@ -0,0 +1,9 @@ + + + + + + + + + \ No newline at end of file diff --git a/icons/monoblade.svg b/icons/monoblade.svg new file mode 100644 index 0000000..c4ddbc0 --- /dev/null +++ b/icons/monoblade.svg @@ -0,0 +1,9 @@ + + + + + + + + + \ No newline at end of file diff --git a/icons/motorcycle.svg b/icons/motorcycle.svg new file mode 100644 index 0000000..de9a2f9 --- /dev/null +++ b/icons/motorcycle.svg @@ -0,0 +1,9 @@ + + + + + + + + + \ No newline at end of file diff --git a/icons/musket.svg b/icons/musket.svg new file mode 100644 index 0000000..65fcaaf --- /dev/null +++ b/icons/musket.svg @@ -0,0 +1,9 @@ + + + + + + + + + \ No newline at end of file diff --git a/icons/notebook.svg b/icons/notebook.svg new file mode 100644 index 0000000..e9140a4 --- /dev/null +++ b/icons/notebook.svg @@ -0,0 +1,9 @@ + + + + + + + + + \ No newline at end of file diff --git a/icons/oil_lantern.svg b/icons/oil_lantern.svg new file mode 100644 index 0000000..f674eaa --- /dev/null +++ b/icons/oil_lantern.svg @@ -0,0 +1,9 @@ + + + + + + + + + \ No newline at end of file diff --git a/icons/oilcloth_coat.svg b/icons/oilcloth_coat.svg new file mode 100644 index 0000000..8ef49ed --- /dev/null +++ b/icons/oilcloth_coat.svg @@ -0,0 +1,9 @@ + + + + + + + + + \ No newline at end of file diff --git a/icons/optic_visor.svg b/icons/optic_visor.svg new file mode 100644 index 0000000..e69b3ad --- /dev/null +++ b/icons/optic_visor.svg @@ -0,0 +1,9 @@ + + + + + + + + + \ No newline at end of file diff --git a/icons/pistol_heavy.svg b/icons/pistol_heavy.svg new file mode 100644 index 0000000..87cf3ca --- /dev/null +++ b/icons/pistol_heavy.svg @@ -0,0 +1,9 @@ + + + + + + + + + \ No newline at end of file diff --git a/icons/pistol_light.svg b/icons/pistol_light.svg new file mode 100644 index 0000000..534c8c5 --- /dev/null +++ b/icons/pistol_light.svg @@ -0,0 +1,9 @@ + + + + + + + + + \ No newline at end of file diff --git a/icons/pistol_med.svg b/icons/pistol_med.svg new file mode 100644 index 0000000..62a68db --- /dev/null +++ b/icons/pistol_med.svg @@ -0,0 +1,9 @@ + + + + + + + + + \ No newline at end of file diff --git a/icons/plate.svg b/icons/plate.svg new file mode 100644 index 0000000..2f2f687 --- /dev/null +++ b/icons/plate.svg @@ -0,0 +1,9 @@ + + + + + + + + + \ No newline at end of file diff --git a/icons/pleasure_tap.svg b/icons/pleasure_tap.svg new file mode 100644 index 0000000..2c9d4cf --- /dev/null +++ b/icons/pleasure_tap.svg @@ -0,0 +1,10 @@ + + + + + + + + + \ No newline at end of file diff --git a/icons/pocket_watch.svg b/icons/pocket_watch.svg new file mode 100644 index 0000000..1479f04 --- /dev/null +++ b/icons/pocket_watch.svg @@ -0,0 +1,9 @@ + + + + + + + + + \ No newline at end of file diff --git a/icons/pool_car.svg b/icons/pool_car.svg new file mode 100644 index 0000000..62f7d6e --- /dev/null +++ b/icons/pool_car.svg @@ -0,0 +1,9 @@ + + + + + + + + + \ No newline at end of file diff --git a/icons/pool_van.svg b/icons/pool_van.svg new file mode 100644 index 0000000..39e9d22 --- /dev/null +++ b/icons/pool_van.svg @@ -0,0 +1,9 @@ + + + + + + + + + \ No newline at end of file diff --git a/icons/portrait_b0.svg b/icons/portrait_b0.svg new file mode 100644 index 0000000..22d5d71 --- /dev/null +++ b/icons/portrait_b0.svg @@ -0,0 +1,9 @@ + + + + + + + + + \ No newline at end of file diff --git a/icons/portrait_b1.svg b/icons/portrait_b1.svg new file mode 100644 index 0000000..1ef7318 --- /dev/null +++ b/icons/portrait_b1.svg @@ -0,0 +1,9 @@ + + + + + + + + + \ No newline at end of file diff --git a/icons/portrait_b2.svg b/icons/portrait_b2.svg new file mode 100644 index 0000000..4d0536e --- /dev/null +++ b/icons/portrait_b2.svg @@ -0,0 +1,9 @@ + + + + + + + + + \ No newline at end of file diff --git a/icons/portrait_b3.svg b/icons/portrait_b3.svg new file mode 100644 index 0000000..393c8e3 --- /dev/null +++ b/icons/portrait_b3.svg @@ -0,0 +1,9 @@ + + + + + + + + + \ No newline at end of file diff --git a/icons/portrait_b4.svg b/icons/portrait_b4.svg new file mode 100644 index 0000000..43d5627 --- /dev/null +++ b/icons/portrait_b4.svg @@ -0,0 +1,9 @@ + + + + + + + + + \ No newline at end of file diff --git a/icons/portrait_b5.svg b/icons/portrait_b5.svg new file mode 100644 index 0000000..7a1c507 --- /dev/null +++ b/icons/portrait_b5.svg @@ -0,0 +1,9 @@ + + + + + + + + + \ No newline at end of file diff --git a/icons/portrait_b6.svg b/icons/portrait_b6.svg new file mode 100644 index 0000000..c05028c --- /dev/null +++ b/icons/portrait_b6.svg @@ -0,0 +1,9 @@ + + + + + + + + + \ No newline at end of file diff --git a/icons/portrait_b7.svg b/icons/portrait_b7.svg new file mode 100644 index 0000000..4e3b051 --- /dev/null +++ b/icons/portrait_b7.svg @@ -0,0 +1,9 @@ + + + + + + + + + \ No newline at end of file diff --git a/icons/portrait_b8.svg b/icons/portrait_b8.svg new file mode 100644 index 0000000..8d34e97 --- /dev/null +++ b/icons/portrait_b8.svg @@ -0,0 +1,9 @@ + + + + + + + + + \ No newline at end of file diff --git a/icons/portrait_b9.svg b/icons/portrait_b9.svg new file mode 100644 index 0000000..63cdf87 --- /dev/null +++ b/icons/portrait_b9.svg @@ -0,0 +1,9 @@ + + + + + + + + + \ No newline at end of file diff --git a/icons/portrait_c0.svg b/icons/portrait_c0.svg new file mode 100644 index 0000000..9003046 --- /dev/null +++ b/icons/portrait_c0.svg @@ -0,0 +1,9 @@ + + + + + + + + + \ No newline at end of file diff --git a/icons/portrait_c1.svg b/icons/portrait_c1.svg new file mode 100644 index 0000000..739993c --- /dev/null +++ b/icons/portrait_c1.svg @@ -0,0 +1,9 @@ + + + + + + + + + \ No newline at end of file diff --git a/icons/portrait_c2.svg b/icons/portrait_c2.svg new file mode 100644 index 0000000..2aa4ec3 --- /dev/null +++ b/icons/portrait_c2.svg @@ -0,0 +1,9 @@ + + + + + + + + + \ No newline at end of file diff --git a/icons/portrait_c3.svg b/icons/portrait_c3.svg new file mode 100644 index 0000000..b63ddbd --- /dev/null +++ b/icons/portrait_c3.svg @@ -0,0 +1,9 @@ + + + + + + + + + \ No newline at end of file diff --git a/icons/portrait_c4.svg b/icons/portrait_c4.svg new file mode 100644 index 0000000..810a55e --- /dev/null +++ b/icons/portrait_c4.svg @@ -0,0 +1,9 @@ + + + + + + + + + \ No newline at end of file diff --git a/icons/portrait_n0.svg b/icons/portrait_n0.svg new file mode 100644 index 0000000..a9f55af --- /dev/null +++ b/icons/portrait_n0.svg @@ -0,0 +1,9 @@ + + + + + + + + + \ No newline at end of file diff --git a/icons/portrait_n1.svg b/icons/portrait_n1.svg new file mode 100644 index 0000000..4b0f2f5 --- /dev/null +++ b/icons/portrait_n1.svg @@ -0,0 +1,9 @@ + + + + + + + + + \ No newline at end of file diff --git a/icons/portrait_n2.svg b/icons/portrait_n2.svg new file mode 100644 index 0000000..37d7da0 --- /dev/null +++ b/icons/portrait_n2.svg @@ -0,0 +1,9 @@ + + + + + + + + + \ No newline at end of file diff --git a/icons/portrait_n3.svg b/icons/portrait_n3.svg new file mode 100644 index 0000000..83642c2 --- /dev/null +++ b/icons/portrait_n3.svg @@ -0,0 +1,9 @@ + + + + + + + + + \ No newline at end of file diff --git a/icons/portrait_n4.svg b/icons/portrait_n4.svg new file mode 100644 index 0000000..5b230ac --- /dev/null +++ b/icons/portrait_n4.svg @@ -0,0 +1,9 @@ + + + + + + + + + \ No newline at end of file diff --git a/icons/portrait_v0.svg b/icons/portrait_v0.svg new file mode 100644 index 0000000..2345221 --- /dev/null +++ b/icons/portrait_v0.svg @@ -0,0 +1,9 @@ + + + + + + + + + \ No newline at end of file diff --git a/icons/portrait_v1.svg b/icons/portrait_v1.svg new file mode 100644 index 0000000..96eb4e4 --- /dev/null +++ b/icons/portrait_v1.svg @@ -0,0 +1,9 @@ + + + + + + + + + \ No newline at end of file diff --git a/icons/portrait_v2.svg b/icons/portrait_v2.svg new file mode 100644 index 0000000..73d6d96 --- /dev/null +++ b/icons/portrait_v2.svg @@ -0,0 +1,9 @@ + + + + + + + + + \ No newline at end of file diff --git a/icons/portrait_v3.svg b/icons/portrait_v3.svg new file mode 100644 index 0000000..d417c14 --- /dev/null +++ b/icons/portrait_v3.svg @@ -0,0 +1,9 @@ + + + + + + + + + \ No newline at end of file diff --git a/icons/portrait_v4.svg b/icons/portrait_v4.svg new file mode 100644 index 0000000..9051035 --- /dev/null +++ b/icons/portrait_v4.svg @@ -0,0 +1,9 @@ + + + + + + + + + \ No newline at end of file diff --git a/icons/power_cell.svg b/icons/power_cell.svg new file mode 100644 index 0000000..f9f6e05 --- /dev/null +++ b/icons/power_cell.svg @@ -0,0 +1,9 @@ + + + + + + + + + \ No newline at end of file diff --git a/icons/power_echo.svg b/icons/power_echo.svg new file mode 100644 index 0000000..828644e --- /dev/null +++ b/icons/power_echo.svg @@ -0,0 +1,9 @@ + + + + + + + + + \ No newline at end of file diff --git a/icons/power_hollow.svg b/icons/power_hollow.svg new file mode 100644 index 0000000..6a059dd --- /dev/null +++ b/icons/power_hollow.svg @@ -0,0 +1,9 @@ + + + + + + + + + \ No newline at end of file diff --git a/icons/power_ledger.svg b/icons/power_ledger.svg new file mode 100644 index 0000000..207a0f5 --- /dev/null +++ b/icons/power_ledger.svg @@ -0,0 +1,9 @@ + + + + + + + + + \ No newline at end of file diff --git a/icons/power_thread.svg b/icons/power_thread.svg new file mode 100644 index 0000000..ce38869 --- /dev/null +++ b/icons/power_thread.svg @@ -0,0 +1,9 @@ + + + + + + + + + \ No newline at end of file diff --git a/icons/power_unmake.svg b/icons/power_unmake.svg new file mode 100644 index 0000000..1723ff2 --- /dev/null +++ b/icons/power_unmake.svg @@ -0,0 +1,9 @@ + + + + + + + + + \ No newline at end of file diff --git a/icons/power_witness.svg b/icons/power_witness.svg new file mode 100644 index 0000000..aeb11a9 --- /dev/null +++ b/icons/power_witness.svg @@ -0,0 +1,9 @@ + + + + + + + + + \ No newline at end of file diff --git a/icons/pressure_tent.svg b/icons/pressure_tent.svg new file mode 100644 index 0000000..45711a9 --- /dev/null +++ b/icons/pressure_tent.svg @@ -0,0 +1,9 @@ + + + + + + + + + \ No newline at end of file diff --git a/icons/probe.svg b/icons/probe.svg new file mode 100644 index 0000000..b70438a --- /dev/null +++ b/icons/probe.svg @@ -0,0 +1,9 @@ + + + + + + + + + \ No newline at end of file diff --git a/icons/project.svg b/icons/project.svg new file mode 100644 index 0000000..dda8683 --- /dev/null +++ b/icons/project.svg @@ -0,0 +1,9 @@ + + + + + + + + + \ No newline at end of file diff --git a/icons/prybar.svg b/icons/prybar.svg new file mode 100644 index 0000000..e228ab3 --- /dev/null +++ b/icons/prybar.svg @@ -0,0 +1,9 @@ + + + + + + + + + \ No newline at end of file diff --git a/icons/punch.svg b/icons/punch.svg new file mode 100644 index 0000000..9d6eb1c --- /dev/null +++ b/icons/punch.svg @@ -0,0 +1,9 @@ + + + + + + + + + \ No newline at end of file diff --git a/icons/quiet_ledger.svg b/icons/quiet_ledger.svg new file mode 100644 index 0000000..821dd39 --- /dev/null +++ b/icons/quiet_ledger.svg @@ -0,0 +1,10 @@ + + + + + + + + + \ No newline at end of file diff --git a/icons/quill_ink.svg b/icons/quill_ink.svg new file mode 100644 index 0000000..9976419 --- /dev/null +++ b/icons/quill_ink.svg @@ -0,0 +1,9 @@ + + + + + + + + + \ No newline at end of file diff --git a/icons/rations.svg b/icons/rations.svg new file mode 100644 index 0000000..43ab3d4 --- /dev/null +++ b/icons/rations.svg @@ -0,0 +1,9 @@ + + + + + + + + + \ No newline at end of file diff --git a/icons/recovery_strop.svg b/icons/recovery_strop.svg new file mode 100644 index 0000000..33f30d5 --- /dev/null +++ b/icons/recovery_strop.svg @@ -0,0 +1,9 @@ + + + + + + + + + \ No newline at end of file diff --git a/icons/restraints.svg b/icons/restraints.svg new file mode 100644 index 0000000..5029aff --- /dev/null +++ b/icons/restraints.svg @@ -0,0 +1,9 @@ + + + + + + + + + \ No newline at end of file diff --git a/icons/returning_coin.svg b/icons/returning_coin.svg new file mode 100644 index 0000000..e01a0f9 --- /dev/null +++ b/icons/returning_coin.svg @@ -0,0 +1,10 @@ + + + + + + + + + \ No newline at end of file diff --git a/icons/revolver.svg b/icons/revolver.svg new file mode 100644 index 0000000..7329629 --- /dev/null +++ b/icons/revolver.svg @@ -0,0 +1,9 @@ + + + + + + + + + \ No newline at end of file diff --git a/icons/rifle_heavy.svg b/icons/rifle_heavy.svg new file mode 100644 index 0000000..5329046 --- /dev/null +++ b/icons/rifle_heavy.svg @@ -0,0 +1,9 @@ + + + + + + + + + \ No newline at end of file diff --git a/icons/rifle_med.svg b/icons/rifle_med.svg new file mode 100644 index 0000000..c75bf36 --- /dev/null +++ b/icons/rifle_med.svg @@ -0,0 +1,9 @@ + + + + + + + + + \ No newline at end of file diff --git a/icons/roof_lamp.svg b/icons/roof_lamp.svg new file mode 100644 index 0000000..2d2f5e6 --- /dev/null +++ b/icons/roof_lamp.svg @@ -0,0 +1,9 @@ + + + + + + + + + \ No newline at end of file diff --git a/icons/rope.svg b/icons/rope.svg new file mode 100644 index 0000000..0b1ccc3 --- /dev/null +++ b/icons/rope.svg @@ -0,0 +1,9 @@ + + + + + + + + + \ No newline at end of file diff --git a/icons/salvage_plate.svg b/icons/salvage_plate.svg new file mode 100644 index 0000000..8625d7d --- /dev/null +++ b/icons/salvage_plate.svg @@ -0,0 +1,10 @@ + + + + + + + + + \ No newline at end of file diff --git a/icons/sample_case.svg b/icons/sample_case.svg new file mode 100644 index 0000000..26462f8 --- /dev/null +++ b/icons/sample_case.svg @@ -0,0 +1,9 @@ + + + + + + + + + \ No newline at end of file diff --git a/icons/sand_glass.svg b/icons/sand_glass.svg new file mode 100644 index 0000000..37de33c --- /dev/null +++ b/icons/sand_glass.svg @@ -0,0 +1,9 @@ + + + + + + + + + \ No newline at end of file diff --git a/icons/scanner.svg b/icons/scanner.svg new file mode 100644 index 0000000..22b5b74 --- /dev/null +++ b/icons/scanner.svg @@ -0,0 +1,9 @@ + + + + + + + + + \ No newline at end of file diff --git a/icons/seal_foam.svg b/icons/seal_foam.svg new file mode 100644 index 0000000..6059f42 --- /dev/null +++ b/icons/seal_foam.svg @@ -0,0 +1,9 @@ + + + + + + + + + \ No newline at end of file diff --git a/icons/sealed_cannister.svg b/icons/sealed_cannister.svg new file mode 100644 index 0000000..87d68e4 --- /dev/null +++ b/icons/sealed_cannister.svg @@ -0,0 +1,9 @@ + + + + + + + + + \ No newline at end of file diff --git a/icons/sealing_wax.svg b/icons/sealing_wax.svg new file mode 100644 index 0000000..c266c27 --- /dev/null +++ b/icons/sealing_wax.svg @@ -0,0 +1,9 @@ + + + + + + + + + \ No newline at end of file diff --git a/icons/seals.svg b/icons/seals.svg new file mode 100644 index 0000000..2795fc9 --- /dev/null +++ b/icons/seals.svg @@ -0,0 +1,9 @@ + + + + + + + + + \ No newline at end of file diff --git a/icons/sensor_stakes.svg b/icons/sensor_stakes.svg new file mode 100644 index 0000000..4f19fec --- /dev/null +++ b/icons/sensor_stakes.svg @@ -0,0 +1,9 @@ + + + + + + + + + \ No newline at end of file diff --git a/icons/shadow_cloth.svg b/icons/shadow_cloth.svg new file mode 100644 index 0000000..be9a582 --- /dev/null +++ b/icons/shadow_cloth.svg @@ -0,0 +1,9 @@ + + + + + + + + + \ No newline at end of file diff --git a/icons/shortbow.svg b/icons/shortbow.svg new file mode 100644 index 0000000..649cfae --- /dev/null +++ b/icons/shortbow.svg @@ -0,0 +1,9 @@ + + + + + + + + + \ No newline at end of file diff --git a/icons/shotgun.svg b/icons/shotgun.svg new file mode 100644 index 0000000..3dc1c36 --- /dev/null +++ b/icons/shotgun.svg @@ -0,0 +1,9 @@ + + + + + + + + + \ No newline at end of file diff --git a/icons/signal_mirror.svg b/icons/signal_mirror.svg new file mode 100644 index 0000000..9379a87 --- /dev/null +++ b/icons/signal_mirror.svg @@ -0,0 +1,9 @@ + + + + + + + + + \ No newline at end of file diff --git a/icons/site_lorry.svg b/icons/site_lorry.svg new file mode 100644 index 0000000..3241184 --- /dev/null +++ b/icons/site_lorry.svg @@ -0,0 +1,9 @@ + + + + + + + + + \ No newline at end of file diff --git a/icons/site_overalls.svg b/icons/site_overalls.svg new file mode 100644 index 0000000..627d9f9 --- /dev/null +++ b/icons/site_overalls.svg @@ -0,0 +1,9 @@ + + + + + + + + + \ No newline at end of file diff --git a/icons/skill_comm.svg b/icons/skill_comm.svg new file mode 100644 index 0000000..672eb90 --- /dev/null +++ b/icons/skill_comm.svg @@ -0,0 +1,9 @@ + + + + + + + + + \ No newline at end of file diff --git a/icons/skill_manip.svg b/icons/skill_manip.svg new file mode 100644 index 0000000..09b7e2f --- /dev/null +++ b/icons/skill_manip.svg @@ -0,0 +1,9 @@ + + + + + + + + + \ No newline at end of file diff --git a/icons/skill_melee.svg b/icons/skill_melee.svg new file mode 100644 index 0000000..de0b600 --- /dev/null +++ b/icons/skill_melee.svg @@ -0,0 +1,9 @@ + + + + + + + + + \ No newline at end of file diff --git a/icons/skill_mental.svg b/icons/skill_mental.svg new file mode 100644 index 0000000..ed2de79 --- /dev/null +++ b/icons/skill_mental.svg @@ -0,0 +1,9 @@ + + + + + + + + + \ No newline at end of file diff --git a/icons/skill_percep.svg b/icons/skill_percep.svg new file mode 100644 index 0000000..e896bbd --- /dev/null +++ b/icons/skill_percep.svg @@ -0,0 +1,9 @@ + + + + + + + + + \ No newline at end of file diff --git a/icons/skill_phys.svg b/icons/skill_phys.svg new file mode 100644 index 0000000..b083200 --- /dev/null +++ b/icons/skill_phys.svg @@ -0,0 +1,9 @@ + + + + + + + + + \ No newline at end of file diff --git a/icons/skill_ranged.svg b/icons/skill_ranged.svg new file mode 100644 index 0000000..0a95d10 --- /dev/null +++ b/icons/skill_ranged.svg @@ -0,0 +1,9 @@ + + + + + + + + + \ No newline at end of file diff --git a/icons/smg.svg b/icons/smg.svg new file mode 100644 index 0000000..798f435 --- /dev/null +++ b/icons/smg.svg @@ -0,0 +1,9 @@ + + + + + + + + + \ No newline at end of file diff --git a/icons/soft_armour.svg b/icons/soft_armour.svg new file mode 100644 index 0000000..577623a --- /dev/null +++ b/icons/soft_armour.svg @@ -0,0 +1,9 @@ + + + + + + + + + \ No newline at end of file diff --git a/icons/sonic_probe.svg b/icons/sonic_probe.svg new file mode 100644 index 0000000..dcce941 --- /dev/null +++ b/icons/sonic_probe.svg @@ -0,0 +1,9 @@ + + + + + + + + + \ No newline at end of file diff --git a/icons/spare_cells.svg b/icons/spare_cells.svg new file mode 100644 index 0000000..2635649 --- /dev/null +++ b/icons/spare_cells.svg @@ -0,0 +1,9 @@ + + + + + + + + + \ No newline at end of file diff --git a/icons/spear1h.svg b/icons/spear1h.svg new file mode 100644 index 0000000..3d86d23 --- /dev/null +++ b/icons/spear1h.svg @@ -0,0 +1,9 @@ + + + + + + + + + \ No newline at end of file diff --git a/icons/specimen_jars.svg b/icons/specimen_jars.svg new file mode 100644 index 0000000..87f7782 --- /dev/null +++ b/icons/specimen_jars.svg @@ -0,0 +1,9 @@ + + + + + + + + + \ No newline at end of file diff --git a/icons/spectacles.svg b/icons/spectacles.svg new file mode 100644 index 0000000..0676832 --- /dev/null +++ b/icons/spectacles.svg @@ -0,0 +1,9 @@ + + + + + + + + + \ No newline at end of file diff --git a/icons/spyglass.svg b/icons/spyglass.svg new file mode 100644 index 0000000..ffa1ef5 --- /dev/null +++ b/icons/spyglass.svg @@ -0,0 +1,9 @@ + + + + + + + + + \ No newline at end of file diff --git a/icons/stab_vest.svg b/icons/stab_vest.svg new file mode 100644 index 0000000..eb420aa --- /dev/null +++ b/icons/stab_vest.svg @@ -0,0 +1,9 @@ + + + + + + + + + \ No newline at end of file diff --git a/icons/stasis_box.svg b/icons/stasis_box.svg new file mode 100644 index 0000000..2b07811 --- /dev/null +++ b/icons/stasis_box.svg @@ -0,0 +1,9 @@ + + + + + + + + + \ No newline at end of file diff --git a/icons/stepping_disc.svg b/icons/stepping_disc.svg new file mode 100644 index 0000000..bb85ef0 --- /dev/null +++ b/icons/stepping_disc.svg @@ -0,0 +1,9 @@ + + + + + + + + + \ No newline at end of file diff --git a/icons/stepping_frame.svg b/icons/stepping_frame.svg new file mode 100644 index 0000000..9effc5c --- /dev/null +++ b/icons/stepping_frame.svg @@ -0,0 +1,10 @@ + + + + + + + + + \ No newline at end of file diff --git a/icons/stopwatch.svg b/icons/stopwatch.svg new file mode 100644 index 0000000..978f672 --- /dev/null +++ b/icons/stopwatch.svg @@ -0,0 +1,9 @@ + + + + + + + + + \ No newline at end of file diff --git a/icons/survey_drone.svg b/icons/survey_drone.svg new file mode 100644 index 0000000..8ea7d12 --- /dev/null +++ b/icons/survey_drone.svg @@ -0,0 +1,9 @@ + + + + + + + + + \ No newline at end of file diff --git a/icons/survey_kit.svg b/icons/survey_kit.svg new file mode 100644 index 0000000..2257736 --- /dev/null +++ b/icons/survey_kit.svg @@ -0,0 +1,9 @@ + + + + + + + + + \ No newline at end of file diff --git a/icons/swab_kit.svg b/icons/swab_kit.svg new file mode 100644 index 0000000..243c1b1 --- /dev/null +++ b/icons/swab_kit.svg @@ -0,0 +1,9 @@ + + + + + + + + + \ No newline at end of file diff --git a/icons/table.svg b/icons/table.svg new file mode 100644 index 0000000..9dfa49e --- /dev/null +++ b/icons/table.svg @@ -0,0 +1,9 @@ + + + + + + + + + \ No newline at end of file diff --git a/icons/talent_agency.svg b/icons/talent_agency.svg new file mode 100644 index 0000000..ac69936 --- /dev/null +++ b/icons/talent_agency.svg @@ -0,0 +1,9 @@ + + + + + + + + + \ No newline at end of file diff --git a/icons/talent_field.svg b/icons/talent_field.svg new file mode 100644 index 0000000..b271a4d --- /dev/null +++ b/icons/talent_field.svg @@ -0,0 +1,9 @@ + + + + + + + + + \ No newline at end of file diff --git a/icons/talent_generic.svg b/icons/talent_generic.svg new file mode 100644 index 0000000..38c6474 --- /dev/null +++ b/icons/talent_generic.svg @@ -0,0 +1,9 @@ + + + + + + + + + \ No newline at end of file diff --git a/icons/talent_species.svg b/icons/talent_species.svg new file mode 100644 index 0000000..afba282 --- /dev/null +++ b/icons/talent_species.svg @@ -0,0 +1,9 @@ + + + + + + + + + \ No newline at end of file diff --git a/icons/tallow_candle.svg b/icons/tallow_candle.svg new file mode 100644 index 0000000..aeb9130 --- /dev/null +++ b/icons/tallow_candle.svg @@ -0,0 +1,9 @@ + + + + + + + + + \ No newline at end of file diff --git a/icons/thermometer.svg b/icons/thermometer.svg new file mode 100644 index 0000000..49b882d --- /dev/null +++ b/icons/thermometer.svg @@ -0,0 +1,9 @@ + + + + + + + + + \ No newline at end of file diff --git a/icons/threshold_key.svg b/icons/threshold_key.svg new file mode 100644 index 0000000..6a09d45 --- /dev/null +++ b/icons/threshold_key.svg @@ -0,0 +1,10 @@ + + + + + + + + + \ No newline at end of file diff --git a/icons/tinderbox.svg b/icons/tinderbox.svg new file mode 100644 index 0000000..a3d021a --- /dev/null +++ b/icons/tinderbox.svg @@ -0,0 +1,9 @@ + + + + + + + + + \ No newline at end of file diff --git a/icons/toolkit.svg b/icons/toolkit.svg new file mode 100644 index 0000000..d6bce52 --- /dev/null +++ b/icons/toolkit.svg @@ -0,0 +1,9 @@ + + + + + + + + + \ No newline at end of file diff --git a/icons/torch.svg b/icons/torch.svg new file mode 100644 index 0000000..7cabee8 --- /dev/null +++ b/icons/torch.svg @@ -0,0 +1,9 @@ + + + + + + + + + \ No newline at end of file diff --git a/icons/tow_line.svg b/icons/tow_line.svg new file mode 100644 index 0000000..e9a2a8d --- /dev/null +++ b/icons/tow_line.svg @@ -0,0 +1,9 @@ + + + + + + + + + \ No newline at end of file diff --git a/icons/translator_pin.svg b/icons/translator_pin.svg new file mode 100644 index 0000000..822ff02 --- /dev/null +++ b/icons/translator_pin.svg @@ -0,0 +1,9 @@ + + + + + + + + + \ No newline at end of file diff --git a/icons/trap.svg b/icons/trap.svg new file mode 100644 index 0000000..e243072 --- /dev/null +++ b/icons/trap.svg @@ -0,0 +1,9 @@ + + + + + + + + + \ No newline at end of file diff --git a/icons/trauma_unit.svg b/icons/trauma_unit.svg new file mode 100644 index 0000000..028e588 --- /dev/null +++ b/icons/trauma_unit.svg @@ -0,0 +1,9 @@ + + + + + + + + + \ No newline at end of file diff --git a/icons/unmaker.svg b/icons/unmaker.svg new file mode 100644 index 0000000..6e632ac --- /dev/null +++ b/icons/unmaker.svg @@ -0,0 +1,10 @@ + + + + + + + + + \ No newline at end of file diff --git a/icons/uv_lamp.svg b/icons/uv_lamp.svg new file mode 100644 index 0000000..acd0ea9 --- /dev/null +++ b/icons/uv_lamp.svg @@ -0,0 +1,9 @@ + + + + + + + + + \ No newline at end of file diff --git a/icons/vacuum_flask.svg b/icons/vacuum_flask.svg new file mode 100644 index 0000000..e291b5c --- /dev/null +++ b/icons/vacuum_flask.svg @@ -0,0 +1,9 @@ + + + + + + + + + \ No newline at end of file diff --git a/icons/vehicle_log.svg b/icons/vehicle_log.svg new file mode 100644 index 0000000..fb09c09 --- /dev/null +++ b/icons/vehicle_log.svg @@ -0,0 +1,9 @@ + + + + + + + + + \ No newline at end of file diff --git a/icons/vehicle_spares.svg b/icons/vehicle_spares.svg new file mode 100644 index 0000000..6733d97 --- /dev/null +++ b/icons/vehicle_spares.svg @@ -0,0 +1,9 @@ + + + + + + + + + \ No newline at end of file diff --git a/icons/wake_tablets.svg b/icons/wake_tablets.svg new file mode 100644 index 0000000..ad7921c --- /dev/null +++ b/icons/wake_tablets.svg @@ -0,0 +1,9 @@ + + + + + + + + + \ No newline at end of file diff --git a/icons/ward.svg b/icons/ward.svg new file mode 100644 index 0000000..baaeafd --- /dev/null +++ b/icons/ward.svg @@ -0,0 +1,10 @@ + + + + + + + + + \ No newline at end of file diff --git a/icons/warrant_card.svg b/icons/warrant_card.svg new file mode 100644 index 0000000..365fde1 --- /dev/null +++ b/icons/warrant_card.svg @@ -0,0 +1,9 @@ + + + + + + + + + \ No newline at end of file diff --git a/icons/water.svg b/icons/water.svg new file mode 100644 index 0000000..26f47c2 --- /dev/null +++ b/icons/water.svg @@ -0,0 +1,9 @@ + + + + + + + + + \ No newline at end of file diff --git a/icons/wax_tablet.svg b/icons/wax_tablet.svg new file mode 100644 index 0000000..12596c0 --- /dev/null +++ b/icons/wax_tablet.svg @@ -0,0 +1,9 @@ + + + + + + + + + \ No newline at end of file diff --git a/icons/wheel_clamps.svg b/icons/wheel_clamps.svg new file mode 100644 index 0000000..b0de86e --- /dev/null +++ b/icons/wheel_clamps.svg @@ -0,0 +1,9 @@ + + + + + + + + + \ No newline at end of file diff --git a/icons/whistle.svg b/icons/whistle.svg new file mode 100644 index 0000000..dbf3fdb --- /dev/null +++ b/icons/whistle.svg @@ -0,0 +1,9 @@ + + + + + + + + + \ No newline at end of file diff --git a/icons/wound_foam.svg b/icons/wound_foam.svg new file mode 100644 index 0000000..aaf15e5 --- /dev/null +++ b/icons/wound_foam.svg @@ -0,0 +1,9 @@ + + + + + + + + + \ No newline at end of file diff --git a/icons/wrong_carriage.svg b/icons/wrong_carriage.svg new file mode 100644 index 0000000..5a134a1 --- /dev/null +++ b/icons/wrong_carriage.svg @@ -0,0 +1,10 @@ + + + + + + + + + \ No newline at end of file diff --git a/icons/wrong_compass.svg b/icons/wrong_compass.svg new file mode 100644 index 0000000..d2b1e12 --- /dev/null +++ b/icons/wrong_compass.svg @@ -0,0 +1,10 @@ + + + + + + + + + \ No newline at end of file diff --git a/lang/en.json b/lang/en.json new file mode 100644 index 0000000..3e7a299 --- /dev/null +++ b/lang/en.json @@ -0,0 +1,740 @@ +{ + "TYPES.Actor.character": "Explorer", + "TYPES.Actor.npc": "Statblock", + "TYPES.Actor.expedition": "Expedition", + "TYPES.Item.skill": "Skill", + "TYPES.Item.weapon": "Weapon", + "TYPES.Item.armour": "Armour", + "TYPES.Item.gear": "Gear", + "TYPES.Item.device": "Device", + "TYPES.Item.discovery": "Discovery", + "TYPES.Item.project": "Research Project", + "TYPES.Item.material": "Material", + "TYPES.Item.group": "Group", + "TYPES.Item.vehicle": "Vehicle", + "TYPES.Item.authority": "Authority", + "RINGBRP.Tab.Access": "Access", + "RINGBRP.New.authority": "New authority", + "RINGBRP.Scope.nothing": "nothing", + "RINGBRP.Scope.read": "read their own records", + "RINGBRP.Scope.routine": "the things they do daily", + "RINGBRP.Scope.privileged": "things they could do, but rarely", + "RINGBRP.Scope.administrative": "grant permissions to others", + "RINGBRP.Authority.Holder": "Belongs to", + "RINGBRP.Authority.Role": "Their job", + "RINGBRP.Authority.Scope": "Scope", + "RINGBRP.Authority.ScopeNote": "What that covers", + "RINGBRP.Authority.Trust": "Trust", + "RINGBRP.Authority.Expires": "Expires", + "RINGBRP.Authority.RedFlags": "Red flags", + "RINGBRP.Authority.MergeHead": "Records", + "RINGBRP.Authority.BurnedTag": "burned", + "RINGBRP.Authority.SuspectTag": "flagged", + "RINGBRP.Authority.MergingTag": "records converging", + "RINGBRP.Authority.Using": "Using {name}", + "RINGBRP.Authority.Asking": "What are you asking it to do?", + "RINGBRP.Authority.Tripping": "Which of their habits are you breaking?", + "RINGBRP.Authority.NoFlags": "Nothing is known about how they behave. Nobody is watching for it yet.", + "RINGBRP.Authority.NoRoll": "no roll, 1 Trust", + "RINGBRP.Authority.ARoll": "a stretch — roll for it", + "RINGBRP.Authority.Cannot": "they could never", + "RINGBRP.Authority.Dossier": "Dossier", + "RINGBRP.Authority.AI": "Watched by", + "RINGBRP.Authority.Use": "Use", + "RINGBRP.Authority.Widen": "Widen the record", + "RINGBRP.Authority.Rehab": "Rehabilitate", + "RINGBRP.Authority.Gathering": "Dossier: {text}", + "RINGBRP.Authority.ItemAdded": "Added to the dossier: {text}", + "RINGBRP.Authority.ItemFailed": "Nothing usable came of it: {text}", + "RINGBRP.Authority.DossierNow": "Dossier {n}/{of} — Trust {trust}, scope: {scope}", + "RINGBRP.Authority.DossierFull": "A dossier holds {n} items. Use what you have.", + "RINGBRP.Authority.TooMuchComputer": "Only one dossier item may be gathered at a keyboard.", + "RINGBRP.Authority.SameSkill": "Another item already used that skill. Somebody else, some other way.", + "RINGBRP.Authority.InScope": "{name} does this every week.", + "RINGBRP.Authority.Stretching": "{name}, asked for: {tier}", + "RINGBRP.Authority.Impossible": "{name} could never do this. Their record covers {scope}, and this is {tier}.", + "RINGBRP.Authority.Worked": "It goes through under {name}'s name.", + "RINGBRP.Authority.Refused": "Refused. {name} is not supposed to be asking.", + "RINGBRP.Authority.TrustLeft": "Trust {n}.", + "RINGBRP.Authority.NowSuspect": "the account is flagged for review.", + "RINGBRP.Authority.BurnedNow": "The credential is burned, and somebody is going to be asked about it.", + "RINGBRP.Authority.Burned": "That credential is burned.", + "RINGBRP.Authority.Suspect": "That credential is flagged. It needs rehabilitating before it will pass again.", + "RINGBRP.Authority.BurnedForGood": "Burned is burned. Find another way in.", + "RINGBRP.Authority.Flags": "Behaving unlike them: {n} flag(s), {mod}%", + "RINGBRP.Authority.Correlated": "Queries do not match the account's usual pattern", + "RINGBRP.Authority.Widened": "Reality now remembers that {name} was cleared for {scope}. −{cost} Coherence.", + "RINGBRP.Authority.WidenNote": "Widening a borrowed record", + "RINGBRP.Authority.WidestAlready": "There is nothing wider than granting permissions to others.", + "RINGBRP.Authority.NotEnoughCoherence": "That would take {n} Coherence and you do not have it.", + "RINGBRP.Authority.Rehabilitating": "Rehabilitating {name}", + "RINGBRP.Authority.Merge.0": "The files are still separate.", + "RINGBRP.Authority.Merge.1": "A circular arrives at your desk addressed to them.", + "RINGBRP.Authority.Merge.2": "Your pension record shows service you did not do, at a firm you have never worked for.", + "RINGBRP.Authority.Merge.3": "Colleagues refer to a meeting you did not attend, and are certain you chaired it.", + "RINGBRP.Authority.Merge.4": "Registry holds one file where there were two. Yours is the copy.", + "RINGBRP.Tab.Transport": "Transport", + "RINGBRP.Vehicle.Crew": "Crew", + "RINGBRP.Vehicle.Seats": "Seats", + "RINGBRP.Vehicle.Cargo": "Cargo", + "RINGBRP.Vehicle.Speed": "Speed", + "RINGBRP.Vehicle.Range": "Range", + "RINGBRP.Vehicle.Hull": "Hull", + "RINGBRP.Vehicle.Terrain": "Goes over", + "RINGBRP.Vehicle.Operate": "Operated with", + "RINGBRP.Speed.walk": "walking pace", + "RINGBRP.Speed.slow": "slow", + "RINGBRP.Speed.road": "road speed", + "RINGBRP.Speed.cross": "cross-country", + "RINGBRP.Speed.fast": "fast", + "RINGBRP.Speed.air": "airborne", + "RINGBRP.Speed.instant": "no travel time", + "RINGBRP.New.vehicle": "New vehicle", + "RINGBRP.Char.str": "STR", + "RINGBRP.Char.siz": "MAS", + "RINGBRP.Char.con": "CON", + "RINGBRP.Char.int": "INT", + "RINGBRP.Char.pow": "POW", + "RINGBRP.Char.dex": "DEX", + "RINGBRP.Char.cha": "APP", + "RINGBRP.Char.edu": "EDU", + "RINGBRP.Category.Melee": "Melee Combat", + "RINGBRP.Category.Ranged": "Ranged Combat", + "RINGBRP.Category.Communication": "Communication", + "RINGBRP.Category.Manipulation": "Manipulation", + "RINGBRP.Category.Mental": "Mental", + "RINGBRP.Category.Perception": "Perception", + "RINGBRP.Category.Physical": "Physical", + "RINGBRP.Gravity.Light": "Light gravity", + "RINGBRP.Gravity.Normal": "Normal gravity", + "RINGBRP.Gravity.Heavy": "Heavy gravity", + "RINGBRP.Style.Reflex": "Reflex", + "RINGBRP.Style.Deliberate": "Deliberate", + "RINGBRP.Style.Hint.Title": "How this style shoots. Both use the same skill — they differ in when they are good.", + "RINGBRP.Style.Hint.Short": "+{n}% up close", + "RINGBRP.Style.Hint.Long": "+{n}% at long range", + "RINGBRP.Style.Hint.Aim": "aiming a full round gives +{n}%", + "RINGBRP.Style.Hint.Second": "a second shot in the round", + "RINGBRP.Style.Hint.Move": "{n}% if you move and fire", + "RINGBRP.Style.Hint.Pierce": "ignores {n} points of armour", + "RINGBRP.Difficulty.Automatic": "Automatic", + "RINGBRP.Difficulty.Easy": "Easy (×2)", + "RINGBRP.Difficulty.Average": "Average", + "RINGBRP.Difficulty.Difficult": "Difficult (÷2)", + "RINGBRP.Difficulty.Impossible": "Impossible", + "RINGBRP.Cover.None": "No cover", + "RINGBRP.Cover.Light": "Light cover", + "RINGBRP.Cover.Partial": "Solid partial cover", + "RINGBRP.Cover.Full": "Firing from full cover", + "RINGBRP.Range.short": "Short", + "RINGBRP.Range.medium": "Medium", + "RINGBRP.Range.long": "Long", + "RINGBRP.Range.extreme": "Extreme", + "RINGBRP.Range.beyond": "Beyond range", + "RINGBRP.Result.Critical": "Critical", + "RINGBRP.Result.Special": "Special", + "RINGBRP.Result.Success": "Success", + "RINGBRP.Result.Failure": "Failure", + "RINGBRP.Result.Fumble": "Fumble", + "RINGBRP.Level.critical": "critical", + "RINGBRP.Level.special": "special", + "RINGBRP.Level.success": "success", + "RINGBRP.Level.failure": "failure", + "RINGBRP.Level.fumble": "fumble", + "RINGBRP.Defence.Answered": "{name} defends {defence} against a {attack} blow.", + "RINGBRP.Defence.TurnedAside": "Turned aside entirely.", + "RINGBRP.Defence.GetsThrough": "It still gets through, as a {level}.", + "RINGBRP.Roll.stamina": "Stamina", + "RINGBRP.Roll.idea": "Idea", + "RINGBRP.Roll.luck": "Luck", + "RINGBRP.Sheet.Name": "Name", + "RINGBRP.Sheet.World": "Home world", + "RINGBRP.Sheet.Gravity": "Gravity", + "RINGBRP.Sheet.Style": "Marksmanship", + "RINGBRP.Sheet.StyleLocked": "Determined by home-world gravity. It cannot be changed.", + "RINGBRP.Sheet.Chronological": "Chronological age", + "RINGBRP.Sheet.Physiological": "Physiological age", + "RINGBRP.Sheet.HP": "Hit points", + "RINGBRP.Sheet.MajorWound": "Major wound", + "RINGBRP.Sheet.MajorWoundHint": "A single blow dealing this much, after armour, causes a Major Wound.", + "RINGBRP.Sheet.Armour": "Armour", + "RINGBRP.Sheet.DamageMod": "Damage mod", + "RINGBRP.Sheet.MOV": "MOV", + "RINGBRP.Sheet.DexRank": "DEX rank", + "RINGBRP.Sheet.Carry": "Carried", + "RINGBRP.Sheet.CategoryBonusHint": "Category bonus. Already included in the ratings below.", + "RINGBRP.Sheet.BandsHint": "critical / special / success / fumble", + "RINGBRP.Sheet.AddSkill": "Add a skill", + "RINGBRP.Sheet.ExperienceTick": "Experience tick", + "RINGBRP.Sheet.Edit": "Edit", + "RINGBRP.Sheet.Delete": "Delete", + "RINGBRP.Sheet.Weapon": "Weapon", + "RINGBRP.Sheet.Damage": "Damage", + "RINGBRP.Sheet.Range": "Range", + "RINGBRP.Sheet.Ammo": "Ammo", + "RINGBRP.Sheet.Points": "pts", + "RINGBRP.Sheet.Integrity": "Integrity", + "RINGBRP.Sheet.ClearWound": "Clear this wound", + "RINGBRP.Sheet.NoWounds": "No active wounds.", + "RINGBRP.Sheet.Biography": "Biography", + "RINGBRP.Sheet.Notes": "Notes", + "RINGBRP.Sheet.Role": "Role", + "RINGBRP.Sheet.Tactics": "Tactics and morale", + "RINGBRP.Tab.Skills": "Skills", + "RINGBRP.Tab.Combat": "Combat", + "RINGBRP.Tab.Gear": "Gear", + "RINGBRP.Tab.Wounds": "Wounds", + "RINGBRP.Tab.Bio": "Notes", + "RINGBRP.Tab.Weapons": "Weapons", + "RINGBRP.Tab.Armour": "Armour", + "RINGBRP.Tab.Detail": "Detail", + "RINGBRP.Tab.Description": "Description", + "RINGBRP.Tab.Tactics": "Tactics", + "RINGBRP.Item.Family": "Skill family", + "RINGBRP.Item.Specialisation": "Specialisation", + "RINGBRP.Item.Category": "Category", + "RINGBRP.Item.Rating": "Rating %", + "RINGBRP.Item.PrintedBase": "Printed base %", + "RINGBRP.Item.TrainingCap": "Training cap %", + "RINGBRP.Item.TransferGroup": "Transfer group", + "RINGBRP.Item.CanBeTaught": "Can be taught", + "RINGBRP.Item.ExperienceEligible": "Experience eligible", + "RINGBRP.Item.BandsPreview": "Bands", + "RINGBRP.Item.Class": "Class", + "RINGBRP.Item.SkillFamily": "Skill family", + "RINGBRP.Item.Coherence": "Coherence cost", + "RINGBRP.Item.SkillSpec": "Skill specialisation", + "RINGBRP.Item.DamageMode": "Damage mode", + "RINGBRP.Item.Damage": "Damage", + "RINGBRP.Item.DamageShort": "Damage (short)", + "RINGBRP.Item.DamageMedium": "Damage (medium)", + "RINGBRP.Item.DamageLong": "Damage (long)", + "RINGBRP.Item.WoundType": "Wound type", + "RINGBRP.Item.Monofilament": "Monofilament", + "RINGBRP.Item.AppliesDamageMod": "Applies damage modifier", + "RINGBRP.Item.FireMode": "Fire mode", + "RINGBRP.Item.RangeShort": "Short (m)", + "RINGBRP.Item.RangeMedium": "Medium (m)", + "RINGBRP.Item.RangeLong": "Long (m)", + "RINGBRP.Item.Capacity": "Capacity", + "RINGBRP.Item.Loaded": "Loaded", + "RINGBRP.Item.Reserve": "Reserve", + "RINGBRP.Item.Mass": "Mass (kg)", + "RINGBRP.Item.Quantity": "Quantity", + "RINGBRP.Item.PowerDraw": "Clearance draw", + "RINGBRP.Item.Facility": "Research facility", + "RINGBRP.Item.Points": "Armour points", + "RINGBRP.Item.Equipped": "Equipped", + "RINGBRP.Item.DocumentId": "Device ID", + "RINGBRP.Item.Material": "Material", + "RINGBRP.Item.IntegrityMax": "Integrity max", + "RINGBRP.Item.Functions": "Functions", + "RINGBRP.Item.Difficulty": "Difficulty", + "RINGBRP.Item.DifficultyAfter": "Difficulty after Discovery", + "RINGBRP.Item.PowerCost": "Clearance cost", + "RINGBRP.Item.PowerCostFail": "Clearance cost on failure", + "RINGBRP.Item.RequiresIntegrity": "Requires integrity", + "RINGBRP.Item.HoursPerAttempt": "Hours per attempt", + "RINGBRP.Item.Effect": "Effect", + "RINGBRP.Item.DiscoveryId": "Discovery ID", + "RINGBRP.Item.Publishable": "Publishable", + "RINGBRP.Item.Published": "Published", + "RINGBRP.Item.Question": "Question", + "RINGBRP.Item.ProjectId": "Project ID", + "RINGBRP.Item.Status": "Status", + "RINGBRP.Item.MaxResearchers": "Max researchers", + "RINGBRP.Item.CurrentStep": "Current step", + "RINGBRP.Item.Yields": "Yields discovery", + "RINGBRP.Item.Steps": "Steps", + "RINGBRP.Item.GroupId": "Group ID", + "RINGBRP.Item.Population": "Population", + "RINGBRP.Item.OfferForm": "Correct offer form", + "RINGBRP.Item.Taboos": "Taboos", + "RINGBRP.Item.Desires": "Desires", + "RINGBRP.Item.MaterialId": "Material ID", + "RINGBRP.Item.Neutrino": "Neutrino absorption", + "RINGBRP.Item.ImmuneTo": "Immune to", + "RINGBRP.Item.BlocksSensors": "Blocks sensors", + "RINGBRP.New.skill": "New Skill", + "RINGBRP.New.weapon": "New Weapon", + "RINGBRP.New.armour": "New Armour", + "RINGBRP.New.gear": "New Gear", + "RINGBRP.New.device": "New Device", + "RINGBRP.New.discovery": "New Discovery", + "RINGBRP.New.project": "New Project", + "RINGBRP.Dialog.Roll": "Roll", + "RINGBRP.Dialog.RollButton": "Roll", + "RINGBRP.Dialog.Cancel": "Cancel", + "RINGBRP.Dialog.Difficulty": "Difficulty", + "RINGBRP.Dialog.Situational": "Situational modifier %", + "RINGBRP.Dialog.OrderNote": "Difficulty multiplies the rating first, then the situational modifier is added.", + "RINGBRP.Dialog.Distance": "Distance to target (m)", + "RINGBRP.Dialog.Bands": "Bands", + "RINGBRP.Dialog.Cover": "Target cover", + "RINGBRP.Dialog.Moved": "I moved this round", + "RINGBRP.Dialog.Aiming": "I spent last round aiming", + "RINGBRP.Dialog.Band": "Which range band?", + "RINGBRP.Dialog.BandHint": "This weapon does different damage at each band.", + "RINGBRP.Attack.Moved": "Moved and fired", + "RINGBRP.Attack.Aimed": "Aimed", + "RINGBRP.Attack.IgnoresArmour": "Ignores {n} points of armour", + "RINGBRP.Chat.DamageFlavor": "{name} — damage", + "RINGBRP.Wound.Header": "Major Wound", + "RINGBRP.Wound.Shifted": "shifted by weapon type", + "RINGBRP.Wound.Loses": "Loses", + "RINGBRP.Wound.StaminaNote": "Make a Stamina roll or fall unconscious. Repeat at the end of each round, to a maximum of three checks. A successful First Aid ends the sequence.", + "RINGBRP.Wound.concussed.title": "Concussed", + "RINGBRP.Wound.concussed.text": "−20% to all rolls. Ends on a successful First Aid, or after 1D6 rounds.", + "RINGBRP.Wound.winded.title": "Winded", + "RINGBRP.Wound.winded.text": "Lose your next action, then −20% Physical and Melee until you spend a full round recovering.", + "RINGBRP.Wound.bleeding.title": "Bleeding", + "RINGBRP.Wound.bleeding.text": "Lose 1 hit point at the end of every round until a successful First Aid or Medicine roll.", + "RINGBRP.Wound.manipulation.title": "Manipulation impaired", + "RINGBRP.Wound.manipulation.text": "One hand is unusable. Two-handed actions are impossible. −30% Manipulation and one-handed weapon skills. First Aid plus one day.", + "RINGBRP.Wound.mobility.title": "Mobility impaired", + "RINGBRP.Wound.mobility.text": "MOV halved. −30% Dodge and Athletics. No running, charging or second attacks. Standing costs a full round. First Aid plus one day.", + "RINGBRP.Wound.deepBleeding.title": "Deep bleeding", + "RINGBRP.Wound.deepBleeding.text": "Lose 2 hit points at the end of every round until a Difficult First Aid or any successful Medicine roll.", + "RINGBRP.Wound.sensory.title": "Sensory", + "RINGBRP.Wound.sensory.text": "Roll 1D2 — 1 sight (−40% Spot, Ranged, Dodge), 2 hearing (−40% Listen, Insight). Medicine (Average, one hour) or an autodoc in ten minutes.", + "RINGBRP.Wound.broken.title": "Broken", + "RINGBRP.Wound.broken.text": "As Manipulation or Mobility impaired (1D2), but only Medicine (Difficult) or an autodoc restores it. Untreated after a week it becomes a characteristic loss.", + "RINGBRP.Wound.crippling.title": "Crippling", + "RINGBRP.Wound.crippling.text": "Out of the fight: defence only, MOV 1.", + "RINGBRP.Wound.catastrophic.title": "Catastrophic", + "RINGBRP.Wound.catastrophic.text": "Immediately unconscious. With a monofilament weapon this is an amputation; an autodoc regrows it in 1D6 weeks.", + "RINGBRP.Expedition.Destination": "Target", + "RINGBRP.Expedition.Knowledge": "Knowledge", + "RINGBRP.Expedition.Known": "Known (Average)", + "RINGBRP.Expedition.Rumoured": "Rumoured (Difficult)", + "RINGBRP.Expedition.Deduced": "Deduced (Difficult + Lore)", + "RINGBRP.Expedition.Supply": "Resources", + "RINGBRP.Expedition.Power": "Clearance", + "RINGBRP.Expedition.Integrity": "Integrity", + "RINGBRP.Expedition.Crisis": "A track has reached zero. Phases are Difficult and cost Containment, but the case goes on.", + "RINGBRP.Expedition.Journey": "Case", + "RINGBRP.Expedition.Research": "Research", + "RINGBRP.Expedition.Contacts": "Contacts", + "RINGBRP.Expedition.ResolveLeg": "Resolve phase", + "RINGBRP.Expedition.Terrain": "Terrain", + "RINGBRP.Expedition.Mod": "Mod", + "RINGBRP.Expedition.Days": "Days", + "RINGBRP.Expedition.Explored": "Explored", + "RINGBRP.Expedition.Projects": "Projects", + "RINGBRP.Expedition.Discoveries": "Discoveries", + "RINGBRP.Expedition.Groups": "Known groups", + "RINGBRP.Expedition.Encounters": "Encounters", + "RINGBRP.Expedition.Step": "Step", + "RINGBRP.Expedition.Title": "Case phase", + "RINGBRP.Expedition.LegRoll": "Phase {n} — {terrain}", + "RINGBRP.Expedition.CriticalSite": "Phase complete, no cost — and a Site is found. Roll on the Site table.", + "RINGBRP.Expedition.CriticalExplored": "Phase complete, no cost. The ground is already known, so no Site is found.", + "RINGBRP.Expedition.Special": "Phase complete at no resource cost, or in half the time — the party chooses.", + "RINGBRP.Expedition.Success": "Phase complete at the baseline cost.", + "RINGBRP.Expedition.Failure": "Phase complete, but at an extra Resources and half again the time.", + "RINGBRP.Expedition.Fumble": "The phase is not completed. Baseline cost paid, Containment down two, Clearance down one, and a hazard is on you. Roll on the Hazard table.", + "RINGBRP.Notify.Impossible": "That is Impossible. No roll is permitted.", + "RINGBRP.Notify.NoSkill": "No skill on this sheet matches {name}.", + "RINGBRP.Notify.OutOfRange": "Beyond twice the long band. The shot is impossible.", + "RINGBRP.Notify.Empty": "Empty. Reload first.", + "RINGBRP.Notify.NoLeg": "No unresolved phase remains.", + "RINGBRP.Notify.BadDamage": "Damage was not a number — the hit was not applied. See the console.", + "RINGBRP.Notify.BlowStopped": "That blow was turned aside. There is no damage to roll.", + "RINGBRP.Notify.MissingKit": "{role}: the catalogue has no item for {keys}. The agent was created without it.", + "RINGBRP.Notify.NoNavigator": "No agent has Tradecraft or Navigate.", + "RINGBRP.Sheet.RollCharHint": "Roll d100 against this characteristic ×5", + "RINGBRP.Roll.Characteristic": "{name} ×5 (from {value})", + "RINGBRP.Sheet.Wounded": "Wounded", + "RINGBRP.Sheet.Readied": "Readied", + "RINGBRP.Sheet.NothingReadied": "nothing equipped", + "RINGBRP.Item.Identity": "Identity", + "RINGBRP.Item.Values": "Values", + "RINGBRP.Item.Physical": "Physical", + "RINGBRP.Item.Protection": "Protection", + "RINGBRP.Item.Carried": "Carried", + "RINGBRP.Item.EnergyPerShot": "Energy per shot", + "RINGBRP.Item.Tradition": "Tradition", + "RINGBRP.Item.Functional": "Functional", + "RINGBRP.Item.Inert": "Inert", + "RINGBRP.Item.Locked": "Locked", + "RINGBRP.Item.Unlocks": "Unlocks", + "RINGBRP.Item.NpcTemplate": "NPC template", + "RINGBRP.Item.Rewards": "Rewards by standing", + "RINGBRP.Item.Taboo": "Taboo", + "RINGBRP.Item.Desire": "Desire", + "RINGBRP.Item.FloorNote": "A skill whose printed base is 5% or more never falls below 5%, however it is modified.", + "RINGBRP.Item.MonoNote": "Monofilament: any Major Wound result of 4–10 becomes 10. Cannot cut transposed alloy, a sealed hull or a stasis field.", + "RINGBRP.Item.RangeNote": "Short +0% · Medium −10% · Long −30% · Extreme (to twice long) −50% · beyond that, impossible.", + "RINGBRP.Item.ArmourNote": "Armour subtracts from damage. A critical attack ignores it entirely; a special halves it.", + "RINGBRP.Expedition.Of": "of", + "RINGBRP.Expedition.Leg": "Phase", + "RINGBRP.Expedition.Cost": "Cost", + "RINGBRP.Expedition.Navigator": "Phase lead", + "RINGBRP.Expedition.Unexplored": "unexplored", + "RINGBRP.Expedition.Itinerary": "Phases", + "RINGBRP.Expedition.Event": "Event", + "RINGBRP.Expedition.Elapsed": "Days elapsed", + "RINGBRP.Expedition.Progress": "Legs", + "RINGBRP.Expedition.JourneyComplete": "Every phase is resolved. The team is at the target.", + "RINGBRP.Expedition.CrisisBlocks": "A track is at zero. No phase may be advanced until it is restored.", + "RINGBRP.Expedition.TableNote": "A critical finds a Site, but only on an unexplored phase. A fumble costs a point of Integrity and rolls a hazard.", + "RINGBRP.Expedition.NoProjects": "No research under way.", + "RINGBRP.Expedition.NoDiscoveries": "Nothing discovered yet.", + "RINGBRP.Expedition.NoContacts": "No contact made.", + "RINGBRP.Expedition.NoGroups": "No groups recorded.", + "RINGBRP.Expedition.Standings": "Standings", + "RINGBRP.Expedition.InPlay": "in play", + "RINGBRP.Item.MassShort": "Mass", + "RINGBRP.Item.Attack": "Attack", + "RINGBRP.Item.Worn": "worn", + "RINGBRP.Item.IgnoredBy": "Ignored by", + "RINGBRP.Expedition.RollTarget": "Roll under", + "RINGBRP.Notify.NoOwner": "This item is not owned by anyone, so there is no skill to roll.", + "RINGBRP.Loc.LegR": "Right leg", + "RINGBRP.Loc.LegL": "Left leg", + "RINGBRP.Loc.Abdomen": "Abdomen", + "RINGBRP.Loc.Chest": "Chest", + "RINGBRP.Loc.ArmR": "Right arm", + "RINGBRP.Loc.ArmL": "Left arm", + "RINGBRP.Loc.Head": "Head", + "RINGBRP.LocEffect.leg": "The leg gives way. MOV halved, −30% Dodge and Athletics, no running or charging. Standing costs an action.", + "RINGBRP.LocEffect.legX": "The leg is destroyed. MOV 1, prone unless helped, and it will not be walked on again without an autodoc and a long stay.", + "RINGBRP.LocEffect.arm": "The arm is useless. Two-handed actions are impossible; −30% Manipulation and any one-handed weapon in that hand is dropped.", + "RINGBRP.LocEffect.armX": "The arm is severed. Everything above, permanently, until an autodoc regrows it.", + "RINGBRP.LocEffect.body": "Internal injury. −30% to all Physical actions and bleeding 1 hit point per round until First Aid.", + "RINGBRP.LocEffect.bodyX": "The body cavity is opened. Bleeding 2 per round; only Medicine or an autodoc will stop it.", + "RINGBRP.LocEffect.vital": "A vital hit. Knocked down, and a Stamina roll or unconscious.", + "RINGBRP.LocEffect.vitalX": "The vital cavity is destroyed. This is a killing blow.", + "RINGBRP.LocEffect.head": "Unconscious immediately, for 1D6 × 10 minutes or until First Aid.", + "RINGBRP.LocEffect.headX": "The head is destroyed. For anything that keeps its brain there, this is death.", + "RINGBRP.Sheet.Species": "Anatomy", + "RINGBRP.Sheet.Locations": "Hit locations", + "RINGBRP.Loc.Melee": "Melee", + "RINGBRP.Loc.Ranged": "Ranged", + "RINGBRP.Loc.Taken": "Taken", + "RINGBRP.Loc.Max": "Max", + "RINGBRP.Loc.Disabled": "disabled", + "RINGBRP.Loc.Destroyed": "destroyed", + "RINGBRP.Loc.Header": "Hit location", + "RINGBRP.Loc.RollHint": "Roll 1D20 for a hit location on this anatomy", + "RINGBRP.Tab.Anatomy": "Anatomy", + "RINGBRP.Sheet.LocationNote": "A blow meeting a location's maximum disables it; twice that destroys it. Damage also comes off general hit points, so the two readings always agree.", + "RINGBRP.Item.TotalMass": "Total mass", + "RINGBRP.Item.Yes": "yes", + "RINGBRP.Item.Unpublished": "Unpublished", + "RINGBRP.Item.SlowTo": "Slow to cut", + "RINGBRP.Item.PrincipalTaboo": "Principal taboo", + "RINGBRP.Item.PrincipalDesire": "Principal desire", + "RINGBRP.Item.BonusSkill": "Bonus to skill", + "RINGBRP.Item.BonusValue": "Bonus %", + "RINGBRP.Sheet.DerivedRank": "Derived rank", + "RINGBRP.Item.GMOnly": "GM ONLY ", + "RINGBRP.Expedition.NothingLearned": "Nothing learned about this group yet.", + "RINGBRP.Skill.Tactics": "Tactics", + "RINGBRP.Notify.Automatic": "Automatic — no roll needed; it simply succeeds.", + "RINGBRP.Circ.Overwhelming": "Overwhelmingly favourable (+30%)", + "RINGBRP.Circ.Favourable": "Favourable (+20%)", + "RINGBRP.Circ.SlightGood": "Slightly favourable (+10%)", + "RINGBRP.Circ.Neutral": "Neutral", + "RINGBRP.Circ.SlightBad": "Slightly unfavourable (−10%)", + "RINGBRP.Circ.Unfavourable": "Unfavourable (−20%)", + "RINGBRP.Circ.Severe": "Severely unfavourable (−30%)", + "RINGBRP.Sheet.Circumstance": "Circumstance", + "RINGBRP.Dialog.DifficultyNote": "How hard the task is in itself. Multiplies the rating.", + "RINGBRP.Dialog.CircumstanceNote": "How the moment favours you. Flat, and never worth more than ±40% in total.", + "RINGBRP.Flavour.Encumbered": "Overloaded: {mod}%", + "RINGBRP.Coh.Title": "Coherence", + "RINGBRP.Coh.Label": "Coherence", + "RINGBRP.Coh.Anchored": "Anchored", + "RINGBRP.Coh.Loose": "Loose", + "RINGBRP.Coh.Adrift": "Adrift", + "RINGBRP.Coh.Unmoored": "Unmoored", + "RINGBRP.Coh.Displaced": "Displaced", + "RINGBRP.Coh.AnchoredNote": "You belong here. Nobody looks twice.", + "RINGBRP.Coh.LooseNote": "People find you subtly wrong and cannot say why. −5% to Communication.", + "RINGBRP.Coh.AdriftNote": "Rooms are the wrong shape when you are not looking. −5% to everything, −10% to Communication.", + "RINGBRP.Coh.UnmooredNote": "−10% to everything, −20% to Communication. Aiming your own way home is at −30%; another agent can aim it for you without penalty.", + "RINGBRP.Coh.DisplacedNote": "You are no longer from here. Whatever you go back to, it will not be what you left.", + "RINGBRP.Coh.Cost.Fumble": "Fumbled while across", + "RINGBRP.Coh.Cost.Paradox": "Witnessed a paradox", + "RINGBRP.Coh.Cost.Tech": "Used transposed materiel", + "RINGBRP.Coh.Cost.Local": "Broke a local law of reality", + "RINGBRP.Coh.Cost.Self": "Met yourself", + "RINGBRP.Coh.Cost.Duplicate": "Killed a duplicate", + "RINGBRP.Coh.Cost.Overstay": "Overstayed the window", + "RINGBRP.Coh.Drift": "Drift log", + "RINGBRP.Flavour.Incoherent": "{band}: {mod}%", + "RINGBRP.Species.Baseline": "Baseline", + "RINGBRP.Sheet.Rank": "Rank", + "RINGBRP.Sheet.RankPlaceholder": "Rank", + "RINGBRP.Coh.DisplacedChat": "{name} has come apart from this reality and gone home to one that is almost right.", + "RINGBRP.Coh.DisplacedGM": "Change exactly one true thing about their life. Do not tell them which. Coherence resets to full — for that Earth.", + "RINGBRP.Notify.CannotRecoverFromZero": "Coherence cannot be recovered from zero. The character displaces instead.", + "RINGBRP.Species.Vesh": "Vesh", + "RINGBRP.Species.Cadence": "Cadence", + "RINGBRP.Loc.BraceFoot": "Brace foot", + "RINGBRP.Loc.BraceLimb": "Brace limb", + "RINGBRP.Loc.LowerTrunk": "Lower trunk", + "RINGBRP.Loc.UpperTrunk": "Upper trunk", + "RINGBRP.Loc.GraspLimb": "Grasp limb", + "RINGBRP.Loc.Ridge": "Sensory ridge", + "RINGBRP.Loc.BodyA": "First body", + "RINGBRP.Loc.BodyB": "Second body", + "RINGBRP.Loc.BodyC": "Third body", + "RINGBRP.Loc.BodyD": "Fourth body", + "RINGBRP.Loc.BodyE": "Fifth body", + "RINGBRP.LocEffect.brace": "The brace limb folds. The Vesh must lean on something or lie down; MOV halved and no action that needs both limbs.", + "RINGBRP.LocEffect.braceX": "The brace limb is destroyed. A Vesh cannot stand without it and will not try. It can still grasp, and will.", + "RINGBRP.LocEffect.grasp": "The grasp limb is useless. A Vesh carries everything in that one limb, so it is now carrying nothing.", + "RINGBRP.LocEffect.graspX": "The grasp limb is severed. This is the injury a Vesh fears — not death, but never holding anything again.", + "RINGBRP.LocEffect.ridge": "The sensory ridge is opened. Its perception of when it is becomes unreliable: −30% to all Perception and it answers questions that have not been asked yet.", + "RINGBRP.LocEffect.ridgeX": "The ridge is destroyed. A Vesh without a ridge is not blind — it is unmoored in time, and it dies within the hour, apparently unbothered.", + "RINGBRP.LocEffect.unit": "That body drops. The Cadence continues; it is simply less. Each disabled body costs the whole person −10% to everything.", + "RINGBRP.LocEffect.unitX": "That body is killed. The Cadence does not react — it has already redistributed. When only one body remains it becomes a frightened animal and flees.", + "RINGBRP.Case.NowAcross": "The team is across. Coherence costs now apply.", + "RINGBRP.Case.NowHome": "The team is home. Coherence can be recovered.", + "RINGBRP.Notify.DebriefAcross": "Debrief only happens at home. Coherence cannot be recovered while across.", + "RINGBRP.Case.Across": "Across", + "RINGBRP.Case.Home": "Home", + "RINGBRP.Case.Debrief": "Debrief", + "RINGBRP.Case.Close": "Close the case", + "RINGBRP.Case.CloseHint": "Every ticked skill and Art rolls to improve, and talents reset.", + "RINGBRP.Case.Closed": "Case closed. Experience rolled.", + "RINGBRP.Case.StillAcross": "The team is still across. Bring them home first.", + "RINGBRP.Coh.ReturnClean": "You can aim the way home yourself.", + "RINGBRP.Coh.ReturnAlone": "Unmoored: aiming your own return is at −30%.", + "RINGBRP.Coh.ReturnAided": "Another agent aims the return for you. No penalty.", + "RINGBRP.Coh.ReturnNear": "A failed return never strands you. You arrive near your target rather than at it — the GM picks the complication.", + "RINGBRP.Case.Resources": "Resources", + "RINGBRP.Case.Clearance": "Clearance", + "RINGBRP.Case.Containment": "Containment", + "RINGBRP.Case.DebriefDone": "Debrief complete. Coherence recovered where it could be.", + "RINGBRP.Expedition.ForcedEncounter": "This phase forces an encounter", + "RINGBRP.Expedition.Destitute": "A track is exhausted. Phases are Difficult and cost Containment, but the case goes on.", + "RINGBRP.Device.Locked": "Locked — the discovery that explains this has not been made.", + "RINGBRP.Device.Damaged": "Too damaged. Needs Integrity {need}; it has {have}.", + "RINGBRP.Device.Unpowered": "Not enough Clearance. Needs {need}; the case has {have}.", + "RINGBRP.Device.Ready": "Ready.", + "RINGBRP.Device.NoSuchFunction": "No such function.", + "RINGBRP.Device.DiscoveryEarned": "Discovery made", + "RINGBRP.Contact.Read": "READ", + "RINGBRP.Contact.Signal": "SIGNAL", + "RINGBRP.Contact.Offer": "OFFER", + "RINGBRP.Contact.Roll": "{stage} — {group}", + "RINGBRP.Contact.Impression": "Impression", + "RINGBRP.Contact.Standing": "Standing", + "RINGBRP.Contact.Pidgin": "Pidgin established", + "RINGBRP.Contact.OfferSpent": "One OFFER per encounter. Impression cannot be farmed.", + "RINGBRP.Heal.Title": "Recovery", + "RINGBRP.Heal.Natural": "Natural healing", + "RINGBRP.Heal.FirstAid": "First Aid", + "RINGBRP.Heal.FieldPack": "Field medic pack", + "RINGBRP.Heal.TraumaUnit": "Trauma unit", + "RINGBRP.Heal.Fumbled": "First Aid fumbled — the patient loses a further hit point.", + "RINGBRP.Exp.Title": "End of case — improvement", + "RINGBRP.Exp.NoGain": "no gain", + "RINGBRP.Exp.NoTicks": "No skill earned a tick this case.", + "RINGBRP.Flavour.Gear": "{item}: +{mod}%", + "RINGBRP.Target.Title": "Target", + "RINGBRP.Target.Nearest": "nearest visible", + "RINGBRP.Target.LowestHP": "tied on range — lowest hit points", + "RINGBRP.Target.LowestDex": "tied on hit points — lowest DEX", + "RINGBRP.Target.GMChoice": "still tied — GM chooses", + "RINGBRP.Target.OnlyDowned": "nobody left standing", + "RINGBRP.Target.None": "no legal target", + "RINGBRP.Tab.Talents": "Talents", + "RINGBRP.Talent.Agency": "Agency training", + "RINGBRP.Talent.Field": "Field experience", + "RINGBRP.Talent.Species": "Species", + "RINGBRP.Talent.Anomalous": "Anomalous — the crossing left this in you", + "RINGBRP.Talent.CoherenceCost": "Coherence cost each use", + "RINGBRP.Talent.None": "No talents yet. Drag them from the Talents compendium.", + "RINGBRP.Contact.NewEncounter": "New encounter", + "RINGBRP.Contact.Who": "Who makes the approach?", + "RINGBRP.Contact.EncounterN": "Encounter {n}. One OFFER is available again.", + "RINGBRP.Contact.OfferSpentShort": "offer spent", + "RINGBRP.Device.Fn.operate": "Operate", + "RINGBRP.Device.Fn.diagnose": "Diagnose", + "RINGBRP.Device.Fn.repair": "Repair", + "RINGBRP.Device.Familiarity": "Unfamiliar design: {mod}%", + "RINGBRP.Device.Spent": "Clearance spent: {n}", + "RINGBRP.Talent.Spent": "{name} has already been used this case.", + "RINGBRP.Talent.UsesLeft": "{n} use(s) left this case", + "RINGBRP.Talent.AtHome": "Anomalous talents reach for the other side. Using one at home is not free.", + "RINGBRP.Talent.NoTrade": "Not enough on either track to make that trade.", + "RINGBRP.Talent.CleanSceneUsed": "Clean Scene: one point of Containment loss cancelled.", + "RINGBRP.Talent.Use": "Use", + "RINGBRP.Talent.Passive": "Always on", + "RINGBRP.Talent.Halved": "Chain of Custody: {name} costs {to} instead of {from}.", + "RINGBRP.Contact.Rewards": "{group} — standing {level}", + "RINGBRP.Contact.ModifierCancelled": "Guided: the phase modifier does not apply.", + "RINGBRP.Contact.GuideBonus": "Local guide: +{mod}%", + "RINGBRP.Gen.NewAgent": "New Agent", + "RINGBRP.Gen.NewNPC": "New NPC / Threat", + "RINGBRP.Gen.Name": "Name", + "RINGBRP.Gen.NameHint": "leave blank to generate one", + "RINGBRP.Gen.Species": "Species", + "RINGBRP.Gen.Role": "Role", + "RINGBRP.Gen.RoleRandomBlurb": "A posting will be drawn at random. Pick one to see what it does.", + "RINGBRP.Gen.KeyStat": "Key characteristic", + "RINGBRP.Gen.Trains": "Trains", + "RINGBRP.Gen.Draws": "Draws from stores", + "RINGBRP.Gen.Tier": "Experience", + "RINGBRP.Gen.Gravity": "Home gravity", + "RINGBRP.Gen.Threat": "Threat level", + "RINGBRP.Gen.Count": "How many", + "RINGBRP.Gen.Random": "Random", + "RINGBRP.Gen.Create": "Create", + "RINGBRP.Gen.Made": "{n} created.", + "RINGBRP.Gen.Hint": "Everything is already chosen at random. Change what you care about and press Create.", + "RINGBRP.Gen.RandomEach": "Random (rolled separately for each)", + "RINGBRP.Install.Done": "Installed {n} items into the Items directory.", + "RINGBRP.Install.Button": "Install all content", + "RINGBRP.Sheet.FullList": "Add full skill list", + "RINGBRP.Sheet.NoSkillsHere": "— none —", + "RINGBRP.Sheet.SkillsShown": "skills", + "RINGBRP.Sheet.AlreadyFull": "Already has every skill.", + "RINGBRP.React.Title": "Reaction", + "RINGBRP.React.Base": "Reaction base", + "RINGBRP.React.Lane": "Lane", + "RINGBRP.React.Reflex": "Reflex", + "RINGBRP.React.Awareness": "Awareness", + "RINGBRP.React.Uses": "uses", + "RINGBRP.React.From.reflex": "reflexes", + "RINGBRP.React.From.awareness": "awareness", + "RINGBRP.React.Aim": "Aim", + "RINGBRP.React.NoAim": "Not aiming", + "RINGBRP.React.Aim1": "Aimed one round (+1)", + "RINGBRP.React.Aim2": "Aimed two or more (+2)", + "RINGBRP.React.Edge": "Edge (+1)", + "RINGBRP.React.Drag": "Drag (−1)", + "RINGBRP.React.Load": "Load", + "RINGBRP.React.Set": "Set reaction", + "RINGBRP.React.SetFor": "Reaction — {name}", + "RINGBRP.React.Misfiled": "misfiled — the die lied", + "RINGBRP.React.MisfiledShort": "MISFILED", + "RINGBRP.React.RailHint": "The printed value is your lane base less what you are carrying — one point per 10 kg over free carry, exactly, so no weight is free. Move along the rail for situation only: Aim +1/+2, Edge +1, Drag −1, capped at ±3. Add a d6. Highest acts first; ties go to the higher DEX.", + "RINGBRP.React.DialogHint": "Move along the rail for situation: Aim +1/+2, Edge +1, Drag −1. Capped at ±3. Load is already in the printed value.", + "RINGBRP.Flavour.Wounded": "Wounded: {mod}%", + "RINGBRP.React.Printed": "Reaction", + "RINGBRP.React.LoadInPrinted": "already subtracted into the printed value", + "RINGBRP.Wound.variant.sight": "sight", + "RINGBRP.Wound.variant.hearing": "hearing", + "RINGBRP.Wound.variant.manipulation": "manipulation", + "RINGBRP.Wound.variant.mobility": "mobility", + "RINGBRP.Heal.Bleeds": "{name} bleeds {amount}.", + "RINGBRP.Heal.BledOut": "bled out", + "RINGBRP.Heal.BleedStopped": "The bleeding is staunched. {name} is no longer losing blood.", + "RINGBRP.Heal.Medicine": "Medicine", + "RINGBRP.React.ReadinessSkill": "from training", + "RINGBRP.React.ReadinessInt": "from wits", + "RINGBRP.Chat.RollDamage": "Roll damage", + "RINGBRP.Chat.DamageByBand": "by range band", + "RINGBRP.Chat.DamageCritical": "maximum damage, armour ignored", + "RINGBRP.Chat.DamageSpecial": "armour counts half", + "RINGBRP.Notify.NoWeapon": "That weapon is no longer on the actor.", + "RINGBRP.Sheet.NotWorn": "not worn", + "RINGBRP.Sheet.ArmourUnworn": "You are carrying armour you have not put on. Toggle the shield beside it on the Gear tab.", + "RINGBRP.Folder.skills": "Skills", + "RINGBRP.Folder.talents": "Talents", + "RINGBRP.Folder.weapons": "Weapons", + "RINGBRP.Folder.armour": "Armour", + "RINGBRP.Folder.gear": "Gear & Devices", + "RINGBRP.React.HowIsThis": "How is this worked out?", + "RINGBRP.React.Readiness": "Readiness", + "RINGBRP.React.BetterOf": "the better of the two", + "RINGBRP.React.ThenAddDie": "on the sheet — then add", + "RINGBRP.React.ReadyFromSkill": "Readiness comes from training: your best of Tradecraft, Insight, Spot or Listen", + "RINGBRP.React.ReadyFromInt": "Readiness comes from wits: INT x3", + "RINGBRP.React.ReadyIntFloor": "wits would only give", + "RINGBRP.React.ReadyBestSkill": "your best relevant skill is only", + "RINGBRP.Sheet.WoundBarHint": "Everything currently modifying your rolls — wounds, lost locations, Coherence and what you are carrying.", + "RINGBRP.Sheet.AllRolls": "all rolls", + "RINGBRP.Sheet.Bleeding": "bleeding", + "RINGBRP.Sheet.PerRound": "round", + "RINGBRP.Sheet.SpeciesHint": "Sets the hit locations and the figure on the Combat tab. Changing it changes the anatomy.", + "RINGBRP.Loc.RollButton": "Roll a location", + "RINGBRP.Sheet.Unconscious": "Unconscious", + "RINGBRP.Sheet.OutOfFight": "Out of the fight", + "RINGBRP.Sheet.Overloaded": "Overloaded", + "RINGBRP.Sheet.Physical": "physical skills", + "RINGBRP.Dialog.Range": "Range to target", + "RINGBRP.Range.Impossible": "out of range", + "RINGBRP.Defence.dodge": "Dodge", + "RINGBRP.Defence.parry": "Parry", + "RINGBRP.Defence.ParryWith": "Parry with {name}", + "RINGBRP.Flavour.Defending": "Defence {n} this round: {mod}%", + "RINGBRP.Notify.DefenceLocked": "Already defending with {kind} this round.", + "RINGBRP.Sheet.Defend": "Defend", + "RINGBRP.Sheet.DefendHint": "Roll a defence. The first this round is free; each one after is 30% harder.", + "RINGBRP.Sheet.ThisRound": "this round", + "RINGBRP.Sheet.NextAt": "next at", + "RINGBRP.Sheet.FirstIsFree": "first is free", + "RINGBRP.Notify.NoParryWeapon": "Nothing in hand to parry with.", + "RINGBRP.Sheet.Mass": "Weight", + "RINGBRP.Folder.era.antique": "Antique", + "RINGBRP.Folder.era.modern": "Agency issue", + "RINGBRP.Folder.era.future": "Recovered", + "RINGBRP.Folder.era.anomalous": "Anomalous", + "RINGBRP.Tab.Powers": "Arts", + "RINGBRP.Power.Used": "A deliberate misfiling — {cost} Coherence", + "RINGBRP.Power.Pushed": "Pushed past its reach — {cost} Coherence", + "RINGBRP.Power.Misfiled": "The misfiling sticks.", + "RINGBRP.Power.MisfiledText": "Something small and true about the world is now different. The GM knows what. You do not.", + "RINGBRP.Power.Arrived": "{name} — the crossing left this behind", + "RINGBRP.Power.ArrivedText": "You did not learn this. You came back with it.", + "RINGBRP.Power.Use": "Use", + "RINGBRP.Power.Push": "Push", + "RINGBRP.Power.None": "No Arts. They are not learned — they arrive.", + "RINGBRP.Power.Origin": "Left by", + "RINGBRP.Notify.NoCoherence": "Displaced. There is nothing left to spend.", + "RINGBRP.Notify.AlreadyHasArt": "That Art is already in them.", + "RINGBRP.Folder.powers": "The Quiet Arts", + "RINGBRP.Art.thread.name": "Thread", + "RINGBRP.Art.echo.name": "Echo", + "RINGBRP.Art.hollow.name": "Hollow", + "RINGBRP.Art.witness.name": "Witness", + "RINGBRP.Art.unmake.name": "Unmake", + "RINGBRP.Art.ledger.name": "Ledger", + "RINGBRP.Cross.Aim": "Aim the way home", + "RINGBRP.Cross.Title": "The return", + "RINGBRP.Cross.critical": "Clean. You step out where you meant to, when you meant to.", + "RINGBRP.Cross.special": "Clean, and quick. Nobody is sick.", + "RINGBRP.Cross.success": "Home.", + "RINGBRP.Cross.failure": "You arrive near your target. Not at it.", + "RINGBRP.Cross.fumble": "You come back. You do not come back the same.", + "RINGBRP.Cross.Clean": "Nothing follows you through.", + "RINGBRP.Cross.Cost": "The crossing takes {n} Coherence on the way past.", + "RINGBRP.Cross.Rough": "a return that did not go to plan", + "RINGBRP.Cross.Origin": "A return that went wrong — {outcome}", + "RINGBRP.Cross.Panel": "Crossing", + "RINGBRP.Cross.GoAcross": "Cross over", + "RINGBRP.Cross.BringHome": "Bring them home", + "RINGBRP.Cross.Return": "Return", + "RINGBRP.Cross.Aided": "Aimed", + "RINGBRP.Cross.Aimed": "alone", + "RINGBRP.Cross.Agent": "Agent", + "RINGBRP.Cross.AidedBy": "Aimed by", + "RINGBRP.Cross.HasArts": "Came back with something once already.", + "RINGBRP.Cross.NoMembers": "No agents assigned to this case.", + "RINGBRP.Cross.HomeHint": "The team is home. Crossing over is a decision, not a roll — the brief names a place and a time, and the margin is the case's first problem.", + "RINGBRP.Cross.PanelHint": "Coming back is a second crossing and it must be aimed. Alone, an Unmoored agent takes a penalty; another agent can always aim it for them at none. No result leaves anybody across.", + "RINGBRP.Cross.AllHint": "The best crosser aims for everyone, then the case is marked home.", + "RINGBRP.Coh.Short": "Coh", + "RINGBRP.Sheet.Skill": "Skill", + "RINGBRP.Mission.Title": "Generate a case", + "RINGBRP.Mission.Make": "Generate", + "RINGBRP.Mission.Roll": "— roll it —", + "RINGBRP.Mission.Class": "Classification", + "RINGBRP.Mission.Anomaly": "What is wrong", + "RINGBRP.Mission.Gap": "How the brief is wrong", + "RINGBRP.Mission.GapHint": "The brief is always incomplete. This is the specific way, and it is the engine of the session.", + "RINGBRP.Mission.Adversary": "Who is in the way", + "RINGBRP.Mission.Seed": "Seed", + "RINGBRP.Mission.SeedHint": "blank for a fresh case; a number reproduces one exactly", + "RINGBRP.Mission.Made": "Case {name} generated — journal and case file created.", + "RINGBRP.Salvage.Title": "Salvage", + "RINGBRP.Salvage.Found": "Left lying about — {era}", + "RINGBRP.Salvage.None": "Nothing of that era in the catalogue ({era}).", + "RINGBRP.Gen.Cancel": "Cancel" +} diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..8a87926 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,193 @@ +{ + "name": "ringbrp", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "ringbrp", + "version": "1.0.0", + "license": "ISC", + "dependencies": { + "classic-level": "^3.0.0" + } + }, + "node_modules/abstract-level": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/abstract-level/-/abstract-level-3.1.1.tgz", + "integrity": "sha512-CW2gKbJFTuX1feMvOrvsVMmijAOgI9kg2Ie9Dq3gOcMt/dVVoVmqNlLcEUCT13NxHFMEajcUcVBIplbyDroDiw==", + "license": "MIT", + "dependencies": { + "buffer": "^6.0.3", + "is-buffer": "^2.0.5", + "level-supports": "^6.2.0", + "level-transcoder": "^1.0.1", + "maybe-combine-errors": "^1.0.0", + "module-error": "^1.0.1" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/buffer": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz", + "integrity": "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.2.1" + } + }, + "node_modules/classic-level": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/classic-level/-/classic-level-3.0.0.tgz", + "integrity": "sha512-yGy8j8LjPbN0Bh3+ygmyYvrmskVita92pD/zCoalfcC9XxZj6iDtZTAnz+ot7GG8p9KLTG+MZ84tSA4AhkgVZQ==", + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "abstract-level": "^3.1.0", + "module-error": "^1.0.1", + "napi-macros": "^2.2.2", + "node-gyp-build": "^4.3.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/ieee754": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", + "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/is-buffer": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/is-buffer/-/is-buffer-2.0.5.tgz", + "integrity": "sha512-i2R6zNFDwgEHJyQUtJEk0XFi1i0dPFn/oqjK3/vPCcDeJvW5NQ83V8QbicfF1SupOaB0h8ntgBC2YiE7dfyctQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/level-supports": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/level-supports/-/level-supports-6.2.0.tgz", + "integrity": "sha512-QNxVXP0IRnBmMsJIh+sb2kwNCYcKciQZJEt+L1hPCHrKNELllXhvrlClVHXBYZVT+a7aTSM6StgNXdAldoab3w==", + "license": "MIT", + "engines": { + "node": ">=16" + } + }, + "node_modules/level-transcoder": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/level-transcoder/-/level-transcoder-1.0.1.tgz", + "integrity": "sha512-t7bFwFtsQeD8cl8NIoQ2iwxA0CL/9IFw7/9gAjOonH0PWTTiRfY7Hq+Ejbsxh86tXobDQ6IOiddjNYIfOBs06w==", + "license": "MIT", + "dependencies": { + "buffer": "^6.0.3", + "module-error": "^1.0.1" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/maybe-combine-errors": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/maybe-combine-errors/-/maybe-combine-errors-1.0.0.tgz", + "integrity": "sha512-eefp6IduNPT6fVdwPp+1NgD0PML1NU5P6j1Mj5nz1nidX8/sWY7119WL8vTAHgqfsY74TzW0w1XPgdYEKkGZ5A==", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/module-error": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/module-error/-/module-error-1.0.2.tgz", + "integrity": "sha512-0yuvsqSCv8LbaOKhnsQ/T5JhyFlCYLPXK3U2sgV10zoKQwzs/MyfuQUOZQ1V/6OCOJsK/TRgNVrPuPDqtdMFtA==", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/napi-macros": { + "version": "2.2.2", + "resolved": "https://registry.npmjs.org/napi-macros/-/napi-macros-2.2.2.tgz", + "integrity": "sha512-hmEVtAGYzVQpCKdbQea4skABsdXW4RUh5t5mJ2zzqowJS2OyXZTU1KhDVFhx+NlWZ4ap9mqR9TcDO3LTTttd+g==", + "license": "MIT" + }, + "node_modules/node-gyp-build": { + "version": "4.8.4", + "resolved": "https://registry.npmjs.org/node-gyp-build/-/node-gyp-build-4.8.4.tgz", + "integrity": "sha512-LA4ZjwlnUblHVgq0oBF3Jl/6h/Nvs5fzBLwdEF4nuxnFdsfajde4WfxtJr3CaiH+F6ewcIB/q4jQ4UzPyid+CQ==", + "license": "MIT", + "bin": { + "node-gyp-build": "bin.js", + "node-gyp-build-optional": "optional.js", + "node-gyp-build-test": "build-test.js" + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..0a45204 --- /dev/null +++ b/package.json @@ -0,0 +1,20 @@ +{ + "name": "ringbrp", + "version": "1.0.0", + "description": "The Custodians \u2014 a Basic Roleplaying game of crossings, for Foundry VTT", + "main": "ringbrp.mjs", + "scripts": { + "build": "node tools/build-packs.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", + "test": "npm run check" + }, + "keywords": [], + "author": "Tim Evans (slaguru666)", + "license": "SEE LICENSE.txt", + "type": "commonjs", + "dependencies": { + "classic-level": "^3.0.0" + } +} diff --git a/packs/armour/000005.ldb b/packs/armour/000005.ldb new file mode 100644 index 0000000..23c0234 Binary files /dev/null and b/packs/armour/000005.ldb differ diff --git a/packs/armour/000008.ldb b/packs/armour/000008.ldb new file mode 100644 index 0000000..55c93b9 Binary files /dev/null and b/packs/armour/000008.ldb differ diff --git a/packs/armour/000009.log b/packs/armour/000009.log new file mode 100644 index 0000000..e69de29 diff --git a/packs/armour/CURRENT b/packs/armour/CURRENT new file mode 100644 index 0000000..875cf23 --- /dev/null +++ b/packs/armour/CURRENT @@ -0,0 +1 @@ +MANIFEST-000007 diff --git a/packs/armour/LOG b/packs/armour/LOG new file mode 100644 index 0000000..aca3ecf --- /dev/null +++ b/packs/armour/LOG @@ -0,0 +1,5 @@ +2026/08/10-18:04:05.102786 17970b000 Recovering log #6 +2026/08/10-18:04:05.102816 17970b000 Level-0 table #8: started +2026/08/10-18:04:05.102965 17970b000 Level-0 table #8: 2303 bytes OK +2026/08/10-18:04:05.103319 17970b000 Delete type=0 #6 +2026/08/10-18:04:05.103360 17970b000 Delete type=3 #4 diff --git a/packs/armour/LOG.old b/packs/armour/LOG.old new file mode 100644 index 0000000..3487e6f --- /dev/null +++ b/packs/armour/LOG.old @@ -0,0 +1,5 @@ +2026/08/10-18:01:28.954232 174703000 Recovering log #3 +2026/08/10-18:01:28.954258 174703000 Level-0 table #5: started +2026/08/10-18:01:28.954400 174703000 Level-0 table #5: 1902 bytes OK +2026/08/10-18:01:28.954676 174703000 Delete type=0 #3 +2026/08/10-18:01:28.954726 174703000 Delete type=3 #2 diff --git a/packs/armour/MANIFEST-000007 b/packs/armour/MANIFEST-000007 new file mode 100644 index 0000000..b7e456d Binary files /dev/null and b/packs/armour/MANIFEST-000007 differ diff --git a/packs/expedition/000005.ldb b/packs/expedition/000005.ldb new file mode 100644 index 0000000..4ff460d Binary files /dev/null and b/packs/expedition/000005.ldb differ diff --git a/packs/expedition/000008.ldb b/packs/expedition/000008.ldb new file mode 100644 index 0000000..cca44ca Binary files /dev/null and b/packs/expedition/000008.ldb differ diff --git a/packs/expedition/000009.log b/packs/expedition/000009.log new file mode 100644 index 0000000..e69de29 diff --git a/packs/expedition/CURRENT b/packs/expedition/CURRENT new file mode 100644 index 0000000..875cf23 --- /dev/null +++ b/packs/expedition/CURRENT @@ -0,0 +1 @@ +MANIFEST-000007 diff --git a/packs/expedition/LOG b/packs/expedition/LOG new file mode 100644 index 0000000..8ac312a --- /dev/null +++ b/packs/expedition/LOG @@ -0,0 +1,5 @@ +2026/08/10-18:04:05.115676 17970b000 Recovering log #6 +2026/08/10-18:04:05.115702 17970b000 Level-0 table #8: started +2026/08/10-18:04:05.116502 17970b000 Level-0 table #8: 5058 bytes OK +2026/08/10-18:04:05.117326 17970b000 Delete type=0 #6 +2026/08/10-18:04:05.118008 17970b000 Delete type=3 #4 diff --git a/packs/expedition/LOG.old b/packs/expedition/LOG.old new file mode 100644 index 0000000..fc7624e --- /dev/null +++ b/packs/expedition/LOG.old @@ -0,0 +1,5 @@ +2026/08/10-18:01:28.962448 17571b000 Recovering log #3 +2026/08/10-18:01:28.962472 17571b000 Level-0 table #5: started +2026/08/10-18:01:28.962615 17571b000 Level-0 table #5: 4129 bytes OK +2026/08/10-18:01:28.962874 17571b000 Delete type=0 #3 +2026/08/10-18:01:28.962919 17571b000 Delete type=3 #2 diff --git a/packs/expedition/MANIFEST-000007 b/packs/expedition/MANIFEST-000007 new file mode 100644 index 0000000..0c049ab Binary files /dev/null and b/packs/expedition/MANIFEST-000007 differ diff --git a/packs/gear/000005.ldb b/packs/gear/000005.ldb new file mode 100644 index 0000000..fedde60 Binary files /dev/null and b/packs/gear/000005.ldb differ diff --git a/packs/gear/000008.ldb b/packs/gear/000008.ldb new file mode 100644 index 0000000..54a4e3e Binary files /dev/null and b/packs/gear/000008.ldb differ diff --git a/packs/gear/000009.log b/packs/gear/000009.log new file mode 100644 index 0000000..e69de29 diff --git a/packs/gear/CURRENT b/packs/gear/CURRENT new file mode 100644 index 0000000..875cf23 --- /dev/null +++ b/packs/gear/CURRENT @@ -0,0 +1 @@ +MANIFEST-000007 diff --git a/packs/gear/LOG b/packs/gear/LOG new file mode 100644 index 0000000..57dfe11 --- /dev/null +++ b/packs/gear/LOG @@ -0,0 +1,5 @@ +2026/08/10-18:04:05.104689 177ee7000 Recovering log #6 +2026/08/10-18:04:05.104840 177ee7000 Level-0 table #8: started +2026/08/10-18:04:05.105171 177ee7000 Level-0 table #8: 33311 bytes OK +2026/08/10-18:04:05.105428 177ee7000 Delete type=0 #6 +2026/08/10-18:04:05.105470 177ee7000 Delete type=3 #4 diff --git a/packs/gear/LOG.old b/packs/gear/LOG.old new file mode 100644 index 0000000..7705c61 --- /dev/null +++ b/packs/gear/LOG.old @@ -0,0 +1,5 @@ +2026/08/10-18:01:28.955312 17571b000 Recovering log #3 +2026/08/10-18:01:28.955407 17571b000 Level-0 table #5: started +2026/08/10-18:01:28.955661 17571b000 Level-0 table #5: 27954 bytes OK +2026/08/10-18:01:28.955877 17571b000 Delete type=0 #3 +2026/08/10-18:01:28.955904 17571b000 Delete type=3 #2 diff --git a/packs/gear/MANIFEST-000007 b/packs/gear/MANIFEST-000007 new file mode 100644 index 0000000..d717c43 Binary files /dev/null and b/packs/gear/MANIFEST-000007 differ diff --git a/packs/npcs/000005.ldb b/packs/npcs/000005.ldb new file mode 100644 index 0000000..796c34c Binary files /dev/null and b/packs/npcs/000005.ldb differ diff --git a/packs/npcs/000008.ldb b/packs/npcs/000008.ldb new file mode 100644 index 0000000..af17354 Binary files /dev/null and b/packs/npcs/000008.ldb differ diff --git a/packs/npcs/000009.log b/packs/npcs/000009.log new file mode 100644 index 0000000..e69de29 diff --git a/packs/npcs/CURRENT b/packs/npcs/CURRENT new file mode 100644 index 0000000..875cf23 --- /dev/null +++ b/packs/npcs/CURRENT @@ -0,0 +1 @@ +MANIFEST-000007 diff --git a/packs/npcs/LOG b/packs/npcs/LOG new file mode 100644 index 0000000..92c3925 --- /dev/null +++ b/packs/npcs/LOG @@ -0,0 +1,5 @@ +2026/08/10-18:04:05.090247 178eff000 Recovering log #6 +2026/08/10-18:04:05.090602 178eff000 Level-0 table #8: started +2026/08/10-18:04:05.091538 178eff000 Level-0 table #8: 76320 bytes OK +2026/08/10-18:04:05.092178 178eff000 Delete type=0 #6 +2026/08/10-18:04:05.092254 178eff000 Delete type=3 #4 diff --git a/packs/npcs/LOG.old b/packs/npcs/LOG.old new file mode 100644 index 0000000..acba62a --- /dev/null +++ b/packs/npcs/LOG.old @@ -0,0 +1,5 @@ +2026/08/10-18:01:28.946179 173ef7000 Recovering log #3 +2026/08/10-18:01:28.946451 173ef7000 Level-0 table #5: started +2026/08/10-18:01:28.947084 173ef7000 Level-0 table #5: 60660 bytes OK +2026/08/10-18:01:28.947366 173ef7000 Delete type=0 #3 +2026/08/10-18:01:28.947413 173ef7000 Delete type=3 #2 diff --git a/packs/npcs/MANIFEST-000007 b/packs/npcs/MANIFEST-000007 new file mode 100644 index 0000000..cc2bcbf Binary files /dev/null and b/packs/npcs/MANIFEST-000007 differ diff --git a/packs/powers/000005.ldb b/packs/powers/000005.ldb new file mode 100644 index 0000000..7f540c6 Binary files /dev/null and b/packs/powers/000005.ldb differ diff --git a/packs/powers/000008.ldb b/packs/powers/000008.ldb new file mode 100644 index 0000000..6f225ae Binary files /dev/null and b/packs/powers/000008.ldb differ diff --git a/packs/powers/000009.log b/packs/powers/000009.log new file mode 100644 index 0000000..e69de29 diff --git a/packs/powers/CURRENT b/packs/powers/CURRENT new file mode 100644 index 0000000..875cf23 --- /dev/null +++ b/packs/powers/CURRENT @@ -0,0 +1 @@ +MANIFEST-000007 diff --git a/packs/powers/LOG b/packs/powers/LOG new file mode 100644 index 0000000..3b6809a --- /dev/null +++ b/packs/powers/LOG @@ -0,0 +1,5 @@ +2026/08/10-18:04:05.098553 17970b000 Recovering log #6 +2026/08/10-18:04:05.098590 17970b000 Level-0 table #8: started +2026/08/10-18:04:05.098729 17970b000 Level-0 table #8: 3619 bytes OK +2026/08/10-18:04:05.099052 17970b000 Delete type=0 #6 +2026/08/10-18:04:05.099134 17970b000 Delete type=3 #4 diff --git a/packs/powers/LOG.old b/packs/powers/LOG.old new file mode 100644 index 0000000..58c01dc --- /dev/null +++ b/packs/powers/LOG.old @@ -0,0 +1,5 @@ +2026/08/10-18:01:28.951049 173ef7000 Recovering log #3 +2026/08/10-18:01:28.951069 173ef7000 Level-0 table #5: started +2026/08/10-18:01:28.951162 173ef7000 Level-0 table #5: 3295 bytes OK +2026/08/10-18:01:28.951540 173ef7000 Delete type=0 #3 +2026/08/10-18:01:28.951628 173ef7000 Delete type=3 #2 diff --git a/packs/powers/MANIFEST-000007 b/packs/powers/MANIFEST-000007 new file mode 100644 index 0000000..c99480e Binary files /dev/null and b/packs/powers/MANIFEST-000007 differ diff --git a/packs/pregens/000005.ldb b/packs/pregens/000005.ldb new file mode 100644 index 0000000..96d12a8 Binary files /dev/null and b/packs/pregens/000005.ldb differ diff --git a/packs/pregens/000008.ldb b/packs/pregens/000008.ldb new file mode 100644 index 0000000..40d42f9 Binary files /dev/null and b/packs/pregens/000008.ldb differ diff --git a/packs/pregens/000009.log b/packs/pregens/000009.log new file mode 100644 index 0000000..e69de29 diff --git a/packs/pregens/CURRENT b/packs/pregens/CURRENT new file mode 100644 index 0000000..875cf23 --- /dev/null +++ b/packs/pregens/CURRENT @@ -0,0 +1 @@ +MANIFEST-000007 diff --git a/packs/pregens/LOG b/packs/pregens/LOG new file mode 100644 index 0000000..e41a513 --- /dev/null +++ b/packs/pregens/LOG @@ -0,0 +1,5 @@ +2026/08/10-18:04:05.086831 178eff000 Recovering log #6 +2026/08/10-18:04:05.087142 178eff000 Level-0 table #8: started +2026/08/10-18:04:05.087812 178eff000 Level-0 table #8: 69660 bytes OK +2026/08/10-18:04:05.088224 178eff000 Delete type=0 #6 +2026/08/10-18:04:05.088274 178eff000 Delete type=3 #4 diff --git a/packs/pregens/LOG.old b/packs/pregens/LOG.old new file mode 100644 index 0000000..36bf404 --- /dev/null +++ b/packs/pregens/LOG.old @@ -0,0 +1,5 @@ +2026/08/10-18:01:28.940993 173ef7000 Recovering log #3 +2026/08/10-18:01:28.941428 173ef7000 Level-0 table #5: started +2026/08/10-18:01:28.942350 173ef7000 Level-0 table #5: 54921 bytes OK +2026/08/10-18:01:28.943315 173ef7000 Delete type=0 #3 +2026/08/10-18:01:28.943481 173ef7000 Delete type=3 #2 diff --git a/packs/pregens/MANIFEST-000007 b/packs/pregens/MANIFEST-000007 new file mode 100644 index 0000000..5d937a7 Binary files /dev/null and b/packs/pregens/MANIFEST-000007 differ diff --git a/packs/rules/000005.ldb b/packs/rules/000005.ldb new file mode 100644 index 0000000..4cba517 Binary files /dev/null and b/packs/rules/000005.ldb differ diff --git a/packs/rules/000008.ldb b/packs/rules/000008.ldb new file mode 100644 index 0000000..d16abe8 Binary files /dev/null and b/packs/rules/000008.ldb differ diff --git a/packs/rules/000009.log b/packs/rules/000009.log new file mode 100644 index 0000000..e69de29 diff --git a/packs/rules/CURRENT b/packs/rules/CURRENT new file mode 100644 index 0000000..875cf23 --- /dev/null +++ b/packs/rules/CURRENT @@ -0,0 +1 @@ +MANIFEST-000007 diff --git a/packs/rules/LOG b/packs/rules/LOG new file mode 100644 index 0000000..aa8b764 --- /dev/null +++ b/packs/rules/LOG @@ -0,0 +1,5 @@ +2026/08/10-18:04:05.107865 1786f3000 Recovering log #6 +2026/08/10-18:04:05.107913 1786f3000 Level-0 table #8: started +2026/08/10-18:04:05.108138 1786f3000 Level-0 table #8: 17058 bytes OK +2026/08/10-18:04:05.108511 1786f3000 Delete type=0 #6 +2026/08/10-18:04:05.108562 1786f3000 Delete type=3 #4 diff --git a/packs/rules/LOG.old b/packs/rules/LOG.old new file mode 100644 index 0000000..cb9a08a --- /dev/null +++ b/packs/rules/LOG.old @@ -0,0 +1,5 @@ +2026/08/10-18:01:28.957762 17571b000 Recovering log #3 +2026/08/10-18:01:28.957822 17571b000 Level-0 table #5: started +2026/08/10-18:01:28.958063 17571b000 Level-0 table #5: 16594 bytes OK +2026/08/10-18:01:28.958333 17571b000 Delete type=0 #3 +2026/08/10-18:01:28.958373 17571b000 Delete type=3 #2 diff --git a/packs/rules/MANIFEST-000007 b/packs/rules/MANIFEST-000007 new file mode 100644 index 0000000..ac8c035 Binary files /dev/null and b/packs/rules/MANIFEST-000007 differ diff --git a/packs/skills/000005.ldb b/packs/skills/000005.ldb new file mode 100644 index 0000000..ed7e1a4 Binary files /dev/null and b/packs/skills/000005.ldb differ diff --git a/packs/skills/000006.log b/packs/skills/000006.log new file mode 100644 index 0000000..3c02d65 Binary files /dev/null and b/packs/skills/000006.log differ diff --git a/packs/skills/CURRENT b/packs/skills/CURRENT new file mode 100644 index 0000000..cacca75 --- /dev/null +++ b/packs/skills/CURRENT @@ -0,0 +1 @@ +MANIFEST-000004 diff --git a/packs/skills/LOG b/packs/skills/LOG new file mode 100644 index 0000000..e8c205b --- /dev/null +++ b/packs/skills/LOG @@ -0,0 +1,5 @@ +2026/08/10-18:04:05.094396 178eff000 Recovering log #3 +2026/08/10-18:04:05.094453 178eff000 Level-0 table #5: started +2026/08/10-18:04:05.094771 178eff000 Level-0 table #5: 6679 bytes OK +2026/08/10-18:04:05.095121 178eff000 Delete type=0 #3 +2026/08/10-18:04:05.095170 178eff000 Delete type=3 #2 diff --git a/packs/skills/LOG.old b/packs/skills/LOG.old new file mode 100644 index 0000000..30c6fc9 --- /dev/null +++ b/packs/skills/LOG.old @@ -0,0 +1 @@ +2026/08/10-18:03:56.834116 16f923000 Delete type=3 #1 diff --git a/packs/skills/MANIFEST-000004 b/packs/skills/MANIFEST-000004 new file mode 100644 index 0000000..8951d45 Binary files /dev/null and b/packs/skills/MANIFEST-000004 differ diff --git a/packs/slice/000005.ldb b/packs/slice/000005.ldb new file mode 100644 index 0000000..4851c77 Binary files /dev/null and b/packs/slice/000005.ldb differ diff --git a/packs/slice/000008.ldb b/packs/slice/000008.ldb new file mode 100644 index 0000000..0fb9fe3 Binary files /dev/null and b/packs/slice/000008.ldb differ diff --git a/packs/slice/000009.log b/packs/slice/000009.log new file mode 100644 index 0000000..e69de29 diff --git a/packs/slice/CURRENT b/packs/slice/CURRENT new file mode 100644 index 0000000..875cf23 --- /dev/null +++ b/packs/slice/CURRENT @@ -0,0 +1 @@ +MANIFEST-000007 diff --git a/packs/slice/LOG b/packs/slice/LOG new file mode 100644 index 0000000..c0ce371 --- /dev/null +++ b/packs/slice/LOG @@ -0,0 +1,5 @@ +2026/08/10-18:04:05.110161 178eff000 Recovering log #6 +2026/08/10-18:04:05.110222 178eff000 Level-0 table #8: started +2026/08/10-18:04:05.111347 178eff000 Level-0 table #8: 17462 bytes OK +2026/08/10-18:04:05.112445 178eff000 Delete type=0 #6 +2026/08/10-18:04:05.112494 178eff000 Delete type=3 #4 diff --git a/packs/slice/LOG.old b/packs/slice/LOG.old new file mode 100644 index 0000000..5cd8b6a --- /dev/null +++ b/packs/slice/LOG.old @@ -0,0 +1,5 @@ +2026/08/10-18:01:28.959373 173ef7000 Recovering log #3 +2026/08/10-18:01:28.959414 173ef7000 Level-0 table #5: started +2026/08/10-18:01:28.959665 173ef7000 Level-0 table #5: 16892 bytes OK +2026/08/10-18:01:28.960034 173ef7000 Delete type=0 #3 +2026/08/10-18:01:28.960081 173ef7000 Delete type=3 #2 diff --git a/packs/slice/MANIFEST-000007 b/packs/slice/MANIFEST-000007 new file mode 100644 index 0000000..4d42457 Binary files /dev/null and b/packs/slice/MANIFEST-000007 differ diff --git a/packs/tables/000005.ldb b/packs/tables/000005.ldb new file mode 100644 index 0000000..64baa1c Binary files /dev/null and b/packs/tables/000005.ldb differ diff --git a/packs/tables/000008.ldb b/packs/tables/000008.ldb new file mode 100644 index 0000000..465e211 Binary files /dev/null and b/packs/tables/000008.ldb differ diff --git a/packs/tables/000009.log b/packs/tables/000009.log new file mode 100644 index 0000000..e69de29 diff --git a/packs/tables/CURRENT b/packs/tables/CURRENT new file mode 100644 index 0000000..875cf23 --- /dev/null +++ b/packs/tables/CURRENT @@ -0,0 +1 @@ +MANIFEST-000007 diff --git a/packs/tables/LOG b/packs/tables/LOG new file mode 100644 index 0000000..ac5fa0d --- /dev/null +++ b/packs/tables/LOG @@ -0,0 +1,5 @@ +2026/08/10-18:04:05.113940 177ee7000 Recovering log #6 +2026/08/10-18:04:05.113972 177ee7000 Level-0 table #8: started +2026/08/10-18:04:05.114101 177ee7000 Level-0 table #8: 4143 bytes OK +2026/08/10-18:04:05.114437 177ee7000 Delete type=0 #6 +2026/08/10-18:04:05.114481 177ee7000 Delete type=3 #4 diff --git a/packs/tables/LOG.old b/packs/tables/LOG.old new file mode 100644 index 0000000..0483118 --- /dev/null +++ b/packs/tables/LOG.old @@ -0,0 +1,5 @@ +2026/08/10-18:01:28.961119 174703000 Recovering log #3 +2026/08/10-18:01:28.961149 174703000 Level-0 table #5: started +2026/08/10-18:01:28.961300 174703000 Level-0 table #5: 3739 bytes OK +2026/08/10-18:01:28.961590 174703000 Delete type=0 #3 +2026/08/10-18:01:28.961623 174703000 Delete type=3 #2 diff --git a/packs/tables/MANIFEST-000007 b/packs/tables/MANIFEST-000007 new file mode 100644 index 0000000..c42e043 Binary files /dev/null and b/packs/tables/MANIFEST-000007 differ diff --git a/packs/talents/000005.ldb b/packs/talents/000005.ldb new file mode 100644 index 0000000..989e264 Binary files /dev/null and b/packs/talents/000005.ldb differ diff --git a/packs/talents/000006.log b/packs/talents/000006.log new file mode 100644 index 0000000..e8a5585 Binary files /dev/null and b/packs/talents/000006.log differ diff --git a/packs/talents/CURRENT b/packs/talents/CURRENT new file mode 100644 index 0000000..cacca75 --- /dev/null +++ b/packs/talents/CURRENT @@ -0,0 +1 @@ +MANIFEST-000004 diff --git a/packs/talents/LOG b/packs/talents/LOG new file mode 100644 index 0000000..0c6f42b --- /dev/null +++ b/packs/talents/LOG @@ -0,0 +1,5 @@ +2026/08/10-18:04:05.096436 1786f3000 Recovering log #3 +2026/08/10-18:04:05.096490 1786f3000 Level-0 table #5: started +2026/08/10-18:04:05.096732 1786f3000 Level-0 table #5: 5105 bytes OK +2026/08/10-18:04:05.097123 1786f3000 Delete type=0 #3 +2026/08/10-18:04:05.097170 1786f3000 Delete type=3 #2 diff --git a/packs/talents/LOG.old b/packs/talents/LOG.old new file mode 100644 index 0000000..54ab141 --- /dev/null +++ b/packs/talents/LOG.old @@ -0,0 +1 @@ +2026/08/10-18:03:56.837651 16e0ff000 Delete type=3 #1 diff --git a/packs/talents/MANIFEST-000004 b/packs/talents/MANIFEST-000004 new file mode 100644 index 0000000..8b0e24d Binary files /dev/null and b/packs/talents/MANIFEST-000004 differ diff --git a/packs/vehicles/000005.ldb b/packs/vehicles/000005.ldb new file mode 100644 index 0000000..41d5456 Binary files /dev/null and b/packs/vehicles/000005.ldb differ diff --git a/packs/vehicles/000008.ldb b/packs/vehicles/000008.ldb new file mode 100644 index 0000000..e6ddc06 Binary files /dev/null and b/packs/vehicles/000008.ldb differ diff --git a/packs/vehicles/000009.log b/packs/vehicles/000009.log new file mode 100644 index 0000000..e69de29 diff --git a/packs/vehicles/CURRENT b/packs/vehicles/CURRENT new file mode 100644 index 0000000..875cf23 --- /dev/null +++ b/packs/vehicles/CURRENT @@ -0,0 +1 @@ +MANIFEST-000007 diff --git a/packs/vehicles/LOG b/packs/vehicles/LOG new file mode 100644 index 0000000..66cf844 --- /dev/null +++ b/packs/vehicles/LOG @@ -0,0 +1,5 @@ +2026/08/10-18:04:05.106622 17970b000 Recovering log #6 +2026/08/10-18:04:05.106650 17970b000 Level-0 table #8: started +2026/08/10-18:04:05.106781 17970b000 Level-0 table #8: 5337 bytes OK +2026/08/10-18:04:05.106994 17970b000 Delete type=0 #6 +2026/08/10-18:04:05.107040 17970b000 Delete type=3 #4 diff --git a/packs/vehicles/LOG.old b/packs/vehicles/LOG.old new file mode 100644 index 0000000..cc02422 --- /dev/null +++ b/packs/vehicles/LOG.old @@ -0,0 +1,5 @@ +2026/08/10-18:01:28.956719 174703000 Recovering log #3 +2026/08/10-18:01:28.956749 174703000 Level-0 table #5: started +2026/08/10-18:01:28.956901 174703000 Level-0 table #5: 5012 bytes OK +2026/08/10-18:01:28.957142 174703000 Delete type=0 #3 +2026/08/10-18:01:28.957186 174703000 Delete type=3 #2 diff --git a/packs/vehicles/MANIFEST-000007 b/packs/vehicles/MANIFEST-000007 new file mode 100644 index 0000000..7d941e1 Binary files /dev/null and b/packs/vehicles/MANIFEST-000007 differ diff --git a/packs/weapons/000005.ldb b/packs/weapons/000005.ldb new file mode 100644 index 0000000..c4e6863 Binary files /dev/null and b/packs/weapons/000005.ldb differ diff --git a/packs/weapons/000008.ldb b/packs/weapons/000008.ldb new file mode 100644 index 0000000..16ebb17 Binary files /dev/null and b/packs/weapons/000008.ldb differ diff --git a/packs/weapons/000009.log b/packs/weapons/000009.log new file mode 100644 index 0000000..e69de29 diff --git a/packs/weapons/CURRENT b/packs/weapons/CURRENT new file mode 100644 index 0000000..875cf23 --- /dev/null +++ b/packs/weapons/CURRENT @@ -0,0 +1 @@ +MANIFEST-000007 diff --git a/packs/weapons/LOG b/packs/weapons/LOG new file mode 100644 index 0000000..177b8cf --- /dev/null +++ b/packs/weapons/LOG @@ -0,0 +1,5 @@ +2026/08/10-18:04:05.100620 177ee7000 Recovering log #6 +2026/08/10-18:04:05.100670 177ee7000 Level-0 table #8: started +2026/08/10-18:04:05.100975 177ee7000 Level-0 table #8: 8798 bytes OK +2026/08/10-18:04:05.101322 177ee7000 Delete type=0 #6 +2026/08/10-18:04:05.101395 177ee7000 Delete type=3 #4 diff --git a/packs/weapons/LOG.old b/packs/weapons/LOG.old new file mode 100644 index 0000000..c950f6a --- /dev/null +++ b/packs/weapons/LOG.old @@ -0,0 +1,5 @@ +2026/08/10-18:01:28.952703 17571b000 Recovering log #3 +2026/08/10-18:01:28.952752 17571b000 Level-0 table #5: started +2026/08/10-18:01:28.952965 17571b000 Level-0 table #5: 7240 bytes OK +2026/08/10-18:01:28.953334 17571b000 Delete type=0 #3 +2026/08/10-18:01:28.953396 17571b000 Delete type=3 #2 diff --git a/packs/weapons/MANIFEST-000007 b/packs/weapons/MANIFEST-000007 new file mode 100644 index 0000000..717f729 Binary files /dev/null and b/packs/weapons/MANIFEST-000007 differ diff --git a/ringbrp.mjs b/ringbrp.mjs new file mode 100644 index 0000000..2aed0c4 --- /dev/null +++ b/ringbrp.mjs @@ -0,0 +1,5242 @@ +/** + * RingBRP — The Custodians — Basic Roleplaying, d100 roll-under + * Implements RINGBRP_RULES_SPEC v0.6. + * + * Rule references in comments use the spec's section numbers so the code and the + * document can be audited against each other. + */ + +/* -------------------------------------------- */ +/* Configuration */ +/* -------------------------------------------- */ + +export const RINGBRP = {}; + +/* -------------------------------------------- */ +/* v13/v14 compatibility shims */ +/* -------------------------------------------- */ + +/** renderTemplate moved under foundry.applications.handlebars in v13. */ +// Rule formulas live in ONE place, shared with the pack builder so the packs can +// never be built under a different rule than the game is played by. +import { + ceil, hitPointsFor, majorWoundFor, damageModifierFor, styleFor, + locationMaxHp, reflexRating, awarenessRating, encumbranceFrom, reactionBaseFrom, + reactionLoadFrom, reactionPrintedFrom, woundEffectsFor, + BLEED, bleedStoppedBy, bleedRateFor, locationEffectsFor, + encumbrancePenaltyFor, ENCUMBERED_CATEGORIES, freeCarryFor, + defencePenaltyFor, defenceTypeAllowed, DEFENCE_STEP, + defenceOutcomeFor, levelRank, LEVEL_LADDER, skillBaseFrom, + brawlFallbackFor, BRAWL_FALLBACK, + SCOPE_TIERS, scopeRank, scopeStanceFor, scrutinyFor, RED_FLAG_STEP, + authorityOutcomeFor, trustAfter, TRUST_MAX, DOSSIER, dossierTrustFor, + dossierScopeFor, widenScopeCostFor, mergeStageFor, MERGE_STAGES, + ARTS, powerCostFor, powerStartingRating, + CROSSING, crossingArtPoolFor, crossingGrantsArt, crossingCostFor, REACTION, + CIRCUMSTANCE, RANGE_LADDER, rangeBandFrom, MAJOR_WOUND_TABLE, + COHERENCE_BANDS, COHERENCE_COSTS, COHERENCE_RECOVERY, coherenceBandFrom +} from "./rules.mjs"; +export { hitPointsFor, majorWoundFor, damageModifierFor, locationMaxHp, reflexRating, awarenessRating }; + +function rbRenderTemplate(path, data) { + const fn = foundry?.applications?.handlebars?.renderTemplate ?? globalThis.renderTemplate; + return fn(path, data); +} + +/** Dialog v1 lives at foundry.appv1.api.Dialog in v13+; the global still resolves. */ +/** + * Every dialog the system opens carries the sheet's classes, so the callout boxes + * are the same object as the character sheet rather than Foundry's default + * parchment with a serif face sitting in front of it. + */ +function rbDialogClass() { + const Base = foundry?.appv1?.api?.Dialog ?? globalThis.Dialog; + if (!Base) return Base; + if (!rbDialogClass._themed) { + rbDialogClass._themed = class RingBRPDialog extends Base { + static get defaultOptions() { + const opts = super.defaultOptions; + return foundry.utils.mergeObject(opts, { + classes: [...(opts.classes ?? []), "ringbrp-window"] + }); + } + }; + } + return rbDialogClass._themed; +} + +/** TextEditor moved under foundry.applications.ux in v13. */ +function rbEnrich(html) { + const TE = foundry?.applications?.ux?.TextEditor?.implementation ?? globalThis.TextEditor; + return TE.enrichHTML(html ?? ""); +} + +/** Skill categories and their single governing characteristic (spec §2.1). */ +RINGBRP.categories = { + melee: { label: "RINGBRP.Category.Melee", stat: "str" }, + ranged: { label: "RINGBRP.Category.Ranged", stat: null }, // set by marksmanship style + comm: { label: "RINGBRP.Category.Communication", stat: "cha" }, + manip: { label: "RINGBRP.Category.Manipulation", stat: "dex" }, + mental: { label: "RINGBRP.Category.Mental", stat: "int" }, + percep: { label: "RINGBRP.Category.Perception", stat: "pow" }, + phys: { label: "RINGBRP.Category.Physical", stat: "dex" } // v0.5: DEX, not STR +}; + +/** + * Marksmanship is now a CHOICE, not an accident of birth. Home world and gravity + * were removed with the setting rewrite — everyone comes from the same Earth — + * but the two shooting styles are a good decision with real trade-offs, so they + * survive as something the agent trained into. + */ + +RINGBRP.marksmanship = { + reflex: { label: "RINGBRP.Style.Reflex", stat: "dex", shortBonus: 10, longBonus: 0, aim: 20, moveAndFire: 0, secondAttack: true }, + deliberate: { label: "RINGBRP.Style.Deliberate", stat: "pow", shortBonus: 0, longBonus: 10, aim: 30, moveAndFire: -20, ignoresArmour: 4 } +}; + +/** Speed as a band, because the game measures distance in bands everywhere else. */ +RINGBRP.speedBands = { + walk: "RINGBRP.Speed.walk", slow: "RINGBRP.Speed.slow", road: "RINGBRP.Speed.road", + cross: "RINGBRP.Speed.cross", fast: "RINGBRP.Speed.fast", air: "RINGBRP.Speed.air", + instant: "RINGBRP.Speed.instant" +}; + +RINGBRP.difficulties = { + automatic: "RINGBRP.Difficulty.Automatic", + easy: "RINGBRP.Difficulty.Easy", + average: "RINGBRP.Difficulty.Average", + difficult: "RINGBRP.Difficulty.Difficult", + impossible: "RINGBRP.Difficulty.Impossible" +}; + +/** + * Circumstance. Difficulty (easy/average/difficult) is a property of the TASK and + * multiplies the rating. Circumstance is a property of the SITUATION and is flat, + * because a hand-hold in the dark is worth the same to an expert as to a novice. + * Rule R-63. + */ +// Not a copy — rules.mjs owns the ladder; this is the keyed view the UI wants. +RINGBRP.circumstances = Object.fromEntries(CIRCUMSTANCE.map(c => [c.id, { mod: c.mod, label: c.label }])); + +/** + * R-64. At most TWO circumstance modifiers apply to one roll: the single largest + * favourable and the single largest unfavourable. They sum, then clamp at +-40. + * This is what stops a roll accumulating six small bonuses into certainty. + */ +export function circumstanceTotal(mods = []) { + const vals = mods.map(m => typeof m === "string" ? (RINGBRP.circumstances[m]?.mod ?? 0) : (Number(m) || 0)); + const best = Math.max(0, ...vals.filter(v => v > 0)); + const worst = Math.min(0, ...vals.filter(v => v < 0)); + return Math.max(-40, Math.min(40, best + worst)); +} + +/** Difficulty ladder (spec S1). */ + +/** Range ladder (spec §4.4). */ +RINGBRP.rangeLadder = [ + { band: "short", mod: 0 }, + { band: "medium", mod: -10 }, + { band: "long", mod: -30 }, + { band: "extreme", mod: -50 } +]; + +/** Cover (spec §4.3). */ +RINGBRP.cover = { + none: { label: "RINGBRP.Cover.None", mod: 0 }, + light: { label: "RINGBRP.Cover.Light", mod: -10 }, + partial: { label: "RINGBRP.Cover.Partial", mod: -30 }, + full: { label: "RINGBRP.Cover.Full", mod: -50 } +}; + +/** woundType → characteristic lost on Major Wound results 9 and 10 (spec §3.3). */ +RINGBRP.woundTypeMap = { + edged: "dex", + blunt: "con", + piercing: "str", + energy: "str", + explosive: "con" +}; + +/** Major Wound consequences, 1d10 (spec §3.3). */ +RINGBRP.majorWoundTable = MAJOR_WOUND_TABLE; + +/** ============================================================ + * HIT LOCATIONS (spec §3.6) + * Taken from the 1984 Creatures Book, which gives each species a + * 1D20 table and each location a FRACTION of total hit points. + * Melee and ranged distributions differ: melee finds limbs because + * they are between you and the target; ranged finds centre of mass. + * ============================================================ */ +/* Location fractions are of TOTAL hit points. They were sized against the old + 26-point pool; halving HP left a leg at 4 against a sidearm doing 4-6 through + armour, so 64% of hits crippled the limb they struck. Raised so a sidearm + cripples ~40% of the time and a rifle ~55%. */ +RINGBRP.locationTables = { + baseline: { + label: "RINGBRP.Species.Baseline", + locations: [ + { id: "legR", label: "RINGBRP.Loc.LegR", melee: [1, 4], ranged: [1, 3], frac: 0.42, kind: "leg" }, + { id: "legL", label: "RINGBRP.Loc.LegL", melee: [5, 8], ranged: [4, 6], frac: 0.42, kind: "leg" }, + { id: "abdomen", label: "RINGBRP.Loc.Abdomen", melee: [9, 11], ranged: [7, 10], frac: 0.42, kind: "body" }, + { id: "chest", label: "RINGBRP.Loc.Chest", melee: [12, 12], ranged: [11, 15], frac: 0.5, kind: "vital"}, + { id: "armR", label: "RINGBRP.Loc.ArmR", melee: [13, 15], ranged: [16, 17], frac: 0.35, kind: "arm" }, + { id: "armL", label: "RINGBRP.Loc.ArmL", melee: [16, 18], ranged: [18, 19], frac: 0.35, kind: "arm" }, + { id: "head", label: "RINGBRP.Loc.Head", melee: [19, 20], ranged: [20, 20], frac: 0.4, kind: "head" } + ] + }, + /* A VESH is bilaterally ASYMMETRIC: one long grasping limb, one short bracing + limb, and no head at all — the senses run along a dorsal ridge. There is no + single killing shot to the skull because there is no skull. The ridge is the + vital, and it is a long target rather than a small one. */ + vesh: { + label: "RINGBRP.Species.Vesh", + locations: [ + { id: "footBrace", label: "RINGBRP.Loc.BraceFoot", melee: [1, 3], ranged: [1, 3], frac: 0.42, kind: "leg" }, + { id: "limbBrace", label: "RINGBRP.Loc.BraceLimb", melee: [4, 8], ranged: [4, 7], frac: 0.42, kind: "brace" }, + { id: "lowTrunk", label: "RINGBRP.Loc.LowerTrunk", melee: [9, 12], ranged: [8, 12], frac: 0.42, kind: "body" }, + { id: "upTrunk", label: "RINGBRP.Loc.UpperTrunk", melee: [13, 15], ranged: [13, 15], frac: 0.42, kind: "body" }, + { id: "limbGrasp", label: "RINGBRP.Loc.GraspLimb", melee: [16, 18], ranged: [16, 17], frac: 0.35, kind: "grasp" }, + { id: "ridge", label: "RINGBRP.Loc.Ridge", melee: [19, 20], ranged: [18, 20], frac: 0.5, kind: "ridge" } + ] + }, + /* A CADENCE is not one body. It is four to six small bodies that together are + one person, and the location roll picks which body you hit. Killing a body + does not kill the person — it reduces them. There is no vital and no head: + every body carries an equal share, and the person degrades as they are + whittled down. This is why shooting a Cadence feels like nothing is working. */ + cadence: { + label: "RINGBRP.Species.Cadence", + locations: [ + { id: "bodyA", label: "RINGBRP.Loc.BodyA", melee: [1, 4], ranged: [1, 4], frac: 0.28, kind: "unit" }, + { id: "bodyB", label: "RINGBRP.Loc.BodyB", melee: [5, 8], ranged: [5, 8], frac: 0.28, kind: "unit" }, + { id: "bodyC", label: "RINGBRP.Loc.BodyC", melee: [9, 12], ranged: [9, 12], frac: 0.28, kind: "unit" }, + { id: "bodyD", label: "RINGBRP.Loc.BodyD", melee: [13, 16], ranged: [13, 16], frac: 0.28, kind: "unit" }, + { id: "bodyE", label: "RINGBRP.Loc.BodyE", melee: [17, 20], ranged: [17, 20], frac: 0.28, kind: "unit" } + ] + } +}; + +/** + * The figure drawn on the combat tab. One entry per species profile, keyed by the + * same location ids as the table above, so a species that has a location gets a + * shape and one that does not simply has none — no separate list to keep in step. + * + * Deliberately blocky: these are readable at sheet size and honest about the + * anatomy. A Vesh is asymmetric with no head; a Cadence is five separate bodies. + */ +RINGBRP.bodyArt = { + baseline: { + viewBox: "0 0 120 196", + parts: { + head: { t: "ellipse", cx: 60, cy: 21, rx: 15, ry: 17 }, + chest: { t: "rect", x: 40, y: 42, w: 40, h: 40, rx: 9 }, + abdomen: { t: "rect", x: 44, y: 84, w: 32, h: 30, rx: 8 }, + armR: { t: "rect", x: 18, y: 45, w: 17, h: 64, rx: 8 }, + armL: { t: "rect", x: 85, y: 45, w: 17, h: 64, rx: 8 }, + legR: { t: "rect", x: 43, y: 117, w: 15, h: 72, rx: 7 }, + legL: { t: "rect", x: 62, y: 117, w: 15, h: 72, rx: 7 } + } + }, + /* One long grasping limb, one short bracing limb, a trunk that leans, and the + sensory ridge running down the back instead of a head. */ + vesh: { + viewBox: "0 0 120 196", + parts: { + ridge: { t: "path", d: "M44 18 L70 14 L76 96 L52 100 Z" }, + upTrunk: { t: "path", d: "M40 34 L70 30 L74 84 L44 88 Z" }, + lowTrunk: { t: "path", d: "M44 90 L74 86 L78 132 L48 136 Z" }, + limbGrasp: { t: "path", d: "M74 36 L96 44 L104 148 L86 150 L82 60 Z" }, + limbBrace: { t: "rect", x: 20, y: 52, w: 16, h: 48, rx: 8 }, + footBrace: { t: "path", d: "M46 136 L78 132 L84 188 L58 190 Z" } + } + }, + /* Five bodies, one person. No vital, no head, every body an equal share. */ + cadence: { + viewBox: "0 0 120 196", + parts: { + bodyA: { t: "ellipse", cx: 34, cy: 52, rx: 20, ry: 24 }, + bodyB: { t: "ellipse", cx: 86, cy: 44, rx: 20, ry: 24 }, + bodyC: { t: "ellipse", cx: 60, cy: 100, rx: 20, ry: 24 }, + bodyD: { t: "ellipse", cx: 28, cy: 148, rx: 20, ry: 24 }, + bodyE: { t: "ellipse", cx: 88, cy: 152, rx: 20, ry: 24 } + } + } +}; + +/** What losing a location does, by anatomical KIND rather than by name, + * so a new species inherits sensible consequences for free. */ +RINGBRP.locationEffects = { + leg: { disabled: "RINGBRP.LocEffect.leg", destroyed: "RINGBRP.LocEffect.legX" }, + arm: { disabled: "RINGBRP.LocEffect.arm", destroyed: "RINGBRP.LocEffect.armX" }, + brace: { disabled: "RINGBRP.LocEffect.brace", destroyed: "RINGBRP.LocEffect.braceX" }, + grasp: { disabled: "RINGBRP.LocEffect.grasp", destroyed: "RINGBRP.LocEffect.graspX" }, + ridge: { disabled: "RINGBRP.LocEffect.ridge", destroyed: "RINGBRP.LocEffect.ridgeX" }, + unit: { disabled: "RINGBRP.LocEffect.unit", destroyed: "RINGBRP.LocEffect.unitX" }, + body: { disabled: "RINGBRP.LocEffect.body", destroyed: "RINGBRP.LocEffect.bodyX" }, + vital: { disabled: "RINGBRP.LocEffect.vital", destroyed: "RINGBRP.LocEffect.vitalX" }, + head: { disabled: "RINGBRP.LocEffect.head", destroyed: "RINGBRP.LocEffect.headX" } +}; + +RINGBRP.fireModes = ["", "single", "rapid", "slow", "beam"]; +RINGBRP.weaponClasses = ["melee", "thrown", "bow", "firearm", "energy", "stun", "special"]; +RINGBRP.damageTags = ["kinetic", "energy", "monofilament", "disintegrator", "explosive", "blunt", "edged", "stun"]; +RINGBRP.traditions = ["tradition_home", "tradition_rival_a", "tradition_rival_b", "tradition_local", "tradition_builder", "tradition_precursor"]; +RINGBRP.environments = ["zero_g", "vacuum", "high_altitude"]; +RINGBRP.researchStepTypes = ["field", "library", "laboratory", "armchair"]; +RINGBRP.facilities = { none: 0, portable: -20, laboratory: 0, builder_grade: 20 }; + +/** ============================================================ + * Combat constants. Initiative is Reaction Lanes — see rules.mjs + * for the formulas and the block above rollReaction for intent. + * ============================================================ */ + +/** H — handling cost of the declared action. */ +/** + * A percentile result read as two dice, tens and units, the way the table reads + * it. Returns the face values so the chat card can draw them. + */ +export function percentileFaces(total) { + const n = Math.max(1, Math.min(100, Number(total) || 0)); + const t = n === 100 ? 0 : Math.floor(n / 10) * 10; + const u = n === 100 ? 0 : n % 10; + return { tens: t, units: u, display: n }; +} + +/** + * Readiness. Acting first is not only how well you handle a weapon — it is + * whether you read the situation before it broke. Drawn from the best of these, + * so being switched-on is rewarded independently of being armed. + */ +RINGBRP.readinessSkills = ["tradecraft", "insight", "spot", "listen"]; + +/** INT floors readiness, so a clever but untrained character is not flat. */ +RINGBRP.readinessIntFactor = 3; + +/** The readiness rating: the best awareness skill, floored by INT x 3. */ +export function readinessRating(actor) { + let best = 0; + for (const fam of RINGBRP.readinessSkills) { + best = Math.max(best, Number(actor?.skillRating?.(fam)) || 0); + } + const int = Number(actor?.system?.characteristics?.int?.value) || 0; + const intFloor = int * RINGBRP.readinessIntFactor; + return { rating: Math.max(best, intFloor), best, intFloor }; +} + +/* -------------------------------------------- */ +/* Reaction — initiative */ +/* -------------------------------------------- */ + +/** + * REACTION LANES. Replaces the old Strike Rank entirely. + * + * The formulas live in rules.mjs and are NOT restated here - reactionBaseFrom(), + * reflexRating(), awarenessRating() and the REACTION constants are the authority. + * This comment describes intent and measured behaviour only, so that it cannot + * drift out of step with the arithmetic the way the old copy did. + * + * Initiative = current printed base + one die, HIGHEST acts first. + * + * Quick thought fully substitutes for quick hands: your best route sets your + * speed, so a perceptive analyst and a fast shooter reach the same lane by + * different fictions. + * + * Measured on the shipped pregens (bases 24 / 18 / 18 / 15) with a d6 and + * three-point bands: the random share is 35.7%, inside the 30-35% design ceiling. + * Same band is a 50/50 with 16.7% ties, broken by DEX. One band apart wins 83.3% + * (90.9% excluding ties). Two bands apart is 100% - a six-point gap cannot be + * closed by a d6, so a large advantage is decisive rather than merely likely. + * That determinism at the top end is the price of keeping the die visible and + * the share honest; it was chosen over shrinking the die back to a d4. + * + * Nothing here is rolled per weapon. Equipment, talents, cover and supernatural + * effects print ONE of three words - Edge, Drag, Aim - and move a marker. + */ +// Not a copy — the same object rules.mjs exports. A second literal here is how +// the divisors drifted before; check-rules now fails if this stops being identical. +RINGBRP.reaction = REACTION; + +/** The four words. Everything that touches initiative uses one of them. */ +RINGBRP.reactionModifiers = { + aim: { label: "RINGBRP.React.Aim", values: [0, 1, 2] }, + edge: { label: "RINGBRP.React.Edge", value: 1 }, + drag: { label: "RINGBRP.React.Drag", value: -1 }, + load: { label: "RINGBRP.React.Load", values: [0, -1, -2] } +}; + + +/** The number printed on the paper character sheet. Calculated once. */ +export function reactionBase(actor) { + const dex = actor?.system?.characteristics?.dex?.value; + const read = readinessRating(actor).rating; + const reflex = reflexRating(dex); + const aware = awarenessRating(read); + const base = reactionBaseFrom(dex, read); + const load = reactionLoadFrom(actor?.system?.encumbrance?.srLoad); + return { + base, load, + printed: reactionPrintedFrom(base, actor?.system?.encumbrance?.srLoad), + reflex, aware, lane: Math.max(reflex, aware), + from: reflex >= aware ? "reflex" : "awareness" + }; +} + +/** + * Load on the reaction, exact and to one decimal. This used to be a three-value + * band, which charged a whole point for the first gram over free carry and then + * nothing further until 10 kg. It is now the granular figure the encumbrance rule + * always computed but nothing consumed. + * + * Load is NOT a rail position. It is a property of what the agent is carrying, + * known before the fight, so it is subtracted into the number printed on the + * sheet. The rail is left entirely to situation — aim, edge, drag. + */ +export function loadBand(actor) { + return reactionLoadFrom(actor?.system?.encumbrance?.srLoad); +} + +/** + * The situational modifier, capped. This is the clip position on the paper rail. + * Load is deliberately absent: it is carried in the printed base instead, so that + * the rail means only what is happening right now and cannot double-count kit. + */ +export function reactionModifier({ aim = 0, edge = false, drag = false } = {}) { + const cap = RINGBRP.reaction.modCap; + const total = Math.max(0, Math.min(2, Number(aim) || 0)) + + (edge ? 1 : 0) - (drag ? 1 : 0); + return Math.max(-cap, Math.min(cap, total)); +} + +/** + * Coherence Flux. An additive Flux would have breached the 35% random ceiling, so + * a misfiled agent's die LIES instead. The distribution stays uniform 1..4 — the + * probabilities and the ceiling are untouched — but reality files them early or + * late, which is the setting doing the work rather than a bolted-on penalty. + */ +export function fluxDie(face, coherence) { + const c = Number(coherence ?? 10); + if (c >= 6) return face; + if (c >= 3) return face === 1 ? 4 : (face === 4 ? 1 : face); // outer faces misfile + return 5 - face; // 1<->4, 2<->3 +} + + +/** Spec §1. Difficulty multiplies the rating FIRST, then situational modifiers add. */ +export function applyDifficulty(rating, difficulty = "average") { + switch (difficulty) { + case "easy": return rating * 2; + case "difficult": return Math.floor(rating / 2); + case "average": + default: return rating; + } +} + +/** + * Spec §1 resolution. Returns the full band breakdown without rolling, + * so sheets can display the odds before the player commits. + */ +export function resolveBands(rating, { difficulty = "average", situational = 0, printedBase = 0 } = {}) { + let effective = applyDifficulty(Number(rating) || 0, difficulty) + (Number(situational) || 0); + if (printedBase >= 5) effective = Math.max(effective, 5); + const band = Math.min(100, Math.max(1, effective)); + // `effective` used to be the RAW figure, so a skill beaten down to 0 or below + // printed "0%" on the chat card while the true chance was 1% — the floor was + // real but arrived sideways, through the critical band, and the card contradicted + // the rule the book states. One honest number now: what you actually need. + return { + effective: band, + band, + critical: ceil(band / 20), + special: ceil(band / 5), + fumbleStart: 101 - ceil((101 - band) / 20) + }; +} + +/** Spec §1. Fumble is tested first; 96–99 never succeed; 00 always fumbles. */ +export function gradeRoll(roll, bands) { + if (roll >= bands.fumbleStart) return "fumble"; + if (roll >= 96 && roll <= 99) return "failure"; + if (roll <= bands.critical) return "critical"; + if (roll <= bands.special) return "special"; + if (roll <= bands.effective) return "success"; + return "failure"; +} + + +/** Spec §4.4. Returns {band, mod} or {band:"beyond", mod:null} when impossible. */ +export const rangeBandFor = rangeBandFrom; + +/** Spec §2.3. Style bonus applies on top of the range modifier. */ +/** + * Plain English for a marksmanship style, assembled from its own numbers so the + * sheet can never drift from the rule. Only clauses that actually apply appear. + */ +function styleHintFor(styleId) { + const cfg = RINGBRP.marksmanship[styleId]; + if (!cfg) return ""; + const F = (k, v) => game.i18n.format(k, v); + const bits = []; + if (cfg.shortBonus) bits.push(F("RINGBRP.Style.Hint.Short", { n: cfg.shortBonus })); + if (cfg.longBonus) bits.push(F("RINGBRP.Style.Hint.Long", { n: cfg.longBonus })); + if (cfg.aim) bits.push(F("RINGBRP.Style.Hint.Aim", { n: cfg.aim })); + if (cfg.secondAttack) bits.push(game.i18n.localize("RINGBRP.Style.Hint.Second")); + if (cfg.moveAndFire) bits.push(F("RINGBRP.Style.Hint.Move", { n: cfg.moveAndFire })); + if (cfg.ignoresArmour) bits.push(F("RINGBRP.Style.Hint.Pierce", { n: cfg.ignoresArmour })); + return bits.join(" · "); +} + +export function styleRangeBonus(style, band) { + const cfg = RINGBRP.marksmanship[style]; + if (!cfg) return 0; + if (band === "short") return cfg.shortBonus ?? 0; + if (band === "long" || band === "extreme") return cfg.longBonus ?? 0; + return 0; +} + +/** Spec §3. Hit points and the floored Major Wound threshold. */ + +/** Spec §3. Damage after armour never goes below zero. */ +export function damageAfterArmour(rolled, armour) { + return Math.max(0, (Number(rolled) || 0) - (Number(armour) || 0)); +} + +/** Spec R-39. Armour treatment by attack success level. */ +export function armourAgainst(level, armour) { + const a = Number(armour) || 0; + if (level === "critical") return 0; // ignores armour entirely + if (level === "special") return Math.floor(a / 2); // counts as half + return a; +} + +/** + * Spec R-39 + §3. Applies a resolved attack to a target and reports whether it + * crossed the Major Wound threshold. Returns a plain object; callers decide what + * to do with it, so this stays testable. + */ +export function resolveHit({ level, rolled, armour, majorWoundThreshold }) { + const effArmour = armourAgainst(level, armour); + const damage = damageAfterArmour(rolled, effArmour); + return { + level, rolled, armour, effectiveArmour: effArmour, damage, + majorWound: damage >= (Number(majorWoundThreshold) || Infinity) + }; +} + +/** Spec §3.6. Which location a 1D20 finds, for this species and attack type. */ +export function locationFor(roll, speciesId = "baseline", mode = "ranged") { + const table = RINGBRP.locationTables[speciesId] ?? RINGBRP.locationTables.baseline; + const key = mode === "melee" ? "melee" : "ranged"; + return table.locations.find(l => roll >= l[key][0] && roll <= l[key][1]) ?? table.locations[0]; +} + + +/** + * Spec §3.6. Resolve a hit against a location. + * A blow that meets the location's maximum disables it; twice that destroys it. + * General hit points still take the damage, so the two systems agree. + */ +export function resolveLocationHit({ damage, locationMax, locationTaken = 0, majorWoundThreshold }) { + const d = Number(damage) || 0; + const after = locationTaken + d; + return { + damage: d, + locationTaken: after, + disabled: after >= locationMax, + // Cumulative, like `disabled` and like the actor's own derived state. This + // used to test THIS hit's damage alone, so a limb could never be destroyed by + // repeated wounds and the resolver disagreed with the sheet about the same + // limb: derived data said destroyed, the resolver said not. + destroyed: after >= locationMax * 2, + majorWound: d >= (Number(majorWoundThreshold) || Infinity) + }; +} + +/* -------------------------------------------- */ +/* Documents */ +/* -------------------------------------------- */ + +class RingBRPActor extends Actor { + + prepareDerivedData() { + super.prepareDerivedData(); + const sys = this.system; + if (this.type === "expedition") return this._prepareExpedition(); + if (!sys.characteristics) return; + + const ch = sys.characteristics; + const v = (k) => Number(ch[k]?.value) || 0; + + // --- Marksmanship style: the agent's own choice --- + sys.marksmanship ??= {}; + sys.marksmanship.style = sys.marksmanship.chosenStyle || "reflex"; + sys.marksmanship.locked = false; + const styleCfg = RINGBRP.marksmanship[sys.marksmanship.style] ?? RINGBRP.marksmanship.reflex; + sys.marksmanship.stat = styleCfg.stat; + + // --- Category bonuses: ceil(stat/2) each (spec §2.1) --- + sys.categoryBonus = {}; + for (const [key, cfg] of Object.entries(RINGBRP.categories)) { + const stat = key === "ranged" ? styleCfg.stat : cfg.stat; + sys.categoryBonus[key] = ceil(v(stat) / 2); + } + + // --- Hit points and Major Wound (spec §3) --- + const maxHp = hitPointsFor(v("con"), v("siz")); + sys.hp ??= { value: maxHp, max: maxHp }; + sys.hp.max = maxHp; + if (sys.hp.value === null || sys.hp.value === undefined) sys.hp.value = maxHp; + sys.hp.value = Math.min(sys.hp.value, maxHp); + sys.majorWound = { value: majorWoundFor(maxHp) }; + + // --- Attribute rolls (spec §3, ×5) --- + sys.rolls = { + stamina: v("con") * 5, + idea: v("int") * 5, + luck: v("pow") * 5 + }; + + // --- Damage modifier and carry (spec §3.1, §4) --- + sys.damageModifier = damageModifierFor(v("str") + v("siz")); + // STR alone ignored how big you are. rules.mjs owns the formula. + sys.freeCarryKg = freeCarryFor(v("str"), v("siz")); + sys.mov = 10; + // Coherence was added after these actors existed, and template.json only + // fills defaults at creation. Default it here so no actor is ever without it. + sys.coherence ??= { value: 10, max: 10, drift: [] }; + sys.coherence.max ??= 10; + sys.coherence.value ??= sys.coherence.max; + sys.coherence.drift ??= []; + sys.coherenceBand = coherenceBand(sys.coherence.value); + + // --- Encumbrance from carried items only (spec §4) --- + let carried = 0; + for (const it of this.items) { + const s = it.system; + if (s?.carried === false) continue; + const mass = Number(s?.massKg) || 0; + const qty = Number(s?.quantity) || 1; + carried += mass * qty; + } + sys.carriedKg = Math.round(carried * 10) / 10; + // Two readings of one condition, deliberately (rule R-23 as revised 8 Aug 2026): + // steps floored whole steps -> MOV and skill penalties + // srLoad ungraded, per 10 kg -> so no weight is ever free + // Computed by rules.mjs, not here. This block used to hold its own copy. + sys.encumbrance = encumbranceFrom(carried, sys.freeCarryKg); + sys.mov = Math.max(1, 10 - sys.encumbrance.steps); + + // Reaction — the number printed on the paper sheet. MUST come after + // encumbrance: the printed value has load subtracted into it, and deriving it + // earlier read an unset sys.encumbrance and silently printed a load of zero. + const _reflex = reflexRating(v("dex")); + const _aware = awarenessRating(readinessRating(this).rating); + const _base = reactionBaseFrom(v("dex"), readinessRating(this).rating); + sys.reaction = { + reflex: _reflex, aware: _aware, + lane: Math.max(_reflex, _aware), + base: _base, + load: reactionLoadFrom(sys.encumbrance.srLoad), + printed: reactionPrintedFrom(_base, sys.encumbrance.srLoad), + from: _reflex >= _aware ? "reflex" : "awareness" + }; + + // --- Armour: sum of equipped armour points --- + let armour = Number(sys.naturalArmour?.value) || 0; + for (const it of this.items) { + if (it.type !== "armour") continue; + if (!it.system.equipped) continue; + armour += Number(it.system.points) || 0; + } + sys.armourPoints = armour; + + // --- hit locations (spec §3.6) --- + const speciesId = sys.speciesProfile || "baseline"; + const table = RINGBRP.locationTables[speciesId] ?? RINGBRP.locationTables.baseline; + const taken = sys.locationDamage ?? {}; + sys.speciesLabel = table.label; + sys.locations = table.locations.map(loc => { + const max = locationMaxHp(maxHp, loc.frac); + const t = Number(taken[loc.id]) || 0; + return { + id: loc.id, label: loc.label, kind: loc.kind, + melee: `${loc.melee[0]}${loc.melee[1] > loc.melee[0] ? `-${loc.melee[1]}` : ""}`, + ranged: `${loc.ranged[0]}${loc.ranged[1] > loc.ranged[0] ? `-${loc.ranged[1]}` : ""}`, + max, taken: t, remaining: Math.max(0, max - t), + armour: (Number(loc.armour) || 0) + armour, + disabled: t >= max, + destroyed: t >= max * 2 + }; + }); + // Wound effects were printed and never applied: a destroyed leg said "MOV + // halved" while MOV stayed where it was. Derive the consequences here so the + // penalty text and the numbers agree. + { + // Both tables, folded. A shattered leg and a concussion are separate facts + // about the same agent, so the numbers add rather than one replacing the + // other. Location consequences used to be computed inline here and covered + // only legs and arms — a destroyed head left the agent conscious, an opened + // Vesh ridge cost no Perception, and a Cadence losing bodies lost nothing. + const loc = locationEffectsFor(sys.locations); + const mw = woundEffectsFor(sys.wounds); + sys.wounded = { + legs: loc.legs, legsDestroyed: loc.legsDestroyed, arms: loc.arms, + vital: loc.vital, unitsLost: loc.unitsLost, + movFactor: loc.movFactor * mw.movFactor, + manipulation: loc.manipulation + mw.manipulation, + physical: loc.physical + mw.physical, + perception: loc.perception + mw.perception, + communication: mw.communication, + all: loc.all + mw.all, + bleed: mw.bleed, + defenceOnly: mw.defenceOnly, + unconscious: loc.unconscious || mw.unconscious, + lostAction: mw.lostAction, + prone: loc.prone || mw.defenceOnly + }; + sys.mov = Math.max(mw.movFloor || 1, Math.floor(sys.mov * sys.wounded.movFactor)); + } + sys.disabledLocations = sys.locations.filter(l => l.disabled).length; + + sys.isDown = (sys.hp.value <= 2) || !!sys.wounded?.defenceOnly || !!sys.wounded?.unconscious; + } + + _prepareExpedition() { + const sys = this.system; + for (const t of ["supply", "power", "integrity"]) { + sys[t] ??= { value: 0, max: 10 }; + sys[t].value = Math.max(0, Math.min(sys[t].value, sys[t].max)); + } + // R-56 at case scale. An exhausted track used to BLOCK phase resolution, which + // strands a case with no way forward. It now degrades instead: the phase is + // Difficult and costs Containment, but it can always be attempted. + sys.destitute = ["supply", "power", "integrity"].filter(t => sys[t].value <= 0); + sys.inCrisis = sys.destitute.length > 0; + const legs = sys.journey?.legs ?? []; + sys.journey.totalLegs = legs.length; + sys.journey.legsRemaining = legs.filter(l => !l.resolved).length; + } + + /** + * A new character starts whole. hp.max is derived from CON + SIZ, so the + * template's placeholder value leaves a fresh actor a few points down until + * something touches it. Fill it in at creation. + */ + async _preCreate(data, options, user) { + await super._preCreate(data, options, user); + if (data.system?.hp?.value !== undefined) return; + const c = this.system?.characteristics ?? {}; + const max = (Number(c.con?.value) || 0) + (Number(c.siz?.value) || 0); + if (max > 0) this.updateSource({ "system.hp.value": max, "system.hp.max": max }); + } + + /** + * R-23's skill penalty was being derived and never applied. Encumbrance costs + * -5% per whole step to the categories that need the body: Physical, + * Manipulation and Melee. Mental and Communication are unaffected. + */ + encumbrancePenaltyFor(categoryId) { + if (!["phys", "manip", "melee"].includes(categoryId)) return 0; + return -(Number(this.system.encumbrance?.skillPenalty) || 0); + } + + /** Total rating for a skill, already inclusive of the category bonus (see design note). */ + skillRating(familyId, specialisationId = "") { + const it = this.items.find(i => i.type === "skill" + && i.system.familyId === familyId + && (!specialisationId || i.system.specialisationId === specialisationId)); + return it ? Number(it.system.value) || 0 : null; + } +} + +class RingBRPItem extends Item { + prepareDerivedData() { + super.prepareDerivedData(); + const sys = this.system; + if (this.type === "skill") { + // A formula base is worked out from the owner's characteristics and acts as a + // FLOOR, not a replacement: Dodge is DEX×2 or whatever training raised it to, + // whichever is higher. Nothing evaluated this before, so Dodge read 0%. + if (sys.base?.mode === "formula" && sys.base.formula && this.actor) { + const floor = skillBaseFrom(sys.base.formula, this.actor.system?.characteristics); + sys.base.value = floor; + sys.value = Math.max(Number(sys.value) || 0, floor); + } + sys.categoryLabel = RINGBRP.categories[sys.categoryId]?.label ?? ""; + sys.displayName = sys.specialisationId + ? `${this.name}` : this.name; + } + if (this.type === "weapon") { + sys.isRanged = ["firearm", "energy", "bow", "thrown", "stun"].includes(sys.weaponClass); + sys.rangeSummary = sys.isRanged && sys.ranges?.long + ? `${sys.ranges.short}/${sys.ranges.medium}/${sys.ranges.long} m` : ""; + } + if (this.type === "device") { + sys.functional = (Number(sys.integrity?.value) || 0) >= (Number(sys.integrity?.max) || 0); + } + } +} + +/* -------------------------------------------- */ +/* Rolling */ +/* -------------------------------------------- */ + +const LEVEL_LABEL = { + critical: "RINGBRP.Result.Critical", + special: "RINGBRP.Result.Special", + success: "RINGBRP.Result.Success", + failure: "RINGBRP.Result.Failure", + fumble: "RINGBRP.Result.Fumble" +}; + +/** + * What the agent's kit is worth to a given skill. Gear carries `skillBonus`, which + * until now was populated and read by nothing. Only carried gear counts, and the + * best single item applies — bonuses do not stack, or a pack full of medical kits + * would make anyone a surgeon. + */ +/** + * Kit and training are separate sources and they STACK. Within each source only + * the best single item applies — a pack full of medical kits does not make anyone + * a surgeon, and two talents that do the same thing are not additive — but a + * medical kit and Triage are different things and should both count. + * + * This function used to iterate every embedded item, which silently swept talents + * into the gear pool and collapsed the two into one bonus. + */ +export function gearBonusFor(actor, skillFamilyId) { + if (!actor || !skillFamilyId) return { value: 0, source: "" }; + const bestOf = pred => { + let best = { value: 0, source: "" }; + for (const it of actor.items ?? []) { + if (!pred(it)) continue; + if (it.system?.carried === false) continue; + const b = it.system?.skillBonus; + if (!b || b.skillFamilyId !== skillFamilyId) continue; + const v = Number(b.value) || 0; + if (v > best.value) best = { value: v, source: it.name }; + } + return best; + }; + const kit = bestOf(i => i.type !== "talent"); + const training = bestOf(i => i.type === "talent"); + const parts = [kit, training].filter(x => x.value); + return { + value: kit.value + training.value, + source: parts.map(x => x.source).join(" + "), + kit, training + }; +} + +/** + * Core skill roll. Everything in the system funnels through here so that the + * band arithmetic exists in exactly one place. + */ +export async function skillRoll({ + actor, label, rating, difficulty = "average", situational = 0, printedBase = 0, + flavourLines = [], speaker = null, categoryId = "", skipCoherence = false, + skillItem = null, skillFamilyId = "", weapon = null +} = {}) { + // Gear bonuses are a rule, not a judgement call, so they sit outside the + // +-40% circumstance cap alongside encumbrance and coherence. + const _fam = skillFamilyId || skillItem?.system?.familyId || ""; + // Internal callers — case phases, First Aid, Medicine, defences, the crossing — + // pass a family and no item, so the experience tick below could never fire for + // them: the skills the job most uses were the only ones that never improved. + // Resolve the item from the family when the caller did not hand one over. + if (!skillItem && actor && _fam) { + skillItem = actor.items.find(i => i.type === "skill" && i.system?.familyId === _fam) ?? null; + } + const _gear = gearBonusFor(actor, _fam); + if (_gear.value) { + situational = (Number(situational) || 0) + _gear.value; + flavourLines = [...flavourLines, game.i18n.format("RINGBRP.Flavour.Gear", + { item: _gear.source, mod: _gear.value })]; + } + // Coherence is applied HERE, at the single choke point, so that attacks, + // characteristic rolls, Luck, Idea and case phases are all covered. Applying it + // per-caller meant an Unmoored agent was somehow still a perfect shot. + // Wounds: a useless arm really does cost you the shot. + if (actor && categoryId) { + const wound = woundPenaltyFor(actor, categoryId); + if (wound) { + situational = (Number(situational) || 0) + wound; + flavourLines = [...flavourLines, game.i18n.format("RINGBRP.Flavour.Wounded", { mod: wound })]; + } + } + // Encumbrance. Computed since R-23 and applied by nothing until now — Rakhi was + // 20 kg over her free carry, carrying a printed -10%, and rolling at full. + if (actor && categoryId) { + const enc = encumbrancePenaltyFor(actor.system?.encumbrance?.skillPenalty, categoryId); + if (enc) { + situational = (Number(situational) || 0) + enc; + flavourLines = [...flavourLines, game.i18n.format("RINGBRP.Flavour.Encumbered", { mod: enc })]; + } + } + if (actor && !skipCoherence) { + const coh = coherencePenaltyFor(actor.system?.coherence?.value, categoryId); + if (coh) { + situational = (Number(situational) || 0) + coh; + flavourLines = [...flavourLines, game.i18n.format("RINGBRP.Flavour.Incoherent", + { mod: coh, band: game.i18n.localize(coherenceBand(actor.system.coherence.value).label) })]; + } + } + if (difficulty === "impossible") { + ui.notifications?.warn(game.i18n.localize("RINGBRP.Notify.Impossible")); + return null; + } + if (difficulty === "automatic") { + ui.notifications?.info(game.i18n.localize("RINGBRP.Notify.Automatic")); + return { roll: null, level: "success", automatic: true }; + } + const bands = resolveBands(rating, { difficulty, situational, printedBase }); + const roll = await new Roll("1d100").evaluate(); + const level = gradeRoll(roll.total, bands); + + const content = await rbRenderTemplate("systems/ringbrp/templates/chat-roll.hbs", + { + label, + rating, + difficulty, + difficultyLabel: game.i18n.localize(RINGBRP.difficulties[difficulty] ?? ""), + situational, + bands, + roll: roll.total, + level, + levelLabel: game.i18n.localize(LEVEL_LABEL[level]), + faces: percentileFaces(roll.total), + flavourLines, + // A hit offers its own damage roll. The roll used to end at "Success" and + // leave the player to find the weapon on the sheet and click it separately, + // which also lost the success level — so criticals were rolled as ordinary hits. + damage: (weapon && ["critical", "special", "success"].includes(level)) + ? { label: weapon.system?.damage?.mode === "byBand" + ? game.i18n.localize("RINGBRP.Chat.DamageByBand") + : (weapon.system?.damage?.formula || "1d6"), + critical: level === "critical", special: level === "special" } + : null + } + ); + + rbDressDice(roll, actor); + await roll.toMessage({ + speaker: speaker ?? ChatMessage.getSpeaker({ actor }), + content, + flags: { ringbrp: { level, bands, label, + weaponId: weapon?.id ?? null, actorId: actor?.id ?? null, + tokenId: actor?.token?.id ?? null } } + }, { rollMode: game.settings.get("core", "rollMode") }); + + // BRP experience: a skill used SUCCESSFULLY under pressure earns a tick. This + // was stored on every skill and set by nothing, so ticks never appeared and the + // end-of-case improvement roll had nothing to work on. + if (skillItem && ["critical", "special", "success"].includes(level) + && skillItem.system?.experienceEligible !== false && !skillItem.system?.experienceTicked) { + await skillItem.update({ "system.experienceTicked": true }); + } + + // F5: Coherence is charged by the system, not remembered by the GM. A fumble + // while the team is across is the commonest cost in play, so it fires here. + if (level === "fumble" && actor && isAcross()) { + await loseCoherence(actor, "fumbleOnCrossing", { note: label }); + } + return { roll: roll.total, level, bands }; +} + +/** Spec §3.3. Rolls the Major Wound consequence and applies weapon shifts. */ +export async function majorWoundRoll({ actor, woundType = "blunt", monofilament = false, blunt = false } = {}) { + // Called positionally by mistake, this rolled and applied a Major Wound to + // nobody. Same class of fault as R-209: fail loudly rather than quietly. + if (!actor) { + console.error("Custodians | majorWoundRoll called without an actor"); + return null; + } + const r = rbDressDice(await new Roll("1d10").evaluate(), actor); + let result = r.total; + if (monofilament && result >= 4) result = 10; // spec §3.3 shift + else if (blunt && result === 10) result = 9; + const entry = RINGBRP.majorWoundTable.find(e => result >= e.min && result <= e.max); + const key = entry?.key ?? "concussed"; + const lostStat = RINGBRP.woundTypeMap[woundType] ?? "con"; + + // Two rows of the table split 1D2. That roll used to be left to the GM's memory + // because nothing consumed it; now the penalty depends on it, so it is rolled + // here and stored on the wound. + let variant = null; + if (key === "sensory" || key === "broken") { + const v = await new Roll("1d2").evaluate(); + variant = key === "sensory" ? (v.total === 1 ? "sight" : "hearing") + : (v.total === 1 ? "manipulation" : "mobility"); + } + + const content = await rbRenderTemplate("systems/ringbrp/templates/chat-wound.hbs", + { + raw: r.total, + result, + shifted: result !== r.total, + key, + title: game.i18n.localize(`RINGBRP.Wound.${key}.title`), + text: game.i18n.localize(`RINGBRP.Wound.${key}.text`), + variant, + variantLabel: variant ? game.i18n.localize(`RINGBRP.Wound.variant.${variant}`) : "", + losesCharacteristic: ["crippling", "catastrophic"].includes(key), + lostStat: game.i18n.localize(`RINGBRP.Char.${lostStat}`), + lostAmount: key === "catastrophic" ? "1d6" : "1d3" + } + ); + await r.toMessage({ speaker: ChatMessage.getSpeaker({ actor }), content }); + + if (actor && ["crippling", "catastrophic", "broken", "bleeding", "deepBleeding", + "manipulation", "mobility", "sensory", "concussed", "winded"].includes(key)) { + const wounds = foundry.utils.duplicate(actor.system.wounds ?? []); + wounds.push({ key, variant, stat: lostStat, staminaChecks: 0, id: foundry.utils.randomID() }); + await actor.update({ "system.wounds": wounds }); + } + return { result, key, variant }; +} + +/** + * Spec §3.6. Roll 1D20 for a location on the target's species table and + * apply the damage there. Reports disabled/destroyed and the kind-specific + * consequence, which is why a Cadence losing a body is not a Vesh losing its grasp limb. + */ +export async function hitLocationRoll({ target, damage, mode = "ranged", + level = "success", armourApplied = false } = {}) { + if (!target) return null; + // R-209. `Number(undefined) || 0` meant a caller who passed a bad damage value + // got a hit that landed, printed a card, and took nothing off — a six-round + // fight once ran with fourteen connecting blows and no casualties, and nothing + // anywhere said a word. Damage must be a real number or this is a bug upstream. + if (!Number.isFinite(Number(damage))) { + console.error("Custodians | hitLocationRoll got non-numeric damage:", damage); + ui.notifications?.error(game.i18n.localize("RINGBRP.Notify.BadDamage")); + return null; + } + const sys = target.system; + const speciesId = sys.speciesProfile || "baseline"; + const r = rbDressDice(await new Roll("1d20").evaluate(), target); + 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); + // Armour was applied on the resolveHit path and skipped entirely here, so the + // same blow did more damage when it went through the location roll. Location + // armour stacks on the body's, and a critical still bypasses (armourAgainst). + let effective = Number(damage) || 0; + if (!armourApplied) { + const worn = Number(sys.armourPoints) || 0; + const locArmour = Number(loc.armour) || 0; + effective = Math.max(0, effective - armourAgainst(level, worn + locArmour)); + } + const res = resolveLocationHit({ + damage: effective, locationMax: max, locationTaken: live?.taken ?? 0, + majorWoundThreshold: sys.majorWound?.value + }); + const effects = RINGBRP.locationEffects[loc.kind] ?? RINGBRP.locationEffects.body; + const effectKey = res.destroyed ? effects.destroyed : (res.disabled ? effects.disabled : null); + + const content = await rbRenderTemplate("systems/ringbrp/templates/chat-location.hbs", { + roll: r.total, mode, + locationLabel: game.i18n.localize(loc.label), + speciesLabel: game.i18n.localize(RINGBRP.locationTables[speciesId]?.label ?? ""), + damage: res.damage, max, taken: res.locationTaken, + disabled: res.disabled, destroyed: res.destroyed, majorWound: res.majorWound, + effect: effectKey ? game.i18n.localize(effectKey) : null + }); + await r.toMessage({ speaker: ChatMessage.getSpeaker({ actor: target }), content }); + + const dmg = foundry.utils.duplicate(sys.locationDamage ?? {}); + dmg[loc.id] = res.locationTaken; + await target.update({ + "system.locationDamage": dmg, + "system.hp.value": Math.max(0, (sys.hp?.value ?? 0) - res.damage) + }); + return { location: loc, ...res }; +} + +/* -------------------------------------------- */ +/* Reaction — rolling and ordering */ +/* -------------------------------------------- */ + +/** + * Score a combatant: current base + d4, with the die read through Coherence Flux. + * Modifiers live on the combatant as flags so they survive a reload and so the + * GM can set them once and leave them. + */ +export async function rollReaction(combatant) { + const actor = combatant?.actor; + if (!actor) return null; + const f = combatant.flags?.ringbrp ?? {}; + const rb = reactionBase(actor); + // Load is already inside rb.printed. An override replaces it rather than + // stacking, so the GM can wave off a pack without the kit being charged twice. + const load = f.loadOverride ?? rb.load; + const printed = f.loadOverride === undefined + ? rb.printed + : 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 raw = roll.total; + const coherence = actor.system?.coherence?.value ?? 10; + const shown = fluxDie(raw, coherence); + const misfiled = shown !== raw; + + // The die is surfaced by the pooled Roll on the chat card, which animates every + // combatant's dice together. Awaiting one animation per combatant here made + // rolling for a group crawl, so it is deliberately not done at this level. + + return { + roll, + total: Math.round((printed + mod + shown) * 10) / 10, + base: rb.base, printed, mod, die: shown, rawDie: raw, misfiled, + lane: rb.lane, from: rb.from, load, aim: f.aim ?? 0, + edge: !!f.edge, drag: !!f.drag, coherence + }; +} + +class RingBRPCombat extends Combat { + /** + * Highest acts first, which is Foundry's default direction — the old system + * needed an inverted comparator and this one does not. Ties: allies choose + * between themselves, opposed combatants are simultaneous, so the fallback is + * only for a stable display order. + */ + _sortCombatants(a, b) { + const x = Number(a.initiative), y = Number(b.initiative); + const ax = Number.isFinite(x), by = Number.isFinite(y); + if (ax && by && x !== y) return y - x; + if (ax !== by) return ax ? -1 : 1; + const dex = c => Number(c.actor?.system?.characteristics?.dex?.value) || 0; + if (dex(a) !== dex(b)) return dex(b) - dex(a); + const list = a.parent?.combatants?.contents ?? b.parent?.combatants?.contents; + return list ? list.indexOf(a) - list.indexOf(b) : 0; + } + + /** + * R-215. Reaction is re-rolled every round — except it never rolled for the + * FIRST one. Foundry does not roll initiative when a combat starts; it waits for + * the GM to press Roll All. So round one ran with every initiative null and the + * comparator falling through to its DEX tiebreak, which meant the opening round + * of every fight — the one where the shooting starts — was the single round the + * Reaction system did not order. Roll it as the fight begins. + */ + async startCombat() { + await super.startCombat(); + const active = this.combatants.filter(c => { + const a = c.actor; if (!a) return false; + return (Number(a.system?.hp?.value) || 0) > 0 && !a.system?.wounded?.unconscious; + }).map(c => c.id); + if (active.length) await this.rollInitiative(active); + return this; + } + + /** One d4 per combatant, one addition. No per-weapon arithmetic. */ + async rollInitiative(ids, { updateTurn = true, messageOptions = {} } = {}) { + ids = typeof ids === "string" ? [ids] : ids; + const updates = []; + const lines = []; + for (const id of ids) { + const c = this.combatants.get(id); + if (!c) continue; + const r = await rollReaction(c); + if (!r) continue; + updates.push({ _id: id, initiative: r.total }); + await c.update({ "flags.ringbrp.reaction": r }); + lines.push({ name: c.name, ...r }); + } + if (updates.length) await this.updateEmbeddedDocuments("Combatant", updates); + if (lines.length) { + // A pooled Roll so the dice are real objects in the message: Dice So Nice + // animates them and the chat card shows the faces. + const pool = Roll.fromTerms([ + foundry.dice.terms.PoolTerm.fromRolls(lines.map(l => l.roll).filter(Boolean)) + ]); + await ChatMessage.create({ + speaker: { alias: game.i18n.localize("RINGBRP.React.Title") }, + content: await rbRenderTemplate("systems/ringbrp/templates/chat-reaction.hbs", + { lines: lines.sort((a, b) => b.total - a.total), die: RINGBRP.reaction.die }), + rolls: [pool], sound: CONFIG.sounds.dice + }); + } + if (updateTurn && this.turns?.length) await this.update({ turn: 0 }); + return this; + } + + /** + * Reaction is re-rolled EVERY round. Foundry keeps initiative across rounds by + * default, which drew the d6 once per fight and froze the order — and that made + * the whole system inert: the Aim rail could not matter, because "aim one round, + * fire the next" needs the order to recompute; the 35.7% random share was tuned + * as round-to-round variance and became a single draw deciding the engagement; + * and load, wounds and coherence all change mid-fight and were never re-read. + * + * The re-roll happens AFTER super.nextRound() so that end-of-round blood loss has + * already been charged — an agent who bleeds out does not get a fresh initiative. + */ + async nextRound() { + await super.nextRound(); + if (!game.user.isGM || this.round <= 1) return this; + await this.resetAll(); + // Only those still in the fight are re-rolled. The downed keep a null + // initiative, which the comparator already sorts to the bottom, so a corpse + // stops drawing a d6 every round and stops appearing above the living. + const active = this.combatants.filter(c => { + const a = c.actor; if (!a) return false; + return (Number(a.system?.hp?.value) || 0) > 0 && !a.system?.wounded?.unconscious; + }).map(c => c.id); + if (active.length) await this.rollInitiative(active); + // Aim is spent. It buys position for exactly one round — the round after the + // one you spent aiming — so it is consumed by the roll it paid for rather than + // granting +2 for the rest of the fight. Edge and Drag persist: they describe + // equipment and conditions, which do not evaporate at the round boundary. + const spent = this.combatants.filter(c => c.flags?.ringbrp?.aim); + for (const c of spent) await c.update({ "flags.ringbrp.aim": 0 }); + return this; + } +} + +/** + * Set a combatant's Reaction modifiers. This is the digital equivalent of moving + * the paperclip along the printed rail: Aim, Edge, Drag and Load, capped at +-3. + */ +export async function setReaction(combatant) { + const actor = combatant?.actor; + if (!actor) return; + const f = combatant.flags?.ringbrp ?? {}; + const rb = reactionBase(actor); + const load = f.loadOverride ?? rb.load; + const printed = f.loadOverride === undefined + ? rb.printed + : reactionPrintedFrom(rb.base, f.loadOverride); + const content = await rbRenderTemplate("systems/ringbrp/templates/dialog-reaction.hbs", { + name: combatant.name, base: rb.base, printed, lane: rb.lane, from: rb.from, + aim: f.aim ?? 0, edge: !!f.edge, drag: !!f.drag, load, + coherence: actor.system?.coherence?.value ?? 10, + rail: Array.from({ length: 7 }, (_, i) => ({ + mod: i - 3, value: Math.round((printed + (i - 3)) * 10) / 10 + })) + }); + return new Promise(resolve => { + new (rbDialogClass())({ + title: game.i18n.format("RINGBRP.React.SetFor", { name: combatant.name }), + content, + buttons: { + ok: { + label: game.i18n.localize("RINGBRP.React.Set"), + callback: async html => { + const el = html[0] ?? html; + await combatant.update({ + "flags.ringbrp.aim": Number(el.querySelector("[name=aim]").value) || 0, + "flags.ringbrp.edge": el.querySelector("[name=edge]").checked, + "flags.ringbrp.drag": el.querySelector("[name=drag]").checked + }); + if (combatant.combat) await combatant.combat.rollInitiative([combatant.id], { updateTurn: false }); + resolve(true); + } + }, + cancel: { label: game.i18n.localize("RINGBRP.Dialog.Cancel"), callback: () => resolve(false) } + }, + default: "ok", close: () => resolve(false) + }).render(true); + }); +} + +/* -------------------------------------------- */ +/* Coherence — the agent's grip on their own reality */ +/* -------------------------------------------- */ + +/** + * An agent who works the crossing does not come back unchanged. Coherence measures + * how firmly they are still bound to the reality they were born in. It falls when + * they break the rules of a place they do not belong in, and it comes back slowly, + * at home, with people who remember them. + * + * Ten is anchored. Zero is displaced, and displacement is permanent. + */ +RINGBRP.coherenceBands = COHERENCE_BANDS; + +/** + * What costs coherence. These are the things that mark an agent as not-from-here. + * A cost is charged once per occurrence; the GM is the arbiter of occurrence. + */ +RINGBRP.coherenceCosts = COHERENCE_COSTS; + +/** Recovery only happens at home, and never faster than one step per debrief. */ +/** + * F6: the case-file tracks. The stored field names are the original ones so that + * existing actors keep working, but everything user-facing reads as a case. + */ +RINGBRP.caseTracks = { + supply: { label: "RINGBRP.Case.Resources", key: "resources" }, + power: { label: "RINGBRP.Case.Clearance", key: "clearance" }, + integrity: { label: "RINGBRP.Case.Containment", key: "containment" } +}; + +RINGBRP.coherenceRecovery = COHERENCE_RECOVERY; + +export const coherenceBand = coherenceBandFrom; + +/** The penalty coherence imposes on a given skill category. */ +/** What a wounded body costs a given skill category. */ +/** + * The whole wound penalty for one skill category. `all` comes from the Major Wound + * table (concussion is "-20% to all rolls") and applies everywhere; the specific + * numbers add on top of it. Ranged and melee read the manipulation figure because + * both are done with the hands, which is how the location wounds already worked. + */ +export function woundPenaltyFor(actor, categoryId) { + const w = actor?.system?.wounded; + if (!w) return 0; + let pen = Number(w.all) || 0; + if (["manip", "melee", "ranged"].includes(categoryId)) pen += Number(w.manipulation) || 0; + if (categoryId === "phys") pen += Number(w.physical) || 0; + if (categoryId === "percep") pen += Number(w.perception) || 0; + if (categoryId === "comm") pen += Number(w.communication) || 0; + return pen; +} + +export function coherencePenaltyFor(value, categoryId) { + const b = coherenceBand(value); + return categoryId === "comm" ? b.comm : b.all; +} + +/** + * Charge a coherence cost and announce it. Returns the new band so a caller can + * react when an agent crosses a threshold. + */ +export async function loseCoherence(actor, reasonKey, { amount = null, note = "" } = {}) { + if (!actor) return null; + const spec = RINGBRP.coherenceCosts[reasonKey]; + const cost = amount ?? spec?.cost ?? 1; + const before = Number(actor.system.coherence?.value ?? 10); + const after = Math.max(0, before - cost); + const drift = [...(actor.system.coherence?.drift ?? []), + { reason: reasonKey, cost, note, at: actor.system.coherence?.drift?.length ?? 0 }]; + await actor.update({ "system.coherence.value": after, "system.coherence.drift": drift }); + const bBefore = coherenceBand(before), bAfter = coherenceBand(after); + if (after === 0 && before > 0) { await displace(actor); } + await ChatMessage.create({ + speaker: ChatMessage.getSpeaker({ actor }), + content: await rbRenderTemplate("systems/ringbrp/templates/chat-coherence.hbs", { + actorName: actor.name, before, after, cost, + reason: game.i18n.localize(spec?.label ?? reasonKey), note, + band: bAfter, crossed: bBefore.id !== bAfter.id, + pips: Array.from({ length: Number(actor.system.coherence?.max ?? 10) }, (_, i) => i < after) + }) + }); + return bAfter; +} + +/** + * 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 + * right, one true thing changed, and that becomes their new baseline. + * Recovery from zero is therefore impossible: you displace instead. + */ +export async function displace(actor) { + if (!actor) return null; + const c = actor.system.coherence ?? {}; + const max = Number(c.max ?? 10); + const record = { + displacedOn: (Number(c.displacements) || 0) + 1, + drift: [...(c.drift ?? [])] + }; + await actor.update({ + "system.coherence.value": max, + "system.coherence.drift": [], + "system.coherence.displacements": record.displacedOn, + "system.coherence.history": [...(c.history ?? []), record] + }); + await ChatMessage.create({ + speaker: ChatMessage.getSpeaker({ actor }), + content: `
+

${game.i18n.localize("RINGBRP.Coh.Displaced")}

+

${game.i18n.format("RINGBRP.Coh.DisplacedChat", { name: actor.name })}

+

${game.i18n.localize("RINGBRP.Coh.DisplacedGM")}

+
`, + whisper: ChatMessage.getWhisperRecipients("GM").map(u => u.id) + }); + return coherenceBand(max); +} + +/** Recovery happens at home, and never from zero — zero displaces instead. */ +export async function regainCoherence(actor, amount = 1) { + if (!actor) return null; + const cur = Number(actor.system.coherence?.value) || 0; + if (cur <= 0) { + ui.notifications?.warn(game.i18n.localize("RINGBRP.Notify.CannotRecoverFromZero")); + return coherenceBand(0); + } + const max = Number(actor.system.coherence?.max ?? 10); + const after = Math.min(max, cur + amount); + await actor.update({ "system.coherence.value": after }); + return coherenceBand(after); +} + +/** + * Resolve a case file's members to world actors. `members` has been three shapes + * over this project's life — objects, raw ids, and now stable keys — and raw ids + * do not survive a compendium import at all, which made the shipped case file + * look populated while resolving to nothing. Accept all three, prefer the key. + */ +export function caseMembers(caseFile) { + const raw = caseFile?.system?.members ?? []; + const out = []; + for (const entry of raw) { + const key = typeof entry === "string" ? entry : (entry?.key ?? entry?.actorId); + if (!key) continue; + const found = game.actors.get(key) + ?? game.actors.find(a => a.getFlag?.("ringbrp", "key") === key) + ?? game.actors.find(a => a.name === key); + if (found) out.push(found); + } + return out; +} + +/** + * Is the team currently across? Coherence costs only apply on the far side, so + * the system needs to know. Held on the active case file so one GM toggle covers + * the whole team rather than each agent tracking it separately. + */ +export function activeCaseFile() { + return game.actors?.find(a => a.type === "expedition" && a.system?.across !== undefined + && a.getFlag?.("ringbrp", "active")) + ?? game.actors?.find(a => a.type === "expedition"); +} +export function isAcross() { return !!activeCaseFile()?.system?.across; } +export async function setAcross(state) { + const cf = activeCaseFile(); + if (!cf) return null; + await cf.update({ "system.across": !!state }); + ui.notifications?.info(game.i18n.localize(state ? "RINGBRP.Case.NowAcross" : "RINGBRP.Case.NowHome")); + return !!state; +} + +/** + * F5: charge the Coherence cost printed on an item. Every piece of transposed + * materiel carries one, and this is what makes "the tool that solves the case is + * the tool that costs you" actually true in play rather than in the bible. + */ +export async function useTransposed(actor, item) { + let cost = Number(item?.system?.coherenceCost ?? item?.system?.coherence) || 0; + if (!actor || !cost) return null; + // Chain of Custody: HALVED, rounding up, never to zero. A flat -1 made every + // 1-cost item free and removed the setting's central tension for that agent; + // halving keeps cheap things costing and makes expensive things bearable. + if (hasTalent(actor, "chain_custody")) { + const before = cost; + cost = Math.max(1, Math.ceil(cost / 2)); + if (cost < before) ui.notifications?.info( + game.i18n.format("RINGBRP.Talent.Halved", { name: item.name, from: before, to: cost })); + } + return loseCoherence(actor, "usedTransposed", { amount: cost, note: item.name }); +} + +/** + * F5: debrief. Recovery is only available at home and only once per debrief, so + * it is a single operation rather than three fiddly ones. + */ +export async function debrief(actor, { downtimeWeeks = 0, withAnchor = false } = {}) { + if (!actor) return null; + if (isAcross()) { + ui.notifications?.warn(game.i18n.localize("RINGBRP.Notify.DebriefAcross")); + return null; + } + // Debriefer: someone else on the team gets you an extra point back. + const debriefer = (activeCaseFile() ? caseMembers(activeCaseFile()) : []) + .find(a => a.id !== actor.id && hasTalent(a, "debriefer")); + let gain = RINGBRP.coherenceRecovery.debrief + (debriefer ? 1 : 0); + gain += Math.max(0, Number(downtimeWeeks) || 0) * RINGBRP.coherenceRecovery.downtimeWeek; + if (withAnchor && actor.items.some(i => i.system?.isAnchor || /anchor/i.test(i.name))) { + gain += RINGBRP.coherenceRecovery.anchorObject; + } + return regainCoherence(actor, gain); +} + +/** + * R-56, the critical-path invariant: nothing on the critical path may be made + * unreachable by a result. An Unmoored agent used to be told they "cannot open a + * path home unaided", which is a gate whenever no other agent is available. + * + * The way home is therefore ALWAYS available. Being Unmoored makes it dearer, not + * impossible: another agent may aim the return for you at no penalty, and alone + * you may still aim it at -30%. A failed return does not strand you either — it + * lands you near your target rather than at it, which is a complication the GM + * narrates, never a dead end. + */ +export function returnCrossing(actor, { aimedByAnother = false } = {}) { + const band = coherenceBand(actor?.system?.coherence?.value); + // The classification's own penalty. A generated brief PRINTS this as a mechanical + // promise — "return modifier for the classification: -20%" — and nothing applied + // it, so a Wrong world was no harder to leave than a Near one. Aiming for someone + // else removes the Coherence penalty; it does not make the world less strange. + const world = Number(activeCaseFile()?.system?.crossingModifier) || 0; + const penalty = (aimedByAnother ? 0 : (band.returnPenalty ?? 0)) + world; + return { + possible: true, // always. This is the invariant. + penalty, worldPenalty: world, + aimedByAnother, + band, + note: aimedByAnother ? "RINGBRP.Coh.ReturnAided" + : penalty ? "RINGBRP.Coh.ReturnAlone" : "RINGBRP.Coh.ReturnClean", + onFailure: "RINGBRP.Coh.ReturnNear" + }; +} + +/** + * The device gate, enforced. Codex flagged twice that `requiredDiscoveryIds` was + * read by nothing, so a locked function could be operated on turn one — and that + * the discovery item ships WITH the case file, so its presence cannot mean it has + * been earned. Both are fixed here: a discovery counts only when `earned`. + */ +export function earnedDiscoveries(scope) { + const out = new Set(); + for (const actor of [scope, ...(game.actors ?? [])].filter(Boolean)) { + if (actor.type !== "expedition") continue; + for (const it of actor.items ?? []) { + if (it.type === "discovery" && it.system?.earned) out.add(it.system.discoveryId); + } + } + return out; +} + +/** Why a device function cannot be used right now, or null if it can. */ +export function deviceFunctionStatus(device, functionId) { + const f = device?.system?.functions?.[functionId]; + if (!f) return { usable: false, reason: "RINGBRP.Device.NoSuchFunction" }; + const earned = earnedDiscoveries(); + const missing = (f.requiredDiscoveryIds ?? []).filter(id => !earned.has(id)); + if (missing.length) return { usable: false, reason: "RINGBRP.Device.Locked", missing }; + const integ = Number(device.system?.integrity?.value) || 0; + if (integ < (Number(f.requiresIntegrity) || 0)) + return { usable: false, reason: "RINGBRP.Device.Damaged", + need: f.requiresIntegrity, have: integ }; + const cf = activeCaseFile(); + const power = Number(cf?.system?.power?.value ?? 0); + if ((Number(f.powerCost) || 0) > power) + return { usable: false, reason: "RINGBRP.Device.Unpowered", need: f.powerCost, have: power }; + return { usable: true, reason: "RINGBRP.Device.Ready" }; +} + +/** + * Resolve a device function: check the gate, roll the governing skill, charge the + * Clearance and Coherence it costs, and apply the outcome. This is the operation + * the whole case turns on and it existed only as a status check until now. + */ +export async function useDeviceFunction(actor, device, functionId) { + const f = device?.system?.functions?.[functionId]; + if (!f) return null; + const st = deviceFunctionStatus(device, functionId); + if (!st.usable) { + ui.notifications?.warn(game.i18n.format(st.reason, { need: st.need, have: st.have })); + return null; + } + const cf = activeCaseFile(); + const earned = earnedDiscoveries(); + const post = (f.requiredDiscoveryIds ?? []).every(id => earned.has(id)) + && (f.requiredDiscoveryIds ?? []).length > 0; + + const rating = actor.skillRating(f.skillFamilyId, f.skillSpecialisationId) ?? 0; + const familiarity = post ? (Number(f.familiarityAfterDiscovery) || 0) : 0; + const difficulty = (post && f.difficultyAfterDiscovery) ? f.difficultyAfterDiscovery : f.difficulty; + + const res = await skillRoll({ + actor, label: `${device.name} — ${game.i18n.localize(`RINGBRP.Device.Fn.${functionId}`)}`, + rating, printedBase: 15, difficulty, + situational: familiarity, + skillFamilyId: f.skillFamilyId, + flavourLines: familiarity ? [game.i18n.format("RINGBRP.Device.Familiarity", { mod: familiarity })] : [] + }); + if (!res) return null; + + const ok = ["critical", "special", "success"].includes(res.level); + // Clearance: the printed cost on success, the failure cost otherwise. + // `!= null` deliberately, not `!== undefined`: the data model fills absent keys + // with null, so an undefined-check silently read a real cost of 4 as 0. + const cost = ok ? (post && f.powerCostAfterDiscovery != null + ? Number(f.powerCostAfterDiscovery) : Number(f.powerCost) || 0) + : (res.level === "fumble" + ? Number(f.powerCostOnFumble ?? f.powerCostOnFailure) || 0 + : Number(f.powerCostOnFailure) || 0); + if (cf && cost) { + await cf.update({ "system.power.value": Math.max(0, (cf.system.power.value || 0) - cost) }); + } + if (Number(f.coherenceCost)) { + await loseCoherence(actor, "usedTransposed", { amount: Number(f.coherenceCost), note: device.name }); + } + // Repair moves Integrity; a fumble takes one back. + if (functionId === "repair") { + const cur = Number(device.system.integrity.value) || 0; + const max = Number(device.system.integrity.max) || 3; + const next = res.level === "fumble" ? Math.max(0, cur - 1) : (ok ? Math.min(max, cur + 1) : cur); + if (next !== cur) await device.update({ "system.integrity.value": next }); + } + const key = ok ? "effect" : (res.level === "fumble" ? "onFumble" : "onFailure"); + await ChatMessage.create({ + speaker: ChatMessage.getSpeaker({ actor }), + content: `

${device.name}

+

${f[key] ?? ""}

${cost ? `

${ + game.i18n.format("RINGBRP.Device.Spent", { n: cost })}

` : ""}
` + }); + return { ...res, cost }; +} + +/** Mark a discovery earned. This is the moment the case turns. */ +export async function earnDiscovery(caseFile, discoveryId) { + const it = caseFile?.items?.find(i => i.type === "discovery" && i.system.discoveryId === discoveryId); + if (!it) return null; + await it.update({ "system.earned": true }); + await ChatMessage.create({ + speaker: ChatMessage.getSpeaker({ actor: caseFile }), + content: `

${ + game.i18n.localize("RINGBRP.Device.DiscoveryEarned")}

${it.name}

` + }); + return it; +} + +/* -------------------------------------------- */ +/* First contact */ +/* -------------------------------------------- */ + +/** + * The social pillar had data and no engine: standings, impression, taboos and + * desires were all stored and nothing could ever change them. That also stranded + * the R-57 privacy fix, which hides a group's secrets until the team has LEARNED + * them — with no way to learn, players would have seen "nothing learned" forever. + * + * Three stages, in order. READ discovers what the group is; SIGNAL establishes + * that you are talking at all; OFFER is the gift, and only OFFER moves standing. + */ +RINGBRP.contactStages = { + read: { label: "RINGBRP.Contact.Read", skills: ["insight", "anthropology", "xenology"] }, + signal: { label: "RINGBRP.Contact.Signal", skills: ["language", "persuade", "fast_talk"] }, + offer: { label: "RINGBRP.Contact.Offer", skills: ["bargain", "persuade", "insight"] } +}; + +/** Impression change per stage result. Only OFFER can move standing. */ +RINGBRP.contactOutcomes = { + critical: { read: 2, signal: 1, offer: 3 }, + special: { read: 1, signal: 1, offer: 2 }, + success: { read: 1, signal: 0, offer: 2 }, + failure: { read: 0, signal: 0, offer: 0 }, + fumble: { read: 0, signal: -1, offer: -2 } +}; + +function standingFor(caseFile, groupId) { + return (caseFile.system.standings ?? []).find(s => s.groupId === groupId) ?? null; +} + +/** + * A group you have never met has no standing entry, and `standingFor` returned null, + * and `resolveContact` bailed out on null — so FIRST contact with a new group was + * the one thing first contact could not do. The entry is created on demand. + */ +async function ensureStanding(caseFile, groupId) { + const found = standingFor(caseFile, groupId); + if (found) return found; + const fresh = { groupId, value: 0, impression: 0, knownTaboos: [], knownDesires: [], + pidginEstablished: false, offerMadeThisEncounter: false }; + await caseFile.update({ "system.standings": [...(caseFile.system.standings ?? []), fresh] }); + return standingFor(caseFile, groupId); +} + +/** + * Who talks? The GM chooses, but the list is ordered by who is actually best at + * this stage and shows the rating, so the obvious choice is the top one and the + * interesting choice is still available. + */ +async function pickContactActor(caseFile, stage) { + const spec = RINGBRP.contactStages[stage]; + const roster = caseMembers(caseFile); + if (!roster.length) { + ui.notifications?.warn(game.i18n.localize("RINGBRP.Notify.NoNavigator")); + return null; + } + const rated = roster.map(a => { + let best = 0, fam = ""; + for (const f of spec.skills) { + const r = a.skillRating(f); + if (r !== null && r > best) { best = r; fam = f; } + } + return { actor: a, rating: best, fam }; + }).sort((x, y) => y.rating - x.rating); + + const content = `
+ +
+

${game.i18n.localize(spec.label)}

`; + + return new Promise(resolve => { + new (rbDialogClass())({ + title: game.i18n.localize(spec.label), + content, + buttons: { + ok: { label: game.i18n.localize("RINGBRP.Dialog.RollButton"), + callback: html => { + const el = html[0] ?? html; + resolve(game.actors.get(el.querySelector("[name=who]").value) ?? null); + } }, + cancel: { label: game.i18n.localize("RINGBRP.Dialog.Cancel"), callback: () => resolve(null) } + }, + default: "ok", close: () => resolve(null) + }).render(true); + }); +} + +/** + * Resolve one stage of contact. Returns the roll result plus what the team + * learned, and writes both back to the case file. + */ +export async function resolveContact(caseFile, groupId, stage, actor, { circumstance = "neutral" } = {}) { + const spec = RINGBRP.contactStages[stage]; + const group = caseFile.items.find(i => i.type === "group" && i.system.groupId === groupId); + if (!spec || !group) return null; + const st = await ensureStanding(caseFile, groupId); + if (!st) return null; + + // best applicable skill the actor actually has + let rating = 0, used = ""; + for (const fam of spec.skills) { + const r = actor.skillRating(fam); + if (r !== null && r > rating) { rating = r; used = fam; } + } + + // R-44: one OFFER per encounter. Impression cannot be farmed. + if (stage === "offer" && st.offerMadeThisEncounter) { + ui.notifications?.warn(game.i18n.localize("RINGBRP.Contact.OfferSpent")); + return null; + } + + const res = await skillRoll({ + actor, label: game.i18n.format("RINGBRP.Contact.Roll", + { stage: game.i18n.localize(spec.label), group: group.name }), + rating, printedBase: 5, categoryId: "comm", + situational: circumstanceTotal([circumstance]) + }); + if (!res) return null; + + const delta = RINGBRP.contactOutcomes[res.level]?.[stage] ?? 0; + const learned = []; + const standings = foundry.utils.deepClone(caseFile.system.standings ?? []); + const rec = standings.find(x => x.groupId === groupId); + + // READ is how a team learns a taboo or a desire. One per success level. + if (stage === "read" && delta > 0) { + const taboos = group.system.taboos ?? [], desires = group.system.desires ?? []; + const unknownT = taboos.filter(t => !(rec.knownTaboos ?? []).includes(t.id)); + const unknownD = desires.filter(d => !(rec.knownDesires ?? []).includes(d.id)); + const extra = (stage === "read" && hasTalent(actor, "read_room")) ? 1 : 0; + for (let i = 0; i < delta + extra; i++) { + if (unknownT.length) { const t = unknownT.shift(); + rec.knownTaboos = [...(rec.knownTaboos ?? []), t.id]; learned.push(t.text); } + else if (unknownD.length) { const d = unknownD.shift(); + rec.knownDesires = [...(rec.knownDesires ?? []), d.id]; learned.push(d.text); } + } + } + if (stage === "signal" && delta > 0) rec.pidginEstablished = true; + if (stage === "offer") { + rec.offerMadeThisEncounter = true; + rec.impression = (Number(rec.impression) || 0) + delta; + rec.value = Math.max(-3, Math.min(3, Number(rec.impression) || 0)); + } + rec.encounters = Number(rec.encounters) || 0; + + await caseFile.update({ "system.standings": standings }); + if (stage === "offer" && delta > 0) await claimRewards(caseFile, groupId); + await ChatMessage.create({ + speaker: ChatMessage.getSpeaker({ actor: caseFile }), + content: await rbRenderTemplate("systems/ringbrp/templates/chat-contact.hbs", { + stage: game.i18n.localize(spec.label), group: group.name, actor: actor.name, + level: res.level, levelLabel: game.i18n.localize(LEVEL_LABEL[res.level]), + used, rating, delta, learned, + impression: rec.impression ?? 0, standing: rec.value ?? 0, + pidgin: !!rec.pidginEstablished + }) + }); + return { ...res, delta, learned, standing: rec.value, impression: rec.impression }; +} + +/** + * Rewards were an inert array: standing moved and nothing it promised happened. + * When standing rises, every reward at or below the new level is granted once, + * recorded on the standing so it cannot be farmed, and read by the phase resolver. + */ +export async function claimRewards(caseFile, groupId) { + const group = caseFile.items.find(i => i.type === "group" && i.system.groupId === groupId); + const standings = foundry.utils.deepClone(caseFile.system.standings ?? []); + const rec = standings.find(x => x.groupId === groupId); + if (!group || !rec) return []; + + const level = Math.max(0, Number(rec.value) || 0); + const claimed = new Set(rec.claimedRewardIds ?? []); + const table = group.system.rewardsByStanding ?? {}; + const granted = []; + + for (let n = 1; n <= level; n++) { + for (const r of table[String(n)] ?? []) { + if (!r?.id || claimed.has(r.id)) continue; + claimed.add(r.id); + granted.push({ ...r, level: n }); + } + } + if (!granted.length) return []; + + rec.claimedRewardIds = [...claimed]; + rec.activeEffects = [...(rec.activeEffects ?? []), ...granted.filter(g => g.effect).map(g => g.effect)]; + await caseFile.update({ "system.standings": standings }); + + // immediate effects land now; the rest are read when the phase resolves + for (const g of granted) { + if (g.effect?.type === "track") { + const t = g.effect.track, cur = caseFile.system[t]?.value ?? 0, max = caseFile.system[t]?.max ?? 10; + await caseFile.update({ [`system.${t}.value`]: Math.max(0, Math.min(max, cur + (Number(g.effect.delta) || 0))) }); + } + } + await ChatMessage.create({ + speaker: ChatMessage.getSpeaker({ actor: caseFile }), + content: `

${ + game.i18n.format("RINGBRP.Contact.Rewards", { group: group.name, level })}

+
` + }); + return granted; +} + +/** Effects a group has granted that apply to a given phase. */ +export function activeRewardEffects(caseFile, phaseIndex) { + const out = []; + for (const st of caseFile?.system?.standings ?? []) { + for (const e of st.activeEffects ?? []) { + if (e.phase !== undefined && e.phase !== phaseIndex) continue; + if (Array.isArray(e.phases) && !e.phases.includes(phaseIndex)) continue; + out.push(e); + } + } + return out; +} + +/** A new encounter clears the one-offer-per-encounter lock. */ +export async function newEncounter(caseFile, groupId) { + const standings = foundry.utils.deepClone(caseFile.system.standings ?? []); + const rec = standings.find(x => x.groupId === groupId); + if (!rec) return null; + rec.offerMadeThisEncounter = false; + rec.encounters = (Number(rec.encounters) || 0) + 1; + await caseFile.update({ "system.standings": standings }); + return rec.encounters; +} + + +/** What is being asked of the credential, and how unlike its owner you are being. */ +async function promptAuthorityUse(authority) { + const sys = authority.system; + const flags = sys.redFlags ?? []; + const scopeOpts = SCOPE_TIERS.slice(1).map(t => { + const stance = scopeStanceFor(sys.scope, t); + const note = stance === "in-scope" ? game.i18n.localize("RINGBRP.Authority.NoRoll") + : stance === "stretch" ? game.i18n.localize("RINGBRP.Authority.ARoll") + : game.i18n.localize("RINGBRP.Authority.Cannot"); + return ``; + }).join(""); + const flagOpts = flags.map((f, i) => + ``).join(""); + + return new Promise(resolve => { + new (rbDialogClass())({ + title: game.i18n.format("RINGBRP.Authority.Using", { name: sys.holderName || authority.name }), + content: `
+
+
+ ${flags.length ? `
${flagOpts}
` + : `

${game.i18n.localize("RINGBRP.Authority.NoFlags")}

`} +
`, + buttons: { + go: { label: game.i18n.localize("RINGBRP.Authority.Use"), + callback: html => { + const el = html[0] ?? html; + resolve({ + actionTier: el.querySelector("[name=tier]")?.value ?? "routine", + flagsTripped: [...el.querySelectorAll("[name=flag]:checked")].length + }); + } }, + cancel: { label: game.i18n.localize("RINGBRP.Gen.Cancel"), callback: () => resolve(null) } + }, + default: "go", + close: () => resolve(null) + }).render(true); + }); +} + + +/* -------------------------------------------- */ +/* Borrowed Authority */ +/* -------------------------------------------- */ + +/** + * Add one item to a credential being assembled. + * + * The dossier is the acquisition procedure, not a second subsystem: each item is a + * separate scene using a DIFFERENT skill, and at most one may be Computer Use, so + * building access is something the whole team does. A dossier item never makes the + * later roll easier — it makes the credential better, which is the distinction that + * keeps three generic successes from beating any target in the game. + */ +export async function addDossierItem(authority, { text = "", familyId = "", actor = null, + establishesRemit = false } = {}) { + if (!authority) return null; + const sys = authority.system; + const dossier = foundry.utils.deepClone(sys.dossier ?? []); + if (dossier.length >= DOSSIER.maxItems) { + ui.notifications?.warn(game.i18n.format("RINGBRP.Authority.DossierFull", { n: DOSSIER.maxItems })); + return null; + } + // One specialist cannot assemble the whole thing alone. + if (familyId === "computer_use" + && dossier.filter(d => d.familyId === "computer_use").length >= DOSSIER.maxComputerUse) { + ui.notifications?.warn(game.i18n.localize("RINGBRP.Authority.TooMuchComputer")); + return null; + } + if (familyId && dossier.some(d => d.familyId === familyId)) { + ui.notifications?.warn(game.i18n.localize("RINGBRP.Authority.SameSkill")); + return null; + } + + const rating = actor?.skillRating?.(familyId) ?? 0; + const res = await skillRoll({ + actor, label: game.i18n.format("RINGBRP.Authority.Gathering", { text: text || "—" }), + rating, categoryId: "mental", skillFamilyId: familyId, printedBase: 0 + }); + if (!res) return null; + if (!["critical", "special", "success"].includes(res.level)) { + await ChatMessage.create({ + speaker: ChatMessage.getSpeaker({ actor }), + content: `
${ + game.i18n.format("RINGBRP.Authority.ItemFailed", { text })}
` + }); + return { added: false, ...res }; + } + + dossier.push({ text, familyId, by: actor?.name ?? "", level: res.level, + establishesRemit: !!establishesRemit }); + const remit = sys.establishesRemit || establishesRemit; + await authority.update({ + "system.dossier": dossier, + "system.establishesRemit": remit, + "system.trust.value": dossierTrustFor(dossier.length), + "system.trust.max": dossierTrustFor(dossier.length), + "system.scope": dossierScopeFor(dossier.length, remit) + }); + await ChatMessage.create({ + speaker: ChatMessage.getSpeaker({ actor }), + content: `
${ + game.i18n.format("RINGBRP.Authority.ItemAdded", { text })}
${ + game.i18n.format("RINGBRP.Authority.DossierNow", { + n: dossier.length, of: DOSSIER.maxItems, + trust: dossierTrustFor(dossier.length), + scope: game.i18n.localize(`RINGBRP.Scope.${dossierScopeFor(dossier.length, remit)}`) + })}
` + }); + return { added: true, dossier, ...res }; +} + +/** + * Use a credential. + * + * Inside its scope there is NO ROLL — that is the whole idea. A facilities + * contractor opening a plant room is not a feat of intrusion, it is Tuesday. What + * costs you is asking the record to believe something one step beyond what the + * person was for, and what makes that hard is behaving unlike them. + */ +export async function useAuthority(actor, authority, { actionTier = "routine", + flagsTripped = 0, + difficulty = "average", + familyId = "computer_use" } = {}) { + if (!actor || !authority) return null; + const sys = authority.system; + if (sys.burned) { + ui.notifications?.warn(game.i18n.localize("RINGBRP.Authority.Burned")); + return null; + } + if (sys.suspect) { + ui.notifications?.warn(game.i18n.localize("RINGBRP.Authority.Suspect")); + return null; + } + + // A gatekeeper AI does not make the attempt harder, it raises the bar for what + // counts as routine — which is a different and nastier thing. + const effectiveTier = (sys.aiPosture === "gatekeeper" && scopeRank(actionTier) >= scopeRank("routine")) + ? SCOPE_TIERS[Math.min(SCOPE_TIERS.length - 1, scopeRank(actionTier) + 1)] + : actionTier; + + const stance = scopeStanceFor(sys.scope, effectiveTier); + + if (stance === "impossible") { + await ChatMessage.create({ + speaker: ChatMessage.getSpeaker({ actor }), + content: `
${ + game.i18n.format("RINGBRP.Authority.Impossible", { + name: sys.holderName || authority.name, + scope: game.i18n.localize(`RINGBRP.Scope.${sys.scope}`), + tier: game.i18n.localize(`RINGBRP.Scope.${effectiveTier}`) })}
` + }); + return { stance, works: false }; + } + + if (stance === "in-scope") { + const left = trustAfter(sys.trust.value, -1, sys.trust.max); + await authority.update({ "system.trust.value": left, "system.suspect": left <= 0 }); + await ChatMessage.create({ + speaker: ChatMessage.getSpeaker({ actor }), + content: `
${ + game.i18n.format("RINGBRP.Authority.InScope", { name: sys.holderName || authority.name })} ${ + game.i18n.format("RINGBRP.Authority.TrustLeft", { n: left })}${ + left <= 0 ? ` — ${game.i18n.localize("RINGBRP.Authority.NowSuspect")}` : ""}
` + }); + return { stance, works: true, rolled: false, trust: left }; + } + + // A stretch. Scrutiny is what the system notices, and it is entirely about + // behaving unlike the person whose name is on the record. + const scrutiny = scrutinyFor(flagsTripped); + const res = await skillRoll({ + actor, label: game.i18n.format("RINGBRP.Authority.Stretching", { + name: sys.holderName || authority.name, + tier: game.i18n.localize(`RINGBRP.Scope.${effectiveTier}`) }), + rating: actor.skillRating(familyId) ?? 0, + difficulty, situational: scrutiny, categoryId: "mental", skillFamilyId: familyId, + flavourLines: flagsTripped + ? [game.i18n.format("RINGBRP.Authority.Flags", { n: flagsTripped, mod: scrutiny })] : [] + }); + if (!res) return null; + + const out = authorityOutcomeFor(res.level); + let trust = trustAfter(sys.trust.value, out.trustDelta, sys.trust.max); + const upd = { "system.trust.value": trust }; + if (out.burned) { upd["system.burned"] = true; upd["system.trust.value"] = 0; trust = 0; } + else if (trust <= 0) upd["system.suspect"] = true; + await authority.update(upd); + + // An analyst AI does not stop you. It writes something down. + if (sys.aiPosture === "analyst" && !out.burned) { + const flags = foundry.utils.deepClone(sys.redFlags ?? []); + flags.push({ text: game.i18n.localize("RINGBRP.Authority.Correlated"), known: false }); + await authority.update({ "system.redFlags": flags }); + } + + await ChatMessage.create({ + speaker: ChatMessage.getSpeaker({ actor }), + content: `
${ + game.i18n.format(out.works ? "RINGBRP.Authority.Worked" : "RINGBRP.Authority.Refused", + { name: sys.holderName || authority.name })} ${ + out.burned ? game.i18n.localize("RINGBRP.Authority.BurnedNow") + : game.i18n.format("RINGBRP.Authority.TrustLeft", { n: trust })}${ + (!out.burned && trust <= 0) ? ` — ${game.i18n.localize("RINGBRP.Authority.NowSuspect")}` : ""}
` + }); + return { stance, works: out.works, rolled: true, burned: out.burned, trust, ...res }; +} + +/** + * Spend Coherence to widen what the record says the person was for. + * + * This is the crossing premise applied to paperwork: reality is made to remember + * that M. Hughes had always been cleared for the switchroom. It works. What it does + * to the agent is the subsystem's actual subject — push a credential past what its + * Trust can carry and the two files converge one step, and nothing in the game + * spends anything to converge them back. + */ +export async function widenScope(actor, authority) { + if (!actor || !authority) return null; + const sys = authority.system; + const at = scopeRank(sys.scope); + if (at >= SCOPE_TIERS.length - 1) { + ui.notifications?.info(game.i18n.localize("RINGBRP.Authority.WidestAlready")); + return null; + } + const cost = widenScopeCostFor(sys.timesWidened); + const have = Number(actor.system?.coherence?.value) || 0; + if (have < cost) { + ui.notifications?.warn(game.i18n.format("RINGBRP.Authority.NotEnoughCoherence", { n: cost })); + return null; + } + await loseCoherence(actor, "misfiled", { amount: cost, + note: game.i18n.format("RINGBRP.Authority.WidenNote", { name: sys.holderName || authority.name }) }); + + const widened = SCOPE_TIERS[at + 1]; + const upd = { "system.scope": widened, "system.timesWidened": (Number(sys.timesWidened) || 0) + 1 }; + + // Pushed past what the credential's Trust can carry, the records converge. + let merged = null; + if ((Number(sys.trust.value) || 0) <= 0) { + const steps = (Number(sys.mergeSteps) || 0) + 1; + upd["system.mergeSteps"] = steps; + merged = mergeStageFor(steps); + } + await authority.update(upd); + + await ChatMessage.create({ + speaker: ChatMessage.getSpeaker({ actor }), + content: `
${ + game.i18n.format("RINGBRP.Authority.Widened", { + name: sys.holderName || authority.name, + scope: game.i18n.localize(`RINGBRP.Scope.${widened}`), cost })}` + + (merged ? `

${game.i18n.localize(merged.label)}

` : "") + + `
` + }); + return { scope: widened, cost, merged }; +} + +/** A suspect credential can be brought back, once, by a scene and a roll. */ +export async function rehabilitate(actor, authority, { familyId = "persuade" } = {}) { + if (!actor || !authority) return null; + const sys = authority.system; + if (sys.burned) { + ui.notifications?.warn(game.i18n.localize("RINGBRP.Authority.BurnedForGood")); + return null; + } + const res = await skillRoll({ + actor, label: game.i18n.format("RINGBRP.Authority.Rehabilitating", + { name: sys.holderName || authority.name }), + rating: actor.skillRating(familyId) ?? 0, + categoryId: "mental", skillFamilyId: familyId, printedBase: 0 + }); + if (!res) return null; + const ok = ["critical", "special", "success"].includes(res.level); + if (ok) { + await authority.update({ "system.suspect": false, + "system.trust.value": trustAfter(0, 1, sys.trust.max) }); + } else if (res.level === "fumble") { + await authority.update({ "system.burned": true }); + } + return { ...res, rehabilitated: ok }; +} + +/* -------------------------------------------- */ +/* Healing */ +/* -------------------------------------------- */ + +/** + * The one core rule the audit found wholly absent: a wounded agent could never + * get better by any means the system offered. BRP-standard rates, plus the two + * devices the agency actually issues. + */ +RINGBRP.healing = { + natural: { perDays: 7, amount: 1, label: "RINGBRP.Heal.Natural" }, + firstAid: { amount: 3, once: true, label: "RINGBRP.Heal.FirstAid" }, + fieldPack: { perHours: 4, amount: 1, label: "RINGBRP.Heal.FieldPack" }, + traumaUnit:{ perHours: 2, amount: 1, label: "RINGBRP.Heal.TraumaUnit" } +}; + +/** + * Restore hit points, and mend location damage proportionally so a disabled limb + * comes back when the body does. Characteristic loss is NOT reversed — that is + * the standing rule and no device here changes it. + */ +export async function heal(actor, amount, { source = "natural", locationId = "" } = {}) { + if (!actor || !(amount > 0)) return null; + const max = Number(actor.system.hp?.max) || 0; + const before = Number(actor.system.hp?.value) || 0; + const after = Math.min(max, before + amount); + const upd = { "system.hp.value": after }; + + // Location damage heals with the body; a named location heals first. + // R-93. Foundry MERGES object updates, so deleting a key from the clone leaves + // the stored value untouched — a location healed to zero silently kept its + // damage, while a partial heal appeared to work. Fully-mended locations must be + // removed with the "-=" deletion form. + const dmg = foundry.utils.deepClone(actor.system.locationDamage ?? {}); + let pool = after - before; + const order = locationId ? [locationId, ...Object.keys(dmg).filter(k => k !== locationId)] + : Object.keys(dmg); + for (const id of order) { + if (pool <= 0) break; + const taken = Number(dmg[id]) || 0; + if (taken <= 0) continue; + const mend = Math.min(taken, pool); + const left = taken - mend; + pool -= mend; + if (left > 0) upd[`system.locationDamage.${id}`] = left; + else upd[`system.locationDamage.-=${id}`] = null; + } + await actor.update(upd); + + await ChatMessage.create({ + speaker: ChatMessage.getSpeaker({ actor }), + content: `

${ + game.i18n.localize("RINGBRP.Heal.Title")}

+ ${game.i18n.localize(RINGBRP.healing[source]?.label ?? source)} + → + +${after - before}
+
${after} + / ${max}
` + }); + return after; +} + +/** First Aid: one attempt per wound, and only the first one counts. */ +export async function firstAid(medic, patient, { difficulty = "average" } = {}) { + if (!medic || !patient) return null; + // The medical kit's +20% used to be hardcoded here AND carried on the item as + // skillBonus — two authorities for one number. The item is now the only one. + const res = await skillRoll({ + actor: medic, label: game.i18n.localize("RINGBRP.Heal.FirstAid"), + rating: medic.skillRating("first_aid") ?? 0, printedBase: 30, difficulty, + categoryId: "mental", skillFamilyId: "first_aid" + }); + if (!res) return null; + // A successful First Aid staunches what it is rated to staunch. This is the half + // of the bleeding rule that was never implemented: the table said "until a + // successful First Aid" and nothing ever cleared the wound. + if (["critical", "special", "success"].includes(res.level)) { + await stopBleeding(patient, { family: "first_aid", difficulty }); + } + let heal_ = { critical: 6, special: 4, success: 3, failure: 0, fumble: -1 }[res.level] ?? 0; + if (heal_ > 0 && hasTalent(medic, "triage")) heal_ += 1; // Triage + if (heal_ > 0) return heal(patient, heal_, { source: "firstAid" }); + if (heal_ < 0) { + await patient.update({ "system.hp.value": Math.max(0, (patient.system.hp.value || 0) - 1) }); + ui.notifications?.warn(game.i18n.localize("RINGBRP.Heal.Fumbled")); + } + return patient.system.hp.value; +} + +/** + * End-of-round blood loss. Called from the combat hook, so the clock runs without + * anyone remembering it — the old rule printed a rate that nothing ever applied. + * + * Stops at 0: a bleeding agent goes down and stays down rather than being ground + * into negatives by a rule nobody is watching. + */ +export async function applyBleeding(actor) { + const rate = Number(actor?.system?.wounded?.bleed) || 0; + if (!actor || rate <= 0) return null; + const before = Number(actor.system.hp?.value) || 0; + if (before <= 0) return before; + const after = Math.max(0, before - rate); + await actor.update({ "system.hp.value": after }); + await ChatMessage.create({ + speaker: ChatMessage.getSpeaker({ actor }), + content: `
${game.i18n.format("RINGBRP.Heal.Bleeds", + { name: actor.name, amount: rate })} ${before} → ${after}` + + (after <= 0 ? ` — ${game.i18n.localize("RINGBRP.Heal.BledOut")}` : "") + `
` + }); + return after; +} + +/** + * Stop bleeding with a successful roll. Which rolls qualify is BLEED's business, + * not this function's. Returns the wound keys actually stopped. + */ +export async function stopBleeding(actor, { family = "", difficulty = "average" } = {}) { + const wounds = actor?.system?.wounds ?? []; + const stopped = wounds.filter(w => bleedStoppedBy(w.key, { family, difficulty })); + if (!stopped.length) return []; + const keep = wounds.filter(w => !stopped.includes(w)); + await actor.update({ "system.wounds": keep }); + await ChatMessage.create({ + speaker: ChatMessage.getSpeaker({ actor }), + content: `
${game.i18n.format("RINGBRP.Heal.BleedStopped", + { name: actor.name })}
` + }); + return stopped.map(w => w.key); +} + +/** + * Medical care — the slower, better answer. Routed through the same BLEED rule as + * First Aid so there is one authority on what stops what. + */ +export async function medicalCare(medic, patient, { difficulty = "average" } = {}) { + if (!medic || !patient) return null; + const res = await skillRoll({ + actor: medic, label: game.i18n.localize("RINGBRP.Heal.Medicine"), + rating: medic.skillRating("medicine") ?? 0, printedBase: 0, difficulty, + categoryId: "mental", skillFamilyId: "medicine" + }); + if (!res) return null; + if (["critical", "special", "success"].includes(res.level)) { + await stopBleeding(patient, { family: "medicine", difficulty }); + return heal(patient, { critical: 6, special: 4, success: 3 }[res.level] ?? 0, + { source: "medicine" }); + } + return patient.system.hp.value; +} + +/** + * Defend against an attack. Rolls Dodge or a weapon's parry at the escalating + * penalty, increments the round's counter and locks the type — the three fields + * the data model has carried since the beginning with nothing to write them. + * + * Returns the roll result plus whether the attack was turned aside, so a caller + * can decide what that means: a dodge avoids the blow, a parry stops it. + */ +export async function defend(actor, { type = "dodge", weapon = null, difficulty = "average", + against = null } = {}) { + if (!actor) return null; + // What is being answered. A defence that does not know the quality of the blow + // cannot grade itself against it, so fall back to the standing blow recorded by + // the last attack, and to an ordinary success if there is not one. + const incoming = against ?? standingBlow(actor)?.level ?? "success"; + const combat = actor.system.combat ?? {}; + const used = Number(combat.defencesThisRound) || 0; + const kind = defenceTypeAllowed(combat.defenceTypeLock, type); + if (kind !== type) { + ui.notifications?.info(game.i18n.format("RINGBRP.Notify.DefenceLocked", + { kind: game.i18n.localize(`RINGBRP.Defence.${kind}`) })); + } + + // Dodge is its own skill; a parry uses the weapon you are holding. + let rating = 0, label = "", famId = "", catId = "phys"; + if (kind === "parry" && weapon) { + rating = actor.skillRating(weapon.system.skillFamilyId, weapon.system.skillSpecialisationId) ?? 0; + label = game.i18n.format("RINGBRP.Defence.ParryWith", { name: weapon.name }); + famId = weapon.system.skillFamilyId; catId = "melee"; + } else { + rating = actor.skillRating("dodge") ?? 0; + label = game.i18n.localize("RINGBRP.Defence.dodge"); + famId = "dodge"; + } + + const penalty = defencePenaltyFor(used); + const res = await skillRoll({ + actor, label, rating, difficulty, situational: penalty, + categoryId: catId, skillFamilyId: famId, printedBase: 0, + flavourLines: penalty ? [game.i18n.format("RINGBRP.Flavour.Defending", + { n: used + 1, mod: penalty })] : [] + }); + if (!res) return null; + + await actor.update({ + "system.combat.defencesThisRound": used + 1, + "system.combat.defenceTypeLock": kind + }); + const outcome = defenceOutcomeFor(incoming, res.level); + // The blow now stands at whatever survived the defence, so the attacker's damage + // roll uses the reduced level and a stopped blow can no longer roll damage at all. + await amendBlow(outcome.turnedAside ? { stopped: true } : { level: outcome.landsAt }); + await ChatMessage.create({ + speaker: ChatMessage.getSpeaker({ actor }), + content: `
` + + `${game.i18n.format("RINGBRP.Defence.Answered", { + name: actor.name, + defence: game.i18n.localize(`RINGBRP.Level.${res.level}`), + attack: game.i18n.localize(`RINGBRP.Level.${incoming}`) })} ` + + (outcome.turnedAside + ? game.i18n.localize("RINGBRP.Defence.TurnedAside") + : game.i18n.format("RINGBRP.Defence.GetsThrough", { + level: game.i18n.localize(`RINGBRP.Level.${outcome.landsAt}`) })) + + `
` + }); + return { ...res, kind, penalty, defencesUsed: used + 1, against: incoming, + landsAt: outcome.landsAt, steps: outcome.steps, + turnedAside: outcome.turnedAside }; +} + +/* -------------------------------------------- */ +/* The standing blow */ +/* -------------------------------------------- */ + +/** + * A defence has to know what it is answering, and the sheet's Dodge and Parry + * buttons had no way of knowing — they were pressed in isolation. Every attack + * roll now leaves a record of itself, and the defender picks it up. + * + * Kept on the active Combat where there is one, so it reaches every client rather + * than only the machine that rolled; a module-level copy covers a brawl nobody + * bothered to start a combat for. It expires so that yesterday's critical cannot + * be answered by today's dodge. + */ +const BLOW_TTL_MS = 120000; +let lastBlow = null; + +export async function recordBlow({ attacker, level, weapon = null, targets = [] } = {}) { + if (!attacker || !["critical", "special", "success"].includes(level)) return null; + const blow = { attackerId: attacker.id, attackerName: attacker.name, level, + weaponName: weapon?.name ?? null, targets, at: Date.now() }; + lastBlow = blow; + if (game.combat) await game.combat.setFlag("ringbrp", "standingBlow", blow); + return blow; +} + +/** Revise the standing blow after a defence has been rolled against it. */ +export async function amendBlow(patch = {}) { + const blow = game.combat?.getFlag("ringbrp", "standingBlow") ?? lastBlow; + if (!blow) return null; + const next = { ...blow, ...patch }; + lastBlow = next; + if (game.combat) await game.combat.setFlag("ringbrp", "standingBlow", next); + return next; +} + +/** The blow this actor is currently answering, if it is still fresh and not theirs. */ +export function standingBlow(defender = null) { + const blow = game.combat?.getFlag("ringbrp", "standingBlow") ?? lastBlow; + if (!blow) return null; + if (Date.now() - (Number(blow.at) || 0) > BLOW_TTL_MS) return null; + if (defender && blow.attackerId === defender.id) return null; // not your own swing + return blow; +} + +/** + * Use one of the Quiet Arts. + * + * Rolls through the same choke point as everything else, so an Art is subject to + * wounds, load and — pointedly — the agent's own Coherence: the further you have + * drifted, the worse you are at the thing that made you drift. The cost is charged + * whether or not it works, because the misfiling is committed the moment you try. + * + * A FUMBLE is the interesting outcome. The misfiling sticks: something small and + * true about the world changes and the GM never says which. That is a story hook + * with a mechanical anchor, not a fail state. + */ +export async function usePower(actor, power, { pushed = false, difficulty = "average" } = {}) { + if (!actor || !power) return null; + const artId = power.system?.artId || ""; + const cost = pushed ? (Number(power.system?.pushCost) || powerCostFor(artId, { pushed: true })) + : (Number(power.system?.coherenceCost) || powerCostFor(artId)); + + // R-56 applies to the Arts too: an agent at 0 Coherence is Displaced, not gone, + // and must still be able to act. The cost simply cannot take them below zero. + const have = Number(actor.system?.coherence?.value) || 0; + if (have <= 0) { + ui.notifications?.warn(game.i18n.localize("RINGBRP.Notify.NoCoherence")); + return null; + } + + const res = await skillRoll({ + actor, label: power.name, rating: Number(power.system?.value) || 0, + difficulty, categoryId: "mental", printedBase: 0, + flavourLines: [game.i18n.format(pushed ? "RINGBRP.Power.Pushed" : "RINGBRP.Power.Used", + { cost })] + }); + if (!res) return null; + + await loseCoherence(actor, "usedCrossingTech", { amount: cost, note: power.name }); + + // An Art earns its tick the way a skill does. Without this it arrived at roughly + // POW x2 and stayed there for the rest of the character's life while charging + // Coherence per attempt — a trap rather than a temptation. + if (["critical", "special", "success"].includes(res.level) && !power.system?.experienceTicked) { + await power.update({ "system.experienceTicked": true }); + } + + if (res.level === "fumble") { + await ChatMessage.create({ + speaker: ChatMessage.getSpeaker({ actor }), + content: `
${game.i18n.localize("RINGBRP.Power.Misfiled")} +

${game.i18n.localize("RINGBRP.Power.MisfiledText")}

` + }); + } + return { ...res, cost, pushed, artId }; +} + +/** + * Aim the way home and take it. + * + * This is the roll `returnCrossing` only ever described. R-56 is enforced here and + * not by convention: **there is no outcome that leaves an agent across**. A failure + * arrives them near the target rather than at it; a fumble brings them back + * changed. Being Unmoored makes it dearer and another agent can always aim it for + * you at no penalty. + * + * A crossing that goes wrong is the ONLY source of the Quiet Arts. The pool is + * decided by how far the agent had already drifted when it went wrong, so the worse + * the state they were in, the worse the thing that follows them home. + */ +export async function crossBack(actor, { aimedByAnother = false, aimedBy = null } = {}) { + if (!actor) return null; + const plan = returnCrossing(actor, { aimedByAnother }); + const roller = aimedByAnother && aimedBy ? aimedBy : actor; + + const res = await skillRoll({ + actor: roller, + label: game.i18n.localize("RINGBRP.Cross.Aim"), + rating: roller.skillRating("transposition") ?? 0, + categoryId: "mental", skillFamilyId: "transposition", printedBase: 0, + situational: plan.penalty, + flavourLines: [game.i18n.localize(plan.note)] + }); + if (!res) return null; + + const cost = crossingCostFor(res.level); + if (cost) await loseCoherence(actor, "overstayed", { amount: cost, note: game.i18n.localize("RINGBRP.Cross.Rough") }); + + // Whatever happened, THIS AGENT is home. That is the invariant, not a courtesy. + // The case file is not cleared here: one agent returning does not end a crossing + // the rest of the team is still standing in. The caller decides when the last + // one is back. + + let art = null; + const coherenceNow = Number(actor.system?.coherence?.value) || 0; + if (crossingGrantsArt(res.level, coherenceNow)) { + const pool = crossingArtPoolFor(coherenceNow) + .filter(id => !actor.items.some(i => i.type === "power" && i.system?.artId === id)); + if (pool.length) { + const pick = pool[Math.floor(Math.random() * pool.length)]; + art = await grantPower(actor, pick, { + origin: game.i18n.format("RINGBRP.Cross.Origin", { + outcome: game.i18n.localize(`RINGBRP.Cross.${res.level}`) + }) + }); + } + } + + await ChatMessage.create({ + speaker: ChatMessage.getSpeaker({ actor }), + content: `
+

${game.i18n.localize("RINGBRP.Cross.Title")}

+

${game.i18n.localize(`RINGBRP.Cross.${res.level}`)}

+

${game.i18n.localize(res.level === "failure" || res.level === "fumble" + ? plan.onFailure : "RINGBRP.Cross.Clean")}

+ ${cost ? `

${game.i18n.format("RINGBRP.Cross.Cost", { n: cost })}

` : ""} +
` + }); + + return { ...res, home: true, cost, art, plan }; +} + +/* ============================================================ + * MISSIONS — generate a whole case, ready to run + * ============================================================ */ + +/** + * Build a case from the mission framework: a JournalEntry the GM reads and a case + * file Actor the table plays against, with the phases already laid in as legs and + * the tracks set by classification. + * + * The journal is deliberately split brief / truth / opposition / phases / pressure, + * because the brief is the only page a player may see and it must be safe to show. + */ +export async function generateMissionCase({ seed = null, notify = true, ...opts } = {}) { + const { generateMission, missionPages } = await import("./tools/mission.mjs"); + const m = generateMission({ seed, ...opts }); + + const journal = await JournalEntry.create({ + name: `${m.codename} — ${m.classification.name}`, + pages: missionPages(m).map((pg, i) => ({ + name: pg.name, type: "text", title: { show: true, level: 1 }, + text: { format: 1, content: pg.content }, sort: (i + 1) * 100, + // CONST.DOCUMENT_OWNERSHIP_LEVELS: 2 is OBSERVER, 0 is NONE, -1 is INHERIT. + // These were the wrong way round — the brief was set to NONE and the four + // secret pages to INHERIT, so a GM opening the entry to the table to share the + // brief would have published the truth, the opposition and the whole plot. + ownership: { default: i === 0 ? 2 : 0 } + })) + }); + + const caseFile = await Actor.create({ + name: `Case: ${m.codename}`, + type: "expedition", + system: { + integrity: { value: m.tracks.containment, max: 10 }, + supply: { value: m.tracks.resources, max: 10 }, + power: { value: m.tracks.clearance, max: 10 }, + across: false, + journey: { + destinationKnowledge: "rumoured", + currentLeg: 0, + legs: m.phases.map(p => ({ + terrain: `${p.name} — ${p.terrain}`, + difficulty: p.difficulty, + modifier: p.modifier, + costSupply: p.costSupply, + costPower: p.costPower, + days: 1, + explored: false, + resolved: false, + forcedEncounterId: p.revealsGap ? p.reveal : null + })) + }, + notes: `

${m.codename} — ${m.classification.name}. ${m.place}.

` + + `

${m.anomaly.brief}

`, + // A case with nobody assigned cannot resolve a phase: resolveLegDialog needs + // a roster to pick a lead from and bails silently without one. + members: (opts.members ?? game.actors.filter(a => a.type === "character")).map(a => a.id), + standings: [], + // read by returnCrossing; the brief promises it, so the return must charge it + crossingModifier: m.crossingModifier, + // read by salvageFrom(); what era of kit is lying about in a world like this + salvageEra: m.salvageEra + }, + // The contact pillar — three stages, standings, learned taboos, and rewards the + // phase resolver already applies — could never fire on a generated case, because + // resolveContact needs a group ITEM on the case file and the generator created + // the actor with no items at all. The people already on the ground now exist. + items: [{ + name: m.group.name, type: "group", img: "systems/ringbrp/icons/group.svg", + system: { + description: `

${m.group.description}

`, + groupId: m.group.id, speciesId: "baseline", population: m.group.population, + taboos: [m.group.taboo], + desires: [m.group.desire], + correctOfferForm: m.group.offerForm, + rewardsByStanding: { + "1": [{ id: "r1_route", text: "They walk you in by a way that avoids the road.", + effect: { type: "cancelModifier", phase: 1 } }], + "2": [{ id: "r2_shelter", text: "You may shelter with them. The next phase costs nothing.", + effect: { type: "waiveCost", phase: 2 } }], + "3": [{ id: "r3_hand", text: "One of them comes with you and knows the ground.", + effect: { type: "skillBonus", value: 20, skillFamilyId: "tradecraft" } }] + } + } + }], + flags: { ringbrp: { + // activeCaseFile() looks for this flag and nothing ever set it, so every + // crossing, debrief and Clean Scene operated on whichever expedition happened + // to be first in the collection — the shipped ILFRACOMBE case, usually. + active: true, + mission: { seed: m.seed, codename: m.codename, gap: m.gap.id, + anomaly: m.anomaly.id, journalId: journal.id } } } + }); + + // Only one case is the active one. + for (const other of game.actors.filter(a => a.type === "expedition" && a.id !== caseFile.id)) { + if (other.getFlag("ringbrp", "active")) await other.unsetFlag("ringbrp", "active"); + } + + if (notify) ui.notifications?.info(game.i18n.format("RINGBRP.Mission.Made", { name: m.codename })); + return { mission: m, journal, caseFile }; +} + +/** A GM dialog: pin what you want, roll the rest. */ +export async function missionDialog() { + const T = await import("./tools/mission-tables.mjs"); + const opt = (arr, none) => `` + + arr.map(x => ``).join(""); + const content = `
+
+
+
+
+
+ +

${game.i18n.localize("RINGBRP.Mission.GapHint")}

+
+
+
+
+
`; + return new Promise(resolve => { + new (rbDialogClass())({ + title: game.i18n.localize("RINGBRP.Mission.Title"), content, + buttons: { + make: { label: game.i18n.localize("RINGBRP.Mission.Make"), + callback: async html => { + const el = html[0] ?? html; + const v = n => el.querySelector(`[name=${n}]`)?.value || undefined; + const seed = Number(v("seed")); + resolve(await generateMissionCase({ + seed: Number.isFinite(seed) && seed ? seed : null, + classId: v("classId"), anomalyId: v("anomalyId"), + gapId: v("gapId"), adversaryId: v("adversaryId") + })); + } }, + cancel: { label: game.i18n.localize("RINGBRP.Dialog.Cancel"), callback: () => resolve(null) } + }, + default: "make", close: () => resolve(null) + }).render(true); + }); +} + +/** + * Grant an Art. There is no other way in: they are not taught, bought or chosen at + * character creation, they are what a crossing left behind. The GM calls this when + * a crossing has cost somebody something and given them this instead. + */ +export async function grantPower(actor, artId, { origin = "", rating = null } = {}) { + if (!actor || !ARTS[artId]) return null; + if (actor.items.some(i => i.type === "power" && i.system?.artId === artId)) { + ui.notifications?.info(game.i18n.localize("RINGBRP.Notify.AlreadyHasArt")); + return null; + } + const pack = game.packs.get("ringbrp.powers"); + const proto = pack ? (await pack.getDocuments()).find(d => d.system?.artId === artId) : null; + const pow = Number(actor.system?.characteristics?.pow?.value) || 10; + const data = proto ? proto.toObject() : { + name: game.i18n.localize(`RINGBRP.Art.${artId}.name`), type: "power", + system: { artId, coherenceCost: powerCostFor(artId), pushCost: powerCostFor(artId, { pushed: true }) } + }; + data.system.value = rating ?? powerStartingRating(pow); + data.system.origin = origin; + const [made] = await actor.createEmbeddedDocuments("Item", [data]); + await ChatMessage.create({ + speaker: ChatMessage.getSpeaker({ actor }), + content: `
+ ${game.i18n.format("RINGBRP.Power.Arrived", { name: made.name })} +

${origin || game.i18n.localize("RINGBRP.Power.ArrivedText")}

` + }); + return made; +} + +/** + * SALVAGE. What is lying about in a world like this one. + * + * `salvageEra` was computed by the mission generator and referenced by absolutely + * nothing — the cleanest instance in the project of a value with no reader. It is + * the whole point of crossing to other times: a Hollow world is full of antique + * kit, a Deep one full of things nobody here can manufacture. Now it picks from the + * catalogue by era, which the items have carried since the era rework. + */ +export async function salvageFrom(caseFile, { count = 1 } = {}) { + const cf = caseFile ?? activeCaseFile(); + const era = cf?.system?.salvageEra || "modern"; + const packs = ["weapons", "armour", "gear"]; + const found = []; + for (const name of packs) { + const pack = game.packs.get(`ringbrp.${name}`); + if (!pack) continue; + for (const d of await pack.getDocuments()) { + if ((d.flags?.ringbrp?.era ?? "modern") !== era) continue; + // Weightless entries are not objects. "Punch / kick" is the unarmed pseudo- + // weapon every character has, and it turned up as salvage lying in a filter + // house, which is funny exactly once. + if (!(Number(d.system?.massKg) > 0)) continue; + found.push(d); + } + } + if (!found.length) { + ui.notifications?.info(game.i18n.format("RINGBRP.Salvage.None", { era })); + return []; + } + const picked = []; + for (let i = 0; i < count && found.length; i++) { + picked.push(found.splice(Math.floor(Math.random() * found.length), 1)[0]); + } + await ChatMessage.create({ + speaker: { alias: game.i18n.localize("RINGBRP.Salvage.Title") }, + content: `

${ + game.i18n.format("RINGBRP.Salvage.Found", { era: game.i18n.localize(`RINGBRP.Folder.era.${era}`) }) + }

` + }); + return picked; +} + +/** Time passing at home or in a safe place. */ +export async function restFor(actor, { days = 0, hours = 0, device = "" } = {}) { + const rules = RINGBRP.healing; + let total = Math.floor((Number(days) || 0) / rules.natural.perDays) * rules.natural.amount; + const dev = rules[device]; + if (dev?.perHours) total += Math.floor((Number(hours) || 0) / dev.perHours) * dev.amount; + return total > 0 ? heal(actor, total, { source: device || "natural" }) : actor.system.hp.value; +} + +/** + * End of case. Every ticked skill rolls to improve: succeed against d100 > current + * rating and it goes up 1D6, and the tick clears either way. This is the step that + * closes the episodic loop — a case ends, the agents are a little better, and the + * ticks reset for the next one. + */ +export async function endOfCase(actor) { + if (!actor) return null; + // Talents reset whether or not anything was learned — end of case is end of case. + // This used to sit after the early return, so a character who ticked no skills + // kept every talent spent for the rest of the campaign. + await resetTalents(actor); + // Powers improve on the same footing as skills. They carried an experienceTicked + // field that nothing set and nothing read, so an Art could never get better. + const ticked = actor.items.filter(i => ["skill", "power"].includes(i.type) + && i.system.experienceTicked); + if (!ticked.length) { + ui.notifications?.info(game.i18n.localize("RINGBRP.Exp.NoTicks")); + return []; + } + const results = []; + for (const sk of ticked) { + const rating = Number(sk.system.value) || 0; + const roll = await new Roll("1d100").evaluate(); + // Roll OVER the current rating: the better you already are, the harder to gain. + const improved = roll.total > rating; + const gain = improved ? (await new Roll("1d6").evaluate()).total : 0; + await sk.update({ "system.value": rating + gain, "system.experienceTicked": false }); + results.push({ name: sk.name, rating, roll: roll.total, gain, improved }); + } + await ChatMessage.create({ + speaker: ChatMessage.getSpeaker({ actor }), + content: `

${ + game.i18n.localize("RINGBRP.Exp.Title")}

` + }); + return results; +} + +/* -------------------------------------------- */ +/* NPC targeting */ +/* -------------------------------------------- */ + +/** + * R-43, finally implemented. It was written into three statblocks — "fires at the + * nearest visible agent; ties go to the lowest current hit points" — and lived + * nowhere else, so every GM had to arbitrate it by hand and inconsistently. + * + * Order: nearest visible, then lowest current hit points, then lowest DEX, then + * the GM chooses from what remains. A character who is DOWN is not a legal target + * while any standing target remains — enemies here are not fanatics, they are + * being paid, and shooting the unconscious wastes a round. + */ +export function npcTarget(attackerToken, candidateTokens = []) { + const live = candidateTokens.filter(t => t?.actor); + if (!live.length) return { target: null, reason: "RINGBRP.Target.None", tied: [] }; + + // visible only; a token with zero HP is down + const isDown = t => (Number(t.actor.system?.hp?.value) || 0) <= 0 || t.actor.system?.isDown; + const visible = live.filter(t => t.visible !== false && !t.document?.hidden); + const pool = visible.length ? visible : live; + + const standing = pool.filter(t => !isDown(t)); + const legal = standing.length ? standing : pool; // only shoot the downed if nothing stands + const shootingDowned = !standing.length; + + const dist = t => { + if (!attackerToken?.center || !t?.center) return 0; + return Math.hypot(t.center.x - attackerToken.center.x, t.center.y - attackerToken.center.y); + }; + const narrow = (arr, keyFn) => { + const best = Math.min(...arr.map(keyFn)); + return arr.filter(t => keyFn(t) === best); + }; + + let pick = narrow(legal, dist); + let reason = "RINGBRP.Target.Nearest"; + if (pick.length > 1) { + pick = narrow(pick, t => Number(t.actor.system?.hp?.value) || 0); + reason = "RINGBRP.Target.LowestHP"; + } + if (pick.length > 1) { + pick = narrow(pick, t => Number(t.actor.system?.characteristics?.dex?.value) || 0); + reason = "RINGBRP.Target.LowestDex"; + } + if (pick.length > 1) reason = "RINGBRP.Target.GMChoice"; + + return { + target: pick[0] ?? null, + tied: pick, + reason: shootingDowned ? "RINGBRP.Target.OnlyDowned" : reason, + distance: pick[0] ? Math.round(dist(pick[0])) : null + }; +} + +/** + * Morale, also prose until now: "when two of a team of six are down, the rest + * withdraw with their wounded." Expressed as a fraction so it holds for any + * group size. + */ +export function npcShouldWithdraw(groupTokens = [], threshold = 1 / 3) { + const live = groupTokens.filter(t => t?.actor); + if (!live.length) return false; + const down = live.filter(t => (Number(t.actor.system?.hp?.value) || 0) <= 0).length; + return down / live.length >= threshold; +} + +/** Announce the choice so the table can see the rule was followed, not fudged. */ +export async function declareNpcTarget(attackerToken, candidateTokens) { + const res = npcTarget(attackerToken, candidateTokens); + if (!res.target) return res; + await ChatMessage.create({ + speaker: ChatMessage.getSpeaker({ actor: attackerToken?.actor }), + content: `

${ + game.i18n.localize("RINGBRP.Target.Title")}

+ ${res.target.name} + ← + ${game.i18n.localize(res.reason)}
`, + whisper: ChatMessage.getWhisperRecipients("GM").map(u => u.id) + }); + return res; +} + +/* -------------------------------------------- */ +/* Talents */ +/* -------------------------------------------- */ + +/** Does this actor have a given talent, and is it still available? */ +export function hasTalent(actor, talentId) { + return (actor?.items ?? []).find(i => i.type === "talent" && i.system?.talentId === talentId) ?? null; +} +export function talentAvailable(talent) { + const per = Number(talent?.system?.usesPerCase) || 0; + if (!per) return true; // unlimited + return (Number(talent.system.usedThisCase) || 0) < per; +} + +/** + * Spend a talent. Charges its Coherence cost, increments its per-case usage, and + * announces it. Passive talents have no cost and no limit and are read where they + * apply rather than clicked here. + */ +export async function useTalent(actor, talent) { + if (!actor || !talent) return null; + if (!talentAvailable(talent)) { + ui.notifications?.warn(game.i18n.format("RINGBRP.Talent.Spent", { name: talent.name })); + return null; + } + const cost = Number(talent.system.coherenceCost) || 0; + if (cost && isAcross() === false && talent.system.category === "anomalous") { + // anomalous talents reach for the other side; using them at home is worse + ui.notifications?.info(game.i18n.localize("RINGBRP.Talent.AtHome")); + } + const per = Number(talent.system.usesPerCase) || 0; + if (per) await talent.update({ "system.usedThisCase": (Number(talent.system.usedThisCase) || 0) + 1 }); + if (cost) await loseCoherence(actor, "usedTransposed", { amount: cost, note: talent.name }); + + await ChatMessage.create({ + speaker: ChatMessage.getSpeaker({ actor }), + content: `
+

${talent.name}

+

${talent.system.summary ?? ""}

+

${talent.system.rule ?? ""}

+ ${per ? `
${game.i18n.format("RINGBRP.Talent.UsesLeft", + { n: per - (Number(talent.system.usedThisCase) || 0) })}
` : ""} +
` + }); + return talent; +} + +/** Requisition: trade one track against the other. An active, decision-shaped talent. */ +export async function requisition(actor, direction = "clearanceToResources") { + const cf = activeCaseFile(); + const t = hasTalent(actor, "requisition"); + if (!cf || !t || !talentAvailable(t)) return null; + const s = cf.system; + if (direction === "clearanceToResources" && (s.power?.value ?? 0) >= 1) { + await cf.update({ "system.power.value": s.power.value - 1, + "system.supply.value": Math.min(s.supply.max, s.supply.value + 2) }); + } else if (direction === "resourcesToClearance" && (s.supply?.value ?? 0) >= 2) { + await cf.update({ "system.supply.value": s.supply.value - 2, + "system.power.value": Math.min(s.power.max, s.power.value + 1) }); + } else { + ui.notifications?.warn(game.i18n.localize("RINGBRP.Talent.NoTrade")); + return null; + } + return useTalent(actor, t); +} + +/** Clean Scene: arm it, and the next Containment loss this case is cancelled. */ +export async function armCleanScene(actor) { + const t = hasTalent(actor, "clean_scene"); + if (!t || !talentAvailable(t)) return null; + const cf = activeCaseFile(); + if (cf) await cf.setFlag("ringbrp", "cleanSceneArmedBy", actor.id); + return useTalent(actor, t); +} + +/** Reset every talent's per-case usage. Called at end of case. */ +export async function resetTalents(actor) { + const used = (actor?.items ?? []).filter(i => i.type === "talent" && Number(i.system.usedThisCase) > 0); + for (const t of used) await t.update({ "system.usedThisCase": 0 }); + return used.length; +} + +/* -------------------------------------------- */ +/* Generation */ +/* -------------------------------------------- */ + +/* ---------------- roles ---------------- */ +/** Each role is a skill emphasis, a kit, a talent leaning and a rank feel. */ +export const ROLES = { + lead: { label: "Field Lead", + blurb: "Runs the team on the ground and signs for what happens to it. Firearms, tradecraft and the nerve to go first.", stat: "dex", + core: ["firearm:pistol","dodge","tradecraft","navigate","athletics","stealth"], + support: ["insight","spot","first_aid","melee_weapon:knife","pilot:ground_car"], + talents: ["dead_reckon","first_through","hard_landing","cover_work"], + kit: ["pistol_med","soft_armour","cover_id","key","torch", "warrant_card", "cordon_kit", "door_wedges"] }, + technical: { label: "Technical", + blurb: "Field systems, electronics and the physics of a site that is behaving incorrectly. Builds and breaks the equipment.", stat: "int", + core: ["engineering:field_systems","repair:electronics","computer_use","anomaly_lore","science:physics"], + support: ["repair:mechanical","research","first_aid","firearm:pistol","demolition"], + talents: ["chain_custody","requisition","clean_scene","echo_sense"], + kit: ["pistol_light","stab_vest","toolkit","scanner","power_cell", "density_scanner", "probe", "thermometer"] }, + talker: { label: "Liaison", + blurb: "The face the department shows a witness. Reads a room, holds a cover, and gets the sentence nobody meant to say.", stat: "cha", + core: ["insight","persuade","fast_talk","bargain","anthropology","language:trade_cant"], + support: ["tradecraft","xenology:baseline","spot","status","firearm:pistol"], + talents: ["read_room","cover_work","debriefer","wrong_memory"], + kit: ["pistol_light","jacket","cover_id","notebook","camera", "blanket", "clipboard", "vacuum_flask"] }, + medic: { label: "Physician", + blurb: "Keeps agents alive and works out what a body has been through. The only posting that can undo a Major Wound.", stat: "int", + core: ["medicine","first_aid","science:biology","insight","xenology:baseline"], + support: ["anthropology","spot","dodge","firearm:pistol","research"], + talents: ["triage","debriefer","read_room","not_quite_here"], + kit: ["pistol_light","stab_vest","medkit","fieldmedic","sample_case", "analgesics", "blanket", "specimen_jars"] }, + heavy: { label: "Containment", + blurb: "Containment. Goes through the door, holds the corridor, and puts things in boxes that do not want to be in boxes.", stat: "str", + core: ["firearm:rifle","brawl","melee_weapon:prybar","athletics","dodge"], + support: ["demolition","first_aid","repair:mechanical","spot","stealth"], + talents: ["hard_landing","chain_custody","first_through","clean_scene"], + kit: ["rifle_med","breach_suit","prybar","restraints","climbing_kit", "bolt_croppers", "cordon_kit", "ear_defenders"] }, + /* --------------------------------------------------------------- + The rest of the establishment. + + Five postings were never the whole agency — they were the ones a + field team fields. These are the other twenty on the books: the + registry that keeps the record crossings are made against, the + archaic postings the agency has never got round to abolishing, and + the specialists nobody calls until the day they are the only thing + that helps. Each carries its own kit, and each kit has at least one + thing no other posting is issued. + --------------------------------------------------------------- */ + + registry: { label: "Registry Officer", + blurb: "Keeps the record that a crossing is made against. Knows what the file says and, more usefully, what it used to say.", stat: "edu", + core: ["research","knowledge","anomaly_lore","language:own","computer_use","insight"], + support: ["spot","art","first_aid","tradecraft","firearm:pistol"], + talents: ["chain_custody","wrong_memory","debriefer","quorum"], + kit: ["spectacles","notebook","handset","jacket","pistol_light", "carbon_book", "date_stamp", "seals"] }, + + survey: { label: "Ground Survey Officer", + blurb: "Reads ground. Establishes where a site actually is, how it is shaped, and which way out will still be there later.", stat: "int", + core: ["navigate","engineering:field_systems","science:physics","spot","athletics"], + support: ["track","stealth","first_aid","repair:mechanical","firearm:pistol"], + talents: ["dead_reckon","first_through","hard_landing","echo_sense"], + kit: ["survey_kit","brass_compass","binocs","oilcloth_coat","rope", "probe", "groundsheet", "chalk"] }, + + anchor: { label: "Anchor Officer", + blurb: "Holds the door open. Transposition, and the discipline to stay filed correctly while doing it.", stat: "pow", + core: ["transposition","anomaly_lore","insight","listen","dodge"], + support: ["knowledge","spot","first_aid","tradecraft","research"], + talents: ["out_of_step","borrowed_hour","not_quite_here","distributed"], + kit: ["anchor","threshold_key","handset","power_cell","soft_armour", "locator_tags", "cold_light", "blanket"] }, + + 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"], + talents: ["debriefer","read_room","wrong_memory","unblinking"], + kit: ["dictaphone","cover_id","notebook","jacket","pistol_light", "carbon_book", "vacuum_flask", "clipboard"] }, + + warden: { label: "Night Warden", + blurb: "Sits with a site overnight so that nobody else has to. Listens, watches, and writes down the times.", stat: "con", + core: ["listen","spot","stealth","dodge","brawl"], + support: ["first_aid","navigate","athletics","melee_weapon:club","anomaly_lore"], + talents: ["unblinking","out_of_step","first_through","clean_scene"], + kit: ["oil_lantern","ward","cudgel","stab_vest","torch", "vacuum_flask", "stopwatch", "blanket"] }, + + stores: { label: "Stores Officer", + blurb: "Requisition, custody and the paperwork that moves materiel. Can get you a thing the department says it does not have.", stat: "edu", + core: ["bargain","status","knowledge","repair:mechanical","computer_use"], + support: ["insight","research","first_aid","firearm:pistol","tradecraft"], + talents: ["requisition","chain_custody","quorum","debriefer"], + kit: ["sealed_cannister","spare_cells","rations","water","jacket", "carbon_book", "seals", "master_keys"] }, + + transport: { label: "Transport Officer", + blurb: "Drives, flies, and gets a wounded team off a site. Keeps the vehicle running with what is in the boot.", stat: "dex", + core: ["pilot:ground_car","pilot:aircraft","navigate","repair:mechanical","spot"], + support: ["dodge","athletics","first_aid","firearm:pistol","listen"], + talents: ["dead_reckon","hard_landing","first_through","borrowed_hour"], + kit: ["beacon","revolver","impact_vest","torch","rope", "master_keys", "groundsheet", "blanket"] }, + + 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"], + talents: ["triage","chain_custody","unblinking","clean_scene"], + kit: ["chirurgeon_kit","gas_mask","envsuit","sample_case","dagger", "specimen_jars", "swab_kit", "barrier_gloves"] }, + + materials: { label: "Materials Officer", + blurb: "Substances, residues and the objects a site leaves behind. Handles what nobody else is cleared to touch.", stat: "int", + core: ["science:physics","science:botany","repair:electronics","research","anomaly_lore"], + support: ["engineering:field_systems","spot","first_aid","computer_use","firearm:pistol"], + talents: ["chain_custody","echo_sense","requisition","quorum"], + kit: ["field_kit","geiger","breather","stab_vest","pistol_light", "specimen_jars", "barrier_gloves", "seal_foam"] }, + + recording: { label: "Recording Officer", + blurb: "Documents the site as found. Photographs, measurements and an exhibit trail that survives a tribunal.", stat: "dex", + core: ["art","spot","computer_use","sleight_of_hand","stealth"], + support: ["repair:electronics","insight","first_aid","listen","firearm:pistol"], + talents: ["clean_scene","chain_custody","cover_work","echo_sense"], + kit: ["optic_visor","data_wafer","evidence_tape","jacket","pistol_light", "uv_lamp", "cordon_kit", "chalk"] }, + + access: { label: "Access Officer", + blurb: "Gets the team into places the owner has locked, and out again without a record of either.", stat: "dex", + core: ["sleight_of_hand","stealth","repair:mechanical","spot","dodge"], + support: ["computer_use","athletics","first_aid","tradecraft","melee_weapon:knife"], + talents: ["first_through","clean_scene","cover_work","out_of_step"], + kit: ["iron_manacles","monoblade","key","toolkit","soft_armour", "lock_rakes", "master_keys", "door_wedges"] }, + + disposal: { label: "Disposal Officer", + blurb: "Renders a thing safe, or renders it gone. Demolition, containment and a very steady hand.", stat: "int", + core: ["demolition","repair:electronics","engineering:field_systems","spot","dodge"], + support: ["first_aid","athletics","anomaly_lore","firearm:rifle","melee_weapon:axe"], + talents: ["first_through","hard_landing","chain_custody","unblinking"], + kit: ["field_net","entrenching_tool","hardsuit","toolkit","torch", "seal_foam", "cordon_kit", "ear_defenders"] }, + + overwatch: { label: "Overwatch Officer", + blurb: "Holds the long angle and tells the team what is coming. Shoots when nothing else will do.", stat: "dex", + core: ["firearm:rifle","spot","stealth","dodge","athletics"], + support: ["navigate","first_aid","listen","track","firearm:pistol"], + talents: ["dead_reckon","unblinking","out_of_step","hard_landing"], + kit: ["rifle_heavy","binocs","soft_armour","rations","torch", "spyglass", "groundsheet", "cold_light"] }, + + rigging: { label: "Rigging Officer", + blurb: "Height, depth and confined access. Gets people and equipment up and down shafts that were not built for it.", stat: "str", + core: ["athletics","repair:mechanical","engineering:field_systems","dodge","melee_weapon:axe"], + support: ["first_aid","spot","navigate","brawl","stealth"], + talents: ["hard_landing","first_through","requisition","distributed"], + kit: ["boarding_axe","climbing_kit","rope","impact_vest","torch", "bolt_croppers", "probe", "door_wedges"] }, + + translation: { label: "Translation Officer", + blurb: "Language, sign and the things said in neither. Reads a document nobody else in the building can.", stat: "int", + core: ["language:trade_cant","language:sign","language:own","anthropology","insight"], + support: ["research","knowledge","first_aid","xenology:baseline","firearm:pistol"], + talents: ["debriefer","read_room","quorum","wrong_memory"], + kit: ["wax_tablet","notebook","handset","jacket","pistol_light", "quill_ink", "carbon_book", "spectacles"] }, + + naturalist: { label: "Field Naturalist", + blurb: "Living things, and things that are pretending to be. Tracks, samples and identifies what came through.", stat: "int", + core: ["science:botany","science:biology","track","scent","spot"], + support: ["navigate","stealth","first_aid","athletics","melee_weapon:sword"], + talents: ["dead_reckon","echo_sense","unblinking","triage"], + kit: ["machete","sample_case","binocs","oilcloth_coat","rations", "specimen_jars", "swab_kit", "groundsheet"] }, + + antiquities: { label: "Antiquities Officer", + blurb: "An archaic posting the department never abolished. Old objects, older records, and the manners to ask about both.", stat: "edu", + core: ["knowledge","art","anomaly_lore","research","status"], + support: ["insight","spot","first_aid","melee_weapon:sword","firearm:pistol"], + talents: ["chain_custody","wrong_memory","borrowed_hour","quorum"], + kit: ["pocket_watch","tallow_candle","duelling_sword","brigandine","flintlock", "quill_ink", "sealing_wax", "sand_glass"] }, + + pursuit: { label: "Pursuit Officer", + blurb: "Follows what has left the site. Ground, scent, and the stamina to keep going after it stops being sensible.", stat: "con", + core: ["track","athletics","spot","listen","stealth"], + support: ["navigate","dodge","first_aid","scent","firearm:rifle"], + talents: ["dead_reckon","first_through","out_of_step","unblinking"], + kit: ["carbine","restraints","soft_armour","rations","torch", "locator_tags", "cold_light", "wake_tablets"] }, + + settlements: { label: "Settlements Officer", + blurb: "Makes the arrangement, files it, and never speaks of the terms again. Most cases close here rather than in a fight.", stat: "cha", + core: ["bargain","status","insight","persuade","knowledge"], + support: ["research","anthropology","first_aid","tradecraft","firearm:pistol"], + talents: ["read_room","debriefer","requisition","quorum"], + kit: ["quiet_ledger","cover_id","notebook","jacket","pistol_light", "carbon_book", "sealing_wax", "vacuum_flask"] }, + + signals: { label: "Signals Officer", + blurb: "Communications, interception and the electronics of a site that is broadcasting when it should not be.", stat: "int", + core: ["repair:electronics","computer_use","listen","engineering:field_systems","spot"], + support: ["research","first_aid","dodge","firearm:pistol","anomaly_lore"], + talents: ["echo_sense","distributed","quorum","requisition"], + kit: ["survey_drone","handset","power_cell","stab_vest","pistol_light", "field_slate", "locator_tags", "uv_lamp"] } +}; + +/** Experience tiers: how good, and what the agency calls you. */ +/** + * What every officer is issued regardless of posting, what seniority adds, and the + * one thing that is theirs rather than the department's. + * + * An agent used to leave creation with five things and an anchor, which is not what + * a real posting looks like — most of this job is small equipment and paperwork. + */ +export const STANDING_ISSUE = ["anchor", "handset", "warrant_card", "dressings", + "torch", "chalk", "whistle", "notebook"]; + +/** Seniority is issued more, and is trusted with more. */ +export const TIER_ISSUE = { + probationary: [], + officer: ["cover_id"], + senior: ["cover_id", "seals", "master_keys"], + veteran: ["cover_id", "seals", "master_keys", "carbon_book", "trauma_unit"] +}; + +/** + * One personal effect. Not issued, not logged, and the reason the anchor works — + * an agent who carries nothing of their own has nothing to be filed against. + */ +export const PERSONAL_EFFECTS = ["pocket_watch", "spectacles", "brass_compass", + "vacuum_flask", "notebook", "camera", "tallow_candle", + "wax_tablet", "sand_glass", "returning_coin"]; + +export const TIERS = { + probationary: { label: "Probationary", rank: "Probationary", core: [35, 50], support: [20, 35], talents: 1, bonusPts: 0 }, + officer: { label: "Field Officer", rank: "Field Officer", core: [45, 60], support: [25, 45], talents: 2, bonusPts: 20 }, + senior: { label: "Senior Field Officer", rank: "Senior Field Officer", core: [55, 70], support: [30, 50], talents: 3, bonusPts: 50 }, + veteran: { label: "Case Officer", rank: "Case Officer", core: [65, 80], support: [35, 55], talents: 4, bonusPts: 90 } +}; + +/** NPC threat tiers — bystanders through anomalies, and the things in between. */ +/** + * Threat tiers. `attacks` pairs a weapon with the EXACT skill that fires it — + * they are granted together or not at all. Previously the tier chose the weapon + * and an unrelated random role chose the skills, so most generated NPCs could not + * use what they were holding: eight Apexes in ten had no usable attack. + */ +export const THREATS = { + bystander: { label: "Bystander", hpMul: 1.0, armour: 0, skill: [15, 30], talents: 0, attacks: [] }, + trained: { label: "Trained", hpMul: 1.0, armour: 1, skill: [30, 45], talents: 0, + attacks: [{ weapon: "dagger", skill: "melee_weapon:knife" }] }, + armed: { label: "Armed", hpMul: 1.0, armour: 2, skill: [40, 55], talents: 0, + attacks: [{ weapon: "pistol_med", skill: "firearm:pistol" }] }, + dangerous: { label: "Dangerous", hpMul: 1.2, armour: 3, skill: [50, 65], talents: 1, + attacks: [{ weapon: "rifle_med", skill: "firearm:rifle" }, + { weapon: "baton", skill: "melee_weapon:club" }] }, + anomalous: { label: "Anomalous", hpMul: 1.5, armour: 5, skill: [55, 75], talents: 2, + attacks: [{ weapon: "grasp", skill: "brawl" }] }, + apex: { label: "Apex", hpMul: 2.0, armour: 7, skill: [65, 85], talents: 3, + attacks: [{ weapon: "grasp", skill: "brawl" }, + { weapon: "unmaker", skill: "energy_weapon:rifle" }] } +}; + +/** Species: characteristic shifts and the location profile they use. */ +export const SPECIES = { + baseline: { label: "Baseline", profile: "baseline", shift: {} }, + vesh: { label: "Vesh", profile: "vesh", shift: { dex: +2, cha: -2, pow: +1 }, talents: ["unblinking","out_of_step"] }, + cadence: { label: "Cadence", profile: "cadence", shift: { con: +2, str: -2, int: +1 }, talents: ["distributed","quorum"] } +}; + +/* ---------------- name pools ---------------- */ +const GIVEN = ["Ceyla","Dunn","Rakhi","Tomas","Ambaro","Nils","Halina","Ottoline","Bez","Marek", + "Sunniva","Idris","Perpetua","Jorge","Nkechi","Vasha","Emeric","Lark","Tobin","Saoirse", + "Anneke","Ruben","Ilse","Cadogan","Mirren","Fenn","Orla","Kester","Zofia","Rune"]; +const FAMILY = ["Marspont","Halvorsen","Oyelaran-Vost","Ekwueme","Sandoval","Achterberg","Quist", + "Delacroix-Ng","Bramall","Iwu","Fenwick","Oyelaran","Strand","Adeyemi","Kowalczyk","Rees-Mogg", + "Vanterpool","Okonkwo","Lindqvist","Brennan","Achebe","Dumont","Sarraceno","Halloran","Vance"]; +const NPC_NAMES = { bystander:["Local","Witness","Neighbour","Publican","Warden","Ferryman"], + trained:["Constable","Ranger","Fisher","Guard","Surveyor"], + armed:["Contractor","Cleaner","Sentry","Enforcer"], + dangerous:["Team lead","Specialist","Hunter","Marksman"], + anomalous:["Revenant","Echo","Walker","The thing in the water"], + apex:["Displaced","The one that came back","Apex"] }; + +export const NAME_POOLS = { GIVEN, FAMILY, NPC_NAMES }; + + +const rndInt = (lo, hi) => lo + Math.floor(Math.random() * (hi - lo + 1)); +const pick = arr => arr[Math.floor(Math.random() * arr.length)]; +const pickN = (arr, n) => { + const a = [...arr]; const out = []; + while (out.length < n && a.length) out.push(a.splice(Math.floor(Math.random() * a.length), 1)[0]); + return out; +}; +async function rollTotal(formula) { return (await new Roll(formula).evaluate()).total; } + +/** + * Build a fully formed, playable agent. EVERY option is optional: called with no + * arguments it produces a complete random character who could be dropped into a + * case immediately. Pass any subset to pin those choices and leave the rest random. + */ +export async function generateCharacter({ + name = null, role = null, tier = "officer", species = null, + rank = null, extraTalents = [], toChat = true +} = {}) { + // An unrecognised role used to reach `R.stat` as undefined and throw, which turns + // a typo in a macro into a stack trace. Fall back the way tier already does. + const roleKey = (role && ROLES[role]) ? role : pick(Object.keys(ROLES)); + const tierKey = TIERS[tier] ? tier : "officer"; + const R = ROLES[roleKey], T = TIERS[tierKey]; + // Baseline is common but not universal; a bare call should sometimes surprise you. + const SP = SPECIES[species ?? pick(["baseline","baseline","baseline","vesh","cadence"])] ?? SPECIES.baseline; + + // characteristics: BRP rolls, nudged so the role's key stat is actually good + const ch = {}; + // Exactly the eight the data model defines. This used to roll `app` and `mas`, + // which do not exist, so the Vesh APP shift was modifying a dead field. + for (const [k, f] of Object.entries({ str: "3d6", con: "3d6", siz: "2d6+6", int: "2d6+6", + pow: "3d6", dex: "3d6", cha: "3d6", edu: "3d6+3" })) { + ch[k] = await rollTotal(f); + } + // Best of the original and two focused rolls: the role's stat is never weak, + // and Containment is strong enough to carry what Containment is issued. + ch[R.stat] = Math.max(ch[R.stat], await rollTotal("2d6+6"), await rollTotal("2d6+6")); + for (const [k, d] of Object.entries(SP.shift ?? {})) ch[k] = Math.max(3, (ch[k] ?? 10) + d); + + const finalName = name ?? `${pick(NAME_POOLS.GIVEN)} ${pick(NAME_POOLS.FAMILY)}`; + const age = rndInt(26, 54); + const actor = await Actor.create({ + name: finalName, type: "character", + img: portraitFor(SP.profile), + // R-214. Foundry defaults actorLink to FALSE, and this never set it — so every + // agent the in-game generator produced was an unlinked token. Put one on the + // map and the damage, the wounds and the Major Wound all landed on the token's + // private copy while the player's sheet stayed pristine. The pack builder has + // always linked characters; this path did not. An agent is one person. + prototypeToken: { name: finalName, actorLink: true, displayName: 20, disposition: 1 }, + system: { + characteristics: Object.fromEntries(Object.entries(ch).map(([k, v]) => [k, { value: v }])), + speciesProfile: SP.profile, + rank: rank ?? T.rank, + age: { chronological: age, physiological: age }, + biography: buildBiography({ name: finalName, role: R, tier: T, species: SP, age }) + } + }); + + // skills from the compendium, rated by tier + const pack = await game.packs.get("ringbrp.skills").getDocuments(); + const byKey = new Map(pack.map(d => [d.system.specialisationId + ? `${d.system.familyId}:${d.system.specialisationId}` : d.system.familyId, d])); + const items = []; + const add = (key, range) => { + const doc = byKey.get(key); if (!doc) return; + items.push({ ...doc.toObject(), system: { ...doc.system, value: rndInt(range[0], range[1]) } }); + }; + for (const k of R.core) add(k, T.core); + for (const k of R.support) add(k, T.support); + // spread the tier's bonus points over the core skills so no two agents are identical + const language = byKey.get("language:trade_cant"); + if (language && !items.some(i => i.name === language.name)) { + items.push({ ...language.toObject(), system: { ...language.system, value: rndInt(40, 80) } }); + } + + // talents: the role's leaning, plus anything the species always has + const talentPack = await game.packs.get("ringbrp.talents").getDocuments(); + const wanted = [...pickN(R.talents, T.talents), ...(SP.talents ?? []), ...extraTalents]; + for (const key of [...new Set(wanted)]) { + const doc = talentPack.find(d => d.system.talentId === key); + if (doc) items.push(doc.toObject()); + } + + // kit + const kitDocs = []; + for (const packName of ["weapons", "armour", "gear"]) { + kitDocs.push(...await game.packs.get(`ringbrp.${packName}`).getDocuments()); + } + const missingKit = []; + // Standing issue, then the posting's kit, then what seniority adds, then one + // thing that is the agent's own. Duplicates fall out on the name check below. + const issue = [...STANDING_ISSUE, ...R.kit, ...(TIER_ISSUE[tierKey] ?? []), + pick(PERSONAL_EFFECTS)]; + for (const key of issue) { + const doc = kitDocs.find(d => d.name && keyMatches(d, key)); + if (!doc) { missingKit.push(key); continue; } + if (items.some(i => i.name === doc.name)) continue; + items.push({ ...doc.toObject(), system: { ...doc.system, equipped: true, carried: true } }); + } + if (missingKit.length) { + console.error(`Custodians | ${R.label}: no catalogue item for kit ${missingKit.join(", ")}`); + ui.notifications?.error(game.i18n.format("RINGBRP.Notify.MissingKit", + { role: R.label, keys: missingKit.join(", ") })); + } + // A talent that modifies a skill the character does not have is dead on arrival + // — this is the Cover Work / Fast Talk bug, reproduced by the generator until + // now. Grant the skill any granted talent modifies. + for (const it of items.filter(i => i.type === "talent")) { + const fam = it.system?.skillBonus?.skillFamilyId; + if (!fam) continue; + const already = items.some(i => i.type === "skill" + && (i.system.specialisationId ? `${i.system.familyId}:${i.system.specialisationId}` : i.system.familyId) === fam); + if (already) continue; + const doc = byKey.get(fam); + if (doc) items.push({ ...doc.toObject(), system: { ...doc.system, value: rndInt(T.support[0], T.support[1]) } }); + } + // Everyone has hands. Brawl is a skill every character carries and "Punch / kick" + // was issued to none of them, so an unarmed attack could not roll damage. + const punch = kitDocs.find(d => d.flags?.ringbrp?.key === "punch"); + if (punch && !items.some(i => i.name === punch.name)) { + items.push({ ...punch.toObject(), system: { ...punch.system, equipped: true, carried: true } }); + } + // The anchor and the handset are part of STANDING_ISSUE above — every officer + // carries both, and the anchor is the character hook and the only way back. + // Reserve ammunition, so a reload is a real option rather than a declared one. + for (const it of items.filter(i => i.type === "weapon" && Number(i.system?.ammo?.capacity))) { + it.system = { ...it.system, ammo: { ...it.system.ammo, reserve: (Number(it.system.ammo.capacity) || 0) * 2 } }; + } + if (items.length) await actor.createEmbeddedDocuments("Item", items); + await grantFullSkillList(actor); // every skill visible, untrained ones at base + await stowOverload(actor); + + // bonus points, spent after creation so derived values are live + let pts = T.bonusPts; + const own = actor.items.filter(i => i.type === "skill"); + while (pts > 0 && own.length) { + const sk = pick(own); const spend = Math.min(pts, rndInt(3, 10)); + await sk.update({ "system.value": (Number(sk.system.value) || 0) + spend }); + pts -= spend; + } + if (toChat) await announceGenerated(actor, `${T.label} · ${R.label} · ${SP.label}`); + return actor; +} + +/** + * Match a compendium item to a generator kit key. + * + * This used to be fuzzy matching on the display name against a hand-maintained + * alias table, and it dropped anything the table had not heard of — silently. A + * Stores Officer issued five things received one, an Overwatch Officer never got + * the rifle they are named for, and `climbing_kit` was mapped to "breaching + * charges" outright. Items now carry their catalogue key in flags, so the match + * is exact; the name pass is only a fallback for anything built before the flag + * existed, and a key that resolves to nothing is now reported rather than lost. + */ +function keyMatches(doc, key) { + const stamped = doc.flags?.ringbrp?.key; + if (stamped) return stamped === key; + return doc.name?.toLowerCase().includes(key.replace(/_/g, " ")); +} +/** + * Build an NPC, a monster, or any of the things in between. Same contract: no + * arguments gives you something usable; every knob is available if you want it. + */ +/** Unknown table key: fall back to a sane default and tell the user which. */ +function warnKey(kind, given, fallback) { + console.warn(`Custodians | unknown ${kind} "${given}" — using "${fallback}"`); + ui.notifications?.warn(`Unknown ${kind} "${given}" — using "${fallback}".`); + return fallback; +} + +export async function generateNPC({ + name = null, threat = null, species = null, role = null, toChat = true +} = {}) { + // R-208. The species lookup fell back to baseline on an unknown key, but threat + // and role did not — a caller passing a role the table has never heard of got a + // TypeError on `R.label` rather than an NPC. Fall back consistently, and say so, + // because a silent substitution during a case is worse than a noisy one. + const tKey = THREATS[threat] ? threat : (threat ? warnKey("threat", threat, "trained") : pick(Object.keys(THREATS))); + const TH = THREATS[tKey]; + const SP = SPECIES[species ?? pick(["baseline","baseline","baseline","vesh","cadence"])] ?? SPECIES.baseline; + const roleKey = ROLES[role] ? role : (role ? warnKey("role", role, "lead") : pick(Object.keys(ROLES))); + const R = ROLES[roleKey]; + + const ch = {}; + for (const [k, f] of Object.entries({ str: "3d6", con: "3d6", siz: "2d6+6", int: "2d6+6", + pow: "3d6", dex: "3d6", cha: "3d6", edu: "2d6+3" })) ch[k] = await rollTotal(f); + for (const [k, d] of Object.entries(SP.shift ?? {})) ch[k] = Math.max(3, (ch[k] ?? 10) + d); + if (tKey === "anomalous" || tKey === "apex") { ch.str += 4; ch.con += 4; ch.pow += 4; ch.cha = Math.max(3, ch.cha - 6); } + + const finalName = name ?? `${pick(NAME_POOLS.NPC_NAMES[tKey] ?? ["Figure"])}`; + const actor = await Actor.create({ + name: finalName, type: "npc", + img: portraitFor(SP.profile, true), + // Deliberately NOT linked: one statblock stands up four bodies, and each needs + // its own hit points. + prototypeToken: { name: finalName, actorLink: false, displayName: 20, disposition: -1 }, + system: { + characteristics: Object.fromEntries(Object.entries(ch).map(([k, v]) => [k, { value: v }])), + speciesProfile: SP.profile, + naturalArmour: { value: TH.armour }, + role: `${TH.label} · ${R.label}` + } + }); + + const pack = await game.packs.get("ringbrp.skills").getDocuments(); + const byKey = new Map(pack.map(d => [d.system.specialisationId + ? `${d.system.familyId}:${d.system.specialisationId}` : d.system.familyId, d])); + const items = []; + for (const k of [...R.core, ...pickN(R.support, 2)]) { + const doc = byKey.get(k); if (!doc) continue; + items.push({ ...doc.toObject(), system: { ...doc.system, value: rndInt(TH.skill[0], TH.skill[1]) } }); + } + // Attack packages: the weapon and the skill that fires it, together. + const weapons = await game.packs.get("ringbrp.weapons").getDocuments(); + for (const atk of TH.attacks ?? []) { + const doc = weapons.find(d => keyMatches(d, atk.weapon)); + if (!doc) continue; + items.push({ ...doc.toObject(), system: { ...doc.system, equipped: true } }); + const sk = byKey.get(atk.skill); + if (sk && !items.some(i => i.name === sk.name)) { + items.push({ ...sk.toObject(), system: { ...sk.system, value: rndInt(TH.skill[0], TH.skill[1]) } }); + } + } + // R-213 again, in the one construction path it was not applied to. The pack + // builder and generateCharacter both issue the unarmed attack; this did not, so + // a generated adversary armed only with a pistol had literally nothing to do at + // close quarters — a whole fight ran with four of them standing there. + // + // This runs AFTER the attack packages on purpose. A creature whose attack IS + // Brawl — the anomalous grasp, the apex — has had its full rating set above and + // keeps it. Only an adversary falling back on its fists gets the reduced figure. + { + const punch = weapons.find(d => d.flags?.ringbrp?.key === "punch"); + if (punch && !items.some(i => i.name === punch.name)) { + items.push({ ...punch.toObject(), system: { ...punch.system, equipped: true } }); + } + const brawl = byKey.get("brawl"); + if (brawl && !items.some(i => i.name === brawl.name)) { + items.push({ ...brawl.toObject(), + system: { ...brawl.system, + value: brawlFallbackFor(rndInt(TH.skill[0], TH.skill[1])) } }); + } + } + // Species talents are not optional flavour — a Cadence without Distributed has a + // vital location it should not have, and fights wrongly. They come first. + const talents = await game.packs.get("ringbrp.talents").getDocuments(); + for (const key of SP.talents ?? []) { + const doc = talents.find(t => t.system.talentId === key); + if (doc) items.push(doc.toObject()); + } + if (TH.talents) { + for (const doc of pickN(talents.filter(t => t.system.category === "anomalous"), TH.talents)) { + if (!items.some(i => i.name === doc.name)) items.push(doc.toObject()); + } + } + if (items.length) await actor.createEmbeddedDocuments("Item", items); + if (toChat) await announceGenerated(actor, `${TH.label} · ${SP.label} · ${R.label}`); + return actor; +} + +/** + * Everyone is ISSUED a full kit; nobody carries all of it. Anything beyond free + * carry is stowed (carried: false) heaviest-first, so a Containment agent does not + * begin every case permanently slower for owning her own breaching charges. + * Weapon and armour are never stowed — you keep what keeps you alive. + */ +async function stowOverload(actor) { + const free = Number(actor.system.freeCarryKg) || 0; + const isAnchor = i => /anchor/i.test(i.name); // the character hook stays on the person + // A secondary melee weapon is a tool as much as a weapon and can be slung. + const primary = actor.items.find(i => i.type === "weapon" && i.system?.isRanged) + ?? actor.items.find(i => i.type === "weapon"); + const stowable = actor.items + .filter(i => (["gear", "device"].includes(i.type) || (i.type === "weapon" && i.id !== primary?.id)) + && i.system?.carried !== false && !isAnchor(i)) + .sort((a, b) => (Number(b.system.massKg) || 0) - (Number(a.system.massKg) || 0)); + const stowed = []; + for (const it of stowable) { + const over = (Number(actor.system.carriedKg) || 0) - free; + if (over <= 0) break; + // Only stow when it actually helps. Shedding a 0.1 kg keepsake to fix a + // 4 kg overload achieves nothing except losing the keepsake. + if ((Number(it.system.massKg) || 0) < Math.min(1, over)) continue; + await it.update({ "system.carried": false }); + stowed.push(it.name); + } + return stowed; +} + +/** A portrait that varies by species, so a party is not five identical squares. */ +function portraitFor(profile, isNpc = false) { + const n = i => `systems/ringbrp/icons/${i}.svg`; + if (isNpc) return n(`portrait_n${rndInt(0, 4)}`); + if (profile === "vesh") return n(`portrait_v${rndInt(0, 4)}`); + if (profile === "cadence") return n(`portrait_c${rndInt(0, 4)}`); + return n(`portrait_b${rndInt(0, 9)}`); +} + +/** A short service record. Not literature, but not a blank box either. */ +function buildBiography({ name, role, tier, species, age }) { + const origin = ["a coastal town nobody visits twice", "an unremarkable suburb", + "a farm that is now a car park", "a city that has forgotten them", "two rooms above a shop", + "a rig in the North Sea", "a mining settlement", "somewhere that made them careful"]; + const recruited = [ + "Recruited after a screening test taken for the money.", + "Recruited after being the only witness who described it accurately.", + "Walked in and asked. Nobody has established how they knew where to walk.", + "Transferred from a department that no longer exists.", + "Recruited from the analysis wing and has never stopped writing reports in their head." + ]; + const anchor = [ + "a landline that still rings", "a dog that is getting old", "a brother who does not know", + "a house they have not sold", "a record they cannot play anywhere else", + "a standing Thursday appointment they have never missed" + ]; + const note = { + lead: "Reads the window times twice. Has been through more thresholds than the roster admits.", + technical: "Can fix it, and would rather understand it than fix it.", + talker: "Notices when the locals are frightened of the wrong thing.", + medic: "Joined to treat agents. Spends most of the work trying to talk to people.", + heavy: "Completely still until they are not." + }[Object.keys(ROLES).find(k => ROLES[k] === role)] ?? ""; + return `

${role.label}, ${tier.label}. ${species.label}, ${age}, out of ${pick(origin)}. ${pick(recruited)}

` + + `

${note}

` + + `

Anchor: ${pick(anchor)}.

`; +} + +async function announceGenerated(actor, subtitle) { + const s = actor.system; + await ChatMessage.create({ + speaker: ChatMessage.getSpeaker({ actor }), + content: `

${actor.name}

+
${subtitle}
+
+ HP ${s.hp?.value ?? "?"}/${s.hp?.max ?? "?"} + MW ${s.majorWound?.value ?? "?"} + Reaction ${s.reaction?.base ?? "?"} + ${actor.items.filter(i => i.type === "skill").length} skills + ${actor.items.filter(i => i.type === "talent").length} talents +
`, + whisper: ChatMessage.getWhisperRecipients("GM").map(u => u.id) + }); +} + +/** + * Put the whole catalogue into the world's Items directory so it can be browsed, + * edited and dragged onto sheets. Idempotent: run it as often as you like. + */ +export async function installContent({ notify = true } = {}) { + // Every item pack, not just skills and talents: a GM should be able to browse + // and drag anything the game ships with. + const packs = ["skills", "talents", "weapons", "armour", "gear"]; + const made = {}; + let total = 0; + const existing = new Set(game.items.map(i => `${i.type}:${i.name}`)); + + // One folder per pack. Installing everything into a single flat list put 120-odd + // items in one alphabetical column, where a pistol sits between two skills and + // nothing can be found. Folders are reused by name across re-installs so running + // this twice does not produce "Weapons" and "Weapons (2)". + const folderFor = async (name, parentId = null) => { + const label = game.i18n.localize(`RINGBRP.Folder.${name}`); + return game.folders.find(f => f.type === "Item" && f.name === label && (f.folder?.id ?? null) === parentId) + ?? await Folder.create({ name: label, type: "Item", sorting: "a", folder: parentId }); + }; + + for (const packName of packs) { + const pack = game.packs.get(`ringbrp.${packName}`); + if (!pack) continue; + const docs = await pack.getDocuments(); + const toMake = docs.filter(d => !existing.has(`${d.type}:${d.name}`)).map(d => { + existing.add(`${d.type}:${d.name}`); + return d.toObject(); + }); + if (toMake.length) { + const parent = await folderFor(packName); + // Weapons, armour and gear span centuries now, so they are filed by era + // inside their type. A flintlock and a gauss rifle are both weapons and + // nobody wants them adjacent in one alphabetical column. + const eras = new Set(toMake.map(o => o.flags?.ringbrp?.era).filter(Boolean)); + const sub = {}; + if (eras.size > 1) { + for (const era of eras) sub[era] = await folderFor(`era.${era}`, parent.id); + } + await Item.createDocuments(toMake.map(o => ({ + ...o, folder: (sub[o.flags?.ringbrp?.era] ?? parent).id + }))); + } + made[packName] = toMake.length; + total += toMake.length; + } + + // Anything already installed flat before this change is filed too, so an existing + // world tidies itself instead of keeping two conventions side by side. + const byType = { skill: "skills", talent: "talents", weapon: "weapons", + armour: "armour", gear: "gear", device: "gear" }; + const orphans = game.items.filter(i => !i.folder && byType[i.type]); + if (orphans.length) { + const cache = {}; + const updates = []; + for (const i of orphans) { + const key = byType[i.type]; + cache[key] ??= await folderFor(key); + updates.push({ _id: i.id, folder: cache[key].id }); + } + await Item.updateDocuments(updates); + } + if (notify) ui.notifications?.info(game.i18n.format("RINGBRP.Install.Done", { n: total })); + return made; +} + +/** + * Give an actor the FULL skill catalogue at printed base, keeping any rating it + * already has. This is how a BRP sheet is meant to read: every skill visible with + * its base chance, so a player can see what they could try, not only what they + * trained. Without it a sheet shows eleven skills across seven categories and + * looks broken. + */ +export async function grantFullSkillList(actor) { + if (!actor) return 0; + const pack = await game.packs.get("ringbrp.skills").getDocuments(); + const have = new Set(actor.items.filter(i => i.type === "skill").map(i => + i.system.specialisationId ? `${i.system.familyId}:${i.system.specialisationId}` : i.system.familyId)); + const add = pack.filter(d => { + const k = d.system.specialisationId ? `${d.system.familyId}:${d.system.specialisationId}` : d.system.familyId; + return !have.has(k); + }).map(d => { + const o = d.toObject(); + o.system.value = Number(o.system.base?.value ?? o.system.base ?? 0) || 0; // untrained: printed base + return o; + }); + if (add.length) await actor.createEmbeddedDocuments("Item", add); + return add.length; +} + +/** + * The generation dialog. Opens with everything already chosen at random, so the + * fastest path is: open, press Create. Change anything you care about and leave + * the rest. "Randomise" re-rolls the unlocked choices. + */ +export async function generateDialog(kind = "character") { + const isPC = kind === "character"; + const opts = (obj, sel) => Object.entries(obj).map(([k, v]) => + ``).join(""); + const rnd = a => a[Math.floor(Math.random() * a.length)]; + const roleSel = rnd(Object.keys(ROLES)); + const tierSel = "officer"; + const threatSel = rnd(Object.keys(THREATS)); + + const content = `
+
+
+
+
+
+
+
+

${game.i18n.localize("RINGBRP.Gen.RoleRandomBlurb")}

+
+ ${isPC ? ` +
+
+ ` + : `
+
`} +
+
+

${game.i18n.localize("RINGBRP.Gen.Hint")}

+
`; + + // Names for the card, read from the compendium so the dialog cannot print a + // different name for an item than the sheet does. + const kitNames_ = new Map(); + for (const pk of ["weapons", "armour", "gear"]) { + for (const d of await game.packs.get(`ringbrp.${pk}`).getDocuments()) { + const k = d.flags?.ringbrp?.key; if (k) kitNames_.set(k, d.name); + } + } + const skillNames = new Map(); + for (const d of await game.packs.get("ringbrp.skills").getDocuments()) { + const id = d.system.specialisationId + ? `${d.system.familyId}:${d.system.specialisationId}` : d.system.familyId; + skillNames.set(id, d.name); + } + + // A dropdown of twenty-five job titles tells you nothing about what any of them + // does. Describe the posting as it is selected, with the numbers that follow from + // it, so the choice is informed rather than a guess at a name. + const roleCard = key => { + const R = ROLES[key]; + if (!R) return `

${game.i18n.localize("RINGBRP.Gen.RoleRandomBlurb")}

`; + // Real catalogue names, not tidied-up ids: "Quill and ink horn", not "Quill Ink". + const skill = id => skillNames.get(id) ?? id; + const kitNames = R.kit.map(k => kitNames_.get(k) ?? k.replace(/_/g, " ")); + return `

${R.blurb ?? ""}

+
+
${game.i18n.localize("RINGBRP.Gen.KeyStat")}
+
${R.stat.toUpperCase()}
+
${game.i18n.localize("RINGBRP.Gen.Trains")}
+
${R.core.map(skill).join(" · ")}
+
${game.i18n.localize("RINGBRP.Gen.Draws")}
+
${kitNames.join(" · ")}
+
`; + }; + + return new Promise(resolve => { + new (rbDialogClass())({ + title: game.i18n.localize(isPC ? "RINGBRP.Gen.NewAgent" : "RINGBRP.Gen.NewNPC"), + content, + render: html => { + const el = html[0] ?? html; + const sel = el.querySelector("[name=role]"); + const card = el.querySelector("[data-rolecard]"); + if (!sel || !card) return; + const paint = () => { card.innerHTML = roleCard(sel.value); }; + sel.addEventListener("change", paint); + paint(); + }, + buttons: { + create: { + label: game.i18n.localize("RINGBRP.Gen.Create"), + callback: async html => { + const el = html[0] ?? html; + const v = n => el.querySelector(`[name=${n}]`)?.value || null; + const count = Math.max(1, Math.min(12, Number(v("count")) || 1)); + // "Random" must be resolved PER ACTOR. Resolving it once and reusing + // the answer turned "make four" into four copies of one character. + const each = n => { + const val = v(n); + return (!val || val === "random") ? null : val; + }; + const made = []; + // Party mode: when several PCs are made with the role left random, + // guarantee coverage rather than rolling five Field Leads. + const roleFixed = each("role"); + const roster = (isPC && !roleFixed && count > 1) + ? pickN(Object.keys(ROLES), Math.min(count, Object.keys(ROLES).length)) + : []; + for (let i = 0; i < count; i++) { + const name = count > 1 && v("name") ? `${v("name")} ${i + 1}` : v("name"); + const role = roleFixed ?? roster[i] ?? pick(Object.keys(ROLES)); + made.push(isPC + ? await generateCharacter({ name, role, tier: each("tier") ?? "officer", + species: each("species") ?? "baseline", + }) + : await generateNPC({ name, role, threat: each("threat"), species: each("species") ?? "baseline" })); + } + if (made.length === 1) made[0].sheet.render(true); + ui.notifications?.info(game.i18n.format("RINGBRP.Gen.Made", { n: made.length })); + resolve(made); + } + }, + cancel: { label: game.i18n.localize("RINGBRP.Dialog.Cancel"), callback: () => resolve([]) } + }, + default: "create", close: () => resolve([]) + }).render(true); + }); +} + +/** Install button on the Items sidebar, so the catalogue is browsable. */ +/** + * Kit dropped onto a character is READIED, not left in the kitbag. + * + * The catalogue packs ship armour and weapons with `equipped: false`, which is + * right for a compendium entry and wrong the moment one lands on an actor: a vest + * dragged onto a sheet contributed 0 armour and a pistol never reached the readied + * strip, both silently. Nothing was broken enough to notice — the item was there, + * the number simply did not move. + * + * Only the FIRST of each kind is readied. Drop a spare vest or a second pistol and + * it stays stowed, because stacking armour points or crowding the strip by accident + * is the opposite failure. Anything that arrives explicitly equipped — pregens, + * generated characters — is left exactly as it is. + */ +Hooks.on("preCreateItem", (item, data) => { + if (!["armour", "weapon"].includes(item.type)) return; + const actor = item.parent; + if (!(actor instanceof Actor)) return; + if (data?.system?.equipped) return; // already readied on purpose + const alreadyWearing = actor.items.some(i => i.type === item.type && i.system?.equipped); + if (alreadyWearing) return; + item.updateSource({ "system.equipped": true, "system.carried": true }); +}); + +/* ============================================================ + * DICE — The Custodians + * + * The set is the setting. The agency keeps a filing system and the game is about + * that filing system being wrong, so the dice carry a ruled registry grid — and + * the further an agent drifts from their own reality, the less the grid agrees + * with itself. An Anchored agent rolls on true rule; an Unmoored one rolls on a + * grid that has started to tear; a Displaced one rolls on something that was never + * filed at all. + * + * 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; + const bar = document.createElement("div"); + bar.className = "rb-genbar"; + bar.innerHTML = ``; + bar.querySelector("button").addEventListener("click", () => installContent()); + (root.querySelector(".directory-header") ?? root).prepend(bar); +}); + +/** Buttons on the Actors sidebar. One click each; the dialog is pre-randomised. */ +Hooks.on("renderActorDirectory", (app, html) => { + const root = html instanceof HTMLElement ? html : html?.[0]; + if (!root || !game.user.isGM || root.querySelector(".rb-genbar")) return; + const bar = document.createElement("div"); + bar.className = "rb-genbar"; + bar.innerHTML = ` + `; + bar.querySelectorAll("button").forEach(b => + b.addEventListener("click", () => generateDialog(b.dataset.kind))); + (root.querySelector(".directory-header") ?? root).prepend(bar); +}); + +/* -------------------------------------------- */ +/* Sheets */ +/* -------------------------------------------- */ + +const { ActorSheet, ItemSheet } = foundry.appv1.sheets; + +class RingBRPCharacterSheet extends ActorSheet { + static get defaultOptions() { + return foundry.utils.mergeObject(super.defaultOptions, { + classes: ["ringbrp", "sheet", "actor", "character"], + template: "systems/ringbrp/templates/character-sheet.hbs", + width: 820, + height: 880, + tabs: [{ navSelector: ".sheet-tabs", contentSelector: ".sheet-body", initial: "skills" }] + }); + } + + async getData(options) { + const ctx = await super.getData(options); + const sys = this.actor.system; + ctx.sys = sys; + ctx.RINGBRP = RINGBRP; + ctx.isCharacter = this.actor.type === "character"; + + ctx.skillsByCategory = {}; + for (const key of Object.keys(RINGBRP.categories)) ctx.skillsByCategory[key] = []; + for (const it of this.actor.items) { + if (it.type !== "skill") continue; + (ctx.skillsByCategory[it.system.categoryId] ??= []).push(it); + } + for (const arr of Object.values(ctx.skillsByCategory)) arr.sort((a, b) => a.name.localeCompare(b.name)); + + ctx.weapons = this.actor.items.filter(i => i.type === "weapon"); + ctx.equippedWeapons = ctx.weapons.filter(w => w.system.equipped); + ctx.armours = this.actor.items.filter(i => i.type === "armour"); + // Transport is listed but never weighed: a vehicle is not carried, it carries. + // Access the team holds. Kept beside the gear because that is what it is: a + // thing you carry, spend, and can lose. + ctx.authorities = this.actor.items.filter(i => i.type === "authority").map(a => ({ + ...a, id: a.id, name: a.name, img: a.img, system: a.system, + scopeLabel: game.i18n.localize(`RINGBRP.Scope.${a.system.scope}`), + flagCount: (a.system.redFlags ?? []).length, + dossierCount: (a.system.dossier ?? []).length, + merged: (Number(a.system.mergeSteps) || 0) > 0 + })); + ctx.vehicles = this.actor.items.filter(i => i.type === "vehicle").map(v => ({ + ...v, id: v.id, name: v.name, img: v.img, system: v.system, + speedLabel: game.i18n.localize(`RINGBRP.Speed.${v.system.speedBand}`), + rating: this.actor.skillRating(v.system.skillFamilyId, v.system.skillSpecialisationId) ?? 0 + })); + ctx.gear = this.actor.items.filter(i => ["gear", "device"].includes(i.type)).map(it => { + if (it.type !== "device") return it; + const fns = Object.keys(it.system.functions ?? {}).map(id => { + const st = deviceFunctionStatus(it, id); + return { id, label: `RINGBRP.Device.Fn.${id}`, usable: st.usable, + why: game.i18n.format(st.reason, { need: st.need, have: st.have }) }; + }); + // a plain object so Handlebars can read `functions` without shadowing the item + return Object.assign(Object.create(Object.getPrototypeOf(it)), it, { functions: fns }); + }); + ctx.wounds = sys.wounds ?? []; + ctx.styleLabel = game.i18n.localize(RINGBRP.marksmanship[sys.marksmanship?.style]?.label ?? ""); + // The dropdown offered a choice with no stated consequence — two words and no + // hint of what either buys you. Built from RINGBRP.marksmanship so the sheet + // cannot describe a style differently from the way the style actually rolls. + ctx.styleHint = styleHintFor(sys.marksmanship?.style); + ctx.styleHints = Object.keys(RINGBRP.marksmanship).map(k => ({ + key: k, label: game.i18n.localize(RINGBRP.marksmanship[k].label), + hint: styleHintFor(k), on: k === sys.marksmanship?.style + })); + ctx.notesHTML = await rbEnrich(sys.notes ?? ""); + ctx.bioHTML = await rbEnrich(sys.biography ?? ""); + // A talent is ACTIVE if it costs something, is limited, or bends an action. + // Everything else is passive and read where it applies, so it gets no button. + ctx.skillCount = this.actor.items.filter(i => i.type === "skill").length; + ctx.talents = this.actor.items.filter(i => i.type === "talent").map(it => { + const per = Number(it.system.usesPerCase) || 0; + const used = Number(it.system.usedThisCase) || 0; + const active = !!(per || Number(it.system.coherenceCost) || it.system.grantsAction); + const available = !per || used < per; + return Object.assign(Object.create(Object.getPrototypeOf(it)), it, { + active, available, + whyUnavailable: available ? "" : game.i18n.format("RINGBRP.Talent.Spent", { name: it.name }) + }); + }); + ctx.talentGroups = [ + { key: "agency", label: "RINGBRP.Talent.Agency", items: ctx.talents.filter(t => t.system.category === "agency") }, + { key: "field", label: "RINGBRP.Talent.Field", items: ctx.talents.filter(t => t.system.category === "field") }, + { key: "species", label: "RINGBRP.Talent.Species", items: ctx.talents.filter(t => t.system.category === "species") }, + { key: "anomalous", label: "RINGBRP.Talent.Anomalous", items: ctx.talents.filter(t => t.system.category === "anomalous") } + ]; + // Everything currently modifying this agent's rolls. FOUR sources reach the + // same choke point — Major Wounds, lost locations, Coherence and what is being + // carried — and the banner watched one, then two. Totals are summed per + // category so a wound and a coherence band that both hit everything read as one + // honest figure rather than "all rolls -20% · all rolls -10%". + { + const w = sys.wounded ?? {}; + const cohBand = coherenceBand(sys.coherence?.value); + const encPen = Number(sys.encumbrance?.skillPenalty) || 0; + const chips = [ + ...(ctx.wounds ?? []).map(x => game.i18n.localize(`RINGBRP.Wound.${x.key}.title`)), + ...(sys.locations ?? []).filter(l => l.disabled) + .map(l => game.i18n.localize(l.label) + (l.destroyed ? " ✕" : "")) + ]; + const total = { + all: (w.all || 0) + (cohBand?.all || 0), + manipulation: w.manipulation || 0, + physical: (w.physical || 0) - encPen, + perception: w.perception || 0, + communication: (w.communication || 0) + (cohBand?.comm || 0) + }; + if (cohBand && (cohBand.all || cohBand.comm)) chips.push(game.i18n.localize(cohBand.label)); + if (encPen) chips.push(game.i18n.localize("RINGBRP.Sheet.Overloaded")); + + const bits = []; + const add = (key, label) => { if (total[key]) bits.push(`${game.i18n.localize(label)} ${total[key]}%`); }; + add("all", "RINGBRP.Sheet.AllRolls"); + add("manipulation", "RINGBRP.Category.Manipulation"); + add("physical", "RINGBRP.Category.Physical"); + add("perception", "RINGBRP.Category.Perception"); + add("communication", "RINGBRP.Category.Communication"); + if (w.bleed) bits.push(`${game.i18n.localize("RINGBRP.Sheet.Bleeding")} ${w.bleed}/${game.i18n.localize("RINGBRP.Sheet.PerRound")}`); + if (sys.mov < 10) bits.push(`MOV ${sys.mov}`); + + ctx.impaired = { + active: bits.length > 0 || chips.length > 0, + chips, summary: bits.join(" · "), + unconscious: !!w.unconscious, defenceOnly: !!w.defenceOnly + }; + } + + // The figure. Each location is paired with its shape so the drawing and the + // table are the same data — a location cannot appear in one and not the other. + { + const art = RINGBRP.bodyArt[sys.speciesProfile || "baseline"] ?? RINGBRP.bodyArt.baseline; + ctx.bodyMap = { + viewBox: art.viewBox, + parts: (sys.locations ?? []).map(l => { + const shape = art.parts[l.id]; + const frac = l.max ? Math.min(1, (l.taken || 0) / l.max) : 0; + return { + ...l, shape, + state: l.destroyed ? "destroyed" : l.disabled ? "disabled" + : frac >= 0.5 ? "hurt" : frac > 0 ? "grazed" : "whole", + pct: Math.round(frac * 100) + }; + }).filter(pt => pt.shape) + }; + ctx.speciesLabel = game.i18n.localize( + RINGBRP.locationTables[sys.speciesProfile || "baseline"]?.label ?? "RINGBRP.Species.Baseline"); + } + + // Armour carried but not worn reads as "ARMOUR 0" next to a list containing a + // vest, which looks like a bug in the sheet rather than a state of the fiction. + ctx.armourUnworn = this.actor.items.filter(i => i.type === "armour" + && i.system?.carried !== false && !i.system?.equipped).map(i => i.name); + ctx.powers = this.actor.items.filter(i => i.type === "power") + .sort((a, b) => (ARTS[a.system.artId]?.order ?? 9) - (ARTS[b.system.artId]?.order ?? 9)); + ctx.defenceNext = defencePenaltyFor(Number(sys.combat?.defencesThisRound) || 0); + ctx.reaction = this._reactionRail(); + ctx.readiness = this._readiness ?? { rating: 0 }; + ctx.coherence = { band: coherenceBand(sys.coherence?.value) }; + ctx.readinessSource = ctx.readiness.best >= ctx.readiness.intFloor + ? game.i18n.localize("RINGBRP.React.ReadinessSkill") + : game.i18n.localize("RINGBRP.React.ReadinessInt"); + return ctx; + } + + /** What each declarable action would cost this character right now. */ + /** The seven-box rail from the paper sheet, rendered on the digital one. */ + _reactionRail() { + const rb = reactionBase(this.actor); + // Show the working. "23.6" on its own is unreadable — nobody can tell whether + // it came from being quick or from being alert, nor what the kit is costing. + const rd = readinessRating(this.actor); + const dex = Number(this.actor.system?.characteristics?.dex?.value) || 0; + const enc = this.actor.system?.encumbrance ?? {}; + const work = { + dex, + dexDiv: REACTION.dexDivisor, + reflex: rb.reflex, + readiness: rd.rating, + readyDiv: REACTION.readinessDivisor, + aware: rb.aware, + fromTraining: rd.best >= rd.intFloor, + best: rd.best, + intFloor: rd.intFloor, + lane: Math.max(rb.reflex, rb.aware), + mult: REACTION.laneMultiplier, + base: rb.base, + load: rb.load, + overKg: enc.overKg ?? enc.over ?? 0, + carried: this.actor.system?.carriedKg ?? 0, + free: this.actor.system?.freeCarryKg ?? 0, + printed: rb.printed, + die: REACTION.die + }; + // The rail is situation only and sits on the printed value, which already has + // load in it. Its resting position is 0, not the load band it used to show. + return { + ...rb, work, + coherence: this.actor.system?.coherence?.value ?? 10, + rail: Array.from({ length: 7 }, (_, i) => ({ + mod: i - 3, + value: Math.round((rb.printed + (i - 3)) * 10) / 10, + current: (i - 3) === 0 + })) + }; + } + + + + activateListeners(html) { + super.activateListeners(html); + if (!this.isEditable) return; + const root = html[0] ?? html; + + root.querySelectorAll("[data-action='roll-skill']").forEach(el => + el.addEventListener("click", ev => this._onRollSkill(ev))); + root.querySelectorAll("[data-action='roll-attribute']").forEach(el => + el.addEventListener("click", ev => this._onRollAttribute(ev))); + root.querySelectorAll("[data-action='roll-characteristic']").forEach(el => + el.addEventListener("click", ev => this._onRollCharacteristic(ev))); + root.querySelectorAll("[data-action='full-skills']").forEach(el => + el.addEventListener("click", async () => { + const n = await grantFullSkillList(this.actor); + ui.notifications?.info(n ? `+${n} skills` : game.i18n.localize("RINGBRP.Sheet.AlreadyFull")); + this.render(false); + })); + root.querySelectorAll("[data-action='use-talent']").forEach(el => + el.addEventListener("click", async ev => { + ev.preventDefault(); ev.stopPropagation(); + const btn = ev.currentTarget; + const item = this.actor.items.get(btn.closest("[data-item-id]")?.dataset.itemId); + if (!item) return; + if (btn.classList.contains("rb-disabled")) { + return ui.notifications?.warn(btn.getAttribute("title")); + } + // the two talents with their own procedure route to it; the rest just spend + if (item.system.talentId === "requisition") await requisition(this.actor); + else if (item.system.talentId === "clean_scene") await armCleanScene(this.actor); + else await useTalent(this.actor, item); + this.render(false); + })); + root.querySelectorAll("[data-action='device-fn']").forEach(el => + el.addEventListener("click", async ev => { + ev.preventDefault(); ev.stopPropagation(); + const btn = ev.currentTarget; + if (btn.classList.contains("rb-disabled")) { + return ui.notifications?.warn(btn.getAttribute("title")); + } + const item = this.actor.items.get(btn.closest("[data-item-id]")?.dataset.itemId); + if (item) await useDeviceFunction(this.actor, item, btn.dataset.fn); + this.render(false); + })); + root.querySelectorAll("[data-action='coherence-adjust']").forEach(el => + el.addEventListener("click", async ev => { + ev.preventDefault(); ev.stopPropagation(); + const delta = Number(ev.currentTarget.dataset.delta) || 0; + if (delta < 0) await loseCoherence(this.actor, "brokeLocalLaw", { amount: 1 }); + else await regainCoherence(this.actor, 1); + })); + root.querySelectorAll("[data-action='roll-location']").forEach(el => + el.addEventListener("click", () => this._onRollLocation())); + root.querySelectorAll("[data-action='roll-attack']").forEach(el => + el.addEventListener("click", ev => this._onRollAttack(ev))); + // Hovering a limb highlights its row and vice versa, so the picture and the + // numbers are obviously the same thing. + const link = (sel, on) => root.querySelectorAll(sel).forEach(el => { + const id = el.dataset.loc; if (!id) return; + el.addEventListener("mouseenter", () => root.querySelectorAll(`[data-loc="${id}"]`) + .forEach(t => t.classList.add(on))); + el.addEventListener("mouseleave", () => root.querySelectorAll(`[data-loc="${id}"]`) + .forEach(t => t.classList.remove(on))); + }); + link(".rb-part", "rb-hi"); + link(".rb-locrow", "rb-hi"); + + for (const [action, pushed] of [["power-use", false], ["power-push", true]]) { + root.querySelectorAll(`[data-action='${action}']`).forEach(el => + el.addEventListener("click", ev => { + const power = this._itemFrom(ev); + if (power) usePower(this.actor, power, { pushed }); + })); + } + + root.querySelectorAll("[data-action='authority-use']").forEach(el => + el.addEventListener("click", async ev => { + const auth = this._itemFrom(ev); if (!auth) return; + const ask = await promptAuthorityUse(auth); + if (!ask) return; + await useAuthority(this.actor, auth, ask); + })); + root.querySelectorAll("[data-action='authority-widen']").forEach(el => + el.addEventListener("click", async ev => { + const auth = this._itemFrom(ev); if (auth) await widenScope(this.actor, auth); + })); + root.querySelectorAll("[data-action='authority-rehab']").forEach(el => + el.addEventListener("click", async ev => { + const auth = this._itemFrom(ev); if (auth) await rehabilitate(this.actor, auth); + })); + + root.querySelectorAll("[data-action='defend']").forEach(el => + el.addEventListener("click", async ev => { + const type = ev.currentTarget.dataset.type || "dodge"; + const weapon = type === "parry" + ? this.actor.items.find(i => i.type === "weapon" && i.system.equipped && !i.system.isRanged) + : null; + if (type === "parry" && !weapon) { + return ui.notifications?.warn(game.i18n.localize("RINGBRP.Notify.NoParryWeapon")); + } + const blow = standingBlow(this.actor); + await defend(this.actor, { type, weapon, against: blow?.level ?? null }); + })); + + root.querySelectorAll("[data-action='roll-damage']").forEach(el => + el.addEventListener("click", ev => this._onRollDamage(ev))); + root.querySelectorAll("[data-action='item-edit']").forEach(el => + el.addEventListener("click", ev => this._itemFrom(ev)?.sheet.render(true))); + root.querySelectorAll("[data-action='item-delete']").forEach(el => + el.addEventListener("click", ev => this._itemFrom(ev)?.delete())); + root.querySelectorAll("[data-action='item-create']").forEach(el => + el.addEventListener("click", ev => this._onItemCreate(ev))); + root.querySelectorAll("[data-action='wound-clear']").forEach(el => + el.addEventListener("click", ev => this._onWoundClear(ev))); + root.querySelectorAll("[data-action='toggle-equipped']").forEach(el => + el.addEventListener("click", ev => { + const it = this._itemFrom(ev); + if (it) it.update({ "system.equipped": !it.system.equipped }); + })); + root.querySelectorAll("[data-action='toggle-tick']").forEach(el => + el.addEventListener("click", ev => { + const it = this._itemFrom(ev); + if (it) it.update({ "system.experienceTicked": !it.system.experienceTicked }); + })); + } + + _itemFrom(ev) { + const id = ev.currentTarget.closest("[data-item-id]")?.dataset.itemId; + return id ? this.actor.items.get(id) : null; + } + + async _onItemCreate(ev) { + const type = ev.currentTarget.dataset.type ?? "skill"; + const created = await this.actor.createEmbeddedDocuments("Item", [{ + name: game.i18n.localize(`RINGBRP.New.${type}`), type + }]); + created[0]?.sheet.render(true); + } + + async _onWoundClear(ev) { + const id = ev.currentTarget.closest("[data-wound-id]")?.dataset.woundId; + const wounds = (this.actor.system.wounds ?? []).filter(w => w.id !== id); + await this.actor.update({ "system.wounds": wounds }); + } + + async _onRollSkill(ev) { + const it = this._itemFrom(ev); + if (!it) return; + const opts = await promptRollOptions({ title: it.name }); + if (!opts) return; + // R-23: encumbrance is a named modifier, not a circumstance, so it is not + // subject to the +-40% circumstance cap. + const enc = this.actor.encumbrancePenaltyFor(it.system.categoryId); + const lines = []; + if (enc) lines.push(game.i18n.format("RINGBRP.Flavour.Encumbered", + { mod: enc, steps: this.actor.system.encumbrance.steps })); + return skillRoll({ + actor: this.actor, + label: it.name, + rating: it.system.value, + printedBase: it.system.base?.value ?? 0, + ...opts, + situational: (opts.situational ?? 0) + enc, + categoryId: it.system.categoryId, + skillItem: it, + flavourLines: lines + }); + } + + /** + * Characteristic roll: d100 against the characteristic x5 (spec §3, BRP standard). + * A STR of 15 rolls against 75. + */ + async _onRollCharacteristic(ev) { + const key = ev.currentTarget.closest("[data-characteristic]")?.dataset.characteristic; + if (!key) return; + const value = Number(this.actor.system.characteristics?.[key]?.value) || 0; + const rating = value * 5; + const name = game.i18n.localize(`RINGBRP.Char.${key}`); + const opts = await promptRollOptions({ title: `${name} ×5` }); + if (!opts) return; + return skillRoll({ + actor: this.actor, + label: game.i18n.format("RINGBRP.Roll.Characteristic", { name, value }), + rating, + ...opts + }); + } + + /** Roll a hit location on this actor's own anatomy, for the GM. */ + async _onRollLocation() { + const mode = await new Promise(resolve => { + new (rbDialogClass())({ + title: game.i18n.localize("RINGBRP.Loc.Header"), + content: `

${game.i18n.localize("RINGBRP.Loc.RollHint")}

`, + buttons: { + melee: { label: game.i18n.localize("RINGBRP.Loc.Melee"), callback: () => resolve("melee") }, + ranged: { label: game.i18n.localize("RINGBRP.Loc.Ranged"), callback: () => resolve("ranged") } + }, + default: "ranged", close: () => resolve(null) + }).render(true); + }); + if (!mode) return; + return hitLocationRoll({ target: this.actor, damage: 0, mode }); + } + + async _onRollAttribute(ev) { + const key = ev.currentTarget.dataset.attribute; + const rating = this.actor.system.rolls?.[key] ?? 0; + const opts = await promptRollOptions({ title: game.i18n.localize(`RINGBRP.Roll.${key}`) }); + if (!opts) return; + return skillRoll({ + actor: this.actor, + label: game.i18n.localize(`RINGBRP.Roll.${key}`), + rating, ...opts + }); + } + + async _onRollAttack(ev) { + // F5: transposed materiel charges its Coherence cost when it is actually used. + const _w = this._itemFrom(ev); + if (_w && Number(_w.system?.coherence ?? _w.system?.coherenceCost)) { + await useTransposed(this.actor, _w); + } + const weapon = this._itemFrom(ev); + if (!weapon) return; + const sys = this.actor.system; + const rating = this.actor.skillRating(weapon.system.skillFamilyId, weapon.system.skillSpecialisationId); + if (rating === null) { + ui.notifications.warn(game.i18n.format("RINGBRP.Notify.NoSkill", { name: weapon.name })); + return; + } + const opts = await promptAttackOptions({ weapon, actor: this.actor }); + if (!opts) return; + if (opts.impossible) { + ui.notifications.warn(game.i18n.localize("RINGBRP.Notify.OutOfRange")); + return; + } + // Ammunition (spec §4.5) + if (weapon.system.ammo?.capacity > 0) { + if ((weapon.system.ammo.loaded ?? 0) <= 0) { + ui.notifications.warn(game.i18n.localize("RINGBRP.Notify.Empty")); + return; + } + await weapon.update({ "system.ammo.loaded": weapon.system.ammo.loaded - 1 }); + } + const res = await skillRoll({ + actor: this.actor, + label: weapon.name, + rating, + difficulty: opts.difficulty, + situational: opts.situational, + flavourLines: opts.lines, + categoryId: weapon.system?.isRanged ? "ranged" : "melee", + weapon + }); + // Leave the blow standing so whoever defends knows what quality it was. + if (res) await recordBlow({ attacker: this.actor, level: res.level, weapon, + targets: [...(game.user.targets ?? [])].map(t => t.id) }); + return res; + } + + async _onRollDamage(ev) { + const weapon = this._itemFrom(ev); + if (!weapon) return; + // This used to roll with no level at all, so it always rolled ordinary damage: + // a critical got neither maximum damage nor its armour bypass, and the whole + // critical rule existed only in the chat card. Use what the attack achieved, + // after any defence has been graded against it. + const blow = standingBlow(); + const mine = blow && blow.attackerId === this.actor.id; + if (mine && blow.stopped) { + return ui.notifications?.warn(game.i18n.localize("RINGBRP.Notify.BlowStopped")); + } + return rollWeaponDamage({ actor: this.actor, weapon, + level: mine ? blow.level : "success" }); + } +} + +class RingBRPNpcSheet extends RingBRPCharacterSheet { + static get defaultOptions() { + return foundry.utils.mergeObject(super.defaultOptions, { + classes: ["ringbrp", "sheet", "actor", "npc"], + template: "systems/ringbrp/templates/npc-sheet.hbs", + width: 700, + height: 760 + }); + } +} + +class RingBRPExpeditionSheet extends ActorSheet { + static get defaultOptions() { + return foundry.utils.mergeObject(super.defaultOptions, { + classes: ["ringbrp", "sheet", "actor", "expedition"], + template: "systems/ringbrp/templates/expedition-sheet.hbs", + width: 720, + height: 700, + tabs: [{ navSelector: ".sheet-tabs", contentSelector: ".sheet-body", initial: "journey" }] + }); + } + + async getData(options) { + const ctx = await super.getData(options); + const sys = this.actor.system; + ctx.sys = sys; + ctx.RINGBRP = RINGBRP; + ctx.projects = this.actor.items.filter(i => i.type === "project"); + ctx.discoveries = this.actor.items.filter(i => i.type === "discovery"); + ctx.isGM = game.user.isGM; + + // The crossing panel. Going out is a decision; coming back is a roll, and each + // agent makes their own unless somebody aims it for them. + { + const members = caseMembers(this.actor); + const rate = a => a.skillRating("transposition") ?? 0; + const best = members.slice().sort((a, b) => rate(b) - rate(a))[0] ?? null; + ctx.crossing = { + across: !!sys.across, + aimer: best ? { id: best.id, name: best.name.split(" ")[0], rating: rate(best) } : null, + members: members.map(a => { + const plan = returnCrossing(a); + return { + id: a.id, name: a.name.split(" ")[0], + rating: rate(a), + effective: resolveBands(rate(a), { situational: plan.penalty }).effective, + penalty: plan.penalty, + coherence: a.system.coherence?.value ?? 10, + band: game.i18n.localize(plan.band?.label ?? ""), + arts: a.items.filter(i => i.type === "power").length + }; + }) + }; + } + // A group's taboos, desires and correct offer form are the answers to the + // first-contact challenge. Players see only what the expedition has learned; + // the GM sees everything, flagged as GM-only. (Codex round 7, B2.) + ctx.groups = this.actor.items.filter(i => i.type === "group").map(g => { + const st = (this.actor.system.standings ?? []).find(x => x.groupId === g.system.groupId); + const knownT = new Set(st?.knownTaboos ?? []); + const knownD = new Set(st?.knownDesires ?? []); + const all = t => (t ?? []).map(x => ({ ...x })); + const taboos = all(g.system.taboos); + const desires = all(g.system.desires); + return { + id: g.id, name: g.name, system: g.system, + population: g.system.population, + taboos: ctx.isGM ? taboos.map(t => ({ ...t, secret: !knownT.has(t.id) })) + : taboos.filter(t => knownT.has(t.id)), + desires: ctx.isGM ? desires.map(d => ({ ...d, secret: !knownD.has(d.id) })) + : desires.filter(d => knownD.has(d.id)), + offerForm: ctx.isGM ? g.system.correctOfferForm : "", + impression: st?.impression ?? 0, standing: st?.value ?? 0, + pidgin: !!st?.pidginEstablished, offerSpent: !!st?.offerMadeThisEncounter, + nothingKnown: !ctx.isGM && !taboos.some(t => knownT.has(t.id)) + && !desires.some(d => knownD.has(d.id)) + }; + }); + ctx.notesHTML = await rbEnrich(sys.notes ?? ""); + + // the three tracks, with pip readouts and state + ctx.tracks = ["supply", "power", "integrity"].map(key => { + const t = sys[key] ?? { value: 0, max: 10 }; + const pips = Array.from({ length: t.max }, (_, i) => i < t.value); + return { + key, value: t.value, max: t.max, pips, + label: `RINGBRP.Expedition.${key.charAt(0).toUpperCase()}${key.slice(1)}`, + crisis: t.value <= 0, + low: t.value > 0 && t.value <= Math.max(1, Math.floor(t.max * 0.3)) + }; + }); + + // what happens next + const legs = sys.journey?.legs ?? []; + ctx.resolvedLegs = legs.filter(l => l.resolved).length; + const idx = sys.journey?.currentLeg ?? 0; + const leg = legs[idx]; + if (leg && !leg.resolved) { + ctx.nextLeg = foundry.utils.duplicate(leg); + ctx.nextLeg.index = idx; + ctx.nextLegNegative = Number(leg.modifier) < 0; + const know = sys.journey?.destinationKnowledge ?? "rumoured"; + ctx.legDifficulty = game.i18n.localize( + RINGBRP.difficulties[know === "known" ? "average" : "difficult"]); + // best available navigator among the members + // One lookup, one skill preference. This used to resolve members by raw id + // and pick on Navigate, duplicating — and then contradicting — the resolver, + // which left the Resolve Phase button permanently disabled on the sheet + // while the same operation worked perfectly from the API. + const roster = caseMembers(this.actor); + const bestIn = fam => roster.reduce((acc, a) => { + const r = a.skillRating?.(fam); + return (r !== null && r !== undefined && r > (acc?.rating ?? -1)) + ? { name: a.name, rating: r, skill: fam } : acc; + }, null); + ctx.navigator = bestIn("tradecraft") ?? bestIn("navigate"); + // the number the GM actually needs: difficulty applied, then the leg modifier + if (ctx.navigator) { + ctx.rollTarget = Math.max(1, applyDifficulty(ctx.navigator.rating, + know === "known" ? "average" : "difficult") + (Number(leg.modifier) || 0)); + } + ctx.blocked = !ctx.navigator; // never blocked by an empty track (R-56) + } + + // standings rendered as a -3..+3 scale so the position is visible, not just a number + ctx.standings = (sys.standings ?? []).map(st => { + const g = this.actor.items.find(i => i.type === "group" && i.system.groupId === st.groupId); + const scale = []; + for (let v = -3; v <= 3; v++) { + scale.push({ + label: v === 0 ? "0" : (v > 0 ? `+${v}` : `${v}`), + cls: v === st.value ? (v < 0 ? "rb-scale-bad on" : "rb-scale-good on") : "" + }); + } + const WORDS = { "-3": "hostile", "-2": "threatened", "-1": "wary", "0": "neutral", + "1": "curious", "2": "welcoming", "3": "allied" }; + return { + name: g?.name ?? st.groupId, value: st.value, encounters: st.encounters ?? 0, scale, + display: st.value > 0 ? `+${st.value}` : `${st.value}`, + word: WORDS[String(st.value)] ?? "", + cls: st.value < 0 ? "rb-standing-bad" : (st.value > 0 ? "rb-standing-good" : "") + }; + }); + return ctx; + } + + activateListeners(html) { + super.activateListeners(html); + if (!this.isEditable) return; + const root = html[0] ?? html; + root.querySelectorAll("[data-action='track-adjust']").forEach(el => + el.addEventListener("click", ev => { + const { track, delta } = ev.currentTarget.dataset; + const cur = this.actor.system[track]?.value ?? 0; + const max = this.actor.system[track]?.max ?? 10; + this.actor.update({ [`system.${track}.value`]: Math.max(0, Math.min(max, cur + Number(delta))) }); + })); + root.querySelectorAll("[data-action='resolve-leg']").forEach(el => + el.addEventListener("click", () => resolveLegDialog(this.actor))); + root.querySelectorAll("[data-action='contact']").forEach(el => + el.addEventListener("click", async ev => { + const bar = ev.currentTarget.closest("[data-group-id]"); + const groupId = bar?.dataset.groupId; + const stage = ev.currentTarget.dataset.stage; + if (!groupId || ev.currentTarget.classList.contains("rb-disabled")) return; + const actor = await pickContactActor(this.actor, stage); + if (!actor) return; + await resolveContact(this.actor, groupId, stage, actor); + this.render(false); + })); + root.querySelectorAll("[data-action='new-encounter']").forEach(el => + el.addEventListener("click", async ev => { + const groupId = ev.currentTarget.closest("[data-group-id]")?.dataset.groupId; + if (!groupId) return; + const n = await newEncounter(this.actor, groupId); + ui.notifications?.info(game.i18n.format("RINGBRP.Contact.EncounterN", { n })); + this.render(false); + })); + root.querySelectorAll("[data-action='toggle-across']").forEach(el => + el.addEventListener("click", () => setAcross(!this.actor.system.across))); + + const actorFromRow = ev => { + const id = ev.currentTarget.closest("[data-actor-id]")?.dataset?.actorId; + return id ? game.actors.get(id) : null; + }; + const aimerFor = () => { + const m = caseMembers(this.actor); + return m.slice().sort((a, b) => (b.skillRating("transposition") ?? 0) + - (a.skillRating("transposition") ?? 0))[0] ?? null; + }; + + root.querySelectorAll("[data-action='cross-out']").forEach(el => + el.addEventListener("click", () => setAcross(true))); + + root.querySelectorAll("[data-action='cross-return']").forEach(el => + el.addEventListener("click", async ev => { + const a = actorFromRow(ev); if (a) await crossBack(a); + })); + + root.querySelectorAll("[data-action='cross-return-aided']").forEach(el => + el.addEventListener("click", async ev => { + const a = actorFromRow(ev); if (!a) return; + const aimer = aimerFor(); + await crossBack(a, { aimedByAnother: !!aimer && aimer !== a, aimedBy: aimer }); + })); + + // Bring the whole team back: the best crosser aims for everyone else, which is + // what the fiction says a team does, and then the case is home. + root.querySelectorAll("[data-action='cross-return-all']").forEach(el => + el.addEventListener("click", async () => { + const members = caseMembers(this.actor); + if (!members.length) return ui.notifications?.warn(game.i18n.localize("RINGBRP.Cross.NoMembers")); + const aimer = aimerFor(); + for (const a of members) { + await crossBack(a, { aimedByAnother: !!aimer && aimer !== a, aimedBy: aimer }); + } + await setAcross(false); + })); + // R-210. endOfCase() was exported, documented as "the step that closes the + // episodic loop", and called by absolutely nothing — no button, no hook. Every + // skill and Art tick sat on the sheet forever and no character could improve + // through play. Same shape as every other fault this system has had: a rule + // that exists and nothing reaches. + root.querySelectorAll("[data-action='close-case']").forEach(el => + el.addEventListener("click", async () => { + if (this.actor.system.across) return ui.notifications?.warn(game.i18n.localize("RINGBRP.Case.StillAcross")); + const members = caseMembers(this.actor); + if (!members.length) return ui.notifications?.warn(game.i18n.localize("RINGBRP.Cross.NoMembers")); + for (const a of members) await endOfCase(a); + ui.notifications?.info(game.i18n.localize("RINGBRP.Case.Closed")); + })); + + root.querySelectorAll("[data-action='debrief']").forEach(el => + el.addEventListener("click", async () => { + for (const a of caseMembers(this.actor)) { + await debrief(a, { withAnchor: true }); + } + ui.notifications?.info(game.i18n.localize("RINGBRP.Case.DebriefDone")); + })); + root.querySelectorAll("[data-action='item-edit']").forEach(el => + el.addEventListener("click", ev => { + const id = ev.currentTarget.closest("[data-item-id]")?.dataset.itemId; + this.actor.items.get(id)?.sheet.render(true); + })); + } +} + +class RingBRPItemSheet extends ItemSheet { + static get defaultOptions() { + return foundry.utils.mergeObject(super.defaultOptions, { + classes: ["ringbrp", "sheet", "item"], + template: "systems/ringbrp/templates/item-sheet.hbs", + width: 620, + height: 620, + tabs: [{ navSelector: ".sheet-tabs", contentSelector: ".sheet-body", initial: "detail" }] + }); + } + + async getData(options) { + const ctx = await super.getData(options); + ctx.sys = this.item.system; + ctx.RINGBRP = RINGBRP; + ctx.isSkill = this.item.type === "skill"; + ctx.isWeapon = this.item.type === "weapon"; + ctx.isArmour = this.item.type === "armour"; + ctx.isDevice = this.item.type === "device"; + ctx.isGear = this.item.type === "gear"; + ctx.isProject = this.item.type === "project"; + ctx.isDiscovery = this.item.type === "discovery"; + ctx.isGroup = this.item.type === "group"; + ctx.isMaterial = this.item.type === "material"; + ctx.isVehicle = this.item.type === "vehicle"; + ctx.isAuthority = this.item.type === "authority"; + ctx.descriptionHTML = await rbEnrich(ctx.sys.description ?? ""); + + if (ctx.isGear) { + ctx.totalMass = Math.round(((Number(ctx.sys.massKg) || 0) * (Number(ctx.sys.quantity) || 1)) * 10) / 10; + ctx.facilityMod = RINGBRP.facilities[ctx.sys.researchFacility] ?? 0; + } + if (ctx.isAuthority) { + ctx.scopeLabel = game.i18n.localize(`RINGBRP.Scope.${ctx.sys.scope}`); + ctx.merge = mergeStageFor(ctx.sys.mergeSteps); + ctx.mergeLabel = game.i18n.localize(ctx.merge.label); + ctx.widenCost = widenScopeCostFor(ctx.sys.timesWidened); + ctx.scopeOptions = SCOPE_TIERS.map(t => ({ + key: t, label: game.i18n.localize(`RINGBRP.Scope.${t}`), selected: t === ctx.sys.scope + })); + } + if (ctx.isVehicle) { + ctx.speedLabel = game.i18n.localize(`RINGBRP.Speed.${ctx.sys.speedBand}`); + ctx.operateLabel = ctx.sys.skillSpecialisationId + ? `${ctx.sys.skillFamilyId} (${ctx.sys.skillSpecialisationId})`.replace(/_/g, " ") + : ctx.sys.skillFamilyId; + } + if (ctx.isGroup) { + ctx.principalTaboo = ctx.sys.taboos?.[0]?.text ?? "—"; + ctx.principalDesire = ctx.sys.desires?.[0]?.text ?? "—"; + } + if (ctx.isWeapon) { + const d = ctx.sys.damage ?? {}; + ctx.isByBand = d.mode === "byBand"; + ctx.isRangedWeapon = !!ctx.sys.isRanged; + const owner = this.item.parent; + const r = owner?.skillRating?.(ctx.sys.skillFamilyId, ctx.sys.skillSpecialisationId); + ctx.attackRating = (r === null || r === undefined) ? null : r; + ctx.damageSummary = ctx.isByBand + ? `${d.short || "—"} / ${d.medium || "—"} / ${d.long || "—"}` + : (d.formula || "—"); + } + return ctx; + } + + activateListeners(html) { + super.activateListeners(html); + if (!this.isEditable) return; + const root = html[0] ?? html; + root.querySelectorAll("[data-action='roll-damage']").forEach(el => + el.addEventListener("click", () => this._onRollDamage())); + root.querySelectorAll("[data-action='roll-attack-item']").forEach(el => + el.addEventListener("click", () => this._onRollAttackFromItem())); + } + + /** Attack roll from the item sheet — the half of the weapon decision that was missing. */ + async _onRollAttackFromItem() { + const actor = this.item.parent; + if (!actor) return ui.notifications.warn(game.i18n.localize("RINGBRP.Notify.NoOwner")); + const rating = actor.skillRating(this.item.system.skillFamilyId, this.item.system.skillSpecialisationId); + if (rating === null) return ui.notifications.warn( + game.i18n.format("RINGBRP.Notify.NoSkill", { name: this.item.name })); + const opts = await promptAttackOptions({ weapon: this.item, actor }); + if (!opts) return; + if (opts.impossible) return ui.notifications.warn(game.i18n.localize("RINGBRP.Notify.OutOfRange")); + return skillRoll({ actor, label: this.item.name, rating, + difficulty: opts.difficulty, situational: opts.situational, flavourLines: opts.lines }); + } + + /** Roll this weapon's damage straight from the item sheet (spec §4.3). */ + async _onRollDamage() { + return rollWeaponDamage({ actor: this.item.parent, weapon: this.item }); + } +} + +/* -------------------------------------------- */ +/* Dialogs */ +/* -------------------------------------------- */ + +async function promptRollOptions({ title } = {}) { + const content = await rbRenderTemplate("systems/ringbrp/templates/dialog-roll.hbs", { RINGBRP }); + return new Promise(resolve => { + new (rbDialogClass())({ + title: title ?? game.i18n.localize("RINGBRP.Dialog.Roll"), + content, + buttons: { + roll: { + label: game.i18n.localize("RINGBRP.Dialog.RollButton"), + callback: html => { + const el = html[0] ?? html; + const circ = el.querySelector("[name=circumstance]")?.value ?? "neutral"; + const free = Number(el.querySelector("[name=situational]").value) || 0; + resolve({ + difficulty: el.querySelector("[name=difficulty]").value, + circumstance: circ, + situational: circumstanceTotal([circ]) + free + }); + } + }, + cancel: { label: game.i18n.localize("RINGBRP.Dialog.Cancel"), callback: () => resolve(null) } + }, + default: "roll", + close: () => resolve(null) + }).render(true); + }); +} + +/** Builds the attack modifier stack from range, style, cover and movement (spec §2.3, §4.2–4.4). */ +async function promptAttackOptions({ weapon, actor }) { + // The weapon already knows its bands, so asking the player to type a distance and + // then deriving the band from it was making them do arithmetic the sheet had + // already done. The dropdown offers the bands; each option carries a distance + // inside that band, so rangeBandFor stays the one authority on what a range costs. + const r = weapon.system.ranges ?? {}; + const bandOptions = []; + if (weapon.system.isRanged && r.long) { + const edges = [ + { id: "short", upto: r.short }, + { id: "medium", upto: r.medium }, + { id: "long", upto: r.long }, + { id: "extreme", upto: r.long * 2 } + ]; + let from = 0; + for (const e of edges) { + if (!(e.upto > from)) continue; + const probe = e.upto; // a distance that lands in this band + bandOptions.push({ + value: probe, + label: game.i18n.localize(`RINGBRP.Range.${e.id}`), + span: `${from + 1}–${e.upto} m`, + mod: rangeBandFrom(probe, r).mod + }); + from = e.upto; + } + bandOptions.push({ + value: r.long * 2 + 1, + label: game.i18n.localize("RINGBRP.Range.beyond"), + span: `over ${r.long * 2} m`, mod: null + }); + } + const content = await rbRenderTemplate("systems/ringbrp/templates/dialog-attack.hbs", + { RINGBRP, weapon, isRanged: weapon.system.isRanged, ranges: weapon.system.ranges, bandOptions }); + return new Promise(resolve => { + new (rbDialogClass())({ + title: weapon.name, + content, + buttons: { + roll: { + label: game.i18n.localize("RINGBRP.Dialog.RollButton"), + callback: html => { + const el = html[0] ?? html; + const distance = Number(el.querySelector("[name=distance]")?.value) || 0; + const cover = el.querySelector("[name=cover]")?.value ?? "none"; + const moved = el.querySelector("[name=moved]")?.checked ?? false; + const aiming = el.querySelector("[name=aiming]")?.checked ?? false; + const extra = Number(el.querySelector("[name=situational]")?.value) || 0; + + const style = actor.system.marksmanship?.style ?? "reflex"; + const styleCfg = RINGBRP.marksmanship[style]; + const lines = []; + let mod = extra; + + if (weapon.system.isRanged && weapon.system.ranges?.long) { + const rb = rangeBandFor(distance, weapon.system.ranges); + if (rb.mod === null) return resolve({ impossible: true }); + mod += rb.mod; + lines.push(`${game.i18n.localize(`RINGBRP.Range.${rb.band}`)} ${rb.mod >= 0 ? "+" : ""}${rb.mod}%`); + const sb = styleRangeBonus(style, rb.band); + if (sb) { mod += sb; lines.push(`${game.i18n.localize(styleCfg.label)} +${sb}%`); } + } + const cv = RINGBRP.cover[cover]?.mod ?? 0; + if (cv) { mod += cv; lines.push(`${game.i18n.localize(RINGBRP.cover[cover].label)} ${cv}%`); } + if (moved && styleCfg.moveAndFire) { + mod += styleCfg.moveAndFire; + lines.push(`${game.i18n.localize("RINGBRP.Attack.Moved")} ${styleCfg.moveAndFire}%`); + } + if (aiming) { + mod += styleCfg.aim; + lines.push(`${game.i18n.localize("RINGBRP.Attack.Aimed")} +${styleCfg.aim}%`); + if (styleCfg.ignoresArmour) lines.push(game.i18n.format("RINGBRP.Attack.IgnoresArmour", { n: styleCfg.ignoresArmour })); + } + resolve({ difficulty: "average", situational: mod, lines }); + } + }, + cancel: { label: game.i18n.localize("RINGBRP.Dialog.Cancel"), callback: () => resolve(null) } + }, + default: "roll", + close: () => resolve(null) + }).render(true); + }); +} + +/** + * Weapon damage, in one place. There were two near-identical copies of this — one + * on the character sheet, one on the item sheet — and neither knew what the attack + * had rolled, so a critical did ordinary damage and nothing offered a damage roll + * after a hit at all. + * + * Success level matters here (spec §4.4): a CRITICAL is maximum weapon damage plus + * a rolled damage modifier and ignores armour; a SPECIAL is normal damage against + * half armour; a success is normal damage against full armour. Armour is applied + * when the hit is resolved, so what changes here is the weapon dice and the note + * the card carries. + */ +export async function rollWeaponDamage({ actor = null, weapon, level = "success", band = null } = {}) { + if (!weapon) return null; + const d = weapon.system?.damage ?? {}; + let formula = d.formula || "1d6"; + if (d.mode === "byBand") { + const b = band ?? await promptDamageBand(); + if (!b) return null; + formula = d[b] || d.formula || "1d6"; + } + const dm = actor?.system?.damageModifier; + const usesDm = weapon.system?.appliesDamageModifier && dm && dm !== "0"; + + let roll; + if (level === "critical") { + // maximum weapon damage, then the damage modifier is still ROLLED + const maxWeapon = await new Roll(formula).evaluate({ maximize: true }); + roll = await new Roll(usesDm ? `${maxWeapon.total}${dm}` : `${maxWeapon.total}`).evaluate(); + } else { + 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") + : ""; + await roll.toMessage({ + speaker: ChatMessage.getSpeaker({ actor: actor ?? undefined }), + flavor: game.i18n.format("RINGBRP.Chat.DamageFlavor", { name: weapon.name }) + + (note ? ` — ${note}` : "") + }); + return roll.total; +} + +async function promptDamageBand() { + return new Promise(resolve => { + new (rbDialogClass())({ + title: game.i18n.localize("RINGBRP.Dialog.Band"), + content: `

${game.i18n.localize("RINGBRP.Dialog.BandHint")}

`, + buttons: { + short: { label: game.i18n.localize("RINGBRP.Range.short"), callback: () => resolve("short") }, + medium: { label: game.i18n.localize("RINGBRP.Range.medium"), callback: () => resolve("medium") }, + long: { label: game.i18n.localize("RINGBRP.Range.long"), callback: () => resolve("long") } + }, + default: "short", + close: () => resolve(null) + }).render(true); + }); +} + +/** Expedition leg resolution (spec §5). */ +/** True when any case track is exhausted. Costs more; never stops the case. */ +function destitute(cf) { return (cf?.system?.destitute?.length ?? 0) > 0; } + +async function resolveLegDialog(expedition) { + const legs = expedition.system.journey?.legs ?? []; + const idx = expedition.system.journey?.currentLeg ?? 0; + const leg = legs[idx]; + if (!leg) return ui.notifications.warn(game.i18n.localize("RINGBRP.Notify.NoLeg")); + + const members = caseMembers(expedition); + // A case phase is not a march. The agent who leads it is whoever is best at + // reading a developing situation, which is Tradecraft first and Navigate only + // as a fallback for phases that really are about ground. + // Tradecraft FIRST, not "best of either": a case phase is led by whoever reads + // the situation best, and Navigate is only consulted when nobody has Tradecraft. + const bestIn = fam => members.reduce((acc, a) => { + const r = a.skillRating(fam); + return (r !== null && r > (acc?.rating ?? -1)) ? { actor: a, rating: r, skill: fam } : acc; + }, null); + const best = bestIn("tradecraft") ?? bestIn("navigate"); + if (!best) return ui.notifications.warn(game.i18n.localize("RINGBRP.Notify.NoNavigator")); + + // A leg may state its own difficulty; the generator sets one deliberately and + // explains the ramp in a comment. It was written and never read, so the brief + // ("phase 1 is Average") and the phase table disagreed on the same document set. + const know = expedition.system.journey.destinationKnowledge; + let difficulty = leg.difficulty ?? (know === "known" ? "average" : "difficult"); + // Rewards a group has granted for this phase. + const rewards = activeRewardEffects(expedition, idx); + const cancelled = rewards.some(e => e.type === "cancelModifier"); + const waived = rewards.some(e => e.type === "waiveCost"); + const rewardBonus = rewards.filter(e => e.type === "skillBonus" + && (!e.skillFamilyId || e.skillFamilyId === best?.skill)).reduce((a, e) => a + (Number(e.value) || 0), 0); + if (destitute(expedition)) difficulty = "difficult"; // dearer, never impossible + const res = await skillRoll({ + actor: best.actor, + label: game.i18n.format("RINGBRP.Expedition.LegRoll", { n: idx + 1, terrain: leg.terrain ?? "" }), + rating: best.rating, + difficulty, + situational: (cancelled ? 0 : Number(leg.modifier) || 0) + rewardBonus, + flavourLines: [ + ...(cancelled ? [game.i18n.localize("RINGBRP.Contact.ModifierCancelled")] : []), + ...(rewardBonus ? [game.i18n.format("RINGBRP.Contact.GuideBonus", { mod: rewardBonus })] : []) + ] + }); + if (!res) return; + + if (leg.forcedEncounterId) { + await ChatMessage.create({ + speaker: ChatMessage.getSpeaker({ actor: expedition }), + content: `

${ + game.i18n.localize("RINGBRP.Expedition.ForcedEncounter")}

${leg.forcedEncounterId}

` + }); + } + + const upd = {}; + let cleanScene = expedition.getFlag("ringbrp", "cleanSceneArmedBy") ? 1 : 0; + const take = (track, n) => { + let amount = n; + if (track === "integrity" && cleanScene > 0) { // Clean Scene absorbs one + const absorbed = Math.min(cleanScene, amount); + amount -= absorbed; cleanScene -= absorbed; + ui.notifications?.info(game.i18n.localize("RINGBRP.Talent.CleanSceneUsed")); + } + if (!amount) return; + const cur = upd[`system.${track}.value`] ?? expedition.system[track].value; + upd[`system.${track}.value`] = Math.max(0, cur - amount); + }; + const baseSupply = waived ? 0 : Number(leg.costSupply ?? 1); + const basePower = Number(leg.costPower ?? 0); + let advance = true, note = ""; + + // A full playthrough showed Clearance and Containment never moving once across + // four phases: only Resources was driven by anything. All three tracks now have + // a reason to exist. Containment is what the world notices; Clearance is what + // the agency will still admit to. + switch (res.level) { + case "critical": + note = leg.explored ? "RINGBRP.Expedition.CriticalExplored" : "RINGBRP.Expedition.CriticalSite"; + break; + case "special": + note = "RINGBRP.Expedition.Special"; + break; + case "success": + take("supply", baseSupply); if (basePower) take("power", basePower); + note = "RINGBRP.Expedition.Success"; + break; + case "failure": + take("supply", baseSupply + 1); if (basePower) take("power", basePower); + take("integrity", 1); // the phase went loudly + note = "RINGBRP.Expedition.Failure"; + break; + case "fumble": + take("supply", baseSupply); if (basePower) take("power", basePower); + take("integrity", 2); // somebody saw something + take("power", 1); // and the agency heard about it + advance = false; + note = "RINGBRP.Expedition.Fumble"; + break; + } + // Running on empty is not a wall. R-56: a depleted track makes the next phase + // dearer, never impossible. + if (destitute(expedition)) take("integrity", 1); + if (!cleanScene && expedition.getFlag("ringbrp", "cleanSceneArmedBy")) { + await expedition.unsetFlag("ringbrp", "cleanSceneArmedBy"); + } + if (advance) { + legs[idx].resolved = true; + // `explored` was READ at the critical branch above and written by nothing, so + // every critical reported "site found" and the "you already know this ground" + // result could never occur. Ground the team has crossed is ground they know. + legs[idx].explored = true; + upd["system.journey.legs"] = legs; + upd["system.journey.currentLeg"] = idx + 1; + // Time was recorded per leg, displayed on the sheet, and never accumulated. + upd["system.journey.elapsedDays"] = + (Number(expedition.system.journey?.elapsedDays) || 0) + (Number(leg.days) || 1); + } + await expedition.update(upd); + ChatMessage.create({ + content: `

${game.i18n.localize("RINGBRP.Expedition.Title")}

+

${game.i18n.localize(note)}

` + }); +} + +/* -------------------------------------------- */ +/* Hooks */ +/* -------------------------------------------- */ + +Hooks.once("init", () => { + console.log("RingBRP | initialising"); + + CONFIG.RINGBRP = RINGBRP; + CONFIG.Actor.documentClass = RingBRPActor; + CONFIG.Item.documentClass = RingBRPItem; + CONFIG.Combat.documentClass = RingBRPCombat; + CONFIG.Combat.initiative = { formula: "0", decimals: 1 }; + + foundry.documents.collections.Actors.unregisterSheet("core", ActorSheet); + foundry.documents.collections.Actors.registerSheet("ringbrp", RingBRPCharacterSheet, { + types: ["character"], makeDefault: true, label: "RingBRP Explorer Sheet" + }); + foundry.documents.collections.Actors.registerSheet("ringbrp", RingBRPNpcSheet, { + types: ["npc"], makeDefault: true, label: "RingBRP Statblock" + }); + foundry.documents.collections.Actors.registerSheet("ringbrp", RingBRPExpeditionSheet, { + types: ["expedition"], makeDefault: true, label: "RingBRP Expedition" + }); + foundry.documents.collections.Items.unregisterSheet("core", ItemSheet); + foundry.documents.collections.Items.registerSheet("ringbrp", RingBRPItemSheet, { + makeDefault: true, label: "RingBRP Item Sheet" + }); + + // Handlebars helpers + Handlebars.registerHelper("rbCeilHalf", v => Math.ceil((Number(v) || 0) / 2)); + Handlebars.registerHelper("rbTimes5", v => (Number(v) || 0) * 5); + Handlebars.registerHelper("rbEq", (a, b) => a === b); + Handlebars.registerHelper("rbOr", (a, b) => a || b); + Handlebars.registerHelper("rbNot", a => !a); + Handlebars.registerHelper("rbLen", v => (v?.length ?? 0)); + Handlebars.registerHelper("rbGt", (a, b) => Number(a) > Number(b)); + Handlebars.registerHelper("rbConcat", (...args) => args.slice(0, -1).join("")); + Handlebars.registerHelper("rbBands", (rating) => { + const b = resolveBands(rating); + return `${b.critical} / ${b.special} / ${b.effective} / ${b.fumbleStart}+`; + }); + Handlebars.registerHelper("rbGt", (a, b) => Number(a) > Number(b)); + Handlebars.registerHelper("rbFixed", (v, n) => (Number(v) || 0).toFixed(Number(n) || 0)); + Handlebars.registerHelper("rbSign", v => (Number(v) >= 0 ? `+${v}` : `${v}`)); + Handlebars.registerHelper("rbAdd", (a, b) => (Number(a) || 0) + (Number(b) || 0)); + + // Make the display face available to journals, drawings and the text editor too. + CONFIG.fontDefinitions["Michroma"] = { + editor: true, + fonts: [{ urls: ["systems/ringbrp/fonts/Michroma-Regular.woff2"], weight: 400, style: "normal" }] + }; + + game.ringbrp = { + RINGBRP, skillRoll, majorWoundRoll, resolveBands, gradeRoll, applyDifficulty, + damageModifierFor, rangeBandFor, styleRangeBonus, hitPointsFor, majorWoundFor, + damageAfterArmour, resolveLegDialog, armourAgainst, resolveHit, + reflexRating, awarenessRating, reactionBase, loadBand, reactionModifier, fluxDie, rollReaction, + reactionLoadFrom, reactionPrintedFrom, + readinessRating, percentileFaces, circumstanceTotal, + coherenceBand, coherencePenaltyFor, woundPenaltyFor, loseCoherence, regainCoherence, displace, + isAcross, setAcross, useTransposed, debrief, activeCaseFile, returnCrossing, caseMembers, + earnedDiscoveries, deviceFunctionStatus, earnDiscovery, useDeviceFunction, pickContactActor, + resolveContact, newEncounter, claimRewards, activeRewardEffects, + defenceOutcomeFor, levelRank, recordBlow, standingBlow, amendBlow, + addDossierItem, useAuthority, widenScope, rehabilitate, + scopeStanceFor, dossierTrustFor, mergeStageFor, SCOPE_TIERS, + heal, firstAid, medicalCare, applyBleeding, stopBleeding, bleedRateFor, BLEED, + defend, defencePenaltyFor, + usePower, grantPower, ARTS, powerCostFor, crossBack, + generateMissionCase, missionDialog, salvageFrom, + crossingArtPoolFor, crossingGrantsArt, crossingCostFor, returnCrossing, + rollWeaponDamage, diceSetFor, + restFor, endOfCase, gearBonusFor, + hasTalent, talentAvailable, useTalent, requisition, armCleanScene, resetTalents, + generateCharacter, generateNPC, generateDialog, installContent, grantFullSkillList, + ROLES, TIERS, THREATS, SPECIES, + npcTarget, npcShouldWithdraw, declareNpcTarget, + setReaction, + locationFor, locationMaxHp, resolveLocationHit, hitLocationRoll + }; +}); + +Hooks.once("ready", () => { + console.log("RingBRP | ready"); +}); + +/** + * Let the GM redeclare a combatant's action from the tracker. + * v14's CombatTracker is an ApplicationV2 with no context-menu hook, so the + * method is patched directly. Both the v2 and v1 entry shapes are supported. + */ +Hooks.once("setup", () => { + const Tracker = CONFIG.ui.combat; + if (!Tracker?.prototype?._getEntryContextOptions) return; + const orig = Tracker.prototype._getEntryContextOptions; + Tracker.prototype._getEntryContextOptions = function () { + const options = orig.call(this) ?? []; + const combatantFrom = li => { + const id = li?.dataset?.combatantId ?? li?.[0]?.dataset?.combatantId; + return (this.viewed ?? game.combat)?.combatants?.get(id) ?? null; + }; + const isV1 = options.length > 0 && options[0].name !== undefined; + options.unshift(isV1 + ? { name: "RINGBRP.React.Set", icon: '', + condition: () => game.user.isGM, + callback: li => { const c = combatantFrom(li); if (c) setReaction(c); } } + : { label: "RINGBRP.React.Set", icon: "fa-solid fa-stopwatch", + visible: () => game.user.isGM, + onClick: (event, li) => { const c = combatantFrom(li); if (c) setReaction(c); } }); + return options; + }; +}); + +/** Show the Reaction breakdown on the tracker, and flag a misfiled die. */ +/** + * Damage from the attack card. Bound on render rather than inline so the button + * works on reload, and registered under both hook names because v13 renamed the + * chat render hook and passes an element where v12 passed jQuery. + */ +async function _onChatDamage(ev) { + const btn = ev.currentTarget; + const card = btn.closest("[data-message-id]"); + const msg = game.messages.get(card?.dataset?.messageId); + const f = msg?.flags?.ringbrp; + if (!f?.weaponId) return; + const actor = f.tokenId ? (canvas.tokens?.get(f.tokenId)?.actor ?? game.actors.get(f.actorId)) + : game.actors.get(f.actorId); + const weapon = actor?.items?.get(f.weaponId); + if (!weapon) return ui.notifications?.warn(game.i18n.localize("RINGBRP.Notify.NoWeapon")); + btn.disabled = true; // one damage roll per hit + await rollWeaponDamage({ actor, weapon, level: f.level }); +} + +// v13 renamed this and warns on every message if the old name is registered, so +// take the new one when it exists rather than registering both. +for (const hook of [foundry?.applications?.sidebar?.tabs?.ChatLog + ? "renderChatMessageHTML" : "renderChatMessage"]) { + Hooks.on(hook, (msg, html) => { + const root = html?.[0] ?? html; + if (!root?.querySelectorAll) return; + root.querySelectorAll("[data-action='chat-damage']") + .forEach(el => el.addEventListener("click", _onChatDamage)); + }); +} + +Hooks.on("renderCombatTracker", (app, html) => { + const root = html instanceof HTMLElement ? html : html?.[0]; + if (!root || !game.combat) return; + for (const el of root.querySelectorAll("[data-combatant-id]")) { + const c = game.combat.combatants.get(el.dataset.combatantId); + const r = c?.flags?.ringbrp?.reaction; + const init = el.querySelector(".token-initiative"); + if (!init || !r) continue; + const sign = r.mod >= 0 ? "+" : ""; + init.setAttribute("title", + `${game.i18n.localize("RINGBRP.React.Base")} ${r.base} ${sign}${r.mod} + d4 ${r.die}` + + (r.misfiled ? ` — ${game.i18n.localize("RINGBRP.React.Misfiled")}` : "")); + const tag = document.createElement("span"); + tag.className = "rb-sr-action" + (r.misfiled ? " rb-misfiled" : ""); + tag.textContent = r.misfiled + ? game.i18n.localize("RINGBRP.React.MisfiledShort") + : `${r.base}${sign}${r.mod}+${r.die}`; + init.appendChild(tag); + } +}); + +/** Reset per-round combat state (spec §4.3). */ +Hooks.on("updateCombat", async (combat, changed) => { + if (!("round" in changed) && !("turn" in changed)) return; + if (!game.user.isGM) return; + + // Blood loss, once per round, charged at the top of the NEW round because that is + // the end of the previous one. Entering round 1 must not charge: no round has + // elapsed yet, and starting combat used to cost a bleeding agent a free tick. + if ("round" in changed && changed.round > 1) { + for (const c of combat.combatants) { + if (c.actor && c.actor.type !== "expedition") await applyBleeding(c.actor); + } + } + + for (const c of combat.combatants) { + const a = c.actor; + if (!a || a.type === "expedition") continue; + if (a.system.combat?.defencesThisRound || a.system.combat?.secondAttackUsed) { + await a.update({ + "system.combat.defencesThisRound": 0, + "system.combat.defenceTypeLock": "", + "system.combat.secondAttackUsed": false + }); + } + } +}); diff --git a/rules.mjs b/rules.mjs new file mode 100644 index 0000000..bcd68da --- /dev/null +++ b/rules.mjs @@ -0,0 +1,664 @@ +/** + * THE CUSTODIANS — canonical rule functions. + * + * This module is the single authority for every pure rule formula. It is imported + * by the runtime (`ringbrp.mjs`, in the browser) AND by the pack builder + * (`tools/content.mjs`, in Node), so the packs can never be built under one rule + * and played under another. + * + * That happened repeatedly. `tools/content.mjs` used to carry its own copies + * "mirrored from ringbrp.mjs", and they drifted: Major Wound was `max(9, hp/3)` + * in the builder while the engine used `hp/2`, and hit points stayed at `CON+SIZ` + * in the builder after the engine halved them. Five separate defects in this + * project came from a rule written twice and changed once. + * + * RULE: if a formula is used in more than one place, it lives here. Nothing in + * this file may import from the runtime or touch Foundry globals. + */ + +export const ceil = Math.ceil; + +/** Hit points: half of CON + SIZ, standard BRP. */ +export function hitPointsFor(con, siz) { + return Math.max(1, ceil(((Number(con) || 0) + (Number(siz) || 0)) / 2)); +} + +/** A Major Wound is a blow of half your hit points or more. */ +export function majorWoundFor(hp) { + return Math.max(1, ceil((Number(hp) || 0) / 2)); +} + +/** Damage modifier from STR + SIZ. */ +export function damageModifierFor(total) { + const t = Number(total) || 0; + if (t <= 8) return "-1d6"; + if (t <= 16) return "-1d3"; + if (t <= 24) return "0"; + if (t <= 32) return "+1d3"; + if (t <= 40) return "+1d6"; + if (t <= 50) return "+2d6"; + if (t <= 60) return "+3d6"; + if (t <= 70) return "+4d6"; + return `+${4 + ceil((t - 70) / 10)}d6`; +} + +/** Marksmanship is the agent's own choice now that home worlds are gone. */ +export function styleFor(chosen = "reflex") { return chosen; } + +/** A location's hit points, as a fraction of the total pool. */ +export function locationMaxHp(totalHp, frac) { + return Math.max(1, ceil((Number(totalHp) || 0) * (Number(frac) || 0))); +} + +/** Reaction: the two axes and the printed base. */ +export const REACTION = { dexDivisor: 3, readinessDivisor: 10, laneMultiplier: 3, die: 6, modCap: 3 }; +export function reflexRating(dex) { return Math.max(1, ceil((Number(dex) || 0) / REACTION.dexDivisor)); } +export function awarenessRating(readiness) { return Math.max(1, ceil((Number(readiness) || 0) / REACTION.readinessDivisor)); } +export function reactionBaseFrom(dex, readiness) { + return REACTION.laneMultiplier * Math.max(reflexRating(dex), awarenessRating(readiness)); +} + +/** Encumbrance: whole steps drive MOV and skills, the continuous figure drives Reaction. */ +/** + * Bleeding, both halves of it. + * + * The table printed a rate and a way to stop it, and the game implemented neither: + * nothing ticked the loss during play, and First Aid healed hit points without ever + * clearing the wound. So bleeding was inert at the table and unstoppable in + * simulation — the same rule failing in both directions at once. + * + * `perRound` is the loss at the end of each round. `stop` lists what ends it; ANY + * one entry is enough. A stop entry with a `difficulty` requires the roll to have + * been made at that difficulty or harder, which is what "a Difficult First Aid" + * means; entries without one accept any difficulty. + */ +export const BLEED = { + // The table says "a successful First Aid" for bleeding. Medicine is added because + // the printed rule left a surgeon unable to stop the lesser wound they could treat. + bleeding: { perRound: 1, + stop: [{ family: "first_aid" }, { family: "medicine" }] }, + deepBleeding: { perRound: 2, + stop: [{ family: "first_aid", difficulty: "difficult" }, + { family: "medicine" }] } +}; + +const DIFFICULTY_RANK = { easy: 0, average: 1, difficult: 2, formidable: 3 }; + +/** Does this successful roll stop this kind of bleeding? */ +export function bleedStoppedBy(key, { family = "", difficulty = "average" } = {}) { + const spec = BLEED[key]; + if (!spec) return false; + return spec.stop.some(s => + s.family === family && + (s.difficulty === undefined || + (DIFFICULTY_RANK[difficulty] ?? 1) >= (DIFFICULTY_RANK[s.difficulty] ?? 1))); +} + +/** Total loss per round from every bleeding wound an actor carries. */ +export function bleedRateFor(wounds = []) { + return (Array.isArray(wounds) ? wounds : []) + .reduce((n, w) => n + (BLEED[w?.key]?.perRound ?? 0), 0); +} + +/** + * Major Wound consequences, as penalties rather than prose. + * + * The table used to be rolled, announced and recorded, and then change nothing: a + * `manipulation` wound whose own text reads "−30% Manipulation" applied zero, + * `concussed` ("−20% to all rolls") applied zero, and `mobility` ("MOV halved") + * left MOV untouched. Only crippling and catastrophic did anything, through the + * separate isDown check. This turns each row into numbers the roll pipeline reads. + * + * Takes the recorded `system.wounds` array; returns penalties to FOLD INTO the + * location-derived wound state, never to replace it. Wounds stack: two concussions + * are −40%, and a Major Wound stacks with a shattered limb, because both are true. + * + * `all` is applied to every category by woundPenaltyFor. The per-category numbers + * are additional and specific. + * + * Variants: `sensory` and `broken` each split two ways on 1D2, chosen when the + * wound is rolled and stored on the entry. An absent variant falls back to the + * harsher reading, so a missing field never quietly costs the target nothing. + */ +export function woundEffectsFor(wounds = []) { + const eff = { all: 0, manipulation: 0, physical: 0, perception: 0, + communication: 0, movFactor: 1, movFloor: 0, bleed: 0, + defenceOnly: false, unconscious: false, lostAction: false }; + for (const w of Array.isArray(wounds) ? wounds : []) { + switch (w?.key) { + case "concussed": eff.all -= 20; break; + case "winded": eff.physical -= 20; eff.lostAction = true; break; + case "bleeding": + case "deepBleeding": eff.bleed += BLEED[w.key].perRound; break; + case "manipulation": eff.manipulation -= 30; break; + case "mobility": eff.physical -= 30; eff.movFactor *= 0.5; break; + case "sensory": + // sight also blinds you to what you are shooting at; hearing does not + eff.perception -= 40; + if (w.variant === "hearing") eff.communication -= 40; + else eff.manipulation -= 40; + break; + case "broken": + if (w.variant === "mobility") { eff.physical -= 30; eff.movFactor *= 0.5; } + else eff.manipulation -= 30; + break; + case "crippling": eff.defenceOnly = true; eff.movFloor = 1; eff.movFactor = 0; break; + case "catastrophic": eff.unconscious = true; eff.defenceOnly = true; + eff.movFloor = 1; eff.movFactor = 0; break; + } + } + return eff; +} + +/** + * Load on the reaction, exact. One point per 10 kg over free carry, with no + * staircase — this is the granular rule R-23 asked for, that no weight is ever + * free and no single kilogramme ever costs a whole point. It replaces the old + * three-value band, which charged a full point for the first gram over and then + * nothing more until 10 kg. + * + * Rounded to one decimal because the value is printed on a paper sheet and added + * to a die by hand. + */ +export function reactionLoadFrom(srLoad) { + // Rounded UP, not to nearest. To nearest would make the first half-kilogramme + // free, and the rule is that no weight is ever free. A tenth of a point is the + // smallest unit worth writing on a paper sheet, so that is the floor of the cost. + return Math.ceil((Number(srLoad) || 0) * 10) / 10; +} + +/** + * The number actually printed on the character sheet: the lane base less load. + * Floored at 1 — R-56, the critical-path invariant. No amount of kit may reduce + * an agent to acting never; it may only ever reduce them to acting last. + */ +export function reactionPrintedFrom(base, srLoad) { + const b = Number(base) || 0; + return Math.max(1, Math.round((b - reactionLoadFrom(srLoad)) * 10) / 10); +} + +export function encumbranceFrom(carriedKg, freeCarryKg) { + const over = Math.max(0, (Number(carriedKg) || 0) - (Number(freeCarryKg) || 0)); + const steps = Math.floor(over / 10); + const rounded = Math.round(over * 10) / 10; + // `over` and `overKg` are the same number under both names: the engine reads one, + // the guard's spot-checks the other, and a rename in either used to silently + // zero the encumbrance penalty. Kept deliberately, not by accident. + return { over: rounded, overKg: rounded, steps, movPenalty: steps, + skillPenalty: steps * 5, srLoad: over / 10 }; +} + +/* ============================================================ + * Rule TABLES. Pure data, no Foundry, so the engine, the pack + * builder and the rules journal all read the same rows. The + * journal is generated from these — a number cannot be right in + * the code and wrong in the book. + * ============================================================ */ + +/** The situational ladder. Applied after difficulty, capped at +-40 in total. */ +export const CIRCUMSTANCE = [ + { id: "overwhelming", mod: 30, label: "RINGBRP.Circ.Overwhelming" }, + { id: "favourable", mod: 20, label: "RINGBRP.Circ.Favourable" }, + { id: "slightGood", mod: 10, label: "RINGBRP.Circ.SlightGood" }, + { id: "neutral", mod: 0, label: "RINGBRP.Circ.Neutral" }, + { id: "slightBad", mod: -10, label: "RINGBRP.Circ.SlightBad" }, + { id: "unfavourable", mod: -20, label: "RINGBRP.Circ.Unfavourable" }, + { id: "severe", mod: -30, label: "RINGBRP.Circ.Severe" } +]; + +/** Range. `mod` null means the shot cannot be attempted at all. */ +export const RANGE_LADDER = [ + { id: "short", mod: 0, upto: r => r.short }, + { id: "medium", mod: -10, upto: r => r.medium }, + { id: "long", mod: -30, upto: r => r.long }, + { id: "extreme", mod: -50, upto: r => r.long * 2 }, + { id: "beyond", mod: null, upto: () => Infinity } +]; + +export function rangeBandFrom(distance, ranges) { + const d = Number(distance) || 0; + const r = { short: Number(ranges?.short) || 0, medium: Number(ranges?.medium) || 0, + long: Number(ranges?.long) || 0 }; + if (!r.short && !r.medium && !r.long) return { band: "short", mod: 0 }; + for (const step of RANGE_LADDER) { + const limit = step.upto(r); + if (d <= limit) return { band: step.id, mod: step.mod }; + } + return { band: "beyond", mod: null }; +} + +/** 1D10 on a Major Wound. Consequences are in woundEffectsFor. */ +export const MAJOR_WOUND_TABLE = [ + { min: 1, max: 1, key: "concussed" }, + { min: 2, max: 2, key: "winded" }, + { min: 3, max: 3, key: "bleeding" }, + { min: 4, max: 4, key: "manipulation" }, + { min: 5, max: 5, key: "mobility" }, + { min: 6, max: 6, key: "deepBleeding" }, + { min: 7, max: 7, key: "sensory" }, + { min: 8, max: 8, key: "broken" }, + { min: 9, max: 9, key: "crippling" }, + { min: 10, max: 10, key: "catastrophic" } +]; + +/** Coherence. 10 is anchored, 0 is displaced and permanent. */ +export const COHERENCE_BANDS = [ + { min: 8, id: "anchored", label: "RINGBRP.Coh.Anchored", all: 0, comm: 0, note: "RINGBRP.Coh.AnchoredNote" }, + { min: 5, id: "loose", label: "RINGBRP.Coh.Loose", all: 0, comm: -5, note: "RINGBRP.Coh.LooseNote" }, + { min: 3, id: "adrift", label: "RINGBRP.Coh.Adrift", all: -5, comm: -10, note: "RINGBRP.Coh.AdriftNote" }, + { min: 1, id: "unmoored", label: "RINGBRP.Coh.Unmoored", all: -10, comm: -20, note: "RINGBRP.Coh.UnmooredNote", + returnPenalty: -30 }, + { min: 0, id: "displaced", label: "RINGBRP.Coh.Displaced", all: -20, comm: -30, note: "RINGBRP.Coh.DisplacedNote" } +]; + +export const COHERENCE_COSTS = { + fumbleOnCrossing: { cost: 1, label: "RINGBRP.Coh.Cost.Fumble" }, + witnessedParadox: { cost: 1, label: "RINGBRP.Coh.Cost.Paradox" }, + usedCrossingTech: { cost: 1, label: "RINGBRP.Coh.Cost.Tech" }, + brokeLocalLaw: { cost: 1, label: "RINGBRP.Coh.Cost.Local" }, + metYourself: { cost: 2, label: "RINGBRP.Coh.Cost.Self" }, + killedADuplicate: { cost: 3, label: "RINGBRP.Coh.Cost.Duplicate" }, + overstayed: { cost: 1, label: "RINGBRP.Coh.Cost.Overstay" } +}; + +export const COHERENCE_RECOVERY = { debrief: 1, downtimeWeek: 1, anchorObject: 1 }; + +export function coherenceBandFrom(value) { + const v = Math.max(0, Number(value) || 0); + return COHERENCE_BANDS.find(b => v >= b.min) ?? COHERENCE_BANDS.at(-1); +} + +/** + * What a lost LOCATION does, by anatomical kind. + * + * Legs and arms were applied; everything else was localised prose that changed no + * number. A destroyed head said "unconscious immediately" and left the agent + * standing; a Vesh's opened sensory ridge promised -30% Perception and gave none; + * and the Cadence rule — "each disabled body costs the whole person -10% to + * everything" — did nothing whatever, which is the entire mechanical identity of + * one of the three playable species. + * + * Same shape as woundEffectsFor so the two fold together additively: both are true + * about the same body at the same time. + */ +export function locationEffectsFor(locations = []) { + const eff = { all: 0, manipulation: 0, physical: 0, perception: 0, + movFactor: 1, unconscious: false, prone: false, + legs: 0, legsDestroyed: 0, arms: 0, vital: false, unitsLost: 0 }; + for (const l of Array.isArray(locations) ? locations : []) { + if (!l?.disabled) continue; + switch (l.kind) { + case "leg": case "brace": + eff.legs++; + if (l.destroyed) eff.legsDestroyed++; + eff.physical -= 30; + break; + case "arm": case "grasp": case "manipulator": + eff.arms++; + eff.manipulation -= 30; + break; + case "ridge": + eff.perception -= 30; + eff.vital = true; + break; + case "head": + eff.unconscious = true; // "unconscious immediately" + eff.vital = true; + break; + case "vital": + eff.vital = true; // knocked down; the Stamina roll is the GM's call + break; + case "unit": + eff.unitsLost++; + eff.all -= 10; // each body lost costs the WHOLE person + break; + } + } + // One leg gone is a limp; both, or a destroyed one, is the floor. + eff.movFactor = eff.legsDestroyed ? 0.1 : (eff.legs ? 0.5 : 1); + eff.prone = eff.legsDestroyed > 0 || eff.vital; + return eff; +} + +/** + * Which skill categories a heavy pack actually impedes. + * + * `encumbranceFrom` has computed a skillPenalty since R-23 and NOTHING ever applied + * it — the comment in skillRoll even says the penalty belongs there. Carrying too + * much is scoped to the physical categories rather than every skill, because a + * rucksack does not make you worse at Knowledge, and a rule that says otherwise is + * a rule nobody will use. + */ +export const ENCUMBERED_CATEGORIES = ["phys", "melee", "ranged", "manip"]; + +export function encumbrancePenaltyFor(skillPenalty, categoryId) { + if (!ENCUMBERED_CATEGORIES.includes(categoryId)) return 0; + return -Math.abs(Number(skillPenalty) || 0); +} + +/** + * What you can carry before it costs you anything. + * + * Was STR alone, in kilogrammes, which ignored how big you are: Rakhi at STR 18 and + * MAS 19 — a large, powerful woman — had a free carry of 18 kg and was twenty + * kilos over with a soldier's ordinary fighting load. A rule that makes a realistic + * pack an emergency is a rule the table will quietly drop. + * + * STR + MAS is the same pair that sets the damage modifier, so one physical axis + * decides both how hard you hit and how much you can shoulder. Rakhi's free carry + * becomes 37 kg and her 38 kg load puts her exactly 1 kg over — heavy, deliberate, + * and worth 0.1 of a point rather than two. + */ +export function freeCarryFor(str, siz) { + return Math.max(1, (Number(str) || 0) + (Number(siz) || 0)); +} + +/** + * Defending. The book says "one offensive action, plus as many defensive reactions + * as you like at an escalating penalty" — and the escalation was the only part that + * existed anywhere: `defencesThisRound` was stored on every actor, reset by the + * round hook, and incremented by nothing, because no defence was ever rolled. + * + * The first defence in a round is free. Each one after it is cumulatively harder, + * so standing in front of three people is possible and stupid. + */ +export const DEFENCE_STEP = -30; + +export function defencePenaltyFor(defencesUsed = 0) { + return DEFENCE_STEP * Math.max(0, Number(defencesUsed) || 0); +} + +/* -------------------------------------------- */ +/* Borrowed Authority */ +/* -------------------------------------------- */ + +/** + * ACCESS IS A RECORD. + * + * There is no hacking skill and no network to enter. What an agent has is a named + * credential belonging to somebody real — a facilities contractor, a locum, a + * supplier's engineer — with a scope of things that person could plausibly do, a + * Trust that erodes as it is used, and a red flag or two describing how the real + * owner behaves. Inside its scope the credential simply works. Outside it, you are + * asking a system to believe something about a person, and that is a roll. + * + * The point of the design in a game about reality keeping a record of what belongs + * where: an Authority IS a record, and Coherence can widen it, because reality can + * be made to remember that the person held that duty. What it costs is that the + * agent's own record starts merging with the one they borrowed. + */ + +/** What a credential could conceivably be asked to do, in order. */ +export const SCOPE_TIERS = ["nothing", "read", "routine", "privileged", "administrative"]; + +export function scopeRank(tier) { + const i = SCOPE_TIERS.indexOf(String(tier)); + return i < 0 ? 0 : i; +} + +/** + * How an action stands against a credential's scope. This is the rule that stops + * "I hack anything": two tiers beyond the credential is not hard, it is impossible, + * and no percentage on any sheet changes that. + * + * at or below scope -> it simply works. No roll. 1 Trust. + * exactly one above -> a stretch. Roll for it. + * two or more above -> impossible. The identity was never able to do this. + */ +export function scopeStanceFor(authorityScope, actionTier) { + const gap = scopeRank(actionTier) - scopeRank(authorityScope); + if (gap <= 0) return "in-scope"; + if (gap === 1) return "stretch"; + return "impossible"; +} + +/** Behaving unlike the person you are claiming to be is what gets noticed. */ +export const RED_FLAG_STEP = -20; + +export function scrutinyFor(flagsTripped = 0) { + return RED_FLAG_STEP * Math.max(0, Number(flagsTripped) || 0); +} + +/** + * What a stretched use of a credential does to it. Mirrors the defence ladder: the + * band is the whole answer, and a fumble is not merely a failure — it burns the + * credential and puts the person it belongs to in the department's way. + */ +export function authorityOutcomeFor(level) { + switch (level) { + case "critical": return { works: true, trustDelta: +1, burned: false }; + case "special": return { works: true, trustDelta: 0, burned: false }; + case "success": return { works: true, trustDelta: -1, burned: false }; + case "fumble": return { works: false, trustDelta: -99, burned: true }; + default: return { works: false, trustDelta: -1, burned: false }; + } +} + +/** Trust never rises above what the dossier bought, and stops at nothing. */ +export const TRUST_MAX = 4; + +export function trustAfter(current, delta, max = TRUST_MAX) { + const t = (Number(current) || 0) + (Number(delta) || 0); + return Math.max(0, Math.min(Number(max) || TRUST_MAX, t)); +} + +/** + * THE DOSSIER — how a credential is obtained. + * + * Not a second subsystem and not a stack of flat bonuses: a dossier item does not + * make the roll easier, it makes the credential better. Each item is a separate + * scene using a different skill, and at most one may be Computer Use, so assembling + * access is something the whole team does rather than one specialist. + * + * Trust starts at 1 and rises by one per item. Scope starts at "read" and a single + * item — the one that establishes what the person is actually FOR — may raise it. + */ +export const DOSSIER = { maxItems: 3, baseTrust: 1, baseScope: "read", maxComputerUse: 1 }; + +export function dossierTrustFor(itemCount) { + return Math.max(1, Math.min(TRUST_MAX, DOSSIER.baseTrust + (Number(itemCount) || 0))); +} + +/** A dossier that establishes remit widens what the credential covers, once. */ +export function dossierScopeFor(itemCount, establishesRemit = false) { + const base = scopeRank(DOSSIER.baseScope); + const up = (establishesRemit && (Number(itemCount) || 0) >= 2) ? 1 : 0; + return SCOPE_TIERS[Math.min(SCOPE_TIERS.length - 1, base + up)]; +} + +/** + * Coherence widens a scope by making reality remember that the person held that + * duty. The cost rises with how far you have already pushed the record — the first + * lie is cheap and the fourth is not. + */ +export function widenScopeCostFor(timesWidened = 0) { + return 1 + Math.max(0, Number(timesWidened) || 0); +} + +/** + * The horror, with a number on it. Every time an Authority is pushed past what its + * Trust can carry, the agent's file and the borrowed one converge one step. This is + * what the subsystem is actually about and it is deliberately not reversible by + * spending anything. + */ +export const MERGE_STAGES = [ + "RINGBRP.Authority.Merge.0", + "RINGBRP.Authority.Merge.1", + "RINGBRP.Authority.Merge.2", + "RINGBRP.Authority.Merge.3", + "RINGBRP.Authority.Merge.4" +]; + +export function mergeStageFor(steps) { + const n = Math.max(0, Math.min(MERGE_STAGES.length - 1, Number(steps) || 0)); + return { step: n, label: MERGE_STAGES[n], terminal: n >= MERGE_STAGES.length - 1 }; +} + +/** + * Fists are a fallback, not a peer of the weapon you are carrying. + * + * A generated adversary was given Brawl from the same range as its weapon skills, + * so an Armed thug was exactly as good with its hands as with the pistol on its + * hip — and any sensible target-picker chose the fists, which then bounced off + * armour 2 for 1d3. Brawl is now a fraction of the trained rating, never below the + * skill's printed base. + * + * This does NOT apply to a creature that ATTACKS with Brawl: the anomalous grasp is + * a trained attack and keeps its full rating. The caller decides which case it is. + */ +export const BRAWL_FALLBACK = { fraction: 0.6, floor: 25 }; + +export function brawlFallbackFor(trainedRating) { + const r = Math.round((Number(trainedRating) || 0) * BRAWL_FALLBACK.fraction); + return Math.max(BRAWL_FALLBACK.floor, r); +} + +/** + * A skill whose base is written as a formula rather than a flat number. + * + * Dodge is the only one, and its base is DEX×2 — which nothing in the system ever + * worked out. `base.mode` was "formula", `base.value` stayed 0, and every reader + * took `system.value` at face value, so an agent whose posting did not train Dodge + * had Dodge 0%: the game's universal defence, unavailable to everybody who had not + * separately bought it, and missing its floor even for those who had. + * + * Deliberately not eval(). Accepts `char`, `char*n`, `char+n` and `char*n+m`. + */ +export function skillBaseFrom(formula, characteristics = {}) { + const m = /^\s*([a-z]+)\s*(?:\*\s*(\d+))?\s*(?:\+\s*(\d+))?\s*$/i.exec(String(formula || "")); + if (!m) return 0; + const stat = Number(characteristics?.[m[1].toLowerCase()]?.value + ?? characteristics?.[m[1].toLowerCase()]) || 0; + return Math.max(0, stat * (m[2] ? Number(m[2]) : 1) + (m[3] ? Number(m[3]) : 0)); +} + +/** + * The five outcome bands, worst to best. Exported so nothing has to re-declare + * the order to compare two rolls against each other. + */ +export const LEVEL_LADDER = ["fumble", "failure", "success", "special", "critical"]; + +/** Where a level sits on the ladder. Anything unknown reads as a failure. */ +export function levelRank(level) { + const i = LEVEL_LADDER.indexOf(String(level)); + return i < 0 ? 1 : i; +} + +/** + * What a defence actually does to the blow it answers. + * + * The old rule was binary: any successful dodge or parry stopped anything, so a + * boarding-axe critical was cancelled outright by an ordinary parry and cost the + * attacker nothing. That sat badly against every other thing the system says about + * criticals — maximum damage, armour ignored entirely — and it made the best roll + * in the game worth no more than the commonest one. + * + * Defences are now graded. A defence stops a blow of its own quality or worse + * outright. Against something better it still helps, but only by as much as it was + * outclassed by: the blow lands one step worse for every step the defence fell + * short. So the attack degrades a critical to a special, a special to a success, + * and a success to nothing — and only a critical defence stops a critical. + * + * landing rank = failure + (attack rank − defence rank) + * + * A failed defence does nothing at all and the blow lands at its own level. + */ +export function defenceOutcomeFor(attackLevel, defenceLevel) { + const a = levelRank(attackLevel); + const d = levelRank(defenceLevel); + // A defence that did not succeed never reduces anything. + if (d < levelRank("success")) { + return { turnedAside: false, landsAt: LEVEL_LADDER[a], steps: 0 }; + } + const steps = a - d; + if (steps <= 0) return { turnedAside: true, landsAt: null, steps: 0 }; + return { turnedAside: false, landsAt: LEVEL_LADDER[levelRank("failure") + steps], steps }; +} + +/** + * You may not mix your defences in a round: having chosen to dodge, you dodge. + * Returns the type you are allowed to use, given what you have already done. + */ +export function defenceTypeAllowed(lock, wanted) { + return !lock || lock === wanted ? wanted : lock; +} + +/** + * THE QUIET ARTS. + * + * A power is not a spell. Crossing is an induced filing error — you persuade the + * record you are somewhere else — and an Art is the same trick performed small and + * on purpose: a misfiling you commit deliberately. So an Art costs the one thing + * that misfiling always costs, which is Coherence, and needs no second resource. + * + * Nobody learns one. They arrive from crossings, in the people the crossing + * changed, which is why the category the agency files them under is the same one + * headed "the crossing left this in you". + */ +export const ARTS = { + thread: { order: 1, coherence: 1, push: 2 }, + echo: { order: 2, coherence: 1, push: 2 }, + hollow: { order: 3, coherence: 1, push: 3 }, + witness: { order: 4, coherence: 1, push: 2 }, + unmake: { order: 5, coherence: 2, push: 4 }, + ledger: { order: 6, coherence: 2, push: 4 } +}; + +/** What using an Art costs, ordinarily and when pushed past its reach. */ +export function powerCostFor(artId, { pushed = false } = {}) { + const a = ARTS[artId]; + if (!a) return pushed ? 2 : 1; + return pushed ? a.push : a.coherence; +} + +/** + * An Art is governed by POW, and its starting rating is what the crossing left + * behind rather than anything the agent trained: POW plus a little, capped low. + * They are dangerous because of what they do, not because of what they roll. + */ +export function powerStartingRating(pow) { + return Math.max(5, Math.min(45, (Number(pow) || 0) * 2)); +} + +/** + * COMING BACK. + * + * The return is a second crossing and it has to be aimed. R-56 holds: it is never + * impossible, only dearer — a failure arrives you NEAR your target rather than at + * it, and a fumble brings you back changed. + * + * "Changed" is where the Quiet Arts come from. Nobody learns one; they arrive in + * people a crossing went wrong for, which is why the pool an agent draws from is + * decided by how far they had already drifted when it happened. You do not choose + * what you get, and the worse the state you were in, the worse the thing that + * follows you home. + */ +export const CROSSING = { + fumbleCoherence: 2, // a botched return costs this on top of everything else + failCoherence: 1 +}; + +/** Which Arts can arrive, given how anchored the agent was when it went wrong. */ +export function crossingArtPoolFor(coherence) { + const v = Number(coherence) || 0; + if (v >= 5) return ["thread", "echo", "witness"]; // still mostly here + if (v >= 3) return ["hollow", "thread", "echo"]; // adrift + return ["unmake", "ledger", "hollow"]; // unmoored or worse +} + +/** + * Did the crossing leave something behind? A fumble always does. A failure only + * does to an agent who was already coming apart. + */ +export function crossingGrantsArt(level, coherence) { + if (level === "fumble") return true; + if (level === "failure") return (Number(coherence) || 0) <= 2; + return false; +} + +/** What a return of this quality costs in Coherence. */ +export function crossingCostFor(level) { + if (level === "fumble") return CROSSING.fumbleCoherence; + if (level === "failure") return CROSSING.failCoherence; + return 0; +} diff --git a/styles/ringbrp.css b/styles/ringbrp.css new file mode 100644 index 0000000..023e820 --- /dev/null +++ b/styles/ringbrp.css @@ -0,0 +1,1241 @@ +/* ============================================================ + RingBRP — visual system v2 + + Corrected after design critique. The governing principle: + USE THE COVER FOR IDENTITY, THE INTERIOR AND THE ORIGINAL + CHARACTER SHEET FOR INFORMATION ARCHITECTURE. + v1 used the cover for everything, which produced a branding + exercise rather than a working tool. + + Sources, sampled not guessed: + · Cover — ultramarine #18268d, lemon yellow (hue 57°), white. + · Interior — Swiss grid; heavy rule above a section head and a + thin rule below; Helvetica body; multi-column category boxes. + · Explorer sheet — characteristics paired with their derived + rolls on one line; skills in columnar category boxes; the + weapons table always visible at the foot, never hidden. + + Type — four registers, two families, one webfont: + · Display Michroma, ONLY at the 18px name and 11px section heads. + · Utility Helvetica Bold 10px caps — every small label. + · Body Helvetica 13px/1.3. + · Data Helvetica Bold with tabular figures. Mono is reserved + for band strings and dice output. + + Colour roles are separated. YELLOW MEANS ROLLABLE OR ACTIVE. + Passive numbers are white. That distinction is the whole point, + and v1 lost it by making every value yellow. + ============================================================ */ + +@font-face { + font-family: "Michroma"; + src: url("../fonts/Michroma-Regular.woff2") format("woff2"); + font-weight: 400; + font-display: swap; +} + +.ringbrp-sheet, +.ringbrp-chat, +.ringbrp-window { + /* sampled from the 1984 Explorer Book cover */ + --rb-void: #0c125b; + --rb-deep: #101a6e; + --rb-blue: #18268d; + --rb-panel: #16215f; + + /* structure — lifted so non-text contrast is actually visible */ + --rb-line: #6674d8; /* 3.1:1 on the page ground */ + --rb-hairline: #4d5ac0; /* 2.2:1 — decorative separators only */ + + --rb-yellow: #f2ea72; + --rb-yellow-dim: rgba(242, 234, 114, 0.45); + --rb-white: #ecefee; + --rb-muted: #a8b2e3; /* 5.96:1 on the lightest ground */ + + --rb-danger: #ff7a5c; + --rb-good: #86e5b0; + + --rb-display: "Michroma", "Eurostile", sans-serif; + --rb-body: "Helvetica Neue", Helvetica, Arial, sans-serif; + --rb-mono: ui-monospace, "SF Mono", Menlo, Consolas, monospace; +} + +/* ============================================================ + Frame — flat spot colour, no gradients + ============================================================ */ + +.ringbrp-sheet { + background: var(--rb-deep); + color: var(--rb-white); + font-family: var(--rb-body); + font-size: 13px; + line-height: 1.3; + height: 100%; + display: flex; + flex-direction: column; + box-shadow: inset 0 0 0 1px var(--rb-yellow-dim); +} + +.ringbrp-sheet input, +.ringbrp-sheet select, +.ringbrp-sheet textarea { + background: var(--rb-void); + border: 1px solid var(--rb-line); + color: var(--rb-white); + border-radius: 0; + padding: 2px 5px; + font-family: var(--rb-body); + font-size: 13px; +} +.ringbrp-sheet input:focus, +.ringbrp-sheet select:focus, +.ringbrp-sheet textarea:focus { + border-color: var(--rb-yellow); + box-shadow: none; + outline: 1px solid var(--rb-yellow); +} +.ringbrp-sheet a { color: var(--rb-white); cursor: pointer; text-decoration: none; } +.ringbrp-sheet a[data-action^='roll'], .ringbrp-sheet .rb-char-x5, +.ringbrp-sheet .rb-skill-name, .ringbrp-sheet .rb-add { color: var(--rb-yellow); } +.ringbrp-sheet a:hover { color: #fff; text-shadow: none; } +.ringbrp-sheet ::-webkit-scrollbar { width: 10px; } +.ringbrp-sheet ::-webkit-scrollbar-thumb { background: var(--rb-line); } +.ringbrp-sheet ::-webkit-scrollbar-track { background: var(--rb-void); } + +/* --- the utility register: every small label --- */ +.rb-origin label, .rb-ages label, .rb-vital-label, .rb-dl, +.rb-table th, .rb-block, .rb-track-label, .rb-type, .rb-grid2 label, +.rb-strip-label { + font-family: var(--rb-body); + font-size: 10px; + font-weight: 700; + letter-spacing: .04em; + text-transform: uppercase; + color: var(--rb-muted); +} + +/* --- data: tabular figures in the body face, not mono --- */ +.rb-char input, .rb-hp input, .rb-big, .rb-armour input, +.rb-skill-val, .rb-dv, .rb-track-val, .rb-standing, .rb-char-x5 { + font-family: var(--rb-body); + font-variant-numeric: tabular-nums; + font-weight: 700; +} + +/* ============================================================ + Header — identity + ============================================================ */ + +.rb-header { + display: flex; gap: 14px; align-items: flex-start; + padding: 12px 14px 10px; + background: transparent; + border-bottom: 3px solid var(--rb-yellow); +} +.rb-portrait { + width: 84px; height: 84px; flex: 0 0 84px; object-fit: cover; + border: 1px solid var(--rb-line); background: var(--rb-void); +} +.rb-headline { flex: 1 1 auto; display: flex; flex-direction: column; gap: 6px; min-width: 0; } + +.rb-name { + font-family: var(--rb-display); + font-size: 18px; + letter-spacing: .02em; + color: var(--rb-white); + background: transparent; + border: none; + border-bottom: 1px solid var(--rb-hairline); + padding: 0 0 4px; +} +.rb-name:focus { border-bottom-color: var(--rb-yellow); outline: none; } +.rb-role { font-size: 12px; color: var(--rb-muted); background: transparent; border: none; } + +.rb-origin, .rb-ages { display: flex; gap: 14px; flex-wrap: wrap; align-items: center; } +.rb-origin label, .rb-ages label { display: flex; align-items: center; gap: 5px; } +.rb-origin input, .rb-ages input { width: 92px; } +.rb-origin select { width: 116px; } +.rb-origin input, .rb-ages input, .rb-origin select { font-weight: 400; letter-spacing: 0; + text-transform: none; color: var(--rb-white); font-size: 12px; +} +.rb-ages input { width: 54px; } +.rb-style-fixed { + font-family: var(--rb-body); font-size: 10px; font-weight: 700; letter-spacing: .06em; + color: var(--rb-void); background: var(--rb-yellow); + padding: 3px 8px; text-transform: uppercase; +} + +/* --- vitals: HP reads as one expression --- */ +.rb-vitals { display: flex; gap: 8px; align-items: stretch; } +.rb-vitals > div { + min-width: 78px; padding: 6px 10px; text-align: center; + background: var(--rb-void); border: 1px solid var(--rb-line); + display: flex; flex-direction: column; justify-content: center; gap: 2px; +} +.rb-vitals > .rb-hp { + display: grid; + grid-template-columns: auto auto auto; + grid-template-rows: auto auto; + align-items: baseline; justify-content: center; column-gap: 3px; +} +.rb-hp .rb-vital-label { grid-column: 1 / -1; } +.rb-hp input { + width: 42px; text-align: right; font-size: 24px; + background: transparent; border: none; padding: 0; color: var(--rb-white); +} +.rb-hp input:focus { outline: none; background: var(--rb-blue); } +.rb-slash { color: var(--rb-muted); font-size: 16px; } +.rb-max { font-size: 15px; color: var(--rb-muted); font-weight: 700; } +.rb-big { font-size: 24px; color: var(--rb-white); } +.rb-armour input { + width: 44px; text-align: center; font-size: 24px; + background: transparent; border: none; padding: 0; color: var(--rb-white); +} +.rb-wounded { border-color: var(--rb-danger) !important; } +.rb-wound-flag { + font-family: var(--rb-body); font-size: 10px; font-weight: 700; + color: var(--rb-danger); text-transform: uppercase; letter-spacing: .04em; +} + +/* ============================================================ + Characteristics — the roll target is the dominant number + ============================================================ */ + +.rb-characteristics { + display: grid; grid-template-columns: repeat(8, 1fr); gap: 1px; + padding: 0; background: var(--rb-hairline); + border-top: 2px solid var(--rb-yellow); + border-bottom: 2px solid var(--rb-yellow); +} +.rb-char { text-align: center; background: var(--rb-deep); padding: 9px 2px 7px; } +.rb-char-label { + display: block; width: 100%; + font-family: var(--rb-body); font-size: 11px; font-weight: 700; + letter-spacing: .12em; color: var(--rb-line); cursor: pointer; +} +.rb-char:hover .rb-char-label { color: var(--rb-white); } +/* The characteristic itself. It was set smaller than the skill ratings beneath + it, so the eight numbers the whole system derives from read as a caption. */ +.rb-char input { + width: 100%; text-align: center; + font-family: var(--rb-display); font-size: 26px; font-weight: 700; + line-height: 1.05; letter-spacing: -.01em; + background: transparent; border: none; padding: 2px 0 3px; color: var(--rb-white); +} +.rb-char input:focus { outline: 1px solid var(--rb-yellow); color: var(--rb-white); } +/* the ×5 chip — the actual roll target, so it keeps parity with the raw score */ +.rb-char-x5 { + display: block; min-height: 30px; line-height: 30px; + font-size: 22px; color: var(--rb-yellow); cursor: pointer; + border-top: 1px solid var(--rb-hairline); +} +.rb-char-x5::before { + content: "×5 "; font-size: 10px; font-weight: 700; + color: var(--rb-muted); letter-spacing: .04em; +} +.rb-char-x5:hover { background: var(--rb-yellow); color: var(--rb-void); } +.rb-char-x5:hover::before { color: var(--rb-void); } + +/* ============================================================ + Derived strip + ============================================================ */ + +.rb-derived { + display: flex; gap: 18px; align-items: center; flex-wrap: wrap; + padding: 7px 14px; border-bottom: 1px solid var(--rb-hairline); +} +.rb-dl { margin-right: 5px; } +.rb-dv { font-size: 13px; color: var(--rb-white); } +.rb-warn { color: var(--rb-danger); } +.rb-rolls { margin-left: auto; display: flex; gap: 6px; } +.rb-rolls button { + font-family: var(--rb-body); font-size: 11px; font-weight: 700; letter-spacing: .04em; + background: transparent; border: 1px solid var(--rb-yellow-dim); + color: var(--rb-yellow); padding: 4px 10px; border-radius: 0; + width: auto; line-height: 1.3; cursor: pointer; text-transform: uppercase; +} +.rb-rolls button:hover { background: var(--rb-yellow); border-color: var(--rb-yellow); color: var(--rb-void); } + +/* ============================================================ + Persistent weapon strip — the original sheet never hides weapons + ============================================================ */ + +.rb-weaponstrip { + display: flex; align-items: center; gap: 14px; flex-wrap: wrap; + padding: 6px 14px; border-bottom: 1px solid var(--rb-hairline); + background: var(--rb-panel); +} +.rb-strip-label { flex: 0 0 auto; } +.rb-strip-weapon { display: flex; align-items: baseline; gap: 7px; font-size: 12px; } +.rb-strip-weapon a { font-weight: 700; } +.rb-strip-dmg { font-weight: 700; font-variant-numeric: tabular-nums; color: var(--rb-white); } +.rb-strip-range, .rb-strip-ammo { font-size: 11px; color: var(--rb-muted); font-variant-numeric: tabular-nums; } +.rb-strip-empty { font-size: 12px; color: var(--rb-muted); font-style: italic; } + +/* ============================================================ + Tabs — solid active state, not a Material underline + ============================================================ */ + +.rb-tabs { + display: flex; padding: 0 14px; + background: var(--rb-void); + border-bottom: 1px solid var(--rb-line); +} +.rb-tabs .item { + font-family: var(--rb-body); font-weight: 700; font-size: 11px; letter-spacing: .06em; + text-transform: uppercase; color: var(--rb-muted); + padding: 9px 14px 8px; +} +.rb-tabs .item:hover { color: var(--rb-white); } +.rb-tabs .item.active { color: var(--rb-void); background: var(--rb-yellow); } + +.rb-body { flex: 1 1 auto; overflow-y: auto; padding: 12px 14px 16px; } + +/* ============================================================ + Section heads — the book's rule treatment + ============================================================ */ + +.rb-cat { margin-bottom: 14px; break-inside: avoid; } +.rb-cat-head { + display: flex; align-items: baseline; gap: 9px; margin: 0 0 4px; + font-family: var(--rb-display); font-size: 11px; letter-spacing: .08em; + text-transform: uppercase; color: var(--rb-white); + border: none; + border-top: 2px solid var(--rb-yellow); + border-bottom: 1px solid var(--rb-hairline); + padding: 5px 0 4px; +} +.rb-catbonus { + font-family: var(--rb-body); font-size: 11px; font-weight: 700; + font-variant-numeric: tabular-nums; + color: var(--rb-yellow); background: transparent; + border: 1px solid var(--rb-yellow-dim); padding: 0 5px; +} +.rb-add { margin-left: auto; font-size: 12px; } +.rb-add-wide { + display: block; text-align: center; padding: 6px; margin-top: 8px; + border: 1px dashed var(--rb-line); + font-family: var(--rb-body); font-size: 10px; font-weight: 700; + letter-spacing: .04em; text-transform: uppercase; +} + +/* ============================================================ + Skills — two columns, the original sheet's search pattern + ============================================================ */ + +.rb-body .tab[data-tab="skills"].active { + display: block; + column-count: 2; + column-gap: 22px; + column-fill: balance; +} +@media (max-width: 720px) { + .rb-body .tab[data-tab="skills"].active { column-count: 1; } +} +.rb-skills { list-style: none; margin: 0; padding: 0; } +.rb-skill { + display: grid; grid-template-columns: 13px 20px minmax(0,1fr) 44px 104px 15px 15px; + gap: 5px; align-items: center; min-height: 26px; padding: 0; + border-bottom: 1px solid var(--rb-hairline); +} +.rb-skill:hover { background: var(--rb-blue); } +.rb-tick { width: 12px; height: 12px; border: 1px solid var(--rb-muted); display: inline-block; } +.rb-tick.on { background: var(--rb-yellow); border-color: var(--rb-yellow); } +.rb-skill-name { font-size: 13px; color: var(--rb-white); } +.rb-skill-name:hover { color: var(--rb-yellow); } +/* passive datum, therefore white. The NAME carries the roll affordance. */ +.rb-skill-val { text-align: right; font-size: 13px; color: var(--rb-white); } +.rb-skill-bands { + font-family: var(--rb-mono); font-size: 9.5px; color: var(--rb-muted); + text-align: right; letter-spacing: -.03em; + white-space: nowrap; overflow: hidden; +} + +/* roll affordance: underline whatever rolls */ +.ringbrp-sheet a[data-action^="roll"] { + text-decoration: underline; + text-decoration-color: var(--rb-yellow-dim); + text-underline-offset: 2px; +} +.rb-char-x5, .rb-char-label { text-decoration: none !important; } + +/* ============================================================ + Tables & lists + ============================================================ */ + +.rb-table { width: 100%; border-collapse: collapse; font-size: 13px; margin-bottom: 12px; } +.rb-table th { text-align: left; border-bottom: 1px solid var(--rb-line); padding: 4px 5px; } +.rb-table td { padding: 5px; border-bottom: 1px solid var(--rb-hairline); } +.rb-table tbody tr:hover { background: var(--rb-blue); } +.rb-actions { text-align: right; white-space: nowrap; } +.rb-list { list-style: none; margin: 0 0 12px; padding: 0; font-size: 13px; } +.rb-list li { + display: flex; align-items: center; gap: 10px; min-height: 26px; + border-bottom: 1px solid var(--rb-hairline); +} +.rb-strong { font-weight: 700; color: var(--rb-white); } +.rb-dim { color: var(--rb-muted); font-size: 12px; } +.rb-empty { color: var(--rb-muted); font-style: italic; text-align: center; padding: 26px; } +.rb-equip { color: var(--rb-muted); } +.rb-equip.on { color: var(--rb-yellow); } +.rb-block { margin: 12px 0 4px; border-top: 1px solid var(--rb-hairline); padding-top: 7px; } +.rb-wounds li { border-left: 3px solid var(--rb-danger); padding-left: 8px; } + +/* ============================================================ + Expedition + ============================================================ */ + +.rb-tracks { display: flex; gap: 8px; padding: 12px 14px; border-bottom: 1px solid var(--rb-hairline); } +.rb-track { + flex: 1; display: flex; align-items: center; justify-content: center; gap: 9px; + background: var(--rb-void); border: 1px solid var(--rb-line); padding: 9px; +} +.rb-track-val { font-size: 26px; color: var(--rb-white); min-width: 32px; text-align: center; } +.rb-track-max { font-size: 12px; color: var(--rb-muted); font-weight: 700; } +.rb-tracks.rb-crisis .rb-track-val { color: var(--rb-danger); } +.rb-crisis-note { color: var(--rb-danger); text-align: center; font-size: 13px; margin: 7px 14px; } +.rb-bigbutton { + width: 100%; padding: 10px; margin-bottom: 12px; + font-family: var(--rb-body); font-weight: 700; font-size: 11px; letter-spacing: .08em; + text-transform: uppercase; background: transparent; + border: 2px solid var(--rb-yellow); color: var(--rb-yellow); border-radius: 0; cursor: pointer; +} +.rb-bigbutton:hover { background: var(--rb-yellow); color: var(--rb-void); } +.rb-table tr.rb-done td { opacity: .45; } +.rb-table tr.rb-current td { box-shadow: inset 3px 0 0 var(--rb-yellow); } +.rb-standing { font-size: 13px; color: var(--rb-white); } +.rb-objective { font-size: 12px; color: var(--rb-muted); font-style: italic; width: 100%; } + +/* ============================================================ + Item sheet + ============================================================ */ + +.rb-item-header { + display: flex; gap: 12px; align-items: center; padding: 12px 14px; + border-bottom: 3px solid var(--rb-yellow); background: transparent; +} +.rb-item-img { width: 52px; height: 52px; border: 1px solid var(--rb-line); } +.rb-grid2 { display: grid; grid-template-columns: 1fr 1fr; gap: 8px 12px; } +.rb-grid2 label { display: flex; flex-direction: column; gap: 3px; } +.rb-grid2 label input, .rb-grid2 label select { + font-weight: 400; letter-spacing: 0; text-transform: none; color: var(--rb-white); +} +.rb-check { flex-direction: row !important; align-items: center; gap: 7px; } +.rb-hint { font-size: 12px; color: var(--rb-muted); margin-top: 10px; } +.rb-fn { border: 1px solid var(--rb-line); padding: 8px 10px; margin-bottom: 10px; } +.rb-fn legend { + font-family: var(--rb-body); font-size: 10px; font-weight: 700; letter-spacing: .06em; + text-transform: uppercase; color: var(--rb-yellow); padding: 0 5px; +} + +/* ============================================================ + Chat — flat card + ============================================================ */ + +.ringbrp-chat { + background: var(--rb-void); + border: 1px solid var(--rb-line); + box-shadow: inset 0 0 0 1px var(--rb-yellow-dim); + padding: 10px 12px; + color: var(--rb-white); + font-family: var(--rb-body); +} +.rb-chat-title { + font-family: var(--rb-display); font-size: 11px; letter-spacing: .08em; + text-transform: uppercase; color: var(--rb-yellow); margin: 0 0 7px; + border: none; border-bottom: 1px solid var(--rb-hairline); padding-bottom: 6px; +} +.rb-chat-line { + font-size: 12px; color: var(--rb-muted); font-variant-numeric: tabular-nums; + display: flex; gap: 7px; align-items: baseline; flex-wrap: wrap; +} +.rb-chat-eff { color: var(--rb-white); font-weight: 700; } +.rb-chat-flavour { list-style: none; margin: 7px 0 0; padding: 0; font-size: 12px; color: var(--rb-muted); } +.rb-chat-flavour li:before { content: "› "; color: var(--rb-yellow); } +.rb-chat-result { display: flex; align-items: baseline; gap: 12px; margin-top: 8px; } +.rb-chat-roll { font-size: 32px; font-weight: 700; line-height: 1; font-variant-numeric: tabular-nums; } +.rb-chat-level { + font-family: var(--rb-display); font-size: 12px; letter-spacing: .12em; text-transform: uppercase; +} +.rb-chat-bands { + display: flex; gap: 14px; font-family: var(--rb-mono); font-size: 10px; + color: var(--rb-muted); margin-top: 7px; + border-top: 1px solid var(--rb-hairline); padding-top: 6px; +} +.rb-roll.rb-critical .rb-chat-level, .rb-roll.rb-critical .rb-chat-roll { color: var(--rb-yellow); } +.rb-roll.rb-special .rb-chat-level, .rb-roll.rb-special .rb-chat-roll { color: var(--rb-good); } +.rb-roll.rb-success .rb-chat-level { color: var(--rb-good); } +.rb-roll.rb-failure .rb-chat-level { color: var(--rb-muted); } +.rb-roll.rb-fumble .rb-chat-level, .rb-roll.rb-fumble .rb-chat-roll { color: var(--rb-danger); } +.rb-wound-title { + font-family: var(--rb-display); font-size: 11px; letter-spacing: .08em; text-transform: uppercase; + color: var(--rb-danger); margin: 8px 0 4px; border: none; +} +.rb-wound-text { font-size: 13px; margin: 0 0 5px; } +.rb-wound-loss { font-size: 13px; color: var(--rb-yellow); margin: 0 0 5px; font-weight: 700; } +.rb-wound-stamina { font-size: 11px; color: var(--rb-muted); font-style: italic; margin: 0; } +.rb-dialog .notes { font-size: 12px; color: var(--rb-muted); } + +/* ============================================================ + ITEM SHEET — the statline, after the 1984 weapons table + ============================================================ */ + +.rb-item-ident { display: flex; flex-direction: column; gap: 3px; min-width: 0; flex: 1 1 auto; } +.rb-item .rb-name { font-size: 18px; } + +.rb-statline { + display: grid; + grid-auto-flow: column; + grid-auto-columns: minmax(0, 1fr); + background: var(--rb-void); + border-top: 2px solid var(--rb-yellow); + border-bottom: 1px solid var(--rb-line); +} +.rb-stat { + min-width: 0; padding: 6px 10px; + border-right: 1px solid var(--rb-line); + display: flex; flex-direction: column; gap: 2px; +} +.rb-stat:last-child { border-right: none; } +.rb-stat-wide { grid-column: span 3; } +/* the actionable datum dominates — the mistake corrected on the character sheet */ +.rb-stat-primary { grid-column: span 2; } +.rb-stat-primary .rb-stat-value { font-size: 22px; line-height: 1.05; color: var(--rb-yellow); } +.rb-stat-label { + font-family: var(--rb-body); font-size: 10px; font-weight: 700; + letter-spacing: .04em; text-transform: uppercase; color: var(--rb-muted); +} +.rb-stat-value { + font-family: var(--rb-body); font-size: 15px; font-weight: 700; + font-variant-numeric: tabular-nums; color: var(--rb-white); +} +.rb-stat-big { font-size: 20px; line-height: 1.1; color: var(--rb-white); } +.rb-stat-of { font-size: 13px; color: var(--rb-muted); } +.rb-stat-mono { font-family: var(--rb-mono); font-size: 12px; font-weight: 400; color: var(--rb-muted); } +.rb-stat-question { font-size: 13px; font-weight: 400; font-style: italic; color: var(--rb-white); } +.rb-stat-roll { color: var(--rb-yellow); cursor: pointer; + text-decoration: underline; text-decoration-color: var(--rb-yellow-dim); text-underline-offset: 3px; } +.rb-stat-roll:hover { color: #fff; } +.rb-statline-locked { box-shadow: inset 3px 0 0 var(--rb-danger); } + +.rb-grid3 { display: grid; grid-template-columns: repeat(3, 1fr); gap: 8px 12px; } +.rb-grid3 label { + display: flex; flex-direction: column; gap: 3px; + font-family: var(--rb-body); font-size: 10px; font-weight: 700; + letter-spacing: .04em; text-transform: uppercase; color: var(--rb-muted); +} +.rb-grid3 label input, .rb-grid3 label select { + font-weight: 400; letter-spacing: 0; text-transform: none; color: var(--rb-white); +} +.rb-wide { width: 100%; } +.rb-hint-warn { color: var(--rb-danger); } +.rb-fnbody { margin-bottom: 12px; } +.rb-fn-effect { + font-size: 12px; color: var(--rb-muted); margin: 0 0 8px; + border-left: 2px solid var(--rb-line); padding-left: 9px; +} +.rb-locked-chip { + font-family: var(--rb-body); font-size: 10px; font-weight: 700; letter-spacing: .04em; + text-transform: uppercase; color: var(--rb-void); background: var(--rb-danger); padding: 1px 6px; +} +.rb-tags { display: flex; flex-wrap: wrap; gap: 5px; margin: 0; } +.rb-tag { + font-family: var(--rb-body); font-size: 11px; font-weight: 700; color: var(--rb-white); + border: 1px solid var(--rb-line); padding: 1px 6px; +} +.rb-taboo { color: var(--rb-danger); font-size: 12px; } +.rb-desire { color: var(--rb-good); font-size: 12px; } +.rb-offerform { color: var(--rb-white); font-size: 12px; } +.rb-standing-chip { + font-family: var(--rb-body); font-size: 11px; font-weight: 700; + color: var(--rb-void); background: var(--rb-yellow); padding: 0 6px; min-width: 26px; text-align: center; +} + +/* --- research steps --- */ +.rb-steps { list-style: none; margin: 0 0 12px; padding: 0; } +.rb-step { + display: grid; grid-template-columns: 26px 1fr auto; gap: 9px; align-items: baseline; + padding: 7px 0; border-bottom: 1px solid var(--rb-hairline); +} +.rb-step-n { + font-family: var(--rb-body); font-size: 12px; font-weight: 700; text-align: center; + color: var(--rb-muted); border: 1px solid var(--rb-line); line-height: 20px; +} +.rb-step-current { box-shadow: inset 3px 0 0 var(--rb-yellow); padding-left: 8px; } +.rb-step-current .rb-step-n { color: var(--rb-void); background: var(--rb-yellow); border-color: var(--rb-yellow); } +.rb-step-done { opacity: .5; } +.rb-step-body { display: flex; flex-direction: column; gap: 2px; } +.rb-step-type { + font-family: var(--rb-body); font-size: 11px; font-weight: 700; + letter-spacing: .04em; text-transform: uppercase; color: var(--rb-white); +} +.rb-step-state { font-size: 11px; color: var(--rb-muted); text-transform: uppercase; letter-spacing: .04em; } +.rb-steps-compact .rb-step { grid-template-columns: 22px auto 1fr; padding: 4px 0; } + +/* ============================================================ + EXPEDITION — tracks, next leg, standings + ============================================================ */ + +.rb-track { flex-direction: column; gap: 3px; padding: 9px 8px; } +.rb-track-row { display: flex; align-items: center; justify-content: center; gap: 10px; } +.rb-track-btn { + font-family: var(--rb-body); font-size: 16px; font-weight: 700; line-height: 1; + width: 22px; height: 22px; display: flex; align-items: center; justify-content: center; + border: 1px solid var(--rb-line); color: var(--rb-muted); +} +.rb-track-btn:hover { background: var(--rb-yellow); border-color: var(--rb-yellow); color: var(--rb-void); } +.rb-track-val { font-size: 30px; line-height: 1; } +.rb-track-max { font-size: 10px; font-weight: 700; letter-spacing: .04em; text-transform: uppercase; color: var(--rb-muted); } +.rb-track-pips { display: none; } +.rb-track-low .rb-track-val { color: var(--rb-white); } +.rb-track-low { border-color: var(--rb-yellow); } +.rb-track-crisis { border-color: var(--rb-danger); } +.rb-track-crisis .rb-track-val { color: var(--rb-danger); } +.rb-track-crisis .rb-pip.on { background: var(--rb-danger); } + +.rb-progress { font-size: 11px; color: var(--rb-muted); text-transform: uppercase; + letter-spacing: .04em; font-weight: 700; display: flex; gap: 5px; align-items: center; } +.rb-progress strong { color: var(--rb-white); font-size: 13px; } + +.rb-nextleg { + border: 1px solid var(--rb-yellow-dim); + background: var(--rb-void); + padding: 12px; margin-bottom: 16px; +} +.rb-nextleg-head { display: flex; align-items: baseline; gap: 10px; margin-bottom: 8px; } +.rb-nextleg-n { + font-family: var(--rb-body); font-size: 10px; font-weight: 700; + letter-spacing: .06em; text-transform: uppercase; + color: var(--rb-void); background: var(--rb-yellow); padding: 2px 7px; +} +.rb-nextleg-terrain { font-size: 14px; font-weight: 700; color: var(--rb-white); } +.rb-unexplored { + margin-left: auto; font-family: var(--rb-body); font-size: 10px; font-weight: 700; + letter-spacing: .04em; text-transform: uppercase; color: var(--rb-white); + border: 1px solid var(--rb-line); padding: 1px 6px; +} +.rb-nextleg-stats { + display: grid; grid-template-columns: repeat(auto-fit, minmax(120px, 1fr)); + gap: 6px 14px; margin-bottom: 10px; +} +.rb-rolltarget { + display: flex; align-items: baseline; gap: 9px; + border-top: 1px solid var(--rb-line); border-bottom: 1px solid var(--rb-line); + padding: 7px 0; margin-bottom: 10px; +} +.rb-rolltarget-label { + font-family: var(--rb-body); font-size: 10px; font-weight: 700; + letter-spacing: .04em; text-transform: uppercase; color: var(--rb-muted); +} +.rb-rolltarget-val { + font-family: var(--rb-body); font-size: 26px; font-weight: 700; + font-variant-numeric: tabular-nums; color: var(--rb-yellow); line-height: 1; +} +.rb-rolltarget-note { font-size: 11px; color: var(--rb-muted); margin-left: auto; } +.rb-bigbutton[disabled] { + border-color: var(--rb-line); color: var(--rb-muted); cursor: not-allowed; opacity: .55; +} +.rb-bigbutton[disabled]:hover { background: transparent; color: var(--rb-muted); } +.rb-nextleg-stats > span { display: flex; align-items: baseline; gap: 5px; } +.rb-nextleg-encounter { + font-size: 12px; color: var(--rb-danger); margin: 0 0 10px; + border-left: 3px solid var(--rb-danger); padding-left: 9px; +} +.rb-nextleg .rb-bigbutton { margin-bottom: 0; } + +.rb-itinerary td { font-size: 12px; } +.rb-leg-n { font-weight: 700; color: var(--rb-muted); width: 24px; } +.rb-leg-event { font-size: 11px; color: var(--rb-danger); } +.rb-yes { color: var(--rb-good); } +.rb-no { color: var(--rb-muted); } + +.rb-project, .rb-group { + border: 1px solid var(--rb-hairline); padding: 10px 12px; margin-bottom: 12px; +} +.rb-project-head { display: flex; align-items: baseline; gap: 10px; margin-bottom: 5px; } +.rb-project-status { + margin-left: auto; font-size: 10px; font-weight: 700; letter-spacing: .04em; + text-transform: uppercase; color: var(--rb-muted); +} +.rb-project-q { font-size: 12px; font-style: italic; color: var(--rb-muted); margin: 0 0 8px; } +.rb-disc-state { + width: 10px; height: 10px; border: 1px solid var(--rb-muted); display: inline-block; flex: 0 0 10px; +} +.rb-disc-state.on { background: var(--rb-good); border-color: var(--rb-good); } + +.rb-standings li { flex-wrap: wrap; } +.rb-standing-scale { display: flex; gap: 2px; margin-left: auto; } +.rb-scale-cell { + font-family: var(--rb-body); font-size: 10px; font-weight: 700; + width: 24px; text-align: center; line-height: 18px; + border: 1px solid var(--rb-hairline); color: var(--rb-muted); +} +.rb-scale-cell.on { color: var(--rb-void); } +.rb-scale-good.on { background: var(--rb-yellow); border-color: var(--rb-yellow); } +.rb-scale-bad.on { background: var(--rb-danger); border-color: var(--rb-danger); } + +/* standing as a signed value plus its word, not a seven-box rating widget */ +.rb-standing-value { + font-family: var(--rb-body); font-size: 15px; font-weight: 700; + font-variant-numeric: tabular-nums; color: var(--rb-white); + min-width: 30px; text-align: right; +} +.rb-standing-good { color: var(--rb-white); } +.rb-standing-bad { color: var(--rb-danger); } +.rb-standing-scale { display: none; } +.rb-stat-small { font-size: 12px; font-weight: 400; color: var(--rb-muted); } + +/* ---- hit locations ---- */ +.rb-species-select { width: 130px; font-size: 12px; margin-left: 4px; } +.rb-loctable td { font-size: 12px; } +.rb-mono-cell { font-family: var(--rb-mono); font-size: 11px; color: var(--rb-muted); text-align: right; } +/* was display:flex on a , which takes the cell out of table layout entirely */ +.rb-loc-cell { white-space: nowrap; } +.rb-loc-flag { + font-family: var(--rb-body); font-size: 9px; font-weight: 700; letter-spacing: .04em; + text-transform: uppercase; color: var(--rb-void); background: var(--rb-yellow); padding: 0 5px; +} +.rb-loc-flag-x { background: var(--rb-danger); } +.rb-loc-row-disabled td { color: var(--rb-yellow); } +.rb-loc-row-destroyed td { color: var(--rb-danger); } +.rb-location .rb-loc-name { font-size: 17px; font-weight: 700; color: var(--rb-white); } +.rb-location .rb-loc-dmg { font-size: 26px; font-weight: 700; font-variant-numeric: tabular-nums; color: var(--rb-yellow); } +.rb-location .rb-loc-of { font-family: var(--rb-mono); font-size: 12px; color: var(--rb-muted); } +.rb-loc-effect { font-size: 12px; margin: 8px 0 0; border-left: 3px solid var(--rb-yellow); padding-left: 9px; } +.rb-loc-disabled .rb-loc-effect { border-left-color: var(--rb-yellow); } +.rb-loc-destroyed .rb-loc-effect { border-left-color: var(--rb-danger); color: var(--rb-danger); } +.rb-sr-action { + display: block; font-family: var(--rb-body); font-size: 9px; font-weight: 700; + letter-spacing: .04em; text-transform: uppercase; color: var(--rb-yellow); +} +.rb-sr-action.rb-sr-default { color: var(--rb-muted); font-weight: 400; font-style: italic; } + + +/* --- House dice --------------------------------------------------------- + Two chips, tens and units, read the way the table reads a percentile roll. + Squared, flat, no gradient — the same discipline as every other panel. */ +.rb-chat-result { display: flex; align-items: center; gap: 12px; } +.rb-dice { display: flex; gap: 5px; } +.rb-die { + position: relative; + min-width: 34px; height: 34px; + display: flex; align-items: center; justify-content: center; + background: var(--rb-deep); + border: 1px solid var(--rb-line); + box-shadow: inset 0 0 0 1px var(--rb-void); + font-family: var(--rb-display); font-size: 14px; line-height: 1; + color: var(--rb-yellow); + font-variant-numeric: tabular-nums; +} +/* The tens die carries the arc: one yellow hairline, the shape everyone knows. */ +.rb-die-tens::after { + content: ""; position: absolute; left: 3px; right: 3px; bottom: 3px; height: 7px; + border-top: 1px solid var(--rb-hairline); + border-radius: 60% 60% 0 0 / 100% 100% 0 0; +} +.rb-die-units { background: var(--rb-blue); } +.rb-chat-outcome { display: flex; flex-direction: column; gap: 1px; } +.rb-axis { font-size: 10px; color: var(--rb-muted); margin: 3px 0 0; } + +/* --- Coherence ----------------------------------------------------------- + The one place red is allowed: it marks a character coming apart. */ +.rb-coherence .rb-coh-reason { font-size: 11px; color: var(--rb-muted); } +.rb-coh-track { display: flex; gap: 3px; margin: 7px 0; } +.rb-coh-track .rb-pip { + width: 12px; height: 12px; background: transparent; border: 1px solid var(--rb-line); +} +.rb-coh-track .rb-pip.on { background: var(--rb-yellow); border-color: var(--rb-yellow); } +.rb-coh-crossed { + margin: 6px 0 0; padding: 5px 7px; border-left: 2px solid var(--rb-yellow); + font-size: 11px; color: var(--rb-white); +} +.rb-coh-unmoored .rb-coh-track .rb-pip.on, +.rb-coh-displaced .rb-coh-track .rb-pip.on { background: #e2564d; border-color: #e2564d; } +.rb-cohgroup .rb-cohband { + display: block; font-family: var(--rb-body); font-size: 8px; font-weight: 700; + letter-spacing: .08em; text-transform: uppercase; color: var(--rb-muted); +} +.rb-cohbtn { color: var(--rb-yellow); padding: 0 5px; cursor: pointer; } +.rb-cohgroup.rb-coh-unmoored .rb-cohband, +.rb-cohgroup.rb-coh-displaced .rb-cohband { color: #e2564d; } +.rb-rank { + display: block; width: 100%; background: transparent; border: 0; + border-bottom: 1px solid var(--rb-hairline); padding: 1px 0 2px; + font-family: var(--rb-body); font-size: 10px; font-weight: 700; + letter-spacing: .12em; text-transform: uppercase; color: var(--rb-muted); +} +.rb-rank:focus { outline: none; border-bottom-color: var(--rb-yellow); color: var(--rb-white); } +.rb-caseline { display: flex; gap: 8px; margin: 0 0 8px; } +.rb-across, .rb-debrief, .rb-closecase { + font-family: var(--rb-body); font-size: 10px; font-weight: 700; + letter-spacing: .1em; text-transform: uppercase; padding: 4px 10px; + border: 1px solid var(--rb-line); color: var(--rb-muted); cursor: pointer; +} +.rb-across.on { background: var(--rb-yellow); border-color: var(--rb-yellow); color: var(--rb-void); } +.rb-closecase { margin-left: auto; border-color: var(--rb-yellow); color: var(--rb-yellow); } +.rb-disabled { opacity: .4; pointer-events: none; } + +/* --- Talents ------------------------------------------------------------- */ +.rb-talents { list-style: none; margin: 0 0 14px; padding: 0; } +.rb-talent { + display: grid; grid-template-columns: 40px 1fr auto 18px; + gap: 10px; align-items: start; + padding: 8px 6px; border-bottom: 1px solid var(--rb-hairline); +} +.rb-talent-icon { width: 38px; height: 38px; border: 1px solid var(--rb-line); } +.rb-talent-name { + display: block; font-family: var(--rb-body); font-size: 12px; font-weight: 700; + letter-spacing: .04em; text-transform: uppercase; color: var(--rb-yellow); +} +.rb-talent-summary { font-size: 12px; color: var(--rb-white); margin: 2px 0 3px; } +.rb-talent-rule { font-size: 11px; color: var(--rb-muted); line-height: 1.35; } +.rb-talent-cost { display: flex; flex-direction: column; align-items: flex-end; gap: 3px; } +.rb-talent-coh { + font-family: var(--rb-body); font-size: 11px; font-weight: 700; + color: #e2564d; border: 1px solid #e2564d; padding: 1px 5px; +} +.rb-talent-uses, .rb-talent-bonus { + font-family: var(--rb-body); font-size: 10px; font-weight: 700; + color: var(--rb-muted); font-variant-numeric: tabular-nums; +} + +/* Item icons in lists: small, square, never competing with the value beside them */ +.rb-li-icon { + width: 22px; height: 22px; vertical-align: middle; + margin-right: 7px; border: 1px solid var(--rb-hairline); flex: 0 0 auto; +} +.rb-td-icon { white-space: nowrap; } +.rb-skill .rb-li-icon { width: 18px; height: 18px; margin: 0; } + +/* --- Contact and device controls ---------------------------------------- */ +.rb-contact-bar, .rb-devicebar { + display: flex; flex-wrap: wrap; align-items: center; gap: 6px; + margin: 7px 0 2px; padding: 6px 0 0; border-top: 1px solid var(--rb-hairline); +} +.rb-cbtn { + font-family: var(--rb-body); font-size: 10px; font-weight: 700; + letter-spacing: .08em; text-transform: uppercase; + padding: 3px 9px; border: 1px solid var(--rb-line); + color: var(--rb-yellow); cursor: pointer; +} +.rb-cbtn:hover { background: var(--rb-yellow); color: var(--rb-void); border-color: var(--rb-yellow); } +.rb-cbtn-alt { color: var(--rb-muted); } +.rb-cstate { font-size: 11px; color: var(--rb-muted); margin-left: auto; } +.rb-devfn { display: flex; align-items: center; gap: 6px; } +.rb-devfn-why { font-size: 10px; color: var(--rb-muted); } +.rb-devfn-blocked .rb-devfn-why { color: #e2564d; } +.rb-talent-passive { + font-family: var(--rb-body); font-size: 9px; font-weight: 700; + letter-spacing: .08em; text-transform: uppercase; color: var(--rb-muted); +} + +/* --- Generator buttons --------------------------------------------------- */ +.rb-genbar { display: flex; gap: 6px; padding: 6px 4px; } +.rb-genbtn { + flex: 1; font-family: var(--rb-body); font-size: 10px; font-weight: 700; + letter-spacing: .08em; text-transform: uppercase; + padding: 5px 8px; background: var(--rb-deep); + border: 1px solid var(--rb-line); color: var(--rb-yellow); cursor: pointer; +} +.rb-genbtn:hover { background: var(--rb-yellow); color: var(--rb-void); border-color: var(--rb-yellow); } +.rb-gen .form-group { margin-bottom: 6px; } +.rb-skillbar { display: flex; align-items: center; gap: 10px; margin: 0 0 8px; } +.rb-skillbar .rb-dim { margin-right: auto; font-size: 11px; } +.rb-skill-empty { font-size: 11px; color: var(--rb-muted); padding: 3px 0 3px 20px; font-style: italic; } + +/* --- Reaction rail ------------------------------------------------------- + The paper sheet's seven boxes, on screen. The base is the only number the + player ever calculates; the rail is filled in once and clipped thereafter. */ +.rb-railbox { border: 1px solid var(--rb-hairline); padding: 10px; margin: 0 0 14px; } +.rb-rail-head { display: flex; align-items: baseline; gap: 10px; margin-bottom: 8px; } +.rb-rail-base { + font-family: var(--rb-display); font-size: 26px; color: var(--rb-yellow); + font-variant-numeric: tabular-nums; line-height: 1; +} +.rb-rail-cap { + font-family: var(--rb-body); font-size: 10px; font-weight: 700; + letter-spacing: .1em; text-transform: uppercase; color: var(--rb-white); +} +.rb-rail-head .rb-dim { margin-left: auto; font-size: 11px; } +.rb-rail { border-collapse: collapse; width: 100%; } +.rb-rail th, .rb-rail td { + border: 1px solid var(--rb-hairline); padding: 4px 0; text-align: center; + font-variant-numeric: tabular-nums; +} +.rb-rail th { font-family: var(--rb-body); font-size: 10px; color: var(--rb-muted); font-weight: 700; } +.rb-rail td { font-family: var(--rb-body); font-size: 15px; color: var(--rb-white); } +.rb-rail th.on, .rb-rail td.on { background: var(--rb-yellow); color: var(--rb-void); border-color: var(--rb-yellow); } + +/* --- Reaction chat card --- */ +.rb-reactlist { list-style: none; margin: 6px 0 0; padding: 0; } +.rb-reactrow { + display: grid; grid-template-columns: 34px 1fr auto auto; gap: 8px; + align-items: baseline; padding: 3px 0; border-bottom: 1px solid var(--rb-hairline); +} +.rb-react-total { + font-family: var(--rb-body); font-size: 16px; font-weight: 700; + color: var(--rb-yellow); text-align: right; font-variant-numeric: tabular-nums; +} +.rb-react-name { font-size: 12px; color: var(--rb-white); } +.rb-react-calc { font-family: var(--rb-mono); font-size: 10px; color: var(--rb-muted); } +.rb-react-flag, .rb-misfiled .rb-sr-action { + font-family: var(--rb-body); font-size: 9px; font-weight: 700; + letter-spacing: .08em; color: #e2564d; +} +.rb-checkrow { display: flex; gap: 16px; } +.rb-reactdlg .rb-rail { margin-bottom: 8px; } + +/* Damage offered by a successful attack card */ +.rb-chat-damage { display: flex; align-items: center; gap: 8px; margin: 6px 0 2px; flex-wrap: wrap; } +.rb-dmg-btn { + flex: 0 1 auto; padding: 3px 10px; cursor: pointer; + background: var(--rb-panel, #1b2a5e); color: var(--rb-accent, #ffd75e); + border: 1px solid var(--rb-accent, #ffd75e); border-radius: 3px; + font-family: inherit; font-size: 0.85rem; letter-spacing: 0.04em; text-transform: uppercase; +} +.rb-dmg-btn:hover:not(:disabled) { background: var(--rb-accent, #ffd75e); color: var(--rb-panel, #1b2a5e); } +.rb-dmg-btn:disabled { opacity: 0.45; cursor: default; } +.rb-dmg-note { font-size: 0.75rem; font-style: italic; color: var(--rb-muted, #9fb0d9); } + +/* Carried but not worn — the ARMOUR box says why it reads zero */ +.rb-armour.rb-unworn { position: relative; border-color: var(--rb-danger, #d9534f); } +.rb-armour.rb-unworn .rb-big { color: var(--rb-danger, #d9534f); } +.rb-unworn-flag { + display: block; font-size: 0.6rem; letter-spacing: 0.08em; text-transform: uppercase; + color: var(--rb-danger, #d9534f); margin-top: -2px; +} + +/* Reaction — the working, so the printed number is not a magic figure */ +.rb-react-work { margin: 4px 0 2px; font-size: 0.78rem; } +.rb-react-work summary { cursor: pointer; color: var(--rb-accent, #ffd75e); letter-spacing: 0.03em; } +.rb-worktable { width: 100%; border-collapse: collapse; margin-top: 4px; } +.rb-worktable th { text-align: left; font-weight: 600; padding: 1px 6px 1px 0; white-space: nowrap; } +.rb-worktable td { padding: 1px 0; color: var(--rb-muted, #9fb0d9); } +.rb-worknum { text-align: right; font-weight: 700; color: var(--rb-text, #e8eeff); width: 3em; } +.rb-worknote td { font-style: italic; font-size: 0.72rem; padding-bottom: 3px; } +.rb-workload .rb-worknum { color: var(--rb-danger, #d9534f); } +.rb-worktotal th, .rb-worktotal .rb-worknum { border-top: 1px solid var(--rb-line, #3b4a80); padding-top: 3px; } +.rb-worktotal .rb-worknum { color: var(--rb-accent, #ffd75e); font-size: 1rem; } + +/* ============================================================ + The figure, and the hit-location table beside it + ============================================================ */ +.rb-bodyblock { + display: grid; grid-template-columns: minmax(130px, 170px) minmax(0, 1fr); + gap: 18px; align-items: start; margin-bottom: 12px; +} +/* On a wide sheet the table used to stretch the full window, throwing the numbers + a foot away from the names they belong to. */ +.rb-bodyside { max-width: 560px; } +.rb-figure { display: flex; flex-direction: column; align-items: center; gap: 4px; } +.rb-body-svg { width: 100%; max-width: 180px; height: auto; display: block; } +.rb-figure-species { + font-size: 0.72rem; letter-spacing: 0.08em; text-transform: uppercase; + color: var(--rb-muted, #9fb0d9); +} +.rb-figure-roll { font-size: 0.72rem; } + +/* Damage state. Read at a glance: pale = whole, red = gone. */ +.rb-part { + stroke: var(--rb-line, #3b4a80); stroke-width: 1.5; + fill: var(--rb-panel-2, #24356b); transition: fill 120ms ease; +} +.rb-part-whole { fill: var(--rb-panel-2, #24356b); } +.rb-part-grazed { fill: #6d7a3f; } +.rb-part-hurt { fill: #a8722c; } +.rb-part-disabled { fill: #b2402f; stroke: #e08b7c; } +.rb-part-destroyed { fill: #5d1b16; stroke: #e08b7c; stroke-dasharray: 3 2; } +.rb-part:hover { stroke: var(--rb-accent, #ffd75e); stroke-width: 2.2; } + +/* The table was ragged because every column sized itself to its content. + Fixed layout plus tabular numerals lines the digits up. */ +/* Auto layout, not fixed. Fixed sized every column from the HEADER row, where the + font is smaller, so 3.6em came out at ~39px and "RANGED" overflowed into its + neighbour — which is why the header read MELEERANGETAKENARMO. Auto layout sizes + each column to its widest cell instead, and nowrap stops the ranges breaking + across two lines. The name column takes 99% so it absorbs all the slack and the + numbers stay gathered on the right. */ +.rb-loctable { table-layout: auto; width: auto; border-collapse: collapse; } +.rb-loctable th, .rb-loctable td { + padding: 3px 10px; vertical-align: middle; white-space: nowrap; +} +.rb-loctable th:first-child, .rb-loctable td:first-child { padding-left: 2px; } +.rb-loctable th:last-child, .rb-loctable td:last-child { padding-right: 2px; } +/* Content-sized, not stretched. Giving the name column the slack pushed the + numbers to the far edge of the panel, a hand's width from the row they describe. + The table is now only as wide as it needs to be and the gutter is a fixed + readable gap. */ +.rb-col-loc { width: auto; text-align: left; padding-right: 22px; min-width: 6.5em; } +.rb-col-num { width: auto; text-align: right; font-variant-numeric: tabular-nums; } +.rb-loctable thead th { + font-size: 0.62rem; letter-spacing: 0.06em; text-transform: uppercase; font-weight: 600; + color: var(--rb-muted, #9fb0d9); border-bottom: 1px solid var(--rb-line, #3b4a80); + padding-bottom: 4px; +} +.rb-loctable tbody td { font-size: 0.8rem; border-bottom: 1px solid var(--rb-hairline, #2a3763); } +.rb-loctable tbody tr:last-child td { border-bottom: none; } +.rb-loctable-compact th, .rb-loctable-compact td { padding: 2px 8px; } +.rb-loctable-compact tbody td { font-size: 0.76rem; } +.rb-locrow-disabled td { color: #e8a99e; } +.rb-locrow-destroyed td { color: #e08b7c; text-decoration: line-through; } +.rb-locrow.rb-hi td { background: rgba(255, 215, 94, 0.12); } + +.rb-sub-head { + margin: 8px 0 3px; font-size: 0.72rem; letter-spacing: 0.08em; + text-transform: uppercase; color: var(--rb-accent, #ffd75e); + border-bottom: 1px solid var(--rb-line, #3b4a80); padding-bottom: 2px; +} +.rb-wounds-compact li { font-size: 0.78rem; padding: 2px 0; } + +/* Wounded banner on the skills tab — you cannot miss what is modifying every roll */ +.rb-woundbar { + display: flex; flex-wrap: wrap; align-items: center; gap: 6px; + margin: 0 0 8px; padding: 5px 9px; border-radius: 3px; + background: rgba(178, 64, 47, 0.16); border: 1px solid var(--rb-danger, #b2402f); +} +.rb-woundbar-title { + font-size: 0.72rem; letter-spacing: 0.08em; text-transform: uppercase; + color: var(--rb-danger, #d9534f); font-weight: 700; +} +.rb-woundchip { + font-size: 0.72rem; padding: 1px 7px; border-radius: 9px; + background: rgba(0, 0, 0, 0.28); color: var(--rb-text, #e8eeff); +} +.rb-woundbar-pen { font-size: 0.72rem; color: var(--rb-muted, #9fb0d9); margin-left: auto; } + +@media (max-width: 620px) { .rb-bodyblock { grid-template-columns: 1fr; } } + +/* The figure's roll button was carrying a full sentence and wrapped to three + lines. Short label, sentence moved to the tooltip. */ +.rb-figure-roll { + display: inline-block; white-space: nowrap; text-align: center; + font-size: 0.7rem; letter-spacing: 0.04em; padding: 3px 10px; margin-top: 2px; +} +/* "No active wounds." was centred in a wide empty box, floating in the middle of + nothing. It belongs on the left, under its heading, like every other row. */ +.rb-bodyside .rb-empty { + text-align: left; margin: 2px 0 0; font-size: 0.76rem; font-style: italic; + color: var(--rb-muted, #9fb0d9); +} +.rb-bodyside .rb-sub-head { margin-top: 12px; } + +/* ============================================================ + Dialogs — the callout boxes, dressed as the sheet + Foundry's default is parchment and a serif face, which read as + a different application sitting on top of the character sheet. + ============================================================ */ +.ringbrp-window.window-app, +.ringbrp-window.application { + background: var(--rb-deep); + color: var(--rb-white); + font-family: var(--rb-body); + border: 1px solid var(--rb-line); + border-radius: 3px; + box-shadow: 0 8px 28px rgba(0, 0, 0, 0.55); +} +.ringbrp-window .window-header { + background: var(--rb-void); + border-bottom: 1px solid var(--rb-line); + color: var(--rb-yellow); + font-family: var(--rb-display); + font-size: 11px; + letter-spacing: 0.08em; + text-transform: uppercase; + padding: 7px 10px; +} +.ringbrp-window .window-header .window-title { font-family: var(--rb-display); font-size: 11px; } +.ringbrp-window .window-header a, +.ringbrp-window .window-header .header-button { color: var(--rb-muted); text-transform: none; } +.ringbrp-window .window-header a:hover { color: var(--rb-yellow); } +.ringbrp-window .window-content { + background: var(--rb-deep); + color: var(--rb-white); + font-family: var(--rb-body); + font-size: 13px; + padding: 12px 14px; +} + +/* form rows: label left, control right, note beneath in muted small caps */ +.ringbrp-window .form-group { display: block; margin-bottom: 12px; } +.ringbrp-window .form-group > label { + display: block; margin-bottom: 4px; + font-family: var(--rb-body); font-weight: 700; + font-size: 10px; letter-spacing: 0.09em; text-transform: uppercase; + color: var(--rb-muted); +} +.ringbrp-window .form-group > label.rb-check { + display: flex; align-items: center; gap: 8px; + font-size: 13px; letter-spacing: 0; text-transform: none; + font-weight: 400; color: var(--rb-white); margin-bottom: 0; +} +.ringbrp-window input[type="text"], +.ringbrp-window input[type="number"], +.ringbrp-window select, +.ringbrp-window textarea { + width: 100%; box-sizing: border-box; + background: var(--rb-panel); + color: var(--rb-white); + border: 1px solid var(--rb-line); + border-radius: 2px; + padding: 5px 8px; + font-family: var(--rb-body); font-size: 13px; + font-variant-numeric: tabular-nums; +} +.ringbrp-window select option { background: var(--rb-panel); color: var(--rb-white); } +.ringbrp-window input:focus, .ringbrp-window select:focus { + outline: none; border-color: var(--rb-yellow); + box-shadow: 0 0 0 2px var(--rb-yellow-dim); +} +.ringbrp-window input[type="checkbox"] { + width: 15px; height: 15px; accent-color: var(--rb-yellow); margin: 0; flex: 0 0 auto; +} +.ringbrp-window .notes, .ringbrp-window .hint, .ringbrp-window p.notes { + margin: 4px 0 0; font-size: 11px; line-height: 1.35; + color: var(--rb-muted); font-style: normal; +} + +/* buttons: yellow means the thing that commits */ +.ringbrp-window .dialog-buttons { gap: 8px; margin-top: 4px; } +.ringbrp-window .dialog-button, .ringbrp-window button { + background: var(--rb-panel); + color: var(--rb-white); + border: 1px solid var(--rb-line); + border-radius: 2px; + font-family: var(--rb-body); font-weight: 700; + font-size: 11px; letter-spacing: 0.08em; text-transform: uppercase; + padding: 7px 12px; line-height: 1; +} +.ringbrp-window .dialog-button:hover, .ringbrp-window button:hover { + background: var(--rb-blue); border-color: var(--rb-yellow); color: var(--rb-yellow); +} +.ringbrp-window .dialog-button.default, +.ringbrp-window .dialog-buttons button:first-child { + background: var(--rb-yellow); color: var(--rb-void); border-color: var(--rb-yellow); +} +.ringbrp-window .dialog-button.default:hover, +.ringbrp-window .dialog-buttons button:first-child:hover { + background: var(--rb-white); color: var(--rb-void); +} +.ringbrp-window a { color: var(--rb-yellow); } +.ringbrp-window hr { border: none; border-top: 1px solid var(--rb-hairline); margin: 10px 0; } + +/* Defending — the counter is the point: it shows what the next one costs */ +.rb-defbar { display: flex; align-items: center; gap: 8px; margin: 6px 0 10px; flex-wrap: wrap; } +.rb-defbar-label { + font-size: 0.68rem; letter-spacing: 0.09em; text-transform: uppercase; + color: var(--rb-muted); font-weight: 700; +} +.rb-defbar-count { font-size: 0.74rem; color: var(--rb-muted); margin-left: auto; } + +/* Weight sits with the rest of an item's numbers, tabular so a kit list scans */ +.rb-mass { font-variant-numeric: tabular-nums; } +.rb-table .rb-col-num { text-align: right; white-space: nowrap; font-variant-numeric: tabular-nums; } + +/* The Quiet Arts — read as documents, not as a spell list */ +.rb-powers > li { padding: 8px 0 10px; border-bottom: 1px solid var(--rb-hairline); } +.rb-powers > li:last-child { border-bottom: none; } +.rb-power-head { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; } +.rb-power-name { font-family: var(--rb-display); font-size: 12px; letter-spacing: 0.04em; } +.rb-power-val { font-weight: 700; font-variant-numeric: tabular-nums; color: var(--rb-yellow); } +.rb-power-cost { + font-size: 0.72rem; padding: 1px 7px; border-radius: 9px; + background: rgba(0,0,0,0.28); color: var(--rb-danger); +} +.rb-power-push { margin-left: auto; } +.rb-power-summary { margin: 5px 0 2px; font-style: italic; color: var(--rb-white); font-size: 12px; } +.rb-power-rule { margin: 0; color: var(--rb-muted); font-size: 12px; line-height: 1.4; } +.rb-power-origin { margin: 4px 0 0; font-size: 11px; color: var(--rb-yellow-dim); } +.rb-power-granted, .rb-misfiled .rb-power-granted { border-left: 3px solid var(--rb-yellow); padding-left: 8px; } + +/* The crossing panel — the one control the whole premise runs through */ +.rb-crossbox { + margin: 8px 0 12px; padding: 8px 10px; border-radius: 3px; + border: 1px solid var(--rb-line); background: var(--rb-panel); +} +.rb-crossbox-across { border-color: var(--rb-yellow); } +.rb-crossbox-head { display: flex; align-items: center; gap: 10px; } +.rb-crossbox-title { + font-family: var(--rb-display); font-size: 11px; letter-spacing: 0.08em; + text-transform: uppercase; color: var(--rb-yellow); +} +.rb-crossbox .rb-cross-all { margin-left: auto; } +.rb-crossbox .rb-hint { margin: 6px 0 8px; } +.rb-crosstable { width: 100%; } +.rb-crosstable .rb-actions { text-align: right; white-space: nowrap; } +.rb-crosstable .rb-actions .rb-cbtn { margin-left: 4px; } +.rb-artmark { color: var(--rb-yellow); font-size: 0.8em; } + +/* --- Marksmanship: say what the choice buys ------------------------------ */ +/* Both styles are shown, not just the chosen one — the point of the block is to + make the choice comparable at the moment it is made. */ +.rb-style-compare { + list-style: none; margin: 4px 0 0; padding: 0; + display: grid; gap: 2px; +} +.rb-style-compare li { + font-family: var(--rb-body); font-size: 9px; line-height: 1.4; + color: var(--rb-muted); padding: 2px 5px; + border-left: 2px solid var(--rb-hairline); +} +.rb-style-compare li.on { color: var(--rb-white); border-left-color: var(--rb-yellow); } +.rb-style-compare b { color: var(--rb-line); font-weight: 700; letter-spacing: .06em; } +.rb-style-compare li.on b { color: var(--rb-yellow); } + +/* --- Defence outcome ------------------------------------------------------ */ +.rb-defence { border-left: 3px solid var(--rb-line); padding-left: 8px; } +.rb-defence.rb-aside { border-left-color: var(--rb-yellow); } +.rb-defence.rb-through { border-left-color: var(--rb-red, #ff7a5c); } + +/* --- Role card in the creation dialog ------------------------------------ */ +.rb-rolecard { + margin: 6px 0 10px; padding: 8px 10px; + border-left: 2px solid var(--rb-yellow); background: var(--rb-deep); +} +.rb-rolecard-blurb { + margin: 0; font-family: var(--rb-body); font-size: 11px; line-height: 1.45; + color: var(--rb-white); +} +.rb-rolecard-facts { + display: grid; grid-template-columns: max-content 1fr; gap: 2px 10px; + margin: 7px 0 0; font-family: var(--rb-body); font-size: 10px; line-height: 1.4; +} +.rb-rolecard-facts dt { + color: var(--rb-muted); text-transform: uppercase; letter-spacing: .08em; + font-weight: 700; white-space: nowrap; +} +.rb-rolecard-facts dd { margin: 0; color: var(--rb-line); } + +/* --- Transport on the gear tab -------------------------------------------- */ +.rb-vehicles li { display: flex; gap: 10px; align-items: center; } +.rb-vehicles .rb-strong { flex: 1 1 auto; } + +/* --- Borrowed Authority --------------------------------------------------- */ +.rb-authorities li { + display: grid; gap: 3px; padding: 8px 6px; + border-bottom: 1px solid var(--rb-hairline); + border-left: 2px solid var(--rb-line); +} +.rb-authorities li.rb-auth-flagged { border-left-color: var(--rb-yellow); } +.rb-authorities li.rb-auth-dead { border-left-color: var(--rb-red, #ff7a5c); opacity: .55; } +.rb-auth-head { display: flex; gap: 8px; align-items: center; } +.rb-auth-head .rb-strong { flex: 0 0 auto; } +.rb-auth-trust { + margin-left: auto; font-family: var(--rb-body); font-size: 10px; font-weight: 700; + letter-spacing: .06em; color: var(--rb-yellow); +} +.rb-auth-tag { + font-family: var(--rb-body); font-size: 9px; font-weight: 700; letter-spacing: .1em; + text-transform: uppercase; padding: 1px 6px; border: 1px solid var(--rb-yellow); + color: var(--rb-yellow); +} +.rb-auth-body { display: flex; gap: 12px; flex-wrap: wrap; font-size: 10px; } +.rb-auth-flags, .rb-auth-merge-tag { + font-family: var(--rb-body); font-size: 10px; color: var(--rb-yellow); +} +.rb-auth-acts { display: flex; gap: 6px; margin-top: 2px; } +.rb-auth-merge { margin: 6px 0 0; font-style: italic; color: var(--rb-yellow); } +.rb-auth.rb-auth-yes { border-left: 3px solid var(--rb-yellow); padding-left: 8px; } +.rb-auth.rb-auth-no { border-left: 3px solid var(--rb-line); padding-left: 8px; } +.rb-dossier { border-left: 3px solid var(--rb-line); padding-left: 8px; } +.rb-dossier-miss { opacity: .7; } +.rb-authuse .rb-check { display: block; font-size: 11px; margin: 2px 0; } diff --git a/system.json b/system.json new file mode 100644 index 0000000..5972fa6 --- /dev/null +++ b/system.json @@ -0,0 +1,171 @@ +{ + "id": "ringbrp", + "title": "The Custodians", + "description": "A Basic Roleplaying game of agents who cross the crossing to contain what should not be there \u2014 and who come back a little less from here each time.", + "version": "0.6.1", + "compatibility": { + "minimum": 13, + "verified": "14.364" + }, + "authors": [ + { + "name": "Tim Evans (slaguru666)" + } + ], + "esmodules": [ + "ringbrp.mjs" + ], + "styles": [ + "styles/ringbrp.css" + ], + "languages": [ + { + "lang": "en", + "name": "English", + "path": "lang/en.json" + } + ], + "socket": false, + "initiative": "@characteristics.dex.value", + "grid": { + "distance": 3, + "units": "m" + }, + "primaryTokenAttribute": "hp", + "secondaryTokenAttribute": null, + "packs": [ + { + "name": "pregens", + "label": "The Custodians \u00b7 Agents", + "path": "packs/pregens", + "type": "Actor", + "system": "ringbrp", + "ownership": { + "PLAYER": "OBSERVER", + "ASSISTANT": "OWNER" + } + }, + { + "name": "npcs", + "label": "The Custodians \u00b7 Natives & Threats", + "path": "packs/npcs", + "type": "Actor", + "system": "ringbrp", + "ownership": { + "PLAYER": "NONE", + "ASSISTANT": "OWNER" + } + }, + { + "name": "skills", + "label": "The Custodians \u00b7 Skill List", + "path": "packs/skills", + "type": "Item", + "system": "ringbrp" + }, + { + "name": "talents", + "label": "The Custodians \u00b7 Talents", + "path": "packs/talents", + "type": "Item", + "system": "ringbrp", + "ownership": { + "PLAYER": "OBSERVER", + "ASSISTANT": "OWNER" + } + }, + { + "name": "powers", + "label": "The Custodians \u00b7 The Quiet Arts", + "path": "packs/powers", + "type": "Item", + "system": "ringbrp", + "ownership": { + "PLAYER": "OBSERVER", + "ASSISTANT": "OWNER" + } + }, + { + "name": "weapons", + "label": "The Custodians \u00b7 Weapons", + "path": "packs/weapons", + "type": "Item", + "system": "ringbrp" + }, + { + "name": "armour", + "label": "The Custodians \u00b7 Armour", + "path": "packs/armour", + "type": "Item", + "system": "ringbrp" + }, + { + "name": "gear", + "label": "The Custodians \u00b7 Gear & Devices", + "path": "packs/gear", + "type": "Item", + "system": "ringbrp" + }, + { + "name": "vehicles", + "label": "Transport", + "path": "packs/vehicles", + "type": "Item", + "system": "ringbrp" + }, + { + "name": "rules", + "label": "The Custodians \u00b7 Rules", + "path": "packs/rules", + "type": "JournalEntry", + "system": "ringbrp" + }, + { + "name": "slice", + "label": "The Custodians \u00b7 The Ilfracombe Crossing", + "path": "packs/slice", + "type": "JournalEntry", + "system": "ringbrp" + }, + { + "name": "tables", + "label": "The Custodians \u00b7 Site & Hazard Tables", + "path": "packs/tables", + "type": "RollTable", + "system": "ringbrp" + }, + { + "name": "expedition", + "label": "The Custodians \u00b7 Case File: ILFRACOMBE", + "path": "packs/expedition", + "type": "Actor", + "system": "ringbrp", + "ownership": { + "PLAYER": "OBSERVER", + "ASSISTANT": "OWNER" + } + } + ], + "packFolders": [ + { + "name": "RingBRP", + "sorting": "m", + "packs": [ + "pregens", + "npcs", + "skills", + "weapons", + "armour", + "gear", + "vehicles", + "slice", + "tables", + "expedition" + ] + } + ], + "url": "https://gitea.timevans.uk/slaguru666/RingBRP", + "manifest": "https://gitea.timevans.uk/slaguru666/RingBRP/raw/branch/main/system.json", + "download": "https://gitea.timevans.uk/slaguru666/RingBRP/archive/main.zip", + "license": "LICENSE.txt" +} diff --git a/template.json b/template.json new file mode 100644 index 0000000..4354cb5 --- /dev/null +++ b/template.json @@ -0,0 +1,440 @@ +{ + "Actor": { + "types": [ + "character", + "npc", + "expedition" + ], + "templates": { + "creature": { + "characteristics": { + "str": { + "value": 13 + }, + "siz": { + "value": 13 + }, + "con": { + "value": 13 + }, + "int": { + "value": 13 + }, + "pow": { + "value": 13 + }, + "dex": { + "value": 13 + }, + "cha": { + "value": 13 + }, + "edu": { + "value": 13 + } + }, + "marksmanship": { + "style": "reflex", + "chosenStyle": "reflex" + }, + "speciesProfile": "baseline", + "locationDamage": {}, + "hp": { + "value": 26, + "max": 26 + }, + "majorWound": { + "value": 9 + }, + "wounds": [], + "familiarities": { + "traditions": [ + "tradition_home" + ], + "environments": [] + }, + "combat": { + "defencesThisRound": 0, + "defenceTypeLock": "", + "stance": "normal", + "secondAttackUsed": false, + "aiming": { + "active": false, + "targetUuid": "", + "bonus": 0 + } + }, + "notes": "", + "coherence": { + "value": 10, + "max": 10, + "drift": [] + } + } + }, + "character": { + "templates": [ + "creature" + ], + "age": { + "chronological": 30, + "physiological": 30 + }, + "pursuits": [], + "biography": "", + "rank": "" + }, + "npc": { + "templates": [ + "creature" + ], + "role": "", + "morale": { + "downThreshold": 2, + "note": "" + }, + "naturalArmour": { + "value": 0 + }, + "tactics": "" + }, + "expedition": { + "supply": { + "value": 8, + "max": 10 + }, + "power": { + "value": 6, + "max": 10 + }, + "integrity": { + "value": 10, + "max": 10 + }, + "integrityMode": "abstract", + "assets": [], + "members": [], + "journey": { + "destinationId": "", + "destinationName": "", + "destinationKnowledge": "rumoured", + "currentLeg": 0, + "elapsedDays": 0, + "siteTableId": "", + "hazardTableId": "", + "legs": [] + }, + "standings": [], + "notes": "", + "across": false, + "crossingModifier": 0, + "salvageEra": "modern" + } + }, + "Item": { + "types": [ + "skill", + "weapon", + "armour", + "gear", + "device", + "discovery", + "project", + "material", + "group", + "talent", + "power", + "vehicle", + "authority" + ], + "templates": { + "physical": { + "massKg": 0, + "quantity": 1, + "equipped": false, + "carried": true, + "traditionIds": [ + "tradition_home" + ], + "description": "" + }, + "describable": { + "description": "" + } + }, + "skill": { + "templates": [ + "describable" + ], + "familyId": "", + "specialisationId": "", + "categoryId": "mental", + "base": { + "mode": "flat", + "value": 5, + "formula": "" + }, + "value": 5, + "trainingCap": null, + "transferGroup": "", + "experienceTicked": false, + "experienceEligible": true, + "canBeTaught": true + }, + "weapon": { + "templates": [ + "physical" + ], + "weaponClass": "melee", + "skillFamilyId": "", + "skillSpecialisationId": "", + "damage": { + "mode": "single", + "formula": "1d6", + "short": "", + "medium": "", + "long": "" + }, + "appliesDamageModifier": true, + "woundType": "blunt", + "monofilament": false, + "fireMode": "", + "ranges": { + "short": 0, + "medium": 0, + "long": 0 + }, + "ammo": { + "capacity": 0, + "loaded": 0, + "reserve": 0, + "reloadRounds": 1 + }, + "energyPerShot": 0, + "damageTags": [ + "kinetic" + ], + "coherenceCost": 0 + }, + "armour": { + "templates": [ + "physical" + ], + "points": 1, + "ignoredBy": [], + "degradation": { + "current": 0, + "max": 4 + }, + "coherenceCost": 0 + }, + "gear": { + "templates": [ + "physical" + ], + "powerDraw": 0, + "skillBonus": { + "skillFamilyId": "", + "value": 0 + }, + "researchFacility": "", + "coherenceCost": 0 + }, + "device": { + "templates": [ + "physical" + ], + "documentId": "", + "materialId": "standard", + "integrity": { + "value": 0, + "max": 3 + }, + "functions": { + "operate": { + "skillFamilyId": "", + "skillSpecialisationId": "", + "difficulty": "average", + "difficultyAfterDiscovery": "", + "analogueIn": [], + "requiredDiscoveryIds": [], + "familiarityAfterDiscovery": -20, + "requiresIntegrity": 0, + "powerCost": 0, + "powerCostAfterDiscovery": null, + "powerCostOnFailure": 0, + "powerCostOnFumble": 0, + "hoursPerAttempt": 0, + "effect": "", + "onFailure": "", + "onFumble": "" + }, + "diagnose": { + "skillFamilyId": "", + "skillSpecialisationId": "", + "difficulty": "average", + "difficultyAfterDiscovery": "", + "analogueIn": [], + "requiredDiscoveryIds": [], + "familiarityAfterDiscovery": -20, + "requiresIntegrity": 0, + "powerCost": 0, + "powerCostAfterDiscovery": null, + "powerCostOnFailure": 0, + "powerCostOnFumble": 0, + "hoursPerAttempt": 0, + "effect": "", + "onFailure": "", + "onFumble": "" + }, + "repair": { + "skillFamilyId": "", + "skillSpecialisationId": "", + "difficulty": "difficult", + "difficultyAfterDiscovery": "", + "analogueIn": [], + "requiredDiscoveryIds": [], + "familiarityAfterDiscovery": -20, + "requiresIntegrity": 0, + "powerCost": 0, + "powerCostAfterDiscovery": null, + "powerCostOnFailure": 0, + "powerCostOnFumble": 0, + "hoursPerAttempt": 4, + "effect": "", + "onFailure": "", + "onFumble": "" + } + }, + "coherenceCost": 0 + }, + "discovery": { + "templates": [ + "describable" + ], + "discoveryId": "", + "question": "", + "unlocks": [], + "publishable": true, + "published": false, + "earned": false + }, + "project": { + "templates": [ + "describable" + ], + "projectId": "", + "question": "", + "status": "proposed", + "maxResearchers": 3, + "currentStep": 0, + "yieldsDiscoveryId": "", + "steps": [], + "complications": [] + }, + "material": { + "templates": [ + "describable" + ], + "materialId": "standard", + "immuneTo": [], + "slowTo": [], + "blocksSensors": [], + "neutrinoAbsorption": 0 + }, + "group": { + "templates": [ + "describable" + ], + "groupId": "", + "speciesId": "", + "population": 0, + "npcTemplateId": "", + "taboos": [], + "desires": [], + "correctOfferForm": "", + "rewardsByStanding": { + "1": [], + "2": [], + "3": [] + } + }, + "talent": { + "templates": [ + "describable" + ], + "talentId": "", + "category": "agency", + "summary": "", + "rule": "", + "skillBonus": { + "skillFamilyId": "", + "value": 0 + }, + "grantsAction": "", + "coherenceCost": 0, + "usesPerCase": 0, + "usedThisCase": 0 + }, + "power": { + "templates": [ + "describable" + ], + "artId": "", + "summary": "", + "rule": "", + "value": 0, + "coherenceCost": 1, + "pushCost": 2, + "pushRule": "", + "origin": "", + "experienceTicked": false + }, + "vehicle": { + "templates": [ + "physical" + ], + "skillFamilyId": "pilot", + "skillSpecialisationId": "ground_car", + "crew": 1, + "seats": 4, + "cargoKg": 200, + "speedBand": "road", + "rangeKm": 400, + "hull": { + "value": 0, + "max": 20 + }, + "armourPoints": 0, + "terrain": [], + "powerDraw": 0, + "coherenceCost": 0, + "era": "modern", + "notes": "" + }, + "authority": { + "templates": [ + "describable" + ], + "holderName": "", + "holderRole": "", + "issuer": "", + "scope": "read", + "scopeNote": "", + "trust": { + "value": 1, + "max": 4 + }, + "expires": "", + "redFlags": [], + "dossier": [], + "establishesRemit": false, + "timesWidened": 0, + "mergeSteps": 0, + "burned": false, + "suspect": false, + "aiPosture": "", + "notes": "" + } + } +} diff --git a/templates/character-sheet.hbs b/templates/character-sheet.hbs new file mode 100644 index 0000000..011e94c --- /dev/null +++ b/templates/character-sheet.hbs @@ -0,0 +1,533 @@ +
+ +
+ +
+ + {{#if isCharacter}}{{/if}} +
+ +
    + {{#each styleHints as |s|}} +
  • {{s.label}} {{s.hint}}
  • + {{/each}} +
+
+ {{#if isCharacter}} +
+ + +
+ {{/if}} +
+ +
+
+ {{localize "RINGBRP.Sheet.HP"}} + + / + {{sys.hp.max}} +
+
+ {{localize "RINGBRP.Sheet.MajorWound"}} + {{sys.majorWound.value}} +
+
+ {{localize "RINGBRP.Sheet.Armour"}} + {{sys.armourPoints}} + {{#if armourUnworn.length}}{{localize "RINGBRP.Sheet.NotWorn"}}{{/if}} +
+ {{#if wounds.length}} +
+ {{localize "RINGBRP.Tab.Wounds"}} + {{wounds.length}} + {{localize "RINGBRP.Sheet.Wounded"}} +
+ {{/if}} +
+
+ +
+ {{#each sys.characteristics as |c key|}} + + {{/each}} +
+ +
+
+ {{localize "RINGBRP.Sheet.DamageMod"}}{{sys.damageModifier}} +
+
+ {{localize "RINGBRP.Sheet.MOV"}}{{sys.mov}} +
+
+ {{localize "RINGBRP.Coh.Label"}} + −{{sys.coherence.value}}+ + {{localize coherence.band.label}} +
+
+ {{localize "RINGBRP.Sheet.Carry"}} + {{sys.carriedKg}} / {{sys.freeCarryKg}} kg +
+
+ + + +
+
+ +
+ {{localize "RINGBRP.Sheet.Readied"}} + {{#each equippedWeapons as |w|}} + + {{w.name}} + {{#if (rbEq w.system.damage.mode "byBand")}}{{w.system.damage.short}}/{{w.system.damage.medium}}/{{w.system.damage.long}}{{else}}{{w.system.damage.formula}}{{/if}} + {{#if w.system.rangeSummary}}{{w.system.rangeSummary}}{{/if}} + {{#if w.system.ammo.capacity}}{{w.system.ammo.loaded}}/{{w.system.ammo.capacity}}{{/if}} + + {{else}} + {{localize "RINGBRP.Sheet.NothingReadied"}} + {{/each}} +
+ + + +
+ +
+ {{#if impaired.active}} +
+ + {{#if impaired.unconscious}}{{localize "RINGBRP.Sheet.Unconscious"}} + {{else if impaired.defenceOnly}}{{localize "RINGBRP.Sheet.OutOfFight"}} + {{else}}{{localize "RINGBRP.Sheet.Wounded"}}{{/if}} + {{#each impaired.chips as |c|}}{{c}}{{/each}} + {{impaired.summary}} +
+ {{/if}} +
+ {{skillCount}} {{localize "RINGBRP.Sheet.SkillsShown"}} + {{localize "RINGBRP.Sheet.FullList"}} +
+ {{#each RINGBRP.categories as |cat key|}} +
+

+ {{localize cat.label}} + +{{lookup ../sys.categoryBonus key}} + +

+
    + {{#unless (rbLen (lookup ../skillsByCategory key))}} +
  1. {{localize "RINGBRP.Sheet.NoSkillsHere"}}
  2. + {{/unless}} + {{#each (lookup ../skillsByCategory key) as |sk|}} +
  3. + + {{sk.name}} + {{sk.system.value}}% + {{rbBands sk.system.value}} + + +
  4. + {{/each}} +
+
+ {{/each}} +
+ +
+ +
+
+ + {{#each bodyMap.parts as |p|}} + {{#if (rbEq p.shape.t "ellipse")}} + + {{else if (rbEq p.shape.t "rect")}} + + {{else}} + + {{/if}} + {{/each}} + + {{speciesLabel}} + {{localize "RINGBRP.Loc.RollButton"}} +
+ +
+ + + + + + + + + + {{#each bodyMap.parts as |l|}} + + + + + + + + {{/each}} + +
{{localize "RINGBRP.Sheet.Locations"}}{{localize "RINGBRP.Loc.Melee"}}{{localize "RINGBRP.Loc.Ranged"}}{{localize "RINGBRP.Loc.Taken"}}{{localize "RINGBRP.Sheet.Armour"}}
{{localize l.label}}{{l.melee}}{{l.ranged}}{{l.taken}}/{{l.max}}{{l.armour}}
+ +

{{localize "RINGBRP.Tab.Wounds"}}{{#if wounds.length}} ({{wounds.length}}){{/if}}

+ {{#if wounds.length}} +
    + {{#each wounds as |w|}} +
  1. + {{localize (rbConcat "RINGBRP.Wound." w.key ".title")}} + {{localize (rbConcat "RINGBRP.Wound." w.key ".text")}} + +
  2. + {{/each}} +
+ {{else}} +

{{localize "RINGBRP.Sheet.NoWounds"}}

+ {{/if}} +
+
+ +
+ {{localize "RINGBRP.Sheet.Defend"}} + {{localize "RINGBRP.Defence.dodge"}} + {{localize "RINGBRP.Defence.parry"}} + + {{#if sys.combat.defencesThisRound}} + {{sys.combat.defencesThisRound}} {{localize "RINGBRP.Sheet.ThisRound"}} · {{localize "RINGBRP.Sheet.NextAt"}} {{defenceNext}}% + {{else}}{{localize "RINGBRP.Sheet.FirstIsFree"}}{{/if}} + +
+ +

{{localize "RINGBRP.React.Title"}}

+
+
+ {{reaction.printed}} + {{localize "RINGBRP.React.Printed"}} + {{localize "RINGBRP.React.Reflex"}} {{reaction.reflex}} · {{localize "RINGBRP.React.Awareness"}} {{reaction.aware}} — {{localize "RINGBRP.React.Uses"}} {{localize (rbConcat "RINGBRP.React.From." reaction.from)}} + {{#if reaction.load}} + {{localize "RINGBRP.React.Base"}} {{reaction.base}} − {{localize "RINGBRP.React.Load"}} {{reaction.load}} + {{/if}} +
+ + {{#each reaction.rail as |r|}}{{/each}} + {{#each reaction.rail as |r|}}{{/each}} +
{{rbSign r.mod}}
{{r.value}}
+
+ {{localize "RINGBRP.React.HowIsThis"}} + + + + + + + + + + + + {{#if reaction.work.load}} + + + + {{/if}} + + + +
{{localize "RINGBRP.React.Reflex"}}DEX {{reaction.work.dex}} ÷ {{reaction.work.dexDiv}}, rounded up{{reaction.work.reflex}}
{{localize "RINGBRP.React.Awareness"}}{{localize "RINGBRP.React.Readiness"}} {{reaction.work.readiness}} ÷ {{reaction.work.readyDiv}}, rounded up{{reaction.work.aware}}
+ {{#if reaction.work.fromTraining}} + {{localize "RINGBRP.React.ReadyFromSkill"}} ({{reaction.work.best}}) — {{localize "RINGBRP.React.ReadyIntFloor"}} {{reaction.work.intFloor}} + {{else}} + {{localize "RINGBRP.React.ReadyFromInt"}} ({{reaction.work.intFloor}}) — {{localize "RINGBRP.React.ReadyBestSkill"}} {{reaction.work.best}} + {{/if}} +
{{localize "RINGBRP.React.Lane"}}{{localize "RINGBRP.React.BetterOf"}} × {{reaction.work.mult}}{{reaction.work.base}}
{{localize "RINGBRP.React.Load"}}{{reaction.work.carried}} / {{reaction.work.free}} kg — {{reaction.work.overKg}} kg over−{{reaction.work.load}}
{{localize "RINGBRP.React.Printed"}}{{localize "RINGBRP.React.ThenAddDie"}} d{{reaction.work.die}}{{reaction.work.printed}}
+
+

{{localize "RINGBRP.React.RailHint"}}

+
+ +

{{localize "RINGBRP.Tab.Weapons"}} + +

+ + + + + + + + {{#each weapons as |w|}} + + + + + + + + + {{/each}} + +
{{localize "RINGBRP.Sheet.Weapon"}}{{localize "RINGBRP.Sheet.Damage"}}{{localize "RINGBRP.Sheet.Range"}}{{localize "RINGBRP.Sheet.Ammo"}}{{localize "RINGBRP.Sheet.Mass"}}
{{w.name}}{{#if (rbEq w.system.damage.mode "byBand")}}{{w.system.damage.short}}/{{w.system.damage.medium}}/{{w.system.damage.long}}{{else}}{{w.system.damage.formula}}{{/if}}{{w.system.rangeSummary}}{{#if w.system.ammo.capacity}}{{w.system.ammo.loaded}}/{{w.system.ammo.capacity}}{{/if}}{{w.system.massKg}} kg + + +
+ +

{{localize "RINGBRP.Tab.Armour"}} + +

+
    + {{#each armours as |a|}} +
  1. + + {{a.name}} + {{a.system.points}} {{localize "RINGBRP.Sheet.Points"}} + {{a.system.massKg}} kg + + +
  2. + {{/each}} +
+
+ +
+ {{#each talentGroups as |grp|}} + {{#if grp.items.length}} +

{{localize grp.label}}

+
    + {{#each grp.items as |t|}} +
  1. + +
    + {{t.name}} +
    {{t.system.summary}}
    +
    {{t.system.rule}}
    +
    +
    + {{#if t.active}} + {{localize "RINGBRP.Talent.Use"}} + {{else}} + {{localize "RINGBRP.Talent.Passive"}} + {{/if}} + {{#if t.system.coherenceCost}}−{{t.system.coherenceCost}}{{/if}} + {{#if t.system.usesPerCase}}{{t.system.usedThisCase}}/{{t.system.usesPerCase}}{{/if}} + {{#if t.system.skillBonus.value}}+{{t.system.skillBonus.value}}%{{/if}} +
    + +
  2. + {{/each}} +
+ {{/if}} + {{/each}} + {{#unless talents.length}}

{{localize "RINGBRP.Talent.None"}}

{{/unless}} +
+ +
+ {{#if powers.length}} +
    + {{#each powers as |p|}} +
  1. +
    + + {{p.name}} + {{p.system.value}}% + −{{p.system.coherenceCost}} + {{localize "RINGBRP.Power.Use"}} + {{localize "RINGBRP.Power.Push"}} −{{p.system.pushCost}} + + +
    +

    {{p.system.summary}}

    +

    {{p.system.rule}}

    + {{#if p.system.origin}} +

    {{localize "RINGBRP.Power.Origin"}}: {{p.system.origin}}

    + {{/if}} +
  2. + {{/each}} +
+ {{else}} +

{{localize "RINGBRP.Power.None"}}

+ {{/if}} +
+ +
+ {{#if authorities.length}} +

{{localize "RINGBRP.Tab.Access"}}

+
    + {{#each authorities as |a|}} +
  1. +
    + + {{#if a.system.holderName}}{{a.system.holderName}}{{else}}{{a.name}}{{/if}} + {{a.system.holderRole}} + + {{localize "RINGBRP.Authority.Trust"}} {{a.system.trust.value}}/{{a.system.trust.max}} + {{#if a.system.burned}}{{localize "RINGBRP.Authority.BurnedTag"}} + {{else if a.system.suspect}}{{localize "RINGBRP.Authority.SuspectTag"}}{{/if}} + + +
    +
    + {{a.scopeLabel}}{{#if a.system.scopeNote}} — {{a.system.scopeNote}}{{/if}} + {{#if a.system.expires}}{{localize "RINGBRP.Authority.Expires"}}: {{a.system.expires}}{{/if}} + {{#if a.flagCount}}{{a.flagCount}} {{localize "RINGBRP.Authority.RedFlags"}}{{/if}} + {{#if a.merged}}{{localize "RINGBRP.Authority.MergingTag"}}{{/if}} +
    + +
  2. + {{/each}} +
+ {{/if}} + + {{#if vehicles.length}} +

{{localize "RINGBRP.Tab.Transport"}}

+
    + {{#each vehicles as |v|}} +
  1. + {{v.name}} + {{v.speedLabel}} + {{localize "RINGBRP.Vehicle.Hull"}} {{v.system.hull.value}}/{{v.system.hull.max}} + {{localize "RINGBRP.Vehicle.Seats"}} {{v.system.seats}} + {{v.rating}}% + + +
  2. + {{/each}} +
+ {{/if}} + +

{{localize "RINGBRP.Tab.Gear"}} + + + + +

+
    + {{#each gear as |g|}} +
  1. + {{g.name}} + {{g.system.massKg}} kg + {{#if (rbEq g.type "device")}} + {{localize "RINGBRP.Sheet.Integrity"}} {{g.system.integrity.value}}/{{g.system.integrity.max}} + {{/if}} + + +
  2. + {{#if (rbEq g.type "device")}} +
  3. + {{#each g.functions as |f|}} + + {{localize f.label}} + {{f.why}} + + {{/each}} +
  4. + {{/if}} + {{/each}} +
+
+ +
+

{{speciesLabel}} + +

+ + + + + + + + + + {{#each sys.locations as |l|}} + + + + + + + + {{/each}} + +
{{localize "RINGBRP.Sheet.Locations"}}{{localize "RINGBRP.Loc.Melee"}}{{localize "RINGBRP.Loc.Ranged"}}{{localize "RINGBRP.Loc.Taken"}}{{localize "RINGBRP.Sheet.Armour"}}
{{localize l.label}} + {{#if l.destroyed}}{{localize "RINGBRP.Loc.Destroyed"}} + {{else if l.disabled}}{{localize "RINGBRP.Loc.Disabled"}}{{/if}}{{l.melee}}{{l.ranged}}{{l.taken}}/{{l.max}}{{l.armour}}
+

{{localize "RINGBRP.Sheet.LocationNote"}}

+
+ +
+ {{#if wounds.length}} +
    + {{#each wounds as |w|}} +
  1. + {{localize (rbConcat "RINGBRP.Wound." w.key ".title")}} + {{localize (rbConcat "RINGBRP.Wound." w.key ".text")}} + +
  2. + {{/each}} +
+ {{else}} +

{{localize "RINGBRP.Sheet.NoWounds"}}

+ {{/if}} +
+ +
+ + +

{{localize "RINGBRP.Sheet.SpeciesHint"}}

+ + + {{editor bioHTML target="system.biography" button=true editable=editable engine="prosemirror"}} + + {{editor notesHTML target="system.notes" button=true editable=editable engine="prosemirror"}} +
+ +
+
diff --git a/templates/chat-coherence.hbs b/templates/chat-coherence.hbs new file mode 100644 index 0000000..c69febf --- /dev/null +++ b/templates/chat-coherence.hbs @@ -0,0 +1,18 @@ +
+

{{localize "RINGBRP.Coh.Title"}}

+
+ {{actorName}} + → + −{{cost}} + {{reason}} +
+ {{#if note}}

{{note}}

{{/if}} +
+ {{#each pips as |on|}}{{/each}} +
+
+ {{after}} + {{localize band.label}} +
+ {{#if crossed}}

{{localize band.note}}

{{/if}} +
diff --git a/templates/chat-contact.hbs b/templates/chat-contact.hbs new file mode 100644 index 0000000..11dfc6c --- /dev/null +++ b/templates/chat-contact.hbs @@ -0,0 +1,17 @@ +
+

{{stage}} — {{group}}

+
+ {{actor}} + → + {{levelLabel}} + {{#if delta}}{{#if (rbGt delta 0)}}+{{/if}}{{delta}}{{/if}} +
+ {{#if learned.length}} + + {{/if}} +
+ {{localize "RINGBRP.Contact.Impression"}} {{impression}} + {{localize "RINGBRP.Contact.Standing"}} {{standing}} + {{#if pidgin}}{{localize "RINGBRP.Contact.Pidgin"}}{{/if}} +
+
diff --git a/templates/chat-location.hbs b/templates/chat-location.hbs new file mode 100644 index 0000000..b79eabc --- /dev/null +++ b/templates/chat-location.hbs @@ -0,0 +1,15 @@ +
+

{{localize "RINGBRP.Loc.Header"}}

+
+ 1D20 {{roll}} + ·{{speciesLabel}} + ·{{#if (rbEq mode "melee")}}{{localize "RINGBRP.Loc.Melee"}}{{else}}{{localize "RINGBRP.Loc.Ranged"}}{{/if}} +
+
+ {{locationLabel}} + {{damage}} + {{taken}} / {{max}} +
+ {{#if effect}}

{{effect}}

{{/if}} + {{#if majorWound}}

{{localize "RINGBRP.Wound.Header"}}

{{/if}} +
diff --git a/templates/chat-reaction.hbs b/templates/chat-reaction.hbs new file mode 100644 index 0000000..a017016 --- /dev/null +++ b/templates/chat-reaction.hbs @@ -0,0 +1,13 @@ +
+

{{localize "RINGBRP.React.Title"}}

+
    + {{#each lines as |l|}} +
  1. + {{l.total}} + {{l.name}} + {{l.printed}}{{#if l.mod}} {{rbSign l.mod}}{{/if}} + {{l.die}}{{#if l.load}} ({{localize "RINGBRP.React.Load"}} −{{l.load}}){{/if}} + {{#if l.misfiled}}{{localize "RINGBRP.React.MisfiledShort"}}{{/if}} +
  2. + {{/each}} +
+
diff --git a/templates/chat-roll.hbs b/templates/chat-roll.hbs new file mode 100644 index 0000000..03c850d --- /dev/null +++ b/templates/chat-roll.hbs @@ -0,0 +1,38 @@ +
+

{{label}}

+
+ {{rating}}% + {{#unless (rbEq difficulty "average")}}{{difficultyLabel}}{{/unless}} + {{#if situational}}{{rbSign situational}}%{{/if}} + → + {{bands.effective}}% +
+ {{#if flavourLines.length}} + + {{/if}} +
+
+ {{faces.tens}} + {{faces.units}} +
+
+ {{roll}} + {{levelLabel}} +
+
+ {{#if damage}} +
+ + {{#if damage.critical}}{{localize "RINGBRP.Chat.DamageCritical"}}{{/if}} + {{#if damage.special}}{{localize "RINGBRP.Chat.DamageSpecial"}}{{/if}} +
+ {{/if}} +
+ ≤{{bands.critical}} + ≤{{bands.special}} + ≤{{bands.effective}} + ≥{{bands.fumbleStart}} +
+
diff --git a/templates/chat-wound.hbs b/templates/chat-wound.hbs new file mode 100644 index 0000000..bef4f87 --- /dev/null +++ b/templates/chat-wound.hbs @@ -0,0 +1,14 @@ +
+

{{localize "RINGBRP.Wound.Header"}}

+
+ {{raw}} + {{#if shifted}}→{{result}} + {{localize "RINGBRP.Wound.Shifted"}}{{/if}} +
+

{{title}}{{#if variantLabel}} ({{variantLabel}}){{/if}}

+

{{text}}

+ {{#if losesCharacteristic}} +

{{localize "RINGBRP.Wound.Loses"}} {{lostAmount}} {{lostStat}}

+ {{/if}} +

{{localize "RINGBRP.Wound.StaminaNote"}}

+
diff --git a/templates/dialog-attack.hbs b/templates/dialog-attack.hbs new file mode 100644 index 0000000..9bb4b35 --- /dev/null +++ b/templates/dialog-attack.hbs @@ -0,0 +1,30 @@ +
+ {{#if bandOptions.length}} +
+ + +
+ {{/if}} +
+ + +
+
+ +
+
+ +
+
+ + +
+
diff --git a/templates/dialog-reaction.hbs b/templates/dialog-reaction.hbs new file mode 100644 index 0000000..a91340d --- /dev/null +++ b/templates/dialog-reaction.hbs @@ -0,0 +1,25 @@ +
+

{{localize "RINGBRP.React.DialogHint"}}

+
+
{{localize "RINGBRP.React.Printed"}} {{printed}} + ({{localize "RINGBRP.React.Lane"}} {{lane}}, {{localize (rbConcat "RINGBRP.React.From." from)}})
+ + {{#each rail as |r|}}{{/each}} + {{#each rail as |r|}}{{/each}} +
{{rbSign r.mod}}
{{r.value}}
+
+
+ + +
+
+ + +
+

{{localize "RINGBRP.React.Load"}}: −{{load}} ({{localize "RINGBRP.React.LoadInPrinted"}}) + {{#unless (rbEq coherence 10)}} · {{localize "RINGBRP.Coh.Label"}} {{coherence}}{{/unless}}

+
diff --git a/templates/dialog-roll.hbs b/templates/dialog-roll.hbs new file mode 100644 index 0000000..29eabd0 --- /dev/null +++ b/templates/dialog-roll.hbs @@ -0,0 +1,27 @@ +
+
+ + +

{{localize "RINGBRP.Dialog.DifficultyNote"}}

+
+
+ + +

{{localize "RINGBRP.Dialog.CircumstanceNote"}}

+
+
+ + +
+

{{localize "RINGBRP.Dialog.OrderNote"}}

+
diff --git a/templates/expedition-sheet.hbs b/templates/expedition-sheet.hbs new file mode 100644 index 0000000..5d6311d --- /dev/null +++ b/templates/expedition-sheet.hbs @@ -0,0 +1,255 @@ +
+ +
+ +
+ +
+ + +
+
+ + {{localize "RINGBRP.Expedition.Progress"}} + {{resolvedLegs}} / {{sys.journey.legs.length}} +
+
+
+ + {{!-- ========================================================== + The three tracks are the whole game. They get the space. + ========================================================== --}} +
+ {{#each tracks as |t|}} +
+ {{localize t.label}} +
+ − + {{t.value}} + + +
+ {{localize "RINGBRP.Expedition.Of"}} {{t.max}} +
+ {{#each t.pips as |on|}}{{/each}} +
+
+ {{/each}} +
+ {{#if sys.inCrisis}}

{{localize "RINGBRP.Expedition.Crisis"}}

{{/if}} + + + +
+
+ {{localize "RINGBRP.Cross.Panel"}} + {{#if crossing.across}} + {{localize "RINGBRP.Cross.BringHome"}} + {{else}} + {{localize "RINGBRP.Cross.GoAcross"}} + {{/if}} +
+ + {{#if crossing.across}} +

{{localize "RINGBRP.Cross.PanelHint"}}

+ + + + + + + + + + {{#each crossing.members as |m|}} + + + + + + + + {{/each}} + +
{{localize "RINGBRP.Cross.Agent"}}{{localize "RINGBRP.Sheet.Skill"}}{{localize "RINGBRP.Coh.Short"}}{{localize "RINGBRP.Cross.Aimed"}}
{{m.name}}{{#if m.arts}} ✦{{m.arts}}{{/if}}{{m.rating}}%{{m.coherence}}{{m.effective}}% + {{localize "RINGBRP.Cross.Return"}} + {{#if ../crossing.aimer}} + {{localize "RINGBRP.Cross.Aided"}} + {{/if}} +
+ {{else}} +

{{localize "RINGBRP.Cross.HomeHint"}}

+ {{/if}} +
+ + + +
+ +
+ + {{!-- What happens next, stated plainly. This was the sheet's biggest gap. --}} + {{#if nextLeg}} +
+
+ {{localize "RINGBRP.Expedition.Leg"}} {{rbAdd nextLeg.index 1}} + {{nextLeg.terrain}} + {{#unless nextLeg.explored}}{{localize "RINGBRP.Expedition.Unexplored"}}{{/unless}} +
+
+ {{localize "RINGBRP.Item.Difficulty"}}{{legDifficulty}} + {{localize "RINGBRP.Expedition.Mod"}}{{rbSign nextLeg.modifier}}% + {{localize "RINGBRP.Expedition.Days"}}{{nextLeg.days}} + {{localize "RINGBRP.Expedition.Cost"}}−{{nextLeg.costSupply}} {{localize "RINGBRP.Expedition.Supply"}}{{#if nextLeg.costPower}} −{{nextLeg.costPower}} {{localize "RINGBRP.Expedition.Power"}}{{/if}} + {{#if navigator}} + {{localize "RINGBRP.Expedition.Navigator"}}{{navigator.name}} {{navigator.rating}}% + {{/if}} +
+ {{#if nextLeg.forcedEncounterId}} +

{{nextLeg.forcedEncounterId}}

+ {{/if}} + {{#if navigator}} +
+ {{localize "RINGBRP.Expedition.RollTarget"}} + {{rollTarget}}% + {{navigator.name}} {{navigator.rating}}% · {{legDifficulty}} · {{rbSign nextLeg.modifier}}% +
+ {{/if}} + + {{#if sys.inCrisis}}

{{localize "RINGBRP.Expedition.CrisisBlocks"}}

{{/if}} + {{#unless navigator}}

{{localize "RINGBRP.Notify.NoNavigator"}}

{{/unless}} +
+ {{else}} +

{{localize "RINGBRP.Expedition.JourneyComplete"}}

+ {{/if}} + +

{{localize "RINGBRP.Expedition.Itinerary"}}

+ + + + + + + + {{#each sys.journey.legs as |leg i|}} + + + + + + + + + {{/each}} + +
#{{localize "RINGBRP.Expedition.Terrain"}}{{localize "RINGBRP.Expedition.Mod"}}{{localize "RINGBRP.Expedition.Days"}}{{localize "RINGBRP.Expedition.Explored"}}{{localize "RINGBRP.Expedition.Event"}}
{{rbAdd i 1}}{{leg.terrain}}{{rbSign leg.modifier}}%{{leg.days}}{{#if leg.explored}}✓{{else}}—{{/if}}{{#if leg.forcedEncounterId}}{{leg.forcedEncounterId}}{{/if}}
+

{{localize "RINGBRP.Expedition.TableNote"}}

+
+ +
+

{{localize "RINGBRP.Expedition.Projects"}}

+ {{#each projects as |p|}} +
+
+ {{p.name}} + {{p.system.status}} +
+

{{p.system.question}}

+
    + {{#each p.system.steps as |s i|}} +
  1. + {{rbAdd i 1}} + {{s.stepType}} + {{#if s.hours}}{{s.hours}} h{{else}}{{localize "RINGBRP.Expedition.InPlay"}}{{/if}} · {{s.difficulty}} + {{#if s.objective}}
    {{s.objective}}
    {{/if}} +
  2. + {{/each}} +
+
+ {{else}} +

{{localize "RINGBRP.Expedition.NoProjects"}}

+ {{/each}} + +

{{localize "RINGBRP.Expedition.Discoveries"}}

+
    + {{#each discoveries as |d|}} +
  1. + + {{d.name}} + {{d.system.question}} +
  2. + {{else}} +
  3. {{localize "RINGBRP.Expedition.NoDiscoveries"}}
  4. + {{/each}} +
+
+ +
+

{{localize "RINGBRP.Expedition.Standings"}}

+
    + {{#each standings as |s|}} +
  1. + {{s.name}} + {{s.display}} + {{s.word}} + {{localize "RINGBRP.Expedition.Encounters"}} {{s.encounters}} +
  2. + {{else}} +
  3. {{localize "RINGBRP.Expedition.NoContacts"}}
  4. + {{/each}} +
+ +

{{localize "RINGBRP.Expedition.Groups"}}

+ {{#each groups as |g|}} +
+
+ {{g.name}} + {{#if isGM}}{{g.population}}{{/if}} +
+ {{#each g.taboos as |t|}}

{{#if t.secret}}{{localize "RINGBRP.Item.GMOnly"}}{{/if}}{{localize "RINGBRP.Item.Taboo"}}: {{t.text}}

{{/each}} + {{#each g.desires as |d|}}

{{#if d.secret}}{{localize "RINGBRP.Item.GMOnly"}}{{/if}}{{localize "RINGBRP.Item.Desire"}}: {{d.text}}

{{/each}} + {{#if g.offerForm}}

{{localize "RINGBRP.Item.GMOnly"}}{{localize "RINGBRP.Item.OfferForm"}}: {{g.offerForm}}

{{/if}} + {{#if g.nothingKnown}}

{{localize "RINGBRP.Expedition.NothingLearned"}}

{{/if}} +
+ {{localize "RINGBRP.Contact.Read"}} + {{localize "RINGBRP.Contact.Signal"}} + {{localize "RINGBRP.Contact.Offer"}} + {{localize "RINGBRP.Contact.NewEncounter"}} + + {{localize "RINGBRP.Contact.Impression"}} {{g.impression}} · + {{localize "RINGBRP.Contact.Standing"}} {{g.standing}} + {{#if g.pidgin}} · {{localize "RINGBRP.Contact.Pidgin"}}{{/if}} + {{#if g.offerSpent}} · {{localize "RINGBRP.Contact.OfferSpentShort"}}{{/if}} + +
+
+ {{else}} +

{{localize "RINGBRP.Expedition.NoGroups"}}

+ {{/each}} +
+ +
+ {{editor notesHTML target="system.notes" button=true editable=editable engine="prosemirror"}} +
+
+
diff --git a/templates/item-sheet.hbs b/templates/item-sheet.hbs new file mode 100644 index 0000000..fc50741 --- /dev/null +++ b/templates/item-sheet.hbs @@ -0,0 +1,467 @@ +
+ +
+ +
+ + {{localize (rbConcat "TYPES.Item." item.type)}} +
+
+ + {{!-- ========================================================== + THE STATLINE — the 1984 weapons table, one row per item. + A player should know what this is without reading a form. + ========================================================== --}} + {{#if isWeapon}} +
+
{{localize "RINGBRP.Sheet.Damage"}} + {{damageSummary}}
+ {{#if attackRating}} +
{{localize "RINGBRP.Item.Attack"}} + {{attackRating}}%
+ {{/if}} +
{{localize "RINGBRP.Sheet.Range"}} + {{#if sys.rangeSummary}}{{sys.rangeSummary}}{{else}}—{{/if}}
+
{{localize "RINGBRP.Item.WoundType"}} + {{sys.woundType}}
+
{{localize "RINGBRP.Item.FireMode"}} + {{#if sys.fireMode}}{{sys.fireMode}}{{else}}—{{/if}}
+ {{#if sys.ammo.capacity}} +
{{localize "RINGBRP.Sheet.Ammo"}} + {{sys.ammo.loaded}}/{{sys.ammo.capacity}}
+ {{/if}} +
{{localize "RINGBRP.Item.MassShort"}} + {{sys.massKg}} kg
+
+ {{/if}} + + {{#if isSkill}} +
+
{{localize "RINGBRP.Item.Rating"}} + {{sys.value}}%
+
{{localize "RINGBRP.Item.PrintedBase"}} + {{sys.base.value}}%
+
{{localize "RINGBRP.Sheet.BandsHint"}} + {{rbBands sys.value}}
+
+ {{/if}} + + {{#if isArmour}} +
+
{{localize "RINGBRP.Item.Points"}} + {{sys.points}}
+
{{localize "RINGBRP.Item.MassShort"}} + {{sys.massKg}} kg
+
{{localize "RINGBRP.Item.Equipped"}} + {{#if sys.equipped}}{{localize "RINGBRP.Item.Worn"}}{{else}}—{{/if}}
+
{{localize "RINGBRP.Item.IgnoredBy"}} + {{#each sys.ignoredBy as |x|}}{{x}}{{#unless @last}}, {{/unless}}{{else}}—{{/each}}
+
+ {{/if}} + + {{#if isAuthority}} +
+
{{localize "RINGBRP.Authority.Trust"}} + {{sys.trust.value}}/{{sys.trust.max}}
+
{{localize "RINGBRP.Authority.Scope"}} + {{scopeLabel}}
+
{{localize "RINGBRP.Authority.Expires"}} + {{#if sys.expires}}{{sys.expires}}{{else}}—{{/if}}
+
{{localize "RINGBRP.Authority.ScopeNote"}} + {{#if sys.scopeNote}}{{sys.scopeNote}}{{else}}—{{/if}}
+
{{localize "RINGBRP.Authority.MergeHead"}} + {{mergeLabel}}
+
+ {{/if}} + + {{#if isVehicle}} +
+
{{localize "RINGBRP.Vehicle.Hull"}} + {{sys.hull.value}}/{{sys.hull.max}}
+
{{localize "RINGBRP.Item.Points"}} + {{sys.armourPoints}}
+
{{localize "RINGBRP.Vehicle.Crew"}} + {{sys.crew}}
+
{{localize "RINGBRP.Vehicle.Seats"}} + {{sys.seats}}
+
{{localize "RINGBRP.Vehicle.Cargo"}} + {{sys.cargoKg}} kg
+
{{localize "RINGBRP.Vehicle.Speed"}} + {{speedLabel}}
+
{{localize "RINGBRP.Vehicle.Range"}} + {{#if sys.rangeKm}}{{sys.rangeKm}} km{{else}}—{{/if}}
+
{{localize "RINGBRP.Vehicle.Operate"}} + {{operateLabel}}
+
{{localize "RINGBRP.Vehicle.Terrain"}} + {{#each sys.terrain as |t|}}{{t}}{{#unless @last}}, {{/unless}}{{else}}—{{/each}}
+
+ {{/if}} + + {{#if isDevice}} +
+
{{localize "RINGBRP.Sheet.Integrity"}} + {{sys.integrity.value}}/{{sys.integrity.max}}
+
{{localize "RINGBRP.Item.Status"}} + {{#if sys.functional}}{{localize "RINGBRP.Item.Functional"}}{{else}}{{localize "RINGBRP.Item.Inert"}}{{/if}}
+
{{localize "RINGBRP.Item.Tradition"}} + {{#each sys.traditionIds as |t|}}{{t}}{{#unless @last}}, {{/unless}}{{/each}}
+
{{localize "RINGBRP.Item.MassShort"}} + {{sys.massKg}} kg
+
+ {{/if}} + + {{#if isProject}} +
+
{{localize "RINGBRP.Item.Question"}} + {{sys.question}}
+
{{localize "RINGBRP.Expedition.Step"}} + {{rbAdd sys.currentStep 1}}/{{sys.steps.length}}
+
+ {{/if}} + + {{#if isGear}} +
+
{{localize "RINGBRP.Item.TotalMass"}} + {{totalMass}} kg
+
{{localize "RINGBRP.Item.Quantity"}} + {{sys.quantity}}
+
{{localize "RINGBRP.Item.Carried"}} + {{#if sys.carried}}{{localize "RINGBRP.Item.Yes"}}{{else}}—{{/if}}
+
{{localize "RINGBRP.Item.PowerDraw"}} + {{#if sys.powerDraw}}{{sys.powerDraw}}{{else}}—{{/if}}
+
{{localize "RINGBRP.Item.Facility"}} + {{#if sys.researchFacility}}{{sys.researchFacility}} ({{rbSign facilityMod}}%){{else}}—{{/if}}
+
+ {{/if}} + + {{#if isDiscovery}} +
+
{{localize "RINGBRP.Item.Question"}} + {{sys.question}}
+
{{localize "RINGBRP.Item.Status"}} + {{#if sys.published}}{{localize "RINGBRP.Item.Published"}}{{else}}{{localize "RINGBRP.Item.Unpublished"}}{{/if}}
+
{{localize "RINGBRP.Item.Unlocks"}} + {{sys.unlocks.length}}
+
+ {{/if}} + + {{#if isGroup}} +
+
{{localize "RINGBRP.Item.Population"}} + {{sys.population}}
+
{{localize "RINGBRP.Item.PrincipalTaboo"}} + {{principalTaboo}}
+
{{localize "RINGBRP.Item.PrincipalDesire"}} + {{principalDesire}}
+
+ {{/if}} + + {{#if isMaterial}} +
+
{{localize "RINGBRP.Item.ImmuneTo"}} + {{#each sys.immuneTo as |x|}}{{x}}{{#unless @last}}, {{/unless}}{{else}}—{{/each}}
+
{{localize "RINGBRP.Item.SlowTo"}} + {{#each sys.slowTo as |x|}}{{x}}{{#unless @last}}, {{/unless}}{{else}}—{{/each}}
+
{{localize "RINGBRP.Item.BlocksSensors"}} + {{#each sys.blocksSensors as |x|}}{{x}}{{#unless @last}}, {{/unless}}{{else}}—{{/each}}
+
{{localize "RINGBRP.Item.Neutrino"}} + {{sys.neutrinoAbsorption}}
+
+ {{/if}} + + + +
+
+ + {{#if isSkill}} +

{{localize "RINGBRP.Item.Identity"}}

+
+ + + + +
+ +

{{localize "RINGBRP.Item.Values"}}

+
+ + + + + +
+

{{localize "RINGBRP.Item.FloorNote"}}

+ {{/if}} + + {{#if isWeapon}} +

{{localize "RINGBRP.Item.Identity"}}

+
+ + + + +
+ +

{{localize "RINGBRP.Sheet.Damage"}}

+
+ + {{#if isByBand}} + + + + {{else}} + + {{/if}} + + +
+ {{#if sys.monofilament}}

{{localize "RINGBRP.Item.MonoNote"}}

{{/if}} + + {{#if isRangedWeapon}} +

{{localize "RINGBRP.Sheet.Range"}}

+
+ + + +
+

{{localize "RINGBRP.Item.RangeNote"}}

+ +

{{localize "RINGBRP.Sheet.Ammo"}}

+
+ + + + + +
+ {{/if}} + +

{{localize "RINGBRP.Item.Physical"}}

+
+ + + +
+ {{/if}} + + {{#if isArmour}} +

{{localize "RINGBRP.Item.Protection"}}

+
+ + + +
+

{{localize "RINGBRP.Item.ArmourNote"}}

+ {{/if}} + + {{#if isGear}} +

{{localize "RINGBRP.Item.Physical"}}

+
+ + + + + + + +
+ {{/if}} + + {{#if isAuthority}} +

{{localize "RINGBRP.Authority.Holder"}}

+
+ + + + + + + +
+ {{#if sys.dossier.length}} +

{{localize "RINGBRP.Authority.Dossier"}}

+
    + {{#each sys.dossier as |d|}} +
  1. {{d.text}} + {{d.familyId}} + {{d.by}} + {{d.level}}
  2. + {{/each}} +
+ {{/if}} + {{#if sys.redFlags.length}} +

{{localize "RINGBRP.Authority.RedFlags"}}

+
    + {{#each sys.redFlags as |f|}}
  • {{f.text}}
  • {{/each}} +
+ {{/if}} + {{/if}} + + {{#if isVehicle}} +

{{localize "RINGBRP.Tab.Transport"}}

+
+ + + + + + + + + +
+

{{localize "RINGBRP.Vehicle.Operate"}}

+
+ + +
+ {{/if}} + + {{#if isDevice}} +

{{localize "RINGBRP.Item.Identity"}}

+
+ + + + + +
+ + {{#each sys.functions as |fn key|}} +

{{key}} + {{#if fn.requiredDiscoveryIds.length}} + {{localize "RINGBRP.Item.Locked"}} + {{/if}} +

+
+ {{#if fn.effect}}

{{fn.effect}}

{{/if}} +
+ + + + + + + + +
+ {{#if fn.onFumble}}

{{fn.onFumble}}

{{/if}} +
+ {{/each}} + {{/if}} + + {{#if isDiscovery}} +

{{localize "RINGBRP.Item.Identity"}}

+ + +
+ + + +
+ {{#if sys.unlocks.length}} +

{{localize "RINGBRP.Item.Unlocks"}}

+
    + {{#each sys.unlocks as |u|}}
  1. {{u.documentId}}{{u.functionId}}
  2. {{/each}} +
+ {{/if}} + {{/if}} + + {{#if isProject}} +

{{localize "RINGBRP.Item.Steps"}}

+
    + {{#each sys.steps as |s i|}} +
  1. + {{rbAdd i 1}} +
    + {{s.stepType}} + {{s.skillFamilyId}}{{#if s.skillSpecialisationId}} ({{s.skillSpecialisationId}}){{/if}} · {{s.difficulty}}{{#if s.hours}} · {{s.hours}} h{{/if}}{{#if s.facility}} · {{s.facility}}{{/if}} + {{#if s.objective}}
    {{s.objective}}
    {{/if}} +
    + {{s.state}} +
  2. + {{/each}} +
+

{{localize "RINGBRP.Item.Values"}}

+
+ + + + +
+ {{/if}} + + {{#if isGroup}} +

{{localize "RINGBRP.Item.Identity"}}

+
+ + + +
+ + +

{{localize "RINGBRP.Item.Taboos"}}

+
    {{#each sys.taboos as |t|}}
  1. {{t.text}}{{#if t.penaltyIfViolated}}{{t.penaltyIfViolated}}%{{/if}}
  2. {{/each}}
+

{{localize "RINGBRP.Item.Desires"}}

+
    {{#each sys.desires as |d|}}
  1. {{d.text}}
  2. {{/each}}
+

{{localize "RINGBRP.Item.Rewards"}}

+
    + {{#each sys.rewardsByStanding as |rewards standing|}} +
  1. +{{standing}} + {{#each rewards as |r|}}{{r}}{{#unless @last}} · {{/unless}}{{/each}}
  2. + {{/each}} +
+ {{/if}} + + {{#if isMaterial}} +

{{localize "RINGBRP.Item.Identity"}}

+
+ + +
+

{{localize "RINGBRP.Item.ImmuneTo"}}

+

{{#each sys.immuneTo as |t|}}{{t}}{{else}}—{{/each}}

+

{{localize "RINGBRP.Item.SlowTo"}}

+

{{#each sys.slowTo as |t|}}{{t}}{{else}}—{{/each}}

+

{{localize "RINGBRP.Item.BlocksSensors"}}

+

{{#each sys.blocksSensors as |t|}}{{t}}{{else}}—{{/each}}

+ {{/if}} +
+ +
+ {{editor descriptionHTML target="system.description" button=true editable=editable engine="prosemirror"}} +
+
+
diff --git a/templates/npc-sheet.hbs b/templates/npc-sheet.hbs new file mode 100644 index 0000000..e9b3126 --- /dev/null +++ b/templates/npc-sheet.hbs @@ -0,0 +1,99 @@ +
+
+ +
+ + +
+ {{styleLabel}} +
+
+
+
{{localize "RINGBRP.Sheet.HP"}} + /{{sys.hp.max}}
+
{{localize "RINGBRP.Sheet.MajorWound"}}{{sys.majorWound.value}}
+
{{localize "RINGBRP.Sheet.Armour"}} +
+
+
+ +
+ {{#each sys.characteristics as |c key|}} + + {{/each}} +
+ +
+
{{localize "RINGBRP.Sheet.DamageMod"}}{{sys.damageModifier}}
+
{{localize "RINGBRP.Sheet.MOV"}}{{sys.mov}}
+
+ + + +
+
+ {{#each RINGBRP.categories as |cat key|}} + {{#if (lookup ../skillsByCategory key).length}} +
+

{{localize cat.label}}+{{lookup ../sys.categoryBonus key}} +

+
    + {{#each (lookup ../skillsByCategory key) as |sk|}} +
  1. + {{sk.name}} + {{sk.system.value}}% + + +
  2. + {{/each}} +
+
+ {{/if}} + {{/each}} + {{localize "RINGBRP.Sheet.AddSkill"}} +
+ +
+

{{localize "RINGBRP.Tab.Weapons"}} +

+ + + + {{#each weapons as |w|}} + + + + + + + + {{/each}} + +
{{localize "RINGBRP.Sheet.Weapon"}}{{localize "RINGBRP.Sheet.Damage"}}{{localize "RINGBRP.Sheet.Range"}}{{localize "RINGBRP.Sheet.Ammo"}}
{{w.name}}{{#if (rbEq w.system.damage.mode "byBand")}}{{w.system.damage.short}}/{{w.system.damage.medium}}/{{w.system.damage.long}}{{else}}{{w.system.damage.formula}}{{/if}}{{w.system.rangeSummary}}{{#if w.system.ammo.capacity}}{{w.system.ammo.loaded}}/{{w.system.ammo.capacity}}{{/if}}
+
    + {{#each armours as |a|}} +
  1. + + {{a.name}}{{a.system.points}} + +
  2. + {{/each}} +
+ {{localize "RINGBRP.Tab.Armour"}} +
+ +
+ + + + {{editor notesHTML target="system.notes" button=true editable=editable engine="prosemirror"}} +
+
+
diff --git a/tools/build-packs.mjs b/tools/build-packs.mjs new file mode 100644 index 0000000..17b0e14 --- /dev/null +++ b/tools/build-packs.mjs @@ -0,0 +1,617 @@ +/** + * Builds the RingBRP LevelDB compendium packs from tools/content.mjs. + * Run: node tools/build-packs.mjs + * + * IDs are derived deterministically from a key string, so rebuilding a pack + * keeps stable references and does not duplicate documents in existing worlds. + */ + +import { ClassicLevel } from "classic-level"; +import { createHash } from "node:crypto"; +import { ICON_MAP, SKILL_ICON } from "./icons.mjs"; +import { rm, mkdir } from "node:fs/promises"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { + SKILL_CATALOGUE, WEAPONS, ARMOURS, GEAR, VEHICLES, PREGENS, NPCS, DEVICES, TALENTS, + SITE_TABLE, HAZARD_TABLE, hitPoints, majorWound, damageModifier, styleFor +} from "./content.mjs"; + +const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); +const PACKS = path.join(ROOT, "packs"); + +const ALPHABET = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"; +function idFor(key) { + const h = createHash("sha256").update(`ringbrp:${key}`).digest(); + let out = ""; + for (let i = 0; i < 16; i++) out += ALPHABET[h[i] % ALPHABET.length]; + return out; +} + +const BASE = () => ({ folder: null, sort: 0, flags: {}, ownership: { default: 0 } }); +const IC = k => ICON_MAP[k] ? `systems/ringbrp/icons/${k}.svg` : null; +const ICON = { + skill: IC("skill_mental"), + weapon: IC("pistol_med"), + melee: IC("dagger"), + armour: IC("stab_vest"), + gear: IC("pack") ?? IC("medkit"), + device: IC("crossing_regulator"), + actor: IC("actor_pc"), + table: IC("table"), + journal: IC("journal"), + talent: IC("talent_generic"), + npc: IC("actor_npc"), + caseFile: IC("actor_case") +}; + +/* ---------------- item factories ---------------- */ + +function skillItem({ fam, spec, cat, base, name, extra = {} }) { + return { + _id: idFor(`skill:${fam}:${spec}`), + name, type: "skill", img: IC(SKILL_ICON[cat]) ?? ICON.skill, + system: { + description: "", + familyId: fam, specialisationId: spec, categoryId: cat, + base: { mode: "flat", value: base, formula: "" }, + value: base, + trainingCap: null, transferGroup: "", + experienceTicked: false, experienceEligible: true, canBeTaught: true, + ...extra + }, + effects: [], ...BASE() + }; +} + +function weaponItem(w) { + const isMelee = w.cls === "melee"; + return { + _id: idFor(`weapon:${w.key}`), + name: w.name, type: "weapon", img: IC(w.key) ?? (isMelee ? ICON.melee : ICON.weapon), + system: { + description: "", massKg: w.mass ?? 0, quantity: 1, equipped: false, carried: true, + traditionIds: [w.trad ?? "tradition_local"], + weaponClass: w.cls, + skillFamilyId: w.fam, skillSpecialisationId: w.spec, + damage: w.byBand + ? { mode: "byBand", formula: "", short: w.byBand.short, medium: w.byBand.medium, long: w.byBand.long } + : { mode: "single", formula: w.dmg, short: "", medium: "", long: "" }, + appliesDamageModifier: !!w.mod, + woundType: w.wound, + monofilament: !!w.mono, + fireMode: w.fire ?? "", + ranges: { short: w.ranges[0], medium: w.ranges[1], long: w.ranges[2] }, + ammo: { capacity: w.cap ?? 0, loaded: w.cap ?? 0, reserve: 0, reloadRounds: w.fire === "slow" ? 2 : 1 }, + energyPerShot: w.energy ?? 0, + damageTags: w.mono ? ["monofilament"] : (w.cls === "energy" ? ["energy"] : ["kinetic"]), + coherenceCost: w.coherence ?? 0 + }, + effects: [], ...BASE(), + flags: { ringbrp: { key: w.key, era: w.era ?? "modern" } } + }; +} + +function armourItem(a) { + return { + _id: idFor(`armour:${a.key}`), + name: a.name, type: "armour", img: IC(a.key) ?? ICON.armour, + system: { + coherenceCost: a.coherence ?? 0, + description: "", massKg: a.mass, quantity: 1, equipped: false, carried: true, + traditionIds: [a.trad ?? "tradition_local"], + points: a.points, ignoredBy: [], degradation: { current: 0, max: 4 } + }, + effects: [], ...BASE(), + flags: { ringbrp: { key: a.key, era: a.era ?? "modern" } } + }; +} + +function gearItem(g) { + return { + _id: idFor(`gear:${g.key}`), + name: g.name, type: "gear", img: IC(g.key) ?? ICON.gear, + system: { + description: g.desc ? `

${g.desc}

` : "", + massKg: g.mass, quantity: 1, equipped: false, carried: true, + traditionIds: [g.trad ?? "tradition_home"], + powerDraw: 0, skillBonus: { skillFamilyId: g.bonus?.fam ?? "", value: g.bonus?.value ?? 0 }, + researchFacility: g.facility ?? "", + coherenceCost: g.coherence ?? 0, + isAnchor: /anchor/i.test(g.name) + }, + effects: [], ...BASE(), + flags: { ringbrp: { key: g.key, era: g.era ?? "modern" } } + }; +} + +function vehicleItem(v) { + return { + _id: idFor(`vehicle:${v.key}`), + name: v.name, type: "vehicle", img: IC(v.key) ?? ICON.gear, + system: { + description: v.desc ? `

${v.desc}

` : "", + // A vehicle is carried by nobody: massKg is what it weighs, not what it costs + // an agent to have. `carried` false keeps it out of the encumbrance sum. + massKg: v.mass ?? 0, quantity: 1, equipped: false, carried: false, + traditionIds: [v.trad ?? "tradition_home"], + skillFamilyId: v.fam ?? "pilot", skillSpecialisationId: v.spec ?? "ground_car", + crew: v.crew ?? 1, seats: v.seats ?? 0, cargoKg: v.cargo ?? 0, + speedBand: v.speed ?? "road", rangeKm: v.range ?? 0, + hull: { value: v.hull ?? 0, max: v.hull ?? 0 }, + armourPoints: v.armour ?? 0, terrain: v.terrain ?? [], + powerDraw: 0, coherenceCost: v.coherence ?? 0, + era: v.era ?? "modern", notes: "" + }, + effects: [], ...BASE(), + flags: { ringbrp: { key: v.key, era: v.era ?? "modern" } } + }; +} + +function deviceItem(d) { + return { + _id: idFor(`device:${d.key}`), + name: d.name, type: "device", img: ICON.device, + system: { + description: d.desc ?? "", massKg: d.mass, quantity: 1, equipped: false, carried: true, + traditionIds: d.traditions, + documentId: d.documentId, materialId: "standard", + integrity: d.integrity, functions: d.functions + }, + effects: [], ...BASE() + }; +} + +/* ---------------- actor factory ---------------- */ + +const SKILL_LOOKUP = new Map( + SKILL_CATALOGUE.map(([fam, spec, cat, base, name, extra]) => [`${fam}:${spec}`, { fam, spec, cat, base, name, extra }]) +); +const WEAPON_LOOKUP = new Map(WEAPONS.map(w => [w.key, w])); +const ARMOUR_LOOKUP = new Map(ARMOURS.map(a => [a.key, a])); +const GEAR_LOOKUP = new Map(GEAR.map(g => [g.key, g])); +const DEVICE_LOOKUP = new Map(DEVICES.map(d => [d.key, d])); + +function buildActor(spec, type) { + const ch = spec.ch; + const hp = hitPoints(ch.con, ch.siz); + const mw = majorWound(hp); + const style = styleFor(spec.chosenStyle ?? "reflex"); + const actorId = idFor(`actor:${spec.key}`); + const stableKey = spec.key; + + const items = []; + const push = (doc, suffix) => { + const copy = foundryClone(doc); + copy._id = idFor(`owned:${spec.key}:${suffix}`); + items.push(copy); + }; + + for (const s of spec.skills ?? []) { + const meta = SKILL_LOOKUP.get(`${s.fam}:${s.spec}`); + if (!meta) throw new Error(`Unknown skill ${s.fam}:${s.spec} on ${spec.name}`); + const doc = skillItem({ ...meta, cat: meta.cat, base: meta.base, name: meta.name, extra: meta.extra ?? {} }); + doc.system.value = s.val; + push(doc, `skill:${s.fam}:${s.spec}`); + } + // Every skill, so the sheet reads like a BRP sheet rather than eleven entries + // scattered over seven categories. Trained ones were pushed above; the rest + // come in at printed base. + const trained = new Set((spec.skills ?? []).map(x => `${x.fam}:${x.spec}`)); + for (const [fam, sp, cat, base, name, extra] of SKILL_CATALOGUE) { + if (trained.has(`${fam}:${sp}`)) continue; + const doc = skillItem({ fam, spec: sp, cat, base, name, extra: extra ?? {} }); + doc.system.value = base; + push(doc, `skill:${fam}:${sp}`); + } + for (const key of spec.talents ?? []) { + const t = TALENTS.find(x => x.key === key); + if (!t) throw new Error(`Unknown talent ${key} on ${spec.name}`); + push(talentItem(t), `talent:${key}`); + } + // Everyone has hands. "Punch / kick" was in the catalogue and issued to nobody, + // so every agent had Brawl and no item to roll damage with — an unarmed attack + // was unrollable for every character in the game. + for (const key of [...new Set(["punch", ...(spec.weapons ?? [])])]) { + const w = WEAPON_LOOKUP.get(key); + if (!w) throw new Error(`Unknown weapon ${key} on ${spec.name}`); + const doc = weaponItem(w); + doc.system.equipped = true; + push(doc, `weapon:${key}`); + } + for (const key of spec.armour ?? []) { + const a = ARMOUR_LOOKUP.get(key); + if (!a) throw new Error(`Unknown armour ${key} on ${spec.name}`); + const doc = armourItem(a); + doc.system.equipped = true; + push(doc, `armour:${key}`); + } + for (const entry of spec.gear ?? []) { + const [key, qty] = Array.isArray(entry) ? entry : [entry, 1]; + const g = GEAR_LOOKUP.get(key); + if (!g) throw new Error(`Unknown gear ${key} on ${spec.name}`); + const doc = gearItem(g); + doc.system.quantity = qty; + push(doc, `gear:${key}`); + } + for (const key of spec.devices ?? []) { + const d = DEVICE_LOOKUP.get(key); + if (!d) throw new Error(`Unknown device ${key} on ${spec.name}`); + push(deviceItem(d), `device:${key}`); + } + + const system = { + characteristics: Object.fromEntries( + ["str", "siz", "con", "int", "pow", "dex", "cha", "edu"].map(k => [k, { value: ch[k] ?? 10 }]) + ), + marksmanship: { style, chosenStyle: spec.chosenStyle ?? style }, + hp: { value: hp, max: hp }, + majorWound: { value: mw }, + wounds: [], + familiarities: { + traditions: ["tradition_home"], + environments: spec.environments ?? [] + }, + combat: { defencesThisRound: 0, defenceTypeLock: "", stance: "normal", secondAttackUsed: false, aiming: { active: false, targetUuid: "", bonus: 0 } }, + // Written explicitly rather than left to the template default. Every packed + // actor shipped as "hominid", a name from the Ringworld setting that is not a + // species this game defines — it only ever worked because the sheet falls back + // to baseline, so most of the cast was drawn and hit-located as something the + // rules do not contain. + speciesProfile: spec.species ?? "baseline", + notes: "" + }; + + if (type === "character") { + system.age = spec.age ?? { chronological: 30, physiological: 30 }; + if (spec.rank) system.rank = spec.rank; + system.pursuits = []; + system.biography = spec.bio ?? ""; + } else { + system.role = spec.role ?? ""; + system.morale = { downThreshold: 2, note: "" }; + system.naturalArmour = { value: Number(spec.naturalArmour) || 0 }; + system.tactics = spec.tactics ?? ""; + } + + const actorDoc = { + _id: actorId, + name: spec.name, type, img: ICON.actor, + system, + items: items.map(i => i._id), // Foundry LevelDB packs store an ID LIST here + effects: [], + prototypeToken: { + name: spec.name, displayName: 20, actorLink: type === "character", + width: 1, height: 1, texture: { src: ICON.actor }, + sight: { enabled: true }, disposition: type === "character" ? 1 : -1, + bar1: { attribute: "hp" }, bar2: { attribute: null }, + flags: {} + }, + ...BASE(), flags: { ringbrp: { key: stableKey } } + }; + return { actorDoc, itemDocs: items }; +} + +function foundryClone(o) { return JSON.parse(JSON.stringify(o)); } + + +function talentItem(t) { + const iconKey = t.cat === "species" ? "talent_species" + : t.cat === "agency" ? "talent_agency" + : t.cat === "field" ? "talent_field" : "talent_generic"; + return { + _id: idFor(`talent:${t.key}`), + name: t.name, type: "talent", img: IC(iconKey) ?? ICON.talent, + system: { + description: `

${t.summary}

${t.rule}

`, + talentId: t.key, category: t.cat, summary: t.summary, rule: t.rule, + skillBonus: { skillFamilyId: t.bonus?.fam ?? "", value: t.bonus?.value ?? 0 }, + grantsAction: t.action ?? "", coherenceCost: t.coherence ?? 0, + usesPerCase: t.uses ?? 0, usedThisCase: 0, + speciesId: t.species ?? "" + }, + effects: [], ...BASE() + }; +} + +/* ---------------- table + journal factories ---------------- */ + +function tableDoc(spec, key) { + const id = idFor(`table:${key}`); + const results = spec.results.map((text, i) => ({ + _id: idFor(`table:${key}:${i}`), + type: "text", + name: `${i + 1}`, + description: `

${text}

`, + img: ICON.table, + weight: 1, + range: [i + 1, i + 1], + drawn: false, + flags: {} + })); + return { + doc: { + _id: id, name: spec.name, img: ICON.table, + description: "", formula: spec.formula, replacement: true, displayRoll: true, + results: results.map(r => r._id), ...BASE() + }, + results, id + }; +} + +function journalDoc(name, key, pages) { + const id = idFor(`journal:${key}`); + const pageDocs = pages.map((p, i) => ({ + _id: idFor(`journal:${key}:${i}`), + name: p.name, type: "text", + title: { show: true, level: 1 }, + text: { format: 1, content: p.content, markdown: "" }, + image: {}, video: { controls: true, volume: 0.5 }, src: null, + system: {}, sort: (i + 1) * 100, ownership: { default: -1 }, flags: {}, + category: null + })); + return { doc: { _id: id, name, pages: pageDocs.map(p => p._id), categories: [], ...BASE() }, pages: pageDocs, id }; +} + +/* ---------------- writer ---------------- */ + +async function writePack(packName, entries) { + const dir = path.join(PACKS, packName); + await rm(dir, { recursive: true, force: true }); + await mkdir(dir, { recursive: true }); + const db = new ClassicLevel(dir, { valueEncoding: "json" }); + await db.open(); + const batch = db.batch(); + for (const [key, value] of entries) batch.put(key, value); + await batch.write(); + await db.close(); + console.log(` ${packName.padEnd(10)} ${String(entries.length).padStart(4)} keys`); +} + +/* ---------------- main ---------------- */ + +// Refuse to build if a rule has been duplicated. The packs used to be built under +// one rule and played under another; this makes that impossible to ship. +{ + const { execFileSync } = await import("node:child_process"); + try { + execFileSync(process.execPath, [new URL("check-rules.mjs", import.meta.url).pathname], + { stdio: "inherit" }); + // And refuse if a posting is issued kit the catalogue cannot supply. + execFileSync(process.execPath, [new URL("check-kits.mjs", import.meta.url).pathname], + { stdio: "inherit" }); + // And refuse if the localisation file cannot be loaded, which un-localises + // every sheet in the system and gives no error the user would connect to it. + execFileSync(process.execPath, [new URL("check-lang.mjs", import.meta.url).pathname], + { stdio: "inherit" }); + } catch { console.error("build aborted: pre-build checks failed"); process.exit(1); } +} + +console.log("Custodians | building compendium packs"); + +// skills +{ + const entries = SKILL_CATALOGUE.map(([fam, spec, cat, base, name, extra]) => { + const doc = skillItem({ fam, spec, cat, base, name, extra: extra ?? {} }); + return [`!items!${doc._id}`, doc]; + }); + await writePack("skills", entries); +} + +// talents +await writePack("talents", TALENTS.map(t => { const d = talentItem(t); return [`!items!${d._id}`, d]; })); + +// weapons +await writePack("weapons", WEAPONS.map(w => { + const d = weaponItem(w); return [`!items!${d._id}`, d]; +})); + +// armour +await writePack("armour", ARMOURS.map(a => { + const d = armourItem(a); return [`!items!${d._id}`, d]; +})); + +// gear + devices +{ + const entries = []; + for (const g of GEAR) { const d = gearItem(g); entries.push([`!items!${d._id}`, d]); } + for (const dv of DEVICES) { const d = deviceItem(dv); entries.push([`!items!${d._id}`, d]); } + await writePack("gear", entries); +} + +// vehicles — their own pack, because a lorry is not a piece of kit +{ + const entries = VEHICLES.map(v => { const d = vehicleItem(v); return [`!items!${d._id}`, d]; }); + await writePack("vehicles", entries); +} + +// pregens +{ + const entries = []; + for (const p of PREGENS) { + const { actorDoc, itemDocs } = buildActor(p, "character"); + entries.push([`!actors!${actorDoc._id}`, actorDoc]); + for (const it of itemDocs) entries.push([`!actors.items!${actorDoc._id}.${it._id}`, it]); + } + await writePack("pregens", entries); +} + +// npcs +{ + const entries = []; + for (const n of NPCS) { + const { actorDoc, itemDocs } = buildActor(n, "npc"); + entries.push([`!actors!${actorDoc._id}`, actorDoc]); + for (const it of itemDocs) entries.push([`!actors.items!${actorDoc._id}.${it._id}`, it]); + } + await writePack("npcs", entries); +} + +// tables +{ + const entries = []; + for (const [spec, key] of [[SITE_TABLE, "sites"], [HAZARD_TABLE, "hazards"]]) { + const t = tableDoc(spec, key); + entries.push([`!tables!${t.id}`, t.doc]); + for (const r of t.results) entries.push([`!tables.results!${t.id}.${r._id}`, r]); + } + await writePack("tables", entries); +} + +// the slice journal +{ + const { SLICE_PAGES } = await import("./slice-text.mjs"); + const j = journalDoc("The Ilfracombe Crossing", "slice", SLICE_PAGES); + const entries = [[`!journal!${j.id}`, j.doc]]; + for (const p of j.pages) entries.push([`!journal.pages!${j.id}.${p._id}`, p]); + await writePack("slice", entries); +} + +// The Quiet Arts. Their own pack, because they are not gear and not talents. +{ + const { POWERS } = await import("./content.mjs"); + const { powerCostFor } = await import("../rules.mjs"); + const entries = POWERS.map(a => { + const id = idFor(`power:${a.key}`); + return [`!items!${id}`, { + _id: id, name: a.name, type: "power", img: IC(`power_${a.art}`) ?? ICON.talent, + system: { + description: `

${a.summary}

${a.rule}

Pushed. ${a.push}

`, + artId: a.art, summary: a.summary, rule: a.rule, pushRule: a.push, + value: 0, + coherenceCost: powerCostFor(a.art), + pushCost: powerCostFor(a.art, { pushed: true }), + origin: "", experienceTicked: false + }, + effects: [], ...BASE(), + flags: { ringbrp: { key: a.key } } + }]; + }); + await writePack("powers", entries); + console.log(` powers ${entries.length} Arts`); +} + +// The rules, generated from rules.mjs so the book cannot drift from the engine. +{ + const { RULES_PAGES } = await import("./rules-text.mjs"); + const j = journalDoc("The Custodians — Rules", "rules", RULES_PAGES); + const entries = [[`!journal!${j.id}`, j.doc]]; + for (const p of j.pages) entries.push([`!journal.pages!${j.id}.${p._id}`, p]); + await writePack("rules", entries); + console.log(` rules ${RULES_PAGES.length} pages`); +} + +// the configured expedition, with its project, discovery and contact group +{ + const expId = idFor("actor:ilfracombe_case"); + const projId = idFor("owned:expedition:project"); + const discId = idFor("owned:expedition:discovery"); + const grpId = idFor("owned:expedition:group_hanging"); + + const project = { + _id: projId, name: "Permeability field initiation", type: "project", img: ICON.skill, + system: { + description: "

The question the whole expedition turns on.

", + projectId: "regulator_operation_project", + question: "How is a threshold held open past its window?", + status: "active", maxResearchers: 3, currentStep: 0, + yieldsDiscoveryId: "regulator_operation", + steps: [ + { stepType: "field", objective: "Recover an intact regulator control plate from the abandoned vehicle.", + skillFamilyId: "engineering", skillSpecialisationId: "field_systems", + difficulty: "average", resolveInPlay: true, hours: null, hoursRemaining: null, + facility: null, state: "pending", isSetback: false }, + { stepType: "laboratory", objective: null, + skillFamilyId: "engineering", skillSpecialisationId: "field_systems", + difficulty: "difficult", resolveInPlay: false, hours: 200, hoursRemaining: 200, + facility: "portable", state: "pending", isSetback: false }, + { stepType: "armchair", objective: null, + skillFamilyId: "research", skillSpecialisationId: "", + difficulty: "average", resolveInPlay: false, hours: 160, hoursRemaining: 160, + facility: null, computerAccess: true, state: "pending", isSetback: false } + ], + complications: [] + }, effects: [], ...BASE() + }; + + const discovery = { + _id: discId, name: "Initiating a permeability field", type: "discovery", img: ICON.device, + system: { + description: "

Not yet earned. Completing the project creates it.

It does two things: it unlocks operate, and it drops repair from Difficult-with-no-analogue to Average at −20% familiarity. Research does not merely grant permission — it makes the work possible.

", + discoveryId: "regulator_operation", earned: false, + question: "How is a threshold held open past its window?", + unlocks: [{ documentId: "crossing_regulator", functionId: "operate" }], + publishable: true, published: false + }, effects: [], ...BASE() + }; + + const group = { + _id: grpId, name: "The Quiet Neighbours", type: "group", img: ICON.actor, + system: { + description: "

Roughly thirty of them. They have lived beside the crossing point for four generations and have rules about it that nobody has written down.

", + groupId: "quiet_neighbours", speciesId: "baseline", population: 30, + npcTemplateId: "quiet_neighbours_npc", + taboos: [{ id: "ground_speech", text: "Will not converse with anyone standing on the ground. Addressing them from ground level is the offence — embarrassment, not hostility.", penaltyIfViolated: -30 }], + desires: [{ id: "medicine", text: "Antibiotics. Their own ran out two generations ago and they have watched the same three illnesses take people ever since.", offerBonusStep: "easy" }], + correctOfferForm: "A sealed course of antibiotics, handed over by someone standing above ground level — a stair, a vehicle roof, a low wall.", + /* Structured so the engine can actually apply them. `text` is what the GM + reads out; `effect` is what the phase resolver acts on. */ + rewardsByStanding: { + "1": [ + { id: "r1_guide_in", text: "They walk you to the beacon by a path that avoids the road.", + effect: { type: "cancelModifier", phase: 2 } }, + { id: "r1_shelter", text: "You may shelter with them. The next phase costs nothing.", + effect: { type: "waiveCost", phase: 2 } } + ], + "2": [ + { id: "r2_guide", text: "Ambaro walks the cliff path with you and knows where it gives way.", + effect: { type: "skillBonus", skillFamilyId: "navigate", value: 20, phases: [2, 3] } }, + { id: "r2_cliff", text: "The cliff path is walked in daylight, on their timing.", + effect: { type: "cancelModifier", phase: 3 } } + ], + "3": [ + { id: "r3_supplies", text: "They give what they can spare, which is more than they can spare.", + effect: { type: "track", track: "supply", delta: 2 } }, + { id: "r3_truth", text: "They tell you what happened to the first team.", + effect: { type: "reveal", note: "The first team went down to the shore on the fourth night and did not come back up. Two of them were carrying something between them." } } + ] + } + }, effects: [], ...BASE() + }; + + const exp = { + _id: expId, name: "Case File: ILFRACOMBE", type: "expedition", img: ICON.actor, + system: { + integrityMode: "abstract", + supply: { value: 8, max: 10 }, power: { value: 6, max: 10 }, integrity: { value: 10, max: 10 }, + // The case file ships with its team. Bare ids, which is the shape the phase + // resolver reads; an empty members list made the case file unusable on import. + assets: [], members: PREGENS.map(g => g.key), // stable keys: raw ids do not survive import + journey: { + destinationId: "ilfracombe", destinationName: "Ilfracombe", + destinationKnowledge: "rumoured", currentLeg: 0, elapsedDays: 0, + siteTableId: "sites", hazardTableId: "hazards", + legs: [ + { index:0, terrain:"Silted river flats", mode:"foot", difficulty:"difficult", modifier:0, days:4, explored:true, resolved:false, forcedEncounterId:"", costSupply:1, costPower:0 }, + { index:1, terrain:"Disused railway cutting", mode:"foot", difficulty:"difficult", modifier:10, days:3, explored:true, resolved:false, forcedEncounterId:"The Quiet Neighbours — contact at the END of this phase", costSupply:1, costPower:0 }, + { index:2, terrain:"Overgrown allotments", mode:"foot", difficulty:"difficult", modifier:-10, days:5, explored:false, resolved:false, forcedEncounterId:"", costSupply:1, costPower:0 }, + { index:3, terrain:"Cliff path above the cove", mode:"foot", difficulty:"difficult", modifier:-20, days:6, explored:false, resolved:false, forcedEncounterId:"Cleaners at the midpoint", costSupply:1, costPower:0 } + ] + }, + standings: [{ groupId:"quiet_neighbours", value:0, impression:0, encounters:0, + knownTaboos:[], knownDesires:[], pidginEstablished:false, obligations:[] }], + notes: "

Critical-path invariant: the eye-storm never takes the brone, and Power can never be exhausted to a dead end — the rim-wall site is a Power-recovery installation.

" + }, + items: [projId, discId, grpId], effects: [], + prototypeToken: { name: "Case File: ILFRACOMBE", displayName: 20, actorLink: true, + width: 1, height: 1, texture: { src: ICON.actor }, sight: { enabled: false }, + disposition: 1, bar1: { attribute: "supply" }, bar2: { attribute: "power" }, flags: {} }, + ...BASE() + }; + + const entries = [[`!actors!${expId}`, exp]]; + for (const it of [project, discovery, group]) entries.push([`!actors.items!${expId}.${it._id}`, it]); + await writePack("expedition", entries); +} + +console.log("RingBRP | packs built"); diff --git a/tools/check-kits.mjs b/tools/check-kits.mjs new file mode 100644 index 0000000..8c58120 --- /dev/null +++ b/tools/check-kits.mjs @@ -0,0 +1,64 @@ +/** + * Every kit key in ROLES must name a real catalogue item. + * + * The generator used to match kit by fuzzy display name and drop what it could + * not find without saying so, which is how a Stores Officer came to be issued + * five things and receive one. Matching is now exact, so a typo is a silent + * omission instead — unless this runs. + * + * node tools/check-kits.mjs + */ +import { readFile } from "node:fs/promises"; +import { SKILL_CATALOGUE, TALENTS, GEAR, WEAPONS, ARMOURS, DEVICES } from "./content.mjs"; + +const WEAPON_BY_KEY = new Map(WEAPONS.map(w => [w.key, w])); + +const src = await readFile(new URL("../ringbrp.mjs", import.meta.url), "utf8"); +const ROLES = eval("(" + src.match(/export const ROLES = (\{[\s\S]*?\n\});/)[1] + ")"); + +const skills = new Set(SKILL_CATALOGUE.map(s => (s[1] ? `${s[0]}:${s[1]}` : s[0]))); +const talents = new Set(TALENTS.map(t => t.key)); +const kit = new Set([...GEAR, ...WEAPONS, ...ARMOURS, ...(DEVICES ?? [])].map(x => x.key)); +const stats = new Set(["str", "con", "siz", "int", "pow", "dex", "cha", "edu", "mas", "app"]); + +const problems = []; +const kitSets = new Map(); +const issued = new Map(); + +for (const [id, r] of Object.entries(ROLES)) { + const where = `${id} (${r.label})`; + if (!stats.has(r.stat)) problems.push(`${where}: stat "${r.stat}" is not a characteristic`); + for (const s of [...r.core, ...r.support]) if (!skills.has(s)) problems.push(`${where}: no skill "${s}"`); + for (const t of r.talents) if (!talents.has(t)) problems.push(`${where}: no talent "${t}"`); + for (const g of r.kit) { + if (!kit.has(g)) problems.push(`${where}: no catalogue item "${g}"`); + issued.set(g, (issued.get(g) ?? 0) + 1); + } + // A posting issued a weapon it has no skill for carries it at the 5% base. Ten + // of the twenty new roles did exactly that, and only a play-through found it — + // an Antiquities Officer drew a flintlock and rolled 5% with it. + const trained = new Set([...r.core, ...r.support]); + for (const k of r.kit) { + const w = WEAPON_BY_KEY.get(k); + if (!w?.fam || k === "punch") continue; + const need = w.spec ? `${w.fam}:${w.spec}` : w.fam; + if (!trained.has(need)) problems.push(`${where}: issued ${w.name}, trains no ${need}`); + } + const sig = JSON.stringify([...r.kit].sort()); + if (kitSets.has(sig)) problems.push(`${where}: identical loadout to ${kitSets.get(sig)}`); + kitSets.set(sig, id); +} + +// Every posting should be issued at least one thing no other posting gets. This +// is a design rule, not a correctness one, so it warns rather than fails. +const noSignature = Object.entries(ROLES) + .filter(([, r]) => !r.kit.some(g => issued.get(g) === 1)) + .map(([id]) => id); + +if (problems.length) { + console.error("check-kits: FAILED\n " + problems.join("\n ")); + process.exit(1); +} +console.log(`check-kits: OK — ${Object.keys(ROLES).length} roles, ` + + `${kit.size} catalogue items, every kit key resolves, every posting can use what it carries, every loadout distinct`); +if (noSignature.length) console.warn(` note: no item unique to ${noSignature.join(", ")}`); diff --git a/tools/check-lang.mjs b/tools/check-lang.mjs new file mode 100644 index 0000000..9903019 --- /dev/null +++ b/tools/check-lang.mjs @@ -0,0 +1,44 @@ +/** + * A localisation key may be a LEAF or a BRANCH, never both. + * + * Foundry expands dotted keys into a nested object, so shipping both + * "RINGBRP.Authority.Merge" (a string) and "RINGBRP.Authority.Merge.0" makes it try + * to assign a property to a string. That throws inside the loader, and the loader + * abandons the WHOLE FILE — one bad pair silently un-localises the entire system and + * every sheet starts printing raw keys. It cost an hour to find once. + * + * node tools/check-lang.mjs + */ +import { readFile, readdir } from "node:fs/promises"; + +const dir = new URL("../lang/", import.meta.url); +let failures = 0; + +for (const file of (await readdir(dir)).filter(f => f.endsWith(".json"))) { + const raw = await readFile(new URL(file, dir), "utf8"); + let json; + try { json = JSON.parse(raw); } + catch (e) { console.error(`${file}: not valid JSON — ${e.message}`); failures++; continue; } + + const keys = Object.keys(json); + const set = new Set(keys); + for (const k of keys) { + const parts = k.split("."); + for (let i = 1; i < parts.length; i++) { + const prefix = parts.slice(0, i).join("."); + if (set.has(prefix)) { + console.error(`${file}: "${prefix}" is a value AND a prefix of "${k}" — Foundry cannot load this file`); + failures++; + } + } + } + // duplicate keys survive JSON.parse by silently overwriting; catch them in the text + const seen = new Set(), dupes = new Set(); + for (const m of raw.matchAll(/^\s*"([^"]+)"\s*:/gm)) { + if (seen.has(m[1])) dupes.add(m[1]); else seen.add(m[1]); + } + for (const d of dupes) { console.error(`${file}: duplicate key "${d}"`); failures++; } + if (!failures) console.log(`check-lang: OK — ${file}, ${keys.length} keys, no leaf/branch collisions`); +} + +if (failures) { console.error(`\ncheck-lang: ${failures} failure(s)`); process.exit(1); } diff --git a/tools/check-rules.mjs b/tools/check-rules.mjs new file mode 100644 index 0000000..7b9a93a --- /dev/null +++ b/tools/check-rules.mjs @@ -0,0 +1,273 @@ +/** + * Guard against the single most persistent defect in this project: a rule + * implemented in two places and changed in one. Five separate bugs came from it, + * the worst being packs built with double hit points because the builder kept its + * own drifted copy of the formula. + * + * Three properties, each closing a different escape route: + * 1. NO file in the system except rules.mjs may define a canonical rule. Scanned + * by discovery, not by a hardcoded file list, so a new file cannot slip past. + * 2. Every re-export or alias of a canonical name must BE that function, not a + * same-named copy. Aliases are discovered from the source, not hand-listed. + * 3. Every function rules.mjs exports must be exercised by at least one check, so + * adding a rule cannot silently arrive with zero coverage. + * + * Run before building packs; build-packs.mjs refuses to build if this fails. + */ +import { readFile, readdir } from "node:fs/promises"; +import * as RULES from "../rules.mjs"; +import * as CONTENT from "./content.mjs"; + +const canonical = Object.keys(RULES).filter(k => typeof RULES[k] === "function"); +let failures = 0; +const fail = m => { console.error(" FAIL " + m); failures++; }; + +/* 1. discover every source file, and let none of them re-define a rule ------- */ +const root = new URL("../", import.meta.url); +const files = []; +for (const dir of ["", "tools/"]) { + for (const name of await readdir(new URL(dir, root))) { + if (!name.endsWith(".mjs")) continue; + const rel = dir + name; + if (rel === "rules.mjs" || rel === "tools/check-rules.mjs") continue; + files.push(rel); + } +} +for (const rel of files) { + const src = await readFile(new URL(rel, root), "utf8"); + for (const name of canonical) { + const defined = new RegExp(`^\\s*(?:export\\s+)?(?:async\\s+)?function ${name}\\s*\\(`, "m").test(src) + || new RegExp(`^\\s*(?:export\\s+)?(?:const|let|var) ${name}\\s*=\\s*(?:\\(|function|async)`, "m").test(src); + if (defined) fail(`${rel} defines ${name}() — it belongs to rules.mjs alone`); + } +} + +/* 2. aliases must be identity-equal ---------------------------------------- * + * REQUIRED is authoritative and hand-held on purpose. Discovery alone is not + * enough: the original bug replaced an alias with an arrow function, which a + * discovery regex simply stops matching — the guard then skips it and reports + * OK. Absence must fail loudly, so every required alias is asserted by name and + * discovery may only ADD to the set, never shrink it. */ +const REQUIRED = { hitPoints: "hitPointsFor", majorWound: "majorWoundFor", damageModifier: "damageModifierFor" }; +const contentSrc = await readFile(new URL("tools/content.mjs", root), "utf8"); +const discovered = Object.fromEntries( + [...contentSrc.matchAll(/^export const (\w+)\s*=\s*(\w+);/gm)] + .filter(([, , target]) => canonical.includes(target)) + .map(([, alias, target]) => [alias, target])); +const pairs = { ...discovered, ...REQUIRED }; +for (const [alias, target] of Object.entries(pairs)) { + if (!(alias in CONTENT)) fail(`content.${alias} is missing — it must re-export rules.${target}`); + else if (CONTENT[alias] !== RULES[target]) fail(`content.${alias} is not the same function as rules.${target}`); +} + +/* 2b. constants are rules too ----------------------------------------------- * + * Functions were guarded from the start; the REACTION constants were not, and a + * second object literal for them survived in ringbrp.mjs with its own copy of the + * divisors. Any non-function export of rules.mjs is now held to the same standard: + * no other file may write an object literal containing its keys. */ +const constants = Object.keys(RULES).filter(k => typeof RULES[k] === "object" && RULES[k]); +for (const name of constants) { + const keys = Object.keys(RULES[name]); + if (keys.length < 2) continue; + for (const rel of files) { + const src = await readFile(new URL(rel, root), "utf8"); + // an object literal that carries two or more of the canonical keys is a copy + for (const m of src.matchAll(/=\s*\{([^{}]*)\}/g)) { + const hits = keys.filter(k => new RegExp(`\\b${k}\\s*:`).test(m[1])); + if (hits.length >= 2) fail(`${rel} re-declares ${name} keys (${hits.join(", ")}) — assign rules.${name} itself`); + } + } +} + +/* 2c. content sanity: species profiles must exist ---------------------------- * + * Every pregen shipped with speciesProfile "hominid" — a name left over from the + * Ringworld setting that is not a key in locationTables. It only ever worked + * because the sheet falls back to baseline, so three of four agents were quietly + * drawn and hit-located as something the game does not define. */ +{ + const engineSrc = await readFile(new URL("ringbrp.mjs", root), "utf8"); + const tablesBlock = engineSrc.match(/^RINGBRP\.locationTables = \{([\s\S]*?)^\};/m); + const profiles = tablesBlock + ? [...tablesBlock[1].matchAll(/^ {2}(\w+):\s*\{/gm)].map(x => x[1]) : []; + const tpl = JSON.parse(await readFile(new URL("template.json", root), "utf8")); + const found = []; + (function walk(o) { + if (Array.isArray(o)) return o.forEach(walk); + if (o && typeof o === "object") for (const [k, v] of Object.entries(o)) { + if (k === "speciesProfile") found.push(v); else walk(v); + } + })(tpl); + if (!profiles.length) fail("could not read locationTables — the species check is not running"); + for (const v of found) { + if (!profiles.includes(v)) fail(`template.json speciesProfile "${v}" is not a species (have: ${profiles.join(", ")})`); + } +} + +/* 3. behaviour, with mandatory coverage of every exported rule ---------------- */ +const checks = [ + ["hitPointsFor(13,10)", RULES.hitPointsFor(13, 10), 12], + ["hitPointsFor(10,19)", RULES.hitPointsFor(10, 19), 15], + ["hitPointsFor(0,0)", RULES.hitPointsFor(0, 0), 1], + ["majorWoundFor(12)", RULES.majorWoundFor(12), 6], + ["majorWoundFor(15)", RULES.majorWoundFor(15), 8], + ["reactionBaseFrom(18,34)", RULES.reactionBaseFrom(18, 34), 18], + ["reactionBaseFrom(10,71)", RULES.reactionBaseFrom(10, 71), 24], + ["reflexRating(18)", RULES.reflexRating(18), 6], + ["awarenessRating(71)", RULES.awarenessRating(71), 8], + ["damageModifierFor(37)", RULES.damageModifierFor(37), "+1d6"], + ["damageModifierFor(5)", RULES.damageModifierFor(5), "-1d6"], + ["damageModifierFor(13)", RULES.damageModifierFor(13), "-1d3"], + ["damageModifierFor(80)", RULES.damageModifierFor(80), "+5d6"], + ["locationMaxHp(12,0.42)", RULES.locationMaxHp(12, 0.42), 6], + ["styleFor('aware')", RULES.styleFor("aware"), "aware"], + ["ceil(1.1)", RULES.ceil(1.1), 2], + // every point over free carry costs, not just whole steps + ["encumbranceFrom(30,20).over", RULES.encumbranceFrom(30, 20).over, 10], + ["encumbranceFrom(30,20).srLoad", RULES.encumbranceFrom(30, 20).srLoad, 1], + ["encumbranceFrom(10,20).over", RULES.encumbranceFrom(10, 20).over, 0], + ["encumbranceFrom(10,20).skillPenalty", RULES.encumbranceFrom(10, 20).skillPenalty, 0], + // load is exact, not banded: 3.4 kg over costs 0.3, not a whole point + ["reactionLoadFrom(0.34)", RULES.reactionLoadFrom(0.34), 0.4], + ["reactionLoadFrom(2)", RULES.reactionLoadFrom(2), 2], + ["reactionLoadFrom(0)", RULES.reactionLoadFrom(0), 0], + ["reactionPrintedFrom(24,0.34)", RULES.reactionPrintedFrom(24, 0.34), 23.6], + // the smallest excess still costs: 100 g over is 0.1, never 0 + ["reactionLoadFrom(0.01)", RULES.reactionLoadFrom(0.01), 0.1], + ["reactionPrintedFrom(15,2)", RULES.reactionPrintedFrom(15, 2), 13], + // R-56: no load may reduce an agent below 1 — last, never never + ["reactionPrintedFrom(15,30)", RULES.reactionPrintedFrom(15, 30), 1], + // Major Wounds are penalties, not prose. Concussion hits every category via `all`. + ["woundEffectsFor([concussed]).all", RULES.woundEffectsFor([{key:"concussed"}]).all, -20], + ["woundEffectsFor(x2 concussed).all", RULES.woundEffectsFor([{key:"concussed"},{key:"concussed"}]).all, -40], + ["woundEffectsFor([mobility]).movFactor", RULES.woundEffectsFor([{key:"mobility"}]).movFactor, 0.5], + ["woundEffectsFor([manipulation])", RULES.woundEffectsFor([{key:"manipulation"}]).manipulation, -30], + ["woundEffectsFor([deepBleeding])", RULES.woundEffectsFor([{key:"deepBleeding"}]).bleed, 2], + ["woundEffectsFor([crippling])", RULES.woundEffectsFor([{key:"crippling"}]).defenceOnly, true], + // a missing variant must not quietly cost nothing + ["woundEffectsFor([sensory no variant])", RULES.woundEffectsFor([{key:"sensory"}]).perception, -40], + ["woundEffectsFor([]).all", RULES.woundEffectsFor([]).all, 0], + // bleeding: the rate and the way to stop it are one authority + ["bleedRateFor([bleeding])", RULES.bleedRateFor([{key:"bleeding"}]), 1], + ["bleedRateFor([deep,bleeding])", RULES.bleedRateFor([{key:"deepBleeding"},{key:"bleeding"}]), 3], + ["bleedRateFor([]) ", RULES.bleedRateFor([]), 0], + ["rate agrees with woundEffectsFor", RULES.bleedRateFor([{key:"deepBleeding"}]) === RULES.woundEffectsFor([{key:"deepBleeding"}]).bleed, true], + ["bleedStoppedBy(bleeding, firstaid)", RULES.bleedStoppedBy("bleeding",{family:"first_aid"}), true], + ["bleedStoppedBy(deep, average firstaid)", RULES.bleedStoppedBy("deepBleeding",{family:"first_aid",difficulty:"average"}), false], + ["bleedStoppedBy(deep, difficult firstaid)",RULES.bleedStoppedBy("deepBleeding",{family:"first_aid",difficulty:"difficult"}), true], + ["bleedStoppedBy(deep, medicine)", RULES.bleedStoppedBy("deepBleeding",{family:"medicine"}), true], + ["bleedStoppedBy(deep, athletics)", RULES.bleedStoppedBy("deepBleeding",{family:"athletics"}), false], + // range ladder and coherence bands, now shared with the rules journal + ["rangeBandFrom(5).mod", RULES.rangeBandFrom(5, {short:10,medium:30,long:90}).mod, 0], + ["rangeBandFrom(25).mod", RULES.rangeBandFrom(25, {short:10,medium:30,long:90}).mod, -10], + ["rangeBandFrom(60).mod", RULES.rangeBandFrom(60, {short:10,medium:30,long:90}).mod, -30], + ["rangeBandFrom(150).mod", RULES.rangeBandFrom(150, {short:10,medium:30,long:90}).mod, -50], + ["rangeBandFrom(400).mod", RULES.rangeBandFrom(400, {short:10,medium:30,long:90}).mod, null], + ["coherenceBandFrom(10).id", RULES.coherenceBandFrom(10).id, "anchored"], + ["coherenceBandFrom(6).id", RULES.coherenceBandFrom(6).id, "loose"], + ["coherenceBandFrom(4).id", RULES.coherenceBandFrom(4).id, "adrift"], + ["coherenceBandFrom(1).id", RULES.coherenceBandFrom(1).id, "unmoored"], + ["coherenceBandFrom(0).id", RULES.coherenceBandFrom(0).id, "displaced"], + // 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], + ["leg destroyed → MOV floor", RULES.locationEffectsFor([{kind:"leg",disabled:true,destroyed:true}]).movFactor, 0.1], + ["arm disabled → hands", RULES.locationEffectsFor([{kind:"arm",disabled:true}]).manipulation, -30], + ["head disabled → unconscious", RULES.locationEffectsFor([{kind:"head",disabled:true}]).unconscious, true], + ["vesh ridge → perception", RULES.locationEffectsFor([{kind:"ridge",disabled:true}]).perception, -30], + ["vesh grasp → hands", RULES.locationEffectsFor([{kind:"grasp",disabled:true}]).manipulation, -30], + // the Cadence rule: each body lost costs the WHOLE person + ["cadence 1 body lost", RULES.locationEffectsFor([{kind:"unit",disabled:true}]).all, -10], + ["cadence 3 bodies lost", RULES.locationEffectsFor(Array.from({length:3},()=>({kind:"unit",disabled:true}))).all, -30], + // encumbrance reaches physical skills and leaves the mind alone + ["encumbrancePenaltyFor(10,'phys')", RULES.encumbrancePenaltyFor(10,"phys"), -10], + ["encumbrancePenaltyFor(10,'ranged')", RULES.encumbrancePenaltyFor(10,"ranged"), -10], + ["encumbrancePenaltyFor(10,'manip')", RULES.encumbrancePenaltyFor(10,"manip"), -10], + ["encumbrancePenaltyFor(10,'mental')", RULES.encumbrancePenaltyFor(10,"mental"), 0], + ["encumbrancePenaltyFor(10,'comm')", RULES.encumbrancePenaltyFor(10,"comm"), 0], + ["encumbrancePenaltyFor(0,'phys')", RULES.encumbrancePenaltyFor(0,"phys"), 0], + // free carry counts strength AND mass, not strength alone + ["freeCarryFor(18,19)", RULES.freeCarryFor(18,19), 37], + ["freeCarryFor(12,13)", RULES.freeCarryFor(12,13), 25], + ["freeCarryFor(0,0)", RULES.freeCarryFor(0,0), 1], + // defending: the first is free, each one after is harder + ["defencePenaltyFor(0)", RULES.defencePenaltyFor(0), 0], + ["defencePenaltyFor(1)", RULES.defencePenaltyFor(1), -30], + ["defencePenaltyFor(3)", RULES.defencePenaltyFor(3), -90], + ["defenceTypeAllowed('dodge','parry')", RULES.defenceTypeAllowed("dodge","parry"), "dodge"], + ["defenceTypeAllowed('','parry')", RULES.defenceTypeAllowed("","parry"), "parry"], + // defences are graded: a defence stops its own quality or worse, and only + // shaves a better blow by as many steps as it was outclassed by + // access is a record: scope is a hard ceiling, not a difficulty + ["scopeRank('routine')", RULES.scopeRank("routine"), 2], + ["scopeRank(unknown)", RULES.scopeRank("nonesuch"), 0], + ["scopeStanceFor(routine, routine)", RULES.scopeStanceFor("routine","routine"), "in-scope"], + ["scopeStanceFor(routine, privileged)", RULES.scopeStanceFor("routine","privileged"), "stretch"], + ["scopeStanceFor(routine, administrative)", RULES.scopeStanceFor("routine","administrative"), "impossible"], + ["scrutinyFor(2 flags)", RULES.scrutinyFor(2), -40], + ["authorityOutcomeFor(special)", RULES.authorityOutcomeFor("special").trustDelta, 0], + ["authorityOutcomeFor(success)", RULES.authorityOutcomeFor("success").trustDelta, -1], + ["authorityOutcomeFor(fumble) burns", RULES.authorityOutcomeFor("fumble").burned, true], + ["trustAfter(1, -1) floors at nothing", RULES.trustAfter(1,-1), 0], + ["trustAfter(4, +1) is capped", RULES.trustAfter(4,1), 4], + ["dossierTrustFor(3)", RULES.dossierTrustFor(3), 4], + ["dossierTrustFor(0)", RULES.dossierTrustFor(0), 1], + ["dossierScopeFor(2, remit)", RULES.dossierScopeFor(2,true), "routine"], + ["dossierScopeFor(3, no remit)", RULES.dossierScopeFor(3,false), "read"], + ["widenScopeCostFor(0)", RULES.widenScopeCostFor(0), 1], + ["widenScopeCostFor(3)", RULES.widenScopeCostFor(3), 4], + ["mergeStageFor(4) is terminal", RULES.mergeStageFor(4).terminal, true], + ["mergeStageFor(0)", RULES.mergeStageFor(0).step, 0], + // fists are a fallback, never a peer of the weapon in hand + ["brawlFallbackFor(50)", RULES.brawlFallbackFor(50), 30], + ["brawlFallbackFor(75)", RULES.brawlFallbackFor(75), 45], + ["brawlFallbackFor(30) hits the floor", RULES.brawlFallbackFor(30), 25], + ["brawlFallbackFor(0) hits the floor", RULES.brawlFallbackFor(0), 25], + // Dodge's base is DEX x 2, and it is a floor rather than a replacement + ["skillBaseFrom('dex*2', DEX 13)", RULES.skillBaseFrom("dex*2",{dex:{value:13}}), 26], + ["skillBaseFrom('dex', DEX 13)", RULES.skillBaseFrom("dex",{dex:{value:13}}), 13], + ["skillBaseFrom('dex*2+5', DEX 13)", RULES.skillBaseFrom("dex*2+5",{dex:{value:13}}),31], + ["skillBaseFrom('' , anything)", RULES.skillBaseFrom("",{dex:{value:13}}), 0], + ["skillBaseFrom(junk) is refused", RULES.skillBaseFrom("dex*2; drop",{dex:{value:13}}), 0], + ["levelRank('critical')", RULES.levelRank("critical"), 4], + ["levelRank(unknown reads as failure)", RULES.levelRank("nonesuch"), 1], + ["defenceOutcomeFor(crit, crit)", RULES.defenceOutcomeFor("critical","critical").turnedAside, true], + ["defenceOutcomeFor(crit, special)", RULES.defenceOutcomeFor("critical","special").landsAt, "success"], + ["defenceOutcomeFor(crit, success)", RULES.defenceOutcomeFor("critical","success").landsAt, "special"], + ["defenceOutcomeFor(crit, failure)", RULES.defenceOutcomeFor("critical","failure").landsAt, "critical"], + ["defenceOutcomeFor(special, success)", RULES.defenceOutcomeFor("special","success").landsAt, "success"], + ["defenceOutcomeFor(special, special)", RULES.defenceOutcomeFor("special","special").turnedAside, true], + ["defenceOutcomeFor(success, success)", RULES.defenceOutcomeFor("success","success").turnedAside, true], + ["defenceOutcomeFor(success, fumble)", RULES.defenceOutcomeFor("success","fumble").landsAt, "success"], + // the Quiet Arts cost Coherence, and Unmake costs more than the rest + ["powerCostFor('thread')", RULES.powerCostFor("thread"), 1], + ["powerCostFor('thread', pushed)", RULES.powerCostFor("thread",{pushed:true}), 2], + ["powerCostFor('unmake')", RULES.powerCostFor("unmake"), 2], + ["powerCostFor('unmake', pushed)", RULES.powerCostFor("unmake",{pushed:true}), 4], + ["powerCostFor(unknown art)", RULES.powerCostFor("nonesuch"), 1], + ["powerStartingRating(13)", RULES.powerStartingRating(13), 26], + ["powerStartingRating(30) is capped", RULES.powerStartingRating(30), 45], + ["powerStartingRating(0) has a floor", RULES.powerStartingRating(0), 5], + // an Art arrives only from a crossing that went wrong + ["crossingGrantsArt(success, 10)", RULES.crossingGrantsArt("success", 10), false], + ["crossingGrantsArt(failure, 10)", RULES.crossingGrantsArt("failure", 10), false], + ["crossingGrantsArt(failure, 2)", RULES.crossingGrantsArt("failure", 2), true], + ["crossingGrantsArt(fumble, 10)", RULES.crossingGrantsArt("fumble", 10), true], + ["crossingCostFor(fumble)", RULES.crossingCostFor("fumble"), 2], + ["crossingCostFor(success)", RULES.crossingCostFor("success"), 0], + // the pool darkens as the agent comes apart + ["crossingArtPoolFor(10)", RULES.crossingArtPoolFor(10).join(","), "thread,echo,witness"], + ["crossingArtPoolFor(4)", RULES.crossingArtPoolFor(4).join(","), "hollow,thread,echo"], + ["crossingArtPoolFor(1)", RULES.crossingArtPoolFor(1).join(","), "unmake,ledger,hollow"] +]; +for (const [label, got, want] of checks) { + if (got !== want) fail(`${label} = ${JSON.stringify(got)}, expected ${JSON.stringify(want)}`); +} + +const covered = new Set(checks.map(([label]) => label.slice(0, label.indexOf("(")))); +for (const name of canonical) { + if (!covered.has(name)) fail(`rules.${name}() has no spot-check — every exported rule must be exercised`); +} + +if (failures) { console.error(`\ncheck-rules: ${failures} failure(s)`); process.exit(1); } +console.log(`check-rules: OK — ${canonical.length} rules, ${files.length} files scanned, ` + + `${Object.keys(pairs).length} aliases + ${constants.length} constant sets checked, ${checks.length} formulas verified`); diff --git a/tools/content.mjs b/tools/content.mjs new file mode 100644 index 0000000..54d5d75 --- /dev/null +++ b/tools/content.mjs @@ -0,0 +1,718 @@ +/** + * RingBRP content source — the playtest slice, expressed as data. + * Everything here is derived from RINGBRP_v0.5_PLAYTEST_SLICE.md. + * Derived values (HP, Major Wound, damage modifier, category bonuses) are COMPUTED, + * not transcribed, so the packs can never drift from the rules. + */ + +// Rule formulas come from the ONE authority. They used to be mirrored here and +// drifted twice: Major Wound and hit points were each built under a different +// rule than the engine played by. +import { ceil, hitPointsFor, majorWoundFor, damageModifierFor, styleFor } from "../rules.mjs"; +export { hitPointsFor, majorWoundFor, damageModifierFor, styleFor }; +export const hitPoints = hitPointsFor; +export const majorWound = majorWoundFor; +export const damageModifier = damageModifierFor; + +/* ---------- skill catalogue (spec §4.7 / v0.5 §2.1) ---------- */ +export const SKILL_CATALOGUE = [ + // melee + ["brawl", "", "melee", 25, "Brawl"], + ["melee_weapon", "club", "melee", 5, "Melee Weapon (Club)"], + ["transposed_weapon", "", "melee", 5, "Transposed Weapon"], + ["melee_weapon", "knife", "melee", 5, "Melee Weapon (Knife)"], + ["melee_weapon", "sword", "melee", 5, "Melee Weapon (Sword)"], + ["melee_weapon", "spear", "melee", 5, "Melee Weapon (Spear)"], + ["melee_weapon", "prybar", "melee", 5, "Melee Weapon (Prybar)"], + // The boarding axe and the entrenching tool both declare spec "axe" and the + // catalogue had no such skill, so neither could ever be rated above zero. + ["melee_weapon", "axe", "melee", 5, "Melee Weapon (Axe)"], + ["variable_sword", "", "melee", 5, "Variable Sword"], + // ranged + ["firearm", "pistol", "ranged", 5, "Firearm (Pistol)"], + ["firearm", "rifle", "ranged", 5, "Firearm (Rifle)"], + ["energy_weapon", "pistol", "ranged", 5, "Energy Weapon (Pistol)"], + ["energy_weapon", "rifle", "ranged", 5, "Energy Weapon (Rifle)"], + ["flashlight_laser", "", "ranged", 5, "Flashlight Laser"], + ["missile_weapon", "bow", "ranged", 5, "Missile Weapon (Bow)"], + ["heavy_weapon", "", "ranged", 5, "Heavy Weapon"], + // communication + ["bargain", "", "comm", 5, "Bargain"], + ["fast_talk", "", "comm", 5, "Fast Talk"], + ["persuade", "", "comm", 15, "Persuade"], + ["insight", "", "comm", 5, "Insight"], + ["language", "trade_cant", "comm", 0, "Language (Trade Cant)"], + ["language", "sign", "comm", 0, "Language (Sign)"], + ["language", "own", "comm", 0, "Language (Own)"], + ["art", "", "comm", 5, "Art"], + ["status", "", "comm", 15, "Status"], + // manipulation + ["repair", "mechanical", "manip", 15, "Repair (Mechanical)"], + ["repair", "electronics", "manip", 15, "Repair (Electronics)"], + ["sleight_of_hand", "", "manip", 5, "Sleight of Hand"], + ["demolition", "", "manip", 1, "Demolition"], + // mental + ["tradecraft", "", "mental", 5, "Tradecraft"], + ["science", "biology", "mental", 1, "Science (Biology)"], + ["science", "physics", "mental", 1, "Science (Physics)"], + ["science", "botany", "mental", 1, "Science (Botany)"], + ["anthropology", "", "mental", 1, "Anthropology"], + ["xenology", "baseline", "mental", 1, "Xenology (Baseline)"], + ["computer_use", "", "mental", 5, "Computer Use"], + ["engineering", "field_systems", "mental", 1, "Engineering (Field Systems)"], + ["knowledge", "", "mental", 5, "Knowledge"], + ["medicine", "", "mental", 1, "Medicine"], + ["first_aid", "", "mental", 30, "First Aid"], + ["navigate", "", "mental", 10, "Navigate"], + ["research", "", "mental", 1, "Research"], + ["anomaly_lore", "", "mental", 0, "Anomaly Lore", { canBeTaught: false }], + // perception + ["listen", "", "percep", 25, "Listen"], + ["spot", "", "percep", 25, "Spot"], + ["track", "", "percep", 10, "Track"], + ["scent", "", "percep", 0, "Scent"], + // physical + ["athletics", "", "phys", 25, "Athletics"], + ["stealth", "", "phys", 10, "Stealth"], + ["dodge", "", "phys", 0, "Dodge", { base: { mode: "formula", value: 0, formula: "dex*2" } }], + ["pilot", "aircraft", "phys", 1, "Pilot (Aircraft)"], + ["pilot", "ground_car", "phys", 1, "Pilot (Ground Car)"], + ["transposition", "", "phys", 1, "Transposition"] +]; + +/* ---------- weapons (v0.5 §4.7) ---------- */ +export const WEAPONS = [ + { key: "punch", name: "Punch / kick", cls: "melee", fam: "brawl", spec: "", dmg: "1d3", wound: "blunt", mod: true, ranges: [0,0,0], fire: "", cap: 0, mass: 0 }, + { key: "baton", name: "Collapsible baton", cls: "melee", fam: "melee_weapon", spec: "club", dmg: "1d6+2", wound: "blunt", mod: true, ranges: [0,0,0], fire: "", cap: 0, mass: 0.7, trad: "tradition_agency" }, + { key: "dagger", name: "Utility knife", cls: "melee", fam: "melee_weapon", spec: "knife", dmg: "1d4+2", wound: "edged", mod: true, ranges: [0,0,0], fire: "", cap: 0, mass: 0.3, trad: "tradition_agency" }, + { key: "prybar", name: "Entry bar", cls: "melee", fam: "melee_weapon", spec: "prybar", dmg: "1d10", wound: "blunt", mod: true, ranges: [0,0,0], fire: "", cap: 0, mass: 3, trad: "tradition_agency" }, + { key: "machete", name: "Machete", cls: "melee", fam: "melee_weapon", spec: "sword", dmg: "1d8+1", wound: "edged", mod: true, ranges: [0,0,0], fire: "", cap: 0, mass: 1.1 }, + { key: "spear1h", name: "Improvised spear", cls: "melee", fam: "melee_weapon", spec: "spear", dmg: "1d8+1", wound: "piercing", mod: true, ranges: [0,0,0], fire: "", cap: 0, mass: 1.8 }, + { key: "grasp", name: "Vesh grasp limb", cls: "melee", fam: "brawl", spec: "", dmg: "1d6+1", wound: "blunt", mod: true, ranges: [0,0,0], fire: "", cap: 0, mass: 0 }, + { key: "shortbow", name: "Hunting bow", cls: "bow", fam: "missile_weapon",spec: "bow", dmg: "1d6+1", wound: "piercing", mod: false, ranges: [30,60,90], fire: "single", cap: 12, mass: 1 }, + { key: "pistol_light", name: "Compact pistol", cls: "firearm", fam: "firearm", spec: "pistol", dmg: "1d6+2", wound: "piercing", mod: false, ranges: [10,30,90], fire: "rapid", cap: 8, mass: 0.7, trad: "tradition_agency" }, + { key: "pistol_med", name: "Service pistol", cls: "firearm", fam: "firearm", spec: "pistol", dmg: "1d8+2", wound: "piercing", mod: false, ranges: [10,30,90], fire: "rapid", cap: 15, mass: 1.1, trad: "tradition_agency" }, + { key: "pistol_heavy", name: "Heavy revolver", cls: "firearm", fam: "firearm", spec: "pistol", dmg: "1d10+2", wound: "piercing", mod: false, ranges: [10,40,120], fire: "rapid", cap: 6, mass: 1.4 }, + { key: "smg", name: "Suppressed carbine", cls: "firearm", fam: "firearm", spec: "rifle", dmg: "1d8+2", wound: "piercing", mod: false, ranges: [20,60,150], fire: "rapid", cap: 30, mass: 2.9, trad: "tradition_agency" }, + { key: "rifle_med", name: "Marksman rifle", cls: "firearm", fam: "firearm", spec: "rifle", dmg: "1d10+2", wound: "piercing", mod: false, ranges: [20,60,180], fire: "single", cap: 10, mass: 3.4, trad: "tradition_agency" }, + { key: "rifle_heavy", name: "Anti-materiel rifle", cls: "firearm", fam: "firearm", spec: "rifle", dmg: "2d6+2", wound: "piercing", mod: false, ranges: [30,80,220], fire: "single", cap: 5, mass: 6.1 }, + { key: "shotgun", name: "Breaching shotgun", cls: "firearm", fam: "firearm", spec: "rifle", dmg: "", wound: "piercing", mod: false, ranges: [10,20,50], fire: "slow", cap: 6, mass: 3.6, trad: "tradition_agency", + byBand: { short: "3d6", medium: "2d6", long: "1d6" } }, + /* Crossing tech. Every one of these costs a point of Coherence to use, which is the + whole design: they are the tools that work, and they are the tools that cost. */ + { key: "cutter", name: "Excision cutter", cls: "melee", fam: "transposed_weapon", spec: "", dmg: "1d20+5", wound: "edged", mod: false, ranges: [1,15,30], fire: "", cap: 0, mass: 0.8, mono: true, trad: "tradition_transposed", coherence: 1 }, + { key: "lance", name: "Redaction lance", cls: "energy", fam: "energy_weapon", spec: "pistol", dmg: "1d4+6", wound: "energy", mod: false, ranges: [20,50,100], fire: "beam", cap: 20, mass: 0.7, trad: "tradition_transposed", energy: 10, coherence: 1 }, + { key: "unmaker", name: "Unmaker", cls: "energy", fam: "energy_weapon", spec: "rifle", dmg: "1d10+30", wound: "energy", mod: false, ranges: [100,200,400],fire: "beam", cap: 5, mass: 4, trad: "tradition_transposed", energy: 40, coherence: 2 }, + + /* ---------- ANTIQUE. Brought back from somewhere behind us, or never left. ---------- */ + { key: "flintlock", name: "Flintlock pistol", cls: "firearm", fam: "firearm", spec: "pistol", dmg: "1d8+1", wound: "piercing", mod: false, ranges: [8,18,36], fire: "slow", cap: 1, mass: 1.3, era: "antique", trad: "tradition_local", desc: "One shot, then a minute of ritual. A fumble means a hangfire: it may still go off." }, + { key: "musket", name: "Musket", cls: "firearm", fam: "firearm", spec: "rifle", dmg: "1d10+2", wound: "piercing", mod: false, ranges: [15,40,90], fire: "slow", cap: 1, mass: 4.5, era: "antique", trad: "tradition_local", desc: "Loud, slow, and it does not care what century you are from." }, + { key: "duelling_sword", name: "Duelling sword", cls: "melee", fam: "melee_weapon", spec: "sword", dmg: "1d8+1", wound: "edged", mod: true, ranges: [0,0,0], fire: "", cap: 0, mass: 1.1, era: "antique", trad: "tradition_local", desc: "Light, quick and entirely unsuited to armour." }, + { key: "boarding_axe",name: "Boarding axe", cls: "melee", fam: "melee_weapon", spec: "axe", dmg: "1d8+2", wound: "edged", mod: true, ranges: [0,0,0], fire: "", cap: 0, mass: 2.2, era: "antique", trad: "tradition_local", desc: "Made for doors and rigging. Works on both." }, + { key: "crossbow", name: "Crossbow", cls: "bow", fam: "missile_weapon", spec: "bow", dmg: "1d10", wound: "piercing", mod: false, ranges: [20,50,110], fire: "slow", cap: 1, mass: 3.4, era: "antique", trad: "tradition_local", desc: "Silent, and it will punch through a stab vest." }, + { key: "cudgel", name: "Cudgel", cls: "melee", fam: "melee_weapon", spec: "club", dmg: "1d6+1", wound: "blunt", mod: true, ranges: [0,0,0], fire: "", cap: 0, mass: 1.0, era: "antique", trad: "tradition_local", desc: "A stick. Reliable in every century tested." }, + + /* ---------- MODERN. Agency issue and local purchase. ---------- */ + { key: "revolver", name: "Service revolver", cls: "firearm", fam: "firearm", spec: "pistol", dmg: "1d8+2", wound: "piercing", mod: false, ranges: [10,28,80], fire: "single", cap: 6, mass: 1.0, era: "modern", trad: "tradition_agency", desc: "Six, and it never jams. The armoury keeps them for crossings that eat electronics." }, + { key: "carbine", name: "Suppressed carbine",cls: "firearm", fam: "firearm", spec: "rifle", dmg: "1d10+1", wound: "piercing", mod: false, ranges: [25,70,160], fire: "rapid", cap: 30, mass: 3.2, era: "modern", trad: "tradition_agency", desc: "Quiet enough that Containment survives the first magazine." }, + { key: "entrenching_tool", name: "Entrenching tool", cls: "melee", fam: "melee_weapon", spec: "axe", dmg: "1d6+2", wound: "edged", mod: true, ranges: [0,0,0], fire: "", cap: 0, mass: 1.4, era: "modern", trad: "tradition_agency", desc: "Digs, pries, chops. Counts as a tool until it does not." }, + + /* ---------- FUTURE. Recovered from somewhere ahead. ---------- */ + { key: "gauss_pistol",name: "Gauss sidearm", cls: "energy", fam: "energy_weapon", spec: "pistol", dmg: "1d8+4", wound: "piercing", mod: false, ranges: [15,45,120], fire: "single", cap: 24, mass: 0.9, era: "future", trad: "tradition_transposed", energy: 4, desc: "No propellant and no report — a tap, and a hole. Ammunition is not manufacturable here." }, + { key: "gauss_rifle", name: "Gauss lance-rifle", cls: "energy", fam: "energy_weapon", spec: "rifle", dmg: "1d12+4", wound: "piercing", mod: false, ranges: [40,120,300], fire: "single", cap: 12, mass: 3.8, era: "future", trad: "tradition_transposed", energy: 8, desc: "Punches an entry wound and an exit wound and very little between them." }, + { key: "monoblade", name: "Monofilament blade",cls: "melee", fam: "melee_weapon", spec: "knife", dmg: "1d10+2", wound: "edged", mod: false, mono: true, ranges: [0,0,0], fire: "", cap: 0, mass: 0.4, era: "future", trad: "tradition_transposed", desc: "Cuts armour as though it were not there. Handle with the sheath on." } +]; + +/* ---------- armour (v0.5 §4.8) ---------- */ +export const ARMOURS = [ + { key: "jacket", name: "Reinforced jacket", points: 1, mass: 2, trad: "tradition_agency" }, + { key: "stab_vest", name: "Stab vest", points: 2, mass: 3, trad: "tradition_agency" }, + { key: "soft_armour", name: "Concealed soft armour", points: 3, mass: 3.5, trad: "tradition_agency" }, + { key: "hide", name: "Cured hide", points: 4, mass: 7 }, + { key: "mail", name: "Mail", points: 5, mass: 9 }, + { key: "plate", name: "Plate", points: 8, mass: 18 }, + { key: "impact_vest", name: "Impact vest", points: 6, mass: 3, trad: "tradition_agency" }, + { key: "breach_suit", name: "Breaching suit", points: 7, mass: 11, trad: "tradition_agency" }, + { key: "envsuit", name: "Environment suit", points: 4, mass: 8, trad: "tradition_agency" }, + { key: "salvage_plate", name: "Salvaged crossing plate", points: 10, mass: 11, trad: "tradition_transposed", coherence: 1 }, + + { key: "oilcloth_coat", name: "Oilcloth coat", points: 1, mass: 3.0, era: "antique", trad: "tradition_local", desc: "Keeps the weather out and very little else." }, + { key: "brigandine", name: "Brigandine", points: 4, mass: 9.0, era: "antique", trad: "tradition_local", desc: "Plates riveted between cloth. Heavy, quiet, and older than the agency." }, + { key: "hardsuit", name: "Sealed hardsuit", points: 9, mass: 12.0, era: "future", trad: "tradition_transposed", desc: "Sealed, powered and not from here. Also a breathing set — see Gear." } +]; + +/* ---------- gear ---------- */ +/* ============================================================================ + VEHICLES + + The department has a transport pool, and a great deal of what is in it should + have been written off. Hull is the vehicle's own hit points; armour protects + whoever is inside it. Speed is a band because the game measures distance in + bands everywhere else, and a figure in km/h would be the only number on the + sheet nobody ever used. + + Some of these are not ours. A vehicle brought back through a crossing carries a + Coherence cost like any other transposed materiel, and the ones that do are the + ones worth the paperwork. + ============================================================================ */ +export const VEHICLES = [ + /* --- the pool, such as it is --- */ + { key: "pool_car", name: "Pool car", fam: "pilot", spec: "ground_car", + crew: 1, seats: 4, cargo: 80, speed: "road", range: 500, hull: 18, armour: 1, mass: 1200, + terrain: ["road"], desc: "Grey saloon, four years old, ashtray full. Anonymous to the point of being conspicuous to anyone looking for anonymous cars." }, + { key: "pool_van", name: "Custodian van", fam: "pilot", spec: "ground_car", + crew: 1, seats: 3, cargo: 900, speed: "road", range: 420, hull: 26, armour: 2, mass: 2100, + terrain: ["road"], desc: "Panel van with a cage bulkhead and no windows in the back. Everything the department has ever contained has been in one of these." }, + { key: "land_rover", name: "Long-wheelbase utility", fam: "pilot", spec: "ground_car", + crew: 1, seats: 6, cargo: 600, speed: "cross", range: 550, hull: 30, armour: 3, mass: 2400, + terrain: ["road","rough","mud"], desc: "Goes where the pool car will not and tells you about it the whole way." }, + { key: "site_lorry", name: "Containment lorry", fam: "pilot", spec: "ground_car", + crew: 2, seats: 3, cargo: 6000, speed: "road", range: 700, hull: 44, armour: 4, mass: 9000, + terrain: ["road"], desc: "A sealed box on wheels with its own power and its own drain. Whatever is in the back is not to be discussed." }, + { key: "motorcycle", name: "Dispatch motorcycle", fam: "pilot", spec: "ground_car", + crew: 1, seats: 2, cargo: 20, speed: "fast", range: 260, hull: 8, armour: 0, mass: 190, + terrain: ["road","rough"], desc: "Faster than the traffic and much faster than the paperwork. No protection whatsoever." }, + + /* --- water and air --- */ + { key: "launch", name: "River launch", fam: "pilot", spec: "ground_car", + crew: 1, seats: 6, cargo: 400, speed: "slow", range: 180, hull: 24, armour: 1, mass: 1800, + terrain: ["water"], desc: "Diesel launch with a shallow draught. Most of the department's older sites are on water, because most of them are drains." }, + { key: "helicopter", name: "Light helicopter", fam: "pilot", spec: "aircraft", + crew: 1, seats: 4, cargo: 250, speed: "air", range: 600, hull: 22, armour: 1, mass: 1400, + terrain: ["air"], desc: "Gets a wounded team off a site in twenty minutes. Announces the department to everyone within three miles." }, + + /* --- ANTIQUE: still on the inventory --- */ + { key: "hand_cart", name: "Hand cart", fam: "pilot", spec: "ground_car", + crew: 1, seats: 0, cargo: 250, speed: "walk", range: 20, hull: 10, armour: 0, mass: 90, + era: "antique", terrain: ["road","rough"], desc: "Two wheels and a pair of shafts. Has moved more contained materiel than any motor vehicle in the department's history." }, + { key: "trap", name: "Pony and trap", fam: "pilot", spec: "ground_car", + crew: 1, seats: 3, cargo: 200, speed: "slow", range: 45, hull: 14, armour: 0, mass: 400, + era: "antique", terrain: ["road","rough"], desc: "Requires feeding and does not start on request, but it will go where a motor will not and it does not announce itself." }, + + /* --- FUTURE: brought back from somewhere ahead --- */ + { key: "flycycle", name: "Flycycle", fam: "pilot", spec: "aircraft", + crew: 1, seats: 2, cargo: 60, speed: "air", range: 900, hull: 10, armour: 1, mass: 210, + era: "future", terrain: ["air","road","rough","water"], desc: "A saddle, a field housing and no visible means of support. Silent, and it does not care what the ground underneath it is doing." }, + { key: "lifter", name: "Cargo lifter", fam: "pilot", spec: "aircraft", + crew: 1, seats: 2, cargo: 4000, speed: "slow", range: 300, hull: 34, armour: 3, mass: 1900, + era: "future", terrain: ["air","water"], desc: "A flatbed that ignores gravity for as long as the cells hold. Lifts a containment box out of a shaft in one piece." }, + { key: "crawler", name: "Sealed crawler", fam: "pilot", spec: "ground_car", + crew: 2, seats: 6, cargo: 1200, speed: "cross", range: 1400, hull: 52, armour: 8, mass: 7000, + era: "future", terrain: ["road","rough","mud","water","vacuum"], desc: "Its own air, its own water and eleven days of both. Built for somewhere that is not here, and it shows." }, + + /* --- ANOMALOUS: it came back with us --- */ + { key: "stepping_frame", name: "The stepping frame", fam: "pilot", spec: "ground_car", + crew: 1, seats: 1, cargo: 120, speed: "instant", range: 0, hull: 12, armour: 0, mass: 45, + era: "anomalous", trad: "tradition_transposed", coherence: 2, terrain: ["anywhere"], + desc: "A doorframe on a pallet. Step through and you are at the other frame, wherever it was left. Costs 2 Coherence per crossing, and the department owns only two frames." }, + { key: "wrong_carriage", name: "The carriage that is already there", fam: "pilot", spec: "ground_car", + crew: 1, seats: 4, cargo: 300, speed: "road", range: 999, hull: 20, armour: 2, mass: 1500, + era: "anomalous", trad: "tradition_transposed", coherence: 1, desc: "It is parked outside wherever you were going to go, and it was parked there before you decided. Costs 1 Coherence to admit you noticed." } +]; + +export const GEAR = [ + { key: "field_kit", name: "Field analysis kit", mass: 9, facility: "portable", desc: "A laboratory in a case. Laboratory analysis steps at −20%." }, + { key: "handset", name: "Agency handset", mass: 0.3, desc: "Encrypted comms and case files. Desk analysis steps take 20% fewer hours. No signal while across." }, + { key: "trauma_unit", name: "Trauma unit", mass: 14, desc: "Vehicle-mounted. Restores 1 hit point every 2 hours. Cannot reverse characteristic loss." }, + { key: "fieldmedic", name: "Field medic pack", mass: 1.2, desc: "Restores 1 hit point every 4 hours and stabilises the dying. Cannot reverse characteristic loss." }, + { key: "medkit", name: "Medical kit", mass: 2, bonus: { fam: "first_aid", value: 20 }, desc: "+20% to First Aid." }, + { key: "toolkit", name: "Technician's toolkit",mass: 8, desc: "Required for most Repair work in the field." }, + { key: "scanner", name: "Ground scanner", mass: 1.5, desc: "Sees through soil and rock. Returns nothing at all through transposed alloy." }, + { key: "power_cell", name: "Power cell", mass: 0.6, desc: "One cell. Restores 1 Resources to the case file track." }, + { key: "cover_id", name: "Cover identity pack", mass: 0.2, bonus: { fam: "persuade", value: 10 }, desc: "Documents, history and a phone that answers. +10% to Persuade while the cover holds. Paper helps; it is the training that sells it." }, + { key: "anchor", name: "Personal anchor", mass: 0.1, desc: "Something that remembers you as you were. Restores 1 Coherence per period of downtime spent with it." }, + { key: "key", name: "Crossing key", mass: 0.4, trad: "tradition_transposed", coherence: 1, desc: "Opens a path without a scheduled window. Costs 1 Coherence each time it is used." }, + /* --- standard agency issue: what every case draws from stores --- */ + { key: "torch", name: "Field lamp", mass: 0.5, desc: "Sealed, sixty hours. Nothing on the far side is reliably lit." }, + { key: "rope", name: "Line and harness", mass: 3.5, bonus: { fam: "athletics", value: 20 }, desc: "+20% Athletics for any climb or descent." }, + { key: "rations", name: "Ration pack (3 days)", mass: 2.4, desc: "Restores 1 Resource to the case when shared by the whole team." }, + { key: "water", name: "Filter flask", mass: 0.9, desc: "Makes almost anything drinkable. Almost." }, + { key: "camera", name: "Evidence camera", mass: 0.8, desc: "Stills and thirty minutes of video. Required to log a Discovery for publication." }, + { key: "sample_case", name: "Sample case", mass: 2.2, bonus: { fam: "science", value: 10 }, desc: "Sealed, inert and shielded. +10% to any Science roll on what is inside it." }, + { key: "restraints", name: "Restraints", mass: 0.6, desc: "Four sets. Containment is easier when the anomaly has wrists." }, + { key: "beacon", name: "Recovery beacon", mass: 1.1, desc: "Marks a position for extraction. The agency will find the beacon; whether it finds you is a separate question." }, + { key: "binocs", name: "Field glasses", mass: 0.7, bonus: { fam: "spot", value: 20 }, desc: "+20% Spot at long range." }, + { key: "notebook", name: "Case notebook", mass: 0.3, bonus: { fam: "research", value: 10 }, desc: "Paper. It works everywhere, which is more than can be said for the handset. +10% Research." }, + { key: "geiger", name: "Radiation counter", mass: 1.0, desc: "Clicks. Some worlds need it and do not look as though they do." }, + { key: "climbing_kit", name: "Breaching charges", mass: 2.8, bonus: { fam: "demolition", value: 15 }, desc: "Four shaped charges. +15% Demolition." }, + { key: "spare_cells", name: "Cell bandolier", mass: 2.4, desc: "Four power cells, carried where you can reach them." }, + { key: "survey_kit", name: "Survey kit", mass: 4.0, bonus: { fam: "navigate", value: 15 }, facility: "portable", desc: "Theodolite, chains and a very stubborn compass. +15% Navigate." }, + { key: "ward", name: "Ward plate", mass: 0.9, trad: "tradition_transposed", coherence: 1, desc: "Transposed. While carried you cannot be misidentified by anything that hunts by identity. Costs 1 Coherence per case carried." }, + + /* ---------- ANTIQUE ---------- */ + { key: "oil_lantern", name: "Oil lantern", mass: 1.4, era: "antique", desc: "Burns four hours on a fill. Visible for a mile, which is the problem." }, + { key: "tallow_candle", name: "Tallow candles", mass: 0.4, era: "antique", quantity: 6, desc: "Six. They gutter in a draught, which is how you find one." }, + { key: "brass_compass", name: "Brass compass", mass: 0.2, era: "antique", bonus: { fam: "navigate", value: 10 }, desc: "+10% Navigate. Unbothered by whatever is eating the handset." }, + { key: "pocket_watch", name: "Pocket watch", mass: 0.15, era: "antique", desc: "Mechanical. It keeps our time, which is how you notice the local time disagreeing." }, + { key: "spectacles", name: "Reading spectacles",mass: 0.05, era: "antique", bonus: { fam: "research", value: 5 }, desc: "+5% Research. Also the difference between reading a ledger and not." }, + { key: "chirurgeon_kit",name: "Chirurgeon's roll", mass: 1.8, era: "antique", bonus: { fam: "first_aid", value: 10 }, desc: "+10% First Aid. Boiled, not sterile. Better than nothing and worse than a medkit." }, + { key: "wax_tablet", name: "Wax tablet", mass: 0.6, era: "antique", desc: "Notes that cannot be recovered by anyone who finds the body." }, + { key: "iron_manacles", name: "Iron manacles", mass: 2.2, era: "antique", desc: "Custody, in the oldest sense the agency uses the word." }, + + /* ---------- MODERN ---------- */ + { key: "gas_mask", name: "Respirator", mass: 1.1, era: "modern", desc: "Filters particulates and most of what a Hollow world has instead of air." }, + { key: "evidence_tape", name: "Evidence tape", mass: 0.3, era: "modern", desc: "Seals a scene. Costs nothing and saves a Containment point more often than anything else in the pack." }, + { key: "dictaphone", name: "Dictaphone", mass: 0.4, era: "modern", desc: "Records the debrief while it is still true. Tape, so it survives a crossing." }, + + /* ---------- FUTURE ---------- */ + { key: "survey_drone", name: "Survey drone", mass: 1.6, era: "future", bonus: { fam: "spot", value: 20 }, desc: "+20% Spot. Forty minutes of flight and no idea what it is looking at." }, + { key: "optic_visor", name: "Optic visor", mass: 0.5, era: "future", bonus: { fam: "spot", value: 15 }, desc: "+15% Spot, and it sees in the dark. It also annotates things that are not there." }, + { key: "data_wafer", name: "Data wafer", mass: 0.02, era: "future", desc: "Somebody's whole archive. Unreadable without a reader nobody here has built." }, + { key: "breather", name: "Rebreather", mass: 0.8, era: "future", desc: "Six hours of air from nothing. The agency would like to know how." }, + { key: "sealed_cannister", name: "Stasis cannister", mass: 2.4, era: "future", bonus: { fam: "science", value: 15 }, desc: "+15% Science on whatever is inside. Nothing in it changes, including decay." }, + { key: "field_net", name: "Containment net", mass: 3.0, era: "future", desc: "Non-lethal custody for something that will not fit in manacles." }, + + /* ---------- ANOMALOUS ---------- */ + { key: "threshold_key", name: "Threshold key", mass: 0.3, era: "anomalous", coherence: 1, desc: "Finds the nearest crossing point and opens it a hand's width. Costs 1 Coherence each time." }, + { key: "quiet_ledger", name: "The quiet ledger", mass: 0.9, era: "anomalous", coherence: 1, desc: "Writes down what happened. Sometimes before it has. Costs 1 Coherence to read the last page." }, + + /* ------------------------------------------------------------------ + STANDING ISSUE and the rest of the stores. + + An agency this old issues a great deal of small equipment, most of it + dull, some of it older than the department. Everything here is light + unless it says otherwise: an officer is expected to carry the lot and + still be able to run. + ------------------------------------------------------------------ */ + + /* --- what every officer is issued --- */ + { key: "warrant_card", name: "Warrant card", mass: 0.05, desc: "Identifies you as an officer of a department the holder is not permitted to name. Opens most doors, and closes some." }, + { key: "dressings", name: "Field dressings", mass: 0.3, desc: "Two pads and a crepe bandage. Enough for a First Aid roll; not enough for a second." }, + { key: "chalk", name: "Marking chalk", mass: 0.1, desc: "Blue for what is safe, yellow for what is not, red for what has moved since you marked it." }, + { key: "whistle", name: "Whistle", mass: 0.05, desc: "Carries where a handset does not. Three blasts means come out, whatever you are doing." }, + + /* --- paperwork, which is most of the job --- */ + { key: "carbon_book", name: "Carbon duplicate book", mass: 0.4, desc: "Every form in triplicate. The third copy goes to Registry and is never seen again." }, + { key: "date_stamp", name: "Date stamp and pad", mass: 0.3, desc: "Stamps the date. Occasionally stamps a different one, which is itself reportable." }, + { key: "seals", name: "Numbered seals", mass: 0.4, desc: "Ten wire seals, consecutively numbered. Breaking one out of order is a paperwork offence." }, + { key: "cordon_kit", name: "Cordon tape and signs", mass: 2.2, desc: "Two hundred metres of tape and four signs citing an Act that does not exist." }, + { key: "clipboard", name: "Clipboard", mass: 0.4, desc: "Nobody stops a person carrying a clipboard. This is not a joke and is in the manual." }, + + /* --- looking at things --- */ + { key: "uv_lamp", name: "Ultraviolet lamp", mass: 0.6, desc: "Shows what has been cleaned up and what has been written on the walls in the meantime." }, + { key: "signal_mirror", name: "Signalling mirror", mass: 0.1, desc: "For line of sight without a signal. Also shows you what is behind you, which is the more common use." }, + { key: "probe", name: "Ground probe", mass: 1.4, desc: "A metre of graduated steel. Finds voids, and finds out how deep they are." }, + { key: "thermometer", name: "Recording thermometer", mass: 0.3, desc: "Anomalies are usually cold. This is how you prove it to Registry." }, + { key: "stopwatch", name: "Stopwatch", mass: 0.1, desc: "Times things. Occasionally disagrees with a second stopwatch, which is the finding." }, + { key: "specimen_jars", name: "Specimen jars", mass: 1.1, desc: "Six, with rubber seals and adhesive labels. Do not reuse a label." }, + { key: "swab_kit", name: "Swab kit", mass: 0.4, desc: "Sterile swabs and a chain-of-custody sleeve for each." }, + + /* --- getting in, and out --- */ + { key: "lock_rakes", name: "Lock rakes", mass: 0.2, desc: "Issued, logged, and signed for. Losing a set is a longer form than losing a firearm." }, + { key: "bolt_croppers", name: "Bolt croppers", mass: 3.4, desc: "Six hundred millimetre. Goes through a padlock, a hasp, and most railings." }, + { key: "master_keys", name: "Master key ring", mass: 0.5, desc: "Council, water board, railway, and three the ring has always had." }, + { key: "door_wedges", name: "Door wedges", mass: 0.3, desc: "Rubber. Keeps a door open behind you, which matters more than opening it did." }, + { key: "groundsheet", name: "Groundsheet", mass: 1.6, desc: "Waxed canvas. For lying on, wrapping, or covering what should not be photographed." }, + + /* --- keeping going --- */ + { key: "vacuum_flask", name: "Vacuum flask", mass: 0.9, desc: "Tea. The single most requisitioned item in the department's history." }, + { key: "blanket", name: "Foil blanket", mass: 0.15, desc: "For shock, for cold, and for the ones you bring back who are not talking yet." }, + { key: "analgesics", name: "Analgesic ampoules", mass: 0.2, desc: "Three doses. Takes the edge off a Major Wound; does not mend it." }, + { key: "wake_tablets", name: "Wakefulness tablets", mass: 0.1, desc: "For the second night. There is a form for the third night and it is not usually granted." }, + { key: "barrier_gloves", name: "Barrier gloves", mass: 0.2, desc: "You do not touch anything on a site with your hands. Ever. This is the whole of the training." }, + { key: "ear_defenders", name: "Ear defenders", mass: 0.4, desc: "Against gunfire, machinery, and the third category the manual declines to name." }, + { key: "site_overalls", name: "Site overalls", mass: 1.2, desc: "Grey, unbadged, and burned afterwards if the site requires it." }, + + /* --- ANTIQUE: postings the agency never got round to abolishing --- */ + { key: "tinderbox", name: "Tinderbox", mass: 0.3, era: "antique", desc: "Flint, steel and char cloth. Works in weather that defeats a lighter." }, + { key: "quill_ink", name: "Quill and ink horn", mass: 0.4, era: "antique", desc: "The Registry still accepts entries made in iron gall, and prefers them." }, + { key: "sealing_wax", name: "Sealing wax and signet", mass: 0.3, era: "antique", desc: "A seal that has been broken cannot be made to look unbroken. That is the point of it." }, + { key: "spyglass", name: "Spyglass", mass: 0.7, era: "antique", desc: "Brass, three draw. Shorter range than field glasses and considerably harder to lose." }, + { key: "lamp_oil", name: "Flask of lamp oil", mass: 1.1, era: "antique", desc: "Four hours of lantern, or one very committed diversion." }, + { key: "laudanum", name: "Bottle of laudanum", mass: 0.3, era: "antique", desc: "Stops the screaming. Does not distinguish between kinds of screaming." }, + { key: "lodestone", name: "Lodestone", mass: 0.4, era: "antique", desc: "Points north. When it does not, you write down which way it does point." }, + { key: "sand_glass", name: "Sand glass", mass: 0.6, era: "antique", desc: "Half an hour. Does not stop, cannot be tampered with, and has settled more disputes than the pocket watch." }, + + /* --- FUTURE: brought back from somewhere ahead --- */ + { key: "wound_foam", name: "Wound foam", mass: 0.4, era: "future", desc: "Seals and packs a wound in four seconds. Getting it out again is a hospital's problem." }, + { key: "cold_light", name: "Cold light strips", mass: 0.3, era: "future", desc: "Twelve strips, forty hours each, no heat and no battery. Mark your route in." }, + { key: "field_slate", name: "Field slate", mass: 0.5, era: "future", desc: "Reads, writes and files. Will not connect to anything here, which Registry considers a feature." }, + { key: "locator_tags", name: "Locator tags", mass: 0.2, era: "future", desc: "Twenty. Tag a person, a door, or a thing you intend to find again." }, + { key: "density_scanner", name: "Handheld density scanner", mass: 1.1, era: "future", desc: "Reads through two metres of brick. Reads nothing at all through a crossing." }, + { key: "seal_foam", name: "Sealing foam", mass: 1.3, era: "future", desc: "Fills and hardens a gap up to a metre across in under a minute. Airtight. Not always enough." }, + { key: "assist_sleeve", name: "Assist sleeve", mass: 2.6, era: "future", desc: "Powered forearm brace. Doubles what one arm can lift and halves how long the arm lasts." }, + + /* --- ANOMALOUS: transposed, and priced in Coherence --- */ + { key: "black_mirror", name: "The black mirror", mass: 0.7, era: "anomalous", trad: "tradition_transposed", coherence: 1, desc: "Shows the room as it was at some point when you were not in it. Costs 1 Coherence to look twice." }, + { key: "wrong_compass", name: "The compass that points elsewhere", mass: 0.3, era: "anomalous", trad: "tradition_transposed", coherence: 1, desc: "Not north. It points at the nearest crossing, and it does not care what is between you and it." }, + { key: "returning_coin", name: "The coin that comes back", mass: 0.02, era: "anomalous", trad: "tradition_transposed", coherence: 1, desc: "Spend it, drop it, bury it. It is in your pocket in the morning. Costs 1 Coherence each time you notice." }, + { key: "attendance_register", name: "The attendance register", mass: 1.1, era: "anomalous", trad: "tradition_transposed", coherence: 1, desc: "Lists who is present. It is right, and it is not always a list you want." }, + { key: "handbell", name: "The handbell", mass: 0.6, era: "anomalous", trad: "tradition_transposed", coherence: 1, desc: "Rings a half second before it is struck. Whatever is coming hears the first ring." }, + + /* ------------------------------------------------------------------ + TRANSPORT STORES — what rides in the boot + ------------------------------------------------------------------ */ + { key: "tow_line", name: "Tow line and shackles", mass: 6.4, desc: "Nine metres, rated well above anything the pool owns. Recovers a vehicle, a container, or an agent from a shaft." }, + { key: "jack_plates", name: "Jack and sand plates", mass: 8.2, desc: "For the mud the site is always in. Two plates, one bottle jack, and twenty minutes you did not have." }, + { key: "jerry_can", name: "Jerry can", mass: 22, desc: "Twenty litres. Heavy, and the difference between a debrief and a long walk." }, + { key: "vehicle_spares", name: "Vehicle spares roll", mass: 5.1, desc: "Belts, hose, fuses, wire and tape. A Repair (Mechanical) roll has something to work with." }, + { key: "wheel_clamps", name: "Wheel clamps", mass: 7.0, desc: "Stops a vehicle leaving. Occasionally stops one that was not going to be driven." }, + { key: "vehicle_log", name: "Vehicle log book", mass: 0.3, desc: "Mileage, driver, and purpose of journey. The single most audited document in the department." }, + { key: "roof_lamp", name: "Roof searchlight", mass: 4.5, desc: "Clamps to a roof rail and runs off the vehicle. Lights a field, and tells the field where you are." }, + { key: "recovery_strop", name: "Recovery strop", mass: 3.2, desc: "Webbing, not steel — it does not whip when it parts. The department learned this the expensive way." }, + + /* ------------------------------------------------------------------ + THE FORWARD STORES + + Materiel from further along than we are. It works, mostly, and Registry + has a standing instruction that none of it is to be issued to a + probationary officer. + ------------------------------------------------------------------ */ + { key: "stepping_disc", name: "Stepping disc", mass: 3.8, era: "future", trad: "tradition_transposed", coherence: 2, desc: "A flat disc a metre across. Step on, and step off the paired disc wherever it was left. Costs 2 Coherence, and the department has four discs and no spares." }, + { key: "stasis_box", name: "Stasis box", mass: 11, era: "future", trad: "tradition_transposed", coherence: 1, desc: "Nothing inside it changes, including how long it has been in there. The standard container for anything that is still doing something." }, + { key: "autodoc", name: "Field autodoc", mass: 24, era: "future", desc: "Diagnoses, seals and transfuses without an operator. Grants a Medicine roll to a team with nobody qualified to make one." }, + { key: "impact_suit", name: "Impact liner", mass: 2.4, era: "future", desc: "Goes under the clothes. Stiffens the instant it is struck and is limp again a second later." }, + { key: "shadow_cloth", name: "Shadow cloth", mass: 1.2, era: "future", desc: "Takes the colour and the light of whatever is behind it, a half second late. Stealth at a walk; useless at a run." }, + { key: "grav_harness", name: "Descent harness", mass: 5.6, era: "future", desc: "Falls at whatever speed you tell it to, down to nothing. Makes a shaft a staircase and a cliff a decision." }, + { key: "sonic_probe", name: "Sonic probe", mass: 0.7, era: "future", desc: "Opens the mechanical fastening of anything that has one. It is not subtle and everyone in the room hears it." }, + { key: "pressure_tent", name: "Pressure tent", mass: 13, era: "future", desc: "Two rooms and an airlock, out of a bag. Holds an atmosphere the local one disagrees with for eleven days." }, + { key: "cell_stack", name: "Cell stack", mass: 4.0, era: "future", desc: "Runs the lifter, the crawler or the autodoc for a week. Warm to the touch, always, which nobody has explained." }, + { key: "translator_pin", name: "Translator pin", mass: 0.05, era: "future", desc: "Renders speech both ways after about a minute of listening. It renders meaning, not manner, and the difference has started arguments." }, + { key: "medical_ram", name: "Trauma ampoules", mass: 0.5, era: "future", desc: "Three. Brings an agent at zero hit points back to one, once. The department requires a form before and a hearing after." }, + { key: "hull_patch", name: "Hull patch kit", mass: 3.1, era: "future", desc: "Seals a hole in a vehicle, a wall, or a suit. Sets in ninety seconds and is stronger than what it is patching." }, + { key: "beacon_hard", name: "Hardened beacon", mass: 1.4, era: "future", desc: "Transmits position through rock, water and most of what a crossing does to a signal. Runs for a year." }, + { key: "sensor_stakes", name: "Sensor stakes", mass: 2.8, era: "future", desc: "Eight. Driven into a perimeter, they report anything crossing the line and what direction it was going." }, + + /* ------------------------------------------------------------------ + Further out still, and correspondingly worse for you + ------------------------------------------------------------------ */ + { key: "general_hull", name: "Sealed sphere", mass: 19, era: "anomalous", trad: "tradition_transposed", coherence: 2, desc: "A container nothing has yet opened from outside, including the department. Whatever is inside it was put there by somebody who meant it. Costs 2 Coherence to carry." }, + { key: "pleasure_tap", name: "The contentment tap", mass: 0.3, era: "anomalous", trad: "tradition_transposed", coherence: 2, desc: "Makes the holder feel that everything is in order. It is not, and the holder will not check. Costs 2 Coherence per use and the department has destroyed six." }, + { key: "long_ledger", name: "The maintenance log", mass: 1.6, era: "anomalous", trad: "tradition_transposed", coherence: 1, desc: "Records the servicing of a structure nobody has located, in a hand that changes every few centuries. Still being written." } +]; + + +/* ---------- talents ---------- + Four categories. AGENCY talents are training the Custodians paid for. FIELD + talents are things you only learn by going across. SPECIES talents are what you + were born as. ANOMALOUS talents are what the crossing left in you — and every + one of them costs Coherence, because nothing you pick up over there is free. */ +export const TALENTS = [ + // --- agency training --- + { key:"cover_work", name:"Cover Work", cat:"agency", + summary:"You build a legend and live in it.", + rule:"+20% Persuade while a cover identity holds. A blown cover costs the case 1 Clearance.", + bonus:{fam:"persuade",value:20} }, + { key:"clean_scene", name:"Clean Scene", cat:"agency", + summary:"You leave nothing a coroner would query.", + rule:"Once per case, cancel one point of Containment loss. Declare it before the phase resolves.", + uses:1 }, + { key:"chain_custody",name:"Chain of Custody", cat:"agency", + summary:"Nothing you carry goes missing, and nothing you carry is unlogged.", + rule:"Transposed materiel you personally carry costs HALF its Coherence, rounded up. It never costs nothing." }, + { key:"debriefer", name:"Debriefer", cat:"agency", + summary:"You are the one who gets it out of people afterwards.", + rule:"At debrief, one other agent of your choice recovers 1 extra Coherence." }, + { key:"requisition", name:"Requisition", cat:"agency", + summary:"You know which form gets it signed today rather than in March.", + rule:"Once per case, convert 1 Clearance into 2 Resources, or the reverse.", uses:1 }, + // --- field --- + { key:"dead_reckon", name:"Dead Reckoning", cat:"field", + summary:"You can find the way back without instruments.", + rule:"+20% Navigate. You always know roughly how long the window has left.", + bonus:{fam:"navigate",value:20} }, + { key:"read_room", name:"Read the Room", cat:"field", + summary:"You know when a conversation has turned before it turns.", + rule:"+20% Insight. On a successful READ you learn one extra fact.", + bonus:{fam:"insight",value:20} }, + { key:"first_through",name:"First Through", cat:"field", + summary:"Somebody has to step across before anyone knows what is there.", + rule:"Once per fight, act first in the round no matter what anyone rolled. Declare before Reaction is rolled.", + action:"first" }, + { key:"triage", name:"Triage", cat:"field", + summary:"You work out who can be saved, quickly.", + rule:"+20% First Aid, and your First Aid restores 1 extra hit point.", + bonus:{fam:"first_aid",value:20} }, + { key:"hard_landing", name:"Hard Landing", cat:"field", + summary:"You have arrived badly enough times to be good at it.", + rule:"Ignore the first 2 points of damage from any fall, collision or bad crossing." }, + // --- species --- + { key:"unblinking", name:"Unblinking", cat:"species", species:"vesh", + summary:"A Vesh has no face to read and does not need to read yours.", + rule:"Immune to intimidation. −20% to any attempt to deceive you; −20% to your own Fast Talk." }, + { key:"out_of_step", name:"Out of Step", cat:"species", species:"vesh", + summary:"You perceive when you are a little loosely.", + rule:"Once per fight, take +3 Reaction for that round — a full lane. Declare before Reaction is rolled.", + action:"outofstep" }, + { key:"distributed", name:"Distributed", cat:"species", species:"cadence", + summary:"You are several bodies and one person.", + rule:"You have no vital location. Each body lost costs the whole person −10% to everything." }, + { key:"quorum", name:"Quorum", cat:"species", species:"cadence", + summary:"You can be in more than one conversation.", + rule:"One additional non-combat action each round, resolved 2 points lower on the Reaction order than your own." }, + // --- anomalous: the crossing left something in you --- + { key:"echo_sense", name:"Echo Sense", cat:"anomalous", + summary:"You feel a threshold before you see one.", + rule:"You always know if a crossing point is within a kilometre. Using it costs 1 Coherence.", + coherence:1 }, + { key:"borrowed_hour",name:"Borrowed Hour", cat:"anomalous", + summary:"Time gives you a moment it will want back.", + rule:"Once per case, take an entire extra turn immediately. Costs 2 Coherence.", + coherence:2, uses:1 }, + { key:"not_quite_here",name:"Not Quite Here", cat:"anomalous", + summary:"Sometimes the world forgets to include you.", + rule:"Once per fight, an attack that would hit you misses instead. Costs 1 Coherence.", + coherence:1 }, + { key:"wrong_memory", name:"Wrong Memory", cat:"anomalous", + summary:"You remember things that have not happened to you.", + rule:"Once per case, ask the GM one question about a place you have never been. The answer is true somewhere. Costs 1 Coherence.", + coherence:1, uses:1 } +]; + +/* ---------- pregenerated explorers (v0.5 §9) ---------- */ +const S = (fam, spec, val) => ({ fam, spec, val }); + +export const PREGENS = [ + { + key: "ceyla", talents: ["cover_work","read_room","requisition"], name: "Ceyla Marspont", + chosenStyle: "deliberate", + ch: { str: 12, siz: 13, con: 13, int: 17, pow: 15, dex: 10, cha: 12, edu: 19 }, + age: { chronological: 44, physiological: 44 }, + rank: "Case Officer", species: "baseline", + bio: "

Case Officer, technical. Twenty years in the analysis wing before anyone let her through a threshold, and she has never entirely stopped writing reports in her head while under fire. She is the slowest of them on her feet and reliably the first to move, because she has usually worked out what is happening a beat before it happens. She decides what the public is allowed to know, and the crossing is the first thing she has met that does not care what she decides.

Her anchor is a landline in her mother's kitchen that still rings.

", + skills: [ + S("transposition","",32), S("energy_weapon","pistol",53), S("dodge","",30), S("brawl","",31), S("athletics","",32), + S("insight","",58), S("tradecraft","",71), S("persuade","",41), S("bargain","",31), + S("language","trade_cant",95), S("navigate","",28), S("repair","electronics",45), + S("computer_use","",68), S("engineering","field_systems",61), S("anomaly_lore","",42), + S("research","",55), S("science","physics",49), S("xenology","baseline",34), + S("first_aid","",46), S("spot","",41), S("listen","",33) + ], + weapons: ["lance","pistol_med"], armour: ["impact_vest"], + gear: ["field_kit","handset","anchor",["power_cell",2]] + }, + { + key: "dunn", talents: ["dead_reckon","first_through","echo_sense"], name: "Dunn Halvorsen", + chosenStyle: "reflex", + ch: { str: 9, siz: 10, con: 13, int: 10, pow: 11, dex: 18, cha: 11, edu: 15 }, + age: { chronological: 38, physiological: 38 }, + rank: "Senior Field Officer", species: "baseline", + bio: "

Field lead. He crossed for the first time at nineteen, on a screening test he sat for the money, and has crossed more times since than anyone else on the roster. Quick-handed and quietly funny. He does not read a room so much as move through it before it has decided anything, which has worked every time so far.

He is on his third anchor. He does not discuss the first two.

", + skills: [ + S("firearm","pistol",59), S("dodge","",49), S("melee_weapon","knife",34), S("athletics","",48), + S("stealth","",33), S("repair","mechanical",42), S("language","trade_cant",75), + S("navigate","",67), S("pilot","aircraft",72), S("pilot","ground_car",54), + S("transposition","",68), S("anomaly_lore","",39), S("spot","",33), + S("listen","",30), S("first_aid","",37), S("insight","",25), S("tradecraft","",34) + ], + weapons: ["pistol_med","dagger"], armour: ["soft_armour"], // knife: the melee skill he actually has + gear: ["scanner","key","cover_id",["power_cell",2]], + environments: ["zero_g","vacuum"] + }, + { + key: "rakhi", talents: ["chain_custody","hard_landing","clean_scene"], name: "Rakhi Oyelaran-Vost", + chosenStyle: "deliberate", + ch: { str: 18, siz: 19, con: 10, int: 13, pow: 13, dex: 11, cha: 10, edu: 16 }, + age: { chronological: 41, physiological: 41 }, + rank: "Field Officer", species: "baseline", + bio: "

Technician, and the reason the heavy equipment moves at all. Short, immensely broad, and completely still until she is not. She grew up on a rig in the North Sea, which is where she learned to be careful and get it right the first time — exactly how she shoots.

", + skills: [ + S("transposition","",28), S("firearm","rifle",47), S("melee_weapon","prybar",54), S("brawl","",44), S("dodge","",31), + S("athletics","",47), S("language","trade_cant",80), S("navigate","",31), S("first_aid","",34), + S("engineering","field_systems",72), S("repair","mechanical",67), S("repair","electronics",58), + S("anomaly_lore","",47), S("science","physics",44), S("computer_use","",47), + S("spot","",47), S("demolition","",29), S("tradecraft","",20) + ], + weapons: ["rifle_med","prybar"], armour: ["breach_suit"], + gear: ["toolkit","anchor"], devices: ["crossing_regulator"] + }, + { + key: "tomas", talents: ["triage","debriefer","read_room"], name: "Tomas Ekwueme", + chosenStyle: "reflex", + ch: { str: 13, siz: 12, con: 14, int: 15, pow: 12, dex: 14, cha: 14, edu: 17 }, + age: { chronological: 36, physiological: 36 }, + rank: "Field Officer", species: "baseline", + bio: "

Physician, and a xenologist against his own better judgement. He joined to treat agents and has spent most of his service trying to work out how to talk to people who have never heard of anything he knows. He is the one who notices that the locals are frightened of the wrong thing.

He carries small gifts on every case as a matter of professional habit. Nobody has told him how often it has saved the team.

", + skills: [ + S("transposition","",35), S("firearm","pistol",43), S("dodge","",39), S("brawl","",32), S("athletics","",40), + S("stealth","",28), S("language","trade_cant",85), S("language","sign",42), + S("navigate","",26), S("anomaly_lore","",31), S("medicine","",64), S("first_aid","",78), + S("science","biology",56), S("anthropology","",52), S("insight","",58), S("tradecraft","",30), + S("xenology","baseline",47), S("bargain","",39), S("persuade","",44), S("spot","",44) + ], + weapons: ["pistol_light"], armour: ["stab_vest"], + gear: ["fieldmedic","medkit","cover_id","anchor"] + } +]; + +/* ---------- NPCs ---------- */ +export const NPCS = [ + { + key: "quiet_neighbours_npc", name: "The Quiet Neighbours", + role: "Near-world locals who have lived beside the threshold for generations", + chosenStyle: "reflex", naturalArmour: 0, species: "baseline", + ch: { str: 9, siz: 8, con: 12, int: 11, pow: 11, dex: 16, cha: 11, edu: 6 }, + skills: [ + S("athletics","",90), S("stealth","",70), S("dodge","",68), + S("missile_weapon","bow",55), S("melee_weapon","spear",50), + S("listen","",60), S("spot","",55), S("language","own",55), S("insight","",45) + ], + weapons: ["shortbow","spear1h"], + tactics: "They have known about the threshold for four generations and have rules about it that nobody has written down. They will not speak to anyone who came through it while still standing on the ground on their side — that is the taboo, and it reads as embarrassment rather than hostility. They want medicine. The correct offer is passed hand to hand with both parties off the ground. At impression −3 they shoot from cover at long range and cannot be caught." + }, + { + key: "keepers", name: "The Keepers (waystation)", + role: "Scavengers who bury what comes through — and the only real information network on the far side", + chosenStyle: "reflex", naturalArmour: 0, species: "baseline", + ch: { str: 13, siz: 12, con: 14, int: 13, pow: 12, dex: 13, cha: 6, edu: 8 }, + skills: [ + S("stealth","",75), S("listen","",70), S("spot","",65), S("track","",60), + S("melee_weapon","knife",55), S("brawl","",50), S("dodge","",45), + S("language","trade_cant",60), S("insight","",55), S("anomaly_lore","",40) + ], + weapons: ["dagger"], + tactics: "Do not look directly at their faces while speaking — they know what they look like. They want to know where bodies lie and what killed them, and they will trade that knowledge for more of it. At impression +2 they give up the location of a Custodian team that went missing eleven years ago, which they have watched ever since." + }, + { + key: "cleaner", name: "Cleaner (contractor)", + role: "Human, armed, and working for whoever got here first", + chosenStyle: "reflex", naturalArmour: 0, species: "baseline", + ch: { str: 13, siz: 12, con: 12, int: 11, pow: 10, dex: 12, cha: 9, edu: 10 }, + skills: [ + S("firearm","pistol",45), S("dodge","",40), S("brawl","",42), S("stealth","",45), + S("athletics","",40), S("spot","",38), S("tradecraft","",35), S("first_aid","",25) + ], + weapons: ["pistol_med"], armour: ["stab_vest"], + tactics: "Advances by bounds — 15 m per round while firing, 30 m if not — because a service pistol at 60 m is at long range and useless. Fires at the nearest visible agent; ties go to the lowest current hit points. When two of a team of six are down, the rest withdraw with their wounded. They are not fanatics. They are being paid." + }, + { + key: "cleaner_marksman", name: "Cleaner (marksman)", + role: "The one holding the high ground", + chosenStyle: "deliberate", naturalArmour: 0, species: "baseline", + ch: { str: 14, siz: 12, con: 12, int: 11, pow: 14, dex: 12, cha: 9, edu: 10 }, + skills: [ + S("firearm","rifle",55), S("dodge","",35), S("stealth","",50), S("spot","",55), + S("athletics","",35), S("tradecraft","",40), S("first_aid","",25) + ], + weapons: ["rifle_med"], armour: ["stab_vest"], + tactics: "DELIBERATE — POW governs the shot. Holds elevation and ALTERNATES AIMING AND FIRING: aim one round, fire the next. This is mandatory — without it the armour bypass never activates and a marksman rifle causes a Major Wound on 0% of hits against an impact vest. An aimed shot gains +30% and ignores 4 points of armour, which is what lets a rifle threaten agency armour at all. Withdraws when two of six are down." + }, + { + key: "revenant", name: "Revenant", + role: "What is left when something comes through without a body to come through in", + chosenStyle: "reflex", naturalArmour: 4, species: "baseline", + ch: { str: 16, siz: 13, con: 16, int: 8, pow: 17, dex: 14, cha: 3, edu: 2 }, + skills: [ + S("brawl","",65), S("dodge","",40), S("stealth","",60), S("track","",70), + S("listen","",65), S("spot","",50), S("athletics","",55) + ], + weapons: ["grasp"], + tactics: "It is looking for the person whose shape it is wearing. It will pass an agent it does not recognise to reach one it does. Physical damage works but slowly; it has no Major Wound threshold in the usual sense and keeps moving until reduced to zero. Anyone who watches it recognise someone loses 1 Coherence: witnessed a paradox." + }, + { + key: "duplicate", name: "Duplicate (of an agent)", + role: "The single most expensive thing a team can meet", + chosenStyle: "reflex", naturalArmour: 0, species: "baseline", + ch: { str: 12, siz: 12, con: 13, int: 14, pow: 13, dex: 14, cha: 12, edu: 15 }, + skills: [ + S("firearm","pistol",50), S("dodge","",45), S("brawl","",40), S("stealth","",40), + S("insight","",60), S("tradecraft","",50), S("persuade","",55), S("spot","",45) + ], + weapons: ["pistol_med"], armour: ["soft_armour"], + tactics: "Build this from the agent it duplicates; the statistics above are only a fallback. It is not evil and it is not a trick — it is that agent, from a branch where one thing went differently, and it has exactly as much right to be here. Meeting it costs 2 Coherence. Killing it costs 3. There is no version of this encounter that is free." + } +]; + +/* ---------- the device ---------- */ +export const DEVICES = [ + { + key: "crossing_regulator", name: "Crossing regulator (inert)", + documentId: "crossing_regulator", traditions: ["tradition_transposed"], mass: 12.5, + integrity: { value: 0, max: 3 }, + desc: "

Transposed. It holds a path open past its scheduled window. This one is dead, and nobody in the agency knows how it starts.

Diagnose is possible now, barely, because the agency has built something that does a hundredth of the same job. Operate is locked until someone answers the question. Repair is effectively impossible until the same answer arrives — and then becomes ordinary work.

", + functions: { + operate: { + skillFamilyId: "engineering", skillSpecialisationId: "field_systems", + difficulty: "average", difficultyAfterDiscovery: "", + analogueIn: [], requiredDiscoveryIds: ["regulator_operation"], + familiarityAfterDiscovery: -20, requiresIntegrity: 3, + powerCost: 4, powerCostOnFailure: 1, powerCostOnFumble: 1, hoursPerAttempt: 0, + coherenceCost: 1, + effect: "Holds a threshold open for a number of rounds equal to the operator's POW, regardless of its window. Pressure equalises across the aperture: −20% to Physical actions in it, an Athletics roll to cross against the draught, two people per round. A failed Athletics simply means you do not cross this round; a fumble is 1D6 collision damage and you still do not cross. Every operator loses 1 Coherence: used transposed materiel.", + onFailure: "The field does not form. One Resource is spent.", + onFumble: "The field forms and collapses within the round. Anything in the aperture takes 3D6 and must make a Luck roll or be caught in a closing threshold, which is not survivable and is not reversible." + }, + diagnose: { + skillFamilyId: "anomaly_lore", skillSpecialisationId: "", + difficulty: "average", difficultyAfterDiscovery: "", + analogueIn: ["tradition_agency"], requiredDiscoveryIds: [], + familiarityAfterDiscovery: -20, requiresIntegrity: 0, + powerCost: 1, powerCostAfterDiscovery: 0, powerCostOnFailure: 0, hoursPerAttempt: 0, + effect: "Reveals whether the unit is functional and how much charge remains. AFTER the Discovery it costs nothing and also reveals what is on the far side: which world, what the local time is, and whether anything is waiting.", + onFailure: "Nothing learned.", onFumble: "A confident, wrong conclusion." + }, + repair: { + skillFamilyId: "repair", skillSpecialisationId: "electronics", + difficulty: "difficult", difficultyAfterDiscovery: "average", + analogueIn: [], requiredDiscoveryIds: [], + familiarityAfterDiscovery: -20, requiresIntegrity: 0, + powerCost: 0, powerCostOnFailure: 0, hoursPerAttempt: 4, + effect: "+1 Integrity, to a target of NET Integrity 3 — a fumble removes one, minimum zero. Before the Discovery this is Difficult with no analogue, floored to 5% by Repair\u2019s printed base of 15. After it, Average at \u221220% familiarity — 38% for a good technician, and an expected 8.48 attempts / 33.9 hours.", + onFailure: "Four hours lost.", onFumble: "\u22121 Integrity, minimum 0, and four hours lost." + } + } + } +]; + +/* ---------- tables ---------- */ +export const SITE_TABLE = { + name: "Case sites (1d6)", + formula: "1d6", + results: [ + "**A substation that should not be drawing power.** Repair (Electronics) Average taps the feed: **+2 Resources**. A fumble trips the grid: −1 Resource, and the local utility sends someone out.", + "**The abandoned vehicle.** A Custodian van, eleven years off the books, nose-down in a ditch. **1D3 power cells (+1 Resource each)** and **an intact regulator control plate**. One Engineering (Field Systems) roll by the best-qualified agent: success = intact, failure = cracked, fumble = cracked and −1 Containment. This completes analysis step 1.", + "**A Keeper waystation.** Three cairns and the smell. Full contact statistics are in the Keepers statblock — taboo, desire, offer form and rewards. At impression +2 they give up where the van's crew went.", + "**Clean water and an intact supply cache.** **+3 Resources**, no roll required.", + "**A burn scar ten miles wide and glass-smooth.** Anomaly Lore Average reveals what did it, and that it was recent: **+1D3 Anomaly Lore**.", + "**A sealed hatch in bare transposed alloy.** The ground scanner returns nothing — the alloy blocks it. It cannot be opened without a working regulator. Note the position; it is a target for a later case." + ] +}; + +export const HAZARD_TABLE = { + name: "Case hazards (1d6)", + formula: "1d6", + results: [ + "**The weather turns wrong.** Not a storm — the wrong storm, for the wrong season, moving the wrong way. Each agent: Athletics Difficult or take 1D6 (armour applies) and lose the heaviest single item carried — **excluding mission-critical equipment such as the regulator** (R-56). A lost item is recoverable on a successful Spot the following day. **−1 Containment.** Anyone who watches the sky for more than a moment loses **1 Coherence**.", + "**Dead ground.** No signal, no landmarks, and a compass that disagrees with itself, for a day's march. **−1 Resource** beyond baseline and **+1 day**.", + "**Fire.** Athletics Average or 2D6 damage; armour applies. This costs the team **−1 Resource**, and anyone who fails loses their medical kit if carried. Weapons and cells are unaffected.", + "**Something is calling.** Each agent: **POW×5** or be drawn off route. Those who fail are separated for 1D3 days; the case loses **1 Resource per separated agent**, once — not per day. Science (Biology) identifies the mechanism afterwards, and wishes it had not.", + "**Water and silt.** Gear fouled. **−1 Containment.** Repair (Mechanical) Average over four hours restores it; otherwise all Manipulation is at −10% until cleaned.", + "**A second team, twenty kilometres out, on a frequency the agency does not use.** No damage. Everyone: **Luck** or spend 1D6 rounds certain they recognised a voice. **−1 Clearance** as the agency denies any second team. Anomaly Lore Average afterwards: the transmission is four days old and has not finished arriving." + ] +}; + + +/* ---------- THE QUIET ARTS ---------- + Not spells. A crossing is an induced filing error; an Art is the same trick + performed small and on purpose. Nobody learns one — they arrive. */ +export const POWERS = [ + { key: "thread", name: "Thread", art: "thread", + summary: "What was once together is still filed together.", + rule: "Hold something and know where the rest of it is, and roughly how far. Works on a torn page and its book, a hand and its glove, a person and the room they died in. Distance is not a limit; how long ago they were parted is.", + push: "Follow the thread rather than merely feel it: you know the way, step by step, for as long as you keep walking." }, + + { key: "echo", name: "Echo", art: "echo", + summary: "When is loosely filed.", + rule: "Stand somewhere and perceive one moment that has already happened there. You choose roughly how far back; you do not choose what you get. You are a witness, not a participant, and nothing there can be affected.", + push: "Take a moment that has NOT happened yet. It is not a prediction and it is not reliable, and having seen it you may find you are the reason for it." }, + + { key: "hollow", name: "Hollow", art: "hollow", + summary: "Make a small absence.", + rule: "One object the size of your two hands is not where the record says it is, for as long as you concentrate. It is not invisible and it is not moved — it is simply not filed here, and eyes and instruments both slide off it.", + push: "Hollow yourself instead. You are not filed at this location while you stand still. Moving ends it, and being spoken to ends it faster." }, + + { key: "witness", name: "Witness", art: "witness", + summary: "The record agrees that you belong here.", + rule: "Not persuasion and not control. Everyone present simply stops finding you unaccountable: your presence, your authority, your right to be holding that. It does not make them like you and it does not survive a document check.", + push: "Extend it to the whole team, or to something you are carrying that ought to raise questions." }, + + { key: "unmake", name: "Unmake", art: "unmake", + summary: "Strike a thing from the record.", + rule: "One object no larger than a door is removed from the account of what is here. It does not shatter or burn — it stops having been present, and what depended on it fails accordingly. Living things are categorically excluded, and the agency has three files about people who tried.", + push: "Something larger, or something load-bearing. Every Custodian who has pushed Unmake is listed in the same file." }, + + { key: "ledger", name: "Ledger", art: "ledger", + summary: "Read the record. The Registrar's art.", + rule: "Ask the record one closed question about this place and receive one true answer: what is filed here, what was, whether this location is where it says it is. The record does not editorialise and it does not warn you.", + push: "Amend one small entry rather than read it. This is the only Art that changes the record on purpose, and the agency does not formally admit it is possible." } +]; diff --git a/tools/icons.mjs b/tools/icons.mjs new file mode 100644 index 0000000..58a4d1e --- /dev/null +++ b/tools/icons.mjs @@ -0,0 +1,155 @@ +/** Item key -> [glyph, family, hue shift]. Shifts keep same-family items distinct. */ +export const ICON_MAP = { + // weapons + punch:["hand","melee",0], baton:["club","melee",-8,"modern"], dagger:["blade","melee",8,"antique"], + prybar:["bar","melee",-14], machete:["sword","melee",16,"antique"], spear1h:["spear","melee",-20,"antique"], + grasp:["claw","melee",24], shortbow:["bow","weapon",30,"antique"], + pistol_light:["pistol","weapon",-6], pistol_med:["pistol","weapon",0], pistol_heavy:["pistol","weapon",6], + smg:["rifle","weapon",-12], rifle_med:["rifle","weapon",12], rifle_heavy:["rifle","weapon",18], + shotgun:["shotgun","weapon",24], cutter:["blade","device",0,"anomalous"], + lance:["beam","device",-14,"anomalous"], unmaker:["lattice","device",14,"anomalous"], + + /* ---- ANTIQUE: brought back, or never left ---- */ + flintlock:["flintlock","weapon",-16,"antique"], musket:["musket","weapon",-22,"antique"], + duelling_sword:["sword","melee",4,"antique"], boarding_axe:["axe","melee",-10,"antique"], + crossbow:["crossbow","weapon",34,"antique"], cudgel:["club","melee",-26,"antique"], + oil_lantern:["lantern","gear",-36,"antique"], tallow_candle:["candle","gear",-42,"antique"], + brass_compass:["compass","gear",40,"antique"], pocket_watch:["pocketwatch","gear",-46,"antique"], + spectacles:["spectacles","gear",46,"antique"], chirurgeon_kit:["syringe","gear",-52,"antique"], + oilcloth_coat:["jacket","armour",-28,"antique"], brigandine:["mail","armour",28,"antique"], + wax_tablet:["book","gear",-56,"antique"], iron_manacles:["manacles","gear",52,"antique"], + /* ---- MODERN: agency issue, already covered above, plus ---- */ + revolver:["revolver","weapon",-30], carbine:["rifle","weapon",-24], + gas_mask:["mask_gas","gear",-60], evidence_tape:["tape","gear",56], + entrenching_tool:["handaxe_tool","melee",-34], dictaphone:["radio","gear",60], + /* ---- FUTURE: recovered from somewhere ahead ---- */ + gauss_pistol:["gauss","weapon",-40,"future"], gauss_rifle:["gauss","weapon",44,"future"], + monoblade:["blade","melee",40,"future"], survey_drone:["drone","gear",-66,"future"], + optic_visor:["visor","gear",66,"future"], data_wafer:["chip","gear",-70,"future"], + breather:["mask_gas","gear",70,"future"], sealed_cannister:["cannister","gear",-74,"future"], + hardsuit:["suit","armour",-34,"future"], field_net:["net","gear",74,"future"], + /* ---- ANOMALOUS: should not exist ---- */ + threshold_key:["aperture","device",-32,"anomalous"], + quiet_ledger:["ledger","device",34,"anomalous"], + /* ---- POWERS: the Quiet Arts ---- */ + power_thread:["thread","power",0], power_echo:["echo","power",14], + power_hollow:["hollow","power",-14], power_witness:["witness","power",28], + power_unmake:["unmake","power",-28], power_ledger:["ledger","power",42], + // armour + jacket:["jacket","armour",-10], stab_vest:["vest","armour",-4], soft_armour:["vest","armour",4], + hide:["jacket","armour",12,"antique"], mail:["mail","armour",-16,"antique"], plate:["plate","armour",16,"antique"], + impact_vest:["vest","armour",10], breach_suit:["suit","armour",-20], + envsuit:["suit","armour",20], salvage_plate:["plate","device",8,"anomalous"], + // gear + field_kit:["flask","gear",-12], handset:["radio","gear",8], trauma_unit:["kit","gear",-20], + fieldmedic:["syringe","gear",14], medkit:["kit","gear",0], toolkit:["cog","gear",-6], + scanner:["scanner","gear",20], power_cell:["cell","gear",-26], cover_id:["folder","gear",26], + anchor:["ring","talent",0], key:["key","device",20,"anomalous"], + torch:["bolt","gear",34], rope:["rope","gear",-32], rations:["pack","gear",30], + water:["flask","gear",24], camera:["camera","gear",-16], sample_case:["vial","gear",-24], + restraints:["manacles","gear",16], beacon:["radio","gear",-30], binocs:["binocs","gear",32], + notebook:["book","gear",4], geiger:["geiger","gear",28], climbing_kit:["bolt","gear",-8], + spare_cells:["cell","gear",12], survey_kit:["cog","gear",34], ward:["aperture","device",-24,"anomalous"], + // device + crossing_regulator:["regulator","device",-8,"anomalous"], + // skills, by category + skill_melee:["blade","skill",-14], skill_ranged:["target","skill",-7], + skill_phys:["boot","skill",0], skill_manip:["hand","skill",7], + skill_mental:["brain","skill",14], skill_percep:["eye","skill",21], + skill_comm:["wave","skill",28], + // talents + talent_generic:["thread","talent",0], talent_species:["triune","talent",18], + talent_agency:["ledger","talent",-18], talent_field:["compass","talent",9], + portrait_b0:["figure","actor",-330], + portrait_b1:["figure","actor",-300], + portrait_b2:["figure","actor",-270], + portrait_b3:["figure","actor",-210], + portrait_b4:["figure","actor",-155], + portrait_b5:["figure","actor",-120], + portrait_b6:["figure","actor",-80], + portrait_b7:["figure","actor",-40], + portrait_b8:["figure","actor",-10], + portrait_b9:["figure","actor",15], + portrait_v0:["mask","actor",-315], + portrait_v1:["mask","actor",-240], + portrait_v2:["mask","actor",-170], + portrait_v3:["mask","actor",-100], + portrait_v4:["mask","actor",-30], + portrait_c0:["triune","actor",-285], + portrait_c1:["triune","actor",-195], + portrait_c2:["triune","actor",-130], + portrait_c3:["triune","actor",-65], + portrait_c4:["triune","actor",5], + portrait_n0:["mask","threat",-355], + portrait_n1:["mask","threat",-315], + portrait_n2:["mask","threat",-275], + portrait_n3:["mask","threat",-155], + portrait_n4:["mask","threat",-75], + // actors + misc + actor_pc:["figure","actor",0], actor_npc:["mask","threat",0], + actor_case:["folder","case",0], table:["disc","case",18], + journal:["book","case",-18], discovery:["lens","device",30], + project:["book","device",-30], material:["ring","armour",0], group:["mask","actor",20], + door:["door","device",0], + /* ---- stores: standing issue and the rest ---- */ + warrant_card:["folder","gear",8], dressings:["kit","gear",-4], chalk:["bolt","gear",44], + whistle:["wave","gear",-38], carbon_book:["book","gear",-12], date_stamp:["disc","gear",18], + seals:["ring","gear",-18], cordon_kit:["tape","gear",22], clipboard:["folder","gear",-22], + uv_lamp:["lens","gear",-44], signal_mirror:["disc","gear",-30], probe:["bar","gear",38], + thermometer:["vial","gear",26], stopwatch:["pocketwatch","gear",14], specimen_jars:["vial","gear",-26], + swab_kit:["kit","gear",34], lock_rakes:["key","gear",-14], bolt_croppers:["handaxe_tool","gear",-34], + master_keys:["key","gear",20], door_wedges:["door","gear",30], groundsheet:["pack","gear",-40], + vacuum_flask:["flask","gear",10], blanket:["pack","gear",42], analgesics:["syringe","gear",6], + wake_tablets:["vial","gear",48], barrier_gloves:["hand","gear",-48], ear_defenders:["ear","gear",16], + site_overalls:["suit","gear",-10], + + /* ---- antique stores ---- */ + tinderbox:["candle","gear",-50,"antique"], quill_ink:["book","gear",50,"antique"], + sealing_wax:["ring","gear",-54,"antique"], spyglass:["binocs","gear",54,"antique"], + lamp_oil:["flask","gear",-58,"antique"], laudanum:["vial","gear",58,"antique"], + lodestone:["compass","gear",-62,"antique"], sand_glass:["pocketwatch","gear",62,"antique"], + + /* ---- future stores ---- */ + wound_foam:["kit","gear",-16,"future"], cold_light:["bolt","gear",-20,"future"], + field_slate:["chip","gear",24,"future"], locator_tags:["target","gear",-24,"future"], + density_scanner:["scanner","gear",28,"future"], seal_foam:["cannister","gear",-28,"future"], + assist_sleeve:["hand","gear",32,"future"], + + /* ---- anomalous stores: transposed, and priced ---- */ + black_mirror:["aperture","device",36,"anomalous"], wrong_compass:["compass","device",-36,"anomalous"], + returning_coin:["disc","device",40,"anomalous"], attendance_register:["ledger","device",-40,"anomalous"], + handbell:["wave","device",44,"anomalous"], + /* ---- transport ---- */ + pool_car:["disc","gear",-6], pool_van:["pack","gear",-2], land_rover:["cog","gear",6], + site_lorry:["cannister","gear",-32], motorcycle:["bolt","gear",-44], launch:["wave","gear",12], + helicopter:["drone","gear",-12], hand_cart:["bar","gear",-60,"antique"], + trap:["rope","gear",60,"antique"], flycycle:["drone","gear",36,"future"], + lifter:["pack","gear",40,"future"], crawler:["suit","gear",-44,"future"], + stepping_frame:["door","device",48,"anomalous"], wrong_carriage:["disc","device",-48,"anomalous"], + + /* ---- transport stores ---- */ + tow_line:["rope","gear",18], jack_plates:["bar","gear",-38], jerry_can:["flask","gear",-42], + vehicle_spares:["cog","gear",-50], wheel_clamps:["manacles","gear",44], vehicle_log:["book","gear",30], + roof_lamp:["lens","gear",52], recovery_strop:["rope","gear",-52], + + /* ---- the forward stores ---- */ + stepping_disc:["aperture","device",-56,"future"], stasis_box:["cannister","gear",56,"future"], + autodoc:["kit","gear",-60,"future"], impact_suit:["vest","armour",44,"future"], + shadow_cloth:["jacket","gear",-64,"future"], grav_harness:["rope","gear",64,"future"], + sonic_probe:["wave","gear",-68,"future"], pressure_tent:["pack","gear",68,"future"], + cell_stack:["cell","gear",-72,"future"], translator_pin:["ear","gear",72,"future"], + medical_ram:["syringe","gear",-76,"future"], hull_patch:["kit","gear",76,"future"], + beacon_hard:["radio","gear",-80,"future"], sensor_stakes:["target","gear",80,"future"], + + /* ---- further out, and worse for you ---- */ + general_hull:["aperture","device",-20,"anomalous"], pleasure_tap:["brain","device",20,"anomalous"], + long_ledger:["ledger","device",-44,"anomalous"], + + /* ---- borrowed authority ---- */ + authority:["folder","device",0], authority_flagged:["folder","device",26], + authority_burned:["folder","device",-26] +}; + +/** Skill category -> icon key. */ +export const SKILL_ICON = { melee:"skill_melee", ranged:"skill_ranged", phys:"skill_phys", + manip:"skill_manip", mental:"skill_mental", percep:"skill_percep", comm:"skill_comm" }; diff --git a/tools/make-dice-textures.mjs b/tools/make-dice-textures.mjs new file mode 100644 index 0000000..f01b368 --- /dev/null +++ b/tools/make-dice-textures.mjs @@ -0,0 +1,176 @@ +/** + * 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"); diff --git a/tools/make-icons.mjs b/tools/make-icons.mjs new file mode 100644 index 0000000..1d67500 --- /dev/null +++ b/tools/make-icons.mjs @@ -0,0 +1,264 @@ +/** + * The Custodians — icon set, v3. + * + * Flat, bold, saturated. Not photorealistic and not trying to be. What changed in + * v3 is DEPTH: a glyph is no longer a single silhouette but up to three layers — + * + * base the mass of the object, in near-white ink + * detail the parts that tell you WHICH object it is, in a darker tone: + * the grip and sight on a pistol, the fuller on a blade, the lens + * housing on a scanner. This is the whole point of the revision. + * mark a small accent in the rim colour — a muzzle flash, a lit indicator + * + * and ERA, because the agency crosses to other times and comes back with the + * shopping. Era is a corner tab plus a treatment of the tile, so a glance at an + * inventory tells you what century a thing is from before you read its name: + * + * antique notched corner, muted ground, no glow — flint, brass, oil + * modern plain tile — agency issue + * future cut corner and a hairline scan across it — recovered, elsewhere + * anomalous a broken rim: the tile does not close — should not exist + * + * Readable at 32px in a Foundry list, still clean at 256px on a sheet. + */ +import { writeFile, mkdir } from "node:fs/promises"; +import path from "node:path"; + +const OUT = path.resolve("icons"); +const S = 128; + +/* Category hue families. Deliberately far apart so a mixed inventory reads fast. */ +const FAMILY = { + weapon: { h: 8 }, melee: { h: 24 }, armour: { h: 205 }, + gear: { h: 150 }, device: { h: 275 }, skill: { h: 45 }, + talent: { h: 185 }, actor: { h: 330 }, threat: { h: 355 }, + case: { h: 220 }, power: { h: 288 } +}; + +const hsl = (h, s, l) => `hsl(${h} ${s}% ${l}%)`; + +/** + * Glyphs. A string is a plain silhouette (v2 behaviour, kept so nothing regresses). + * An object may carry { base, detail, mark, stroke } — `stroke` lists which of + * those layers are drawn as lines rather than filled. + */ +const G = { + /* ---------------- firearms: era told by the furniture ---------------- */ + pistol: { base: `M22 62h52l10-10h16v14l-12 6v10H60l-6 12H34l6-12H22z`, + detail: `M30 70h18v8H30z M60 56h14v6H60z`, mark: `M112 60h10v8h-10z` }, + revolver: { base: `M20 60h48l8-8h18v12l-10 6v10H56l-8 14H26l8-14H20z`, + detail: `M44 56a10 10 0 1 1 0 20 10 10 0 0 1 0-20z M28 70h14v8H28z`, + mark: `M110 58h10v10h-10z` }, + flintlock: { base: `M18 64h44l40-16v10L70 74v8H56l-10 16H22l10-16H18z`, + detail: `M56 54l10-8 6 8-8 8z M26 72h16v8H26z` }, + rifle: { base: `M12 60h84l20-8v12l-14 6v8H60l-8 14H32l8-14H12z`, + detail: `M46 66h30v6H46z M84 52h10v8H84z`, mark: `M116 58h6v10h-6z` }, + musket: { base: `M10 66h96l14-6v8l-14 6v6H62l-10 14H24l10-14H10z`, + detail: `M40 70h26v6H40z M66 56l8-8 6 6-8 8z` }, + smg: { base: `M18 54h60v14H18z M30 68h16v30H30z M78 56h30v10H78z`, + detail: `M52 68h12v22H52z M22 58h8v6h-8z`, mark: `M108 56h10v10h-10z` }, + shotgun: { base: `M12 46h58v10H12z M12 60h58v10H12z M70 44l30 16-30 16z`, + detail: `M100 52h16v16h-16z M20 48h14v4H20z` }, + gauss: { base: `M14 58h72v14H14z M86 50h26v30H86z`, + detail: `M24 62h48v6H24z M92 58h14v14H92z`, + mark: `M112 54h10v22h-10z`, stroke: ["mark"] }, + + /* ---------------- blades and hafted ---------------- */ + blade: { base: `M78 12l12 12-44 44-12-12z M40 60l12 12-8 12-16-4z M34 84l10 10-16 16-10-10z`, + detail: `M74 22l6 6-38 38-6-6z` }, + sword: { base: `M64 8l10 10v58H54V18z M40 76h48v10H40z M58 86h12v34H58z`, + detail: `M62 20h4v52h-4z M60 108h8v8h-8z` }, + axe: { base: `M52 14h14v106H52z M66 20c22 0 34 14 34 26s-12 24-34 24z`, + detail: `M72 30c12 2 18 10 18 16s-6 14-18 16z` }, + spear: { base: `M62 8l10 26-10 12-10-12z M58 46h12v74H58z`, + detail: `M60 20h8v12h-8z M56 92h16v6H56z` }, + club: { base: `M44 100l40-72 14 8-40 72z M84 18a12 12 0 1 1 18 10 12 12 0 0 1-18-10z`, + detail: `M52 96l32-58` , stroke: ["detail"] }, + bar: { base: `M52 12h24v92H52z M44 104h40v14H44z`, + detail: `M58 24h12v70H58z M52 12l-8-6h40l-8 6z` }, + claw: { base: `M30 18c14 22 14 62 6 90M56 12c10 26 10 68 2 96M82 18c8 24 6 64-2 88`, + stroke: ["base"] }, + bow: { base: `M40 16c30 14 30 82 0 96M36 12v104`, stroke: ["base"], + detail: `M40 16l52 48-52 48`, }, + crossbow: { base: `M14 58h100v12H14z M52 40h12v46H52z`, + detail: `M20 46c18 8 18 28 0 36M108 46c-18 8-18 28 0 36`, stroke: ["detail"] }, + + /* ---------------- crossing tech ---------------- */ + beam: { base: `M18 58h48v12H18z M66 46l30 18-30 18z`, + detail: `M26 62h30v4H26z`, mark: `M96 56h16v16H96z` }, + bolt: { base: `M70 8L28 72h28l-10 48 46-68H62z`, detail: `M64 26L44 62h16z` }, + lattice: { base: `M64 10l46 26v56l-46 26-46-26V36z`, + detail: `M64 34l24 14v28l-24 14-24-14V48z`, mark: `M64 58a8 8 0 1 1 0 16 8 8 0 0 1 0-16z` }, + aperture: { base: `M64 14a50 50 0 1 1 0 100 50 50 0 0 1 0-100z`, + detail: `M64 38a26 26 0 1 1 0 52 26 26 0 0 1 0-52z`, + mark: `M64 54a10 10 0 1 1 0 20 10 10 0 0 1 0-20z` }, + + /* ---------------- armour ---------------- */ + vest: { base: `M40 20h48l10 18v56a10 10 0 0 1-10 10H40a10 10 0 0 1-10-10V38z`, + detail: `M64 20v84 M38 46h20M74 46h20` , stroke: ["detail"] }, + plate: { base: `M64 12l40 14v40c0 28-18 44-40 50-22-6-40-22-40-50V26z`, + detail: `M64 26l26 10v30c0 18-12 28-26 32-14-4-26-14-26-32V36z` }, + jacket: { base: `M42 22h44l12 16v62H30V38z`, detail: `M64 22v78 M30 54h68`, stroke: ["detail"] }, + suit: { base: `M64 8a16 16 0 1 1 0 32 16 16 0 0 1 0-32z M40 44h48l10 20v56H30V64z`, + detail: `M52 20h24v10H52z M44 72h40v12H44z` }, + mail: { base: `M40 20h48l10 18v56a10 10 0 0 1-10 10H40a10 10 0 0 1-10-10V38z`, + detail: `M40 40h12v12H40zM60 40h12v12H60zM80 40h10v12H80zM50 56h12v12H50zM70 56h12v12H70zM40 72h12v12H40zM60 72h12v12H60zM80 72h10v12H80z` }, + + /* ---------------- gear, all eras ---------------- */ + kit: { base: `M26 40h76v66H26z M52 40V26h24v14`, detail: `M64 56v34 M47 73h34`, stroke: ["detail"] }, + pack: { base: `M34 38h60v70H34z M50 38V22h28v16`, detail: `M34 66h60 M50 78h28`, stroke: ["detail"] }, + cell: { base: `M42 22h44v18h10v66H32V40h10z`, detail: `M52 58h24v30H52z`, mark: `M56 64h16v6H56z` }, + key: { base: `M34 46a20 20 0 1 1 0 36 20 20 0 0 1 0-36z M54 60h56v10H88v14H78V70h-8v18H60V70z`, + detail: `M34 58a8 8 0 1 1 0 12 8 8 0 0 1 0-12z` }, + disc: { base: `M64 20a44 44 0 1 1 0 88 44 44 0 0 1 0-88z`, detail: `M64 50a14 14 0 1 1 0 28 14 14 0 0 1 0-28z` }, + scanner: { base: `M24 34h80v52H24z M38 96h52v10H38z`, detail: `M34 44h60v34H34z`, + mark: `M40 50h48M40 62h34M40 72h44`, stroke: ["mark"] }, + lens: { base: `M56 20a34 34 0 1 1 0 68 34 34 0 0 1 0-68z M80 82l28 28`, + detail: `M56 34a20 20 0 1 1 0 40 20 20 0 0 1 0-40z` }, + cog: { base: `M64 34a30 30 0 1 1 0 60 30 30 0 0 1 0-60z M58 8h12v18H58z M58 102h12v18H58z M8 58h18v12H8z M102 58h18v12h-18z`, + detail: `M64 52a12 12 0 1 1 0 24 12 12 0 0 1 0-24z` }, + regulator: { base: `M20 40h88v48H20z`, detail: `M34 54h20v20H34z M74 54h20v20H74z`, + mark: `M54 64h20v4H54z M44 88v14M84 88v14`, stroke: ["mark"] }, + radio: { base: `M28 52h72v56H28z M44 52V26h8v26`, detail: `M44 66a10 10 0 1 1 0 20 10 10 0 0 1 0-20z`, + mark: `M64 66h20M64 80h20`, stroke: ["mark"] }, + flask: { base: `M52 14h24v34l24 50a12 12 0 0 1-10 18H38a12 12 0 0 1-10-18l24-50z`, + detail: `M40 78h48 M50 20h28`, stroke: ["detail"] }, + lantern: { base: `M44 30h40v10H44z M38 40h52v58H38z M56 98h16v10H56z`, + detail: `M50 50h28v40H50z`, mark: `M58 60h12v20H58z` }, + candle: { base: `M54 46h20v58H54z M44 104h40v10H44z`, detail: `M60 30c8 6 8 14 4 16-6-2-8-10-4-16z` }, + compass: { base: `M64 16a48 48 0 1 1 0 96 48 48 0 0 1 0-96z`, + detail: `M64 34l12 30-12 30-12-30z`, mark: `M64 56a8 8 0 1 1 0 16 8 8 0 0 1 0-16z` }, + spectacles: { base: `M20 56a20 20 0 1 1 0 20 20 20 0 0 1 0-20z M88 56a20 20 0 1 1 0 20 20 20 0 0 1 0-20z`, + detail: `M40 64h48`, stroke: ["detail"] }, + camera: { base: `M18 44h92v56H18z M44 44l8-12h24l8 12`, detail: `M64 56a18 18 0 1 1 0 36 18 18 0 0 1 0-36z`, + mark: `M96 52h10v8H96z` }, + binocs: { base: `M22 40h34v66H22z M72 40h34v66H72z`, detail: `M56 58h16v12H56z`, + mark: `M28 50h22v10H28z M78 50h22v10H78z` }, + rope: { base: `M32 20c40 0 24 44 64 44M32 64c40 0 24 44 64 44`, stroke: ["base"] }, + book: { base: `M24 20h38a14 14 0 0 1 14 14v74a14 14 0 0 0-14-12H24z M104 20H66a14 14 0 0 0-14 14v74a14 14 0 0 1 14-12h38z`, + detail: `M32 40h30M32 56h30M66 40h30M66 56h30`, stroke: ["detail"] }, + folder: { base: `M14 30h40l10 14h50v66H14z`, detail: `M14 62h100 M28 78h60`, stroke: ["detail"] }, + vial: { base: `M50 12h28v12l-8 6v72a10 10 0 0 1-12 0V30l-8-6z`, detail: `M58 70h12v24H58z` }, + syringe: { base: `M18 92l40-40 18 18-40 40z M62 44l22-22 18 18-22 22z`, + detail: `M96 16l16 16 M30 96l-12 12`, stroke: ["detail"] }, + mask_gas: { base: `M64 16c26 0 40 14 40 34 0 22-18 34-24 44-4 8-28 8-32 0-6-10-24-22-24-44 0-20 14-34 40-34z`, + detail: `M44 46a12 12 0 1 1 0 20 12 12 0 0 1 0-20z M84 46a12 12 0 1 1 0 20 12 12 0 0 1 0-20z`, + mark: `M54 84h20v14H54z` }, + geiger: { base: `M26 46h58v50H26z M84 58h20v26H84z`, detail: `M36 56h38v14H36z`, + mark: `M40 80h30`, stroke: ["mark"] }, + tape: { base: `M64 22a42 42 0 1 1 0 84 42 42 0 0 1 0-84z`, detail: `M64 50a14 14 0 1 1 0 28 14 14 0 0 1 0-28z`, + mark: `M22 64h84`, stroke: ["mark"] }, + pocketwatch: { base: `M64 26a42 42 0 1 1 0 84 42 42 0 0 1 0-84z M56 8h16v18H56z`, + detail: `M64 44v24l16 10`, stroke: ["detail"] }, + handaxe_tool: { base: `M40 92l40-56 12 8-40 56z`, detail: `M76 28l18-8 6 16-18 8z` }, + chip: { base: `M34 34h60v60H34z`, detail: `M48 48h32v32H48z`, + mark: `M34 46H20M34 64H20M34 82H20M94 46h14M94 64h14M94 82h14`, stroke: ["mark"] }, + drone: { base: `M52 52h24v24H52z`, detail: `M20 28a14 14 0 1 1 0 4zM94 28a14 14 0 1 1 0 4zM20 96a14 14 0 1 1 0 4zM94 96a14 14 0 1 1 0 4z`, + mark: `M34 40l18 12M94 40L76 52M34 88l18-12M94 88L76 76`, stroke: ["mark"] }, + visor: { base: `M18 48h92v26a16 16 0 0 1-16 16H34a16 16 0 0 1-16-16z`, + detail: `M28 56h72v14H28z`, mark: `M36 60h20v6H36z` }, + cannister: { base: `M44 20h40v88H44z`, detail: `M52 34h24v18H52z`, mark: `M44 62h40M44 78h40`, stroke: ["mark"] }, + net: { base: `M20 20h88v88H20z`, detail: `M20 48h88M20 76h88M48 20v88M76 20v88`, stroke: ["detail"] }, + manacles: { base: `M36 44a22 22 0 1 1 0 40 22 22 0 0 1 0-40z M92 44a22 22 0 1 1 0 40 22 22 0 0 1 0-40z`, + detail: `M58 62h12`, stroke: ["detail"] }, + + /* ---------------- powers: the Quiet Arts ---------------- */ + thread: { base: `M20 100c30-70 58 40 88-30`, stroke: ["base"], + detail: `M20 100a6 6 0 1 1 0 2zM108 70a6 6 0 1 1 0 2z` }, + echo: { base: `M64 64a10 10 0 1 1 0 2z`, detail: `M64 40a24 24 0 1 1 0 48 24 24 0 0 1 0-48zM64 22a42 42 0 1 1 0 84 42 42 0 0 1 0-84z`, + stroke: ["detail"] }, + hollow: { base: `M64 12l48 28v48l-48 28-48-28V40z`, detail: `M64 40l22 13v26L64 92 42 79V53z` }, + witness: { base: `M8 64c26-32 82-32 112 0-30 32-86 32-112 0z`, detail: `M64 44a20 20 0 1 1 0 40 20 20 0 0 1 0-40z`, + mark: `M64 56a8 8 0 1 1 0 16 8 8 0 0 1 0-16z` }, + unmake: { base: `M28 28l72 72M100 28l-72 72`, stroke: ["base"], + detail: `M64 44a20 20 0 1 1 0 40 20 20 0 0 1 0-40z` }, + ledger: { base: `M28 16h72v96H28z`, detail: `M40 36h48M40 52h48M40 68h30`, stroke: ["detail"], + mark: `M76 76l10 10 18-22`, }, + + /* ---------------- the rest, carried over ---------------- */ + eye: { base: `M8 64c26-32 82-32 112 0-30 32-86 32-112 0z`, detail: `M64 44a20 20 0 1 1 0 40 20 20 0 0 1 0-40z` }, + ear: { base: `M46 20c30-8 46 12 44 34-2 24-22 24-24 40s-16 20-26 12c-12-10-6-24 2-30 10-8-16-46 4-56z`, stroke: ["base"] }, + brain: { base: `M40 28c-14 6-18 22-10 32-8 12-2 28 12 30 4 12 20 16 28 8 10 8 24 2 26-10 12-8 10-26-2-32 2-14-12-26-26-22-6-8-18-10-28-6z`, + detail: `M64 24v78`, stroke: ["base", "detail"] }, + hand: { base: `M40 60V28a8 8 0 0 1 16 0v26 M56 54V20a8 8 0 0 1 16 0v34 M72 56V28a8 8 0 0 1 16 0v34c0 30-12 46-32 46s-32-14-32-34v-8a8 8 0 0 1 16 0`, stroke: ["base"] }, + boot: { base: `M36 16h26v50l34 20v26H36z`, detail: `M36 76h26`, stroke: ["detail"] }, + mask: { base: `M22 34h84v34c0 26-20 46-42 52-22-6-42-26-42-52z`, detail: `M44 56h14M70 56h14`, stroke: ["detail"] }, + ring: { base: `M64 16a48 48 0 1 1 0 96 48 48 0 0 1 0-96z`, detail: `M64 42a22 22 0 1 1 0 44 22 22 0 0 1 0-44z` }, + triune: { base: `M34 22a14 14 0 1 1 0 28 14 14 0 0 1 0-28z M94 22a14 14 0 1 1 0 28 14 14 0 0 1 0-28z M30 68h68v18H30z`, + detail: `M34 50l14 18h32l14-18 M38 86v30M64 86v30M90 86v30`, stroke: ["detail"] }, + figure: { base: `M64 14a18 18 0 1 1 0 36 18 18 0 0 1 0-36z M40 56h48v40H40z`, + detail: `M46 96l-6 24M82 96l6 24`, stroke: ["detail"] }, + target: { base: `M64 12a52 52 0 1 1 0 104 52 52 0 0 1 0-104z`, + detail: `M64 40a24 24 0 1 1 0 48 24 24 0 0 1 0-48z`, mark: `M64 58a6 6 0 1 1 0 12 6 6 0 0 1 0-12z` }, + wave: { base: `M10 64c14-26 28-26 42 0s28 26 42 0 22-14 24-6`, stroke: ["base"] }, + door: { base: `M30 16h68v96H30z`, detail: `M84 60a5 5 0 1 1 0 10 5 5 0 0 1 0-10z` } +}; + +const ERAS = { antique: 1, modern: 1, future: 1, anomalous: 1 }; + +function layers(glyph, ink, mid, rim) { + const g = typeof G[glyph] === "string" ? { base: G[glyph] } : (G[glyph] ?? G.disc); + const stroke = new Set(g.stroke ?? []); + const draw = (d, fill, isStroke, w = 9) => !d ? "" : isStroke + ? `` + : ``; + return [ + draw(g.base, ink, stroke.has("base")), + draw(g.detail, mid, stroke.has("detail"), 6), + draw(g.mark, rim, stroke.has("mark"), 6) + ].join("\n"); +} + +function svg(glyph, hue, { dark = false, era = "modern" } = {}) { + const muted = era === "antique"; + const bg1 = hsl(hue, muted ? 38 : 62, dark ? 22 : (muted ? 30 : 34)); + const bg2 = hsl(hue, muted ? 44 : 68, dark ? 14 : 22); + const ink = hsl(hue, muted ? 55 : 90, muted ? 82 : 88); + const mid = hsl(hue, muted ? 40 : 70, muted ? 52 : 46); + const rim = hsl(hue, 80, 64); + + // era tab: a corner treatment, so the century reads before the name does + let corner = "", scan = "", rimPath = + ``; + if (era === "antique") corner = ``; + if (era === "future") { + corner = ``; + scan = ``; + } + if (era === "anomalous") { + rimPath = ``; + } + + return ` + +${corner} +${rimPath} +${layers(glyph, ink, mid, rim)} +${scan} +`; +} + +export async function writeIcon(name, glyph, family, opts) { + const fam = FAMILY[family] ?? FAMILY.gear; + await mkdir(OUT, { recursive: true }); + const hue = (fam.h + (opts?.shift ?? 0) + 360) % 360; + await writeFile(path.join(OUT, `${name}.svg`), svg(glyph, hue, opts), "utf8"); + return `systems/ringbrp/icons/${name}.svg`; +} + +export { FAMILY, G, ERAS }; + +/* ---------------- main ---------------- */ +import { ICON_MAP } from "./icons.mjs"; +if (import.meta.url === `file://${process.argv[1]}`) { + let n = 0, byEra = {}; + for (const [name, spec] of Object.entries(ICON_MAP)) { + const [glyph, family, shift, era = "modern"] = spec; + await writeIcon(name, glyph, family, { shift, era }); + byEra[era] = (byEra[era] ?? 0) + 1; + n++; + } + console.log(`Custodians | ${n} icons written to icons/`); + console.log(" by era: " + Object.entries(byEra).map(([k, v]) => `${k} ${v}`).join(" · ")); +} diff --git a/tools/mission-tables.mjs b/tools/mission-tables.mjs new file mode 100644 index 0000000..2a089a5 --- /dev/null +++ b/tools/mission-tables.mjs @@ -0,0 +1,430 @@ +/** + * MISSION TABLES — the raw material. + * + * Separated from the composer because this file is content and that one is + * machinery, and they change for entirely different reasons: a table gains rows + * when the setting gains texture, the composer changes when the SHAPE of a case + * changes. Keeping them apart means adding twenty new anomalies never risks the + * logic that makes a brief contradict its truth. + * + * Everything here is data. No Foundry, no functions with side effects. + */ + +/* ============================================================ + * WHAT IS WRONG + * ============================================================ */ +export const ANOMALIES = [ + { id: "held_open", name: "A crossing held open", threat: "structural", + brief: "A threshold at {place} has been open for {duration} and has not closed.", + truth: "It is being held open deliberately, from the other side, and whoever is doing it is getting better at it.", + signs: ["compasses lag by a degree", "the tide comes in twice", "radio carries a conversation nobody is having", + "the same gull, repeatedly", "batteries drain from full in an hour"], + evidence: ["a regulator plate, cracked, not agency issue", "scorch in a ring, no heat damage at the centre", + "eleven days of identical weather readings"], + containment: "Anyone who walks through it is a second incident.", + closes: "Find what is holding it and stop holding it. It will close on its own in under a minute.", + fits: { resolutions: ["close", "map", "arrange"], gaps: ["worse", "already_contained", "wrong_thing", "agency_knew", "local_stake", "clock_running", "the_brief_is_bait", "already_over"], + adversaries: ["cleaners", "rival_desk", "locals", "quiet_neighbours"], classes: ["near", "deep", "hollow", "wrong"], + placeKinds: ["remote", "structure", "settled"] } }, + + { id: "duplicate", name: "A duplicate", threat: "personal", + brief: "A person at {place} is reported to be in two places, and both are attending work.", + truth: "There is no original left. Both are copies, and one of them knows.", + signs: ["two sets of the same fingerprints, hours apart", "a wedding ring that fits neither", + "a dog that will not go near one of them", "the same signature, improving"], + evidence: ["a duplicated payslip", "a photograph with one of them slightly out of focus", + "a dental record that matches twice"], + containment: "The town has noticed. It has been explained as twins for eleven days.", + closes: "One of them must be taken into custody. Deciding which is the case.", + fits: { resolutions: ["custody", "arrange", "close"], gaps: ["not_hostile", "wrong_thing", "one_of_them", "local_stake", "clock_running", "already_over", "the_brief_is_bait"], + adversaries: ["cleaners", "rival_desk", "locals", "duplicate"], classes: ["near"], + placeKinds: ["settled"] } }, + + { id: "misfiled_object", name: "Something misfiled in", threat: "material", + brief: "An object recovered near {place} is not manufacturable and is not ours.", + truth: "It is not an object. It is the part of a larger thing that fitted through.", + signs: ["it is warm on the side away from the light", "instruments read it as absent", + "it has a serial number in our numerals", "it is heavier on Tuesdays"], + evidence: ["a second, smaller piece, buried", "tool marks made by nothing in the catalogue", + "a packing crate with a shipping label for a road that does not exist"], + containment: "It has been photographed. The photographs are already circulating.", + closes: "Recover it whole, or account for every piece. Half of it is worse than none.", + fits: { resolutions: ["recover", "close", "map"], gaps: ["worse", "already_contained", "wrong_thing", "agency_knew", "local_stake", "clock_running", "the_brief_is_bait"], + adversaries: ["cleaners", "rival_desk", "locals", "quiet_neighbours"], classes: ["near", "deep", "hollow", "wrong"], + placeKinds: ["remote", "structure", "settled"] } }, + + { id: "revenant", name: "Something wearing a shape", threat: "personal", + brief: "A member of the public at {place} has been behaving as somebody who died {duration} ago.", + truth: "It came through without a body and took the nearest one that was not being used.", + signs: ["it uses the dead man's turn of phrase", "it does not eat", "it keeps going back to the same doorway", + "it will not be photographed twice"], + evidence: ["a grave that has been opened neatly from above", "a coat that fits nobody in the family", + "a diary continuing in the same hand, badly"], + containment: "The family have started telling people.", + closes: "It is looking for something. Give it that, or take the body away from it.", + fits: { resolutions: ["custody", "close", "arrange"], gaps: ["not_hostile", "wrong_thing", "one_of_them", "local_stake", "already_over", "the_brief_is_bait"], + adversaries: ["cleaners", "rival_desk", "locals", "revenant"], classes: ["near"], + placeKinds: ["settled"] } }, + + { id: "thin_place", name: "A place wearing thin", threat: "structural", + brief: "Three separate people have walked into {place} and come out somewhere they should not have.", + truth: "The location is filed twice and the two filings are drifting apart.", + signs: ["the same building on two streets", "post that arrives before it is sent", + "a staircase with a landing nobody built", "two sets of church bells, half a beat apart"], + evidence: ["an Ordnance Survey sheet that disagrees with itself", "a key that opens the same door twice differently", + "a bus timetable for a route that runs one way only"], + containment: "It is on a public right of way.", + closes: "Decide which filing is the true one and make the other untenable.", + fits: { resolutions: ["close", "map", "handover"], gaps: ["worse", "already_contained", "wrong_thing", "agency_knew", "clock_running", "local_stake"], + adversaries: ["cleaners", "rival_desk", "locals", "quiet_neighbours"], classes: ["near", "deep", "hollow", "wrong"], + placeKinds: ["structure", "settled", "remote"] } }, + + { id: "missing_team", name: "A team that did not come back", threat: "agency", + brief: "A Custodian team crossed to {place} on a routine survey {duration} ago and their beacon is still transmitting.", + truth: "Three of the four are still alive. The fourth is what the beacon is transmitting.", + signs: ["the beacon repeats a fourteen-second interval", "their kit is stacked, not scattered", + "somebody has been eating", "one set of boots, walking out and not back"], + evidence: ["a case log with the last four pages torn out", "a regulator with its cell removed and pocketed", + "four sleeping bags, three used"], + containment: "The agency will not say the word 'rescue' in writing.", + closes: "Bring back what is left, and the log. The agency wants the log.", + fits: { resolutions: ["first_team", "close", "map"], gaps: ["worse", "not_hostile", "wrong_thing", "one_of_them", "local_stake", "already_over", "the_brief_is_bait"], + adversaries: ["cleaners", "rival_desk", "quiet_neighbours", "duplicate", "revenant"], classes: ["near", "deep", "hollow", "wrong"], + placeKinds: ["remote", "structure", "settled"] } }, + + { id: "recurrence", name: "A day that will not file", threat: "structural", + brief: "{place} has recorded the same eleven minutes four times in {duration}.", + truth: "Something is being rehearsed, and each attempt gets further before it stops.", + signs: ["a till roll printing the same sale", "birds landing in identical formation", + "one witness who remembers all four", "a clock that gains exactly eleven minutes"], + evidence: ["four identical CCTV files with different timestamps", "a shopping receipt for a future date", + "a witness statement written in the witness's hand before they gave it"], + containment: "A local paper has run it as a curiosity.", + closes: "Find out what the rehearsal is FOR. Stopping the loop without knowing is the mistake.", + fits: { resolutions: ["close", "map", "arrange"], gaps: ["worse", "already_contained", "wrong_thing", "agency_knew", "clock_running", "already_over", "the_brief_is_bait"], + adversaries: ["cleaners", "rival_desk", "locals", "quiet_neighbours"], classes: ["near", "deep", "wrong"], + placeKinds: ["settled", "structure"] } }, + + { id: "quiet_ledger", name: "A record that disagrees", threat: "agency", + brief: "The parish records for {place} list a family that no living person remembers.", + truth: "They were removed from the world and not from the paperwork. Something is using the gap.", + signs: ["a headstone with no plot", "a house number between two others", + "a school photograph with a gap in the row", "an electoral roll nobody will amend"], + evidence: ["a deed of sale signed by a person who does not exist", "a ration book, unused, correctly issued", + "a christening record in fresh ink on old paper"], + containment: "An amateur historian has published.", + closes: "Restore the record or complete the removal. The agency has a preference and will not state it.", + fits: { resolutions: ["restore", "arrange", "map"], gaps: ["worse", "agency_knew", "local_stake", "one_of_them", "already_over", "the_brief_is_bait", "wrong_thing"], + adversaries: ["cleaners", "rival_desk", "locals", "quiet_neighbours"], classes: ["near"], + placeKinds: ["records"] } }, + + { id: "inheritance", name: "Something left to somebody", threat: "material", + brief: "A property at {place} has passed to an heir along with a clause the solicitors cannot explain.", + truth: "The clause is a containment protocol. The family have been Custodians without the word for four generations.", + signs: ["a room that is cleaned but not used", "a standing order to a bank that closed", + "instructions in three hands, the earliest in Latin", "a key held by the solicitor, not the family"], + evidence: ["a household ledger with one line per decade", "a photograph of a previous agency team, uncaptioned", + "a will that names the agency without naming it"], + containment: "The heir intends to sell.", + closes: "Take up the protocol, or persuade the heir to. There is no third answer that holds.", + fits: { resolutions: ["handover", "arrange", "custody"], gaps: ["not_hostile", "wrong_thing", "one_of_them", "local_stake", "clock_running", "the_brief_is_bait"], + adversaries: ["cleaners", "rival_desk", "locals", "quiet_neighbours"], classes: ["near"], + placeKinds: ["settled", "structure"] } }, + + { id: "wrong_weather", name: "Weather that is not from here", threat: "structural", + brief: "{place} has had {duration} of conditions that do not appear in any regional record.", + truth: "It is not weather. It is the other side's weather, arriving through a gap too small to walk through.", + signs: ["snow that does not melt on skin", "rain that falls upward at the edges", + "a wind with a direction but no source", "pollen from nothing that grows here"], + evidence: ["a sample that is not water", "a barometer reading below the survivable", + "livestock refusing one specific field"], + containment: "The Met Office has asked a question in writing.", + closes: "Find the gap. It is smaller than a letterbox and it is somewhere absurd.", + fits: { resolutions: ["close", "map", "recover"], gaps: ["worse", "already_contained", "wrong_thing", "agency_knew", "clock_running", "the_brief_is_bait"], + adversaries: ["cleaners", "rival_desk", "locals", "quiet_neighbours"], classes: ["near", "deep", "hollow", "wrong"], + placeKinds: ["remote", "structure", "settled"] } } +]; + +/* ============================================================ + * WHERE AND WHEN + * ============================================================ */ +export const CLASSES = [ + { id: "near", name: "Near", weight: 5, modifier: 0, era: "modern", + note: "Almost this. Recognisable, survivable, dangerous because you relax.", + texture: ["the shops are the wrong chain", "the coins are right and the queen is not", + "everyone is very slightly better dressed", "a war ended a year late"] }, + { id: "deep", name: "Deep", weight: 2, modifier: -10, era: "future", + note: "The error resolved a long way out. The biology went another way, or the physics did.", + texture: ["the trees are wrong at the root", "there are no birds and something else does that job", + "the light arrives a moment after the sun moves", "the buildings are grown"] }, + { id: "hollow", name: "Hollow", weight: 2, modifier: -10, era: "antique", + note: "Nobody home. Something used to be.", + texture: ["the tables are laid", "a fire that has been out for a long time and is warm", + "no bodies anywhere", "every door open, every gate shut"] }, + { id: "wrong", name: "Wrong", weight: 1, modifier: -20, era: "anomalous", + note: "It does not behave, and the technique does not work cleanly there. Not given to probationers.", + texture: ["distances do not agree between two people", "the dead speak but only in the plural", + "your own handwriting is unfamiliar", "there is a sound that instruments do not record"] } +]; + +/** + * Places, tagged. `settled` means there are people here to talk to; `records` means + * there is an institution with paperwork; `structure` means there is a building to + * be inside. An anomaly that needs a parish register cannot happen in a forestry + * plantation, and one about a man attending work cannot happen on a canal bank. + */ +export const PLACES = [ + { text: "a stretch of coast outside Ilfracombe", kinds: ["remote"] }, + { text: "a flooded quarry above Nenthead", kinds: ["remote"] }, + { text: "the back of a working reservoir in Powys", kinds: ["remote", "structure"] }, + { text: "a decommissioned filter house on the Lea", kinds: ["structure"] }, + { text: "a farm at the end of a private road in Angus", kinds: ["settled", "structure"] }, + { text: "a pier that closed in 1974", kinds: ["structure"] }, + { text: "sixty acres of managed forestry near Kielder", kinds: ["remote"] }, + { text: "a chalk pit outside Ashford", kinds: ["remote"] }, + { text: "the top four floors of a car park in Coventry", kinds: ["structure", "settled"] }, + { text: "a tidal island off the Solway", kinds: ["remote"] }, + { text: "an aerodrome that appears on one map", kinds: ["structure", "records"] }, + { text: "a village with two war memorials", kinds: ["settled", "records", "structure"] }, + { text: "a Victorian pumping station under a retail park", kinds: ["structure"] }, + { text: "a stretch of canal with no towpath", kinds: ["remote"] }, + { text: "a boarding school out of term", kinds: ["structure", "records"] }, + { text: "a caravan site closed for the winter", kinds: ["structure"] }, + { text: "a market town with a working parish office", kinds: ["settled", "records", "structure"] }, + { text: "a colliery village three streets wide", kinds: ["settled", "structure"] }, + { text: "a county records office over a bank", kinds: ["settled", "records", "structure"] }, + { text: "a hospital annexe awaiting demolition", kinds: ["structure", "records"] } +]; + +/** + * Split, because the brief says "has been open for {duration}" and "since the + * spring" cannot follow "for". Spans are countable; vague dates are not, and only + * spans may be substituted into a slot that expects a length of time. + */ +export const DURATION_SPANS = ["four days", "eleven days", "three weeks", "nineteen hours", + "two months", "six days", "a fortnight"]; +export const DURATION_VAGUE = ["the spring", "a date nobody will confirm", "the last hard frost"]; + +/* ============================================================ + * THE GAP — the specific way the brief is wrong + * ============================================================ */ +export const GAPS = [ + { id: "worse", name: "It is worse than briefed", + text: "The scale in the brief is out by an order of magnitude, and the brief was written before the last two reports came in.", + reveal: "This phase makes the true scale unmissable." }, + { id: "already_contained", name: "Somebody has already contained it", + text: "Somebody got there first and did the job badly. Their solution is holding, and it is the reason things are getting worse.", + reveal: "The team find the earlier fix before they find the anomaly." }, + { id: "not_hostile", name: "It is not hostile", + text: "The thing the brief calls a threat is frightened, and it is frightened of something the brief does not mention.", + reveal: "It runs from them at the first opportunity, in a direction that means something." }, + { id: "wrong_thing", name: "The brief describes the symptom", + text: "What they were sent for is a side effect. The cause is elsewhere in the same location and has not been noticed.", + reveal: "Dealing with the briefed problem makes the real one worse, visibly, within the hour." }, + { id: "agency_knew", name: "The agency knew", + text: "This is not the first team. The file has been edited, and the edit is visible if anybody reads the page numbers.", + reveal: "A previous team's kit, or their handwriting, somewhere it should not be." }, + { id: "local_stake", name: "Somebody local wants it", + text: "A person or group on the ground is protecting the anomaly, because it is the only thing keeping something else true.", + reveal: "The first helpful local turns out to have been steering them all day." }, + { id: "clock_running", name: "There is a deadline nobody mentioned", + text: "Something happens on a fixed date, and the brief was written before anyone worked out what.", + reveal: "A countdown that is legible once you know what you are looking at." }, + { id: "one_of_them", name: "It concerns one of them", + text: "One agent has a connection to this place or these people that the assigning officer either missed or did not miss.", + reveal: "Somebody uses that agent's name before it is given." }, + { id: "already_over", name: "It has already happened", + text: "The event the brief is trying to prevent took place before the team crossed. They are inside the aftermath and have not realised.", + reveal: "A date, written somewhere ordinary, that is in the past." }, + { id: "the_brief_is_bait", name: "The brief is bait", + text: "Somebody wanted a Custodian team at this location on this date, and the case file is how they arranged it.", + reveal: "Everything has been made slightly too easy up to the point where it is not." } +]; + +/* ============================================================ + * WHO IS IN THE WAY — and the people, with names + * ============================================================ */ +export const ADVERSARIES = [ + { id: "cleaners", name: "Contract cleaners", tier: "armed", npc: "Cleaner (contractor)", count: [2, 4], + motive: "Somebody is paying them to make sure nothing is recovered intact.", + tactics: "Professional, unenthusiastic, willing to withdraw. They will not die for it.", + leverage: "They can be bought, or convinced the invoice will not be honoured." }, + { id: "rival_desk", name: "A rival desk", tier: "dangerous", npc: "Cleaner (marksman)", count: [2, 3], + motive: "Another part of the agency wants this closed before your desk writes it up.", + tactics: "Correct paperwork, better equipment, no interest in a fight they can win by arriving first.", + leverage: "Clearance. Show them a higher authorisation and they must comply." }, + { id: "revenant", name: "A revenant", tier: "anomalous", npc: "Revenant", count: [1, 1], + motive: "It is looking for the person whose shape it is wearing.", + tactics: "It will pass anyone it does not recognise to reach someone it does.", + leverage: "Tell it where that person is. It will go, and that becomes somebody's problem." }, + { id: "locals", name: "People who live here", tier: "trained", npc: "The Keepers (waystation)", count: [3, 6], + motive: "They have made an arrangement with the thing and the arrangement is working.", + tactics: "Obstruction, misdirection, and eventually a shotgun. They are not evil and they are not wrong.", + leverage: "Offer them a better arrangement. The agency can, and hates to." }, + { id: "quiet_neighbours", name: "The quiet neighbours", tier: "armed", npc: "The Quiet Neighbours", count: [2, 5], + motive: "They were misfiled here generations ago and this is their last stable ground.", + tactics: "They watch. They negotiate. They do not bluff.", + leverage: "Somewhere else to be. It has to be real and it has to be shown." }, + { id: "duplicate", name: "A duplicate of an agent", tier: "dangerous", npc: "Duplicate (of an agent)", count: [1, 1], + motive: "It has the same brief, the same training, and one more crossing of experience.", + tactics: "It knows what the team will do because it is what it would do.", + leverage: "It wants to be the one who goes home. Offer it that and mean it." } +]; + +export const NAMES_FIRST = ["Marianne", "Errol", "Bess", "Hadley", "Ivo", "Constance", "Peter", + "Ada", "Roland", "Nell", "Gwilym", "Ottoline", "Sam", "Ruth", "Alec"]; +export const NAMES_LAST = ["Trewin", "Kettle", "Ashgrove", "Bardsley", "Vane", "Pyle", "Mottram", + "Sallow", "Quiller", "Nye", "Harker", "Dunmore", "Speed", "Ferriby"]; + +export const NPC_ROLES = [ + { role: "the one who called it in", wants: "to be told they were right to call", + lies: "about how long they have known" }, + { role: "the person who owns the ground", wants: "the team gone before the weekend", + lies: "about who else has been here" }, + { role: "the local officer", wants: "a file they can close", + lies: "about the first report, which they lost" }, + { role: "the one who has seen it", wants: "somebody to say the word out loud", + lies: "about being alone when it happened" }, + { role: "the one profiting", wants: "the arrangement to continue", + lies: "about everything, fluently and well" }, + { role: "the child who is not frightened", wants: "to show somebody", + lies: "not at all, which is the problem" }, + { role: "the previous team's survivor", wants: "not to be taken back", + lies: "about what the fourth one did" } +]; + +/* ============================================================ + * PHASES — the shape of the approach + * ============================================================ */ +export const PHASE_KINDS = [ + { id: "approach", name: "Approach", verb: "getting there unobserved", + terrains: ["silted river flats", "a disused railway cutting", "overgrown allotments", + "a cliff path above the cove", "three miles of forestry track", "a flooded underpass"] }, + { id: "cordon", name: "The cordon", verb: "getting past whoever is already here", + terrains: ["a police outer cordon", "a farm gate with a man on it", "a coastguard station", + "a private road with cameras", "a village that has stopped talking to strangers"] }, + { id: "search", name: "The search", verb: "finding the thing itself", + terrains: ["forty rooms and a cellar", "two miles of foreshore at low water", "a plantation on a grid", + "a records office", "a caravan site in the dark"] }, + { id: "interior", name: "Inside", verb: "the place where the rules are different", + terrains: ["a pumping station below the water table", "the fourth floor and above", + "a chalk gallery", "the far side of the threshold", "a house with the wrong number of rooms"] }, + { id: "containment", name: "Containment", verb: "doing the thing they were sent to do", + terrains: ["the room it is in", "the doorway it keeps returning to", "a kitchen table and four chairs", + "the threshold itself", "a records room with the lights off", "wherever the decision has to be made"] }, + { id: "extraction", name: "Extraction", verb: "getting out with it", + terrains: ["back the way they came, in daylight", "a tidal causeway with ninety minutes", + "an open field and a mile of it", "the only road, now watched"] } +]; + +/* ============================================================ + * WHAT MAKES IT HARD, AND WHAT GETS WORSE + * ============================================================ */ +export const COMPLICATIONS = [ + "The window is nineteen hours and the agency has not said what happens at twenty.", + "The team's regulator is inert. Nobody on the team knows how it starts.", + "The cover identity will not survive a document check, and there is a police presence.", + "One phase must be crossed twice, and it is worse the second time.", + "The only route in is public, in daylight, in front of people.", + "Local law enforcement has opened a file and is looking for the team.", + "There is a child involved and the containment answer does not account for children.", + "Comms do not work across. Whatever the team decides, they decide alone.", + "One of the team is recognised on arrival, by name, by somebody they have never met.", + "The weather closes the only route out four hours after they arrive." +]; + +export const CLOCKS = [ + { id: "public", name: "The public find out", costs: "Containment", + steps: ["a photograph is posted", "a local reporter arrives", "it is on the national news"] }, + { id: "spread", name: "It gets bigger", costs: "Containment", + steps: ["a second sighting", "a second location", "it is no longer one incident"] }, + { id: "deniable", name: "The desk backs away", costs: "Clearance", + steps: ["the liaison stops answering", "the authorisation is withdrawn", "the case becomes deniable"] }, + { id: "window", name: "The window closes", costs: "the way home", + steps: ["the margin narrows", "the return is Difficult", "the return is aimed at −30%"] }, + { id: "attention", name: "It notices them", costs: "everything", + steps: ["it knows somebody is here", "it knows how many", "it knows their names"] } +]; + +/* ============================================================ + * WHAT IT TAKES OUT OF THEM, AND WHAT THEY GET + * ============================================================ */ +export const COHERENCE_HOOKS = [ + "Transposed materiel is the only thing that will open the inner door.", + "There is a paradox in plain sight and somebody will look at it.", + "The local law of reality is different in one specific, tempting way.", + "A duplicate of a team member is present and can be spoken to.", + "The fastest way through is to break something that is true here.", + "The window will be overstayed. It is only a question of by how long.", + "Somebody will have to go back through a second time.", + "There is a body that has to be carried, and it is one of ours." +]; + +/** + * Objectives. Given ids because an anomaly must be able to say which of these it + * can actually support: ordering a team to "recover the object" when the anomaly is + * a duplicated person is the single worst thing this generator did. + */ +export const RESOLUTIONS = [ + { id: "close", text: "Close it, and leave the public a version of events they can live with." }, + { id: "recover", text: "Recover the object and let the location resolve itself. It will, in about a year." }, + { id: "custody", text: "Take the person into custody. Custody, in the oldest sense the agency uses the word." }, + { id: "first_team",text: "Establish what happened to the first team and bring back whatever is left of them." }, + { id: "arrange", text: "Make an arrangement, file it, and never speak of the terms again." }, + { id: "map", text: "Nothing can be closed. Map it, mark it, and get out with the map." }, + { id: "handover", text: "Persuade somebody local to take on the job permanently, and never check on them again." }, + { id: "restore", text: "Put the record back the way it was, and make sure it stays put." } +]; + +export const DEBRIEF_QUESTIONS = [ + "Who else knows, and can they be relied upon to be wrong about it?", + "Was anything brought back that was not on the manifest?", + "At what point did the team stop following the brief, and why?", + "Is the location safe to re-enter, and would you?", + "Which of you was alone, and for how long?", + "The file will say this was routine. Do you object to that?" +]; + +export const FAILURE_STATES = [ + "The anomaly is still there and the agency has to send somebody else, which is a sentence in a file with names in it.", + "It is public. Containment becomes a press office problem and the desk remembers whose case it was.", + "It followed somebody home. That is next season's case and it has a Custodian's name on it.", + "The arrangement the locals had is broken and nothing has replaced it.", + "The team came back. Not all of them came back as themselves." +]; + + +/* ============================================================ + * GROUPS — so a generated case can reach the contact system + * + * The engine has a full first-contact pillar: three stages, standings, taboos + * learned in play, desires, a correct offer form, and rewards that the phase + * resolver already reads and applies. A generated case shipped with no group item + * at all, so none of it could ever fire. These are the people already on the + * ground when the team arrives. + * ============================================================ */ +export const GROUPS = [ + { id: "parish", name: "The parish", population: 400, + description: "Four hundred people who have lived with it long enough to have manners about it.", + taboo: { id: "no_naming", text: "It is never named aloud indoors. Say what it is under a roof and the room empties.", penaltyIfViolated: -30 }, + desire: { id: "burial", text: "A body returned. There is one they have never been able to bury.", offerBonusStep: "easy" }, + offerForm: "A name, a date, and somewhere to put a stone — delivered outdoors." }, + + { id: "keepers", name: "The keepers of the ground", population: 12, + description: "A dozen of them, and an arrangement none of them will describe to an outsider.", + taboo: { id: "no_photographs", text: "Nothing is photographed. A raised camera ends the conversation and starts a different one.", penaltyIfViolated: -30 }, + desire: { id: "relief", text: "To be relieved. Somebody, anybody, official enough to take the duty on.", offerBonusStep: "easy" }, + offerForm: "A written undertaking on agency paper, signed, with a date on it." }, + + { id: "displaced", name: "The ones who came through", population: 30, + description: "Misfiled here generations ago. This is their last stable ground and they know exactly how stable it is.", + taboo: { id: "ground_speech", text: "They will not converse with anyone standing on the ground. Address them from a stair, a wall, a vehicle roof.", penaltyIfViolated: -30 }, + desire: { id: "medicine", text: "Antibiotics. Theirs ran out two generations ago.", offerBonusStep: "easy" }, + offerForm: "A sealed course of antibiotics, handed over from above ground level." }, + + { id: "staff", name: "What is left of the staff", population: 6, + description: "Six people still turning up to a job at a site that closed. Nobody has told them to stop.", + taboo: { id: "no_authority", text: "Do not produce a warrant or an order. The last people who did took someone away.", penaltyIfViolated: -30 }, + desire: { id: "wages", text: "To be paid. Eleven months, and they have kept the log the whole time.", offerBonusStep: "easy" }, + offerForm: "Money, in cash, counted in front of them, and a receipt they can keep." } +]; diff --git a/tools/mission.mjs b/tools/mission.mjs new file mode 100644 index 0000000..38ab451 --- /dev/null +++ b/tools/mission.mjs @@ -0,0 +1,358 @@ +/** + * MISSION FRAMEWORK — the composer. + * + * A case is generated, but not at random: the parts are chosen to CONTRADICT each + * other in a specific way, because that contradiction is the scenario. + * + * The bible states the shape plainly — "They are briefed on a place and a time and + * a thing to do. They cross. They find that the brief was, as always, incomplete." + * So every mission has a BRIEF, a TRUTH, and a named GAP between them. A generator + * that produced only a situation would produce a location; producing a brief and a + * truth that disagree produces a case. + * + * Content lives in mission-tables.mjs. This file is machinery. + * Pure functions, no Foundry: the same code builds packs in Node and generates live + * missions in the browser. + */ +import { + ANOMALIES, CLASSES, PLACES, DURATION_SPANS, DURATION_VAGUE, GAPS, ADVERSARIES, + NAMES_FIRST, NAMES_LAST, NPC_ROLES, PHASE_KINDS, COMPLICATIONS, CLOCKS, + COHERENCE_HOOKS, RESOLUTIONS, DEBRIEF_QUESTIONS, FAILURE_STATES, GROUPS +} from "./mission-tables.mjs"; + +/* ---------------- picking ---------------- */ +const pick = (arr, r) => arr[Math.floor(r() * arr.length)]; +const pickWeighted = (arr, r) => { + const total = arr.reduce((n, x) => n + (x.weight ?? 1), 0); + let t = r() * total; + for (const x of arr) { t -= (x.weight ?? 1); if (t <= 0) return x; } + return arr.at(-1); +}; +const some = (arr, n, r) => { + const copy = arr.slice(), out = []; + while (out.length < n && copy.length) out.push(copy.splice(Math.floor(r() * copy.length), 1)[0]); + return out; +}; +const between = ([lo, hi], r) => lo + Math.floor(r() * (hi - lo + 1)); + +/** + * A small deterministic PRNG, so a seed reproduces a case exactly. + * + * splitmix32 rather than a plain LCG: with an LCG, seeds 1, 2 and 3 produced the + * SAME gap and the same phase shape, because consecutive small seeds stay + * correlated for the first several draws and every part of a mission is drawn at a + * fixed position in the sequence. The avalanche step decorrelates them. + */ +export function seeded(seed) { + let s = ((Number(seed) || 1) >>> 0) + 0x9e3779b9; + return () => { + s = (s + 0x9e3779b9) >>> 0; + let t = s; + t = Math.imul(t ^ (t >>> 16), 0x21f0aaad) >>> 0; + t = Math.imul(t ^ (t >>> 15), 0x735a2d97) >>> 0; + return ((t ^ (t >>> 15)) >>> 0) / 4294967296; + }; +} + +const CODE_A = ["QUIET", "LOW", "GLASS", "PAPER", "SALT", "IRON", "SOFT", "LONG", "PALE", "SHORT", "DRY", "PLAIN"]; +const CODE_B = ["HARBOUR", "LEDGER", "MARGIN", "TENANT", "ORCHARD", "SIGNAL", "COUNTY", "LANTERN", "TALLY", "VERGE", "PARISH", "CUTTING"]; + +const personName = r => `${pick(NAMES_FIRST, r)} ${pick(NAMES_LAST, r)}`; + +/* ---------------- the parts ---------------- */ + +/** People the team will actually talk to. Each wants something and each lies once. */ +function buildCast(r, { adversary, anomaly }) { + const roles = some(NPC_ROLES, 3, r); + const cast = roles.map(role => ({ + name: personName(r), role: role.role, wants: role.wants, lies: role.lies + })); + // A revenant and a duplicate are singular and do not negotiate, so giving them a + // human spokesman who lies "about how many of them there are" was nonsense on two + // counts. Only the collective adversaries get a voice. + if (!["revenant", "duplicate"].includes(adversary.id)) { + cast.push({ + name: personName(r), + role: `speaks for ${adversary.name.toLowerCase()}`, + wants: `to be left to it — ${adversary.motive.replace(/^Somebody/, "somebody")}`, + lies: "about how many of them there are" + }); + } + // Duplicate names read as a clue in a game about duplicates. + const seen = new Set(); + return cast.filter(c => !seen.has(c.name) && seen.add(c.name)); +} + +/** + * Phases. Always an approach and an extraction; the middle is drawn from the kinds + * that suit the anomaly. Cost rises as they get closer, and the phase where the GAP + * is revealed is marked, because that is the beat the whole case turns on. + */ +function buildPhases(r, { anomaly, gap }) { + // A CONTAINMENT phase is compulsory. Without it the spine was approach → search → + // extraction, and "getting out with it" presumed the it had been obtained + // offscreen: the generator produced an approach, not a session. The phase where + // they do the thing they were sent to do is the one the case is about. + const middle = some(PHASE_KINDS.filter(k => !["approach", "extraction", "containment"].includes(k.id)), 2, r); + const kinds = [ + PHASE_KINDS.find(k => k.id === "approach"), + ...middle, + PHASE_KINDS.find(k => k.id === "containment"), + PHASE_KINDS.find(k => k.id === "extraction") + ]; + const revealAt = 1 + Math.floor(r() * 2); // phase 2 or 3: never the first + // One sign per phase, not repeated: picking independently used the same beat in + // three of four phases. + const shuffled = some(anomaly.signs, anomaly.signs.length, r); + const signs = kinds.map((_, i) => shuffled[i % shuffled.length]); + return kinds.map((k, i) => ({ + index: i, + kind: k.id, + name: k.name, + terrain: pick(k.terrains, r), + about: k.verb, + difficulty: i === 0 ? "average" : "difficult", + // The classification modifier belongs to the CROSSING, not to every phase. Both + // applied, so a Wrong-class extraction came out at -35% on top of Difficult, + // which floors a 71% Tradecraft to 1% and makes the case unplayable rather than + // hard. Phases carry their own gentle ramp; the classification bites on the way + // home, where the fiction says it should. + modifier: -(i * 5), + // Costs must be affordable from the starting budget. They totalled 7 against a + // starting 6 for every non-Near class, so half of all cases were arithmetically + // unfinishable before the extraction phase. + costSupply: i === 0 ? 1 : (k.id === "extraction" ? 1 : 2), + costPower: k.id === "interior" ? 1 : 0, + revealsGap: i === revealAt, + reveal: i === revealAt ? gap.reveal : null, + sign: signs[i] + })); +} + +/** A day-by-day account of what has already happened, so the GM has a history. */ +function buildTimeline(r, { anomaly, duration }) { + // The middle rows used to be hard-coded "a week later" / "six days ago", which + // read as nonsense against a nineteen-hour anomaly and contradicted row one in + // better than a quarter of cases. They are relative now, so any duration works. + return [ + { when: `${duration} ago`, what: "It starts. Nobody reports it because nobody has a word for it." }, + { when: "not long after", what: `${pick(anomaly.signs, r)} — noticed, written down, not acted on.` }, + { when: "later still", what: "The first person tells somebody official. The report is filed correctly and slowly." }, + { when: "once the file surfaces", what: `The agency notices. ${anomaly.containment}` }, + { when: "yesterday", what: "The brief is written. It is already out of date when it is signed." }, + { when: "today", what: "The team crosses." } + ]; +} + +/* ---------------- the composer ---------------- */ + +/** + * Build a mission. Pass a seed for a reproducible one. `opts` can pin any part — + * classification, anomaly, adversary, gap — so a GM can generate around a fixed + * idea rather than accept the whole roll. + */ +export function generateMission({ seed = null, rnd = null, ...opts } = {}) { + const r = rnd ?? (seed !== null ? seeded(seed) : Math.random); + + // THE ANOMALY IS THE PARENT KEY. Everything else is filtered to what it can + // actually support. Picking these independently produced a brief ordering a team + // to "recover the object" when the anomaly was a duplicated person, a Hollow + // world ("nobody home") whose containment note read "the town has noticed", and + // gaps whose reveal contradicted the truth outright. The tables did not need more + // content; they needed edges. + const anomaly = opts.anomalyId ? ANOMALIES.find(a => a.id === opts.anomalyId) : pick(ANOMALIES, r); + const fits = anomaly.fits ?? {}; + const only = (arr, allow) => { + const kept = allow?.length ? arr.filter(x => allow.includes(x.id)) : arr; + return kept.length ? kept : arr; // never leave a table empty + }; + + const classification = opts.classId + ? CLASSES.find(c => c.id === opts.classId) + : pickWeighted(only(CLASSES, fits.classes), r); + const adversary = opts.adversaryId + ? ADVERSARIES.find(a => a.id === opts.adversaryId) + : pick(only(ADVERSARIES, fits.adversaries), r); + const gap = opts.gapId ? GAPS.find(g => g.id === opts.gapId) : pick(only(GAPS, fits.gaps), r); + + const placeOptions = PLACES.filter(pl => !fits.placeKinds?.length + || pl.kinds.some(k => fits.placeKinds.includes(k))); + const placeEntry = opts.place ? { text: opts.place, kinds: [] } + : pick(placeOptions.length ? placeOptions : PLACES, r); + const place = placeEntry.text; + + // A span may follow "for"; a vague date may not. Keeping both in one table gave + // "has been open for since the spring" and a timeline row reading "since a date + // nobody will confirm ago". + const span = pick(DURATION_SPANS, r); + const vague = pick(DURATION_VAGUE, r); + const duration = span; + const clock = pick(CLOCKS, r); + + // Every PLACES entry is lowercase and three anomaly templates open with {place}, + // so one brief in five began with a lowercase letter. + const upperFirst = t => t.charAt(0).toUpperCase() + t.slice(1); + const fill = t => upperFirst(t.replace("{place}", place).replace("{duration}", span)); + const codename = `${pick(CODE_A, r)} ${pick(CODE_B, r)}`; + + const mission = { + codename, seed, + classification, + place, duration, + anomaly: { + ...anomaly, + brief: fill(anomaly.brief), + truth: fill(anomaly.truth), + signs: some(anomaly.signs, 3, r), + evidence: some(anomaly.evidence, 2, r) + }, + gap, + texture: some(classification.texture, 2, r), + adversary: { ...adversary, count: between(adversary.count, r) }, + cast: buildCast(r, { adversary, anomaly }), + complication: pick(COMPLICATIONS, r), + clock, + coherenceHooks: some(COHERENCE_HOOKS, 2, r), + resolution: pick(only(RESOLUTIONS, fits.resolutions), r).text, + debriefQuestion: pick(DEBRIEF_QUESTIONS, r), + failure: pick(FAILURE_STATES, r), + group: pick(GROUPS, r), + timeline: buildTimeline(r, { anomaly, duration }), + // Resources are set AFTER the phases exist, from what the phases actually cost. + // They were a guess — 8 for Near, 6 otherwise — against a spine costing 7, so + // every non-Near case was unfinishable before extraction. A budget that + // contradicts its own costs is not difficulty, it is an arithmetic error. + tracks: { containment: 10, resources: 0, clearance: classification.id === "wrong" ? 4 : 6 }, + crossingModifier: classification.modifier, + salvageEra: classification.era + }; + mission.phases = buildPhases(r, mission); + + // Enough to finish on a clean run, plus slack that the clock and complications + // will eat. A harder classification gets less slack, not an impossible sum. + const spine = mission.phases.reduce((n, p) => n + p.costSupply, 0); + const slack = classification.id === "near" ? 3 : (classification.id === "wrong" ? 1 : 2); + mission.tracks.resources = Math.min(10, spine + slack); + mission.phaseCost = spine; + return mission; +} + +/* ---------------- rendering ---------------- */ + +const esc = s => String(s ?? "").replace(/&/g, "&").replace(/ `
    ${xs.map(x => `
  • ${esc(x)}
  • `).join("")}
`; + +/** The player-facing brief. Everything here is true as far as the agency knows. */ +export function briefPage(m) { + return ` +

Classification ${esc(m.classification.name)}. ${esc(m.classification.note)}

+

The brief

+

${esc(m.anomaly.brief)}

+

Containment. ${esc(m.anomaly.containment)}

+

What you are being sent to do

+

${esc(m.resolution)}

+

Known complication

+

${esc(m.complication)}

+

Reported signs

+${ul(m.anomaly.signs)} +

Starting state

+
    +
  • Containment ${m.tracks.containment} · Resources ${m.tracks.resources} · Clearance ${m.tracks.clearance}
  • +
  • Destination knowledge: rumoured — every phase this journey is Difficult.
  • +
  • Return modifier for the classification: ${m.crossingModifier ? `${m.crossingModifier}%` : "none"}
  • +
`; +} + +/** The GM's page. The brief is wrong and this says how. */ +export function truthPage(m) { + return ` +

What is actually happening

+

${esc(m.anomaly.truth)}

+

The gap — ${esc(m.gap.name)}

+

${esc(m.gap.text)}

+

How it surfaces. ${esc(m.gap.reveal)}

+

How it closes

+

${esc(m.anomaly.closes)}

+

If it does not

+

${esc(m.failure)}

+

What the place is like

+${ul(m.texture)} +

Physical evidence

+${ul(m.anomaly.evidence)}`; +} + +export function castPage(m) { + return ` +

${esc(m.adversary.name)} — ${m.adversary.count}

+

Why. ${esc(m.adversary.motive)}

+

How they fight. ${esc(m.adversary.tactics)}

+

Leverage. ${esc(m.adversary.leverage)}

+

Statblock: ${esc(m.adversary.npc)}, tier ${esc(m.adversary.tier)}.

+

${esc(m.group.name)} — ${m.group.population}

+

${esc(m.group.description)}

+

Taboo. ${esc(m.group.taboo.text)}

+

They want. ${esc(m.group.desire.text)}

+

The correct offer. ${esc(m.group.offerForm)}

+

People

+ +${m.cast.map(c => ``).join("")} +
WhoThey areThey wantThey lie
${esc(c.name)}${esc(c.role)}${esc(c.wants)}${esc(c.lies)}
`; +} + +export function phasesPage(m) { + return ` +

Phases

+ +${m.phases.map((p, i) => ` + + +`).join("")} +
#PhaseGroundDiff.ModCost
${i + 1}${esc(p.name)}${esc(p.terrain)}${esc(p.difficulty)}${p.modifier ? `${p.modifier}%` : "—"}−${p.costSupply}${p.costPower ? ` −${p.costPower}` : ""}
+

Each phase is about ${esc(m.phases[0].about)} and so on; the bold row is where the gap surfaces.

+${m.phases.map((p, i) => `

${i + 1}. ${esc(p.name)} — ${esc(p.terrain)}

+

This phase is about ${esc(p.about)}. A sign of it here: ${esc(p.sign)}.

+${p.reveal ? `

The gap surfaces. ${esc(p.reveal)}

` : ""}`).join("")}`; +} + +export function pressurePage(m) { + return ` +

The clock — ${esc(m.clock.name)}

+

Costs ${esc(m.clock.costs)}. Advance it when the team are slow, loud or unlucky.

+
    ${m.clock.steps.map(s => `
  1. ${esc(s)}
  2. `).join("")}
+

What it will cost them

+${ul(m.coherenceHooks)} +

Timeline before they arrive

+ +${m.timeline.map(t => ``).join("")} +
WhenWhat
${esc(t.when)}${esc(t.what)}
+

At the debrief

+

${esc(m.debriefQuestion)}

`; +} + +/** The whole case as journal pages, ready to be written into a compendium. */ +export function missionPages(m) { + return [ + { name: `${m.codename} · Brief`, content: briefPage(m) }, + { name: `${m.codename} · The truth`, content: truthPage(m) }, + { name: `${m.codename} · Opposition`, content: castPage(m) }, + { name: `${m.codename} · Phases`, content: phasesPage(m) }, + { name: `${m.codename} · Pressure`, content: pressurePage(m) } + ]; +} + +/** Plain text, for a terminal or a quick read. */ +export function missionSummary(m) { + return [ + `${m.codename} [${m.classification.name}]`, + ` where ${m.place}`, + ` brief ${m.anomaly.brief}`, + ` truth ${m.anomaly.truth}`, + ` the gap ${m.gap.name} — ${m.gap.text}`, + ` against ${m.adversary.count} × ${m.adversary.name} (${m.adversary.npc})`, + ` clock ${m.clock.name}, costs ${m.clock.costs}`, + ` snag ${m.complication}`, + ` ends ${m.resolution}`, + ` phases ${m.phases.map(p => p.name).join(" → ")}`, + ` cast ${m.cast.map(c => `${c.name} (${c.role})`).join("; ")}` + ].join("\n"); +} diff --git a/tools/rules-text.mjs b/tools/rules-text.mjs new file mode 100644 index 0000000..374a7d9 --- /dev/null +++ b/tools/rules-text.mjs @@ -0,0 +1,519 @@ +/** + * THE RULES, generated from the rules themselves. + * + * Every number in this journal is computed from rules.mjs at build time and every + * name is read from lang/en.json, so the book cannot say one thing while the engine + * does another. That failure has happened five times in this project already; a + * rulebook written by hand would have been the sixth. + * + * Written to be learned in order and abandoned early: pages 1 to 3 are the whole + * playable game, 4 to 6 are what happens when it goes wrong, and the rest is + * reference you look up rather than read. + */ +import { readFile } from "node:fs/promises"; +import { + freeCarryFor, hitPointsFor, majorWoundFor, damageModifierFor, locationMaxHp, + reflexRating, awarenessRating, reactionBaseFrom, reactionLoadFrom, reactionPrintedFrom, + encumbranceFrom, woundEffectsFor, bleedRateFor, + defencePenaltyFor, defenceOutcomeFor, ARTS, powerCostFor, + SCOPE_TIERS, scopeStanceFor, scrutinyFor, authorityOutcomeFor, + dossierTrustFor, widenScopeCostFor, DOSSIER, + crossingArtPoolFor, crossingGrantsArt, crossingCostFor, + REACTION, BLEED, CIRCUMSTANCE, RANGE_LADDER, rangeBandFrom, + MAJOR_WOUND_TABLE, COHERENCE_BANDS, COHERENCE_COSTS, COHERENCE_RECOVERY +} from "../rules.mjs"; + +const LANG = JSON.parse(await readFile(new URL("../lang/en.json", import.meta.url), "utf8")); +const L = key => LANG[key] ?? key; +const pc = n => `${n > 0 ? "+" : ""}${n}%`; + +/* ---------- small builders so the tables below stay readable ---------- */ +const table = (head, rows) => + `${head.map(h => ``).join("")}` + + rows.map(r => `${r.map((c, i) => `${c}`).join("")}`).join("") + + `
${h}
`; + +/* ---------- derived figures, computed not typed ---------- */ +const D = REACTION.die; +const band = 2; // two agents one lane apart, for the odds quoted below +const oddsOneLane = (() => { + let w = 0, t = 0, n = D * D; + for (let a = 1; a <= D; a++) for (let b = 1; b <= D; b++) { + const x = REACTION.laneMultiplier + a; + if (x > b) w++; else if (x === b) t++; + } + return { win: (100 * w / n).toFixed(0), tie: (100 * t / n).toFixed(0) }; +})(); + +const damageLadder = [6, 12, 20, 28, 36, 45, 55, 65, 75] + .map(t => [`${t}`, damageModifierFor(t)]); + +const woundRows = MAJOR_WOUND_TABLE.map(r => { + const eff = woundEffectsFor([{ key: r.key }]); + const bits = []; + if (eff.all) bits.push(`${pc(eff.all)} to everything`); + if (eff.manipulation) bits.push(`${pc(eff.manipulation)} hands, melee and ranged`); + if (eff.physical) bits.push(`${pc(eff.physical)} physical`); + if (eff.perception) bits.push(`${pc(eff.perception)} perception`); + if (eff.communication) bits.push(`${pc(eff.communication)} communication`); + if (eff.movFactor !== 1 && eff.movFactor > 0) bits.push(`MOV x${eff.movFactor}`); + if (eff.bleed) bits.push(`${eff.bleed} hp every round`); + if (eff.lostAction) bits.push("lose your next action"); + if (eff.unconscious) bits.push("unconscious"); + else if (eff.defenceOnly) bits.push("out of the fight, MOV 1"); + return [`${r.min}`, L(`RINGBRP.Wound.${r.key}.title`), bits.join(" · ") || "—"]; +}); + +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}`; + const eff = [b.all ? `${pc(b.all)} everything` : "", b.comm ? `${pc(b.comm)} communication` : ""] + .filter(Boolean).join(", ") || "none"; + return [rng, L(b.label), eff]; +}); + +/* Ceyla, from the shipped pregens — real numbers, so the worked example is checkable */ +const CEYLA = { dex: 10, readiness: 71, con: 12, siz: 13, carried: 15.4, free: 12 }; +const cReflex = reflexRating(CEYLA.dex); +const cAware = awarenessRating(CEYLA.readiness); +const cBase = reactionBaseFrom(CEYLA.dex, CEYLA.readiness); +const cEnc = encumbranceFrom(CEYLA.carried, CEYLA.free); +const cLoad = reactionLoadFrom(cEnc.srLoad); +const cPrinted = reactionPrintedFrom(cBase, cEnc.srLoad); +const cHp = hitPointsFor(CEYLA.con, CEYLA.siz); +const cMw = majorWoundFor(cHp); + +export const RULES_PAGES = [ + +{ name: "1 · The whole game", content: ` +

You are a Custodian. Something is here that should not be. Go there, sort it +out, come back. It is the coming back that gets harder.

+ +

The only rule you must know

+

Everything you attempt has a percentage. Roll 1D100 and get equal to or +under it. That is the whole engine. Everything else is a modifier to that +number or a consequence of the result.

+ +${table(["Roll", "Result", "What it means"], [ + ["01 to 1⁄20 of your skill", "Critical", "the best version of what you wanted"], + ["up to 1⁄5 of your skill", "Special", "notably better than asked for"], + ["up to your skill", "Success", "you do it"], + ["over your skill", "Failure", "you do not"], + ["the top 5% of the range", "Fumble", "you do it wrong, and it costs"] +])} + +

A skill of 60 criticals on 01–03, specials on 04–12, succeeds to 60, and fumbles +on 98–00. The sheet prints those four numbers next to every skill so you never work +them out.

+ +

Two rules override everything. 96 to 99 never succeed, +however good you are, and 00 is always a fumble. So no skill is ever +a certainty: a 100% expert still comes unstuck one roll in twenty. And no skill is +ever hopeless either — the floor is 1%, so there is always the chance of a +critical.

+ +

The three things that change the number

+
    +
  1. Difficulty — set by the GM before you roll. Average leaves it +alone, Difficult halves it, Easy doubles it.
  2. +
  3. Circumstance — the situation, from ${pc(CIRCUMSTANCE[0].mod)} to +${pc(CIRCUMSTANCE.at(-1).mod)}, capped at ±40% however many apply.
  4. +
  5. Your condition — wounds, what you are carrying, and how far you +have drifted from your own reality. All three are printed on the sheet and applied +automatically.
  6. +
+

Difficulty first, then everything else. A skill can never be reduced below 1%.

+ +

Where the numbers come from

+${table(["Thing", "How", "Ceyla, for example"], [ + ["Hit points", "(CON + SIZ) ÷ 2, rounded up", `${CEYLA.con} + ${CEYLA.siz} → ${cHp}`], + ["Major Wound", "half your hit points, rounded up", `${cMw} in one blow`], + ["Damage modifier", "STR + SIZ on a ladder", "see the reference page"], + ["Reaction", "your best route to acting first", `${cPrinted}`] +])} + +

That is enough to play

+

Page 2 is fighting and page 3 is being hurt. Pages 4 and 5 are the setting doing +its work. The last page is a table you look things up in and never read.

` }, + +{ name: "2 · Fighting", content: ` +

Who goes first

+

Your sheet prints one number: Reaction. Add 1D${D} +to it. Highest acts first; a tie goes to the higher DEX. That is it — no +per-weapon arithmetic, and the die is rolled fresh every round.

+ +

Reaction is the better of two routes, because being quick and +being alert are both ways of acting first:

+${table(["Route", "How", "Ceyla"], [ + ["Reflex", `DEX ÷ ${REACTION.dexDivisor}, rounded up`, `${CEYLA.dex} → ${cReflex}`], + ["Awareness", `Readiness ÷ ${REACTION.readinessDivisor}, rounded up`, `${CEYLA.readiness} → ${cAware}`] +])} +

Readiness is the best of your Tradecraft, Insight, Spot or +Listen, floored at INT × 3 so a clever but untrained agent is never flat. Take +the better of the two routes, multiply by ${REACTION.laneMultiplier}, and subtract +what you are carrying. Ceyla: the better of ${cReflex} and ${cAware} is ${cAware}, +× ${REACTION.laneMultiplier} = ${cBase}, less ${cLoad} for +${cEnc.over} kg over her free carry → ${cPrinted}.

+

An analyst can be quicker than a shooter. That is deliberate.

+ +

Moving the marker

+

Only three things move you on the rail during a fight, and they cap at +±${REACTION.modCap}:

+
    +
  • Aim +1 or +2 — spend a round aiming, act earlier the next one. +It is spent when it pays out.
  • +
  • Edge +1 — equipment, a talent, high ground, a warning.
  • +
  • Drag −1 — surprised, in the dark, waist-deep in water.
  • +
+

One lane apart wins about ${oddsOneLane.win}% of the time, ties ${oddsOneLane.tie}%. +Two lanes apart cannot be caught by the die at all.

+ +

Your turn

+

One offensive action, plus as many defensive reactions as you like +at an escalating penalty. Attacking, aiming, taking cover, reloading, giving First +Aid and operating a device each use the action.

+

Movement is free — 15 m if you also act, 30 m if you do not. So +you may move and take cover, but not take cover and shoot.

+ +

Defending

+

Defences are not actions and you may make as many as you like. The catch is that +they get harder, and you may not mix them: having chosen to dodge, you dodge.

+${table(["Defence this round", "Modifier"], + [0,1,2,3].map(n => [`${n + 1}${["st","nd","rd","th"][Math.min(n,3)]}`, + defencePenaltyFor(n) ? `${defencePenaltyFor(n)}%` : "no penalty"]))} +

Dodge uses the skill of that name and avoids the blow. +Parry uses the weapon in your hand and stops it. Standing in front +of three people is therefore possible, and stupid.

+ +

A defence is graded against the blow it answers. It stops a blow +of its own quality or worse outright. Against something better it still helps, but +only by as much as it was outclassed by — so an ordinary parry will not cancel a +critical, it will only reduce it. Only a critical stops a critical.

+${table(["Blow", "vs critical", "vs special", "vs success", "vs failed defence"], + ["critical", "special", "success"].map(atk => + [`${atk}`, ...["critical", "special", "success", "failure"].map(def => { + const o = defenceOutcomeFor(atk, def); + return o.turnedAside ? "turned aside" : `lands as ${o.landsAt}`; + })]))} +

Read the reduced blow as the result for everything that follows: a critical cut +down to a special no longer ignores armour, it merely halves it.

+ +

Access

+

There is no hacking skill and nothing to break into. What an agent has is a +borrowed authority: a named credential belonging to somebody real, +with a scope of things that person could plausibly do, a Trust that erodes as it is +used, and a red flag or two describing how the real owner behaves.

+ +

Inside its scope there is no roll. A facilities contractor opening +a plant room is not an act of intrusion, it is Tuesday. It costs 1 Trust and it works. +What costs you is asking the record to believe something one step beyond what the +person was for.

+ +${table(["What you are asking", "Against a credential for routine work"], + SCOPE_TIERS.slice(1).map(t => { + const stance = scopeStanceFor("routine", t); + return [L(`RINGBRP.Scope.${t}`), + stance === "in-scope" ? "no roll — 1 Trust" + : stance === "stretch" ? "a stretch — roll for it" + : "impossible, at any skill"]; + }))} + +

Two tiers beyond the credential is not difficult, it is impossible. No +percentage on any sheet gets a contractor issuing accounts. That is the rule that +stops one agent quietly owning every system in the setting.

+ +

Behaving unlike them

+

Each red flag you break is ${pc(scrutinyFor(1))} on the roll. The system is not +testing whether you are clever. It is testing whether you are behaving like the +person whose name is on the record.

+ +${table(["Result", "What happens to the credential"], [ + ["Critical", "it works, and Trust goes UP by 1"], + ["Special", "it works and costs nothing"], + ["Success", "it works; Trust down 1"], + ["Failure", "refused; Trust down 1"], + ["Fumble", "burned — and somebody real is going to be asked about it"] +])} +

At 0 Trust the account is flagged and will not pass again until a +scene is spent rehabilitating it. Burned is permanent.

+ +

The dossier

+

A credential is not found, it is assembled. Up to ${DOSSIER.maxItems} dossier +items, each a separate scene using a different skill, and at most +${DOSSIER.maxComputerUse} of them at a keyboard — so access is something the whole +team builds and not one specialist's party trick. A dossier item never makes the +later roll easier; it makes the credential better. Trust starts at +${dossierTrustFor(0)} and rises to ${dossierTrustFor(DOSSIER.maxItems)} with a full +dossier, and the item that establishes what the person is actually for +widens the scope.

+ +

Making it true

+

You may spend Coherence to widen a scope, because reality can be made to remember +that the person always held that duty. The first costs +${widenScopeCostFor(0)} and each one after costs more — ${widenScopeCostFor(1)}, then +${widenScopeCostFor(2)}. It works. It always works.

+

Push a credential past what its Trust will carry and your file and theirs converge +one step, and nothing in this game spends anything to converge them back: +a circular arrives addressed to them; your pension record shows service you did not do; +colleagues are certain you chaired a meeting you did not attend; and at the end, +Registry holds one file where there were two, and yours is the copy.

+ +

Shooting and hitting

+${table(["Range", "Modifier"], RANGE_LADDER.map(r => + [L(`RINGBRP.Range.${r.id}`) === `RINGBRP.Range.${r.id}` ? r.id : L(`RINGBRP.Range.${r.id}`), + r.mod === null ? "not possible" : pc(r.mod)]))} +

Cover penalises, it does not block: solid partial cover is +${pc(-30)} to the attacker and never removes line of sight.

+ +

What a hit does

+${table(["Result", "Damage", "Armour"], [ + ["Critical", "maximum weapon damage, plus a rolled damage modifier", "ignored entirely"], + ["Special", "normal", "counts half, rounded down"], + ["Success", "normal", "full"], + ["Fumble", "none", "you drop or jam it; a firearm needs a round to clear"] +])} +

The critical is the pressure valve. Good armour makes an ordinary hit survivable +and does nothing at all about a critical.

` }, + +{ name: "3 · Getting hurt", content: ` +

Hit points and locations

+

Damage comes off your total and the location it struck. Each +location has its own capacity, a fraction of your total.

+
    +
  • Damage in a location beyond its capacity disables it.
  • +
  • Twice its capacity destroys it.
  • +
+

A disabled leg halves your movement and costs ${pc(-30)} physical; a disabled arm +costs ${pc(-30)} to anything done with hands, which includes shooting. The figure on +the Combat tab colours in as this happens.

+ +

Major Wounds

+

Take half your hit points or more in a single blow and roll +1D10 on this table. Ceyla's threshold is ${cMw}.

+${table(["1D10", "Wound", "What it does to you"], woundRows)} +

These stack, with each other and with a shattered limb, and they are applied to +every roll you make. The Skills tab shows a red bar when you are carrying any.

+ +

Bleeding

+${table(["Wound", "Per round", "Stopped by"], Object.entries(BLEED).map(([k, v]) => + [L(`RINGBRP.Wound.${k}.title`), `${v.perRound} hp`, + v.stop.map(s => `${s.difficulty ? `${s.difficulty} ` : ""}${s.family.replace("_", " ")}`).join(", or ")]))} +

Blood loss is charged at the end of every round and does not stop on its own. +Someone has to spend an action. That is the decision the rule exists to create.

+ +

Healing

+

First Aid restores a few points and closes a bleed. Real recovery takes days at +home, or hours in an autodoc. Nothing in the field puts a destroyed limb back.

` }, + +{ name: "4 · Coherence", content: ` +

You are from here. Every time you do something that only makes sense somewhere +else, you are a little less from here.

+ +

Coherence runs from 10 down to 0. Ten is anchored. Zero is +displaced, and displacement is permanent.

+${table(["Coherence", "Band", "Effect"], cohRows)} +

Communication degrades first and worst. You can still do the job for a while, and +the people you are doing it for stop recognising you long before you stop being +useful.

+ +

What it costs

+${table(["Occurrence", "Cost"], Object.values(COHERENCE_COSTS) + .sort((a, b) => a.cost - b.cost) + .map(c => [L(c.label), `−${c.cost}`]))} +

Charged once per occurrence. The first two losses cost you nothing at all — and +the third drops you out of Anchored, by which point you have stopped watching.

+ +

What brings it back

+

Only at home, and never faster than one step at a time: a debrief ++${COHERENCE_RECOVERY.debrief}, a week of downtime +${COHERENCE_RECOVERY.downtimeWeek}, +time with an anchor +${COHERENCE_RECOVERY.anchorObject}. An anchor is +a person or object that remembers you as you were.

+ +

The way home is never closed

+

Being Unmoored makes the return dearer, never impossible. Another agent may always +aim it for you at no penalty; alone you may always aim it at +${pc(COHERENCE_BANDS.find(b => b.id === "unmoored").returnPenalty)}; and a failed +return does not strand you either — you arrive near your target rather than at it. +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.

` }, + +{ name: "5 · The Quiet Arts", content: ` +

Some agents come back with something. It is not a gift and it is not +training — it is the crossing, left in a person.

+ +

What an Art is

+

A crossing is an induced filing error: you persuade the world you +are somewhere else and it moves you to resolve the contradiction. An Art is the same +trick, performed small and on purpose — a misfiling you commit deliberately.

+

So there are no fireballs. Every Art is a sentence about the record: what is filed +here, what was, what is about to be, and what could be persuaded not to have been.

+ +

Using one

+

Roll under its rating like any skill. It is governed by POW, and your own +Coherence penalises it — the further you have drifted, the worse you +are at the thing that made you drift.

+

The cost is Coherence, and it is charged whether or not it works, +because the misfiling is committed the moment you try. Push an Art past its ordinary +reach and it costs more.

+${table(["Art", "It says", "Ordinary", "Pushed"], + Object.keys(ARTS).map(k => [ + L(`RINGBRP.Art.${k}.name`), + { thread: "what was together is still together", + echo: "when is loosely filed", + hollow: "this is not filed here", + witness: "the record agrees you belong", + unmake: "this was not present", + ledger: "read the record; amend it" }[k], + `−${powerCostFor(k)}`, `−${powerCostFor(k, { pushed: true })}` + ]))} + +

Fumbling one

+

A fumble is not a failure. The misfiling sticks. Something small +and true about the world is now different, and the GM never says which. That is the +cost of doing on purpose the thing the agency spends its budget undoing.

+ +

Where they come from

+

Nobody learns an Art. They are not taught, bought, chosen at +character creation or found in a manual. A character who wants one has exactly one +route: go across, and have the way home go wrong.

+ +

Coming back

+

The return is a second crossing and it has to be aimed. Roll Transposition. +Being Unmoored makes it dearer; another agent can always aim it for you at no +penalty. There is no result that leaves you across.

+${table(["Return", "What happens", "Coherence", "An Art?"], [ + ["Success or better", "home, where and when you meant", "—", "no"], + ["Failure", "you arrive near your target, not at it", + `−${crossingCostFor("failure")}`, "only if you were already Unmoored"], + ["Fumble", "you come back, and not the same", + `−${crossingCostFor("fumble")}`, "always"] +])} + +

What follows you home

+

You do not choose the Art, and the pool is decided by how far you had +already drifted when it went wrong. The worse the state you were in, the +worse the thing that comes back with you.

+${table(["Coherence at the time", "What can arrive"], [ + ["8 and up — Anchored, or Loose", crossingArtPoolFor(10).map(a => L(`RINGBRP.Art.${a}.name`)).join(", ")], + ["4 to 3 — Adrift", crossingArtPoolFor(4).map(a => L(`RINGBRP.Art.${a}.name`)).join(", ")], + ["2 and below — Unmoored", crossingArtPoolFor(1).map(a => L(`RINGBRP.Art.${a}.name`)).join(", ")] +])} +

Which is the trap the setting has been building the whole time: the agents most +likely to come back with an Art are the ones least able to afford using it.

` }, + +{ name: "6 · A case", content: ` +

One case is one session. It has three tracks, and they are the pressure.

+
    +
  • Containment — how well the anomaly is still contained. Falls when +the public sees something, when a body is left where it will be found, when a local +authority gets involved.
  • +
  • Resources — kit, ammunition, favours, cover identities. Spent, +not regained mid-case.
  • +
  • Clearance — how far the agency will back you. Falls when you go +off the brief. At zero the case becomes deniable and you are on your own.
  • +
+ +

The shape of a session

+

Briefing → Approach → Investigation → Confrontation → +Extraction. Each phase has a difficulty, a cost and the chance of a forced +encounter. Agents are briefed on a place, a time and a thing to do. They cross. They +find the brief was, as always, incomplete. They sort it out or they do not. They come +back and are debriefed, and the debrief is where coherence is recovered and where the +GM finds out what the players think they did.

+ +

Afterwards

+

Every skill you used successfully under pressure is ticked. At the end of the case +each ticked skill rolls to improve: beat your current rating on 1D100 and it goes up +1D6. Ticks clear either way.

+

Cases do not need to connect. The running thread of a campaign is not a plot — it +is the four coherence tracks going down.

` }, + +{ name: "7 · Reference", content: ` +

Damage modifier

+

STR + SIZ, added to melee and thrown damage.

+${table(["STR + SIZ", "Modifier"], damageLadder)} + +

Circumstance

+${table(["Situation", "Modifier"], CIRCUMSTANCE.map(c => [L(c.label).replace(/\s*\([^)]*\)/, ""), pc(c.mod)]))} +

Capped at ±40% no matter how many apply.

+ +

Encumbrance

+

Your free carry is STR + MAS in kilogrammes — the same pair that +sets your damage modifier. Over that:

+${table(["Over free carry", "Skills", "MOV", "Reaction"], + [0, 1, 5, 10, 15, 20, 30].map(kg => { + const e = encumbranceFrom(100 + kg, 100); + return [`${kg} kg`, e.skillPenalty ? pc(-e.skillPenalty) : "—", + e.movPenalty ? `−${e.movPenalty}` : "—", + reactionLoadFrom(e.srLoad) ? `−${reactionLoadFrom(e.srLoad)}` : "—"]; + }))} +

Skills and MOV move in whole steps of 10 kg. Reaction is exact — every +kilogramme costs, so no weight is ever free.

+

The skill penalty applies to physical, melee, ranged and manipulation +only. A heavy pack does not make you worse at Knowledge.

+ +

Location capacity

+

A fraction of your total hit points, rounded up. For a ${cHp}-hit-point agent:

+${table(["Location", "Share", "Capacity"], [ + ["Head", "40%", `${locationMaxHp(cHp, 0.4)}`], + ["Chest", "50%", `${locationMaxHp(cHp, 0.5)}`], + ["Abdomen and legs", "42%", `${locationMaxHp(cHp, 0.42)}`], + ["Arms", "35%", `${locationMaxHp(cHp, 0.35)}`] +])} + +

Rolls that ignore your skill

+${table(["Roll", "Result"], [ + ["96–99", "always fails, whatever your skill"], + ["00", "always a fumble"], + ["01", "always at least a critical chance — the floor is 1%"] +])} + +

The turn, in order

+
    +
  1. Roll 1D${D}, add your printed Reaction, apply Aim, Edge or Drag.
  2. +
  3. Highest first. Ties to higher DEX.
  4. +
  5. On your turn: one action, free movement, unlimited defences at a rising penalty.
  6. +
  7. To attack: skill, then difficulty, then range and circumstance, then roll.
  8. +
  9. On a hit: damage, less armour by success level, to a rolled location.
  10. +
  11. Half your hit points in one blow? Roll 1D10 for a Major Wound.
  12. +
  13. End of round: anyone bleeding loses their hit points.
  14. +
` }, + +{ name: "8 · A round, worked through", content: ` +

Ceyla Marspont, agency analyst, at a boathouse door with two Cleaners inside. +Every number here is the one her sheet actually prints.

+ +

Initiative

+

DEX ${CEYLA.dex} gives Reflex ${cReflex}. Her Tradecraft is +${CEYLA.readiness}, giving Awareness ${cAware} — she is not fast, she +is ready. The better route is ${cAware}, × ${REACTION.laneMultiplier} = +${cBase}. She is carrying ${CEYLA.carried} kg against a free carry of ${CEYLA.free}, +so ${cEnc.over} kg over costs her ${cLoad}. Her sheet prints +${cPrinted}.

+

She rolls 1D${D} and gets 4: ${Math.round((cPrinted + 4) * 10) / 10}. +The Cleaners print 12 and roll 5 and 2. Ceyla acts first, comfortably.

+ +

The shot

+

Redaction lance, Energy Weapon (Pistol) at 53%. The Cleaner is 25 m away — +medium range, ${pc(-10)}. Nothing else applies, so she needs +43 or under. Her bands: critical on 01–02, special to 08, success to +43, fumble on 97+.

+

She rolls 07. A special.

+ +

The damage

+

The lance does 1D4+6. She rolls 8. A special means the Cleaner's stab vest counts +half — 2 becomes 1 — so 7 gets through. The location +roll says the leg.

+

The Cleaner has 12 hit points, so a Major Wound needs 6. Seven is more than six: +roll 1D10. It comes up 6 — ${L("RINGBRP.Wound.deepBleeding.title")}. +He is now losing ${bleedRateFor([{ key: "deepBleeding" }])} hit points every +round until someone spends an action on him, and his friend has to decide +whether that someone is going to be him.

+ +

What it cost her

+

The lance is transposed materiel. Using it costs +1 Coherence, charged automatically. Ceyla drops from 10 to 9. Nothing +happens yet — the first two losses are free — which is exactly how the third one +arrives without anybody noticing.

` } + +]; diff --git a/tools/slice-text.mjs b/tools/slice-text.mjs new file mode 100644 index 0000000..48ae07c --- /dev/null +++ b/tools/slice-text.mjs @@ -0,0 +1,204 @@ +/** GM-facing journal for the playtest slice. Content from RINGBRP_v0.5_PLAYTEST_SLICE.md. */ + +export const SLICE_PAGES = [ + { + name: "The Situation", + content: ` +

Brief. Eleven days ago a Custodian team crossed to a Near world on a routine survey and did not come back. Their beacon is still transmitting from a stretch of coast outside a town called Ilfracombe — a town that exists on our Earth too, which is why the case was graded routine in the first place.

+

The anomaly. Something on that coast is holding a crossing open. It should not be possible without a regulator, and the missing team was not issued one. Whatever is doing it has been doing it for eleven days.

+

The objective. Reach the beacon, find out what happened to the first team, and close the crossing. In that order. The agency would like the regulator back; it would prefer the four agents back; it will settle for knowing which.

+

The complication. The team's own regulator is dead. Nobody aboard knows how it starts, and the way home was aimed on the assumption that it would.

+

Cover. Environment Agency coastal survey. It will hold for about a day.

+

Starting state

+
    +
  • Resources 8 · Clearance 6 · Containment 10
  • +
  • Destination knowledge: rumoured — every Navigate roll this journey is Difficult
  • +
  • Four phases on foot. The vehicle is grounded and stays grounded.
  • +
+

What this slice is testing

+

Every subsystem, once each: the case team tracks, first contact, the research chain, the familiarity gradient, and one fight that exercises both marksmanship styles at their proper ranges. If the GM has to invent a number anywhere, that is a defect — write it down.

` + }, + { + name: "The Approach — four phases", + content: ` +

Baseline cost is −1 Supply per leg. All four are on foot.

+ + + + + + +
#TerrainNavModDaysExploredEvent
1Silted river flats, chest-high reedDifficult+0%4yes—
2disused railway embankmentDifficult+10%3yesContact at the end of this leg
3Closed forest district beltDifficult−10%5no—
4Rim-wall foothills, to 2.9 km absoluteDifficult−20%6noRaiders at the midpoint
+

Ordering matters

+

The Quiet Neighbours intercept the team at the end of phase 2, at the field boundary. Resolve the contact before rolling phase 3, so that its rewards apply to the phase they are meant to help with. This was a real defect in an earlier draft: contact used to happen after phase 3, and its rewards were retroactive.

+

Resolution

+

Navigate governs every overland leg. Pilot does not substitute — on a foot leg it does nothing at all.

+
    +
  • Critical — leg complete, no cost, and a Site is found. Only on an unexplored leg; on an explored one, treat as Special.
  • +
  • Special — leg complete, no cost, or half the time. The party chooses.
  • +
  • Success — leg complete at baseline cost.
  • +
  • Failure — leg complete, baseline cost plus one Supply, and half again the elapsed time.
  • +
  • Fumble — the leg is not complete. Baseline cost is still paid, Integrity drops one, half the listed days elapse, and a hazard lands.
  • +
+

Altitude: the foothills top out at 2.9 km absolute. The high-altitude rule needs 4 km. It does not apply anywhere in this slice.

` + }, + { + name: "First Contact — the Quiet Neighbours", + content: ` +

Roughly thirty adults. They live above the ground and consider that the only civilised place to be. Statistics are in the Natives & Threats compendium.

+

The two facts a READ can reveal

+

TABOO — they will not converse with anyone standing on the ground. A person on the ground is prey, or a corpse, or an animal, and the distinction is not important. Addressing them from ground level is the offence. It reads as embarrassment on the party's behalf, not hostility. While the party is on the ground, SIGNAL is at −30% whether or not they know why.

+

DESIRE — medicine. Their own ran out two generations ago, and they have watched the same three illnesses take people ever since.

+

On a critical READ, a third fact: they have watched the party for two days, and they know it is carrying something transposed, because it does not smell of anything.

+

Running the three stages

+

READ — Tomas is the obvious roller: Insight 58, Anthropology 52, Xenology (Baseline) 47.

+

SIGNAL — no shared language, but Tomas has Language (Sign) 42 and the eldest, Ambaro, has 30. That is a weak shared language, so it is opposed. If Ambaro wins, they simply misunderstand — treat it as a failure, not an offence. Alternatives: Persuade 44 or Art at Difficult, or Insight 58 at Difficult.

+

OFFER — Bargain governs, verbal or not. The correct form is a gift passed hand to hand with both parties off the ground. Someone has to climb: Athletics, Average — generic Athletics covers it, there is no separate Climb specialisation on these sheets. Dunn 48 or Tomas 40. Rakhi at nineteen MAS going over a wall is a comedy they will remember for a generation.

+

Antibiotics are the gift. One sealed course is the correct quantity; Tomas carries four in his field medic pack. Correct form is Easy; anything offered from the ground, or any other trade good, is Difficult.

+

The abandoned vehicle is a fixed location, not a reward

+

This is the most important change in v0.6, and it exists because two earlier versions failed on it. The abandoned vehicle holds the control plate, and the control plate is the object the entire research chain depends on. It used to be a Standing +3 reward. The maths said the party would reach it 1.7% of the time.

+

So: the party will reach the vehicle. Contact decides only how fast and at what cost.

+
    +
  • Impression +2 or +3 — immediate escort. No detour, no Supply, no Navigate roll. Sees-The-Long-Way also guides legs 3 and 4: +20% Navigate, and the leg-4 −20% is cancelled.
  • +
  • Impression +1 — one-day detour, no Supply cost. The leg-3 −10% is cancelled.
  • +
  • Impression 0 — three-day detour and −1 Supply. They find it themselves.
  • +
  • Impression −1 or worse — three-day detour, −1 Supply, and the band shadows them: every Stealth and Spot roll on leg 3 is opposed at 70/55.
  • +
+

And one further reward that now carries real weight: at impression +1 or better the party may research inside the band's territory as guests, which costs no Supply. Since the research takes about forty-five days and would otherwise cost 9 Supply, this is often the difference between finishing and starving.

+

Permanent Standing still moves by at most ±1 for any later meeting.

+

Impression arithmetic

+ + + + +
RouteImpression
SIGNAL success + OFFER success or special+2
SIGNAL special + OFFER special+3
SIGNAL critical + OFFER success or better+3
+

One OFFER per encounter. Tomas's four medicine gifts are four encounters' worth, not four attempts at one. Impression cannot be farmed.

+

The climb is Athletics Average. On a failure another character may try in the same encounter. On a fumble the climber falls, takes 1D3, and the band finds it funny — impression is unaffected.

+

Why this is the fastest route

+

+2 is a good encounter; +3 requires a critical at SIGNAL or OFFER — SIGNAL special plus OFFER special is only +2, and an earlier draft got that arithmetic wrong twice. Since the vehicle is no longer gated, +3 being rare is now merely a bonus rather than a scenario-ender.

The quickest way to the object the whole scenario depends on still runs through understanding a taboo about where you are standing. No amount of firepower reaches the vehicle sooner.

` + }, + { + name: "Combat — the cliff path", + content: ` +

Six Cleaners raiders working the foothills for salvage. They want power cells. They are not suicidal.

+

Geometry

+
Cliff path 120 m long, 30 m wide, climbing toward the rim.
+Rifles (2): north lip, 40 m above the floor, 60 m from the party's entry.
+Pistols (4): behind boulders on the floor at 45, 60, 60 and 75 m.
+Cover on the floor at 15, 35, 55 and 80 m — solid partial, attacker −30%.
+It blocks line of sight to the floor shooters but NOT to the lip.
+Party enters at 0 m. Exit at 120 m.
+

At entry the pistols are at 60 m — long band, −30%. The rifles are at 60 m — medium, −10%. That is why the pistol raiders must close, and with the range ladder in place it is now mechanically true rather than merely asserted.

+

Behaviour

+
    +
  1. Round 0 — a rifle puts a warning round into the ground and a raider shouts a demand for the power cells. The gesture at the cells is unambiguous even without the words.
  2. +
  3. If the party complies — they lose 2 Power and the raiders withdraw. This is a legitimate outcome. Do not penalise it. It leaves exactly 4 Power, which is exactly one attempt at the brone, and that is a much better bind than a fight.
  4. +
  5. If the party fights — the rifles engage from the lip. The pistol raiders advance by bounds: 15 m per round while firing, 30 m if they do not fire, moving cover to cover at 80 → 55 → 35 m.
  6. +
  7. Targeting — each raider fires at the nearest visible party member; ties go to the lowest current hit points. This is not optional colour. In an earlier draft it was unstated, and simulation showed it swung the party's casualty rate from 15% to 81%.
  8. +
  9. Morale — when two of the six are down, the rest withdraw with their wounded. Down means hit points at 2 or less, or unconscious, or a Major Wound of 9 or 10.
  10. +
  11. No pursuit past the cliff path.
  12. +
+

What it is testing

+

The rifle raiders are Deliberate — heavy-gravity origin, so POW governs their shot and an aimed round ignores four points of armour. That is the only reason a heavy rifle threatens a the catalogued worlds impact vest at all: against armour 6 an unaimed rifle causes a Major Wound on zero percent of hits. Aimed, it bites.

+

The pistol raiders are Reflex, and are useless until they close. Rakhi answers the lip; Dunn and Tomas answer the floor. The encounter cannot be won by one style alone, and that is the point.

+

The rulings the fight needs — all of them

+

Success levels. A critical deals maximum weapon damage plus a rolled damage modifier and ignores armour entirely. A special deals normal damage and the target's armour counts as half, rounded down. A success is normal damage against full armour. A fumble drops or jams the weapon; a loaded firearm needs one full round to clear.

+

Action economy. One offensive action per round, plus unlimited defensive reactions at the escalating penalty. Attacking, aiming, taking cover, reloading, giving First Aid and operating a device each consume it. Movement does not — 15 m if you also act, 30 m if you do not. So you may move and take cover, but not take cover and shoot.

+

Cover penalises, it does not block. Solid partial cover is −30% to the attacker and never removes line of sight.

+

Rifle cadence. The two Deliberate rifles alternate aiming and firing — aim one round, fire the next. Without that they never activate the armour bypass and the Major Wound table does not fire at all against impact vests. An aimed rifle at 60 m: skill 50, medium −10, aim +30 = 70%, ignoring 4 armour. Against Dunn's vest 4 that is a Major Wound on 40.8% of shots; against an impact vest 6, 19.4%.

+

Pistol positions. They start at 45, 60, 60 and 75 m and bound forward to the cover at 35 m — 15 m per round while firing, 30 m if not. Ignore the 80→55→35 route in earlier drafts; it ran the wrong way.

+

Targeting. Nearest visible, then lowest current hit points, then lowest DEX, then GM's choice. A character who is down is not a legal target while anyone is still standing.

+

Wound checks. The Stamina roll made immediately on taking the wound is the first of the three. Ordinary wound unconsciousness lasts 1D6 × 10 minutes, or until First Aid.

+

What the simulation says

+

Twenty thousand fights per case: with the party in cover and shooting the rifles first, someone goes down 50% of the time and the party wipes 0.2% of the time. Out of cover, someone goes down 88% of the time. Shooting the nearest raider instead of the rifles turns it into a 48% party wipe. Take cover, and shoot the lip.

` + }, + { + name: "Research and the Device", + content: ` +

The project

+

Question: how is a transposed alloy-permeability field initiated?

+

Step 1 — FIELD. Objective: recover an intact regulator control plate from the abandoned vehicle. Resolved in play at the vehicle site. One Engineering (Field Systems) roll by the best-qualified character, Average. Success = intact; failure = cracked; fumble = cracked and −1 Integrity.

+

A cracked plate inserts one 100-hour Average Repair (Electronics) step before step 2 — not a percentage penalty. An earlier draft applied −20% instead, which produced about a thousand expected research days.

+

Step 2 — LABORATORY. Engineering (Field Systems), Difficult, 200 hours, portable kit −20%. Rakhi 16%, Ceyla 10%, combined 24.4% on the first attempt.

+

The calibration ramp is what makes this playable: a retry costs only 40 hours and grants +10% cumulative to everyone on the step.

+ + + + + + +
AttemptPer-attemptCumulativeHours
124.4%24.4%200
240.8%55.2%240
355.2%79.9%280
467.6%93.5%320
+

Step 3 — ARMCHAIR. Research, Average, 200 hours reduced to 160 by Ceyla's agency handset. She rolls 55%.

+

93.5% complete by day 32; expected 25.9 days. Step 3 adds an expected 1.65 attempts and 18.6 days. Steps 2 and 3 together: expected 44.6 days. Note that is an expectation, not a guarantee — there is a 6.5% tail past day 32 on step 2 alone.

+

Research hours are project hours, not per-researcher hours. Two researchers do not halve the calendar; they improve the odds per attempt.

+

Stationary time costs 1 Supply per 5 days — about 9 Supply for the whole project, which the party does not have. They must either run Supply recoveries or accept the band's hospitality, which costs nothing. Roll one hazard per ten research days, ignoring results 2 and 5 (both travel-only; reroll them).

+

The device

+

The brone is stored at Integrity 0. The Discovery does two separate things, and both matter:

+
    +
  1. It unlocks the operate function, which is otherwise not attemptable at all.
  2. +
  3. It drops repair from Difficult-with-no-analogue to Average at −20% familiarity.
  4. +
+

Before the Discovery, Rakhi repairs at 58 halved to 29, minus 50 — floored to 5% (Repair's printed base is 15, so the 5% floor applies). Effectively impossible, which is correct. After it: 58 − 20 = 38%.

+

The target is net Integrity 3, not three successes — a fumble removes a point. That makes it a reflecting walk, not a simple count: expected 8.48 attempts, 33.9 hours. 59.8% done by eight attempts, 94.5% by sixteen.

+

Research does not merely grant permission. It makes the work possible. An earlier draft unlocked the function and left the device broken with no path to fixing it.

+

Diagnose, worked

+

Rakhi's Megastructure Lore is 17. Average difficulty, −20% familiarity because the locals made something similar: 17 − 20 = −3, clamped to 1%. Megastructure Lore's printed base is 0, so the 5% floor does not rescue it. Ceyla's 12 gives the same. Nobody in the catalogued worlds can read builder-grade hardware yet, and the tech gradient is doing exactly its job.

+

After the Discovery, diagnose costs 0 Power and its output widens to include what is on the far side of the wall. This matters: the post-compliance budget is exactly Power 4, and operating costs 4.

+

Beyond the aperture

+

The far side is the outer face of the cliff: hard vacuum, 0.99 gee outward, and a sixteen-hundred-kilometre drop to the shoreline. Only Dunn and Tomas have pressure suits. The post-Discovery diagnose reveals this, so it is information the party can act on rather than a surprise death.

+

Power budget, corrected. The vehicle yields 1D3 Power and is now guaranteed, so complying with the raiders (−2) leaves 5 to 7, not 4. Diagnose after the Discovery is free. Operating costs 4 on success and 1 on a failure or fumble. The rim-wall site is a Power-recovery installation — Repair (Electronics), Average, one day, +2 — so running dry costs days and Supply, never the scenario.

Critical-path invariant: the eye-storm never takes the brone, and Power can never be exhausted to a dead end. Two versions of this scenario died on exactly that kind of single point of failure.

` + }, + { + name: "Reference — bases, masses, budgets", + content: ` +

Printed skill bases

+

The resolution rule floors any skill whose printed base is 5% or more at 5%. A rating is not a base, so these matter.

+ + + + + + + + + + +
SkillBaseSkillBase
Brawl25Navigate10
Melee / Firearm / Energy / Bow5Research1
Bargain, Fast Talk, Insight5Megastructure Lore0
Persuade, Status15Listen, Spot, Athletics25
Language (any)0Track, Stealth10
Repair (any)15Scent0
Science, Anthropology, Xenology1DodgeDEX×2
Engineering, Medicine1Pilot, Spacecraft1
Computer Use, Knowledge5First Aid30
+

Because Repair's base is 15, pre-Discovery repair of the brone floors at 5%, not 1%. Because Megastructure Lore's base is 0, diagnose really does sit at 1%.

+

Masses — for the eye-storm hazard and encumbrance

+ + + + + + + + + +
ItemkgItemkg
Crossing regulator12.5Marksman rifle3.2
Trauma unit14Environment suit8
Engineer's toolkit8Prybar3
Field analysis kit9Impact vest3
Medical kit2Service pistol0.8
Ground scanner1.5Redaction lance0.7
Field medic pack1.2Power cell0.6
Agency handset0.3Medicine (0.5 kg)0.5
+

Heaviest single item, by character: Ceyla field analysis kit 9 · Rakhi the regulator 12.5 · Dunn ground scanner 1.5 · Tomas field medic pack 1.2.

+

Abstract consumables

+

Rations are not itemised. The firestorm hazard costs −1 Supply and the medical kit if carried. The vampire hazard costs −1 Supply per separated character, once, not per day.

+

The Keeper route to the vehicle

+

The Keepers want the location of any death. The party has crossed twelve days of country; if the GM wants something concrete, the silted flats of leg 1 held a drowned Grass Giant. At impression +2 they give up the vehicle's position: one day, no Navigate roll. It is inside the district belt, not beyond it.

+

Research complication 6

+

Replaced. Instead of "something notices", the work draws a scavenger pack: 1D3 Night Hunters — use the Keepers statblock at −20% to everything — arriving 1D6 hours later. They want the food stores, not the researchers.

` + }, + { + name: "GM — running the acceptance test", + content: ` +

This slice exists to be broken. Run it start to finish using only this journal, the compendia and the character sheets, and log every failure.

+
    +
  • [INVENT] — you had to make up a number, a name or a ruling
  • +
  • [LOOKUP] — the value existed but you could not find it
  • +
  • [CONTRA] — two parts disagreed
  • +
  • [BREAK] — a rule produced an absurd or unplayable result
  • +
+

Target: zero INVENT, zero CONTRA. Anything else is a defect for the next version.

+

Out of scope — not defects

+

Advancement between sessions, training, the simweb, publication downtime, psionics, starship travel, vehicle-scale combat, any species other than human, and building a character from scratch. The four explorers are given.

+

Design history worth knowing

+

Two rules in an earlier draft were each correct on their own and together made this scenario impossible: permanent Standing was capped at ±1 per encounter, and the control plate was a Standing +3 reward with only one contact available. The plate had a 0.666% chance of ever appearing. The fix was to separate impression within an encounter from permanent Standing between encounters.

+

That is the sort of thing only a full playthrough finds, which is why this exists.

` + } +];