diff --git a/server/src/systems/__tests__/relationshipSystem.test.ts b/server/src/systems/__tests__/relationshipSystem.test.ts new file mode 100644 index 0000000..8dcf8b0 --- /dev/null +++ b/server/src/systems/__tests__/relationshipSystem.test.ts @@ -0,0 +1,239 @@ +import { describe, it, expect } from 'vitest'; +import { World } from '../../ecs/World.js'; +import { relationshipSystem } from '../relationshipSystem.js'; +import type { + Position, Needs, Movement, NPCBrain, SocialState, Stats, + StatModifiers, Relationships, RelationshipData, EntityId, +} from '@dflike/shared'; + +function createNPC( + world: World, x: number, y: number, + opts?: { stats?: Partial }, +): EntityId { + const e = world.createEntity(); + world.addComponent(e, 'position', { x, y }); + world.addComponent(e, 'needs', { hunger: 80, energy: 80 }); + world.addComponent(e, 'movement', { + state: 'idle', target: null, path: [], direction: 0, moveProgress: 0, + }); + world.addComponent(e, 'npcBrain', { currentGoal: 'wander', goalQueue: [] }); + world.addComponent(e, 'socialState', { + phase: 'none', partnerId: null, phaseTimer: 0, outcome: null, + globalCooldown: 0, pairCooldowns: new Map(), lastOutcome: null, + }); + const baseStats: Stats = { + strength: 10, dexterity: 10, constitution: 10, intelligence: 10, perception: 10, + sociability: 10, courage: 10, curiosity: 10, empathy: 10, temperament: 10, + ...(opts?.stats ?? {}), + }; + world.addComponent(e, 'stats', baseStats); + world.addComponent(e, 'statModifiers', { modifiers: [] }); + world.addComponent(e, 'relationships', new Map()); + return e; +} + +describe('relationshipSystem', () => { + describe('processing lastOutcome', () => { + it('creates relationship entries on first interaction', () => { + const world = new World(); + const a = createNPC(world, 5, 5); + const b = createNPC(world, 8, 5); + const sa = world.getComponent(a, 'socialState')!; + const sb = world.getComponent(b, 'socialState')!; + sa.lastOutcome = { partnerId: b, outcome: 'positive', tick: 1 }; + sb.lastOutcome = { partnerId: a, outcome: 'positive', tick: 1 }; + relationshipSystem(world); + const relsA = world.getComponent(a, 'relationships')!; + const relsB = world.getComponent(b, 'relationships')!; + expect(relsA.has(b)).toBe(true); + expect(relsB.has(a)).toBe(true); + expect(relsA.get(b)!.value).toBeGreaterThan(0); + expect(relsB.get(a)!.value).toBeGreaterThan(0); + }); + + it('clears lastOutcome after processing', () => { + const world = new World(); + const a = createNPC(world, 5, 5); + const b = createNPC(world, 8, 5); + const sa = world.getComponent(a, 'socialState')!; + const sb = world.getComponent(b, 'socialState')!; + sa.lastOutcome = { partnerId: b, outcome: 'positive', tick: 1 }; + sb.lastOutcome = { partnerId: a, outcome: 'positive', tick: 1 }; + relationshipSystem(world); + expect(sa.lastOutcome).toBeNull(); + expect(sb.lastOutcome).toBeNull(); + }); + + it('negative outcome decreases relationship value', () => { + const world = new World(); + const a = createNPC(world, 5, 5); + const b = createNPC(world, 8, 5); + const sa = world.getComponent(a, 'socialState')!; + const sb = world.getComponent(b, 'socialState')!; + sa.lastOutcome = { partnerId: b, outcome: 'negative', tick: 1 }; + sb.lastOutcome = { partnerId: a, outcome: 'negative', tick: 1 }; + relationshipSystem(world); + const relsA = world.getComponent(a, 'relationships')!; + expect(relsA.get(b)!.value).toBeLessThan(0); + }); + + it('increments interaction count', () => { + const world = new World(); + const a = createNPC(world, 5, 5); + const b = createNPC(world, 8, 5); + const sa = world.getComponent(a, 'socialState')!; + const sb = world.getComponent(b, 'socialState')!; + sa.lastOutcome = { partnerId: b, outcome: 'positive', tick: 1 }; + sb.lastOutcome = { partnerId: a, outcome: 'positive', tick: 1 }; + relationshipSystem(world); + sa.lastOutcome = { partnerId: b, outcome: 'positive', tick: 50 }; + sb.lastOutcome = { partnerId: a, outcome: 'positive', tick: 50 }; + relationshipSystem(world); + const relsA = world.getComponent(a, 'relationships')!; + expect(relsA.get(b)!.interactions).toBe(2); + }); + + it('applies diminishing returns on repeated interactions', () => { + const world = new World(); + const a = createNPC(world, 5, 5); + const b = createNPC(world, 8, 5); + const sa = world.getComponent(a, 'socialState')!; + const sb = world.getComponent(b, 'socialState')!; + sa.lastOutcome = { partnerId: b, outcome: 'positive', tick: 1 }; + sb.lastOutcome = { partnerId: a, outcome: 'positive', tick: 1 }; + relationshipSystem(world); + const relsA = world.getComponent(a, 'relationships')!; + const firstDelta = relsA.get(b)!.value; + + sa.lastOutcome = { partnerId: b, outcome: 'positive', tick: 50 }; + sb.lastOutcome = { partnerId: a, outcome: 'positive', tick: 50 }; + relationshipSystem(world); + const secondDelta = relsA.get(b)!.value - firstDelta; + expect(secondDelta).toBeLessThan(firstDelta); + }); + + it('blends individual and shared outcomes asymmetrically', () => { + const world = new World(); + const a = createNPC(world, 5, 5); + const b = createNPC(world, 8, 5); + const sa = world.getComponent(a, 'socialState')!; + const sb = world.getComponent(b, 'socialState')!; + sa.lastOutcome = { partnerId: b, outcome: 'positive', tick: 1 }; + sb.lastOutcome = { partnerId: a, outcome: 'negative', tick: 1 }; + relationshipSystem(world); + const relsA = world.getComponent(a, 'relationships')!; + const relsB = world.getComponent(b, 'relationships')!; + expect(relsA.get(b)!.value).not.toBe(relsB.get(a)!.value); + }); + + it('clamps relationship value to [-100, 100]', () => { + const world = new World(); + const a = createNPC(world, 5, 5); + const b = createNPC(world, 8, 5); + const relsA = world.getComponent(a, 'relationships')!; + const relsB = world.getComponent(b, 'relationships')!; + relsA.set(b, { value: 99, interactions: 0, lastInteractionTick: 0, status: 'active' }); + relsB.set(a, { value: 99, interactions: 0, lastInteractionTick: 0, status: 'active' }); + const sa = world.getComponent(a, 'socialState')!; + const sb = world.getComponent(b, 'socialState')!; + sa.lastOutcome = { partnerId: b, outcome: 'positive', tick: 1 }; + sb.lastOutcome = { partnerId: a, outcome: 'positive', tick: 1 }; + relationshipSystem(world); + expect(relsA.get(b)!.value).toBeLessThanOrEqual(100); + }); + }); + + describe('stat influence', () => { + it('high empathy increases positive relationship gain', () => { + const world = new World(); + const aHigh = createNPC(world, 5, 5, { stats: { empathy: 18 } }); + const bHigh = createNPC(world, 8, 5); + const saH = world.getComponent(aHigh, 'socialState')!; + const sbH = world.getComponent(bHigh, 'socialState')!; + saH.lastOutcome = { partnerId: bHigh, outcome: 'positive', tick: 1 }; + sbH.lastOutcome = { partnerId: aHigh, outcome: 'positive', tick: 1 }; + + const world2 = new World(); + const aLow = createNPC(world2, 5, 5, { stats: { empathy: 3 } }); + const bLow = createNPC(world2, 8, 5); + const saL = world2.getComponent(aLow, 'socialState')!; + const sbL = world2.getComponent(bLow, 'socialState')!; + saL.lastOutcome = { partnerId: bLow, outcome: 'positive', tick: 1 }; + sbL.lastOutcome = { partnerId: aLow, outcome: 'positive', tick: 1 }; + + relationshipSystem(world); + relationshipSystem(world2); + + const highGain = world.getComponent(aHigh, 'relationships')!.get(bHigh)!.value; + const lowGain = world2.getComponent(aLow, 'relationships')!.get(bLow)!.value; + expect(highGain).toBeGreaterThan(lowGain); + }); + + it('high temperament increases negative relationship loss', () => { + const world = new World(); + const aHigh = createNPC(world, 5, 5, { stats: { temperament: 18 } }); + const bHigh = createNPC(world, 8, 5); + const saH = world.getComponent(aHigh, 'socialState')!; + const sbH = world.getComponent(bHigh, 'socialState')!; + saH.lastOutcome = { partnerId: bHigh, outcome: 'negative', tick: 1 }; + sbH.lastOutcome = { partnerId: aHigh, outcome: 'negative', tick: 1 }; + + const world2 = new World(); + const aLow = createNPC(world2, 5, 5, { stats: { temperament: 3 } }); + const bLow = createNPC(world2, 8, 5); + const saL = world2.getComponent(aLow, 'socialState')!; + const sbL = world2.getComponent(bLow, 'socialState')!; + saL.lastOutcome = { partnerId: bLow, outcome: 'negative', tick: 1 }; + sbL.lastOutcome = { partnerId: aLow, outcome: 'negative', tick: 1 }; + + relationshipSystem(world); + relationshipSystem(world2); + + const highLoss = world.getComponent(aHigh, 'relationships')!.get(bHigh)!.value; + const lowLoss = world2.getComponent(aLow, 'relationships')!.get(bLow)!.value; + expect(highLoss).toBeLessThan(lowLoss); + }); + }); + + describe('despawn handling', () => { + it('fades weak relationships with removed entities', () => { + const world = new World(); + const a = createNPC(world, 5, 5); + const b = createNPC(world, 8, 5); + const relsA = world.getComponent(a, 'relationships')!; + relsA.set(b, { value: 10, interactions: 2, lastInteractionTick: 0, status: 'active' }); + world.removeEntity(b); + relationshipSystem(world); + const rel = relsA.get(b); + expect(rel).toBeDefined(); + expect(rel!.value).toBeLessThan(10); + }); + + it('preserves strong relationships as memories', () => { + const world = new World(); + const a = createNPC(world, 5, 5); + const b = createNPC(world, 8, 5); + const relsA = world.getComponent(a, 'relationships')!; + relsA.set(b, { value: 50, interactions: 10, lastInteractionTick: 0, status: 'active' }); + world.removeEntity(b); + relationshipSystem(world); + const rel = relsA.get(b); + expect(rel).toBeDefined(); + expect(rel!.status).toBe('memory'); + expect(rel!.value).toBe(50); + }); + + it('cleans up faded relationships that reach zero', () => { + const world = new World(); + const a = createNPC(world, 5, 5); + const b = createNPC(world, 8, 5); + const relsA = world.getComponent(a, 'relationships')!; + relsA.set(b, { value: 0.005, interactions: 1, lastInteractionTick: 0, status: 'active' }); + world.removeEntity(b); + for (let i = 0; i < 10; i++) { + relationshipSystem(world); + } + expect(relsA.has(b)).toBe(false); + }); + }); +}); diff --git a/server/src/systems/relationshipSystem.ts b/server/src/systems/relationshipSystem.ts new file mode 100644 index 0000000..c54bce1 --- /dev/null +++ b/server/src/systems/relationshipSystem.ts @@ -0,0 +1,131 @@ +import type { + SocialState, Relationships, RelationshipData, EntityId, + InteractionOutcome, +} from '@dflike/shared'; +import type { World } from '../ecs/World.js'; +import { getEffectiveStat } from './statHelpers.js'; +import { relationshipConfig as cfg } from '../config/relationshipConfig.js'; + +function computeIndividualDelta( + world: World, + entity: EntityId, + outcome: InteractionOutcome, + interactions: number, +): number { + const isPositive = outcome === 'positive'; + + // 1. Base delta + const baseDelta = isPositive ? cfg.baseDeltaPositive : -cfg.baseDeltaNegative; + + // 2. Diminishing returns + let delta = baseDelta / (1 + interactions * cfg.diminishingFactor); + + // 3/4. Stat scaling + if (isPositive) { + const empathy = getEffectiveStat(world, entity, 'empathy'); + delta *= (1 + (empathy - 10) * cfg.empathyWeight); + } else { + const temperament = getEffectiveStat(world, entity, 'temperament'); + delta *= (1 + (temperament - 10) * cfg.temperamentWeight); + } + + // 5. Sociability bonus + const sociability = getEffectiveStat(world, entity, 'sociability'); + if (isPositive) { + delta += (sociability - 10) * cfg.sociabilityBonus; + } else { + delta -= (sociability - 10) * cfg.sociabilityBonus; + } + + return delta; +} + +export function relationshipSystem(world: World): void { + const entities = world.query('socialState', 'relationships', 'stats'); + const processed = new Set(); + + // Phase 1: Process completed interactions + for (const entity of entities) { + const social = world.getComponent(entity, 'socialState')!; + if (!social.lastOutcome) continue; + + const partnerId = social.lastOutcome.partnerId; + const pairKey = entity < partnerId + ? `${entity}:${partnerId}` + : `${partnerId}:${entity}`; + + if (processed.has(pairKey)) continue; + processed.add(pairKey); + + const partnerSocial = world.getComponent(partnerId, 'socialState'); + if (!partnerSocial || !partnerSocial.lastOutcome) continue; + + const relsA = world.getComponent(entity, 'relationships')!; + const relsB = world.getComponent(partnerId, 'relationships')!; + + // Lazy-init relationship entries + if (!relsA.has(partnerId)) { + relsA.set(partnerId, { value: 0, interactions: 0, lastInteractionTick: 0, status: 'active' }); + } + if (!relsB.has(entity)) { + relsB.set(entity, { value: 0, interactions: 0, lastInteractionTick: 0, status: 'active' }); + } + + const relA = relsA.get(partnerId)!; + const relB = relsB.get(entity)!; + + // Compute individual deltas using existing interactions count (before incrementing) + const deltaA = computeIndividualDelta(world, entity, social.lastOutcome.outcome, relA.interactions); + const deltaB = computeIndividualDelta(world, partnerId, partnerSocial.lastOutcome.outcome, relB.interactions); + + // 6. Blended asymmetry + const avgDelta = (deltaA + deltaB) / 2; + const finalDeltaA = cfg.sharedWeight * avgDelta + cfg.individualWeight * deltaA; + const finalDeltaB = cfg.sharedWeight * avgDelta + cfg.individualWeight * deltaB; + + // Apply and clamp + relA.value = Math.max(-100, Math.min(100, relA.value + finalDeltaA)); + relB.value = Math.max(-100, Math.min(100, relB.value + finalDeltaB)); + + // Increment interactions and update tick + relA.interactions++; + relB.interactions++; + relA.lastInteractionTick = social.lastOutcome.tick; + relB.lastInteractionTick = partnerSocial.lastOutcome.tick; + + // Clear lastOutcome on both + social.lastOutcome = null; + partnerSocial.lastOutcome = null; + } + + // Phase 2: Handle despawned entity relationships + for (const entity of entities) { + const rels = world.getComponent(entity, 'relationships')!; + const toRemove: EntityId[] = []; + + for (const [otherId, rel] of rels) { + // Check if other entity still exists + if (world.getComponent(otherId, 'position') !== undefined) continue; + + if (Math.abs(rel.value) >= cfg.memoryThreshold) { + // Strong relationship becomes memory + rel.status = 'memory'; + } else { + // Fade toward 0 + if (rel.value > 0) { + rel.value = Math.max(0, rel.value - cfg.fadeRatePerTick); + } else { + rel.value = Math.min(0, rel.value + cfg.fadeRatePerTick); + } + + if (Math.abs(rel.value) < 0.01) { + toRemove.push(otherId); + } + } + } + + for (const id of toRemove) { + rels.delete(id); + } + } +}