feat(engine): automation recipe export and import
This commit is contained in:
@@ -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']);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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 };
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user