feat(m1): PR3.1 automation unlock and recipes #19

Merged
ginnoir merged 9 commits from feat/m1-progression into main 2026-06-12 01:26:51 -05:00
2 changed files with 154 additions and 0 deletions
Showing only changes of commit e2a44bec7d - Show all commits
+75
View File
@@ -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']);
});
});
+79
View File
@@ -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 };
}