Files
settled-reach/server/src/npc/relationships.rs
T
jpmschweitzerandClaude Opus 4.6 768a431c38 fix(simulation): PR #66 review — dedup vision, spawn components, doc fixes
- vision.rs: deduplicate own-tile entity iteration, add same-tile test
- spawn.rs: add NpcVisionState, NpcMemory, PlayerAwareness to content-
  spawned NPCs (matching generate_npc)
- pressure.rs: update who_knows_full_scan doc to reflect actual call
  frequency, document O(N) acceptability at call site
- types.rs: fix stale protocol version comment (13→14)
- generate.rs: format!() → .to_string() (clippy)
- vision.rs: hardcoded 10 → TICKS_PER_GAME_MINUTE
- relationships.rs: fix comment "15%" → "20%" to match code
- pressure.rs: document total() floor-truncation
- save_state.rs: document NpcMemory exclusion as intentional

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-25 10:04:18 +01:00

1363 lines
48 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 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<TrustEvent>,
}
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<TrustEvent> {
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<RelationshipEvent>,
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<Item = &mut RelationshipEdge> {
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<PropagationEvent>,
}
impl PropagationQueue {
pub fn push(&mut self, event: PropagationEvent) {
self.events.push(event);
}
pub fn drain(&mut self) -> Vec<PropagationEvent> {
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<DelayedTrustChange>,
}
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<DelayedTrustChange> {
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<TrustEventQueue>,
mut propagation_queue: ResMut<PropagationQueue>,
mut graph: ResMut<RelationshipGraph>,
registry: Res<EntityRegistry>,
time: Res<SimulationTime>,
) {
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<PropagationQueue>,
mut delay_queue: ResMut<DelayedTrustQueue>,
mut graph: ResMut<RelationshipGraph>,
time: Res<SimulationTime>,
) {
// --- 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<StableId> = 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<StableId> = 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<SimulationTime>, mut graph: ResMut<RelationshipGraph>) {
// 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::<SimulationTime>();
world.init_resource::<EntityRegistry>();
world.init_resource::<RelationshipGraph>();
world.init_resource::<TrustEventQueue>();
world.init_resource::<PropagationQueue>();
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::<EntityRegistry>().register(npc);
world.resource_mut::<EntityRegistry>().register(player);
world
.resource_mut::<TrustEventQueue>()
.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::<EntityRegistry>();
let npc_sid = registry.to_stable(npc).unwrap();
let player_sid = registry.to_stable(player).unwrap();
let graph = world.resource::<RelationshipGraph>();
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::<EntityRegistry>().register(npc);
world.resource_mut::<EntityRegistry>().register(player);
world
.resource_mut::<TrustEventQueue>()
.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::<EntityRegistry>();
let npc_sid = registry.to_stable(npc).unwrap();
let player_sid = registry.to_stable(player).unwrap();
let graph = world.resource::<RelationshipGraph>();
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::<EntityRegistry>().register(npc);
world.resource_mut::<EntityRegistry>().register(player);
world
.resource_mut::<TrustEventQueue>()
.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::<EntityRegistry>();
let npc_sid = registry.to_stable(npc).unwrap();
let player_sid = registry.to_stable(player).unwrap();
let graph = world.resource::<RelationshipGraph>();
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::<EntityRegistry>().register(npc);
world.resource_mut::<EntityRegistry>().register(player);
// Push 5 talk events
for _ in 0..5 {
world
.resource_mut::<TrustEventQueue>()
.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::<EntityRegistry>();
let npc_sid = registry.to_stable(npc).unwrap();
let player_sid = registry.to_stable(player).unwrap();
let graph = world.resource::<RelationshipGraph>();
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::<EntityRegistry>().register(npc);
world.resource_mut::<EntityRegistry>().register(player);
// Push 15 talk events — should clamp at 10
for _ in 0..15 {
world
.resource_mut::<TrustEventQueue>()
.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::<EntityRegistry>();
let npc_sid = registry.to_stable(npc).unwrap();
let player_sid = registry.to_stable(player).unwrap();
let graph = world.resource::<RelationshipGraph>();
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::<EntityRegistry>().register(npc);
world.resource_mut::<EntityRegistry>().register(player);
// Push 8 confrontation events — 8 * -2 = -16, should clamp at -10
for _ in 0..8 {
world
.resource_mut::<TrustEventQueue>()
.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::<EntityRegistry>();
let npc_sid = registry.to_stable(npc).unwrap();
let player_sid = registry.to_stable(player).unwrap();
let graph = world.resource::<RelationshipGraph>();
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::<EntityRegistry>().register(npc);
world.resource_mut::<EntityRegistry>().register(player);
// 3 talks (+3) then 1 walk-away (-1) then 1 confrontation (-2) = net 0
let mut queue = world.resource_mut::<TrustEventQueue>();
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::<EntityRegistry>();
let npc_sid = registry.to_stable(npc).unwrap();
let player_sid = registry.to_stable(player).unwrap();
let graph = world.resource::<RelationshipGraph>();
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::<SimulationTime>().tick = 42;
let npc = world.spawn_empty().id();
let player = world.spawn_empty().id();
world.resource_mut::<EntityRegistry>().register(npc);
world.resource_mut::<EntityRegistry>().register(player);
world
.resource_mut::<TrustEventQueue>()
.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::<EntityRegistry>();
let npc_sid = registry.to_stable(npc).unwrap();
let player_sid = registry.to_stable(player).unwrap();
let graph = world.resource::<RelationshipGraph>();
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::<EntityRegistry>().register(npc);
let player_sid = world.resource_mut::<EntityRegistry>().register(player);
// Pre-populate with a Friend edge at trust 5
world.resource_mut::<RelationshipGraph>().set_relationship(
npc_sid,
player_sid,
make_edge(RelationshipKind::Friend, 5),
);
world
.resource_mut::<TrustEventQueue>()
.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::<RelationshipGraph>();
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::<SimulationTime>();
world.init_resource::<RelationshipGraph>();
world.init_resource::<PropagationQueue>();
world.init_resource::<DelayedTrustQueue>();
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::<RelationshipGraph>().set_relationship(
npc_a,
npc_b,
make_edge(RelationshipKind::Friend, 5),
);
// Queue propagation from A
world.resource_mut::<PropagationQueue>().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::<RelationshipGraph>();
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::<RelationshipGraph>().set_relationship(
npc_a,
npc_b,
make_edge(RelationshipKind::Colleague, 2),
);
world.resource_mut::<PropagationQueue>().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::<RelationshipGraph>();
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::<SimulationTime>().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::<RelationshipGraph>();
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::<PropagationQueue>().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::<RelationshipGraph>();
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::<DelayedTrustQueue>();
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::<SimulationTime>().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::<RelationshipGraph>();
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::<PropagationQueue>().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::<SimulationTime>().tick = 40;
schedule.run(&mut world);
// C should now have an edge with positive trust
let graph = world.resource::<RelationshipGraph>();
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::<RelationshipGraph>().set_relationship(
npc_a,
npc_b,
make_edge(RelationshipKind::Friend, 5),
);
world.resource_mut::<RelationshipGraph>().set_relationship(
npc_b,
npc_a,
make_edge(RelationshipKind::Friend, 5),
);
world.resource_mut::<PropagationQueue>().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::<RelationshipGraph>();
// 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::<RelationshipGraph>().set_relationship(
npc_a,
npc_b,
make_edge(RelationshipKind::Friend, 5),
);
world.resource_mut::<PropagationQueue>().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::<RelationshipGraph>();
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::<PropagationQueue>().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::<RelationshipGraph>();
assert!(graph.is_empty(), "No relationships → no propagation edges created");
let delay_queue = world.resource::<DelayedTrustQueue>();
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::<EntityRegistry>().register(npc);
world.resource_mut::<EntityRegistry>().register(player);
world
.resource_mut::<TrustEventQueue>()
.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::<PropagationQueue>().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::<EntityRegistry>().register(npc);
world
.resource_mut::<TrustEventQueue>()
.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::<RelationshipGraph>();
assert!(graph.is_empty(), "no edge should be created for unregistered entity");
}
}