feat: NPC desire system — motivation layer for emergent behavior
Adds 0-3 personal desires per NPC that drive crafting priorities, influence invention, and create emergent stories. Hybrid LLM approach: traits generated at spawn + event-driven updates. Key components: - Desire types (material/social/shelter/comfort/community/creative) - Fulfillment checking system (7 criteria types, runs every 100 ticks) - Desire generator system (periodic + event-driven LLM generation) - Combined backstory+desires LLM generation at spawn - Industry system scores recipes by desire match - Desires passed into invention prompts - Persistence, wire protocol, and client display - All LLM templates reframed from medieval to frontier settlement Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
import type { EntityState, NarrationEvent, MemoryEvent, Stats, StatName } from '@dflike/shared';
|
||||
import type { EntityState, NarrationEvent, MemoryEvent, Stats, StatName, DesireCategory } from '@dflike/shared';
|
||||
import { attachTooltip } from './tooltip.js';
|
||||
|
||||
const ACTIVITY_LABELS: Record<string, string> = {
|
||||
@@ -32,6 +32,18 @@ const EB = {
|
||||
shine: 'rgba(200,200,255,0.08)', // Subtle window shine
|
||||
};
|
||||
|
||||
function desireCategoryColor(category: string): string {
|
||||
switch (category) {
|
||||
case 'material': return '#d4a574';
|
||||
case 'shelter': return '#c4956a';
|
||||
case 'comfort': return '#e8b87a';
|
||||
case 'social': return '#7ab8e8';
|
||||
case 'community': return '#7ae8a0';
|
||||
case 'creative': return '#c87ae8';
|
||||
default: return '#9898d0';
|
||||
}
|
||||
}
|
||||
|
||||
const STAT_FULL_NAMES: Record<string, string> = {
|
||||
STR: 'Strength', DEX: 'Dexterity', CON: 'Constitution', INT: 'Intelligence', PER: 'Perception',
|
||||
SOC: 'Sociability', COU: 'Courage', CUR: 'Curiosity', EMP: 'Empathy', TMP: 'Temperament',
|
||||
@@ -48,6 +60,7 @@ export class NpcInfoPanel {
|
||||
private needsBarsContainer: HTMLDivElement;
|
||||
private needsBars: Map<string, { wrapper: HTMLDivElement; fill: HTMLDivElement; valueEl: HTMLDivElement }> = new Map();
|
||||
private backstoryEl: HTMLDivElement;
|
||||
private desiresEl: HTMLDivElement;
|
||||
private statsContainer: HTMLDivElement;
|
||||
private statElements: Map<string, HTMLDivElement> = new Map();
|
||||
private statusTab!: HTMLDivElement;
|
||||
@@ -265,6 +278,16 @@ export class NpcInfoPanel {
|
||||
`;
|
||||
this.statusContent.appendChild(this.backstoryEl);
|
||||
|
||||
// Desires section
|
||||
this.desiresEl = document.createElement('div');
|
||||
this.desiresEl.style.cssText = `
|
||||
padding: 4px 8px;
|
||||
font-family: 'Press Start 2P', monospace;
|
||||
font-size: 10px;
|
||||
line-height: 1.8;
|
||||
`;
|
||||
this.statusContent.appendChild(this.desiresEl);
|
||||
|
||||
// Inner thought (italic first-person, shown above recent events)
|
||||
this.thoughtEl = document.createElement('div');
|
||||
this.thoughtEl.style.cssText = `
|
||||
@@ -441,6 +464,25 @@ export class NpcInfoPanel {
|
||||
this.backstoryEl.style.minHeight = '0';
|
||||
}
|
||||
|
||||
// Update desires
|
||||
const desires = entity.desires;
|
||||
if (desires && desires.length > 0) {
|
||||
let html = `<div style="color: ${EB.textMuted}; font-size: 9px; margin-bottom: 2px;">Desires</div>`;
|
||||
for (const desire of desires) {
|
||||
const color = desireCategoryColor(desire.category);
|
||||
html += `<div style="color: ${color}; padding: 1px 0;">◆ ${desire.description}</div>`;
|
||||
}
|
||||
const empty = 3 - desires.length;
|
||||
for (let i = 0; i < empty; i++) {
|
||||
html += `<div style="color: ${EB.textMuted}; font-style: italic; padding: 1px 0;">○ (daydreaming...)</div>`;
|
||||
}
|
||||
this.desiresEl.innerHTML = html;
|
||||
this.desiresEl.style.display = 'block';
|
||||
} else {
|
||||
this.desiresEl.innerHTML = `<div style="color: ${EB.textMuted}; font-size: 9px;">Content for now.</div>`;
|
||||
this.desiresEl.style.display = 'block';
|
||||
}
|
||||
|
||||
if (entity.needs) {
|
||||
this.updateNeeds(entity.needs);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
export const desireConfig = {
|
||||
maxDesires: 3,
|
||||
|
||||
// Fulfillment checking
|
||||
fulfillmentCheckInterval: 100, // ticks between checks
|
||||
|
||||
// Periodic generation
|
||||
periodicCheckInterval: 3333, // ~1 game-day worth of ticks (100/0.03)
|
||||
periodicBaseChance: 0.3, // 30% chance per check if slot available
|
||||
periodicStatScale: 0.05, // per point of (curiosity + intelligence - 20)
|
||||
|
||||
// Cooldown after fulfillment before slot reopens
|
||||
cooldownBaseTicks: 500,
|
||||
cooldownCuriosityScale: 0.05, // per curiosity point from 10, reduces cooldown
|
||||
|
||||
// Trigger sensitivity (base chance that an inflection event creates a desire)
|
||||
triggerBondChange: 0.6,
|
||||
triggerNewInvention: 0.4,
|
||||
triggerCrisisRecovery: 0.5,
|
||||
triggerObservation: 0.3,
|
||||
triggerDesireFulfilled: 0.5,
|
||||
|
||||
// Stat composite scaling for triggers
|
||||
triggerStatScale: 0.05, // per composite point from 20
|
||||
} as const;
|
||||
@@ -19,7 +19,10 @@ import { pickupSystem } from '../systems/pickupSystem.js';
|
||||
import { RecipeRegistry } from '../industry/recipeRegistry.js';
|
||||
import { spawnNPC } from './spawner.js';
|
||||
import { createLlmService, type LlmService } from '../llm/llmService.js';
|
||||
import { generateBackstory } from '../llm/backstoryGenerator.js';
|
||||
import { generateBackstoryAndDesires } from '../llm/backstoryGenerator.js';
|
||||
import { desireFulfillmentSystem } from '../systems/desireFulfillmentSystem.js';
|
||||
import { createDesireGeneratorSystem } from '../systems/desireGeneratorSystem.js';
|
||||
import type { DesireGeneratorSystem } from '../systems/desireGeneratorSystem.js';
|
||||
import { createNarrationService, type NarrationService } from '../llm/narrationService.js';
|
||||
import { narrationEmitter } from '../systems/narrationEmitter.js';
|
||||
import type { EntityId, InventionSummary, Position } from '@dflike/shared';
|
||||
@@ -44,6 +47,7 @@ export class GameLoop {
|
||||
readonly eventMemoryService: EventMemoryService;
|
||||
readonly thoughtSystem: ThoughtSystem;
|
||||
readonly inventionSystem: InventionSystem;
|
||||
private desireGeneratorSystem: DesireGeneratorSystem;
|
||||
public followedEntityIds: Set<EntityId> = new Set();
|
||||
public onInventionCreated: ((summary: InventionSummary) => void) | null = null;
|
||||
public onStockpileEvent: ((entry: StockpileLogEntry) => void) | null = null;
|
||||
@@ -66,6 +70,7 @@ export class GameLoop {
|
||||
this.eventMemoryService,
|
||||
(summary) => this.onInventionCreated?.(summary),
|
||||
);
|
||||
this.desireGeneratorSystem = createDesireGeneratorSystem(this.llmService, this.eventMemoryService);
|
||||
|
||||
this.saveManager = new SaveManager(options?.savePath ?? 'saves/default.db');
|
||||
|
||||
@@ -218,7 +223,7 @@ export class GameLoop {
|
||||
}
|
||||
|
||||
generateNpcBackstory(entityId: number): void {
|
||||
generateBackstory(this.world, entityId, this.llmService);
|
||||
generateBackstoryAndDesires(this.world, entityId, this.llmService, this.tick);
|
||||
}
|
||||
|
||||
private spawnInitialNPCs(count: number): void {
|
||||
@@ -274,6 +279,8 @@ export class GameLoop {
|
||||
socialSystem(this.world, this.eventMemoryService);
|
||||
narrationEmitter(this.world, this.narrationService, this.followedEntityIds, this.eventMemoryService);
|
||||
relationshipSystem(this.world, this.eventMemoryService);
|
||||
desireFulfillmentSystem(this.world, this.tick, this.eventMemoryService);
|
||||
this.desireGeneratorSystem.update(this.world, this.tick);
|
||||
industrySystem(this.world, this.map);
|
||||
pickupSystem(this.world, this.tick, (entry) => this.onStockpileEvent?.(entry));
|
||||
gatheringSystem(this.world, this.map);
|
||||
|
||||
@@ -3,7 +3,7 @@ import type { GameMap } from '../map/GameMap.js';
|
||||
import { generateRandomAppearance } from '../spawner/appearanceGenerator.js';
|
||||
import { generateName } from '../spawner/nameGenerator.js';
|
||||
import { generateStats } from '../spawner/statGenerator.js';
|
||||
import type { EntityId, Position, Needs, Movement, NPCBrain, Appearance, SocialState, Stats, StatModifiers, Relationships } from '@dflike/shared';
|
||||
import type { EntityId, Position, Needs, Movement, NPCBrain, Appearance, SocialState, Stats, StatModifiers, Relationships, Desire } from '@dflike/shared';
|
||||
import type { EventMemoryService } from '../llm/eventMemoryService.js';
|
||||
|
||||
export function spawnNPC(world: World, map: GameMap, positionHint?: Position, eventMemoryService?: EventMemoryService): EntityId {
|
||||
@@ -48,6 +48,7 @@ export function spawnNPC(world: World, map: GameMap, positionHint?: Position, ev
|
||||
world.addComponent<Relationships>(entity, 'relationships', new Map());
|
||||
world.addComponent<string>(entity, 'backstory', '');
|
||||
world.addComponent<Map<string, number>>(entity, 'inventory', new Map());
|
||||
world.addComponent<Desire[]>(entity, 'desires', []);
|
||||
|
||||
eventMemoryService?.record(entity, {
|
||||
type: 'spawned',
|
||||
|
||||
@@ -1,47 +1,312 @@
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { World } from '../../ecs/World.js';
|
||||
import { formatStatsForPrompt, generateBackstory } from '../backstoryGenerator.js';
|
||||
import {
|
||||
formatStatsForPrompt,
|
||||
generateBackstory,
|
||||
generateBackstoryAndDesires,
|
||||
getWorldContext,
|
||||
validateDesire,
|
||||
} from '../backstoryGenerator.js';
|
||||
import type { Stats } from '@dflike/shared';
|
||||
import type { LlmService } from '../llmService.js';
|
||||
import { ItemRegistry } from '../../industry/itemRegistry.js';
|
||||
import { RecipeRegistry } from '../../industry/recipeRegistry.js';
|
||||
|
||||
function createMockLlm(response: string | null = null) {
|
||||
return {
|
||||
generate: vi.fn().mockResolvedValue(response),
|
||||
queueDepth: () => 0,
|
||||
clear: () => {},
|
||||
activeModel: () => 'test-model',
|
||||
usageStats: () => ({}),
|
||||
usageSummary: () => '',
|
||||
tokenUsage: () => ({ record() {}, getRecent() { return []; }, lastSummary() { return ''; } }),
|
||||
} as unknown as LlmService;
|
||||
}
|
||||
|
||||
function createWorldWithSingletons(): World {
|
||||
const world = new World();
|
||||
world.setSingleton('itemRegistry', ItemRegistry.createDefault());
|
||||
world.setSingleton('recipeRegistry', RecipeRegistry.createDefault());
|
||||
return world;
|
||||
}
|
||||
|
||||
const defaultStats: Stats = {
|
||||
strength: 15, dexterity: 12, constitution: 14,
|
||||
intelligence: 8, perception: 10,
|
||||
sociability: 6, courage: 16, curiosity: 11,
|
||||
empathy: 7, temperament: 13,
|
||||
};
|
||||
|
||||
describe('formatStatsForPrompt', () => {
|
||||
it('formats stats into a readable string', () => {
|
||||
const stats: Stats = {
|
||||
strength: 15, dexterity: 12, constitution: 14,
|
||||
intelligence: 8, perception: 10,
|
||||
sociability: 6, courage: 16, curiosity: 11,
|
||||
empathy: 7, temperament: 13,
|
||||
};
|
||||
const result = formatStatsForPrompt(stats);
|
||||
const result = formatStatsForPrompt(defaultStats);
|
||||
expect(result).toBe(
|
||||
'strength:15, dexterity:12, constitution:14, intelligence:8, perception:10, sociability:6, courage:16, curiosity:11, empathy:7, temperament:13'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('generateBackstory', () => {
|
||||
describe('validateDesire', () => {
|
||||
it('returns a valid desire from correct input', () => {
|
||||
const raw = {
|
||||
description: 'Build a shelter',
|
||||
category: 'shelter',
|
||||
fulfillment: { type: 'structure_exists', structureType: 'shelter' },
|
||||
priority: 0.8,
|
||||
};
|
||||
const result = validateDesire(raw, 100);
|
||||
expect(result).not.toBeNull();
|
||||
expect(result!.description).toBe('Build a shelter');
|
||||
expect(result!.category).toBe('shelter');
|
||||
expect(result!.priority).toBe(0.8);
|
||||
expect(result!.source).toBe('spawn');
|
||||
expect(result!.createdAtTick).toBe(100);
|
||||
expect(result!.id).toMatch(/^desire_100_/);
|
||||
});
|
||||
|
||||
it('returns null for invalid category', () => {
|
||||
const raw = {
|
||||
description: 'Do something',
|
||||
category: 'invalid_category',
|
||||
fulfillment: { type: 'custom', check: 'something' },
|
||||
priority: 0.5,
|
||||
};
|
||||
expect(validateDesire(raw, 0)).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null for missing fulfillment', () => {
|
||||
const raw = {
|
||||
description: 'Do something',
|
||||
category: 'material',
|
||||
};
|
||||
expect(validateDesire(raw, 0)).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null for invalid fulfillment type', () => {
|
||||
const raw = {
|
||||
description: 'Do something',
|
||||
category: 'material',
|
||||
fulfillment: { type: 'nonexistent' },
|
||||
};
|
||||
expect(validateDesire(raw, 0)).toBeNull();
|
||||
});
|
||||
|
||||
it('clamps priority to 0-1 range', () => {
|
||||
const makeRaw = (priority: number) => ({
|
||||
description: 'Test',
|
||||
category: 'material',
|
||||
fulfillment: { type: 'own_item', itemId: 'log', quantity: 1 },
|
||||
priority,
|
||||
});
|
||||
|
||||
const high = validateDesire(makeRaw(5.0), 0);
|
||||
expect(high!.priority).toBe(1.0);
|
||||
|
||||
const low = validateDesire(makeRaw(-2.0), 0);
|
||||
expect(low!.priority).toBe(0.0);
|
||||
});
|
||||
|
||||
it('defaults priority to 0.5 when not a number', () => {
|
||||
const raw = {
|
||||
description: 'Test',
|
||||
category: 'material',
|
||||
fulfillment: { type: 'own_item', itemId: 'log', quantity: 1 },
|
||||
priority: 'high',
|
||||
};
|
||||
const result = validateDesire(raw, 0);
|
||||
expect(result!.priority).toBe(0.5);
|
||||
});
|
||||
|
||||
it('returns null for null or non-object input', () => {
|
||||
expect(validateDesire(null, 0)).toBeNull();
|
||||
expect(validateDesire('string', 0)).toBeNull();
|
||||
expect(validateDesire(42, 0)).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null for missing description', () => {
|
||||
const raw = {
|
||||
category: 'material',
|
||||
fulfillment: { type: 'own_item', itemId: 'log', quantity: 1 },
|
||||
};
|
||||
expect(validateDesire(raw, 0)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('getWorldContext', () => {
|
||||
it('returns resource types, recipes, and structures', () => {
|
||||
const world = createWorldWithSingletons();
|
||||
const ctx = getWorldContext(world);
|
||||
expect(ctx.resourceTypes).toContain('Log');
|
||||
expect(ctx.resourceTypes).toContain('Stone');
|
||||
expect(ctx.recipeList).toContain('craft_wooden_axe');
|
||||
expect(ctx.structureList).toBe('none');
|
||||
});
|
||||
|
||||
it('lists completed structures', () => {
|
||||
const world = createWorldWithSingletons();
|
||||
const s = world.createEntity();
|
||||
world.addComponent(s, 'structure', {
|
||||
type: 'stockpile', subtype: 'general',
|
||||
inventory: new Map(),
|
||||
});
|
||||
const ctx = getWorldContext(world);
|
||||
expect(ctx.structureList).toBe('stockpile:general');
|
||||
});
|
||||
|
||||
it('excludes structures still under construction', () => {
|
||||
const world = createWorldWithSingletons();
|
||||
const s = world.createEntity();
|
||||
world.addComponent(s, 'structure', {
|
||||
type: 'workshop', subtype: 'workbench',
|
||||
inventory: new Map(), buildProgress: 0.5,
|
||||
});
|
||||
const ctx = getWorldContext(world);
|
||||
expect(ctx.structureList).toBe('none');
|
||||
});
|
||||
});
|
||||
|
||||
describe('generateBackstoryAndDesires', () => {
|
||||
it('sets backstory and desires from valid JSON response', async () => {
|
||||
const world = createWorldWithSingletons();
|
||||
const entity = world.createEntity();
|
||||
world.addComponent(entity, 'name', 'Brynn');
|
||||
world.addComponent(entity, 'stats', defaultStats);
|
||||
|
||||
const llmResponse = JSON.stringify({
|
||||
backstory: 'A quiet wanderer drawn to the wild.',
|
||||
desires: [
|
||||
{
|
||||
description: 'Craft a wooden axe',
|
||||
category: 'material',
|
||||
fulfillment: { type: 'own_item', itemId: 'wooden_axe', quantity: 1 },
|
||||
priority: 0.9,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const mockLlm = createMockLlm(llmResponse);
|
||||
await generateBackstoryAndDesires(world, entity, mockLlm, 50);
|
||||
|
||||
expect(world.getComponent<string>(entity, 'backstory')).toBe(
|
||||
'A quiet wanderer drawn to the wild.'
|
||||
);
|
||||
const desires = world.getComponent<unknown[]>(entity, 'desires');
|
||||
expect(desires).toHaveLength(1);
|
||||
expect((desires![0] as { description: string }).description).toBe('Craft a wooden axe');
|
||||
});
|
||||
|
||||
it('falls back to raw backstory on invalid JSON', async () => {
|
||||
const world = createWorldWithSingletons();
|
||||
const entity = world.createEntity();
|
||||
world.addComponent(entity, 'name', 'Thom');
|
||||
world.addComponent(entity, 'stats', defaultStats);
|
||||
|
||||
const mockLlm = createMockLlm('A bold settler with fiery temper.');
|
||||
await generateBackstoryAndDesires(world, entity, mockLlm, 0);
|
||||
|
||||
expect(world.getComponent<string>(entity, 'backstory')).toBe(
|
||||
'A bold settler with fiery temper.'
|
||||
);
|
||||
expect(world.getComponent(entity, 'desires')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('does nothing when LLM returns null', async () => {
|
||||
const world = createWorldWithSingletons();
|
||||
const entity = world.createEntity();
|
||||
world.addComponent(entity, 'name', 'Thom');
|
||||
world.addComponent(entity, 'stats', defaultStats);
|
||||
|
||||
const mockLlm = createMockLlm(null);
|
||||
await generateBackstoryAndDesires(world, entity, mockLlm, 0);
|
||||
|
||||
expect(world.getComponent(entity, 'backstory')).toBeUndefined();
|
||||
expect(world.getComponent(entity, 'desires')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('passes world context variables to LLM', async () => {
|
||||
const world = createWorldWithSingletons();
|
||||
const entity = world.createEntity();
|
||||
world.addComponent(entity, 'name', 'Ava');
|
||||
world.addComponent(entity, 'stats', defaultStats);
|
||||
|
||||
const mockLlm = createMockLlm(null);
|
||||
await generateBackstoryAndDesires(world, entity, mockLlm, 0);
|
||||
|
||||
expect(mockLlm.generate).toHaveBeenCalledWith('backstoryAndDesires', expect.objectContaining({
|
||||
npcName: 'Ava',
|
||||
resourceTypes: expect.stringContaining('Log'),
|
||||
recipeList: expect.stringContaining('craft_wooden_axe'),
|
||||
structureList: 'none',
|
||||
}));
|
||||
});
|
||||
|
||||
it('limits desires to 2 maximum', async () => {
|
||||
const world = createWorldWithSingletons();
|
||||
const entity = world.createEntity();
|
||||
world.addComponent(entity, 'name', 'Brynn');
|
||||
world.addComponent(entity, 'stats', defaultStats);
|
||||
|
||||
const llmResponse = JSON.stringify({
|
||||
backstory: 'Test.',
|
||||
desires: [
|
||||
{ description: 'D1', category: 'material', fulfillment: { type: 'own_item', itemId: 'log', quantity: 1 }, priority: 0.5 },
|
||||
{ description: 'D2', category: 'social', fulfillment: { type: 'relationship_tier', tier: 'friend' }, priority: 0.6 },
|
||||
{ description: 'D3', category: 'shelter', fulfillment: { type: 'structure_exists', structureType: 'shelter' }, priority: 0.7 },
|
||||
],
|
||||
});
|
||||
|
||||
const mockLlm = createMockLlm(llmResponse);
|
||||
await generateBackstoryAndDesires(world, entity, mockLlm, 0);
|
||||
|
||||
const desires = world.getComponent<unknown[]>(entity, 'desires');
|
||||
expect(desires).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('skips invalid desires but keeps valid ones', async () => {
|
||||
const world = createWorldWithSingletons();
|
||||
const entity = world.createEntity();
|
||||
world.addComponent(entity, 'name', 'Brynn');
|
||||
world.addComponent(entity, 'stats', defaultStats);
|
||||
|
||||
const llmResponse = JSON.stringify({
|
||||
backstory: 'Test.',
|
||||
desires: [
|
||||
{ description: 'Bad', category: 'invalid', fulfillment: { type: 'own_item' }, priority: 0.5 },
|
||||
{ description: 'Good', category: 'material', fulfillment: { type: 'own_item', itemId: 'log', quantity: 1 }, priority: 0.8 },
|
||||
],
|
||||
});
|
||||
|
||||
const mockLlm = createMockLlm(llmResponse);
|
||||
await generateBackstoryAndDesires(world, entity, mockLlm, 0);
|
||||
|
||||
const desires = world.getComponent<unknown[]>(entity, 'desires');
|
||||
expect(desires).toHaveLength(1);
|
||||
expect((desires![0] as { description: string }).description).toBe('Good');
|
||||
});
|
||||
|
||||
it('does nothing if entity is missing components', async () => {
|
||||
const world = createWorldWithSingletons();
|
||||
const entity = world.createEntity();
|
||||
|
||||
const mockLlm = createMockLlm('text');
|
||||
await generateBackstoryAndDesires(world, entity, mockLlm, 0);
|
||||
expect(mockLlm.generate).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('generateBackstory (legacy)', () => {
|
||||
it('calls LLM service with correct template and variables', async () => {
|
||||
const world = new World();
|
||||
const entity = world.createEntity();
|
||||
world.addComponent(entity, 'name', 'Brynn');
|
||||
world.addComponent(entity, 'backstory', '');
|
||||
world.addComponent<Stats>(entity, 'stats', {
|
||||
strength: 15, dexterity: 12, constitution: 14,
|
||||
intelligence: 8, perception: 10,
|
||||
sociability: 6, courage: 16, curiosity: 11,
|
||||
empathy: 7, temperament: 13,
|
||||
});
|
||||
world.addComponent<Stats>(entity, 'stats', defaultStats);
|
||||
|
||||
const mockService: LlmService = {
|
||||
generate: vi.fn().mockResolvedValue('A brave warrior from the north.'),
|
||||
queueDepth: () => 0,
|
||||
clear: () => {},
|
||||
tokenUsage: () => ({ record() {}, getRecent() { return []; }, lastSummary() { return ''; } }),
|
||||
};
|
||||
const mockLlm = createMockLlm('A brave warrior from the north.');
|
||||
await generateBackstory(world, entity, mockLlm);
|
||||
|
||||
await generateBackstory(world, entity, mockService);
|
||||
|
||||
expect(mockService.generate).toHaveBeenCalledWith('backstory', {
|
||||
expect(mockLlm.generate).toHaveBeenCalledWith('backstory', {
|
||||
npcName: 'Brynn',
|
||||
stats: 'strength:15, dexterity:12, constitution:14, intelligence:8, perception:10, sociability:6, courage:16, curiosity:11, empathy:7, temperament:13',
|
||||
});
|
||||
@@ -63,14 +328,8 @@ describe('generateBackstory', () => {
|
||||
empathy: 10, temperament: 10,
|
||||
});
|
||||
|
||||
const mockService: LlmService = {
|
||||
generate: vi.fn().mockResolvedValue(null),
|
||||
queueDepth: () => 0,
|
||||
clear: () => {},
|
||||
tokenUsage: () => ({ record() {}, getRecent() { return []; }, lastSummary() { return ''; } }),
|
||||
};
|
||||
|
||||
await generateBackstory(world, entity, mockService);
|
||||
const mockLlm = createMockLlm(null);
|
||||
await generateBackstory(world, entity, mockLlm);
|
||||
|
||||
expect(world.getComponent<string>(entity, 'backstory')).toBe('');
|
||||
});
|
||||
@@ -79,14 +338,8 @@ describe('generateBackstory', () => {
|
||||
const world = new World();
|
||||
const entity = world.createEntity();
|
||||
|
||||
const mockService: LlmService = {
|
||||
generate: vi.fn().mockResolvedValue('text'),
|
||||
queueDepth: () => 0,
|
||||
clear: () => {},
|
||||
tokenUsage: () => ({ record() {}, getRecent() { return []; }, lastSummary() { return ''; } }),
|
||||
};
|
||||
|
||||
await generateBackstory(world, entity, mockService);
|
||||
expect(mockService.generate).not.toHaveBeenCalled();
|
||||
const mockLlm = createMockLlm('text');
|
||||
await generateBackstory(world, entity, mockLlm);
|
||||
expect(mockLlm.generate).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,16 +1,142 @@
|
||||
import type { Stats } from '@dflike/shared';
|
||||
import type { Stats, Desire, FulfillmentCriteria, DesireCategory } from '@dflike/shared';
|
||||
import type { World } from '../ecs/World.js';
|
||||
import type { LlmService } from './llmService.js';
|
||||
import type { ItemRegistry } from '../industry/itemRegistry.js';
|
||||
import type { RecipeRegistry } from '../industry/recipeRegistry.js';
|
||||
import type { StructureData } from '../systems/buildingSystem.js';
|
||||
|
||||
const STAT_KEYS: (keyof Stats)[] = [
|
||||
'strength', 'dexterity', 'constitution', 'intelligence', 'perception',
|
||||
'sociability', 'courage', 'curiosity', 'empathy', 'temperament',
|
||||
];
|
||||
|
||||
const VALID_CATEGORIES: DesireCategory[] = [
|
||||
'material', 'social', 'shelter', 'comfort', 'community', 'creative',
|
||||
];
|
||||
|
||||
const VALID_FULFILLMENT_TYPES = [
|
||||
'own_item', 'own_item_category', 'structure_exists', 'building_exists',
|
||||
'relationship_tier', 'recipe_exists', 'custom',
|
||||
];
|
||||
|
||||
export function formatStatsForPrompt(stats: Stats): string {
|
||||
return STAT_KEYS.map(k => `${k}:${Math.floor(stats[k])}`).join(', ');
|
||||
}
|
||||
|
||||
export function getWorldContext(world: World): {
|
||||
resourceTypes: string;
|
||||
recipeList: string;
|
||||
structureList: string;
|
||||
} {
|
||||
const itemRegistry = world.getSingleton<ItemRegistry>('itemRegistry');
|
||||
const recipeRegistry = world.getSingleton<RecipeRegistry>('recipeRegistry');
|
||||
|
||||
const resourceTypes = itemRegistry
|
||||
? itemRegistry.getByCategory('resource').map(i => i.name).join(', ')
|
||||
: 'none';
|
||||
|
||||
const recipeList = recipeRegistry
|
||||
? recipeRegistry.getAll().map(r => `${r.id} (makes ${r.outputItemId})`).join(', ')
|
||||
: 'none';
|
||||
|
||||
// Find completed structures
|
||||
const structureEntities = world.query('structure');
|
||||
const structures: string[] = [];
|
||||
for (const eid of structureEntities) {
|
||||
const s = world.getComponent<StructureData>(eid, 'structure');
|
||||
if (s && s.buildProgress === undefined) {
|
||||
structures.push(`${s.type}:${s.subtype}`);
|
||||
}
|
||||
}
|
||||
const structureList = structures.length > 0 ? structures.join(', ') : 'none';
|
||||
|
||||
return { resourceTypes, recipeList, structureList };
|
||||
}
|
||||
|
||||
export function validateDesire(raw: unknown, tick: number): Desire | null {
|
||||
if (!raw || typeof raw !== 'object') return null;
|
||||
|
||||
const obj = raw as Record<string, unknown>;
|
||||
|
||||
if (typeof obj.description !== 'string' || !obj.description) return null;
|
||||
|
||||
if (!VALID_CATEGORIES.includes(obj.category as DesireCategory)) return null;
|
||||
|
||||
if (!obj.fulfillment || typeof obj.fulfillment !== 'object') return null;
|
||||
const fulfillment = obj.fulfillment as Record<string, unknown>;
|
||||
if (!VALID_FULFILLMENT_TYPES.includes(fulfillment.type as string)) return null;
|
||||
|
||||
let priority = typeof obj.priority === 'number' ? obj.priority : 0.5;
|
||||
priority = Math.max(0, Math.min(1, priority));
|
||||
|
||||
const id = `desire_${tick}_${Math.random().toString(36).slice(2, 8)}`;
|
||||
|
||||
return {
|
||||
id,
|
||||
description: obj.description as string,
|
||||
category: obj.category as DesireCategory,
|
||||
fulfillment: obj.fulfillment as FulfillmentCriteria,
|
||||
priority,
|
||||
source: 'spawn',
|
||||
createdAtTick: tick,
|
||||
};
|
||||
}
|
||||
|
||||
export async function generateBackstoryAndDesires(
|
||||
world: World,
|
||||
entityId: number,
|
||||
llmService: LlmService,
|
||||
tick: number,
|
||||
): Promise<void> {
|
||||
const name = world.getComponent<string>(entityId, 'name');
|
||||
const stats = world.getComponent<Stats>(entityId, 'stats');
|
||||
if (!name || !stats) return;
|
||||
|
||||
const context = getWorldContext(world);
|
||||
|
||||
const result = await llmService.generate('backstoryAndDesires', {
|
||||
npcName: name,
|
||||
stats: formatStatsForPrompt(stats),
|
||||
resourceTypes: context.resourceTypes,
|
||||
recipeList: context.recipeList,
|
||||
structureList: context.structureList,
|
||||
});
|
||||
|
||||
if (!result) return;
|
||||
|
||||
let parsed: { backstory?: string; desires?: unknown[] } | null = null;
|
||||
try {
|
||||
parsed = JSON.parse(result);
|
||||
} catch {
|
||||
// JSON parse failed — use raw string as backstory fallback
|
||||
world.addComponent<string>(entityId, 'backstory', result);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!parsed || typeof parsed !== 'object') {
|
||||
world.addComponent<string>(entityId, 'backstory', result);
|
||||
return;
|
||||
}
|
||||
|
||||
if (typeof parsed.backstory === 'string' && parsed.backstory) {
|
||||
world.addComponent<string>(entityId, 'backstory', parsed.backstory);
|
||||
}
|
||||
|
||||
if (Array.isArray(parsed.desires)) {
|
||||
const desires: Desire[] = [];
|
||||
for (const rawDesire of parsed.desires.slice(0, 2)) {
|
||||
const desire = validateDesire(rawDesire, tick);
|
||||
if (desire) {
|
||||
desires.push(desire);
|
||||
}
|
||||
}
|
||||
if (desires.length > 0) {
|
||||
world.addComponent(entityId, 'desires', desires);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** @deprecated Use generateBackstoryAndDesires instead */
|
||||
export async function generateBackstory(
|
||||
world: World,
|
||||
entityId: number,
|
||||
|
||||
+68
-12
@@ -4,11 +4,12 @@ export const templates: Record<string, PromptTemplate> = {
|
||||
backstory: {
|
||||
name: 'backstory',
|
||||
systemPrompt:
|
||||
'You are a narrator for a medieval fantasy village simulation. ' +
|
||||
'Write brief, evocative NPC backstories. Keep responses to 1-2 sentences. ' +
|
||||
'Do not use cliches. Ground details in daily village life.',
|
||||
'You narrate for a simulation of settlers founding a new community in untamed wilderness. ' +
|
||||
'There is no existing civilization — no shops, guilds, or infrastructure. ' +
|
||||
'Write brief, evocative settler backstories. Keep responses to 1-2 sentences. ' +
|
||||
'Do not reference professions or institutions that do not exist. Ground details in personality.',
|
||||
userPrompt:
|
||||
'Generate a short backstory for an NPC named {{npcName}}.\n' +
|
||||
'Generate a short backstory for a settler named {{npcName}}.\n' +
|
||||
'Their stats: {{stats}}\n' +
|
||||
'The backstory should reflect their personality stats. ' +
|
||||
'High sociability means outgoing, low means reclusive. ' +
|
||||
@@ -20,7 +21,7 @@ export const templates: Record<string, PromptTemplate> = {
|
||||
socialNarration: {
|
||||
name: 'socialNarration',
|
||||
systemPrompt:
|
||||
'You are a narrator for a medieval fantasy village simulation. ' +
|
||||
'You narrate for a simulation of settlers in a fledgling wilderness community. ' +
|
||||
'Describe NPC social interactions in 1 sentence. ' +
|
||||
'Be specific and grounded. No purple prose.',
|
||||
userPrompt:
|
||||
@@ -34,20 +35,20 @@ export const templates: Record<string, PromptTemplate> = {
|
||||
batchedThoughts: {
|
||||
name: 'batchedThoughts',
|
||||
systemPrompt:
|
||||
'You voice the inner thoughts of NPCs in a medieval village simulation. ' +
|
||||
'You voice the inner thoughts of settlers in a fledgling wilderness community. ' +
|
||||
'Write one brief thought per NPC in first person (1 sentence each). ' +
|
||||
'Reflect their personality and current situation. Be natural, not dramatic. ' +
|
||||
'Respond with numbered lines matching the input.',
|
||||
userPrompt:
|
||||
'Generate a brief inner thought for each NPC:\n\n{{npcList}}',
|
||||
'Generate a brief inner thought for each settler:\n\n{{npcList}}',
|
||||
},
|
||||
|
||||
invention: {
|
||||
name: 'invention',
|
||||
systemPrompt:
|
||||
'You are an inventor in a medieval fantasy village simulation. ' +
|
||||
'Given available materials and an NPC\'s stats, consider 3 possible inventions, ' +
|
||||
'then select the one that best fits the NPC\'s personality. ' +
|
||||
'You are an inventor in a fledgling wilderness settlement. ' +
|
||||
'Given available materials and a settler\'s stats, consider 3 possible inventions, ' +
|
||||
'then select the one that best fits the settler\'s personality. ' +
|
||||
'Respond ONLY with a valid JSON object. No explanation, no markdown, just JSON.',
|
||||
userPrompt:
|
||||
'{{npcName}} is having a creative moment.\n\n' +
|
||||
@@ -55,16 +56,71 @@ export const templates: Record<string, PromptTemplate> = {
|
||||
'Strength:{{strength}} Dexterity:{{dexterity}} Constitution:{{constitution}} Intelligence:{{intelligence}}\n' +
|
||||
'Perception:{{perception}} Sociability:{{sociability}} Courage:{{courage}} Curiosity:{{curiosity}}\n' +
|
||||
'Empathy:{{empathy}} Temperament:{{temperament}}\n\n' +
|
||||
'{{desiresSection}}' +
|
||||
'Available materials:\n{{materials}}\n\n' +
|
||||
'Known items (do not reinvent):\n{{knownItems}}\n\n' +
|
||||
'Consider 3 possible inventions using 2-3 existing materials, then pick\n' +
|
||||
'the one that best matches this NPC\'s personality and stats.\n' +
|
||||
'the one that best matches this settler\'s personality and stats.\n' +
|
||||
'Use Title Case for the item name.\n\n' +
|
||||
'Respond with JSON:\n' +
|
||||
'{"name": "Item Name", "description": "brief flavor text", ' +
|
||||
'"reasoning": "why this suits the NPC", ' +
|
||||
'"reasoning": "why this suits the settler", ' +
|
||||
'"category": "resource|tool|material|structure", ' +
|
||||
'"inputs": [{"itemId": "existing_item_id", "quantity": N}], ' +
|
||||
'"workshopType": null, "toolRequired": null}',
|
||||
},
|
||||
|
||||
backstoryAndDesires: {
|
||||
name: 'backstoryAndDesires',
|
||||
systemPrompt:
|
||||
'You narrate for a simulation of settlers founding a new community in untamed wilderness. ' +
|
||||
'There is no existing civilization — no shops, guilds, or infrastructure. ' +
|
||||
'The world is raw: trees, stone, water, wild food. Everything must be built from scratch.\n\n' +
|
||||
'Write a brief backstory (1-2 sentences) and 1-2 initial desires for this settler. ' +
|
||||
'The backstory should reflect their personality without referencing professions or institutions that do not exist. ' +
|
||||
'Desires should be grounded in what is achievable or aspirational given the current world state.\n\n' +
|
||||
'Respond ONLY with a valid JSON object. No explanation, no markdown, just JSON.',
|
||||
userPrompt:
|
||||
'New settler: {{npcName}}\n' +
|
||||
'Stats (each ranges 3-18, 10 is average): {{stats}}\n\n' +
|
||||
'Current world state:\n' +
|
||||
'- Available resources: {{resourceTypes}}\n' +
|
||||
'- Known recipes: {{recipeList}}\n' +
|
||||
'- Existing structures: {{structureList}}\n\n' +
|
||||
'Respond with JSON:\n' +
|
||||
'{"backstory": "1-2 sentence backstory", "desires": [' +
|
||||
'{"description": "human-readable desire", ' +
|
||||
'"category": "material|social|shelter|comfort|community|creative", ' +
|
||||
'"fulfillment": {"type": "own_item|structure_exists|building_exists|relationship_tier|recipe_exists|custom", ...criteria}, ' +
|
||||
'"priority": 0.0-1.0, ' +
|
||||
'"reasoning": "why this fits the settler"}]}',
|
||||
},
|
||||
|
||||
desireGeneration: {
|
||||
name: 'desireGeneration',
|
||||
systemPrompt:
|
||||
'You narrate for a simulation of settlers founding a new community in untamed wilderness. ' +
|
||||
'There is no existing civilization — no shops, guilds, or infrastructure. ' +
|
||||
'Generate a new personal desire for this settler based on their personality and recent experiences.\n\n' +
|
||||
'Respond ONLY with a valid JSON object. No explanation, no markdown, just JSON.',
|
||||
userPrompt:
|
||||
'Settler: {{npcName}}\n' +
|
||||
'Stats: {{stats}}\n' +
|
||||
'Backstory: {{backstory}}\n' +
|
||||
'Current desires: {{currentDesires}}\n' +
|
||||
'Recent memories: {{recentEvents}}\n' +
|
||||
'Trigger: {{trigger}}\n\n' +
|
||||
'World state:\n' +
|
||||
'- Available resources: {{resourceTypes}}\n' +
|
||||
'- Known recipes: {{recipeList}}\n' +
|
||||
'- Existing structures: {{structureList}}\n' +
|
||||
'- Recent inventions: {{recentInventions}}\n\n' +
|
||||
'Generate ONE new desire that does not duplicate existing desires.\n' +
|
||||
'Respond with JSON:\n' +
|
||||
'{"description": "human-readable desire", ' +
|
||||
'"category": "material|social|shelter|comfort|community|creative", ' +
|
||||
'"fulfillment": {"type": "own_item|own_item_category|structure_exists|building_exists|relationship_tier|recipe_exists|custom", ...criteria}, ' +
|
||||
'"priority": 0.0-1.0, ' +
|
||||
'"reasoning": "why this fits the settler and trigger"}',
|
||||
},
|
||||
};
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type {
|
||||
EntityState, WorldState, StateUpdate,
|
||||
Position, Movement, Appearance, Needs, NPCBrain, PlayerControlled, SocialState, Stats, Relationships,
|
||||
Desire,
|
||||
} from '@dflike/shared';
|
||||
import { TILE_SIZE } from '@dflike/shared';
|
||||
import type { World } from '../ecs/World.js';
|
||||
@@ -54,6 +55,10 @@ export function serializeEntity(world: World, entityId: number): EntityState {
|
||||
const inventory = inventoryComponent && inventoryComponent.size > 0
|
||||
? Object.fromEntries(inventoryComponent)
|
||||
: undefined;
|
||||
const desiresComponent = world.getComponent<Desire[]>(entityId, 'desires');
|
||||
const desires = desiresComponent && desiresComponent.length > 0
|
||||
? desiresComponent.map(d => ({ description: d.description, category: d.category }))
|
||||
: undefined;
|
||||
return {
|
||||
id: entityId,
|
||||
position: world.getComponent<Position>(entityId, 'position')!,
|
||||
@@ -72,6 +77,7 @@ export function serializeEntity(world: World, entityId: number): EntityState {
|
||||
stats,
|
||||
relationships,
|
||||
inventory,
|
||||
desires,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ import { getDatabase } from './database.js';
|
||||
const SERIALIZABLE_COMPONENTS = [
|
||||
'needs', 'stats', 'appearance', 'npcBrain', 'socialState',
|
||||
'statModifiers', 'backstory', 'inventory', 'structure', 'movement',
|
||||
'desires',
|
||||
] as const;
|
||||
|
||||
function serializeComponent(name: string, data: unknown): string {
|
||||
|
||||
@@ -0,0 +1,231 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { World } from '../../ecs/World.js';
|
||||
import { desireFulfillmentSystem } from '../desireFulfillmentSystem.js';
|
||||
import { RecipeRegistry } from '../../industry/recipeRegistry.js';
|
||||
import { ItemRegistry } from '../../industry/itemRegistry.js';
|
||||
import type { Desire } from '@dflike/shared';
|
||||
|
||||
function makeDesire(overrides: Partial<Desire> & Pick<Desire, 'fulfillment'>): Desire {
|
||||
return {
|
||||
id: 'test-desire',
|
||||
description: 'Test desire',
|
||||
category: 'material',
|
||||
priority: 0.5,
|
||||
source: 'spawn',
|
||||
createdAtTick: 0,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe('desireFulfillmentSystem', () => {
|
||||
it('does nothing on non-interval ticks', () => {
|
||||
const world = new World();
|
||||
const e = world.createEntity();
|
||||
const desire = makeDesire({
|
||||
fulfillment: { type: 'own_item', itemId: 'log', quantity: 1 },
|
||||
});
|
||||
world.addComponent<Desire[]>(e, 'desires', [desire]);
|
||||
// Give the NPC a log so it WOULD be fulfilled if checked
|
||||
world.addComponent(e, 'inventory', new Map([['log', 5]]));
|
||||
|
||||
desireFulfillmentSystem(world, 1); // not on interval
|
||||
|
||||
const desires = world.getComponent<Desire[]>(e, 'desires');
|
||||
expect(desires).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('removes desire when own_item fulfilled', () => {
|
||||
const world = new World();
|
||||
const e = world.createEntity();
|
||||
const desire = makeDesire({
|
||||
fulfillment: { type: 'own_item', itemId: 'log', quantity: 3 },
|
||||
});
|
||||
world.addComponent<Desire[]>(e, 'desires', [desire]);
|
||||
world.addComponent(e, 'inventory', new Map([['log', 5]]));
|
||||
|
||||
desireFulfillmentSystem(world, 100);
|
||||
|
||||
const desires = world.getComponent<Desire[]>(e, 'desires');
|
||||
expect(desires).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('keeps desire when own_item not fulfilled', () => {
|
||||
const world = new World();
|
||||
const e = world.createEntity();
|
||||
const desire = makeDesire({
|
||||
fulfillment: { type: 'own_item', itemId: 'log', quantity: 10 },
|
||||
});
|
||||
world.addComponent<Desire[]>(e, 'desires', [desire]);
|
||||
world.addComponent(e, 'inventory', new Map([['log', 5]]));
|
||||
|
||||
desireFulfillmentSystem(world, 100);
|
||||
|
||||
const desires = world.getComponent<Desire[]>(e, 'desires');
|
||||
expect(desires).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('fulfills structure_exists when matching completed structure found', () => {
|
||||
const world = new World();
|
||||
const e = world.createEntity();
|
||||
const desire = makeDesire({
|
||||
fulfillment: { type: 'structure_exists', structureType: 'stockpile' },
|
||||
});
|
||||
world.addComponent<Desire[]>(e, 'desires', [desire]);
|
||||
|
||||
// Create a completed structure (buildProgress === undefined means complete)
|
||||
const s = world.createEntity();
|
||||
world.addComponent(s, 'structure', {
|
||||
type: 'stockpile',
|
||||
subtype: 'general',
|
||||
inventory: new Map(),
|
||||
// buildProgress undefined = complete
|
||||
});
|
||||
|
||||
desireFulfillmentSystem(world, 100);
|
||||
|
||||
const desires = world.getComponent<Desire[]>(e, 'desires');
|
||||
expect(desires).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('does NOT fulfill structure_exists for incomplete structures', () => {
|
||||
const world = new World();
|
||||
const e = world.createEntity();
|
||||
const desire = makeDesire({
|
||||
fulfillment: { type: 'structure_exists', structureType: 'stockpile' },
|
||||
});
|
||||
world.addComponent<Desire[]>(e, 'desires', [desire]);
|
||||
|
||||
// Create an incomplete structure
|
||||
const s = world.createEntity();
|
||||
world.addComponent(s, 'structure', {
|
||||
type: 'stockpile',
|
||||
subtype: 'general',
|
||||
inventory: new Map(),
|
||||
buildProgress: 0.5,
|
||||
});
|
||||
|
||||
desireFulfillmentSystem(world, 100);
|
||||
|
||||
const desires = world.getComponent<Desire[]>(e, 'desires');
|
||||
expect(desires).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('fulfills relationship_tier criteria', () => {
|
||||
const world = new World();
|
||||
const e = world.createEntity();
|
||||
const desire = makeDesire({
|
||||
fulfillment: { type: 'relationship_tier', tier: 'Friend', count: 1 },
|
||||
});
|
||||
world.addComponent<Desire[]>(e, 'desires', [desire]);
|
||||
|
||||
// Friend tier requires value >= 20
|
||||
const other = world.createEntity();
|
||||
const rels = new Map([[other, {
|
||||
value: 25,
|
||||
interactions: 5,
|
||||
lastInteractionTick: 0,
|
||||
status: 'active',
|
||||
}]]);
|
||||
world.addComponent(e, 'relationships', rels);
|
||||
|
||||
desireFulfillmentSystem(world, 100);
|
||||
|
||||
const desires = world.getComponent<Desire[]>(e, 'desires');
|
||||
expect(desires).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('building_exists always returns false', () => {
|
||||
const world = new World();
|
||||
const e = world.createEntity();
|
||||
const desire = makeDesire({
|
||||
fulfillment: { type: 'building_exists', buildingType: 'house' },
|
||||
});
|
||||
world.addComponent<Desire[]>(e, 'desires', [desire]);
|
||||
|
||||
desireFulfillmentSystem(world, 100);
|
||||
|
||||
const desires = world.getComponent<Desire[]>(e, 'desires');
|
||||
expect(desires).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('fulfills recipe_exists when matching recipe found in default registry', () => {
|
||||
const world = new World();
|
||||
const e = world.createEntity();
|
||||
const desire = makeDesire({
|
||||
fulfillment: { type: 'recipe_exists', tag: 'wooden_axe' },
|
||||
});
|
||||
world.addComponent<Desire[]>(e, 'desires', [desire]);
|
||||
|
||||
const registry = RecipeRegistry.createDefault();
|
||||
world.setSingleton('recipeRegistry', registry);
|
||||
|
||||
desireFulfillmentSystem(world, 100);
|
||||
|
||||
const desires = world.getComponent<Desire[]>(e, 'desires');
|
||||
expect(desires).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('keeps recipe_exists desire when no matching recipe', () => {
|
||||
const world = new World();
|
||||
const e = world.createEntity();
|
||||
const desire = makeDesire({
|
||||
fulfillment: { type: 'recipe_exists', tag: 'nonexistent_recipe' },
|
||||
});
|
||||
world.addComponent<Desire[]>(e, 'desires', [desire]);
|
||||
|
||||
const registry = RecipeRegistry.createDefault();
|
||||
world.setSingleton('recipeRegistry', registry);
|
||||
|
||||
desireFulfillmentSystem(world, 100);
|
||||
|
||||
const desires = world.getComponent<Desire[]>(e, 'desires');
|
||||
expect(desires).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('fulfills own_item_category when matching items found', () => {
|
||||
const world = new World();
|
||||
const e = world.createEntity();
|
||||
const desire = makeDesire({
|
||||
fulfillment: { type: 'own_item_category', category: 'tool', quantity: 1 },
|
||||
});
|
||||
world.addComponent<Desire[]>(e, 'desires', [desire]);
|
||||
world.addComponent(e, 'inventory', new Map([['wooden_axe', 1]]));
|
||||
|
||||
const itemRegistry = ItemRegistry.createDefault();
|
||||
world.setSingleton('itemRegistry', itemRegistry);
|
||||
|
||||
desireFulfillmentSystem(world, 100);
|
||||
|
||||
const desires = world.getComponent<Desire[]>(e, 'desires');
|
||||
expect(desires).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('custom always returns false', () => {
|
||||
const world = new World();
|
||||
const e = world.createEntity();
|
||||
const desire = makeDesire({
|
||||
fulfillment: { type: 'custom', check: 'some_check' },
|
||||
});
|
||||
world.addComponent<Desire[]>(e, 'desires', [desire]);
|
||||
|
||||
desireFulfillmentSystem(world, 100);
|
||||
|
||||
const desires = world.getComponent<Desire[]>(e, 'desires');
|
||||
expect(desires).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('only updates component when something changed', () => {
|
||||
const world = new World();
|
||||
const e = world.createEntity();
|
||||
const desires = [
|
||||
makeDesire({ id: 'd1', fulfillment: { type: 'custom', check: 'x' } }),
|
||||
];
|
||||
world.addComponent<Desire[]>(e, 'desires', desires);
|
||||
|
||||
desireFulfillmentSystem(world, 100);
|
||||
|
||||
// The same array reference should still be there (no update needed)
|
||||
const result = world.getComponent<Desire[]>(e, 'desires');
|
||||
expect(result).toBe(desires);
|
||||
});
|
||||
});
|
||||
@@ -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,60 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { World } from '../../ecs/World.js';
|
||||
import { desireFulfillmentSystem } from '../desireFulfillmentSystem.js';
|
||||
import { industrySystem } from '../industrySystem.js';
|
||||
import { GameMap } from '../../map/GameMap.js';
|
||||
import { ItemRegistry } from '../../industry/itemRegistry.js';
|
||||
import { RecipeRegistry } from '../../industry/recipeRegistry.js';
|
||||
import type { Desire, Stats, NPCBrain, Needs } from '@dflike/shared';
|
||||
|
||||
function setupIntegrationWorld() {
|
||||
const world = new World();
|
||||
const map = new GameMap(20, 20);
|
||||
const itemRegistry = ItemRegistry.createDefault();
|
||||
const recipeRegistry = RecipeRegistry.createDefault();
|
||||
world.setSingleton('itemRegistry', itemRegistry);
|
||||
world.setSingleton('recipeRegistry', recipeRegistry);
|
||||
return { world, map };
|
||||
}
|
||||
|
||||
describe('desire lifecycle integration', () => {
|
||||
it('desire drives crafting priority then gets fulfilled', () => {
|
||||
const { world, map } = setupIntegrationWorld();
|
||||
|
||||
// Create NPC with materials for both axe and hammer, but desires hammer
|
||||
const entity = world.createEntity();
|
||||
world.addComponent(entity, 'position', { x: 5, y: 5 });
|
||||
world.addComponent(entity, 'name', 'TestNPC');
|
||||
const inv = new Map([['log', 4], ['stone', 2]]);
|
||||
world.addComponent(entity, 'inventory', inv);
|
||||
world.addComponent<Stats>(entity, 'stats', {
|
||||
strength: 10, dexterity: 10, constitution: 10, intelligence: 10, perception: 10,
|
||||
sociability: 10, courage: 10, curiosity: 10, empathy: 10, temperament: 10,
|
||||
});
|
||||
world.addComponent(entity, 'statModifiers', { modifiers: [] });
|
||||
world.addComponent<Needs>(entity, 'needs', { hunger: 80, energy: 80, productivity: 20 });
|
||||
world.addComponent<NPCBrain>(entity, 'npcBrain', { currentGoal: 'wander', goalQueue: [] });
|
||||
world.addComponent(entity, 'movement', { state: 'idle', target: null, path: [], direction: 0, moveProgress: 0 });
|
||||
|
||||
const hammerDesire: Desire = {
|
||||
id: 'test_hammer', description: 'wants a hammer', category: 'material',
|
||||
fulfillment: { type: 'own_item', itemId: 'hammer', quantity: 1 },
|
||||
priority: 0.9, source: 'spawn', createdAtTick: 0,
|
||||
};
|
||||
world.addComponent<Desire[]>(entity, 'desires', [hammerDesire]);
|
||||
|
||||
// Industry should pick hammer recipe due to desire
|
||||
industrySystem(world, map);
|
||||
const craftingState = world.getComponent<any>(entity, 'craftingState');
|
||||
expect(craftingState).toBeTruthy();
|
||||
expect(craftingState.recipeId).toBe('craft_hammer');
|
||||
|
||||
// Simulate crafting completion: add hammer to inventory
|
||||
inv.set('hammer', 1);
|
||||
|
||||
// Fulfillment check should remove the desire
|
||||
desireFulfillmentSystem(world, 100);
|
||||
const desires = world.getComponent<Desire[]>(entity, 'desires')!;
|
||||
expect(desires).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
@@ -117,4 +117,37 @@ describe('industrySystem', () => {
|
||||
const brain = world.getComponent<NPCBrain>(e, 'npcBrain')!;
|
||||
expect(brain.currentGoal).toBe('craft');
|
||||
});
|
||||
|
||||
describe('desire-based recipe scoring', () => {
|
||||
it('prefers recipe matching a desire over other craftable recipes', () => {
|
||||
const { world, map } = createIndustryWorld();
|
||||
const inv = new Map([['log', 4], ['stone', 2]]);
|
||||
const entity = createIndustryNPC(world, 5, 5, inv);
|
||||
world.addComponent(entity, 'desires', [{
|
||||
id: 'd1', description: 'wants a hammer', category: 'material',
|
||||
fulfillment: { type: 'own_item', itemId: 'hammer', quantity: 1 },
|
||||
priority: 0.8, source: 'spawn', createdAtTick: 0,
|
||||
}]);
|
||||
industrySystem(world, map);
|
||||
const brain = world.getComponent<NPCBrain>(entity, 'npcBrain')!;
|
||||
expect(brain.currentGoal).toBe('craft');
|
||||
const craftingState = world.getComponent<CraftingState>(entity, 'craftingState');
|
||||
expect(craftingState!.recipeId).toBe('craft_hammer');
|
||||
});
|
||||
|
||||
it('falls back to first available when no desires match', () => {
|
||||
const { world, map } = createIndustryWorld();
|
||||
const inv = new Map([['log', 4], ['stone', 2]]);
|
||||
const entity = createIndustryNPC(world, 5, 5, inv);
|
||||
world.addComponent(entity, 'desires', [{
|
||||
id: 'd1', description: 'wants a house', category: 'shelter',
|
||||
fulfillment: { type: 'building_exists', buildingType: 'house' },
|
||||
priority: 0.8, source: 'spawn', createdAtTick: 0,
|
||||
}]);
|
||||
industrySystem(world, map);
|
||||
const brain = world.getComponent<NPCBrain>(entity, 'npcBrain')!;
|
||||
expect(brain.currentGoal).toBe('craft');
|
||||
// Should still craft something (either axe or hammer)
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
import type { Desire, FulfillmentCriteria } from '@dflike/shared';
|
||||
import type { World } from '../ecs/World.js';
|
||||
import type { StructureData } from './buildingSystem.js';
|
||||
import type { EventMemoryService } from '../llm/eventMemoryService.js';
|
||||
import { classify } from './relationshipHelpers.js';
|
||||
import { RecipeRegistry } from '../industry/recipeRegistry.js';
|
||||
import { ItemRegistry } from '../industry/itemRegistry.js';
|
||||
import { desireConfig } from '../config/desireConfig.js';
|
||||
|
||||
interface RelationshipData {
|
||||
value: number;
|
||||
interactions: number;
|
||||
lastInteractionTick: number;
|
||||
status: string;
|
||||
}
|
||||
|
||||
function checkFulfillment(
|
||||
world: World,
|
||||
entityId: number,
|
||||
criteria: FulfillmentCriteria,
|
||||
): boolean {
|
||||
switch (criteria.type) {
|
||||
case 'own_item': {
|
||||
const inv = world.getComponent<Map<string, number>>(entityId, 'inventory');
|
||||
if (!inv) return false;
|
||||
return (inv.get(criteria.itemId) ?? 0) >= criteria.quantity;
|
||||
}
|
||||
|
||||
case 'own_item_category': {
|
||||
const inv = world.getComponent<Map<string, number>>(entityId, 'inventory');
|
||||
if (!inv) return false;
|
||||
const itemRegistry = world.getSingleton<ItemRegistry>('itemRegistry');
|
||||
if (!itemRegistry) return false;
|
||||
let count = 0;
|
||||
for (const [itemId, qty] of inv) {
|
||||
const def = itemRegistry.get(itemId);
|
||||
if (def && def.category === criteria.category) {
|
||||
count += qty;
|
||||
}
|
||||
}
|
||||
return count >= criteria.quantity;
|
||||
}
|
||||
|
||||
case 'structure_exists': {
|
||||
for (const eid of world.query('structure')) {
|
||||
const s = world.getComponent<StructureData>(eid, 'structure');
|
||||
if (s && s.type === criteria.structureType && s.buildProgress === undefined) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
case 'building_exists':
|
||||
return false;
|
||||
|
||||
case 'relationship_tier': {
|
||||
const rels = world.getComponent<Map<number, RelationshipData>>(entityId, 'relationships');
|
||||
if (!rels) return false;
|
||||
let count = 0;
|
||||
for (const [, rel] of rels) {
|
||||
if (classify(rel.value) === criteria.tier) {
|
||||
count++;
|
||||
}
|
||||
}
|
||||
return count >= criteria.count;
|
||||
}
|
||||
|
||||
case 'recipe_exists': {
|
||||
const registry = world.getSingleton<RecipeRegistry>('recipeRegistry');
|
||||
if (!registry) return false;
|
||||
for (const recipe of registry.getAll()) {
|
||||
if (recipe.outputItemId === criteria.tag || recipe.id.includes(criteria.tag)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
case 'custom':
|
||||
return false;
|
||||
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function desireFulfillmentSystem(
|
||||
world: World,
|
||||
tick: number,
|
||||
eventMemoryService?: EventMemoryService,
|
||||
): void {
|
||||
if (tick % desireConfig.fulfillmentCheckInterval !== 0) return;
|
||||
|
||||
for (const entityId of world.query('desires')) {
|
||||
const desires = world.getComponent<Desire[]>(entityId, 'desires');
|
||||
if (!desires || desires.length === 0) continue;
|
||||
|
||||
const remaining: Desire[] = [];
|
||||
for (const desire of desires) {
|
||||
if (checkFulfillment(world, entityId, desire.fulfillment)) {
|
||||
eventMemoryService?.record(entityId, {
|
||||
type: 'desire_fulfilled',
|
||||
tick,
|
||||
detail: desire.description,
|
||||
});
|
||||
} else {
|
||||
remaining.push(desire);
|
||||
}
|
||||
}
|
||||
|
||||
if (remaining.length !== desires.length) {
|
||||
world.addComponent(entityId, 'desires', remaining);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
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);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import {
|
||||
PRODUCTIVITY_THRESHOLD,
|
||||
type Needs, type NPCBrain, type Position,
|
||||
type Desire, type Needs, type NPCBrain, type Position,
|
||||
} from '@dflike/shared';
|
||||
import type { World } from '../ecs/World.js';
|
||||
import type { GameMap } from '../map/GameMap.js';
|
||||
@@ -8,10 +8,39 @@ import type { GatheringState } from './gatheringSystem.js';
|
||||
import type { CraftingState } from './craftingSystem.js';
|
||||
import type { BuildingState, StructureData } from './buildingSystem.js';
|
||||
import type { PickupState } from './pickupSystem.js';
|
||||
import { RecipeRegistry } from '../industry/recipeRegistry.js';
|
||||
import { RecipeRegistry, type Recipe } from '../industry/recipeRegistry.js';
|
||||
import { ItemRegistry } from '../industry/itemRegistry.js';
|
||||
import { getEffectiveStat } from './statHelpers.js';
|
||||
import { industryConfig } from '../config/industryConfig.js';
|
||||
|
||||
function scoreCraftableRecipes(recipes: Recipe[], desires: Desire[], itemRegistry?: ItemRegistry): Recipe {
|
||||
if (recipes.length === 1 || desires.length === 0) return recipes[0];
|
||||
|
||||
let bestRecipe = recipes[0];
|
||||
let bestScore = 0;
|
||||
|
||||
for (const recipe of recipes) {
|
||||
let score = 1; // base score
|
||||
for (const desire of desires) {
|
||||
const f = desire.fulfillment;
|
||||
if (f.type === 'own_item' && f.itemId === recipe.outputItemId) {
|
||||
score += desire.priority * 10;
|
||||
} else if (f.type === 'own_item_category' && itemRegistry) {
|
||||
const outputDef = itemRegistry.get(recipe.outputItemId);
|
||||
if (outputDef && outputDef.category === f.category) {
|
||||
score += desire.priority * 5;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (score > bestScore) {
|
||||
bestScore = score;
|
||||
bestRecipe = recipe;
|
||||
}
|
||||
}
|
||||
|
||||
return bestRecipe;
|
||||
}
|
||||
|
||||
const BUILD_RECIPE_MAP: Record<string, { type: 'stockpile' | 'workshop'; subtype: string }> = {
|
||||
stockpile: { type: 'stockpile', subtype: 'general' },
|
||||
workbench: { type: 'workshop', subtype: 'workbench' },
|
||||
@@ -65,7 +94,9 @@ export function industrySystem(world: World, map: GameMap): void {
|
||||
// Try craft first
|
||||
const craftRecipes = registry.findCraftable(inv).filter(r => !r.id.startsWith('build_'));
|
||||
if (craftRecipes.length > 0) {
|
||||
const recipe = craftRecipes[0];
|
||||
const desires = world.getComponent<Desire[]>(entity, 'desires') ?? [];
|
||||
const itemRegistry = world.getSingleton<ItemRegistry>('itemRegistry');
|
||||
const recipe = scoreCraftableRecipes(craftRecipes, desires, itemRegistry);
|
||||
const dex = getEffectiveStat(world, entity, 'dexterity');
|
||||
const baseTicks = industryConfig.craftBaseTicks;
|
||||
const ticks = Math.max(1, Math.round(baseTicks * (1 - (dex - 10) * industryConfig.craftDexterityModifier)));
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { EntityId, InventionSummary } from '@dflike/shared';
|
||||
import type { EntityId, InventionSummary, Desire } from '@dflike/shared';
|
||||
import type { World } from '../ecs/World.js';
|
||||
import type { LlmService } from '../llm/llmService.js';
|
||||
import type { NarrationService } from '../llm/narrationService.js';
|
||||
@@ -79,6 +79,11 @@ export function createInventionSystem(
|
||||
const emp = getEffectiveStat(world, entityId, 'empathy');
|
||||
const tmp = getEffectiveStat(world, entityId, 'temperament');
|
||||
|
||||
const desires = world.getComponent<Desire[]>(entityId, 'desires') ?? [];
|
||||
const desiresSection = desires.length > 0
|
||||
? `This settler's current desires:\n${desires.map(d => `- ${d.description}`).join('\n')}\n\n`
|
||||
: '';
|
||||
|
||||
pendingEntities.add(entityId);
|
||||
|
||||
llmService.generate('invention', {
|
||||
@@ -95,6 +100,7 @@ export function createInventionSystem(
|
||||
temperament: String(tmp),
|
||||
materials: materialsList,
|
||||
knownItems,
|
||||
desiresSection,
|
||||
}).then(response => {
|
||||
pendingEntities.delete(entityId);
|
||||
|
||||
|
||||
+33
-1
@@ -13,7 +13,8 @@ export type MemoryEventType =
|
||||
| 'goal_change'
|
||||
| 'bond_formed' | 'bond_dissolved'
|
||||
| 'spawned'
|
||||
| 'invention';
|
||||
| 'invention'
|
||||
| 'desire_fulfilled' | 'desire_added';
|
||||
|
||||
export interface MemoryEvent {
|
||||
id: number;
|
||||
@@ -145,6 +146,7 @@ export interface EntityState {
|
||||
name?: string; // NPC display name
|
||||
backstory?: string;
|
||||
inventory?: Record<string, number>;
|
||||
desires?: { description: string; category: DesireCategory; }[];
|
||||
socialState?: {
|
||||
phase: InteractionPhase;
|
||||
partnerId: EntityId | null;
|
||||
@@ -235,6 +237,36 @@ export interface StockpileLogEntry {
|
||||
tick: number;
|
||||
}
|
||||
|
||||
// Desire system types
|
||||
export type DesireCategory =
|
||||
| 'material' // wants a specific item/tool
|
||||
| 'social' // wants relationships, bonds
|
||||
| 'shelter' // wants housing/personal space
|
||||
| 'comfort' // wants food security, rest, quality of life
|
||||
| 'community' // wants to improve the settlement for everyone
|
||||
| 'creative'; // wants to make/invent something novel
|
||||
|
||||
export type FulfillmentCriteria =
|
||||
| { type: 'own_item'; itemId: string; quantity: number }
|
||||
| { type: 'own_item_category'; category: string; quantity: number }
|
||||
| { type: 'structure_exists'; structureType: string }
|
||||
| { type: 'building_exists'; buildingType: string }
|
||||
| { type: 'relationship_tier'; tier: string; count: number }
|
||||
| { type: 'recipe_exists'; tag: string }
|
||||
| { type: 'custom'; check: string };
|
||||
|
||||
export interface Desire {
|
||||
id: string;
|
||||
description: string;
|
||||
category: DesireCategory;
|
||||
fulfillment: FulfillmentCriteria;
|
||||
priority: number; // 0-1
|
||||
source: 'spawn' | 'event' | 'periodic';
|
||||
sourceDetail?: string;
|
||||
createdAtTick: number;
|
||||
cooldownTicks?: number;
|
||||
}
|
||||
|
||||
// Server -> Client events
|
||||
export interface ServerEvents {
|
||||
'world-state': (data: WorldState) => void;
|
||||
|
||||
Reference in New Issue
Block a user