mecha10-core 4.2.9

Mecha10 core runtime and foundational types
Documentation
//! Synchronous, in-memory scratch storage shared by sibling nodes in a running behavior tree.
//!
//! `Blackboard` is a key/value store (`String` -> `serde_json::Value`) that lets sibling
//! `BehaviorNode`s read and write shared scratch data directly, without a pub/sub topic
//! round-trip through `message_bus`. One `Blackboard` is attached to each [`Context`](crate::context::Context)
//! (see `Context::blackboard`), so its lifetime matches the owning tree/process: it is
//! constructed once when the `Context` is built and is **not** persisted anywhere - it holds
//! only what's currently in memory and is gone on process restart.
//!
//! This is intentionally a different, much simpler mechanism than
//! [`crate::state::ConcreteStateManager`] (accessed via `Context::state_manager`), which is a
//! deliberately *persistent* store for surviving process restarts. Reach for the blackboard for
//! cheap scratch data within a single run of a tree; reach for the state manager when the value
//! needs to outlive the process.
//!
//! All operations are synchronous (backed by `std::sync::RwLock`, not `tokio::sync::RwLock`), so
//! they can be called from anywhere - including non-async code - without an `.await`.
//!
//! Values are stored as `serde_json::Value` for stringly-typed flexibility across node types,
//! with [`Blackboard::get_as`]/[`Blackboard::set_as`] convenience wrappers for working with
//! concrete `Deserialize`/`Serialize` types directly.

use crate::error::Result;
use serde::de::DeserializeOwned;
use serde::Serialize;
use std::collections::HashMap;
use std::sync::{Arc, RwLock};

/// A synchronous, thread-safe, in-memory key/value store scoped to a single running behavior
/// tree. See the [module docs](self) for details.
///
/// Cloning a `Blackboard` is cheap and shares the same underlying storage (it wraps an
/// `Arc<RwLock<..>>` internally), mirroring how [`Context`](crate::context::Context) itself is
/// cloned per tick while remaining backed by the same shared state.
#[derive(Clone, Debug, Default)]
pub struct Blackboard {
    data: Arc<RwLock<HashMap<String, serde_json::Value>>>,
}

impl Blackboard {
    /// Create a new, empty blackboard.
    pub fn new() -> Self {
        Self::default()
    }

    /// Get the raw JSON value stored under `key`, if any.
    pub fn get(&self, key: &str) -> Option<serde_json::Value> {
        self.data
            .read()
            .unwrap_or_else(|poisoned| poisoned.into_inner())
            .get(key)
            .cloned()
    }

    /// Get the value stored under `key`, deserialized into `T`.
    ///
    /// Returns `Ok(None)` if the key isn't present, and `Err` if the stored value can't be
    /// deserialized into `T`.
    pub fn get_as<T: DeserializeOwned>(&self, key: &str) -> Result<Option<T>> {
        match self.get(key) {
            Some(value) => Ok(Some(serde_json::from_value(value)?)),
            None => Ok(None),
        }
    }

    /// Set `key` to the given raw JSON value, overwriting any existing value.
    pub fn set(&self, key: impl Into<String>, value: serde_json::Value) {
        self.data
            .write()
            .unwrap_or_else(|poisoned| poisoned.into_inner())
            .insert(key.into(), value);
    }

    /// Serialize `value` and store it under `key`, overwriting any existing value.
    pub fn set_as<T: Serialize>(&self, key: impl Into<String>, value: &T) -> Result<()> {
        let json = serde_json::to_value(value)?;
        self.set(key, json);
        Ok(())
    }

    /// Remove and return the value stored under `key`, if any.
    pub fn remove(&self, key: &str) -> Option<serde_json::Value> {
        self.data
            .write()
            .unwrap_or_else(|poisoned| poisoned.into_inner())
            .remove(key)
    }

    /// Remove every entry from the blackboard.
    pub fn clear(&self) {
        self.data
            .write()
            .unwrap_or_else(|poisoned| poisoned.into_inner())
            .clear();
    }

    /// Returns `true` if `key` is present.
    pub fn contains_key(&self, key: &str) -> bool {
        self.data
            .read()
            .unwrap_or_else(|poisoned| poisoned.into_inner())
            .contains_key(key)
    }

    /// Number of entries currently stored.
    pub fn len(&self) -> usize {
        self.data.read().unwrap_or_else(|poisoned| poisoned.into_inner()).len()
    }

    /// Returns `true` if the blackboard has no entries.
    pub fn is_empty(&self) -> bool {
        self.data
            .read()
            .unwrap_or_else(|poisoned| poisoned.into_inner())
            .is_empty()
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use serde::Deserialize;

    #[test]
    fn new_blackboard_is_empty() {
        let bb = Blackboard::new();
        assert!(bb.is_empty());
        assert_eq!(bb.len(), 0);
        assert_eq!(bb.get("missing"), None);
    }

    #[test]
    fn set_get_remove_clear_round_trip() {
        let bb = Blackboard::new();

        bb.set("target_x", serde_json::json!(1.5));
        assert_eq!(bb.get("target_x"), Some(serde_json::json!(1.5)));
        assert!(bb.contains_key("target_x"));
        assert_eq!(bb.len(), 1);

        bb.set("target_x", serde_json::json!(2.5));
        assert_eq!(bb.get("target_x"), Some(serde_json::json!(2.5)));

        let removed = bb.remove("target_x");
        assert_eq!(removed, Some(serde_json::json!(2.5)));
        assert_eq!(bb.get("target_x"), None);
        assert!(!bb.contains_key("target_x"));

        bb.set("a", serde_json::json!("x"));
        bb.set("b", serde_json::json!("y"));
        assert_eq!(bb.len(), 2);
        bb.clear();
        assert!(bb.is_empty());
    }

    #[derive(Debug, Serialize, Deserialize, PartialEq)]
    struct Target {
        x: f64,
        y: f64,
    }

    #[test]
    fn typed_get_set_round_trip() {
        let bb = Blackboard::new();
        assert_eq!(bb.get_as::<Target>("target").unwrap(), None);

        let target = Target { x: 1.0, y: 2.0 };
        bb.set_as("target", &target).unwrap();

        assert_eq!(bb.get_as::<Target>("target").unwrap(), Some(target));
    }

    #[test]
    fn typed_get_errors_on_mismatched_shape() {
        let bb = Blackboard::new();
        bb.set("target", serde_json::json!("not an object"));

        assert!(bb.get_as::<Target>("target").is_err());
    }

    #[test]
    fn clone_shares_underlying_storage() {
        let bb = Blackboard::new();
        let clone = bb.clone();

        clone.set("shared", serde_json::json!(true));

        assert_eq!(bb.get("shared"), Some(serde_json::json!(true)));
    }

    #[test]
    fn concurrent_reads_and_writes_do_not_panic_or_deadlock() {
        use std::thread;

        let bb = Blackboard::new();
        for i in 0..8 {
            bb.set(format!("key{i}"), serde_json::json!(i));
        }

        let mut handles = Vec::new();
        for i in 0..8 {
            let bb = bb.clone();
            handles.push(thread::spawn(move || {
                for _ in 0..100 {
                    let _ = bb.get(&format!("key{i}"));
                    bb.set(format!("key{i}"), serde_json::json!(i));
                    let _ = bb.len();
                }
            }));
        }

        for handle in handles {
            handle.join().expect("blackboard task should not panic");
        }

        assert_eq!(bb.len(), 8);
    }
}