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 <noreply@anthropic.com>
This commit is contained in:
@@ -117,4 +117,37 @@ describe('industrySystem', () => {
|
||||
const brain = world.getComponent<NPCBrain>(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<NPCBrain>(entity, 'npcBrain')!;
|
||||
expect(brain.currentGoal).toBe('craft');
|
||||
const craftingState = world.getComponent<CraftingState>(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<NPCBrain>(entity, 'npcBrain')!;
|
||||
expect(brain.currentGoal).toBe('craft');
|
||||
// Should still craft something (either axe or hammer)
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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<string, { type: 'stockpile' | 'workshop'; subtype: string }> = {
|
||||
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<Desire[]>(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)));
|
||||
|
||||
Reference in New Issue
Block a user