feat(content): add Zod story graph schema and validation
This commit is contained in:
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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<typeof storyOutcomeSchema>;
|
||||||
|
export type StoryChoice = z.infer<typeof storyChoiceSchema>;
|
||||||
|
export type StoryTrigger = z.infer<typeof storyTriggerSchema>;
|
||||||
|
export type StoryNode = z.infer<typeof storyNodeSchema>;
|
||||||
|
|
||||||
|
export interface StoryContent {
|
||||||
|
storyNodes: StoryNode[];
|
||||||
|
storyNodesById: Record<string, StoryNode>;
|
||||||
|
bootTargetNodeId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function indexStoryNodes(nodes: StoryNode[]): Record<string, StoryNode> {
|
||||||
|
const byId: Record<string, StoryNode> = {};
|
||||||
|
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<string, ActionDef>,
|
||||||
|
resourcesById: Record<string, { id: string }>,
|
||||||
|
): 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 };
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user