feat: add proposal interaction handling to social system

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
root
2026-03-07 19:29:54 +00:00
parent 66596fb96d
commit 0bfeef7fa1
2 changed files with 360 additions and 54 deletions
@@ -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<SocialState>(a, 'socialState')!;
socialA.pendingProposal = { targetId: c, type: 'partner' };
socialSystem(world);
expect(socialA.partnerId).toBe(c);
expect(socialA.isProposalInteraction).toBe(true);
});
it('should transition from emoting to proposing when isProposalInteraction is set', () => {
const world = new World();
world.setSingleton('bondRegistry', createBondRegistry());
const a = createNPC(world, 5, 5);
const b = createNPC(world, 6, 5);
const socialA = world.getComponent<SocialState>(a, 'socialState')!;
socialA.phase = 'emoting';
socialA.partnerId = b;
socialA.phaseTimer = 1;
socialA.outcome = 'positive';
socialA.isProposalInteraction = true;
const socialB = world.getComponent<SocialState>(b, 'socialState')!;
socialB.phase = 'emoting';
socialB.partnerId = a;
socialB.phaseTimer = 1;
socialB.outcome = 'positive';
socialSystem(world);
expect(socialA.phase).toBe('proposing');
expect(socialB.phase).toBe('proposing');
});
it('should form partner bond on accepted proposal', () => {
const world = new World();
const registry = createBondRegistry();
world.setSingleton('bondRegistry', registry);
const a = createNPC(world, 5, 5);
const b = createNPC(world, 6, 5);
// Give both relationship components with high values
world.addComponent<Relationships>(a, 'relationships', new Map<number, RelationshipData>([
[b, { value: 85, interactions: 10, lastInteractionTick: 0, status: 'active' }]
]));
world.addComponent<Relationships>(b, 'relationships', new Map<number, RelationshipData>([
[a, { value: 85, interactions: 10, lastInteractionTick: 0, status: 'active' }]
]));
// Set up proposal interaction in proposing phase about to complete
const socialA = world.getComponent<SocialState>(a, 'socialState')!;
socialA.phase = 'proposing';
socialA.partnerId = b;
socialA.phaseTimer = 1;
socialA.isProposalInteraction = true;
const socialB = world.getComponent<SocialState>(b, 'socialState')!;
socialB.phase = 'proposing';
socialB.partnerId = a;
socialB.phaseTimer = 1;
// Add stats for acceptance (high empathy/sociability, low temperament)
addStats(world, b, { empathy: 15, sociability: 15, temperament: 5 });
// Mock Math.random to ensure acceptance
vi.spyOn(Math, 'random').mockReturnValue(0.1);
socialSystem(world);
vi.restoreAllMocks();
expect(hasBond(registry, a, b, 'partner')).toBe(true);
expect(socialA.phase).toBe('none');
});
it('should apply rejection penalties when receiver value below threshold', () => {
const world = new World();
const registry = createBondRegistry();
world.setSingleton('bondRegistry', registry);
const a = createNPC(world, 5, 5);
const b = createNPC(world, 6, 5);
// Proposer high, receiver low
world.addComponent<Relationships>(a, 'relationships', new Map<number, RelationshipData>([
[b, { value: 85, interactions: 10, lastInteractionTick: 0, status: 'active' }]
]));
world.addComponent<Relationships>(b, 'relationships', new Map<number, RelationshipData>([
[a, { value: 50, interactions: 10, lastInteractionTick: 0, status: 'active' }]
]));
const socialA = world.getComponent<SocialState>(a, 'socialState')!;
socialA.phase = 'proposing';
socialA.partnerId = b;
socialA.phaseTimer = 1;
socialA.isProposalInteraction = true;
const socialB = world.getComponent<SocialState>(b, 'socialState')!;
socialB.phase = 'proposing';
socialB.partnerId = a;
socialB.phaseTimer = 1;
addStats(world, a, { courage: 10, temperament: 10, sociability: 10 });
addStats(world, b, { temperament: 10, sociability: 10 });
socialSystem(world);
expect(hasBond(registry, a, b, 'partner')).toBe(false);
// addStats already adds statModifiers, so penalty mods should be applied
const relsA = world.getComponent<Relationships>(a, 'relationships')!;
expect(relsA.get(b)!.value).toBeLessThan(85); // proposer took hit
const relsB = world.getComponent<Relationships>(b, 'relationships')!;
expect(relsB.get(a)!.value).toBeLessThan(50); // rejecter took smaller hit
expect(socialA.proposalCooldown).toBeGreaterThan(0);
});
it('should set proposal cooldown scaled by courage on rejection', () => {
const world = new World();
world.setSingleton('bondRegistry', createBondRegistry());
const a = createNPC(world, 5, 5);
const b = createNPC(world, 6, 5);
world.addComponent<Relationships>(a, 'relationships', new Map<number, RelationshipData>([
[b, { value: 85, interactions: 10, lastInteractionTick: 0, status: 'active' }]
]));
world.addComponent<Relationships>(b, 'relationships', new Map<number, RelationshipData>([
[a, { value: 50, interactions: 10, lastInteractionTick: 0, status: 'active' }]
]));
const socialA = world.getComponent<SocialState>(a, 'socialState')!;
socialA.phase = 'proposing';
socialA.partnerId = b;
socialA.phaseTimer = 1;
socialA.isProposalInteraction = true;
const socialB = world.getComponent<SocialState>(b, 'socialState')!;
socialB.phase = 'proposing';
socialB.partnerId = a;
socialB.phaseTimer = 1;
// High courage = shorter cooldown
addStats(world, a, { courage: 18, temperament: 10, sociability: 10 });
addStats(world, b, { temperament: 10, sociability: 10 });
socialSystem(world);
// Base 500, courage 18: 500 * (1 - (18-10)*0.04) = 500 * 0.68 = 340
expect(socialA.proposalCooldown).toBe(340);
});
});
});
+198 -52
View File
@@ -2,11 +2,16 @@ import {
AWARENESS_RADIUS, FACING_DURATION, PAUSING_DURATION, EMOTING_DURATION,
SOCIAL_GLOBAL_COOLDOWN, SOCIAL_PAIR_COOLDOWN,
HUNGER_THRESHOLD, ENERGY_THRESHOLD, Direction,
PROPOSAL_EMOTING_DURATION,
type SocialState, type Position, type Movement, type NPCBrain, type Needs, type EntityId,
type Stats, type StatModifiers,
} from '@dflike/shared';
import type { Relationships } from '@dflike/shared';
import type { World } from '../ecs/World.js';
import { getEffectiveStat } from './statHelpers.js';
import type { BondRegistry } from './bondRegistry.js';
import { addBond } from './bondRegistry.js';
import { relationshipConfig as cfg } from '../config/relationshipConfig.js';
function directionTo(from: Position, to: Position): number {
const dx = to.x - from.x;
@@ -100,75 +105,182 @@ export function socialSystem(world: World): void {
partnerSocial.phaseTimer = EMOTING_DURATION;
partnerSocial.outcome = Math.random() < positiveChanceB ? 'positive' : 'negative';
} else if (social.phase === 'emoting') {
// Check if this is a proposal interaction
if (social.isProposalInteraction || partnerSocial.isProposalInteraction) {
// Transition to proposing phase (ring emoji on client)
social.phase = 'proposing';
social.phaseTimer = PROPOSAL_EMOTING_DURATION;
partnerSocial.phase = 'proposing';
partnerSocial.phaseTimer = PROPOSAL_EMOTING_DURATION;
} else {
// Normal emoting→done transition
const socA = getEffectiveStat(world, e, 'sociability');
social.globalCooldown = Math.round(SOCIAL_GLOBAL_COOLDOWN * (1 - (socA - 10) * 0.04));
social.pairCooldowns.set(partnerId!, SOCIAL_PAIR_COOLDOWN);
const socB = getEffectiveStat(world, partnerId!, 'sociability');
partnerSocial.globalCooldown = Math.round(SOCIAL_GLOBAL_COOLDOWN * (1 - (socB - 10) * 0.04));
partnerSocial.pairCooldowns.set(e, SOCIAL_PAIR_COOLDOWN);
// Baseline stat drift
const statsA = world.getComponent<Stats>(e, 'stats');
if (statsA) {
if (social.outcome === 'positive') {
statsA.sociability = Math.max(3, Math.min(18, statsA.sociability + 0.05));
statsA.empathy = Math.max(3, Math.min(18, statsA.empathy + 0.05));
} else {
statsA.temperament = Math.max(3, Math.min(18, statsA.temperament + 0.02));
}
}
const statsB = world.getComponent<Stats>(partnerId!, 'stats');
if (statsB) {
if (partnerSocial.outcome === 'positive') {
statsB.sociability = Math.max(3, Math.min(18, statsB.sociability + 0.05));
statsB.empathy = Math.max(3, Math.min(18, statsB.empathy + 0.05));
} else {
statsB.temperament = Math.max(3, Math.min(18, statsB.temperament + 0.02));
}
}
// Event-based transient modifiers
const modsA = world.getComponent<StatModifiers>(e, 'statModifiers');
if (modsA) {
if (social.outcome === 'positive') {
modsA.modifiers.push({ stat: 'sociability', value: 1, remaining: 100 });
} else {
modsA.modifiers.push({ stat: 'sociability', value: -1, remaining: 100 });
modsA.modifiers.push({ stat: 'empathy', value: -1, remaining: 100 });
}
}
const modsB = world.getComponent<StatModifiers>(partnerId!, 'statModifiers');
if (modsB) {
if (partnerSocial.outcome === 'positive') {
modsB.modifiers.push({ stat: 'sociability', value: 1, remaining: 100 });
} else {
modsB.modifiers.push({ stat: 'sociability', value: -1, remaining: 100 });
modsB.modifiers.push({ stat: 'empathy', value: -1, remaining: 100 });
}
}
// Emit completed interaction for relationship system
social.lastOutcome = {
partnerId: partnerId!,
outcome: social.outcome!,
tick: 0,
};
partnerSocial.lastOutcome = {
partnerId: e,
outcome: partnerSocial.outcome!,
tick: 0,
};
social.phase = 'none';
social.partnerId = null;
social.phaseTimer = 0;
social.outcome = null;
partnerSocial.phase = 'none';
partnerSocial.partnerId = null;
partnerSocial.phaseTimer = 0;
partnerSocial.outcome = null;
}
} else if (social.phase === 'proposing') {
// Resolve the proposal
const registry = world.getSingleton<BondRegistry>('bondRegistry');
// Determine who is the initiator (the one with isProposalInteraction=true)
const initiator = social.isProposalInteraction ? e : partnerId!;
const receiver = social.isProposalInteraction ? partnerId! : e;
const initiatorSocial = social.isProposalInteraction ? social : partnerSocial;
const receiverSocial = social.isProposalInteraction ? partnerSocial : social;
const relsReceiver = world.getComponent<Relationships>(receiver, 'relationships');
const receiverValue = relsReceiver?.get(initiator)?.value ?? 0;
let accepted = false;
if (receiverValue >= cfg.proposalAcceptanceThreshold) {
const recEmpathy = getEffectiveStat(world, receiver, 'empathy');
const recSociability = getEffectiveStat(world, receiver, 'sociability');
const recTemperament = getEffectiveStat(world, receiver, 'temperament');
const chance = cfg.proposalBaseAcceptanceChance
+ (recEmpathy - 10) * cfg.proposalEmpathyWeight
+ (recSociability - 10) * cfg.proposalSociabilityWeight
+ (recTemperament - 10) * cfg.proposalTemperamentWeight;
accepted = Math.random() < chance;
}
if (accepted && registry) {
addBond(registry, initiator, receiver, 'partner', 0);
// Sentiment boost on both sides
const relsInit = world.getComponent<Relationships>(initiator, 'relationships');
const relInit = relsInit?.get(receiver);
if (relInit) relInit.value = Math.min(100, relInit.value + cfg.proposalAcceptanceBonus);
const relRec = relsReceiver?.get(initiator);
if (relRec) relRec.value = Math.min(100, relRec.value + cfg.proposalAcceptanceBonus);
// Positive transient mods on both
const modsInit = world.getComponent<StatModifiers>(initiator, 'statModifiers');
if (modsInit) {
modsInit.modifiers.push({ stat: 'sociability', value: cfg.proposalAcceptanceSociabilityMod, remaining: cfg.proposalModDecayTicks });
modsInit.modifiers.push({ stat: 'empathy', value: cfg.proposalAcceptanceEmpathyMod, remaining: cfg.proposalModDecayTicks });
}
const modsRec = world.getComponent<StatModifiers>(receiver, 'statModifiers');
if (modsRec) {
modsRec.modifiers.push({ stat: 'sociability', value: cfg.proposalAcceptanceSociabilityMod, remaining: cfg.proposalModDecayTicks });
modsRec.modifiers.push({ stat: 'empathy', value: cfg.proposalAcceptanceEmpathyMod, remaining: cfg.proposalModDecayTicks });
}
social.outcome = 'positive';
partnerSocial.outcome = 'positive';
} else {
// Rejection penalties
const relsInit = world.getComponent<Relationships>(initiator, 'relationships');
const relInit = relsInit?.get(receiver);
if (relInit) {
relInit.value = Math.max(-100, relInit.value - cfg.baseDeltaNegative * cfg.proposalRejectionMultiplier);
}
const relRec = relsReceiver?.get(initiator);
if (relRec) {
relRec.value = Math.max(-100, relRec.value - cfg.baseDeltaNegative * cfg.proposalRejectionRejecterMultiplier);
}
// Proposal cooldown on initiator, scaled by courage
const courage = getEffectiveStat(world, initiator, 'courage');
initiatorSocial.proposalCooldown = Math.round(
cfg.baseProposalCooldown * (1 - (courage - 10) * cfg.courageCooldownWeight)
);
// Negative transient mods on proposer
const modsInit = world.getComponent<StatModifiers>(initiator, 'statModifiers');
if (modsInit) {
modsInit.modifiers.push({ stat: 'sociability', value: cfg.proposalRejectionSociabilityMod, remaining: cfg.proposalModDecayTicks });
modsInit.modifiers.push({ stat: 'empathy', value: cfg.proposalRejectionEmpathyMod, remaining: cfg.proposalModDecayTicks });
}
social.outcome = 'negative';
partnerSocial.outcome = 'negative';
}
// Set cooldowns (same as normal interaction end)
const socA = getEffectiveStat(world, e, 'sociability');
social.globalCooldown = Math.round(SOCIAL_GLOBAL_COOLDOWN * (1 - (socA - 10) * 0.04));
social.pairCooldowns.set(partnerId!, SOCIAL_PAIR_COOLDOWN);
const socB = getEffectiveStat(world, partnerId!, 'sociability');
partnerSocial.globalCooldown = Math.round(SOCIAL_GLOBAL_COOLDOWN * (1 - (socB - 10) * 0.04));
partnerSocial.pairCooldowns.set(e, SOCIAL_PAIR_COOLDOWN);
// Baseline stat drift
const statsA = world.getComponent<Stats>(e, 'stats');
if (statsA) {
if (social.outcome === 'positive') {
statsA.sociability = Math.max(3, Math.min(18, statsA.sociability + 0.05));
statsA.empathy = Math.max(3, Math.min(18, statsA.empathy + 0.05));
} else {
statsA.temperament = Math.max(3, Math.min(18, statsA.temperament + 0.02));
}
}
const statsB = world.getComponent<Stats>(partnerId!, 'stats');
if (statsB) {
if (partnerSocial.outcome === 'positive') {
statsB.sociability = Math.max(3, Math.min(18, statsB.sociability + 0.05));
statsB.empathy = Math.max(3, Math.min(18, statsB.empathy + 0.05));
} else {
statsB.temperament = Math.max(3, Math.min(18, statsB.temperament + 0.02));
}
}
// Event-based transient modifiers
const modsA = world.getComponent<StatModifiers>(e, 'statModifiers');
if (modsA) {
if (social.outcome === 'positive') {
modsA.modifiers.push({ stat: 'sociability', value: 1, remaining: 100 });
} else {
modsA.modifiers.push({ stat: 'sociability', value: -1, remaining: 100 });
modsA.modifiers.push({ stat: 'empathy', value: -1, remaining: 100 });
}
}
const modsB = world.getComponent<StatModifiers>(partnerId!, 'statModifiers');
if (modsB) {
if (partnerSocial.outcome === 'positive') {
modsB.modifiers.push({ stat: 'sociability', value: 1, remaining: 100 });
} else {
modsB.modifiers.push({ stat: 'sociability', value: -1, remaining: 100 });
modsB.modifiers.push({ stat: 'empathy', value: -1, remaining: 100 });
}
}
// Emit completed interaction for relationship system
social.lastOutcome = {
partnerId: partnerId!,
outcome: social.outcome!,
tick: 0,
};
partnerSocial.lastOutcome = {
partnerId: e,
outcome: partnerSocial.outcome!,
tick: 0,
};
// Emit lastOutcome for relationship system
social.lastOutcome = { partnerId: partnerId!, outcome: social.outcome!, tick: 0 };
partnerSocial.lastOutcome = { partnerId: e, outcome: partnerSocial.outcome!, tick: 0 };
// Reset both entities
social.phase = 'none';
social.partnerId = null;
social.phaseTimer = 0;
social.outcome = null;
social.isProposalInteraction = false;
social.pendingProposal = null;
partnerSocial.phase = 'none';
partnerSocial.partnerId = null;
partnerSocial.phaseTimer = 0;
partnerSocial.outcome = null;
partnerSocial.isProposalInteraction = false;
partnerSocial.pendingProposal = null;
}
}
}
@@ -181,6 +293,40 @@ export function socialSystem(world: World): void {
const pos = world.getComponent<Position>(e, 'position')!;
// Check for pending proposal target first
if (social.pendingProposal) {
const target = social.pendingProposal.targetId;
const targetPos = world.getComponent<Position>(target, 'position');
const targetSocial = world.getComponent<SocialState>(target, 'socialState');
if (targetPos && targetSocial && targetSocial.phase === 'none'
&& !claimedThisTick.has(target) && targetSocial.globalCooldown <= 0) {
const dist = manhattanDist(pos, targetPos);
const perceptionA = getEffectiveStat(world, e, 'perception');
const awarenessA = AWARENESS_RADIUS + (perceptionA - 10);
if (dist <= awarenessA) {
social.phase = 'facing';
social.partnerId = target;
social.phaseTimer = FACING_DURATION;
social.isProposalInteraction = true;
targetSocial.phase = 'facing';
targetSocial.partnerId = e;
targetSocial.phaseTimer = FACING_DURATION;
const movA = world.getComponent<Movement>(e, 'movement')!;
const movB = world.getComponent<Movement>(target, 'movement')!;
movA.direction = directionTo(pos, targetPos);
movA.state = 'idle';
movB.direction = directionTo(targetPos, pos);
movB.state = 'idle';
claimedThisTick.add(e);
claimedThisTick.add(target);
continue; // skip normal target scan
}
}
}
let closestDist = Infinity;
let closestTarget: EntityId | null = null;