diff --git a/src/content/__tests__/schema.test.ts b/src/content/__tests__/schema.test.ts index 960a724..fe5b756 100644 --- a/src/content/__tests__/schema.test.ts +++ b/src/content/__tests__/schema.test.ts @@ -6,6 +6,7 @@ const validActions = [ { id: 'forage', name: 'Forage', + group: { id: 'camp', label: 'Camp' }, durationMs: 3000, yields: [{ resourceId: 'gold', amount: 1 }], }, @@ -28,6 +29,7 @@ describe('buildContent()', () => { { id: 'forage', name: 'Forage', + group: { id: 'camp', label: 'Camp' }, durationMs: 3000, yields: [{ resourceId: 'ghost', amount: 1 }], }, @@ -48,6 +50,7 @@ describe('buildContent()', () => { { id: 'forage', name: 'Forage', + group: { id: 'camp', label: 'Camp' }, durationMs: -1, yields: [{ resourceId: 'gold', amount: 1 }], }, @@ -66,6 +69,7 @@ describe('costs and multi-yield', () => { { id: 'craft', name: 'Craft', + group: { id: 'camp', label: 'Camp' }, durationMs: 1000, costs: [{ resourceId: 'wood', amount: 2 }], yields: [ @@ -84,6 +88,7 @@ describe('costs and multi-yield', () => { { id: 'craft', name: 'Craft', + group: { id: 'camp', label: 'Camp' }, durationMs: 1000, costs: [{ resourceId: 'ghost', amount: 1 }], yields: [{ resourceId: 'gold', amount: 1 }], @@ -104,6 +109,7 @@ describe('unlock conditions', () => { { id: 'forage', name: 'Forage', + group: { id: 'camp', label: 'Camp' }, durationMs: 3000, yields: [{ resourceId: 'gold', amount: 1 }], unlock: { @@ -131,6 +137,7 @@ describe('action narrative fields', () => { { id: 'forage', name: 'Forage', + group: { id: 'camp', label: 'Camp' }, durationMs: 3000, yields: [{ resourceId: 'gold', amount: 1 }], storyHint: 'Gather what the forest offers.', @@ -148,3 +155,57 @@ describe('action narrative fields', () => { expect(content.actionsById.forage.storyTooltip).toBeUndefined(); }); }); + +describe('action kind schema', () => { + it('accepts kind, group, and storyChoiceId', () => { + const content = buildContent({ + resources: [{ id: 'supplies', name: 'Supplies' }], + actions: [ + { + id: 'pick_high_road', + name: 'Take the high road', + kind: 'story', + group: { id: 'fork', label: 'Crossroads' }, + storyChoiceId: 'pick_a', + yields: [], + }, + ], + }); + expect(content.actionsById.pick_high_road.kind).toBe('story'); + expect(content.actionsById.pick_high_road.group.label).toBe('Crossroads'); + }); + + it('defaults kind to timed and requires durationMs for timed actions', () => { + expect(() => + buildContent({ + resources: [{ id: 'supplies', name: 'Supplies' }], + actions: [ + { + id: 'broken', + name: 'Broken', + kind: 'timed', + group: { id: 'camp', label: 'Camp' }, + yields: [{ resourceId: 'supplies', amount: 1 }], + }, + ], + }), + ).toThrow(); + }); + + it('requires durationMs for loop actions', () => { + const content = buildContent({ + resources: [{ id: 'supplies', name: 'Supplies' }], + actions: [ + { + id: 'rest', + name: 'Rest', + kind: 'loop', + group: { id: 'camp_loop', label: 'Camp activities' }, + durationMs: 2000, + yields: [{ resourceId: 'supplies', amount: 1 }], + }, + ], + }); + expect(content.actionsById.rest.kind).toBe('loop'); + }); +}); diff --git a/src/content/index.ts b/src/content/index.ts index 59ad305..1222a88 100644 --- a/src/content/index.ts +++ b/src/content/index.ts @@ -9,4 +9,4 @@ const story = buildStoryContent(storyNodeDefs, base.actionsById, base.resourcesB export type GameContent = Content & StoryContent; export const content: GameContent = { ...base, ...story }; -export type { ActionDef, Content, ResourceDef } from './schema'; +export type { ActionDef, ActionGroup, ActionKind, Content, ResourceDef } from './schema'; diff --git a/src/content/schema.ts b/src/content/schema.ts index 41771a4..d42ad9d 100644 --- a/src/content/schema.ts +++ b/src/content/schema.ts @@ -25,20 +25,63 @@ export const unlockDefSchema = z.object({ requireStoryFlags: z.array(z.string().min(1)).optional(), }); -export const actionDefSchema = z.object({ +export const actionKindSchema = z.enum(['instant', 'loop', 'timed', 'story', 'context']); + +export const actionGroupSchema = z.object({ id: z.string().min(1), - name: z.string().min(1), - durationMs: z.number().positive(), - costs: z.array(resourceAmountSchema).default([]), - yields: z.array(resourceAmountSchema).min(1), - unlock: unlockDefSchema.optional(), - storyHint: z.string().min(1).optional(), - storyTooltip: z.string().min(1).optional(), + 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) { + 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 {