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 <noreply@openai.com>
This commit is contained in:
@@ -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: |
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
run-home/
|
||||
.horizon-cache/
|
||||
airdef-mod/target/
|
||||
airdef-core/target/
|
||||
__pycache__/
|
||||
/out/
|
||||
.DS_Store
|
||||
|
||||
@@ -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.
|
||||
|
||||

|
||||
@@ -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 <on|off>` — «последний шанс»: комплекс стреляет и по классу вне ролей, если цель вот-вот попадёт (по умолчанию вкл; сохраняется);
|
||||
- `/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).
|
||||
|
||||
Generated
+16
@@ -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"
|
||||
@@ -0,0 +1,13 @@
|
||||
[package]
|
||||
name = "airdef-core"
|
||||
version = "0.2.0"
|
||||
edition = "2024"
|
||||
publish = false
|
||||
|
||||
[dependencies]
|
||||
glam = "0.30"
|
||||
|
||||
[features]
|
||||
test-fixtures = []
|
||||
|
||||
[workspace]
|
||||
@@ -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.
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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));
|
||||
}
|
||||
Generated
+8
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -97,7 +97,7 @@ fn parse_layout(path: &str) -> Vec<Unit> {
|
||||
units
|
||||
}
|
||||
|
||||
fn main() {
|
||||
pub fn main() {
|
||||
let args: Vec<String> = std::env::args().skip(1).collect();
|
||||
let mut name = "big-smart".to_string();
|
||||
let mut i = 0;
|
||||
|
||||
@@ -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)]
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
||||
@@ -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};
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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<crate::engines::Decl>,
|
||||
pub engines: Vec<EngineDecl>,
|
||||
pub engine_cut: bool,
|
||||
pub commands: Vec<String>,
|
||||
/// 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) {
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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::<Vec<_>>(), b.iter().map(|s| (s.id, s.born as u64)).collect::<Vec<_>>());
|
||||
// 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() {
|
||||
|
||||
+104
-80
@@ -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<Estimate>| 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<Obs> {
|
||||
(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<DVec3>) -> (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)));
|
||||
|
||||
@@ -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()),
|
||||
|
||||
@@ -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::<Vec<_>>(), b.iter().map(|s| (s.id, s.born as u64)).collect::<Vec<_>>());
|
||||
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};
|
||||
|
||||
+1
-17
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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::<Vec<_>>(),
|
||||
sim.drones.iter().map(|d| (d.id, d.pos, d.vel)).collect::<Vec<_>>(),
|
||||
sim.missiles.iter().map(|m| (m.id, m.pos)).collect::<Vec<_>>(), out.messages));
|
||||
}
|
||||
(trace, sim.sams.iter().map(|s| s.fired).sum::<u32>())
|
||||
}};
|
||||
}
|
||||
|
||||
#[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");
|
||||
}
|
||||
Reference in New Issue
Block a user