feat: add superlatives computation with tests

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
root
2026-03-08 04:41:05 +00:00
parent 215eb66c23
commit e709082694
2 changed files with 546 additions and 0 deletions
@@ -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<Relationships>(a, 'relationships')!.set(b, { value: 70, interactions: 5, lastInteractionTick: 0, status: 'active' });
world.getComponent<Relationships>(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<Relationships>(a, 'relationships')!.set(b, { value: -30, interactions: 5, lastInteractionTick: 0, status: 'active' });
world.getComponent<Relationships>(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<Relationships>(a, 'relationships')!.set(c, { value: 60, interactions: 5, lastInteractionTick: 0, status: 'active' });
world.getComponent<Relationships>(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<Relationships>(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<Relationships>(a, 'relationships')!.set(c, { value: -40, interactions: 5, lastInteractionTick: 0, status: 'active' });
world.getComponent<Relationships>(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<Relationships>(a, 'relationships')!.set(b, { value: 10, interactions: 5, lastInteractionTick: 0, status: 'active' });
world.getComponent<Relationships>(b, 'relationships')!.set(a, { value: 10, interactions: 5, lastInteractionTick: 0, status: 'active' });
world.getComponent<Relationships>(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<Relationships>(b, 'relationships')!.set(a, { value: 10, interactions: 5, lastInteractionTick: 0, status: 'active' });
world.getComponent<Relationships>(b, 'relationships')!.set(c, { value: 20, interactions: 5, lastInteractionTick: 0, status: 'active' });
world.getComponent<Relationships>(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<Relationships>(a, 'relationships')!.set(b, { value: 30, interactions: 5, lastInteractionTick: 0, status: 'memory' });
world.getComponent<Relationships>(a, 'relationships')!.set(c, { value: 40, interactions: 5, lastInteractionTick: 0, status: 'memory' });
world.getComponent<Relationships>(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<Relationships>(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<Relationships>(a, 'relationships')!.set(b, { value: 80, interactions: 5, lastInteractionTick: 0, status: 'active' });
world.getComponent<Relationships>(b, 'relationships')!.set(a, { value: 60, interactions: 5, lastInteractionTick: 0, status: 'active' });
// A<->C mutual: 90 and 95, avg 92.5
world.getComponent<Relationships>(a, 'relationships')!.set(c, { value: 90, interactions: 5, lastInteractionTick: 0, status: 'active' });
world.getComponent<Relationships>(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<Relationships>(b, 'relationships')!.set(a, { value: 80, interactions: 5, lastInteractionTick: 0, status: 'active' });
world.getComponent<Relationships>(c, 'relationships')!.set(a, { value: -80, interactions: 5, lastInteractionTick: 0, status: 'active' });
world.getComponent<Relationships>(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<Relationships>(b, 'relationships')!.set(a, { value: 80, interactions: 5, lastInteractionTick: 0, status: 'active' });
world.getComponent<Relationships>(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<Relationships>(a, 'relationships')!.set(b, { value: 85, interactions: 5, lastInteractionTick: 0, status: 'active' });
world.getComponent<Relationships>(a, 'relationships')!.set(c, { value: 55, interactions: 5, lastInteractionTick: 0, status: 'active' });
world.getComponent<Relationships>(a, 'relationships')!.set(d, { value: 60, interactions: 5, lastInteractionTick: 0, status: 'active' });
// Bob: 1 relationship >= 50
world.getComponent<Relationships>(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<Relationships>(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<Relationships>(a, 'relationships')!.set(b, { value: 90, interactions: 5, lastInteractionTick: 0, status: 'memory' });
const result = computeSuperlatives(world, registry);
expect(result.mostLoved).toBeNull();
});
});
+238
View File
@@ -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<EntityId, number[]>(); // incoming active relationship values
const activeOutgoingCount = new Map<EntityId, number>(); // count of active outgoing
const memoryCount = new Map<EntityId, number>(); // count of memory-status outgoing
const socialButterflyCount = new Map<EntityId, number>(); // 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<Relationships>(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<string>(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<string>();
for (const id of npcs) {
const rels = world.getComponent<Relationships>(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<Relationships>(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,
};
}