From e08fc65ac8569c33e7ed58a22e383765b588a859 Mon Sep 17 00:00:00 2001 From: chexit Date: Thu, 29 Oct 2026 16:18:33 +0300 Subject: [PATCH] P1 issues: ground queries keep room for unknown land (#1), a destroyed truck frees its job (#2), trucks follow a system that moved (#3), repair cancels cook-offs (#5), a failed save is retried and autosaves are paced (#6, #7), a combined system reloads when either weapon is spent (#20), munitions go down to the land left under a destroyed target (#34); regression tests from the issues Co-Authored-By: Claude Opus 5.5 --- airdef-mod/src/lib.rs | 20 +++++-- airdef-mod/src/sim/damage.rs | 2 + airdef-mod/src/sim/logistics.rs | 12 +++- airdef-mod/src/sim/mod.rs | 23 ++++++-- airdef-mod/src/sim/raid.rs | 10 ++-- airdef-mod/src/sim/regress.rs | 100 ++++++++++++++++++++++++++++++++ airdef-mod/src/sim/sight.rs | 2 +- 7 files changed, 153 insertions(+), 16 deletions(-) create mode 100644 airdef-mod/src/sim/regress.rs diff --git a/airdef-mod/src/lib.rs b/airdef-mod/src/lib.rs index a99ad1e..0a2936b 100644 --- a/airdef-mod/src/lib.rs +++ b/airdef-mod/src/lib.rs @@ -56,6 +56,9 @@ struct AirDef { sim: Sim, ground: Ground, dirty: bool, + /// Wall-clock-free pacing of autosaves: the tick before which none is written (at most one a + /// second while guns fire, and a pause after a failed write). + next_save: u64, hud_shown: bool, /// The system the monitor shows (K cycles), none: hidden. monitor: Option, @@ -517,10 +520,17 @@ impl AirDef { for (kind, roles) in &self.sim.roles { text += &format!("roles {} {}\n", kind.key(), roles); } - if let Err(e) = storage::set(&self.state_key(), text.as_bytes()) { - horizon_mod_sdk::warn(&format!("could not save: {e}")); + // Clean only once written: a failed write is tried again after a pause. + match storage::set(&self.state_key(), text.as_bytes()) { + Ok(()) => { + self.dirty = false; + self.next_save = self.sim.tick + 20; + } + Err(e) => { + horizon_mod_sdk::warn(&format!("could not save: {e}")); + self.next_save = self.sim.tick + 200; + } } - self.dirty = false; } fn load(&mut self) { @@ -635,6 +645,7 @@ impl AirDef { let shader = self.sim.shader; let seed = game::get_clock().map_or(0x5eed, |c| c.game_time.wrapping_mul(2654435761) ^ 0x9e37); self.sim = Sim::new(seed); + self.next_save = 0; self.sim.shader = shader; self.ground = Ground::default(); self.monitor = None; @@ -885,6 +896,7 @@ impl Mod for AirDef { sim: Sim::new(seed), ground: Ground::default(), dirty: false, + next_save: 0, hud_shown: false, monitor: None, monitor_shown: false, @@ -1044,7 +1056,7 @@ impl Mod for AirDef { self.health_mark = mark; self.dirty = true; } - if self.dirty { + if self.dirty && self.sim.tick >= self.next_save { self.save(); } } diff --git a/airdef-mod/src/sim/damage.rs b/airdef-mod/src/sim/damage.rs index cb6dca2..ea82db6 100644 --- a/airdef-mod/src/sim/damage.rs +++ b/airdef-mod/src/sim/damage.rs @@ -189,6 +189,8 @@ impl Sim { for h in &mut self.target_health { *h = 100.0; } + // The wrecks' ammunition still to cook off goes with them. + self.later.clear(); n } diff --git a/airdef-mod/src/sim/logistics.rs b/airdef-mod/src/sim/logistics.rs index 318121b..94d2c15 100644 --- a/airdef-mod/src/sim/logistics.rs +++ b/airdef-mod/src/sim/logistics.rs @@ -43,6 +43,10 @@ impl Sim { self.reload_now(i, out); } } + // A destroyed truck drops its job, for another to take. + for s in self.sams.iter_mut().filter(|s| s.kind == SamKind::Tzm && !s.alive()) { + s.job = None; + } // The trucks: a job each, when idle. for t in 0..self.sams.len() { let truck = &self.sams[t]; @@ -66,13 +70,17 @@ impl Sim { } self.sams[t].job = None; } else if self.sams[j].alive() && (self.sams[j].empty || self.sams[j].kind == SamKind::Depot) { - continue; // still on its way (a new order follows when it arrives) + // Stopped short of it (it drove off meanwhile): on to where it stands now. + let to = self.sams[j].pos; + let side = horizontal(at - to).normalize_or(DVec3::X) * (HANDOVER * 0.6); + self.order_move(t, to + side); + continue; } else { self.sams[t].job = None; } } // A new job: the nearest waiting system no other truck is driving to, or a depot to load at. - let taken: Vec = self.sams.iter().filter(|s| s.kind == SamKind::Tzm).filter_map(|s| s.job).collect(); + let taken: Vec = self.sams.iter().filter(|s| s.kind == SamKind::Tzm && s.alive()).filter_map(|s| s.job).collect(); let next = if self.sams[t].cargo > 0 { self.sams .iter() diff --git a/airdef-mod/src/sim/mod.rs b/airdef-mod/src/sim/mod.rs index db0c176..1b862a5 100644 --- a/airdef-mod/src/sim/mod.rs +++ b/airdef-mod/src/sim/mod.rs @@ -18,6 +18,8 @@ mod lights; mod logistics; mod orders; mod units; +#[cfg(test)] +mod regress; pub use ew::EW_RADIUS; pub use units::{Family, SamKind}; use units::Muzzle; @@ -182,7 +184,9 @@ impl Ground { let key = (p.x.floor() as i32, p.z.floor() as i32); match self.known.get(&key) { Some(&(y, when)) => { - if now.saturating_sub(when) > 200 && self.wanted.len() < 256 && self.asked.insert(key) { + // Refreshes of known columns take at most a quarter of the questions: the rest + // stay free for columns never seen, or those would starve. + if now.saturating_sub(when) > 200 && self.wanted.len() < 64 && self.asked.insert(key) { self.wanted.push(key); } (y > -64).then_some(f64::from(y)) @@ -1317,6 +1321,15 @@ impl Sim { d.age += 1; let spec = d.kind.spec(); let below = ground.at(d.pos, now); + // Near its aim it looks at the land there again: a platform or hilltop blown away + // meanwhile, it goes for what is left below, not for the air where it stood. + if d.flight != Flight::Falling + && horizontal(d.target - d.pos).length() < 250.0 + && let Some(g) = ground.at(d.target, now) + && g < d.target.y - 1.5 + { + d.target.y = g; + } if d.flight == Flight::Falling { d.vel.y -= 0.05; d.vel *= 0.985; @@ -1352,7 +1365,7 @@ impl Sim { let speed = d.vel.length(); let hit = d.pos.distance(d.target) < speed.max(2.0) || below.is_some_and(|g| d.pos.y <= g + 0.2) - || (d.vel.y < 0.0 && d.pos.y < d.target.y - 1.0) + || (d.vel.y < 0.0 && d.pos.y < d.target.y - 1.0 && horizontal(d.target - d.pos).length() < speed * 2.0) || d.age > 4000; if hit { boom.push((d.pos, spec.warhead, false)); @@ -1777,8 +1790,10 @@ impl Sim { let aim = sam.gun_target.or(sam.target).and_then(|t| self.drones.iter().find(|d| d.id == t)); sam.launched = sam.launched.saturating_sub(1); let Some(aim) = aim else { - // Out of missiles and nothing to shoot: reload now, guns or not. - if spec.missile.is_some() && sam.missiles == 0 && sam.reloading == 0 && !sam.empty { + // Out of missiles or of shells, and nothing to shoot: reload now (both, as a + // combined system's crew would), not only once both are spent. + let spent = (spec.missile.is_some() && sam.missiles == 0) || (spec.gun.is_some() && sam.bursts == 0); + if spent && sam.reloading == 0 && !sam.empty { if logistics { sam.empty = true } else { sam.reloading = spec.reload } } sam.state = if sam.reloading > 0 { diff --git a/airdef-mod/src/sim/raid.rs b/airdef-mod/src/sim/raid.rs index ca28644..a086485 100644 --- a/airdef-mod/src/sim/raid.rs +++ b/airdef-mod/src/sim/raid.rs @@ -104,10 +104,10 @@ const CELLS: i32 = 140; const WORK_PER_TICK: usize = 40_000; /// The planner's grid over a raid: its corner, its cells' size and how many a side. -struct Grid { - min: DVec3, - cell: f64, - size: i32, +pub(super) struct Grid { + pub(super) min: DVec3, + pub(super) cell: f64, + pub(super) size: i32, } impl Grid { @@ -401,7 +401,7 @@ impl Sim { /// The price of each cell of `grid` for this kind flying low: 1, and much more where a /// system sees it there (its radar's reach for it, the land) and can shoot it. - fn price_grid(&self, grid: &Grid, kind: DroneKind) -> (Vec, usize) { + pub(super) fn price_grid(&self, grid: &Grid, kind: DroneKind) -> (Vec, usize) { let mut cost = 0; let spec = kind.spec(); let height = spec.clearance.max(6.0); diff --git a/airdef-mod/src/sim/regress.rs b/airdef-mod/src/sim/regress.rs new file mode 100644 index 0000000..b1b8dd6 --- /dev/null +++ b/airdef-mod/src/sim/regress.rs @@ -0,0 +1,100 @@ +//! Regression tests for the issues filed on Gitea (each names its issue). +use super::*; +use super::raid::Grid; + +// #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=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}; + 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"); + } +} diff --git a/airdef-mod/src/sim/sight.rs b/airdef-mod/src/sim/sight.rs index 1564878..f43626b 100644 --- a/airdef-mod/src/sim/sight.rs +++ b/airdef-mod/src/sim/sight.rs @@ -17,7 +17,7 @@ const NEAR: f64 = 24.0; pub(super) struct Horizon { /// Per ray, per sample out: the steepest rise of the land so far (tan of its elevation). - rays: Vec>, + pub(super) rays: Vec>, } impl Horizon {