From a7ecf2225b974b8773667d92864a67136cff71d2 Mon Sep 17 00:00:00 2001 From: ginnoir Date: Fri, 12 Jun 2026 00:45:27 -0500 Subject: [PATCH] feat(engine): automation runner with manual-first precedence --- src/engine/__tests__/automation.test.ts | 91 ++++++++++++++++++++++++- src/engine/automation.ts | 40 ++++++++++- src/engine/game.ts | 9 ++- 3 files changed, 136 insertions(+), 4 deletions(-) diff --git a/src/engine/__tests__/automation.test.ts b/src/engine/__tests__/automation.test.ts index 8839051..a439a2b 100644 --- a/src/engine/__tests__/automation.test.ts +++ b/src/engine/__tests__/automation.test.ts @@ -5,9 +5,10 @@ import { automationUnlockThreshold, clearAutomationQueue, isAutomationUnlocked, + maybeRunAutomation, removeFromAutomationQueue, } from '../automation'; -import { createGameState } from '../game'; +import { createGameState, enqueueAction, tickGame } from '../game'; const DEFAULT_GROUP = { id: 'automation', label: 'Automation' }; @@ -151,3 +152,91 @@ describe('automation queue CRUD', () => { expect(state.automationQueue).toEqual([]); }); }); + +function runnerContent() { + return buildContent({ + resources: [ + { id: 'supplies', name: 'Supplies', startAmount: 0 }, + { id: 'coin', name: 'Coin', startAmount: 2 }, + ], + actions: [ + { + id: 'gather', + name: 'Gather', + group: DEFAULT_GROUP, + durationMs: 100, + yields: [{ resourceId: 'supplies', amount: 1 }], + }, + { + id: 'buy', + name: 'Buy', + kind: 'instant', + group: DEFAULT_GROUP, + costs: [{ resourceId: 'coin', amount: 1 }], + yields: [{ resourceId: 'supplies', amount: 1 }], + }, + { + id: 'rest', + name: 'Rest', + kind: 'loop', + group: DEFAULT_GROUP, + durationMs: 100, + loopPriority: 0, + yields: [{ resourceId: 'supplies', amount: 1 }], + }, + ], + }); +} + +describe('maybeRunAutomation()', () => { + it('starts the first affordable automation action when manual queue is idle', () => { + const content = runnerContent(); + const state = createGameState(content); + state.manualCompletionCounts.gather = 1; + state.automationQueue = ['gather']; + + maybeRunAutomation(state, content); + + expect(state.activeActionId).toBe('gather'); + }); + + it('does not run when the manual queue has items', () => { + const content = runnerContent(); + const state = createGameState(content); + state.actionQueue = ['gather']; + state.manualCompletionCounts.gather = 1; + state.automationQueue = ['gather']; + + maybeRunAutomation(state, content); + + expect(state.activeActionId).toBeNull(); + }); + + it('executes affordable instant automation and advances to the next candidate', () => { + const content = runnerContent(); + const state = createGameState(content); + state.manualCompletionCounts.buy = 1; + state.manualCompletionCounts.gather = 1; + state.automationQueue = ['buy', 'gather']; + + maybeRunAutomation(state, content); + + expect(state.resources.coin).toBe(1); + expect(state.resources.supplies).toBe(1); + expect(state.activeActionId).toBe('gather'); + }); + + it('starts automation before enabled loop actions after manual queue exhausts', () => { + const content = runnerContent(); + const state = createGameState(content); + state.manualCompletionCounts.gather = 1; + state.automationQueue = ['gather']; + state.enabledLoopActionIds.rest = true; + enqueueAction(state, content, 'gather'); + + tickGame(state, content, 100); + + expect(state.activeActionId).toBe('gather'); + expect(state.enabledLoopActionIds.rest).toBe(true); + }); +}); diff --git a/src/engine/automation.ts b/src/engine/automation.ts index e618e38..3f916e9 100644 --- a/src/engine/automation.ts +++ b/src/engine/automation.ts @@ -1,5 +1,14 @@ import type { Content } from '../content/schema'; -import type { GameState } from './game'; +import type { StoryContent } from '../content/storySchema'; +import { + beginAction, + executeInstant, + executeStoryAction, + type GameState, + isActionAvailable, +} from './game'; + +type GameContent = Content & StoryContent; function assertKnownAction(content: Content, actionId: string): void { if (!content.actionsById[actionId]) { @@ -46,3 +55,32 @@ export function removeFromAutomationQueue(state: GameState, index: number): void export function clearAutomationQueue(state: GameState): void { state.automationQueue.length = 0; } + +export function maybeRunAutomation(state: GameState, content: Content): void { + if (state.activeActionId !== null || state.actionQueue.length > 0) return; + + for (const actionId of state.automationQueue) { + if (!isAutomationUnlocked(state, content, actionId)) continue; + if (!isActionAvailable(state, content, actionId)) continue; + + const action = content.actionsById[actionId]; + if (!action) continue; + + switch (action.kind) { + case 'instant': + executeInstant(state, content, actionId); + continue; + case 'timed': + case 'loop': + beginAction(state, content, actionId); + return; + case 'story': + executeStoryAction(state, content as GameContent, actionId); + return; + case 'context': + continue; + default: + continue; + } + } +} diff --git a/src/engine/game.ts b/src/engine/game.ts index 74aedda..471d246 100644 --- a/src/engine/game.ts +++ b/src/engine/game.ts @@ -1,5 +1,6 @@ import type { Content } from '../content/schema'; import type { StoryContent } from '../content/storySchema'; +import { maybeRunAutomation } from './automation'; import { applyChoice, isStoryChoiceAvailable, type StoryEvent } from './story'; type GameContent = Content & StoryContent; @@ -108,7 +109,7 @@ export function recordManualCompletion(state: GameState, content: Content, actio state.manualCompletionCounts[actionId] = (state.manualCompletionCounts[actionId] ?? 0) + 1; } -function beginAction(state: GameState, content: Content, actionId: string): void { +export function beginAction(state: GameState, content: Content, actionId: string): void { assertKnownAction(content, actionId); deductCosts(state, content, actionId); state.activeActionId = actionId; @@ -128,6 +129,7 @@ function startNextFromQueue(state: GameState, content: Content): void { } state.activeActionId = null; state.actionElapsedMs = 0; + maybeRunAutomation(state, content); } export interface TickResult { @@ -287,7 +289,10 @@ export function performAction( */ export function tickGame(state: GameState, content: Content, tickMs: number): TickResult { const completedActionIds: string[] = []; - if (!state.activeActionId) return { completedActionIds }; + if (!state.activeActionId) { + maybeRunAutomation(state, content); + if (!state.activeActionId) return { completedActionIds }; + } state.actionElapsedMs += tickMs; while (state.activeActionId) {