Skip to main content

arora_web/
lib.rs

1//! Run an Arora device in the browser.
2//!
3//! The centerpiece is [`AroraWeb`] — exported to JavaScript as `AroraRuntime`
4//! — the `wasm_bindgen` surface over an [`arora::Arora`]: a synchronous
5//! [`step`](AroraWeb::step), a self-pacing [`run`](AroraWeb::run), in-process
6//! [`call`](AroraWeb::call) dispatch, and the [`store_json`] accessors that
7//! move store values across the JS boundary as JSON in the Arora
8//! [`Value`] vocabulary. Its JS constructor (and [`AroraWebBuilder`], JS
9//! `AroraRuntimeBuilder`) builds the self-contained demo device on the
10//! runtime's in-process defaults; a downstream device composes its own
11//! [`arora::Arora`] with [`arora::AroraBuilder`] — the HAL, bridge, and store
12//! seams are trait objects that cannot cross the JS boundary — and wraps it
13//! with `AroraWeb::from`, reusing this whole JS surface.
14//!
15//! It also carries a lower-level surface: [`Engine`] and [`BehaviorTreeRunner`]
16//! load guest modules (header JSON + executable bytes) and run behavior trees
17//! directly on the engine, hosting modules via the browser's native
18//! `WebAssembly` runtime — see `arora_engine::executor::browser`.
19//!
20//! This crate only carries non-trivial content when built for `wasm32-*`
21//! targets. On the host it is an empty shim (deps are gated to `wasm32`) so it
22//! can sit in the workspace and be verified by `cargo package` on publish
23//! without pulling wasm-only deps into a host link.
24
25#![cfg(target_arch = "wasm32")]
26
27use std::cell::RefCell;
28use std::collections::HashMap;
29use std::pin::Pin;
30use std::rc::Rc;
31
32use arora_engine::{
33    call::{CallBridge, Callable, CallableId},
34    engine::EngineBuilder,
35    executor::browser::{BrowserExecutor, SharedLoaderRc},
36    load::load_module_from_parts,
37};
38use arora_types::module::low::Header;
39use arora_types::{
40    call::Call,
41    value::{Enumeration, StructureField, StructureWithoutId, Value},
42};
43use uuid::Uuid;
44use wasm_bindgen::prelude::*;
45
46/// Route Rust panics to the browser console. Called from every entry point
47/// (the [`AroraWeb`] constructors, `Engine::new`, `BehaviorTreeRunner::new`)
48/// rather than from a `#[wasm_bindgen(start)]`: a reusable library must not
49/// claim the module `start`, or every downstream cdylib that also defines one
50/// collides on the `_start` symbol at link time. `set_once` makes repeat calls
51/// free.
52fn install_panic_hook() {
53    console_error_panic_hook::set_once();
54}
55
56// =============================================================================
57// The browser device
58// =============================================================================
59
60use arora::{Arora, AroraBuilder, Caller, LocalCaller, MethodSignature, TaskId};
61use arora_bridge::client::{self, KeyInfo};
62use arora_types::call::CallError;
63use arora_types::data::{DataStore, Key, StateChange, Subscription};
64use futures::future::Either;
65use futures::FutureExt;
66use std::cell::Cell;
67use std::future::Future;
68use std::task::Poll;
69use std::time::Duration;
70use tokio::sync::watch;
71
72/// Hand `future` to JavaScript as a Promise, having polled it once already: a
73/// future that is already done settles the promise at once, and one that is
74/// not is polled on from wasm-bindgen-futures' queue.
75fn promise(future: impl Future<Output = Result<JsValue, JsValue>> + 'static) -> js_sys::Promise {
76    let mut future = Box::pin(future);
77    let mut cx = std::task::Context::from_waker(futures::task::noop_waker_ref());
78    match future.as_mut().poll(&mut cx) {
79        std::task::Poll::Ready(Ok(value)) => js_sys::Promise::resolve(&value),
80        std::task::Poll::Ready(Err(error)) => js_sys::Promise::reject(&error),
81        std::task::Poll::Pending => wasm_bindgen_futures::future_to_promise(future),
82    }
83}
84
85/// Serialize to a plain JS value — objects as objects, not `Map`s — the form
86/// every structured answer of this surface takes.
87fn to_js(value: &impl serde::Serialize) -> Result<JsValue, JsValue> {
88    value
89        .serialize(&serde_wasm_bindgen::Serializer::json_compatible())
90        .map_err(|e| JsValue::from_str(&format!("serialize failed: {e}")))
91}
92
93/// Serialize an Arora [`Value`] to the plain JS form of its JSON, e.g.
94/// `{"f32": 0.75}`.
95fn value_to_js(value: &Value) -> Result<JsValue, JsValue> {
96    let json = serde_json::to_value(value)
97        .map_err(|e| JsValue::from_str(&format!("serialize value failed: {e}")))?;
98    to_js(&json)
99}
100
101/// A run handle as a client reads it — the plain object `invoke` of a
102/// task-shaped method and `spawn` answer with: `arora_bridge::client::Run`.
103fn run_to_js(handle: &arora::TaskHandle) -> Result<JsValue, JsValue> {
104    let spawned = arora_behavior::interpreter_module::encode_spawn_result(handle);
105    let run = client::run_of(&spawned).map_err(|e| JsValue::from_str(&e))?;
106    to_js(&run)
107}
108
109/// Value↔JSON accessors over a device's [`DataStore`] and change
110/// [`Subscription`] — the store surface every browser device shares. Values
111/// cross the JS boundary as JSON in the Arora [`Value`] vocabulary, e.g.
112/// `{"f32": 0.75}`. [`AroraWeb`] exposes them as methods; a downstream device
113/// wrapping its own store reaches them here directly.
114pub mod store_json {
115    use super::*;
116
117    /// Write one key into the store. `value_json` is an Arora [`Value`] as
118    /// JSON, e.g. `{"f32": 0.75}`.
119    pub fn set_value(store: &dyn DataStore, path: &str, value_json: &str) -> Result<(), JsValue> {
120        let value: Value = serde_json::from_str(value_json)
121            .map_err(|e| JsValue::from_str(&format!("invalid value json for {path}: {e}")))?;
122        store
123            .write(StateChange::set(path, value))
124            .map_err(|e| JsValue::from_str(&format!("write {path} failed: {e}")))
125    }
126
127    /// Write several keys at once, as one store change. `values_json` is a JSON
128    /// object mapping each key path to an Arora [`Value`], e.g.
129    /// `{"sensor/x": {"f32": 0.75}}`.
130    pub fn write_values(store: &dyn DataStore, values_json: &str) -> Result<(), JsValue> {
131        let map: serde_json::Map<String, serde_json::Value> = serde_json::from_str(values_json)
132            .map_err(|e| JsValue::from_str(&format!("invalid values json: {e}")))?;
133        let mut change = StateChange::new();
134        for (path, raw) in map {
135            let value: Value = serde_json::from_value(raw)
136                .map_err(|e| JsValue::from_str(&format!("invalid value for {path}: {e}")))?;
137            change.set.insert(Key::new(path), Some(value));
138        }
139        store
140            .write(change)
141            .map_err(|e| JsValue::from_str(&format!("write failed: {e}")))
142    }
143
144    /// Read keys from the store. `paths` is a JS `string[]`; the result is a JS
145    /// object mapping each path to its Arora [`Value`] (or `null` if absent).
146    pub fn read_values(store: &dyn DataStore, paths: JsValue) -> Result<JsValue, JsValue> {
147        let paths: Vec<String> = serde_wasm_bindgen::from_value(paths)
148            .map_err(|e| JsValue::from_str(&format!("paths must be a string[]: {e}")))?;
149        let keys: Vec<Key> = paths.iter().map(Key::new).collect();
150        let values = store.read(&keys);
151        let mut out = serde_json::Map::with_capacity(paths.len());
152        for (path, value) in paths.into_iter().zip(values) {
153            out.insert(path, value_to_json(value)?);
154        }
155        to_js_object(out)
156    }
157
158    /// A snapshot of every key currently in the store, as a JS object mapping
159    /// path → Arora [`Value`].
160    pub fn snapshot(store: &dyn DataStore) -> Result<JsValue, JsValue> {
161        let state = store.snapshot();
162        let mut out = serde_json::Map::with_capacity(state.storage.len());
163        for (key, value) in state.storage {
164            out.insert(key.path, value_to_json(value)?);
165        }
166        to_js_object(out)
167    }
168
169    /// Drain the changes the subscription holds, as a JS object mapping each
170    /// changed path to its latest Arora [`Value`] (or `null` for a cleared
171    /// key). Poll-based counterpart to a push subscription:
172    /// [`DataStore::subscribe`] delivers over a std channel JavaScript cannot
173    /// await, so the channel holds every change until it is drained here. A
174    /// subscription opens on the store's whole current state, so the first
175    /// drain returns the full picture.
176    ///
177    /// [`AroraWeb`] does not leave its subscription's channel to grow between
178    /// drains: it folds it into the latest value per key on every step.
179    pub fn drain_changes(changes: &Subscription) -> Result<JsValue, JsValue> {
180        let mut latest = HashMap::new();
181        while let Some(change) = changes.try_recv() {
182            fold_change(&mut latest, change);
183        }
184        latest_to_js(latest.drain())
185    }
186
187    /// Fold `change` into `latest`, the latest value per key: a key `change`
188    /// sets holds its new value, a key it unsets holds `None`.
189    pub(crate) fn fold_change(latest: &mut HashMap<Key, Option<Value>>, change: StateChange) {
190        latest.extend(change.set);
191        latest.extend(change.unset.into_iter().map(|key| (key, None)));
192    }
193
194    /// The latest value per key as a JS object mapping path → Arora [`Value`]
195    /// (`null` for a cleared key).
196    pub(crate) fn latest_to_js(
197        latest: impl Iterator<Item = (Key, Option<Value>)>,
198    ) -> Result<JsValue, JsValue> {
199        let mut out = serde_json::Map::new();
200        for (key, value) in latest {
201            out.insert(key.path, value_to_json(value)?);
202        }
203        to_js_object(out)
204    }
205
206    /// Serialize a path-keyed JSON map to a **plain JS object**.
207    /// serde-wasm-bindgen's default representation for maps is a JS `Map`, but
208    /// these accessors promise plain objects (`{"sensor/x": {"f32": 0.5}}`),
209    /// so serialize JSON-compatibly.
210    fn to_js_object(out: serde_json::Map<String, serde_json::Value>) -> Result<JsValue, JsValue> {
211        use serde::Serialize;
212        serde_json::Value::Object(out)
213            .serialize(&serde_wasm_bindgen::Serializer::json_compatible())
214            .map_err(|e| JsValue::from_str(&format!("serialize failed: {e}")))
215    }
216
217    /// Serialize an optional Arora value to JSON (`null` when absent).
218    fn value_to_json(value: Option<Value>) -> Result<serde_json::Value, JsValue> {
219        match value {
220            Some(v) => serde_json::to_value(v)
221                .map_err(|e| JsValue::from_str(&format!("serialize value failed: {e}"))),
222            None => Ok(serde_json::Value::Null),
223        }
224    }
225}
226
227/// The store changes a page has not drained yet, as the latest value per key.
228///
229/// The subscription's channel takes a copy of every change the store applies;
230/// a device changing a few hundred keys per step at 100 steps a second fills
231/// it with tens of thousands of entries a second, all held until a drain.
232/// Folded on every step and on every write the page makes, the channel holds
233/// no more than one step's changes, and `latest` one entry per key changed
234/// since the last drain: what a page has not drained grows with the keys that
235/// change, not with the steps it goes without draining. A drain hands over the
236/// net change since the previous one — a key set twice reads its second value,
237/// a key set then unset reads cleared — which is what a page shows.
238struct Undrained {
239    subscription: Subscription,
240    latest: HashMap<Key, Option<Value>>,
241}
242
243impl Undrained {
244    fn new(subscription: Subscription) -> Self {
245        Self {
246            subscription,
247            latest: HashMap::new(),
248        }
249    }
250
251    /// Move what the subscription's channel holds into `latest`.
252    fn fold(&mut self) {
253        while let Some(change) = self.subscription.try_recv() {
254            store_json::fold_change(&mut self.latest, change);
255        }
256    }
257
258    /// Everything not drained yet, as a path → Value JS object; `latest`
259    /// keeps its capacity for the changes that follow.
260    fn drain(&mut self) -> Result<JsValue, JsValue> {
261        self.fold();
262        store_json::latest_to_js(self.latest.drain())
263    }
264}
265
266#[wasm_bindgen]
267extern "C" {
268    /// The global `queueMicrotask`, in a page, a worker and Node alike.
269    #[wasm_bindgen(js_name = queueMicrotask)]
270    fn queue_microtask(callback: &js_sys::Function);
271
272    /// The global `setTimeout`, in a page, a worker and Node alike.
273    #[wasm_bindgen(js_name = setTimeout)]
274    fn set_timeout(callback: &js_sys::Function, ms: i32);
275
276    #[wasm_bindgen(js_namespace = console, js_name = error)]
277    fn console_error(message: &str);
278}
279
280/// What the device's steps did that a waiting operation acts on, sent to every
281/// operation awaiting its reply.
282#[derive(Clone, Debug)]
283enum Stepped {
284    /// Nothing an operation waits on has happened yet.
285    Idle,
286    /// A step failed with this message, so an operation it left unanswered
287    /// rejects with it rather than waiting for a step that may fail the same
288    /// way.
289    Failed(String),
290    /// [`run`](AroraWeb::run) gave the device back. An operation its steps
291    /// left unanswered asks for a step at a zero dt, since no run step
292    /// will apply it now.
293    Parked,
294}
295
296/// Steps the device at a zero dt for what the page asked of it while no
297/// [`run`](AroraWeb::run) is ongoing.
298///
299/// The requests of one turn — the page's operations, its writes to the store,
300/// and a wrapping crate's [`request_step`](AroraWeb::request_step) — share one
301/// step, taken in the next microtask. It applies everything
302/// queued on the device's inbound channel (the page's operations in the order
303/// it issued them, and what bridges delivered), ticks the behavior with no
304/// time elapsed, and writes the HAL and the bridges what changed, the page's
305/// writes with it. A step that runs first takes the place of the requested
306/// one: a page's own [`step`](AroraWeb::step) clears the request, and while
307/// `run()` owns the device its next step applies the queue, so the request
308/// lapses.
309///
310/// A request made from inside a step cannot nest one: its step follows the
311/// current one as a task (`setTimeout`), not a microtask, so a callback that
312/// asks for one on every tick paces a chain of steps on the event loop
313/// instead of starving it.
314#[derive(Clone)]
315struct StepScheduler {
316    device: Rc<RefCell<Option<Arora>>>,
317    changes: Rc<RefCell<Undrained>>,
318    // Whether a step at a zero dt is due; a microtask or a task is queued
319    // whenever it is.
320    due: Rc<Cell<bool>>,
321    stepped: Rc<watch::Sender<Stepped>>,
322}
323
324impl StepScheduler {
325    fn new(device: Arora, changes: Rc<RefCell<Undrained>>) -> Self {
326        Self {
327            device: Rc::new(RefCell::new(Some(device))),
328            changes,
329            due: Rc::new(Cell::new(false)),
330            stepped: Rc::new(watch::channel(Stepped::Idle).0),
331        }
332    }
333
334    /// Whether a step holds the device: a page's own, or one at a zero dt.
335    fn mid_step(&self) -> bool {
336        self.device.try_borrow_mut().is_err()
337    }
338
339    /// Ask for a step at a zero dt: in the next microtask, or after the
340    /// current step when asked from inside one. Requests made before it runs
341    /// share it.
342    fn request(&self) {
343        if self.due.replace(true) {
344            return;
345        }
346        let scheduler = self.clone();
347        let callback = Closure::once_into_js(move || scheduler.step_if_due());
348        if self.mid_step() {
349            set_timeout(callback.unchecked_ref(), 0);
350        } else {
351            queue_microtask(callback.unchecked_ref());
352        }
353    }
354
355    /// A step is about to apply the queue: the request is met by it.
356    fn met(&self) {
357        self.due.set(false);
358    }
359
360    /// A step failed: what it left unanswered rejects with `message`.
361    fn failed(&self, message: String) {
362        self.stepped.send_replace(Stepped::Failed(message));
363    }
364
365    fn step_if_due(&self) {
366        if !self.due.replace(false) {
367            return;
368        }
369        let Ok(mut parked) = self.device.try_borrow_mut() else {
370            // A microtask or a task runs between steps, never inside one;
371            // ask again after this one all the same.
372            self.request();
373            return;
374        };
375        // While `run()` owns the device, the run's next step applies the
376        // queue.
377        let Some(device) = parked.as_mut() else {
378            return;
379        };
380        let stepped = device.step(Duration::ZERO);
381        drop(parked);
382        self.changes.borrow_mut().fold();
383        if let Err(e) = stepped {
384            let message = e.to_string();
385            // A page's write has no promise to reject: the console says it.
386            console_error(&format!("arora-web: a step at a zero dt failed: {message}"));
387            self.failed(message);
388        }
389    }
390}
391
392/// The browser Arora device, exported to JavaScript as `AroraRuntime`.
393///
394/// The JS constructor builds the self-contained demo device — the runtime's
395/// in-process defaults: fake hardware, no bridge, a private store, an empty
396/// behavior — and [`AroraWebBuilder`] (JS `AroraRuntimeBuilder`) additionally
397/// loads guest modules. A downstream device composes its own [`Arora`] with
398/// [`arora::AroraBuilder`] and wraps it with `AroraWeb::from`, reusing this
399/// whole JS surface.
400///
401/// Drive it by calling [`step`](Self::step) from your own clock (e.g.
402/// `requestAnimationFrame`) or by awaiting [`run`](Self::run), which hands the
403/// device to [`Arora::run`] until [`stop`](AroraWeb::stop) reclaims it.
404/// Either way the rest of the surface stays live: `setValue`/`readValues`/
405/// `snapshot` work on a sibling handle of the store, `drainChanges` on its
406/// subscription — folded into the latest value per key on every step, so what
407/// a page has not drained is one entry per changed key however many steps it
408/// goes without draining — and the client operations are the ones a remote
409/// client has over a bridge.
410///
411/// The operations that change the device — `call`, `invoke`, `spawn`, `halt` —
412/// are queued on its in-process [`LocalCaller`] before the method returns,
413/// and settle on the reply of the step that applies them:
414/// - while no `run()` is ongoing, a step at a zero dt the operation asks for:
415///   the operations issued in one turn share one such step, applied in the
416///   order they were issued, so a page that never steps the device has them
417///   applied all the same. A page that steps in the same turn applies them
418///   with its own step, and none at a zero dt follows;
419/// - while `run()` owns the device, the run's next step: the cadence is the
420///   run's alone.
421///
422/// A step at a zero dt is an ordinary step with no time elapsed: it publishes
423/// `arora/dt` as zero and ticks the behavior once, so what runs on time holds
424/// while a node that counts ticks advances one. A step that fails rejects each
425/// operation it left unanswered with its error. That operation is not
426/// withdrawn: if the step failed before applying the queue, a later step still
427/// applies it.
428///
429/// A page's writes to the store — `setValue`, `writeValues` — hold in the store
430/// when the method returns, and reach the HAL and the bridges by the same
431/// steps: a step at a zero dt they share with the turn's operations while no
432/// `run()` is ongoing, the run's next step while one is.
433///
434/// The operations that only read — `listKeys`, `describeMethods` — answer
435/// from the device's current state, with no step, in both modes: the keys
436/// from the store's sibling handle, the methods from the method index read
437/// when the device was wrapped (it changes only through `Arora`'s module
438/// methods, which this wrapper does not call). A read does not see an
439/// operation still queued, so a page awaits that operation's promise first.
440///
441/// A call into this surface from a callback the step itself invokes is an
442/// ordinary call: a step cannot nest, so an operation it issues is queued, and
443/// applied by a step at a zero dt taken as a task once the current step has
444/// ended (or by the run's next step). A write it makes is carried out by the
445/// current step, or by the next one if the current step has already flushed.
446/// A page should not rely on such calls: what a step needs done belongs in a
447/// behavior, where it is part of the step.
448///
449/// Every method is an ordinary Rust method too, for a Rust crate that wraps
450/// this device in its own `wasm_bindgen` type; [`caller`](Self::caller) hands
451/// such a crate the typed operations, [`complete`](Self::complete) has
452/// them settle as this surface's do, and [`request_step`](Self::request_step)
453/// asks for the step they would take, for a change the crate makes by
454/// another way.
455#[wasm_bindgen(js_name = AroraRuntime)]
456pub struct AroraWeb {
457    // The device and the zero-dt steps taken for the page. The device parks
458    // in the scheduler between steps; `run` takes it out and the run
459    // future puts it back.
460    steps: StepScheduler,
461    // Ends the current `run` loop at its next step boundary; `None` while no
462    // loop runs (the sender is consumed by `stop`).
463    stop: RefCell<Option<futures::channel::oneshot::Sender<()>>>,
464    caller: LocalCaller,
465    store: Box<dyn DataStore>,
466    // The device's method index, read when it was wrapped: `describeMethods`
467    // and `invoke` answer from it without a step.
468    methods: Vec<MethodSignature>,
469    // Shared with the run future, which folds the subscription after every
470    // step it takes.
471    changes: Rc<RefCell<Undrained>>,
472    // The device's standing-error watch, taken before `run` can own the
473    // device, so the reading stays available while it self-paces.
474    behavior_error: watch::Receiver<Option<String>>,
475    // The awaitable end of the same watch: taken out for the duration of one
476    // `behaviorErrorChanged` await, so sequential awaits share one seen
477    // cursor and no change is missed between them.
478    behavior_error_changes: Rc<RefCell<Option<watch::Receiver<Option<String>>>>>,
479}
480
481impl From<Arora> for AroraWeb {
482    /// Wrap a composed device: keep its in-process [`LocalCaller`], a sibling
483    /// handle of its store, its method index, and a subscription (opening on
484    /// the whole current state), then expose it all to JavaScript.
485    fn from(arora: Arora) -> Self {
486        install_panic_hook();
487        let caller = arora.caller();
488        let store = arora.store().clone_box();
489        let methods = arora.describe_methods(None);
490        let changes = Rc::new(RefCell::new(Undrained::new(store.subscribe())));
491        let behavior_error = arora.behavior_error();
492        let behavior_error_changes = Rc::new(RefCell::new(Some(arora.behavior_error())));
493        Self {
494            steps: StepScheduler::new(arora, changes.clone()),
495            stop: RefCell::new(None),
496            caller,
497            store,
498            methods,
499            changes,
500            behavior_error,
501            behavior_error_changes,
502        }
503    }
504}
505
506#[wasm_bindgen(js_class = "AroraRuntime")]
507impl AroraWeb {
508    /// Build the demo device on the runtime's in-process defaults: fake HAL,
509    /// no bridge, a private store, an empty (idle) behavior interpreter.
510    #[wasm_bindgen(constructor)]
511    pub fn new() -> Result<AroraWeb, JsValue> {
512        Ok(AroraWeb::from(Arora::builder().build().map_err(|e| {
513            JsValue::from_str(&format!("arora build failed: {e:?}"))
514        })?))
515    }
516
517    /// Advance the device one step. `dt_ms` is the milliseconds elapsed since
518    /// the previous step — a plain JS number, exactly what a
519    /// `requestAnimationFrame` timestamp delta gives. This is the only place
520    /// the rAF millisecond unit is converted to the core `Duration` dt.
521    /// It applies every operation queued so far, so none of them takes a step
522    /// at a zero dt. Unavailable once [`run`](Self::run) has taken the device,
523    /// and from a callback the step itself invokes.
524    pub fn step(&self, dt_ms: f64) -> Result<(), JsValue> {
525        let mut parked = self
526            .steps
527            .device
528            .try_borrow_mut()
529            .map_err(|_| JsValue::from_str("the device is mid-step; call between steps"))?;
530        let device = parked
531            .as_mut()
532            .ok_or_else(|| JsValue::from_str("run() drives the device; step() is unavailable"))?;
533        self.steps.met();
534        let stepped = device.step(Duration::from_secs_f64(dt_ms / 1_000.0));
535        drop(parked);
536        self.changes.borrow_mut().fold();
537        stepped.map_err(|e| {
538            // The operations this step left unanswered reject with its error
539            // rather than wait for a step that may fail the same way.
540            self.steps.failed(e.to_string());
541            JsValue::from_str(&format!("step failed: {e}"))
542        })
543    }
544
545    /// Hand the device to [`Arora::run`]: a self-paced loop at `period_ms`
546    /// (default: the runtime's ~100 Hz) that owns the device until stepping
547    /// fails — the promise rejects — or [`stop`](Self::stop) reclaims it — the
548    /// promise resolves and `step()` works again. While it runs the rest of
549    /// the surface keeps working, and the operations that change the device
550    /// apply at the run's next step, adding no step of their own.
551    ///
552    /// `run` awaits between steps, so it shares its thread; see
553    /// [`Arora::run`] for where a cadence survives in a browser (main thread
554    /// vs dedicated worker).
555    pub fn run(&self, period_ms: Option<f64>) -> js_sys::Promise {
556        let taken = match self.steps.device.try_borrow_mut() {
557            Ok(mut parked) => parked.take(),
558            Err(_) => {
559                return js_sys::Promise::reject(&JsValue::from_str(
560                    "the device is mid-step; call between steps",
561                ))
562            }
563        };
564        let Some(mut device) = taken else {
565            return js_sys::Promise::reject(&JsValue::from_str("the device is already running"));
566        };
567        let steps = self.steps.clone();
568        let changes = self.changes.clone();
569        let (stop_tx, stop_rx) = futures::channel::oneshot::channel::<()>();
570        *self.stop.borrow_mut() = Some(stop_tx);
571        wasm_bindgen_futures::future_to_promise(async move {
572            let period = period_ms
573                .map(|ms| Duration::from_secs_f64(ms / 1_000.0))
574                .unwrap_or(Arora::DEFAULT_STEP_PERIOD);
575            let outcome = {
576                let run = device.run(period);
577                futures::pin_mut!(run);
578                // Each step runs inside one poll of the loop: folding after
579                // every poll folds every step's changes.
580                let run = futures::future::poll_fn(|cx| {
581                    let polled = run.as_mut().poll(cx);
582                    changes.borrow_mut().fold();
583                    polled
584                })
585                .fuse();
586                let mut stop_rx = stop_rx.fuse();
587                futures::pin_mut!(run);
588                futures::select_biased! {
589                    result = run => Some(result),
590                    _ = stop_rx => None,
591                }
592            };
593            // Whatever ended the loop, the device parks back in the scheduler: a
594            // failed run leaves a device the embedder can still step, probe
595            // ([`Arora::behavior_error`]), or run again. An operation the
596            // run's steps left unanswered now takes a step at a zero dt.
597            steps.device.borrow_mut().replace(device);
598            steps.stepped.send_replace(Stepped::Parked);
599            match outcome {
600                // `Arora::run` only returns by failing; surface that.
601                Some(result) => {
602                    result.map_err(|e| JsValue::from_str(&format!("run failed: {e}")))?;
603                    Ok(JsValue::UNDEFINED)
604                }
605                // Stopped: the loop ended between steps.
606                None => Ok(JsValue::UNDEFINED),
607            }
608        })
609    }
610
611    /// Reclaim the device from [`run`](Self::run): the loop ends at its next
612    /// step boundary, the run promise resolves, and `step()` (or another
613    /// `run()`) works again. A no-op while nothing runs.
614    pub fn stop(&self) {
615        if let Some(stop) = self.stop.borrow_mut().take() {
616            let _ = stop.send(());
617        }
618    }
619
620    /// Whether [`run`](Self::run) currently owns the device.
621    #[wasm_bindgen(getter)]
622    pub fn running(&self) -> bool {
623        // Held mid-step by a page's own step or a zero-dt one: not running.
624        self.steps
625            .device
626            .try_borrow()
627            .is_ok_and(|parked| parked.is_none())
628    }
629
630    /// The behavior's standing error — the message of its latest failed
631    /// tick, `undefined` while the behavior is healthy or none is installed.
632    /// A failing tick does not stop the device; the reading stays available
633    /// while `run()` owns it.
634    #[wasm_bindgen(getter, js_name = behaviorError)]
635    pub fn behavior_error(&self) -> Option<String> {
636        self.behavior_error.borrow().clone()
637    }
638
639    /// Resolves on the next change of the behavior's standing error, with
640    /// the new reading: a message when a distinct failure appears,
641    /// `undefined` when a tick recovers. Sequential awaits share one cursor,
642    /// so no change is missed between them; one await may be pending at a
643    /// time. Rejects when the device is gone.
644    #[wasm_bindgen(js_name = behaviorErrorChanged)]
645    pub fn behavior_error_changed(&self) -> js_sys::Promise {
646        let Some(mut errors) = self.behavior_error_changes.borrow_mut().take() else {
647            return js_sys::Promise::reject(&JsValue::from_str(
648                "a behaviorErrorChanged await is already pending",
649            ));
650        };
651        let home = self.behavior_error_changes.clone();
652        wasm_bindgen_futures::future_to_promise(async move {
653            let outcome = errors.changed().await;
654            let value = errors.borrow_and_update().clone();
655            home.borrow_mut().replace(errors);
656            outcome.map_err(|_| JsValue::from_str("the device is gone"))?;
657            Ok(match value {
658                Some(message) => JsValue::from_str(&message),
659                None => JsValue::UNDEFINED,
660            })
661        })
662    }
663
664    /// Dispatch a [`Call`] into the device through its in-process caller.
665    /// `call_json` is an `arora_types::call::Call` as JSON; the promise
666    /// resolves to the `CallResult` as a JSON string once the call is applied
667    /// (see [`AroraWeb`] for which step applies it).
668    pub fn call(&self, call_json: &str) -> js_sys::Promise {
669        let call: Call = match serde_json::from_str(call_json) {
670            Ok(call) => call,
671            Err(e) => return reject(format!("invalid call json: {e}")),
672        };
673        let reply = self.call_now(call);
674        promise(async move {
675            let result = reply
676                .await
677                .map_err(|e| JsValue::from_str(&format!("call failed: {e}")))?;
678            let json = serde_json::to_string(&result)
679                .map_err(|e| JsValue::from_str(&format!("serialize failed: {e}")))?;
680            Ok(JsValue::from_str(&json))
681        })
682    }
683
684    /// Call the device's method named `method` with arguments by parameter
685    /// name, as a remote client invokes it over a bridge.
686    ///
687    /// `args_json` is a JSON object mapping each parameter name to an Arora
688    /// [`Value`] in the JSON form `call` takes, e.g. `{"x": {"f64": 2.5}}`. The
689    /// promise resolves to the return value, as a plain JS value of that JSON
690    /// form. A task-shaped method — one returning the behavior `Status` — is
691    /// spawned as a run instead, and resolves to the run's handle, the plain
692    /// object [`spawn`](Self::spawn) answers with.
693    ///
694    /// Names are the bare names modules export, so two modules may share one:
695    /// `module_id` (a uuid string) names the exporting module to choose. A name
696    /// more than one module exports, without `module_id`, rejects naming those
697    /// modules. An argument the method does not declare rejects too.
698    ///
699    /// The method's signature is read at once, from the device's method
700    /// index; its call is applied as `call`'s is.
701    pub fn invoke(
702        &self,
703        method: &str,
704        args_json: &str,
705        module_id: Option<String>,
706    ) -> js_sys::Promise {
707        let failed = |e: String| reject(format!("invoke {method} failed: {e}"));
708        let args: HashMap<String, Value> = match serde_json::from_str(args_json) {
709            Ok(args) => args,
710            Err(e) => return reject(format!("invalid args json: {e}")),
711        };
712        let module = match module_id.map(|id| Uuid::parse_str(&id).map_err(|e| (id, e))) {
713            None => None,
714            Some(Ok(module)) => Some(module),
715            Some(Err((id, e))) => return reject(format!("'{id}' is not a module id: {e}")),
716        };
717        let signature = match client::find_method(&self.methods, method, module) {
718            Ok(signature) => signature,
719            Err(e) => return failed(e),
720        };
721        let call = match client::call_of(signature, args) {
722            Ok(call) => call,
723            Err(e) => return failed(e),
724        };
725        let method = method.to_string();
726        let failed = move |e: CallError| JsValue::from_str(&format!("invoke {method} failed: {e}"));
727        if client::task_shaped(&signature.function) {
728            let caller = self.caller.clone();
729            let started = self.complete(async move { caller.spawn(call).await });
730            promise(async move { run_to_js(&started.await.map_err(failed)?) })
731        } else {
732            let returned = self.call_now(call);
733            promise(async move { value_to_js(&returned.await.map_err(failed)?.ret) })
734        }
735    }
736
737    /// Start `call_json` (an `arora_types::call::Call` as JSON) as a task run,
738    /// concurrently with every other run. The promise resolves to the run's
739    /// handle, a plain object `{run, status, feedback, result, update}`: `run`
740    /// the run id (a uuid string, which [`halt`](Self::halt) takes), `status`
741    /// the path of the key that reports how the run goes and ends, and
742    /// `feedback`, `result` and `update` the paths of the keys carrying its
743    /// progress, its result, and what steers it. It resolves once the spawn is
744    /// applied, as `call`'s does.
745    pub fn spawn(&self, call_json: &str) -> js_sys::Promise {
746        let call: Call = match serde_json::from_str(call_json) {
747            Ok(call) => call,
748            Err(e) => return reject(format!("invalid call json: {e}")),
749        };
750        let caller = self.caller.clone();
751        let started = self.complete(async move { caller.spawn(call).await });
752        promise(async move {
753            let handle = started
754                .await
755                .map_err(|e| JsValue::from_str(&format!("spawn failed: {e}")))?;
756            run_to_js(&handle)
757        })
758    }
759
760    /// Stop the run `run_id` names (the `run` of its handle, a uuid string):
761    /// it ends `Failure` on the step after the halt is applied. Idempotent —
762    /// halting a finished or unknown run resolves all the same. It resolves
763    /// once the halt is applied, as `call`'s does.
764    pub fn halt(&self, run_id: &str) -> js_sys::Promise {
765        let run = match Uuid::parse_str(run_id) {
766            Ok(run) => TaskId(run),
767            Err(e) => return reject(format!("'{run_id}' is not a run id: {e}")),
768        };
769        let caller = self.caller.clone();
770        let halted = self.complete(async move { caller.halt(run).await });
771        promise(async move {
772            halted
773                .await
774                .map_err(|e| JsValue::from_str(&format!("halt failed: {e}")))?;
775            Ok(JsValue::UNDEFINED)
776        })
777    }
778
779    /// The device's keys whose path starts with `prefix` (all of them when
780    /// absent), sorted by path. The promise resolves to an array of
781    /// `{path, __meta}` objects: each key with what its store says it is — the
782    /// shape it holds, its range and unit, where it rests, whether a client may
783    /// write it — as every Arora bridge lists keys. It answers from the store
784    /// as it is, with no step, and resolves at once.
785    #[wasm_bindgen(js_name = listKeys)]
786    pub fn list_keys(&self, prefix: Option<String>) -> js_sys::Promise {
787        let keys: Vec<KeyInfo> = arora::list_keys(&*self.store, prefix.as_deref())
788            .into_iter()
789            .map(|(path, meta)| KeyInfo { path, meta })
790            .collect();
791        settled(to_js(&keys))
792    }
793
794    /// The device's callable methods whose name starts with `prefix` (all of
795    /// them when absent), sorted by name. The promise resolves to an array of
796    /// method descriptions, as a bridge lists methods — `path` (the name),
797    /// `params` (each `name`, `param_type`, `required`, in declaration order),
798    /// `return_type`, `task` (whether invoking it starts a run) — plus `module`,
799    /// the exporting module's id, which [`invoke`](Self::invoke) takes when two
800    /// modules export one name. It answers from the device's method index,
801    /// with no step, and resolves at once.
802    #[wasm_bindgen(js_name = describeMethods)]
803    pub fn describe_methods(&self, prefix: Option<String>) -> js_sys::Promise {
804        let described = self
805            .methods
806            .iter()
807            .filter(|signature| {
808                prefix
809                    .as_deref()
810                    .is_none_or(|prefix| signature.name.starts_with(prefix))
811            })
812            .map(|signature| {
813                let mut method = serde_json::to_value(client::method_info(signature))
814                    .map_err(|e| JsValue::from_str(&format!("serialize failed: {e}")))?;
815                method["module"] = serde_json::Value::String(signature.module_id.to_string());
816                Ok(method)
817            })
818            .collect::<Result<Vec<_>, JsValue>>();
819        settled(described.and_then(|methods| to_js(&methods)))
820    }
821
822    /// Write one key into the store (Arora [`Value`] as JSON, e.g. `{"f32": 1}`).
823    /// The store holds it when this returns; the step that carries it to the
824    /// HAL and the bridges is the one a client operation takes (see
825    /// [`AroraWeb`]).
826    #[wasm_bindgen(js_name = setValue)]
827    pub fn set_value(&self, path: &str, value_json: &str) -> Result<(), JsValue> {
828        let written = store_json::set_value(&*self.store, path, value_json);
829        self.wrote(written)
830    }
831
832    /// Write several keys at once, as one store change (a JSON object mapping
833    /// each key path to an Arora [`Value`]). The store holds them when this
834    /// returns, and a step carries them out as `setValue`'s.
835    #[wasm_bindgen(js_name = writeValues)]
836    pub fn write_values(&self, values_json: &str) -> Result<(), JsValue> {
837        let written = store_json::write_values(&*self.store, values_json);
838        self.wrote(written)
839    }
840
841    /// Read keys from the store; `paths` is a `string[]`, result maps path→Value.
842    #[wasm_bindgen(js_name = readValues)]
843    pub fn read_values(&self, paths: JsValue) -> Result<JsValue, JsValue> {
844        store_json::read_values(&*self.store, paths)
845    }
846
847    /// A snapshot of every key in the store as a path→Value object.
848    pub fn snapshot(&self) -> Result<JsValue, JsValue> {
849        store_json::snapshot(&*self.store)
850    }
851
852    /// Drain the keys that changed since the last call, as a path→Value object
853    /// holding each key's latest value (`null` for a cleared key): the net
854    /// change since the previous drain. The first drain returns the store's
855    /// whole current state — the subscription opens on it. What is not
856    /// drained is held as one value per key, however long the page goes
857    /// without draining.
858    #[wasm_bindgen(js_name = drainChanges)]
859    pub fn drain_changes(&self) -> Result<JsValue, JsValue> {
860        self.changes.borrow_mut().drain()
861    }
862}
863
864impl AroraWeb {
865    /// The device's in-process caller: the typed operations behind `call`,
866    /// `invoke`, `spawn` and `halt`, for a Rust crate that wraps this device
867    /// and wants them unserialized. An operation through it is a remote's: it
868    /// applies at the device's next step. Hand its reply to
869    /// [`complete`](Self::complete) to have it settle as this surface's
870    /// operations do.
871    pub fn caller(&self) -> LocalCaller {
872        self.caller.clone()
873    }
874
875    /// Settle `reply`, the reply of an operation that changes the device, as
876    /// this surface's own `call`, `invoke`, `spawn` and `halt` settle: applied
877    /// by a step at a zero dt while no [`run`](Self::run) is ongoing — the
878    /// operations issued in one turn sharing one — and by the run's next step
879    /// while one is.
880    ///
881    /// `reply` is polled once before this returns, so an operation that
882    /// queues itself at its future's first poll (a [`LocalCaller`] call
883    /// wrapped in an `async` block) is queued by then, behind those issued
884    /// before it. A reply that is already there settles at once. A step that
885    /// fails rejects every operation it left unanswered with its error,
886    /// without withdrawing it (see [`AroraWeb`]). A read-only operation has nothing to apply: answer it from the
887    /// device's state instead (`listKeys`, `describeMethods`).
888    pub fn complete<V: 'static>(
889        &self,
890        reply: impl Future<Output = Result<V, CallError>> + 'static,
891    ) -> impl Future<Output = Result<V, CallError>> + 'static {
892        let mut reply = Box::pin(reply);
893        let first = reply.as_mut().poll(&mut std::task::Context::from_waker(
894            futures::task::noop_waker_ref(),
895        ));
896        let mut stepped = self.steps.stepped.subscribe();
897        let steps = self.steps.clone();
898        if first.is_pending() {
899            steps.request();
900        }
901        async move {
902            if let Poll::Ready(answer) = first {
903                return answer;
904            }
905            loop {
906                {
907                    // The sender lives in `steps`, which this future holds:
908                    // the channel stays open while it waits.
909                    let changed = std::pin::pin!(stepped.changed());
910                    if let Either::Left((answer, _)) =
911                        futures::future::select(reply.as_mut(), changed).await
912                    {
913                        return answer;
914                    }
915                }
916                let event = stepped.borrow_and_update().clone();
917                match event {
918                    Stepped::Idle => {}
919                    Stepped::Failed(message) => {
920                        return match reply.as_mut().now_or_never() {
921                            Some(answer) => answer,
922                            None => Err(CallError::Generic {
923                                message: format!("step failed: {message}"),
924                            }),
925                        }
926                    }
927                    Stepped::Parked => steps.request(),
928                }
929            }
930        }
931    }
932
933    /// Ask for a step at a zero dt, as this surface's operations that change
934    /// the device do: for a change a wrapping crate makes to the device by
935    /// another way than its caller or its store — a setting its behavior
936    /// reads — that the device should act on whether or not the page steps
937    /// it.
938    ///
939    /// The step is the one the turn's operations and store writes share,
940    /// taken in the next microtask, and a page's own [`step`](Self::step) in
941    /// the same turn takes its place. Asked from inside a step, it is taken
942    /// as a task once that step has ended. While [`run`](Self::run) owns the
943    /// device, the request lapses: the run's next step acts on the change.
944    pub fn request_step(&self) {
945        self.steps.request();
946    }
947
948    /// After a page's write to the store: fold it into what the page has not
949    /// drained, and have a step carry it to the HAL and the bridges — a step
950    /// at a zero dt while no run is ongoing, the run's next step while one is.
951    ///
952    /// A write from inside a step asks for none: the step that holds the
953    /// device flushes it, or, written once that step has flushed, the next
954    /// step does.
955    fn wrote(&self, written: Result<(), JsValue>) -> Result<(), JsValue> {
956        self.changes.borrow_mut().fold();
957        if written.is_ok() && !self.steps.mid_step() {
958            self.steps.request();
959        }
960        written
961    }
962
963    /// Queue `call` and settle its reply as [`complete`](Self::complete) does.
964    fn call_now(
965        &self,
966        call: Call,
967    ) -> impl Future<Output = Result<arora_types::call::CallResult, CallError>> + 'static {
968        let caller = self.caller.clone();
969        self.complete(async move { caller.call(call).await })
970    }
971}
972
973/// A promise rejected with `message`.
974fn reject(message: String) -> js_sys::Promise {
975    js_sys::Promise::reject(&JsValue::from_str(&message))
976}
977
978/// A promise already settled with `answer`.
979fn settled(answer: Result<JsValue, JsValue>) -> js_sys::Promise {
980    match answer {
981        Ok(value) => js_sys::Promise::resolve(&value),
982        Err(error) => js_sys::Promise::reject(&error),
983    }
984}
985
986/// Assemble a browser device from what the JS world can supply — guest wasm
987/// modules and the records they are described by, over the demo defaults;
988/// exported to JavaScript as `AroraRuntimeBuilder`. A device that needs a
989/// real HAL, bridge, store, or behavior interpreter is composed in Rust with
990/// [`arora::AroraBuilder`] and wrapped with `AroraWeb::from` instead: those
991/// seams are trait objects that cannot cross the JS boundary.
992#[wasm_bindgen(js_name = AroraRuntimeBuilder)]
993#[derive(Default)]
994pub struct AroraWebBuilder {
995    inner: AroraBuilder,
996    records: arora::LocalRegistry,
997}
998
999#[wasm_bindgen(js_class = "AroraRuntimeBuilder")]
1000impl AroraWebBuilder {
1001    #[wasm_bindgen(constructor)]
1002    pub fn new() -> AroraWebBuilder {
1003        AroraWebBuilder::default()
1004    }
1005
1006    /// Load a guest module into the device's engine — in the browser, a
1007    /// `.wasm` run by the native `WebAssembly` host. `header_json` is the
1008    /// module's low-level header as JSON; `executable` its wasm bytes.
1009    /// Repeatable — each call loads one module.
1010    #[wasm_bindgen(js_name = withModule)]
1011    pub fn with_module(&mut self, header_json: &str, executable: &[u8]) -> Result<(), JsValue> {
1012        let header: Header = serde_json::from_str(header_json)
1013            .map_err(|e| JsValue::from_str(&format!("invalid header json: {e}")))?;
1014        let inner = std::mem::take(&mut self.inner);
1015        self.inner = inner.with_module(header, executable.to_vec());
1016        Ok(())
1017    }
1018
1019    /// Load a guest module compiled ahead into the device's engine, as
1020    /// `withModule` loads one from its bytes. The device instantiates the
1021    /// compiled `WebAssembly.Module` instead of compiling the bytes, so devices
1022    /// built from one `CompiledModule` compile it once between them.
1023    /// Repeatable — each call loads one module.
1024    #[wasm_bindgen(js_name = withCompiledModule)]
1025    pub fn with_compiled_module(&mut self, module: &CompiledModule) {
1026        let inner = std::mem::take(&mut self.inner);
1027        self.inner = inner.with_compiled_module(&module.inner);
1028    }
1029
1030    /// Add the records guest modules are described by: a module's
1031    /// `records/` directory as its package ships it — an object mapping each
1032    /// record file's path under `records/` (`module/<id>@<version>.yaml`,
1033    /// `structure/…`, `enumeration/…`, `folder/<id>.yaml`) to its YAML.
1034    /// Repeatable: the records of several modules fill one registry, a
1035    /// record they share held once.
1036    ///
1037    /// A guest module whose record the device holds has every export
1038    /// described — one taking or returning a structure included — and the
1039    /// build fails when that record does not match the module's header or
1040    /// lacks a record it depends on (`AroraBuilder::with_registry`). Fails
1041    /// here on a path outside that layout, a record that does not read, or a
1042    /// record another call gave differently under the same id and version;
1043    /// the records read before a failure stay, so a page builds a new
1044    /// builder rather than retrying on this one.
1045    #[wasm_bindgen(js_name = withRecords)]
1046    pub fn with_records(
1047        &mut self,
1048        #[wasm_bindgen(unchecked_param_type = "Record<string, string>")] records: JsValue,
1049    ) -> Result<(), JsValue> {
1050        use arora_registry::local_yaml::{load_records, RecordKind};
1051        use futures::FutureExt;
1052
1053        let files: std::collections::BTreeMap<String, String> =
1054            serde_wasm_bindgen::from_value(records).map_err(|e| {
1055                JsValue::from_str(&format!(
1056                    "records must map each record file's path to its YAML: {e}"
1057                ))
1058            })?;
1059        let mut entries = Vec::with_capacity(files.len());
1060        for (path, yaml) in &files {
1061            let record = path.split_once('/').and_then(|(directory, file)| {
1062                Some((
1063                    RecordKind::from_directory(directory)?,
1064                    file.strip_suffix(".yaml")?,
1065                ))
1066            });
1067            let Some((kind, stem)) = record else {
1068                return Err(JsValue::from_str(&format!(
1069                    "'{path}' is not a record file's path: <folder|enumeration|structure|module>/<id>[@<version>].yaml"
1070                )));
1071            };
1072            entries.push((kind, stem, yaml.clone()));
1073        }
1074        load_records(entries, &mut self.records)
1075            .now_or_never()
1076            .ok_or_else(|| JsValue::from_str("the records did not load synchronously"))?
1077            .map_err(|e| JsValue::from_str(&format!("the records do not load: {e}")))
1078    }
1079
1080    /// Build the device. The builder resets to its defaults, ready to
1081    /// assemble another.
1082    pub fn build(&mut self) -> Result<AroraWeb, JsValue> {
1083        let arora = std::mem::take(&mut self.inner)
1084            .with_registry(std::mem::take(&mut self.records))
1085            .build()
1086            .map_err(|e| JsValue::from_str(&format!("arora build failed: {e:?}")))?;
1087        Ok(AroraWeb::from(arora))
1088    }
1089}
1090
1091/// A guest module compiled once, to load into any number of devices with
1092/// `AroraRuntimeBuilder.withCompiledModule`: each device instantiates it —
1093/// its own memory and state — without compiling it again.
1094#[wasm_bindgen]
1095pub struct CompiledModule {
1096    inner: arora::CompiledModule,
1097}
1098
1099#[wasm_bindgen]
1100impl CompiledModule {
1101    /// Compile a guest module. `header_json` is the module's low-level header
1102    /// as JSON; `executable` its wasm bytes. Compiles synchronously
1103    /// (`new WebAssembly.Module`), which Chrome rejects above 8 MB on the main
1104    /// thread. Fails on an invalid header or an executable that does not
1105    /// compile.
1106    #[wasm_bindgen(constructor)]
1107    pub fn new(header_json: &str, executable: &[u8]) -> Result<CompiledModule, JsValue> {
1108        install_panic_hook();
1109        let header: Header = serde_json::from_str(header_json)
1110            .map_err(|e| JsValue::from_str(&format!("invalid header json: {e}")))?;
1111        let inner = arora::CompiledModule::new(header, executable)
1112            .map_err(|e| JsValue::from_str(&format!("module does not compile: {e}")))?;
1113        Ok(CompiledModule { inner })
1114    }
1115}
1116
1117/// JS-callable handle to a configured Arora engine.
1118#[wasm_bindgen]
1119pub struct Engine {
1120    inner: std::pin::Pin<Box<arora_engine::engine::Engine>>,
1121    loader: SharedLoaderRc,
1122    function_module: HashMap<Uuid, Uuid>,
1123    module_headers: HashMap<Uuid, String>,
1124}
1125
1126#[wasm_bindgen]
1127impl Engine {
1128    #[wasm_bindgen(constructor)]
1129    pub fn new() -> Engine {
1130        install_panic_hook();
1131        let executor = BrowserExecutor::new();
1132        let loader = executor.shared();
1133        let inner = EngineBuilder::new().add_executor(executor).build();
1134        Engine {
1135            inner,
1136            loader,
1137            function_module: HashMap::new(),
1138            module_headers: HashMap::new(),
1139        }
1140    }
1141
1142    /// Load a module given its header (as JSON) and executable bytes.
1143    /// Returns the module's UUID as a string.
1144    ///
1145    /// Compiles and instantiates synchronously: Chrome rejects both above
1146    /// 8 MB on the main thread — use `prepareModule` + `loadPreparedModule`
1147    /// for large executables.
1148    #[wasm_bindgen(js_name = loadModule)]
1149    pub fn load_module(&mut self, header_json: &str, executable: &[u8]) -> Result<String, JsValue> {
1150        self.load_module_inner(header_json, executable.to_vec().into_boxed_slice())
1151    }
1152
1153    /// Asynchronously compile and instantiate a module's executable
1154    /// (via `WebAssembly.instantiate`, no main-thread size limit).
1155    /// Follow up with `loadPreparedModule` to complete the load.
1156    #[wasm_bindgen(js_name = prepareModule)]
1157    pub fn prepare_module(&self, header_json: &str, executable: Vec<u8>) -> js_sys::Promise {
1158        prepare_module_impl(self.loader.clone(), header_json, executable)
1159    }
1160
1161    /// Load a module whose executable was staged by `prepareModule`.
1162    /// Returns the module's UUID as a string.
1163    #[wasm_bindgen(js_name = loadPreparedModule)]
1164    pub fn load_prepared_module(&mut self, header_json: &str) -> Result<String, JsValue> {
1165        self.load_module_inner(header_json, Box::new([]))
1166    }
1167
1168    fn load_module_inner(
1169        &mut self,
1170        header_json: &str,
1171        executable: Box<[u8]>,
1172    ) -> Result<String, JsValue> {
1173        let header: Header = serde_json::from_str(header_json)
1174            .map_err(|e| JsValue::from_str(&format!("invalid header json: {e}")))?;
1175        let header_json_str = header_json.to_string();
1176        let loaded = load_module_from_parts(&mut self.inner, header, executable)
1177            .map_err(|e| JsValue::from_str(&format!("load_module failed: {e}")))?;
1178        for fn_id in &loaded.function_ids {
1179            self.function_module.insert(*fn_id, loaded.id);
1180        }
1181        self.module_headers.insert(loaded.id, header_json_str);
1182        Ok(loaded.id.to_string())
1183    }
1184
1185    /// Returns a JSON array of all loaded module headers.
1186    #[wasm_bindgen(js_name = listModules)]
1187    pub fn list_modules(&self) -> String {
1188        let headers: Vec<serde_json::Value> = self
1189            .module_headers
1190            .values()
1191            .filter_map(|s| serde_json::from_str(s).ok())
1192            .collect();
1193        serde_json::to_string(&headers).unwrap_or_else(|_| "[]".to_string())
1194    }
1195
1196    /// Call a function. `call_json` is a JSON document matching
1197    /// `arora_engine::call::Call`. Returns the result as a JSON string.
1198    #[wasm_bindgen]
1199    pub fn call(&mut self, call_json: &str) -> Result<String, JsValue> {
1200        let mut call: Call = serde_json::from_str(call_json)
1201            .map_err(|e| JsValue::from_str(&format!("invalid call json: {e}")))?;
1202        if call.module_id.is_none() {
1203            // Resolve locally: the module the function was loaded with.
1204            call.module_id = Some(
1205                *self
1206                    .function_module
1207                    .get(&call.id)
1208                    .ok_or_else(|| JsValue::from_str("no module known for function"))?,
1209            );
1210        }
1211        let result = self
1212            .inner
1213            .arora_call(call)
1214            .map_err(|e| JsValue::from_str(&format!("call failed: {e}")))?;
1215        serde_json::to_string(&result)
1216            .map_err(|e| JsValue::from_str(&format!("serialize failed: {e}")))
1217    }
1218}
1219
1220impl Default for Engine {
1221    fn default() -> Self {
1222        Self::new()
1223    }
1224}
1225
1226/// Shared implementation of `prepareModule`: parses the header for the
1227/// module id, then stages an asynchronously-instantiated module in the
1228/// loader. Returns a `Promise<void>`.
1229fn prepare_module_impl(
1230    loader: SharedLoaderRc,
1231    header_json: &str,
1232    executable: Vec<u8>,
1233) -> js_sys::Promise {
1234    let header: Result<Header, _> = serde_json::from_str(header_json);
1235    wasm_bindgen_futures::future_to_promise(async move {
1236        let header = header.map_err(|e| JsValue::from_str(&format!("invalid header json: {e}")))?;
1237        loader.prepare(header.id, executable).await?;
1238        Ok(JsValue::UNDEFINED)
1239    })
1240}
1241
1242// =============================================================================
1243// Behavior-tree runner
1244//
1245// A self-contained BT runtime built directly on arora's engine primitives —
1246// no dependency on the arora-behavior-tree crate.
1247// =============================================================================
1248
1249// UUID constants from arora-behavior-tree-types generated code.
1250// TickId struct id: 6f49e650-84ca-4899-a9bd-1f3bf17fab51
1251const TICK_ID_STRUCT_BYTES: [u8; 16] = [
1252    0x6f, 0x49, 0xe6, 0x50, 0x84, 0xca, 0x48, 0x99, 0xa9, 0xbd, 0x1f, 0x3b, 0xf1, 0x7f, 0xab, 0x51,
1253];
1254// TickId::callable_id field: 237992d2-17d1-459f-bca1-7185fa6a69d7
1255const TICK_ID_CALLABLE_FIELD_BYTES: [u8; 16] = [
1256    0x23, 0x79, 0x92, 0xd2, 0x17, 0xd1, 0x45, 0x9f, 0xbc, 0xa1, 0x71, 0x85, 0xfa, 0x6a, 0x69, 0xd7,
1257];
1258// Status::Success variant: 766e9e9a-446d-4e46-83e6-14b7ca101169
1259const STATUS_SUCCESS_BYTES: [u8; 16] = [
1260    0x76, 0x6e, 0x9e, 0x9a, 0x44, 0x6d, 0x4e, 0x46, 0x83, 0xe6, 0x14, 0xb7, 0xca, 0x10, 0x11, 0x69,
1261];
1262// Status::Failure variant: 2468f46c-bb60-425c-9a4d-9ad326ccc7e2
1263const STATUS_FAILURE_BYTES: [u8; 16] = [
1264    0x24, 0x68, 0xf4, 0x6c, 0xbb, 0x60, 0x42, 0x5c, 0x9a, 0x4d, 0x9a, 0xd3, 0x26, 0xcc, 0xc7, 0xe2,
1265];
1266// Status enum type: 325a5767-e344-4532-860e-0749bcf2e428
1267const STATUS_ENUM_BYTES: [u8; 16] = [
1268    0x32, 0x5a, 0x57, 0x67, 0xe3, 0x44, 0x45, 0x32, 0x86, 0x0e, 0x07, 0x49, 0xbc, 0xf2, 0xe4, 0x28,
1269];
1270// _ret special out-parameter: 5f726574-0000-4000-8000-000000000000 ("_ret" in ASCII)
1271const RET_PARAM_BYTES: [u8; 16] = [
1272    0x5f, 0x72, 0x65, 0x74, 0x00, 0x00, 0x40, 0x00, 0x80, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
1273];
1274
1275fn value_to_status(v: &Value) -> &'static str {
1276    if let Value::Enumeration(e) = v {
1277        if *e.variant_id.as_bytes() == STATUS_SUCCESS_BYTES {
1278            return "success";
1279        }
1280        if *e.variant_id.as_bytes() == STATUS_FAILURE_BYTES {
1281            return "failure";
1282        }
1283    }
1284    "running"
1285}
1286
1287/// A node argument expression: either a literal value or a reference to a
1288/// named variable (identified by UUID).
1289#[derive(serde::Deserialize, Clone, Debug)]
1290#[serde(rename_all = "snake_case")]
1291enum BtExpression {
1292    VariableId(Uuid),
1293    Value(Value),
1294}
1295
1296/// A single BT node – mirrors the arora-behavior-tree YAML schema.
1297#[derive(serde::Deserialize, Debug)]
1298struct BtNode {
1299    id: Uuid,
1300    function: Uuid,
1301    #[serde(default)]
1302    children: Option<Vec<Uuid>>,
1303    /// Parameter arguments: maps parameter UUID to a literal value or variable.
1304    /// Use the special key `5f726574-0000-4000-8000-000000000000` (_ret) to
1305    /// capture the return value into a variable; the node then always succeeds.
1306    #[serde(default)]
1307    arguments: HashMap<Uuid, BtExpression>,
1308}
1309
1310/// Metadata extracted from a module header for one exported function.
1311struct FnMeta {
1312    module_id: Uuid,
1313    /// Parameter ID of the `children: TickId[]` argument, present only for
1314    /// control nodes (seq, fallback, parallel …).
1315    children_param_id: Option<Uuid>,
1316}
1317
1318/// An arora Callable that wraps one BT node.
1319struct NodeCallable {
1320    node_id: Uuid,
1321    fn_id: Uuid,
1322    module_id: Uuid,
1323    children_param_id: Option<Uuid>,
1324    children_callable_ids: Vec<u64>,
1325    arguments: HashMap<Uuid, BtExpression>,
1326    variables: Rc<RefCell<HashMap<Uuid, Value>>>,
1327    trace: Rc<RefCell<Vec<(Uuid, &'static str)>>>,
1328}
1329
1330impl Callable for NodeCallable {
1331    fn call(&self, caller: &mut dyn CallBridge) -> Result<Value, arora_engine::call::CallError> {
1332        let tick_id_type = Uuid::from_bytes(TICK_ID_STRUCT_BYTES);
1333        let callable_field = Uuid::from_bytes(TICK_ID_CALLABLE_FIELD_BYTES);
1334        let ret_param_id = Uuid::from_bytes(RET_PARAM_BYTES);
1335
1336        let mut args = Vec::new();
1337
1338        if let Some(children_param_id) = self.children_param_id {
1339            let elements: Vec<StructureWithoutId> = self
1340                .children_callable_ids
1341                .iter()
1342                .map(|&id| StructureWithoutId {
1343                    fields: vec![StructureField {
1344                        id: callable_field,
1345                        value: Box::new(Value::U64(id)),
1346                    }],
1347                })
1348                .collect();
1349            args.push(StructureField {
1350                id: children_param_id,
1351                value: Box::new(Value::ArrayStructure {
1352                    id: tick_id_type,
1353                    elements,
1354                }),
1355            });
1356        }
1357
1358        for (&param_id, expr) in &self.arguments {
1359            if param_id == ret_param_id {
1360                continue;
1361            }
1362            let value = match expr {
1363                BtExpression::Value(v) => v.clone(),
1364                BtExpression::VariableId(var_id) => self
1365                    .variables
1366                    .borrow()
1367                    .get(var_id)
1368                    .cloned()
1369                    .unwrap_or(Value::Unit),
1370            };
1371            args.push(StructureField {
1372                id: param_id,
1373                value: Box::new(value),
1374            });
1375        }
1376
1377        // Build a map of param_id -> variable_id for mutable arguments so we can
1378        // write mutated values back to the variable store after the call.
1379        let mutable_param_vars: HashMap<Uuid, Uuid> = self
1380            .arguments
1381            .iter()
1382            .filter_map(|(&param_id, expr)| {
1383                if param_id == ret_param_id {
1384                    return None;
1385                }
1386                if let BtExpression::VariableId(var_id) = expr {
1387                    Some((param_id, *var_id))
1388                } else {
1389                    None
1390                }
1391            })
1392            .collect();
1393
1394        let result = caller.arora_call(Call {
1395            module_id: Some(self.module_id),
1396            id: self.fn_id,
1397            args,
1398        })?;
1399
1400        // Write back mutated parameter values to bound variables.
1401        for mutated in &result.mutated {
1402            if let Some(&var_id) = mutable_param_vars.get(&mutated.id) {
1403                self.variables
1404                    .borrow_mut()
1405                    .insert(var_id, *mutated.value.clone());
1406            }
1407        }
1408
1409        let has_ret = self.arguments.contains_key(&ret_param_id);
1410        if has_ret {
1411            if let Some(BtExpression::VariableId(var_id)) = self.arguments.get(&ret_param_id) {
1412                self.variables
1413                    .borrow_mut()
1414                    .insert(*var_id, result.ret.clone());
1415            }
1416        }
1417
1418        let s = if has_ret {
1419            "success"
1420        } else {
1421            value_to_status(&result.ret)
1422        };
1423        self.trace.borrow_mut().push((self.node_id, s));
1424
1425        if has_ret {
1426            Ok(Value::Enumeration(Enumeration {
1427                id: Uuid::from_bytes(STATUS_ENUM_BYTES),
1428                variant_id: Uuid::from_bytes(STATUS_SUCCESS_BYTES),
1429                value: Box::new(Value::Unit),
1430            }))
1431        } else {
1432            Ok(result.ret)
1433        }
1434    }
1435}
1436
1437/// Recursively registers callables for `node_id` and all descendants.
1438/// Returns the callable id of the registered root callable.
1439fn register_node(
1440    engine: &mut dyn CallBridge,
1441    node_id: Uuid,
1442    node_index: &HashMap<Uuid, BtNode>,
1443    fn_meta: &HashMap<Uuid, FnMeta>,
1444    trace: &Rc<RefCell<Vec<(Uuid, &'static str)>>>,
1445    variables: &Rc<RefCell<HashMap<Uuid, Value>>>,
1446) -> Result<u64, String> {
1447    let node = node_index
1448        .get(&node_id)
1449        .ok_or_else(|| format!("node {node_id} not found in tree"))?;
1450    let meta = fn_meta
1451        .get(&node.function)
1452        .ok_or_else(|| format!("function {} not registered in fn_meta", node.function))?;
1453
1454    let children_callable_ids = match &node.children {
1455        None => vec![],
1456        Some(ids) => ids
1457            .iter()
1458            .map(|&child_id| register_node(engine, child_id, node_index, fn_meta, trace, variables))
1459            .collect::<Result<Vec<_>, _>>()?,
1460    };
1461
1462    let callable: Rc<dyn Callable> = Rc::new(NodeCallable {
1463        node_id,
1464        fn_id: node.function,
1465        module_id: meta.module_id,
1466        children_param_id: meta.children_param_id,
1467        children_callable_ids,
1468        arguments: node.arguments.clone(),
1469        variables: variables.clone(),
1470        trace: trace.clone(),
1471    });
1472    let id = engine.arora_register_callable(callable);
1473    Ok(id.id)
1474}
1475
1476/// JS-callable handle for loading modules and executing behavior trees.
1477///
1478/// Usage:
1479/// 1. `new BehaviorTreeRunner()`
1480/// 2. `runner.loadModule(headerJson, wasmBytes)` – can be called for multiple modules
1481/// 3. `runner.run(nodesJson)` – returns `{status, trace}`
1482/// 4. Or `runner.setVariable(varId, valueJson)` + `runner.tick(nodesJson)` for
1483///    stateful tick-by-tick execution with variable bindings.
1484#[wasm_bindgen]
1485pub struct BehaviorTreeRunner {
1486    inner: Pin<Box<arora_engine::engine::Engine>>,
1487    loader: SharedLoaderRc,
1488    fn_meta: HashMap<Uuid, FnMeta>,
1489    variables: Rc<RefCell<HashMap<Uuid, Value>>>,
1490    module_headers: HashMap<Uuid, String>,
1491}
1492
1493#[wasm_bindgen]
1494impl BehaviorTreeRunner {
1495    #[wasm_bindgen(constructor)]
1496    pub fn new() -> BehaviorTreeRunner {
1497        install_panic_hook();
1498        let executor = BrowserExecutor::new();
1499        let loader = executor.shared();
1500        let inner = EngineBuilder::new().add_executor(executor).build();
1501        BehaviorTreeRunner {
1502            inner,
1503            loader,
1504            fn_meta: HashMap::new(),
1505            variables: Rc::new(RefCell::new(HashMap::new())),
1506            module_headers: HashMap::new(),
1507        }
1508    }
1509
1510    /// Load a WASM module. `header_json` must be the module's YAML header
1511    /// converted to JSON (the JS side can use js-yaml for that).
1512    /// Returns the module UUID string.
1513    ///
1514    /// Compiles and instantiates synchronously: Chrome rejects both above
1515    /// 8 MB on the main thread — use `prepareModule` + `loadPreparedModule`
1516    /// for large executables.
1517    #[wasm_bindgen(js_name = loadModule)]
1518    pub fn load_module(&mut self, header_json: &str, executable: &[u8]) -> Result<String, JsValue> {
1519        self.load_module_inner(header_json, executable.to_vec().into_boxed_slice())
1520    }
1521
1522    /// Asynchronously compile and instantiate a module's executable
1523    /// (via `WebAssembly.instantiate`, no main-thread size limit).
1524    /// Follow up with `loadPreparedModule` to complete the load.
1525    #[wasm_bindgen(js_name = prepareModule)]
1526    pub fn prepare_module(&self, header_json: &str, executable: Vec<u8>) -> js_sys::Promise {
1527        prepare_module_impl(self.loader.clone(), header_json, executable)
1528    }
1529
1530    /// Load a module whose executable was staged by `prepareModule`.
1531    /// Returns the module UUID string.
1532    #[wasm_bindgen(js_name = loadPreparedModule)]
1533    pub fn load_prepared_module(&mut self, header_json: &str) -> Result<String, JsValue> {
1534        self.load_module_inner(header_json, Box::new([]))
1535    }
1536
1537    fn load_module_inner(
1538        &mut self,
1539        header_json: &str,
1540        executable: Box<[u8]>,
1541    ) -> Result<String, JsValue> {
1542        let header: Header = serde_json::from_str(header_json)
1543            .map_err(|e| JsValue::from_str(&format!("invalid header: {e}")))?;
1544        let module_id = header.id;
1545        let header_json_str = header_json.to_string();
1546
1547        for export in &header.exports {
1548            let arora_types::module::low::ExportSymbol::Function(f) = export;
1549            let children_param_id = f.parameters.first().and_then(|p| {
1550                if let arora_types::module::low::TypeRef::Array { id } = &p.ty {
1551                    if id == &Uuid::from_bytes(TICK_ID_STRUCT_BYTES) {
1552                        Some(p.id)
1553                    } else {
1554                        None
1555                    }
1556                } else {
1557                    None
1558                }
1559            });
1560
1561            self.fn_meta.insert(
1562                f.id,
1563                FnMeta {
1564                    module_id,
1565                    children_param_id,
1566                },
1567            );
1568        }
1569
1570        let result = load_module_from_parts(&mut self.inner, header, executable)
1571            .map_err(|e| JsValue::from_str(&format!("load failed: {e}")))?;
1572        self.module_headers.insert(result.id, header_json_str);
1573        Ok(result.id.to_string())
1574    }
1575
1576    /// Returns a JSON array of all loaded module headers.
1577    #[wasm_bindgen(js_name = listModules)]
1578    pub fn list_modules(&self) -> String {
1579        let headers: Vec<serde_json::Value> = self
1580            .module_headers
1581            .values()
1582            .filter_map(|s| serde_json::from_str(s).ok())
1583            .collect();
1584        serde_json::to_string(&headers).unwrap_or_else(|_| "[]".to_string())
1585    }
1586
1587    /// Initialize or update a variable. `var_id` is a UUID string; `value_json`
1588    /// is the serialized `Value` (e.g. `{"f32": 0.0}`).
1589    #[wasm_bindgen(js_name = setVariable)]
1590    pub fn set_variable(&mut self, var_id: &str, value_json: &str) -> Result<(), JsValue> {
1591        let var_id: Uuid = var_id
1592            .parse()
1593            .map_err(|_| JsValue::from_str("bad var_id: not a valid UUID"))?;
1594        let value: Value = serde_json::from_str(value_json)
1595            .map_err(|e| JsValue::from_str(&format!("bad value JSON: {e}")))?;
1596        self.variables.borrow_mut().insert(var_id, value);
1597        Ok(())
1598    }
1599
1600    /// Run one tick of the behavior tree. Variables persist across calls.
1601    ///
1602    /// `nodes_json` is a JSON array where each element is:
1603    ///   `{ id, function, children?, arguments?, return_binding? }`
1604    ///
1605    /// Returns: `{ "status": "...", "trace": [...], "variables": { varId: value } }`
1606    pub fn tick(&mut self, nodes_json: &str) -> Result<String, JsValue> {
1607        let nodes: Vec<BtNode> = serde_json::from_str(nodes_json)
1608            .map_err(|e| JsValue::from_str(&format!("bad nodes JSON: {e}")))?;
1609        if nodes.is_empty() {
1610            return Err(JsValue::from_str("tree has no nodes"));
1611        }
1612
1613        let root_id = nodes[0].id;
1614        let node_index: HashMap<Uuid, BtNode> = nodes.into_iter().map(|n| (n.id, n)).collect();
1615        let trace: Rc<RefCell<Vec<(Uuid, &'static str)>>> = Rc::new(RefCell::new(Vec::new()));
1616
1617        let fn_meta = &self.fn_meta;
1618        let root_callable_id = register_node(
1619            &mut *self.inner,
1620            root_id,
1621            &node_index,
1622            fn_meta,
1623            &trace,
1624            &self.variables,
1625        )
1626        .map_err(|e| JsValue::from_str(&format!("setup error: {e}")))?;
1627
1628        let callable_id = CallableId {
1629            id: root_callable_id,
1630        };
1631        let result = Callable::call(&callable_id, &mut *self.inner)
1632            .map_err(|e| JsValue::from_str(&format!("tick error: {e}")))?;
1633        let status = value_to_status(&result);
1634
1635        let trace_json: Vec<serde_json::Value> = trace
1636            .borrow()
1637            .iter()
1638            .map(|(id, s)| serde_json::json!({ "nodeId": id.to_string(), "status": s }))
1639            .collect();
1640
1641        let vars_json: serde_json::Map<String, serde_json::Value> = self
1642            .variables
1643            .borrow()
1644            .iter()
1645            .filter_map(|(id, v)| serde_json::to_value(v).ok().map(|jv| (id.to_string(), jv)))
1646            .collect();
1647
1648        Ok(serde_json::json!({
1649          "status": status,
1650          "trace": trace_json,
1651          "variables": vars_json,
1652        })
1653        .to_string())
1654    }
1655
1656    /// Run a behavior tree to completion (ticks until not Running).
1657    ///
1658    /// `nodes_json` is a JSON array where each element is:
1659    ///   `{ id: "<uuid>", function: "<uuid>", children?: ["<uuid>", ...] }`
1660    /// The first element is the root node.
1661    ///
1662    /// Returns a JSON string:
1663    ///   `{ "status": "success"|"failure"|"running",
1664    ///      "trace": [{"nodeId": "<uuid>", "status": "..."}] }`
1665    pub fn run(&mut self, nodes_json: &str) -> Result<String, JsValue> {
1666        let nodes: Vec<BtNode> = serde_json::from_str(nodes_json)
1667            .map_err(|e| JsValue::from_str(&format!("bad nodes JSON: {e}")))?;
1668        if nodes.is_empty() {
1669            return Err(JsValue::from_str("tree has no nodes"));
1670        }
1671
1672        let root_id = nodes[0].id;
1673        let node_index: HashMap<Uuid, BtNode> = nodes.into_iter().map(|n| (n.id, n)).collect();
1674        let trace: Rc<RefCell<Vec<(Uuid, &'static str)>>> = Rc::new(RefCell::new(Vec::new()));
1675
1676        let fn_meta = &self.fn_meta;
1677        let root_callable_id = register_node(
1678            &mut *self.inner,
1679            root_id,
1680            &node_index,
1681            fn_meta,
1682            &trace,
1683            &self.variables,
1684        )
1685        .map_err(|e| JsValue::from_str(&format!("setup error: {e}")))?;
1686
1687        let callable_id = CallableId {
1688            id: root_callable_id,
1689        };
1690
1691        let mut last_status = "running";
1692        for _ in 0..10_000 {
1693            let result = Callable::call(&callable_id, &mut *self.inner)
1694                .map_err(|e| JsValue::from_str(&format!("tick error: {e}")))?;
1695            last_status = value_to_status(&result);
1696            if last_status != "running" {
1697                break;
1698            }
1699        }
1700
1701        let trace_json: Vec<serde_json::Value> = trace
1702            .borrow()
1703            .iter()
1704            .map(|(id, s)| serde_json::json!({ "nodeId": id.to_string(), "status": s }))
1705            .collect();
1706
1707        let out = serde_json::json!({
1708          "status": last_status,
1709          "trace": trace_json,
1710        });
1711        Ok(out.to_string())
1712    }
1713}
1714
1715impl Default for BehaviorTreeRunner {
1716    fn default() -> Self {
1717        Self::new()
1718    }
1719}
1720
1721// =============================================================================
1722// Type registry
1723//
1724// A minimal wasm-compatible registry for resolving type UUIDs to names.
1725// Loads from the records.json files emitted by arora-module-rust::generate_records.
1726// =============================================================================
1727
1728/// JS-callable type registry that resolves type UUIDs to human-readable names.
1729#[wasm_bindgen]
1730pub struct Registry {
1731    entries: HashMap<Uuid, (String, Option<Uuid>)>,
1732}
1733
1734#[derive(serde::Deserialize)]
1735struct RecordEntry {
1736    id: Uuid,
1737    name: String,
1738    parent: Option<Uuid>,
1739}
1740
1741#[wasm_bindgen]
1742impl Registry {
1743    #[wasm_bindgen(constructor)]
1744    pub fn new() -> Registry {
1745        Registry {
1746            entries: HashMap::new(),
1747        }
1748    }
1749
1750    /// Load type records from a JSON array: `[{id, name, parent?}, ...]`.
1751    #[wasm_bindgen(js_name = loadRecordsJson)]
1752    pub fn load_records_json(&mut self, json: &str) -> Result<(), JsValue> {
1753        let records: Vec<RecordEntry> = serde_json::from_str(json)
1754            .map_err(|e| JsValue::from_str(&format!("invalid records JSON: {e}")))?;
1755        for r in records {
1756            self.entries.insert(r.id, (r.name, r.parent));
1757        }
1758        Ok(())
1759    }
1760
1761    /// Resolve a UUID string to a dot-separated name path (e.g. `"arora.Status"`).
1762    /// Returns `None` if the UUID is not known.
1763    #[wasm_bindgen(js_name = resolveId)]
1764    pub fn resolve_id(&self, id: &str) -> Option<String> {
1765        let uuid: Uuid = id.parse().ok()?;
1766        self.compute_path(&uuid)
1767    }
1768
1769    fn compute_path(&self, id: &Uuid) -> Option<String> {
1770        let (name, parent) = self.entries.get(id)?;
1771        match parent {
1772            None => Some(name.clone()),
1773            Some(parent_id) if !self.entries.contains_key(parent_id) => Some(name.clone()),
1774            Some(parent_id) => Some(format!("{}.{}", self.compute_path(parent_id)?, name)),
1775        }
1776    }
1777}
1778
1779impl Default for Registry {
1780    fn default() -> Self {
1781        Self::new()
1782    }
1783}
1784
1785/// What a page has not drained stays one entry per changed key, whichever
1786/// way the device steps. Run in a browser with `wasm-pack test --headless
1787/// --firefox crates/arora-web --lib`.
1788#[cfg(test)]
1789mod undrained_tests {
1790    use super::*;
1791    use wasm_bindgen_futures::JsFuture;
1792    use wasm_bindgen_test::*;
1793
1794    wasm_bindgen_test_configure!(run_in_browser);
1795
1796    /// The keys a busy device changes on each of its steps.
1797    const KEYS: usize = 200;
1798
1799    /// Write every one of the [`KEYS`] to `value`, as one store change.
1800    fn frame(store: &dyn DataStore, value: f32) {
1801        let mut change = StateChange::new();
1802        for key in 0..KEYS {
1803            change
1804                .set
1805                .insert(Key::new(format!("face/{key}")), Some(Value::F32(value)));
1806        }
1807        store.write(change).expect("write a frame");
1808    }
1809
1810    /// A demo device and a sibling handle of its store, through which a test
1811    /// writes as the device's own writers do.
1812    fn device() -> (AroraWeb, Box<dyn DataStore>) {
1813        let arora = Arora::builder().build().expect("build the device");
1814        let store = arora.store().clone_box();
1815        (AroraWeb::from(arora), store)
1816    }
1817
1818    /// Whether the subscription's channel is empty: every change it received
1819    /// has been folded.
1820    fn channel_is_empty(web: &AroraWeb) -> bool {
1821        web.changes.borrow().subscription.try_recv().is_none()
1822    }
1823
1824    /// The value the backlog holds for `face/{key}`.
1825    fn held(web: &AroraWeb, key: usize) -> Option<Value> {
1826        web.changes
1827            .borrow()
1828            .latest
1829            .get(&Key::new(format!("face/{key}")))
1830            .cloned()
1831            .flatten()
1832    }
1833
1834    async fn sleep(ms: i32) {
1835        let timer = js_sys::Promise::new(&mut |resolve, _| {
1836            let set_timeout: js_sys::Function =
1837                js_sys::Reflect::get(&js_sys::global(), &JsValue::from_str("setTimeout"))
1838                    .expect("a global setTimeout")
1839                    .into();
1840            set_timeout
1841                .call2(&JsValue::NULL, &resolve, &JsValue::from(ms))
1842                .expect("setTimeout");
1843        });
1844        JsFuture::from(timer).await.expect("the timer fires");
1845    }
1846
1847    #[wasm_bindgen_test]
1848    fn stepping_without_a_drain_holds_one_value_per_key() {
1849        let (web, store) = device();
1850        for step in 0..1_000 {
1851            frame(&*store, step as f32);
1852            web.step(10.0).expect("step");
1853            assert!(channel_is_empty(&web), "step {step} left changes unfolded");
1854        }
1855        let keys = store.snapshot().storage.len();
1856        assert!(keys >= KEYS, "the store holds the frame's keys");
1857        assert!(
1858            web.changes.borrow().latest.len() <= keys,
1859            "the backlog holds no more than one entry per key"
1860        );
1861        assert_eq!(held(&web, 7), Some(Value::F32(999.0)));
1862    }
1863
1864    #[wasm_bindgen_test]
1865    async fn a_running_device_folds_every_step() {
1866        let (web, store) = device();
1867        let running = web.run(Some(1.0));
1868        for step in 0..50 {
1869            frame(&*store, step as f32);
1870            // Several periods: the loop steps, and folds, before this resumes.
1871            sleep(5).await;
1872            assert!(channel_is_empty(&web), "step {step} left changes unfolded");
1873            assert_eq!(held(&web, 0), Some(Value::F32(step as f32)));
1874        }
1875        web.stop();
1876        JsFuture::from(running).await.expect("the run stops");
1877    }
1878
1879    #[wasm_bindgen_test]
1880    fn a_page_write_is_folded_at_once() {
1881        let (web, _store) = device();
1882        web.drain_changes().expect("the first drain");
1883        for value in 0..100 {
1884            web.set_value("face/0", &format!(r#"{{"f32": {value}}}"#))
1885                .expect("set a key");
1886            assert!(channel_is_empty(&web));
1887        }
1888        assert_eq!(held(&web, 0), Some(Value::F32(99.0)));
1889    }
1890}