Check chassis footprint and bound pending route exploration (#114)

Reject narrow corridors, recheck motion clearance, and wait for footprint data before flooding the shared query budget.

Co-Authored-By: Codex GPT-6 Luna <noreply@openai.com>
This commit is contained in:
2026-11-02 01:17:03 +03:00
parent 5f07027faa
commit e7c00267b1
2 changed files with 134 additions and 10 deletions
+21 -3
View File
@@ -48,7 +48,7 @@ impl Sim {
return false;
}
sam.dest = Some(to);
let route = route::Route::new(sam.pos, to, slack, sam.kind.climbs(), self.tick);
let route = route::Route::new(sam.pos, to, slack, sam.kind, self.tick);
self.routes.map.insert(sam.id, route);
sam.target = None;
sam.gun_target = None;
@@ -102,7 +102,7 @@ impl Sim {
sam.march = sam.kind.march_ticks();
continue;
};
let route = routes.map.entry(sam.id).or_insert_with(|| route::Route::new(sam.pos, to, 0.0, sam.kind.climbs(), now));
let route = routes.map.entry(sam.id).or_insert_with(|| route::Route::new(sam.pos, to, 0.0, sam.kind, now));
let (go, last) = match route.drive(ground, now, sam.pos, share) {
route::Drive::Wait(why) => {
if route.waited(now) > 40 && !route.told {
@@ -145,7 +145,8 @@ impl Sim {
let turning = angle_diff(sam.heading, heading_of(flat)).abs();
let step = SPEED * sam.drive_speed() * (1.0 - turning / std::f64::consts::PI).max(0.2);
let next = sam.pos + ahead * step;
// The column ahead: a wall or a drop the grid missed turns the vehicle back to the planner.
// The column and chassis clearance ahead: a wall, low ceiling or a change after planning
// turns the vehicle back to the planner. Unknown voxels remain a wait, never a drive.
let Some(y) = ground.at(next, now) else {
if route.wait_ahead() {
out.messages.push(format!("{} №{}: {}", sam.kind.name(), n + 1, route::Why::NoMap.text()));
@@ -156,6 +157,23 @@ impl Sim {
}
continue;
};
match route.clearance(ground, next, now) {
route::Clearance::Clear => {}
route::Clearance::Pending => {
if route.wait_ahead() {
out.messages.push(format!("{} №{}: {}", sam.kind.name(), n + 1, route::Why::NoMap.text()));
routes.map.remove(&sam.id);
sam.dest = None;
sam.state = SamState::Deploying;
sam.march = sam.kind.march_ticks();
}
continue;
}
route::Clearance::Blocked => {
route.blocked_ahead(next, sam.pos);
continue;
}
}
route.ahead_ok();
let rise = y - sam.pos.y;
if rise > route::STEP_UP || rise < -route::STEP_DOWN {
+113 -7
View File
@@ -64,6 +64,44 @@ pub(super) enum Drive {
Fail(Why),
}
/// Result of checking the vehicle's clearance at its next position.
pub(super) enum Clearance {
Clear,
Blocked,
Pending,
}
/// Conservative chassis envelope. The circular footprint is the circumscribed plan-view of the
/// folded vehicle; it makes turns safe without adding heading to the bounded A* state.
#[derive(Clone, Copy)]
struct Chassis {
radius: f64,
height: f64,
}
impl Chassis {
fn for_kind(kind: SamKind) -> Self {
let (radius, height) = match kind.spec().family {
Family::Trainable => (2.25, 3.0),
Family::Gun => (1.75, 3.0),
Family::Turret => (2.0, 3.25),
Family::Vertical => (2.75, 3.5),
Family::Static => (2.0, 3.0),
Family::Truck => (1.75, 2.75),
Family::Light => (1.25, 2.5),
};
Self { radius, height }
}
/// Integer voxel sample points inside the conservative footprint.
fn samples(self) -> Vec<(i32, i32)> {
let r = self.radius.ceil() as i32;
(-r..=r).flat_map(|x| (-r..=r).filter_map(move |z| {
((x * x + z * z) as f64 <= self.radius * self.radius + 0.5).then_some((x, z))
})).collect()
}
}
#[derive(Clone, Copy)]
struct Info {
h: f32,
@@ -103,6 +141,7 @@ pub(super) struct Route {
slack: f64,
cell: i32,
slope: f64,
chassis: Chassis,
start: (i32, i32),
lo: (i32, i32),
hi: (i32, i32),
@@ -131,14 +170,15 @@ pub(super) struct Route {
}
impl Route {
pub(super) fn new(pos: DVec3, dest: DVec3, slack: f64, slope: f64, now: u64) -> Self {
pub(super) fn new(pos: DVec3, dest: DVec3, slack: f64, kind: SamKind, now: u64) -> Self {
let dist = horizontal(dest - pos).length();
let cell = if dist <= 400.0 { 4 } else if dist <= 900.0 { 6 } else { 8 };
let mut r = Self {
dest,
slack,
cell,
slope,
slope: kind.climbs(),
chassis: Chassis::for_kind(kind),
start: (0, 0),
lo: (0, 0),
hi: (0, 0),
@@ -232,10 +272,50 @@ impl Route {
}
}
};
if verdict.ok {
match self.clearance_at(ground, DVec3::new(f64::from(x) + 0.5, f64::from(verdict.h), f64::from(z) + 0.5), now) {
Clearance::Clear => {}
Clearance::Blocked => {
let blocked = Info { h: verdict.h, ok: false };
self.cells.insert(c, blocked);
return Eval::Bad;
}
Clearance::Pending => return Eval::Pending,
}
}
self.cells.insert(c, verdict);
if verdict.ok { Eval::Ok(verdict.h) } else { Eval::Bad }
}
/// Check a chassis-sized footprint against sampled support columns and voxel headroom.
/// Unknown columns or sections are pending so neither search nor motion assumes they are open.
fn clearance_at(&self, ground: &mut Ground, pos: DVec3, now: u64) -> Clearance {
let base = pos.y;
let mut pending = false;
for (dx, dz) in self.chassis.samples() {
let x = pos.x.floor() as i32 + dx;
let z = pos.z.floor() as i32 + dz;
let Some(column) = ground.column(x, z, 1024) else { pending = true; continue };
let Some(h) = column else { return Clearance::Blocked };
let dh = h - base;
if dh > STEP_UP || dh < -STEP_DOWN {
return Clearance::Blocked;
}
let top = DVec3::new(f64::from(x) + 0.5, h + 0.05, f64::from(z) + 0.5);
match ground.world.sweep(top, top + DVec3::Y * self.chassis.height, false, now) {
world::Sweep::Clear => {}
world::Sweep::Unknown { pending: true, .. } | world::Sweep::Wait { .. } => pending = true,
world::Sweep::Unknown { .. } | world::Sweep::Hit { .. } => return Clearance::Blocked,
}
}
if pending { Clearance::Pending } else { Clearance::Clear }
}
/// Recheck the envelope at the live position, since obstacles may change after planning.
pub(super) fn clearance(&self, ground: &mut Ground, pos: DVec3, now: u64) -> Clearance {
self.clearance_at(ground, pos, now)
}
/// Relaxes the edge from the opened cell `c` to `nb`; parks it when the land between isn't known yet.
fn relax(&mut self, ground: &mut Ground, now: u64, c: (i32, i32), nb: (i32, i32)) {
if nb.0 < self.lo.0 || nb.1 < self.lo.1 || nb.0 > self.hi.0 || nb.1 > self.hi.1 {
@@ -305,7 +385,7 @@ impl Route {
}
}
if now >= self.retry_at && !self.parked.is_empty() {
self.retry_at = now + 6;
self.retry_at = now + 1;
let parked = std::mem::take(&mut self.parked);
for (k, &(c, nb)) in parked.iter().enumerate() {
// The game loads the chunk of the first few, if it loads on request.
@@ -336,6 +416,9 @@ impl Route {
for (di, dj) in [(1, 0), (-1, 0), (0, 1), (0, -1), (1, 1), (1, -1), (-1, 1), (-1, -1)] {
self.relax(ground, now, c, (c.0 + di, c.1 + dj));
}
// Let pending footprint queries settle before opening a broad, less useful frontier.
// Otherwise three trucks can saturate the shared world budget without ever moving.
if !self.parked.is_empty() { break; }
if self.expanded >= MAX_NODES {
self.settle(now);
return;
@@ -505,13 +588,13 @@ mod tests {
}
/// Drives a Patriot from the origin to `to` over land of the given heights (water: a height over a lower floor).
fn march<H: Fn(i32, i32) -> i32, B: Fn(i32, i32) -> i32>(height: H, floor: B, to: DVec3, ticks: u32) -> (Sim, Out, u32) {
fn march_kind<H: Fn(i32, i32) -> i32, B: Fn(i32, i32) -> i32>(kind: SamKind, height: H, floor: B, to: DVec3, ticks: u32) -> (Sim, Out, u32) {
let src = Src { height, floor };
let mut sim = Sim::new(3);
sim.damage = false;
let mut ground = Ground::default();
let id = sim.id();
sim.sams.push(Sam::new(id, SamKind::Patriot, DVec3::new(0.5, 64.0, 0.5), 0.0));
sim.sams.push(Sam::new(id, kind, DVec3::new(0.5, 64.0, 0.5), 0.0));
let mut out = Out::default();
for _ in 0..3 {
sim.tick += 1;
@@ -538,6 +621,10 @@ mod tests {
(sim, out, used)
}
fn march<H: Fn(i32, i32) -> i32, B: Fn(i32, i32) -> i32>(height: H, floor: B, to: DVec3, ticks: u32) -> (Sim, Out, u32) {
march_kind(SamKind::Patriot, height, floor, to, ticks)
}
// A wall across the way, with a gap far to one side: the vehicle goes round and arrives.
#[test]
fn a_wall_with_a_gap_is_driven_round() {
@@ -572,8 +659,27 @@ mod tests {
// Open ground: straight on, arrives.
#[test]
fn open_ground_is_driven_straight() {
let (sim, _, _) = march(|_, _| 64, |_, _| 64, DVec3::new(100.0, 64.0, 30.5), 1500);
let (sim, out, _) = march(|_, _| 64, |_, _| 64, DVec3::new(100.0, 64.0, 30.5), 1500);
let s = &sim.sams[0];
assert!(!s.moving() && s.pos.distance(DVec3::new(100.0, 64.0, 30.5)) < 3.0, "{:?}", s.pos);
let route = sim.routes.map.get(&s.id).map(|r| (r.expanded, r.parked.len(), r.open.len(), r.cells.len()));
assert!(!s.moving() && s.pos.distance(DVec3::new(100.0, 64.0, 30.5)) < 3.0, "pos={:?} state={:?} messages={:?} route={route:?}", s.pos, s.state, out.messages);
}
// A point-column route fits through this one-block slit, but the S-400 chassis does not.
#[test]
fn s400_rejects_a_one_block_corridor() {
let walls = |x: i32, z: i32| if (-500..500).contains(&x) && z != 0 { 96 } else { 64 };
let (sim, out, _) = march_kind(SamKind::S400, walls, walls, DVec3::new(60.5, 64.0, 0.5), 4000);
assert!(!sim.sams[0].moving(), "{:?}", sim.sams[0].state);
assert!(sim.sams[0].pos.x < 20.0, "narrow passage was traversed: {:?}", sim.sams[0].pos);
assert!(out.messages.iter().any(|m| m.contains("прохода нет") || m.contains("не проехать")), "{:?}", out.messages);
}
// A broad, level lane remains driveable for the same large vehicle.
#[test]
fn s400_uses_a_wide_corridor() {
let walls = |x: i32, z: i32| if (-500..500).contains(&x) && !(-6..=6).contains(&z) { 96 } else { 64 };
let (sim, _, _) = march_kind(SamKind::S400, walls, walls, DVec3::new(60.5, 64.0, 0.5), 5000);
assert!(!sim.sams[0].moving() && sim.sams[0].pos.distance(DVec3::new(60.5, 64.0, 0.5)) < 3.0, "pos={:?} state={:?}", sim.sams[0].pos, sim.sams[0].state);
}
}