diff --git a/docs/plans/2026-03-07-npc-social-interactions-impl.md b/docs/plans/2026-03-07-npc-social-interactions-impl.md new file mode 100644 index 0000000..966f281 --- /dev/null +++ b/docs/plans/2026-03-07-npc-social-interactions-impl.md @@ -0,0 +1,977 @@ +# NPC Social Interactions Implementation Plan + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. + +**Goal:** Add proximity-based NPC-to-NPC social interactions with a face/pause/emote sequence and floating emoji overlay. + +**Architecture:** New `SocialState` ECS component + `socialSystem` inserted between npcBrain and movement. Brain and movement systems get guards to skip entities mid-interaction. Client renders floating HTML emoji above sprites during emoting phase. + +**Tech Stack:** TypeScript, vitest (server tests), Phaser 3 (client), Socket.io (broadcast) + +--- + +### Task 1: Add SocialState type and social constants to shared + +**Files:** +- Modify: `shared/src/types.ts` +- Modify: `shared/src/constants.ts` + +**Step 1: Add types to shared/src/types.ts** + +Add after the `NPCBrain` interface (line 47): + +```typescript +export type InteractionPhase = 'none' | 'facing' | 'pausing' | 'emoting'; +export type InteractionOutcome = 'positive' | 'negative'; + +export interface SocialState { + phase: InteractionPhase; + partnerId: EntityId | null; + phaseTimer: number; + outcome: InteractionOutcome | null; + globalCooldown: number; + pairCooldowns: Map; +} +``` + +Add `socialState` to `EntityState` (after `name?` field): + +```typescript +socialState?: { + phase: InteractionPhase; + partnerId: EntityId | null; + outcome: InteractionOutcome | null; +}; +``` + +**Step 2: Add constants to shared/src/constants.ts** + +Add at the end of the file: + +```typescript +// Social interactions +export const AWARENESS_RADIUS = 5; // Manhattan distance in tiles +export const FACING_DURATION = 10; // ticks (1s) +export const PAUSING_DURATION = 15; // ticks (1.5s) +export const EMOTING_DURATION = 20; // ticks (2s) +export const SOCIAL_GLOBAL_COOLDOWN = 50; // ticks (5s) +export const SOCIAL_PAIR_COOLDOWN = 300; // ticks (30s) +``` + +**Step 3: Rebuild shared** + +Run: `npm -w shared run build` (or whatever builds the shared package — check package.json) + +If there's no explicit build, just verify the types compile: +Run: `npx -w shared tsc --noEmit` + +**Step 4: Commit** + +```bash +git add shared/src/types.ts shared/src/constants.ts +git commit -m "feat: add SocialState types and social interaction constants" +``` + +--- + +### Task 2: Write failing tests for socialSystem + +**Files:** +- Create: `server/src/systems/__tests__/socialSystem.test.ts` + +**Step 1: Write the test file** + +```typescript +import { describe, it, expect } from 'vitest'; +import { World } from '../../ecs/World.js'; +import { socialSystem } from '../socialSystem.js'; +import type { Position, Needs, Movement, NPCBrain, SocialState, EntityId } from '@dflike/shared'; +import { + FACING_DURATION, PAUSING_DURATION, EMOTING_DURATION, + SOCIAL_GLOBAL_COOLDOWN, SOCIAL_PAIR_COOLDOWN, + HUNGER_THRESHOLD, ENERGY_THRESHOLD, +} from '@dflike/shared'; + +function createNPC( + world: World, x: number, y: number, + opts?: { hunger?: number; energy?: number; goal?: string; walking?: boolean }, +): EntityId { + const e = world.createEntity(); + world.addComponent(e, 'position', { x, y }); + world.addComponent(e, 'needs', { + hunger: opts?.hunger ?? 80, + energy: opts?.energy ?? 80, + }); + world.addComponent(e, 'movement', { + state: opts?.walking ? 'walking' : 'idle', + target: opts?.walking ? { x: x + 5, y } : null, + path: opts?.walking ? [{ x: x + 1, y }, { x: x + 2, y }] : [], + direction: 0, + moveProgress: 0, + }); + world.addComponent(e, 'npcBrain', { + currentGoal: (opts?.goal as any) ?? 'wander', + goalQueue: [], + }); + world.addComponent(e, 'socialState', { + phase: 'none', + partnerId: null, + phaseTimer: 0, + outcome: null, + globalCooldown: 0, + pairCooldowns: new Map(), + }); + return e; +} + +describe('socialSystem', () => { + describe('proximity detection', () => { + it('initiates interaction when two idle NPCs are within awareness radius', () => { + const world = new World(); + const a = createNPC(world, 5, 5); + const b = createNPC(world, 8, 5); // 3 tiles away + socialSystem(world); + const sa = world.getComponent(a, 'socialState')!; + const sb = world.getComponent(b, 'socialState')!; + expect(sa.phase).toBe('facing'); + expect(sa.partnerId).toBe(b); + expect(sb.phase).toBe('facing'); + expect(sb.partnerId).toBe(a); + }); + + it('does not initiate when NPCs are outside awareness radius', () => { + const world = new World(); + const a = createNPC(world, 0, 0); + const b = createNPC(world, 10, 0); // 10 tiles away + socialSystem(world); + const sa = world.getComponent(a, 'socialState')!; + expect(sa.phase).toBe('none'); + }); + + it('skips player-controlled entities', () => { + const world = new World(); + const a = createNPC(world, 5, 5); + const b = createNPC(world, 6, 5); + world.addComponent(b, 'playerControlled', { playerId: 'p1', mode: 'avatar' }); + socialSystem(world); + const sa = world.getComponent(a, 'socialState')!; + expect(sa.phase).toBe('none'); + }); + }); + + describe('target rejection', () => { + it('rejects target on eat goal with low hunger', () => { + const world = new World(); + const a = createNPC(world, 5, 5); + const b = createNPC(world, 6, 5, { goal: 'eat', hunger: HUNGER_THRESHOLD - 1 }); + socialSystem(world); + const sa = world.getComponent(a, 'socialState')!; + expect(sa.phase).toBe('none'); + }); + + it('rejects target on rest goal with low energy', () => { + const world = new World(); + const a = createNPC(world, 5, 5); + const b = createNPC(world, 6, 5, { goal: 'rest', energy: ENERGY_THRESHOLD - 1 }); + socialSystem(world); + const sa = world.getComponent(a, 'socialState')!; + expect(sa.phase).toBe('none'); + }); + + it('allows target on eat goal when hunger is not critical', () => { + const world = new World(); + const a = createNPC(world, 5, 5); + const b = createNPC(world, 6, 5, { goal: 'eat', hunger: HUNGER_THRESHOLD + 10 }); + socialSystem(world); + const sa = world.getComponent(a, 'socialState')!; + expect(sa.phase).toBe('facing'); + }); + }); + + describe('cooldowns', () => { + it('respects global cooldown', () => { + const world = new World(); + const a = createNPC(world, 5, 5); + const b = createNPC(world, 6, 5); + const sa = world.getComponent(a, 'socialState')!; + sa.globalCooldown = 10; + socialSystem(world); + expect(sa.phase).toBe('none'); + }); + + it('decrements global cooldown each tick', () => { + const world = new World(); + const a = createNPC(world, 5, 5); + // Put b far away so no interaction triggers + const _b = createNPC(world, 50, 50); + const sa = world.getComponent(a, 'socialState')!; + sa.globalCooldown = 10; + socialSystem(world); + expect(sa.globalCooldown).toBe(9); + }); + + it('respects pair cooldown', () => { + const world = new World(); + const a = createNPC(world, 5, 5); + const b = createNPC(world, 6, 5); + const sa = world.getComponent(a, 'socialState')!; + sa.pairCooldowns.set(b, 100); + socialSystem(world); + expect(sa.phase).toBe('none'); + }); + + it('decrements pair cooldowns and removes expired', () => { + const world = new World(); + const a = createNPC(world, 5, 5); + const sa = world.getComponent(a, 'socialState')!; + sa.pairCooldowns.set(99 as EntityId, 1); + sa.pairCooldowns.set(100 as EntityId, 5); + socialSystem(world); + expect(sa.pairCooldowns.has(99 as EntityId)).toBe(false); + expect(sa.pairCooldowns.get(100 as EntityId)).toBe(4); + }); + }); + + describe('phase transitions', () => { + it('transitions from facing to pausing when timer expires', () => { + const world = new World(); + const a = createNPC(world, 5, 5); + const b = createNPC(world, 6, 5); + const sa = world.getComponent(a, 'socialState')!; + const sb = world.getComponent(b, 'socialState')!; + sa.phase = 'facing'; sa.partnerId = b; sa.phaseTimer = 1; + sb.phase = 'facing'; sb.partnerId = a; sb.phaseTimer = 1; + socialSystem(world); + expect(sa.phase).toBe('pausing'); + expect(sa.phaseTimer).toBe(PAUSING_DURATION); + }); + + it('transitions from pausing to emoting and rolls outcome', () => { + const world = new World(); + const a = createNPC(world, 5, 5); + const b = createNPC(world, 6, 5); + const sa = world.getComponent(a, 'socialState')!; + const sb = world.getComponent(b, 'socialState')!; + sa.phase = 'pausing'; sa.partnerId = b; sa.phaseTimer = 1; + sb.phase = 'pausing'; sb.partnerId = a; sb.phaseTimer = 1; + socialSystem(world); + expect(sa.phase).toBe('emoting'); + expect(sa.phaseTimer).toBe(EMOTING_DURATION); + expect(['positive', 'negative']).toContain(sa.outcome); + expect(['positive', 'negative']).toContain(sb.outcome); + }); + + it('transitions from emoting to none and sets cooldowns', () => { + const world = new World(); + const a = createNPC(world, 5, 5); + const b = createNPC(world, 6, 5); + const sa = world.getComponent(a, 'socialState')!; + const sb = world.getComponent(b, 'socialState')!; + sa.phase = 'emoting'; sa.partnerId = b; sa.phaseTimer = 1; sa.outcome = 'positive'; + sb.phase = 'emoting'; sb.partnerId = a; sb.phaseTimer = 1; sb.outcome = 'negative'; + socialSystem(world); + expect(sa.phase).toBe('none'); + expect(sa.partnerId).toBeNull(); + expect(sa.outcome).toBeNull(); + expect(sa.globalCooldown).toBe(SOCIAL_GLOBAL_COOLDOWN); + expect(sa.pairCooldowns.get(b)).toBe(SOCIAL_PAIR_COOLDOWN); + }); + }); + + describe('direction facing', () => { + it('NPCs face each other when interaction starts', () => { + const world = new World(); + const a = createNPC(world, 5, 5); + const b = createNPC(world, 8, 5); // b is to the right of a + socialSystem(world); + const ma = world.getComponent(a, 'movement')!; + const mb = world.getComponent(b, 'movement')!; + // a should face right (3), b should face left (1) + expect(ma.direction).toBe(3); // RIGHT + expect(mb.direction).toBe(1); // LEFT + }); + }); + + describe('conflict resolution', () => { + it('first initiator claims target; second NPC cannot claim same target', () => { + const world = new World(); + const a = createNPC(world, 5, 5); + const b = createNPC(world, 6, 5); + const c = createNPC(world, 7, 5); + socialSystem(world); + // a and b should pair (a is first in entity ID order, b is closest) + const sa = world.getComponent(a, 'socialState')!; + const sb = world.getComponent(b, 'socialState')!; + const sc = world.getComponent(c, 'socialState')!; + expect(sa.phase).toBe('facing'); + expect(sb.phase).toBe('facing'); + expect(sa.partnerId).toBe(b); + expect(sb.partnerId).toBe(a); + // c should not be paired (b is taken) + // c might pair with nobody (a is also taken) — stays none + expect(sc.phase).toBe('none'); + }); + }); + + describe('partner despawn', () => { + it('resets remaining NPC when partner is removed', () => { + const world = new World(); + const a = createNPC(world, 5, 5); + const b = createNPC(world, 6, 5); + const sa = world.getComponent(a, 'socialState')!; + sa.phase = 'facing'; sa.partnerId = b; sa.phaseTimer = 5; + // Remove b from world + world.removeEntity(b); + socialSystem(world); + expect(sa.phase).toBe('none'); + expect(sa.partnerId).toBeNull(); + }); + }); +}); +``` + +**Step 2: Run tests to verify they fail** + +Run: `npm -w server run test -- --run server/src/systems/__tests__/socialSystem.test.ts` +Expected: FAIL — `socialSystem` module not found + +**Step 3: Commit** + +```bash +git add server/src/systems/__tests__/socialSystem.test.ts +git commit -m "test: add failing tests for social interaction system" +``` + +--- + +### Task 3: Implement socialSystem + +**Files:** +- Create: `server/src/systems/socialSystem.ts` + +**Step 1: Write the implementation** + +```typescript +import { + AWARENESS_RADIUS, FACING_DURATION, PAUSING_DURATION, EMOTING_DURATION, + SOCIAL_GLOBAL_COOLDOWN, SOCIAL_PAIR_COOLDOWN, + HUNGER_THRESHOLD, ENERGY_THRESHOLD, Direction, + type SocialState, type Position, type Movement, type NPCBrain, type Needs, type EntityId, +} from '@dflike/shared'; +import type { World } from '../ecs/World.js'; + +function directionTo(from: Position, to: Position): number { + const dx = to.x - from.x; + const dy = to.y - from.y; + if (Math.abs(dx) > Math.abs(dy)) return dx > 0 ? Direction.RIGHT : Direction.LEFT; + return dy > 0 ? Direction.DOWN : Direction.UP; +} + +function manhattanDist(a: Position, b: Position): number { + return Math.abs(a.x - b.x) + Math.abs(a.y - b.y); +} + +function isTargetBusy(world: World, target: EntityId): boolean { + const brain = world.getComponent(target, 'npcBrain'); + if (!brain || brain.currentGoal === 'wander' || brain.currentGoal === null) return false; + const needs = world.getComponent(target, 'needs'); + if (!needs) return false; + if (brain.currentGoal === 'eat' && needs.hunger < HUNGER_THRESHOLD) return true; + if (brain.currentGoal === 'rest' && needs.energy < ENERGY_THRESHOLD) return true; + return false; +} + +export function socialSystem(world: World): void { + const socialEntities = world.query('socialState', 'position', 'movement', 'npcBrain', 'needs'); + + // Filter out player-controlled entities + const npcs = socialEntities.filter(e => !world.getComponent(e, 'playerControlled')); + + // Track which entities got claimed this tick (for conflict resolution) + const claimedThisTick = new Set(); + + // --- Cooldown decay for ALL social entities --- + for (const entity of npcs) { + const social = world.getComponent(entity, 'socialState')!; + if (social.globalCooldown > 0) social.globalCooldown--; + for (const [partnerId, ticks] of social.pairCooldowns) { + if (ticks <= 1) { + social.pairCooldowns.delete(partnerId); + } else { + social.pairCooldowns.set(partnerId, ticks - 1); + } + } + } + + // --- Phase 1: Update active interactions --- + for (const entity of npcs) { + const social = world.getComponent(entity, 'socialState')!; + if (social.phase === 'none') continue; + + // Check if partner still exists + if (social.partnerId !== null && !world.getComponent(social.partnerId, 'socialState')) { + social.phase = 'none'; + social.partnerId = null; + social.phaseTimer = 0; + social.outcome = null; + continue; + } + + social.phaseTimer--; + + if (social.phaseTimer <= 0) { + if (social.phase === 'facing') { + social.phase = 'pausing'; + social.phaseTimer = PAUSING_DURATION; + } else if (social.phase === 'pausing') { + social.phase = 'emoting'; + social.phaseTimer = EMOTING_DURATION; + social.outcome = Math.random() < 0.5 ? 'positive' : 'negative'; + } else if (social.phase === 'emoting') { + // Set cooldowns + social.globalCooldown = SOCIAL_GLOBAL_COOLDOWN; + if (social.partnerId !== null) { + social.pairCooldowns.set(social.partnerId, SOCIAL_PAIR_COOLDOWN); + } + social.phase = 'none'; + social.partnerId = null; + social.outcome = null; + social.phaseTimer = 0; + } + } + } + + // --- Phase 2: Detect new interactions --- + // Process in entity ID order (npcs from world.query are already sorted by creation order) + for (const entity of npcs) { + const social = world.getComponent(entity, 'socialState')!; + if (social.phase !== 'none' || social.globalCooldown > 0) continue; + if (claimedThisTick.has(entity)) continue; + + const pos = world.getComponent(entity, 'position')!; + const movement = world.getComponent(entity, 'movement')!; + + // Find closest eligible partner + let bestPartner: EntityId | null = null; + let bestDist = Infinity; + + for (const other of npcs) { + if (other === entity) continue; + if (claimedThisTick.has(other)) continue; + + const otherSocial = world.getComponent(other, 'socialState')!; + if (otherSocial.phase !== 'none') continue; + if (social.pairCooldowns.has(other)) continue; + + const otherPos = world.getComponent(other, 'position')!; + const dist = manhattanDist(pos, otherPos); + if (dist > AWARENESS_RADIUS) continue; + + // Check if target is too busy + if (isTargetBusy(world, other)) continue; + + if (dist < bestDist) { + bestDist = dist; + bestPartner = other; + } + } + + if (bestPartner !== null) { + const partnerSocial = world.getComponent(bestPartner, 'socialState')!; + const partnerPos = world.getComponent(bestPartner, 'position')!; + const partnerMovement = world.getComponent(bestPartner, 'movement')!; + + // Start interaction for both + social.phase = 'facing'; + social.partnerId = bestPartner; + social.phaseTimer = FACING_DURATION; + social.outcome = null; + + partnerSocial.phase = 'facing'; + partnerSocial.partnerId = entity; + partnerSocial.phaseTimer = FACING_DURATION; + partnerSocial.outcome = null; + + // Face each other + movement.direction = directionTo(pos, partnerPos); + partnerMovement.direction = directionTo(partnerPos, pos); + + // Set movement to idle (freeze in place) + movement.state = 'idle'; + partnerMovement.state = 'idle'; + + // Mark both as claimed + claimedThisTick.add(entity); + claimedThisTick.add(bestPartner); + } + } +} +``` + +**Step 2: Run tests to verify they pass** + +Run: `npm -w server run test -- --run server/src/systems/__tests__/socialSystem.test.ts` +Expected: All tests PASS + +**Step 3: Commit** + +```bash +git add server/src/systems/socialSystem.ts +git commit -m "feat: implement socialSystem with proximity detection and phase state machine" +``` + +--- + +### Task 4: Write and pass tests for brain/movement guards + +**Files:** +- Modify: `server/src/systems/__tests__/systems.test.ts` +- Modify: `server/src/systems/npcBrainSystem.ts` +- Modify: `server/src/systems/movementSystem.ts` + +**Step 1: Add failing guard tests to systems.test.ts** + +Add these tests to the existing file. Import `SocialState` type and add a helper: + +At the top, add to imports: +```typescript +import type { Needs, Movement, NPCBrain, Position, SocialState } from '@dflike/shared'; +``` + +Add helper to attach social state (after existing `createNPC` helper): +```typescript +function addSocialState(world: World, entity: number, phase: SocialState['phase'] = 'none') { + world.addComponent(entity, 'socialState', { + phase, + partnerId: null, + phaseTimer: 10, + outcome: null, + globalCooldown: 0, + pairCooldowns: new Map(), + }); +} +``` + +Add to `npcBrainSystem` describe block: +```typescript +it('skips NPC when socialState phase is not none', () => { + const world = new World(); + const map = new GameMap(10, 10); + const e = createNPC(world, 5, 5, 80, 80); + addSocialState(world, e, 'facing'); + const brain = world.getComponent(e, 'npcBrain')!; + brain.currentGoal = null; + npcBrainSystem(world, map); + // Brain should NOT have assigned a new goal + expect(brain.currentGoal).toBeNull(); +}); +``` + +Add to `movementSystem` describe block: +```typescript +it('skips entity when socialState phase is not none', () => { + const world = new World(); + const e = world.createEntity(); + world.addComponent(e, 'position', { x: 0, y: 0 }); + world.addComponent(e, 'movement', { + state: 'walking', + target: { x: 3, y: 0 }, + path: [{ x: 1, y: 0 }, { x: 2, y: 0 }, { x: 3, y: 0 }], + direction: 2, + moveProgress: 0.75, + }); + addSocialState(world, e, 'pausing'); + movementSystem(world); + const pos = world.getComponent(e, 'position')!; + expect(pos).toEqual({ x: 0, y: 0 }); // Should not have moved +}); +``` + +**Step 2: Run tests to verify the new tests fail** + +Run: `npm -w server run test -- --run server/src/systems/__tests__/systems.test.ts` +Expected: 2 new tests FAIL + +**Step 3: Add guard to npcBrainSystem.ts** + +At the top of the for loop body in `npcBrainSystem` (after line 33, getting `pos`), add: + +```typescript +// Skip NPCs in social interaction +const socialState = world.getComponent(entity, 'socialState'); +if (socialState && socialState.phase !== 'none') continue; +``` + +Add `SocialState` to the import from `@dflike/shared`: +```typescript +import { + HUNGER_THRESHOLD, ENERGY_THRESHOLD, Direction, + type Needs, type Movement, type NPCBrain, type Position, type SocialState, +} from '@dflike/shared'; +``` + +**Step 4: Add guard to movementSystem.ts** + +At the top of the for loop body in `movementSystem` (after line 11, getting `pos`), add: + +```typescript +// Skip entities in social interaction +const socialState = world.getComponent(entity, 'socialState'); +if (socialState && socialState.phase !== 'none') continue; +``` + +Add `SocialState` to the import: +```typescript +import { Direction, MOVE_SPEED, type Movement, type Position, type SocialState } from '@dflike/shared'; +``` + +**Step 5: Run all tests to verify they pass** + +Run: `npm -w server run test -- --run` +Expected: All tests PASS (existing + new) + +**Step 6: Commit** + +```bash +git add server/src/systems/__tests__/systems.test.ts server/src/systems/npcBrainSystem.ts server/src/systems/movementSystem.ts +git commit -m "feat: add social interaction guards to brain and movement systems" +``` + +--- + +### Task 5: Wire socialSystem into GameLoop and spawner + +**Files:** +- Modify: `server/src/game/GameLoop.ts` +- Modify: `server/src/game/spawner.ts` + +**Step 1: Add socialSystem to GameLoop** + +In `GameLoop.ts`, add import: +```typescript +import { socialSystem } from '../systems/socialSystem.js'; +``` + +In the `update()` method, insert `socialSystem(this.world)` between `npcBrainSystem` and `movementSystem`: + +```typescript +private update(): void { + this.tick++; + needsDecaySystem(this.world); + npcBrainSystem(this.world, this.map); + socialSystem(this.world); + movementSystem(this.world); + if (this.tick % BROADCAST_EVERY_N_TICKS === 0 && this.onBroadcast) { + this.onBroadcast(); + } +} +``` + +**Step 2: Add SocialState to spawner** + +In `spawner.ts`, add `SocialState` to the import: +```typescript +import type { EntityId, Position, Needs, Movement, NPCBrain, Appearance, SocialState } from '@dflike/shared'; +``` + +After the `npcBrain` component addition (line 23-26), add: +```typescript +world.addComponent(entity, 'socialState', { + phase: 'none', + partnerId: null, + phaseTimer: 0, + outcome: null, + globalCooldown: 0, + pairCooldowns: new Map(), +}); +``` + +**Step 3: Run all server tests** + +Run: `npm -w server run test -- --run` +Expected: All tests PASS + +**Step 4: Commit** + +```bash +git add server/src/game/GameLoop.ts server/src/game/spawner.ts +git commit -m "feat: wire socialSystem into game loop and spawn SocialState on NPCs" +``` + +--- + +### Task 6: Add socialState to state serializer + +**Files:** +- Modify: `server/src/network/stateSerializer.ts` + +**Step 1: Update serializeEntity** + +Add `SocialState` to imports: +```typescript +import type { + EntityState, WorldState, StateUpdate, + Position, Movement, Appearance, Needs, NPCBrain, PlayerControlled, SocialState, +} from '@dflike/shared'; +``` + +In `serializeEntity`, add `socialState` field. Only include the client-relevant fields: + +```typescript +export function serializeEntity(world: World, entityId: number): EntityState { + const socialState = world.getComponent(entityId, 'socialState'); + return { + id: entityId, + position: world.getComponent(entityId, 'position')!, + movement: world.getComponent(entityId, 'movement')!, + appearance: world.getComponent(entityId, 'appearance')!, + needs: world.getComponent(entityId, 'needs'), + npcBrain: world.getComponent(entityId, 'npcBrain'), + playerControlled: world.getComponent(entityId, 'playerControlled'), + name: world.getComponent(entityId, 'name'), + socialState: socialState ? { + phase: socialState.phase, + partnerId: socialState.partnerId, + outcome: socialState.outcome, + } : undefined, + }; +} +``` + +**Step 2: Run all server tests** + +Run: `npm -w server run test -- --run` +Expected: All tests PASS + +**Step 3: Commit** + +```bash +git add server/src/network/stateSerializer.ts +git commit -m "feat: serialize socialState for client broadcast" +``` + +--- + +### Task 7: Implement client-side emoji overlay + +**Files:** +- Create: `client/src/ui/InteractionEmoji.ts` +- Modify: `client/src/scenes/GameScene.ts` + +**Step 1: Create InteractionEmoji manager** + +```typescript +import { TILE_SIZE } from '@dflike/shared'; + +interface ActiveEmoji { + element: HTMLDivElement; + entityId: number; +} + +export class InteractionEmojiManager { + private activeEmojis: Map = new Map(); + private gameCanvas: HTMLCanvasElement | null = null; + + setCanvas(canvas: HTMLCanvasElement): void { + this.gameCanvas = canvas; + } + + /** + * Call each frame with current entity states and camera info. + * Shows/hides emoji elements based on socialState. + */ + update( + entities: { id: number; socialState?: { phase: string; outcome: string | null } }[], + camera: { scrollX: number; scrollY: number; zoom: number }, + entityPositions: Map, + ): void { + const emoting = new Set(); + + for (const entity of entities) { + if (entity.socialState?.phase === 'emoting' && entity.socialState.outcome) { + emoting.add(entity.id); + if (!this.activeEmojis.has(entity.id)) { + this.createEmoji(entity.id, entity.socialState.outcome); + } + } + } + + // Remove emojis for entities no longer emoting + for (const [id, emoji] of this.activeEmojis) { + if (!emoting.has(id)) { + emoji.element.remove(); + this.activeEmojis.delete(id); + } + } + + // Update positions + for (const [id, emoji] of this.activeEmojis) { + const pos = entityPositions.get(id); + if (pos) { + emoji.element.style.left = `${pos.screenX}px`; + emoji.element.style.top = `${pos.screenY - 40}px`; + } + } + } + + private createEmoji(entityId: number, outcome: string): void { + const el = document.createElement('div'); + el.style.cssText = ` + position: fixed; + font-size: 24px; + pointer-events: none; + z-index: 999; + text-align: center; + transform: translate(-50%, -100%); + animation: emoji-float 2s ease-out forwards; + `; + el.textContent = outcome === 'positive' ? '\u{1F60A}' : '\u{1F620}'; + + // Add animation keyframes if not already added + if (!document.getElementById('emoji-float-style')) { + const style = document.createElement('style'); + style.id = 'emoji-float-style'; + style.textContent = ` + @keyframes emoji-float { + 0% { opacity: 1; transform: translate(-50%, -100%) translateY(0); } + 70% { opacity: 1; } + 100% { opacity: 0; transform: translate(-50%, -100%) translateY(-20px); } + } + `; + document.head.appendChild(style); + } + + document.body.appendChild(el); + this.activeEmojis.set(entityId, { element: el, entityId }); + } + + destroy(): void { + for (const [, emoji] of this.activeEmojis) { + emoji.element.remove(); + } + this.activeEmojis.clear(); + const style = document.getElementById('emoji-float-style'); + if (style) style.remove(); + } +} +``` + +**Step 2: Integrate into GameScene** + +In `GameScene.ts`, add import: +```typescript +import { InteractionEmojiManager } from '../ui/InteractionEmoji.js'; +``` + +Add field to class: +```typescript +private emojiManager!: InteractionEmojiManager; +``` + +In `create()`, after `this.npcInfoPanel = new NpcInfoPanel()`: +```typescript +this.emojiManager = new InteractionEmojiManager(); +``` + +In `handleStateUpdate()`, after the entity removal loop (after line 237), add emoji update: +```typescript +// Update interaction emojis +const entityPositions = new Map(); +const cam = this.cameras.main; +for (const entity of update.entities) { + const es = this.entitySprites.get(entity.id); + if (es) { + const screenX = (es.sprite.x - cam.scrollX) * cam.zoom; + const screenY = (es.sprite.y - cam.scrollY) * cam.zoom; + entityPositions.set(entity.id, { screenX, screenY }); + } +} +this.emojiManager.update(update.entities, cam, entityPositions); +``` + +**Step 3: Verify client builds** + +Run: `npm -w client run build` +Expected: Build succeeds + +**Step 4: Commit** + +```bash +git add client/src/ui/InteractionEmoji.ts client/src/scenes/GameScene.ts +git commit -m "feat: add floating emoji overlay for NPC social interactions" +``` + +--- + +### Task 8: Manual integration test and verify + +**Step 1: Start server and client** + +Run (in two terminals): +```bash +npm -w server run dev +npm -w client run dev +``` + +**Step 2: Verify interactions visually** + +- Watch NPCs wander. When two come within 5 tiles, they should stop, face each other, pause, then show an emoji. +- Verify emojis float up and fade out. +- Verify NPCs resume their previous behavior after the interaction. +- Check that interactions have cooldowns (same pair doesn't immediately re-interact). + +**Step 3: Run full test suite** + +Run: `npm -w server run test -- --run` +Expected: All tests PASS (existing 29 + new social tests) + +**Step 4: Final commit if any fixes needed** + +--- + +### Task 9: Update NPC info panel activity label for social interactions + +**Files:** +- Modify: `client/src/ui/NpcInfoPanel.ts` + +**Step 1: Add social activity labels** + +In `NpcInfoPanel.ts`, update the `ACTIVITY_LABELS` map and the `updateInfo` method to show social state: + +Update `updateInfo`: +```typescript +updateInfo(entity: EntityState): void { + this.nameEl.textContent = entity.name ?? `NPC #${entity.id}`; + + // Show social state if interacting + if (entity.socialState && entity.socialState.phase !== 'none') { + const socialLabels: Record = { + facing: 'Socializing', + pausing: 'Socializing', + emoting: 'Socializing', + }; + this.activityEl.textContent = socialLabels[entity.socialState.phase] ?? 'Socializing'; + } else { + const goal = entity.npcBrain?.currentGoal; + this.activityEl.textContent = goal ? (ACTIVITY_LABELS[goal] ?? goal) : 'Idle'; + } + + if (entity.needs) { + this.updateNeeds(entity.needs); + } +} +``` + +Add `EntityState` type import if not already present (it's already imported on line 1). + +**Step 2: Verify client builds** + +Run: `npm -w client run build` +Expected: Build succeeds + +**Step 3: Commit** + +```bash +git add client/src/ui/NpcInfoPanel.ts +git commit -m "feat: show Socializing activity in NPC info panel during interactions" +```