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 <noreply@anthropic.com>
This commit is contained in:
2026-02-20 18:52:36 +01:00
co-authored by Claude Sonnet 4.6
parent bd60b356f8
commit 8bd65a5662
+539 -1
View File
@@ -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<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 {
@@ -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<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)
// ---------------------------------------------------------------------------
/// 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<TrustEventQueue>,
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"
);
}
}
// ---------------------------------------------------------------------------
// 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)]
@@ -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::<SimulationTime>();
world.init_resource::<EntityRegistry>();
world.init_resource::<RelationshipGraph>();
world.init_resource::<TrustEventQueue>();
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
}
#[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");
}
}