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