From f3985d536388afa880568dd1bbfa4e9c07870cd4 Mon Sep 17 00:00:00 2001 From: chexit Date: Mon, 2 Nov 2026 23:53:23 +0300 Subject: [PATCH] Record energy loss and exercise meeting geometry Co-authored-by: Codex --- airdef-mod/src/sim/mod.rs | 14 +++++++- airdef-mod/src/sim/record.rs | 5 +++ airdef-mod/src/sim/regress.rs | 68 +++++++++++++++++++++++++++++++++++ 3 files changed, 86 insertions(+), 1 deletion(-) diff --git a/airdef-mod/src/sim/mod.rs b/airdef-mod/src/sim/mod.rs index fdc0708..7fb0110 100644 --- a/airdef-mod/src/sim/mod.rs +++ b/airdef-mod/src/sim/mod.rs @@ -2957,11 +2957,12 @@ impl Sim { fn step_missiles(&mut self, ground: &mut Ground, out: &mut Out) { let now = self.tick; let mut done: Vec<(u32, Option, bool, u32)> = Vec::new(); // (missile, target, kill, sam) - let (mut missed, mut expired, mut orphaned) = (0u32, 0u32, 0u32); + let (mut missed, mut expired, mut orphaned, mut energy_lost) = (0u32, 0u32, 0u32, 0u32); let (mut blocked, mut scrubbed) = (0u32, 0u32); let mut impacts = HashMap::new(); let mut ignitions = Vec::new(); let mut burnouts = Vec::new(); + let mut energy_losses = Vec::new(); #[cfg(test)] let mut scrub_log = Vec::new(); let sams_fit: HashMap = self.sams.iter().map(|s| (s.id, 1.0 - 0.3 * (1.0 - s.health.clamp(0.0, 1.0)))).collect(); @@ -3107,6 +3108,13 @@ impl Sim { done.push((m.id, None, false, m.sam)); continue; } + // A spent motor cannot sustain guidance once it has lost most of its speed. + if coast && speed < spec.speed * 0.25 && target.is_some() { + energy_lost += 1; + energy_losses.push((m.pos, m.id, m.kind, speed)); + done.push((m.id, None, false, m.sam)); + continue; + } if m.age > spec.life || (target.is_none() && m.age > 30) { // Its target gone (shot down by another, or lost), or its life spent chasing one. if target.is_none() { @@ -3145,11 +3153,15 @@ impl Sim { for (pos, missile, kind) in burnouts { self.scene_event(pos, record::SceneKind::MotorBurnout { missile, kind }); } + for (pos, missile, kind, speed) in energy_losses { + self.scene_event(pos, record::SceneKind::MotorEnergyLost { missile, kind, speed }); + } #[cfg(test)] self.scrubs.extend(scrub_log); self.note(|t| { t.missed += missed; t.expired += expired; + t.energy_lost += energy_lost; t.orphaned += orphaned; t.blocked += blocked; t.scrubbed += scrubbed; diff --git a/airdef-mod/src/sim/record.rs b/airdef-mod/src/sim/record.rs index f578b74..30d0bfe 100644 --- a/airdef-mod/src/sim/record.rs +++ b/airdef-mod/src/sim/record.rs @@ -254,6 +254,7 @@ pub enum SceneKind { MissileLaunch { missile: u32, sam: u32, target: u32, kind: MissileKind, sound: &'static str, pitch: f32 }, MotorIgnition { missile: u32, kind: MissileKind, sound: Option<&'static str> }, MotorBurnout { missile: u32, kind: MissileKind }, + MotorEnergyLost { missile: u32, kind: MissileKind, speed: f64 }, TrackAcquired { sam: u32, target: u32, ever_own: bool }, TrackLost { sam: u32, target: u32 }, TrackSource { sam: u32, target: u32, ever_own: bool }, @@ -313,6 +314,8 @@ pub struct Tally { pub missed: u32, /// Interceptors whose life ran out with the target still flying. pub expired: u32, + /// Interceptors whose post-burn speed fell below their guidance threshold. + pub energy_lost: u32, /// Interceptors whose target was gone (shot down by another, or lost) before they arrived. pub orphaned: u32, /// Times a system lost the track of the target it held, which was still in the sky. @@ -330,6 +333,7 @@ impl Tally { Tally { missed: self.missed.saturating_sub(start.missed), expired: self.expired.saturating_sub(start.expired), + energy_lost: self.energy_lost.saturating_sub(start.energy_lost), orphaned: self.orphaned.saturating_sub(start.orphaned), tracks_lost: self.tracks_lost.saturating_sub(start.tracks_lost), ammo_waits: self.ammo_waits.saturating_sub(start.ammo_waits), @@ -342,6 +346,7 @@ impl Tally { let parts: Vec = [ (self.missed, "промахов по взрывателю"), (self.expired, "ракет выработали ресурс"), + (self.energy_lost, "ракет потеряли скорость после выгорания"), (self.orphaned, "ракет остались без цели"), (self.tracks_lost, "потерь трека"), (self.ammo_waits, "ожиданий БК"), diff --git a/airdef-mod/src/sim/regress.rs b/airdef-mod/src/sim/regress.rs index 65e8863..b2958bc 100644 --- a/airdef-mod/src/sim/regress.rs +++ b/airdef-mod/src/sim/regress.rs @@ -1899,3 +1899,71 @@ fn interceptor_lead_depends_on_meeting_geometry() { assert!(crossing.x > 0.0, "crossing target must be led"); assert!(approaching.x.abs() < 1e-9 && receding.x.abs() < 1e-9); } + +#[test] +fn energy_loss_is_recorded_separately_from_expiry() { + let mut sim = Sim::new(120); + let kind = MissileKind::Pac3; + let pos = DVec3::new(0.0, 100.0, 900.0); + sim.add_drone(DroneKind::Shahed, pos, DVec3::ZERO, DVec3::new(0.0, 100.0, 5000.0)); + sim.begin_recording_if_active(); + let (target, id) = (sim.drones[0].id, sim.id()); + sim.tick = 100; + let fix = Some(Fix { pos, vel: DVec3::ZERO, tick: 100, seeker: false }); + sim.missiles.push(Missile { id, kind, pos: DVec3::new(0.0, 100.0, 0.0), vel: DVec3::Z * kind.spec().speed * 0.2, target, sam: 999, age: kind.burn_ticks() + 1, hold: 0, fix, seen: 0 }); + sim.step_missiles(&mut Ground::default(), &mut Out::default()); + assert!(sim.missiles.is_empty()); + assert_eq!(sim.tally.energy_lost, 1); + assert_eq!(sim.tally.expired, 0); + let events = sim.rec.as_ref().unwrap().scene.as_ref().unwrap().events(); + assert!(events.iter().any(|e| matches!(e.kind, record::SceneKind::MotorEnergyLost { missile, speed, .. } if missile == id && speed > 0.0))); +} + +#[test] +fn equal_speed_head_on_crossing_and_chase_have_distinct_reach_times() { + let fly = |vel: DVec3| { + let mut sim = Sim::new(120); + let mut ground = Ground::default(); + let land = Land(|_, _| 64); + let pos = DVec3::new(0.0, 200.0, 400.0); + sim.add_drone(DroneKind::Shahed, pos, DVec3::ZERO, DVec3::new(0.0, 200.0, 5000.0)); + sim.drones[0].vel = vel; + let (target, id) = (sim.drones[0].id, sim.id()); + sim.missiles.push(Missile { id, kind: MissileKind::Pac3, pos: DVec3::new(0.0, 200.0, 0.0), vel: DVec3::Z * 3.0, target, sam: 999, age: 20, hold: 0, fix: None, seen: 0 }); + for step in 1..200 { + sim.tick = step; + sim.drones[0].pos += vel; + sim.missiles[0].fix = Some(Fix { pos: sim.drones[0].pos, vel, tick: sim.tick, seeker: false }); + sim.step_missiles(&mut ground, &mut Out::default()); + answer_land(&mut ground, &land, sim.tick); + if sim.missiles.is_empty() { + assert_eq!(sim.tally.blocked + sim.tally.scrubbed + sim.tally.expired + sim.tally.energy_lost, 0, "lost before reaching target"); + return step; + } + } + panic!("interceptor did not reach the target") + }; + let head_on = fly(-DVec3::Z); + let crossing = fly(DVec3::X); + let chase = fly(DVec3::Z); + assert!(head_on < crossing && crossing < chase, "{head_on}, {crossing}, {chase}"); +} + +#[test] +fn spent_motor_has_less_maneuver_at_low_speed() { + let fly = |age: u32| { + let mut sim = Sim::new(120); + let kind = MissileKind::Pac3; + let at = DVec3::new(500.0, 200.0, 500.0); + sim.add_drone(DroneKind::Shahed, at, DVec3::ZERO, at); + sim.tick = 100; + let (target, id) = (sim.drones[0].id, sim.id()); + let fix = Some(Fix { pos: at, vel: DVec3::ZERO, tick: 100, seeker: false }); + sim.missiles.push(Missile { id, kind, pos: DVec3::new(0.0, 200.0, 0.0), vel: DVec3::Z * kind.spec().speed * 0.4, target, sam: 999, age, hold: 0, fix, seen: 0 }); + sim.step_missiles(&mut Ground::default(), &mut Out::default()); + sim.missiles[0].vel.normalize() + }; + let powered = fly(30); + let coast = fly(MissileKind::Pac3.burn_ticks() + 1); + assert!(powered.x > coast.x && coast.x > 0.0, "{powered:?} {coast:?}"); +}