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:
@@ -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"
|
||||
```
|
||||
Reference in New Issue
Block a user