From 66596fb96d89288b76ce3f723ce7dd23d3f67ca0 Mon Sep 17 00:00:00 2001 From: root Date: Sat, 7 Mar 2026 19:24:32 +0000 Subject: [PATCH] feat: add proposal queuing and bond-aware despawn to relationship system Co-Authored-By: Claude Opus 4.6 --- .../__tests__/relationshipSystem.test.ts | 109 ++++++++++++++++++ server/src/systems/relationshipSystem.ts | 30 +++++ 2 files changed, 139 insertions(+) diff --git a/server/src/systems/__tests__/relationshipSystem.test.ts b/server/src/systems/__tests__/relationshipSystem.test.ts index a021b1e..1b71d6b 100644 --- a/server/src/systems/__tests__/relationshipSystem.test.ts +++ b/server/src/systems/__tests__/relationshipSystem.test.ts @@ -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(a, 'relationships')!; + relsA.set(b, { value: 78, interactions: 10, lastInteractionTick: 0, status: 'active' }); + const relsB = world.getComponent(b, 'relationships')!; + relsB.set(a, { value: 78, interactions: 10, lastInteractionTick: 0, status: 'active' }); + + const socialA = world.getComponent(a, 'socialState')!; + socialA.lastOutcome = { partnerId: b, outcome: 'positive', tick: 0 }; + const socialB = world.getComponent(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(a, 'socialState')!.pendingProposal; + const propB = world.getComponent(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(a, 'relationships')!; + relsA.set(b, { value: 85, interactions: 10, lastInteractionTick: 0, status: 'active' }); + const relsB = world.getComponent(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(a, 'socialState')!; + socialA.lastOutcome = { partnerId: b, outcome: 'positive', tick: 0 }; + const socialB = world.getComponent(b, 'socialState')!; + socialB.lastOutcome = { partnerId: a, outcome: 'positive', tick: 0 }; + + relationshipSystem(world); + + expect(world.getComponent(a, 'socialState')!.pendingProposal).toBeNull(); + expect(world.getComponent(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(a, 'relationships')!; + relsA.set(b, { value: 85, interactions: 10, lastInteractionTick: 0, status: 'active' }); + + const socialA = world.getComponent(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(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(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); + }); + }); }); diff --git a/server/src/systems/relationshipSystem.ts b/server/src/systems/relationshipSystem.ts index c54bce1..c5fa14f 100644 --- a/server/src/systems/relationshipSystem.ts +++ b/server/src/systems/relationshipSystem.ts @@ -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(); + const registry = world.getSingleton('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(entity, 'socialState')!; + if (social.pendingProposal) continue; + if (social.proposalCooldown > 0) { + social.proposalCooldown--; + continue; + } + + const rels = world.getComponent(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(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';