From a66a7db06641fa223b44e98b0fac8d112cf72631 Mon Sep 17 00:00:00 2001 From: ginnoir Date: Thu, 11 Jun 2026 18:49:28 -0500 Subject: [PATCH] feat(content): add Zod story graph schema and validation --- src/content/__tests__/storySchema.test.ts | 79 ++++++++++++++ src/content/storySchema.ts | 119 ++++++++++++++++++++++ 2 files changed, 198 insertions(+) create mode 100644 src/content/__tests__/storySchema.test.ts create mode 100644 src/content/storySchema.ts diff --git a/src/content/__tests__/storySchema.test.ts b/src/content/__tests__/storySchema.test.ts new file mode 100644 index 0000000..23c380e --- /dev/null +++ b/src/content/__tests__/storySchema.test.ts @@ -0,0 +1,79 @@ +import { describe, expect, it } from 'vitest'; +import { buildStoryContent } from '../storySchema'; + +const resourcesById = { + supplies: { id: 'supplies', name: 'Supplies', startAmount: 0 }, + coin: { id: 'coin', name: 'Coin', startAmount: 0 }, +}; +const actionsById = { + scout_path: { id: 'scout_path', name: 'Scout', durationMs: 1000, costs: [], yields: [{ resourceId: 'coin', amount: 1 }] }, +}; + +const validNodes = [ + { + id: 'boot_intro', + prose: 'You wake at the crossroads.', + triggers: [{ type: 'boot', targetNodeId: 'boot_intro' }], + }, + { + id: 'fork_choice', + prose: 'Which way?', + choices: [ + { + id: 'pick_a', + label: 'High road', + outcomes: [{ type: 'setFlag', flag: 'route_a' }], + targetNodeId: 'route_a_beat', + }, + { + id: 'pick_b', + label: 'Low road', + outcomes: [{ type: 'setFlag', flag: 'route_b' }], + targetNodeId: 'route_b_beat', + }, + ], + }, + { id: 'route_a_beat', prose: 'The high road.' }, + { id: 'route_b_beat', prose: 'The low road.' }, +]; + +describe('buildStoryContent()', () => { + it('indexes nodes and finds boot trigger', () => { + const story = buildStoryContent(validNodes, actionsById, resourcesById); + expect(story.storyNodesById.fork_choice.prose).toContain('Which way'); + expect(story.bootTargetNodeId).toBe('boot_intro'); + }); + + it('rejects dangling targetNodeId on choices', () => { + expect(() => + buildStoryContent( + [{ id: 'n', prose: 'x', choices: [{ id: 'c', label: 'y', outcomes: [], targetNodeId: 'missing' }] }], + actionsById, + resourcesById, + ), + ).toThrow(/unknown story node/i); + }); + + it('rejects unknown actionId in actionComplete trigger', () => { + expect(() => + buildStoryContent( + [ + { + id: 't', + prose: 'x', + triggers: [{ type: 'actionComplete', actionId: 'ghost', targetNodeId: 'boot_intro' }], + }, + { id: 'boot_intro', prose: 'hi' }, + ], + actionsById, + resourcesById, + ), + ).toThrow(/unknown action/i); + }); + + it('requires exactly one boot trigger', () => { + expect(() => + buildStoryContent([{ id: 'n', prose: 'no boot' }], actionsById, resourcesById), + ).toThrow(/boot trigger/i); + }); +}); diff --git a/src/content/storySchema.ts b/src/content/storySchema.ts new file mode 100644 index 0000000..313f8bf --- /dev/null +++ b/src/content/storySchema.ts @@ -0,0 +1,119 @@ +import { z } from 'zod'; +import type { ActionDef } from './schema'; + +export const storyOutcomeSchema = z.discriminatedUnion('type', [ + z.object({ type: z.literal('setFlag'), flag: z.string().min(1) }), + z.object({ type: z.literal('clearFlag'), flag: z.string().min(1) }), + z.object({ type: z.literal('grantResource'), resourceId: z.string().min(1), amount: z.number().positive() }), + z.object({ type: z.literal('consumeResource'), resourceId: z.string().min(1), amount: z.number().positive() }), + z.object({ type: z.literal('log'), text: z.string().min(1) }), +]); + +export const choiceRequirementsSchema = z.object({ + minResources: z.record(z.string(), z.number().nonnegative()).optional(), + requireStoryFlags: z.array(z.string().min(1)).optional(), + excludeStoryFlags: z.array(z.string().min(1)).optional(), +}); + +export const storyChoiceSchema = z.object({ + id: z.string().min(1), + label: z.string().min(1), + requirements: choiceRequirementsSchema.optional(), + outcomes: z.array(storyOutcomeSchema).default([]), + targetNodeId: z.string().min(1), +}); + +export const storyTriggerSchema = z.object({ + type: z.enum(['boot', 'actionComplete', 'minResources']), + actionId: z.string().min(1).optional(), + minResources: z.record(z.string(), z.number().nonnegative()).optional(), + targetNodeId: z.string().min(1), + once: z.boolean().default(true), +}); + +export const storyNodeSchema = z.object({ + id: z.string().min(1), + prose: z.string().min(1), + choices: z.array(storyChoiceSchema).optional(), + triggers: z.array(storyTriggerSchema).optional(), + enterOutcomes: z.array(storyOutcomeSchema).optional(), +}); + +export type StoryOutcome = z.infer; +export type StoryChoice = z.infer; +export type StoryTrigger = z.infer; +export type StoryNode = z.infer; + +export interface StoryContent { + storyNodes: StoryNode[]; + storyNodesById: Record; + bootTargetNodeId: string; +} + +function indexStoryNodes(nodes: StoryNode[]): Record { + const byId: Record = {}; + for (const node of nodes) { + if (byId[node.id]) throw new Error(`Duplicate story node id "${node.id}"`); + byId[node.id] = node; + } + return byId; +} + +export function buildStoryContent( + rawNodes: unknown[], + actionsById: Record, + resourcesById: Record, +): StoryContent { + const storyNodes = rawNodes.map((n) => storyNodeSchema.parse(n)); + const storyNodesById = indexStoryNodes(storyNodes); + + let bootTargetNodeId: string | null = null; + for (const node of storyNodes) { + for (const trigger of node.triggers ?? []) { + if (trigger.type === 'boot') { + if (bootTargetNodeId !== null) { + throw new Error('Story graph must have exactly one boot trigger'); + } + bootTargetNodeId = trigger.targetNodeId; + } + if (trigger.type === 'actionComplete' && !actionsById[trigger.actionId ?? '']) { + throw new Error(`Story trigger references unknown action "${trigger.actionId}"`); + } + if (trigger.minResources) { + for (const resourceId of Object.keys(trigger.minResources)) { + if (!resourcesById[resourceId]) { + throw new Error(`Story trigger references unknown resource "${resourceId}"`); + } + } + } + if (!storyNodesById[trigger.targetNodeId]) { + throw new Error(`Story trigger references unknown story node "${trigger.targetNodeId}"`); + } + } + for (const choice of node.choices ?? []) { + if (!storyNodesById[choice.targetNodeId]) { + throw new Error(`Story choice references unknown story node "${choice.targetNodeId}"`); + } + for (const outcome of choice.outcomes) { + if (outcome.type === 'grantResource' || outcome.type === 'consumeResource') { + if (!resourcesById[outcome.resourceId]) { + throw new Error(`Story outcome references unknown resource "${outcome.resourceId}"`); + } + } + } + } + for (const outcome of node.enterOutcomes ?? []) { + if (outcome.type === 'grantResource' || outcome.type === 'consumeResource') { + if (!resourcesById[outcome.resourceId]) { + throw new Error(`Story enterOutcome references unknown resource "${outcome.resourceId}"`); + } + } + } + } + + if (bootTargetNodeId === null) { + throw new Error('Story graph must have exactly one boot trigger'); + } + + return { storyNodes, storyNodesById, bootTargetNodeId }; +}