From f3591e68d7254767f514a98c12870bef3eff7ba7 Mon Sep 17 00:00:00 2001 From: root Date: Tue, 10 Mar 2026 00:07:09 +0000 Subject: [PATCH] feat: industrySystem scores recipes by desire match Adds scoreCraftableRecipes helper that scores recipes based on NPC desires (priority * 10 for own_item match). Falls back to first available when no desires match. Co-Authored-By: Claude Opus 4.6 --- .../systems/__tests__/industrySystem.test.ts | 33 +++++++++++++++++++ server/src/systems/industrySystem.ts | 32 ++++++++++++++++-- 2 files changed, 62 insertions(+), 3 deletions(-) diff --git a/server/src/systems/__tests__/industrySystem.test.ts b/server/src/systems/__tests__/industrySystem.test.ts index 0e17a10..6efe77a 100644 --- a/server/src/systems/__tests__/industrySystem.test.ts +++ b/server/src/systems/__tests__/industrySystem.test.ts @@ -117,4 +117,37 @@ describe('industrySystem', () => { const brain = world.getComponent(e, 'npcBrain')!; expect(brain.currentGoal).toBe('craft'); }); + + describe('desire-based recipe scoring', () => { + it('prefers recipe matching a desire over other craftable recipes', () => { + const { world, map } = createIndustryWorld(); + const inv = new Map([['log', 4], ['stone', 2]]); + const entity = createIndustryNPC(world, 5, 5, inv); + world.addComponent(entity, 'desires', [{ + id: 'd1', description: 'wants a hammer', category: 'material', + fulfillment: { type: 'own_item', itemId: 'hammer', quantity: 1 }, + priority: 0.8, source: 'spawn', createdAtTick: 0, + }]); + industrySystem(world, map); + const brain = world.getComponent(entity, 'npcBrain')!; + expect(brain.currentGoal).toBe('craft'); + const craftingState = world.getComponent(entity, 'craftingState'); + expect(craftingState!.recipeId).toBe('craft_hammer'); + }); + + it('falls back to first available when no desires match', () => { + const { world, map } = createIndustryWorld(); + const inv = new Map([['log', 4], ['stone', 2]]); + const entity = createIndustryNPC(world, 5, 5, inv); + world.addComponent(entity, 'desires', [{ + id: 'd1', description: 'wants a house', category: 'shelter', + fulfillment: { type: 'building_exists', buildingType: 'house' }, + priority: 0.8, source: 'spawn', createdAtTick: 0, + }]); + industrySystem(world, map); + const brain = world.getComponent(entity, 'npcBrain')!; + expect(brain.currentGoal).toBe('craft'); + // Should still craft something (either axe or hammer) + }); + }); }); diff --git a/server/src/systems/industrySystem.ts b/server/src/systems/industrySystem.ts index b315c5b..6794078 100644 --- a/server/src/systems/industrySystem.ts +++ b/server/src/systems/industrySystem.ts @@ -1,6 +1,6 @@ import { PRODUCTIVITY_THRESHOLD, - type Needs, type NPCBrain, type Position, + 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'; @@ -8,10 +8,35 @@ 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 } from '../industry/recipeRegistry.js'; +import { RecipeRegistry, type Recipe } from '../industry/recipeRegistry.js'; import { getEffectiveStat } from './statHelpers.js'; import { industryConfig } from '../config/industryConfig.js'; +function scoreCraftableRecipes(recipes: Recipe[], desires: Desire[]): 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') { + 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' }, @@ -65,7 +90,8 @@ export function industrySystem(world: World, map: GameMap): void { // Try craft first const craftRecipes = registry.findCraftable(inv).filter(r => !r.id.startsWith('build_')); if (craftRecipes.length > 0) { - const recipe = craftRecipes[0]; + const desires = world.getComponent(entity, 'desires') ?? []; + const recipe = scoreCraftableRecipes(craftRecipes, desires); const dex = getEffectiveStat(world, entity, 'dexterity'); const baseTicks = industryConfig.craftBaseTicks; const ticks = Math.max(1, Math.round(baseTicks * (1 - (dex - 10) * industryConfig.craftDexterityModifier)));