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}