test: add failing tests for social interaction system

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
root
2026-03-07 13:00:34 +00:00
parent ce55c86c5a
commit d0a7bfc4bc
@@ -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<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);
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);
socialSystem(world);
const sa = world.getComponent<SocialState>(a, 'socialState')!;
const sb = world.getComponent<SocialState>(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<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, 8, 5, { goal: 'eat', hunger: HUNGER_THRESHOLD - 5 });
socialSystem(world);
const sa = world.getComponent<SocialState>(a, 'socialState')!;
const sb = world.getComponent<SocialState>(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<SocialState>(a, 'socialState')!;
const sb = world.getComponent<SocialState>(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<SocialState>(a, 'socialState')!;
const sb = world.getComponent<SocialState>(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<SocialState>(a, 'socialState')!;
sa.globalCooldown = 10;
socialSystem(world);
const saAfter = world.getComponent<SocialState>(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<SocialState>(a, 'socialState')!;
sa.globalCooldown = 10;
socialSystem(world);
const saAfter = world.getComponent<SocialState>(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<SocialState>(a, 'socialState')!;
sa.pairCooldowns.set(b, 10);
socialSystem(world);
const saAfter = world.getComponent<SocialState>(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<SocialState>(a, 'socialState')!;
sa.pairCooldowns.set(b, 1);
sa.pairCooldowns.set(c, 5);
socialSystem(world);
const saAfter = world.getComponent<SocialState>(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<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);
const saAfter = world.getComponent<SocialState>(a, 'socialState')!;
const sbAfter = world.getComponent<SocialState>(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<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);
const saAfter = world.getComponent<SocialState>(a, 'socialState')!;
const sbAfter = world.getComponent<SocialState>(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<SocialState>(a, 'socialState')!;
const sb = world.getComponent<SocialState>(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<SocialState>(a, 'socialState')!;
const sbAfter = world.getComponent<SocialState>(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<Movement>(a, 'movement')!;
const mb = world.getComponent<Movement>(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<SocialState>(a, 'socialState')!;
const sb = world.getComponent<SocialState>(b, 'socialState')!;
const sc = world.getComponent<SocialState>(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<SocialState>(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<SocialState>(a, 'socialState')!;
const sb = world.getComponent<SocialState>(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<SocialState>(a, 'socialState')!;
expect(saAfter.phase).toBe('none');
expect(saAfter.partnerId).toBeNull();
});
});
});