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}`