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    /// Load a guest module compiled ahead into the device's engine, as
626    /// `withModule` loads one from its bytes. The device instantiates the
627    /// compiled `WebAssembly.Module` instead of compiling the bytes, so devices
628    /// built from one `CompiledModule` compile it once between them.
629    /// Repeatable — each call loads one module.
630    #[wasm_bindgen(js_name = withCompiledModule)]
631    pub fn with_compiled_module(&mut self, module: &CompiledModule) {
632        let inner = std::mem::take(&mut self.inner);
633        self.inner = inner.with_compiled_module(&module.inner);
634    }
635
636    /// Build the device. The builder resets to its defaults, ready to
637    /// assemble another.
638    pub fn build(&mut self) -> Result<AroraWeb, JsValue> {
639        let arora = std::mem::take(&mut self.inner)
640            .build()
641            .map_err(|e| JsValue::from_str(&format!("arora build failed: {e:?}")))?;
642        Ok(AroraWeb::from(arora))
643    }
644}
645
646/// A guest module compiled once, to load into any number of devices with
647/// `AroraRuntimeBuilder.withCompiledModule`: each device instantiates it —
648/// its own memory and state — without compiling it again.
649#[wasm_bindgen]
650pub struct CompiledModule {
651    inner: arora::CompiledModule,
652}
653
654#[wasm_bindgen]
655impl CompiledModule {
656    /// Compile a guest module. `header_json` is the module's low-level header
657    /// as JSON; `executable` its wasm bytes. Compiles synchronously
658    /// (`new WebAssembly.Module`), which Chrome rejects above 8 MB on the main
659    /// thread. Fails on an invalid header or an executable that does not
660    /// compile.
661    #[wasm_bindgen(constructor)]
662    pub fn new(header_json: &str, executable: &[u8]) -> Result<CompiledModule, JsValue> {
663        install_panic_hook();
664        let header: Header = serde_json::from_str(header_json)
665            .map_err(|e| JsValue::from_str(&format!("invalid header json: {e}")))?;
666        let inner = arora::CompiledModule::new(header, executable)
667            .map_err(|e| JsValue::from_str(&format!("module does not compile: {e}")))?;
668        Ok(CompiledModule { inner })
669    }
670}
671
672/// JS-callable handle to a configured Arora engine.
673#[wasm_bindgen]
674pub struct Engine {
675    inner: std::pin::Pin<Box<arora_engine::engine::Engine>>,
676    loader: SharedLoaderRc,
677    function_module: HashMap<Uuid, Uuid>,
678    module_headers: HashMap<Uuid, String>,
679}
680
681#[wasm_bindgen]
682impl Engine {
683    #[wasm_bindgen(constructor)]
684    pub fn new() -> Engine {
685        install_panic_hook();
686        let executor = BrowserExecutor::new();
687        let loader = executor.shared();
688        let inner = EngineBuilder::new().add_executor(executor).build();
689        Engine {
690            inner,
691            loader,
692            function_module: HashMap::new(),
693            module_headers: HashMap::new(),
694        }
695    }
696
697    /// Load a module given its header (as JSON) and executable bytes.
698    /// Returns the module's UUID as a string.
699    ///
700    /// Compiles and instantiates synchronously: Chrome rejects both above
701    /// 8 MB on the main thread — use `prepareModule` + `loadPreparedModule`
702    /// for large executables.
703    #[wasm_bindgen(js_name = loadModule)]
704    pub fn load_module(&mut self, header_json: &str, executable: &[u8]) -> Result<String, JsValue> {
705        self.load_module_inner(header_json, executable.to_vec().into_boxed_slice())
706    }
707
708    /// Asynchronously compile and instantiate a module's executable
709    /// (via `WebAssembly.instantiate`, no main-thread size limit).
710    /// Follow up with `loadPreparedModule` to complete the load.
711    #[wasm_bindgen(js_name = prepareModule)]
712    pub fn prepare_module(&self, header_json: &str, executable: Vec<u8>) -> js_sys::Promise {
713        prepare_module_impl(self.loader.clone(), header_json, executable)
714    }
715
716    /// Load a module whose executable was staged by `prepareModule`.
717    /// Returns the module's UUID as a string.
718    #[wasm_bindgen(js_name = loadPreparedModule)]
719    pub fn load_prepared_module(&mut self, header_json: &str) -> Result<String, JsValue> {
720        self.load_module_inner(header_json, Box::new([]))
721    }
722
723    fn load_module_inner(
724        &mut self,
725        header_json: &str,
726        executable: Box<[u8]>,
727    ) -> Result<String, JsValue> {
728        let header: Header = serde_json::from_str(header_json)
729            .map_err(|e| JsValue::from_str(&format!("invalid header json: {e}")))?;
730        let header_json_str = header_json.to_string();
731        let loaded = load_module_from_parts(&mut self.inner, header, executable)
732            .map_err(|e| JsValue::from_str(&format!("load_module failed: {e}")))?;
733        for fn_id in &loaded.function_ids {
734            self.function_module.insert(*fn_id, loaded.id);
735        }
736        self.module_headers.insert(loaded.id, header_json_str);
737        Ok(loaded.id.to_string())
738    }
739
740    /// Returns a JSON array of all loaded module headers.
741    #[wasm_bindgen(js_name = listModules)]
742    pub fn list_modules(&self) -> String {
743        let headers: Vec<serde_json::Value> = self
744            .module_headers
745            .values()
746            .filter_map(|s| serde_json::from_str(s).ok())
747            .collect();
748        serde_json::to_string(&headers).unwrap_or_else(|_| "[]".to_string())
749    }
750
751    /// Call a function. `call_json` is a JSON document matching
752    /// `arora_engine::call::Call`. Returns the result as a JSON string.
753    #[wasm_bindgen]
754    pub fn call(&mut self, call_json: &str) -> Result<String, JsValue> {
755        let mut call: Call = serde_json::from_str(call_json)
756            .map_err(|e| JsValue::from_str(&format!("invalid call json: {e}")))?;
757        if call.module_id.is_none() {
758            // Resolve locally: the module the function was loaded with.
759            call.module_id = Some(
760                *self
761                    .function_module
762                    .get(&call.id)
763                    .ok_or_else(|| JsValue::from_str("no module known for function"))?,
764            );
765        }
766        let result = self
767            .inner
768            .arora_call(call)
769            .map_err(|e| JsValue::from_str(&format!("call failed: {e}")))?;
770        serde_json::to_string(&result)
771            .map_err(|e| JsValue::from_str(&format!("serialize failed: {e}")))
772    }
773}
774
775impl Default for Engine {
776    fn default() -> Self {
777        Self::new()
778    }
779}
780
781/// Shared implementation of `prepareModule`: parses the header for the
782/// module id, then stages an asynchronously-instantiated module in the
783/// loader. Returns a `Promise<void>`.
784fn prepare_module_impl(
785    loader: SharedLoaderRc,
786    header_json: &str,
787    executable: Vec<u8>,
788) -> js_sys::Promise {
789    let header: Result<Header, _> = serde_json::from_str(header_json);
790    wasm_bindgen_futures::future_to_promise(async move {
791        let header = header.map_err(|e| JsValue::from_str(&format!("invalid header json: {e}")))?;
792        loader.prepare(header.id, executable).await?;
793        Ok(JsValue::UNDEFINED)
794    })
795}
796
797// =============================================================================
798// Behavior-tree runner
799//
800// A self-contained BT runtime built directly on arora's engine primitives —
801// no dependency on the arora-behavior-tree crate.
802// =============================================================================
803
804// UUID constants from arora-behavior-tree-types generated code.
805// TickId struct id: 6f49e650-84ca-4899-a9bd-1f3bf17fab51
806const TICK_ID_STRUCT_BYTES: [u8; 16] = [
807    0x6f, 0x49, 0xe6, 0x50, 0x84, 0xca, 0x48, 0x99, 0xa9, 0xbd, 0x1f, 0x3b, 0xf1, 0x7f, 0xab, 0x51,
808];
809// TickId::callable_id field: 237992d2-17d1-459f-bca1-7185fa6a69d7
810const TICK_ID_CALLABLE_FIELD_BYTES: [u8; 16] = [
811    0x23, 0x79, 0x92, 0xd2, 0x17, 0xd1, 0x45, 0x9f, 0xbc, 0xa1, 0x71, 0x85, 0xfa, 0x6a, 0x69, 0xd7,
812];
813// Status::Success variant: 766e9e9a-446d-4e46-83e6-14b7ca101169
814const STATUS_SUCCESS_BYTES: [u8; 16] = [
815    0x76, 0x6e, 0x9e, 0x9a, 0x44, 0x6d, 0x4e, 0x46, 0x83, 0xe6, 0x14, 0xb7, 0xca, 0x10, 0x11, 0x69,
816];
817// Status::Failure variant: 2468f46c-bb60-425c-9a4d-9ad326ccc7e2
818const STATUS_FAILURE_BYTES: [u8; 16] = [
819    0x24, 0x68, 0xf4, 0x6c, 0xbb, 0x60, 0x42, 0x5c, 0x9a, 0x4d, 0x9a, 0xd3, 0x26, 0xcc, 0xc7, 0xe2,
820];
821// Status enum type: 325a5767-e344-4532-860e-0749bcf2e428
822const STATUS_ENUM_BYTES: [u8; 16] = [
823    0x32, 0x5a, 0x57, 0x67, 0xe3, 0x44, 0x45, 0x32, 0x86, 0x0e, 0x07, 0x49, 0xbc, 0xf2, 0xe4, 0x28,
824];
825// _ret special out-parameter: 5f726574-0000-4000-8000-000000000000 ("_ret" in ASCII)
826const RET_PARAM_BYTES: [u8; 16] = [
827    0x5f, 0x72, 0x65, 0x74, 0x00, 0x00, 0x40, 0x00, 0x80, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
828];
829
830fn value_to_status(v: &Value) -> &'static str {
831    if let Value::Enumeration(e) = v {
832        if *e.variant_id.as_bytes() == STATUS_SUCCESS_BYTES {
833            return "success";
834        }
835        if *e.variant_id.as_bytes() == STATUS_FAILURE_BYTES {
836            return "failure";
837        }
838    }
839    "running"
840}
841
842/// A node argument expression: either a literal value or a reference to a
843/// named variable (identified by UUID).
844#[derive(serde::Deserialize, Clone, Debug)]
845#[serde(rename_all = "snake_case")]
846enum BtExpression {
847    VariableId(Uuid),
848    Value(Value),
849}
850
851/// A single BT node – mirrors the arora-behavior-tree YAML schema.
852#[derive(serde::Deserialize, Debug)]
853struct BtNode {
854    id: Uuid,
855    function: Uuid,
856    #[serde(default)]
857    children: Option<Vec<Uuid>>,
858    /// Parameter arguments: maps parameter UUID to a literal value or variable.
859    /// Use the special key `5f726574-0000-4000-8000-000000000000` (_ret) to
860    /// capture the return value into a variable; the node then always succeeds.
861    #[serde(default)]
862    arguments: HashMap<Uuid, BtExpression>,
863}
864
865/// Metadata extracted from a module header for one exported function.
866struct FnMeta {
867    module_id: Uuid,
868    /// Parameter ID of the `children: TickId[]` argument, present only for
869    /// control nodes (seq, fallback, parallel …).
870    children_param_id: Option<Uuid>,
871}
872
873/// An arora Callable that wraps one BT node.
874struct NodeCallable {
875    node_id: Uuid,
876    fn_id: Uuid,
877    module_id: Uuid,
878    children_param_id: Option<Uuid>,
879    children_callable_ids: Vec<u64>,
880    arguments: HashMap<Uuid, BtExpression>,
881    variables: Rc<RefCell<HashMap<Uuid, Value>>>,
882    trace: Rc<RefCell<Vec<(Uuid, &'static str)>>>,
883}
884
885impl Callable for NodeCallable {
886    fn call(&self, caller: &mut dyn CallBridge) -> Result<Value, arora_engine::call::CallError> {
887        let tick_id_type = Uuid::from_bytes(TICK_ID_STRUCT_BYTES);
888        let callable_field = Uuid::from_bytes(TICK_ID_CALLABLE_FIELD_BYTES);
889        let ret_param_id = Uuid::from_bytes(RET_PARAM_BYTES);
890
891        let mut args = Vec::new();
892
893        if let Some(children_param_id) = self.children_param_id {
894            let elements: Vec<StructureWithoutId> = self
895                .children_callable_ids
896                .iter()
897                .map(|&id| StructureWithoutId {
898                    fields: vec![StructureField {
899                        id: callable_field,
900                        value: Box::new(Value::U64(id)),
901                    }],
902                })
903                .collect();
904            args.push(StructureField {
905                id: children_param_id,
906                value: Box::new(Value::ArrayStructure {
907                    id: tick_id_type,
908                    elements,
909                }),
910            });
911        }
912
913        for (&param_id, expr) in &self.arguments {
914            if param_id == ret_param_id {
915                continue;
916            }
917            let value = match expr {
918                BtExpression::Value(v) => v.clone(),
919                BtExpression::VariableId(var_id) => self
920                    .variables
921                    .borrow()
922                    .get(var_id)
923                    .cloned()
924                    .unwrap_or(Value::Unit),
925            };
926            args.push(StructureField {
927                id: param_id,
928                value: Box::new(value),
929            });
930        }
931
932        // Build a map of param_id -> variable_id for mutable arguments so we can
933        // write mutated values back to the variable store after the call.
934        let mutable_param_vars: HashMap<Uuid, Uuid> = self
935            .arguments
936            .iter()
937            .filter_map(|(&param_id, expr)| {
938                if param_id == ret_param_id {
939                    return None;
940                }
941                if let BtExpression::VariableId(var_id) = expr {
942                    Some((param_id, *var_id))
943                } else {
944                    None
945                }
946            })
947            .collect();
948
949        let result = caller.arora_call(Call {
950            module_id: Some(self.module_id),
951            id: self.fn_id,
952            args,
953        })?;
954
955        // Write back mutated parameter values to bound variables.
956        for mutated in &result.mutated {
957            if let Some(&var_id) = mutable_param_vars.get(&mutated.id) {
958                self.variables
959                    .borrow_mut()
960                    .insert(var_id, *mutated.value.clone());
961            }
962        }
963
964        let has_ret = self.arguments.contains_key(&ret_param_id);
965        if has_ret {
966            if let Some(BtExpression::VariableId(var_id)) = self.arguments.get(&ret_param_id) {
967                self.variables
968                    .borrow_mut()
969                    .insert(*var_id, result.ret.clone());
970            }
971        }
972
973        let s = if has_ret {
974            "success"
975        } else {
976            value_to_status(&result.ret)
977        };
978        self.trace.borrow_mut().push((self.node_id, s));
979
980        if has_ret {
981            Ok(Value::Enumeration(Enumeration {
982                id: Uuid::from_bytes(STATUS_ENUM_BYTES),
983                variant_id: Uuid::from_bytes(STATUS_SUCCESS_BYTES),
984                value: Box::new(Value::Unit),
985            }))
986        } else {
987            Ok(result.ret)
988        }
989    }
990}
991
992/// Recursively registers callables for `node_id` and all descendants.
993/// Returns the callable id of the registered root callable.
994fn register_node(
995    engine: &mut dyn CallBridge,
996    node_id: Uuid,
997    node_index: &HashMap<Uuid, BtNode>,
998    fn_meta: &HashMap<Uuid, FnMeta>,
999    trace: &Rc<RefCell<Vec<(Uuid, &'static str)>>>,
1000    variables: &Rc<RefCell<HashMap<Uuid, Value>>>,
1001) -> Result<u64, String> {
1002    let node = node_index
1003        .get(&node_id)
1004        .ok_or_else(|| format!("node {node_id} not found in tree"))?;
1005    let meta = fn_meta
1006        .get(&node.function)
1007        .ok_or_else(|| format!("function {} not registered in fn_meta", node.function))?;
1008
1009    let children_callable_ids = match &node.children {
1010        None => vec![],
1011        Some(ids) => ids
1012            .iter()
1013            .map(|&child_id| register_node(engine, child_id, node_index, fn_meta, trace, variables))
1014            .collect::<Result<Vec<_>, _>>()?,
1015    };
1016
1017    let callable: Rc<dyn Callable> = Rc::new(NodeCallable {
1018        node_id,
1019        fn_id: node.function,
1020        module_id: meta.module_id,
1021        children_param_id: meta.children_param_id,
1022        children_callable_ids,
1023        arguments: node.arguments.clone(),
1024        variables: variables.clone(),
1025        trace: trace.clone(),
1026    });
1027    let id = engine.arora_register_callable(callable);
1028    Ok(id.id)
1029}
1030
1031/// JS-callable handle for loading modules and executing behavior trees.
1032///
1033/// Usage:
1034/// 1. `new BehaviorTreeRunner()`
1035/// 2. `runner.loadModule(headerJson, wasmBytes)` – can be called for multiple modules
1036/// 3. `runner.run(nodesJson)` – returns `{status, trace}`
1037/// 4. Or `runner.setVariable(varId, valueJson)` + `runner.tick(nodesJson)` for
1038///    stateful tick-by-tick execution with variable bindings.
1039#[wasm_bindgen]
1040pub struct BehaviorTreeRunner {
1041    inner: Pin<Box<arora_engine::engine::Engine>>,
1042    loader: SharedLoaderRc,
1043    fn_meta: HashMap<Uuid, FnMeta>,
1044    variables: Rc<RefCell<HashMap<Uuid, Value>>>,
1045    module_headers: HashMap<Uuid, String>,
1046}
1047
1048#[wasm_bindgen]
1049impl BehaviorTreeRunner {
1050    #[wasm_bindgen(constructor)]
1051    pub fn new() -> BehaviorTreeRunner {
1052        install_panic_hook();
1053        let executor = BrowserExecutor::new();
1054        let loader = executor.shared();
1055        let inner = EngineBuilder::new().add_executor(executor).build();
1056        BehaviorTreeRunner {
1057            inner,
1058            loader,
1059            fn_meta: HashMap::new(),
1060            variables: Rc::new(RefCell::new(HashMap::new())),
1061            module_headers: HashMap::new(),
1062        }
1063    }
1064
1065    /// Load a WASM module. `header_json` must be the module's YAML header
1066    /// converted to JSON (the JS side can use js-yaml for that).
1067    /// Returns the module UUID string.
1068    ///
1069    /// Compiles and instantiates synchronously: Chrome rejects both above
1070    /// 8 MB on the main thread — use `prepareModule` + `loadPreparedModule`
1071    /// for large executables.
1072    #[wasm_bindgen(js_name = loadModule)]
1073    pub fn load_module(&mut self, header_json: &str, executable: &[u8]) -> Result<String, JsValue> {
1074        self.load_module_inner(header_json, executable.to_vec().into_boxed_slice())
1075    }
1076
1077    /// Asynchronously compile and instantiate a module's executable
1078    /// (via `WebAssembly.instantiate`, no main-thread size limit).
1079    /// Follow up with `loadPreparedModule` to complete the load.
1080    #[wasm_bindgen(js_name = prepareModule)]
1081    pub fn prepare_module(&self, header_json: &str, executable: Vec<u8>) -> js_sys::Promise {
1082        prepare_module_impl(self.loader.clone(), header_json, executable)
1083    }
1084
1085    /// Load a module whose executable was staged by `prepareModule`.
1086    /// Returns the module UUID string.
1087    #[wasm_bindgen(js_name = loadPreparedModule)]
1088    pub fn load_prepared_module(&mut self, header_json: &str) -> Result<String, JsValue> {
1089        self.load_module_inner(header_json, Box::new([]))
1090    }
1091
1092    fn load_module_inner(
1093        &mut self,
1094        header_json: &str,
1095        executable: Box<[u8]>,
1096    ) -> Result<String, JsValue> {
1097        let header: Header = serde_json::from_str(header_json)
1098            .map_err(|e| JsValue::from_str(&format!("invalid header: {e}")))?;
1099        let module_id = header.id;
1100        let header_json_str = header_json.to_string();
1101
1102        for export in &header.exports {
1103            let arora_types::module::low::ExportSymbol::Function(f) = export;
1104            let children_param_id = f.parameters.first().and_then(|p| {
1105                if let arora_types::module::low::TypeRef::Array { id } = &p.ty {
1106                    if id == &Uuid::from_bytes(TICK_ID_STRUCT_BYTES) {
1107                        Some(p.id)
1108                    } else {
1109                        None
1110                    }
1111                } else {
1112                    None
1113                }
1114            });
1115
1116            self.fn_meta.insert(
1117                f.id,
1118                FnMeta {
1119                    module_id,
1120                    children_param_id,
1121                },
1122            );
1123        }
1124
1125        let result = load_module_from_parts(&mut self.inner, header, executable)
1126            .map_err(|e| JsValue::from_str(&format!("load failed: {e}")))?;
1127        self.module_headers.insert(result.id, header_json_str);
1128        Ok(result.id.to_string())
1129    }
1130
1131    /// Returns a JSON array of all loaded module headers.
1132    #[wasm_bindgen(js_name = listModules)]
1133    pub fn list_modules(&self) -> String {
1134        let headers: Vec<serde_json::Value> = self
1135            .module_headers
1136            .values()
1137            .filter_map(|s| serde_json::from_str(s).ok())
1138            .collect();
1139        serde_json::to_string(&headers).unwrap_or_else(|_| "[]".to_string())
1140    }
1141
1142    /// Initialize or update a variable. `var_id` is a UUID string; `value_json`
1143    /// is the serialized `Value` (e.g. `{"f32": 0.0}`).
1144    #[wasm_bindgen(js_name = setVariable)]
1145    pub fn set_variable(&mut self, var_id: &str, value_json: &str) -> Result<(), JsValue> {
1146        let var_id: Uuid = var_id
1147            .parse()
1148            .map_err(|_| JsValue::from_str("bad var_id: not a valid UUID"))?;
1149        let value: Value = serde_json::from_str(value_json)
1150            .map_err(|e| JsValue::from_str(&format!("bad value JSON: {e}")))?;
1151        self.variables.borrow_mut().insert(var_id, value);
1152        Ok(())
1153    }
1154
1155    /// Run one tick of the behavior tree. Variables persist across calls.
1156    ///
1157    /// `nodes_json` is a JSON array where each element is:
1158    ///   `{ id, function, children?, arguments?, return_binding? }`
1159    ///
1160    /// Returns: `{ "status": "...", "trace": [...], "variables": { varId: value } }`
1161    pub fn tick(&mut self, nodes_json: &str) -> Result<String, JsValue> {
1162        let nodes: Vec<BtNode> = serde_json::from_str(nodes_json)
1163            .map_err(|e| JsValue::from_str(&format!("bad nodes JSON: {e}")))?;
1164        if nodes.is_empty() {
1165            return Err(JsValue::from_str("tree has no nodes"));
1166        }
1167
1168        let root_id = nodes[0].id;
1169        let node_index: HashMap<Uuid, BtNode> = nodes.into_iter().map(|n| (n.id, n)).collect();
1170        let trace: Rc<RefCell<Vec<(Uuid, &'static str)>>> = Rc::new(RefCell::new(Vec::new()));
1171
1172        let fn_meta = &self.fn_meta;
1173        let root_callable_id = register_node(
1174            &mut *self.inner,
1175            root_id,
1176            &node_index,
1177            fn_meta,
1178            &trace,
1179            &self.variables,
1180        )
1181        .map_err(|e| JsValue::from_str(&format!("setup error: {e}")))?;
1182
1183        let callable_id = CallableId {
1184            id: root_callable_id,
1185        };
1186        let result = Callable::call(&callable_id, &mut *self.inner)
1187            .map_err(|e| JsValue::from_str(&format!("tick error: {e}")))?;
1188        let status = value_to_status(&result);
1189
1190        let trace_json: Vec<serde_json::Value> = trace
1191            .borrow()
1192            .iter()
1193            .map(|(id, s)| serde_json::json!({ "nodeId": id.to_string(), "status": s }))
1194            .collect();
1195
1196        let vars_json: serde_json::Map<String, serde_json::Value> = self
1197            .variables
1198            .borrow()
1199            .iter()
1200            .filter_map(|(id, v)| serde_json::to_value(v).ok().map(|jv| (id.to_string(), jv)))
1201            .collect();
1202
1203        Ok(serde_json::json!({
1204          "status": status,
1205          "trace": trace_json,
1206          "variables": vars_json,
1207        })
1208        .to_string())
1209    }
1210
1211    /// Run a behavior tree to completion (ticks until not Running).
1212    ///
1213    /// `nodes_json` is a JSON array where each element is:
1214    ///   `{ id: "<uuid>", function: "<uuid>", children?: ["<uuid>", ...] }`
1215    /// The first element is the root node.
1216    ///
1217    /// Returns a JSON string:
1218    ///   `{ "status": "success"|"failure"|"running",
1219    ///      "trace": [{"nodeId": "<uuid>", "status": "..."}] }`
1220    pub fn run(&mut self, nodes_json: &str) -> Result<String, JsValue> {
1221        let nodes: Vec<BtNode> = serde_json::from_str(nodes_json)
1222            .map_err(|e| JsValue::from_str(&format!("bad nodes JSON: {e}")))?;
1223        if nodes.is_empty() {
1224            return Err(JsValue::from_str("tree has no nodes"));
1225        }
1226
1227        let root_id = nodes[0].id;
1228        let node_index: HashMap<Uuid, BtNode> = nodes.into_iter().map(|n| (n.id, n)).collect();
1229        let trace: Rc<RefCell<Vec<(Uuid, &'static str)>>> = Rc::new(RefCell::new(Vec::new()));
1230
1231        let fn_meta = &self.fn_meta;
1232        let root_callable_id = register_node(
1233            &mut *self.inner,
1234            root_id,
1235            &node_index,
1236            fn_meta,
1237            &trace,
1238            &self.variables,
1239        )
1240        .map_err(|e| JsValue::from_str(&format!("setup error: {e}")))?;
1241
1242        let callable_id = CallableId {
1243            id: root_callable_id,
1244        };
1245
1246        let mut last_status = "running";
1247        for _ in 0..10_000 {
1248            let result = Callable::call(&callable_id, &mut *self.inner)
1249                .map_err(|e| JsValue::from_str(&format!("tick error: {e}")))?;
1250            last_status = value_to_status(&result);
1251            if last_status != "running" {
1252                break;
1253            }
1254        }
1255
1256        let trace_json: Vec<serde_json::Value> = trace
1257            .borrow()
1258            .iter()
1259            .map(|(id, s)| serde_json::json!({ "nodeId": id.to_string(), "status": s }))
1260            .collect();
1261
1262        let out = serde_json::json!({
1263          "status": last_status,
1264          "trace": trace_json,
1265        });
1266        Ok(out.to_string())
1267    }
1268}
1269
1270impl Default for BehaviorTreeRunner {
1271    fn default() -> Self {
1272        Self::new()
1273    }
1274}
1275
1276// =============================================================================
1277// Type registry
1278//
1279// A minimal wasm-compatible registry for resolving type UUIDs to names.
1280// Loads from the records.json files emitted by arora-module-rust::generate_records.
1281// =============================================================================
1282
1283/// JS-callable type registry that resolves type UUIDs to human-readable names.
1284#[wasm_bindgen]
1285pub struct Registry {
1286    entries: HashMap<Uuid, (String, Option<Uuid>)>,
1287}
1288
1289#[derive(serde::Deserialize)]
1290struct RecordEntry {
1291    id: Uuid,
1292    name: String,
1293    parent: Option<Uuid>,
1294}
1295
1296#[wasm_bindgen]
1297impl Registry {
1298    #[wasm_bindgen(constructor)]
1299    pub fn new() -> Registry {
1300        Registry {
1301            entries: HashMap::new(),
1302        }
1303    }
1304
1305    /// Load type records from a JSON array: `[{id, name, parent?}, ...]`.
1306    #[wasm_bindgen(js_name = loadRecordsJson)]
1307    pub fn load_records_json(&mut self, json: &str) -> Result<(), JsValue> {
1308        let records: Vec<RecordEntry> = serde_json::from_str(json)
1309            .map_err(|e| JsValue::from_str(&format!("invalid records JSON: {e}")))?;
1310        for r in records {
1311            self.entries.insert(r.id, (r.name, r.parent));
1312        }
1313        Ok(())
1314    }
1315
1316    /// Resolve a UUID string to a dot-separated name path (e.g. `"behavior_tree.Status"`).
1317    /// Returns `None` if the UUID is not known.
1318    #[wasm_bindgen(js_name = resolveId)]
1319    pub fn resolve_id(&self, id: &str) -> Option<String> {
1320        let uuid: Uuid = id.parse().ok()?;
1321        self.compute_path(&uuid)
1322    }
1323
1324    fn compute_path(&self, id: &Uuid) -> Option<String> {
1325        let (name, parent) = self.entries.get(id)?;
1326        match parent {
1327            None => Some(name.clone()),
1328            Some(parent_id) if !self.entries.contains_key(parent_id) => Some(name.clone()),
1329            Some(parent_id) => Some(format!("{}.{}", self.compute_path(parent_id)?, name)),
1330        }
1331    }
1332}
1333
1334impl Default for Registry {
1335    fn default() -> Self {
1336        Self::new()
1337    }
1338}