Files
idlegame/src/content/storySchema.ts
T
ginnoir c76265d648
CI / verify (push) Failing after 58s
CI / verify (pull_request) Failing after 41s
docs: document story engine and PR2 playable loop
2026-06-11 19:00:49 -05:00

128 lines
4.5 KiB
TypeScript

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 };
}