From 5f07027faa4e108209fdc13366e11c214397cb07 Mon Sep 17 00:00:00 2001 From: chexit Date: Mon, 2 Nov 2026 01:17:03 +0300 Subject: [PATCH] Build canonical simulation without Horizon SDK and remove hidden launch fixes (#87 #107) Preserve the adapter facade and move adapter-only checks out of domain tests. Late tracks do not reveal an unseen launch origin or its timestamp. Co-Authored-By: Codex GPT-6.1 Terra --- .gitea/workflows/build.yml | 9 +- .gitignore | 1 + README.md | 15 ++- airdef-core/Cargo.lock | 16 +++ airdef-core/Cargo.toml | 13 ++ airdef-core/README.md | 13 ++ airdef-core/examples/sim_run.rs | 8 ++ airdef-core/src/lib.rs | 6 + airdef-core/tests/scenarios.rs | 37 ++++++ airdef-mod/Cargo.lock | 8 ++ airdef-mod/Cargo.toml | 4 + airdef-mod/examples/sim_run/main.rs | 2 +- airdef-mod/src/engines.rs | 15 +-- airdef-mod/src/layout.rs | 13 ++ airdef-mod/src/lib.rs | 2 +- airdef-mod/src/panel.rs | 5 +- airdef-mod/src/sim/balance.rs | 2 +- airdef-mod/src/sim/doctrine.rs | 12 -- airdef-mod/src/sim/mod.rs | 23 +++- airdef-mod/src/sim/raid.rs | 39 ++++-- airdef-mod/src/sim/record.rs | 6 +- airdef-mod/src/sim/regress.rs | 39 ------ airdef-mod/src/sim/strike.rs | 184 ++++++++++++++++------------ airdef-mod/src/sim/why.rs | 10 +- airdef-mod/src/smoke.rs | 28 +++++ airdef-mod/src/wave.rs | 18 +-- airdef-mod/tests/core_boundary.rs | 37 ++++++ 27 files changed, 371 insertions(+), 194 deletions(-) create mode 100644 airdef-core/Cargo.lock create mode 100644 airdef-core/Cargo.toml create mode 100644 airdef-core/README.md create mode 100644 airdef-core/examples/sim_run.rs create mode 100644 airdef-core/src/lib.rs create mode 100644 airdef-core/tests/scenarios.rs create mode 100644 airdef-mod/tests/core_boundary.rs diff --git a/.gitea/workflows/build.yml b/.gitea/workflows/build.yml index d6a70ec..87b73e2 100644 --- a/.gitea/workflows/build.yml +++ b/.gitea/workflows/build.yml @@ -30,8 +30,13 @@ jobs: # падало по таймауту, а сохранение вешало «Complete job» на пару минут. Инструмент собирается заново. - name: Форк Horizon run: nice -n 19 python3 tools/airdef.py fork - # Чистая симуляция тестируется нативно (хост-таргет раннера, без WASM): регрессии из sim/regress.rs - # и сценарии tests/scenarios.rs (NaN, налёт кончается, ТЗМ не зависают). Нужен форк: SDK подключён путём. + - name: Проверка закреплённого checkout + run: python3 -m unittest discover -s tools -p test_engine_checkout.py + - name: Тесты ядра без SDK + working-directory: airdef-core + run: | + HOST=$(rustc -vV | sed -n 's/^host: //p') + nice -n 19 cargo test --target "$HOST" - name: Тесты мода working-directory: airdef-mod run: | diff --git a/.gitignore b/.gitignore index 4e91c62..0322357 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,7 @@ run-home/ .horizon-cache/ airdef-mod/target/ +airdef-core/target/ __pycache__/ /out/ .DS_Store diff --git a/README.md b/README.md index 952a668..d6f3602 100644 --- a/README.md +++ b/README.md @@ -3,7 +3,8 @@ Мод для Horizon (Minecraft 26.3 на Rust): Шахед-136, Герань-3, Patriot и Гепард с 3D-моделями, наведением ракет, слежением башен, падением сбитых дронов и эффектами взрывов. -Работает на **своём форке Horizon** (`horizon/`, ветка `airdef`): в нём добавлен API отрисовки +Работает на **[своём форке Horizon](https://gitea.maoniao.team/chexit/horizon-airdef)** +(`horizon/` — сабмодуль, закреплённый SHA): в нём добавлен API отрисовки 3D-моделей, которого нет в основном Horizon. ![Перехваты](screenshots/intercepts.jpg) @@ -16,7 +17,8 @@ Коротко, из исходников: ```bash -python3 tools/airdef.py all # скачать Horizon, наложить патчи форка, собрать игру и мод, установить мод +git submodule update --init --recursive # также выполняется командой all +python3 tools/airdef.py all # получить закреплённый движок, собрать игру и мод, установить мод python3 tools/airdef.py play # запустить (аргументы игры — после: --world "Имя мира") ``` @@ -159,7 +161,7 @@ API мода `render.set-lights` (до 512, плавно интерполиру - `/airdef raid` — налёт, настроенный в меню; `/airdef target here` — отметить объект под ногами; `/airdef repair` — ремонт; - `/airdef lastchance ` — «последний шанс»: комплекс стреляет и по классу вне ролей, если цель вот-вот попадёт (по умолчанию вкл; сохраняется); - `/airdef sites` — известные пусковые площадки (ПУ) и ударные ракеты; `/airdef strike <номер ПУ>` — ответный удар по ПУ (#101, «удары по ПУ»). Как это играется: - - **Разведка без волшебства.** Площадку находит только работающая РЛС: либо пуск в её досягаемости (низкая цель: радиогоризонт и холмы решают) даёт точечное наблюдение, либо собственный трек вылетевшей цели «указывает назад» лучом в пару градусов, а время полёта с момента пуска (тревоги) на скорости цели даёт дальность (±8% и секунда времени пуска); у баллистической ракеты дуга точнее. Две РЛС, держащие одну цель, дают два наблюдения. Наблюдения сводятся методом наименьших квадратов, ответ всегда с кругом ошибки (90%), он сужается с каждым пуском. Если места не получилось (круг шире 800 блоков или есть только лучи вдоль одной линии), известен только **азимут** (на скопе метка «N?» на краю), удар невозможен. С местом: ромб на скопе (круг ошибки пунктиром) или метка на краю; в панели строка «Известных ПУ…». Далёкие площадки (километры) открываются грубо (сотни блоков) и бить их почти бесполезно, пока не накопятся пуски. + - **Разведка без волшебства.** РЛС в пределах досягаемости и прямой видимости может заметить сам пуск и получить неточную позицию. Несколько наблюдённых пусков уточняют её. Если дрон обнаружен позже, используются только две измеренные позиции: обратный курс — слабое предположение, манёвр до обнаружения неизвестен, поэтому место старта не считается установленным. Повторные треки одного полёта не создают ложную точность. Для удара нужна достаточно точная наблюдённая позиция; скрытые координаты ПУ и время невидимого старта её не подменяют. - **Удар.** Ударных ракет мало (3 в запасе, максимум 4, новая за 5 минут), дальность до 4500 блоков; стартуют оттуда, где вы стоите, летят 4 блока в тик (2 км за 25 с) в точку *оценки*. На подлёте ракету может сбить ПВО противника: у ПУ её нет (40%), лёгкая (35%, сбивает в 30%) или тяжёлая (25%, 65%), у баллистических батарей тяжёлая чаще; узнаёте только по итогу. Разброс ракеты 14 блоков; решает истинное расстояние до ПУ: до 35 блоков — **уничтожена** (запас сгорел и рвётся, отложенные запуски отменены, огонь и дымящийся остов, взрыв общим кодом), до 90 — **подавлена** на 30 с (пуски отложены, не отменены), дальше — **промах**, воронка рядом, ПУ цела: очередь за попадание в соседний грунт не списывается. Плохо известную ПУ (круг в сотни блоков) бить почти бесполезно. - **Вне карты.** ПУ без земли (нет загруженной карты) тоже находится: азимут и дальность по трекам, но не рисуется. Удар, который попал бы, прекращает её пуски («результат не виден»); промах теряется. - **Цена выбора.** Ракета запаса не вернётся, пока летит, налёт идёт: близкие площадки не остановить, далёкие (крылатые и баллистические за 2–4 км) успеваете, но для этого нужны РЛС с обзором в сторону налёта и риск промаха или ПВО противника. Отчёт налёта называет сорванные пуски: «Налёт сорван ударами по ПУ (№…): не взлетело N из заявленных M». @@ -272,9 +274,8 @@ API мода `render.set-lights` (до 512, плавно интерполиру ## Устройство ``` -HORIZON_COMMIT коммит апстрима Horizon, на который накладываются патчи -horizon-patches/ изменения форка (git format-patch); tools/airdef.py fork собирает из них horizon/ -horizon/ форк (не в репозитории; ветка airdef). Что в нём меняется: +.gitmodules URL отдельного репозитория движка +horizon/ git submodule: точный SHA форка Horizon. Что в нём меняется: crates/horizon-mod-sdk/wit/horizon.wit render.set-models, spawn-particles, play-sound, query-ground / ground crates/horizon-mods/src/runtime.rs хост этих функций @@ -309,3 +310,5 @@ run-home/ тестовая игровая папка (для пров - Мод клиентский: дроны и ПВО видит только тот, у кого он стоит. Мультиплеер не поддерживается. - Окно Horizon на macOS не рисует кадры, пока оно перекрыто другими окнами (это поведение движка). - Шейдер работает, только если не включён шейдер-пак Horizon (они взаимоисключающие). + +Разработка и обновление закреплённого движка: [docs/ENGINE.md](docs/ENGINE.md). diff --git a/airdef-core/Cargo.lock b/airdef-core/Cargo.lock new file mode 100644 index 0000000..83b2dd8 --- /dev/null +++ b/airdef-core/Cargo.lock @@ -0,0 +1,16 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "airdef-core" +version = "0.2.0" +dependencies = [ + "glam", +] + +[[package]] +name = "glam" +version = "0.30.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19fc433e8437a212d1b6f1e68c7824af3aed907da60afa994e7f542d18d12aa9" diff --git a/airdef-core/Cargo.toml b/airdef-core/Cargo.toml new file mode 100644 index 0000000..58dbf1e --- /dev/null +++ b/airdef-core/Cargo.toml @@ -0,0 +1,13 @@ +[package] +name = "airdef-core" +version = "0.2.0" +edition = "2024" +publish = false + +[dependencies] +glam = "0.30" + +[features] +test-fixtures = [] + +[workspace] diff --git a/airdef-core/README.md b/airdef-core/README.md new file mode 100644 index 0000000..cf53cba --- /dev/null +++ b/airdef-core/README.md @@ -0,0 +1,13 @@ +# AirDef headless simulation + +This package compiles the same `airdef-mod/src/sim` source as the game adapter, with `glam` as its only dependency. A checkout does not need the Horizon submodule to build or run it: + +```sh +cargo test --manifest-path airdef-core/Cargo.toml +cargo run --manifest-path airdef-core/Cargo.toml --release --example sim_run -- --list +cargo run --manifest-path airdef-core/Cargo.toml --release --example sim_run -- --scenario big-smart +``` + +The shared scenario harness runs for both packages. Adapter integration tests for layout and smoke stay in `airdef-mod`; `core_boundary` verifies the public type facade and deterministic combat trace. + +This is an incremental boundary: the simulation still produces rendering and audio declarations and formats some UI text. Moving those outputs and diagnostics to adapters, remain follow-up work. The mod re-exports `airdef_core::sim` as its existing public facade. diff --git a/airdef-core/examples/sim_run.rs b/airdef-core/examples/sim_run.rs new file mode 100644 index 0000000..b4ecf3d --- /dev/null +++ b/airdef-core/examples/sim_run.rs @@ -0,0 +1,8 @@ +//! Run the same native scenario CLI without building the Horizon SDK. +extern crate airdef_core as airdef; +#[path = "../../airdef-mod/examples/sim_run/main.rs"] +mod runner; + +fn main() { + runner::main(); +} diff --git a/airdef-core/src/lib.rs b/airdef-core/src/lib.rs new file mode 100644 index 0000000..52287f1 --- /dev/null +++ b/airdef-core/src/lib.rs @@ -0,0 +1,6 @@ +//! SDK-independent build of the same simulation used by the AirDef adapter. +//! +//! The source remains shared while the domain/presentation boundary is extracted; +//! no generated copy can drift from the mod's simulation. +#[path = "../../airdef-mod/src/sim/mod.rs"] +pub mod sim; diff --git a/airdef-core/tests/scenarios.rs b/airdef-core/tests/scenarios.rs new file mode 100644 index 0000000..33ddcb0 --- /dev/null +++ b/airdef-core/tests/scenarios.rs @@ -0,0 +1,37 @@ +//! The mod's headless scenarios run against the SDK-independent package too. +extern crate airdef_core as airdef; +#[path = "../../airdef-mod/examples/sim_run/harness.rs"] +mod harness; + +fn check(sc: harness::Scenario) -> harness::Report { + let r = harness::run(&sc); + let problems = r.problems(); + assert!(problems.is_empty(), "scenario {} failed: {problems:?}\n{}", r.name, r.text()); + assert!(r.launched > 0); + r +} + +#[test] +fn big_smart_raid_on_61_systems() { + let r = check(harness::big_smart()); + assert_eq!(r.units, 61); + assert!(r.fired > 0 && r.ground_queries > 0); +} + +#[test] +fn logistics_trucks_depot_and_raid() { + let r = check(harness::logistics()); + assert!(r.truck_busy_ticks > 0 && r.fired > 0); +} + +#[test] +fn ew_and_adaptive_enemy_over_three_raids() { + assert_eq!(check(harness::ew_adaptive()).raids_ended, 3); +} + +#[test] +fn same_seed_same_result() { + let a = harness::run(&harness::logistics()); + let b = harness::run(&harness::logistics()); + assert_eq!((a.ticks_run, a.launched, a.kills, a.hits, a.fired, a.ground_queries), (b.ticks_run, b.launched, b.kills, b.hits, b.fired, b.ground_queries)); +} diff --git a/airdef-mod/Cargo.lock b/airdef-mod/Cargo.lock index d8c630a..1270314 100644 --- a/airdef-mod/Cargo.lock +++ b/airdef-mod/Cargo.lock @@ -6,10 +6,18 @@ version = 4 name = "airdef" version = "0.2.0" dependencies = [ + "airdef-core", "glam", "horizon-mod-sdk", ] +[[package]] +name = "airdef-core" +version = "0.2.0" +dependencies = [ + "glam", +] + [[package]] name = "anyhow" version = "1.0.104" diff --git a/airdef-mod/Cargo.toml b/airdef-mod/Cargo.toml index 3368bf3..616e673 100644 --- a/airdef-mod/Cargo.toml +++ b/airdef-mod/Cargo.toml @@ -9,9 +9,13 @@ description = "AirDef: long-range drones and air defence for the AirDef fork of crate-type = ["cdylib", "rlib"] [dependencies] +airdef-core = { path = "../airdef-core" } horizon-mod-sdk = { path = "../horizon/crates/horizon-mod-sdk" } glam = "0.30" +[dev-dependencies] +airdef-core = { path = "../airdef-core", features = ["test-fixtures"] } + [profile.release] opt-level = "s" lto = true diff --git a/airdef-mod/examples/sim_run/main.rs b/airdef-mod/examples/sim_run/main.rs index 10a583b..3e401cb 100644 --- a/airdef-mod/examples/sim_run/main.rs +++ b/airdef-mod/examples/sim_run/main.rs @@ -97,7 +97,7 @@ fn parse_layout(path: &str) -> Vec { units } -fn main() { +pub fn main() { let args: Vec = std::env::args().skip(1).collect(); let mut name = "big-smart".to_string(); let mut i = 0; diff --git a/airdef-mod/src/engines.rs b/airdef-mod/src/engines.rs index 31bdc6d..3418511 100644 --- a/airdef-mod/src/engines.rs +++ b/airdef-mod/src/engines.rs @@ -22,20 +22,7 @@ const MAX: usize = 128; /// change what is heard, so it stays off; the game bounds it to 12% anyway. pub const DOPPLER: f32 = 0.0; -/// An engine that sounds this tick. -#[derive(Clone, Debug, PartialEq)] -pub struct Decl { - /// The entity: a drone's id, a missile's with a high bit, a replay track's with another. - pub key: u32, - pub event: &'static str, - pub at: DVec3, - /// Blocks a second. - pub vel: DVec3, - pub gain: f32, - pub pitch: f32, - /// Shot down: the engine dies away. - pub damaged: bool, -} +pub use crate::sim::EngineDecl as Decl; /// What a source is asked to be. #[derive(Clone, Debug, PartialEq)] diff --git a/airdef-mod/src/layout.rs b/airdef-mod/src/layout.rs index e2c04e8..e4f95e5 100644 --- a/airdef-mod/src/layout.rs +++ b/airdef-mod/src/layout.rs @@ -221,4 +221,17 @@ mod tests { let w = crate::wave::WaveConfig::default().text(); assert!(Layout::parse(&format!("wave {w}\n")).is_ok(), "{w}"); } + #[test] + fn the_save_line_is_what_the_layout_reads() { + let mut d = crate::sim::doctrine::Doctrine { salvo: crate::sim::doctrine::Salvo::Economy, reserve: 3, ..Default::default() }; + d.sector = Some((1.5, 0.5)); + d.guards = vec![DVec3::new(1.0, 2.0, 3.0)]; + let text = format!("sam {} 0 70 0 0\ndoctrine 0 {}\n", SamKind::Patriot.key(), d.save_text()); + let l = crate::layout::Layout::parse(&text).unwrap(); + assert_eq!(l.doctrines, vec![(0, d)]); + assert!(crate::layout::Layout::parse("doctrine 0 loud 1 - - 0\n").is_err()); + assert!(crate::layout::Layout::parse("doctrine 0 eco 1 - - 2 1 2 3\n").is_err(), "guards cut short"); + } + + } diff --git a/airdef-mod/src/lib.rs b/airdef-mod/src/lib.rs index 8a5fdc0..0f34081 100644 --- a/airdef-mod/src/lib.rs +++ b/airdef-mod/src/lib.rs @@ -23,7 +23,7 @@ mod panel; mod radar; mod replay; mod smoke; -pub mod sim; +pub use airdef_core::sim; mod scenario; mod wave; use sim::{Class, DroneKind, Ground, Out, Sam, SamKind, Sim}; diff --git a/airdef-mod/src/panel.rs b/airdef-mod/src/panel.rs index 6aec1df..4bec565 100644 --- a/airdef-mod/src/panel.rs +++ b/airdef-mod/src/panel.rs @@ -13,10 +13,7 @@ const GREEN: u32 = 0x80FF90; /// The most lines the summary holds (the HUD's room). const MAX_LINES: usize = 16; -/// Metres (a block is a metre) as a short distance. -pub fn distance(m: f64) -> String { - if m >= 1000.0 { format!("{:.1} км", m / 1000.0) } else { format!("{:.0} м", m) } -} +pub use crate::sim::distance; /// Blocks a tick as km/h. fn kmh(v: DVec3) -> f64 { diff --git a/airdef-mod/src/sim/balance.rs b/airdef-mod/src/sim/balance.rs index 75cf1ed..952c4b1 100644 --- a/airdef-mod/src/sim/balance.rs +++ b/airdef-mod/src/sim/balance.rs @@ -236,7 +236,7 @@ pub(super) fn fly(s: &Setup) -> Result { land.serve(&mut ground, sim.tick); sim.step(&mut ground, None, &mut out); } - let mut order = crate::wave::WaveConfig::mix(s.size, s.dist); + let mut order = RaidOrder::mix(s.size, s.dist); // The Oreshnik's blocks are a raid of their own; this measures the ordinary mass raid. order.kinds.retain(|k| k.0 != DroneKind::Oreshnik); if s.smart { diff --git a/airdef-mod/src/sim/doctrine.rs b/airdef-mod/src/sim/doctrine.rs index 358a861..508f016 100644 --- a/airdef-mod/src/sim/doctrine.rs +++ b/airdef-mod/src/sim/doctrine.rs @@ -194,18 +194,6 @@ mod tests { assert_eq!(d.area_blocks(DVec3::ZERO, &away, 0), NO_THREAT); } - #[test] - fn the_save_line_is_what_the_layout_reads() { - let mut d = Doctrine { salvo: Salvo::Economy, reserve: 3, ..Default::default() }; - d.sector = Some((1.5, 0.5)); - d.guards = vec![DVec3::new(1.0, 2.0, 3.0)]; - let text = format!("sam {} 0 70 0 0\ndoctrine 0 {}\n", SamKind::Patriot.key(), d.save_text()); - let l = crate::layout::Layout::parse(&text).unwrap(); - assert_eq!(l.doctrines, vec![(0, d)]); - assert!(crate::layout::Layout::parse("doctrine 0 loud 1 - - 0\n").is_err()); - assert!(crate::layout::Layout::parse("doctrine 0 eco 1 - - 2 1 2 3\n").is_err(), "guards cut short"); - } - use super::sites::testland::{Land, serve}; /// One Patriot against one Shahed flying in from the north at its aim, the doctrine set before the raid: diff --git a/airdef-mod/src/sim/mod.rs b/airdef-mod/src/sim/mod.rs index caff02c..b097725 100644 --- a/airdef-mod/src/sim/mod.rs +++ b/airdef-mod/src/sim/mod.rs @@ -69,6 +69,25 @@ pub struct Particle { pub scale: f32, } +/// Sound source description produced by the simulation and consumed by the audio adapter. +#[derive(Clone, Debug, PartialEq)] +pub struct EngineDecl { + pub key: u32, + pub event: &'static str, + pub at: DVec3, + /// Blocks per second. + pub vel: DVec3, + pub gain: f32, + pub pitch: f32, + pub damaged: bool, +} + +/// Shared distance text used by existing diagnostics and the HUD. +/// Moving diagnostic text to the presentation adapter is a separate migration. +pub fn distance(m: f64) -> String { + if m >= 1000.0 { format!("{:.1} км", m / 1000.0) } else { format!("{:.0} м", m) } +} + /// A blast the post-processing shader draws as a fireball, flash and shock. #[derive(Clone, Copy)] pub struct Blast { @@ -89,7 +108,7 @@ pub struct Out { pub sounds: Vec<(&'static str, DVec3, f32, f32)>, /// The engines sounding this tick (persistent sources, `crate::engines`), and whether the /// picture cut (a replay seek or a new shot): every engine then starts afresh. - pub engines: Vec, + pub engines: Vec, pub engine_cut: bool, pub commands: Vec, /// Camera jolt (strength, seconds), the strongest this tick. @@ -121,7 +140,7 @@ impl Out { } /// An engine that sounds: `vel` is the simulation's, blocks a tick. pub fn engine(&mut self, key: u32, event: &'static str, at: DVec3, vel: DVec3, gain: f32, pitch: f32, damaged: bool) { - self.engines.push(crate::engines::Decl { key, event, at, vel: vel * 20.0, gain, pitch, damaged }); + self.engines.push(EngineDecl { key, event, at, vel: vel * 20.0, gain, pitch, damaged }); } fn jolt(&mut self, strength: f32, seconds: f32) { if self.shake.is_none_or(|(s, _)| s < strength) { diff --git a/airdef-mod/src/sim/raid.rs b/airdef-mod/src/sim/raid.rs index 118ebfd..0bccf9d 100644 --- a/airdef-mod/src/sim/raid.rs +++ b/airdef-mod/src/sim/raid.rs @@ -60,6 +60,29 @@ pub struct RaidOrder { pub aim: Aim, } +impl RaidOrder { + /// A combined strike of about `n`, as `/airdef wave mix n` orders it. + pub fn mix(n: u32, dist: f64) -> RaidOrder { + let n = n.max(4); + let mut kinds = vec![ + (DroneKind::Gerbera, n / 4 + 1), + (DroneKind::Shahed, n / 3 + 1), + (DroneKind::Geran3, n / 8 + 1), + (DroneKind::Kh101, n / 8 + 1), + (DroneKind::Kalibr, n / 10 + 1), + (DroneKind::Kh22, n / 16), + (DroneKind::Zircon, n / 20), + (DroneKind::Iskander, n / 12 + 1), + (DroneKind::Kinzhal, n / 16), + ]; + if n >= 30 { + kinds.push((DroneKind::Oreshnik, 1)); + } + kinds.retain(|k| k.1 > 0); + RaidOrder { kinds, approach: Approach::TwoSectors, dist, aim: Aim::Objects } + } +} + /// A drone of a raid being planned. struct Item { d: Drone, @@ -963,7 +986,7 @@ mod tests { serve(&mut ground, &land, sim.tick); sim.step(&mut ground, None, &mut out); } - let mut order = crate::wave::WaveConfig::mix(36, 500.0); + let mut order = RaidOrder::mix(36, 500.0); order.aim = Aim::Smart; order.approach = Approach::AllSides; let total: u32 = order.kinds.iter().map(|k| k.1).sum(); @@ -1044,13 +1067,15 @@ mod big { serve(&mut ground, &land, sim.tick); sim.step(&mut ground, None, &mut out); } - let mut w = crate::wave::WaveConfig::default(); - w.counts = [10, 12, 6, 6, 6, 6, 4, 4, 6, 4, 1]; - w.aim = Aim::Smart; - w.approach = Approach::AllSides; - w.dist = 800.0; + let order = RaidOrder { + kinds: [DroneKind::Gerbera, DroneKind::Shahed, DroneKind::Geran3, DroneKind::Lancet, + DroneKind::Kh101, DroneKind::Kalibr, DroneKind::Kh22, DroneKind::Zircon, + DroneKind::Iskander, DroneKind::Kinzhal, DroneKind::Oreshnik] + .into_iter().zip([10, 12, 6, 6, 6, 6, 4, 4, 6, 4, 1]).collect(), + aim: Aim::Smart, approach: Approach::AllSides, dist: 800.0, + }; let t = std::time::Instant::now(); - sim.order_raid(&w.order(), &sim.targets.clone(), &mut out); + sim.order_raid(&order, &sim.targets.clone(), &mut out); let mut ticks = 0; let mut slowest = std::time::Duration::ZERO; while sim.planning() { diff --git a/airdef-mod/src/sim/record.rs b/airdef-mod/src/sim/record.rs index 6b692a8..392d58e 100644 --- a/airdef-mod/src/sim/record.rs +++ b/airdef-mod/src/sim/record.rs @@ -101,10 +101,10 @@ impl SamTrack { } } -#[cfg(test)] +#[cfg(any(test, feature = "test-fixtures"))] impl Recording { /// A recording of `len` ticks with nothing in it (for the planner's tests). - pub(crate) fn blank(len: u32) -> Self { + pub fn blank(len: u32) -> Self { Recording { number: 1, origin: DVec3::ZERO, @@ -123,7 +123,7 @@ impl Recording { } } /// A straight track from `from` to `to` over the ticks `first..=last` (every `STEP`). - pub(crate) fn straight(&mut self, id: u32, what: What, first: u32, last: u32, from: DVec3, to: DVec3) { + pub fn straight(&mut self, id: u32, what: What, first: u32, last: u32, from: DVec3, to: DVec3) { let n = ((last - first) / STEP).max(1) as usize; let points = (0..=n).map(|k| { let p = from.lerp(to, k as f64 / n as f64); diff --git a/airdef-mod/src/sim/regress.rs b/airdef-mod/src/sim/regress.rs index f21afd7..3f11086 100644 --- a/airdef-mod/src/sim/regress.rs +++ b/airdef-mod/src/sim/regress.rs @@ -663,45 +663,6 @@ fn replay_fires_and_blasts_follow_the_recording() { // #78: a replay's smoke is rebuilt from the recording as a function of its time: trails behind // what flew (clearing the fog), a column over the fire, billows that outlive the fireball; a seek // shows what playing through shows, and the live Sim is not touched. -#[test] -fn replay_smoke_has_trails_and_fire_columns_and_is_a_function_of_time() { - use record::{Event, EventKind}; - let mut sim = Sim::new(1); - sim.add_drone(DroneKind::Geran3, DVec3::new(0.0, 80.0, 0.0), DVec3::Z, DVec3::new(0.0, 70.0, 2000.0)); - sim.add_drone(DroneKind::Geran3, DVec3::new(30.0, 80.0, 0.0), DVec3::Z, DVec3::new(30.0, 70.0, 2000.0)); - let mut g = Ground::default(); - let mut out = Out::default(); - for _ in 0..160 { - sim.step(&mut g, None, &mut out); - } - let mut rec = sim.rec.take().unwrap(); - assert_eq!(rec.tracks.len(), 2); - rec.events = vec![Event { at: 40, pos: DVec3::new(5.0, 70.0, 5.0), kind: EventKind::Hit { drone: 1, kind: DroneKind::Geran3, power: 2, object: None, sam: None } }]; - let live_tick = sim.tick; - let live_fires = sim.fires.list.len(); - let t = 130.0; - let a = crate::smoke::replay_smoke(&rec, t); - let trails = a.iter().filter(|s| s.clear > 0.0).count(); - let columns = a.iter().filter(|s| s.soot > 0.8).count(); - let billows = a.iter().filter(|s| s.soot == 0.55).count(); - assert!(trails > 0, "trails of both tracks: {trails}"); - assert!(columns > 0, "a column over the fire: {columns}"); - assert!(billows > 0, "the blast's billows"); - // Seeking away and back, or forward from nothing, gives the same smoke. - let _ = crate::smoke::replay_smoke(&rec, 10.0); - let _ = crate::smoke::replay_smoke(&rec, 155.0); - let b = crate::smoke::replay_smoke(&rec, t); - assert_eq!(a.iter().map(|s| (s.id, s.born as u64)).collect::>(), b.iter().map(|s| (s.id, s.born as u64)).collect::>()); - // Nothing from the future; before the raid, nothing. - assert!(a.iter().all(|s| s.born <= t)); - assert!(crate::smoke::replay_smoke(&rec, -5.0).is_empty()); - // The billows go on long after the short fireball is out. - let late = crate::smoke::replay_smoke(&rec, 400.0); - assert!(late.iter().any(|s| s.soot == 0.55)); - // The live state is not touched. - assert_eq!((sim.tick, sim.fires.list.len()), (live_tick, live_fires)); -} - // #76: the protected objects of a replay are the recording's, not the live marks. #[test] fn a_replay_draws_the_recorded_objects_not_the_live_ones() { diff --git a/airdef-mod/src/sim/strike.rs b/airdef-mod/src/sim/strike.rs index 4aaf89f..612b52b 100644 --- a/airdef-mod/src/sim/strike.rs +++ b/airdef-mod/src/sim/strike.rs @@ -3,16 +3,12 @@ //! **Finding a site.** Nothing is known by magic. A site shows itself two ways, and only to a radar that is up: //! - a launch inside a radar's reach (low targets: the radio horizon, the hills): a *point* sighting, good to a few //! percent of the distance; -//! - a launched drone held on a radar's own track: the track points back to where it came from, a *ray* from where it was -//! picked up, a few degrees wide, and the time since the launch (the alarm) at the drone's speed gives how far: a range -//! good to some percent plus a second of launch time. Two radars holding it give two sightings. A ballistic missile's arc -//! says more: a point sighting, a few percent of the range. +//! - a drone first detected later supplies a tentative reverse-course bearing from two measured track positions. +//! A turn before detection can invalidate that bearing, so it is deliberately broad and cannot locate the site. +//! Multiple sensors following the same flight do not count as independent launch observations. //! -//! The sightings of one site are combined by weighted least squares (`solve`): points average, a ray with a range is -//! a long thin ellipse along it, a bare ray pulls across itself only, and each weighs by how sure it is. The answer carries its own error (`radius`, the 90% circle), which -//! shrinks with the launches seen and with a second radar holding the same drones. A site whose sightings pin no place -//! down (bare rays along one line, a circle wider than `max_fix_radius`) is *a bearing*, not yet a place (no `fix`), and -//! cannot be struck. +//! Directly observed launches provide independent point sightings, combined by weighted least squares (`solve`). +//! A site without a sufficiently accurate fix remains a bearing and cannot be struck. //! //! **Striking it.** `order_strike`: one of a few strike missiles (a stock that refills slowly), fired from where the //! player stands at the *estimated* place. It flies `STRIKE_SPEED`; on arrival the enemy's cover may shoot it down; else @@ -31,10 +27,6 @@ use glam::DVec2; pub struct Tune { /// Angular error of a track pointing back (radians, 1 sigma): 3 degrees. pub track_sigma: f64, - /// The range from the time of flight: error as a part of the range, and ticks of launch time (times the speed). - pub range_sigma: (f64, f64), - /// A ballistic missile's launch point from its arc: error as a part of the distance, and the least. - pub ballistic_sigma: (f64, f64), /// A launch seen by a radar in reach: error as a part of the distance, and the least (blocks). pub seen_sigma: (f64, f64), /// How high a launch is, for the radar's horizon (blocks over the ground). @@ -75,8 +67,6 @@ pub struct Tune { pub const TUNE: Tune = Tune { track_sigma: 0.052, - range_sigma: (0.08, 20.0), - ballistic_sigma: (0.05, 20.0), seen_sigma: (0.04, 8.0), launch_height: 4.0, max_fix_radius: 800.0, @@ -104,8 +94,8 @@ pub const TUNE: Tune = Tune { const RADIUS_K: f64 = 3.0; /// One sighting: a point (`dir` none, `sigma` in blocks), a ray from `at` along `dir` (`sigma` in radians), or a ray with -/// a range on it (`range`: blocks from `at` to the site and its error): the track says where from, and the time since -/// the launch flash at the alarm, at the drone's speed, how far. +/// a range on it (`range`: blocks from `at` to the site and its error). A range requires an independent measurement; +/// a late track by itself does not provide one. #[derive(Clone, Copy, Debug)] pub struct Obs { pub at: DVec2, @@ -256,13 +246,14 @@ pub struct Known { pub aimed: bool, } -/// A launched drone still to be picked up on a track: (drone, site, class, launched at). +/// A launched drone awaiting track history. `at` is used only to expire this bookkeeping. #[derive(Clone, Copy, Debug)] struct Flown { drone: u32, site: u32, class: SiteClass, at: u64, + sample: Option<(u32, u64, DVec2)>, } /// The target of a strike as it was when the missile left: what the outcome is judged against, even if the site @@ -381,8 +372,9 @@ impl Known { let place = match &self.est { Some(e) if e.fix => { let d = e.pos - DVec2::new(from.x, from.z); - format!("{:.0}° {}, {}, ±{:.0} м ({})", compass(d).to_degrees(), wind(compass(d)), crate::panel::distance(d.length()), e.radius, accuracy(e)) + format!("{:.0}° {}, {}, ±{:.0} м ({})", compass(d).to_degrees(), wind(compass(d)), super::distance(d.length()), e.radius, accuracy(e)) } + Some(e) if e.bearing_err >= std::f64::consts::PI => "место неизвестно: обратный курс цели не определяет место старта".into(), Some(e) => format!("{:.0}° {} ±{:.0}°, дальность неизвестна (только азимут)", e.bearing.to_degrees(), wind(e.bearing), e.bearing_err.to_degrees().max(1.0)), None => "место неизвестно".into(), }; @@ -429,9 +421,9 @@ impl Sim { /// A drone just left site `site` (a launch the radars may have seen). pub(super) fn scout_launch(&mut self, drone: u32, site: u32, kind: DroneKind, at: DVec3) { let Some(s) = self.sites.get(site) else { return }; - let (class, offmap, pos) = (s.class, s.offmap, s.pos); + let (class, offmap) = (s.class, s.offmap); if self.strikes.flown.len() < 400 { - self.strikes.flown.push(Flown { drone, site, class, at: self.tick }); + self.strikes.flown.push(Flown { drone, site, class, at: self.tick, sample: None }); } if offmap { return; @@ -452,7 +444,7 @@ impl Sim { if let Some(dist) = seen { let sigma = (dist * TUNE.seen_sigma.0).max(TUNE.seen_sigma.1); let (gx, gz) = (gauss(self.seed, drone as u64, site as u64, 1), gauss(self.seed, drone as u64, site as u64, 2)); - let at = DVec2::new(pos.x + gx * sigma, pos.z + gz * sigma); + let at = DVec2::new(at.x + gx * sigma, at.z + gz * sigma); self.add_obs(site, class, Obs::point(at, sigma)); } } @@ -469,6 +461,11 @@ impl Sim { }; let before = self.strikes.known[i].est; let k = &mut self.strikes.known[i]; + // A course bearing is only a weak hypothesis. Once a launch is seen, do not + // let that hypothesis pull the measured point away from its actual location. + if obs.dir.is_none() { + k.obs.retain(|o| o.dir.is_none()); + } k.obs.push(obs); if k.obs.len() > TUNE.keep_obs { k.obs.remove(0); @@ -481,6 +478,7 @@ impl Sim { let words = |e: &Option| e.as_ref().map(accuracy); let msg = match (&before, &k.est) { (None, Some(e)) if e.fix => Some(format!("РЛС засекла пуски: новая ПУ №{} ({}), место определено ±{:.0} м", site, class_name(class), e.radius)), + (None, Some(e)) if e.bearing_err >= std::f64::consts::PI => Some(format!("РЛС обнаружила цель с ПУ №{} ({}); место старта неизвестно", site, class_name(class))), (None, Some(e)) => Some(format!("РЛС засекла пуски: новая ПУ №{} ({}), азимут {:.0}° {}", site, class_name(class), e.bearing.to_degrees(), wind(e.bearing))), (Some(b), Some(e)) if !b.fix && e.fix => Some(format!("ПУ №{}: место определено, ±{:.0} м ({})", site, e.radius, accuracy(e))), (Some(_), Some(_)) if words(&before) != words(&k.est) => Some(format!("ПУ №{}: точность {}, ±{:.0} м", site, accuracy(k.est.as_ref().unwrap()), k.est.as_ref().unwrap().radius)), @@ -509,41 +507,43 @@ impl Sim { let mut new: Vec<(u32, SiteClass, Obs)> = Vec::new(); let flown = std::mem::take(&mut self.strikes.flown); let mut keep = Vec::with_capacity(flown.len()); - for f in flown { - let Some(d) = self.drones.iter().find(|d| d.id == f.drone).filter(|d| d.alive()) else { continue }; - if now.saturating_sub(f.at) > TUNE.scout_life { + for mut f in flown { + if !self.drones.iter().any(|d| d.id == f.drone && d.alive()) || now.saturating_sub(f.at) > TUNE.scout_life { continue; } - // The tracks of the (up to two) systems whose own radar holds it: each is a sighting of its own. - let mut held: Vec<(u32, sight::Track)> = self.sams.iter().filter(|s| s.alive()).filter_map(|s| s.track(f.drone).filter(|t| t.own_tick > 0).map(|t| (s.id, *t))).collect(); - if held.is_empty() { - keep.push(f); + // A single tentative bearing per site avoids treating correlated samples, + // sensors or flights following the same route as independent evidence. + // Directly observed launches, if any, already carry stronger information. + if self.strikes.known.iter().any(|k| k.site == f.site && !k.obs.is_empty()) + || new.iter().any(|(site, _, _)| *site == f.site) + { continue; } - held.sort_by_key(|h| std::cmp::Reverse(h.1.tick)); - held.truncate(2); - let Some(site) = self.sites.get(f.site) else { continue }; - let truth = DVec2::new(site.pos.x, site.pos.z); - for (sam, t) in held { - let from = DVec2::new(t.pos.x, t.pos.z); - let away = truth - from; - let dist = away.length(); - if dist < 30.0 { - continue; - } - let (k, c) = (f.drone as u64, f.site as u64 * 64 + sam as u64 % 64); - if d.kind.spec().ballistic.is_some() { - let sigma = (dist * TUNE.ballistic_sigma.0).max(TUNE.ballistic_sigma.1); - let at = DVec2::new(truth.x + gauss(self.seed, k, c, 3) * sigma, truth.y + gauss(self.seed, k, c, 4) * sigma); - new.push((f.site, f.class, Obs::point(at, sigma))); + let tracks = self.sams.iter().filter(|s| s.alive()).filter_map(|s| { + s.track(f.drone).filter(|t| t.own_tick > 0 && t.tick == t.own_tick).map(|t| (s.id, *t)) + }); + let held = if let Some((sam, _, _)) = f.sample { + tracks.filter(|(id, _)| *id == sam).max_by_key(|(_, t)| t.own_tick) + } else { + tracks.max_by_key(|(_, t)| t.own_tick) + }; + let Some((sam, t)) = held else { keep.push(f); continue }; + let pos = DVec2::new(t.pos.x, t.pos.z); + if let Some((_, first_tick, first_pos)) = f.sample { + // Require two distinct measurements and enough movement; never infer + // elapsed flight time from the unseen launch bookkeeping. + let delta = pos - first_pos; + if t.own_tick > first_tick && delta.length() >= 20.0 { + let direction = -delta.normalize(); + // Course before detection may have turned substantially. This is + // a broad indication, never a ranged or ballistic point estimate. + new.push((f.site, f.class, Obs::ray(first_pos, direction, std::f64::consts::PI))); } else { - // Bearing from the track; range from the time of flight since the launch (the alarm), at the - // drone's speed: a fraction of the range and a second of launch time. - let a = away.y.atan2(away.x) + gauss(self.seed, k, c, 5) * TUNE.track_sigma; - let sr = dist * TUNE.range_sigma.0 + d.kind.spec().speed * TUNE.range_sigma.1; - let r = (dist + gauss(self.seed, k, c, 6) * sr).max(20.0); - new.push((f.site, f.class, Obs { range: Some((r, sr)), ..Obs::ray(from, DVec2::new(a.cos(), a.sin()), TUNE.track_sigma) })); + keep.push(f); } + } else { + f.sample = Some((sam, t.own_tick, pos)); + keep.push(f); } } self.strikes.flown = keep; @@ -696,7 +696,7 @@ impl Sim { } let dist = (est.pos - DVec2::new(from.x, from.z)).length(); if dist > TUNE.range { - return Err(format!("ПУ №{id} дальше дальности удара ({})", crate::panel::distance(TUNE.range))); + return Err(format!("ПУ №{id} дальше дальности удара ({})", super::distance(TUNE.range))); } // The target as it is now: the missile is judged against this whatever becomes of the site meanwhile (#108). let site = self.sites.get(id).ok_or_else(|| format!("ПУ №{id} уже нет на карте"))?; @@ -732,7 +732,7 @@ impl Sim { "Удар по ПУ №{id}: ракета пошла в точку ±{:.0} м, лёт {} с, дальше {}. Осталось ударных ракет: {}", est.radius, ticks / 20, - crate::panel::distance(dist), + super::distance(dist), self.strikes.stock )) } @@ -886,7 +886,7 @@ impl Sim { TUNE.stock_max, if self.strikes.stock < TUNE.stock_max { format!(" (следующая через {} с)", self.strikes.seconds_to_next()) } else { String::new() }, self.strikes.flights.len(), - crate::panel::distance(TUNE.range) + super::distance(TUNE.range) )); v } @@ -942,32 +942,6 @@ mod tests { assert!((165..=196).contains(&within), "{within} of 200 inside the circle"); } - #[test] - fn a_track_with_a_time_of_flight_range_places_the_site_and_more_of_them_narrow_it() { - let truth = DVec2::new(100.0, 3000.0); - let ranged = |n: u64, seed: u64| -> Vec { - (0..n) - .map(|k| { - let at = DVec2::new(-40.0 + gauss(seed, k, 9, 1) * 30.0, 2200.0 + gauss(seed, k, 9, 2) * 30.0); - let away = truth - at; - let a = away.y.atan2(away.x) + gauss(seed, k, 1, 5) * TUNE.track_sigma; - let sr = away.length() * TUNE.range_sigma.0 + 20.0; - Obs { range: Some((away.length() + gauss(seed, k, 1, 6) * sr, sr)), ..Obs::ray(at, DVec2::new(a.cos(), a.sin()), TUNE.track_sigma) } - }) - .collect() - }; - let (mut within, mut shrink) = (0, 0); - for seed in 0..100u64 { - let (one, many) = (solve(&ranged(1, seed)).unwrap(), solve(&ranged(16, seed)).unwrap()); - within += usize::from((many.pos - truth).length() <= many.radius); - shrink += usize::from(many.radius < one.radius * 0.5); - } - assert!(within >= 80, "{within} of 100 inside the circle"); - assert_eq!(shrink, 100); - // Even one is a (rough) place for a site 800 blocks away. - assert!(solve(&ranged(1, 1)).unwrap().fix); - } - /// A ramp 300 blocks south of the origin with `n` shaheds queued 40 ticks apart, and a radar `radar` blocks from it /// (None: no radar). fn scene(seed: u64, n: u32, radar: Option) -> (Sim, Ground, Out, u32) { @@ -1007,6 +981,56 @@ mod tests { } } + #[test] + fn late_track_uses_only_measured_history_and_does_not_accumulate_correlated_fixes() { + fn observed(hidden_x: f64, hidden_launch_tick: u64) -> (Sim, u32, u32) { + let (mut sim, mut ground, mut out, id) = scene(9, 0, Some(DVec3::ZERO)); + let site = sim.sites.list.iter_mut().find(|site| site.id == id).unwrap(); + site.pos.x = hidden_x; + site.offmap = true; + let d = sim.make_drone(DroneKind::Shahed, DVec3::new(0.0, 100.0, 1000.0), -DVec3::Z, DVec3::ZERO); + let drone = d.id; + sim.drones.push(d); + sim.tick = hidden_launch_tick; + sim.scout_launch(drone, id, DroneKind::Shahed, DVec3::new(hidden_x, 70.0, 700.0)); + let track = sight::Track { id: drone, kind: DroneKind::Shahed, pos: DVec3::new(0.0, 100.0, 1000.0), vel: -DVec3::Z * 5.0, tick: 100, own_tick: 100, ttl: 100, hits: 3, locked: false }; + sim.sams[0].tracks.push(track); + sim.tick = 100; + sim.step_strikes(&mut ground, None, &mut out); + let t = &mut sim.sams[0].tracks[0]; + t.pos.z = 960.0; + t.tick = 108; + t.own_tick = 108; + sim.tick = 108; + sim.step_strikes(&mut ground, None, &mut out); + (sim, id, drone) + } + let (mut a, id, drone) = observed(0.0, 1); + let (b, _, _) = observed(1500.0, 40); + let ea = a.strikes.get(id).unwrap().est.unwrap(); + let eb = b.strikes.get(id).unwrap().est.unwrap(); + assert!(!ea.fix && !eb.fix); + assert_eq!((ea.origin, ea.pos, ea.bearing, ea.bearing_err), (eb.origin, eb.pos, eb.bearing, eb.bearing_err)); + assert_eq!(ea.bearing_err, std::f64::consts::PI); + + // Repeated sensors/observations of the same course cannot manufacture confidence. + for _ in 0..20 { + a.strikes.flown.push(Flown { drone, site: id, class: SiteClass::Ramp, at: 108, sample: None }); + } + a.tick = 112; + a.step_strikes(&mut Ground::default(), None, &mut Out::default()); + let k = a.strikes.get(id).unwrap(); + assert_eq!(k.obs.len(), 1); + assert_eq!(k.est.unwrap().bearing_err, ea.bearing_err); + + // A real launch measurement supersedes the tentative course bearing. + a.add_obs(id, SiteClass::Ramp, Obs::point(DVec2::new(20.0, 700.0), 20.0)); + let k = a.strikes.get(id).unwrap(); + assert_eq!(k.obs.len(), 1); + assert!(k.est.unwrap().fix); + assert_eq!(k.est.unwrap().pos, DVec2::new(20.0, 700.0)); + } + #[test] fn launches_seen_by_a_radar_in_reach_locate_the_site_and_the_circle_shrinks() { let (mut sim, mut ground, mut out, id) = scene(11, 12, Some(DVec3::new(0.0, 64.0, 200.0))); diff --git a/airdef-mod/src/sim/why.rs b/airdef-mod/src/sim/why.rs index fc7c736..3dbcc63 100644 --- a/airdef-mod/src/sim/why.rs +++ b/airdef-mod/src/sim/why.rs @@ -334,7 +334,7 @@ impl Sim { let dist = sam.track(t.id).map_or(t.pos, |tr| tr.est(self.tick)).distance(sam.pos); v.push(check( true, - format!("цель: {} {}, {}", t.kind.spec().name, if held { "(сопровождается)" } else { "(самая опасная)" }, crate::panel::distance(dist)), + format!("цель: {} {}, {}", t.kind.spec().name, if held { "(сопровождается)" } else { "(самая опасная)" }, super::distance(dist)), )); let track = sam.track(t.id); let blocks = target_blocks(sam.roles, track, now, t); @@ -447,7 +447,7 @@ impl Sim { no.push(name); } } - v.push(check(far_ok, if far_ok { format!("дальний пуск по РЛС: цель на {} дальше обычных {}", crate::panel::distance(dist), crate::panel::distance(load.max)) } else { format!("дальний пуск по РЛС не разрешён: {}", no.join(", ")) })); + v.push(check(far_ok, if far_ok { format!("дальний пуск по РЛС: цель на {} дальше обычных {}", super::distance(dist), super::distance(load.max)) } else { format!("дальний пуск по РЛС не разрешён: {}", no.join(", ")) })); } let free = first_claim; let need = sam.doctrine.needed(t); @@ -493,8 +493,8 @@ impl Sim { cand, format!( "орудие: сопровождение до {}, цель на {}, других орудий на ней {on_it}{}", - crate::panel::distance(gun.track), - crate::panel::distance(dist), + super::distance(gun.track), + super::distance(dist), if cand { "" } else { " — вне сопровождения (нужна своя РЛС)" } ), )); @@ -502,7 +502,7 @@ impl Sim { let mut why = Vec::new(); for (bit, name) in [ (AIM_ERROR, format!("орудие ещё наводится ({:.0}°)", err.to_degrees())), - (OUT_OF_RANGE, format!("дальше {}", crate::panel::distance(gun.range))), + (OUT_OF_RANGE, format!("дальше {}", super::distance(gun.range))), (COOLDOWN, "пауза между очередями".to_string()), (RELOADING, "перезарядка".to_string()), (NO_ROUNDS, "нет снарядов".to_string()), diff --git a/airdef-mod/src/smoke.rs b/airdef-mod/src/smoke.rs index bf76772..caccd92 100644 --- a/airdef-mod/src/smoke.rs +++ b/airdef-mod/src/smoke.rs @@ -290,6 +290,34 @@ mod tests { use super::*; use crate::sim::{DroneKind, MissileKind}; + #[test] + fn replay_smoke_has_trails_and_fire_columns_and_is_a_function_of_time() { + use crate::sim::record::{Event, EventKind, Recording, What}; + let sim = Sim::new(1); + let mut rec = Recording::blank(160); + for id in 1..=2 { + let x = (id - 1) as f64 * 30.0; + rec.straight(id, What::Drone(DroneKind::Geran3), 0, 160, + DVec3::new(x, 80.0, 0.0), DVec3::new(x, 80.0, 500.0)); + } + assert_eq!(rec.tracks.len(), 2); + rec.events = vec![Event { at: 40, pos: DVec3::new(5.0, 70.0, 5.0), kind: EventKind::Hit { drone: 1, kind: DroneKind::Geran3, power: 2, object: None, sam: None } }]; + let live_state = (sim.tick, sim.fires.list.len()); + let t = 130.0; + let a = replay_smoke(&rec, t); + assert!(a.iter().any(|s| s.clear > 0.0), "trails of both tracks"); + assert!(a.iter().any(|s| s.soot > 0.8), "a column over the fire"); + assert!(a.iter().any(|s| s.soot == 0.55), "the blast's billows"); + let _ = replay_smoke(&rec, 10.0); + let _ = replay_smoke(&rec, 155.0); + let b = replay_smoke(&rec, t); + assert_eq!(a.iter().map(|s| (s.id, s.born as u64)).collect::>(), b.iter().map(|s| (s.id, s.born as u64)).collect::>()); + assert!(a.iter().all(|s| s.born <= t)); + assert!(replay_smoke(&rec, -5.0).is_empty()); + assert!(replay_smoke(&rec, 400.0).iter().any(|s| s.soot == 0.55)); + assert_eq!((sim.tick, sim.fires.list.len()), live_state); + } + #[test] fn live_counter_strike_leaves_a_trail_and_releases_its_source() { use crate::sim::sites::{LaunchSite, SiteClass}; diff --git a/airdef-mod/src/wave.rs b/airdef-mod/src/wave.rs index ce5e1d1..7303061 100644 --- a/airdef-mod/src/wave.rs +++ b/airdef-mod/src/wave.rs @@ -177,22 +177,6 @@ impl WaveConfig { } /// A combined strike of about `n`, as `/airdef wave mix n` orders it. pub fn mix(n: u32, dist: f64) -> RaidOrder { - let n = n.max(4); - let mut kinds = vec![ - (DroneKind::Gerbera, n / 4 + 1), - (DroneKind::Shahed, n / 3 + 1), - (DroneKind::Geran3, n / 8 + 1), - (DroneKind::Kh101, n / 8 + 1), - (DroneKind::Kalibr, n / 10 + 1), - (DroneKind::Kh22, n / 16), - (DroneKind::Zircon, n / 20), - (DroneKind::Iskander, n / 12 + 1), - (DroneKind::Kinzhal, n / 16), - ]; - if n >= 30 { - kinds.push((DroneKind::Oreshnik, 1)); - } - kinds.retain(|k| k.1 > 0); - RaidOrder { kinds, approach: Approach::TwoSectors, dist, aim: Aim::Objects } + RaidOrder::mix(n, dist) } } diff --git a/airdef-mod/tests/core_boundary.rs b/airdef-mod/tests/core_boundary.rs new file mode 100644 index 0000000..6a77f71 --- /dev/null +++ b/airdef-mod/tests/core_boundary.rs @@ -0,0 +1,37 @@ +//! The adapter re-exports the actual core types and keeps deterministic combat traces. +use glam::DVec3; + +macro_rules! trace { + ($sim:path) => {{ + use $sim::{DroneKind, Ground, Out, Sam, SamKind, Sim}; + let mut sim = Sim::new(0x87); + let id = sim.id(); + sim.sams.push(Sam::new(id, SamKind::Patriot, DVec3::new(0.0, 64.0, 0.0), 0.0)); + let mut out = Out::default(); + for (i, kind) in [DroneKind::Shahed, DroneKind::Kh101, DroneKind::Iskander].into_iter().enumerate() { + sim.launch(kind, DVec3::new(i as f64 * 100.0, 110.0, 800.0), -DVec3::Z, DVec3::new(0.0, 64.0, 0.0), &mut out); + } + let mut ground = Ground::default(); + let mut trace = Vec::new(); + for _ in 0..600 { + let wanted = ground.take_wanted(); + ground.answer(wanted.into_iter().map(|(x, z)| (x, z, 64)).collect(), sim.tick); + out = Out::default(); + sim.step(&mut ground, None, &mut out); + trace.push(format!("{}:{}:{}:{}:{:?}:{:?}:{:?}:{:?}", sim.tick, sim.launched, sim.kills, sim.hits, + sim.sams.iter().map(|s| (s.id, s.missiles, s.fired, s.health)).collect::>(), + sim.drones.iter().map(|d| (d.id, d.pos, d.vel)).collect::>(), + sim.missiles.iter().map(|m| (m.id, m.pos)).collect::>(), out.messages)); + } + (trace, sim.sams.iter().map(|s| s.fired).sum::()) + }}; +} + +#[test] +fn adapter_reexports_core_and_combat_trace_is_deterministic() { + let _: airdef_core::sim::Sim = airdef::sim::Sim::new(1); + let adapter = trace!(airdef::sim); + let core = trace!(airdef_core::sim); + assert_eq!(adapter, core); + assert!(adapter.1 > 0, "the trace must include actual interceptor launches"); +}