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 <noreply@anthropic.com>
This commit is contained in:
root
2026-03-08 18:02:59 +00:00
parent afc8329a82
commit 541b70b781
4 changed files with 331 additions and 2 deletions
+15
View File
@@ -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<EntityId> = new Set();
private tick = 0;
private interval: ReturnType<typeof setInterval> | 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;
@@ -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<Stats> },
): EntityId {
const e = world.createEntity();
world.addComponent<Position>(e, 'position', { x: 0, y: 0 });
world.addComponent<Needs>(e, 'needs', { hunger: 80, energy: 80 });
world.addComponent<Movement>(e, 'movement', {
state: 'idle', target: null, path: [], direction: 0, moveProgress: 0,
});
world.addComponent<NPCBrain>(e, 'npcBrain', { currentGoal: 'wander', goalQueue: [] });
world.addComponent<SocialState>(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<Stats>(e, 'stats', baseStats);
world.addComponent<StatModifiers>(e, 'statModifiers', { modifiers: [] });
world.addComponent<Relationships>(e, 'relationships', new Map());
world.addComponent<string>(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<SocialState>(a, 'socialState')!;
const sb = world.getComponent<SocialState>(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<typeof vi.fn>).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<SocialState>(a, 'socialState')!;
const sb = world.getComponent<SocialState>(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<SocialState>(a, 'socialState')!;
const sb = world.getComponent<SocialState>(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<typeof vi.fn>).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<Relationships>(a, 'relationships')!;
relsA.set(b, { value: 55, interactions: 5, lastInteractionTick: 0, status: 'active' });
const sa = world.getComponent<SocialState>(a, 'socialState')!;
const sb = world.getComponent<SocialState>(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<typeof vi.fn>).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<SocialState>(a, 'socialState')!;
const sb = world.getComponent<SocialState>(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<typeof vi.fn>).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<SocialState>(a, 'socialState')!;
const sb = world.getComponent<SocialState>(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<typeof vi.fn>).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<SocialState>(a, 'socialState')!;
const sb = world.getComponent<SocialState>(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<typeof vi.fn>).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<Relationships>(a, 'relationships')!;
relsA.set(b, { value: -60, interactions: 5, lastInteractionTick: 0, status: 'active' });
const sa = world.getComponent<SocialState>(a, 'socialState')!;
const sb = world.getComponent<SocialState>(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<typeof vi.fn>).mock.calls[0][0] as InteractionRecord;
expect(call.isPriority).toBe(true);
});
});
+95
View File
@@ -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<EntityId>,
): void {
const entities = world.query('socialState', 'name', 'stats');
const processed = new Set<string>();
for (const entity of entities) {
const social = world.getComponent<SocialState>(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<SocialState>(partnerId, 'socialState');
if (!partnerSocial || !partnerSocial.lastOutcome) continue;
// Get names
const name1 = world.getComponent<string>(entity, 'name') ?? 'Unknown';
const name2 = world.getComponent<string>(partnerId, 'name') ?? 'Unknown';
// Get stats
const stats1 = world.getComponent<Stats>(entity, 'stats')!;
const stats2 = world.getComponent<Stats>(partnerId, 'stats');
// Get relationship data
const rels = world.getComponent<Relationships>(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,
});
}
}
+2 -2
View File
@@ -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;