From c01917d38aa12a7869adf883303fb9c2ced57eba Mon Sep 17 00:00:00 2001 From: Tim Evans Date: Mon, 20 Jul 2026 13:55:33 +0100 Subject: [PATCH] feat: scenario validator + AFTERIMAGE pilot data --- src/core/scenario.js | 15 +++++++++++++++ src/scenarios/afterimage.js | 14 ++++++++++++++ tests/core/scenario.test.js | 26 ++++++++++++++++++++++++++ 3 files changed, 55 insertions(+) create mode 100644 src/core/scenario.js create mode 100644 src/scenarios/afterimage.js create mode 100644 tests/core/scenario.test.js diff --git a/src/core/scenario.js b/src/core/scenario.js new file mode 100644 index 0000000..d7150cf --- /dev/null +++ b/src/core/scenario.js @@ -0,0 +1,15 @@ +export function validateScenario(s) { + if (!s || typeof s !== 'object') return ['scenario must be an object']; + const errors = []; + if (!s.meta?.id) errors.push('meta.id required'); + if (!s.meta?.system) errors.push('meta.system required'); + if (!Array.isArray(s.timeline) || s.timeline.length === 0) { + errors.push('timeline must be a non-empty array'); + } else { + s.timeline.forEach((b, i) => { + if (!b.id) errors.push(`timeline[${i}].id required`); + if (typeof b.targetMin !== 'number') errors.push(`timeline[${i}].targetMin must be a number`); + }); + } + return errors; +} diff --git a/src/scenarios/afterimage.js b/src/scenarios/afterimage.js new file mode 100644 index 0000000..341b6a4 --- /dev/null +++ b/src/scenarios/afterimage.js @@ -0,0 +1,14 @@ +export default { + meta: { id: 'afterimage', title: 'AFTERIMAGE', system: 'year-zero', players: 4, playMinutes: 210, slot: 'Fri · Slot 2' }, + timeline: [ + { id: 'a1-open', act: 1, label: 'The pier — the case lands', targetMin: 0, hardTrigger: true }, + { id: 'a1-parlor', act: 1, label: 'Parlor 88 interview', targetMin: 35, cutHint: 'Summarise the ledger; skip the tea ritual' }, + { id: 'a2-market', act: 2, label: 'The Market', targetMin: 90 }, + { id: 'a2-doorcam', act: 2, label: 'Door-cam reveal', targetMin: 120, hardTrigger: true, notes: 'The pivot — do not let this slip past 2:15' }, + { id: 'a3-pier', act: 3, label: 'Pier confrontation', targetMin: 175, hardTrigger: true }, + { id: 'epilogue', act: 4, label: 'Baseline / ambiguous end', targetMin: 200 }, + ], + clues: [], + cast: [], + props: [], +}; diff --git a/tests/core/scenario.test.js b/tests/core/scenario.test.js new file mode 100644 index 0000000..2deb65a --- /dev/null +++ b/tests/core/scenario.test.js @@ -0,0 +1,26 @@ +import { describe, it, expect } from 'vitest'; +import { validateScenario } from '../../src/core/scenario.js'; +import afterimage from '../../src/scenarios/afterimage.js'; +import { getRulePack } from '../../src/dice/rulepacks/index.js'; + +describe('scenario validation', () => { + it('flags missing required fields', () => { + expect(validateScenario(null)).toContain('scenario must be an object'); + const errs = validateScenario({ meta: {}, timeline: [] }); + expect(errs).toContain('meta.id required'); + expect(errs).toContain('meta.system required'); + expect(errs).toContain('timeline must be a non-empty array'); + }); + + it('flags a beat missing targetMin', () => { + const errs = validateScenario({ meta: { id: 'x', system: 'year-zero' }, timeline: [{ id: 'a' }] }); + expect(errs).toContain('timeline[0].targetMin must be a number'); + }); + + it('accepts the AFTERIMAGE pilot and its system resolves', () => { + expect(validateScenario(afterimage)).toEqual([]); + expect(afterimage.meta.system).toBe('year-zero'); + expect(getRulePack(afterimage.meta.system)).not.toBe(null); + expect(afterimage.timeline.length).toBeGreaterThan(3); + }); +});