feat(state): runtime automation and recipe commands

This commit is contained in:
ginnoir
2026-06-12 00:51:17 -05:00
parent 4b5372cdf9
commit 29bc4d4bc5
2 changed files with 90 additions and 0 deletions
+46
View File
@@ -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();
+44
View File
@@ -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) {