diff --git a/docs/architecture.md b/docs/architecture.md index 11f0083..6564181 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -82,6 +82,39 @@ progression is driven by Story-kind actions. The full design lives in `docs/superpowers/specs/2026-06-11-m1-pr3-shell-ui-design.md`. +### Automation + +Automation is universal across action kinds once an action has at least its +configured number of successful completions (`automation.unlockAfterManualCompletions`, +default `1`). The engine stores those counts in +`GameState.manualCompletionCounts` and stores configured automation in +`GameState.automationQueue`. + +`src/engine/automation.ts` owns unlock checks, queue mutation, and the runner. +The runner preserves precedence: manual active/queued actions first, +automation second, loop-idle actions last. `tickGame` can start automation when +the manual queue exhausts, including during offline catch-up; loop actions still +start only from the live runtime path. + +`src/engine/recipe.ts` serializes automation queues as shareable text using +content action ids. The v1 multiline format is: + +```text +idlegame-recipe/v1 +# name: Camp loop +gather_supplies +rest +``` + +The single-line alias is: + +```text +idlegame-recipe/v1:gather_supplies,rest +``` + +Import rejects unknown action ids and actions that are not automation-unlocked +for the current save. + ## Verification Run the full local gate before pushing: diff --git a/src/engine/__tests__/automation.test.ts b/src/engine/__tests__/automation.test.ts new file mode 100644 index 0000000..a439a2b --- /dev/null +++ b/src/engine/__tests__/automation.test.ts @@ -0,0 +1,242 @@ +import { describe, expect, it } from 'vitest'; +import { buildContent } from '../../content/schema'; +import { + addToAutomationQueue, + automationUnlockThreshold, + clearAutomationQueue, + isAutomationUnlocked, + maybeRunAutomation, + removeFromAutomationQueue, +} from '../automation'; +import { createGameState, enqueueAction, tickGame } 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 }], + }, + { + id: 'train', + name: 'Train', + group: DEFAULT_GROUP, + durationMs: 100, + yields: [{ resourceId: 'renown', amount: 1 }], + automation: { unlockAfterManualCompletions: 2 }, + }, + ], + }); +} + +describe('automationUnlockThreshold()', () => { + it('returns the action-specific automation threshold when configured', () => { + const content = automationContent(); + + expect(automationUnlockThreshold(content, 'train')).toBe(2); + }); + + it('defaults to one manual completion when automation config is absent', () => { + const content = automationContent(); + + expect(automationUnlockThreshold(content, 'survey')).toBe(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('throws RangeError when removing a non-integer index', () => { + const content = automationContent(); + const state = createGameState(content); + state.automationQueue = ['forage']; + + expect(() => removeFromAutomationQueue(state, 0.5)).toThrow(RangeError); + expect(state.automationQueue).toEqual(['forage']); + }); + + it('empties the queue', () => { + const content = automationContent(); + const state = createGameState(content); + state.automationQueue = ['forage', 'survey']; + + clearAutomationQueue(state); + + 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/__tests__/game.test.ts b/src/engine/__tests__/game.test.ts index e4f7e77..9f97768 100644 --- a/src/engine/__tests__/game.test.ts +++ b/src/engine/__tests__/game.test.ts @@ -11,6 +11,7 @@ import { executeInstant, maybeStartLoopAction, performAction, + recordManualCompletion, startAction, tickGame, } from '../game'; @@ -104,6 +105,11 @@ describe('createGameState()', () => { expect(state.actionElapsedMs).toBe(0); expect(state.actionQueue).toEqual([]); }); + + it('initializes empty manual completion counts', () => { + const state = createGameState(testContent()); + expect(state.manualCompletionCounts).toEqual({}); + }); }); describe('createGameState() story fields', () => { @@ -351,6 +357,24 @@ describe('tickGame() completion result', () => { const result = tickGame(state, content, 100); expect(result.completedActionIds).toEqual([]); }); + + it('increments manualCompletionCounts when a timed action completes', () => { + const content = testContent(); + const state = createGameState(content); + enqueueAction(state, content, 'forage'); + tickGame(state, content, 300); + expect(state.manualCompletionCounts.forage).toBe(1); + }); +}); + +describe('recordManualCompletion()', () => { + it('increments known content actions only', () => { + const content = testContent(); + const state = createGameState(content); + recordManualCompletion(state, content, 'forage'); + recordManualCompletion(state, content, 'missing'); + expect(state.manualCompletionCounts).toEqual({ forage: 1 }); + }); }); describe('tickGame()', () => { @@ -417,6 +441,7 @@ describe('executeInstant()', () => { expect(state.resources.supplies).toBe(1); expect(state.activeActionId).toBeNull(); expect(state.actionQueue).toEqual([]); + expect(state.manualCompletionCounts.buy_supply).toBe(1); }); it('throws when unaffordable', () => { @@ -493,6 +518,7 @@ describe('performAction()', () => { enterStoryNode(state, gameContent, 'fork_choice'); const events = performAction(state, gameContent, 'pick_high_road'); expect(state.storyFlags.route_a).toBe(true); + expect(state.manualCompletionCounts.pick_high_road).toBe(1); expect(events.length).toBeGreaterThan(0); expect(events[0].kind).toBe('enter'); }); diff --git a/src/engine/__tests__/recipe.test.ts b/src/engine/__tests__/recipe.test.ts new file mode 100644 index 0000000..3bbb264 --- /dev/null +++ b/src/engine/__tests__/recipe.test.ts @@ -0,0 +1,75 @@ +import { describe, expect, it } from 'vitest'; +import { buildContent } from '../../content/schema'; +import { createGameState } from '../game'; +import { exportRecipe, importRecipe, RECIPE_HEADER_V1 } from '../recipe'; + +const DEFAULT_GROUP = { id: 'camp', label: 'Camp' }; + +function recipeContent() { + return buildContent({ + resources: [{ id: 'supplies', name: 'Supplies' }], + actions: [ + { + id: 'gather_supplies', + name: 'Gather', + kind: 'timed', + group: DEFAULT_GROUP, + durationMs: 1000, + yields: [{ resourceId: 'supplies', amount: 1 }], + }, + { + id: 'rest', + name: 'Rest', + kind: 'loop', + group: DEFAULT_GROUP, + durationMs: 1000, + yields: [{ resourceId: 'supplies', amount: 1 }], + }, + ], + }); +} + +describe('recipe export/import', () => { + it('round-trips multi-line format', () => { + const content = recipeContent(); + const state = createGameState(content); + state.manualCompletionCounts = { gather_supplies: 1, rest: 1 }; + state.automationQueue = ['gather_supplies', 'rest']; + + const text = exportRecipe(state, content, { name: 'Camp loop' }); + + expect(text).toContain(RECIPE_HEADER_V1); + expect(text).toContain('gather_supplies'); + + const fresh = createGameState(content); + fresh.manualCompletionCounts = { gather_supplies: 1, rest: 1 }; + importRecipe(fresh, content, text); + + expect(fresh.automationQueue).toEqual(['gather_supplies', 'rest']); + }); + + it('rejects unknown action ids', () => { + const content = recipeContent(); + const state = createGameState(content); + + expect(() => importRecipe(state, content, `${RECIPE_HEADER_V1}\nnot_real`)).toThrow(/unknown/i); + }); + + it('rejects locked action ids', () => { + const content = recipeContent(); + const state = createGameState(content); + const text = `${RECIPE_HEADER_V1}\ngather_supplies`; + + expect(() => importRecipe(state, content, text)).toThrow(/locked/i); + }); + + it('parses single-line alias', () => { + const content = recipeContent(); + const state = createGameState(content); + state.manualCompletionCounts.gather_supplies = 1; + + importRecipe(state, content, `${RECIPE_HEADER_V1}:gather_supplies`); + + expect(state.automationQueue).toEqual(['gather_supplies']); + }); +}); diff --git a/src/engine/__tests__/save.test.ts b/src/engine/__tests__/save.test.ts index 25e9401..58d6db5 100644 --- a/src/engine/__tests__/save.test.ts +++ b/src/engine/__tests__/save.test.ts @@ -79,6 +79,22 @@ describe('createSave()', () => { state.enabledLoopActionIds.rest = false; expect(save.state.enabledLoopActionIds).toEqual({ rest: true }); }); + + it('snapshots manualCompletionCounts and automationQueue in the save payload', () => { + const state = sampleState() as ReturnType & { + manualCompletionCounts: Record; + automationQueue: string[]; + }; + state.manualCompletionCounts = { forage: 2 }; + state.automationQueue = ['forage']; + const save = createSave(state, 1700); + expect(save.state.manualCompletionCounts).toEqual({ forage: 2 }); + expect(save.state.automationQueue).toEqual(['forage']); + state.manualCompletionCounts.forage = 3; + state.automationQueue.push('forage'); + expect(save.state.manualCompletionCounts).toEqual({ forage: 2 }); + expect(save.state.automationQueue).toEqual(['forage']); + }); }); describe('serialize / deserialize round-trip', () => { @@ -101,6 +117,18 @@ describe('serialize / deserialize round-trip', () => { expect(restored.state.enabledLoopActionIds).toEqual({ rest: true, patrol: false }); }); + it('preserves manualCompletionCounts and automationQueue through a round-trip', () => { + const state = sampleState() as ReturnType & { + manualCompletionCounts: Record; + automationQueue: string[]; + }; + state.manualCompletionCounts = { forage: 4 }; + state.automationQueue = ['forage']; + const restored = deserializeSave(serializeSave(createSave(state, 1700))); + expect(restored.state.manualCompletionCounts).toEqual({ forage: 4 }); + expect(restored.state.automationQueue).toEqual(['forage']); + }); + it('defaults enabledLoopActionIds to {} when absent from save JSON', () => { const json = JSON.stringify({ version: 1, @@ -114,6 +142,21 @@ describe('serialize / deserialize round-trip', () => { const restored = deserializeSave(json); expect(restored.state.enabledLoopActionIds).toEqual({}); }); + + it('defaults manualCompletionCounts and automationQueue when absent from save JSON', () => { + const json = JSON.stringify({ + version: 1, + savedAt: 1700, + state: { + resources: { gold: 0 }, + activeActionId: null, + actionElapsedMs: 0, + }, + }); + const restored = deserializeSave(json); + expect(restored.state.manualCompletionCounts).toEqual({}); + expect(restored.state.automationQueue).toEqual([]); + }); }); describe('invalid / tampered saves', () => { diff --git a/src/engine/automation.ts b/src/engine/automation.ts new file mode 100644 index 0000000..3f916e9 --- /dev/null +++ b/src/engine/automation.ts @@ -0,0 +1,86 @@ +import type { Content } from '../content/schema'; +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]) { + 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 (!Number.isInteger(index) || 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; +} + +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 eeb3da4..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; @@ -28,6 +29,10 @@ export interface GameState { seenStoryNodeIds: string[]; /** loop-kind action id -> whether the player has enabled it for idle running. */ enabledLoopActionIds: Record; + /** action id -> number of successful player-enabled completions. */ + manualCompletionCounts: Record; + /** Action ids configured for future automation repeat. */ + automationQueue: string[]; } export function createGameState(content: Content): GameState { @@ -44,6 +49,8 @@ export function createGameState(content: Content): GameState { currentStoryNodeId: '', seenStoryNodeIds: [], enabledLoopActionIds: {}, + manualCompletionCounts: {}, + automationQueue: [], }; } @@ -97,7 +104,12 @@ function grantYields(state: GameState, content: Content, actionId: string): void } } -function beginAction(state: GameState, content: Content, actionId: string): void { +export function recordManualCompletion(state: GameState, content: Content, actionId: string): void { + if (!content.actionsById[actionId]) return; + state.manualCompletionCounts[actionId] = (state.manualCompletionCounts[actionId] ?? 0) + 1; +} + +export function beginAction(state: GameState, content: Content, actionId: string): void { assertKnownAction(content, actionId); deductCosts(state, content, actionId); state.activeActionId = actionId; @@ -117,6 +129,7 @@ function startNextFromQueue(state: GameState, content: Content): void { } state.activeActionId = null; state.actionElapsedMs = 0; + maybeRunAutomation(state, content); } export interface TickResult { @@ -178,6 +191,7 @@ export function executeInstant(state: GameState, content: Content, actionId: str } deductCosts(state, content, actionId); grantYields(state, content, actionId); + recordManualCompletion(state, content, actionId); } /** @@ -197,7 +211,9 @@ export function executeStoryAction( if (!isStoryChoiceAvailable(state, content, action.storyChoiceId)) { throw new Error(`Story choice "${action.storyChoiceId}" is not available`); } - return applyChoice(state, content, action.storyChoiceId); + const events = applyChoice(state, content, action.storyChoiceId); + recordManualCompletion(state, content, actionId); + return events; } /** @@ -273,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) { @@ -285,6 +304,7 @@ export function tickGame(state: GameState, content: Content, tickMs: number): Ti state.actionElapsedMs -= action.durationMs; grantYields(state, content, actionId); + recordManualCompletion(state, content, actionId); completedActionIds.push(actionId); startNextFromQueue(state, content); } diff --git a/src/engine/recipe.ts b/src/engine/recipe.ts new file mode 100644 index 0000000..5fbdeb6 --- /dev/null +++ b/src/engine/recipe.ts @@ -0,0 +1,79 @@ +import type { Content } from '../content/schema'; +import { addToAutomationQueue, clearAutomationQueue, isAutomationUnlocked } from './automation'; +import type { GameState } from './game'; + +export const RECIPE_HEADER_V1 = 'idlegame-recipe/v1'; + +const ACTION_ID_RE = /^[a-z][a-z0-9_]*$/; + +export interface RecipeMeta { + name?: string; +} + +export function exportRecipe(state: GameState, content: Content, meta?: RecipeMeta): string { + const lines = [RECIPE_HEADER_V1]; + if (meta?.name) lines.push(`# name: ${meta.name}`); + + for (const actionId of state.automationQueue) { + if (content.actionsById[actionId]) { + lines.push(actionId); + } + } + + return lines.join('\n'); +} + +export function parseRecipeLines(text: string): { name?: string; actionIds: string[] } { + const trimmed = text.trim(); + if (trimmed.startsWith(`${RECIPE_HEADER_V1}:`)) { + const actionIds = trimmed + .slice(RECIPE_HEADER_V1.length + 1) + .split(',') + .map((s) => s.trim()) + .filter(Boolean); + return { actionIds }; + } + + const lines = trimmed.split(/\r?\n/); + if (lines[0]?.trim() !== RECIPE_HEADER_V1) { + throw new Error(`Invalid recipe header; expected "${RECIPE_HEADER_V1}"`); + } + + let name: string | undefined; + const actionIds: string[] = []; + for (let i = 1; i < lines.length; i += 1) { + const line = lines[i]?.trim() ?? ''; + if (!line) continue; + if (line.startsWith('# name:')) { + name = line.slice('# name:'.length).trim(); + continue; + } + if (line.startsWith('#')) continue; + if (!ACTION_ID_RE.test(line)) { + throw new Error(`Invalid action id "${line}"`); + } + actionIds.push(line); + } + + return { name, actionIds }; +} + +export function importRecipe(state: GameState, content: Content, text: string): { name?: string } { + const { name, actionIds } = parseRecipeLines(text); + const unknown = actionIds.filter((id) => !content.actionsById[id]); + if (unknown.length > 0) { + throw new Error(`Unknown action ids: ${unknown.join(', ')}`); + } + + const locked = actionIds.filter((id) => !isAutomationUnlocked(state, content, id)); + if (locked.length > 0) { + throw new Error(`Automation locked for: ${locked.join(', ')}`); + } + + clearAutomationQueue(state); + for (const actionId of actionIds) { + addToAutomationQueue(state, content, actionId); + } + + return { name }; +} diff --git a/src/engine/save.ts b/src/engine/save.ts index 6d125d0..ee538e6 100644 --- a/src/engine/save.ts +++ b/src/engine/save.ts @@ -33,6 +33,8 @@ export const gameStateSchema = z.object({ currentStoryNodeId: z.string().default(''), seenStoryNodeIds: z.array(z.string()).default([]), enabledLoopActionIds: z.record(z.string(), z.boolean()).default({}), + manualCompletionCounts: z.record(z.string(), z.number()).default({}), + automationQueue: z.array(z.string()).default([]), }); export const saveSchema = z.object({ @@ -57,6 +59,8 @@ export function createSave(state: GameState, now: number): SaveData { currentStoryNodeId: state.currentStoryNodeId, seenStoryNodeIds: [...state.seenStoryNodeIds], enabledLoopActionIds: { ...state.enabledLoopActionIds }, + manualCompletionCounts: { ...state.manualCompletionCounts }, + automationQueue: [...state.automationQueue], }, }; } diff --git a/src/state/__tests__/persistence.test.ts b/src/state/__tests__/persistence.test.ts index 2103567..f1492ce 100644 --- a/src/state/__tests__/persistence.test.ts +++ b/src/state/__tests__/persistence.test.ts @@ -111,6 +111,8 @@ describe('loadGame()', () => { currentStoryNodeId: '', seenStoryNodeIds: [], enabledLoopActionIds: {}, + manualCompletionCounts: {}, + automationQueue: [], }, 1000, ), 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/__tests__/viewModel.test.ts b/src/state/__tests__/viewModel.test.ts index 157d2a5..8d2d467 100644 --- a/src/state/__tests__/viewModel.test.ts +++ b/src/state/__tests__/viewModel.test.ts @@ -239,6 +239,39 @@ describe('action columns projection', () => { expect(rest?.loopEnabled).toBe(true); }); + it('marks automationUnlocked on actions after manual completion', () => { + const state = createGameState(content); + state.manualCompletionCounts.gather_supplies = 1; + const view = toView(state, content); + const gather = view.actionColumns + .flatMap((c) => c.groups) + .flatMap((g) => g.actions) + .find((a) => a.id === 'gather_supplies'); + + expect(gather?.automationUnlocked).toBe(true); + }); + + it('marks actions already in the automation queue', () => { + const state = createGameState(content); + state.automationQueue = ['gather_supplies']; + const view = toView(state, content); + const gather = view.actionColumns + .flatMap((c) => c.groups) + .flatMap((g) => g.actions) + .find((a) => a.id === 'gather_supplies'); + + expect(gather?.inAutomationQueue).toBe(true); + }); + + it('projects automation queue ids and display names', () => { + const state = createGameState(content); + state.automationQueue = ['gather_supplies', 'missing_action']; + const view = toView(state, content); + + expect(view.automationQueueIds).toEqual(['gather_supplies', 'missing_action']); + expect(view.automationQueueNames).toEqual(['Gather supplies', 'missing_action']); + }); + it('shows story actions only when their choice is available, hiding siblings after a fork is taken', () => { const state = createGameState(content); // before reaching the fork, story actions are hidden diff --git a/src/state/persistence.ts b/src/state/persistence.ts index f036a32..5f0f94a 100644 --- a/src/state/persistence.ts +++ b/src/state/persistence.ts @@ -107,6 +107,8 @@ export async function loadGame( currentStoryNodeId: save.state.currentStoryNodeId ?? '', seenStoryNodeIds: [...(save.state.seenStoryNodeIds ?? [])], enabledLoopActionIds: { ...(save.state.enabledLoopActionIds ?? {}) }, + manualCompletionCounts: { ...(save.state.manualCompletionCounts ?? {}) }, + automationQueue: [...(save.state.automationQueue ?? [])], }; savedAt = save.savedAt; } catch { 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) { diff --git a/src/state/store.ts b/src/state/store.ts index 90e010e..154a49b 100644 --- a/src/state/store.ts +++ b/src/state/store.ts @@ -42,6 +42,8 @@ export const useGameStore = create((set, get) => ({ actionProgress: 0, queuedActionIds: [], queuedActionNames: [], + automationQueueIds: [], + automationQueueNames: [], actions: [], story: { currentProse: null, atBootIntro: false, choices: [], tree: [] }, actionColumns: [], diff --git a/src/state/viewModel.ts b/src/state/viewModel.ts index c49e35a..473caa1 100644 --- a/src/state/viewModel.ts +++ b/src/state/viewModel.ts @@ -1,4 +1,5 @@ import type { GameContent } from '../content/index'; +import { isAutomationUnlocked } from '../engine/automation'; import { canAffordAction, canUnlockAction, @@ -40,6 +41,8 @@ export interface ActionView { yieldsSummary: string | null; kind: ActionColumnKind; loopEnabled: boolean; + automationUnlocked: boolean; + inAutomationQueue: boolean; } export interface ActionGroupView { @@ -84,6 +87,8 @@ export interface GameView { actionProgress: number; queuedActionIds: string[]; queuedActionNames: string[]; + automationQueueIds: string[]; + automationQueueNames: string[]; actions: ActionView[]; story: StoryView; actionColumns: ActionColumnView[]; @@ -153,6 +158,8 @@ export function toView(state: GameState, content: GameContent): GameView { : 0; const queuedActionIds = [...state.actionQueue]; const queuedActionNames = queuedActionIds.map((id) => content.actionsById[id]?.name ?? id); + const automationQueueIds = [...state.automationQueue]; + const automationQueueNames = automationQueueIds.map((id) => content.actionsById[id]?.name ?? id); // Build a map of ActionView by id for column assembly const actionViewMap = new Map(); @@ -172,6 +179,8 @@ export function toView(state: GameState, content: GameContent): GameView { yieldsSummary: formatResourceList(a.yields, content), kind: a.kind, loopEnabled: !!state.enabledLoopActionIds[a.id], + automationUnlocked: isAutomationUnlocked(state, content, a.id), + inAutomationQueue: state.automationQueue.includes(a.id), }; actionViewMap.set(a.id, view); return view; @@ -243,6 +252,8 @@ export function toView(state: GameState, content: GameContent): GameView { actionProgress, queuedActionIds, queuedActionNames, + automationQueueIds, + automationQueueNames, actions, story, actionColumns, diff --git a/src/ui/ActionCard.tsx b/src/ui/ActionCard.tsx index cf8f2e4..395d43a 100644 --- a/src/ui/ActionCard.tsx +++ b/src/ui/ActionCard.tsx @@ -148,6 +148,22 @@ export function ActionCard({ action }: ActionCardProps) { ) : null} ) : null} + {action.automationUnlocked ? ( + + ) : null} ); } diff --git a/src/ui/AutomationBar.tsx b/src/ui/AutomationBar.tsx new file mode 100644 index 0000000..531112d --- /dev/null +++ b/src/ui/AutomationBar.tsx @@ -0,0 +1,88 @@ +import { useState } from 'react'; +import { gameRuntime } from '../state/runtime'; +import { useGameStore } from '../state/store'; + +export function AutomationBar() { + const automationQueueIds = useGameStore((s) => s.automationQueueIds); + const automationQueueNames = useGameStore((s) => s.automationQueueNames); + const [importText, setImportText] = useState(''); + + async function copyRecipe() { + const text = gameRuntime.exportAutomationRecipe(); + try { + await navigator.clipboard?.writeText(text); + return; + } catch { + // Fall through to the legacy selection path. + } + + const textarea = document.createElement('textarea'); + textarea.value = text; + textarea.setAttribute('readonly', 'true'); + textarea.style.position = 'fixed'; + textarea.style.opacity = '0'; + document.body.append(textarea); + textarea.select(); + document.execCommand('copy'); + textarea.remove(); + } + + function importRecipeText() { + gameRuntime.importAutomationRecipe(importText); + setImportText(''); + } + + return ( +
+
+

+ Automation +

+ +
+ + {automationQueueNames.length > 0 ? ( +
    + {automationQueueNames.map((name, index) => ( +
  1. + {index + 1}. {name} +
  2. + ))} +
+ ) : ( +

No automated processes.

+ )} + +
+