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:
root
2026-03-10 00:16:00 +00:00
18 changed files with 1364 additions and 63 deletions
+43 -1
View File
@@ -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;">&#9670; ${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;">&#9675; (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);
}
+25
View File
@@ -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;
+9 -2
View File
@@ -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);
+2 -1
View File
@@ -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();
});
});
+127 -1
View File
@@ -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
View File
@@ -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"}',
},
};
+6
View File
@@ -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);
}
}
}
+137
View File
@@ -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);
},
};
}
+34 -3
View File
@@ -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)));
+7 -1
View File
@@ -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
View File
@@ -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;