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,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> = {}): 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> = {}): 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<Needs>): number {
|
||||
const entity = world.createEntity();
|
||||
world.addComponent(entity, 'name', name);
|
||||
world.addComponent(entity, 'npcBrain', { currentGoal: 'wander' });
|
||||
world.addComponent<Stats>(entity, 'stats', makeStats());
|
||||
world.addComponent<Needs>(entity, 'needs', { hunger: 80, energy: 80, ...needs });
|
||||
return entity;
|
||||
}
|
||||
|
||||
describe('ThoughtSystem', () => {
|
||||
let world: World;
|
||||
let llm: LlmService;
|
||||
let followedIds: Set<number>;
|
||||
|
||||
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();
|
||||
});
|
||||
});
|
||||
@@ -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