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:
@@ -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('; ');
|
||||
}
|
||||
Reference in New Issue
Block a user