From 541b70b7815c15d7efe2b4c67f9fc7ecc28c6eb7 Mon Sep 17 00:00:00 2001 From: root Date: Sun, 8 Mar 2026 18:02:59 +0000 Subject: [PATCH] feat: wire narration emitter into social system and GameLoop Add narrationEmitter system that reads completed interactions from socialState.lastOutcome and feeds them to the NarrationService. It runs between socialSystem and relationshipSystem to capture outcomes before they are cleared. Includes pair deduplication, proposal detection, priority marking for followed NPCs and high-value relationships, and personality formatting for LLM prompts. Co-Authored-By: Claude Opus 4.6 --- server/src/game/GameLoop.ts | 15 ++ .../__tests__/narrationEmitter.test.ts | 219 ++++++++++++++++++ server/src/systems/narrationEmitter.ts | 95 ++++++++ shared/src/narration.ts | 4 +- 4 files changed, 331 insertions(+), 2 deletions(-) create mode 100644 server/src/systems/__tests__/narrationEmitter.test.ts create mode 100644 server/src/systems/narrationEmitter.ts diff --git a/server/src/game/GameLoop.ts b/server/src/game/GameLoop.ts index 1f7d7f1..febc060 100644 --- a/server/src/game/GameLoop.ts +++ b/server/src/game/GameLoop.ts @@ -11,11 +11,16 @@ import { relationshipSystem } from '../systems/relationshipSystem.js'; import { createBondRegistry } from '../systems/bondRegistry.js'; import { spawnNPC } from './spawner.js'; import { createLlmService, type LlmService } from '../llm/llmService.js'; +import { createNarrationService, type NarrationService } from '../llm/narrationService.js'; +import { narrationEmitter } from '../systems/narrationEmitter.js'; +import type { EntityId } from '@dflike/shared'; export class GameLoop { readonly world: World; readonly map: GameMap; readonly llmService: LlmService; + readonly narrationService: NarrationService; + public followedEntityIds: Set = new Set(); private tick = 0; private interval: ReturnType | null = null; private onBroadcast: (() => void) | null = null; @@ -25,6 +30,7 @@ export class GameLoop { this.world.setSingleton('bondRegistry', createBondRegistry()); this.map = new GameMap(); this.llmService = createLlmService(); + this.narrationService = createNarrationService(this.llmService); this.setupMap(); this.spawnInitialNPCs(8); } @@ -78,6 +84,7 @@ export class GameLoop { needsDecaySystem(this.world); npcBrainSystem(this.world, this.map); socialSystem(this.world); + narrationEmitter(this.world, this.narrationService, this.followedEntityIds); relationshipSystem(this.world); movementSystem(this.world); @@ -91,6 +98,14 @@ export class GameLoop { return this.tick; } + setFollowedEntity(entityId: EntityId | null): void { + if (entityId === null) { + this.followedEntityIds.clear(); + } else { + this.followedEntityIds.add(entityId); + } + } + getGameTime(): number { const dayTicks = 100 / ENERGY_DECAY_PER_TICK; const nightTicks = dayTicks / DAY_NIGHT_RATIO; diff --git a/server/src/systems/__tests__/narrationEmitter.test.ts b/server/src/systems/__tests__/narrationEmitter.test.ts new file mode 100644 index 0000000..4b4072f --- /dev/null +++ b/server/src/systems/__tests__/narrationEmitter.test.ts @@ -0,0 +1,219 @@ +import { describe, it, expect, vi } from 'vitest'; +import { World } from '../../ecs/World.js'; +import { narrationEmitter } from '../narrationEmitter.js'; +import type { NarrationService, InteractionRecord } from '../../llm/narrationService.js'; +import type { + Position, Needs, Movement, NPCBrain, SocialState, Stats, + StatModifiers, Relationships, EntityId, NarrationEvent, +} from '@dflike/shared'; + +function createMockNarrationService(): NarrationService { + return { + recordInteraction: vi.fn(() => ({ + id: 1, + tick: 0, + type: 'social' as const, + entityIds: [1, 2] as [EntityId, EntityId], + names: ['A', 'B'] as [string, string], + outcome: 'positive' as const, + narration: 'test', + isLlmGenerated: false, + })), + getRecentEvents: vi.fn(() => []), + getEventsForEntity: vi.fn(() => []), + onEventUpdated: null, + onEventCreated: null, + }; +} + +function createNPC( + world: World, + name: string, + opts?: { stats?: Partial }, +): EntityId { + const e = world.createEntity(); + world.addComponent(e, 'position', { x: 0, y: 0 }); + world.addComponent(e, 'needs', { hunger: 80, energy: 80 }); + world.addComponent(e, 'movement', { + state: 'idle', target: null, path: [], direction: 0, moveProgress: 0, + }); + world.addComponent(e, 'npcBrain', { currentGoal: 'wander', goalQueue: [] }); + world.addComponent(e, 'socialState', { + phase: 'none', partnerId: null, phaseTimer: 0, outcome: null, + globalCooldown: 0, pairCooldowns: new Map(), lastOutcome: null, + proposalCooldown: 0, pendingProposal: null, isProposalInteraction: false, + }); + const baseStats: Stats = { + strength: 10, dexterity: 10, constitution: 10, intelligence: 10, perception: 10, + sociability: 10, courage: 10, curiosity: 10, empathy: 10, temperament: 10, + ...(opts?.stats ?? {}), + }; + world.addComponent(e, 'stats', baseStats); + world.addComponent(e, 'statModifiers', { modifiers: [] }); + world.addComponent(e, 'relationships', new Map()); + world.addComponent(e, 'name', name); + return e; +} + +describe('narrationEmitter', () => { + it('calls recordInteraction for completed interactions', () => { + const world = new World(); + const service = createMockNarrationService(); + const a = createNPC(world, 'Alice'); + const b = createNPC(world, 'Bob'); + + const sa = world.getComponent(a, 'socialState')!; + const sb = world.getComponent(b, 'socialState')!; + sa.lastOutcome = { partnerId: b, outcome: 'positive', tick: 10 }; + sb.lastOutcome = { partnerId: a, outcome: 'positive', tick: 10 }; + + narrationEmitter(world, service, new Set()); + + expect(service.recordInteraction).toHaveBeenCalledTimes(1); + const call = (service.recordInteraction as ReturnType).mock.calls[0][0] as InteractionRecord; + expect(call.names).toEqual(['Alice', 'Bob']); + expect(call.outcome).toBe('positive'); + expect(call.tick).toBe(10); + }); + + it('does not double-emit for the same pair', () => { + const world = new World(); + const service = createMockNarrationService(); + const a = createNPC(world, 'Alice'); + const b = createNPC(world, 'Bob'); + + const sa = world.getComponent(a, 'socialState')!; + const sb = world.getComponent(b, 'socialState')!; + sa.lastOutcome = { partnerId: b, outcome: 'positive', tick: 10 }; + sb.lastOutcome = { partnerId: a, outcome: 'positive', tick: 10 }; + + narrationEmitter(world, service, new Set()); + + // Should only be called once despite both entities having lastOutcome + expect(service.recordInteraction).toHaveBeenCalledTimes(1); + }); + + it('marks followed NPC interactions as priority', () => { + const world = new World(); + const service = createMockNarrationService(); + const a = createNPC(world, 'Alice'); + const b = createNPC(world, 'Bob'); + + const sa = world.getComponent(a, 'socialState')!; + const sb = world.getComponent(b, 'socialState')!; + sa.lastOutcome = { partnerId: b, outcome: 'positive', tick: 10 }; + sb.lastOutcome = { partnerId: a, outcome: 'positive', tick: 10 }; + + narrationEmitter(world, service, new Set([a])); + + const call = (service.recordInteraction as ReturnType).mock.calls[0][0] as InteractionRecord; + expect(call.isPriority).toBe(true); + }); + + it('marks high-value relationship interactions as priority (abs >= 50)', () => { + const world = new World(); + const service = createMockNarrationService(); + const a = createNPC(world, 'Alice'); + const b = createNPC(world, 'Bob'); + + // Set up a high-value relationship + const relsA = world.getComponent(a, 'relationships')!; + relsA.set(b, { value: 55, interactions: 5, lastInteractionTick: 0, status: 'active' }); + + const sa = world.getComponent(a, 'socialState')!; + const sb = world.getComponent(b, 'socialState')!; + sa.lastOutcome = { partnerId: b, outcome: 'positive', tick: 10 }; + sb.lastOutcome = { partnerId: a, outcome: 'positive', tick: 10 }; + + narrationEmitter(world, service, new Set()); + + const call = (service.recordInteraction as ReturnType).mock.calls[0][0] as InteractionRecord; + expect(call.isPriority).toBe(true); + }); + + it('does not mark low-value unfollowed interactions as priority', () => { + const world = new World(); + const service = createMockNarrationService(); + const a = createNPC(world, 'Alice'); + const b = createNPC(world, 'Bob'); + + const sa = world.getComponent(a, 'socialState')!; + const sb = world.getComponent(b, 'socialState')!; + sa.lastOutcome = { partnerId: b, outcome: 'positive', tick: 10 }; + sb.lastOutcome = { partnerId: a, outcome: 'positive', tick: 10 }; + + narrationEmitter(world, service, new Set()); + + const call = (service.recordInteraction as ReturnType).mock.calls[0][0] as InteractionRecord; + expect(call.isPriority).toBe(false); + }); + + it('detects proposal interactions and maps accepted outcome', () => { + const world = new World(); + const service = createMockNarrationService(); + const a = createNPC(world, 'Alice'); + const b = createNPC(world, 'Bob'); + + const sa = world.getComponent(a, 'socialState')!; + const sb = world.getComponent(b, 'socialState')!; + sa.lastOutcome = { partnerId: b, outcome: 'positive', tick: 10 }; + sb.lastOutcome = { partnerId: a, outcome: 'positive', tick: 10 }; + sa.isProposalInteraction = true; + + narrationEmitter(world, service, new Set()); + + const call = (service.recordInteraction as ReturnType).mock.calls[0][0] as InteractionRecord; + expect(call.isProposal).toBe(true); + expect(call.outcome).toBe('proposal_accepted'); + }); + + it('detects proposal interactions and maps rejected outcome', () => { + const world = new World(); + const service = createMockNarrationService(); + const a = createNPC(world, 'Alice'); + const b = createNPC(world, 'Bob'); + + const sa = world.getComponent(a, 'socialState')!; + const sb = world.getComponent(b, 'socialState')!; + sa.lastOutcome = { partnerId: b, outcome: 'negative', tick: 10 }; + sb.lastOutcome = { partnerId: a, outcome: 'negative', tick: 10 }; + sb.isProposalInteraction = true; + + narrationEmitter(world, service, new Set()); + + const call = (service.recordInteraction as ReturnType).mock.calls[0][0] as InteractionRecord; + expect(call.isProposal).toBe(true); + expect(call.outcome).toBe('proposal_rejected'); + }); + + it('does nothing when no lastOutcome exists', () => { + const world = new World(); + const service = createMockNarrationService(); + createNPC(world, 'Alice'); + createNPC(world, 'Bob'); + + narrationEmitter(world, service, new Set()); + + expect(service.recordInteraction).not.toHaveBeenCalled(); + }); + + it('marks negative high-value relationship as priority (abs >= 50)', () => { + const world = new World(); + const service = createMockNarrationService(); + const a = createNPC(world, 'Alice'); + const b = createNPC(world, 'Bob'); + + const relsA = world.getComponent(a, 'relationships')!; + relsA.set(b, { value: -60, interactions: 5, lastInteractionTick: 0, status: 'active' }); + + const sa = world.getComponent(a, 'socialState')!; + const sb = world.getComponent(b, 'socialState')!; + sa.lastOutcome = { partnerId: b, outcome: 'negative', tick: 10 }; + sb.lastOutcome = { partnerId: a, outcome: 'negative', tick: 10 }; + + narrationEmitter(world, service, new Set()); + + const call = (service.recordInteraction as ReturnType).mock.calls[0][0] as InteractionRecord; + expect(call.isPriority).toBe(true); + }); +}); diff --git a/server/src/systems/narrationEmitter.ts b/server/src/systems/narrationEmitter.ts new file mode 100644 index 0000000..11a5c54 --- /dev/null +++ b/server/src/systems/narrationEmitter.ts @@ -0,0 +1,95 @@ +import type { + SocialState, Stats, Relationships, EntityId, NarrationOutcome, +} from '@dflike/shared'; +import type { World } from '../ecs/World.js'; +import type { NarrationService } from '../llm/narrationService.js'; +import { classify } from './relationshipHelpers.js'; + +function formatStatsForPrompt(stats: Stats): string { + const personality = [ + `sociability:${stats.sociability}`, + `courage:${stats.courage}`, + `curiosity:${stats.curiosity}`, + `empathy:${stats.empathy}`, + `temperament:${stats.temperament}`, + ]; + return personality.join(', '); +} + +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; + + // Deduplicate by pair key + const pairKey = entity < partnerId + ? `${entity}:${partnerId}` + : `${partnerId}:${entity}`; + + if (processed.has(pairKey)) continue; + processed.add(pairKey); + + // Get partner's social state + const partnerSocial = world.getComponent(partnerId, 'socialState'); + if (!partnerSocial || !partnerSocial.lastOutcome) continue; + + // Get names + const name1 = world.getComponent(entity, 'name') ?? 'Unknown'; + const name2 = world.getComponent(partnerId, 'name') ?? 'Unknown'; + + // Get stats + const stats1 = world.getComponent(entity, 'stats')!; + const stats2 = world.getComponent(partnerId, 'stats'); + + // Get relationship data + const rels = world.getComponent(entity, 'relationships'); + const rel = rels?.get(partnerId); + const relValue = rel?.value ?? 0; + const classification = classify(relValue); + + // Determine if this is a proposal interaction + const isProposal = social.isProposalInteraction || partnerSocial.isProposalInteraction; + + // Determine outcome + let outcome: NarrationOutcome; + if (isProposal) { + outcome = social.lastOutcome.outcome === 'positive' + ? 'proposal_accepted' + : 'proposal_rejected'; + } else { + outcome = social.lastOutcome.outcome; + } + + // Determine priority: followed NPC or high-value relationship (abs >= 50) + const isFollowed = followedEntityIds.has(entity) || followedEntityIds.has(partnerId); + const isHighValue = Math.abs(relValue) >= 50; + const isPriority = isFollowed || isHighValue; + + // Order entity IDs consistently (lower first) + const [firstId, secondId] = entity < partnerId ? [entity, partnerId] : [partnerId, entity]; + const [firstName, secondName] = entity < partnerId ? [name1, name2] : [name2, name1]; + const [firstStats, secondStats] = entity < partnerId ? [stats1, stats2] : [stats2, stats1]; + + narrationService.recordInteraction({ + tick: social.lastOutcome.tick, + entityIds: [firstId, secondId], + names: [firstName, secondName], + outcome, + classification, + isProposal, + isPriority, + npc1Personality: firstStats ? formatStatsForPrompt(firstStats) : undefined, + npc2Personality: secondStats ? formatStatsForPrompt(secondStats) : undefined, + sentiment: relValue, + }); + } +} diff --git a/shared/src/narration.ts b/shared/src/narration.ts index a5474ae..147b293 100644 --- a/shared/src/narration.ts +++ b/shared/src/narration.ts @@ -1,6 +1,6 @@ -import type { EntityId } from './types.js'; +import type { EntityId, InteractionOutcome } from './types.js'; -export type NarrationOutcome = 'positive' | 'negative' | 'proposal_accepted' | 'proposal_rejected'; +export type NarrationOutcome = InteractionOutcome | 'proposal_accepted' | 'proposal_rejected'; export interface NarrationEvent { id: number;