a8f27a869d
- desireFulfillmentSystem records memory events when desires are fulfilled - desireGeneratorSystem populates recentEvents and recentInventions template vars - industrySystem own_item_category scoring now checks recipe output category Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
138 lines
4.4 KiB
TypeScript
138 lines
4.4 KiB
TypeScript
import type { EntityId, Desire, Stats } from '@dflike/shared';
|
|
import type { World } from '../ecs/World.js';
|
|
import type { LlmService } from '../llm/llmService.js';
|
|
import type { EventMemoryService } from '../llm/eventMemoryService.js';
|
|
import type { InventionTimeline } from '../industry/inventionTimeline.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,
|
|
eventMemoryService?: EventMemoryService,
|
|
): 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 recentEvents = eventMemoryService
|
|
? eventMemoryService.getRecentEvents(entityId, 5).map(e => e.detail).join('; ')
|
|
: '';
|
|
|
|
const timeline = world.getSingleton<InventionTimeline>('inventionTimeline');
|
|
const recentInventions = timeline
|
|
? timeline.getSummaries().slice(-5).map(s => s.name).join(', ')
|
|
: '';
|
|
|
|
const variables: Record<string, string> = {
|
|
npcName: name,
|
|
stats: formatStatsForPrompt(stats),
|
|
backstory,
|
|
currentDesires: currentDesireList,
|
|
recentEvents: recentEvents || 'none',
|
|
recentInventions: recentInventions || 'none',
|
|
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);
|
|
},
|
|
};
|
|
}
|