From 39310da705be6702faa6afd032df309566323bfa Mon Sep 17 00:00:00 2001 From: Jamie Wells Date: Mon, 9 Nov 2026 04:33:00 +0000 Subject: [PATCH 1/2] Classify JavaScript under ui as UI behavior --- .../crates/engine-play/src/script_runtime.rs | 74 ++++++++++++++++++- .../engine-scripting/src/agent_authoring.md | 2 + .../engine-scripting/src/external_runtime.rs | 52 ++++++++++++- .../crates/engine-scripting/src/manifest.rs | 48 +++++++++++- Engine/crates/engine-scripting/src/model.rs | 17 ++++- Engine/docs/Scripting/scriptingJavaScript.md | 6 ++ Engine/docs/Scripting/scriptingWeb.md | 61 ++++++++++++++- 7 files changed, 251 insertions(+), 9 deletions(-) diff --git a/Engine/crates/engine-play/src/script_runtime.rs b/Engine/crates/engine-play/src/script_runtime.rs index 8dc1bcea..c149daf2 100644 --- a/Engine/crates/engine-play/src/script_runtime.rs +++ b/Engine/crates/engine-play/src/script_runtime.rs @@ -99,7 +99,7 @@ impl RuntimeBehavior { Self::Web(old) => WebBehavior::load_shared( old.script_id(), source, - "hot-reload.html", + old.source_name(), Arc::clone(&host), 200_000, ) @@ -852,7 +852,11 @@ fn read_script_source( } Err(error) => return Err(format!("could not read {}: {error}", source_path.display())), }; - if entry.language == ScriptLanguage::Web { + if entry.language == ScriptLanguage::Web + && entry.relative_path.extension().is_some_and(|extension| { + extension.eq_ignore_ascii_case("html") || extension.eq_ignore_ascii_case("htm") + }) + { resolve_linked_web_scripts( snapshot_root, &entry.relative_path, @@ -5010,6 +5014,72 @@ rustic_run(["on_start"=>"on_start"]);"#), assert!(logs.lock().unwrap().is_empty()); } + #[test] + fn ui_javascript_receives_gameplay_signals_reads_scene_state_and_reloads() { + for path in ["ui/hud.js", "ui/nested/hud.mjs"] { + let temp = tempfile::tempdir().unwrap(); + std::fs::create_dir_all(temp.path().join("scripts")).unwrap(); + std::fs::create_dir_all(temp.path().join("ui/nested")).unwrap(); + std::fs::write( + temp.path().join("scripts/player.js"), + br"globalThis.behavior={ + FixedUpdate(){rustic.set_translation(3,0,0);Events.emit('score',[7]);} + };", + ) + .unwrap(); + let ui_source = br"globalThis.behavior={Start(){Events.on('score',score=>{ + const position=rustic.get_object_attribute('Player','Position'); + rustic.log('info',score+':'+position[0]); + });}};"; + std::fs::write(temp.path().join(path), ui_source).unwrap(); + let player_id = + engine_scripting::register_script_asset(temp.path(), "scripts/player.js").unwrap(); + let ui_id = engine_scripting::register_script_asset(temp.path(), path).unwrap(); + let manifest = + load_manifest(&std::fs::read(temp.path().join("config/scripts.ron")).unwrap()) + .unwrap() + .manifest; + assert_eq!( + manifest + .scripts + .iter() + .find(|e| e.id == ui_id) + .unwrap() + .language, + ScriptLanguage::Web + ); + let scene = SceneDocument { + environment: engine_world::SceneEnvironment::default(), + id: engine_core::SceneId::new(), + name: "UI bridge".into(), + startup_scripts: Vec::new(), + entities: vec![ + EntitySnapshot { + name: Some("Player".into()), + scripts: vec![ScriptComponent::new(player_id)], + ..EntitySnapshot::default() + }, + EntitySnapshot { + scripts: vec![ScriptComponent::new(ui_id)], + ..EntitySnapshot::default() + }, + ], + instances: Vec::new(), + }; + GameSettings::default().save(temp.path()).unwrap(); + let mut runtime = + RuntimeScripts::load(temp.path(), &scene.to_bytes().unwrap()).unwrap(); + runtime.fixed_update(0.016); + runtime.frame_update(0.016); + assert_eq!(runtime.drain_logs(), vec![("info".into(), "7:3".into())]); + assert!(runtime.reload(ui_id, b"globalThis.behavior={").is_err()); + runtime.reload(ui_id, ui_source).unwrap(); + runtime.fixed_update(0.016); + runtime.frame_update(0.016); + assert_eq!(runtime.drain_logs(), vec![("info".into(), "7:3".into())]); + } + } + #[test] fn linked_web_script_executes_on_its_attached_entity() { let temp = tempfile::tempdir().unwrap(); diff --git a/Engine/crates/engine-scripting/src/agent_authoring.md b/Engine/crates/engine-scripting/src/agent_authoring.md index f2a34ea1..b6c7e1ff 100644 --- a/Engine/crates/engine-scripting/src/agent_authoring.md +++ b/Engine/crates/engine-scripting/src/agent_authoring.md @@ -94,6 +94,8 @@ Use `object_duplicate` to copy an object/folder and its whole subtree with fresh ## Attach scripts at the correct scope +JavaScript `.js`/`.mjs` sources inside the top-level `ui/` folder (including subfolders) are automatically Web/UI assets; sources elsewhere remain gameplay JavaScript. UI scripts still require a global, scene or object attachment (or inclusion from an attached HTML entry). Avoid attaching an HTML helper twice. The same sandboxed scene APIs and `Events.on`/`Events.emit` work across UI and gameplay instances; share exposed object state or signal payloads rather than private JavaScript globals. This does not provide DOM rendering or browser/Node/module APIs. + Write the gameplay source to the project, then call `script_attach` with its relative `path` and `scope`. The tool registers the source's stable script asset ID; never hand-invent registry IDs. Scope belongs to an attachment, so one source may be attached in several places. Adding a new scope does not remove another attachment: detach the old one explicitly to move behavior and avoid duplicate execution. - `global`: game/session services such as progression, settings or shared state; listed in `settings.json` and persists across scene switches. Takes `enabled` and `execution_order` and saves immediately. diff --git a/Engine/crates/engine-scripting/src/external_runtime.rs b/Engine/crates/engine-scripting/src/external_runtime.rs index 0c211470..c839c567 100644 --- a/Engine/crates/engine-scripting/src/external_runtime.rs +++ b/Engine/crates/engine-scripting/src/external_runtime.rs @@ -372,13 +372,14 @@ impl ExternalBehavior { } } -/// HTML/CSS entry content with sandboxed inline JavaScript lifecycle callbacks. +/// UI content with sandboxed standalone or inline JavaScript lifecycle callbacks. pub struct WebBehavior { inner: JavaScriptBehavior, + source_name: String, } impl WebBehavior { - /// Validates the document and creates its sandboxed inline-script behavior. + /// Validates UI content and creates its sandboxed JavaScript behavior. /// /// # Errors /// Returns structural HTML/CSS or JavaScript diagnostics. @@ -412,7 +413,15 @@ impl WebBehavior { validate_web(source, source_name)?; let text = std::str::from_utf8(source) .map_err(|error| ExternalRuntimeError::InvalidUtf8("HTML/CSS", error.to_string()))?; - let script = extract_inline_scripts(text)?; + let script = if std::path::Path::new(source_name) + .extension() + .is_some_and(|extension| { + extension.eq_ignore_ascii_case("js") || extension.eq_ignore_ascii_case("mjs") + }) { + text.to_owned() + } else { + extract_inline_scripts(text)? + }; let script = if script.trim().is_empty() { "globalThis.behavior = {};".to_owned() } else { @@ -426,7 +435,14 @@ impl WebBehavior { instruction_budget, ) .map_err(web_javascript_error)?; - Ok(Self { inner }) + Ok(Self { + inner, + source_name: source_name.to_owned(), + }) + } + + pub fn source_name(&self) -> &str { + &self.source_name } pub const fn script_id(&self) -> ScriptId { @@ -959,6 +975,14 @@ fn validate_source_bytes( } fn validate_web(source: &[u8], source_name: &str) -> Result<(), ExternalRuntimeError> { + if std::path::Path::new(source_name) + .extension() + .is_some_and(|extension| { + extension.eq_ignore_ascii_case("js") || extension.eq_ignore_ascii_case("mjs") + }) + { + return validate_javascript(source, source_name, 1024 * 1024).map_err(web_javascript_error); + } let text = std::str::from_utf8(source) .map_err(|error| ExternalRuntimeError::InvalidUtf8("HTML/CSS", error.to_string()))?; let lower = text.to_ascii_lowercase(); @@ -1840,6 +1864,26 @@ for line in sys.stdin: } } + #[test] + fn standalone_ui_javascript_is_validated_without_html_parsing() { + validate_external( + ScriptLanguage::Web, + b"globalThis.behavior={Start(){print('');}};", + "ui/hud.js", + 1024, + ) + .unwrap(); + assert!( + validate_external( + ScriptLanguage::Web, + b"globalThis.behavior={", + "ui/hud.mjs", + 1024 + ) + .is_err() + ); + } + #[test] fn web_behavior_executes_inline_lifecycle() { let source = br""; diff --git a/Engine/crates/engine-scripting/src/manifest.rs b/Engine/crates/engine-scripting/src/manifest.rs index bd9fc778..7e578da9 100644 --- a/Engine/crates/engine-scripting/src/manifest.rs +++ b/Engine/crates/engine-scripting/src/manifest.rs @@ -193,7 +193,7 @@ pub fn load_manifest(bytes: &[u8]) -> Result { supported: CURRENT_SCRIPT_MANIFEST_VERSION, }); } - let migrated = probe.format_version == 1; + let mut migrated = probe.format_version == 1; let mut manifest = if migrated { let old: ManifestV1 = ron::from_str(text).map_err(|e| ManifestError::Parse(e.to_string()))?; @@ -214,6 +214,16 @@ pub fn load_manifest(bytes: &[u8]) -> Result { } else { ron::from_str(text).map_err(|e| ManifestError::Parse(e.to_string()))? }; + // Preserve existing UI script identities and attachments while adopting the + // folder-based default for registries written before UI JavaScript support. + for entry in &mut manifest.scripts { + if entry.language == ScriptLanguage::JavaScript + && ScriptLanguage::from_path(&entry.relative_path) == Some(ScriptLanguage::Web) + { + entry.language = ScriptLanguage::Web; + migrated = true; + } + } if let Some(entry) = manifest.scripts.iter().find(|entry| { !safe_relative(&entry.relative_path) || !entry.language.accepts_project_path(&entry.relative_path) @@ -318,6 +328,42 @@ mod tests { Err(ManifestError::FutureVersion { .. }) )); } + #[test] + fn legacy_ui_javascript_adopts_web_category_without_changing_identity() { + let id = ScriptId::new(); + let source = format!( + r#"(format_version:2,scripts:[(id:"{id}",language:java_script,relative_path:"ui/hud.js",api_version:(major:1,minor:0))])"# + ); + let loaded = load_manifest(source.as_bytes()).unwrap(); + assert!(loaded.migrated); + assert_eq!(loaded.manifest.scripts[0].id, id); + assert_eq!(loaded.manifest.scripts[0].language, ScriptLanguage::Web); + } + + #[test] + fn moving_javascript_between_ui_and_gameplay_preserves_identity() { + let temp = tempfile::tempdir().unwrap(); + std::fs::create_dir_all(temp.path().join("scripts")).unwrap(); + std::fs::write( + temp.path().join("scripts/hud.js"), + "globalThis.behavior={};", + ) + .unwrap(); + let id = register_script_asset(temp.path(), "scripts/hud.js").unwrap(); + for (path, language) in [ + ("ui/nested/hud.js", ScriptLanguage::Web), + ("scripts/hud.js", ScriptLanguage::JavaScript), + ] { + move_script_asset(temp.path(), id, path).unwrap(); + let loaded = + load_manifest(&std::fs::read(temp.path().join("config/scripts.ron")).unwrap()) + .unwrap(); + assert_eq!(loaded.manifest.scripts[0].id, id); + assert_eq!(loaded.manifest.scripts[0].language, language); + assert!(temp.path().join(path).is_file()); + } + } + #[test] fn duplicates_are_quarantined_and_traversal_is_rejected_on_save() { let id = ScriptId::new(); diff --git a/Engine/crates/engine-scripting/src/model.rs b/Engine/crates/engine-scripting/src/model.rs index 1f318f82..e0f86462 100644 --- a/Engine/crates/engine-scripting/src/model.rs +++ b/Engine/crates/engine-scripting/src/model.rs @@ -141,7 +141,7 @@ impl ScriptLanguage { Self::CSharp => "C#", Self::Java => "Java", Self::Php => "PHP", - Self::Web => "HTML/CSS", + Self::Web => "HTML/CSS/JavaScript", } } @@ -150,6 +150,7 @@ impl ScriptLanguage { match extension.as_str() { "lua" => Some(Self::Lua54), "luau" => Some(Self::Luau), + "js" | "mjs" if path.starts_with("ui") => Some(Self::Web), "js" | "mjs" => Some(Self::JavaScript), "py" => Some(Self::Python), "c" => Some(Self::C), @@ -367,6 +368,20 @@ mod tests { #[test] fn web_languages_are_confined_to_the_ui_directory() { assert!(ScriptLanguage::Web.accepts_project_path(Path::new("ui/hud.html"))); + for path in ["ui/hud.js", "ui/nested/hud.mjs", "ui/hud.JS"] { + assert_eq!( + ScriptLanguage::from_path(Path::new(path)), + Some(ScriptLanguage::Web) + ); + assert!(ScriptLanguage::Web.accepts_project_path(Path::new(path))); + assert!(!ScriptLanguage::JavaScript.accepts_project_path(Path::new(path))); + } + for path in ["scripts/ui/hud.js", "ui-other/hud.js", "hud.js"] { + assert_eq!( + ScriptLanguage::from_path(Path::new(path)), + Some(ScriptLanguage::JavaScript) + ); + } assert!(ScriptLanguage::Web.accepts_project_path(Path::new("ui/theme.css"))); assert!(ScriptLanguage::Php.accepts_project_path(Path::new("ui/menu.php"))); assert!(!ScriptLanguage::Web.accepts_project_path(Path::new("scripts/hud.html"))); diff --git a/Engine/docs/Scripting/scriptingJavaScript.md b/Engine/docs/Scripting/scriptingJavaScript.md index e4140c1f..c4c26ffb 100644 --- a/Engine/docs/Scripting/scriptingJavaScript.md +++ b/Engine/docs/Scripting/scriptingJavaScript.md @@ -5,6 +5,12 @@ JavaScript is bundled and sandboxed. A script assigns one behavior object to APIs. Create `.js` or `.mjs` gameplay files with the editor so an Asset ID and the chosen global, scene, or component attachment are created automatically. +JavaScript under the project's top-level `ui/` folder, including subfolders, +automatically uses the Web/UI category. It keeps the same lifecycle, scene access +and `Events` APIs. Attachments still determine when it runs. See +[UI JavaScript and gameplay signals](scriptingWeb.md#javascript-in-ui) for setup, +a working example and runtime limitations. + ## Behavior lifecycle ```javascript diff --git a/Engine/docs/Scripting/scriptingWeb.md b/Engine/docs/Scripting/scriptingWeb.md index 043eecf6..d6672d12 100644 --- a/Engine/docs/Scripting/scriptingWeb.md +++ b/Engine/docs/Scripting/scriptingWeb.md @@ -2,7 +2,8 @@ Web assets are `.html`, `.htm`, or `.css` files under `ui/`. The directory rule is enforced: they are not accepted as gameplay assets elsewhere. Rustic structurally -validates the document and executes inline `` in an attached HTML +entry runs within that entry; attach it separately only if you want another instance. + +UI scripts keep the same sandboxed JavaScript APIs as gameplay scripts. They can +read scene state exposed by gameplay objects and receive or emit `Events` signals. +Each behavior has its own JavaScript globals; another script's private variables +are not directly accessible. Publish the values needed by the UI through signals +or engine-owned object state. + +For example, create `scripts/player.js` and attach it to an object named `Player`: + +```javascript +globalThis.behavior = { + FixedUpdate() { + rustic.set_translation(3, 0, 0); + Events.emit("ScoreChanged", [7]); + } +}; +``` + +Create `ui/hud.js` and attach it as a scene startup script: + +```javascript +globalThis.behavior = { + Start() { + Events.on("ScoreChanged", score => { + const position = rustic.get_object_attribute("Player", "Position"); + rustic.log("info", `HUD score: ${score}; player x: ${position[0]}`); + }); + } +}; +``` + +Play logs show `HUD score: 7; player x: 3`. Signals deliver during frame updates; +a listener must be connected before emission, so publish current state after +subscribing or read engine-owned state to initialize a HUD. If nothing runs, +check the attachment, enabled state, active scene and script diagnostics. The +folder alone does not start a script. Use the exact top-level `ui/` name; +`scripts/ui/` remains gameplay code. + +UI JavaScript still uses the bundled behavior runtime: `document`, DOM updates, +browser globals, Node APIs and ES module imports are unavailable. `.mjs` uses +the same behavior format as `.js`. Use the supported engine APIs to consume +state; this classification does not add a rendered HTML HUD. From 1f48f963b39bb88f2c6828b4732830119d6e0ccc Mon Sep 17 00:00:00 2001 From: Jamie Wells Date: Mon, 9 Nov 2026 04:39:54 +0000 Subject: [PATCH 2/2] Update yanked async-executor dependency --- Engine/Cargo.lock | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Engine/Cargo.lock b/Engine/Cargo.lock index 0b37dc84..81a62c5f 100644 --- a/Engine/Cargo.lock +++ b/Engine/Cargo.lock @@ -164,9 +164,9 @@ dependencies = [ [[package]] name = "async-executor" -version = "1.14.0" +version = "1.14.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c96bf972d85afc50bf5ab8fe2d54d1586b4e0b46c97c50a0c9e71e2f7bcd812a" +checksum = "f426a31126c735126297bc5ea7a521263c9faff4f63ca415267ee625651c4f37" dependencies = [ "async-task", "concurrent-queue",