Files
dflike/server/src/systems/thoughtSystem.ts
T
root 1acfc78e13 refactor(llm): remove isDailyLimitReached from downstream consumers
The LlmService interface no longer exposes isDailyLimitReached — model
switching is handled internally. Remove all checks in thoughtSystem,
inventionSystem, narrationService, and thoughtGenerator. Update mocks
in test files to match the new interface and remove obsolete daily-limit
test cases.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 16:03:09 +00:00

183 lines
6.0 KiB
TypeScript

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<EntityId>, 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<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;
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 social = world.getComponent<SocialState>(entityId, 'socialState');
const brain = world.getComponent<any>(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<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, 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('; ');
}