From 5aa42d829b450a2fc967493edeccb26b2bed1276 Mon Sep 17 00:00:00 2001 From: root Date: Sun, 8 Mar 2026 23:22:58 +0000 Subject: [PATCH] feat(systems): add buildingSystem with structure progress tracking Co-Authored-By: Claude Opus 4.6 --- .../systems/__tests__/buildingSystem.test.ts | 137 ++++++++++++++++++ server/src/systems/buildingSystem.ts | 51 +++++++ 2 files changed, 188 insertions(+) create mode 100644 server/src/systems/__tests__/buildingSystem.test.ts create mode 100644 server/src/systems/buildingSystem.ts diff --git a/server/src/systems/__tests__/buildingSystem.test.ts b/server/src/systems/__tests__/buildingSystem.test.ts new file mode 100644 index 0000000..a5b1896 --- /dev/null +++ b/server/src/systems/__tests__/buildingSystem.test.ts @@ -0,0 +1,137 @@ +import { describe, it, expect } from 'vitest'; +import { World } from '../../ecs/World.js'; +import { GameMap } from '../../map/GameMap.js'; +import { buildingSystem, type BuildingState, type StructureData } from '../buildingSystem.js'; +import type { Needs, Movement, NPCBrain, Position, Stats, StatModifiers } from '@dflike/shared'; +import { PRODUCTIVITY_RECOVERY_RATE } from '@dflike/shared'; + +function createBuildingNPC(world: World, x: number, y: number, overrides?: { 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: 'build', goalQueue: [] }); + world.addComponent>(e, 'inventory', new Map([['log', 10], ['stone', 5]])); + 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: [] }); + return e; +} + +describe('buildingSystem', () => { + it('progresses building when NPC has buildingState', () => { + const world = new World(); + const map = new GameMap(10, 10); + const e = createBuildingNPC(world, 5, 5); + const structureEntity = world.createEntity(); + world.addComponent(structureEntity, 'position', { x: 5, y: 4 }); + world.addComponent(structureEntity, 'structure', { + type: 'stockpile', + subtype: 'general', + inventory: new Map(), + buildProgress: 0, + builderEntityId: e, + }); + world.addComponent(e, 'buildingState', { + structureEntityId: structureEntity, + recipeId: 'build_stockpile', + }); + buildingSystem(world, map); + const structure = world.getComponent(structureEntity, 'structure')!; + expect(structure.buildProgress).toBeGreaterThan(0); + }); + + it('recovers productivity while building', () => { + const world = new World(); + const map = new GameMap(10, 10); + const e = createBuildingNPC(world, 5, 5, { productivity: 30 }); + const structureEntity = world.createEntity(); + world.addComponent(structureEntity, 'position', { x: 5, y: 4 }); + world.addComponent(structureEntity, 'structure', { + type: 'stockpile', + subtype: 'general', + inventory: new Map(), + buildProgress: 0, + builderEntityId: e, + }); + world.addComponent(e, 'buildingState', { + structureEntityId: structureEntity, + recipeId: 'build_stockpile', + }); + buildingSystem(world, map); + const needs = world.getComponent(e, 'needs')!; + expect(needs.productivity).toBeCloseTo(30 + PRODUCTIVITY_RECOVERY_RATE); + }); + + it('completes building when progress reaches 1', () => { + const world = new World(); + const map = new GameMap(10, 10); + const e = createBuildingNPC(world, 5, 5); + const structureEntity = world.createEntity(); + world.addComponent(structureEntity, 'position', { x: 5, y: 4 }); + world.addComponent(structureEntity, 'structure', { + type: 'stockpile', + subtype: 'general', + inventory: new Map(), + buildProgress: 0.99, + builderEntityId: e, + }); + world.addComponent(e, 'buildingState', { + structureEntityId: structureEntity, + recipeId: 'build_stockpile', + }); + buildingSystem(world, map); + const structure = world.getComponent(structureEntity, 'structure')!; + expect(structure.buildProgress).toBeUndefined(); + expect(structure.builderEntityId).toBeUndefined(); + expect(world.getComponent(e, 'buildingState')).toBeUndefined(); + }); + + it('skips NPC whose goal is not build', () => { + const world = new World(); + const map = new GameMap(10, 10); + const e = createBuildingNPC(world, 5, 5); + const brain = world.getComponent(e, 'npcBrain')!; + brain.currentGoal = 'wander'; + const structureEntity = world.createEntity(); + world.addComponent(structureEntity, 'position', { x: 5, y: 4 }); + world.addComponent(structureEntity, 'structure', { + type: 'stockpile', + subtype: 'general', + inventory: new Map(), + buildProgress: 0, + builderEntityId: e, + }); + world.addComponent(e, 'buildingState', { + structureEntityId: structureEntity, + recipeId: 'build_stockpile', + }); + buildingSystem(world, map); + const structure = world.getComponent(structureEntity, 'structure')!; + expect(structure.buildProgress).toBe(0); + }); + + it('resets goal to wander when productivity satisfied after building completes', () => { + const world = new World(); + const map = new GameMap(10, 10); + const e = createBuildingNPC(world, 5, 5, { productivity: 70 }); + const structureEntity = world.createEntity(); + world.addComponent(structureEntity, 'position', { x: 5, y: 4 }); + world.addComponent(structureEntity, 'structure', { + type: 'stockpile', + subtype: 'general', + inventory: new Map(), + buildProgress: 0.99, + builderEntityId: e, + }); + world.addComponent(e, 'buildingState', { + structureEntityId: structureEntity, + recipeId: 'build_stockpile', + }); + buildingSystem(world, map); + const brain = world.getComponent(e, 'npcBrain')!; + expect(brain.currentGoal).toBe('wander'); + }); +}); diff --git a/server/src/systems/buildingSystem.ts b/server/src/systems/buildingSystem.ts new file mode 100644 index 0000000..4197baa --- /dev/null +++ b/server/src/systems/buildingSystem.ts @@ -0,0 +1,51 @@ +import { + PRODUCTIVITY_RECOVERY_RATE, PRODUCTIVITY_THRESHOLD, + type Needs, type NPCBrain, +} from '@dflike/shared'; +import type { World } from '../ecs/World.js'; +import type { GameMap } from '../map/GameMap.js'; +import { industryConfig } from '../config/industryConfig.js'; + +export interface StructureData { + type: 'stockpile' | 'workshop'; + subtype: string; // e.g. 'general', 'workbench', 'kiln' + inventory: Map; + buildProgress?: number; // 0-1, undefined = complete + builderEntityId?: number; +} + +export interface BuildingState { + structureEntityId: number; + recipeId: string; +} + +export function buildingSystem(world: World, _map: GameMap): void { + for (const entity of world.query('npcBrain', 'needs')) { + const brain = world.getComponent(entity, 'npcBrain')!; + if (brain.currentGoal !== 'build') continue; + + const bs = world.getComponent(entity, 'buildingState'); + if (!bs) continue; + + const needs = world.getComponent(entity, 'needs')!; + const structure = world.getComponent(bs.structureEntityId, 'structure'); + if (!structure || structure.buildProgress === undefined) { + world.removeComponent(entity, 'buildingState'); + brain.currentGoal = 'wander'; + continue; + } + + structure.buildProgress += industryConfig.buildProgressPerTick; + needs.productivity = Math.min(100, needs.productivity + PRODUCTIVITY_RECOVERY_RATE); + + if (structure.buildProgress >= 1) { + structure.buildProgress = undefined; + structure.builderEntityId = undefined; + world.removeComponent(entity, 'buildingState'); + + if (needs.productivity >= PRODUCTIVITY_THRESHOLD) { + brain.currentGoal = 'wander'; + } + } + } +}