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 <noreply@openai.com>
This commit is contained in:
2026-11-02 01:17:03 +03:00
parent 4761ae7d70
commit 5f07027faa
27 changed files with 371 additions and 194 deletions
+7 -2
View File
@@ -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
View File
@@ -1,6 +1,7 @@
run-home/
.horizon-cache/
airdef-mod/target/
airdef-core/target/
__pycache__/
/out/
.DS_Store
+9 -6
View File
@@ -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 <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).
+16
View File
@@ -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"
+13
View File
@@ -0,0 +1,13 @@
[package]
name = "airdef-core"
version = "0.2.0"
edition = "2024"
publish = false
[dependencies]
glam = "0.30"
[features]
test-fixtures = []
[workspace]
+13
View File
@@ -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.
+8
View File
@@ -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();
}
+6
View File
@@ -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;
+37
View File
@@ -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));
}
+8
View File
@@ -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"
+4
View File
@@ -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
+1 -1
View File
@@ -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;
+1 -14
View File
@@ -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)]
+13
View File
@@ -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");
}
}
+1 -1
View File
@@ -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};
+1 -4
View File
@@ -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 {
+1 -1
View File
@@ -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 {
-12
View File
@@ -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:
+21 -2
View File
@@ -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) {
+32 -7
View File
@@ -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() {
+3 -3
View File
@@ -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);
-39
View File
@@ -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
View File
@@ -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)));
+5 -5
View File
@@ -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()),
+28
View File
@@ -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
View File
@@ -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)
}
}
+37
View File
@@ -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");
}