feat: scenario validator + AFTERIMAGE pilot data

This commit is contained in:
2026-07-20 13:55:33 +01:00
parent e9fa9366fa
commit c01917d38a
3 changed files with 55 additions and 0 deletions
+15
View File
@@ -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;
}
+14
View File
@@ -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: [],
};
+26
View File
@@ -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);
});
});