From 143d9b28581ae0d176dfaa4d8e89a92a6dcd974d Mon Sep 17 00:00:00 2001 From: chexit Date: Mon, 2 Nov 2026 23:50:33 +0300 Subject: [PATCH] Prototype finite interceptor motor and coast Co-authored-by: Codex --- airdef-mod/src/replay.rs | 4 ++++ airdef-mod/src/sim/mod.rs | 39 +++++++++++++++++++++++++++-------- airdef-mod/src/sim/record.rs | 15 ++++++++++++-- airdef-mod/src/sim/regress.rs | 24 +++++++++++++++++++++ 4 files changed, 71 insertions(+), 11 deletions(-) diff --git a/airdef-mod/src/replay.rs b/airdef-mod/src/replay.rs index c3cb164..cec7e89 100644 --- a/airdef-mod/src/replay.rs +++ b/airdef-mod/src/replay.rs @@ -1140,7 +1140,11 @@ mod engine_tests { let mut journal = SceneJournal::default(); journal.fixture_push(SceneEvent { at: 19, pos: DVec3::new(2.0, 66.0, 0.0), kind: SceneKind::MissileLaunch { missile: 2, sam: 5, target: 1, kind: MissileKind::M48n6, sound: "airdef:launch.s400_eject", pitch: 1.0 } }); journal.fixture_push(SceneEvent { at: 24, pos: DVec3::new(2.0, 75.0, 0.0), kind: SceneKind::MotorIgnition { missile: 2, kind: MissileKind::M48n6, sound: Some("airdef:launch.s400") } }); + journal.fixture_push(SceneEvent { at: 50, pos: DVec3::new(2.0, 75.0, 100.0), kind: SceneKind::MotorBurnout { missile: 2, kind: MissileKind::M48n6 } }); rec.scene = Some(journal); + assert!(rec.missile_is_lit(&rec.tracks[0], 49.0, 5)); + assert!(!rec.missile_is_lit(&rec.tracks[0], 50.0, 5)); + assert!(rec.missile_is_lit(&rec.tracks[0], 49.0, 5), "seek back restores the recorded state"); let mut p = player(&rec); let mut out = Out::default(); p.heard = 18.0; diff --git a/airdef-mod/src/sim/mod.rs b/airdef-mod/src/sim/mod.rs index 0c9f35b..fbdd3ac 100644 --- a/airdef-mod/src/sim/mod.rs +++ b/airdef-mod/src/sim/mod.rs @@ -1280,9 +1280,20 @@ pub const MAX_COAST: u64 = 240; /// Ticks the seeker keeps a target it lost sight of (it looks again round the last place). const REACQUIRE: u64 = 12; +impl MissileKind { + /// Fictional game motor duration in ticks, independent of the flight lifetime. + pub fn burn_ticks(self) -> u32 { + let spec = self.spec(); + if self == MissileKind::P1sun { spec.life } else { spec.life * 2 / 3 } + } +} + impl Missile { - /// Whether the main motor is running (cold ejection has no exhaust). - pub fn engine_lit(&self) -> bool { self.age > self.kind.spec().cold } + /// Cold ejection and post-burn coast have no powered exhaust. + pub fn engine_lit(&self) -> bool { + let spec = self.kind.spec(); + self.age > spec.cold && self.age <= spec.cold + self.kind.burn_ticks() + } } impl MissileKind { @@ -2949,6 +2960,7 @@ impl Sim { let (mut missed, mut expired, mut orphaned) = (0u32, 0u32, 0u32); let (mut blocked, mut scrubbed) = (0u32, 0u32); let mut ignitions = Vec::new(); + let mut burnouts = 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(); @@ -2968,11 +2980,17 @@ impl Sim { m.age += 1; let spec = m.kind.spec(); let target = self.drones.iter().find(|d| d.id == m.target && d.alive()); - let lit = m.age > spec.cold; + let lit = m.engine_lit(); + let coast = m.age > spec.cold + m.kind.burn_ticks(); + if m.age == spec.cold + m.kind.burn_ticks() + 1 { + burnouts.push((m.pos, m.id, m.kind)); + } let speed = if !lit { (m.vel.length() * 0.97).max(0.4) - } else { + } else if lit { (m.vel.length() + spec.accel).min(spec.speed) + } else { + m.vel.length() * 0.995 }; let dir = m.vel.normalize_or(DVec3::Y); // What it knows: the link's newest track, and its own seeker once the target is in its reach and in view. @@ -2982,7 +3000,7 @@ impl Sim { m.fix = Some(Fix { pos: tr.pos, vel: tr.vel, tick: tr.tick, seeker: false }); } if let Some(t) = target - && lit + && m.age > spec.cold && m.age >= spec.boost && m.kind.seeker() > 0.0 { @@ -3027,7 +3045,7 @@ impl Sim { done.push((m.id, target.map(|t| t.id), false, m.sam)); continue; } - turn(dir, want, spec.turn) + turn(dir, want, spec.turn * if coast { (speed / spec.speed).clamp(0.15, 1.0) } else { 1.0 }) } // After a cold launch it tips over towards the target while the motor burns. Some((at, _)) if lit && spec.cold > 0 => turn(dir, at - m.pos, spec.turn * 0.5), @@ -3070,9 +3088,9 @@ impl Sim { let k = (-rel0.dot(delta) / delta.length_squared().max(1e-9)).clamp(0.0, 1.0); if (rel0 + delta * k).length() < spec.fuse && world.map(|w| w.0).or(waiting).is_none_or(|w| k <= w) { // A target faster than the interceptor is hard to hit. - let ratio = (spec.speed / t.vel.length().max(0.1)).min(1.0).powf(1.5); + let ratio = (speed / t.vel.length().max(0.1)).min(1.0).powf(1.5); let fit = sams_fit.get(&m.sam).copied().unwrap_or(1.0) * launchers.get(&m.sam).map_or(1.0, |(at, max)| why::reach_fit(*max, at.distance(m.pos))); - let kill = self.rng.chance(spec.pk * fit * ratio / t.kind.spec().stealth.sqrt()); + let kill = self.rng.chance(spec.pk * fit * ratio); missed += u32::from(!kill); done.push((m.id, Some(t.id), kill, m.sam)); continue; @@ -3120,6 +3138,9 @@ impl Sim { for (pos, missile, kind, sound) in ignitions { self.scene_event(pos, record::SceneKind::MotorIgnition { missile, kind, sound }); } + for (pos, missile, kind) in burnouts { + self.scene_event(pos, record::SceneKind::MotorBurnout { missile, kind }); + } #[cfg(test)] self.scrubs.extend(scrub_log); self.note(|t| { @@ -3154,7 +3175,7 @@ impl Sim { let spec = m.kind.spec(); let rot = look(m.vel, 0.0) * DQuat::from_rotation_z(m.age as f64 * 0.3); out.model(m.id, spec.model, m.pos, rot, spec.scale, false); - if m.age > spec.cold && spec.flame > 0.0 { + if m.engine_lit() && spec.flame > 0.0 { let back = (spec.rear - 8.0) / 16.0 * spec.scale + spec.flame; let size = spec.flame * if m.age < 40 { 1.2 } else { 0.8 } * (0.85 + 0.15 * self.vis.f()); out.model(m.id + 1, "airdef:flame", m.pos + rot * DVec3::new(0.0, 0.0, back), rot, size, true); diff --git a/airdef-mod/src/sim/record.rs b/airdef-mod/src/sim/record.rs index 5b15d31..f578b74 100644 --- a/airdef-mod/src/sim/record.rs +++ b/airdef-mod/src/sim/record.rs @@ -253,6 +253,7 @@ pub struct Event { 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 }, TrackAcquired { sam: u32, target: u32, ever_own: bool }, TrackLost { sam: u32, target: u32 }, TrackSource { sam: u32, target: u32, ever_own: bool }, @@ -281,6 +282,7 @@ pub struct SceneJournal { fire_events: Vec, launches: HashMap, ignitions: HashMap, + burnouts: HashMap, } impl SceneJournal { @@ -296,6 +298,7 @@ impl SceneJournal { match event.kind { SceneKind::MissileLaunch { missile, .. } => { self.launches.insert(missile, index); } SceneKind::MotorIgnition { missile, .. } => { self.ignitions.insert(missile, index); } + SceneKind::MotorBurnout { missile, .. } => { self.burnouts.insert(missile, index); } SceneKind::FireStarted { .. } | SceneKind::FireChanged { .. } | SceneKind::FireEnded { .. } => self.fire_events.push(index), _ => {} } @@ -623,9 +626,17 @@ impl Recording { /// Recorded ignition takes precedence; older/incomplete tracks retain their original fallback. pub fn missile_is_lit(&self, tr: &Track, t: f64, cold: u32) -> bool { - if let Some(event) = self.missile_ignition(tr.id) { return t >= event.at as f64; } + if let Some(scene) = &self.scene + && let Some(index) = scene.burnouts.get(&tr.id) + && t >= scene.events[*index].at as f64 { return false; } + if let Some(event) = self.missile_ignition(tr.id) { + let burn = match tr.what { What::Missile(kind, _) => kind.burn_ticks(), _ => return false }; + return t >= event.at as f64 && t < event.at as f64 + burn as f64; + } if self.missile_launch(tr.id).is_some() && self.scene.as_ref().is_some_and(|s| !s.truncated) { return false; } - t - tr.first as f64 > cold as f64 + let age = t - tr.first as f64; + let burn = match tr.what { What::Missile(kind, _) => kind.burn_ticks(), _ => return false }; + age > cold as f64 && age <= (cold + burn) as f64 } fn note_tracks(&mut self, sams: &[Sam]) { diff --git a/airdef-mod/src/sim/regress.rs b/airdef-mod/src/sim/regress.rs index 80bd60a..c19d4b5 100644 --- a/airdef-mod/src/sim/regress.rs +++ b/airdef-mod/src/sim/regress.rs @@ -1854,3 +1854,27 @@ fn destroying_a_pending_carrier_and_loaded_truck_does_not_restore_stock_on_repai assert!(!sim.sams[1].ground_request); assert_eq!((sim.sams[0].ground_stock, sim.sams[2].ground_stock), (0, 0)); } + +#[test] +fn motor_burnout_reduces_speed_and_stops_powered_effects() { + let kind = MissileKind::Pac3; + let spec = kind.spec(); + let mut sim = Sim::new(120); + sim.add_drone(DroneKind::Shahed, DVec3::new(0.0, 100.0, 2000.0), DVec3::ZERO, DVec3::new(0.0, 100.0, 5000.0)); + let (target, id) = (sim.drones[0].id, sim.id()); + sim.tick = 100; + let fix = Some(Fix { pos: sim.drones[0].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 * (spec.speed * 0.8), target, sam: 999, age: spec.cold + kind.burn_ticks() - 2, hold: 0, fix, seen: 0 }); + let mut out = Out::default(); + sim.step_missiles(&mut Ground::default(), &mut out); + let powered = sim.missiles[0].vel.length(); + assert!(sim.missiles[0].engine_lit()); + assert!(out.engines.iter().any(|e| e.key == 0x4000_0000 | id)); + sim.step_missiles(&mut Ground::default(), &mut Out::default()); + let last = sim.missiles[0].vel.length(); + let mut out = Out::default(); + sim.step_missiles(&mut Ground::default(), &mut out); + assert!(powered <= last && sim.missiles[0].vel.length() < last); + assert!(!sim.missiles[0].engine_lit()); + assert!(!out.engines.iter().any(|e| e.key == 0x4000_0000 | id)); +}