feat: markdown → scenario-data generator (tools/)
Add a deterministic authoring tool that turns a structured scenario markdown file into a validated scenario-data module: - tools/scenario-md.js: pure parseScenarioMarkdown + emitScenarioModule (frontmatter → meta; ## Timeline/## Clues/## Cast lists → the arrays; auto-slugged ids; validated via validateScenario before emit). - tools/md-to-scenario.mjs: CLI (npm run gen:scenario), file or stdout. - tools/example-scenario.md: documented format example. Parses a defined format, not freeform con prose. tools/ is dev-only and never bundled. tests/tools/scenario-md.test.js round-trips + validates. 187 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
6d529c9799
commit
7c6ee3eecc
@@ -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
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
+2
-1
@@ -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",
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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
|
||||
@@ -0,0 +1,27 @@
|
||||
#!/usr/bin/env node
|
||||
// CLI: node tools/md-to-scenario.mjs <input.md> [output.js]
|
||||
// Reads a structured scenario markdown file and writes a scenario-data JS module
|
||||
// (to <output.js>, 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 <input.md> [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);
|
||||
}
|
||||
@@ -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: [],
|
||||
};
|
||||
`;
|
||||
}
|
||||
Reference in New Issue
Block a user