diff --git a/airdef-mod/src/sim/strike.rs b/airdef-mod/src/sim/strike.rs index dbf9b95..4aaf89f 100644 --- a/airdef-mod/src/sim/strike.rs +++ b/airdef-mod/src/sim/strike.rs @@ -587,12 +587,11 @@ impl Sim { let below = ground.at(DVec3::new(f.land.x, 0.0, f.land.y), now); 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 want = f.from.lerp(end, t) + DVec3::Y * (TUNE.height * (std::f64::consts::PI * t).sin().max(0.0).sqrt()); + let planned = f.from.lerp(end, t) + DVec3::Y * (TUNE.height * (std::f64::consts::PI * t).sin().max(0.0).sqrt()); let from = f.pos; - if now >= f.arrive && from.distance(end) <= TUNE.speed * 1.5 + 2.0 { - self.land_strike(f, from, ground, player, out); - continue; - } + // Time spent waiting for world data must not accumulate into a faster step. + let delta = planned - from; + let want = from + delta.clamp_length_max(TUNE.speed); // A missile that never reaches its point (the world never tells of the way) ends somewhere: it does // not stay in the air for ever. if now >= f.arrive + 600 { @@ -605,9 +604,11 @@ impl Sim { } let sweep = ground.trace(f.key(), from, want, false, now); let mut pos = want; + let mut permitted = true; let contact = match sweep { world::Sweep::Hit { point, .. } => Some(point), world::Sweep::Wait { t } => { + permitted = false; pos = from.lerp(want, (t - 1e-6).max(0.0)); None } @@ -616,19 +617,32 @@ impl Sim { // the game doesn't have (not one still loading) is passed, high over the land. let low = ground.at(from, now).is_some_and(|g| from.y < g + 12.0); if let Some(t) = ground.unread(&sweep, &mut f.hold, low) { + permitted = false; pos = from.lerp(want, t); } None } - world::Sweep::Clear => None, + world::Sweep::Clear => { + ground.unread(&sweep, &mut f.hold, false); + None + }, }; if let Some(point) = contact { - self.crash_strike(f, point, ground, player, out); + // A surface exactly at the intended endpoint is the landing, not an intervening wall. + if point.distance(end) < 1e-6 { + self.land_strike(f, point, ground, player, out); + } else { + self.crash_strike(f, point, ground, player, out); + } + continue; + } + if permitted && pos.distance(end) < 1e-6 { + self.land_strike(f, pos, ground, player, out); continue; } // The land's height says it met the ground where the blocks were not read: the same contact. let here = ground.at(pos, now); - if ground.ground_hit(here, pos, 0.2, &sweep, now) { + if permitted && ground.ground_hit(here, pos, 0.2, &sweep, now) { self.crash_strike(f, pos, ground, player, out); continue; } @@ -641,7 +655,7 @@ impl Sim { out.model(id + 1, "airdef:geran3_flame", pos + rot * DVec3::new(0.0, up * spec.scale, back * spec.scale), rot, spec.scale * size * flicker, true); } if let Some((event, pitch)) = DroneKind::Kalibr.engine_voice() { - out.engine(id, event, pos, dir * TUNE.speed, 0.8, pitch, false); + out.engine(id, event, pos, pos - from, 0.8, pitch, false); } f.pos = pos; rest.push(f); @@ -1413,6 +1427,52 @@ mod tests { assert_eq!(sim.strikes.fired, 1); } + #[test] + fn terminal_wall_is_resolved_before_landing() { + let mut sim = Sim::new(11); + sim.strikes.cover_override = Some(0); + let id = fixed_site(&mut sim, DVec3::new(0.0, 64.0, 300.0), 2); + let (mut ground, mut out) = (Ground::default(), Out::default()); + sim.order_strike(id, DVec3::new(0.0, 64.0, 0.0), &mut out).unwrap(); + let f = sim.strikes.flights[0]; + let wall = Wall { at: f.land.y.floor() as i32 - 1, hole: None }; + for _ in 0..500 { + serve(&mut ground, &wall, sim.tick); + sim.step(&mut ground, None, &mut out); + if sim.strikes.flights.is_empty() { break; } + } + assert!(sim.strikes.flights.is_empty()); + assert_eq!((sim.strikes.destroyed, sim.strikes.missed), (0, 1)); + assert!(sim.sites.get(id).unwrap().alive); + assert_eq!(sim.sites.pending(id), 2); + assert!(matches!(strike_events(&sim).as_slice(), [record::EventKind::Strike { result: record::StrikeResult::Blocked, .. }])); + assert!((sim.blasts[0].at.z - f64::from(wall.at)).abs() < 0.01); + } + + #[test] + fn delayed_world_answers_do_not_accelerate_the_strike() { + let mut sim = Sim::new(11); + sim.strikes.cover_override = Some(0); + let id = fixed_site(&mut sim, DVec3::new(0.0, 64.0, 1000.0), 0); + let (mut ground, mut out) = (Ground::default(), Out::default()); + sim.order_strike(id, DVec3::new(0.0, 64.0, 0.0), &mut out).unwrap(); + for _ in 0..20 { sim.step(&mut ground, None, &mut out); } + let f = sim.strikes.flights[0]; + let open = Wall { at: 10_000, hole: None }; + warm(&mut ground, &open, f.from, DVec3::new(f.land.x, 110.0, f.land.y)); + let mut previous = f.pos; + for _ in 0..500 { + serve(&mut ground, &open, sim.tick); + sim.step(&mut ground, None, &mut out); + if let Some(f) = sim.strikes.flights.first() { + assert!(previous.distance(f.pos) <= TUNE.speed + 1e-8, "jump at tick {}", sim.tick); + previous = f.pos; + } else { break; } + } + assert!(sim.strikes.flights.is_empty(), "strike failed to arrive"); + assert_eq!(sim.strikes.destroyed, 1, "{:?}", out.messages); + } + // #106: an open way works as before: the missile reaches its point, the site dies, one result, one stock spent. #[test] fn a_counter_strike_on_an_open_way_still_reaches_its_site() {