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:
+16
-4
@@ -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<usize>,
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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<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 {
|
||||
self.sams
|
||||
.iter()
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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<f32>, usize) {
|
||||
pub(super) fn price_grid(&self, grid: &Grid, kind: DroneKind) -> (Vec<f32>, usize) {
|
||||
let mut cost = 0;
|
||||
let spec = kind.spec();
|
||||
let height = spec.clearance.max(6.0);
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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<Vec<f32>>,
|
||||
pub(super) rays: Vec<Vec<f32>>,
|
||||
}
|
||||
|
||||
impl Horizon {
|
||||
|
||||
Reference in New Issue
Block a user