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 <noreply@anthropic.com>
This commit is contained in:
2026-10-29 16:18:33 +03:00
parent 90379f6e17
commit e08fc65ac8
7 changed files with 153 additions and 16 deletions
+16 -4
View File
@@ -56,6 +56,9 @@ struct AirDef {
sim: Sim, sim: Sim,
ground: Ground, ground: Ground,
dirty: bool, 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, hud_shown: bool,
/// The system the monitor shows (K cycles), none: hidden. /// The system the monitor shows (K cycles), none: hidden.
monitor: Option<usize>, monitor: Option<usize>,
@@ -517,10 +520,17 @@ impl AirDef {
for (kind, roles) in &self.sim.roles { for (kind, roles) in &self.sim.roles {
text += &format!("roles {} {}\n", kind.key(), roles); text += &format!("roles {} {}\n", kind.key(), roles);
} }
if let Err(e) = storage::set(&self.state_key(), text.as_bytes()) { // Clean only once written: a failed write is tried again after a pause.
horizon_mod_sdk::warn(&format!("could not save: {e}")); 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) { fn load(&mut self) {
@@ -635,6 +645,7 @@ impl AirDef {
let shader = self.sim.shader; let shader = self.sim.shader;
let seed = game::get_clock().map_or(0x5eed, |c| c.game_time.wrapping_mul(2654435761) ^ 0x9e37); let seed = game::get_clock().map_or(0x5eed, |c| c.game_time.wrapping_mul(2654435761) ^ 0x9e37);
self.sim = Sim::new(seed); self.sim = Sim::new(seed);
self.next_save = 0;
self.sim.shader = shader; self.sim.shader = shader;
self.ground = Ground::default(); self.ground = Ground::default();
self.monitor = None; self.monitor = None;
@@ -885,6 +896,7 @@ impl Mod for AirDef {
sim: Sim::new(seed), sim: Sim::new(seed),
ground: Ground::default(), ground: Ground::default(),
dirty: false, dirty: false,
next_save: 0,
hud_shown: false, hud_shown: false,
monitor: None, monitor: None,
monitor_shown: false, monitor_shown: false,
@@ -1044,7 +1056,7 @@ impl Mod for AirDef {
self.health_mark = mark; self.health_mark = mark;
self.dirty = true; self.dirty = true;
} }
if self.dirty { if self.dirty && self.sim.tick >= self.next_save {
self.save(); self.save();
} }
} }
+2
View File
@@ -189,6 +189,8 @@ impl Sim {
for h in &mut self.target_health { for h in &mut self.target_health {
*h = 100.0; *h = 100.0;
} }
// The wrecks' ammunition still to cook off goes with them.
self.later.clear();
n n
} }
+10 -2
View File
@@ -43,6 +43,10 @@ impl Sim {
self.reload_now(i, out); 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. // The trucks: a job each, when idle.
for t in 0..self.sams.len() { for t in 0..self.sams.len() {
let truck = &self.sams[t]; let truck = &self.sams[t];
@@ -66,13 +70,17 @@ impl Sim {
} }
self.sams[t].job = None; self.sams[t].job = None;
} else if self.sams[j].alive() && (self.sams[j].empty || self.sams[j].kind == SamKind::Depot) { } 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 { } else {
self.sams[t].job = None; self.sams[t].job = None;
} }
} }
// A new job: the nearest waiting system no other truck is driving to, or a depot to load at. // A new job: the nearest waiting system no other truck is driving to, or a depot to load at.
let taken: Vec<u32> = self.sams.iter().filter(|s| s.kind == SamKind::Tzm).filter_map(|s| s.job).collect(); let taken: Vec<u32> = 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 { let next = if self.sams[t].cargo > 0 {
self.sams self.sams
.iter() .iter()
+19 -4
View File
@@ -18,6 +18,8 @@ mod lights;
mod logistics; mod logistics;
mod orders; mod orders;
mod units; mod units;
#[cfg(test)]
mod regress;
pub use ew::EW_RADIUS; pub use ew::EW_RADIUS;
pub use units::{Family, SamKind}; pub use units::{Family, SamKind};
use units::Muzzle; use units::Muzzle;
@@ -182,7 +184,9 @@ impl Ground {
let key = (p.x.floor() as i32, p.z.floor() as i32); let key = (p.x.floor() as i32, p.z.floor() as i32);
match self.known.get(&key) { match self.known.get(&key) {
Some(&(y, when)) => { 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); self.wanted.push(key);
} }
(y > -64).then_some(f64::from(y)) (y > -64).then_some(f64::from(y))
@@ -1317,6 +1321,15 @@ impl Sim {
d.age += 1; d.age += 1;
let spec = d.kind.spec(); let spec = d.kind.spec();
let below = ground.at(d.pos, now); 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 { if d.flight == Flight::Falling {
d.vel.y -= 0.05; d.vel.y -= 0.05;
d.vel *= 0.985; d.vel *= 0.985;
@@ -1352,7 +1365,7 @@ impl Sim {
let speed = d.vel.length(); let speed = d.vel.length();
let hit = d.pos.distance(d.target) < speed.max(2.0) let hit = d.pos.distance(d.target) < speed.max(2.0)
|| below.is_some_and(|g| d.pos.y <= g + 0.2) || 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; || d.age > 4000;
if hit { if hit {
boom.push((d.pos, spec.warhead, false)); 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)); 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); sam.launched = sam.launched.saturating_sub(1);
let Some(aim) = aim else { let Some(aim) = aim else {
// Out of missiles and nothing to shoot: reload now, guns or not. // Out of missiles or of shells, and nothing to shoot: reload now (both, as a
if spec.missile.is_some() && sam.missiles == 0 && sam.reloading == 0 && !sam.empty { // 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 } if logistics { sam.empty = true } else { sam.reloading = spec.reload }
} }
sam.state = if sam.reloading > 0 { sam.state = if sam.reloading > 0 {
+5 -5
View File
@@ -104,10 +104,10 @@ const CELLS: i32 = 140;
const WORK_PER_TICK: usize = 40_000; const WORK_PER_TICK: usize = 40_000;
/// The planner's grid over a raid: its corner, its cells' size and how many a side. /// The planner's grid over a raid: its corner, its cells' size and how many a side.
struct Grid { pub(super) struct Grid {
min: DVec3, pub(super) min: DVec3,
cell: f64, pub(super) cell: f64,
size: i32, pub(super) size: i32,
} }
impl Grid { 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 /// 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. /// 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<f32>, usize) { pub(super) fn price_grid(&self, grid: &Grid, kind: DroneKind) -> (Vec<f32>, usize) {
let mut cost = 0; let mut cost = 0;
let spec = kind.spec(); let spec = kind.spec();
let height = spec.clearance.max(6.0); let height = spec.clearance.max(6.0);
+100
View File
@@ -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<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};
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");
}
}
+1 -1
View File
@@ -17,7 +17,7 @@ const NEAR: f64 = 24.0;
pub(super) struct Horizon { pub(super) struct Horizon {
/// Per ray, per sample out: the steepest rise of the land so far (tan of its elevation). /// Per ray, per sample out: the steepest rise of the land so far (tan of its elevation).
rays: Vec<Vec<f32>>, pub(super) rays: Vec<Vec<f32>>,
} }
impl Horizon { impl Horizon {