//! 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::>()); 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()); }