diff --git a/server/src/systems/__tests__/socialSystem.test.ts b/server/src/systems/__tests__/socialSystem.test.ts index d3afade..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, @@ -457,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/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;