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:
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user