feat(m1): PR3.1 automation unlock and recipes #19
@@ -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