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;