4 Commits
Author SHA1 Message Date
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
29 changed files with 514 additions and 26 deletions
+2 -2
View File
@@ -182,7 +182,7 @@ The build runs all three guards before it writes anything:
```
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 — 44 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"
}
+1 -1
View File
@@ -1 +1 @@
2026/08/12-18:41:45.665018 17412b000 Delete type=3 #1
2026/08/12-19:31:16.133590 1737ab000 Delete type=3 #1
+1 -1
View File
@@ -1 +1 @@
2026/08/12-18:41:45.733783 172907000 Delete type=3 #1
2026/08/12-19:31:16.207708 171f87000 Delete type=3 #1
+1 -1
View File
@@ -1 +1 @@
2026/08/12-18:41:45.666832 17391f000 Delete type=3 #1
2026/08/12-19:31:16.135472 171f87000 Delete type=3 #1
Binary file not shown.
+1 -1
View File
@@ -1 +1 @@
2026/08/12-18:41:45.717519 17412b000 Delete type=3 #1
2026/08/12-19:31:16.192039 171f87000 Delete type=3 #1
+1 -1
View File
@@ -1 +1 @@
2026/08/12-18:41:45.701249 173113000 Delete type=3 #1
2026/08/12-19:31:16.173317 172f9f000 Delete type=3 #1
+1 -1
View File
@@ -1 +1 @@
2026/08/12-18:41:45.728905 173113000 Delete type=3 #1
2026/08/12-19:31:16.202720 1737ab000 Delete type=3 #1
+1 -1
View File
@@ -1 +1 @@
2026/08/12-18:41:45.686790 17412b000 Delete type=3 #1
2026/08/12-19:31:16.157576 172793000 Delete type=3 #1
+1 -1
View File
@@ -1 +1 @@
2026/08/12-18:41:45.725345 17412b000 Delete type=3 #1
2026/08/12-19:31:16.199567 172793000 Delete type=3 #1
+1 -1
View File
@@ -1 +1 @@
2026/08/12-18:41:45.721830 17391f000 Delete type=3 #1
2026/08/12-19:31:16.195999 1737ab000 Delete type=3 #1
+1 -1
View File
@@ -1 +1 @@
2026/08/12-18:41:45.732314 17391f000 Delete type=3 #1
2026/08/12-19:31:16.206279 172f9f000 Delete type=3 #1
+1 -1
View File
@@ -1 +1 @@
2026/08/12-18:41:45.659409 172907000 Delete type=3 #1
2026/08/12-19:31:16.127123 171f87000 Delete type=3 #1
+1 -1
View File
@@ -1 +1 @@
2026/08/12-18:41:45.727208 173113000 Delete type=3 #1
2026/08/12-19:31:16.201269 172793000 Delete type=3 #1
+1 -1
View File
@@ -1 +1 @@
2026/08/12-18:41:45.711308 17412b000 Delete type=3 #1
2026/08/12-19:31:16.184597 172793000 Delete type=3 #1
+1 -1
View File
@@ -1 +1 @@
2026/08/12-18:41:45.705364 17412b000 Delete type=3 #1
2026/08/12-19:31:16.178030 172f9f000 Delete type=3 #1
+1 -1
View File
@@ -1 +1 @@
2026/08/12-18:41:45.662077 172907000 Delete type=3 #1
2026/08/12-19:31:16.129805 171f87000 Delete type=3 #1
+1 -1
View File
@@ -1 +1 @@
2026/08/12-18:41:45.668653 17412b000 Delete type=3 #1
2026/08/12-19:31:16.137478 1737ab000 Delete type=3 #1
+1 -1
View File
@@ -1 +1 @@
2026/08/12-18:41:45.663680 17412b000 Delete type=3 #1
2026/08/12-19:31:16.131540 171f87000 Delete type=3 #1
+9 -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)
})
@@ -5514,6 +5517,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) {
+104
View File
@@ -1684,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.1",
"version": "1.0.5",
"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">
+87 -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 ---------------- */
/**
@@ -793,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.
@@ -806,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 };
@@ -834,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();