Skip to main content

opsis_core/
state.rs

1use std::collections::BTreeMap;
2use std::fmt;
3
4use serde::{Deserialize, Serialize};
5
6use crate::clock::{WorldClock, WorldTick};
7use crate::spatial::GeoHotspot;
8
9/// A domain of world-state activity.
10#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
11pub enum StateDomain {
12    Emergency,
13    Health,
14    Finance,
15    Trade,
16    Conflict,
17    Politics,
18    Weather,
19    Space,
20    Ocean,
21    Technology,
22    Personal,
23    Infrastructure,
24    Custom(String),
25}
26
27impl fmt::Display for StateDomain {
28    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
29        match self {
30            Self::Custom(name) => write!(f, "{name}"),
31            other => fmt::Debug::fmt(other, f),
32        }
33    }
34}
35
36/// Direction of activity change.
37#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
38pub enum Trend {
39    Rising,
40    Falling,
41    Stable,
42    Spike,
43    Crash,
44}
45
46/// A single domain's state line — tracks activity level and trend.
47#[derive(Debug, Clone, Serialize, Deserialize)]
48pub struct StateLine {
49    /// The domain this state line tracks.
50    pub domain: StateDomain,
51    /// Normalised activity level (0.0–1.0).
52    pub activity: f32,
53    /// Current trend direction.
54    pub trend: Trend,
55    /// Spatial hotspots within this domain.
56    pub hotspots: Vec<GeoHotspot>,
57    /// Tick of last update.
58    pub last_updated: WorldTick,
59    /// Recent activity history for trend detection (not serialised).
60    #[serde(skip)]
61    pub activity_history: Vec<f32>,
62}
63
64impl StateLine {
65    /// Create a new state line at zero activity.
66    pub fn new(domain: StateDomain) -> Self {
67        Self {
68            domain,
69            activity: 0.0,
70            trend: Trend::Stable,
71            hotspots: Vec::new(),
72            last_updated: WorldTick::zero(),
73            activity_history: Vec::new(),
74        }
75    }
76
77    /// Apply an exponential moving average (EMA) update with alpha = 0.3,
78    /// then detect the current trend.
79    pub fn update_activity(&mut self, sample: f32, tick: WorldTick) {
80        const ALPHA: f32 = 0.3;
81        self.activity = ALPHA * sample + (1.0 - ALPHA) * self.activity;
82        self.activity_history.push(self.activity);
83        if self.activity_history.len() > 60 {
84            self.activity_history.remove(0);
85        }
86        self.trend = self.detect_trend();
87        self.last_updated = tick;
88    }
89
90    /// Compute trend from the last 3 activity samples.
91    fn detect_trend(&self) -> Trend {
92        let len = self.activity_history.len();
93        if len < 2 {
94            return Trend::Stable;
95        }
96
97        // Average delta over the last (up to 3) intervals.
98        let window = len.min(3);
99        let start_idx = len - window;
100        let delta =
101            (self.activity_history[len - 1] - self.activity_history[start_idx]) / window as f32;
102
103        if delta > 0.15 {
104            Trend::Spike
105        } else if delta < -0.15 {
106            Trend::Crash
107        } else if delta > 0.03 {
108            Trend::Rising
109        } else if delta < -0.03 {
110            Trend::Falling
111        } else {
112            Trend::Stable
113        }
114    }
115}
116
117/// The full world state — a clock plus per-domain state lines.
118#[derive(Debug, Clone, Serialize, Deserialize)]
119pub struct WorldState {
120    /// The world clock driving the simulation.
121    pub clock: WorldClock,
122    /// Per-domain state lines.
123    pub state_lines: BTreeMap<StateDomain, StateLine>,
124}
125
126impl WorldState {
127    /// Create a world state pre-populated with the 12 default domains.
128    pub fn new(clock: WorldClock) -> Self {
129        let default_domains = [
130            StateDomain::Emergency,
131            StateDomain::Health,
132            StateDomain::Finance,
133            StateDomain::Trade,
134            StateDomain::Conflict,
135            StateDomain::Politics,
136            StateDomain::Weather,
137            StateDomain::Space,
138            StateDomain::Ocean,
139            StateDomain::Technology,
140            StateDomain::Personal,
141            StateDomain::Infrastructure,
142        ];
143
144        let state_lines = default_domains
145            .into_iter()
146            .map(|d| {
147                let sl = StateLine::new(d.clone());
148                (d, sl)
149            })
150            .collect();
151
152        Self { clock, state_lines }
153    }
154
155    /// Get a mutable reference to a state line, inserting a default if the
156    /// domain doesn't exist yet (useful for `Custom` domains).
157    pub fn state_line_mut(&mut self, domain: &StateDomain) -> &mut StateLine {
158        self.state_lines
159            .entry(domain.clone())
160            .or_insert_with(|| StateLine::new(domain.clone()))
161    }
162}
163
164#[cfg(test)]
165mod tests {
166    use super::*;
167
168    #[test]
169    fn domain_display() {
170        assert_eq!(StateDomain::Finance.to_string(), "Finance");
171        assert_eq!(StateDomain::Custom("Crypto".into()).to_string(), "Crypto");
172    }
173
174    #[test]
175    fn initial_activity_zero() {
176        let sl = StateLine::new(StateDomain::Weather);
177        assert!((sl.activity - 0.0).abs() < f32::EPSILON);
178    }
179
180    #[test]
181    fn ema_smoothing() {
182        let mut sl = StateLine::new(StateDomain::Finance);
183        sl.update_activity(1.0, WorldTick(1));
184        // EMA: 0.3 * 1.0 + 0.7 * 0.0 = 0.3
185        assert!((sl.activity - 0.3).abs() < 1e-5, "got {}", sl.activity);
186    }
187
188    #[test]
189    fn spike_detection() {
190        let mut sl = StateLine::new(StateDomain::Finance);
191        // Establish a low baseline first (activity converges near 0.1).
192        for i in 0..10 {
193            sl.update_activity(0.1, WorldTick(i));
194        }
195        assert!(matches!(sl.trend, Trend::Stable));
196        // Suddenly jump to max — the second tick after the jump should
197        // register as a spike because the 3-sample window spans the
198        // transition.
199        sl.update_activity(1.0, WorldTick(10));
200        sl.update_activity(1.0, WorldTick(11));
201        assert_eq!(sl.trend, Trend::Spike);
202    }
203
204    #[test]
205    fn twelve_default_domains() {
206        let ws = WorldState::new(WorldClock::default());
207        assert_eq!(ws.state_lines.len(), 12);
208    }
209
210    #[test]
211    fn custom_domain_on_access() {
212        let mut ws = WorldState::new(WorldClock::default());
213        let domain = StateDomain::Custom("Crypto".into());
214        let sl = ws.state_line_mut(&domain);
215        assert_eq!(sl.domain, domain);
216        assert_eq!(ws.state_lines.len(), 13);
217    }
218}