diff --git a/server/src/systems/__tests__/socialSystem.test.ts b/server/src/systems/__tests__/socialSystem.test.ts index 6ccffd6..887eced 100644 --- a/server/src/systems/__tests__/socialSystem.test.ts +++ b/server/src/systems/__tests__/socialSystem.test.ts @@ -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 = {}): 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(entity, 'stats', base); + world.addComponent(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(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(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(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')!; + // 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(a, 'socialState')!; + const sb = w.getComponent(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(a, 'socialState')!; + if (saAfter.outcome === 'positive') positiveCount++; + } + expect(positiveCount).toBeGreaterThan(100); + }); + }); }); diff --git a/server/src/systems/socialSystem.ts b/server/src/systems/socialSystem.ts index 15eaa5d..9aefa53 100644 --- a/server/src/systems/socialSystem.ts +++ b/server/src/systems/socialSystem.ts @@ -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(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; }