feat: integrate perception, sociability, and empathy into social system

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
root
2026-03-07 14:23:21 +00:00
parent 42b574259f
commit fbe06e9d79
2 changed files with 88 additions and 6 deletions
@@ -1,7 +1,7 @@
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 type { Position, Needs, Movement, NPCBrain, SocialState, EntityId, Stats, StatModifiers } from '@dflike/shared';
import {
AWARENESS_RADIUS,
HUNGER_THRESHOLD,
@@ -43,6 +43,16 @@ function createNPC(
return e;
}
function addStats(world: World, entity: number, overrides: Partial<Stats> = {}): void {
const base: Stats = {
strength: 10, dexterity: 10, constitution: 10, intelligence: 10, perception: 10,
sociability: 10, courage: 10, curiosity: 10, empathy: 10, temperament: 10,
...overrides,
};
world.addComponent<Stats>(entity, 'stats', base);
world.addComponent<StatModifiers>(entity, 'statModifiers', { modifiers: [] });
}
describe('socialSystem', () => {
describe('proximity detection', () => {
it('initiates interaction when two idle NPCs are within awareness radius', () => {
@@ -289,4 +299,67 @@ describe('socialSystem', () => {
expect(saAfter.partnerId).toBeNull();
});
});
describe('stat integration', () => {
it('high perception extends awareness radius', () => {
const world = new World();
// Place NPCs at distance 7 (outside default AWARENESS_RADIUS of 5)
const a = createNPC(world, 0, 0);
const b = createNPC(world, 7, 0);
addStats(world, a, { perception: 14 }); // +4 radius = 9
addStats(world, b);
socialSystem(world);
const sa = world.getComponent<SocialState>(a, 'socialState')!;
expect(sa.phase).toBe('facing');
});
it('low perception shrinks awareness radius', () => {
const world = new World();
// Place NPCs at distance 4 (inside default AWARENESS_RADIUS of 5)
const a = createNPC(world, 0, 0);
const b = createNPC(world, 4, 0);
addStats(world, a, { perception: 6 }); // -4 radius = 1
addStats(world, b, { perception: 6 }); // both have shrunk awareness
socialSystem(world);
const sa = world.getComponent<SocialState>(a, 'socialState')!;
expect(sa.phase).toBe('none');
});
it('high sociability reduces global cooldown', () => {
const world = new World();
const a = createNPC(world, 5, 5);
const b = createNPC(world, 8, 5);
addStats(world, a, { sociability: 15 }); // multiplier: 1 - 5*0.04 = 0.8
addStats(world, b);
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')!;
// 50 * 0.8 = 40
expect(saAfter.globalCooldown).toBe(40);
});
it('high empathy biases positive outcome', () => {
const world = new World();
let positiveCount = 0;
const trials = 200;
for (let i = 0; i < trials; i++) {
const w = new World();
const a = createNPC(w, 5, 5);
const b = createNPC(w, 8, 5);
addStats(w, a, { empathy: 18 }); // +0.24 bias = 0.74 chance
addStats(w, b, { empathy: 18 });
const sa = w.getComponent<SocialState>(a, 'socialState')!;
const sb = w.getComponent<SocialState>(b, 'socialState')!;
sa.phase = 'pausing'; sa.partnerId = b; sa.phaseTimer = 1;
sb.phase = 'pausing'; sb.partnerId = a; sb.phaseTimer = 1;
socialSystem(w);
const saAfter = w.getComponent<SocialState>(a, 'socialState')!;
if (saAfter.outcome === 'positive') positiveCount++;
}
expect(positiveCount).toBeGreaterThan(100);
});
});
});
+14 -5
View File
@@ -5,6 +5,7 @@ import {
type SocialState, type Position, type Movement, type NPCBrain, type Needs, type EntityId,
} from '@dflike/shared';
import type { World } from '../ecs/World.js';
import { getEffectiveStat } from './statHelpers.js';
function directionTo(from: Position, to: Position): number {
const dx = to.x - from.x;
@@ -86,22 +87,28 @@ export function socialSystem(world: World): void {
partnerSocial.phase = 'pausing';
partnerSocial.phaseTimer = PAUSING_DURATION;
} else if (social.phase === 'pausing') {
const outcome = Math.random() < 0.5 ? 'positive' : 'negative';
const empA = getEffectiveStat(world, e, 'empathy');
const positiveChanceA = 0.5 + (empA - 10) * 0.03;
const outcome = Math.random() < positiveChanceA ? 'positive' : 'negative';
social.phase = 'emoting';
social.phaseTimer = EMOTING_DURATION;
social.outcome = outcome;
const empB = getEffectiveStat(world, partnerId!, 'empathy');
const positiveChanceB = 0.5 + (empB - 10) * 0.03;
partnerSocial.phase = 'emoting';
partnerSocial.phaseTimer = EMOTING_DURATION;
partnerSocial.outcome = Math.random() < 0.5 ? 'positive' : 'negative';
partnerSocial.outcome = Math.random() < positiveChanceB ? 'positive' : 'negative';
} else if (social.phase === 'emoting') {
social.globalCooldown = SOCIAL_GLOBAL_COOLDOWN;
const socA = getEffectiveStat(world, e, 'sociability');
social.globalCooldown = Math.round(SOCIAL_GLOBAL_COOLDOWN * (1 - (socA - 10) * 0.04));
social.pairCooldowns.set(partnerId!, SOCIAL_PAIR_COOLDOWN);
social.phase = 'none';
social.partnerId = null;
social.phaseTimer = 0;
social.outcome = null;
partnerSocial.globalCooldown = SOCIAL_GLOBAL_COOLDOWN;
const socB = getEffectiveStat(world, partnerId!, 'sociability');
partnerSocial.globalCooldown = Math.round(SOCIAL_GLOBAL_COOLDOWN * (1 - (socB - 10) * 0.04));
partnerSocial.pairCooldowns.set(e, SOCIAL_PAIR_COOLDOWN);
partnerSocial.phase = 'none';
partnerSocial.partnerId = null;
@@ -136,7 +143,9 @@ export function socialSystem(world: World): void {
const otherPos = world.getComponent<Position>(other, 'position')!;
const dist = manhattanDist(pos, otherPos);
if (dist <= AWARENESS_RADIUS && dist < closestDist) {
const perceptionA = getEffectiveStat(world, e, 'perception');
const awarenessA = AWARENESS_RADIUS + (perceptionA - 10);
if (dist <= awarenessA && dist < closestDist) {
closestDist = dist;
closestTarget = other;
}