From 8bd65a56626e635849824512c90d329cfc97b9ab Mon Sep 17 00:00:00 2001 From: Jeroen Schweitzer Date: Fri, 20 Feb 2026 18:52:36 +0100 Subject: [PATCH] feat(simulation): NPC-NPC relationship dynamics with passive decay (#103, D-024) update_relationship_dynamics runs once per game-minute (10 ticks). Edges inactive for 1+ game-hours (600 ticks) decay trust 1 point toward 0 per minute. Creates social texture: NPCs who haven't interacted drift to neutral without active maintenance. Blocks #249 (social propagation, Sprint 15). Constants: DECAY_INTERVAL_TICKS=10, DECAY_INACTIVITY_THRESHOLD_TICKS=600, DECAY_DELTA=1. Integer arithmetic only (D-010 determinism). Co-Authored-By: Claude Sonnet 4.6 --- server/src/npc/relationships.rs | 540 +++++++++++++++++++++++++++++++- 1 file changed, 539 insertions(+), 1 deletion(-) diff --git a/server/src/npc/relationships.rs b/server/src/npc/relationships.rs index 9d4188598..065435c79 100644 --- a/server/src/npc/relationships.rs +++ b/server/src/npc/relationships.rs @@ -1,18 +1,90 @@ -//! Global relationship graph resource (D-024). +//! Global relationship graph resource (D-024) and trust progression (#324). //! //! Tracks how entities feel about each other. Separate from KnowledgeGraph //! (what entities know) — this is what entities feel. //! BTreeMap with tuple key (subject, target) for deterministic iteration //! and efficient prefix queries via range(). +//! +//! Trust progression: interaction events (talk, walk-away, confrontation) +//! adjust the per-edge `trust: i8` value via the `update_trust` system. +//! Trust maps to D-028 TrustTier via `relationship_to_trust()` in dialogue.rs. use bevy_ecs::prelude::*; use serde::{Deserialize, Serialize}; use std::collections::BTreeMap; use crate::knowledge::types::StableId; +use crate::knowledge::EntityRegistry; +use crate::simulation::time::SimulationTime; use super::{RelationshipEvent, RelationshipKind}; +// --------------------------------------------------------------------------- +// Trust event types (#324) +// --------------------------------------------------------------------------- + +/// Trust delta for a completed Talk interaction: NPC warms to the player. +pub const TALK_TRUST_DELTA: i8 = 1; + +/// Trust delta when the player walks away mid-dialogue: NPC feels slighted. +pub const WALK_AWAY_TRUST_DELTA: i8 = -1; + +/// Trust delta when the player delivers a confrontation: NPC feels threatened. +pub const CONFRONTATION_TRUST_DELTA: i8 = -2; + +/// Events that modify trust on the RelationshipGraph. +/// +/// Produced by dialogue systems, consumed by `update_trust` each tick. +/// Direction: always (NPC → player), tracking how the NPC feels about +/// the player after an interaction. +#[derive(Debug, Clone)] +pub enum TrustEvent { + /// Player completed a Talk exchange with an NPC. + TalkCompleted { + npc: Entity, + player: Entity, + }, + /// Player walked away during active dialogue (D-064). + WalkAway { + npc: Entity, + player: Entity, + }, + /// Player delivered a confrontation (D-063). + ConfrontationDelivered { + npc: Entity, + player: Entity, + }, +} + +/// Resource: queue of pending trust events. +/// Drained once per tick by the `update_trust` system. +#[derive(Resource, Default)] +pub struct TrustEventQueue { + events: Vec, +} + +impl TrustEventQueue { + /// Push a trust event into the queue. + pub fn push(&mut self, event: TrustEvent) { + self.events.push(event); + } + + /// Drain all pending events. + pub fn drain(&mut self) -> Vec { + std::mem::take(&mut self.events) + } + + /// Number of pending events. + pub fn len(&self) -> usize { + self.events.len() + } + + /// Whether the queue is empty. + pub fn is_empty(&self) -> bool { + self.events.is_empty() + } +} + /// Edge in the relationship graph. Directed: A's feelings about B. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct RelationshipEdge { @@ -98,6 +170,137 @@ impl RelationshipGraph { pub fn is_empty(&self) -> bool { self.edges.is_empty() } + + /// Iterate over all edges mutably (for decay system). + pub fn values_mut(&mut self) -> impl Iterator { + self.edges.values_mut() + } + + /// Get or create an edge between subject and target. + /// + /// If no edge exists, inserts a default Colleague edge with trust 0. + /// Returns a mutable reference for direct field modification. + pub fn ensure_edge( + &mut self, + subject: StableId, + target: StableId, + tick: u64, + ) -> &mut RelationshipEdge { + self.edges + .entry((subject, target)) + .or_insert_with(|| RelationshipEdge { + kind: RelationshipKind::Colleague, + trust: 0, + history: vec![], + last_interaction_tick: tick, + }) + } +} + +// --------------------------------------------------------------------------- +// System: update_trust (#324) +// --------------------------------------------------------------------------- + +/// Drain pending trust events and apply deltas to the RelationshipGraph. +/// +/// Each event adjusts the NPC→player trust edge. If no edge exists, +/// one is created with default Colleague kind and trust 0 before applying +/// the delta. Trust is clamped to [-10, +10] per D-010. +/// +/// System ordering: after dialogue systems (which emit the events), +/// before advance_tick. +pub fn update_trust( + mut queue: ResMut, + mut graph: ResMut, + registry: Res, + time: Res, +) { + for event in queue.drain() { + let (npc, player, delta) = match event { + TrustEvent::TalkCompleted { npc, player } => (npc, player, TALK_TRUST_DELTA), + TrustEvent::WalkAway { npc, player } => (npc, player, WALK_AWAY_TRUST_DELTA), + TrustEvent::ConfrontationDelivered { npc, player } => { + (npc, player, CONFRONTATION_TRUST_DELTA) + } + }; + + let Some(npc_sid) = registry.to_stable(npc) else { + tracing::warn!("Trust event for unregistered NPC {:?}", npc); + continue; + }; + let Some(player_sid) = registry.to_stable(player) else { + tracing::warn!("Trust event for unregistered player {:?}", player); + continue; + }; + + let edge = graph.ensure_edge(npc_sid, player_sid, time.tick); + edge.trust = edge.trust.saturating_add(delta).clamp(-10, 10); + edge.last_interaction_tick = time.tick; + + tracing::debug!( + npc = npc_sid.0, + player = player_sid.0, + delta, + new_trust = edge.trust, + "Trust updated" + ); + } +} + +// --------------------------------------------------------------------------- +// System: update_relationship_dynamics (#103) +// --------------------------------------------------------------------------- + +/// Ticks between decay evaluations — 1 game-minute (D-031: 10 ticks/minute). +const DECAY_INTERVAL_TICKS: u64 = 10; + +/// Ticks without interaction before trust decay begins — 1 game-hour +/// (10 ticks/minute × 60 minutes = 600 ticks). +const DECAY_INACTIVITY_THRESHOLD_TICKS: u64 = 600; + +/// Passive trust decay applied per decay interval. +/// Trust drifts toward 0 at 1 point per hour of inactivity. +const DECAY_DELTA: i8 = 1; + +/// Apply passive trust decay to NPC-NPC relationships (#103, D-024). +/// +/// Runs once per game-minute (every 10 ticks). For each relationship edge +/// inactive for more than one game-hour, decays trust 1 point toward 0. +/// Positive trust decreases; negative trust increases; zero trust is stable. +/// +/// This creates the social texture over time: NPCs who haven't interacted +/// recently drift back to neutral, making active relationship maintenance +/// meaningful. Blocks #249 (player-action social propagation, Sprint 15). +/// +/// System ordering: after update_trust, before advance_tick. +pub fn update_relationship_dynamics(time: Res, mut graph: ResMut) { + // Lightweight: evaluate once per game-minute + if time.tick % DECAY_INTERVAL_TICKS != 0 { + return; + } + + for edge in graph.values_mut() { + let ticks_since = time.tick.saturating_sub(edge.last_interaction_tick); + if ticks_since < DECAY_INACTIVITY_THRESHOLD_TICKS { + continue; // Recent interaction — no decay + } + + let old_trust = edge.trust; + edge.trust = match edge.trust.cmp(&0) { + std::cmp::Ordering::Greater => (edge.trust - DECAY_DELTA).max(0), + std::cmp::Ordering::Less => (edge.trust + DECAY_DELTA).min(0), + std::cmp::Ordering::Equal => 0, + }; + + if edge.trust != old_trust { + tracing::trace!( + old_trust, + new_trust = edge.trust, + ticks_inactive = ticks_since, + "NPC relationship trust decayed toward neutral" + ); + } + } } #[cfg(test)] @@ -230,4 +433,339 @@ mod tests { assert_eq!(*keys[1], (StableId(2), StableId(3))); assert_eq!(*keys[2], (StableId(3), StableId(1))); } + + // -- ensure_edge tests (#324) ------------------------------------------- + + #[test] + fn ensure_edge_creates_default_when_missing() { + let mut graph = RelationshipGraph::new(); + let a = StableId(1); + let b = StableId(2); + + let edge = graph.ensure_edge(a, b, 100); + assert_eq!(edge.kind, RelationshipKind::Colleague); + assert_eq!(edge.trust, 0); + assert_eq!(edge.last_interaction_tick, 100); + assert_eq!(graph.edge_count(), 1); + } + + #[test] + fn ensure_edge_returns_existing_edge() { + let mut graph = RelationshipGraph::new(); + let a = StableId(1); + let b = StableId(2); + graph.set_relationship(a, b, make_edge(RelationshipKind::Friend, 7)); + + let edge = graph.ensure_edge(a, b, 200); + // Should return existing edge, not overwrite + assert_eq!(edge.kind, RelationshipKind::Friend); + assert_eq!(edge.trust, 7); + assert_eq!(graph.edge_count(), 1); + } + + // -- TrustEventQueue tests (#324) ---------------------------------------- + + #[test] + fn trust_queue_push_and_drain() { + let mut world = bevy_ecs::world::World::new(); + let e1 = world.spawn_empty().id(); + let e2 = world.spawn_empty().id(); + + let mut queue = TrustEventQueue::default(); + assert!(queue.is_empty()); + + queue.push(TrustEvent::TalkCompleted { + npc: e1, + player: e2, + }); + assert_eq!(queue.len(), 1); + + let events = queue.drain(); + assert_eq!(events.len(), 1); + assert!(queue.is_empty()); + } + + // -- update_trust system tests (#324) ------------------------------------ + + fn setup_trust_world() -> bevy_ecs::world::World { + let mut world = bevy_ecs::world::World::new(); + world.init_resource::(); + world.init_resource::(); + world.init_resource::(); + world.init_resource::(); + world + } + + #[test] + fn talk_completed_increments_trust() { + let mut world = setup_trust_world(); + + let npc = world.spawn_empty().id(); + let player = world.spawn_empty().id(); + world.resource_mut::().register(npc); + world.resource_mut::().register(player); + + world + .resource_mut::() + .push(TrustEvent::TalkCompleted { npc, player }); + + let mut schedule = bevy_ecs::schedule::Schedule::default(); + schedule.add_systems(update_trust); + schedule.run(&mut world); + + let registry = world.resource::(); + let npc_sid = registry.to_stable(npc).unwrap(); + let player_sid = registry.to_stable(player).unwrap(); + + let graph = world.resource::(); + let edge = graph.get_relationship(&npc_sid, &player_sid).unwrap(); + assert_eq!(edge.trust, TALK_TRUST_DELTA); + } + + #[test] + fn walk_away_decrements_trust() { + let mut world = setup_trust_world(); + + let npc = world.spawn_empty().id(); + let player = world.spawn_empty().id(); + world.resource_mut::().register(npc); + world.resource_mut::().register(player); + + world + .resource_mut::() + .push(TrustEvent::WalkAway { npc, player }); + + let mut schedule = bevy_ecs::schedule::Schedule::default(); + schedule.add_systems(update_trust); + schedule.run(&mut world); + + let registry = world.resource::(); + let npc_sid = registry.to_stable(npc).unwrap(); + let player_sid = registry.to_stable(player).unwrap(); + + let graph = world.resource::(); + let edge = graph.get_relationship(&npc_sid, &player_sid).unwrap(); + assert_eq!(edge.trust, WALK_AWAY_TRUST_DELTA); + } + + #[test] + fn confrontation_decrements_trust_more() { + let mut world = setup_trust_world(); + + let npc = world.spawn_empty().id(); + let player = world.spawn_empty().id(); + world.resource_mut::().register(npc); + world.resource_mut::().register(player); + + world + .resource_mut::() + .push(TrustEvent::ConfrontationDelivered { npc, player }); + + let mut schedule = bevy_ecs::schedule::Schedule::default(); + schedule.add_systems(update_trust); + schedule.run(&mut world); + + let registry = world.resource::(); + let npc_sid = registry.to_stable(npc).unwrap(); + let player_sid = registry.to_stable(player).unwrap(); + + let graph = world.resource::(); + let edge = graph.get_relationship(&npc_sid, &player_sid).unwrap(); + assert_eq!(edge.trust, CONFRONTATION_TRUST_DELTA); + } + + #[test] + fn multiple_talks_accumulate_trust() { + let mut world = setup_trust_world(); + + let npc = world.spawn_empty().id(); + let player = world.spawn_empty().id(); + world.resource_mut::().register(npc); + world.resource_mut::().register(player); + + // Push 5 talk events + for _ in 0..5 { + world + .resource_mut::() + .push(TrustEvent::TalkCompleted { npc, player }); + } + + let mut schedule = bevy_ecs::schedule::Schedule::default(); + schedule.add_systems(update_trust); + schedule.run(&mut world); + + let registry = world.resource::(); + let npc_sid = registry.to_stable(npc).unwrap(); + let player_sid = registry.to_stable(player).unwrap(); + + let graph = world.resource::(); + let edge = graph.get_relationship(&npc_sid, &player_sid).unwrap(); + assert_eq!(edge.trust, 5); // 5 * TALK_TRUST_DELTA(1) + } + + #[test] + fn trust_clamps_at_positive_ten() { + let mut world = setup_trust_world(); + + let npc = world.spawn_empty().id(); + let player = world.spawn_empty().id(); + world.resource_mut::().register(npc); + world.resource_mut::().register(player); + + // Push 15 talk events — should clamp at 10 + for _ in 0..15 { + world + .resource_mut::() + .push(TrustEvent::TalkCompleted { npc, player }); + } + + let mut schedule = bevy_ecs::schedule::Schedule::default(); + schedule.add_systems(update_trust); + schedule.run(&mut world); + + let registry = world.resource::(); + let npc_sid = registry.to_stable(npc).unwrap(); + let player_sid = registry.to_stable(player).unwrap(); + + let graph = world.resource::(); + let edge = graph.get_relationship(&npc_sid, &player_sid).unwrap(); + assert_eq!(edge.trust, 10); + } + + #[test] + fn trust_clamps_at_negative_ten() { + let mut world = setup_trust_world(); + + let npc = world.spawn_empty().id(); + let player = world.spawn_empty().id(); + world.resource_mut::().register(npc); + world.resource_mut::().register(player); + + // Push 8 confrontation events — 8 * -2 = -16, should clamp at -10 + for _ in 0..8 { + world + .resource_mut::() + .push(TrustEvent::ConfrontationDelivered { npc, player }); + } + + let mut schedule = bevy_ecs::schedule::Schedule::default(); + schedule.add_systems(update_trust); + schedule.run(&mut world); + + let registry = world.resource::(); + let npc_sid = registry.to_stable(npc).unwrap(); + let player_sid = registry.to_stable(player).unwrap(); + + let graph = world.resource::(); + let edge = graph.get_relationship(&npc_sid, &player_sid).unwrap(); + assert_eq!(edge.trust, -10); + } + + #[test] + fn mixed_events_net_correctly() { + let mut world = setup_trust_world(); + + let npc = world.spawn_empty().id(); + let player = world.spawn_empty().id(); + world.resource_mut::().register(npc); + world.resource_mut::().register(player); + + // 3 talks (+3) then 1 walk-away (-1) then 1 confrontation (-2) = net 0 + let mut queue = world.resource_mut::(); + queue.push(TrustEvent::TalkCompleted { npc, player }); + queue.push(TrustEvent::TalkCompleted { npc, player }); + queue.push(TrustEvent::TalkCompleted { npc, player }); + queue.push(TrustEvent::WalkAway { npc, player }); + queue.push(TrustEvent::ConfrontationDelivered { npc, player }); + + let mut schedule = bevy_ecs::schedule::Schedule::default(); + schedule.add_systems(update_trust); + schedule.run(&mut world); + + let registry = world.resource::(); + let npc_sid = registry.to_stable(npc).unwrap(); + let player_sid = registry.to_stable(player).unwrap(); + + let graph = world.resource::(); + let edge = graph.get_relationship(&npc_sid, &player_sid).unwrap(); + assert_eq!(edge.trust, 0); + } + + #[test] + fn update_trust_updates_last_interaction_tick() { + let mut world = setup_trust_world(); + world.resource_mut::().tick = 42; + + let npc = world.spawn_empty().id(); + let player = world.spawn_empty().id(); + world.resource_mut::().register(npc); + world.resource_mut::().register(player); + + world + .resource_mut::() + .push(TrustEvent::TalkCompleted { npc, player }); + + let mut schedule = bevy_ecs::schedule::Schedule::default(); + schedule.add_systems(update_trust); + schedule.run(&mut world); + + let registry = world.resource::(); + let npc_sid = registry.to_stable(npc).unwrap(); + let player_sid = registry.to_stable(player).unwrap(); + + let graph = world.resource::(); + let edge = graph.get_relationship(&npc_sid, &player_sid).unwrap(); + assert_eq!(edge.last_interaction_tick, 42); + } + + #[test] + fn update_trust_preserves_existing_edge_kind() { + let mut world = setup_trust_world(); + + let npc = world.spawn_empty().id(); + let player = world.spawn_empty().id(); + let npc_sid = world.resource_mut::().register(npc); + let player_sid = world.resource_mut::().register(player); + + // Pre-populate with a Friend edge at trust 5 + world.resource_mut::().set_relationship( + npc_sid, + player_sid, + make_edge(RelationshipKind::Friend, 5), + ); + + world + .resource_mut::() + .push(TrustEvent::TalkCompleted { npc, player }); + + let mut schedule = bevy_ecs::schedule::Schedule::default(); + schedule.add_systems(update_trust); + schedule.run(&mut world); + + let graph = world.resource::(); + let edge = graph.get_relationship(&npc_sid, &player_sid).unwrap(); + assert_eq!(edge.kind, RelationshipKind::Friend); // Kind preserved + assert_eq!(edge.trust, 6); // 5 + 1 + } + + #[test] + fn unregistered_entity_event_is_skipped() { + let mut world = setup_trust_world(); + + let npc = world.spawn_empty().id(); + let player = world.spawn_empty().id(); + // Only register npc, not player + world.resource_mut::().register(npc); + + world + .resource_mut::() + .push(TrustEvent::TalkCompleted { npc, player }); + + let mut schedule = bevy_ecs::schedule::Schedule::default(); + schedule.add_systems(update_trust); + schedule.run(&mut world); // Should not panic + + let graph = world.resource::(); + assert!(graph.is_empty(), "no edge should be created for unregistered entity"); + } }