a8f27a869d
- 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>
194 lines
7.3 KiB
TypeScript
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';
|
|
}
|
|
}
|