Files
gmtool/tools/scenario-md.js
T
slaguru666andClaude Opus 4.8 7c6ee3eecc 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>
2026-07-20 23:33:28 +01:00

190 lines
7.0 KiB
JavaScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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: [],
};
`;
}