diff --git a/src/state/__tests__/runtime.test.ts b/src/state/__tests__/runtime.test.ts index 008c3cb..7536e4e 100644 --- a/src/state/__tests__/runtime.test.ts +++ b/src/state/__tests__/runtime.test.ts @@ -1,9 +1,15 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { content } from '../../content'; +import type { GameState } from '../../engine/game'; +import { RECIPE_HEADER_V1 } from '../../engine/recipe'; import { getPrefs } from '../prefs'; import { GameRuntime } from '../runtime'; import { useGameStore } from '../store'; +function runtimeState(runtime: GameRuntime): GameState { + return (runtime as unknown as { state: GameState }).state; +} + describe('GameRuntime', () => { let runtime: GameRuntime; @@ -78,6 +84,46 @@ describe('GameRuntime', () => { expect(view.actions.find((a) => a.id === 'rest')?.loopEnabled).toBe(true); }); + it('toggles an unlocked action in and out of the automation queue', () => { + const state = runtimeState(runtime); + state.manualCompletionCounts.gather_supplies = 1; + + runtime.toggleAutomation('gather_supplies'); + + expect(useGameStore.getState().automationQueueIds).toEqual(['gather_supplies']); + + runtime.toggleAutomation('gather_supplies'); + + expect(useGameStore.getState().automationQueueIds).toEqual([]); + }); + + it('logs automation toggle errors for locked actions', () => { + runtime.toggleAutomation('gather_supplies'); + + expect(useGameStore.getState().log.at(-1)).toMatch(/automation.*locked/i); + }); + + it('exports the automation recipe text', () => { + const state = runtimeState(runtime); + state.manualCompletionCounts.gather_supplies = 1; + state.automationQueue = ['gather_supplies']; + + const text = runtime.exportAutomationRecipe(); + + expect(text).toContain(RECIPE_HEADER_V1); + expect(text).toContain('gather_supplies'); + }); + + it('imports an automation recipe and publishes the queue', () => { + const state = runtimeState(runtime); + state.manualCompletionCounts.gather_supplies = 1; + + runtime.importAutomationRecipe(`${RECIPE_HEADER_V1}:gather_supplies`); + + expect(useGameStore.getState().automationQueueIds).toEqual(['gather_supplies']); + expect(useGameStore.getState().log.at(-1)).toMatch(/imported recipe/i); + }); + it('performs story action and appends story log', () => { // Let's first move state to fork_choice node where story choices are available runtime.continueStory(); diff --git a/src/state/runtime.ts b/src/state/runtime.ts index ead630f..9ce4b6b 100644 --- a/src/state/runtime.ts +++ b/src/state/runtime.ts @@ -1,4 +1,9 @@ import { content } from '../content'; +import { + addToAutomationQueue, + maybeRunAutomation, + removeFromAutomationQueue, +} from '../engine/automation'; import { cancelQueuedAction as engineCancelQueuedAction, type GameState, @@ -6,6 +11,7 @@ import { performAction as performActionEngine, tickGame, } from '../engine/game'; +import { exportRecipe, importRecipe } from '../engine/recipe'; 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'; @@ -147,6 +153,44 @@ export class GameRuntime { this.performAction(actionId); } + toggleAutomation(actionId: string): void { + const state = this.state; + if (!state) return; + try { + const existingIndex = state.automationQueue.indexOf(actionId); + if (existingIndex >= 0) { + removeFromAutomationQueue(state, existingIndex); + } else { + addToAutomationQueue(state, content, actionId); + maybeRunAutomation(state, content); + } + this.publish(); + } catch (err) { + useGameStore.getState().appendLog(err instanceof Error ? err.message : 'Automation failed'); + } + } + + exportAutomationRecipe(): string { + const state = this.state; + if (!state) return ''; + return exportRecipe(state, content); + } + + importAutomationRecipe(text: string): void { + const state = this.state; + if (!state) return; + try { + const meta = importRecipe(state, content, text); + maybeRunAutomation(state, content); + useGameStore + .getState() + .appendLog(meta.name ? `Imported recipe: ${meta.name}` : 'Imported recipe.'); + this.publish(); + } catch (err) { + useGameStore.getState().appendLog(err instanceof Error ? err.message : 'Import failed'); + } + } + applyStoryChoice(choiceId: string): void { const action = content.actions.find((a) => a.storyChoiceId === choiceId); if (action) {