feat: consume scoped engine capabilities and preserve safe fallbacks
build / mod (push) Failing after 19s
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:
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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;
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user