docs: add NPC social interactions implementation plan

9-task TDD plan covering shared types, socialSystem, brain/movement
guards, game loop wiring, state serialization, client emoji overlay,
and info panel integration.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
root
2026-03-07 12:55:08 +00:00
parent cc8fc7def7
commit 3fc04a6a3a
@@ -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<EntityId, number>;
}
```
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<Position>(e, 'position', { x, y });
world.addComponent<Needs>(e, 'needs', {
hunger: opts?.hunger ?? 80,
energy: opts?.energy ?? 80,
});
world.addComponent<Movement>(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<NPCBrain>(e, 'npcBrain', {
currentGoal: (opts?.goal as any) ?? 'wander',
goalQueue: [],
});
world.addComponent<SocialState>(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<SocialState>(a, 'socialState')!;
const sb = world.getComponent<SocialState>(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<SocialState>(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<SocialState>(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<SocialState>(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<SocialState>(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<SocialState>(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<SocialState>(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<SocialState>(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<SocialState>(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<SocialState>(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<SocialState>(a, 'socialState')!;
const sb = world.getComponent<SocialState>(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<SocialState>(a, 'socialState')!;
const sb = world.getComponent<SocialState>(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<SocialState>(a, 'socialState')!;
const sb = world.getComponent<SocialState>(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<Movement>(a, 'movement')!;
const mb = world.getComponent<Movement>(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<SocialState>(a, 'socialState')!;
const sb = world.getComponent<SocialState>(b, 'socialState')!;
const sc = world.getComponent<SocialState>(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<SocialState>(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<NPCBrain>(target, 'npcBrain');
if (!brain || brain.currentGoal === 'wander' || brain.currentGoal === null) return false;
const needs = world.getComponent<Needs>(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<EntityId>();
// --- Cooldown decay for ALL social entities ---
for (const entity of npcs) {
const social = world.getComponent<SocialState>(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<SocialState>(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<SocialState>(entity, 'socialState')!;
if (social.phase !== 'none' || social.globalCooldown > 0) continue;
if (claimedThisTick.has(entity)) continue;
const pos = world.getComponent<Position>(entity, 'position')!;
const movement = world.getComponent<Movement>(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<SocialState>(other, 'socialState')!;
if (otherSocial.phase !== 'none') continue;
if (social.pairCooldowns.has(other)) continue;
const otherPos = world.getComponent<Position>(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<SocialState>(bestPartner, 'socialState')!;
const partnerPos = world.getComponent<Position>(bestPartner, 'position')!;
const partnerMovement = world.getComponent<Movement>(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<SocialState>(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<NPCBrain>(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<Position>(e, 'position', { x: 0, y: 0 });
world.addComponent<Movement>(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<Position>(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<SocialState>(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<SocialState>(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<SocialState>(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<SocialState>(entityId, 'socialState');
return {
id: entityId,
position: world.getComponent<Position>(entityId, 'position')!,
movement: world.getComponent<Movement>(entityId, 'movement')!,
appearance: world.getComponent<Appearance>(entityId, 'appearance')!,
needs: world.getComponent<Needs>(entityId, 'needs'),
npcBrain: world.getComponent<NPCBrain>(entityId, 'npcBrain'),
playerControlled: world.getComponent<PlayerControlled>(entityId, 'playerControlled'),
name: world.getComponent<string>(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<number, ActiveEmoji> = 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<number, { screenX: number; screenY: number }>,
): void {
const emoting = new Set<number>();
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<number, { screenX: number; screenY: number }>();
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<string, string> = {
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"
```