From aa3f5651ddce3a22d3656814f9dbb51e7319cf89 Mon Sep 17 00:00:00 2001 From: root Date: Sun, 8 Mar 2026 16:49:32 +0000 Subject: [PATCH] feat: add backstory generation helper for NPC LLM backstories Adds formatStatsForPrompt to convert Stats into prompt-friendly abbreviations and generateBackstory to call the LLM service with the backstory template, writing the result back to the entity's backstory component. Co-Authored-By: Claude Opus 4.6 --- .../llm/__tests__/backstoryGenerator.test.ts | 89 +++++++++++++++++++ server/src/llm/backstoryGenerator.ts | 38 ++++++++ 2 files changed, 127 insertions(+) create mode 100644 server/src/llm/__tests__/backstoryGenerator.test.ts create mode 100644 server/src/llm/backstoryGenerator.ts diff --git a/server/src/llm/__tests__/backstoryGenerator.test.ts b/server/src/llm/__tests__/backstoryGenerator.test.ts new file mode 100644 index 0000000..5e9cee8 --- /dev/null +++ b/server/src/llm/__tests__/backstoryGenerator.test.ts @@ -0,0 +1,89 @@ +import { describe, it, expect, vi } from 'vitest'; +import { World } from '../../ecs/World.js'; +import { formatStatsForPrompt, generateBackstory } from '../backstoryGenerator.js'; +import type { Stats } from '@dflike/shared'; +import type { LlmService } from '../llmService.js'; + +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); + expect(result).toBe( + 'STR:15, DEX:12, CON:14, INT:8, PER:10, SOC:6, COU:16, CUR:11, EMP:7, TMP:13' + ); + }); +}); + +describe('generateBackstory', () => { + 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(entity, 'stats', { + strength: 15, dexterity: 12, constitution: 14, + intelligence: 8, perception: 10, + sociability: 6, courage: 16, curiosity: 11, + empathy: 7, temperament: 13, + }); + + const mockService: LlmService = { + generate: vi.fn().mockResolvedValue('A brave warrior from the north.'), + queueDepth: () => 0, + clear: () => {}, + }; + + await generateBackstory(world, entity, mockService); + + expect(mockService.generate).toHaveBeenCalledWith('backstory', { + npcName: 'Brynn', + stats: 'STR:15, DEX:12, CON:14, INT:8, PER:10, SOC:6, COU:16, CUR:11, EMP:7, TMP:13', + }); + + expect(world.getComponent(entity, 'backstory')).toBe( + 'A brave warrior from the north.' + ); + }); + + it('leaves backstory empty when LLM returns null', async () => { + const world = new World(); + const entity = world.createEntity(); + world.addComponent(entity, 'name', 'Thom'); + world.addComponent(entity, 'backstory', ''); + world.addComponent(entity, 'stats', { + strength: 10, dexterity: 10, constitution: 10, + intelligence: 10, perception: 10, + sociability: 10, courage: 10, curiosity: 10, + empathy: 10, temperament: 10, + }); + + const mockService: LlmService = { + generate: vi.fn().mockResolvedValue(null), + queueDepth: () => 0, + clear: () => {}, + }; + + await generateBackstory(world, entity, mockService); + + expect(world.getComponent(entity, 'backstory')).toBe(''); + }); + + it('does not crash if entity is missing components', async () => { + const world = new World(); + const entity = world.createEntity(); + + const mockService: LlmService = { + generate: vi.fn().mockResolvedValue('text'), + queueDepth: () => 0, + clear: () => {}, + }; + + await generateBackstory(world, entity, mockService); + expect(mockService.generate).not.toHaveBeenCalled(); + }); +}); diff --git a/server/src/llm/backstoryGenerator.ts b/server/src/llm/backstoryGenerator.ts new file mode 100644 index 0000000..5897f40 --- /dev/null +++ b/server/src/llm/backstoryGenerator.ts @@ -0,0 +1,38 @@ +import type { Stats } from '@dflike/shared'; +import type { World } from '../ecs/World.js'; +import type { LlmService } from './llmService.js'; + +const STAT_KEYS: (keyof Stats)[] = [ + 'strength', 'dexterity', 'constitution', 'intelligence', 'perception', + 'sociability', 'courage', 'curiosity', 'empathy', 'temperament', +]; + +const STAT_ABBREVS: Record = { + strength: 'STR', dexterity: 'DEX', constitution: 'CON', + intelligence: 'INT', perception: 'PER', + sociability: 'SOC', courage: 'COU', curiosity: 'CUR', + empathy: 'EMP', temperament: 'TMP', +}; + +export function formatStatsForPrompt(stats: Stats): string { + return STAT_KEYS.map(k => `${STAT_ABBREVS[k]}:${Math.floor(stats[k])}`).join(', '); +} + +export async function generateBackstory( + world: World, + entityId: number, + llmService: LlmService, +): Promise { + const name = world.getComponent(entityId, 'name'); + const stats = world.getComponent(entityId, 'stats'); + if (!name || !stats) return; + + const result = await llmService.generate('backstory', { + npcName: name, + stats: formatStatsForPrompt(stats), + }); + + if (result) { + world.addComponent(entityId, 'backstory', result); + } +}