Merge branch 'worktree-relationship-system'

This commit is contained in:
root
2026-03-07 15:41:22 +00:00
12 changed files with 866 additions and 12 deletions
+236 -10
View File
@@ -37,6 +37,11 @@ export class NpcInfoPanel {
private needsBars: Map<string, { wrapper: HTMLDivElement; fill: HTMLDivElement; valueEl: HTMLDivElement }> = new Map();
private statsContainer: HTMLDivElement;
private statElements: Map<string, HTMLDivElement> = new Map();
private statusTab!: HTMLDivElement;
private relationshipsTab!: HTMLDivElement;
private statusContent!: HTMLDivElement;
private relationshipsContent!: HTMLDivElement;
private activeTab: 'status' | 'relationships' = 'status';
private visible = false;
constructor() {
@@ -134,9 +139,51 @@ export class NpcInfoPanel {
portraitWell.appendChild(portraitFrame);
this.container.appendChild(portraitWell);
// Info section
const infoSection = document.createElement('div');
infoSection.style.cssText = `
// Tab bar
const tabBar = document.createElement('div');
tabBar.style.cssText = `
display: flex;
border-bottom: 2px solid ${EB.borderInner};
font-family: 'Press Start 2P', monospace;
`;
this.statusTab = document.createElement('div');
this.statusTab.textContent = 'Status';
this.statusTab.style.cssText = `
flex: 1;
text-align: center;
padding: 8px 0;
font-size: 9px;
cursor: pointer;
color: ${EB.textPrimary};
border-bottom: 2px solid ${EB.borderOuter};
user-select: none;
`;
this.relationshipsTab = document.createElement('div');
this.relationshipsTab.textContent = 'Relations';
this.relationshipsTab.style.cssText = `
flex: 1;
text-align: center;
padding: 8px 0;
font-size: 9px;
cursor: pointer;
color: ${EB.textMuted};
user-select: none;
`;
this.statusTab.addEventListener('click', () => this.switchTab('status'));
this.relationshipsTab.addEventListener('click', () => this.switchTab('relationships'));
tabBar.appendChild(this.statusTab);
tabBar.appendChild(this.relationshipsTab);
this.container.appendChild(tabBar);
// Content wrapper
const contentWrapper = document.createElement('div');
// Status content (existing info section)
this.statusContent = document.createElement('div');
this.statusContent.style.cssText = `
padding: 10px 12px 12px;
display: flex;
flex-direction: column;
@@ -156,7 +203,7 @@ export class NpcInfoPanel {
text-shadow: 1px 1px 0 rgba(0,0,0,0.5);
letter-spacing: 0.5px;
`;
infoSection.appendChild(this.nameEl);
this.statusContent.appendChild(this.nameEl);
// Activity
this.activityEl = document.createElement('div');
@@ -166,7 +213,7 @@ export class NpcInfoPanel {
text-align: center;
padding-bottom: 2px;
`;
infoSection.appendChild(this.activityEl);
this.statusContent.appendChild(this.activityEl);
// Decorative separator (EarthBound diamond pattern)
this.separatorEl = document.createElement('div');
@@ -179,7 +226,7 @@ export class NpcInfoPanel {
user-select: none;
`;
this.separatorEl.textContent = '\u25C6\u25C6\u25C6';
infoSection.appendChild(this.separatorEl);
this.statusContent.appendChild(this.separatorEl);
// Needs bars container
this.needsBarsContainer = document.createElement('div');
@@ -188,7 +235,7 @@ export class NpcInfoPanel {
flex-direction: column;
gap: 6px;
`;
infoSection.appendChild(this.needsBarsContainer);
this.statusContent.appendChild(this.needsBarsContainer);
// Stats separator
const statsSeparator = document.createElement('div');
@@ -201,7 +248,7 @@ export class NpcInfoPanel {
user-select: none;
`;
statsSeparator.textContent = '\u25C6\u25C6\u25C6';
infoSection.appendChild(statsSeparator);
this.statusContent.appendChild(statsSeparator);
// Stats container - two columns
this.statsContainer = document.createElement('div');
@@ -211,9 +258,21 @@ export class NpcInfoPanel {
gap: 3px 8px;
font-family: 'Press Start 2P', monospace;
`;
infoSection.appendChild(this.statsContainer);
this.statusContent.appendChild(this.statsContainer);
this.container.appendChild(infoSection);
contentWrapper.appendChild(this.statusContent);
this.relationshipsContent = document.createElement('div');
this.relationshipsContent.style.cssText = `
display: none;
padding: 8px 12px;
font-family: 'Press Start 2P', monospace;
max-height: 300px;
overflow-y: auto;
`;
contentWrapper.appendChild(this.relationshipsContent);
this.container.appendChild(contentWrapper);
this.outerFrame.appendChild(this.container);
document.body.appendChild(this.outerFrame);
}
@@ -248,6 +307,10 @@ export class NpcInfoPanel {
if (entity.stats) {
this.updateStats(entity.stats);
}
if (entity.relationships) {
this.updateRelationships(entity.relationships);
}
}
updateNeeds(needs: { hunger: number; energy: number }): void {
@@ -390,6 +453,169 @@ export class NpcInfoPanel {
valueEl.textContent = String(value);
}
private switchTab(tab: 'status' | 'relationships'): void {
this.activeTab = tab;
if (tab === 'status') {
this.statusContent.style.display = '';
this.relationshipsContent.style.display = 'none';
this.statusTab.style.color = EB.textPrimary;
this.statusTab.style.borderBottom = `2px solid ${EB.borderOuter}`;
this.relationshipsTab.style.color = EB.textMuted;
this.relationshipsTab.style.borderBottom = 'none';
} else {
this.statusContent.style.display = 'none';
this.relationshipsContent.style.display = '';
this.relationshipsTab.style.color = EB.textPrimary;
this.relationshipsTab.style.borderBottom = `2px solid ${EB.borderOuter}`;
this.statusTab.style.color = EB.textMuted;
this.statusTab.style.borderBottom = 'none';
}
}
updateRelationships(relationships: NonNullable<EntityState['relationships']>): void {
if (relationships.length === 0) {
this.relationshipsContent.innerHTML = '';
const empty = document.createElement('div');
empty.style.cssText = `text-align: center; color: ${EB.textMuted}; font-size: 9px; padding: 16px 0;`;
empty.textContent = 'No relationships yet';
this.relationshipsContent.appendChild(empty);
return;
}
const tierOrder = ['Partner', 'Close Friend', 'Friend', 'Acquaintance', 'Stranger', 'Wary', 'Rival', 'Enemy', 'Nemesis'];
const groups = new Map<string, typeof relationships>();
const memories: typeof relationships = [];
for (const rel of relationships) {
if (rel.status === 'memory') {
memories.push(rel);
continue;
}
const group = groups.get(rel.classification) ?? [];
group.push(rel);
groups.set(rel.classification, group);
}
this.relationshipsContent.innerHTML = '';
const tierIcons: Record<string, string> = {
'Partner': '\u2665', 'Close Friend': '\u2605', 'Friend': '\u25C6',
'Acquaintance': '\u25CB', 'Stranger': '\u00B7',
'Wary': '\u25B2', 'Rival': '\u2716', 'Enemy': '\u2620', 'Nemesis': '\u2620',
};
for (const tier of tierOrder) {
const group = groups.get(tier);
if (!group || group.length === 0) continue;
group.sort((a, b) => Math.abs(b.value) - Math.abs(a.value));
const showCount = 2;
const shown = group.slice(0, showCount);
const remaining = group.length - showCount;
const tierEl = document.createElement('div');
tierEl.style.cssText = 'margin-bottom: 8px;';
const tierLabel = document.createElement('div');
const icon = tierIcons[tier] ?? '';
tierLabel.style.cssText = `font-size: 8px; color: ${EB.textSecondary}; margin-bottom: 4px;`;
tierLabel.textContent = `${icon} ${tier}${remaining > 0 ? ` (${remaining} more...)` : ''}`;
tierEl.appendChild(tierLabel);
for (const rel of shown) {
tierEl.appendChild(this.createRelSlider(rel.name, rel.value, tier));
}
this.relationshipsContent.appendChild(tierEl);
}
if (memories.length > 0) {
const memEl = document.createElement('div');
memEl.style.cssText = 'margin-bottom: 8px;';
const memLabel = document.createElement('div');
memLabel.style.cssText = `font-size: 8px; color: ${EB.textMuted}; margin-bottom: 4px;`;
memLabel.textContent = '\u25CB Memories';
memEl.appendChild(memLabel);
for (const rel of memories.slice(0, 2)) {
memEl.appendChild(this.createRelSlider(rel.name + ' \u2020', rel.value, 'memory'));
}
this.relationshipsContent.appendChild(memEl);
}
}
private createRelSlider(name: string, value: number, tier: string): HTMLDivElement {
const row = document.createElement('div');
row.style.cssText = `
display: flex;
align-items: center;
gap: 4px;
margin-bottom: 3px;
font-size: 8px;
`;
const nameEl = document.createElement('span');
nameEl.style.cssText = `color: ${EB.textPrimary}; min-width: 70px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;`;
nameEl.textContent = name;
const slider = document.createElement('div');
slider.style.cssText = `
flex: 1;
height: 6px;
background: ${EB.bgBar};
border-radius: 3px;
position: relative;
border: 1px solid rgba(255,255,255,0.1);
`;
const center = document.createElement('div');
center.style.cssText = `
position: absolute;
left: 50%;
top: 0;
bottom: 0;
width: 1px;
background: ${EB.textMuted};
`;
slider.appendChild(center);
const marker = document.createElement('div');
const pct = ((value + 100) / 200) * 100;
const color = this.getRelColor(tier);
marker.style.cssText = `
position: absolute;
left: ${pct}%;
top: -1px;
width: 5px;
height: 8px;
background: ${color};
border-radius: 2px;
transform: translateX(-50%);
box-shadow: 0 0 3px ${color};
`;
slider.appendChild(marker);
row.appendChild(nameEl);
row.appendChild(slider);
return row;
}
private getRelColor(tier: string): string {
switch (tier) {
case 'Partner': return '#ff69b4';
case 'Close Friend': return '#58d858';
case 'Friend': return '#40b840';
case 'Acquaintance': return '#6868a8';
case 'Stranger': return '#6868a8';
case 'Wary': return '#d8d858';
case 'Rival': return '#d89858';
case 'Enemy': return '#d85858';
case 'Nemesis': return '#ff2020';
case 'memory': return '#6868a8';
default: return '#6868a8';
}
}
isVisible(): boolean {
return this.visible;
}
+40
View File
@@ -0,0 +1,40 @@
export const relationshipConfig = {
// Delta calculation
baseDeltaPositive: 5,
baseDeltaNegative: 6,
diminishingFactor: 0.05,
sharedWeight: 0.3,
individualWeight: 0.7,
// Stat influence weights
empathyWeight: 0.03,
temperamentWeight: 0.03,
sociabilityBonus: 0.2,
curiosityRadiusWeight: 0.5,
courageAvoidanceThreshold: 8,
// Classification thresholds (ordered high to low)
tiers: [
{ min: 80, label: 'Partner' },
{ min: 50, label: 'Close Friend' },
{ min: 20, label: 'Friend' },
{ min: 5, label: 'Acquaintance' },
{ min: -4, label: 'Stranger' },
{ min: -19, label: 'Wary' },
{ min: -49, label: 'Rival' },
{ min: -79, label: 'Enemy' },
{ min: -100, label: 'Nemesis' },
] as const,
// Caps
defaultPartnerCap: 1,
polyPartnerCap: 2,
polySociabilityThreshold: 15,
polyEmpathyThreshold: 14,
baseFriendCap: 5,
friendCapPerSociability: 0.5,
// Despawn behavior
memoryThreshold: 20,
fadeRatePerTick: 0.01,
};
+2
View File
@@ -6,6 +6,7 @@ import { npcBrainSystem } from '../systems/npcBrainSystem.js';
import { movementSystem } from '../systems/movementSystem.js';
import { socialSystem } from '../systems/socialSystem.js';
import { statModifierSystem } from '../systems/statModifierSystem.js';
import { relationshipSystem } from '../systems/relationshipSystem.js';
import { spawnNPC } from './spawner.js';
export class GameLoop {
@@ -66,6 +67,7 @@ export class GameLoop {
needsDecaySystem(this.world);
npcBrainSystem(this.world, this.map);
socialSystem(this.world);
relationshipSystem(this.world);
movementSystem(this.world);
// Broadcast state periodically
+3 -1
View File
@@ -3,7 +3,7 @@ import type { GameMap } from '../map/GameMap.js';
import { generateRandomAppearance } from '../spawner/appearanceGenerator.js';
import { generateName } from '../spawner/nameGenerator.js';
import { generateStats } from '../spawner/statGenerator.js';
import type { EntityId, Position, Needs, Movement, NPCBrain, Appearance, SocialState, Stats, StatModifiers } from '@dflike/shared';
import type { EntityId, Position, Needs, Movement, NPCBrain, Appearance, SocialState, Stats, StatModifiers, Relationships } from '@dflike/shared';
export function spawnNPC(world: World, map: GameMap, positionHint?: Position): EntityId {
const entity = world.createEntity();
@@ -36,9 +36,11 @@ export function spawnNPC(world: World, map: GameMap, positionHint?: Position): E
outcome: null,
globalCooldown: 0,
pairCooldowns: new Map(),
lastOutcome: null,
});
world.addComponent<Stats>(entity, 'stats', generateStats());
world.addComponent<StatModifiers>(entity, 'statModifiers', { modifiers: [] });
world.addComponent<Relationships>(entity, 'relationships', new Map());
return entity;
}
+13 -1
View File
@@ -1,11 +1,12 @@
import type {
EntityState, WorldState, StateUpdate,
Position, Movement, Appearance, Needs, NPCBrain, PlayerControlled, SocialState, Stats,
Position, Movement, Appearance, Needs, NPCBrain, PlayerControlled, SocialState, Stats, Relationships,
} from '@dflike/shared';
import { TILE_SIZE } from '@dflike/shared';
import type { World } from '../ecs/World.js';
import type { GameMap } from '../map/GameMap.js';
import { getEffectiveStat } from '../systems/statHelpers.js';
import { classify } from '../systems/relationshipHelpers.js';
export function serializeEntity(world: World, entityId: number): EntityState {
const socialState = world.getComponent<SocialState>(entityId, 'socialState');
@@ -22,6 +23,16 @@ export function serializeEntity(world: World, entityId: number): EntityState {
empathy: getEffectiveStat(world, entityId, 'empathy'),
temperament: getEffectiveStat(world, entityId, 'temperament'),
} : undefined;
const relationshipsComponent = world.getComponent<Relationships>(entityId, 'relationships');
const relationships = relationshipsComponent && relationshipsComponent.size > 0
? [...relationshipsComponent.entries()].map(([otherId, rel]) => ({
entityId: otherId,
name: world.getComponent<string>(otherId, 'name') ?? `NPC #${otherId}`,
value: Math.round(rel.value * 10) / 10,
classification: classify(rel.value),
status: rel.status,
}))
: undefined;
return {
id: entityId,
position: world.getComponent<Position>(entityId, 'position')!,
@@ -37,6 +48,7 @@ export function serializeEntity(world: World, entityId: number): EntityState {
outcome: socialState.outcome,
} : undefined,
stats,
relationships,
};
}
@@ -0,0 +1,84 @@
import { describe, it, expect } from 'vitest';
import { classify, getPartnerCap, getFriendCap } from '../relationshipHelpers.js';
describe('relationshipHelpers', () => {
describe('classify', () => {
it('returns Partner for value >= 80', () => {
expect(classify(80)).toBe('Partner');
expect(classify(100)).toBe('Partner');
});
it('returns Close Friend for value 50-79', () => {
expect(classify(50)).toBe('Close Friend');
expect(classify(79)).toBe('Close Friend');
});
it('returns Friend for value 20-49', () => {
expect(classify(20)).toBe('Friend');
expect(classify(49)).toBe('Friend');
});
it('returns Acquaintance for value 5-19', () => {
expect(classify(5)).toBe('Acquaintance');
expect(classify(19)).toBe('Acquaintance');
});
it('returns Stranger for value -4 to 4', () => {
expect(classify(0)).toBe('Stranger');
expect(classify(4)).toBe('Stranger');
expect(classify(-4)).toBe('Stranger');
});
it('returns Wary for value -19 to -5', () => {
expect(classify(-5)).toBe('Wary');
expect(classify(-19)).toBe('Wary');
});
it('returns Rival for value -49 to -20', () => {
expect(classify(-20)).toBe('Rival');
expect(classify(-49)).toBe('Rival');
});
it('returns Enemy for value -79 to -50', () => {
expect(classify(-50)).toBe('Enemy');
expect(classify(-79)).toBe('Enemy');
});
it('returns Nemesis for value <= -80', () => {
expect(classify(-80)).toBe('Nemesis');
expect(classify(-100)).toBe('Nemesis');
});
});
describe('getPartnerCap', () => {
it('returns 1 for average stats', () => {
expect(getPartnerCap(10, 10)).toBe(1);
});
it('returns 2 for high sociability and empathy', () => {
expect(getPartnerCap(15, 14)).toBe(2);
});
it('returns 1 if only sociability is high', () => {
expect(getPartnerCap(15, 10)).toBe(1);
});
it('returns 1 if only empathy is high', () => {
expect(getPartnerCap(10, 14)).toBe(1);
});
});
describe('getFriendCap', () => {
it('returns baseCap for sociability 10', () => {
expect(getFriendCap(10)).toBe(5);
});
it('increases with high sociability', () => {
expect(getFriendCap(16)).toBe(8);
});
it('decreases with low sociability', () => {
expect(getFriendCap(6)).toBe(3);
});
});
});
@@ -0,0 +1,239 @@
import { describe, it, expect } from 'vitest';
import { World } from '../../ecs/World.js';
import { relationshipSystem } from '../relationshipSystem.js';
import type {
Position, Needs, Movement, NPCBrain, SocialState, Stats,
StatModifiers, Relationships, RelationshipData, EntityId,
} from '@dflike/shared';
function createNPC(
world: World, x: number, y: number,
opts?: { stats?: Partial<Stats> },
): EntityId {
const e = world.createEntity();
world.addComponent<Position>(e, 'position', { x, y });
world.addComponent<Needs>(e, 'needs', { hunger: 80, energy: 80 });
world.addComponent<Movement>(e, 'movement', {
state: 'idle', target: null, path: [], direction: 0, moveProgress: 0,
});
world.addComponent<NPCBrain>(e, 'npcBrain', { currentGoal: 'wander', goalQueue: [] });
world.addComponent<SocialState>(e, 'socialState', {
phase: 'none', partnerId: null, phaseTimer: 0, outcome: null,
globalCooldown: 0, pairCooldowns: new Map(), lastOutcome: null,
});
const baseStats: Stats = {
strength: 10, dexterity: 10, constitution: 10, intelligence: 10, perception: 10,
sociability: 10, courage: 10, curiosity: 10, empathy: 10, temperament: 10,
...(opts?.stats ?? {}),
};
world.addComponent<Stats>(e, 'stats', baseStats);
world.addComponent<StatModifiers>(e, 'statModifiers', { modifiers: [] });
world.addComponent<Relationships>(e, 'relationships', new Map());
return e;
}
describe('relationshipSystem', () => {
describe('processing lastOutcome', () => {
it('creates relationship entries on first interaction', () => {
const world = new World();
const a = createNPC(world, 5, 5);
const b = createNPC(world, 8, 5);
const sa = world.getComponent<SocialState>(a, 'socialState')!;
const sb = world.getComponent<SocialState>(b, 'socialState')!;
sa.lastOutcome = { partnerId: b, outcome: 'positive', tick: 1 };
sb.lastOutcome = { partnerId: a, outcome: 'positive', tick: 1 };
relationshipSystem(world);
const relsA = world.getComponent<Relationships>(a, 'relationships')!;
const relsB = world.getComponent<Relationships>(b, 'relationships')!;
expect(relsA.has(b)).toBe(true);
expect(relsB.has(a)).toBe(true);
expect(relsA.get(b)!.value).toBeGreaterThan(0);
expect(relsB.get(a)!.value).toBeGreaterThan(0);
});
it('clears lastOutcome after processing', () => {
const world = new World();
const a = createNPC(world, 5, 5);
const b = createNPC(world, 8, 5);
const sa = world.getComponent<SocialState>(a, 'socialState')!;
const sb = world.getComponent<SocialState>(b, 'socialState')!;
sa.lastOutcome = { partnerId: b, outcome: 'positive', tick: 1 };
sb.lastOutcome = { partnerId: a, outcome: 'positive', tick: 1 };
relationshipSystem(world);
expect(sa.lastOutcome).toBeNull();
expect(sb.lastOutcome).toBeNull();
});
it('negative outcome decreases relationship value', () => {
const world = new World();
const a = createNPC(world, 5, 5);
const b = createNPC(world, 8, 5);
const sa = world.getComponent<SocialState>(a, 'socialState')!;
const sb = world.getComponent<SocialState>(b, 'socialState')!;
sa.lastOutcome = { partnerId: b, outcome: 'negative', tick: 1 };
sb.lastOutcome = { partnerId: a, outcome: 'negative', tick: 1 };
relationshipSystem(world);
const relsA = world.getComponent<Relationships>(a, 'relationships')!;
expect(relsA.get(b)!.value).toBeLessThan(0);
});
it('increments interaction count', () => {
const world = new World();
const a = createNPC(world, 5, 5);
const b = createNPC(world, 8, 5);
const sa = world.getComponent<SocialState>(a, 'socialState')!;
const sb = world.getComponent<SocialState>(b, 'socialState')!;
sa.lastOutcome = { partnerId: b, outcome: 'positive', tick: 1 };
sb.lastOutcome = { partnerId: a, outcome: 'positive', tick: 1 };
relationshipSystem(world);
sa.lastOutcome = { partnerId: b, outcome: 'positive', tick: 50 };
sb.lastOutcome = { partnerId: a, outcome: 'positive', tick: 50 };
relationshipSystem(world);
const relsA = world.getComponent<Relationships>(a, 'relationships')!;
expect(relsA.get(b)!.interactions).toBe(2);
});
it('applies diminishing returns on repeated interactions', () => {
const world = new World();
const a = createNPC(world, 5, 5);
const b = createNPC(world, 8, 5);
const sa = world.getComponent<SocialState>(a, 'socialState')!;
const sb = world.getComponent<SocialState>(b, 'socialState')!;
sa.lastOutcome = { partnerId: b, outcome: 'positive', tick: 1 };
sb.lastOutcome = { partnerId: a, outcome: 'positive', tick: 1 };
relationshipSystem(world);
const relsA = world.getComponent<Relationships>(a, 'relationships')!;
const firstDelta = relsA.get(b)!.value;
sa.lastOutcome = { partnerId: b, outcome: 'positive', tick: 50 };
sb.lastOutcome = { partnerId: a, outcome: 'positive', tick: 50 };
relationshipSystem(world);
const secondDelta = relsA.get(b)!.value - firstDelta;
expect(secondDelta).toBeLessThan(firstDelta);
});
it('blends individual and shared outcomes asymmetrically', () => {
const world = new World();
const a = createNPC(world, 5, 5);
const b = createNPC(world, 8, 5);
const sa = world.getComponent<SocialState>(a, 'socialState')!;
const sb = world.getComponent<SocialState>(b, 'socialState')!;
sa.lastOutcome = { partnerId: b, outcome: 'positive', tick: 1 };
sb.lastOutcome = { partnerId: a, outcome: 'negative', tick: 1 };
relationshipSystem(world);
const relsA = world.getComponent<Relationships>(a, 'relationships')!;
const relsB = world.getComponent<Relationships>(b, 'relationships')!;
expect(relsA.get(b)!.value).not.toBe(relsB.get(a)!.value);
});
it('clamps relationship value to [-100, 100]', () => {
const world = new World();
const a = createNPC(world, 5, 5);
const b = createNPC(world, 8, 5);
const relsA = world.getComponent<Relationships>(a, 'relationships')!;
const relsB = world.getComponent<Relationships>(b, 'relationships')!;
relsA.set(b, { value: 99, interactions: 0, lastInteractionTick: 0, status: 'active' });
relsB.set(a, { value: 99, interactions: 0, lastInteractionTick: 0, status: 'active' });
const sa = world.getComponent<SocialState>(a, 'socialState')!;
const sb = world.getComponent<SocialState>(b, 'socialState')!;
sa.lastOutcome = { partnerId: b, outcome: 'positive', tick: 1 };
sb.lastOutcome = { partnerId: a, outcome: 'positive', tick: 1 };
relationshipSystem(world);
expect(relsA.get(b)!.value).toBeLessThanOrEqual(100);
});
});
describe('stat influence', () => {
it('high empathy increases positive relationship gain', () => {
const world = new World();
const aHigh = createNPC(world, 5, 5, { stats: { empathy: 18 } });
const bHigh = createNPC(world, 8, 5);
const saH = world.getComponent<SocialState>(aHigh, 'socialState')!;
const sbH = world.getComponent<SocialState>(bHigh, 'socialState')!;
saH.lastOutcome = { partnerId: bHigh, outcome: 'positive', tick: 1 };
sbH.lastOutcome = { partnerId: aHigh, outcome: 'positive', tick: 1 };
const world2 = new World();
const aLow = createNPC(world2, 5, 5, { stats: { empathy: 3 } });
const bLow = createNPC(world2, 8, 5);
const saL = world2.getComponent<SocialState>(aLow, 'socialState')!;
const sbL = world2.getComponent<SocialState>(bLow, 'socialState')!;
saL.lastOutcome = { partnerId: bLow, outcome: 'positive', tick: 1 };
sbL.lastOutcome = { partnerId: aLow, outcome: 'positive', tick: 1 };
relationshipSystem(world);
relationshipSystem(world2);
const highGain = world.getComponent<Relationships>(aHigh, 'relationships')!.get(bHigh)!.value;
const lowGain = world2.getComponent<Relationships>(aLow, 'relationships')!.get(bLow)!.value;
expect(highGain).toBeGreaterThan(lowGain);
});
it('high temperament increases negative relationship loss', () => {
const world = new World();
const aHigh = createNPC(world, 5, 5, { stats: { temperament: 18 } });
const bHigh = createNPC(world, 8, 5);
const saH = world.getComponent<SocialState>(aHigh, 'socialState')!;
const sbH = world.getComponent<SocialState>(bHigh, 'socialState')!;
saH.lastOutcome = { partnerId: bHigh, outcome: 'negative', tick: 1 };
sbH.lastOutcome = { partnerId: aHigh, outcome: 'negative', tick: 1 };
const world2 = new World();
const aLow = createNPC(world2, 5, 5, { stats: { temperament: 3 } });
const bLow = createNPC(world2, 8, 5);
const saL = world2.getComponent<SocialState>(aLow, 'socialState')!;
const sbL = world2.getComponent<SocialState>(bLow, 'socialState')!;
saL.lastOutcome = { partnerId: bLow, outcome: 'negative', tick: 1 };
sbL.lastOutcome = { partnerId: aLow, outcome: 'negative', tick: 1 };
relationshipSystem(world);
relationshipSystem(world2);
const highLoss = world.getComponent<Relationships>(aHigh, 'relationships')!.get(bHigh)!.value;
const lowLoss = world2.getComponent<Relationships>(aLow, 'relationships')!.get(bLow)!.value;
expect(highLoss).toBeLessThan(lowLoss);
});
});
describe('despawn handling', () => {
it('fades weak relationships with removed entities', () => {
const world = new World();
const a = createNPC(world, 5, 5);
const b = createNPC(world, 8, 5);
const relsA = world.getComponent<Relationships>(a, 'relationships')!;
relsA.set(b, { value: 10, interactions: 2, lastInteractionTick: 0, status: 'active' });
world.removeEntity(b);
relationshipSystem(world);
const rel = relsA.get(b);
expect(rel).toBeDefined();
expect(rel!.value).toBeLessThan(10);
});
it('preserves strong relationships as memories', () => {
const world = new World();
const a = createNPC(world, 5, 5);
const b = createNPC(world, 8, 5);
const relsA = world.getComponent<Relationships>(a, 'relationships')!;
relsA.set(b, { value: 50, interactions: 10, lastInteractionTick: 0, status: 'active' });
world.removeEntity(b);
relationshipSystem(world);
const rel = relsA.get(b);
expect(rel).toBeDefined();
expect(rel!.status).toBe('memory');
expect(rel!.value).toBe(50);
});
it('cleans up faded relationships that reach zero', () => {
const world = new World();
const a = createNPC(world, 5, 5);
const b = createNPC(world, 8, 5);
const relsA = world.getComponent<Relationships>(a, 'relationships')!;
relsA.set(b, { value: 0.005, interactions: 1, lastInteractionTick: 0, status: 'active' });
world.removeEntity(b);
for (let i = 0; i < 10; i++) {
relationshipSystem(world);
}
expect(relsA.has(b)).toBe(false);
});
});
});
@@ -39,6 +39,7 @@ function createNPC(
outcome: null,
globalCooldown: 0,
pairCooldowns: new Map(),
lastOutcome: null,
});
return e;
}
@@ -213,6 +214,31 @@ describe('socialSystem', () => {
expect(['positive', 'negative']).toContain(sbAfter.outcome);
});
it('emoting -> none sets lastOutcome for relationship processing', () => {
const world = new World();
const a = createNPC(world, 5, 5);
const b = createNPC(world, 8, 5);
const sa = world.getComponent<SocialState>(a, 'socialState')!;
const sb = world.getComponent<SocialState>(b, 'socialState')!;
sa.phase = 'emoting';
sa.partnerId = b;
sa.phaseTimer = 1;
sa.outcome = 'positive';
sb.phase = 'emoting';
sb.partnerId = a;
sb.phaseTimer = 1;
sb.outcome = 'negative';
socialSystem(world);
const saAfter = world.getComponent<SocialState>(a, 'socialState')!;
const sbAfter = world.getComponent<SocialState>(b, 'socialState')!;
expect(saAfter.lastOutcome).not.toBeNull();
expect(saAfter.lastOutcome!.partnerId).toBe(b);
expect(saAfter.lastOutcome!.outcome).toBe('positive');
expect(sbAfter.lastOutcome).not.toBeNull();
expect(sbAfter.lastOutcome!.partnerId).toBe(a);
expect(sbAfter.lastOutcome!.outcome).toBe('negative');
});
it('emoting -> none sets cooldowns', () => {
const world = new World();
const a = createNPC(world, 5, 5);
+57
View File
@@ -0,0 +1,57 @@
import { relationshipConfig } from '../config/relationshipConfig.js';
export function classify(value: number): string {
for (const tier of relationshipConfig.tiers) {
if (value >= tier.min) return tier.label;
}
return 'Nemesis';
}
export function getPartnerCap(sociability: number, empathy: number): number {
if (
sociability >= relationshipConfig.polySociabilityThreshold &&
empathy >= relationshipConfig.polyEmpathyThreshold
) {
return relationshipConfig.polyPartnerCap;
}
return relationshipConfig.defaultPartnerCap;
}
export function getFriendCap(sociability: number): number {
return relationshipConfig.baseFriendCap +
Math.floor((sociability - 10) * relationshipConfig.friendCapPerSociability);
}
export function getEffectiveClassification(
entityId: number,
value: number,
allRelationships: Map<number, { value: number }>,
sociability: number,
empathy: number,
): string {
const raw = classify(value);
if (raw === 'Partner') {
const cap = getPartnerCap(sociability, empathy);
let partnerCount = 0;
for (const [otherId, rel] of allRelationships) {
if (otherId !== entityId && rel.value >= 80 && rel.value > value) {
partnerCount++;
}
}
if (partnerCount >= cap) return 'Close Friend';
}
if (raw === 'Friend') {
const cap = getFriendCap(sociability);
let friendCount = 0;
for (const [otherId, rel] of allRelationships) {
if (otherId !== entityId && rel.value >= 20 && rel.value < 80 && rel.value > value) {
friendCount++;
}
}
if (friendCount >= cap) return 'Acquaintance';
}
return raw;
}
+131
View File
@@ -0,0 +1,131 @@
import type {
SocialState, Relationships, RelationshipData, EntityId,
InteractionOutcome,
} from '@dflike/shared';
import type { World } from '../ecs/World.js';
import { getEffectiveStat } from './statHelpers.js';
import { relationshipConfig as cfg } from '../config/relationshipConfig.js';
function computeIndividualDelta(
world: World,
entity: EntityId,
outcome: InteractionOutcome,
interactions: number,
): number {
const isPositive = outcome === 'positive';
// 1. Base delta
const baseDelta = isPositive ? cfg.baseDeltaPositive : -cfg.baseDeltaNegative;
// 2. Diminishing returns
let delta = baseDelta / (1 + interactions * cfg.diminishingFactor);
// 3/4. Stat scaling
if (isPositive) {
const empathy = getEffectiveStat(world, entity, 'empathy');
delta *= (1 + (empathy - 10) * cfg.empathyWeight);
} else {
const temperament = getEffectiveStat(world, entity, 'temperament');
delta *= (1 + (temperament - 10) * cfg.temperamentWeight);
}
// 5. Sociability bonus
const sociability = getEffectiveStat(world, entity, 'sociability');
if (isPositive) {
delta += (sociability - 10) * cfg.sociabilityBonus;
} else {
delta -= (sociability - 10) * cfg.sociabilityBonus;
}
return delta;
}
export function relationshipSystem(world: World): void {
const entities = world.query('socialState', 'relationships', 'stats');
const processed = new Set<string>();
// Phase 1: Process completed interactions
for (const entity of entities) {
const social = world.getComponent<SocialState>(entity, 'socialState')!;
if (!social.lastOutcome) continue;
const partnerId = social.lastOutcome.partnerId;
const pairKey = entity < partnerId
? `${entity}:${partnerId}`
: `${partnerId}:${entity}`;
if (processed.has(pairKey)) continue;
processed.add(pairKey);
const partnerSocial = world.getComponent<SocialState>(partnerId, 'socialState');
if (!partnerSocial || !partnerSocial.lastOutcome) continue;
const relsA = world.getComponent<Relationships>(entity, 'relationships')!;
const relsB = world.getComponent<Relationships>(partnerId, 'relationships')!;
// Lazy-init relationship entries
if (!relsA.has(partnerId)) {
relsA.set(partnerId, { value: 0, interactions: 0, lastInteractionTick: 0, status: 'active' });
}
if (!relsB.has(entity)) {
relsB.set(entity, { value: 0, interactions: 0, lastInteractionTick: 0, status: 'active' });
}
const relA = relsA.get(partnerId)!;
const relB = relsB.get(entity)!;
// Compute individual deltas using existing interactions count (before incrementing)
const deltaA = computeIndividualDelta(world, entity, social.lastOutcome.outcome, relA.interactions);
const deltaB = computeIndividualDelta(world, partnerId, partnerSocial.lastOutcome.outcome, relB.interactions);
// 6. Blended asymmetry
const avgDelta = (deltaA + deltaB) / 2;
const finalDeltaA = cfg.sharedWeight * avgDelta + cfg.individualWeight * deltaA;
const finalDeltaB = cfg.sharedWeight * avgDelta + cfg.individualWeight * deltaB;
// Apply and clamp
relA.value = Math.max(-100, Math.min(100, relA.value + finalDeltaA));
relB.value = Math.max(-100, Math.min(100, relB.value + finalDeltaB));
// Increment interactions and update tick
relA.interactions++;
relB.interactions++;
relA.lastInteractionTick = social.lastOutcome.tick;
relB.lastInteractionTick = partnerSocial.lastOutcome.tick;
// Clear lastOutcome on both
social.lastOutcome = null;
partnerSocial.lastOutcome = null;
}
// Phase 2: Handle despawned entity relationships
for (const entity of entities) {
const rels = world.getComponent<Relationships>(entity, 'relationships')!;
const toRemove: EntityId[] = [];
for (const [otherId, rel] of rels) {
// Check if other entity still exists
if (world.getComponent(otherId, 'position') !== undefined) continue;
if (Math.abs(rel.value) >= cfg.memoryThreshold) {
// Strong relationship becomes memory
rel.status = 'memory';
} else {
// Fade toward 0
if (rel.value > 0) {
rel.value = Math.max(0, rel.value - cfg.fadeRatePerTick);
} else {
rel.value = Math.min(0, rel.value + cfg.fadeRatePerTick);
}
if (Math.abs(rel.value) < 0.01) {
toRemove.push(otherId);
}
}
}
for (const id of toRemove) {
rels.delete(id);
}
}
}
+12
View File
@@ -148,6 +148,18 @@ export function socialSystem(world: World): void {
}
}
// Emit completed interaction for relationship system
social.lastOutcome = {
partnerId: partnerId!,
outcome: social.outcome!,
tick: 0,
};
partnerSocial.lastOutcome = {
partnerId: e,
outcome: partnerSocial.outcome!,
tick: 0,
};
social.phase = 'none';
social.partnerId = null;
social.phaseTimer = 0;
+23
View File
@@ -83,6 +83,22 @@ export interface SocialState {
outcome: InteractionOutcome | null;
globalCooldown: number;
pairCooldowns: Map<EntityId, number>;
lastOutcome: LastOutcome | null;
}
export interface RelationshipData {
value: number; // -100 to 100
interactions: number; // total interaction count
lastInteractionTick: number;
status: 'active' | 'memory';
}
export type Relationships = Map<EntityId, RelationshipData>;
export interface LastOutcome {
partnerId: EntityId;
outcome: InteractionOutcome;
tick: number;
}
// Network protocol messages
@@ -101,6 +117,13 @@ export interface EntityState {
partnerId: EntityId | null;
outcome: InteractionOutcome | null;
};
relationships?: Array<{
entityId: EntityId;
name: string;
value: number;
classification: string;
status: 'active' | 'memory';
}>;
}
export interface WorldState {