diff --git a/.github/workflows/quality.yml b/.github/workflows/quality.yml index 1e0d7a3b..4f3ea529 100644 --- a/.github/workflows/quality.yml +++ b/.github/workflows/quality.yml @@ -73,6 +73,12 @@ jobs: if: ${{ !cancelled() }} run: | rustic-luau-host --version + # External adapter tests compile and launch several native processes. + # Avoid competing for Windows runner resources within the fixed + # three-second callback deadline; Linux retains parallel coverage. + if [[ "$RUNNER_OS" == "Windows" ]]; then + export RUST_TEST_THREADS=1 + fi cargo test --locked --workspace cargo test --locked --manifest-path apps/luau-host/Cargo.toml - name: Verify rendered lighting and viewport diff --git a/Engine/apps/editor/src/play_session.rs b/Engine/apps/editor/src/play_session.rs index 2c559948..0cd0ebda 100644 --- a/Engine/apps/editor/src/play_session.rs +++ b/Engine/apps/editor/src/play_session.rs @@ -50,8 +50,13 @@ impl EditorPlaySession { programming.push(SnapshotInput::new("config/scripts.ron", manifest_bytes)); for entry in manifest.manifest.scripts { let path = document.project().root().join(&entry.relative_path); - let bytes = std::fs::read(&path) - .map_err(|error| format!("could not read {}: {error}", path.display()))?; + let bytes = match std::fs::read(&path) { + Ok(bytes) => bytes, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => continue, + Err(error) => { + return Err(format!("could not read {}: {error}", path.display())); + } + }; validate_script( entry.language, &bytes, @@ -177,8 +182,18 @@ impl EditorPlaySession { let manifest = load_manifest(&bytes).map_err(|error| error.to_string())?; let mut outcomes = Vec::new(); for entry in manifest.manifest.scripts { - let source = std::fs::read(project_root.join(&entry.relative_path)) - .map_err(|error| error.to_string())?; + let source = match std::fs::read(project_root.join(&entry.relative_path)) { + Ok(source) => source, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => { + outcomes.push(format!( + "script {} source {} is missing; reload skipped", + entry.id, + entry.relative_path.display() + )); + continue; + } + Err(error) => return Err(error.to_string()), + }; validate_script( entry.language, &source, diff --git a/Engine/crates/engine-play/src/script_runtime.rs b/Engine/crates/engine-play/src/script_runtime.rs index 904629ed..426ed82d 100644 --- a/Engine/crates/engine-play/src/script_runtime.rs +++ b/Engine/crates/engine-play/src/script_runtime.rs @@ -396,20 +396,22 @@ impl RuntimeScripts { if !component.enabled { continue; } - let entry = entries.get(&component.script_id).ok_or_else(|| { - format!( - "entity {} references missing script {}", - entity.id, component.script_id - ) - })?; - let source_path = snapshot_root.join(&entry.relative_path); - let mut source = std::fs::read(&source_path).map_err(|error| { - format!("could not read {}: {error}", source_path.display()) - })?; - if entry.language == ScriptLanguage::Web { - source = - resolve_linked_web_scripts(snapshot_root, &entry.relative_path, &source)?; - } + let Some(entry) = entries.get(&component.script_id) else { + push_log( + &logs, + &dropped_logs, + "warn", + &format!( + "entity {} references missing script {}; skipped for this play session", + entity.id, component.script_id + ), + ); + continue; + }; + let Some(source) = read_script_source(snapshot_root, entry, &logs, &dropped_logs)? + else { + continue; + }; let host = RuntimeHost { gameplay: Arc::clone(&gameplay), owner_script: ScriptId::new(), @@ -744,18 +746,21 @@ fn load_startup_references( .next() .unwrap_or_else(EntityId::new); for reference in references.iter().filter(|reference| reference.enabled) { - let entry = entries.get(&reference.asset_id).ok_or_else(|| { - format!( - "startup scope references missing script {}", - reference.asset_id - ) - })?; - let source_path = snapshot_root.join(&entry.relative_path); - let mut source = std::fs::read(&source_path) - .map_err(|error| format!("could not read {}: {error}", source_path.display()))?; - if entry.language == ScriptLanguage::Web { - source = resolve_linked_web_scripts(snapshot_root, &entry.relative_path, &source)?; - } + let Some(entry) = entries.get(&reference.asset_id) else { + push_log( + logs, + dropped_logs, + "warn", + &format!( + "startup scope references missing script {}; skipped for this play session", + reference.asset_id + ), + ); + continue; + }; + let Some(source) = read_script_source(snapshot_root, entry, logs, dropped_logs)? else { + continue; + }; let host = RuntimeHost { gameplay: Arc::clone(gameplay), owner_script: ScriptId::new(), @@ -783,10 +788,50 @@ fn load_startup_references( Ok(()) } +fn read_script_source( + snapshot_root: &Path, + entry: &engine_scripting::ScriptManifestEntry, + logs: &Arc>>, + dropped_logs: &Arc>, +) -> Result>, String> { + let source_path = snapshot_root.join(&entry.relative_path); + let source = match std::fs::read(&source_path) { + Ok(source) => source, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => { + push_log( + logs, + dropped_logs, + "warn", + &format!( + "script {} source {} is missing; skipped for this play session", + entry.id, + entry.relative_path.display() + ), + ); + return Ok(None); + } + Err(error) => return Err(format!("could not read {}: {error}", source_path.display())), + }; + if entry.language == ScriptLanguage::Web { + resolve_linked_web_scripts( + snapshot_root, + &entry.relative_path, + &source, + logs, + dropped_logs, + ) + .map(Some) + } else { + Ok(Some(source)) + } +} + fn resolve_linked_web_scripts( snapshot_root: &Path, document_path: &Path, source: &[u8], + logs: &Arc>>, + dropped_logs: &Arc>, ) -> Result, String> { let text = std::str::from_utf8(source).map_err(|error| error.to_string())?; let lower = text.to_ascii_lowercase(); @@ -820,13 +865,26 @@ fn resolve_linked_web_scripts( )); } let linked_path = snapshot_root.join(&relative); - let linked = std::fs::read_to_string(&linked_path).map_err(|error| { - format!( - "could not read linked script {}: {error}", - linked_path.display() - ) - })?; - output.push_str(&linked); + match std::fs::read_to_string(&linked_path) { + Ok(linked) => output.push_str(&linked), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => { + push_log( + logs, + dropped_logs, + "warn", + &format!( + "linked script {} is missing; skipped for this play session", + relative.display() + ), + ); + } + Err(error) => { + return Err(format!( + "could not read linked script {}: {error}", + linked_path.display() + )); + } + } } else { output.push_str(&text[tag_end + 1..close]); } @@ -3942,6 +4000,123 @@ rustic_run(["on_start"=>"on_start"]);"#), ); } + #[test] + fn missing_scripts_are_skipped_in_every_scope_without_retries() { + for scope in ["global", "scene", "object"] { + for missing_entry in [false, true] { + let temp = tempfile::tempdir().unwrap(); + std::fs::create_dir_all(temp.path().join("config")).unwrap(); + let missing = ScriptId::new(); + let working = ScriptId::new(); + let references = vec![ScriptReference::new(missing), ScriptReference::new(working)]; + let mut actor = EntitySnapshot::default(); + if scope == "object" { + actor.scripts = + vec![ScriptComponent::new(missing), ScriptComponent::new(working)]; + } + let scene = SceneDocument { + environment: engine_world::SceneEnvironment::default(), + id: engine_core::SceneId::new(), + name: "Missing scripts".into(), + startup_scripts: if scope == "scene" { + references.clone() + } else { + Vec::new() + }, + entities: vec![actor], + instances: Vec::new(), + }; + let mut settings = GameSettings::default(); + if scope == "global" { + settings.global_scripts = references; + } + settings.save(temp.path()).unwrap(); + let mut entries = vec![ScriptManifestEntry { + id: working, + language: ScriptLanguage::Lua54, + relative_path: "working.lua".into(), + api_version: ScriptApiVersion::CURRENT, + public_properties: Vec::new(), + }]; + if !missing_entry { + entries.push(ScriptManifestEntry { + id: missing, + relative_path: "missing.lua".into(), + ..entries[0].clone() + }); + } + save_manifest_atomic( + &temp.path().join("config/scripts.ron"), + &ScriptManifest { + format_version: 2, + scripts: entries, + }, + ) + .unwrap(); + std::fs::write( + temp.path().join("working.lua"), + b"return { fixed_update=function(dt) rustic.log('info', 'still running') end }", + ) + .unwrap(); + let mut runtime = + RuntimeScripts::load(temp.path(), &scene.to_bytes().unwrap()).unwrap(); + assert_eq!( + runtime.behaviors.len(), + 1, + "{scope}: missing entry={missing_entry}" + ); + let warnings = runtime.drain_logs(); + assert_eq!(warnings.len(), 1); + assert_eq!(warnings[0].0, "warn"); + assert!(warnings[0].1.contains(&missing.to_string())); + assert!(warnings[0].1.contains("skipped for this play session")); + // Restoring the source does not trigger a retry during this session. + std::fs::write(temp.path().join("missing.lua"), b"return {}").unwrap(); + for _ in 0..3 { + runtime.fixed_update(0.016); + runtime.frame_update(0.016); + } + let logs = runtime.drain_logs(); + assert_eq!(logs.len(), 3); + assert!( + logs.iter() + .all(|(level, message)| level == "info" && message == "still running") + ); + assert_eq!(runtime.behaviors.len(), 1); + } + } + } + + #[test] + fn linked_web_script_errors_other_than_missing_files_still_fail() { + let temp = tempfile::tempdir().unwrap(); + let logs = Arc::new(Mutex::new(VecDeque::new())); + let dropped_logs = Arc::new(Mutex::new(0)); + std::fs::write(temp.path().join("invalid.js"), [0xff]).unwrap(); + std::fs::create_dir(temp.path().join("directory.js")).unwrap(); + let absolute = temp.path().join("absolute.js"); + for link in [ + "../outside.js", + absolute.to_str().unwrap(), + "invalid.js", + "directory.js", + ] { + let source = format!(""); + assert!( + resolve_linked_web_scripts( + temp.path(), + Path::new("index.html"), + source.as_bytes(), + &logs, + &dropped_logs, + ) + .is_err(), + "{link} should not be treated as a missing script" + ); + } + assert!(logs.lock().unwrap().is_empty()); + } + #[test] fn linked_web_script_executes_on_its_attached_entity() { let temp = tempfile::tempdir().unwrap(); @@ -3977,16 +4152,32 @@ rustic_run(["on_start"=>"on_start"]);"#), GameSettings::default().save(temp.path()).unwrap(); std::fs::write( temp.path().join("ui/index.html"), - br#""#, + br#""#, ) .unwrap(); std::fs::write( temp.path().join("ui/behavior.js"), - b"globalThis.behavior={Start(){rustic.set_translation(9,0,0);}};", + b"globalThis.behavior={Start(){rustic.set_translation(9,0,0);},FixedUpdate(dt){rustic.log('info', 'linked ran');}};", ) .unwrap(); - let runtime = RuntimeScripts::load(temp.path(), &scene.to_bytes().unwrap()).unwrap(); + let mut runtime = RuntimeScripts::load(temp.path(), &scene.to_bytes().unwrap()).unwrap(); + let logs = runtime.drain_logs(); + assert_eq!(logs.len(), 2); + assert_eq!(logs[0].0, "warn"); + assert!( + logs[0] + .1 + .contains(&Path::new("ui").join("missing.js").display().to_string()) + ); + assert_eq!(logs[1], ("info".into(), "inline ran".into())); + for _ in 0..3 { + runtime.fixed_update(0.016); + } + assert_eq!( + runtime.drain_logs(), + vec![("info".into(), "linked ran".into()); 3] + ); let changes = runtime .runtime_changes("base", &scene.to_bytes().unwrap()) .unwrap(); diff --git a/Engine/docs/Scripting/README.md b/Engine/docs/Scripting/README.md index 9e066fe8..57b4b836 100644 --- a/Engine/docs/Scripting/README.md +++ b/Engine/docs/Scripting/README.md @@ -20,6 +20,33 @@ Every gameplay language uses engine-owned built-in functions. Scripts define callbacks and call the API; no user-written JSON request/response loop is needed. CSS is styling only; use inline JavaScript in HTML for gameplay API calls. +## Missing scripts during Play + +Press **Play** after creating or attaching scripts through **Programming > New +Script**, **Programming > Attach Existing Script**, or **+ Add Component** in the +Inspector. If an enabled global startup, scene startup, or object component script +has a missing Asset ID in the script manifest or its source file has been deleted, +Play skips that attachment and continues running the scene and the remaining +scripts. This also applies to automatically added startup scripts. Missing scripts +are not retried during the same play session. + +The runtime log shows a warning naming the missing script ID or source path and +`skipped for this play session`. To recover, stop Play, restore the file at the +reported path or remove/replace the attachment through the editor, then press +Play again. Do not edit serialized scene or manifest files to repair attachments. +Manual script reload skips missing source files and continues reloading available +files; an already running instance keeps its last good code. + +For HTML assets, a missing quoted `` followed by an inline behavior +continues with the inline behavior even when `missing.js` does not exist. Keep +linked paths relative to the HTML asset, in its directory or a child directory. + +This recovery applies to missing scripts only. Invalid settings or manifests, +unreadable files for other reasons, syntax errors, and linked paths containing +`..` or absolute paths still fail existing validation. + ## Rules shared by every language Create, move, rename, and attach scripts in the editor. Never edit diff --git a/Engine/docs/Scripting/scriptingWeb.md b/Engine/docs/Scripting/scriptingWeb.md index b5a2a9f1..002142d6 100644 --- a/Engine/docs/Scripting/scriptingWeb.md +++ b/Engine/docs/Scripting/scriptingWeb.md @@ -99,3 +99,7 @@ API 1.1 exposes shared-core easing, tweens, movement, skeletal/keyframe/procedur animation, timelines, timers, paths, cameras, physics, effects, audio and signals. See [Shared gameplay actions](gameplayActions.md) for attachment, native call conventions, duration/speed options, callbacks, scene-clock controls and backend limits. + +A missing linked script is skipped with a runtime warning for that play session. +Remaining linked and inline scripts continue running. Restore the file, stop Play, +and start Play again to load it. Missing files are not retried during the session.