diff --git a/client/src/ui/InteractionEmoji.ts b/client/src/ui/InteractionEmoji.ts index 1d9e52c..cd773b6 100644 --- a/client/src/ui/InteractionEmoji.ts +++ b/client/src/ui/InteractionEmoji.ts @@ -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 = new Map(); @@ -18,10 +19,10 @@ export class InteractionEmojiManager { const emoting = new Set(); 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', () => { diff --git a/client/src/ui/NpcInfoPanel.ts b/client/src/ui/NpcInfoPanel.ts index 1d98cbf..7bd0817 100644 --- a/client/src/ui/NpcInfoPanel.ts +++ b/client/src/ui/NpcInfoPanel.ts @@ -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(); const memories: typeof relationships = []; @@ -500,7 +500,7 @@ export class NpcInfoPanel { this.relationshipsContent.innerHTML = ''; const tierIcons: Record = { - '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'; diff --git a/docs/plans/2026-03-07-mutual-bonds-design.md b/docs/plans/2026-03-07-mutual-bonds-design.md new file mode 100644 index 0000000..d12a7de --- /dev/null +++ b/docs/plans/2026-03-07-mutual-bonds-design.md @@ -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` + +```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 diff --git a/docs/plans/2026-03-07-mutual-bonds-implementation.md b/docs/plans/2026-03-07-mutual-bonds-implementation.md new file mode 100644 index 0000000..b723a34 --- /dev/null +++ b/docs/plans/2026-03-07-mutual-bonds-implementation.md @@ -0,0 +1,1266 @@ +# Mutual Bond System Implementation Plan + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. + +**Goal:** Separate feelings from formal relationship statuses by introducing a central bond registry and proposal interaction protocol. + +**Architecture:** World-level bond registry (singleton on World) stores mutual relationships. RelationshipSystem queues proposals when sentiment crosses threshold. SocialSystem executes proposal interactions with acceptance/rejection. Classification reworked to use registry for "Partner" label. + +**Tech Stack:** TypeScript, vitest, ECS (server/src/ecs/World.ts), Phaser 3 (client) + +--- + +### Task 1: Add Singleton Support to World + +**Files:** +- Modify: `server/src/ecs/World.ts:5-51` +- Modify: `server/src/ecs/__tests__/World.test.ts` + +**Step 1: Write the failing test** + +In `server/src/ecs/__tests__/World.test.ts`, add a new describe block: + +```typescript +describe('singletons', () => { + it('should store and retrieve singletons', () => { + const world = new World(); + world.setSingleton('testRegistry', new Map([['a', 1]])); + const result = world.getSingleton>('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(); + }); +}); +``` + +**Step 2: Run test to verify it fails** + +Run: `npm -w server run test -- --run server/src/ecs/__tests__/World.test.ts` +Expected: FAIL — `setSingleton` and `getSingleton` not defined + +**Step 3: Write minimal implementation** + +In `server/src/ecs/World.ts`, add to the World class: + +```typescript +private singletons: Map = new Map(); + +setSingleton(name: string, data: T): void { + this.singletons.set(name, data); +} + +getSingleton(name: string): T | undefined { + return this.singletons.get(name) as T | undefined; +} +``` + +**Step 4: Run test to verify it passes** + +Run: `npm -w server run test -- --run server/src/ecs/__tests__/World.test.ts` +Expected: PASS + +**Step 5: Commit** + +```bash +git add server/src/ecs/World.ts server/src/ecs/__tests__/World.test.ts +git commit -m "feat: add singleton support to World ECS" +``` + +--- + +### Task 2: Bond Registry Types and Helpers + +**Files:** +- Create: `server/src/systems/bondRegistry.ts` +- Create: `server/src/systems/__tests__/bondRegistry.test.ts` + +**Step 1: Write the failing tests** + +Create `server/src/systems/__tests__/bondRegistry.test.ts`: + +```typescript +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'); // no throw + 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); + }); + }); +}); +``` + +**Step 2: Run test to verify it fails** + +Run: `npm -w server run test -- --run server/src/systems/__tests__/bondRegistry.test.ts` +Expected: FAIL — module not found + +**Step 3: Write minimal implementation** + +Create `server/src/systems/bondRegistry.ts`: + +```typescript +import type { EntityId } from '@dflike/shared'; + +export interface Bond { + type: string; + formedAtTick: number; + status: 'active' | 'former'; +} + +export type BondRegistry = Map; + +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)!; + // Don't duplicate same bond type + 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; +} +``` + +**Step 4: Run test to verify it passes** + +Run: `npm -w server run test -- --run server/src/systems/__tests__/bondRegistry.test.ts` +Expected: PASS + +**Step 5: Commit** + +```bash +git add server/src/systems/bondRegistry.ts server/src/systems/__tests__/bondRegistry.test.ts +git commit -m "feat: add bond registry with helpers for mutual relationships" +``` + +--- + +### Task 3: Update Shared Types + +**Files:** +- Modify: `shared/src/types.ts:76-77,79-87,105-127` +- Modify: `shared/src/constants.ts` (add PROPOSAL_EMOTING_DURATION) + +**Step 1: Update InteractionPhase to include 'proposing'** + +In `shared/src/types.ts`, change line 76: + +```typescript +export type InteractionPhase = 'none' | 'facing' | 'pausing' | 'emoting' | 'proposing'; +``` + +**Step 2: Add proposal fields to SocialState** + +In `shared/src/types.ts`, update the SocialState interface (lines 79-87) to add after `lastOutcome`: + +```typescript +export interface SocialState { + phase: InteractionPhase; + partnerId: EntityId | null; + phaseTimer: number; + outcome: InteractionOutcome | null; + globalCooldown: number; + pairCooldowns: Map; + lastOutcome: LastOutcome | null; + proposalCooldown: number; + pendingProposal: { targetId: EntityId; type: string } | null; + isProposalInteraction: boolean; +} +``` + +**Step 3: Add bond field to EntityState relationships** + +In `shared/src/types.ts`, update the relationships array type in EntityState (around line 122): + +```typescript + relationships?: Array<{ + entityId: EntityId; + name: string; + value: number; + classification: string; + status: 'active' | 'memory'; + bond: string | null; // 'partner', 'former_partner', etc. + }>; +``` + +**Step 4: Add proposal constants** + +In `shared/src/constants.ts`, add: + +```typescript +export const PROPOSAL_EMOTING_DURATION = 30; // 3 seconds - longer for dramatic effect +``` + +**Step 5: Rebuild shared types** + +Run: `npx -w shared tsc` +Expected: Compiles successfully + +**Step 6: Commit** + +```bash +git add shared/src/types.ts shared/src/constants.ts +git commit -m "feat: add proposal and bond types to shared protocol" +``` + +--- + +### Task 4: Update Relationship Config + +**Files:** +- Modify: `server/src/config/relationshipConfig.ts` + +**Step 1: Add proposal config values** + +Add to the `relationshipConfig` object: + +```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, +``` + +**Step 2: Commit** + +```bash +git add server/src/config/relationshipConfig.ts +git commit -m "feat: add proposal system config values" +``` + +--- + +### Task 5: Rename Partner Tier to Devoted + Bond-Aware Classification + +**Files:** +- Modify: `server/src/config/relationshipConfig.ts:18` (tier label) +- Modify: `server/src/systems/relationshipHelpers.ts` +- Modify: `server/src/systems/__tests__/relationshipHelpers.test.ts` + +**Step 1: Update the failing tests** + +In `server/src/systems/__tests__/relationshipHelpers.test.ts`: + +Update `classify` tests: change all `'Partner'` expectations to `'Devoted'`: + +```typescript + it('returns Devoted for values >= 80', () => { + expect(classify(80)).toBe('Devoted'); + expect(classify(100)).toBe('Devoted'); + }); +``` + +Add new tests for bond-aware classification: + +```typescript +import { + classify, getPartnerCap, getFriendCap, getEffectiveClassification, +} from '../relationshipHelpers.js'; +import { createBondRegistry, addBond } from '../bondRegistry.js'; + +// ... existing tests ... + +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 former_partner classification when bond is former', () => { + const registry = createBondRegistry(); + addBond(registry, 1, 2, 'partner', 0); + const { dissolveBond } = await import('../bondRegistry.js'); + 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'); + }); +}); +``` + +Note: The test for `getEffectiveClassification` adds two new parameters: `registry` and `selfEntityId`. Update the existing tests to pass `createBondRegistry()` and a dummy entity ID to maintain compatibility. + +**Step 2: Run tests to verify they fail** + +Run: `npm -w server run test -- --run server/src/systems/__tests__/relationshipHelpers.test.ts` +Expected: FAIL — 'Partner' !== 'Devoted', extra params + +**Step 3: Update config tier label** + +In `server/src/config/relationshipConfig.ts` line 18, change: + +```typescript + { min: 80, label: 'Devoted' }, +``` + +**Step 4: Update relationshipHelpers.ts** + +Replace `server/src/systems/relationshipHelpers.ts` with: + +```typescript +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) { + if (value >= tier.min) return tier.label; + } + return 'Nemesis'; +} + +export function getPartnerCap(sociability: number, empathy: number): number { + if ( + sociability >= relationshipConfig.polySociabilityThreshold && + empathy >= relationshipConfig.polyEmpathyThreshold + ) { + return relationshipConfig.polyPartnerCap; + } + return relationshipConfig.defaultPartnerCap; +} + +export function getFriendCap(sociability: number): number { + return relationshipConfig.baseFriendCap + + Math.floor((sociability - 10) * relationshipConfig.friendCapPerSociability); +} + +export function getEffectiveClassification( + entityId: number, + value: number, + allRelationships: Map, + sociability: number, + empathy: number, + registry?: BondRegistry, + selfEntityId?: number, +): string { + // 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; + for (const [otherId, rel] of allRelationships) { + if (otherId !== entityId && rel.value >= 20 && rel.value < 80 && rel.value > value) { + friendCount++; + } + } + if (friendCount >= cap) return 'Acquaintance'; + } + + return raw; +} +``` + +Note: The old partner-cap logic in `getEffectiveClassification` is removed — partner status is now exclusively determined by the bond registry, not by value thresholds. The `'Devoted'` tier label remains as the raw sentiment classification. + +**Step 5: Run tests to verify they pass** + +Run: `npm -w server run test -- --run server/src/systems/__tests__/relationshipHelpers.test.ts` +Expected: PASS + +**Step 6: Run full test suite to check for regressions** + +Run: `npm -w server run test` +Expected: All tests pass. Some relationship system tests may need updating if they assert 'Partner' classification — update those to 'Devoted'. + +**Step 7: Commit** + +```bash +git add server/src/config/relationshipConfig.ts server/src/systems/relationshipHelpers.ts server/src/systems/__tests__/relationshipHelpers.test.ts +git commit -m "feat: rename Partner tier to Devoted, add bond-aware classification" +``` + +--- + +### Task 6: Initialize Bond Registry in GameLoop + Update Spawner + +**Files:** +- Modify: `server/src/game/GameLoop.ts:19-24` +- Modify: `server/src/game/spawner.ts:31-38` (SocialState init) + +**Step 1: Initialize bond registry in GameLoop constructor** + +In `server/src/game/GameLoop.ts`, add import and initialization: + +```typescript +import { createBondRegistry } from '../systems/bondRegistry.js'; +``` + +In constructor, after `this.world = new World();`: + +```typescript +this.world.setSingleton('bondRegistry', createBondRegistry()); +``` + +Pass the world to `relationshipSystem` (it already receives world, so no signature change needed — the system will read the singleton internally). + +**Step 2: Update spawner SocialState initialization** + +In `server/src/game/spawner.ts`, update the socialState component (around line 31) to include new fields: + +```typescript + world.addComponent(entity, 'socialState', { + phase: 'none', + partnerId: null, + phaseTimer: 0, + outcome: null, + globalCooldown: 0, + pairCooldowns: new Map(), + lastOutcome: null, + proposalCooldown: 0, + pendingProposal: null, + isProposalInteraction: false, + }); +``` + +**Step 3: Run full test suite** + +Run: `npm -w server run test` +Expected: Tests pass. Some test helpers that create SocialState may need the new fields added — update `createNPC()` helpers in test files to include `proposalCooldown: 0`, `pendingProposal: null`, `isProposalInteraction: false`. + +**Step 4: Commit** + +```bash +git add server/src/game/GameLoop.ts server/src/game/spawner.ts +git commit -m "feat: initialize bond registry singleton and update spawner" +``` + +--- + +### Task 7: Update RelationshipSystem — Proposal Queuing + Bond-Aware Despawn + +**Files:** +- Modify: `server/src/systems/relationshipSystem.ts` +- Modify: `server/src/systems/__tests__/relationshipSystem.test.ts` + +**Step 1: Write the failing tests for proposal queuing** + +Add to `server/src/systems/__tests__/relationshipSystem.test.ts`: + +```typescript +import { createBondRegistry, hasBond } from '../bondRegistry.js'; +import type { BondRegistry } from '../bondRegistry.js'; + +// Update createNPC helper to include new SocialState fields: +// proposalCooldown: 0, pendingProposal: null, isProposalInteraction: 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, { x: 1, y: 1 }); + const b = createNPC(world, { x: 2, y: 2 }); + + // Pre-set high relationship values just below threshold + const relsA = world.getComponent(a, 'relationships')!; + relsA.set(b, { value: 78, interactions: 10, lastInteractionTick: 0, status: 'active' }); + const relsB = world.getComponent(b, 'relationships')!; + relsB.set(a, { value: 78, interactions: 10, lastInteractionTick: 0, status: 'active' }); + + // Set up positive interaction outcome + const socialA = world.getComponent(a, 'socialState')!; + socialA.lastOutcome = { partnerId: b, outcome: 'positive', tick: 0 }; + const socialB = world.getComponent(b, 'socialState')!; + socialB.lastOutcome = { partnerId: a, outcome: 'positive', tick: 0 }; + + relationshipSystem(world); + + // At least one should have a pending proposal (the one whose value crossed 80) + const propA = world.getComponent(a, 'socialState')!.pendingProposal; + const propB = world.getComponent(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, { x: 1, y: 1 }); + const b = createNPC(world, { x: 2, y: 2 }); + + addBond(registry, a, b, 'partner', 0); + + const relsA = world.getComponent(a, 'relationships')!; + relsA.set(b, { value: 85, interactions: 10, lastInteractionTick: 0, status: 'active' }); + + const socialA = world.getComponent(a, 'socialState')!; + socialA.lastOutcome = { partnerId: b, outcome: 'positive', tick: 0 }; + const socialB = world.getComponent(b, 'socialState')!; + socialB.lastOutcome = { partnerId: a, outcome: 'positive', tick: 0 }; + + relationshipSystem(world); + + expect(socialA.pendingProposal).toBeNull(); + }); + + it('should not queue proposal if proposal cooldown is active', () => { + const world = new World(); + world.setSingleton('bondRegistry', createBondRegistry()); + const a = createNPC(world, { x: 1, y: 1 }); + const b = createNPC(world, { x: 2, y: 2 }); + + const relsA = world.getComponent(a, 'relationships')!; + relsA.set(b, { value: 85, interactions: 10, lastInteractionTick: 0, status: 'active' }); + + const socialA = world.getComponent(a, 'socialState')!; + socialA.proposalCooldown = 100; + socialA.lastOutcome = { partnerId: b, outcome: 'positive', tick: 0 }; + const socialB = world.getComponent(b, 'socialState')!; + socialB.lastOutcome = { partnerId: a, outcome: 'positive', tick: 0 }; + + relationshipSystem(world); + + expect(socialA.pendingProposal).toBeNull(); + }); +}); + +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, { x: 1, y: 1 }); + const b = createNPC(world, { x: 2, y: 2 }); + + addBond(registry, a, b, 'partner', 0); + const relsA = world.getComponent(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); + }); +}); +``` + +**Step 2: Run tests to verify they fail** + +Run: `npm -w server run test -- --run server/src/systems/__tests__/relationshipSystem.test.ts` +Expected: FAIL + +**Step 3: Update relationshipSystem implementation** + +In `server/src/systems/relationshipSystem.ts`, add imports: + +```typescript +import type { BondRegistry } from './bondRegistry.js'; +import { hasBond, dissolveBond } from './bondRegistry.js'; +import { relationshipConfig as cfg } from '../config/relationshipConfig.js'; +``` + +After Phase 1 (processing interactions), add Phase 1.5 — proposal queuing: + +```typescript + // Phase 1.5: Queue proposals for entities that crossed the threshold + const registry = world.getSingleton('bondRegistry'); + if (registry) { + for (const entity of entities) { + const social = world.getComponent(entity, 'socialState')!; + if (social.pendingProposal) continue; + if (social.proposalCooldown > 0) { + social.proposalCooldown--; + continue; + } + + const rels = world.getComponent(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' }; + } + } + } +``` + +In Phase 2 (despawn handling), add bond dissolution. Inside the loop where we check for despawned entities, add before the memory/fade logic: + +```typescript + // Dissolve any active bonds with despawned entity + if (registry) { + dissolveBond(registry, entity, otherId, 'partner'); + } +``` + +**Step 4: Run tests to verify they pass** + +Run: `npm -w server run test -- --run server/src/systems/__tests__/relationshipSystem.test.ts` +Expected: PASS + +**Step 5: Run full test suite** + +Run: `npm -w server run test` +Expected: All pass + +**Step 6: Commit** + +```bash +git add server/src/systems/relationshipSystem.ts server/src/systems/__tests__/relationshipSystem.test.ts +git commit -m "feat: add proposal queuing and bond-aware despawn to relationship system" +``` + +--- + +### Task 8: Update SocialSystem — Proposal Interaction Handling + +**Files:** +- Modify: `server/src/systems/socialSystem.ts` +- Modify: `server/src/systems/__tests__/socialSystem.test.ts` + +**Step 1: Write the failing tests** + +Add to `server/src/systems/__tests__/socialSystem.test.ts`: + +```typescript +import { createBondRegistry, hasBond, addBond } from '../bondRegistry.js'; +import { relationshipConfig } from '../../config/relationshipConfig.js'; + +// Update createNPC helper to include new SocialState fields + +describe('proposal interactions', () => { + it('should prioritize proposal target over closest NPC', () => { + const world = new World(); + world.setSingleton('bondRegistry', createBondRegistry()); + const a = createNPC(world, { x: 5, y: 5 }); + const b = createNPC(world, { x: 6, y: 5 }); // closer + const c = createNPC(world, { x: 7, y: 5 }); // proposal target, farther + + const socialA = world.getComponent(a, 'socialState')!; + socialA.pendingProposal = { targetId: c, type: 'partner' }; + + socialSystem(world); + + expect(socialA.partnerId).toBe(c); + expect(socialA.isProposalInteraction).toBe(true); + }); + + it('should form partner bond on accepted proposal', () => { + const world = new World(); + const registry = createBondRegistry(); + world.setSingleton('bondRegistry', registry); + const a = createNPC(world, { x: 5, y: 5 }); + const b = createNPC(world, { x: 6, y: 5 }); + + // Give both high relationship values + const relsA = world.getComponent(a, 'relationships')!; + relsA.set(b, { value: 85, interactions: 10, lastInteractionTick: 0, status: 'active' }); + const relsB = world.getComponent(b, 'relationships')!; + relsB.set(a, { value: 85, interactions: 10, lastInteractionTick: 0, status: 'active' }); + + // Set up proposal interaction in emoting phase about to complete + const socialA = world.getComponent(a, 'socialState')!; + socialA.phase = 'proposing'; + socialA.partnerId = b; + socialA.phaseTimer = 1; + socialA.isProposalInteraction = true; + socialA.pendingProposal = null; + + const socialB = world.getComponent(b, 'socialState')!; + socialB.phase = 'proposing'; + socialB.partnerId = a; + socialB.phaseTimer = 1; + + // Add stats for acceptance roll (high empathy + sociability) + addStats(world, a, { empathy: 15, sociability: 15, temperament: 5 }); + addStats(world, b, { empathy: 15, sociability: 15, temperament: 5 }); + + // Mock Math.random to ensure acceptance + const origRandom = Math.random; + Math.random = () => 0.1; // below 0.85 base chance + stat bonuses + + socialSystem(world); + + Math.random = origRandom; + + expect(hasBond(registry, a, b, 'partner')).toBe(true); + expect(socialA.phase).toBe('none'); + }); + + it('should apply rejection penalties on rejected proposal', () => { + const world = new World(); + const registry = createBondRegistry(); + world.setSingleton('bondRegistry', registry); + const a = createNPC(world, { x: 5, y: 5 }); + const b = createNPC(world, { x: 6, y: 5 }); + + // Proposer has high value, rejecter has low value + const relsA = world.getComponent(a, 'relationships')!; + relsA.set(b, { value: 85, interactions: 10, lastInteractionTick: 0, status: 'active' }); + const relsB = world.getComponent(b, 'relationships')!; + relsB.set(a, { value: 50, interactions: 10, lastInteractionTick: 0, status: 'active' }); // below threshold + + const socialA = world.getComponent(a, 'socialState')!; + socialA.phase = 'proposing'; + socialA.partnerId = b; + socialA.phaseTimer = 1; + socialA.isProposalInteraction = true; + + const socialB = world.getComponent(b, 'socialState')!; + socialB.phase = 'proposing'; + socialB.partnerId = a; + socialB.phaseTimer = 1; + + addStats(world, a, { courage: 10, temperament: 10 }); + addStats(world, b, { temperament: 10 }); + + socialSystem(world); + + expect(hasBond(registry, a, b, 'partner')).toBe(false); + expect(relsA.get(b)!.value).toBeLessThan(85); // proposer took hit + expect(relsB.get(a)!.value).toBeLessThan(50); // rejecter took smaller hit + expect(socialA.proposalCooldown).toBeGreaterThan(0); // cooldown set + }); +}); +``` + +**Step 2: Run tests to verify they fail** + +Run: `npm -w server run test -- --run server/src/systems/__tests__/socialSystem.test.ts` +Expected: FAIL + +**Step 3: Update socialSystem implementation** + +In `server/src/systems/socialSystem.ts`, add imports: + +```typescript +import { PROPOSAL_EMOTING_DURATION } from '@dflike/shared'; +import type { Relationships } from '@dflike/shared'; +import type { BondRegistry } from './bondRegistry.js'; +import { addBond } from './bondRegistry.js'; +import { relationshipConfig as cfg } from '../config/relationshipConfig.js'; +``` + +**Phase 1 changes** — handle the `proposing` phase. In the phase transition block (where `social.phase === 'emoting'` is handled), add a new branch for when the interaction completes the `emoting` phase and `isProposalInteraction` is true: + +After the `emoting → done` transition, when `isProposalInteraction` is true on the initiator, instead of normal emoting→done, transition to `proposing` phase: + +```typescript + } else if (social.phase === 'emoting' && social.isProposalInteraction) { + // Transition to proposing phase (ring emoji) + social.phase = 'proposing'; + social.phaseTimer = PROPOSAL_EMOTING_DURATION; + partnerSocial.phase = 'proposing'; + partnerSocial.phaseTimer = PROPOSAL_EMOTING_DURATION; + } else if (social.phase === 'proposing') { + // Resolve proposal + const registry = world.getSingleton('bondRegistry'); + const initiator = social.isProposalInteraction ? e : partnerId!; + const receiver = social.isProposalInteraction ? partnerId! : e; + + const relsReceiver = world.getComponent(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 + const relsInit = world.getComponent(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 + const modsInit = world.getComponent(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(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(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 initSocial = world.getComponent(initiator, 'socialState')!; + const courage = getEffectiveStat(world, initiator, 'courage'); + initSocial.proposalCooldown = Math.round( + cfg.baseProposalCooldown * (1 - (courage - 10) * cfg.courageCooldownWeight) + ); + // Negative transient mods on proposer + const modsInit = world.getComponent(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 and reset + 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); + + // Emit lastOutcome for relationship system (normal processing) + 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; + social.isProposalInteraction = false; + social.pendingProposal = null; + + partnerSocial.phase = 'none'; + partnerSocial.partnerId = null; + partnerSocial.phaseTimer = 0; + partnerSocial.outcome = null; + partnerSocial.isProposalInteraction = false; + } +``` + +**Phase 2 changes** — when initiating new interactions, check for pending proposals: + +In the detection loop, before the closest-target scan, add: + +```typescript + // Check for pending proposal target first + if (social.pendingProposal) { + const target = social.pendingProposal.targetId; + const targetPos = world.getComponent(target, 'position'); + const targetSocial = world.getComponent(target, 'socialState'); + if (targetPos && targetSocial && targetSocial.phase === 'none' + && !claimedThisTick.has(target) && !targetSocial.globalCooldown) { + const dist = manhattanDist(pos, targetPos); + const perceptionA = getEffectiveStat(world, e, 'perception'); + const awarenessA = AWARENESS_RADIUS + (perceptionA - 10); + if (dist <= awarenessA) { + // Initiate proposal interaction + 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(e, 'movement')!; + const movB = world.getComponent(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 + } + } + } +``` + +**Step 4: Run tests to verify they pass** + +Run: `npm -w server run test -- --run server/src/systems/__tests__/socialSystem.test.ts` +Expected: PASS + +**Step 5: Run full test suite** + +Run: `npm -w server run test` +Expected: All pass + +**Step 6: Commit** + +```bash +git add server/src/systems/socialSystem.ts server/src/systems/__tests__/socialSystem.test.ts +git commit -m "feat: add proposal interaction handling to social system" +``` + +--- + +### Task 9: Update State Serializer + +**Files:** +- Modify: `server/src/network/stateSerializer.ts` + +**Step 1: Update serializer to use bond-aware classification** + +In `server/src/network/stateSerializer.ts`: + +Replace `classify` import with: + +```typescript +import { getEffectiveClassification } from '../systems/relationshipHelpers.js'; +import type { BondRegistry } from '../systems/bondRegistry.js'; +import { hasBond } from '../systems/bondRegistry.js'; +``` + +Update the relationships serialization (around line 27-35): + +```typescript + const relationshipsComponent = world.getComponent(entityId, 'relationships'); + const registry = world.getSingleton('bondRegistry'); + const relationships = relationshipsComponent && relationshipsComponent.size > 0 + ? [...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(otherId, 'name') ?? `NPC #${otherId}`, + value: Math.round(rel.value * 10) / 10, + classification, + status: rel.status, + bond, + }; + }) + : undefined; +``` + +**Step 2: Run full test suite** + +Run: `npm -w server run test` +Expected: All pass + +**Step 3: Commit** + +```bash +git add server/src/network/stateSerializer.ts +git commit -m "feat: use bond-aware classification in state serializer" +``` + +--- + +### Task 10: Update Client — Proposal Emoji + Partner Display + +**Files:** +- Modify: `client/src/ui/InteractionEmoji.ts:3-4,50` +- Modify: `client/src/ui/NpcInfoPanel.ts` (relationship display) + +**Step 1: Add ring emoji for proposals** + +In `client/src/ui/InteractionEmoji.ts`, add a new emoji constant: + +```typescript +const EMOJI_PROPOSAL = '\u{1F48D}'; // 💍 ring +``` + +In the `createEmoji()` method, update emoji selection (around line 50): + +```typescript + // Determine which emoji to show + let emoji: string; + if (entity.socialState?.phase === 'proposing') { + emoji = EMOJI_PROPOSAL; + } else { + emoji = outcome === 'positive' ? EMOJI_POSITIVE : EMOJI_NEGATIVE; + } + el.textContent = emoji; +``` + +Also update the `update()` method to show emojis during `'proposing'` phase (line 18): + +```typescript + if (entity.socialState?.phase !== 'emoting' && entity.socialState?.phase !== 'proposing') { +``` + +**Step 2: Add partner ring icon in NpcInfoPanel** + +In `client/src/ui/NpcInfoPanel.ts`, in the relationship rendering code, add a ring icon next to partner names. Where the name label is created for each relationship entry, check for the `bond` field: + +```typescript + const bondIcon = rel.bond === 'partner' ? ' \u{1F48D}' : + rel.bond === 'former_partner' ? ' \u{1F48D}\u{FE0E}' : ''; + nameEl.textContent = `${rel.name}${bondIcon}`; +``` + +Also add 'Partner' to the tier color map: + +```typescript + Partner: '#ff69b4', // same hot pink, now only from bonds +``` + +**Step 3: Build client to verify no type errors** + +Run: `npm -w client run build` +Expected: Build succeeds + +**Step 4: Commit** + +```bash +git add client/src/ui/InteractionEmoji.ts client/src/ui/NpcInfoPanel.ts +git commit -m "feat: add ring emoji for proposals and partner bond display" +``` + +--- + +### Task 11: Fix Existing Tests for New SocialState Shape + +**Files:** +- Modify: `server/src/systems/__tests__/socialSystem.test.ts` (createNPC helper) +- Modify: `server/src/systems/__tests__/relationshipSystem.test.ts` (createNPC helper) + +This task should be done incrementally during Tasks 6-8 as tests break, but is listed here as a catch-all. + +**Step 1: Update all test createNPC helpers** + +Every test helper that creates a SocialState must include the new fields: + +```typescript + world.addComponent(entity, 'socialState', { + phase: 'none', + partnerId: null, + phaseTimer: 0, + outcome: null, + globalCooldown: 0, + pairCooldowns: new Map(), + lastOutcome: null, + proposalCooldown: 0, + pendingProposal: null, + isProposalInteraction: false, + }); +``` + +**Step 2: Update test assertions that check for 'Partner' classification** + +Any test asserting `classify(80) === 'Partner'` should now assert `'Devoted'`. + +**Step 3: Add bondRegistry singleton to test worlds where needed** + +Tests that run `relationshipSystem()` need: + +```typescript +world.setSingleton('bondRegistry', createBondRegistry()); +``` + +**Step 4: Run full test suite** + +Run: `npm -w server run test` +Expected: All pass + +**Step 5: Commit** + +```bash +git add server/src/systems/__tests__/ +git commit -m "fix: update tests for new SocialState shape and Devoted tier" +``` + +--- + +### Task 12: Rebuild Shared + Final Verification + +**Step 1: Rebuild shared types** + +Run: `npx -w shared tsc` + +**Step 2: Run full server test suite** + +Run: `npm -w server run test` +Expected: All tests pass + +**Step 3: Build client** + +Run: `npm -w client run build` +Expected: Build succeeds + +**Step 4: Final commit if any remaining changes** + +```bash +git add -A +git commit -m "chore: rebuild shared types for mutual bond system" +``` diff --git a/server/src/config/relationshipConfig.ts b/server/src/config/relationshipConfig.ts index 915dea0..4ffef32 100644 --- a/server/src/config/relationshipConfig.ts +++ b/server/src/config/relationshipConfig.ts @@ -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, }; diff --git a/server/src/ecs/World.ts b/server/src/ecs/World.ts index 1b6a946..c1a6ee0 100644 --- a/server/src/ecs/World.ts +++ b/server/src/ecs/World.ts @@ -6,6 +6,7 @@ export class World { private nextId: EntityId = 1; private components: Map> = new Map(); private entities: Set = new Set(); + private singletons: Map = new Map(); createEntity(): EntityId { const id = this.nextId++; @@ -48,4 +49,12 @@ export class World { getAllEntities(): EntityId[] { return [...this.entities]; } + + setSingleton(name: string, data: T): void { + this.singletons.set(name, data); + } + + getSingleton(name: string): T | undefined { + return this.singletons.get(name) as T | undefined; + } } diff --git a/server/src/ecs/__tests__/World.test.ts b/server/src/ecs/__tests__/World.test.ts index ce09e40..fa7a92d 100644 --- a/server/src/ecs/__tests__/World.test.ts +++ b/server/src/ecs/__tests__/World.test.ts @@ -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>('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(); + }); + }); }); diff --git a/server/src/game/GameLoop.ts b/server/src/game/GameLoop.ts index 599bef5..03f8f22 100644 --- a/server/src/game/GameLoop.ts +++ b/server/src/game/GameLoop.ts @@ -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); diff --git a/server/src/game/spawner.ts b/server/src/game/spawner.ts index 4fa8327..42a2ed0 100644 --- a/server/src/game/spawner.ts +++ b/server/src/game/spawner.ts @@ -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(entity, 'stats', generateStats()); world.addComponent(entity, 'statModifiers', { modifiers: [] }); diff --git a/server/src/network/stateSerializer.ts b/server/src/network/stateSerializer.ts index efe0cd8..191535b 100644 --- a/server/src/network/stateSerializer.ts +++ b/server/src/network/stateSerializer.ts @@ -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(entityId, 'socialState'); @@ -24,14 +26,29 @@ export function serializeEntity(world: World, entityId: number): EntityState { temperament: getEffectiveStat(world, entityId, 'temperament'), } : undefined; const relationshipsComponent = world.getComponent(entityId, 'relationships'); + const registry = world.getSingleton('bondRegistry'); const relationships = relationshipsComponent && relationshipsComponent.size > 0 - ? [...relationshipsComponent.entries()].map(([otherId, rel]) => ({ - entityId: otherId, - name: world.getComponent(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(otherId, 'name') ?? `NPC #${otherId}`, + value: Math.round(rel.value * 10) / 10, + classification, + status: rel.status, + bond, + }; + }) : undefined; return { id: entityId, diff --git a/server/src/systems/__tests__/bondRegistry.test.ts b/server/src/systems/__tests__/bondRegistry.test.ts new file mode 100644 index 0000000..2d757b8 --- /dev/null +++ b/server/src/systems/__tests__/bondRegistry.test.ts @@ -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); + }); + }); +}); diff --git a/server/src/systems/__tests__/relationshipHelpers.test.ts b/server/src/systems/__tests__/relationshipHelpers.test.ts index 719a340..5d8c908 100644 --- a/server/src/systems/__tests__/relationshipHelpers.test.ts +++ b/server/src/systems/__tests__/relationshipHelpers.test.ts @@ -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'); + }); + }); }); diff --git a/server/src/systems/__tests__/relationshipSystem.test.ts b/server/src/systems/__tests__/relationshipSystem.test.ts index 8dcf8b0..1b71d6b 100644 --- a/server/src/systems/__tests__/relationshipSystem.test.ts +++ b/server/src/systems/__tests__/relationshipSystem.test.ts @@ -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(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(a, 'relationships')!; + relsA.set(b, { value: 78, interactions: 10, lastInteractionTick: 0, status: 'active' }); + const relsB = world.getComponent(b, 'relationships')!; + relsB.set(a, { value: 78, interactions: 10, lastInteractionTick: 0, status: 'active' }); + + const socialA = world.getComponent(a, 'socialState')!; + socialA.lastOutcome = { partnerId: b, outcome: 'positive', tick: 0 }; + const socialB = world.getComponent(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(a, 'socialState')!.pendingProposal; + const propB = world.getComponent(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(a, 'relationships')!; + relsA.set(b, { value: 85, interactions: 10, lastInteractionTick: 0, status: 'active' }); + const relsB = world.getComponent(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(a, 'socialState')!; + socialA.lastOutcome = { partnerId: b, outcome: 'positive', tick: 0 }; + const socialB = world.getComponent(b, 'socialState')!; + socialB.lastOutcome = { partnerId: a, outcome: 'positive', tick: 0 }; + + relationshipSystem(world); + + expect(world.getComponent(a, 'socialState')!.pendingProposal).toBeNull(); + expect(world.getComponent(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(a, 'relationships')!; + relsA.set(b, { value: 85, interactions: 10, lastInteractionTick: 0, status: 'active' }); + + const socialA = world.getComponent(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(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(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); + }); + }); }); diff --git a/server/src/systems/__tests__/socialSystem.test.ts b/server/src/systems/__tests__/socialSystem.test.ts index b4a1226..e3d73ec 100644 --- a/server/src/systems/__tests__/socialSystem.test.ts +++ b/server/src/systems/__tests__/socialSystem.test.ts @@ -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(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(a, 'socialState')!; + socialA.phase = 'emoting'; + socialA.partnerId = b; + socialA.phaseTimer = 1; + socialA.outcome = 'positive'; + socialA.isProposalInteraction = true; + + const socialB = world.getComponent(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(a, 'relationships', new Map([ + [b, { value: 85, interactions: 10, lastInteractionTick: 0, status: 'active' }] + ])); + world.addComponent(b, 'relationships', new Map([ + [a, { value: 85, interactions: 10, lastInteractionTick: 0, status: 'active' }] + ])); + + // Set up proposal interaction in proposing phase about to complete + const socialA = world.getComponent(a, 'socialState')!; + socialA.phase = 'proposing'; + socialA.partnerId = b; + socialA.phaseTimer = 1; + socialA.isProposalInteraction = true; + + const socialB = world.getComponent(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(a, 'relationships', new Map([ + [b, { value: 85, interactions: 10, lastInteractionTick: 0, status: 'active' }] + ])); + world.addComponent(b, 'relationships', new Map([ + [a, { value: 50, interactions: 10, lastInteractionTick: 0, status: 'active' }] + ])); + + const socialA = world.getComponent(a, 'socialState')!; + socialA.phase = 'proposing'; + socialA.partnerId = b; + socialA.phaseTimer = 1; + socialA.isProposalInteraction = true; + + const socialB = world.getComponent(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(a, 'relationships')!; + expect(relsA.get(b)!.value).toBeLessThan(85); // proposer took hit + const relsB = world.getComponent(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(a, 'relationships', new Map([ + [b, { value: 85, interactions: 10, lastInteractionTick: 0, status: 'active' }] + ])); + world.addComponent(b, 'relationships', new Map([ + [a, { value: 50, interactions: 10, lastInteractionTick: 0, status: 'active' }] + ])); + + const socialA = world.getComponent(a, 'socialState')!; + socialA.phase = 'proposing'; + socialA.partnerId = b; + socialA.phaseTimer = 1; + socialA.isProposalInteraction = true; + + const socialB = world.getComponent(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); + }); + }); }); diff --git a/server/src/systems/__tests__/systems.test.ts b/server/src/systems/__tests__/systems.test.ts index 03c3440..b22e3df 100644 --- a/server/src/systems/__tests__/systems.test.ts +++ b/server/src/systems/__tests__/systems.test.ts @@ -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, }); } diff --git a/server/src/systems/bondRegistry.ts b/server/src/systems/bondRegistry.ts new file mode 100644 index 0000000..c88cf6e --- /dev/null +++ b/server/src/systems/bondRegistry.ts @@ -0,0 +1,59 @@ +import type { EntityId } from '@dflike/shared'; + +export interface Bond { + type: string; + formedAtTick: number; + status: 'active' | 'former'; +} + +export type BondRegistry = Map; + +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; +} diff --git a/server/src/systems/relationshipHelpers.ts b/server/src/systems/relationshipHelpers.ts index c1694e7..fe8eaf8 100644 --- a/server/src/systems/relationshipHelpers.ts +++ b/server/src/systems/relationshipHelpers.ts @@ -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, 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; diff --git a/server/src/systems/relationshipSystem.ts b/server/src/systems/relationshipSystem.ts index c54bce1..c5fa14f 100644 --- a/server/src/systems/relationshipSystem.ts +++ b/server/src/systems/relationshipSystem.ts @@ -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(); + const registry = world.getSingleton('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(entity, 'socialState')!; + if (social.pendingProposal) continue; + if (social.proposalCooldown > 0) { + social.proposalCooldown--; + continue; + } + + const rels = world.getComponent(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(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'; diff --git a/server/src/systems/socialSystem.ts b/server/src/systems/socialSystem.ts index 71abe37..747d064 100644 --- a/server/src/systems/socialSystem.ts +++ b/server/src/systems/socialSystem.ts @@ -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(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(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(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(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'); + // 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(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(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(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(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(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(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(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(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(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(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(e, 'position')!; + // Check for pending proposal target first + if (social.pendingProposal) { + const target = social.pendingProposal.targetId; + const targetPos = world.getComponent(target, 'position'); + const targetSocial = world.getComponent(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(e, 'movement')!; + const movB = world.getComponent(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; diff --git a/shared/src/constants.ts b/shared/src/constants.ts index ccc2a1f..e5a3fff 100644 --- a/shared/src/constants.ts +++ b/shared/src/constants.ts @@ -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; diff --git a/shared/src/types.ts b/shared/src/types.ts index 756f37c..47f2a96 100644 --- a/shared/src/types.ts +++ b/shared/src/types.ts @@ -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; 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; }>; }