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/desireGeneratorSystem.ts b/server/src/systems/desireGeneratorSystem.ts new file mode 100644 index 0000000..639005e --- /dev/null +++ b/server/src/systems/desireGeneratorSystem.ts @@ -0,0 +1,121 @@ +import type { EntityId, Desire, Stats } from '@dflike/shared'; +import type { World } from '../ecs/World.js'; +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; + } + + 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 variables: Record = { + npcName: name, + stats: formatStatsForPrompt(stats), + backstory, + currentDesires: currentDesireList, + 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); + }, + }; +}