diff --git a/docs/plans/2026-03-08-social-narration-impl.md b/docs/plans/2026-03-08-social-narration-impl.md new file mode 100644 index 0000000..991b65a --- /dev/null +++ b/docs/plans/2026-03-08-social-narration-impl.md @@ -0,0 +1,1015 @@ +# Social Interaction Narration — Implementation Plan + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. + +**Goal:** When NPCs complete social interactions, generate narration text (via LLM or template fallback) and deliver it to clients via a new Events tab on the left panel and a Recent Events section in the NPC info panel. + +**Architecture:** Server detects completed interactions in a new `narrationSystem` that runs after `relationshipSystem`. It produces `NarrationEvent` objects stored in a rolling buffer, emits them via socket.io, and queues LLM generation for priority events. The client receives events and displays them in a refactored left panel (tabs: Stats / Events) and in the NPC info panel. + +**Tech Stack:** TypeScript, socket.io, vitest, existing LLM infrastructure (OpenRouter client, generation queue, prompt templates) + +--- + +### Task 1: Update Rate Limits in LLM Config + +**Files:** +- Modify: `server/src/config/llmConfig.ts` +- Modify: `server/src/llm/__tests__/llmConfig.test.ts` (if exists, otherwise `server/src/config/__tests__/llmConfig.test.ts`) + +**Step 1: Find and read the llmConfig test file** + +Run: `find /opt/dflike/server -name 'llmConfig*test*' -type f` + +**Step 2: Write the failing test** + +Add a test to the existing llmConfig test file: + +```typescript +it('includes daily request limit', () => { + const config = getLlmConfig(); + expect(config.requestsPerDay).toBe(1000); +}); + +it('uses 20 requests per minute', () => { + const config = getLlmConfig(); + expect(config.requestsPerMinute).toBe(20); +}); +``` + +**Step 3: Run test to verify it fails** + +Run: `cd /opt/dflike && npx vitest run server/src --reporter=verbose -t "daily request limit"` +Expected: FAIL — `requestsPerDay` does not exist on type + +**Step 4: Update llmConfig.ts** + +In `server/src/config/llmConfig.ts`, add `requestsPerDay` to the interface and change `requestsPerMinute` to 20: + +```typescript +export interface LlmConfig { + apiKey: string; + model: string; + maxTokens: number; + temperature: number; + requestsPerMinute: number; + requestsPerDay: number; + timeoutMs: number; + enabled: boolean; +} + +export function getLlmConfig(): LlmConfig { + const apiKey = process.env.OPENROUTER_API_KEY ?? ''; + return { + apiKey, + model: process.env.LLM_MODEL ?? 'arcee-ai/trinity-large-preview:free', + maxTokens: 200, + temperature: 0.8, + requestsPerMinute: 20, + requestsPerDay: 1000, + timeoutMs: 15000, + enabled: apiKey.length > 0, + }; +} +``` + +**Step 5: Run tests to verify they pass** + +Run: `cd /opt/dflike && npx vitest run server/src --reporter=verbose` +Expected: All pass + +**Step 6: Add daily rate limiting to generationQueue** + +Update `server/src/llm/generationQueue.ts` to accept and enforce a `requestsPerDay` option: + +```typescript +interface QueueOptions { + requestsPerMinute: number; + requestsPerDay?: number; +} +``` + +Add a `dailyCount` counter and a `dailyResetTime` tracker. When `dailyCount >= requestsPerDay`, resolve enqueued items with `null` immediately. Reset counter when `Date.now() >= dailyResetTime`. + +Add a public method `isDailyLimitReached(): boolean`. + +Write tests for the daily limit behavior in `server/src/llm/__tests__/generationQueue.test.ts`. + +**Step 7: Wire daily limit through llmService** + +Update `server/src/llm/llmService.ts` to pass `requestsPerDay` from config to the queue. Add `isDailyLimitReached()` to the `LlmService` interface. + +**Step 8: Run all tests** + +Run: `cd /opt/dflike && npx vitest run server/src --reporter=verbose` +Expected: All pass + +**Step 9: Commit** + +```bash +git add server/src/config/llmConfig.ts server/src/llm/generationQueue.ts server/src/llm/llmService.ts server/src/llm/__tests__/generationQueue.test.ts server/src/llm/__tests__/llmService.test.ts +git commit -m "feat: update rate limits to 20 RPM / 1000 per day" +``` + +--- + +### Task 2: NarrationEvent Type + Template Fallback Text Generator + +**Files:** +- Create: `shared/src/narration.ts` +- Modify: `shared/src/index.ts` (re-export) +- Create: `server/src/llm/narrationTemplates.ts` +- Create: `server/src/llm/__tests__/narrationTemplates.test.ts` + +**Step 1: Define NarrationEvent type in shared** + +In `shared/src/narration.ts`: + +```typescript +import type { EntityId } from './types.js'; + +export type NarrationOutcome = 'positive' | 'negative' | 'proposal_accepted' | 'proposal_rejected'; + +export interface NarrationEvent { + id: number; + tick: number; + type: 'social' | 'proposal'; + entityIds: [EntityId, EntityId]; + names: [string, string]; + outcome: NarrationOutcome; + narration: string; + isLlmGenerated: boolean; +} +``` + +Add `export * from './narration.js';` to `shared/src/index.ts`. + +**Step 2: Write failing tests for template fallback generator** + +In `server/src/llm/__tests__/narrationTemplates.test.ts`: + +```typescript +import { describe, it, expect } from 'vitest'; +import { generateFallbackNarration } from '../narrationTemplates.js'; + +describe('generateFallbackNarration', () => { + it('generates positive interaction text', () => { + const text = generateFallbackNarration('Brynn', 'Thom', 'positive', 'Friend'); + expect(text).toBeTruthy(); + expect(text).toContain('Brynn'); + expect(text).toContain('Thom'); + }); + + it('generates negative interaction text', () => { + const text = generateFallbackNarration('Brynn', 'Thom', 'negative', 'Rival'); + expect(text).toBeTruthy(); + }); + + it('generates proposal accepted text', () => { + const text = generateFallbackNarration('Brynn', 'Thom', 'proposal_accepted', 'Devoted'); + expect(text).toBeTruthy(); + }); + + it('generates proposal rejected text', () => { + const text = generateFallbackNarration('Brynn', 'Thom', 'proposal_rejected', 'Close Friend'); + expect(text).toBeTruthy(); + }); + + it('varies output across calls', () => { + const results = new Set(); + for (let i = 0; i < 20; i++) { + results.add(generateFallbackNarration('A', 'B', 'positive', 'Friend')); + } + expect(results.size).toBeGreaterThan(1); + }); +}); +``` + +**Step 3: Run test to verify it fails** + +Run: `cd /opt/dflike && npx vitest run server/src/llm/__tests__/narrationTemplates.test.ts --reporter=verbose` +Expected: FAIL — module not found + +**Step 4: Implement narrationTemplates.ts** + +In `server/src/llm/narrationTemplates.ts`: + +```typescript +import type { NarrationOutcome } from '@dflike/shared'; + +const POSITIVE_TEMPLATES = [ + '{a} and {b} shared a warm exchange.', + '{a} and {b} enjoyed a friendly chat.', + '{a} smiled at {b}, and {b} smiled back.', + '{a} and {b} had a pleasant conversation.', + '{a} greeted {b} warmly.', +]; + +const NEGATIVE_TEMPLATES = [ + '{a} and {b} exchanged harsh words.', + '{a} scowled at {b}.', + '{a} and {b} had a tense encounter.', + '{a} brushed past {b} coldly.', + '{a} and {b} glared at each other.', +]; + +const PROPOSAL_ACCEPTED_TEMPLATES = [ + '{a} proposed to {b}, and {b} accepted!', + '{a} and {b} pledged themselves to each other.', + '{b} said yes to {a}\'s proposal!', +]; + +const PROPOSAL_REJECTED_TEMPLATES = [ + '{a} proposed to {b}, but was turned down.', + '{b} rejected {a}\'s advances.', + '{a}\'s proposal to {b} was met with silence.', +]; + +function pickRandom(arr: T[]): T { + return arr[Math.floor(Math.random() * arr.length)]; +} + +export function generateFallbackNarration( + name1: string, + name2: string, + outcome: NarrationOutcome, + _classification: string, +): string { + const templates: Record = { + positive: POSITIVE_TEMPLATES, + negative: NEGATIVE_TEMPLATES, + proposal_accepted: PROPOSAL_ACCEPTED_TEMPLATES, + proposal_rejected: PROPOSAL_REJECTED_TEMPLATES, + }; + const template = pickRandom(templates[outcome]); + return template.replace(/\{a\}/g, name1).replace(/\{b\}/g, name2); +} +``` + +**Step 5: Run tests to verify they pass** + +Run: `cd /opt/dflike && npx vitest run server/src/llm/__tests__/narrationTemplates.test.ts --reporter=verbose` +Expected: All pass + +**Step 6: Commit** + +```bash +git add shared/src/narration.ts shared/src/index.ts server/src/llm/narrationTemplates.ts server/src/llm/__tests__/narrationTemplates.test.ts +git commit -m "feat: add NarrationEvent type and template fallback text generator" +``` + +--- + +### Task 3: NarrationService (Server-Side Event Buffer + LLM Priority) + +**Files:** +- Create: `server/src/llm/narrationService.ts` +- Create: `server/src/llm/__tests__/narrationService.test.ts` + +**Step 1: Write failing tests** + +In `server/src/llm/__tests__/narrationService.test.ts`: + +```typescript +import { describe, it, expect, vi } from 'vitest'; +import { createNarrationService } from '../narrationService.js'; +import type { LlmService } from '../llmService.js'; +import type { NarrationEvent } from '@dflike/shared'; + +function mockLlmService(returnValue: string | null = null): LlmService { + return { + generate: vi.fn().mockResolvedValue(returnValue), + queueDepth: () => 0, + clear: () => {}, + isDailyLimitReached: () => false, + }; +} + +describe('narrationService', () => { + it('creates a narration event with fallback text', () => { + const service = createNarrationService(mockLlmService()); + const event = service.recordInteraction({ + tick: 100, + entityIds: [1, 2], + names: ['Brynn', 'Thom'], + outcome: 'positive', + classification: 'Friend', + isProposal: false, + isPriority: false, + }); + expect(event.narration).toBeTruthy(); + expect(event.isLlmGenerated).toBe(false); + expect(event.type).toBe('social'); + }); + + it('queues LLM generation for priority interactions', () => { + const llm = mockLlmService('A vivid narration.'); + const service = createNarrationService(llm); + service.recordInteraction({ + tick: 100, + entityIds: [1, 2], + names: ['Brynn', 'Thom'], + outcome: 'positive', + classification: 'Friend', + isProposal: false, + isPriority: true, + }); + expect(llm.generate).toHaveBeenCalledWith('socialNarration', expect.any(Object)); + }); + + it('does not queue LLM for non-priority interactions', () => { + const llm = mockLlmService(); + const service = createNarrationService(llm); + service.recordInteraction({ + tick: 100, + entityIds: [1, 2], + names: ['Brynn', 'Thom'], + outcome: 'positive', + classification: 'Friend', + isProposal: false, + isPriority: false, + }); + expect(llm.generate).not.toHaveBeenCalled(); + }); + + it('always queues LLM for proposals', () => { + const llm = mockLlmService(); + const service = createNarrationService(llm); + service.recordInteraction({ + tick: 100, + entityIds: [1, 2], + names: ['Brynn', 'Thom'], + outcome: 'proposal_accepted', + classification: 'Devoted', + isProposal: true, + isPriority: false, + }); + expect(llm.generate).toHaveBeenCalled(); + }); + + it('maintains a rolling buffer of events', () => { + const service = createNarrationService(mockLlmService()); + for (let i = 0; i < 60; i++) { + service.recordInteraction({ + tick: i, + entityIds: [1, 2], + names: ['A', 'B'], + outcome: 'positive', + classification: 'Friend', + isProposal: false, + isPriority: false, + }); + } + expect(service.getRecentEvents().length).toBe(50); + }); + + it('getEventsForEntity filters by entity ID', () => { + const service = createNarrationService(mockLlmService()); + service.recordInteraction({ + tick: 1, entityIds: [1, 2], names: ['A', 'B'], + outcome: 'positive', classification: 'Friend', + isProposal: false, isPriority: false, + }); + service.recordInteraction({ + tick: 2, entityIds: [3, 4], names: ['C', 'D'], + outcome: 'negative', classification: 'Rival', + isProposal: false, isPriority: false, + }); + expect(service.getEventsForEntity(1).length).toBe(1); + expect(service.getEventsForEntity(5).length).toBe(0); + }); +}); +``` + +**Step 2: Run tests to verify they fail** + +Run: `cd /opt/dflike && npx vitest run server/src/llm/__tests__/narrationService.test.ts --reporter=verbose` +Expected: FAIL — module not found + +**Step 3: Implement narrationService.ts** + +In `server/src/llm/narrationService.ts`: + +```typescript +import type { NarrationEvent, NarrationOutcome, EntityId } from '@dflike/shared'; +import type { LlmService } from './llmService.js'; +import { generateFallbackNarration } from './narrationTemplates.js'; +import { formatStatsForPrompt } from './backstoryGenerator.js'; + +const BUFFER_SIZE = 50; + +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; +} + +export function createNarrationService(llmService: LlmService): NarrationService { + const events: 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, + }; + + events.push(event); + if (events.length > BUFFER_SIZE) { + events.shift(); + } + + service.onEventCreated?.(event); + + // Queue LLM for proposals (always) or priority interactions + const shouldNarrate = record.isProposal || record.isPriority; + if (shouldNarrate && !llmService.isDailyLimitReached()) { + const variables: Record = { + npc1Name: record.names[0], + npc2Name: record.names[1], + npc1Personality: record.npc1Personality ?? 'unknown', + npc2Personality: record.npc2Personality ?? 'unknown', + outcome: record.outcome.replace('_', ' '), + relationship: record.classification, + sentiment: String(record.sentiment ?? 0), + }; + + llmService.generate('socialNarration', variables).then(result => { + if (result) { + event.narration = result; + event.isLlmGenerated = true; + service.onEventUpdated?.(event); + } + }); + } + + return event; + }, + + getRecentEvents(): NarrationEvent[] { + return [...events]; + }, + + getEventsForEntity(entityId: EntityId): NarrationEvent[] { + return events.filter(e => + e.entityIds[0] === entityId || e.entityIds[1] === entityId + ); + }, + }; + + return service; +} +``` + +**Step 4: Run tests to verify they pass** + +Run: `cd /opt/dflike && npx vitest run server/src/llm/__tests__/narrationService.test.ts --reporter=verbose` +Expected: All pass + +**Step 5: Commit** + +```bash +git add server/src/llm/narrationService.ts server/src/llm/__tests__/narrationService.test.ts +git commit -m "feat: add narration service with event buffer and LLM priority" +``` + +--- + +### Task 4: Wire Narration into Social System + GameLoop + +**Files:** +- Create: `server/src/systems/narrationEmitter.ts` +- Create: `server/src/systems/__tests__/narrationEmitter.test.ts` +- Modify: `server/src/game/GameLoop.ts` + +**Step 1: Write failing tests for narrationEmitter** + +The `narrationEmitter` reads completed interactions (entities whose `socialState.lastOutcome` is set, BEFORE the relationship system clears them) and calls `narrationService.recordInteraction()`. + +In `server/src/systems/__tests__/narrationEmitter.test.ts`: + +```typescript +import { describe, it, expect, vi } from 'vitest'; +import { World } from '../../ecs/World.js'; +import { narrationEmitter } from '../narrationEmitter.js'; +import type { NarrationService } from '../../llm/narrationService.js'; +import type { SocialState, Stats, Relationships } from '@dflike/shared'; + +function mockNarrationService(): NarrationService { + return { + recordInteraction: vi.fn().mockReturnValue({ + id: 1, tick: 0, type: 'social', entityIds: [1, 2], + names: ['A', 'B'], outcome: 'positive', narration: 'test', isLlmGenerated: false, + }), + getRecentEvents: () => [], + getEventsForEntity: () => [], + onEventUpdated: null, + onEventCreated: null, + }; +} + +function createInteractingPair(world: World): [number, number] { + const a = world.createEntity(); + const b = world.createEntity(); + + const socialA: SocialState = { + phase: 'none', partnerId: null, phaseTimer: 0, outcome: null, + globalCooldown: 0, pairCooldowns: new Map(), + lastOutcome: { partnerId: b, outcome: 'positive', tick: 100 }, + proposalCooldown: 0, pendingProposal: null, isProposalInteraction: false, + }; + const socialB: SocialState = { + phase: 'none', partnerId: null, phaseTimer: 0, outcome: null, + globalCooldown: 0, pairCooldowns: new Map(), + lastOutcome: { partnerId: a, outcome: 'positive', tick: 100 }, + proposalCooldown: 0, pendingProposal: null, isProposalInteraction: false, + }; + + world.addComponent(a, 'socialState', socialA); + world.addComponent(b, 'socialState', socialB); + world.addComponent(a, 'name', 'Brynn'); + world.addComponent(b, 'name', 'Thom'); + world.addComponent(a, 'stats', { + strength: 10, dexterity: 10, constitution: 10, intelligence: 10, perception: 10, + sociability: 10, courage: 10, curiosity: 10, empathy: 10, temperament: 10, + }); + world.addComponent(b, 'stats', { + strength: 10, dexterity: 10, constitution: 10, intelligence: 10, perception: 10, + sociability: 10, courage: 10, curiosity: 10, empathy: 10, temperament: 10, + }); + world.addComponent(a, 'relationships', new Map()); + world.addComponent(b, 'relationships', new Map()); + + return [a, b]; +} + +describe('narrationEmitter', () => { + it('calls recordInteraction for completed interactions', () => { + const world = new World(); + const ns = mockNarrationService(); + const [a, b] = createInteractingPair(world); + + narrationEmitter(world, ns, new Set()); + + expect(ns.recordInteraction).toHaveBeenCalledTimes(1); + expect(ns.recordInteraction).toHaveBeenCalledWith( + expect.objectContaining({ + entityIds: [a, b], + names: ['Brynn', 'Thom'], + outcome: 'positive', + }) + ); + }); + + it('does not double-emit for the same pair', () => { + const world = new World(); + const ns = mockNarrationService(); + createInteractingPair(world); + + narrationEmitter(world, ns, new Set()); + expect(ns.recordInteraction).toHaveBeenCalledTimes(1); + }); + + it('marks followed NPC interactions as priority', () => { + const world = new World(); + const ns = mockNarrationService(); + const [a, _b] = createInteractingPair(world); + + narrationEmitter(world, ns, new Set([a])); + + expect(ns.recordInteraction).toHaveBeenCalledWith( + expect.objectContaining({ isPriority: true }) + ); + }); +}); +``` + +**Step 2: Run tests to verify they fail** + +Run: `cd /opt/dflike && npx vitest run server/src/systems/__tests__/narrationEmitter.test.ts --reporter=verbose` +Expected: FAIL — module not found + +**Step 3: Implement narrationEmitter** + +In `server/src/systems/narrationEmitter.ts`: + +```typescript +import type { SocialState, Stats, Relationships, EntityId } from '@dflike/shared'; +import type { World } from '../ecs/World.js'; +import type { NarrationService } from '../llm/narrationService.js'; +import { formatStatsForPrompt } from '../llm/backstoryGenerator.js'; +import { classify } from './relationshipHelpers.js'; + +export function narrationEmitter( + world: World, + narrationService: NarrationService, + followedEntityIds: Set, +): void { + const entities = world.query('socialState', 'name', 'stats'); + const processed = new Set(); + + for (const entity of entities) { + const social = world.getComponent(entity, 'socialState')!; + if (!social.lastOutcome) continue; + + const partnerId = social.lastOutcome.partnerId; + const pairKey = entity < partnerId + ? `${entity}:${partnerId}` + : `${partnerId}:${entity}`; + + if (processed.has(pairKey)) continue; + processed.add(pairKey); + + const partnerSocial = world.getComponent(partnerId, 'socialState'); + if (!partnerSocial?.lastOutcome) continue; + + const name1 = world.getComponent(entity, 'name') ?? `NPC #${entity}`; + const name2 = world.getComponent(partnerId, 'name') ?? `NPC #${partnerId}`; + + const stats1 = world.getComponent(entity, 'stats'); + const stats2 = world.getComponent(partnerId, 'stats'); + + const rels = world.getComponent(entity, 'relationships'); + const relData = rels?.get(partnerId); + const classification = relData ? classify(relData.value) : 'Stranger'; + const sentiment = relData ? Math.round(relData.value) : 0; + + // Determine if this was a proposal + const wasProposal = social.isProposalInteraction || partnerSocial.isProposalInteraction; + let outcome = social.lastOutcome.outcome; + if (wasProposal) { + outcome = social.lastOutcome.outcome === 'positive' ? 'proposal_accepted' : 'proposal_rejected'; + } + + const isPriority = followedEntityIds.has(entity) || followedEntityIds.has(partnerId) + || (relData !== undefined && Math.abs(relData.value) >= 50); + + const [id1, id2] = entity < partnerId ? [entity, partnerId] : [partnerId, entity]; + const [n1, n2] = entity < partnerId ? [name1, name2] : [name2, name1]; + const [s1, s2] = entity < partnerId ? [stats1, stats2] : [stats2, stats1]; + + narrationService.recordInteraction({ + tick: social.lastOutcome.tick, + entityIds: [id1, id2], + names: [n1, n2], + outcome, + classification, + isProposal: wasProposal, + isPriority, + npc1Personality: s1 ? formatStatsForPrompt(s1) : undefined, + npc2Personality: s2 ? formatStatsForPrompt(s2) : undefined, + sentiment, + }); + } +} +``` + +**Step 4: Run tests to verify they pass** + +Run: `cd /opt/dflike && npx vitest run server/src/systems/__tests__/narrationEmitter.test.ts --reporter=verbose` +Expected: All pass + +**Step 5: Wire into GameLoop** + +In `server/src/game/GameLoop.ts`: +- Import `createNarrationService` and `narrationEmitter` +- Create `narrationService` in constructor +- Add a `followedEntityIds: Set` field (starts empty) +- Add `narrationEmitter(this.world, this.narrationService, this.followedEntityIds)` in `update()`, BETWEEN `socialSystem` and `relationshipSystem` (must run before relationship system clears `lastOutcome`) +- Expose `narrationService` as a public readonly property +- Add `setFollowedEntity(entityId: EntityId | null)` method to manage the set + +**Step 6: Run all server tests** + +Run: `cd /opt/dflike && npx vitest run server/src --reporter=verbose` +Expected: All pass + +**Step 7: Commit** + +```bash +git add server/src/systems/narrationEmitter.ts server/src/systems/__tests__/narrationEmitter.test.ts server/src/game/GameLoop.ts +git commit -m "feat: wire narration emitter into game loop between social and relationship systems" +``` + +--- + +### Task 5: Socket.io Events for Narration + +**Files:** +- Modify: `shared/src/types.ts` (add socket events) +- Modify: `server/src/network/SocketServer.ts` +- Modify: `client/src/network/SocketClient.ts` + +**Step 1: Add narration socket events to shared types** + +In `shared/src/types.ts`, add to `ServerEvents`: + +```typescript +'narration-event': (data: NarrationEvent) => void; +'narration-update': (data: { id: number; narration: string }) => void; +'narration-history': (data: NarrationEvent[]) => void; +``` + +Import `NarrationEvent` from `./narration.js`. + +**Step 2: Wire SocketServer to emit narration events** + +In `server/src/network/SocketServer.ts`: +- In the constructor, after creating the game loop, set up narration event callbacks: + +```typescript +const narrationService = this.gameLoop.narrationService; + +narrationService.onEventCreated = (event) => { + this.io.emit('narration-event', event); +}; + +narrationService.onEventUpdated = (event) => { + this.io.emit('narration-update', { id: event.id, narration: event.narration }); +}; +``` + +- On client connection, send narration history: + +```typescript +socket.emit('narration-history', narrationService.getRecentEvents()); +``` + +- Handle follow tracking: when client sends `'player-input'` with `type: 'follow'`, call `this.gameLoop.setFollowedEntity(input.targetEntityId ?? null)`. + +**Step 3: Add client socket handlers** + +In `client/src/network/SocketClient.ts`: +- Add callback properties: + +```typescript +onNarrationEvent: ((data: NarrationEvent) => void) | null = null; +onNarrationUpdate: ((data: { id: number; narration: string }) => void) | null = null; +onNarrationHistory: ((data: NarrationEvent[]) => void) | null = null; +``` + +- Register listeners in constructor: + +```typescript +this.socket.on('narration-event', (data) => this.onNarrationEvent?.(data)); +this.socket.on('narration-update', (data) => this.onNarrationUpdate?.(data)); +this.socket.on('narration-history', (data) => this.onNarrationHistory?.(data)); +``` + +**Step 4: Run all tests** + +Run: `cd /opt/dflike && npx vitest run --reporter=verbose` +Expected: All pass + +**Step 5: Commit** + +```bash +git add shared/src/types.ts shared/src/narration.ts server/src/network/SocketServer.ts client/src/network/SocketClient.ts +git commit -m "feat: add socket.io events for narration delivery" +``` + +--- + +### Task 6: Refactor Left Panel — Tabbed Container with Stats + Events + +**Files:** +- Create: `client/src/ui/LeftPanel.ts` +- Create: `client/src/ui/EventsFeed.ts` +- Modify: `client/src/ui/SuperlativesPanel.ts` (extract content into reusable form) +- Modify: `client/src/scenes/GameScene.ts` + +**Step 1: Create LeftPanel as a tabbed container** + +`client/src/ui/LeftPanel.ts` — Creates the outer shell: left-aligned container with the slide-out panel and two tab buttons ("S" for Stats, "E" for Events) stacked vertically on the right edge. The panel content area swaps between the Superlatives content and Events feed content. + +Key structure: +- Container: fixed left, vertically centered, same styling as current SuperlativesPanel +- Tab buttons: stacked vertically, "S" (Stats) on top, "E" (Events) below +- Active tab highlighted with different background color +- Panel slides out when either tab is clicked; clicking the active tab collapses it +- Same EarthBound double-border aesthetic +- Header text changes based on active tab: "SUPERLATIVES" or "EVENTS" + +Constructor takes two content elements (superlatives content div, events feed div) and manages showing/hiding them. + +Public API: +- `expand(tab: 'stats' | 'events'): void` +- `collapse(): void` +- `isExpanded(): boolean` +- `getActiveTab(): 'stats' | 'events' | null` +- `destroy(): void` + +Dispatches custom events: `'left-panel-opened'` (with `detail: { tab }`) and `'left-panel-closed'`. + +**Step 2: Create EventsFeed** + +`client/src/ui/EventsFeed.ts` — A scrollable list of narration events. + +```typescript +export class EventsFeed { + private container: HTMLDivElement; + private events: NarrationEvent[] = []; + private eventElements: Map = new Map(); + private maxEvents = 30; + + constructor(); + getElement(): HTMLDivElement; + addEvent(event: NarrationEvent): void; + updateEvent(id: number, narration: string): void; + loadHistory(events: NarrationEvent[]): void; + clear(): void; +} +``` + +Each event entry: +- Small timestamp (game time, muted color) +- Narration text (primary color, wrapping) +- NPC names are clickable (dispatch `'narration-follow'` custom event with entityId) +- LLM-generated text shown in slightly different color or with a subtle indicator +- Auto-scrolls to bottom on new events (unless user has scrolled up) +- Newest events at the bottom + +**Step 3: Refactor SuperlativesPanel** + +Modify `client/src/ui/SuperlativesPanel.ts`: +- Extract the content (the inner frame with header + categories) into a method `getContentElement(): HTMLDivElement` +- Remove the container, tab, and slide-out logic (LeftPanel handles that now) +- Keep the `update(data)` method +- Remove `toggle()`, `expand()`, `collapse()`, `isExpanded()`, `destroy()` — LeftPanel owns these now + +**Step 4: Update GameScene** + +In `client/src/scenes/GameScene.ts`: +- Replace `SuperlativesPanel` instantiation with `LeftPanel` that wraps superlatives content + events feed +- Update the panel open/close event listeners to use `'left-panel-opened'` / `'left-panel-closed'` +- Subscribe/unsubscribe superlatives based on which tab is active +- Wire `SocketClient` narration callbacks to `EventsFeed`: + - `onNarrationEvent` → `eventsFeed.addEvent()` + - `onNarrationUpdate` → `eventsFeed.updateEvent()` + - `onNarrationHistory` → `eventsFeed.loadHistory()` +- Wire `'narration-follow'` custom event to follow that NPC (same as `'superlative-follow'`) + +**Step 5: Test manually** + +Run: `cd /opt/dflike && npm run dev` (or however the dev server starts) +Verify: +- Left panel has two tab buttons (S and E) +- Clicking S opens superlatives (same as before) +- Clicking E opens events feed +- Events appear as NPCs interact +- Clicking an NPC name in events follows that NPC +- LLM narrations replace fallback text when they arrive + +**Step 6: Commit** + +```bash +git add client/src/ui/LeftPanel.ts client/src/ui/EventsFeed.ts client/src/ui/SuperlativesPanel.ts client/src/scenes/GameScene.ts +git commit -m "feat: add tabbed left panel with Events feed alongside Superlatives" +``` + +--- + +### Task 7: Recent Events in NPC Info Panel + +**Files:** +- Modify: `client/src/ui/NpcInfoPanel.ts` +- Modify: `client/src/scenes/GameScene.ts` + +**Step 1: Add recent events section to NPC info panel** + +In `client/src/ui/NpcInfoPanel.ts`: +- Add a new div after the backstory element: `recentEventsEl` +- Styled similarly to backstory: small font, muted color, with a separator above +- Shows last 2-3 narration events involving this NPC +- Each event is a single line of narration text +- Add method: `updateRecentEvents(events: NarrationEvent[]): void` + +**Step 2: Wire events to panel in GameScene** + +In `client/src/scenes/GameScene.ts`: +- When in follow mode and panel is visible, filter narration events for the followed NPC +- On each `narration-event` and `narration-update`, check if the followed NPC is involved +- Call `npcInfoPanel.updateRecentEvents(filteredEvents)` with the last 3 events for that NPC + +Store a local reference to narration events (or query the EventsFeed) to get events for a specific entity. + +**Step 3: Test manually** + +- Follow an NPC +- Wait for them to interact +- Verify narration text appears in the NPC info panel's recent events section + +**Step 4: Commit** + +```bash +git add client/src/ui/NpcInfoPanel.ts client/src/scenes/GameScene.ts +git commit -m "feat: show recent narration events in NPC info panel" +``` + +--- + +### Task 8: Follow Tracking via Socket + +**Files:** +- Modify: `shared/src/types.ts` (add follow-npc client event if not already present) +- Modify: `server/src/network/SocketServer.ts` +- Modify: `client/src/scenes/GameScene.ts` + +**Step 1: Add follow tracking** + +The server needs to know which NPC each client is following so it can prioritize LLM narration for those NPCs. + +In `shared/src/types.ts`, add to `ClientEvents`: + +```typescript +'follow-npc': (data: { entityId: EntityId | null }) => void; +``` + +In `client/src/scenes/GameScene.ts`: +- When entering follow mode or cycling NPCs, emit `'follow-npc'` with the followed entity ID +- When leaving follow mode, emit `'follow-npc'` with `null` + +In `server/src/network/SocketServer.ts`: +- Track followed entity IDs per socket in a `Map` +- On `'follow-npc'`, update the map and rebuild `gameLoop.followedEntityIds` set from all active follows + +**Step 2: Test manually** + +- Follow an NPC, verify their interactions get LLM narration +- Switch to camera mode, verify narration falls back to template + +**Step 3: Commit** + +```bash +git add shared/src/types.ts server/src/network/SocketServer.ts client/src/scenes/GameScene.ts +git commit -m "feat: track followed NPCs for LLM narration priority" +``` + +--- + +### Task 9: Final Integration Testing + Cleanup + +**Step 1: Run all tests** + +Run: `cd /opt/dflike && npx vitest run --reporter=verbose` +Expected: All pass + +**Step 2: Manual integration test** + +Run: `cd /opt/dflike && npm run dev` + +Checklist: +- [ ] Events tab shows narration entries as NPCs interact +- [ ] Template fallback text appears immediately +- [ ] LLM narration replaces fallback for priority interactions (when API key is set) +- [ ] Proposals always get LLM narration +- [ ] NPC info panel shows recent events for followed NPC +- [ ] Clicking NPC names in events feed follows that NPC +- [ ] Tab switching between Stats and Events works correctly +- [ ] Panel collapse/expand works for both tabs +- [ ] Game functions normally without API key (fallback only) + +**Step 3: Update roadmap** + +In `docs/plans/2026-03-08-llm-integration-roadmap.md`, mark task 1.3 as complete: + +```markdown +### 1.3 Social Interaction Narration +- [x] When social outcome fires, queue LLM narration request +- [x] Use NPC stats, relationship tier, and recent history as prompt context +- [x] Deliver narration text to client for display +- [x] **Status:** COMPLETE +``` + +**Step 4: Commit** + +```bash +git add docs/plans/2026-03-08-llm-integration-roadmap.md +git commit -m "docs: mark task 1.3 social narration as complete" +```