Files
airdef/airdef-mod/src/sim/sensor_tests.rs
T
chexit c96676f3e5
build / mod (push) Failing after 18s
feat: attenuate indirect blast damage behind known cover
Use a bounded read-only pre-destruction occupancy sample; preserve conservative damage for unresolved geometry and do not defer or repeat damage on replies.

Co-authored-by: Codex <codex@openai.com>
2026-11-02 23:55:38 +03:00

257 lines
10 KiB
Rust

//! Radar scan cycles with ageing tracks (#46) and damage to a system's parts (#50).
use super::sight::{swept, FORECAST_AGE};
use super::*;
fn flat() -> (Ground, Out) {
(Ground::default(), Out::default())
}
fn radar_at(sim: &mut Sim, kind: SamKind, x: f64) -> usize {
let id = sim.id();
sim.sams.push(Sam::new(id, kind, DVec3::new(x, 70.0, 0.0), 0.0));
sim.sams.len() - 1
}
/// A Shahed flying east along z = 40, from x = `from`, high enough to be above any horizon of the systems.
fn drone_from(sim: &mut Sim, from: f64) -> u32 {
sim.add_drone(DroneKind::Shahed, DVec3::new(from, 95.0, 40.0), DVec3::X, DVec3::new(5000.0, 95.0, 40.0));
sim.drones.last().unwrap().id
}
// #46: the radar measures a target when the beam passes it; between passes its track stays where it was measured.
#[test]
fn a_radar_measures_once_a_turn_and_the_track_stays_put_between() {
let mut sim = Sim::new(5);
let (mut ground, mut out) = flat();
let r = radar_at(&mut sim, SamKind::Radar, 0.0);
let id = drone_from(&mut sim, -150.0);
let (mut seen_at, mut last) = (Vec::new(), None::<(u64, DVec3)>);
let mut stale_gap = 0.0f64;
for _ in 0..700 {
sim.step(&mut ground, None, &mut out);
let Some(t) = sim.sams[r].track(id).copied() else { continue };
match last {
Some((tick, pos)) if tick == t.tick => {
assert_eq!(pos, t.pos, "the track moved without a new measurement");
let d = sim.drones.iter().find(|d| d.id == id).unwrap();
stale_gap = stale_gap.max(d.pos.distance(t.pos));
if t.age(sim.tick) > FORECAST_AGE {
assert!(t.forecast(sim.tick));
}
}
_ => seen_at.push(t.tick),
}
last = Some((t.tick, t.pos));
}
assert!(seen_at.len() >= 2, "measured {} times", seen_at.len());
// A turn of the early-warning radar is 140 ticks (less relative to a target that moves round it): never every tick.
assert!(seen_at.windows(2).all(|w| w[1] - w[0] >= 60), "{seen_at:?}");
assert!(stale_gap > 20.0, "the track never fell behind the target ({stale_gap})");
}
#[test]
fn a_beam_crossing_north_between_ticks_still_passes_a_target_there() {
let tau = std::f64::consts::TAU;
assert!(swept(0.01, tau - 0.05, 0.1));
assert!(swept(tau - 0.02, tau - 0.05, 0.1));
assert!(!swept(0.2, tau - 0.05, 0.1));
assert!(!swept(tau - 0.1, tau - 0.05, 0.1));
}
#[test]
fn two_radars_have_their_own_phase_and_the_network_keeps_the_age() {
let mut sim = Sim::new(7);
let (mut ground, mut out) = flat();
let a = radar_at(&mut sim, SamKind::Radar, 0.0);
let b = radar_at(&mut sim, SamKind::Radar, 500.0);
let n = radar_at(&mut sim, SamKind::P1sun, 250.0);
assert_ne!(sim.sams[a].beam, sim.sams[b].beam, "radars start at different phases");
let id = drone_from(&mut sim, -50.0);
let mut relayed = 0;
for _ in 0..800 {
sim.step(&mut ground, None, &mut out);
let own = |s: usize| sim.sams[s].track(id).map_or(0, |t| t.own_tick);
let Some(t) = sim.sams[n].track(id) else { continue };
relayed += 1;
assert_eq!(t.own_tick, 0, "a relayed measurement is not the receiver's own");
assert!(t.tick <= own(a).max(own(b)), "the network made a track fresher than any measurement");
if own(a).max(own(b)) > 0 {
assert_eq!(t.tick, own(a).max(own(b)), "the network holds the freshest measurement");
}
}
assert!(relayed > 100, "the network never passed a track ({relayed})");
}
#[test]
fn a_lock_measures_every_tick_and_a_dead_radar_stops_the_feed() {
let mut sim = Sim::new(3);
let (mut ground, mut out) = flat();
let p = radar_at(&mut sim, SamKind::Pantsir, 0.0);
let net = radar_at(&mut sim, SamKind::P1sun, 100.0);
let id = drone_from(&mut sim, -190.0);
let mut locked = 0;
for _ in 0..600 {
sim.step(&mut ground, None, &mut out);
if sim.sams[p].target == Some(id)
&& let Some(t) = sim.sams[p].track(id)
&& t.age(sim.tick) <= 1
&& t.locked
{
locked += 1;
}
}
assert!(locked > 5, "no fire-control lock ({locked})");
// The radar is destroyed: the network's track is no longer renewed.
let newest = sim.sams[net].tracks.iter().map(|t| t.tick).max();
sim.sams[p].health = 0.0;
let drones = sim.drones.iter().filter(|d| d.alive()).count();
for _ in 0..40 {
sim.step(&mut ground, None, &mut out);
}
assert!(sim.sams[p].tracks.is_empty() && sim.sams[p].seen.is_empty());
if drones > 0 {
assert!(sim.sams[net].tracks.iter().map(|t| t.tick).max() <= newest, "a dead radar still renewed a track");
}
}
#[test]
fn a_failed_radar_gives_no_own_tracks_but_the_network_still_feeds_it() {
let mut sim = Sim::new(9);
let (mut ground, mut out) = flat();
let p = radar_at(&mut sim, SamKind::Patriot, 0.0);
let early = radar_at(&mut sim, SamKind::Radar, 120.0);
let id = drone_from(&mut sim, -150.0);
sim.sams[p].comps.0[Part::Sensor as usize] = 0.0;
for _ in 0..500 {
sim.step(&mut ground, None, &mut out);
}
assert!(!sim.sams[p].radar_up());
assert!(sim.sams[p].own.is_empty(), "a failed radar measured something");
assert!(sim.sams[p].track(id).is_some(), "the network should still feed the system");
// Without its link it is blind.
sim.sams[p].comps.0[Part::Comms as usize] = 0.0;
sim.sams[p].tracks.clear();
for _ in 0..200 {
sim.step(&mut ground, None, &mut out);
}
assert!(sim.sams[p].tracks.is_empty(), "no link, no network tracks");
assert!(sim.sams[early].own.len() >= sim.sams[p].own.len());
}
// #50: a blast hurts the parts nearest to it.
#[test]
fn a_blast_from_behind_hurts_the_link_a_blast_from_the_front_the_drive() {
let at = DVec3::new(0.0, 70.0, 0.0);
let hurt = |dz: f64| {
let mut sim = Sim::new(4);
let id = sim.id();
sim.sams.push(Sam::new(id, SamKind::Patriot, at, 0.0));
let mut out = Out::default();
sim.blast_damage(at + DVec3::new(0.0, 3.0, dz), 2, &Ground::default(), &mut out);
assert!(sim.sams[0].alive());
let c = sim.sams[0].comps;
(1.0 - c.get(Part::Comms), 1.0 - c.get(Part::Mount), 1.0 - c.get(Part::Sensor))
};
let (back_comms, back_mount, _) = hurt(6.0);
let (front_comms, front_mount, _) = hurt(-6.0);
assert!(back_comms > back_mount, "behind: link {back_comms} drive {back_mount}");
assert!(front_mount > front_comms, "front: link {front_comms} drive {front_mount}");
}
#[test]
fn a_close_blast_can_leave_a_firing_but_immobile_system() {
// Over many blasts at moderate range some leave the system able to fire and unable to drive.
let mut found = false;
for seed in 0..60 {
let mut sim = Sim::new(seed);
let id = sim.id();
sim.sams.push(Sam::new(id, SamKind::Pantsir, DVec3::new(0.0, 70.0, 0.0), 0.0));
let mut out = Out::default();
sim.tick = seed;
sim.blast_damage(DVec3::new(0.0, 69.0, 6.0 + seed as f64 * 0.06), 3, &Ground::default(), &mut out);
let s = &sim.sams[0];
if s.alive() && s.part(Part::Tracks) <= 0.0 && s.part(Part::Launcher) > 0.0 && s.part(Part::Gun) > 0.0 {
assert!(!sim.order_move(0, DVec3::new(300.0, 70.0, 0.0)), "failed running gear must refuse a march");
assert!(why::missile_blocks(&sim.sams[0]) & why::LAUNCHER_DOWN == 0);
assert!(sim.why(0).iter().any(|c| c.text.contains("ходовая отказала")), "{:?}", sim.why(0).iter().map(|c| &c.text).collect::<Vec<_>>());
found = true;
break;
}
}
assert!(found, "no seed left it firing but immobile");
}
#[test]
fn a_fresh_own_radar_track_keeps_gun_hits_equal_day_and_night() {
fn firing(seed: u64, night: bool) -> (u32, u32) {
let mut sim = Sim::new(seed);
let (mut ground, mut out) = flat();
let gun = radar_at(&mut sim, SamKind::Gepard, 0.0);
sim.night = night;
sim.add_drone(
DroneKind::Shahed,
DVec3::new(0.0, 95.0, 35.0),
DVec3::ZERO,
DVec3::new(5000.0, 95.0, 35.0),
);
let starting_bursts = sim.sams[gun].bursts;
for _ in 0..240 {
sim.step(&mut ground, None, &mut out);
}
(starting_bursts - sim.sams[gun].bursts, sim.sams[gun].kills)
}
for seed in 0..32 {
let day = firing(seed, false);
let night = firing(seed, true);
assert!(day.0 > 0, "seed {seed} did not fire");
assert_eq!(night, day, "seed {seed}: radar gun changed its fire or kills at night");
}
}
#[test]
fn failed_parts_block_what_they_serve_and_repair_restores_them() {
let mut sim = Sim::new(2);
let g = radar_at(&mut sim, SamKind::Gepard, 0.0);
let p = radar_at(&mut sim, SamKind::Patriot, 400.0);
sim.sams[g].comps.0[Part::Gun as usize] = 0.0;
assert!(!sim.sams[g].gun_up());
assert!(why::gun_blocks(&sim.sams[g], 1, 1, 0.0, 10.0, 90.0, 0) & why::JAMMED != 0);
sim.sams[p].comps.0[Part::Launcher as usize] = 0.0;
assert!(why::missile_blocks(&sim.sams[p]) & why::LAUNCHER_DOWN != 0);
// A worn launcher: fewer cells, a longer reload.
sim.sams[p].comps.0[Part::Launcher as usize] = 0.3;
sim.sams[p].fit_cells();
let full = Sam::new(0, SamKind::Patriot, DVec3::ZERO, 0.0);
assert!(sim.sams[p].cells() < full.cells());
assert!(sim.sams[p].missiles <= sim.sams[p].cells());
assert!(sim.sams[p].reload_time() > full.reload_time());
// A worn mount turns slower, a failed one not at all.
sim.sams[p].comps.0[Part::Mount as usize] = 0.0;
assert_eq!(sim.sams[p].drive_fit(), 0.0);
sim.sams[p].comps.0[Part::Tracks as usize] = 0.0;
assert_eq!(sim.repair(), 2, "both damaged systems are counted");
assert!(sim.sams.iter().all(|s| s.comps.whole() && s.cells() == s.kind.spec().missile.as_ref().map_or(0, |m| m.magazine)));
assert!(sim.later.is_empty(), "repair must not leave cook-offs behind");
assert!(sim.order_move(p, DVec3::new(500.0, 70.0, 0.0)));
}
#[test]
fn the_same_seed_rolls_the_same_scan_and_damage() {
let run = || {
let mut sim = Sim::new(11);
let (mut ground, mut out) = flat();
let r = radar_at(&mut sim, SamKind::Radar, 0.0);
let id = drone_from(&mut sim, -150.0);
let mut ticks = Vec::new();
for _ in 0..500 {
sim.step(&mut ground, None, &mut out);
if let Some(t) = sim.sams[r].track(id) {
ticks.push(t.tick);
}
}
ticks
};
assert_eq!(run(), run());
}