Skip to main content

arora_web/
lib.rs

1//! Run an Arora device in the browser.
2//!
3//! The centerpiece is [`AroraWeb`] — exported to JavaScript as `AroraRuntime`
4//! — the `wasm_bindgen` surface over an [`arora::Arora`]: a synchronous
5//! [`step`](AroraWeb::step), a self-pacing [`run`](AroraWeb::run), in-process
6//! [`call`](AroraWeb::call) dispatch, and the [`store_json`] accessors that
7//! move store values across the JS boundary as JSON in the Arora
8//! [`Value`] vocabulary. Its JS constructor (and [`AroraWebBuilder`], JS
9//! `AroraRuntimeBuilder`) builds the self-contained demo device on the
10//! runtime's in-process defaults; a downstream device composes its own
11//! [`arora::Arora`] with [`arora::AroraBuilder`] — the HAL, bridge, and store
12//! seams are trait objects that cannot cross the JS boundary — and wraps it
13//! with `AroraWeb::from`, reusing this whole JS surface.
14//!
15//! It also carries a lower-level surface: [`Engine`] and [`BehaviorTreeRunner`]
16//! load guest modules (header JSON + executable bytes) and run behavior trees
17//! directly on the engine, hosting modules via the browser's native
18//! `WebAssembly` runtime — see `arora_engine::executor::browser`.
19//!
20//! This crate only carries non-trivial content when built for `wasm32-*`
21//! targets. On the host it is an empty shim (deps are gated to `wasm32`) so it
22//! can sit in the workspace and be verified by `cargo package` on publish
23//! without pulling wasm-only deps into a host link.
24
25#![cfg(target_arch = "wasm32")]
26
27use std::cell::RefCell;
28use std::collections::HashMap;
29use std::pin::Pin;
30use std::rc::Rc;
31
32use arora_engine::{
33    call::{CallBridge, Callable, CallableId},
34    engine::EngineBuilder,
35    executor::browser::{BrowserExecutor, SharedLoaderRc},
36    load::load_module_from_parts,
37};
38use arora_types::module::low::Header;
39use arora_types::{
40    call::Call,
41    value::{Enumeration, StructureField, StructureWithoutId, Value},
42};
43use uuid::Uuid;
44use wasm_bindgen::prelude::*;
45
46/// Route Rust panics to the browser console. Called from every entry point
47/// (the [`AroraWeb`] constructors, `Engine::new`, `BehaviorTreeRunner::new`)
48/// rather than from a `#[wasm_bindgen(start)]`: a reusable library must not
49/// claim the module `start`, or every downstream cdylib that also defines one
50/// collides on the `_start` symbol at link time. `set_once` makes repeat calls
51/// free.
52fn install_panic_hook() {
53    console_error_panic_hook::set_once();
54}
55
56// =============================================================================
57// The browser device
58// =============================================================================
59
60use arora::{Arora, AroraBuilder, Caller, LocalCaller};
61use arora_types::data::{DataStore, Key, StateChange, Subscription};
62use std::time::Duration;
63
64/// Value↔JSON accessors over a device's [`DataStore`] and change
65/// [`Subscription`] — the store surface every browser device shares. Values
66/// cross the JS boundary as JSON in the Arora [`Value`] vocabulary, e.g.
67/// `{"f32": 0.75}`. [`AroraWeb`] exposes them as methods; a downstream device
68/// wrapping its own store reaches them here directly.
69pub mod store_json {
70    use super::*;
71
72    /// Write one key into the store. `value_json` is an Arora [`Value`] as
73    /// JSON, e.g. `{"f32": 0.75}`.
74    pub fn set_value(store: &dyn DataStore, path: &str, value_json: &str) -> Result<(), JsValue> {
75        let value: Value = serde_json::from_str(value_json)
76            .map_err(|e| JsValue::from_str(&format!("invalid value json for {path}: {e}")))?;
77        store
78            .write(StateChange::set(path, value))
79            .map_err(|e| JsValue::from_str(&format!("write {path} failed: {e}")))
80    }
81
82    /// Write several keys at once, as one store change. `values_json` is a JSON
83    /// object mapping each key path to an Arora [`Value`], e.g.
84    /// `{"sensor/x": {"f32": 0.75}}`.
85    pub fn write_values(store: &dyn DataStore, values_json: &str) -> Result<(), JsValue> {
86        let map: serde_json::Map<String, serde_json::Value> = serde_json::from_str(values_json)
87            .map_err(|e| JsValue::from_str(&format!("invalid values json: {e}")))?;
88        let mut change = StateChange::new();
89        for (path, raw) in map {
90            let value: Value = serde_json::from_value(raw)
91                .map_err(|e| JsValue::from_str(&format!("invalid value for {path}: {e}")))?;
92            change.set.insert(Key::new(path), Some(value));
93        }
94        store
95            .write(change)
96            .map_err(|e| JsValue::from_str(&format!("write failed: {e}")))
97    }
98
99    /// Read keys from the store. `paths` is a JS `string[]`; the result is a JS
100    /// object mapping each path to its Arora [`Value`] (or `null` if absent).
101    pub fn read_values(store: &dyn DataStore, paths: JsValue) -> Result<JsValue, JsValue> {
102        let paths: Vec<String> = serde_wasm_bindgen::from_value(paths)
103            .map_err(|e| JsValue::from_str(&format!("paths must be a string[]: {e}")))?;
104        let keys: Vec<Key> = paths.iter().map(Key::new).collect();
105        let values = store.read(&keys);
106        let mut out = serde_json::Map::with_capacity(paths.len());
107        for (path, value) in paths.into_iter().zip(values) {
108            out.insert(path, value_to_json(value)?);
109        }
110        to_js_object(out)
111    }
112
113    /// A snapshot of every key currently in the store, as a JS object mapping
114    /// path → Arora [`Value`].
115    pub fn snapshot(store: &dyn DataStore) -> Result<JsValue, JsValue> {
116        let state = store.snapshot();
117        let mut out = serde_json::Map::with_capacity(state.storage.len());
118        for (key, value) in state.storage {
119            out.insert(key.path, value_to_json(value)?);
120        }
121        to_js_object(out)
122    }
123
124    /// Drain the changes the subscription accumulated, as a JS object mapping
125    /// path → new Arora [`Value`] (or `null` for a cleared key). Poll-based
126    /// counterpart to a push subscription: [`DataStore::subscribe`] delivers
127    /// over a std channel JavaScript cannot await, so changes accumulate and
128    /// are handed over on demand. A subscription opens on the store's whole
129    /// current state, so the first drain returns the full picture.
130    pub fn drain_changes(changes: &Subscription) -> Result<JsValue, JsValue> {
131        let mut out = serde_json::Map::new();
132        while let Some(change) = changes.try_recv() {
133            for (key, value) in change.set {
134                out.insert(key.path, value_to_json(value)?);
135            }
136            for key in change.unset {
137                out.insert(key.path, serde_json::Value::Null);
138            }
139        }
140        to_js_object(out)
141    }
142
143    /// Serialize a path-keyed JSON map to a **plain JS object**.
144    /// serde-wasm-bindgen's default representation for maps is a JS `Map`, but
145    /// these accessors promise plain objects (`{"sensor/x": {"f32": 0.5}}`),
146    /// so serialize JSON-compatibly.
147    fn to_js_object(out: serde_json::Map<String, serde_json::Value>) -> Result<JsValue, JsValue> {
148        use serde::Serialize;
149        serde_json::Value::Object(out)
150            .serialize(&serde_wasm_bindgen::Serializer::json_compatible())
151            .map_err(|e| JsValue::from_str(&format!("serialize failed: {e}")))
152    }
153
154    /// Serialize an optional Arora value to JSON (`null` when absent).
155    fn value_to_json(value: Option<Value>) -> Result<serde_json::Value, JsValue> {
156        match value {
157            Some(v) => serde_json::to_value(v)
158                .map_err(|e| JsValue::from_str(&format!("serialize value failed: {e}"))),
159            None => Ok(serde_json::Value::Null),
160        }
161    }
162}
163
164/// The browser Arora device, exported to JavaScript as `AroraRuntime`.
165///
166/// The JS constructor builds the self-contained demo device — the runtime's
167/// in-process defaults: fake hardware, no bridge, a private store, an empty
168/// behavior — and [`AroraWebBuilder`] (JS `AroraRuntimeBuilder`) additionally
169/// loads guest modules. A downstream device composes its own [`Arora`] with
170/// [`arora::AroraBuilder`] and wraps it with `AroraWeb::from`, reusing this
171/// whole JS surface.
172///
173/// Drive it by calling [`step`](Self::step) from your own clock (e.g.
174/// `requestAnimationFrame`) or by awaiting [`run`](Self::run), which hands the
175/// device to [`Arora::run`] until [`stop`](AroraWeb::stop) reclaims it.
176/// Either way the rest of the surface stays
177/// live: `setValue`/`readValues`/`snapshot` work on a sibling handle of the
178/// store, `drainChanges` on its subscription, and `call` on the device's
179/// in-process [`Caller`] — none of them touch the stepping device.
180#[wasm_bindgen(js_name = AroraRuntime)]
181pub struct AroraWeb {
182    // The device parks here between manual steps; `run` takes it out and
183    // `stop` puts it back. Shared with the run future, which returns the
184    // device on a stop.
185    device: Rc<RefCell<Option<Arora>>>,
186    // Ends the current `run` loop at its next step boundary; `None` while no
187    // loop runs (the sender is consumed by `stop`).
188    stop: RefCell<Option<futures::channel::oneshot::Sender<()>>>,
189    caller: LocalCaller,
190    store: Box<dyn DataStore>,
191    changes: Subscription,
192    // The device's standing-error watch, taken before `run` can own the
193    // device, so the reading stays available while it self-paces.
194    behavior_error: tokio::sync::watch::Receiver<Option<String>>,
195    // The awaitable end of the same watch: taken out for the duration of one
196    // `behaviorErrorChanged` await, so sequential awaits share one seen
197    // cursor and no change is missed between them.
198    behavior_error_changes: Rc<RefCell<Option<tokio::sync::watch::Receiver<Option<String>>>>>,
199}
200
201impl From<Arora> for AroraWeb {
202    /// Wrap a composed device: keep its in-process [`LocalCaller`], a sibling
203    /// handle of its store, and a subscription (opening on the whole current
204    /// state), then expose it all to JavaScript.
205    fn from(arora: Arora) -> Self {
206        install_panic_hook();
207        let caller = arora.caller();
208        let store = arora.store().clone_box();
209        let changes = store.subscribe();
210        let behavior_error = arora.behavior_error();
211        let behavior_error_changes = Rc::new(RefCell::new(Some(arora.behavior_error())));
212        Self {
213            device: Rc::new(RefCell::new(Some(arora))),
214            stop: RefCell::new(None),
215            caller,
216            store,
217            changes,
218            behavior_error,
219            behavior_error_changes,
220        }
221    }
222}
223
224#[wasm_bindgen(js_class = "AroraRuntime")]
225impl AroraWeb {
226    /// Build the demo device on the runtime's in-process defaults: fake HAL,
227    /// no bridge, a private store, an empty (idle) behavior interpreter.
228    #[wasm_bindgen(constructor)]
229    pub fn new() -> Result<AroraWeb, JsValue> {
230        Ok(AroraWeb::from(Arora::builder().build().map_err(|e| {
231            JsValue::from_str(&format!("arora build failed: {e:?}"))
232        })?))
233    }
234
235    /// Advance the device one step. `dt_ms` is the milliseconds elapsed since
236    /// the previous step — a plain JS number, exactly what a
237    /// `requestAnimationFrame` timestamp delta gives. This is the only place
238    /// the rAF millisecond unit is converted to the core `Duration` dt.
239    /// Unavailable once [`run`](Self::run) has taken the device.
240    pub fn step(&self, dt_ms: f64) -> Result<(), JsValue> {
241        let mut slot = self
242            .device
243            .try_borrow_mut()
244            .map_err(|_| JsValue::from_str("the device is mid-step; call between steps"))?;
245        let device = slot
246            .as_mut()
247            .ok_or_else(|| JsValue::from_str("run() drives the device; step() is unavailable"))?;
248        device
249            .step(Duration::from_secs_f64(dt_ms / 1_000.0))
250            .map_err(|e| JsValue::from_str(&format!("step failed: {e}")))
251    }
252
253    /// Hand the device to [`Arora::run`]: a self-paced loop at `period_ms`
254    /// (default: the runtime's ~100 Hz) that owns the device until stepping
255    /// fails — the promise rejects — or [`stop`](Self::stop) reclaims it — the
256    /// promise resolves and `step()` works again. While it runs the rest of
257    /// the surface keeps working: it never touches the stepping device.
258    ///
259    /// `run` awaits between steps, so it shares its thread; see
260    /// [`Arora::run`] for where a cadence survives in a browser (main thread
261    /// vs dedicated worker).
262    pub fn run(&self, period_ms: Option<f64>) -> js_sys::Promise {
263        let Some(mut device) = self.device.borrow_mut().take() else {
264            return js_sys::Promise::reject(&JsValue::from_str("the device is already running"));
265        };
266        let slot = self.device.clone();
267        let (stop_tx, stop_rx) = futures::channel::oneshot::channel::<()>();
268        *self.stop.borrow_mut() = Some(stop_tx);
269        wasm_bindgen_futures::future_to_promise(async move {
270            let period = period_ms
271                .map(|ms| Duration::from_secs_f64(ms / 1_000.0))
272                .unwrap_or(Arora::DEFAULT_STEP_PERIOD);
273            let outcome = {
274                use futures::FutureExt;
275                let run = device.run(period).fuse();
276                let mut stop_rx = stop_rx.fuse();
277                futures::pin_mut!(run);
278                futures::select_biased! {
279                    result = run => Some(result),
280                    _ = stop_rx => None,
281                }
282            };
283            // Whatever ended the loop, the device parks back in its slot: a
284            // failed run leaves a device the embedder can still step, probe
285            // ([`Arora::behavior_error`]), or run again.
286            slot.borrow_mut().replace(device);
287            match outcome {
288                // `Arora::run` only returns by failing; surface that.
289                Some(result) => {
290                    result.map_err(|e| JsValue::from_str(&format!("run failed: {e}")))?;
291                    Ok(JsValue::UNDEFINED)
292                }
293                // Stopped: the loop ended between steps.
294                None => Ok(JsValue::UNDEFINED),
295            }
296        })
297    }
298
299    /// Reclaim the device from [`run`](Self::run): the loop ends at its next
300    /// step boundary, the run promise resolves, and `step()` (or another
301    /// `run()`) works again. A no-op while nothing runs.
302    pub fn stop(&self) {
303        if let Some(stop) = self.stop.borrow_mut().take() {
304            let _ = stop.send(());
305        }
306    }
307
308    /// Whether [`run`](Self::run) currently owns the device.
309    #[wasm_bindgen(getter)]
310    pub fn running(&self) -> bool {
311        self.device.borrow().is_none()
312    }
313
314    /// The behavior's standing error — the message of its latest failed
315    /// tick, `undefined` while the behavior is healthy or none is installed.
316    /// A failing tick does not stop the device; the reading stays available
317    /// while `run()` owns it.
318    #[wasm_bindgen(getter, js_name = behaviorError)]
319    pub fn behavior_error(&self) -> Option<String> {
320        self.behavior_error.borrow().clone()
321    }
322
323    /// Resolves on the next change of the behavior's standing error, with
324    /// the new reading: a message when a distinct failure appears,
325    /// `undefined` when a tick recovers. Sequential awaits share one cursor,
326    /// so no change is missed between them; one await may be pending at a
327    /// time. Rejects when the device is gone.
328    #[wasm_bindgen(js_name = behaviorErrorChanged)]
329    pub fn behavior_error_changed(&self) -> js_sys::Promise {
330        let Some(mut errors) = self.behavior_error_changes.borrow_mut().take() else {
331            return js_sys::Promise::reject(&JsValue::from_str(
332                "a behaviorErrorChanged await is already pending",
333            ));
334        };
335        let slot = self.behavior_error_changes.clone();
336        wasm_bindgen_futures::future_to_promise(async move {
337            let outcome = errors.changed().await;
338            let value = errors.borrow_and_update().clone();
339            slot.borrow_mut().replace(errors);
340            outcome.map_err(|_| JsValue::from_str("the device is gone"))?;
341            Ok(match value {
342                Some(message) => JsValue::from_str(&message),
343                None => JsValue::UNDEFINED,
344            })
345        })
346    }
347
348    /// Dispatch a [`Call`] into the device through its in-process caller. The
349    /// promise resolves after the step that applies it — the device must be
350    /// stepping (a `run()` loop, or your own `step` calls) for it to land.
351    /// `call_json` is an `arora_types::call::Call` as JSON; the result is the
352    /// `CallResult` as JSON.
353    pub fn call(&self, call_json: &str) -> js_sys::Promise {
354        let parsed: Result<Call, _> = serde_json::from_str(call_json)
355            .map_err(|e| JsValue::from_str(&format!("invalid call json: {e}")));
356        let caller = self.caller.clone();
357        wasm_bindgen_futures::future_to_promise(async move {
358            let result = caller
359                .call(parsed?)
360                .await
361                .map_err(|e| JsValue::from_str(&format!("call failed: {e}")))?;
362            let json = serde_json::to_string(&result)
363                .map_err(|e| JsValue::from_str(&format!("serialize failed: {e}")))?;
364            Ok(JsValue::from_str(&json))
365        })
366    }
367
368    /// Write one key into the store (Arora [`Value`] as JSON, e.g. `{"f32": 1}`).
369    #[wasm_bindgen(js_name = setValue)]
370    pub fn set_value(&self, path: &str, value_json: &str) -> Result<(), JsValue> {
371        store_json::set_value(&*self.store, path, value_json)
372    }
373
374    /// Write several keys at once, as one store change (a JSON object mapping
375    /// each key path to an Arora [`Value`]).
376    #[wasm_bindgen(js_name = writeValues)]
377    pub fn write_values(&self, values_json: &str) -> Result<(), JsValue> {
378        store_json::write_values(&*self.store, values_json)
379    }
380
381    /// Read keys from the store; `paths` is a `string[]`, result maps path→Value.
382    #[wasm_bindgen(js_name = readValues)]
383    pub fn read_values(&self, paths: JsValue) -> Result<JsValue, JsValue> {
384        store_json::read_values(&*self.store, paths)
385    }
386
387    /// A snapshot of every key in the store as a path→Value object.
388    pub fn snapshot(&self) -> Result<JsValue, JsValue> {
389        store_json::snapshot(&*self.store)
390    }
391
392    /// Drain the keys that changed since the last call, as a path→Value object
393    /// (`null` for a cleared key). The first drain returns the store's whole
394    /// current state — the subscription opens on it.
395    #[wasm_bindgen(js_name = drainChanges)]
396    pub fn drain_changes(&self) -> Result<JsValue, JsValue> {
397        store_json::drain_changes(&self.changes)
398    }
399}
400
401/// Assemble a browser device from what the JS world can supply — guest wasm
402/// modules over the demo defaults; exported to JavaScript as
403/// `AroraRuntimeBuilder`. A device that needs a real HAL, bridge, store, or
404/// behavior interpreter is composed in Rust with [`arora::AroraBuilder`] and
405/// wrapped with `AroraWeb::from` instead: those seams are trait objects that
406/// cannot cross the JS boundary.
407#[wasm_bindgen(js_name = AroraRuntimeBuilder)]
408#[derive(Default)]
409pub struct AroraWebBuilder {
410    inner: AroraBuilder,
411}
412
413#[wasm_bindgen(js_class = "AroraRuntimeBuilder")]
414impl AroraWebBuilder {
415    #[wasm_bindgen(constructor)]
416    pub fn new() -> AroraWebBuilder {
417        AroraWebBuilder::default()
418    }
419
420    /// Load a guest module into the device's engine — in the browser, a
421    /// `.wasm` run by the native `WebAssembly` host. `header_json` is the
422    /// module's low-level header as JSON; `executable` its wasm bytes.
423    /// Repeatable — each call loads one module.
424    #[wasm_bindgen(js_name = withModule)]
425    pub fn with_module(&mut self, header_json: &str, executable: &[u8]) -> Result<(), JsValue> {
426        let header: Header = serde_json::from_str(header_json)
427            .map_err(|e| JsValue::from_str(&format!("invalid header json: {e}")))?;
428        let inner = std::mem::take(&mut self.inner);
429        self.inner = inner.with_module(header, executable.to_vec());
430        Ok(())
431    }
432
433    /// Build the device. The builder resets to its defaults, ready to
434    /// assemble another.
435    pub fn build(&mut self) -> Result<AroraWeb, JsValue> {
436        let arora = std::mem::take(&mut self.inner)
437            .build()
438            .map_err(|e| JsValue::from_str(&format!("arora build failed: {e:?}")))?;
439        Ok(AroraWeb::from(arora))
440    }
441}
442
443/// JS-callable handle to a configured Arora engine.
444#[wasm_bindgen]
445pub struct Engine {
446    inner: std::pin::Pin<Box<arora_engine::engine::Engine>>,
447    loader: SharedLoaderRc,
448    function_module: HashMap<Uuid, Uuid>,
449    module_headers: HashMap<Uuid, String>,
450}
451
452#[wasm_bindgen]
453impl Engine {
454    #[wasm_bindgen(constructor)]
455    pub fn new() -> Engine {
456        install_panic_hook();
457        let executor = BrowserExecutor::new();
458        let loader = executor.shared();
459        let inner = EngineBuilder::new().add_executor(executor).build();
460        Engine {
461            inner,
462            loader,
463            function_module: HashMap::new(),
464            module_headers: HashMap::new(),
465        }
466    }
467
468    /// Load a module given its header (as JSON) and executable bytes.
469    /// Returns the module's UUID as a string.
470    ///
471    /// Compiles and instantiates synchronously: Chrome rejects both above
472    /// 8 MB on the main thread — use `prepareModule` + `loadPreparedModule`
473    /// for large executables.
474    #[wasm_bindgen(js_name = loadModule)]
475    pub fn load_module(&mut self, header_json: &str, executable: &[u8]) -> Result<String, JsValue> {
476        self.load_module_inner(header_json, executable.to_vec().into_boxed_slice())
477    }
478
479    /// Asynchronously compile and instantiate a module's executable
480    /// (via `WebAssembly.instantiate`, no main-thread size limit).
481    /// Follow up with `loadPreparedModule` to complete the load.
482    #[wasm_bindgen(js_name = prepareModule)]
483    pub fn prepare_module(&self, header_json: &str, executable: Vec<u8>) -> js_sys::Promise {
484        prepare_module_impl(self.loader.clone(), header_json, executable)
485    }
486
487    /// Load a module whose executable was staged by `prepareModule`.
488    /// Returns the module's UUID as a string.
489    #[wasm_bindgen(js_name = loadPreparedModule)]
490    pub fn load_prepared_module(&mut self, header_json: &str) -> Result<String, JsValue> {
491        self.load_module_inner(header_json, Box::new([]))
492    }
493
494    fn load_module_inner(
495        &mut self,
496        header_json: &str,
497        executable: Box<[u8]>,
498    ) -> Result<String, JsValue> {
499        let header: Header = serde_json::from_str(header_json)
500            .map_err(|e| JsValue::from_str(&format!("invalid header json: {e}")))?;
501        let header_json_str = header_json.to_string();
502        let loaded = load_module_from_parts(&mut *self.inner, header, executable)
503            .map_err(|e| JsValue::from_str(&format!("load_module failed: {e}")))?;
504        for fn_id in &loaded.function_ids {
505            self.function_module.insert(*fn_id, loaded.id);
506        }
507        self.module_headers.insert(loaded.id, header_json_str);
508        Ok(loaded.id.to_string())
509    }
510
511    /// Returns a JSON array of all loaded module headers.
512    #[wasm_bindgen(js_name = listModules)]
513    pub fn list_modules(&self) -> String {
514        let headers: Vec<serde_json::Value> = self
515            .module_headers
516            .values()
517            .filter_map(|s| serde_json::from_str(s).ok())
518            .collect();
519        serde_json::to_string(&headers).unwrap_or_else(|_| "[]".to_string())
520    }
521
522    /// Call a function. `call_json` is a JSON document matching
523    /// `arora_engine::call::Call`. Returns the result as a JSON string.
524    #[wasm_bindgen]
525    pub fn call(&mut self, call_json: &str) -> Result<String, JsValue> {
526        let mut call: Call = serde_json::from_str(call_json)
527            .map_err(|e| JsValue::from_str(&format!("invalid call json: {e}")))?;
528        if call.module_id.is_none() {
529            // Resolve locally: the module the function was loaded with.
530            call.module_id = Some(
531                *self
532                    .function_module
533                    .get(&call.id)
534                    .ok_or_else(|| JsValue::from_str("no module known for function"))?,
535            );
536        }
537        let result = self
538            .inner
539            .arora_call(call)
540            .map_err(|e| JsValue::from_str(&format!("call failed: {e}")))?;
541        serde_json::to_string(&result)
542            .map_err(|e| JsValue::from_str(&format!("serialize failed: {e}")))
543    }
544}
545
546impl Default for Engine {
547    fn default() -> Self {
548        Self::new()
549    }
550}
551
552/// Shared implementation of `prepareModule`: parses the header for the
553/// module id, then stages an asynchronously-instantiated module in the
554/// loader. Returns a `Promise<void>`.
555fn prepare_module_impl(
556    loader: SharedLoaderRc,
557    header_json: &str,
558    executable: Vec<u8>,
559) -> js_sys::Promise {
560    let header: Result<Header, _> = serde_json::from_str(header_json);
561    wasm_bindgen_futures::future_to_promise(async move {
562        let header = header.map_err(|e| JsValue::from_str(&format!("invalid header json: {e}")))?;
563        loader.prepare(header.id, executable).await?;
564        Ok(JsValue::UNDEFINED)
565    })
566}
567
568// =============================================================================
569// Behavior-tree runner
570//
571// A self-contained BT runtime built directly on arora's engine primitives —
572// no dependency on the arora-behavior-tree crate.
573// =============================================================================
574
575// UUID constants from arora-behavior-tree-types generated code.
576// TickId struct id: 6f49e650-84ca-4899-a9bd-1f3bf17fab51
577const TICK_ID_STRUCT_BYTES: [u8; 16] = [
578    0x6f, 0x49, 0xe6, 0x50, 0x84, 0xca, 0x48, 0x99, 0xa9, 0xbd, 0x1f, 0x3b, 0xf1, 0x7f, 0xab, 0x51,
579];
580// TickId::callable_id field: 237992d2-17d1-459f-bca1-7185fa6a69d7
581const TICK_ID_CALLABLE_FIELD_BYTES: [u8; 16] = [
582    0x23, 0x79, 0x92, 0xd2, 0x17, 0xd1, 0x45, 0x9f, 0xbc, 0xa1, 0x71, 0x85, 0xfa, 0x6a, 0x69, 0xd7,
583];
584// Status::Success variant: 766e9e9a-446d-4e46-83e6-14b7ca101169
585const STATUS_SUCCESS_BYTES: [u8; 16] = [
586    0x76, 0x6e, 0x9e, 0x9a, 0x44, 0x6d, 0x4e, 0x46, 0x83, 0xe6, 0x14, 0xb7, 0xca, 0x10, 0x11, 0x69,
587];
588// Status::Failure variant: 2468f46c-bb60-425c-9a4d-9ad326ccc7e2
589const STATUS_FAILURE_BYTES: [u8; 16] = [
590    0x24, 0x68, 0xf4, 0x6c, 0xbb, 0x60, 0x42, 0x5c, 0x9a, 0x4d, 0x9a, 0xd3, 0x26, 0xcc, 0xc7, 0xe2,
591];
592// Status enum type: 325a5767-e344-4532-860e-0749bcf2e428
593const STATUS_ENUM_BYTES: [u8; 16] = [
594    0x32, 0x5a, 0x57, 0x67, 0xe3, 0x44, 0x45, 0x32, 0x86, 0x0e, 0x07, 0x49, 0xbc, 0xf2, 0xe4, 0x28,
595];
596// _ret special out-parameter: 5f726574-0000-4000-8000-000000000000 ("_ret" in ASCII)
597const RET_PARAM_BYTES: [u8; 16] = [
598    0x5f, 0x72, 0x65, 0x74, 0x00, 0x00, 0x40, 0x00, 0x80, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
599];
600
601fn value_to_status(v: &Value) -> &'static str {
602    if let Value::Enumeration(e) = v {
603        if *e.variant_id.as_bytes() == STATUS_SUCCESS_BYTES {
604            return "success";
605        }
606        if *e.variant_id.as_bytes() == STATUS_FAILURE_BYTES {
607            return "failure";
608        }
609    }
610    "running"
611}
612
613/// A node argument expression: either a literal value or a reference to a
614/// named variable (identified by UUID).
615#[derive(serde::Deserialize, Clone, Debug)]
616#[serde(rename_all = "snake_case")]
617enum BtExpression {
618    VariableId(Uuid),
619    Value(Value),
620}
621
622/// A single BT node – mirrors the arora-behavior-tree YAML schema.
623#[derive(serde::Deserialize, Debug)]
624struct BtNode {
625    id: Uuid,
626    function: Uuid,
627    #[serde(default)]
628    children: Option<Vec<Uuid>>,
629    /// Parameter arguments: maps parameter UUID to a literal value or variable.
630    /// Use the special key `5f726574-0000-4000-8000-000000000000` (_ret) to
631    /// capture the return value into a variable; the node then always succeeds.
632    #[serde(default)]
633    arguments: HashMap<Uuid, BtExpression>,
634}
635
636/// Metadata extracted from a module header for one exported function.
637struct FnMeta {
638    module_id: Uuid,
639    /// Parameter ID of the `children: TickId[]` argument, present only for
640    /// control nodes (seq, fallback, parallel …).
641    children_param_id: Option<Uuid>,
642}
643
644/// An arora Callable that wraps one BT node.
645struct NodeCallable {
646    node_id: Uuid,
647    fn_id: Uuid,
648    module_id: Uuid,
649    children_param_id: Option<Uuid>,
650    children_callable_ids: Vec<u64>,
651    arguments: HashMap<Uuid, BtExpression>,
652    variables: Rc<RefCell<HashMap<Uuid, Value>>>,
653    trace: Rc<RefCell<Vec<(Uuid, &'static str)>>>,
654}
655
656impl Callable for NodeCallable {
657    fn call(&self, caller: &mut dyn CallBridge) -> Result<Value, arora_engine::call::CallError> {
658        let tick_id_type = Uuid::from_bytes(TICK_ID_STRUCT_BYTES);
659        let callable_field = Uuid::from_bytes(TICK_ID_CALLABLE_FIELD_BYTES);
660        let ret_param_id = Uuid::from_bytes(RET_PARAM_BYTES);
661
662        let mut args = Vec::new();
663
664        if let Some(children_param_id) = self.children_param_id {
665            let elements: Vec<StructureWithoutId> = self
666                .children_callable_ids
667                .iter()
668                .map(|&id| StructureWithoutId {
669                    fields: vec![StructureField {
670                        id: callable_field,
671                        value: Box::new(Value::U64(id)),
672                    }],
673                })
674                .collect();
675            args.push(StructureField {
676                id: children_param_id,
677                value: Box::new(Value::ArrayStructure {
678                    id: tick_id_type,
679                    elements,
680                }),
681            });
682        }
683
684        for (&param_id, expr) in &self.arguments {
685            if param_id == ret_param_id {
686                continue;
687            }
688            let value = match expr {
689                BtExpression::Value(v) => v.clone(),
690                BtExpression::VariableId(var_id) => self
691                    .variables
692                    .borrow()
693                    .get(var_id)
694                    .cloned()
695                    .unwrap_or(Value::Unit),
696            };
697            args.push(StructureField {
698                id: param_id,
699                value: Box::new(value),
700            });
701        }
702
703        // Build a map of param_id -> variable_id for mutable arguments so we can
704        // write mutated values back to the variable store after the call.
705        let mutable_param_vars: HashMap<Uuid, Uuid> = self
706            .arguments
707            .iter()
708            .filter_map(|(&param_id, expr)| {
709                if param_id == ret_param_id {
710                    return None;
711                }
712                if let BtExpression::VariableId(var_id) = expr {
713                    Some((param_id, *var_id))
714                } else {
715                    None
716                }
717            })
718            .collect();
719
720        let result = caller.arora_call(Call {
721            module_id: Some(self.module_id),
722            id: self.fn_id,
723            args,
724        })?;
725
726        // Write back mutated parameter values to bound variables.
727        for mutated in &result.mutated {
728            if let Some(&var_id) = mutable_param_vars.get(&mutated.id) {
729                self.variables
730                    .borrow_mut()
731                    .insert(var_id, *mutated.value.clone());
732            }
733        }
734
735        let has_ret = self.arguments.contains_key(&ret_param_id);
736        if has_ret {
737            if let Some(BtExpression::VariableId(var_id)) = self.arguments.get(&ret_param_id) {
738                self.variables
739                    .borrow_mut()
740                    .insert(*var_id, result.ret.clone());
741            }
742        }
743
744        let s = if has_ret {
745            "success"
746        } else {
747            value_to_status(&result.ret)
748        };
749        self.trace.borrow_mut().push((self.node_id, s));
750
751        if has_ret {
752            Ok(Value::Enumeration(Enumeration {
753                id: Uuid::from_bytes(STATUS_ENUM_BYTES),
754                variant_id: Uuid::from_bytes(STATUS_SUCCESS_BYTES),
755                value: Box::new(Value::Unit),
756            }))
757        } else {
758            Ok(result.ret)
759        }
760    }
761}
762
763/// Recursively registers callables for `node_id` and all descendants.
764/// Returns the callable id of the registered root callable.
765fn register_node(
766    engine: &mut dyn CallBridge,
767    node_id: Uuid,
768    node_index: &HashMap<Uuid, BtNode>,
769    fn_meta: &HashMap<Uuid, FnMeta>,
770    trace: &Rc<RefCell<Vec<(Uuid, &'static str)>>>,
771    variables: &Rc<RefCell<HashMap<Uuid, Value>>>,
772) -> Result<u64, String> {
773    let node = node_index
774        .get(&node_id)
775        .ok_or_else(|| format!("node {node_id} not found in tree"))?;
776    let meta = fn_meta
777        .get(&node.function)
778        .ok_or_else(|| format!("function {} not registered in fn_meta", node.function))?;
779
780    let children_callable_ids = match &node.children {
781        None => vec![],
782        Some(ids) => ids
783            .iter()
784            .map(|&child_id| register_node(engine, child_id, node_index, fn_meta, trace, variables))
785            .collect::<Result<Vec<_>, _>>()?,
786    };
787
788    let callable: Rc<dyn Callable> = Rc::new(NodeCallable {
789        node_id,
790        fn_id: node.function,
791        module_id: meta.module_id,
792        children_param_id: meta.children_param_id,
793        children_callable_ids,
794        arguments: node.arguments.clone(),
795        variables: variables.clone(),
796        trace: trace.clone(),
797    });
798    let id = engine.arora_register_callable(callable);
799    Ok(id.id)
800}
801
802/// JS-callable handle for loading modules and executing behavior trees.
803///
804/// Usage:
805/// 1. `new BehaviorTreeRunner()`
806/// 2. `runner.loadModule(headerJson, wasmBytes)` – can be called for multiple modules
807/// 3. `runner.run(nodesJson)` – returns `{status, trace}`
808/// 4. Or `runner.setVariable(varId, valueJson)` + `runner.tick(nodesJson)` for
809///    stateful tick-by-tick execution with variable bindings.
810#[wasm_bindgen]
811pub struct BehaviorTreeRunner {
812    inner: Pin<Box<arora_engine::engine::Engine>>,
813    loader: SharedLoaderRc,
814    fn_meta: HashMap<Uuid, FnMeta>,
815    variables: Rc<RefCell<HashMap<Uuid, Value>>>,
816    module_headers: HashMap<Uuid, String>,
817}
818
819#[wasm_bindgen]
820impl BehaviorTreeRunner {
821    #[wasm_bindgen(constructor)]
822    pub fn new() -> BehaviorTreeRunner {
823        install_panic_hook();
824        let executor = BrowserExecutor::new();
825        let loader = executor.shared();
826        let inner = EngineBuilder::new().add_executor(executor).build();
827        BehaviorTreeRunner {
828            inner,
829            loader,
830            fn_meta: HashMap::new(),
831            variables: Rc::new(RefCell::new(HashMap::new())),
832            module_headers: HashMap::new(),
833        }
834    }
835
836    /// Load a WASM module. `header_json` must be the module's YAML header
837    /// converted to JSON (the JS side can use js-yaml for that).
838    /// Returns the module UUID string.
839    ///
840    /// Compiles and instantiates synchronously: Chrome rejects both above
841    /// 8 MB on the main thread — use `prepareModule` + `loadPreparedModule`
842    /// for large executables.
843    #[wasm_bindgen(js_name = loadModule)]
844    pub fn load_module(&mut self, header_json: &str, executable: &[u8]) -> Result<String, JsValue> {
845        self.load_module_inner(header_json, executable.to_vec().into_boxed_slice())
846    }
847
848    /// Asynchronously compile and instantiate a module's executable
849    /// (via `WebAssembly.instantiate`, no main-thread size limit).
850    /// Follow up with `loadPreparedModule` to complete the load.
851    #[wasm_bindgen(js_name = prepareModule)]
852    pub fn prepare_module(&self, header_json: &str, executable: Vec<u8>) -> js_sys::Promise {
853        prepare_module_impl(self.loader.clone(), header_json, executable)
854    }
855
856    /// Load a module whose executable was staged by `prepareModule`.
857    /// Returns the module UUID string.
858    #[wasm_bindgen(js_name = loadPreparedModule)]
859    pub fn load_prepared_module(&mut self, header_json: &str) -> Result<String, JsValue> {
860        self.load_module_inner(header_json, Box::new([]))
861    }
862
863    fn load_module_inner(
864        &mut self,
865        header_json: &str,
866        executable: Box<[u8]>,
867    ) -> Result<String, JsValue> {
868        let header: Header = serde_json::from_str(header_json)
869            .map_err(|e| JsValue::from_str(&format!("invalid header: {e}")))?;
870        let module_id = header.id;
871        let header_json_str = header_json.to_string();
872
873        for export in &header.exports {
874            let arora_types::module::low::ExportSymbol::Function(f) = export;
875            let children_param_id = f.parameters.first().and_then(|p| {
876                if let arora_types::module::low::TypeRef::Array { id } = &p.ty {
877                    if id == &Uuid::from_bytes(TICK_ID_STRUCT_BYTES) {
878                        Some(p.id)
879                    } else {
880                        None
881                    }
882                } else {
883                    None
884                }
885            });
886
887            self.fn_meta.insert(
888                f.id,
889                FnMeta {
890                    module_id,
891                    children_param_id,
892                },
893            );
894        }
895
896        let result = load_module_from_parts(&mut *self.inner, header, executable)
897            .map_err(|e| JsValue::from_str(&format!("load failed: {e}")))?;
898        self.module_headers.insert(result.id, header_json_str);
899        Ok(result.id.to_string())
900    }
901
902    /// Returns a JSON array of all loaded module headers.
903    #[wasm_bindgen(js_name = listModules)]
904    pub fn list_modules(&self) -> String {
905        let headers: Vec<serde_json::Value> = self
906            .module_headers
907            .values()
908            .filter_map(|s| serde_json::from_str(s).ok())
909            .collect();
910        serde_json::to_string(&headers).unwrap_or_else(|_| "[]".to_string())
911    }
912
913    /// Initialize or update a variable. `var_id` is a UUID string; `value_json`
914    /// is the serialized `Value` (e.g. `{"f32": 0.0}`).
915    #[wasm_bindgen(js_name = setVariable)]
916    pub fn set_variable(&mut self, var_id: &str, value_json: &str) -> Result<(), JsValue> {
917        let var_id: Uuid = var_id
918            .parse()
919            .map_err(|_| JsValue::from_str("bad var_id: not a valid UUID"))?;
920        let value: Value = serde_json::from_str(value_json)
921            .map_err(|e| JsValue::from_str(&format!("bad value JSON: {e}")))?;
922        self.variables.borrow_mut().insert(var_id, value);
923        Ok(())
924    }
925
926    /// Run one tick of the behavior tree. Variables persist across calls.
927    ///
928    /// `nodes_json` is a JSON array where each element is:
929    ///   `{ id, function, children?, arguments?, return_binding? }`
930    ///
931    /// Returns: `{ "status": "...", "trace": [...], "variables": { varId: value } }`
932    pub fn tick(&mut self, nodes_json: &str) -> Result<String, JsValue> {
933        let nodes: Vec<BtNode> = serde_json::from_str(nodes_json)
934            .map_err(|e| JsValue::from_str(&format!("bad nodes JSON: {e}")))?;
935        if nodes.is_empty() {
936            return Err(JsValue::from_str("tree has no nodes"));
937        }
938
939        let root_id = nodes[0].id;
940        let node_index: HashMap<Uuid, BtNode> = nodes.into_iter().map(|n| (n.id, n)).collect();
941        let trace: Rc<RefCell<Vec<(Uuid, &'static str)>>> = Rc::new(RefCell::new(Vec::new()));
942
943        let fn_meta = &self.fn_meta;
944        let root_callable_id = register_node(
945            &mut *self.inner,
946            root_id,
947            &node_index,
948            fn_meta,
949            &trace,
950            &self.variables,
951        )
952        .map_err(|e| JsValue::from_str(&format!("setup error: {e}")))?;
953
954        let callable_id = CallableId {
955            id: root_callable_id,
956        };
957        let result = Callable::call(&callable_id, &mut *self.inner)
958            .map_err(|e| JsValue::from_str(&format!("tick error: {e}")))?;
959        let status = value_to_status(&result);
960
961        let trace_json: Vec<serde_json::Value> = trace
962            .borrow()
963            .iter()
964            .map(|(id, s)| serde_json::json!({ "nodeId": id.to_string(), "status": s }))
965            .collect();
966
967        let vars_json: serde_json::Map<String, serde_json::Value> = self
968            .variables
969            .borrow()
970            .iter()
971            .filter_map(|(id, v)| serde_json::to_value(v).ok().map(|jv| (id.to_string(), jv)))
972            .collect();
973
974        Ok(serde_json::json!({
975          "status": status,
976          "trace": trace_json,
977          "variables": vars_json,
978        })
979        .to_string())
980    }
981
982    /// Run a behavior tree to completion (ticks until not Running).
983    ///
984    /// `nodes_json` is a JSON array where each element is:
985    ///   `{ id: "<uuid>", function: "<uuid>", children?: ["<uuid>", ...] }`
986    /// The first element is the root node.
987    ///
988    /// Returns a JSON string:
989    ///   `{ "status": "success"|"failure"|"running",
990    ///      "trace": [{"nodeId": "<uuid>", "status": "..."}] }`
991    pub fn run(&mut self, nodes_json: &str) -> Result<String, JsValue> {
992        let nodes: Vec<BtNode> = serde_json::from_str(nodes_json)
993            .map_err(|e| JsValue::from_str(&format!("bad nodes JSON: {e}")))?;
994        if nodes.is_empty() {
995            return Err(JsValue::from_str("tree has no nodes"));
996        }
997
998        let root_id = nodes[0].id;
999        let node_index: HashMap<Uuid, BtNode> = nodes.into_iter().map(|n| (n.id, n)).collect();
1000        let trace: Rc<RefCell<Vec<(Uuid, &'static str)>>> = Rc::new(RefCell::new(Vec::new()));
1001
1002        let fn_meta = &self.fn_meta;
1003        let root_callable_id = register_node(
1004            &mut *self.inner,
1005            root_id,
1006            &node_index,
1007            fn_meta,
1008            &trace,
1009            &self.variables,
1010        )
1011        .map_err(|e| JsValue::from_str(&format!("setup error: {e}")))?;
1012
1013        let callable_id = CallableId {
1014            id: root_callable_id,
1015        };
1016
1017        let mut last_status = "running";
1018        for _ in 0..10_000 {
1019            let result = Callable::call(&callable_id, &mut *self.inner)
1020                .map_err(|e| JsValue::from_str(&format!("tick error: {e}")))?;
1021            last_status = value_to_status(&result);
1022            if last_status != "running" {
1023                break;
1024            }
1025        }
1026
1027        let trace_json: Vec<serde_json::Value> = trace
1028            .borrow()
1029            .iter()
1030            .map(|(id, s)| serde_json::json!({ "nodeId": id.to_string(), "status": s }))
1031            .collect();
1032
1033        let out = serde_json::json!({
1034          "status": last_status,
1035          "trace": trace_json,
1036        });
1037        Ok(out.to_string())
1038    }
1039}
1040
1041impl Default for BehaviorTreeRunner {
1042    fn default() -> Self {
1043        Self::new()
1044    }
1045}
1046
1047// =============================================================================
1048// Type registry
1049//
1050// A minimal wasm-compatible registry for resolving type UUIDs to names.
1051// Loads from the records.json files emitted by arora-module-rust::generate_records.
1052// =============================================================================
1053
1054/// JS-callable type registry that resolves type UUIDs to human-readable names.
1055#[wasm_bindgen]
1056pub struct Registry {
1057    entries: HashMap<Uuid, (String, Option<Uuid>)>,
1058}
1059
1060#[derive(serde::Deserialize)]
1061struct RecordEntry {
1062    id: Uuid,
1063    name: String,
1064    parent: Option<Uuid>,
1065}
1066
1067#[wasm_bindgen]
1068impl Registry {
1069    #[wasm_bindgen(constructor)]
1070    pub fn new() -> Registry {
1071        Registry {
1072            entries: HashMap::new(),
1073        }
1074    }
1075
1076    /// Load type records from a JSON array: `[{id, name, parent?}, ...]`.
1077    #[wasm_bindgen(js_name = loadRecordsJson)]
1078    pub fn load_records_json(&mut self, json: &str) -> Result<(), JsValue> {
1079        let records: Vec<RecordEntry> = serde_json::from_str(json)
1080            .map_err(|e| JsValue::from_str(&format!("invalid records JSON: {e}")))?;
1081        for r in records {
1082            self.entries.insert(r.id, (r.name, r.parent));
1083        }
1084        Ok(())
1085    }
1086
1087    /// Resolve a UUID string to a dot-separated name path (e.g. `"behavior_tree.Status"`).
1088    /// Returns `None` if the UUID is not known.
1089    #[wasm_bindgen(js_name = resolveId)]
1090    pub fn resolve_id(&self, id: &str) -> Option<String> {
1091        let uuid: Uuid = id.parse().ok()?;
1092        self.compute_path(&uuid)
1093    }
1094
1095    fn compute_path(&self, id: &Uuid) -> Option<String> {
1096        let (name, parent) = self.entries.get(id)?;
1097        match parent {
1098            None => Some(name.clone()),
1099            Some(parent_id) if !self.entries.contains_key(parent_id) => Some(name.clone()),
1100            Some(parent_id) => Some(format!("{}.{}", self.compute_path(parent_id)?, name)),
1101        }
1102    }
1103}
1104
1105impl Default for Registry {
1106    fn default() -> Self {
1107        Self::new()
1108    }
1109}