diff --git a/docs/plans/2026-03-08-npc-event-memory-impl.md b/docs/plans/2026-03-08-npc-event-memory-impl.md new file mode 100644 index 0000000..541c14c --- /dev/null +++ b/docs/plans/2026-03-08-npc-event-memory-impl.md @@ -0,0 +1,1250 @@ +# NPC Event Memory System Implementation Plan + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. + +**Goal:** Add a per-NPC event memory system that captures all significant events, stores them in a circular buffer, surfaces them in the info panel as a History tab, and provides smart selection for LLM prompt context. + +**Architecture:** New `EventMemoryService` (singleton, factory pattern like `createNarrationService`) manages per-entity circular buffers of `MemoryEvent` objects. Existing systems call `service.record()` at event sites. Client receives events via new socket events. Thought and narration templates get richer context via `selectForPrompt()`. + +**Tech Stack:** TypeScript, vitest (server tests), Socket.io (client delivery), Phaser 3 (UI) + +--- + +### Task 1: Shared Types — MemoryEvent + Socket Events + +**Files:** +- Modify: `shared/src/types.ts` + +**Step 1: Add MemoryEvent types to shared/src/types.ts** + +Add after the `NarrationEvent` import block (line 2), before the EntityId type (line 5): + +```typescript +// Event memory types +export type MemoryEventType = + | 'social_positive' | 'social_negative' + | 'proposal_accepted' | 'proposal_rejected' + | 'tier_change' + | 'need_crisis' | 'need_recovery' + | 'goal_change' + | 'bond_formed' | 'bond_dissolved' + | 'spawned'; + +export interface MemoryEvent { + id: number; + type: MemoryEventType; + tick: number; + otherEntityId?: EntityId; + otherName?: string; + detail: string; + oldTier?: string; + newTier?: string; + need?: 'hunger' | 'energy'; + oldGoal?: string; + newGoal?: string; +} +``` + +Note: `EntityId` is used in `MemoryEvent`, so place the type/interface AFTER the `EntityId` declaration (line 5). The `MemoryEventType` type alias can go before it since it doesn't reference `EntityId`. + +**Step 2: Add socket events to ServerEvents interface** + +In `ServerEvents` (around line 190), add: + +```typescript +'memory-event': (data: { entityId: EntityId; event: MemoryEvent }) => void; +'memory-history': (data: { entityId: EntityId; events: MemoryEvent[] }) => void; +``` + +**Step 3: Rebuild shared types** + +Run: `npx -w shared tsc` +Expected: Clean compilation, no errors + +**Step 4: Commit** + +```bash +git add shared/src/types.ts +git commit -m "feat(memory): add MemoryEvent types and socket events to shared" +``` + +--- + +### Task 2: EventMemoryService — Core + Tests + +**Files:** +- Create: `server/src/llm/eventMemoryService.ts` +- Create: `server/src/llm/__tests__/eventMemoryService.test.ts` + +**Step 1: Write the tests** + +Create `server/src/llm/__tests__/eventMemoryService.test.ts`: + +```typescript +import { describe, it, expect, vi } from 'vitest'; +import { createEventMemoryService } from '../eventMemoryService.js'; +import type { MemoryEventType } from '@dflike/shared'; + +function makeEvent(overrides: Partial<{ type: MemoryEventType; tick: number; otherEntityId: number; otherName: string; detail: string }> = {}) { + return { + type: 'social_positive' as MemoryEventType, + tick: 100, + detail: 'Had a positive interaction with Bob', + ...overrides, + }; +} + +describe('EventMemoryService', () => { + it('records an event and assigns auto-incrementing id', () => { + const service = createEventMemoryService(); + service.record(1, makeEvent()); + service.record(1, makeEvent({ tick: 200 })); + const events = service.getEvents(1); + expect(events).toHaveLength(2); + expect(events[0].id).toBe(1); + expect(events[1].id).toBe(2); + }); + + it('returns empty array for unknown entity', () => { + const service = createEventMemoryService(); + expect(service.getEvents(99)).toEqual([]); + }); + + it('caps buffer at maxEvents per entity', () => { + const service = createEventMemoryService(10); + for (let i = 0; i < 15; i++) { + service.record(1, makeEvent({ tick: i })); + } + const events = service.getEvents(1); + expect(events).toHaveLength(10); + expect(events[0].tick).toBe(5); // oldest 5 dropped + }); + + it('getRecentEvents returns last N events', () => { + const service = createEventMemoryService(); + for (let i = 0; i < 10; i++) { + service.record(1, makeEvent({ tick: i })); + } + const recent = service.getRecentEvents(1, 3); + expect(recent).toHaveLength(3); + expect(recent[0].tick).toBe(7); + expect(recent[2].tick).toBe(9); + }); + + it('separate buffers per entity', () => { + const service = createEventMemoryService(); + service.record(1, makeEvent({ detail: 'event for 1' })); + service.record(2, makeEvent({ detail: 'event for 2' })); + expect(service.getEvents(1)).toHaveLength(1); + expect(service.getEvents(2)).toHaveLength(1); + expect(service.getEvents(1)[0].detail).toBe('event for 1'); + }); + + it('onEventRecorded callback fires with entityId and event', () => { + const service = createEventMemoryService(); + const recorded: Array<{ entityId: number; event: any }> = []; + service.onEventRecorded = (entityId, event) => recorded.push({ entityId, event }); + + service.record(1, makeEvent()); + expect(recorded).toHaveLength(1); + expect(recorded[0].entityId).toBe(1); + expect(recorded[0].event.id).toBe(1); + }); + + describe('selectForPrompt', () => { + it('returns most recent events by default', () => { + const service = createEventMemoryService(); + for (let i = 0; i < 20; i++) { + service.record(1, makeEvent({ tick: i })); + } + const selected = service.selectForPrompt(1, { maxCount: 5 }); + expect(selected).toHaveLength(5); + // Should be most recent ticks + expect(selected[0].tick).toBe(19); + }); + + it('boosts events matching preferTypes', () => { + const service = createEventMemoryService(); + // Old event with preferred type + service.record(1, makeEvent({ tick: 1, type: 'tier_change' })); + // Many recent events with non-preferred type + for (let i = 2; i <= 6; i++) { + service.record(1, makeEvent({ tick: i, type: 'goal_change' })); + } + const selected = service.selectForPrompt(1, { + maxCount: 3, + preferTypes: ['tier_change'], + }); + // tier_change event should appear despite being oldest + expect(selected.some(e => e.type === 'tier_change')).toBe(true); + }); + + it('boosts events matching preferEntityId', () => { + const service = createEventMemoryService(); + // Old event involving entity 5 + service.record(1, makeEvent({ tick: 1, otherEntityId: 5 })); + // Many recent events involving entity 9 + for (let i = 2; i <= 6; i++) { + service.record(1, makeEvent({ tick: i, otherEntityId: 9 })); + } + const selected = service.selectForPrompt(1, { + maxCount: 3, + preferEntityId: 5, + }); + expect(selected.some(e => e.otherEntityId === 5)).toBe(true); + }); + + it('returns empty array for unknown entity', () => { + const service = createEventMemoryService(); + expect(service.selectForPrompt(99, { maxCount: 5 })).toEqual([]); + }); + + it('returns all events if fewer than maxCount', () => { + const service = createEventMemoryService(); + service.record(1, makeEvent({ tick: 1 })); + service.record(1, makeEvent({ tick: 2 })); + const selected = service.selectForPrompt(1, { maxCount: 10 }); + expect(selected).toHaveLength(2); + }); + }); +}); +``` + +**Step 2: Run tests to verify they fail** + +Run: `npm -w server run test -- --run src/llm/__tests__/eventMemoryService.test.ts` +Expected: FAIL — module not found + +**Step 3: Implement EventMemoryService** + +Create `server/src/llm/eventMemoryService.ts`: + +```typescript +import type { EntityId, MemoryEvent, MemoryEventType } from '@dflike/shared'; + +export interface EventMemoryService { + record(entityId: EntityId, event: Omit): MemoryEvent; + getEvents(entityId: EntityId): MemoryEvent[]; + getRecentEvents(entityId: EntityId, count: number): MemoryEvent[]; + selectForPrompt(entityId: EntityId, opts: { + maxCount: number; + preferTypes?: MemoryEventType[]; + preferEntityId?: EntityId; + }): MemoryEvent[]; + onEventRecorded: ((entityId: EntityId, event: MemoryEvent) => void) | null; +} + +export function createEventMemoryService(maxEvents = 50): EventMemoryService { + const buffers = new Map(); + let nextId = 1; + + function getBuffer(entityId: EntityId): MemoryEvent[] { + let buf = buffers.get(entityId); + if (!buf) { + buf = []; + buffers.set(entityId, buf); + } + return buf; + } + + const service: EventMemoryService = { + onEventRecorded: null, + + record(entityId: EntityId, eventData: Omit): MemoryEvent { + const event: MemoryEvent = { id: nextId++, ...eventData }; + const buf = getBuffer(entityId); + buf.push(event); + if (buf.length > maxEvents) { + buf.shift(); + } + service.onEventRecorded?.(entityId, event); + return event; + }, + + getEvents(entityId: EntityId): MemoryEvent[] { + return [...(buffers.get(entityId) ?? [])]; + }, + + getRecentEvents(entityId: EntityId, count: number): MemoryEvent[] { + const buf = buffers.get(entityId) ?? []; + return buf.slice(-count); + }, + + selectForPrompt(entityId: EntityId, opts: { + maxCount: number; + preferTypes?: MemoryEventType[]; + preferEntityId?: EntityId; + }): MemoryEvent[] { + const buf = buffers.get(entityId); + if (!buf || buf.length === 0) return []; + if (buf.length <= opts.maxCount) return [...buf].reverse(); + + const latestTick = buf[buf.length - 1].tick; + const oldestTick = buf[0].tick; + const tickRange = Math.max(1, latestTick - oldestTick); + + const scored = buf.map((event) => { + // Recency: 0.0 to 1.0 + let score = (event.tick - oldestTick) / tickRange; + + // Type match: 2x boost + if (opts.preferTypes && opts.preferTypes.includes(event.type)) { + score *= 2; + } + + // Entity match: 3x boost + if (opts.preferEntityId != null && event.otherEntityId === opts.preferEntityId) { + score *= 3; + } + + return { event, score }; + }); + + scored.sort((a, b) => b.score - a.score); + return scored.slice(0, opts.maxCount).map((s) => s.event); + }, + }; + + return service; +} +``` + +**Step 4: Run tests to verify they pass** + +Run: `npm -w server run test -- --run src/llm/__tests__/eventMemoryService.test.ts` +Expected: All tests PASS + +**Step 5: Commit** + +```bash +git add server/src/llm/eventMemoryService.ts server/src/llm/__tests__/eventMemoryService.test.ts +git commit -m "feat(memory): add EventMemoryService with smart selection and tests" +``` + +--- + +### Task 3: Wire EventMemoryService into GameLoop + SocketServer + +**Files:** +- Modify: `server/src/game/GameLoop.ts` +- Modify: `server/src/network/SocketServer.ts` + +**Step 1: Add EventMemoryService to GameLoop** + +In `server/src/game/GameLoop.ts`: + +Add import at top: +```typescript +import { createEventMemoryService, type EventMemoryService } from '../llm/eventMemoryService.js'; +``` + +Add property to class (after `thoughtSystem` property, around line 25): +```typescript +readonly eventMemoryService: EventMemoryService; +``` + +In constructor (after `this.narrationService` creation, around line 36): +```typescript +this.eventMemoryService = createEventMemoryService(); +``` + +**Step 2: Wire callbacks in SocketServer** + +In `server/src/network/SocketServer.ts`: + +In `setupBroadcast()` method (after thoughtSystem.onThought wiring, around line 47), add: + +```typescript +this.gameLoop.eventMemoryService.onEventRecorded = (entityId, event) => { + // Only send to clients following this NPC + for (const [socketId, followedId] of this.followedEntities) { + if (followedId === entityId) { + const sock = this.io.sockets.sockets.get(socketId); + sock?.emit('memory-event', { entityId, event }); + } + } +}; +``` + +In the `follow-npc` handler (around line 118), after `this.rebuildFollowedEntityIds()`, add: + +```typescript +// Send memory history when following a new NPC +if (data.entityId !== null) { + const events = this.gameLoop.eventMemoryService.getEvents(data.entityId); + if (events.length > 0) { + socket.emit('memory-history', { entityId: data.entityId, events }); + } +} +``` + +**Step 3: Verify compilation** + +Run: `npx -w shared tsc && npx -w server tsc --noEmit` +Expected: No errors + +**Step 4: Run existing tests to verify no breakage** + +Run: `npm -w server run test -- --run` +Expected: All 115+ tests pass + +**Step 5: Commit** + +```bash +git add server/src/game/GameLoop.ts server/src/network/SocketServer.ts +git commit -m "feat(memory): wire EventMemoryService into GameLoop and SocketServer" +``` + +--- + +### Task 4: Record Events — Spawner + Social System + +**Files:** +- Modify: `server/src/game/spawner.ts` +- Modify: `server/src/systems/socialSystem.ts` + +**Step 1: Record spawned event in spawner** + +In `server/src/game/spawner.ts`: + +Add import: +```typescript +import type { EventMemoryService } from '../llm/eventMemoryService.js'; +``` + +Change function signature to accept optional eventMemoryService: +```typescript +export function spawnNPC(world: World, map: GameMap, positionHint?: Position, eventMemoryService?: EventMemoryService): EntityId { +``` + +After all components are added (before `return entity`, around line 48), add: +```typescript +eventMemoryService?.record(entity, { + type: 'spawned', + tick: 0, + detail: `${world.getComponent(entity, 'name')} arrived in the village`, +}); +``` + +Update calls in `GameLoop.ts` constructor `spawnInitialNPCs` (line 66): +```typescript +const entity = spawnNPC(this.world, this.map, undefined, this.eventMemoryService); +``` + +Update call in `SocketServer.ts` spawn-npc handler (line 91): +```typescript +const npcEntity = spawnNPC(world, map, target, this.gameLoop.eventMemoryService); +``` + +**Step 2: Record social and proposal events in socialSystem** + +In `server/src/systems/socialSystem.ts`: + +Add import: +```typescript +import type { EventMemoryService } from '../llm/eventMemoryService.js'; +``` + +Change function signature: +```typescript +export function socialSystem(world: World, eventMemoryService?: EventMemoryService): void { +``` + +After lastOutcome is set for normal interactions (after line 175, before `social.phase = 'none'`), add: +```typescript +if (eventMemoryService) { + const nameA = world.getComponent(e, 'name') ?? 'Unknown'; + const nameB = world.getComponent(partnerId!, 'name') ?? 'Unknown'; + eventMemoryService.record(e, { + type: social.outcome === 'positive' ? 'social_positive' : 'social_negative', + tick: 0, + otherEntityId: partnerId!, + otherName: nameB, + detail: social.outcome === 'positive' + ? `Had a positive interaction with ${nameB}` + : `Had a negative interaction with ${nameB}`, + }); + eventMemoryService.record(partnerId!, { + type: partnerSocial.outcome === 'positive' ? 'social_positive' : 'social_negative', + tick: 0, + otherEntityId: e, + otherName: nameA, + detail: partnerSocial.outcome === 'positive' + ? `Had a positive interaction with ${nameA}` + : `Had a negative interaction with ${nameA}`, + }); +} +``` + +After proposal resolution (after line 268, before the reset block), add: +```typescript +if (eventMemoryService) { + const nameInit = world.getComponent(initiator, 'name') ?? 'Unknown'; + const nameRec = world.getComponent(receiver, 'name') ?? 'Unknown'; + if (accepted) { + eventMemoryService.record(initiator, { + type: 'proposal_accepted', + tick: 0, + otherEntityId: receiver, + otherName: nameRec, + detail: `Proposal to ${nameRec} was accepted`, + }); + eventMemoryService.record(receiver, { + type: 'proposal_accepted', + tick: 0, + otherEntityId: initiator, + otherName: nameInit, + detail: `Accepted ${nameInit}'s proposal`, + }); + eventMemoryService.record(initiator, { + type: 'bond_formed', + tick: 0, + otherEntityId: receiver, + otherName: nameRec, + detail: `Became partners with ${nameRec}`, + }); + eventMemoryService.record(receiver, { + type: 'bond_formed', + tick: 0, + otherEntityId: initiator, + otherName: nameInit, + detail: `Became partners with ${nameInit}`, + }); + } else { + eventMemoryService.record(initiator, { + type: 'proposal_rejected', + tick: 0, + otherEntityId: receiver, + otherName: nameRec, + detail: `Proposal to ${nameRec} was rejected`, + }); + eventMemoryService.record(receiver, { + type: 'proposal_rejected', + tick: 0, + otherEntityId: initiator, + otherName: nameInit, + detail: `Rejected ${nameInit}'s proposal`, + }); + } +} +``` + +Update call in `GameLoop.ts` update method (line 95): +```typescript +socialSystem(this.world, this.eventMemoryService); +``` + +**Step 3: Run tests** + +Run: `npm -w server run test -- --run` +Expected: All tests pass (socialSystem tests may need the new optional parameter, but it's optional so existing calls should work) + +**Step 4: Commit** + +```bash +git add server/src/game/spawner.ts server/src/systems/socialSystem.ts server/src/game/GameLoop.ts server/src/network/SocketServer.ts +git commit -m "feat(memory): record spawned, social, proposal, and bond events" +``` + +--- + +### Task 5: Record Events — Relationship System (Tier Changes + Bond Dissolution) + +**Files:** +- Modify: `server/src/systems/relationshipSystem.ts` + +**Step 1: Add tier change detection and recording** + +In `server/src/systems/relationshipSystem.ts`: + +Add imports: +```typescript +import type { EventMemoryService } from '../llm/eventMemoryService.js'; +import { classify } from './relationshipHelpers.js'; +``` + +Note: `classify` is already imported indirectly via `relationshipHelpers.js` being used in other files, but check if it's imported here. If not, add the import. + +Change function signature: +```typescript +export function relationshipSystem(world: World, eventMemoryService?: EventMemoryService): void { +``` + +In Phase 1, before applying deltas (before line 90), capture old tiers: +```typescript +const oldTierA = classify(relA.value); +const oldTierB = classify(relB.value); +``` + +After applying deltas and incrementing interactions (after line 97), add tier change detection: +```typescript +if (eventMemoryService) { + const newTierA = classify(relA.value); + const newTierB = classify(relB.value); + const nameA = world.getComponent(entity, 'name') ?? 'Unknown'; + const nameB = world.getComponent(partnerId, 'name') ?? 'Unknown'; + + if (oldTierA !== newTierA) { + eventMemoryService.record(entity, { + type: 'tier_change', + tick: 0, + otherEntityId: partnerId, + otherName: nameB, + oldTier: oldTierA, + newTier: newTierA, + detail: `Relationship with ${nameB} changed from ${oldTierA} to ${newTierA}`, + }); + } + if (oldTierB !== newTierB) { + eventMemoryService.record(partnerId, { + type: 'tier_change', + tick: 0, + otherEntityId: entity, + otherName: nameA, + oldTier: oldTierB, + newTier: newTierB, + detail: `Relationship with ${nameA} changed from ${oldTierB} to ${newTierB}`, + }); + } +} +``` + +In Phase 2, before `dissolveBond` call (around line 137), add bond dissolution event: +```typescript +if (eventMemoryService && registry && hasBond(registry, entity, otherId, 'partner')) { + const otherName = world.getComponent(otherId, 'name') ?? 'Unknown'; + eventMemoryService.record(entity, { + type: 'bond_dissolved', + tick: 0, + otherEntityId: otherId, + otherName, + detail: `Partnership with ${otherName} ended`, + }); +} +``` + +Update call in `GameLoop.ts` update method (line 97): +```typescript +relationshipSystem(this.world, this.eventMemoryService); +``` + +**Step 2: Run tests** + +Run: `npm -w server run test -- --run` +Expected: All tests pass + +**Step 3: Commit** + +```bash +git add server/src/systems/relationshipSystem.ts server/src/game/GameLoop.ts +git commit -m "feat(memory): record tier changes and bond dissolution events" +``` + +--- + +### Task 6: Record Events — Needs Decay + NPC Brain (Goal Changes) + +**Files:** +- Modify: `server/src/systems/needsDecaySystem.ts` +- Modify: `server/src/systems/npcBrainSystem.ts` + +**Step 1: Record need crisis and recovery events in needsDecaySystem** + +In `server/src/systems/needsDecaySystem.ts`: + +Add imports: +```typescript +import { HUNGER_THRESHOLD, ENERGY_THRESHOLD } from '@dflike/shared'; +import type { EventMemoryService } from '../llm/eventMemoryService.js'; +``` + +Note: `HUNGER_THRESHOLD` and `ENERGY_THRESHOLD` may already be available from the existing imports in `@dflike/shared`. Check — if not, add them. + +Change function signature: +```typescript +export function needsDecaySystem(world: World, eventMemoryService?: EventMemoryService): void { +``` + +Before the hunger/energy decay lines (before line 10), capture previous values: +```typescript +const prevHunger = needs.hunger; +const prevEnergy = needs.energy; +``` + +After the decay lines (after line 11), add threshold crossing detection: +```typescript +if (eventMemoryService) { + const name = world.getComponent(entity, 'name') ?? 'Unknown'; + // Need crisis: crossed below threshold + if (prevHunger >= HUNGER_THRESHOLD && needs.hunger < HUNGER_THRESHOLD) { + eventMemoryService.record(entity, { + type: 'need_crisis', + tick: 0, + need: 'hunger', + detail: `${name} became hungry`, + }); + } + if (prevEnergy >= ENERGY_THRESHOLD && needs.energy < ENERGY_THRESHOLD) { + eventMemoryService.record(entity, { + type: 'need_crisis', + tick: 0, + need: 'energy', + detail: `${name} became exhausted`, + }); + } +} +``` + +For recovery detection, we need to check in `npcBrainSystem` where needs are restored (eating/resting). In `npcBrainSystem.ts`: + +Add import: +```typescript +import type { EventMemoryService } from '../llm/eventMemoryService.js'; +``` + +Change function signature: +```typescript +export function npcBrainSystem(world: World, map: GameMap, eventMemoryService?: EventMemoryService): void { +``` + +Capture previous goal before the goal determination (before line 52): +```typescript +const prevGoal = brain.currentGoal; +``` + +At the eat recovery (line 43), add need recovery event: +```typescript +if (brain.currentGoal === 'eat' && movement.state === 'idle') { + const prevHunger = needs.hunger; + needs.hunger = Math.min(100, needs.hunger + 20); + if (eventMemoryService && prevHunger < HUNGER_THRESHOLD && needs.hunger >= HUNGER_THRESHOLD) { + const name = world.getComponent(entity, 'name') ?? 'Unknown'; + eventMemoryService.record(entity, { + type: 'need_recovery', + tick: 0, + need: 'hunger', + detail: `${name} satisfied their hunger`, + }); + } + brain.currentGoal = null; +} +``` + +Similarly at the rest recovery (line 46): +```typescript +if (brain.currentGoal === 'rest' && movement.state === 'idle') { + const prevEnergy = needs.energy; + needs.energy = Math.min(100, needs.energy + 20); + if (eventMemoryService && prevEnergy < ENERGY_THRESHOLD && needs.energy >= ENERGY_THRESHOLD) { + const name = world.getComponent(entity, 'name') ?? 'Unknown'; + eventMemoryService.record(entity, { + type: 'need_recovery', + tick: 0, + need: 'energy', + detail: `${name} recovered their energy`, + }); + } + brain.currentGoal = null; +} +``` + +After goal is set (after line 56), add goal change detection: +```typescript +if (eventMemoryService && brain.currentGoal !== prevGoal && prevGoal !== null) { + const name = world.getComponent(entity, 'name') ?? 'Unknown'; + const goalLabels: Record = { wander: 'wandering', eat: 'looking for food', rest: 'looking for rest' }; + eventMemoryService.record(entity, { + type: 'goal_change', + tick: 0, + oldGoal: prevGoal, + newGoal: brain.currentGoal ?? 'idle', + detail: `${name} started ${goalLabels[brain.currentGoal ?? ''] ?? brain.currentGoal}`, + }); +} +``` + +Update calls in `GameLoop.ts` update method: +```typescript +needsDecaySystem(this.world, this.eventMemoryService); +npcBrainSystem(this.world, this.map, this.eventMemoryService); +``` + +**Step 2: Run tests** + +Run: `npm -w server run test -- --run` +Expected: All tests pass + +**Step 3: Commit** + +```bash +git add server/src/systems/needsDecaySystem.ts server/src/systems/npcBrainSystem.ts server/src/game/GameLoop.ts +git commit -m "feat(memory): record need crisis, recovery, and goal change events" +``` + +--- + +### Task 7: Client — SocketClient + GameScene Memory State + +**Files:** +- Modify: `client/src/network/SocketClient.ts` +- Modify: `client/src/scenes/GameScene.ts` + +**Step 1: Add memory event callbacks to SocketClient** + +In `client/src/network/SocketClient.ts`: + +Add `MemoryEvent` to the import from `@dflike/shared` (line 2): +```typescript +import type { ..., MemoryEvent } from '@dflike/shared'; +``` + +Add callback properties (after `onNpcThought`, around line 20): +```typescript +onMemoryEvent: ((data: { entityId: number; event: MemoryEvent }) => void) | null = null; +onMemoryHistory: ((data: { entityId: number; events: MemoryEvent[] }) => void) | null = null; +``` + +Add socket listeners in constructor (after line 52): +```typescript +this.socket.on('memory-event', (data) => this.onMemoryEvent?.(data)); +this.socket.on('memory-history', (data) => this.onMemoryHistory?.(data)); +``` + +**Step 2: Add memory state to GameScene** + +In `client/src/scenes/GameScene.ts`: + +Add `MemoryEvent` to the import from `@dflike/shared`. + +Add state field (after `npcThoughts` map, around line 44): +```typescript +private memoryEvents: Map = new Map(); +``` + +In the `create()` method, wire up the new callbacks (after `onNpcThought` wiring, around line 207): + +```typescript +this.client.onMemoryEvent = (data) => { + const events = this.memoryEvents.get(data.entityId) ?? []; + events.push(data.event); + if (events.length > 50) events.splice(0, events.length - 50); + this.memoryEvents.set(data.entityId, events); + // Update info panel if following this NPC and on history tab + if (this.mode === 'follow' && this.npcInfoPanel.isVisible()) { + const npcIds = this.getNpcIds(); + const followedId = npcIds[this.followTargetIndex]; + if (followedId === data.entityId) { + this.npcInfoPanel.updateHistory(events); + } + } +}; + +this.client.onMemoryHistory = (data) => { + this.memoryEvents.set(data.entityId, data.events.slice(-50)); + if (this.mode === 'follow' && this.npcInfoPanel.isVisible()) { + const npcIds = this.getNpcIds(); + const followedId = npcIds[this.followTargetIndex]; + if (followedId === data.entityId) { + this.npcInfoPanel.updateHistory(data.events); + } + } +}; +``` + +Where NPC follow is initiated (the places that call `npcInfoPanel.updateRecentEvents` after switching follow target), also add: +```typescript +this.npcInfoPanel.updateHistory(this.memoryEvents.get(targetId) ?? []); +``` + +Add a helper to get NPC entity IDs if one doesn't exist already. Check if `getNpcIds()` is a method — if not, extract the pattern used in the follow mode code. + +**Step 3: Verify client builds** + +Run: `npm -w client run build` +Expected: Build succeeds (may fail on `updateHistory` not yet existing on NpcInfoPanel — that's OK, we'll add it in next task) + +**Step 4: Commit** + +```bash +git add client/src/network/SocketClient.ts client/src/scenes/GameScene.ts +git commit -m "feat(memory): wire memory events in SocketClient and GameScene" +``` + +--- + +### Task 8: Client — History Tab in NpcInfoPanel + +**Files:** +- Modify: `client/src/ui/NpcInfoPanel.ts` + +**Step 1: Add History tab** + +In `client/src/ui/NpcInfoPanel.ts`: + +Add `MemoryEvent` to the import from `@dflike/shared` (line 1). + +Add new properties (after `relationshipsContent`, around line 46): +```typescript +private historyTab!: HTMLDivElement; +private historyContent!: HTMLDivElement; +``` + +Update `activeTab` type: +```typescript +private activeTab: 'status' | 'relationships' | 'history' = 'status'; +``` + +In the constructor, add the History tab to the tab bar (after `this.relationshipsTab` creation, before `tabBar.appendChild`): + +```typescript +this.historyTab = document.createElement('div'); +this.historyTab.textContent = 'History'; +this.historyTab.style.cssText = ` + flex: 1; + text-align: center; + padding: 8px 0; + font-size: 14px; + cursor: pointer; + color: ${EB.textMuted}; + user-select: none; +`; +this.historyTab.addEventListener('click', () => this.switchTab('history')); +``` + +Add `tabBar.appendChild(this.historyTab)` after the relationships tab appendChild. + +Create history content div (after `this.relationshipsContent` creation): +```typescript +this.historyContent = document.createElement('div'); +this.historyContent.style.cssText = ` + display: none; + padding: 8px 12px; + font-family: 'Press Start 2P', monospace; +`; +contentWrapper.appendChild(this.historyContent); +``` + +**Step 2: Update switchTab to handle 3 tabs** + +Replace the `switchTab` method: +```typescript +private switchTab(tab: 'status' | 'relationships' | 'history'): void { + this.activeTab = tab; + const tabs = [ + { key: 'status', tabEl: this.statusTab, contentEl: this.statusContent }, + { key: 'relationships', tabEl: this.relationshipsTab, contentEl: this.relationshipsContent }, + { key: 'history', tabEl: this.historyTab, contentEl: this.historyContent }, + ] as const; + + for (const t of tabs) { + if (t.key === tab) { + t.contentEl.style.display = ''; + t.tabEl.style.color = EB.textPrimary; + t.tabEl.style.borderBottom = `2px solid ${EB.borderOuter}`; + } else { + t.contentEl.style.display = 'none'; + t.tabEl.style.color = EB.textMuted; + t.tabEl.style.borderBottom = 'none'; + } + } +} +``` + +**Step 3: Add updateHistory method** + +Add this public method to the class: + +```typescript +updateHistory(events: MemoryEvent[]): void { + this.historyContent.innerHTML = ''; + + if (events.length === 0) { + const empty = document.createElement('div'); + empty.style.cssText = `text-align: center; color: ${EB.textMuted}; font-size: 14px; padding: 16px 0;`; + empty.textContent = 'No events yet'; + this.historyContent.appendChild(empty); + return; + } + + // Newest first + const sorted = [...events].reverse(); + for (const event of sorted) { + const row = document.createElement('div'); + row.style.cssText = ` + font-size: 10px; + color: ${EB.textSecondary}; + padding: 3px 0; + border-bottom: 1px solid ${EB.borderGap}; + line-height: 1.6; + `; + + const icon = this.getEventIcon(event.type); + row.textContent = `${icon} ${event.detail}`; + row.title = event.detail; + this.historyContent.appendChild(row); + } +} + +private getEventIcon(type: string): string { + switch (type) { + case 'social_positive': return '\u{1F91D}'; + case 'social_negative': return '\u{1F4A2}'; + case 'proposal_accepted': return '\u{1F48D}'; + case 'proposal_rejected': return '\u{1F494}'; + case 'tier_change': return '\u{2B50}'; + case 'need_crisis': return '\u{26A0}'; + case 'need_recovery': return '\u{2705}'; + case 'goal_change': return '\u{27A1}'; + case 'bond_formed': return '\u{2764}'; + case 'bond_dissolved': return '\u{1F525}'; + case 'spawned': return '\u{1F331}'; + default: return '\u{25CF}'; + } +} +``` + +**Step 4: Verify client builds** + +Run: `npm -w client run build` +Expected: Build succeeds + +**Step 5: Commit** + +```bash +git add client/src/ui/NpcInfoPanel.ts +git commit -m "feat(memory): add History tab to NPC info panel" +``` + +--- + +### Task 9: LLM Integration — Replace recentEvents in Thought System + +**Files:** +- Modify: `server/src/systems/thoughtSystem.ts` + +**Step 1: Update thoughtSystem to use EventMemoryService** + +In `server/src/systems/thoughtSystem.ts`: + +Add import: +```typescript +import type { EventMemoryService } from '../llm/eventMemoryService.js'; +``` + +Update `createThoughtSystem` signature: +```typescript +export function createThoughtSystem( + llmService: LlmService, + narrationService?: NarrationService, + eventMemoryService?: EventMemoryService, +): ThoughtSystem { +``` + +Replace `describeRecentEvents` function to prefer eventMemoryService: +```typescript +function describeRecentEvents( + entityId: EntityId, + narrationService?: NarrationService, + eventMemoryService?: EventMemoryService, + trigger?: ThoughtContext['trigger'], +): string { + if (eventMemoryService) { + const preferTypes = trigger === 'post_interaction' + ? ['social_positive', 'social_negative', 'proposal_accepted', 'proposal_rejected'] as const + : trigger === 'need_critical' + ? ['need_crisis', 'need_recovery'] as const + : undefined; + + const events = eventMemoryService.selectForPrompt(entityId, { + maxCount: 5, + preferTypes: preferTypes as any, + }); + if (events.length > 0) { + return events.map(e => `- ${e.detail}`).join('\n'); + } + } + // Fallback to narration service + if (!narrationService) return 'none'; + const events = narrationService.getEventsForEntity(entityId); + if (events.length === 0) return 'none'; + return events.slice(-2).map(e => e.narration).join('; '); +} +``` + +Update the call to `describeRecentEvents` in the request builder (around line 68): +```typescript +const { entityId, context } = eligible.find(e => e.entityId === entityId) ?? { context: {} as ThoughtContext }; +// (this is inside the map, so we have access to entityId and can find the context) +``` + +Actually, simpler approach — update the `requests` mapping (around line 60-71) to pass context: + +```typescript +const requests: ThoughtRequest[] = eligible.map(({ entityId, context }) => { + const name = world.getComponent(entityId, 'name') ?? 'Unknown'; + const stats = world.getComponent(entityId, 'stats'); + const needs = world.getComponent(entityId, 'needs'); + const brain = world.getComponent(entityId, 'npcBrain'); + + const personality = stats ? formatStatsForPrompt(stats) : ''; + const currentState = describeState(needs, brain); + const recentEvents = describeRecentEvents(entityId, narrationService, eventMemoryService, context.trigger); + + return { entityId, name, personality, currentState, recentEvents }; +}); +``` + +Update GameLoop constructor (line 37) to pass eventMemoryService: +```typescript +this.thoughtSystem = createThoughtSystem(this.llmService, this.narrationService, this.eventMemoryService); +``` + +Note: `this.eventMemoryService` must be created BEFORE `this.thoughtSystem` in the constructor. + +**Step 2: Run tests** + +Run: `npm -w server run test -- --run` +Expected: All tests pass (thoughtSystem tests use optional params) + +**Step 3: Commit** + +```bash +git add server/src/systems/thoughtSystem.ts server/src/game/GameLoop.ts +git commit -m "feat(memory): integrate event memory into thought generation context" +``` + +--- + +### Task 10: LLM Integration — Add History to Social Narration + +**Files:** +- Modify: `server/src/systems/narrationEmitter.ts` +- Modify: `server/src/llm/narrationService.ts` +- Modify: `server/src/llm/templates.ts` (if template needs a new variable) + +**Step 1: Pass EventMemoryService to narrationEmitter** + +In `server/src/systems/narrationEmitter.ts`: + +Add import: +```typescript +import type { EventMemoryService } from '../llm/eventMemoryService.js'; +``` + +Update function signature: +```typescript +export function narrationEmitter( + world: World, + narrationService: NarrationService, + followedEntityIds: Set, + eventMemoryService?: EventMemoryService, +): void { +``` + +Before calling `narrationService.recordInteraction()`, build shared history string: +```typescript +let sharedHistory = ''; +if (eventMemoryService) { + const events = eventMemoryService.selectForPrompt(firstId, { + maxCount: 3, + preferEntityId: secondId, + preferTypes: ['social_positive', 'social_negative', 'tier_change'], + }); + if (events.length > 0) { + sharedHistory = events.map(e => `- ${e.detail}`).join('\n'); + } +} +``` + +Add `sharedHistory` to the `recordInteraction` call by extending `InteractionRecord`. + +**Step 2: Extend InteractionRecord with optional sharedHistory** + +In `server/src/llm/narrationService.ts`, add to `InteractionRecord` interface: +```typescript +sharedHistory?: string; +``` + +In the LLM generation variables (around line 64-72), add: +```typescript +recentHistory: record.sharedHistory ?? '', +``` + +**Step 3: Update template to include recentHistory** + +Check `server/src/llm/templates.ts` for the `socialNarration` template. Add `{{recentHistory}}` to the prompt if it's not empty. The template likely has a system/user prompt structure — add to the user prompt section something like: + +``` +{{#if recentHistory}}Their recent shared history: +{{recentHistory}}{{/if}} +``` + +Since the template system uses simple `{{variable}}` replacement (not conditionals), just always include it: +``` +Recent history between them: +{{recentHistory}} +``` + +If `recentHistory` is empty string, that line will just say "Recent history between them:" with nothing after, which is fine for the LLM. + +Update `narrationEmitter` call in GameLoop (line 96): +```typescript +narrationEmitter(this.world, this.narrationService, this.followedEntityIds, this.eventMemoryService); +``` + +Also pass `sharedHistory` in the `recordInteraction` call: +```typescript +narrationService.recordInteraction({ + ...existing fields..., + sharedHistory, +}); +``` + +**Step 4: Run tests** + +Run: `npm -w server run test -- --run` +Expected: All tests pass + +**Step 5: Commit** + +```bash +git add server/src/systems/narrationEmitter.ts server/src/llm/narrationService.ts server/src/llm/templates.ts server/src/game/GameLoop.ts +git commit -m "feat(memory): add shared history context to social narration prompts" +``` + +--- + +### Task 11: Update LLM Roadmap + Final Verification + +**Files:** +- Modify: `docs/plans/2026-03-08-llm-integration-roadmap.md` + +**Step 1: Mark task 2.1 as complete in roadmap** + +Update the 2.1 section checkboxes to `[x]` and set status to COMPLETE. + +**Step 2: Run full test suite** + +Run: `npm -w server run test -- --run` +Expected: All tests pass + +**Step 3: Run client build** + +Run: `npm -w client run build` +Expected: Clean build + +**Step 4: Manual smoke test** + +Run: `npm -w server run dev` and `npm -w client run dev` +- Follow an NPC +- Switch to History tab — should show "spawned" event +- Wait for social interactions — new events should appear +- Wait for needs to drop — need_crisis events should appear +- Verify thought generation still works + +**Step 5: Commit** + +```bash +git add docs/plans/2026-03-08-llm-integration-roadmap.md +git commit -m "docs: mark task 2.1 (NPC event memory) as complete" +```