diff --git a/README.md b/README.md index 3168bd3..5bb93fc 100644 --- a/README.md +++ b/README.md @@ -91,6 +91,38 @@ export default { `meta.system` selects the dice rule-pack. `hardTrigger` marks immovable beats; `cutHint` is what to compress if you reach a beat behind schedule. +### Generating a scenario from markdown + +Instead of hand-writing the module, author a scenario in a structured markdown file and generate it: + +```bash +npm run gen:scenario -- input.md src/scenarios/my-scenario.js # or omit the output path to print +``` + +The tool (`tools/scenario-md.js`, wrapped by `tools/md-to-scenario.mjs`) is a deterministic parser — +frontmatter → `meta`, and `## Timeline` / `## Clues` / `## Cast` lists → the arrays. See +[`tools/example-scenario.md`](tools/example-scenario.md) for the full format; in short: + +```markdown +--- +id: my-scenario +system: year-zero +players: 4 +playMinutes: 210 +--- +## Timeline +- [0] The opening beat {hard} #a1-open ← [minutes] label, {hard}, optional #id +- [35] Interview | cut: skip the tea ritual ← | cut: … → cutHint +## Clues +- The door-cam face {essential} {act: Act One} | fallback: the monologue cards +## Cast +- MARY FLETCHER — Innkeeper; saw the watchers | secret: she saw Crowe sew +``` + +IDs are auto-slugged from the label/name when not pinned with `#id`. The output is validated against +`validateScenario` before it is written. It emits app data only, not prose — freeform con docs are +not parsed; you write the structured markdown. The `tools/` directory is dev-only (never bundled). + ## Roadmap 1. **Slice 1** — shell + Director Rail + data model + dice engine (Year-Zero). ✅ @@ -98,7 +130,7 @@ compress if you reach a beat behind schedule. 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 "live now / up next / done", deep-links into each scenario). ✅ All **six** Continuum 2026 slots ported and scheduled with real times. -6. **Later** — online art "Generate"; markdown → scenario-data generator; native iPad wrapper. (All design §5 tray tools — dice, NPC, art, clue-net, cast, break timer, parking-lot, wake-lock — are built.) +6. **Later** — online art "Generate"; native iPad wrapper. (All design §5 tray tools — dice, NPC, art, clue-net, cast, break timer, parking-lot, wake-lock — plus the markdown → scenario-data generator are built.) ## Docs diff --git a/docs/superpowers/plans/2026-07-20-director-slice5.md b/docs/superpowers/plans/2026-07-20-director-slice5.md index bf6ed99..db53879 100644 --- a/docs/superpowers/plans/2026-07-20-director-slice5.md +++ b/docs/superpowers/plans/2026-07-20-director-slice5.md @@ -85,6 +85,12 @@ jsdom. No new dependencies. Fully offline. chip that lights when active. Tests: `parking-lot.test.js`, `wake-lock.test.js`, `gm-shell-parking.test.js`, `gm-shell-wake.test.js`. This completes every design §5 tray tool. +- **Markdown → scenario-data generator built.** `tools/scenario-md.js` (pure parse + emit) + + `tools/md-to-scenario.mjs` (CLI, `npm run gen:scenario`) turn a structured scenario markdown file + into a validated scenario module. Deterministic parser over a defined format (frontmatter + Timeline + / Clues / Cast lists) — not freeform-prose scraping. `tests/tools/scenario-md.test.js` (round-trip + + validation); `tools/` is dev-only, never bundled. + ## Still deferred (backlog) -- Online art "Generate"; markdown → scenario-data generator; native iPad wrapper. +- Online art "Generate" (needs wifi + a gen backend); native iPad wrapper. diff --git a/package.json b/package.json index 8634e97..b249549 100644 --- a/package.json +++ b/package.json @@ -8,7 +8,8 @@ "build": "vite build", "preview": "vite preview", "test": "vitest run", - "test:watch": "vitest" + "test:watch": "vitest", + "gen:scenario": "node tools/md-to-scenario.mjs" }, "devDependencies": { "jsdom": "^24.0.0", diff --git a/tests/tools/scenario-md.test.js b/tests/tools/scenario-md.test.js new file mode 100644 index 0000000..e4f49db --- /dev/null +++ b/tests/tools/scenario-md.test.js @@ -0,0 +1,96 @@ +import { describe, it, expect } from 'vitest'; +import { readFileSync } from 'node:fs'; +import { parseScenarioMarkdown, emitScenarioModule } from '../../tools/scenario-md.js'; +import { validateScenario } from '../../src/core/scenario.js'; + +const MD = `--- +id: demo +title: DEMO +system: coc-d100 +players: 4 +playMinutes: 210 +slot: Sat · Slot 4 +--- + +## Timeline +- [0] The opening {hard} #open +- [35] Interview | cut: summarise the ledger +- [120] The reveal {hard} + +## Clues +- The door-cam face {essential} {act: Act One} | fallback: the monologue cards +- A colour detail + +## Cast +- MARY FLETCHER — Innkeeper; saw the watchers | secret: she saw Crowe sew +- DANNY TATE — Stable boy, 14 +- THE NARRATOR {pc} +`; + +// Load the emitted module string as a real ES module value. +async function importModule(js) { + const url = 'data:text/javascript;base64,' + Buffer.from(js).toString('base64'); + return (await import(url)).default; +} + +describe('markdown → scenario-data', () => { + it('parses frontmatter into meta with numeric coercion', () => { + const s = parseScenarioMarkdown(MD); + expect(s.meta).toEqual({ id: 'demo', title: 'DEMO', system: 'coc-d100', players: 4, playMinutes: 210, slot: 'Sat · Slot 4' }); + }); + + it('parses timeline beats with minutes, hard triggers, cut hints and ids', () => { + const { timeline } = parseScenarioMarkdown(MD); + expect(timeline[0]).toEqual({ id: 'open', label: 'The opening', targetMin: 0, hardTrigger: true }); + expect(timeline[1]).toEqual({ id: 'interview', label: 'Interview', targetMin: 35, cutHint: 'summarise the ledger' }); + expect(timeline[2]).toEqual({ id: 'the-reveal', label: 'The reveal', targetMin: 120, hardTrigger: true }); + }); + + it('parses clues with essential / act / fallback and auto-ids', () => { + const { clues } = parseScenarioMarkdown(MD); + expect(clues[0]).toEqual({ id: 'the-door-cam-face', label: 'The door-cam face', essential: true, act: 'Act One', fallback: 'the monologue cards' }); + expect(clues[1]).toEqual({ id: 'a-colour-detail', label: 'A colour detail', essential: false }); + }); + + it('parses cast with name/note/secret and pc/npc kind', () => { + const { cast } = parseScenarioMarkdown(MD); + expect(cast[0]).toEqual({ id: 'mary-fletcher', name: 'MARY FLETCHER', kind: 'npc', note: 'Innkeeper; saw the watchers', secret: 'she saw Crowe sew' }); + expect(cast[1]).toEqual({ id: 'danny-tate', name: 'DANNY TATE', kind: 'npc', note: 'Stable boy, 14' }); + expect(cast[2]).toEqual({ id: 'the-narrator', name: 'THE NARRATOR', kind: 'pc' }); + }); + + it('produces a module that validates and round-trips back to the same data', async () => { + const parsed = parseScenarioMarkdown(MD); + const js = emitScenarioModule(parsed); + const mod = await importModule(js); + expect(validateScenario(mod)).toEqual([]); + expect(mod).toEqual(parsed); + }); + + it('dedupes ids derived from identical labels', () => { + const s = parseScenarioMarkdown(`--- +id: dup +system: year-zero +--- +## Timeline +- [0] Beat +- [10] Beat +`); + expect(s.timeline.map((b) => b.id)).toEqual(['beat', 'beat-2']); + }); + + it('throws on missing frontmatter and on an empty timeline', () => { + expect(() => parseScenarioMarkdown('## Timeline\n- [0] x')).toThrow(/frontmatter/); + expect(() => parseScenarioMarkdown('---\nid: x\nsystem: year-zero\n---\n')).toThrow(/invalid scenario/); + }); + + it('regenerates the shipped example markdown into a valid module', async () => { + const md = readFileSync('tools/example-scenario.md', 'utf8'); // vitest runs from repo root + const mod = await importModule(emitScenarioModule(parseScenarioMarkdown(md))); + expect(validateScenario(mod)).toEqual([]); + expect(mod.meta.id).toBe('example-md'); + expect(mod.timeline).toHaveLength(4); + expect(mod.clues.filter((c) => c.essential)).toHaveLength(2); + expect(mod.cast).toHaveLength(2); + }); +}); diff --git a/tools/example-scenario.md b/tools/example-scenario.md new file mode 100644 index 0000000..51c5734 --- /dev/null +++ b/tools/example-scenario.md @@ -0,0 +1,36 @@ +--- +id: example-md +title: EXAMPLE — Generated From Markdown +system: year-zero +players: 4 +playMinutes: 210 +slot: Fri · Slot 2 +--- + +# Notes are ignored — only frontmatter and the sections below are parsed. + +## Timeline + +Beats are `[minutes-from-start] label`. Add `{hard}` for a hard trigger, +`| cut: …` for a cut hint, and `#id` to pin a stable id (else it is slugged). + +- [0] The opening beat {hard} #a1-open +- [35] Parlor interview | cut: summarise the ledger; skip the tea ritual +- [120] The reveal {hard} +- [200] Ambiguous epilogue + +## Clues + +`{essential}` marks a clue the ending needs; `{act: …}` tags the act; +`| fallback: …` is how to deliver it if missed. + +- The door-cam face that cannot exist {essential} {act: Act One} | fallback: the monologue cards +- The identical childhood, recited twice {essential} +- A colour detail {act: Act One} + +## Cast + +`NAME — one-line note`, optionally `| secret: …`, and `{pc}` for a player character. + +- MARY FLETCHER — Innkeeper; saw the watchers | secret: she saw Crowe sew the pendant +- DANNY TATE — Stable boy, 14 diff --git a/tools/md-to-scenario.mjs b/tools/md-to-scenario.mjs new file mode 100644 index 0000000..6d006fa --- /dev/null +++ b/tools/md-to-scenario.mjs @@ -0,0 +1,27 @@ +#!/usr/bin/env node +// CLI: node tools/md-to-scenario.mjs [output.js] +// Reads a structured scenario markdown file and writes a scenario-data JS module +// (to , or stdout if omitted). See tools/scenario-md.js for the format. +import { readFileSync, writeFileSync } from 'node:fs'; +import { parseScenarioMarkdown, emitScenarioModule } from './scenario-md.js'; + +const [, , inPath, outPath] = process.argv; +if (!inPath) { + console.error('Usage: node tools/md-to-scenario.mjs [output.js]'); + process.exit(1); +} + +let js; +try { + js = emitScenarioModule(parseScenarioMarkdown(readFileSync(inPath, 'utf8'))); +} catch (e) { + console.error(`Error: ${e.message}`); + process.exit(1); +} + +if (outPath) { + writeFileSync(outPath, js); + console.error(`Wrote ${outPath}`); +} else { + process.stdout.write(js); +} diff --git a/tools/scenario-md.js b/tools/scenario-md.js new file mode 100644 index 0000000..9c2b7b3 --- /dev/null +++ b/tools/scenario-md.js @@ -0,0 +1,189 @@ +// Markdown → scenario-data generator (pure). Parses a structured scenario +// markdown file into the scenario model and emits a matching JS module. +// +// Format (see tools/example-scenario.md): +// --- ← frontmatter → meta +// id: example +// title: EXAMPLE +// system: year-zero +// players: 4 +// playMinutes: 210 +// slot: Fri · Slot 2 +// --- +// ## Timeline +// - [0] The opening beat {hard} #a1-open +// - [35] Parlor interview | cut: summarise the ledger +// ## Clues +// - The door-cam face {essential} {act: Act One} | fallback: the monologue cards +// ## Cast +// - MARY FLETCHER — Innkeeper; saw the watchers | secret: she saw Crowe sew +// +// Trailing tokens on any list item: `{flag}`, `{key: value}`, `| key: value`, +// and `#explicit-id`. IDs are auto-slugged from the label/name when not given. +import { validateScenario } from '../src/core/scenario.js'; + +// ---- parsing helpers ------------------------------------------------------ +function slugify(s) { + return String(s).normalize('NFKD').replace(/[̀-ͯ]/g, '') + .toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, ''); +} +function takeTrailingId(s) { + const m = s.match(/\s+#([\w-]+)\s*$/); + return m ? { id: m[1], rest: s.slice(0, m.index).trim() } : { id: null, rest: s }; +} +function takeFlag(s, flag) { + const re = new RegExp(`\\s*\\{${flag}\\}\\s*`); + return re.test(s) ? { present: true, rest: s.replace(re, ' ').trim() } : { present: false, rest: s }; +} +function takeBraceField(s, key) { + const m = s.match(new RegExp(`\\{${key}:\\s*([^}]*)\\}`)); + return m ? { value: m[1].trim(), rest: s.replace(m[0], ' ').replace(/\s+/g, ' ').trim() } : { value: null, rest: s }; +} +function takePipeField(s, key) { + const idx = s.indexOf(`| ${key}:`); + if (idx === -1) return { value: null, rest: s }; + return { value: s.slice(idx + `| ${key}:`.length).trim(), rest: s.slice(0, idx).trim() }; +} + +function parseFrontmatter(md) { + const m = md.match(/^?---\r?\n([\s\S]*?)\r?\n---\r?\n?/); + if (!m) throw new Error('missing "---" frontmatter block at the top of the file'); + const meta = {}; + for (const line of m[1].split(/\r?\n/)) { + const mm = line.match(/^([\w-]+):\s*(.*)$/); + if (mm) meta[mm[1]] = mm[2].trim(); + } + for (const k of ['players', 'playMinutes']) { + if (meta[k] != null && meta[k] !== '') { + const n = Number(meta[k]); + meta[k] = Number.isFinite(n) ? n : meta[k]; // keep e.g. "5–6" as a string + } + } + return { meta, body: md.slice(m[0].length) }; +} + +function sections(body) { + const out = {}; + let cur = null; + for (const line of body.split(/\r?\n/)) { + const h = line.match(/^##\s+(.+?)\s*$/); + if (h) { cur = h[1].trim().toLowerCase(); out[cur] = out[cur] || []; continue; } + if (cur && /^\s*-\s+/.test(line)) out[cur].push(line.replace(/^\s*-\s+/, '').trim()); + } + return out; +} + +function parseTimeline(items) { + return items.map((raw) => { + const t = takeTrailingId(raw); + const cut = takePipeField(t.rest, 'cut'); + const mm = cut.rest.match(/^\[(\d+)\]\s*(.*)$/); + if (!mm) throw new Error(`timeline beat needs a [minutes] prefix: "${raw}"`); + const hard = takeFlag(mm[2], 'hard'); + const beat = { label: hard.rest.trim(), targetMin: Number(mm[1]) }; + if (t.id) beat.id = t.id; + if (hard.present) beat.hardTrigger = true; + if (cut.value) beat.cutHint = cut.value; + return beat; + }); +} + +function parseClues(items) { + return items.map((raw) => { + const t = takeTrailingId(raw); + const fb = takePipeField(t.rest, 'fallback'); + const ess = takeFlag(fb.rest, 'essential'); + const act = takeBraceField(ess.rest, 'act'); + const clue = { label: act.rest.trim(), essential: ess.present }; + if (t.id) clue.id = t.id; + if (act.value) clue.act = act.value; + if (fb.value) clue.fallback = fb.value; + return clue; + }); +} + +function parseCast(items) { + return items.map((raw) => { + const t = takeTrailingId(raw); + const sec = takePipeField(t.rest, 'secret'); + const pc = takeFlag(sec.rest, 'pc'); + const npc = takeFlag(pc.rest, 'npc'); + let name = npc.rest, note = null; + const sep = npc.rest.match(/\s+[—–-]\s+/); + if (sep) { name = npc.rest.slice(0, sep.index).trim(); note = npc.rest.slice(sep.index + sep[0].length).trim(); } + const member = { name: name.trim(), kind: pc.present ? 'pc' : 'npc' }; + if (t.id) member.id = t.id; + if (note) member.note = note; + if (sec.value) member.secret = sec.value; + return member; + }); +} + +function assignIds(arr, keyField) { + const used = new Set(); + for (const item of arr) { + let id = item.id || slugify(item[keyField] || '') || 'x'; + const base = id; + let n = 2; + while (used.has(id)) id = `${base}-${n++}`; + used.add(id); + item.id = id; + } +} + +export function parseScenarioMarkdown(md) { + const { meta, body } = parseFrontmatter(md); + const secs = sections(body); + const timeline = parseTimeline(secs.timeline || []); + const clues = parseClues(secs.clues || []); + const cast = parseCast(secs.cast || []); + assignIds(timeline, 'label'); + assignIds(clues, 'label'); + assignIds(cast, 'name'); + const scenario = { meta, timeline, clues, cast, props: [] }; + const errors = validateScenario(scenario); + if (errors.length) throw new Error('invalid scenario: ' + errors.join('; ')); + return scenario; +} + +// ---- emitting ------------------------------------------------------------- +const q = (v) => "'" + String(v).replace(/\\/g, '\\\\').replace(/'/g, "\\'") + "'"; + +export function emitScenarioModule(s) { + const meta = [`id: ${q(s.meta.id)}`, `title: ${q(s.meta.title ?? s.meta.id)}`, `system: ${q(s.meta.system)}`]; + if (s.meta.players != null) meta.push(`players: ${typeof s.meta.players === 'number' ? s.meta.players : q(s.meta.players)}`); + if (s.meta.playMinutes != null) meta.push(`playMinutes: ${typeof s.meta.playMinutes === 'number' ? s.meta.playMinutes : q(s.meta.playMinutes)}`); + if (s.meta.slot != null) meta.push(`slot: ${q(s.meta.slot)}`); + + const rows = (arr, fn) => arr.map((x) => ' { ' + fn(x).join(', ') + ' },').join('\n'); + const timeline = rows(s.timeline, (b) => { + const p = [`id: ${q(b.id)}`, `label: ${q(b.label)}`, `targetMin: ${b.targetMin}`]; + if (b.hardTrigger) p.push('hardTrigger: true'); + if (b.cutHint) p.push(`cutHint: ${q(b.cutHint)}`); + return p; + }); + const clues = rows(s.clues, (c) => { + const p = [`id: ${q(c.id)}`, `label: ${q(c.label)}`, `essential: ${!!c.essential}`]; + if (c.act) p.push(`act: ${q(c.act)}`); + if (c.fallback) p.push(`fallback: ${q(c.fallback)}`); + return p; + }); + const cast = rows(s.cast, (c) => { + const p = [`id: ${q(c.id)}`, `name: ${q(c.name)}`, `kind: ${q(c.kind || 'npc')}`]; + if (c.note) p.push(`note: ${q(c.note)}`); + if (c.secret) p.push(`secret: ${q(c.secret)}`); + return p; + }); + const block = (label, body) => ` ${label}: [${body ? '\n' + body + '\n ' : ''}],`; + + return `export default { + meta: { ${meta.join(', ')} }, + timeline: [ +${timeline} + ], +${block('clues', clues)} +${block('cast', cast)} + props: [], +}; +`; +}