6 Commits
Author SHA1 Message Date
slaguru666andClaude Opus 5 9b6c61ffcb Turn the custom Dice So Nice skins off
Registering them with "preferred" made them the default for everybody in the
world, which overrides whatever dice a player had already chosen — the system
helping itself to a setting that belongs to the user. Off at the author's
request.

One named constant rather than a settings API: this codebase registers no game
settings at all, and adding that surface, its lang keys and its UI to switch one
feature off is more machinery than the job needs. Flip RB_DICE_ENABLED to true
and everything below it still works.

The textures in dice/ and the generator behind `npm run dice` are left alone.
Nothing reads them while this is false, and deleting work in order to turn a
feature off is a poor trade.

Verified in a running Foundry with Dice So Nice active: "custodians" is no
longer among the registered systems, and preferredSystem has gone back to
"standard" rather than ours.

Version 1.0.6.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-12 20:34:22 +01:00
slaguru666andClaude Opus 5 0852427586 Fix the pause: it would not hide, it was off centre, and it was too small
Three faults in 1.0.4, and the first was the serious one.

  1. It never went away. Core hides the overlay with `#pause { display: none }`
     and shows it with `#pause.paused { display: flex }`. 1.0.4 styled the BARE
     #pause with display:flex, which wins on specificity, so unpausing removed
     the class and changed nothing — GAME PAUSED sat over the table for the rest
     of the session. Every rule here is now scoped to `.paused` and `display` is
     not set anywhere.

  2. It was off centre because I repositioned it. Core already centres it: a
     full-width band at top calc(50vh - 100px), column flex, centred both ways,
     pointer-events none. That layout was correct and 1.0.4 fought it with
     left/bottom/transform. It is left alone now; only the height changes, and
     the top moves up by exactly half the extra so the centring holds.

  3. It was small: a 322x116 panel in the corner. It is now core's full-width
     band at 300px with a 170px dial — against core's own 100px — and the
     caption at 24px to match core's.

Guarded: check-behaviour now parses the stylesheet and fails any rule that sets
display on an unscoped #pause. Verified it bites. It strips CSS comments first,
because the first version of the guard reported the comment explaining the
guard — which is exactly what the template guard did last time, so that is twice
now and the lesson is written down properly.

Verified by driving the real toggle: running → display none, box 0x0, no class;
paused → display flex, 341x302, centre X exactly on the viewport centre;
unpaused again → display none, box 0x0.

Version 1.0.5.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-12 19:31:35 +01:00
slaguru666andClaude Opus 5 ad771ff2b5 A uniselector for the pause
Foundry's pause is a spinning d20 and a caption. This replaces both from CSS
alone, without touching the markup: figure#pause becomes a small navy panel with
a yellow rule along the top, and the <img> becomes the arm of a uniselector.

A uniselector is the stepping switch inside a telephone exchange — an arm that
clicks round one contact at a time, hunting for a free line. It is the right
object for this game. The department's whole business is a machine that says
where things belong, and OPEN DAY is set inside one. Eleven contacts, because
eleven minutes is the interval this setting keeps returning to.

It STEPS rather than sweeps: steps(11) over 7.7s, so a contact every 700ms. A
continuous spin reads as a loading throbber; a discrete click reads as a machine
working through a list, which is what the department is doing while the table is
out of the room. The arm carries a faint trailing sector, because without it a
700ms step is easy to miss and the stepping is the whole idea.

No new text. Foundry's caption is localised and this only restyles it — a string
hardcoded in a stylesheet would be one the lang file could never reach.

Two things the browser had to be asked rather than assumed:

  - Core pins #pause with BOTH top and bottom, so an auto height stretched it to
    180px for a 16px caption. It is pinned here instead and sized to content.
  - The arm is rotated, so getBoundingClientRect returns the diagonal of its own
    square (75px for a 52px element). The layout takes its size from the flex
    basis and never from the measured box.

prefers-reduced-motion stops the movement and keeps the object — a stepping dial
in the corner of the eye is exactly what that preference is asking about.

Version 1.0.4.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-12 19:25:12 +01:00
slaguru666andClaude Opus 5 53481bdfc1 Ship the LAST ADMISSION plates as showable images
A third journal in the adventure — LAST ADMISSION — Show the players — holding
the seven scene plates and the floor plan as eight image pages at player
visibility. Right-click, Show Players, done.

title.show is false and there is no caption, on purpose: the table gets the
picture and nothing else. No heading telling them which room this is, no label
naming the thing on the stairs. The page NAMES exist only for the GM's sidebar
so the right one can be found in a hurry under pressure, and the players never
see them.

Ordered by when each comes up, not by asset number, for the same reason.

The builder throws if a plate is missing from disk, because a missing image is
a blank frame on a projector in front of six people.

Verified in Foundry: eight pages import at OBSERVER, the image loads, and the
rendered page contains no headings, no figcaption and no text at all.

Version 1.0.3.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-12 19:12:35 +01:00
slaguru666andClaude Opus 5 51bd8e618a Audit every remaining sheet and dialog; add the Culland Hall scene
Opened all of it this time, rather than driving the system through its API.

Two more defects of the same family as the NPC sheet, and in the same place —
the types added last:

  - Talent and Art had no TYPES.Item.* label, so their sheets printed the raw
    key "TYPES.Item.talent" under the name.
  - Neither had a branch in item-sheet.hbs or an isTalent/isPower flag, so the
    Detail tab rendered EMPTY over a document holding category, rule, uses per
    case, Coherence cost, push cost and origin. Eleven of thirteen types were
    handled, which is exactly the shape an eye skips.

