From 0df225a2de23ea7bc414bdf98ef03eb3ed185ec0 Mon Sep 17 00:00:00 2001 From: chexit Date: Mon, 2 Nov 2026 23:51:21 +0300 Subject: [PATCH] Observe emitting radars and expose emission control Co-authored-by: Codex --- airdef-mod/src/commands.rs | 4 ++-- airdef-mod/src/layout.rs | 3 +++ airdef-mod/src/map.rs | 13 +++++++++++- airdef-mod/src/sim/battery.rs | 34 ++++++++++++++++++++++++++++- airdef-mod/src/sim/ew.rs | 8 +++---- airdef-mod/src/sim/raid.rs | 40 +++++++++++++++++++---------------- airdef-mod/src/sim/sight.rs | 11 +++++++++- 7 files changed, 86 insertions(+), 27 deletions(-) diff --git a/airdef-mod/src/commands.rs b/airdef-mod/src/commands.rs index aa3d9f6..d536c6e 100644 --- a/airdef-mod/src/commands.rs +++ b/airdef-mod/src/commands.rs @@ -71,7 +71,7 @@ pub fn tree() -> Tree { Node::lit("target").then(Node::lit("here").runs()), Node::lit("give").runs(), Node::lit("battery").runs().then(Node::arg("номер", Value::int_from(1)).then(Node::arg("режим", Value::choice(["integrated", "launcher"])).runs())), - Node::lit("radar").then(Node::arg("номер", Value::int_from(1)).then(Node::arg("роль", Value::choice(["multi", "search", "engagement", "height"])).runs())), + Node::lit("radar").then(Node::arg("номер", Value::int_from(1)).then(Node::arg("роль", Value::choice(["on", "off", "multi", "search", "engagement", "height"])).runs())), Node::lit("deploy").then(Node::arg("комплекс", Value::choice(deploy_kinds)).runs()), Node::lit("wave").then( Node::arg("тип", Value::choice(wave_kinds)).runs().then( @@ -166,7 +166,7 @@ mod tests { "replay export mine all fps=30 size=1280x720 crf=18 sec=12.5 noaudio", "replay export fps=60", "replay stop", "scenario save a", "scenario save a fresh", "scenario load a", "scenario load a force", "scenario list", "scenario delete a", "why", "why 2", "ew on", "ew off", "move 4", "target here", "targets clear", - "sites", "strike 3", "battery", "battery 1 launcher", "battery 1 integrated", "radar 2 engagement", "radar 2 search", "deploy s400_launcher", "deploy s500_launcher", "deploy radar_search", "deploy radar_track", "deploy radar_height", "deploy patriot", "deploy light", "deploy s400", "wave mix", "wave shahed", "wave shahed 20", "wave kh101 80 800", "wave shahed 20 4000", + "sites", "strike 3", "battery", "battery 1 launcher", "battery 1 integrated", "radar 2 on", "radar 2 off", "radar 2 engagement", "radar 2 search", "deploy s400_launcher", "deploy s500_launcher", "deploy radar_search", "deploy radar_track", "deploy radar_height", "deploy patriot", "deploy light", "deploy s400", "wave mix", "wave shahed", "wave shahed 20", "wave kh101 80 800", "wave shahed 20 4000", "wave oreshnik 1 60", "damage on", "damage off", "truth on", "shader hd", "shader off", "shader on", "hold on", "lastchance on", "lastchance off", "roles all default", "doctrine", "doctrine 1 reserve 4", "doctrine all salvo eco", "doctrine 2 sector 90 45", "doctrine 2 sector off", "doctrine 1 guard 1,2", "doctrine 1 reset", "roles patriot drone,jet", "roles pantsir all", "roles tor ballistic", ] { diff --git a/airdef-mod/src/layout.rs b/airdef-mod/src/layout.rs index 3d6e552..291eec6 100644 --- a/airdef-mod/src/layout.rs +++ b/airdef-mod/src/layout.rs @@ -208,12 +208,15 @@ mod tests { fn battery_modes_load_and_old_layouts_default() { let old = Layout::parse("sam s400 0 70 0 0").unwrap(); assert_eq!(old.sams[0].mode, sim::battery::SamMode::Integrated); + assert!(old.sams[0].radar_on); assert_eq!(old.sams[0].radar_role, sim::battery::RadarRole::Multifunction); let data = "sam s400 0 70 0 0 30 1 0 4 0 0 0 - - - 1 1 1 1 1 1 0 0 0 launcher multi 5\nsam radar 1 70 0 0 0 1 0 0 0 0 0 - - - 1 1 1 1 1 1 0 0 0 integrated engagement"; let l = Layout::parse(data).unwrap(); assert_eq!(l.sams[0].mode, sim::battery::SamMode::LauncherOnly); assert_eq!(l.sams[0].spent_cells, 5); assert_eq!(l.sams[1].radar_role, sim::battery::RadarRole::Engagement); + assert!(!Layout::parse(&data.replace("launcher multi 5", "launcher multi 5 false")).unwrap().sams[0].radar_on); + assert!(Layout::parse(&data.replace("launcher multi 5", "launcher multi 5 nope")).is_err()); assert!(Layout::parse(&data.replace("engagement", "invalid")).is_err()); assert!(Layout::parse(&data.replace("launcher multi 5", "launcher multi 256")).is_err()); } diff --git a/airdef-mod/src/map.rs b/airdef-mod/src/map.rs index fc374c9..a0c78bf 100644 --- a/airdef-mod/src/map.rs +++ b/airdef-mod/src/map.rs @@ -104,6 +104,7 @@ enum Act { Reserve, Salvo, Role(u8), + RadarEmission, ResetDoc, Strike(u32), Centre, @@ -1033,6 +1034,12 @@ impl AirDef { } ui.mode = Mode::View; } + Act::RadarEmission => { + if let Some(i) = self.sel_sam(ui) { + self.sim.sams[i].radar_on = !self.sim.sams[i].radar_on; + self.dirty = true; + } + } Act::Role(bit) => { if let Some(i) = self.sel_sam(ui) { let s = &mut self.sim.sams[i]; @@ -2327,6 +2334,10 @@ impl AirDef { } else if sam.mode == sim::battery::SamMode::LauncherOnly { lines.push(("Отдельная пусковая: внешняя РЛС сопровождения".into(), WHITE)); } + if k.spec().sensor > 0.0 && sam.mode != sim::battery::SamMode::LauncherOnly { + lines.push((format!("Излучение РЛС: {}", if sam.radar_on { "вкл" } else { "выкл" }), WHITE)); + btns.push((if sam.radar_on { "Выключить РЛС" } else { "Включить РЛС" }.into(), Act::RadarEmission, sam.radar_on)); + } lines.push((sam.status(), WHITE)); lines.push((format!("Исправен {:.0}% · {:.0} {:.0}", sam.health * 100.0, sam.pos.x, sam.pos.z), GREY)); if k.armed() { @@ -2334,7 +2345,7 @@ impl AirDef { lines.push((format!("Боезапас: {} ракет, {} очередей{}", sam.missiles, sam.bursts, if sam.reloading > 0 { format!(" · перезарядка {} с", sam.reloading / 20) } else { String::new() }), WHITE)); lines.push((format!("Дальность огня: {}", distance(k.range())), WHITE)); } - if let Some(sensor) = self.sim.low_sensor(k, sam.pos, Some(sam.id)) { + if let Some(sensor) = sam.radar_up().then(|| self.sim.low_sensor(k, sam.pos, Some(sam.id))).flatten() { lines.push((format!("Свой обзор для низкой цели: {} (зелёное кольцо)", distance(sensor.reach)), WHITE)); } if let Some(t) = sam.gun_target.or(sam.target) { diff --git a/airdef-mod/src/sim/battery.rs b/airdef-mod/src/sim/battery.rs index 8af5cc9..52e9b9d 100644 --- a/airdef-mod/src/sim/battery.rs +++ b/airdef-mod/src/sim/battery.rs @@ -73,7 +73,7 @@ impl Sim { pub fn battery_status(&self) -> Vec { self.sams.iter().enumerate().filter_map(|(i, sam)| { if sam.kind == SamKind::Radar { - Some(format!("№{} РЛС: {}, {}", i + 1, sam.radar_role.name(), if sam.radar_up() && sam.link_range() > 0.0 { "работает" } else { "нет связи или РЛС не работает" })) + Some(format!("№{} РЛС: {}, излучение {}, {}", i + 1, sam.radar_role.name(), if sam.radar_on { "вкл" } else { "выкл" }, if sam.radar_up() && sam.link_range() > 0.0 { "работает" } else { "нет связи или РЛС не работает" })) } else if matches!(sam.kind, SamKind::S400 | SamKind::S500) { let linked = self.sams.iter().any(|r| r.kind == SamKind::Radar && r.radar_role.authorizes() && r.radar_up() && r.link_range() > 0.0 && sam.link_range() > 0.0 && r.pos.distance(sam.pos) <= r.link_range().min(sam.link_range())); Some(format!("№{} {}: {}{}", i + 1, sam.kind.name(), sam.mode.key(), if sam.mode == SamMode::LauncherOnly { if linked { ", РЛС связана" } else { ", нет связанной РЛС сопровождения" } } else { "" })) @@ -255,3 +255,35 @@ mod tests { assert_eq!(other.mode, SamMode::Integrated); } } + +#[cfg(test)] +mod emission_tests { + use super::*; + #[test] + fn silence_accepts_network_without_inventing_own_measurements_and_contacts_age() { + let mut sim = Sim::new(121); + sim.tick = 20; + let mut source = Sam::new(1, SamKind::Radar, DVec3::ZERO, 0.0); + let mut receiver = Sam::new(2, SamKind::Radar, DVec3::ZERO, 0.0); + receiver.set_radar_role(RadarRole::Engagement); + receiver.radar_on = false; + sim.add_drone(DroneKind::Kh101, DVec3::new(0.0, 70.0, -100.0), DVec3::Z, DVec3::ZERO); + let id = sim.drones[0].id; + source.tracks.push(Track { id, kind: DroneKind::Kh101, pos: sim.drones[0].pos, vel: DVec3::ZERO, tick: 20, own_tick: 20, ttl: 200, hits: 2, locked: false }); + sim.sams.extend([source, receiver]); + let mut ground = Ground::default(); + sim.update_sight(&mut ground); + assert_eq!(sim.sams[1].track(id).unwrap().own_tick, 0); + assert_eq!(sim.sams[1].track(id).unwrap().tick, 20); + assert!(!sim.sams[1].radar_up()); + assert_eq!(sim.known_units(), vec![(1, DVec3::ZERO)]); + sim.sams[0].radar_on = false; + sim.sams[0].pos = DVec3::new(400.0, 0.0, 0.0); + sim.tick = 40; + sim.update_sight(&mut ground); + assert_eq!(sim.known_units(), vec![(1, DVec3::ZERO)]); + assert_eq!(sim.sams[1].track(id).unwrap().tick, 20); + sim.tick = 6020; + assert!(sim.known_units().is_empty()); + } +} diff --git a/airdef-mod/src/sim/ew.rs b/airdef-mod/src/sim/ew.rs index 580905f..05b9c1c 100644 --- a/airdef-mod/src/sim/ew.rs +++ b/airdef-mod/src/sim/ew.rs @@ -137,7 +137,8 @@ impl Sim { /// Units the enemy knows of and would go for, newest first: (id, where). pub(super) fn known_units(&self) -> Vec<(u32, DVec3)> { let mut out: Vec<(u32, DVec3)> = Vec::new(); - for &(id, at, _) in self.intel.revealed.iter().rev() { + for &(id, at, observed) in self.intel.revealed.iter().rev() { + if self.tick.saturating_sub(observed) >= 6000 { continue; } if !out.iter().any(|o| o.0 == id) && self.sams.iter().any(|s| s.id == id && s.alive()) { out.push((id, at)); } @@ -145,10 +146,9 @@ impl Sim { out } - /// Whether the enemy has this unit in its intelligence: a radar emits, so it is known; the rest only once - /// they gave themselves away (a launch, jamming) and are in `known`. + /// Whether an observation or another disclosure placed this unit in enemy intelligence. pub(super) fn enemy_knows(s: &Sam, known: &[(u32, DVec3)]) -> bool { - s.alive() && (s.kind == SamKind::Radar || known.iter().any(|k| k.0 == s.id)) + s.alive() && known.iter().any(|k| k.0 == s.id) } /// During a smart raid: its drones not yet launched go for what just gave itself away (a jamming station diff --git a/airdef-mod/src/sim/raid.rs b/airdef-mod/src/sim/raid.rs index 0bccf9d..0495359 100644 --- a/airdef-mod/src/sim/raid.rs +++ b/airdef-mod/src/sim/raid.rs @@ -588,10 +588,14 @@ impl Sim { // themselves away; the rest of the player's layout is not in its hands. let known = self.known_units(); let alive: Vec<&Sam> = alive.iter().copied().filter(|s| Self::enemy_knows(s, &known)).collect(); + let pick_known = |list: &[&Sam], r: f64| -> Option<(DVec3, u32)> { + let s = *list.get((r * list.len() as f64) as usize)?; + known.iter().find(|(id, _)| *id == s.id).map(|(_, pos)| (*pos, s.id)) + }; let known_ew: Vec<&Sam> = alive.iter().copied().filter(|s| s.kind == SamKind::Ew).collect(); if matches!(kind, DroneKind::Lancet | DroneKind::Kh22) && !known_ew.is_empty() && roll < 0.6 { let s = known_ew[(r * known_ew.len() as f64) as usize % known_ew.len()]; - return (s.pos, Some(s.id)); + return (known.iter().find(|(id, _)| *id == s.id).unwrap().1, Some(s.id)); } // Short-range systems (guns, Tor, P1-SUN) for the Lancets; long-range ones and the radars // that feed them for the fast missiles. @@ -599,10 +603,10 @@ impl Sim { let long: Vec<&Sam> = alive.iter().copied().filter(|s| s.kind.range() >= 450.0 || s.kind == SamKind::Radar).collect(); let choice = match kind { - DroneKind::Lancet if roll < 0.75 => pick(&guns, r).or_else(|| pick(&alive, r)), - DroneKind::Kh22 | DroneKind::Zircon if roll < 0.45 => pick(&long, r), - DroneKind::Iskander | DroneKind::Kinzhal if roll < 0.3 => pick(&long, r), - DroneKind::Shahed | DroneKind::Geran3 if roll < 0.2 => pick(&alive, r), + DroneKind::Lancet if roll < 0.75 => pick_known(&guns, r).or_else(|| pick_known(&alive, r)), + DroneKind::Kh22 | DroneKind::Zircon if roll < 0.45 => pick_known(&long, r), + DroneKind::Iskander | DroneKind::Kinzhal if roll < 0.3 => pick_known(&long, r), + DroneKind::Shahed | DroneKind::Geran3 if roll < 0.2 => pick_known(&alive, r), _ => None, }; choice.map_or((object, None), |(p, id)| (p, Some(id))) @@ -627,11 +631,12 @@ impl Sim { if kind.jammable() { let known = self.known_units(); for s in self.sams.iter().filter(|s| s.kind == SamKind::Ew && s.alive() && known.iter().any(|k| k.0 == s.id)) { - let (ci, cj) = grid.index(s.pos); + let pos = known.iter().find(|(id, _)| *id == s.id).unwrap().1; + let (ci, cj) = grid.index(pos); let r = (EW_RADIUS / grid.cell).ceil() as i32; for i in (ci - r).max(0)..=(ci + r).min(n - 1) { for j in (cj - r).max(0)..=(cj + r).min(n - 1) { - if horizontal(grid.centre((i, j), s.pos.y) - s.pos).length() < EW_RADIUS { + if horizontal(grid.centre((i, j), pos.y) - pos).length() < EW_RADIUS { prices[(i * n + j) as usize] += 12.0; } } @@ -640,30 +645,29 @@ impl Sim { } // Only the systems the enemy knows of are priced in (#93): a hidden one is flown into. for s in self.sams.iter().filter(|s| s.alive() && s.roles & spec.class.bit() != 0 && Self::enemy_knows(s, &known_now)) { + let pos = known_now.iter().find(|(id, _)| *id == s.id).unwrap().1; let sp = s.kind.spec(); let (lo, hi) = sp.missile.as_ref().map_or((0.0, 0.0), |m| (m.min, m.max)); let gun = sp.gun.as_ref().map_or(0.0, |g| g.range); // A gun needs its own radar; a missile also flies on the tracks of any radar within data-link range. let own = sight::reach(s, kind, height); - let net = self - .sams - .iter() - .filter(|r| r.alive() && r.id != s.id && r.pos.distance(s.pos) <= sight::LINK) - .map(|r| sight::reach(r, kind, height)) - .fold(0.0, f64::max); + let net = self.sams.iter().filter_map(|r| { + let at = known_now.iter().find(|(id, _)| *id == r.id)?.1; + (r.id != s.id && at.distance(pos) <= sight::LINK).then(|| sight::reach(r, kind, height)) + }).fold(0.0, f64::max); let (hi, gun) = (hi.min(own.max(net)), gun.min(own)); let far = hi.max(gun); - let at = sight::antenna(s); - let horizon = self.horizons.get(&s.id); + let at = pos + (sight::antenna(s) - s.pos); + let horizon = (pos == s.pos).then(|| self.horizons.get(&s.id)).flatten(); // Only the cells round it. - let (ci, cj) = grid.index(s.pos); + let (ci, cj) = grid.index(pos); let r = (far / grid.cell).ceil() as i32 + 1; for i in (ci - r).max(0)..=(ci + r).min(n - 1) { for j in (cj - r).max(0)..=(cj + r).min(n - 1) { cost += 1; let ground = land.map(|l| l[(i * n + j) as usize]).filter(|g| g.is_finite()); - let p = grid.centre((i, j), ground.map_or(s.pos.y + height, |g| f64::from(g) + height)); - let d = horizontal(p - s.pos).length(); + let p = grid.centre((i, j), ground.map_or(pos.y + height, |g| f64::from(g) + height)); + let d = horizontal(p - pos).length(); if d > far { continue; } diff --git a/airdef-mod/src/sim/sight.rs b/airdef-mod/src/sim/sight.rs index c5ff5f0..c33a661 100644 --- a/airdef-mod/src/sim/sight.rs +++ b/airdef-mod/src/sim/sight.rs @@ -466,11 +466,20 @@ impl Sim { } // The network: every unit gets the tracks the radars within data-link range measured themselves, // with the time they were measured: a relayed track is never fresher than its measurement. + // Fictional passive observation: hostile aircraft within 1600 blocks fix an emitting radar + // every 20 ticks. Silence leaves the previous report to age normally. + if now % 20 == 0 { + for s in self.sams.iter().filter(|s| s.radar_up()) { + if self.drones.iter().any(|d| d.alive() && d.pos.distance(s.pos) <= 1600.0) { + self.intel.revealed.push((s.id, s.pos, now)); + } + } + } let shared: Vec<(u32, DVec3, f64, Vec)> = self .sams .iter() .filter(|s| s.radar_up() && s.link_range() > 0.0) - .map(|s| (s.id, s.pos, s.link_range(), s.tracks.iter().filter(|t| t.own_tick == t.tick).copied().collect::>())) + .map(|s| (s.id, s.pos, s.link_range(), s.tracks.iter().filter(|t| t.own_tick > 0 && t.own_tick == t.tick).copied().collect::>())) .filter(|s| !s.3.is_empty()) .collect(); for sam in &mut self.sams {