feat(thought): add ThoughtSystem with batching, cooldowns, and trigger detection

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
root
2026-03-08 21:08:30 +00:00
parent 4e5edda6c6
commit fb19f6cf8d
2 changed files with 274 additions and 0 deletions
+141
View File
@@ -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<EntityId>, 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<EntityId, number>();
let lastPeriodicTick = 0;
let pendingGeneration = false;
const system: ThoughtSystem = {
onThought: null,
update(world: World, followedEntityIds: Set<EntityId>, 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<Needs>(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<string>(entityId, 'name') ?? 'Unknown';
const stats = world.getComponent<Stats>(entityId, 'stats');
const needs = world.getComponent<Needs>(entityId, 'needs');
const brain = world.getComponent<any>(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('; ');
}