diff --git a/server/src/systems/__tests__/superlativesComputer.test.ts b/server/src/systems/__tests__/superlativesComputer.test.ts new file mode 100644 index 0000000..a9c00c6 --- /dev/null +++ b/server/src/systems/__tests__/superlativesComputer.test.ts @@ -0,0 +1,308 @@ +import { describe, it, expect } from 'vitest'; +import { World } from '../../ecs/World.js'; +import { computeSuperlatives } from '../superlativesComputer.js'; +import { createBondRegistry, addBond, dissolveBond } from '../bondRegistry.js'; +import type { BondRegistry } from '../bondRegistry.js'; +import type { Relationships } from '@dflike/shared'; + +function setupNPC( + world: World, + name: string, + relationships: [number, number, 'active' | 'memory'][], +): number { + const entity = world.createEntity(); + world.addComponent(entity, 'name', name); + world.addComponent(entity, 'npcBrain', {}); + const rels: Relationships = new Map(); + for (const [targetId, value, status] of relationships) { + rels.set(targetId, { value, interactions: 5, lastInteractionTick: 0, status }); + } + world.addComponent(entity, 'relationships', rels); + return entity; +} + +describe('computeSuperlatives', () => { + it('returns all nulls when no NPCs exist', () => { + const world = new World(); + const registry = createBondRegistry(); + const result = computeSuperlatives(world, registry); + expect(result.mostLoved).toBeNull(); + expect(result.mostPopular).toBeNull(); + expect(result.mostReviled).toBeNull(); + expect(result.mostAnnoying).toBeNull(); + expect(result.shyest).toBeNull(); + expect(result.mostOutgoing).toBeNull(); + expect(result.biggestHeartbreaker).toBeNull(); + expect(result.mostDevoted).toBeNull(); + expect(result.loneliest).toBeNull(); + expect(result.mostPolarizing).toBeNull(); + expect(result.socialButterfly).toBeNull(); + }); + + it('computes mostLoved (highest single incoming value)', () => { + const world = new World(); + const registry = createBondRegistry(); + // A likes B at 70, A likes C at 90 + const a = setupNPC(world, 'Alice', []); + const b = setupNPC(world, 'Bob', []); + const c = setupNPC(world, 'Carol', []); + // Set Alice's relationships + world.getComponent(a, 'relationships')!.set(b, { value: 70, interactions: 5, lastInteractionTick: 0, status: 'active' }); + world.getComponent(a, 'relationships')!.set(c, { value: 90, interactions: 5, lastInteractionTick: 0, status: 'active' }); + + const result = computeSuperlatives(world, registry); + expect(result.mostLoved).not.toBeNull(); + expect(result.mostLoved!.entityId).toBe(c); + expect(result.mostLoved!.name).toBe('Carol'); + expect(result.mostLoved!.value).toBe(90); + }); + + it('computes mostReviled (lowest single incoming value)', () => { + const world = new World(); + const registry = createBondRegistry(); + const a = setupNPC(world, 'Alice', []); + const b = setupNPC(world, 'Bob', []); + const c = setupNPC(world, 'Carol', []); + world.getComponent(a, 'relationships')!.set(b, { value: -30, interactions: 5, lastInteractionTick: 0, status: 'active' }); + world.getComponent(a, 'relationships')!.set(c, { value: -80, interactions: 5, lastInteractionTick: 0, status: 'active' }); + + const result = computeSuperlatives(world, registry); + expect(result.mostReviled).not.toBeNull(); + expect(result.mostReviled!.entityId).toBe(c); + expect(result.mostReviled!.value).toBe(-80); + }); + + it('computes mostPopular (highest avg incoming, min 2 required)', () => { + const world = new World(); + const registry = createBondRegistry(); + const a = setupNPC(world, 'Alice', []); + const b = setupNPC(world, 'Bob', []); + const c = setupNPC(world, 'Carol', []); + // Both A and B like C + world.getComponent(a, 'relationships')!.set(c, { value: 60, interactions: 5, lastInteractionTick: 0, status: 'active' }); + world.getComponent(b, 'relationships')!.set(c, { value: 80, interactions: 5, lastInteractionTick: 0, status: 'active' }); + + const result = computeSuperlatives(world, registry); + expect(result.mostPopular).not.toBeNull(); + expect(result.mostPopular!.entityId).toBe(c); + expect(result.mostPopular!.value).toBe(70); // (60+80)/2 + }); + + it('returns mostPopular null with only 1 incoming', () => { + const world = new World(); + const registry = createBondRegistry(); + const a = setupNPC(world, 'Alice', []); + const b = setupNPC(world, 'Bob', []); + world.getComponent(a, 'relationships')!.set(b, { value: 90, interactions: 5, lastInteractionTick: 0, status: 'active' }); + + const result = computeSuperlatives(world, registry); + expect(result.mostPopular).toBeNull(); + }); + + it('computes mostAnnoying (lowest avg incoming, min 2)', () => { + const world = new World(); + const registry = createBondRegistry(); + const a = setupNPC(world, 'Alice', []); + const b = setupNPC(world, 'Bob', []); + const c = setupNPC(world, 'Carol', []); + world.getComponent(a, 'relationships')!.set(c, { value: -40, interactions: 5, lastInteractionTick: 0, status: 'active' }); + world.getComponent(b, 'relationships')!.set(c, { value: -60, interactions: 5, lastInteractionTick: 0, status: 'active' }); + + const result = computeSuperlatives(world, registry); + expect(result.mostAnnoying).not.toBeNull(); + expect(result.mostAnnoying!.entityId).toBe(c); + expect(result.mostAnnoying!.value).toBe(-50); // (-40+-60)/2 + }); + + it('computes shyest (fewest active outgoing)', () => { + const world = new World(); + const registry = createBondRegistry(); + const a = setupNPC(world, 'Alice', []); + const b = setupNPC(world, 'Bob', []); + const c = setupNPC(world, 'Carol', []); + // Alice: 1 active outgoing, Bob: 2, Carol: 0 + world.getComponent(a, 'relationships')!.set(b, { value: 10, interactions: 5, lastInteractionTick: 0, status: 'active' }); + world.getComponent(b, 'relationships')!.set(a, { value: 10, interactions: 5, lastInteractionTick: 0, status: 'active' }); + world.getComponent(b, 'relationships')!.set(c, { value: 10, interactions: 5, lastInteractionTick: 0, status: 'active' }); + + const result = computeSuperlatives(world, registry); + expect(result.shyest).not.toBeNull(); + expect(result.shyest!.entityId).toBe(c); + expect(result.shyest!.value).toBe(0); + }); + + it('computes mostOutgoing (most active outgoing)', () => { + const world = new World(); + const registry = createBondRegistry(); + const a = setupNPC(world, 'Alice', []); + const b = setupNPC(world, 'Bob', []); + const c = setupNPC(world, 'Carol', []); + world.getComponent(b, 'relationships')!.set(a, { value: 10, interactions: 5, lastInteractionTick: 0, status: 'active' }); + world.getComponent(b, 'relationships')!.set(c, { value: 20, interactions: 5, lastInteractionTick: 0, status: 'active' }); + world.getComponent(a, 'relationships')!.set(c, { value: 10, interactions: 5, lastInteractionTick: 0, status: 'active' }); + + const result = computeSuperlatives(world, registry); + expect(result.mostOutgoing).not.toBeNull(); + expect(result.mostOutgoing!.entityId).toBe(b); + expect(result.mostOutgoing!.name).toBe('Bob'); + expect(result.mostOutgoing!.value).toBe(2); + }); + + it('computes biggestHeartbreaker (most former partner bonds)', () => { + const world = new World(); + const registry = createBondRegistry(); + const a = setupNPC(world, 'Alice', []); + const b = setupNPC(world, 'Bob', []); + const c = setupNPC(world, 'Carol', []); + const d = setupNPC(world, 'Dave', []); + // Alice had former partners with B, C, and D + addBond(registry, a, b, 'partner', 10); + dissolveBond(registry, a, b, 'partner'); + addBond(registry, a, c, 'partner', 20); + dissolveBond(registry, a, c, 'partner'); + addBond(registry, a, d, 'partner', 30); + dissolveBond(registry, a, d, 'partner'); + // Bob had former partner with C + addBond(registry, b, c, 'partner', 40); + dissolveBond(registry, b, c, 'partner'); + + const result = computeSuperlatives(world, registry); + expect(result.biggestHeartbreaker).not.toBeNull(); + expect(result.biggestHeartbreaker!.entityId).toBe(a); + expect(result.biggestHeartbreaker!.value).toBe(3); + }); + + it('returns biggestHeartbreaker null when no former partners', () => { + const world = new World(); + const registry = createBondRegistry(); + setupNPC(world, 'Alice', []); + setupNPC(world, 'Bob', []); + + const result = computeSuperlatives(world, registry); + expect(result.biggestHeartbreaker).toBeNull(); + }); + + it('computes loneliest (most memory-status relationships)', () => { + const world = new World(); + const registry = createBondRegistry(); + const a = setupNPC(world, 'Alice', []); + const b = setupNPC(world, 'Bob', []); + const c = setupNPC(world, 'Carol', []); + // Alice has 2 memory rels, Bob has 1 + world.getComponent(a, 'relationships')!.set(b, { value: 30, interactions: 5, lastInteractionTick: 0, status: 'memory' }); + world.getComponent(a, 'relationships')!.set(c, { value: 40, interactions: 5, lastInteractionTick: 0, status: 'memory' }); + world.getComponent(b, 'relationships')!.set(c, { value: 20, interactions: 5, lastInteractionTick: 0, status: 'memory' }); + + const result = computeSuperlatives(world, registry); + expect(result.loneliest).not.toBeNull(); + expect(result.loneliest!.entityId).toBe(a); + expect(result.loneliest!.value).toBe(2); + }); + + it('returns loneliest null when no memory relationships', () => { + const world = new World(); + const registry = createBondRegistry(); + const a = setupNPC(world, 'Alice', []); + const b = setupNPC(world, 'Bob', []); + world.getComponent(a, 'relationships')!.set(b, { value: 30, interactions: 5, lastInteractionTick: 0, status: 'active' }); + + const result = computeSuperlatives(world, registry); + expect(result.loneliest).toBeNull(); + }); + + it('computes mostDevoted (highest mutual average)', () => { + const world = new World(); + const registry = createBondRegistry(); + const a = setupNPC(world, 'Alice', []); + const b = setupNPC(world, 'Bob', []); + const c = setupNPC(world, 'Carol', []); + // A<->B mutual: 80 and 60, avg 70 + world.getComponent(a, 'relationships')!.set(b, { value: 80, interactions: 5, lastInteractionTick: 0, status: 'active' }); + world.getComponent(b, 'relationships')!.set(a, { value: 60, interactions: 5, lastInteractionTick: 0, status: 'active' }); + // A<->C mutual: 90 and 95, avg 92.5 + world.getComponent(a, 'relationships')!.set(c, { value: 90, interactions: 5, lastInteractionTick: 0, status: 'active' }); + world.getComponent(c, 'relationships')!.set(a, { value: 95, interactions: 5, lastInteractionTick: 0, status: 'active' }); + + const result = computeSuperlatives(world, registry); + expect(result.mostDevoted).not.toBeNull(); + // The winner should be the NPC with highest mutual avg - that's either A or C from the A<->C pair + // A has mutual with C at avg 92.5, C has mutual with A at avg 92.5 + // Both A and C qualify; implementation picks whichever comes first + expect(result.mostDevoted!.value).toBe(92.5); + }); + + it('computes mostPolarizing (highest std dev, min 3 incoming)', () => { + const world = new World(); + const registry = createBondRegistry(); + const a = setupNPC(world, 'Alice', []); + const b = setupNPC(world, 'Bob', []); + const c = setupNPC(world, 'Carol', []); + const d = setupNPC(world, 'Dave', []); + // 3 NPCs have opinions on Alice: 80, -80, 0 -> std dev = sqrt((80^2+80^2+0^2)/3) = sqrt(4266.67) ≈ 65.3 + // Wait, std dev: mean = 0, variance = (80^2 + 80^2 + 0^2)/3 = 12800/3, sd = sqrt(4266.67) ≈ 65.3 + world.getComponent(b, 'relationships')!.set(a, { value: 80, interactions: 5, lastInteractionTick: 0, status: 'active' }); + world.getComponent(c, 'relationships')!.set(a, { value: -80, interactions: 5, lastInteractionTick: 0, status: 'active' }); + world.getComponent(d, 'relationships')!.set(a, { value: 0, interactions: 5, lastInteractionTick: 0, status: 'active' }); + + const result = computeSuperlatives(world, registry); + expect(result.mostPolarizing).not.toBeNull(); + expect(result.mostPolarizing!.entityId).toBe(a); + expect(result.mostPolarizing!.value).toBeCloseTo(65.3, 0); + }); + + it('returns mostPolarizing null with fewer than 3 incoming', () => { + const world = new World(); + const registry = createBondRegistry(); + const a = setupNPC(world, 'Alice', []); + const b = setupNPC(world, 'Bob', []); + const c = setupNPC(world, 'Carol', []); + world.getComponent(b, 'relationships')!.set(a, { value: 80, interactions: 5, lastInteractionTick: 0, status: 'active' }); + world.getComponent(c, 'relationships')!.set(a, { value: -80, interactions: 5, lastInteractionTick: 0, status: 'active' }); + + const result = computeSuperlatives(world, registry); + expect(result.mostPolarizing).toBeNull(); + }); + + it('computes socialButterfly (most partners + close friends, value >= 50)', () => { + const world = new World(); + const registry = createBondRegistry(); + const a = setupNPC(world, 'Alice', []); + const b = setupNPC(world, 'Bob', []); + const c = setupNPC(world, 'Carol', []); + const d = setupNPC(world, 'Dave', []); + // Alice: 3 relationships >= 50 (Devoted or Close Friend) + world.getComponent(a, 'relationships')!.set(b, { value: 85, interactions: 5, lastInteractionTick: 0, status: 'active' }); + world.getComponent(a, 'relationships')!.set(c, { value: 55, interactions: 5, lastInteractionTick: 0, status: 'active' }); + world.getComponent(a, 'relationships')!.set(d, { value: 60, interactions: 5, lastInteractionTick: 0, status: 'active' }); + // Bob: 1 relationship >= 50 + world.getComponent(b, 'relationships')!.set(a, { value: 50, interactions: 5, lastInteractionTick: 0, status: 'active' }); + + const result = computeSuperlatives(world, registry); + expect(result.socialButterfly).not.toBeNull(); + expect(result.socialButterfly!.entityId).toBe(a); + expect(result.socialButterfly!.value).toBe(3); + }); + + it('returns socialButterfly null when nobody has value >= 50', () => { + const world = new World(); + const registry = createBondRegistry(); + const a = setupNPC(world, 'Alice', []); + const b = setupNPC(world, 'Bob', []); + world.getComponent(a, 'relationships')!.set(b, { value: 40, interactions: 5, lastInteractionTick: 0, status: 'active' }); + + const result = computeSuperlatives(world, registry); + expect(result.socialButterfly).toBeNull(); + }); + + it('only counts active relationships for incoming values', () => { + const world = new World(); + const registry = createBondRegistry(); + const a = setupNPC(world, 'Alice', []); + const b = setupNPC(world, 'Bob', []); + // Memory relationships should not count as incoming for mostLoved + world.getComponent(a, 'relationships')!.set(b, { value: 90, interactions: 5, lastInteractionTick: 0, status: 'memory' }); + + const result = computeSuperlatives(world, registry); + expect(result.mostLoved).toBeNull(); + }); +}); diff --git a/server/src/systems/superlativesComputer.ts b/server/src/systems/superlativesComputer.ts new file mode 100644 index 0000000..75171c6 --- /dev/null +++ b/server/src/systems/superlativesComputer.ts @@ -0,0 +1,238 @@ +import type { EntityId, Relationships, SuperlativesData, SuperlativeEntry } from '@dflike/shared'; +import { World } from '../ecs/World.js'; +import type { BondRegistry } from './bondRegistry.js'; +import { getBonds } from './bondRegistry.js'; +import { classify } from './relationshipHelpers.js'; + +export function computeSuperlatives(world: World, registry: BondRegistry): SuperlativesData { + const npcs = world.query('npcBrain'); + + // Per-NPC aggregated data + const incomingValues = new Map(); // incoming active relationship values + const activeOutgoingCount = new Map(); // count of active outgoing + const memoryCount = new Map(); // count of memory-status outgoing + const socialButterflyCount = new Map(); // count of outgoing active with value >= 50 + + // Initialize all NPCs + for (const id of npcs) { + incomingValues.set(id, []); + activeOutgoingCount.set(id, 0); + memoryCount.set(id, 0); + socialButterflyCount.set(id, 0); + } + + const npcSet = new Set(npcs); + + // Single pass: gather data + for (const id of npcs) { + const rels = world.getComponent(id, 'relationships'); + if (!rels) continue; + + let activeOut = 0; + let memOut = 0; + let butterflyCount = 0; + + for (const [targetId, rel] of rels) { + if (!npcSet.has(targetId)) continue; + + if (rel.status === 'active') { + activeOut++; + // This is an incoming value for the target + incomingValues.get(targetId)?.push(rel.value); + // Social butterfly: active outgoing with value >= 50 + if (rel.value >= 50) { + butterflyCount++; + } + } else if (rel.status === 'memory') { + memOut++; + } + } + + activeOutgoingCount.set(id, activeOut); + memoryCount.set(id, memOut); + socialButterflyCount.set(id, butterflyCount); + } + + // Helper to get NPC name + const getName = (id: EntityId): string => world.getComponent(id, 'name') ?? `NPC ${id}`; + + // Helper to make an entry + const makeEntry = (id: EntityId, value: number): SuperlativeEntry => ({ + entityId: id, + name: getName(id), + value: Math.round(value * 10) / 10, + }); + + // --- Reduce per category --- + + // Most Loved: highest single incoming value + let mostLoved: SuperlativeEntry | null = null; + let mostLovedVal = -Infinity; + + // Most Reviled: lowest single incoming value + let mostReviled: SuperlativeEntry | null = null; + let mostReviledVal = Infinity; + + // Most Popular: highest avg incoming (min 2) + let mostPopular: SuperlativeEntry | null = null; + let mostPopularVal = -Infinity; + + // Most Annoying: lowest avg incoming (min 2) + let mostAnnoying: SuperlativeEntry | null = null; + let mostAnnoyingVal = Infinity; + + // Most Polarizing: highest std dev incoming (min 3) + let mostPolarizing: SuperlativeEntry | null = null; + let mostPolarizingVal = -Infinity; + + for (const id of npcs) { + const incoming = incomingValues.get(id)!; + + // Most Loved / Most Reviled + for (const v of incoming) { + if (v > mostLovedVal) { + mostLovedVal = v; + mostLoved = makeEntry(id, v); + } + if (v < mostReviledVal) { + mostReviledVal = v; + mostReviled = makeEntry(id, v); + } + } + + // Most Popular / Most Annoying (min 2 incoming) + if (incoming.length >= 2) { + const avg = incoming.reduce((a, b) => a + b, 0) / incoming.length; + if (avg > mostPopularVal) { + mostPopularVal = avg; + mostPopular = makeEntry(id, avg); + } + if (avg < mostAnnoyingVal) { + mostAnnoyingVal = avg; + mostAnnoying = makeEntry(id, avg); + } + } + + // Most Polarizing (min 3 incoming) + if (incoming.length >= 3) { + const mean = incoming.reduce((a, b) => a + b, 0) / incoming.length; + const variance = incoming.reduce((sum, v) => sum + (v - mean) ** 2, 0) / incoming.length; + const stdDev = Math.sqrt(variance); + if (stdDev > mostPolarizingVal) { + mostPolarizingVal = stdDev; + mostPolarizing = makeEntry(id, stdDev); + } + } + } + + // Shyest: fewest active outgoing + let shyest: SuperlativeEntry | null = null; + let shyestVal = Infinity; + + // Most Outgoing: most active outgoing + let mostOutgoing: SuperlativeEntry | null = null; + let mostOutgoingVal = -Infinity; + + for (const id of npcs) { + const count = activeOutgoingCount.get(id)!; + if (count < shyestVal) { + shyestVal = count; + shyest = makeEntry(id, count); + } + if (count > mostOutgoingVal) { + mostOutgoingVal = count; + mostOutgoing = makeEntry(id, count); + } + } + + // Biggest Heartbreaker: most former partner bonds (must be > 0) + let biggestHeartbreaker: SuperlativeEntry | null = null; + let heartbreakerVal = 0; + + for (const id of npcs) { + let formerCount = 0; + for (const [key, bonds] of registry) { + const [a, b] = key.split(':').map(Number); + if (a !== id && b !== id) continue; + formerCount += bonds.filter(bond => bond.type === 'partner' && bond.status === 'former').length; + } + if (formerCount > heartbreakerVal) { + heartbreakerVal = formerCount; + biggestHeartbreaker = makeEntry(id, formerCount); + } + } + if (heartbreakerVal === 0) biggestHeartbreaker = null; + + // Most Devoted: highest mutual relationship average (both directions active) + let mostDevoted: SuperlativeEntry | null = null; + let mostDevotedVal = -Infinity; + const processedPairs = new Set(); + + for (const id of npcs) { + const rels = world.getComponent(id, 'relationships'); + if (!rels) continue; + for (const [targetId, rel] of rels) { + if (!npcSet.has(targetId)) continue; + if (rel.status !== 'active') continue; + + const pairKey = id < targetId ? `${id}:${targetId}` : `${targetId}:${id}`; + if (processedPairs.has(pairKey)) continue; + + const reverseRels = world.getComponent(targetId, 'relationships'); + const reverseRel = reverseRels?.get(id); + if (!reverseRel || reverseRel.status !== 'active') continue; + + processedPairs.add(pairKey); + const avg = (rel.value + reverseRel.value) / 2; + + // Award to both NPCs - pick the one with higher individual contribution, + // or just pick the first one in the pair + if (avg > mostDevotedVal) { + mostDevotedVal = avg; + // Pick the entity that appears first (the one iterating) + mostDevoted = makeEntry(id, avg); + } + } + } + if (mostDevotedVal === -Infinity) mostDevoted = null; + + // Loneliest: most memory-status relationships (must be > 0) + let loneliest: SuperlativeEntry | null = null; + let loneliestVal = 0; + + for (const id of npcs) { + const count = memoryCount.get(id)!; + if (count > loneliestVal) { + loneliestVal = count; + loneliest = makeEntry(id, count); + } + } + if (loneliestVal === 0) loneliest = null; + + // Social Butterfly: most partners + close friends (value >= 50, must be > 0) + let socialButterfly: SuperlativeEntry | null = null; + let socialButterflyVal = 0; + + for (const id of npcs) { + const count = socialButterflyCount.get(id)!; + if (count > socialButterflyVal) { + socialButterflyVal = count; + socialButterfly = makeEntry(id, count); + } + } + if (socialButterflyVal === 0) socialButterfly = null; + + return { + mostLoved, + mostPopular, + mostReviled, + mostAnnoying, + shyest, + mostOutgoing, + biggestHeartbreaker, + mostDevoted, + loneliest, + mostPolarizing, + socialButterfly, + }; +}