Skip to main content

proof_engine/ai/
blackboard.rs

1//! Shared AI knowledge base — the Blackboard pattern.
2//!
3//! A `Blackboard` is a typed key-value store where each entry has an optional
4//! time-to-live (TTL).  Multiple agents can share a single `SharedBlackboard`
5//! (`Arc<RwLock<Blackboard>>`).  `BlackboardCondition` integrates with behavior
6//! trees to guard transitions on blackboard values.
7//!
8//! # Example
9//! ```rust
10//! use proof_engine::ai::blackboard::{Blackboard, BlackboardValue, BlackboardCondition};
11//!
12//! let mut bb = Blackboard::new();
13//! bb.set("enemy_health", BlackboardValue::Float(45.0));
14//! bb.set_with_ttl("target_pos", BlackboardValue::Vec2(glam::Vec2::new(3.0, 4.0)), 5.0);
15//!
16//! // Check a condition
17//! let cond = BlackboardCondition::KeyLessThan("enemy_health".into(), 50.0);
18//! assert!(cond.evaluate(&bb));
19//!
20//! bb.update(6.0); // expire the target_pos entry
21//! assert!(!bb.contains("target_pos"));
22//! ```
23
24use glam::{Vec2, Vec3};
25use std::collections::HashMap;
26use std::sync::{Arc, RwLock};
27
28// ---------------------------------------------------------------------------
29// BlackboardValue
30// ---------------------------------------------------------------------------
31
32/// All value types that can be stored on a blackboard.
33#[derive(Debug, Clone, PartialEq)]
34pub enum BlackboardValue {
35    Bool(bool),
36    Int(i64),
37    Float(f64),
38    Vec2(Vec2),
39    Vec3(Vec3),
40    Str(String),
41    Entity(u64),
42    List(Vec<BlackboardValue>),
43}
44
45impl BlackboardValue {
46    /// Attempt to extract a `f64` from Float, Int, or Bool.
47    pub fn as_float(&self) -> Option<f64> {
48        match self {
49            BlackboardValue::Float(v) => Some(*v),
50            BlackboardValue::Int(v)   => Some(*v as f64),
51            BlackboardValue::Bool(v)  => Some(if *v { 1.0 } else { 0.0 }),
52            _ => None,
53        }
54    }
55
56    pub fn as_bool(&self) -> Option<bool> {
57        match self {
58            BlackboardValue::Bool(v)  => Some(*v),
59            BlackboardValue::Int(v)   => Some(*v != 0),
60            BlackboardValue::Float(v) => Some(*v != 0.0),
61            _ => None,
62        }
63    }
64
65    pub fn as_int(&self) -> Option<i64> {
66        match self {
67            BlackboardValue::Int(v)   => Some(*v),
68            BlackboardValue::Float(v) => Some(*v as i64),
69            BlackboardValue::Bool(v)  => Some(if *v { 1 } else { 0 }),
70            _ => None,
71        }
72    }
73
74    pub fn as_str(&self) -> Option<&str> {
75        match self {
76            BlackboardValue::Str(s) => Some(s.as_str()),
77            _ => None,
78        }
79    }
80
81    pub fn as_vec2(&self) -> Option<Vec2> {
82        match self { BlackboardValue::Vec2(v) => Some(*v), _ => None }
83    }
84
85    pub fn as_vec3(&self) -> Option<Vec3> {
86        match self { BlackboardValue::Vec3(v) => Some(*v), _ => None }
87    }
88
89    pub fn as_entity(&self) -> Option<u64> {
90        match self { BlackboardValue::Entity(e) => Some(*e), _ => None }
91    }
92
93    pub fn as_list(&self) -> Option<&Vec<BlackboardValue>> {
94        match self { BlackboardValue::List(l) => Some(l), _ => None }
95    }
96
97    /// Human-readable type name.
98    pub fn type_name(&self) -> &'static str {
99        match self {
100            BlackboardValue::Bool(_)   => "bool",
101            BlackboardValue::Int(_)    => "int",
102            BlackboardValue::Float(_)  => "float",
103            BlackboardValue::Vec2(_)   => "vec2",
104            BlackboardValue::Vec3(_)   => "vec3",
105            BlackboardValue::Str(_)    => "str",
106            BlackboardValue::Entity(_) => "entity",
107            BlackboardValue::List(_)   => "list",
108        }
109    }
110}
111
112impl From<bool>   for BlackboardValue { fn from(v: bool)   -> Self { BlackboardValue::Bool(v) } }
113impl From<i64>    for BlackboardValue { fn from(v: i64)    -> Self { BlackboardValue::Int(v) } }
114impl From<i32>    for BlackboardValue { fn from(v: i32)    -> Self { BlackboardValue::Int(v as i64) } }
115impl From<f64>    for BlackboardValue { fn from(v: f64)    -> Self { BlackboardValue::Float(v) } }
116impl From<f32>    for BlackboardValue { fn from(v: f32)    -> Self { BlackboardValue::Float(v as f64) } }
117impl From<Vec2>   for BlackboardValue { fn from(v: Vec2)   -> Self { BlackboardValue::Vec2(v) } }
118impl From<Vec3>   for BlackboardValue { fn from(v: Vec3)   -> Self { BlackboardValue::Vec3(v) } }
119impl From<String> for BlackboardValue { fn from(v: String) -> Self { BlackboardValue::Str(v) } }
120impl From<&str>   for BlackboardValue { fn from(v: &str)   -> Self { BlackboardValue::Str(v.into()) } }
121impl From<u64>    for BlackboardValue {
122    fn from(v: u64) -> Self { BlackboardValue::Entity(v) }
123}
124
125// ---------------------------------------------------------------------------
126// BlackboardEntry
127// ---------------------------------------------------------------------------
128
129/// A single entry on the blackboard, with optional expiry.
130#[derive(Debug, Clone)]
131pub struct BlackboardEntry {
132    pub value: BlackboardValue,
133    /// Simulated time when this entry was written.
134    pub timestamp: f64,
135    /// Optional time-to-live in seconds.  `None` = permanent.
136    pub ttl: Option<f64>,
137}
138
139impl BlackboardEntry {
140    pub fn new(value: BlackboardValue, timestamp: f64) -> Self {
141        BlackboardEntry { value, timestamp, ttl: None }
142    }
143
144    pub fn with_ttl(mut self, ttl: f64) -> Self {
145        self.ttl = Some(ttl);
146        self
147    }
148
149    /// Returns `true` if this entry has expired at time `now`.
150    pub fn is_expired(&self, now: f64) -> bool {
151        match self.ttl {
152            Some(ttl) => now > self.timestamp + ttl,
153            None => false,
154        }
155    }
156
157    /// Remaining time-to-live; returns 0.0 if already expired.
158    pub fn remaining_ttl(&self, now: f64) -> f64 {
159        match self.ttl {
160            Some(ttl) => (self.timestamp + ttl - now).max(0.0),
161            None => f64::INFINITY,
162        }
163    }
164}
165
166// ---------------------------------------------------------------------------
167// Blackboard
168// ---------------------------------------------------------------------------
169
170/// A typed, expiring key-value knowledge base for AI agents.
171#[derive(Debug, Clone, Default)]
172pub struct Blackboard {
173    pub entries: HashMap<String, BlackboardEntry>,
174    pub current_time: f64,
175}
176
177impl Blackboard {
178    pub fn new() -> Self { Blackboard::default() }
179
180    /// Store a permanent entry.
181    pub fn set(&mut self, key: impl Into<String>, value: impl Into<BlackboardValue>) {
182        let entry = BlackboardEntry::new(value.into(), self.current_time);
183        self.entries.insert(key.into(), entry);
184    }
185
186    /// Store an entry that expires after `ttl` seconds.
187    pub fn set_with_ttl(
188        &mut self,
189        key: impl Into<String>,
190        value: impl Into<BlackboardValue>,
191        ttl: f64,
192    ) {
193        let entry = BlackboardEntry::new(value.into(), self.current_time).with_ttl(ttl);
194        self.entries.insert(key.into(), entry);
195    }
196
197    /// Retrieve a value if it exists and has not expired.
198    pub fn get(&self, key: &str) -> Option<&BlackboardValue> {
199        self.entries.get(key).and_then(|e| {
200            if e.is_expired(self.current_time) { None } else { Some(&e.value) }
201        })
202    }
203
204    /// Retrieve the full entry (value + metadata).
205    pub fn get_entry(&self, key: &str) -> Option<&BlackboardEntry> {
206        self.entries.get(key).filter(|e| !e.is_expired(self.current_time))
207    }
208
209    // Typed accessors -----------------------------------------------------------
210
211    pub fn get_bool(&self, key: &str) -> Option<bool> {
212        self.get(key)?.as_bool()
213    }
214
215    pub fn get_int(&self, key: &str) -> Option<i64> {
216        self.get(key)?.as_int()
217    }
218
219    pub fn get_float(&self, key: &str) -> Option<f64> {
220        self.get(key)?.as_float()
221    }
222
223    pub fn get_vec2(&self, key: &str) -> Option<Vec2> {
224        self.get(key)?.as_vec2()
225    }
226
227    pub fn get_vec3(&self, key: &str) -> Option<Vec3> {
228        self.get(key)?.as_vec3()
229    }
230
231    pub fn get_str(&self, key: &str) -> Option<&str> {
232        self.get(key)?.as_str()
233    }
234
235    pub fn get_entity(&self, key: &str) -> Option<u64> {
236        self.get(key)?.as_entity()
237    }
238
239    // Mutation ------------------------------------------------------------------
240
241    /// Remove a key from the blackboard.
242    pub fn remove(&mut self, key: &str) -> Option<BlackboardValue> {
243        self.entries.remove(key).map(|e| e.value)
244    }
245
246    /// Advance simulated time and remove all expired entries.
247    pub fn update(&mut self, dt: f64) {
248        self.current_time += dt;
249        self.entries.retain(|_, e| !e.is_expired(self.current_time));
250    }
251
252    /// Set the current time directly (useful for tests).
253    pub fn set_time(&mut self, t: f64) {
254        self.current_time = t;
255        self.entries.retain(|_, e| !e.is_expired(t));
256    }
257
258    // Query ---------------------------------------------------------------------
259
260    pub fn contains(&self, key: &str) -> bool { self.get(key).is_some() }
261
262    pub fn len(&self) -> usize {
263        self.entries.values().filter(|e| !e.is_expired(self.current_time)).count()
264    }
265
266    pub fn is_empty(&self) -> bool { self.len() == 0 }
267
268    /// Iterate over non-expired keys.
269    pub fn keys(&self) -> impl Iterator<Item = &String> {
270        self.entries.iter()
271            .filter(|(_, e)| !e.is_expired(self.current_time))
272            .map(|(k, _)| k)
273    }
274
275    /// Clear all entries.
276    pub fn clear(&mut self) { self.entries.clear(); }
277
278    /// Merge entries from `other` into this blackboard.
279    /// Entries in `other` overwrite entries in `self` if the key already exists.
280    pub fn merge(&mut self, other: &Blackboard) {
281        for (k, e) in &other.entries {
282            if !e.is_expired(other.current_time) {
283                self.entries.insert(k.clone(), e.clone());
284            }
285        }
286    }
287
288    /// Increment an integer value, inserting 0 + delta if not present.
289    pub fn increment_int(&mut self, key: &str, delta: i64) {
290        let current = self.get_int(key).unwrap_or(0);
291        self.set(key, BlackboardValue::Int(current + delta));
292    }
293
294    /// Increment a float value.
295    pub fn increment_float(&mut self, key: &str, delta: f64) {
296        let current = self.get_float(key).unwrap_or(0.0);
297        self.set(key, BlackboardValue::Float(current + delta));
298    }
299
300    /// Append a value to a list entry; creates the list if not present.
301    pub fn push_to_list(&mut self, key: &str, value: BlackboardValue) {
302        let mut list = match self.get(key) {
303            Some(BlackboardValue::List(l)) => l.clone(),
304            _ => Vec::new(),
305        };
306        list.push(value);
307        self.set(key, BlackboardValue::List(list));
308    }
309
310    /// Returns all non-expired (key, value) pairs.
311    pub fn snapshot(&self) -> Vec<(&String, &BlackboardValue)> {
312        self.entries.iter()
313            .filter(|(_, e)| !e.is_expired(self.current_time))
314            .map(|(k, e)| (k, &e.value))
315            .collect()
316    }
317}
318
319// ---------------------------------------------------------------------------
320// SharedBlackboard
321// ---------------------------------------------------------------------------
322
323/// A thread-safe, reference-counted blackboard for cross-agent sharing.
324#[derive(Debug, Clone)]
325pub struct SharedBlackboard(pub Arc<RwLock<Blackboard>>);
326
327impl SharedBlackboard {
328    pub fn new() -> Self {
329        SharedBlackboard(Arc::new(RwLock::new(Blackboard::new())))
330    }
331
332    pub fn set(&self, key: impl Into<String>, value: impl Into<BlackboardValue>) {
333        if let Ok(mut bb) = self.0.write() {
334            bb.set(key, value);
335        }
336    }
337
338    pub fn set_with_ttl(&self, key: impl Into<String>, value: impl Into<BlackboardValue>, ttl: f64) {
339        if let Ok(mut bb) = self.0.write() {
340            bb.set_with_ttl(key, value, ttl);
341        }
342    }
343
344    pub fn get_float(&self, key: &str) -> Option<f64> {
345        self.0.read().ok()?.get_float(key)
346    }
347
348    pub fn get_bool(&self, key: &str) -> Option<bool> {
349        self.0.read().ok()?.get_bool(key)
350    }
351
352    pub fn get_vec2(&self, key: &str) -> Option<Vec2> {
353        self.0.read().ok()?.get_vec2(key)
354    }
355
356    pub fn get_entity(&self, key: &str) -> Option<u64> {
357        self.0.read().ok()?.get_entity(key)
358    }
359
360    pub fn contains(&self, key: &str) -> bool {
361        self.0.read().map(|bb| bb.contains(key)).unwrap_or(false)
362    }
363
364    pub fn update(&self, dt: f64) {
365        if let Ok(mut bb) = self.0.write() {
366            bb.update(dt);
367        }
368    }
369
370    pub fn remove(&self, key: &str) {
371        if let Ok(mut bb) = self.0.write() {
372            bb.remove(key);
373        }
374    }
375
376    /// Create a snapshot of all non-expired values.
377    pub fn read<T, F: FnOnce(&Blackboard) -> T>(&self, f: F) -> Option<T> {
378        self.0.read().ok().map(|bb| f(&*bb))
379    }
380
381    pub fn write<T, F: FnOnce(&mut Blackboard) -> T>(&self, f: F) -> Option<T> {
382        self.0.write().ok().map(|mut bb| f(&mut *bb))
383    }
384}
385
386impl Default for SharedBlackboard {
387    fn default() -> Self { Self::new() }
388}
389
390// ---------------------------------------------------------------------------
391// BlackboardCondition
392// ---------------------------------------------------------------------------
393
394/// A condition that can be evaluated against a `Blackboard`.
395/// Primarily for use in behavior tree guard nodes.
396#[derive(Debug, Clone, PartialEq)]
397pub enum BlackboardCondition {
398    /// True if the key exists (and has not expired).
399    HasKey(String),
400    /// True if the key does NOT exist (or has expired).
401    NotHasKey(String),
402    /// True if the value at key equals the given `BlackboardValue`.
403    KeyEquals(String, BlackboardValue),
404    /// True if the numeric value at key is strictly greater than `threshold`.
405    KeyGreaterThan(String, f64),
406    /// True if the numeric value at key is strictly less than `threshold`.
407    KeyLessThan(String, f64),
408    /// True if the numeric value at key is >= `min` and <= `max`.
409    KeyInRange(String, f64, f64),
410    /// True if the boolean at key is true.
411    IsTrue(String),
412    /// True if the boolean at key is false (or absent).
413    IsFalse(String),
414    /// Logical AND of two conditions.
415    And(Box<BlackboardCondition>, Box<BlackboardCondition>),
416    /// Logical OR of two conditions.
417    Or(Box<BlackboardCondition>, Box<BlackboardCondition>),
418    /// Logical NOT of a condition.
419    Not(Box<BlackboardCondition>),
420}
421
422impl BlackboardCondition {
423    /// Evaluate this condition against the given blackboard.
424    pub fn evaluate(&self, bb: &Blackboard) -> bool {
425        match self {
426            BlackboardCondition::HasKey(k) => bb.contains(k),
427            BlackboardCondition::NotHasKey(k) => !bb.contains(k),
428            BlackboardCondition::KeyEquals(k, v) => bb.get(k) == Some(v),
429            BlackboardCondition::KeyGreaterThan(k, threshold) => {
430                bb.get_float(k).map(|f| f > *threshold).unwrap_or(false)
431            }
432            BlackboardCondition::KeyLessThan(k, threshold) => {
433                bb.get_float(k).map(|f| f < *threshold).unwrap_or(false)
434            }
435            BlackboardCondition::KeyInRange(k, min, max) => {
436                bb.get_float(k).map(|f| f >= *min && f <= *max).unwrap_or(false)
437            }
438            BlackboardCondition::IsTrue(k) => {
439                bb.get_bool(k).unwrap_or(false)
440            }
441            BlackboardCondition::IsFalse(k) => {
442                !bb.get_bool(k).unwrap_or(false)
443            }
444            BlackboardCondition::And(a, b) => a.evaluate(bb) && b.evaluate(bb),
445            BlackboardCondition::Or(a, b)  => a.evaluate(bb) || b.evaluate(bb),
446            BlackboardCondition::Not(c)    => !c.evaluate(bb),
447        }
448    }
449
450    // --- Builder helpers -------------------------------------------------------
451
452    pub fn and(self, other: BlackboardCondition) -> BlackboardCondition {
453        BlackboardCondition::And(Box::new(self), Box::new(other))
454    }
455
456    pub fn or(self, other: BlackboardCondition) -> BlackboardCondition {
457        BlackboardCondition::Or(Box::new(self), Box::new(other))
458    }
459
460    pub fn not(self) -> BlackboardCondition {
461        BlackboardCondition::Not(Box::new(self))
462    }
463}
464
465// ---------------------------------------------------------------------------
466// BlackboardObserver — tracks writes so systems can react
467// ---------------------------------------------------------------------------
468
469/// A simple change-notification system for the blackboard.
470#[derive(Debug, Clone, Default)]
471pub struct BlackboardObserver {
472    /// Keys to watch.
473    watched_keys: Vec<String>,
474    /// Last seen values per key.
475    last_values: HashMap<String, BlackboardValue>,
476    /// Keys that changed since last `drain_changes`.
477    changed: Vec<String>,
478}
479
480impl BlackboardObserver {
481    pub fn new() -> Self { BlackboardObserver::default() }
482
483    /// Register a key to watch.
484    pub fn watch(&mut self, key: impl Into<String>) {
485        self.watched_keys.push(key.into());
486    }
487
488    /// Poll the blackboard for changes to watched keys.
489    pub fn poll(&mut self, bb: &Blackboard) {
490        for key in &self.watched_keys {
491            let current = bb.get(key).cloned();
492            let previous = self.last_values.get(key).cloned();
493            if current != previous {
494                self.changed.push(key.clone());
495                match current {
496                    Some(v) => { self.last_values.insert(key.clone(), v); }
497                    None    => { self.last_values.remove(key); }
498                }
499            }
500        }
501    }
502
503    /// Returns and clears the list of changed keys since last poll.
504    pub fn drain_changes(&mut self) -> Vec<String> {
505        std::mem::take(&mut self.changed)
506    }
507
508    /// Check whether a key changed in the last poll.
509    pub fn has_changed(&self, key: &str) -> bool {
510        self.changed.iter().any(|k| k == key)
511    }
512}
513
514// ---------------------------------------------------------------------------
515// BlackboardSerializer — simple text-based debug dump
516// ---------------------------------------------------------------------------
517
518impl Blackboard {
519    /// Dump all non-expired entries to a human-readable string.
520    pub fn debug_dump(&self) -> String {
521        let mut entries: Vec<_> = self.entries.iter()
522            .filter(|(_, e)| !e.is_expired(self.current_time))
523            .collect();
524        entries.sort_by_key(|(k, _)| k.as_str());
525
526        let mut out = String::from("Blackboard {\n");
527        for (k, e) in &entries {
528            let ttl_info = match e.ttl {
529                Some(_) => format!(" [TTL: {:.2}s]", e.remaining_ttl(self.current_time)),
530                None => String::new(),
531            };
532            out += &format!("  {} = {:?}{}\n", k, e.value, ttl_info);
533        }
534        out += "}";
535        out
536    }
537}
538
539// ---------------------------------------------------------------------------
540// Unit tests
541// ---------------------------------------------------------------------------
542
543#[cfg(test)]
544mod tests {
545    use super::*;
546    use glam::Vec2;
547
548    #[test]
549    fn test_set_and_get() {
550        let mut bb = Blackboard::new();
551        bb.set("health", BlackboardValue::Float(100.0));
552        assert_eq!(bb.get_float("health"), Some(100.0));
553    }
554
555    #[test]
556    fn test_set_and_get_bool() {
557        let mut bb = Blackboard::new();
558        bb.set("alive", true);
559        assert_eq!(bb.get_bool("alive"), Some(true));
560    }
561
562    #[test]
563    fn test_set_and_get_int() {
564        let mut bb = Blackboard::new();
565        bb.set("kills", BlackboardValue::Int(5));
566        assert_eq!(bb.get_int("kills"), Some(5));
567    }
568
569    #[test]
570    fn test_set_and_get_vec2() {
571        let mut bb = Blackboard::new();
572        bb.set("pos", Vec2::new(1.0, 2.0));
573        assert_eq!(bb.get_vec2("pos"), Some(Vec2::new(1.0, 2.0)));
574    }
575
576    #[test]
577    fn test_set_and_get_entity() {
578        let mut bb = Blackboard::new();
579        bb.set("target", BlackboardValue::Entity(42));
580        assert_eq!(bb.get_entity("target"), Some(42));
581    }
582
583    #[test]
584    fn test_set_and_get_str() {
585        let mut bb = Blackboard::new();
586        bb.set("state", "patrolling");
587        assert_eq!(bb.get_str("state"), Some("patrolling"));
588    }
589
590    #[test]
591    fn test_ttl_expiry() {
592        let mut bb = Blackboard::new();
593        bb.set_with_ttl("temp", BlackboardValue::Float(1.0), 2.0);
594        assert!(bb.contains("temp"));
595        bb.update(3.0);
596        assert!(!bb.contains("temp"), "entry should have expired");
597    }
598
599    #[test]
600    fn test_ttl_not_yet_expired() {
601        let mut bb = Blackboard::new();
602        bb.set_with_ttl("temp", BlackboardValue::Float(1.0), 10.0);
603        bb.update(5.0);
604        assert!(bb.contains("temp"), "should still be valid");
605    }
606
607    #[test]
608    fn test_permanent_entry_not_expired() {
609        let mut bb = Blackboard::new();
610        bb.set("permanent", BlackboardValue::Bool(true));
611        bb.update(9999.0);
612        assert!(bb.contains("permanent"));
613    }
614
615    #[test]
616    fn test_remove() {
617        let mut bb = Blackboard::new();
618        bb.set("x", BlackboardValue::Int(1));
619        assert!(bb.contains("x"));
620        bb.remove("x");
621        assert!(!bb.contains("x"));
622    }
623
624    #[test]
625    fn test_len() {
626        let mut bb = Blackboard::new();
627        bb.set("a", BlackboardValue::Bool(true));
628        bb.set("b", BlackboardValue::Bool(false));
629        assert_eq!(bb.len(), 2);
630    }
631
632    #[test]
633    fn test_clear() {
634        let mut bb = Blackboard::new();
635        bb.set("a", BlackboardValue::Bool(true));
636        bb.clear();
637        assert_eq!(bb.len(), 0);
638    }
639
640    #[test]
641    fn test_merge() {
642        let mut bb1 = Blackboard::new();
643        bb1.set("a", BlackboardValue::Int(1));
644        let mut bb2 = Blackboard::new();
645        bb2.set("b", BlackboardValue::Int(2));
646        bb2.set("a", BlackboardValue::Int(99)); // should overwrite
647        bb1.merge(&bb2);
648        assert_eq!(bb1.get_int("a"), Some(99));
649        assert_eq!(bb1.get_int("b"), Some(2));
650    }
651
652    #[test]
653    fn test_condition_has_key() {
654        let mut bb = Blackboard::new();
655        bb.set("x", BlackboardValue::Bool(true));
656        assert!(BlackboardCondition::HasKey("x".into()).evaluate(&bb));
657        assert!(!BlackboardCondition::HasKey("y".into()).evaluate(&bb));
658    }
659
660    #[test]
661    fn test_condition_not_has_key() {
662        let bb = Blackboard::new();
663        assert!(BlackboardCondition::NotHasKey("missing".into()).evaluate(&bb));
664    }
665
666    #[test]
667    fn test_condition_key_equals() {
668        let mut bb = Blackboard::new();
669        bb.set("mode", "attack");
670        let cond = BlackboardCondition::KeyEquals("mode".into(), BlackboardValue::Str("attack".into()));
671        assert!(cond.evaluate(&bb));
672        let cond2 = BlackboardCondition::KeyEquals("mode".into(), BlackboardValue::Str("flee".into()));
673        assert!(!cond2.evaluate(&bb));
674    }
675
676    #[test]
677    fn test_condition_greater_than() {
678        let mut bb = Blackboard::new();
679        bb.set("health", BlackboardValue::Float(75.0));
680        assert!(BlackboardCondition::KeyGreaterThan("health".into(), 50.0).evaluate(&bb));
681        assert!(!BlackboardCondition::KeyGreaterThan("health".into(), 80.0).evaluate(&bb));
682    }
683
684    #[test]
685    fn test_condition_less_than() {
686        let mut bb = Blackboard::new();
687        bb.set("health", BlackboardValue::Float(25.0));
688        assert!(BlackboardCondition::KeyLessThan("health".into(), 50.0).evaluate(&bb));
689        assert!(!BlackboardCondition::KeyLessThan("health".into(), 10.0).evaluate(&bb));
690    }
691
692    #[test]
693    fn test_condition_in_range() {
694        let mut bb = Blackboard::new();
695        bb.set("ammo", BlackboardValue::Int(5));
696        assert!(BlackboardCondition::KeyInRange("ammo".into(), 1.0, 10.0).evaluate(&bb));
697        assert!(!BlackboardCondition::KeyInRange("ammo".into(), 6.0, 10.0).evaluate(&bb));
698    }
699
700    #[test]
701    fn test_condition_is_true_false() {
702        let mut bb = Blackboard::new();
703        bb.set("armed", true);
704        assert!(BlackboardCondition::IsTrue("armed".into()).evaluate(&bb));
705        assert!(!BlackboardCondition::IsFalse("armed".into()).evaluate(&bb));
706    }
707
708    #[test]
709    fn test_condition_and() {
710        let mut bb = Blackboard::new();
711        bb.set("a", true);
712        bb.set("b", true);
713        let cond = BlackboardCondition::IsTrue("a".into())
714            .and(BlackboardCondition::IsTrue("b".into()));
715        assert!(cond.evaluate(&bb));
716        bb.set("b", false);
717        assert!(!cond.evaluate(&bb));
718    }
719
720    #[test]
721    fn test_condition_or() {
722        let mut bb = Blackboard::new();
723        bb.set("a", true);
724        bb.set("b", false);
725        let cond = BlackboardCondition::IsTrue("a".into())
726            .or(BlackboardCondition::IsTrue("b".into()));
727        assert!(cond.evaluate(&bb));
728    }
729
730    #[test]
731    fn test_condition_not() {
732        let mut bb = Blackboard::new();
733        bb.set("enemy_visible", false);
734        let cond = BlackboardCondition::IsTrue("enemy_visible".into()).not();
735        assert!(cond.evaluate(&bb));
736    }
737
738    #[test]
739    fn test_increment_int() {
740        let mut bb = Blackboard::new();
741        bb.increment_int("score", 10);
742        bb.increment_int("score", 5);
743        assert_eq!(bb.get_int("score"), Some(15));
744    }
745
746    #[test]
747    fn test_increment_float() {
748        let mut bb = Blackboard::new();
749        bb.increment_float("damage", 12.5);
750        bb.increment_float("damage", 7.5);
751        assert!((bb.get_float("damage").unwrap() - 20.0).abs() < 0.001);
752    }
753
754    #[test]
755    fn test_push_to_list() {
756        let mut bb = Blackboard::new();
757        bb.push_to_list("log", BlackboardValue::Str("event1".into()));
758        bb.push_to_list("log", BlackboardValue::Str("event2".into()));
759        let list = bb.get("log").unwrap().as_list().unwrap();
760        assert_eq!(list.len(), 2);
761    }
762
763    #[test]
764    fn test_shared_blackboard() {
765        let shared = SharedBlackboard::new();
766        shared.set("hp", BlackboardValue::Float(80.0));
767        assert_eq!(shared.get_float("hp"), Some(80.0));
768    }
769
770    #[test]
771    fn test_shared_blackboard_ttl_update() {
772        let shared = SharedBlackboard::new();
773        shared.set_with_ttl("temp", BlackboardValue::Bool(true), 1.0);
774        assert!(shared.contains("temp"));
775        shared.update(2.0);
776        assert!(!shared.contains("temp"));
777    }
778
779    #[test]
780    fn test_observer_poll() {
781        let mut bb = Blackboard::new();
782        let mut obs = BlackboardObserver::new();
783        obs.watch("health");
784        bb.set("health", BlackboardValue::Float(100.0));
785        obs.poll(&bb);
786        let changes = obs.drain_changes();
787        assert!(changes.contains(&"health".to_string()));
788    }
789
790    #[test]
791    fn test_observer_no_change() {
792        let mut bb = Blackboard::new();
793        bb.set("health", BlackboardValue::Float(100.0));
794        let mut obs = BlackboardObserver::new();
795        obs.watch("health");
796        obs.poll(&bb); // first poll — sets last value
797        obs.drain_changes();
798        obs.poll(&bb); // nothing changed
799        assert!(obs.drain_changes().is_empty());
800    }
801
802    #[test]
803    fn test_debug_dump() {
804        let mut bb = Blackboard::new();
805        bb.set("x", BlackboardValue::Int(42));
806        let dump = bb.debug_dump();
807        assert!(dump.contains("x"));
808    }
809
810    #[test]
811    fn test_snapshot() {
812        let mut bb = Blackboard::new();
813        bb.set("a", BlackboardValue::Bool(true));
814        bb.set("b", BlackboardValue::Float(3.14));
815        let snap = bb.snapshot();
816        assert_eq!(snap.len(), 2);
817    }
818
819    #[test]
820    fn test_value_from_impls() {
821        let v: BlackboardValue = true.into();
822        assert_eq!(v, BlackboardValue::Bool(true));
823        let v: BlackboardValue = 42i64.into();
824        assert_eq!(v, BlackboardValue::Int(42));
825        let v: BlackboardValue = 3.14f64.into();
826        assert_eq!(v, BlackboardValue::Float(3.14));
827    }
828
829    #[test]
830    fn test_remaining_ttl() {
831        let mut bb = Blackboard::new();
832        bb.set_with_ttl("item", BlackboardValue::Bool(true), 10.0);
833        let entry = bb.get_entry("item").unwrap();
834        assert!((entry.remaining_ttl(0.0) - 10.0).abs() < 0.001);
835        assert!((entry.remaining_ttl(5.0) - 5.0).abs() < 0.001);
836        assert_eq!(entry.remaining_ttl(15.0), 0.0);
837    }
838}