Files
dflike/server/src/systems/industrySystem.ts
T
root a8f27a869d fix: emit desire_fulfilled events, populate LLM context, fix category scoring
- desireFulfillmentSystem records memory events when desires are fulfilled
- desireGeneratorSystem populates recentEvents and recentInventions template vars
- industrySystem own_item_category scoring now checks recipe output category

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-10 00:14:35 +00:00

194 lines
7.3 KiB
TypeScript

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<string, { type: 'stockpile' | 'workshop'; subtype: string }> = {
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<StructureData>(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>('recipeRegistry');
if (!registry) return;
for (const entity of world.query('npcBrain', 'needs', 'inventory', 'position')) {
const needs = world.getComponent<Needs>(entity, 'needs')!;
if (needs.productivity >= PRODUCTIVITY_THRESHOLD) continue;
const brain = world.getComponent<NPCBrain>(entity, 'npcBrain')!;
// Skip if already actively doing something productive
if (brain.currentGoal === 'craft' && world.getComponent<CraftingState>(entity, 'craftingState')) continue;
if (brain.currentGoal === 'build' && world.getComponent<BuildingState>(entity, 'buildingState')) continue;
if (brain.currentGoal === 'gather' && world.getComponent<GatheringState>(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<Map<string, number>>(entity, 'inventory')!;
const pos = world.getComponent<Position>(entity, 'position')!;
// Try craft first
const craftRecipes = registry.findCraftable(inv).filter(r => !r.id.startsWith('build_'));
if (craftRecipes.length > 0) {
const desires = world.getComponent<Desire[]>(entity, 'desires') ?? [];
const itemRegistry = world.getSingleton<ItemRegistry>('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<CraftingState>(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<Position>(structureEntity, 'position', buildPos);
world.addComponent<StructureData>(structureEntity, 'structure', {
type: mapping.type,
subtype: mapping.subtype,
inventory: new Map(),
buildProgress: 0,
builderEntityId: entity,
});
brain.currentGoal = 'build';
world.addComponent<BuildingState>(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<StructureData>(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<PickupState>(entity, 'pickupState', {
stockpileEntityId: stockpileEntity,
items: neededItems,
});
didPickup = true;
break;
}
}
if (didPickup) continue;
// Fallback: gather
brain.currentGoal = 'gather';
}
}