Flights over unknown land hold a safe height, air starts wait for the land, systems and aims settle on the surface; map labels, filters, coverage by terrain and scenes
build / mod (push) Successful in 8m34s
build / mod (push) Successful in 8m34s
Far raids over mountains with a 300-block known disc: crashes from 19 to 2 of 368 (mixed), 22 to about 9 (smart). Balance tests gain hilly land. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -2110,6 +2110,8 @@ impl Mod for AirDef {
|
||||
sam.roles = *r;
|
||||
}
|
||||
self.sim.sams.push(sam);
|
||||
// Standing in the ground (a chunk that had not loaded as the player fell): it comes up to the surface.
|
||||
self.sim.settle(sid);
|
||||
self.dirty = true;
|
||||
Some(format!("{} развёрнут", kind.name()))
|
||||
}
|
||||
|
||||
+1027
-182
File diff suppressed because it is too large
Load Diff
+227
-14
@@ -1,7 +1,6 @@
|
||||
//! Balance measurements: a whole raid on flat land, flown headlessly against a cluster of systems.
|
||||
//! The `#[ignore]`d tests print tables (`cargo test --release balance -- --ignored --nocapture`);
|
||||
//! the others assert the cheap invariants. The tables are what the air defence's tuning is judged by.
|
||||
use super::sites::testland::{Land, serve};
|
||||
use super::*;
|
||||
|
||||
/// The ten armed kinds in the order a cluster takes them (long-range heavy ones spread evenly).
|
||||
@@ -48,12 +47,19 @@ pub(super) struct Result {
|
||||
pub untracked: u32,
|
||||
pub hits: u32,
|
||||
pub crashes: u32,
|
||||
/// Interceptors lost against the land, and ones that blew up short of a ridge.
|
||||
pub blocked: u32,
|
||||
pub scrubbed: u32,
|
||||
pub unshot: u32,
|
||||
pub missed: u32,
|
||||
/// Missiles fired by the heavy systems (S-400, S-500, Patriot).
|
||||
pub heavy: u32,
|
||||
pub by_kind: Vec<(DroneKind, u32, u32)>,
|
||||
pub tally: String,
|
||||
/// Drones that vanished short of their aim without being shot down: kind, where, how far from the aim.
|
||||
pub crashed: Vec<(DroneKind, DVec3, f64)>,
|
||||
/// Chunk tickets the sim asked for.
|
||||
pub tickets: usize,
|
||||
}
|
||||
|
||||
pub(super) struct Setup {
|
||||
@@ -65,20 +71,121 @@ pub(super) struct Setup {
|
||||
pub objects: usize,
|
||||
/// Called on the sim before the raid (a test sets its switches).
|
||||
pub tweak: fn(&mut Sim),
|
||||
/// Hilly land instead of flat (`hills`).
|
||||
pub hilly: bool,
|
||||
/// Only a disc of this radius round the player (the origin) is loaded, as in the game; beyond it the land is not
|
||||
/// there (`Void`) unless a chunk ticket was asked for (loaded after a delay, for a while).
|
||||
pub disc: Option<f64>,
|
||||
/// Mountains on top of the hills (ridges of up to 150 blocks, 600-900 blocks apart).
|
||||
pub mountains: bool,
|
||||
/// How far out the raid launches (the order's distance).
|
||||
pub dist: f64,
|
||||
/// Systems and objects sunk this far into the land (a player who stood inside it).
|
||||
pub bury: f64,
|
||||
}
|
||||
|
||||
/// 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
|
||||
}
|
||||
|
||||
fn ground_at(hilly: bool, x: f64, z: f64) -> f64 {
|
||||
if hilly { f64::from(hills(x as i32, z as i32)) } else { 64.0 }
|
||||
}
|
||||
|
||||
/// Mountains: broad ridges over the hills, nothing within about 300 blocks of the origin.
|
||||
pub(super) fn mountains(x: i32, z: i32) -> i32 {
|
||||
let (x, z) = (f64::from(x), f64::from(z));
|
||||
let r = (x * x + z * z).sqrt();
|
||||
let ridge = ((x / 410.0 + 0.7).sin() * (z / 530.0 - 0.4).sin() + (x / 700.0 - z / 610.0).sin() * 0.6).max(0.0);
|
||||
(ridge * 150.0 * ((r - 300.0) / 400.0).clamp(0.0, 1.0)) as i32
|
||||
}
|
||||
|
||||
/// The game's view of the land: all of it, or a disc round the player and the chunks asked for.
|
||||
pub(super) struct Loader {
|
||||
pub hilly: bool,
|
||||
pub mountains: bool,
|
||||
pub disc: Option<f64>,
|
||||
asked: std::cell::RefCell<HashMap<(i32, i32), (u64, u64)>>,
|
||||
pub tickets: usize,
|
||||
}
|
||||
const TICKET_DELAY: u64 = 40;
|
||||
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 }
|
||||
}
|
||||
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 }
|
||||
}
|
||||
fn loaded(&self, c: (i32, i32), now: u64) -> Option<bool> {
|
||||
let Some(r) = self.disc else { return Some(true) };
|
||||
let (x, z) = (f64::from(c.0 * 16 + 8), f64::from(c.1 * 16 + 8));
|
||||
if (x * x + z * z).sqrt() <= r {
|
||||
return Some(true);
|
||||
}
|
||||
match self.asked.borrow().get(&c) {
|
||||
Some(&(since, until)) if now < until => Some(now >= since + TICKET_DELAY),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
pub fn serve(&mut self, ground: &mut Ground, now: u64) {
|
||||
for c in ground.take_chunks() {
|
||||
self.tickets += 1;
|
||||
if std::env::var("NOTICKETS").is_err() {
|
||||
let mut asked = self.asked.borrow_mut();
|
||||
let e = asked.entry(c).or_insert((now, now));
|
||||
if now >= e.1 {
|
||||
*e = (now, now);
|
||||
}
|
||||
e.1 = now + TICKET_DELAY + TICKET_LIFE;
|
||||
}
|
||||
}
|
||||
let cols = ground
|
||||
.take_wanted()
|
||||
.into_iter()
|
||||
.map(|(x, z)| {
|
||||
let y = match self.loaded((x >> 4, z >> 4), now) {
|
||||
Some(true) => self.height(x, z),
|
||||
Some(false) => PENDING,
|
||||
None => -64,
|
||||
};
|
||||
(x, z, y)
|
||||
})
|
||||
.collect();
|
||||
ground.answer(cols, now);
|
||||
let secs = ground.world.take_wanted(now);
|
||||
let view = Seen { l: self, now };
|
||||
ground.world.serve(&view, secs, now);
|
||||
}
|
||||
}
|
||||
struct Seen<'a> {
|
||||
l: &'a Loader,
|
||||
now: u64,
|
||||
}
|
||||
impl world::WorldSource for Seen<'_> {
|
||||
fn solid(&self, x: i32, y: i32, z: i32) -> Option<bool> {
|
||||
self.l.loaded((x >> 4, z >> 4), self.now).filter(|&l| l).map(|_| y < self.l.height(x, z))
|
||||
}
|
||||
}
|
||||
|
||||
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: |_| {} }
|
||||
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 }
|
||||
}
|
||||
}
|
||||
|
||||
/// 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 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 {
|
||||
let p = DVec3::new(((k % side) as f64 - side as f64 / 2.0) * 70.0, 70.0, ((k / side) as f64 - side as f64 / 2.0) * 70.0);
|
||||
let (px, pz) = (((k % side) as f64 - side as f64 / 2.0) * 70.0, ((k / side) as f64 - side as f64 / 2.0) * 70.0);
|
||||
let p = DVec3::new(px, if s.hilly { y(px, pz) + 6.0 + s.bury } else { 70.0 }, pz);
|
||||
sim.targets.push(p);
|
||||
sim.target_health.push(100.0);
|
||||
}
|
||||
@@ -88,13 +195,15 @@ pub(super) fn cluster(sim: &mut Sim, s: &Setup) {
|
||||
let a = rng.f() * TAU;
|
||||
let r = 30.0 + rng.f() * 110.0;
|
||||
let id = sim.id();
|
||||
sim.sams.push(Sam::new(id, MIX[i % MIX.len()], at + DVec3::new(a.sin() * r, 0.0, a.cos() * r), rng.f() * TAU));
|
||||
let (px, pz) = (at.x + a.sin() * r, at.z + a.cos() * r);
|
||||
sim.sams.push(Sam::new(id, MIX[i % MIX.len()], DVec3::new(px, y(px, pz), pz), rng.f() * TAU));
|
||||
}
|
||||
for _ in 0..s.radars {
|
||||
let a = rng.f() * TAU;
|
||||
let r = rng.f() * 220.0;
|
||||
let id = sim.id();
|
||||
sim.sams.push(Sam::new(id, SamKind::Radar, DVec3::new(a.sin() * r, 70.0, a.cos() * r), 0.0));
|
||||
let (px, pz) = (a.sin() * r, a.cos() * r);
|
||||
sim.sams.push(Sam::new(id, SamKind::Radar, DVec3::new(px, if s.hilly { y(px, pz) } else { 70.0 }, pz), 0.0));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -104,13 +213,13 @@ pub(super) fn fly(s: &Setup) -> Result {
|
||||
cluster(&mut sim, s);
|
||||
(s.tweak)(&mut sim);
|
||||
let mut ground = Ground::default();
|
||||
let land = Land(|_, _| 64);
|
||||
let mut land = Loader::new(s);
|
||||
let mut out = Out::default();
|
||||
for _ in 0..200 {
|
||||
serve(&mut ground, &land, sim.tick);
|
||||
land.serve(&mut ground, sim.tick);
|
||||
sim.step(&mut ground, None, &mut out);
|
||||
}
|
||||
let mut order = crate::wave::WaveConfig::mix(s.size, 500.0);
|
||||
let mut order = crate::wave::WaveConfig::mix(s.size, s.dist);
|
||||
// The Oreshnik's blocks are a raid of their own; this measures the ordinary mass raid.
|
||||
order.kinds.retain(|k| k.0 != DroneKind::Oreshnik);
|
||||
if s.smart {
|
||||
@@ -125,7 +234,7 @@ pub(super) fn fly(s: &Setup) -> Result {
|
||||
let start = sim.tick;
|
||||
let mut gone_unkilled: Vec<Hist> = Vec::new();
|
||||
for _ in 0..9000 {
|
||||
serve(&mut ground, &land, sim.tick);
|
||||
land.serve(&mut ground, sim.tick);
|
||||
out = Out::default();
|
||||
sim.step(&mut ground, None, &mut out);
|
||||
let now = sim.tick;
|
||||
@@ -184,6 +293,8 @@ pub(super) fn fly(s: &Setup) -> Result {
|
||||
break;
|
||||
}
|
||||
}
|
||||
result.tickets = land.tickets;
|
||||
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;
|
||||
result.kills = sim.kills;
|
||||
@@ -191,6 +302,8 @@ pub(super) fn fly(s: &Setup) -> Result {
|
||||
result.heavy = sim.sams.iter().filter(|m| matches!(m.kind, SamKind::S400 | SamKind::S500 | SamKind::Patriot)).map(|m| m.fired).sum();
|
||||
result.hits = sim.hits;
|
||||
result.crashes = sim.crashes;
|
||||
result.blocked = sim.tally.blocked;
|
||||
result.scrubbed = sim.tally.scrubbed;
|
||||
result.leaked = sim.hits;
|
||||
let mut kinds: HashMap<DroneKind, (u32, u32)> = HashMap::new();
|
||||
for h in &gone_unkilled {
|
||||
@@ -215,33 +328,60 @@ pub(super) fn fly(s: &Setup) -> Result {
|
||||
}
|
||||
|
||||
pub(super) fn table(title: &str, smart: bool, size: u32, systems: &[usize], seeds: &[u64], tweak: fn(&mut Sim)) -> Vec<(usize, f64)> {
|
||||
table_on(title, smart, size, systems, seeds, tweak, std::env::var("HILLS").is_ok())
|
||||
}
|
||||
|
||||
pub(super) fn table_on(title: &str, smart: bool, size: u32, systems: &[usize], seeds: &[u64], tweak: fn(&mut Sim), hilly: bool) -> Vec<(usize, f64)> {
|
||||
let mut means = Vec::new();
|
||||
eprintln!("== {title} ({} raid)", if smart { "smart" } else { "mix" });
|
||||
eprintln!("{:>4} {:>4} {:>5} {:>5} {:>5} {:>6} {:>6} | leaks: unseen nocover untrk unshot missed hits crash", "sams", "seed", "sent", "kills", "leak", "missil", "heavy");
|
||||
eprintln!("== {title} ({} raid{})", if smart { "smart" } else { "mix" }, if hilly { ", hills" } else { "" });
|
||||
eprintln!("{:>4} {:>4} {:>5} {:>5} {:>5} {:>6} {:>6} | leaks: unseen nocover untrk unshot missed hits crash blkd scrub", "sams", "seed", "sent", "kills", "leak", "missil", "heavy");
|
||||
for &n in systems {
|
||||
let (mut total, mut missiles, mut heavy, mut clean) = (0, 0, 0, 0);
|
||||
let (mut total, mut missiles, mut heavy, mut clean, mut crashes, mut blocked) = (0, 0, 0, 0, 0, 0);
|
||||
for &seed in seeds {
|
||||
let mut s = Setup::new(n, smart, seed);
|
||||
s.tweak = tweak;
|
||||
s.size = size;
|
||||
s.hilly = hilly;
|
||||
let r = fly(&s);
|
||||
total += r.leaked;
|
||||
clean += u32::from(r.leaked == 0);
|
||||
crashes += r.crashes;
|
||||
blocked += r.blocked + r.scrubbed;
|
||||
missiles += r.missiles;
|
||||
heavy += r.heavy;
|
||||
if std::env::var("ROWS").is_ok() { eprintln!("{n:>4} {seed:>4} {:>5} {:>5} {:>5} {:>6} {:>6} | {:>12} {:>7} {:>5} {:>6} {:>6} {:>4} {:>5} {:?}", r.launched, r.kills, r.leaked, r.missiles, r.heavy, r.unseen, r.no_cover, r.untracked, r.unshot, r.missed, r.hits, r.crashes, r.by_kind.iter().map(|k| format!("{:?}:{}", k.0, k.1)).collect::<Vec<_>>()); }
|
||||
if std::env::var("ROWS").is_ok() { eprintln!("{n:>4} {seed:>4} {:>5} {:>5} {:>5} {:>6} {:>6} | {:>12} {:>7} {:>5} {:>6} {:>6} {:>4} {:>5} {:>4} {:>5} {:?}", r.launched, r.kills, r.leaked, r.missiles, r.heavy, r.unseen, r.no_cover, r.untracked, r.unshot, r.missed, r.hits, r.crashes, r.blocked, r.scrubbed, r.by_kind.iter().map(|k| format!("{:?}:{}", k.0, k.1)).collect::<Vec<_>>()); }
|
||||
if seed == seeds[0] && std::env::var("ROWS").is_ok() {
|
||||
eprintln!(" tally: {}", r.tally);
|
||||
}
|
||||
}
|
||||
let mean = total as f64 / seeds.len() as f64;
|
||||
let k = seeds.len() as f64;
|
||||
eprintln!("{n:>4} mean leaks {mean:.1} (clean seeds {clean}/{}), missiles {:.0}, of them heavy {:.0}", seeds.len(), missiles as f64 / k, heavy as f64 / k);
|
||||
eprintln!("{n:>4} mean leaks {mean:.1} (clean seeds {clean}/{}), missiles {:.0}, of them heavy {:.0}, drone crashes {:.1}, interceptors lost to land {:.1}", seeds.len(), missiles as f64 / k, heavy as f64 / k, crashes as f64 / k, blocked as f64 / k);
|
||||
means.push((n, mean));
|
||||
}
|
||||
means
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore]
|
||||
fn buried_table() {
|
||||
for n in [20usize, 150] {
|
||||
for seed in 1..=4u64 {
|
||||
let mut s = Setup::new(n, false, seed);
|
||||
s.hilly = true;
|
||||
s.bury = 7.0;
|
||||
let r = fly(&s);
|
||||
eprintln!("buried {n} seed {seed}: sent {} kills {} leaked {} crashes {} blocked {} missiles {}", r.launched, r.kills, r.leaked, r.crashes, r.blocked, r.missiles);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore]
|
||||
fn hills_table() {
|
||||
table_on("hills", false, 36, &[20, 50, 150], &[1, 2, 3, 4, 5, 6], |_| {}, true);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore]
|
||||
fn raid_table() {
|
||||
@@ -265,6 +405,46 @@ fn a_small_cluster_loses_and_a_big_one_holds() {
|
||||
assert!(big.leaked <= 5, "150 systems leaked {}", big.leaked);
|
||||
}
|
||||
|
||||
/// The same on rolling hills: 150 systems still hold about as on flat land, drones do not fly into the land on
|
||||
/// their own, and the interceptors do not routinely strike it (a few that blow up short of a ridge are allowed).
|
||||
#[test]
|
||||
fn on_hills_a_big_cluster_holds_and_nothing_routinely_hits_the_land() {
|
||||
let mut s = Setup::new(150, false, 2);
|
||||
s.size = 80;
|
||||
s.hilly = true;
|
||||
let big = fly(&s);
|
||||
let mut s = Setup::new(20, false, 2);
|
||||
s.size = 80;
|
||||
s.hilly = true;
|
||||
let small = fly(&s);
|
||||
eprintln!("hills, 20 systems: {small:?}\nhills, 150 systems: {big:?}");
|
||||
assert!(small.leaked >= 10, "20 systems should clearly lose on hills too, leaked {}", small.leaked);
|
||||
assert!(big.leaked <= 6, "150 systems on hills leaked {}", big.leaked);
|
||||
assert!(big.crashes <= 2, "drones flew into the hills: {}", big.crashes);
|
||||
assert!(big.blocked * 20 <= big.missiles, "interceptors struck the land: {} of {}", big.blocked, big.missiles);
|
||||
}
|
||||
|
||||
/// A raid from 2500 blocks over mountains, with only a disc round the player known (and chunks that load on request):
|
||||
/// the flights do not fly into the land by themselves (a rare one that cannot climb a ridge in time is allowed).
|
||||
#[test]
|
||||
fn far_raids_over_mountains_do_not_crash_into_them() {
|
||||
let (mut flown, mut crashed) = (0, 0);
|
||||
for seed in [3u64, 4, 8] {
|
||||
for smart in [false, true] {
|
||||
let mut s = Setup::new(50, smart, seed);
|
||||
s.hilly = true;
|
||||
s.mountains = true;
|
||||
s.dist = 2500.0;
|
||||
s.disc = Some(300.0);
|
||||
let r = fly(&s);
|
||||
eprintln!("seed {seed} smart {smart}: {} crashed of {}: {:?}", r.crashed.len(), r.launched, r.crashed.iter().map(|c| c.0).collect::<Vec<_>>());
|
||||
flown += r.launched;
|
||||
crashed += r.crashed.len() as u32;
|
||||
}
|
||||
}
|
||||
assert!(crashed * 40 <= flown, "{crashed} of {flown} flew into the land");
|
||||
}
|
||||
|
||||
/// What each of the new rules adds, one at a time (the raid of 36 and a heavier one of 80).
|
||||
#[test]
|
||||
#[ignore]
|
||||
@@ -295,3 +475,36 @@ fn one_raid() {
|
||||
eprintln!("seed {seed}: sent {} kills {} leaked {} missiles {} ticks {}", r.launched, r.kills, r.leaked, r.missiles, r.ticks);
|
||||
}
|
||||
}
|
||||
|
||||
/// Far launches over mountains where only a disc round the player is known: who crashes, where and how often.
|
||||
/// `cargo test --release far_table -- --ignored --nocapture` (DISC=300 sets the radius, 0 for all known).
|
||||
#[test]
|
||||
#[ignore]
|
||||
fn far_table() {
|
||||
let disc: f64 = std::env::var("DISC").ok().and_then(|v| v.parse().ok()).unwrap_or(300.0);
|
||||
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 {
|
||||
let mut s = Setup::new(50, std::env::var("SMART").is_ok(), seed);
|
||||
s.hilly = true;
|
||||
s.mountains = true;
|
||||
s.dist = dist;
|
||||
s.disc = (disc > 0.0).then_some(disc);
|
||||
let r = fly(&s);
|
||||
launched += r.launched;
|
||||
kills += r.kills;
|
||||
leaked += r.leaked;
|
||||
tickets += r.tickets;
|
||||
for c in &r.crashed {
|
||||
by_kind.entry(c.0).or_default().0 += 1;
|
||||
if std::env::var("ROWS").is_ok() {
|
||||
let (x, z) = (c.1.x as i32, c.1.z as i32);
|
||||
eprintln!(" seed {seed} {:?} crashed at {:.0},{:.0},{:.0} ({:.0} from origin, land {}), {:.0} from its aim", c.0, c.1.x, c.1.y, c.1.z, c.1.x.hypot(c.1.z), Loader::new(&s).height(x, z), c.2);
|
||||
}
|
||||
}
|
||||
}
|
||||
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<_>>());
|
||||
}
|
||||
|
||||
@@ -36,7 +36,7 @@ mod regress;
|
||||
mod sensor_tests;
|
||||
pub use ew::EW_RADIUS;
|
||||
pub use damage::{Comps, Part, PartState};
|
||||
pub use sight::{Ident, Track};
|
||||
pub use sight::{Ident, LowSensor, Track, RIM_RAYS};
|
||||
use damage::dice;
|
||||
use outcome::{Impact, Outcome};
|
||||
pub use units::{Family, SamKind};
|
||||
@@ -356,6 +356,21 @@ impl Ground {
|
||||
return false;
|
||||
}
|
||||
self.chunks.push(c);
|
||||
// What was answered as "no chunk" there is asked again: the chunk is being loaded now.
|
||||
for x in c.0 * 16..c.0 * 16 + 16 {
|
||||
for z in c.1 * 16..c.1 * 16 + 16 {
|
||||
if self.known.get(&(x, z)).is_some_and(|v| v.0 <= -64) {
|
||||
self.known.remove(&(x, z));
|
||||
}
|
||||
}
|
||||
}
|
||||
for x in (c.0 * 16..c.0 * 16 + 16).step_by(8) {
|
||||
for z in (c.1 * 16..c.1 * 16 + 16).step_by(8) {
|
||||
if self.known.get(&(x + 4, z + 4)).is_some_and(|v| v.0 <= -64) {
|
||||
self.known.remove(&(x + 4, z + 4));
|
||||
}
|
||||
}
|
||||
}
|
||||
true
|
||||
}
|
||||
/// How many chunks are wanted so far this tick.
|
||||
@@ -920,11 +935,14 @@ struct Nav {
|
||||
need: f64,
|
||||
/// The height from which a straight dive on its target clears the land (unknown: -inf).
|
||||
dive: f64,
|
||||
/// The land ahead is not known (a chunk the game does not have, or not yet): it keeps a transit height
|
||||
/// above anything it could meet and comes down only once the land ahead is told of.
|
||||
blind: bool,
|
||||
}
|
||||
|
||||
impl Default for Nav {
|
||||
fn default() -> Self {
|
||||
Nav { need: f64::NEG_INFINITY, dive: f64::NEG_INFINITY }
|
||||
Nav { need: f64::NEG_INFINITY, dive: f64::NEG_INFINITY, blind: false }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1492,6 +1510,11 @@ pub struct Sim {
|
||||
pub hits: u32,
|
||||
/// Drones that struck the land or a building short of their aim.
|
||||
pub crashes: u32,
|
||||
/// Systems just deployed by hand and not yet checked for standing in the land: (id, until tick).
|
||||
settling: Vec<(u32, u64)>,
|
||||
/// What crashed, where, and its aim (balance measurements only).
|
||||
#[cfg(test)]
|
||||
pub crashed: Vec<(DroneKind, DVec3, DVec3)>,
|
||||
pub launched: u32,
|
||||
/// Explosions damage the land (primed TNT through commands).
|
||||
pub damage: bool,
|
||||
@@ -1578,6 +1601,9 @@ impl Sim {
|
||||
kills: 0,
|
||||
hits: 0,
|
||||
crashes: 0,
|
||||
settling: Vec::new(),
|
||||
#[cfg(test)]
|
||||
crashed: Vec::new(),
|
||||
launched: 0,
|
||||
damage: true,
|
||||
seed: seed.wrapping_mul(0x2545_F491_4F6C_DD1D) | 1,
|
||||
@@ -1707,12 +1733,43 @@ impl Sim {
|
||||
self.fires.extinguish();
|
||||
}
|
||||
|
||||
/// A system just put down by the player is looked at for a few seconds: one standing inside the land (a player
|
||||
/// who fell into a chunk that had not loaded) is brought up to the surface. Buried, its interceptors would
|
||||
/// strike the dirt above it and the raid's aim at it would lie under the land. Only a spot whose block is known
|
||||
/// solid and whose land height is known is moved, to that height.
|
||||
pub fn settle(&mut self, id: u32) {
|
||||
self.settling.push((id, self.tick + 100));
|
||||
}
|
||||
|
||||
fn step_settling(&mut self, ground: &mut Ground) {
|
||||
let now = self.tick;
|
||||
let sams = &mut self.sams;
|
||||
self.settling.retain(|&(id, until)| {
|
||||
let Some(sam) = sams.iter_mut().find(|s| s.id == id) else { return false };
|
||||
let p = sam.pos;
|
||||
let (x, y, z) = (p.x.floor() as i32, p.y.floor() as i32, p.z.floor() as i32);
|
||||
if ground.world.is_known_open(x, y, z, now) {
|
||||
return false;
|
||||
}
|
||||
if ground.world.is_known_solid(x, y, z, now) {
|
||||
if let Some(g) = ground.at(p, now) {
|
||||
if g > p.y + 1.5 && g < p.y + 96.0 {
|
||||
sam.pos.y = g;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
now < until
|
||||
});
|
||||
}
|
||||
|
||||
/// One game tick.
|
||||
pub fn step(&mut self, ground: &mut Ground, player: Option<DVec3>, out: &mut Out) {
|
||||
self.tick += 1;
|
||||
self.plan_step(Some(&mut *ground), out);
|
||||
self.adapt();
|
||||
self.release_queue(out);
|
||||
self.step_settling(ground);
|
||||
self.release_queue(ground, out);
|
||||
self.step_drones(ground, player, out);
|
||||
self.step_later(ground, player, out);
|
||||
self.step_moves(ground, out);
|
||||
@@ -1754,11 +1811,12 @@ impl Sim {
|
||||
let from = d.pos;
|
||||
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.
|
||||
// meanwhile, it goes for what is left below, not for the air where it stood; an aim inside the land (a mark
|
||||
// set from a spot the player stood in the ground) is the surface over it, else the strike counts as a crash.
|
||||
if d.flight != Flight::Falling
|
||||
&& horizontal(d.target - d.pos).length() < 250.0
|
||||
&& let Some(g) = ground.at(d.target, now)
|
||||
&& g < d.target.y - 1.5
|
||||
&& (g < d.target.y - 1.5 || (g > d.target.y + 1.5 && !spec.decoy && spec.ballistic.is_none() && g < d.target.y + 96.0))
|
||||
{
|
||||
d.target.y = g;
|
||||
}
|
||||
@@ -1820,6 +1878,10 @@ impl Sim {
|
||||
// Into the land or a building short of its aim: a crash, not a hit.
|
||||
if landed && d.pos.distance(d.target) >= speed.max(2.0) + 6.0 {
|
||||
crashes.push((d.id, d.kind, d.pos, spec.warhead));
|
||||
#[cfg(test)]
|
||||
{
|
||||
self.crashed.push((d.kind, d.pos, d.target));
|
||||
}
|
||||
removed.push(d.id);
|
||||
continue;
|
||||
}
|
||||
@@ -1921,7 +1983,9 @@ impl Sim {
|
||||
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);
|
||||
let (need, blind) = terrain::required_height(ground, d.pos, &lines, spec.clearance, &climb);
|
||||
d.nav.need = need.unwrap_or(f64::NEG_INFINITY);
|
||||
d.nav.blind = blind;
|
||||
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 {
|
||||
@@ -1954,13 +2018,14 @@ impl Sim {
|
||||
} 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 base = if d.low && !d.nav.blind { 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;
|
||||
// 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);
|
||||
let slope = if urgent {
|
||||
full
|
||||
} else {
|
||||
@@ -1997,6 +2062,10 @@ impl Sim {
|
||||
if in_ground && d.pos.distance(d.target) >= speed.max(1.5) + 3.0 {
|
||||
if !spec.decoy {
|
||||
crashes.push((d.id, d.kind, d.pos, spec.warhead));
|
||||
#[cfg(test)]
|
||||
{
|
||||
self.crashed.push((d.kind, d.pos, d.target));
|
||||
}
|
||||
}
|
||||
removed.push(d.id);
|
||||
continue;
|
||||
|
||||
@@ -237,6 +237,9 @@ impl PartialOrd for Open {
|
||||
}
|
||||
}
|
||||
|
||||
/// Ticks an air start waits for the land under it to be told of.
|
||||
const AIR_START_WAIT: u32 = 120;
|
||||
|
||||
impl Sim {
|
||||
/// Orders a raid on `objects` (at least one point). A smart raid is planned over the next ticks
|
||||
/// (each route found in full, a few a tick) and launched when its plan is ready.
|
||||
@@ -649,8 +652,11 @@ impl Sim {
|
||||
(prices, cost)
|
||||
}
|
||||
|
||||
/// A planned raid's drones take off when their time comes.
|
||||
pub(super) fn release_queue(&mut self, out: &mut Out) {
|
||||
/// Ticks an air start waits for the land under it to be told of.
|
||||
const AIR_START_WAIT: u32 = 120;
|
||||
|
||||
/// A planned raid's drones take off when their time comes.
|
||||
pub(super) fn release_queue(&mut self, ground: &mut Ground, out: &mut Out) {
|
||||
let now = self.tick;
|
||||
if self.queue.iter().all(|q| q.0 > now) {
|
||||
return;
|
||||
@@ -658,16 +664,41 @@ impl Sim {
|
||||
let (go, wait): (Vec<_>, Vec<_>) = std::mem::take(&mut self.queue).into_iter().partition(|q| q.0 <= now);
|
||||
self.queue = wait;
|
||||
let mut cancelled = 0;
|
||||
for (t, d) in go {
|
||||
for (t, mut d) in go {
|
||||
// A site's job leaves its site one at a time, from its stock; a destroyed or empty site cancels it.
|
||||
let from_site = self.sites.site_of(d.id);
|
||||
// An air start (an off-map site's drone) was planned over land nobody had read. It waits (a second or
|
||||
// two) for the land under it to be known, and begins above it if that is higher than the start: else it
|
||||
// is born inside a hill and crashes at once.
|
||||
let air = from_site.is_some_and(|id| self.sites.list.iter().any(|s| s.id == id && s.offmap));
|
||||
if air {
|
||||
let clear = if d.kind.spec().ballistic.is_some() { 10.0 } else { d.kind.spec().clearance };
|
||||
match ground.at(d.pos, now) {
|
||||
None if d.age < AIR_START_WAIT => {
|
||||
// The game has no chunk there: it is asked to load the ones round the start (and kept a while).
|
||||
let c = ((d.pos.x.floor() as i32) >> 4, (d.pos.z.floor() as i32) >> 4);
|
||||
for (dx, dz) in [(0, 0), (1, 0), (-1, 0), (0, 1), (0, -1)] {
|
||||
ground.want_chunk((c.0 + dx, c.1 + dz));
|
||||
}
|
||||
d.age += 1;
|
||||
self.queue.push((t, d));
|
||||
continue;
|
||||
}
|
||||
Some(g) if d.pos.y < g + clear => d.pos.y = g + clear,
|
||||
_ => {}
|
||||
}
|
||||
d.age = 0;
|
||||
}
|
||||
let slot = self.site_slot(d.id);
|
||||
if let (Some(site), sites::Slot::Go | sites::Slot::Air) = (from_site, &slot) {
|
||||
self.scout_launch(d.id, site, d.kind, d.pos);
|
||||
}
|
||||
match slot {
|
||||
// Free: not a site's job. Air: an off-map site's drone, already in flight position.
|
||||
sites::Slot::Free | sites::Slot::Air => {}
|
||||
sites::Slot::Free => {}
|
||||
// The air start was planned over land nobody had read; if it is read by now and the land there is
|
||||
// higher than the start, it begins above it (else it is born inside a hill and crashes at once).
|
||||
sites::Slot::Air => {}
|
||||
sites::Slot::Busy => {
|
||||
self.queue.push((t, d));
|
||||
continue;
|
||||
@@ -677,6 +708,14 @@ impl Sim {
|
||||
continue;
|
||||
}
|
||||
sites::Slot::Go => {
|
||||
// On a slope the pad's side or the muzzle's far end can stand in the land that rises there: the
|
||||
// drone leaves from above the land at its own column, not from inside the hill.
|
||||
if let Some(g) = ground.at(d.pos, now)
|
||||
&& d.pos.y < g + 1.5
|
||||
&& g < d.pos.y + 40.0
|
||||
{
|
||||
d.pos.y = g + 1.5;
|
||||
}
|
||||
let dir = d.vel.normalize_or_zero();
|
||||
out.sound(d.kind.launch_sound(), d.pos, 1.0, 1.0);
|
||||
out.particles("minecraft:flame", d.pos - dir * 1.5, 0.15, -dir * 0.1, 0.02, 4);
|
||||
|
||||
@@ -1594,3 +1594,78 @@ fn mass_strikes_keep_fires_effects_and_lights_bounded() {
|
||||
assert!(sim.fires.is_empty(), "fires burn for ever");
|
||||
let _ = commands;
|
||||
}
|
||||
|
||||
/// A system put down inside the land (the player stood in the ground) is brought up to the surface, and its
|
||||
/// interceptors then leave the launcher; one on the surface or in the open air is left where it is.
|
||||
#[test]
|
||||
fn a_system_deployed_inside_the_land_comes_up() {
|
||||
let land = sites::testland::Land(|_, _| 63);
|
||||
let mut sim = Sim::new(1);
|
||||
let mut ground = Ground::default();
|
||||
let mut out = Out::default();
|
||||
let buried = sim.id();
|
||||
sim.sams.push(Sam::new(buried, SamKind::Patriot, DVec3::new(20.0, 56.0, 17.0), 0.0));
|
||||
sim.settle(buried);
|
||||
let open = sim.id();
|
||||
sim.sams.push(Sam::new(open, SamKind::S400, DVec3::new(60.0, 63.0, 17.0), 0.0));
|
||||
sim.settle(open);
|
||||
let air = sim.id();
|
||||
sim.sams.push(Sam::new(air, SamKind::Tor, DVec3::new(90.0, 70.0, 17.0), 0.0));
|
||||
sim.settle(air);
|
||||
for _ in 0..60 {
|
||||
sites::testland::step(&mut sim, &mut ground, &land, &mut out);
|
||||
}
|
||||
let y = |id| sim.sams.iter().find(|s| s.id == id).unwrap().pos.y;
|
||||
assert_eq!(y(buried), 63.0, "a buried system comes up to the surface");
|
||||
assert_eq!(y(open), 63.0);
|
||||
assert_eq!(y(air), 70.0, "one in the open air is not moved");
|
||||
}
|
||||
|
||||
/// An aim point inside the land (an object marked where the player stood in the ground) is the surface over it:
|
||||
/// a drone that arrives is a hit, not a crash into the land short of its aim.
|
||||
#[test]
|
||||
fn a_drone_at_an_aim_under_the_land_hits() {
|
||||
let (mut ground, land) = (Ground::default(), sites::testland::Land(|_, _| 63));
|
||||
let mut sim = Sim::new(5);
|
||||
sim.targets.push(DVec3::new(0.0, 56.0, 0.0));
|
||||
sim.target_health.push(100.0);
|
||||
let d = sim.make_drone(DroneKind::Geran3, DVec3::new(-400.0, 100.0, 0.0), DVec3::X, DVec3::new(0.0, 56.0, 0.0));
|
||||
sim.drones.push(d);
|
||||
let mut out = Out::default();
|
||||
for _ in 0..1200 {
|
||||
sites::testland::step(&mut sim, &mut ground, &land, &mut out);
|
||||
if sim.drones.is_empty() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
assert_eq!((sim.hits, sim.crashes), (1, 0), "hits {} crashes {}", sim.hits, sim.crashes);
|
||||
}
|
||||
|
||||
/// Over land the game has not told of, a low-flying drone does not hug the height of its aim: it holds the transit
|
||||
/// height (the cruise height over the aim) and comes down only once the land ahead is known.
|
||||
#[test]
|
||||
fn over_unknown_land_a_low_flight_holds_its_transit_height() {
|
||||
let aim = DVec3::new(0.0, 64.0, 3000.0);
|
||||
let fly = |known: bool| {
|
||||
let mut sim = Sim::new(4);
|
||||
let mut d = sim.make_drone(DroneKind::Kh101, DVec3::new(0.0, 70.0, 0.0), DVec3::Z, aim);
|
||||
d.low = true;
|
||||
d.route = vec![aim];
|
||||
sim.drones.push(d);
|
||||
let (mut ground, land) = (Ground::default(), sites::testland::Land(|_, _| 64));
|
||||
let mut out = Out::default();
|
||||
for _ in 0..300 {
|
||||
if known {
|
||||
sites::testland::step(&mut sim, &mut ground, &land, &mut out);
|
||||
} else {
|
||||
sim.step(&mut ground, None, &mut out);
|
||||
}
|
||||
}
|
||||
sim.drones[0].pos.y
|
||||
};
|
||||
let spec = DroneKind::Kh101.spec();
|
||||
let blind = fly(false);
|
||||
let seen = fly(true);
|
||||
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}");
|
||||
}
|
||||
|
||||
@@ -93,6 +93,97 @@ pub(super) fn reach(sam: &Sam, kind: DroneKind, height: f64) -> f64 {
|
||||
radar.min(horizon).max(NEAR)
|
||||
}
|
||||
|
||||
/// The low flyer the command map shows the radars' coverage for: a Shahed over the land.
|
||||
const LOW_KIND: DroneKind = DroneKind::Shahed;
|
||||
|
||||
/// A radar as the command map sees it: where its antenna is and how far it reaches a low flyer.
|
||||
#[derive(Clone, Copy)]
|
||||
pub struct LowSensor {
|
||||
pub id: u32,
|
||||
pub ant: DVec3,
|
||||
/// The ground under the unit (a place whose land is unknown is taken as level with it).
|
||||
pub base: f64,
|
||||
pub reach: f64,
|
||||
/// Its horizon is known: the land hides what it hides.
|
||||
pub terrain: bool,
|
||||
}
|
||||
|
||||
/// Rays of `Sim::low_rim`.
|
||||
pub const RIM_RAYS: usize = RAYS;
|
||||
|
||||
impl Ground {
|
||||
/// The land's height at `p` from what the game has already answered; asks for nothing.
|
||||
pub fn peek(&self, p: DVec3) -> Option<f64> {
|
||||
const LAT: i32 = super::terrain::LATTICE;
|
||||
let k = (p.x.floor() as i32, p.z.floor() as i32);
|
||||
let hit = self.known.get(&k).or_else(|| {
|
||||
self.known.get(&(k.0.div_euclid(LAT) * LAT + LAT / 2, k.1.div_euclid(LAT) * LAT + LAT / 2))
|
||||
});
|
||||
hit.and_then(|&(y, _)| (y > -64).then_some(f64::from(y)))
|
||||
}
|
||||
}
|
||||
|
||||
impl Sim {
|
||||
/// A radar for a unit of `kind` at `pos` as the map draws it; `id`: the unit it is (its parts and horizon), None for a ghost.
|
||||
pub fn low_sensor(&self, kind: SamKind, pos: DVec3, id: Option<u32>) -> Option<LowSensor> {
|
||||
let spec = kind.spec();
|
||||
if spec.sensor <= 0.0 {
|
||||
return None;
|
||||
}
|
||||
let fit = id.and_then(|i| self.sams.iter().find(|s| s.id == i)).map_or(1.0, |s| s.radar_fit());
|
||||
let height = LOW_KIND.spec().clearance;
|
||||
let radar = spec.sensor * fit * LOW_KIND.rcs().powf(0.25);
|
||||
let reach = radar.min(HORIZON * (spec.mast.sqrt() + height.sqrt())).max(NEAR);
|
||||
let terrain = id.is_some_and(|i| self.horizons.contains_key(&i));
|
||||
Some(LowSensor { id: id.unwrap_or(u32::MAX), ant: pos + DVec3::Y * spec.mast, base: pos.y, reach, terrain })
|
||||
}
|
||||
|
||||
/// The radars that are up now.
|
||||
pub fn low_sensors(&self) -> Vec<LowSensor> {
|
||||
self.sams.iter().filter(|s| s.radar_up()).filter_map(|s| self.low_sensor(s.kind, s.pos, Some(s.id))).collect()
|
||||
}
|
||||
|
||||
/// Whether the radar sees a low flyer (over the land) at the column (`x`, `z`): in its reach and not behind the land.
|
||||
pub fn low_sees(&self, ground: &Ground, s: &LowSensor, x: f64, z: f64) -> bool {
|
||||
let (dx, dz) = (x - s.ant.x, z - s.ant.z);
|
||||
let d2 = dx * dx + dz * dz;
|
||||
if d2 > s.reach * s.reach {
|
||||
return false;
|
||||
}
|
||||
if !s.terrain || d2 <= NEAR * NEAR {
|
||||
return true;
|
||||
}
|
||||
let Some(h) = self.horizons.get(&s.id) else { return true };
|
||||
let g = ground.peek(DVec3::new(x, 0.0, z)).unwrap_or(s.base);
|
||||
!h.hides(s.ant, DVec3::new(x, g + LOW_KIND.spec().clearance, z))
|
||||
}
|
||||
|
||||
/// Per ray round the radar, how far a low flyer is seen before the land first hides it (at most the reach).
|
||||
pub fn low_rim(&self, ground: &Ground, s: &LowSensor) -> Vec<f32> {
|
||||
let mut out = vec![s.reach as f32; RAYS];
|
||||
let Some(h) = self.horizons.get(&s.id).filter(|_| s.terrain) else { return out };
|
||||
let steps = ((s.reach / STEP) as usize).min(h.rays[0].len());
|
||||
let height = LOW_KIND.spec().clearance;
|
||||
for (r, o) in out.iter_mut().enumerate() {
|
||||
let a = r as f64 / RAYS as f64 * TAU;
|
||||
let (sx, sz) = (a.sin(), a.cos());
|
||||
for k in 1..=steps {
|
||||
let d = k as f64 * STEP;
|
||||
if d <= NEAR {
|
||||
continue;
|
||||
}
|
||||
let p = DVec3::new(s.ant.x + sx * d, 0.0, s.ant.z + sz * d);
|
||||
let y = ground.peek(p).unwrap_or(s.base) + height - s.ant.y;
|
||||
if ((y / d) as f32) < h.rays[r][k - 1] {
|
||||
*o = (d - STEP).max(NEAR) as f32;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
}
|
||||
|
||||
/// How a kind's radar looks round.
|
||||
pub(super) struct Scan {
|
||||
/// Ticks for the beam to go round.
|
||||
@@ -367,3 +458,74 @@ impl Sim {
|
||||
best
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod low_view_tests {
|
||||
use super::*;
|
||||
|
||||
/// A radar on level land (70) with a hill east of it (140 high, x 128..160): the east ray is cut at the hill, the others are not.
|
||||
fn world() -> (Sim, Ground, u32) {
|
||||
let mut sim = Sim::new(3);
|
||||
let id = sim.id();
|
||||
sim.sams.push(Sam::new(id, SamKind::Buk, DVec3::new(0.0, 70.0, 0.0), 0.0));
|
||||
let mut ground = Ground::default();
|
||||
let cols: Vec<(i32, i32, i32)> = (-60..60).map(|k| (k * 16, 0, if (128..160).contains(&(k * 16)) { 140 } else { 70 })).collect();
|
||||
ground.answer(cols, 0);
|
||||
let h = Horizon::build(antenna(&sim.sams[0]), sim.sams[0].kind.spec().sensor, &mut ground, 0);
|
||||
sim.horizons.insert(id, h);
|
||||
(sim, ground, id)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_land_cuts_the_coverage_edge_behind_a_hill() {
|
||||
let (sim, ground, id) = world();
|
||||
let ls = sim.low_sensor(SamKind::Buk, DVec3::new(0.0, 70.0, 0.0), Some(id)).unwrap();
|
||||
assert!(ls.terrain && ls.reach > 200.0, "{}", ls.reach);
|
||||
let rim = sim.low_rim(&ground, &ls);
|
||||
let east = rim[RIM_RAYS / 4];
|
||||
assert!((128.0..=176.0).contains(&east), "east edge {east}");
|
||||
// Rays whose land is unknown hide nothing: the whole reach.
|
||||
assert_eq!(rim[0], ls.reach as f32);
|
||||
assert_eq!(rim[RIM_RAYS * 3 / 4], ls.reach as f32);
|
||||
assert!(sim.low_sees(&ground, &ls, -200.0, 0.0));
|
||||
assert!(sim.low_sees(&ground, &ls, 100.0, 0.0));
|
||||
assert!(!sim.low_sees(&ground, &ls, 200.0, 0.0), "behind the hill");
|
||||
assert!(!sim.low_sees(&ground, &ls, ls.reach + 50.0, 0.0), "out of reach");
|
||||
// A ghost (no horizon) sees the whole circle.
|
||||
let ghost = sim.low_sensor(SamKind::Buk, DVec3::new(0.0, 70.0, 0.0), None).unwrap();
|
||||
assert!(sim.low_sees(&ground, &ghost, 200.0, 0.0));
|
||||
}
|
||||
|
||||
// The map's tint asks `low_sees` for a cell of a coarse grid at every radar that reaches it, and the edges
|
||||
// ask `low_rim`: with a Polygon's worth of systems both stay cheap (native time, a few ms; wasm is a few times that).
|
||||
#[test]
|
||||
fn coverage_of_128_systems_is_cheap() {
|
||||
let (mut sim, mut ground, _) = world();
|
||||
for i in 0..127 {
|
||||
let id = sim.id();
|
||||
let pos = DVec3::new((i % 16) as f64 * 40.0 - 300.0, 70.0, (i / 16) as f64 * 40.0 - 150.0);
|
||||
sim.sams.push(Sam::new(id, SamKind::Buk, pos, 0.0));
|
||||
let k = sim.sams.len() - 1;
|
||||
let h = Horizon::build(antenna(&sim.sams[k]), sim.sams[k].kind.spec().sensor, &mut ground, 0);
|
||||
sim.horizons.insert(id, h);
|
||||
}
|
||||
let sensors = sim.low_sensors();
|
||||
assert_eq!(sensors.len(), 128);
|
||||
let t = std::time::Instant::now();
|
||||
let mut seen = 0;
|
||||
for cy in 0..28 {
|
||||
for cx in 0..36 {
|
||||
let (x, z) = (cx as f64 * 30.0 - 500.0, cy as f64 * 30.0 - 400.0);
|
||||
// Worst case for the early exit: a cell no radar sees is tested against all that reach it.
|
||||
if sensors.iter().any(|s| sim.low_sees(&ground, s, x, z)) {
|
||||
seen += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
let tint = t.elapsed();
|
||||
let t = std::time::Instant::now();
|
||||
let rims: usize = sensors.iter().take(3).map(|s| sim.low_rim(&ground, s).len()).sum();
|
||||
eprintln!("tint of 1008 cells: {tint:?} ({seen} seen); 3 rims: {:?} ({rims})", t.elapsed());
|
||||
assert!(seen > 0 && tint.as_millis() < 100, "{tint:?}");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -805,6 +805,25 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// An off-map site's drones start in the air at the height planned over land nobody had read; if the land
|
||||
/// turns out higher there by the time they leave, they start above it instead of being born inside the hill.
|
||||
#[test]
|
||||
fn an_off_map_start_over_a_hill_begins_above_it() {
|
||||
let (mut ground, mut out) = (Ground::default(), Out::default());
|
||||
let mut sim = Sim::new(3);
|
||||
sim.order_raid(&order(Approach::TwoSectors, 300.0), &[DVec3::new(0.0, 70.0, 0.0)], &mut out);
|
||||
while sim.planning() {
|
||||
sim.step(&mut ground, None, &mut out);
|
||||
}
|
||||
assert!(sim.sites.list.iter().all(|s| s.offmap));
|
||||
let land = Land(|x, z| if x * x + z * z > 200 * 200 { 150 } else { 64 });
|
||||
for _ in 0..500 {
|
||||
step(&mut sim, &mut ground, &land, &mut out);
|
||||
assert!(sim.drones.iter().all(|d| d.age > 3 || d.pos.y > 148.0 || d.flight == Flight::Ballistic), "a drone started inside the hill");
|
||||
}
|
||||
assert!(sim.launched > 0);
|
||||
}
|
||||
|
||||
/// Chunks that load on request: a column of a chunk asked for answers `PENDING` until `delay` ticks
|
||||
/// after the first request, and a chunk never asked for is not there (-64).
|
||||
struct Lazy {
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
use super::*;
|
||||
|
||||
/// Land heights are sampled at the centres of cells this wide.
|
||||
const LATTICE: i32 = 8;
|
||||
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;
|
||||
|
||||
@@ -168,6 +168,9 @@ pub(super) fn clamp_pitch(v: DVec3, lo: f64, hi: f64, level: DVec3) -> DVec3 {
|
||||
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,
|
||||
@@ -177,8 +180,10 @@ const REACH: [f64; 21] = [
|
||||
/// 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> {
|
||||
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 {
|
||||
@@ -187,10 +192,20 @@ pub(super) fn required_height(ground: &mut Ground, pos: DVec3, lines: &[(DVec3,
|
||||
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
|
||||
(need, blind)
|
||||
}
|
||||
|
||||
/// The least height to start from, over here, so that the straight line down to `target` stays `margin` over the
|
||||
|
||||
Reference in New Issue
Block a user