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