//! 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 { pub kind: RelationshipKind, pub trust: i8, // -10..+10, integer for determinism (D-010) pub history: Vec, pub last_interaction_tick: u64, } /// Global relationship graph resource. /// BTreeMap<(subject, target), edge> for deterministic iteration (D-010). /// Directed graph: edge (A, B) represents how A feels about B. /// /// TODO(v0.2): RelationshipGraph is per-world. Multiplayer needs per-observer /// relationship views (D-010). #[derive(Resource, Debug, Clone, Default, Serialize, Deserialize)] pub struct RelationshipGraph { edges: BTreeMap<(StableId, StableId), RelationshipEdge>, } impl RelationshipGraph { pub fn new() -> Self { Self { edges: BTreeMap::new(), } } /// Set or update a relationship edge. pub fn set_relationship( &mut self, subject: StableId, target: StableId, edge: RelationshipEdge, ) { self.edges.insert((subject, target), edge); } /// Get a relationship edge (how subject feels about target). pub fn get_relationship( &self, subject: &StableId, target: &StableId, ) -> Option<&RelationshipEdge> { self.edges.get(&(*subject, *target)) } /// Get all relationships for a subject (who the subject has feelings about). /// Uses BTreeMap range query: all edges with matching subject are contiguous. pub fn relationships_of(&self, subject: &StableId) -> Vec<(&StableId, &RelationshipEdge)> { self.edges .range((*subject, StableId(0))..=(*subject, StableId(u64::MAX))) .map(|((_, target), edge)| (target, edge)) .collect() } /// Get all entities who have feelings about a target. /// O(N) full scan of all edges. Called once per game-minute (every 10 ticks) /// by the pressure system — acceptable at v0.1 NPC counts. pub fn who_knows_full_scan(&self, target: &StableId) -> Vec<(&StableId, &RelationshipEdge)> { self.edges .iter() .filter(|((_, t), _)| t == target) .map(|((s, _), edge)| (s, edge)) .collect() } /// Update trust level for an existing relationship. /// Clamps to -10..+10. Returns false if edge does not exist. pub fn adjust_trust(&mut self, subject: &StableId, target: &StableId, delta: i8) -> bool { if let Some(edge) = self.edges.get_mut(&(*subject, *target)) { edge.trust = edge.trust.saturating_add(delta).clamp(-10, 10); true } else { false } } /// Number of edges in the graph. pub fn edge_count(&self) -> usize { self.edges.len() } /// Whether the graph has any edges. 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) // --------------------------------------------------------------------------- // --------------------------------------------------------------------------- // Social propagation (#249, D-029) // --------------------------------------------------------------------------- /// Minimum absolute trust required on a relationship edge for that edge /// to carry propagation (D-029: "strong relationships", trust > 3). pub const PROPAGATION_TRUST_THRESHOLD: i8 = 3; /// Delay in ticks before third-order propagation is applied. /// 30 ticks ≈ 3 game-minutes (D-031: 10 ticks/minute). pub const THIRD_ORDER_DELAY_TICKS: u64 = 30; /// A first-order trust change that needs social propagation. /// /// Produced by `update_trust` for each processed event, consumed by /// `propagate_social_actions` to fan out second- and third-order effects. #[derive(Debug, Clone)] pub struct PropagationEvent { /// The NPC directly affected by the player action (first-order subject). pub npc: StableId, /// The player entity (target of how NPCs feel). pub player: StableId, /// The first-order delta (same as what was applied to the graph). pub delta: i8, } /// Queue of propagation events emitted by `update_trust`. #[derive(Resource, Default)] pub struct PropagationQueue { events: Vec, } impl PropagationQueue { pub fn push(&mut self, event: PropagationEvent) { self.events.push(event); } pub fn drain(&mut self) -> Vec { std::mem::take(&mut self.events) } } /// A deferred trust change for third-order propagation. #[derive(Debug, Clone)] pub struct DelayedTrustChange { /// NPC whose trust toward the player will be adjusted. pub npc: StableId, /// Player entity. pub player: StableId, /// Scaled delta to apply (already clamped to meaningful range). pub delta: i8, /// Tick at which this change should be applied. pub apply_at_tick: u64, } /// Queue of deferred third-order trust changes. #[derive(Resource, Default)] pub struct DelayedTrustQueue { pending: Vec, } impl DelayedTrustQueue { pub fn push(&mut self, change: DelayedTrustChange) { self.pending.push(change); } /// Drain changes that are due at or before `current_tick`. pub fn drain_due(&mut self, current_tick: u64) -> Vec { let mut due = Vec::new(); let mut remaining = Vec::new(); for change in self.pending.drain(..) { if change.apply_at_tick <= current_tick { due.push(change); } else { remaining.push(change); } } self.pending = remaining; due } /// Number of pending deferred changes. pub fn pending_count(&self) -> usize { self.pending.len() } } /// Scale a first-order delta by a propagation factor, using integer arithmetic /// (D-010: integer-only determinism). Returns 0 when the scaled value would /// round to zero — small deltas naturally attenuate to nothing. /// /// Factor is expressed in tenths (e.g. 4 = 40%, 2 = 20%). /// /// Rounding: away from zero (ceiling of abs value, preserving sign). fn scale_delta(delta: i8, factor_tenths: i8) -> i8 { // Multiply by factor, round up (ceiling of absolute value) let scaled_abs = (delta.unsigned_abs() as i16 * factor_tenths as i16 + 9) / 10; let scaled = scaled_abs.min(10) as i8; if delta < 0 { -(scaled as i8) } else { scaled as i8 } } /// 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. /// /// Also queues a `PropagationEvent` for each processed event so that /// `propagate_social_actions` can fan out second- and third-order effects. /// /// System ordering: after dialogue systems (which emit the events), /// before advance_tick. pub fn update_trust( mut queue: ResMut, mut propagation_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" ); // Queue propagation event for second/third-order effects (#249) propagation_queue.push(PropagationEvent { npc: npc_sid, player: player_sid, delta, }); } } /// System: fan out player-action trust changes through the social graph (#249, D-029). /// /// Processes `PropagationQueue` events (produced by `update_trust`) and applies: /// - **Second-order** (immediate, 40%): NPCs with trust > 3 toward the first-order NPC /// - **Third-order** (delayed 30 ticks, 15%): one further hop, same threshold /// /// Cycle prevention: a visited set per propagation pass prevents A→B→A loops. /// Propagation topology varies per seed (D-029 anti-metagaming property) because /// the `RelationshipGraph` is seeded differently per run. /// /// System ordering: after `update_trust`, before `advance_tick`. pub fn propagate_social_actions( mut propagation_queue: ResMut, mut delay_queue: ResMut, mut graph: ResMut, time: Res, ) { // --- Apply any due delayed third-order changes first --- for change in delay_queue.drain_due(time.tick) { if change.delta != 0 { graph.ensure_edge(change.npc, change.player, time.tick); graph.adjust_trust(&change.npc, &change.player, change.delta); tracing::trace!( npc = change.npc.0, player = change.player.0, delta = change.delta, "Third-order trust propagated (delayed)" ); } } // --- Fan out new propagation events --- for event in propagation_queue.drain() { let PropagationEvent { npc, player, delta } = event; // Visited set prevents cycles (D-029) let mut visited = std::collections::BTreeSet::new(); visited.insert(npc); // --- Second-order (immediate, 40% of delta) --- let second_delta = scale_delta(delta, 4); // 40% if second_delta != 0 { // Find NPCs that the first-order NPC trusts strongly let second_order: Vec = graph .relationships_of(&npc) .into_iter() .filter(|(_, edge)| edge.trust > PROPAGATION_TRUST_THRESHOLD) .map(|(target, _)| *target) .collect(); for second in &second_order { if visited.contains(second) { continue; } visited.insert(*second); graph.ensure_edge(*second, player, time.tick); graph.adjust_trust(second, &player, second_delta); tracing::trace!( first_order_npc = npc.0, second_order_npc = second.0, player = player.0, delta = second_delta, "Second-order trust propagated" ); } // --- Third-order (delayed 30 ticks, 15% of delta) --- let third_delta = scale_delta(delta, 2); // ~15% (2/10 = 20%, nearest integer approx) if third_delta != 0 { let apply_at = time.tick + THIRD_ORDER_DELAY_TICKS; for second in &second_order { let third_order: Vec = graph .relationships_of(second) .into_iter() .filter(|(_, edge)| edge.trust > PROPAGATION_TRUST_THRESHOLD) .map(|(target, _)| *target) .collect(); for third in third_order { if visited.contains(&third) { continue; } visited.insert(third); delay_queue.push(DelayedTrustChange { npc: third, player, delta: third_delta, apply_at_tick: apply_at, }); tracing::trace!( third_order_npc = third.0, player = player.0, delta = third_delta, apply_at, "Third-order trust queued for delayed propagation" ); } } } } } } // --------------------------------------------------------------------------- // 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)] mod tests { use super::*; fn make_edge(kind: RelationshipKind, trust: i8) -> RelationshipEdge { RelationshipEdge { kind, trust, history: vec![], last_interaction_tick: 0, } } #[test] fn set_and_get_relationship() { let mut graph = RelationshipGraph::new(); let a = StableId(1); let b = StableId(2); graph.set_relationship(a, b, make_edge(RelationshipKind::Friend, 5)); let edge = graph.get_relationship(&a, &b).unwrap(); assert_eq!(edge.kind, RelationshipKind::Friend); assert_eq!(edge.trust, 5); // Reverse direction is empty assert!(graph.get_relationship(&b, &a).is_none()); } #[test] fn relationships_of_subject() { let mut graph = RelationshipGraph::new(); let a = StableId(1); graph.set_relationship(a, StableId(10), make_edge(RelationshipKind::Friend, 5)); graph.set_relationship(a, StableId(20), make_edge(RelationshipKind::Colleague, 2)); graph.set_relationship(a, StableId(30), make_edge(RelationshipKind::Rival, -3)); // Different subject — should not appear graph.set_relationship( StableId(2), StableId(10), make_edge(RelationshipKind::Family, 8), ); let rels = graph.relationships_of(&a); assert_eq!(rels.len(), 3); // BTreeMap iteration order: sorted by target StableId assert_eq!(*rels[0].0, StableId(10)); assert_eq!(*rels[1].0, StableId(20)); assert_eq!(*rels[2].0, StableId(30)); } #[test] fn who_knows_target() { let mut graph = RelationshipGraph::new(); let target = StableId(10); graph.set_relationship(StableId(1), target, make_edge(RelationshipKind::Friend, 5)); graph.set_relationship(StableId(2), target, make_edge(RelationshipKind::Rival, -2)); graph.set_relationship( StableId(3), target, make_edge(RelationshipKind::Colleague, 0), ); // Edge to different target — should not appear graph.set_relationship( StableId(1), StableId(99), make_edge(RelationshipKind::Family, 8), ); let knowers = graph.who_knows_full_scan(&target); assert_eq!(knowers.len(), 3); } #[test] fn adjust_trust_clamps() { let mut graph = RelationshipGraph::new(); let a = StableId(1); let b = StableId(2); graph.set_relationship(a, b, make_edge(RelationshipKind::Friend, 8)); // Positive overflow clamps at +10 assert!(graph.adjust_trust(&a, &b, 5)); assert_eq!(graph.get_relationship(&a, &b).unwrap().trust, 10); // Negative underflow clamps at -10 assert!(graph.adjust_trust(&a, &b, -25)); assert_eq!(graph.get_relationship(&a, &b).unwrap().trust, -10); } #[test] fn adjust_trust_missing_edge_returns_false() { let mut graph = RelationshipGraph::new(); assert!(!graph.adjust_trust(&StableId(1), &StableId(2), 1)); } #[test] fn empty_graph() { let graph = RelationshipGraph::new(); assert!(graph.is_empty()); assert_eq!(graph.edge_count(), 0); } #[test] fn deterministic_iteration() { let mut graph = RelationshipGraph::new(); // Insert in arbitrary order graph.set_relationship( StableId(3), StableId(1), make_edge(RelationshipKind::Rival, -1), ); graph.set_relationship( StableId(1), StableId(2), make_edge(RelationshipKind::Friend, 5), ); graph.set_relationship( StableId(2), StableId(3), make_edge(RelationshipKind::Colleague, 0), ); // Iteration order should be deterministic (sorted by (subject, target)) let keys: Vec<_> = graph.edges.keys().collect(); assert_eq!(*keys[0], (StableId(1), StableId(2))); 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.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 } // -- PropagationQueue tests (#249) ---------------------------------------- #[test] fn propagation_queue_push_and_drain() { let mut q = PropagationQueue::default(); q.push(PropagationEvent { npc: StableId(1), player: StableId(2), delta: 1, }); q.push(PropagationEvent { npc: StableId(3), player: StableId(2), delta: -2, }); let drained = q.drain(); assert_eq!(drained.len(), 2); assert!(q.drain().is_empty()); } #[test] fn delayed_trust_queue_drain_due_filters_by_tick() { let mut q = DelayedTrustQueue::default(); q.push(DelayedTrustChange { npc: StableId(1), player: StableId(10), delta: 1, apply_at_tick: 50, }); q.push(DelayedTrustChange { npc: StableId(2), player: StableId(10), delta: -1, apply_at_tick: 100, }); // Only change at tick 50 is due at tick 60 let due = q.drain_due(60); assert_eq!(due.len(), 1); assert_eq!(due[0].npc, StableId(1)); // Change at tick 100 is still pending assert_eq!(q.pending_count(), 1); // At tick 100 it becomes due let due2 = q.drain_due(100); assert_eq!(due2.len(), 1); assert_eq!(due2[0].npc, StableId(2)); assert_eq!(q.pending_count(), 0); } #[test] fn scale_delta_forty_percent() { // factor_tenths=4 → 40% assert_eq!(scale_delta(1, 4), 1); // 0.4 → rounds up to 1 assert_eq!(scale_delta(2, 4), 1); // 0.8 → rounds up to 1 assert_eq!(scale_delta(5, 4), 2); // 2.0 → 2 assert_eq!(scale_delta(10, 4), 4); // 4.0 → 4 assert_eq!(scale_delta(-2, 4), -1); // negative preserved } #[test] fn scale_delta_zero_when_too_small() { // delta=0 → always 0 assert_eq!(scale_delta(0, 4), 0); } // -- propagate_social_actions system tests (#249) ------------------------- fn setup_propagation_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 second_order_trust_propagates_immediately() { // Spec (#249, D-029): NPCs strongly connected to the first-order NPC // receive 40% of the delta in the same tick. // // Graph: A → B (trust 5, > threshold 3) // A → player (will be first-order) // Event: player action affects A (delta=+2) // Expected: B gains 40% of 2 = 1 (ceil) toward player let mut world = setup_propagation_world(); let npc_a = StableId(1); let npc_b = StableId(2); let player = StableId(99); // A strongly trusts B (A→B trust=5) world.resource_mut::().set_relationship( npc_a, npc_b, make_edge(RelationshipKind::Friend, 5), ); // Queue propagation from A world.resource_mut::().push(PropagationEvent { npc: npc_a, player, delta: 2, }); let mut schedule = bevy_ecs::schedule::Schedule::default(); schedule.add_systems(propagate_social_actions); schedule.run(&mut world); // B should now have a trust edge toward the player (positive) let graph = world.resource::(); let edge = graph.get_relationship(&npc_b, &player).expect("B should have edge to player"); assert!( edge.trust > 0, "Second-order NPC should trust player more after positive first-order event" ); } #[test] fn weak_relationship_does_not_propagate() { // Spec (#249): Only edges with trust > PROPAGATION_TRUST_THRESHOLD (3) carry propagation. // // Graph: A → B (trust 2, ≤ threshold 3) // Event: player action affects A (delta=+5) // Expected: B gets no propagation (trust ≤ threshold) let mut world = setup_propagation_world(); let npc_a = StableId(1); let npc_b = StableId(2); let player = StableId(99); // A weakly trusts B (below threshold) world.resource_mut::().set_relationship( npc_a, npc_b, make_edge(RelationshipKind::Colleague, 2), ); world.resource_mut::().push(PropagationEvent { npc: npc_a, player, delta: 5, }); let mut schedule = bevy_ecs::schedule::Schedule::default(); schedule.add_systems(propagate_social_actions); schedule.run(&mut world); // B should NOT have any edge to the player let graph = world.resource::(); assert!( graph.get_relationship(&npc_b, &player).is_none(), "Weak relationship should not carry propagation" ); } #[test] fn third_order_trust_is_deferred_by_thirty_ticks() { // Spec (#249): Third-order changes are queued for 30 ticks in the future. // // Graph: A → B (trust 5), B → C (trust 5) // Event: player action affects A (delta=+5) // Expected: C's change is queued for tick+30, not applied immediately let mut world = setup_propagation_world(); world.resource_mut::().tick = 10; // Set a known tick let npc_a = StableId(1); let npc_b = StableId(2); let npc_c = StableId(3); let player = StableId(99); let mut graph = world.resource_mut::(); graph.set_relationship(npc_a, npc_b, make_edge(RelationshipKind::Friend, 5)); graph.set_relationship(npc_b, npc_c, make_edge(RelationshipKind::Friend, 5)); world.resource_mut::().push(PropagationEvent { npc: npc_a, player, delta: 5, }); let mut schedule = bevy_ecs::schedule::Schedule::default(); schedule.add_systems(propagate_social_actions); schedule.run(&mut world); // C should NOT have an edge yet (it's deferred) { let graph = world.resource::(); assert!( graph.get_relationship(&npc_c, &player).is_none(), "Third-order changes should be deferred, not applied immediately" ); } // Delay queue should have one pending change for C at tick 10+30=40 let delay_queue = world.resource::(); assert_eq!(delay_queue.pending_count(), 1); } #[test] fn delayed_changes_applied_when_due() { // Once the tick advances past apply_at_tick, the delayed change is applied. let mut world = setup_propagation_world(); world.resource_mut::().tick = 10; let npc_a = StableId(1); let npc_b = StableId(2); let npc_c = StableId(3); let player = StableId(99); let mut graph = world.resource_mut::(); graph.set_relationship(npc_a, npc_b, make_edge(RelationshipKind::Friend, 5)); graph.set_relationship(npc_b, npc_c, make_edge(RelationshipKind::Friend, 5)); // First run: queue the third-order change world.resource_mut::().push(PropagationEvent { npc: npc_a, player, delta: 5, }); let mut schedule = bevy_ecs::schedule::Schedule::default(); schedule.add_systems(propagate_social_actions); schedule.run(&mut world); // Advance tick to 40 (past apply_at_tick = 40) world.resource_mut::().tick = 40; schedule.run(&mut world); // C should now have an edge with positive trust let graph = world.resource::(); let edge = graph.get_relationship(&npc_c, &player) .expect("Delayed change should have been applied by now"); assert!(edge.trust > 0, "Third-order trust should be positive after delayed application"); } #[test] fn cycle_prevention_no_a_to_b_to_a_loop() { // Spec (#249, D-029): visited set prevents A→B→A cycles. // // Graph: A ↔ B (both trust each other at 5) // Event: player action affects A (delta=+2) // A should propagate to B, but B should NOT propagate back to A. let mut world = setup_propagation_world(); let npc_a = StableId(1); let npc_b = StableId(2); let player = StableId(99); // Bidirectional strong trust world.resource_mut::().set_relationship( npc_a, npc_b, make_edge(RelationshipKind::Friend, 5), ); world.resource_mut::().set_relationship( npc_b, npc_a, make_edge(RelationshipKind::Friend, 5), ); world.resource_mut::().push(PropagationEvent { npc: npc_a, player, delta: 2, }); let mut schedule = bevy_ecs::schedule::Schedule::default(); schedule.add_systems(propagate_social_actions); schedule.run(&mut world); // A should not get a second-order change from the cycle (A was the origin) let graph = world.resource::(); // The only A→player edge effect should be through the propagation event // (no direct creation in propagate_social_actions — only update_trust does that). // B should have an edge to player. assert!( graph.get_relationship(&npc_b, &player).is_some(), "B should get second-order propagation from A" ); // A should NOT have a re-propagated edge (cycle prevented) assert!( graph.get_relationship(&npc_a, &player).is_none(), "A should not receive back-propagation from B (cycle prevention)" ); } #[test] fn negative_delta_propagates_as_negative() { // Spec (#249): Negative deltas (walk-away, confrontation) also propagate // with the same sign. let mut world = setup_propagation_world(); let npc_a = StableId(1); let npc_b = StableId(2); let player = StableId(99); world.resource_mut::().set_relationship( npc_a, npc_b, make_edge(RelationshipKind::Friend, 5), ); world.resource_mut::().push(PropagationEvent { npc: npc_a, player, delta: -2, // confrontation }); let mut schedule = bevy_ecs::schedule::Schedule::default(); schedule.add_systems(propagate_social_actions); schedule.run(&mut world); // B should trust player LESS after A was confronted let graph = world.resource::(); let edge = graph.get_relationship(&npc_b, &player) .expect("B should have edge to player"); assert!( edge.trust < 0, "Negative propagation should reduce B's trust in player" ); } #[test] fn no_relationships_means_no_propagation() { // If the first-order NPC has no relationships, the queue is drained // but nothing propagates. let mut world = setup_propagation_world(); let npc_a = StableId(1); let player = StableId(99); world.resource_mut::().push(PropagationEvent { npc: npc_a, player, delta: 5, }); let mut schedule = bevy_ecs::schedule::Schedule::default(); schedule.add_systems(propagate_social_actions); schedule.run(&mut world); let graph = world.resource::(); assert!(graph.is_empty(), "No relationships → no propagation edges created"); let delay_queue = world.resource::(); assert_eq!(delay_queue.pending_count(), 0, "No delay queue entries either"); } #[test] fn update_trust_populates_propagation_queue() { // Integration: update_trust should push to PropagationQueue for downstream #249. 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); // PropagationQueue should have 1 event (for downstream propagation) let prop_events = world.resource_mut::().drain(); assert_eq!(prop_events.len(), 1); assert_eq!(prop_events[0].delta, TALK_TRUST_DELTA); } #[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"); } }