feat: add desire generator system for periodic and event-driven desire creation
Factory-function system that generates new desires via LLM based on periodic stat-based probability checks and external event triggers. Tracks pending entities to prevent duplicate in-flight requests. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,138 @@
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { createDesireGeneratorSystem } from '../desireGeneratorSystem.js';
|
||||
import { World } from '../../ecs/World.js';
|
||||
import { ItemRegistry } from '../../industry/itemRegistry.js';
|
||||
import { RecipeRegistry } from '../../industry/recipeRegistry.js';
|
||||
import type { LlmService } from '../../llm/llmService.js';
|
||||
import type { Desire, Needs, Stats, NPCBrain, StatModifiers } from '@dflike/shared';
|
||||
import { desireConfig } from '../../config/desireConfig.js';
|
||||
|
||||
function createMockLlm(response: string | null = null): LlmService {
|
||||
return {
|
||||
generate: vi.fn().mockResolvedValue(response),
|
||||
queueDepth: () => 0,
|
||||
clear: () => {},
|
||||
destroy: () => {},
|
||||
activeModel: () => 'test-model',
|
||||
usageStats: () => ({}),
|
||||
usageSummary: () => '',
|
||||
tokenUsage: () => ({ record() {}, getRecent() { return []; }, lastSummary() { return ''; } }),
|
||||
} as unknown as LlmService;
|
||||
}
|
||||
|
||||
function setupWorld() {
|
||||
const world = new World();
|
||||
const itemRegistry = ItemRegistry.createDefault();
|
||||
const recipeRegistry = RecipeRegistry.createDefault();
|
||||
world.setSingleton('itemRegistry', itemRegistry);
|
||||
world.setSingleton('recipeRegistry', recipeRegistry);
|
||||
return { world };
|
||||
}
|
||||
|
||||
function addNPC(world: World, opts?: {
|
||||
intelligence?: number;
|
||||
curiosity?: number;
|
||||
desireCount?: number;
|
||||
noDesires?: boolean;
|
||||
}) {
|
||||
const e = world.createEntity();
|
||||
world.addComponent<Needs>(e, 'needs', { hunger: 80, energy: 80, productivity: 20 });
|
||||
world.addComponent<NPCBrain>(e, 'npcBrain', { currentGoal: 'idle', goalQueue: [] });
|
||||
world.addComponent<Stats>(e, 'stats', {
|
||||
strength: 10, dexterity: 10, constitution: 10,
|
||||
intelligence: opts?.intelligence ?? 10, perception: 10,
|
||||
sociability: 10, courage: 10, curiosity: opts?.curiosity ?? 10,
|
||||
empathy: 10, temperament: 10,
|
||||
});
|
||||
world.addComponent<StatModifiers>(e, 'statModifiers', { modifiers: [] });
|
||||
world.addComponent<Map<string, number>>(e, 'inventory', new Map([['log', 3]]));
|
||||
world.addComponent<string>(e, 'name', 'TestNPC');
|
||||
world.addComponent<string>(e, 'backstory', 'A test NPC.');
|
||||
|
||||
if (!opts?.noDesires) {
|
||||
const desires: Desire[] = [];
|
||||
const count = opts?.desireCount ?? 0;
|
||||
for (let i = 0; i < count; i++) {
|
||||
desires.push({
|
||||
id: `desire_${i}`,
|
||||
description: `Test desire ${i}`,
|
||||
category: 'material',
|
||||
fulfillment: { type: 'own_item', itemId: 'log', quantity: 1 },
|
||||
priority: 0.5,
|
||||
source: 'spawn',
|
||||
createdAtTick: 0,
|
||||
});
|
||||
}
|
||||
world.addComponent(e, 'desires', desires);
|
||||
}
|
||||
|
||||
return e;
|
||||
}
|
||||
|
||||
describe('desireGeneratorSystem', () => {
|
||||
it('does not generate when NPC has max desires', () => {
|
||||
const { world } = setupWorld();
|
||||
addNPC(world, { desireCount: desireConfig.maxDesires });
|
||||
const llm = createMockLlm();
|
||||
const system = createDesireGeneratorSystem(llm);
|
||||
|
||||
vi.spyOn(Math, 'random').mockReturnValue(0);
|
||||
system.update(world, desireConfig.periodicCheckInterval);
|
||||
expect(llm.generate).not.toHaveBeenCalled();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('does not generate on non-interval ticks', () => {
|
||||
const { world } = setupWorld();
|
||||
addNPC(world);
|
||||
const llm = createMockLlm();
|
||||
const system = createDesireGeneratorSystem(llm);
|
||||
|
||||
system.update(world, 1);
|
||||
expect(llm.generate).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('generates desire when NPC has open slots and probability passes', async () => {
|
||||
const { world } = setupWorld();
|
||||
const entityId = addNPC(world, { desireCount: 0, curiosity: 14, intelligence: 14 });
|
||||
|
||||
const desireResponse = JSON.stringify({
|
||||
description: 'I want to build a cozy shelter',
|
||||
category: 'shelter',
|
||||
fulfillment: { type: 'structure_exists', structureType: 'shelter' },
|
||||
priority: 0.7,
|
||||
});
|
||||
const llm = createMockLlm(desireResponse);
|
||||
const system = createDesireGeneratorSystem(llm);
|
||||
|
||||
const randomSpy = vi.spyOn(Math, 'random').mockReturnValue(0);
|
||||
system.update(world, desireConfig.periodicCheckInterval);
|
||||
randomSpy.mockRestore();
|
||||
|
||||
expect(llm.generate).toHaveBeenCalledTimes(1);
|
||||
expect(llm.generate).toHaveBeenCalledWith('desireGeneration', expect.objectContaining({
|
||||
npcName: 'TestNPC',
|
||||
}));
|
||||
|
||||
await vi.waitFor(() => {
|
||||
const desires = world.getComponent<Desire[]>(entityId, 'desires');
|
||||
expect(desires).toHaveLength(1);
|
||||
});
|
||||
|
||||
const desires = world.getComponent<Desire[]>(entityId, 'desires')!;
|
||||
expect(desires[0].source).toBe('periodic');
|
||||
expect(desires[0].category).toBe('shelter');
|
||||
});
|
||||
|
||||
it('skips NPC without desires component', () => {
|
||||
const { world } = setupWorld();
|
||||
addNPC(world, { noDesires: true });
|
||||
const llm = createMockLlm();
|
||||
const system = createDesireGeneratorSystem(llm);
|
||||
|
||||
vi.spyOn(Math, 'random').mockReturnValue(0);
|
||||
system.update(world, desireConfig.periodicCheckInterval);
|
||||
expect(llm.generate).not.toHaveBeenCalled();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,121 @@
|
||||
import type { EntityId, Desire, Stats } from '@dflike/shared';
|
||||
import type { World } from '../ecs/World.js';
|
||||
import type { LlmService } from '../llm/llmService.js';
|
||||
import { desireConfig } from '../config/desireConfig.js';
|
||||
import { getEffectiveStat } from './statHelpers.js';
|
||||
import { formatStatsForPrompt, getWorldContext, validateDesire } from '../llm/backstoryGenerator.js';
|
||||
|
||||
export interface DesireGeneratorSystem {
|
||||
update(world: World, tick: number): void;
|
||||
triggerEvent(world: World, entityId: number, trigger: string, tick: number): void;
|
||||
}
|
||||
|
||||
export function createDesireGeneratorSystem(llmService: LlmService): DesireGeneratorSystem {
|
||||
const pendingEntities = new Set<EntityId>();
|
||||
|
||||
function canAddDesire(world: World, entityId: number): boolean {
|
||||
const desires = world.getComponent<Desire[]>(entityId, 'desires');
|
||||
if (!desires) return false;
|
||||
return desires.length < desireConfig.maxDesires;
|
||||
}
|
||||
|
||||
function fireDesireRequest(
|
||||
world: World,
|
||||
entityId: number,
|
||||
tick: number,
|
||||
source: 'periodic' | 'event',
|
||||
trigger?: string,
|
||||
): void {
|
||||
if (pendingEntities.has(entityId)) return;
|
||||
|
||||
const name = world.getComponent<string>(entityId, 'name') ?? 'Unknown';
|
||||
const stats = world.getComponent<Stats>(entityId, 'stats');
|
||||
const backstory = world.getComponent<string>(entityId, 'backstory') ?? '';
|
||||
const currentDesires = world.getComponent<Desire[]>(entityId, 'desires') ?? [];
|
||||
|
||||
if (!stats) return;
|
||||
|
||||
const context = getWorldContext(world);
|
||||
const currentDesireList = currentDesires.map(d => d.description).join('; ') || 'none';
|
||||
|
||||
const variables: Record<string, string> = {
|
||||
npcName: name,
|
||||
stats: formatStatsForPrompt(stats),
|
||||
backstory,
|
||||
currentDesires: currentDesireList,
|
||||
resourceTypes: context.resourceTypes,
|
||||
recipeList: context.recipeList,
|
||||
structureList: context.structureList,
|
||||
};
|
||||
|
||||
if (trigger) {
|
||||
variables.trigger = trigger;
|
||||
}
|
||||
|
||||
pendingEntities.add(entityId);
|
||||
|
||||
llmService.generate('desireGeneration', variables).then(response => {
|
||||
pendingEntities.delete(entityId);
|
||||
if (!response) return;
|
||||
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(response);
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
|
||||
const desire = validateDesire(parsed, tick);
|
||||
if (!desire) return;
|
||||
|
||||
desire.source = source;
|
||||
if (trigger) {
|
||||
desire.sourceDetail = trigger;
|
||||
}
|
||||
|
||||
// Re-check capacity (may have changed during async request)
|
||||
if (!canAddDesire(world, entityId)) return;
|
||||
|
||||
const existingDesires = world.getComponent<Desire[]>(entityId, 'desires');
|
||||
if (!existingDesires) return;
|
||||
|
||||
world.addComponent(entityId, 'desires', [...existingDesires, desire]);
|
||||
}).catch(() => {
|
||||
pendingEntities.delete(entityId);
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
update(world: World, tick: number): void {
|
||||
if (tick % desireConfig.periodicCheckInterval !== 0) return;
|
||||
|
||||
const npcs = world.query('desires');
|
||||
|
||||
for (const entityId of npcs) {
|
||||
if (!canAddDesire(world, entityId)) continue;
|
||||
if (pendingEntities.has(entityId)) continue;
|
||||
|
||||
const curiosity = getEffectiveStat(world, entityId, 'curiosity');
|
||||
const intelligence = getEffectiveStat(world, entityId, 'intelligence');
|
||||
const composite = curiosity + intelligence;
|
||||
|
||||
const chance = Math.max(
|
||||
0.05,
|
||||
Math.min(
|
||||
0.8,
|
||||
desireConfig.periodicBaseChance + (composite - 20) * desireConfig.periodicStatScale,
|
||||
),
|
||||
);
|
||||
|
||||
if (Math.random() >= chance) continue;
|
||||
|
||||
fireDesireRequest(world, entityId, tick, 'periodic');
|
||||
}
|
||||
},
|
||||
|
||||
triggerEvent(world: World, entityId: number, trigger: string, tick: number): void {
|
||||
if (!canAddDesire(world, entityId)) return;
|
||||
fireDesireRequest(world, entityId, tick, 'event', trigger);
|
||||
},
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user