diff --git a/server/src/systems/__tests__/systems.test.ts b/server/src/systems/__tests__/systems.test.ts new file mode 100644 index 0000000..23bb296 --- /dev/null +++ b/server/src/systems/__tests__/systems.test.ts @@ -0,0 +1,112 @@ +import { describe, it, expect } from 'vitest'; +import { World } from '../../ecs/World.js'; +import { GameMap } from '../../map/GameMap.js'; +import { needsDecaySystem } from '../needsDecaySystem.js'; +import { npcBrainSystem } from '../npcBrainSystem.js'; +import { movementSystem } from '../movementSystem.js'; +import type { Needs, Movement, NPCBrain, Position } from '@dflike/shared'; +import { HUNGER_DECAY_PER_TICK, ENERGY_DECAY_PER_TICK } from '@dflike/shared'; + +function createNPC(world: World, x: number, y: number, hunger = 80, energy = 80) { + const e = world.createEntity(); + world.addComponent(e, 'position', { x, y }); + world.addComponent(e, 'needs', { hunger, energy }); + world.addComponent(e, 'movement', { state: 'idle', target: null, path: [], direction: 0 }); + world.addComponent(e, 'npcBrain', { currentGoal: null, goalQueue: [] }); + return e; +} + +describe('needsDecaySystem', () => { + it('decays hunger and energy each tick', () => { + const world = new World(); + const e = createNPC(world, 0, 0, 50, 50); + needsDecaySystem(world); + const needs = world.getComponent(e, 'needs')!; + expect(needs.hunger).toBeCloseTo(50 - HUNGER_DECAY_PER_TICK); + expect(needs.energy).toBeCloseTo(50 - ENERGY_DECAY_PER_TICK); + }); + + it('clamps needs at 0', () => { + const world = new World(); + const e = createNPC(world, 0, 0, 0.01, 0.01); + needsDecaySystem(world); + const needs = world.getComponent(e, 'needs')!; + expect(needs.hunger).toBeGreaterThanOrEqual(0); + expect(needs.energy).toBeGreaterThanOrEqual(0); + }); +}); + +describe('npcBrainSystem', () => { + it('sets wander goal when needs are satisfied', () => { + const world = new World(); + const map = new GameMap(10, 10); + const e = createNPC(world, 5, 5, 80, 80); + npcBrainSystem(world, map); + const brain = world.getComponent(e, 'npcBrain')!; + expect(brain.currentGoal).toBe('wander'); + }); + + it('sets eat goal when hunger is low', () => { + const world = new World(); + const map = new GameMap(10, 10); + map.addPointOfInterest({ type: 'food', position: { x: 3, y: 3 } }); + const e = createNPC(world, 5, 5, 15, 80); + npcBrainSystem(world, map); + const brain = world.getComponent(e, 'npcBrain')!; + expect(brain.currentGoal).toBe('eat'); + }); + + it('sets rest goal when energy is low', () => { + const world = new World(); + const map = new GameMap(10, 10); + map.addPointOfInterest({ type: 'rest', position: { x: 7, y: 7 } }); + const e = createNPC(world, 5, 5, 80, 10); + npcBrainSystem(world, map); + const brain = world.getComponent(e, 'npcBrain')!; + expect(brain.currentGoal).toBe('rest'); + }); + + it('prioritizes energy over hunger', () => { + const world = new World(); + const map = new GameMap(10, 10); + map.addPointOfInterest({ type: 'food', position: { x: 3, y: 3 } }); + map.addPointOfInterest({ type: 'rest', position: { x: 7, y: 7 } }); + const e = createNPC(world, 5, 5, 15, 10); + npcBrainSystem(world, map); + const brain = world.getComponent(e, 'npcBrain')!; + expect(brain.currentGoal).toBe('rest'); + }); +}); + +describe('movementSystem', () => { + it('moves entity along path', () => { + const world = new World(); + const e = world.createEntity(); + world.addComponent(e, 'position', { x: 0, y: 0 }); + world.addComponent(e, 'movement', { + state: 'walking', + target: { x: 2, y: 0 }, + path: [{ x: 1, y: 0 }, { x: 2, y: 0 }], + direction: 2, // RIGHT + }); + movementSystem(world); + const pos = world.getComponent(e, 'position')!; + expect(pos).toEqual({ x: 1, y: 0 }); + }); + + it('sets idle when path is exhausted', () => { + const world = new World(); + const e = world.createEntity(); + world.addComponent(e, 'position', { x: 1, y: 0 }); + world.addComponent(e, 'movement', { + state: 'walking', + target: { x: 1, y: 0 }, + path: [], + direction: 2, + }); + movementSystem(world); + const mov = world.getComponent(e, 'movement')!; + expect(mov.state).toBe('idle'); + expect(mov.target).toBeNull(); + }); +}); diff --git a/server/src/systems/movementSystem.ts b/server/src/systems/movementSystem.ts new file mode 100644 index 0000000..3a5977b --- /dev/null +++ b/server/src/systems/movementSystem.ts @@ -0,0 +1,32 @@ +import { Direction, type Movement, type Position } from '@dflike/shared'; +import type { World } from '../ecs/World.js'; + +function directionFromDelta(dx: number, dy: number): number { + if (Math.abs(dx) > Math.abs(dy)) return dx > 0 ? Direction.RIGHT : Direction.LEFT; + return dy > 0 ? Direction.DOWN : Direction.UP; +} + +export function movementSystem(world: World): void { + for (const entity of world.query('position', 'movement')) { + const movement = world.getComponent(entity, 'movement')!; + const pos = world.getComponent(entity, 'position')!; + + if (movement.state !== 'walking' || movement.path.length === 0) { + if (movement.state === 'walking' && movement.path.length === 0) { + movement.state = 'idle'; + movement.target = null; + } + continue; + } + + const next = movement.path.shift()!; + movement.direction = directionFromDelta(next.x - pos.x, next.y - pos.y); + pos.x = next.x; + pos.y = next.y; + + if (movement.path.length === 0) { + movement.state = 'idle'; + movement.target = null; + } + } +} diff --git a/server/src/systems/needsDecaySystem.ts b/server/src/systems/needsDecaySystem.ts new file mode 100644 index 0000000..9e17fee --- /dev/null +++ b/server/src/systems/needsDecaySystem.ts @@ -0,0 +1,10 @@ +import { HUNGER_DECAY_PER_TICK, ENERGY_DECAY_PER_TICK, type Needs } from '@dflike/shared'; +import type { World } from '../ecs/World.js'; + +export function needsDecaySystem(world: World): void { + for (const entity of world.query('needs')) { + const needs = world.getComponent(entity, 'needs')!; + needs.hunger = Math.max(0, needs.hunger - HUNGER_DECAY_PER_TICK); + needs.energy = Math.max(0, needs.energy - ENERGY_DECAY_PER_TICK); + } +} diff --git a/server/src/systems/npcBrainSystem.ts b/server/src/systems/npcBrainSystem.ts new file mode 100644 index 0000000..1badede --- /dev/null +++ b/server/src/systems/npcBrainSystem.ts @@ -0,0 +1,75 @@ +import { + HUNGER_THRESHOLD, ENERGY_THRESHOLD, + type Needs, type Movement, type NPCBrain, type Position, +} from '@dflike/shared'; +import type { World } from '../ecs/World.js'; +import type { GameMap } from '../map/GameMap.js'; +import { findPath } from '../map/pathfinding.js'; + +function closestPOI(map: GameMap, from: Position, type: 'food' | 'rest'): Position | null { + const pois = map.getPointsOfInterest(type); + if (pois.length === 0) return null; + let best = pois[0].position; + let bestDist = Math.abs(from.x - best.x) + Math.abs(from.y - best.y); + for (let i = 1; i < pois.length; i++) { + const d = Math.abs(from.x - pois[i].position.x) + Math.abs(from.y - pois[i].position.y); + if (d < bestDist) { best = pois[i].position; bestDist = d; } + } + return best; +} + +function directionTo(from: Position, to: Position): number { + const dx = to.x - from.x; + const dy = to.y - from.y; + if (Math.abs(dx) > Math.abs(dy)) return dx > 0 ? 2 : 1; // RIGHT : LEFT + return dy > 0 ? 0 : 3; // DOWN : UP +} + +export function npcBrainSystem(world: World, map: GameMap): void { + for (const entity of world.query('npcBrain', 'needs', 'movement', 'position')) { + const brain = world.getComponent(entity, 'npcBrain')!; + const needs = world.getComponent(entity, 'needs')!; + const movement = world.getComponent(entity, 'movement')!; + const pos = world.getComponent(entity, 'position')!; + + // Skip if currently walking toward a goal + if (movement.state === 'walking' && movement.path.length > 0) continue; + + // Check if at goal destination — recover needs + if (brain.currentGoal === 'eat' && movement.state === 'idle') { + needs.hunger = Math.min(100, needs.hunger + 20); + brain.currentGoal = null; + } + if (brain.currentGoal === 'rest' && movement.state === 'idle') { + needs.energy = Math.min(100, needs.energy + 20); + brain.currentGoal = null; + } + + // Determine new goal based on needs priority + let goal: 'rest' | 'eat' | 'wander' = 'wander'; + if (needs.energy < ENERGY_THRESHOLD) goal = 'rest'; + else if (needs.hunger < HUNGER_THRESHOLD) goal = 'eat'; + + brain.currentGoal = goal; + + // Pick target based on goal + let target: Position | null = null; + if (goal === 'eat') { + target = closestPOI(map, pos, 'food'); + } else if (goal === 'rest') { + target = closestPOI(map, pos, 'rest'); + } + if (!target) { + target = map.getRandomWalkable(); + } + + // Pathfind to target + const path = findPath(map, pos, target); + if (path && path.length > 0) { + movement.state = 'walking'; + movement.target = target; + movement.path = path; + movement.direction = directionTo(pos, path[0]); + } + } +}