- 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>
1363 lines
48 KiB
Rust
1363 lines
48 KiB
Rust
//! 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");
|
||
}
|
||
}
|