From b8155872e2f075f92fb7abc3103d475d08b92fca Mon Sep 17 00:00:00 2001 From: Willow Sparks Date: Tue, 3 Feb 2026 21:04:43 +0000 Subject: [PATCH] Add rust4d_scripting crate with Lua 5.4 runtime Create the scripting engine foundation (Wave 3, Swarm A Sub-Phase A): - ScriptEngine with Lua VM initialization via mlua - Sandboxed globals (os, io, loadfile, dofile removed) - Script loading from game directory with require() support - Lifecycle callbacks: on_init, on_update, on_fixed_update, on_shutdown - ScriptError type with Display and Error impls - Custom print() routing to log::info - 22 tests covering VM init, sandboxing, loading, lifecycle, error handling --- Cargo.toml | 2 + crates/rust4d_scripting/Cargo.toml | 12 + crates/rust4d_scripting/src/error.rs | 50 ++++ crates/rust4d_scripting/src/lib.rs | 349 +++++++++++++++++++++++ crates/rust4d_scripting/src/lifecycle.rs | 102 +++++++ crates/rust4d_scripting/src/loader.rs | 113 ++++++++ crates/rust4d_scripting/src/vm.rs | 140 +++++++++ 7 files changed, 768 insertions(+) create mode 100644 crates/rust4d_scripting/Cargo.toml create mode 100644 crates/rust4d_scripting/src/error.rs create mode 100644 crates/rust4d_scripting/src/lib.rs create mode 100644 crates/rust4d_scripting/src/lifecycle.rs create mode 100644 crates/rust4d_scripting/src/loader.rs create mode 100644 crates/rust4d_scripting/src/vm.rs diff --git a/Cargo.toml b/Cargo.toml index 0af8357..df24625 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -7,6 +7,7 @@ members = [ "crates/rust4d_input", "crates/rust4d_physics", "crates/rust4d_game", + "crates/rust4d_scripting", ] [workspace.dependencies] @@ -29,6 +30,7 @@ rust4d_render = { path = "crates/rust4d_render" } rust4d_input = { path = "crates/rust4d_input" } rust4d_physics = { path = "crates/rust4d_physics" } rust4d_game = { path = "crates/rust4d_game" } +rust4d_scripting = { path = "crates/rust4d_scripting" } [package] name = "rust4d" diff --git a/crates/rust4d_scripting/Cargo.toml b/crates/rust4d_scripting/Cargo.toml new file mode 100644 index 0000000..d75a21a --- /dev/null +++ b/crates/rust4d_scripting/Cargo.toml @@ -0,0 +1,12 @@ +[package] +name = "rust4d_scripting" +version = "0.1.0" +edition = "2021" + +[dependencies] +mlua = { version = "0.10", features = ["lua54", "vendored", "send"] } +log = "0.4" + +[dev-dependencies] +env_logger = "0.11" +tempfile = "3" diff --git a/crates/rust4d_scripting/src/error.rs b/crates/rust4d_scripting/src/error.rs new file mode 100644 index 0000000..c192f42 --- /dev/null +++ b/crates/rust4d_scripting/src/error.rs @@ -0,0 +1,50 @@ +//! Error types for the scripting engine + +/// Errors that can occur in the scripting engine +#[derive(Debug)] +pub enum ScriptError { + /// Script file not found + FileNotFound(String), + /// IO error reading script file + IoError(String, std::io::Error), + /// Lua execution error (syntax error, runtime error) + LuaError(mlua::Error), + /// Error in a lifecycle callback + RuntimeError { + callback: String, + error: mlua::Error, + }, + /// Hot-reload error (old script continues running) + ReloadError { + path: String, + error: mlua::Error, + }, +} + +impl std::fmt::Display for ScriptError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::FileNotFound(path) => write!(f, "Script not found: {}", path), + Self::IoError(path, e) => write!(f, "Failed to read {}: {}", path, e), + Self::LuaError(e) => write!(f, "Lua error: {}", e), + Self::RuntimeError { callback, error } => { + write!(f, "Error in {}(): {}", callback, error) + } + Self::ReloadError { path, error } => { + write!(f, "Reload failed for {}: {}", path, error) + } + } + } +} + +impl std::error::Error for ScriptError { + fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { + match self { + Self::IoError(_, e) => Some(e), + Self::LuaError(e) => Some(e), + Self::RuntimeError { error, .. } => Some(error), + Self::ReloadError { error, .. } => Some(error), + _ => None, + } + } +} diff --git a/crates/rust4d_scripting/src/lib.rs b/crates/rust4d_scripting/src/lib.rs new file mode 100644 index 0000000..bfe9b96 --- /dev/null +++ b/crates/rust4d_scripting/src/lib.rs @@ -0,0 +1,349 @@ +//! Lua scripting engine for Rust4D +//! +//! This crate provides a Lua 5.4 scripting runtime for the Rust4D engine, +//! powered by mlua. It handles: +//! +//! - Lua VM initialization with sandboxed globals +//! - Script loading from a game directory +//! - Game loop lifecycle callbacks (on_init, on_update, on_fixed_update, on_shutdown) +//! - Error handling and reporting +//! +//! ## Usage +//! +//! ```no_run +//! use rust4d_scripting::{ScriptEngine, ScriptConfig}; +//! +//! let config = ScriptConfig { +//! scripts_dir: "my_game/scripts".to_string(), +//! ..Default::default() +//! }; +//! let mut engine = ScriptEngine::new(config).unwrap(); +//! engine.load_game().unwrap(); +//! engine.call_init().unwrap(); +//! +//! // In the game loop: +//! engine.call_update(0.016).unwrap(); +//! engine.call_fixed_update(1.0 / 60.0).unwrap(); +//! +//! // On shutdown: +//! engine.call_shutdown().unwrap(); +//! ``` + +pub mod error; +pub mod lifecycle; +pub mod loader; +pub mod vm; + +pub use error::ScriptError; +pub use vm::ScriptConfig; + +use mlua::prelude::*; + +/// The main scripting engine handle. +/// +/// Owns a Lua VM and manages script loading and lifecycle callbacks. +pub struct ScriptEngine { + lua: Lua, + config: ScriptConfig, + error_state: Option, +} + +impl ScriptEngine { + /// Create a new scripting engine with the given configuration. + /// + /// This initializes the Lua VM with sandboxed globals and configured + /// package paths but does not load any scripts. + pub fn new(config: ScriptConfig) -> Result { + let lua = vm::create_lua_vm(&config)?; + Ok(Self { + lua, + config, + error_state: None, + }) + } + + /// Load the game's main.lua and all required modules. + /// + /// This executes `/main.lua`, which can use `require()` + /// to load additional modules. After this call, all lifecycle callbacks + /// (on_init, on_update, etc.) should be defined as globals. + pub fn load_game(&mut self) -> Result<(), ScriptError> { + loader::load_game_scripts(&self.lua, &self.config.scripts_dir) + } + + /// Call the `on_init()` lifecycle callback. + /// + /// This should be called once after `load_game()` and before the game loop starts. + /// If `on_init` is not defined, this is a no-op. + pub fn call_init(&self) -> Result<(), ScriptError> { + lifecycle::call_lifecycle(&self.lua, "on_init", ()) + } + + /// Call the `on_update(dt)` lifecycle callback. + /// + /// Called each frame with the variable-timestep delta time. + /// If `on_update` is not defined, this is a no-op. + pub fn call_update(&self, dt: f32) -> Result<(), ScriptError> { + lifecycle::call_lifecycle(&self.lua, "on_update", dt) + } + + /// Call the `on_fixed_update(dt)` lifecycle callback. + /// + /// Called at a fixed timestep rate for deterministic game logic. + /// If `on_fixed_update` is not defined, this is a no-op. + pub fn call_fixed_update(&self, dt: f32) -> Result<(), ScriptError> { + lifecycle::call_lifecycle(&self.lua, "on_fixed_update", dt) + } + + /// Call the `on_shutdown()` lifecycle callback. + /// + /// Called once before the engine exits. If `on_shutdown` is not defined, + /// this is a no-op. + pub fn call_shutdown(&self) -> Result<(), ScriptError> { + lifecycle::call_lifecycle(&self.lua, "on_shutdown", ()) + } + + /// Get the last error encountered by the engine. + /// + /// Useful for displaying errors on-screen during development. + pub fn last_error(&self) -> Option<&ScriptError> { + self.error_state.as_ref() + } + + /// Set the error state (for internal use when suppressing repeated errors). + pub fn set_error(&mut self, err: ScriptError) { + self.error_state = Some(err); + } + + /// Clear the error state. + pub fn clear_error(&mut self) { + self.error_state = None; + } + + /// Get a reference to the underlying Lua VM. + /// + /// Useful for registering additional bindings or for testing. + pub fn lua(&self) -> &Lua { + &self.lua + } + + /// Get the script configuration. + pub fn config(&self) -> &ScriptConfig { + &self.config + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::io::Write; + + fn create_game_dir() -> (tempfile::TempDir, ScriptConfig) { + let dir = tempfile::tempdir().unwrap(); + let config = ScriptConfig { + scripts_dir: dir.path().to_str().unwrap().to_string(), + ..Default::default() + }; + (dir, config) + } + + #[test] + fn test_engine_new() { + let (_dir, config) = create_game_dir(); + let engine = ScriptEngine::new(config); + assert!(engine.is_ok()); + } + + #[test] + fn test_engine_load_missing_main() { + let (_dir, config) = create_game_dir(); + let mut engine = ScriptEngine::new(config).unwrap(); + let result = engine.load_game(); + assert!(result.is_err()); + } + + #[test] + fn test_engine_full_lifecycle() { + let (dir, config) = create_game_dir(); + + // Write main.lua with all lifecycle callbacks + let main_path = dir.path().join("main.lua"); + std::fs::write( + &main_path, + r#" + lifecycle_log = {} + + function on_init() + table.insert(lifecycle_log, "init") + end + + function on_update(dt) + table.insert(lifecycle_log, "update:" .. tostring(dt)) + end + + function on_fixed_update(dt) + table.insert(lifecycle_log, "fixed:" .. tostring(dt)) + end + + function on_shutdown() + table.insert(lifecycle_log, "shutdown") + end + "#, + ) + .unwrap(); + + let mut engine = ScriptEngine::new(config).unwrap(); + engine.load_game().unwrap(); + engine.call_init().unwrap(); + engine.call_update(0.016).unwrap(); + engine.call_fixed_update(1.0 / 60.0).unwrap(); + engine.call_shutdown().unwrap(); + + // Verify the lifecycle log + let count: i64 = engine + .lua() + .load("return #lifecycle_log") + .eval() + .unwrap(); + assert_eq!(count, 4); + + let first: String = engine + .lua() + .load("return lifecycle_log[1]") + .eval() + .unwrap(); + assert_eq!(first, "init"); + + let last: String = engine + .lua() + .load("return lifecycle_log[4]") + .eval() + .unwrap(); + assert_eq!(last, "shutdown"); + } + + #[test] + fn test_engine_missing_callbacks_ok() { + let (dir, config) = create_game_dir(); + + // main.lua with no callbacks defined + let main_path = dir.path().join("main.lua"); + std::fs::write(&main_path, "-- empty script").unwrap(); + + let mut engine = ScriptEngine::new(config).unwrap(); + engine.load_game().unwrap(); + assert!(engine.call_init().is_ok()); + assert!(engine.call_update(0.016).is_ok()); + assert!(engine.call_fixed_update(1.0 / 60.0).is_ok()); + assert!(engine.call_shutdown().is_ok()); + } + + #[test] + fn test_engine_runtime_error_in_callback() { + let (dir, config) = create_game_dir(); + + let main_path = dir.path().join("main.lua"); + std::fs::write( + &main_path, + r#" + function on_update(dt) + error("something went wrong") + end + "#, + ) + .unwrap(); + + let mut engine = ScriptEngine::new(config).unwrap(); + engine.load_game().unwrap(); + let result = engine.call_update(0.016); + assert!(result.is_err()); + } + + #[test] + fn test_engine_require_modules() { + let (dir, config) = create_game_dir(); + + // Create a module + let mut module_file = + std::fs::File::create(dir.path().join("utils.lua")).unwrap(); + writeln!(module_file, "local M = {{}}").unwrap(); + writeln!(module_file, "function M.double(x) return x * 2 end").unwrap(); + writeln!(module_file, "return M").unwrap(); + + // main.lua that requires and uses the module + let main_path = dir.path().join("main.lua"); + std::fs::write( + &main_path, + r#" + local utils = require("utils") + result = utils.double(21) + "#, + ) + .unwrap(); + + let mut engine = ScriptEngine::new(config).unwrap(); + engine.load_game().unwrap(); + + let result: i64 = engine.lua().load("return result").eval().unwrap(); + assert_eq!(result, 42); + } + + #[test] + fn test_engine_error_state() { + let (_dir, config) = create_game_dir(); + let mut engine = ScriptEngine::new(config).unwrap(); + + assert!(engine.last_error().is_none()); + + engine.set_error(ScriptError::FileNotFound("test.lua".to_string())); + assert!(engine.last_error().is_some()); + + engine.clear_error(); + assert!(engine.last_error().is_none()); + } + + #[test] + fn test_engine_update_receives_correct_dt() { + let (dir, config) = create_game_dir(); + + let main_path = dir.path().join("main.lua"); + std::fs::write( + &main_path, + r#" + function on_update(dt) + received_dt = dt + end + "#, + ) + .unwrap(); + + let mut engine = ScriptEngine::new(config).unwrap(); + engine.load_game().unwrap(); + engine.call_update(0.033).unwrap(); + + let dt: f64 = engine.lua().load("return received_dt").eval().unwrap(); + assert!((dt - 0.033).abs() < 0.001); + } + + #[test] + fn test_sandboxing_prevents_os_access() { + let (dir, config) = create_game_dir(); + + let main_path = dir.path().join("main.lua"); + std::fs::write( + &main_path, + r#" + function on_init() + if os then + error("os should not be available") + end + end + "#, + ) + .unwrap(); + + let mut engine = ScriptEngine::new(config).unwrap(); + engine.load_game().unwrap(); + engine.call_init().unwrap(); // Should not error + } +} diff --git a/crates/rust4d_scripting/src/lifecycle.rs b/crates/rust4d_scripting/src/lifecycle.rs new file mode 100644 index 0000000..9707b02 --- /dev/null +++ b/crates/rust4d_scripting/src/lifecycle.rs @@ -0,0 +1,102 @@ +//! Game loop lifecycle callback dispatch + +use mlua::prelude::*; +use crate::error::ScriptError; + +/// Call a global Lua lifecycle function if it exists, silently ignoring missing functions. +/// +/// If the function exists, it is called with the provided arguments. +/// If the function doesn't exist (global is nil), this returns Ok(()). +/// If the function exists but errors during execution, this returns a RuntimeError. +pub fn call_lifecycle(lua: &Lua, name: &str, args: impl IntoLuaMulti) -> Result<(), ScriptError> { + let globals = lua.globals(); + + match globals.get::(name) { + Ok(LuaValue::Function(func)) => { + func.call::<()>(args).map_err(|e| ScriptError::RuntimeError { + callback: name.to_string(), + error: e, + })?; + Ok(()) + } + Ok(LuaValue::Nil) => { + // Function doesn't exist — that's fine, it's optional + Ok(()) + } + Ok(_) => { + // Global exists but isn't a function — silently ignore + Ok(()) + } + Err(e) => Err(ScriptError::LuaError(e)), + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::vm::{create_lua_vm, ScriptConfig}; + + fn test_lua() -> Lua { + create_lua_vm(&ScriptConfig::default()).unwrap() + } + + #[test] + fn test_call_existing_callback() { + let lua = test_lua(); + lua.load("function on_init() called_init = true end") + .exec() + .unwrap(); + + call_lifecycle(&lua, "on_init", ()).unwrap(); + + let called: bool = lua.load("return called_init").eval().unwrap(); + assert!(called); + } + + #[test] + fn test_missing_callback_is_ok() { + let lua = test_lua(); + // on_init is not defined + let result = call_lifecycle(&lua, "on_init", ()); + assert!(result.is_ok()); + } + + #[test] + fn test_callback_receives_args() { + let lua = test_lua(); + lua.load("function on_update(dt) received_dt = dt end") + .exec() + .unwrap(); + + call_lifecycle(&lua, "on_update", 0.016_f32).unwrap(); + + let dt: f32 = lua.load("return received_dt").eval().unwrap(); + assert!((dt - 0.016).abs() < 0.0001); + } + + #[test] + fn test_callback_runtime_error() { + let lua = test_lua(); + lua.load("function on_update(dt) error('boom') end") + .exec() + .unwrap(); + + let result = call_lifecycle(&lua, "on_update", 0.016_f32); + assert!(result.is_err()); + let err = result.unwrap_err(); + assert!(matches!(err, ScriptError::RuntimeError { .. })); + // Check the callback name is captured + if let ScriptError::RuntimeError { callback, .. } = &err { + assert_eq!(callback, "on_update"); + } + } + + #[test] + fn test_non_function_global_is_ok() { + let lua = test_lua(); + lua.load("on_init = 42").exec().unwrap(); // Not a function + + let result = call_lifecycle(&lua, "on_init", ()); + assert!(result.is_ok()); + } +} diff --git a/crates/rust4d_scripting/src/loader.rs b/crates/rust4d_scripting/src/loader.rs new file mode 100644 index 0000000..a648402 --- /dev/null +++ b/crates/rust4d_scripting/src/loader.rs @@ -0,0 +1,113 @@ +//! Script loading and require() resolution + +use mlua::prelude::*; +use crate::error::ScriptError; + +/// Load the game's main.lua entry point and execute it. +/// +/// This establishes the global game module by executing `main.lua` from the +/// scripts directory. The script can use `require()` to load other modules, +/// which will be resolved relative to the scripts directory. +pub fn load_game_scripts(lua: &Lua, scripts_dir: &str) -> Result<(), ScriptError> { + let main_path = format!("{}/main.lua", scripts_dir); + + if !std::path::Path::new(&main_path).exists() { + return Err(ScriptError::FileNotFound(main_path)); + } + + let source = std::fs::read_to_string(&main_path) + .map_err(|e| ScriptError::IoError(main_path.clone(), e))?; + + lua.load(source) + .set_name(main_path) + .exec() + .map_err(ScriptError::LuaError)?; + + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::vm::{create_lua_vm, ScriptConfig}; + use std::io::Write; + + #[test] + fn test_load_main_lua() { + let dir = tempfile::tempdir().unwrap(); + let main_path = dir.path().join("main.lua"); + std::fs::write(&main_path, "test_global = 42").unwrap(); + + let config = ScriptConfig { + scripts_dir: dir.path().to_str().unwrap().to_string(), + ..Default::default() + }; + let lua = create_lua_vm(&config).unwrap(); + load_game_scripts(&lua, &config.scripts_dir).unwrap(); + + let val: i64 = lua.load("return test_global").eval().unwrap(); + assert_eq!(val, 42); + } + + #[test] + fn test_missing_main_lua() { + let dir = tempfile::tempdir().unwrap(); + let config = ScriptConfig { + scripts_dir: dir.path().to_str().unwrap().to_string(), + ..Default::default() + }; + let lua = create_lua_vm(&config).unwrap(); + let result = load_game_scripts(&lua, &config.scripts_dir); + assert!(result.is_err()); + let err = result.unwrap_err(); + assert!(matches!(err, ScriptError::FileNotFound(_))); + } + + #[test] + fn test_syntax_error_in_main() { + let dir = tempfile::tempdir().unwrap(); + let main_path = dir.path().join("main.lua"); + std::fs::write(&main_path, "this is not valid lua!!!").unwrap(); + + let config = ScriptConfig { + scripts_dir: dir.path().to_str().unwrap().to_string(), + ..Default::default() + }; + let lua = create_lua_vm(&config).unwrap(); + let result = load_game_scripts(&lua, &config.scripts_dir); + assert!(result.is_err()); + assert!(matches!(result.unwrap_err(), ScriptError::LuaError(_))); + } + + #[test] + fn test_require_resolves_from_scripts_dir() { + let dir = tempfile::tempdir().unwrap(); + + // Create a module + let mut module_file = std::fs::File::create(dir.path().join("mymodule.lua")).unwrap(); + writeln!(module_file, "local M = {{}}").unwrap(); + writeln!(module_file, "M.value = 99").unwrap(); + writeln!(module_file, "return M").unwrap(); + + // Create main.lua that requires the module + let main_path = dir.path().join("main.lua"); + std::fs::write( + &main_path, + r#" + local mymod = require("mymodule") + loaded_value = mymod.value + "#, + ) + .unwrap(); + + let config = ScriptConfig { + scripts_dir: dir.path().to_str().unwrap().to_string(), + ..Default::default() + }; + let lua = create_lua_vm(&config).unwrap(); + load_game_scripts(&lua, &config.scripts_dir).unwrap(); + + let val: i64 = lua.load("return loaded_value").eval().unwrap(); + assert_eq!(val, 99); + } +} diff --git a/crates/rust4d_scripting/src/vm.rs b/crates/rust4d_scripting/src/vm.rs new file mode 100644 index 0000000..bee9c09 --- /dev/null +++ b/crates/rust4d_scripting/src/vm.rs @@ -0,0 +1,140 @@ +//! Lua VM initialization and configuration + +use mlua::prelude::*; +use crate::error::ScriptError; + +/// Configuration for the scripting engine +pub struct ScriptConfig { + /// Root directory for game scripts + pub scripts_dir: String, + /// Whether to enable hot-reload file watching + pub hot_reload: bool, + /// Memory limit for the Lua VM in bytes (0 = unlimited) + pub memory_limit: usize, + /// Instruction count limit per call (0 = unlimited, for sandboxing) + pub instruction_limit: u32, +} + +impl Default for ScriptConfig { + fn default() -> Self { + Self { + scripts_dir: "scripts".to_string(), + hot_reload: cfg!(debug_assertions), + memory_limit: 64 * 1024 * 1024, // 64MB + instruction_limit: 0, + } + } +} + +/// Initialize a Lua VM with engine configuration. +/// +/// The VM is sandboxed: `os`, `io`, `loadfile`, and `dofile` are removed. +/// `print()` is replaced with a version that routes to `log::info!`. +/// `package.path` is configured to resolve `require()` from the scripts directory. +pub fn create_lua_vm(config: &ScriptConfig) -> Result { + let lua = Lua::new(); + + // Configure package.path for require() resolution + lua.load(format!( + r#"package.path = "{0}/?.lua;{0}/?/init.lua""#, + config.scripts_dir.replace('\\', "/") + )) + .exec() + .map_err(ScriptError::LuaError)?; + + // Remove dangerous standard library modules for sandboxing + let globals = lua.globals(); + globals.set("os", LuaNil).map_err(ScriptError::LuaError)?; + globals.set("io", LuaNil).map_err(ScriptError::LuaError)?; + globals + .set("loadfile", LuaNil) + .map_err(ScriptError::LuaError)?; + globals + .set("dofile", LuaNil) + .map_err(ScriptError::LuaError)?; + + // Replace print() with engine-aware version that routes to log::info + let print_fn = lua + .create_function(|_, args: LuaMultiValue| { + let parts: Vec = args + .iter() + .map(|v| match v { + LuaValue::Nil => "nil".to_string(), + LuaValue::Boolean(b) => b.to_string(), + LuaValue::Integer(n) => n.to_string(), + LuaValue::Number(n) => n.to_string(), + LuaValue::String(s) => { + match s.to_str() { + Ok(s) => s.to_string(), + Err(_) => "".to_string(), + } + } + other => format!("{:?}", other), + }) + .collect(); + log::info!("[lua] {}", parts.join("\t")); + Ok(()) + }) + .map_err(ScriptError::LuaError)?; + globals + .set("print", print_fn) + .map_err(ScriptError::LuaError)?; + + Ok(lua) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_vm_creates_with_default_config() { + let config = ScriptConfig::default(); + let lua = create_lua_vm(&config).expect("VM should create"); + // Verify we can execute basic Lua + lua.load("local x = 1 + 1").exec().unwrap(); + } + + #[test] + fn test_sandboxed_globals_removed() { + let config = ScriptConfig::default(); + let lua = create_lua_vm(&config).unwrap(); + let globals = lua.globals(); + + // These should be nil + assert!(globals.get::("os").unwrap() == LuaNil); + assert!(globals.get::("io").unwrap() == LuaNil); + assert!(globals.get::("loadfile").unwrap() == LuaNil); + assert!(globals.get::("dofile").unwrap() == LuaNil); + + // These should still exist + assert!(globals.get::("math").unwrap() != LuaNil); + assert!(globals.get::("string").unwrap() != LuaNil); + assert!(globals.get::("table").unwrap() != LuaNil); + assert!(globals.get::("coroutine").unwrap() != LuaNil); + } + + #[test] + fn test_print_does_not_panic() { + let config = ScriptConfig::default(); + let lua = create_lua_vm(&config).unwrap(); + // Should not panic even though log isn't fully initialized + lua.load(r#"print("hello", 42, true, nil)"#) + .exec() + .unwrap(); + } + + #[test] + fn test_package_path_configured() { + let config = ScriptConfig { + scripts_dir: "/tmp/test_scripts".to_string(), + ..Default::default() + }; + let lua = create_lua_vm(&config).unwrap(); + let path: String = lua + .load("return package.path") + .eval() + .unwrap(); + assert!(path.contains("/tmp/test_scripts/?.lua")); + } +} -- knotmirror 0.0.1