feat: consume scoped engine capabilities and preserve safe fallbacks
build / mod (push) Failing after 19s

Pin API27, diagnose prepared states, gate capture ownership and keep far-sound fallback until acoustics is prepared. Retain engine changes in build provenance and cancel invalid return reservations after cell loss.

Co-authored-by: Codex <codex@openai.com>
This commit is contained in:
2026-11-03 00:24:46 +03:00
parent 4dddea2e8c
commit 910f02ca92
7 changed files with 200 additions and 10 deletions
+147
View File
@@ -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());
}
}
+4 -3
View File
@@ -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}");
+37 -3
View File
@@ -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<Result<String, String>> = 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;
+1
View File
@@ -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.
+7
View File
@@ -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"
+1 -1
Submodule horizon updated: 86b1014a92...c89c78c470
+3 -3
View File
@@ -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: