Observe emitting radars and expose emission control

Co-authored-by: Codex <codex@openai.com>
This commit is contained in:
2026-11-02 23:51:21 +03:00
parent 26b258f00f
commit 0df225a2de
7 changed files with 86 additions and 27 deletions
+2 -2
View File
@@ -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",
] {
+3
View File
@@ -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());
}
+12 -1
View File
@@ -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) {
+33 -1
View File
@@ -73,7 +73,7 @@ impl Sim {
pub fn battery_status(&self) -> Vec<String> {
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());
}
}
+4 -4
View File
@@ -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
+22 -18
View File
@@ -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;
}
+10 -1
View File
@@ -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<Track>)> = 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::<Vec<_>>()))
.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::<Vec<_>>()))
.filter(|s| !s.3.is_empty())
.collect();
for sam in &mut self.sams {