test: add failing tests for social interaction system
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user