diff --git a/server/src/systems/__tests__/socialSystem.test.ts b/server/src/systems/__tests__/socialSystem.test.ts new file mode 100644 index 0000000..d41bbec --- /dev/null +++ b/server/src/systems/__tests__/socialSystem.test.ts @@ -0,0 +1,292 @@ +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 { + AWARENESS_RADIUS, + HUNGER_THRESHOLD, + ENERGY_THRESHOLD, + SOCIAL_GLOBAL_COOLDOWN, + SOCIAL_PAIR_COOLDOWN, + Direction, +} 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); + 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); + socialSystem(world); + const sa = world.getComponent(a, 'socialState')!; + const sb = world.getComponent(b, 'socialState')!; + expect(sa.phase).toBe('none'); + expect(sb.phase).toBe('none'); + }); + + it('skips player-controlled entities', () => { + const world = new World(); + const a = createNPC(world, 5, 5); + const b = createNPC(world, 8, 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, 8, 5, { goal: 'eat', hunger: HUNGER_THRESHOLD - 5 }); + socialSystem(world); + const sa = world.getComponent(a, 'socialState')!; + const sb = world.getComponent(b, 'socialState')!; + expect(sa.phase).toBe('none'); + expect(sb.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, 8, 5, { goal: 'rest', energy: ENERGY_THRESHOLD - 5 }); + socialSystem(world); + const sa = world.getComponent(a, 'socialState')!; + const sb = world.getComponent(b, 'socialState')!; + expect(sa.phase).toBe('none'); + expect(sb.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, 8, 5, { goal: 'eat', hunger: HUNGER_THRESHOLD + 5 }); + socialSystem(world); + const sa = world.getComponent(a, 'socialState')!; + const sb = world.getComponent(b, 'socialState')!; + expect(sa.phase).toBe('facing'); + expect(sb.phase).toBe('facing'); + }); + }); + + describe('cooldowns', () => { + it('respects global cooldown', () => { + const world = new World(); + const a = createNPC(world, 5, 5); + const b = createNPC(world, 8, 5); + const sa = world.getComponent(a, 'socialState')!; + sa.globalCooldown = 10; + socialSystem(world); + const saAfter = world.getComponent(a, 'socialState')!; + expect(saAfter.phase).toBe('none'); + }); + + it('decrements global cooldown each tick', () => { + const world = new World(); + const a = createNPC(world, 5, 5); + const sa = world.getComponent(a, 'socialState')!; + sa.globalCooldown = 10; + socialSystem(world); + const saAfter = world.getComponent(a, 'socialState')!; + expect(saAfter.globalCooldown).toBe(9); + }); + + it('respects pair cooldown', () => { + const world = new World(); + const a = createNPC(world, 5, 5); + const b = createNPC(world, 8, 5); + const sa = world.getComponent(a, 'socialState')!; + sa.pairCooldowns.set(b, 10); + socialSystem(world); + const saAfter = world.getComponent(a, 'socialState')!; + expect(saAfter.phase).toBe('none'); + }); + + it('decrements pair cooldowns and removes expired', () => { + const world = new World(); + const a = createNPC(world, 5, 5); + const b = createNPC(world, 8, 5); + const c = createNPC(world, 20, 20); // far away, just for a second pair cooldown entry + const sa = world.getComponent(a, 'socialState')!; + sa.pairCooldowns.set(b, 1); + sa.pairCooldowns.set(c, 5); + socialSystem(world); + const saAfter = world.getComponent(a, 'socialState')!; + expect(saAfter.pairCooldowns.has(b)).toBe(false); + expect(saAfter.pairCooldowns.get(c)).toBe(4); + }); + }); + + describe('phase transitions', () => { + it('facing -> pausing when timer expires', () => { + const world = new World(); + const a = createNPC(world, 5, 5); + const b = createNPC(world, 8, 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); + const saAfter = world.getComponent(a, 'socialState')!; + const sbAfter = world.getComponent(b, 'socialState')!; + expect(saAfter.phase).toBe('pausing'); + expect(sbAfter.phase).toBe('pausing'); + }); + + it('pausing -> emoting rolls outcome', () => { + const world = new World(); + const a = createNPC(world, 5, 5); + const b = createNPC(world, 8, 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); + const saAfter = world.getComponent(a, 'socialState')!; + const sbAfter = world.getComponent(b, 'socialState')!; + expect(saAfter.phase).toBe('emoting'); + expect(['positive', 'negative']).toContain(saAfter.outcome); + expect(sbAfter.outcome).toBe(saAfter.outcome); + }); + + it('emoting -> none sets cooldowns', () => { + const world = new World(); + const a = createNPC(world, 5, 5); + const b = createNPC(world, 8, 5); + const sa = world.getComponent(a, 'socialState')!; + const sb = world.getComponent(b, 'socialState')!; + sa.phase = 'emoting'; + sa.partnerId = b; + sa.phaseTimer = 1; + sb.phase = 'emoting'; + sb.partnerId = a; + sb.phaseTimer = 1; + socialSystem(world); + const saAfter = world.getComponent(a, 'socialState')!; + const sbAfter = world.getComponent(b, 'socialState')!; + expect(saAfter.phase).toBe('none'); + expect(saAfter.globalCooldown).toBe(SOCIAL_GLOBAL_COOLDOWN); + expect(saAfter.pairCooldowns.get(b)).toBe(SOCIAL_PAIR_COOLDOWN); + expect(sbAfter.phase).toBe('none'); + expect(sbAfter.globalCooldown).toBe(SOCIAL_GLOBAL_COOLDOWN); + expect(sbAfter.pairCooldowns.get(a)).toBe(SOCIAL_PAIR_COOLDOWN); + }); + }); + + describe('direction facing', () => { + it('NPCs face each other', () => { + const world = new World(); + const a = createNPC(world, 5, 5); + const b = createNPC(world, 8, 5); + socialSystem(world); + const ma = world.getComponent(a, 'movement')!; + const mb = world.getComponent(b, 'movement')!; + expect(ma.direction).toBe(Direction.RIGHT); // 3 + expect(mb.direction).toBe(Direction.LEFT); // 1 + }); + }); + + describe('conflict resolution', () => { + it('first initiator claims target; second NPC cannot claim same target', () => { + const world = new World(); + const a = createNPC(world, 0, 0); + const b = createNPC(world, 3, 0); + const c = createNPC(world, 5, 0); + socialSystem(world); + const sa = world.getComponent(a, 'socialState')!; + const sb = world.getComponent(b, 'socialState')!; + const sc = world.getComponent(c, 'socialState')!; + // b is within range of both a and c; only one pair should form + const bInteracting = sb.phase === 'facing'; + if (bInteracting) { + // b is paired with exactly one of a or c + const bPartner = sb.partnerId; + expect([a, c]).toContain(bPartner); + // the other NPC should remain in 'none' + const other = bPartner === a ? c : a; + const sOther = world.getComponent(other, 'socialState')!; + expect(sOther.phase).toBe('none'); + } + // at most one pair formed + const interacting = [sa, sb, sc].filter(s => s.phase === 'facing'); + expect(interacting.length).toBeLessThanOrEqual(2); + }); + }); + + describe('partner despawn', () => { + it('resets remaining NPC when partner is removed from world', () => { + const world = new World(); + const a = createNPC(world, 5, 5); + const b = createNPC(world, 8, 5); + // Set up an active interaction + const sa = world.getComponent(a, 'socialState')!; + const sb = world.getComponent(b, 'socialState')!; + sa.phase = 'pausing'; + sa.partnerId = b; + sa.phaseTimer = 5; + sb.phase = 'pausing'; + sb.partnerId = a; + sb.phaseTimer = 5; + // Remove partner b from the world + world.removeEntity(b); + socialSystem(world); + const saAfter = world.getComponent(a, 'socialState')!; + expect(saAfter.phase).toBe('none'); + expect(saAfter.partnerId).toBeNull(); + }); + }); +});