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:
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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';
|
||||
|
||||
Reference in New Issue
Block a user