feat: add relationshipSystem with delta calculation and despawn handling

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
root
2026-03-07 15:34:46 +00:00
parent df0f036cf2
commit 4f5e4f34ba
2 changed files with 370 additions and 0 deletions
@@ -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<Stats> },
): EntityId {
const e = world.createEntity();
world.addComponent<Position>(e, 'position', { x, y });
world.addComponent<Needs>(e, 'needs', { hunger: 80, energy: 80 });
world.addComponent<Movement>(e, 'movement', {
state: 'idle', target: null, path: [], direction: 0, moveProgress: 0,
});
world.addComponent<NPCBrain>(e, 'npcBrain', { currentGoal: 'wander', goalQueue: [] });
world.addComponent<SocialState>(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<Stats>(e, 'stats', baseStats);
world.addComponent<StatModifiers>(e, 'statModifiers', { modifiers: [] });
world.addComponent<Relationships>(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<SocialState>(a, 'socialState')!;
const sb = world.getComponent<SocialState>(b, 'socialState')!;
sa.lastOutcome = { partnerId: b, outcome: 'positive', tick: 1 };
sb.lastOutcome = { partnerId: a, outcome: 'positive', tick: 1 };
relationshipSystem(world);
const relsA = world.getComponent<Relationships>(a, 'relationships')!;
const relsB = world.getComponent<Relationships>(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<SocialState>(a, 'socialState')!;
const sb = world.getComponent<SocialState>(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<SocialState>(a, 'socialState')!;
const sb = world.getComponent<SocialState>(b, 'socialState')!;
sa.lastOutcome = { partnerId: b, outcome: 'negative', tick: 1 };
sb.lastOutcome = { partnerId: a, outcome: 'negative', tick: 1 };
relationshipSystem(world);
const relsA = world.getComponent<Relationships>(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<SocialState>(a, 'socialState')!;
const sb = world.getComponent<SocialState>(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<Relationships>(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<SocialState>(a, 'socialState')!;
const sb = world.getComponent<SocialState>(b, 'socialState')!;
sa.lastOutcome = { partnerId: b, outcome: 'positive', tick: 1 };
sb.lastOutcome = { partnerId: a, outcome: 'positive', tick: 1 };
relationshipSystem(world);
const relsA = world.getComponent<Relationships>(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<SocialState>(a, 'socialState')!;
const sb = world.getComponent<SocialState>(b, 'socialState')!;
sa.lastOutcome = { partnerId: b, outcome: 'positive', tick: 1 };
sb.lastOutcome = { partnerId: a, outcome: 'negative', tick: 1 };
relationshipSystem(world);
const relsA = world.getComponent<Relationships>(a, 'relationships')!;
const relsB = world.getComponent<Relationships>(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<Relationships>(a, 'relationships')!;
const relsB = world.getComponent<Relationships>(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<SocialState>(a, 'socialState')!;
const sb = world.getComponent<SocialState>(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<SocialState>(aHigh, 'socialState')!;
const sbH = world.getComponent<SocialState>(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<SocialState>(aLow, 'socialState')!;
const sbL = world2.getComponent<SocialState>(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<Relationships>(aHigh, 'relationships')!.get(bHigh)!.value;
const lowGain = world2.getComponent<Relationships>(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<SocialState>(aHigh, 'socialState')!;
const sbH = world.getComponent<SocialState>(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<SocialState>(aLow, 'socialState')!;
const sbL = world2.getComponent<SocialState>(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<Relationships>(aHigh, 'relationships')!.get(bHigh)!.value;
const lowLoss = world2.getComponent<Relationships>(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<Relationships>(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<Relationships>(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<Relationships>(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);
});
});
});
+131
View File
@@ -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<string>();
// Phase 1: Process completed interactions
for (const entity of entities) {
const social = world.getComponent<SocialState>(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<SocialState>(partnerId, 'socialState');
if (!partnerSocial || !partnerSocial.lastOutcome) continue;
const relsA = world.getComponent<Relationships>(entity, 'relationships')!;
const relsB = world.getComponent<Relationships>(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<Relationships>(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);
}
}
}