From e0e5c6615eda43742302db2a9a6e8d6d0df07454 Mon Sep 17 00:00:00 2001 From: root Date: Sun, 8 Mar 2026 23:25:39 +0000 Subject: [PATCH] feat(systems): add industrySystem with craft/build/gather decision tree Co-Authored-By: Claude Opus 4.6 --- .../systems/__tests__/industrySystem.test.ts | 120 ++++++++++++++++++ server/src/systems/industrySystem.ts | 120 ++++++++++++++++++ 2 files changed, 240 insertions(+) create mode 100644 server/src/systems/__tests__/industrySystem.test.ts create mode 100644 server/src/systems/industrySystem.ts diff --git a/server/src/systems/__tests__/industrySystem.test.ts b/server/src/systems/__tests__/industrySystem.test.ts new file mode 100644 index 0000000..0e17a10 --- /dev/null +++ b/server/src/systems/__tests__/industrySystem.test.ts @@ -0,0 +1,120 @@ +import { describe, it, expect } from 'vitest'; +import { World } from '../../ecs/World.js'; +import { GameMap } from '../../map/GameMap.js'; +import { industrySystem } from '../industrySystem.js'; +import type { CraftingState } from '../craftingSystem.js'; +import type { BuildingState, StructureData } from '../buildingSystem.js'; +import type { Needs, Movement, NPCBrain, Position, Stats, StatModifiers } from '@dflike/shared'; +import { RecipeRegistry } from '../../industry/recipeRegistry.js'; + +function createIndustryWorld(): { world: World; map: GameMap } { + const world = new World(); + world.setSingleton('recipeRegistry', RecipeRegistry.createDefault()); + const map = new GameMap(20, 20); + map.resourceTiles = [{ x: 3, y: 3, resourceType: 'log' }]; + return { world, map }; +} + +function createIndustryNPC(world: World, x: number, y: number, inv?: Map, overrides?: { intelligence?: number; productivity?: number }) { + const e = world.createEntity(); + world.addComponent(e, 'position', { x, y }); + 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: 'wander', goalQueue: [] }); + world.addComponent>(e, 'inventory', inv ?? new Map()); + const int = overrides?.intelligence ?? 10; + world.addComponent(e, 'stats', { + strength: 10, dexterity: 10, constitution: 10, intelligence: int, perception: 10, + sociability: 10, courage: 10, curiosity: 10, empathy: 10, temperament: 10, + }); + world.addComponent(e, 'statModifiers', { modifiers: [] }); + return e; +} + +describe('industrySystem', () => { + it('sets goal to craft when NPC has materials for a recipe', () => { + const { world, map } = createIndustryWorld(); + const inv = new Map([['log', 4], ['stone', 2]]); + const e = createIndustryNPC(world, 5, 5, inv); + industrySystem(world, map); + const brain = world.getComponent(e, 'npcBrain')!; + expect(brain.currentGoal).toBe('craft'); + const cs = world.getComponent(e, 'craftingState'); + expect(cs).toBeDefined(); + expect(cs!.ticksRemaining).toBeGreaterThan(0); + }); + + it('sets goal to build when NPC has materials for a build recipe and no structure exists', () => { + const { world, map } = createIndustryWorld(); + const inv = new Map([['log', 4]]); + const e = createIndustryNPC(world, 5, 5, inv); + industrySystem(world, map); + const brain = world.getComponent(e, 'npcBrain')!; + expect(brain.currentGoal).toBe('build'); + const bs = world.getComponent(e, 'buildingState'); + expect(bs).toBeDefined(); + }); + + it('sets goal to gather when NPC has no materials for any recipe', () => { + const { world, map } = createIndustryWorld(); + const e = createIndustryNPC(world, 5, 5); + industrySystem(world, map); + const brain = world.getComponent(e, 'npcBrain')!; + expect(brain.currentGoal).toBe('gather'); + }); + + it('skips NPC whose productivity is above threshold', () => { + const { world, map } = createIndustryWorld(); + const e = createIndustryNPC(world, 5, 5, undefined, { productivity: 80 }); + industrySystem(world, map); + const brain = world.getComponent(e, 'npcBrain')!; + expect(brain.currentGoal).toBe('wander'); + }); + + it('skips NPC already crafting', () => { + const { world, map } = createIndustryWorld(); + const inv = new Map([['log', 4], ['stone', 2]]); + const e = createIndustryNPC(world, 5, 5, inv); + const brain = world.getComponent(e, 'npcBrain')!; + brain.currentGoal = 'craft'; + world.addComponent(e, 'craftingState', { recipeId: 'craft_wooden_axe', ticksRemaining: 10 }); + industrySystem(world, map); + const cs = world.getComponent(e, 'craftingState')!; + expect(cs.ticksRemaining).toBe(10); + }); + + it('skips NPC already building', () => { + const { world, map } = createIndustryWorld(); + const e = createIndustryNPC(world, 5, 5); + const brain = world.getComponent(e, 'npcBrain')!; + brain.currentGoal = 'build'; + world.addComponent(e, 'buildingState', { structureEntityId: 99, recipeId: 'build_stockpile' }); + industrySystem(world, map); + expect(brain.currentGoal).toBe('build'); + }); + + it('does not build a stockpile when one already exists', () => { + const { world, map } = createIndustryWorld(); + const inv = new Map([['log', 4]]); + const e = createIndustryNPC(world, 5, 5, inv); + const existingStockpile = world.createEntity(); + world.addComponent(existingStockpile, 'position', { x: 10, y: 10 }); + world.addComponent(existingStockpile, 'structure', { + type: 'stockpile', + subtype: 'general', + inventory: new Map(), + }); + industrySystem(world, map); + const brain = world.getComponent(e, 'npcBrain')!; + expect(brain.currentGoal).toBe('gather'); + }); + + it('prefers craft over build when both are possible', () => { + const { world, map } = createIndustryWorld(); + const inv = new Map([['log', 6], ['stone', 2]]); + const e = createIndustryNPC(world, 5, 5, inv); + industrySystem(world, map); + const brain = world.getComponent(e, 'npcBrain')!; + expect(brain.currentGoal).toBe('craft'); + }); +}); diff --git a/server/src/systems/industrySystem.ts b/server/src/systems/industrySystem.ts new file mode 100644 index 0000000..c9c062a --- /dev/null +++ b/server/src/systems/industrySystem.ts @@ -0,0 +1,120 @@ +import { + PRODUCTIVITY_THRESHOLD, + type Needs, type NPCBrain, type Position, +} from '@dflike/shared'; +import type { World } from '../ecs/World.js'; +import type { GameMap } from '../map/GameMap.js'; +import type { GatheringState } from './gatheringSystem.js'; +import type { CraftingState } from './craftingSystem.js'; +import type { BuildingState, StructureData } from './buildingSystem.js'; +import { RecipeRegistry } from '../industry/recipeRegistry.js'; +import { getEffectiveStat } from './statHelpers.js'; +import { industryConfig } from '../config/industryConfig.js'; + +const BUILD_RECIPE_MAP: Record = { + stockpile: { type: 'stockpile', subtype: 'general' }, + workbench: { type: 'workshop', subtype: 'workbench' }, +}; + +function structureExists(world: World, structureType: string, subtype: string): boolean { + for (const entity of world.query('structure')) { + const s = world.getComponent(entity, 'structure')!; + if (s.type === structureType && s.subtype === subtype && s.buildProgress === undefined) { + return true; + } + } + return false; +} + +function findNearbyWalkable(map: GameMap, from: Position): Position | null { + for (let r = 1; r <= 3; r++) { + for (let dx = -r; dx <= r; dx++) { + for (let dy = -r; dy <= r; dy++) { + if (Math.abs(dx) !== r && Math.abs(dy) !== r) continue; + const x = from.x + dx; + const y = from.y + dy; + if (map.isWalkable(x, y)) return { x, y }; + } + } + } + return null; +} + +export function industrySystem(world: World, map: GameMap): void { + const registry = world.getSingleton('recipeRegistry'); + if (!registry) return; + + for (const entity of world.query('npcBrain', 'needs', 'inventory', 'position')) { + const needs = world.getComponent(entity, 'needs')!; + if (needs.productivity >= PRODUCTIVITY_THRESHOLD) continue; + + const brain = world.getComponent(entity, 'npcBrain')!; + + // Skip if already actively doing something productive + if (brain.currentGoal === 'craft' && world.getComponent(entity, 'craftingState')) continue; + if (brain.currentGoal === 'build' && world.getComponent(entity, 'buildingState')) continue; + if (brain.currentGoal === 'gather' && world.getComponent(entity, 'gatheringState')) continue; + if (brain.currentGoal === 'gather' || brain.currentGoal === 'eat' || brain.currentGoal === 'rest') continue; + if (brain.currentGoal === 'dropoff') continue; + + const inv = world.getComponent>(entity, 'inventory')!; + const pos = world.getComponent(entity, 'position')!; + + // Try craft first + const craftRecipes = registry.findCraftable(inv).filter(r => !r.id.startsWith('build_')); + if (craftRecipes.length > 0) { + const recipe = craftRecipes[0]; + const dex = getEffectiveStat(world, entity, 'dexterity'); + const baseTicks = industryConfig.craftBaseTicks; + const ticks = Math.max(1, Math.round(baseTicks * (1 - (dex - 10) * industryConfig.craftDexterityModifier))); + brain.currentGoal = 'craft'; + world.addComponent(entity, 'craftingState', { + recipeId: recipe.id, + ticksRemaining: ticks, + }); + continue; + } + + // Try build + const buildRecipes = registry.findCraftable(inv).filter(r => r.id.startsWith('build_')); + let didBuild = false; + for (const recipe of buildRecipes) { + const mapping = BUILD_RECIPE_MAP[recipe.outputItemId]; + if (!mapping) continue; + if (structureExists(world, mapping.type, mapping.subtype)) continue; + + const buildPos = findNearbyWalkable(map, pos); + if (!buildPos) continue; + + // Consume inputs + for (const input of recipe.inputs) { + const current = inv.get(input.itemId) ?? 0; + const remaining = current - input.quantity; + if (remaining <= 0) inv.delete(input.itemId); + else inv.set(input.itemId, remaining); + } + + const structureEntity = world.createEntity(); + world.addComponent(structureEntity, 'position', buildPos); + world.addComponent(structureEntity, 'structure', { + type: mapping.type, + subtype: mapping.subtype, + inventory: new Map(), + buildProgress: 0, + builderEntityId: entity, + }); + + brain.currentGoal = 'build'; + world.addComponent(entity, 'buildingState', { + structureEntityId: structureEntity, + recipeId: recipe.id, + }); + didBuild = true; + break; + } + if (didBuild) continue; + + // Fallback: gather + brain.currentGoal = 'gather'; + } +}