diff --git a/airdef-mod/src/capabilities.rs b/airdef-mod/src/capabilities.rs new file mode 100644 index 0000000..5d860f0 --- /dev/null +++ b/airdef-mod/src/capabilities.rs @@ -0,0 +1,147 @@ +//! Host milestones, not promises of audible output or completed GPU/file work. +use horizon_mod_sdk::render::{self, CapabilityReason, CapabilityState}; + +pub fn ready(s: &CapabilityState) -> bool { + s.generation != 0 + && s.available + && s.enabled + && s.request_accepted + && s.prepared + && s.reason == CapabilityReason::Ready +} + +pub fn capture_problem(s: &CapabilityState) -> Option<&'static str> { + if !s.available { + Some("Движок не поддерживает подтверждение захвата") + } else if !s.enabled || s.generation == 0 { + Some("Захват недоступен без текущего мира") + } else if !s.request_accepted { + Some("Захват больше не принадлежит этой сессии мода") + } else if s.reason == CapabilityReason::Failed { + Some("Ошибка пути захвата движка") + } else { + None + } +} + +/// Adopt the existing global preference once in this world, without switching it. +/// A configured request and actual backend preparation are still separate milestones. +pub fn acoustics() -> CapabilityState { + let s = render::acoustics_status(); + if s.available && s.generation != 0 && !s.request_accepted { + render::set_acoustics(s.enabled); + render::acoustics_status() + } else { + s + } +} + +fn reason(r: CapabilityReason) -> &'static str { + match r { + CapabilityReason::AwaitingWorld => "awaiting-world", + CapabilityReason::Disabled => "disabled", + CapabilityReason::NotRequested => "not-requested", + CapabilityReason::AwaitingBackend => "awaiting-backend", + CapabilityReason::Failed => "failed", + CapabilityReason::Ready => "ready", + } +} + +fn line( + name: &str, + generation: u64, + available: bool, + enabled: bool, + accepted: bool, + prepared: bool, + why: &str, +) -> String { + format!("{name}: API={available}, включено={enabled}, запрос={accepted}, подготовлено={prepared}, поколение={generation}, {why}") +} + +pub fn diagnostics() -> String { + let smoke = render::smoke_status(); + let acoustic = acoustics(); + let capture = render::capture_owner_status(); + let smoke_reason = match smoke.reason { + render::SmokeReason::AwaitingFrame => "awaiting-frame", + render::SmokeReason::Disabled => "disabled", + render::SmokeReason::PathUnavailable => "path-unavailable", + render::SmokeReason::Ready => "ready", + }; + [ + "Возможности движка (API 27; подготовка не означает готовый GPU-кадр или файл):".into(), + line( + "Дым", + smoke.generation, + smoke.available, + smoke.enabled, + smoke.request_accepted, + smoke.backend_ready, + smoke_reason, + ), + line( + "Акустика", + acoustic.generation, + acoustic.available, + acoustic.enabled, + acoustic.request_accepted, + acoustic.prepared, + reason(acoustic.reason), + ), + line( + "Захват этого мода", + capture.generation, + capture.available, + capture.enabled, + capture.request_accepted, + capture.prepared, + reason(capture.reason), + ), + ] + .join("\n") +} + +#[cfg(test)] +mod tests { + use super::*; + fn state() -> CapabilityState { + CapabilityState { + generation: 5, + available: true, + enabled: true, + request_accepted: true, + prepared: true, + reason: CapabilityReason::Ready, + } + } + #[test] + fn acceptance_alone_never_disables_the_working_fallback() { + assert!(ready(&state())); + for missing in 0..6 { + let mut s = state(); + match missing { + 0 => s.generation = 0, + 1 => s.available = false, + 2 => s.enabled = false, + 3 => s.request_accepted = false, + 4 => s.prepared = false, + _ => s.reason = CapabilityReason::AwaitingBackend, + } + assert!(!ready(&s)); + } + } + #[test] + fn a_foreign_capture_is_not_success_and_preparing_is_not_failure() { + let mut s = state(); + s.request_accepted = false; + assert!(capture_problem(&s).is_some()); + s = state(); + s.prepared = false; + s.reason = CapabilityReason::AwaitingBackend; + assert!(capture_problem(&s).is_none()); + assert!(!ready(&s)); + s.reason = CapabilityReason::Failed; + assert!(capture_problem(&s).is_some()); + } +} diff --git a/airdef-mod/src/commands.rs b/airdef-mod/src/commands.rs index 1ed50f9..e8c7aaf 100644 --- a/airdef-mod/src/commands.rs +++ b/airdef-mod/src/commands.rs @@ -19,6 +19,7 @@ pub fn tree() -> Tree { let toggle = |word: &str| Node::lit(word).then(Node::arg("on/off", on_off()).runs()); Tree::new("airdef").runs().with([ Node::lit("menu").runs(), + Node::lit("capabilities").runs(), Node::lit("monitor").runs(), Node::lit("grid").runs(), Node::lit("replay").runs().with([ @@ -161,7 +162,7 @@ mod tests { #[test] fn every_branch_of_the_handler_parses() { for line in [ - "", "menu", "monitor", "grid", "give", "raid", "repair", "import", "flare", "clear", "stats", "roles", + "", "menu", "capabilities", "monitor", "grid", "give", "raid", "repair", "import", "flare", "clear", "stats", "roles", "replay", "replay map", "replay full", "replay reel", "replay film", "replay film best", "replay film all", "replay film 3", "replay export", "replay export mine", "replay export best", "replay export all", "replay export 3", "replay export mine all fps=30 size=1280x720 crf=18 sec=12.5 noaudio", "replay export fps=60", "replay stop", @@ -243,7 +244,7 @@ mod tests { "target", "give", "deploy", "wave", "clear", "targets", "damage", "truth", "shader", "hold", "lastchance", "roles", "doctrine", "stats", "sites", "strike", "ground-stock", "ground-load", "ground-unload", "ground-cancel"] { assert!(top.contains(&w.to_string()), "{w} in {top:?}"); } - assert_eq!(top.len(), 34); + assert_eq!(top.len(), 35); assert_eq!(complete("rep", &[]), ["repair", "replay"]); assert_eq!(complete("replay ", &[]), ["export", "film", "full", "map", "reel", "stop"]); assert_eq!(complete("replay film ", &[]), ["all", "best"]); @@ -279,7 +280,7 @@ mod tests { #[test] fn the_help_line_is_the_trees() { let help = help(); - assert!(help.starts_with("/airdef menu | monitor | grid | replay "), "{help}"); + assert!(help.starts_with("/airdef menu | capabilities | monitor | grid | replay "), "{help}"); assert!(help.contains("scenario save <имя> [fresh]"), "{help}"); assert!(help.contains("wave <тип> [<кол-во> [<дальность>]]"), "{help}"); assert!(help.ends_with("| stats"), "{help}"); diff --git a/airdef-mod/src/lib.rs b/airdef-mod/src/lib.rs index 2d28f89..2255f57 100644 --- a/airdef-mod/src/lib.rs +++ b/airdef-mod/src/lib.rs @@ -13,6 +13,7 @@ use horizon_mod_sdk::{ }; mod cinema; +mod capabilities; mod engines; mod commands; mod layout; @@ -810,7 +811,7 @@ impl AirDef { let mut booms: Vec<(f64, usize, DVec3, f32, f32)> = Vec::new(); // The game muffles by walls and distance itself (#96): the near variants play out to the // reach of its tracing, so nothing is muffled twice; past it the far ones still do. - let far = if render::acoustics() { ACOUSTIC_FAR } else { FAR }; + let far = if capabilities::ready(&capabilities::acoustics()) { ACOUSTIC_FAR } else { FAR }; for (event, at, volume, pitch) in out.sounds { let dist = at.distance(ctx.listener); if let Some(power) = cinema::blast_power(event) { @@ -1356,6 +1357,21 @@ impl AirDef { } ex.ticks += 1; let st = render::capture_status(); + let owner = render::capture_owner_status(); + if ex.stage != Stage::Planning { + if let Some(problem) = capabilities::capture_problem(&owner) { + let message = if st.message.is_empty() { problem.to_string() } else { format!("{problem}: {}", st.message) }; + say(&format!("Экспорт не удался: {message}")); + r.done = true; + return Some(Err(message)); + } + if ex.stage == Stage::Arming && ex.ticks > 600 { + let message = "Движок не подтвердил подготовку захвата за 600 тиков".to_string(); + say(&message); + r.done = true; + return Some(Err(message)); + } + } let failed = |s: &render::CaptureState| s.phase == render::CapturePhase::Failed; let mut finish: Option> = None; match ex.stage { @@ -1375,12 +1391,13 @@ impl AirDef { Ok(()) => { say(&format!("Кадры готовы ({}); запись…", r.film_summary())); ex.stage = Stage::Arming; + ex.ticks = 0; } Err(e) => finish = Some(Err(e)), } } Stage::Arming => { - if st.phase == render::CapturePhase::Recording { + if st.phase == render::CapturePhase::Recording && capabilities::ready(&owner) { r.wait = false; ex.stage = Stage::Recording; ex.ticks = 0; @@ -1400,7 +1417,7 @@ impl AirDef { } } Stage::Finishing => match st.phase { - render::CapturePhase::Done => { + render::CapturePhase::Done if capabilities::ready(&owner) => { let note = if st.message.is_empty() { String::new() } else { format!(" ({})", st.message) }; finish = Some(Ok(format!("{}x{}, {} кадров: {}{note}", st.width, st.height, st.frames, st.file))); } @@ -2268,6 +2285,7 @@ impl Mod for AirDef { self.open_menu(Page::Main); None } + ["capabilities"] => Some(capabilities::diagnostics()), ["monitor"] => { self.cycle_monitor(); None @@ -2597,6 +2615,22 @@ mod tests { } } + #[test] + fn a_return_reservation_lost_with_the_launcher_keeps_a_loadable_save() { + let mut game = test_mod(); + let mut sam = Sam::new(100, SamKind::S400, DVec3::ZERO, 0.0); + sam.ground_rounds = 1; + sam.missiles = 3; + sam.ground_return = true; + sam.comps.0[sim::Part::Launcher as usize] = 0.0; + sam.fit_cells(); + game.sim.sams.push(sam); + let mut restored = test_mod(); + restored.apply_state(&game.state_text(false, false)).unwrap(); + assert_eq!(restored.sim.sams[0].ground_rounds, 0); + assert!(!restored.sim.sams[0].ground_return); + } + #[test] fn return_round_drives_and_survives_both_state_round_trips() { struct Flat; diff --git a/airdef-mod/src/sim/damage.rs b/airdef-mod/src/sim/damage.rs index f180501..25525d7 100644 --- a/airdef-mod/src/sim/damage.rs +++ b/airdef-mod/src/sim/damage.rs @@ -471,6 +471,7 @@ impl Sam { if self.kind.spec().missile.is_some() { self.ground_rounds = self.ground_rounds.min(self.cells()); self.missiles = self.missiles.min(self.cells().saturating_sub(self.ground_rounds)); + if self.ground_rounds == 0 { self.ground_return = false; } } } /// Ticks a reload takes: longer with a damaged launcher. diff --git a/airdef-mod/tests/wasm_contract.toml b/airdef-mod/tests/wasm_contract.toml index c7d6402..68dda80 100644 --- a/airdef-mod/tests/wasm_contract.toml +++ b/airdef-mod/tests/wasm_contract.toml @@ -5,12 +5,19 @@ ticks = 2 frames = 2 expect.commands = ["airdef"] +[[step]] +command = "airdef capabilities" + [[step]] command = "airdef deploy patriot" [[step]] command = "airdef deploy s400" +[[step]] +command = "airdef ground-cancel 2" +expect.error = "Нет ожидающей заявки RM48U" + # Exercise the typed inventory through the actual WASM command boundary. [[step]] command = "airdef ground-load 2" diff --git a/horizon b/horizon index 86b1014..c89c78c 160000 --- a/horizon +++ b/horizon @@ -1 +1 @@ -Subproject commit 86b1014a9218e68041444cbf52db02a85107c815 +Subproject commit c89c78c47047fcfc0dfb0b8968600c650bf12d2b diff --git a/tools/airdef.py b/tools/airdef.py index 066f89b..69c1b6f 100644 --- a/tools/airdef.py +++ b/tools/airdef.py @@ -134,8 +134,8 @@ def mod(): "engine": engine_identity(), "engine_pin": engine_commit(), "wit_sha256": hashlib.sha256((FORK / "crates/horizon-mod-sdk/wit/horizon.wit").read_bytes()).hexdigest(), "artifact_sha256": hashlib.sha256(artifact.read_bytes()).hexdigest(), - "airdef_dirty": bool(subprocess.check_output(["git", "-c", "submodule.horizon.ignore=all", "status", "--porcelain", "--untracked-files=no"], cwd=ROOT)), - "airdef_diff_sha256": hashlib.sha256(subprocess.check_output(["git", "-c", "submodule.horizon.ignore=all", "diff", "--binary", "HEAD"], cwd=ROOT)).hexdigest()} + "airdef_dirty": bool(subprocess.check_output(["git", "status", "--porcelain", "--untracked-files=no"], cwd=ROOT)), + "airdef_diff_sha256": hashlib.sha256(subprocess.check_output(["git", "diff", "--binary", "HEAD"], cwd=ROOT)).hexdigest()} artifact.with_name("build-identity.json").write_text(json.dumps(identity, indent=2) + "\n") @@ -176,7 +176,7 @@ def test_budgets(): } def git(*args, text=True): - return subprocess.check_output(["git", "-c", "submodule.horizon.ignore=all", *args], cwd=ROOT, + return subprocess.check_output(["git", *args], cwd=ROOT, text=text, stderr=subprocess.DEVNULL) try: