feat(content): migrate stub actions to kind/group model

This commit is contained in:
ginnoir
2026-06-11 20:23:49 -05:00
parent c1b38e36aa
commit b54805637c
9 changed files with 83 additions and 13 deletions
+12 -5
View File
@@ -3,21 +3,28 @@ import { createGameState, enqueueAction, tickGame } from '../../engine/game';
import { content } from '../index'; import { content } from '../index';
describe('M1 stub content pack', () => { 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.resources).toHaveLength(2);
expect(content.actions.length).toBeGreaterThanOrEqual(4); expect(content.actions.length).toBeGreaterThanOrEqual(4);
expect(content.actions.length).toBeLessThanOrEqual(6);
const withCosts = content.actions.filter((a) => a.costs.length > 0); const withCosts = content.actions.filter((a) => a.costs.length > 0);
const withUnlocks = content.actions.filter((a) => a.unlock !== undefined); const withUnlocks = content.actions.filter((a) => a.unlock !== undefined);
expect(withCosts.length).toBeGreaterThanOrEqual(2); expect(withCosts.length).toBeGreaterThanOrEqual(2);
expect(withUnlocks.length).toBeGreaterThanOrEqual(1); 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 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) { 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); enqueueAction(state, content, trade.id);
tickGame(state, content, trade.durationMs); tickGame(state, content, trade.durationMs);
@@ -9,6 +9,8 @@ const actionsById = {
scout_path: { scout_path: {
id: 'scout_path', id: 'scout_path',
name: 'Scout', name: 'Scout',
kind: 'timed' as const,
group: { id: 'travel', label: 'Travel' },
durationMs: 1000, durationMs: 1000,
costs: [], costs: [],
yields: [{ resourceId: 'coin', amount: 1 }], yields: [{ resourceId: 'coin', amount: 1 }],
+34 -1
View File
@@ -7,6 +7,8 @@ export const actionDefs = [
{ {
id: 'gather_supplies', id: 'gather_supplies',
name: 'Gather supplies', name: 'Gather supplies',
kind: 'timed',
group: { id: 'camp', label: 'Camp' },
durationMs: 3000, durationMs: 3000,
yields: [{ resourceId: 'supplies', amount: 2 }], yields: [{ resourceId: 'supplies', amount: 2 }],
storyHint: 'Basic camp labor.', storyHint: 'Basic camp labor.',
@@ -15,6 +17,8 @@ export const actionDefs = [
{ {
id: 'scout_path', id: 'scout_path',
name: 'Scout the path', name: 'Scout the path',
kind: 'timed',
group: { id: 'travel', label: 'Travel' },
durationMs: 5000, durationMs: 5000,
costs: [{ resourceId: 'supplies', amount: 2 }], costs: [{ resourceId: 'supplies', amount: 2 }],
yields: [{ resourceId: 'coin', amount: 1 }], yields: [{ resourceId: 'coin', amount: 1 }],
@@ -24,6 +28,8 @@ export const actionDefs = [
{ {
id: 'trade_supplies', id: 'trade_supplies',
name: 'Trade at camp', name: 'Trade at camp',
kind: 'timed',
group: { id: 'camp', label: 'Camp' },
durationMs: 4000, durationMs: 4000,
costs: [{ resourceId: 'supplies', amount: 3 }], costs: [{ resourceId: 'supplies', amount: 3 }],
yields: [{ resourceId: 'coin', amount: 2 }], yields: [{ resourceId: 'coin', amount: 2 }],
@@ -34,6 +40,8 @@ export const actionDefs = [
{ {
id: 'fortify_camp', id: 'fortify_camp',
name: 'Fortify camp', name: 'Fortify camp',
kind: 'timed',
group: { id: 'camp', label: 'Camp' },
durationMs: 8000, durationMs: 8000,
costs: [ costs: [
{ resourceId: 'supplies', amount: 5 }, { resourceId: 'supplies', amount: 5 },
@@ -47,6 +55,8 @@ export const actionDefs = [
{ {
id: 'push_onward', id: 'push_onward',
name: 'Push onward', name: 'Push onward',
kind: 'timed',
group: { id: 'travel', label: 'Travel' },
durationMs: 6000, durationMs: 6000,
costs: [{ resourceId: 'supplies', amount: 2 }], costs: [{ resourceId: 'supplies', amount: 2 }],
yields: [{ resourceId: 'coin', amount: 3 }], yields: [{ resourceId: 'coin', amount: 3 }],
@@ -57,9 +67,32 @@ export const actionDefs = [
{ {
id: 'rest', id: 'rest',
name: 'Rest briefly', name: 'Rest briefly',
kind: 'loop',
group: { id: 'camp_loop', label: 'Camp activities' },
loopPriority: 0,
durationMs: 2000, durationMs: 2000,
yields: [{ resourceId: 'supplies', amount: 1 }], yields: [{ resourceId: 'supplies', amount: 1 }],
storyHint: 'Catch your breath.', 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: [],
}, },
]; ];
+1
View File
@@ -10,6 +10,7 @@ function testContent() {
{ {
id: 'forage', id: 'forage',
name: 'Forage', name: 'Forage',
group: { id: 'test', label: 'Test' },
durationMs: 300, durationMs: 300,
yields: [{ resourceId: 'gold', amount: 1 }], yields: [{ resourceId: 'gold', amount: 1 }],
}, },
+14 -3
View File
@@ -10,6 +10,8 @@ import {
tickGame, tickGame,
} from '../game'; } from '../game';
const DEFAULT_GROUP = { id: 'test', label: 'Test' };
function testContent() { function testContent() {
return buildContent({ return buildContent({
resources: [{ id: 'gold', name: 'Gold', startAmount: 5 }], resources: [{ id: 'gold', name: 'Gold', startAmount: 5 }],
@@ -17,6 +19,7 @@ function testContent() {
{ {
id: 'forage', id: 'forage',
name: 'Forage', name: 'Forage',
group: DEFAULT_GROUP,
durationMs: 300, durationMs: 300,
yields: [{ resourceId: 'gold', amount: 2 }], yields: [{ resourceId: 'gold', amount: 2 }],
}, },
@@ -28,9 +31,9 @@ function queueContent() {
return buildContent({ return buildContent({
resources: [{ id: 'gold', name: 'Gold', startAmount: 0 }], resources: [{ id: 'gold', name: 'Gold', startAmount: 0 }],
actions: [ actions: [
{ id: 'a', name: 'A', 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', 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', 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', id: 'gather',
name: 'Gather', name: 'Gather',
group: DEFAULT_GROUP,
durationMs: 300, durationMs: 300,
yields: [{ resourceId: 'supplies', amount: 2 }], yields: [{ resourceId: 'supplies', amount: 2 }],
}, },
{ {
id: 'trade', id: 'trade',
name: 'Trade', name: 'Trade',
group: DEFAULT_GROUP,
durationMs: 300, durationMs: 300,
costs: [{ resourceId: 'supplies', amount: 5 }], costs: [{ resourceId: 'supplies', amount: 5 }],
yields: [{ resourceId: 'coin', amount: 3 }], yields: [{ resourceId: 'coin', amount: 3 }],
@@ -58,6 +63,7 @@ function costContent() {
{ {
id: 'scout', id: 'scout',
name: 'Scout', name: 'Scout',
group: DEFAULT_GROUP,
durationMs: 300, durationMs: 300,
yields: [{ resourceId: 'coin', amount: 1 }], yields: [{ resourceId: 'coin', amount: 1 }],
unlock: { minResources: { coin: 1 } }, unlock: { minResources: { coin: 1 } },
@@ -204,6 +210,7 @@ describe('unlock conditions', () => {
{ {
id: 'secret', id: 'secret',
name: 'Secret', name: 'Secret',
group: DEFAULT_GROUP,
durationMs: 100, durationMs: 100,
yields: [{ resourceId: 'gold', amount: 1 }], yields: [{ resourceId: 'gold', amount: 1 }],
unlock: { requireStoryFlags: ['path_scouted'] }, unlock: { requireStoryFlags: ['path_scouted'] },
@@ -245,12 +252,14 @@ describe('completion advances queue', () => {
{ {
id: 'cheap', id: 'cheap',
name: 'Cheap', name: 'Cheap',
group: DEFAULT_GROUP,
durationMs: 100, durationMs: 100,
yields: [{ resourceId: 'supplies', amount: 1 }], yields: [{ resourceId: 'supplies', amount: 1 }],
}, },
{ {
id: 'dear', id: 'dear',
name: 'Dear', name: 'Dear',
group: DEFAULT_GROUP,
durationMs: 100, durationMs: 100,
costs: [{ resourceId: 'supplies', amount: 6 }], costs: [{ resourceId: 'supplies', amount: 6 }],
yields: [{ resourceId: 'supplies', amount: 1 }], yields: [{ resourceId: 'supplies', amount: 1 }],
@@ -258,6 +267,7 @@ describe('completion advances queue', () => {
{ {
id: 'free', id: 'free',
name: 'Free', name: 'Free',
group: DEFAULT_GROUP,
durationMs: 100, durationMs: 100,
yields: [{ resourceId: 'supplies', amount: 1 }], yields: [{ resourceId: 'supplies', amount: 1 }],
}, },
@@ -283,6 +293,7 @@ describe('completion advances queue', () => {
{ {
id: 'combo', id: 'combo',
name: 'Combo', name: 'Combo',
group: DEFAULT_GROUP,
durationMs: 100, durationMs: 100,
yields: [ yields: [
{ resourceId: 'a', amount: 2 }, { resourceId: 'a', amount: 2 },
+1
View File
@@ -18,6 +18,7 @@ function testContent() {
{ {
id: 'forage', id: 'forage',
name: 'Forage', name: 'Forage',
group: { id: 'test', label: 'Test' },
durationMs: 3000, durationMs: 3000,
yields: [{ resourceId: 'gold', amount: 1 }], yields: [{ resourceId: 'gold', amount: 1 }],
}, },
+7
View File
@@ -13,6 +13,8 @@ import {
type StoryEvent, type StoryEvent,
} from '../story'; } from '../story';
const DEFAULT_GROUP = { id: 'test', label: 'Test' };
function gameContent() { function gameContent() {
const base = buildContent({ const base = buildContent({
resources: [ resources: [
@@ -23,6 +25,7 @@ function gameContent() {
{ {
id: 'scout_path', id: 'scout_path',
name: 'Scout', name: 'Scout',
group: DEFAULT_GROUP,
durationMs: 1000, durationMs: 1000,
costs: [], costs: [],
yields: [{ resourceId: 'coin', amount: 1 }], yields: [{ resourceId: 'coin', amount: 1 }],
@@ -54,6 +57,7 @@ function gameContentWithActionTrigger() {
{ {
id: 'scout_path', id: 'scout_path',
name: 'Scout', name: 'Scout',
group: DEFAULT_GROUP,
durationMs: 1000, durationMs: 1000,
costs: [], costs: [],
yields: [{ resourceId: 'coin', amount: 1 }], yields: [{ resourceId: 'coin', amount: 1 }],
@@ -91,6 +95,7 @@ function gameContentWithThreshold() {
{ {
id: 'scout_path', id: 'scout_path',
name: 'Scout', name: 'Scout',
group: DEFAULT_GROUP,
durationMs: 1000, durationMs: 1000,
costs: [], costs: [],
yields: [{ resourceId: 'coin', amount: 1 }], yields: [{ resourceId: 'coin', amount: 1 }],
@@ -132,6 +137,7 @@ function gameContentWithFork() {
{ {
id: 'scout_path', id: 'scout_path',
name: 'Scout', name: 'Scout',
group: DEFAULT_GROUP,
durationMs: 1000, durationMs: 1000,
costs: [], costs: [],
yields: [{ resourceId: 'coin', amount: 1 }], yields: [{ resourceId: 'coin', amount: 1 }],
@@ -189,6 +195,7 @@ function gameContentWithGatedChoice() {
{ {
id: 'scout_path', id: 'scout_path',
name: 'Scout', name: 'Scout',
group: DEFAULT_GROUP,
durationMs: 1000, durationMs: 1000,
costs: [], costs: [],
yields: [{ resourceId: 'coin', amount: 1 }], yields: [{ resourceId: 'coin', amount: 1 }],
+5 -2
View File
@@ -4,6 +4,8 @@ import { createGameState, enqueueAction, startAction } from '../../engine/game';
import { createSave, serializeSave } from '../../engine/save'; import { createSave, serializeSave } from '../../engine/save';
import { createMemoryBackend, loadGame, saveGame } from '../persistence'; import { createMemoryBackend, loadGame, saveGame } from '../persistence';
const DEFAULT_GROUP = { id: 'test', label: 'Test' };
function testContent() { function testContent() {
return buildContent({ return buildContent({
resources: [{ id: 'gold', name: 'Gold', startAmount: 0 }], resources: [{ id: 'gold', name: 'Gold', startAmount: 0 }],
@@ -11,6 +13,7 @@ function testContent() {
{ {
id: 'forage', id: 'forage',
name: 'Forage', name: 'Forage',
group: DEFAULT_GROUP,
durationMs: 3000, durationMs: 3000,
yields: [{ resourceId: 'gold', amount: 1 }], yields: [{ resourceId: 'gold', amount: 1 }],
}, },
@@ -22,8 +25,8 @@ function queueTestContent() {
return buildContent({ return buildContent({
resources: [{ id: 'gold', name: 'Gold', startAmount: 0 }], resources: [{ id: 'gold', name: 'Gold', startAmount: 0 }],
actions: [ actions: [
{ id: 'a', name: 'A', 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', durationMs: 3000, yields: [{ resourceId: 'gold', amount: 1 }] }, { id: 'b', name: 'B', group: DEFAULT_GROUP, durationMs: 3000, yields: [{ resourceId: 'gold', amount: 1 }] },
], ],
}); });
} }
+7 -2
View File
@@ -5,6 +5,8 @@ import { createGameState, enqueueAction, startAction } from '../../engine/game';
import { enterStoryNode } from '../../engine/story'; import { enterStoryNode } from '../../engine/story';
import { formatOfflineDuration, toView } from '../viewModel'; import { formatOfflineDuration, toView } from '../viewModel';
const DEFAULT_GROUP = { id: 'test', label: 'Test' };
function testContent() { function testContent() {
const base = buildContent({ const base = buildContent({
resources: [{ id: 'gold', name: 'Gold', startAmount: 4 }], resources: [{ id: 'gold', name: 'Gold', startAmount: 4 }],
@@ -12,6 +14,7 @@ function testContent() {
{ {
id: 'forage', id: 'forage',
name: 'Forage', name: 'Forage',
group: DEFAULT_GROUP,
durationMs: 200, durationMs: 200,
yields: [{ resourceId: 'gold', amount: 1 }], yields: [{ resourceId: 'gold', amount: 1 }],
}, },
@@ -87,8 +90,8 @@ describe('toView()', () => {
const content = contentWithActions( const content = contentWithActions(
[{ id: 'gold', name: 'Gold' }], [{ id: 'gold', name: 'Gold' }],
[ [
{ id: 'a', name: 'Alpha', 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', 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); const state = createGameState(content);
@@ -108,6 +111,7 @@ describe('toView() action availability', () => {
{ {
id: 'locked', id: 'locked',
name: 'Locked', name: 'Locked',
group: DEFAULT_GROUP,
durationMs: 1000, durationMs: 1000,
yields: [{ resourceId: 'coin', amount: 1 }], yields: [{ resourceId: 'coin', amount: 1 }],
unlock: { requireStoryFlags: ['route_a'] }, unlock: { requireStoryFlags: ['route_a'] },
@@ -127,6 +131,7 @@ describe('toView() action availability', () => {
{ {
id: 'forage', id: 'forage',
name: 'Forage', name: 'Forage',
group: DEFAULT_GROUP,
durationMs: 1000, durationMs: 1000,
yields: [{ resourceId: 'coin', amount: 1 }], yields: [{ resourceId: 'coin', amount: 1 }],
}, },