Compare commits

..

1 Commits

Author SHA1 Message Date
chexit 38e515dea9 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
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>
2026-10-31 02:19:31 +03:00
9 changed files with 1650 additions and 211 deletions
+2
View File
@@ -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
View File
File diff suppressed because it is too large Load Diff
+227 -14
View File
@@ -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<_>>());
}
+77 -8
View File
@@ -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;
+43 -4
View File
@@ -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);
+75
View File
@@ -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}");
}
+162
View File
@@ -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:?}");
}
}
+19
View File
@@ -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 {
+18 -3
View File
@@ -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