import type { EntityId, Stats, Needs, SocialState } from '@dflike/shared'; import type { World } from '../ecs/World.js'; import type { LlmService } from '../llm/llmService.js'; import { generateBatchedThoughts, type ThoughtRequest } from '../llm/thoughtGenerator.js'; import { pickThoughtEmoji, type ThoughtContext } from '../llm/thoughtEmoji.js'; import { formatStatsForPrompt } from '../llm/backstoryGenerator.js'; import { thoughtConfig } from '../config/thoughtConfig.js'; import type { NarrationService } from '../llm/narrationService.js'; import type { EventMemoryService } from '../llm/eventMemoryService.js'; export interface Thought { text: string; emoji: string; tick: number; } export interface ThoughtSystem { update(world: World, followedEntityIds: Set, tick: number): void; onThought: ((data: { entityId: EntityId; text: string; emoji: string }) => void) | null; } export function createThoughtSystem( llmService: LlmService, narrationService?: NarrationService, eventMemoryService?: EventMemoryService, ): ThoughtSystem { const cooldowns = new Map(); let lastPeriodicTick = 0; let pendingGeneration = false; const system: ThoughtSystem = { onThought: null, update(world: World, followedEntityIds: Set, tick: number): void { if (tick - lastPeriodicTick < thoughtConfig.periodicIntervalTicks) return; lastPeriodicTick = tick; if (pendingGeneration) return; const eligible: { entityId: EntityId; context: ThoughtContext }[] = []; const npcs = world.query('npcBrain', 'name', 'stats', 'needs'); for (const entityId of npcs) { if (!followedEntityIds.has(entityId)) continue; const cooldownUntil = cooldowns.get(entityId) ?? 0; if (tick < cooldownUntil) continue; const needs = world.getComponent(entityId, 'needs'); const social = world.getComponent(entityId, 'socialState'); const brain = world.getComponent(entityId, 'npcBrain'); const context = determineTrigger(needs, social, brain, tick); eligible.push({ entityId, context }); if (eligible.length >= thoughtConfig.maxBatchSize) break; } if (eligible.length === 0) return; const requests: ThoughtRequest[] = eligible.map(({ entityId, context }) => { const name = world.getComponent(entityId, 'name') ?? 'Unknown'; const stats = world.getComponent(entityId, 'stats'); const needs = world.getComponent(entityId, 'needs'); const brain = world.getComponent(entityId, 'npcBrain'); const personality = stats ? formatStatsForPrompt(stats) : ''; const currentState = describeState(needs, brain); const recentEvents = describeRecentEvents(entityId, narrationService, eventMemoryService, context.trigger); return { entityId, name, personality, currentState, recentEvents }; }); for (const { entityId } of eligible) { cooldowns.set(entityId, tick + thoughtConfig.perNpcCooldownTicks); } pendingGeneration = true; generateBatchedThoughts(requests, llmService) .then((results) => { for (const result of results) { const pending = eligible.find((p) => p.entityId === result.entityId); const emoji = pending ? pickThoughtEmoji(pending.context) : '\u{1F4AD}'; const thought: Thought = { text: result.text, emoji, tick, }; world.addComponent(result.entityId, 'thought', thought); system.onThought?.({ entityId: result.entityId, text: result.text, emoji }); } }) .catch(() => {}) .finally(() => { pendingGeneration = false; }); }, }; return system; } function determineTrigger( needs: Needs | undefined, social: SocialState | undefined, brain: any, tick: number, ): ThoughtContext { // Check for recent interaction (within last 100 ticks = 10 seconds) if (social?.lastOutcome && tick - social.lastOutcome.tick < 100) { return { trigger: 'post_interaction', interactionPositive: social.lastOutcome.outcome === 'positive', }; } // Check for critical needs if (needs) { const { needCriticalThreshold } = thoughtConfig; if (needs.hunger < needCriticalThreshold || needs.energy < needCriticalThreshold) { return { trigger: 'need_critical', hunger: needs.hunger, energy: needs.energy, }; } } // Check if idle/wandering if (!brain?.currentGoal || brain.currentGoal === 'wander') { return { trigger: 'idle' }; } return { trigger: 'periodic' }; } function describeState(needs: Needs | undefined, brain: any): string { const parts: string[] = []; if (needs) { if (needs.hunger < 30) parts.push('hungry'); if (needs.energy < 30) parts.push('tired'); if (parts.length === 0) parts.push('content'); } if (brain?.currentGoal) { parts.push(brain.currentGoal === 'wander' ? 'wandering' : brain.currentGoal); } return parts.join(', ') || 'idle'; } function describeRecentEvents( entityId: EntityId, narrationService?: NarrationService, eventMemoryService?: EventMemoryService, trigger?: ThoughtContext['trigger'], ): string { if (eventMemoryService) { const preferTypes = trigger === 'post_interaction' ? ['social_positive', 'social_negative', 'proposal_accepted', 'proposal_rejected'] as const : trigger === 'need_critical' ? ['need_crisis', 'need_recovery'] as const : undefined; const events = eventMemoryService.selectForPrompt(entityId, { maxCount: 5, preferTypes, }); if (events.length > 0) { return events.map(e => `- ${e.detail}`).join('\n'); } } // Fallback to narration service if (!narrationService) return 'none'; const events = narrationService.getEventsForEntity(entityId); if (events.length === 0) return 'none'; return events .slice(-2) .map((e) => e.narration) .join('; '); }