From 5c8189300176cb16376e481eac206038d346759e Mon Sep 17 00:00:00 2001 From: root Date: Tue, 10 Mar 2026 20:44:07 +0000 Subject: [PATCH] feat: rework hunger to consume berries, add drink/forage goals Eating now consumes berries from inventory or nearby stockpile instead of recovering hunger for free. Added drink goal (consumes water) and forage goal (desperate survival gather when no food/water available). Priority order: sleep > eat > drink > forage > dropoff > gather > wander. Co-Authored-By: Claude Opus 4.6 --- server/src/systems/__tests__/systems.test.ts | 80 ++++++++- server/src/systems/npcBrainSystem.ts | 175 +++++++++++++++---- 2 files changed, 216 insertions(+), 39 deletions(-) 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();