Merge branch 'worktree-relationship-system'
This commit is contained in:
+236
-10
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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,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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user