diff --git a/server/src/industry/__tests__/recipeRegistry.test.ts b/server/src/industry/__tests__/recipeRegistry.test.ts new file mode 100644 index 0000000..9ecd584 --- /dev/null +++ b/server/src/industry/__tests__/recipeRegistry.test.ts @@ -0,0 +1,84 @@ +import { describe, it, expect } from 'vitest'; +import { RecipeRegistry, type Recipe } from '../recipeRegistry.js'; + +describe('RecipeRegistry', () => { + it('registers and retrieves a recipe', () => { + const registry = new RecipeRegistry(); + const recipe: Recipe = { + id: 'craft_wooden_axe', + outputItemId: 'wooden_axe', + outputQuantity: 1, + inputs: [{ itemId: 'log', quantity: 2 }, { itemId: 'stone', quantity: 1 }], + source: 'seed', + }; + registry.register(recipe); + expect(registry.get('craft_wooden_axe')).toEqual(recipe); + }); + + it('throws on duplicate registration', () => { + const registry = new RecipeRegistry(); + const recipe: Recipe = { + id: 'test', + outputItemId: 'x', + outputQuantity: 1, + inputs: [], + source: 'seed', + }; + registry.register(recipe); + expect(() => registry.register(recipe)).toThrow(); + }); + + it('returns undefined for unknown recipe', () => { + const registry = new RecipeRegistry(); + expect(registry.get('nonexistent')).toBeUndefined(); + }); + + it('getAll returns all registered recipes', () => { + const registry = new RecipeRegistry(); + registry.register({ id: 'a', outputItemId: 'x', outputQuantity: 1, inputs: [], source: 'seed' }); + registry.register({ id: 'b', outputItemId: 'y', outputQuantity: 1, inputs: [], source: 'seed' }); + expect(registry.getAll()).toHaveLength(2); + }); + + it('findCraftable returns recipes NPC has materials for', () => { + const registry = new RecipeRegistry(); + registry.register({ + id: 'craft_wooden_axe', + outputItemId: 'wooden_axe', + outputQuantity: 1, + inputs: [{ itemId: 'log', quantity: 2 }, { itemId: 'stone', quantity: 1 }], + source: 'seed', + }); + registry.register({ + id: 'craft_hammer', + outputItemId: 'hammer', + outputQuantity: 1, + inputs: [{ itemId: 'log', quantity: 2 }, { itemId: 'stone', quantity: 1 }], + source: 'seed', + }); + const inv = new Map([['log', 3], ['stone', 1]]); + const craftable = registry.findCraftable(inv); + expect(craftable).toHaveLength(2); + }); + + it('findCraftable excludes recipes without enough materials', () => { + const registry = new RecipeRegistry(); + registry.register({ + id: 'craft_wooden_axe', + outputItemId: 'wooden_axe', + outputQuantity: 1, + inputs: [{ itemId: 'log', quantity: 2 }, { itemId: 'stone', quantity: 1 }], + source: 'seed', + }); + const inv = new Map([['log', 1]]); + expect(registry.findCraftable(inv)).toHaveLength(0); + }); + + it('createDefault has seed recipes for axe, hammer, stockpile, workbench', () => { + const registry = RecipeRegistry.createDefault(); + expect(registry.get('craft_wooden_axe')).toBeDefined(); + expect(registry.get('craft_hammer')).toBeDefined(); + expect(registry.get('build_stockpile')).toBeDefined(); + expect(registry.get('build_workbench')).toBeDefined(); + }); +}); diff --git a/server/src/industry/itemRegistry.ts b/server/src/industry/itemRegistry.ts index b02ed81..3ae8d7c 100644 --- a/server/src/industry/itemRegistry.ts +++ b/server/src/industry/itemRegistry.ts @@ -55,6 +55,20 @@ export class ItemRegistry { category: 'resource', source: 'seed', }); + registry.register({ + id: 'wooden_axe', + name: 'Wooden Axe', + description: 'A crude axe made from logs and stone', + category: 'tool', + source: 'seed', + }); + registry.register({ + id: 'hammer', + name: 'Hammer', + description: 'A basic hammer for construction', + category: 'tool', + source: 'seed', + }); return registry; } } diff --git a/server/src/industry/recipeRegistry.ts b/server/src/industry/recipeRegistry.ts new file mode 100644 index 0000000..293de36 --- /dev/null +++ b/server/src/industry/recipeRegistry.ts @@ -0,0 +1,75 @@ +import type { Inventory } from './inventoryHelpers.js'; + +export interface Recipe { + id: string; + outputItemId: string; + outputQuantity: number; + inputs: { itemId: string; quantity: number }[]; + workshopType?: string; // required workshop (undefined = hand-craftable) + toolRequired?: string; // required tool in inventory (undefined = none) + source: 'seed' | 'invented'; +} + +export class RecipeRegistry { + private recipes = new Map(); + + register(recipe: Recipe): void { + if (this.recipes.has(recipe.id)) { + throw new Error(`Recipe '${recipe.id}' already registered`); + } + this.recipes.set(recipe.id, recipe); + } + + get(id: string): Recipe | undefined { + return this.recipes.get(id); + } + + getAll(): Recipe[] { + return [...this.recipes.values()]; + } + + findCraftable(inv: Inventory, workshopType?: string): Recipe[] { + return this.getAll().filter(recipe => { + if (recipe.workshopType && recipe.workshopType !== workshopType) return false; + return recipe.inputs.every(input => (inv.get(input.itemId) ?? 0) >= input.quantity); + }); + } + + static createDefault(): RecipeRegistry { + const registry = new RecipeRegistry(); + + registry.register({ + id: 'craft_wooden_axe', + outputItemId: 'wooden_axe', + outputQuantity: 1, + inputs: [{ itemId: 'log', quantity: 2 }, { itemId: 'stone', quantity: 1 }], + source: 'seed', + }); + + registry.register({ + id: 'craft_hammer', + outputItemId: 'hammer', + outputQuantity: 1, + inputs: [{ itemId: 'log', quantity: 2 }, { itemId: 'stone', quantity: 1 }], + source: 'seed', + }); + + registry.register({ + id: 'build_stockpile', + outputItemId: 'stockpile', + outputQuantity: 1, + inputs: [{ itemId: 'log', quantity: 4 }], + source: 'seed', + }); + + registry.register({ + id: 'build_workbench', + outputItemId: 'workbench', + outputQuantity: 1, + inputs: [{ itemId: 'log', quantity: 3 }, { itemId: 'stone', quantity: 2 }], + source: 'seed', + }); + + return registry; + } +}