import { z } from 'zod'; /** * Content schemas. * * Game content (resources, actions, later story nodes) is data, validated at * load time with zod so a malformed definition fails loudly instead of corrupting * runtime state. The engine consumes the validated `Content` object and never * reaches for raw definitions. */ export const resourceDefSchema = z.object({ id: z.string().min(1), name: z.string().min(1), startAmount: z.number().nonnegative().default(0), }); export const resourceAmountSchema = z.object({ resourceId: z.string().min(1), amount: z.number().positive(), }); export const unlockDefSchema = z.object({ minResources: z.record(z.string(), z.number().nonnegative()).optional(), requireStoryFlags: z.array(z.string().min(1)).optional(), }); export const actionKindSchema = z.enum(['instant', 'loop', 'timed', 'story', 'context']); export const actionGroupSchema = z.object({ id: z.string().min(1), label: z.string().min(1), }); export const actionDefSchema = z .object({ id: z.string().min(1), name: z.string().min(1), kind: actionKindSchema.default('timed'), group: actionGroupSchema, durationMs: z.number().positive().optional(), loopPriority: z.number().int().nonnegative().optional(), costs: z.array(resourceAmountSchema).default([]), yields: z.array(resourceAmountSchema).default([]), unlock: unlockDefSchema.optional(), storyHint: z.string().min(1).optional(), storyTooltip: z.string().min(1).optional(), storyChoiceId: z.string().min(1).optional(), contextId: z.string().min(1).optional(), automation: z .object({ unlockAfterManualCompletions: z.number().int().positive().default(1), }) .optional(), }) .superRefine((action, ctx) => { if ((action.kind === 'timed' || action.kind === 'loop') && action.durationMs === undefined) { ctx.addIssue({ code: 'custom', message: `${action.kind} actions require durationMs`, path: ['durationMs'], }); } if (action.kind === 'story' && action.storyChoiceId === undefined) { ctx.addIssue({ code: 'custom', message: 'story actions require storyChoiceId', path: ['storyChoiceId'], }); } if (action.kind === 'timed' && action.yields.length === 0) { ctx.addIssue({ code: 'custom', message: 'timed actions require at least one yield', path: ['yields'], }); } }); export type ResourceDef = z.infer; export type ResourceAmount = z.infer; export type UnlockDef = z.infer; export type ActionKind = z.infer; export type ActionGroup = z.infer; export type ActionDef = z.infer; export interface Content { resources: ResourceDef[]; actions: ActionDef[]; resourcesById: Record; actionsById: Record; } function indexById(items: T[], kind: string): Record { const byId: Record = {}; for (const item of items) { if (byId[item.id]) { throw new Error(`Duplicate ${kind} id "${item.id}"`); } byId[item.id] = item; } return byId; } /** Validate raw definitions and build the indexed, referentially-checked Content. */ export function buildContent(input: { resources: unknown[]; actions: unknown[] }): Content { const resources = input.resources.map((r) => resourceDefSchema.parse(r)); const actions = input.actions.map((a) => actionDefSchema.parse(a)); const resourcesById = indexById(resources, 'resource'); const actionsById = indexById(actions, 'action'); for (const action of actions) { for (const y of action.yields) { if (!resourcesById[y.resourceId]) { throw new Error(`Action "${action.id}" yields unknown resource "${y.resourceId}"`); } } for (const c of action.costs) { if (!resourcesById[c.resourceId]) { throw new Error(`Action "${action.id}" cost references unknown resource "${c.resourceId}"`); } } } return { resources, actions, resourcesById, actionsById }; }