From fb19f6cf8d2001519aa6cccc831b5f1740b034b2 Mon Sep 17 00:00:00 2001 From: root Date: Sun, 8 Mar 2026 21:08:30 +0000 Subject: [PATCH] feat(thought): add ThoughtSystem with batching, cooldowns, and trigger detection Co-Authored-By: Claude Opus 4.6 --- .../systems/__tests__/thoughtSystem.test.ts | 133 +++++++++++++++++ server/src/systems/thoughtSystem.ts | 141 ++++++++++++++++++ 2 files changed, 274 insertions(+) create mode 100644 server/src/systems/__tests__/thoughtSystem.test.ts create mode 100644 server/src/systems/thoughtSystem.ts diff --git a/server/src/systems/__tests__/thoughtSystem.test.ts b/server/src/systems/__tests__/thoughtSystem.test.ts new file mode 100644 index 0000000..970dc99 --- /dev/null +++ b/server/src/systems/__tests__/thoughtSystem.test.ts @@ -0,0 +1,133 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { World } from '../../ecs/World.js'; +import { createThoughtSystem } from '../thoughtSystem.js'; +import type { LlmService } from '../../llm/llmService.js'; +import type { Stats, Needs } from '@dflike/shared'; + +function mockLlmService(overrides: Partial = {}): LlmService { + return { + generate: vi.fn().mockResolvedValue(null), + queueDepth: vi.fn().mockReturnValue(0), + clear: vi.fn(), + isDailyLimitReached: vi.fn().mockReturnValue(false), + ...overrides, + }; +} + +function makeStats(overrides: Partial = {}): Stats { + return { + strength: 10, dexterity: 10, constitution: 10, + intelligence: 10, perception: 10, + sociability: 10, courage: 10, curiosity: 10, + empathy: 10, temperament: 10, + ...overrides, + }; +} + +function makeNpc(world: World, name: string, needs?: Partial): number { + const entity = world.createEntity(); + world.addComponent(entity, 'name', name); + world.addComponent(entity, 'npcBrain', { currentGoal: 'wander' }); + world.addComponent(entity, 'stats', makeStats()); + world.addComponent(entity, 'needs', { hunger: 80, energy: 80, ...needs }); + return entity; +} + +describe('ThoughtSystem', () => { + let world: World; + let llm: LlmService; + let followedIds: Set; + + beforeEach(() => { + world = new World(); + llm = mockLlmService(); + followedIds = new Set(); + }); + + it('does not generate thoughts before interval elapses', () => { + const system = createThoughtSystem(llm); + const e = makeNpc(world, 'Bjorn'); + followedIds.add(e); + + for (let i = 0; i < 10; i++) { + system.update(world, followedIds, i + 1); + } + + expect(llm.generate).not.toHaveBeenCalled(); + }); + + it('generates thoughts after interval elapses for followed NPCs', async () => { + const response = '1. I feel like wandering today.'; + llm = mockLlmService({ + generate: vi.fn().mockResolvedValue(response), + }); + + const system = createThoughtSystem(llm); + const e = makeNpc(world, 'Bjorn'); + followedIds.add(e); + + system.update(world, followedIds, 900); + + expect(llm.generate).toHaveBeenCalledOnce(); + }); + + it('does not generate thoughts for non-followed NPCs on periodic tick', () => { + const system = createThoughtSystem(llm); + makeNpc(world, 'Bjorn'); + + system.update(world, followedIds, 900); + expect(llm.generate).not.toHaveBeenCalled(); + }); + + it('stores thought on entity after LLM response', async () => { + const response = '1. The sky looks strange today.'; + llm = mockLlmService({ + generate: vi.fn().mockResolvedValue(response), + }); + + const onThought = vi.fn(); + const system = createThoughtSystem(llm); + system.onThought = onThought; + const e = makeNpc(world, 'Bjorn'); + followedIds.add(e); + + system.update(world, followedIds, 900); + + await vi.waitFor(() => expect(onThought).toHaveBeenCalled()); + + const thought = world.getComponent(e, 'thought') as any; + expect(thought).toBeDefined(); + expect(thought.text).toBe('The sky looks strange today.'); + }); + + it('respects per-NPC cooldown', async () => { + const response = '1. Thinking.'; + llm = mockLlmService({ + generate: vi.fn().mockResolvedValue(response), + }); + + const system = createThoughtSystem(llm); + const e = makeNpc(world, 'Bjorn'); + followedIds.add(e); + + system.update(world, followedIds, 900); + await vi.waitFor(() => expect(llm.generate).toHaveBeenCalledOnce()); + + // Next interval — should be on cooldown (900+900=1800 needed, but only 1800 ticks elapsed) + system.update(world, followedIds, 1800); + expect(llm.generate).toHaveBeenCalledOnce(); // still just once — cooldown not expired yet + }); + + it('does not generate when daily limit reached', () => { + llm = mockLlmService({ + isDailyLimitReached: vi.fn().mockReturnValue(true), + }); + + const system = createThoughtSystem(llm); + const e = makeNpc(world, 'Bjorn'); + followedIds.add(e); + + system.update(world, followedIds, 900); + expect(llm.generate).not.toHaveBeenCalled(); + }); +}); diff --git a/server/src/systems/thoughtSystem.ts b/server/src/systems/thoughtSystem.ts new file mode 100644 index 0000000..3d88c3f --- /dev/null +++ b/server/src/systems/thoughtSystem.ts @@ -0,0 +1,141 @@ +import type { EntityId, Stats, Needs } 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'; + +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, +): 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; + if (llmService.isDailyLimitReached()) 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 context = determineTrigger(needs); + + eligible.push({ entityId, context }); + + if (eligible.length >= thoughtConfig.maxBatchSize) break; + } + + if (eligible.length === 0) return; + + const requests: ThoughtRequest[] = eligible.map(({ entityId }) => { + 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); + + 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): ThoughtContext { + if (needs) { + const { needCriticalThreshold } = thoughtConfig; + if (needs.hunger < needCriticalThreshold || needs.energy < needCriticalThreshold) { + return { + trigger: 'need_critical', + hunger: needs.hunger, + energy: needs.energy, + }; + } + } + 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, +): string { + if (!narrationService) return 'none'; + const events = narrationService.getEventsForEntity(entityId); + if (events.length === 0) return 'none'; + return events + .slice(-2) + .map((e) => e.narration) + .join('; '); +}