Files
airdef/airdef-mod/src/sim/regress.rs
T
chexit 5f07027faa Build canonical simulation without Horizon SDK and remove hidden launch fixes (#87 #107)
Preserve the adapter facade and move adapter-only checks out of domain tests. Late tracks do not reveal an unseen launch origin or its timestamp.

Co-Authored-By: Codex GPT-6.1 Terra <noreply@openai.com>
2026-11-02 01:17:03 +03:00

1768 lines
85 KiB
Rust

//! Regression tests for the issues filed on Gitea (each names its issue).
use super::*;
use super::raid::Grid;
use super::damage::MISS_RADIUS;
// #1: [bug] Запросы рельефа голодают: часть направлений РЛС никогда не получает высоты
#[test]
fn terrain_queries_must_eventually_cover_all_radar_bearings() {
let mut sim=Sim::new(1);
for i in 0..10 {
let id=sim.id();sim.sams.push(Sam::new(id,SamKind::Radar,DVec3::new(i as f64*5000.,70.,0.),0.));
}
let mut ground=Ground::default();
for now in 1..=2500 {
sim.tick=now;sim.update_sight(&mut ground);
let answers=ground.take_wanted().into_iter().map(|(x,z)|(x,z,200)).collect();
ground.answer(answers,now);
}
let visible:Vec<usize>=sim.horizons.values().map(|h|h.rays.iter().filter(|r|r.last().is_some_and(|v|v.is_finite())).count()).collect();
eprintln!("radar terrain coverage after 125 seconds: {:?} / 48 bearings each",visible);
assert!(visible.iter().all(|n|*n==48),"refreshes of early rays starve unknown terrain in later rays");
}
// #2: [bug] Уничтоженная ТЗМ навсегда резервирует заявку на подвоз боеприпасов
#[test]
fn dead_truck_must_not_reserve_a_waiting_system() {
let mut sim=Sim::new(1);
let depot=sim.id(); sim.sams.push(Sam::new(depot,SamKind::Depot,DVec3::new(500.,70.,0.),0.));
let target=sim.id(); let mut s=Sam::new(target,SamKind::Tor,DVec3::new(0.,70.,0.),0.);s.empty=true;s.missiles=0;sim.sams.push(s);
let dead=sim.id();let mut s=Sam::new(dead,SamKind::Tzm,DVec3::new(100.,70.,0.),0.);s.job=Some(target);s.health=0.;sim.sams.push(s);
let live=sim.id();sim.sams.push(Sam::new(live,SamKind::Tzm,DVec3::new(30.,70.,0.),0.));
sim.tick=3;sim.step_supply(&mut Out::default());
assert_eq!(sim.sams[3].job,Some(target),"a live loaded truck must take the waiting system");
}
// #3: [bug] ТЗМ застревает, если обслуживаемая установка сменила позицию
#[test]
fn truck_must_follow_a_target_that_moved_away() {
let mut sim=Sim::new(1);
let depot=sim.id();sim.sams.push(Sam::new(depot,SamKind::Depot,DVec3::new(500.,70.,0.),0.));
let target=sim.id();let mut s=Sam::new(target,SamKind::Tor,DVec3::new(100.,70.,0.),0.);s.empty=true;s.missiles=0;sim.sams.push(s);
let truck=sim.id();let mut s=Sam::new(truck,SamKind::Tzm,DVec3::new(0.,70.,0.),0.);s.job=Some(target);sim.sams.push(s);
sim.tick=2;sim.step_supply(&mut Out::default());
assert!(sim.sams[2].moving(),"truck at obsolete rendezvous must get a new move order");
}
// #4: [bug] Планировщик умного налёта не учитывает пусковую с внешним целеуказанием
#[test]
fn network_launcher_must_contribute_to_route_risk() {
let mut sim=Sim::new(1);
let id=sim.id();sim.sams.push(Sam::new(id,SamKind::P1sun,DVec3::new(0.,70.,0.),0.));
let id=sim.id();sim.sams.push(Sam::new(id,SamKind::Radar,DVec3::new(0.,70.,0.),0.));
let grid=Grid{min:DVec3::new(-160.,0.,-160.),cell:16.,size:20};
// #93: the planner prices only what the enemy knows; the radar emits, the launcher is not known until it gives itself away.
let (prices,_)=sim.price_grid(&grid,DroneKind::Shahed);
assert!(prices.iter().all(|p|*p==1.),"an unknown launcher must not be priced");
sim.intel.revealed.push((sim.sams[0].id,DVec3::new(0.,70.,0.),0));
let (prices,_)=sim.price_grid(&grid,DroneKind::Shahed);
assert!(prices.iter().any(|p|*p>1.),"armed launcher with a network radar is invisible to route risk");
}
// #5: [bug] После ремонта комплекс снова уничтожается от собственной отложенной детонации
#[test]
fn repair_must_cancel_pending_magazine_explosions() {
let mut sim=Sim::new(1);
let id=sim.id();sim.sams.push(Sam::new(id,SamKind::Patriot,DVec3::new(0.,70.,0.),0.));
sim.blast_damage(DVec3::new(0.,70.,0.),4,&mut Out::default());
assert!(!sim.later.is_empty());sim.repair();
sim.tick=3; sim.step_later(&mut Ground::default(),None,&mut Out::default());
assert!(sim.sams[0].alive(),"a fully repaired launcher was destroyed again by its own pre-repair cook-off");
}
// #34: drones and ballistic missiles burst where their target's platform used to be.
#[test]
fn munitions_must_go_down_to_the_land_left_under_a_destroyed_target() {
for (kind, from, vel) in [
(DroneKind::Shahed, DVec3::new(0.0, 121.0, 0.0), DVec3::ZERO),
(DroneKind::OreshnikSub, DVec3::new(0.0, 124.0, 0.0), DVec3::ZERO),
(DroneKind::Iskander, DVec3::new(100.0, 121.0, 0.0), DVec3::new(0.0, -4.0, 0.0)),
] {
let mut sim = Sim::new(1);
sim.damage = false;
let mut ground = Ground::default();
// The land under the old platform (at y 120) is at 64 now.
for x in -120..=120 {
for z in -8..=8 {
ground.answer(vec![(x, z, 64)], 0);
}
}
sim.add_drone(kind, from, DVec3::NEG_Y, DVec3::new(0.0, 120.0, 0.0));
if vel != DVec3::ZERO {
sim.drones[0].vel = vel;
sim.drones[0].flight = Flight::Ballistic;
}
let mut out = Out::default();
for _ in 0..400 {
if sim.drones.is_empty() {
break;
}
sim.step(&mut ground, None, &mut out);
}
let blast = sim.blasts.first().map(|b| b.at);
assert!(blast.is_some_and(|b| b.y < 80.0), "{kind:?} burst at {blast:?}, in the air over the land");
}
}
// #15: lights of blasts in one tick at the same whole X must stay apart
#[test]
fn blasts_in_one_tick_have_their_own_ids() {
let mut sim = Sim::new(1);
sim.tick = 100;
sim.fireball(DVec3::new(0.0, 70.0, 100.0), 4.0, 10);
sim.fireball(DVec3::new(0.0, 70.0, 200.0), 4.0, 10);
assert_ne!(sim.blasts[0].id, sim.blasts[1].id);
// A blast going away does not hand its id to the next one.
let gone = sim.blasts.remove(0).id;
sim.fireball(DVec3::new(0.0, 70.0, 300.0), 4.0, 10);
assert!(sim.blasts.iter().all(|b| b.id != gone));
}
// #21: a smart drone sent at a new target gets a route again, not a straight line
#[test]
fn retargeted_smart_drone_gets_a_route_round_the_defences() {
let mut sim = Sim::new(1);
// A system in the way, between the drone and the unit it is sent at.
let id = sim.id();
sim.sams.push(Sam::new(id, SamKind::Patriot, DVec3::new(0.0, 70.0, 0.0), 0.0));
let id = sim.id();
sim.sams.push(Sam::new(id, SamKind::Radar, DVec3::new(0.0, 70.0, 0.0), 0.0));
let goal = sim.id();
sim.sams.push(Sam::new(goal, SamKind::Ew, DVec3::new(0.0, 70.0, 1000.0), 0.0));
sim.intel.revealed.push((goal, DVec3::new(0.0, 70.0, 1000.0), 0));
// Its first target, a system that is destroyed by now.
let id = sim.id();
let mut wreck = Sam::new(id, SamKind::Patriot, DVec3::new(3000.0, 70.0, -1000.0), 0.0);
wreck.health = 0.0;
sim.sams.push(wreck);
sim.add_drone(DroneKind::Shahed, DVec3::new(0.0, 100.0, -1000.0), DVec3::Z, DVec3::new(3000.0, 70.0, -1000.0));
sim.drones[0].pos = DVec3::new(0.0, 100.0, -1000.0);
sim.drones[0].smart = true;
sim.tick = 40;
sim.adapt();
let d = &sim.drones[0];
assert!((d.target - d.err).distance(DVec3::new(0.0, 70.0, 1000.0)) < 1.0, "not sent at the unit");
assert!(d.route.len() > 1, "no route after the new target: it flies straight through the defences");
}
// #31: a big cache of fresh columns is not scanned on every answer
#[test]
fn big_ground_cache_is_swept_rarely() {
let mut ground = Ground::default();
ground.known.extend((0..300_001).map(|x| ((x, 0), (64, 2000))));
ground.known.insert((-1, 0), (64, 0));
// The first answer past the threshold sweeps what is stale and keeps what is fresh.
ground.answer(vec![(-2, 0, 64)], 2100);
assert!(!ground.known.contains_key(&(-1, 0)), "a stale column was never swept");
assert!(ground.known.len() > 300_000);
// The next ones wait their pause: a stale column added now survives them.
ground.known.insert((-1, 0), (64, 0));
let swept = ground.swept;
ground.answer(vec![(-3, 0, 64)], 2101);
assert_eq!(ground.swept, swept, "swept again at once");
assert!(ground.known.contains_key(&(-1, 0)));
ground.answer(vec![(-4, 0, 64)], 2100 + SWEEP_EVERY);
assert!(!ground.known.contains_key(&(-1, 0)), "never swept again");
}
// #32: the pieces of an Oreshnik teach the enemy about the sector it came from
#[test]
fn oreshnik_pieces_keep_the_sector() {
let mut sim = Sim::new(1);
sim.add_drone(DroneKind::Oreshnik, DVec3::new(100.0, 300.0, 0.0), DVec3::NEG_Y, DVec3::new(0.0, 70.0, 0.0));
sim.drones[0].vel = DVec3::new(-1.0, -1.0, 0.0);
sim.drones[0].sector = 7;
let mut out = Out::default();
sim.step_drones(&mut Ground::default(), None, &mut out);
let pieces: Vec<_> = sim.drones.iter().filter(|d| d.kind == DroneKind::OreshnikBlock).collect();
assert_eq!(pieces.len(), 6);
assert!(pieces.iter().all(|d| d.sector == 7));
}
// #33: a turret does not fire before it has turned to the target
#[test]
fn turret_waits_for_its_aim() {
let mut sim = Sim::new(1);
let id = sim.id();
let mut s = Sam::new(id, SamKind::Gepard, DVec3::new(0.0, 70.0, 0.0), 0.0);
s.cooldown = 0;
sim.sams.push(s);
let id = sim.id();
sim.sams.push(Sam::new(id, SamKind::Radar, DVec3::new(0.0, 70.0, 0.0), 0.0));
// Straight behind the turret's heading, a slow target well inside the gun's reach.
sim.add_drone(DroneKind::Shahed, DVec3::new(0.0, 75.0, 60.0), DVec3::ZERO, DVec3::new(0.0, 70.0, 900.0));
// (Pointing away from the gun, so that it doesn't start by turning back over it.)
sim.drones[0].vel = DVec3::Z * 0.01;
let (mut ground, mut out) = (Ground::default(), Out::default());
let mut turned = 0;
for _ in 0..200 {
sim.step(&mut ground, None, &mut out);
let g = &sim.sams[0];
let want = heading_of(sim.drones[0].pos - g.mount().0);
if angle_diff(g.yaw, want).abs() > 0.1 {
assert_eq!(g.bursts, 160, "fired with the turret still turning");
turned += 1;
}
if g.bursts < 160 || sim.drones.is_empty() {
break;
}
}
assert!(turned > 20, "the turret turned too fast to tell ({turned} ticks)");
assert!(sim.sams[0].bursts < 160, "never fired");
}
// #33: the drive speeds up, brakes in time and does not overshoot
#[test]
fn drive_arrives_without_overshoot() {
for err in [std::f64::consts::PI, 1.57, 0.3, 0.02] {
let (mut vel, mut left) = (0.0, err);
let (speed, accel) = (0.088, 0.011);
let mut ticks = 0;
while left > 0.0 {
let step = drive(&mut vel, left, speed, accel);
assert!(step.abs() <= speed + 1e-9 && step >= 0.0, "step {step} at {left} left");
left -= step;
ticks += 1;
assert!(ticks < 400, "never arrived for {err}");
}
assert!(left.abs() < 1e-9 && vel == 0.0);
}
}
// #49: a direct hit by an armed drone destroys the system at once, not with a dent of its health
#[test]
fn armed_strike_on_a_system_destroys_it_at_once() {
let centre = DVec3::new(0.0, 70.0, 0.0);
let hull = 2.2 * SamKind::Patriot.spec().scale;
for kind in [DroneKind::Lancet, DroneKind::Shahed] {
let mut destroyed = 0;
for seed in 1..=40 {
let mut sim = Sim::new(seed);
// The strike itself is under test: the Patriot does not shoot the Lancet down on a last chance.
sim.last_chance = false;
let id = sim.id();
sim.sams.push(Sam::new(id, SamKind::Patriot, centre, 0.0));
let mut ground = Ground::default();
sim.add_drone(kind, DVec3::new(0.0, 120.0, 0.0), DVec3::NEG_Y, centre);
let mut out = Out::default();
let mut impact = None;
for _ in 0..400 {
sim.step(&mut ground, None, &mut out);
if let Some(b) = sim.blasts.first() {
impact = Some(b.at);
break;
}
if sim.drones.is_empty() {
break;
}
}
let at = impact.expect("the strike must go off");
let inside = DVec3::new(at.x, 0.0, at.z).distance(DVec3::new(centre.x, 0.0, centre.z)) <= hull && at.y < centre.y + 3.0;
let dead = !sim.sams[0].alive();
if inside {
assert!(dead, "{kind:?} seed {seed} struck the hull at {at:?} but the system lives");
}
destroyed += dead as u32;
}
assert!(destroyed >= 30, "{kind:?}: only {destroyed}/40 direct strikes destroyed the system");
}
}
// #49: a near miss is only splash, an unarmed body is no direct hit, the top of the hull counts
#[test]
fn near_miss_and_unarmed_body_are_not_direct_hits() {
let mut sim = Sim::new(1);
let id = sim.id();
sim.sams.push(Sam::new(id, SamKind::Patriot, DVec3::new(0.0, 70.0, 0.0), 0.0));
let down = DVec3::new(0.0, -2.0, 0.0);
sim.direct_hit(DVec3::new(6.0, 71.0, 0.0), down, 3, &mut Out::default());
assert!(sim.sams[0].alive(), "a near miss six blocks off destroyed the system");
sim.direct_hit(DVec3::new(0.0, 71.0, 0.0), down, 0, &mut Out::default());
assert!(sim.sams[0].alive(), "an unarmed body destroyed the system");
sim.direct_hit(DVec3::new(0.0, 73.0, 0.0), down, 1, &mut Out::default());
assert!(!sim.sams[0].alive(), "an armed warhead into the top of the hull did not destroy the system");
}
// #59: a strike misses at a small, kind-specific rate; its scatter is the kind's CEP (half land within it)
#[test]
fn strike_scatter_matches_each_kinds_cep_and_miss_rate() {
const N: u64 = 5000;
for kind in DroneKind::ALL {
let mut sim = Sim::new(7);
let (cep, gross) = kind.accuracy();
let sigma = cep / (2.0 * std::f64::consts::LN_2).sqrt();
let expected = (1.0 - gross) * (-MISS_RADIUS * MISS_RADIUS / (2.0 * sigma * sigma)).exp() + gross;
let (mut misses, mut within, mut farthest) = (0u64, 0u64, 0.0f64);
for _ in 0..N {
let e = sim.aim_error(kind);
assert_eq!(e.y, 0.0);
let r = e.length();
within += (r <= cep) as u64;
if r > MISS_RADIUS {
misses += 1;
farthest = farthest.max(r);
}
}
let rate = misses as f64 / N as f64;
let half = within as f64 / N as f64;
assert!((rate - expected).abs() < 0.015, "{kind:?}: miss rate {rate:.4}, expected {expected:.4}");
assert!((0.005..=0.20).contains(&rate), "{kind:?}: miss rate {rate:.4} is not a small share (the IRBM bus is the loosest)");
assert!((half - 0.5 * (1.0 - gross)).abs() < 0.03, "{kind:?}: {half:.3} of strikes within the CEP {cep}");
assert!(farthest < MISS_RADIUS + 4.0 * cep, "{kind:?}: a miss {farthest:.2} blocks out, far from the aim");
}
}
// #59: the drone flies to its own error point and bursts there: the miss lands near the aim, by the CEP
#[test]
fn strikes_burst_near_their_aim_within_the_kinds_cep() {
let aim = DVec3::new(0.0, 70.0, 0.0);
for kind in [DroneKind::Shahed, DroneKind::Lancet] {
let cep = kind.accuracy().0;
let mut misses = 0;
for seed in 1..=60 {
let mut sim = Sim::new(seed);
let mut ground = Ground::default();
sim.add_drone(kind, DVec3::new(0.0, 120.0, 0.0), DVec3::NEG_Y, aim);
let mut out = Out::default();
let mut impact = None;
for _ in 0..400 {
sim.step(&mut ground, None, &mut out);
if let Some(b) = sim.blasts.first() {
impact = Some(b.at);
break;
}
if sim.drones.is_empty() {
break;
}
}
let at = impact.expect("the strike must go off");
let off = DVec3::new(at.x - aim.x, 0.0, at.z - aim.z).length();
assert!(off < MISS_RADIUS + 4.0 * cep + 2.0, "{kind:?} seed {seed} burst {off:.2} blocks from its aim");
misses += (off > MISS_RADIUS) as u32;
}
assert!(misses <= 6, "{kind:?}: {misses}/60 strikes missed");
}
}
// ---- #42, #43: flights are swept against the blocks of the world (a synthetic world stands in for the game)
/// Blocks from a function: `Some(solid)`, or `None` where there is no chunk.
struct Blocks<F: Fn(i32, i32, i32) -> Option<bool>>(F);
impl<F: Fn(i32, i32, i32) -> Option<bool>> world::WorldSource for Blocks<F> {
fn solid(&self, x: i32, y: i32, z: i32) -> Option<bool> {
(self.0)(x, y, z)
}
}
/// Reads the sections a flight along `a` to `b` needs, as the game would answer them over a few ticks.
fn warm(ground: &mut Ground, src: &dyn world::WorldSource, a: DVec3, b: DVec3) {
for _ in 0..4 {
ground.world.sweep(a, b, false, 0);
let wanted = ground.world.take_wanted(0);
ground.world.serve(src, wanted, 0);
}
}
/// One tick of the drones with the game answering what was asked.
fn fly(sim: &mut Sim, ground: &mut Ground, src: &dyn world::WorldSource) {
sim.tick += 1;
sim.step_drones(ground, None, &mut Out::default());
let wanted = ground.world.take_wanted(sim.tick);
ground.world.serve(src, wanted, sim.tick);
}
// #42: a drone that crosses a one-block wall in a tick explodes at the wall, and that is no hit on its aim
#[test]
fn fast_drone_must_not_cross_a_one_block_wall() {
let src = Blocks(|x, y, z| Some(y < 64 || (x == 3 && (64..141).contains(&y) && z.abs() < 50)));
let mut sim = Sim::new(1);
sim.damage = false;
let mut ground = Ground::default();
sim.add_drone(DroneKind::Zircon, DVec3::new(0.0, 100.0, 0.0), DVec3::X, DVec3::new(1000.0, 100.0, 0.0));
sim.drones[0].flight = Flight::Dive;
warm(&mut ground, &src, DVec3::new(0.0, 100.0, 0.0), DVec3::new(30.0, 100.0, 0.0));
// The wall's exact shape is asked of the game (#63): a tick or two, the drone holds at its face meanwhile.
for _ in 0..4 {
fly(&mut sim, &mut ground, &src);
assert!(sim.drones.first().is_none_or(|d| d.pos.x < 3.5), "crossed the wall");
}
assert!(sim.drones.is_empty(), "stuck at the wall");
let at = sim.blasts.first().expect("no blast").at;
assert!((at.x - 3.0).abs() < 0.05 && (at.y - 100.0).abs() < 1.5, "burst at {at:?}, not at the wall face x=3");
assert_eq!((sim.crashes, sim.hits), (1, 0), "a wall in the way is a crash, not a hit on the aim");
assert!(sim.replays.iter().chain(sim.rec.iter()).all(|r| r.events.iter().all(|e| !matches!(e.kind, record::EventKind::Hit { .. }))));
}
// #42: a drone flying into a hill side explodes at the face of the hill
#[test]
fn drone_flying_into_a_hill_explodes_at_its_face() {
// Flat at 64 to x=50, then a slope of 1:1.
let height = |x: i32| 64 + (x - 50).max(0);
let src = Blocks(move |x, y, _| Some(y < height(x)));
for kind in [DroneKind::Shahed, DroneKind::Zircon] {
let mut sim = Sim::new(3);
sim.damage = false;
let mut ground = Ground::default();
// The aim is on the far side of the face, close enough that the dive is not called off for the hill (it is
// below, at the end of the dive, where flight planning leaves it alone).
sim.add_drone(kind, DVec3::new(0.0, 80.0, 0.0), DVec3::X, DVec3::new(80.0, 80.0, 0.0));
sim.drones[0].flight = Flight::Dive;
for _ in 0..300 {
if sim.drones.is_empty() {
break;
}
fly(&mut sim, &mut ground, &src);
}
assert!(sim.drones.is_empty(), "{kind:?} flew on");
let at = sim.blasts.first().expect("no blast").at;
// The slope meets y=80 at x=66.
assert!(at.x > 62.0 && at.x < 67.5, "{kind:?} burst at {at:?}, not at the hill face (x~66)");
assert_eq!(sim.crashes, 1, "{kind:?}");
}
}
// #42: an interceptor cannot pass through a wall to its target, and does not kill it from behind the wall
#[test]
fn missile_cannot_pass_through_a_wall() {
let src = Blocks(|x, y, z| Some(y < 64 || (x == 3 && (64..141).contains(&y) && z.abs() < 50)));
let mut sim = Sim::new(1);
let mut ground = Ground::default();
// The target stands still just behind the wall, inside the fuse's reach.
sim.add_drone(DroneKind::Shahed, DVec3::new(8.5, 100.0, 0.0), DVec3::ZERO, DVec3::new(0.0, 100.0, 900.0));
sim.drones[0].vel = DVec3::ZERO;
sim.drones[0].engaged = 1;
let (target, id) = (sim.drones[0].id, sim.id());
sim.missiles.push(Missile { id, kind: MissileKind::Pac3, pos: DVec3::new(0.0, 100.0, 0.0), vel: DVec3::X * 3.0, target, sam: 0, age: 20, hold: 0, fix: None, seen: 0 });
warm(&mut ground, &src, DVec3::new(0.0, 100.0, 0.0), DVec3::new(30.0, 100.0, 0.0));
for _ in 0..4 {
sim.tick += 1;
sim.step_missiles(&mut ground, &mut Out::default());
let wanted = ground.world.take_wanted(sim.tick);
ground.world.serve(&src, wanted, sim.tick);
}
assert!(sim.missiles.is_empty(), "the missile is still flying");
assert_eq!(sim.kills, 0, "a kill through the wall");
assert!(sim.drones[0].alive(), "the target died behind the wall");
assert_eq!(sim.drones[0].engaged, 0, "the target stays claimed by a missile that is gone");
assert_eq!(sim.tally.blocked, 1);
assert!(sim.blasts.iter().all(|b| b.at.x < 3.5), "burst behind the wall");
}
// #74: a pending exact answer is no free pass for the fuse: the same outcome whether the reply is late or not
#[test]
fn pending_exact_answer_does_not_let_the_fuse_kill_through_a_wall() {
let src = Blocks(|x, y, z| Some(y < 64 || (x == 3 && (64..141).contains(&y) && z.abs() < 50)));
let run = |seed: u64, target_x: f64, reply: bool| {
let mut sim = Sim::new(seed);
let mut ground = Ground::default();
sim.add_drone(DroneKind::Shahed, DVec3::new(target_x, 100.0, 0.0), DVec3::ZERO, DVec3::new(0.0, 100.0, 900.0));
sim.drones[0].vel = DVec3::ZERO;
sim.drones[0].engaged = 1;
let (target, id) = (sim.drones[0].id, sim.id());
sim.tick = 20;
let fix = Fix { pos: DVec3::new(target_x, 100.0, 0.0), vel: DVec3::ZERO, tick: 20, seeker: false };
sim.missiles.push(Missile { id, kind: MissileKind::Pac3, pos: DVec3::new(0.0, 100.0, 0.0), vel: DVec3::X * 6.0, target, sam: 0, age: 20, hold: 0, fix: Some(fix), seen: 0 });
warm(&mut ground, &src, DVec3::new(0.0, 100.0, 0.0), DVec3::new(30.0, 100.0, 0.0));
let mut first = 0;
for i in 0..4 {
sim.tick += 1;
sim.step_missiles(&mut ground, &mut Out::default());
if i == 0 {
first = sim.kills;
}
let wanted = ground.world.take_wanted(sim.tick);
if reply {
ground.world.serve(&src, wanted, sim.tick);
} else {
// Sections only: the exact rays are never answered inside the test.
let answers = wanted.into_iter().map(|sec| world::SectionAnswer { sec, fill: world::section_of(&src, sec) }).collect();
ground.world.answer(answers, sim.tick);
}
}
(first, sim.kills, sim.drones.first().is_some_and(Drone::alive))
};
for seed in 1..=10 {
// Just behind the wall, inside the fuse's reach of a missile held at its face.
for reply in [true, false] {
let (first, total, alive) = run(seed, 5.5, reply);
assert_eq!((first, total, alive), (0, 0, true), "seed {seed} reply {reply}: kill through the wall");
}
}
// In front of the wall the fuse still works, with the reply late or on time.
let kills = |reply| (1..=10).filter(|&seed| run(seed, 2.0, reply).1 > 0).count();
assert!(kills(true) > 0 && kills(true) == kills(false), "{} vs {}", kills(true), kills(false));
}
// #42, #43: a bridge deck above the flight is no ground under it
#[test]
fn flight_under_a_bridge_is_not_a_ground_hit() {
let src = Blocks(|_, y, _| Some(y < 64 || y == 140));
let mut sim = Sim::new(1);
sim.damage = false;
let mut ground = Ground::default();
// The height map knows only the deck: 141 everywhere.
ground.answer((-5..400).map(|x| (x, 0, 141)).collect(), 0);
sim.add_drone(DroneKind::Shahed, DVec3::new(0.0, 100.0, 0.0), DVec3::X, DVec3::new(2000.0, 100.0, 0.0));
warm(&mut ground, &src, DVec3::new(0.0, 100.0, 0.0), DVec3::new(300.0, 100.0, 0.0));
for _ in 0..40 {
fly(&mut sim, &mut ground, &src);
}
assert_eq!(sim.drones.len(), 1, "the deck above was taken for the ground");
assert_eq!(sim.crashes, 0);
}
// #43: unknown chunks and spent budgets are never air; changes and another world void the cache
#[test]
fn unknown_is_not_clear_and_changes_invalidate() {
let mut ground = Ground::default();
let (a, b) = (DVec3::new(0.5, 70.5, 0.5), DVec3::new(30.5, 70.5, 0.5));
// Nothing read yet: the segment is unknown and the sections are asked for.
assert!(matches!(ground.world.sweep(a, b, false, 1), world::Sweep::Unknown { .. }));
let asked = ground.world.take_wanted(1);
assert!(asked.contains(&(0, 4, 0)), "{asked:?}");
// The game has no chunk there: still unknown, not air.
let nothing = Blocks(|_, _, _| None);
ground.world.serve(&nothing, asked, 2);
assert!(matches!(ground.world.sweep(a, b, false, 3), world::Sweep::Unknown { .. }));
// A wall appears in a chunk that now is loaded: found after it is read again.
let wall = Blocks(|x, _, _| Some(x == 20));
for now in [70, 71, 72] {
ground.world.sweep(a, b, false, now);
let w = ground.world.take_wanted(now);
ground.world.serve(&wall, w, now);
}
let hit = ground.world.sweep(a, b, false, 73);
assert!(matches!(hit, world::Sweep::Hit { block: [20, 70, 0], normal: [-1, 0, 0], .. }), "{hit:?}");
// The wall is broken: the game says which section changed, and the next sweep asks again.
let open = Blocks(|_, _, _| Some(false));
ground.world.changed(7, false, &[(1, 4, 0)]);
assert!(matches!(ground.world.sweep(a, b, false, 74), world::Sweep::Hit { .. }), "stale data is used until the answer");
let w = ground.world.take_wanted(74);
assert!(w.contains(&(1, 4, 0)), "{w:?}");
ground.world.serve(&open, w, 75);
assert_eq!(ground.world.sweep(a, b, false, 76), world::Sweep::Clear);
// Another world: everything goes.
ground.world.changed(8, false, &[]);
assert!(ground.world.is_empty());
}
// #43: the same wall and tunnel fixtures answer alike by sweep and by sections, and the cache is bounded
#[test]
fn wall_and_tunnel_fixtures_agree() {
let tunnel = Blocks(|x, y, z| Some(y < 60 || (x == 10 && !(y == 70 && z == 0))));
let mut ground = Ground::default();
for z in [0.5, 1.5] {
let (a, b) = (DVec3::new(0.5, 70.5, z), DVec3::new(30.5, 70.5, z));
warm(&mut ground, &tunnel, a, b);
let r = ground.world.sweep(a, b, false, 1);
if z == 0.5 {
assert_eq!(r, world::Sweep::Clear, "the tunnel is open");
} else {
assert!(matches!(r, world::Sweep::Hit { block: [10, 70, 1], .. }), "{r:?}");
}
}
assert!(ground.world.bytes() < 64 * 1024);
}
// #57: a recording keeps the world clock of the raid, with jumps as steps.
#[test]
fn recordings_keep_the_time_of_day() {
let mut sim = Sim::new(1);
sim.day_time = Some(18000);
sim.add_drone(DroneKind::Geran3, DVec3::new(0.0, 80.0, 0.0), DVec3::Z, DVec3::new(0.0, 70.0, 500.0));
let mut g = Ground::default();
let mut out = Out::default();
for i in 0..100u64 {
sim.day_time = Some(if i < 50 { 18000 + i } else { 6000 + i });
sim.step(&mut g, None, &mut out);
}
let r = sim.rec.as_ref().unwrap();
assert_eq!(r.day_time(0.0), Some(18000 + sim_first(r)));
// The jump is a step, not a glide through the day.
for t in 0..r.len.min(100) {
let v = r.day_time(t as f64).unwrap();
assert!((18000..18060).contains(&v) || (6000..6110).contains(&v), "{t}: {v}");
}
}
// #57: a clock missing at the start, in the middle, or later recovering never loses or shifts the samples.
#[test]
fn recorded_clock_survives_gaps() {
let mut sim = Sim::new(1);
sim.add_drone(DroneKind::Geran3, DVec3::new(0.0, 80.0, 0.0), DVec3::Z, DVec3::new(0.0, 70.0, 500.0));
let mut g = Ground::default();
let mut out = Out::default();
for i in 0..100u64 {
// Unknown at the start (ticks 0-19) and for a stretch in the middle (50-69).
sim.day_time = if i < 20 || (50..70).contains(&i) { None } else { Some(1000 + i) };
sim.step(&mut g, None, &mut out);
}
let r = sim.rec.as_ref().unwrap();
assert_eq!(r.day_time(5.0), None, "before the first sample: unknown, not the current time");
let v = r.day_time(30.0).expect("recovered after a missing start");
assert!((1020..1040).contains(&v), "{v}");
// Inside the gap the last known time holds; nothing is invented.
let held = r.day_time(60.0).unwrap();
assert!((1030..1050).contains(&held), "{held}");
// After the gap the new samples land at their own ticks (seek forward and back alike).
let after = r.day_time(85.0).unwrap();
assert!((1080..1100).contains(&after), "{after}");
assert!(r.day_time(30.0).unwrap() < r.day_time(85.0).unwrap());
assert!(r.day_time(85.0).unwrap() > r.day_time(60.0).unwrap());
assert!(r.day_time(25.0).unwrap() <= r.day_time(35.0).unwrap());
}
// #56: a replay's fires and blasts are pure functions of its time: they end with their life,
// merge as the live ones do, and a seek back shows the same frame.
#[test]
fn replay_fires_and_blasts_follow_the_recording() {
use record::{Event, EventKind};
let mut sim = Sim::new(1);
sim.day_time = Some(1000);
sim.add_drone(DroneKind::Geran3, DVec3::new(0.0, 80.0, 0.0), DVec3::Z, DVec3::new(0.0, 70.0, 500.0));
let mut g = Ground::default();
let mut out = Out::default();
sim.step(&mut g, None, &mut out);
let mut rec = sim.rec.take().unwrap();
let at = DVec3::new(5.0, 70.0, 5.0);
let crash = |power| EventKind::Crash { drone: 1, kind: DroneKind::Geran3, power };
let life = fires::life_for(1);
rec.events = vec![
Event { at: 10, pos: at, kind: crash(1) },
// The same spot while it burns: one fire, burning longer.
Event { at: 500, pos: at + DVec3::X, kind: crash(3) },
// The same spot long after it died: a new fire.
Event { at: 500 + 4000 + fires::life_for(3), pos: at, kind: crash(1) },
];
assert!(record::replay_fires(&rec, 0.0).is_empty(), "before the first event");
let f = record::replay_fires(&rec, 600.0);
assert_eq!(f.len(), 1);
assert_eq!(f[0].start, 10);
assert!(f[0].life >= 490 + fires::life_for(3) && f[0].size >= fires::size_for(3), "fed: {:?}", f[0]);
// Past its life it is gone: no flames, no smoke.
let end = f[0].start + f[0].life;
assert!(record::replay_fires(&rec, end as f64 - 1.0).len() == 1);
assert!(record::replay_fires(&rec, end as f64 + 1.0).is_empty());
assert_eq!(fires::replay_fire(1, life as f64 + 10.0), (0.0, 0.0));
assert_eq!(fires::replay_fire(1, 1.0e5), (0.0, 0.0));
assert!(fires::replay_fire(1, 100.0).1 > 0.0);
// The later event lights a new one.
let f = record::replay_fires(&rec, (500 + 4000 + fires::life_for(3) + 5) as f64);
assert_eq!(f.len(), 1);
assert_eq!(f[0].start, 500 + 4000 + fires::life_for(3));
// Seeking back and forth gives the same blasts.
let a = record::replay_blasts(&rec, 20.0);
let _ = record::replay_blasts(&rec, 510.0);
let b = record::replay_blasts(&rec, 20.0);
assert_eq!(a.len(), 1);
assert_eq!((a[0].id, a[0].start, a[0].life), (b[0].id, b[0].start, b[0].life));
assert!(record::replay_blasts(&rec, 5.0).is_empty());
assert!(record::replay_blasts(&rec, 10.0 + a[0].life as f64).is_empty(), "burnt out");
}
// #78: a replay's smoke is rebuilt from the recording as a function of its time: trails behind
// what flew (clearing the fog), a column over the fire, billows that outlive the fireball; a seek
// shows what playing through shows, and the live Sim is not touched.
// #76: the protected objects of a replay are the recording's, not the live marks.
#[test]
fn a_replay_draws_the_recorded_objects_not_the_live_ones() {
let mut sim = Sim::new(1);
sim.targets.push(DVec3::new(0.0, 70.0, 0.0));
sim.target_health.push(100.0);
sim.add_drone(DroneKind::Geran3, DVec3::new(0.0, 80.0, 0.0), DVec3::Z, DVec3::new(0.0, 70.0, 500.0));
let mut g = Ground::default();
let mut out = Out::default();
sim.step(&mut g, None, &mut out);
let rec = sim.rec.take().unwrap();
assert_eq!(rec.objects.len(), 1);
// The live mark is moved away after the raid; the replay still shows it where it was.
sim.targets[0] = DVec3::new(900.0, 70.0, 900.0);
let mut shown = Out::default();
sim.draw_ghosts(&rec, 0.0, 0x1000, &mut shown);
let marks: Vec<_> = shown.models.iter().filter(|m| m.name == "airdef:target").collect();
assert_eq!(marks.len(), 1);
assert!(marks[0].at.x.abs() < 1.0 && marks[0].at.z.abs() < 1.0, "{:?}", marks[0].at);
}
fn sim_first(r: &record::Recording) -> u64 {
r.tracks.first().map_or(0, |t| t.first as u64)
}
// #52: a crash lights a fire; the oldest of too many dies down; water leaves steam only.
#[test]
fn fires_start_merge_and_are_limited() {
let mut sim = Sim::new(1);
sim.ignite(DVec3::new(0.0, 70.0, 0.0), 1, None);
sim.ignite(DVec3::new(2.0, 70.0, 0.0), 2, None);
assert_eq!(sim.fires.len(), 1, "one fire where two things fell");
assert!(sim.fires.list[0].size >= fires::size_for(2));
for i in 0..30 {
sim.tick += 10;
sim.ignite(DVec3::new(100.0 * (i + 1) as f64, 70.0, 0.0), 1, None);
}
let now = sim.tick;
// Past the limit the oldest are already dying down.
let healthy = sim.fires.list.iter().filter(|f| (now - f.start) + 160 < f.life as u64).count();
assert!(healthy <= fires::MAX, "{healthy}");
let (fl, sm) = fires::level(10.0, 3000.0);
assert!(fl > 0.0 && sm > 0.0);
assert_eq!(fires::level(3000.0, 3000.0).0, 0.0);
}
// ---- flight over the land: strike drones follow it, launchers keep their missiles for shots the land allows,
// interceptors keep clear of it. A synthetic land (a height function) stands in for the game.
/// Land from a height function: blocks below it are solid, and the game's height map says the same.
struct Land<F: Fn(i32, i32) -> i32>(F);
impl<F: Fn(i32, i32) -> i32> world::WorldSource for Land<F> {
fn solid(&self, x: i32, y: i32, z: i32) -> Option<bool> {
Some(y < (self.0)(x, z))
}
}
/// What the game answers a tick after the sim asked: land heights, and the sections it needs.
fn answer_land<F: Fn(i32, i32) -> i32>(ground: &mut Ground, land: &Land<F>, now: u64) {
let columns = ground.take_wanted().into_iter().map(|(x, z)| (x, z, (land.0)(x, z))).collect();
ground.answer(columns, now);
let sections = ground.world.take_wanted(now);
ground.world.serve(land, sections, now);
}
/// A ridge across the way, `rise` blocks high over a flat at 64, `half` blocks of slope to each side, crest at x=300.
fn ridge(rise: i32, half: i32) -> Land<impl Fn(i32, i32) -> i32> {
ridge_at(300, rise, half)
}
fn ridge_at(crest: i32, rise: i32, half: i32) -> Land<impl Fn(i32, i32) -> i32> {
Land(move |x, _| 64 + (rise - (x - crest).abs() * rise / half).max(0))
}
/// Flies one drone of `kind` along +x over `land` from x=0 to its aim at x=750, to the end. Returns the sim.
fn fly_over<F: Fn(i32, i32) -> i32>(kind: DroneKind, land: &Land<F>, low: bool) -> Sim {
let mut sim = Sim::new(5);
sim.damage = false;
let mut ground = Ground::default();
let spec = kind.spec();
let height = if spec.cruise > 0.0 { spec.cruise } else { 25.0 };
sim.add_drone(kind, DVec3::new(0.0, 64.0 + height, 0.0), DVec3::X, DVec3::new(750.0, 64.0, 0.0));
sim.drones[0].low = low;
for _ in 0..4 {
sim.tick += 1;
answer_land(&mut ground, land, sim.tick);
}
let mut out = Out::default();
for _ in 0..4000 {
if sim.drones.is_empty() {
break;
}
sim.tick += 1;
sim.step_drones(&mut ground, None, &mut out);
answer_land(&mut ground, land, sim.tick);
}
sim
}
// Strike drones climb over a hill they could not have seen 20 blocks ahead.
#[test]
fn a_raid_crossing_a_hill_flies_over_it() {
for (rise, half) in [(60, 100), (60, 60), (90, 120)] {
let land = ridge(rise, half);
for kind in [DroneKind::Shahed, DroneKind::Geran3, DroneKind::Lancet, DroneKind::Gerbera, DroneKind::Kh101, DroneKind::Kalibr, DroneKind::Kh22, DroneKind::Zircon] {
for low in [false, true] {
let sim = fly_over(kind, &land, low);
assert!(sim.drones.is_empty(), "{kind:?}: still flying");
// It bursts at its aim, not on the hill 450 blocks short of it (where a crash or a decoy's end shows too).
let at = sim.blasts.first().expect("no blast").at;
assert!(at.x > 700.0, "{kind:?} low={low} burst at x={:.0} over a {rise}-block hill ({half} to the crest)", at.x);
}
}
}
}
// A low drone hugs the land: it does not fly at cruise height over the flat behind the ridge.
#[test]
fn a_low_drone_hugs_the_land_after_the_ridge() {
let land = ridge(60, 100);
let mut sim = Sim::new(5);
sim.damage = false;
let mut ground = Ground::default();
sim.add_drone(DroneKind::Shahed, DVec3::new(0.0, 75.0, 0.0), DVec3::X, DVec3::new(900.0, 64.0, 0.0));
sim.drones[0].low = true;
let mut out = Out::default();
let mut highest_after = 0.0f64;
for _ in 0..4000 {
if sim.drones.is_empty() {
break;
}
sim.tick += 1;
sim.step_drones(&mut ground, None, &mut out);
answer_land(&mut ground, &land, sim.tick);
if let Some(d) = sim.drones.first()
&& d.pos.x > 560.0
&& d.pos.x < 800.0
{
highest_after = highest_after.max(d.pos.y);
}
}
assert_eq!(sim.crashes, 0);
assert!(highest_after < 64.0 + 40.0, "still {highest_after:.0} high far behind the ridge");
}
/// A target flying slowly along the ridge's far side at `x`, with a radar on the crest to see it.
fn behind_ridge(land: &Land<impl Fn(i32, i32) -> i32>, crest: i32, kind: SamKind, x: f64, ticks: u32) -> Sim {
let mut sim = Sim::new(9);
sim.damage = false;
let ground_y = (land.0)(0, 0) as f64;
let id = sim.id();
sim.sams.push(Sam::new(id, kind, DVec3::new(0.0, ground_y, 0.0), 0.0));
let id = sim.id();
sim.sams.push(Sam::new(id, SamKind::Radar, DVec3::new(f64::from(crest), (land.0)(crest, 0) as f64, -60.0), 0.0));
let mut ground = Ground::default();
let mut out = Out::default();
for _ in 0..40 {
sim.tick += 1;
answer_land(&mut ground, land, sim.tick);
}
// It crawls along the far side of the ridge, 20 blocks over the land.
let there = (land.0)(x as i32, 0) as f64;
sim.add_drone(DroneKind::Shahed, DVec3::new(x, there + 20.0, -100.0), DVec3::Z, DVec3::new(x, there, 3000.0));
for _ in 0..ticks {
sim.step(&mut ground, None, &mut out);
answer_land(&mut ground, land, sim.tick);
}
sim
}
// A short-range system behind a hill does not fire at a target the hill masks from it, whatever the network says.
#[test]
fn a_system_does_not_fire_at_a_target_masked_by_a_hill() {
// A short-range system 250 blocks short of the crest, the target 80 blocks beyond it.
type Boxed = Land<Box<dyn Fn(i32, i32) -> i32>>;
let hill: Boxed = Land(Box::new(|x, _| 64 + (60 - (x - 150).abs()).max(0)));
let flat: Boxed = Land(Box::new(|_, _| 64));
let shot = |land: &Boxed| {
let mut sim = Sim::new(9);
sim.damage = false;
let id = sim.id();
sim.sams.push(Sam::new(id, SamKind::Tor, DVec3::new(100.0, 64.0, 0.0), 0.0));
let id = sim.id();
sim.sams.push(Sam::new(id, SamKind::Radar, DVec3::new(150.0, (land.0)(150, 0) as f64, -60.0), 0.0));
let mut ground = Ground::default();
let mut out = Out::default();
for _ in 0..40 {
sim.tick += 1;
answer_land(&mut ground, land, sim.tick);
}
sim.add_drone(DroneKind::Gerbera, DVec3::new(200.0, 84.0, -60.0), DVec3::Z, DVec3::new(200.0, 64.0, 3000.0));
let mut masked = 0u32;
let mut seen = false;
let mut told = false;
for _ in 0..300 {
sim.step(&mut ground, None, &mut out);
answer_land(&mut ground, land, sim.tick);
seen |= sim.sams[0].seen.len() == 1;
masked += sim.sams[0].terrain.values().filter(|(_, b)| *b == terrain::MASKED).count() as u32;
// `why` names the reason while the verdict stands.
told |= sim.why(0).iter().any(|c| !c.ok && c.text.contains("за рельефом"));
}
(sim, seen, masked, told)
};
let (open, seen, _, _) = shot(&flat);
assert!(seen && open.sams[0].fired > 0, "control: with a clear line the Tor must fire (seen {seen}, fired {})", open.sams[0].fired);
let (shut, seen, masked, told) = shot(&hill);
assert!(seen, "the network must have shown the target to the Tor");
assert_eq!(shut.sams[0].fired, 0, "the Tor fired at a target behind the hill");
assert!(masked > 0, "no launch was judged and refused for the land");
assert_eq!(shut.tally.blocked + shut.tally.scrubbed, 0);
assert!(told, "`why` never named the land as the reason");
}
// A long-range system lofts over a low ridge (its interceptor does not hit it) but not over a mountain.
#[test]
fn a_long_range_system_lofts_over_a_ridge_or_waits() {
let low = ridge_at(150, 25, 60);
let sim = behind_ridge(&low, 150, SamKind::IrisT, 230.0, 300);
assert!(sim.sams[0].fired > 0, "an IRIS-T SLM must loft over a 25-block ridge");
assert_eq!(sim.tally.blocked + sim.tally.scrubbed, 0, "its interceptors hit the ridge");
let tall = ridge_at(150, 400, 100);
// The track is fresh only for a while (the target creeps out of reach): the reason is read at several moments.
let mut told = false;
for ticks in (150..=300).step_by(10) {
let sim = behind_ridge(&tall, 150, SamKind::IrisT, 230.0, ticks);
assert_eq!(sim.sams[0].fired, 0, "no missile can climb a 400-block mountain at this range");
assert_eq!(sim.tally.blocked + sim.tally.scrubbed, 0);
told |= sim.why(0).iter().any(|c| !c.ok && c.text.contains("рельеф"));
}
assert!(told, "`why` never named the land as the reason");
}
// Interceptors fly over rolling land at a target low over it, and none ends in the ground.
#[test]
fn an_interceptor_over_rolling_land_does_not_hit_the_ground() {
let rolling = Land(|x: i32, z: i32| 70 + (28.0 * ((f64::from(x) / 21.0).sin() + (f64::from(z) / 33.0).cos()) / 2.0).round() as i32);
for kind in [MissileKind::Pac3, MissileKind::M48n6, MissileKind::IrisT, MissileKind::M57e6, MissileKind::M9m338, MissileKind::P1sun] {
for dx in [160.0, 260.0, 380.0] {
let mut sim = Sim::new(4);
sim.damage = false;
let mut ground = Ground::default();
let mut out = Out::default();
for _ in 0..4 {
sim.tick += 1;
answer_land(&mut ground, &rolling, sim.tick);
}
// A Gerbera 20 blocks over the land at the far end, flying away from the launcher.
let at = DVec3::new(dx, (rolling.0)(dx as i32, 0) as f64 + 20.0, 0.0);
sim.add_drone(DroneKind::Gerbera, at, DVec3::X, DVec3::new(5000.0, 64.0, 0.0));
let (target, id) = (sim.drones[0].id, sim.id());
let from = DVec3::new(0.0, (rolling.0)(0, 0) as f64 + 3.0, 0.0);
sim.drones[0].engaged = 1;
sim.missiles.push(Missile { id, kind, pos: from, vel: DVec3::new(0.5, 0.8, 0.0).normalize() * 0.8, target, sam: 0, age: 0, hold: 0, fix: Some(fix_of(&sim.drones[0], sim.tick)), seen: 0 });
for _ in 0..500 {
if sim.missiles.is_empty() {
break;
}
sim.tick += 1;
sim.step_missiles(&mut ground, &mut out);
answer_land(&mut ground, &rolling, sim.tick);
}
assert!(sim.missiles.is_empty(), "{kind:?} at {dx}: still flying");
assert_eq!(sim.tally.blocked, 0, "{kind:?} at {dx}: the interceptor hit the ground");
}
}
}
/// The fix a link would have given on a target (a test stand-in for the firing system's track; these tests keep their targets still).
fn fix_of(d: &Drone, tick: u64) -> Fix {
Fix { pos: d.pos, vel: DVec3::ZERO, tick, seeker: false }
}
// An interceptor with no way over the land blows up in the air, and that is counted.
#[test]
fn an_interceptor_without_a_way_over_blows_up_high() {
let wall = ridge(300, 40);
let mut sim = Sim::new(4);
sim.damage = false;
let mut ground = Ground::default();
let mut out = Out::default();
for _ in 0..4 {
sim.tick += 1;
answer_land(&mut ground, &wall, sim.tick);
}
sim.add_drone(DroneKind::Gerbera, DVec3::new(420.0, 90.0, 0.0), DVec3::X, DVec3::new(5000.0, 64.0, 0.0));
let (target, id) = (sim.drones[0].id, sim.id());
sim.drones[0].engaged = 1;
sim.missiles.push(Missile { id, kind: MissileKind::M57e6, pos: DVec3::new(230.0, 70.0, 0.0), vel: DVec3::X * 3.0, target, sam: 0, age: 20, hold: 0, fix: Some(fix_of(&sim.drones[0], sim.tick)), seen: 0 });
for _ in 0..200 {
if sim.missiles.is_empty() {
break;
}
sim.tick += 1;
sim.step_missiles(&mut ground, &mut out);
answer_land(&mut ground, &wall, sim.tick);
}
assert!(sim.missiles.is_empty());
assert_eq!((sim.tally.scrubbed, sim.tally.blocked), (1, 0), "{:?}", sim.tally);
assert_eq!(sim.drones[0].engaged, 0);
let at = sim.blasts.first().expect("no blast").at;
assert!(at.y > (wall.0)(at.x as i32, 0) as f64 + 3.0, "it burst in the ground at {at:?}");
}
// ---- #61-#66, #69 (review of 6f3fc64): unread blocks, cache lifetime, exact shapes, heights, accuracy, guidance, fires
/// Blocks with finer shapes: `shape` gives the boxes of a block (none: from `solid`).
struct Shaped<S: Fn(i32, i32, i32) -> Option<Vec<[f64; 6]>>>(S);
impl<S: Fn(i32, i32, i32) -> Option<Vec<[f64; 6]>>> world::WorldSource for Shaped<S> {
fn solid(&self, x: i32, y: i32, z: i32) -> Option<bool> {
(self.0)(x, y, z).map(|b| !b.is_empty())
}
fn boxes(&self, x: i32, y: i32, z: i32) -> Vec<[f64; 6]> {
(self.0)(x, y, z).unwrap_or_default()
}
}
// #61: a flight on a cold cache does not cross the wall before the game has answered
#[test]
fn cold_cache_never_means_clear_for_a_drone() {
let src = Blocks(|x, y, z| Some(y < 64 || (x == 3 && (64..141).contains(&y) && z.abs() < 50)));
let mut sim = Sim::new(1);
sim.damage = false;
let mut ground = Ground::default();
sim.add_drone(DroneKind::Zircon, DVec3::new(0.0, 100.0, 0.0), DVec3::X, DVec3::new(1000.0, 100.0, 0.0));
sim.drones[0].flight = Flight::Dive;
// No warm-up: nothing is read. One tick: the drone has not gone through, and asks for the sections.
sim.tick += 1;
sim.step_drones(&mut ground, None, &mut Out::default());
assert!(sim.drones[0].pos.x < 3.5, "crossed the wall unread: x = {}", sim.drones[0].pos.x);
assert!(ground.world.stats.held > 0);
let mut hit_wall = false;
for _ in 0..8 {
let wanted = ground.world.take_wanted(sim.tick);
ground.world.serve(&src, wanted, sim.tick);
sim.tick += 1;
sim.step_drones(&mut ground, None, &mut Out::default());
assert!(sim.drones.first().is_none_or(|d| d.pos.x < 3.5), "crossed the wall");
hit_wall |= sim.drones.is_empty();
}
assert!(hit_wall && sim.crashes == 1, "the wall was not found once read: crashes {}", sim.crashes);
}
// #61: nor does an interceptor fuse past the unread part of its way
#[test]
fn cold_cache_does_not_fuse_through_a_wall() {
let src = Blocks(|x, y, z| Some(y < 64 || (x == 3 && (64..141).contains(&y) && z.abs() < 50)));
let mut sim = Sim::new(1);
let mut ground = Ground::default();
sim.add_drone(DroneKind::Shahed, DVec3::new(8.5, 100.0, 0.0), DVec3::ZERO, DVec3::new(0.0, 100.0, 900.0));
sim.drones[0].vel = DVec3::ZERO;
sim.drones[0].engaged = 1;
let (target, id) = (sim.drones[0].id, sim.id());
let fix = Some(fix_of(&sim.drones[0], 0));
sim.missiles.push(Missile { id, kind: MissileKind::Pac3, pos: DVec3::new(0.0, 100.0, 0.0), vel: DVec3::X * 3.0, target, sam: 0, age: 20, hold: 0, fix, seen: 0 });
sim.tick = 1;
sim.step_missiles(&mut ground, &mut Out::default());
assert_eq!(sim.missiles.len(), 1, "the missile went on (held) at the edge of what is read");
assert!(sim.missiles[0].pos.x < 3.5 && sim.kills == 0 && sim.tally.missed == 0, "fused unread");
for _ in 0..6 {
let wanted = ground.world.take_wanted(sim.tick);
ground.world.serve(&src, wanted, sim.tick);
sim.tick += 1;
sim.step_missiles(&mut ground, &mut Out::default());
}
assert!(sim.missiles.is_empty() && sim.kills == 0 && sim.tally.blocked == 1, "{:?}", sim.tally);
}
// #61: a chunk the game does not have holds a low flight for a bounded time, then it goes on; a section
// that was queued as a look-ahead moves up when a step needs it, even with the queue full.
#[test]
fn unknown_policy_is_bounded_and_urgent_sections_jump_the_queue() {
let none = Blocks(|_, _, _| None);
let mut ground = Ground::default();
// Ground known: the flight is 5 blocks over it, so it is low.
ground.answer((-40..400).map(|x| (x, 0, 64)).collect(), 0);
let mut sim = Sim::new(1);
sim.damage = false;
sim.add_drone(DroneKind::Shahed, DVec3::new(0.0, 69.0, 0.0), DVec3::X, DVec3::new(5000.0, 69.0, 0.0));
let mut held = 0;
for _ in 0..900 {
let before = sim.drones[0].pos;
sim.tick += 1;
sim.step_drones(&mut ground, None, &mut Out::default());
let wanted = ground.world.take_wanted(sim.tick);
ground.world.serve(&none, wanted, sim.tick);
held += (sim.drones[0].pos.distance(before) < 1e-3) as u32;
}
assert!(held > 20, "an unloaded chunk was no obstacle at all ({held})");
assert!(sim.drones[0].pos.x > 100.0, "held for ever at x = {}", sim.drones[0].pos.x);
// Urgent over ahead.
let mut w = world::WorldCache::default();
for i in 0..600 {
w.look_ahead(DVec3::new(f64::from(i) * 16.0, 70.0, 0.0), DVec3::X, 1.0, 1);
}
w.sweep(DVec3::new(500.5, 70.5, 0.5), DVec3::new(501.5, 70.5, 0.5), false, 1);
let first = w.take_wanted(1);
assert!(first.contains(&(31, 4, 0)), "the section a step needs waited behind look-aheads");
}
// #62: a chunk's unload or replacement voids what was known; light-only changes cost nothing
#[test]
fn chunk_replacement_and_age_invalidate_known_sections() {
let mut w = world::WorldCache::default();
let (a, b) = (DVec3::new(0.5, 70.5, 0.5), DVec3::new(30.5, 70.5, 0.5));
let empty = Blocks(|_, _, _| Some(false));
let wall = Blocks(|x, _, _| Some(x == 20));
w.changed_rev(1, 10, false, &[]);
for t in 1..4 {
w.sweep(a, b, false, t);
let q = w.take_wanted(t);
w.serve(&empty, q, t);
}
assert_eq!(w.sweep(a, b, false, 5), world::Sweep::Clear);
// Light only: the counter does not move, nothing happens.
let n = w.stats.invalidated;
w.changed_rev(1, 10, false, &[]);
assert_eq!(w.stats.invalidated, n, "a light update invalidated the cache");
// The chunk is replaced by a packet with a wall: the game says which column (unload/replace note).
w.columns_changed(&[(0, 0), (1, 0)]);
assert!(matches!(w.sweep(a, b, false, 6), world::Sweep::Unknown { pending: true, .. }), "an unloaded chunk still answers from memory");
for t in 7..10 {
w.sweep(a, b, false, t);
let q = w.take_wanted(t);
w.serve(&wall, q, t);
}
assert!(matches!(w.sweep(a, b, false, 10), world::Sweep::Hit { block: [20, 70, 0], .. }));
// A replacement nobody listed: the counter moved with no section named; everything read is read again.
w.changed_rev(1, 11, false, &[]);
assert!(matches!(w.sweep(a, b, false, 11), world::Sweep::Hit { .. }), "old data is used until the answer");
let q = w.take_wanted(11);
assert!(!q.is_empty());
w.serve(&empty, q, 12);
assert_eq!(w.sweep(a, b, false, 13), world::Sweep::Clear);
// And a section in use is read again after a while, told of or not.
for t in (14..14 + 1300).step_by(100) {
w.sweep(a, b, false, t);
}
assert!(w.stats.aged > 0 && !w.take_wanted(1400).is_empty(), "never read again");
// Another world, as ever.
w.changed_rev(2, 0, false, &[]);
assert!(w.is_empty());
}
// #63: a lower slab does not stop what flies over its free half; a full wall and the slab's own half do
#[test]
fn partial_blocks_use_exact_shapes() {
let slab = Shaped(|x, y, z| {
if y < 64 {
Some(vec![[0.0, 0.0, 0.0, 1.0, 1.0, 1.0]])
} else if (x, y, z) == (10, 70, 0) {
Some(vec![[0.0, 0.0, 0.0, 1.0, 0.5, 1.0]])
} else if x == 20 && z == 0 {
Some(vec![[0.0, 0.0, 0.0, 1.0, 1.0, 1.0]])
} else {
Some(vec![])
}
});
let mut g = Ground::default();
let run = |g: &mut Ground, a: DVec3, b: DVec3| {
let mut r = g.trace(7, a, b, false, 1);
for t in 1..8 {
let q = g.world.take_wanted(t);
g.world.serve(&slab, q, t);
if !matches!(r, world::Sweep::Wait { .. } | world::Sweep::Unknown { .. }) {
break;
}
r = g.trace(7, a, b, false, t + 1);
}
r
};
// The free upper half: clear (and the wall at x=20 is further on than the segment).
let r = run(&mut g, DVec3::new(9.5, 70.75, 0.5), DVec3::new(11.5, 70.75, 0.5));
assert_eq!(r, world::Sweep::Clear, "the free half of a slab was an obstacle");
// Through the slab's own half: a hit where its shape starts.
let mut g = Ground::default();
let r = run(&mut g, DVec3::new(9.5, 70.25, 0.5), DVec3::new(11.5, 70.25, 0.5));
let world::Sweep::Hit { point, normal, block, .. } = r else { panic!("{r:?}") };
assert!((point.x - 10.0).abs() < 1e-9 && normal == [-1, 0, 0] && block == [10, 70, 0], "{r:?}");
// A full-size wall is a hit at its face, like the section's cube.
let mut g = Ground::default();
let r = run(&mut g, DVec3::new(18.5, 70.5, 0.5), DVec3::new(22.5, 70.5, 0.5));
assert!(matches!(r, world::Sweep::Hit { block: [20, 70, 0], normal: [-1, 0, 0], .. }), "{r:?}");
// The flight holds at the face meanwhile (a pending answer is a wait, not a pass).
let mut g = Ground::default();
let w = world::WorldCache::default();
drop(w);
for t in 0..4 {
let q = g.world.take_wanted(t);
g.world.serve(&slab, q, t);
g.world.sweep(DVec3::new(18.5, 70.5, 0.5), DVec3::new(22.5, 70.5, 0.5), false, t);
}
let first = g.trace(9, DVec3::new(18.5, 70.5, 0.5), DVec3::new(22.5, 70.5, 0.5), false, 5);
assert!(matches!(first, world::Sweep::Wait { .. }), "{first:?}");
// Never answered: after the wait the solid cube of the sections stands.
let last = g.trace(9, DVec3::new(18.5, 70.5, 0.5), DVec3::new(22.5, 70.5, 0.5), false, 5 + 31);
assert!(matches!(last, world::Sweep::Hit { .. }), "{last:?}");
}
// #73: an exact answer belongs to the segment and the geometry it was asked of
#[test]
fn exact_answers_are_not_reused_for_other_segments_or_changed_blocks() {
use std::cell::Cell;
let cube = Cell::new(false);
let world = Shaped(|x, y, z| {
if y < 64 {
Some(vec![[0.0, 0.0, 0.0, 1.0, 1.0, 1.0]])
} else if (x, y, z) == (10, 70, 0) {
Some(vec![[0.0, 0.0, 0.0, 1.0, if cube.get() { 1.0 } else { 0.5 }, 1.0]])
} else {
Some(vec![])
}
});
let mut g = Ground::default();
g.world.changed(1, false, &[]);
let (upper_a, upper_b) = (DVec3::new(9.5, 70.75, 0.5), DVec3::new(11.5, 70.75, 0.5));
let (lower_a, lower_b) = (DVec3::new(9.5, 70.25, 0.5), DVec3::new(11.5, 70.25, 0.5));
// Settles a step: waits for the game's answers the way a held flight does.
let now = Cell::new(1u64);
let settle = |g: &mut Ground, a: DVec3, b: DVec3| {
let mut r = g.trace(7, a, b, false, now.get());
for _ in 0..8 {
if !matches!(r, world::Sweep::Wait { .. } | world::Sweep::Unknown { .. }) {
break;
}
let q = g.world.take_wanted(now.get());
g.world.serve(&world, q, now.get());
now.set(now.get() + 1);
r = g.trace(7, a, b, false, now.get());
}
r
};
assert_eq!(settle(&mut g, upper_a, upper_b), world::Sweep::Clear);
// The same flight and block, the other half of the slab: not the old Clear, a new question.
now.set(now.get() + 1);
let r = g.trace(7, lower_a, lower_b, false, now.get());
assert!(matches!(r, world::Sweep::Wait { .. }), "old Clear reused through the slab: {r:?}");
let r = settle(&mut g, lower_a, lower_b);
assert!(matches!(r, world::Sweep::Hit { block: [10, 70, 0], .. }), "{r:?}");
// A repeat of the same step is served from memory and agrees with a fresh ask.
let r2 = g.trace(7, lower_a, lower_b, false, now.get() + 1);
assert_eq!(r, r2);
// The slab becomes a full cube: the section is read again, the old Clear is void.
assert_eq!(settle(&mut g, upper_a, upper_b), world::Sweep::Clear, "the upper step is clear again over the slab");
cube.set(true);
g.world.changed(1, false, &[(0, 4, 0)]);
now.set(now.get() + 1);
let r = g.trace(7, upper_a, upper_b, false, now.get());
assert!(!matches!(r, world::Sweep::Clear), "Clear survived the block's replacement: {r:?}");
// Not the cache's data but the game's: the section is read and the ray asked again.
let r = settle(&mut g, upper_a, upper_b);
assert!(matches!(r, world::Sweep::Hit { block: [10, 70, 0], .. }), "{r:?}");
// A late reply to a question asked before a change does not touch the new world.
cube.set(false);
let mut h = Ground::default();
h.world.changed(1, false, &[]);
h.world.sweep(upper_a, upper_b, false, 1);
let q = h.world.take_wanted(1);
h.world.serve(&world, q, 1);
let r = h.trace(8, upper_a, upper_b, false, 2);
assert!(matches!(r, world::Sweep::Wait { .. }), "{r:?}");
let old = h.world.take_rays(2);
assert_eq!(old.len(), 1);
cube.set(true);
h.world.changed(1, false, &[(0, 4, 0)]);
h.world.serve_rays(&world, old);
let r = h.trace(8, upper_a, upper_b, false, 3);
assert!(!matches!(r, world::Sweep::Clear), "a late reply was applied to the new world: {r:?}");
// The wait is bounded however often the question is asked again.
let mut h = Ground::default();
h.world.changed(1, false, &[]);
h.world.sweep(upper_a, upper_b, false, 1);
let q = h.world.take_wanted(1);
h.world.serve(&world, q, 1);
let mut last = world::Sweep::Clear;
for i in 0..60u64 {
let dy = DVec3::new(0.0, 0.001 * i as f64, 0.0);
last = h.trace(9, lower_a + dy, lower_b + dy, false, 2 + i);
}
assert!(matches!(last, world::Sweep::Hit { .. }), "a flight changing course every tick hangs: {last:?}");
}
// #64: heights known are old when blocks change, a chunk is replaced, or time passes; they are asked again
#[test]
fn land_heights_follow_world_changes_and_age() {
let mut g = Ground::default();
g.answer(vec![(4, 4, 150)], 0);
assert_eq!(g.land(DVec3::new(4.0, 70.0, 4.0)), Some(150.0));
assert!(g.take_wanted().is_empty(), "a fresh height was asked again");
// The mountain is blown away: the game says which section changed.
g.world.changed(1, false, &[(0, 9, 0)]);
g.answer(vec![], 10);
assert_eq!(g.land(DVec3::new(4.0, 70.0, 4.0)), Some(150.0), "the old height is used until the new one comes");
assert!(g.take_wanted().contains(&(4, 4)), "a changed column was not asked again");
g.answer(vec![(4, 4, 70)], 11);
assert_eq!(g.land(DVec3::new(4.0, 70.0, 4.0)), Some(70.0));
// Another column is not touched by that.
g.answer(vec![(100, 100, 90)], 12);
g.world.changed(1, false, &[(0, 9, 0)]);
g.answer(vec![], 13);
assert_eq!(g.land(DVec3::new(100.0, 70.0, 100.0)), Some(90.0));
assert!(!g.take_wanted().contains(&(100, 100)));
// Time alone: the issue's case, 500 seconds on.
g.answer(vec![], 10_000);
assert_eq!(g.land(DVec3::new(4.0, 70.0, 4.0)), Some(70.0));
assert!(g.take_wanted().contains(&(4, 4)), "never asked again after 500 s");
// A planner column too.
let mut g = Ground::default();
g.answer(vec![(20, 20, 80)], 0);
g.answer(vec![], 5000);
assert_eq!(g.column(20, 20, 1000), Some(Some(80.0)));
assert!(g.take_wanted().contains(&(20, 20)));
// A chunk replaced.
let mut g = Ground::default();
g.answer(vec![(4, 4, 150), (8, 8, 150)], 0);
g.world.columns_changed(&[(0, 0)]);
g.answer(vec![], 1);
g.land(DVec3::new(4.0, 70.0, 4.0));
assert!(g.take_wanted().contains(&(4, 4)));
}
// #65: manual launches and raids draw their navigation error the same way, and a retarget keeps it
#[test]
fn raids_have_the_same_strike_accuracy_as_manual_launches() {
for kind in [DroneKind::Kh101, DroneKind::Shahed, DroneKind::Zircon] {
let (cep, gross) = kind.accuracy();
let aim = DVec3::new(0.0, 70.0, 1000.0);
let mut sim = Sim::new(1);
let (mut manual, mut raid) = (Vec::new(), Vec::new());
for _ in 0..600 {
sim.add_drone(kind, DVec3::new(0.0, 120.0, 0.0), DVec3::Z, aim);
let d = sim.drones.pop().unwrap();
manual.push(horizontal(d.target - aim).length());
let d = sim.make_drone(kind, DVec3::new(0.0, 120.0, 0.0), DVec3::Z, aim);
raid.push(horizontal(d.target - aim).length());
assert!(d.err != DVec3::ZERO && (d.target - aim - d.err).length() < 1e-9);
}
for (name, v) in [("manual", &mut manual), ("raid", &mut raid)] {
v.sort_by(f64::total_cmp);
let median = v[v.len() / 2];
assert!((median - cep).abs() < cep * 0.25, "{kind:?} {name}: median {median} for a CEP of {cep}");
let far = v.iter().filter(|r| **r > MISS_RADIUS).count() as f64 / v.len() as f64;
assert!(far > gross * 0.4 && far < gross * 2.5 + 0.02, "{kind:?} {name}: {far} beyond the blast for a fault rate of {gross}");
}
}
// A planned raid's drones all carry their error, and a smart retarget gives the new aim on top of it.
let mut sim = Sim::new(3);
let id = sim.id();
sim.sams.push(Sam::new(id, SamKind::Ew, DVec3::new(0.0, 70.0, 900.0), 0.0));
sim.intel.revealed.push((id, DVec3::new(0.0, 70.0, 900.0), 0));
let order = RaidOrder { kinds: vec![(DroneKind::Zircon, 20)], approach: Approach::Sector, dist: 400.0, aim: Aim::Objects };
sim.order_raid(&order, &[DVec3::new(0.0, 70.0, 0.0)], &mut Out::default());
// (The raid's launch sites need land the game has told of: a flat one is served.)
let (mut ground, land) = (Ground::default(), sites::testland::Land(|_, _| 64));
for _ in 0..40 {
if !sim.planning() {
break;
}
sites::testland::step(&mut sim, &mut ground, &land, &mut Out::default());
}
assert!(!sim.queue.is_empty() && sim.queue.iter().all(|(_, d)| d.err != DVec3::ZERO), "raid drones without a navigation error");
let mut d = sim.make_drone(DroneKind::Zircon, DVec3::new(0.0, 100.0, -500.0), DVec3::Z, DVec3::new(0.0, 70.0, 0.0));
let err = d.err;
d.smart = true;
sim.queue.clear();
sim.queue.push((0, d));
sim.tick = 40;
sim.adapt();
let d = &sim.queue[0].1;
assert!((d.target - d.err - DVec3::new(0.0, 70.0, 900.0)).length() < 1e-9 && d.err == err, "the retarget lost or doubled the error");
}
// #66: an interceptor steers on what it was told or what its seeker holds, not on the true position
#[test]
fn interceptors_guide_on_tracks_and_seekers_not_on_truth() {
let fly = |hidden: DVec3, fix: Option<Fix>| {
let mut sim = Sim::new(1);
sim.add_drone(DroneKind::Shahed, hidden, DVec3::ZERO, DVec3::new(0.0, 100.0, 5000.0));
sim.drones[0].vel = DVec3::ZERO;
let (target, id) = (sim.drones[0].id, sim.id());
sim.missiles.push(Missile { id, kind: MissileKind::Pac3, pos: DVec3::new(0.0, 100.0, 0.0), vel: DVec3::Z * 3.0, target, sam: 999, age: 100, hold: 0, fix, seen: 0 });
sim.tick = 100;
sim.step_missiles(&mut Ground::default(), &mut Out::default());
sim.missiles.first().map(|m| m.vel)
};
// The issue's case: no system, no track, a target 600 away that is somewhere else in the second copy.
let a = fly(DVec3::new(0.0, 100.0, 600.0), None);
let b = fly(DVec3::new(300.0, 100.0, 600.0), None);
assert_eq!(a, b, "the missile knows where the target is without any measurement");
// The data link's fix is what it steers on, wherever the target went since.
let told = Some(Fix { pos: DVec3::new(300.0, 100.0, 600.0), vel: DVec3::ZERO, tick: 100, seeker: false });
let (c, d) = (fly(DVec3::new(0.0, 100.0, 600.0), told), fly(DVec3::new(-300.0, 100.0, 600.0), told));
assert_eq!(c, d);
assert!(c.unwrap().x > 0.05, "it did not turn to the fix: {c:?}");
// The seeker takes over once the target is in its reach: now the truth steers it.
let near = |at: DVec3| {
let mut sim = Sim::new(1);
sim.add_drone(DroneKind::Shahed, at, DVec3::ZERO, DVec3::new(0.0, 100.0, 5000.0));
sim.drones[0].vel = DVec3::ZERO;
let (target, id) = (sim.drones[0].id, sim.id());
let stale = Some(Fix { pos: DVec3::new(-60.0, 100.0, 80.0), vel: DVec3::ZERO, tick: 90, seeker: false });
sim.missiles.push(Missile { id, kind: MissileKind::Pac3, pos: DVec3::new(0.0, 100.0, 0.0), vel: DVec3::Z * 3.0, target, sam: 999, age: 100, hold: 0, fix: stale, seen: 0 });
sim.tick = 100;
// The world is read: open air between them is a confirmed line of sight.
let air = Blocks(|_, y, _| Some(y < 64));
let mut ground = Ground::default();
for t in 0..16 {
ground.world.probe(DVec3::new(0.0, 100.0, 0.0), at, t);
let q = ground.world.take_wanted(t);
ground.world.serve(&air, q, t);
}
sim.step_missiles(&mut ground, &mut Out::default());
(sim.missiles[0].vel, sim.missiles[0].seen)
};
let (v, seen) = near(DVec3::new(40.0, 100.0, 70.0));
assert!(seen == 100 && v.x > 0.05, "the seeker did not hold a target in its reach: {v:?}");
let (v, seen) = near(DVec3::new(300.0, 100.0, 70.0));
assert!(seen == 0 && v.x < -0.05, "a target outside its reach steered it: {v:?}");
// Behind a mountain the seeker sees nothing.
let mut sim = Sim::new(1);
sim.add_drone(DroneKind::Shahed, DVec3::new(40.0, 100.0, 70.0), DVec3::ZERO, DVec3::new(0.0, 100.0, 5000.0));
sim.drones[0].vel = DVec3::ZERO;
let (target, id) = (sim.drones[0].id, sim.id());
sim.missiles.push(Missile { id, kind: MissileKind::Pac3, pos: DVec3::new(0.0, 100.0, 0.0), vel: DVec3::Z * 3.0, target, sam: 999, age: 100, hold: 0, fix: Some(Fix { pos: DVec3::new(0.0, 100.0, 300.0), vel: DVec3::ZERO, tick: 90, seeker: false }), seen: 0 });
let hill = Blocks(|x, y, z| Some(y < 64 || ((10..14).contains(&x) && (0..40).contains(&z) && y < 300)));
let mut ground = Ground::default();
for t in 0..16 {
ground.world.probe(DVec3::new(0.0, 100.0, 0.0), DVec3::new(40.0, 100.0, 70.0), t);
let q = ground.world.take_wanted(t);
ground.world.serve(&hill, q, t);
}
sim.tick = 100;
sim.step_missiles(&mut ground, &mut Out::default());
assert_eq!(sim.missiles[0].seen, 0, "the seeker saw through a mountain");
// No fix for too long and no seeker on it: the interceptor is lost, and counted so.
let mut sim = Sim::new(1);
sim.add_drone(DroneKind::Shahed, DVec3::new(0.0, 100.0, 3000.0), DVec3::ZERO, DVec3::new(0.0, 100.0, 5000.0));
sim.drones[0].vel = DVec3::ZERO;
let (target, id) = (sim.drones[0].id, sim.id());
let old = Some(Fix { pos: DVec3::new(0.0, 100.0, 300.0), vel: DVec3::ZERO, tick: 0, seeker: false });
sim.missiles.push(Missile { id, kind: MissileKind::Pac3, pos: DVec3::new(0.0, 100.0, 0.0), vel: DVec3::Z * 3.0, target, sam: 999, age: 100, hold: 0, fix: old, seen: 0 });
sim.tick = MAX_COAST + 50;
sim.step_missiles(&mut Ground::default(), &mut Out::default());
assert!(sim.missiles.is_empty() && sim.tally.expired == 1, "{:?}", sim.tally);
}
// #75: unread blocks are not a line of sight; out of sight the seeker coasts on its last estimate whole
#[test]
fn seeker_needs_confirmed_sight_and_coasts_on_its_last_estimate() {
let setup = |target_at: DVec3, tv: DVec3, fix: Option<Fix>| {
let mut sim = Sim::new(1);
sim.add_drone(DroneKind::Shahed, target_at, DVec3::ZERO, DVec3::new(0.0, 100.0, 5000.0));
sim.drones[0].vel = tv;
let (target, id) = (sim.drones[0].id, sim.id());
sim.missiles.push(Missile { id, kind: MissileKind::Pac3, pos: DVec3::new(0.0, 100.0, 0.0), vel: DVec3::Z * 3.0, target, sam: 999, age: 100, hold: 0, fix, seen: 0 });
sim.tick = 100;
sim
};
let at = DVec3::new(0.0, 100.0, 70.0);
// Cold world: nothing read, nothing locked, however close the target is.
let mut sim = setup(DVec3::new(20.0, 100.0, 70.0), DVec3::ZERO, None);
sim.step_missiles(&mut Ground::default(), &mut Out::default());
assert!(sim.missiles.iter().all(|m| m.seen == 0 && m.fix.is_none()), "an unread world was taken for line of sight");
// Warm open air: locked.
let air = Blocks(|_, y, _| Some(y < 64));
let mut ground = Ground::default();
for t in 0..16 {
ground.world.probe(DVec3::new(0.0, 100.0, 0.0), at, t);
let q = ground.world.take_wanted(t);
ground.world.serve(&air, q, t);
}
let mut sim = setup(at, DVec3::new(0.5, 0.0, 0.0), None);
sim.step_missiles(&mut ground, &mut Out::default());
let f = sim.missiles[0].fix.expect("no lock in clear air");
assert!(sim.missiles[0].seen == 100 && f.seeker && f.tick == 100 && f.vel == DVec3::new(0.5, 0.0, 0.0), "{f:?}");
// Then a wall comes between them and the target manoeuvres: the estimate stays as it was, pos, vel and tick.
let wall = Blocks(|x, y, z| Some(y < 64 || (x == 5 && y < 200 && z.abs() < 200)));
let mut ground = Ground::default();
for t in 0..16 {
ground.world.probe(DVec3::new(0.0, 100.0, 0.0), DVec3::new(10.0, 100.0, 70.0), t);
let q = ground.world.take_wanted(t);
ground.world.serve(&wall, q, t);
}
let last = Fix { pos: at, vel: DVec3::ZERO, tick: 100, seeker: true };
let mut sim = setup(DVec3::new(10.0, 100.0, 70.0), DVec3::new(2.0, 0.0, 0.0), Some(last));
sim.missiles[0].seen = 100;
sim.tick = 101;
sim.step_missiles(&mut ground, &mut Out::default());
let f = sim.missiles[0].fix.expect("the estimate was dropped");
assert!(f.pos == last.pos && f.vel == DVec3::ZERO && f.tick == 100, "truth leaked through the wall: {f:?}");
}
// #85: the graphics settings never reach the combat: the same seed and commands play out the same
#[test]
fn graphics_settings_do_not_change_combat_randomness_or_ids() {
// The issue's case: one explosion, only the shader differs.
let after_blast = |shader: bool| {
let mut sim = Sim::new(12345);
sim.shader = shader;
sim.fireball(DVec3::new(0.0, 80.0, 0.0), 4.0, 11);
let id = sim.id();
let aim = sim.aim_error(DroneKind::Shahed);
(id, aim)
};
assert_eq!(after_blast(true), after_blast(false), "a fireball spent entity ids or combat randomness");
// A shot-down drone, with its debris and sound: no more than the same shot-down without them.
let after_kill = |shader: bool, effects: u32| {
let mut sim = Sim::new(99);
sim.shader = shader;
let mut out = Out::default();
for i in 0..effects {
sim.fireball(DVec3::new(f64::from(i), 80.0, 0.0), 4.0, 11);
}
sim.add_drone(DroneKind::Shahed, DVec3::new(0.0, 80.0, 0.0), DVec3::X, DVec3::new(900.0, 80.0, 0.0));
sim.shoot_down(0, 0, None, &mut out);
let id = sim.id();
(sim.drones[0].id, id, sim.aim_error(DroneKind::Shahed))
};
assert_eq!(after_kill(true, 0), after_kill(false, 0));
assert_eq!(after_kill(false, 3), after_kill(true, 9));
// A whole raid: the combat's outcome is the same with the shader on and off (and drawn or not).
let play = |shader: bool, drawn: bool| {
let mut sim = Sim::new(31);
sim.shader = shader;
sim.damage = false;
let mut ground = Ground::default();
let mut out = Out::default();
for (i, kind) in [SamKind::Patriot, SamKind::Pantsir, SamKind::Gepard, SamKind::Patriot].into_iter().enumerate() {
let id = sim.id();
sim.sams.push(Sam::new(id, kind, DVec3::new(i as f64 * 25.0 - 40.0, 70.0, 10.0 * i as f64), 0.0));
}
sim.raid(DroneKind::Shahed, DVec3::new(0.0, 70.0, 0.0), 14, 500.0, &mut out);
// Whatever the settings, the world answers the same: nothing but the land is asked for here.
let mut trace = Vec::new();
for _ in 0..1500 {
if !drawn {
out = Out::default();
}
sim.step(&mut ground, None, &mut out);
if out.models.len() > 20_000 {
out = Out::default();
}
trace.push((sim.kills, sim.hits, sim.launched, sim.drones.len(), sim.missiles.len()));
}
let drones: Vec<_> = sim.drones.iter().map(|d| (d.id, d.pos.to_array().map(f64::to_bits))).collect();
let missiles: Vec<_> = sim.missiles.iter().map(|m| (m.id, m.pos.to_array().map(f64::to_bits))).collect();
let sams: Vec<_> = sim.sams.iter().map(|s| (s.id, s.kills)).collect();
(trace, drones, missiles, sams, sim.crashes, sim.tally.blocked, sim.tally.expired)
};
let (a, b) = (play(true, true), play(false, false));
assert!(a.0.last().is_some_and(|t| t.2 > 0), "nothing was launched: the test proves nothing");
assert_eq!(a, b, "the combat differs with the graphics");
}
// #69: many separate strikes: the list, what is drawn and the lights are bounded; the rest end properly
#[test]
fn mass_strikes_keep_fires_effects_and_lights_bounded() {
let mut sim = Sim::new(1);
sim.damage = true;
let mut ground = Ground::default();
ground.answer((-100..2000).flat_map(|x| (-10..10).map(move |z| (x, z, 70))).collect(), 0);
for i in 0..120 {
sim.ignite(DVec3::new(f64::from(i) * 10.0, 70.0, 0.0), (i % 5) as u32, None);
}
assert!(sim.fires.len() <= fires::LIST_MAX, "{} fires kept", sim.fires.len());
let mut peak_particles = 0;
for _ in 0..200 {
sim.tick += 1;
let mut out = Out::default();
sim.step_fires(&mut ground, &mut out);
peak_particles = peak_particles.max(out.particles.iter().map(|p| p.count as usize).sum::<usize>());
assert!(sim.fires.list.iter().filter(|f| f.shown).count() <= fires::MAX);
assert!(sim.fires.len() <= fires::LIST_MAX);
}
// Twelve fires at full strength: well under what 120 would have been.
assert!(peak_particles < 12 * 24 + fires::LIST_MAX, "{peak_particles} particles in a tick");
// A long stream of strikes, one every few ticks, stays bounded; at the end all go out and the blocks go with them.
for i in 0..400 {
sim.tick += 5;
sim.ignite(DVec3::new(f64::from(i % 150) * 12.0, 70.0, 0.0), 2, None);
sim.step_fires(&mut ground, &mut Out::default());
assert!(sim.fires.len() <= fires::LIST_MAX);
}
let mut commands = 0;
for _ in 0..8000 {
sim.tick += 1;
let mut out = Out::default();
sim.step_fires(&mut ground, &mut out);
commands += out.commands.len();
if sim.fires.is_empty() {
break;
}
}
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}");
}
/// 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);
}
/// #90: a cruise flight that stops getting nearer its aim (a turn circle wider than the distance, a hold that
/// never ends) falls out of the sky after `STALL_TICKS`, so a raid is not held open by it.
#[test]
fn a_flight_that_makes_no_headway_runs_out_of_fuel() {
let mut pace = Pace::default();
// Nearer and nearer: never stalled.
for age in 1..10_000u32 {
assert!(!pace.stalled(age, 5000.0 - f64::from(age) * 0.5, 1), "a flight under way stalled at {age}");
}
// Circling at 40 blocks from the aim.
let mut pace = Pace::default();
let mut ended = None;
for age in 1..10_000u32 {
if pace.stalled(age, 40.0 + f64::from(age % 7), 1) {
ended = Some(age);
break;
}
}
let ended = ended.expect("a circling flight never ran out of fuel");
assert!((STALL_TICKS..STALL_TICKS + 20).contains(&ended), "{ended}");
// Passing a route point is progress, however far off the aim the way goes.
let mut pace = Pace::default();
for age in 1..10_000u32 {
assert!(!pace.stalled(age, 3000.0, 10 - (age / 1000) as usize), "route points passed, still stalled at {age}");
}
}
/// #90: the same in the simulation: a drone that gets no nearer its aim (here the aim keeps its distance, as one
/// does that it cannot turn onto) ends, falling, within the stall time, and the raid is not held open by it.
#[test]
fn a_drone_that_never_arrives_ends_the_raid() {
let mut sim = Sim::new(4);
let mut d = sim.make_drone(DroneKind::Kh101, DVec3::new(0.0, 400.0, 0.0), DVec3::X, DVec3::new(3000.0, 400.0, 0.0));
d.low = false;
sim.drones.push(d);
let (mut ground, mut out) = (Ground::default(), Out::default());
let mut ended = None;
for t in 0..STALL_TICKS + 400 {
if let Some(d) = sim.drones.first_mut() {
d.target = d.pos + DVec3::X * 3000.0;
}
sim.step(&mut ground, None, &mut out);
if !sim.drones.iter().any(|d| d.alive()) {
ended = Some(t);
break;
}
}
let ended = ended.expect("still airborne after the stall time");
assert!(ended >= STALL_TICKS, "ended early: {ended}");
assert_eq!(sim.stalled, 1);
}
/// #90: an answer says which world it is of. Heights of another dimension are not this one's, and an answer to a
/// question asked before the world changed is dropped, not read as the new world's.
#[test]
fn heights_of_another_world_are_void_and_late_answers_are_dropped() {
let mut ground = Ground::default();
ground.world.changed_rev(1, 0, false, &[]);
ground.answer(vec![(4, 4, 70), (12, 4, NO_LAND)], 1);
assert_eq!(ground.land_state(DVec3::new(4.5, 80.0, 4.5)), Some(Some(70.0)));
assert_eq!(ground.land_state(DVec3::new(12.5, 80.0, 4.5)), Some(None), "no land is an answer too");
// A question goes out in world 1; the dimension changes before the answer comes.
let _ = ground.land(DVec3::new(100.5, 80.0, 100.5));
assert_eq!(ground.take_wanted().len(), 1);
ground.world.changed_rev(2, 0, false, &[]);
ground.answer(vec![(100, 100, 55)], 2);
assert_eq!(ground.world.stats.late_dropped, 1);
assert_eq!(ground.land_state(DVec3::new(4.5, 80.0, 4.5)), None, "the old dimension's height is gone, asked again");
assert_eq!(ground.land_state(DVec3::new(100.5, 80.0, 100.5)), None, "the late answer was not taken");
}
/// #90: a blast asked of the game in the blocks is confirmed by the game's own report of blocks changed there; one
/// that is not reported is told apart by whether the land was there.
#[test]
fn a_blast_in_the_blocks_waits_for_the_games_word() {
let mut ground = Ground::default();
ground.answer(vec![(4, 4, 70)], 1);
let near = DVec3::new(4.5, 70.0, 4.5);
let far = DVec3::new(900.5, 70.0, 900.5);
ground.world.expect_blast(near, 8.0, 1);
ground.world.expect_blast(near + DVec3::X * 200.0, 8.0, 1);
ground.world.expect_blast(far, 8.0, 1);
ground.world.changed_rev(1, 1, false, &[(0, 4, 0)]);
assert_eq!(ground.world.stats.blasts_confirmed, 1);
ground.answer(vec![], 1 + world::CONFIRM_TICKS);
let s = ground.world.stats;
assert_eq!((s.blasts_asked, s.blasts_confirmed, s.blasts_unchanged, s.blasts_unloaded), (3, 1, 0, 2));
}