Merge branch 'worktree-relationship-system'

This commit is contained in:
root
2026-03-07 20:57:08 +00:00
21 changed files with 2169 additions and 91 deletions
+11 -4
View File
@@ -2,6 +2,7 @@ import type { EntityState } from '@dflike/shared';
const EMOJI_POSITIVE = '\u{1F60A}';
const EMOJI_NEGATIVE = '\u{1F620}';
const EMOJI_PROPOSAL = '\u{1F48D}';
export class InteractionEmojiManager {
private activeEmojis: Map<number, HTMLDivElement> = new Map();
@@ -18,10 +19,10 @@ export class InteractionEmojiManager {
const emoting = new Set<number>();
for (const entity of entities) {
if (entity.socialState?.phase === 'emoting' && entity.socialState.outcome) {
if ((entity.socialState?.phase === 'emoting' || entity.socialState?.phase === 'proposing') && entity.socialState.outcome) {
emoting.add(entity.id);
if (!this.activeEmojis.has(entity.id)) {
this.createEmoji(entity.id, entity.socialState.outcome);
this.createEmoji(entity.id, entity.socialState.outcome, entity.socialState.phase);
}
}
}
@@ -37,7 +38,7 @@ export class InteractionEmojiManager {
}
}
private createEmoji(entityId: number, outcome: string): void {
private createEmoji(entityId: number, outcome: string, phase?: string): void {
const div = document.createElement('div');
div.style.cssText = `
position: fixed;
@@ -47,7 +48,13 @@ export class InteractionEmojiManager {
transform: translate(-50%, -100%);
`;
div.style.animation = 'emoji-float 2s ease-out forwards';
div.textContent = outcome === 'positive' ? EMOJI_POSITIVE : EMOJI_NEGATIVE;
let emoji: string;
if (phase === 'proposing') {
emoji = EMOJI_PROPOSAL;
} else {
emoji = outcome === 'positive' ? EMOJI_POSITIVE : EMOJI_NEGATIVE;
}
div.textContent = emoji;
// Self-cleanup when animation completes
div.addEventListener('animationend', () => {
+10 -8
View File
@@ -483,7 +483,7 @@ export class NpcInfoPanel {
return;
}
const tierOrder = ['Partner', 'Close Friend', 'Friend', 'Acquaintance', 'Stranger', 'Wary', 'Rival', 'Enemy', 'Nemesis'];
const tierOrder = ['Partner', 'Devoted', 'Close Friend', 'Friend', 'Acquaintance', 'Stranger', 'Wary', 'Rival', 'Enemy', 'Nemesis'];
const groups = new Map<string, typeof relationships>();
const memories: typeof relationships = [];
@@ -500,7 +500,7 @@ export class NpcInfoPanel {
this.relationshipsContent.innerHTML = '';
const tierIcons: Record<string, string> = {
'Partner': '\u2665', 'Close Friend': '\u2605', 'Friend': '\u25C6',
'Partner': '\u2665', 'Devoted': '\u2764', 'Close Friend': '\u2605', 'Friend': '\u25C6',
'Acquaintance': '\u25CB', 'Stranger': '\u00B7',
'Wary': '\u25B2', 'Rival': '\u2716', 'Enemy': '\u2620', 'Nemesis': '\u2620',
};
@@ -525,7 +525,7 @@ export class NpcInfoPanel {
tierEl.appendChild(tierLabel);
for (const rel of shown) {
tierEl.appendChild(this.createRelSlider(rel.name, rel.value, tier));
tierEl.appendChild(this.createRelSlider(rel.name, rel.value, tier, rel.bond));
}
if (hidden.length > 0) {
@@ -533,7 +533,7 @@ export class NpcInfoPanel {
const overflow = document.createElement('div');
overflow.style.cssText = isExpanded ? '' : 'display: none;';
for (const rel of hidden) {
overflow.appendChild(this.createRelSlider(rel.name, rel.value, tier));
overflow.appendChild(this.createRelSlider(rel.name, rel.value, tier, rel.bond));
}
tierEl.appendChild(overflow);
@@ -579,7 +579,7 @@ export class NpcInfoPanel {
const memHidden = memories.slice(2);
for (const rel of memShown) {
memEl.appendChild(this.createRelSlider(rel.name + ' \u2020', rel.value, 'memory'));
memEl.appendChild(this.createRelSlider(rel.name + ' \u2020', rel.value, 'memory', rel.bond));
}
if (memHidden.length > 0) {
@@ -588,7 +588,7 @@ export class NpcInfoPanel {
const overflow = document.createElement('div');
overflow.style.cssText = isExpanded ? '' : 'display: none;';
for (const rel of memHidden) {
overflow.appendChild(this.createRelSlider(rel.name + ' \u2020', rel.value, 'memory'));
overflow.appendChild(this.createRelSlider(rel.name + ' \u2020', rel.value, 'memory', rel.bond));
}
memEl.appendChild(overflow);
@@ -623,7 +623,7 @@ export class NpcInfoPanel {
}
}
private createRelSlider(name: string, value: number, tier: string): HTMLDivElement {
private createRelSlider(name: string, value: number, tier: string, bond?: string | null): HTMLDivElement {
const row = document.createElement('div');
row.style.cssText = `
display: flex;
@@ -635,7 +635,8 @@ export class NpcInfoPanel {
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 bondIcon = bond === 'partner' ? ' \u{1F48D}' : bond === 'former_partner' ? ' \u{1F48D}\u{FE0E}' : '';
nameEl.textContent = name + bondIcon;
const slider = document.createElement('div');
slider.style.cssText = `
@@ -682,6 +683,7 @@ export class NpcInfoPanel {
private getRelColor(tier: string): string {
switch (tier) {
case 'Partner': return '#ff69b4';
case 'Devoted': return '#ff69b4';
case 'Close Friend': return '#58d858';
case 'Friend': return '#40b840';
case 'Acquaintance': return '#6868a8';
@@ -0,0 +1,92 @@
# Mutual Bond System Design
## Problem
Partnership is currently a one-sided label derived from sentiment value. NPC A can call B their partner while B considers C their partner. The `classify()` function maps value >= 80 to "Partner" independently per entity, with no mutuality enforcement.
## Solution
Separate feelings from formal relationship status via three changes:
### 1. Bond Registry
World-level singleton component: `BondRegistry = Map<pairKey, Bond[]>`
```typescript
interface Bond {
type: 'partner'; // extensible: 'sibling', 'parent', etc.
formedAtTick: number;
status: 'active' | 'former';
}
```
- Keyed by canonical pair key (`"lowerEntityId:higherEntityId"`)
- Helper functions: `makePairKey()`, `getBonds()`, `hasBond()`, `addBond()`, `dissolveBond()`
- When a partner despawns, bond transitions to `status: 'former'` (consistent with existing memory system)
- Single source of truth for all structural relationships
### 2. Proposal Interaction
General-purpose mutual agreement protocol, with partnership as the first use case.
**New fields on `SocialState`:**
- `proposalCooldown: number` -- ticks until this entity can propose again
- `pendingProposal: { targetId: EntityId, type: 'partner' } | null` -- queued by relationship system
**Flow:**
1. **RelationshipSystem** (after updating sentiment): if entity's value toward other crosses >= `proposalThreshold` (default 80), no existing bond, no proposal cooldown, sets `pendingProposal` on the entity
2. **SocialSystem** (new interaction detection): when scanning for partners, if entity has `pendingProposal` targeting a nearby NPC, prioritize initiating interaction with that target. Interaction is flagged as proposal type.
3. **Proposal phases**: reuses existing `facing -> pausing -> emoting` but emoting phase uses ring emoji
4. **Resolution** (emoting->done transition):
- Check rejecter's sentiment toward proposer (must be >= `proposalAcceptanceThreshold`)
- Stat-influenced acceptance roll: base ~85%, empathy (+), sociability (+), temperament (-)
- **Accept**: bond added to registry, sentiment boost, positive emote, positive transient mods
- **Reject**: proposer takes amplified negative delta (2x), rejecter takes smaller hit (0.5x), both scaled by temperament. Proposal cooldown set on initiator, reduced by courage.
### 3. Classification Rework
- `classify()`: rename >= 80 tier from `'Partner'` to `'Devoted'`
- `getEffectiveClassification()`: add bond registry parameter. If `hasBond(partner, active)`, return `'Partner'` overriding the tier label
- `stateSerializer.ts`: switch from `classify()` to `getEffectiveClassification()`, pass bond registry
- Wire protocol: add `bond: 'partner' | 'former_partner' | null` field to serialized relationships
### 4. Client Display
- Ring emoji during proposal interaction (above both NPCs)
- NPC info panel: ring icon next to partner names
- Acceptance: ring emoji, then positive emote
- Rejection: ring emoji transitions to negative emote
### 5. Config Additions
All tuning values in `relationshipConfig.ts`:
```typescript
// Proposal system
proposalThreshold: 80,
proposalAcceptanceThreshold: 80,
proposalBaseAcceptanceChance: 0.85,
proposalEmpathyWeight: 0.02,
proposalSociabilityWeight: 0.02,
proposalTemperamentWeight: -0.02,
proposalAcceptanceBonus: 5,
proposalRejectionMultiplier: 2.0,
proposalRejectionRejecterMultiplier: 0.5,
baseProposalCooldown: 500,
courageCooldownWeight: 0.04,
proposalAcceptanceSociabilityMod: 2,
proposalAcceptanceEmpathyMod: 1,
proposalRejectionSociabilityMod: -1,
proposalRejectionEmpathyMod: -1,
proposalModDecayTicks: 150,
```
## Key Properties
- Feelings remain asymmetric and per-entity (existing system unchanged)
- Formal relationships are symmetric and centrally stored
- System is general enough for future bond types (sibling, parent/child)
- All tuning values configurable
- Rejection has real consequences, creating emergent drama
- System execution order unchanged: social -> relationship
File diff suppressed because it is too large Load Diff
+19 -1
View File
@@ -15,7 +15,7 @@ export const relationshipConfig = {
// Classification thresholds (ordered high to low)
tiers: [
{ min: 80, label: 'Partner' },
{ min: 80, label: 'Devoted' },
{ min: 50, label: 'Close Friend' },
{ min: 20, label: 'Friend' },
{ min: 5, label: 'Acquaintance' },
@@ -37,4 +37,22 @@ export const relationshipConfig = {
// Despawn behavior
memoryThreshold: 20,
fadeRatePerTick: 0.01,
// Proposal system
proposalThreshold: 80,
proposalAcceptanceThreshold: 80,
proposalBaseAcceptanceChance: 0.85,
proposalEmpathyWeight: 0.02,
proposalSociabilityWeight: 0.02,
proposalTemperamentWeight: -0.02,
proposalAcceptanceBonus: 5,
proposalRejectionMultiplier: 2.0,
proposalRejectionRejecterMultiplier: 0.5,
baseProposalCooldown: 500,
courageCooldownWeight: 0.04,
proposalAcceptanceSociabilityMod: 2,
proposalAcceptanceEmpathyMod: 1,
proposalRejectionSociabilityMod: -1,
proposalRejectionEmpathyMod: -1,
proposalModDecayTicks: 150,
};
+9
View File
@@ -6,6 +6,7 @@ export class World {
private nextId: EntityId = 1;
private components: Map<ComponentName, Map<EntityId, unknown>> = new Map();
private entities: Set<EntityId> = new Set();
private singletons: Map<string, unknown> = new Map();
createEntity(): EntityId {
const id = this.nextId++;
@@ -48,4 +49,12 @@ export class World {
getAllEntities(): EntityId[] {
return [...this.entities];
}
setSingleton<T>(name: string, data: T): void {
this.singletons.set(name, data);
}
getSingleton<T>(name: string): T | undefined {
return this.singletons.get(name) as T | undefined;
}
}
+15
View File
@@ -57,4 +57,19 @@ describe('World', () => {
expect(world.getComponent(entity, 'position')).toBeUndefined();
expect(world.getComponent(entity, 'needs')).toEqual({ hunger: 100, energy: 100 });
});
describe('singletons', () => {
it('should store and retrieve singletons', () => {
const world = new World();
world.setSingleton('testRegistry', new Map([['a', 1]]));
const result = world.getSingleton<Map<string, number>>('testRegistry');
expect(result).toBeDefined();
expect(result!.get('a')).toBe(1);
});
it('should return undefined for missing singletons', () => {
const world = new World();
expect(world.getSingleton('nonexistent')).toBeUndefined();
});
});
});
+2
View File
@@ -7,6 +7,7 @@ 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 { createBondRegistry } from '../systems/bondRegistry.js';
import { spawnNPC } from './spawner.js';
export class GameLoop {
@@ -18,6 +19,7 @@ export class GameLoop {
constructor() {
this.world = new World();
this.world.setSingleton('bondRegistry', createBondRegistry());
this.map = new GameMap();
this.setupMap();
this.spawnInitialNPCs(8);
+3
View File
@@ -37,6 +37,9 @@ export function spawnNPC(world: World, map: GameMap, positionHint?: Position): E
globalCooldown: 0,
pairCooldowns: new Map(),
lastOutcome: null,
proposalCooldown: 0,
pendingProposal: null,
isProposalInteraction: false,
});
world.addComponent<Stats>(entity, 'stats', generateStats());
world.addComponent<StatModifiers>(entity, 'statModifiers', { modifiers: [] });
+25 -8
View File
@@ -6,7 +6,9 @@ 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';
import { getEffectiveClassification } from '../systems/relationshipHelpers.js';
import type { BondRegistry } from '../systems/bondRegistry.js';
import { hasBond } from '../systems/bondRegistry.js';
export function serializeEntity(world: World, entityId: number): EntityState {
const socialState = world.getComponent<SocialState>(entityId, 'socialState');
@@ -24,14 +26,29 @@ export function serializeEntity(world: World, entityId: number): EntityState {
temperament: getEffectiveStat(world, entityId, 'temperament'),
} : undefined;
const relationshipsComponent = world.getComponent<Relationships>(entityId, 'relationships');
const registry = world.getSingleton<BondRegistry>('bondRegistry');
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,
}))
? [...relationshipsComponent.entries()].map(([otherId, rel]) => {
const sociability = statsComponent ? getEffectiveStat(world, entityId, 'sociability') : 10;
const empathy = statsComponent ? getEffectiveStat(world, entityId, 'empathy') : 10;
const classification = getEffectiveClassification(
otherId, rel.value, relationshipsComponent, sociability, empathy,
registry, entityId,
);
let bond: string | null = null;
if (registry) {
if (hasBond(registry, entityId, otherId, 'partner')) bond = 'partner';
else if (hasBond(registry, entityId, otherId, 'partner', 'former')) bond = 'former_partner';
}
return {
entityId: otherId,
name: world.getComponent<string>(otherId, 'name') ?? `NPC #${otherId}`,
value: Math.round(rel.value * 10) / 10,
classification,
status: rel.status,
bond,
};
})
: undefined;
return {
id: entityId,
@@ -0,0 +1,97 @@
import { describe, it, expect } from 'vitest';
import {
makePairKey, createBondRegistry, addBond, hasBond,
getBonds, dissolveBond, getActiveBondCount,
} from '../bondRegistry.js';
describe('bondRegistry', () => {
describe('makePairKey', () => {
it('should order IDs canonically (lower first)', () => {
expect(makePairKey(5, 3)).toBe('3:5');
expect(makePairKey(3, 5)).toBe('3:5');
});
it('should handle equal IDs', () => {
expect(makePairKey(7, 7)).toBe('7:7');
});
});
describe('addBond', () => {
it('should add a bond between two entities', () => {
const registry = createBondRegistry();
addBond(registry, 1, 2, 'partner', 100);
expect(hasBond(registry, 1, 2, 'partner')).toBe(true);
expect(hasBond(registry, 2, 1, 'partner')).toBe(true);
});
it('should not duplicate bond types for same pair', () => {
const registry = createBondRegistry();
addBond(registry, 1, 2, 'partner', 100);
addBond(registry, 1, 2, 'partner', 200);
const bonds = getBonds(registry, 1, 2);
expect(bonds.filter(b => b.type === 'partner')).toHaveLength(1);
});
it('should allow multiple bond types for same pair', () => {
const registry = createBondRegistry();
addBond(registry, 1, 2, 'partner', 100);
addBond(registry, 1, 2, 'sibling', 200);
const bonds = getBonds(registry, 1, 2);
expect(bonds).toHaveLength(2);
});
});
describe('hasBond', () => {
it('should return false when no bonds exist', () => {
const registry = createBondRegistry();
expect(hasBond(registry, 1, 2, 'partner')).toBe(false);
});
it('should check active status by default', () => {
const registry = createBondRegistry();
addBond(registry, 1, 2, 'partner', 100);
dissolveBond(registry, 1, 2, 'partner');
expect(hasBond(registry, 1, 2, 'partner')).toBe(false);
});
it('should find former bonds when specified', () => {
const registry = createBondRegistry();
addBond(registry, 1, 2, 'partner', 100);
dissolveBond(registry, 1, 2, 'partner');
expect(hasBond(registry, 1, 2, 'partner', 'former')).toBe(true);
});
});
describe('dissolveBond', () => {
it('should mark bond as former', () => {
const registry = createBondRegistry();
addBond(registry, 1, 2, 'partner', 100);
dissolveBond(registry, 1, 2, 'partner');
const bonds = getBonds(registry, 1, 2);
expect(bonds[0].status).toBe('former');
});
it('should be a no-op if bond does not exist', () => {
const registry = createBondRegistry();
dissolveBond(registry, 1, 2, 'partner');
expect(getBonds(registry, 1, 2)).toHaveLength(0);
});
});
describe('getActiveBondCount', () => {
it('should count active bonds of a type for an entity', () => {
const registry = createBondRegistry();
addBond(registry, 1, 2, 'partner', 100);
addBond(registry, 1, 3, 'partner', 200);
expect(getActiveBondCount(registry, 1, 'partner')).toBe(2);
expect(getActiveBondCount(registry, 2, 'partner')).toBe(1);
});
it('should not count former bonds', () => {
const registry = createBondRegistry();
addBond(registry, 1, 2, 'partner', 100);
dissolveBond(registry, 1, 2, 'partner');
expect(getActiveBondCount(registry, 1, 'partner')).toBe(0);
});
});
});
@@ -1,11 +1,12 @@
import { describe, it, expect } from 'vitest';
import { classify, getPartnerCap, getFriendCap } from '../relationshipHelpers.js';
import { classify, getPartnerCap, getFriendCap, getEffectiveClassification } from '../relationshipHelpers.js';
import { createBondRegistry, addBond, dissolveBond } from '../bondRegistry.js';
describe('relationshipHelpers', () => {
describe('classify', () => {
it('returns Partner for value >= 80', () => {
expect(classify(80)).toBe('Partner');
expect(classify(100)).toBe('Partner');
it('returns Devoted for value >= 80', () => {
expect(classify(80)).toBe('Devoted');
expect(classify(100)).toBe('Devoted');
});
it('returns Close Friend for value 50-79', () => {
@@ -81,4 +82,37 @@ describe('relationshipHelpers', () => {
expect(getFriendCap(6)).toBe(3);
});
});
describe('getEffectiveClassification with bonds', () => {
it('should return Partner when active partner bond exists', () => {
const registry = createBondRegistry();
addBond(registry, 1, 2, 'partner', 0);
const allRels = new Map([[2, { value: 85 }]]);
const result = getEffectiveClassification(2, 85, allRels, 10, 10, registry, 1);
expect(result).toBe('Partner');
});
it('should return Devoted when no partner bond despite high value', () => {
const registry = createBondRegistry();
const allRels = new Map([[2, { value: 85 }]]);
const result = getEffectiveClassification(2, 85, allRels, 10, 10, registry, 1);
expect(result).toBe('Devoted');
});
it('should return Devoted when bond is former', () => {
const registry = createBondRegistry();
addBond(registry, 1, 2, 'partner', 0);
dissolveBond(registry, 1, 2, 'partner');
const allRels = new Map([[2, { value: 85 }]]);
const result = getEffectiveClassification(2, 85, allRels, 10, 10, registry, 1);
expect(result).toBe('Devoted');
});
it('should still return Devoted without registry (backward compat)', () => {
const allRels = new Map([[2, { value: 85 }]]);
// Without registry, no bond override, falls through to raw classify
const result = getEffectiveClassification(2, 85, allRels, 10, 10);
expect(result).toBe('Devoted');
});
});
});
@@ -1,6 +1,8 @@
import { describe, it, expect } from 'vitest';
import { World } from '../../ecs/World.js';
import { relationshipSystem } from '../relationshipSystem.js';
import { createBondRegistry, hasBond, addBond } from '../bondRegistry.js';
import type { BondRegistry } from '../bondRegistry.js';
import type {
Position, Needs, Movement, NPCBrain, SocialState, Stats,
StatModifiers, Relationships, RelationshipData, EntityId,
@@ -20,6 +22,7 @@ function createNPC(
world.addComponent<SocialState>(e, 'socialState', {
phase: 'none', partnerId: null, phaseTimer: 0, outcome: null,
globalCooldown: 0, pairCooldowns: new Map(), lastOutcome: null,
proposalCooldown: 0, pendingProposal: null, isProposalInteraction: false,
});
const baseStats: Stats = {
strength: 10, dexterity: 10, constitution: 10, intelligence: 10, perception: 10,
@@ -236,4 +239,111 @@ describe('relationshipSystem', () => {
expect(relsA.has(b)).toBe(false);
});
});
describe('proposal queuing', () => {
it('should queue proposal when value crosses threshold after interaction', () => {
const world = new World();
world.setSingleton('bondRegistry', createBondRegistry());
const a = createNPC(world, 1, 1);
const b = createNPC(world, 2, 2);
const relsA = world.getComponent<Relationships>(a, 'relationships')!;
relsA.set(b, { value: 78, interactions: 10, lastInteractionTick: 0, status: 'active' });
const relsB = world.getComponent<Relationships>(b, 'relationships')!;
relsB.set(a, { value: 78, interactions: 10, lastInteractionTick: 0, status: 'active' });
const socialA = world.getComponent<SocialState>(a, 'socialState')!;
socialA.lastOutcome = { partnerId: b, outcome: 'positive', tick: 0 };
const socialB = world.getComponent<SocialState>(b, 'socialState')!;
socialB.lastOutcome = { partnerId: a, outcome: 'positive', tick: 0 };
relationshipSystem(world);
// After processing, values should cross 80+, so at least one should have proposal queued
const propA = world.getComponent<SocialState>(a, 'socialState')!.pendingProposal;
const propB = world.getComponent<SocialState>(b, 'socialState')!.pendingProposal;
const hasProposal = (propA !== null && propA.targetId === b) ||
(propB !== null && propB.targetId === a);
expect(hasProposal).toBe(true);
});
it('should not queue proposal if bond already exists', () => {
const world = new World();
const registry = createBondRegistry();
world.setSingleton('bondRegistry', registry);
const a = createNPC(world, 1, 1);
const b = createNPC(world, 2, 2);
addBond(registry, a, b, 'partner', 0);
const relsA = world.getComponent<Relationships>(a, 'relationships')!;
relsA.set(b, { value: 85, interactions: 10, lastInteractionTick: 0, status: 'active' });
const relsB = world.getComponent<Relationships>(b, 'relationships')!;
relsB.set(a, { value: 85, interactions: 10, lastInteractionTick: 0, status: 'active' });
// Need lastOutcome on both for the pair to be processed
const socialA = world.getComponent<SocialState>(a, 'socialState')!;
socialA.lastOutcome = { partnerId: b, outcome: 'positive', tick: 0 };
const socialB = world.getComponent<SocialState>(b, 'socialState')!;
socialB.lastOutcome = { partnerId: a, outcome: 'positive', tick: 0 };
relationshipSystem(world);
expect(world.getComponent<SocialState>(a, 'socialState')!.pendingProposal).toBeNull();
expect(world.getComponent<SocialState>(b, 'socialState')!.pendingProposal).toBeNull();
});
it('should not queue proposal if proposal cooldown is active', () => {
const world = new World();
world.setSingleton('bondRegistry', createBondRegistry());
const a = createNPC(world, 1, 1);
const b = createNPC(world, 2, 2);
const relsA = world.getComponent<Relationships>(a, 'relationships')!;
relsA.set(b, { value: 85, interactions: 10, lastInteractionTick: 0, status: 'active' });
const socialA = world.getComponent<SocialState>(a, 'socialState')!;
socialA.proposalCooldown = 100;
// No lastOutcome needed — proposal check runs regardless
relationshipSystem(world);
expect(socialA.pendingProposal).toBeNull();
expect(socialA.proposalCooldown).toBe(99); // decremented by 1
});
it('should decrement proposal cooldown each tick', () => {
const world = new World();
world.setSingleton('bondRegistry', createBondRegistry());
const a = createNPC(world, 1, 1);
const socialA = world.getComponent<SocialState>(a, 'socialState')!;
socialA.proposalCooldown = 5;
relationshipSystem(world);
expect(socialA.proposalCooldown).toBe(4);
});
});
describe('bond-aware despawn', () => {
it('should dissolve partner bond when entity despawns', () => {
const world = new World();
const registry = createBondRegistry();
world.setSingleton('bondRegistry', registry);
const a = createNPC(world, 1, 1);
const b = createNPC(world, 2, 2);
addBond(registry, a, b, 'partner', 0);
const relsA = world.getComponent<Relationships>(a, 'relationships')!;
relsA.set(b, { value: 85, interactions: 10, lastInteractionTick: 0, status: 'active' });
// Remove entity b (simulate despawn)
world.removeEntity(b);
relationshipSystem(world);
expect(hasBond(registry, a, b, 'partner')).toBe(false);
expect(hasBond(registry, a, b, 'partner', 'former')).toBe(true);
});
});
});
@@ -1,7 +1,7 @@
import { describe, it, expect } from 'vitest';
import { describe, it, expect, vi } from 'vitest';
import { World } from '../../ecs/World.js';
import { socialSystem } from '../socialSystem.js';
import type { Position, Needs, Movement, NPCBrain, SocialState, EntityId, Stats, StatModifiers } from '@dflike/shared';
import type { Position, Needs, Movement, NPCBrain, SocialState, EntityId, Stats, StatModifiers, Relationships, RelationshipData } from '@dflike/shared';
import {
AWARENESS_RADIUS,
HUNGER_THRESHOLD,
@@ -10,6 +10,8 @@ import {
SOCIAL_PAIR_COOLDOWN,
Direction,
} from '@dflike/shared';
import { createBondRegistry, hasBond } from '../bondRegistry.js';
import { relationshipConfig } from '../../config/relationshipConfig.js';
function createNPC(
world: World, x: number, y: number,
@@ -40,6 +42,9 @@ function createNPC(
globalCooldown: 0,
pairCooldowns: new Map(),
lastOutcome: null,
proposalCooldown: 0,
pendingProposal: null,
isProposalInteraction: false,
});
return e;
}
@@ -454,4 +459,162 @@ describe('socialSystem', () => {
expect(positiveCount).toBeGreaterThan(100);
});
});
describe('proposal interactions', () => {
it('should prioritize proposal target over closest NPC', () => {
const world = new World();
world.setSingleton('bondRegistry', createBondRegistry());
const a = createNPC(world, 5, 5);
const b = createNPC(world, 6, 5); // closer
const c = createNPC(world, 7, 5); // proposal target
const socialA = world.getComponent<SocialState>(a, 'socialState')!;
socialA.pendingProposal = { targetId: c, type: 'partner' };
socialSystem(world);
expect(socialA.partnerId).toBe(c);
expect(socialA.isProposalInteraction).toBe(true);
});
it('should transition from emoting to proposing when isProposalInteraction is set', () => {
const world = new World();
world.setSingleton('bondRegistry', createBondRegistry());
const a = createNPC(world, 5, 5);
const b = createNPC(world, 6, 5);
const socialA = world.getComponent<SocialState>(a, 'socialState')!;
socialA.phase = 'emoting';
socialA.partnerId = b;
socialA.phaseTimer = 1;
socialA.outcome = 'positive';
socialA.isProposalInteraction = true;
const socialB = world.getComponent<SocialState>(b, 'socialState')!;
socialB.phase = 'emoting';
socialB.partnerId = a;
socialB.phaseTimer = 1;
socialB.outcome = 'positive';
socialSystem(world);
expect(socialA.phase).toBe('proposing');
expect(socialB.phase).toBe('proposing');
});
it('should form partner bond on accepted proposal', () => {
const world = new World();
const registry = createBondRegistry();
world.setSingleton('bondRegistry', registry);
const a = createNPC(world, 5, 5);
const b = createNPC(world, 6, 5);
// Give both relationship components with high values
world.addComponent<Relationships>(a, 'relationships', new Map<number, RelationshipData>([
[b, { value: 85, interactions: 10, lastInteractionTick: 0, status: 'active' }]
]));
world.addComponent<Relationships>(b, 'relationships', new Map<number, RelationshipData>([
[a, { value: 85, interactions: 10, lastInteractionTick: 0, status: 'active' }]
]));
// Set up proposal interaction in proposing phase about to complete
const socialA = world.getComponent<SocialState>(a, 'socialState')!;
socialA.phase = 'proposing';
socialA.partnerId = b;
socialA.phaseTimer = 1;
socialA.isProposalInteraction = true;
const socialB = world.getComponent<SocialState>(b, 'socialState')!;
socialB.phase = 'proposing';
socialB.partnerId = a;
socialB.phaseTimer = 1;
// Add stats for acceptance (high empathy/sociability, low temperament)
addStats(world, b, { empathy: 15, sociability: 15, temperament: 5 });
// Mock Math.random to ensure acceptance
vi.spyOn(Math, 'random').mockReturnValue(0.1);
socialSystem(world);
vi.restoreAllMocks();
expect(hasBond(registry, a, b, 'partner')).toBe(true);
expect(socialA.phase).toBe('none');
});
it('should apply rejection penalties when receiver value below threshold', () => {
const world = new World();
const registry = createBondRegistry();
world.setSingleton('bondRegistry', registry);
const a = createNPC(world, 5, 5);
const b = createNPC(world, 6, 5);
// Proposer high, receiver low
world.addComponent<Relationships>(a, 'relationships', new Map<number, RelationshipData>([
[b, { value: 85, interactions: 10, lastInteractionTick: 0, status: 'active' }]
]));
world.addComponent<Relationships>(b, 'relationships', new Map<number, RelationshipData>([
[a, { value: 50, interactions: 10, lastInteractionTick: 0, status: 'active' }]
]));
const socialA = world.getComponent<SocialState>(a, 'socialState')!;
socialA.phase = 'proposing';
socialA.partnerId = b;
socialA.phaseTimer = 1;
socialA.isProposalInteraction = true;
const socialB = world.getComponent<SocialState>(b, 'socialState')!;
socialB.phase = 'proposing';
socialB.partnerId = a;
socialB.phaseTimer = 1;
addStats(world, a, { courage: 10, temperament: 10, sociability: 10 });
addStats(world, b, { temperament: 10, sociability: 10 });
socialSystem(world);
expect(hasBond(registry, a, b, 'partner')).toBe(false);
// addStats already adds statModifiers, so penalty mods should be applied
const relsA = world.getComponent<Relationships>(a, 'relationships')!;
expect(relsA.get(b)!.value).toBeLessThan(85); // proposer took hit
const relsB = world.getComponent<Relationships>(b, 'relationships')!;
expect(relsB.get(a)!.value).toBeLessThan(50); // rejecter took smaller hit
expect(socialA.proposalCooldown).toBeGreaterThan(0);
});
it('should set proposal cooldown scaled by courage on rejection', () => {
const world = new World();
world.setSingleton('bondRegistry', createBondRegistry());
const a = createNPC(world, 5, 5);
const b = createNPC(world, 6, 5);
world.addComponent<Relationships>(a, 'relationships', new Map<number, RelationshipData>([
[b, { value: 85, interactions: 10, lastInteractionTick: 0, status: 'active' }]
]));
world.addComponent<Relationships>(b, 'relationships', new Map<number, RelationshipData>([
[a, { value: 50, interactions: 10, lastInteractionTick: 0, status: 'active' }]
]));
const socialA = world.getComponent<SocialState>(a, 'socialState')!;
socialA.phase = 'proposing';
socialA.partnerId = b;
socialA.phaseTimer = 1;
socialA.isProposalInteraction = true;
const socialB = world.getComponent<SocialState>(b, 'socialState')!;
socialB.phase = 'proposing';
socialB.partnerId = a;
socialB.phaseTimer = 1;
// High courage = shorter cooldown
addStats(world, a, { courage: 18, temperament: 10, sociability: 10 });
addStats(world, b, { temperament: 10, sociability: 10 });
socialSystem(world);
// Base 500, courage 18: 500 * (1 - (18-10)*0.04) = 500 * 0.68 = 340
expect(socialA.proposalCooldown).toBe(340);
});
});
});
@@ -34,6 +34,10 @@ function addSocialState(world: World, entity: number, phase: SocialState['phase'
outcome: null,
globalCooldown: 0,
pairCooldowns: new Map(),
lastOutcome: null,
proposalCooldown: 0,
pendingProposal: null,
isProposalInteraction: false,
});
}
+59
View File
@@ -0,0 +1,59 @@
import type { EntityId } from '@dflike/shared';
export interface Bond {
type: string;
formedAtTick: number;
status: 'active' | 'former';
}
export type BondRegistry = Map<string, Bond[]>;
export function createBondRegistry(): BondRegistry {
return new Map();
}
export function makePairKey(a: EntityId, b: EntityId): string {
return a < b ? `${a}:${b}` : `${b}:${a}`;
}
export function addBond(
registry: BondRegistry, a: EntityId, b: EntityId,
type: string, tick: number,
): void {
const key = makePairKey(a, b);
if (!registry.has(key)) registry.set(key, []);
const bonds = registry.get(key)!;
if (bonds.some(b => b.type === type && b.status === 'active')) return;
bonds.push({ type, formedAtTick: tick, status: 'active' });
}
export function getBonds(registry: BondRegistry, a: EntityId, b: EntityId): Bond[] {
return registry.get(makePairKey(a, b)) ?? [];
}
export function hasBond(
registry: BondRegistry, a: EntityId, b: EntityId,
type: string, status: 'active' | 'former' = 'active',
): boolean {
return getBonds(registry, a, b).some(b => b.type === type && b.status === status);
}
export function dissolveBond(
registry: BondRegistry, a: EntityId, b: EntityId, type: string,
): void {
const bonds = getBonds(registry, a, b);
const bond = bonds.find(b => b.type === type && b.status === 'active');
if (bond) bond.status = 'former';
}
export function getActiveBondCount(
registry: BondRegistry, entity: EntityId, type: string,
): number {
let count = 0;
for (const [key, bonds] of registry) {
const [a, b] = key.split(':').map(Number);
if (a !== entity && b !== entity) continue;
count += bonds.filter(bond => bond.type === type && bond.status === 'active').length;
}
return count;
}
+9 -11
View File
@@ -1,4 +1,6 @@
import { relationshipConfig } from '../config/relationshipConfig.js';
import type { BondRegistry } from './bondRegistry.js';
import { hasBond } from './bondRegistry.js';
export function classify(value: number): string {
for (const tier of relationshipConfig.tiers) {
@@ -28,20 +30,16 @@ export function getEffectiveClassification(
allRelationships: Map<number, { value: number }>,
sociability: number,
empathy: number,
registry?: BondRegistry,
selfEntityId?: 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';
// Bond-based override: if active partner bond exists, return Partner
if (registry && selfEntityId !== undefined && hasBond(registry, selfEntityId, entityId, 'partner')) {
return 'Partner';
}
const raw = classify(value);
if (raw === 'Friend') {
const cap = getFriendCap(sociability);
let friendCount = 0;
+30
View File
@@ -5,6 +5,8 @@ import type {
import type { World } from '../ecs/World.js';
import { getEffectiveStat } from './statHelpers.js';
import { relationshipConfig as cfg } from '../config/relationshipConfig.js';
import type { BondRegistry } from './bondRegistry.js';
import { hasBond, dissolveBond } from './bondRegistry.js';
function computeIndividualDelta(
world: World,
@@ -43,6 +45,7 @@ function computeIndividualDelta(
export function relationshipSystem(world: World): void {
const entities = world.query('socialState', 'relationships', 'stats');
const processed = new Set<string>();
const registry = world.getSingleton<BondRegistry>('bondRegistry');
// Phase 1: Process completed interactions
for (const entity of entities) {
@@ -98,6 +101,28 @@ export function relationshipSystem(world: World): void {
partnerSocial.lastOutcome = null;
}
// Phase 1.5: Queue proposals for entities that crossed the threshold
if (registry) {
for (const entity of entities) {
const social = world.getComponent<SocialState>(entity, 'socialState')!;
if (social.pendingProposal) continue;
if (social.proposalCooldown > 0) {
social.proposalCooldown--;
continue;
}
const rels = world.getComponent<Relationships>(entity, 'relationships')!;
for (const [otherId, rel] of rels) {
if (rel.status !== 'active') continue;
if (rel.value < cfg.proposalThreshold) continue;
if (hasBond(registry, entity, otherId, 'partner')) continue;
if (social.pendingProposal) break;
social.pendingProposal = { targetId: otherId, type: 'partner' };
}
}
}
// Phase 2: Handle despawned entity relationships
for (const entity of entities) {
const rels = world.getComponent<Relationships>(entity, 'relationships')!;
@@ -107,6 +132,11 @@ export function relationshipSystem(world: World): void {
// Check if other entity still exists
if (world.getComponent(otherId, 'position') !== undefined) continue;
// Dissolve any active bonds with despawned entity
if (registry) {
dissolveBond(registry, entity, otherId, 'partner');
}
if (Math.abs(rel.value) >= cfg.memoryThreshold) {
// Strong relationship becomes memory
rel.status = 'memory';
+198 -52
View File
@@ -2,11 +2,16 @@ import {
AWARENESS_RADIUS, FACING_DURATION, PAUSING_DURATION, EMOTING_DURATION,
SOCIAL_GLOBAL_COOLDOWN, SOCIAL_PAIR_COOLDOWN,
HUNGER_THRESHOLD, ENERGY_THRESHOLD, Direction,
PROPOSAL_EMOTING_DURATION,
type SocialState, type Position, type Movement, type NPCBrain, type Needs, type EntityId,
type Stats, type StatModifiers,
} from '@dflike/shared';
import type { Relationships } from '@dflike/shared';
import type { World } from '../ecs/World.js';
import { getEffectiveStat } from './statHelpers.js';
import type { BondRegistry } from './bondRegistry.js';
import { addBond } from './bondRegistry.js';
import { relationshipConfig as cfg } from '../config/relationshipConfig.js';
function directionTo(from: Position, to: Position): number {
const dx = to.x - from.x;
@@ -100,75 +105,182 @@ export function socialSystem(world: World): void {
partnerSocial.phaseTimer = EMOTING_DURATION;
partnerSocial.outcome = Math.random() < positiveChanceB ? 'positive' : 'negative';
} else if (social.phase === 'emoting') {
// Check if this is a proposal interaction
if (social.isProposalInteraction || partnerSocial.isProposalInteraction) {
// Transition to proposing phase (ring emoji on client)
social.phase = 'proposing';
social.phaseTimer = PROPOSAL_EMOTING_DURATION;
partnerSocial.phase = 'proposing';
partnerSocial.phaseTimer = PROPOSAL_EMOTING_DURATION;
} else {
// Normal emoting→done transition
const socA = getEffectiveStat(world, e, 'sociability');
social.globalCooldown = Math.round(SOCIAL_GLOBAL_COOLDOWN * (1 - (socA - 10) * 0.04));
social.pairCooldowns.set(partnerId!, SOCIAL_PAIR_COOLDOWN);
const socB = getEffectiveStat(world, partnerId!, 'sociability');
partnerSocial.globalCooldown = Math.round(SOCIAL_GLOBAL_COOLDOWN * (1 - (socB - 10) * 0.04));
partnerSocial.pairCooldowns.set(e, SOCIAL_PAIR_COOLDOWN);
// Baseline stat drift
const statsA = world.getComponent<Stats>(e, 'stats');
if (statsA) {
if (social.outcome === 'positive') {
statsA.sociability = Math.max(3, Math.min(18, statsA.sociability + 0.05));
statsA.empathy = Math.max(3, Math.min(18, statsA.empathy + 0.05));
} else {
statsA.temperament = Math.max(3, Math.min(18, statsA.temperament + 0.02));
}
}
const statsB = world.getComponent<Stats>(partnerId!, 'stats');
if (statsB) {
if (partnerSocial.outcome === 'positive') {
statsB.sociability = Math.max(3, Math.min(18, statsB.sociability + 0.05));
statsB.empathy = Math.max(3, Math.min(18, statsB.empathy + 0.05));
} else {
statsB.temperament = Math.max(3, Math.min(18, statsB.temperament + 0.02));
}
}
// Event-based transient modifiers
const modsA = world.getComponent<StatModifiers>(e, 'statModifiers');
if (modsA) {
if (social.outcome === 'positive') {
modsA.modifiers.push({ stat: 'sociability', value: 1, remaining: 100 });
} else {
modsA.modifiers.push({ stat: 'sociability', value: -1, remaining: 100 });
modsA.modifiers.push({ stat: 'empathy', value: -1, remaining: 100 });
}
}
const modsB = world.getComponent<StatModifiers>(partnerId!, 'statModifiers');
if (modsB) {
if (partnerSocial.outcome === 'positive') {
modsB.modifiers.push({ stat: 'sociability', value: 1, remaining: 100 });
} else {
modsB.modifiers.push({ stat: 'sociability', value: -1, remaining: 100 });
modsB.modifiers.push({ stat: 'empathy', value: -1, remaining: 100 });
}
}
// 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;
social.outcome = null;
partnerSocial.phase = 'none';
partnerSocial.partnerId = null;
partnerSocial.phaseTimer = 0;
partnerSocial.outcome = null;
}
} else if (social.phase === 'proposing') {
// Resolve the proposal
const registry = world.getSingleton<BondRegistry>('bondRegistry');
// Determine who is the initiator (the one with isProposalInteraction=true)
const initiator = social.isProposalInteraction ? e : partnerId!;
const receiver = social.isProposalInteraction ? partnerId! : e;
const initiatorSocial = social.isProposalInteraction ? social : partnerSocial;
const receiverSocial = social.isProposalInteraction ? partnerSocial : social;
const relsReceiver = world.getComponent<Relationships>(receiver, 'relationships');
const receiverValue = relsReceiver?.get(initiator)?.value ?? 0;
let accepted = false;
if (receiverValue >= cfg.proposalAcceptanceThreshold) {
const recEmpathy = getEffectiveStat(world, receiver, 'empathy');
const recSociability = getEffectiveStat(world, receiver, 'sociability');
const recTemperament = getEffectiveStat(world, receiver, 'temperament');
const chance = cfg.proposalBaseAcceptanceChance
+ (recEmpathy - 10) * cfg.proposalEmpathyWeight
+ (recSociability - 10) * cfg.proposalSociabilityWeight
+ (recTemperament - 10) * cfg.proposalTemperamentWeight;
accepted = Math.random() < chance;
}
if (accepted && registry) {
addBond(registry, initiator, receiver, 'partner', 0);
// Sentiment boost on both sides
const relsInit = world.getComponent<Relationships>(initiator, 'relationships');
const relInit = relsInit?.get(receiver);
if (relInit) relInit.value = Math.min(100, relInit.value + cfg.proposalAcceptanceBonus);
const relRec = relsReceiver?.get(initiator);
if (relRec) relRec.value = Math.min(100, relRec.value + cfg.proposalAcceptanceBonus);
// Positive transient mods on both
const modsInit = world.getComponent<StatModifiers>(initiator, 'statModifiers');
if (modsInit) {
modsInit.modifiers.push({ stat: 'sociability', value: cfg.proposalAcceptanceSociabilityMod, remaining: cfg.proposalModDecayTicks });
modsInit.modifiers.push({ stat: 'empathy', value: cfg.proposalAcceptanceEmpathyMod, remaining: cfg.proposalModDecayTicks });
}
const modsRec = world.getComponent<StatModifiers>(receiver, 'statModifiers');
if (modsRec) {
modsRec.modifiers.push({ stat: 'sociability', value: cfg.proposalAcceptanceSociabilityMod, remaining: cfg.proposalModDecayTicks });
modsRec.modifiers.push({ stat: 'empathy', value: cfg.proposalAcceptanceEmpathyMod, remaining: cfg.proposalModDecayTicks });
}
social.outcome = 'positive';
partnerSocial.outcome = 'positive';
} else {
// Rejection penalties
const relsInit = world.getComponent<Relationships>(initiator, 'relationships');
const relInit = relsInit?.get(receiver);
if (relInit) {
relInit.value = Math.max(-100, relInit.value - cfg.baseDeltaNegative * cfg.proposalRejectionMultiplier);
}
const relRec = relsReceiver?.get(initiator);
if (relRec) {
relRec.value = Math.max(-100, relRec.value - cfg.baseDeltaNegative * cfg.proposalRejectionRejecterMultiplier);
}
// Proposal cooldown on initiator, scaled by courage
const courage = getEffectiveStat(world, initiator, 'courage');
initiatorSocial.proposalCooldown = Math.round(
cfg.baseProposalCooldown * (1 - (courage - 10) * cfg.courageCooldownWeight)
);
// Negative transient mods on proposer
const modsInit = world.getComponent<StatModifiers>(initiator, 'statModifiers');
if (modsInit) {
modsInit.modifiers.push({ stat: 'sociability', value: cfg.proposalRejectionSociabilityMod, remaining: cfg.proposalModDecayTicks });
modsInit.modifiers.push({ stat: 'empathy', value: cfg.proposalRejectionEmpathyMod, remaining: cfg.proposalModDecayTicks });
}
social.outcome = 'negative';
partnerSocial.outcome = 'negative';
}
// Set cooldowns (same as normal interaction end)
const socA = getEffectiveStat(world, e, 'sociability');
social.globalCooldown = Math.round(SOCIAL_GLOBAL_COOLDOWN * (1 - (socA - 10) * 0.04));
social.pairCooldowns.set(partnerId!, SOCIAL_PAIR_COOLDOWN);
const socB = getEffectiveStat(world, partnerId!, 'sociability');
partnerSocial.globalCooldown = Math.round(SOCIAL_GLOBAL_COOLDOWN * (1 - (socB - 10) * 0.04));
partnerSocial.pairCooldowns.set(e, SOCIAL_PAIR_COOLDOWN);
// Baseline stat drift
const statsA = world.getComponent<Stats>(e, 'stats');
if (statsA) {
if (social.outcome === 'positive') {
statsA.sociability = Math.max(3, Math.min(18, statsA.sociability + 0.05));
statsA.empathy = Math.max(3, Math.min(18, statsA.empathy + 0.05));
} else {
statsA.temperament = Math.max(3, Math.min(18, statsA.temperament + 0.02));
}
}
const statsB = world.getComponent<Stats>(partnerId!, 'stats');
if (statsB) {
if (partnerSocial.outcome === 'positive') {
statsB.sociability = Math.max(3, Math.min(18, statsB.sociability + 0.05));
statsB.empathy = Math.max(3, Math.min(18, statsB.empathy + 0.05));
} else {
statsB.temperament = Math.max(3, Math.min(18, statsB.temperament + 0.02));
}
}
// Event-based transient modifiers
const modsA = world.getComponent<StatModifiers>(e, 'statModifiers');
if (modsA) {
if (social.outcome === 'positive') {
modsA.modifiers.push({ stat: 'sociability', value: 1, remaining: 100 });
} else {
modsA.modifiers.push({ stat: 'sociability', value: -1, remaining: 100 });
modsA.modifiers.push({ stat: 'empathy', value: -1, remaining: 100 });
}
}
const modsB = world.getComponent<StatModifiers>(partnerId!, 'statModifiers');
if (modsB) {
if (partnerSocial.outcome === 'positive') {
modsB.modifiers.push({ stat: 'sociability', value: 1, remaining: 100 });
} else {
modsB.modifiers.push({ stat: 'sociability', value: -1, remaining: 100 });
modsB.modifiers.push({ stat: 'empathy', value: -1, remaining: 100 });
}
}
// Emit completed interaction for relationship system
social.lastOutcome = {
partnerId: partnerId!,
outcome: social.outcome!,
tick: 0,
};
partnerSocial.lastOutcome = {
partnerId: e,
outcome: partnerSocial.outcome!,
tick: 0,
};
// Emit lastOutcome for relationship system
social.lastOutcome = { partnerId: partnerId!, outcome: social.outcome!, tick: 0 };
partnerSocial.lastOutcome = { partnerId: e, outcome: partnerSocial.outcome!, tick: 0 };
// Reset both entities
social.phase = 'none';
social.partnerId = null;
social.phaseTimer = 0;
social.outcome = null;
social.isProposalInteraction = false;
social.pendingProposal = null;
partnerSocial.phase = 'none';
partnerSocial.partnerId = null;
partnerSocial.phaseTimer = 0;
partnerSocial.outcome = null;
partnerSocial.isProposalInteraction = false;
partnerSocial.pendingProposal = null;
}
}
}
@@ -181,6 +293,40 @@ export function socialSystem(world: World): void {
const pos = world.getComponent<Position>(e, 'position')!;
// Check for pending proposal target first
if (social.pendingProposal) {
const target = social.pendingProposal.targetId;
const targetPos = world.getComponent<Position>(target, 'position');
const targetSocial = world.getComponent<SocialState>(target, 'socialState');
if (targetPos && targetSocial && targetSocial.phase === 'none'
&& !claimedThisTick.has(target) && targetSocial.globalCooldown <= 0) {
const dist = manhattanDist(pos, targetPos);
const perceptionA = getEffectiveStat(world, e, 'perception');
const awarenessA = AWARENESS_RADIUS + (perceptionA - 10);
if (dist <= awarenessA) {
social.phase = 'facing';
social.partnerId = target;
social.phaseTimer = FACING_DURATION;
social.isProposalInteraction = true;
targetSocial.phase = 'facing';
targetSocial.partnerId = e;
targetSocial.phaseTimer = FACING_DURATION;
const movA = world.getComponent<Movement>(e, 'movement')!;
const movB = world.getComponent<Movement>(target, 'movement')!;
movA.direction = directionTo(pos, targetPos);
movA.state = 'idle';
movB.direction = directionTo(targetPos, pos);
movB.state = 'idle';
claimedThisTick.add(e);
claimedThisTick.add(target);
continue; // skip normal target scan
}
}
}
let closestDist = Infinity;
let closestTarget: EntityId | null = null;
+2
View File
@@ -59,5 +59,7 @@ export const EMOTING_DURATION = 20; // ticks (2s)
export const SOCIAL_GLOBAL_COOLDOWN = 50; // ticks (5s)
export const SOCIAL_PAIR_COOLDOWN = 300; // ticks (30s)
export const PROPOSAL_EMOTING_DURATION = 30; // 3 seconds - longer for dramatic effect
// Camera mode commands
export const MAX_NPC_COUNT = 50;
+5 -1
View File
@@ -73,7 +73,7 @@ export interface NPCBrain {
goalQueue: GoalType[];
}
export type InteractionPhase = 'none' | 'facing' | 'pausing' | 'emoting';
export type InteractionPhase = 'none' | 'facing' | 'pausing' | 'emoting' | 'proposing';
export type InteractionOutcome = 'positive' | 'negative';
export interface SocialState {
@@ -84,6 +84,9 @@ export interface SocialState {
globalCooldown: number;
pairCooldowns: Map<EntityId, number>;
lastOutcome: LastOutcome | null;
proposalCooldown: number;
pendingProposal: { targetId: EntityId; type: string } | null;
isProposalInteraction: boolean;
}
export interface RelationshipData {
@@ -123,6 +126,7 @@ export interface EntityState {
value: number;
classification: string;
status: 'active' | 'memory';
bond: string | null;
}>;
}