diff --git a/docs/plans/2026-03-08-phase-a-resource-gathering.md b/docs/plans/2026-03-08-phase-a-resource-gathering.md new file mode 100644 index 0000000..ad5fa73 --- /dev/null +++ b/docs/plans/2026-03-08-phase-a-resource-gathering.md @@ -0,0 +1,1434 @@ +# Phase A: Harvestable Map + Gathering — Implementation Plan + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. + +**Goal:** NPCs walk to resource tiles, gather materials, and carry them in inventory. A new `productivity` need motivates gathering behavior. + +**Architecture:** Extend the existing ECS with new components (inventory, gathering state), a new system (gatheringSystem), and map generation changes (water ponds, stone deposits, tree resource marking). The productivity need drives NPCs to gather when idle, using the same priority pattern as hunger/energy. + +**Tech Stack:** TypeScript, vitest, existing ECS framework, shared types package + +--- + +### Task 1: Shared Types — Productivity Need + GoalType + Inventory + +**Files:** +- Modify: `shared/src/types.ts:48-51` (Needs interface) +- Modify: `shared/src/types.ts:81` (GoalType union) +- Modify: `shared/src/types.ts:96-99` (NPCBrain interface) +- Modify: `shared/src/types.ts:133-157` (EntityState — add inventory) +- Modify: `shared/src/constants.ts:77-82` (Terrain enum — add STONE) + +**Step 1: Add productivity to Needs interface** + +In `shared/src/types.ts`, change the Needs interface: + +```ts +export interface Needs { + hunger: number; // 0-100 + energy: number; // 0-100 + productivity: number; // 0-100 +} +``` + +**Step 2: Add 'gather' to GoalType** + +```ts +export type GoalType = 'wander' | 'eat' | 'rest' | 'gather'; +``` + +**Step 3: Add gatherTarget to NPCBrain** + +```ts +export interface NPCBrain { + currentGoal: GoalType | null; + goalQueue: GoalType[]; + gatherTarget?: { x: number; y: number; resourceType: string } | null; +} +``` + +**Step 4: Add inventory to EntityState** + +In the EntityState interface, add after `backstory`: + +```ts + inventory?: Record; +``` + +**Step 5: Add STONE terrain type** + +In `shared/src/constants.ts`, change the Terrain object: + +```ts +export const Terrain = { + GRASS: 0, + WATER: 1, + DIRT: 2, + STONE: 3, +} as const; +``` + +**Step 6: Add productivity constants** + +In `shared/src/constants.ts`, after the NPC needs section: + +```ts +// Productivity need +export const PRODUCTIVITY_DECAY_PER_TICK = 0.02; +export const PRODUCTIVITY_THRESHOLD = 40; +export const PRODUCTIVITY_RECOVERY_RATE = 0.5; +``` + +**Step 7: Add resourceTiles to WorldState** + +In `shared/src/types.ts`, add to the WorldState interface after `trunkDecorations`: + +```ts + resourceTiles: Array<{ x: number; y: number; resourceType: string }>; +``` + +**Step 8: Rebuild shared types** + +Run: `npx -w shared tsc` +Expected: Clean compilation, no errors + +**Step 9: Commit** + +```bash +git add shared/src/types.ts shared/src/constants.ts +git commit -m "feat(shared): add productivity need, gather goal, inventory, STONE terrain" +``` + +--- + +### Task 2: Industry Config + +**Files:** +- Create: `server/src/config/industryConfig.ts` + +**Step 1: Create the config file** + +```ts +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; +``` + +**Step 2: Commit** + +```bash +git add server/src/config/industryConfig.ts +git commit -m "feat(config): add industry config for gathering and map generation" +``` + +--- + +### Task 3: ItemRegistry Singleton + +**Files:** +- Create: `server/src/industry/itemRegistry.ts` +- Create: `server/src/industry/__tests__/itemRegistry.test.ts` + +**Step 1: Write the failing tests** + +```ts +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(); + }); +}); +``` + +**Step 2: Run tests to verify they fail** + +Run: `npm -w server run test -- --run src/industry/__tests__/itemRegistry.test.ts` +Expected: FAIL — module not found + +**Step 3: Write the implementation** + +```ts +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; + } +} +``` + +**Step 4: Run tests to verify they pass** + +Run: `npm -w server run test -- --run src/industry/__tests__/itemRegistry.test.ts` +Expected: All 6 tests PASS + +**Step 5: Commit** + +```bash +git add server/src/industry/itemRegistry.ts server/src/industry/__tests__/itemRegistry.test.ts +git commit -m "feat(industry): add ItemRegistry with seed resources" +``` + +--- + +### Task 4: Inventory Helpers + +**Files:** +- Create: `server/src/industry/inventoryHelpers.ts` +- Create: `server/src/industry/__tests__/inventoryHelpers.test.ts` + +**Step 1: Write the failing tests** + +```ts +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); + }); +}); +``` + +**Step 2: Run tests to verify they fail** + +Run: `npm -w server run test -- --run src/industry/__tests__/inventoryHelpers.test.ts` +Expected: FAIL — module not found + +**Step 3: Write the implementation** + +```ts +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; +} +``` + +**Step 4: Run tests to verify they pass** + +Run: `npm -w server run test -- --run src/industry/__tests__/inventoryHelpers.test.ts` +Expected: All 9 tests PASS + +**Step 5: Commit** + +```bash +git add server/src/industry/inventoryHelpers.ts server/src/industry/__tests__/inventoryHelpers.test.ts +git commit -m "feat(industry): add inventory helper functions" +``` + +--- + +### Task 5: Map Generation — Water Ponds, Stone Deposits, Resource Tiles + +**Files:** +- Modify: `server/src/map/mapGenerator.ts` (add water/stone/resource generation) +- Modify: `server/src/map/GameMap.ts` (add resourceTiles tracking) +- Create: `server/src/map/__tests__/mapGenerator.test.ts` + +**Step 1: Write the failing tests** + +```ts +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) { + // The resource tile itself should NOT be water + expect(map.terrain[r.y * 32 + r.x]).not.toBe(Terrain.WATER); + // Should be adjacent to at least one water tile + 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('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); + }); +}); +``` + +**Step 2: Run tests to verify they fail** + +Run: `npm -w server run test -- --run src/map/__tests__/mapGenerator.test.ts` +Expected: FAIL — resourceTiles not in GeneratedMap, no water/stone generated + +**Step 3: Update GameMap to track resource tiles** + +In `server/src/map/GameMap.ts`, add a `resourceTiles` array: + +```ts +// Add to class fields: +resourceTiles: Array<{ x: number; y: number; resourceType: string }> = []; +``` + +**Step 4: Update mapGenerator to generate water, stone, and resource tiles** + +Rewrite `server/src/map/mapGenerator.ts` to: + +```ts +import { WORLD_WIDTH, WORLD_HEIGHT, Terrain } from '@dflike/shared'; +import { industryConfig } from '../config/industryConfig.js'; + +export interface GeneratedMap { + 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) +function createRng(seed: number) { + let s = seed | 0; + return () => { + s = (s + 0x6D2B79F5) | 0; + let t = Math.imul(s ^ (s >>> 15), 1 | s); + t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t; + return ((t ^ (t >>> 14)) >>> 0) / 4294967296; + }; +} + +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) }, + { x: Math.floor(width * 0.75), y: Math.floor(height * 0.75) }, + ]; + const restPositions = [ + { x: Math.floor(width * 0.75), y: Math.floor(height * 0.25) }, + { x: Math.floor(width * 0.25), y: Math.floor(height * 0.75) }, + ]; + + // 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); + } + // Random walk + 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 + 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]]) { + 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) { + // Only add if not already a water resource tile + if (!resourceTiles.some(r => r.x === nx && r.y === ny && r.resourceType === 'water')) { + resourceTiles.push({ x: nx, y: ny, resourceType: 'water' }); + } + } + } + } + } + } + + // --- 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 (existing decoration system) --- + // Generate tree clusters similar to existing approach + 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); // 6 trunk variants + trunkDecorations[y * width + x] = trunkTile; + obstacles.add(key); + + // Canopy at (x, y-1) — visual only + if (y > 0) { + const canopyTile = randRange(rng, 0, 5); // 6 canopy variants + 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 }; +} +``` + +**Important note:** The current `generateMap` doesn't generate trees at all (decorations are all -1). This implementation adds tree generation alongside water/stone. If the client currently generates trees separately, this may need adjustment — check after implementation. + +**Step 5: Run tests to verify they pass** + +Run: `npm -w server run test -- --run src/map/__tests__/mapGenerator.test.ts` +Expected: All 7 tests PASS + +**Step 6: Run full test suite to check for regressions** + +Run: `npm -w server run test` +Expected: All tests pass. Some existing tests may need updating if they call `generateMap` and now get different results (the return type changed to include `resourceTiles`). + +**Step 7: Commit** + +```bash +git add server/src/map/mapGenerator.ts server/src/map/GameMap.ts server/src/map/__tests__/mapGenerator.test.ts +git commit -m "feat(map): generate water ponds, stone deposits, and tree resources" +``` + +--- + +### Task 6: GameMap + GameLoop — Load Resource Tiles + +**Files:** +- Modify: `server/src/map/GameMap.ts:16` (add resourceTiles field — already done in Task 5) +- Modify: `server/src/game/GameLoop.ts:45-61` (setupMap loads resourceTiles) + +**Step 1: Update GameLoop.setupMap to load resource tiles** + +In `server/src/game/GameLoop.ts`, in the `setupMap()` method, add after loading obstacles: + +```ts +// Load resource tiles +this.map.resourceTiles = generated.resourceTiles; +``` + +**Step 2: Run full test suite** + +Run: `npm -w server run test` +Expected: All tests pass + +**Step 3: Commit** + +```bash +git add server/src/game/GameLoop.ts +git commit -m "feat(map): load resource tiles into GameMap on startup" +``` + +--- + +### Task 7: Spawner — Add Inventory + Productivity + +**Files:** +- Modify: `server/src/game/spawner.ts:16-19` (add productivity to needs, add inventory component) +- Modify: `server/src/game/__tests__/spawner.test.ts` (update expectations) + +**Step 1: Read the existing spawner test** + +Read `server/src/game/__tests__/spawner.test.ts` to understand existing test patterns. + +**Step 2: Update spawner to add productivity and inventory** + +In `server/src/game/spawner.ts`, change the needs initialization: + +```ts +world.addComponent(entity, 'needs', { + hunger: 40 + Math.random() * 40, + energy: 40 + Math.random() * 40, + productivity: 60 + Math.random() * 40, // 60-100 +}); +``` + +After the existing components, add: + +```ts +world.addComponent>(entity, 'inventory', new Map()); +``` + +**Step 3: Update the spawner test** + +Add a test checking that spawned NPCs have productivity and inventory: + +```ts +it('spawned NPC has productivity need', () => { + // ... spawn and check needs.productivity is defined and in range +}); + +it('spawned NPC has empty inventory', () => { + // ... spawn and check inventory component exists and is empty Map +}); +``` + +**Step 4: Run tests** + +Run: `npm -w server run test -- --run src/game/__tests__/spawner.test.ts` +Expected: All tests pass + +**Step 5: Fix any other tests broken by Needs shape change** + +The `Needs` interface now requires `productivity`. Search for all tests that create `Needs` objects and add `productivity: 80` (or similar) to them. Key files to check: +- `server/src/systems/__tests__/systems.test.ts` (the `createNPC` helper) +- Any other test files that construct Needs objects + +Run: `npm -w server run test` +Expected: All tests pass + +**Step 6: Commit** + +```bash +git add server/src/game/spawner.ts server/src/game/__tests__/spawner.test.ts server/src/systems/__tests__/systems.test.ts +git commit -m "feat(spawner): add productivity need and inventory to spawned NPCs" +``` + +--- + +### Task 8: Needs Decay — Productivity Decay + +**Files:** +- Modify: `server/src/systems/needsDecaySystem.ts` (add productivity decay) +- Modify: `server/src/systems/__tests__/systems.test.ts` (add productivity decay tests) + +**Step 1: Write the failing test** + +Add to the `needsDecaySystem` describe block in `systems.test.ts`: + +```ts +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')!; + expect(needs.productivity).toBeCloseTo(80 - PRODUCTIVITY_DECAY_PER_TICK); +}); +``` + +Note: Update `createNPC` helper to include `productivity: 80` in the Needs object. + +**Step 2: Run tests to verify they fail** + +Run: `npm -w server run test -- --run src/systems/__tests__/systems.test.ts` +Expected: FAIL — productivity not decaying + +**Step 3: Update needsDecaySystem** + +In `server/src/systems/needsDecaySystem.ts`, add after energy decay: + +```ts +import { HUNGER_DECAY_PER_TICK, ENERGY_DECAY_PER_TICK, PRODUCTIVITY_DECAY_PER_TICK, HUNGER_THRESHOLD, ENERGY_THRESHOLD, PRODUCTIVITY_THRESHOLD, type Needs } from '@dflike/shared'; + +// ... inside the for loop, after energy decay: +needs.productivity = Math.max(0, needs.productivity - PRODUCTIVITY_DECAY_PER_TICK); +``` + +Add the productivity crisis event (analogous to hunger/energy): + +```ts +if (eventMemoryService) { + // ... existing hunger/energy checks ... + 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' as any, // extend MemoryEvent need type later if desired + detail: `${name} felt unproductive`, + }); + } +} +``` + +**Step 4: Run tests to verify they pass** + +Run: `npm -w server run test -- --run src/systems/__tests__/systems.test.ts` +Expected: All tests pass + +**Step 5: Commit** + +```bash +git add server/src/systems/needsDecaySystem.ts server/src/systems/__tests__/systems.test.ts +git commit -m "feat(needs): add productivity decay to needsDecaySystem" +``` + +--- + +### Task 9: NPC Brain — Gather Goal + +**Files:** +- Modify: `server/src/systems/npcBrainSystem.ts` (add gather goal logic) +- Modify: `server/src/systems/__tests__/systems.test.ts` (add gather goal tests) + +**Step 1: Write the failing tests** + +Add to the `npcBrainSystem` describe block: + +```ts +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); + const needs = world.getComponent(e, 'needs')!; + needs.productivity = 60; + npcBrainSystem(world, map); + const brain = world.getComponent(e, 'npcBrain')!; + expect(brain.currentGoal).toBe('wander'); +}); +``` + +**Step 2: Run tests to verify they fail** + +Run: `npm -w server run test -- --run src/systems/__tests__/systems.test.ts` +Expected: FAIL — no gather logic + +**Step 3: Update npcBrainSystem** + +In `server/src/systems/npcBrainSystem.ts`: + +1. Import `PRODUCTIVITY_THRESHOLD` from shared +2. Change the goal determination logic: + +```ts +import { + HUNGER_THRESHOLD, ENERGY_THRESHOLD, PRODUCTIVITY_THRESHOLD, Direction, + type Needs, type Movement, type NPCBrain, type Position, type SocialState, type GoalType, +} 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'; +``` + +Change the goal determination section (around line 73-77): + +```ts +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'; +``` + +Add gather target selection after the existing target logic: + +```ts +if (goal === 'gather') { + target = findNearestResource(map, pos); + if (!target) { + goal = 'wander'; + target = map.getRandomWalkable(); + } +} +``` + +Add the `findNearestResource` helper function: + +```ts +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 }; +} +``` + +Update goalLabels to include gather: + +```ts +const goalLabels: Record = { + wander: 'wandering', eat: 'looking for food', rest: 'looking for rest', gather: 'looking for resources', +}; +``` + +**Step 4: Run tests to verify they pass** + +Run: `npm -w server run test -- --run src/systems/__tests__/systems.test.ts` +Expected: All tests pass + +**Step 5: Commit** + +```bash +git add server/src/systems/npcBrainSystem.ts server/src/systems/__tests__/systems.test.ts +git commit -m "feat(brain): add gather goal when productivity is low" +``` + +--- + +### Task 10: Gathering System + +**Files:** +- Create: `server/src/systems/gatheringSystem.ts` +- Create: `server/src/systems/__tests__/gatheringSystem.test.ts` + +**Step 1: Write the failing tests** + +```ts +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); + // Start gathering + gatheringSystem(world, map); + const gs = world.getComponent(e, 'gatheringState')!; + // Fast-forward to completion + 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')!; + // base 30, modifier: 30 * (1 - (16-10) * 0.03) = 30 * 0.82 = 24.6 → 25 + 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'); + }); +}); +``` + +**Step 2: Run tests to verify they fail** + +Run: `npm -w server run test -- --run src/systems/__tests__/gatheringSystem.test.ts` +Expected: FAIL — module not found + +**Step 3: Write the implementation** + +```ts +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); + } +} +``` + +**Step 4: Run tests to verify they pass** + +Run: `npm -w server run test -- --run src/systems/__tests__/gatheringSystem.test.ts` +Expected: All 9 tests PASS + +**Step 5: Commit** + +```bash +git add server/src/systems/gatheringSystem.ts server/src/systems/__tests__/gatheringSystem.test.ts +git commit -m "feat(systems): add gatheringSystem with timer, stat modifier, and inventory" +``` + +--- + +### Task 11: GameLoop — Wire Up Gathering System + +**Files:** +- Modify: `server/src/game/GameLoop.ts` (add gatheringSystem to tick order) + +**Step 1: Add gathering system import and call** + +In `server/src/game/GameLoop.ts`: + +```ts +import { gatheringSystem } from '../systems/gatheringSystem.js'; +``` + +In the `update()` method, add after `relationshipSystem` and before `movementSystem`: + +```ts +gatheringSystem(this.world, this.map); +``` + +The system order becomes: +``` +statModifier → needsDecay → npcBrain → social → narration → +relationship → gathering → movement → thoughtSystem +``` + +**Step 2: Register ItemRegistry as world singleton** + +In the constructor, after creating bondRegistry: + +```ts +import { ItemRegistry } from '../industry/itemRegistry.js'; + +// In constructor: +this.world.setSingleton('itemRegistry', ItemRegistry.createDefault()); +``` + +**Step 3: Run full test suite** + +Run: `npm -w server run test` +Expected: All tests pass + +**Step 4: Commit** + +```bash +git add server/src/game/GameLoop.ts +git commit -m "feat(loop): wire gathering system into game tick order" +``` + +--- + +### Task 12: Wire Protocol — Inventory + Resource Tiles + +**Files:** +- Modify: `server/src/network/stateSerializer.ts` (add inventory to entity serialization) +- Modify: `server/src/network/stateSerializer.ts` (add resourceTiles to world state) + +**Step 1: Add inventory serialization** + +In `serializeEntity()` in `stateSerializer.ts`, add before the return: + +```ts +const inventoryComponent = world.getComponent>(entityId, 'inventory'); +const inventory = inventoryComponent && inventoryComponent.size > 0 + ? Object.fromEntries(inventoryComponent) + : undefined; +``` + +Add `inventory` to the returned EntityState object. + +**Step 2: Add resourceTiles to world state** + +In `serializeWorldState()`, add `resourceTiles` to the returned object: + +```ts +resourceTiles: map.resourceTiles, +``` + +**Step 3: Run full test suite** + +Run: `npm -w server run test` +Expected: All tests pass + +**Step 4: Commit** + +```bash +git add server/src/network/stateSerializer.ts +git commit -m "feat(protocol): broadcast inventory and resource tiles to clients" +``` + +--- + +### Task 13: Movement System — Pause During Gathering + +**Files:** +- Modify: `server/src/systems/movementSystem.ts` (skip entity while gathering) + +**Step 1: Add gathering state check** + +In `movementSystem.ts`, after the social state check, add: + +```ts +import type { GatheringState } from './gatheringSystem.js'; + +// Inside the for loop, after socialState check: +const gatheringState = world.getComponent(entity, 'gatheringState'); +if (gatheringState) continue; +``` + +This prevents NPCs from moving while actively gathering. + +**Step 2: Run full test suite** + +Run: `npm -w server run test` +Expected: All tests pass + +**Step 3: Commit** + +```bash +git add server/src/systems/movementSystem.ts +git commit -m "feat(movement): pause movement while NPC is gathering" +``` + +--- + +### Task 14: Integration Test + Full Suite Verification + +**Files:** +- Modify: `server/src/systems/__tests__/gatheringSystem.test.ts` (add integration test) + +**Step 1: Write integration test** + +Add to the gatheringSystem test file: + +```ts +describe('gathering integration', () => { + 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); + }); +}); +``` + +**Step 2: Run the gathering tests** + +Run: `npm -w server run test -- --run src/systems/__tests__/gatheringSystem.test.ts` +Expected: All tests pass + +**Step 3: Run the full test suite** + +Run: `npm -w server run test` +Expected: ALL tests pass (including all pre-existing tests) + +**Step 4: Commit** + +```bash +git add server/src/systems/__tests__/gatheringSystem.test.ts +git commit -m "test: add gathering integration test" +``` + +--- + +### Task 15: Rebuild Shared + Manual Smoke Test + +**Step 1: Rebuild shared types** + +Run: `npx -w shared tsc` +Expected: Clean compilation + +**Step 2: Start server and verify it boots** + +Run: `npm -w server run dev` +Expected: Server starts without errors, logs "Game loop started at 10 ticks/sec" + +Stop the server after confirming it boots. + +**Step 3: Final commit — update design doc status** + +In `docs/plans/2026-03-08-resource-tool-invention-design.md`, change Phase A status: + +``` +**Status:** COMPLETE +``` + +```bash +git add docs/plans/2026-03-08-resource-tool-invention-design.md +git commit -m "docs: mark Phase A (harvestable map + gathering) as complete" +``` diff --git a/docs/plans/2026-03-08-phase-b-crafting-building.md b/docs/plans/2026-03-08-phase-b-crafting-building.md new file mode 100644 index 0000000..978071c --- /dev/null +++ b/docs/plans/2026-03-08-phase-b-crafting-building.md @@ -0,0 +1,1559 @@ +# Phase B: Crafting + Building — Implementation Plan + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. + +**Goal:** NPCs craft tools from gathered resources and build structures (stockpiles, workshops). After gathering, NPCs drop off items at stockpiles. An industry system decides whether to craft, build, or gather. + +**Architecture:** Extend the ECS with new components (CraftingState, StructureData), a RecipeRegistry singleton, and three new systems (industrySystem, craftingSystem, buildingSystem). The npcBrain goal priority expands to include 'craft' and 'build'. Movement system pauses during crafting/building (same pattern as gathering). Drop-off behavior adds a new goal type 'dropoff'. + +**Tech Stack:** TypeScript, vitest, existing ECS framework, shared types package + +--- + +### Task 1: Shared Types — Add 'craft', 'build', 'dropoff' GoalTypes + +**Files:** +- Modify: `shared/src/types.ts:82` (GoalType union) + +**Step 1: Update GoalType** + +In `shared/src/types.ts`, change line 82: + +```ts +export type GoalType = 'wander' | 'eat' | 'rest' | 'gather' | 'craft' | 'build' | 'dropoff'; +``` + +**Step 2: Rebuild shared types** + +Run: `npx -w shared tsc` +Expected: Clean compilation, no errors + +**Step 3: Commit** + +```bash +git add shared/src/types.ts +git commit -m "feat(shared): add craft, build, dropoff goal types" +``` + +--- + +### Task 2: Industry Config — Add crafting and building tuning values + +**Files:** +- Modify: `server/src/config/industryConfig.ts` + +**Step 1: Add crafting and building config values** + +In `server/src/config/industryConfig.ts`, add after existing config: + +```ts +export const industryConfig = { + // Gathering + gatherBaseTicks: 30, + gatherStrengthModifier: 0.03, // per point from 10 + gatherYield: 1, + + // Crafting + craftBaseTicks: 40, + craftDexterityModifier: 0.03, // per point from 10 + + // Building + buildBaseTicks: 60, + buildProgressPerTick: 0.02, // base progress rate (1/50 ticks) + + // Map generation + waterPondCount: { min: 2, max: 4 }, + waterPondSize: { min: 3, max: 6 }, + stoneClusterCount: { min: 3, max: 5 }, + stoneClusterSize: { min: 2, max: 4 }, +} as const; +``` + +**Step 2: Commit** + +```bash +git add server/src/config/industryConfig.ts +git commit -m "feat(config): add crafting and building tuning values" +``` + +--- + +### Task 3: RecipeRegistry — Seed recipes for tools and structures + +**Files:** +- Create: `server/src/industry/recipeRegistry.ts` +- Create: `server/src/industry/__tests__/recipeRegistry.test.ts` + +**Step 1: Write the failing tests** + +Create `server/src/industry/__tests__/recipeRegistry.test.ts`: + +```ts +import { describe, it, expect } from 'vitest'; +import { RecipeRegistry, type Recipe } from '../recipeRegistry.js'; + +describe('RecipeRegistry', () => { + it('registers and retrieves a recipe', () => { + const registry = new RecipeRegistry(); + const recipe: Recipe = { + id: 'craft_wooden_axe', + outputItemId: 'wooden_axe', + outputQuantity: 1, + inputs: [{ itemId: 'log', quantity: 2 }, { itemId: 'stone', quantity: 1 }], + source: 'seed', + }; + registry.register(recipe); + expect(registry.get('craft_wooden_axe')).toEqual(recipe); + }); + + it('throws on duplicate registration', () => { + const registry = new RecipeRegistry(); + const recipe: Recipe = { + id: 'test', + outputItemId: 'x', + outputQuantity: 1, + inputs: [], + source: 'seed', + }; + registry.register(recipe); + expect(() => registry.register(recipe)).toThrow(); + }); + + it('returns undefined for unknown recipe', () => { + const registry = new RecipeRegistry(); + expect(registry.get('nonexistent')).toBeUndefined(); + }); + + it('getAll returns all registered recipes', () => { + const registry = new RecipeRegistry(); + registry.register({ id: 'a', outputItemId: 'x', outputQuantity: 1, inputs: [], source: 'seed' }); + registry.register({ id: 'b', outputItemId: 'y', outputQuantity: 1, inputs: [], source: 'seed' }); + expect(registry.getAll()).toHaveLength(2); + }); + + it('findCraftable returns recipes NPC has materials for', () => { + const registry = new RecipeRegistry(); + registry.register({ + id: 'craft_wooden_axe', + outputItemId: 'wooden_axe', + outputQuantity: 1, + inputs: [{ itemId: 'log', quantity: 2 }, { itemId: 'stone', quantity: 1 }], + source: 'seed', + }); + registry.register({ + id: 'craft_hammer', + outputItemId: 'hammer', + outputQuantity: 1, + inputs: [{ itemId: 'log', quantity: 2 }, { itemId: 'stone', quantity: 1 }], + source: 'seed', + }); + const inv = new Map([['log', 3], ['stone', 1]]); + const craftable = registry.findCraftable(inv); + // Only enough stone for one recipe, but both are craftable independently + expect(craftable).toHaveLength(2); + }); + + it('findCraftable excludes recipes without enough materials', () => { + const registry = new RecipeRegistry(); + registry.register({ + id: 'craft_wooden_axe', + outputItemId: 'wooden_axe', + outputQuantity: 1, + inputs: [{ itemId: 'log', quantity: 2 }, { itemId: 'stone', quantity: 1 }], + source: 'seed', + }); + const inv = new Map([['log', 1]]); + expect(registry.findCraftable(inv)).toHaveLength(0); + }); + + it('createDefault has seed recipes for axe, hammer, stockpile, workbench', () => { + const registry = RecipeRegistry.createDefault(); + expect(registry.get('craft_wooden_axe')).toBeDefined(); + expect(registry.get('craft_hammer')).toBeDefined(); + expect(registry.get('build_stockpile')).toBeDefined(); + expect(registry.get('build_workbench')).toBeDefined(); + }); +}); +``` + +**Step 2: Run tests to verify they fail** + +Run: `npm -w server run test -- --run src/industry/__tests__/recipeRegistry.test.ts` +Expected: FAIL — module not found + +**Step 3: Implement RecipeRegistry** + +Create `server/src/industry/recipeRegistry.ts`: + +```ts +import type { Inventory } from './inventoryHelpers.js'; + +export interface Recipe { + id: string; + outputItemId: string; + outputQuantity: number; + inputs: { itemId: string; quantity: number }[]; + workshopType?: string; // required workshop (undefined = hand-craftable) + toolRequired?: string; // required tool in inventory (undefined = none) + source: 'seed' | 'invented'; +} + +export class RecipeRegistry { + private recipes = new Map(); + + register(recipe: Recipe): void { + if (this.recipes.has(recipe.id)) { + throw new Error(`Recipe '${recipe.id}' already registered`); + } + this.recipes.set(recipe.id, recipe); + } + + get(id: string): Recipe | undefined { + return this.recipes.get(id); + } + + getAll(): Recipe[] { + return [...this.recipes.values()]; + } + + findCraftable(inv: Inventory, workshopType?: string): Recipe[] { + return this.getAll().filter(recipe => { + // Check workshop requirement + if (recipe.workshopType && recipe.workshopType !== workshopType) return false; + // Check all inputs available + return recipe.inputs.every(input => (inv.get(input.itemId) ?? 0) >= input.quantity); + }); + } + + static createDefault(): RecipeRegistry { + const registry = new RecipeRegistry(); + + registry.register({ + id: 'craft_wooden_axe', + outputItemId: 'wooden_axe', + outputQuantity: 1, + inputs: [{ itemId: 'log', quantity: 2 }, { itemId: 'stone', quantity: 1 }], + source: 'seed', + }); + + registry.register({ + id: 'craft_hammer', + outputItemId: 'hammer', + outputQuantity: 1, + inputs: [{ itemId: 'log', quantity: 2 }, { itemId: 'stone', quantity: 1 }], + source: 'seed', + }); + + registry.register({ + id: 'build_stockpile', + outputItemId: 'stockpile', + outputQuantity: 1, + inputs: [{ itemId: 'log', quantity: 4 }], + source: 'seed', + }); + + registry.register({ + id: 'build_workbench', + outputItemId: 'workbench', + outputQuantity: 1, + inputs: [{ itemId: 'log', quantity: 3 }, { itemId: 'stone', quantity: 2 }], + source: 'seed', + }); + + return registry; + } +} +``` + +**Step 4: Register seed tool items in ItemRegistry** + +Modify `server/src/industry/itemRegistry.ts`, inside `createDefault()` after the water registration, add: + +```ts + registry.register({ + id: 'wooden_axe', + name: 'Wooden Axe', + description: 'A crude axe made from logs and stone', + category: 'tool', + source: 'seed', + }); + registry.register({ + id: 'hammer', + name: 'Hammer', + description: 'A basic hammer for construction', + category: 'tool', + source: 'seed', + }); +``` + +**Step 5: Run tests to verify they pass** + +Run: `npm -w server run test -- --run src/industry/__tests__/recipeRegistry.test.ts` +Expected: All 7 tests PASS + +**Step 6: Commit** + +```bash +git add server/src/industry/recipeRegistry.ts server/src/industry/__tests__/recipeRegistry.test.ts server/src/industry/itemRegistry.ts +git commit -m "feat(industry): add RecipeRegistry with seed recipes for tools and structures" +``` + +--- + +### Task 4: Crafting System — consume inputs, timer, produce output + +**Files:** +- Create: `server/src/systems/craftingSystem.ts` +- Create: `server/src/systems/__tests__/craftingSystem.test.ts` + +**Step 1: Write the failing tests** + +Create `server/src/systems/__tests__/craftingSystem.test.ts`: + +```ts +import { describe, it, expect } from 'vitest'; +import { World } from '../../ecs/World.js'; +import { craftingSystem, type CraftingState } from '../craftingSystem.js'; +import type { Needs, Movement, NPCBrain, Position, Stats, StatModifiers } from '@dflike/shared'; +import { PRODUCTIVITY_RECOVERY_RATE } from '@dflike/shared'; +import { RecipeRegistry } from '../../industry/recipeRegistry.js'; + +function createCraftingWorld(): World { + const world = new World(); + world.setSingleton('recipeRegistry', RecipeRegistry.createDefault()); + return world; +} + +function createCraftingNPC(world: World, overrides?: { dexterity?: number; productivity?: number }) { + const e = world.createEntity(); + world.addComponent(e, 'position', { x: 5, y: 5 }); + 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: 'craft', goalQueue: [] }); + world.addComponent>(e, 'inventory', new Map([['log', 5], ['stone', 3]])); + const dex = overrides?.dexterity ?? 10; + world.addComponent(e, 'stats', { + strength: 10, dexterity: dex, constitution: 10, intelligence: 10, perception: 10, + sociability: 10, courage: 10, curiosity: 10, empathy: 10, temperament: 10, + }); + world.addComponent(e, 'statModifiers', { modifiers: [] }); + return e; +} + +describe('craftingSystem', () => { + it('starts crafting when NPC has goal craft and a craftingState', () => { + const world = createCraftingWorld(); + const e = createCraftingNPC(world); + world.addComponent(e, 'craftingState', { + recipeId: 'craft_wooden_axe', + ticksRemaining: 40, + }); + craftingSystem(world); + const cs = world.getComponent(e, 'craftingState')!; + expect(cs.ticksRemaining).toBe(39); + }); + + it('consumes inputs and produces output on completion', () => { + const world = createCraftingWorld(); + const e = createCraftingNPC(world); + world.addComponent(e, 'craftingState', { + recipeId: 'craft_wooden_axe', + ticksRemaining: 1, + }); + craftingSystem(world); + const inv = world.getComponent>(e, 'inventory')!; + expect(inv.get('wooden_axe')).toBe(1); + expect(inv.get('log')).toBe(3); // 5 - 2 + expect(inv.get('stone')).toBe(2); // 3 - 1 + }); + + it('clears craftingState on completion', () => { + const world = createCraftingWorld(); + const e = createCraftingNPC(world); + world.addComponent(e, 'craftingState', { + recipeId: 'craft_wooden_axe', + ticksRemaining: 1, + }); + craftingSystem(world); + expect(world.getComponent(e, 'craftingState')).toBeUndefined(); + }); + + it('recovers productivity while crafting', () => { + const world = createCraftingWorld(); + const e = createCraftingNPC(world, { productivity: 30 }); + world.addComponent(e, 'craftingState', { + recipeId: 'craft_wooden_axe', + ticksRemaining: 10, + }); + craftingSystem(world); + const needs = world.getComponent(e, 'needs')!; + expect(needs.productivity).toBeCloseTo(30 + PRODUCTIVITY_RECOVERY_RATE); + }); + + it('skips NPC whose goal is not craft', () => { + const world = createCraftingWorld(); + const e = createCraftingNPC(world); + const brain = world.getComponent(e, 'npcBrain')!; + brain.currentGoal = 'wander'; + world.addComponent(e, 'craftingState', { + recipeId: 'craft_wooden_axe', + ticksRemaining: 10, + }); + craftingSystem(world); + const cs = world.getComponent(e, 'craftingState')!; + expect(cs.ticksRemaining).toBe(10); // unchanged + }); + + it('resets goal to wander when productivity satisfied after crafting', () => { + const world = createCraftingWorld(); + const e = createCraftingNPC(world, { productivity: 70 }); + world.addComponent(e, 'craftingState', { + recipeId: 'craft_wooden_axe', + ticksRemaining: 1, + }); + craftingSystem(world); + const brain = world.getComponent(e, 'npcBrain')!; + expect(brain.currentGoal).toBe('wander'); + }); +}); +``` + +**Step 2: Run tests to verify they fail** + +Run: `npm -w server run test -- --run src/systems/__tests__/craftingSystem.test.ts` +Expected: FAIL — module not found + +**Step 3: Implement craftingSystem** + +Create `server/src/systems/craftingSystem.ts`: + +```ts +import { + PRODUCTIVITY_RECOVERY_RATE, PRODUCTIVITY_THRESHOLD, + type Needs, type NPCBrain, +} from '@dflike/shared'; +import type { World } from '../ecs/World.js'; +import { addItem, removeItem } from '../industry/inventoryHelpers.js'; +import { RecipeRegistry } from '../industry/recipeRegistry.js'; + +export interface CraftingState { + recipeId: string; + ticksRemaining: number; +} + +export function craftingSystem(world: World): void { + for (const entity of world.query('npcBrain', 'needs', 'inventory')) { + const brain = world.getComponent(entity, 'npcBrain')!; + if (brain.currentGoal !== 'craft') continue; + + const cs = world.getComponent(entity, 'craftingState'); + if (!cs) continue; + + const needs = world.getComponent(entity, 'needs')!; + const inv = world.getComponent>(entity, 'inventory')!; + + cs.ticksRemaining--; + needs.productivity = Math.min(100, needs.productivity + PRODUCTIVITY_RECOVERY_RATE); + + if (cs.ticksRemaining <= 0) { + const registry = world.getSingleton('recipeRegistry'); + const recipe = registry?.get(cs.recipeId); + if (recipe) { + // Consume inputs + for (const input of recipe.inputs) { + removeItem(inv, input.itemId, input.quantity); + } + // Produce output + addItem(inv, recipe.outputItemId, recipe.outputQuantity); + } + world.removeComponent(entity, 'craftingState'); + + if (needs.productivity >= PRODUCTIVITY_THRESHOLD) { + brain.currentGoal = 'wander'; + } + } + } +} +``` + +**Step 4: Run tests to verify they pass** + +Run: `npm -w server run test -- --run src/systems/__tests__/craftingSystem.test.ts` +Expected: All 6 tests PASS + +**Step 5: Commit** + +```bash +git add server/src/systems/craftingSystem.ts server/src/systems/__tests__/craftingSystem.test.ts +git commit -m "feat(systems): add craftingSystem with input consumption and output production" +``` + +--- + +### Task 5: Building System — create structure entities with progress + +**Files:** +- Create: `server/src/systems/buildingSystem.ts` +- Create: `server/src/systems/__tests__/buildingSystem.test.ts` + +**Step 1: Write the failing tests** + +Create `server/src/systems/__tests__/buildingSystem.test.ts`: + +```ts +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); + // Create a structure entity the NPC is building + 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(); // completed + expect(structure.builderEntityId).toBeUndefined(); // cleared + 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); // unchanged + }); + + 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'); + }); +}); +``` + +**Step 2: Run tests to verify they fail** + +Run: `npm -w server run test -- --run src/systems/__tests__/buildingSystem.test.ts` +Expected: FAIL — module not found + +**Step 3: Implement buildingSystem** + +Create `server/src/systems/buildingSystem.ts`: + +```ts +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) { + // Structure gone or already complete + world.removeComponent(entity, 'buildingState'); + brain.currentGoal = 'wander'; + continue; + } + + // Progress the build + structure.buildProgress += industryConfig.buildProgressPerTick; + needs.productivity = Math.min(100, needs.productivity + PRODUCTIVITY_RECOVERY_RATE); + + if (structure.buildProgress >= 1) { + // Complete + structure.buildProgress = undefined; + structure.builderEntityId = undefined; + world.removeComponent(entity, 'buildingState'); + + if (needs.productivity >= PRODUCTIVITY_THRESHOLD) { + brain.currentGoal = 'wander'; + } + } + } +} +``` + +**Step 4: Run tests to verify they pass** + +Run: `npm -w server run test -- --run src/systems/__tests__/buildingSystem.test.ts` +Expected: All 5 tests PASS + +**Step 5: Commit** + +```bash +git add server/src/systems/buildingSystem.ts server/src/systems/__tests__/buildingSystem.test.ts +git commit -m "feat(systems): add buildingSystem with structure progress tracking" +``` + +--- + +### Task 6: Industry System — decision tree (craft vs build vs gather) + +**Files:** +- Create: `server/src/systems/industrySystem.ts` +- Create: `server/src/systems/__tests__/industrySystem.test.ts` + +The industry system runs BEFORE craftingSystem/buildingSystem/gatheringSystem. It checks NPCs with low productivity and no active craft/build/gather state, then decides what to do and sets up the appropriate state + goal. + +**Step 1: Write the failing tests** + +Create `server/src/systems/__tests__/industrySystem.test.ts`: + +```ts +import { describe, it, expect } from 'vitest'; +import { World } from '../../ecs/World.js'; +import { GameMap } from '../../map/GameMap.js'; +import { industrySystem } from '../industrySystem.js'; +import type { CraftingState } from '../craftingSystem.js'; +import type { BuildingState, StructureData } from '../buildingSystem.js'; +import type { Needs, Movement, NPCBrain, Position, Stats, StatModifiers } from '@dflike/shared'; +import { RecipeRegistry } from '../../industry/recipeRegistry.js'; + +function createIndustryWorld(): { world: World; map: GameMap } { + const world = new World(); + world.setSingleton('recipeRegistry', RecipeRegistry.createDefault()); + const map = new GameMap(20, 20); + map.resourceTiles = [{ x: 3, y: 3, resourceType: 'log' }]; + return { world, map }; +} + +function createIndustryNPC(world: World, x: number, y: number, inv?: Map, overrides?: { intelligence?: 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: 'wander', goalQueue: [] }); + world.addComponent>(e, 'inventory', inv ?? new Map()); + const int = overrides?.intelligence ?? 10; + world.addComponent(e, 'stats', { + strength: 10, dexterity: 10, constitution: 10, intelligence: int, perception: 10, + sociability: 10, courage: 10, curiosity: 10, empathy: 10, temperament: 10, + }); + world.addComponent(e, 'statModifiers', { modifiers: [] }); + return e; +} + +describe('industrySystem', () => { + it('sets goal to craft when NPC has materials for a recipe', () => { + const { world, map } = createIndustryWorld(); + const inv = new Map([['log', 4], ['stone', 2]]); + const e = createIndustryNPC(world, 5, 5, inv); + industrySystem(world, map); + const brain = world.getComponent(e, 'npcBrain')!; + expect(brain.currentGoal).toBe('craft'); + const cs = world.getComponent(e, 'craftingState'); + expect(cs).toBeDefined(); + expect(cs!.ticksRemaining).toBeGreaterThan(0); + }); + + it('sets goal to build when NPC has materials for a build recipe and no structure of that type exists', () => { + const { world, map } = createIndustryWorld(); + const inv = new Map([['log', 4]]); + const e = createIndustryNPC(world, 5, 5, inv); + industrySystem(world, map); + const brain = world.getComponent(e, 'npcBrain')!; + expect(brain.currentGoal).toBe('build'); + const bs = world.getComponent(e, 'buildingState'); + expect(bs).toBeDefined(); + }); + + it('sets goal to gather when NPC has no materials for any recipe', () => { + const { world, map } = createIndustryWorld(); + const e = createIndustryNPC(world, 5, 5); + industrySystem(world, map); + const brain = world.getComponent(e, 'npcBrain')!; + expect(brain.currentGoal).toBe('gather'); + }); + + it('skips NPC whose productivity is above threshold', () => { + const { world, map } = createIndustryWorld(); + const e = createIndustryNPC(world, 5, 5, undefined, { productivity: 80 }); + industrySystem(world, map); + const brain = world.getComponent(e, 'npcBrain')!; + expect(brain.currentGoal).toBe('wander'); // unchanged + }); + + it('skips NPC already crafting', () => { + const { world, map } = createIndustryWorld(); + const inv = new Map([['log', 4], ['stone', 2]]); + const e = createIndustryNPC(world, 5, 5, inv); + const brain = world.getComponent(e, 'npcBrain')!; + brain.currentGoal = 'craft'; + world.addComponent(e, 'craftingState', { recipeId: 'craft_wooden_axe', ticksRemaining: 10 }); + industrySystem(world, map); + // Should not change anything + const cs = world.getComponent(e, 'craftingState')!; + expect(cs.ticksRemaining).toBe(10); + }); + + it('skips NPC already building', () => { + const { world, map } = createIndustryWorld(); + const e = createIndustryNPC(world, 5, 5); + const brain = world.getComponent(e, 'npcBrain')!; + brain.currentGoal = 'build'; + world.addComponent(e, 'buildingState', { structureEntityId: 99, recipeId: 'build_stockpile' }); + industrySystem(world, map); + expect(brain.currentGoal).toBe('build'); + }); + + it('does not build a stockpile when one already exists', () => { + const { world, map } = createIndustryWorld(); + // NPC only has materials for stockpile (4 log), not for crafting tools + const inv = new Map([['log', 4]]); + const e = createIndustryNPC(world, 5, 5, inv); + // Create existing completed stockpile + const existingStockpile = world.createEntity(); + world.addComponent(existingStockpile, 'position', { x: 10, y: 10 }); + world.addComponent(existingStockpile, 'structure', { + type: 'stockpile', + subtype: 'general', + inventory: new Map(), + }); + industrySystem(world, map); + const brain = world.getComponent(e, 'npcBrain')!; + // Should fallback to gather since stockpile already exists and no craft recipe matches + expect(brain.currentGoal).toBe('gather'); + }); + + it('prefers craft over build when both are possible', () => { + const { world, map } = createIndustryWorld(); + // Enough for both craft_wooden_axe (2 log + 1 stone) and build_stockpile (4 log) + const inv = new Map([['log', 6], ['stone', 2]]); + const e = createIndustryNPC(world, 5, 5, inv); + industrySystem(world, map); + const brain = world.getComponent(e, 'npcBrain')!; + expect(brain.currentGoal).toBe('craft'); + }); +}); +``` + +**Step 2: Run tests to verify they fail** + +Run: `npm -w server run test -- --run src/systems/__tests__/industrySystem.test.ts` +Expected: FAIL — module not found + +**Step 3: Implement industrySystem** + +Create `server/src/systems/industrySystem.ts`: + +```ts +import { + PRODUCTIVITY_THRESHOLD, + type Needs, type NPCBrain, type Position, +} from '@dflike/shared'; +import type { World } from '../ecs/World.js'; +import type { GameMap } from '../map/GameMap.js'; +import type { GatheringState } from './gatheringSystem.js'; +import type { CraftingState } from './craftingSystem.js'; +import type { BuildingState, StructureData } from './buildingSystem.js'; +import { RecipeRegistry, type Recipe } from '../industry/recipeRegistry.js'; +import { getEffectiveStat } from './statHelpers.js'; +import { industryConfig } from '../config/industryConfig.js'; + +/** Map recipe outputItemId to structure type/subtype */ +const BUILD_RECIPE_MAP: Record = { + stockpile: { type: 'stockpile', subtype: 'general' }, + workbench: { type: 'workshop', subtype: 'workbench' }, +}; + +function structureExists(world: World, structureType: string, subtype: string): boolean { + for (const entity of world.query('structure')) { + const s = world.getComponent(entity, 'structure')!; + if (s.type === structureType && s.subtype === subtype && s.buildProgress === undefined) { + return true; + } + } + return false; +} + +function findNearbyWalkable(map: GameMap, from: Position): Position | null { + // Search in a small radius for a walkable tile + for (let r = 1; r <= 3; r++) { + for (let dx = -r; dx <= r; dx++) { + for (let dy = -r; dy <= r; dy++) { + if (Math.abs(dx) !== r && Math.abs(dy) !== r) continue; + const x = from.x + dx; + const y = from.y + dy; + if (map.isWalkable(x, y)) return { x, y }; + } + } + } + return null; +} + +export function industrySystem(world: World, map: GameMap): void { + const registry = world.getSingleton('recipeRegistry'); + if (!registry) return; + + for (const entity of world.query('npcBrain', 'needs', 'inventory', 'position')) { + const needs = world.getComponent(entity, 'needs')!; + if (needs.productivity >= PRODUCTIVITY_THRESHOLD) continue; + + const brain = world.getComponent(entity, 'npcBrain')!; + + // Skip if already actively doing something productive + if (brain.currentGoal === 'craft' && world.getComponent(entity, 'craftingState')) continue; + if (brain.currentGoal === 'build' && world.getComponent(entity, 'buildingState')) continue; + if (brain.currentGoal === 'gather' && world.getComponent(entity, 'gatheringState')) continue; + // Skip if walking toward a gather/eat/rest target + if (brain.currentGoal === 'gather' || brain.currentGoal === 'eat' || brain.currentGoal === 'rest') continue; + // Skip if dropping off + if (brain.currentGoal === 'dropoff') continue; + + const inv = world.getComponent>(entity, 'inventory')!; + const pos = world.getComponent(entity, 'position')!; + + // Try craft first + const craftRecipes = registry.findCraftable(inv).filter(r => !r.id.startsWith('build_')); + if (craftRecipes.length > 0) { + const recipe = craftRecipes[0]; + const dex = getEffectiveStat(world, entity, 'dexterity'); + const baseTicks = industryConfig.craftBaseTicks; + const ticks = Math.max(1, Math.round(baseTicks * (1 - (dex - 10) * industryConfig.craftDexterityModifier))); + brain.currentGoal = 'craft'; + world.addComponent(entity, 'craftingState', { + recipeId: recipe.id, + ticksRemaining: ticks, + }); + continue; + } + + // Try build + const buildRecipes = registry.findCraftable(inv).filter(r => r.id.startsWith('build_')); + for (const recipe of buildRecipes) { + const mapping = BUILD_RECIPE_MAP[recipe.outputItemId]; + if (!mapping) continue; + if (structureExists(world, mapping.type, mapping.subtype)) continue; + + // Find a walkable spot nearby to place the structure + const buildPos = findNearbyWalkable(map, pos); + if (!buildPos) continue; + + // Consume inputs + for (const input of recipe.inputs) { + const current = inv.get(input.itemId) ?? 0; + const remaining = current - input.quantity; + if (remaining <= 0) inv.delete(input.itemId); + else inv.set(input.itemId, remaining); + } + + // Create structure entity + const structureEntity = world.createEntity(); + world.addComponent(structureEntity, 'position', buildPos); + world.addComponent(structureEntity, 'structure', { + type: mapping.type, + subtype: mapping.subtype, + inventory: new Map(), + buildProgress: 0, + builderEntityId: entity, + }); + + brain.currentGoal = 'build'; + world.addComponent(entity, 'buildingState', { + structureEntityId: structureEntity, + recipeId: recipe.id, + }); + break; + } + if (brain.currentGoal === 'build') continue; + + // Fallback: gather + brain.currentGoal = 'gather'; + } +} +``` + +**Step 4: Run tests to verify they pass** + +Run: `npm -w server run test -- --run src/systems/__tests__/industrySystem.test.ts` +Expected: All 8 tests PASS + +**Step 5: Commit** + +```bash +git add server/src/systems/industrySystem.ts server/src/systems/__tests__/industrySystem.test.ts +git commit -m "feat(systems): add industrySystem with craft/build/gather decision tree" +``` + +--- + +### Task 7: Drop-off Behavior — NPCs deposit items at stockpiles + +**Files:** +- Create: `server/src/systems/dropoffSystem.ts` +- Create: `server/src/systems/__tests__/dropoffSystem.test.ts` + +After gathering is complete, if a completed stockpile exists, the NPC paths to it and transfers items. The gatheringSystem already resets to 'wander' when productivity is satisfied. We need to intercept when gathering completes (productivity still low) and check for stockpile drop-off. + +**Step 1: Write the failing tests** + +Create `server/src/systems/__tests__/dropoffSystem.test.ts`: + +```ts +import { describe, it, expect } from 'vitest'; +import { World } from '../../ecs/World.js'; +import { GameMap } from '../../map/GameMap.js'; +import { dropoffSystem } from '../dropoffSystem.js'; +import type { StructureData } from '../buildingSystem.js'; +import type { Needs, Movement, NPCBrain, Position, Stats, StatModifiers } from '@dflike/shared'; + +function createDropoffNPC(world: World, x: number, y: number, inv?: Map) { + const e = world.createEntity(); + world.addComponent(e, 'position', { x, y }); + world.addComponent(e, 'needs', { hunger: 80, energy: 80, productivity: 30 }); + world.addComponent(e, 'movement', { state: 'idle', target: null, path: [], direction: 0, moveProgress: 0 }); + world.addComponent(e, 'npcBrain', { currentGoal: 'dropoff', goalQueue: [] }); + world.addComponent>(e, 'inventory', inv ?? new Map([['log', 3], ['stone', 1]])); + 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; +} + +function createStockpile(world: World, x: number, y: number): number { + const e = world.createEntity(); + world.addComponent(e, 'position', { x, y }); + world.addComponent(e, 'structure', { + type: 'stockpile', + subtype: 'general', + inventory: new Map(), + }); + return e; +} + +describe('dropoffSystem', () => { + it('transfers inventory to stockpile when NPC is at stockpile position', () => { + const world = new World(); + const map = new GameMap(10, 10); + const stockpileEntity = createStockpile(world, 5, 5); + const e = createDropoffNPC(world, 5, 5, new Map([['log', 3], ['stone', 1]])); + dropoffSystem(world, map); + const npcInv = world.getComponent>(e, 'inventory')!; + expect(npcInv.size).toBe(0); + const stockpile = world.getComponent(stockpileEntity, 'structure')!; + expect(stockpile.inventory.get('log')).toBe(3); + expect(stockpile.inventory.get('stone')).toBe(1); + }); + + it('resets goal after drop-off', () => { + const world = new World(); + const map = new GameMap(10, 10); + createStockpile(world, 5, 5); + const e = createDropoffNPC(world, 5, 5); + dropoffSystem(world, map); + const brain = world.getComponent(e, 'npcBrain')!; + expect(brain.currentGoal).toBe('wander'); + }); + + it('does not transfer when NPC is not at stockpile', () => { + const world = new World(); + const map = new GameMap(10, 10); + createStockpile(world, 5, 5); + const e = createDropoffNPC(world, 3, 3); + dropoffSystem(world, map); + const npcInv = world.getComponent>(e, 'inventory')!; + expect(npcInv.size).toBe(2); // unchanged + }); + + it('skips NPC whose goal is not dropoff', () => { + const world = new World(); + const map = new GameMap(10, 10); + createStockpile(world, 5, 5); + const e = createDropoffNPC(world, 5, 5); + const brain = world.getComponent(e, 'npcBrain')!; + brain.currentGoal = 'wander'; + dropoffSystem(world, map); + const npcInv = world.getComponent>(e, 'inventory')!; + expect(npcInv.size).toBe(2); // unchanged + }); +}); +``` + +**Step 2: Run tests to verify they fail** + +Run: `npm -w server run test -- --run src/systems/__tests__/dropoffSystem.test.ts` +Expected: FAIL — module not found + +**Step 3: Implement dropoffSystem** + +Create `server/src/systems/dropoffSystem.ts`: + +```ts +import type { Needs, Movement, NPCBrain, Position } from '@dflike/shared'; +import type { World } from '../ecs/World.js'; +import type { GameMap } from '../map/GameMap.js'; +import type { StructureData } from './buildingSystem.js'; +import { addItem } from '../industry/inventoryHelpers.js'; + +export function dropoffSystem(world: World, _map: GameMap): void { + for (const entity of world.query('npcBrain', 'position', 'inventory', 'movement')) { + const brain = world.getComponent(entity, 'npcBrain')!; + if (brain.currentGoal !== 'dropoff') continue; + + const movement = world.getComponent(entity, 'movement')!; + if (movement.state !== 'idle') continue; + + const pos = world.getComponent(entity, 'position')!; + const inv = world.getComponent>(entity, 'inventory')!; + + // Find a completed stockpile at our position + let deposited = false; + for (const sEntity of world.query('structure', 'position')) { + const sData = world.getComponent(sEntity, 'structure')!; + if (sData.type !== 'stockpile' || sData.buildProgress !== undefined) continue; + const sPos = world.getComponent(sEntity, 'position')!; + if (sPos.x !== pos.x || sPos.y !== pos.y) continue; + + // Transfer all items + for (const [itemId, qty] of inv) { + addItem(sData.inventory, itemId, qty); + } + inv.clear(); + deposited = true; + break; + } + + if (deposited) { + brain.currentGoal = 'wander'; + } + } +} +``` + +**Step 4: Run tests to verify they pass** + +Run: `npm -w server run test -- --run src/systems/__tests__/dropoffSystem.test.ts` +Expected: All 4 tests PASS + +**Step 5: Commit** + +```bash +git add server/src/systems/dropoffSystem.ts server/src/systems/__tests__/dropoffSystem.test.ts +git commit -m "feat(systems): add dropoffSystem for stockpile item transfer" +``` + +--- + +### Task 8: NPC Brain Updates — integrate craft/build/dropoff goals + +**Files:** +- Modify: `server/src/systems/npcBrainSystem.ts` +- Modify: `server/src/systems/gatheringSystem.ts` (add dropoff after gathering) +- Modify: `server/src/systems/movementSystem.ts` (pause during crafting/building) + +**Step 1: Update npcBrainSystem to handle craft/build/dropoff goals** + +In `server/src/systems/npcBrainSystem.ts`: + +1. Add imports at top: +```ts +import type { CraftingState } from './craftingSystem.js'; +import type { BuildingState, StructureData } from './buildingSystem.js'; +import type { GatheringState } from './gatheringSystem.js'; +``` + +2. After the social interaction skip (line 49), add skips for active industry states: +```ts + // Skip if actively crafting, building, or gathering + const craftingState = world.getComponent(entity, 'craftingState'); + if (craftingState) continue; + const buildingState = world.getComponent(entity, 'buildingState'); + if (buildingState) continue; + const gatheringState = world.getComponent(entity, 'gatheringState'); + if (gatheringState) continue; +``` + +3. In the goal label map (line 95), add new labels: +```ts +const goalLabels: Record = { + wander: 'wandering', eat: 'looking for food', rest: 'looking for rest', + gather: 'looking for resources', craft: 'crafting', build: 'building', + dropoff: 'dropping off items', +}; +``` + +4. In the target selection section, add handling for 'dropoff': +After the `gather` target block (line 117), add: +```ts + } else if (goal === 'dropoff') { + // Find nearest completed stockpile + for (const sEntity of world.query('structure', 'position')) { + const sData = world.getComponent(sEntity, 'structure')!; + if (sData.type !== 'stockpile' || sData.buildProgress !== undefined) continue; + const sPos = world.getComponent(sEntity, 'position')!; + const d = Math.abs(pos.x - sPos.x) + Math.abs(pos.y - sPos.y); + if (!target || d < (Math.abs(pos.x - target.x) + Math.abs(pos.y - target.y))) { + target = { x: sPos.x, y: sPos.y }; + } + } + if (!target) { + // No stockpile found, just wander + goal = 'wander'; + brain.currentGoal = 'wander'; + } + } +``` + +Note: 'craft' and 'build' goals don't need target selection here — the industrySystem handles setup (crafting is in-place, building creates the structure nearby). The brain just needs to not override those goals. + +**Step 2: Update gatheringSystem to trigger dropoff after gathering** + +In `server/src/systems/gatheringSystem.ts`, after gathering completes and productivity check (lines 40-44): + +Replace: +```ts + // Decide next action + if (needs.productivity >= PRODUCTIVITY_THRESHOLD) { + brain.currentGoal = 'wander'; + } + // else brain stays on 'gather', npcBrain will pick new target next tick +``` + +With: +```ts + // Decide next action + if (needs.productivity >= PRODUCTIVITY_THRESHOLD) { + brain.currentGoal = 'wander'; + } else { + // Check if a stockpile exists for drop-off + let hasStockpile = false; + for (const sEntity of world.query('structure')) { + const sData = world.getComponent(sEntity, 'structure'); + if (sData && sData.type === 'stockpile' && sData.buildProgress === undefined) { + hasStockpile = true; + break; + } + } + if (hasStockpile && inv && inv.size > 0) { + brain.currentGoal = 'dropoff'; + } + // else brain stays on 'gather', npcBrain will pick new target next tick + } +``` + +Add import at top of gatheringSystem.ts: +```ts +import type { StructureData } from './buildingSystem.js'; +``` + +**Step 3: Update movementSystem to pause during crafting/building** + +In `server/src/systems/movementSystem.ts`, after the gatheringState skip (line 19), add: + +```ts + const craftingState = world.getComponent(entity, 'craftingState'); + if (craftingState) continue; + const buildingState = world.getComponent(entity, 'buildingState'); + if (buildingState) continue; +``` + +Add imports at top: +```ts +import type { CraftingState } from './craftingSystem.js'; +import type { BuildingState } from './buildingSystem.js'; +``` + +**Step 4: Run all existing tests to verify nothing breaks** + +Run: `npm -w server run test` +Expected: All tests PASS + +**Step 5: Commit** + +```bash +git add server/src/systems/npcBrainSystem.ts server/src/systems/gatheringSystem.ts server/src/systems/movementSystem.ts +git commit -m "feat(systems): integrate craft/build/dropoff goals into brain and movement" +``` + +--- + +### Task 9: Wire new systems into GameLoop + +**Files:** +- Modify: `server/src/game/GameLoop.ts` + +**Step 1: Add imports** + +In `server/src/game/GameLoop.ts`, add after existing imports: + +```ts +import { industrySystem } from '../systems/industrySystem.js'; +import { craftingSystem } from '../systems/craftingSystem.js'; +import { buildingSystem } from '../systems/buildingSystem.js'; +import { dropoffSystem } from '../systems/dropoffSystem.js'; +import { RecipeRegistry } from '../industry/recipeRegistry.js'; +``` + +**Step 2: Register RecipeRegistry singleton in constructor** + +In the constructor, after the itemRegistry singleton (line 38), add: + +```ts + this.world.setSingleton('recipeRegistry', RecipeRegistry.createDefault()); +``` + +**Step 3: Update system execution order in update()** + +The new system order should be: +1. `statModifierSystem` +2. `needsDecaySystem` +3. `npcBrainSystem` +4. `socialSystem` +5. `narrationEmitter` +6. `relationshipSystem` +7. **`industrySystem`** (decides craft/build/gather — NEW) +8. `gatheringSystem` (existing) +9. **`craftingSystem`** (NEW) +10. **`buildingSystem`** (NEW) +11. **`dropoffSystem`** (NEW) +12. `movementSystem` +13. `thoughtSystem` + +Replace the update() method's system calls (lines 101-109): + +```ts + // Run systems in order + statModifierSystem(this.world); + needsDecaySystem(this.world, this.eventMemoryService); + npcBrainSystem(this.world, this.map, this.eventMemoryService); + socialSystem(this.world, this.eventMemoryService); + narrationEmitter(this.world, this.narrationService, this.followedEntityIds, this.eventMemoryService); + relationshipSystem(this.world, this.eventMemoryService); + industrySystem(this.world, this.map); + gatheringSystem(this.world, this.map); + craftingSystem(this.world); + buildingSystem(this.world, this.map); + dropoffSystem(this.world, this.map); + movementSystem(this.world); + this.thoughtSystem.update(this.world, this.followedEntityIds, this.tick); +``` + +**Step 4: Run all tests** + +Run: `npm -w server run test` +Expected: All tests PASS + +**Step 5: Commit** + +```bash +git add server/src/game/GameLoop.ts +git commit -m "feat(game): wire industrySystem, craftingSystem, buildingSystem, dropoffSystem into game loop" +``` + +--- + +### Task 10: Integration Tests — full lifecycle gather → craft → build + +**Files:** +- Create: `server/src/systems/__tests__/industryIntegration.test.ts` + +**Step 1: Write integration tests** + +Create `server/src/systems/__tests__/industryIntegration.test.ts`: + +```ts +import { describe, it, expect } from 'vitest'; +import { World } from '../../ecs/World.js'; +import { GameMap } from '../../map/GameMap.js'; +import { industrySystem } from '../industrySystem.js'; +import { craftingSystem, type CraftingState } from '../craftingSystem.js'; +import { buildingSystem, type BuildingState, type StructureData } from '../buildingSystem.js'; +import { gatheringSystem, type GatheringState } from '../gatheringSystem.js'; +import { dropoffSystem } from '../dropoffSystem.js'; +import { RecipeRegistry } from '../../industry/recipeRegistry.js'; +import type { Needs, Movement, NPCBrain, Position, Stats, StatModifiers } from '@dflike/shared'; + +function createTestWorld(): { world: World; map: GameMap } { + const world = new World(); + world.setSingleton('recipeRegistry', RecipeRegistry.createDefault()); + const map = new GameMap(20, 20); + map.resourceTiles = [ + { x: 3, y: 3, resourceType: 'log' }, + { x: 7, y: 7, resourceType: 'stone' }, + ]; + return { world, map }; +} + +function createTestNPC(world: World, x: number, y: number, inv?: Map) { + const e = world.createEntity(); + world.addComponent(e, 'position', { x, y }); + world.addComponent(e, 'needs', { hunger: 80, energy: 80, productivity: 20 }); + world.addComponent(e, 'movement', { state: 'idle', target: null, path: [], direction: 0, moveProgress: 0 }); + world.addComponent(e, 'npcBrain', { currentGoal: 'wander', goalQueue: [] }); + world.addComponent>(e, 'inventory', inv ?? new Map()); + 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('industry integration', () => { + it('NPC with materials crafts a tool', () => { + const { world, map } = createTestWorld(); + const inv = new Map([['log', 4], ['stone', 2]]); + const e = createTestNPC(world, 5, 5, inv); + + // Industry system assigns craft goal + industrySystem(world, map); + const brain = world.getComponent(e, 'npcBrain')!; + expect(brain.currentGoal).toBe('craft'); + + // Run crafting until done + const cs = world.getComponent(e, 'craftingState')!; + for (let i = 0; i < cs.ticksRemaining + 1; i++) { + craftingSystem(world); + } + + const finalInv = world.getComponent>(e, 'inventory')!; + expect(finalInv.has('wooden_axe') || finalInv.has('hammer')).toBe(true); + }); + + it('NPC builds a stockpile when it has materials and no stockpile exists', () => { + const { world, map } = createTestWorld(); + const inv = new Map([['log', 4]]); + const e = createTestNPC(world, 5, 5, inv); + + industrySystem(world, map); + const brain = world.getComponent(e, 'npcBrain')!; + expect(brain.currentGoal).toBe('build'); + + const bs = world.getComponent(e, 'buildingState')!; + // Run building until complete + for (let i = 0; i < 60; i++) { + buildingSystem(world, map); + } + + const structure = world.getComponent(bs.structureEntityId, 'structure')!; + expect(structure.buildProgress).toBeUndefined(); // completed + }); + + it('NPC drops off items at stockpile after gathering', () => { + const { world, map } = createTestWorld(); + const e = createTestNPC(world, 5, 5, new Map([['log', 2]])); + + // Create a completed stockpile at the NPC's position + const stockpileEntity = world.createEntity(); + world.addComponent(stockpileEntity, 'position', { x: 5, y: 5 }); + world.addComponent(stockpileEntity, 'structure', { + type: 'stockpile', + subtype: 'general', + inventory: new Map(), + }); + + // Set NPC to dropoff goal + const brain = world.getComponent(e, 'npcBrain')!; + brain.currentGoal = 'dropoff'; + + dropoffSystem(world, map); + + const npcInv = world.getComponent>(e, 'inventory')!; + expect(npcInv.size).toBe(0); + + const stockpile = world.getComponent(stockpileEntity, 'structure')!; + expect(stockpile.inventory.get('log')).toBe(2); + expect(brain.currentGoal).toBe('wander'); + }); + + it('full lifecycle: no materials → gather fallback', () => { + const { world, map } = createTestWorld(); + const e = createTestNPC(world, 5, 5); + + industrySystem(world, map); + const brain = world.getComponent(e, 'npcBrain')!; + expect(brain.currentGoal).toBe('gather'); + }); +}); +``` + +**Step 2: Run integration tests** + +Run: `npm -w server run test -- --run src/systems/__tests__/industryIntegration.test.ts` +Expected: All 4 tests PASS + +**Step 3: Run full test suite** + +Run: `npm -w server run test` +Expected: All tests PASS (existing + new) + +**Step 4: Commit** + +```bash +git add server/src/systems/__tests__/industryIntegration.test.ts +git commit -m "test(systems): add industry integration tests for gather/craft/build lifecycle" +``` + +--- + +### Task 11: Update CLAUDE.md and design doc status + +**Files:** +- Modify: `CLAUDE.md` +- Modify: `docs/plans/2026-03-08-resource-tool-invention-design.md` + +**Step 1: Update CLAUDE.md key entry points** + +Add after `server/src/systems/gatheringSystem.ts` references or in the Key Entry Points section: + +``` +- `server/src/systems/industrySystem.ts` -- craft/build/gather decision tree +- `server/src/systems/craftingSystem.ts` -- input consumption, timer, output production +- `server/src/systems/buildingSystem.ts` -- structure creation with build progress +- `server/src/systems/dropoffSystem.ts` -- stockpile item deposit +- `server/src/industry/recipeRegistry.ts` -- recipe registry with seed recipes +``` + +**Step 2: Update system execution order in CLAUDE.md** + +Update the systems run order line to: +``` +- Systems run in order: statModifier -> needsDecay -> npcBrain -> social -> narration -> relationship -> industry -> gathering -> crafting -> building -> dropoff -> movement -> thought +``` + +**Step 3: Mark Phase B as COMPLETE in design doc** + +In `docs/plans/2026-03-08-resource-tool-invention-design.md`, change Phase B status from `NOT STARTED` to `COMPLETE`. + +**Step 4: Commit** + +```bash +git add CLAUDE.md docs/plans/2026-03-08-resource-tool-invention-design.md +git commit -m "docs: mark Phase B (crafting + building) as complete" +``` diff --git a/docs/plans/2026-03-08-phase-c-llm-invention.md b/docs/plans/2026-03-08-phase-c-llm-invention.md new file mode 100644 index 0000000..16e4874 --- /dev/null +++ b/docs/plans/2026-03-08-phase-c-llm-invention.md @@ -0,0 +1,1443 @@ +# Phase C: LLM Invention — Implementation Plan + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. + +**Goal:** NPCs occasionally have "eureka moments" where the LLM invents new items, recipes, or workshops from existing materials. Inventions chain — new items become inputs for future inventions. + +**Architecture:** A new `inventionSystem` runs every ~100 ticks. Per-NPC probability check (scaled by INT + CUR stats, requiring recent industry activity). On eureka, queues an async LLM call with available materials context. Response is validated, then registered as a new item + recipe. An `InventionTimeline` world singleton tracks all inventions. A new "Most Inventive" superlative rewards prolific inventors. Narration events announce inventions. + +**Tech Stack:** TypeScript, vitest, existing ECS framework, LlmService (OpenRouter), shared types package + +--- + +### Task 1: Invention Config — Add tuning values + +**Files:** +- Modify: `server/src/config/industryConfig.ts` + +**Step 1: Add invention config values** + +In `server/src/config/industryConfig.ts`, add invention settings after existing config: + +```ts +export const industryConfig = { + // ... existing values ... + + // Invention + inventionCheckInterval: 100, // ticks between eureka checks + inventionBaseChance: 0.005, // 0.5% base per check + inventionIntelligenceScale: 0.1, // per INT point from 10 + inventionCuriosityScale: 0.1, // per CUR point from 10 + inventionMinChance: 0.001, // floor + inventionMaxChance: 0.05, // ceiling (5%) +} as const; +``` + +**Step 2: Commit** + +```bash +git add server/src/config/industryConfig.ts +git commit -m "feat(config): add invention tuning values to industryConfig" +``` + +--- + +### Task 2: InventionTimeline — World singleton + types + +**Files:** +- Create: `server/src/industry/inventionTimeline.ts` +- Test: `server/src/industry/__tests__/inventionTimeline.test.ts` + +**Step 1: Write the failing test** + +Create `server/src/industry/__tests__/inventionTimeline.test.ts`: + +```ts +import { describe, it, expect } from 'vitest'; +import { createInventionTimeline, type InventionTimeline } from '../inventionTimeline.js'; + +describe('InventionTimeline', () => { + it('starts empty', () => { + const timeline = createInventionTimeline(); + expect(timeline.getAll()).toEqual([]); + }); + + it('records an invention entry', () => { + const timeline = createInventionTimeline(); + timeline.record({ + itemId: 'rope', + inventorEntityId: 1, + inventorName: 'Gwen', + tick: 500, + day: 2, + }); + const all = timeline.getAll(); + expect(all).toHaveLength(1); + expect(all[0].itemId).toBe('rope'); + expect(all[0].inventorName).toBe('Gwen'); + }); + + it('counts inventions per entity', () => { + const timeline = createInventionTimeline(); + timeline.record({ itemId: 'rope', inventorEntityId: 1, inventorName: 'Gwen', tick: 500, day: 2 }); + timeline.record({ itemId: 'clay', inventorEntityId: 1, inventorName: 'Gwen', tick: 600, day: 2 }); + timeline.record({ itemId: 'kiln', inventorEntityId: 2, inventorName: 'Bram', tick: 700, day: 3 }); + + expect(timeline.countByEntity(1)).toBe(2); + expect(timeline.countByEntity(2)).toBe(1); + expect(timeline.countByEntity(3)).toBe(0); + }); + + it('returns entries in chronological order', () => { + const timeline = createInventionTimeline(); + timeline.record({ itemId: 'a', inventorEntityId: 1, inventorName: 'X', tick: 300, day: 1 }); + timeline.record({ itemId: 'b', inventorEntityId: 2, inventorName: 'Y', tick: 100, day: 1 }); + const all = timeline.getAll(); + expect(all[0].tick).toBe(300); + expect(all[1].tick).toBe(100); + }); +}); +``` + +**Step 2: Run test to verify it fails** + +Run: `npm -w server run test -- --run src/industry/__tests__/inventionTimeline.test.ts` +Expected: FAIL — module not found + +**Step 3: Write the implementation** + +Create `server/src/industry/inventionTimeline.ts`: + +```ts +import type { EntityId } from '@dflike/shared'; + +export interface InventionEntry { + itemId: string; + inventorEntityId: EntityId; + inventorName: string; + tick: number; + day: number; +} + +export interface InventionTimeline { + record(entry: InventionEntry): void; + getAll(): InventionEntry[]; + countByEntity(entityId: EntityId): number; +} + +export function createInventionTimeline(): InventionTimeline { + const entries: InventionEntry[] = []; + + return { + record(entry: InventionEntry): void { + entries.push(entry); + }, + + getAll(): InventionEntry[] { + return [...entries]; + }, + + countByEntity(entityId: EntityId): number { + return entries.filter(e => e.inventorEntityId === entityId).length; + }, + }; +} +``` + +**Step 4: Run test to verify it passes** + +Run: `npm -w server run test -- --run src/industry/__tests__/inventionTimeline.test.ts` +Expected: PASS (all 4 tests) + +**Step 5: Commit** + +```bash +git add server/src/industry/inventionTimeline.ts server/src/industry/__tests__/inventionTimeline.test.ts +git commit -m "feat(industry): add InventionTimeline singleton for tracking inventions" +``` + +--- + +### Task 3: Invention Validation Pipeline + +**Files:** +- Create: `server/src/industry/inventionValidator.ts` +- Test: `server/src/industry/__tests__/inventionValidator.test.ts` + +**Step 1: Write the failing tests** + +Create `server/src/industry/__tests__/inventionValidator.test.ts`: + +```ts +import { describe, it, expect } from 'vitest'; +import { validateInvention, type RawInvention } from '../inventionValidator.js'; +import { ItemRegistry } from '../itemRegistry.js'; + +function createTestRegistry(): ItemRegistry { + return ItemRegistry.createDefault(); // has log, stone, water, wooden_axe, hammer +} + +describe('validateInvention', () => { + it('accepts a valid crafted item', () => { + const registry = createTestRegistry(); + const raw: RawInvention = { + name: 'rope', + description: 'Twisted plant fibers', + category: 'material', + inputs: [{ itemId: 'log', quantity: 2 }], + }; + const result = validateInvention(raw, registry); + expect(result.valid).toBe(true); + expect(result.itemId).toBe('rope'); + }); + + it('rejects when input itemId does not exist', () => { + const registry = createTestRegistry(); + const raw: RawInvention = { + name: 'magic wand', + description: 'A wand', + category: 'tool', + inputs: [{ itemId: 'unicorn_horn', quantity: 1 }], + }; + const result = validateInvention(raw, registry); + expect(result.valid).toBe(false); + expect(result.error).toContain('unicorn_horn'); + }); + + it('rejects duplicate item name (case-insensitive)', () => { + const registry = createTestRegistry(); + const raw: RawInvention = { + name: 'Log', + description: 'Another log', + category: 'resource', + inputs: [], + }; + const result = validateInvention(raw, registry); + expect(result.valid).toBe(false); + expect(result.error).toContain('already exists'); + }); + + it('rejects invalid category', () => { + const registry = createTestRegistry(); + const raw: RawInvention = { + name: 'potion', + description: 'A potion', + category: 'magic' as any, + inputs: [{ itemId: 'water', quantity: 1 }], + }; + const result = validateInvention(raw, registry); + expect(result.valid).toBe(false); + expect(result.error).toContain('category'); + }); + + it('rejects missing name', () => { + const registry = createTestRegistry(); + const raw: RawInvention = { + name: '', + description: 'No name', + category: 'material', + inputs: [{ itemId: 'log', quantity: 1 }], + }; + const result = validateInvention(raw, registry); + expect(result.valid).toBe(false); + }); + + it('generates a snake_case itemId from name', () => { + const registry = createTestRegistry(); + const raw: RawInvention = { + name: 'Fishing Rod', + description: 'A rod for catching fish', + category: 'tool', + inputs: [{ itemId: 'log', quantity: 2 }], + }; + const result = validateInvention(raw, registry); + expect(result.valid).toBe(true); + expect(result.itemId).toBe('fishing_rod'); + }); + + it('accepts a structure invention with workshopType', () => { + const registry = createTestRegistry(); + const raw: RawInvention = { + name: 'kiln', + description: 'A stone furnace', + category: 'structure', + inputs: [{ itemId: 'stone', quantity: 5 }], + workshopType: 'kiln', + }; + const result = validateInvention(raw, registry); + expect(result.valid).toBe(true); + expect(result.workshopType).toBe('kiln'); + }); + + it('rejects if inputs array is missing', () => { + const registry = createTestRegistry(); + const raw = { + name: 'thing', + description: 'A thing', + category: 'material', + } as any; + const result = validateInvention(raw, registry); + expect(result.valid).toBe(false); + }); +}); +``` + +**Step 2: Run test to verify it fails** + +Run: `npm -w server run test -- --run src/industry/__tests__/inventionValidator.test.ts` +Expected: FAIL — module not found + +**Step 3: Write the implementation** + +Create `server/src/industry/inventionValidator.ts`: + +```ts +import type { ItemRegistry } from './itemRegistry.js'; + +export interface RawInvention { + name: string; + description: string; + category: string; + inputs: { itemId: string; quantity: number }[]; + workshopType?: string | null; + toolRequired?: string | null; +} + +export interface ValidationResult { + valid: boolean; + error?: string; + itemId?: string; + workshopType?: string; +} + +const VALID_CATEGORIES = ['resource', 'tool', 'material', 'structure']; + +function toSnakeCase(name: string): string { + return name.toLowerCase().trim().replace(/\s+/g, '_').replace(/[^a-z0-9_]/g, ''); +} + +export function validateInvention(raw: RawInvention, registry: ItemRegistry): ValidationResult { + // Check name exists + if (!raw.name || typeof raw.name !== 'string' || raw.name.trim().length === 0) { + return { valid: false, error: 'Missing or empty name' }; + } + + // Check description exists + if (!raw.description || typeof raw.description !== 'string') { + return { valid: false, error: 'Missing description' }; + } + + // Check category is valid + if (!VALID_CATEGORIES.includes(raw.category)) { + return { valid: false, error: `Invalid category '${raw.category}'; must be one of: ${VALID_CATEGORIES.join(', ')}` }; + } + + // Check inputs is array + if (!Array.isArray(raw.inputs)) { + return { valid: false, error: 'Missing or invalid inputs array' }; + } + + // Check all input itemIds exist in registry + for (const input of raw.inputs) { + if (!registry.get(input.itemId)) { + return { valid: false, error: `Unknown input item '${input.itemId}'` }; + } + if (typeof input.quantity !== 'number' || input.quantity < 1) { + return { valid: false, error: `Invalid quantity for '${input.itemId}'` }; + } + } + + // Generate itemId + const itemId = toSnakeCase(raw.name); + if (!itemId) { + return { valid: false, error: 'Name produces empty itemId' }; + } + + // Check for name collision (case-insensitive via itemId and existing names) + const existingItems = registry.getAll(); + if (existingItems.some(item => item.id === itemId || item.name.toLowerCase() === raw.name.toLowerCase().trim())) { + return { valid: false, error: `Item '${raw.name}' already exists` }; + } + + // Check toolRequired exists if specified + if (raw.toolRequired && !registry.get(raw.toolRequired)) { + return { valid: false, error: `Unknown toolRequired '${raw.toolRequired}'` }; + } + + return { + valid: true, + itemId, + workshopType: raw.workshopType ?? undefined, + }; +} +``` + +**Step 4: Run test to verify it passes** + +Run: `npm -w server run test -- --run src/industry/__tests__/inventionValidator.test.ts` +Expected: PASS (all 8 tests) + +**Step 5: Commit** + +```bash +git add server/src/industry/inventionValidator.ts server/src/industry/__tests__/inventionValidator.test.ts +git commit -m "feat(industry): add invention validation pipeline" +``` + +--- + +### Task 4: Invention Prompt Template + +**Files:** +- Modify: `server/src/llm/templates.ts` + +**Step 1: Add the invention template** + +In `server/src/llm/templates.ts`, add an `invention` entry to the templates object: + +```ts + invention: { + name: 'invention', + systemPrompt: + 'You are an inventor in a medieval fantasy village simulation. ' + + 'Given available materials, invent ONE new item that could be crafted from 2-3 existing materials. ' + + 'Respond ONLY with a valid JSON object. No explanation, no markdown, just JSON.', + userPrompt: + '{{npcName}} (INT:{{intelligence}}, CUR:{{curiosity}}) is having a creative moment.\n' + + 'Available materials in the world:\n{{materials}}\n\n' + + 'Existing inventions (do not duplicate):\n{{existingInventions}}\n\n' + + 'Invent ONE new item. Respond with JSON:\n' + + '{"name": "item name", "description": "brief flavor text", ' + + '"category": "resource|tool|material|structure", ' + + '"inputs": [{"itemId": "existing_item_id", "quantity": N}], ' + + '"workshopType": null or "workshop_subtype", ' + + '"toolRequired": null or "tool_id"}', + }, +``` + +**Step 2: Commit** + +```bash +git add server/src/llm/templates.ts +git commit -m "feat(llm): add invention prompt template" +``` + +--- + +### Task 5: Invention LLM Response Parser + +**Files:** +- Create: `server/src/industry/inventionParser.ts` +- Test: `server/src/industry/__tests__/inventionParser.test.ts` + +**Step 1: Write the failing tests** + +Create `server/src/industry/__tests__/inventionParser.test.ts`: + +```ts +import { describe, it, expect } from 'vitest'; +import { parseInventionResponse } from '../inventionParser.js'; + +describe('parseInventionResponse', () => { + it('parses valid JSON', () => { + const json = '{"name": "rope", "description": "Twisted fibers", "category": "material", "inputs": [{"itemId": "log", "quantity": 2}]}'; + const result = parseInventionResponse(json); + expect(result).not.toBeNull(); + expect(result!.name).toBe('rope'); + expect(result!.inputs).toHaveLength(1); + }); + + it('extracts JSON from markdown code block', () => { + const response = '```json\n{"name": "rope", "description": "Twisted fibers", "category": "material", "inputs": [{"itemId": "log", "quantity": 2}]}\n```'; + const result = parseInventionResponse(response); + expect(result).not.toBeNull(); + expect(result!.name).toBe('rope'); + }); + + it('returns null for unparseable text', () => { + const result = parseInventionResponse('I think we should make a rope'); + expect(result).toBeNull(); + }); + + it('returns null for null input', () => { + const result = parseInventionResponse(null); + expect(result).toBeNull(); + }); + + it('normalizes null workshopType to undefined', () => { + const json = '{"name": "rope", "description": "Fibers", "category": "material", "inputs": [{"itemId": "log", "quantity": 2}], "workshopType": null}'; + const result = parseInventionResponse(json); + expect(result).not.toBeNull(); + expect(result!.workshopType).toBeUndefined(); + }); + + it('preserves workshopType when non-null', () => { + const json = '{"name": "kiln", "description": "A furnace", "category": "structure", "inputs": [{"itemId": "stone", "quantity": 5}], "workshopType": "kiln"}'; + const result = parseInventionResponse(json); + expect(result!.workshopType).toBe('kiln'); + }); +}); +``` + +**Step 2: Run test to verify it fails** + +Run: `npm -w server run test -- --run src/industry/__tests__/inventionParser.test.ts` +Expected: FAIL — module not found + +**Step 3: Write the implementation** + +Create `server/src/industry/inventionParser.ts`: + +```ts +import type { RawInvention } from './inventionValidator.js'; + +export function parseInventionResponse(response: string | null): RawInvention | null { + if (!response) return null; + + let jsonStr = response.trim(); + + // Strip markdown code fences if present + const fenceMatch = jsonStr.match(/```(?:json)?\s*\n?([\s\S]*?)\n?```/); + if (fenceMatch) { + jsonStr = fenceMatch[1].trim(); + } + + try { + const parsed = JSON.parse(jsonStr); + + if (!parsed || typeof parsed !== 'object') return null; + if (typeof parsed.name !== 'string') return null; + if (typeof parsed.description !== 'string') return null; + if (typeof parsed.category !== 'string') return null; + if (!Array.isArray(parsed.inputs)) return null; + + return { + name: parsed.name, + description: parsed.description, + category: parsed.category, + inputs: parsed.inputs, + workshopType: parsed.workshopType ?? undefined, + toolRequired: parsed.toolRequired ?? undefined, + }; + } catch { + return null; + } +} +``` + +**Step 4: Run test to verify it passes** + +Run: `npm -w server run test -- --run src/industry/__tests__/inventionParser.test.ts` +Expected: PASS (all 6 tests) + +**Step 5: Commit** + +```bash +git add server/src/industry/inventionParser.ts server/src/industry/__tests__/inventionParser.test.ts +git commit -m "feat(industry): add LLM invention response parser" +``` + +--- + +### Task 6: Invention Registration Helper + +**Files:** +- Create: `server/src/industry/inventionRegistrar.ts` +- Test: `server/src/industry/__tests__/inventionRegistrar.test.ts` + +This helper takes a validated invention and registers the new item + recipe in both registries. + +**Step 1: Write the failing tests** + +Create `server/src/industry/__tests__/inventionRegistrar.test.ts`: + +```ts +import { describe, it, expect } from 'vitest'; +import { registerInvention } from '../inventionRegistrar.js'; +import { ItemRegistry } from '../itemRegistry.js'; +import { RecipeRegistry } from '../recipeRegistry.js'; +import { createInventionTimeline } from '../inventionTimeline.js'; +import type { RawInvention } from '../inventionValidator.js'; + +function createRegistries() { + return { + itemRegistry: ItemRegistry.createDefault(), + recipeRegistry: RecipeRegistry.createDefault(), + timeline: createInventionTimeline(), + }; +} + +describe('registerInvention', () => { + it('registers new item and recipe', () => { + const { itemRegistry, recipeRegistry, timeline } = createRegistries(); + const raw: RawInvention = { + name: 'rope', + description: 'Twisted plant fibers', + category: 'material', + inputs: [{ itemId: 'log', quantity: 2 }], + }; + + const result = registerInvention({ + raw, + itemId: 'rope', + itemRegistry, + recipeRegistry, + timeline, + inventorEntityId: 1, + inventorName: 'Gwen', + tick: 500, + day: 2, + }); + + expect(result).toBe(true); + expect(itemRegistry.get('rope')).toBeDefined(); + expect(itemRegistry.get('rope')!.source).toBe('invented'); + expect(itemRegistry.get('rope')!.inventedBy!.name).toBe('Gwen'); + expect(recipeRegistry.get('craft_rope')).toBeDefined(); + expect(recipeRegistry.get('craft_rope')!.source).toBe('invented'); + expect(timeline.getAll()).toHaveLength(1); + }); + + it('registers structure recipe with build_ prefix', () => { + const { itemRegistry, recipeRegistry, timeline } = createRegistries(); + const raw: RawInvention = { + name: 'kiln', + description: 'A stone furnace', + category: 'structure', + inputs: [{ itemId: 'stone', quantity: 5 }], + workshopType: 'kiln', + }; + + registerInvention({ + raw, + itemId: 'kiln', + itemRegistry, + recipeRegistry, + timeline, + inventorEntityId: 2, + inventorName: 'Bram', + tick: 700, + day: 3, + }); + + expect(recipeRegistry.get('build_kiln')).toBeDefined(); + expect(recipeRegistry.get('build_kiln')!.outputItemId).toBe('kiln'); + }); + + it('includes toolRequired in recipe', () => { + const { itemRegistry, recipeRegistry, timeline } = createRegistries(); + const raw: RawInvention = { + name: 'plank', + description: 'A wooden plank', + category: 'material', + inputs: [{ itemId: 'log', quantity: 1 }], + toolRequired: 'wooden_axe', + }; + + registerInvention({ + raw, + itemId: 'plank', + itemRegistry, + recipeRegistry, + timeline, + inventorEntityId: 1, + inventorName: 'Gwen', + tick: 500, + day: 2, + }); + + expect(recipeRegistry.get('craft_plank')!.toolRequired).toBe('wooden_axe'); + }); +}); +``` + +**Step 2: Run test to verify it fails** + +Run: `npm -w server run test -- --run src/industry/__tests__/inventionRegistrar.test.ts` +Expected: FAIL — module not found + +**Step 3: Write the implementation** + +Create `server/src/industry/inventionRegistrar.ts`: + +```ts +import type { EntityId } from '@dflike/shared'; +import type { ItemRegistry } from './itemRegistry.js'; +import type { RecipeRegistry } from './recipeRegistry.js'; +import type { InventionTimeline } from './inventionTimeline.js'; +import type { RawInvention } from './inventionValidator.js'; + +export interface RegisterInventionArgs { + raw: RawInvention; + itemId: string; + itemRegistry: ItemRegistry; + recipeRegistry: RecipeRegistry; + timeline: InventionTimeline; + inventorEntityId: EntityId; + inventorName: string; + tick: number; + day: number; +} + +export function registerInvention(args: RegisterInventionArgs): boolean { + const { raw, itemId, itemRegistry, recipeRegistry, timeline, inventorEntityId, inventorName, tick, day } = args; + + // Register item + itemRegistry.register({ + id: itemId, + name: raw.name, + description: raw.description, + category: raw.category as 'resource' | 'tool' | 'material' | 'structure', + source: 'invented', + inventedBy: { entityId: inventorEntityId, name: inventorName, tick, day }, + }); + + // Register recipe + const recipePrefix = raw.category === 'structure' ? 'build_' : 'craft_'; + recipeRegistry.register({ + id: `${recipePrefix}${itemId}`, + outputItemId: itemId, + outputQuantity: 1, + inputs: raw.inputs, + workshopType: raw.workshopType ?? undefined, + toolRequired: raw.toolRequired ?? undefined, + source: 'invented', + }); + + // Record in timeline + timeline.record({ itemId, inventorEntityId, inventorName, tick, day }); + + return true; +} +``` + +**Step 4: Run test to verify it passes** + +Run: `npm -w server run test -- --run src/industry/__tests__/inventionRegistrar.test.ts` +Expected: PASS (all 3 tests) + +**Step 5: Commit** + +```bash +git add server/src/industry/inventionRegistrar.ts server/src/industry/__tests__/inventionRegistrar.test.ts +git commit -m "feat(industry): add invention registration helper" +``` + +--- + +### Task 7: MemoryEventType — Add 'invention' event type + +**Files:** +- Modify: `shared/src/types.ts:8-15` (MemoryEventType union) + +**Step 1: Add 'invention' to MemoryEventType** + +In `shared/src/types.ts`, change the MemoryEventType union: + +```ts +export type MemoryEventType = + | 'social_positive' | 'social_negative' + | 'proposal_accepted' | 'proposal_rejected' + | 'tier_change' + | 'need_crisis' | 'need_recovery' + | 'goal_change' + | 'bond_formed' | 'bond_dissolved' + | 'spawned' + | 'invention'; +``` + +**Step 2: Rebuild shared types** + +Run: `npx -w shared tsc` +Expected: Clean compilation, no errors + +**Step 3: Commit** + +```bash +git add shared/src/types.ts +git commit -m "feat(shared): add 'invention' memory event type" +``` + +--- + +### Task 8: NarrationEvent — Add 'invention' type + +**Files:** +- Modify: `shared/src/narration.ts` (NarrationEvent type field) + +Check the exact narration types file first. The `NarrationEvent.type` is currently `'social' | 'proposal'`. Add `'invention'`. + +**Step 1: Locate and modify narration types** + +In `shared/src/narration.ts`, change the `type` field of NarrationEvent: + +```ts +type: 'social' | 'proposal' | 'invention'; +``` + +**Step 2: Rebuild shared types** + +Run: `npx -w shared tsc` +Expected: Clean compilation, no errors + +**Step 3: Commit** + +```bash +git add shared/src/narration.ts +git commit -m "feat(shared): add 'invention' narration event type" +``` + +--- + +### Task 9: SuperlativesData — Add mostInventive + +**Files:** +- Modify: `shared/src/types.ts:203-215` (SuperlativesData interface) +- Modify: `server/src/systems/superlativesComputer.ts` +- Test: check existing superlatives tests or add inline + +**Step 1: Add to shared types** + +In `shared/src/types.ts`, add `mostInventive` to `SuperlativesData`: + +```ts +export interface SuperlativesData { + // ... existing fields ... + socialButterfly: SuperlativeEntry | null; + mostInventive: SuperlativeEntry | null; +} +``` + +**Step 2: Rebuild shared types** + +Run: `npx -w shared tsc` +Expected: Compilation errors in superlativesComputer.ts (missing field) + +**Step 3: Update superlativesComputer.ts** + +The `computeSuperlatives` function needs to accept an `InventionTimeline` parameter and compute mostInventive. Add it as an optional parameter (since it's a new world singleton). + +At the end of `computeSuperlatives`, before the return: + +```ts +import type { InventionTimeline } from '../industry/inventionTimeline.js'; + +export function computeSuperlatives(world: World, registry: BondRegistry, inventionTimeline?: InventionTimeline): SuperlativesData { + // ... existing code ... + + // Most Inventive: most inventions in timeline (min 1) + let mostInventive: SuperlativeEntry | null = null; + if (inventionTimeline) { + let mostInventiveVal = 0; + for (const id of npcs) { + const count = inventionTimeline.countByEntity(id); + if (count > mostInventiveVal) { + mostInventiveVal = count; + mostInventive = makeEntry(id, count); + } + } + if (mostInventiveVal === 0) mostInventive = null; + } + + return { + // ... existing fields ... + socialButterfly, + mostInventive, + }; +} +``` + +**Step 4: Run all tests** + +Run: `npm -w server run test` +Expected: PASS — existing tests should still pass, superlatives return includes new field + +**Step 5: Commit** + +```bash +git add shared/src/types.ts server/src/systems/superlativesComputer.ts +git commit -m "feat(superlatives): add mostInventive superlative" +``` + +--- + +### Task 10: Invention System — Core logic + +**Files:** +- Create: `server/src/systems/inventionSystem.ts` +- Test: `server/src/systems/__tests__/inventionSystem.test.ts` + +**Step 1: Write the failing tests** + +Create `server/src/systems/__tests__/inventionSystem.test.ts`: + +```ts +import { describe, it, expect, vi } from 'vitest'; +import { createInventionSystem } from '../inventionSystem.js'; +import { World } from '../../ecs/World.js'; +import { ItemRegistry } from '../../industry/itemRegistry.js'; +import { RecipeRegistry } from '../../industry/recipeRegistry.js'; +import { createInventionTimeline } from '../../industry/inventionTimeline.js'; +import { createEventMemoryService, type EventMemoryService } from '../../llm/eventMemoryService.js'; +import type { LlmService } from '../../llm/llmService.js'; +import type { NarrationService } from '../../llm/narrationService.js'; +import type { Needs, Stats, NPCBrain, StatModifiers } from '@dflike/shared'; + +function createMockLlm(): LlmService { + return { + generate: vi.fn().mockResolvedValue(null), + queueDepth: () => 0, + clear: () => {}, + isDailyLimitReached: () => false, + }; +} + +function createMockNarration(): NarrationService { + return { + recordInteraction: vi.fn() as any, + getRecentEvents: () => [], + getEventsForEntity: () => [], + onEventUpdated: null, + onEventCreated: null, + }; +} + +function setupWorld() { + const world = new World(); + const itemRegistry = ItemRegistry.createDefault(); + const recipeRegistry = RecipeRegistry.createDefault(); + const timeline = createInventionTimeline(); + world.setSingleton('itemRegistry', itemRegistry); + world.setSingleton('recipeRegistry', recipeRegistry); + world.setSingleton('inventionTimeline', timeline); + return { world, itemRegistry, recipeRegistry, timeline }; +} + +function addNPC(world: World, opts?: { intelligence?: number; curiosity?: number; inv?: Map }) { + const e = world.createEntity(); + world.addComponent(e, 'needs', { hunger: 80, energy: 80, productivity: 20 }); + world.addComponent(e, 'npcBrain', { currentGoal: 'gather', goalQueue: [] }); + world.addComponent(e, 'stats', { + strength: 10, dexterity: 10, constitution: 10, + intelligence: opts?.intelligence ?? 10, perception: 10, + sociability: 10, courage: 10, curiosity: opts?.curiosity ?? 10, + empathy: 10, temperament: 10, + }); + world.addComponent(e, 'statModifiers', { modifiers: [] }); + world.addComponent>(e, 'inventory', opts?.inv ?? new Map([['log', 3]])); + world.addComponent(e, 'name', 'TestNPC'); + return e; +} + +describe('inventionSystem', () => { + it('does not run on non-interval ticks', () => { + const { world } = setupWorld(); + addNPC(world, { inv: new Map([['log', 5]]) }); + const llm = createMockLlm(); + const system = createInventionSystem(llm, createMockNarration(), createEventMemoryService()); + + system.update(world, 1); // tick 1, not on interval + expect(llm.generate).not.toHaveBeenCalled(); + }); + + it('skips NPC with empty inventory', () => { + const { world } = setupWorld(); + addNPC(world, { inv: new Map() }); + const llm = createMockLlm(); + const system = createInventionSystem(llm, createMockNarration(), createEventMemoryService()); + + // Force deterministic: use seeded random by mocking Math.random + vi.spyOn(Math, 'random').mockReturnValue(0); // always triggers + system.update(world, 100); + expect(llm.generate).not.toHaveBeenCalled(); + vi.restoreAllMocks(); + }); + + it('triggers LLM call when random check passes and NPC has items', () => { + const { world } = setupWorld(); + addNPC(world, { intelligence: 18, curiosity: 18, inv: new Map([['log', 5]]) }); + const llm = createMockLlm(); + const system = createInventionSystem(llm, createMockNarration(), createEventMemoryService()); + + vi.spyOn(Math, 'random').mockReturnValue(0); // always triggers + system.update(world, 100); + expect(llm.generate).toHaveBeenCalledTimes(1); + expect(llm.generate).toHaveBeenCalledWith('invention', expect.objectContaining({ + npcName: 'TestNPC', + })); + vi.restoreAllMocks(); + }); + + it('does not trigger when random check fails', () => { + const { world } = setupWorld(); + addNPC(world, { inv: new Map([['log', 5]]) }); + const llm = createMockLlm(); + const system = createInventionSystem(llm, createMockNarration(), createEventMemoryService()); + + vi.spyOn(Math, 'random').mockReturnValue(0.99); // never triggers + system.update(world, 100); + expect(llm.generate).not.toHaveBeenCalled(); + vi.restoreAllMocks(); + }); + + it('registers valid invention from LLM response', async () => { + const { world, itemRegistry, timeline } = setupWorld(); + addNPC(world, { inv: new Map([['log', 5]]) }); + + const llm = createMockLlm(); + const inventionJson = JSON.stringify({ + name: 'rope', + description: 'Twisted fibers', + category: 'material', + inputs: [{ itemId: 'log', quantity: 2 }], + }); + (llm.generate as ReturnType).mockResolvedValue(inventionJson); + + const narration = createMockNarration(); + const eventMemory = createEventMemoryService(); + const system = createInventionSystem(llm, narration, eventMemory); + + vi.spyOn(Math, 'random').mockReturnValue(0); + system.update(world, 100); + vi.restoreAllMocks(); + + // Wait for async LLM resolution + await vi.waitFor(() => { + expect(itemRegistry.get('rope')).toBeDefined(); + }); + + expect(itemRegistry.get('rope')!.source).toBe('invented'); + expect(timeline.getAll()).toHaveLength(1); + }); + + it('ignores invalid LLM response gracefully', async () => { + const { world, itemRegistry } = setupWorld(); + addNPC(world, { inv: new Map([['log', 5]]) }); + + const llm = createMockLlm(); + (llm.generate as ReturnType).mockResolvedValue('not json at all'); + + const system = createInventionSystem(llm, createMockNarration(), createEventMemoryService()); + + vi.spyOn(Math, 'random').mockReturnValue(0); + system.update(world, 100); + vi.restoreAllMocks(); + + // Wait a tick for async + await new Promise(r => setTimeout(r, 10)); + // No new items registered (only seed items) + expect(itemRegistry.getAll().length).toBe(5); + }); + + it('skips when daily LLM limit reached', () => { + const { world } = setupWorld(); + addNPC(world, { inv: new Map([['log', 5]]) }); + const llm = createMockLlm(); + (llm as any).isDailyLimitReached = () => true; + const system = createInventionSystem(llm, createMockNarration(), createEventMemoryService()); + + vi.spyOn(Math, 'random').mockReturnValue(0); + system.update(world, 100); + expect(llm.generate).not.toHaveBeenCalled(); + vi.restoreAllMocks(); + }); +}); +``` + +**Step 2: Run test to verify it fails** + +Run: `npm -w server run test -- --run src/systems/__tests__/inventionSystem.test.ts` +Expected: FAIL — module not found + +**Step 3: Write the implementation** + +Create `server/src/systems/inventionSystem.ts`: + +```ts +import type { EntityId, Needs, NPCBrain, Stats, StatModifiers } from '@dflike/shared'; +import type { World } from '../ecs/World.js'; +import type { LlmService } from '../llm/llmService.js'; +import type { NarrationService } from '../llm/narrationService.js'; +import type { EventMemoryService } from '../llm/eventMemoryService.js'; +import type { ItemRegistry } from '../industry/itemRegistry.js'; +import type { RecipeRegistry } from '../industry/recipeRegistry.js'; +import type { InventionTimeline } from '../industry/inventionTimeline.js'; +import { getEffectiveStat } from './statHelpers.js'; +import { parseInventionResponse } from '../industry/inventionParser.js'; +import { validateInvention } from '../industry/inventionValidator.js'; +import { registerInvention } from '../industry/inventionRegistrar.js'; +import { industryConfig } from '../config/industryConfig.js'; +import { ENERGY_DECAY_PER_TICK, DAY_NIGHT_RATIO } from '@dflike/shared'; + +export interface InventionSystem { + update(world: World, tick: number): void; +} + +export function createInventionSystem( + llmService: LlmService, + narrationService: NarrationService, + eventMemoryService: EventMemoryService, +): InventionSystem { + // Track in-flight requests to avoid duplicates + const pendingEntities = new Set(); + + return { + update(world: World, tick: number): void { + // Only check on interval ticks + if (tick % industryConfig.inventionCheckInterval !== 0) return; + + // Skip if daily limit reached + if (llmService.isDailyLimitReached()) return; + + const itemRegistry = world.getSingleton('itemRegistry'); + const recipeRegistry = world.getSingleton('recipeRegistry'); + const timeline = world.getSingleton('inventionTimeline'); + if (!itemRegistry || !recipeRegistry || !timeline) return; + + const npcs = world.query('npcBrain'); + + for (const entityId of npcs) { + if (pendingEntities.has(entityId)) continue; + + const inv = world.getComponent>(entityId, 'inventory'); + if (!inv || inv.size === 0) continue; + + const intelligence = getEffectiveStat(world, entityId, 'intelligence'); + const curiosity = getEffectiveStat(world, entityId, 'curiosity'); + + // Calculate eureka probability + const intMult = 1 + (intelligence - 10) * industryConfig.inventionIntelligenceScale; + const curMult = 1 + (curiosity - 10) * industryConfig.inventionCuriosityScale; + const chance = Math.max( + industryConfig.inventionMinChance, + Math.min(industryConfig.inventionMaxChance, industryConfig.inventionBaseChance * intMult * curMult), + ); + + if (Math.random() >= chance) continue; + + // Eureka! Queue LLM call + const name = world.getComponent(entityId, 'name') ?? 'Unknown'; + const allItems = itemRegistry.getAll(); + const materialsList = allItems + .map(i => `- ${i.id} (${i.category}): ${i.name}`) + .join('\n'); + + const existingInventions = allItems + .filter(i => i.source === 'invented') + .map(i => i.name) + .join(', ') || 'none yet'; + + pendingEntities.add(entityId); + + llmService.generate('invention', { + npcName: name, + intelligence: String(intelligence), + curiosity: String(curiosity), + materials: materialsList, + existingInventions, + }).then(response => { + pendingEntities.delete(entityId); + + const parsed = parseInventionResponse(response); + if (!parsed) return; + + const validation = validateInvention(parsed, itemRegistry); + if (!validation.valid) return; + + // Compute current day + const dayTicks = 100 / ENERGY_DECAY_PER_TICK; + const nightTicks = dayTicks / DAY_NIGHT_RATIO; + const cycleTicks = Math.round(dayTicks + nightTicks); + const day = Math.floor(tick / cycleTicks) + 1; + + registerInvention({ + raw: parsed, + itemId: validation.itemId!, + itemRegistry, + recipeRegistry, + timeline, + inventorEntityId: entityId, + inventorName: name, + tick, + day, + }); + + // Emit narration event + narrationService.onEventCreated?.({ + id: Date.now(), + tick, + type: 'invention', + entityIds: [entityId, entityId], + names: [name, name], + outcome: 'positive', + narration: `${name} has invented the ${parsed.name}! "${parsed.description}"`, + isLlmGenerated: true, + }); + + // Record memory event + eventMemoryService.record(entityId, { + type: 'invention', + tick, + detail: `Invented ${parsed.name}: ${parsed.description}`, + }); + }).catch(() => { + pendingEntities.delete(entityId); + }); + } + }, + }; +} +``` + +**Step 4: Run test to verify it passes** + +Run: `npm -w server run test -- --run src/systems/__tests__/inventionSystem.test.ts` +Expected: PASS (all 7 tests) + +**Step 5: Commit** + +```bash +git add server/src/systems/inventionSystem.ts server/src/systems/__tests__/inventionSystem.test.ts +git commit -m "feat(systems): add inventionSystem with LLM-driven eureka moments" +``` + +--- + +### Task 11: Wire into GameLoop + +**Files:** +- Modify: `server/src/game/GameLoop.ts` + +**Step 1: Import and create inventionSystem** + +Add imports: + +```ts +import { createInventionSystem, type InventionSystem } from '../systems/inventionSystem.js'; +import { createInventionTimeline } from '../industry/inventionTimeline.js'; +``` + +In the constructor, after creating `recipeRegistry` singleton: + +```ts +this.world.setSingleton('inventionTimeline', createInventionTimeline()); +``` + +Add field to the class: + +```ts +readonly inventionSystem: InventionSystem; +``` + +In constructor, after `this.thoughtSystem = ...`: + +```ts +this.inventionSystem = createInventionSystem(this.llmService, this.narrationService, this.eventMemoryService); +``` + +**Step 2: Add to update loop** + +In the `update()` method, add after `this.thoughtSystem.update(...)`: + +```ts +this.inventionSystem.update(this.world, this.tick); +``` + +**Step 3: Update superlatives calls** + +Find where `computeSuperlatives` is called (likely in SocketServer.ts or similar) and pass `inventionTimeline` as 3rd argument. + +Search for usage: `computeSuperlatives(` — update the call to include `world.getSingleton('inventionTimeline')`. + +**Step 4: Run all tests** + +Run: `npm -w server run test` +Expected: PASS — all existing tests + new tests + +**Step 5: Commit** + +```bash +git add server/src/game/GameLoop.ts +git commit -m "feat(game): wire inventionSystem into game loop" +``` + +--- + +### Task 12: Pass inventionTimeline to superlatives + +**Files:** +- Modify: wherever `computeSuperlatives` is called (likely `server/src/network/SocketServer.ts` or GameLoop broadcast) + +**Step 1: Find and update the call** + +Search for `computeSuperlatives(` in the codebase. Pass the `inventionTimeline` singleton: + +```ts +const inventionTimeline = gameLoop.world.getSingleton('inventionTimeline'); +computeSuperlatives(gameLoop.world, bondRegistry, inventionTimeline) +``` + +**Step 2: Run tests** + +Run: `npm -w server run test` +Expected: PASS + +**Step 3: Commit** + +```bash +git add +git commit -m "feat(network): pass inventionTimeline to superlatives computation" +``` + +--- + +### Task 13: Full Integration Test + +**Files:** +- Create: `server/src/systems/__tests__/inventionIntegration.test.ts` + +**Step 1: Write integration test** + +```ts +import { describe, it, expect, vi } from 'vitest'; +import { World } from '../../ecs/World.js'; +import { ItemRegistry } from '../../industry/itemRegistry.js'; +import { RecipeRegistry } from '../../industry/recipeRegistry.js'; +import { createInventionTimeline } from '../../industry/inventionTimeline.js'; +import { createInventionSystem } from '../inventionSystem.js'; +import { createEventMemoryService } from '../../llm/eventMemoryService.js'; +import type { LlmService } from '../../llm/llmService.js'; +import type { NarrationService } from '../../llm/narrationService.js'; +import type { Needs, Stats, NPCBrain, StatModifiers } from '@dflike/shared'; + +describe('invention integration', () => { + it('end-to-end: NPC invents item that appears in future prompts', async () => { + const world = new World(); + const itemRegistry = ItemRegistry.createDefault(); + const recipeRegistry = RecipeRegistry.createDefault(); + const timeline = createInventionTimeline(); + world.setSingleton('itemRegistry', itemRegistry); + world.setSingleton('recipeRegistry', recipeRegistry); + world.setSingleton('inventionTimeline', timeline); + + const e = world.createEntity(); + world.addComponent(e, 'needs', { hunger: 80, energy: 80, productivity: 20 }); + world.addComponent(e, 'npcBrain', { currentGoal: 'gather', goalQueue: [] }); + world.addComponent(e, 'stats', { + strength: 10, dexterity: 10, constitution: 10, + intelligence: 18, perception: 10, + sociability: 10, courage: 10, curiosity: 18, + empathy: 10, temperament: 10, + }); + world.addComponent(e, 'statModifiers', { modifiers: [] }); + world.addComponent>(e, 'inventory', new Map([['log', 5], ['stone', 3]])); + world.addComponent(e, 'name', 'Gwendolyn'); + + const llm: LlmService = { + generate: vi.fn().mockResolvedValueOnce(JSON.stringify({ + name: 'rope', + description: 'Twisted plant fibers', + category: 'material', + inputs: [{ itemId: 'log', quantity: 2 }], + })), + queueDepth: () => 0, + clear: () => {}, + isDailyLimitReached: () => false, + }; + + const narrationEvents: any[] = []; + const narration: NarrationService = { + recordInteraction: vi.fn() as any, + getRecentEvents: () => [], + getEventsForEntity: () => [], + onEventUpdated: null, + onEventCreated: (event) => { narrationEvents.push(event); }, + }; + + const eventMemory = createEventMemoryService(); + const system = createInventionSystem(llm, narration, eventMemory); + + vi.spyOn(Math, 'random').mockReturnValue(0); + system.update(world, 100); + vi.restoreAllMocks(); + + // Wait for async LLM + await vi.waitFor(() => { + expect(itemRegistry.get('rope')).toBeDefined(); + }); + + // Item registered + expect(itemRegistry.get('rope')!.inventedBy!.name).toBe('Gwendolyn'); + + // Recipe registered + expect(recipeRegistry.get('craft_rope')).toBeDefined(); + + // Timeline recorded + expect(timeline.countByEntity(e)).toBe(1); + + // Narration emitted + expect(narrationEvents).toHaveLength(1); + expect(narrationEvents[0].narration).toContain('rope'); + + // Memory event recorded + const memories = eventMemory.getEvents(e); + expect(memories.some(m => m.type === 'invention')).toBe(true); + + // Rope now appears in materials for next invention + const secondCall = vi.fn().mockResolvedValue(null); + (llm as any).generate = secondCall; + + vi.spyOn(Math, 'random').mockReturnValue(0); + system.update(world, 200); + vi.restoreAllMocks(); + + expect(secondCall).toHaveBeenCalled(); + const materialsArg = secondCall.mock.calls[0][1].materials; + expect(materialsArg).toContain('rope'); + }); +}); +``` + +**Step 2: Run the test** + +Run: `npm -w server run test -- --run src/systems/__tests__/inventionIntegration.test.ts` +Expected: PASS + +**Step 3: Commit** + +```bash +git add server/src/systems/__tests__/inventionIntegration.test.ts +git commit -m "test(systems): add invention integration test for end-to-end lifecycle" +``` + +--- + +### Task 14: Run Full Test Suite + +**Step 1: Run all tests** + +Run: `npm -w server run test` +Expected: ALL PASS (existing 338 + new tests) + +**Step 2: Final commit if any fixups needed** + +If any tests needed fixing, commit fixes here. + +--- + +## Summary of Files + +| File | Action | Purpose | +|------|--------|---------| +| `server/src/config/industryConfig.ts` | Modify | Add invention tuning values | +| `server/src/industry/inventionTimeline.ts` | Create | InventionTimeline singleton | +| `server/src/industry/inventionValidator.ts` | Create | Validate LLM invention output | +| `server/src/industry/inventionParser.ts` | Create | Parse LLM JSON response | +| `server/src/industry/inventionRegistrar.ts` | Create | Register item + recipe from valid invention | +| `server/src/llm/templates.ts` | Modify | Add invention prompt template | +| `shared/src/types.ts` | Modify | Add 'invention' MemoryEventType, mostInventive superlative | +| `shared/src/narration.ts` | Modify | Add 'invention' narration type | +| `server/src/systems/inventionSystem.ts` | Create | Core invention system (periodic check, LLM call, validation, registration) | +| `server/src/systems/superlativesComputer.ts` | Modify | Add mostInventive calculation | +| `server/src/game/GameLoop.ts` | Modify | Wire inventionSystem + inventionTimeline | +| Network layer file | Modify | Pass inventionTimeline to superlatives |