diff --git a/airdef-mod/NOTES-terrain-guidance.md b/airdef-mod/NOTES-terrain-guidance.md new file mode 100644 index 0000000..a72f186 --- /dev/null +++ b/airdef-mod/NOTES-terrain-guidance.md @@ -0,0 +1,5 @@ +Status of terrain-aware flight (not committed): +- Done: sim/terrain.rs (look-ahead terrain following, dive only down a clear line, interceptor steer/loft/self-destruct, launch predicate), planner relief pricing + spawn above land (raid.rs), SAM launch check + why line (mod.rs, why.rs), Tally.scrubbed, world.probe, harness terrain fixes, 6 new tests in regress.rs. Build has no warnings; cargo test passes (43 + 4). +- Numbers (6 seeds, sim_run big-smart): hills:70:100:140 crashes 13 -> 3, hills:70:150:120 29 -> 2, default hills 2 -> 2 (all terminal-dive near-target by Kh101/Kalibr). +- Half-done / known: terminal dive of slow-turning cruise missiles still sometimes hits ground 7-20 blocks short of target; hills:70:150:120 tick max 38 ms / p95 2 ms (planner terrain phase + ground queries) not profiled; not tested in game. +- Next: tune dive geometry for Kh101/Kalibr, profile the planner's 1024-columns-per-tick height requests, optional in-game check. diff --git a/airdef-mod/examples/sim_run/harness.rs b/airdef-mod/examples/sim_run/harness.rs index 41d7de4..e6c4c01 100644 --- a/airdef-mod/examples/sim_run/harness.rs +++ b/airdef-mod/examples/sim_run/harness.rs @@ -108,6 +108,8 @@ pub struct Report { /// The block cache's counters (issue #43) and the drones that struck the land (issue #42). pub world: String, pub crashes: u32, + /// What went wrong for the air defence, as the raid report words it. + pub failures: String, pub ground_batches: u64, pub tick_p50: Duration, pub tick_p95: Duration, @@ -135,14 +137,16 @@ pub fn run(sc: &Scenario) -> Report { sim.damage = false; // explosions would change the land the height function describes for u in &sc.units { let id = sim.id(); - let mut s = Sam::new(id, u.kind, u.pos, u.heading); + // Systems stand on the land, whatever the layout says. + let pos = DVec3::new(u.pos.x, f64::from(sc.terrain.height(u.pos.x.floor() as i32, u.pos.z.floor() as i32)), u.pos.z); + let mut s = Sam::new(id, u.kind, pos, u.heading); if u.empty { s.empty = true; s.missiles = 0; } sim.sams.push(s); } - sim.targets = sc.objects.clone(); + sim.targets = sc.objects.iter().map(|o| DVec3::new(o.x, f64::from(sc.terrain.height(o.x.floor() as i32, o.z.floor() as i32)), o.z)).collect(); sim.target_health = vec![100.0; sc.objects.len()]; if sc.ew { sim.set_ew(true); @@ -163,7 +167,8 @@ pub fn run(sc: &Scenario) -> Report { if !ordered[i] && now >= spec.at && !sim.planning() && (i == 0 || ended[i - 1]) { let order = RaidOrder { kinds: spec.kinds.clone(), approach: spec.approach, dist: spec.dist, aim: spec.aim }; launched_before[i] = sim.launched; - sim.order_raid(&order, &sc.objects, &mut out); + let objects = sim.targets.clone(); + sim.order_raid(&order, &objects, &mut out); ordered[i] = true; } } @@ -226,6 +231,7 @@ pub fn run(sc: &Scenario) -> Report { r.raids_ordered = ordered.iter().filter(|o| **o).count(); r.world = ground.world.text(); r.crashes = sim.crashes; + r.failures = sim.tally.text(); r.raids_ended = ended.iter().filter(|o| **o).count(); r.all_ended = r.raids_ended == sc.raids.len(); r.tick_total = times.iter().sum(); @@ -268,7 +274,8 @@ impl Report { ground queries: {gq} in {gb} batches\n\ non-finite positions: {nan}\n\ stuck trucks: {stuck:?} (busy truck-ticks {busy})\n\ - crashes into the land: {crashes}; {world}\n", + crashes into the land: {crashes}; {world}\n\ + air-defence failures: {failures}\n", name = self.name, seed = self.seed, units = self.units, @@ -293,6 +300,7 @@ impl Report { busy = self.truck_busy_ticks, crashes = self.crashes, world = self.world, + failures = if self.failures.is_empty() { "none" } else { &self.failures }, ) } pub fn json(&self) -> String { diff --git a/airdef-mod/src/sim/mod.rs b/airdef-mod/src/sim/mod.rs index fd92837..25d7430 100644 --- a/airdef-mod/src/sim/mod.rs +++ b/airdef-mod/src/sim/mod.rs @@ -14,6 +14,7 @@ pub mod fires; mod raid; pub mod record; mod sight; +mod terrain; mod ew; mod lights; mod logistics; @@ -745,6 +746,23 @@ pub struct Drone { sector: u8, /// Sent by the smart enemy: it may be turned on systems that give themselves away. smart: bool, + /// What the land ahead asks of its flight (see `terrain`). + nav: Nav, +} + +/// A drone's terrain following: refreshed every few ticks, used every one. +#[derive(Clone, Copy, Debug)] +struct Nav { + /// The height it must be at now to clear the land ahead (unknown: -inf). + need: f64, + /// The height from which a straight dive on its target clears the land (unknown: -inf). + dive: f64, +} + +impl Default for Nav { + fn default() -> Self { + Nav { need: f64::NEG_INFINITY, dive: f64::NEG_INFINITY } + } } impl Drone { @@ -1073,6 +1091,10 @@ pub struct Sam { pub ew_lock: bool, /// Ticks till a gun fires its next illumination flare. flare_cd: u32, + /// Shots the land ruled out, by target: when it was judged and why (`terrain::MASKED`, `terrain::PATH_BLOCKED`). + pub(super) terrain: HashMap, + /// Targets whose land hasn't been answered yet, and since when it has been waited for. + waits: HashMap, } impl Sam { @@ -1124,6 +1146,8 @@ impl Sam { heat: 0.0, ew_lock: false, flare_cd: 0, + terrain: HashMap::new(), + waits: HashMap::new(), } } pub fn alive(&self) -> bool { @@ -1384,6 +1408,7 @@ impl Sim { jammed: false, sector: 0, smart: false, + nav: Nav::default(), }); self.launched += 1; } @@ -1433,7 +1458,7 @@ impl Sim { /// One game tick. pub fn step(&mut self, ground: &mut Ground, player: Option, out: &mut Out) { self.tick += 1; - self.plan_step(out); + self.plan_step(Some(&mut *ground), out); self.adapt(); self.release_queue(); self.step_drones(ground, player, out); @@ -1443,7 +1468,7 @@ impl Sim { self.step_ew(out); self.update_sight(ground); self.step_lights(out); - self.step_sams(out); + self.step_sams(ground, out); self.step_missiles(ground, out); self.step_fx(ground, out); self.draw_targets(out); @@ -1473,7 +1498,7 @@ impl Sim { let spec = d.kind.spec(); let below = ground.at(d.pos, now); let from = d.pos; - ground.ahead(d.pos, d.vel, 20.0, now); + ground.ahead(d.pos, d.vel, (120.0 / d.vel.length().max(0.05)).max(20.0), now); // Near its aim it looks at the land there again: a platform or hilltop blown away // meanwhile, it goes for what is left below, not for the air where it stood. if d.flight != Flight::Falling @@ -1596,9 +1621,6 @@ impl Sim { } continue; } - if d.flight == Flight::Cruise && d.route.len() <= 1 && horizontal(d.target - d.pos).length() < spec.dive { - d.flight = Flight::Dive; - } // An interceptor coming: drones drop to the treetops (radars lose them sooner). if d.engaged > 0 && matches!(spec.class, Class::Drone | Class::Jet) { d.low = true; @@ -1614,24 +1636,86 @@ impl Sim { { d.route.remove(0); } - let goal = if d.flight == Flight::Dive { - d.target - } else { - // Cruise height over the target, and `clearance` over the land ahead. - let ahead = ground.at(d.pos + horizontal(d.vel).normalize_or_zero() * 20.0, now); - let floor = match (below, ahead) { - (Some(a), Some(b)) => a.max(b) + spec.clearance, - (Some(g), None) | (None, Some(g)) => g + spec.clearance, - (None, None) => d.target.y + spec.clearance, - }; - let to = d.route.first().copied().unwrap_or(d.target); - // Under the radars: as low as the land lets it, all the way. - let height = if d.low { floor.max(d.target.y + 4.0) } else { (d.target.y + spec.cruise).max(floor) }; - DVec3::new(to.x, height, to.z) + let climb = d.kind.climb(); + // A dive begins far enough out to bring it down onto the target: a missile that turns slowly needs a long + // way to lose height it has over the aim. + let dive_at = { + let r = spec.speed * 1.35 / (spec.turn * 3.5).max(0.01); + let drop = (d.pos.y - d.target.y).clamp(0.0, r); + spec.dive.max(((2.0 * r * drop - drop * drop).sqrt() * 1.1).min(spec.dive * 2.5)) }; + let way = d.route.first().copied().unwrap_or(d.target); + let to_target = horizontal(d.target - d.pos).length(); + // What the land ahead asks: along its heading and towards its next point, several seconds out. + if d.age <= 1 || (now + u64::from(d.id)) % 3 == 0 { + let heading = horizontal(d.vel).normalize_or_zero(); + let towards = horizontal(way - d.pos).normalize_or_zero(); + let look = d.kind.look_len(); + let mut lines = vec![(heading, look)]; + let angle = heading.angle_between(towards); + if towards != DVec3::ZERO && angle > 0.1 { + let reach = look.min(horizontal(way - d.pos).length() + 30.0); + lines.push((towards, reach)); + if angle > 0.4 { + lines.push(((heading + towards).normalize_or_zero(), reach)); + } + } + d.nav.need = terrain::required_height(ground, d.pos, &lines, spec.clearance, &climb).unwrap_or(f64::NEG_INFINITY); + d.nav.dive = if to_target < dive_at * 3.0 { + terrain::dive_height(ground, d.pos, d.target, terrain::DIVE_MARGIN).unwrap_or(f64::NEG_INFINITY) + } else { + f64::NEG_INFINITY + }; + } + // The dive into the target starts when the line down to it is clear of the land, else it comes in higher; + // a dive that finds the line blocked pulls out and climbs. + if d.flight == Flight::Cruise && d.route.len() <= 1 && to_target < dive_at && d.pos.y >= d.nav.dive - 1.0 && d.vel.y < 0.3 * spec.speed { + d.flight = Flight::Dive; + } else if d.flight == Flight::Dive && to_target > 40.0 && d.pos.y < d.nav.dive - 4.0 { + d.flight = Flight::Cruise; + } let rate = if d.flight == Flight::Dive { spec.turn * 3.5 } else { spec.turn }; let old = d.vel.normalize_or(DVec3::NEG_Z); - let new = turn(old, goal - d.pos, rate); + let probe_len = (spec.speed * 10.0).clamp(50.0, 150.0); + let new = if d.flight == Flight::Dive { + let mut new = turn(old, d.target - d.pos, rate); + // Something solid across the dive, short of the target (a building the heights don't show): up and round. + if to_target > 40.0 + && d.age > 8 + && let world::Sweep::Hit { point, .. } = ground.world.probe(d.pos, d.pos + new * probe_len.min(to_target - 20.0), now) + && point.distance(d.target) > 16.0 + { + d.flight = Flight::Cruise; + d.nav.need = d.nav.need.max(point.y + spec.clearance); + new = turn(old, horizontal(way - d.pos).normalize_or(horizontal(old)) + DVec3::Y * climb.pitch.tan(), spec.turn); + } + new + } else { + let heading = horizontal(way - d.pos).normalize_or(horizontal(old).normalize_or(DVec3::NEG_Z)); + // Cruise height over the target; as low as the land lets it when under the radars. + let base = if d.low { d.target.y + 4.0 } else { d.target.y + spec.cruise }; + let own = below.map_or(f64::NEG_INFINITY, |g| g + spec.clearance); + let need = d.nav.need.max(own); + let safe = if need.is_finite() { need + 2.0 } else { d.target.y + spec.clearance }; + let height = base.max(safe).max(d.nav.dive + 1.0); + let full = climb.pitch.tan(); + let urgent = need.is_finite() && d.pos.y < need; + let slope = if urgent { + full + } else { + ((height - d.pos.y) / (climb.radius * 1.5).min((to_target * 0.5).max(30.0)).max(30.0)).clamp(-0.35, full) + }; + let mut new = terrain::fly_towards(old, heading, slope, rate, urgent); + // Something solid ahead that the heights don't show (trees, a building, an overhang): climb. + if d.age > 8 + && let world::Sweep::Hit { point, .. } = + ground.world.probe(d.pos - DVec3::Y * 4.0, d.pos - DVec3::Y * 4.0 + new * probe_len, now) + { + d.nav.need = d.nav.need.max(point.y + spec.clearance); + new = terrain::fly_towards(old, heading, full, rate, true); + } + terrain::clamp_pitch(new, -0.6, climb.pitch, heading) + }; let side = old.cross(new).y; d.roll += ((side * 40.0).clamp(-0.8, 0.8) - d.roll) * 0.15; let speed = if d.flight == Flight::Dive { spec.speed * 1.35 } else { spec.speed }; @@ -1733,6 +1817,7 @@ impl Sim { jammed: false, sector, smart: false, + nav: Nav::default(), }); } for d in &self.drones { @@ -1884,7 +1969,7 @@ impl Sim { // ------------------------------------------------ air defence - fn step_sams(&mut self, out: &mut Out) { + fn step_sams(&mut self, ground: &mut Ground, out: &mut Out) { if self.hold { for i in 0..self.sams.len() { if self.sams[i].alive() && !self.sams[i].moving() { @@ -1923,6 +2008,11 @@ impl Sim { sam.dish = sam.beam; } sam.jam = sam.jam.saturating_sub(1); + if self.tick % 64 == 0 { + let now = self.tick; + sam.terrain.retain(|_, (when, _)| now.saturating_sub(*when) < 4 * terrain::VERDICT_AGE); + sam.waits.retain(|_, since| now.saturating_sub(*since) < 4 * terrain::WAIT_TICKS); + } if sam.reloading > 0 { sam.reloading -= 1; if sam.reloading == 0 { @@ -1950,7 +2040,10 @@ impl Sim { let (had_target, had_gun) = (self.sams[i].target, self.sams[i].gun_target); let now = self.tick; let (target, gun_target) = { - let tracks = &self.sams[i].tracks; + let me = &self.sams[i]; + let tracks = &me.tracks; + // A shot the land ruled out a moment ago is not tried again at once. + let open = |id: u32| me.terrain_verdict(id, now).is_none_or(|b| b == 0); // The conditions are `why`'s, shared with the diagnostic. A system knows its targets by their tracks: // the forecast of the last measurement, never the true place. let drone = |tr: &Track| drone_at.get(&tr.id).map(|&k| &self.drones[k]); @@ -1958,7 +2051,7 @@ impl Sim { let needed = why::needed; let target = spec.missile.as_ref().map(|load| { let ok = |tr: &Track, k: f64| { - drone(tr).is_some_and(|d| why::allowed(roles, tr, now, d) && why::in_reach(load.min, load.max, k, pos, tr.est(now))) + drone(tr).is_some_and(|d| why::allowed(roles, tr, now, d) && open(tr.id) && why::in_reach(load.min, load.max, k, pos, tr.est(now))) }; let keep = had_target.filter(|t| tracks.iter().any(|tr| tr.id == *t && ok(tr, 1.1))); keep.or_else(|| { @@ -2133,9 +2226,38 @@ impl Sim { // The missile turns after its boost, but the launcher must point near the target. let t = sam.track(mt).filter(|_| drone_at.contains_key(&mt)).map(|tr| tr.est(now)); let aligned = why::missile_aligned(spec.family, sam.yaw, mount, t, aim_id, mt, aim_error); - if ready && aligned { - let n = load.magazine - sam.missiles; - let (muzzle, dir) = sam.muzzle(n); + let n = load.magazine - sam.missiles; + let (muzzle, dir) = sam.muzzle(n); + // The land: a target behind a hill, or a path the hill closes, is not fired at; the system looks for another. + let land = if ready && aligned { + match (sam.terrain_verdict(mt, now), sam.track(mt)) { + (Some(bits), _) => bits, + (None, Some(tr)) => { + let mut bits = terrain::launch_blocks(ground, load.kind, muzzle, sight::antenna(sam), tr.est(now), tr.vel); + if bits == terrain::WAIT { + // The land is being asked for: a moment's wait, not a long one (the game may not answer). + if now.saturating_sub(*sam.waits.entry(mt).or_insert(now)) > terrain::WAIT_TICKS { + bits = 0; + } + } else { + sam.waits.remove(&mt); + if bits != 0 { + sam.terrain.insert(mt, (now, bits)); + } + } + bits + } + (None, None) => 0, + } + } else { + 0 + }; + if land == terrain::WAIT { + // The land under the shot is not known yet: it waits. + } else if land != 0 { + retarget(&mut missile_on, Some(mt), None); + sam.target = None; + } else if ready && aligned { launches.push((load.kind, muzzle, dir, mt, sam.id)); // Its missile is on it from now, so the systems after this one this tick see // the target taken; this one's next launch looks for another. @@ -2204,7 +2326,7 @@ impl Sim { 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 blocked = 0u32; + let (mut blocked, mut scrubbed) = (0u32, 0u32); 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(); for m in &mut self.missiles { m.age += 1; @@ -2223,7 +2345,14 @@ impl Sim { let los = (t.pos - m.pos).normalize_or_zero(); let closing = (speed - t.vel.dot(los)).max(1.0); let tgo = t.pos.distance(m.pos) / closing; - turn(dir, t.pos + t.vel * tgo - m.pos, spec.turn) + // Over the land in the way: lofted as far as the missile can, else it blows up before the ridge. + let (want, fail) = terrain::steer(ground, m.kind, m.pos, speed, t.pos + t.vel * tgo); + if fail { + scrubbed += 1; + done.push((m.id, Some(t.id), false, m.sam)); + continue; + } + turn(dir, want, spec.turn) } // After a cold launch it tips over towards the target while the motor burns. Some(t) if lit && spec.cold > 0 => turn(dir, t.pos - m.pos, spec.turn * 0.5), @@ -2303,6 +2432,7 @@ impl Sim { t.expired += expired; t.orphaned += orphaned; t.blocked += blocked; + t.scrubbed += scrubbed; }); for (mid, target, kill, sam) in done { if let Some(i) = self.missiles.iter().position(|m| m.id == mid) { diff --git a/airdef-mod/src/sim/raid.rs b/airdef-mod/src/sim/raid.rs index c729b4f..fc1a90e 100644 --- a/airdef-mod/src/sim/raid.rs +++ b/airdef-mod/src/sim/raid.rs @@ -84,6 +84,12 @@ pub(super) struct Plan { approach: Approach, total: u32, centre: DVec3, + /// The land under the grid's cells (NaN where the game has none to tell of), the cells still unanswered, and + /// the ticks the planner has waited for the answers. A smart raid is routed over the land, not round it. + heights: Vec, + unanswered: Vec, + waited: u32, + mapped: bool, } /// The raid in progress: the counts at its start, for the report. @@ -123,6 +129,20 @@ impl Grid { /// The cheapest way over `grid` from `from` to `to` by its prices (A*): points along it, `to` last. pub(super) fn plan_route(grid: &Grid, prices: &[f32], from: DVec3, to: DVec3) -> (Vec, usize) { + plan_route_on(grid, prices, None, from, to) +} + +/// The land a route crosses: its height under each cell (NaN unknown), the climb a kind holds for long +/// (rise a block), and the height above which the land forces a drone up from its low altitude. +pub(super) struct Relief<'a> { + pub heights: &'a [f32], + pub slope: f32, + pub floor: f32, +} + +/// [`plan_route`] over relief: a climb steeper than the kind holds is dear (a cliff or a ridge it would have to +/// pull up over), and high land costs more than a valley, where it can fly low and stay under the radars. +pub(super) fn plan_route_on(grid: &Grid, prices: &[f32], relief: Option<&Relief>, from: DVec3, to: DVec3) -> (Vec, usize) { let mut opened = 0; let n = grid.size; let at = |(i, j): (i32, i32)| (i * n + j) as usize; @@ -151,7 +171,21 @@ pub(super) fn plan_route(grid: &Grid, prices: &[f32], from: DVec3, to: DVec3) -> } let len = if di != 0 && dj != 0 { std::f32::consts::SQRT_2 } else { 1.0 }; // The target's own cell is where it must go, whatever covers it. - let price = if nb == goal { len } else { len * prices[at(nb)] }; + let mut price = if nb == goal { len } else { len * prices[at(nb)] }; + if let Some(r) = relief + && nb != goal + { + let (h0, h1) = (r.heights[at(c)], r.heights[at(nb)]); + if h1.is_finite() { + price += len * ((h1 - r.floor) / 30.0).clamp(0.0, 4.0); + if h0.is_finite() { + let slope = (h1 - h0) / (len * grid.cell as f32); + if slope > r.slope { + price += (slope - r.slope) * 20.0 * len; + } + } + } + } let ng = g + price; if ng < best[at(nb)] { best[at(nb)] = ng; @@ -250,15 +284,53 @@ impl Sim { approach: order.approach, total, centre, + heights: Vec::new(), + unanswered: Vec::new(), + waited: 0, + mapped: !smart, }); // A raid without routes is ready at once. - self.plan_step(out); + self.plan_step(None, out); total } /// Works on the raid's plan a while (a tick's share), and launches it when it is ready. - pub(super) fn plan_step(&mut self, out: &mut Out) { + pub(super) fn plan_step(&mut self, ground: Option<&mut Ground>, out: &mut Out) { let Some(mut plan) = self.plan.take() else { return }; + // A smart raid first learns the land under its grid: asked for in bulk, a thousand columns a tick, for + // up to two seconds (what the game has no land for stays unknown and costs nothing). + if !plan.mapped { + let Some(ground) = ground else { + self.plan = Some(plan); + return; + }; + let n = plan.grid.size; + if plan.heights.is_empty() { + plan.heights = vec![f32::NAN; (n * n) as usize]; + plan.unanswered = (0..(n * n) as u32).collect(); + } + let Plan { grid, heights, unanswered, .. } = &mut plan; + unanswered.retain(|&k| { + let (i, j) = (k as i32 / n, k as i32 % n); + let c = grid.centre((i, j), 0.0); + match ground.column(c.x.floor() as i32, c.z.floor() as i32, 1024) { + None => true, + Some(h) => { + if let Some(h) = h { + heights[k as usize] = h as f32; + } + false + } + } + }); + plan.waited += 1; + if plan.unanswered.is_empty() || plan.waited >= 40 { + plan.mapped = true; + } else { + self.plan = Some(plan); + return; + } + } let mut work = 0usize; while plan.next < plan.items.len() && work < WORK_PER_TICK { let i = plan.next; @@ -268,17 +340,39 @@ impl Sim { }; let spec = kind.spec(); let mut d = plan.items[i].d.clone(); + // It starts over the land, not in it: a hill under the starting point lifts it. + let mut from = from; + if !plan.heights.is_empty() { + let (gi, gj) = plan.grid.index(from); + let g = plan.heights[(gi * plan.grid.size + gj) as usize]; + let floor = f64::from(g) + if spec.ballistic.is_some() { 10.0 } else { spec.clearance + 10.0 }; + if g.is_finite() && from.y < floor { + from.y = floor; + d.pos.y = floor; + if spec.ballistic.is_some() { + let flat = horizontal(aim - from); + let time = (flat.length() / spec.speed).max(30.0); + d.vel = flat / time + DVec3::Y * ((aim.y - from.y + 0.5 * GRAVITY * time * time) / time); + } + } + } let mut path = from.distance(aim); if let Some(key) = key { let route = match plan.routes.get(&key) { Some(r) => r.clone(), None => { if !plan.prices.contains_key(&kind) { - let (p, cost) = self.price_grid(&plan.grid, kind); + let heights = (!plan.heights.is_empty()).then_some(plan.heights.as_slice()); + let (p, cost) = self.price_grid_on(&plan.grid, kind, heights); work += cost; plan.prices.insert(kind, p); } - let (r, cost) = plan_route(&plan.grid, &plan.prices[&kind], from, aim); + let relief = (!plan.heights.is_empty()).then(|| Relief { + heights: &plan.heights, + slope: (kind.climb_pitch().tan() * 0.5) as f32, + floor: (aim.y + spec.clearance) as f32, + }); + let (r, cost) = plan_route_on(&plan.grid, &plan.prices[&kind], relief.as_ref(), from, aim); work += cost; plan.routes.insert(key, r.clone()); r @@ -357,6 +451,7 @@ impl Sim { jammed: false, sector: 0, smart: false, + nav: Nav::default(), } } @@ -404,6 +499,12 @@ impl Sim { /// The price of each cell of `grid` for this kind flying low: 1, and much more where a /// system sees it there (its radar's reach for it, the land) and can shoot it. pub(super) fn price_grid(&self, grid: &Grid, kind: DroneKind) -> (Vec, usize) { + self.price_grid_on(grid, kind, None) + } + + /// The same over known land: a cell's drone flies `height` over the land there, not over the system's feet, + /// so what a radar sees and what a hill hides follow the relief. + pub(super) fn price_grid_on(&self, grid: &Grid, kind: DroneKind, land: Option<&[f32]>) -> (Vec, usize) { let mut cost = 0; let spec = kind.spec(); let height = spec.clearance.max(6.0); @@ -446,7 +547,8 @@ impl Sim { for i in (ci - r).max(0)..=(ci + r).min(n - 1) { for j in (cj - r).max(0)..=(cj + r).min(n - 1) { cost += 1; - let p = grid.centre((i, j), s.pos.y + height); + let ground = land.map(|l| l[(i * n + j) as usize]).filter(|g| g.is_finite()); + let p = grid.centre((i, j), ground.map_or(s.pos.y + height, |g| f64::from(g) + height)); let d = horizontal(p - s.pos).length(); if d > far { continue; diff --git a/airdef-mod/src/sim/record.rs b/airdef-mod/src/sim/record.rs index 666a5f8..10d2f64 100644 --- a/airdef-mod/src/sim/record.rs +++ b/airdef-mod/src/sim/record.rs @@ -65,6 +65,8 @@ pub struct Tally { pub ammo_waits: u32, /// Interceptors lost against the land or a building on their way. pub blocked: u32, + /// Interceptors that blew up in the air short of land they could not climb over. + pub scrubbed: u32, } impl Tally { @@ -76,6 +78,7 @@ impl Tally { tracks_lost: self.tracks_lost.saturating_sub(start.tracks_lost), ammo_waits: self.ammo_waits.saturating_sub(start.ammo_waits), blocked: self.blocked.saturating_sub(start.blocked), + scrubbed: self.scrubbed.saturating_sub(start.scrubbed), } } /// The non-zero counts as a line, or nothing. @@ -87,6 +90,7 @@ impl Tally { (self.tracks_lost, "потерь трека"), (self.ammo_waits, "ожиданий БК"), (self.blocked, "ракет разбились о препятствие"), + (self.scrubbed, "ракет самоуничтожились у рельефа"), ] .into_iter() .filter(|p| p.0 > 0) diff --git a/airdef-mod/src/sim/regress.rs b/airdef-mod/src/sim/regress.rs index 9030f9b..113117e 100644 --- a/airdef-mod/src/sim/regress.rs +++ b/airdef-mod/src/sim/regress.rs @@ -187,7 +187,8 @@ fn turret_waits_for_its_aim() { sim.sams.push(Sam::new(id, SamKind::Radar, DVec3::new(0.0, 70.0, 0.0), 0.0)); // Straight behind the turret's heading, a slow target well inside the gun's reach. sim.add_drone(DroneKind::Shahed, DVec3::new(0.0, 75.0, 60.0), DVec3::ZERO, DVec3::new(0.0, 70.0, 900.0)); - sim.drones[0].vel = DVec3::ZERO; + // (Pointing away from the gun, so that it doesn't start by turning back over it.) + sim.drones[0].vel = DVec3::Z * 0.01; let (mut ground, mut out) = (Ground::default(), Out::default()); let mut turned = 0; for _ in 0..200 { @@ -392,7 +393,9 @@ fn drone_flying_into_a_hill_explodes_at_its_face() { let mut sim = Sim::new(3); sim.damage = false; let mut ground = Ground::default(); - sim.add_drone(kind, DVec3::new(0.0, 80.0, 0.0), DVec3::X, DVec3::new(600.0, 80.0, 0.0)); + // The aim is on the far side of the face, close enough that the dive is not called off for the hill (it is + // below, at the end of the dive, where flight planning leaves it alone). + sim.add_drone(kind, DVec3::new(0.0, 80.0, 0.0), DVec3::X, DVec3::new(80.0, 80.0, 0.0)); sim.drones[0].flight = Flight::Dive; for _ in 0..300 { if sim.drones.is_empty() { @@ -547,3 +550,251 @@ fn fires_start_merge_and_are_limited() { assert!(fl > 0.0 && sm > 0.0); assert_eq!(fires::level(3000.0, 3000.0).0, 0.0); } + +// ---- flight over the land: strike drones follow it, launchers keep their missiles for shots the land allows, +// interceptors keep clear of it. A synthetic land (a height function) stands in for the game. + +/// Land from a height function: blocks below it are solid, and the game's height map says the same. +struct Land i32>(F); +impl i32> world::WorldSource for Land { + fn solid(&self, x: i32, y: i32, z: i32) -> Option { + Some(y < (self.0)(x, z)) + } +} + +/// What the game answers a tick after the sim asked: land heights, and the sections it needs. +fn answer_land i32>(ground: &mut Ground, land: &Land, now: u64) { + let columns = ground.take_wanted().into_iter().map(|(x, z)| (x, z, (land.0)(x, z))).collect(); + ground.answer(columns, now); + let sections = ground.world.take_wanted(now); + ground.world.serve(land, sections, now); +} + +/// A ridge across the way, `rise` blocks high over a flat at 64, `half` blocks of slope to each side, crest at x=300. +fn ridge(rise: i32, half: i32) -> Land i32> { + ridge_at(300, rise, half) +} + +fn ridge_at(crest: i32, rise: i32, half: i32) -> Land i32> { + Land(move |x, _| 64 + (rise - (x - crest).abs() * rise / half).max(0)) +} + +/// Flies one drone of `kind` along +x over `land` from x=0 to its aim at x=750, to the end. Returns the sim. +fn fly_over i32>(kind: DroneKind, land: &Land, low: bool) -> Sim { + let mut sim = Sim::new(5); + sim.damage = false; + let mut ground = Ground::default(); + let spec = kind.spec(); + let height = if spec.cruise > 0.0 { spec.cruise } else { 25.0 }; + sim.add_drone(kind, DVec3::new(0.0, 64.0 + height, 0.0), DVec3::X, DVec3::new(750.0, 64.0, 0.0)); + sim.drones[0].low = low; + for _ in 0..4 { + sim.tick += 1; + answer_land(&mut ground, land, sim.tick); + } + let mut out = Out::default(); + for _ in 0..4000 { + if sim.drones.is_empty() { + break; + } + sim.tick += 1; + sim.step_drones(&mut ground, None, &mut out); + answer_land(&mut ground, land, sim.tick); + } + sim +} + +// Strike drones climb over a hill they could not have seen 20 blocks ahead. +#[test] +fn a_raid_crossing_a_hill_flies_over_it() { + for (rise, half) in [(60, 100), (60, 60), (90, 120)] { + let land = ridge(rise, half); + for kind in [DroneKind::Shahed, DroneKind::Geran3, DroneKind::Lancet, DroneKind::Gerbera, DroneKind::Kh101, DroneKind::Kalibr, DroneKind::Kh22, DroneKind::Zircon] { + for low in [false, true] { + let sim = fly_over(kind, &land, low); + assert!(sim.drones.is_empty(), "{kind:?}: still flying"); + // It bursts at its aim, not on the hill 450 blocks short of it (where a crash or a decoy's end shows too). + let at = sim.blasts.first().expect("no blast").at; + assert!(at.x > 700.0, "{kind:?} low={low} burst at x={:.0} over a {rise}-block hill ({half} to the crest)", at.x); + } + } + } +} + +// A low drone hugs the land: it does not fly at cruise height over the flat behind the ridge. +#[test] +fn a_low_drone_hugs_the_land_after_the_ridge() { + let land = ridge(60, 100); + let mut sim = Sim::new(5); + sim.damage = false; + let mut ground = Ground::default(); + sim.add_drone(DroneKind::Shahed, DVec3::new(0.0, 75.0, 0.0), DVec3::X, DVec3::new(900.0, 64.0, 0.0)); + sim.drones[0].low = true; + let mut out = Out::default(); + let mut highest_after = 0.0f64; + for _ in 0..4000 { + if sim.drones.is_empty() { + break; + } + sim.tick += 1; + sim.step_drones(&mut ground, None, &mut out); + answer_land(&mut ground, &land, sim.tick); + if let Some(d) = sim.drones.first() + && d.pos.x > 560.0 + && d.pos.x < 800.0 + { + highest_after = highest_after.max(d.pos.y); + } + } + assert_eq!(sim.crashes, 0); + assert!(highest_after < 64.0 + 40.0, "still {highest_after:.0} high far behind the ridge"); +} + +/// A target flying slowly along the ridge's far side at `x`, with a radar on the crest to see it. +fn behind_ridge(land: &Land i32>, crest: i32, kind: SamKind, x: f64, ticks: u32) -> Sim { + let mut sim = Sim::new(9); + sim.damage = false; + let ground_y = (land.0)(0, 0) as f64; + let id = sim.id(); + sim.sams.push(Sam::new(id, kind, DVec3::new(0.0, ground_y, 0.0), 0.0)); + let id = sim.id(); + sim.sams.push(Sam::new(id, SamKind::Radar, DVec3::new(f64::from(crest), (land.0)(crest, 0) as f64, -60.0), 0.0)); + let mut ground = Ground::default(); + let mut out = Out::default(); + for _ in 0..40 { + sim.tick += 1; + answer_land(&mut ground, land, sim.tick); + } + // It crawls along the far side of the ridge, 20 blocks over the land. + let there = (land.0)(x as i32, 0) as f64; + sim.add_drone(DroneKind::Gerbera, DVec3::new(x, there + 20.0, -100.0), DVec3::Z, DVec3::new(x, there, 3000.0)); + for _ in 0..ticks { + sim.step(&mut ground, None, &mut out); + answer_land(&mut ground, land, sim.tick); + } + sim +} + +// A short-range system behind a hill does not fire at a target the hill masks from it, whatever the network says. +#[test] +fn a_system_does_not_fire_at_a_target_masked_by_a_hill() { + // A short-range system 250 blocks short of the crest, the target 80 blocks beyond it. + type Boxed = Land i32>>; + let hill: Boxed = Land(Box::new(|x, _| 64 + (60 - (x - 150).abs()).max(0))); + let flat: Boxed = Land(Box::new(|_, _| 64)); + let shot = |land: &Boxed| { + let mut sim = Sim::new(9); + sim.damage = false; + let id = sim.id(); + sim.sams.push(Sam::new(id, SamKind::Tor, DVec3::new(100.0, 64.0, 0.0), 0.0)); + let id = sim.id(); + sim.sams.push(Sam::new(id, SamKind::Radar, DVec3::new(150.0, (land.0)(150, 0) as f64, -60.0), 0.0)); + let mut ground = Ground::default(); + let mut out = Out::default(); + for _ in 0..40 { + sim.tick += 1; + answer_land(&mut ground, land, sim.tick); + } + sim.add_drone(DroneKind::Gerbera, DVec3::new(200.0, 84.0, -60.0), DVec3::Z, DVec3::new(200.0, 64.0, 3000.0)); + let mut masked = 0u32; + let mut seen = false; + let mut told = false; + for _ in 0..300 { + sim.step(&mut ground, None, &mut out); + answer_land(&mut ground, land, sim.tick); + seen |= sim.sams[0].seen.len() == 1; + masked += sim.sams[0].terrain.values().filter(|(_, b)| *b == terrain::MASKED).count() as u32; + // `why` names the reason while the verdict stands. + told |= sim.why(0).iter().any(|c| !c.ok && c.text.contains("за рельефом")); + } + (sim, seen, masked, told) + }; + let (open, seen, _, _) = shot(&flat); + assert!(seen && open.sams[0].fired > 0, "control: with a clear line the Tor must fire (seen {seen}, fired {})", open.sams[0].fired); + let (shut, seen, masked, told) = shot(&hill); + assert!(seen, "the network must have shown the target to the Tor"); + assert_eq!(shut.sams[0].fired, 0, "the Tor fired at a target behind the hill"); + assert!(masked > 0, "no launch was judged and refused for the land"); + assert_eq!(shut.tally.blocked + shut.tally.scrubbed, 0); + assert!(told, "`why` never named the land as the reason"); +} + +// A long-range system lofts over a low ridge (its interceptor does not hit it) but not over a mountain. +#[test] +fn a_long_range_system_lofts_over_a_ridge_or_waits() { + let low = ridge_at(150, 25, 60); + let sim = behind_ridge(&low, 150, SamKind::IrisT, 230.0, 300); + assert!(sim.sams[0].fired > 0, "an IRIS-T SLM must loft over a 25-block ridge"); + assert_eq!(sim.tally.blocked + sim.tally.scrubbed, 0, "its interceptors hit the ridge"); + let tall = ridge_at(150, 400, 100); + let sim = behind_ridge(&tall, 150, SamKind::IrisT, 230.0, 300); + assert_eq!(sim.sams[0].fired, 0, "no missile can climb a 400-block mountain at this range"); + assert_eq!(sim.tally.blocked + sim.tally.scrubbed, 0); + assert!(sim.why(0).iter().any(|c| !c.ok && c.text.contains("рельеф")), "{:?}", sim.why(0).iter().map(|c| &c.text).collect::>()); +} + +// Interceptors fly over rolling land at a target low over it, and none ends in the ground. +#[test] +fn an_interceptor_over_rolling_land_does_not_hit_the_ground() { + let rolling = Land(|x: i32, z: i32| 70 + (28.0 * ((f64::from(x) / 21.0).sin() + (f64::from(z) / 33.0).cos()) / 2.0).round() as i32); + for kind in [MissileKind::Pac3, MissileKind::M48n6, MissileKind::IrisT, MissileKind::M57e6, MissileKind::M9m338, MissileKind::P1sun] { + for dx in [160.0, 260.0, 380.0] { + let mut sim = Sim::new(4); + sim.damage = false; + let mut ground = Ground::default(); + let mut out = Out::default(); + for _ in 0..4 { + sim.tick += 1; + answer_land(&mut ground, &rolling, sim.tick); + } + // A Gerbera 20 blocks over the land at the far end, flying away from the launcher. + let at = DVec3::new(dx, (rolling.0)(dx as i32, 0) as f64 + 20.0, 0.0); + sim.add_drone(DroneKind::Gerbera, at, DVec3::X, DVec3::new(5000.0, 64.0, 0.0)); + let (target, id) = (sim.drones[0].id, sim.id()); + let from = DVec3::new(0.0, (rolling.0)(0, 0) as f64 + 3.0, 0.0); + sim.drones[0].engaged = 1; + sim.missiles.push(Missile { id, kind, pos: from, vel: DVec3::new(0.5, 0.8, 0.0).normalize() * 0.8, target, sam: 0, age: 0 }); + for _ in 0..500 { + if sim.missiles.is_empty() { + break; + } + sim.tick += 1; + sim.step_missiles(&mut ground, &mut out); + answer_land(&mut ground, &rolling, sim.tick); + } + assert!(sim.missiles.is_empty(), "{kind:?} at {dx}: still flying"); + assert_eq!(sim.tally.blocked, 0, "{kind:?} at {dx}: the interceptor hit the ground"); + } + } +} + +// An interceptor with no way over the land blows up in the air, and that is counted. +#[test] +fn an_interceptor_without_a_way_over_blows_up_high() { + let wall = ridge(300, 40); + let mut sim = Sim::new(4); + sim.damage = false; + let mut ground = Ground::default(); + let mut out = Out::default(); + for _ in 0..4 { + sim.tick += 1; + answer_land(&mut ground, &wall, sim.tick); + } + sim.add_drone(DroneKind::Gerbera, DVec3::new(420.0, 90.0, 0.0), DVec3::X, DVec3::new(5000.0, 64.0, 0.0)); + let (target, id) = (sim.drones[0].id, sim.id()); + sim.drones[0].engaged = 1; + sim.missiles.push(Missile { id, kind: MissileKind::M57e6, pos: DVec3::new(230.0, 70.0, 0.0), vel: DVec3::X * 3.0, target, sam: 0, age: 20 }); + for _ in 0..200 { + if sim.missiles.is_empty() { + break; + } + sim.tick += 1; + sim.step_missiles(&mut ground, &mut out); + answer_land(&mut ground, &wall, sim.tick); + } + assert!(sim.missiles.is_empty()); + assert_eq!((sim.tally.scrubbed, sim.tally.blocked), (1, 0), "{:?}", sim.tally); + assert_eq!(sim.drones[0].engaged, 0); + let at = sim.blasts.first().expect("no blast").at; + assert!(at.y > (wall.0)(at.x as i32, 0) as f64 + 3.0, "it burst in the ground at {at:?}"); +} diff --git a/airdef-mod/src/sim/terrain.rs b/airdef-mod/src/sim/terrain.rs new file mode 100644 index 0000000..1827e1e --- /dev/null +++ b/airdef-mod/src/sim/terrain.rs @@ -0,0 +1,344 @@ +//! Flights and the land. Strike drones follow the terrain with a look-ahead worth several seconds of flight +//! (what they can still climb is a question of their speed and turn rate), dive only down a clear line, and +//! interceptors keep a safety height over the land ahead, loft over a ridge when the line is blocked, and +//! blow up high when there is no way over. The launch predicates (`why`) ask the same questions of a +//! shot before it is fired, so a system doesn't spend missiles on targets behind a hill. +//! +//! The land comes from `Ground::land`: heights sampled on a coarse lattice, so many flights share columns and +//! the questions to the game stay few; what the heights miss (trees, buildings, overhangs) is caught by a +//! probe through the block cache. +use super::*; + +/// Land heights are sampled at the centres of cells this wide. +const LATTICE: i32 = 8; +/// Land questions waiting at once: flights ask for more than a tick can send, and wait. +const ASK_MAX: usize = 192; + +/// What the game has said of the land at a place. +#[derive(Clone, Copy, PartialEq, Debug)] +pub(super) enum Rise { + /// Not yet: asked. + Pending, + /// There is no land to tell of (no chunk). + Void, + /// One above the highest solid block. + At(f64), +} + +impl Ground { + /// One above the highest solid block at the lattice cell holding `p`, if known; asked for when it isn't. + /// Unlike `at` it never asks again for a column it has, and it leaves room for the questions that matter more. + pub fn land(&mut self, p: DVec3) -> Option { + match self.rise(p) { + Rise::At(g) => Some(g), + _ => None, + } + } + + /// The same, saying whether the game has not answered yet or has no land there to tell of. + pub(super) fn rise(&mut self, p: DVec3) -> Rise { + let key = ( + (p.x.floor() as i32).div_euclid(LATTICE) * LATTICE + LATTICE / 2, + (p.z.floor() as i32).div_euclid(LATTICE) * LATTICE + LATTICE / 2, + ); + match self.known.get(&key) { + Some(&(y, _)) if y > -64 => Rise::At(f64::from(y)), + Some(_) => Rise::Void, + None => { + if self.wanted.len() < ASK_MAX && self.asked.insert(key) { + self.wanted.push(key); + } + Rise::Pending + } + } + } + + /// The land's height at a column for the raid planner: `None` while the game hasn't answered, `Some(None)` + /// where it has no land to tell of. Asks, up to `cap` questions waiting. + pub(super) fn column(&mut self, x: i32, z: i32, cap: usize) -> Option> { + match self.known.get(&(x, z)) { + Some(&(y, _)) => Some((y > -64).then_some(f64::from(y))), + None => { + if self.wanted.len() < cap && self.asked.insert((x, z)) { + self.wanted.push((x, z)); + } + None + } + } + } +} + +/// What a flight can do about a rise ahead. +#[derive(Clone, Copy, Debug)] +pub(super) struct Climb { + /// Radius of its tightest pull-up (blocks). + pub radius: f64, + /// The steepest climb it holds (radians). + pub pitch: f64, +} + +impl Climb { + /// How much height it can have gained by the time it has gone `s` blocks on, starting level. + pub fn gain(&self, s: f64) -> f64 { + let arc = self.radius * self.pitch.sin(); + if s <= arc { + self.radius - (self.radius * self.radius - s * s).max(0.0).sqrt() + } else { + self.radius * (1.0 - self.pitch.cos()) + (s - arc) * self.pitch.tan() + } + } +} + +impl DroneKind { + /// The steepest climb it holds in cruise (radians): cruise missiles are the shallowest. + pub(super) fn climb_pitch(self) -> f64 { + match self { + DroneKind::Kh101 | DroneKind::Kalibr => 0.45, + DroneKind::Lancet => 0.7, + DroneKind::Geran3 | DroneKind::Kh22 => 0.6, + _ => 0.5, + } + } + pub(super) fn climb(self) -> Climb { + let s = self.spec(); + Climb { radius: (s.speed / s.turn.max(0.01) * 1.15).max(12.0), pitch: self.climb_pitch() } + } + /// Blocks it looks ahead: eight seconds of flight, at least a pull-up and a climb over a hill. + pub(super) fn look_len(self) -> f64 { + let s = self.spec(); + (s.speed * 160.0).clamp(160.0, 900.0) + } +} + +/// A flight's new heading: it turns in yaw towards `heading` and in pitch towards a climb of `slope`, together +/// at most `rate` radians a tick, so a turn on to a new course doesn't dip or climb by itself (a great-circle +/// turn between nearly opposite headings can go either way). With `climb` set the pitch comes first and the +/// yaw gets what is left of the rate. +pub(super) fn fly_towards(old: DVec3, heading: DVec3, slope: f64, rate: f64, climb: bool) -> DVec3 { + let (old_yaw, old_pitch) = (old.x.atan2(old.z), old.y.clamp(-1.0, 1.0).asin()); + let want_yaw = heading.x.atan2(heading.z); + let mut yaw = angle_diff(old_yaw, want_yaw); + let mut pitch = slope.atan() - old_pitch; + if climb { + pitch = pitch.clamp(-rate, rate); + yaw = yaw.clamp(-(rate * rate - pitch * pitch).max(0.0).sqrt(), (rate * rate - pitch * pitch).max(0.0).sqrt()); + } else { + let total = yaw.hypot(pitch); + if total > rate { + yaw *= rate / total; + pitch *= rate / total; + } + } + let (yaw, pitch) = (old_yaw + yaw, old_pitch + pitch); + DVec3::new(yaw.sin() * pitch.cos(), pitch.sin(), yaw.cos() * pitch.cos()) +} + +/// How far over the land a dive's line passes. +pub(super) const DIVE_MARGIN: f64 = 8.0; + +/// `v` with its pitch held between `lo` and `hi` (radians); `level` is the way it faces if it points straight up or down. +pub(super) fn clamp_pitch(v: DVec3, lo: f64, hi: f64, level: DVec3) -> DVec3 { + let pitch = v.y.clamp(-1.0, 1.0).asin(); + if (lo..=hi).contains(&pitch) { + return v; + } + let pitch = pitch.clamp(lo, hi); + horizontal(v).normalize_or(level) * pitch.cos() + DVec3::Y * pitch.sin() +} + +/// Distances ahead where the land is sampled. +const REACH: [f64; 21] = [ + 0.0, 8.0, 16.0, 24.0, 32.0, 44.0, 56.0, 72.0, 90.0, 112.0, 136.0, 164.0, 196.0, 232.0, 272.0, 316.0, 366.0, 424.0, 500.0, 600.0, + 720.0, +]; + +/// The height a flight must be at *now* to clear the land along each of `lines` (a unit horizontal direction and +/// how far out to look) by `clear`, given what it can climb: the greatest over the samples of the land's height plus clearance +/// less the height it can gain on the way. `None` when nothing is known. +pub(super) fn required_height(ground: &mut Ground, pos: DVec3, lines: &[(DVec3, f64)], clear: f64, climb: &Climb) -> Option { + let mut need: Option = None; + for &(dir, len) in lines { + for &s in REACH.iter().chain(&[900.0]) { + if s > len { + break; + } + if let Some(g) = ground.land(pos + dir * s) { + let h = g + clear - climb.gain(s); + need = Some(need.map_or(h, |n: f64| n.max(h))); + } + } + } + need +} + +/// The least height to start from, over here, so that the straight line down to `target` stays `margin` over the +/// land (the last stretch before the aim is the target's own business). `None` when the land isn't known. +pub(super) fn dive_height(ground: &mut Ground, pos: DVec3, target: DVec3, margin: f64) -> Option { + let flat = horizontal(target - pos); + let total = flat.length(); + if total < 20.0 { + return None; + } + let dir = flat / total; + let mut need: Option = None; + let n = ((total - 14.0) / 12.0).clamp(1.0, 40.0) as i32; + for k in 0..=n { + let s = (total - 14.0) * f64::from(k) / f64::from(n); + if let Some(g) = ground.land(pos + dir * s) { + let f = s / total; + let y = (g + margin - target.y * f) / (1.0 - f); + need = Some(need.map_or(y, |m: f64| m.max(y))); + } + } + need +} + +/// The slope (rise a block) a flight at `from` must hold to clear the land by `margin` on its way towards +/// `to`, with a pull-up of `radius` for the climb to begin early, ignoring the last `skip` blocks before `to`. +/// Returns the slope and how far away the land that asks for it is; `None` when nothing is in the way or known. +pub(super) fn rope(ground: &mut Ground, from: DVec3, to: DVec3, margin: f64, radius: f64, (near, reach, skip): (f64, f64, f64), pending: &mut bool) -> Option<(f64, f64)> { + let flat = horizontal(to - from); + let total = flat.length(); + if total < 1.0 { + return None; + } + let dir = flat / total; + let end = (total - skip).min(reach); + let mut best: Option<(f64, f64)> = None; + let mut s = near; + while s <= end { + match ground.rise(from + dir * s) { + Rise::At(g) => { + let slope = (g + margin - from.y) / (s - radius * 0.6).max(6.0); + if best.is_none_or(|b| slope > b.0) { + best = Some((slope, s)); + } + } + Rise::Pending => *pending = true, + Rise::Void => {} + } + s += if s < 64.0 { 8.0 } else { 16.0 }; + } + best +} + +// ---------------------------------------------------------------- interceptors + +impl MissileKind { + /// The steepest climb it flies to get over land (radians): long-range missiles loft, short-range ones don't. + pub(super) fn loft(self) -> f64 { + match self { + MissileKind::M77n6 => 1.25, + MissileKind::M48n6 => 1.1, + MissileKind::Pac3 | MissileKind::IrisT | MissileKind::Amraam => 0.9, + MissileKind::M9m317 => 0.8, + MissileKind::P1sun => 0.5, + MissileKind::M57e6 | MissileKind::M9m338 => 0.3, + } + } + /// Whether it lofts over a ridge to a target behind it. + pub(super) fn lofts(self) -> bool { + self.loft() >= 0.5 + } +} + +/// How high above the land an interceptor keeps. +pub(super) const MISSILE_MARGIN: f64 = 8.0; + +/// What keeps an interceptor from being launched at a target for the land in the way. +pub(super) const MASKED: u8 = 1; +pub(super) const PATH_BLOCKED: u8 = 2; +/// The land under the line of fire has been asked for and not answered: the shot waits a tick or two. +pub(super) const WAIT: u8 = 4; +/// Ticks a shot waits for the land to be answered before it is judged on what is known. +pub(super) const WAIT_TICKS: u64 = 30; + +/// Whether a shot from `muzzle` (launcher at `antenna`) at a target forecast at `est` moving at `vel` may be +/// fired for the land: the intercept point must not be behind a hill from the launcher, unless the missile +/// lofts, and the path the missile would fly must be clear. +pub(super) fn launch_blocks(ground: &mut Ground, kind: MissileKind, muzzle: DVec3, antenna: DVec3, est: DVec3, vel: DVec3) -> u8 { + let spec = kind.spec(); + // Where it meets the target: the forecast carried on for the flight time. + let mut at = est; + for _ in 0..2 { + let tgo = muzzle.distance(at) / (spec.speed * 0.8); + at = est + vel * tgo; + } + let flat = horizontal(at - antenna); + let total = flat.length(); + if total < 40.0 { + return 0; + } + let dir = flat / total; + // The line of sight from the launcher: any rise of the land over the line to the intercept point masks it. + let mut masked = false; + let mut waiting = false; + let mut s = 12.0; + while s < total - 12.0 { + match ground.rise(antenna + dir * s) { + Rise::At(g) if g + 1.0 > antenna.y + (at.y - antenna.y) * s / total => { + masked = true; + break; + } + Rise::Pending => waiting = true, + _ => {} + } + s += 16.0; + } + if !masked && waiting { + return WAIT; + } + if !masked { + return 0; + } + if !kind.lofts() { + return MASKED; + } + // A lofted path: climbs at the slope the highest land in the way asks for, and comes down on the target. + let radius = spec.speed / spec.turn.max(0.05); + let mut pending = false; + let path = rope(ground, muzzle, at, MISSILE_MARGIN, radius, (32.0, total, 24.0), &mut pending); + match path { + Some((slope, _)) if slope > kind.loft().tan() => PATH_BLOCKED, + _ if pending => WAIT, + _ => 0, + } +} + +impl Sam { + /// What the land said of a shot at `target` when it was last judged, if lately. + pub(super) fn terrain_verdict(&self, target: u32, now: u64) -> Option { + self.terrain.get(&target).filter(|(when, _)| now.saturating_sub(*when) <= VERDICT_AGE).map(|(_, bits)| *bits) + } +} + +/// Ticks a launch verdict (the land in the way) is kept before the shot is judged again. +pub(super) const VERDICT_AGE: u64 = 20; + +/// Which way an interceptor steers to keep clear of the land: `aim` is where it would go; the result is a direction +/// (unit) raised as far as its loft allows, and whether the land is in the way beyond that. +pub(super) fn steer(ground: &mut Ground, kind: MissileKind, pos: DVec3, speed: f64, aim: DVec3) -> (DVec3, bool) { + let spec = kind.spec(); + let want = (aim - pos).normalize_or(DVec3::Y); + let flat = horizontal(want); + let flat_len = flat.length(); + let reach = (speed * 18.0).clamp(60.0, 320.0); + let radius = speed / spec.turn.max(0.05); + let Some((slope, at)) = rope(ground, pos, aim, MISSILE_MARGIN, radius, (8.0, reach, 20.0), &mut false) else { return (want, false) }; + let wanted = if flat_len > 1e-6 { want.y / flat_len } else { f64::INFINITY }; + if slope <= wanted { + return (want, false); + } + let cap = kind.loft().tan(); + let dir = flat.normalize_or(DVec3::X); + let raised = (dir + DVec3::Y * slope.min(cap)).normalize(); + // No way over, and the land close enough ahead that the next ticks will end in it: blow up before. + let mut fail = false; + if slope > cap + 0.02 && at < (speed * 6.0).max(40.0) { + fail = (1..=4).any(|k| { + let p = pos + raised * speed * f64::from(k); + ground.land(p).is_some_and(|g| g + 2.0 > p.y) + }); + } + (raised, fail) +} diff --git a/airdef-mod/src/sim/why.rs b/airdef-mod/src/sim/why.rs index 6f44a2b..1cf1987 100644 --- a/airdef-mod/src/sim/why.rs +++ b/airdef-mod/src/sim/why.rs @@ -308,7 +308,19 @@ impl Sim { let aligned = missile_aligned(spec.family, sam.yaw, mount, Some(est), t.id, t.id, err); let turn = if vertical { "вертикальный старт" } else { "доворот" }; v.push(check(aligned, format!("{turn}: ошибка наведения {:.0}°{}", err.to_degrees(), if aligned { "" } else { " — ещё доворачивается" }))); - fires |= reach == 0 && free < need && mb == 0 && aligned && blocks == 0; + // The land: the verdict the launch decision gave this target (`terrain::launch_blocks`) a moment ago. + let land = sam.terrain_verdict(t.id, now).unwrap_or(0); + if land != 0 { + v.push(check( + false, + if land & terrain::MASKED != 0 { + "рельеф: цель за рельефом — ракета не стартует, ждём, пока цель выйдет из-за него" + } else { + "рельеф: траектория перекрыта рельефом — подняться над ним ракета не может" + }, + )); + } + fires |= reach == 0 && free < need && mb == 0 && aligned && blocks == 0 && land == 0; } if let Some(gun) = &spec.gun { let on_it = guns_on.get(&t.id).copied().unwrap_or(0) - u32::from(sam.gun_target == Some(t.id)); diff --git a/airdef-mod/src/sim/world.rs b/airdef-mod/src/sim/world.rs index ec4c5d9..2f69728 100644 --- a/airdef-mod/src/sim/world.rs +++ b/airdef-mod/src/sim/world.rs @@ -94,6 +94,8 @@ pub struct WorldCache { /// Sections asked, and when. asked: HashMap, epoch: u32, + /// Sweeps made as a look ahead (flight planning) ask for sections after the urgent ones. + quiet: bool, pub stats: WorldStats, } @@ -277,7 +279,7 @@ impl WorldCache { let s = sec_of(x, y, z); if *last != Some(s) { *last = Some(s); - self.touch(s, true, now); + self.touch(s, !self.quiet, now); } match &self.secs.get(&s)?.fill { Fill::Unknown => None, @@ -301,6 +303,15 @@ impl WorldCache { self.solid(&mut None, x, y, z, now) == Some(true) } + /// A sweep made to plan a flight, not to move one: the sections it needs are asked after the urgent ones, + /// and a start inside a block is not a hit. + pub fn probe(&mut self, a: DVec3, b: DVec3, now: u64) -> Sweep { + self.quiet = true; + let r = self.sweep(a, b, true, now); + self.quiet = false; + r + } + /// The first solid block along `a` to `b`, by walking the blocks it /// crosses; `skip_start` ignores the block it starts in (a launch from a /// pad that is part of the land).