From 61c463c1c7df458856e784fee16488708874f688 Mon Sep 17 00:00:00 2001 From: root Date: Sun, 8 Mar 2026 22:47:51 +0000 Subject: [PATCH] feat(brain): add gather goal when productivity is low Co-Authored-By: Claude Opus 4.6 --- server/src/systems/__tests__/systems.test.ts | 47 ++++++++++++++++++++ server/src/systems/npcBrainSystem.ts | 27 +++++++++-- 2 files changed, 70 insertions(+), 4 deletions(-) diff --git a/server/src/systems/__tests__/systems.test.ts b/server/src/systems/__tests__/systems.test.ts index 7a613dd..73d15fe 100644 --- a/server/src/systems/__tests__/systems.test.ts +++ b/server/src/systems/__tests__/systems.test.ts @@ -148,6 +148,53 @@ describe('npcBrainSystem', () => { expect(brain.currentGoal).toBe('rest'); }); + it('sets gather goal when productivity is low', () => { + const world = new World(); + const map = new GameMap(10, 10); + map.resourceTiles = [{ x: 3, y: 3, resourceType: 'log' }]; + const e = createNPC(world, 5, 5, 80, 80); + const needs = world.getComponent(e, 'needs')!; + needs.productivity = 30; // below threshold of 40 + npcBrainSystem(world, map); + const brain = world.getComponent(e, 'npcBrain')!; + expect(brain.currentGoal).toBe('gather'); + }); + + it('prioritizes rest and eat over gather', () => { + const world = new World(); + const map = new GameMap(10, 10); + map.addPointOfInterest({ type: 'rest', position: { x: 7, y: 7 } }); + map.resourceTiles = [{ x: 3, y: 3, resourceType: 'log' }]; + const e = createNPC(world, 5, 5, 80, 10); + const needs = world.getComponent(e, 'needs')!; + needs.productivity = 30; + npcBrainSystem(world, map); + const brain = world.getComponent(e, 'npcBrain')!; + expect(brain.currentGoal).toBe('rest'); + }); + + it('sets wander when productivity is above threshold', () => { + const world = new World(); + const map = new GameMap(10, 10); + map.resourceTiles = [{ x: 3, y: 3, resourceType: 'log' }]; + const e = createNPC(world, 5, 5, 80, 80); + npcBrainSystem(world, map); + const brain = world.getComponent(e, 'npcBrain')!; + expect(brain.currentGoal).toBe('wander'); + }); + + it('falls back to wander when no resource tiles exist', () => { + const world = new World(); + const map = new GameMap(10, 10); + map.resourceTiles = []; + const e = createNPC(world, 5, 5, 80, 80); + const needs = world.getComponent(e, 'needs')!; + needs.productivity = 30; + npcBrainSystem(world, map); + const brain = world.getComponent(e, 'npcBrain')!; + expect(brain.currentGoal).toBe('wander'); + }); + it('skips NPC when socialState phase is not none', () => { const world = new World(); const map = new GameMap(10, 10); diff --git a/server/src/systems/npcBrainSystem.ts b/server/src/systems/npcBrainSystem.ts index 27164f2..1d06460 100644 --- a/server/src/systems/npcBrainSystem.ts +++ b/server/src/systems/npcBrainSystem.ts @@ -1,12 +1,24 @@ import { - HUNGER_THRESHOLD, ENERGY_THRESHOLD, Direction, - type Needs, type Movement, type NPCBrain, type Position, type SocialState, + HUNGER_THRESHOLD, ENERGY_THRESHOLD, PRODUCTIVITY_THRESHOLD, Direction, + type GoalType, type Needs, type Movement, type NPCBrain, type Position, type SocialState, } from '@dflike/shared'; import type { World } from '../ecs/World.js'; import type { GameMap } from '../map/GameMap.js'; import { findPath } from '../map/pathfinding.js'; import type { EventMemoryService } from '../llm/eventMemoryService.js'; +function findNearestResource(map: GameMap, from: Position): Position | null { + const tiles = map.resourceTiles; + if (tiles.length === 0) return null; + let best = tiles[0]; + let bestDist = Math.abs(from.x - best.x) + Math.abs(from.y - best.y); + for (let i = 1; i < tiles.length; i++) { + const d = Math.abs(from.x - tiles[i].x) + Math.abs(from.y - tiles[i].y); + if (d < bestDist) { best = tiles[i]; bestDist = d; } + } + return { x: best.x, y: best.y }; +} + function closestPOI(map: GameMap, from: Position, type: 'food' | 'rest'): Position | null { const pois = map.getPointsOfInterest(type); if (pois.length === 0) return null; @@ -71,15 +83,16 @@ export function npcBrainSystem(world: World, map: GameMap, eventMemoryService?: // Determine new goal based on needs priority const prevGoal = brain.currentGoal; - let goal: 'rest' | 'eat' | 'wander' = 'wander'; + let goal: GoalType = 'wander'; if (needs.energy < ENERGY_THRESHOLD) goal = 'rest'; else if (needs.hunger < HUNGER_THRESHOLD) goal = 'eat'; + else if (needs.productivity < PRODUCTIVITY_THRESHOLD) goal = 'gather'; brain.currentGoal = goal; 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' }; + const goalLabels: Record = { wander: 'wandering', eat: 'looking for food', rest: 'looking for rest', gather: 'looking for resources' }; eventMemoryService.record(entity, { type: 'goal_change', tick: 0, @@ -95,6 +108,12 @@ export function npcBrainSystem(world: World, map: GameMap, eventMemoryService?: target = closestPOI(map, pos, 'food'); } else if (goal === 'rest') { target = closestPOI(map, pos, 'rest'); + } else if (goal === 'gather') { + target = findNearestResource(map, pos); + if (!target) { + goal = 'wander'; + brain.currentGoal = 'wander'; + } } if (!target) { target = map.getRandomWalkable();