From 3089233e025eef8abd0468a15204f94694e6065e Mon Sep 17 00:00:00 2001 From: Tim Evans Date: Mon, 20 Jul 2026 16:54:25 +0100 Subject: [PATCH] feat: wire clue safety-net into shell + rail; add example scenario --- src/components/director-rail.js | 4 +++ src/components/gm-shell.js | 19 +++++++++- src/scenarios/example-with-clues.js | 19 ++++++++++ src/styles.css | 17 +++++++++ tests/components/gm-shell-clues.test.js | 47 +++++++++++++++++++++++++ 5 files changed, 105 insertions(+), 1 deletion(-) create mode 100644 src/scenarios/example-with-clues.js create mode 100644 tests/components/gm-shell-clues.test.js diff --git a/src/components/director-rail.js b/src/components/director-rail.js index cb3bc5b..567bae6 100644 --- a/src/components/director-rail.js +++ b/src/components/director-rail.js @@ -29,6 +29,7 @@ export class DirectorRail extends HTMLElement { + `; this.querySelector('[data-role=reached]').addEventListener('click', () => { @@ -43,6 +44,9 @@ export class DirectorRail extends HTMLElement { this.querySelector('[data-role=open-art]').addEventListener('click', () => { this.dispatchEvent(new CustomEvent('open-tool', { detail: { tool: 'art' }, bubbles: true })); }); + this.querySelector('[data-role=open-clues]').addEventListener('click', () => { + this.dispatchEvent(new CustomEvent('open-tool', { detail: { tool: 'clues' }, bubbles: true })); + }); } } customElements.define('director-rail', DirectorRail); diff --git a/src/components/gm-shell.js b/src/components/gm-shell.js index baca761..c5aebe0 100644 --- a/src/components/gm-shell.js +++ b/src/components/gm-shell.js @@ -6,6 +6,7 @@ import './dice-tray.js'; import './npc-tray.js'; import './art-tray.js'; import './prop-viewer.js'; +import './clue-net.js'; export class GmShell extends HTMLElement { constructor() { @@ -16,6 +17,7 @@ export class GmShell extends HTMLElement { this.clockState = initialClock(); this.stamps = {}; this.cast = []; + this.revealedClues = []; this._tick = null; } @@ -26,12 +28,14 @@ export class GmShell extends HTMLElement { + `; this.addEventListener('reached', () => this.onReached()); this.addEventListener('open-tool', (e) => this.onOpenTool(e)); this.addEventListener('keep-npc', (e) => this.onKeepNpc(e)); this.addEventListener('show-prop', (e) => this.onShowProp(e)); + this.addEventListener('toggle-clue', (e) => this.onToggleClue(e)); } loadScenario(scenario) { @@ -41,6 +45,10 @@ export class GmShell extends HTMLElement { this.stamps = this.store.get('stamps', {}); this.cast = this.store.get('cast', []); this.querySelector('dice-tray').systemId = scenario.meta.system; + this.revealedClues = this.store.get('revealedClues', []); + const net = this.querySelector('clue-net'); + net.clues = this.scenario.clues || []; + net.revealed = this.revealedClues; this.refresh(); } @@ -63,7 +71,7 @@ export class GmShell extends HTMLElement { onOpenTool(e) { const tool = e.detail?.tool; - const trays = { dice: 'dice-tray', npc: 'npc-tray', art: 'art-tray' }; + const trays = { dice: 'dice-tray', npc: 'npc-tray', art: 'art-tray', clues: 'clue-net' }; if (!(tool in trays)) return; const target = this.querySelector(trays[tool]); const willShow = target.hidden; @@ -76,6 +84,15 @@ export class GmShell extends HTMLElement { this.store.set('cast', this.cast); } + onToggleClue(e) { + const id = e.detail.id; + this.revealedClues = this.revealedClues.includes(id) + ? this.revealedClues.filter((x) => x !== id) + : [...this.revealedClues, id]; + this.store.set('revealedClues', this.revealedClues); + this.querySelector('clue-net').revealed = this.revealedClues; + } + onShowProp(e) { this.querySelector('prop-viewer').show(e.detail.src, e.detail.label); } diff --git a/src/scenarios/example-with-clues.js b/src/scenarios/example-with-clues.js new file mode 100644 index 0000000..57157c3 --- /dev/null +++ b/src/scenarios/example-with-clues.js @@ -0,0 +1,19 @@ +// src/scenarios/example-with-clues.js +// TEMPLATE / EXAMPLE — not a convention scenario. Demonstrates the clue model +// for the safety-net. Copy this shape into a real scenario's `clues: []`. +export default { + meta: { id: 'example-clues', title: 'Example — Clue Net', system: 'year-zero', players: 4, playMinutes: 210, slot: 'Example' }, + timeline: [ + { id: 'a1', act: 1, label: 'Opening', targetMin: 0, hardTrigger: true }, + { id: 'a2', act: 2, label: 'The reveal', targetMin: 90, hardTrigger: true }, + { id: 'a3', act: 3, label: 'Resolution', targetMin: 175, hardTrigger: true }, + ], + clues: [ + { id: 'motive', label: 'The motive', essential: true, act: 'Act 1', fallback: 'The letter spells it out if missed' }, + { id: 'means', label: 'The means', essential: true, act: 'Act 2', fallback: 'The coroner volunteers it at the morgue' }, + { id: 'opportunity', label: 'The opportunity', essential: true, act: 'Act 2', fallback: 'The timetable is pinned to the wall' }, + { id: 'flavour', label: 'A colour detail', essential: false, act: 'Act 1' }, + ], + cast: [], + props: [], +}; diff --git a/src/styles.css b/src/styles.css index 16fae5d..ed3162b 100644 --- a/src/styles.css +++ b/src/styles.css @@ -61,3 +61,20 @@ prop-viewer[hidden] { display: none; } .params { display: flex; gap: 10px; flex-wrap: wrap; margin: 8px 0; } .param { font-size: 11px; color: #9fb0c2; display: flex; flex-direction: column; gap: 3px; } .param input { width: 80px; background: #161d27; border: 1px solid #2a3542; border-radius: 6px; padding: 6px 8px; color: #e7eef6; } + +/* Slice 4 clue safety-net */ +.clue-net { position: absolute; top: 0; left: 0; right: 0; background: #0f141b; border-bottom: 1px solid #2b3644; padding: 14px; display: flex; flex-direction: column; gap: 10px; max-height: 100%; overflow-y: auto; } +clue-net[hidden] { display: none; } +.clue-banner { padding: 8px 12px; border-radius: 8px; font-weight: 700; font-size: 13px; } +.clue-banner.ok { background: #17251c; color: #8ff0b6; } +.clue-banner.warn { background: #2a2013; color: #ffd479; } +.clue-list { display: flex; flex-direction: column; gap: 6px; } +.clue-row { display: flex; gap: 10px; align-items: flex-start; padding: 8px; border: 1px solid #263041; border-radius: 8px; } +.clue-row.revealed { opacity: .6; } +.clue-toggle { width: 34px; height: 34px; border-radius: 8px; border: 1px solid #2a3542; background: #161d27; color: #cfe0f0; font-size: 16px; flex: 0 0 auto; } +.clue-body { flex: 1; } +.clue-label { font-size: 13px; color: #e7eef6; } +.clue-ess { font-size: 9px; text-transform: uppercase; letter-spacing: .08em; color: #ffd479; border: 1px solid #4a3a1f; border-radius: 10px; padding: 1px 6px; } +.clue-act { font-size: 10px; color: #7f8ea0; } +.clue-fallback { margin-top: 4px; font-size: 12px; color: #ff9c9c; } +.clue-empty { color: #6b7a8d; font-size: 12px; } diff --git a/tests/components/gm-shell-clues.test.js b/tests/components/gm-shell-clues.test.js new file mode 100644 index 0000000..73eca89 --- /dev/null +++ b/tests/components/gm-shell-clues.test.js @@ -0,0 +1,47 @@ +import { describe, it, expect, beforeEach } from 'vitest'; +import '../../src/components/gm-shell.js'; +import example from '../../src/scenarios/example-with-clues.js'; + +describe(' clue safety-net', () => { + let el; + beforeEach(() => { + localStorage.clear(); + document.body.innerHTML = ''; + el = document.createElement('gm-shell'); + el.now = () => 0; + document.body.appendChild(el); + el.loadScenario(example); + }); + + it('mounts a hidden clue-net fed from the scenario clues', () => { + const net = el.querySelector('clue-net'); + expect(net).not.toBe(null); + expect(net.hidden).toBe(true); + expect(net.clues.length).toBe(example.clues.length); + }); + + it('open-tool clues reveals the clue-net', () => { + el.dispatchEvent(new CustomEvent('open-tool', { detail: { tool: 'clues' }, bubbles: true })); + expect(el.querySelector('clue-net').hidden).toBe(false); + }); + + it('toggle-clue records the id, persists it, and updates the net', () => { + const id = example.clues[0].id; + el.dispatchEvent(new CustomEvent('toggle-clue', { detail: { id }, bubbles: true })); + expect(el.revealedClues).toContain(id); + const persisted = JSON.parse(localStorage.getItem('gmd.' + example.meta.id + '.revealedClues')); + expect(persisted).toContain(id); + expect(el.querySelector('clue-net').revealed).toContain(id); + // toggling again removes it + el.dispatchEvent(new CustomEvent('toggle-clue', { detail: { id }, bubbles: true })); + expect(el.revealedClues).not.toContain(id); + }); + + it('the rail exposes a clues chip that emits open-tool', () => { + const rail = el.querySelector('director-rail'); + let tool = null; + el.addEventListener('open-tool', (e) => { tool = e.detail.tool; }); + rail.querySelector('[data-role=open-clues]').click(); + expect(tool).toBe('clues'); + }); +});