Stage the cold launch and spool every interceptor's motor (#129)
The interceptor's launch is one sequence: the cap opens, the round is thrown out unlit and slows under linear gas drag (`cold` ticks), the motor lights where it has reached, it turns with a growing rate while thrust spools up (`boost` ticks), and full guidance starts only at `cold + boost`. A rail launch lights at once but spools over the same `boost` ticks; the electric P1-SUN has no rocket spool or plume. S-400/500 now hold the unlit ejection for 20/24 ticks (1.00/1.20 s at 20 Hz); IRIS-T/Tor keep shorter 8-tick throws. Ignition, its sound, flame and recorded MotorIgnition event all use the missile's current position. The RM48U strike follows the same 20-tick choreography, counts the throw in its flight time and starts its recorded smoke at ignition. Maximum speed, turn, accel, pk and every collision rule are unchanged. Co-Authored-By: Kimi Code <noreply@kimi.com>
This commit is contained in:
@@ -1210,7 +1210,7 @@ mod engine_tests {
|
||||
assert_eq!(out.sounds.iter().map(|s| s.0).collect::<Vec<_>>(), vec!["airdef:launch.s400_eject"]);
|
||||
assert!(out.engines.is_empty());
|
||||
out = Out::default();
|
||||
p.t = 16.0;
|
||||
p.t = 10.0 + crate::sim::strike::EJECT_TICKS as f64 + 1.0;
|
||||
p.sounds(&rec, DVec3::ZERO, &mut out);
|
||||
p.engines(&rec, DVec3::ZERO, false, &mut out);
|
||||
assert_eq!(out.sounds.iter().map(|s| s.0).collect::<Vec<_>>(), vec!["airdef:launch.s400"]);
|
||||
|
||||
+50
-11
@@ -824,10 +824,15 @@ struct MissileSpec {
|
||||
speed: f64,
|
||||
accel: f64,
|
||||
turn: f64,
|
||||
/// Ticks of straight flight before guidance.
|
||||
/// Departure: ticks from ignition to full guidance (spool-up; a cold launch also captures
|
||||
/// its course); full guidance starts at `cold + boost`.
|
||||
boost: u32,
|
||||
/// Thrown out of the tube by gas first; the motor lights this many ticks later.
|
||||
/// Thrown out of the tube by gas first; the motor lights this many ticks later (#129).
|
||||
cold: u32,
|
||||
/// Gas-throw speed out of the tube, blocks a tick (cold-launched kinds only).
|
||||
eject: f64,
|
||||
/// The ejection's linear drag, blocks a tick² (cold-launched kinds only).
|
||||
eject_drag: f64,
|
||||
pk: f64,
|
||||
fuse: f64,
|
||||
life: u32,
|
||||
@@ -843,6 +848,31 @@ struct MissileSpec {
|
||||
voice: (&'static str, f32, f32),
|
||||
}
|
||||
|
||||
impl MissileSpec {
|
||||
/// The launch choreography is one sequence of stages (#129): the cap opens on the ground
|
||||
/// (`launch::OPEN_TICKS`), the round is thrown out of the tube unlit and slows under drag
|
||||
/// (`age <= cold`), the motor lights where it has reached (`age == cold + 1`), it tips onto
|
||||
/// its course with a growing rate while the motor spools up (`cold < age < cold + boost`),
|
||||
/// and full guidance — terrain lofting, the seeker, full turn — runs from `age >= cold + boost`.
|
||||
/// Rail-launched kinds (`cold == 0`) light at once but spool up over the same `boost` ticks:
|
||||
/// their stages are ignition, departure, guidance.
|
||||
fn guided(&self, age: u32) -> bool {
|
||||
age >= self.cold + self.boost
|
||||
}
|
||||
/// The departure's course-capture rate as a share of the full turn (0..=1).
|
||||
fn departure_ramp(&self, age: u32) -> f64 {
|
||||
(age.saturating_sub(self.cold) as f64 / self.boost.max(1) as f64).clamp(0.0, 1.0)
|
||||
}
|
||||
/// The motor's spool-up as a share of the full acceleration (0..=1). A rail launch spools over
|
||||
/// the same `boost` ticks its departure lasts; an electric kind has no rocket motor to light.
|
||||
fn spool(&self, age: u32, guided: bool) -> f64 {
|
||||
if guided || self.flame <= 0.0 {
|
||||
return 1.0;
|
||||
}
|
||||
0.45 + 0.55 * self.departure_ramp(age)
|
||||
}
|
||||
}
|
||||
|
||||
impl MissileKind {
|
||||
/// How thick its motor smoke is (its size); `None` for an electric one.
|
||||
pub fn trail_scale(self) -> Option<f32> {
|
||||
@@ -2543,7 +2573,7 @@ impl Sim {
|
||||
.map(|tr| Fix { pos: tr.pos, vel: tr.vel, tick: tr.tick, seeker: false });
|
||||
let spec = kind.spec();
|
||||
let id = self.id();
|
||||
let v0 = if spec.cold > 0 { 0.7 } else { 0.8 };
|
||||
let v0 = if spec.cold > 0 { spec.eject } else { 0.8 };
|
||||
self.missiles.push(Missile { id, kind, pos: muzzle, vel: dir * v0, target, sam, age: 0, hold: 0, fix, seen: 0 });
|
||||
if spec.cold > 0 {
|
||||
// Thrown out by gas: a puff, a thump; the motor lights later.
|
||||
@@ -2612,10 +2642,15 @@ impl Sim {
|
||||
if m.age == spec.cold + m.kind.burn_ticks() + 1 {
|
||||
burnouts.push((m.pos, m.id, m.kind));
|
||||
}
|
||||
// The launch's stages (#129): ejection (unlit, gas drag), ignition (the motor lights
|
||||
// where the round has reached), departure (spool-up and course capture) and guidance.
|
||||
// Guidance never runs before ignition: it starts at `cold + boost`, the motor at `cold`.
|
||||
let guided = spec.guided(m.age);
|
||||
let speed = if m.age <= spec.cold {
|
||||
(m.vel.length() * 0.97).max(0.4)
|
||||
// The gas throw bleeds off linearly; the round is never left hanging still.
|
||||
(m.vel.length() - spec.eject_drag).max(spec.eject * 0.25)
|
||||
} else if lit {
|
||||
(m.vel.length() + spec.accel).min(spec.speed)
|
||||
(m.vel.length() + spec.accel * spec.spool(m.age, guided)).min(spec.speed)
|
||||
} else {
|
||||
m.vel.length() * 0.995
|
||||
};
|
||||
@@ -2627,8 +2662,7 @@ impl Sim {
|
||||
m.fix = Some(Fix { pos: tr.pos, vel: tr.vel, tick: tr.tick, seeker: false });
|
||||
}
|
||||
if let Some(t) = target
|
||||
&& m.age > spec.cold
|
||||
&& m.age >= spec.boost
|
||||
&& guided
|
||||
&& m.kind.seeker() > 0.0
|
||||
{
|
||||
let range = m.kind.seeker() * t.kind.rcs().powf(0.25).clamp(0.6, 1.6);
|
||||
@@ -2643,14 +2677,14 @@ impl Sim {
|
||||
}
|
||||
}
|
||||
// Nothing new for too long and no seeker on it: the interceptor has lost its target.
|
||||
if target.is_some() && m.fix.is_none_or(|f| now.saturating_sub(f.tick) > MAX_COAST) && m.age > spec.boost + 20 {
|
||||
if target.is_some() && m.fix.is_none_or(|f| now.saturating_sub(f.tick) > MAX_COAST) && m.age > spec.cold + spec.boost + 20 {
|
||||
expired += 1;
|
||||
done.push((m.id, None, false, m.sam));
|
||||
continue;
|
||||
}
|
||||
let aim = m.fix.map(|f| (f.est(now), f.vel));
|
||||
let new = match aim {
|
||||
Some((at, vel)) if m.age >= spec.boost => {
|
||||
Some((at, vel)) if guided => {
|
||||
// Time to go by the closing speed: head-on targets come to meet it.
|
||||
let los = (at - m.pos).normalize_or_zero();
|
||||
let closing = (speed - vel.dot(los)).max(1.0);
|
||||
@@ -2674,8 +2708,13 @@ impl Sim {
|
||||
}
|
||||
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),
|
||||
// Departure: after a cold launch it tips onto the target's course while the motor
|
||||
// spools up, at a rate that grows into the full one — no snap from the vertical to
|
||||
// the horizontal (#129). A rail launch already points along its rail; it keeps that
|
||||
// course until guidance can loft it over the land, as before.
|
||||
Some((at, _)) if lit && spec.cold > 0 => {
|
||||
turn(dir, at - m.pos, spec.turn * (0.2 + 0.8 * spec.departure_ramp(m.age)))
|
||||
}
|
||||
_ => dir,
|
||||
};
|
||||
let before = m.pos;
|
||||
|
||||
@@ -2213,6 +2213,8 @@ fn spent_motor_has_less_maneuver_at_low_speed() {
|
||||
assert!(powered.x > coast.x && coast.x > 0.0, "{powered:?} {coast:?}");
|
||||
}
|
||||
|
||||
// #129: the staged launch's exact speed rules: gas-drag ejection with a floor, a spooled
|
||||
// departure that grows into full acceleration, full power at guidance, the coast drag after.
|
||||
#[test]
|
||||
fn cold_ejection_power_and_coast_use_their_own_speed_rules() {
|
||||
let kind = MissileKind::M48n6;
|
||||
@@ -2228,10 +2230,270 @@ fn cold_ejection_power_and_coast_use_their_own_speed_rules() {
|
||||
sim.step_missiles(&mut Ground::default(), &mut Out::default());
|
||||
sim.missiles[0].vel.length()
|
||||
};
|
||||
assert!((fly(0, 2.0) - 2.0 * 0.97).abs() < 1e-9, "cold ejection drag");
|
||||
assert!((fly(spec.cold, 2.0) - (2.0 + spec.accel)).abs() < 1e-9, "powered acceleration");
|
||||
assert!((fly(0, spec.eject) - (spec.eject - spec.eject_drag)).abs() < 1e-9, "cold ejection drag");
|
||||
assert!((fly(spec.cold - 1, 0.1) - spec.eject * 0.25).abs() < 1e-9, "cold ejection floor near ignition");
|
||||
assert!((fly(0, 0.2) - spec.eject * 0.25).abs() < 1e-9, "cold ejection floor");
|
||||
let spool0 = 0.45 + 0.55 / f64::from(spec.boost);
|
||||
assert!((fly(spec.cold, 2.0) - (2.0 + spec.accel * spool0)).abs() < 1e-9, "ignition spool-up");
|
||||
assert!((fly(spec.cold + spec.boost - 1, 2.0) - (2.0 + spec.accel)).abs() < 1e-9, "powered acceleration");
|
||||
assert!((fly(spec.cold + kind.burn_ticks(), 2.0) - 2.0 * 0.995).abs() < 1e-9, "coast drag");
|
||||
assert!((fly(0, 0.2) - 0.4).abs() < 1e-9, "cold ejection minimum");
|
||||
}
|
||||
|
||||
// ---- #129: the staged cold launch. Ejection (unlit gas throw) → ignition where the round has
|
||||
// reached → departure (spool-up and course capture at a growing rate) → guidance at cold + boost.
|
||||
|
||||
/// Every cold-launched kind walks its stages in order; before ignition there is no motor voice,
|
||||
/// no flame and no steering, the speed is continuous across ignition, and the turn each tick
|
||||
/// never exceeds what the stage allows.
|
||||
#[test]
|
||||
fn cold_launch_stages_run_in_order_and_guidance_follows_ignition() {
|
||||
for &kind in &[MissileKind::M48n6, MissileKind::M77n6, MissileKind::IrisT, MissileKind::M9m338] {
|
||||
let spec = kind.spec();
|
||||
let mut sim = Sim::new(120);
|
||||
// A still target well off the vertical: the tip-over has somewhere to turn.
|
||||
let target_pos = DVec3::new(300.0, 120.0, 300.0);
|
||||
sim.add_drone(DroneKind::Shahed, target_pos, DVec3::ZERO, target_pos);
|
||||
let (target, id) = (sim.drones[0].id, sim.id());
|
||||
sim.missiles.push(Missile { id, kind, pos: DVec3::new(0.0, 64.0, 0.0), vel: DVec3::Y * spec.eject, target, sam: 999, age: 0, hold: 0,
|
||||
fix: Some(Fix { pos: target_pos, vel: DVec3::ZERO, tick: 0, seeker: false }), seen: 0 });
|
||||
sim.begin_recording_if_active();
|
||||
let mut previous_speed = spec.eject;
|
||||
let mut previous_dir = DVec3::Y;
|
||||
let mut saw_ignition = false;
|
||||
for age in 1..=(spec.cold + spec.boost + 2) {
|
||||
sim.tick += 1;
|
||||
let mut out = Out::default();
|
||||
sim.step_missiles(&mut Ground::default(), &mut out);
|
||||
let m = sim.missiles.iter().find(|m| m.id == id).unwrap_or_else(|| panic!("{kind:?} age {age}"));
|
||||
let speed = m.vel.length();
|
||||
let dir = m.vel.normalize_or(DVec3::Y);
|
||||
let voice = out.engines.iter().any(|e| e.key == (0x4000_0000 | id));
|
||||
let flame = out.particles.iter().any(|p| p.kind == "minecraft:flame");
|
||||
assert_eq!(voice, m.engine_lit(), "{kind:?} age {age}: the motor voice runs only when lit");
|
||||
if !m.engine_lit() {
|
||||
assert!(!voice && !flame, "{kind:?} age {age}: powered effects before ignition");
|
||||
assert!((speed - (previous_speed - spec.eject_drag)).abs() < 1e-9, "{kind:?} age {age}: ejection drag");
|
||||
assert!(dir.angle_between(DVec3::Y) < 1e-9, "{kind:?} age {age}: steering before ignition");
|
||||
} else if !saw_ignition {
|
||||
saw_ignition = true;
|
||||
assert_eq!(m.age, spec.cold + 1, "{kind:?}: ignition one tick after the ejection ends");
|
||||
assert!(speed > previous_speed - 1e-9, "{kind:?}: speed jumps down across ignition ({previous_speed} -> {speed})");
|
||||
assert!(out.sounds.iter().any(|s| s.0 == sound::LAUNCH_S400 || s.0 == sound::LAUNCH_S500), "{kind:?}: ignition sound");
|
||||
let events = sim.rec.as_ref().unwrap().scene.as_ref().unwrap().events();
|
||||
assert!(events.iter().any(|e| matches!(e.kind, record::SceneKind::MotorIgnition { missile, .. } if missile == id)), "{kind:?}: ignition recorded");
|
||||
}
|
||||
let allowed = if spec.guided(m.age) {
|
||||
spec.turn
|
||||
} else if m.engine_lit() {
|
||||
// Departure: the rate grows into the full one; no snap at the boundary.
|
||||
spec.turn * (0.2 + 0.8 * spec.departure_ramp(m.age))
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
assert!(previous_dir.angle_between(dir) <= allowed + 1e-9, "{kind:?} age {age}: turned past the stage's rate");
|
||||
previous_speed = speed;
|
||||
previous_dir = dir;
|
||||
}
|
||||
assert!(saw_ignition, "{kind:?}: never lit");
|
||||
let m = sim.missiles.iter().find(|m| m.id == id).unwrap();
|
||||
assert!(m.vel.normalize().angle_between(DVec3::Y) > 0.3, "{kind:?}: still vertical after guidance started");
|
||||
}
|
||||
}
|
||||
|
||||
/// The invariant the issue is about: with a longer ignition delay the old `age >= boost` guidance
|
||||
/// would have switched on before the motor existed. Now guidance implies ignition, for every
|
||||
/// profile and every age, and the seeker opens with guidance, not with the raw boost counter.
|
||||
#[test]
|
||||
fn guidance_never_starts_before_ignition_for_any_profile() {
|
||||
for kind in [
|
||||
MissileKind::Pac3, MissileKind::M57e6, MissileKind::M48n6, MissileKind::M77n6, MissileKind::IrisT,
|
||||
MissileKind::Amraam, MissileKind::M9m317, MissileKind::M9m338, MissileKind::P1sun,
|
||||
] {
|
||||
let spec = kind.spec();
|
||||
for age in 0..=spec.life.min(2000) {
|
||||
assert!(!spec.guided(age) || age > spec.cold, "{kind:?} age {age}: guidance before ignition (cold {})", spec.cold);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Across the whole staging the speed changes smoothly (no jumps beyond the stage's own drag or
|
||||
/// full acceleration) and the per-tick turn never exceeds the profile's rate, for every kind.
|
||||
/// A rocket's first powered tick is a throttled one for rail and cold launches alike (#129).
|
||||
#[test]
|
||||
fn launch_accel_and_turn_stay_continuous_for_every_interceptor() {
|
||||
for kind in [
|
||||
MissileKind::Pac3, MissileKind::M57e6, MissileKind::M48n6, MissileKind::M77n6, MissileKind::IrisT,
|
||||
MissileKind::Amraam, MissileKind::M9m317, MissileKind::M9m338, MissileKind::P1sun,
|
||||
] {
|
||||
let spec = kind.spec();
|
||||
let mut sim = Sim::new(120);
|
||||
let target_pos = DVec3::new(400.0, 140.0, 400.0);
|
||||
sim.add_drone(DroneKind::Shahed, target_pos, DVec3::ZERO, target_pos);
|
||||
let (target, id) = (sim.drones[0].id, sim.id());
|
||||
let v0 = if spec.cold > 0 { spec.eject } else { 0.8 };
|
||||
sim.missiles.push(Missile { id, kind, pos: DVec3::new(0.0, 64.0, 0.0), vel: DVec3::Y * v0, target, sam: 999, age: 0, hold: 0,
|
||||
fix: Some(Fix { pos: target_pos, vel: DVec3::ZERO, tick: 0, seeker: false }), seen: 0 });
|
||||
let mut previous_speed = v0;
|
||||
let mut previous_dir = DVec3::Y;
|
||||
let mut first_power: Option<f64> = None;
|
||||
for _ in 0..120 {
|
||||
sim.tick += 1;
|
||||
sim.step_missiles(&mut Ground::default(), &mut Out::default());
|
||||
let Some(m) = sim.missiles.iter().find(|m| m.id == id) else { break };
|
||||
let speed = m.vel.length();
|
||||
let dir = m.vel.normalize_or(DVec3::Y);
|
||||
assert!(speed - previous_speed <= spec.accel + 1e-9, "{kind:?}: speed jumped up {previous_speed} -> {speed}");
|
||||
let allowed_down = spec.eject_drag + previous_speed * 0.005;
|
||||
assert!(speed >= previous_speed - allowed_down - 1e-9, "{kind:?}: speed jumped down {previous_speed} -> {speed}");
|
||||
assert!(previous_dir.angle_between(dir) <= spec.turn + 1e-9, "{kind:?}: turned past the profile's rate");
|
||||
if m.engine_lit() && first_power.is_none() {
|
||||
first_power = Some(speed - previous_speed);
|
||||
}
|
||||
previous_speed = speed;
|
||||
previous_dir = dir;
|
||||
}
|
||||
if spec.flame > 0.0 {
|
||||
let gain = first_power.unwrap_or_else(|| panic!("{kind:?}: the motor never lit"));
|
||||
let spooled = spec.accel * (0.45 + 0.55 / f64::from(spec.boost.max(1)));
|
||||
assert!(gain > 0.0 && gain <= spooled + 1e-9, "{kind:?}: the motor reached full thrust at once ({gain} > {spooled})");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The electric P1-SUN is not a rocket: it keeps no flame, no powered smoke and its own voice.
|
||||
#[test]
|
||||
fn p1sun_stays_a_ramming_drone_without_a_plume() {
|
||||
let kind = MissileKind::P1sun;
|
||||
let spec = kind.spec();
|
||||
assert_eq!(spec.flame, 0.0);
|
||||
assert_eq!(spec.cold, 0);
|
||||
let mut sim = Sim::new(120);
|
||||
let target_pos = DVec3::new(200.0, 64.0, 0.0);
|
||||
sim.add_drone(DroneKind::Shahed, target_pos, DVec3::ZERO, target_pos);
|
||||
let (target, id) = (sim.drones[0].id, sim.id());
|
||||
sim.missiles.push(Missile { id, kind, pos: DVec3::ZERO, vel: DVec3::X * 0.8, target, sam: 999, age: 0, hold: 0,
|
||||
fix: Some(Fix { pos: target_pos, vel: DVec3::ZERO, tick: 0, seeker: false }), seen: 0 });
|
||||
for _ in 0..40 {
|
||||
sim.tick += 1;
|
||||
let mut out = Out::default();
|
||||
sim.step_missiles(&mut Ground::default(), &mut out);
|
||||
assert!(!out.particles.iter().any(|p| p.kind == "minecraft:flame"), "P1-SUN grew a rocket plume");
|
||||
if let Some(m) = sim.missiles.iter().find(|m| m.id == id) {
|
||||
assert!(m.engine_lit(), "the electric interceptor's motor state is wrong");
|
||||
let (event, _, _) = kind.engine_voice();
|
||||
assert!(out.engines.iter().any(|e| e.key == (0x4000_0000 | id) && e.event == event));
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A real S-400 launch records the ejection and the ignition `cold` ticks apart; the replay's lit
|
||||
/// state follows the recorded ignition, so a seek across it shows one flame and one voice sequence.
|
||||
#[test]
|
||||
fn recorded_ignition_is_cold_ticks_after_ejection_and_replay_follows_it() {
|
||||
let mut sim = Sim::new(0x129);
|
||||
let sam = sim.id();
|
||||
sim.sams.push(Sam::new(sam, SamKind::S400, DVec3::new(0.0, 64.0, 0.0), 0.0));
|
||||
sim.sams[0].cooldown = 0;
|
||||
let mut out = Out::default();
|
||||
sim.launch(DroneKind::Kh101, DVec3::new(0.0, 160.0, -600.0), DVec3::Z, DVec3::new(0.0, 64.0, 0.0), &mut out);
|
||||
let mut ground = Ground::default();
|
||||
for _ in 0..200 {
|
||||
// The firing system's own measurement, fresh this tick (as `why::radar_sees` feeds it):
|
||||
// the test is about the launch stages, not about the scan's chance.
|
||||
let now = sim.tick;
|
||||
let tracks: Vec<sight::Track> = sim.drones.iter().filter(|d| d.alive())
|
||||
.map(|d| sight::Track { id: d.id, kind: d.kind, pos: d.pos, vel: d.vel, tick: now, own_tick: now, ttl: 400, hits: 3, locked: false })
|
||||
.collect();
|
||||
sim.sams[0].tracks.retain(|t| !tracks.iter().any(|n| n.id == t.id));
|
||||
sim.sams[0].tracks.extend(tracks);
|
||||
let wanted = ground.take_wanted();
|
||||
ground.answer(wanted.into_iter().map(|(x, z)| (x, z, 64)).collect(), sim.tick);
|
||||
out = Out::default();
|
||||
sim.step(&mut ground, None, &mut out);
|
||||
if sim.replays.iter().chain(sim.rec.iter()).flat_map(|r| r.scene.iter().flat_map(|s| s.events())).any(|e| matches!(e.kind, record::SceneKind::MotorIgnition { .. })) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
let rec = sim.rec.as_ref().or_else(|| sim.replays.last()).expect("a recording");
|
||||
let scene = rec.scene.as_ref().unwrap();
|
||||
let launch = scene.events().iter().find(|e| matches!(e.kind, record::SceneKind::MissileLaunch { .. })).expect("the ejection is recorded");
|
||||
let missile = match launch.kind { record::SceneKind::MissileLaunch { missile, .. } => missile, _ => unreachable!() };
|
||||
let spec = MissileKind::M48n6.spec();
|
||||
let ignition = rec.missile_ignition(missile).expect("the ignition is recorded");
|
||||
assert_eq!(ignition.at - launch.at, spec.cold, "ignition {} ticks after the ejection", spec.cold);
|
||||
let track = rec.track(missile).expect("the interceptor's track");
|
||||
for t in [ignition.at as f64 - 1.0, ignition.at as f64 - 0.1] {
|
||||
assert!(!rec.missile_is_lit(track, t, spec.cold), "lit before the recorded ignition at {t}");
|
||||
}
|
||||
for t in [ignition.at as f64, ignition.at as f64 + 5.0] {
|
||||
assert!(rec.missile_is_lit(track, t, spec.cold), "not lit at the recorded ignition ({t})");
|
||||
}
|
||||
assert!(!rec.missile_is_lit(track, ignition.at as f64 + spec.cold as f64 + f64::from(MissileKind::M48n6.burn_ticks()) + 2.0, spec.cold), "lit past the recorded burnout");
|
||||
}
|
||||
|
||||
/// The physical RM48U strike ejects unlit and slow, then lights at `EJECT_TICKS` where it has
|
||||
/// reached: no motor voice or flame before, one ignition sound at the hand-over.
|
||||
#[test]
|
||||
fn rm48u_strike_ejects_before_ignition_on_its_own_path() {
|
||||
use super::sites::testland::{Land, serve};
|
||||
use super::sites::{LaunchSite, SiteClass};
|
||||
use super::strike::{Estimate, Known, SiteState};
|
||||
use glam::DVec2;
|
||||
let land = Land(|_, _| 64);
|
||||
let mut sim = Sim::new(11);
|
||||
// A fixed site the strike needs no scouting for; its launches are queued far ahead, so a wave
|
||||
// boundary does not retire it while the test runs.
|
||||
let id = sim.sites.add(LaunchSite {
|
||||
id: 0, side: 0, sector: 0, bearing: 0.0, class: SiteClass::Ramp,
|
||||
pos: DVec3::new(0.0, 64.0, 400.0), range: 400.0, water: false,
|
||||
stock: 4, planned: 4, alive: true, next_free: 0, offmap: false,
|
||||
});
|
||||
for i in 0..4 {
|
||||
let site = sim.sites.get(id).unwrap().clone();
|
||||
let d = sim.make_drone(DroneKind::Shahed, site.muzzle(i), -DVec3::Z, DVec3::new(0.0, 70.0, 0.0));
|
||||
sim.sites.job(d.id, id);
|
||||
sim.queue.push((20_000 + u64::from(i), d));
|
||||
}
|
||||
sim.strikes.known.push(Known {
|
||||
site: id, class: SiteClass::Ramp, obs: Vec::new(),
|
||||
est: Some(Estimate { pos: DVec2::new(0.0, 400.0), radius: 5.0, fix: true, bearing: 0.0, bearing_err: 0.01, origin: DVec2::ZERO }),
|
||||
launches: 1, last: 0, state: SiteState::Active, aimed: false,
|
||||
});
|
||||
let carrier = sim.id();
|
||||
let mut sam = Sam::new(carrier, SamKind::S400, DVec3::new(0.0, 64.0, 0.0), 0.0);
|
||||
sam.missiles = 2;
|
||||
sam.ground_rounds = 2;
|
||||
sam.cooldown = 0;
|
||||
sim.sams.push(sam);
|
||||
let mut out = Out::default();
|
||||
sim.order_strike(id, DVec3::ZERO, &mut out).unwrap();
|
||||
let (mut ground, mut heard_ignition) = (Ground::default(), 0);
|
||||
let mut saw_voice_before = false;
|
||||
let mut saw_flame_before = false;
|
||||
let mut rise = 0.0f64;
|
||||
let mut all_messages = Vec::new();
|
||||
for _ in 0..(launch::OPEN_TICKS as usize + strike::EJECT_TICKS as usize + 6) {
|
||||
serve(&mut ground, &land, sim.tick);
|
||||
out = Out::default();
|
||||
sim.step(&mut ground, None, &mut out);
|
||||
all_messages.append(&mut out.messages);
|
||||
if let Some(f) = sim.strikes.flights.first() {
|
||||
let age = sim.tick - f.start;
|
||||
if age < strike::EJECT_TICKS {
|
||||
saw_voice_before |= out.engines.iter().any(|e| e.key == 0x7500_0000 + f.id * PARTS);
|
||||
saw_flame_before |= out.models.iter().any(|m| m.name == "airdef:flame" && m.id == 0x7500_0000 + f.id * PARTS + 1);
|
||||
rise = rise.max(f.pos.y - f.from.y);
|
||||
}
|
||||
if age == strike::EJECT_TICKS {
|
||||
heard_ignition = out.sounds.iter().filter(|s| s.0 == sound::LAUNCH_S400).count();
|
||||
}
|
||||
}
|
||||
}
|
||||
assert!(rise > 10.0, "the ejection rose only {rise} blocks");
|
||||
assert!(!saw_voice_before && !saw_flame_before, "powered effects during the unlit ejection");
|
||||
assert_eq!(heard_ignition, 1, "exactly one ignition sound at the hand-over: {all_messages:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -300,6 +300,8 @@ const MISSILE_SPEC_PAC3: MissileSpec = MissileSpec {
|
||||
turn: 0.3,
|
||||
boost: 8,
|
||||
cold: 0,
|
||||
eject: 0.0,
|
||||
eject_drag: 0.0,
|
||||
pk: 0.9,
|
||||
fuse: 4.0,
|
||||
life: 240,
|
||||
@@ -318,6 +320,8 @@ const MISSILE_SPEC_M57E6: MissileSpec = MissileSpec {
|
||||
turn: 0.32,
|
||||
boost: 5,
|
||||
cold: 0,
|
||||
eject: 0.0,
|
||||
eject_drag: 0.0,
|
||||
pk: 0.82,
|
||||
fuse: 3.0,
|
||||
life: 160,
|
||||
@@ -335,7 +339,9 @@ const MISSILE_SPEC_M48N6: MissileSpec = MissileSpec {
|
||||
accel: 0.5,
|
||||
turn: 0.22,
|
||||
boost: 8,
|
||||
cold: 5,
|
||||
cold: 20,
|
||||
eject: 2.4,
|
||||
eject_drag: 0.07,
|
||||
pk: 0.9,
|
||||
fuse: 5.0,
|
||||
life: 300,
|
||||
@@ -353,7 +359,9 @@ const MISSILE_SPEC_M77N6: MissileSpec = MissileSpec {
|
||||
accel: 0.6,
|
||||
turn: 0.22,
|
||||
boost: 8,
|
||||
cold: 5,
|
||||
cold: 24,
|
||||
eject: 2.6,
|
||||
eject_drag: 0.07,
|
||||
pk: 0.95,
|
||||
fuse: 6.0,
|
||||
life: 320,
|
||||
@@ -371,7 +379,9 @@ const MISSILE_SPEC_IRIST: MissileSpec = MissileSpec {
|
||||
accel: 0.5,
|
||||
turn: 0.35,
|
||||
boost: 6,
|
||||
cold: 4,
|
||||
cold: 8,
|
||||
eject: 1.8,
|
||||
eject_drag: 0.1,
|
||||
pk: 0.9,
|
||||
fuse: 4.0,
|
||||
life: 260,
|
||||
@@ -390,6 +400,8 @@ const MISSILE_SPEC_AMRAAM: MissileSpec = MissileSpec {
|
||||
turn: 0.3,
|
||||
boost: 6,
|
||||
cold: 0,
|
||||
eject: 0.0,
|
||||
eject_drag: 0.0,
|
||||
pk: 0.85,
|
||||
fuse: 4.0,
|
||||
life: 260,
|
||||
@@ -408,6 +420,8 @@ const MISSILE_SPEC_M9M317: MissileSpec = MissileSpec {
|
||||
turn: 0.25,
|
||||
boost: 8,
|
||||
cold: 0,
|
||||
eject: 0.0,
|
||||
eject_drag: 0.0,
|
||||
pk: 0.85,
|
||||
fuse: 5.0,
|
||||
life: 300,
|
||||
@@ -425,7 +439,9 @@ const MISSILE_SPEC_M9M338: MissileSpec = MissileSpec {
|
||||
accel: 0.6,
|
||||
turn: 0.4,
|
||||
boost: 4,
|
||||
cold: 4,
|
||||
cold: 8,
|
||||
eject: 1.6,
|
||||
eject_drag: 0.1,
|
||||
pk: 0.9,
|
||||
fuse: 3.0,
|
||||
life: 160,
|
||||
@@ -444,6 +460,8 @@ const MISSILE_SPEC_P1SUN: MissileSpec = MissileSpec {
|
||||
turn: 0.3,
|
||||
boost: 4,
|
||||
cold: 0,
|
||||
eject: 0.0,
|
||||
eject_drag: 0.0,
|
||||
pk: 0.8,
|
||||
fuse: 3.0,
|
||||
life: 900,
|
||||
|
||||
@@ -25,7 +25,16 @@ use glam::DVec2;
|
||||
/// Minecraft presentation profile; neither dimensions nor timing reproduce a real weapon.
|
||||
pub const ROUND_MODEL: &str = "airdef:rm48u";
|
||||
pub const ROUND_SCALE: f64 = 1.5;
|
||||
pub const EJECT_TICKS: u64 = 5;
|
||||
/// Ticks the gas throw takes before the motor lights: the same staged cold launch as the
|
||||
/// interceptor's `cold` (#129), so a physical strike shows the unlit ejection too.
|
||||
pub const EJECT_TICKS: u64 = 20;
|
||||
|
||||
/// The unlit ejection's height at flight age `age`: a gas throw of 2.2 blocks a tick with a
|
||||
/// linear drag of 0.09 a tick², integrated (game scale, like the interceptor's `eject`).
|
||||
fn eject_rise(age: u64) -> f64 {
|
||||
let a = age as f64;
|
||||
(2.2 * a - 0.045 * a * a).max(0.0)
|
||||
}
|
||||
|
||||
/// Shared live/replay appearance, including the unlit cold-ejection phase.
|
||||
pub(super) fn round_visual(out: &mut Out, id: u32, pos: DVec3, rot: DQuat, lit: bool) {
|
||||
@@ -581,10 +590,18 @@ impl Sim {
|
||||
let end = DVec3::new(f.land.x, below.unwrap_or(f.from.y), f.land.y);
|
||||
let t = ((now - f.start) as f64 / (f.arrive - f.start).max(1) as f64).clamp(0.0, 1.0);
|
||||
let age = now.saturating_sub(f.start);
|
||||
let arc = |t: f64| TUNE.height * (std::f64::consts::PI * t).sin().max(0.0).sqrt();
|
||||
let planned = if f.carrier.is_some() && age < EJECT_TICKS {
|
||||
f.from + DVec3::Y * (age as f64 * 0.7)
|
||||
// The unlit gas throw: it rises and slows, no motor yet (#129).
|
||||
f.from + DVec3::Y * eject_rise(age)
|
||||
} else if f.carrier.is_some() {
|
||||
// From where the ejection reached, onto the cruise arc: the plan is continuous
|
||||
// at ignition, and the flight time counted at the order includes the ejection.
|
||||
let top = f.from + DVec3::Y * eject_rise(EJECT_TICKS);
|
||||
let t = ((age - EJECT_TICKS) as f64 / (f.arrive - f.start).saturating_sub(EJECT_TICKS).max(1) as f64).clamp(0.0, 1.0);
|
||||
top.lerp(end, t) + DVec3::Y * arc(t)
|
||||
} else {
|
||||
f.from.lerp(end, t) + DVec3::Y * (TUNE.height * (std::f64::consts::PI * t).sin().max(0.0).sqrt())
|
||||
f.from.lerp(end, t) + DVec3::Y * arc(t)
|
||||
};
|
||||
let from = f.pos;
|
||||
// Time spent waiting for world data must not accumulate into a faster step.
|
||||
@@ -753,7 +770,9 @@ impl Sim {
|
||||
self.intel.revealed.push((p.carrier, sam.pos, self.tick));
|
||||
self.strikes.fired += 1;
|
||||
self.strikes.next += 1;
|
||||
let ticks = (dist / TUNE.speed).ceil().max(20.0) as u64;
|
||||
// The counted flight includes the unlit ejection: the missile is honest about
|
||||
// arriving a second later (#129).
|
||||
let ticks = (dist / TUNE.speed).ceil().max(20.0) as u64 + EJECT_TICKS;
|
||||
let (gx, gz) = (gauss(self.seed, self.strikes.next as u64, p.site as u64, 7), gauss(self.seed, self.strikes.next as u64, p.site as u64, 8));
|
||||
self.strikes.flights.push(Flight { id: self.strikes.next, site: p.site, carrier: Some(p.carrier), from: start, pos: start, aim: p.aim,
|
||||
land: p.aim + DVec2::new(gx, gz) * TUNE.cep, start: self.tick, arrive: self.tick + ticks, snap, hold: 0 });
|
||||
@@ -790,7 +809,8 @@ impl Sim {
|
||||
self.strikes.stock -= 1;
|
||||
self.strikes.fired += 1;
|
||||
self.strikes.next += 1;
|
||||
let ticks = (dist / TUNE.speed).ceil().max(20.0) as u64;
|
||||
// The counted flight includes the unlit ejection (#129).
|
||||
let ticks = (dist / TUNE.speed).ceil().max(20.0) as u64 + EJECT_TICKS;
|
||||
let start = from + DVec3::Y * 1.5;
|
||||
// Its own scatter at the aim, rolled once here: the flight and its outcome name one point.
|
||||
let (gx, gz) = (gauss(self.seed, self.strikes.next as u64, id as u64, 7), gauss(self.seed, self.strikes.next as u64, id as u64, 8));
|
||||
|
||||
@@ -608,13 +608,15 @@ mod tests {
|
||||
#[test]
|
||||
fn recorded_physical_strike_trail_starts_at_ignition_not_at_the_muzzle() {
|
||||
use crate::sim::record::{Recording, What};
|
||||
let mut rec = Recording::blank(40);
|
||||
rec.straight(1, What::StrikeCarrier(77), 10, 30, DVec3::ZERO, DVec3::Z * 20.0);
|
||||
let ignite = 10.0 + crate::sim::strike::EJECT_TICKS as f64;
|
||||
let mut rec = Recording::blank(120);
|
||||
rec.straight(1, What::StrikeCarrier(77), 10, 90, DVec3::ZERO, DVec3::Z * 80.0);
|
||||
assert!(replay_smoke(&rec, 14.0).is_empty());
|
||||
let segments = replay_smoke(&rec, 18.0);
|
||||
assert_eq!(segments.len(), 1);
|
||||
assert!((segments[0].az - 5.0).abs() < 1e-6);
|
||||
assert!((segments[0].bz - 8.0).abs() < 1e-6);
|
||||
assert!(replay_smoke(&rec, (ignite - 2.0).max(30.0)).is_empty(), "no trail before the ignition");
|
||||
let segments = replay_smoke(&rec, ignite + 12.0);
|
||||
assert_eq!(segments.len(), 2);
|
||||
assert!((segments[0].az - 20.0).abs() < 1e-6 && (segments[0].bz - 24.0).abs() < 1e-6, "the trail begins at the recorded ignition");
|
||||
assert!((segments[1].az - 24.0).abs() < 1e-6 && (segments[1].bz - 32.0).abs() < 1e-6);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
+22
-4
@@ -679,6 +679,8 @@
|
||||
"turn": 0.3,
|
||||
"boost": 8,
|
||||
"cold": 0,
|
||||
"eject": 0.0,
|
||||
"eject_drag": 0.0,
|
||||
"pk": 0.9,
|
||||
"fuse": 4.0,
|
||||
"life": 240,
|
||||
@@ -697,6 +699,8 @@
|
||||
"turn": 0.32,
|
||||
"boost": 5,
|
||||
"cold": 0,
|
||||
"eject": 0.0,
|
||||
"eject_drag": 0.0,
|
||||
"pk": 0.82,
|
||||
"fuse": 3.0,
|
||||
"life": 160,
|
||||
@@ -714,7 +718,9 @@
|
||||
"accel": 0.5,
|
||||
"turn": 0.22,
|
||||
"boost": 8,
|
||||
"cold": 5,
|
||||
"cold": 20,
|
||||
"eject": 2.4,
|
||||
"eject_drag": 0.07,
|
||||
"pk": 0.9,
|
||||
"fuse": 5.0,
|
||||
"life": 300,
|
||||
@@ -732,7 +738,9 @@
|
||||
"accel": 0.6,
|
||||
"turn": 0.22,
|
||||
"boost": 8,
|
||||
"cold": 5,
|
||||
"cold": 24,
|
||||
"eject": 2.6,
|
||||
"eject_drag": 0.07,
|
||||
"pk": 0.95,
|
||||
"fuse": 6.0,
|
||||
"life": 320,
|
||||
@@ -750,7 +758,9 @@
|
||||
"accel": 0.5,
|
||||
"turn": 0.35,
|
||||
"boost": 6,
|
||||
"cold": 4,
|
||||
"cold": 8,
|
||||
"eject": 1.8,
|
||||
"eject_drag": 0.1,
|
||||
"pk": 0.9,
|
||||
"fuse": 4.0,
|
||||
"life": 260,
|
||||
@@ -769,6 +779,8 @@
|
||||
"turn": 0.3,
|
||||
"boost": 6,
|
||||
"cold": 0,
|
||||
"eject": 0.0,
|
||||
"eject_drag": 0.0,
|
||||
"pk": 0.85,
|
||||
"fuse": 4.0,
|
||||
"life": 260,
|
||||
@@ -787,6 +799,8 @@
|
||||
"turn": 0.25,
|
||||
"boost": 8,
|
||||
"cold": 0,
|
||||
"eject": 0.0,
|
||||
"eject_drag": 0.0,
|
||||
"pk": 0.85,
|
||||
"fuse": 5.0,
|
||||
"life": 300,
|
||||
@@ -804,7 +818,9 @@
|
||||
"accel": 0.6,
|
||||
"turn": 0.4,
|
||||
"boost": 4,
|
||||
"cold": 4,
|
||||
"cold": 8,
|
||||
"eject": 1.6,
|
||||
"eject_drag": 0.1,
|
||||
"pk": 0.9,
|
||||
"fuse": 3.0,
|
||||
"life": 160,
|
||||
@@ -823,6 +839,8 @@
|
||||
"turn": 0.3,
|
||||
"boost": 4,
|
||||
"cold": 0,
|
||||
"eject": 0.0,
|
||||
"eject_drag": 0.0,
|
||||
"pk": 0.8,
|
||||
"fuse": 3.0,
|
||||
"life": 900,
|
||||
|
||||
@@ -230,8 +230,8 @@ def validate_specs(data, content, events):
|
||||
missile_kinds = set()
|
||||
for i, entry in enumerate(data["missiles"]):
|
||||
where = f"missiles[{i}]"
|
||||
_exact(entry, {"kind", "model", "speed", "accel", "turn", "boost", "cold", "pk", "fuse",
|
||||
"life", "scale", "flame", "rear", "seeker", "launch_sound", "voice"}, where)
|
||||
_exact(entry, {"kind", "model", "speed", "accel", "turn", "boost", "cold", "eject", "eject_drag",
|
||||
"pk", "fuse", "life", "scale", "flame", "rear", "seeker", "launch_sound", "voice"}, where)
|
||||
kind = _text(entry["kind"], f"{where}.kind", r"[A-Z][A-Za-z0-9]*")
|
||||
if kind in missile_kinds:
|
||||
raise ValueError(f"{where}: duplicate missile kind {kind}")
|
||||
@@ -241,6 +241,8 @@ def validate_specs(data, content, events):
|
||||
_number(entry[field], f"{where}.{field}", 0)
|
||||
_integer(entry["boost"], f"{where}.boost")
|
||||
_integer(entry["cold"], f"{where}.cold")
|
||||
_number(entry["eject"], f"{where}.eject", 0)
|
||||
_number(entry["eject_drag"], f"{where}.eject_drag", 0)
|
||||
_integer(entry["life"], f"{where}.life", 1)
|
||||
_sound(entry["launch_sound"], f"{where}.launch_sound", events)
|
||||
_exact(entry["voice"], {"event", "gain", "pitch"}, f"{where}.voice")
|
||||
|
||||
@@ -167,6 +167,8 @@ def missile_profile(entry):
|
||||
f" turn: {num(entry['turn'])},",
|
||||
f" boost: {integer(entry['boost'])},",
|
||||
f" cold: {integer(entry['cold'])},",
|
||||
f" eject: {num(entry['eject'])},",
|
||||
f" eject_drag: {num(entry['eject_drag'])},",
|
||||
f" pk: {num(entry['pk'])},",
|
||||
f" fuse: {num(entry['fuse'])},",
|
||||
f" life: {integer(entry['life'])},",
|
||||
|
||||
Reference in New Issue
Block a user