128 lines
4.1 KiB
TypeScript
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 };
|
|
}
|