diff --git a/src/engine/__tests__/automation.test.ts b/src/engine/__tests__/automation.test.ts new file mode 100644 index 0000000..3fbbfa8 --- /dev/null +++ b/src/engine/__tests__/automation.test.ts @@ -0,0 +1,121 @@ +import { describe, expect, it } from 'vitest'; +import { buildContent } from '../../content/schema'; +import { + addToAutomationQueue, + clearAutomationQueue, + isAutomationUnlocked, + removeFromAutomationQueue, +} from '../automation'; +import { createGameState } from '../game'; + +const DEFAULT_GROUP = { id: 'automation', label: 'Automation' }; + +function automationContent() { + return buildContent({ + resources: [ + { id: 'supplies', name: 'Supplies', startAmount: 0 }, + { id: 'renown', name: 'Renown', startAmount: 0 }, + ], + actions: [ + { + id: 'forage', + name: 'Forage', + group: DEFAULT_GROUP, + durationMs: 100, + yields: [{ resourceId: 'supplies', amount: 1 }], + automation: { unlockAfterManualCompletions: 1 }, + }, + { + id: 'survey', + name: 'Survey', + group: DEFAULT_GROUP, + durationMs: 100, + yields: [{ resourceId: 'renown', amount: 1 }], + }, + ], + }); +} + +describe('isAutomationUnlocked()', () => { + it('returns false before the first manual completion when an action unlocks after one', () => { + const content = automationContent(); + const state = createGameState(content); + + expect(isAutomationUnlocked(state, content, 'forage')).toBe(false); + }); + + it('returns true once the manual completion threshold is met', () => { + const content = automationContent(); + const state = createGameState(content); + state.manualCompletionCounts.forage = 1; + + expect(isAutomationUnlocked(state, content, 'forage')).toBe(true); + }); +}); + +describe('automation queue CRUD', () => { + it('throws for unknown action ids', () => { + const content = automationContent(); + const state = createGameState(content); + + expect(() => addToAutomationQueue(state, content, 'missing')).toThrow( + 'Unknown action "missing"', + ); + }); + + it('throws for locked action ids', () => { + const content = automationContent(); + const state = createGameState(content); + + expect(() => addToAutomationQueue(state, content, 'forage')).toThrow(/automation.*locked/i); + }); + + it('appends an unlocked action id', () => { + const content = automationContent(); + const state = createGameState(content); + state.manualCompletionCounts.forage = 1; + + addToAutomationQueue(state, content, 'forage'); + + expect(state.automationQueue).toEqual(['forage']); + }); + + it('does not duplicate an action already in the queue', () => { + const content = automationContent(); + const state = createGameState(content); + state.manualCompletionCounts.forage = 1; + + addToAutomationQueue(state, content, 'forage'); + addToAutomationQueue(state, content, 'forage'); + + expect(state.automationQueue).toEqual(['forage']); + }); + + it('removes by index', () => { + const content = automationContent(); + const state = createGameState(content); + state.automationQueue = ['forage', 'survey']; + + removeFromAutomationQueue(state, 0); + + expect(state.automationQueue).toEqual(['survey']); + }); + + it('throws RangeError when removing an out-of-range index', () => { + const content = automationContent(); + const state = createGameState(content); + state.automationQueue = ['forage']; + + expect(() => removeFromAutomationQueue(state, 1)).toThrow(RangeError); + }); + + it('empties the queue', () => { + const content = automationContent(); + const state = createGameState(content); + state.automationQueue = ['forage', 'survey']; + + clearAutomationQueue(state); + + expect(state.automationQueue).toEqual([]); + }); +}); diff --git a/src/engine/automation.ts b/src/engine/automation.ts new file mode 100644 index 0000000..08c6969 --- /dev/null +++ b/src/engine/automation.ts @@ -0,0 +1,48 @@ +import type { Content } from '../content/schema'; +import type { GameState } from './game'; + +function assertKnownAction(content: Content, actionId: string): void { + if (!content.actionsById[actionId]) { + throw new Error(`Unknown action "${actionId}"`); + } +} + +export function automationUnlockThreshold(content: Content, actionId: string): number { + const action = content.actionsById[actionId]; + if (!action) { + throw new Error(`Unknown action "${actionId}"`); + } + return action.automation?.unlockAfterManualCompletions ?? 1; +} + +export function isAutomationUnlocked( + state: GameState, + content: Content, + actionId: string, +): boolean { + assertKnownAction(content, actionId); + return ( + (state.manualCompletionCounts[actionId] ?? 0) >= automationUnlockThreshold(content, actionId) + ); +} + +export function addToAutomationQueue(state: GameState, content: Content, actionId: string): void { + assertKnownAction(content, actionId); + if (!isAutomationUnlocked(state, content, actionId)) { + throw new Error(`Automation for action "${actionId}" is locked`); + } + if (!state.automationQueue.includes(actionId)) { + state.automationQueue.push(actionId); + } +} + +export function removeFromAutomationQueue(state: GameState, index: number): void { + if (index < 0 || index >= state.automationQueue.length) { + throw new RangeError(`Automation queue index ${index} is out of range`); + } + state.automationQueue.splice(index, 1); +} + +export function clearAutomationQueue(state: GameState): void { + state.automationQueue.length = 0; +}