From 53750df48b6bb3e7d832bb7c3f397d7c503b264c Mon Sep 17 00:00:00 2001 From: root Date: Sun, 8 Mar 2026 22:40:09 +0000 Subject: [PATCH 01/14] feat(shared): add productivity need, gather goal, inventory, STONE terrain Co-Authored-By: Claude Opus 4.6 --- shared/src/constants.ts | 6 ++++++ shared/src/types.ts | 8 ++++++-- 2 files changed, 12 insertions(+), 2 deletions(-) diff --git a/shared/src/constants.ts b/shared/src/constants.ts index ed9edc8..3b5c0e0 100644 --- a/shared/src/constants.ts +++ b/shared/src/constants.ts @@ -16,6 +16,11 @@ export const HUNGER_THRESHOLD = 30; export const ENERGY_THRESHOLD = 20; export const NEED_RECOVERY_RATE = 0.5; +// Productivity need +export const PRODUCTIVITY_DECAY_PER_TICK = 0.02; +export const PRODUCTIVITY_THRESHOLD = 40; +export const PRODUCTIVITY_RECOVERY_RATE = 0.5; + // Day/night cycle export const DAY_NIGHT_RATIO = 2; // day is 2x as long as night export const DAY_HOURS = 12; // game hours of daytime @@ -78,6 +83,7 @@ export const Terrain = { GRASS: 0, WATER: 1, DIRT: 2, + STONE: 3, } as const; export type Terrain = (typeof Terrain)[keyof typeof Terrain]; diff --git a/shared/src/types.ts b/shared/src/types.ts index 02e39a1..61a181b 100644 --- a/shared/src/types.ts +++ b/shared/src/types.ts @@ -23,7 +23,7 @@ export interface MemoryEvent { detail: string; oldTier?: string; newTier?: string; - need?: 'hunger' | 'energy'; + need?: 'hunger' | 'energy' | 'productivity'; oldGoal?: string; newGoal?: string; } @@ -48,6 +48,7 @@ export interface Appearance { export interface Needs { hunger: number; // 0-100 energy: number; // 0-100 + productivity: number; // 0-100 } export interface Stats { @@ -78,7 +79,7 @@ export interface StatModifiers { } export type MovementState = 'idle' | 'walking'; -export type GoalType = 'wander' | 'eat' | 'rest'; +export type GoalType = 'wander' | 'eat' | 'rest' | 'gather'; export interface Movement { state: MovementState; @@ -96,6 +97,7 @@ export interface PlayerControlled { export interface NPCBrain { currentGoal: GoalType | null; goalQueue: GoalType[]; + gatherTarget?: { x: number; y: number; resourceType: string } | null; } export type InteractionPhase = 'none' | 'facing' | 'pausing' | 'emoting' | 'proposing'; @@ -141,6 +143,7 @@ export interface EntityState { playerControlled?: PlayerControlled; name?: string; // NPC display name backstory?: string; + inventory?: Record; socialState?: { phase: InteractionPhase; partnerId: EntityId | null; @@ -166,6 +169,7 @@ export interface WorldState { terrain: number[]; // flat array [y * width + x], terrain type per tile decorations: number[]; // flat array [y * width + x], decoration tile index (-1 = none) trunkDecorations: number[]; // flat array [y * width + x], trunk tile index (-1 = none) + resourceTiles: Array<{ x: number; y: number; resourceType: string }>; } export interface StateUpdate { From 4f48c65fe326f67c4b22f359a21d017eb5c9109d Mon Sep 17 00:00:00 2001 From: root Date: Sun, 8 Mar 2026 22:40:33 +0000 Subject: [PATCH 02/14] feat(config): add industry config for gathering and map generation Co-Authored-By: Claude Opus 4.6 --- server/src/config/industryConfig.ts | 12 ++++++++++++ 1 file changed, 12 insertions(+) create mode 100644 server/src/config/industryConfig.ts diff --git a/server/src/config/industryConfig.ts b/server/src/config/industryConfig.ts new file mode 100644 index 0000000..d4ad044 --- /dev/null +++ b/server/src/config/industryConfig.ts @@ -0,0 +1,12 @@ +export const industryConfig = { + // Gathering + gatherBaseTicks: 30, + gatherStrengthModifier: 0.03, // per point from 10 + gatherYield: 1, + + // Map generation + waterPondCount: { min: 2, max: 4 }, + waterPondSize: { min: 3, max: 6 }, + stoneClusterCount: { min: 3, max: 5 }, + stoneClusterSize: { min: 2, max: 4 }, +} as const; From 25546b5f44bb5f10fc9ad3e1882772e8e57ac469 Mon Sep 17 00:00:00 2001 From: root Date: Sun, 8 Mar 2026 22:41:03 +0000 Subject: [PATCH 03/14] feat(industry): add ItemRegistry with seed resources Co-Authored-By: Claude Opus 4.6 --- .../industry/__tests__/itemRegistry.test.ts | 57 ++++++++++++++++++ server/src/industry/itemRegistry.ts | 60 +++++++++++++++++++ 2 files changed, 117 insertions(+) create mode 100644 server/src/industry/__tests__/itemRegistry.test.ts create mode 100644 server/src/industry/itemRegistry.ts diff --git a/server/src/industry/__tests__/itemRegistry.test.ts b/server/src/industry/__tests__/itemRegistry.test.ts new file mode 100644 index 0000000..44e51da --- /dev/null +++ b/server/src/industry/__tests__/itemRegistry.test.ts @@ -0,0 +1,57 @@ +import { describe, it, expect } from 'vitest'; +import { ItemRegistry, type ItemDefinition } from '../itemRegistry.js'; + +describe('ItemRegistry', () => { + it('registers and retrieves an item', () => { + const registry = new ItemRegistry(); + const item: ItemDefinition = { + id: 'log', + name: 'Log', + description: 'A wooden log', + category: 'resource', + source: 'seed', + }; + registry.register(item); + expect(registry.get('log')).toEqual(item); + }); + + it('returns undefined for unknown items', () => { + const registry = new ItemRegistry(); + expect(registry.get('nonexistent')).toBeUndefined(); + }); + + it('prevents duplicate registration', () => { + const registry = new ItemRegistry(); + const item: ItemDefinition = { + id: 'log', + name: 'Log', + description: 'A wooden log', + category: 'resource', + source: 'seed', + }; + registry.register(item); + expect(() => registry.register(item)).toThrow('already registered'); + }); + + it('lists all registered items', () => { + const registry = new ItemRegistry(); + registry.register({ id: 'log', name: 'Log', description: '', category: 'resource', source: 'seed' }); + registry.register({ id: 'stone', name: 'Stone', description: '', category: 'resource', source: 'seed' }); + expect(registry.getAll()).toHaveLength(2); + }); + + it('finds items by category', () => { + const registry = new ItemRegistry(); + registry.register({ id: 'log', name: 'Log', description: '', category: 'resource', source: 'seed' }); + registry.register({ id: 'axe', name: 'Axe', description: '', category: 'tool', source: 'seed' }); + expect(registry.getByCategory('resource')).toHaveLength(1); + expect(registry.getByCategory('resource')[0].id).toBe('log'); + }); + + it('createDefault registers seed resources', () => { + const registry = ItemRegistry.createDefault(); + expect(registry.get('log')).toBeDefined(); + expect(registry.get('stone')).toBeDefined(); + expect(registry.get('water')).toBeDefined(); + }); +}); diff --git a/server/src/industry/itemRegistry.ts b/server/src/industry/itemRegistry.ts new file mode 100644 index 0000000..b02ed81 --- /dev/null +++ b/server/src/industry/itemRegistry.ts @@ -0,0 +1,60 @@ +import type { EntityId } from '@dflike/shared'; + +export interface ItemDefinition { + id: string; + name: string; + description: string; + category: 'resource' | 'tool' | 'material' | 'structure'; + sourceTerrain?: number[]; + source: 'seed' | 'invented'; + inventedBy?: { entityId: EntityId; name: string; tick: number; day: number }; +} + +export class ItemRegistry { + private items = new Map(); + + register(item: ItemDefinition): void { + if (this.items.has(item.id)) { + throw new Error(`Item '${item.id}' already registered`); + } + this.items.set(item.id, item); + } + + get(id: string): ItemDefinition | undefined { + return this.items.get(id); + } + + getAll(): ItemDefinition[] { + return [...this.items.values()]; + } + + getByCategory(category: ItemDefinition['category']): ItemDefinition[] { + return this.getAll().filter(i => i.category === category); + } + + static createDefault(): ItemRegistry { + const registry = new ItemRegistry(); + registry.register({ + id: 'log', + name: 'Log', + description: 'A rough-hewn wooden log', + category: 'resource', + source: 'seed', + }); + registry.register({ + id: 'stone', + name: 'Stone', + description: 'A chunk of raw stone', + category: 'resource', + source: 'seed', + }); + registry.register({ + id: 'water', + name: 'Water', + description: 'Fresh water collected from a pond', + category: 'resource', + source: 'seed', + }); + return registry; + } +} From b3a9197aecf4343ab6d815076168450226438b6d Mon Sep 17 00:00:00 2001 From: root Date: Sun, 8 Mar 2026 22:41:18 +0000 Subject: [PATCH 04/14] feat(industry): add inventory helper functions Co-Authored-By: Claude Opus 4.6 --- .../__tests__/inventoryHelpers.test.ts | 64 +++++++++++++++++++ server/src/industry/inventoryHelpers.ts | 25 ++++++++ 2 files changed, 89 insertions(+) create mode 100644 server/src/industry/__tests__/inventoryHelpers.test.ts create mode 100644 server/src/industry/inventoryHelpers.ts diff --git a/server/src/industry/__tests__/inventoryHelpers.test.ts b/server/src/industry/__tests__/inventoryHelpers.test.ts new file mode 100644 index 0000000..217009b --- /dev/null +++ b/server/src/industry/__tests__/inventoryHelpers.test.ts @@ -0,0 +1,64 @@ +import { describe, it, expect } from 'vitest'; +import { addItem, removeItem, hasItem, getItemCount } from '../inventoryHelpers.js'; + +describe('inventoryHelpers', () => { + it('addItem adds to empty inventory', () => { + const inv = new Map(); + addItem(inv, 'log', 1); + expect(inv.get('log')).toBe(1); + }); + + it('addItem stacks quantities', () => { + const inv = new Map(); + addItem(inv, 'log', 2); + addItem(inv, 'log', 3); + expect(inv.get('log')).toBe(5); + }); + + it('removeItem subtracts quantity', () => { + const inv = new Map(); + addItem(inv, 'log', 5); + const removed = removeItem(inv, 'log', 3); + expect(removed).toBe(true); + expect(inv.get('log')).toBe(2); + }); + + it('removeItem removes entry when quantity reaches 0', () => { + const inv = new Map(); + addItem(inv, 'log', 3); + removeItem(inv, 'log', 3); + expect(inv.has('log')).toBe(false); + }); + + it('removeItem returns false when insufficient', () => { + const inv = new Map(); + addItem(inv, 'log', 1); + expect(removeItem(inv, 'log', 5)).toBe(false); + expect(inv.get('log')).toBe(1); + }); + + it('removeItem returns false for missing item', () => { + const inv = new Map(); + expect(removeItem(inv, 'log', 1)).toBe(false); + }); + + it('hasItem checks quantity', () => { + const inv = new Map(); + addItem(inv, 'log', 3); + expect(hasItem(inv, 'log', 2)).toBe(true); + expect(hasItem(inv, 'log', 3)).toBe(true); + expect(hasItem(inv, 'log', 4)).toBe(false); + }); + + it('hasItem defaults to checking for 1', () => { + const inv = new Map(); + expect(hasItem(inv, 'log')).toBe(false); + addItem(inv, 'log', 1); + expect(hasItem(inv, 'log')).toBe(true); + }); + + it('getItemCount returns 0 for missing item', () => { + const inv = new Map(); + expect(getItemCount(inv, 'log')).toBe(0); + }); +}); diff --git a/server/src/industry/inventoryHelpers.ts b/server/src/industry/inventoryHelpers.ts new file mode 100644 index 0000000..5a41a11 --- /dev/null +++ b/server/src/industry/inventoryHelpers.ts @@ -0,0 +1,25 @@ +export type Inventory = Map; + +export function addItem(inv: Inventory, itemId: string, quantity: number): void { + inv.set(itemId, (inv.get(itemId) ?? 0) + quantity); +} + +export function removeItem(inv: Inventory, itemId: string, quantity: number): boolean { + const current = inv.get(itemId) ?? 0; + if (current < quantity) return false; + const remaining = current - quantity; + if (remaining === 0) { + inv.delete(itemId); + } else { + inv.set(itemId, remaining); + } + return true; +} + +export function hasItem(inv: Inventory, itemId: string, quantity = 1): boolean { + return (inv.get(itemId) ?? 0) >= quantity; +} + +export function getItemCount(inv: Inventory, itemId: string): number { + return inv.get(itemId) ?? 0; +} From 429523f3b44681c3b3e0c149bc610b448751e28b Mon Sep 17 00:00:00 2001 From: root Date: Sun, 8 Mar 2026 22:43:47 +0000 Subject: [PATCH 05/14] feat(map): generate water ponds, stone deposits, and tree resources Co-Authored-By: Claude Opus 4.6 --- server/src/map/GameMap.ts | 1 + server/src/map/__tests__/mapGenerator.test.ts | 85 ++++++++++++ server/src/map/mapGenerator.ts | 128 +++++++++++++++++- 3 files changed, 209 insertions(+), 5 deletions(-) create mode 100644 server/src/map/__tests__/mapGenerator.test.ts diff --git a/server/src/map/GameMap.ts b/server/src/map/GameMap.ts index 98a9207..493695c 100644 --- a/server/src/map/GameMap.ts +++ b/server/src/map/GameMap.ts @@ -13,6 +13,7 @@ export class GameMap { terrain: number[] = []; // flat array of Terrain values decorations: number[] = []; // flat array of canopy tile indices (-1 = none) trunkDecorations: number[] = []; // flat array of trunk tile indices (-1 = none) + resourceTiles: Array<{ x: number; y: number; resourceType: string }> = []; constructor(width = WORLD_WIDTH, height = WORLD_HEIGHT) { this.width = width; diff --git a/server/src/map/__tests__/mapGenerator.test.ts b/server/src/map/__tests__/mapGenerator.test.ts new file mode 100644 index 0000000..5b0b74e --- /dev/null +++ b/server/src/map/__tests__/mapGenerator.test.ts @@ -0,0 +1,85 @@ +import { describe, it, expect } from 'vitest'; +import { generateMap } from '../mapGenerator.js'; +import { Terrain } from '@dflike/shared'; + +describe('generateMap — resource generation', () => { + it('generates water pond tiles', () => { + const map = generateMap(32, 32, 42); + const waterCount = map.terrain.filter(t => t === Terrain.WATER).length; + expect(waterCount).toBeGreaterThan(0); + }); + + it('water tiles are marked as obstacles', () => { + const map = generateMap(32, 32, 42); + for (let y = 0; y < 32; y++) { + for (let x = 0; x < 32; x++) { + if (map.terrain[y * 32 + x] === Terrain.WATER) { + expect(map.obstacles.has(`${x},${y}`)).toBe(true); + } + } + } + }); + + it('generates stone deposit tiles', () => { + const map = generateMap(32, 32, 42); + const stoneCount = map.terrain.filter(t => t === Terrain.STONE).length; + expect(stoneCount).toBeGreaterThan(0); + }); + + it('stone tiles are walkable (not in obstacles)', () => { + const map = generateMap(32, 32, 42); + for (let y = 0; y < 32; y++) { + for (let x = 0; x < 32; x++) { + if (map.terrain[y * 32 + x] === Terrain.STONE) { + expect(map.obstacles.has(`${x},${y}`)).toBe(false); + } + } + } + }); + + it('includes resourceTiles for all resource types', () => { + const map = generateMap(32, 32, 42); + const types = new Set(map.resourceTiles.map(r => r.resourceType)); + expect(types.has('log')).toBe(true); + expect(types.has('stone')).toBe(true); + expect(types.has('water')).toBe(true); + }); + + it('water resource tiles are adjacent to water terrain, not on water', () => { + const map = generateMap(32, 32, 42); + const waterResources = map.resourceTiles.filter(r => r.resourceType === 'water'); + for (const r of waterResources) { + expect(map.terrain[r.y * 32 + r.x]).not.toBe(Terrain.WATER); + const neighbors = [ + { x: r.x - 1, y: r.y }, { x: r.x + 1, y: r.y }, + { x: r.x, y: r.y - 1 }, { x: r.x, y: r.y + 1 }, + ].filter(n => n.x >= 0 && n.x < 32 && n.y >= 0 && n.y < 32); + const hasWaterNeighbor = neighbors.some(n => map.terrain[n.y * 32 + n.x] === Terrain.WATER); + expect(hasWaterNeighbor).toBe(true); + } + }); + + it('generates tree decorations', () => { + const map = generateMap(32, 32, 42); + const trunkCount = map.trunkDecorations.filter(t => t !== -1).length; + expect(trunkCount).toBeGreaterThan(0); + }); + + it('tree trunk tiles are marked as obstacles', () => { + const map = generateMap(32, 32, 42); + for (let y = 0; y < 32; y++) { + for (let x = 0; x < 32; x++) { + if (map.trunkDecorations[y * 32 + x] !== -1) { + expect(map.obstacles.has(`${x},${y}`)).toBe(true); + } + } + } + }); + + it('deterministic with same seed', () => { + const a = generateMap(32, 32, 123); + const b = generateMap(32, 32, 123); + expect(a.terrain).toEqual(b.terrain); + expect(a.resourceTiles).toEqual(b.resourceTiles); + }); +}); diff --git a/server/src/map/mapGenerator.ts b/server/src/map/mapGenerator.ts index c243d3f..a49af25 100644 --- a/server/src/map/mapGenerator.ts +++ b/server/src/map/mapGenerator.ts @@ -1,12 +1,14 @@ import { WORLD_WIDTH, WORLD_HEIGHT, Terrain } from '@dflike/shared'; +import { industryConfig } from '../config/industryConfig.js'; export interface GeneratedMap { - terrain: number[]; // flat array, Terrain values - decorations: number[]; // flat array, canopy tile index (-1 = none) - trunkDecorations: number[]; // flat array, trunk tile index (-1 = none) - obstacles: Set; // "x,y" keys for non-walkable tiles + terrain: number[]; + decorations: number[]; + trunkDecorations: number[]; + obstacles: Set; foodPositions: { x: number; y: number }[]; restPositions: { x: number; y: number }[]; + resourceTiles: Array<{ x: number; y: number; resourceType: string }>; } // Simple seeded pseudo-random (mulberry32) @@ -20,15 +22,21 @@ function createRng(seed: number) { }; } +function randRange(rng: () => number, min: number, max: number): number { + return min + Math.floor(rng() * (max - min + 1)); +} + export function generateMap( width = WORLD_WIDTH, height = WORLD_HEIGHT, _seed?: number, ): GeneratedMap { + const rng = _seed !== undefined ? createRng(_seed) : Math.random; const terrain = new Array(width * height).fill(Terrain.GRASS); const decorations = new Array(width * height).fill(-1); const trunkDecorations = new Array(width * height).fill(-1); const obstacles = new Set(); + const resourceTiles: Array<{ x: number; y: number; resourceType: string }> = []; const foodPositions = [ { x: Math.floor(width * 0.25), y: Math.floor(height * 0.25) }, @@ -39,5 +47,115 @@ export function generateMap( { x: Math.floor(width * 0.25), y: Math.floor(height * 0.75) }, ]; - return { terrain, decorations, trunkDecorations, obstacles, foodPositions, restPositions }; + // Reserve positions that must stay clear + const reserved = new Set(); + for (const p of [...foodPositions, ...restPositions]) { + reserved.add(`${p.x},${p.y}`); + } + + // --- Water ponds --- + const pondCount = randRange(rng, industryConfig.waterPondCount.min, industryConfig.waterPondCount.max); + for (let p = 0; p < pondCount; p++) { + const cx = randRange(rng, 4, width - 5); + const cy = randRange(rng, 4, height - 5); + const size = randRange(rng, industryConfig.waterPondSize.min, industryConfig.waterPondSize.max); + + // Random walk to create organic pond shape + const pondTiles = new Set(); + let wx = cx, wy = cy; + for (let s = 0; s < size * 3; s++) { + const key = `${wx},${wy}`; + if (!reserved.has(key) && wx > 1 && wx < width - 2 && wy > 1 && wy < height - 2) { + pondTiles.add(key); + } + const dir = Math.floor(rng() * 4); + if (dir === 0) wx++; + else if (dir === 1) wx--; + else if (dir === 2) wy++; + else wy--; + } + + for (const key of pondTiles) { + const [x, y] = key.split(',').map(Number); + terrain[y * width + x] = Terrain.WATER; + obstacles.add(key); + } + + // Water gather points: walkable tiles adjacent to water + const waterResourceSet = new Set(); + for (const key of pondTiles) { + const [x, y] = key.split(',').map(Number); + for (const [nx, ny] of [[x-1,y],[x+1,y],[x,y-1],[x,y+1]] as [number,number][]) { + if (nx >= 0 && nx < width && ny >= 0 && ny < height) { + const nk = `${nx},${ny}`; + if (!pondTiles.has(nk) && !obstacles.has(nk) && terrain[ny * width + nx] !== Terrain.WATER) { + if (!waterResourceSet.has(nk)) { + waterResourceSet.add(nk); + resourceTiles.push({ x: nx, y: ny, resourceType: 'water' }); + } + } + } + } + } + } + + // Post-process: remove water resource tiles that ended up under a later pond + for (let i = resourceTiles.length - 1; i >= 0; i--) { + const r = resourceTiles[i]; + if (r.resourceType === 'water' && terrain[r.y * width + r.x] === Terrain.WATER) { + resourceTiles.splice(i, 1); + } + } + + // --- Stone deposits --- + const stoneCount = randRange(rng, industryConfig.stoneClusterCount.min, industryConfig.stoneClusterCount.max); + for (let s = 0; s < stoneCount; s++) { + const cx = randRange(rng, 3, width - 4); + const cy = randRange(rng, 3, height - 4); + const size = randRange(rng, industryConfig.stoneClusterSize.min, industryConfig.stoneClusterSize.max); + + for (let i = 0; i < size; i++) { + const ox = randRange(rng, -1, 1); + const oy = randRange(rng, -1, 1); + const x = cx + ox; + const y = cy + oy; + const key = `${x},${y}`; + if (!reserved.has(key) && !obstacles.has(key) && terrain[y * width + x] === Terrain.GRASS) { + terrain[y * width + x] = Terrain.STONE; + resourceTiles.push({ x, y, resourceType: 'stone' }); + } + } + } + + // --- Trees --- + const treeClusterCount = randRange(rng, 8, 14); + for (let c = 0; c < treeClusterCount; c++) { + const cx = randRange(rng, 3, width - 4); + const cy = randRange(rng, 3, height - 4); + const treeCount = randRange(rng, 2, 5); + + for (let t = 0; t < treeCount; t++) { + const x = cx + randRange(rng, -2, 2); + const y = cy + randRange(rng, -2, 2); + if (x < 1 || x >= width - 1 || y < 1 || y >= height - 1) continue; + const key = `${x},${y}`; + if (reserved.has(key) || obstacles.has(key) || terrain[y * width + x] !== Terrain.GRASS) continue; + + // Trunk at (x, y) — obstacle and resource tile + const trunkTile = randRange(rng, 0, 5); + trunkDecorations[y * width + x] = trunkTile; + obstacles.add(key); + + // Canopy at (x, y-1) — visual only + if (y > 0) { + const canopyTile = randRange(rng, 0, 5); + decorations[(y - 1) * width + x] = canopyTile; + } + + // Mark trunk tile as log resource + resourceTiles.push({ x, y, resourceType: 'log' }); + } + } + + return { terrain, decorations, trunkDecorations, obstacles, foodPositions, restPositions, resourceTiles }; } From 096597e7e564bf7cd774c20810b25ff37c917c93 Mon Sep 17 00:00:00 2001 From: root Date: Sun, 8 Mar 2026 22:44:59 +0000 Subject: [PATCH 06/14] feat(loop): register ItemRegistry and load resource tiles Co-Authored-By: Claude Opus 4.6 --- server/src/game/GameLoop.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/server/src/game/GameLoop.ts b/server/src/game/GameLoop.ts index b15ad8b..77e3034 100644 --- a/server/src/game/GameLoop.ts +++ b/server/src/game/GameLoop.ts @@ -9,6 +9,7 @@ import { socialSystem } from '../systems/socialSystem.js'; import { statModifierSystem } from '../systems/statModifierSystem.js'; import { relationshipSystem } from '../systems/relationshipSystem.js'; import { createBondRegistry } from '../systems/bondRegistry.js'; +import { ItemRegistry } from '../industry/itemRegistry.js'; import { spawnNPC } from './spawner.js'; import { createLlmService, type LlmService } from '../llm/llmService.js'; import { generateBackstory } from '../llm/backstoryGenerator.js'; @@ -33,6 +34,7 @@ export class GameLoop { constructor() { this.world = new World(); this.world.setSingleton('bondRegistry', createBondRegistry()); + this.world.setSingleton('itemRegistry', ItemRegistry.createDefault()); this.map = new GameMap(); this.llmService = createLlmService(); this.narrationService = createNarrationService(this.llmService); @@ -51,6 +53,9 @@ export class GameLoop { this.map.trunkDecorations = generated.trunkDecorations; this.map.loadObstacles(generated.obstacles); + // Load resource tiles + this.map.resourceTiles = generated.resourceTiles; + // Points of interest from generator for (const pos of generated.foodPositions) { this.map.addPointOfInterest({ type: 'food', position: pos }); From 4670bfc08becf7d0a1d6ce63fa9e179ed146ebc6 Mon Sep 17 00:00:00 2001 From: root Date: Sun, 8 Mar 2026 22:45:34 +0000 Subject: [PATCH 07/14] feat(spawner): add productivity need and inventory to spawned NPCs Co-Authored-By: Claude Opus 4.6 --- server/src/game/__tests__/spawner.test.ts | 21 ++++++++++++++++++- server/src/game/spawner.ts | 2 ++ .../__tests__/narrationEmitter.test.ts | 2 +- .../__tests__/relationshipSystem.test.ts | 2 +- .../systems/__tests__/socialSystem.test.ts | 1 + .../__tests__/statModifierSystem.test.ts | 2 +- .../systems/__tests__/thoughtSystem.test.ts | 2 +- 7 files changed, 27 insertions(+), 5 deletions(-) diff --git a/server/src/game/__tests__/spawner.test.ts b/server/src/game/__tests__/spawner.test.ts index 367d52a..bb9cae6 100644 --- a/server/src/game/__tests__/spawner.test.ts +++ b/server/src/game/__tests__/spawner.test.ts @@ -2,7 +2,7 @@ import { describe, it, expect } from 'vitest'; import { World } from '../../ecs/World.js'; import { GameMap } from '../../map/GameMap.js'; import { spawnNPC } from '../spawner.js'; -import type { Position, Stats } from '@dflike/shared'; +import type { Position, Stats, Needs } from '@dflike/shared'; describe('spawnNPC', () => { it('spawns at random position when no hint given', () => { @@ -49,6 +49,25 @@ describe('spawnNPC', () => { expect(world.getComponent(entity, 'backstory')).toBe(''); }); + it('spawned NPC has productivity need', () => { + const world = new World(); + const map = new GameMap(); + const entity = spawnNPC(world, map); + const needs = world.getComponent(entity, 'needs')!; + expect(needs.productivity).toBeDefined(); + expect(needs.productivity).toBeGreaterThanOrEqual(60); + }); + + it('spawned NPC has empty inventory', () => { + const world = new World(); + const map = new GameMap(); + const entity = spawnNPC(world, map); + const inventory = world.getComponent>(entity, 'inventory')!; + expect(inventory).toBeDefined(); + expect(inventory).toBeInstanceOf(Map); + expect(inventory.size).toBe(0); + }); + it('generates stats in 3-18 range', () => { const world = new World(); const map = new GameMap(); diff --git a/server/src/game/spawner.ts b/server/src/game/spawner.ts index 9ad2367..85b8ddd 100644 --- a/server/src/game/spawner.ts +++ b/server/src/game/spawner.ts @@ -16,6 +16,7 @@ export function spawnNPC(world: World, map: GameMap, positionHint?: Position, ev world.addComponent(entity, 'needs', { hunger: 40 + Math.random() * 40, // 40-80 energy: 40 + Math.random() * 40, + productivity: 60 + Math.random() * 40, // 60-100 }); world.addComponent(entity, 'movement', { state: 'idle', @@ -46,6 +47,7 @@ export function spawnNPC(world: World, map: GameMap, positionHint?: Position, ev world.addComponent(entity, 'statModifiers', { modifiers: [] }); world.addComponent(entity, 'relationships', new Map()); world.addComponent(entity, 'backstory', ''); + world.addComponent>(entity, 'inventory', new Map()); eventMemoryService?.record(entity, { type: 'spawned', diff --git a/server/src/systems/__tests__/narrationEmitter.test.ts b/server/src/systems/__tests__/narrationEmitter.test.ts index 4b4072f..cab9486 100644 --- a/server/src/systems/__tests__/narrationEmitter.test.ts +++ b/server/src/systems/__tests__/narrationEmitter.test.ts @@ -33,7 +33,7 @@ function createNPC( ): EntityId { const e = world.createEntity(); world.addComponent(e, 'position', { x: 0, y: 0 }); - world.addComponent(e, 'needs', { hunger: 80, energy: 80 }); + world.addComponent(e, 'needs', { hunger: 80, energy: 80, productivity: 80 }); world.addComponent(e, 'movement', { state: 'idle', target: null, path: [], direction: 0, moveProgress: 0, }); diff --git a/server/src/systems/__tests__/relationshipSystem.test.ts b/server/src/systems/__tests__/relationshipSystem.test.ts index 1b71d6b..5d97c9b 100644 --- a/server/src/systems/__tests__/relationshipSystem.test.ts +++ b/server/src/systems/__tests__/relationshipSystem.test.ts @@ -14,7 +14,7 @@ function createNPC( ): EntityId { const e = world.createEntity(); world.addComponent(e, 'position', { x, y }); - world.addComponent(e, 'needs', { hunger: 80, energy: 80 }); + world.addComponent(e, 'needs', { hunger: 80, energy: 80, productivity: 80 }); world.addComponent(e, 'movement', { state: 'idle', target: null, path: [], direction: 0, moveProgress: 0, }); diff --git a/server/src/systems/__tests__/socialSystem.test.ts b/server/src/systems/__tests__/socialSystem.test.ts index 10fac21..3f37066 100644 --- a/server/src/systems/__tests__/socialSystem.test.ts +++ b/server/src/systems/__tests__/socialSystem.test.ts @@ -22,6 +22,7 @@ function createNPC( world.addComponent(e, 'needs', { hunger: opts?.hunger ?? 80, energy: opts?.energy ?? 80, + productivity: 80, }); world.addComponent(e, 'movement', { state: opts?.walking ? 'walking' : 'idle', diff --git a/server/src/systems/__tests__/statModifierSystem.test.ts b/server/src/systems/__tests__/statModifierSystem.test.ts index dffef62..97199ed 100644 --- a/server/src/systems/__tests__/statModifierSystem.test.ts +++ b/server/src/systems/__tests__/statModifierSystem.test.ts @@ -18,7 +18,7 @@ function setupEntity( world.addComponent(e, 'stats', base); world.addComponent(e, 'statModifiers', { modifiers: [...modifiers] }); if (needs) { - world.addComponent(e, 'needs', { hunger: 80, energy: 80, ...needs }); + world.addComponent(e, 'needs', { hunger: 80, energy: 80, productivity: 80, ...needs }); } return e; } diff --git a/server/src/systems/__tests__/thoughtSystem.test.ts b/server/src/systems/__tests__/thoughtSystem.test.ts index 970dc99..ef999e8 100644 --- a/server/src/systems/__tests__/thoughtSystem.test.ts +++ b/server/src/systems/__tests__/thoughtSystem.test.ts @@ -29,7 +29,7 @@ function makeNpc(world: World, name: string, needs?: Partial): number { world.addComponent(entity, 'name', name); world.addComponent(entity, 'npcBrain', { currentGoal: 'wander' }); world.addComponent(entity, 'stats', makeStats()); - world.addComponent(entity, 'needs', { hunger: 80, energy: 80, ...needs }); + world.addComponent(entity, 'needs', { hunger: 80, energy: 80, productivity: 80, ...needs }); return entity; } From e1edbd47c642172971e3c918f4e512423af2893e Mon Sep 17 00:00:00 2001 From: root Date: Sun, 8 Mar 2026 22:46:57 +0000 Subject: [PATCH 08/14] feat(needs): add productivity decay to needsDecaySystem Co-Authored-By: Claude Opus 4.6 --- server/src/systems/__tests__/systems.test.ts | 22 ++++++++++++++++++-- server/src/systems/needsDecaySystem.ts | 13 +++++++++++- 2 files changed, 32 insertions(+), 3 deletions(-) diff --git a/server/src/systems/__tests__/systems.test.ts b/server/src/systems/__tests__/systems.test.ts index b22e3df..7a613dd 100644 --- a/server/src/systems/__tests__/systems.test.ts +++ b/server/src/systems/__tests__/systems.test.ts @@ -5,12 +5,12 @@ import { needsDecaySystem } from '../needsDecaySystem.js'; import { npcBrainSystem } from '../npcBrainSystem.js'; import { movementSystem } from '../movementSystem.js'; import type { Needs, Movement, NPCBrain, Position, SocialState, Stats, StatModifiers } from '@dflike/shared'; -import { HUNGER_DECAY_PER_TICK, ENERGY_DECAY_PER_TICK, MOVE_SPEED } from '@dflike/shared'; +import { HUNGER_DECAY_PER_TICK, ENERGY_DECAY_PER_TICK, PRODUCTIVITY_DECAY_PER_TICK, MOVE_SPEED } 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, 'needs', { hunger, energy, productivity: 80 }); world.addComponent(e, 'movement', { state: 'idle', target: null, path: [], direction: 0, moveProgress: 0 }); world.addComponent(e, 'npcBrain', { currentGoal: null, goalQueue: [] }); return e; @@ -79,6 +79,24 @@ describe('needsDecaySystem', () => { expect(needs.hunger).toBeCloseTo(50 - HUNGER_DECAY_PER_TICK); }); + it('decays productivity each tick', () => { + const world = new World(); + const e = createNPC(world, 0, 0, 50, 50); + needsDecaySystem(world); + const needs = world.getComponent(e, 'needs')!; + expect(needs.productivity).toBeCloseTo(80 - PRODUCTIVITY_DECAY_PER_TICK); + }); + + it('productivity decay is not affected by constitution', () => { + const world = new World(); + const e = createNPC(world, 0, 0, 50, 50); + addStats(world, e, { constitution: 18 }); + needsDecaySystem(world); + const needs = world.getComponent(e, 'needs')!; + // Same decay regardless of constitution + expect(needs.productivity).toBeCloseTo(80 - PRODUCTIVITY_DECAY_PER_TICK); + }); + it('clamps needs at 0', () => { const world = new World(); const e = createNPC(world, 0, 0, 0.01, 0.01); diff --git a/server/src/systems/needsDecaySystem.ts b/server/src/systems/needsDecaySystem.ts index 59a36c2..3b141a3 100644 --- a/server/src/systems/needsDecaySystem.ts +++ b/server/src/systems/needsDecaySystem.ts @@ -1,4 +1,4 @@ -import { HUNGER_DECAY_PER_TICK, ENERGY_DECAY_PER_TICK, HUNGER_THRESHOLD, ENERGY_THRESHOLD, type Needs } from '@dflike/shared'; +import { HUNGER_DECAY_PER_TICK, ENERGY_DECAY_PER_TICK, PRODUCTIVITY_DECAY_PER_TICK, HUNGER_THRESHOLD, ENERGY_THRESHOLD, PRODUCTIVITY_THRESHOLD, type Needs } from '@dflike/shared'; import type { World } from '../ecs/World.js'; import { getEffectiveStat } from './statHelpers.js'; import type { EventMemoryService } from '../llm/eventMemoryService.js'; @@ -10,8 +10,10 @@ export function needsDecaySystem(world: World, eventMemoryService?: EventMemoryS const conMultiplier = 1 - (con - 10) * 0.03; const prevHunger = needs.hunger; const prevEnergy = needs.energy; + const prevProductivity = needs.productivity; needs.hunger = Math.max(0, needs.hunger - HUNGER_DECAY_PER_TICK * conMultiplier); needs.energy = Math.max(0, needs.energy - ENERGY_DECAY_PER_TICK * conMultiplier); + needs.productivity = Math.max(0, needs.productivity - PRODUCTIVITY_DECAY_PER_TICK); if (eventMemoryService) { if (prevHunger >= HUNGER_THRESHOLD && needs.hunger < HUNGER_THRESHOLD) { @@ -32,6 +34,15 @@ export function needsDecaySystem(world: World, eventMemoryService?: EventMemoryS detail: `${name} became exhausted`, }); } + if (prevProductivity >= PRODUCTIVITY_THRESHOLD && needs.productivity < PRODUCTIVITY_THRESHOLD) { + const name = world.getComponent(entity, 'name') ?? 'Unknown'; + eventMemoryService.record(entity, { + type: 'need_crisis', + tick: 0, + need: 'productivity', + detail: `${name} felt unproductive`, + }); + } } } } From 61c463c1c7df458856e784fee16488708874f688 Mon Sep 17 00:00:00 2001 From: root Date: Sun, 8 Mar 2026 22:47:51 +0000 Subject: [PATCH 09/14] 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(); From 5fba656c62021458852e8f2b38920b0fd025cbf6 Mon Sep 17 00:00:00 2001 From: root Date: Sun, 8 Mar 2026 22:49:47 +0000 Subject: [PATCH 10/14] 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); + } +} From 3a075b68e0d4a7c794211a4bc9aa22156c078dc6 Mon Sep 17 00:00:00 2001 From: root Date: Sun, 8 Mar 2026 22:50:38 +0000 Subject: [PATCH 11/14] feat(protocol): broadcast inventory and resource tiles to clients Co-Authored-By: Claude Opus 4.6 --- server/src/network/stateSerializer.ts | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/server/src/network/stateSerializer.ts b/server/src/network/stateSerializer.ts index 46bb19b..1d414b1 100644 --- a/server/src/network/stateSerializer.ts +++ b/server/src/network/stateSerializer.ts @@ -50,6 +50,10 @@ export function serializeEntity(world: World, entityId: number): EntityState { }; }) : undefined; + const inventoryComponent = world.getComponent>(entityId, 'inventory'); + const inventory = inventoryComponent && inventoryComponent.size > 0 + ? Object.fromEntries(inventoryComponent) + : undefined; return { id: entityId, position: world.getComponent(entityId, 'position')!, @@ -67,6 +71,7 @@ export function serializeEntity(world: World, entityId: number): EntityState { } : undefined, stats, relationships, + inventory, }; } @@ -82,6 +87,7 @@ export function serializeWorldState(world: World, map: GameMap): WorldState { terrain: map.terrain, decorations: map.decorations, trunkDecorations: map.trunkDecorations, + resourceTiles: map.resourceTiles, }; } From 25d842e367877b8e7b321b709ae104496ac0b612 Mon Sep 17 00:00:00 2001 From: root Date: Sun, 8 Mar 2026 22:50:48 +0000 Subject: [PATCH 12/14] feat(systems): pause movement during gathering, wire gathering into game loop Co-Authored-By: Claude Opus 4.6 --- server/src/game/GameLoop.ts | 2 ++ server/src/systems/movementSystem.ts | 4 ++++ 2 files changed, 6 insertions(+) diff --git a/server/src/game/GameLoop.ts b/server/src/game/GameLoop.ts index 77e3034..5085997 100644 --- a/server/src/game/GameLoop.ts +++ b/server/src/game/GameLoop.ts @@ -8,6 +8,7 @@ import { movementSystem } from '../systems/movementSystem.js'; import { socialSystem } from '../systems/socialSystem.js'; import { statModifierSystem } from '../systems/statModifierSystem.js'; import { relationshipSystem } from '../systems/relationshipSystem.js'; +import { gatheringSystem } from '../systems/gatheringSystem.js'; import { createBondRegistry } from '../systems/bondRegistry.js'; import { ItemRegistry } from '../industry/itemRegistry.js'; import { spawnNPC } from './spawner.js'; @@ -103,6 +104,7 @@ export class GameLoop { socialSystem(this.world, this.eventMemoryService); narrationEmitter(this.world, this.narrationService, this.followedEntityIds, this.eventMemoryService); relationshipSystem(this.world, this.eventMemoryService); + gatheringSystem(this.world, this.map); movementSystem(this.world); this.thoughtSystem.update(this.world, this.followedEntityIds, this.tick); diff --git a/server/src/systems/movementSystem.ts b/server/src/systems/movementSystem.ts index 4df3f41..91e53a3 100644 --- a/server/src/systems/movementSystem.ts +++ b/server/src/systems/movementSystem.ts @@ -1,5 +1,6 @@ import { Direction, MOVE_SPEED, type Movement, type Position, type SocialState } from '@dflike/shared'; import type { World } from '../ecs/World.js'; +import type { GatheringState } from './gatheringSystem.js'; function directionFromDelta(dx: number, dy: number): number { if (Math.abs(dx) > Math.abs(dy)) return dx > 0 ? Direction.RIGHT : Direction.LEFT; @@ -14,6 +15,9 @@ export function movementSystem(world: World): void { const socialState = world.getComponent(entity, 'socialState'); if (socialState && socialState.phase !== 'none') continue; + const gatheringState = world.getComponent(entity, 'gatheringState'); + if (gatheringState) continue; + if (movement.state !== 'walking' || movement.path.length === 0) { if (movement.state === 'walking' && movement.path.length === 0) { movement.state = 'idle'; From 2abcb7b1e1f20f83be1f5e8dee4aebe14258f915 Mon Sep 17 00:00:00 2001 From: root Date: Sun, 8 Mar 2026 22:52:10 +0000 Subject: [PATCH 13/14] feat(client): render stone terrain tiles as gray overlays Stone tiles now display as gray rectangles with darker borders instead of being hidden under the base grass layer. Also treats stone as a land tile for water edge autotiling so water/stone boundaries render correctly. Co-Authored-By: Claude Opus 4.6 --- client/src/scenes/GameScene.ts | 28 +++++++++++++++++++++++++++- 1 file changed, 27 insertions(+), 1 deletion(-) diff --git a/client/src/scenes/GameScene.ts b/client/src/scenes/GameScene.ts index ee6a7c5..7d434a4 100644 --- a/client/src/scenes/GameScene.ts +++ b/client/src/scenes/GameScene.ts @@ -403,7 +403,7 @@ export class GameScene extends Phaser.Scene { // Water layer: watergrass edges on GRASS tiles, inner corners or fill on WATER tiles if (t === Terrain.WATER) { waterRow.push(pickWaterInnerCorner(x, y)); - } else if (t === Terrain.GRASS) { + } else if (t === Terrain.GRASS || t === Terrain.STONE) { waterRow.push(pickWaterEdge(x, y)); } else { waterRow.push(-1); @@ -510,6 +510,32 @@ export class GameScene extends Phaser.Scene { TILE_SIZE - 16, ); } + + // Stone deposit markers + const stoneGraphics = this.add.graphics(); + stoneGraphics.setDepth(-6); + for (let y = 0; y < worldHeight; y++) { + for (let x = 0; x < worldWidth; x++) { + if (terrain[y * worldWidth + x] === Terrain.STONE) { + // Gray stone tile with slight variation + stoneGraphics.fillStyle(0x888888, 0.8); + stoneGraphics.fillRect( + x * TILE_SIZE, + y * TILE_SIZE, + TILE_SIZE, + TILE_SIZE, + ); + // Darker border for definition + stoneGraphics.lineStyle(1, 0x666666, 0.6); + stoneGraphics.strokeRect( + x * TILE_SIZE + 1, + y * TILE_SIZE + 1, + TILE_SIZE - 2, + TILE_SIZE - 2, + ); + } + } + } } private async spawnEntities(entities: EntityState[]): Promise { From bfaa0ce6b8197d1b22b5e146ed77429e7b82b031 Mon Sep 17 00:00:00 2001 From: root Date: Sun, 8 Mar 2026 22:52:52 +0000 Subject: [PATCH 14/14] docs: mark Phase A (harvestable map + gathering) as complete Co-Authored-By: Claude Opus 4.6 --- docs/plans/2026-03-08-resource-tool-invention-design.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/plans/2026-03-08-resource-tool-invention-design.md b/docs/plans/2026-03-08-resource-tool-invention-design.md index 161d809..70ae597 100644 --- a/docs/plans/2026-03-08-resource-tool-invention-design.md +++ b/docs/plans/2026-03-08-resource-tool-invention-design.md @@ -35,7 +35,7 @@ This system is split into four independent phases. Each phase is self-contained **Goal:** NPCs walk to resource tiles, gather materials, and carry them in inventory. A new `productivity` need motivates this behavior. -**Status:** NOT STARTED +**Status:** COMPLETE ### A.1 Map Generation — Resource Tiles