From 925f8a7494f4d9cdd02f96258ca169ad991e3446 Mon Sep 17 00:00:00 2001 From: root Date: Sat, 7 Mar 2026 19:16:33 +0000 Subject: [PATCH] feat: add bond registry with helpers for mutual relationships Co-Authored-By: Claude Opus 4.6 --- .../systems/__tests__/bondRegistry.test.ts | 97 +++++++++++++++++++ server/src/systems/bondRegistry.ts | 59 +++++++++++ 2 files changed, 156 insertions(+) create mode 100644 server/src/systems/__tests__/bondRegistry.test.ts create mode 100644 server/src/systems/bondRegistry.ts diff --git a/server/src/systems/__tests__/bondRegistry.test.ts b/server/src/systems/__tests__/bondRegistry.test.ts new file mode 100644 index 0000000..2d757b8 --- /dev/null +++ b/server/src/systems/__tests__/bondRegistry.test.ts @@ -0,0 +1,97 @@ +import { describe, it, expect } from 'vitest'; +import { + makePairKey, createBondRegistry, addBond, hasBond, + getBonds, dissolveBond, getActiveBondCount, +} from '../bondRegistry.js'; + +describe('bondRegistry', () => { + describe('makePairKey', () => { + it('should order IDs canonically (lower first)', () => { + expect(makePairKey(5, 3)).toBe('3:5'); + expect(makePairKey(3, 5)).toBe('3:5'); + }); + + it('should handle equal IDs', () => { + expect(makePairKey(7, 7)).toBe('7:7'); + }); + }); + + describe('addBond', () => { + it('should add a bond between two entities', () => { + const registry = createBondRegistry(); + addBond(registry, 1, 2, 'partner', 100); + expect(hasBond(registry, 1, 2, 'partner')).toBe(true); + expect(hasBond(registry, 2, 1, 'partner')).toBe(true); + }); + + it('should not duplicate bond types for same pair', () => { + const registry = createBondRegistry(); + addBond(registry, 1, 2, 'partner', 100); + addBond(registry, 1, 2, 'partner', 200); + const bonds = getBonds(registry, 1, 2); + expect(bonds.filter(b => b.type === 'partner')).toHaveLength(1); + }); + + it('should allow multiple bond types for same pair', () => { + const registry = createBondRegistry(); + addBond(registry, 1, 2, 'partner', 100); + addBond(registry, 1, 2, 'sibling', 200); + const bonds = getBonds(registry, 1, 2); + expect(bonds).toHaveLength(2); + }); + }); + + describe('hasBond', () => { + it('should return false when no bonds exist', () => { + const registry = createBondRegistry(); + expect(hasBond(registry, 1, 2, 'partner')).toBe(false); + }); + + it('should check active status by default', () => { + const registry = createBondRegistry(); + addBond(registry, 1, 2, 'partner', 100); + dissolveBond(registry, 1, 2, 'partner'); + expect(hasBond(registry, 1, 2, 'partner')).toBe(false); + }); + + it('should find former bonds when specified', () => { + const registry = createBondRegistry(); + addBond(registry, 1, 2, 'partner', 100); + dissolveBond(registry, 1, 2, 'partner'); + expect(hasBond(registry, 1, 2, 'partner', 'former')).toBe(true); + }); + }); + + describe('dissolveBond', () => { + it('should mark bond as former', () => { + const registry = createBondRegistry(); + addBond(registry, 1, 2, 'partner', 100); + dissolveBond(registry, 1, 2, 'partner'); + const bonds = getBonds(registry, 1, 2); + expect(bonds[0].status).toBe('former'); + }); + + it('should be a no-op if bond does not exist', () => { + const registry = createBondRegistry(); + dissolveBond(registry, 1, 2, 'partner'); + expect(getBonds(registry, 1, 2)).toHaveLength(0); + }); + }); + + describe('getActiveBondCount', () => { + it('should count active bonds of a type for an entity', () => { + const registry = createBondRegistry(); + addBond(registry, 1, 2, 'partner', 100); + addBond(registry, 1, 3, 'partner', 200); + expect(getActiveBondCount(registry, 1, 'partner')).toBe(2); + expect(getActiveBondCount(registry, 2, 'partner')).toBe(1); + }); + + it('should not count former bonds', () => { + const registry = createBondRegistry(); + addBond(registry, 1, 2, 'partner', 100); + dissolveBond(registry, 1, 2, 'partner'); + expect(getActiveBondCount(registry, 1, 'partner')).toBe(0); + }); + }); +}); diff --git a/server/src/systems/bondRegistry.ts b/server/src/systems/bondRegistry.ts new file mode 100644 index 0000000..c88cf6e --- /dev/null +++ b/server/src/systems/bondRegistry.ts @@ -0,0 +1,59 @@ +import type { EntityId } from '@dflike/shared'; + +export interface Bond { + type: string; + formedAtTick: number; + status: 'active' | 'former'; +} + +export type BondRegistry = Map; + +export function createBondRegistry(): BondRegistry { + return new Map(); +} + +export function makePairKey(a: EntityId, b: EntityId): string { + return a < b ? `${a}:${b}` : `${b}:${a}`; +} + +export function addBond( + registry: BondRegistry, a: EntityId, b: EntityId, + type: string, tick: number, +): void { + const key = makePairKey(a, b); + if (!registry.has(key)) registry.set(key, []); + const bonds = registry.get(key)!; + if (bonds.some(b => b.type === type && b.status === 'active')) return; + bonds.push({ type, formedAtTick: tick, status: 'active' }); +} + +export function getBonds(registry: BondRegistry, a: EntityId, b: EntityId): Bond[] { + return registry.get(makePairKey(a, b)) ?? []; +} + +export function hasBond( + registry: BondRegistry, a: EntityId, b: EntityId, + type: string, status: 'active' | 'former' = 'active', +): boolean { + return getBonds(registry, a, b).some(b => b.type === type && b.status === status); +} + +export function dissolveBond( + registry: BondRegistry, a: EntityId, b: EntityId, type: string, +): void { + const bonds = getBonds(registry, a, b); + const bond = bonds.find(b => b.type === type && b.status === 'active'); + if (bond) bond.status = 'former'; +} + +export function getActiveBondCount( + registry: BondRegistry, entity: EntityId, type: string, +): number { + let count = 0; + for (const [key, bonds] of registry) { + const [a, b] = key.split(':').map(Number); + if (a !== entity && b !== entity) continue; + count += bonds.filter(bond => bond.type === type && bond.status === 'active').length; + } + return count; +}