Both now have a header statline and a full Detail section, and a guard walks
template.json: every declared type must have a label, a flag set in ringbrp.mjs,
and an {{#if isX}} section in the sheet.

One hardening fix: loseCoherence applies the cost and THEN announces it, so
anything that throws in between leaves the actor quietly poorer with no message.
localize() throws on a non-string, which an unrecognised reason key is.

Everything else came back clean, and it is worth recording what was checked so
the next audit knows what "checked" meant: 13 item types opened, all render; the
expedition sheet (4 tabs); all 7 chat cards driven through their real entry
points — roll, coherence, reaction via Combat.rollAll, hit location, weapon
damage, major wound, and contact through a case file that actually has a group;
all 3 dialogs opened by clicking the controls a player clicks. No errors, no raw
localisation keys anywhere.

Also: Culland Hall as a playable Scene. Drawn as SVG rather than generated,
because a room count, a door position and a label are all load-bearing here and
a generator cannot be trusted with any of them — three label-collision passes in
the browser before it was clean, which is the documented cost of doing it this
way. 2800x2000 at 100px to the square so the Foundry grid lands on the grid
drawn into the image; 5ft squares; no walls, because half-done walls are worse
than none and a GM who wants vision can add them in ten minutes.

Verified in Foundry: the scene imports, activates, and draws.

Version 1.0.2.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-12 19:02:18 +01:00
slaguru666andClaude Opus 5 02ec47a351 Fix every NPC sheet, which has never opened
npc-sheet.hbs line 43 read

    {{#if (lookup ../skillsByCategory key).length}}

and Handlebars cannot read a property off a subexpression. That is a PARSE
error, so the template never compiled, so every NPC sheet in the system threw
on click — Natives & Threats, the starter's enemy roster, and the whole LAST
ADMISSION cast. #if already treats an empty array as falsy, so the .length was
never doing anything in the first place.

It was invisible to all four existing guards, and the reason is worth writing
down: check-rules reads .mjs, check-behaviour says in its own header that it
cannot reach sheets, and every play-through in this project has driven the
system through its API rather than by clicking. Nobody had ever opened an NPC
sheet, including me, in any of the sessions that "played through" scenarios
full of NPCs.

So: check-templates.mjs, a fifth guard, compiling every .hbs in about a tenth
of a second, wired into the build ahead of check-behaviour. It also names the
specific shape — a property read off a subexpression — because the raw parse
error says only where. Verified it bites by reintroducing the bug. Handlebars
is a devDependency now.

Opening the sheet then showed a second thing nobody had seen: the NPC skill
rows carry four children and were using the character sheet's seven-column
grid, so the name landed in a 13px column and the whole list overlapped
itself. The NPC row now has its own four-column template.

Verified by opening all six LAST ADMISSION cast sheets with an error hook
attached: six rendered, no errors, and Old Marrow reads HP 20/20, major wound
10, armour 2, Brawl 60%, Melee Weapon (Polearm) 60%.

Version 1.0.1.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-12 18:42:05 +01:00
55 changed files with 710 additions and 117 deletions
+3 -3
View File
@@ -180,9 +180,9 @@ The build runs all three guards before it writes anything:
<!-- guards:start -->
```
check-rules: OK — 87 rules, 20 files scanned, 3 aliases + 41 constant sets checked, 275 formulas verified
check-rules: OK — 87 rules, 21 files scanned, 3 aliases + 41 constant sets checked, 275 formulas verified
check-kits: OK — 25 roles, 10 trades, 218 catalogue items, every kit key resolves, every posting can use what it carries, every loadout distinct
check-lang: OK — en.json, 927 keys, no leaf/branch collisions
check-behaviour: OK — 43 behavioural tests
check-lang: OK — en.json, 945 keys, no leaf/branch collisions
check-behaviour: OK — 46 behavioural tests
```
<!-- guards:end -->
+159
View File
@@ -0,0 +1,159 @@
<svg xmlns="http://www.w3.org/2000/svg" width="2800" height="2000" viewBox="0 0 2800 2000">
<!-- CULLAND HALL — ground floor and grounds, for LAST ADMISSION.
Drawn rather than generated: a generator cannot be trusted with a room count,
a door position or a label, and all three are load-bearing here.
2800x2000 at 100px to the square = 28 x 20 squares of 5ft. -->
<defs>
<style>
.rm { fill:#16215f; stroke:#6674d8; stroke-width:6; }
.rm2 { fill:#101a6e; stroke:#6674d8; stroke-width:6; }
.out { fill:#0f1750; stroke:#4d5ac0; stroke-width:4; }
.wall { stroke:#8f9ae6; stroke-width:10; stroke-linecap:square; }
.door { stroke:#f2ea72; stroke-width:11; stroke-linecap:round; }
.lbl { font-family:'Helvetica Neue',Helvetica,Arial,sans-serif; font-weight:700;
font-size:34px; fill:#f2ea72; letter-spacing:2px; }
.sub { font-family:'Helvetica Neue',Helvetica,Arial,sans-serif;
font-size:24px; fill:#a8b2e3; letter-spacing:1px; }
.num { font-family:'Helvetica Neue',Helvetica,Arial,sans-serif; font-weight:700;
font-size:44px; fill:#0c125b; }
.ttl { font-family:'Helvetica Neue',Helvetica,Arial,sans-serif; font-weight:700;
font-size:54px; fill:#ecefee; letter-spacing:5px; }
.key { font-family:'Helvetica Neue',Helvetica,Arial,sans-serif; font-size:26px; fill:#ecefee; }
.grid { stroke:#233073; stroke-width:1; }
</style>
</defs>
<rect width="2800" height="2000" fill="#0c125b"/>
<!-- one-square grid, so the Foundry grid lands on the drawn one -->
<g class="grid">
<path d="M100 0V2000M200 0V2000M300 0V2000M400 0V2000M500 0V2000M600 0V2000M700 0V2000M800 0V2000M900 0V2000M1000 0V2000M1100 0V2000M1200 0V2000M1300 0V2000M1400 0V2000M1500 0V2000M1600 0V2000M1700 0V2000M1800 0V2000M1900 0V2000M2000 0V2000M2100 0V2000M2200 0V2000M2300 0V2000M2400 0V2000M2500 0V2000M2600 0V2000M2700 0V2000"/>
<path d="M0 100H2800M0 200H2800M0 300H2800M0 400H2800M0 500H2800M0 600H2800M0 700H2800M0 800H2800M0 900H2800M0 1000H2800M0 1100H2800M0 1200H2800M0 1300H2800M0 1400H2800M0 1500H2800M0 1600H2800M0 1700H2800M0 1800H2800M0 1900H2800"/>
</g>
<text x="120" y="120" class="ttl">CULLAND HALL</text>
<text x="120" y="160" class="sub">GROUND FLOOR · THE CULLAND HAUNTING · LAST ADMISSION 22:00</text>
<!-- ================= the house ================= -->
<rect x="400" y="300" width="2000" height="1200" class="rm2"/>
<!-- 5 · Long Gallery -->
<rect x="700" y="400" width="430" height="300" class="rm"/>
<circle cx="760" cy="450" r="30" fill="#f2ea72"/><text x="746" y="465" class="num">5</text>
<text x="730" y="560" class="lbl" font-size="30">LONG GALLERY</text>
<text x="730" y="598" class="sub" font-size="21">strobe · mirrors · no cast at all</text>
<!-- 6 · Ballroom -->
<rect x="700" y="720" width="430" height="360" class="rm"/>
<circle cx="760" cy="770" r="30" fill="#f2ea72"/><text x="746" y="785" class="num">6</text>
<text x="730" y="880" class="lbl" font-size="30">BALLROOM</text>
<text x="730" y="918" class="sub" font-size="21">lights up · fire exit · "thanks for coming"</text>
<!-- 1 · Entrance Hall -->
<rect x="1150" y="1100" width="500" height="400" class="rm"/>
<circle cx="1190" cy="1140" r="30" fill="#f2ea72"/><text x="1176" y="1155" class="num">1</text>
<text x="1188" y="1235" class="lbl" font-size="30">ENTRANCE HALL</text>
<text x="1188" y="1275" class="sub" font-size="21">the WALL PANEL — four names</text>
<rect x="1188" y="1300" width="200" height="26" fill="#f2ea72"/>
<text x="1188" y="1372" class="sub" font-size="21">ticket desk · head count</text>
<!-- main stairs (Edith) -->
<rect x="1660" y="1140" width="170" height="250" class="rm2"/>
<g stroke="#6674d8" stroke-width="4">
<path d="M1660 1180H1830M1660 1220H1830M1660 1260H1830M1660 1300H1830M1660 1340H1830"/>
</g>
<text x="1663" y="1168" class="sub" font-size="21">MAIN STAIRS</text>
<circle cx="1745" cy="1260" r="26" fill="#ff7a5c"/>
<path d="M1650 1265H1600" class="door"/>
<text x="1870" y="1268" class="sub" fill="#ff7a5c">EDITH CRANE stands here</text>
<!-- 2 · The Ward, east corridor -->
<rect x="1700" y="900" width="700" height="200" class="rm"/>
<circle cx="1740" cy="940" r="30" fill="#f2ea72"/><text x="1726" y="955" class="num">2</text>
<text x="1786" y="1005" class="lbl" font-size="30">THE WARD — EAST CORRIDOR</text>
<text x="1786" y="1045" class="sub" font-size="21">Sister Enderby counts</text>
<rect x="2320" y="905" width="16" height="190" fill="#ff7a5c"/>
<text x="1990" y="1160" class="sub" fill="#ff7a5c" font-size="21">JOSH PARDOE is behind this wall →</text>
<!-- 3 · Nursery -->
<rect x="1900" y="400" width="500" height="420" class="rm"/>
<circle cx="1940" cy="440" r="30" fill="#f2ea72"/><text x="1926" y="455" class="num">3</text>
<text x="1990" y="530" class="lbl">THE NURSERY</text>
<text x="1990" y="570" class="sub">Sammy in the wall · hole at ankle height</text>
<rect x="1905" y="700" width="60" height="24" fill="#f2ea72"/>
<!-- green room -->
<rect x="450" y="400" width="220" height="300" class="rm"/>
<text x="466" y="446" class="lbl" font-size="20">GREEN</text>
<text x="466" y="472" class="lbl" font-size="20">ROOM</text>
<text x="466" y="508" class="sub" font-size="19">call sheet</text>
<text x="466" y="534" class="sub" font-size="19">costumes</text>
<text x="466" y="560" class="sub" font-size="19">SET PLANS</text>
<text x="466" y="586" class="sub" font-size="19">pinned up</text>
<!-- control room -->
<rect x="450" y="720" width="220" height="180" class="rm"/>
<text x="466" y="768" class="lbl" font-size="22">CONTROL</text>
<text x="466" y="802" class="sub" font-size="19">Ferriby · comms</text>
<!-- kitchens -->
<rect x="450" y="920" width="220" height="300" class="rm"/>
<text x="466" y="966" class="lbl" font-size="22">KITCHENS</text>
<text x="466" y="1000" class="sub" font-size="19">Sharrock's way</text>
<text x="466" y="1026" class="sub" font-size="19">out — NOT on</text>
<text x="466" y="1052" class="sub" font-size="19">the fire plan</text>
<!-- yard -->
<rect x="200" y="980" width="230" height="240" class="out"/>
<text x="220" y="1030" class="lbl" font-size="28">YARD</text>
<text x="220" y="1070" class="sub">skip · taped hasp</text>
<!-- doors -->
<g class="door">
<path d="M1370 1500H1450"/> <!-- front door -->
<path d="M1130 1180H1150"/> <!-- hall to ballroom side -->
<path d="M915 1080V1100"/> <!-- ballroom to hall run -->
<path d="M1650 1000H1700"/> <!-- hall to ward -->
<path d="M2150 900V820"/> <!-- ward to nursery -->
<path d="M670 560H700"/> <!-- green room to gallery -->
<path d="M670 800H700"/> <!-- control to ballroom -->
<path d="M430 1070H450"/> <!-- kitchens to yard -->
<path d="M915 700V720"/> <!-- gallery to ballroom -->
</g>
<!-- ================= grounds ================= -->
<rect x="200" y="1560" width="2400" height="380" class="out"/>
<text x="230" y="1610" class="sub">THE LAWN — ninety feet of it</text>
<!-- marquee -->
<rect x="1150" y="1560" width="500" height="230" class="rm"/>
<text x="1200" y="1650" class="lbl" font-size="30">MARQUEE</text>
<text x="1200" y="1690" class="sub">queue · box office · muster point</text>
<!-- glasshouse -->
<rect x="2000" y="1600" width="560" height="300" class="rm"/>
<circle cx="2040" cy="1640" r="30" fill="#f2ea72"/><text x="2026" y="1655" class="num">4</text>
<text x="2090" y="1720" class="lbl">THE GLASSHOUSE</text>
<text x="2090" y="1760" class="sub">Old Marrow · the taped line</text>
<path d="M2000 1830H2560" stroke="#f2ea72" stroke-width="6" stroke-dasharray="18 14"/>
<text x="2090" y="1880" class="sub" fill="#f2ea72">he stops here — and tonight he does not</text>
<!-- car park -->
<rect x="250" y="1700" width="620" height="220" class="out"/>
<text x="280" y="1760" class="lbl" font-size="28">CAR PARK</text>
<text x="280" y="1800" class="sub">the van · the tea urn · blue lights</text>
<!-- Marrow's ground: he will not come indoors -->
<path d="M700 1545H2600" stroke="#ff7a5c" stroke-width="8" stroke-dasharray="26 18"/>
<text x="700" y="1530" class="sub" fill="#ff7a5c">OLD MARROW WILL NOT CROSS THIS LINE — inside is a complete answer</text>
<!-- key -->
<g transform="translate(2420,120)">
<rect x="-20" y="-40" width="360" height="230" fill="#101a6e" stroke="#4d5ac0" stroke-width="4"/>
<text x="0" y="0" class="key" fill="#f2ea72">KEY</text>
<rect x="0" y="30" width="34" height="14" fill="#f2ea72"/><text x="50" y="44" class="key">door / opening</text>
<circle cx="17" cy="85" r="14" fill="#ff7a5c"/><text x="50" y="94" class="key">a body, or a person</text>
<path d="M0 135H34" stroke="#ff7a5c" stroke-width="7" stroke-dasharray="12 8"/>
<text x="50" y="144" class="key">Marrow's limit</text>
</g>
</svg>

After

Width:  |  Height:  |  Size: 8.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 131 KiB

+8
View File
@@ -426,6 +426,13 @@ If there is time — and there usually is, because this ends early — she says
## Artwork
**Showing them at the table.** The adventure ships a third journal,
**LAST ADMISSION — Show the players**, holding these as eight image pages at player
visibility. Right-click a page and *Show Players*: the table gets the picture and
nothing else — no heading, no caption, no label naming what they are looking at. The
page names are for the GM's sidebar and the players never see them.
**Style line:** flat editorial vector, deep navy and pale yellow with warm off-white,
1970s British public-information poster, `--style raw --ar 3:2 --v 6`.
@@ -439,6 +446,7 @@ If there is time — and there usually is, because this ends early — she says
| LA-ART-06 | The green room: mirrors, costumes, a clipboard on the door — used on the Act One page, where the call sheet is |
| LA-ART-07 | Edith Crane on the stairs |
| LA-ART-08 | The car park afterwards: a van, a tea urn, blue lights |
| LA-MAP-01 | **Culland Hall floor plan** — drawn, not generated (`art/maps/culland-hall.svg`), and shipped as a playable **Scene** in the adventure: 2800×2000 at 100px to the square, 5ft grid, no walls (add them if you want line-of-sight). |
---
+19 -1
View File
@@ -13,6 +13,8 @@
"TYPES.Item.group": "Group",
"TYPES.Item.vehicle": "Vehicle",
"TYPES.Item.authority": "Authority",
"TYPES.Item.talent": "Talent",
"TYPES.Item.power": "Art",
"RINGBRP.Tab.Access": "Access",
"RINGBRP.New.authority": "New authority",
"RINGBRP.Scope.nothing": "nothing at all",
@@ -925,5 +927,21 @@
"RINGBRP.Gen.RolledChar": "{ch}",
"RINGBRP.Gen.TakeThem": "Keep these",
"RINGBRP.Gen.Nth": "agent {n}",
"RINGBRP.Dialog.Circumstance": "How are the circumstances?"
"RINGBRP.Dialog.Circumstance": "How are the circumstances?",
"RINGBRP.Item.TalentId": "Talent key",
"RINGBRP.Item.TalentCategory": "Category",
"RINGBRP.Item.TalentSummary": "In one line",
"RINGBRP.Item.TalentRule": "What it does",
"RINGBRP.Item.GrantsAction": "Grants action",
"RINGBRP.Item.UsesPerCase": "Uses per case",
"RINGBRP.Item.UsedThisCase": "Used this case",
"RINGBRP.Item.SkillBonusFam": "Bonus to skill",
"RINGBRP.Item.SkillBonusVal": "Bonus",
"RINGBRP.Item.ArtId": "Art key",
"RINGBRP.Item.ArtSummary": "In one line",
"RINGBRP.Item.ArtRule": "What it does",
"RINGBRP.Item.ArtRating": "Rating",
"RINGBRP.Item.PushCost": "Push cost",
"RINGBRP.Item.PushRule": "What pushing does",
"RINGBRP.Item.Origin": "Where it came from"
}
+4 -1
View File
@@ -7,7 +7,7 @@
"build": "node tools/build-packs.mjs && node tools/update-readme.mjs",
"icons": "node tools/make-icons.mjs",
"dice": "node tools/make-dice-textures.mjs",
"check": "node tools/check-rules.mjs && node tools/check-kits.mjs && node tools/check-lang.mjs && node tools/check-behaviour.mjs",
"check": "node tools/check-rules.mjs && node tools/check-kits.mjs && node tools/check-lang.mjs && node tools/check-templates.mjs && node tools/check-behaviour.mjs",
"test": "npm run check",
"readme": "node tools/update-readme.mjs"
},
@@ -17,5 +17,8 @@
"type": "commonjs",
"dependencies": {
"classic-level": "^3.0.0"
},
"devDependencies": {
"handlebars": "^4.7.9"
}
}
+5 -5
View File
@@ -1,5 +1,5 @@
2026/08/12-18:25:14.825703 1726ef000 Recovering log #3
2026/08/12-18:25:14.825849 1726ef000 Level-0 table #5: started
2026/08/12-18:25:14.825995 1726ef000 Level-0 table #5: 4591 bytes OK
2026/08/12-18:25:14.826236 1726ef000 Delete type=0 #3
2026/08/12-18:25:14.826263 1726ef000 Delete type=3 #2
2026/08/12-20:33:19.258824 175abb000 Recovering log #3
2026/08/12-20:33:19.258964 175abb000 Level-0 table #5: started
2026/08/12-20:33:19.259113 175abb000 Level-0 table #5: 4591 bytes OK
2026/08/12-20:33:19.259390 175abb000 Delete type=0 #3
2026/08/12-20:33:19.259432 175abb000 Delete type=3 #2
+1 -1
View File
@@ -1 +1 @@
2026/08/12-18:25:00.997036 17328b000 Delete type=3 #1
2026/08/12-20:33:03.500947 17385b000 Delete type=3 #1
+5 -5
View File
@@ -1,5 +1,5 @@
2026/08/12-18:25:14.845998 172efb000 Recovering log #3
2026/08/12-18:25:14.846266 172efb000 Level-0 table #5: started
2026/08/12-18:25:14.846391 172efb000 Level-0 table #5: 4129 bytes OK
2026/08/12-18:25:14.846614 172efb000 Delete type=0 #3
2026/08/12-18:25:14.846646 172efb000 Delete type=3 #2
2026/08/12-20:33:19.281748 1772df000 Recovering log #3
2026/08/12-20:33:19.282130 1772df000 Level-0 table #5: started
2026/08/12-20:33:19.282266 1772df000 Level-0 table #5: 4129 bytes OK
2026/08/12-20:33:19.282510 1772df000 Delete type=0 #3
2026/08/12-20:33:19.282542 1772df000 Delete type=3 #2
+1 -1
View File
@@ -1 +1 @@
2026/08/12-18:25:01.063788 173a97000 Delete type=3 #1
2026/08/12-20:33:03.577064 17385b000 Delete type=3 #1
+5 -5
View File
@@ -1,5 +1,5 @@
2026/08/12-18:25:14.826938 171ee3000 Recovering log #3
2026/08/12-18:25:14.827153 171ee3000 Level-0 table #5: started
2026/08/12-18:25:14.827483 171ee3000 Level-0 table #5: 31614 bytes OK
2026/08/12-18:25:14.827723 171ee3000 Delete type=0 #3
2026/08/12-18:25:14.827764 171ee3000 Delete type=3 #2
2026/08/12-20:33:19.260549 1772df000 Recovering log #3
2026/08/12-20:33:19.260715 1772df000 Level-0 table #5: started
2026/08/12-20:33:19.261020 1772df000 Level-0 table #5: 31614 bytes OK
2026/08/12-20:33:19.261257 1772df000 Delete type=0 #3
2026/08/12-20:33:19.261283 1772df000 Delete type=3 #2
+1 -1
View File
@@ -1 +1 @@
2026/08/12-18:25:00.998955 172a7f000 Delete type=3 #1
2026/08/12-20:33:03.503203 174067000 Delete type=3 #1
Binary file not shown.
Binary file not shown.
+5 -5
View File
@@ -1,5 +1,5 @@
2026/08/12-18:25:14.839430 1726ef000 Recovering log #3
2026/08/12-18:25:14.839716 1726ef000 Level-0 table #5: started
2026/08/12-18:25:14.840052 1726ef000 Level-0 table #5: 44390 bytes OK
2026/08/12-18:25:14.840621 1726ef000 Delete type=0 #3
2026/08/12-18:25:14.840694 1726ef000 Delete type=3 #2
2026/08/12-20:33:19.273026 175abb000 Recovering log #3
2026/08/12-20:33:19.273575 175abb000 Level-0 table #5: started
2026/08/12-20:33:19.273963 175abb000 Level-0 table #5: 45532 bytes OK
2026/08/12-20:33:19.274265 175abb000 Delete type=0 #3
2026/08/12-20:33:19.274316 175abb000 Delete type=3 #2
+1 -1
View File
@@ -1 +1 @@
2026/08/12-18:25:01.048880 17328b000 Delete type=3 #1
2026/08/12-20:33:03.557918 172843000 Delete type=3 #1
Binary file not shown.
+5 -5
View File
@@ -1,5 +1,5 @@
2026/08/12-18:25:14.812575 1726ef000 Recovering log #3
2026/08/12-18:25:14.814591 1726ef000 Level-0 table #5: started
2026/08/12-18:25:14.816158 1726ef000 Level-0 table #5: 225945 bytes OK
2026/08/12-18:25:14.816434 1726ef000 Delete type=0 #3
2026/08/12-18:25:14.816497 1726ef000 Delete type=3 #2
2026/08/12-20:33:19.245248 1762c7000 Recovering log #3
2026/08/12-20:33:19.246727 1762c7000 Level-0 table #5: started
2026/08/12-20:33:19.248205 1762c7000 Level-0 table #5: 225945 bytes OK
2026/08/12-20:33:19.248498 1762c7000 Delete type=0 #3
2026/08/12-20:33:19.248546 1762c7000 Delete type=3 #2
+1 -1
View File
@@ -1 +1 @@
2026/08/12-18:25:01.033123 172a7f000 Delete type=3 #1
2026/08/12-20:33:03.540045 172843000 Delete type=3 #1
+5 -5
View File
@@ -1,5 +1,5 @@
2026/08/12-18:25:14.822117 173707000 Recovering log #3
2026/08/12-18:25:14.822240 173707000 Level-0 table #5: started
2026/08/12-18:25:14.822383 173707000 Level-0 table #5: 3295 bytes OK
2026/08/12-18:25:14.822727 173707000 Delete type=0 #3
2026/08/12-18:25:14.822771 173707000 Delete type=3 #2
2026/08/12-20:33:19.255310 175abb000 Recovering log #3
2026/08/12-20:33:19.255554 175abb000 Level-0 table #5: started
2026/08/12-20:33:19.255695 175abb000 Level-0 table #5: 3295 bytes OK
2026/08/12-20:33:19.255978 175abb000 Delete type=0 #3
2026/08/12-20:33:19.256022 175abb000 Delete type=3 #2
+1 -1
View File
@@ -1 +1 @@
2026/08/12-18:25:01.058642 173a97000 Delete type=3 #1
2026/08/12-20:33:03.569801 17304f000 Delete type=3 #1
+1 -1
View File
@@ -1 +1 @@
MANIFEST-000007
MANIFEST-000004
+5 -3
View File
@@ -1,3 +1,5 @@
2026/08/12-18:25:14.806453 172efb000 Recovering log #6
2026/08/12-18:25:14.806833 172efb000 Delete type=0 #6
2026/08/12-18:25:14.806857 172efb000 Delete type=3 #4
2026/08/12-20:33:19.232393 175abb000 Recovering log #3
2026/08/12-20:33:19.235807 175abb000 Level-0 table #5: started
2026/08/12-20:33:19.239193 175abb000 Level-0 table #5: 545293 bytes OK
2026/08/12-20:33:19.239934 175abb000 Delete type=0 #3
2026/08/12-20:33:19.240034 175abb000 Delete type=3 #2
+1 -5
View File
@@ -1,5 +1 @@
2026/08/12-18:25:01.665437 16f483000 Recovering log #3
2026/08/12-18:25:01.667727 16f483000 Level-0 table #5: started
2026/08/12-18:25:01.671815 16f483000 Level-0 table #5: 545293 bytes OK
2026/08/12-18:25:01.672145 16f483000 Delete type=0 #3
2026/08/12-18:25:01.672193 16f483000 Delete type=3 #2
2026/08/12-20:33:03.524869 17385b000 Delete type=3 #1
Binary file not shown.
Binary file not shown.
+5 -5
View File
@@ -1,5 +1,5 @@
2026/08/12-18:25:14.829681 173707000 Recovering log #3
2026/08/12-18:25:14.829858 173707000 Level-0 table #5: started
2026/08/12-18:25:14.830111 173707000 Level-0 table #5: 21613 bytes OK
2026/08/12-18:25:14.830352 173707000 Delete type=0 #3
2026/08/12-18:25:14.830387 173707000 Delete type=3 #2
2026/08/12-20:33:19.263420 1762c7000 Recovering log #3
2026/08/12-20:33:19.263582 1762c7000 Level-0 table #5: started
2026/08/12-20:33:19.263817 1762c7000 Level-0 table #5: 21613 bytes OK
2026/08/12-20:33:19.264044 1762c7000 Delete type=0 #3
2026/08/12-20:33:19.264079 1762c7000 Delete type=3 #2
+1 -1
View File
@@ -1 +1 @@
2026/08/12-18:25:01.055569 172a7f000 Delete type=3 #1
2026/08/12-20:33:03.565946 17304f000 Delete type=3 #1
+5 -5
View File
@@ -1,5 +1,5 @@
2026/08/12-18:25:14.841492 1726ef000 Recovering log #3
2026/08/12-18:25:14.841753 1726ef000 Level-0 table #5: started
2026/08/12-18:25:14.842198 1726ef000 Level-0 table #5: 43953 bytes OK
2026/08/12-18:25:14.842469 1726ef000 Delete type=0 #3
2026/08/12-18:25:14.842504 1726ef000 Delete type=3 #2
2026/08/12-20:33:19.275275 175abb000 Recovering log #3
2026/08/12-20:33:19.275814 175abb000 Level-0 table #5: started
2026/08/12-20:33:19.276182 175abb000 Level-0 table #5: 43953 bytes OK
2026/08/12-20:33:19.276489 175abb000 Delete type=0 #3
2026/08/12-20:33:19.276529 175abb000 Delete type=3 #2
+1 -1
View File
@@ -1 +1 @@
2026/08/12-18:25:01.052553 173a97000 Delete type=3 #1
2026/08/12-20:33:03.562330 17385b000 Delete type=3 #1
+5 -5
View File
@@ -1,5 +1,5 @@
2026/08/12-18:25:14.831296 172efb000 Recovering log #3
2026/08/12-18:25:14.831754 172efb000 Level-0 table #5: started
2026/08/12-18:25:14.832094 172efb000 Level-0 table #5: 33484 bytes OK
2026/08/12-18:25:14.832323 172efb000 Delete type=0 #3
2026/08/12-18:25:14.832355 172efb000 Delete type=3 #2
2026/08/12-20:33:19.264705 176ad3000 Recovering log #3
2026/08/12-20:33:19.265215 176ad3000 Level-0 table #5: started
2026/08/12-20:33:19.265531 176ad3000 Level-0 table #5: 33484 bytes OK
2026/08/12-20:33:19.265770 176ad3000 Delete type=0 #3
2026/08/12-20:33:19.265802 176ad3000 Delete type=3 #2
+1 -1
View File
@@ -1 +1 @@
2026/08/12-18:25:01.062100 17328b000 Delete type=3 #1
2026/08/12-20:33:03.575038 17304f000 Delete type=3 #1
+5 -5
View File
@@ -1,5 +1,5 @@
2026/08/12-18:25:14.819152 173707000 Recovering log #3
2026/08/12-18:25:14.819603 173707000 Level-0 table #5: started
2026/08/12-18:25:14.819785 173707000 Level-0 table #5: 7590 bytes OK
2026/08/12-18:25:14.820023 173707000 Delete type=0 #3
2026/08/12-18:25:14.820059 173707000 Delete type=3 #2
2026/08/12-20:33:19.251838 175abb000 Recovering log #3
2026/08/12-20:33:19.252367 175abb000 Level-0 table #5: started
2026/08/12-20:33:19.252611 175abb000 Level-0 table #5: 7590 bytes OK
2026/08/12-20:33:19.252941 175abb000 Delete type=0 #3
2026/08/12-20:33:19.252982 175abb000 Delete type=3 #2
+1 -1
View File
@@ -1 +1 @@
2026/08/12-18:25:00.991223 172a7f000 Delete type=3 #1
2026/08/12-20:33:03.494044 17304f000 Delete type=3 #1
+5 -5
View File
@@ -1,5 +1,5 @@
2026/08/12-18:25:14.843251 1726ef000 Recovering log #3
2026/08/12-18:25:14.843315 1726ef000 Level-0 table #5: started
2026/08/12-18:25:14.843666 1726ef000 Level-0 table #5: 16902 bytes OK
2026/08/12-18:25:14.843880 1726ef000 Delete type=0 #3
2026/08/12-18:25:14.843909 1726ef000 Delete type=3 #2
2026/08/12-20:33:19.277727 175abb000 Recovering log #3
2026/08/12-20:33:19.278352 175abb000 Level-0 table #5: started
2026/08/12-20:33:19.278612 175abb000 Level-0 table #5: 16902 bytes OK
2026/08/12-20:33:19.278891 175abb000 Delete type=0 #3
2026/08/12-20:33:19.278925 175abb000 Delete type=3 #2
+1 -1
View File
@@ -1 +1 @@
2026/08/12-18:25:01.057365 1742a3000 Delete type=3 #1
2026/08/12-20:33:03.567928 17385b000 Delete type=3 #1
+5 -5
View File
@@ -1,5 +1,5 @@
2026/08/12-18:25:14.834950 1726ef000 Recovering log #3
2026/08/12-18:25:14.836174 1726ef000 Level-0 table #5: started
2026/08/12-18:25:14.836766 1726ef000 Level-0 table #5: 82299 bytes OK
2026/08/12-18:25:14.837010 1726ef000 Delete type=0 #3
2026/08/12-18:25:14.837044 1726ef000 Delete type=3 #2
2026/08/12-20:33:19.268400 175abb000 Recovering log #3
2026/08/12-20:33:19.269480 175abb000 Level-0 table #5: started
2026/08/12-20:33:19.270081 175abb000 Level-0 table #5: 82299 bytes OK
2026/08/12-20:33:19.270432 175abb000 Delete type=0 #3
2026/08/12-20:33:19.270471 175abb000 Delete type=3 #2
+1 -1
View File
@@ -1 +1 @@
2026/08/12-18:25:01.042866 17328b000 Delete type=3 #1
2026/08/12-20:33:03.550640 174067000 Delete type=3 #1
+5 -5
View File
@@ -1,5 +1,5 @@
2026/08/12-18:25:14.844722 173707000 Recovering log #3
2026/08/12-18:25:14.844812 173707000 Level-0 table #5: started
2026/08/12-18:25:14.844927 173707000 Level-0 table #5: 3739 bytes OK
2026/08/12-18:25:14.845139 173707000 Delete type=0 #3
2026/08/12-18:25:14.845172 173707000 Delete type=3 #2
2026/08/12-20:33:19.280144 176ad3000 Recovering log #3
2026/08/12-20:33:19.280500 176ad3000 Level-0 table #5: started
2026/08/12-20:33:19.280605 176ad3000 Level-0 table #5: 3739 bytes OK
2026/08/12-20:33:19.280818 176ad3000 Delete type=0 #3
2026/08/12-20:33:19.280857 176ad3000 Delete type=3 #2
+1 -1
View File
@@ -1 +1 @@
2026/08/12-18:25:01.037171 17328b000 Delete type=3 #1
2026/08/12-20:33:03.544720 17385b000 Delete type=3 #1
+5 -5
View File
@@ -1,5 +1,5 @@
2026/08/12-18:25:14.820693 171ee3000 Recovering log #3
2026/08/12-18:25:14.820828 171ee3000 Level-0 table #5: started
2026/08/12-18:25:14.821041 171ee3000 Level-0 table #5: 5770 bytes OK
2026/08/12-18:25:14.821296 171ee3000 Delete type=0 #3
2026/08/12-18:25:14.821332 171ee3000 Delete type=3 #2
2026/08/12-20:33:19.253872 1762c7000 Recovering log #3
2026/08/12-20:33:19.254006 1762c7000 Level-0 table #5: started
2026/08/12-20:33:19.254155 1762c7000 Level-0 table #5: 5770 bytes OK
2026/08/12-20:33:19.254377 1762c7000 Delete type=0 #3
2026/08/12-20:33:19.254415 1762c7000 Delete type=3 #2
+1 -1
View File
@@ -1 +1 @@
2026/08/12-18:25:00.993769 1742a3000 Delete type=3 #1
2026/08/12-20:33:03.496700 174067000 Delete type=3 #1
+5 -5
View File
@@ -1,5 +1,5 @@
2026/08/12-18:25:14.828480 1726ef000 Recovering log #3
2026/08/12-18:25:14.828626 1726ef000 Level-0 table #5: started
2026/08/12-18:25:14.828783 1726ef000 Level-0 table #5: 6939 bytes OK
2026/08/12-18:25:14.828982 1726ef000 Delete type=0 #3
2026/08/12-18:25:14.829012 1726ef000 Delete type=3 #2
2026/08/12-20:33:19.262214 175abb000 Recovering log #3
2026/08/12-20:33:19.262355 175abb000 Level-0 table #5: started
2026/08/12-20:33:19.262536 175abb000 Level-0 table #5: 6939 bytes OK
2026/08/12-20:33:19.262774 175abb000 Delete type=0 #3
2026/08/12-20:33:19.262806 175abb000 Delete type=3 #2
+1 -1
View File
@@ -1 +1 @@
2026/08/12-18:25:01.000633 17328b000 Delete type=3 #1
2026/08/12-20:33:03.505366 174067000 Delete type=3 #1
+5 -5
View File
@@ -1,5 +1,5 @@
2026/08/12-18:25:14.823695 171ee3000 Recovering log #3
2026/08/12-18:25:14.824080 171ee3000 Level-0 table #5: started
2026/08/12-18:25:14.824417 171ee3000 Level-0 table #5: 14767 bytes OK
2026/08/12-18:25:14.824758 171ee3000 Delete type=0 #3
2026/08/12-18:25:14.824810 171ee3000 Delete type=3 #2
2026/08/12-20:33:19.257047 1772df000 Recovering log #3
2026/08/12-20:33:19.257521 1772df000 Level-0 table #5: started
2026/08/12-20:33:19.257788 1772df000 Level-0 table #5: 14767 bytes OK
2026/08/12-20:33:19.258070 1772df000 Delete type=0 #3
2026/08/12-20:33:19.258099 1772df000 Delete type=3 #2
+1 -1
View File
@@ -1 +1 @@
2026/08/12-18:25:00.995275 1742a3000 Delete type=3 #1
2026/08/12-20:33:03.498976 172843000 Delete type=3 #1
+24 -1
View File
@@ -1524,7 +1524,10 @@ export async function loseCoherence(actor, reasonKey, { amount = null, note = ""
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,
// The cost is already applied by this point, so anything that throws here loses
// the announcement and leaves the actor quietly poorer. localize() throws on a
// non-string, which an unrecognised reason will be.
reason: game.i18n.localize(String(spec?.label ?? reasonKey ?? "")), note,
band: bAfter, crossed: bBefore.id !== bAfter.id,
pips: Array.from({ length: Number(actor.system.coherence?.max ?? 10) }, (_, i) => i < after)
})
@@ -4582,7 +4585,22 @@ function rbDressDice(roll, actor) {
return roll;
}
/**
* The custom Dice So Nice skins are OFF.
*
* Registering them with "preferred" makes them the default for everybody in the world,
* which overrides whatever dice a player had already chosen for themselves — the system
* helping itself to a setting that belongs to the user. Turned off at the author's
* request; flip this to true to put them back, and everything below still works.
*
* The textures in dice/ and the generator behind `npm run dice` are left alone. Nothing
* reads them while this is false, and deleting work to turn a feature off is a poor
* trade.
*/
const RB_DICE_ENABLED = false;
Hooks.once("diceSoNiceReady", async (dice3d) => {
if (!RB_DICE_ENABLED) return;
try {
dice3d.addSystem({ id: "custodians", name: "The Custodians" }, "preferred");
@@ -5514,6 +5532,11 @@ class RingBRPItemSheet extends ItemSheet {
ctx.isMaterial = this.item.type === "material";
ctx.isVehicle = this.item.type === "vehicle";
ctx.isAuthority = this.item.type === "authority";
// Talents and Arts were added after this list and never joined it, so their sheets
// rendered an empty Detail tab over real data — category, rule, uses and cost all
// sat in the document with nothing on screen reading them.
ctx.isTalent = this.item.type === "talent";
ctx.isPower = this.item.type === "power";
ctx.descriptionHTML = await rbEnrich(ctx.sys.description ?? "");
if (ctx.isGear) {
+112
View File
@@ -410,11 +410,19 @@
column-fill: balance;
}
.rb-skills { list-style: none; margin: 0; padding: 0; }
/* Seven columns: tick, icon, name, value, bands, edit, delete. The NPC sheet's rows
carry four children, not seven, so it gets its own template below — sharing this one
put the name in a 13px column and the value in a 20px one, and the whole list
overlapped itself. Nobody saw it because nobody had ever opened an NPC sheet. */
.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);
}
/* name · value · edit · delete */
.rb-npc .rb-skill {
grid-template-columns: minmax(0,1fr) 46px 15px 15px;
}
.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); }
@@ -1676,3 +1684,107 @@
.rb-dying { border-left: 3px solid var(--rb-danger); padding-left: 8px; }
.rb-stable { border-left: 3px solid var(--rb-yellow); padding-left: 8px; }
.rb-dead { border-left: 3px solid var(--rb-white); padding-left: 8px; }
/* ============================================================
THE PAUSE — a uniselector, stepping
============================================================
Foundry's pause is <figure id="pause"> holding an <img> and a <figcaption>, and core
hides it with `#pause { display: none }` / shows it with `#pause.paused { display:
flex }`. The first version of this styled the bare #pause with display:flex and so
overrode the hide — the panel never went away when the game unpaused. EVERY rule here
that could affect visibility or layout is therefore scoped to `.paused`, and `display`
is not set anywhere.
Core also already centres it: a full-width band, 180px tall, at top calc(50vh - 100px),
column flex, centred both ways, pointer-events none. That layout is correct and is
left alone — only its size and its looks change here. The first version repositioned
it with left/bottom and pushed it off centre for the trouble.
A uniselector is the stepping switch inside a telephone exchange: an arm that clicks
round one contact at a time, hunting for a free line. The department's whole business
is a machine that says where things belong, and OPEN DAY is set inside one. Eleven
contacts, because eleven minutes is the interval this setting keeps returning to.
It STEPS rather than sweeps — a contact every 700ms. A continuous spin reads as a
loading throbber; a discrete click reads as a machine working through a list.
============================================================ */
/* Taller than core's 180px, and moved up by exactly half the extra so it stays centred. */
#pause.paused {
height: 300px;
top: calc(50vh - 150px);
gap: 28px;
background: linear-gradient(to right,
transparent 0%,
color-mix(in srgb, var(--rb-void) 82%, transparent) 38%,
color-mix(in srgb, var(--rb-void) 82%, transparent) 62%,
transparent 100%);
}
/* the dial: eleven contacts, standing still, behind the arm */
#pause.paused::before {
content: "";
position: absolute;
top: 24px; left: 50%; margin-left: -85px;
width: 170px; height: 170px;
border-radius: 50%;
background:
repeating-conic-gradient(from -90deg,
var(--rb-line) 0deg 1.6deg, transparent 1.6deg 32.727deg);
-webkit-mask: radial-gradient(circle, transparent 0 52px, #000 54px);
mask: radial-gradient(circle, transparent 0 52px, #000 54px);
opacity: .9;
}
/* the arm. The <img> keeps its box and loses its pixels: object-position walks the
raster out of frame, which leaves a paintable element without adding a wrapper. */
#pause.paused img {
width: 170px; height: 170px;
object-fit: none; object-position: -9999px -9999px;
border: none; box-shadow: none; filter: none;
/* the arm, and behind it the contact it has just left — without the trailing sector
a 700ms step is easy to miss, and the stepping is the whole idea */
background: conic-gradient(from -91deg,
var(--rb-yellow) 0deg 3.5deg,
var(--rb-yellow-dim) 3.5deg 28deg,
transparent 28deg 360deg);
-webkit-mask: radial-gradient(circle, #000 0 78px, transparent 80px);
mask: radial-gradient(circle, #000 0 78px, transparent 80px);
animation: rb-uniselector 7.7s steps(11, end) infinite;
}
/* core adds fa-spin to that img; ours must be the only rotation running */
#pause.paused img.fa-spin { animation: rb-uniselector 7.7s steps(11, end) infinite; }
/* the hub, and the amber that says the record is being written */
#pause.paused::after {
content: "";
position: absolute;
top: 101px; left: 50%; margin-left: -8px;
width: 16px; height: 16px;
border-radius: 50%;
background: var(--rb-yellow);
animation: rb-pause-pip 2.6s ease-in-out infinite;
}
#pause.paused figcaption {
font-family: var(--rb-display);
font-size: 24px;
letter-spacing: .3em;
text-transform: uppercase;
color: var(--rb-yellow);
line-height: 1.2;
text-shadow: 0 2px 18px var(--rb-void);
}
@keyframes rb-uniselector { from { transform: rotate(0deg); } to { transform: rotate(360deg); } }
@keyframes rb-pause-pip { 0%, 100% { opacity: .25; } 50% { opacity: 1; } }
/* A stepping dial in the corner of the eye is exactly what this preference is about.
Keep the object, stop the movement — including core's own pulse on the whole figure. */
@media (prefers-reduced-motion: reduce) {
#pause.paused,
#pause.paused img,
#pause.paused img.fa-spin,
#pause.paused::after { animation: none; }
#pause.paused img { transform: rotate(-32.7deg); }
}
+1 -1
View File
@@ -2,7 +2,7 @@
"id": "ringbrp",
"title": "The Custodians",
"description": "A Basic Roleplaying game of agents who cross the crossing to contain what should not be there — and who come back a little less from here each time.",
"version": "1.0.0",
"version": "1.0.6",
"compatibility": {
"minimum": 13,
"verified": "14.364"
+58
View File
@@ -171,6 +171,28 @@
</section>
{{/if}}
{{#if isTalent}}
<section class="rb-statline">
<div class="rb-stat rb-stat-primary"><span class="rb-stat-label">{{localize "RINGBRP.Item.TalentCategory"}}</span>
<span class="rb-stat-value rb-stat-small">{{sys.category}}</span></div>
<div class="rb-stat"><span class="rb-stat-label">{{localize "RINGBRP.Item.UsesPerCase"}}</span>
<span class="rb-stat-value">{{#if sys.usesPerCase}}{{sys.usedThisCase}} / {{sys.usesPerCase}}{{else}}&mdash;{{/if}}</span></div>
<div class="rb-stat"><span class="rb-stat-label">{{localize "RINGBRP.Coh.Label"}}</span>
<span class="rb-stat-value">{{#if sys.coherenceCost}}{{sys.coherenceCost}}{{else}}&mdash;{{/if}}</span></div>
</section>
{{/if}}
{{#if isPower}}
<section class="rb-statline">
<div class="rb-stat rb-stat-primary"><span class="rb-stat-label">{{localize "RINGBRP.Item.ArtRating"}}</span>
<span class="rb-stat-value">{{sys.value}}%</span></div>
<div class="rb-stat"><span class="rb-stat-label">{{localize "RINGBRP.Coh.Label"}}</span>
<span class="rb-stat-value">{{sys.coherenceCost}}</span></div>
<div class="rb-stat"><span class="rb-stat-label">{{localize "RINGBRP.Item.PushCost"}}</span>
<span class="rb-stat-value">{{sys.pushCost}}</span></div>
</section>
{{/if}}
<nav class="sheet-tabs rb-tabs" data-group="primary">
<a class="item" data-tab="detail">{{localize "RINGBRP.Tab.Detail"}}</a>
<a class="item" data-tab="desc">{{localize "RINGBRP.Tab.Description"}}</a>
@@ -471,6 +493,42 @@
<h3 class="rb-cat-head">{{localize "RINGBRP.Item.BlocksSensors"}}</h3>
<p class="rb-tags">{{#each sys.blocksSensors as |t|}}<span class="rb-tag">{{t}}</span>{{else}}<span class="rb-dim">&mdash;</span>{{/each}}</p>
{{/if}}
{{#if isTalent}}
<h3 class="rb-cat-head">{{localize "RINGBRP.Item.Identity"}}</h3>
<div class="rb-grid2">
<label>{{localize "RINGBRP.Item.TalentId"}}<input type="text" name="system.talentId" value="{{sys.talentId}}"/></label>
<label>{{localize "RINGBRP.Item.TalentCategory"}}<input type="text" name="system.category" value="{{sys.category}}"/></label>
</div>
<label class="rb-wide">{{localize "RINGBRP.Item.TalentSummary"}}<input type="text" name="system.summary" value="{{sys.summary}}"/></label>
<label class="rb-wide">{{localize "RINGBRP.Item.TalentRule"}}<textarea name="system.rule" rows="3">{{sys.rule}}</textarea></label>
<h3 class="rb-cat-head">{{localize "RINGBRP.Tab.Combat"}}</h3>
<div class="rb-grid2">
<label>{{localize "RINGBRP.Item.UsesPerCase"}}<input type="number" name="system.usesPerCase" value="{{sys.usesPerCase}}"/></label>
<label>{{localize "RINGBRP.Item.UsedThisCase"}}<input type="number" name="system.usedThisCase" value="{{sys.usedThisCase}}"/></label>
<label>{{localize "RINGBRP.Coh.Label"}}<input type="number" name="system.coherenceCost" value="{{sys.coherenceCost}}"/></label>
<label>{{localize "RINGBRP.Item.GrantsAction"}}<input type="text" name="system.grantsAction" value="{{sys.grantsAction}}"/></label>
<label>{{localize "RINGBRP.Item.SkillBonusFam"}}<input type="text" name="system.skillBonus.skillFamilyId" value="{{sys.skillBonus.skillFamilyId}}"/></label>
<label>{{localize "RINGBRP.Item.SkillBonusVal"}}<input type="number" name="system.skillBonus.value" value="{{sys.skillBonus.value}}"/></label>
</div>
{{/if}}
{{#if isPower}}
<h3 class="rb-cat-head">{{localize "RINGBRP.Item.Identity"}}</h3>
<div class="rb-grid2">
<label>{{localize "RINGBRP.Item.ArtId"}}<input type="text" name="system.artId" value="{{sys.artId}}"/></label>
<label>{{localize "RINGBRP.Item.ArtRating"}}<input type="number" name="system.value" value="{{sys.value}}"/></label>
</div>
<label class="rb-wide">{{localize "RINGBRP.Item.ArtSummary"}}<input type="text" name="system.summary" value="{{sys.summary}}"/></label>
<label class="rb-wide">{{localize "RINGBRP.Item.ArtRule"}}<textarea name="system.rule" rows="3">{{sys.rule}}</textarea></label>
<h3 class="rb-cat-head">{{localize "RINGBRP.Coh.Label"}}</h3>
<div class="rb-grid2">
<label>{{localize "RINGBRP.Coh.Label"}}<input type="number" name="system.coherenceCost" value="{{sys.coherenceCost}}"/></label>
<label>{{localize "RINGBRP.Item.PushCost"}}<input type="number" name="system.pushCost" value="{{sys.pushCost}}"/></label>
</div>
<label class="rb-wide">{{localize "RINGBRP.Item.PushRule"}}<textarea name="system.pushRule" rows="2">{{sys.pushRule}}</textarea></label>
<label class="rb-wide">{{localize "RINGBRP.Item.Origin"}}<input type="text" name="system.origin" value="{{sys.origin}}"/></label>
{{/if}}
</div>
<div class="tab" data-group="primary" data-tab="desc">
+4 -1
View File
@@ -40,7 +40,10 @@
<section class="sheet-body rb-body">
<div class="tab" data-group="primary" data-tab="skills">
{{#each RINGBRP.categories as |cat key|}}
{{#if (lookup ../skillsByCategory key).length}}
{{! Handlebars cannot read a property off a subexpression — `(lookup a b).length`
is a parse error, and a parse error in a template is a sheet that never opens.
#if already treats an empty array as falsy, so the length was never needed. }}
{{#if (lookup ../skillsByCategory key)}}
<div class="rb-cat">
<h3 class="rb-cat-head">{{localize cat.label}}<span class="rb-catbonus">+{{lookup ../sys.categoryBonus key}}</span>
<a class="rb-add" data-action="item-create" data-type="skill"><i class="fas fa-plus"></i></a></h3>
+92 -4
View File
@@ -461,6 +461,38 @@ function journalDoc(name, key, pages) {
return { doc: { _id: id, name, pages: pageDocs.map(p => p._id), categories: [], ...BASE() }, pages: pageDocs, id };
}
/**
* A journal of nothing but pictures, for showing to players.
*
* title.show is FALSE and there is no caption on purpose: when the GM hits Show Players
* the table gets the image and nothing else — no heading telling them which room this
* is, no label naming the thing they are looking at. The page NAME is for the GM's list
* in the sidebar, so they can find the right one in a hurry; the players never see it.
*/
function imageJournal(name, key, plates) {
const id = idFor(`journal:${key}`);
const pageDocs = plates.map((p, i) => {
// A missing plate is a blank frame on a projector in front of six people.
if (!existsSync(path.join(ROOT, p.src))) throw new Error(`${name}: no plate at ${p.src}`);
return {
_id: idFor(`journal:${key}:${i}`),
name: p.name, type: "image",
title: { show: false, level: 1 },
src: `systems/ringbrp/${p.src}`,
image: { caption: "" },
text: { format: 1, content: "", markdown: "" },
video: { controls: true, volume: 0.5 },
system: {}, sort: (i + 1) * 100, ownership: { default: 2 }, flags: {},
category: null
};
});
return {
doc: { _id: id, name, pages: pageDocs.map(p => p._id), categories: [],
...BASE(), ownership: { default: 2 }, flags: { ringbrp: { brand: true } } },
pages: pageDocs, id
};
}
/* ---------------- writer ---------------- */
/**
@@ -503,6 +535,11 @@ async function writePack(packName, entries) {
// 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" });
// And refuse if a template will not compile. A parse error in a .hbs is a sheet
// that never opens, and no other guard reads templates — npc-sheet.hbs shipped
// broken and every NPC in the system threw on click.
execFileSync(process.execPath, [new URL("check-templates.mjs", import.meta.url).pathname],
{ stdio: "inherit" });
// And refuse if a rule a play-through already found is broken again. The static
// guards cannot tell whether a rule is READ; these tests can.
execFileSync(process.execPath, [new URL("check-behaviour.mjs", import.meta.url).pathname],
@@ -788,6 +825,42 @@ await writePack("armour", ARMOURS.map(a => {
+ `${S.STARTER_PAGES.length} GM pages, ${S.STARTER_HANDOUTS.length} handouts`);
}
/**
* Culland Hall as a playable Scene.
*
* The map is DRAWN (art/maps/culland-hall.svg) rather than generated, because a room
* count, a door position and a label are all load-bearing in this scenario and a
* generator cannot be trusted with any of them. 2800x2000 at 100px to the square, so
* the Foundry grid lands exactly on the grid drawn into the image.
*
* No walls or lighting: the scenario is a conversation with a floor plan behind it, and
* a GM who wants line-of-sight can add them in ten minutes. Shipping half-done walls
* would be worse than shipping none.
*/
function cullandScene() {
const id = idFor("scene:culland_hall");
return {
_id: id,
name: "Culland Hall — ground floor and grounds",
navigation: true, navName: "Culland Hall", active: false,
background: { src: "systems/ringbrp/art/maps/culland-hall.webp" },
foreground: null, thumb: null,
width: 2800, height: 2000, padding: 0.1,
initial: { x: 1400, y: 1000, scale: 0.4 },
backgroundColor: "#0c125b",
grid: { type: 1, size: 100, style: "solidLines", thickness: 1,
color: "#6674d8", alpha: 0.12, distance: 5, units: "ft" },
tokenVision: false, fog: { exploration: false, overlay: null },
globalLight: true, darkness: 0,
environment: { globalLight: { enabled: true }, darknessLevel: 0 },
drawings: [], tokens: [], lights: [], sounds: [], regions: [], templates: [],
tiles: [], walls: [], notes: [],
playlist: null, playlistSound: null, journal: null, journalEntryPage: null,
weather: "", flags: { ringbrp: { key: "culland_hall" } },
...BASE()
};
}
// LAST ADMISSION — the two-hour prologue, and the one to run by default. Same shape as
// OPEN DAY's adventure and for the same reason: journals and cast only, because the
// player characters are the "(before)" pregens in the Pregens pack.
@@ -801,6 +874,19 @@ await writePack("armour", ARMOURS.map(a => {
castDocs.push(actorDoc);
}
// The plates, one per page, for showing at the table. Named for what the GM will be
// looking for under pressure, ordered by when it comes up.
const showJournal = imageJournal("LAST ADMISSION — Show the players", "la_show", [
{ name: "1 · The house, from the drive", src: "art/scenes/la_01_hall.webp" },
{ name: "2 · Floor plan — Culland Hall", src: "art/maps/culland-hall.webp" },
{ name: "3 · Backstage — the green room", src: "art/scenes/la_06_green.webp" },
{ name: "4 · The east corridor", src: "art/scenes/la_03_corridor.webp" },
{ name: "5 · The nursery", src: "art/scenes/la_04_nursery.webp" },
{ name: "6 · The glasshouse — Old Marrow", src: "art/scenes/la_05_glass.webp" },
{ name: "7 · The stairs — the woman standing", src: "art/scenes/la_07_stairs.webp" },
{ name: "8 · The car park, afterwards", src: "art/scenes/la_08_carpark.webp" }
]);
const gmJournal = journalDoc("LAST ADMISSION — Run the prologue", "la_gm", L.LA_PAGES);
const handouts = journalDoc("LAST ADMISSION — Player handouts", "la_handouts", L.LA_HANDOUTS);
handouts.doc.ownership = { default: 2 };
@@ -829,16 +915,18 @@ await writePack("armour", ARMOURS.map(a => {
actors: castDocs,
items: [],
journal: [
{ ...gmJournal.doc, pages: gmJournal.pages },
{ ...handouts.doc, pages: handouts.pages }
{ ...gmJournal.doc, pages: gmJournal.pages },
{ ...handouts.doc, pages: handouts.pages },
{ ...showJournal.doc, pages: showJournal.pages }
],
scenes: [], combats: [], tables: [], macros: [], cards: [], playlists: [], folders: [],
scenes: [cullandScene()], combats: [], tables: [], macros: [], cards: [], playlists: [], folders: [],
sort: 0, ...BASE()
};
await writePack("lastadmission", [[`!adventures!${advId}`, adventure]]);
console.log(` lastadm LAST ADMISSION — ${castDocs.length} cast, `
+ `${L.LA_PAGES.length} GM pages, ${L.LA_HANDOUTS.length} handouts`);
+ `${L.LA_PAGES.length} GM pages, ${L.LA_HANDOUTS.length} handouts, `
+ `${showJournal.pages.length} plates to show`);
}
// OPEN DAY — the prologue. A much smaller adventure than BLACK PLATFORM: no case file,
+50
View File
@@ -398,6 +398,56 @@ test("every compendium pack is declared in exactly one packFolders folder", asyn
assert.equal(dupes.length, 0, `declared in more than one folder: ${dupes.join(", ")}`);
});
// Talent and Art were added to template.json and then forgotten twice over: no
// TYPES.Item.* label, so the sheet printed the raw key under the name, and no branch in
// item-sheet.hbs, so the Detail tab was blank over a document full of real data. Eleven
// of thirteen types were handled, which is exactly the shape a human eye skips.
test("every declared document type has a label and a sheet section", async () => {
const { readFile } = await import("node:fs/promises");
const here = u => new URL(u, import.meta.url);
const tmpl = JSON.parse(await readFile(here("../template.json"), "utf8"));
const lang = JSON.parse(await readFile(here("../lang/en.json"), "utf8"));
const itemSheet = await readFile(here("../templates/item-sheet.hbs"), "utf8");
const system = await readFile(here("../ringbrp.mjs"), "utf8");
for (const doc of ["Actor", "Item"]) {
for (const t of tmpl[doc].types) {
assert.ok(lang[`TYPES.${doc}.${t}`],
`no TYPES.${doc}.${t} in en.json — the sheet prints the raw key`);
}
}
// Item types share one sheet, so each needs its own branch and the flag that drives it.
for (const t of tmpl.Item.types) {
const flag = `is${t[0].toUpperCase()}${t.slice(1)}`;
assert.ok(system.includes(`ctx.${flag} =`),
`the item sheet never sets ${flag}, so its section can never render`);
assert.ok(itemSheet.includes(`{{#if ${flag}}}`),
`item-sheet.hbs has no {{#if ${flag}}} section — Detail will be blank for ${t}`);
}
});
// Core hides the pause overlay with `#pause { display: none }` and shows it with
// `#pause.paused { display: flex }`. A system rule on the BARE #pause that sets display
// wins on specificity and the overlay never goes away — the game unpauses, play carries
// on, and GAME PAUSED sits over the table for the rest of the session. Shipped exactly
// that in 1.0.4. Anything targeting #pause must be scoped to .paused.
test("no stylesheet rule sets display on a bare #pause", async () => {
const { readFile } = await import("node:fs/promises");
const raw = await readFile(new URL("../styles/ringbrp.css", import.meta.url), "utf8");
// Comments first, or this guard reports the comment that explains the guard — which
// is precisely what the template guard did the last time round.
const css = raw.replace(/\/\*[\s\S]*?\*\//g, "");
// every rule whose selector mentions #pause, with its declaration block
for (const m of css.matchAll(/([^{}]*#pause[^{}]*)\{([^{}]*)\}/g)) {
const selector = m[1].trim().replace(/\s+/g, " ");
const body = m[2];
const scoped = selector.split(",").every(sel => !sel.includes("#pause") || /#pause[^,]*\.paused/.test(sel));
if (scoped) continue;
assert.ok(!/(^|;)\s*display\s*:/.test(body),
`"${selector}" sets display on an unscoped #pause — the overlay will not hide on unpause`);
}
});
/* ---------------------------------------------------------------- */
await runAll();
+73
View File
@@ -0,0 +1,73 @@
/**
* TEMPLATE GUARD.
*
* Every .hbs in templates/ must compile. This exists because it did not, and shipped:
* npc-sheet.hbs line 43 read
*
* {{#if (lookup ../skillsByCategory key).length}}
*
* and Handlebars cannot read a property off a subexpression. That is a PARSE error, so
* the template never compiled, so every NPC sheet in the system threw on open — the
* Natives & Threats compendium, the starter's enemy roster, the whole LAST ADMISSION
* cast. It was invisible to every other guard: check-rules reads .mjs, check-behaviour
* says in its own header that it cannot reach sheets, and the play-throughs drove the
* system through its API rather than by clicking, so no NPC sheet was ever opened.
*
* The lesson is narrow and cheap to hold: a template is code, and code that is never
* compiled by the build is code nobody has checked. This compiles all of them in about
* a tenth of a second.
*
* It cannot tell you a template is CORRECT — only that Handlebars will accept it. What
* a sheet looks like once it has data in it is still verified by opening one.
*/
import Handlebars from "handlebars";
import { readFile, readdir } from "node:fs/promises";
import path from "node:path";
import { fileURLToPath } from "node:url";
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
const DIR = path.join(ROOT, "templates");
async function hbsFiles(dir) {
const out = [];
for (const e of await readdir(dir, { withFileTypes: true })) {
const full = path.join(dir, e.name);
if (e.isDirectory()) out.push(...await hbsFiles(full));
else if (e.name.endsWith(".hbs")) out.push(full);
}
return out;
}
const files = (await hbsFiles(DIR)).sort();
const failures = [];
for (const file of files) {
const src = await readFile(file, "utf8");
const rel = path.relative(ROOT, file);
try {
Handlebars.precompile(src);
} catch (err) {
// Handlebars reports the line in its message; keep it, it is the useful part.
failures.push(` ${rel}\n ${String(err.message).split("\n")[0]}`);
continue;
}
// The specific shape that caused this file to exist. Handlebars' own parser catches
// it, but naming it makes the failure say WHY rather than just where. Comments are
// blanked first, keeping line numbers, or this file's own explanation of the bug
// would report the bug.
const scan = src
.replace(/\{\{!--[\s\S]*?--\}\}|\{\{![\s\S]*?\}\}/g, m => m.replace(/[^\n]/g, " "));
const bad = scan.split("\n").findIndex(l => /\)\s*\.[a-zA-Z_]/.test(l));
if (bad >= 0) {
failures.push(` ${rel}:${bad + 1}\n property read off a subexpression — `
+ `Handlebars cannot do (helper x).prop`);
}
}
if (failures.length) {
console.error(`check-templates: ${failures.length} template(s) will not compile\n`
+ failures.join("\n"));
process.exit(1);
}
console.log(`check-templates: OK — ${files.length} templates compile`);