diff --git a/client/src/ui/NpcInfoPanel.ts b/client/src/ui/NpcInfoPanel.ts index 30eaf3f..b11442a 100644 --- a/client/src/ui/NpcInfoPanel.ts +++ b/client/src/ui/NpcInfoPanel.ts @@ -1,4 +1,4 @@ -import type { EntityState, NarrationEvent, MemoryEvent, Stats, StatName } from '@dflike/shared'; +import type { EntityState, NarrationEvent, MemoryEvent, Stats, StatName, DesireCategory } from '@dflike/shared'; import { attachTooltip } from './tooltip.js'; const ACTIVITY_LABELS: Record = { @@ -32,6 +32,18 @@ const EB = { shine: 'rgba(200,200,255,0.08)', // Subtle window shine }; +function desireCategoryColor(category: string): string { + switch (category) { + case 'material': return '#d4a574'; + case 'shelter': return '#c4956a'; + case 'comfort': return '#e8b87a'; + case 'social': return '#7ab8e8'; + case 'community': return '#7ae8a0'; + case 'creative': return '#c87ae8'; + default: return '#9898d0'; + } +} + const STAT_FULL_NAMES: Record = { STR: 'Strength', DEX: 'Dexterity', CON: 'Constitution', INT: 'Intelligence', PER: 'Perception', SOC: 'Sociability', COU: 'Courage', CUR: 'Curiosity', EMP: 'Empathy', TMP: 'Temperament', @@ -48,6 +60,7 @@ export class NpcInfoPanel { private needsBarsContainer: HTMLDivElement; private needsBars: Map = new Map(); private backstoryEl: HTMLDivElement; + private desiresEl: HTMLDivElement; private statsContainer: HTMLDivElement; private statElements: Map = new Map(); private statusTab!: HTMLDivElement; @@ -265,6 +278,16 @@ export class NpcInfoPanel { `; this.statusContent.appendChild(this.backstoryEl); + // Desires section + this.desiresEl = document.createElement('div'); + this.desiresEl.style.cssText = ` + padding: 4px 8px; + font-family: 'Press Start 2P', monospace; + font-size: 10px; + line-height: 1.8; + `; + this.statusContent.appendChild(this.desiresEl); + // Inner thought (italic first-person, shown above recent events) this.thoughtEl = document.createElement('div'); this.thoughtEl.style.cssText = ` @@ -441,6 +464,25 @@ export class NpcInfoPanel { this.backstoryEl.style.minHeight = '0'; } + // Update desires + const desires = entity.desires; + if (desires && desires.length > 0) { + let html = `
Desires
`; + for (const desire of desires) { + const color = desireCategoryColor(desire.category); + html += `
◆ ${desire.description}
`; + } + const empty = 3 - desires.length; + for (let i = 0; i < empty; i++) { + html += `
○ (daydreaming...)
`; + } + this.desiresEl.innerHTML = html; + this.desiresEl.style.display = 'block'; + } else { + this.desiresEl.innerHTML = `
Content for now.
`; + this.desiresEl.style.display = 'block'; + } + if (entity.needs) { this.updateNeeds(entity.needs); } diff --git a/server/src/config/desireConfig.ts b/server/src/config/desireConfig.ts new file mode 100644 index 0000000..43e03b7 --- /dev/null +++ b/server/src/config/desireConfig.ts @@ -0,0 +1,25 @@ +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; diff --git a/server/src/game/GameLoop.ts b/server/src/game/GameLoop.ts index 86aa7e8..0fb9249 100644 --- a/server/src/game/GameLoop.ts +++ b/server/src/game/GameLoop.ts @@ -19,7 +19,10 @@ import { pickupSystem } from '../systems/pickupSystem.js'; import { RecipeRegistry } from '../industry/recipeRegistry.js'; import { spawnNPC } from './spawner.js'; import { createLlmService, type LlmService } from '../llm/llmService.js'; -import { generateBackstory } from '../llm/backstoryGenerator.js'; +import { generateBackstoryAndDesires } from '../llm/backstoryGenerator.js'; +import { desireFulfillmentSystem } from '../systems/desireFulfillmentSystem.js'; +import { createDesireGeneratorSystem } from '../systems/desireGeneratorSystem.js'; +import type { DesireGeneratorSystem } from '../systems/desireGeneratorSystem.js'; import { createNarrationService, type NarrationService } from '../llm/narrationService.js'; import { narrationEmitter } from '../systems/narrationEmitter.js'; import type { EntityId, InventionSummary, Position } from '@dflike/shared'; @@ -44,6 +47,7 @@ export class GameLoop { readonly eventMemoryService: EventMemoryService; readonly thoughtSystem: ThoughtSystem; readonly inventionSystem: InventionSystem; + private desireGeneratorSystem: DesireGeneratorSystem; public followedEntityIds: Set = new Set(); public onInventionCreated: ((summary: InventionSummary) => void) | null = null; public onStockpileEvent: ((entry: StockpileLogEntry) => void) | null = null; @@ -66,6 +70,7 @@ export class GameLoop { this.eventMemoryService, (summary) => this.onInventionCreated?.(summary), ); + this.desireGeneratorSystem = createDesireGeneratorSystem(this.llmService, this.eventMemoryService); this.saveManager = new SaveManager(options?.savePath ?? 'saves/default.db'); @@ -218,7 +223,7 @@ export class GameLoop { } generateNpcBackstory(entityId: number): void { - generateBackstory(this.world, entityId, this.llmService); + generateBackstoryAndDesires(this.world, entityId, this.llmService, this.tick); } private spawnInitialNPCs(count: number): void { @@ -274,6 +279,8 @@ export class GameLoop { socialSystem(this.world, this.eventMemoryService); narrationEmitter(this.world, this.narrationService, this.followedEntityIds, this.eventMemoryService); relationshipSystem(this.world, this.eventMemoryService); + desireFulfillmentSystem(this.world, this.tick, this.eventMemoryService); + this.desireGeneratorSystem.update(this.world, this.tick); industrySystem(this.world, this.map); pickupSystem(this.world, this.tick, (entry) => this.onStockpileEvent?.(entry)); gatheringSystem(this.world, this.map); diff --git a/server/src/game/spawner.ts b/server/src/game/spawner.ts index 85b8ddd..fa29567 100644 --- a/server/src/game/spawner.ts +++ b/server/src/game/spawner.ts @@ -3,7 +3,7 @@ import type { GameMap } from '../map/GameMap.js'; import { generateRandomAppearance } from '../spawner/appearanceGenerator.js'; import { generateName } from '../spawner/nameGenerator.js'; import { generateStats } from '../spawner/statGenerator.js'; -import type { EntityId, Position, Needs, Movement, NPCBrain, Appearance, SocialState, Stats, StatModifiers, Relationships } from '@dflike/shared'; +import type { EntityId, Position, Needs, Movement, NPCBrain, Appearance, SocialState, Stats, StatModifiers, Relationships, Desire } from '@dflike/shared'; import type { EventMemoryService } from '../llm/eventMemoryService.js'; export function spawnNPC(world: World, map: GameMap, positionHint?: Position, eventMemoryService?: EventMemoryService): EntityId { @@ -48,6 +48,7 @@ export function spawnNPC(world: World, map: GameMap, positionHint?: Position, ev world.addComponent(entity, 'relationships', new Map()); world.addComponent(entity, 'backstory', ''); world.addComponent>(entity, 'inventory', new Map()); + world.addComponent(entity, 'desires', []); eventMemoryService?.record(entity, { type: 'spawned', diff --git a/server/src/llm/__tests__/backstoryGenerator.test.ts b/server/src/llm/__tests__/backstoryGenerator.test.ts index 6de1ca9..3cc738c 100644 --- a/server/src/llm/__tests__/backstoryGenerator.test.ts +++ b/server/src/llm/__tests__/backstoryGenerator.test.ts @@ -1,47 +1,312 @@ import { describe, it, expect, vi } from 'vitest'; import { World } from '../../ecs/World.js'; -import { formatStatsForPrompt, generateBackstory } from '../backstoryGenerator.js'; +import { + formatStatsForPrompt, + generateBackstory, + generateBackstoryAndDesires, + getWorldContext, + validateDesire, +} from '../backstoryGenerator.js'; import type { Stats } from '@dflike/shared'; import type { LlmService } from '../llmService.js'; +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 ''; } }), + } as unknown as LlmService; +} + +function createWorldWithSingletons(): World { + const world = new World(); + world.setSingleton('itemRegistry', ItemRegistry.createDefault()); + world.setSingleton('recipeRegistry', RecipeRegistry.createDefault()); + return world; +} + +const defaultStats: 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 into a readable string', () => { - const stats: Stats = { - strength: 15, dexterity: 12, constitution: 14, - intelligence: 8, perception: 10, - sociability: 6, courage: 16, curiosity: 11, - empathy: 7, temperament: 13, - }; - const result = formatStatsForPrompt(stats); + const result = formatStatsForPrompt(defaultStats); expect(result).toBe( 'strength:15, dexterity:12, constitution:14, intelligence:8, perception:10, sociability:6, courage:16, curiosity:11, empathy:7, temperament:13' ); }); }); -describe('generateBackstory', () => { +describe('validateDesire', () => { + it('returns a valid desire from correct input', () => { + const raw = { + description: 'Build a shelter', + category: 'shelter', + fulfillment: { type: 'structure_exists', structureType: 'shelter' }, + priority: 0.8, + }; + const result = validateDesire(raw, 100); + expect(result).not.toBeNull(); + expect(result!.description).toBe('Build a shelter'); + expect(result!.category).toBe('shelter'); + expect(result!.priority).toBe(0.8); + expect(result!.source).toBe('spawn'); + expect(result!.createdAtTick).toBe(100); + expect(result!.id).toMatch(/^desire_100_/); + }); + + it('returns null for invalid category', () => { + const raw = { + description: 'Do something', + category: 'invalid_category', + fulfillment: { type: 'custom', check: 'something' }, + priority: 0.5, + }; + expect(validateDesire(raw, 0)).toBeNull(); + }); + + it('returns null for missing fulfillment', () => { + const raw = { + description: 'Do something', + category: 'material', + }; + expect(validateDesire(raw, 0)).toBeNull(); + }); + + it('returns null for invalid fulfillment type', () => { + const raw = { + description: 'Do something', + category: 'material', + fulfillment: { type: 'nonexistent' }, + }; + expect(validateDesire(raw, 0)).toBeNull(); + }); + + it('clamps priority to 0-1 range', () => { + const makeRaw = (priority: number) => ({ + description: 'Test', + category: 'material', + fulfillment: { type: 'own_item', itemId: 'log', quantity: 1 }, + priority, + }); + + const high = validateDesire(makeRaw(5.0), 0); + expect(high!.priority).toBe(1.0); + + const low = validateDesire(makeRaw(-2.0), 0); + expect(low!.priority).toBe(0.0); + }); + + it('defaults priority to 0.5 when not a number', () => { + const raw = { + description: 'Test', + category: 'material', + fulfillment: { type: 'own_item', itemId: 'log', quantity: 1 }, + priority: 'high', + }; + const result = validateDesire(raw, 0); + expect(result!.priority).toBe(0.5); + }); + + it('returns null for null or non-object input', () => { + expect(validateDesire(null, 0)).toBeNull(); + expect(validateDesire('string', 0)).toBeNull(); + expect(validateDesire(42, 0)).toBeNull(); + }); + + it('returns null for missing description', () => { + const raw = { + category: 'material', + fulfillment: { type: 'own_item', itemId: 'log', quantity: 1 }, + }; + expect(validateDesire(raw, 0)).toBeNull(); + }); +}); + +describe('getWorldContext', () => { + it('returns resource types, recipes, and structures', () => { + const world = createWorldWithSingletons(); + const ctx = getWorldContext(world); + expect(ctx.resourceTypes).toContain('Log'); + expect(ctx.resourceTypes).toContain('Stone'); + expect(ctx.recipeList).toContain('craft_wooden_axe'); + expect(ctx.structureList).toBe('none'); + }); + + it('lists completed structures', () => { + const world = createWorldWithSingletons(); + const s = world.createEntity(); + world.addComponent(s, 'structure', { + type: 'stockpile', subtype: 'general', + inventory: new Map(), + }); + const ctx = getWorldContext(world); + expect(ctx.structureList).toBe('stockpile:general'); + }); + + it('excludes structures still under construction', () => { + const world = createWorldWithSingletons(); + const s = world.createEntity(); + world.addComponent(s, 'structure', { + type: 'workshop', subtype: 'workbench', + inventory: new Map(), buildProgress: 0.5, + }); + const ctx = getWorldContext(world); + expect(ctx.structureList).toBe('none'); + }); +}); + +describe('generateBackstoryAndDesires', () => { + it('sets backstory and desires from valid JSON response', async () => { + const world = createWorldWithSingletons(); + const entity = world.createEntity(); + world.addComponent(entity, 'name', 'Brynn'); + world.addComponent(entity, 'stats', defaultStats); + + const llmResponse = JSON.stringify({ + backstory: 'A quiet wanderer drawn to the wild.', + desires: [ + { + description: 'Craft a wooden axe', + category: 'material', + fulfillment: { type: 'own_item', itemId: 'wooden_axe', quantity: 1 }, + priority: 0.9, + }, + ], + }); + + const mockLlm = createMockLlm(llmResponse); + await generateBackstoryAndDesires(world, entity, mockLlm, 50); + + expect(world.getComponent(entity, 'backstory')).toBe( + 'A quiet wanderer drawn to the wild.' + ); + const desires = world.getComponent(entity, 'desires'); + expect(desires).toHaveLength(1); + expect((desires![0] as { description: string }).description).toBe('Craft a wooden axe'); + }); + + it('falls back to raw backstory on invalid JSON', async () => { + const world = createWorldWithSingletons(); + const entity = world.createEntity(); + world.addComponent(entity, 'name', 'Thom'); + world.addComponent(entity, 'stats', defaultStats); + + const mockLlm = createMockLlm('A bold settler with fiery temper.'); + await generateBackstoryAndDesires(world, entity, mockLlm, 0); + + expect(world.getComponent(entity, 'backstory')).toBe( + 'A bold settler with fiery temper.' + ); + expect(world.getComponent(entity, 'desires')).toBeUndefined(); + }); + + it('does nothing when LLM returns null', async () => { + const world = createWorldWithSingletons(); + const entity = world.createEntity(); + world.addComponent(entity, 'name', 'Thom'); + world.addComponent(entity, 'stats', defaultStats); + + const mockLlm = createMockLlm(null); + await generateBackstoryAndDesires(world, entity, mockLlm, 0); + + expect(world.getComponent(entity, 'backstory')).toBeUndefined(); + expect(world.getComponent(entity, 'desires')).toBeUndefined(); + }); + + it('passes world context variables to LLM', async () => { + const world = createWorldWithSingletons(); + const entity = world.createEntity(); + world.addComponent(entity, 'name', 'Ava'); + world.addComponent(entity, 'stats', defaultStats); + + const mockLlm = createMockLlm(null); + await generateBackstoryAndDesires(world, entity, mockLlm, 0); + + expect(mockLlm.generate).toHaveBeenCalledWith('backstoryAndDesires', expect.objectContaining({ + npcName: 'Ava', + resourceTypes: expect.stringContaining('Log'), + recipeList: expect.stringContaining('craft_wooden_axe'), + structureList: 'none', + })); + }); + + it('limits desires to 2 maximum', async () => { + const world = createWorldWithSingletons(); + const entity = world.createEntity(); + world.addComponent(entity, 'name', 'Brynn'); + world.addComponent(entity, 'stats', defaultStats); + + const llmResponse = JSON.stringify({ + backstory: 'Test.', + desires: [ + { description: 'D1', category: 'material', fulfillment: { type: 'own_item', itemId: 'log', quantity: 1 }, priority: 0.5 }, + { description: 'D2', category: 'social', fulfillment: { type: 'relationship_tier', tier: 'friend' }, priority: 0.6 }, + { description: 'D3', category: 'shelter', fulfillment: { type: 'structure_exists', structureType: 'shelter' }, priority: 0.7 }, + ], + }); + + const mockLlm = createMockLlm(llmResponse); + await generateBackstoryAndDesires(world, entity, mockLlm, 0); + + const desires = world.getComponent(entity, 'desires'); + expect(desires).toHaveLength(2); + }); + + it('skips invalid desires but keeps valid ones', async () => { + const world = createWorldWithSingletons(); + const entity = world.createEntity(); + world.addComponent(entity, 'name', 'Brynn'); + world.addComponent(entity, 'stats', defaultStats); + + const llmResponse = JSON.stringify({ + backstory: 'Test.', + desires: [ + { description: 'Bad', category: 'invalid', fulfillment: { type: 'own_item' }, priority: 0.5 }, + { description: 'Good', category: 'material', fulfillment: { type: 'own_item', itemId: 'log', quantity: 1 }, priority: 0.8 }, + ], + }); + + const mockLlm = createMockLlm(llmResponse); + await generateBackstoryAndDesires(world, entity, mockLlm, 0); + + const desires = world.getComponent(entity, 'desires'); + expect(desires).toHaveLength(1); + expect((desires![0] as { description: string }).description).toBe('Good'); + }); + + it('does nothing if entity is missing components', async () => { + const world = createWorldWithSingletons(); + const entity = world.createEntity(); + + const mockLlm = createMockLlm('text'); + await generateBackstoryAndDesires(world, entity, mockLlm, 0); + expect(mockLlm.generate).not.toHaveBeenCalled(); + }); +}); + +describe('generateBackstory (legacy)', () => { it('calls LLM service with correct template and variables', async () => { const world = new World(); const entity = world.createEntity(); world.addComponent(entity, 'name', 'Brynn'); world.addComponent(entity, 'backstory', ''); - world.addComponent(entity, 'stats', { - strength: 15, dexterity: 12, constitution: 14, - intelligence: 8, perception: 10, - sociability: 6, courage: 16, curiosity: 11, - empathy: 7, temperament: 13, - }); + world.addComponent(entity, 'stats', defaultStats); - const mockService: LlmService = { - generate: vi.fn().mockResolvedValue('A brave warrior from the north.'), - queueDepth: () => 0, - clear: () => {}, - tokenUsage: () => ({ record() {}, getRecent() { return []; }, lastSummary() { return ''; } }), - }; + const mockLlm = createMockLlm('A brave warrior from the north.'); + await generateBackstory(world, entity, mockLlm); - await generateBackstory(world, entity, mockService); - - expect(mockService.generate).toHaveBeenCalledWith('backstory', { + expect(mockLlm.generate).toHaveBeenCalledWith('backstory', { npcName: 'Brynn', stats: 'strength:15, dexterity:12, constitution:14, intelligence:8, perception:10, sociability:6, courage:16, curiosity:11, empathy:7, temperament:13', }); @@ -63,14 +328,8 @@ describe('generateBackstory', () => { empathy: 10, temperament: 10, }); - const mockService: LlmService = { - generate: vi.fn().mockResolvedValue(null), - queueDepth: () => 0, - clear: () => {}, - tokenUsage: () => ({ record() {}, getRecent() { return []; }, lastSummary() { return ''; } }), - }; - - await generateBackstory(world, entity, mockService); + const mockLlm = createMockLlm(null); + await generateBackstory(world, entity, mockLlm); expect(world.getComponent(entity, 'backstory')).toBe(''); }); @@ -79,14 +338,8 @@ describe('generateBackstory', () => { const world = new World(); const entity = world.createEntity(); - const mockService: LlmService = { - generate: vi.fn().mockResolvedValue('text'), - queueDepth: () => 0, - clear: () => {}, - tokenUsage: () => ({ record() {}, getRecent() { return []; }, lastSummary() { return ''; } }), - }; - - await generateBackstory(world, entity, mockService); - expect(mockService.generate).not.toHaveBeenCalled(); + const mockLlm = createMockLlm('text'); + await generateBackstory(world, entity, mockLlm); + expect(mockLlm.generate).not.toHaveBeenCalled(); }); }); diff --git a/server/src/llm/backstoryGenerator.ts b/server/src/llm/backstoryGenerator.ts index ab7c942..6e1de0e 100644 --- a/server/src/llm/backstoryGenerator.ts +++ b/server/src/llm/backstoryGenerator.ts @@ -1,16 +1,142 @@ -import type { Stats } from '@dflike/shared'; +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'; +import type { StructureData } from '../systems/buildingSystem.js'; const STAT_KEYS: (keyof Stats)[] = [ 'strength', 'dexterity', 'constitution', 'intelligence', 'perception', 'sociability', 'courage', 'curiosity', 'empathy', 'temperament', ]; +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', +]; + export function formatStatsForPrompt(stats: Stats): string { return STAT_KEYS.map(k => `${k}:${Math.floor(stats[k])}`).join(', '); } +export 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(', ') + : 'none'; + + const recipeList = recipeRegistry + ? recipeRegistry.getAll().map(r => `${r.id} (makes ${r.outputItemId})`).join(', ') + : 'none'; + + // Find completed structures + const structureEntities = world.query('structure'); + const structures: string[] = []; + for (const eid of structureEntities) { + const s = world.getComponent(eid, 'structure'); + if (s && s.buildProgress === undefined) { + structures.push(`${s.type}:${s.subtype}`); + } + } + const structureList = structures.length > 0 ? structures.join(', ') : 'none'; + + return { resourceTypes, recipeList, structureList }; +} + +export function validateDesire(raw: unknown, tick: number): Desire | null { + if (!raw || typeof raw !== 'object') return null; + + const obj = raw as Record; + + if (typeof obj.description !== 'string' || !obj.description) return null; + + if (!VALID_CATEGORIES.includes(obj.category as DesireCategory)) return null; + + if (!obj.fulfillment || typeof obj.fulfillment !== 'object') return null; + const fulfillment = obj.fulfillment as Record; + if (!VALID_FULFILLMENT_TYPES.includes(fulfillment.type as string)) return null; + + let priority = typeof obj.priority === 'number' ? obj.priority : 0.5; + priority = Math.max(0, Math.min(1, priority)); + + const id = `desire_${tick}_${Math.random().toString(36).slice(2, 8)}`; + + return { + id, + description: obj.description as string, + category: obj.category as DesireCategory, + fulfillment: obj.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 context = getWorldContext(world); + + const result = await llmService.generate('backstoryAndDesires', { + npcName: name, + stats: formatStatsForPrompt(stats), + resourceTypes: context.resourceTypes, + recipeList: context.recipeList, + structureList: context.structureList, + }); + + if (!result) return; + + let parsed: { backstory?: string; desires?: unknown[] } | null = null; + try { + parsed = JSON.parse(result); + } catch { + // JSON parse failed — use raw string as backstory fallback + world.addComponent(entityId, 'backstory', result); + return; + } + + if (!parsed || typeof parsed !== 'object') { + world.addComponent(entityId, 'backstory', result); + return; + } + + if (typeof parsed.backstory === 'string' && parsed.backstory) { + world.addComponent(entityId, 'backstory', parsed.backstory); + } + + if (Array.isArray(parsed.desires)) { + const desires: Desire[] = []; + for (const rawDesire of parsed.desires.slice(0, 2)) { + const desire = validateDesire(rawDesire, tick); + if (desire) { + desires.push(desire); + } + } + if (desires.length > 0) { + world.addComponent(entityId, 'desires', desires); + } + } +} + +/** @deprecated Use generateBackstoryAndDesires instead */ export async function generateBackstory( world: World, entityId: number, diff --git a/server/src/llm/templates.ts b/server/src/llm/templates.ts index 4e06272..26ec32b 100644 --- a/server/src/llm/templates.ts +++ b/server/src/llm/templates.ts @@ -4,11 +4,12 @@ export const templates: Record = { backstory: { name: 'backstory', systemPrompt: - 'You are a narrator for a medieval fantasy village simulation. ' + - 'Write brief, evocative NPC backstories. Keep responses to 1-2 sentences. ' + - 'Do not use cliches. Ground details in daily village life.', + '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 an NPC named {{npcName}}.\n' + + '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. ' + @@ -20,7 +21,7 @@ export const templates: Record = { socialNarration: { name: 'socialNarration', systemPrompt: - 'You are a narrator for a medieval fantasy village simulation. ' + + '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: @@ -34,20 +35,20 @@ export const templates: Record = { batchedThoughts: { name: 'batchedThoughts', systemPrompt: - 'You voice the inner thoughts of NPCs in a medieval village simulation. ' + + '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 NPC:\n\n{{npcList}}', + 'Generate a brief inner thought for each settler:\n\n{{npcList}}', }, invention: { name: 'invention', systemPrompt: - 'You are an inventor in a medieval fantasy village simulation. ' + - 'Given available materials and an NPC\'s stats, consider 3 possible inventions, ' + - 'then select the one that best fits the NPC\'s personality. ' + + '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' + @@ -55,16 +56,71 @@ export const templates: Record = { '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 NPC\'s personality and stats.\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 NPC", ' + + '"reasoning": "why this suits the settler", ' + '"category": "resource|tool|material|structure", ' + '"inputs": [{"itemId": "existing_item_id", "quantity": N}], ' + '"workshopType": null, "toolRequired": null}', }, + + 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"}]}', + }, + + 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"}', + }, }; diff --git a/server/src/network/stateSerializer.ts b/server/src/network/stateSerializer.ts index 1d414b1..c8c584b 100644 --- a/server/src/network/stateSerializer.ts +++ b/server/src/network/stateSerializer.ts @@ -1,6 +1,7 @@ import type { EntityState, WorldState, StateUpdate, Position, Movement, Appearance, Needs, NPCBrain, PlayerControlled, SocialState, Stats, Relationships, + Desire, } from '@dflike/shared'; import { TILE_SIZE } from '@dflike/shared'; import type { World } from '../ecs/World.js'; @@ -54,6 +55,10 @@ export function serializeEntity(world: World, entityId: number): EntityState { const inventory = inventoryComponent && inventoryComponent.size > 0 ? Object.fromEntries(inventoryComponent) : undefined; + const desiresComponent = world.getComponent(entityId, 'desires'); + const desires = desiresComponent && desiresComponent.length > 0 + ? desiresComponent.map(d => ({ description: d.description, category: d.category })) + : undefined; return { id: entityId, position: world.getComponent(entityId, 'position')!, @@ -72,6 +77,7 @@ export function serializeEntity(world: World, entityId: number): EntityState { stats, relationships, inventory, + desires, }; } diff --git a/server/src/persistence/entitySerializer.ts b/server/src/persistence/entitySerializer.ts index 7a95d3a..deff5f1 100644 --- a/server/src/persistence/entitySerializer.ts +++ b/server/src/persistence/entitySerializer.ts @@ -8,6 +8,7 @@ import { getDatabase } from './database.js'; const SERIALIZABLE_COMPONENTS = [ 'needs', 'stats', 'appearance', 'npcBrain', 'socialState', 'statModifiers', 'backstory', 'inventory', 'structure', 'movement', + 'desires', ] as const; function serializeComponent(name: string, data: unknown): string { diff --git a/server/src/systems/__tests__/desireFulfillmentSystem.test.ts b/server/src/systems/__tests__/desireFulfillmentSystem.test.ts new file mode 100644 index 0000000..427f254 --- /dev/null +++ b/server/src/systems/__tests__/desireFulfillmentSystem.test.ts @@ -0,0 +1,231 @@ +import { describe, it, expect } from 'vitest'; +import { World } from '../../ecs/World.js'; +import { desireFulfillmentSystem } from '../desireFulfillmentSystem.js'; +import { RecipeRegistry } from '../../industry/recipeRegistry.js'; +import { ItemRegistry } from '../../industry/itemRegistry.js'; +import type { Desire } from '@dflike/shared'; + +function makeDesire(overrides: Partial & Pick): Desire { + return { + id: 'test-desire', + description: 'Test desire', + category: 'material', + priority: 0.5, + source: 'spawn', + createdAtTick: 0, + ...overrides, + }; +} + +describe('desireFulfillmentSystem', () => { + it('does nothing on non-interval ticks', () => { + const world = new World(); + const e = world.createEntity(); + const desire = makeDesire({ + fulfillment: { type: 'own_item', itemId: 'log', quantity: 1 }, + }); + world.addComponent(e, 'desires', [desire]); + // Give the NPC a log so it WOULD be fulfilled if checked + world.addComponent(e, 'inventory', new Map([['log', 5]])); + + desireFulfillmentSystem(world, 1); // not on interval + + const desires = world.getComponent(e, 'desires'); + expect(desires).toHaveLength(1); + }); + + it('removes desire when own_item fulfilled', () => { + const world = new World(); + const e = world.createEntity(); + const desire = makeDesire({ + fulfillment: { type: 'own_item', itemId: 'log', quantity: 3 }, + }); + world.addComponent(e, 'desires', [desire]); + world.addComponent(e, 'inventory', new Map([['log', 5]])); + + desireFulfillmentSystem(world, 100); + + const desires = world.getComponent(e, 'desires'); + expect(desires).toHaveLength(0); + }); + + it('keeps desire when own_item not fulfilled', () => { + const world = new World(); + const e = world.createEntity(); + const desire = makeDesire({ + fulfillment: { type: 'own_item', itemId: 'log', quantity: 10 }, + }); + world.addComponent(e, 'desires', [desire]); + world.addComponent(e, 'inventory', new Map([['log', 5]])); + + desireFulfillmentSystem(world, 100); + + const desires = world.getComponent(e, 'desires'); + expect(desires).toHaveLength(1); + }); + + it('fulfills structure_exists when matching completed structure found', () => { + const world = new World(); + const e = world.createEntity(); + const desire = makeDesire({ + fulfillment: { type: 'structure_exists', structureType: 'stockpile' }, + }); + world.addComponent(e, 'desires', [desire]); + + // Create a completed structure (buildProgress === undefined means complete) + const s = world.createEntity(); + world.addComponent(s, 'structure', { + type: 'stockpile', + subtype: 'general', + inventory: new Map(), + // buildProgress undefined = complete + }); + + desireFulfillmentSystem(world, 100); + + const desires = world.getComponent(e, 'desires'); + expect(desires).toHaveLength(0); + }); + + it('does NOT fulfill structure_exists for incomplete structures', () => { + const world = new World(); + const e = world.createEntity(); + const desire = makeDesire({ + fulfillment: { type: 'structure_exists', structureType: 'stockpile' }, + }); + world.addComponent(e, 'desires', [desire]); + + // Create an incomplete structure + const s = world.createEntity(); + world.addComponent(s, 'structure', { + type: 'stockpile', + subtype: 'general', + inventory: new Map(), + buildProgress: 0.5, + }); + + desireFulfillmentSystem(world, 100); + + const desires = world.getComponent(e, 'desires'); + expect(desires).toHaveLength(1); + }); + + it('fulfills relationship_tier criteria', () => { + const world = new World(); + const e = world.createEntity(); + const desire = makeDesire({ + fulfillment: { type: 'relationship_tier', tier: 'Friend', count: 1 }, + }); + world.addComponent(e, 'desires', [desire]); + + // Friend tier requires value >= 20 + const other = world.createEntity(); + const rels = new Map([[other, { + value: 25, + interactions: 5, + lastInteractionTick: 0, + status: 'active', + }]]); + world.addComponent(e, 'relationships', rels); + + desireFulfillmentSystem(world, 100); + + const desires = world.getComponent(e, 'desires'); + expect(desires).toHaveLength(0); + }); + + it('building_exists always returns false', () => { + const world = new World(); + const e = world.createEntity(); + const desire = makeDesire({ + fulfillment: { type: 'building_exists', buildingType: 'house' }, + }); + world.addComponent(e, 'desires', [desire]); + + desireFulfillmentSystem(world, 100); + + const desires = world.getComponent(e, 'desires'); + expect(desires).toHaveLength(1); + }); + + it('fulfills recipe_exists when matching recipe found in default registry', () => { + const world = new World(); + const e = world.createEntity(); + const desire = makeDesire({ + fulfillment: { type: 'recipe_exists', tag: 'wooden_axe' }, + }); + world.addComponent(e, 'desires', [desire]); + + const registry = RecipeRegistry.createDefault(); + world.setSingleton('recipeRegistry', registry); + + desireFulfillmentSystem(world, 100); + + const desires = world.getComponent(e, 'desires'); + expect(desires).toHaveLength(0); + }); + + it('keeps recipe_exists desire when no matching recipe', () => { + const world = new World(); + const e = world.createEntity(); + const desire = makeDesire({ + fulfillment: { type: 'recipe_exists', tag: 'nonexistent_recipe' }, + }); + world.addComponent(e, 'desires', [desire]); + + const registry = RecipeRegistry.createDefault(); + world.setSingleton('recipeRegistry', registry); + + desireFulfillmentSystem(world, 100); + + const desires = world.getComponent(e, 'desires'); + expect(desires).toHaveLength(1); + }); + + it('fulfills own_item_category when matching items found', () => { + const world = new World(); + const e = world.createEntity(); + const desire = makeDesire({ + fulfillment: { type: 'own_item_category', category: 'tool', quantity: 1 }, + }); + world.addComponent(e, 'desires', [desire]); + world.addComponent(e, 'inventory', new Map([['wooden_axe', 1]])); + + const itemRegistry = ItemRegistry.createDefault(); + world.setSingleton('itemRegistry', itemRegistry); + + desireFulfillmentSystem(world, 100); + + const desires = world.getComponent(e, 'desires'); + expect(desires).toHaveLength(0); + }); + + it('custom always returns false', () => { + const world = new World(); + const e = world.createEntity(); + const desire = makeDesire({ + fulfillment: { type: 'custom', check: 'some_check' }, + }); + world.addComponent(e, 'desires', [desire]); + + desireFulfillmentSystem(world, 100); + + const desires = world.getComponent(e, 'desires'); + expect(desires).toHaveLength(1); + }); + + it('only updates component when something changed', () => { + const world = new World(); + const e = world.createEntity(); + const desires = [ + makeDesire({ id: 'd1', fulfillment: { type: 'custom', check: 'x' } }), + ]; + world.addComponent(e, 'desires', desires); + + desireFulfillmentSystem(world, 100); + + // The same array reference should still be there (no update needed) + const result = world.getComponent(e, 'desires'); + expect(result).toBe(desires); + }); +}); diff --git a/server/src/systems/__tests__/desireGeneratorSystem.test.ts b/server/src/systems/__tests__/desireGeneratorSystem.test.ts new file mode 100644 index 0000000..83eef26 --- /dev/null +++ b/server/src/systems/__tests__/desireGeneratorSystem.test.ts @@ -0,0 +1,138 @@ +import { describe, it, expect, vi } from 'vitest'; +import { createDesireGeneratorSystem } from '../desireGeneratorSystem.js'; +import { World } from '../../ecs/World.js'; +import { ItemRegistry } from '../../industry/itemRegistry.js'; +import { RecipeRegistry } from '../../industry/recipeRegistry.js'; +import type { LlmService } from '../../llm/llmService.js'; +import type { Desire, Needs, Stats, NPCBrain, StatModifiers } from '@dflike/shared'; +import { desireConfig } from '../../config/desireConfig.js'; + +function createMockLlm(response: string | null = null): LlmService { + return { + generate: vi.fn().mockResolvedValue(response), + queueDepth: () => 0, + clear: () => {}, + destroy: () => {}, + activeModel: () => 'test-model', + usageStats: () => ({}), + usageSummary: () => '', + tokenUsage: () => ({ record() {}, getRecent() { return []; }, lastSummary() { return ''; } }), + } as unknown as LlmService; +} + +function setupWorld() { + const world = new World(); + const itemRegistry = ItemRegistry.createDefault(); + const recipeRegistry = RecipeRegistry.createDefault(); + world.setSingleton('itemRegistry', itemRegistry); + world.setSingleton('recipeRegistry', recipeRegistry); + return { world }; +} + +function addNPC(world: World, opts?: { + intelligence?: number; + curiosity?: number; + desireCount?: number; + noDesires?: boolean; +}) { + const e = world.createEntity(); + world.addComponent(e, 'needs', { hunger: 80, energy: 80, productivity: 20 }); + world.addComponent(e, 'npcBrain', { currentGoal: 'idle', 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', new Map([['log', 3]])); + world.addComponent(e, 'name', 'TestNPC'); + world.addComponent(e, 'backstory', 'A test NPC.'); + + if (!opts?.noDesires) { + const desires: Desire[] = []; + const count = opts?.desireCount ?? 0; + for (let i = 0; i < count; i++) { + desires.push({ + id: `desire_${i}`, + description: `Test desire ${i}`, + category: 'material', + fulfillment: { type: 'own_item', itemId: 'log', quantity: 1 }, + priority: 0.5, + source: 'spawn', + createdAtTick: 0, + }); + } + world.addComponent(e, 'desires', desires); + } + + return e; +} + +describe('desireGeneratorSystem', () => { + it('does not generate when NPC has max desires', () => { + const { world } = setupWorld(); + addNPC(world, { desireCount: desireConfig.maxDesires }); + const llm = createMockLlm(); + const system = createDesireGeneratorSystem(llm); + + vi.spyOn(Math, 'random').mockReturnValue(0); + system.update(world, desireConfig.periodicCheckInterval); + expect(llm.generate).not.toHaveBeenCalled(); + vi.restoreAllMocks(); + }); + + it('does not generate on non-interval ticks', () => { + const { world } = setupWorld(); + addNPC(world); + const llm = createMockLlm(); + const system = createDesireGeneratorSystem(llm); + + system.update(world, 1); + expect(llm.generate).not.toHaveBeenCalled(); + }); + + it('generates desire when NPC has open slots and probability passes', async () => { + const { world } = setupWorld(); + const entityId = addNPC(world, { desireCount: 0, curiosity: 14, intelligence: 14 }); + + const desireResponse = JSON.stringify({ + description: 'I want to build a cozy shelter', + category: 'shelter', + fulfillment: { type: 'structure_exists', structureType: 'shelter' }, + priority: 0.7, + }); + const llm = createMockLlm(desireResponse); + const system = createDesireGeneratorSystem(llm); + + const randomSpy = vi.spyOn(Math, 'random').mockReturnValue(0); + system.update(world, desireConfig.periodicCheckInterval); + randomSpy.mockRestore(); + + expect(llm.generate).toHaveBeenCalledTimes(1); + expect(llm.generate).toHaveBeenCalledWith('desireGeneration', expect.objectContaining({ + npcName: 'TestNPC', + })); + + await vi.waitFor(() => { + const desires = world.getComponent(entityId, 'desires'); + expect(desires).toHaveLength(1); + }); + + const desires = world.getComponent(entityId, 'desires')!; + expect(desires[0].source).toBe('periodic'); + expect(desires[0].category).toBe('shelter'); + }); + + it('skips NPC without desires component', () => { + const { world } = setupWorld(); + addNPC(world, { noDesires: true }); + const llm = createMockLlm(); + const system = createDesireGeneratorSystem(llm); + + vi.spyOn(Math, 'random').mockReturnValue(0); + system.update(world, desireConfig.periodicCheckInterval); + expect(llm.generate).not.toHaveBeenCalled(); + vi.restoreAllMocks(); + }); +}); diff --git a/server/src/systems/__tests__/desireLifecycle.test.ts b/server/src/systems/__tests__/desireLifecycle.test.ts new file mode 100644 index 0000000..2122d54 --- /dev/null +++ b/server/src/systems/__tests__/desireLifecycle.test.ts @@ -0,0 +1,60 @@ +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 '../../map/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); + }); +}); diff --git a/server/src/systems/__tests__/industrySystem.test.ts b/server/src/systems/__tests__/industrySystem.test.ts index 0e17a10..6efe77a 100644 --- a/server/src/systems/__tests__/industrySystem.test.ts +++ b/server/src/systems/__tests__/industrySystem.test.ts @@ -117,4 +117,37 @@ describe('industrySystem', () => { const brain = world.getComponent(e, 'npcBrain')!; expect(brain.currentGoal).toBe('craft'); }); + + 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); + 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'); + 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 craft something (either axe or hammer) + }); + }); }); diff --git a/server/src/systems/desireFulfillmentSystem.ts b/server/src/systems/desireFulfillmentSystem.ts new file mode 100644 index 0000000..6f54fe7 --- /dev/null +++ b/server/src/systems/desireFulfillmentSystem.ts @@ -0,0 +1,116 @@ +import type { Desire, FulfillmentCriteria } from '@dflike/shared'; +import type { World } from '../ecs/World.js'; +import type { StructureData } from './buildingSystem.js'; +import type { EventMemoryService } from '../llm/eventMemoryService.js'; +import { classify } from './relationshipHelpers.js'; +import { RecipeRegistry } from '../industry/recipeRegistry.js'; +import { ItemRegistry } from '../industry/itemRegistry.js'; +import { desireConfig } from '../config/desireConfig.js'; + +interface RelationshipData { + value: number; + interactions: number; + lastInteractionTick: number; + status: string; +} + +function checkFulfillment( + world: World, + entityId: number, + criteria: FulfillmentCriteria, +): boolean { + switch (criteria.type) { + case 'own_item': { + const inv = world.getComponent>(entityId, 'inventory'); + if (!inv) return false; + return (inv.get(criteria.itemId) ?? 0) >= criteria.quantity; + } + + case 'own_item_category': { + const inv = world.getComponent>(entityId, 'inventory'); + if (!inv) return false; + const itemRegistry = world.getSingleton('itemRegistry'); + if (!itemRegistry) return false; + let count = 0; + for (const [itemId, qty] of inv) { + const def = itemRegistry.get(itemId); + if (def && def.category === criteria.category) { + count += qty; + } + } + return count >= criteria.quantity; + } + + case 'structure_exists': { + for (const eid of world.query('structure')) { + const s = world.getComponent(eid, 'structure'); + if (s && s.type === criteria.structureType && s.buildProgress === undefined) { + return true; + } + } + return false; + } + + case 'building_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 (classify(rel.value) === criteria.tier) { + count++; + } + } + return count >= criteria.count; + } + + case 'recipe_exists': { + const registry = world.getSingleton('recipeRegistry'); + if (!registry) return false; + for (const recipe of registry.getAll()) { + if (recipe.outputItemId === criteria.tag || recipe.id.includes(criteria.tag)) { + return true; + } + } + return false; + } + + case 'custom': + return false; + + default: + return false; + } +} + +export function desireFulfillmentSystem( + world: World, + tick: number, + eventMemoryService?: EventMemoryService, +): void { + if (tick % desireConfig.fulfillmentCheckInterval !== 0) return; + + for (const entityId of world.query('desires')) { + const desires = world.getComponent(entityId, 'desires'); + if (!desires || desires.length === 0) continue; + + const remaining: Desire[] = []; + for (const desire of desires) { + if (checkFulfillment(world, entityId, desire.fulfillment)) { + eventMemoryService?.record(entityId, { + type: 'desire_fulfilled', + tick, + detail: desire.description, + }); + } else { + remaining.push(desire); + } + } + + if (remaining.length !== desires.length) { + world.addComponent(entityId, 'desires', remaining); + } + } +} diff --git a/server/src/systems/desireGeneratorSystem.ts b/server/src/systems/desireGeneratorSystem.ts new file mode 100644 index 0000000..474f73d --- /dev/null +++ b/server/src/systems/desireGeneratorSystem.ts @@ -0,0 +1,137 @@ +import type { EntityId, Desire, Stats } from '@dflike/shared'; +import type { World } from '../ecs/World.js'; +import type { LlmService } from '../llm/llmService.js'; +import type { EventMemoryService } from '../llm/eventMemoryService.js'; +import type { InventionTimeline } from '../industry/inventionTimeline.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, + eventMemoryService?: EventMemoryService, +): 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; + } + + function fireDesireRequest( + world: World, + entityId: number, + tick: number, + source: 'periodic' | 'event', + trigger?: string, + ): void { + if (pendingEntities.has(entityId)) return; + + const name = world.getComponent(entityId, 'name') ?? 'Unknown'; + const stats = world.getComponent(entityId, 'stats'); + const backstory = world.getComponent(entityId, 'backstory') ?? ''; + const currentDesires = world.getComponent(entityId, 'desires') ?? []; + + if (!stats) return; + + const context = getWorldContext(world); + const currentDesireList = currentDesires.map(d => d.description).join('; ') || 'none'; + + const recentEvents = eventMemoryService + ? eventMemoryService.getRecentEvents(entityId, 5).map(e => e.detail).join('; ') + : ''; + + const timeline = world.getSingleton('inventionTimeline'); + const recentInventions = timeline + ? timeline.getSummaries().slice(-5).map(s => s.name).join(', ') + : ''; + + const variables: Record = { + npcName: name, + stats: formatStatsForPrompt(stats), + backstory, + currentDesires: currentDesireList, + recentEvents: recentEvents || 'none', + recentInventions: recentInventions || 'none', + resourceTypes: context.resourceTypes, + recipeList: context.recipeList, + structureList: context.structureList, + }; + + if (trigger) { + variables.trigger = trigger; + } + + pendingEntities.add(entityId); + + llmService.generate('desireGeneration', variables).then(response => { + pendingEntities.delete(entityId); + if (!response) return; + + let parsed: unknown; + try { + parsed = JSON.parse(response); + } catch { + return; + } + + const desire = validateDesire(parsed, tick); + if (!desire) return; + + desire.source = source; + if (trigger) { + desire.sourceDetail = trigger; + } + + // Re-check capacity (may have changed during async request) + if (!canAddDesire(world, entityId)) return; + + const existingDesires = world.getComponent(entityId, 'desires'); + if (!existingDesires) return; + + world.addComponent(entityId, 'desires', [...existingDesires, desire]); + }).catch(() => { + pendingEntities.delete(entityId); + }); + } + + return { + update(world: World, tick: number): void { + if (tick % desireConfig.periodicCheckInterval !== 0) return; + + const npcs = world.query('desires'); + + for (const entityId of npcs) { + if (!canAddDesire(world, entityId)) continue; + if (pendingEntities.has(entityId)) continue; + + const curiosity = getEffectiveStat(world, entityId, 'curiosity'); + const intelligence = getEffectiveStat(world, entityId, 'intelligence'); + const composite = curiosity + intelligence; + + const chance = Math.max( + 0.05, + Math.min( + 0.8, + desireConfig.periodicBaseChance + (composite - 20) * desireConfig.periodicStatScale, + ), + ); + + if (Math.random() >= chance) continue; + + fireDesireRequest(world, entityId, tick, 'periodic'); + } + }, + + triggerEvent(world: World, entityId: number, trigger: string, tick: number): void { + if (!canAddDesire(world, entityId)) return; + fireDesireRequest(world, entityId, tick, 'event', trigger); + }, + }; +} diff --git a/server/src/systems/industrySystem.ts b/server/src/systems/industrySystem.ts index b315c5b..6270338 100644 --- a/server/src/systems/industrySystem.ts +++ b/server/src/systems/industrySystem.ts @@ -1,6 +1,6 @@ import { PRODUCTIVITY_THRESHOLD, - type Needs, type NPCBrain, type Position, + type Desire, type Needs, type NPCBrain, type Position, } from '@dflike/shared'; import type { World } from '../ecs/World.js'; import type { GameMap } from '../map/GameMap.js'; @@ -8,10 +8,39 @@ import type { GatheringState } from './gatheringSystem.js'; import type { CraftingState } from './craftingSystem.js'; import type { BuildingState, StructureData } from './buildingSystem.js'; import type { PickupState } from './pickupSystem.js'; -import { RecipeRegistry } from '../industry/recipeRegistry.js'; +import { RecipeRegistry, type Recipe } from '../industry/recipeRegistry.js'; +import { ItemRegistry } from '../industry/itemRegistry.js'; import { getEffectiveStat } from './statHelpers.js'; import { industryConfig } from '../config/industryConfig.js'; +function scoreCraftableRecipes(recipes: Recipe[], desires: Desire[], itemRegistry?: ItemRegistry): 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' && itemRegistry) { + const outputDef = itemRegistry.get(recipe.outputItemId); + if (outputDef && outputDef.category === f.category) { + score += desire.priority * 5; + } + } + } + if (score > bestScore) { + bestScore = score; + bestRecipe = recipe; + } + } + + return bestRecipe; +} + const BUILD_RECIPE_MAP: Record = { stockpile: { type: 'stockpile', subtype: 'general' }, workbench: { type: 'workshop', subtype: 'workbench' }, @@ -65,7 +94,9 @@ export function industrySystem(world: World, map: GameMap): void { // Try craft first const craftRecipes = registry.findCraftable(inv).filter(r => !r.id.startsWith('build_')); if (craftRecipes.length > 0) { - const recipe = craftRecipes[0]; + const desires = world.getComponent(entity, 'desires') ?? []; + const itemRegistry = world.getSingleton('itemRegistry'); + const recipe = scoreCraftableRecipes(craftRecipes, desires, itemRegistry); const dex = getEffectiveStat(world, entity, 'dexterity'); const baseTicks = industryConfig.craftBaseTicks; const ticks = Math.max(1, Math.round(baseTicks * (1 - (dex - 10) * industryConfig.craftDexterityModifier))); diff --git a/server/src/systems/inventionSystem.ts b/server/src/systems/inventionSystem.ts index 0b3c757..42fcf4e 100644 --- a/server/src/systems/inventionSystem.ts +++ b/server/src/systems/inventionSystem.ts @@ -1,4 +1,4 @@ -import type { EntityId, InventionSummary } from '@dflike/shared'; +import type { EntityId, InventionSummary, Desire } from '@dflike/shared'; import type { World } from '../ecs/World.js'; import type { LlmService } from '../llm/llmService.js'; import type { NarrationService } from '../llm/narrationService.js'; @@ -79,6 +79,11 @@ export function createInventionSystem( const emp = getEffectiveStat(world, entityId, 'empathy'); const tmp = getEffectiveStat(world, entityId, 'temperament'); + 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\n` + : ''; + pendingEntities.add(entityId); llmService.generate('invention', { @@ -95,6 +100,7 @@ export function createInventionSystem( temperament: String(tmp), materials: materialsList, knownItems, + desiresSection, }).then(response => { pendingEntities.delete(entityId); diff --git a/shared/src/types.ts b/shared/src/types.ts index 7415c2d..b8f43d8 100644 --- a/shared/src/types.ts +++ b/shared/src/types.ts @@ -13,7 +13,8 @@ export type MemoryEventType = | 'goal_change' | 'bond_formed' | 'bond_dissolved' | 'spawned' - | 'invention'; + | 'invention' + | 'desire_fulfilled' | 'desire_added'; export interface MemoryEvent { id: number; @@ -145,6 +146,7 @@ export interface EntityState { name?: string; // NPC display name backstory?: string; inventory?: Record; + desires?: { description: string; category: DesireCategory; }[]; socialState?: { phase: InteractionPhase; partnerId: EntityId | null; @@ -235,6 +237,36 @@ export interface StockpileLogEntry { tick: number; } +// 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; +} + // Server -> Client events export interface ServerEvents { 'world-state': (data: WorldState) => void;