|
| 1 | +//! Affective theory of mind: per-agent store of last-observed mood / emotion. |
| 2 | +//! |
| 3 | +//! Reads: VisibleObjects, MindGraph (sentience filter), EmotionalState, TickCount |
| 4 | +//! Writes: AffectiveToM, SimEvent |
| 5 | +//! Upstream: mind::social_perception |
| 6 | +//! Downstream: psyche::appraisal, prosocial behaviors, other-regarding drives |
| 7 | +
|
| 8 | +use bevy::prelude::*; |
| 9 | +use std::collections::HashMap; |
| 10 | + |
| 11 | +use crate::agent::Agent; |
| 12 | +use crate::agent::events::{SimEvent, SimEventKind}; |
| 13 | +use crate::agent::mind::knowledge::{Concept, MindGraph, Node}; |
| 14 | +use crate::agent::mind::perception::VisibleObjects; |
| 15 | +use crate::agent::psyche::emotions::{EmotionType, EmotionalState}; |
| 16 | +use crate::core::GameTime; |
| 17 | +use crate::core::tick::{TICK_RARE_PERIOD, TickCount}; |
| 18 | + |
| 19 | +/// Cap on distinct targets per observer. Matches the first-order ToM |
| 20 | +/// per-target cap so memory stays predictable as the social graph grows. |
| 21 | +pub const MAX_AFFECTIVE_TARGETS: usize = 32; |
| 22 | + |
| 23 | +/// Game-time over which observation confidence decays linearly to zero. |
| 24 | +/// 24 game-hours: by next morning, what an agent saw of someone yesterday |
| 25 | +/// is fully faded. |
| 26 | +pub const CONFIDENCE_DECAY_TICKS: u64 = 24 * GameTime::TICKS_PER_HOUR; |
| 27 | + |
| 28 | +/// Confidence floor below which an entry is pruned during decay. |
| 29 | +pub const MIN_CONFIDENCE: f32 = 0.1; |
| 30 | + |
| 31 | +/// Mood at or below this counts as "distressed" even when no acute |
| 32 | +/// emotion is active — captures the drained / numb / stressed-out band. |
| 33 | +const DISTRESSED_MOOD: f32 = -0.3; |
| 34 | + |
| 35 | +/// Snapshot of another agent's affective state at a single observation. |
| 36 | +/// Confidence is derived from `observed_at` via `confidence_at(now)` — |
| 37 | +/// not stored, to avoid stale-by-default state. |
| 38 | +#[derive(Debug, Clone, Copy)] |
| 39 | +pub struct PerceivedMood { |
| 40 | + pub dominant_emotion: Option<EmotionType>, |
| 41 | + /// Valence at observation, range -1.0..=1.0. |
| 42 | + pub mood: f32, |
| 43 | + /// Stress at observation, range 0..=100. |
| 44 | + pub stress: f32, |
| 45 | + pub observed_at: u64, |
| 46 | +} |
| 47 | + |
| 48 | +impl PerceivedMood { |
| 49 | + /// Linearly-decayed confidence at `now`, clamped to [0, 1]. |
| 50 | + pub fn confidence_at(&self, now: u64) -> f32 { |
| 51 | + let age = now.saturating_sub(self.observed_at) as f32; |
| 52 | + (1.0 - age / CONFIDENCE_DECAY_TICKS as f32).clamp(0.0, 1.0) |
| 53 | + } |
| 54 | +} |
| 55 | + |
| 56 | +#[derive(Component, Debug, Default, Clone, Reflect)] |
| 57 | +#[reflect(Component)] |
| 58 | +pub struct AffectiveToM { |
| 59 | + #[reflect(ignore)] |
| 60 | + beliefs: HashMap<Entity, PerceivedMood>, |
| 61 | +} |
| 62 | + |
| 63 | +impl AffectiveToM { |
| 64 | + /// Record what `target` looked like emotionally at `tick`. Refreshes |
| 65 | + /// the existing entry; on capacity overflow evicts the oldest target. |
| 66 | + pub fn record_observation( |
| 67 | + &mut self, |
| 68 | + target: Entity, |
| 69 | + dominant_emotion: Option<EmotionType>, |
| 70 | + mood: f32, |
| 71 | + stress: f32, |
| 72 | + tick: u64, |
| 73 | + ) { |
| 74 | + let entry = PerceivedMood { |
| 75 | + dominant_emotion, |
| 76 | + mood, |
| 77 | + stress, |
| 78 | + observed_at: tick, |
| 79 | + }; |
| 80 | + |
| 81 | + if let Some(existing) = self.beliefs.get_mut(&target) { |
| 82 | + *existing = entry; |
| 83 | + return; |
| 84 | + } |
| 85 | + |
| 86 | + if self.beliefs.len() >= MAX_AFFECTIVE_TARGETS |
| 87 | + && let Some(oldest) = self |
| 88 | + .beliefs |
| 89 | + .iter() |
| 90 | + .min_by_key(|(_, e)| e.observed_at) |
| 91 | + .map(|(t, _)| *t) |
| 92 | + { |
| 93 | + self.beliefs.remove(&oldest); |
| 94 | + } |
| 95 | + |
| 96 | + self.beliefs.insert(target, entry); |
| 97 | + } |
| 98 | + |
| 99 | + pub fn perceived_mood(&self, target: Entity) -> Option<&PerceivedMood> { |
| 100 | + self.beliefs.get(&target) |
| 101 | + } |
| 102 | + |
| 103 | + /// True iff the last observation of `target` shows distress: a |
| 104 | + /// negative-valence dominant emotion, or — when no acute emotion is |
| 105 | + /// active — a mood at or below `DISTRESSED_MOOD`. The mood-only |
| 106 | + /// branch catches "drained / quietly miserable", which has no |
| 107 | + /// acute-emotion signature. |
| 108 | + pub fn has_seen_distressed(&self, target: Entity) -> bool { |
| 109 | + let Some(mood) = self.beliefs.get(&target) else { |
| 110 | + return false; |
| 111 | + }; |
| 112 | + let by_emotion = matches!( |
| 113 | + mood.dominant_emotion, |
| 114 | + Some(EmotionType::Sadness | EmotionType::Fear | EmotionType::Anger) |
| 115 | + ); |
| 116 | + by_emotion || mood.mood <= DISTRESSED_MOOD |
| 117 | + } |
| 118 | + |
| 119 | + pub fn target_count(&self) -> usize { |
| 120 | + self.beliefs.len() |
| 121 | + } |
| 122 | + |
| 123 | + /// Drop entries whose confidence at `now` has fallen below |
| 124 | + /// `MIN_CONFIDENCE`. Returns the number of evictions. |
| 125 | + pub fn decay(&mut self, now: u64) -> usize { |
| 126 | + let before = self.beliefs.len(); |
| 127 | + self.beliefs |
| 128 | + .retain(|_, mood| mood.confidence_at(now) >= MIN_CONFIDENCE); |
| 129 | + before - self.beliefs.len() |
| 130 | + } |
| 131 | +} |
| 132 | + |
| 133 | +/// Per-observer system: sample affective state of every visible sentient |
| 134 | +/// agent and record it on the observer's `AffectiveToM`. Runs every |
| 135 | +/// tick — work per observer is `visible-sentient-count` HashMap ops. |
| 136 | +/// SimEvents are gated to dominant-emotion changes so the log isn't |
| 137 | +/// flooded with no-ops. |
| 138 | +pub fn update_affective_tom( |
| 139 | + mut observers: Query<(Entity, &VisibleObjects, &MindGraph, &mut AffectiveToM), With<Agent>>, |
| 140 | + targets: Query<&EmotionalState, With<Agent>>, |
| 141 | + tick: Res<TickCount>, |
| 142 | + mut sim_events: MessageWriter<SimEvent>, |
| 143 | +) { |
| 144 | + let now = tick.current; |
| 145 | + |
| 146 | + for (observer, visible, mind, mut tom) in observers.iter_mut() { |
| 147 | + for visible_entity in |
| 148 | + visible.iter_by_concept(|c| mind.has_trait(&Node::Concept(c), Concept::Sentient)) |
| 149 | + { |
| 150 | + if visible_entity == observer { |
| 151 | + continue; |
| 152 | + } |
| 153 | + let Ok(state) = targets.get(visible_entity) else { |
| 154 | + continue; |
| 155 | + }; |
| 156 | + |
| 157 | + // Change-detection: only emit a SimEvent when the dominant |
| 158 | + // emotion flips. Mood / stress drift quietly under the live |
| 159 | + // sample but don't deserve their own log line. |
| 160 | + let new_emotion = state.dominant_emotion(); |
| 161 | + let prev_emotion = tom |
| 162 | + .perceived_mood(visible_entity) |
| 163 | + .and_then(|m| m.dominant_emotion); |
| 164 | + |
| 165 | + tom.record_observation( |
| 166 | + visible_entity, |
| 167 | + new_emotion, |
| 168 | + state.current_mood, |
| 169 | + state.stress_level, |
| 170 | + now, |
| 171 | + ); |
| 172 | + |
| 173 | + if new_emotion != prev_emotion { |
| 174 | + sim_events.write(SimEvent::single( |
| 175 | + now, |
| 176 | + observer, |
| 177 | + SimEventKind::AffectiveToMUpdated { |
| 178 | + agent: observer, |
| 179 | + about: visible_entity, |
| 180 | + }, |
| 181 | + )); |
| 182 | + } |
| 183 | + } |
| 184 | + } |
| 185 | +} |
| 186 | + |
| 187 | +/// Slow staggered prune of stale entries. Cadence shares |
| 188 | +/// `TICK_RARE_PERIOD` with the update system so each observer's |
| 189 | +/// AffectiveToM is touched at most once per period. |
| 190 | +pub fn decay_affective_tom( |
| 191 | + mut observers: Query<(Entity, &mut AffectiveToM), With<Agent>>, |
| 192 | + tick: Res<TickCount>, |
| 193 | +) { |
| 194 | + for (entity, mut tom) in observers.iter_mut() { |
| 195 | + if !tick.should_run(entity, TICK_RARE_PERIOD) { |
| 196 | + continue; |
| 197 | + } |
| 198 | + tom.decay(tick.current); |
| 199 | + } |
| 200 | +} |
| 201 | + |
| 202 | +#[cfg(test)] |
| 203 | +mod tests { |
| 204 | + use super::*; |
| 205 | + use crate::agent::psyche::emotions::Emotion; |
| 206 | + |
| 207 | + fn test_entity(id: u32) -> Entity { |
| 208 | + Entity::from_bits(id as u64) |
| 209 | + } |
| 210 | + |
| 211 | + #[test] |
| 212 | + fn recording_a_sad_target_stores_sadness_as_dominant_emotion() { |
| 213 | + let mut tom = AffectiveToM::default(); |
| 214 | + let alice = test_entity(1); |
| 215 | + let mut state = EmotionalState::default(); |
| 216 | + state.add_emotion(Emotion::new(EmotionType::Sadness, 0.7)); |
| 217 | + |
| 218 | + tom.record_observation(alice, state.dominant_emotion(), -0.4, 30.0, 100); |
| 219 | + |
| 220 | + let mood = tom.perceived_mood(alice).expect("must record observation"); |
| 221 | + assert_eq!(mood.dominant_emotion, Some(EmotionType::Sadness)); |
| 222 | + assert!((mood.mood - -0.4).abs() < 1e-6); |
| 223 | + assert_eq!(mood.observed_at, 100); |
| 224 | + } |
| 225 | + |
| 226 | + #[test] |
| 227 | + fn never_observed_target_returns_none() { |
| 228 | + let tom = AffectiveToM::default(); |
| 229 | + assert!(tom.perceived_mood(test_entity(99)).is_none()); |
| 230 | + assert!(!tom.has_seen_distressed(test_entity(99))); |
| 231 | + } |
| 232 | + |
| 233 | + #[test] |
| 234 | + fn confidence_decays_linearly_with_age() { |
| 235 | + let mut tom = AffectiveToM::default(); |
| 236 | + let alice = test_entity(1); |
| 237 | + tom.record_observation(alice, None, 0.0, 0.0, 0); |
| 238 | + |
| 239 | + let mood = tom.perceived_mood(alice).unwrap(); |
| 240 | + let halfway = mood.confidence_at(CONFIDENCE_DECAY_TICKS / 2); |
| 241 | + assert!( |
| 242 | + (halfway - 0.5).abs() < 1e-3, |
| 243 | + "halfway through decay window should give ~0.5, got {halfway}" |
| 244 | + ); |
| 245 | + assert!(mood.confidence_at(CONFIDENCE_DECAY_TICKS * 2).abs() < 1e-6); |
| 246 | + } |
| 247 | + |
| 248 | + #[test] |
| 249 | + fn decay_evicts_entries_below_threshold() { |
| 250 | + let mut tom = AffectiveToM::default(); |
| 251 | + let alice = test_entity(1); |
| 252 | + tom.record_observation(alice, None, 0.0, 0.0, 0); |
| 253 | + |
| 254 | + let evicted = tom.decay(CONFIDENCE_DECAY_TICKS + 1); |
| 255 | + assert_eq!(evicted, 1); |
| 256 | + assert!(tom.perceived_mood(alice).is_none()); |
| 257 | + } |
| 258 | + |
| 259 | + #[test] |
| 260 | + fn capacity_evicts_oldest_target() { |
| 261 | + let mut tom = AffectiveToM::default(); |
| 262 | + // Entity::from_bits(0) is invalid in Bevy 0.18 — start ids at 1. |
| 263 | + for i in 1..=MAX_AFFECTIVE_TARGETS { |
| 264 | + tom.record_observation(test_entity(i as u32), None, 0.0, 0.0, i as u64); |
| 265 | + } |
| 266 | + assert_eq!(tom.target_count(), MAX_AFFECTIVE_TARGETS); |
| 267 | + |
| 268 | + let newcomer = test_entity(999); |
| 269 | + tom.record_observation(newcomer, None, 0.0, 0.0, 1000); |
| 270 | + assert_eq!(tom.target_count(), MAX_AFFECTIVE_TARGETS); |
| 271 | + assert!(tom.perceived_mood(test_entity(1)).is_none()); |
| 272 | + assert!(tom.perceived_mood(newcomer).is_some()); |
| 273 | + } |
| 274 | + |
| 275 | + #[test] |
| 276 | + fn re_observing_same_target_refreshes_in_place() { |
| 277 | + let mut tom = AffectiveToM::default(); |
| 278 | + let alice = test_entity(1); |
| 279 | + tom.record_observation(alice, Some(EmotionType::Sadness), -0.5, 40.0, 100); |
| 280 | + tom.record_observation(alice, Some(EmotionType::Joy), 0.6, 5.0, 500); |
| 281 | + |
| 282 | + assert_eq!(tom.target_count(), 1); |
| 283 | + let mood = tom.perceived_mood(alice).unwrap(); |
| 284 | + assert_eq!(mood.dominant_emotion, Some(EmotionType::Joy)); |
| 285 | + assert_eq!(mood.observed_at, 500); |
| 286 | + } |
| 287 | + |
| 288 | + #[test] |
| 289 | + fn has_seen_distressed_fires_on_negative_dominant_emotion() { |
| 290 | + let mut tom = AffectiveToM::default(); |
| 291 | + let alice = test_entity(1); |
| 292 | + tom.record_observation(alice, Some(EmotionType::Sadness), 0.0, 30.0, 0); |
| 293 | + assert!(tom.has_seen_distressed(alice)); |
| 294 | + } |
| 295 | + |
| 296 | + #[test] |
| 297 | + fn has_seen_distressed_fires_on_low_mood_without_emotion() { |
| 298 | + let mut tom = AffectiveToM::default(); |
| 299 | + let alice = test_entity(1); |
| 300 | + tom.record_observation(alice, None, -0.6, 10.0, 0); |
| 301 | + assert!(tom.has_seen_distressed(alice)); |
| 302 | + } |
| 303 | + |
| 304 | + #[test] |
| 305 | + fn has_seen_distressed_quiet_for_neutral_target() { |
| 306 | + let mut tom = AffectiveToM::default(); |
| 307 | + let alice = test_entity(1); |
| 308 | + tom.record_observation(alice, Some(EmotionType::Joy), 0.4, 10.0, 0); |
| 309 | + assert!(!tom.has_seen_distressed(alice)); |
| 310 | + } |
| 311 | +} |
0 commit comments