From 5fba656c62021458852e8f2b38920b0fd025cbf6 Mon Sep 17 00:00:00 2001 From: root Date: Sun, 8 Mar 2026 22:49:47 +0000 Subject: [PATCH] feat(systems): add gatheringSystem with timer, stat modifier, and inventory Co-Authored-By: Claude Opus 4.6 --- .../systems/__tests__/gatheringSystem.test.ts | 166 ++++++++++++++++++ server/src/systems/gatheringSystem.ts | 68 +++++++ 2 files changed, 234 insertions(+) create mode 100644 server/src/systems/__tests__/gatheringSystem.test.ts create mode 100644 server/src/systems/gatheringSystem.ts diff --git a/server/src/systems/__tests__/gatheringSystem.test.ts b/server/src/systems/__tests__/gatheringSystem.test.ts new file mode 100644 index 0000000..e51e7e7 --- /dev/null +++ b/server/src/systems/__tests__/gatheringSystem.test.ts @@ -0,0 +1,166 @@ +import { describe, it, expect } from 'vitest'; +import { World } from '../../ecs/World.js'; +import { GameMap } from '../../map/GameMap.js'; +import { gatheringSystem, type GatheringState } from '../gatheringSystem.js'; +import type { Needs, Movement, NPCBrain, Position, Stats, StatModifiers } from '@dflike/shared'; +import { PRODUCTIVITY_RECOVERY_RATE } from '@dflike/shared'; + +function createGatheringNPC(world: World, x: number, y: number, overrides?: { strength?: number; productivity?: number }) { + const e = world.createEntity(); + world.addComponent(e, 'position', { x, y }); + world.addComponent(e, 'needs', { hunger: 80, energy: 80, productivity: overrides?.productivity ?? 30 }); + world.addComponent(e, 'movement', { state: 'idle', target: null, path: [], direction: 0, moveProgress: 0 }); + world.addComponent(e, 'npcBrain', { currentGoal: 'gather', goalQueue: [] }); + world.addComponent>(e, 'inventory', new Map()); + const str = overrides?.strength ?? 10; + world.addComponent(e, 'stats', { + strength: str, dexterity: 10, constitution: 10, intelligence: 10, perception: 10, + sociability: 10, courage: 10, curiosity: 10, empathy: 10, temperament: 10, + }); + world.addComponent(e, 'statModifiers', { modifiers: [] }); + return e; +} + +describe('gatheringSystem', () => { + it('starts gathering when NPC arrives at resource tile', () => { + const world = new World(); + const map = new GameMap(10, 10); + map.resourceTiles = [{ x: 3, y: 3, resourceType: 'log' }]; + const e = createGatheringNPC(world, 3, 3); + gatheringSystem(world, map); + const gs = world.getComponent(e, 'gatheringState'); + expect(gs).toBeDefined(); + expect(gs!.resourceType).toBe('log'); + expect(gs!.ticksRemaining).toBeGreaterThan(0); + }); + + it('does not start gathering when not at resource tile', () => { + const world = new World(); + const map = new GameMap(10, 10); + map.resourceTiles = [{ x: 3, y: 3, resourceType: 'log' }]; + const e = createGatheringNPC(world, 5, 5); + gatheringSystem(world, map); + const gs = world.getComponent(e, 'gatheringState'); + expect(gs).toBeUndefined(); + }); + + it('decrements timer each tick while gathering', () => { + const world = new World(); + const map = new GameMap(10, 10); + map.resourceTiles = [{ x: 3, y: 3, resourceType: 'log' }]; + const e = createGatheringNPC(world, 3, 3); + gatheringSystem(world, map); + const gs = world.getComponent(e, 'gatheringState')!; + const initial = gs.ticksRemaining; + gatheringSystem(world, map); + expect(gs.ticksRemaining).toBe(initial - 1); + }); + + it('recovers productivity while gathering', () => { + const world = new World(); + const map = new GameMap(10, 10); + map.resourceTiles = [{ x: 3, y: 3, resourceType: 'log' }]; + const e = createGatheringNPC(world, 3, 3, { productivity: 30 }); + gatheringSystem(world, map); + const needs = world.getComponent(e, 'needs')!; + expect(needs.productivity).toBeCloseTo(30 + PRODUCTIVITY_RECOVERY_RATE); + }); + + it('adds item to inventory on completion', () => { + const world = new World(); + const map = new GameMap(10, 10); + map.resourceTiles = [{ x: 3, y: 3, resourceType: 'log' }]; + const e = createGatheringNPC(world, 3, 3); + gatheringSystem(world, map); + const gs = world.getComponent(e, 'gatheringState')!; + gs.ticksRemaining = 1; + gatheringSystem(world, map); + const inv = world.getComponent>(e, 'inventory')!; + expect(inv.get('log')).toBe(1); + }); + + it('clears gathering state on completion', () => { + const world = new World(); + const map = new GameMap(10, 10); + map.resourceTiles = [{ x: 3, y: 3, resourceType: 'log' }]; + const e = createGatheringNPC(world, 3, 3); + gatheringSystem(world, map); + const gs = world.getComponent(e, 'gatheringState')!; + gs.ticksRemaining = 1; + gatheringSystem(world, map); + expect(world.getComponent(e, 'gatheringState')).toBeUndefined(); + }); + + it('high strength reduces gather time', () => { + const world = new World(); + const map = new GameMap(10, 10); + map.resourceTiles = [{ x: 3, y: 3, resourceType: 'log' }]; + const e = createGatheringNPC(world, 3, 3, { strength: 16 }); + gatheringSystem(world, map); + const gs = world.getComponent(e, 'gatheringState')!; + expect(gs.ticksRemaining).toBeLessThan(30); + }); + + it('skips NPC whose goal is not gather', () => { + const world = new World(); + const map = new GameMap(10, 10); + map.resourceTiles = [{ x: 3, y: 3, resourceType: 'log' }]; + const e = createGatheringNPC(world, 3, 3); + const brain = world.getComponent(e, 'npcBrain')!; + brain.currentGoal = 'wander'; + gatheringSystem(world, map); + expect(world.getComponent(e, 'gatheringState')).toBeUndefined(); + }); + + it('resets goal to wander when productivity is satisfied after gathering', () => { + const world = new World(); + const map = new GameMap(10, 10); + map.resourceTiles = [{ x: 3, y: 3, resourceType: 'log' }]; + const e = createGatheringNPC(world, 3, 3, { productivity: 70 }); + gatheringSystem(world, map); + const gs = world.getComponent(e, 'gatheringState')!; + gs.ticksRemaining = 1; + gatheringSystem(world, map); + const brain = world.getComponent(e, 'npcBrain')!; + expect(brain.currentGoal).toBe('wander'); + }); + + it('does not start gathering when NPC is still walking', () => { + const world = new World(); + const map = new GameMap(10, 10); + map.resourceTiles = [{ x: 3, y: 3, resourceType: 'log' }]; + const e = createGatheringNPC(world, 3, 3); + const movement = world.getComponent(e, 'movement')!; + movement.state = 'walking'; + movement.path = [{ x: 4, y: 3 }]; + gatheringSystem(world, map); + expect(world.getComponent(e, 'gatheringState')).toBeUndefined(); + }); + + it('full cycle: NPC with low productivity gathers and gets item', () => { + const world = new World(); + const map = new GameMap(10, 10); + map.resourceTiles = [{ x: 3, y: 3, resourceType: 'stone' }]; + const e = createGatheringNPC(world, 3, 3, { productivity: 30 }); + + // First tick: starts gathering + gatheringSystem(world, map); + const gs = world.getComponent(e, 'gatheringState')!; + expect(gs).toBeDefined(); + expect(gs.resourceType).toBe('stone'); + + // Run until completion + const totalTicks = gs.ticksRemaining; + for (let i = 0; i < totalTicks; i++) { + gatheringSystem(world, map); + } + + // Verify item added + const inv = world.getComponent>(e, 'inventory')!; + expect(inv.get('stone')).toBe(1); + + // Verify productivity recovered + const needs = world.getComponent(e, 'needs')!; + expect(needs.productivity).toBeGreaterThan(30); + }); +}); diff --git a/server/src/systems/gatheringSystem.ts b/server/src/systems/gatheringSystem.ts new file mode 100644 index 0000000..35ca926 --- /dev/null +++ b/server/src/systems/gatheringSystem.ts @@ -0,0 +1,68 @@ +import { + PRODUCTIVITY_RECOVERY_RATE, PRODUCTIVITY_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 { getEffectiveStat } from './statHelpers.js'; +import { addItem } from '../industry/inventoryHelpers.js'; +import { industryConfig } from '../config/industryConfig.js'; + +export interface GatheringState { + resourceType: string; + ticksRemaining: number; +} + +export function gatheringSystem(world: World, map: GameMap): void { + for (const entity of world.query('npcBrain', 'position', 'needs', 'movement')) { + const brain = world.getComponent(entity, 'npcBrain')!; + + if (brain.currentGoal !== 'gather') continue; + + const pos = world.getComponent(entity, 'position')!; + const needs = world.getComponent(entity, 'needs')!; + const movement = world.getComponent(entity, 'movement')!; + const gs = world.getComponent(entity, 'gatheringState'); + + // Already gathering — tick down + if (gs) { + gs.ticksRemaining--; + needs.productivity = Math.min(100, needs.productivity + PRODUCTIVITY_RECOVERY_RATE); + + if (gs.ticksRemaining <= 0) { + // Complete: add resource to inventory + const inv = world.getComponent>(entity, 'inventory'); + if (inv) { + addItem(inv, gs.resourceType, industryConfig.gatherYield); + } + world.removeComponent(entity, 'gatheringState'); + + // Decide next action + if (needs.productivity >= PRODUCTIVITY_THRESHOLD) { + brain.currentGoal = 'wander'; + } + // else brain stays on 'gather', npcBrain will pick new target next tick + } + continue; + } + + // Not yet gathering — check if at resource tile and idle + if (movement.state !== 'idle') continue; + + const resourceTile = map.resourceTiles.find(r => r.x === pos.x && r.y === pos.y); + if (!resourceTile) continue; + + // Start gathering + const str = getEffectiveStat(world, entity, 'strength'); + const baseTicks = industryConfig.gatherBaseTicks; + const ticks = Math.max(1, Math.round(baseTicks * (1 - (str - 10) * industryConfig.gatherStrengthModifier))); + + world.addComponent(entity, 'gatheringState', { + resourceType: resourceTile.resourceType, + ticksRemaining: ticks, + }); + + // Recover productivity on first tick too + needs.productivity = Math.min(100, needs.productivity + PRODUCTIVITY_RECOVERY_RATE); + } +}