From b54805637cb26f2f5765437521d4390db391c4ab Mon Sep 17 00:00:00 2001 From: ginnoir Date: Thu, 11 Jun 2026 20:23:49 -0500 Subject: [PATCH] feat(content): migrate stub actions to kind/group model --- src/content/__tests__/definitions.test.ts | 17 +++++++---- src/content/__tests__/storySchema.test.ts | 2 ++ src/content/definitions.ts | 35 ++++++++++++++++++++++- src/engine/__tests__/determinism.test.ts | 1 + src/engine/__tests__/game.test.ts | 17 +++++++++-- src/engine/__tests__/save.test.ts | 1 + src/engine/__tests__/story.test.ts | 7 +++++ src/state/__tests__/persistence.test.ts | 7 +++-- src/state/__tests__/viewModel.test.ts | 9 ++++-- 9 files changed, 83 insertions(+), 13 deletions(-) diff --git a/src/content/__tests__/definitions.test.ts b/src/content/__tests__/definitions.test.ts index bfa1315..88d838a 100644 --- a/src/content/__tests__/definitions.test.ts +++ b/src/content/__tests__/definitions.test.ts @@ -3,21 +3,28 @@ import { createGameState, enqueueAction, tickGame } from '../../engine/game'; import { content } from '../index'; describe('M1 stub content pack', () => { - it('defines two resources and four to five actions with costs and unlocks', () => { + it('defines two resources and multiple actions with costs, unlocks, and varied kinds', () => { expect(content.resources).toHaveLength(2); expect(content.actions.length).toBeGreaterThanOrEqual(4); - expect(content.actions.length).toBeLessThanOrEqual(6); const withCosts = content.actions.filter((a) => a.costs.length > 0); const withUnlocks = content.actions.filter((a) => a.unlock !== undefined); expect(withCosts.length).toBeGreaterThanOrEqual(2); expect(withUnlocks.length).toBeGreaterThanOrEqual(1); + // verify the new kinds are present + const kinds = new Set(content.actions.map((a) => a.kind)); + expect(kinds.has('timed')).toBe(true); + expect(kinds.has('loop')).toBe(true); + expect(kinds.has('story')).toBe(true); }); - it('can simulate a costed action without throwing', () => { + it('can simulate a costed timed action without throwing', () => { const state = createGameState(content); - const trade = content.actions.find((a) => a.costs.length > 0); + const trade = content.actions.find((a) => a.costs.length > 0 && a.kind === 'timed'); if (!trade) { - throw new Error('expected at least one costed action'); + throw new Error('expected at least one costed timed action'); + } + if (trade.durationMs === undefined) { + throw new Error('expected costed timed action to have durationMs'); } enqueueAction(state, content, trade.id); tickGame(state, content, trade.durationMs); diff --git a/src/content/__tests__/storySchema.test.ts b/src/content/__tests__/storySchema.test.ts index 046e897..6f0be59 100644 --- a/src/content/__tests__/storySchema.test.ts +++ b/src/content/__tests__/storySchema.test.ts @@ -9,6 +9,8 @@ const actionsById = { scout_path: { id: 'scout_path', name: 'Scout', + kind: 'timed' as const, + group: { id: 'travel', label: 'Travel' }, durationMs: 1000, costs: [], yields: [{ resourceId: 'coin', amount: 1 }], diff --git a/src/content/definitions.ts b/src/content/definitions.ts index ad9157e..27a432d 100644 --- a/src/content/definitions.ts +++ b/src/content/definitions.ts @@ -7,6 +7,8 @@ export const actionDefs = [ { id: 'gather_supplies', name: 'Gather supplies', + kind: 'timed', + group: { id: 'camp', label: 'Camp' }, durationMs: 3000, yields: [{ resourceId: 'supplies', amount: 2 }], storyHint: 'Basic camp labor.', @@ -15,6 +17,8 @@ export const actionDefs = [ { id: 'scout_path', name: 'Scout the path', + kind: 'timed', + group: { id: 'travel', label: 'Travel' }, durationMs: 5000, costs: [{ resourceId: 'supplies', amount: 2 }], yields: [{ resourceId: 'coin', amount: 1 }], @@ -24,6 +28,8 @@ export const actionDefs = [ { id: 'trade_supplies', name: 'Trade at camp', + kind: 'timed', + group: { id: 'camp', label: 'Camp' }, durationMs: 4000, costs: [{ resourceId: 'supplies', amount: 3 }], yields: [{ resourceId: 'coin', amount: 2 }], @@ -34,6 +40,8 @@ export const actionDefs = [ { id: 'fortify_camp', name: 'Fortify camp', + kind: 'timed', + group: { id: 'camp', label: 'Camp' }, durationMs: 8000, costs: [ { resourceId: 'supplies', amount: 5 }, @@ -47,6 +55,8 @@ export const actionDefs = [ { id: 'push_onward', name: 'Push onward', + kind: 'timed', + group: { id: 'travel', label: 'Travel' }, durationMs: 6000, costs: [{ resourceId: 'supplies', amount: 2 }], yields: [{ resourceId: 'coin', amount: 3 }], @@ -57,9 +67,32 @@ export const actionDefs = [ { id: 'rest', name: 'Rest briefly', + kind: 'loop', + group: { id: 'camp_loop', label: 'Camp activities' }, + loopPriority: 0, durationMs: 2000, yields: [{ resourceId: 'supplies', amount: 1 }], storyHint: 'Catch your breath.', - storyTooltip: 'Yields 1 Supply. Quick recovery.', + storyTooltip: 'Idle upkeep — runs when nothing else is queued.', + }, + { + id: 'pick_high_road', + name: 'Take the high road', + kind: 'story', + group: { id: 'fork', label: 'Crossroads' }, + storyChoiceId: 'pick_a', + storyHint: 'Route A — high ground and supplies.', + storyTooltip: 'Story fork: grants route A flag and resources. Hides river path.', + yields: [], + }, + { + id: 'follow_river', + name: 'Follow the river', + kind: 'story', + group: { id: 'fork', label: 'Crossroads' }, + storyChoiceId: 'pick_b', + storyHint: 'Route B — river trade and coin.', + storyTooltip: 'Story fork: grants route B flag. Hides high road path.', + yields: [], }, ]; diff --git a/src/engine/__tests__/determinism.test.ts b/src/engine/__tests__/determinism.test.ts index 6e6ef1f..799010b 100644 --- a/src/engine/__tests__/determinism.test.ts +++ b/src/engine/__tests__/determinism.test.ts @@ -10,6 +10,7 @@ function testContent() { { id: 'forage', name: 'Forage', + group: { id: 'test', label: 'Test' }, durationMs: 300, yields: [{ resourceId: 'gold', amount: 1 }], }, diff --git a/src/engine/__tests__/game.test.ts b/src/engine/__tests__/game.test.ts index b353fd1..aee2c49 100644 --- a/src/engine/__tests__/game.test.ts +++ b/src/engine/__tests__/game.test.ts @@ -10,6 +10,8 @@ import { tickGame, } from '../game'; +const DEFAULT_GROUP = { id: 'test', label: 'Test' }; + function testContent() { return buildContent({ resources: [{ id: 'gold', name: 'Gold', startAmount: 5 }], @@ -17,6 +19,7 @@ function testContent() { { id: 'forage', name: 'Forage', + group: DEFAULT_GROUP, durationMs: 300, yields: [{ resourceId: 'gold', amount: 2 }], }, @@ -28,9 +31,9 @@ function queueContent() { return buildContent({ resources: [{ id: 'gold', name: 'Gold', startAmount: 0 }], actions: [ - { id: 'a', name: 'A', durationMs: 1000, yields: [{ resourceId: 'gold', amount: 1 }] }, - { id: 'b', name: 'B', durationMs: 1000, yields: [{ resourceId: 'gold', amount: 1 }] }, - { id: 'c', name: 'C', durationMs: 1000, yields: [{ resourceId: 'gold', amount: 1 }] }, + { id: 'a', name: 'A', group: DEFAULT_GROUP, durationMs: 1000, yields: [{ resourceId: 'gold', amount: 1 }] }, + { id: 'b', name: 'B', group: DEFAULT_GROUP, durationMs: 1000, yields: [{ resourceId: 'gold', amount: 1 }] }, + { id: 'c', name: 'C', group: DEFAULT_GROUP, durationMs: 1000, yields: [{ resourceId: 'gold', amount: 1 }] }, ], }); } @@ -45,12 +48,14 @@ function costContent() { { id: 'gather', name: 'Gather', + group: DEFAULT_GROUP, durationMs: 300, yields: [{ resourceId: 'supplies', amount: 2 }], }, { id: 'trade', name: 'Trade', + group: DEFAULT_GROUP, durationMs: 300, costs: [{ resourceId: 'supplies', amount: 5 }], yields: [{ resourceId: 'coin', amount: 3 }], @@ -58,6 +63,7 @@ function costContent() { { id: 'scout', name: 'Scout', + group: DEFAULT_GROUP, durationMs: 300, yields: [{ resourceId: 'coin', amount: 1 }], unlock: { minResources: { coin: 1 } }, @@ -204,6 +210,7 @@ describe('unlock conditions', () => { { id: 'secret', name: 'Secret', + group: DEFAULT_GROUP, durationMs: 100, yields: [{ resourceId: 'gold', amount: 1 }], unlock: { requireStoryFlags: ['path_scouted'] }, @@ -245,12 +252,14 @@ describe('completion advances queue', () => { { id: 'cheap', name: 'Cheap', + group: DEFAULT_GROUP, durationMs: 100, yields: [{ resourceId: 'supplies', amount: 1 }], }, { id: 'dear', name: 'Dear', + group: DEFAULT_GROUP, durationMs: 100, costs: [{ resourceId: 'supplies', amount: 6 }], yields: [{ resourceId: 'supplies', amount: 1 }], @@ -258,6 +267,7 @@ describe('completion advances queue', () => { { id: 'free', name: 'Free', + group: DEFAULT_GROUP, durationMs: 100, yields: [{ resourceId: 'supplies', amount: 1 }], }, @@ -283,6 +293,7 @@ describe('completion advances queue', () => { { id: 'combo', name: 'Combo', + group: DEFAULT_GROUP, durationMs: 100, yields: [ { resourceId: 'a', amount: 2 }, diff --git a/src/engine/__tests__/save.test.ts b/src/engine/__tests__/save.test.ts index b0eaf37..776f826 100644 --- a/src/engine/__tests__/save.test.ts +++ b/src/engine/__tests__/save.test.ts @@ -18,6 +18,7 @@ function testContent() { { id: 'forage', name: 'Forage', + group: { id: 'test', label: 'Test' }, durationMs: 3000, yields: [{ resourceId: 'gold', amount: 1 }], }, diff --git a/src/engine/__tests__/story.test.ts b/src/engine/__tests__/story.test.ts index 006ff25..4897240 100644 --- a/src/engine/__tests__/story.test.ts +++ b/src/engine/__tests__/story.test.ts @@ -13,6 +13,8 @@ import { type StoryEvent, } from '../story'; +const DEFAULT_GROUP = { id: 'test', label: 'Test' }; + function gameContent() { const base = buildContent({ resources: [ @@ -23,6 +25,7 @@ function gameContent() { { id: 'scout_path', name: 'Scout', + group: DEFAULT_GROUP, durationMs: 1000, costs: [], yields: [{ resourceId: 'coin', amount: 1 }], @@ -54,6 +57,7 @@ function gameContentWithActionTrigger() { { id: 'scout_path', name: 'Scout', + group: DEFAULT_GROUP, durationMs: 1000, costs: [], yields: [{ resourceId: 'coin', amount: 1 }], @@ -91,6 +95,7 @@ function gameContentWithThreshold() { { id: 'scout_path', name: 'Scout', + group: DEFAULT_GROUP, durationMs: 1000, costs: [], yields: [{ resourceId: 'coin', amount: 1 }], @@ -132,6 +137,7 @@ function gameContentWithFork() { { id: 'scout_path', name: 'Scout', + group: DEFAULT_GROUP, durationMs: 1000, costs: [], yields: [{ resourceId: 'coin', amount: 1 }], @@ -189,6 +195,7 @@ function gameContentWithGatedChoice() { { id: 'scout_path', name: 'Scout', + group: DEFAULT_GROUP, durationMs: 1000, costs: [], yields: [{ resourceId: 'coin', amount: 1 }], diff --git a/src/state/__tests__/persistence.test.ts b/src/state/__tests__/persistence.test.ts index 22f4629..b639082 100644 --- a/src/state/__tests__/persistence.test.ts +++ b/src/state/__tests__/persistence.test.ts @@ -4,6 +4,8 @@ import { createGameState, enqueueAction, startAction } from '../../engine/game'; import { createSave, serializeSave } from '../../engine/save'; import { createMemoryBackend, loadGame, saveGame } from '../persistence'; +const DEFAULT_GROUP = { id: 'test', label: 'Test' }; + function testContent() { return buildContent({ resources: [{ id: 'gold', name: 'Gold', startAmount: 0 }], @@ -11,6 +13,7 @@ function testContent() { { id: 'forage', name: 'Forage', + group: DEFAULT_GROUP, durationMs: 3000, yields: [{ resourceId: 'gold', amount: 1 }], }, @@ -22,8 +25,8 @@ function queueTestContent() { return buildContent({ resources: [{ id: 'gold', name: 'Gold', startAmount: 0 }], actions: [ - { id: 'a', name: 'A', durationMs: 3000, yields: [{ resourceId: 'gold', amount: 1 }] }, - { id: 'b', name: 'B', durationMs: 3000, yields: [{ resourceId: 'gold', amount: 1 }] }, + { id: 'a', name: 'A', group: DEFAULT_GROUP, durationMs: 3000, yields: [{ resourceId: 'gold', amount: 1 }] }, + { id: 'b', name: 'B', group: DEFAULT_GROUP, durationMs: 3000, yields: [{ resourceId: 'gold', amount: 1 }] }, ], }); } diff --git a/src/state/__tests__/viewModel.test.ts b/src/state/__tests__/viewModel.test.ts index ff8816b..3954e72 100644 --- a/src/state/__tests__/viewModel.test.ts +++ b/src/state/__tests__/viewModel.test.ts @@ -5,6 +5,8 @@ import { createGameState, enqueueAction, startAction } from '../../engine/game'; import { enterStoryNode } from '../../engine/story'; import { formatOfflineDuration, toView } from '../viewModel'; +const DEFAULT_GROUP = { id: 'test', label: 'Test' }; + function testContent() { const base = buildContent({ resources: [{ id: 'gold', name: 'Gold', startAmount: 4 }], @@ -12,6 +14,7 @@ function testContent() { { id: 'forage', name: 'Forage', + group: DEFAULT_GROUP, durationMs: 200, yields: [{ resourceId: 'gold', amount: 1 }], }, @@ -87,8 +90,8 @@ describe('toView()', () => { const content = contentWithActions( [{ id: 'gold', name: 'Gold' }], [ - { id: 'a', name: 'Alpha', durationMs: 1000, yields: [{ resourceId: 'gold', amount: 1 }] }, - { id: 'b', name: 'Bravo', durationMs: 1000, yields: [{ resourceId: 'gold', amount: 1 }] }, + { id: 'a', name: 'Alpha', group: DEFAULT_GROUP, durationMs: 1000, yields: [{ resourceId: 'gold', amount: 1 }] }, + { id: 'b', name: 'Bravo', group: DEFAULT_GROUP, durationMs: 1000, yields: [{ resourceId: 'gold', amount: 1 }] }, ], ); const state = createGameState(content); @@ -108,6 +111,7 @@ describe('toView() action availability', () => { { id: 'locked', name: 'Locked', + group: DEFAULT_GROUP, durationMs: 1000, yields: [{ resourceId: 'coin', amount: 1 }], unlock: { requireStoryFlags: ['route_a'] }, @@ -127,6 +131,7 @@ describe('toView() action availability', () => { { id: 'forage', name: 'Forage', + group: DEFAULT_GROUP, durationMs: 1000, yields: [{ resourceId: 'coin', amount: 1 }], },