From 6140823c9922cc55883fcc16481f58b21d4dfc6d Mon Sep 17 00:00:00 2001 From: root Date: Sun, 8 Mar 2026 23:22:19 +0000 Subject: [PATCH] feat(systems): add craftingSystem with input consumption and output production Co-Authored-By: Claude Opus 4.6 --- .../systems/__tests__/craftingSystem.test.ts | 105 ++++++++++++++++++ server/src/systems/craftingSystem.ts | 44 ++++++++ 2 files changed, 149 insertions(+) create mode 100644 server/src/systems/__tests__/craftingSystem.test.ts create mode 100644 server/src/systems/craftingSystem.ts diff --git a/server/src/systems/__tests__/craftingSystem.test.ts b/server/src/systems/__tests__/craftingSystem.test.ts new file mode 100644 index 0000000..263ee96 --- /dev/null +++ b/server/src/systems/__tests__/craftingSystem.test.ts @@ -0,0 +1,105 @@ +import { describe, it, expect } from 'vitest'; +import { World } from '../../ecs/World.js'; +import { craftingSystem, type CraftingState } from '../craftingSystem.js'; +import type { Needs, Movement, NPCBrain, Position, Stats, StatModifiers } from '@dflike/shared'; +import { PRODUCTIVITY_RECOVERY_RATE } from '@dflike/shared'; +import { RecipeRegistry } from '../../industry/recipeRegistry.js'; + +function createCraftingWorld(): World { + const world = new World(); + world.setSingleton('recipeRegistry', RecipeRegistry.createDefault()); + return world; +} + +function createCraftingNPC(world: World, overrides?: { dexterity?: number; productivity?: number }) { + const e = world.createEntity(); + world.addComponent(e, 'position', { x: 5, y: 5 }); + world.addComponent(e, 'needs', { hunger: 80, energy: 80, productivity: overrides?.productivity ?? 30 }); + world.addComponent(e, 'movement', { state: 'idle', target: null, path: [], direction: 0, moveProgress: 0 }); + world.addComponent(e, 'npcBrain', { currentGoal: 'craft', goalQueue: [] }); + world.addComponent>(e, 'inventory', new Map([['log', 5], ['stone', 3]])); + const dex = overrides?.dexterity ?? 10; + world.addComponent(e, 'stats', { + strength: 10, dexterity: dex, constitution: 10, intelligence: 10, perception: 10, + sociability: 10, courage: 10, curiosity: 10, empathy: 10, temperament: 10, + }); + world.addComponent(e, 'statModifiers', { modifiers: [] }); + return e; +} + +describe('craftingSystem', () => { + it('decrements timer each tick while crafting', () => { + const world = createCraftingWorld(); + const e = createCraftingNPC(world); + world.addComponent(e, 'craftingState', { + recipeId: 'craft_wooden_axe', + ticksRemaining: 40, + }); + craftingSystem(world); + const cs = world.getComponent(e, 'craftingState')!; + expect(cs.ticksRemaining).toBe(39); + }); + + it('consumes inputs and produces output on completion', () => { + const world = createCraftingWorld(); + const e = createCraftingNPC(world); + world.addComponent(e, 'craftingState', { + recipeId: 'craft_wooden_axe', + ticksRemaining: 1, + }); + craftingSystem(world); + const inv = world.getComponent>(e, 'inventory')!; + expect(inv.get('wooden_axe')).toBe(1); + expect(inv.get('log')).toBe(3); // 5 - 2 + expect(inv.get('stone')).toBe(2); // 3 - 1 + }); + + it('clears craftingState on completion', () => { + const world = createCraftingWorld(); + const e = createCraftingNPC(world); + world.addComponent(e, 'craftingState', { + recipeId: 'craft_wooden_axe', + ticksRemaining: 1, + }); + craftingSystem(world); + expect(world.getComponent(e, 'craftingState')).toBeUndefined(); + }); + + it('recovers productivity while crafting', () => { + const world = createCraftingWorld(); + const e = createCraftingNPC(world, { productivity: 30 }); + world.addComponent(e, 'craftingState', { + recipeId: 'craft_wooden_axe', + ticksRemaining: 10, + }); + craftingSystem(world); + const needs = world.getComponent(e, 'needs')!; + expect(needs.productivity).toBeCloseTo(30 + PRODUCTIVITY_RECOVERY_RATE); + }); + + it('skips NPC whose goal is not craft', () => { + const world = createCraftingWorld(); + const e = createCraftingNPC(world); + const brain = world.getComponent(e, 'npcBrain')!; + brain.currentGoal = 'wander'; + world.addComponent(e, 'craftingState', { + recipeId: 'craft_wooden_axe', + ticksRemaining: 10, + }); + craftingSystem(world); + const cs = world.getComponent(e, 'craftingState')!; + expect(cs.ticksRemaining).toBe(10); + }); + + it('resets goal to wander when productivity satisfied after crafting', () => { + const world = createCraftingWorld(); + const e = createCraftingNPC(world, { productivity: 70 }); + world.addComponent(e, 'craftingState', { + recipeId: 'craft_wooden_axe', + ticksRemaining: 1, + }); + craftingSystem(world); + const brain = world.getComponent(e, 'npcBrain')!; + expect(brain.currentGoal).toBe('wander'); + }); +}); diff --git a/server/src/systems/craftingSystem.ts b/server/src/systems/craftingSystem.ts new file mode 100644 index 0000000..0eaba9c --- /dev/null +++ b/server/src/systems/craftingSystem.ts @@ -0,0 +1,44 @@ +import { + PRODUCTIVITY_RECOVERY_RATE, PRODUCTIVITY_THRESHOLD, + type Needs, type NPCBrain, +} from '@dflike/shared'; +import type { World } from '../ecs/World.js'; +import { addItem, removeItem } from '../industry/inventoryHelpers.js'; +import { RecipeRegistry } from '../industry/recipeRegistry.js'; + +export interface CraftingState { + recipeId: string; + ticksRemaining: number; +} + +export function craftingSystem(world: World): void { + for (const entity of world.query('npcBrain', 'needs', 'inventory')) { + const brain = world.getComponent(entity, 'npcBrain')!; + if (brain.currentGoal !== 'craft') continue; + + const cs = world.getComponent(entity, 'craftingState'); + if (!cs) continue; + + const needs = world.getComponent(entity, 'needs')!; + const inv = world.getComponent>(entity, 'inventory')!; + + cs.ticksRemaining--; + needs.productivity = Math.min(100, needs.productivity + PRODUCTIVITY_RECOVERY_RATE); + + if (cs.ticksRemaining <= 0) { + const registry = world.getSingleton('recipeRegistry'); + const recipe = registry?.get(cs.recipeId); + if (recipe) { + for (const input of recipe.inputs) { + removeItem(inv, input.itemId, input.quantity); + } + addItem(inv, recipe.outputItemId, recipe.outputQuantity); + } + world.removeComponent(entity, 'craftingState'); + + if (needs.productivity >= PRODUCTIVITY_THRESHOLD) { + brain.currentGoal = 'wander'; + } + } + } +}