import { PRODUCTIVITY_THRESHOLD, type Desire, 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 type { PickupState } from './pickupSystem.js'; import { RecipeRegistry, type Recipe } from '../industry/recipeRegistry.js'; import { ItemRegistry } from '../industry/itemRegistry.js'; import { getEffectiveStat } from './statHelpers.js'; import { industryConfig } from '../config/industryConfig.js'; function scoreCraftableRecipes(recipes: Recipe[], desires: Desire[], itemRegistry?: ItemRegistry): Recipe { if (recipes.length === 1 || desires.length === 0) return recipes[0]; let bestRecipe = recipes[0]; let bestScore = 0; for (const recipe of recipes) { let score = 1; // base score for (const desire of desires) { const f = desire.fulfillment; if (f.type === 'own_item' && f.itemId === recipe.outputItemId) { score += desire.priority * 10; } else if (f.type === 'own_item_category' && itemRegistry) { const outputDef = itemRegistry.get(recipe.outputItemId); if (outputDef && outputDef.category === f.category) { score += desire.priority * 5; } } } if (score > bestScore) { bestScore = score; bestRecipe = recipe; } } return bestRecipe; } 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 === 'sleep') continue; if (brain.currentGoal === 'dropoff') continue; if (brain.currentGoal === 'pickup') 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 desires = world.getComponent(entity, 'desires') ?? []; const itemRegistry = world.getSingleton('itemRegistry'); const recipe = scoreCraftableRecipes(craftRecipes, desires, itemRegistry); 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; // Try pickup from stockpile for crafting let didPickup = false; const allCraftRecipes = registry.getAll().filter(r => !r.id.startsWith('build_')); for (const recipe of allCraftRecipes) { const neededItems: { itemId: string; quantity: number }[] = []; for (const input of recipe.inputs) { const haveInInv = inv.get(input.itemId) ?? 0; const stillNeed = input.quantity - haveInInv; if (stillNeed > 0) { neededItems.push({ itemId: input.itemId, quantity: stillNeed }); } } if (neededItems.length === 0) continue; // NPC can already craft — handled above // Find a stockpile that has all needed items let stockpileEntity: number | null = null; for (const sEntity of world.query('structure', 'position')) { const sData = world.getComponent(sEntity, 'structure')!; if (sData.type !== 'stockpile' || sData.buildProgress !== undefined) continue; const hasAll = neededItems.every(n => (sData.inventory.get(n.itemId) ?? 0) >= n.quantity); if (hasAll) { stockpileEntity = sEntity; break; } } if (stockpileEntity !== null) { brain.currentGoal = 'pickup'; world.addComponent(entity, 'pickupState', { stockpileEntityId: stockpileEntity, items: neededItems, }); didPickup = true; break; } } if (didPickup) continue; // Fallback: gather brain.currentGoal = 'gather'; } }