From 6b1e1a138358f7b80c498d01f466a14cdc7d0253 Mon Sep 17 00:00:00 2001 From: dhenry Date: Tue, 15 Sep 2026 15:51:37 -0400 Subject: [PATCH 1/2] fix(#1119): make melee auto-engage reachability 3-D, not XY-only MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit target_in_melee_range, reconcile_engage_nav_state, and drive_auto_engage_melee all judged whether a target was reachable using only XY distance. A target on an elevated ledge could sit well inside the XY ring while being tens of units above the player — physically out of reach of this driver's XY-only steering (wish_vspeed is always 0.0) — yet get reported as in melee range/engaging, even silently overwriting a prior honest no_path from the walker's own real pathfinding. target_in_melee_range now uses Entity::dist_to (genuine 3-D distance). A new MELEE_ENGAGE_MAX_Z_GAP constant (anchored to the real A* planner's own STEP_H walkable-rise cap) gates whether a target is worth chasing at all, via a new shared ActionLoop::melee_chase_plausible helper used by both reconcile_engage_nav_state's want_engage gate and drive_auto_engage_melee's outer gate — replacing the previous hand-duplicated (and matching-but-wrong) XY-only checks at each site. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01NxfLFobwMobnF1jKjozRKp --- crates/eqoxide-core/src/game_state.rs | 55 +++++++++- crates/eqoxide-net/src/action_loop.rs | 139 +++++++++++++++++++++++--- 2 files changed, 178 insertions(+), 16 deletions(-) diff --git a/crates/eqoxide-core/src/game_state.rs b/crates/eqoxide-core/src/game_state.rs index 287bf50d..d5cb5bb9 100644 --- a/crates/eqoxide-core/src/game_state.rs +++ b/crates/eqoxide-core/src/game_state.rs @@ -1084,6 +1084,18 @@ pub const MELEE_ENGAGE_RANGE: f32 = 5.0; /// `drive_auto_engage_melee`'s `PET_STANDOFF` literal. pub const PET_STANDOFF_RANGE: f32 = 25.0; +/// Max |Z gap| between player and target for melee auto-engage to treat the target as reachable +/// by DIRECT chase (#1119). `ActionLoop::drive_auto_engage_melee` steers purely in the XY plane +/// (`wish_vspeed` is always `0.0`) and relies on ground contact to close ordinary terrain relief — +/// it does no real pathfinding. A target this far above/below the player sits on a tier that only +/// real pathfinding (climb/jump planning) could reach; chasing it directly just walks the +/// character to the base of the ledge and stalls there while `nav_state` keeps claiming +/// `engaging`. Set to match `STEP_H`, the real A* planner's own per-cell walkable-rise cap +/// (`crates/eqoxide-nav/src/collision.rs`) — the same line the planner itself draws between a +/// walkable step and a real climb — so this excludes exactly the terrain the planner would also +/// refuse to cross without a real route, not ordinary stairs or slopes. +pub const MELEE_ENGAGE_MAX_Z_GAP: f32 = 20.0; + /// All state the renderer needs for one frame. /// /// `PartialEq` is load-bearing: `eq_net::gameplay::publish_snapshot` compares the freshly-mutated @@ -2266,12 +2278,17 @@ impl GameState { /// `drive_auto_engage_melee` checks its own `dist > engage` against — this predicate only /// describes that driver's behavior, it does not govern it (that driver has its own copy, on /// the other side of the eqoxide-net/eqoxide-core boundary). + /// + /// **Genuinely 3-D (#1119).** Distance is [`Entity::dist_to`], not an XY-only measure: a + /// target on a ledge tens of units above the player can sit well inside the XY ring while + /// being physically out of weapon reach, and `drive_auto_engage_melee`'s steering is XY-only + /// (never sets `wish_vspeed`) — it can never actually close that gap. Reporting `true` there + /// would tell a caller driving combat off this field that swings should be landing when the + /// character cannot even touch the target. pub fn target_in_melee_range(&self) -> Option { let tid = self.target_id?; let e = self.world.entities.get(&tid).filter(|e| !e.dead)?; - let dx = e.x - self.player_x; - let dy = e.y - self.player_y; - let dist = (dx * dx + dy * dy).sqrt(); + let dist = e.dist_to(self.player_x, self.player_y, self.player_z); let engage = if self.pet_id.is_some() { PET_STANDOFF_RANGE } else { MELEE_ENGAGE_RANGE }; Some(dist <= engage) } @@ -2744,6 +2761,38 @@ pub(crate) mod tests { assert!((d - 0.0).abs() < 1e-5, "expected 0.0, got {d}"); } + // --- GameState::target_in_melee_range --- + + /// #1119 — a target on an elevated ledge, well inside the XY ring but ~42u above the player + /// (the issue's own reproduction gap), must NOT report in melee range. Mutation check: revert + /// `target_in_melee_range` to its old XY-only `(dx*dx+dy*dy).sqrt()` → this goes RED, since 2u + /// of XY separation alone is inside `MELEE_ENGAGE_RANGE`. + #[test] + fn target_in_melee_range_is_false_across_an_unreachable_z_gap_1119() { + let mut gs = GameState::new(); + gs.player_x = 0.0; + gs.player_y = 0.0; + gs.player_z = 0.0; + gs.upsert_entity(make_entity(9, "a ledge rat", 2.0, 0.0, 42.0, true)); + gs.set_target(9); + assert_eq!(gs.target_in_melee_range(), Some(false), + "2u of XY separation is inside MELEE_ENGAGE_RANGE, but the ~42u vertical gap makes the \ + target physically unreachable — a 3-D distance check must catch this"); + } + + /// CONTROL for the test above: the same 2u XY gap with NO vertical separation must still read + /// `true`, or the 1119 test proves nothing about the Z axis specifically. + #[test] + fn target_in_melee_range_is_true_at_the_same_xy_gap_with_no_z_gap() { + let mut gs = GameState::new(); + gs.player_x = 0.0; + gs.player_y = 0.0; + gs.player_z = 0.0; + gs.upsert_entity(make_entity(9, "a ground rat", 2.0, 0.0, 0.0, true)); + gs.set_target(9); + assert_eq!(gs.target_in_melee_range(), Some(true)); + } + // --- GameState::log_msg --- #[test] diff --git a/crates/eqoxide-net/src/action_loop.rs b/crates/eqoxide-net/src/action_loop.rs index 3bb3fb82..785d9cf9 100644 --- a/crates/eqoxide-net/src/action_loop.rs +++ b/crates/eqoxide-net/src/action_loop.rs @@ -2801,10 +2801,27 @@ impl ActionLoop { /// `/v1/move/*` under a life halt, so no goto can be accepted to be silently stomped or /// silently missed during the freeze; the latch resumes normal operation the first tick the /// halt clears (#1007 final review, M3). + /// #1119: shared by `reconcile_engage_nav_state`'s `want_engage` gate and + /// `drive_auto_engage_melee`'s own outer gate — kept as one function precisely so the two + /// cannot drift the way the original 2D-only distance calc did (that drift is what made the + /// bug: both sites had matching-but-wrong XY-only predicates). True only when a target is + /// both within the ~200u "worth walking to" radius AND its |Z gap| is within + /// [`eqoxide_core::game_state::MELEE_ENGAGE_MAX_Z_GAP`] — beyond that gap, this driver's + /// XY-only steering (`wish_vspeed` always `0.0`) cannot physically reach the target, so it + /// must not be reported/promoted as an engage in progress. + fn melee_chase_plausible(dx: f32, dy: f32, dz: f32) -> bool { + use eqoxide_core::game_state::MELEE_ENGAGE_MAX_Z_GAP; + dz.abs() <= MELEE_ENGAGE_MAX_Z_GAP && (dx * dx + dy * dy + dz * dz).sqrt() < 200.0 + } + fn reconcile_engage_nav_state(&mut self, gs: &GameState) { // Same predicate as `drive_auto_engage_melee`'s own gates: auto_attack on, a live - // (non-dead) target, 2D distance < 200u. Kept in lock-step with that fn on purpose - // — the word must mean exactly "that driver is about to steer." + // (non-dead) target, within `melee_chase_plausible`'s 3-D radius AND Z-gap bound (#1119). + // Kept in lock-step with that fn on purpose — the word must mean exactly "that driver is + // about to steer," and a target this driver's XY-only chase can never actually reach must + // not be promoted to `engaging` — that would silently overwrite a prior, honest `no_path` + // (from a real pathfinding attempt at the same unreachable target) with an optimistic word + // this driver cannot make good on. let want_engage = self.auto_attack && gs.target_id .and_then(|tid| gs.world.entities.get(&tid)) @@ -2812,7 +2829,8 @@ impl ActionLoop { .map(|e| { let dx = e.x - gs.player_x; let dy = e.y - gs.player_y; - (dx * dx + dy * dy).sqrt() < 200.0 + let dz = e.z - gs.player_z; + Self::melee_chase_plausible(dx, dy, dz) }) .unwrap_or(false); @@ -2888,16 +2906,21 @@ impl ActionLoop { // same reason: // - this driver, so auto-attack does not pin the player walking at a corpse; // - `reconcile_engage_nav_state`, whose `want_engage` predicate is the same - // `auto_attack && live target && < 200u` shape — an unfiltered dead target - // would pin `nav_state` at `engaging` indefinitely (the #1007 lie in a new - // place). + // `auto_attack && live target && melee_chase_plausible(..)` shape — an + // unfiltered dead target would pin `nav_state` at `engaging` indefinitely + // (the #1007 lie in a new place). // `drive_auto_pet_combat` above has always filtered `!e.dead` for exactly this. - if let Some((ex, ey)) = gs.world.entities.get(&tid) - .filter(|e| !e.dead).map(|e| (e.x, e.y)) { + if let Some((ex, ey, ez)) = gs.world.entities.get(&tid) + .filter(|e| !e.dead).map(|e| (e.x, e.y, e.z)) { let dx = ex - gs.player_x; let dy = ey - gs.player_y; - let dist = (dx * dx + dy * dy).sqrt(); - if dist < 200.0 { // engage targets within ~200u (sparse spawns; walk to them) + let dz = ez - gs.player_z; + // #1119: worth chasing at all only within `melee_chase_plausible`'s 3-D radius + // AND Z-gap bound — a target on a ledge too far above/below to ever reach via + // this driver's XY-only steering (`wish_vspeed` stays `0.0` below) must not be + // engaged, or the character just walks to the base of the ledge and stalls + // there while still reporting `engaging`. + if Self::melee_chase_plausible(dx, dy, dz) { // With a pet, DON'T walk into melee — the pet holds aggro (PET_ATTACK) and a // squishy caster who closes to melee just gets killed (a level-1 necro died // to a level-4 skeleton this way). Stand off ~25u: out of the mob's melee but @@ -2910,14 +2933,25 @@ impl ActionLoop { // carrying a second copy of these numbers that could silently drift from it. use eqoxide_core::game_state::{MELEE_ENGAGE_RANGE, PET_STANDOFF_RANGE}; let engage = if gs.pet_id.is_some() { PET_STANDOFF_RANGE } else { MELEE_ENGAGE_RANGE }; - let hdg = if dist > 0.01 { eq_heading(dx, dy) } else { gs.player_heading }; + // #1119: the XY-only distance the STEERING vector normalizes by, kept + // separate from `dist3d` (the actual "am I in range to swing" measure, + // matching `target_in_melee_range`) — this driver can only ever close an XY + // gap, so the direction it walks must stay XY-only even though whether it + // has ARRIVED is judged in 3-D. + let dist2d = (dx * dx + dy * dy).sqrt(); + let dist3d = (dx * dx + dy * dy + dz * dz).sqrt(); + let hdg = if dist2d > 0.01 { eq_heading(dx, dy) } else { gs.player_heading }; gs.player_heading = hdg; - if dist > engage { + if dist3d > engage { // Drive the controller toward the target (it owns collide-and-slide). let swim = self.collision.read().unwrap().as_ref() .is_some_and(|c| c.in_water([gs.player_x, gs.player_y, gs.player_z])); + // `dist2d` can be ~0 while `dist3d > engage` (a target almost directly + // overhead/underfoot, still inside the Z-gap bound) — there is no XY + // direction left to close, so hold rather than divide by ~0 (#1119). + let wish_dir = if dist2d > 0.01 { [dx / dist2d, dy / dist2d] } else { [0.0, 0.0] }; *self.controller.nav_intent.lock().unwrap() = Some(MoveIntent { - wish_dir: [dx / dist, dy / dist], + wish_dir, wish_vspeed: 0.0, jump: false, want_swim: swim, @@ -8655,6 +8689,85 @@ mod tests { "premise: a LIVE target within 200u must still engage, or this test proves nothing"); } + /// #1119 — a target 2u away in XY but ~42u above the player (the issue's own reproduction + /// gap) must not be engaged: this driver's steering is XY-only (`wish_vspeed` stays `0.0` + /// below), so it can never actually close a gap that size, and pretending otherwise pins the + /// character walking into the base of the ledge while `nav_state`/`target_in_melee_range` + /// keep claiming progress. + /// + /// Mutation check: revert the `melee_chase_plausible` gate to the old XY-only + /// `(dx*dx+dy*dy).sqrt() < 200.0` → this goes RED, since 2u of XY separation alone satisfies + /// it. + #[tokio::test] + async fn drive_auto_engage_melee_declines_an_unreachable_ledge_target_1119() { + let (mut stream, _rx) = crate::transport::test_stream(0, 0).await; + let group: eqoxide_ipc::GroupShared = + std::sync::Arc::new(std::sync::Mutex::new(eqoxide_ipc::GroupSnapshot::default())); + let mut nav = test_action_loop(group); + + let mut gs = GameState::new(); + gs.player_id = 9; + gs.player_x = 0.0; + gs.player_y = 0.0; + gs.player_z = 0.0; + gs.upsert_entity(eqoxide_core::game_state::make_entity(51, "a ledge rat", 2.0, 0.0, 42.0, true)); + gs.set_target(51); + nav.auto_attack = true; + + assert!(!nav.drive_auto_engage_melee(&mut stream, &mut gs), + "a target 42u above the player is beyond MELEE_ENGAGE_MAX_Z_GAP — this XY-only \ + driver can never reach it and must decline rather than pin the character at the \ + base of the ledge"); + assert!(nav.controller.nav_intent.lock().unwrap().is_none(), + "declining the engage must not leave a stale walk-toward-the-ledge intent behind"); + + // CONTROL: the same 2u XY gap with NO Z separation must still engage, or the assertion + // above proves nothing about the Z axis specifically. + gs.world.entities.get_mut(&51).unwrap().z = 0.0; + assert!(nav.drive_auto_engage_melee(&mut stream, &mut gs), + "premise: at the same XY gap with no Z gap the driver must still engage"); + } + + /// #1119 — the reconciler must not silently overwrite a prior, honest `no_path` (from a real + /// pathfinding attempt) with the optimistic `engaging` word when the only live target is on + /// an unreachable ledge. `no_path` is `TERMINAL_NAV_STATES`-terminal precisely so a caller can + /// trust it as "this driver gave up honestly" — `enter_engaging`'s unconditional overwrite + /// defeats that the moment auto-attack sees a nearby-but-unreachable target. + /// + /// Mutation check: revert `reconcile_engage_nav_state`'s `want_engage` gate to the old + /// XY-only `< 200u` predicate → this goes RED (the state flips to `engaging`). + #[test] + fn reconcile_engage_does_not_clobber_no_path_for_an_unreachable_ledge_target_1119() { + let group: eqoxide_ipc::GroupShared = + std::sync::Arc::new(std::sync::Mutex::new(eqoxide_ipc::GroupSnapshot::default())); + let mut nav = test_action_loop(group); + *nav.nav.nav_state.lock().unwrap() = + eqoxide_ipc::NavStatus { state: "no_path".to_string(), ..Default::default() }; + + let mut gs = GameState::new(); + gs.player_x = 0.0; + gs.player_y = 0.0; + gs.player_z = 0.0; + gs.upsert_entity(eqoxide_core::game_state::make_entity(52, "a ledge rat", 2.0, 0.0, 42.0, true)); + gs.set_target(52); + nav.auto_attack = true; + + nav.reconcile_engage_nav_state(&gs); + assert_eq!(nav.nav.nav_state.lock().unwrap().state, "no_path", + "the target is XY-close but ~42u above the player — this driver can never reach it, \ + so the reconciler must leave the honest no_path alone rather than promote it to \ + engaging"); + + // CONTROL: the identical target at the SAME XY gap with no Z separation must still + // promote no_path to engaging, or the assertion above proves nothing about the Z axis. + *nav.nav.nav_state.lock().unwrap() = + eqoxide_ipc::NavStatus { state: "no_path".to_string(), ..Default::default() }; + gs.world.entities.get_mut(&52).unwrap().z = 0.0; + nav.reconcile_engage_nav_state(&gs); + assert_eq!(nav.nav.nav_state.lock().unwrap().state, eqoxide_ipc::NAV_STATE_ENGAGING, + "premise: with no Z gap the same XY-close target must promote no_path to engaging"); + } + #[test] fn zone_change_resets_stale_destination_and_path() { // #248: a destination + route left over from the PREVIOUS zone must not survive a crossing — From c10df48999d371127da08d22046760c43efb780d Mon Sep 17 00:00:00 2001 From: dhenry Date: Wed, 16 Sep 2026 10:08:31 -0400 Subject: [PATCH 2/2] fix(#1119 review): close melee-engage Z-gap review findings Addresses findings from an independent review of PR #1124: - drive_auto_engage_melee could pin a character at a stuck-forever `engaging` with zero progress: a target within MELEE_ENGAGE_MAX_Z_GAP but directly overhead/underfoot (dx=dy~=0) made wish_dir fall back to [0.0, 0.0] every tick, since there is no XY direction left to close a purely vertical gap. melee_chase_plausible now declines that case outright instead of reporting it plausible. - The Z-gap cap was symmetric (+-20) despite the real A* planner's own climb/drop limits being asymmetric (STEP_H=20 climbing, MAX_STEP_DOWN=60 descending, since a drop is gravity-assisted and a climb is not) -- added MELEE_ENGAGE_MAX_Z_DROP=60 and made the gate asymmetric to match, so a legitimately gravity-reachable target 20-60u below is no longer wrongly declined. - target_in_melee_range and melee_chase_plausible could disagree in pet mode: PET_STANDOFF_RANGE (25) is wider than MELEE_ENGAGE_MAX_Z_GAP (20), so a target at dz=22 read "in range" by raw 3-D distance while the driver silently declined to chase it. Both now share GameState::melee_z_reachable_by_chase. - Corrected MELEE_ENGAGE_MAX_Z_GAP's doc comment: collision.rs's own comment on STEP_H says a smooth ramp's aggregate rise is governed by MAX_WALK_GRADE, not the per-cell STEP_H, so the constant is a heuristic approximation of what the real planner can climb, not an exact match. - docs/http-api.md's `engaging`/`melee_engaged`/`target_in_melee_range` entries now describe the Z-gap gate. - melee_chase_plausible now returns the already-computed (dist2d, dist3d) instead of callers recomputing dist3d's sqrt a second time. Verified: full workspace test suite (0 failed) and clippy -D warnings both clean after the change; new regression + boundary tests cover the stuck-forever case, the asymmetric cap boundaries, and the pet-mode Z-gap/standoff-range mismatch. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01CWrs7cDd1NAJmaLscBjgcj --- crates/eqoxide-core/src/game_state.rs | 102 ++++++++++++-- crates/eqoxide-net/src/action_loop.rs | 196 +++++++++++++++++++++----- docs/http-api.md | 13 +- 3 files changed, 265 insertions(+), 46 deletions(-) diff --git a/crates/eqoxide-core/src/game_state.rs b/crates/eqoxide-core/src/game_state.rs index d5cb5bb9..a4888a5c 100644 --- a/crates/eqoxide-core/src/game_state.rs +++ b/crates/eqoxide-core/src/game_state.rs @@ -1084,18 +1084,45 @@ pub const MELEE_ENGAGE_RANGE: f32 = 5.0; /// `drive_auto_engage_melee`'s `PET_STANDOFF` literal. pub const PET_STANDOFF_RANGE: f32 = 25.0; -/// Max |Z gap| between player and target for melee auto-engage to treat the target as reachable -/// by DIRECT chase (#1119). `ActionLoop::drive_auto_engage_melee` steers purely in the XY plane +/// Max Z the target may sit ABOVE the player for melee auto-engage to treat it as reachable by +/// DIRECT chase (#1119). `ActionLoop::drive_auto_engage_melee` steers purely in the XY plane /// (`wish_vspeed` is always `0.0`) and relies on ground contact to close ordinary terrain relief — -/// it does no real pathfinding. A target this far above/below the player sits on a tier that only -/// real pathfinding (climb/jump planning) could reach; chasing it directly just walks the -/// character to the base of the ledge and stalls there while `nav_state` keeps claiming -/// `engaging`. Set to match `STEP_H`, the real A* planner's own per-cell walkable-rise cap -/// (`crates/eqoxide-nav/src/collision.rs`) — the same line the planner itself draws between a -/// walkable step and a real climb — so this excludes exactly the terrain the planner would also -/// refuse to cross without a real route, not ordinary stairs or slopes. +/// it does no real pathfinding. A target this far above the player sits on a tier that only real +/// pathfinding (climb/jump planning) could reach; chasing it directly just walks the character to +/// the base of the ledge and stalls there while `nav_state` keeps claiming `engaging`. +/// +/// Chosen to match `STEP_H` in `crates/eqoxide-nav/src/collision.rs` (currently `20.0`, duplicated +/// as several function-local consts there rather than a single shared export — nothing enforces +/// these literals staying equal beyond this comment, so if you change one, grep the other file for +/// `STEP_H` and update it too). **This is a heuristic, not an exact match for what the real +/// planner can climb**: per `collision.rs`'s own comment above its `STEP_H`/`MAX_STEP_DOWN` +/// definitions, what actually bounds a walkable climb there is the feet-level `path_clear` grade +/// check along the whole route (`MAX_WALK_GRADE`), which has no flat aggregate-elevation cap for a +/// smooth ramp — `STEP_H` only bounds a single discrete riser. A long, gentle ramp rising well past +/// 20u is legitimately walkable to the real planner but will be declined here; that tradeoff is +/// deliberate — never claim `engaging` on a target this XY-only driver cannot actually reach, at +/// the cost of occasionally declining a target reachable only via a long ramp. pub const MELEE_ENGAGE_MAX_Z_GAP: f32 = 20.0; +/// Max Z the target may sit BELOW the player for melee auto-engage to treat it as reachable by +/// direct chase (#1119 follow-up). Larger than [`MELEE_ENGAGE_MAX_Z_GAP`] because descending is +/// gravity-assisted — the controller's own ground-contact/falling physics closes a drop for free +/// as the character walks off an edge, whereas closing a rise requires the step-up physics to +/// actually climb it, which this driver's `wish_vspeed: 0.0` steering never asks for directly. +/// Matches `MAX_STEP_DOWN` in `collision.rs` (currently `60.0`, same no-shared-export caveat as +/// above) — the real planner's own cap on how far a single step may drop. +pub const MELEE_ENGAGE_MAX_Z_DROP: f32 = 60.0; + +/// Whether a target this far above/below the player (`dz = target_z - player_z`) is one +/// [`MELEE_ENGAGE_MAX_Z_GAP`]/[`MELEE_ENGAGE_MAX_Z_DROP`] together call reachable by direct +/// chase — asymmetric because closing a drop is gravity-assisted and closing a rise is not (see +/// those constants' docs). Shared by [`GameState::target_in_melee_range`] and +/// `eqoxide_net::ActionLoop::melee_chase_plausible` so the two cannot independently drift on the +/// Z-gap rule the way the pre-#1119 XY-only checks drifted on the XY one. +pub fn melee_z_reachable_by_chase(dz: f32) -> bool { + if dz >= 0.0 { dz <= MELEE_ENGAGE_MAX_Z_GAP } else { -dz <= MELEE_ENGAGE_MAX_Z_DROP } +} + /// All state the renderer needs for one frame. /// /// `PartialEq` is load-bearing: `eq_net::gameplay::publish_snapshot` compares the freshly-mutated @@ -2285,9 +2312,18 @@ impl GameState { /// (never sets `wish_vspeed`) — it can never actually close that gap. Reporting `true` there /// would tell a caller driving combat off this field that swings should be landing when the /// character cannot even touch the target. + /// + /// Also gated by [`melee_z_reachable_by_chase`], not just the engage radius (#1119 follow-up): + /// `PET_STANDOFF_RANGE` (25.0) is wider than `MELEE_ENGAGE_MAX_Z_GAP` (20.0), so without this a + /// pet-mode target sitting directly overhead at e.g. `dz=22` would read `true` here (within + /// 25.0 of 3-D distance) while `drive_auto_engage_melee` silently declines to chase it at all + /// — a caller would see "in range" for a target the driver never even attempts to approach. pub fn target_in_melee_range(&self) -> Option { let tid = self.target_id?; let e = self.world.entities.get(&tid).filter(|e| !e.dead)?; + if !melee_z_reachable_by_chase(e.z - self.player_z) { + return Some(false); + } let dist = e.dist_to(self.player_x, self.player_y, self.player_z); let engage = if self.pet_id.is_some() { PET_STANDOFF_RANGE } else { MELEE_ENGAGE_RANGE }; Some(dist <= engage) @@ -2569,7 +2605,8 @@ mod pose_tests_643 { #[cfg(test)] pub(crate) mod tests { use super::{CastState, ControllerHold, ControllerHoldReason, DialogueChoice, Door, GameState, - HeldMotion, LastConsider, MerchantItem, Relocation, TaskOffer, ZonePoint, make_entity}; + HeldMotion, LastConsider, MerchantItem, Relocation, TaskOffer, ZonePoint, + make_entity, melee_z_reachable_by_chase}; /// #586/#598: exhaustive property over every ordering of the levitate channels' events — /// including the FULL-SNAPSHOT (`resync_from_snapshot`) path that carries the real mid-zone @@ -2793,6 +2830,51 @@ pub(crate) mod tests { assert_eq!(gs.target_in_melee_range(), Some(true)); } + /// #1119 follow-up (review finding): a pet-mode target sitting directly overhead at `dz=22` + /// is within `PET_STANDOFF_RANGE` (25.0) by raw 3-D distance, but past `MELEE_ENGAGE_MAX_Z_GAP` + /// (20.0) — the Z bound `drive_auto_engage_melee` actually chases against. Without the Z-gap + /// gate here, this predicate would say `true` ("in range") for a target the driver never even + /// attempts to approach, since it declines the chase entirely on the Z bound. + #[test] + fn target_in_melee_range_is_false_for_a_pet_mode_target_within_standoff_but_past_the_z_gap() { + let mut gs = GameState::new(); + gs.player_x = 0.0; + gs.player_y = 0.0; + gs.player_z = 0.0; + gs.pet_id = Some(64); + gs.upsert_entity(make_entity(9, "an overhead rat", 0.0, 0.0, 22.0, true)); + gs.set_target(9); + assert_eq!(gs.target_in_melee_range(), Some(false), + "dz=22 is within PET_STANDOFF_RANGE(25) by raw distance but past \ + MELEE_ENGAGE_MAX_Z_GAP(20) — the driver declines this chase, so the predicate must \ + not claim it's in range"); + } + + /// CONTROL for the test above: the same pet-mode setup at `dz=18` (inside the Z-gap cap) must + /// still read `true`, or the test above proves nothing about the Z-gap gate specifically. + #[test] + fn target_in_melee_range_is_true_for_a_pet_mode_target_within_both_standoff_and_z_gap() { + let mut gs = GameState::new(); + gs.player_x = 0.0; + gs.player_y = 0.0; + gs.player_z = 0.0; + gs.pet_id = Some(64); + gs.upsert_entity(make_entity(9, "an overhead rat", 0.0, 0.0, 18.0, true)); + gs.set_target(9); + assert_eq!(gs.target_in_melee_range(), Some(true)); + } + + // --- GameState::melee_z_reachable_by_chase --- + + #[test] + fn melee_z_reachable_by_chase_is_asymmetric_at_its_boundaries() { + assert!(melee_z_reachable_by_chase(20.0), "climb boundary is inclusive"); + assert!(!melee_z_reachable_by_chase(20.01), "just past the climb cap must be false"); + assert!(melee_z_reachable_by_chase(-60.0), "drop boundary is inclusive"); + assert!(!melee_z_reachable_by_chase(-60.01), "just past the drop cap must be false"); + assert!(melee_z_reachable_by_chase(0.0)); + } + // --- GameState::log_msg --- #[test] diff --git a/crates/eqoxide-net/src/action_loop.rs b/crates/eqoxide-net/src/action_loop.rs index 785d9cf9..959c3846 100644 --- a/crates/eqoxide-net/src/action_loop.rs +++ b/crates/eqoxide-net/src/action_loop.rs @@ -2804,14 +2804,31 @@ impl ActionLoop { /// #1119: shared by `reconcile_engage_nav_state`'s `want_engage` gate and /// `drive_auto_engage_melee`'s own outer gate — kept as one function precisely so the two /// cannot drift the way the original 2D-only distance calc did (that drift is what made the - /// bug: both sites had matching-but-wrong XY-only predicates). True only when a target is - /// both within the ~200u "worth walking to" radius AND its |Z gap| is within - /// [`eqoxide_core::game_state::MELEE_ENGAGE_MAX_Z_GAP`] — beyond that gap, this driver's - /// XY-only steering (`wish_vspeed` always `0.0`) cannot physically reach the target, so it - /// must not be reported/promoted as an engage in progress. - fn melee_chase_plausible(dx: f32, dy: f32, dz: f32) -> bool { - use eqoxide_core::game_state::MELEE_ENGAGE_MAX_Z_GAP; - dz.abs() <= MELEE_ENGAGE_MAX_Z_GAP && (dx * dx + dy * dy + dz * dz).sqrt() < 200.0 + /// bug: both sites had matching-but-wrong XY-only predicates). `Some((dist2d, dist3d))` only + /// when a target is within the ~200u "worth walking to" radius, its Z gap is within + /// [`eqoxide_core::game_state::melee_z_reachable_by_chase`], AND there is XY distance left for + /// this driver to actually close — beyond the Z bound, or with the target already directly + /// overhead/underfoot and still out of `engage` range, this driver's XY-only steering + /// (`wish_vspeed` always `0.0`) cannot physically reach the target, so it must not be + /// reported/promoted as an engage in progress. `engage` is the caller's already-resolved stop + /// distance (`MELEE_ENGAGE_RANGE`/`PET_STANDOFF_RANGE`) — needed for that last check: without + /// it, a target sitting almost directly overhead with a Z gap inside the cap but outside + /// `engage` range would report "plausible" forever with nothing left to walk toward, pinning + /// `nav_state` at `engaging` with zero progress (#1119 near-zero-XY stuck case, review + /// finding). Returns the already-computed `(dist2d, dist3d)` so callers don't redo the sqrt. + fn melee_chase_plausible(dx: f32, dy: f32, dz: f32, engage: f32) -> Option<(f32, f32)> { + use eqoxide_core::game_state::melee_z_reachable_by_chase; + if !melee_z_reachable_by_chase(dz) { + return None; + } + let dist2d = (dx * dx + dy * dy).sqrt(); + let dist3d = (dx * dx + dy * dy + dz * dz).sqrt(); + if dist2d <= 0.01 && dist3d > engage { + // Directly above/below the target: no XY direction left to close, so more "chasing" + // can never shrink dist3d toward engage range — decline rather than loop forever. + return None; + } + (dist3d < 200.0).then_some((dist2d, dist3d)) } fn reconcile_engage_nav_state(&mut self, gs: &GameState) { @@ -2822,17 +2839,18 @@ impl ActionLoop { // not be promoted to `engaging` — that would silently overwrite a prior, honest `no_path` // (from a real pathfinding attempt at the same unreachable target) with an optimistic word // this driver cannot make good on. + use eqoxide_core::game_state::{MELEE_ENGAGE_RANGE, PET_STANDOFF_RANGE}; let want_engage = self.auto_attack && gs.target_id .and_then(|tid| gs.world.entities.get(&tid)) .filter(|e| !e.dead) - .map(|e| { + .is_some_and(|e| { let dx = e.x - gs.player_x; let dy = e.y - gs.player_y; let dz = e.z - gs.player_z; - Self::melee_chase_plausible(dx, dy, dz) - }) - .unwrap_or(false); + let engage = if gs.pet_id.is_some() { PET_STANDOFF_RANGE } else { MELEE_ENGAGE_RANGE }; + Self::melee_chase_plausible(dx, dy, dz, engage).is_some() + }); if want_engage { if !self.engage_active { @@ -2915,41 +2933,39 @@ impl ActionLoop { let dx = ex - gs.player_x; let dy = ey - gs.player_y; let dz = ez - gs.player_z; + // With a pet, DON'T walk into melee — the pet holds aggro (PET_ATTACK) and a + // squishy caster who closes to melee just gets killed (a level-1 necro died + // to a level-4 skeleton this way). Stand off ~25u: out of the mob's melee but + // close enough to loot the corpse after the pet kills it. + // + // #1007 follow-up: these thresholds moved to `eqoxide_core::game_state` (as + // `MELEE_ENGAGE_RANGE`/`PET_STANDOFF_RANGE`) so `GameState::target_in_melee_ + // range` — the `/observe/debug` disclosure of "still closing" vs "in range, + // not landing swings" — can describe this driver's own behavior instead of + // carrying a second copy of these numbers that could silently drift from it. + use eqoxide_core::game_state::{MELEE_ENGAGE_RANGE, PET_STANDOFF_RANGE}; + let engage = if gs.pet_id.is_some() { PET_STANDOFF_RANGE } else { MELEE_ENGAGE_RANGE }; // #1119: worth chasing at all only within `melee_chase_plausible`'s 3-D radius // AND Z-gap bound — a target on a ledge too far above/below to ever reach via - // this driver's XY-only steering (`wish_vspeed` stays `0.0` below) must not be - // engaged, or the character just walks to the base of the ledge and stalls - // there while still reporting `engaging`. - if Self::melee_chase_plausible(dx, dy, dz) { - // With a pet, DON'T walk into melee — the pet holds aggro (PET_ATTACK) and a - // squishy caster who closes to melee just gets killed (a level-1 necro died - // to a level-4 skeleton this way). Stand off ~25u: out of the mob's melee but - // close enough to loot the corpse after the pet kills it. - // - // #1007 follow-up: these thresholds moved to `eqoxide_core::game_state` (as - // `MELEE_ENGAGE_RANGE`/`PET_STANDOFF_RANGE`) so `GameState::target_in_melee_ - // range` — the `/observe/debug` disclosure of "still closing" vs "in range, - // not landing swings" — can describe this driver's own behavior instead of - // carrying a second copy of these numbers that could silently drift from it. - use eqoxide_core::game_state::{MELEE_ENGAGE_RANGE, PET_STANDOFF_RANGE}; - let engage = if gs.pet_id.is_some() { PET_STANDOFF_RANGE } else { MELEE_ENGAGE_RANGE }; + // this driver's XY-only steering (`wish_vspeed` stays `0.0` below), or one + // directly overhead/underfoot with no XY left to close, must not be engaged, or + // the character just walks to the base of the ledge (or stands still) and + // stalls there while still reporting `engaging`. + if let Some((dist2d, dist3d)) = Self::melee_chase_plausible(dx, dy, dz, engage) { // #1119: the XY-only distance the STEERING vector normalizes by, kept // separate from `dist3d` (the actual "am I in range to swing" measure, // matching `target_in_melee_range`) — this driver can only ever close an XY // gap, so the direction it walks must stay XY-only even though whether it // has ARRIVED is judged in 3-D. - let dist2d = (dx * dx + dy * dy).sqrt(); - let dist3d = (dx * dx + dy * dy + dz * dz).sqrt(); let hdg = if dist2d > 0.01 { eq_heading(dx, dy) } else { gs.player_heading }; gs.player_heading = hdg; if dist3d > engage { // Drive the controller toward the target (it owns collide-and-slide). let swim = self.collision.read().unwrap().as_ref() .is_some_and(|c| c.in_water([gs.player_x, gs.player_y, gs.player_z])); - // `dist2d` can be ~0 while `dist3d > engage` (a target almost directly - // overhead/underfoot, still inside the Z-gap bound) — there is no XY - // direction left to close, so hold rather than divide by ~0 (#1119). - let wish_dir = if dist2d > 0.01 { [dx / dist2d, dy / dist2d] } else { [0.0, 0.0] }; + // `melee_chase_plausible` already declined the `dist2d <= 0.01` case + // above when `dist3d > engage`, so `dist2d` is guaranteed > 0.01 here. + let wish_dir = [dx / dist2d, dy / dist2d]; *self.controller.nav_intent.lock().unwrap() = Some(MoveIntent { wish_dir, wish_vspeed: 0.0, @@ -8728,6 +8744,118 @@ mod tests { "premise: at the same XY gap with no Z gap the driver must still engage"); } + /// #1119 review finding — a target directly overhead/underfoot (dx=dy=0) with a Z gap inside + /// `MELEE_ENGAGE_MAX_Z_GAP` but outside `MELEE_ENGAGE_RANGE` has no XY direction left to + /// close. The original `melee_chase_plausible` (a bare Z-gap + radius check) reported this as + /// plausible forever; `wish_dir` fell back to `[0.0, 0.0]`, and the character held position + /// broadcasting `engaging` with zero progress on every tick — a narrower recurrence of the + /// exact #1119 bug this PR set out to fix. The driver must decline instead. + #[tokio::test] + async fn drive_auto_engage_melee_declines_a_directly_overhead_target_with_no_xy_left_to_close() { + let (mut stream, _rx) = crate::transport::test_stream(0, 0).await; + let group: eqoxide_ipc::GroupShared = + std::sync::Arc::new(std::sync::Mutex::new(eqoxide_ipc::GroupSnapshot::default())); + let mut nav = test_action_loop(group); + + let mut gs = GameState::new(); + gs.player_id = 9; + gs.player_x = 0.0; + gs.player_y = 0.0; + gs.player_z = 0.0; + gs.upsert_entity(eqoxide_core::game_state::make_entity(53, "an overhead rat", 0.0, 0.0, 12.0, true)); + gs.set_target(53); + nav.auto_attack = true; + + assert!(!nav.drive_auto_engage_melee(&mut stream, &mut gs), + "dz=12 is within the Z-gap cap but with dx=dy=0 there is no XY direction to walk — \ + the driver must decline rather than hold position forever advertising engaging"); + assert!(nav.controller.nav_intent.lock().unwrap().is_none(), + "declining must not leave a stale zero-vector nav intent behind"); + } + + /// #1119 review finding — `MELEE_ENGAGE_MAX_Z_GAP` (climb) is an inclusive boundary: exactly + /// at the cap the driver must still engage, and one unit past it must decline. + #[tokio::test] + async fn drive_auto_engage_melee_treats_max_z_gap_boundary_inclusively() { + let (mut stream, _rx) = crate::transport::test_stream(0, 0).await; + let group: eqoxide_ipc::GroupShared = + std::sync::Arc::new(std::sync::Mutex::new(eqoxide_ipc::GroupSnapshot::default())); + let mut nav = test_action_loop(group); + + let mut gs = GameState::new(); + gs.player_id = 9; + gs.player_x = 0.0; + gs.player_y = 0.0; + gs.player_z = 0.0; + gs.upsert_entity(eqoxide_core::game_state::make_entity(54, "a boundary rat", 10.0, 0.0, 20.0, true)); + gs.set_target(54); + nav.auto_attack = true; + + assert!(nav.drive_auto_engage_melee(&mut stream, &mut gs), + "dz==20.0 (== MELEE_ENGAGE_MAX_Z_GAP) is the inclusive boundary and must still engage"); + + gs.world.entities.get_mut(&54).unwrap().z = 20.01; + assert!(!nav.drive_auto_engage_melee(&mut stream, &mut gs), + "dz==20.01 is just past MELEE_ENGAGE_MAX_Z_GAP and must decline"); + } + + /// #1119 review finding — descending is gravity-assisted, so the driver tolerates a much + /// larger downward gap (`MELEE_ENGAGE_MAX_Z_DROP`, matching the real planner's + /// `MAX_STEP_DOWN`) than upward (`MELEE_ENGAGE_MAX_Z_GAP`). A flat symmetric +/-20 cap — what + /// this PR shipped with initially — would wrongly decline a target 60u below that the old + /// XY-only code (and gravity) could reach. + #[tokio::test] + async fn drive_auto_engage_melee_allows_a_much_larger_downward_gap_than_upward() { + let (mut stream, _rx) = crate::transport::test_stream(0, 0).await; + let group: eqoxide_ipc::GroupShared = + std::sync::Arc::new(std::sync::Mutex::new(eqoxide_ipc::GroupSnapshot::default())); + let mut nav = test_action_loop(group); + + let mut gs = GameState::new(); + gs.player_id = 9; + gs.player_x = 0.0; + gs.player_y = 0.0; + gs.player_z = 0.0; + gs.upsert_entity(eqoxide_core::game_state::make_entity(55, "a rat below a ledge", 10.0, 0.0, -60.0, true)); + gs.set_target(55); + nav.auto_attack = true; + + assert!(nav.drive_auto_engage_melee(&mut stream, &mut gs), + "dz=-60 (== MELEE_ENGAGE_MAX_Z_DROP) is a gravity-assisted drop, not a climb, and \ + must still engage"); + + gs.world.entities.get_mut(&55).unwrap().z = -60.01; + assert!(!nav.drive_auto_engage_melee(&mut stream, &mut gs), + "dz=-60.01 is just past MELEE_ENGAGE_MAX_Z_DROP and must decline"); + } + + /// #1119 review finding — `PET_STANDOFF_RANGE` (25.0) is wider than `MELEE_ENGAGE_MAX_Z_GAP` + /// (20.0). Without the Z-gap gate running independently of `engage`, a pet-mode target at + /// dz=22 would look "in range" by raw distance while the driver silently declines to chase + /// it at all — see the matching `GameState::target_in_melee_range` fix for the same gap. + #[tokio::test] + async fn drive_auto_engage_melee_declines_a_pet_mode_target_within_standoff_but_past_the_z_gap() { + let (mut stream, _rx) = crate::transport::test_stream(0, 0).await; + let group: eqoxide_ipc::GroupShared = + std::sync::Arc::new(std::sync::Mutex::new(eqoxide_ipc::GroupSnapshot::default())); + let mut nav = test_action_loop(group); + + let mut gs = GameState::new(); + gs.player_id = 9; + gs.player_x = 0.0; + gs.player_y = 0.0; + gs.player_z = 0.0; + gs.pet_id = Some(64); + gs.upsert_entity(eqoxide_core::game_state::make_entity(56, "a pet-mode overhead rat", 5.0, 0.0, 22.0, true)); + gs.set_target(56); + nav.auto_attack = true; + + assert!(!nav.drive_auto_engage_melee(&mut stream, &mut gs), + "dz=22 is within PET_STANDOFF_RANGE(25) by raw distance but past \ + MELEE_ENGAGE_MAX_Z_GAP(20) — the Z-gap gate must decline regardless of the pet's \ + wider standoff radius"); + } + /// #1119 — the reconciler must not silently overwrite a prior, honest `no_path` (from a real /// pathfinding attempt) with the optimistic `engaging` word when the only live target is on /// an unreachable ledge. `no_path` is `TERMINAL_NAV_STATES`-terminal precisely so a caller can diff --git a/docs/http-api.md b/docs/http-api.md index bda0ddd1..558a804c 100644 --- a/docs/http-api.md +++ b/docs/http-api.md @@ -372,7 +372,7 @@ machine-readable *why*, `null` unless a state has one). Together they are how yo | `navigating_partial` | Walking a **partial** route: the search was cut short, so this is *not* a route to your goal — it's progress toward a frontier, and it will re-plan from the far end. Usually resolves to `navigating` or `arrived`. | `search_node_cap` | | `navigating_stalled` | **A route is committed and the walker is NOT executing it.** The body has neither advanced its route cursor nor improved its closest approach to the goal for `NAV_STUCK_TICKS` (20) walker ticks — about 3 s. **Only fixed-destination goals reach this state** — see the limitation under `nav_stall` below. This is **not terminal** — it is not on the terminal list below, and the walker goes on backing off and re-pathing under it. **The verdict latches:** a re-path or a back-off does not clear it. It exists because the alternative is worse — before #851 a walker circling under a ledge published plain `navigating` for the whole ~32 s it spent recovering, and an agent polling `nav_state` had no way to tell it apart from a walk that was working. Read **`nav_stall`** (below) for how long and how many re-paths. If the walker never recovers you will eventually get `blocked` (`walker_stalled` or `local_no_way_through`, at 8 re-path attempts) or `blocked`/`no_progress` (60 s). | `goal_z_snapped` (see below), `search_node_cap`, or — (it carries whatever reason the committed route carries) | | `following` | A `/follow` chase has caught up; holding near the leader, still latched. | — | -| `engaging` | Auto-attack is pursuing a live target into melee range (#1007) — `drive_auto_engage_melee` is steering the body at `target_id`. **TRANSIENT**, retires to `idle` / `melee_disengaged` the first tick the pursuit ends (target died/despawned, moved beyond ~200u, `auto_attack` turned off, or a fresh `/move/{goto,follow,zone_cross}` disengaged it). `nav_goal` is `null` (a live entity, not a fixed point). **Not a terminal state** — never read it as a finished outcome. | — | +| `engaging` | Auto-attack is pursuing a live target into melee range (#1007) — `drive_auto_engage_melee` is steering the body at `target_id`. **TRANSIENT**, retires to `idle` / `melee_disengaged` the first tick the pursuit ends (target died/despawned, moved beyond ~200u OR beyond the Z-gap bound below, `auto_attack` turned off, or a fresh `/move/{goto,follow,zone_cross}` disengaged it). Also never entered/retires immediately for a target outside the Z-gap bound — up to 20u ABOVE the player (a climb) or 60u BELOW (a gravity-assisted drop) — since this driver's XY-only steering (no vertical speed, no pathfinding) can never actually reach a target past that gap (#1119); see `target_in_melee_range` below for the same bound. `nav_goal` is `null` (a live entity, not a fixed point). **Not a terminal state** — never read it as a finished outcome. | — | | `arrived` | Reached the goal. | `goal_z_snapped` (see below) or — | | `no_path` | **No route was published for this goal — read `nav_reason` before concluding one cannot exist.** For most reasons it is definitive: the planner searched to completion, so do not retry the same goal, pick another. **Not all of them are.** `planner_dead` means the pathfinding worker died, and on `/move/zone_cross` the `region_data_*` reasons (#815) mean the zone's region map could not be read — neither is a completed search, and both are **"I don't know", not "no"** — the same reading `search_exhausted` carries, but reported under this state rather than that one. The state itself is still terminal — nothing will retire it for you — so the retry decision is `nav_reason`'s to make, not this row's. | see below | | `search_exhausted` | The planner **gave up**. This is **"I don't know", not "no"** — a route may well exist. Try a nearer waypoint. | `search_node_cap` | @@ -473,7 +473,7 @@ those call sites now names itself. The complete set of ways to reach `idle`: | `respawned` | The `dead` state cleared because the character came back up (#644) — a real death ended. Since #1000 it is published **only** for `dead`; the HP-only halt retires under `hp_restored` instead, so this word never claims a respawn that did not happen. | | `hp_restored` | The `halted_hp_zero` state cleared because `hp` came back above 0 (#1000). **Nothing died and nothing respawned** — that is the whole reason it is not `respawned`. | | `zone_cross_dropped_unhandled` | **A client bug, reported instead of hidden.** Your `/move/zone_cross` was consumed by the client and produced no outcome at all — no walk, no crossing, no refusal. Nothing is in flight and nothing will happen; retry, or use `/move/goto`. If you see this, please file it with the zone and your position: it means a code path took your request and wrote nothing, which is exactly the defect the backstop that emits this reason exists to make visible (#725). | -| `melee_engaged` | Companion to `nav_state: engaging`. Auto-attack has a live target within the ~200u engage radius and is steering toward it. If a `/move/goto` was in flight when the pursuit began it was superseded once (a single `nav_goal_id` bump). | +| `melee_engaged` | Companion to `nav_state: engaging`. Auto-attack has a live target within the ~200u engage radius AND within the Z-gap bound (20u climb / 60u drop, #1119) and is steering toward it. If a `/move/goto` was in flight when the pursuit began it was superseded once (a single `nav_goal_id` bump). | | `melee_disengaged` | On the `idle` that `engaging` retires to: the melee pursuit ended and nothing replaced it (target died/despawned, moved out of range, `auto_attack` off, or a fresh `/move/*` disengaged it). Distinct from `stopped` (you asked via `/move/stop`) and `goto_superseded` (manual movement took over). | ### `target_cleared_reason` / `target_in_melee_range` — disambiguating two honest-but-broken-looking states (#1007) @@ -503,6 +503,15 @@ look identical on `/observe/debug`: and "in range but landing no swings" (`target_in_melee_range: true`) — two very different problems that look the same without it. + **Also `false` for a target this driver's XY-only steering can never actually reach, regardless + of raw distance (#1119).** `drive_auto_engage_melee` never sets vertical speed and does no real + pathfinding, so a target on an elevated/sunken ledge can sit well inside the ring above by + straight-line distance while being physically unreachable by walking there. The field is gated + on the same asymmetric Z-gap bound the driver itself chases against: up to 20 units ABOVE the + player (a climb) or up to 60 units BELOW (a gravity-assisted drop) — past either bound this reads + `false` even if the raw 3-D distance is inside the 5/25-unit ring above (this matters most in + pet mode, where the 25-unit standoff ring is wider than the 20-unit climb bound). + ### `target_dead` — is the CURRENT target a corpse? (#1117 follow-up) **`player.target_dead`** (`bool | null`) — same gating as `target_con`/`target_level` above: `null`