From ee3d463c9c8affb0157869e274dd45827a8f632f Mon Sep 17 00:00:00 2001 From: ginnoir Date: Thu, 11 Jun 2026 18:16:26 -0500 Subject: [PATCH] feat(content): add action costs and multi-yield schema --- src/content/__tests__/schema.test.ts | 58 ++++++++++++++++++++++-- src/content/definitions.ts | 2 +- src/content/schema.ts | 29 ++++++++---- src/engine/__tests__/determinism.test.ts | 2 +- src/engine/__tests__/game.test.ts | 2 +- src/engine/__tests__/save.test.ts | 2 +- src/engine/game.ts | 4 +- src/state/__tests__/persistence.test.ts | 2 +- src/state/__tests__/viewModel.test.ts | 2 +- 9 files changed, 85 insertions(+), 18 deletions(-) diff --git a/src/content/__tests__/schema.test.ts b/src/content/__tests__/schema.test.ts index 6d9a75f..49c7daf 100644 --- a/src/content/__tests__/schema.test.ts +++ b/src/content/__tests__/schema.test.ts @@ -3,7 +3,12 @@ import { buildContent } from '../schema'; const validResources = [{ id: 'gold', name: 'Gold' }]; const validActions = [ - { id: 'forage', name: 'Forage', durationMs: 3000, yields: { resourceId: 'gold', amount: 1 } }, + { + id: 'forage', + name: 'Forage', + durationMs: 3000, + yields: [{ resourceId: 'gold', amount: 1 }], + }, ]; describe('buildContent()', () => { @@ -24,7 +29,7 @@ describe('buildContent()', () => { id: 'forage', name: 'Forage', durationMs: 3000, - yields: { resourceId: 'ghost', amount: 1 }, + yields: [{ resourceId: 'ghost', amount: 1 }], }, ]; expect(() => buildContent({ resources: validResources, actions })).toThrow(/unknown resource/i); @@ -40,8 +45,55 @@ describe('buildContent()', () => { it('rejects a structurally invalid definition', () => { const actions = [ - { id: 'forage', name: 'Forage', durationMs: -1, yields: { resourceId: 'gold', amount: 1 } }, + { + id: 'forage', + name: 'Forage', + durationMs: -1, + yields: [{ resourceId: 'gold', amount: 1 }], + }, ]; expect(() => buildContent({ resources: validResources, actions })).toThrow(); }); }); + +describe('costs and multi-yield', () => { + it('accepts optional costs and multiple yields', () => { + const resources = [ + { id: 'gold', name: 'Gold' }, + { id: 'wood', name: 'Wood' }, + ]; + const actions = [ + { + id: 'craft', + name: 'Craft', + durationMs: 1000, + costs: [{ resourceId: 'wood', amount: 2 }], + yields: [ + { resourceId: 'gold', amount: 1 }, + { resourceId: 'wood', amount: 1 }, + ], + }, + ]; + const content = buildContent({ resources, actions }); + expect(content.actionsById.craft.costs).toEqual([{ resourceId: 'wood', amount: 2 }]); + expect(content.actionsById.craft.yields).toHaveLength(2); + }); + + it('rejects cost referencing unknown resource', () => { + const actions = [ + { + id: 'craft', + name: 'Craft', + durationMs: 1000, + costs: [{ resourceId: 'ghost', amount: 1 }], + yields: [{ resourceId: 'gold', amount: 1 }], + }, + ]; + expect(() => buildContent({ resources: validResources, actions })).toThrow(/unknown resource/i); + }); + + it('defaults costs to empty array', () => { + const content = buildContent({ resources: validResources, actions: validActions }); + expect(content.actionsById.forage.costs).toEqual([]); + }); +}); diff --git a/src/content/definitions.ts b/src/content/definitions.ts index 343827d..d3779ef 100644 --- a/src/content/definitions.ts +++ b/src/content/definitions.ts @@ -12,6 +12,6 @@ export const actionDefs = [ id: 'forage', name: 'Forage for coin', durationMs: 3000, - yields: { resourceId: 'gold', amount: 1 }, + yields: [{ resourceId: 'gold', amount: 1 }], }, ]; diff --git a/src/content/schema.ts b/src/content/schema.ts index b5e023c..2ac353f 100644 --- a/src/content/schema.ts +++ b/src/content/schema.ts @@ -15,17 +15,21 @@ export const resourceDefSchema = z.object({ startAmount: z.number().nonnegative().default(0), }); +export const resourceAmountSchema = z.object({ + resourceId: z.string().min(1), + amount: z.number().positive(), +}); + export const actionDefSchema = z.object({ id: z.string().min(1), name: z.string().min(1), durationMs: z.number().positive(), - yields: z.object({ - resourceId: z.string().min(1), - amount: z.number().positive(), - }), + costs: z.array(resourceAmountSchema).default([]), + yields: z.array(resourceAmountSchema).min(1), }); export type ResourceDef = z.infer; +export type ResourceAmount = z.infer; export type ActionDef = z.infer; export interface Content { @@ -55,10 +59,19 @@ export function buildContent(input: { resources: unknown[]; actions: unknown[] } const actionsById = indexById(actions, 'action'); for (const action of actions) { - if (!resourcesById[action.yields.resourceId]) { - throw new Error( - `Action "${action.id}" yields unknown resource "${action.yields.resourceId}"`, - ); + 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}"`, + ); + } } } diff --git a/src/engine/__tests__/determinism.test.ts b/src/engine/__tests__/determinism.test.ts index 70cce04..fd0cd1c 100644 --- a/src/engine/__tests__/determinism.test.ts +++ b/src/engine/__tests__/determinism.test.ts @@ -11,7 +11,7 @@ function testContent() { id: 'forage', name: 'Forage', durationMs: 300, - yields: { resourceId: 'gold', amount: 1 }, + yields: [{ resourceId: 'gold', amount: 1 }], }, ], }); diff --git a/src/engine/__tests__/game.test.ts b/src/engine/__tests__/game.test.ts index 58c6d5d..b1722e6 100644 --- a/src/engine/__tests__/game.test.ts +++ b/src/engine/__tests__/game.test.ts @@ -6,7 +6,7 @@ function testContent() { return buildContent({ resources: [{ id: 'gold', name: 'Gold', startAmount: 5 }], actions: [ - { id: 'forage', name: 'Forage', durationMs: 300, yields: { resourceId: 'gold', amount: 2 } }, + { id: 'forage', name: 'Forage', durationMs: 300, yields: [{ resourceId: 'gold', amount: 2 }] }, ], }); } diff --git a/src/engine/__tests__/save.test.ts b/src/engine/__tests__/save.test.ts index 3725092..bcad6dc 100644 --- a/src/engine/__tests__/save.test.ts +++ b/src/engine/__tests__/save.test.ts @@ -15,7 +15,7 @@ function testContent() { return buildContent({ resources: [{ id: 'gold', name: 'Gold', startAmount: 0 }], actions: [ - { id: 'forage', name: 'Forage', durationMs: 3000, yields: { resourceId: 'gold', amount: 1 } }, + { id: 'forage', name: 'Forage', durationMs: 3000, yields: [{ resourceId: 'gold', amount: 1 }] }, ], }); } diff --git a/src/engine/game.ts b/src/engine/game.ts index e6eeaa6..e637c5c 100644 --- a/src/engine/game.ts +++ b/src/engine/game.ts @@ -50,6 +50,8 @@ export function tickGame(state: GameState, content: Content, tickMs: number): vo state.actionElapsedMs += tickMs; while (state.actionElapsedMs >= action.durationMs) { state.actionElapsedMs -= action.durationMs; - state.resources[action.yields.resourceId] += action.yields.amount; + for (const y of action.yields) { + state.resources[y.resourceId] += y.amount; + } } } diff --git a/src/state/__tests__/persistence.test.ts b/src/state/__tests__/persistence.test.ts index c22d4a4..bd310d8 100644 --- a/src/state/__tests__/persistence.test.ts +++ b/src/state/__tests__/persistence.test.ts @@ -8,7 +8,7 @@ function testContent() { return buildContent({ resources: [{ id: 'gold', name: 'Gold', startAmount: 0 }], actions: [ - { id: 'forage', name: 'Forage', durationMs: 3000, yields: { resourceId: 'gold', amount: 1 } }, + { id: 'forage', name: 'Forage', durationMs: 3000, yields: [{ resourceId: 'gold', amount: 1 }] }, ], }); } diff --git a/src/state/__tests__/viewModel.test.ts b/src/state/__tests__/viewModel.test.ts index 971e2ff..48ab5b6 100644 --- a/src/state/__tests__/viewModel.test.ts +++ b/src/state/__tests__/viewModel.test.ts @@ -7,7 +7,7 @@ function testContent() { return buildContent({ resources: [{ id: 'gold', name: 'Gold', startAmount: 4 }], actions: [ - { id: 'forage', name: 'Forage', durationMs: 200, yields: { resourceId: 'gold', amount: 1 } }, + { id: 'forage', name: 'Forage', durationMs: 200, yields: [{ resourceId: 'gold', amount: 1 }] }, ], }); }