Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 557346548a |
@@ -60,6 +60,7 @@ pub(super) struct Result {
|
||||
pub crashed: Vec<(DroneKind, DVec3, f64)>,
|
||||
/// Chunk tickets the sim asked for.
|
||||
pub tickets: usize,
|
||||
pub scrubs: Vec<(MissileKind, u32, f64, f64, DVec3, f64, f64)>,
|
||||
}
|
||||
|
||||
pub(super) struct Setup {
|
||||
@@ -82,17 +83,21 @@ pub(super) struct Setup {
|
||||
pub dist: f64,
|
||||
/// Systems and objects sunk this far into the land (a player who stood inside it).
|
||||
pub bury: f64,
|
||||
/// How steep the hills are (1 is the usual), and whether a forest's canopy stands on them (the game's height map shows the leaves).
|
||||
pub steep: f64,
|
||||
pub trees: bool,
|
||||
}
|
||||
|
||||
/// Rolling hills: ridges of 20-35 blocks over a plain at 64, wavelengths of 90-250 blocks.
|
||||
pub(super) fn hills(x: i32, z: i32) -> i32 {
|
||||
let (x, z) = (f64::from(x), f64::from(z));
|
||||
let n = (x / 53.0 + (z / 91.0).sin() * 1.7).sin() * 0.5 + (z / 41.0 - (x / 77.0).cos()).sin() * 0.35 + ((x + z) / 130.0).sin() * 0.4 + ((x - 2.0 * z) / 29.0).sin() * 0.12;
|
||||
64 + (n * 28.0 + 14.0).max(-4.0) as i32
|
||||
hills_k(x, z, 1.0)
|
||||
}
|
||||
|
||||
fn ground_at(hilly: bool, x: f64, z: f64) -> f64 {
|
||||
if hilly { f64::from(hills(x as i32, z as i32)) } else { 64.0 }
|
||||
/// `hills` with its ridges `k` times as steep (higher and closer together).
|
||||
pub(super) fn hills_k(x: i32, z: i32, k: f64) -> i32 {
|
||||
let (x, z) = (f64::from(x) * k, f64::from(z) * k);
|
||||
let n = (x / 53.0 + (z / 91.0).sin() * 1.7).sin() * 0.5 + (z / 41.0 - (x / 77.0).cos()).sin() * 0.35 + ((x + z) / 130.0).sin() * 0.4 + ((x - 2.0 * z) / 29.0).sin() * 0.12;
|
||||
64 + (n * 28.0 * k + 14.0).max(-4.0) as i32
|
||||
}
|
||||
|
||||
/// Mountains: broad ridges over the hills, nothing within about 300 blocks of the origin.
|
||||
@@ -108,6 +113,8 @@ pub(super) struct Loader {
|
||||
pub hilly: bool,
|
||||
pub mountains: bool,
|
||||
pub disc: Option<f64>,
|
||||
steep: f64,
|
||||
trees: bool,
|
||||
asked: std::cell::RefCell<HashMap<(i32, i32), (u64, u64)>>,
|
||||
pub tickets: usize,
|
||||
}
|
||||
@@ -116,10 +123,19 @@ const TICKET_LIFE: u64 = 400;
|
||||
|
||||
impl Loader {
|
||||
pub fn new(s: &Setup) -> Self {
|
||||
Loader { hilly: s.hilly, mountains: s.mountains, disc: s.disc, asked: Default::default(), tickets: 0 }
|
||||
Loader { hilly: s.hilly, mountains: s.mountains, disc: s.disc, steep: s.steep, trees: s.trees, asked: Default::default(), tickets: 0 }
|
||||
}
|
||||
/// The land without the forest on it.
|
||||
pub fn bare(&self, x: i32, z: i32) -> f64 {
|
||||
f64::from(hills_k(x, z, self.steep) + if self.mountains { mountains(x, z) } else { 0 })
|
||||
}
|
||||
pub fn height(&self, x: i32, z: i32) -> i32 {
|
||||
if self.hilly { hills(x, z) + if self.mountains { mountains(x, z) } else { 0 } } else { 64 }
|
||||
if !self.hilly {
|
||||
return 64;
|
||||
}
|
||||
// A forest: a quarter of the 3x3 patches carry a canopy 8 blocks over the ground (the game's height map shows the leaves).
|
||||
let canopy = if self.trees && ((x.div_euclid(3).wrapping_mul(73856093) ^ z.div_euclid(3).wrapping_mul(19349663)) as u32 >> 3) % 4 == 0 { 8 } else { 0 };
|
||||
hills_k(x, z, self.steep) + canopy + if self.mountains { mountains(x, z) } else { 0 }
|
||||
}
|
||||
fn loaded(&self, c: (i32, i32), now: u64) -> Option<bool> {
|
||||
let Some(r) = self.disc else { return Some(true) };
|
||||
@@ -174,13 +190,14 @@ impl world::WorldSource for Seen<'_> {
|
||||
|
||||
impl Setup {
|
||||
pub fn new(systems: usize, smart: bool, seed: u64) -> Self {
|
||||
Setup { systems, smart, seed, size: 36, radars: (systems / 10).max(1), objects: 36, tweak: |_| {}, hilly: false, bury: 0.0, disc: None, mountains: false, dist: 500.0 }
|
||||
Setup { systems, smart, seed, size: 36, radars: (systems / 10).max(1), objects: 36, tweak: |_| {}, hilly: false, bury: 0.0, disc: None, mountains: false, dist: 500.0, steep: std::env::var("STEEP").ok().and_then(|v| v.parse().ok()).unwrap_or(1.0), trees: std::env::var("TREES").is_ok() }
|
||||
}
|
||||
}
|
||||
|
||||
/// The cluster: `objects` in a grid, systems of the mix scattered round them, radars among them.
|
||||
pub(super) fn cluster(sim: &mut Sim, s: &Setup) {
|
||||
let y = |x: f64, z: f64| if s.hilly { ground_at(true, x, z) - s.bury } else { 70.0 };
|
||||
let l = Loader::new(s);
|
||||
let y = |x: f64, z: f64| if s.hilly { l.bare(x as i32, z as i32) - s.bury } else { 70.0 };
|
||||
let mut rng = Rng::new(s.seed.wrapping_mul(977) + 5);
|
||||
let side = (s.objects as f64).sqrt().ceil() as usize;
|
||||
for k in 0..s.objects {
|
||||
@@ -294,6 +311,7 @@ pub(super) fn fly(s: &Setup) -> Result {
|
||||
}
|
||||
}
|
||||
result.tickets = land.tickets;
|
||||
result.scrubs = sim.scrubs.clone();
|
||||
result.crashed = sim.crashed.iter().map(|c| (c.0, c.1, c.1.distance(c.2))).collect();
|
||||
result.ticks = sim.tick - start;
|
||||
result.launched = sim.launched;
|
||||
@@ -445,6 +463,26 @@ fn far_raids_over_mountains_do_not_crash_into_them() {
|
||||
assert!(crashed * 40 <= flown, "{crashed} of {flown} flew into the land");
|
||||
}
|
||||
|
||||
/// Steep forested hills: an interceptor that is fired is one the land lets through. The launch check asks the same
|
||||
/// of a shot as the flight does (the margin over the land, the loft), so few blow up short of a ridge.
|
||||
#[test]
|
||||
fn on_steep_forested_hills_few_interceptors_are_scrubbed() {
|
||||
let (mut fired, mut scrubbed) = (0, 0);
|
||||
for seed in 1..=3u64 {
|
||||
let mut s = Setup::new(15, false, seed);
|
||||
s.radars = 2;
|
||||
s.objects = 4;
|
||||
s.hilly = true;
|
||||
s.steep = 1.6;
|
||||
s.trees = true;
|
||||
let r = fly(&s);
|
||||
fired += r.missiles;
|
||||
scrubbed += r.scrubbed;
|
||||
}
|
||||
eprintln!("scrubbed {scrubbed} of {fired}");
|
||||
assert!(scrubbed * 100 <= fired * 10, "{scrubbed} of {fired} interceptors blew up short of a ridge");
|
||||
}
|
||||
|
||||
/// What each of the new rules adds, one at a time (the raid of 36 and a heavier one of 80).
|
||||
#[test]
|
||||
#[ignore]
|
||||
@@ -485,7 +523,7 @@ fn far_table() {
|
||||
let dist: f64 = std::env::var("DIST").ok().and_then(|v| v.parse().ok()).unwrap_or(2500.0);
|
||||
let mut by_kind: HashMap<DroneKind, (u32, u32)> = HashMap::new();
|
||||
let (mut launched, mut kills, mut leaked, mut tickets) = (0, 0, 0, 0);
|
||||
for seed in 1..=8u64 {
|
||||
for seed in std::env::var("SEED").ok().and_then(|v| v.parse::<u64>().ok()).map_or(1..=8u64, |v| v..=v) {
|
||||
let mut s = Setup::new(50, std::env::var("SMART").is_ok(), seed);
|
||||
s.hilly = true;
|
||||
s.mountains = true;
|
||||
@@ -506,5 +544,36 @@ fn far_table() {
|
||||
}
|
||||
let mut v: Vec<_> = by_kind.into_iter().collect();
|
||||
v.sort_by_key(|k| k.0 as u8);
|
||||
eprintln!("disc {disc} dist {dist}: launched {launched} kills {kills} leaked {leaked} chunk tickets {tickets}; crashed by kind {:?}", v.iter().map(|k| (k.0, k.1.0)).collect::<Vec<_>>());
|
||||
eprintln!("disc {disc} dist {dist}: launched {launched} kills {kills} leaked {leaked} chunk tickets {tickets}; crashed by kind {:?}", v.iter().map(|k| (k.0.clone(), k.1.0)).collect::<Vec<_>>());
|
||||
}
|
||||
|
||||
/// Interceptors that blew up short of a ridge (`STEEP=1.6`, `SAMS=15`, `HILLS=0` for flat): by kind, and what the target was doing.
|
||||
#[test]
|
||||
#[ignore]
|
||||
fn scrub_table() {
|
||||
let n: usize = std::env::var("SAMS").ok().and_then(|v| v.parse().ok()).unwrap_or(15);
|
||||
let hilly = std::env::var("HILLS").map_or(true, |v| v != "0");
|
||||
let (mut fired, mut scrubbed, mut kills, mut launched, mut leaked) = (0, 0, 0, 0, 0);
|
||||
let mut by: HashMap<String, (u32, u32)> = HashMap::new();
|
||||
let (mut near, mut low) = (0, 0);
|
||||
for seed in 1..=8u64 {
|
||||
let mut s = Setup::new(n, false, seed);
|
||||
s.radars = 2;
|
||||
s.objects = 4;
|
||||
s.hilly = hilly;
|
||||
let r = fly(&s);
|
||||
fired += r.missiles;
|
||||
scrubbed += r.scrubbed;
|
||||
kills += r.kills;
|
||||
launched += r.launched;
|
||||
leaked += r.leaked;
|
||||
for c in &r.scrubs {
|
||||
by.entry(format!("{:?}", c.0)).or_default().0 += 1;
|
||||
near += u32::from(c.2 < 150.0);
|
||||
low += u32::from(c.3 < 30.0);
|
||||
}
|
||||
}
|
||||
let mut v: Vec<_> = by.into_iter().collect();
|
||||
v.sort();
|
||||
eprintln!("sams {n} hilly {hilly}: launched {launched} kills {kills} leaked {leaked} missiles {fired} scrubbed {scrubbed} (target within 150: {near}, target under 30 over land: {low}) by kind {:?}", v.iter().map(|k| (k.0.clone(), k.1.0)).collect::<Vec<_>>());
|
||||
}
|
||||
|
||||
@@ -1515,6 +1515,9 @@ pub struct Sim {
|
||||
/// What crashed, where, and its aim (balance measurements only).
|
||||
#[cfg(test)]
|
||||
pub crashed: Vec<(DroneKind, DVec3, DVec3)>,
|
||||
/// Interceptors that blew up short of a ridge: kind, age, distance to the target, the target's height over the land under it (balance measurements only).
|
||||
#[cfg(test)]
|
||||
pub scrubs: Vec<(MissileKind, u32, f64, f64, DVec3, f64, f64)>,
|
||||
pub launched: u32,
|
||||
/// Explosions damage the land (primed TNT through commands).
|
||||
pub damage: bool,
|
||||
@@ -1604,6 +1607,8 @@ impl Sim {
|
||||
settling: Vec::new(),
|
||||
#[cfg(test)]
|
||||
crashed: Vec::new(),
|
||||
#[cfg(test)]
|
||||
scrubs: Vec::new(),
|
||||
launched: 0,
|
||||
damage: true,
|
||||
seed: seed.wrapping_mul(0x2545_F491_4F6C_DD1D) | 1,
|
||||
@@ -2022,7 +2027,11 @@ impl Sim {
|
||||
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 mut height = base.max(safe).max(d.nav.dive + 1.0);
|
||||
// Blind: it does not come down over land nobody has told of; it holds the height it has.
|
||||
if d.nav.blind && d.low && to_target > 300.0 {
|
||||
height = height.max(d.pos.y);
|
||||
}
|
||||
let full = climb.pitch.tan();
|
||||
// Blind (land ahead unknown): to the transit height at once, not at the gentle slope of a descent plan.
|
||||
let urgent = (need.is_finite() && d.pos.y < need) || (d.nav.blind && d.pos.y < base - 2.0);
|
||||
@@ -2771,6 +2780,8 @@ impl Sim {
|
||||
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, mut scrubbed) = (0u32, 0u32);
|
||||
#[cfg(test)]
|
||||
let mut scrub_log = Vec::new();
|
||||
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();
|
||||
// Where each launcher stands and how far its missiles reach: a hit past that is less likely (`why::reach_fit`).
|
||||
let launchers: HashMap<u32, (DVec3, f64)> = self.sams.iter().filter_map(|s| Some((s.id, (s.pos, s.kind.spec().missile.as_ref()?.max)))).collect();
|
||||
@@ -2831,9 +2842,19 @@ impl Sim {
|
||||
let closing = (speed - vel.dot(los)).max(1.0);
|
||||
let tgo = at.distance(m.pos) / closing;
|
||||
// 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, at + vel * tgo);
|
||||
let (want, fail) = terrain::steer(ground, m.kind, m.pos, speed, at + vel * tgo, now);
|
||||
if fail {
|
||||
scrubbed += 1;
|
||||
#[cfg(test)]
|
||||
{
|
||||
let over = target.and_then(|t| ground.at(t.pos, now).map(|g| t.pos.y - g)).unwrap_or(f64::NAN);
|
||||
let dist = target.map_or(f64::NAN, |t| t.pos.distance(m.pos));
|
||||
let mover = ground.at(m.pos, now).map_or(f64::NAN, |g| m.pos.y - g);
|
||||
let from = launchers.get(&m.sam).map_or(0.0, |l| m.pos.distance(l.0));
|
||||
if scrub_log.len() < 30 {
|
||||
scrub_log.push((m.kind, m.age, dist, over, m.pos, mover, from));
|
||||
}
|
||||
}
|
||||
done.push((m.id, target.map(|t| t.id), false, m.sam));
|
||||
continue;
|
||||
}
|
||||
@@ -2923,6 +2944,8 @@ impl Sim {
|
||||
}
|
||||
}
|
||||
}
|
||||
#[cfg(test)]
|
||||
self.scrubs.extend(scrub_log);
|
||||
self.note(|t| {
|
||||
t.missed += missed;
|
||||
t.expired += expired;
|
||||
|
||||
@@ -1669,3 +1669,45 @@ fn over_unknown_land_a_low_flight_holds_its_transit_height() {
|
||||
assert!(blind > aim.y + spec.cruise - 8.0, "blind and low: {blind}");
|
||||
assert!(seen < aim.y + 20.0, "over land it knows it keeps hugging it: {seen}");
|
||||
}
|
||||
|
||||
/// The height map is a coarse upper bound (a forest's canopy shows as land): an interceptor under it, with the blocks
|
||||
/// around it open, is not blown up for "no way over"; it is blown up only before blocks that are really there.
|
||||
#[test]
|
||||
fn an_interceptor_under_the_canopy_is_not_scrubbed_before_open_air() {
|
||||
let blocks = sites::testland::Land(|_, _| 64);
|
||||
let steer = |solid_to: i32| {
|
||||
let mut ground = Ground::default();
|
||||
let land = sites::testland::Land(move |_, _| solid_to);
|
||||
let kind = MissileKind::M57e6;
|
||||
let pos = DVec3::new(0.0, 66.0, 0.0);
|
||||
let mut fail = false;
|
||||
for t in 0..30 {
|
||||
// The heights say 76 (the canopy); the blocks are solid only up to `solid_to`.
|
||||
let cols = ground.take_wanted().into_iter().map(|(x, z)| (x, z, 76)).collect();
|
||||
ground.answer(cols, t);
|
||||
let secs = ground.world.take_wanted(t);
|
||||
ground.world.serve(&land, secs, t);
|
||||
fail = terrain::steer(&mut ground, kind, pos, kind.spec().speed, DVec3::new(0.0, 70.0, 200.0), t).1;
|
||||
}
|
||||
fail
|
||||
};
|
||||
let _ = blocks;
|
||||
assert!(!steer(64), "open air under the canopy");
|
||||
assert!(steer(80), "real blocks ahead");
|
||||
}
|
||||
|
||||
/// A flight that is high over land nobody has told of does not come down towards the height of its aim.
|
||||
#[test]
|
||||
fn over_unknown_land_a_flight_does_not_descend() {
|
||||
let aim = DVec3::new(0.0, 64.0, 3000.0);
|
||||
let mut sim = Sim::new(4);
|
||||
let mut d = sim.make_drone(DroneKind::Kh101, DVec3::new(0.0, 250.0, 0.0), DVec3::Z, aim);
|
||||
d.low = true;
|
||||
d.route = vec![aim];
|
||||
sim.drones.push(d);
|
||||
let (mut ground, mut out) = (Ground::default(), Out::default());
|
||||
for _ in 0..150 {
|
||||
sim.step(&mut ground, None, &mut out);
|
||||
}
|
||||
assert!(sim.drones[0].pos.y > 245.0, "it came down over unknown land: {}", sim.drones[0].pos.y);
|
||||
}
|
||||
|
||||
@@ -354,7 +354,7 @@ 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) {
|
||||
pub(super) fn steer(ground: &mut Ground, kind: MissileKind, pos: DVec3, speed: f64, aim: DVec3, now: u64) -> (DVec3, bool) {
|
||||
let spec = kind.spec();
|
||||
let want = (aim - pos).normalize_or(DVec3::Y);
|
||||
let flat = horizontal(want);
|
||||
@@ -372,10 +372,12 @@ pub(super) fn steer(ground: &mut Ground, kind: MissileKind, pos: DVec3, speed: f
|
||||
// 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) {
|
||||
// The heights are a coarse upper bound (the canopy of a forest, the roof of a building): it blows up only before
|
||||
// blocks that are really there, not before what the height map says is higher than the missile.
|
||||
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)
|
||||
});
|
||||
}) && matches!(ground.world.probe(pos, pos + raised * speed * 4.0, now), world::Sweep::Hit { .. });
|
||||
}
|
||||
(raised, fail)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,383 @@
|
||||
//! 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.
|
||||
pub(super) 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.
|
||||
/// Like `at` it asks again for a column that is old or was reported changed (#64), 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,
|
||||
}
|
||||
}
|
||||
|
||||
/// For a planner: None while the game has not answered, Some(None) where it has no land to
|
||||
/// tell of, else Some(Some(height)).
|
||||
pub fn land_state(&mut self, p: DVec3) -> Option<Option<f64>> {
|
||||
match self.rise(p) {
|
||||
Rise::Pending => None,
|
||||
Rise::Void => Some(None),
|
||||
Rise::At(g) => Some(Some(g)),
|
||||
}
|
||||
}
|
||||
|
||||
/// 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, when)) => {
|
||||
// Old or reported changed: still used, asked again with the questions to spare.
|
||||
if self.due(when) && self.wanted.len() < ASK_MAX / 2 && self.asked.insert(key) {
|
||||
self.wanted.push(key);
|
||||
}
|
||||
if y > -64 { Rise::At(f64::from(y)) } else { 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, when)) => {
|
||||
if self.due(when) && self.wanted.len() < cap / 2 && self.asked.insert((x, z)) {
|
||||
self.wanted.push((x, z));
|
||||
}
|
||||
Some((y > -64).then_some(f64::from(y)))
|
||||
}
|
||||
None => {
|
||||
// A chunk the game is loading is asked about again in a few ticks, not every one.
|
||||
let loading = self.loading.get(&(x, z)).is_some_and(|&t| self.now.saturating_sub(t) < LOAD_RECHECK);
|
||||
if !loading && 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()
|
||||
}
|
||||
|
||||
/// Chunks a tick the flights' look-ahead may ask the game to load (the rest of the budget is the launch-site search's).
|
||||
const CHUNK_BUDGET: usize = 46;
|
||||
|
||||
/// 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>, bool) {
|
||||
let mut need: Option<f64> = None;
|
||||
// Land nobody has told of within the first half of the look-ahead: the flight is blind there.
|
||||
let mut blind = false;
|
||||
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)));
|
||||
} else {
|
||||
if s <= len * 0.5 {
|
||||
blind = true;
|
||||
}
|
||||
// Land the game has not told of ahead: its chunk is asked for (it loads in a couple of seconds, a flight
|
||||
// is that far from it), so the pull-up starts from the first known column and not at the ridge.
|
||||
if ground.chunks_wanted() < CHUNK_BUDGET {
|
||||
let p = pos + dir * s;
|
||||
ground.want_chunk(((p.x.floor() as i32) >> 4, (p.z.floor() as i32) >> 4));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
(need, blind)
|
||||
}
|
||||
|
||||
/// 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, now: u64) -> (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) {
|
||||
// The heights are a coarse upper bound (the canopy of a forest, the roof of a building): it blows up only before
|
||||
// blocks that are really there, not before what the height map says is higher than the missile.
|
||||
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)
|
||||
}) && matches!(ground.world.probe(pos, pos + raised * speed * 4.0, now), world::Sweep::Hit { .. });
|
||||
}
|
||||
(raised, fail)
|
||||
}
|
||||
Reference in New Issue
Block a user