feat: add relationshipSystem with delta calculation and despawn handling
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user