Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ab619f4b73 | ||
|
|
9b6c61ffcb | ||
|
|
0852427586 | ||
|
|
ad771ff2b5 | ||
|
|
53481bdfc1 | ||
|
|
51bd8e618a | ||
|
|
02ec47a351 |
@@ -28,7 +28,8 @@ git clone https://gitea.timevans.uk/slaguru666/RingBRP.git ringbrp
|
||||
```
|
||||
|
||||
Requires **Foundry v13+** (verified against v14.364). No modules are required. Dice So
|
||||
Nice is supported but optional — the system registers four of its own dice textures.
|
||||
Nice works if you have it, using your own dice — the system ships no dice skins and does
|
||||
not touch anybody's dice settings.
|
||||
|
||||
### Deploying to a remote Foundry server
|
||||
|
||||
@@ -47,7 +48,7 @@ packs or regenerate art on that machine:
|
||||
```bash
|
||||
npm install # pulls classic-level, used to write the LevelDB packs
|
||||
npm run build # rebuilds packs/ from tools/content.mjs
|
||||
npm run check # the three guards; the build refuses to run if they fail
|
||||
npm run check # the five guards; the build refuses to run if they fail
|
||||
```
|
||||
|
||||
To update a deployed server: `git pull` and restart Foundry. If the pull touches
|
||||
@@ -152,13 +153,12 @@ template.json data model for every Actor and Item type
|
||||
templates/ Handlebars sheets
|
||||
styles/ one stylesheet
|
||||
lang/en.json all user-facing text
|
||||
icons/ dice/ fonts/ generated art
|
||||
icons/ fonts/ art/ generated art
|
||||
packs/ built LevelDB compendia (committed — see Deploying)
|
||||
tools/
|
||||
content.mjs the catalogue: skills, weapons, armour, gear, vehicles, NPCs
|
||||
build-packs.mjs builds packs/ — runs the three guards first and refuses on failure
|
||||
build-packs.mjs builds packs/ — runs the five guards first and refuses on failure
|
||||
make-icons.mjs draws all 231 icons
|
||||
make-dice-textures.mjs
|
||||
rules-text.mjs generates the rules journal FROM rules.mjs
|
||||
mission.mjs the case generator
|
||||
mission-tables.mjs its content tables
|
||||
@@ -176,13 +176,13 @@ stops being identity-equal to what it aliases, if a constant is re-declared as a
|
||||
literal, or if an exported rule has no spot-check. The packs used to be built under
|
||||
one set of numbers and played under another; this makes that impossible to ship.
|
||||
|
||||
The build runs all three guards before it writes anything:
|
||||
The build runs all five guards before it writes anything:
|
||||
|
||||
<!-- guards:start -->
|
||||
```
|
||||
check-rules: OK — 87 rules, 20 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 -->
|
||||
|
||||
@@ -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 |
|
After Width: | Height: | Size: 131 KiB |
|
Before Width: | Height: | Size: 43 KiB |
|
Before Width: | Height: | Size: 15 KiB |
|
Before Width: | Height: | Size: 3.9 KiB |
|
Before Width: | Height: | Size: 1.5 KiB |
|
Before Width: | Height: | Size: 785 B |
|
Before Width: | Height: | Size: 970 B |
|
Before Width: | Height: | Size: 826 B |
@@ -33,10 +33,9 @@ Only if you intend to edit the catalogue there:
|
||||
|
||||
```bash
|
||||
npm install # classic-level, for writing LevelDB
|
||||
npm run check # the three guards
|
||||
npm run check # the five guards
|
||||
npm run build # rebuilds packs/
|
||||
node tools/make-icons.mjs # redraws all icons
|
||||
node tools/make-dice-textures.mjs # regenerates the Dice So Nice textures
|
||||
```
|
||||
|
||||
`npm run build` runs the guards first and aborts if any fails, so a broken catalogue
|
||||
|
||||
@@ -426,6 +426,13 @@ If there is time — and there usually is, because this ends early — she says
|
||||
|
||||
## Artwork
|
||||
|
||||
**Showing them at the table.** The adventure ships a third journal,
|
||||
**LAST ADMISSION — Show the players**, holding these as eight image pages at player
|
||||
visibility. Right-click a page and *Show Players*: the table gets the picture and
|
||||
nothing else — no heading, no caption, no label naming what they are looking at. The
|
||||
page names are for the GM's sidebar and the players never see them.
|
||||
|
||||
|
||||
**Style line:** flat editorial vector, deep navy and pale yellow with warm off-white,
|
||||
1970s British public-information poster, `--style raw --ar 3:2 --v 6`.
|
||||
|
||||
@@ -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). |
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -6,8 +6,7 @@
|
||||
"scripts": {
|
||||
"build": "node tools/build-packs.mjs && node tools/update-readme.mjs",
|
||||
"icons": "node tools/make-icons.mjs",
|
||||
"dice": "node tools/make-dice-textures.mjs",
|
||||
"check": "node tools/check-rules.mjs && node tools/check-kits.mjs && node tools/check-lang.mjs && node tools/check-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 +16,8 @@
|
||||
"type": "commonjs",
|
||||
"dependencies": {
|
||||
"classic-level": "^3.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"handlebars": "^4.7.9"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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-22:44:48.920895 172817000 Recovering log #3
|
||||
2026/08/12-22:44:48.921047 172817000 Level-0 table #5: started
|
||||
2026/08/12-22:44:48.921223 172817000 Level-0 table #5: 4591 bytes OK
|
||||
2026/08/12-22:44:48.921517 172817000 Delete type=0 #3
|
||||
2026/08/12-22:44:48.921554 172817000 Delete type=3 #2
|
||||
|
||||
@@ -1 +1 @@
|
||||
2026/08/12-18:25:00.997036 17328b000 Delete type=3 #1
|
||||
2026/08/12-22:44:33.287733 16f5db000 Delete type=3 #1
|
||||
|
||||
@@ -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-22:44:48.946376 17382f000 Recovering log #3
|
||||
2026/08/12-22:44:48.946637 17382f000 Level-0 table #5: started
|
||||
2026/08/12-22:44:48.946788 17382f000 Level-0 table #5: 4129 bytes OK
|
||||
2026/08/12-22:44:48.947029 17382f000 Delete type=0 #3
|
||||
2026/08/12-22:44:48.947065 17382f000 Delete type=3 #2
|
||||
|
||||
@@ -1 +1 @@
|
||||
2026/08/12-18:25:01.063788 173a97000 Delete type=3 #1
|
||||
2026/08/12-22:44:33.362147 16edcf000 Delete type=3 #1
|
||||
|
||||
@@ -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-22:44:48.922471 173023000 Recovering log #3
|
||||
2026/08/12-22:44:48.923346 173023000 Level-0 table #5: started
|
||||
2026/08/12-22:44:48.923621 173023000 Level-0 table #5: 31614 bytes OK
|
||||
2026/08/12-22:44:48.923809 173023000 Delete type=0 #3
|
||||
2026/08/12-22:44:48.923836 173023000 Delete type=3 #2
|
||||
|
||||
@@ -1 +1 @@
|
||||
2026/08/12-18:25:00.998955 172a7f000 Delete type=3 #1
|
||||
2026/08/12-22:44:33.290003 16f5db000 Delete type=3 #1
|
||||
|
||||
@@ -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-22:44:48.936635 17200b000 Recovering log #3
|
||||
2026/08/12-22:44:48.937301 17200b000 Level-0 table #5: started
|
||||
2026/08/12-22:44:48.937794 17200b000 Level-0 table #5: 45532 bytes OK
|
||||
2026/08/12-22:44:48.938219 17200b000 Delete type=0 #3
|
||||
2026/08/12-22:44:48.938275 17200b000 Delete type=3 #2
|
||||
|
||||
@@ -1 +1 @@
|
||||
2026/08/12-18:25:01.048880 17328b000 Delete type=3 #1
|
||||
2026/08/12-22:44:33.345401 16e5c3000 Delete type=3 #1
|
||||
|
||||
@@ -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-22:44:48.907196 173023000 Recovering log #3
|
||||
2026/08/12-22:44:48.908927 173023000 Level-0 table #5: started
|
||||
2026/08/12-22:44:48.910392 173023000 Level-0 table #5: 225945 bytes OK
|
||||
2026/08/12-22:44:48.910874 173023000 Delete type=0 #3
|
||||
2026/08/12-22:44:48.910934 173023000 Delete type=3 #2
|
||||
|
||||
@@ -1 +1 @@
|
||||
2026/08/12-18:25:01.033123 172a7f000 Delete type=3 #1
|
||||
2026/08/12-22:44:33.327015 16f5db000 Delete type=3 #1
|
||||
|
||||
@@ -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-22:44:48.917345 172817000 Recovering log #3
|
||||
2026/08/12-22:44:48.917660 172817000 Level-0 table #5: started
|
||||
2026/08/12-22:44:48.917794 172817000 Level-0 table #5: 3295 bytes OK
|
||||
2026/08/12-22:44:48.918046 172817000 Delete type=0 #3
|
||||
2026/08/12-22:44:48.918081 172817000 Delete type=3 #2
|
||||
|
||||
@@ -1 +1 @@
|
||||
2026/08/12-18:25:01.058642 173a97000 Delete type=3 #1
|
||||
2026/08/12-22:44:33.356982 16edcf000 Delete type=3 #1
|
||||
|
||||
@@ -1 +1 @@
|
||||
MANIFEST-000007
|
||||
MANIFEST-000004
|
||||
|
||||
@@ -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-22:44:48.896053 17200b000 Recovering log #3
|
||||
2026/08/12-22:44:48.898901 17200b000 Level-0 table #5: started
|
||||
2026/08/12-22:44:48.902279 17200b000 Level-0 table #5: 545293 bytes OK
|
||||
2026/08/12-22:44:48.902701 17200b000 Delete type=0 #3
|
||||
2026/08/12-22:44:48.902787 17200b000 Delete type=3 #2
|
||||
|
||||
@@ -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-22:44:33.311753 16e5c3000 Delete type=3 #1
|
||||
|
||||
@@ -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-22:44:48.925841 17382f000 Recovering log #3
|
||||
2026/08/12-22:44:48.926073 17382f000 Level-0 table #5: started
|
||||
2026/08/12-22:44:48.926304 17382f000 Level-0 table #5: 21613 bytes OK
|
||||
2026/08/12-22:44:48.926557 17382f000 Delete type=0 #3
|
||||
2026/08/12-22:44:48.926598 17382f000 Delete type=3 #2
|
||||
|
||||
@@ -1 +1 @@
|
||||
2026/08/12-18:25:01.055569 172a7f000 Delete type=3 #1
|
||||
2026/08/12-22:44:33.353318 16edcf000 Delete type=3 #1
|
||||
|
||||
@@ -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-22:44:48.939398 17200b000 Recovering log #3
|
||||
2026/08/12-22:44:48.940505 17200b000 Level-0 table #5: started
|
||||
2026/08/12-22:44:48.941454 17200b000 Level-0 table #5: 43953 bytes OK
|
||||
2026/08/12-22:44:48.942198 17200b000 Delete type=0 #3
|
||||
2026/08/12-22:44:48.942249 17200b000 Delete type=3 #2
|
||||
|
||||
@@ -1 +1 @@
|
||||
2026/08/12-18:25:01.052553 173a97000 Delete type=3 #1
|
||||
2026/08/12-22:44:33.349699 16f5db000 Delete type=3 #1
|
||||
|
||||
@@ -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-22:44:48.927292 172817000 Recovering log #3
|
||||
2026/08/12-22:44:48.927647 172817000 Level-0 table #5: started
|
||||
2026/08/12-22:44:48.928175 172817000 Level-0 table #5: 33484 bytes OK
|
||||
2026/08/12-22:44:48.928522 172817000 Delete type=0 #3
|
||||
2026/08/12-22:44:48.928579 172817000 Delete type=3 #2
|
||||
|
||||
@@ -1 +1 @@
|
||||
2026/08/12-18:25:01.062100 17328b000 Delete type=3 #1
|
||||
2026/08/12-22:44:33.360431 16e5c3000 Delete type=3 #1
|
||||
|
||||
@@ -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-22:44:48.913614 172817000 Recovering log #3
|
||||
2026/08/12-22:44:48.914288 172817000 Level-0 table #5: started
|
||||
2026/08/12-22:44:48.914458 172817000 Level-0 table #5: 7590 bytes OK
|
||||
2026/08/12-22:44:48.914739 172817000 Delete type=0 #3
|
||||
2026/08/12-22:44:48.914773 172817000 Delete type=3 #2
|
||||
|
||||
@@ -1 +1 @@
|
||||
2026/08/12-18:25:00.991223 172a7f000 Delete type=3 #1
|
||||
2026/08/12-22:44:33.281949 16edcf000 Delete type=3 #1
|
||||
|
||||
@@ -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-22:44:48.943128 17200b000 Recovering log #3
|
||||
2026/08/12-22:44:48.943292 17200b000 Level-0 table #5: started
|
||||
2026/08/12-22:44:48.943546 17200b000 Level-0 table #5: 16902 bytes OK
|
||||
2026/08/12-22:44:48.943901 17200b000 Delete type=0 #3
|
||||
2026/08/12-22:44:48.943932 17200b000 Delete type=3 #2
|
||||
|
||||
@@ -1 +1 @@
|
||||
2026/08/12-18:25:01.057365 1742a3000 Delete type=3 #1
|
||||
2026/08/12-22:44:33.355279 16e5c3000 Delete type=3 #1
|
||||
|
||||
@@ -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-22:44:48.931234 17200b000 Recovering log #3
|
||||
2026/08/12-22:44:48.932452 17200b000 Level-0 table #5: started
|
||||
2026/08/12-22:44:48.933139 17200b000 Level-0 table #5: 82299 bytes OK
|
||||
2026/08/12-22:44:48.933493 17200b000 Delete type=0 #3
|
||||
2026/08/12-22:44:48.933538 17200b000 Delete type=3 #2
|
||||
|
||||
@@ -1 +1 @@
|
||||
2026/08/12-18:25:01.042866 17328b000 Delete type=3 #1
|
||||
2026/08/12-22:44:33.337758 16f5db000 Delete type=3 #1
|
||||
|
||||
@@ -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-22:44:48.944808 172817000 Recovering log #3
|
||||
2026/08/12-22:44:48.944973 172817000 Level-0 table #5: started
|
||||
2026/08/12-22:44:48.945086 172817000 Level-0 table #5: 3739 bytes OK
|
||||
2026/08/12-22:44:48.945317 172817000 Delete type=0 #3
|
||||
2026/08/12-22:44:48.945349 172817000 Delete type=3 #2
|
||||
|
||||
@@ -1 +1 @@
|
||||
2026/08/12-18:25:01.037171 17328b000 Delete type=3 #1
|
||||
2026/08/12-22:44:33.331276 16e5c3000 Delete type=3 #1
|
||||
|
||||
@@ -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-22:44:48.915783 173023000 Recovering log #3
|
||||
2026/08/12-22:44:48.915922 173023000 Level-0 table #5: started
|
||||
2026/08/12-22:44:48.916069 173023000 Level-0 table #5: 5770 bytes OK
|
||||
2026/08/12-22:44:48.916313 173023000 Delete type=0 #3
|
||||
2026/08/12-22:44:48.916348 173023000 Delete type=3 #2
|
||||
|
||||
@@ -1 +1 @@
|
||||
2026/08/12-18:25:00.993769 1742a3000 Delete type=3 #1
|
||||
2026/08/12-22:44:33.284515 16e5c3000 Delete type=3 #1
|
||||
|
||||
@@ -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-22:44:48.924550 17200b000 Recovering log #3
|
||||
2026/08/12-22:44:48.924684 17200b000 Level-0 table #5: started
|
||||
2026/08/12-22:44:48.924837 17200b000 Level-0 table #5: 6939 bytes OK
|
||||
2026/08/12-22:44:48.925120 17200b000 Delete type=0 #3
|
||||
2026/08/12-22:44:48.925163 17200b000 Delete type=3 #2
|
||||
|
||||
@@ -1 +1 @@
|
||||
2026/08/12-18:25:01.000633 17328b000 Delete type=3 #1
|
||||
2026/08/12-22:44:33.292038 16fde7000 Delete type=3 #1
|
||||
|
||||
@@ -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-22:44:48.918709 173023000 Recovering log #3
|
||||
2026/08/12-22:44:48.919008 173023000 Level-0 table #5: started
|
||||
2026/08/12-22:44:48.919255 173023000 Level-0 table #5: 14767 bytes OK
|
||||
2026/08/12-22:44:48.919627 173023000 Delete type=0 #3
|
||||
2026/08/12-22:44:48.919674 173023000 Delete type=3 #2
|
||||
|
||||
@@ -1 +1 @@
|
||||
2026/08/12-18:25:00.995275 1742a3000 Delete type=3 #1
|
||||
2026/08/12-22:44:33.286188 16e5c3000 Delete type=3 #1
|
||||
|
||||
@@ -1075,7 +1075,6 @@ export async function skillRoll({
|
||||
}
|
||||
);
|
||||
|
||||
rbDressDice(roll, actor);
|
||||
await roll.toMessage({
|
||||
speaker: speaker ?? ChatMessage.getSpeaker({ actor }),
|
||||
content,
|
||||
@@ -1108,7 +1107,7 @@ export async function majorWoundRoll({ actor, woundType = "blunt", monofilament
|
||||
console.error("Custodians | majorWoundRoll called without an actor");
|
||||
return null;
|
||||
}
|
||||
const r = rbDressDice(await new Roll("1d10").evaluate(), actor);
|
||||
const r = await new Roll("1d10").evaluate();
|
||||
let result = r.total;
|
||||
if (monofilament && result >= 4) result = 10; // spec §3.3 shift
|
||||
else if (blunt && result === 10) result = 9;
|
||||
@@ -1171,7 +1170,7 @@ export async function hitLocationRoll({ target, damage, mode = "ranged",
|
||||
}
|
||||
const sys = target.system;
|
||||
const speciesId = sys.speciesProfile || "baseline";
|
||||
const r = rbDressDice(await new Roll("1d20").evaluate(), target);
|
||||
const r = await new Roll("1d20").evaluate();
|
||||
const loc = locationFor(r.total, speciesId, mode);
|
||||
const live = (sys.locations ?? []).find(l => l.id === loc.id);
|
||||
const max = live?.max ?? locationMaxHp(sys.hp?.max ?? 1, loc.frac);
|
||||
@@ -1280,7 +1279,7 @@ export async function rollReaction(combatant) {
|
||||
: reactionPrintedFrom(rb.base, f.loadOverride);
|
||||
const mod = reactionModifier({ aim: f.aim ?? 0, edge: !!f.edge, drag: !!f.drag });
|
||||
|
||||
const roll = rbDressDice(await new Roll(`1d${RINGBRP.reaction.die}`).evaluate(), actor);
|
||||
const roll = await new Roll(`1d${RINGBRP.reaction.die}`).evaluate();
|
||||
const raw = roll.total;
|
||||
const coherence = actor.system?.coherence?.value ?? 10;
|
||||
const shown = fluxDie(raw, coherence);
|
||||
@@ -1524,7 +1523,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)
|
||||
})
|
||||
@@ -2381,7 +2383,7 @@ export async function fall(actor, metres, { soft = false, controlled = false } =
|
||||
content: `<div class="ringbrp-chat">${game.i18n.format("RINGBRP.Fall.Short", { m: metres })}</div>` });
|
||||
return { damage: 0 };
|
||||
}
|
||||
const roll = rbDressDice(await new Roll(plan.formula).evaluate(), actor);
|
||||
const roll = await new Roll(plan.formula).evaluate();
|
||||
await roll.toMessage({ speaker: ChatMessage.getSpeaker({ actor }),
|
||||
flavor: game.i18n.format("RINGBRP.Fall.Flavor", { m: metres, dice: plan.formula }) });
|
||||
// armourApplied true: the fall ignores armour, and this is how we say so.
|
||||
@@ -2414,7 +2416,7 @@ export async function asphyxiate(actor, { held = 0, prepared = true } = {}) {
|
||||
export async function burn(actor, bandId = "clothes", { round = 1 } = {}) {
|
||||
if (!actor) return null;
|
||||
const band = fireBandFor(bandId);
|
||||
const roll = rbDressDice(await new Roll(band.damage).evaluate(), actor);
|
||||
const roll = await new Roll(band.damage).evaluate();
|
||||
// Armour on the round you catch, and never after. A sealed suit is the exception
|
||||
// and the GM should say so by running it as a `spark` that never spreads.
|
||||
const armour = (band.armour && round === 1) ? (Number(actor.system.armourPoints) || 0) : 0;
|
||||
@@ -2433,7 +2435,7 @@ export async function burn(actor, bandId = "clothes", { round = 1 } = {}) {
|
||||
* armour is only ever half of one, at contact.
|
||||
*/
|
||||
export async function detonate({ formula = "4d6", radius = 6, targets = [] } = {}) {
|
||||
const roll = rbDressDice(await new Roll(formula).evaluate(), null);
|
||||
const roll = await new Roll(formula).evaluate();
|
||||
await roll.toMessage({ flavor: game.i18n.format("RINGBRP.Blast.Flavor",
|
||||
{ formula, r: radius }) });
|
||||
const out = [];
|
||||
@@ -4289,7 +4291,7 @@ export async function rollCharacteristicsDialog({ actorName = "" } = {}) {
|
||||
const rollOne = async k => {
|
||||
const cell = el.querySelector(`tr[data-ch="${k}"] .rb-roll-value`);
|
||||
if (!cell || cell.dataset.value) return; // rolled is rolled
|
||||
const roll = rbDressDice(await new Roll(CHARACTERISTIC_DICE[k]).evaluate());
|
||||
const roll = await new Roll(CHARACTERISTIC_DICE[k]).evaluate();
|
||||
await roll.toMessage({
|
||||
flavor: game.i18n.format("RINGBRP.Gen.RolledChar",
|
||||
{ ch: game.i18n.localize(`RINGBRP.Char.${k}`) })
|
||||
@@ -4535,90 +4537,6 @@ Hooks.on("preCreateItem", (item, data) => {
|
||||
* The colourset is chosen per roll from the roller's Coherence, so the dice
|
||||
* themselves report the one number the players are trying not to look at.
|
||||
* ============================================================ */
|
||||
const RB_DICE = {
|
||||
textures: {
|
||||
"custodians-registry": { name: "Registry", file: "custodians-registry.png" },
|
||||
"custodians-drift": { name: "Drift", file: "custodians-drift.png" },
|
||||
"custodians-displaced": { name: "Displaced", file: "custodians-displaced.png" },
|
||||
"custodians-agency": { name: "Agency", file: "custodians-agency.png" }
|
||||
},
|
||||
// one colourset per Coherence band, plus the plain agency set
|
||||
sets: [
|
||||
{ name: "custodians-anchored", description: "Custodians — Anchored",
|
||||
foreground: "#f2ea72", background: "#18268d", outline: "#0c125b",
|
||||
edge: "#6674d8", texture: "custodians-registry", material: "metal" },
|
||||
{ name: "custodians-loose", description: "Custodians — Loose",
|
||||
foreground: "#f2ea72", background: "#16215f", outline: "#0c125b",
|
||||
edge: "#6674d8", texture: "custodians-registry", material: "plastic" },
|
||||
{ name: "custodians-adrift", description: "Custodians — Adrift",
|
||||
foreground: "#ecefee", background: "#141e78", outline: "#0c125b",
|
||||
edge: "#7a6cc8", texture: "custodians-drift", material: "plastic" },
|
||||
{ name: "custodians-unmoored", description: "Custodians — Unmoored",
|
||||
foreground: "#ff7a5c", background: "#101a6e", outline: "#08103f",
|
||||
edge: "#ff7a5c", texture: "custodians-drift", material: "glass" },
|
||||
{ name: "custodians-displaced", description: "Custodians — Displaced",
|
||||
foreground: "#c9b6ff", background: "#0c0e3c", outline: "#000000",
|
||||
edge: "#8a6cc0", texture: "custodians-displaced", material: "glass" },
|
||||
{ name: "custodians-agency", description: "Custodians — Agency issue",
|
||||
foreground: "#f2ea72", background: "#16215f", outline: "#0c125b",
|
||||
edge: "#a8b2e3", texture: "custodians-agency", material: "metal" }
|
||||
]
|
||||
};
|
||||
|
||||
/** Which set an agent rolls, from the band they are in. */
|
||||
export function diceSetFor(actor) {
|
||||
const band = coherenceBand(actor?.system?.coherence?.value ?? 10);
|
||||
return `custodians-${band?.id ?? "anchored"}`;
|
||||
}
|
||||
|
||||
/** Tag a Roll so Dice So Nice draws it in the roller's colours. */
|
||||
function rbDressDice(roll, actor) {
|
||||
if (!roll?.dice?.length) return roll;
|
||||
const colorset = diceSetFor(actor);
|
||||
for (const d of roll.dice) {
|
||||
d.options.colorset = colorset;
|
||||
d.options.appearance = { ...(d.options.appearance ?? {}), colorset };
|
||||
}
|
||||
return roll;
|
||||
}
|
||||
|
||||
Hooks.once("diceSoNiceReady", async (dice3d) => {
|
||||
try {
|
||||
dice3d.addSystem({ id: "custodians", name: "The Custodians" }, "preferred");
|
||||
|
||||
// addTexture returns a Promise and the colourset that references a texture must
|
||||
// not be registered before it resolves, or the die renders untextured.
|
||||
await Promise.all(Object.entries(RB_DICE.textures).map(([id, t]) =>
|
||||
dice3d.addTexture(id, {
|
||||
name: t.name, composite: "source-over",
|
||||
source: `systems/ringbrp/dice/${t.file}`
|
||||
})));
|
||||
|
||||
for (const set of RB_DICE.sets) {
|
||||
dice3d.addColorset({
|
||||
...set, category: "The Custodians",
|
||||
font: "Michroma", visibility: "visible"
|
||||
}, set.name === "custodians-anchored" ? "default" : "showcase");
|
||||
}
|
||||
|
||||
// The percentile pair is the game's core roll, so it is the one worth dressing.
|
||||
dice3d.addDicePreset({
|
||||
type: "d100", system: "custodians",
|
||||
labels: ["00", "10", "20", "30", "40", "50", "60", "70", "80", "90"]
|
||||
});
|
||||
dice3d.addDicePreset({ type: "d10", system: "custodians",
|
||||
labels: ["0", "1", "2", "3", "4", "5", "6", "7", "8", "9"] });
|
||||
for (const t of ["d4", "d6", "d8", "d12", "d20"]) {
|
||||
const n = Number(t.slice(1));
|
||||
dice3d.addDicePreset({ type: t, system: "custodians",
|
||||
labels: Array.from({ length: n }, (_, i) => String(i + 1)) });
|
||||
}
|
||||
console.log("Custodians | dice registered with Dice So Nice");
|
||||
} catch (err) {
|
||||
console.warn("Custodians | could not register dice", err);
|
||||
}
|
||||
});
|
||||
|
||||
Hooks.on("renderItemDirectory", (app, html) => {
|
||||
const root = html instanceof HTMLElement ? html : html?.[0];
|
||||
if (!root || !game.user.isGM || root.querySelector(".rb-genbar")) return;
|
||||
@@ -5514,6 +5432,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) {
|
||||
@@ -5867,7 +5790,6 @@ export async function rollWeaponDamage({ actor = null, weapon, level = "success"
|
||||
roll = await new Roll(usesDm ? formula + dm : formula).evaluate();
|
||||
}
|
||||
|
||||
rbDressDice(roll, actor);
|
||||
const note = level === "critical" ? game.i18n.localize("RINGBRP.Chat.DamageCritical")
|
||||
: level === "special" ? game.i18n.localize("RINGBRP.Chat.DamageSpecial")
|
||||
: "";
|
||||
@@ -6181,7 +6103,7 @@ Hooks.once("init", () => {
|
||||
usePower, grantPower, ARTS, powerCostFor, crossBack,
|
||||
generateMissionCase, missionDialog, salvageFrom,
|
||||
crossingArtPoolFor, crossingGrantsArt, crossingCostFor, returnCrossing,
|
||||
rollWeaponDamage, diceSetFor,
|
||||
rollWeaponDamage,
|
||||
restFor, endOfCase, gearBonusFor,
|
||||
hasTalent, talentAvailable, useTalent, requisition, armCleanScene, resetTalents,
|
||||
generateCharacter, generateNPC, generateDialog, installContent, grantFullSkillList,
|
||||
|
||||
@@ -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); }
|
||||
}
|
||||
|
||||
@@ -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.7",
|
||||
"compatibility": {
|
||||
"minimum": 13,
|
||||
"verified": "14.364"
|
||||
|
||||
@@ -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}}—{{/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}}—{{/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">—</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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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`);
|
||||
@@ -1,176 +0,0 @@
|
||||
/**
|
||||
* Dice textures for The Custodians, generated rather than drawn.
|
||||
*
|
||||
* Written as raw RGBA and encoded to PNG with node:zlib, so the repo gains no
|
||||
* image dependency and the textures can be regenerated deterministically. Every
|
||||
* pattern is tileable — Dice So Nice wraps these across a die face, and a seam
|
||||
* shows up immediately on a d20.
|
||||
*
|
||||
* The set is the setting: the Custodians keep a filing system, and the game is
|
||||
* about that filing system being wrong. So the dice carry a ruled grid, and the
|
||||
* further an agent drifts from their own reality the more the grid stops agreeing
|
||||
* with itself.
|
||||
*
|
||||
* node tools/make-dice-textures.mjs
|
||||
*/
|
||||
import { deflateSync } from "node:zlib";
|
||||
import { writeFile, mkdir } from "node:fs/promises";
|
||||
|
||||
const SIZE = 256;
|
||||
|
||||
/* ---------- a minimal PNG encoder: RGBA, no interlace ---------- */
|
||||
function crc32(buf) {
|
||||
let c, table = crc32.table;
|
||||
if (!table) {
|
||||
table = crc32.table = new Int32Array(256);
|
||||
for (let n = 0; n < 256; n++) {
|
||||
c = n;
|
||||
for (let k = 0; k < 8; k++) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1;
|
||||
table[n] = c;
|
||||
}
|
||||
}
|
||||
c = -1;
|
||||
for (let i = 0; i < buf.length; i++) c = table[(c ^ buf[i]) & 0xff] ^ (c >>> 8);
|
||||
return (c ^ -1) >>> 0;
|
||||
}
|
||||
|
||||
function chunk(type, data) {
|
||||
const len = Buffer.alloc(4); len.writeUInt32BE(data.length);
|
||||
const td = Buffer.concat([Buffer.from(type, "ascii"), data]);
|
||||
const crc = Buffer.alloc(4); crc.writeUInt32BE(crc32(td));
|
||||
return Buffer.concat([len, td, crc]);
|
||||
}
|
||||
|
||||
function png(width, height, rgba) {
|
||||
const ihdr = Buffer.alloc(13);
|
||||
ihdr.writeUInt32BE(width, 0); ihdr.writeUInt32BE(height, 4);
|
||||
ihdr[8] = 8; ihdr[9] = 6; ihdr[10] = 0; ihdr[11] = 0; ihdr[12] = 0;
|
||||
// each scanline is prefixed with filter byte 0
|
||||
const raw = Buffer.alloc(height * (width * 4 + 1));
|
||||
for (let y = 0; y < height; y++) {
|
||||
raw[y * (width * 4 + 1)] = 0;
|
||||
rgba.copy(raw, y * (width * 4 + 1) + 1, y * width * 4, (y + 1) * width * 4);
|
||||
}
|
||||
return Buffer.concat([
|
||||
Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]),
|
||||
chunk("IHDR", ihdr),
|
||||
chunk("IDAT", deflateSync(raw, { level: 9 })),
|
||||
chunk("IEND", Buffer.alloc(0))
|
||||
]);
|
||||
}
|
||||
|
||||
/* ---------- deterministic noise, so a rebuild is byte-identical ---------- */
|
||||
let seed = 20260810;
|
||||
const rnd = () => (seed = (seed * 1664525 + 1013904223) >>> 0) / 4294967296;
|
||||
|
||||
const canvas = () => Buffer.alloc(SIZE * SIZE * 4);
|
||||
const put = (buf, x, y, r, g, b, a = 255) => {
|
||||
const i = ((y & (SIZE - 1)) * SIZE + (x & (SIZE - 1))) * 4;
|
||||
buf[i] = r; buf[i + 1] = g; buf[i + 2] = b; buf[i + 3] = a;
|
||||
};
|
||||
const fill = (buf, r, g, b, a = 255) => {
|
||||
for (let i = 0; i < buf.length; i += 4) { buf[i] = r; buf[i + 1] = g; buf[i + 2] = b; buf[i + 3] = a; }
|
||||
};
|
||||
|
||||
/* ============================================================
|
||||
1. REGISTRY — the ruled grid of a filing system, kept true.
|
||||
============================================================ */
|
||||
function registry() {
|
||||
const b = canvas();
|
||||
fill(b, 24, 38, 141); // ultramarine ground
|
||||
for (let i = 0; i < SIZE; i++) {
|
||||
for (const step of [32]) {
|
||||
for (let k = 0; k < SIZE; k += step) {
|
||||
put(b, i, k, 102, 116, 216); // --rb-line
|
||||
put(b, k, i, 102, 116, 216);
|
||||
}
|
||||
}
|
||||
for (let k = 16; k < SIZE; k += 32) { // fainter half-lines
|
||||
put(b, i, k, 60, 78, 175);
|
||||
put(b, k, i, 60, 78, 175);
|
||||
}
|
||||
}
|
||||
// registry ticks at the intersections
|
||||
for (let x = 0; x < SIZE; x += 64) for (let y = 0; y < SIZE; y += 64) {
|
||||
for (let d = -3; d <= 3; d++) { put(b, x + d, y, 242, 234, 114); put(b, x, y + d, 242, 234, 114); }
|
||||
}
|
||||
return b;
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
2. DRIFT — the same grid, no longer agreeing with itself.
|
||||
Used for agents who have come loose from their own reality.
|
||||
============================================================ */
|
||||
function drift() {
|
||||
const b = canvas();
|
||||
fill(b, 20, 30, 120);
|
||||
const wobble = y => Math.round(6 * Math.sin(y / 14) + 3 * Math.sin(y / 5.5));
|
||||
for (let i = 0; i < SIZE; i++) {
|
||||
for (let k = 0; k < SIZE; k += 32) {
|
||||
put(b, i + wobble(i), k, 122, 108, 200);
|
||||
put(b, k, i + wobble(i), 122, 108, 200);
|
||||
}
|
||||
}
|
||||
for (let n = 0; n < 900; n++) { // grain where it has torn
|
||||
const x = (rnd() * SIZE) | 0, y = (rnd() * SIZE) | 0;
|
||||
put(b, x, y, 200, 120, 150, 255);
|
||||
}
|
||||
for (let x = 0; x < SIZE; x += 64) for (let y = 0; y < SIZE; y += 64) {
|
||||
const ox = ((rnd() * 7) | 0) - 3, oy = ((rnd() * 7) | 0) - 3;
|
||||
for (let d = -3; d <= 3; d++) { put(b, x + d + ox, y + oy, 255, 122, 92); put(b, x + ox, y + d + oy, 255, 122, 92); }
|
||||
}
|
||||
return b;
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
3. DISPLACED — the grid is gone. What is left is not filed.
|
||||
============================================================ */
|
||||
function displaced() {
|
||||
const b = canvas();
|
||||
fill(b, 12, 14, 60);
|
||||
for (let n = 0; n < 5200; n++) {
|
||||
const x = (rnd() * SIZE) | 0, y = (rnd() * SIZE) | 0;
|
||||
const v = 40 + ((rnd() * 90) | 0);
|
||||
put(b, x, y, v, v * 0.5, v * 0.7);
|
||||
}
|
||||
// a few surviving fragments of rule, going nowhere
|
||||
for (let n = 0; n < 26; n++) {
|
||||
const x = (rnd() * SIZE) | 0, y = (rnd() * SIZE) | 0, len = 6 + ((rnd() * 22) | 0);
|
||||
const horiz = rnd() > 0.5;
|
||||
for (let d = 0; d < len; d++) {
|
||||
if (horiz) put(b, x + d, y, 150, 120, 190); else put(b, x, y + d, 150, 120, 190);
|
||||
}
|
||||
}
|
||||
return b;
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
4. AGENCY — brushed plate, for the agency-issue set.
|
||||
============================================================ */
|
||||
function agency() {
|
||||
const b = canvas();
|
||||
for (let y = 0; y < SIZE; y++) {
|
||||
for (let x = 0; x < SIZE; x++) {
|
||||
const grain = 6 * Math.sin(x / 1.7 + y / 90) + (rnd() * 7 - 3.5);
|
||||
const v = 30 + grain;
|
||||
put(b, x, y, 22 + v, 33 + v, 95 + v);
|
||||
}
|
||||
}
|
||||
for (let k = 0; k < SIZE; k += 48) for (let i = 0; i < SIZE; i++) put(b, i, k, 90, 104, 190);
|
||||
return b;
|
||||
}
|
||||
|
||||
const TEXTURES = {
|
||||
"custodians-registry": registry,
|
||||
"custodians-drift": drift,
|
||||
"custodians-displaced": displaced,
|
||||
"custodians-agency": agency
|
||||
};
|
||||
|
||||
await mkdir(new URL("../dice/", import.meta.url), { recursive: true });
|
||||
for (const [name, make] of Object.entries(TEXTURES)) {
|
||||
const buf = png(SIZE, SIZE, make());
|
||||
await writeFile(new URL(`../dice/${name}.png`, import.meta.url), buf);
|
||||
console.log(` ${name}.png ${SIZE}x${SIZE} ${(buf.length / 1024).toFixed(1)} kB`);
|
||||
}
|
||||
console.log("Custodians | dice textures written");
|
||||