Skip to main content

thread_engine/
behavior.rs

1//! **Behavior ABI** — the sandboxed interactivity layer (behavior-abi-v0.1).
2//!
3//! A world's WASM behavior module receives events (a player interacts, a tick) and
4//! returns **declarative [`Action`]s** — intents the *browser* performs. The module
5//! never touches IO: it can't open a URL, buy something, or veilwalk directly; it
6//! asks the host to, so the sandbox stays pure and the host stays authoritative.
7//! That's the whole safety model, and it's what lets a world be interactive without
8//! a browser trusting its code.
9//!
10//! This module is the transport-agnostic core: the [`Action`]/[`InteractEvent`]
11//! types (the wire between module and host) and the [`Behavior`] seam. The actual
12//! WASM execution is a feature-gated binding (`behaviors`, see
13//! [`crate::behavior_wasm`]); tests here use a plain-Rust `Behavior`, so the ABI and
14//! the dispatch model are verified with no WASM runtime.
15
16use serde::{Deserialize, Serialize};
17
18/// A declarative effect a behavior asks the host to perform (module → host). The
19/// host — never the module — carries it out, so the sandbox never touches IO.
20#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
21#[serde(tag = "action")]
22pub enum Action {
23    /// Open a canonical Codex entry panel.
24    #[serde(rename = "codex.open")]
25    CodexOpen { slug: String },
26    /// Buy an item. `price_ref` is a *reference*, never authoritative — the server
27    /// sets the real price (matches server-side pricing). Replies `purchase_result`.
28    #[serde(rename = "commerce.buy")]
29    CommerceBuy {
30        item: String,
31        #[serde(default)]
32        price_ref: serde_json::Value,
33    },
34    /// Veilwalk to a Locator.
35    #[serde(rename = "navigate")]
36    Navigate { to: String },
37    /// A transient UI toast.
38    #[serde(rename = "notify")]
39    Notify {
40        text: String,
41        #[serde(default, skip_serializing_if = "Option::is_none")]
42        level: Option<String>,
43    },
44    /// Relay an interaction to co-present travelers.
45    #[serde(rename = "presence.emit")]
46    PresenceEmit {
47        event: String,
48        #[serde(default)]
49        data: serde_json::Value,
50    },
51    /// Mutate visible placement state (colour, visibility, transform).
52    #[serde(rename = "set_state")]
53    SetState {
54        #[serde(default, skip_serializing_if = "Option::is_none")]
55        placement: Option<String>,
56        patch: serde_json::Value,
57    },
58    /// Put an item in the visitor's inventory. `item` is a material/item number
59    /// (the `NNNN` of a `StructuredId`). Emitted by declarative interactions.
60    #[serde(rename = "give_item")]
61    GiveItem { item: u16, count: u32 },
62    /// Remove a placed object from the world (mesh + collider) at `position` — a
63    /// declarative `despawn`. The browser hides the nearest rendered instance.
64    #[serde(rename = "despawn")]
65    Despawn { position: [f32; 3] },
66    /// Conjure a new object into the world: a builtin mesh (`cube`/`sphere`/…)
67    /// at `position`, uniformly scaled, colored, optionally **named** — a named
68    /// spawn is addressable by later `set_state` (move/tint) calls. The browser
69    /// caps runaway spawners; behaviors conjure decor, not geometry floods.
70    #[serde(rename = "spawn")]
71    Spawn {
72        builtin: String,
73        position: [f32; 3],
74        #[serde(default = "one_f32")]
75        scale: f32,
76        #[serde(default = "white_rgb")]
77        color: [f32; 3],
78        #[serde(default)]
79        name: String,
80        /// Declarative idle motion for the conjured object: `"spin"`, `"bob"`,
81        /// or empty for static — the same vocabulary as manifest `animate`.
82        #[serde(default)]
83        animate: String,
84        #[serde(default = "one_f32")]
85        anim_speed: f32,
86        #[serde(default = "quarter_f32")]
87        anim_amp: f32,
88    },
89}
90
91fn quarter_f32() -> f32 {
92    0.25
93}
94
95fn one_f32() -> f32 {
96    1.0
97}
98fn white_rgb() -> [f32; 3] {
99    [1.0, 1.0, 1.0]
100}
101
102/// The return of `thread_on_interact` — the effects a behavior wants performed.
103#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
104pub struct ActionList {
105    #[serde(default)]
106    pub actions: Vec<Action>,
107}
108
109/// Who triggered an event (the interacting player), as far as a behavior may know.
110#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
111pub struct Actor {
112    /// The interacting Passport subject, if the player is identified.
113    #[serde(default, skip_serializing_if = "Option::is_none")]
114    pub passport_sub: Option<String>,
115}
116
117/// `host → module` when a player interacts with a bound placement.
118#[derive(Debug, Clone, Serialize, Deserialize)]
119pub struct InteractEvent {
120    /// The placement name that was interacted with.
121    pub placement: String,
122    #[serde(default)]
123    pub actor: Actor,
124    /// The world's id.
125    pub world: String,
126    /// The placement's manifest `data` block (e.g. `{item, price, currency}`).
127    #[serde(default)]
128    pub data: serde_json::Value,
129}
130
131/// One instantiated behavior module (a WASM instance, or a plain-Rust one in tests).
132/// The browser owns a [`Behavior`] per manifest `behavior` id and dispatches events
133/// to it, then performs the [`Action`]s it returns.
134pub trait Behavior {
135    /// Called once after instantiation.
136    fn on_load(&mut self) {}
137    /// A player interacted with a bound placement → the effects to perform.
138    fn on_interact(&mut self, event: &InteractEvent) -> Vec<Action>;
139    /// A per-frame tick, if the behavior declared `"tick"` in its `on[]`.
140    fn on_tick(&mut self, _dt_ms: i32) {}
141    /// An asynchronous reply (`purchase_result`, `codex_ready`, presence, …).
142    fn on_event(&mut self, _event: &serde_json::Value) -> Vec<Action> {
143        Vec::new()
144    }
145}
146
147/// Parse an `ActionList` from a behavior's JSON return (empty on malformed — a
148/// misbehaving module must never break the world; the super-stable tenet).
149pub fn parse_actions(json: &str) -> Vec<Action> {
150    serde_json::from_str::<ActionList>(json)
151        .map(|a| a.actions)
152        .unwrap_or_default()
153}
154
155#[cfg(test)]
156mod tests {
157    use super::*;
158    use serde_json::json;
159
160    #[test]
161    fn actions_use_the_dotted_wire_names() {
162        let a = Action::CodexOpen {
163            slug: "the-thread".into(),
164        };
165        assert_eq!(
166            serde_json::to_value(&a).unwrap(),
167            json!({"action": "codex.open", "slug": "the-thread"})
168        );
169        let n = Action::Navigate {
170            to: "thread://pixygon.io".into(),
171        };
172        assert_eq!(serde_json::to_value(&n).unwrap()["action"], "navigate");
173    }
174
175    #[test]
176    fn action_list_round_trips() {
177        let list = ActionList {
178            actions: vec![
179                Action::Notify {
180                    text: "Sold!".into(),
181                    level: Some("info".into()),
182                },
183                Action::SetState {
184                    placement: Some("shelf".into()),
185                    patch: json!({"sold": true}),
186                },
187            ],
188        };
189        let json = serde_json::to_string(&list).unwrap();
190        assert_eq!(parse_actions(&json), list.actions);
191    }
192
193    #[test]
194    fn malformed_returns_no_actions_not_an_error() {
195        assert!(parse_actions("not json").is_empty());
196        assert!(parse_actions("{}").is_empty());
197    }
198
199    /// The two reference behaviors (§7) implemented in plain Rust — proves the ABI +
200    /// dispatch model with no WASM runtime.
201    struct CodexViewer;
202    impl Behavior for CodexViewer {
203        fn on_interact(&mut self, e: &InteractEvent) -> Vec<Action> {
204            match e.data.get("slug").and_then(|s| s.as_str()) {
205                Some(slug) => vec![Action::CodexOpen {
206                    slug: slug.to_string(),
207                }],
208                None => vec![],
209            }
210        }
211    }
212
213    #[test]
214    fn codex_viewer_reference_behavior() {
215        let mut b = CodexViewer;
216        let ev = InteractEvent {
217            placement: "pedestal".into(),
218            actor: Actor::default(),
219            world: "codex-archive".into(),
220            data: json!({ "slug": "the-thread" }),
221        };
222        assert_eq!(
223            b.on_interact(&ev),
224            vec![Action::CodexOpen {
225                slug: "the-thread".into()
226            }]
227        );
228    }
229}