diff --git a/docs/plans/2026-03-09-npc-desire-system-implementation.md b/docs/plans/2026-03-09-npc-desire-system-implementation.md new file mode 100644 index 0000000..3c6c56a --- /dev/null +++ b/docs/plans/2026-03-09-npc-desire-system-implementation.md @@ -0,0 +1,1635 @@ +# NPC Desire System Implementation Plan + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. + +**Goal:** Add a motivation layer where NPCs have 0-3 personal desires that drive crafting priorities, influence invention, and create emergent stories. + +**Architecture:** Desires are a plain data component on NPCs, consumed by existing systems. Two new systems handle generation (LLM, slow timer + events) and fulfillment checking (deterministic, every ~100 ticks). industrySystem scores recipes by desire match. All LLM templates reframed from "medieval fantasy village" to "frontier settlement." + +**Tech Stack:** TypeScript, vitest, better-sqlite3, LLM (OpenRouter), ECS pattern (plain data components, pure function systems) + +**Design doc:** `docs/plans/2026-03-09-npc-desire-system-design.md` + +--- + +### Task 1: Add Desire Types to Shared + +**Files:** +- Modify: `shared/src/types.ts` + +**Step 1: Add desire types to shared/src/types.ts** + +Add after the `MemoryEventType` union (line 16), add `'desire_fulfilled'` to the union: + +```typescript +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' + | 'desire_fulfilled' | 'desire_added'; +``` + +Add after the `StockpileLogEntry` interface (after line 236): + +```typescript +// Desire system types +export type DesireCategory = + | 'material' // wants a specific item/tool + | 'social' // wants relationships, bonds + | 'shelter' // wants housing/personal space + | 'comfort' // wants food security, rest, quality of life + | 'community' // wants to improve the settlement for everyone + | 'creative'; // wants to make/invent something novel + +export type FulfillmentCriteria = + | { type: 'own_item'; itemId: string; quantity: number } + | { type: 'own_item_category'; category: string; quantity: number } + | { type: 'structure_exists'; structureType: string } + | { type: 'building_exists'; buildingType: string } + | { type: 'relationship_tier'; tier: string; count: number } + | { type: 'recipe_exists'; tag: string } + | { type: 'custom'; check: string }; + +export interface Desire { + id: string; + description: string; + category: DesireCategory; + fulfillment: FulfillmentCriteria; + priority: number; // 0-1 + source: 'spawn' | 'event' | 'periodic'; + sourceDetail?: string; + createdAtTick: number; + cooldownTicks?: number; +} +``` + +Add `desires` to `EntityState` (after `inventory` field, ~line 147): + +```typescript + desires?: { description: string; category: DesireCategory; }[]; +``` + +**Step 2: Rebuild shared types** + +Run: `npx -w shared tsc` +Expected: Clean compile, no errors + +**Step 3: Commit** + +```bash +git add shared/src/types.ts +git commit -m "feat: add Desire types to shared" +``` + +--- + +### Task 2: Add Desire Config + +**Files:** +- Create: `server/src/config/desireConfig.ts` + +**Step 1: Create the config file** + +```typescript +export const desireConfig = { + maxDesires: 3, + + // Fulfillment checking + fulfillmentCheckInterval: 100, // ticks between checks + + // Periodic generation + periodicCheckInterval: 3333, // ~1 game-day worth of ticks (100/0.03) + periodicBaseChance: 0.3, // 30% chance per check if slot available + periodicStatScale: 0.05, // per point of (curiosity + intelligence - 20) + + // Cooldown after fulfillment before slot reopens + cooldownBaseTicks: 500, + cooldownCuriosityScale: 0.05, // per curiosity point from 10, reduces cooldown + + // Trigger sensitivity (base chance that an inflection event creates a desire) + triggerBondChange: 0.6, + triggerNewInvention: 0.4, + triggerCrisisRecovery: 0.5, + triggerObservation: 0.3, + triggerDesireFulfilled: 0.5, + + // Stat composite scaling for triggers + triggerStatScale: 0.05, // per composite point from 20 +} as const; +``` + +**Step 2: Commit** + +```bash +git add server/src/config/desireConfig.ts +git commit -m "feat: add desireConfig with tuning values" +``` + +--- + +### Task 3: Desire Fulfillment System (with tests) + +**Files:** +- Create: `server/src/systems/desireFulfillmentSystem.ts` +- Create: `server/src/systems/__tests__/desireFulfillmentSystem.test.ts` + +**Step 1: Write the failing tests** + +```typescript +import { describe, it, expect, vi } from 'vitest'; +import { World } from '../../ecs/World.js'; +import { desireFulfillmentSystem } from '../desireFulfillmentSystem.js'; +import type { Desire, Needs, Stats, NPCBrain } from '@dflike/shared'; +import { RecipeRegistry } from '../../industry/recipeRegistry.js'; + +function createTestWorld(): World { + const world = new World(); + const recipeRegistry = RecipeRegistry.createDefault(); + world.setSingleton('recipeRegistry', recipeRegistry); + return world; +} + +function createNPCWithDesire(world: World, desire: Desire, inv?: Map): number { + const entity = world.createEntity(); + world.addComponent(entity, 'desires', [desire]); + world.addComponent(entity, 'inventory', inv ?? new Map()); + world.addComponent(entity, 'stats', { + strength: 10, dexterity: 10, constitution: 10, intelligence: 10, perception: 10, + sociability: 10, courage: 10, curiosity: 10, empathy: 10, temperament: 10, + }); + world.addComponent(entity, 'needs', { hunger: 80, energy: 80, productivity: 50 }); + world.addComponent(entity, 'npcBrain', { currentGoal: null, goalQueue: [] }); + world.addComponent(entity, 'name', 'TestNPC'); + return entity; +} + +describe('desireFulfillmentSystem', () => { + it('does nothing when not on check interval', () => { + const world = createTestWorld(); + const inv = new Map([['wooden_axe', 1]]); + const desire: Desire = { + id: 'test1', description: 'wants an axe', category: 'material', + fulfillment: { type: 'own_item', itemId: 'wooden_axe', quantity: 1 }, + priority: 0.5, source: 'spawn', createdAtTick: 0, + }; + const entity = createNPCWithDesire(world, desire, inv); + desireFulfillmentSystem(world, 50); // not on interval + expect(world.getComponent(entity, 'desires')).toHaveLength(1); + }); + + it('removes desire when own_item fulfilled', () => { + const world = createTestWorld(); + const inv = new Map([['wooden_axe', 1]]); + const desire: Desire = { + id: 'test1', description: 'wants an axe', category: 'material', + fulfillment: { type: 'own_item', itemId: 'wooden_axe', quantity: 1 }, + priority: 0.5, source: 'spawn', createdAtTick: 0, + }; + const entity = createNPCWithDesire(world, desire, inv); + desireFulfillmentSystem(world, 100); + expect(world.getComponent(entity, 'desires')).toHaveLength(0); + }); + + it('keeps desire when own_item not fulfilled', () => { + const world = createTestWorld(); + const inv = new Map([['log', 2]]); + const desire: Desire = { + id: 'test1', description: 'wants an axe', category: 'material', + fulfillment: { type: 'own_item', itemId: 'wooden_axe', quantity: 1 }, + priority: 0.5, source: 'spawn', createdAtTick: 0, + }; + const entity = createNPCWithDesire(world, desire, inv); + desireFulfillmentSystem(world, 100); + expect(world.getComponent(entity, 'desires')).toHaveLength(1); + }); + + it('fulfills structure_exists when matching structure found', () => { + const world = createTestWorld(); + const desire: Desire = { + id: 'test1', description: 'wants a stockpile', category: 'community', + fulfillment: { type: 'structure_exists', structureType: 'stockpile' }, + priority: 0.5, source: 'spawn', createdAtTick: 0, + }; + const entity = createNPCWithDesire(world, desire); + // Create a completed structure + const structure = world.createEntity(); + world.addComponent(structure, 'structure', { + type: 'stockpile', subtype: 'stockpile', buildProgress: 1, inventory: new Map(), + }); + desireFulfillmentSystem(world, 100); + expect(world.getComponent(entity, 'desires')).toHaveLength(0); + }); + + it('does not fulfill structure_exists for incomplete structures', () => { + const world = createTestWorld(); + const desire: Desire = { + id: 'test1', description: 'wants a stockpile', category: 'community', + fulfillment: { type: 'structure_exists', structureType: 'stockpile' }, + priority: 0.5, source: 'spawn', createdAtTick: 0, + }; + const entity = createNPCWithDesire(world, desire); + const structure = world.createEntity(); + world.addComponent(structure, 'structure', { + type: 'stockpile', subtype: 'stockpile', buildProgress: 0.5, inventory: new Map(), + }); + desireFulfillmentSystem(world, 100); + expect(world.getComponent(entity, 'desires')).toHaveLength(1); + }); + + it('fulfills relationship_tier criteria', () => { + const world = createTestWorld(); + const desire: Desire = { + id: 'test1', description: 'wants a friend', category: 'social', + fulfillment: { type: 'relationship_tier', tier: 'Friend', count: 1 }, + priority: 0.5, source: 'spawn', createdAtTick: 0, + }; + const entity = createNPCWithDesire(world, desire); + // Add a friend-level relationship (value >= 30) + const other = world.createEntity(); + const rels = new Map([[other, { value: 35, interactions: 5, lastInteractionTick: 0, status: 'active' as const }]]); + world.addComponent(entity, 'relationships', rels); + desireFulfillmentSystem(world, 100); + expect(world.getComponent(entity, 'desires')).toHaveLength(0); + }); + + it('building_exists always returns false (deferred)', () => { + const world = createTestWorld(); + const desire: Desire = { + id: 'test1', description: 'wants a house', category: 'shelter', + fulfillment: { type: 'building_exists', buildingType: 'house' }, + priority: 0.5, source: 'spawn', createdAtTick: 0, + }; + const entity = createNPCWithDesire(world, desire); + desireFulfillmentSystem(world, 100); + expect(world.getComponent(entity, 'desires')).toHaveLength(1); + }); + + it('fulfills recipe_exists when matching recipe found', () => { + const world = createTestWorld(); + const desire: Desire = { + id: 'test1', description: 'wants axe recipe', category: 'material', + fulfillment: { type: 'recipe_exists', tag: 'wooden_axe' }, + priority: 0.5, source: 'spawn', createdAtTick: 0, + }; + const entity = createNPCWithDesire(world, desire); + desireFulfillmentSystem(world, 100); + // wooden_axe recipe exists in default registry + expect(world.getComponent(entity, 'desires')).toHaveLength(0); + }); +}); +``` + +**Step 2: Run tests to verify they fail** + +Run: `npm -w server run test -- --run src/systems/__tests__/desireFulfillmentSystem.test.ts` +Expected: FAIL — module not found + +**Step 3: Write the implementation** + +```typescript +import type { World } from '../ecs/World.js'; +import type { Desire, Relationships } from '@dflike/shared'; +import type { RecipeRegistry } from '../industry/recipeRegistry.js'; +import { desireConfig } from '../config/desireConfig.js'; +import { classify } from './relationshipHelpers.js'; + +function checkFulfillment( + desire: Desire, + inventory: Map, + world: World, + entityId: number, +): boolean { + const f = desire.fulfillment; + switch (f.type) { + case 'own_item': + return (inventory.get(f.itemId) ?? 0) >= f.quantity; + + case 'own_item_category': { + // Count items matching category from item registry + const itemRegistry = world.getSingleton('itemRegistry'); + if (!itemRegistry) return false; + let count = 0; + for (const [itemId, qty] of inventory) { + const def = itemRegistry.get(itemId); + if (def && def.category === f.category) count += qty; + } + return count >= f.quantity; + } + + case 'structure_exists': { + const structures = world.queryAll('structure'); + return structures.some(sid => { + const s = world.getComponent<{ type: string; buildProgress: number }>(sid, 'structure'); + return s && s.type === f.structureType && s.buildProgress >= 1; + }); + } + + case 'building_exists': + // Deferred until building system exists + return false; + + case 'relationship_tier': { + const rels = world.getComponent(entityId, 'relationships'); + if (!rels) return false; + let count = 0; + for (const [, rel] of rels) { + if (rel.status === 'active' && classify(rel.value) === f.tier) count++; + } + return count >= f.count; + } + + case 'recipe_exists': { + const recipeRegistry = world.getSingleton('recipeRegistry'); + if (!recipeRegistry) return false; + return recipeRegistry.getAll().some(r => r.outputItemId === f.tag || r.id.includes(f.tag)); + } + + case 'custom': + return false; // Future LLM re-evaluation + } +} + +export function desireFulfillmentSystem(world: World, tick: number): void { + if (tick % desireConfig.fulfillmentCheckInterval !== 0) return; + + const npcs = world.queryAll('desires'); + for (const entityId of npcs) { + const desires = world.getComponent(entityId, 'desires'); + const inventory = world.getComponent>(entityId, 'inventory') ?? new Map(); + if (!desires || desires.length === 0) continue; + + const remaining: Desire[] = []; + for (const desire of desires) { + if (checkFulfillment(desire, inventory, world, entityId)) { + // Desire fulfilled — will be picked up by event system for memory recording + // For now just remove it + } else { + remaining.push(desire); + } + } + + if (remaining.length !== desires.length) { + world.addComponent(entityId, 'desires', remaining); + } + } +} +``` + +**Step 4: Run tests to verify they pass** + +Run: `npm -w server run test -- --run src/systems/__tests__/desireFulfillmentSystem.test.ts` +Expected: All PASS + +**Step 5: Commit** + +```bash +git add server/src/systems/desireFulfillmentSystem.ts server/src/systems/__tests__/desireFulfillmentSystem.test.ts +git commit -m "feat: add desireFulfillmentSystem with fulfillment checking" +``` + +--- + +### Task 4: Reframe LLM Templates + +**Files:** +- Modify: `server/src/llm/templates.ts` + +**Step 1: Update all template system prompts** + +Replace the entire `templates` object. Change every "medieval fantasy village simulation" reference to frontier settlement framing: + +**backstory template** — replaced entirely in Task 5 (combined backstory+desires). For now, update the framing: + +```typescript +backstory: { + name: 'backstory', + systemPrompt: + 'You narrate for a simulation of settlers founding a new community in untamed wilderness. ' + + 'There is no existing civilization — no shops, guilds, or infrastructure. ' + + 'Write brief, evocative settler backstories. Keep responses to 1-2 sentences. ' + + 'Do not reference professions or institutions that do not exist. Ground details in personality.', + userPrompt: + 'Generate a short backstory for a settler named {{npcName}}.\n' + + 'Their stats: {{stats}}\n' + + 'The backstory should reflect their personality stats. ' + + 'High sociability means outgoing, low means reclusive. ' + + 'High courage means bold, low means timid. ' + + 'High empathy means caring, low means self-focused. ' + + 'High temperament means volatile, low means calm.', +}, +``` + +**socialNarration template:** + +```typescript +socialNarration: { + name: 'socialNarration', + systemPrompt: + 'You narrate for a simulation of settlers in a fledgling wilderness community. ' + + 'Describe NPC social interactions in 1 sentence. ' + + 'Be specific and grounded. No purple prose.', + userPrompt: + '{{npc1Name}} ({{npc1Personality}}) had a {{outcome}} interaction with ' + + '{{npc2Name}} ({{npc2Personality}}). ' + + 'They are currently {{relationship}} (sentiment: {{sentiment}}/100).\n' + + 'Their recent shared history:\n{{recentHistory}}\n' + + 'Describe what happened in one vivid sentence.', +}, +``` + +**batchedThoughts template:** + +```typescript +batchedThoughts: { + name: 'batchedThoughts', + systemPrompt: + 'You voice the inner thoughts of settlers in a fledgling wilderness community. ' + + 'Write one brief thought per NPC in first person (1 sentence each). ' + + 'Reflect their personality and current situation. Be natural, not dramatic. ' + + 'Respond with numbered lines matching the input.', + userPrompt: + 'Generate a brief inner thought for each settler:\n\n{{npcList}}', +}, +``` + +**invention template:** + +```typescript +invention: { + name: 'invention', + systemPrompt: + 'You are an inventor in a fledgling wilderness settlement. ' + + 'Given available materials and a settler\'s stats, consider 3 possible inventions, ' + + 'then select the one that best fits the settler\'s personality. ' + + 'Respond ONLY with a valid JSON object. No explanation, no markdown, just JSON.', + userPrompt: + '{{npcName}} is having a creative moment.\n\n' + + 'Stats (each ranges 3-18, 10 is average):\n' + + 'Strength:{{strength}} Dexterity:{{dexterity}} Constitution:{{constitution}} Intelligence:{{intelligence}}\n' + + 'Perception:{{perception}} Sociability:{{sociability}} Courage:{{courage}} Curiosity:{{curiosity}}\n' + + 'Empathy:{{empathy}} Temperament:{{temperament}}\n\n' + + '{{desiresSection}}' + + 'Available materials:\n{{materials}}\n\n' + + 'Known items (do not reinvent):\n{{knownItems}}\n\n' + + 'Consider 3 possible inventions using 2-3 existing materials, then pick\n' + + 'the one that best matches this settler\'s personality and stats.\n' + + 'Use Title Case for the item name.\n\n' + + 'Respond with JSON:\n' + + '{"name": "Item Name", "description": "brief flavor text", ' + + '"reasoning": "why this suits the settler", ' + + '"category": "resource|tool|material|structure", ' + + '"inputs": [{"itemId": "existing_item_id", "quantity": N}], ' + + '"workshopType": null, "toolRequired": null}', +}, +``` + +**Step 2: Run existing tests to verify nothing broke** + +Run: `npm -w server run test -- --run` +Expected: All existing tests pass (template changes are string-only, tests mock LLM) + +**Step 3: Commit** + +```bash +git add server/src/llm/templates.ts +git commit -m "refactor: reframe LLM templates from medieval to frontier settlement" +``` + +--- + +### Task 5: Combined Backstory + Desires Generator + +**Files:** +- Modify: `server/src/llm/backstoryGenerator.ts` +- Modify: `server/src/llm/templates.ts` (add new template) +- Modify: `server/src/llm/__tests__/backstoryGenerator.test.ts` + +**Step 1: Add `backstoryAndDesires` template to templates.ts** + +Add a new entry to the templates object: + +```typescript +backstoryAndDesires: { + name: 'backstoryAndDesires', + systemPrompt: + 'You narrate for a simulation of settlers founding a new community in untamed wilderness. ' + + 'There is no existing civilization — no shops, guilds, or infrastructure. ' + + 'The world is raw: trees, stone, water, wild food. Everything must be built from scratch.\n\n' + + 'Write a brief backstory (1-2 sentences) and 1-2 initial desires for this settler. ' + + 'The backstory should reflect their personality without referencing professions or institutions that do not exist. ' + + 'Desires should be grounded in what is achievable or aspirational given the current world state.\n\n' + + 'Respond ONLY with a valid JSON object. No explanation, no markdown, just JSON.', + userPrompt: + 'New settler: {{npcName}}\n' + + 'Stats (each ranges 3-18, 10 is average): {{stats}}\n\n' + + 'Current world state:\n' + + '- Available resources: {{resourceTypes}}\n' + + '- Known recipes: {{recipeList}}\n' + + '- Existing structures: {{structureList}}\n\n' + + 'Respond with JSON:\n' + + '{"backstory": "1-2 sentence backstory", "desires": [' + + '{"description": "human-readable desire", ' + + '"category": "material|social|shelter|comfort|community|creative", ' + + '"fulfillment": {"type": "own_item|structure_exists|building_exists|relationship_tier|recipe_exists|custom", ...criteria}, ' + + '"priority": 0.0-1.0, ' + + '"reasoning": "why this fits the settler"}]}', +}, +``` + +**Step 2: Add desire generation template for event/periodic triggers** + +```typescript +desireGeneration: { + name: 'desireGeneration', + systemPrompt: + 'You narrate for a simulation of settlers founding a new community in untamed wilderness. ' + + 'There is no existing civilization — no shops, guilds, or infrastructure. ' + + 'Generate a new personal desire for this settler based on their personality and recent experiences.\n\n' + + 'Respond ONLY with a valid JSON object. No explanation, no markdown, just JSON.', + userPrompt: + 'Settler: {{npcName}}\n' + + 'Stats: {{stats}}\n' + + 'Backstory: {{backstory}}\n' + + 'Current desires: {{currentDesires}}\n' + + 'Recent memories: {{recentEvents}}\n' + + 'Trigger: {{trigger}}\n\n' + + 'World state:\n' + + '- Available resources: {{resourceTypes}}\n' + + '- Known recipes: {{recipeList}}\n' + + '- Existing structures: {{structureList}}\n' + + '- Recent inventions: {{recentInventions}}\n\n' + + 'Generate ONE new desire that does not duplicate existing desires.\n' + + 'Respond with JSON:\n' + + '{"description": "human-readable desire", ' + + '"category": "material|social|shelter|comfort|community|creative", ' + + '"fulfillment": {"type": "own_item|own_item_category|structure_exists|building_exists|relationship_tier|recipe_exists|custom", ...criteria}, ' + + '"priority": 0.0-1.0, ' + + '"reasoning": "why this fits the settler and trigger"}', +}, +``` + +**Step 3: Rewrite backstoryGenerator.ts to support combined generation** + +```typescript +import type { Stats, Desire, FulfillmentCriteria, DesireCategory } from '@dflike/shared'; +import type { World } from '../ecs/World.js'; +import type { LlmService } from './llmService.js'; +import type { ItemRegistry } from '../industry/itemRegistry.js'; +import type { RecipeRegistry } from '../industry/recipeRegistry.js'; + +const STAT_KEYS: (keyof Stats)[] = [ + 'strength', 'dexterity', 'constitution', 'intelligence', 'perception', + 'sociability', 'courage', 'curiosity', 'empathy', 'temperament', +]; + +export function formatStatsForPrompt(stats: Stats): string { + return STAT_KEYS.map(k => `${k}:${Math.floor(stats[k])}`).join(', '); +} + +function getWorldContext(world: World): { resourceTypes: string; recipeList: string; structureList: string } { + const itemRegistry = world.getSingleton('itemRegistry'); + const recipeRegistry = world.getSingleton('recipeRegistry'); + + const resourceTypes = itemRegistry + ? itemRegistry.getByCategory('resource').map(i => i.name).join(', ') + : 'logs, stone, water'; + + const recipeList = recipeRegistry + ? recipeRegistry.getAll().map(r => `${r.id} (${r.outputItemId})`).join(', ') + : 'none'; + + const structures = world.queryAll('structure'); + const structureTypes = new Set(); + for (const sid of structures) { + const s = world.getComponent<{ type: string; buildProgress: number }>(sid, 'structure'); + if (s && s.buildProgress >= 1) structureTypes.add(s.type); + } + const structureList = structureTypes.size > 0 ? [...structureTypes].join(', ') : 'none'; + + return { resourceTypes, recipeList, structureList }; +} + +const VALID_CATEGORIES: DesireCategory[] = ['material', 'social', 'shelter', 'comfort', 'community', 'creative']; +const VALID_FULFILLMENT_TYPES = ['own_item', 'own_item_category', 'structure_exists', 'building_exists', 'relationship_tier', 'recipe_exists', 'custom']; + +function validateDesire(raw: unknown, tick: number): Desire | null { + if (!raw || typeof raw !== 'object') return null; + const d = raw as Record; + if (typeof d.description !== 'string' || !d.description) return null; + if (!VALID_CATEGORIES.includes(d.category as DesireCategory)) return null; + + const f = d.fulfillment as Record | undefined; + if (!f || typeof f !== 'object' || !VALID_FULFILLMENT_TYPES.includes(f.type as string)) return null; + + const priority = typeof d.priority === 'number' ? Math.max(0, Math.min(1, d.priority)) : 0.5; + + return { + id: `desire_${tick}_${Math.random().toString(36).slice(2, 8)}`, + description: d.description, + category: d.category as DesireCategory, + fulfillment: d.fulfillment as FulfillmentCriteria, + priority, + source: 'spawn', + createdAtTick: tick, + }; +} + +export async function generateBackstoryAndDesires( + world: World, + entityId: number, + llmService: LlmService, + tick: number, +): Promise { + const name = world.getComponent(entityId, 'name'); + const stats = world.getComponent(entityId, 'stats'); + if (!name || !stats) return; + + const ctx = getWorldContext(world); + + const result = await llmService.generate('backstoryAndDesires', { + npcName: name, + stats: formatStatsForPrompt(stats), + resourceTypes: ctx.resourceTypes, + recipeList: ctx.recipeList, + structureList: ctx.structureList, + }); + + if (!result) return; + + try { + const parsed = JSON.parse(result); + + if (typeof parsed.backstory === 'string' && parsed.backstory) { + world.addComponent(entityId, 'backstory', parsed.backstory); + } + + if (Array.isArray(parsed.desires)) { + const desires: Desire[] = []; + for (const raw of parsed.desires.slice(0, 2)) { + const desire = validateDesire(raw, tick); + if (desire) desires.push(desire); + } + if (desires.length > 0) { + const existing = world.getComponent(entityId, 'desires') ?? []; + world.addComponent(entityId, 'desires', [...existing, ...desires]); + } + } + } catch { + // JSON parse failed — try setting raw result as backstory only + if (result.length < 500) { + world.addComponent(entityId, 'backstory', result); + } + } +} + +// Keep legacy function for backward compatibility during transition +export async function generateBackstory( + world: World, + entityId: number, + llmService: LlmService, +): Promise { + const name = world.getComponent(entityId, 'name'); + const stats = world.getComponent(entityId, 'stats'); + if (!name || !stats) return; + + const result = await llmService.generate('backstory', { + npcName: name, + stats: formatStatsForPrompt(stats), + }); + + if (result) { + world.addComponent(entityId, 'backstory', result); + } +} + +export { getWorldContext, validateDesire }; +``` + +**Step 4: Update backstory tests** + +Add tests for the new `generateBackstoryAndDesires` function and `validateDesire`: + +```typescript +import { describe, it, expect, vi } from 'vitest'; +import { World } from '../../ecs/World.js'; +import { + formatStatsForPrompt, + generateBackstory, + generateBackstoryAndDesires, + validateDesire, +} from '../backstoryGenerator.js'; +import type { Stats, Desire } from '@dflike/shared'; +import { ItemRegistry } from '../../industry/itemRegistry.js'; +import { RecipeRegistry } from '../../industry/recipeRegistry.js'; + +function createMockLlm(response: string | null = null) { + return { + generate: vi.fn().mockResolvedValue(response), + queueDepth: () => 0, + clear: () => {}, + activeModel: () => 'test-model', + usageStats: () => ({}), + usageSummary: () => '', + tokenUsage: () => ({ record() {}, getRecent() { return []; }, lastSummary() { return ''; } }), + }; +} + +function createWorldWithSingletons(): World { + const world = new World(); + world.setSingleton('itemRegistry', ItemRegistry.createDefault()); + world.setSingleton('recipeRegistry', RecipeRegistry.createDefault()); + return world; +} + +const testStats: Stats = { + strength: 15, dexterity: 12, constitution: 14, intelligence: 8, perception: 10, + sociability: 6, courage: 16, curiosity: 11, empathy: 7, temperament: 13, +}; + +describe('formatStatsForPrompt', () => { + it('formats stats as comma-separated key:value pairs', () => { + const result = formatStatsForPrompt(testStats); + expect(result).toBe( + 'strength:15, dexterity:12, constitution:14, intelligence:8, perception:10, ' + + 'sociability:6, courage:16, curiosity:11, empathy:7, temperament:13', + ); + }); +}); + +describe('validateDesire', () => { + it('returns valid desire from well-formed input', () => { + const raw = { + description: 'wants an axe', + category: 'material', + fulfillment: { type: 'own_item', itemId: 'wooden_axe', quantity: 1 }, + priority: 0.7, + }; + const result = validateDesire(raw, 100); + expect(result).not.toBeNull(); + expect(result!.description).toBe('wants an axe'); + expect(result!.category).toBe('material'); + expect(result!.priority).toBe(0.7); + expect(result!.source).toBe('spawn'); + }); + + it('rejects invalid category', () => { + const raw = { + description: 'test', category: 'invalid', + fulfillment: { type: 'own_item', itemId: 'log', quantity: 1 }, + }; + expect(validateDesire(raw, 0)).toBeNull(); + }); + + it('rejects missing fulfillment', () => { + const raw = { description: 'test', category: 'material' }; + expect(validateDesire(raw, 0)).toBeNull(); + }); + + it('clamps priority to 0-1', () => { + const raw = { + description: 'test', category: 'material', + fulfillment: { type: 'own_item', itemId: 'log', quantity: 1 }, + priority: 5.0, + }; + expect(validateDesire(raw, 0)!.priority).toBe(1); + }); +}); + +describe('generateBackstoryAndDesires', () => { + it('sets backstory and desires from valid JSON response', async () => { + const response = JSON.stringify({ + backstory: 'A quiet soul drawn to the frontier.', + desires: [{ + description: 'wants to build shelter', + category: 'shelter', + fulfillment: { type: 'building_exists', buildingType: 'shelter' }, + priority: 0.8, + }], + }); + const llm = createMockLlm(response); + const world = createWorldWithSingletons(); + const entity = world.createEntity(); + world.addComponent(entity, 'name', 'Brynn'); + world.addComponent(entity, 'backstory', ''); + world.addComponent(entity, 'stats', testStats); + world.addComponent(entity, 'desires', []); + + await generateBackstoryAndDesires(world, entity, llm as any, 100); + + expect(world.getComponent(entity, 'backstory')).toBe('A quiet soul drawn to the frontier.'); + const desires = world.getComponent(entity, 'desires')!; + expect(desires).toHaveLength(1); + expect(desires[0].description).toBe('wants to build shelter'); + expect(desires[0].category).toBe('shelter'); + }); + + it('falls back to raw backstory on invalid JSON', async () => { + const llm = createMockLlm('Just a simple backstory sentence.'); + const world = createWorldWithSingletons(); + const entity = world.createEntity(); + world.addComponent(entity, 'name', 'Brynn'); + world.addComponent(entity, 'backstory', ''); + world.addComponent(entity, 'stats', testStats); + + await generateBackstoryAndDesires(world, entity, llm as any, 100); + + expect(world.getComponent(entity, 'backstory')).toBe('Just a simple backstory sentence.'); + }); + + it('does nothing when LLM returns null', async () => { + const llm = createMockLlm(null); + const world = createWorldWithSingletons(); + const entity = world.createEntity(); + world.addComponent(entity, 'name', 'Brynn'); + world.addComponent(entity, 'backstory', ''); + world.addComponent(entity, 'stats', testStats); + + await generateBackstoryAndDesires(world, entity, llm as any, 100); + + expect(world.getComponent(entity, 'backstory')).toBe(''); + }); + + it('passes world context to LLM template', async () => { + const llm = createMockLlm(null); + const world = createWorldWithSingletons(); + const entity = world.createEntity(); + world.addComponent(entity, 'name', 'Brynn'); + world.addComponent(entity, 'backstory', ''); + world.addComponent(entity, 'stats', testStats); + + await generateBackstoryAndDesires(world, entity, llm as any, 100); + + expect(llm.generate).toHaveBeenCalledWith('backstoryAndDesires', expect.objectContaining({ + npcName: 'Brynn', + resourceTypes: expect.any(String), + recipeList: expect.any(String), + structureList: expect.any(String), + })); + }); +}); + +describe('generateBackstory (legacy)', () => { + it('sets backstory from LLM response', async () => { + const llm = createMockLlm('A brave warrior from the north.'); + const world = new World(); + const entity = world.createEntity(); + world.addComponent(entity, 'name', 'Brynn'); + world.addComponent(entity, 'backstory', ''); + world.addComponent(entity, 'stats', testStats); + + await generateBackstory(world, entity, llm as any); + + expect(world.getComponent(entity, 'backstory')).toBe('A brave warrior from the north.'); + }); +}); +``` + +**Step 5: Run tests** + +Run: `npm -w server run test -- --run src/llm/__tests__/backstoryGenerator.test.ts` +Expected: All PASS + +**Step 6: Commit** + +```bash +git add server/src/llm/backstoryGenerator.ts server/src/llm/templates.ts server/src/llm/__tests__/backstoryGenerator.test.ts +git commit -m "feat: combined backstory+desires generator with world context" +``` + +--- + +### Task 6: Desire Generator System + +**Files:** +- Create: `server/src/systems/desireGeneratorSystem.ts` +- Create: `server/src/systems/__tests__/desireGeneratorSystem.test.ts` + +**Step 1: Write the failing tests** + +```typescript +import { describe, it, expect, vi } from 'vitest'; +import { World } from '../../ecs/World.js'; +import { createDesireGeneratorSystem } from '../desireGeneratorSystem.js'; +import type { Desire, Stats, NPCBrain, Needs } from '@dflike/shared'; +import { ItemRegistry } from '../../industry/itemRegistry.js'; +import { RecipeRegistry } from '../../industry/recipeRegistry.js'; + +function createMockLlm(response: string | null = null) { + return { + generate: vi.fn().mockResolvedValue(response), + queueDepth: () => 0, + clear: () => {}, + activeModel: () => 'test-model', + usageStats: () => ({}), + usageSummary: () => '', + tokenUsage: () => ({ record() {}, getRecent() { return []; }, lastSummary() { return ''; } }), + }; +} + +function setupWorld(): World { + const world = new World(); + world.setSingleton('itemRegistry', ItemRegistry.createDefault()); + world.setSingleton('recipeRegistry', RecipeRegistry.createDefault()); + return world; +} + +function addNPC(world: World, opts?: { + desires?: Desire[]; + curiosity?: number; + intelligence?: number; +}): number { + const entity = world.createEntity(); + world.addComponent(entity, 'name', 'TestNPC'); + world.addComponent(entity, 'backstory', 'A quiet settler.'); + world.addComponent(entity, 'desires', opts?.desires ?? []); + world.addComponent(entity, '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(entity, 'needs', { hunger: 80, energy: 80, productivity: 50 }); + world.addComponent(entity, 'npcBrain', { currentGoal: null, goalQueue: [] }); + world.addComponent(entity, 'inventory', new Map()); + return entity; +} + +describe('desireGeneratorSystem', () => { + it('does not generate when NPC has max desires', () => { + const world = setupWorld(); + const desires: Desire[] = [ + { id: 'd1', description: 'a', category: 'material', fulfillment: { type: 'custom', check: '' }, priority: 0.5, source: 'spawn', createdAtTick: 0 }, + { id: 'd2', description: 'b', category: 'social', fulfillment: { type: 'custom', check: '' }, priority: 0.5, source: 'spawn', createdAtTick: 0 }, + { id: 'd3', description: 'c', category: 'shelter', fulfillment: { type: 'custom', check: '' }, priority: 0.5, source: 'spawn', createdAtTick: 0 }, + ]; + addNPC(world, { desires }); + const llm = createMockLlm(); + const system = createDesireGeneratorSystem(llm as any); + + vi.spyOn(Math, 'random').mockReturnValue(0); // guarantee trigger + system.update(world, 3333); + vi.restoreAllMocks(); + + expect(llm.generate).not.toHaveBeenCalled(); + }); + + it('does not generate on non-interval ticks', () => { + const world = setupWorld(); + addNPC(world, {}); + const llm = createMockLlm(); + const system = createDesireGeneratorSystem(llm as any); + + system.update(world, 50); + + expect(llm.generate).not.toHaveBeenCalled(); + }); + + it('generates desire when NPC has open slots and probability passes', async () => { + const response = JSON.stringify({ + description: 'wants to gather more wood', + category: 'material', + fulfillment: { type: 'own_item', itemId: 'log', quantity: 5 }, + priority: 0.6, + reasoning: 'curious nature drives resource hoarding', + }); + const llm = createMockLlm(response); + const world = setupWorld(); + const entity = addNPC(world, { curiosity: 15, intelligence: 15 }); + const system = createDesireGeneratorSystem(llm as any); + + vi.spyOn(Math, 'random').mockReturnValue(0); // guarantee trigger + system.update(world, 3333); + vi.restoreAllMocks(); + + await vi.waitFor(() => { + const desires = world.getComponent(entity, 'desires'); + expect(desires!.length).toBe(1); + }); + }); + + it('skips NPC without desires component', () => { + const world = setupWorld(); + const entity = world.createEntity(); + world.addComponent(entity, 'npcBrain', { currentGoal: null, goalQueue: [] }); + // No desires component + const llm = createMockLlm(); + const system = createDesireGeneratorSystem(llm as any); + + system.update(world, 3333); + + expect(llm.generate).not.toHaveBeenCalled(); + }); +}); +``` + +**Step 2: Run tests to verify they fail** + +Run: `npm -w server run test -- --run src/systems/__tests__/desireGeneratorSystem.test.ts` +Expected: FAIL — module not found + +**Step 3: Write the implementation** + +```typescript +import type { World } from '../ecs/World.js'; +import type { Desire, Stats } from '@dflike/shared'; +import type { LlmService } from '../llm/llmService.js'; +import { desireConfig } from '../config/desireConfig.js'; +import { getEffectiveStat } from './statHelpers.js'; +import { formatStatsForPrompt, getWorldContext, validateDesire } from '../llm/backstoryGenerator.js'; + +export interface DesireGeneratorSystem { + update(world: World, tick: number): void; + triggerEvent(world: World, entityId: number, trigger: string, tick: number): void; +} + +export function createDesireGeneratorSystem( + llmService: LlmService, +): DesireGeneratorSystem { + const pendingEntities = new Set(); + + function canAddDesire(world: World, entityId: number): boolean { + const desires = world.getComponent(entityId, 'desires'); + if (!desires) return false; + return desires.length < desireConfig.maxDesires; + } + + async function requestDesire( + world: World, entityId: number, trigger: string, tick: number, + ): Promise { + if (pendingEntities.has(entityId)) return; + pendingEntities.add(entityId); + + try { + const name = world.getComponent(entityId, 'name') ?? 'Unknown'; + const stats = world.getComponent(entityId, 'stats'); + if (!stats) return; + + const backstory = world.getComponent(entityId, 'backstory') ?? ''; + const desires = world.getComponent(entityId, 'desires') ?? []; + const currentDesires = desires.map(d => d.description).join('; ') || 'none'; + const ctx = getWorldContext(world); + + const result = await llmService.generate('desireGeneration', { + npcName: name, + stats: formatStatsForPrompt(stats), + backstory, + currentDesires, + recentEvents: '', // TODO: integrate eventMemoryService + trigger, + resourceTypes: ctx.resourceTypes, + recipeList: ctx.recipeList, + structureList: ctx.structureList, + recentInventions: '', // TODO: integrate inventionTimeline + }); + + if (!result) return; + // Re-check capacity (might have changed during async) + if (!canAddDesire(world, entityId)) return; + + try { + const parsed = JSON.parse(result); + const desire = validateDesire(parsed, tick); + if (desire) { + desire.source = trigger === 'periodic' ? 'periodic' : 'event'; + desire.sourceDetail = trigger; + const existing = world.getComponent(entityId, 'desires') ?? []; + world.addComponent(entityId, 'desires', [...existing, desire]); + } + } catch { + // Invalid JSON — skip + } + } finally { + pendingEntities.delete(entityId); + } + } + + return { + update(world: World, tick: number): void { + if (tick % desireConfig.periodicCheckInterval !== 0) return; + + const npcs = world.queryAll('desires'); + for (const entityId of npcs) { + if (!canAddDesire(world, entityId)) continue; + + const curiosity = getEffectiveStat(world, entityId, 'curiosity'); + const intelligence = getEffectiveStat(world, entityId, 'intelligence'); + const composite = curiosity + intelligence; + const chance = desireConfig.periodicBaseChance + + (composite - 20) * desireConfig.periodicStatScale; + + if (Math.random() < Math.max(0.05, Math.min(0.8, chance))) { + requestDesire(world, entityId, 'periodic', tick); + } + } + }, + + triggerEvent(world: World, entityId: number, trigger: string, tick: number): void { + if (!canAddDesire(world, entityId)) return; + requestDesire(world, entityId, trigger, tick); + }, + }; +} +``` + +**Step 4: Run tests** + +Run: `npm -w server run test -- --run src/systems/__tests__/desireGeneratorSystem.test.ts` +Expected: All PASS + +**Step 5: Commit** + +```bash +git add server/src/systems/desireGeneratorSystem.ts server/src/systems/__tests__/desireGeneratorSystem.test.ts +git commit -m "feat: add desireGeneratorSystem with periodic and event-driven generation" +``` + +--- + +### Task 7: Wire industrySystem to Score Recipes by Desire + +**Files:** +- Modify: `server/src/systems/industrySystem.ts` +- Modify: `server/src/systems/__tests__/industrySystem.test.ts` + +**Step 1: Write failing tests for desire-based recipe scoring** + +Add to the existing test file: + +```typescript +describe('desire-based recipe scoring', () => { + it('prefers recipe matching a desire over other craftable recipes', () => { + const { world, map } = createIndustryWorld(); + const inv = new Map([['log', 4], ['stone', 2]]); + const entity = createIndustryNPC(world, 5, 5, inv); + // NPC desires a hammer specifically + world.addComponent(entity, 'desires', [{ + id: 'd1', description: 'wants a hammer', category: 'material', + fulfillment: { type: 'own_item', itemId: 'hammer', quantity: 1 }, + priority: 0.8, source: 'spawn', createdAtTick: 0, + }]); + + industrySystem(world, map); + + const brain = world.getComponent(entity, 'npcBrain')!; + expect(brain.currentGoal).toBe('craft'); + // Should have chosen hammer recipe since it matches desire + const craftingState = world.getComponent(entity, 'craftingState'); + expect(craftingState.recipeId).toBe('craft_hammer'); + }); + + it('falls back to first available when no desires match', () => { + const { world, map } = createIndustryWorld(); + const inv = new Map([['log', 4], ['stone', 2]]); + const entity = createIndustryNPC(world, 5, 5, inv); + world.addComponent(entity, 'desires', [{ + id: 'd1', description: 'wants a house', category: 'shelter', + fulfillment: { type: 'building_exists', buildingType: 'house' }, + priority: 0.8, source: 'spawn', createdAtTick: 0, + }]); + + industrySystem(world, map); + + const brain = world.getComponent(entity, 'npcBrain')!; + expect(brain.currentGoal).toBe('craft'); + // Should still pick something (first available) + }); +}); +``` + +**Step 2: Run tests to verify they fail** + +Run: `npm -w server run test -- --run src/systems/__tests__/industrySystem.test.ts` +Expected: FAIL — desires not read, no scoring logic + +**Step 3: Modify industrySystem to score recipes** + +In `industrySystem.ts`, in the crafting section (~line 65), replace the "pick first craftable recipe" logic with scoring: + +```typescript +// Inside the craft attempt block, replace: +// const recipe = craftable[0]; +// With: +const desires = world.getComponent(entityId, 'desires') ?? []; +const recipe = scoreCraftableRecipes(craftable, desires); +``` + +Add this helper function in the same file: + +```typescript +import type { Desire } from '@dflike/shared'; +import type { Recipe } from '../industry/recipeRegistry.js'; + +function scoreCraftableRecipes(recipes: Recipe[], desires: Desire[]): Recipe { + if (recipes.length === 1 || desires.length === 0) return recipes[0]; + + let bestRecipe = recipes[0]; + let bestScore = 0; + + for (const recipe of recipes) { + let score = 1; // base score + for (const desire of desires) { + const f = desire.fulfillment; + if (f.type === 'own_item' && f.itemId === recipe.outputItemId) { + score += desire.priority * 10; + } else if (f.type === 'own_item_category') { + // Check if recipe output category matches + score += desire.priority * 5; + } + } + if (score > bestScore) { + bestScore = score; + bestRecipe = recipe; + } + } + + return bestRecipe; +} +``` + +**Step 4: Run tests** + +Run: `npm -w server run test -- --run src/systems/__tests__/industrySystem.test.ts` +Expected: All PASS (old and new tests) + +**Step 5: Run full test suite** + +Run: `npm -w server run test -- --run` +Expected: All tests pass + +**Step 6: Commit** + +```bash +git add server/src/systems/industrySystem.ts server/src/systems/__tests__/industrySystem.test.ts +git commit -m "feat: industrySystem scores recipes by desire match" +``` + +--- + +### Task 8: Wire Desires into Invention Prompts + +**Files:** +- Modify: `server/src/systems/inventionSystem.ts` + +**Step 1: Add desire context to invention LLM calls** + +In the section where the invention prompt variables are built (~line 82-98), add desire descriptions: + +```typescript +const desires = world.getComponent(entityId, 'desires') ?? []; +const desiresSection = desires.length > 0 + ? `This settler's current desires:\n${desires.map(d => `- ${d.description}`).join('\n')}\n\nIf possible, invent something that helps fulfill one of these desires.\n\n` + : ''; +``` + +Add `desiresSection` to the template variables passed to `llmService.generate('invention', { ..., desiresSection })`. + +**Step 2: Run full test suite** + +Run: `npm -w server run test -- --run` +Expected: All pass (invention tests mock LLM, won't break from extra variable) + +**Step 3: Commit** + +```bash +git add server/src/systems/inventionSystem.ts +git commit -m "feat: pass NPC desires into invention prompts" +``` + +--- + +### Task 9: Persistence — Save/Load Desires + +**Files:** +- Modify: `server/src/persistence/entitySerializer.ts` + +**Step 1: Add desires to SERIALIZABLE_COMPONENTS** + +Add `'desires'` to the array at line 9: + +```typescript +const SERIALIZABLE_COMPONENTS = [ + 'needs', 'stats', 'appearance', 'npcBrain', 'socialState', + 'statModifiers', 'backstory', 'inventory', 'structure', 'movement', + 'desires', +] as const; +``` + +Desires are plain JSON-serializable arrays (no Maps or special types), so the default `JSON.stringify`/`JSON.parse` path handles them without special cases. + +**Step 2: Run existing persistence tests** + +Run: `npm -w server run test -- --run src/persistence/` +Expected: All PASS + +**Step 3: Commit** + +```bash +git add server/src/persistence/entitySerializer.ts +git commit -m "feat: persist desires component in entity serializer" +``` + +--- + +### Task 10: Wire State Serializer for Client + +**Files:** +- Modify: `server/src/network/stateSerializer.ts` + +**Step 1: Add desires to serializeEntity** + +After the inventory serialization (~line 56), add: + +```typescript +const desiresComponent = world.getComponent(entityId, 'desires'); +const desires = desiresComponent && desiresComponent.length > 0 + ? desiresComponent.map(d => ({ description: d.description, category: d.category })) + : undefined; +``` + +Add `desires` to the returned EntityState object (~line 74): + +```typescript +return { + // ... existing fields ... + desires, +}; +``` + +Add the import for `Desire` type at the top. + +**Step 2: Run tests** + +Run: `npm -w server run test -- --run` +Expected: All PASS + +**Step 3: Commit** + +```bash +git add server/src/network/stateSerializer.ts +git commit -m "feat: send desires to client via state serializer" +``` + +--- + +### Task 11: Wire Systems into GameLoop + +**Files:** +- Modify: `server/src/game/GameLoop.ts` + +**Step 1: Import new systems** + +Add imports: + +```typescript +import { desireFulfillmentSystem } from '../systems/desireFulfillmentSystem.js'; +import { createDesireGeneratorSystem } from '../systems/desireGeneratorSystem.js'; +import type { DesireGeneratorSystem } from '../systems/desireGeneratorSystem.js'; +``` + +**Step 2: Create desireGeneratorSystem instance** + +In the constructor, after creating `inventionSystem` (~similar pattern): + +```typescript +private desireGeneratorSystem: DesireGeneratorSystem; + +// In constructor: +this.desireGeneratorSystem = createDesireGeneratorSystem(this.llmService); +``` + +**Step 3: Add to update() system execution order** + +After `relationshipSystem` and before `industrySystem` (~line 276-277), insert: + +```typescript +desireFulfillmentSystem(this.world, this.tick); +this.desireGeneratorSystem.update(this.world, this.tick); +``` + +**Step 4: Update spawner to initialize desires component** + +In `spawner.ts`, add `desires: []` component to spawned NPCs (similar to other array components). + +**Step 5: Update generateNpcBackstory to use combined generator** + +Change `generateNpcBackstory` method: + +```typescript +generateNpcBackstory(entityId: number): void { + generateBackstoryAndDesires(this.world, entityId, this.llmService, this.tick); +} +``` + +Update import from `generateBackstory` to `generateBackstoryAndDesires`. + +**Step 6: Run full test suite** + +Run: `npm -w server run test -- --run` +Expected: All PASS + +**Step 7: Commit** + +```bash +git add server/src/game/GameLoop.ts server/src/game/spawner.ts +git commit -m "feat: wire desire systems into GameLoop and spawner" +``` + +--- + +### Task 12: Integration Test — Full Desire Lifecycle + +**Files:** +- Create: `server/src/systems/__tests__/desireLifecycle.test.ts` + +**Step 1: Write integration test** + +```typescript +import { describe, it, expect } from 'vitest'; +import { World } from '../../ecs/World.js'; +import { desireFulfillmentSystem } from '../desireFulfillmentSystem.js'; +import { industrySystem } from '../industrySystem.js'; +import { GameMap } from '../../game/GameMap.js'; +import { ItemRegistry } from '../../industry/itemRegistry.js'; +import { RecipeRegistry } from '../../industry/recipeRegistry.js'; +import type { Desire, Stats, NPCBrain, Needs } from '@dflike/shared'; + +function setupIntegrationWorld() { + const world = new World(); + const map = new GameMap(20, 20); + const itemRegistry = ItemRegistry.createDefault(); + const recipeRegistry = RecipeRegistry.createDefault(); + world.setSingleton('itemRegistry', itemRegistry); + world.setSingleton('recipeRegistry', recipeRegistry); + return { world, map }; +} + +describe('desire lifecycle integration', () => { + it('desire drives crafting priority then gets fulfilled', () => { + const { world, map } = setupIntegrationWorld(); + + // Create NPC with materials for both axe and hammer, but desires hammer + const entity = world.createEntity(); + world.addComponent(entity, 'position', { x: 5, y: 5 }); + world.addComponent(entity, 'name', 'TestNPC'); + const inv = new Map([['log', 4], ['stone', 2]]); + world.addComponent(entity, 'inventory', inv); + world.addComponent(entity, 'stats', { + strength: 10, dexterity: 10, constitution: 10, intelligence: 10, perception: 10, + sociability: 10, courage: 10, curiosity: 10, empathy: 10, temperament: 10, + }); + world.addComponent(entity, 'statModifiers', { modifiers: [] }); + world.addComponent(entity, 'needs', { hunger: 80, energy: 80, productivity: 20 }); + world.addComponent(entity, 'npcBrain', { currentGoal: 'wander', goalQueue: [] }); + world.addComponent(entity, 'movement', { state: 'idle', target: null, path: [], direction: 0, moveProgress: 0 }); + + const hammerDesire: Desire = { + id: 'test_hammer', description: 'wants a hammer', category: 'material', + fulfillment: { type: 'own_item', itemId: 'hammer', quantity: 1 }, + priority: 0.9, source: 'spawn', createdAtTick: 0, + }; + world.addComponent(entity, 'desires', [hammerDesire]); + + // Industry should pick hammer recipe due to desire + industrySystem(world, map); + const craftingState = world.getComponent(entity, 'craftingState'); + expect(craftingState).toBeTruthy(); + expect(craftingState.recipeId).toBe('craft_hammer'); + + // Simulate crafting completion: add hammer to inventory + inv.set('hammer', 1); + + // Fulfillment check should remove the desire + desireFulfillmentSystem(world, 100); + const desires = world.getComponent(entity, 'desires')!; + expect(desires).toHaveLength(0); + }); +}); +``` + +**Step 2: Run integration test** + +Run: `npm -w server run test -- --run src/systems/__tests__/desireLifecycle.test.ts` +Expected: PASS + +**Step 3: Run full test suite** + +Run: `npm -w server run test -- --run` +Expected: All PASS + +**Step 4: Commit** + +```bash +git add server/src/systems/__tests__/desireLifecycle.test.ts +git commit -m "test: add desire lifecycle integration test" +``` + +--- + +### Task 13: Client — Display Desires in NPC Info Panel + +**Files:** +- Modify: `client/src/ui/NpcInfoPanel.ts` + +**Step 1: Find the status tab rendering section** + +Look for where backstory is rendered in the Status tab. Add a desires section below it. + +**Step 2: Add desires rendering** + +After the backstory section, add: + +```typescript +// Desires section +const desires = entity.desires; +if (desires && desires.length > 0) { + html += '
'; + html += ''; + for (const desire of desires) { + const color = desireCategoryColor(desire.category); + html += `
◆ ${desire.description}
`; + } + // Show empty slots + const empty = 3 - desires.length; + for (let i = 0; i < empty; i++) { + html += '
○ (daydreaming...)
'; + } + html += '
'; +} else { + html += '
'; + html += ''; + html += '
Content for now.
'; + html += '
'; +} +``` + +Add the color helper: + +```typescript +function desireCategoryColor(category: string): string { + switch (category) { + case 'material': return '#d4a574'; // warm tan + case 'shelter': return '#c4956a'; // warm brown + case 'comfort': return '#e8b87a'; // golden + case 'social': return '#7ab8e8'; // cool blue + case 'community': return '#7ae8a0'; // soft green + case 'creative': return '#c87ae8'; // purple + default: return '#aaa'; + } +} +``` + +Add CSS for desires (in the styles section): + +```css +.desires-section { margin: 8px 0; } +.desire-item { font-size: 12px; padding: 2px 0; padding-left: 8px; } +.desire-empty { color: #555; font-style: italic; } +``` + +**Step 3: Build client** + +Run: `npm -w client run build` +Expected: Clean build + +**Step 4: Commit** + +```bash +git add client/src/ui/NpcInfoPanel.ts +git commit -m "feat: display NPC desires in info panel" +``` + +--- + +### Task 14: Final Integration — Manual Testing & Cleanup + +**Step 1: Run full server test suite** + +Run: `npm -w server run test -- --run` +Expected: All tests pass + +**Step 2: Rebuild shared types** + +Run: `npx -w shared tsc` +Expected: Clean + +**Step 3: Build client** + +Run: `npm -w client run build` +Expected: Clean + +**Step 4: Start server with new world and verify** + +Run: `npm -w server run dev -- --new-world` +Expected: Server starts, NPCs spawn, backstory+desires generate via LLM, desires appear in state updates + +**Step 5: Final commit if any cleanup needed** + +```bash +git add -A +git commit -m "chore: desire system cleanup and integration polish" +```