feat: Slice 5 — convention hub (live now / up next, deep-links)
Add the convention hub landing screen: a pure schedule engine (analyzeSchedule) classifies each slot as live/next/done/upcoming from an injected now; <con-hub> renders the slate + summary and emits open-scenario; a scenario registry resolves a slot's scenarioId to its module; gm-shell routes hub<->session (hiding/restoring the Rail) with a home rail chip. Ships continuum-2026.js with the real slate (only AFTERIMAGE ported; others scenarioId:null, start-times TBC) and a fully-timed example-con.js template/fixture. 122 tests pass; build clean. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
a7a7870a76
commit
b57880d630
@@ -57,6 +57,10 @@ Components are dumb renderers that emit events; the shell wires them together.
|
||||
| `src/npc/` | NPC generator (`generator.js`) + genre table packs (`packs/noir.js`) |
|
||||
| `src/art/` | Pencil-art library search (`search.js`) + tagged manifest |
|
||||
| `src/clues/safety-net.js` | Clue safety-net engine (essential-gap + solvability) |
|
||||
| `src/con/schedule.js` | Pure convention-schedule analysis (live / up-next / done / upcoming) |
|
||||
| `src/components/con-hub.js` | `<con-hub>` — the convention landing screen; deep-links into a scenario |
|
||||
| `src/scenarios/index.js` | Scenario registry (`getScenario(id)`) the hub deep-links through |
|
||||
| `src/con/` | Convention data (`continuum-2026.js`; `example-con.js` is a fully-timed template) |
|
||||
| `src/components/director-rail.js` | `<director-rail>` — the always-on pacing bar (🎲 👤 ✏️ 🔍 chips) |
|
||||
| `src/components/dice-tray.js` | `<dice-tray>` — pack selector + die picker + roll + verdict |
|
||||
| `src/components/npc-tray.js` · `art-tray.js` · `prop-viewer.js` · `clue-net.js` | The tray tools |
|
||||
@@ -92,8 +96,8 @@ compress if you reach a beat behind schedule.
|
||||
2. **Slice 2** — NPC generator (offline genre tables) + art generator (offline pencil-art library). ✅
|
||||
3. **Slice 3** — dice rule-packs (CoC d100, VANITY d6-pool, Panic & Glory, Dee Sanction) + tray pack selector. ✅
|
||||
4. **Slice 4** — the clue **safety-net** (essential-clue gap tracker + fallbacks). ✅
|
||||
5. **Slice 5** — the convention **hub** (all slots, live "up next / live now").
|
||||
6. **Later** — remaining tray tools (break timer, parking-lot note, wake-lock); online art "Generate"; scenario porting from the Continuum docs; markdown → scenario-data generator; native iPad wrapper.
|
||||
5. **Slice 5** — the convention **hub** (all slots, live "live now / up next / done", deep-links into each scenario). ✅
|
||||
6. **Later** — remaining tray tools (break timer, parking-lot note, wake-lock); online art "Generate"; scenario porting from the Continuum docs; real slot start-times for the hub; markdown → scenario-data generator; native iPad wrapper.
|
||||
|
||||
## Docs
|
||||
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
# The Director — Slice 5 Implementation Plan
|
||||
|
||||
**Goal:** Add the convention **hub** — a landing screen listing every slot of a convention with
|
||||
live **"live now / up next / done / upcoming"** status, that deep-links into each ported scenario
|
||||
and lets the GM return to the hub from a running session.
|
||||
|
||||
**Architecture:** Same pattern as the other slices — a pure engine (`analyzeSchedule`) that classifies
|
||||
slots from a wall-clock `now`, a `<con-hub>` light-DOM component that renders the slot list + summary
|
||||
and emits `open-scenario`, a scenario **registry** (`src/scenarios/index.js`) resolving a slot's
|
||||
`scenarioId` to its module, and shell routing that switches between the hub and a session (hiding the
|
||||
Rail/stage in hub mode) plus a 🏠 rail chip to return. No fabricated content: only AFTERIMAGE is
|
||||
ported, so `continuum-2026.js` marks the other four slots `scenarioId: null` ("Not yet ported") with
|
||||
`startsAt: null` (times TBC, not invented); a clearly-labelled `example-con.js` provides fully-timed
|
||||
slots as the demo/test fixture.
|
||||
|
||||
**Tech Stack:** JavaScript (ES modules, no TypeScript), Web Components (light DOM), Vite, Vitest +
|
||||
jsdom. No new dependencies. Fully offline.
|
||||
|
||||
## Global Constraints
|
||||
|
||||
- **Offline-first, no runtime framework, light DOM, no TypeScript.**
|
||||
- **Con model:** `{ meta: { id, title }, slots: [ { id, scenarioId, title, slot, system?, players?, playMinutes?, startsAt? } ] }`.
|
||||
`slot` is the display label; `startsAt` (ISO) drives timing; `scenarioId` deep-links via the registry
|
||||
(null ⇒ not yet ported). A null/absent `startsAt` ⇒ unscheduled: listed, never live/next.
|
||||
- **Determinism:** `analyzeSchedule(slots, nowMs)` is pure; `<con-hub>` takes an injected `now()` like the shell.
|
||||
- **Escaping:** all author text (titles, slot labels, systems) via `escapeHtml`.
|
||||
- **Rail invariant preserved:** the hub is a *pre-session* screen; the Rail is hidden in hub mode and
|
||||
restored (never removed) when a session is open.
|
||||
|
||||
## Tasks (all complete)
|
||||
|
||||
1. **Schedule engine** — `src/con/schedule.js` (`analyzeSchedule`, `slotStartMs`) + `tests/con/schedule.test.js` (6).
|
||||
2. **Scenario registry** — `src/scenarios/index.js` (`SCENARIOS`, `getScenario`) + `tests/scenarios/registry.test.js` (2).
|
||||
3. **`<con-hub>` component** — `src/components/con-hub.js` + `tests/components/con-hub.test.js` (6).
|
||||
4. **Con data** — `src/con/continuum-2026.js` (real slate, honest placeholders) + `src/con/example-con.js` (template/fixture).
|
||||
5. **Shell + rail wiring** — `gm-shell.js` (`loadCon`/`showHub`/`showSession`/`onOpenScenario`, `open-hub`),
|
||||
`director-rail.js` (🏠 chip), `styles.css`, boot in `main.js` → `loadCon(continuum2026)` +
|
||||
`tests/components/gm-shell-hub.test.js` (4).
|
||||
|
||||
## Validation
|
||||
|
||||
- `npm test` → 122 passing (34 files); build succeeds (35 modules). Manual: hub renders the Continuum
|
||||
slate, "Open" deep-links AFTERIMAGE into a live Rail session, 🏠 returns to the hub. Console clean.
|
||||
|
||||
## Deferred (backlog, not this slice)
|
||||
|
||||
- Real slot **start times** for Continuum 2026 (add each `startsAt` to light up live/up-next).
|
||||
- Porting the four remaining scenarios (Day One, Vain Crown, Another Fine Mess, Prince's Bride) into
|
||||
scenario modules + registering them (then flip each slot's `scenarioId`).
|
||||
@@ -0,0 +1,66 @@
|
||||
import { analyzeSchedule } from '../con/schedule.js';
|
||||
import { escapeHtml } from '../core/escape-html.js';
|
||||
|
||||
const STATUS_LABEL = { live: 'Live now', next: 'Up next', done: 'Done', upcoming: 'Upcoming' };
|
||||
|
||||
export class ConHub extends HTMLElement {
|
||||
constructor() {
|
||||
super();
|
||||
this._con = null;
|
||||
this.now = () => Date.now();
|
||||
}
|
||||
connectedCallback() { this.render(); }
|
||||
|
||||
set con(v) { this._con = v || null; this.render(); }
|
||||
get con() { return this._con; }
|
||||
|
||||
render() {
|
||||
if (!this._con) {
|
||||
this.innerHTML = `<div class="hub hub--empty" data-role="empty">No convention loaded</div>`;
|
||||
return;
|
||||
}
|
||||
const con = this._con;
|
||||
const a = analyzeSchedule(con.slots || [], this.now());
|
||||
|
||||
const summary = a.liveNow
|
||||
? `<span class="hub-live">● Live now — ${escapeHtml(a.liveNow.title)}</span>`
|
||||
: (a.upNext
|
||||
? `<span class="hub-next">Up next — ${escapeHtml(a.upNext.title)}</span>`
|
||||
: `<span class="hub-idle">No sessions scheduled</span>`);
|
||||
|
||||
const cards = a.slots.map((s) => {
|
||||
const canOpen = !!s.scenarioId;
|
||||
const meta = [s.slot, s.system, s.players ? `${s.players}p` : null]
|
||||
.filter(Boolean).map(escapeHtml).join(' · ');
|
||||
const action = canOpen
|
||||
? `<button class="hub-open" data-role="open" data-scenario-id="${escapeHtml(s.scenarioId)}">Open</button>`
|
||||
: `<span class="hub-soon" data-role="not-ready">Not yet ported</span>`;
|
||||
return `
|
||||
<div class="hub-card status-${s.status}" data-slot-id="${escapeHtml(s.id)}" data-status="${s.status}">
|
||||
<div class="hub-card-main">
|
||||
<div class="hub-card-title">${escapeHtml(s.title)} <span class="hub-badge badge-${s.status}">${STATUS_LABEL[s.status]}</span></div>
|
||||
<div class="hub-card-meta">${meta}</div>
|
||||
</div>
|
||||
${action}
|
||||
</div>`;
|
||||
}).join('');
|
||||
|
||||
this.innerHTML = `
|
||||
<div class="hub">
|
||||
<header class="hub-head">
|
||||
<h1 class="hub-title">${escapeHtml(con.meta?.title || 'Convention')}</h1>
|
||||
<div class="hub-summary" data-role="summary">${summary}</div>
|
||||
</header>
|
||||
<div class="hub-list">${cards}</div>
|
||||
</div>`;
|
||||
|
||||
this.querySelectorAll('[data-role=open]').forEach((btn) => {
|
||||
btn.addEventListener('click', () => {
|
||||
this.dispatchEvent(new CustomEvent('open-scenario', {
|
||||
detail: { scenarioId: btn.dataset.scenarioId }, bubbles: true,
|
||||
}));
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
customElements.define('con-hub', ConHub);
|
||||
@@ -26,6 +26,7 @@ export class DirectorRail extends HTMLElement {
|
||||
<span class="v"><span data-role="here">${here}</span>${drift ? ` <span class="drift ${driftClass}">${drift}</span>` : ''}</span></div>
|
||||
<div class="cell trig"><span class="k">Next hard trigger</span><span class="v" data-role="next">${nhText}</span></div>
|
||||
<button class="reached" data-role="reached">✓ Reached it</button>
|
||||
<button class="tray-btn" data-role="open-hub" aria-label="Hub">🏠</button>
|
||||
<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>
|
||||
@@ -35,6 +36,9 @@ export class DirectorRail extends HTMLElement {
|
||||
this.querySelector('[data-role=reached]').addEventListener('click', () => {
|
||||
this.dispatchEvent(new CustomEvent('reached', { bubbles: true }));
|
||||
});
|
||||
this.querySelector('[data-role=open-hub]').addEventListener('click', () => {
|
||||
this.dispatchEvent(new CustomEvent('open-hub', { bubbles: true }));
|
||||
});
|
||||
this.querySelector('[data-role=open-dice]').addEventListener('click', () => {
|
||||
this.dispatchEvent(new CustomEvent('open-tool', { detail: { tool: 'dice' }, bubbles: true }));
|
||||
});
|
||||
|
||||
@@ -7,12 +7,15 @@ import './npc-tray.js';
|
||||
import './art-tray.js';
|
||||
import './prop-viewer.js';
|
||||
import './clue-net.js';
|
||||
import './con-hub.js';
|
||||
import { getScenario } from '../scenarios/index.js';
|
||||
|
||||
export class GmShell extends HTMLElement {
|
||||
constructor() {
|
||||
super();
|
||||
this.now = () => Date.now();
|
||||
this.scenario = null;
|
||||
this.con = null;
|
||||
this.store = null;
|
||||
this.clockState = initialClock();
|
||||
this.stamps = {};
|
||||
@@ -23,6 +26,7 @@ export class GmShell extends HTMLElement {
|
||||
|
||||
connectedCallback() {
|
||||
this.innerHTML = `
|
||||
<con-hub hidden></con-hub>
|
||||
<director-rail></director-rail>
|
||||
<main class="stage">
|
||||
<dice-tray hidden></dice-tray>
|
||||
@@ -36,6 +40,35 @@ export class GmShell extends HTMLElement {
|
||||
this.addEventListener('keep-npc', (e) => this.onKeepNpc(e));
|
||||
this.addEventListener('show-prop', (e) => this.onShowProp(e));
|
||||
this.addEventListener('toggle-clue', (e) => this.onToggleClue(e));
|
||||
this.addEventListener('open-scenario', (e) => this.onOpenScenario(e));
|
||||
this.addEventListener('open-hub', () => this.showHub());
|
||||
}
|
||||
|
||||
loadCon(con) {
|
||||
this.con = con;
|
||||
const hub = this.querySelector('con-hub');
|
||||
hub.now = this.now;
|
||||
hub.con = con;
|
||||
this.showHub();
|
||||
}
|
||||
|
||||
showHub() {
|
||||
this.querySelector('con-hub').hidden = false;
|
||||
this.querySelector('director-rail').hidden = true;
|
||||
this.querySelector('.stage').hidden = true;
|
||||
}
|
||||
|
||||
showSession() {
|
||||
this.querySelector('con-hub').hidden = true;
|
||||
this.querySelector('director-rail').hidden = false;
|
||||
this.querySelector('.stage').hidden = false;
|
||||
}
|
||||
|
||||
onOpenScenario(e) {
|
||||
const scenario = getScenario(e.detail?.scenarioId);
|
||||
if (!scenario) return;
|
||||
this.loadScenario(scenario);
|
||||
this.showSession();
|
||||
}
|
||||
|
||||
loadScenario(scenario) {
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
// Continuum 2026 — the real convention this build is first deployed for.
|
||||
// Only AFTERIMAGE is ported into a scenario module today (scenarioId set); the
|
||||
// other four slots are known slate metadata with scenarioId: null until they are
|
||||
// ported (see the "scenario porting" backlog item). Slot start times are left
|
||||
// null (TBC) rather than invented — add each slot's real `startsAt` (ISO local
|
||||
// time) to light up the live / up-next timing on the hub.
|
||||
export default {
|
||||
meta: { id: 'continuum-2026', title: 'Continuum 2026' },
|
||||
slots: [
|
||||
{ id: 'c26-fri-1', scenarioId: null, title: 'Day One', slot: 'Fri · Slot 1', startsAt: null },
|
||||
{ id: 'c26-fri-2', scenarioId: 'afterimage', title: 'AFTERIMAGE', slot: 'Fri · Slot 2', system: 'year-zero', players: 4, playMinutes: 210, startsAt: null },
|
||||
{ id: 'c26-sat-4', scenarioId: null, title: 'Vain Crown', slot: 'Sat · Slot 4', startsAt: null },
|
||||
{ id: 'c26-sun-6', scenarioId: null, title: 'Another Fine Mess', slot: 'Sun · Slot 6', system: 'coc-d100', startsAt: null },
|
||||
{ id: 'c26-sun-7', scenarioId: null, title: "Prince's Bride", slot: 'Sun · Slot 7', system: 'dee-sanction', startsAt: null },
|
||||
],
|
||||
};
|
||||
@@ -0,0 +1,13 @@
|
||||
// TEMPLATE / EXAMPLE — not a real convention. Demonstrates the con-schedule
|
||||
// model with fully-timed slots so live / up-next / done statuses are visible.
|
||||
// A slot's `scenarioId` deep-links into a registered scenario module
|
||||
// (src/scenarios/index.js); `slot` is the display label, `startsAt` (ISO) drives
|
||||
// the live/next timing. Copy this shape into a real con file.
|
||||
export default {
|
||||
meta: { id: 'example-con', title: 'Example Convention' },
|
||||
slots: [
|
||||
{ id: 'ex-fri-1', scenarioId: 'afterimage', title: 'AFTERIMAGE', slot: 'Fri · Slot 1 · 10:00', system: 'year-zero', players: 4, playMinutes: 210, startsAt: '2026-08-14T10:00:00' },
|
||||
{ id: 'ex-fri-2', scenarioId: 'example-clues', title: 'Example — Clue Net', slot: 'Fri · Slot 2 · 14:30', system: 'year-zero', players: 4, playMinutes: 210, startsAt: '2026-08-14T14:30:00' },
|
||||
{ id: 'ex-sat-1', scenarioId: null, title: 'Unported Demo Slot', slot: 'Sat · Slot 1 · 10:00', playMinutes: 210, startsAt: '2026-08-15T10:00:00' },
|
||||
],
|
||||
};
|
||||
@@ -0,0 +1,39 @@
|
||||
// Pure convention-schedule analysis. Given slots and a wall-clock `nowMs`,
|
||||
// classify each slot as done | live | next | upcoming and surface the live/up-next.
|
||||
// Slots carry `startsAt` (ISO string) for timing logic; a null/absent start is
|
||||
// treated as unscheduled (sorted last, never live/next) so the hub still lists it.
|
||||
export function slotStartMs(slot) {
|
||||
if (typeof slot.startMs === 'number') return slot.startMs;
|
||||
if (slot.startsAt) {
|
||||
const t = Date.parse(slot.startsAt);
|
||||
if (!Number.isNaN(t)) return t;
|
||||
}
|
||||
return Infinity;
|
||||
}
|
||||
|
||||
export function analyzeSchedule(slots = [], nowMs = 0) {
|
||||
const enriched = slots
|
||||
.map((s) => {
|
||||
const startMs = slotStartMs(s);
|
||||
const endMs = startMs === Infinity ? Infinity : startMs + (s.playMinutes ?? 0) * 60000;
|
||||
return { ...s, startMs, endMs };
|
||||
})
|
||||
.sort((a, b) => a.startMs - b.startMs);
|
||||
|
||||
let nextAssigned = false;
|
||||
const outSlots = enriched.map((s) => {
|
||||
let status;
|
||||
if (s.startMs === Infinity) status = 'upcoming';
|
||||
else if (nowMs >= s.endMs) status = 'done';
|
||||
else if (nowMs >= s.startMs) status = 'live';
|
||||
else if (!nextAssigned) { status = 'next'; nextAssigned = true; }
|
||||
else status = 'upcoming';
|
||||
return { ...s, status };
|
||||
});
|
||||
|
||||
return {
|
||||
slots: outSlots,
|
||||
liveNow: outSlots.find((s) => s.status === 'live') || null,
|
||||
upNext: outSlots.find((s) => s.status === 'next') || null,
|
||||
};
|
||||
}
|
||||
+2
-2
@@ -1,13 +1,13 @@
|
||||
// Boot entry.
|
||||
import './components/gm-shell.js';
|
||||
import afterimage from './scenarios/afterimage.js';
|
||||
import continuum2026 from './con/continuum-2026.js';
|
||||
|
||||
export const APP = 'the-director';
|
||||
|
||||
if (typeof document !== 'undefined' && document.querySelector('gm-shell')) {
|
||||
customElements.whenDefined('gm-shell').then(() => {
|
||||
const shell = document.querySelector('gm-shell');
|
||||
shell.loadScenario(afterimage);
|
||||
shell.loadCon(continuum2026);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
// Scenario registry — maps a scenario id to its module so the con hub can
|
||||
// deep-link a slot into the shell. Register each new scenario module here.
|
||||
import afterimage from './afterimage.js';
|
||||
import exampleWithClues from './example-with-clues.js';
|
||||
|
||||
export const SCENARIOS = {
|
||||
[afterimage.meta.id]: afterimage,
|
||||
[exampleWithClues.meta.id]: exampleWithClues,
|
||||
};
|
||||
|
||||
export function getScenario(id) {
|
||||
return SCENARIOS[id] || null;
|
||||
}
|
||||
@@ -78,3 +78,32 @@ clue-net[hidden] { display: none; }
|
||||
.clue-act { font-size: 10px; color: #7f8ea0; }
|
||||
.clue-fallback { margin-top: 4px; font-size: 12px; color: #ff9c9c; }
|
||||
.clue-empty { color: #6b7a8d; font-size: 12px; }
|
||||
|
||||
/* Slice 5 — convention hub */
|
||||
con-hub[hidden] { display: none; }
|
||||
director-rail[hidden] { display: none; }
|
||||
.stage[hidden] { display: none; }
|
||||
.hub { padding: 20px 16px 32px; max-width: 720px; margin: 0 auto; }
|
||||
.hub--empty { color: #6b7a8d; font-size: 13px; padding: 24px 16px; }
|
||||
.hub-head { margin-bottom: 16px; }
|
||||
.hub-title { font-size: 22px; font-weight: 800; margin: 0 0 6px; letter-spacing: .01em; }
|
||||
.hub-summary { font-size: 13px; font-weight: 700; }
|
||||
.hub-summary .hub-live { color: #8ff0b6; }
|
||||
.hub-summary .hub-next { color: #ffd479; }
|
||||
.hub-summary .hub-idle { color: #7f8ea0; }
|
||||
.hub-list { display: flex; flex-direction: column; gap: 10px; }
|
||||
.hub-card { display: flex; align-items: center; gap: 12px; padding: 14px; border: 1px solid #263041;
|
||||
border-radius: 12px; background: #12171f; }
|
||||
.hub-card.status-live { border-color: #2f7d54; background: #12211a; }
|
||||
.hub-card.status-next { border-color: #4a3a1f; }
|
||||
.hub-card.status-done { opacity: .55; }
|
||||
.hub-card-main { flex: 1; min-width: 0; }
|
||||
.hub-card-title { font-size: 16px; font-weight: 700; color: #e7eef6; display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
|
||||
.hub-card-meta { font-size: 12px; color: #8fa1b4; margin-top: 3px; }
|
||||
.hub-badge { font-size: 9px; text-transform: uppercase; letter-spacing: .08em; border-radius: 12px; padding: 2px 8px;
|
||||
background: #1b2431; color: #9fb0c2; border: 1px solid #2a3542; }
|
||||
.hub-badge.badge-live { background: #17251c; color: #8ff0b6; border-color: #2f7d54; }
|
||||
.hub-badge.badge-next { background: #2a2013; color: #ffd479; border-color: #4a3a1f; }
|
||||
.hub-open { border: none; padding: 10px 20px; border-radius: 9px; background: #1c8a5a; color: #eafff4;
|
||||
font-weight: 700; font-size: 14px; flex: 0 0 auto; }
|
||||
.hub-soon { font-size: 11px; color: #6b7a8d; flex: 0 0 auto; }
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
import { describe, it, expect, beforeEach } from 'vitest';
|
||||
import '../../src/components/con-hub.js';
|
||||
|
||||
const CON = {
|
||||
meta: { id: 'demo', title: 'Demo Con' },
|
||||
slots: [
|
||||
{ id: 's1', scenarioId: 'a', title: 'Past Game', slot: 'Fri · S1', system: 'year-zero', players: 4, playMinutes: 60, startsAt: '2026-07-24T09:00:00Z' },
|
||||
{ id: 's2', scenarioId: 'b', title: 'Now Game', slot: 'Fri · S2', system: 'coc-d100', players: 5, playMinutes: 60, startsAt: '2026-07-24T12:00:00Z' },
|
||||
{ id: 's3', scenarioId: null, title: 'Unported', slot: 'Sat · S4', playMinutes: 60, startsAt: '2026-07-25T09:00:00Z' },
|
||||
],
|
||||
};
|
||||
|
||||
describe('<con-hub>', () => {
|
||||
let el;
|
||||
beforeEach(() => {
|
||||
document.body.innerHTML = '';
|
||||
el = document.createElement('con-hub');
|
||||
el.now = () => Date.parse('2026-07-24T12:30:00Z');
|
||||
document.body.appendChild(el);
|
||||
});
|
||||
|
||||
it('shows an empty state with no con', () => {
|
||||
expect(el.querySelector('[data-role=empty]')).not.toBe(null);
|
||||
});
|
||||
|
||||
it('renders a card per slot with its status', () => {
|
||||
el.con = CON;
|
||||
expect(el.querySelectorAll('[data-slot-id]').length).toBe(3);
|
||||
expect(el.querySelector('[data-slot-id=s1]').dataset.status).toBe('done');
|
||||
expect(el.querySelector('[data-slot-id=s2]').dataset.status).toBe('live');
|
||||
expect(el.querySelector('[data-slot-id=s3]').dataset.status).toBe('next');
|
||||
});
|
||||
|
||||
it('summarises the live session', () => {
|
||||
el.con = CON;
|
||||
expect(el.querySelector('[data-role=summary]').textContent).toContain('Now Game');
|
||||
});
|
||||
|
||||
it('emits open-scenario for a ported slot', () => {
|
||||
el.con = CON;
|
||||
let detail = null;
|
||||
el.addEventListener('open-scenario', (e) => { detail = e.detail; });
|
||||
el.querySelector('[data-slot-id=s2] [data-role=open]').click();
|
||||
expect(detail).toEqual({ scenarioId: 'b' });
|
||||
});
|
||||
|
||||
it('shows "not yet ported" and no open button for an unported slot', () => {
|
||||
el.con = CON;
|
||||
expect(el.querySelector('[data-slot-id=s3] [data-role=not-ready]')).not.toBe(null);
|
||||
expect(el.querySelector('[data-slot-id=s3] [data-role=open]')).toBe(null);
|
||||
});
|
||||
|
||||
it('escapes author text', () => {
|
||||
el.con = { meta: { title: 'x' }, slots: [{ id: 'x', scenarioId: null, title: 'evil <b>', slot: 'a" <i>', playMinutes: 60 }] };
|
||||
expect(el.querySelector('[data-slot-id=x]').innerHTML).not.toContain('<b>');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,48 @@
|
||||
import { describe, it, expect, beforeEach } from 'vitest';
|
||||
import '../../src/components/gm-shell.js';
|
||||
import continuum from '../../src/con/continuum-2026.js';
|
||||
|
||||
describe('<gm-shell> convention hub', () => {
|
||||
let el;
|
||||
beforeEach(() => {
|
||||
localStorage.clear();
|
||||
document.body.innerHTML = '';
|
||||
el = document.createElement('gm-shell');
|
||||
el.now = () => 0;
|
||||
document.body.appendChild(el);
|
||||
el.loadCon(continuum);
|
||||
});
|
||||
|
||||
it('boots into the hub with the rail and stage hidden', () => {
|
||||
expect(el.querySelector('con-hub').hidden).toBe(false);
|
||||
expect(el.querySelector('director-rail').hidden).toBe(true);
|
||||
expect(el.querySelector('.stage').hidden).toBe(true);
|
||||
expect(el.querySelector('con-hub').con).toBe(continuum);
|
||||
});
|
||||
|
||||
it('deep-links a ported slot into a session and hides the hub', () => {
|
||||
el.dispatchEvent(new CustomEvent('open-scenario', { detail: { scenarioId: 'afterimage' }, bubbles: true }));
|
||||
expect(el.scenario.meta.id).toBe('afterimage');
|
||||
expect(el.querySelector('con-hub').hidden).toBe(true);
|
||||
expect(el.querySelector('director-rail').hidden).toBe(false);
|
||||
expect(el.querySelector('.stage').hidden).toBe(false);
|
||||
expect(el.querySelector('director-rail [data-role=here]').textContent).toContain('The pier');
|
||||
});
|
||||
|
||||
it('ignores open-scenario for an unknown / unported slot', () => {
|
||||
el.dispatchEvent(new CustomEvent('open-scenario', { detail: { scenarioId: 'not-ported' }, bubbles: true }));
|
||||
expect(el.scenario).toBe(null);
|
||||
expect(el.querySelector('con-hub').hidden).toBe(false);
|
||||
});
|
||||
|
||||
it('the rail hub chip emits open-hub, returning to the hub', () => {
|
||||
el.dispatchEvent(new CustomEvent('open-scenario', { detail: { scenarioId: 'afterimage' }, bubbles: true }));
|
||||
const rail = el.querySelector('director-rail');
|
||||
let fired = 0;
|
||||
el.addEventListener('open-hub', () => fired++);
|
||||
rail.querySelector('[data-role=open-hub]').click();
|
||||
expect(fired).toBe(1);
|
||||
expect(el.querySelector('con-hub').hidden).toBe(false);
|
||||
expect(rail.hidden).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,57 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { analyzeSchedule, slotStartMs } from '../../src/con/schedule.js';
|
||||
|
||||
const at = (iso) => Date.parse(iso);
|
||||
|
||||
const SLOTS = [
|
||||
{ id: 's1', title: 'Morning', playMinutes: 60, startsAt: '2026-07-24T09:00:00Z' },
|
||||
{ id: 's2', title: 'Midday', playMinutes: 60, startsAt: '2026-07-24T12:00:00Z' },
|
||||
{ id: 's3', title: 'Afternoon', playMinutes: 60, startsAt: '2026-07-24T15:00:00Z' },
|
||||
{ id: 's4', title: 'Untimed', playMinutes: 60 },
|
||||
];
|
||||
|
||||
describe('analyzeSchedule', () => {
|
||||
it('classifies done / live / next / upcoming around now', () => {
|
||||
const r = analyzeSchedule(SLOTS, at('2026-07-24T12:30:00Z'));
|
||||
const byId = Object.fromEntries(r.slots.map((s) => [s.id, s.status]));
|
||||
expect(byId.s1).toBe('done'); // ended 10:00
|
||||
expect(byId.s2).toBe('live'); // 12:00–13:00
|
||||
expect(byId.s3).toBe('next'); // first future timed slot
|
||||
expect(byId.s4).toBe('upcoming'); // untimed, never next
|
||||
expect(r.liveNow.id).toBe('s2');
|
||||
expect(r.upNext.id).toBe('s3');
|
||||
});
|
||||
|
||||
it('before the con: nothing live, earliest is next', () => {
|
||||
const r = analyzeSchedule(SLOTS, at('2026-07-24T06:00:00Z'));
|
||||
expect(r.liveNow).toBe(null);
|
||||
expect(r.upNext.id).toBe('s1');
|
||||
expect(r.slots.find((s) => s.id === 's2').status).toBe('upcoming');
|
||||
});
|
||||
|
||||
it('after the con: everything done except the untimed slot', () => {
|
||||
const r = analyzeSchedule(SLOTS, at('2026-07-25T00:00:00Z'));
|
||||
expect(r.liveNow).toBe(null);
|
||||
expect(r.upNext).toBe(null);
|
||||
expect(r.slots.find((s) => s.id === 's1').status).toBe('done');
|
||||
expect(r.slots.find((s) => s.id === 's4').status).toBe('upcoming');
|
||||
});
|
||||
|
||||
it('sorts slots by start time, untimed last', () => {
|
||||
const r = analyzeSchedule(SLOTS, 0);
|
||||
expect(r.slots.map((s) => s.id)).toEqual(['s1', 's2', 's3', 's4']);
|
||||
});
|
||||
|
||||
it('treats an empty schedule as idle', () => {
|
||||
const r = analyzeSchedule([], 0);
|
||||
expect(r.slots).toEqual([]);
|
||||
expect(r.liveNow).toBe(null);
|
||||
expect(r.upNext).toBe(null);
|
||||
});
|
||||
|
||||
it('slotStartMs falls back to Infinity when unscheduled', () => {
|
||||
expect(slotStartMs({})).toBe(Infinity);
|
||||
expect(slotStartMs({ startMs: 5 })).toBe(5);
|
||||
expect(slotStartMs({ startsAt: '2026-07-24T09:00:00Z' })).toBe(at('2026-07-24T09:00:00Z'));
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,14 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { SCENARIOS, getScenario } from '../../src/scenarios/index.js';
|
||||
import afterimage from '../../src/scenarios/afterimage.js';
|
||||
|
||||
describe('scenario registry', () => {
|
||||
it('resolves a registered scenario by id', () => {
|
||||
expect(getScenario('afterimage')).toBe(afterimage);
|
||||
expect(SCENARIOS.afterimage).toBe(afterimage);
|
||||
});
|
||||
|
||||
it('returns null for an unknown id', () => {
|
||||
expect(getScenario('nope')).toBe(null);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user