Terrain-aware flight: drones look 8 s ahead and climb in time, the planner prices terrain, SAMs hold fire at targets masked by terrain (long-range ones loft over ridges), interceptors keep clear of the land or self-destruct in the air
build / mod (push) Successful in 3m24s

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-29 23:39:44 +03:00
parent 9075717872
commit 6f3fc644bc
9 changed files with 910 additions and 43 deletions
+5
View File
@@ -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.
+12 -4
View File
@@ -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 {
+159 -29
View File
@@ -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<u32, (u64, u8)>,
/// Targets whose land hasn't been answered yet, and since when it has been waited for.
waits: HashMap<u32, u64>,
}
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<DVec3>, 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<u32>, 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<u32, f64> = 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) {
+108 -6
View File
@@ -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<f32>,
unanswered: Vec<u32>,
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<DVec3>, 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<DVec3>, 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<f32>, 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<f32>, 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;
+4
View File
@@ -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)
+253 -2
View File
@@ -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<F: Fn(i32, i32) -> i32>(F);
impl<F: Fn(i32, i32) -> i32> world::WorldSource for Land<F> {
fn solid(&self, x: i32, y: i32, z: i32) -> Option<bool> {
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<F: Fn(i32, i32) -> i32>(ground: &mut Ground, land: &Land<F>, 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<impl Fn(i32, i32) -> i32> {
ridge_at(300, rise, half)
}
fn ridge_at(crest: i32, rise: i32, half: i32) -> Land<impl Fn(i32, i32) -> 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<F: Fn(i32, i32) -> i32>(kind: DroneKind, land: &Land<F>, 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<impl Fn(i32, i32) -> 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<Box<dyn Fn(i32, i32) -> 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::<Vec<_>>());
}
// 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:?}");
}
+344
View File
@@ -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<f64> {
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<Option<f64>> {
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<f64> {
let mut need: Option<f64> = 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<f64> {
let flat = horizontal(target - pos);
let total = flat.length();
if total < 20.0 {
return None;
}
let dir = flat / total;
let mut need: Option<f64> = 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<u8> {
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)
}
+13 -1
View File
@@ -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));
+12 -1
View File
@@ -94,6 +94,8 @@ pub struct WorldCache {
/// Sections asked, and when.
asked: HashMap<Sec, u64>,
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).