feat: add proposal queuing and bond-aware despawn to relationship system

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
root
2026-03-07 19:24:32 +00:00
parent bf2a9817ab
commit 66596fb96d
2 changed files with 139 additions and 0 deletions
@@ -1,6 +1,8 @@
import { describe, it, expect } from 'vitest';
import { World } from '../../ecs/World.js';
import { relationshipSystem } from '../relationshipSystem.js';
import { createBondRegistry, hasBond, addBond } from '../bondRegistry.js';
import type { BondRegistry } from '../bondRegistry.js';
import type {
Position, Needs, Movement, NPCBrain, SocialState, Stats,
StatModifiers, Relationships, RelationshipData, EntityId,
@@ -237,4 +239,111 @@ describe('relationshipSystem', () => {
expect(relsA.has(b)).toBe(false);
});
});
describe('proposal queuing', () => {
it('should queue proposal when value crosses threshold after interaction', () => {
const world = new World();
world.setSingleton('bondRegistry', createBondRegistry());
const a = createNPC(world, 1, 1);
const b = createNPC(world, 2, 2);
const relsA = world.getComponent<Relationships>(a, 'relationships')!;
relsA.set(b, { value: 78, interactions: 10, lastInteractionTick: 0, status: 'active' });
const relsB = world.getComponent<Relationships>(b, 'relationships')!;
relsB.set(a, { value: 78, interactions: 10, lastInteractionTick: 0, status: 'active' });
const socialA = world.getComponent<SocialState>(a, 'socialState')!;
socialA.lastOutcome = { partnerId: b, outcome: 'positive', tick: 0 };
const socialB = world.getComponent<SocialState>(b, 'socialState')!;
socialB.lastOutcome = { partnerId: a, outcome: 'positive', tick: 0 };
relationshipSystem(world);
// After processing, values should cross 80+, so at least one should have proposal queued
const propA = world.getComponent<SocialState>(a, 'socialState')!.pendingProposal;
const propB = world.getComponent<SocialState>(b, 'socialState')!.pendingProposal;
const hasProposal = (propA !== null && propA.targetId === b) ||
(propB !== null && propB.targetId === a);
expect(hasProposal).toBe(true);
});
it('should not queue proposal if bond already exists', () => {
const world = new World();
const registry = createBondRegistry();
world.setSingleton('bondRegistry', registry);
const a = createNPC(world, 1, 1);
const b = createNPC(world, 2, 2);
addBond(registry, a, b, 'partner', 0);
const relsA = world.getComponent<Relationships>(a, 'relationships')!;
relsA.set(b, { value: 85, interactions: 10, lastInteractionTick: 0, status: 'active' });
const relsB = world.getComponent<Relationships>(b, 'relationships')!;
relsB.set(a, { value: 85, interactions: 10, lastInteractionTick: 0, status: 'active' });
// Need lastOutcome on both for the pair to be processed
const socialA = world.getComponent<SocialState>(a, 'socialState')!;
socialA.lastOutcome = { partnerId: b, outcome: 'positive', tick: 0 };
const socialB = world.getComponent<SocialState>(b, 'socialState')!;
socialB.lastOutcome = { partnerId: a, outcome: 'positive', tick: 0 };
relationshipSystem(world);
expect(world.getComponent<SocialState>(a, 'socialState')!.pendingProposal).toBeNull();
expect(world.getComponent<SocialState>(b, 'socialState')!.pendingProposal).toBeNull();
});
it('should not queue proposal if proposal cooldown is active', () => {
const world = new World();
world.setSingleton('bondRegistry', createBondRegistry());
const a = createNPC(world, 1, 1);
const b = createNPC(world, 2, 2);
const relsA = world.getComponent<Relationships>(a, 'relationships')!;
relsA.set(b, { value: 85, interactions: 10, lastInteractionTick: 0, status: 'active' });
const socialA = world.getComponent<SocialState>(a, 'socialState')!;
socialA.proposalCooldown = 100;
// No lastOutcome needed — proposal check runs regardless
relationshipSystem(world);
expect(socialA.pendingProposal).toBeNull();
expect(socialA.proposalCooldown).toBe(99); // decremented by 1
});
it('should decrement proposal cooldown each tick', () => {
const world = new World();
world.setSingleton('bondRegistry', createBondRegistry());
const a = createNPC(world, 1, 1);
const socialA = world.getComponent<SocialState>(a, 'socialState')!;
socialA.proposalCooldown = 5;
relationshipSystem(world);
expect(socialA.proposalCooldown).toBe(4);
});
});
describe('bond-aware despawn', () => {
it('should dissolve partner bond when entity despawns', () => {
const world = new World();
const registry = createBondRegistry();
world.setSingleton('bondRegistry', registry);
const a = createNPC(world, 1, 1);
const b = createNPC(world, 2, 2);
addBond(registry, a, b, 'partner', 0);
const relsA = world.getComponent<Relationships>(a, 'relationships')!;
relsA.set(b, { value: 85, interactions: 10, lastInteractionTick: 0, status: 'active' });
// Remove entity b (simulate despawn)
world.removeEntity(b);
relationshipSystem(world);
expect(hasBond(registry, a, b, 'partner')).toBe(false);
expect(hasBond(registry, a, b, 'partner', 'former')).toBe(true);
});
});
});
+30
View File
@@ -5,6 +5,8 @@ import type {
import type { World } from '../ecs/World.js';
import { getEffectiveStat } from './statHelpers.js';
import { relationshipConfig as cfg } from '../config/relationshipConfig.js';
import type { BondRegistry } from './bondRegistry.js';
import { hasBond, dissolveBond } from './bondRegistry.js';
function computeIndividualDelta(
world: World,
@@ -43,6 +45,7 @@ function computeIndividualDelta(
export function relationshipSystem(world: World): void {
const entities = world.query('socialState', 'relationships', 'stats');
const processed = new Set<string>();
const registry = world.getSingleton<BondRegistry>('bondRegistry');
// Phase 1: Process completed interactions
for (const entity of entities) {
@@ -98,6 +101,28 @@ export function relationshipSystem(world: World): void {
partnerSocial.lastOutcome = null;
}
// Phase 1.5: Queue proposals for entities that crossed the threshold
if (registry) {
for (const entity of entities) {
const social = world.getComponent<SocialState>(entity, 'socialState')!;
if (social.pendingProposal) continue;
if (social.proposalCooldown > 0) {
social.proposalCooldown--;
continue;
}
const rels = world.getComponent<Relationships>(entity, 'relationships')!;
for (const [otherId, rel] of rels) {
if (rel.status !== 'active') continue;
if (rel.value < cfg.proposalThreshold) continue;
if (hasBond(registry, entity, otherId, 'partner')) continue;
if (social.pendingProposal) break;
social.pendingProposal = { targetId: otherId, type: 'partner' };
}
}
}
// Phase 2: Handle despawned entity relationships
for (const entity of entities) {
const rels = world.getComponent<Relationships>(entity, 'relationships')!;
@@ -107,6 +132,11 @@ export function relationshipSystem(world: World): void {
// Check if other entity still exists
if (world.getComponent(otherId, 'position') !== undefined) continue;
// Dissolve any active bonds with despawned entity
if (registry) {
dissolveBond(registry, entity, otherId, 'partner');
}
if (Math.abs(rel.value) >= cfg.memoryThreshold) {
// Strong relationship becomes memory
rel.status = 'memory';