import { describe, it, expect, vi, afterEach } from 'vitest'; import { World } from '../../ecs/World.js'; import { ItemRegistry } from '../../industry/itemRegistry.js'; import { RecipeRegistry } from '../../industry/recipeRegistry.js'; import { createInventionTimeline } from '../../industry/inventionTimeline.js'; import { createInventionSystem } from '../inventionSystem.js'; import { createEventMemoryService } from '../../llm/eventMemoryService.js'; import type { LlmService } from '../../llm/llmService.js'; import type { NarrationService } from '../../llm/narrationService.js'; import type { Needs, Stats, NPCBrain, StatModifiers } from '@dflike/shared'; describe('invention integration', () => { afterEach(() => { vi.restoreAllMocks(); }); it('end-to-end: NPC invents item that appears in future prompts', async () => { const world = new World(); const itemRegistry = ItemRegistry.createDefault(); const recipeRegistry = RecipeRegistry.createDefault(); const timeline = createInventionTimeline(); world.setSingleton('itemRegistry', itemRegistry); world.setSingleton('recipeRegistry', recipeRegistry); world.setSingleton('inventionTimeline', timeline); const e = world.createEntity(); world.addComponent(e, 'needs', { hunger: 80, energy: 80, productivity: 20 }); world.addComponent(e, 'npcBrain', { currentGoal: 'gather', goalQueue: [] }); world.addComponent(e, 'stats', { strength: 10, dexterity: 10, constitution: 10, intelligence: 18, perception: 10, sociability: 10, courage: 10, curiosity: 18, empathy: 10, temperament: 10, }); world.addComponent(e, 'statModifiers', { modifiers: [] }); world.addComponent>(e, 'inventory', new Map([['log', 5], ['stone', 3]])); world.addComponent(e, 'name', 'Gwendolyn'); const generateMock = vi.fn(); const llm: LlmService = { generate: generateMock, queueDepth: () => 0, clear: () => {}, activeModel: () => 'test-model', usageStats: () => ({}), usageSummary: () => '', }; const narrationEvents: any[] = []; const narration: NarrationService = { recordInteraction: vi.fn() as any, getRecentEvents: () => [], getEventsForEntity: () => [], onEventUpdated: null, onEventCreated: (event) => { narrationEvents.push(event); }, }; const eventMemory = createEventMemoryService(); const system = createInventionSystem(llm, narration, eventMemory); // First call: return a valid invention response generateMock.mockResolvedValueOnce(JSON.stringify({ name: 'rope', description: 'Twisted plant fibers', category: 'material', inputs: [{ itemId: 'log', quantity: 2 }], })); // Force Math.random to return 0 so eureka always triggers vi.spyOn(Math, 'random').mockReturnValue(0); system.update(world, 100); // Wait for async LLM promise to resolve await vi.waitFor(() => { expect(itemRegistry.get('rope')).toBeDefined(); }); // Item registered expect(itemRegistry.get('rope')!.inventedBy!.name).toBe('Gwendolyn'); // Recipe registered expect(recipeRegistry.get('craft_rope')).toBeDefined(); // Timeline recorded expect(timeline.countByEntity(e)).toBe(1); // Narration emitted expect(narrationEvents).toHaveLength(1); expect(narrationEvents[0].narration).toContain('rope'); // Memory event recorded const memories = eventMemory.getEvents(e); expect(memories.some(m => m.type === 'invention')).toBe(true); // Ensure all microtasks have completed (pendingEntities cleared) await new Promise(r => setTimeout(r, 10)); // Second call: return null (we just want to verify the materials arg) generateMock.mockResolvedValueOnce(null); // Rope now appears in materials for next invention system.update(world, 200); expect(generateMock).toHaveBeenCalledTimes(2); const secondCallArgs = generateMock.mock.calls[1][1]; expect(secondCallArgs.materials).toContain('rope'); }); });