feat: add NarrationService with event buffer, LLM priority, and template fallback

Implements NarrationService that manages a rolling buffer of 50 narration events,
generates immediate template fallback text, and queues LLM generation for priority
interactions and proposals (respecting daily limits).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
root
2026-03-08 17:57:03 +00:00
parent 865054facc
commit 4bcf90d7c5
2 changed files with 308 additions and 0 deletions
@@ -0,0 +1,210 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import type { NarrationEvent } from '@dflike/shared';
import type { LlmService } from '../llmService.js';
import type { InteractionRecord } from '../narrationService.js';
import { createNarrationService } from '../narrationService.js';
function mockLlmService(overrides: Partial<LlmService> = {}): LlmService {
return {
generate: vi.fn().mockResolvedValue('LLM narration text'),
queueDepth: vi.fn().mockReturnValue(0),
clear: vi.fn(),
isDailyLimitReached: vi.fn().mockReturnValue(false),
...overrides,
};
}
function makeRecord(overrides: Partial<InteractionRecord> = {}): InteractionRecord {
return {
tick: 100,
entityIds: [1, 2],
names: ['Alice', 'Bob'],
outcome: 'positive',
classification: 'acquaintance',
isProposal: false,
isPriority: false,
npc1Personality: 'friendly',
npc2Personality: 'curious',
sentiment: 0.5,
...overrides,
};
}
describe('NarrationService', () => {
let llm: LlmService;
beforeEach(() => {
llm = mockLlmService();
});
it('creates event with fallback text and returns it', () => {
const service = createNarrationService(llm);
const event = service.recordInteraction(makeRecord());
expect(event.id).toBe(1);
expect(event.tick).toBe(100);
expect(event.type).toBe('social');
expect(event.entityIds).toEqual([1, 2]);
expect(event.names).toEqual(['Alice', 'Bob']);
expect(event.outcome).toBe('positive');
expect(event.narration).toBeTruthy();
expect(event.isLlmGenerated).toBe(false);
});
it('sets type to proposal when isProposal is true', () => {
const service = createNarrationService(llm);
const event = service.recordInteraction(makeRecord({ isProposal: true, outcome: 'proposal_accepted' }));
expect(event.type).toBe('proposal');
});
it('auto-increments event ids', () => {
const service = createNarrationService(llm);
const e1 = service.recordInteraction(makeRecord());
const e2 = service.recordInteraction(makeRecord());
expect(e1.id).toBe(1);
expect(e2.id).toBe(2);
});
it('queues LLM for priority interactions', () => {
const service = createNarrationService(llm);
service.recordInteraction(makeRecord({ isPriority: true }));
expect(llm.generate).toHaveBeenCalledOnce();
expect(llm.generate).toHaveBeenCalledWith('socialNarration', expect.objectContaining({
npc1Name: 'Alice',
npc2Name: 'Bob',
}));
});
it('always queues LLM for proposals', () => {
const service = createNarrationService(llm);
service.recordInteraction(makeRecord({ isProposal: true, outcome: 'proposal_accepted' }));
expect(llm.generate).toHaveBeenCalledOnce();
});
it('does NOT queue LLM for non-priority, non-proposal interactions', () => {
const service = createNarrationService(llm);
service.recordInteraction(makeRecord({ isPriority: false, isProposal: false }));
expect(llm.generate).not.toHaveBeenCalled();
});
it('does not queue LLM when daily limit is reached', () => {
llm = mockLlmService({ isDailyLimitReached: vi.fn().mockReturnValue(true) });
const service = createNarrationService(llm);
service.recordInteraction(makeRecord({ isPriority: true }));
expect(llm.generate).not.toHaveBeenCalled();
});
it('rolling buffer caps at 50', () => {
const service = createNarrationService(llm);
for (let i = 0; i < 55; i++) {
service.recordInteraction(makeRecord({ tick: i }));
}
const events = service.getRecentEvents();
expect(events.length).toBe(50);
// oldest events (tick 0-4) should have been dropped
expect(events[0].tick).toBe(5);
});
it('getRecentEvents returns a copy', () => {
const service = createNarrationService(llm);
service.recordInteraction(makeRecord());
const events1 = service.getRecentEvents();
const events2 = service.getRecentEvents();
expect(events1).not.toBe(events2);
expect(events1).toEqual(events2);
});
it('getEventsForEntity filters correctly', () => {
const service = createNarrationService(llm);
service.recordInteraction(makeRecord({ entityIds: [1, 2] }));
service.recordInteraction(makeRecord({ entityIds: [3, 4] }));
service.recordInteraction(makeRecord({ entityIds: [2, 5] }));
const eventsFor2 = service.getEventsForEntity(2);
expect(eventsFor2.length).toBe(2);
const eventsFor3 = service.getEventsForEntity(3);
expect(eventsFor3.length).toBe(1);
const eventsFor9 = service.getEventsForEntity(9);
expect(eventsFor9.length).toBe(0);
});
it('onEventCreated callback fires', () => {
const service = createNarrationService(llm);
const created: NarrationEvent[] = [];
service.onEventCreated = (e) => created.push(e);
service.recordInteraction(makeRecord());
expect(created.length).toBe(1);
expect(created[0].id).toBe(1);
});
it('onEventUpdated callback fires when LLM result arrives', async () => {
let resolveGenerate: (val: string) => void;
const generatePromise = new Promise<string>((resolve) => {
resolveGenerate = resolve;
});
llm = mockLlmService({
generate: vi.fn().mockReturnValue(generatePromise),
});
const service = createNarrationService(llm);
const updated: NarrationEvent[] = [];
service.onEventUpdated = (e) => updated.push(e);
const event = service.recordInteraction(makeRecord({ isPriority: true }));
expect(updated.length).toBe(0);
expect(event.isLlmGenerated).toBe(false);
resolveGenerate!('A beautifully crafted narration.');
await vi.waitFor(() => expect(updated.length).toBe(1));
expect(updated[0].narration).toBe('A beautifully crafted narration.');
expect(updated[0].isLlmGenerated).toBe(true);
});
it('does not update event when LLM returns null', async () => {
llm = mockLlmService({
generate: vi.fn().mockResolvedValue(null),
});
const service = createNarrationService(llm);
const updated: NarrationEvent[] = [];
service.onEventUpdated = (e) => updated.push(e);
service.recordInteraction(makeRecord({ isPriority: true }));
// Wait a tick for the promise to settle
await new Promise((r) => setTimeout(r, 0));
expect(updated.length).toBe(0);
});
it('passes correct variables to LLM generate', () => {
const service = createNarrationService(llm);
service.recordInteraction(makeRecord({
isPriority: true,
names: ['Elara', 'Brynn'],
npc1Personality: 'bold',
npc2Personality: 'shy',
outcome: 'negative',
classification: 'rival',
sentiment: -0.3,
}));
expect(llm.generate).toHaveBeenCalledWith('socialNarration', {
npc1Name: 'Elara',
npc2Name: 'Brynn',
npc1Personality: 'bold',
npc2Personality: 'shy',
outcome: 'negative',
relationship: 'rival',
sentiment: '-0.3',
});
});
});
+98
View File
@@ -0,0 +1,98 @@
import type { EntityId, NarrationEvent, NarrationOutcome } from '@dflike/shared';
import type { LlmService } from './llmService.js';
import { generateFallbackNarration } from './narrationTemplates.js';
export interface InteractionRecord {
tick: number;
entityIds: [EntityId, EntityId];
names: [string, string];
outcome: NarrationOutcome;
classification: string;
isProposal: boolean;
isPriority: boolean;
npc1Personality?: string;
npc2Personality?: string;
sentiment?: number;
}
export interface NarrationService {
recordInteraction(record: InteractionRecord): NarrationEvent;
getRecentEvents(): NarrationEvent[];
getEventsForEntity(entityId: EntityId): NarrationEvent[];
onEventUpdated: ((event: NarrationEvent) => void) | null;
onEventCreated: ((event: NarrationEvent) => void) | null;
}
const MAX_BUFFER_SIZE = 50;
export function createNarrationService(llmService: LlmService): NarrationService {
const buffer: NarrationEvent[] = [];
let nextId = 1;
const service: NarrationService = {
onEventUpdated: null,
onEventCreated: null,
recordInteraction(record: InteractionRecord): NarrationEvent {
const event: NarrationEvent = {
id: nextId++,
tick: record.tick,
type: record.isProposal ? 'proposal' : 'social',
entityIds: record.entityIds,
names: record.names,
outcome: record.outcome,
narration: generateFallbackNarration(
record.names[0],
record.names[1],
record.outcome,
record.classification,
),
isLlmGenerated: false,
};
buffer.push(event);
if (buffer.length > MAX_BUFFER_SIZE) {
buffer.shift();
}
service.onEventCreated?.(event);
const shouldQueueLlm =
(record.isProposal || record.isPriority) && !llmService.isDailyLimitReached();
if (shouldQueueLlm) {
const variables: Record<string, string> = {
npc1Name: record.names[0],
npc2Name: record.names[1],
npc1Personality: record.npc1Personality ?? '',
npc2Personality: record.npc2Personality ?? '',
outcome: record.outcome,
relationship: record.classification,
sentiment: String(record.sentiment ?? ''),
};
llmService.generate('socialNarration', variables).then((result) => {
if (result != null) {
event.narration = result;
event.isLlmGenerated = true;
service.onEventUpdated?.(event);
}
});
}
return event;
},
getRecentEvents(): NarrationEvent[] {
return [...buffer];
},
getEventsForEntity(entityId: EntityId): NarrationEvent[] {
return buffer.filter(
(e) => e.entityIds[0] === entityId || e.entityIds[1] === entityId,
);
},
};
return service;
}