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