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); }, }; }