diff --git a/server/src/systems/__tests__/systems.test.ts b/server/src/systems/__tests__/systems.test.ts index 65652c3..12ea214 100644 --- a/server/src/systems/__tests__/systems.test.ts +++ b/server/src/systems/__tests__/systems.test.ts @@ -181,15 +181,27 @@ describe('npcBrainSystem', () => { expect(brain.currentGoal).toBe('wander'); }); - it('sets eat goal when hunger is low', () => { + it('sets eat goal when hunger is low and berries in inventory', () => { const world = new World(); const map = new GameMap(10, 10); const e = createNPC(world, 5, 5, 15, 80); - npcBrainSystem(world, map, 0.5); + world.addComponent>(e, 'inventory', new Map([['berries', 1]])); + world.addComponent(e, 'stats', { strength: 10, dexterity: 10, constitution: 10, intelligence: 10, perception: 10, sociability: 10, courage: 10, curiosity: 10, empathy: 10, temperament: 10 }); + world.addComponent(e, 'statModifiers', { modifiers: [] }); + npcBrainSystem(world, map, 0.5, undefined, rc); const brain = world.getComponent(e, 'npcBrain')!; expect(brain.currentGoal).toBe('eat'); }); + it('sets forage goal when hunger is low but no berries available', () => { + const world = new World(); + const map = new GameMap(10, 10); + const e = createNPC(world, 5, 5, 15, 80); + npcBrainSystem(world, map, 0.5, undefined, rc); + const brain = world.getComponent(e, 'npcBrain')!; + expect(brain.currentGoal).toBe('forage'); + }); + it('sets sleep goal when energy is low', () => { const world = new World(); const map = new GameMap(10, 10); @@ -312,6 +324,70 @@ describe('npcBrainSystem', () => { npcBrainSystem(world, map, 0.5); expect(brain.currentGoal).toBe('sleep'); }); + + it('consumes berries from inventory when eating', () => { + const world = new World(); + const map = new GameMap(10, 10); + const e = createNPC(world, 5, 5, 20, 80); + world.addComponent>(e, 'inventory', new Map([['berries', 3]])); + world.addComponent(e, 'stats', { strength: 10, dexterity: 10, constitution: 10, intelligence: 10, perception: 10, sociability: 10, courage: 10, curiosity: 10, empathy: 10, temperament: 10 }); + world.addComponent(e, 'statModifiers', { modifiers: [] }); + const brain = world.getComponent(e, 'npcBrain')!; + brain.currentGoal = 'eat'; + const movement = world.getComponent(e, 'movement')!; + movement.state = 'idle'; + npcBrainSystem(world, map, 0.3, undefined, rc); + const inv = world.getComponent>(e, 'inventory')!; + expect(inv.get('berries')).toBe(2); + const needs = world.getComponent(e, 'needs')!; + expect(needs.hunger).toBe(40); + }); + + it('consumes water from inventory when drinking', () => { + const world = new World(); + const map = new GameMap(10, 10); + const e = createNPC(world, 5, 5, 80, 80); + const needs = world.getComponent(e, 'needs')!; + needs.thirst = 20; + world.addComponent>(e, 'inventory', new Map([['water', 2]])); + world.addComponent(e, 'stats', { strength: 10, dexterity: 10, constitution: 10, intelligence: 10, perception: 10, sociability: 10, courage: 10, curiosity: 10, empathy: 10, temperament: 10 }); + world.addComponent(e, 'statModifiers', { modifiers: [] }); + const brain = world.getComponent(e, 'npcBrain')!; + brain.currentGoal = 'drink'; + const movement = world.getComponent(e, 'movement')!; + movement.state = 'idle'; + npcBrainSystem(world, map, 0.3, undefined, rc); + const inv = world.getComponent>(e, 'inventory')!; + expect(inv.get('water')).toBe(1); + expect(needs.thirst).toBe(40); + }); + + it('sets forage goal when hungry with no berries available', () => { + const world = new World(); + const map = new GameMap(10, 10); + map.resourceTiles = [{ x: 3, y: 3, resourceType: 'berries' }]; + const e = createNPC(world, 5, 5, 20, 80); + world.addComponent>(e, 'inventory', new Map()); + world.addComponent(e, 'stats', { strength: 10, dexterity: 10, constitution: 10, intelligence: 10, perception: 10, sociability: 10, courage: 10, curiosity: 10, empathy: 10, temperament: 10 }); + world.addComponent(e, 'statModifiers', { modifiers: [] }); + npcBrainSystem(world, map, 0.3, undefined, rc); + const brain = world.getComponent(e, 'npcBrain')!; + expect(brain.currentGoal).toBe('forage'); + }); + + it('sets drink goal when thirsty with water in inventory', () => { + const world = new World(); + const map = new GameMap(10, 10); + const e = createNPC(world, 5, 5, 80, 80); + const needs = world.getComponent(e, 'needs')!; + needs.thirst = 20; + world.addComponent>(e, 'inventory', new Map([['water', 1]])); + world.addComponent(e, 'stats', { strength: 10, dexterity: 10, constitution: 10, intelligence: 10, perception: 10, sociability: 10, courage: 10, curiosity: 10, empathy: 10, temperament: 10 }); + world.addComponent(e, 'statModifiers', { modifiers: [] }); + npcBrainSystem(world, map, 0.3, undefined, rc); + const brain = world.getComponent(e, 'npcBrain')!; + expect(brain.currentGoal).toBe('drink'); + }); }); describe('movementSystem', () => { diff --git a/server/src/systems/npcBrainSystem.ts b/server/src/systems/npcBrainSystem.ts index 51a142c..62469d7 100644 --- a/server/src/systems/npcBrainSystem.ts +++ b/server/src/systems/npcBrainSystem.ts @@ -15,12 +15,12 @@ import { getCarryCapacity } from './statHelpers.js'; import { getTotalItemCount } from '../industry/inventoryHelpers.js'; import type { RuntimeConstants } from '../config/runtimeConstants.js'; -function findNearestResource(map: GameMap, from: Position): Position | null { - const tiles = map.resourceTiles; - if (tiles.length === 0) return null; +function findNearestResource(map: GameMap, from: Position, resourceType?: string): Position | null { + const candidates = resourceType ? map.resourceTiles.filter(r => r.resourceType === resourceType) : map.resourceTiles; + if (candidates.length === 0) return null; // Sort by distance so we try closest first - const sorted = [...tiles].sort((a, b) => + const sorted = [...candidates].sort((a, b) => (Math.abs(from.x - a.x) + Math.abs(from.y - a.y)) - (Math.abs(from.x - b.x) + Math.abs(from.y - b.y)) ); @@ -40,11 +40,6 @@ function isNighttime(gameTime: number): boolean { return gameTime >= SLEEP_NIGHT_START; } -function closestPOI(_map: GameMap, _from: Position, _type: 'food'): Position | null { - // Food POIs removed — eating now uses berry bushes (handled in later task) - return null; -} - function directionTo(from: Position, to: Position): number { const dx = to.x - from.x; const dy = to.y - from.y; @@ -65,12 +60,61 @@ function findNearestStockpile(world: World, from: Position): Position | null { return best; } +function findNearestStockpileWithItem(world: World, pos: Position, itemId: string): Position | null { + let best: Position | null = null; + let bestDist = Infinity; + for (const se of world.query('structure', 'position')) { + const sd = world.getComponent(se, 'structure')!; + if (sd.type === 'stockpile' && sd.buildProgress === undefined && (sd.inventory.get(itemId) ?? 0) > 0) { + const sp = world.getComponent(se, 'position')!; + const dist = Math.abs(sp.x - pos.x) + Math.abs(sp.y - pos.y); + if (dist < bestDist) { bestDist = dist; best = sp; } + } + } + return best; +} + +function hasItemAvailable(world: World, entity: number, pos: Position, itemId: string): 'inventory' | 'stockpile' | null { + const inv = world.getComponent>(entity, 'inventory'); + if (inv && (inv.get(itemId) ?? 0) > 0) return 'inventory'; + for (const se of world.query('structure', 'position')) { + const sd = world.getComponent(se, 'structure')!; + if (sd.type === 'stockpile' && sd.buildProgress === undefined && (sd.inventory.get(itemId) ?? 0) > 0) return 'stockpile'; + } + return null; +} + +function consumeFromInventory(inv: Map, itemId: string): boolean { + const qty = inv.get(itemId) ?? 0; + if (qty <= 0) return false; + if (qty === 1) inv.delete(itemId); + else inv.set(itemId, qty - 1); + return true; +} + +function consumeFromNearbyStockpile(world: World, pos: Position, itemId: string): boolean { + for (const se of world.query('structure', 'position')) { + const sp = world.getComponent(se, 'position')!; + if (sp.x === pos.x && sp.y === pos.y) { + const sd = world.getComponent(se, 'structure')!; + if (sd.type === 'stockpile' && (sd.inventory.get(itemId) ?? 0) > 0) { + const qty = sd.inventory.get(itemId)!; + if (qty <= 1) sd.inventory.delete(itemId); + else sd.inventory.set(itemId, qty - 1); + return true; + } + } + } + return false; +} + export function npcBrainSystem(world: World, map: GameMap, gameTime: number, eventMemoryService?: EventMemoryService, rc?: RuntimeConstants): void { const HUNGER_THRESHOLD = rc?.get('HUNGER_THRESHOLD') ?? 30; const ENERGY_THRESHOLD = rc?.get('ENERGY_THRESHOLD') ?? 20; const PRODUCTIVITY_THRESHOLD = rc?.get('PRODUCTIVITY_THRESHOLD') ?? 40; const SLEEP_WAKE_THRESHOLD = rc?.get('SLEEP_WAKE_THRESHOLD') ?? 85; const SLEEP_VOLUNTARY_ENERGY_THRESHOLD = rc?.get('SLEEP_VOLUNTARY_ENERGY_THRESHOLD') ?? 60; + const THIRST_THRESHOLD = rc?.get('THIRST_THRESHOLD') ?? 30; for (const entity of world.query('npcBrain', 'needs', 'movement', 'position')) { const brain = world.getComponent(entity, 'npcBrain')!; const needs = world.getComponent(entity, 'needs')!; @@ -114,18 +158,40 @@ export function npcBrainSystem(world: World, map: GameMap, gameTime: number, eve // Skip if currently walking toward a goal if (movement.state === 'walking' && movement.path.length > 0) continue; - // Check if at goal destination — recover needs + // Eat at destination — consume berries if (brain.currentGoal === 'eat' && movement.state === 'idle') { - const prevHunger = needs.hunger; - needs.hunger = Math.min(100, needs.hunger + 20); - if (eventMemoryService && prevHunger < HUNGER_THRESHOLD && needs.hunger >= HUNGER_THRESHOLD) { - const name = world.getComponent(entity, 'name') ?? 'Unknown'; - eventMemoryService.record(entity, { - type: 'need_recovery', - tick: 0, - need: 'hunger', - detail: `${name} satisfied their hunger`, - }); + const inv = world.getComponent>(entity, 'inventory'); + let consumed = inv ? consumeFromInventory(inv, 'berries') : false; + if (!consumed) consumed = consumeFromNearbyStockpile(world, pos, 'berries'); + if (consumed) { + const prevHunger = needs.hunger; + needs.hunger = Math.min(100, needs.hunger + 20); + if (eventMemoryService && prevHunger < HUNGER_THRESHOLD && needs.hunger >= HUNGER_THRESHOLD) { + const name = world.getComponent(entity, 'name') ?? 'Unknown'; + eventMemoryService.record(entity, { + type: 'need_recovery', tick: 0, need: 'hunger', + detail: `${name} satisfied their hunger`, + }); + } + } + brain.currentGoal = null; + } + + // Drink at destination — consume water + if (brain.currentGoal === 'drink' && movement.state === 'idle') { + const inv = world.getComponent>(entity, 'inventory'); + let consumed = inv ? consumeFromInventory(inv, 'water') : false; + if (!consumed) consumed = consumeFromNearbyStockpile(world, pos, 'water'); + if (consumed) { + const prevThirst = needs.thirst; + needs.thirst = Math.min(100, needs.thirst + 20); + if (eventMemoryService && prevThirst < THIRST_THRESHOLD && needs.thirst >= THIRST_THRESHOLD) { + const name = world.getComponent(entity, 'name') ?? 'Unknown'; + eventMemoryService.record(entity, { + type: 'need_recovery', tick: 0, need: 'thirst', + detail: `${name} quenched their thirst`, + }); + } } brain.currentGoal = null; } @@ -167,10 +233,25 @@ export function npcBrainSystem(world: World, map: GameMap, gameTime: number, eve // Determine new goal based on needs priority const prevGoal = brain.currentGoal; let goal: GoalType = 'wander'; - if (needs.energy < ENERGY_THRESHOLD) goal = 'sleep'; - else if (isNighttime(gameTime) && needs.energy < SLEEP_VOLUNTARY_ENERGY_THRESHOLD) goal = 'sleep'; - else if (needs.hunger < HUNGER_THRESHOLD) goal = 'eat'; - else { + if (needs.energy < ENERGY_THRESHOLD) { + goal = 'sleep'; + } else if (isNighttime(gameTime) && needs.energy < SLEEP_VOLUNTARY_ENERGY_THRESHOLD) { + goal = 'sleep'; + } else if (needs.hunger < HUNGER_THRESHOLD) { + const source = hasItemAvailable(world, entity, pos, 'berries'); + if (source) { + goal = 'eat'; + } else { + goal = 'forage'; + } + } else if (needs.thirst < THIRST_THRESHOLD) { + const source = hasItemAvailable(world, entity, pos, 'water'); + if (source) { + goal = 'drink'; + } else { + goal = 'forage'; + } + } else { // Check if NPC is carrying items at or over capacity → dropoff priority const inv = world.getComponent>(entity, 'inventory'); const totalItems = inv ? getTotalItemCount(inv) : 0; @@ -193,7 +274,7 @@ export function npcBrainSystem(world: World, map: GameMap, gameTime: number, eve if (eventMemoryService && brain.currentGoal !== prevGoal && prevGoal !== null) { const name = world.getComponent(entity, 'name') ?? 'Unknown'; - const goalLabels: Record = { wander: 'wandering', eat: 'looking for food', sleep: 'going to sleep', gather: 'looking for resources', craft: 'crafting', build: 'building', dropoff: 'dropping off items', pickup: 'picking up materials' }; + const goalLabels: Record = { wander: 'wandering', eat: 'looking for food', drink: 'looking for water', forage: 'foraging for survival', sleep: 'going to sleep', gather: 'looking for resources', craft: 'crafting', build: 'building', dropoff: 'dropping off items', pickup: 'picking up materials' }; eventMemoryService.record(entity, { type: 'goal_change', tick: 0, @@ -214,19 +295,39 @@ export function npcBrainSystem(world: World, map: GameMap, gameTime: number, eve // Pick target based on goal let target: Position | null = null; if (goal === 'eat') { - target = closestPOI(map, pos, 'food'); - } else if (goal === 'gather') { + if (hasItemAvailable(world, entity, pos, 'berries') === 'inventory') { + // Eat from inventory in place + target = pos; + } else { + target = findNearestStockpileWithItem(world, pos, 'berries'); + if (!target) { goal = 'forage'; brain.currentGoal = 'forage'; } + } + } + if (goal === 'drink') { + if (hasItemAvailable(world, entity, pos, 'water') === 'inventory') { + target = pos; + } else { + target = findNearestStockpileWithItem(world, pos, 'water'); + if (!target) { goal = 'forage'; brain.currentGoal = 'forage'; } + } + } + if (goal === 'forage') { + if (needs.hunger < HUNGER_THRESHOLD) { + target = findNearestResource(map, pos, 'berries'); + } else if (needs.thirst < THIRST_THRESHOLD) { + target = findNearestResource(map, pos, 'water'); + } + if (!target) { + target = findNearestResource(map, pos); + } + } + if (goal === 'gather') { target = findNearestResource(map, pos); - if (!target) { - goal = 'wander'; - brain.currentGoal = 'wander'; - } - } else if (goal === 'dropoff') { + if (!target) { goal = 'wander'; brain.currentGoal = 'wander'; } + } + if (goal === 'dropoff') { target = findNearestStockpile(world, pos); - if (!target) { - goal = 'wander'; - brain.currentGoal = 'wander'; - } + if (!target) { goal = 'wander'; brain.currentGoal = 'wander'; } } if (!target) { target = map.getRandomWalkable(); @@ -239,7 +340,7 @@ export function npcBrainSystem(world: World, map: GameMap, gameTime: number, eve movement.target = target; movement.path = path; movement.direction = directionTo(pos, path[0]); - } else if (goal === 'gather') { + } else if (goal === 'gather' || goal === 'forage') { // Can't reach any resource — fall back to wander brain.currentGoal = 'wander'; const wanderTarget = map.getRandomWalkable();