From 9f77a84d6f575401df9399ed6b5dae19d31c320f Mon Sep 17 00:00:00 2001 From: root Date: Mon, 9 Mar 2026 02:52:37 +0000 Subject: [PATCH] feat(server): add pickup system for NPC stockpile material retrieval NPCs can now retrieve crafting materials from stockpiles instead of always gathering raw resources. The industry system checks if a stockpile has needed materials before falling back to gathering. Co-Authored-By: Claude Opus 4.6 --- client/src/ui/NpcInfoPanel.ts | 1 + server/src/game/GameLoop.ts | 2 + server/src/systems/industrySystem.ts | 42 +++++++++++++++++++ server/src/systems/npcBrainSystem.ts | 24 ++++++++++- server/src/systems/pickupSystem.ts | 61 ++++++++++++++++++++++++++++ 5 files changed, 129 insertions(+), 1 deletion(-) create mode 100644 server/src/systems/pickupSystem.ts diff --git a/client/src/ui/NpcInfoPanel.ts b/client/src/ui/NpcInfoPanel.ts index 6fe1c21..dc9159b 100644 --- a/client/src/ui/NpcInfoPanel.ts +++ b/client/src/ui/NpcInfoPanel.ts @@ -8,6 +8,7 @@ const ACTIVITY_LABELS: Record = { craft: 'Crafting', build: 'Building', dropoff: 'Dropping off', + pickup: 'Picking up', }; // EarthBound-inspired color palette diff --git a/server/src/game/GameLoop.ts b/server/src/game/GameLoop.ts index 8c949a1..748c583 100644 --- a/server/src/game/GameLoop.ts +++ b/server/src/game/GameLoop.ts @@ -15,6 +15,7 @@ import { industrySystem } from '../systems/industrySystem.js'; import { craftingSystem } from '../systems/craftingSystem.js'; import { buildingSystem } from '../systems/buildingSystem.js'; import { dropoffSystem } from '../systems/dropoffSystem.js'; +import { pickupSystem } from '../systems/pickupSystem.js'; import { RecipeRegistry } from '../industry/recipeRegistry.js'; import { spawnNPC } from './spawner.js'; import { createLlmService, type LlmService } from '../llm/llmService.js'; @@ -161,6 +162,7 @@ export class GameLoop { narrationEmitter(this.world, this.narrationService, this.followedEntityIds, this.eventMemoryService); relationshipSystem(this.world, this.eventMemoryService); industrySystem(this.world, this.map); + pickupSystem(this.world, this.tick, (entry) => this.onStockpileEvent?.(entry)); gatheringSystem(this.world, this.map); craftingSystem(this.world); buildingSystem(this.world, this.map); diff --git a/server/src/systems/industrySystem.ts b/server/src/systems/industrySystem.ts index c9c062a..44f2086 100644 --- a/server/src/systems/industrySystem.ts +++ b/server/src/systems/industrySystem.ts @@ -7,6 +7,7 @@ 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 } from '../industry/recipeRegistry.js'; import { getEffectiveStat } from './statHelpers.js'; import { industryConfig } from '../config/industryConfig.js'; @@ -56,6 +57,7 @@ export function industrySystem(world: World, map: GameMap): void { if (brain.currentGoal === 'gather' && world.getComponent(entity, 'gatheringState')) continue; if (brain.currentGoal === 'gather' || brain.currentGoal === 'eat' || brain.currentGoal === 'rest') continue; if (brain.currentGoal === 'dropoff') continue; + if (brain.currentGoal === 'pickup') continue; const inv = world.getComponent>(entity, 'inventory')!; const pos = world.getComponent(entity, 'position')!; @@ -114,6 +116,46 @@ export function industrySystem(world: World, map: GameMap): void { } 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'; } diff --git a/server/src/systems/npcBrainSystem.ts b/server/src/systems/npcBrainSystem.ts index 5a87fd0..81ae51e 100644 --- a/server/src/systems/npcBrainSystem.ts +++ b/server/src/systems/npcBrainSystem.ts @@ -9,6 +9,7 @@ import type { EventMemoryService } from '../llm/eventMemoryService.js'; import type { CraftingState } from './craftingSystem.js'; import type { BuildingState, StructureData } from './buildingSystem.js'; import type { GatheringState } from './gatheringSystem.js'; +import type { PickupState } from './pickupSystem.js'; function findNearestResource(map: GameMap, from: Position): Position | null { const tiles = map.resourceTiles; @@ -92,6 +93,27 @@ export function npcBrainSystem(world: World, map: GameMap, eventMemoryService?: brain.currentGoal = null; } + // Preserve dropoff goal — NPC should finish delivering items before switching + if (brain.currentGoal === 'dropoff') continue; + + // Preserve pickup goal — NPC should finish picking up items + if (brain.currentGoal === 'pickup') { + const ps = world.getComponent(entity, 'pickupState'); + if (ps) { + const sPos = world.getComponent(ps.stockpileEntityId, 'position'); + if (sPos) { + const path = findPath(map, pos, sPos); + if (path && path.length > 0) { + movement.state = 'walking'; + movement.target = sPos; + movement.path = path; + movement.direction = directionTo(pos, path[0]); + } + } + } + continue; + } + // Determine new goal based on needs priority const prevGoal = brain.currentGoal; let goal: GoalType = 'wander'; @@ -103,7 +125,7 @@ export function npcBrainSystem(world: World, map: GameMap, eventMemoryService?: if (eventMemoryService && brain.currentGoal !== prevGoal && prevGoal !== null) { const name = world.getComponent(entity, 'name') ?? 'Unknown'; - const goalLabels: Record = { wander: 'wandering', eat: 'looking for food', rest: 'looking for rest', gather: 'looking for resources', craft: 'crafting', build: 'building', dropoff: 'dropping off items' }; + const goalLabels: Record = { wander: 'wandering', eat: 'looking for food', rest: 'looking for rest', gather: 'looking for resources', craft: 'crafting', build: 'building', dropoff: 'dropping off items', pickup: 'picking up materials' }; eventMemoryService.record(entity, { type: 'goal_change', tick: 0, diff --git a/server/src/systems/pickupSystem.ts b/server/src/systems/pickupSystem.ts new file mode 100644 index 0000000..eb1abe2 --- /dev/null +++ b/server/src/systems/pickupSystem.ts @@ -0,0 +1,61 @@ +import type { Movement, NPCBrain, Position, StockpileLogEntry } from '@dflike/shared'; +import type { World } from '../ecs/World.js'; +import type { StructureData } from './buildingSystem.js'; +import { addItem, removeItem } from '../industry/inventoryHelpers.js'; +import type { StockpileLog } from '../industry/stockpileLog.js'; + +export interface PickupState { + stockpileEntityId: number; + items: { itemId: string; quantity: number }[]; +} + +export function pickupSystem( + world: World, + tick: number, + onStockpileEvent?: (entry: StockpileLogEntry) => void, +): void { + const stockpileLog = world.getSingleton('stockpileLog'); + + for (const entity of world.query('npcBrain', 'position', 'inventory', 'movement')) { + const brain = world.getComponent(entity, 'npcBrain')!; + if (brain.currentGoal !== 'pickup') continue; + + const movement = world.getComponent(entity, 'movement')!; + if (movement.state !== 'idle') continue; + + const pickupState = world.getComponent(entity, 'pickupState'); + if (!pickupState) { + brain.currentGoal = 'wander'; + continue; + } + + const pos = world.getComponent(entity, 'position')!; + const sPos = world.getComponent(pickupState.stockpileEntityId, 'position'); + if (!sPos || sPos.x !== pos.x || sPos.y !== pos.y) continue; + + const sData = world.getComponent(pickupState.stockpileEntityId, 'structure'); + if (!sData) { + world.removeComponent(entity, 'pickupState'); + brain.currentGoal = 'wander'; + continue; + } + + const inv = world.getComponent>(entity, 'inventory')!; + const npcName = world.getComponent(entity, 'name') ?? 'Unknown'; + + for (const needed of pickupState.items) { + const available = sData.inventory.get(needed.itemId) ?? 0; + const take = Math.min(available, needed.quantity); + if (take > 0) { + removeItem(sData.inventory, needed.itemId, take); + addItem(inv, needed.itemId, take); + const entry: StockpileLogEntry = { npcName, action: 'pickup', itemId: needed.itemId, quantity: take, tick }; + stockpileLog?.record(entry); + onStockpileEvent?.(entry); + } + } + + world.removeComponent(entity, 'pickupState'); + brain.currentGoal = 'craft'; + } +}