Skip to main content

arora_web/
lib.rs

1//! Run an Arora device in the browser.
2//!
3//! The centerpiece is [`BrowserRuntime`], the reusable primitive that wires a
4//! full [`arora::Runtime`] over an injected HAL, bridge, and data store and
5//! exposes the JS-facing surface every browser device needs — a synchronous
6//! `step()` plus Value↔JSON accessors on the store. [`AroraRuntime`] is the
7//! bundled demo device built on it (in-process fakes + `SimpleDataStore`); each
8//! downstream device ships its own thin `#[wasm_bindgen]` wrapper the same way.
9//!
10//! It also carries a lower-level surface: [`Engine`] and [`BehaviorTreeRunner`]
11//! load guest modules (header JSON + executable bytes) and run behavior trees
12//! directly on the engine, hosting modules via the browser's native
13//! `WebAssembly` runtime — see `arora_engine::executor::browser`.
14//!
15//! This crate only carries non-trivial content when built for `wasm32-*`
16//! targets. On the host it is an empty shim (deps are gated to `wasm32`) so it
17//! can sit in the workspace and be verified by `cargo package` on publish
18//! without pulling wasm-only deps into a host link.
19
20#![cfg(target_arch = "wasm32")]
21
22use std::cell::RefCell;
23use std::collections::HashMap;
24use std::pin::Pin;
25use std::rc::Rc;
26
27use arora_engine::{
28    call::{CallBridge, Callable, CallableId},
29    engine::EngineBuilder,
30    executor::browser::{BrowserExecutor, SharedLoaderRc},
31    load::load_module_from_parts,
32};
33use arora_types::module::low::Header;
34use arora_types::{
35    call::Call,
36    value::{Enumeration, StructureField, StructureWithoutId, Value},
37};
38use uuid::Uuid;
39use wasm_bindgen::prelude::*;
40
41/// Route Rust panics to the browser console. Called from every entry point
42/// (`BrowserRuntime::start`, `Engine::new`, `BehaviorTreeRunner::new`) rather
43/// than from a `#[wasm_bindgen(start)]`: a reusable library must not claim the
44/// module `start`, or every downstream cdylib that also defines one collides on
45/// the `_start` symbol at link time. `set_once` makes repeat calls free.
46fn install_panic_hook() {
47    console_error_panic_hook::set_once();
48}
49
50// =============================================================================
51// Browser runtime primitive
52//
53// `BrowserRuntime` is the reusable core for running any Arora device in the
54// browser. It wires an `arora::Runtime` over an injected HAL, bridge, and data
55// store (all trait objects, so the caller picks the backends) and exposes the
56// JS-facing surface every browser device needs: a synchronous `step()` plus
57// Value↔JSON accessors on the injected store. There is no async pump — the
58// bridge and HAL own any async internally, behind their synchronous seams, so
59// the whole thing is a plain synchronous object driven by `step()`.
60//
61// It is a plain Rust type, not a `#[wasm_bindgen]` export — the wasm-bindgen
62// boundary cannot carry `Arc<dyn Trait>`. Each device ships a thin
63// `#[wasm_bindgen]` cdylib that constructs its concrete HAL/bridge/store and
64// behaviors, then forwards to a `BrowserRuntime` held inside. `AroraRuntime`
65// below is one such wrapper (the in-process fakes); Vizij ships another.
66// =============================================================================
67
68use arora::runtime::{Runtime, StepOutcome};
69use arora_behavior::BehaviorInterpreter;
70use arora_bridge::{Bridge, FakeBridge};
71use arora_hal::{FakeHal, Hal};
72use arora_simple_data_store::SimpleDataStore;
73use arora_types::data::{DataStore, Key, StateChange, Subscription};
74use std::sync::Arc;
75
76/// The reusable core of a browser-hosted Arora device.
77///
78/// Assemble it with [`BrowserRuntime::start`] over your chosen HAL, bridge, and
79/// [`DataStore`]; queue one or more behaviors; then drive it a tick at a time
80/// with [`step`](Self::step) (e.g. from `requestAnimationFrame` or a Web Worker
81/// loop). Read and write the injected store across the JS boundary with the
82/// Value↔JSON accessors — values cross as JSON in the Arora [`Value`] vocabulary,
83/// e.g. `{"f32": 0.75}`.
84pub struct BrowserRuntime {
85    runtime: Runtime,
86    store: Arc<dyn DataStore>,
87    changes: Subscription,
88}
89
90impl BrowserRuntime {
91    /// Boot an [`arora::Arora`] (engine + native behavior-tree nodes) and inject
92    /// the given `hal`, `bridge`, and `store` via [`Runtime::with_io_in`]. There
93    /// is no async pump to spawn — the bridge and HAL own any async internally,
94    /// behind their synchronous seams. Queue behaviors next, then drive with
95    /// [`step`](Self::step).
96    pub async fn start(
97        hal: Arc<dyn Hal>,
98        bridge: Arc<dyn Bridge>,
99        store: Arc<dyn DataStore>,
100    ) -> Result<BrowserRuntime, JsValue> {
101        install_panic_hook();
102        let arora = arora::Arora::start()
103            .await
104            .map_err(|e| JsValue::from_str(&format!("arora start failed: {e:?}")))?;
105        let changes = store.subscribe();
106        let runtime = Runtime::with_io_in(arora, hal, bridge, store.clone());
107        Ok(BrowserRuntime {
108            runtime,
109            store,
110            changes,
111        })
112    }
113
114    /// The injected store, for direct access beyond the JSON accessors.
115    pub fn store(&self) -> &Arc<dyn DataStore> {
116        &self.store
117    }
118
119    /// Queue a [`BehaviorInterpreter`] to run on the next step.
120    pub fn queue_behavior(&mut self, behavior: Box<dyn BehaviorInterpreter>) {
121        self.runtime.queue_behavior(behavior);
122    }
123
124    /// Queue a behavior tree (Groot XML) to run on the next step.
125    pub fn queue_groot_xml(&mut self, xml: &str) -> Result<(), JsValue> {
126        self.runtime
127            .queue_groot_xml(xml)
128            .map_err(|e| JsValue::from_str(&format!("queue failed: {e}")))
129    }
130
131    /// Advance the runtime one tick. `dt_ns` is the **nanoseconds** elapsed since
132    /// the previous step. A web driver measures it from `requestAnimationFrame`
133    /// timestamps (milliseconds) and converts — see the [`AroraRuntime::step`]
134    /// wasm boundary. The runtime publishes it (and the accumulated time) under
135    /// the golden keys before ticking. Returns `true` while live, `false` once
136    /// the device has been unregistered (stop stepping then).
137    pub fn step(&mut self, dt_ns: u64) -> Result<bool, JsValue> {
138        match self.runtime.step(dt_ns) {
139            Ok(StepOutcome::Live) => Ok(true),
140            Ok(StepOutcome::Unregistered) => Ok(false),
141            Err(e) => Err(JsValue::from_str(&format!("step failed: {e}"))),
142        }
143    }
144
145    /// Write one key into the store. `value_json` is an Arora [`Value`] as JSON,
146    /// e.g. `{"f32": 0.75}`.
147    pub fn set_value(&self, path: &str, value_json: &str) -> Result<(), JsValue> {
148        let value: Value = serde_json::from_str(value_json)
149            .map_err(|e| JsValue::from_str(&format!("invalid value json for {path}: {e}")))?;
150        self.store
151            .write(StateChange::set(path, value))
152            .map_err(|e| JsValue::from_str(&format!("write {path} failed: {e}")))
153    }
154
155    /// Write several keys at once, as one store change. `values_json` is a JSON
156    /// object mapping each key path to an Arora [`Value`], e.g.
157    /// `{"sensor/x": {"f32": 0.75}}`.
158    pub fn write_values(&self, values_json: &str) -> Result<(), JsValue> {
159        let map: serde_json::Map<String, serde_json::Value> = serde_json::from_str(values_json)
160            .map_err(|e| JsValue::from_str(&format!("invalid values json: {e}")))?;
161        let mut change = StateChange::new();
162        for (path, raw) in map {
163            let value: Value = serde_json::from_value(raw)
164                .map_err(|e| JsValue::from_str(&format!("invalid value for {path}: {e}")))?;
165            change.set.insert(Key::new(path), Some(value));
166        }
167        self.store
168            .write(change)
169            .map_err(|e| JsValue::from_str(&format!("write failed: {e}")))
170    }
171
172    /// Read keys from the store. `paths` is a JS `string[]`; the result is a JS
173    /// object mapping each path to its Arora [`Value`] (or `null` if absent).
174    pub fn read_values(&self, paths: JsValue) -> Result<JsValue, JsValue> {
175        let paths: Vec<String> = serde_wasm_bindgen::from_value(paths)
176            .map_err(|e| JsValue::from_str(&format!("paths must be a string[]: {e}")))?;
177        let keys: Vec<Key> = paths.iter().map(Key::new).collect();
178        let values = self.store.read(&keys);
179        let mut out = serde_json::Map::with_capacity(paths.len());
180        for (path, value) in paths.into_iter().zip(values) {
181            out.insert(path, value_to_json(value)?);
182        }
183        serde_wasm_bindgen::to_value(&serde_json::Value::Object(out))
184            .map_err(|e| JsValue::from_str(&format!("serialize failed: {e}")))
185    }
186
187    /// A snapshot of every key currently in the store, as a JS object mapping
188    /// path → Arora [`Value`].
189    pub fn snapshot(&self) -> Result<JsValue, JsValue> {
190        let state = self.store.snapshot();
191        let mut out = serde_json::Map::with_capacity(state.storage.len());
192        for (key, value) in state.storage {
193            out.insert(key.path, value_to_json(value)?);
194        }
195        serde_wasm_bindgen::to_value(&serde_json::Value::Object(out))
196            .map_err(|e| JsValue::from_str(&format!("serialize failed: {e}")))
197    }
198
199    /// Drain the keys that changed in the store since the last call, as a JS
200    /// object mapping path → new Arora [`Value`] (or `null` for a cleared key).
201    /// Poll-based counterpart to a push subscription: [`DataStore::subscribe`]
202    /// delivers over a std channel JavaScript cannot await, so changes accumulate
203    /// and are handed over on demand — call it right after [`step`](Self::step).
204    pub fn drain_changes(&self) -> Result<JsValue, JsValue> {
205        let mut out = serde_json::Map::new();
206        while let Some(change) = self.changes.try_recv() {
207            for (key, value) in change.set {
208                out.insert(key.path, value_to_json(value)?);
209            }
210            for key in change.unset {
211                out.insert(key.path, serde_json::Value::Null);
212            }
213        }
214        serde_wasm_bindgen::to_value(&serde_json::Value::Object(out))
215            .map_err(|e| JsValue::from_str(&format!("serialize failed: {e}")))
216    }
217}
218
219/// Serialize an optional Arora value to JSON (`null` when absent).
220fn value_to_json(value: Option<Value>) -> Result<serde_json::Value, JsValue> {
221    match value {
222        Some(v) => serde_json::to_value(v)
223            .map_err(|e| JsValue::from_str(&format!("serialize value failed: {e}"))),
224        None => Ok(serde_json::Value::Null),
225    }
226}
227
228// =============================================================================
229// Opinionated Arora runtime (demo)
230//
231// A `#[wasm_bindgen]` device built on `BrowserRuntime` with the in-process fake
232// HAL and bridge over a plain `SimpleDataStore`. The basic behavior-tree control
233// nodes are wired natively into the engine, so no node module needs to be
234// fetched or loaded. Drive it by calling `step()`.
235// =============================================================================
236
237/// JS-callable handle to a running opinionated Arora runtime.
238#[wasm_bindgen]
239pub struct AroraRuntime {
240    inner: BrowserRuntime,
241}
242
243#[wasm_bindgen]
244impl AroraRuntime {
245    /// Start the runtime with an in-process fake HAL and bridge over a plain
246    /// `SimpleDataStore`. Spawns the async io pump on the browser event loop;
247    /// drive the runtime by calling `step()`.
248    pub async fn start() -> Result<AroraRuntime, JsValue> {
249        let inner = BrowserRuntime::start(
250            Arc::new(FakeHal::new()),
251            Arc::new(FakeBridge::new()),
252            Arc::new(SimpleDataStore::new()),
253        )
254        .await?;
255        Ok(AroraRuntime { inner })
256    }
257
258    /// Advance the runtime one step. `dt_ms` is the milliseconds elapsed since
259    /// the previous step — a plain JS number, exactly what a
260    /// `requestAnimationFrame` timestamp delta gives. The core clock is integer
261    /// nanoseconds, so this converts at the wasm boundary. Returns `true` while
262    /// live, `false` once the device has been unregistered (stop calling then).
263    pub fn step(&mut self, dt_ms: f64) -> Result<bool, JsValue> {
264        self.inner.step((dt_ms * 1_000_000.0) as u64)
265    }
266
267    /// Queue a behavior tree (Groot XML) to run on the next step.
268    #[wasm_bindgen(js_name = queueGrootXml)]
269    pub fn queue_groot_xml(&mut self, xml: &str) -> Result<(), JsValue> {
270        self.inner.queue_groot_xml(xml)
271    }
272
273    /// Write one key into the store (Arora [`Value`] as JSON, e.g. `{"f32": 1}`).
274    #[wasm_bindgen(js_name = setValue)]
275    pub fn set_value(&self, path: &str, value_json: &str) -> Result<(), JsValue> {
276        self.inner.set_value(path, value_json)
277    }
278
279    /// Read keys from the store; `paths` is a `string[]`, result maps path→Value.
280    #[wasm_bindgen(js_name = readValues)]
281    pub fn read_values(&self, paths: JsValue) -> Result<JsValue, JsValue> {
282        self.inner.read_values(paths)
283    }
284
285    /// A snapshot of every key in the store as a path→Value object.
286    pub fn snapshot(&self) -> Result<JsValue, JsValue> {
287        self.inner.snapshot()
288    }
289}
290
291/// JS-callable handle to a configured Arora engine.
292#[wasm_bindgen]
293pub struct Engine {
294    inner: std::pin::Pin<Box<arora_engine::engine::Engine>>,
295    loader: SharedLoaderRc,
296    function_module: HashMap<Uuid, Uuid>,
297    module_headers: HashMap<Uuid, String>,
298}
299
300#[wasm_bindgen]
301impl Engine {
302    #[wasm_bindgen(constructor)]
303    pub fn new() -> Engine {
304        install_panic_hook();
305        let executor = BrowserExecutor::new();
306        let loader = executor.shared();
307        let inner = EngineBuilder::new().add_executor(executor).build();
308        Engine {
309            inner,
310            loader,
311            function_module: HashMap::new(),
312            module_headers: HashMap::new(),
313        }
314    }
315
316    /// Load a module given its header (as JSON) and executable bytes.
317    /// Returns the module's UUID as a string.
318    ///
319    /// Compiles and instantiates synchronously: Chrome rejects both above
320    /// 8 MB on the main thread — use `prepareModule` + `loadPreparedModule`
321    /// for large executables.
322    #[wasm_bindgen(js_name = loadModule)]
323    pub fn load_module(&mut self, header_json: &str, executable: &[u8]) -> Result<String, JsValue> {
324        self.load_module_inner(header_json, executable.to_vec().into_boxed_slice())
325    }
326
327    /// Asynchronously compile and instantiate a module's executable
328    /// (via `WebAssembly.instantiate`, no main-thread size limit).
329    /// Follow up with `loadPreparedModule` to complete the load.
330    #[wasm_bindgen(js_name = prepareModule)]
331    pub fn prepare_module(&self, header_json: &str, executable: Vec<u8>) -> js_sys::Promise {
332        prepare_module_impl(self.loader.clone(), header_json, executable)
333    }
334
335    /// Load a module whose executable was staged by `prepareModule`.
336    /// Returns the module's UUID as a string.
337    #[wasm_bindgen(js_name = loadPreparedModule)]
338    pub fn load_prepared_module(&mut self, header_json: &str) -> Result<String, JsValue> {
339        self.load_module_inner(header_json, Box::new([]))
340    }
341
342    fn load_module_inner(
343        &mut self,
344        header_json: &str,
345        executable: Box<[u8]>,
346    ) -> Result<String, JsValue> {
347        let header: Header = serde_json::from_str(header_json)
348            .map_err(|e| JsValue::from_str(&format!("invalid header json: {e}")))?;
349        let header_json_str = header_json.to_string();
350        let loaded = load_module_from_parts(&mut *self.inner, header, executable)
351            .map_err(|e| JsValue::from_str(&format!("load_module failed: {e}")))?;
352        for fn_id in &loaded.function_ids {
353            self.function_module.insert(*fn_id, loaded.id);
354        }
355        self.module_headers.insert(loaded.id, header_json_str);
356        Ok(loaded.id.to_string())
357    }
358
359    /// Returns a JSON array of all loaded module headers.
360    #[wasm_bindgen(js_name = listModules)]
361    pub fn list_modules(&self) -> String {
362        let headers: Vec<serde_json::Value> = self
363            .module_headers
364            .values()
365            .filter_map(|s| serde_json::from_str(s).ok())
366            .collect();
367        serde_json::to_string(&headers).unwrap_or_else(|_| "[]".to_string())
368    }
369
370    /// Call a function. `call_json` is a JSON document matching
371    /// `arora_engine::call::Call`. Returns the result as a JSON string.
372    #[wasm_bindgen]
373    pub fn call(&mut self, call_json: &str) -> Result<String, JsValue> {
374        let call: Call = serde_json::from_str(call_json)
375            .map_err(|e| JsValue::from_str(&format!("invalid call json: {e}")))?;
376        let module_id = if let Some(m) = call.module_id {
377            m
378        } else {
379            *self
380                .function_module
381                .get(&call.id)
382                .ok_or_else(|| JsValue::from_str("no module known for function"))?
383        };
384        let result = self
385            .inner
386            .arora_call(&module_id, call)
387            .map_err(|e| JsValue::from_str(&format!("call failed: {e}")))?;
388        serde_json::to_string(&result)
389            .map_err(|e| JsValue::from_str(&format!("serialize failed: {e}")))
390    }
391}
392
393impl Default for Engine {
394    fn default() -> Self {
395        Self::new()
396    }
397}
398
399/// Shared implementation of `prepareModule`: parses the header for the
400/// module id, then stages an asynchronously-instantiated module in the
401/// loader. Returns a `Promise<void>`.
402fn prepare_module_impl(
403    loader: SharedLoaderRc,
404    header_json: &str,
405    executable: Vec<u8>,
406) -> js_sys::Promise {
407    let header: Result<Header, _> = serde_json::from_str(header_json);
408    wasm_bindgen_futures::future_to_promise(async move {
409        let header = header.map_err(|e| JsValue::from_str(&format!("invalid header json: {e}")))?;
410        loader.prepare(header.id, executable).await?;
411        Ok(JsValue::UNDEFINED)
412    })
413}
414
415// =============================================================================
416// Behavior-tree runner
417//
418// A self-contained BT runtime built directly on arora's engine primitives —
419// no dependency on the arora-behavior-tree crate.
420// =============================================================================
421
422// UUID constants from arora-behavior-tree-types generated code.
423// TickId struct id: 6f49e650-84ca-4899-a9bd-1f3bf17fab51
424const TICK_ID_STRUCT_BYTES: [u8; 16] = [
425    0x6f, 0x49, 0xe6, 0x50, 0x84, 0xca, 0x48, 0x99, 0xa9, 0xbd, 0x1f, 0x3b, 0xf1, 0x7f, 0xab, 0x51,
426];
427// TickId::callable_id field: 237992d2-17d1-459f-bca1-7185fa6a69d7
428const TICK_ID_CALLABLE_FIELD_BYTES: [u8; 16] = [
429    0x23, 0x79, 0x92, 0xd2, 0x17, 0xd1, 0x45, 0x9f, 0xbc, 0xa1, 0x71, 0x85, 0xfa, 0x6a, 0x69, 0xd7,
430];
431// Status::Success variant: 766e9e9a-446d-4e46-83e6-14b7ca101169
432const STATUS_SUCCESS_BYTES: [u8; 16] = [
433    0x76, 0x6e, 0x9e, 0x9a, 0x44, 0x6d, 0x4e, 0x46, 0x83, 0xe6, 0x14, 0xb7, 0xca, 0x10, 0x11, 0x69,
434];
435// Status::Failure variant: 2468f46c-bb60-425c-9a4d-9ad326ccc7e2
436const STATUS_FAILURE_BYTES: [u8; 16] = [
437    0x24, 0x68, 0xf4, 0x6c, 0xbb, 0x60, 0x42, 0x5c, 0x9a, 0x4d, 0x9a, 0xd3, 0x26, 0xcc, 0xc7, 0xe2,
438];
439// Status enum type: 325a5767-e344-4532-860e-0749bcf2e428
440const STATUS_ENUM_BYTES: [u8; 16] = [
441    0x32, 0x5a, 0x57, 0x67, 0xe3, 0x44, 0x45, 0x32, 0x86, 0x0e, 0x07, 0x49, 0xbc, 0xf2, 0xe4, 0x28,
442];
443// _ret special out-parameter: 5f726574-0000-4000-8000-000000000000 ("_ret" in ASCII)
444const RET_PARAM_BYTES: [u8; 16] = [
445    0x5f, 0x72, 0x65, 0x74, 0x00, 0x00, 0x40, 0x00, 0x80, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
446];
447
448fn value_to_status(v: &Value) -> &'static str {
449    if let Value::Enumeration(e) = v {
450        if *e.variant_id.as_bytes() == STATUS_SUCCESS_BYTES {
451            return "success";
452        }
453        if *e.variant_id.as_bytes() == STATUS_FAILURE_BYTES {
454            return "failure";
455        }
456    }
457    "running"
458}
459
460/// A node argument expression: either a literal value or a reference to a
461/// named variable (identified by UUID).
462#[derive(serde::Deserialize, Clone, Debug)]
463#[serde(rename_all = "snake_case")]
464enum BtExpression {
465    VariableId(Uuid),
466    Value(Value),
467}
468
469/// A single BT node – mirrors the arora-behavior-tree YAML schema.
470#[derive(serde::Deserialize, Debug)]
471struct BtNode {
472    id: Uuid,
473    function: Uuid,
474    #[serde(default)]
475    children: Option<Vec<Uuid>>,
476    /// Parameter arguments: maps parameter UUID to a literal value or variable.
477    /// Use the special key `5f726574-0000-4000-8000-000000000000` (_ret) to
478    /// capture the return value into a variable; the node then always succeeds.
479    #[serde(default)]
480    arguments: HashMap<Uuid, BtExpression>,
481}
482
483/// Metadata extracted from a module header for one exported function.
484struct FnMeta {
485    module_id: Uuid,
486    /// Parameter ID of the `children: TickId[]` argument, present only for
487    /// control nodes (seq, fallback, parallel …).
488    children_param_id: Option<Uuid>,
489}
490
491/// An arora Callable that wraps one BT node.
492struct NodeCallable {
493    node_id: Uuid,
494    fn_id: Uuid,
495    module_id: Uuid,
496    children_param_id: Option<Uuid>,
497    children_callable_ids: Vec<u64>,
498    arguments: HashMap<Uuid, BtExpression>,
499    variables: Rc<RefCell<HashMap<Uuid, Value>>>,
500    trace: Rc<RefCell<Vec<(Uuid, &'static str)>>>,
501}
502
503impl Callable for NodeCallable {
504    fn call(&self, caller: &mut dyn CallBridge) -> Result<Value, arora_engine::call::CallError> {
505        let tick_id_type = Uuid::from_bytes(TICK_ID_STRUCT_BYTES);
506        let callable_field = Uuid::from_bytes(TICK_ID_CALLABLE_FIELD_BYTES);
507        let ret_param_id = Uuid::from_bytes(RET_PARAM_BYTES);
508
509        let mut args = Vec::new();
510
511        if let Some(children_param_id) = self.children_param_id {
512            let elements: Vec<StructureWithoutId> = self
513                .children_callable_ids
514                .iter()
515                .map(|&id| StructureWithoutId {
516                    fields: vec![StructureField {
517                        id: callable_field,
518                        value: Box::new(Value::U64(id)),
519                    }],
520                })
521                .collect();
522            args.push(StructureField {
523                id: children_param_id,
524                value: Box::new(Value::ArrayStructure {
525                    id: tick_id_type,
526                    elements,
527                }),
528            });
529        }
530
531        for (&param_id, expr) in &self.arguments {
532            if param_id == ret_param_id {
533                continue;
534            }
535            let value = match expr {
536                BtExpression::Value(v) => v.clone(),
537                BtExpression::VariableId(var_id) => self
538                    .variables
539                    .borrow()
540                    .get(var_id)
541                    .cloned()
542                    .unwrap_or(Value::Unit),
543            };
544            args.push(StructureField {
545                id: param_id,
546                value: Box::new(value),
547            });
548        }
549
550        // Build a map of param_id -> variable_id for mutable arguments so we can
551        // write mutated values back to the variable store after the call.
552        let mutable_param_vars: HashMap<Uuid, Uuid> = self
553            .arguments
554            .iter()
555            .filter_map(|(&param_id, expr)| {
556                if param_id == ret_param_id {
557                    return None;
558                }
559                if let BtExpression::VariableId(var_id) = expr {
560                    Some((param_id, *var_id))
561                } else {
562                    None
563                }
564            })
565            .collect();
566
567        let result = caller.arora_call(
568            &self.module_id,
569            Call {
570                module_id: None,
571                id: self.fn_id,
572                args,
573            },
574        )?;
575
576        // Write back mutated parameter values to bound variables.
577        for mutated in &result.mutated {
578            if let Some(&var_id) = mutable_param_vars.get(&mutated.id) {
579                self.variables
580                    .borrow_mut()
581                    .insert(var_id, *mutated.value.clone());
582            }
583        }
584
585        let has_ret = self.arguments.contains_key(&ret_param_id);
586        if has_ret {
587            if let Some(BtExpression::VariableId(var_id)) = self.arguments.get(&ret_param_id) {
588                self.variables
589                    .borrow_mut()
590                    .insert(*var_id, result.ret.clone());
591            }
592        }
593
594        let s = if has_ret {
595            "success"
596        } else {
597            value_to_status(&result.ret)
598        };
599        self.trace.borrow_mut().push((self.node_id, s));
600
601        if has_ret {
602            Ok(Value::Enumeration(Enumeration {
603                id: Uuid::from_bytes(STATUS_ENUM_BYTES),
604                variant_id: Uuid::from_bytes(STATUS_SUCCESS_BYTES),
605                value: Box::new(Value::Unit),
606            }))
607        } else {
608            Ok(result.ret)
609        }
610    }
611}
612
613/// Recursively registers callables for `node_id` and all descendants.
614/// Returns the callable id of the registered root callable.
615fn register_node(
616    engine: &mut dyn CallBridge,
617    node_id: Uuid,
618    node_index: &HashMap<Uuid, BtNode>,
619    fn_meta: &HashMap<Uuid, FnMeta>,
620    trace: &Rc<RefCell<Vec<(Uuid, &'static str)>>>,
621    variables: &Rc<RefCell<HashMap<Uuid, Value>>>,
622) -> Result<u64, String> {
623    let node = node_index
624        .get(&node_id)
625        .ok_or_else(|| format!("node {node_id} not found in tree"))?;
626    let meta = fn_meta
627        .get(&node.function)
628        .ok_or_else(|| format!("function {} not registered in fn_meta", node.function))?;
629
630    let children_callable_ids = match &node.children {
631        None => vec![],
632        Some(ids) => ids
633            .iter()
634            .map(|&child_id| register_node(engine, child_id, node_index, fn_meta, trace, variables))
635            .collect::<Result<Vec<_>, _>>()?,
636    };
637
638    let callable: Rc<dyn Callable> = Rc::new(NodeCallable {
639        node_id,
640        fn_id: node.function,
641        module_id: meta.module_id,
642        children_param_id: meta.children_param_id,
643        children_callable_ids,
644        arguments: node.arguments.clone(),
645        variables: variables.clone(),
646        trace: trace.clone(),
647    });
648    let id = engine.arora_register_callable(callable);
649    Ok(id.id)
650}
651
652/// JS-callable handle for loading modules and executing behavior trees.
653///
654/// Usage:
655/// 1. `new BehaviorTreeRunner()`
656/// 2. `runner.loadModule(headerJson, wasmBytes)` – can be called for multiple modules
657/// 3. `runner.run(nodesJson)` – returns `{status, trace}`
658/// 4. Or `runner.setVariable(varId, valueJson)` + `runner.tick(nodesJson)` for
659///    stateful tick-by-tick execution with variable bindings.
660#[wasm_bindgen]
661pub struct BehaviorTreeRunner {
662    inner: Pin<Box<arora_engine::engine::Engine>>,
663    loader: SharedLoaderRc,
664    fn_meta: HashMap<Uuid, FnMeta>,
665    variables: Rc<RefCell<HashMap<Uuid, Value>>>,
666    module_headers: HashMap<Uuid, String>,
667}
668
669#[wasm_bindgen]
670impl BehaviorTreeRunner {
671    #[wasm_bindgen(constructor)]
672    pub fn new() -> BehaviorTreeRunner {
673        install_panic_hook();
674        let executor = BrowserExecutor::new();
675        let loader = executor.shared();
676        let inner = EngineBuilder::new().add_executor(executor).build();
677        BehaviorTreeRunner {
678            inner,
679            loader,
680            fn_meta: HashMap::new(),
681            variables: Rc::new(RefCell::new(HashMap::new())),
682            module_headers: HashMap::new(),
683        }
684    }
685
686    /// Load a WASM module. `header_json` must be the module's YAML header
687    /// converted to JSON (the JS side can use js-yaml for that).
688    /// Returns the module UUID string.
689    ///
690    /// Compiles and instantiates synchronously: Chrome rejects both above
691    /// 8 MB on the main thread — use `prepareModule` + `loadPreparedModule`
692    /// for large executables.
693    #[wasm_bindgen(js_name = loadModule)]
694    pub fn load_module(&mut self, header_json: &str, executable: &[u8]) -> Result<String, JsValue> {
695        self.load_module_inner(header_json, executable.to_vec().into_boxed_slice())
696    }
697
698    /// Asynchronously compile and instantiate a module's executable
699    /// (via `WebAssembly.instantiate`, no main-thread size limit).
700    /// Follow up with `loadPreparedModule` to complete the load.
701    #[wasm_bindgen(js_name = prepareModule)]
702    pub fn prepare_module(&self, header_json: &str, executable: Vec<u8>) -> js_sys::Promise {
703        prepare_module_impl(self.loader.clone(), header_json, executable)
704    }
705
706    /// Load a module whose executable was staged by `prepareModule`.
707    /// Returns the module UUID string.
708    #[wasm_bindgen(js_name = loadPreparedModule)]
709    pub fn load_prepared_module(&mut self, header_json: &str) -> Result<String, JsValue> {
710        self.load_module_inner(header_json, Box::new([]))
711    }
712
713    fn load_module_inner(
714        &mut self,
715        header_json: &str,
716        executable: Box<[u8]>,
717    ) -> Result<String, JsValue> {
718        let header: Header = serde_json::from_str(header_json)
719            .map_err(|e| JsValue::from_str(&format!("invalid header: {e}")))?;
720        let module_id = header.id;
721        let header_json_str = header_json.to_string();
722
723        for export in &header.exports {
724            let arora_types::module::low::ExportSymbol::Function(f) = export;
725            let children_param_id = f.parameters.first().and_then(|p| {
726                if let arora_types::module::low::TypeRef::Array { id } = &p.ty {
727                    if id == &Uuid::from_bytes(TICK_ID_STRUCT_BYTES) {
728                        Some(p.id)
729                    } else {
730                        None
731                    }
732                } else {
733                    None
734                }
735            });
736
737            self.fn_meta.insert(
738                f.id,
739                FnMeta {
740                    module_id,
741                    children_param_id,
742                },
743            );
744        }
745
746        let result = load_module_from_parts(&mut *self.inner, header, executable)
747            .map_err(|e| JsValue::from_str(&format!("load failed: {e}")))?;
748        self.module_headers.insert(result.id, header_json_str);
749        Ok(result.id.to_string())
750    }
751
752    /// Returns a JSON array of all loaded module headers.
753    #[wasm_bindgen(js_name = listModules)]
754    pub fn list_modules(&self) -> String {
755        let headers: Vec<serde_json::Value> = self
756            .module_headers
757            .values()
758            .filter_map(|s| serde_json::from_str(s).ok())
759            .collect();
760        serde_json::to_string(&headers).unwrap_or_else(|_| "[]".to_string())
761    }
762
763    /// Initialize or update a variable. `var_id` is a UUID string; `value_json`
764    /// is the serialized `Value` (e.g. `{"f32": 0.0}`).
765    #[wasm_bindgen(js_name = setVariable)]
766    pub fn set_variable(&mut self, var_id: &str, value_json: &str) -> Result<(), JsValue> {
767        let var_id: Uuid = var_id
768            .parse()
769            .map_err(|_| JsValue::from_str("bad var_id: not a valid UUID"))?;
770        let value: Value = serde_json::from_str(value_json)
771            .map_err(|e| JsValue::from_str(&format!("bad value JSON: {e}")))?;
772        self.variables.borrow_mut().insert(var_id, value);
773        Ok(())
774    }
775
776    /// Run one tick of the behavior tree. Variables persist across calls.
777    ///
778    /// `nodes_json` is a JSON array where each element is:
779    ///   `{ id, function, children?, arguments?, return_binding? }`
780    ///
781    /// Returns: `{ "status": "...", "trace": [...], "variables": { varId: value } }`
782    pub fn tick(&mut self, nodes_json: &str) -> Result<String, JsValue> {
783        let nodes: Vec<BtNode> = serde_json::from_str(nodes_json)
784            .map_err(|e| JsValue::from_str(&format!("bad nodes JSON: {e}")))?;
785        if nodes.is_empty() {
786            return Err(JsValue::from_str("tree has no nodes"));
787        }
788
789        let root_id = nodes[0].id;
790        let node_index: HashMap<Uuid, BtNode> = nodes.into_iter().map(|n| (n.id, n)).collect();
791        let trace: Rc<RefCell<Vec<(Uuid, &'static str)>>> = Rc::new(RefCell::new(Vec::new()));
792
793        let fn_meta = &self.fn_meta;
794        let root_callable_id = register_node(
795            &mut *self.inner,
796            root_id,
797            &node_index,
798            fn_meta,
799            &trace,
800            &self.variables,
801        )
802        .map_err(|e| JsValue::from_str(&format!("setup error: {e}")))?;
803
804        let callable_id = CallableId {
805            id: root_callable_id,
806        };
807        let result = Callable::call(&callable_id, &mut *self.inner)
808            .map_err(|e| JsValue::from_str(&format!("tick error: {e}")))?;
809        let status = value_to_status(&result);
810
811        let trace_json: Vec<serde_json::Value> = trace
812            .borrow()
813            .iter()
814            .map(|(id, s)| serde_json::json!({ "nodeId": id.to_string(), "status": s }))
815            .collect();
816
817        let vars_json: serde_json::Map<String, serde_json::Value> = self
818            .variables
819            .borrow()
820            .iter()
821            .filter_map(|(id, v)| serde_json::to_value(v).ok().map(|jv| (id.to_string(), jv)))
822            .collect();
823
824        Ok(serde_json::json!({
825          "status": status,
826          "trace": trace_json,
827          "variables": vars_json,
828        })
829        .to_string())
830    }
831
832    /// Run a behavior tree to completion (ticks until not Running).
833    ///
834    /// `nodes_json` is a JSON array where each element is:
835    ///   `{ id: "<uuid>", function: "<uuid>", children?: ["<uuid>", ...] }`
836    /// The first element is the root node.
837    ///
838    /// Returns a JSON string:
839    ///   `{ "status": "success"|"failure"|"running",
840    ///      "trace": [{"nodeId": "<uuid>", "status": "..."}] }`
841    pub fn run(&mut self, nodes_json: &str) -> Result<String, JsValue> {
842        let nodes: Vec<BtNode> = serde_json::from_str(nodes_json)
843            .map_err(|e| JsValue::from_str(&format!("bad nodes JSON: {e}")))?;
844        if nodes.is_empty() {
845            return Err(JsValue::from_str("tree has no nodes"));
846        }
847
848        let root_id = nodes[0].id;
849        let node_index: HashMap<Uuid, BtNode> = nodes.into_iter().map(|n| (n.id, n)).collect();
850        let trace: Rc<RefCell<Vec<(Uuid, &'static str)>>> = Rc::new(RefCell::new(Vec::new()));
851
852        let fn_meta = &self.fn_meta;
853        let root_callable_id = register_node(
854            &mut *self.inner,
855            root_id,
856            &node_index,
857            fn_meta,
858            &trace,
859            &self.variables,
860        )
861        .map_err(|e| JsValue::from_str(&format!("setup error: {e}")))?;
862
863        let callable_id = CallableId {
864            id: root_callable_id,
865        };
866
867        let mut last_status = "running";
868        for _ in 0..10_000 {
869            let result = Callable::call(&callable_id, &mut *self.inner)
870                .map_err(|e| JsValue::from_str(&format!("tick error: {e}")))?;
871            last_status = value_to_status(&result);
872            if last_status != "running" {
873                break;
874            }
875        }
876
877        let trace_json: Vec<serde_json::Value> = trace
878            .borrow()
879            .iter()
880            .map(|(id, s)| serde_json::json!({ "nodeId": id.to_string(), "status": s }))
881            .collect();
882
883        let out = serde_json::json!({
884          "status": last_status,
885          "trace": trace_json,
886        });
887        Ok(out.to_string())
888    }
889}
890
891impl Default for BehaviorTreeRunner {
892    fn default() -> Self {
893        Self::new()
894    }
895}
896
897// =============================================================================
898// Type registry
899//
900// A minimal wasm-compatible registry for resolving type UUIDs to names.
901// Loads from the records.json files emitted by arora-module-rust::generate_records.
902// =============================================================================
903
904/// JS-callable type registry that resolves type UUIDs to human-readable names.
905#[wasm_bindgen]
906pub struct Registry {
907    entries: HashMap<Uuid, (String, Option<Uuid>)>,
908}
909
910#[derive(serde::Deserialize)]
911struct RecordEntry {
912    id: Uuid,
913    name: String,
914    parent: Option<Uuid>,
915}
916
917#[wasm_bindgen]
918impl Registry {
919    #[wasm_bindgen(constructor)]
920    pub fn new() -> Registry {
921        Registry {
922            entries: HashMap::new(),
923        }
924    }
925
926    /// Load type records from a JSON array: `[{id, name, parent?}, ...]`.
927    #[wasm_bindgen(js_name = loadRecordsJson)]
928    pub fn load_records_json(&mut self, json: &str) -> Result<(), JsValue> {
929        let records: Vec<RecordEntry> = serde_json::from_str(json)
930            .map_err(|e| JsValue::from_str(&format!("invalid records JSON: {e}")))?;
931        for r in records {
932            self.entries.insert(r.id, (r.name, r.parent));
933        }
934        Ok(())
935    }
936
937    /// Resolve a UUID string to a dot-separated name path (e.g. `"behavior_tree.Status"`).
938    /// Returns `None` if the UUID is not known.
939    #[wasm_bindgen(js_name = resolveId)]
940    pub fn resolve_id(&self, id: &str) -> Option<String> {
941        let uuid: Uuid = id.parse().ok()?;
942        self.compute_path(&uuid)
943    }
944
945    fn compute_path(&self, id: &Uuid) -> Option<String> {
946        let (name, parent) = self.entries.get(id)?;
947        match parent {
948            None => Some(name.clone()),
949            Some(parent_id) if !self.entries.contains_key(parent_id) => Some(name.clone()),
950            Some(parent_id) => Some(format!("{}.{}", self.compute_path(parent_id)?, name)),
951        }
952    }
953}
954
955impl Default for Registry {
956    fn default() -> Self {
957        Self::new()
958    }
959}