From c48e53cc7bd34d33ebf12d001d933bde80d98f58 Mon Sep 17 00:00:00 2001 From: ginnoir Date: Thu, 11 Jun 2026 20:12:21 -0500 Subject: [PATCH 01/26] feat(content): add action kind and group schema --- src/content/__tests__/schema.test.ts | 61 ++++++++++++++++++++++++++++ src/content/index.ts | 2 +- src/content/schema.ts | 59 +++++++++++++++++++++++---- 3 files changed, 113 insertions(+), 9 deletions(-) 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 { -- 2.54.0 From c1b38e36aacc47583a7c280cd18d739440920074 Mon Sep 17 00:00:00 2001 From: ginnoir Date: Thu, 11 Jun 2026 20:17:50 -0500 Subject: [PATCH 02/26] fix(content): guard optional durationMs and cover loop validation --- src/content/__tests__/schema.test.ts | 17 +++++++++++++++++ src/content/schema.ts | 2 +- src/engine/game.ts | 1 + src/state/viewModel.ts | 2 +- 4 files changed, 20 insertions(+), 2 deletions(-) diff --git a/src/content/__tests__/schema.test.ts b/src/content/__tests__/schema.test.ts index fe5b756..b15c946 100644 --- a/src/content/__tests__/schema.test.ts +++ b/src/content/__tests__/schema.test.ts @@ -208,4 +208,21 @@ describe('action kind schema', () => { }); expect(content.actionsById.rest.kind).toBe('loop'); }); + + it('rejects loop action without durationMs', () => { + expect(() => + buildContent({ + resources: [{ id: 'supplies', name: 'Supplies' }], + actions: [ + { + id: 'broken_loop', + name: 'Broken loop', + kind: 'loop', + group: { id: 'camp', label: 'Camp' }, + yields: [], + }, + ], + }), + ).toThrow(/durationMs/); + }); }); diff --git a/src/content/schema.ts b/src/content/schema.ts index d42ad9d..527d274 100644 --- a/src/content/schema.ts +++ b/src/content/schema.ts @@ -61,7 +61,7 @@ export const actionDefSchema = z path: ['durationMs'], }); } - if (action.kind === 'story' && !action.storyChoiceId) { + if (action.kind === 'story' && action.storyChoiceId === undefined) { ctx.addIssue({ code: 'custom', message: 'story actions require storyChoiceId', diff --git a/src/engine/game.ts b/src/engine/game.ts index b83b52e..4b7a040 100644 --- a/src/engine/game.ts +++ b/src/engine/game.ts @@ -169,6 +169,7 @@ export function tickGame(state: GameState, content: Content, tickMs: number): Ti const actionId = state.activeActionId; const action = content.actionsById[actionId]; if (!action) return { completedActionIds }; + if (action.durationMs === undefined) return { completedActionIds }; if (state.actionElapsedMs < action.durationMs) return { completedActionIds }; state.actionElapsedMs -= action.durationMs; diff --git a/src/state/viewModel.ts b/src/state/viewModel.ts index 64b9957..168df55 100644 --- a/src/state/viewModel.ts +++ b/src/state/viewModel.ts @@ -80,7 +80,7 @@ export function toView(state: GameState, content: GameContent): GameView { })); const action = state.activeActionId ? content.actionsById[state.activeActionId] : undefined; - const actionProgress = action ? Math.min(1, state.actionElapsedMs / action.durationMs) : 0; + const actionProgress = action && action.durationMs ? Math.min(1, state.actionElapsedMs / action.durationMs) : 0; const queuedActionIds = [...state.actionQueue]; const queuedActionNames = queuedActionIds.map((id) => content.actionsById[id]?.name ?? id); -- 2.54.0 From b54805637cb26f2f5765437521d4390db391c4ab Mon Sep 17 00:00:00 2001 From: ginnoir Date: Thu, 11 Jun 2026 20:23:49 -0500 Subject: [PATCH 03/26] 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 }], }, -- 2.54.0 From b3940774c440cc601bfedefa38a295920df0e18f Mon Sep 17 00:00:00 2001 From: ginnoir Date: Thu, 11 Jun 2026 20:29:28 -0500 Subject: [PATCH 04/26] feat(engine): add instant action execution Implements executeInstant, which applies an instant action's costs and yields immediately with no queue slot and no duration requirement. --- src/engine/__tests__/game.test.ts | 35 +++++++++++++++++++++++++++++++ src/engine/game.ts | 17 +++++++++++++++ 2 files changed, 52 insertions(+) diff --git a/src/engine/__tests__/game.test.ts b/src/engine/__tests__/game.test.ts index aee2c49..0a58bfe 100644 --- a/src/engine/__tests__/game.test.ts +++ b/src/engine/__tests__/game.test.ts @@ -6,6 +6,7 @@ import { clearQueue, createGameState, enqueueAction, + executeInstant, startAction, tickGame, } from '../game'; @@ -367,3 +368,37 @@ describe('tickGame()', () => { expect(state.activeActionId).toBeNull(); }); }); + +describe('executeInstant()', () => { + const instantContent = buildContent({ + resources: [ + { id: 'supplies', name: 'Supplies', startAmount: 0 }, + { id: 'coin', name: 'Coin', startAmount: 5 }, + ], + actions: [ + { + id: 'buy_supply', + name: 'Buy supply', + kind: 'instant', + group: { id: 'buy', label: 'Buy' }, + costs: [{ resourceId: 'coin', amount: 2 }], + yields: [{ resourceId: 'supplies', amount: 1 }], + }, + ], + }); + + it('applies costs and yields immediately without queueing', () => { + const state = createGameState(instantContent); + executeInstant(state, instantContent, 'buy_supply'); + expect(state.resources.coin).toBe(3); + expect(state.resources.supplies).toBe(1); + expect(state.activeActionId).toBeNull(); + expect(state.actionQueue).toEqual([]); + }); + + it('throws when unaffordable', () => { + const state = createGameState(instantContent); + state.resources.coin = 0; + expect(() => executeInstant(state, instantContent, 'buy_supply')).toThrow(/Cannot/); + }); +}); diff --git a/src/engine/game.ts b/src/engine/game.ts index 4b7a040..c7110a8 100644 --- a/src/engine/game.ts +++ b/src/engine/game.ts @@ -156,6 +156,23 @@ export function clearQueue(state: GameState): void { state.actionQueue.length = 0; } +/** + * Execute an instant action immediately, deducting costs and granting yields + * without occupying a queue slot or requiring a duration. + * Throws if the action is not of kind 'instant' or is unavailable. + */ +export function executeInstant(state: GameState, content: Content, actionId: string): void { + const action = content.actionsById[actionId]; + if (!action || action.kind !== 'instant') { + throw new Error(`Action "${actionId}" is not instant`); + } + if (!isActionAvailable(state, content, actionId)) { + throw new Error(`Cannot perform instant action "${actionId}"`); + } + deductCosts(state, content, actionId); + grantYields(state, content, actionId); +} + /** * Advance the active action by `tickMs`. On completion, grants yields and * advances the queue — actions do not auto-repeat when the queue is empty. -- 2.54.0 From 8d9484171f6f7d5e26086ccea69d7d41ae16ff19 Mon Sep 17 00:00:00 2001 From: ginnoir Date: Thu, 11 Jun 2026 20:36:07 -0500 Subject: [PATCH 05/26] feat(engine): execute story actions via storyChoiceId --- src/engine/__tests__/storyActions.test.ts | 22 +++++++++++++++++++++ src/engine/game.ts | 24 +++++++++++++++++++++++ src/engine/story.ts | 12 ++++++++++++ 3 files changed, 58 insertions(+) create mode 100644 src/engine/__tests__/storyActions.test.ts diff --git a/src/engine/__tests__/storyActions.test.ts b/src/engine/__tests__/storyActions.test.ts new file mode 100644 index 0000000..03b5ad2 --- /dev/null +++ b/src/engine/__tests__/storyActions.test.ts @@ -0,0 +1,22 @@ +import { describe, expect, it } from 'vitest'; +import { content } from '../../content/index'; +import { createGameState, executeStoryAction } from '../game'; +import { enterStoryNode } from '../story'; + +describe('executeStoryAction()', () => { + it('applies the linked story choice', () => { + const state = createGameState(content); + enterStoryNode(state, content, 'fork_choice'); + executeStoryAction(state, content, 'pick_high_road'); + expect(state.storyFlags.route_a).toBe(true); + expect(state.currentStoryNodeId).toBe('route_a_beat'); + }); + + it('throws when the choice is not available', () => { + const state = createGameState(content); + enterStoryNode(state, content, 'fork_choice'); + executeStoryAction(state, content, 'pick_high_road'); + // after taking route A, the current node has no choices, so follow_river (pick_b) is unavailable + expect(() => executeStoryAction(state, content, 'follow_river')).toThrow(); + }); +}); diff --git a/src/engine/game.ts b/src/engine/game.ts index c7110a8..0f6501b 100644 --- a/src/engine/game.ts +++ b/src/engine/game.ts @@ -1,4 +1,8 @@ import type { Content } from '../content/schema'; +import type { StoryContent } from '../content/storySchema'; +import { applyChoice, isStoryChoiceAvailable } from './story'; + +type GameContent = Content & StoryContent; /** * Core game state and per-tick simulation. @@ -173,6 +177,26 @@ export function executeInstant(state: GameState, content: Content, actionId: str grantYields(state, content, actionId); } +/** + * Execute a story action by resolving its storyChoiceId and applying it. + * Throws if the action is not of kind 'story', has no storyChoiceId, or the + * choice is not currently available. + */ +export function executeStoryAction( + state: GameState, + content: GameContent, + actionId: string, +): void { + const action = content.actionsById[actionId]; + if (!action || action.kind !== 'story' || !action.storyChoiceId) { + throw new Error(`Action "${actionId}" is not a story action`); + } + if (!isStoryChoiceAvailable(state, content, action.storyChoiceId)) { + throw new Error(`Story choice "${action.storyChoiceId}" is not available`); + } + applyChoice(state, content, action.storyChoiceId); +} + /** * Advance the active action by `tickMs`. On completion, grants yields and * advances the queue — actions do not auto-repeat when the queue is empty. diff --git a/src/engine/story.ts b/src/engine/story.ts index 2d6714c..ad5899f 100644 --- a/src/engine/story.ts +++ b/src/engine/story.ts @@ -165,6 +165,18 @@ export function getAvailableChoices(state: GameState, content: GameContent): Sto return node.choices.filter((c) => meetsChoiceRequirements(state, c.requirements)); } +export function isStoryChoiceAvailable( + state: GameState, + content: GameContent, + storyChoiceId: string, +): boolean { + const node = getCurrentNode(state, content); + if (!node?.choices) return false; + const choice = node.choices.find((c) => c.id === storyChoiceId); + if (!choice) return false; + return meetsChoiceRequirements(state, choice.requirements); +} + export function applyChoice( state: GameState, content: GameContent, -- 2.54.0 From 0de61d3e41b5869fe38b450be4b8c89262b69def Mon Sep 17 00:00:00 2001 From: ginnoir Date: Thu, 11 Jun 2026 20:44:20 -0500 Subject: [PATCH 06/26] feat(engine): loop idle runner with enabledLoopActionIds --- src/engine/__tests__/game.test.ts | 44 +++++++++++++++++++++++++ src/engine/__tests__/save.test.ts | 30 +++++++++++++++++ src/engine/game.ts | 23 +++++++++++++ src/engine/save.ts | 2 ++ src/state/__tests__/persistence.test.ts | 1 + src/state/persistence.ts | 1 + 6 files changed, 101 insertions(+) diff --git a/src/engine/__tests__/game.test.ts b/src/engine/__tests__/game.test.ts index 0a58bfe..2e0091d 100644 --- a/src/engine/__tests__/game.test.ts +++ b/src/engine/__tests__/game.test.ts @@ -7,6 +7,7 @@ import { createGameState, enqueueAction, executeInstant, + maybeStartLoopAction, startAction, tickGame, } from '../game'; @@ -402,3 +403,46 @@ describe('executeInstant()', () => { expect(() => executeInstant(state, instantContent, 'buy_supply')).toThrow(/Cannot/); }); }); + +describe('loop idle runner', () => { + const loopContent = buildContent({ + resources: [{ id: 'supplies', name: 'Supplies', startAmount: 0 }], + actions: [ + { + id: 'rest', + name: 'Rest', + kind: 'loop', + group: { id: 'camp_loop', label: 'Camp' }, + durationMs: 1000, + loopPriority: 0, + yields: [{ resourceId: 'supplies', amount: 1 }], + }, + ], + }); + + it('defaults enabledLoopActionIds to empty', () => { + const state = createGameState(loopContent); + expect(state.enabledLoopActionIds).toEqual({}); + }); + + it('starts enabled loop action when idle', () => { + const state = createGameState(loopContent); + state.enabledLoopActionIds = { rest: true }; + maybeStartLoopAction(state, loopContent); + expect(state.activeActionId).toBe('rest'); + }); + + it('does not start loop when queue has items', () => { + const state = createGameState(loopContent); + state.enabledLoopActionIds = { rest: true }; + state.actionQueue.push('rest'); + maybeStartLoopAction(state, loopContent); + expect(state.activeActionId).toBeNull(); + }); + + it('does not start a disabled loop action', () => { + const state = createGameState(loopContent); + maybeStartLoopAction(state, loopContent); + expect(state.activeActionId).toBeNull(); + }); +}); diff --git a/src/engine/__tests__/save.test.ts b/src/engine/__tests__/save.test.ts index 776f826..0685b75 100644 --- a/src/engine/__tests__/save.test.ts +++ b/src/engine/__tests__/save.test.ts @@ -70,6 +70,15 @@ describe('createSave()', () => { expect(save.state.currentStoryNodeId).toBe('route_a_beat'); expect(save.state.seenStoryNodeIds).toHaveLength(2); }); + + it('snapshots enabledLoopActionIds in the save payload and isolates from mutation', () => { + const state = sampleState(); + state.enabledLoopActionIds = { rest: true }; + const save = createSave(state, 1700); + expect(save.state.enabledLoopActionIds).toEqual({ rest: true }); + state.enabledLoopActionIds.rest = false; + expect(save.state.enabledLoopActionIds).toEqual({ rest: true }); + }); }); describe('serialize / deserialize round-trip', () => { @@ -84,6 +93,27 @@ describe('serialize / deserialize round-trip', () => { const restored = fromExportString(toExportString(save)); expect(restored).toEqual(save); }); + + it('preserves enabledLoopActionIds through a round-trip', () => { + const state = sampleState(); + state.enabledLoopActionIds = { rest: true, patrol: false }; + const restored = deserializeSave(serializeSave(createSave(state, 1700))); + expect(restored.state.enabledLoopActionIds).toEqual({ rest: true, patrol: false }); + }); + + it('defaults enabledLoopActionIds to {} when absent from save JSON', () => { + const json = JSON.stringify({ + version: 1, + savedAt: 1700, + state: { + resources: { gold: 0 }, + activeActionId: null, + actionElapsedMs: 0, + }, + }); + const restored = deserializeSave(json); + expect(restored.state.enabledLoopActionIds).toEqual({}); + }); }); describe('invalid / tampered saves', () => { diff --git a/src/engine/game.ts b/src/engine/game.ts index 0f6501b..04fccc3 100644 --- a/src/engine/game.ts +++ b/src/engine/game.ts @@ -26,6 +26,8 @@ export interface GameState { currentStoryNodeId: string; /** Story node ids the player has already seen. */ seenStoryNodeIds: string[]; + /** loop-kind action id -> whether the player has enabled it for idle running. */ + enabledLoopActionIds: Record; } export function createGameState(content: Content): GameState { @@ -41,6 +43,7 @@ export function createGameState(content: Content): GameState { storyFlags: {}, currentStoryNodeId: '', seenStoryNodeIds: [], + enabledLoopActionIds: {}, }; } @@ -197,6 +200,26 @@ export function executeStoryAction( applyChoice(state, content, action.storyChoiceId); } +/** + * Start the highest-priority enabled, available loop action when the game is idle. + * Invoked by the runtime AFTER each live tick — never from `tickGame`, so loop + * actions do not run during offline catch-up (which replays `tickGame` directly). + */ +export function maybeStartLoopAction(state: GameState, content: Content): void { + if (state.activeActionId !== null || state.actionQueue.length > 0) return; + + const candidates = content.actions + .filter((a) => a.kind === 'loop' && state.enabledLoopActionIds[a.id]) + .sort((a, b) => (a.loopPriority ?? 0) - (b.loopPriority ?? 0)); + + for (const action of candidates) { + if (isActionAvailable(state, content, action.id)) { + beginAction(state, content, action.id); + return; + } + } +} + /** * Advance the active action by `tickMs`. On completion, grants yields and * advances the queue — actions do not auto-repeat when the queue is empty. diff --git a/src/engine/save.ts b/src/engine/save.ts index f4c4cf5..6d125d0 100644 --- a/src/engine/save.ts +++ b/src/engine/save.ts @@ -32,6 +32,7 @@ export const gameStateSchema = z.object({ storyFlags: z.record(z.string(), z.boolean()).default({}), currentStoryNodeId: z.string().default(''), seenStoryNodeIds: z.array(z.string()).default([]), + enabledLoopActionIds: z.record(z.string(), z.boolean()).default({}), }); export const saveSchema = z.object({ @@ -55,6 +56,7 @@ export function createSave(state: GameState, now: number): SaveData { storyFlags: { ...state.storyFlags }, currentStoryNodeId: state.currentStoryNodeId, seenStoryNodeIds: [...state.seenStoryNodeIds], + enabledLoopActionIds: { ...state.enabledLoopActionIds }, }, }; } diff --git a/src/state/__tests__/persistence.test.ts b/src/state/__tests__/persistence.test.ts index b639082..7bfd198 100644 --- a/src/state/__tests__/persistence.test.ts +++ b/src/state/__tests__/persistence.test.ts @@ -98,6 +98,7 @@ describe('loadGame()', () => { storyFlags: {}, currentStoryNodeId: '', seenStoryNodeIds: [], + enabledLoopActionIds: {}, }, 1000, ), diff --git a/src/state/persistence.ts b/src/state/persistence.ts index 8455258..f036a32 100644 --- a/src/state/persistence.ts +++ b/src/state/persistence.ts @@ -106,6 +106,7 @@ export async function loadGame( storyFlags: { ...save.state.storyFlags }, currentStoryNodeId: save.state.currentStoryNodeId ?? '', seenStoryNodeIds: [...(save.state.seenStoryNodeIds ?? [])], + enabledLoopActionIds: { ...(save.state.enabledLoopActionIds ?? {}) }, }; savedAt = save.savedAt; } catch { -- 2.54.0 From 163f710f4c773cfe37120bff080e857595ebff60 Mon Sep 17 00:00:00 2001 From: ginnoir Date: Thu, 11 Jun 2026 20:53:55 -0500 Subject: [PATCH 07/26] feat(engine): add performAction dispatcher by kind --- src/engine/__tests__/game.test.ts | 93 +++++++++++++++++++++++++++++++ src/engine/game.ts | 44 +++++++++++++++ 2 files changed, 137 insertions(+) diff --git a/src/engine/__tests__/game.test.ts b/src/engine/__tests__/game.test.ts index 2e0091d..71a4b51 100644 --- a/src/engine/__tests__/game.test.ts +++ b/src/engine/__tests__/game.test.ts @@ -8,9 +8,13 @@ import { enqueueAction, executeInstant, maybeStartLoopAction, + performAction, startAction, tickGame, } from '../game'; +import { content as gameContent } from '../../content/index'; +import { buildStoryContent } from '../../content/storySchema'; +import { enterStoryNode } from '../story'; const DEFAULT_GROUP = { id: 'test', label: 'Test' }; @@ -446,3 +450,92 @@ describe('loop idle runner', () => { expect(state.activeActionId).toBeNull(); }); }); + +describe('performAction()', () => { + it('dispatches timed actions through enqueueAction', () => { + const state = createGameState(gameContent); + performAction(state, gameContent, 'gather_supplies'); + expect(state.activeActionId).toBe('gather_supplies'); + }); + + it('toggles loop actions and starts them when idle', () => { + const state = createGameState(gameContent); + performAction(state, gameContent, 'rest'); // enable + expect(state.enabledLoopActionIds.rest).toBe(true); + expect(state.activeActionId).toBe('rest'); // started because idle + available + performAction(state, gameContent, 'rest'); // disable + expect(state.enabledLoopActionIds.rest).toBe(false); + }); + + it('dispatches story actions through executeStoryAction', () => { + const state = createGameState(gameContent); + enterStoryNode(state, gameContent, 'fork_choice'); + performAction(state, gameContent, 'pick_high_road'); + expect(state.storyFlags.route_a).toBe(true); + }); + + it('executes instant actions immediately', () => { + const base = buildContent({ + resources: [ + { id: 'supplies', name: 'Supplies', startAmount: 0 }, + { id: 'coin', name: 'Coin', startAmount: 5 }, + ], + actions: [ + { + id: 'buy_supply', + name: 'Buy supply', + kind: 'instant', + group: { id: 'buy', label: 'Buy' }, + costs: [{ resourceId: 'coin', amount: 2 }], + yields: [{ resourceId: 'supplies', amount: 1 }], + }, + { + id: 'enter_cave', + name: 'Enter cave', + kind: 'context', + group: { id: 'travel', label: 'Travel' }, + contextId: 'cave', + }, + ], + }); + const story = buildStoryContent( + [{ id: 'boot', prose: 'x', triggers: [{ type: 'boot', targetNodeId: 'boot' }] }], + base.actionsById, + base.resourcesById, + ); + const fixture = { ...base, ...story }; + const state = createGameState(fixture); + performAction(state, fixture, 'buy_supply'); + expect(state.resources.coin).toBe(3); + expect(state.resources.supplies).toBe(1); + expect(state.activeActionId).toBeNull(); + }); + + it('throws for context actions (not implemented in M1)', () => { + const base = buildContent({ + resources: [{ id: 'supplies', name: 'Supplies', startAmount: 0 }], + actions: [ + { + id: 'enter_cave', + name: 'Enter cave', + kind: 'context', + group: { id: 'travel', label: 'Travel' }, + contextId: 'cave', + }, + ], + }); + const story = buildStoryContent( + [{ id: 'boot', prose: 'x', triggers: [{ type: 'boot', targetNodeId: 'boot' }] }], + base.actionsById, + base.resourcesById, + ); + const fixture = { ...base, ...story }; + const state = createGameState(fixture); + expect(() => performAction(state, fixture, 'enter_cave')).toThrow(/not implemented/i); + }); + + it('throws for unknown actions', () => { + const state = createGameState(gameContent); + expect(() => performAction(state, gameContent, 'nope')).toThrow(/[Uu]nknown/); + }); +}); diff --git a/src/engine/game.ts b/src/engine/game.ts index 04fccc3..9b56bdb 100644 --- a/src/engine/game.ts +++ b/src/engine/game.ts @@ -220,6 +220,50 @@ export function maybeStartLoopAction(state: GameState, content: Content): void { } } +/** + * Single entry point for all player-initiated action dispatch. + * + * Dispatches by action.kind to the appropriate per-kind function: + * - instant: execute immediately (costs/yields, no queue slot) + * - timed: enqueue (start if idle, queue otherwise) + * - loop: toggle player enable preference; start runner if just enabled + * - story: resolve storyChoiceId and apply the choice + * - context: not implemented in M1 — throws + * + * Note on loop toggle: disabling is always allowed, even when the action is + * currently unaffordable. Affordability is the runner's concern (maybeStartLoopAction + * re-checks each tick). Throwing on unaffordable before toggling would wrongly + * block the player from DISABLING an active but now-unaffordable loop. + */ +export function performAction(state: GameState, content: GameContent, actionId: string): void { + const action = content.actionsById[actionId]; + if (!action) throw new Error(`Unknown action "${actionId}"`); + + switch (action.kind) { + case 'instant': + executeInstant(state, content, actionId); + break; + case 'timed': + enqueueAction(state, content, actionId); + break; + case 'loop': { + const willEnable = !state.enabledLoopActionIds[actionId]; + state.enabledLoopActionIds[actionId] = willEnable; + if (willEnable) { + maybeStartLoopAction(state, content); + } + break; + } + case 'story': + executeStoryAction(state, content, actionId); + break; + case 'context': + throw new Error(`Context action "${actionId}" is not implemented`); + default: + throw new Error(`Unknown action kind "${(action as { kind: string }).kind}"`); + } +} + /** * Advance the active action by `tickMs`. On completion, grants yields and * advances the queue — actions do not auto-repeat when the queue is empty. -- 2.54.0 From 73d836fe96c22d69d9a3dfa69f742e82029e9d95 Mon Sep 17 00:00:00 2001 From: ginnoir Date: Thu, 11 Jun 2026 21:01:42 -0500 Subject: [PATCH 08/26] feat(state): project actions into columns and story tree --- src/state/__tests__/viewModel.test.ts | 72 +++++++++++++- src/state/store.ts | 3 +- src/state/viewModel.ts | 135 +++++++++++++++++++++++--- 3 files changed, 197 insertions(+), 13 deletions(-) diff --git a/src/state/__tests__/viewModel.test.ts b/src/state/__tests__/viewModel.test.ts index 3954e72..d08cd44 100644 --- a/src/state/__tests__/viewModel.test.ts +++ b/src/state/__tests__/viewModel.test.ts @@ -1,7 +1,8 @@ import { describe, expect, it } from 'vitest'; import { buildContent } from '../../content/schema'; import { buildStoryContent } from '../../content/storySchema'; -import { createGameState, enqueueAction, startAction } from '../../engine/game'; +import { content } from '../../content/index'; +import { createGameState, enqueueAction, performAction, startAction } from '../../engine/game'; import { enterStoryNode } from '../../engine/story'; import { formatOfflineDuration, toView } from '../viewModel'; @@ -193,3 +194,72 @@ describe('formatOfflineDuration()', () => { expect(formatOfflineDuration(7_200_000)).toBe('2h'); }); }); + +describe('action columns projection', () => { + it('produces all five kind columns in fixed order', () => { + const state = createGameState(content); + const view = toView(state, content); + expect(view.actionColumns.map((c) => c.kind)).toEqual([ + 'instant', 'loop', 'timed', 'story', 'context', + ]); + }); + + it('groups timed actions by their content group', () => { + const state = createGameState(content); + const view = toView(state, content); + const timed = view.actionColumns.find((c) => c.kind === 'timed'); + expect(timed?.groups.some((g) => g.id === 'camp')).toBe(true); + expect(timed?.groups.some((g) => g.id === 'travel')).toBe(true); + }); + + it('marks loopEnabled from enabledLoopActionIds', () => { + const state = createGameState(content); + state.enabledLoopActionIds = { rest: true }; + const view = toView(state, content); + const rest = view.actionColumns + .flatMap((c) => c.groups) + .flatMap((g) => g.actions) + .find((a) => a.id === 'rest'); + expect(rest?.loopEnabled).toBe(true); + }); + + it('shows story actions only when their choice is available, hiding siblings after a fork is taken', () => { + const state = createGameState(content); + // before reaching the fork, story actions are hidden + let storyCol = toView(state, content).actionColumns.find((c) => c.kind === 'story'); + expect(storyCol?.groups.flatMap((g) => g.actions)).toHaveLength(0); + // at the fork, both story actions appear + enterStoryNode(state, content, 'fork_choice'); + storyCol = toView(state, content).actionColumns.find((c) => c.kind === 'story'); + const idsAtFork = storyCol?.groups.flatMap((g) => g.actions).map((a) => a.id) ?? []; + expect(idsAtFork).toEqual(expect.arrayContaining(['pick_high_road', 'follow_river'])); + // after taking route A, the sibling hides + performAction(state, content, 'pick_high_road'); + storyCol = toView(state, content).actionColumns.find((c) => c.kind === 'story'); + expect(storyCol?.groups.flatMap((g) => g.actions)).toHaveLength(0); + }); +}); + +describe('story tree projection', () => { + it('builds a tree marking seen and active nodes', () => { + const state = createGameState(content); + enterStoryNode(state, content, 'fork_choice'); + const view = toView(state, content); + // fork_choice should be a node in the tree, marked active+seen, with route children + const findNode = (nodes: typeof view.story.tree, id: string): (typeof nodes)[number] | undefined => { + for (const n of nodes) { + if (n.id === id) return n; + const deeper = findNode(n.children, id); + if (deeper) return deeper; + } + return undefined; + }; + const fork = findNode(view.story.tree, 'fork_choice'); + expect(fork).toBeDefined(); + expect(fork?.active).toBe(true); + expect(fork?.seen).toBe(true); + expect(fork?.children.map((c) => c.id)).toEqual( + expect.arrayContaining(['route_a_beat', 'route_b_beat']), + ); + }); +}); diff --git a/src/state/store.ts b/src/state/store.ts index b498971..4a40a14 100644 --- a/src/state/store.ts +++ b/src/state/store.ts @@ -40,7 +40,8 @@ export const useGameStore = create((set) => ({ queuedActionIds: [], queuedActionNames: [], actions: [], - story: { currentProse: null, choices: [] }, + story: { currentProse: null, choices: [], tree: [] }, + actionColumns: [], log: [], storyPanelOpen: false, storyHasUnread: false, diff --git a/src/state/viewModel.ts b/src/state/viewModel.ts index 168df55..85d0114 100644 --- a/src/state/viewModel.ts +++ b/src/state/viewModel.ts @@ -5,7 +5,7 @@ import { type GameState, isActionAvailable, } from '../engine/game'; -import { getAvailableChoices, getCurrentNode } from '../engine/story'; +import { getAvailableChoices, getCurrentNode, isStoryChoiceAvailable } from '../engine/story'; /** * Pure mapping from engine state to the view model the React shell renders. @@ -18,6 +18,17 @@ export interface ResourceView { amount: number; } +export const ACTION_COLUMN_ORDER = ['instant', 'loop', 'timed', 'story', 'context'] as const; +export type ActionColumnKind = (typeof ACTION_COLUMN_ORDER)[number]; + +const COLUMN_LABELS: Record = { + instant: 'Instant', + loop: 'Loop', + timed: 'Timed', + story: 'Story', + context: 'Context', +}; + export interface ActionView { id: string; name: string; @@ -27,6 +38,20 @@ export interface ActionView { storyTooltip?: string; costsSummary: string | null; yieldsSummary: string | null; + kind: ActionColumnKind; + loopEnabled: boolean; +} + +export interface ActionGroupView { + id: string; + label: string; + actions: ActionView[]; +} + +export interface ActionColumnView { + kind: ActionColumnKind; + label: string; + groups: ActionGroupView[]; } export interface StoryChoiceView { @@ -36,9 +61,18 @@ export interface StoryChoiceView { disabledReason: string | null; } +export interface StoryTreeNodeView { + id: string; + label: string; + seen: boolean; + active: boolean; + children: StoryTreeNodeView[]; +} + export interface StoryView { currentProse: string | null; choices: StoryChoiceView[]; + tree: StoryTreeNodeView[]; } export interface GameView { @@ -51,6 +85,7 @@ export interface GameView { queuedActionNames: string[]; actions: ActionView[]; story: StoryView; + actionColumns: ActionColumnView[]; } function actionDisabledReason( @@ -72,6 +107,38 @@ function formatResourceList( .join(', '); } +function buildStoryTree(state: GameState, content: GameContent): StoryTreeNodeView[] { + // Edges come from choice targets and trigger targets, skipping self-edges. + const childIds = new Set(); + const childrenOf = new Map(); + for (const node of content.storyNodes) { + const targets: string[] = []; + for (const choice of node.choices ?? []) { + if (choice.targetNodeId !== node.id) targets.push(choice.targetNodeId); + } + for (const trigger of node.triggers ?? []) { + if (trigger.targetNodeId !== node.id) targets.push(trigger.targetNodeId); + } + childrenOf.set(node.id, targets); + for (const t of targets) childIds.add(t); + } + const build = (id: string, seenOnPath: Set): StoryTreeNodeView => { + const children = seenOnPath.has(id) + ? [] + : (childrenOf.get(id) ?? []).map((c) => build(c, new Set(seenOnPath).add(id))); + return { + id, + label: id, // minimal label per spec (T3.0 ships a minimal tree) + seen: state.seenStoryNodeIds.includes(id), + active: state.currentStoryNodeId === id, + children, + }; + }; + return content.storyNodes + .filter((n) => !childIds.has(n.id)) + .map((n) => build(n.id, new Set())); +} + export function toView(state: GameState, content: GameContent): GameView { const resources: ResourceView[] = content.resources.map((resource) => ({ id: resource.id, @@ -84,16 +151,60 @@ export function toView(state: GameState, content: GameContent): GameView { const queuedActionIds = [...state.actionQueue]; const queuedActionNames = queuedActionIds.map((id) => content.actionsById[id]?.name ?? id); - const actions: ActionView[] = content.actions.map((a) => ({ - id: a.id, - name: a.name, - available: isActionAvailable(state, content, a.id), - disabledReason: actionDisabledReason(state, content, a.id), - storyHint: a.storyHint, - storyTooltip: a.storyTooltip, - costsSummary: a.costs.length ? formatResourceList(a.costs, content) : null, - yieldsSummary: formatResourceList(a.yields, content), - })); + // Build a map of ActionView by id for column assembly + const actionViewMap = new Map(); + const actions: ActionView[] = content.actions.map((a) => { + const isStory = a.kind === 'story'; + const available = isStory + ? isStoryChoiceAvailable(state, content, a.storyChoiceId ?? '') + : isActionAvailable(state, content, a.id); + const view: ActionView = { + id: a.id, + name: a.name, + available, + disabledReason: isStory ? null : actionDisabledReason(state, content, a.id), + storyHint: a.storyHint, + storyTooltip: a.storyTooltip, + costsSummary: a.costs.length ? formatResourceList(a.costs, content) : null, + yieldsSummary: formatResourceList(a.yields, content), + kind: a.kind, + loopEnabled: !!state.enabledLoopActionIds[a.id], + }; + actionViewMap.set(a.id, view); + return view; + }); + + // Build action columns: one per kind in fixed order + const actionColumns: ActionColumnView[] = ACTION_COLUMN_ORDER.map((kind) => { + // Collect actions of this kind + const kindActions = content.actions.filter((a) => a.kind === kind); + + // For story kind: only include available actions (hides siblings after fork) + const includedActions = kind === 'story' + ? kindActions.filter((a) => actionViewMap.get(a.id)?.available === true) + : kindActions; + + // Group by action.group, preserving first-seen order + const groupOrder: string[] = []; + const groupMap = new Map(); + for (const a of includedActions) { + const view = actionViewMap.get(a.id); + if (!view) continue; + if (!groupMap.has(a.group.id)) { + groupOrder.push(a.group.id); + groupMap.set(a.group.id, { id: a.group.id, label: a.group.label, actions: [] }); + } + groupMap.get(a.group.id)!.actions.push(view); + } + + const groups: ActionGroupView[] = groupOrder.map((gid) => groupMap.get(gid)!); + + return { + kind, + label: COLUMN_LABELS[kind], + groups, + }; + }); const node = getCurrentNode(state, content); const availableChoices = getAvailableChoices(state, content); @@ -110,6 +221,7 @@ export function toView(state: GameState, content: GameContent): GameView { disabledReason: available ? null : 'Requirements not met', }; }), + tree: buildStoryTree(state, content), }; return { @@ -121,6 +233,7 @@ export function toView(state: GameState, content: GameContent): GameView { queuedActionNames, actions, story, + actionColumns, }; } -- 2.54.0 From 9b2793b264dcedac278644371d87ed98848e1fa8 Mon Sep 17 00:00:00 2001 From: ginnoir Date: Thu, 11 Jun 2026 21:10:07 -0500 Subject: [PATCH 09/26] feat(state): nav panel store and expanded prefs --- src/state/__tests__/prefs.test.ts | 15 ++++++++- src/state/__tests__/store.test.ts | 51 +++++++++++++++++++++++++++++++ src/state/prefs.ts | 6 +++- src/state/store.ts | 20 ++++++++++++ 4 files changed, 90 insertions(+), 2 deletions(-) create mode 100644 src/state/__tests__/store.test.ts diff --git a/src/state/__tests__/prefs.test.ts b/src/state/__tests__/prefs.test.ts index 56d2a17..8d5727a 100644 --- a/src/state/__tests__/prefs.test.ts +++ b/src/state/__tests__/prefs.test.ts @@ -15,7 +15,12 @@ describe('prefs', () => { }); it('returns defaults when localStorage empty', () => { - expect(getPrefs()).toEqual({ storyOpenMode: 'auto', actionDetailMode: 'inline' }); + expect(getPrefs()).toEqual({ + storyOpenMode: 'auto', + actionDetailMode: 'inline', + collapsedActionGroups: {}, + showEventLog: true, + }); }); it('round-trips updated prefs', () => { @@ -23,3 +28,11 @@ describe('prefs', () => { expect(getPrefs().storyOpenMode).toBe('manual'); }); }); + +describe('expanded prefs', () => { + it('defaults collapsedActionGroups and showEventLog', () => { + const prefs = getPrefs(); + expect(prefs.collapsedActionGroups).toEqual({}); + expect(prefs.showEventLog).toBe(true); + }); +}); diff --git a/src/state/__tests__/store.test.ts b/src/state/__tests__/store.test.ts new file mode 100644 index 0000000..026e521 --- /dev/null +++ b/src/state/__tests__/store.test.ts @@ -0,0 +1,51 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { useGameStore } from '../store'; + +describe('game store nav and prefs', () => { + beforeEach(() => { + const store: Record = {}; + vi.stubGlobal('localStorage', { + getItem(key: string) { + return store[key] ?? null; + }, + setItem(key: string, value: string) { + store[key] = value; + }, + }); + }); + + it('has default activePanel and selectedStoryNodeId', () => { + const state = useGameStore.getState(); + expect(state.activePanel).toBe('play'); + expect(state.selectedStoryNodeId).toBeNull(); + }); + + it('updates activePanel via setActivePanel', () => { + const state = useGameStore.getState(); + state.setActivePanel('story'); + expect(useGameStore.getState().activePanel).toBe('story'); + + // Reset back + useGameStore.getState().setActivePanel('play'); + }); + + it('updates selectedStoryNodeId via setSelectedStoryNodeId', () => { + const state = useGameStore.getState(); + state.setSelectedStoryNodeId('node-1'); + expect(useGameStore.getState().selectedStoryNodeId).toBe('node-1'); + + state.setSelectedStoryNodeId(null); + expect(useGameStore.getState().selectedStoryNodeId).toBeNull(); + }); + + it('toggles collapsed action groups in prefs', () => { + const state = useGameStore.getState(); + expect(state.prefs.collapsedActionGroups['skills:gather']).toBeUndefined(); + + state.toggleActionGroupCollapsed('skills:gather'); + expect(useGameStore.getState().prefs.collapsedActionGroups['skills:gather']).toBe(true); + + useGameStore.getState().toggleActionGroupCollapsed('skills:gather'); + expect(useGameStore.getState().prefs.collapsedActionGroups['skills:gather']).toBe(false); + }); +}); diff --git a/src/state/prefs.ts b/src/state/prefs.ts index 1055b71..feca74e 100644 --- a/src/state/prefs.ts +++ b/src/state/prefs.ts @@ -1,4 +1,4 @@ -const PREFS_KEY = 'idlegame:prefs:v1'; +const PREFS_KEY = 'idlegame:prefs:v2'; export type StoryOpenMode = 'auto' | 'choices-only' | 'manual'; export type ActionDetailMode = 'inline' | 'hover' | 'info-button'; @@ -6,11 +6,15 @@ export type ActionDetailMode = 'inline' | 'hover' | 'info-button'; export interface GamePrefs { storyOpenMode: StoryOpenMode; actionDetailMode: ActionDetailMode; + collapsedActionGroups: Record; + showEventLog: boolean; } const DEFAULTS: GamePrefs = { storyOpenMode: 'auto', actionDetailMode: 'inline', + collapsedActionGroups: {}, + showEventLog: true, }; export function getPrefs(): GamePrefs { diff --git a/src/state/store.ts b/src/state/store.ts index 4a40a14..5e161b9 100644 --- a/src/state/store.ts +++ b/src/state/store.ts @@ -16,6 +16,8 @@ export interface StoryLogEntry { choiceLabel?: string; } +export type ActivePanel = 'play' | 'story' | 'settings' | 'about'; + export interface GameStoreState extends GameView { log: string[]; storyPanelOpen: boolean; @@ -23,6 +25,8 @@ export interface GameStoreState extends GameView { storyLog: StoryLogEntry[]; prefs: GamePrefs; settingsOpen: boolean; + activePanel: ActivePanel; + selectedStoryNodeId: string | null; setView: (view: GameView) => void; appendLog: (line: string) => void; appendStoryLog: (entry: StoryLogEntry) => void; @@ -30,6 +34,9 @@ export interface GameStoreState extends GameView { setStoryHasUnread: (unread: boolean) => void; setPrefs: (partial: Partial) => void; setSettingsOpen: (open: boolean) => void; + setActivePanel: (panel: ActivePanel) => void; + setSelectedStoryNodeId: (id: string | null) => void; + toggleActionGroupCollapsed: (groupKey: string) => void; } export const useGameStore = create((set) => ({ @@ -48,6 +55,8 @@ export const useGameStore = create((set) => ({ storyLog: [], prefs: getPrefs(), settingsOpen: false, + activePanel: 'play', + selectedStoryNodeId: null, setView: (view) => set((state) => ({ ...state, ...view })), appendLog: (line) => set((state) => ({ log: [...state.log, line].slice(-MAX_LOG_LINES) })), appendStoryLog: (entry) => set((state) => ({ storyLog: [...state.storyLog, entry] })), @@ -58,4 +67,15 @@ export const useGameStore = create((set) => ({ set({ prefs }); }, setSettingsOpen: (open) => set({ settingsOpen: open }), + setActivePanel: (panel) => set({ activePanel: panel }), + setSelectedStoryNodeId: (id) => set({ selectedStoryNodeId: id }), + toggleActionGroupCollapsed: (groupKey) => + set((state) => { + const nextCollapsed = { + ...state.prefs.collapsedActionGroups, + [groupKey]: !state.prefs.collapsedActionGroups[groupKey], + }; + const prefs = persistPrefs({ collapsedActionGroups: nextCollapsed }); + return { prefs }; + }), })); -- 2.54.0 From 09e2d06b877992ff7dab2ecdb2084a4463661b16 Mon Sep 17 00:00:00 2001 From: ginnoir Date: Thu, 11 Jun 2026 21:12:20 -0500 Subject: [PATCH 10/26] refactor(state): fix code quality findings for nav panel store and prefs --- src/state/__tests__/prefs.test.ts | 42 ++++++++++++++++++++++++++++++- src/state/__tests__/store.test.ts | 12 ++++++++- src/state/prefs.ts | 15 ++++++++++- src/state/store.ts | 18 ++++++------- 4 files changed, 75 insertions(+), 12 deletions(-) diff --git a/src/state/__tests__/prefs.test.ts b/src/state/__tests__/prefs.test.ts index 8d5727a..fd26684 100644 --- a/src/state/__tests__/prefs.test.ts +++ b/src/state/__tests__/prefs.test.ts @@ -1,4 +1,4 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { getPrefs, setPrefs } from '../prefs'; describe('prefs', () => { @@ -14,6 +14,10 @@ describe('prefs', () => { }); }); + afterEach(() => { + vi.unstubAllGlobals(); + }); + it('returns defaults when localStorage empty', () => { expect(getPrefs()).toEqual({ storyOpenMode: 'auto', @@ -27,6 +31,42 @@ describe('prefs', () => { setPrefs({ storyOpenMode: 'manual', actionDetailMode: 'hover' }); expect(getPrefs().storyOpenMode).toBe('manual'); }); + + it('migrates v1 prefs to v2 if v2 does not exist', () => { + const v1Prefs = { + storyOpenMode: 'manual', + actionDetailMode: 'hover', + collapsedActionGroups: { 'some-group': true }, + }; + localStorage.setItem('idlegame:prefs:v1', JSON.stringify(v1Prefs)); + + const migratedPrefs = getPrefs(); + + expect(migratedPrefs).toEqual({ + storyOpenMode: 'manual', + actionDetailMode: 'hover', + collapsedActionGroups: { 'some-group': true }, + showEventLog: true, + }); + + const rawV2 = localStorage.getItem('idlegame:prefs:v2'); + expect(rawV2).not.toBeNull(); + expect(JSON.parse(rawV2 ?? 'null')).toEqual(migratedPrefs); + }); + + it('returns defaults and does not migrate if v1 prefs is invalid JSON', () => { + localStorage.setItem('idlegame:prefs:v1', '{invalid-json}'); + + const prefs = getPrefs(); + expect(prefs).toEqual({ + storyOpenMode: 'auto', + actionDetailMode: 'inline', + collapsedActionGroups: {}, + showEventLog: true, + }); + + expect(localStorage.getItem('idlegame:prefs:v2')).toBeNull(); + }); }); describe('expanded prefs', () => { diff --git a/src/state/__tests__/store.test.ts b/src/state/__tests__/store.test.ts index 026e521..9a52eca 100644 --- a/src/state/__tests__/store.test.ts +++ b/src/state/__tests__/store.test.ts @@ -1,4 +1,5 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { getPrefs } from '../prefs'; import { useGameStore } from '../store'; describe('game store nav and prefs', () => { @@ -12,6 +13,15 @@ describe('game store nav and prefs', () => { store[key] = value; }, }); + useGameStore.setState({ + activePanel: 'play', + selectedStoryNodeId: null, + prefs: getPrefs(), + }); + }); + + afterEach(() => { + vi.unstubAllGlobals(); }); it('has default activePanel and selectedStoryNodeId', () => { diff --git a/src/state/prefs.ts b/src/state/prefs.ts index feca74e..a46fd63 100644 --- a/src/state/prefs.ts +++ b/src/state/prefs.ts @@ -21,7 +21,20 @@ export function getPrefs(): GamePrefs { if (typeof localStorage === 'undefined') return { ...DEFAULTS }; try { const raw = localStorage.getItem(PREFS_KEY); - if (!raw) return { ...DEFAULTS }; + if (!raw) { + const rawV1 = localStorage.getItem('idlegame:prefs:v1'); + if (rawV1) { + try { + const parsedV1 = JSON.parse(rawV1); + const migrated = { ...DEFAULTS, ...parsedV1 }; + localStorage.setItem(PREFS_KEY, JSON.stringify(migrated)); + return migrated; + } catch { + return { ...DEFAULTS }; + } + } + return { ...DEFAULTS }; + } return { ...DEFAULTS, ...JSON.parse(raw) }; } catch { return { ...DEFAULTS }; diff --git a/src/state/store.ts b/src/state/store.ts index 5e161b9..96c155a 100644 --- a/src/state/store.ts +++ b/src/state/store.ts @@ -69,13 +69,13 @@ export const useGameStore = create((set) => ({ setSettingsOpen: (open) => set({ settingsOpen: open }), setActivePanel: (panel) => set({ activePanel: panel }), setSelectedStoryNodeId: (id) => set({ selectedStoryNodeId: id }), - toggleActionGroupCollapsed: (groupKey) => - set((state) => { - const nextCollapsed = { - ...state.prefs.collapsedActionGroups, - [groupKey]: !state.prefs.collapsedActionGroups[groupKey], - }; - const prefs = persistPrefs({ collapsedActionGroups: nextCollapsed }); - return { prefs }; - }), + toggleActionGroupCollapsed: (groupKey) => { + const currentPrefs = useGameStore.getState().prefs; + const nextCollapsed = { + ...currentPrefs.collapsedActionGroups, + [groupKey]: !currentPrefs.collapsedActionGroups[groupKey], + }; + const prefs = persistPrefs({ collapsedActionGroups: nextCollapsed }); + set({ prefs }); + }, })); -- 2.54.0 From adf730702d26cce63ffad864ddf68d0fe4e08967 Mon Sep 17 00:00:00 2001 From: ginnoir Date: Thu, 11 Jun 2026 21:14:04 -0500 Subject: [PATCH 11/26] refactor(state): address review recommendations for store and prefs --- src/state/__tests__/prefs.test.ts | 12 ++++++------ src/state/__tests__/store.test.ts | 6 ------ src/state/store.ts | 4 ++-- 3 files changed, 8 insertions(+), 14 deletions(-) diff --git a/src/state/__tests__/prefs.test.ts b/src/state/__tests__/prefs.test.ts index fd26684..7771d94 100644 --- a/src/state/__tests__/prefs.test.ts +++ b/src/state/__tests__/prefs.test.ts @@ -67,12 +67,12 @@ describe('prefs', () => { expect(localStorage.getItem('idlegame:prefs:v2')).toBeNull(); }); -}); -describe('expanded prefs', () => { - it('defaults collapsedActionGroups and showEventLog', () => { - const prefs = getPrefs(); - expect(prefs.collapsedActionGroups).toEqual({}); - expect(prefs.showEventLog).toBe(true); + describe('expanded prefs', () => { + it('defaults collapsedActionGroups and showEventLog', () => { + const prefs = getPrefs(); + expect(prefs.collapsedActionGroups).toEqual({}); + expect(prefs.showEventLog).toBe(true); + }); }); }); diff --git a/src/state/__tests__/store.test.ts b/src/state/__tests__/store.test.ts index 9a52eca..cca2040 100644 --- a/src/state/__tests__/store.test.ts +++ b/src/state/__tests__/store.test.ts @@ -34,18 +34,12 @@ describe('game store nav and prefs', () => { const state = useGameStore.getState(); state.setActivePanel('story'); expect(useGameStore.getState().activePanel).toBe('story'); - - // Reset back - useGameStore.getState().setActivePanel('play'); }); it('updates selectedStoryNodeId via setSelectedStoryNodeId', () => { const state = useGameStore.getState(); state.setSelectedStoryNodeId('node-1'); expect(useGameStore.getState().selectedStoryNodeId).toBe('node-1'); - - state.setSelectedStoryNodeId(null); - expect(useGameStore.getState().selectedStoryNodeId).toBeNull(); }); it('toggles collapsed action groups in prefs', () => { diff --git a/src/state/store.ts b/src/state/store.ts index 96c155a..297d2c0 100644 --- a/src/state/store.ts +++ b/src/state/store.ts @@ -39,7 +39,7 @@ export interface GameStoreState extends GameView { toggleActionGroupCollapsed: (groupKey: string) => void; } -export const useGameStore = create((set) => ({ +export const useGameStore = create((set, get) => ({ resources: [], activeActionId: null, actionName: null, @@ -70,7 +70,7 @@ export const useGameStore = create((set) => ({ setActivePanel: (panel) => set({ activePanel: panel }), setSelectedStoryNodeId: (id) => set({ selectedStoryNodeId: id }), toggleActionGroupCollapsed: (groupKey) => { - const currentPrefs = useGameStore.getState().prefs; + const currentPrefs = get().prefs; const nextCollapsed = { ...currentPrefs.collapsedActionGroups, [groupKey]: !currentPrefs.collapsedActionGroups[groupKey], -- 2.54.0 From 239b2506ecb93e92cef9a551eec195b8cf8be935 Mon Sep 17 00:00:00 2001 From: ginnoir Date: Thu, 11 Jun 2026 21:16:27 -0500 Subject: [PATCH 12/26] feat(state): runtime performAction and nav story signals --- src/engine/__tests__/game.test.ts | 47 ++++++++--- src/engine/game.ts | 25 +++--- src/state/__tests__/runtime.test.ts | 121 +++++++++++++++++++++++++++ src/state/runtime.ts | 125 +++++++++++++++++++--------- src/state/storyOrchestration.ts | 4 +- 5 files changed, 257 insertions(+), 65 deletions(-) create mode 100644 src/state/__tests__/runtime.test.ts diff --git a/src/engine/__tests__/game.test.ts b/src/engine/__tests__/game.test.ts index 71a4b51..e4f7e77 100644 --- a/src/engine/__tests__/game.test.ts +++ b/src/engine/__tests__/game.test.ts @@ -1,5 +1,7 @@ import { describe, expect, it } from 'vitest'; +import { content as gameContent } from '../../content/index'; import { buildContent } from '../../content/schema'; +import { buildStoryContent } from '../../content/storySchema'; import { cancelQueuedAction, canUnlockAction, @@ -12,8 +14,6 @@ import { startAction, tickGame, } from '../game'; -import { content as gameContent } from '../../content/index'; -import { buildStoryContent } from '../../content/storySchema'; import { enterStoryNode } from '../story'; const DEFAULT_GROUP = { id: 'test', label: 'Test' }; @@ -37,9 +37,27 @@ function queueContent() { return buildContent({ resources: [{ id: 'gold', name: 'Gold', startAmount: 0 }], actions: [ - { 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 }] }, + { + 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 }], + }, ], }); } @@ -452,26 +470,31 @@ describe('loop idle runner', () => { }); describe('performAction()', () => { - it('dispatches timed actions through enqueueAction', () => { + it('dispatches timed actions through enqueueAction and returns empty array', () => { const state = createGameState(gameContent); - performAction(state, gameContent, 'gather_supplies'); + const events = performAction(state, gameContent, 'gather_supplies'); expect(state.activeActionId).toBe('gather_supplies'); + expect(events).toEqual([]); }); - it('toggles loop actions and starts them when idle', () => { + it('toggles loop actions, starts them when idle, and returns empty array', () => { const state = createGameState(gameContent); - performAction(state, gameContent, 'rest'); // enable + const events1 = performAction(state, gameContent, 'rest'); // enable expect(state.enabledLoopActionIds.rest).toBe(true); expect(state.activeActionId).toBe('rest'); // started because idle + available - performAction(state, gameContent, 'rest'); // disable + expect(events1).toEqual([]); + const events2 = performAction(state, gameContent, 'rest'); // disable expect(state.enabledLoopActionIds.rest).toBe(false); + expect(events2).toEqual([]); }); - it('dispatches story actions through executeStoryAction', () => { + it('dispatches story actions through executeStoryAction and returns events', () => { const state = createGameState(gameContent); enterStoryNode(state, gameContent, 'fork_choice'); - performAction(state, gameContent, 'pick_high_road'); + const events = performAction(state, gameContent, 'pick_high_road'); expect(state.storyFlags.route_a).toBe(true); + expect(events.length).toBeGreaterThan(0); + expect(events[0].kind).toBe('enter'); }); it('executes instant actions immediately', () => { diff --git a/src/engine/game.ts b/src/engine/game.ts index 9b56bdb..eeb3da4 100644 --- a/src/engine/game.ts +++ b/src/engine/game.ts @@ -1,6 +1,6 @@ import type { Content } from '../content/schema'; import type { StoryContent } from '../content/storySchema'; -import { applyChoice, isStoryChoiceAvailable } from './story'; +import { applyChoice, isStoryChoiceAvailable, type StoryEvent } from './story'; type GameContent = Content & StoryContent; @@ -170,7 +170,7 @@ export function clearQueue(state: GameState): void { */ export function executeInstant(state: GameState, content: Content, actionId: string): void { const action = content.actionsById[actionId]; - if (!action || action.kind !== 'instant') { + if (action?.kind !== 'instant') { throw new Error(`Action "${actionId}" is not instant`); } if (!isActionAvailable(state, content, actionId)) { @@ -189,15 +189,15 @@ export function executeStoryAction( state: GameState, content: GameContent, actionId: string, -): void { +): StoryEvent[] { const action = content.actionsById[actionId]; - if (!action || action.kind !== 'story' || !action.storyChoiceId) { + if (action?.kind !== 'story' || !action.storyChoiceId) { throw new Error(`Action "${actionId}" is not a story action`); } if (!isStoryChoiceAvailable(state, content, action.storyChoiceId)) { throw new Error(`Story choice "${action.storyChoiceId}" is not available`); } - applyChoice(state, content, action.storyChoiceId); + return applyChoice(state, content, action.storyChoiceId); } /** @@ -235,28 +235,31 @@ export function maybeStartLoopAction(state: GameState, content: Content): void { * re-checks each tick). Throwing on unaffordable before toggling would wrongly * block the player from DISABLING an active but now-unaffordable loop. */ -export function performAction(state: GameState, content: GameContent, actionId: string): void { +export function performAction( + state: GameState, + content: GameContent, + actionId: string, +): StoryEvent[] { const action = content.actionsById[actionId]; if (!action) throw new Error(`Unknown action "${actionId}"`); switch (action.kind) { case 'instant': executeInstant(state, content, actionId); - break; + return []; case 'timed': enqueueAction(state, content, actionId); - break; + return []; case 'loop': { const willEnable = !state.enabledLoopActionIds[actionId]; state.enabledLoopActionIds[actionId] = willEnable; if (willEnable) { maybeStartLoopAction(state, content); } - break; + return []; } case 'story': - executeStoryAction(state, content, actionId); - break; + return executeStoryAction(state, content, actionId); case 'context': throw new Error(`Context action "${actionId}" is not implemented`); default: diff --git a/src/state/__tests__/runtime.test.ts b/src/state/__tests__/runtime.test.ts new file mode 100644 index 0000000..aa41019 --- /dev/null +++ b/src/state/__tests__/runtime.test.ts @@ -0,0 +1,121 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { getPrefs } from '../prefs'; +import { GameRuntime } from '../runtime'; +import { useGameStore } from '../store'; + +describe('GameRuntime', () => { + let runtime: GameRuntime; + + beforeEach(async () => { + // Stub localStorage + const storage: Record = {}; + vi.stubGlobal('localStorage', { + getItem(key: string) { + return storage[key] ?? null; + }, + setItem(key: string, value: string) { + storage[key] = value; + }, + removeItem(key: string) { + delete storage[key]; + }, + clear() { + for (const k of Object.keys(storage)) { + delete storage[k]; + } + }, + }); + + // Stub requestAnimationFrame + vi.stubGlobal('requestAnimationFrame', vi.fn().mockReturnValue(1)); + vi.stubGlobal('cancelAnimationFrame', vi.fn()); + + // Stub visibilityState and document.addEventListener + vi.stubGlobal('document', { + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + visibilityState: 'visible', + }); + + // Stub window.addEventListener + vi.stubGlobal('window', { + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + }); + + // Setup clean store + useGameStore.setState({ + log: [], + storyPanelOpen: false, + storyHasUnread: false, + storyLog: [], + prefs: getPrefs(), + activePanel: 'play', + }); + + runtime = new GameRuntime(); + // Boot the runtime to initialize state + await runtime.boot(); + }); + + afterEach(() => { + runtime.stop(); + vi.unstubAllGlobals(); + }); + + it('performs timed action', () => { + runtime.performAction('gather_supplies'); + const store = useGameStore.getState(); + expect(store.log).toContain('Started: Gather supplies.'); + }); + + it('performs loop action', () => { + // Loop actions toggle enable state + runtime.performAction('rest'); + const view = useGameStore.getState(); + // Verify it is enabled + expect(view.actions.find((a) => a.id === 'rest')?.loopEnabled).toBe(true); + }); + + it('performs story action and appends story log', () => { + // Let's first move state to fork_choice node where story choices are available + runtime.setActivePanel('story'); + + // Pick the high road + runtime.performAction('pick_high_road'); + + const store = useGameStore.getState(); + // The story log should have the node we entered: route_a_beat + expect(store.storyLog.some((entry) => entry.nodeId === 'route_a_beat')).toBe(true); + // Should append choice label to normal log + expect(store.log).toContain('Story: Take the high road'); + }); + + it('sets active panel and handles auto-advance from boot_intro', () => { + // Initially we boot into boot_intro. Since prefs.storyOpenMode is 'auto', + // the boot trigger will auto-navigate to the 'story' panel immediately. + expect(useGameStore.getState().activePanel).toBe('story'); + + // If we call setActivePanel('story') again, it should trigger the auto-advance logic + // from 'boot_intro' to 'fork_choice'. + runtime.setActivePanel('story'); + + expect(useGameStore.getState().activePanel).toBe('story'); + // Since we were at boot_intro and active panel set to story, it should auto-advance to fork_choice + const store = useGameStore.getState(); + expect(store.storyLog.some((entry) => entry.nodeId === 'fork_choice')).toBe(true); + expect(store.log.some((line) => line.includes('Story:'))).toBe(true); + }); + + it('delegates openStoryPanel and closeStoryPanel to setActivePanel', () => { + // Force activePanel back to play first + runtime.setActivePanel('play'); + expect(useGameStore.getState().activePanel).toBe('play'); + + runtime.openStoryPanel(); + expect(useGameStore.getState().activePanel).toBe('story'); + + runtime.closeStoryPanel(); + expect(useGameStore.getState().activePanel).toBe('play'); + }); +}); diff --git a/src/state/runtime.ts b/src/state/runtime.ts index 6ccd13a..facba6a 100644 --- a/src/state/runtime.ts +++ b/src/state/runtime.ts @@ -1,20 +1,20 @@ import { content } from '../content'; import { cancelQueuedAction as engineCancelQueuedAction, - enqueueAction as engineEnqueueAction, type GameState, - isActionAvailable, + maybeStartLoopAction, + performAction as performActionEngine, tickGame, } from '../engine/game'; import { applyChoice as engineApplyChoice, enterStoryNode, initStory } from '../engine/story'; import { advance, createTickLoop, TICK_MS, type TickLoop } from '../engine/tickLoop'; import { createDefaultBackend, loadGame, type SaveBackend, saveGame } from './persistence'; import { getPrefs } from './prefs'; -import { useGameStore } from './store'; +import { type ActivePanel, useGameStore } from './store'; import { processStoryTriggers, type StoryUiEffect, - shouldAutoOpenPanel, + shouldAutoNavigateToStory, storyEventsToLogEntries, } from './storyOrchestration'; import { formatOfflineDuration, toView } from './viewModel'; @@ -32,7 +32,7 @@ import { formatOfflineDuration, toView } from './viewModel'; const PUBLISH_INTERVAL_MS = 100; // ~10 fps view refresh const AUTOSAVE_INTERVAL_MS = 10_000; -class GameRuntime { +export class GameRuntime { private state: GameState | null = null; private readonly loop: TickLoop = createTickLoop(); private readonly backend: SaveBackend = createDefaultBackend(); @@ -77,45 +77,83 @@ class GameRuntime { } } - enqueueAction(actionId: string): void { + performAction(actionId: string): void { const state = this.state; - if (!state) { - return; - } - if (!isActionAvailable(state, content, actionId)) { - const actionView = toView(state, content).actions.find((a) => a.id === actionId); - useGameStore.getState().appendLog(actionView?.disabledReason ?? 'Cannot enqueue'); - return; - } + if (!state) return; + const action = content.actionsById[actionId]; + if (!action) return; + try { - engineEnqueueAction(state, content, actionId); - const action = content.actionsById[actionId]; - if (action) { + const isStory = action.kind === 'story'; + const events = performActionEngine(state, content, actionId); + const store = useGameStore.getState(); + + if (isStory) { + const entries = storyEventsToLogEntries(events); + for (const entry of entries) { + store.appendStoryLog(entry); + } + const choiceLabel = entries.at(-1)?.choiceLabel; + if (choiceLabel) { + store.appendLog(`Story: ${choiceLabel}`); + } + this.runPublishTriggers(); + } else if (action.kind === 'timed') { const verb = state.actionQueue.includes(actionId) ? 'Queued' : 'Started'; - useGameStore.getState().appendLog(`${verb}: ${action.name}.`); + store.appendLog(`${verb}: ${action.name}.`); } + + this.publish(); } catch (err) { - const msg = err instanceof Error ? err.message : 'Cannot start action'; - useGameStore.getState().appendLog(msg); + useGameStore.getState().appendLog(err instanceof Error ? err.message : 'Action failed'); } - this.publish(); + } + + setActivePanel(panel: ActivePanel): void { + const store = useGameStore.getState(); + store.setActivePanel(panel); + if (panel === 'story') { + store.setStoryHasUnread(false); + const state = this.state; + if (state && state.currentStoryNodeId === 'boot_intro') { + const events = enterStoryNode(state, content, 'fork_choice'); + const entries = storyEventsToLogEntries(events); + for (const entry of entries) { + store.appendStoryLog(entry); + } + for (const entry of entries) { + const nodeProse = content.storyNodesById[entry.nodeId]?.prose ?? ''; + store.appendLog(`Story: ${nodeProse.slice(0, 40)}…`); + } + this.publish(); + } + } + } + + enqueueAction(actionId: string): void { + this.performAction(actionId); } applyStoryChoice(choiceId: string): void { - const state = this.state; - if (!state) return; - try { - const events = engineApplyChoice(state, content, choiceId); - const entries = storyEventsToLogEntries(events); - const store = useGameStore.getState(); - for (const entry of entries) store.appendStoryLog(entry); - const choiceLabel = entries.at(-1)?.choiceLabel; - if (choiceLabel) store.appendLog(`Story: ${choiceLabel}`); - store.setStoryPanelOpen(false); - this.runPublishTriggers(); - this.publish(); - } catch (err) { - useGameStore.getState().appendLog(err instanceof Error ? err.message : 'Choice failed'); + const action = content.actions.find((a) => a.storyChoiceId === choiceId); + if (action) { + this.performAction(action.id); + } else { + const state = this.state; + if (!state) return; + try { + const events = engineApplyChoice(state, content, choiceId); + const entries = storyEventsToLogEntries(events); + const store = useGameStore.getState(); + for (const entry of entries) store.appendStoryLog(entry); + const choiceLabel = entries.at(-1)?.choiceLabel; + if (choiceLabel) store.appendLog(`Story: ${choiceLabel}`); + store.setStoryPanelOpen(false); + this.runPublishTriggers(); + this.publish(); + } catch (err) { + useGameStore.getState().appendLog(err instanceof Error ? err.message : 'Choice failed'); + } } } @@ -132,12 +170,11 @@ class GameRuntime { } openStoryPanel(): void { - useGameStore.getState().setStoryPanelOpen(true); - useGameStore.getState().setStoryHasUnread(false); + this.setActivePanel('story'); } closeStoryPanel(): void { - useGameStore.getState().setStoryPanelOpen(false); + this.setActivePanel('play'); } continueStory(): void { @@ -152,7 +189,7 @@ class GameRuntime { store.appendLog(`Story: ${content.storyNodesById[entry.nodeId]?.prose.slice(0, 40)}…`); } const prefs = store.prefs; - if (shouldAutoOpenPanel(prefs, 'fork_choice', content)) { + if (shouldAutoNavigateToStory(prefs, 'fork_choice', content)) { store.setStoryPanelOpen(true); store.setStoryHasUnread(false); } else { @@ -168,11 +205,18 @@ class GameRuntime { private applyStoryUiEffect(effect: StoryUiEffect): void { const store = useGameStore.getState(); + const prefs = store.prefs; for (const entry of effect.logEntries) store.appendStoryLog(entry); for (const line of effect.eventLogLines) store.appendLog(line); + if (effect.shouldOpenPanel) { + if (prefs.storyOpenMode === 'auto') { + store.setActivePanel('story'); + store.setStoryHasUnread(false); + } else { + store.setStoryHasUnread(true); + } store.setStoryPanelOpen(true); - store.setStoryHasUnread(false); } else if (effect.enteredNodeIds.length > 0) { store.setStoryHasUnread(true); } @@ -201,6 +245,7 @@ class GameRuntime { ); this.applyStoryUiEffect(effect); } + maybeStartLoopAction(state, content); }); if (monoNow - this.lastPublishAt >= PUBLISH_INTERVAL_MS) { diff --git a/src/state/storyOrchestration.ts b/src/state/storyOrchestration.ts index 6f253e3..64ef83c 100644 --- a/src/state/storyOrchestration.ts +++ b/src/state/storyOrchestration.ts @@ -17,7 +17,7 @@ export function storyEventsToLogEntries(events: StoryEvent[]): StoryLogEntry[] { .map((e) => ({ nodeId: e.nodeId, prose: e.prose, choiceLabel: e.choiceLabel })); } -export function shouldAutoOpenPanel( +export function shouldAutoNavigateToStory( prefs: GamePrefs, nodeId: string, content: GameContent, @@ -39,7 +39,7 @@ export function processStoryTriggers( const logEntries = storyEventsToLogEntries(events); const shouldOpenPanel = enteredNodeIds.length > 0 && - enteredNodeIds.some((id) => shouldAutoOpenPanel(prefs, id, content)); + enteredNodeIds.some((id) => shouldAutoNavigateToStory(prefs, id, content)); const eventLogLines = logEntries.map((e) => e.choiceLabel ? `Story: ${e.choiceLabel}` -- 2.54.0 From b29b17c6cb81da318b2222c843f3e3835de21af3 Mon Sep 17 00:00:00 2001 From: ginnoir Date: Thu, 11 Jun 2026 21:20:52 -0500 Subject: [PATCH 13/26] refactor(state): fix code quality findings for runtime and story log --- src/state/__tests__/runtime.test.ts | 180 ++++++++++++++++++++++++++-- src/state/runtime.ts | 46 +++---- src/state/storyOrchestration.ts | 14 ++- 3 files changed, 204 insertions(+), 36 deletions(-) diff --git a/src/state/__tests__/runtime.test.ts b/src/state/__tests__/runtime.test.ts index aa41019..7c22589 100644 --- a/src/state/__tests__/runtime.test.ts +++ b/src/state/__tests__/runtime.test.ts @@ -1,4 +1,5 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { content } from '../../content'; import { getPrefs } from '../prefs'; import { GameRuntime } from '../runtime'; import { useGameStore } from '../store'; @@ -29,6 +30,7 @@ describe('GameRuntime', () => { // Stub requestAnimationFrame vi.stubGlobal('requestAnimationFrame', vi.fn().mockReturnValue(1)); vi.stubGlobal('cancelAnimationFrame', vi.fn()); + vi.stubGlobal('indexedDB', undefined); // Stub visibilityState and document.addEventListener vi.stubGlobal('document', { @@ -79,7 +81,7 @@ describe('GameRuntime', () => { it('performs story action and appends story log', () => { // Let's first move state to fork_choice node where story choices are available - runtime.setActivePanel('story'); + runtime.continueStory(); // Pick the high road runtime.performAction('pick_high_road'); @@ -91,20 +93,160 @@ describe('GameRuntime', () => { expect(store.log).toContain('Story: Take the high road'); }); - it('sets active panel and handles auto-advance from boot_intro', () => { - // Initially we boot into boot_intro. Since prefs.storyOpenMode is 'auto', - // the boot trigger will auto-navigate to the 'story' panel immediately. - expect(useGameStore.getState().activePanel).toBe('story'); + it('performs story action and appends custom log outcomes', () => { + // Save original fork_choice node + const originalNode = content.storyNodesById.fork_choice; + if (!originalNode) { + throw new Error('fork_choice node not found in content'); + } + const originalNodes = [...content.storyNodes]; - // If we call setActivePanel('story') again, it should trigger the auto-advance logic + // Create a modified fork_choice node with a log outcome on pick_a + const choices = originalNode.choices ?? []; + const firstChoice = choices[0]; + if (!firstChoice) { + throw new Error('first choice not found on fork_choice'); + } + + const modifiedChoice = { + ...firstChoice, + outcomes: [ + ...(firstChoice.outcomes ?? []), + { type: 'log' as const, text: 'Custom log from story action!' }, + ], + }; + + const modifiedNode = { + ...originalNode, + choices: [modifiedChoice, ...choices.slice(1)], + }; + + // Mutate content + content.storyNodesById.fork_choice = modifiedNode; + content.storyNodes = content.storyNodes.map((n) => (n.id === 'fork_choice' ? modifiedNode : n)); + + try { + // Move to fork_choice + runtime.continueStory(); + + // Clear logs to check cleanly + useGameStore.setState({ log: [], storyLog: [] }); + + // Perform the story action + runtime.performAction('pick_high_road'); + + const store = useGameStore.getState(); + expect(store.log).toContain('Custom log from story action!'); + } finally { + // Restore content + content.storyNodesById.fork_choice = originalNode; + content.storyNodes = originalNodes; + } + }); + + it('setActivePanel does not auto-advance boot_intro to fork_choice', () => { + // Initially we boot into boot_intro. + // If we call setActivePanel('story'), it should NOT trigger the auto-advance logic // from 'boot_intro' to 'fork_choice'. runtime.setActivePanel('story'); - expect(useGameStore.getState().activePanel).toBe('story'); - // Since we were at boot_intro and active panel set to story, it should auto-advance to fork_choice const store = useGameStore.getState(); - expect(store.storyLog.some((entry) => entry.nodeId === 'fork_choice')).toBe(true); - expect(store.log.some((line) => line.includes('Story:'))).toBe(true); + expect(store.storyLog.some((entry) => entry.nodeId === 'fork_choice')).toBe(false); + }); + + it('continueStory() advances boot_intro to fork_choice and opens panel when storyOpenMode is auto', () => { + const store = useGameStore.getState(); + store.setPrefs({ ...store.prefs, storyOpenMode: 'auto' }); + + // Force play panel and closed/no unread story + store.setActivePanel('play'); + store.setStoryPanelOpen(false); + store.setStoryHasUnread(false); + + runtime.continueStory(); + + const updated = useGameStore.getState(); + // Verifies it advances to fork_choice + expect(updated.storyLog.some((entry) => entry.nodeId === 'fork_choice')).toBe(true); + // Verifies it opens panel and does not set unread (since it's open) + expect(updated.storyPanelOpen).toBe(true); + expect(updated.storyHasUnread).toBe(false); + }); + + it('continueStory() advances boot_intro to fork_choice and sets unread when storyOpenMode is manual', () => { + const store = useGameStore.getState(); + store.setPrefs({ ...store.prefs, storyOpenMode: 'manual' }); + + // Force play panel and closed/no unread story + store.setActivePanel('play'); + store.setStoryPanelOpen(false); + store.setStoryHasUnread(false); + + runtime.continueStory(); + + const updated = useGameStore.getState(); + // Verifies it advances to fork_choice + expect(updated.storyLog.some((entry) => entry.nodeId === 'fork_choice')).toBe(true); + // Verifies it does NOT open panel and sets unread flag to true + expect(updated.storyPanelOpen).toBe(false); + expect(updated.storyHasUnread).toBe(true); + }); + + it('applies story choice with an action mapping and processes log outcomes', () => { + // Move to fork_choice + runtime.continueStory(); + + // Apply choice 'pick_a' (which maps to 'pick_high_road' action) + runtime.applyStoryChoice('pick_a'); + + const store = useGameStore.getState(); + expect(store.storyLog.some((entry) => entry.nodeId === 'route_a_beat')).toBe(true); + expect(store.log).toContain('Story: Take the high road'); + }); + + it('applies story choice without an action mapping and appends custom log outcomes', () => { + // Save original fork_choice node + const originalNode = content.storyNodesById.fork_choice; + if (!originalNode) { + throw new Error('fork_choice node not found in content'); + } + const originalNodes = [...content.storyNodes]; + + // Create a modified fork_choice node with a custom choice that has a 'log' outcome + const customChoice = { + id: 'custom_choice_no_action', + label: 'Perform custom choice', + outcomes: [{ type: 'log' as const, text: 'This is a custom log outcome!' }], + targetNodeId: 'route_a_beat', + }; + + const modifiedNode = { + ...originalNode, + choices: [...(originalNode.choices ?? []), customChoice], + }; + + // Mutate content + content.storyNodesById.fork_choice = modifiedNode; + content.storyNodes = content.storyNodes.map((n) => (n.id === 'fork_choice' ? modifiedNode : n)); + + try { + // Move to fork_choice + runtime.continueStory(); + + // Clear logs to check cleanly + useGameStore.setState({ log: [], storyLog: [] }); + + // Apply choice + runtime.applyStoryChoice('custom_choice_no_action'); + + const store = useGameStore.getState(); + expect(store.log).toContain('This is a custom log outcome!'); + expect(store.storyLog.some((entry) => entry.nodeId === 'route_a_beat')).toBe(true); + } finally { + // Restore content + content.storyNodesById.fork_choice = originalNode; + content.storyNodes = originalNodes; + } }); it('delegates openStoryPanel and closeStoryPanel to setActivePanel', () => { @@ -118,4 +260,22 @@ describe('GameRuntime', () => { runtime.closeStoryPanel(); expect(useGameStore.getState().activePanel).toBe('play'); }); + + it('registers lifecycle listeners on boot and removes them on stop', () => { + const addSpyDoc = vi.spyOn(document, 'addEventListener'); + const removeSpyDoc = vi.spyOn(document, 'removeEventListener'); + const addSpyWin = vi.spyOn(window, 'addEventListener'); + const removeSpyWin = vi.spyOn(window, 'removeEventListener'); + + const testRuntime = new GameRuntime(); + testRuntime.boot(); + + expect(addSpyDoc).toHaveBeenCalledWith('visibilitychange', expect.any(Function)); + expect(addSpyWin).toHaveBeenCalledWith('beforeunload', expect.any(Function)); + + testRuntime.stop(); + + expect(removeSpyDoc).toHaveBeenCalledWith('visibilitychange', expect.any(Function)); + expect(removeSpyWin).toHaveBeenCalledWith('beforeunload', expect.any(Function)); + }); }); diff --git a/src/state/runtime.ts b/src/state/runtime.ts index facba6a..d907322 100644 --- a/src/state/runtime.ts +++ b/src/state/runtime.ts @@ -41,6 +41,16 @@ export class GameRuntime { private lastSaveAt = 0; private booted = false; + private readonly visibilityChangeListener = (): void => { + if (document.visibilityState === 'hidden') { + void this.save(); + } + }; + + private readonly beforeUnloadListener = (): void => { + void this.save(); + }; + async boot(): Promise { if (this.booted) { return; @@ -75,6 +85,8 @@ export class GameRuntime { cancelAnimationFrame(this.rafId); this.rafId = null; } + document.removeEventListener('visibilitychange', this.visibilityChangeListener); + window.removeEventListener('beforeunload', this.beforeUnloadListener); } performAction(actionId: string): void { @@ -88,6 +100,11 @@ export class GameRuntime { const events = performActionEngine(state, content, actionId); const store = useGameStore.getState(); + // Append any custom log outcomes + for (const event of events.filter((e) => e.kind === 'log')) { + store.appendLog(event.prose); + } + if (isStory) { const entries = storyEventsToLogEntries(events); for (const entry of entries) { @@ -114,19 +131,6 @@ export class GameRuntime { store.setActivePanel(panel); if (panel === 'story') { store.setStoryHasUnread(false); - const state = this.state; - if (state && state.currentStoryNodeId === 'boot_intro') { - const events = enterStoryNode(state, content, 'fork_choice'); - const entries = storyEventsToLogEntries(events); - for (const entry of entries) { - store.appendStoryLog(entry); - } - for (const entry of entries) { - const nodeProse = content.storyNodesById[entry.nodeId]?.prose ?? ''; - store.appendLog(`Story: ${nodeProse.slice(0, 40)}…`); - } - this.publish(); - } } } @@ -145,6 +149,12 @@ export class GameRuntime { const events = engineApplyChoice(state, content, choiceId); const entries = storyEventsToLogEntries(events); const store = useGameStore.getState(); + + // Append any custom log outcomes + for (const event of events.filter((e) => e.kind === 'log')) { + store.appendLog(event.prose); + } + for (const entry of entries) store.appendStoryLog(entry); const choiceLabel = entries.at(-1)?.choiceLabel; if (choiceLabel) store.appendLog(`Story: ${choiceLabel}`); @@ -279,14 +289,8 @@ export class GameRuntime { } private installLifecycleHooks(): void { - document.addEventListener('visibilitychange', () => { - if (document.visibilityState === 'hidden') { - void this.save(); - } - }); - window.addEventListener('beforeunload', () => { - void this.save(); - }); + document.addEventListener('visibilitychange', this.visibilityChangeListener); + window.addEventListener('beforeunload', this.beforeUnloadListener); } } diff --git a/src/state/storyOrchestration.ts b/src/state/storyOrchestration.ts index 64ef83c..56f0568 100644 --- a/src/state/storyOrchestration.ts +++ b/src/state/storyOrchestration.ts @@ -40,10 +40,14 @@ export function processStoryTriggers( const shouldOpenPanel = enteredNodeIds.length > 0 && enteredNodeIds.some((id) => shouldAutoNavigateToStory(prefs, id, content)); - const eventLogLines = logEntries.map((e) => - e.choiceLabel - ? `Story: ${e.choiceLabel}` - : `Story: ${content.storyNodesById[e.nodeId]?.prose.slice(0, 40)}…`, - ); + const customLogs = events.filter((e) => e.kind === 'log').map((e) => e.prose); + const eventLogLines = [ + ...customLogs, + ...logEntries.map((e) => + e.choiceLabel + ? `Story: ${e.choiceLabel}` + : `Story: ${content.storyNodesById[e.nodeId]?.prose.slice(0, 40)}…`, + ), + ]; return { enteredNodeIds, logEntries, shouldOpenPanel, eventLogLines }; } -- 2.54.0 From ed9607e9e86823b00d97f9cdb221d965ded32e2a Mon Sep 17 00:00:00 2001 From: ginnoir Date: Thu, 11 Jun 2026 21:23:18 -0500 Subject: [PATCH 14/26] refactor(state): address HMR and defensive slicing recommendations for runtime --- src/state/runtime.ts | 11 ++++++++++- src/state/storyOrchestration.ts | 2 +- 2 files changed, 11 insertions(+), 2 deletions(-) diff --git a/src/state/runtime.ts b/src/state/runtime.ts index d907322..aae47e6 100644 --- a/src/state/runtime.ts +++ b/src/state/runtime.ts @@ -87,6 +87,7 @@ export class GameRuntime { } document.removeEventListener('visibilitychange', this.visibilityChangeListener); window.removeEventListener('beforeunload', this.beforeUnloadListener); + this.booted = false; } performAction(actionId: string): void { @@ -196,7 +197,9 @@ export class GameRuntime { const store = useGameStore.getState(); for (const entry of entries) store.appendStoryLog(entry); for (const entry of entries) { - store.appendLog(`Story: ${content.storyNodesById[entry.nodeId]?.prose.slice(0, 40)}…`); + store.appendLog( + `Story: ${(content.storyNodesById[entry.nodeId]?.prose ?? '').slice(0, 40)}…`, + ); } const prefs = store.prefs; if (shouldAutoNavigateToStory(prefs, 'fork_choice', content)) { @@ -295,3 +298,9 @@ export class GameRuntime { } export const gameRuntime = new GameRuntime(); + +if (import.meta.hot) { + import.meta.hot.dispose(() => { + gameRuntime.stop(); + }); +} diff --git a/src/state/storyOrchestration.ts b/src/state/storyOrchestration.ts index 56f0568..6ce4537 100644 --- a/src/state/storyOrchestration.ts +++ b/src/state/storyOrchestration.ts @@ -46,7 +46,7 @@ export function processStoryTriggers( ...logEntries.map((e) => e.choiceLabel ? `Story: ${e.choiceLabel}` - : `Story: ${content.storyNodesById[e.nodeId]?.prose.slice(0, 40)}…`, + : `Story: ${(content.storyNodesById[e.nodeId]?.prose ?? '').slice(0, 40)}…`, ), ]; return { enteredNodeIds, logEntries, shouldOpenPanel, eventLogLines }; -- 2.54.0 From ee79872d4d1a74add08a57c98490da9a29a612b8 Mon Sep 17 00:00:00 2001 From: ginnoir Date: Thu, 11 Jun 2026 21:24:42 -0500 Subject: [PATCH 15/26] feat(ui): app shell with nav rail --- src/ui/AboutPanel.tsx | 53 +++++++++++++++ src/ui/App.tsx | 76 ++++++++------------- src/ui/AppShell.tsx | 21 ++++++ src/ui/NavRail.tsx | 140 +++++++++++++++++++++++++++++++++++++++ src/ui/PlayPanel.tsx | 9 +++ src/ui/RightRail.tsx | 19 ++++++ src/ui/SettingsPanel.tsx | 86 ++++++++++++++++++++++++ src/ui/StoryView.tsx | 76 +++++++++++++++++++++ 8 files changed, 432 insertions(+), 48 deletions(-) create mode 100644 src/ui/AboutPanel.tsx create mode 100644 src/ui/AppShell.tsx create mode 100644 src/ui/NavRail.tsx create mode 100644 src/ui/PlayPanel.tsx create mode 100644 src/ui/RightRail.tsx create mode 100644 src/ui/SettingsPanel.tsx create mode 100644 src/ui/StoryView.tsx diff --git a/src/ui/AboutPanel.tsx b/src/ui/AboutPanel.tsx new file mode 100644 index 0000000..7280517 --- /dev/null +++ b/src/ui/AboutPanel.tsx @@ -0,0 +1,53 @@ +export function AboutPanel() { + return ( +
+
+

About Idlegame

+

A text-fantasy RPG incremental experience.

+
+ +
+
+

+ How to Play +

+

+ Idlegame is driven by actions and choices. Select actions from the{' '} + Play screen to execute them. Some actions + are timed and can be queued. Once completed, they reward you with resources, unlock new + deeds, or advance the chronicle. +

+
+ +
+

+ The Chronicle +

+

+ As you perform actions, you will unlock narrative points of interest. Head to the{' '} + Story tab to make crucial choices and read + the history of your journey. +

+
+ +
+

+ Technical Specs +

+
+
+
Version
+
0.1.0 (M1 Milestone)
+
Engine
+
Pure TypeScript State Machine
+
Framework
+
React + Zustand + Tailwind CSS
+
Target Platform
+
Responsive Web & PWA
+
+
+
+
+
+ ); +} diff --git a/src/ui/App.tsx b/src/ui/App.tsx index bdc9ee0..fe4bfa9 100644 --- a/src/ui/App.tsx +++ b/src/ui/App.tsx @@ -1,65 +1,45 @@ import { useEffect } from 'react'; import { gameRuntime } from '../state/runtime'; import { useGameStore } from '../state/store'; -import { ActionPanel } from './ActionPanel'; -import { EventLog } from './EventLog'; -import { ResourceBar } from './ResourceBar'; -import { SettingsDrawer } from './SettingsDrawer'; -import { StoryPanel } from './StoryPanel'; +import { AboutPanel } from './AboutPanel'; +import { AppShell } from './AppShell'; +import { NavRail } from './NavRail'; +import { PlayPanel } from './PlayPanel'; +import { RightRail } from './RightRail'; +import { SettingsPanel } from './SettingsPanel'; +import { StoryView } from './StoryView'; /** * M1 playable-loop shell. Boots the runtime once on mount; everything else * renders from the Zustand store the runtime feeds. */ export function App() { - const storyHasUnread = useGameStore((s) => s.storyHasUnread); - const settingsOpen = useGameStore((s) => s.settingsOpen); - const setSettingsOpen = useGameStore((s) => s.setSettingsOpen); + const activePanel = useGameStore((s) => s.activePanel); useEffect(() => { void gameRuntime.boot(); }, []); + const renderActivePanel = () => { + switch (activePanel) { + case 'play': + return ; + case 'story': + return ; + case 'settings': + return ; + case 'about': + return ; + default: + return ; + } + }; + return ( - <> - -
-
-
-

Idlegame

-
- - -
-
-

M1 playable loop

- -
- - - -
- + } + center={renderActivePanel()} + right={activePanel === 'play' ? : undefined} + /> ); } diff --git a/src/ui/AppShell.tsx b/src/ui/AppShell.tsx new file mode 100644 index 0000000..952cca6 --- /dev/null +++ b/src/ui/AppShell.tsx @@ -0,0 +1,21 @@ +import type { ReactNode } from 'react'; + +interface AppShellProps { + nav: ReactNode; + center: ReactNode; + right?: ReactNode; +} + +export function AppShell({ nav, center, right }: AppShellProps) { + return ( +
+
{nav}
+
{center}
+ {right ? ( + + ) : null} +
+ ); +} diff --git a/src/ui/NavRail.tsx b/src/ui/NavRail.tsx new file mode 100644 index 0000000..050a9e9 --- /dev/null +++ b/src/ui/NavRail.tsx @@ -0,0 +1,140 @@ +import { gameRuntime } from '../state/runtime'; +import { type ActivePanel, useGameStore } from '../state/store'; + +export function NavRail() { + const activePanel = useGameStore((s) => s.activePanel); + const storyHasUnread = useGameStore((s) => s.storyHasUnread); + + const navItems: { id: ActivePanel; label: string; icon: React.ReactNode }[] = [ + { + id: 'play', + label: 'Play', + icon: ( + + ), + }, + { + id: 'story', + label: 'Story', + icon: ( + + ), + }, + { + id: 'settings', + label: 'Settings', + icon: ( + + ), + }, + { + id: 'about', + label: 'About', + icon: ( + + ), + }, + ]; + + return ( +
+
+ + IDLEGAME + + + M1 + +
+ + + +
+ senpai edition +
+
+ ); +} diff --git a/src/ui/PlayPanel.tsx b/src/ui/PlayPanel.tsx new file mode 100644 index 0000000..01e5623 --- /dev/null +++ b/src/ui/PlayPanel.tsx @@ -0,0 +1,9 @@ +import { ActionPanel } from './ActionPanel'; + +export function PlayPanel() { + return ( +
+ +
+ ); +} diff --git a/src/ui/RightRail.tsx b/src/ui/RightRail.tsx new file mode 100644 index 0000000..3c0cc8c --- /dev/null +++ b/src/ui/RightRail.tsx @@ -0,0 +1,19 @@ +import { EventLog } from './EventLog'; +import { ResourceBar } from './ResourceBar'; + +export function RightRail() { + return ( +
+
+

+ Resources +

+ +
+
+

Log

+ +
+
+ ); +} diff --git a/src/ui/SettingsPanel.tsx b/src/ui/SettingsPanel.tsx new file mode 100644 index 0000000..6080a1e --- /dev/null +++ b/src/ui/SettingsPanel.tsx @@ -0,0 +1,86 @@ +import type { ActionDetailMode, StoryOpenMode } from '../state/prefs'; +import { useGameStore } from '../state/store'; + +export function SettingsPanel() { + const prefs = useGameStore((s) => s.prefs); + const setPrefs = useGameStore((s) => s.setPrefs); + + return ( +
+
+

Game Settings

+

Configure your gameplay and UI preferences.

+
+ +
+ {/* Story Open Mode */} +
+
+ Story Navigation + + How should the game navigate when story events are unlocked? + +
+
+ {(['auto', 'choices-only', 'manual'] as StoryOpenMode[]).map((mode) => { + const labels: Record = { + auto: 'Automatically', + 'choices-only': 'Choices Only', + manual: 'Manually', + }; + const isActive = prefs.storyOpenMode === mode; + return ( + + ); + })} +
+
+ + {/* Action Detail Mode */} +
+
+ Action Details + + Where should action requirements and details be shown? + +
+
+ {(['inline', 'hover', 'info-button'] as ActionDetailMode[]).map((mode) => { + const labels: Record = { + inline: 'Inline', + hover: 'Hover Tooltips', + 'info-button': 'Info Button', + }; + const isActive = prefs.actionDetailMode === mode; + return ( + + ); + })} +
+
+
+
+ ); +} diff --git a/src/ui/StoryView.tsx b/src/ui/StoryView.tsx new file mode 100644 index 0000000..ec7c2bf --- /dev/null +++ b/src/ui/StoryView.tsx @@ -0,0 +1,76 @@ +import { gameRuntime } from '../state/runtime'; +import { useGameStore } from '../state/store'; + +export function StoryView() { + const story = useGameStore((s) => s.story); + const storyLog = useGameStore((s) => s.storyLog); + + const hasChoices = story.choices.length > 0; + + return ( +
+ {/* Story Log Section */} +
+

+ Story Log +

+ {storyLog.length === 0 ? ( +

The chronicle is empty...

+ ) : ( +
    + {storyLog.map((entry) => ( +
  • + {entry.choiceLabel ? ( + [{entry.choiceLabel}] + ) : null} + {entry.prose} +
  • + ))} +
+ )} +
+ + {/* Active Node Section */} +
+
+

+ {story.currentProse || + 'The path ahead is shrouded in mist. Begin an action to unveil your story.'} +

+
+ + {story.currentProse && ( +
+ {hasChoices ? ( +
+ {story.choices.map((choice) => ( + + ))} +
+ ) : ( + + )} +
+ )} +
+
+ ); +} -- 2.54.0 From 232b84299c0aef96e8833848b08a36f50bf419b6 Mon Sep 17 00:00:00 2001 From: ginnoir Date: Thu, 11 Jun 2026 21:27:41 -0500 Subject: [PATCH 16/26] refactor(ui): resolve mobile layout and HMR findings for app shell and nav rail --- src/ui/AppShell.tsx | 8 ++++- src/ui/NavRail.tsx | 12 +++---- src/ui/PlayPanel.tsx | 8 +++++ src/ui/RightRail.tsx | 1 - src/ui/SettingsDrawer.tsx | 40 --------------------- src/ui/StoryPanel.tsx | 74 --------------------------------------- src/ui/StoryView.tsx | 6 ++-- 7 files changed, 24 insertions(+), 125 deletions(-) delete mode 100644 src/ui/SettingsDrawer.tsx delete mode 100644 src/ui/StoryPanel.tsx diff --git a/src/ui/AppShell.tsx b/src/ui/AppShell.tsx index 952cca6..b120c1a 100644 --- a/src/ui/AppShell.tsx +++ b/src/ui/AppShell.tsx @@ -8,7 +8,13 @@ interface AppShellProps { export function AppShell({ nav, center, right }: AppShellProps) { return ( -
+
{nav}
{center}
{right ? ( diff --git a/src/ui/NavRail.tsx b/src/ui/NavRail.tsx index 050a9e9..35289f2 100644 --- a/src/ui/NavRail.tsx +++ b/src/ui/NavRail.tsx @@ -95,8 +95,8 @@ export function NavRail() { ]; return ( -
-
+
+
IDLEGAME @@ -113,17 +113,17 @@ export function NavRail() { key={item.id} type="button" onClick={() => gameRuntime.setActivePanel(item.id)} - className={`relative flex cursor-pointer items-center gap-3 rounded-lg px-3 py-2.5 text-sm font-medium transition-all duration-200 ${ + className={`relative flex cursor-pointer items-center justify-center md:justify-start gap-3 rounded-lg px-2.5 py-2.5 md:px-3 text-sm font-medium transition-all duration-200 ${ isActive ? 'border border-amber-500/30 bg-amber-500/10 text-amber-400' : 'border border-transparent text-slate-400 hover:bg-slate-900/60 hover:text-slate-200' }`} > {item.icon} - {item.label} + {item.label} {item.id === 'story' && storyHasUnread ? (
-

Log

diff --git a/src/ui/SettingsDrawer.tsx b/src/ui/SettingsDrawer.tsx deleted file mode 100644 index 0d467ff..0000000 --- a/src/ui/SettingsDrawer.tsx +++ /dev/null @@ -1,40 +0,0 @@ -import type { ActionDetailMode, StoryOpenMode } from '../state/prefs'; -import { useGameStore } from '../state/store'; - -/** Preference panel toggled from the header gear; changes persist immediately. */ -export function SettingsDrawer() { - const open = useGameStore((s) => s.settingsOpen); - const prefs = useGameStore((s) => s.prefs); - const setPrefs = useGameStore((s) => s.setPrefs); - - if (!open) return null; - - return ( -
- - -
- ); -} diff --git a/src/ui/StoryPanel.tsx b/src/ui/StoryPanel.tsx deleted file mode 100644 index 97481f2..0000000 --- a/src/ui/StoryPanel.tsx +++ /dev/null @@ -1,74 +0,0 @@ -import { gameRuntime } from '../state/runtime'; -import { useGameStore } from '../state/store'; - -/** Full-screen VN overlay with prose, choices, and desktop story log sidebar. */ -export function StoryPanel() { - const open = useGameStore((s) => s.storyPanelOpen); - const story = useGameStore((s) => s.story); - const storyLog = useGameStore((s) => s.storyLog); - - if (!open) return null; - - const hasChoices = story.choices.length > 0; - - return ( -
-
- -
-

{story.currentProse}

- {hasChoices ? ( -
- {story.choices.map((choice) => ( - - ))} -
- ) : ( - - )} - -
-
-
- ); -} diff --git a/src/ui/StoryView.tsx b/src/ui/StoryView.tsx index ec7c2bf..609e50b 100644 --- a/src/ui/StoryView.tsx +++ b/src/ui/StoryView.tsx @@ -8,9 +8,9 @@ export function StoryView() { const hasChoices = story.choices.length > 0; return ( -
+
{/* Story Log Section */} -
+

Story Log

@@ -34,7 +34,7 @@ export function StoryView() {
{/* Active Node Section */} -
+

{story.currentProse || -- 2.54.0 From 08d186f42ea74acbdaf434194f79ccbfa9abd717 Mon Sep 17 00:00:00 2001 From: ginnoir Date: Thu, 11 Jun 2026 21:29:44 -0500 Subject: [PATCH 17/26] refactor: clean up unused panel overlay state and methods --- src/state/__tests__/runtime.test.ts | 19 ++----------------- src/state/runtime.ts | 16 ++-------------- src/state/store.ts | 8 -------- 3 files changed, 4 insertions(+), 39 deletions(-) diff --git a/src/state/__tests__/runtime.test.ts b/src/state/__tests__/runtime.test.ts index 7c22589..008c3cb 100644 --- a/src/state/__tests__/runtime.test.ts +++ b/src/state/__tests__/runtime.test.ts @@ -48,7 +48,6 @@ describe('GameRuntime', () => { // Setup clean store useGameStore.setState({ log: [], - storyPanelOpen: false, storyHasUnread: false, storyLog: [], prefs: getPrefs(), @@ -160,7 +159,6 @@ describe('GameRuntime', () => { // Force play panel and closed/no unread story store.setActivePanel('play'); - store.setStoryPanelOpen(false); store.setStoryHasUnread(false); runtime.continueStory(); @@ -169,7 +167,7 @@ describe('GameRuntime', () => { // Verifies it advances to fork_choice expect(updated.storyLog.some((entry) => entry.nodeId === 'fork_choice')).toBe(true); // Verifies it opens panel and does not set unread (since it's open) - expect(updated.storyPanelOpen).toBe(true); + expect(updated.activePanel).toBe('story'); expect(updated.storyHasUnread).toBe(false); }); @@ -179,7 +177,6 @@ describe('GameRuntime', () => { // Force play panel and closed/no unread story store.setActivePanel('play'); - store.setStoryPanelOpen(false); store.setStoryHasUnread(false); runtime.continueStory(); @@ -188,7 +185,7 @@ describe('GameRuntime', () => { // Verifies it advances to fork_choice expect(updated.storyLog.some((entry) => entry.nodeId === 'fork_choice')).toBe(true); // Verifies it does NOT open panel and sets unread flag to true - expect(updated.storyPanelOpen).toBe(false); + expect(updated.activePanel).toBe('play'); expect(updated.storyHasUnread).toBe(true); }); @@ -249,18 +246,6 @@ describe('GameRuntime', () => { } }); - it('delegates openStoryPanel and closeStoryPanel to setActivePanel', () => { - // Force activePanel back to play first - runtime.setActivePanel('play'); - expect(useGameStore.getState().activePanel).toBe('play'); - - runtime.openStoryPanel(); - expect(useGameStore.getState().activePanel).toBe('story'); - - runtime.closeStoryPanel(); - expect(useGameStore.getState().activePanel).toBe('play'); - }); - it('registers lifecycle listeners on boot and removes them on stop', () => { const addSpyDoc = vi.spyOn(document, 'addEventListener'); const removeSpyDoc = vi.spyOn(document, 'removeEventListener'); diff --git a/src/state/runtime.ts b/src/state/runtime.ts index aae47e6..015dcb8 100644 --- a/src/state/runtime.ts +++ b/src/state/runtime.ts @@ -159,7 +159,6 @@ export class GameRuntime { for (const entry of entries) store.appendStoryLog(entry); const choiceLabel = entries.at(-1)?.choiceLabel; if (choiceLabel) store.appendLog(`Story: ${choiceLabel}`); - store.setStoryPanelOpen(false); this.runPublishTriggers(); this.publish(); } catch (err) { @@ -180,14 +179,6 @@ export class GameRuntime { } } - openStoryPanel(): void { - this.setActivePanel('story'); - } - - closeStoryPanel(): void { - this.setActivePanel('play'); - } - continueStory(): void { const state = this.state; if (!state) return; @@ -203,16 +194,14 @@ export class GameRuntime { } const prefs = store.prefs; if (shouldAutoNavigateToStory(prefs, 'fork_choice', content)) { - store.setStoryPanelOpen(true); - store.setStoryHasUnread(false); + this.setActivePanel('story'); } else { store.setStoryHasUnread(true); - store.setStoryPanelOpen(false); } this.publish(); return; } - this.closeStoryPanel(); + this.setActivePanel('play'); this.publish(); } @@ -229,7 +218,6 @@ export class GameRuntime { } else { store.setStoryHasUnread(true); } - store.setStoryPanelOpen(true); } else if (effect.enteredNodeIds.length > 0) { store.setStoryHasUnread(true); } diff --git a/src/state/store.ts b/src/state/store.ts index 297d2c0..850ad63 100644 --- a/src/state/store.ts +++ b/src/state/store.ts @@ -20,20 +20,16 @@ export type ActivePanel = 'play' | 'story' | 'settings' | 'about'; export interface GameStoreState extends GameView { log: string[]; - storyPanelOpen: boolean; storyHasUnread: boolean; storyLog: StoryLogEntry[]; prefs: GamePrefs; - settingsOpen: boolean; activePanel: ActivePanel; selectedStoryNodeId: string | null; setView: (view: GameView) => void; appendLog: (line: string) => void; appendStoryLog: (entry: StoryLogEntry) => void; - setStoryPanelOpen: (open: boolean) => void; setStoryHasUnread: (unread: boolean) => void; setPrefs: (partial: Partial) => void; - setSettingsOpen: (open: boolean) => void; setActivePanel: (panel: ActivePanel) => void; setSelectedStoryNodeId: (id: string | null) => void; toggleActionGroupCollapsed: (groupKey: string) => void; @@ -50,23 +46,19 @@ export const useGameStore = create((set, get) => ({ story: { currentProse: null, choices: [], tree: [] }, actionColumns: [], log: [], - storyPanelOpen: false, storyHasUnread: false, storyLog: [], prefs: getPrefs(), - settingsOpen: false, activePanel: 'play', selectedStoryNodeId: null, setView: (view) => set((state) => ({ ...state, ...view })), appendLog: (line) => set((state) => ({ log: [...state.log, line].slice(-MAX_LOG_LINES) })), appendStoryLog: (entry) => set((state) => ({ storyLog: [...state.storyLog, entry] })), - setStoryPanelOpen: (open) => set({ storyPanelOpen: open }), setStoryHasUnread: (unread) => set({ storyHasUnread: unread }), setPrefs: (partial) => { const prefs = persistPrefs(partial); set({ prefs }); }, - setSettingsOpen: (open) => set({ settingsOpen: open }), setActivePanel: (panel) => set({ activePanel: panel }), setSelectedStoryNodeId: (id) => set({ selectedStoryNodeId: id }), toggleActionGroupCollapsed: (groupKey) => { -- 2.54.0 From bbded8a266e901f8c4a28ba5000d76c64005bedf Mon Sep 17 00:00:00 2001 From: ginnoir Date: Thu, 11 Jun 2026 21:32:13 -0500 Subject: [PATCH 18/26] feat(ui): action columns with collapsible groups --- src/state/runtime.ts | 4 ++ src/ui/ActionCard.tsx | 131 ++++++++++++++++++++++++++++++++++++++++ src/ui/ActionColumn.tsx | 23 +++++++ src/ui/ActionGroup.tsx | 35 +++++++++++ src/ui/ActionPanel.tsx | 112 ---------------------------------- src/ui/PlayPanel.tsx | 50 ++++++++++++++- 6 files changed, 241 insertions(+), 114 deletions(-) create mode 100644 src/ui/ActionCard.tsx create mode 100644 src/ui/ActionColumn.tsx create mode 100644 src/ui/ActionGroup.tsx delete mode 100644 src/ui/ActionPanel.tsx diff --git a/src/state/runtime.ts b/src/state/runtime.ts index 015dcb8..fd0c1dc 100644 --- a/src/state/runtime.ts +++ b/src/state/runtime.ts @@ -135,6 +135,10 @@ export class GameRuntime { } } + toggleActionGroupCollapsed(groupKey: string): void { + useGameStore.getState().toggleActionGroupCollapsed(groupKey); + } + enqueueAction(actionId: string): void { this.performAction(actionId); } diff --git a/src/ui/ActionCard.tsx b/src/ui/ActionCard.tsx new file mode 100644 index 0000000..bf00255 --- /dev/null +++ b/src/ui/ActionCard.tsx @@ -0,0 +1,131 @@ +import { useState } from 'react'; +import { gameRuntime } from '../state/runtime'; +import { useGameStore } from '../state/store'; +import type { ActionView } from '../state/viewModel'; + +interface ActionCardProps { + action: ActionView; +} + +export function ActionCard({ action }: ActionCardProps) { + const activeActionId = useGameStore((s) => s.activeActionId); + const actionProgress = useGameStore((s) => s.actionProgress); + const prefs = useGameStore((s) => s.prefs); + const [isOpen, setIsOpen] = useState(false); + + const isActive = action.id === activeActionId; + const isDisabled = !action.available && !isActive; + + const summaryParts = [ + action.costsSummary ? `Cost: ${action.costsSummary}` : null, + action.yieldsSummary ? `Yield: ${action.yieldsSummary}` : null, + ].filter(Boolean); + + let borderBgClass = ''; + if (isDisabled) { + borderBgClass = 'border-slate-800 bg-slate-900/40 opacity-50 cursor-not-allowed'; + } else { + borderBgClass = + 'border-slate-700 bg-slate-800/70 hover:border-amber-500/60 hover:bg-slate-800 cursor-pointer'; + if (action.kind === 'story') { + borderBgClass = + 'border-amber-500/50 bg-slate-800/70 hover:border-amber-500/80 hover:bg-slate-800 cursor-pointer'; + } else if (action.kind === 'loop' && action.loopEnabled) { + borderBgClass = + 'border-amber-500 bg-amber-500/10 shadow-[0_0_8px_rgba(245,158,11,0.15)] hover:border-amber-400 hover:bg-amber-500/15 cursor-pointer'; + } + } + + return ( +

+ + {prefs.actionDetailMode === 'info-button' && action.storyTooltip ? ( +
+ + {isOpen ? ( +
+ {action.storyTooltip} +
+ ) : null} +
+ ) : null} +
+ ); +} diff --git a/src/ui/ActionColumn.tsx b/src/ui/ActionColumn.tsx new file mode 100644 index 0000000..ee7229c --- /dev/null +++ b/src/ui/ActionColumn.tsx @@ -0,0 +1,23 @@ +import type { ActionGroupView } from '../state/viewModel'; +import { ActionGroup } from './ActionGroup'; + +interface ActionColumnProps { + label: string; + groups: ActionGroupView[]; + actionKind: string; +} + +export function ActionColumn({ label, groups, actionKind }: ActionColumnProps) { + return ( +
+

+ {label} +

+
+ {groups.map((group) => ( + + ))} +
+
+ ); +} diff --git a/src/ui/ActionGroup.tsx b/src/ui/ActionGroup.tsx new file mode 100644 index 0000000..3974a80 --- /dev/null +++ b/src/ui/ActionGroup.tsx @@ -0,0 +1,35 @@ +import { gameRuntime } from '../state/runtime'; +import { useGameStore } from '../state/store'; +import type { ActionGroupView } from '../state/viewModel'; +import { ActionCard } from './ActionCard'; + +interface ActionGroupProps { + group: ActionGroupView; + actionKind: string; +} + +export function ActionGroup({ group, actionKind }: ActionGroupProps) { + const collapsed = useGameStore( + (s) => !!s.prefs.collapsedActionGroups[`${actionKind}:${group.id}`], + ); + + return ( +
+ + {!collapsed && ( +
+ {group.actions.map((action) => ( + + ))} +
+ )} +
+ ); +} diff --git a/src/ui/ActionPanel.tsx b/src/ui/ActionPanel.tsx deleted file mode 100644 index 5dd2f37..0000000 --- a/src/ui/ActionPanel.tsx +++ /dev/null @@ -1,112 +0,0 @@ -import { useState } from 'react'; -import { gameRuntime } from '../state/runtime'; -import { useGameStore } from '../state/store'; - -/** Action list with queue, cancel, disabled states, and story hints/tooltips. */ -export function ActionPanel() { - const actions = useGameStore((s) => s.actions); - const activeActionId = useGameStore((s) => s.activeActionId); - const actionProgress = useGameStore((s) => s.actionProgress); - const queuedActionIds = useGameStore((s) => s.queuedActionIds); - const queuedActionNames = useGameStore((s) => s.queuedActionNames); - const prefs = useGameStore((s) => s.prefs); - const [openInfoId, setOpenInfoId] = useState(null); - - return ( -
-

Actions

- {actions.map((action) => { - const isActive = action.id === activeActionId; - const isDisabled = !action.available && !isActive; - const summaryParts = [ - action.costsSummary ? `Cost: ${action.costsSummary}` : null, - action.yieldsSummary ? `Yield: ${action.yieldsSummary}` : null, - ].filter(Boolean); - - return ( -
- - {prefs.actionDetailMode === 'info-button' && action.storyTooltip ? ( -
- - {openInfoId === action.id ? ( -
- {action.storyTooltip} -
- ) : null} -
- ) : null} -
- ); - })} - {queuedActionNames.length > 0 ? ( -
    - {queuedActionNames.map((name, index) => ( -
  1. - {name} - -
  2. - ))} -
- ) : null} -
- ); -} diff --git a/src/ui/PlayPanel.tsx b/src/ui/PlayPanel.tsx index 788e26b..a151c12 100644 --- a/src/ui/PlayPanel.tsx +++ b/src/ui/PlayPanel.tsx @@ -1,14 +1,60 @@ -import { ActionPanel } from './ActionPanel'; +import { gameRuntime } from '../state/runtime'; +import { useGameStore } from '../state/store'; +import { ActionColumn } from './ActionColumn'; import { EventLog } from './EventLog'; import { ResourceBar } from './ResourceBar'; export function PlayPanel() { + const columns = useGameStore((s) => s.actionColumns).filter( + (col) => col.groups.length > 0 && col.groups.some((g) => g.actions.length > 0), + ); + const queuedActionIds = useGameStore((s) => s.queuedActionIds); + const queuedActionNames = useGameStore((s) => s.queuedActionNames); + return (
- + +
+ {columns.map((col) => ( + + ))} +
+ + {queuedActionNames.length > 0 ? ( +
+

+ Action Queue +

+
    + {queuedActionNames.map((name, index) => ( +
  1. + {name} + +
  2. + ))} +
+
+ ) : null} +
-- 2.54.0 From 75273a76de37fec665549ade7517334b96ccde92 Mon Sep 17 00:00:00 2001 From: ginnoir Date: Thu, 11 Jun 2026 21:34:46 -0500 Subject: [PATCH 19/26] refactor(ui): improve action card accessibility, flex layout and scroll styling --- src/index.css | 16 ++++++++++++++++ src/ui/ActionCard.tsx | 34 ++++++++++++++++++++++++++-------- 2 files changed, 42 insertions(+), 8 deletions(-) diff --git a/src/index.css b/src/index.css index 33a8804..c1fbf4c 100644 --- a/src/index.css +++ b/src/index.css @@ -10,3 +10,19 @@ body { min-height: 100dvh; background-color: #020617; /* slate-950 */ } + +/* Custom Scrollbar for action columns */ +.overflow-x-auto::-webkit-scrollbar { + height: 6px; +} +.overflow-x-auto::-webkit-scrollbar-track { + background: rgba(15, 23, 42, 0.3); + border-radius: 9999px; +} +.overflow-x-auto::-webkit-scrollbar-thumb { + background: rgba(100, 116, 139, 0.4); + border-radius: 9999px; +} +.overflow-x-auto::-webkit-scrollbar-thumb:hover { + background: rgba(245, 158, 11, 0.5); +} diff --git a/src/ui/ActionCard.tsx b/src/ui/ActionCard.tsx index bf00255..9b5ef1e 100644 --- a/src/ui/ActionCard.tsx +++ b/src/ui/ActionCard.tsx @@ -1,4 +1,4 @@ -import { useState } from 'react'; +import { useEffect, useRef, useState } from 'react'; import { gameRuntime } from '../state/runtime'; import { useGameStore } from '../state/store'; import type { ActionView } from '../state/viewModel'; @@ -11,7 +11,21 @@ export function ActionCard({ action }: ActionCardProps) { const activeActionId = useGameStore((s) => s.activeActionId); const actionProgress = useGameStore((s) => s.actionProgress); const prefs = useGameStore((s) => s.prefs); - const [isOpen, setIsOpen] = useState(false); + const [openInfoId, setOpenInfoId] = useState(null); + const containerRef = useRef(null); + + useEffect(() => { + function handleClickOutside(event: MouseEvent) { + if (containerRef.current && !containerRef.current.contains(event.target as Node)) { + setOpenInfoId(null); + } + } + + document.addEventListener('click', handleClickOutside); + return () => { + document.removeEventListener('click', handleClickOutside); + }; + }, []); const isActive = action.id === activeActionId; const isDisabled = !action.available && !isActive; @@ -36,14 +50,17 @@ export function ActionCard({ action }: ActionCardProps) { } } + const tooltipId = `tooltip-${action.id}`; + return ( -
+
- {isOpen ? ( + {openInfoId === action.id ? (
+ {/* Show event log */} +
+ +
); -- 2.54.0 From 4d508ae77c81183c0f31ee733108171d4a950188 Mon Sep 17 00:00:00 2001 From: ginnoir Date: Thu, 11 Jun 2026 22:31:24 -0500 Subject: [PATCH 24/26] refactor(ui): link event-log disclosure with aria-controls Add aria-controls to the right-rail event-log collapse toggle pointing at the log region, and title-case the settings label for consistency. --- src/ui/RightRail.tsx | 3 ++- src/ui/SettingsPanel.tsx | 2 +- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/src/ui/RightRail.tsx b/src/ui/RightRail.tsx index 30da995..ee4fb0c 100644 --- a/src/ui/RightRail.tsx +++ b/src/ui/RightRail.tsx @@ -31,6 +31,7 @@ export function RightRail() { - {logOpen && } +
{logOpen && }
)}
diff --git a/src/ui/SettingsPanel.tsx b/src/ui/SettingsPanel.tsx index b23a942..33acc67 100644 --- a/src/ui/SettingsPanel.tsx +++ b/src/ui/SettingsPanel.tsx @@ -84,7 +84,7 @@ export function SettingsPanel() {