9 Commits
Author SHA1 Message Date
tevansandClaude Fable 5 7683deb604 Use the Tear portrait as the climax handout image
Journal (8) THE TEAR's reveal page now shows the generated pencil artwork
(handout-entity.png, full resolution) instead of the fractal SVG.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-17 00:02:19 +01:00
tevansandClaude Fable 5 4ec537d4b8 Swap Vance / Rostova / Tear art for user-generated pencil portraits
The three original FALSE ALARM NPCs now match the Congregation set:
Deputy Vance (Changed), Dr. Rostova, and the Tear (rendered as a suited
figure with a receding non-Euclidean void for a head - 'almost done
learning our shape'). Pure asset swap at the existing token paths.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-16 23:59:49 +01:00
tevansandClaude Fable 5 bf4af421b5 Swap Congregation NPC art for user-generated pencil portraits
Agnes Corliss, the Returned Petrie brothers and the Called now use the
black-and-white pencil portraits (768px PNG) in place of the stylized
placeholders. Pure asset swap - pack document paths unchanged. SVG
sources retained alongside.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-16 23:38:53 +01:00
tevansandClaude Fable 5 b10f8213a7 FALSE ALARM midpoint encounter: The Congregation — v2.1.0
New Scene 2.5 at Cold Cauldron Crossing (Ridge Road mile 9): the town
comes up the mountain. Adds:
- 4 NPCs built to challenge 4 Agents: Agnes Corliss the Switchboard Widow
  (the Chorus SAN drain through the Agents' own radios, Stand Still action
  denial, Speak the Names; weakness = her dead husband's handset), the
  Returned Petrie brothers (armor 3, numbing grasp, pond Baptism drag,
  fire-vulnerable), and the Called x5 (restraining civilian wall - killing
  one is 1/1D8 SAN and a cover-up disaster).
- New battle map (blueprint style, 4400x3200 2x PNG) with 8 placed tokens,
  new gear item (Elias Corliss's Ham Handset), 4-page GM journal 13 with
  read-aloud, tactics, non-massacre outs and aftermath, plus a Scene 2.5
  pointer in the scene guide.
- Scene coordinate fix: padding 0 on all adventure scenes and centered
  token placement (tokens previously offset by the 0.25-padding origin).

Verified by fresh import in isolated Foundry: 7 actors, 13 journals,
4 scenes, 8 tokens on the crossing map at correct positions.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-16 23:24:15 +01:00
tevansandClaude Fable 5 7dcbd96b13 Fix pause overlay showing GAME PAUSED while unpaused — v2.0.5
Root cause of the toggle cycle bug: the theme set display:flex on
#pause.dg2-pause unconditionally, out-specificity-ing core's hidden
(unpaused) state. After unpausing, the pause element stayed visible and
showed Foundry's default GAME PAUSED markup (the overlay injector
correctly skips unpaused renders). Scope the flex layout to
#pause.dg2-pause.paused and explicitly hide :not(.paused).

Verified three full pause/unpause cycles: paused = radar scope visible,
unpaused = element hidden. (Earlier tests misread visibility via
offsetParent, which is always null for position:fixed elements.)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-16 22:39:52 +01:00
tevansandClaude Fable 5 7bbe36c29e Deterministic pause overlay via GamePause subclass — v2.0.4
The v2.0.3 pauseGame-hook re-injection raced Foundry's own pause re-render:
on slower clients the default GAME PAUSED markup landed after our overlay,
so pressing Space after an unpause showed the stock screen. Register a
DG2GamePause subclass (CONFIG.ui.pause) that applies the radar-scope
overlay inside _onRender - part of every render, no timing window. Hook
fallbacks retained (with retries) for cores lacking the AppV2 GamePause.
Verified with five rapid unpause/pause cycles at 150ms settle.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-16 22:27:50 +01:00
tevansandClaude Fable 5 7d684ffdb2 Robust pause overlay + PNG NPC tokens — v2.0.3
The radar-scope pause overlay only applied via the renderGamePause hook,
which does not fire on every pause toggle path — pressing Space after an
unpause could show Foundry's default GAME PAUSED. The overlay injector is
now idempotent and wired to renderGamePause AND pauseGame (plus a ready
check), so the scope applies on every toggle; verified across repeated
unpause/pause cycles.

Also rasterize the three FALSE ALARM NPC tokens (Rostova, Changed Vance,
the Tear) to 1024px PNGs, matching the map fix in v2.0.1.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-16 22:16:11 +01:00
tevansandClaude Fable 5 ff13de2ca5 Radar-scope pause screen + Providence/Calamity critical events — v2.0.2
Pause: replace the stock spinner with an animated DG2 radar scope (centre-
swept beam, phosphor trail, range rings, glinting blips) captioned MISSION
PAUSED / ALL STATIONS HOLD.

Home rule (criticalEvents automation setting, default on): when an Agent
rolls a natural 01 on a percentile test, roll 1d6 on the PROVIDENCE table
(1 = recover 1D4 SAN ... 6 = the whole cell gains +20%/1 WP); a natural 00
rolls on the CALAMITY table (1 = equipment failure ... 6 = everyone in the
scene loses SAN as something notices the cell). Events post as dramatic
gold/red dossier chat cards with the d6 attached (themed 3D dice); the
Handler applies effects. Verified both paths end-to-end in isolated Foundry.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-16 22:02:20 +01:00
tevansandClaude Fable 5 0fba241210 Ship FALSE ALARM maps as 2x PNGs (crisp when zoomed) — v2.0.1
Foundry rasterizes SVG scene backgrounds into a fixed texture, so the
vector maps looked low-res when zoomed in. Render the three battle maps to
native-resolution PNGs via headless Chrome (compound 5200x3600, server
3600x2800, dome 4000x4000) and point the scenes at them at grid 200 px/
5-ft square (same 26x18 / 20x20 layout, 1:1 with the texture). Tokens
repositioned to the 2x coordinate space. Journal handouts stay SVG (they
render as crisp vector <img> in journals). Bump to v2.0.1.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-16 15:16:57 +01:00
29 changed files with 665 additions and 34 deletions
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.9 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 418 KiB

@@ -0,0 +1,96 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 2200 1600" font-family="Courier New, monospace">
<defs>
<pattern id="cgrid" width="100" height="100" patternUnits="userSpaceOnUse">
<path d="M100 0H0V100" fill="none" stroke="#1d2a30" stroke-width="1"/>
</pattern>
<g id="cpine"><path d="M0 60 L20 -10 L40 60 Z M6 30 L20 -30 L34 30 Z" fill="#c9d6dd" opacity="0.45"/></g>
</defs>
<rect width="2200" height="1600" fill="#080a0d"/>
<rect width="2200" height="1600" fill="url(#cgrid)"/>
<!-- treeline both sides -->
<g>
<use href="#cpine" x="80" y="120"/><use href="#cpine" x="200" y="90"/><use href="#cpine" x="330" y="130"/>
<use href="#cpine" x="1750" y="100"/><use href="#cpine" x="1880" y="130"/><use href="#cpine" x="2010" y="95"/>
<use href="#cpine" x="90" y="1380"/><use href="#cpine" x="220" y="1420"/><use href="#cpine" x="1900" y="1400"/><use href="#cpine" x="2030" y="1360"/>
</g>
<!-- Ridge Road: gravel band across -->
<path d="M0 700 C 400 660 800 740 1200 700 C 1600 660 1900 720 2200 690" fill="none" stroke="#3a3325" stroke-width="150" opacity="0.85"/>
<path d="M0 700 C 400 660 800 740 1200 700 C 1600 660 1900 720 2200 690" fill="none" stroke="#7a6b3a" stroke-width="4" stroke-dasharray="26 18"/>
<text x="150" y="620" fill="#a08b4a" font-size="26" transform="rotate(-4 150 620)">RIDGE ROAD — MILE 9</text>
<text x="1960" y="640" fill="#a08b4a" font-size="22" transform="rotate(-3 1960 640)">to SITE ME-07 →</text>
<text x="60" y="800" fill="#a08b4a" font-size="22">← to CORLISS NOTCH</text>
<!-- winter gate, forced open -->
<g transform="translate(430 700)">
<rect x="-12" y="-90" width="24" height="180" fill="#9fb0b8"/>
<path d="M12 -70 L200 -110" stroke="#9fb0b8" stroke-width="12"/>
<text x="30" y="-130" fill="#9fb0b8" font-size="22">WINTER GATE — CHAIN CUT</text>
</g>
<!-- Cold Cauldron Pond, bottom right, thin ice -->
<g>
<path d="M1450 1080 C 1600 1000 1900 1020 2050 1140 C 2160 1240 2100 1420 1900 1470 C 1650 1530 1400 1420 1400 1260 C 1400 1180 1410 1110 1450 1080 Z"
fill="#0d1a20" stroke="#3d6b7a" stroke-width="3"/>
<g stroke="#5f97a8" stroke-width="2" opacity="0.6" fill="none">
<path d="M1520 1180 L1640 1260 L1600 1350"/>
<path d="M1800 1120 L1760 1240 L1880 1300"/>
</g>
<text x="1750" y="1240" fill="#5f97a8" font-size="26" text-anchor="middle">COLD CAULDRON POND</text>
<text x="1750" y="1272" fill="#3d6b7a" font-size="18" text-anchor="middle">ice: THIN — cracks radiate from shore</text>
</g>
<!-- Petrie truck: still here, hood open, running lights -->
<g transform="translate(1250 880) rotate(18)">
<rect x="-70" y="-150" width="140" height="300" rx="16" fill="#0f1418" stroke="#dfe8ee" stroke-width="4"/>
<rect x="-52" y="-120" width="104" height="90" fill="none" stroke="#9fb0b8" stroke-width="3"/>
<rect x="-52" y="30" width="104" height="100" fill="none" stroke="#9fb0b8" stroke-width="3"/>
<circle cx="-40" cy="-160" r="10" fill="#c9dd9e"/><circle cx="40" cy="-160" r="10" fill="#c9dd9e"/>
<text x="0" y="185" fill="#9fb0b8" font-size="20" text-anchor="middle" transform="rotate(-18)">PETRIE PICKUP</text>
<text x="0" y="211" fill="#5c6b73" font-size="15" text-anchor="middle" transform="rotate(-18)">10 weeks · engine still idling</text>
</g>
<!-- locals' vehicles blocking road: wood truck + two cars, headlights crossing -->
<g transform="translate(820 700) rotate(-84)">
<rect x="-80" y="-190" width="160" height="380" rx="10" fill="#0f1418" stroke="#dfe8ee" stroke-width="5"/>
<path d="M-80 -60 H80 M-80 60 H80" stroke="#3a474e" stroke-width="2"/>
<text x="0" y="230" fill="#9fb0b8" font-size="20" text-anchor="middle" transform="rotate(84)">LOG TRUCK (jackknifed)</text>
</g>
<g transform="translate(1035 585) rotate(24)">
<rect x="-55" y="-105" width="110" height="210" rx="14" fill="#0f1418" stroke="#dfe8ee" stroke-width="4"/>
<path d="M-70 -140 L-160 -230 M70 -140 L160 -230" stroke="#c9dd9e" stroke-width="6" opacity="0.5"/>
<text x="0" y="145" fill="#9fb0b8" font-size="18" text-anchor="middle" transform="rotate(-24)">SEDAN, doors open</text>
</g>
<g transform="translate(980 835) rotate(-12)">
<rect x="-55" y="-105" width="110" height="210" rx="14" fill="#0f1418" stroke="#dfe8ee" stroke-width="4"/>
<text x="0" y="150" fill="#9fb0b8" font-size="18" text-anchor="middle" transform="rotate(12)">DINER'S WAGON</text>
</g>
<!-- the congregation: circle of figures on the road, facing the ridge -->
<g transform="translate(1450 620)">
<circle r="120" fill="none" stroke="#8aa353" stroke-width="3" stroke-dasharray="8 10"/>
<g fill="#c9dd9e">
<circle cx="0" cy="-95" r="13"/><circle cx="82" cy="-42" r="13"/><circle cx="88" cy="52" r="13"/>
<circle cx="0" cy="98" r="13"/><circle cx="-88" cy="52" r="13"/><circle cx="-82" cy="-42" r="13"/>
</g>
<circle r="17" fill="#e8cd7a"/>
<text x="0" y="-150" fill="#8aa353" font-size="24" text-anchor="middle">THE CONGREGATION</text>
<text x="0" y="170" fill="#5c6b73" font-size="17" text-anchor="middle">standing in the snow, unbothered · humming</text>
<text x="0" y="24" fill="#7a6b3a" font-size="14" text-anchor="middle">AGNES</text>
</g>
<!-- petrie markers by pond edge -->
<g fill="#8fd4e8">
<circle cx="1560" cy="1050" r="14"/><circle cx="1640" cy="1085" r="14"/>
<text x="1600" y="1020" fill="#8fd4e8" font-size="20" text-anchor="middle">THE RETURNED (×2)</text>
</g>
<!-- title block -->
<g transform="translate(40 40)">
<rect width="600" height="120" fill="#0a0d10" stroke="#8aa353" stroke-width="2"/>
<text x="20" y="46" fill="#dfe8ee" font-size="32" letter-spacing="2">COLD CAULDRON CROSSING</text>
<text x="20" y="80" fill="#8aa353" font-size="21">MIDPOINT ENCOUNTER — THE CONGREGATION</text>
<text x="20" y="106" fill="#5c6b73" font-size="17">Ridge Road mile 9 · night · heavy snow · 1 sq = 5 ft</text>
</g>
</svg>

After

Width:  |  Height:  |  Size: 5.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.4 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 142 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

@@ -0,0 +1,33 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">
<rect width="512" height="512" fill="#0a0d10"/>
<!-- static halo: concentric broadcast rings -->
<g stroke="#e8cd7a" fill="none" opacity="0.35">
<circle cx="256" cy="220" r="150" stroke-width="2"/>
<circle cx="256" cy="220" r="190" stroke-width="1.5" stroke-dasharray="10 8"/>
<circle cx="256" cy="220" r="230" stroke-width="1" stroke-dasharray="4 12"/>
</g>
<g fill="#e8e2d4">
<!-- shawled shoulders -->
<path d="M256 318c-70 0-116 32-132 90-6 22-9 46-11 104h286c-2-58-5-82-11-104-16-58-62-90-132-90z"/>
<!-- head, gaunt -->
<path d="M256 122c-42 0-70 32-70 80 0 44 28 80 70 80s70-36 70-80c0-48-28-80-70-80z"/>
<!-- severe pulled-back hair -->
<path d="M256 122c-42 0-70 32-70 80 0 4 0 8 1 12 8-24 32-38 69-38s61 14 69 38c1-4 1-8 1-12 0-48-28-80-70-80z" fill="#b8b2a4"/>
<!-- bun -->
<circle cx="256" cy="118" r="22" fill="#b8b2a4"/>
</g>
<!-- headset band + earpiece pressed to ear -->
<path d="M186 170 C 200 120 312 120 326 170" stroke="#4a5560" stroke-width="12" fill="none"/>
<circle cx="188" cy="196" r="26" fill="#4a5560"/><circle cx="188" cy="196" r="12" fill="#0a0d10"/>
<!-- blank white eyes -->
<g fill="#f5f5ef"><ellipse cx="228" cy="200" rx="13" ry="9"/><ellipse cx="284" cy="200" rx="13" ry="9"/></g>
<!-- mouth open, speaking someone else's words: static pouring out -->
<ellipse cx="256" cy="252" rx="16" ry="22" fill="#0a0d10"/>
<g stroke="#e8cd7a" stroke-width="3" fill="none" opacity="0.9">
<path d="M256 260 q30 6 44 30 M256 264 q-28 8 -40 32 M256 268 q6 30 0 52"/>
</g>
<!-- frost on collar -->
<path d="M170 340 Q256 316 342 340" stroke="#bfeaf6" stroke-width="4" fill="none" opacity="0.6"/>
<!-- shawl folds -->
<path d="M210 360 L226 512 M302 360 L286 512" stroke="#0a0d10" stroke-width="5" opacity="0.6"/>
</svg>

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 MiB

@@ -0,0 +1,30 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">
<rect width="512" height="512" fill="#0a0d10"/>
<!-- three overlapping entranced townsfolk, eyes closed, faces upturned -->
<g fill="#d9d2c4" opacity="0.55">
<path d="M120 512 c0 -70 34 -108 76 -108 s76 38 76 108z"/>
<g transform="rotate(-6 196 330)"><path d="M196 268 c-30 0 -50 24 -50 58 0 32 20 58 50 58 s50 -26 50 -58 c0 -34 -20 -58 -50 -58z"/></g>
</g>
<g fill="#d9d2c4" opacity="0.75">
<path d="M240 512 c0 -76 36 -116 82 -116 s82 40 82 116z"/>
<g transform="rotate(5 322 300)"><path d="M322 232 c-32 0 -54 26 -54 62 0 36 22 62 54 62 s54 -26 54 -62 c0 -36 -22 -62 -54 -62z"/></g>
</g>
<!-- front figure: teenager, head tipped back, mouth humming -->
<g fill="#e8e2d4">
<path d="M60 512 c0 -84 40 -128 92 -128 s92 44 92 128z"/>
<g transform="rotate(-10 152 260)">
<path d="M152 182 c-36 0 -60 28 -60 68 0 40 24 68 60 68 s60 -28 60 -68 c0 -40 -24 -68 -60 -68z"/>
<path d="M152 182 c-36 0 -60 28 -60 68 l0 4 c10 -24 30 -36 60 -36 s50 12 60 36 l0 -4 c0 -40 -24 -68 -60 -68z" fill="#a89f88"/>
</g>
</g>
<!-- closed eyes (peaceful, wrong) + humming mouths -->
<g stroke="#0a0d10" stroke-width="6" fill="none">
<g transform="rotate(-10 152 260)"><path d="M124 252 q14 10 28 0 M166 252 q14 10 28 0 M138 292 q16 -8 32 0"/></g>
</g>
<g stroke="#0a0d10" stroke-width="5" fill="none" opacity="0.75">
<g transform="rotate(5 322 300)"><path d="M300 292 q12 8 24 0 M336 292 q12 8 24 0 M312 326 q14 -6 28 0"/></g>
</g>
<!-- shared hum: single thin waveform rising -->
<path d="M100 150 q40 -30 80 0 t80 0 t80 0 t80 0" stroke="#8aa353" stroke-width="3" fill="none" opacity="0.7"/>
<path d="M140 110 q30 -22 60 0 t60 0 t60 0" stroke="#8aa353" stroke-width="2" fill="none" opacity="0.45"/>
</svg>

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

@@ -0,0 +1,29 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">
<rect width="512" height="512" fill="#08171c"/>
<!-- pond-ice fracture backdrop -->
<g stroke="#3d6b7a" stroke-width="1.5" opacity="0.5">
<path d="M60 470 L180 360 L140 300 M452 470 L340 380 L390 300 M256 500 L256 420"/>
</g>
<g fill="#cfe0e6">
<!-- broad hunter shoulders, soaked parka -->
<path d="M256 312c-76 0-124 34-140 94-6 22-9 48-11 106h302c-2-58-5-84-11-106-16-60-64-94-140-94z"/>
<!-- head -->
<path d="M256 128c-44 0-72 32-72 80 0 46 28 84 72 84s72-38 72-84c0-48-28-80-72-80z"/>
<!-- wet flat hair -->
<path d="M256 128c-44 0-72 32-72 80l0 6c10-26 34-40 72-40s62 14 72 40l0-6c0-48-28-80-72-80z" fill="#8fa6ad"/>
</g>
<!-- waterline stain across face -->
<path d="M184 226 Q256 214 328 226" stroke="#3d6b7a" stroke-width="5" fill="none" opacity="0.7"/>
<!-- sunken dark eyes, pinprick light -->
<g fill="#08171c"><ellipse cx="228" cy="204" rx="16" ry="20"/><ellipse cx="284" cy="204" rx="16" ry="20"/></g>
<g fill="#8fd4e8"><circle cx="228" cy="206" r="4"/><circle cx="284" cy="206" r="4"/></g>
<!-- slack mouth, water weed -->
<path d="M226 262 Q256 274 286 262" stroke="#08171c" stroke-width="7" fill="none"/>
<path d="M280 268 q14 26 4 52" stroke="#2c4a3e" stroke-width="5" fill="none"/>
<!-- parka zip + frost -->
<path d="M256 330 V512" stroke="#08171c" stroke-width="6"/>
<path d="M176 344 Q256 322 336 344" stroke="#bfeaf6" stroke-width="4" fill="none" opacity="0.7"/>
<!-- boat hook in fist -->
<path d="M108 512 L128 330 q2 -18 14 -14 l4 2 q10 4 6 20 L142 512" fill="#8fa6ad"/>
<path d="M120 336 q-26 -10 -18 -36 q4 -14 20 -12" stroke="#8fa6ad" stroke-width="10" fill="none"/>
</svg>

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

@@ -0,0 +1,35 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">
<rect width="512" height="512" fill="#08171c"/>
<g stroke="#3d6b7a" stroke-width="1.5" opacity="0.5">
<path d="M80 470 L200 380 L160 320 M430 470 L330 360 L380 290"/>
</g>
<g fill="#cfe0e6">
<!-- heavier build, hunched -->
<path d="M256 306c-80 0-130 36-146 96-6 22-9 52-11 110h314c-2-58-5-88-11-110-16-60-66-96-146-96z"/>
<!-- head tilted -->
<g transform="rotate(-8 256 210)">
<path d="M256 124c-44 0-72 32-72 80 0 46 28 84 72 84s72-38 72-84c0-48-28-80-72-80z"/>
<path d="M256 124c-44 0-72 32-72 80l0 6c10-26 34-40 72-40s62 14 72 40l0-6c0-48-28-80-72-80z" fill="#8fa6ad"/>
</g>
</g>
<!-- cap frozen to head -->
<g transform="rotate(-8 256 210)">
<path d="M184 176 C 196 130 316 130 328 176 L328 190 L184 190 Z" fill="#5c6b73"/>
<rect x="184" y="184" width="150" height="14" rx="7" fill="#5c6b73"/>
</g>
<!-- one eye frozen shut, one pinprick -->
<g transform="rotate(-8 256 210)">
<path d="M214 206 L246 202" stroke="#08171c" stroke-width="8"/>
<ellipse cx="286" cy="206" rx="16" ry="20" fill="#08171c"/><circle cx="286" cy="208" r="4" fill="#8fd4e8"/>
<!-- jaw hangs, breath of static -->
<path d="M230 262 Q258 284 288 258" stroke="#08171c" stroke-width="8" fill="none"/>
</g>
<g stroke="#bfeaf6" stroke-width="2.5" fill="none" opacity="0.7">
<path d="M262 282 q20 14 44 12 M258 288 q10 22 32 30"/>
</g>
<!-- frost -->
<path d="M168 336 Q256 312 344 336" stroke="#bfeaf6" stroke-width="4" fill="none" opacity="0.7"/>
<!-- splitting maul over shoulder -->
<path d="M356 512 L380 300 q2 -16 14 -12 l6 2 q10 4 6 18 L382 512" fill="#8fa6ad"/>
<rect x="352" y="268" width="86" height="40" rx="8" fill="#8fa6ad" transform="rotate(-14 395 288)"/>
</svg>

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 MiB

+85 -12
View File
@@ -65,6 +65,8 @@ const items = [
"Incendiary supplied for sanitation. Burns hot enough to reduce a frozen body — or a data drive — to slag. Needed to dispose of Dr. Rostova's remains without leaving forensic questions."),
gearItem("Cold-Weather Field Kit", "camping-gear", "Standard",
"Parkas, chemical heat packs, headlamps and rope. The dome interior is pitch black and the whole site is well below freezing; Agents without light and warmth accrue penalties over a long night."),
gearItem("Elias Corliss's Ham Handset", "walkie-talkie", "Trivial",
"A 1970s hand microphone on a worn leather strap, wired to nothing. Agnes Corliss has carried it since her husband died at his radio bench in 1988. <b>The Tear speaks to her through it</b> &mdash; it is the anchor of the Chorus. Destroying it (called shot &minus;20%, 6 HP) or taking it from her silences the static and frees the Called. Afterward it is inert &mdash; but at 3 a.m. it is very slightly cold to the touch."),
];
// ---------------------------------------------------------------- actors
@@ -95,7 +97,7 @@ function npcActor({ key, name, type, img, token, stats, health, skills, notes, s
const aRostova = npcActor({
key: "rostova", name: "Dr. Elena Rostova", type: "npc",
img: `${A}/token-rostova.svg`, token: `${A}/token-rostova.svg`,
img: `${A}/token-rostova.png`, token: `${A}/token-rostova.png`,
stats: { str: 10, con: 10, dex: 11, int: 15, pow: 12, cha: 11 }, health: 10, san: 0,
skills: { computer_science: 70, science: 0, sigint: 40, first_aid: 30, alertness: 40 },
shortDescription: "FAA facility technician — deceased, flash-frozen.",
@@ -104,7 +106,7 @@ const aRostova = npcActor({
const aVance = npcActor({
key: "vance", name: "Deputy Marcus Vance (Changed)", type: "unnatural",
img: `${A}/token-vance.svg`, token: `${A}/token-vance.svg`,
img: `${A}/token-vance.png`, token: `${A}/token-vance.png`,
stats: { str: 17, con: 16, dex: 16, int: 6, pow: 15, cha: 3 }, health: 16,
san: { failed: "1D6", success: "1" },
skills: { alertness: 60, athletics: 70, dodge: 50, melee_weapons: 65, unarmed_combat: 55 },
@@ -115,14 +117,63 @@ const aVance = npcActor({
const aTear = npcActor({
key: "tear", name: "The Tear (Static Between Stations)", type: "unnatural",
img: `${A}/token-entity.svg`, token: `${A}/token-entity.svg`,
img: `${A}/token-entity.png`, token: `${A}/token-entity.png`,
stats: { str: 20, con: 30, dex: 20, int: 20, pow: 25, cha: 1 }, health: 30,
san: { failed: "1D20", success: "1D6" },
skills: { alertness: 90, dodge: 80 },
shortDescription: "An extradimensional entity that mimics radio frequencies.",
notes: "<h2>The Tear &mdash; the Static Between Stations</h2><p>Not a creature so much as a <b>place that should not touch this one</b>: a shifting, non-Euclidean fractal silhouette that bleeds through the centre of the radar dish when the broadcast peaks. The air turns static-charged; hair stands on end; the temperature crashes.</p><h3>Rules</h3><ul><li><b>Firearms are useless.</b> Bullets pass through it and <b>warp out of shape</b>, doing nothing. Do not roll damage.</li><li><b>Presence:</b> being in its manifested presence costs <b>1D6/1D20 SAN</b>. Prolonged exposure (a round overloading the transformer beside it) is the full 1D20 branch.</li><li>It draws humans toward the freezing dark by <b>mimicking frequencies</b> — the corrupted radar return, the sub-carrier voice whispering the Agents' own names.</li></ul><h3>The only resolution: cut the power</h3><p>The tear cannot be fought, only <b>closed</b> by killing the broadcast:</p><ul><li><b>Blow the backup generator outside</b> &mdash; a hazardous run through the dark and snow (Demolitions, or improvised; STR/Athletics to reach it, exposure to Vance en route), or</li><li><b>Manually overload the main transformer inside the dome</b> &mdash; the Agent doing so takes <b>lethal electrical shock</b> (Lethality; survivors are badly hurt) and the full <b>1D6/1D20 SAN</b> exposure to the entity at arm's length.</li></ul><p>When power dies the tear <b>snaps back</b>, and the vacuum <b>implodes the dome.</b></p>",
});
const actors = [aRostova, aVance, aTear];
// ---- Midpoint encounter: The Congregation ----
const aAgnes = npcActor({
key: "agnes", name: "Agnes Corliss, the Switchboard Widow", type: "unnatural",
img: `${A}/token-agnes.png`, token: `${A}/token-agnes.png`,
stats: { str: 8, con: 12, dex: 9, int: 14, pow: 18, cha: 16 }, health: 10,
san: { failed: "1D6", success: "1" },
skills: { alertness: 55, occult: 40, persuade: 60, sigint: 50 },
shortDescription: "The Tear speaks through her dead husband's handset.",
notes: "<h2>Agnes Corliss &mdash; the Switchboard Widow (78)</h2><p>Great-granddaughter of the town's founders. Her husband Elias died at his ham bench in 1988, headphones on, tuned to the ridge. Agnes has listened every night since &mdash; and three months ago, <b>the ridge started answering in Elias's voice.</b> She believes the broadcast is heaven. She has been gathering the weak-willed of Corliss Notch to &#8220;hear the call.&#8221; Tonight she has brought them to meet it.</p><h3>Combat &mdash; she does not fight. She RULES the fight.</h3><ul><li><b>HP 10, WP 18.</b> Physically frail. <b>The Chorus Protects:</b> while any Called or Returned is conscious within 10 m, attacks against Agnes suffer &minus;20% (they lurch into the line of fire).</li><li><b>THE CHORUS (free action, every round):</b> layered static pours from her open mouth and from every radio, phone and earpiece the Agents carry. All who hear it: <b>SAN 0/1D4 on first exposure, then 0/1 per further round</b>; &minus;20% to any concentration-based test while it sustains. Agents who ditch comms and plug their ears reduce this to first-exposure only.</li><li><b>STAND STILL (action):</b> she speaks one Agent's true name <i>backward</i>. Opposed test: her POW&times;5 (90%) vs the target's POW&times;5. Fail: the Agent <b>loses their next action</b>, standing slack. Critical fail: they also drop what they hold.</li><li><b>SPEAK THE NAMES (once per Agent per night):</b> she recites something private &mdash; a Bond's name, a buried guilt (Handler: mine the Agent's Bonds). Target: <b>SAN 0/1D4.</b></li></ul><h3>Weakness &mdash; CUT THE FEED</h3><p>The Chorus anchors through <b>Elias's handset</b> on her belt (called shot &minus;20%, 6 HP, or a successful grapple to snatch it). Destroy or take it &mdash; or deafen her (flash-bang) &mdash; and the Chorus dies: Stand Still drops to 45%, the Called wake, and Agnes collapses into a confused, weeping old woman asking where Elias has gone. <b>She is a victim.</b> What A-Cell does with her afterward is the Handler's knife to twist.</p><p><em>SAN to witness the Chorus in a dead loved one's voice: 1/1D6.</em></p>",
});
const aDale = npcActor({
key: "dale", name: "Dale Petrie, Returned", type: "unnatural",
img: `${A}/token-returned-dale.png`, token: `${A}/token-returned-dale.png`,
stats: { str: 17, con: 16, dex: 12, int: 7, pow: 12, cha: 4 }, health: 17,
san: { failed: "1D6", success: "1" },
skills: { alertness: 50, athletics: 60, melee_weapons: 60, unarmed_combat: 55, swim: 70 },
shortDescription: "Ten weeks drowned. Walking anyway.",
weapons: [
{ name: "Boat Hook", icon: "forcible-entry-tool", skill: "melee_weapons", damage: "1D8+2", range: "0M" },
{ name: "Grasp of the Drowned", icon: "unarmed", skill: "unarmed_combat", damage: "1D6+1", range: "0M" },
],
notes: "<h2>Dale Petrie &mdash; Returned (was 41)</h2><p>The elder of the vanished hunters. Ten weeks at the bottom of Cold Cauldron Pond, and now he stands at its edge, soaked and patient, holding the boat hook he went in with. The Tear kept the shape and threw away most of the man.</p><h3>Combat (each brother)</h3><ul><li><b>HP 17. Frost-hardened flesh: 3 points armor</b> &mdash; cold meat doesn't tear. Ignores stun, pain and fear.</li><li><b>Boat Hook</b> 60%, 1D8+2. <b>Grasp of the Drowned</b> 55%, 1D6+1 &mdash; on a hit the limb seized goes numb: <b>&minus;20% to the target's DEX-based tests next round.</b></li><li><b>Cold radius:</b> Agents within 3 m for 3+ consecutive rounds: CON&times;5 or 1 HP from the cold.</li><li><b>COLD CAULDRON BAPTISM:</b> if BOTH brothers grapple one Agent, they drag them toward the pond &mdash; 2 rounds to the shore, then under the thin ice. Drowning and freezing, simultaneously. This is what they came to do: <b>the pond is how the Tear tastes people.</b></li></ul><h3>Weaknesses</h3><ul><li><b>Fire/thermite: double damage</b>, and an open flame makes both brothers flinch back one step.</li><li>An Agent who shouts their names with a successful <b>HUMINT</b> roll makes one brother hesitate a full round (once per brother). Something in there remembers being called in for supper.</li></ul><p><em>SAN to see one clearly: 1/1D6.</em></p>",
});
const aRoy = npcActor({
key: "roy", name: "Roy Petrie, Returned", type: "unnatural",
img: `${A}/token-returned-roy.png`, token: `${A}/token-returned-roy.png`,
stats: { str: 18, con: 16, dex: 11, int: 6, pow: 12, cha: 4 }, health: 17,
san: { failed: "1D6", success: "1" },
skills: { alertness: 45, athletics: 60, melee_weapons: 60, unarmed_combat: 55, swim: 70 },
shortDescription: "The younger brother. He hums the broadcast now.",
weapons: [
{ name: "Splitting Maul", icon: "wood-axe", skill: "melee_weapons", damage: "1D10+2", range: "0M" },
{ name: "Grasp of the Drowned", icon: "unarmed", skill: "unarmed_combat", damage: "1D6+1", range: "0M" },
],
notes: "<h2>Roy Petrie &mdash; Returned (was 38)</h2><p>The younger, bigger brother. One eye is frozen shut; his jaw hangs; from somewhere inside him comes the six-second static loop, humming, over and over. He carries the splitting maul from his own woodpile.</p><p><b>Use Dale's stat block</b> &mdash; identical rules, except: <b>Splitting Maul</b> 60%, 1D10+2 (slower, meaner). Roy always moves toward whoever is LOUDEST &mdash; gunfire, shouting, a radio squawk. Handler note: point him at the Agent doing the most talking.</p><p>Armor 3 (frost-hardened flesh) &middot; fire = double damage &middot; HUMINT name-call makes him hesitate once &middot; Baptism drag with Dale &middot; SAN 1/1D6.</p>",
});
const aCalled = npcActor({
key: "called", name: "The Called (Notch townsfolk)", type: "npc",
img: `${A}/token-called.png`, token: `${A}/token-called.png`,
stats: { str: 11, con: 11, dex: 10, int: 10, pow: 10, cha: 10 }, health: 11, san: 40,
skills: { alertness: 30, athletics: 30, unarmed_combat: 40 },
shortDescription: "Five entranced neighbours. They are crying while they do it.",
notes: "<h2>The Called &mdash; five townsfolk of Corliss Notch</h2><p>Cody Aldous (17, deleted the livestream), Ruth the diner cook, Whit Emmons the trapper, and the Pelletier couple from the feed store. They stand in the snow in their nightclothes and hunting jackets, faces upturned, <b>humming the six-second loop in unison.</b> Look closely (<b>HUMINT 40%</b>): they are weeping while they hum. They are prisoners in their own bodies.</p><h3>Combat &mdash; they are a wall, not an army</h3><ul><li>Use this one actor for all five (HP 11 each; track hits).</li><li><b>They do not kill. They RESTRAIN:</b> grapple at 40%, working in pairs (+20% for the second). A held Agent is carried toward Agnes &mdash; or the pond.</li><li>While the Chorus sustains they are immune to Persuade and feel no fear.</li><li><b>Pain wakes them a little:</b> any damage dealt to one grants it a POW&times;5 test each round to shake free of the trance.</li><li><b>When the Chorus dies</b> (handset destroyed/taken, Agnes deafened or down): they ALL wake at once &mdash; five terrified, hypothermic civilians in a firefight.</li></ul><h3>The cost</h3><p><b>Killing one of the Called costs the shooter 1/1D8 SAN</b> &mdash; they are innocent &mdash; and hands the cover-up (journal &#9451;) a body that cannot be blamed on weather. The Handler should make the easy trigger-pull expensive.</p>",
});
const actors = [aRostova, aVance, aTear, aAgnes, aDale, aRoy, aCalled];
// ---------------------------------------------------------------- journals
let jSort = 0;
@@ -199,7 +250,7 @@ const journal_audio = journal("07-audio", "⑦ HANDOUT — Final Transmission (A
]);
const journal_tear = journal("08-tear", "⑧ HANDOUT — THE TEAR (climax only)", [
imagePage("The Manifestation", `${A}/handout-entity.svg`, "DO NOT REVEAL until the broadcast peaks."),
imagePage("The Manifestation", `${A}/handout-entity.png`, "DO NOT REVEAL until the broadcast peaks."),
textPage("Reveal notes",
"<p><b>Hold this image back</b> until the radar dish activates and the entity bleeds through. The air is static-charged, everyone's hair stands on end, and a shifting, non-Euclidean <b>fractal silhouette</b> tears open at the centre of the dish. Presence costs <b>1D6/1D20 SAN</b>. Bullets pass through and warp uselessly. See <em>GM — NPCs &amp; The Threat</em>.</p>"),
]);
@@ -209,6 +260,8 @@ const journal_gm_scenes = journal("09-gm-scenes", "⑨ GM — Scene Guide &amp;
"<p>A fenced compound: one concrete equipment building and a massive geodesic radar dome over the pines. Heavy snow.</p><ul><li>The outer chain-link fence has been <b>cut from the inside out.</b></li><li><b>Deputy's cruiser</b> by the gate: door wide open, engine dead, interior coated in thick frost. <b>Forensics/Search:</b> his service weapon is in the footwell, <b>missing three rounds &mdash; no bullet holes in the car.</b></li><li><b>Survival/Tracking:</b> footprints lead from the driver's side into the woods, spaced <b>~8 feet apart</b> &mdash; as if sprinting, or dragged in massive strides.</li></ul>"),
textPage("Scene 2 — The Server Room",
"<p>The concrete building runs on a failing backup generator that hums unsteadily. <b>Dr. Elena Rostova</b> is dead at the console: <b>frozen solid</b> in a 40&deg;F room, eyes wide, lips torn from her teeth.</p><ul><li><b>Computer Science:</b> the log shows a &#8220;geometric shadow&#8221; over the dish, not an aircraft (Handout ②).</li><li><b>SIGINT/Science:</b> the final transmission is oscillating static. Listening closely = <b>SAN 0/1D4</b>; success hears a voice whispering the Agents' names (Handout ③).</li></ul>"),
textPage("Scene 2.5 — MIDPOINT: The Congregation",
"<p>Between the server room and the dome, the town comes up the mountain. When the Agents step back outside &mdash; or drive for the dome &mdash; <b>headlights crest the Ridge Road at mile 9</b>, and under the wind they hear it: a dozen human voices <b>humming the six-second static loop in unison.</b></p><p>Full encounter, map, and stat blocks: journal <b>&#9452; GM &mdash; MIDPOINT: The Congregation</b>. Run it before Scene 3; it is the operation's turn from investigation to horror.</p>"),
textPage("Scene 3 — The Radar Dome",
"<p>The upper catwalk inside the dome is pitch black, reeking of <b>ozone and scorched copper</b>. <b>Deputy Vance is here</b> — blind, self-mutilated, frantically rewiring the transmitter to broadcast the static loop at maximum amplification.</p><ul><li>He is <b>no longer entirely human</b>: unhinged jaw, jerky speed, screaming the tech manual backward. Witnessing him = <b>1/1D6 SAN</b>.</li><li>If interposed, he attacks with a heavy wrench (see <em>GM — NPCs</em>).</li></ul>"),
textPage("The Climax — The Broadcast",
@@ -232,6 +285,16 @@ const journal_gm_truth = journal("11-gm-truth", "⑪ GM — The Truth (What's Re
"<p>The tear cannot be fought &mdash; only <b>starved of signal.</b> Cut the primary power (blow the outside generator, or overload the dome transformer by hand at lethal cost) and the channel collapses: the entity snaps back and the vacuum implodes the dome. If the Agents <b>fail</b>, the broadcast completes, the doorway holds open, and Sable Ridge becomes a permanent cold spot that swallows the Notch over the following winter &mdash; a much larger Delta Green problem, later.</p>"),
]);
const journal_midpoint = journal("13-gm-midpoint", "⑬ GM — MIDPOINT: The Congregation", [
imagePage("Cold Cauldron Crossing", `${A}/map-crossing.svg`, "Ridge Road, mile 9 — the battle map is in the Scenes folder."),
textPage("The Setup",
"<p>The broadcast has been calling Corliss Notch for weeks &mdash; the hum, the radio that answers, the names. Tonight, as the transmitter nears full power, the most susceptible finally <b>answered</b>. <b>Agnes Corliss</b>, who has heard her dead husband in the static for months, has led five entranced neighbours up the Ridge Road to &#8220;meet the call.&#8221; And at Cold Cauldron Pond, the Tear has sent up what it kept: <b>the Petrie brothers.</b></p><p><b>Trigger:</b> run this when the Agents leave the equipment building for the dome (or try to drive back down). A jackknifed log truck blocks the road at mile 9; vehicles cluster with doors open, engines running; six figures stand in a circle in the snow, faces upturned, humming. The pond glimmers to the south. <b>Read-aloud:</b></p><blockquote><p><i>The snow parts around them like they've been standing there an hour. Old Agnes Corliss stands at the centre in her nightcoat, one hand pressing a dead man's radio handset to her ear. Without turning, she says your point-man's name &mdash; first, middle, last &mdash; in a voice that is not hers. From the pond shore, two big men in soaked hunting jackets begin to walk up the bank. They have been dead for ten weeks, and they are smiling like they're late for supper.</i></p></blockquote>"),
textPage("The Opposition",
"<p>Four actor sheets in the FALSE ALARM folder carry the full rules:</p><ul><li><b>Agnes Corliss, the Switchboard Widow</b> &mdash; the control piece. The Chorus (rolling SAN drain + concentration penalties through every radio the Agents carry), Stand Still (name spoken backward = lose an action), Speak the Names (weaponized Bonds). Frail &mdash; but screened by everyone else.</li><li><b>Dale &amp; Roy Petrie, Returned</b> &mdash; the muscle. Armor 3, ignore pain, boat hook and splitting maul, numbing grasp, and the <b>Baptism</b>: grappled Agents get dragged under the pond ice. Fire is doubled against them.</li><li><b>The Called</b> (&times;5) &mdash; the wall, and the moral trap. They grapple, restrain and carry; they never kill; they are crying while they do it. Killing one is 1/1D8 SAN and a cover-up catastrophe.</li></ul><p><b>Balance note (4 Agents):</b> this is deliberately unwinnable as a straight shootout &mdash; ~34 effective HP of armored Returned plus five bodies the Agents must not shoot, while Agnes strips actions and SAN. The door out is the <b>handset</b>.</p>"),
textPage("Running It — tactics, outs, aftermath",
"<p><b>Round order of intent:</b> the Called advance to restrain; the Returned make for whoever resists hardest (Roy goes for the loudest); Agnes stands still and takes Agents apart with words. Anyone dragged to the pond has 2 rounds before they go under.</p><p><b>Ways to win that aren't massacre:</b></p><ul><li><b>Cut the feed:</b> destroy or snatch Elias's handset (called shot &minus;20%, 6 HP, or grapple Agnes). Chorus dies, the Called wake, the Returned stop mid-stride &mdash; then turn and walk back into the pond.</li><li><b>Deafen her:</b> a flash-bang inside 10 m silences the Chorus for good.</li><li><b>Names:</b> HUMINT + shouting a Petrie's name buys a round per brother. Enough rounds to run.</li><li><b>Run the gauntlet:</b> ram past the log truck (Drive &minus;20% in snow). The Congregation follows &mdash; slowly, on foot, uphill, humming. They arrive at the compound for the climax if not dealt with.</li></ul><p><b>Aftermath:</b> freed Called remember everything as a dream of &#8220;the most beautiful voice.&#8221; Waking all five alive: <b>+1 SAN each Agent.</b> Agnes, if freed, keeps asking where Elias went &mdash; and once, flatly, in the wrong voice: <i>&#8220;IT IS ALMOST DONE LEARNING OUR SHAPE.&#8221;</i> That is the players' warning for Scene 3. Every civilian death here becomes a named problem in &#9451; Cover-Up.</p>"),
]);
const journal_gm_after = journal("12-gm-after", "⑫ GM — Cover-Up &amp; Aftermath", [
textPage("Loose ends",
"<ul><li><b>Burn</b> the frozen remains of Dr. Rostova (Thermite Charge) and manage her FAA record and next of kin (the sister in Vermont).</li><li><b>Stage the cruiser</b> as a horrific wildlife attack or a vehicular accident. Recover Vance's body if at all possible &mdash; the Notch will search for its deputy.</li><li><b>Wipe the FAA's remote cloud backups</b> (Computer Science) as well as the on-site drive.</li><li>The imploded dome sells the &#8220;catastrophic weather-generator failure&#8221; on its own &mdash; if nothing contradicts it. Loose talk in Corliss Notch is the real threat to the cover.</li></ul>"),
@@ -243,32 +306,42 @@ const journalArr = [
journal_briefing, journal_site, journal_area, journal_activity, journal_persons,
journal_radar, journal_audio, journal_tear,
journal_gm_scenes, journal_gm_threat, journal_gm_truth, journal_gm_after,
journal_midpoint,
].map((j) => { delete j._key; return j; });
// ---------------------------------------------------------------- scenes
function gridCfg() { return { type: 1, size: 100, style: "solidLines", thickness: 1, color: "#59ff87", alpha: 0.12, distance: 5, units: "ft" }; }
function gridCfg() { return { type: 1, size: 200, style: "solidLines", thickness: 1, color: "#59ff87", alpha: 0.10, distance: 5, units: "ft" }; }
function sceneToken(actor, x, y, hidden = false) {
const tid = id(`fa-tok-${actor._id}-${x}-${y}`);
// x/y are the CENTER of the intended map position (scenes use padding 0,
// so map pixels === scene coordinates); token docs store the top-left.
return { _id: tid, name: actor.name, actorId: actor._id, actorLink: false,
texture: { src: actor.prototypeToken.texture.src, anchorX: 0.5, anchorY: 0.5, scaleX: 1, scaleY: 1, fit: "contain" },
x, y, width: 1, height: 1, hidden, disposition: actor.prototypeToken.disposition, displayName: 20,
x: x - 100, y: y - 100, width: 1, height: 1, hidden, disposition: actor.prototypeToken.disposition, displayName: 20,
sight: { enabled: false }, sort: 0, flags: {} };
}
function scene(key, name, bg, w, h, tokens, notesFlag) {
return {
_id: id(`fa-scene-${key}`), name, active: false, navigation: true, navName: "",
width: w, height: h, padding: 0.25, background: { src: `${A}/${bg}` }, foreground: null,
width: w, height: h, padding: 0, background: { src: `${A}/${bg}` }, foreground: null,
grid: gridCfg(), initial: { x: Math.round(w / 2), y: Math.round(h / 2), scale: 0.5 },
backgroundColor: "#050708", tokenVision: false, fogExploration: false, globalLight: true, darkness: 0,
tokens, walls: [], lights: [], sounds: [], templates: [], notes: [], drawings: [], tiles: [],
folder: fScenes._id, sort: 0, ownership: { default: 0 }, flags: {}, _stats: STATS,
};
}
// Maps are 2x PNGs (200 px / 5-ft square) so they stay crisp when zoomed in.
const scenes = [
scene("1-arrival", "Scene 1 — The Arrival (Compound)", "map-compound.svg", 2600, 1800, []),
scene("2-server", "Scene 2 — The Server Room", "map-server-room.svg", 1800, 1400, [sceneToken(aRostova, 900, 780)]),
scene("3-dome", "Scene 3 — The Radar Dome", "map-radar-dome.svg", 2000, 2000,
[sceneToken(aVance, 1000, 1000), sceneToken(aTear, 1000, 1000, true)]),
scene("1-arrival", "Scene 1 — The Arrival (Compound)", "map-compound.png", 5200, 3600, []),
scene("2-server", "Scene 2 — The Server Room", "map-server-room.png", 3600, 2800, [sceneToken(aRostova, 1800, 1560)]),
scene("25-crossing", "Scene 2.5 — Cold Cauldron Crossing", "map-crossing.png", 4400, 3200, [
sceneToken(aAgnes, 2900, 1240),
sceneToken(aCalled, 2740, 1100), sceneToken(aCalled, 3070, 1140), sceneToken(aCalled, 2900, 1430),
sceneToken(aCalled, 2680, 1330), sceneToken(aCalled, 3100, 1350),
sceneToken(aDale, 3120, 2100), sceneToken(aRoy, 3280, 2170),
]),
scene("3-dome", "Scene 3 — The Radar Dome", "map-radar-dome.png", 4000, 4000,
[sceneToken(aVance, 2000, 2000), sceneToken(aTear, 2000, 2000, true)]),
];
// ---------------------------------------------------------------- adventure
+146
View File
@@ -531,3 +531,149 @@
border: 1px solid var(--dg2-hairline);
border-radius: 4px;
}
/* ------------------------------------------------------------------ */
/* Delta Green 2 - pause overlay (radar scope) */
/* ------------------------------------------------------------------ */
#pause.dg2-pause {
background: none;
}
/* Layout ONLY while actually paused - never override Foundry's hidden
(unpaused) state, or the stock GAME PAUSED markup shows after unpausing. */
#pause.dg2-pause.paused {
display: flex;
flex-direction: column;
align-items: center;
gap: 14px;
}
#pause.dg2-pause:not(.paused) {
display: none;
}
#pause.dg2-pause img { display: none; }
.dg2-pause-scope {
position: relative;
width: 190px;
height: 190px;
border-radius: 50%;
overflow: hidden;
border: 1px solid rgba(138, 163, 83, 0.5);
background:
radial-gradient(circle at 50% 50%, rgba(138, 163, 83, 0.10), rgba(10, 13, 16, 0.85) 70%);
box-shadow: 0 0 40px rgba(0, 0, 0, 0.75), inset 0 0 30px rgba(0, 0, 0, 0.6);
}
.dg2-pause-ring {
position: absolute; left: 50%; top: 50%; transform: translate(-50%, -50%);
border: 1px solid rgba(138, 163, 83, 0.30); border-radius: 50%;
}
.dg2-pause-ring.r1 { width: 32%; height: 32%; }
.dg2-pause-ring.r2 { width: 64%; height: 64%; }
.dg2-pause-ring.r3 { width: 94%; height: 94%; }
.dg2-pause-cross::before, .dg2-pause-cross::after {
content: ""; position: absolute; background: rgba(138, 163, 83, 0.22);
}
.dg2-pause-cross::before { left: 50%; top: 4%; bottom: 4%; width: 1px; }
.dg2-pause-cross::after { top: 50%; left: 4%; right: 4%; height: 1px; }
.dg2-pause-wedge {
position: absolute; inset: 0; border-radius: 50%;
animation: dg2-pause-spin 4s linear infinite;
background: conic-gradient(from 0deg at 50% 50%,
rgba(214, 240, 170, 0.85) 0deg,
rgba(138, 163, 83, 0) 6deg,
rgba(138, 163, 83, 0) 240deg,
rgba(138, 163, 83, 0.16) 310deg,
rgba(138, 163, 83, 0.45) 348deg,
rgba(214, 240, 170, 0.85) 360deg);
}
.dg2-pause-beam {
position: absolute; left: calc(50% - 1.5px); top: 3%; width: 3px; height: 47%;
transform-origin: bottom center;
animation: dg2-pause-spin 4s linear infinite;
background: linear-gradient(to top, rgba(230, 248, 195, 1), rgba(183, 216, 115, 0));
box-shadow: 0 0 12px rgba(183, 216, 115, 0.8);
}
.dg2-pause-hub {
position: absolute; left: 50%; top: 50%; width: 10px; height: 10px;
transform: translate(-50%, -50%); border-radius: 50%;
background: #c9e59a; box-shadow: 0 0 10px rgba(183, 216, 115, 0.9);
}
.dg2-pause-blip {
position: absolute; width: 6px; height: 6px; border-radius: 50%;
background: #c9e59a; box-shadow: 0 0 8px rgba(183, 216, 115, 0.9);
opacity: 0; animation: dg2-pause-blip 4s linear infinite;
}
.dg2-pause-blip.b1 { left: 64%; top: 36%; animation-delay: -0.6s; }
.dg2-pause-blip.b2 { left: 40%; top: 62%; animation-delay: -2.4s; }
@keyframes dg2-pause-spin { to { transform: rotate(360deg); } }
@keyframes dg2-pause-blip { 0% { opacity: 0.95; } 14% { opacity: 0.45; } 32% { opacity: 0; } 100% { opacity: 0; } }
.dg2-pause-caption {
display: flex; flex-direction: column; align-items: center; gap: 4px;
font-family: "PublicSans", sans-serif;
}
.dg2-pause-title {
font-size: 1.5rem; font-weight: 800; letter-spacing: 8px; text-transform: uppercase;
color: #e6ecef; text-shadow: 0 0 18px rgba(138, 163, 83, 0.55);
animation: dg2-pause-flicker 3.2s infinite;
}
.dg2-pause-sub {
font-size: 0.72rem; letter-spacing: 4px; color: #8aa353; text-transform: uppercase;
}
@keyframes dg2-pause-flicker {
0%, 100% { opacity: 1; }
92% { opacity: 1; }
93% { opacity: 0.35; }
94% { opacity: 1; }
96% { opacity: 0.6; }
97% { opacity: 1; }
}
/* ------------------------------------------------------------------ */
/* Delta Green 2 - critical event chat cards */
/* ------------------------------------------------------------------ */
.chat-message.dg2-msg-providence { border-left-color: #d6b45a; }
.chat-message.dg2-msg-providence .dg2-card-strip .dg2-card-type { color: #e8cd7a; }
.chat-message.dg2-msg-providence .dg2-chip {
color: #e8cd7a; background: rgba(214, 180, 90, 0.12); border-color: rgba(214, 180, 90, 0.5);
}
.chat-message.dg2-msg-calamity { border-left-color: #8b1a12; }
.chat-message.dg2-msg-calamity { animation: dg2-calamity-in 0.6s ease-out; }
.chat-message.dg2-msg-calamity .dg2-card-strip .dg2-card-type { color: #ff6a4d; }
.chat-message.dg2-msg-calamity .dg2-chip {
color: #ff8d75; background: rgba(139, 26, 18, 0.2); border-color: rgba(184, 50, 38, 0.6);
}
@keyframes dg2-calamity-in {
0% { transform: translateX(0); }
20% { transform: translateX(-4px); }
40% { transform: translateX(4px); }
60% { transform: translateX(-2px); }
80% { transform: translateX(2px); }
100% { transform: translateX(0); }
}
.dg2-crit-event .dg2-crit-lede {
font-size: 0.85rem; color: #aeb9c0; margin: 2px 0 6px;
}
.dg2-crit-event .dg2-crit-text {
font-size: 0.9rem; line-height: 1.45; color: #e6ecef;
border-top: 1px solid #272b32; padding-top: 6px;
}
+4 -1
View File
@@ -402,5 +402,8 @@
"DG2.Chat.LethalForce": "Lethal Force",
"DG2.Chat.TheUnnatural": "The Unnatural",
"DG2.Chat.EyesOnly": "Delta Green // Eyes Only",
"DG2.Chat.Target": "Target"
"DG2.Chat.Target": "Target",
"DG2.PausedSub": "All stations hold // awaiting Handler",
"DG.Settings.criticalEvents.name": "Critical Events (Home Rule)",
"DG.Settings.criticalEvents.hint": "A natural 01 on a percentile test triggers a d6 Providence boon; a natural 00 triggers a d6 Calamity. Escalating tables, announced in chat; the Handler applies effects."
}
+66 -11
View File
@@ -37,6 +37,24 @@ import RitualItemData from "./data/item/ritual.js";
const { Actors, Items } = foundry.documents.collections;
Hooks.once("init", async () => {
// Own the pause UI: applying our overlay inside _onRender means it is part
// of EVERY render of the pause application - no hook/timing races.
try {
const BasePause = foundry.applications?.ui?.GamePause;
if (BasePause) {
class DG2GamePause extends BasePause {
/** @override */
async _onRender(context, options) {
await super._onRender?.(context, options);
applyDG2PauseOverlay(this.element);
}
}
CONFIG.ui.pause = DG2GamePause;
}
} catch (ex) {
console.warn("Delta Green 2 | could not register pause subclass", ex);
}
Object.assign(CONFIG.Actor.dataModels, {
// The keys are the types defined in system.json documentTypes.Actor
agent: AgentData,
@@ -221,23 +239,60 @@ Hooks.once("diceSoNiceReady", (dice3d) => {
}
});
Hooks.on("renderGamePause", function (_, html, options) {
if (options.cssClass !== "paused") return;
/**
* Replace the stock pause spinner with the DG2 radar-scope animation.
* Idempotent: safe to call on every render/toggle; skips if already applied.
* Wired to BOTH renderGamePause and pauseGame so the scope appears no matter
* which path Foundry takes when the pause state changes.
*/
function applyDG2PauseOverlay(element) {
try {
const gamePausedOverrideText = game.i18n.translations.DG.MissionPaused;
const el = element ?? document.getElementById("pause");
if (!el || !game.paused) return;
if (el.querySelector(".dg2-pause-scope")) return; // already applied
if (typeof gamePausedOverrideText === "undefined") {
return;
}
const caption = game.i18n.translations.DG?.MissionPaused ?? "Mission Paused";
const sub =
game.i18n.translations.DG2?.PausedSub ??
"All stations hold // awaiting Handler";
html.querySelector("figcaption").textContent = gamePausedOverrideText;
html.querySelector("img").classList.remove("fa-spin"); // I don't like the logo spinning personally
} catch {
// noop
el.classList.add("dg2-pause");
el.innerHTML = `
<div class="dg2-pause-scope" aria-hidden="true">
<span class="dg2-pause-ring r1"></span>
<span class="dg2-pause-ring r2"></span>
<span class="dg2-pause-ring r3"></span>
<span class="dg2-pause-cross"></span>
<span class="dg2-pause-wedge"></span>
<span class="dg2-pause-beam"></span>
<span class="dg2-pause-hub"></span>
<span class="dg2-pause-blip b1"></span>
<span class="dg2-pause-blip b2"></span>
</div>
<figcaption class="dg2-pause-caption">
<span class="dg2-pause-title">${caption}</span>
<span class="dg2-pause-sub">${sub}</span>
</figcaption>`;
} catch (ex) {
console.warn("Delta Green 2 | pause overlay failed", ex);
}
}
Hooks.on("renderGamePause", (_app, html) => {
const el = html instanceof HTMLElement ? html : html?.[0];
applyDG2PauseOverlay(el);
});
Hooks.on("pauseGame", () => {
// Fallback for cores where the pause subclass isn't registered: re-apply
// after Foundry's own render, with retries to outlast a slow re-render.
for (const delay of [50, 250, 700]) {
setTimeout(() => applyDG2PauseOverlay(), delay);
}
});
Hooks.once("ready", () => applyDG2PauseOverlay());
function addEventListenerToChatMessage(element) {
element.addEventListener("click", (event) => {
const btnWithAction = event.target.closest("button[data-action]");
+123
View File
@@ -0,0 +1,123 @@
/**
* Delta Green 2 home rule — critical events.
*
* A natural 01 on any percentile test triggers PROVIDENCE: a d6 roll on an
* escalating table of boons (1 = a small mercy, 6 = the whole cell benefits).
* A natural 00 (100) triggers CALAMITY: a d6 roll on an escalating table of
* misfortunes (1 = bad but not crippling, 6 = everyone at the table pays).
*
* Events are announced with a dramatic chat card; the Handler applies the
* mechanical effects. Toggled by the `criticalEvents` automation setting.
*/
import { tagRollDice } from "../integrations/dice-so-nice.js";
const PROVIDENCE = [
{
title: "A MOMENT OF CLARITY",
text: "For one impossible second, the world makes sense. The static in the Agent's head goes quiet. <b>Recover 1D4 SAN.</b>",
},
{
title: "MUSCLE MEMORY",
text: "Training the Agent forgot they had takes over completely. <b>Mark this skill for improvement</b> as if it had been earned the hard way &mdash; and it costs nothing tonight.",
},
{
title: "ADRENALINE EDGE",
text: "Time dilates. Every detail is crystal. The Agent gains <b>+20% to their next test this scene</b>, any skill.",
},
{
title: "THE PROGRAM PROVIDES",
text: "Against all odds, exactly the right thing is at hand &mdash; a spare magazine, a working flashlight, a door someone forgot to lock. <b>Recover 1D4 WP and gain +20% to the Agent's next test this scene.</b>",
},
{
title: "UNTOUCHABLE",
text: "Tonight, the dark looks elsewhere. <b>The next attack or hazard that would harm the Agent this scene misses, glances off, or simply&hellip; doesn't.</b>",
},
{
title: "THE CELL IN PERFECT SYNC",
text: "One flawless action locks the whole team into rhythm &mdash; hand signals, breath, heartbeat. <b>Every Agent in the scene recovers 1 WP and gains +20% to their next test.</b> For a moment, Delta Green is winning.",
},
];
const CALAMITY = [
{
title: "EQUIPMENT FAILURE",
text: "The tool betrays its owner &mdash; the weapon jams, the lockpick snaps, the radio dies mid-word. <b>The item used is fouled or broken until repaired or reloaded.</b> Bad. Not crippling. Yet.",
},
{
title: "BLOOD ON THE SNOW",
text: "A misstep, a torn seam, an edge in the dark. <b>The Agent takes 1D4 damage</b> &mdash; nothing fatal, but it will be felt for the rest of the night.",
},
{
title: "RATTLED",
text: "The failure gets inside. Hands shake; the checklist scatters. <b>The Agent loses 1D4 WP and takes &minus;20% on their next test this scene.</b>",
},
{
title: "SOMETHING NOTICED",
text: "In the wake of the blunder there is a pause &mdash; the sense of an ear turning. <b>The Agent makes a SAN test (0/1D4).</b> Whatever is out there now knows one of their names.",
},
{
title: "THE PRICE IS PAID IN FULL",
text: "Everything that could go wrong, does, at once. <b>The Agent takes 1D6 damage AND their equipped item is dropped or broken.</b> Somewhere, quietly, a door that was closed is now open.",
},
{
title: "IT SEES ALL OF YOU",
text: "The failure tears something open, and for a heartbeat every Agent stands in the light of it. <b>Everyone in the scene loses 0/1D4 SAN and takes &minus;20% on their next test.</b> Radios whisper. Lights gutter. It knows the cell exists.",
},
];
/**
* Check an evaluated percentile roll for a natural 01 / 00 and, if the
* home rule is enabled and the roller is an Agent, post the event card.
*
* @param {DGPercentileRoll} roll - the evaluated roll (post-toChat).
* @returns {Promise<ChatMessage|null>}
*/
export async function maybeTriggerCriticalEvent(roll) {
try {
if (!game.settings.get("deltagreen2", "criticalEvents")) return null;
if (roll.actor?.type !== "agent") return null;
if (roll.total !== 1 && roll.total !== 100) return null;
const providence = roll.total === 1;
const d6 = new Roll("1d6");
await d6.evaluate();
tagRollDice(d6, providence ? "dg2-agency" : "dg2-unnatural");
const entry = (providence ? PROVIDENCE : CALAMITY)[d6.total - 1];
const actorName = roll.actor?.name ?? "The Agent";
const rollName = roll.localizedKey ?? "the test";
const strip = providence
? { type: "PROVIDENCE — NATURAL 01", cls: "dg2-msg-providence", icon: "fa-star-of-life" }
: { type: "CALAMITY — NATURAL 00", cls: "dg2-msg-calamity", icon: "fa-radiation" };
const lede = providence
? `For one impossible moment, everything breaks <i>${actorName}'s</i> way on <b>${rollName}</b>.`
: `On <b>${rollName}</b>, the night turns on <i>${actorName}</i>.`;
const content = `
<div class="dg2-card dg2-crit-event">
<div class="dg2-card-strip">
<span class="dg2-card-type">${strip.type}</span>
<span class="dg2-card-org">${game.i18n.localize("DG2.Chat.EyesOnly")}</span>
</div>
<div class="dg2-card-title">
<span class="dg2-card-name">${entry.title}</span>
<span class="dg2-chip"><i class="fas fa-dice-d6"></i> ${d6.total} / 6</span>
</div>
<p class="dg2-crit-lede">${lede}</p>
<p class="dg2-crit-text">${entry.text}</p>
</div>`;
return await ChatMessage.create({
content,
rolls: [d6],
sound: CONFIG.sounds.dice,
speaker: ChatMessage.getSpeaker({ actor: roll.actor }),
flags: { deltagreen2: { variant: providence ? "providence" : "calamity" } },
});
} catch (ex) {
console.warn("Delta Green 2 | critical event failed", ex);
return null;
}
}
+5 -1
View File
@@ -5,6 +5,7 @@ import {
tagRollDice,
colorsetForPercentileRoll,
} from "../integrations/dice-so-nice.js";
import { maybeTriggerCriticalEvent } from "../other/critical-events.js";
const { renderTemplate } = foundry.applications.handlebars;
const { DialogV2 } = foundry.applications.api;
@@ -410,10 +411,13 @@ export class DGPercentileRoll extends DGRoll {
[keyForUpdate]: true,
});
await maybeTriggerCriticalEvent(this);
return message;
}
return this.toMessage({ content: html });
const message = await this.toMessage({ content: html });
await maybeTriggerCriticalEvent(this);
return message;
}
/**
+4
View File
@@ -220,6 +220,10 @@ class AutomationSettings extends SettingForm {
default: true,
type: Boolean,
},
criticalEvents: {
default: true,
type: Boolean,
},
};
}
}
Binary file not shown.
+3 -3
View File
@@ -1,3 +1,3 @@
2026/07/16-10:43:02.339226 1702bf000 Recovering log #4
2026/07/16-10:43:02.340194 1702bf000 Delete type=0 #4
2026/07/16-10:43:02.340235 1702bf000 Delete type=3 #2
2026/07/17-00:01:55.659195 16ea8f000 Recovering log #4
2026/07/17-00:01:55.660325 16ea8f000 Delete type=0 #4
2026/07/17-00:01:55.660363 16ea8f000 Delete type=3 #2
+5 -5
View File
@@ -1,5 +1,5 @@
2026/07/16-10:42:47.050877 171d97000 Delete type=3 #1
2026/07/16-10:42:47.053360 17262f000 Level-0 table #5: started
2026/07/16-10:42:47.053724 17262f000 Level-0 table #5: 19547 bytes OK
2026/07/16-10:42:47.053835 17262f000 Delete type=0 #3
2026/07/16-10:42:47.053915 17262f000 Manual compaction at level-0 from '!adventures!5zf355zpx5s1ux56' @ 72057594037927935 : 1 .. '!adventures!5zf355zpx5s1ux56' @ 0 : 0; will stop at (end)
2026/07/17-00:01:55.574374 16fbe7000 Delete type=3 #1
2026/07/17-00:01:55.577581 17047f000 Level-0 table #5: started
2026/07/17-00:01:55.578252 17047f000 Level-0 table #5: 28866 bytes OK
2026/07/17-00:01:55.578476 17047f000 Delete type=0 #3
2026/07/17-00:01:55.578633 17047f000 Manual compaction at level-0 from '!adventures!5zf355zpx5s1ux56' @ 72057594037927935 : 1 .. '!adventures!5zf355zpx5s1ux56' @ 0 : 0; will stop at (end)
Binary file not shown.
+1 -1
View File
@@ -15,7 +15,7 @@
"discord": "jalensailin"
}
],
"version": "2.0.0",
"version": "2.1.0",
"compatibility": {
"minimum": "13",
"verified": "14",