1acfc78e13
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>
183 lines
6.0 KiB
TypeScript
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('; ');
|
|
}
|