feat: integrate perception, sociability, and empathy into social system
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user