//! Thinkers, and how to reach one. //! //! A node is an address, not a place. The same lance commander can be a closure //! on this thread, a subprocess in this container, or a service on another //! machine, and nothing above it changes: the tree is built from strings. //! //! That is the point of the indirection rather than a flourish. The interesting //! nodes are not all going to be Rust functions - a planner backed by a language //! model wants to run somewhere with a key and a GPU and a long timeout, and a //! unit thinker wants to run a thousand times a second next to the state. Both //! have to hang off the same tree or the tree is a lie. //! //! Three transports, one line protocol - a JSON document per line, in and out. //! Same bytes whether it crosses a function call, a pipe or a socket, so a node //! can be moved by editing a config string and nothing else. //! //! | address | where it runs | //! |---|---| //! | `local:brawler` | a thinker registered in this process | //! | `proc:python3 bots/planner.py` | a child process, over its stdio | //! | `tcp:10.0.0.4:7000` | anything listening on that socket | //! //! Deliberately not HTTP. The line protocol is the same on all three, so a //! remote node is a small adapter rather than a second protocol to keep in //! step; an HTTP transport can be added later without any caller learning about //! it. use std::collections::HashMap; use std::sync::Arc; use anyhow::{anyhow, Context, Result}; use async_trait::async_trait; use sds_core::features::latch::Latches; use sds_core::plan::{ForceDecision, UnitThought}; use sds_core::stance::{Grit, Stance}; use sds_core::wire::{Board, Observation}; use serde::{Deserialize, Serialize}; mod process; mod tcp; pub use process::ProcessNode; pub use tcp::TcpNode; /// What a node is asked. #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(tag = "ask", rename_all = "snake_case")] pub enum Request { /// Unit level: what could this machine do, and what is it worth. Propose { observation: Observation, /// The map. Carried in the request rather than assumed to be cached, /// because a unit thinker may be a process on another machine that has /// never seen this match before. #[serde(default)] board: Board, unit: i32, /// What the force is currently trying to do. A hint - a unit that can /// only serve the stance badly should say so rather than pretend. stance: Stance, /// What the force refuses to forget. Carried rather than kept here: a /// unit thinker may be a process that has seen none of this match. #[serde(default)] latches: Latches, }, /// Force level, at the top of a round: what would you be doing? /// /// Asked before movement, and answered without one hex having been looked /// at - see `sds_core::situation` for why that is a hard boundary rather /// than a convention. The force proposes; the coordinator harmonises the /// answers and hands each force an order back on [`Request::Command`]. Consider { force: String, round: i32, /// This force's own machines, still standing. #[serde(default)] ours: Vec, /// Every enemy machine this side can see. #[serde(default)] theirs: Vec, /// The tactic the force is on now, by name, or `None` in its first /// round. #[serde(default)] held: Option, }, /// Force level: here is everything below you; decide. Command { force: String, thoughts: Vec, stance: Stance, grit: Grit, round: i32, /// What each enemy has left, by unit id: armour plus structure. The /// denominator `sds_core::reconcile` normalises its target terms by, /// and the reason two units can decide that one of them finishes a /// machine rather than both emptying into it. /// /// Empty when the caller did not say. A target the map does not name /// is left out of the coordination terms rather than assumed healthy: /// an unknown denominator is not a large one. #[serde(default)] remaining: std::collections::BTreeMap, /// Every friendly machine still standing, whichever force it belongs /// to. /// /// What `sds_core::features::incoming` shares the enemy's fire out /// over. All of them rather than this force's: two lances that each /// normalise over their own units are two lances that each believe the /// whole enemy line is pointed at them, which is the count the /// allocation exists to remove. /// /// Empty when the caller did not say, and then no unit is given an /// incoming term at all - a feature nobody measured contributes /// nothing, and that is better than a denominator nobody has. #[serde(default)] ours: Vec, /// What this force proposed on [`Request::Consider`], carried back down /// so the decision it produces records what was asked for beside what /// was ordered. `None` when the caller did not harmonise. #[serde(default)] proposal: Option, /// The tactic the coordinator has put this force on for this round. /// /// An order, not a preference: the force proposed on /// [`Request::Consider`] and the coordinator has already weighed that /// against what it was on. `None` when the caller did not harmonise, /// and then the force falls back to its formation's opening, which is /// what it did before there was a coordinator to ask. #[serde(default)] tactic: Option, }, } /// What a node answers. #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(tag = "answer", rename_all = "snake_case")] pub enum Response { Thought(Box), Decision(Box), /// What a force would be doing, and everything it scored getting there. Considered(Box), } impl Response { pub fn into_thought(self) -> Result { match self { Response::Thought(t) => Ok(*t), other => Err(anyhow!("asked a unit to propose, got {}", other.kind())), } } pub fn into_decision(self) -> Result { match self { Response::Decision(d) => Ok(*d), other => Err(anyhow!("asked a force to command, got {}", other.kind())), } } pub fn into_considered(self) -> Result { match self { Response::Considered(c) => Ok(*c), other => Err(anyhow!("asked a force to consider, got {}", other.kind())), } } fn kind(&self) -> &'static str { match self { Response::Thought(_) => "a unit thought", Response::Decision(_) => "a force decision", Response::Considered(_) => "a tactic proposal", } } } /// Something that can think. Implement this to add a node that runs in-process. #[async_trait] pub trait Thinker: Send + Sync { async fn think(&self, request: Request) -> Result; } /// A reachable thinker. #[async_trait] pub trait Node: Send + Sync { fn address(&self) -> &str; async fn think(&self, request: Request) -> Result; } /// A thinker in this process, reached by name. pub struct LocalNode { address: String, thinker: Arc, } impl LocalNode { pub fn new(name: &str, thinker: Arc) -> Self { Self { address: format!("local:{name}"), thinker, } } } #[async_trait] impl Node for LocalNode { fn address(&self) -> &str { &self.address } async fn think(&self, request: Request) -> Result { self.thinker.think(request).await } } /// Builds nodes from address strings. /// /// The registry holds the in-process thinkers a `local:` address can name. /// Everything else is constructed on demand, so a config file naming a machine /// that is not there fails when it is dialled rather than at start-up - which /// is the behaviour you want when one remote planner is optional and the rest /// of the tree is not. #[derive(Default)] pub struct Registry { thinkers: HashMap>, } impl Registry { pub fn new() -> Self { Self::default() } pub fn register(&mut self, name: &str, thinker: Arc) -> &mut Self { self.thinkers.insert(name.to_string(), thinker); self } /// Resolve an address into a node. pub fn resolve(&self, address: &str) -> Result> { let (scheme, rest) = address .split_once(':') .with_context(|| format!("address '{address}' has no scheme"))?; match scheme { "local" => { let thinker = self .thinkers .get(rest) .with_context(|| format!("no thinker registered as '{rest}'"))?; Ok(Arc::new(LocalNode::new(rest, thinker.clone()))) } "proc" => Ok(Arc::new(ProcessNode::spawn(rest)?)), "tcp" => Ok(Arc::new(TcpNode::new(rest))), other => Err(anyhow!("unknown node scheme '{other}' in '{address}'")), } } }