From e2a44bec7d2258afd940705d2c01196e08805368 Mon Sep 17 00:00:00 2001 From: ginnoir Date: Fri, 12 Jun 2026 00:47:19 -0500 Subject: [PATCH] feat(engine): automation recipe export and import --- src/engine/__tests__/recipe.test.ts | 75 +++++++++++++++++++++++++++ src/engine/recipe.ts | 79 +++++++++++++++++++++++++++++ 2 files changed, 154 insertions(+) create mode 100644 src/engine/__tests__/recipe.test.ts create mode 100644 src/engine/recipe.ts 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/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 }; +}