Files
idlegame/src/content/schema.ts
T

128 lines
4.1 KiB
TypeScript

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<typeof resourceDefSchema>;
export type ResourceAmount = z.infer<typeof resourceAmountSchema>;
export type UnlockDef = z.infer<typeof unlockDefSchema>;
export type ActionKind = z.infer<typeof actionKindSchema>;
export type ActionGroup = z.infer<typeof actionGroupSchema>;
export type ActionDef = z.infer<typeof actionDefSchema>;
export interface Content {
resources: ResourceDef[];
actions: ActionDef[];
resourcesById: Record<string, ResourceDef>;
actionsById: Record<string, ActionDef>;
}
function indexById<T extends { id: string }>(items: T[], kind: string): Record<string, T> {
const byId: Record<string, T> = {};
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 };
}