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,
|
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();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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 {
|
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 {
|
||||||
|
|||||||
Reference in New Issue
Block a user