feat: wire clue safety-net into shell + rail; add example scenario

This commit is contained in:
2026-07-20 16:54:25 +01:00
parent 77148bf0f8
commit 3089233e02
5 changed files with 105 additions and 1 deletions
+4
View File
@@ -29,6 +29,7 @@ export class DirectorRail extends HTMLElement {
<button class="tray-btn" data-role="open-dice" aria-label="Dice">🎲</button>
<button class="tray-btn" data-role="open-npc" aria-label="NPC">👤</button>
<button class="tray-btn" data-role="open-art" aria-label="Art">✏️</button>
<button class="tray-btn" data-role="open-clues" aria-label="Clues">🔍</button>
</div>`;
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);
+18 -1
View File
@@ -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 {
<dice-tray hidden></dice-tray>
<npc-tray hidden></npc-tray>
<art-tray hidden></art-tray>
<clue-net hidden></clue-net>
</main>
<prop-viewer></prop-viewer>`;
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);
}
+19
View File
@@ -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: [],
};
+17
View File
@@ -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; }
+47
View File
@@ -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('<gm-shell> 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');
});
});