Skip to main content

dotzuki_engine_script/
engine.rs

1use std::cell::RefCell;
2use std::path::Path;
3use std::rc::Rc;
4
5use boa_engine::builtins::promise::PromiseState;
6#[cfg(target_arch = "wasm32")]
7use boa_engine::module::IdleModuleLoader;
8#[cfg(not(target_arch = "wasm32"))]
9use boa_engine::module::SimpleModuleLoader;
10use boa_engine::object::builtins::{JsFunction, JsPromise};
11use boa_engine::property::Attribute;
12use boa_engine::{js_string, Context, JsArgs, JsNativeError, JsResult, JsValue, Module, NativeFunction, Source};
13
14use crate::api_registrar::ScriptApiRegistrar;
15use crate::command::{CommandResult, ScriptCommand};
16
17#[derive(Debug, thiserror::Error)]
18pub enum ScriptEngineError {
19    #[error("JS error: {0}")]
20    JsError(String),
21    #[error("Script not found for map: {0}")]
22    ScriptNotFound(String),
23    #[error("Function not found: {0}")]
24    FunctionNotFound(String),
25    #[error("Engine not initialized")]
26    NotInitialized,
27}
28
29#[derive(Debug, Clone, PartialEq, Eq)]
30pub enum EngineState {
31    Idle,
32    Running,
33    WaitingForCommand,
34    Finished,
35}
36
37struct PendingResolve {
38    resolve_fn: JsFunction,
39}
40
41/// Shared state between the JS runtime and the Rust game loop.
42/// Commands issued by JS `await game.showText(...)` are placed here;
43/// the game loop reads them, executes the operation, then calls `signal_done`.
44pub struct SharedBridge {
45    pending_command: Option<ScriptCommand>,
46    pending_resolve: Option<PendingResolve>,
47    flags: std::collections::HashMap<String, bool>,
48    /// Generic, game-agnostic seeded query state read by synchronous JS
49    /// query functions (registered via `register_sync_fn`). The core engine
50    /// does not know what these keys mean — the game layer seeds them and
51    /// registers named query functions that interpret them.
52    numbers: std::collections::HashMap<String, f64>,
53    texts: std::collections::HashMap<String, String>,
54    sets: std::collections::HashMap<String, std::collections::HashSet<String>>,
55    player_x: u8,
56    player_y: u8,
57    pub lang: String,
58    /// State for the script-side RNG used by `game.showRandomText(...)` (and any
59    /// future `randInt`-style primitives). Game scripts have no `Math.random` /
60    /// `Date.now`, so all randomness must originate on the Rust side: the game
61    /// layer mixes real entropy in via [`ScriptEngine::mix_rng`], and tests can
62    /// pin a deterministic stream via [`ScriptEngine::seed_rng`].
63    rng_state: u64,
64}
65
66/// Non-zero default seed (a common splitmix64/golden-ratio constant). Keeping the
67/// state non-zero matters because xorshift64 is stuck at 0.
68const DEFAULT_RNG_SEED: u64 = 0x9E37_79B9_7F4A_7C15;
69
70impl SharedBridge {
71    fn new() -> Self {
72        Self {
73            pending_command: None,
74            pending_resolve: None,
75            flags: std::collections::HashMap::new(),
76            numbers: std::collections::HashMap::new(),
77            texts: std::collections::HashMap::new(),
78            sets: std::collections::HashMap::new(),
79            player_x: 0,
80            player_y: 0,
81            lang: "en".to_string(),
82            rng_state: DEFAULT_RNG_SEED,
83        }
84    }
85
86    /// Advance the internal xorshift64 RNG and return the next 64-bit value.
87    fn next_rand(&mut self) -> u64 {
88        let mut x = self.rng_state;
89        if x == 0 {
90            x = DEFAULT_RNG_SEED;
91        }
92        x ^= x << 13;
93        x ^= x >> 7;
94        x ^= x << 17;
95        self.rng_state = x;
96        x
97    }
98}
99
100/// Read-only view over the seeded query state of a [`SharedBridge`].
101///
102/// Passed to synchronous query closures registered via
103/// [`ScriptEngine::register_sync_fn`] so they can answer `@if`-style
104/// conditions without issuing a command or awaiting a promise.
105pub struct BridgeView<'a> {
106    inner: &'a SharedBridge,
107}
108
109impl<'a> BridgeView<'a> {
110    /// Numeric seeded value (defaults to `0.0`).
111    pub fn number(&self, k: &str) -> f64 {
112        self.inner.numbers.get(k).copied().unwrap_or(0.0)
113    }
114    /// Text seeded value (defaults to empty string).
115    pub fn text(&self, k: &str) -> String {
116        self.inner.texts.get(k).cloned().unwrap_or_default()
117    }
118    /// Whether the seeded set `k` contains `v`.
119    pub fn set_contains(&self, k: &str, v: &str) -> bool {
120        self.inner.sets.get(k).is_some_and(|s| s.contains(v))
121    }
122    /// Boolean flag value (defaults to `false`).
123    pub fn flag(&self, k: &str) -> bool {
124        self.inner.flags.get(k).copied().unwrap_or(false)
125    }
126}
127
128pub struct ScriptEngine {
129    context: Context,
130    bridge: Rc<RefCell<SharedBridge>>,
131    state: EngineState,
132    /// The currently loaded ES6 module (holds exported function bindings).
133    current_module: Option<Module>,
134}
135
136impl ScriptEngine {
137    pub fn new() -> Self {
138        #[cfg(target_arch = "wasm32")]
139        let mut context = Context::builder()
140            .module_loader(Rc::new(IdleModuleLoader))
141            .build()
142            .expect("failed to build JS context");
143
144        #[cfg(not(target_arch = "wasm32"))]
145        let mut context = Context::builder()
146            .module_loader(Rc::new(SimpleModuleLoader::new(".").expect(
147                "failed to create module loader (current directory must exist)",
148            )))
149            .build()
150            .expect("failed to build JS context");
151        let bridge = Rc::new(RefCell::new(SharedBridge::new()));
152
153        register_core_game_api(&mut context, bridge.clone());
154
155        Self {
156            context,
157            bridge,
158            state: EngineState::Idle,
159            current_module: None,
160        }
161    }
162
163    pub fn state(&self) -> &EngineState {
164        &self.state
165    }
166
167    pub fn is_idle(&self) -> bool {
168        self.state == EngineState::Idle
169    }
170
171    pub fn is_waiting(&self) -> bool {
172        self.state == EngineState::WaitingForCommand
173    }
174
175    pub fn set_flag(&mut self, flag: &str, value: bool) {
176        self.bridge
177            .borrow_mut()
178            .flags
179            .insert(flag.to_string(), value);
180    }
181
182    pub fn get_flag(&self, flag: &str) -> bool {
183        self.bridge
184            .borrow()
185            .flags
186            .get(flag)
187            .copied()
188            .unwrap_or(false)
189    }
190
191    /// Return a snapshot of all flags currently held in the bridge.
192    /// Used by the overworld to persist flags across map transitions.
193    pub fn get_all_flags(&self) -> std::collections::HashMap<String, bool> {
194        self.bridge.borrow().flags.clone()
195    }
196
197    /// Bulk-insert flags into the bridge (additive — does not clear existing).
198    /// Called after creating a new ScriptEngine to restore persistent flags.
199    pub fn seed_flags(&mut self, flags: &std::collections::HashMap<String, bool>) {
200        let mut b = self.bridge.borrow_mut();
201        for (k, v) in flags {
202            b.flags.insert(k.clone(), *v);
203        }
204    }
205
206    /// Pin the script-side RNG to a deterministic starting state. Intended for
207    /// tests; a value of `0` is treated as the default non-zero seed.
208    pub fn seed_rng(&mut self, seed: u64) {
209        self.bridge.borrow_mut().rng_state = if seed == 0 { DEFAULT_RNG_SEED } else { seed };
210    }
211
212    /// Mix externally-sourced entropy into the script-side RNG. The game layer
213    /// calls this (e.g. once per frame with a draw from the overworld RNG) so
214    /// `game.showRandomText(...)` picks vary between playthroughs even though
215    /// scripts themselves have no access to `Math.random`/`Date.now`.
216    pub fn mix_rng(&mut self, entropy: u64) {
217        let mut b = self.bridge.borrow_mut();
218        b.rng_state ^= entropy.wrapping_mul(0x2545_F491_4F6C_DD1D);
219        if b.rng_state == 0 {
220            b.rng_state = DEFAULT_RNG_SEED;
221        }
222    }
223
224    /// Seed a numeric value read by synchronous query functions.
225    pub fn seed_number(&mut self, k: &str, v: f64) {
226        self.bridge.borrow_mut().numbers.insert(k.into(), v);
227    }
228
229    /// Seed a text value read by synchronous query functions.
230    pub fn seed_text(&mut self, k: &str, v: &str) {
231        self.bridge.borrow_mut().texts.insert(k.into(), v.into());
232    }
233
234    /// Seed a string set read by synchronous query functions
235    /// (e.g. the player's bag, as a set of item constant names).
236    pub fn seed_set(&mut self, k: &str, vals: &[String]) {
237        self.bridge
238            .borrow_mut()
239            .sets
240            .insert(k.into(), vals.iter().cloned().collect());
241    }
242
243    pub fn set_player_position(&mut self, x: u8, y: u8) {
244        self.bridge.borrow_mut().player_x = x;
245        self.bridge.borrow_mut().player_y = y;
246    }
247
248    pub fn set_lang(&mut self, lang: &str) {
249        self.bridge.borrow_mut().lang = lang.to_string();
250    }
251
252    pub fn load_script(&mut self, source: &str) -> Result<(), ScriptEngineError> {
253        log::info!(target: "dotzuki::overworld", "[ScriptEngine] load_script: {} bytes", source.len());
254        let src = Source::from_reader(source.as_bytes(), Some(Path::new("script.mjs")));
255        let module = Module::parse(src, None, &mut self.context)
256            .map_err(|e| {
257                log::warn!(target: "dotzuki::overworld", "[ScriptEngine] Module parse failed: {}", e);
258                ScriptEngineError::JsError(e.to_string())
259            })?;
260
261        self.context
262            .module_loader()
263            .register_module(js_string!("script.mjs"), module.clone());
264
265        let promise = module.load_link_evaluate(&mut self.context);
266        self.context.run_jobs();
267
268        match promise.state() {
269            PromiseState::Fulfilled(_) => {
270                log::info!(target: "dotzuki::overworld", "[ScriptEngine] Module evaluated OK");
271            }
272            PromiseState::Rejected(err) => {
273                log::warn!(target: "dotzuki::overworld", "[ScriptEngine] Module evaluation rejected: {:?}", err);
274                return Err(ScriptEngineError::JsError(format!(
275                    "Module evaluation failed: {:?}",
276                    err
277                )));
278            }
279            PromiseState::Pending => {
280                log::warn!(target: "dotzuki::overworld", "[ScriptEngine] Module evaluation stuck pending");
281                return Err(ScriptEngineError::JsError(
282                    "Module evaluation stuck in pending state".to_string(),
283                ));
284            }
285        }
286
287        self.current_module = Some(module);
288
289        if let Some(ref m) = self.current_module {
290            for name in &["enterMap", "talkNurse", "talkLinkReceptionist", "talkGentleman"] {
291                let has = m.get_value(js_string!(*name), &mut self.context)
292                    .map(|v| v.is_callable())
293                    .unwrap_or(false);
294                log::info!(target: "dotzuki::overworld", "[ScriptEngine] Export check: {} = {}", name, has);
295            }
296        }
297
298        Ok(())
299    }
300
301    pub fn load_shared_module(
302        &mut self,
303        name: &str,
304        source: &str,
305    ) -> Result<(), ScriptEngineError> {
306        log::info!(target: "dotzuki::overworld", "[ScriptEngine] load_shared_module '{}': {} bytes", name, source.len());
307        let src = Source::from_reader(source.as_bytes(), Some(Path::new(name)));
308        let module = Module::parse(src, None, &mut self.context)
309            .map_err(|e| {
310                log::warn!(target: "dotzuki::overworld", "[ScriptEngine] Shared module parse failed: {}", e);
311                ScriptEngineError::JsError(e.to_string())
312            })?;
313
314        self.context
315            .module_loader()
316            .register_module(js_string!(name), module.clone());
317
318        let promise = module.load_link_evaluate(&mut self.context);
319        self.context.run_jobs();
320
321        match promise.state() {
322            PromiseState::Fulfilled(_) => {
323                log::info!(target: "dotzuki::overworld", "[ScriptEngine] Shared module '{}' evaluated OK", name);
324                if let Ok(val) = module.get_value(js_string!("talkNurse"), &mut self.context) {
325                    log::info!(target: "dotzuki::overworld", "[ScriptEngine] Shared module talkNurse callable: {}", val.is_callable());
326                }
327            }
328            PromiseState::Rejected(err) => {
329                log::warn!(target: "dotzuki::overworld", "[ScriptEngine] Shared module '{}' rejected: {:?}", name, err);
330                return Err(ScriptEngineError::JsError(format!(
331                    "Shared module evaluation failed: {:?}",
332                    err
333                )));
334            }
335            PromiseState::Pending => {
336                log::warn!(target: "dotzuki::overworld", "[ScriptEngine] Shared module '{}' stuck pending", name);
337                return Err(ScriptEngineError::JsError(
338                    "Shared module evaluation stuck in pending state".to_string(),
339                ));
340            }
341        }
342        Ok(())
343    }
344
345    /// Call a JS async function by name (e.g., "scriptDefault", "talkProf").
346    /// The function must be `export`-ed from the loaded module.
347    /// Returns the first ScriptCommand if the function immediately awaits one.
348    pub fn call_function(
349        &mut self,
350        fn_name: &str,
351        args: &[JsValue],
352    ) -> Result<Option<ScriptCommand>, ScriptEngineError> {
353        // Resolve `talkMom` → `storyline_talkMom` etc. (see `resolved_fn_name`).
354        let resolved = self
355            .resolved_fn_name(fn_name)
356            .unwrap_or_else(|| fn_name.to_string());
357        let fn_name = resolved.as_str();
358        log::info!(target: "dotzuki::overworld", "[ScriptEngine] call_function: {}", fn_name);
359        let module = self
360            .current_module
361            .as_ref()
362            .ok_or(ScriptEngineError::NotInitialized)?;
363
364        let func = module
365            .get_value(js_string!(fn_name), &mut self.context)
366            .map_err(|e| {
367                log::warn!(target: "dotzuki::overworld", "[ScriptEngine] get_value error for {}: {}", fn_name, e);
368                ScriptEngineError::JsError(e.to_string())
369            })?;
370
371        if func.is_undefined() || func.is_null() {
372            log::warn!(target: "dotzuki::overworld", "[ScriptEngine] Function '{}' is undefined or null", fn_name);
373            return Err(ScriptEngineError::FunctionNotFound(fn_name.to_string()));
374        }
375
376        let func_obj = func
377            .as_callable()
378            .ok_or_else(|| {
379                log::warn!(target: "dotzuki::overworld", "[ScriptEngine] Function '{}' is not callable", fn_name);
380                ScriptEngineError::FunctionNotFound(fn_name.to_string())
381            })?;
382
383        log::info!(target: "dotzuki::overworld", "[ScriptEngine] Calling function '{}'...", fn_name);
384        let result = func_obj
385            .call(&JsValue::undefined(), args, &mut self.context);
386        
387        match result {
388            Ok(_) => {
389                log::info!(target: "dotzuki::overworld", "[ScriptEngine] Function '{}' call succeeded", fn_name);
390            }
391            Err(e) => {
392                log::warn!(target: "dotzuki::overworld", "[ScriptEngine] Function '{}' call failed: {}", fn_name, e);
393                return Err(ScriptEngineError::JsError(e.to_string()));
394            }
395        }
396
397        self.context.run_jobs();
398
399        self.state = EngineState::Running;
400        let cmd = self.check_pending_command()?;
401        log::info!(target: "dotzuki::overworld", "[ScriptEngine] After call_function '{}': pending_command = {:?}", fn_name, cmd.is_some());
402        Ok(cmd)
403    }
404
405    /// Called each frame by the game loop.
406    /// Returns the current pending command if the script is waiting.
407    pub fn tick(&mut self) -> Option<ScriptCommand> {
408        match self.state {
409            EngineState::WaitingForCommand => self.bridge.borrow().pending_command.clone(),
410            EngineState::Idle | EngineState::Finished => None,
411            EngineState::Running => match self.check_pending_command() {
412                Ok(cmd) => cmd,
413                Err(_) => {
414                    self.state = EngineState::Finished;
415                    None
416                }
417            },
418        }
419    }
420
421    /// Signal that the game has completed the pending command.
422    /// Resolves the JS promise so the async function can continue.
423    pub fn signal_done(
424        &mut self,
425        result: CommandResult,
426    ) -> Result<Option<ScriptCommand>, ScriptEngineError> {
427        if self.state != EngineState::WaitingForCommand {
428            return Ok(None);
429        }
430
431        let resolve = self.bridge.borrow_mut().pending_resolve.take();
432        self.bridge.borrow_mut().pending_command = None;
433
434        if let Some(pending) = resolve {
435            let js_result = command_result_to_js(&result, &mut self.context);
436            pending
437                .resolve_fn
438                .call(&JsValue::undefined(), &[js_result], &mut self.context)
439                .map_err(|e| ScriptEngineError::JsError(e.to_string()))?;
440
441            self.context.run_jobs();
442        }
443
444        self.state = EngineState::Running;
445        self.check_pending_command()
446    }
447
448    fn check_pending_command(&mut self) -> Result<Option<ScriptCommand>, ScriptEngineError> {
449        let cmd = self.bridge.borrow().pending_command.clone();
450        if cmd.is_some() {
451            self.state = EngineState::WaitingForCommand;
452        } else if self.state == EngineState::Running {
453            self.state = EngineState::Idle;
454        }
455        Ok(cmd)
456    }
457
458    /// Register an async command function on the `game` global JS object.
459    ///
460    /// The `builder` closure receives JS arguments and returns a `ScriptCommand`.
461    /// The engine automatically creates a Promise, stores the command + resolve
462    /// function in the bridge, and returns the Promise to JS.
463    ///
464    /// This is the building block for `ScriptApiRegistrar` implementations.
465    pub fn register_async_fn(
466        &mut self,
467        name: &str,
468        builder: impl Fn(&[JsValue], &mut Context) -> JsResult<ScriptCommand> + 'static,
469    ) {
470        let bridge = self.bridge.clone();
471        let func = unsafe {
472            NativeFunction::from_closure(move |_this, args, ctx| {
473                let (promise, resolvers) = JsPromise::new_pending(ctx);
474                let cmd = builder(args, ctx)?;
475                let mut b = bridge.borrow_mut();
476                b.pending_command = Some(cmd);
477                b.pending_resolve = Some(PendingResolve {
478                    resolve_fn: resolvers.resolve,
479                });
480                Ok(promise.into())
481            })
482        };
483        let game_obj = self
484            .context
485            .global_object()
486            .get(js_string!("game"), &mut self.context)
487            .expect("game global not found")
488            .to_object(&mut self.context)
489            .expect("game global is not an object");
490        game_obj
491            .set(
492                js_string!(name),
493                func.to_js_function(self.context.realm()),
494                true,
495                &mut self.context,
496            )
497            .unwrap_or_else(|_| panic!("failed to register game.{}", name));
498    }
499
500    /// Register a *synchronous* query function on the `game` global JS object.
501    ///
502    /// Unlike [`register_async_fn`](Self::register_async_fn), the closure returns
503    /// a `JsValue` directly (no promise, no pending command). It is handed a
504    /// read-only [`BridgeView`] over the seeded query state so it can answer
505    /// `@if`-style conditions immediately.
506    pub fn register_sync_fn<F>(&mut self, name: &str, f: F)
507    where
508        F: Fn(&[JsValue], &mut Context, &BridgeView) -> JsResult<JsValue> + 'static,
509    {
510        let bridge = self.bridge.clone();
511        // SAFETY: closure captures only `Rc<RefCell<SharedBridge>>` which holds no
512        // GC-traced (boa `Trace`) types, so it cannot cause use-after-free.
513        let func = unsafe {
514            NativeFunction::from_closure(move |_this, args, ctx| {
515                let b = bridge.borrow();
516                let view = BridgeView { inner: &b };
517                f(args, ctx, &view)
518            })
519        };
520        let game_obj = self
521            .context
522            .global_object()
523            .get(js_string!("game"), &mut self.context)
524            .expect("game global not found")
525            .to_object(&mut self.context)
526            .expect("game global is not an object");
527        game_obj
528            .set(
529                js_string!(name),
530                func.to_js_function(self.context.realm()),
531                true,
532                &mut self.context,
533            )
534            .unwrap_or_else(|_| panic!("failed to register game.{}", name));
535    }
536
537    /// Construct a `ScriptEngine` with a game-specific API registrar.
538    ///
539    /// Core APIs (showText, moveNpc, getFlag, warpTo, playMusic, etc.) are always
540    /// registered. The `registrar` adds game-specific APIs such as `giveMonster`,
541    /// `startBattle`, etc.
542    pub fn with_api(registrar: &dyn ScriptApiRegistrar) -> Self {
543        let mut engine = Self::new();
544        registrar.register_api(&mut engine);
545        engine
546    }
547}
548
549impl Default for ScriptEngine {
550    fn default() -> Self {
551        Self::new()
552    }
553}
554
555// ── Convenience call methods ─────────────────────────────────────
556// These allow pokered-core to call JS functions without depending on boa_engine directly.
557
558impl ScriptEngine {
559    /// Call a JS function with no arguments.
560    pub fn call_function_no_args(
561        &mut self,
562        fn_name: &str,
563    ) -> Result<Option<ScriptCommand>, ScriptEngineError> {
564        self.call_function(fn_name, &[])
565    }
566
567    /// Call a JS function with a single u8 argument (e.g., npc text_id lookup).
568    pub fn call_function_with_u8(
569        &mut self,
570        fn_name: &str,
571        arg: u8,
572    ) -> Result<Option<ScriptCommand>, ScriptEngineError> {
573        self.call_function(fn_name, &[JsValue::from(arg as i32)])
574    }
575
576    /// Call a JS function with two u16 arguments (e.g., coord event trigger).
577    pub fn call_function_with_xy(
578        &mut self,
579        fn_name: &str,
580        x: u16,
581        y: u16,
582    ) -> Result<Option<ScriptCommand>, ScriptEngineError> {
583        self.call_function(fn_name, &[JsValue::from(x as i32), JsValue::from(y as i32)])
584    }
585
586    /// Call a JS function with a single string argument.
587    pub fn call_function_with_str(
588        &mut self,
589        fn_name: &str,
590        arg: &str,
591    ) -> Result<Option<ScriptCommand>, ScriptEngineError> {
592        self.call_function(fn_name, &[JsValue::from(js_string!(arg))])
593    }
594
595    /// Resolve a trigger/binding name to the exported function that actually
596    /// exists in the current module. Configs bind the *bare* name (e.g.
597    /// `talkMom`, `SeafoamIslandsB4FOnLoad`) but the DSL compiler exports
598    /// `@storyline` blocks under a `storyline_`-prefixed name
599    /// (`storyline_talkMom`). Try the exact name first (so `onLoad` names and
600    /// any bare `.js` functions still win), then the `storyline_` fallback.
601    fn resolved_fn_name(&mut self, fn_name: &str) -> Option<String> {
602        let module = self.current_module.clone()?;
603        if matches!(module.get_value(js_string!(fn_name), &mut self.context), Ok(v) if v.is_callable())
604        {
605            return Some(fn_name.to_string());
606        }
607        let prefixed = format!("storyline_{fn_name}");
608        if matches!(module.get_value(js_string!(prefixed.as_str()), &mut self.context), Ok(v) if v.is_callable())
609        {
610            return Some(prefixed);
611        }
612        None
613    }
614
615    /// Check if a JS function exists in the module's exports (matching the
616    /// `storyline_` resolution used by [`Self::call_function`]).
617    pub fn has_function(&mut self, fn_name: &str) -> bool {
618        self.resolved_fn_name(fn_name).is_some()
619    }
620}
621
622fn command_result_to_js(result: &CommandResult, _context: &mut Context) -> JsValue {
623    match result {
624        CommandResult::Void => JsValue::undefined(),
625        CommandResult::Bool(b) => JsValue::from(*b),
626        CommandResult::Number(n) => JsValue::from(*n),
627        CommandResult::Text(s) => JsValue::from(js_string!(s.as_str())),
628    }
629}
630
631fn register_core_game_api(context: &mut Context, bridge: Rc<RefCell<SharedBridge>>) {
632    let mut game_obj = boa_engine::object::ObjectInitializer::new(context);
633    let game_obj = game_obj.build();
634
635    let lang_bridge = bridge.clone();
636    let lang_fn = unsafe {
637        NativeFunction::from_closure(move |_this: &JsValue, _args: &[JsValue], _ctx: &mut Context| -> JsResult<JsValue> {
638            Ok(JsValue::from(js_string!(lang_bridge.borrow().lang.as_str())))
639        })
640    };
641    game_obj
642        .set(js_string!("lang"), lang_fn.to_js_function(context.realm()), true, context)
643        .expect("failed to register game.lang");
644
645    let t_bridge = bridge.clone();
646    let t_fn = unsafe {
647        NativeFunction::from_closure(move |_this: &JsValue, args: &[JsValue], ctx: &mut Context| -> JsResult<JsValue> {
648        let en = args.get_or_undefined(0).to_string(ctx).map_or(String::new(), |s| s.to_std_string_lossy());
649        let zh = args.get_or_undefined(1).to_string(ctx).map_or(String::new(), |s| s.to_std_string_lossy());
650        let result = if t_bridge.borrow().lang == "zh" { zh } else { en };
651            Ok(JsValue::from(js_string!(result)))
652        })
653    };
654    game_obj
655        .set(js_string!("t"), t_fn.to_js_function(context.realm()), true, context)
656        .expect("failed to register game.t");
657
658    // game.showRandomText(a, b, c, ...) OR game.showRandomText([a, b, c])
659    //   -> Promise<void>
660    // Picks one line at random (Rust-side RNG) and shows it, exactly like
661    // game.showText. Used for original flavor-text pools where an NPC/sign picks
662    // a line from a set each interaction (e.g. gossip NPCs, the cruise ship
663    // chefs). Resolves like showText once the box is dismissed.
664    let rand_text_bridge = bridge.clone();
665    // SAFETY: captures only `Rc<RefCell<SharedBridge>>`, which holds no
666    // GC-traced (boa `Trace`) types, so it cannot cause use-after-free.
667    let rand_text_fn = unsafe {
668        NativeFunction::from_closure(
669            move |_this: &JsValue, args: &[JsValue], ctx: &mut Context| -> JsResult<JsValue> {
670                // Accept a single array argument, or a variadic list of strings.
671                let mut options: Vec<String> = Vec::new();
672                if args.len() == 1 && args[0].is_object() {
673                    let obj = args[0].to_object(ctx)?;
674                    let len = obj.get(js_string!("length"), ctx)?.to_u32(ctx)?;
675                    for i in 0..len {
676                        options.push(obj.get(i, ctx)?.to_string(ctx)?.to_std_string_lossy());
677                    }
678                } else {
679                    for a in args {
680                        options.push(a.to_string(ctx)?.to_std_string_lossy());
681                    }
682                }
683
684                let (promise, resolvers) = JsPromise::new_pending(ctx);
685
686                let mut b = rand_text_bridge.borrow_mut();
687                let text = if options.is_empty() {
688                    String::new()
689                } else {
690                    let idx = (b.next_rand() % options.len() as u64) as usize;
691                    options.swap_remove(idx)
692                };
693                b.pending_command = Some(ScriptCommand::ShowText { text });
694                b.pending_resolve = Some(PendingResolve {
695                    resolve_fn: resolvers.resolve,
696                });
697
698                Ok(promise.into())
699            },
700        )
701    };
702    game_obj
703        .set(
704            js_string!("showRandomText"),
705            rand_text_fn.to_js_function(context.realm()),
706            true,
707            context,
708        )
709        .expect("failed to register game.showRandomText");
710
711    macro_rules! register_async_command {
712        ($name:expr, $bridge:expr, $context:expr, $game_obj:expr, $cmd_builder:expr) => {{
713            let bridge = $bridge.clone();
714            // SAFETY: The closure captures only `Rc<RefCell<SharedBridge>>` which contains no
715            // GC-traced (boa `Trace`) types, so it cannot cause use-after-free with the GC.
716            let func = unsafe {
717                NativeFunction::from_closure(move |_this, args, ctx| {
718                    let (promise, resolvers) = JsPromise::new_pending(ctx);
719
720                    let cmd = ($cmd_builder)(args, ctx)?;
721
722                    let mut b = bridge.borrow_mut();
723                    b.pending_command = Some(cmd);
724                    b.pending_resolve = Some(PendingResolve {
725                        resolve_fn: resolvers.resolve,
726                    });
727
728                    Ok(promise.into())
729                })
730            };
731            $game_obj
732                .set(
733                    js_string!($name),
734                    func.to_js_function($context.realm()),
735                    true,
736                    $context,
737                )
738                .expect(concat!("failed to register game.", $name));
739        }};
740    }
741
742    // game.showText(text: string) -> Promise<void>
743    register_async_command!(
744        "showText",
745        bridge,
746        context,
747        game_obj,
748        |args: &[JsValue], ctx: &mut Context| -> JsResult<ScriptCommand> {
749            let text = args
750                .get_or_undefined(0)
751                .to_string(ctx)?
752                .to_std_string_lossy();
753            Ok(ScriptCommand::ShowText { text })
754        }
755    );
756
757    // game.showChoice(options: string[]) -> Promise<number>
758    register_async_command!(
759        "showChoice",
760        bridge,
761        context,
762        game_obj,
763        |args: &[JsValue], ctx: &mut Context| -> JsResult<ScriptCommand> {
764            let arr = args.get_or_undefined(0).to_object(ctx)?;
765            let len = arr.get(js_string!("length"), ctx)?.to_u32(ctx)?;
766            let mut options = Vec::new();
767            for i in 0..len {
768                let val = arr.get(i, ctx)?;
769                options.push(val.to_string(ctx)?.to_std_string_lossy());
770            }
771            Ok(ScriptCommand::ShowChoice { options })
772        }
773    );
774
775    // game.moveNpc(npcId: string, path: [number, number][]) -> Promise<void>
776    register_async_command!(
777        "moveNpc",
778        bridge,
779        context,
780        game_obj,
781        |args: &[JsValue], ctx: &mut Context| -> JsResult<ScriptCommand> {
782            let npc_id = args
783                .get_or_undefined(0)
784                .to_string(ctx)?
785                .to_std_string_lossy();
786            let arr = args.get_or_undefined(1).to_object(ctx)?;
787            let len = arr.get(js_string!("length"), ctx)?.to_u32(ctx)?;
788            let mut path = Vec::new();
789            for i in 0..len {
790                let point = arr.get(i, ctx)?.to_object(ctx)?;
791                let x = point.get(0, ctx)?.to_u32(ctx)? as u8;
792                let y = point.get(1, ctx)?.to_u32(ctx)? as u8;
793                path.push((x, y));
794            }
795            Ok(ScriptCommand::MoveNpc { npc_id, path })
796        }
797    );
798
799    // game.startNpcMove(npcId: string, path: [number, number][]) -> Promise<void>
800    // Fire-and-forget: starts NPC moving along path, resolves immediately.
801    register_async_command!(
802        "startNpcMove",
803        bridge,
804        context,
805        game_obj,
806        |args: &[JsValue], ctx: &mut Context| -> JsResult<ScriptCommand> {
807            let npc_id = args
808                .get_or_undefined(0)
809                .to_string(ctx)?
810                .to_std_string_lossy();
811            let arr = args.get_or_undefined(1).to_object(ctx)?;
812            let len = arr.get(js_string!("length"), ctx)?.to_u32(ctx)?;
813            let mut path = Vec::new();
814            for i in 0..len {
815                let point = arr.get(i, ctx)?.to_object(ctx)?;
816                let x = point.get(0, ctx)?.to_u32(ctx)? as u8;
817                let y = point.get(1, ctx)?.to_u32(ctx)? as u8;
818                path.push((x, y));
819            }
820            Ok(ScriptCommand::StartNpcMove { npc_id, path })
821        }
822    );
823
824    // game.awaitNpcMove(npcId: string) -> Promise<void>
825    // Blocks until the NPC's scripted path is complete.
826    register_async_command!(
827        "awaitNpcMove",
828        bridge,
829        context,
830        game_obj,
831        |args: &[JsValue], ctx: &mut Context| -> JsResult<ScriptCommand> {
832            let npc_id = args
833                .get_or_undefined(0)
834                .to_string(ctx)?
835                .to_std_string_lossy();
836            Ok(ScriptCommand::AwaitNpcMove { npc_id })
837        }
838    );
839
840    // game.movePlayer(path: [number, number][]) -> Promise<void>
841    // Blocks until the player finishes walking the path.
842    register_async_command!(
843        "movePlayer",
844        bridge,
845        context,
846        game_obj,
847        |args: &[JsValue], ctx: &mut Context| -> JsResult<ScriptCommand> {
848            let arr = args.get_or_undefined(0).to_object(ctx)?;
849            let len = arr.get(js_string!("length"), ctx)?.to_u32(ctx)?;
850            let mut path = Vec::new();
851            for i in 0..len {
852                let point = arr.get(i, ctx)?.to_object(ctx)?;
853                let x = point.get(0, ctx)?.to_u32(ctx)? as u8;
854                let y = point.get(1, ctx)?.to_u32(ctx)? as u8;
855                path.push((x, y));
856            }
857            Ok(ScriptCommand::MovePlayer { path })
858        }
859    );
860
861    // game.movePlayerRelative(steps: ([number, number] | DirectionString)[]) -> Promise<void>
862    // Each entry is a (dx, dy) delta (or a direction string) applied
863    // cumulatively from the player's current position; the deltas are
864    // resolved to absolute waypoints by the game core when the command
865    // runs. Blocks until the player finishes walking.
866    register_async_command!(
867        "movePlayerRelative",
868        bridge,
869        context,
870        game_obj,
871        |args: &[JsValue], ctx: &mut Context| -> JsResult<ScriptCommand> {
872            let arr = args.get_or_undefined(0).to_object(ctx)?;
873            let len = arr.get(js_string!("length"), ctx)?.to_u32(ctx)?;
874            let mut steps = Vec::new();
875            for i in 0..len {
876                let entry = arr.get(i, ctx)?;
877                if entry.is_string() {
878                    let dir = entry.to_string(ctx)?.to_std_string_lossy();
879                    let delta = match dir.to_ascii_lowercase().as_str() {
880                        "up" | "north" => (0i16, -1i16),
881                        "down" | "south" => (0, 1),
882                        "left" | "west" => (-1, 0),
883                        "right" | "east" => (1, 0),
884                        other => {
885                            return Err(JsNativeError::typ()
886                                .with_message(format!(
887                                    "movePlayerRelative: unknown direction '{other}'"
888                                ))
889                                .into())
890                        }
891                    };
892                    steps.push(delta);
893                } else {
894                    let point = entry.to_object(ctx)?;
895                    let dx = point.get(0, ctx)?.to_i32(ctx)? as i16;
896                    let dy = point.get(1, ctx)?.to_i32(ctx)? as i16;
897                    steps.push((dx, dy));
898                }
899            }
900            Ok(ScriptCommand::MovePlayerRelative { steps })
901        }
902    );
903
904    // game.moveNpcTo(npcId: string, x: number, y: number) -> Promise<void>
905    // Plans a terrain-aware path and resolves when movement is done.
906    register_async_command!(
907        "moveNpcTo",
908        bridge,
909        context,
910        game_obj,
911        |args: &[JsValue], ctx: &mut Context| -> JsResult<ScriptCommand> {
912            let npc_id = args
913                .get_or_undefined(0)
914                .to_string(ctx)?
915                .to_std_string_lossy();
916            let x = args.get_or_undefined(1).to_u32(ctx)? as u8;
917            let y = args.get_or_undefined(2).to_u32(ctx)? as u8;
918            Ok(ScriptCommand::MoveNpcTo { npc_id, x, y })
919        }
920    );
921
922    // game.startNpcMoveTo(npcId: string, x: number, y: number) -> Promise<void>
923    // Plans a terrain-aware path, starts movement immediately and resolves at once.
924    register_async_command!(
925        "startNpcMoveTo",
926        bridge,
927        context,
928        game_obj,
929        |args: &[JsValue], ctx: &mut Context| -> JsResult<ScriptCommand> {
930            let npc_id = args
931                .get_or_undefined(0)
932                .to_string(ctx)?
933                .to_std_string_lossy();
934            let x = args.get_or_undefined(1).to_u32(ctx)? as u8;
935            let y = args.get_or_undefined(2).to_u32(ctx)? as u8;
936            Ok(ScriptCommand::StartNpcMoveTo { npc_id, x, y })
937        }
938    );
939
940    // game.movePlayerTo(x: number, y: number) -> Promise<void>
941    // Plans a terrain-aware path and resolves when movement is done.
942    register_async_command!(
943        "movePlayerTo",
944        bridge,
945        context,
946        game_obj,
947        |args: &[JsValue], ctx: &mut Context| -> JsResult<ScriptCommand> {
948            let x = args.get_or_undefined(0).to_u32(ctx)? as u8;
949            let y = args.get_or_undefined(1).to_u32(ctx)? as u8;
950            Ok(ScriptCommand::MovePlayerTo { x, y })
951        }
952    );
953
954    // game.faceNpc(npcId: string, direction: string) -> Promise<void>
955    register_async_command!(
956        "faceNpc",
957        bridge,
958        context,
959        game_obj,
960        |args: &[JsValue], ctx: &mut Context| -> JsResult<ScriptCommand> {
961            let npc_id = args
962                .get_or_undefined(0)
963                .to_string(ctx)?
964                .to_std_string_lossy();
965            let direction = args
966                .get_or_undefined(1)
967                .to_string(ctx)?
968                .to_std_string_lossy();
969            Ok(ScriptCommand::FaceNpc { npc_id, direction })
970        }
971    );
972
973    // game.facePlayer(direction: string) -> Promise<void>
974    register_async_command!(
975        "facePlayer",
976        bridge,
977        context,
978        game_obj,
979        |args: &[JsValue], ctx: &mut Context| -> JsResult<ScriptCommand> {
980            let direction = args
981                .get_or_undefined(0)
982                .to_string(ctx)?
983                .to_std_string_lossy();
984            Ok(ScriptCommand::FacePlayer { direction })
985        }
986    );
987
988    // game.setNpcFrame(npcId: string, frame: number) -> Promise<void>
989    register_async_command!(
990        "setNpcFrame",
991        bridge,
992        context,
993        game_obj,
994        |args: &[JsValue], ctx: &mut Context| -> JsResult<ScriptCommand> {
995            let npc_id = args
996                .get_or_undefined(0)
997                .to_string(ctx)?
998                .to_std_string_lossy();
999            let frame = args
1000                .get_or_undefined(1)
1001                .to_number(ctx)? as u8;
1002            Ok(ScriptCommand::SetNpcFrame { npc_id, frame })
1003        }
1004    );
1005
1006    // game.playMusic(musicId: string) -> Promise<void>
1007    register_async_command!(
1008        "playMusic",
1009        bridge,
1010        context,
1011        game_obj,
1012        |args: &[JsValue], ctx: &mut Context| -> JsResult<ScriptCommand> {
1013            let music_id = args
1014                .get_or_undefined(0)
1015                .to_string(ctx)?
1016                .to_std_string_lossy();
1017            Ok(ScriptCommand::PlayMusic { music_id })
1018        }
1019    );
1020
1021    // game.playSound(soundId: string) -> Promise<void>
1022    register_async_command!(
1023        "playSound",
1024        bridge,
1025        context,
1026        game_obj,
1027        |args: &[JsValue], ctx: &mut Context| -> JsResult<ScriptCommand> {
1028            let sound_id = args
1029                .get_or_undefined(0)
1030                .to_string(ctx)?
1031                .to_std_string_lossy();
1032            Ok(ScriptCommand::PlaySound { sound_id })
1033        }
1034    );
1035
1036    // game.stopMusic() -> Promise<void>
1037    register_async_command!(
1038        "stopMusic",
1039        bridge,
1040        context,
1041        game_obj,
1042        |_args: &[JsValue], _ctx: &mut Context| -> JsResult<ScriptCommand> {
1043            Ok(ScriptCommand::StopMusic)
1044        }
1045    );
1046
1047    // game.fadeOutMusic() -> Promise<void>
1048    register_async_command!(
1049        "fadeOutMusic",
1050        bridge,
1051        context,
1052        game_obj,
1053        |_args: &[JsValue], _ctx: &mut Context| -> JsResult<ScriptCommand> {
1054            Ok(ScriptCommand::FadeOutMusic)
1055        }
1056    );
1057
1058    // game.delay(frames: number) -> Promise<void>
1059    register_async_command!("delay", bridge, context, game_obj, |args: &[JsValue],
1060                                                                 ctx: &mut Context|
1061     -> JsResult<
1062        ScriptCommand,
1063    > {
1064        let frames = args.get_or_undefined(0).to_u32(ctx)? as u16;
1065        Ok(ScriptCommand::Delay { frames })
1066    });
1067
1068    // game.warpTo(map: string, x: number, y: number) -> Promise<void>
1069    register_async_command!("warpTo", bridge, context, game_obj, |args: &[JsValue],
1070                                                                  ctx: &mut Context|
1071     -> JsResult<
1072        ScriptCommand,
1073    > {
1074        let map = args
1075            .get_or_undefined(0)
1076            .to_string(ctx)?
1077            .to_std_string_lossy();
1078        let x = args.get_or_undefined(1).to_u32(ctx)? as u8;
1079        let y = args.get_or_undefined(2).to_u32(ctx)? as u8;
1080        Ok(ScriptCommand::WarpTo { map, x, y })
1081    });
1082
1083    // game.heal() -> Promise<void>
1084    register_async_command!("heal", bridge, context, game_obj, |_args: &[JsValue],
1085                                                                 _ctx: &mut Context|
1086     -> JsResult<
1087        ScriptCommand,
1088    > {
1089        Ok(ScriptCommand::Heal)
1090    });
1091
1092    // game.fadeScreen(fadeType: string) -> Promise<void>
1093    register_async_command!(
1094        "fadeScreen",
1095        bridge,
1096        context,
1097        game_obj,
1098        |args: &[JsValue], ctx: &mut Context| -> JsResult<ScriptCommand> {
1099            let fade_type = args
1100                .get_or_undefined(0)
1101                .to_string(ctx)?
1102                .to_std_string_lossy();
1103            Ok(ScriptCommand::FadeScreen { fade_type })
1104        }
1105    );
1106
1107    // game.showObject(objectIndexOrToggleId: number | string) -> Promise<void>
1108    register_async_command!(
1109        "showObject",
1110        bridge,
1111        context,
1112        game_obj,
1113        |args: &[JsValue], ctx: &mut Context| -> JsResult<ScriptCommand> {
1114            let arg = args.get_or_undefined(0);
1115            if arg.is_string() {
1116                let toggle_id = arg.to_string(ctx)?.to_std_string_lossy();
1117                Ok(ScriptCommand::ShowObjectByName { toggle_id })
1118            } else {
1119                let object_index = arg.to_u32(ctx)? as u8;
1120                Ok(ScriptCommand::ShowObject { object_index })
1121            }
1122        }
1123    );
1124
1125    // game.hideObject(objectIndexOrToggleId: number | string) -> Promise<void>
1126    register_async_command!(
1127        "hideObject",
1128        bridge,
1129        context,
1130        game_obj,
1131        |args: &[JsValue], ctx: &mut Context| -> JsResult<ScriptCommand> {
1132            let arg = args.get_or_undefined(0);
1133            if arg.is_string() {
1134                let toggle_id = arg.to_string(ctx)?.to_std_string_lossy();
1135                Ok(ScriptCommand::HideObjectByName { toggle_id })
1136            } else {
1137                let object_index = arg.to_u32(ctx)? as u8;
1138                Ok(ScriptCommand::HideObject { object_index })
1139            }
1140        }
1141    );
1142
1143    // game.showObjectByName(toggleId: string) -> Promise<void>
1144    // Explicit string-only alias used by many .scene files (e.g. `@load` guards).
1145    // Without this the call is `undefined` and the handler throws before the
1146    // object is ever toggled.
1147    register_async_command!(
1148        "showObjectByName",
1149        bridge,
1150        context,
1151        game_obj,
1152        |args: &[JsValue], ctx: &mut Context| -> JsResult<ScriptCommand> {
1153            let toggle_id = args.get_or_undefined(0).to_string(ctx)?.to_std_string_lossy();
1154            Ok(ScriptCommand::ShowObjectByName { toggle_id })
1155        }
1156    );
1157
1158    // game.hideObjectByName(toggleId: string) -> Promise<void>
1159    register_async_command!(
1160        "hideObjectByName",
1161        bridge,
1162        context,
1163        game_obj,
1164        |args: &[JsValue], ctx: &mut Context| -> JsResult<ScriptCommand> {
1165            let toggle_id = args.get_or_undefined(0).to_string(ctx)?.to_std_string_lossy();
1166            Ok(ScriptCommand::HideObjectByName { toggle_id })
1167        }
1168    );
1169
1170    // game.setJoyIgnore(mask: number) -> Promise<void>
1171    register_async_command!(
1172        "setJoyIgnore",
1173        bridge,
1174        context,
1175        game_obj,
1176        |args: &[JsValue], ctx: &mut Context| -> JsResult<ScriptCommand> {
1177            let mask = args.get_or_undefined(0).to_u32(ctx)? as u8;
1178            Ok(ScriptCommand::SetJoyIgnore { mask })
1179        }
1180    );
1181
1182    // game.clearJoyIgnore() -> Promise<void>
1183    register_async_command!(
1184        "clearJoyIgnore",
1185        bridge,
1186        context,
1187        game_obj,
1188        |_args: &[JsValue], _ctx: &mut Context| -> JsResult<ScriptCommand> {
1189            Ok(ScriptCommand::ClearJoyIgnore)
1190        }
1191    );
1192
1193    // game.followNpc(npcId: string, targetX: number, targetY: number) -> Promise<void>
1194    register_async_command!(
1195        "followNpc",
1196        bridge,
1197        context,
1198        game_obj,
1199        |args: &[JsValue], ctx: &mut Context| -> JsResult<ScriptCommand> {
1200            let npc_id = args
1201                .get_or_undefined(0)
1202                .to_string(ctx)?
1203                .to_std_string_lossy();
1204            let target_x = args.get_or_undefined(1).to_u32(ctx)? as u8;
1205            let target_y = args.get_or_undefined(2).to_u32(ctx)? as u8;
1206            Ok(ScriptCommand::FollowNpc {
1207                npc_id,
1208                target_x,
1209                target_y,
1210            })
1211        }
1212    );
1213
1214    // game.openShop(items: string[]) -> Promise<void>
1215    register_async_command!(
1216        "openShop",
1217        bridge,
1218        context,
1219        game_obj,
1220        |args: &[JsValue], ctx: &mut Context| -> JsResult<ScriptCommand> {
1221            let arr = args.get_or_undefined(0).to_object(ctx)?;
1222            let len = arr.get(js_string!("length"), ctx)?.to_u32(ctx)?;
1223            let mut items = Vec::new();
1224            for i in 0..len {
1225                let val = arr.get(i, ctx)?;
1226                items.push(val.to_string(ctx)?.to_std_string_lossy());
1227            }
1228            Ok(ScriptCommand::OpenShop { items })
1229        }
1230    );
1231
1232    // game.showEmotionBubble(npcId: string, emotion: string) -> Promise<void>
1233    register_async_command!(
1234        "showEmotionBubble",
1235        bridge,
1236        context,
1237        game_obj,
1238        |args: &[JsValue], ctx: &mut Context| -> JsResult<ScriptCommand> {
1239            let npc_id = args
1240                .get_or_undefined(0)
1241                .to_string(ctx)?
1242                .to_std_string_lossy();
1243            let emotion = args
1244                .get_or_undefined(1)
1245                .to_string(ctx)?
1246                .to_std_string_lossy();
1247            Ok(ScriptCommand::ShowEmotionBubble { npc_id, emotion })
1248        }
1249    );
1250
1251    // game.setNpcPosition(npcId: string, x: number, y: number) -> Promise<void>
1252    register_async_command!(
1253        "setNpcPosition",
1254        bridge,
1255        context,
1256        game_obj,
1257        |args: &[JsValue], ctx: &mut Context| -> JsResult<ScriptCommand> {
1258            let npc_id = args
1259                .get_or_undefined(0)
1260                .to_string(ctx)?
1261                .to_std_string_lossy();
1262            let x = args.get_or_undefined(1).to_u32(ctx)? as u8;
1263            let y = args.get_or_undefined(2).to_u32(ctx)? as u8;
1264            Ok(ScriptCommand::SetNpcPosition { npc_id, x, y })
1265        }
1266    );
1267
1268    // game.showScene(sceneName: string) -> Promise<void>
1269    register_async_command!(
1270        "showScene",
1271        bridge,
1272        context,
1273        game_obj,
1274        |args: &[JsValue], ctx: &mut Context| -> JsResult<ScriptCommand> {
1275            let scene_name = args
1276                .get_or_undefined(0)
1277                .to_string(ctx)?
1278                .to_std_string_lossy();
1279            Ok(ScriptCommand::ShowScene {
1280                scene_name,
1281                layout_json: None,
1282            })
1283        }
1284    );
1285
1286    // game.hideScene(sceneName: string) -> Promise<void>
1287    register_async_command!(
1288        "hideScene",
1289        bridge,
1290        context,
1291        game_obj,
1292        |args: &[JsValue], ctx: &mut Context| -> JsResult<ScriptCommand> {
1293            let scene_name = args
1294                .get_or_undefined(0)
1295                .to_string(ctx)?
1296                .to_std_string_lossy();
1297            Ok(ScriptCommand::HideScene { scene_name })
1298        }
1299    );
1300
1301    // game.updateUI(sceneName: string, data: any) -> Promise<void>
1302    register_async_command!(
1303        "updateUI",
1304        bridge,
1305        context,
1306        game_obj,
1307        |args: &[JsValue], ctx: &mut Context| -> JsResult<ScriptCommand> {
1308            let scene_name = args
1309                .get_or_undefined(0)
1310                .to_string(ctx)?
1311                .to_std_string_lossy();
1312            let data_val = args.get_or_undefined(1);
1313            let json_val = data_val.to_json(ctx)?;
1314            let data_json = json_val.to_string();
1315            Ok(ScriptCommand::UpdateUI {
1316                scene_name,
1317                data_json,
1318            })
1319        }
1320    );
1321
1322    // game.getFlag(flag: string) -> boolean
1323    {
1324        let bridge = bridge.clone();
1325        // SAFETY: closure captures Rc<RefCell<SharedBridge>> — no GC-traced types.
1326        let func = unsafe {
1327            NativeFunction::from_closure(move |_this, args, ctx| {
1328                let flag = args
1329                    .get_or_undefined(0)
1330                    .to_string(ctx)?
1331                    .to_std_string_lossy();
1332                let val = bridge.borrow().flags.get(&flag).copied().unwrap_or(false);
1333                Ok(JsValue::from(val))
1334            })
1335        };
1336        game_obj
1337            .set(
1338                js_string!("getFlag"),
1339                func.to_js_function(context.realm()),
1340                true,
1341                context,
1342            )
1343            .expect("failed to register game.getFlag");
1344    }
1345
1346    // game.setFlag(flag: string) -> void
1347    {
1348        let bridge = bridge.clone();
1349        // SAFETY: closure captures Rc<RefCell<SharedBridge>> — no GC-traced types.
1350        let func = unsafe {
1351            NativeFunction::from_closure(move |_this, args, ctx| {
1352                let flag = args
1353                    .get_or_undefined(0)
1354                    .to_string(ctx)?
1355                    .to_std_string_lossy();
1356                bridge.borrow_mut().flags.insert(flag, true);
1357                Ok(JsValue::undefined())
1358            })
1359        };
1360        game_obj
1361            .set(
1362                js_string!("setFlag"),
1363                func.to_js_function(context.realm()),
1364                true,
1365                context,
1366            )
1367            .expect("failed to register game.setFlag");
1368    }
1369
1370    // game.resetFlag(flag: string) -> void
1371    {
1372        let bridge = bridge.clone();
1373        // SAFETY: closure captures Rc<RefCell<SharedBridge>> — no GC-traced types.
1374        let func = unsafe {
1375            NativeFunction::from_closure(move |_this, args, ctx| {
1376                let flag = args
1377                    .get_or_undefined(0)
1378                    .to_string(ctx)?
1379                    .to_std_string_lossy();
1380                bridge.borrow_mut().flags.insert(flag, false);
1381                Ok(JsValue::undefined())
1382            })
1383        };
1384        game_obj
1385            .set(
1386                js_string!("resetFlag"),
1387                func.to_js_function(context.realm()),
1388                true,
1389                context,
1390            )
1391            .expect("failed to register game.resetFlag");
1392    }
1393
1394    // game.getPlayerPosition() -> {x: number, y: number}
1395    {
1396        let bridge = bridge.clone();
1397        let func = unsafe {
1398            NativeFunction::from_closure(move |_this, _args, ctx| {
1399                let b = bridge.borrow();
1400                let pos = boa_engine::object::ObjectInitializer::new(ctx)
1401                    .property(
1402                        js_string!("x"),
1403                        JsValue::from(b.player_x as i32),
1404                        Attribute::all(),
1405                    )
1406                    .property(
1407                        js_string!("y"),
1408                        JsValue::from(b.player_y as i32),
1409                        Attribute::all(),
1410                    )
1411                    .build();
1412                Ok(pos.into())
1413            })
1414        };
1415        game_obj
1416            .set(
1417                js_string!("getPlayerPosition"),
1418                func.to_js_function(context.realm()),
1419                true,
1420                context,
1421            )
1422            .expect("failed to register game.getPlayerPosition");
1423    }
1424
1425    // game.getPlayerX() -> number
1426    {
1427        let bridge = bridge.clone();
1428        let func = unsafe {
1429            NativeFunction::from_closure(move |_this, _args, _ctx| {
1430                Ok(JsValue::from(bridge.borrow().player_x as i32))
1431            })
1432        };
1433        game_obj
1434            .set(
1435                js_string!("getPlayerX"),
1436                func.to_js_function(context.realm()),
1437                true,
1438                context,
1439            )
1440            .expect("failed to register game.getPlayerX");
1441    }
1442
1443    // game.getPlayerY() -> number
1444    {
1445        let bridge = bridge.clone();
1446        let func = unsafe {
1447            NativeFunction::from_closure(move |_this, _args, _ctx| {
1448                Ok(JsValue::from(bridge.borrow().player_y as i32))
1449            })
1450        };
1451        game_obj
1452            .set(
1453                js_string!("getPlayerY"),
1454                func.to_js_function(context.realm()),
1455                true,
1456                context,
1457            )
1458            .expect("failed to register game.getPlayerY");
1459    }
1460
1461    context
1462        .register_global_property(js_string!("game"), game_obj, Attribute::all())
1463        .expect("failed to register global game object");
1464}