Skip to content

Refuse a path whose last leg climbs past a jump - #2212

Closed
sunzenshen wants to merge 1 commit into
NeotokyoRebuild:masterfrom
sunzenshen:bot-trivial-path-unusable
Closed

sunzenshen wants to merge 1 commit into
NeotokyoRebuild:masterfrom
sunzenshen:bot-trivial-path-unusable

Conversation

@sunzenshen

@sunzenshen sunzenshen commented Oct 9, 2026 •

Copy link
Copy Markdown
Contributor

Description

CNEOBotPathCompute accepts a path whose last leg ends far above the floor it starts from, and bots then stand under the goal. Path::Compute builds such a path when GetNearestNavArea finds no area for the goal position: the search then stops at the first area for which CNavArea::Contains reports the goal inside it. Contains checks the point only against areas whose extent overlaps its own, height included, so a flat floor area directly under the goal "contains" a point 170 u above it. The search reports the goal reached and ends the path with a straight leg from that floor up to the goal (when the bot already stands in that area, the same line comes from BuildTrivialPath). The path follower raises FAIL_FELL_OFF ("goal is high above us") on every update while the bot stands under the goal, the behavior computes the same path again, and the bot stands there until it is shot or its behavior changes.

ntre_apparatus_dm hits this under the top of a ramp at (720, 137, 584): bots retreating to cover on the ramp, or fighting from it, walk to the floor below and stand. With the navmesh from NeotokyoRebuild/neoAssets#157 it is the map's top stuck spot, about 14 FAIL_FELL_OFF events a 30-bot match toward that cover; with this PR, 0.3.

Dependencies and status

Needs:

  • None.

Helped by:

  • None.

Helps:

Pending before review:

  • A Windows build (built and tested on Linux so far).

What this PR does

  1. A path whose last leg climbs past a jump is not usable (neo_bot_path_compute.cpp). IsPathUsable refuses a path, complete or partial, when its goal ends more than GetMaxJumpHeight() above the floor of the area its last leg starts from, measured on that area's own plane. Legs with a planned jump-up, gap jump, drop or ladder at either end are left alone. CNEOBotPathCompute then runs its existing second pass (fastest route, reservations ignored) and returns false if that path is refused too. A path that reaches its goal area never trips the check: its last leg lies in that area and ends on its surface.
  2. RetreatToCover acts on a failed path (neo_bot_retreat_to_cover.cpp). It ignored the result of its path compute. On a failure it now picks another cover area, and ends ("No path to my cover") when there is none or the pick repeats.

How to test

  1. ntre_apparatus_dm has no navmesh on master: copy neo/maps/nav/ntre_apparatus_dm.nav from ntre_apparatus_dm: first navmesh neoAssets#157 into the game's maps/nav/ folder.
  2. map ntre_apparatus_dm, sv_cheats 1, neo_bot_quota 30, nb_debug behavior, and join spectator.
  3. Watch the floor under the top of the ramp, near (720, 140, 584); a bot goes there about once a 5-minute match. Without this PR, a bot in RetreatToCover or Attack walks to just east of x 720 and stands still under the ramp's edge for a second or more (developer 1 with nb_debug path prints OnMoveToFailure( FAIL_FELL_OFF ) on every update). With this PR, it takes the ramp or goes to other cover; the behavior output shows "No path to my cover" when a re-pick repeats.

Risk

The check runs on every path a NEO bot computes (about 30 call sites); it refuses only paths whose last leg climbs past a jump, which the follower failed on arrival before this PR. What a refused caller does:

  • Attack drops its cover area and chases the threat (existing branch); the next cover search waits for the enemy to be seen again and for sv_neo_bot_attack_cover_search_interval (1 s).
  • RetreatToCover re-picks its cover (above). While every pick fails, Attack and RetreatToCover can hand over to each other each update, with the bot standing as it did before.
  • Callers that check the result or COMPLETE_PATH (seek and destroy, CTG and JGR seek and capture, juggernaut, get health, move to vantage point) take their existing "no path" branch.
  • Callers that ignore the result (CTG carrier, escort, lone wolf, ambush, grenade throw, retreat from grenade or hazard, nav entity move and destroy, approach object, detpack) hold an invalid path until their own re-path timer: the bot stands instead of walking toward the point under the goal, where the stock path ended in the same FAIL_FELL_OFF. On CTG maps, escorting or intercepting a ghost carrier on a ledge with no nav area found under it is the case to watch.
  • Not changed: a bot coming from farther away still gets the path that ends on the floor under the cover, walks it, and is refused only when it re-paths from there. One wasted walk per retreat, no loop; removing it means changing CNavArea::Contains or Path::Compute ("Considered, not included").
  • Fall deaths on apparatus leaned up in the first 24 pairs (x1.26 [0.97, 1.59]) and down in 16 more (x0.81 [0.55, 1.18]); read as noise (Results).
  • Kills a match over the 28 other maps: -1.7 [-3.1, -0.4] pooled over 117 pairs, carried by the three JGR maps (-8.5 [-15.7, -1.6] over 17 pairs); 22 more JGR pairs gave -3.6 [-15.0, +5.9]. Open: no mechanism of this PR has been tied to it, and it is not shown to be noise either (Results).

Cost

Path computation only (not per tick): two segment-type checks, one CNavArea::GetZ and one comparison per pass of CNEOBotPathCompute; no trace. RetreatToCover runs one more cover search per failed path compute, the same search it runs when it starts.

Results

Every match below: Release builds on Linux of upstream master at #2175 with #2198 and #2176, without and with this PR. The build with this PR also carried a change #2198 leaves out (a gap jump's goal handed on to its landing once the bot is below the take-off; #2198's "Considered, not included"), so every "with this PR" number below is this PR plus that hand-off. Measured on its own against #2198 as published (9 maps, 72 pairs, 30 bots, same builds otherwise), the hand-off moved fall deaths x0.87 [0.55, 1.44], FAIL_FELL_OFF x1.02 [0.77, 1.32], stuck episodes x1.19 [1.00, 1.41] and kills -0.6 a match [-1.7, +0.5]. Both builds have the same test-only logging (kills, stuck events, move failures, match end). #157's navmesh with edits in progress at other spots; 30 bots, default weapon damage (1.485), 15 s pre-round freeze; both arms of each pair in the same batch of 8 parallel servers. Every match reached match end with 0 asserts and a clean exit. "Stuck" is OnStuck plus FAIL_STUCK move failures, without the round-start spawn crowd, SeekWeapon and dead bots; an episode is one bot's events less than 2 s apart. Fall deaths are deaths to trigger_hurt or the world. Intervals are 95 % bootstrap intervals (4000 draws) of the paired ratio.

ntre_apparatus_dm, 24 pairs of one-round, 5-minute DM matches (48 matches):

a match without with this PR paired
FAIL_FELL_OFF toward the ramp cover 14.04 0.29
episodes at the spot (box 256 u around it) with FAIL_FELL_OFF 1.42 0.17
episodes at the spot, any stuck or move failure 2.67 1.71
FAIL_FELL_OFF move failures, whole map 21.3 7.9 x0.51 [0.36, 0.72], 18 pairs down / 5 up
FAIL_NO_PATH move failures 27.8 27.3 x0.94 [0.78, 1.15]
stuck episodes 9.8 10.0 x1.05 [0.87, 1.28]
fall deaths 4.2 5.2 x1.26 [0.97, 1.59], 14 pairs up / 8 down
player kills 245.6 245.5 x1.00 [0.98, 1.02]

No new stuck spot above one episode a match. In 10 more matches with this PR and nb_debug behavior, no bot failed toward the ramp cover, and RetreatToCover ended with "No path to my cover" once.

Fall deaths: the lean in the table did not replicate. The 24 pairs above had 24 more fall deaths with this PR (13 at the edges of three gaps, areas 3404, 8352 and 8340, already marked PRECISE and CLIFF; 4 at area 74). 16 more pairs on the same mesh and builds, run later, gave 83 -> 67 (x0.81 [0.55, 1.18]) with no rise at any of those four areas (3404 3 -> 2, 8352 4 -> 2, 8340 4 -> 4, area 74 1 -> 2). Over all 40 pairs the ratio of sums is x1.04. Read as noise; no mechanism of this PR that reaches a gap edge has been found.

No run at 12 bots exists for this PR.

Every other map. The 28 other ntre_ maps in scope, each on the navmesh at the head of its neoAssets PR, without and with this PR (the same two builds as above, 30 bots, damage 1.485, 15 s freeze, Release, Linux, 8 parallel servers): 1 round a match, CTG 3 minutes, JGR and TDM 5 minutes, the ghost or juggernaut pinned to one spawn a match (up to 4 spawns a map, each once; bullet_tdm and isolation 4, yard 3 spawns x 3 repeats). 117 pairs, 234 matches, every one at match end with 0 asserts and a clean exit. Cells are totals over the map's matches, without -> with this PR; kills a match; captures and eliminations are rounds of the map's pairs. Fall deaths are near zero on these maps in either build (6 -> 9 in all, skyline 6 -> 7, sentinel_ctg 0 -> 1, yard 0 -> 1).

map matches falls FAIL_FELL_OFF FAIL_NO_PATH stuck episodes kills a match captures eliminations
ntre_ballistrade_ctg 8 0 -> 0 2 -> 2 11 -> 7 2 -> 6 21.5 -> 19.0 3 -> 3 1 -> 1
ntre_bullet_tdm 8 0 -> 0 29 -> 44 17 -> 20 10 -> 13 182.8 -> 182.2 - -
ntre_dawn_ctg 8 0 -> 0 12 -> 4 2 -> 4 8 -> 7 21.5 -> 23.5 1 -> 1 3 -> 3
ntre_decom_ctg 8 0 -> 0 3 -> 17 10 -> 6 4 -> 4 24.8 -> 21.2 1 -> 1 3 -> 3
ntre_disengage_ctg 8 0 -> 0 0 -> 0 22 -> 25 2 -> 1 24.2 -> 24.0 0 -> 0 4 -> 4
ntre_dusk_ctg 8 0 -> 0 8 -> 4 7 -> 12 13 -> 3 25.8 -> 24.2 1 -> 0 1 -> 4
ntre_engage_ctg 8 0 -> 0 3 -> 4 15 -> 19 3 -> 1 26.0 -> 21.5 0 -> 1 3 -> 3
ntre_ghost_ctg 8 0 -> 0 19 -> 17 0 -> 5 8 -> 14 24.8 -> 24.8 0 -> 0 4 -> 4
ntre_isolation_ctg 8 0 -> 0 27 -> 12 12 -> 8 7 -> 10 22.8 -> 23.5 0 -> 0 4 -> 4
ntre_marketa_ctg 8 0 -> 0 3 -> 0 5 -> 0 7 -> 5 26.0 -> 24.2 0 -> 1 4 -> 3
ntre_oilstain_ctg 8 0 -> 0 2 -> 2 34 -> 18 2 -> 2 24.8 -> 23.8 0 -> 0 4 -> 4
ntre_pissalley_ctg 8 0 -> 0 5 -> 0 4 -> 0 7 -> 9 23.5 -> 25.0 0 -> 0 4 -> 3
ntre_redlight_ctg 8 0 -> 0 2 -> 1 51 -> 18 8 -> 5 22.5 -> 25.5 0 -> 0 4 -> 4
ntre_ridgeline_ctg 8 0 -> 0 6 -> 8 9 -> 12 5 -> 6 27.0 -> 26.5 0 -> 0 4 -> 4
ntre_rise_ctg 8 0 -> 0 1 -> 0 0 -> 0 12 -> 2 25.0 -> 24.2 0 -> 0 4 -> 4
ntre_rogue_ctg 8 0 -> 0 5 -> 3 10 -> 23 1 -> 7 23.8 -> 24.5 2 -> 0 2 -> 4
ntre_saitama_ctg 8 0 -> 0 3 -> 6 7 -> 6 13 -> 11 24.2 -> 25.5 2 -> 0 2 -> 4
ntre_sentinel_ctg 8 0 -> 1 10 -> 7 14 -> 10 7 -> 7 26.0 -> 24.2 0 -> 1 4 -> 3
ntre_sentinel_jgr 8 0 -> 0 53 -> 12 59 -> 60 24 -> 26 109.0 -> 99.8 - -
ntre_shrine_ctg 8 0 -> 0 1 -> 1 1 -> 10 8 -> 8 26.5 -> 25.5 0 -> 0 4 -> 4
ntre_skyline_ctg 8 6 -> 7 6 -> 3 4 -> 5 15 -> 14 23.8 -> 23.0 1 -> 0 3 -> 4
ntre_subsurface_ctg 8 0 -> 0 2 -> 2 10 -> 3 11 -> 7 21.8 -> 26.2 1 -> 0 3 -> 4
ntre_tarmac_ctg 8 0 -> 0 2 -> 0 1 -> 0 3 -> 5 27.0 -> 23.0 0 -> 0 4 -> 4
ntre_terminal_jgr 8 0 -> 0 17 -> 23 23 -> 14 27 -> 22 75.0 -> 61.2 - -
ntre_threadplate_ctg 8 0 -> 0 2 -> 9 9 -> 4 10 -> 5 26.0 -> 24.2 1 -> 0 3 -> 4
ntre_transit_ctg 8 0 -> 0 14 -> 8 20 -> 34 8 -> 7 26.0 -> 25.5 0 -> 0 4 -> 4
ntre_vtol_ctg 8 0 -> 0 27 -> 3 0 -> 13 12 -> 7 23.8 -> 23.0 0 -> 0 4 -> 4
ntre_yard_jgr 18 0 -> 1 245 -> 151 196 -> 211 80 -> 50 104.3 -> 98.4 - -

Pooled over the 117 pairs (ratio of sums, with this PR over without): FAIL_FELL_OFF 509 -> 343, x0.67 [0.51, 0.88]; FAIL_NO_PATH 553 -> 547, x0.99 [0.75, 1.30]; stuck episodes 317 -> 264, x0.83 [0.68, 1.02]; stuck events 1230 -> 936, x0.76 [0.58, 1.00]; fall deaths 6 -> 9; kills -1.7 a match [-3.1, -0.4] (CTG, 96 pairs, -0.5 [-1.4, +0.3]; JGR, 17 pairs, -8.5 [-15.7, -1.6]; TDM, 4 pairs, -0.5); captures 13 -> 8 and eliminations 80 -> 87 of 117 rounds. FAIL_NO_PATH is this PR's own risk metric (a refused path is reported as no path) and is flat; the maps where it rose from near zero (vtol 0 -> 13, shrine 1 -> 10, ghost 0 -> 5) are scattered Attack events with no goal area, not one spot, and the pooled count does not move. The overhang-heavy maps (bullet_tdm, sentinel_jgr, tarmac, threadplate, transit) do not stand apart from the rest: sentinel_jgr and transit's FAIL_FELL_OFF fell, bullet_tdm's and threadplate's rose, all within the per-map scatter.

Three follow-ups on what moved in the first wave, same setup: (1) captures 13 -> 8 across 8 maps, 1 or 2 each (9 pairs where only the build without this PR captured, 4 the other way): 36 more CTG pairs on those 8 maps gave captures 2 -> 4; not reproduced. (2) rogue_ctg FAIL_NO_PATH 10 -> 23 and ghost_ctg stuck episodes 8 -> 14 (the only per-map cells with an interval clear of 1): 8 more rogue pairs gave FAIL_NO_PATH 60 -> 58, 4 more ghost pairs gave episodes 14 -> 13 and FAIL_FELL_OFF 14 -> 24 this time; the cells alternate, not reproduced. (3) JGR kills -8.5 a match over 17 pairs: 22 more JGR pairs gave -3.6 [-15.0, +5.9], terminal_jgr 7 pairs down and 5 up out of 12 with a spread of 22 to 95 kills a match; still leaning, listed under Risk. New stuck spots with this PR (2 or more events a match where the build without had under 0.5 within 128 u) were one-bot long stucks at static geometry or a prop in 1 of 4 matches, a different set in each wave, except one physics-prop spot on subsurface_ctg at (-642, -3898, -1760) that appeared in both; none is a fall or a no-path spot. Watch list: skyline_ctg fall deaths 8 -> 14 over its 8 pairs (it also moved under #2198 alone, 6 -> 10 in that PR's screen; no link to this PR's mechanism read yet) and that subsurface prop spot.

Approaches tried and dropped

  • Refuse only the two-segment straight line BuildTrivialPath returns for a partial path. The path bots loop on at this spot is reported complete (the CNavArea::Contains case), so the check never ran there: 106 FAIL_FELL_OFF toward the ramp cover in 10 matches. This led to judging the last leg of every path.

Considered, not included

  • Stop a floor area from containing a goal far above it (CNavArea::Contains, or Path::Compute refusing its own result). It would also save the walk to the floor under the cover, but it changes stock NextBot code that every path search and other nav queries use; refusing the result in CNEOBotPathCompute keeps the change to NEO bots.
  • A navmesh edit at the spot. The cause is the path search, not the mesh: any floor area under a goal with no nav area found for it does this.

Toolchain

  • Linux GCC Distro Native (Linux Mint 22.3, GCC 13.3.0)

Linked Issues

@sunzenshen

Copy link
Copy Markdown
Contributor Author

Also don't love how this is a custom solution for one specific map. Maybe let's test the new navmeshes without this PR and see if the results are good enough?

A bot told to reach a goal high above it, such as cover on a ramp or
walkway overhead, can be handed a path whose last leg is a straight
line up to the goal. The path follower raises FAIL_FELL_OFF ("goal is
high above us") on every tick the bot stands below it, the behavior
computes the same line again, and the bot stands there until it is
shot or the behavior changes.

Path::Compute builds such a leg in two ways. When no area the search
reaches is nearer the goal than the bot's own, it falls back to
BuildTrivialPath, a two-segment partial path, and CNEOBotPathCompute
accepts any partial path. When the goal has no nav area of its own
(GetNearestNavArea finds none with its line-of-sight check), the search
stops at the first area that "contains" the goal, and CNavArea::Contains
only compares against areas whose extent overlaps its own height range.
A floor area directly below the goal therefore contains it: the search
ends there, reports the goal reached, and builds a straight line up to
the goal from that area.

So every path's last leg is judged, complete or not: when the goal
sits more than the bot's jump height (GetMaxJumpHeight) above the floor
of the area the leg starts from, and no planned jump-up, gap jump, drop
or ladder leads into it, the path is unusable. The compute then falls
through to the reservation-free pass and reports no path. Attack
already drops its cover area and chases on a failed compute; retreat to
cover now picks another cover area, or ends when the pick repeats,
instead of ignoring the result.

On ntre_apparatus_dm, area 2067 below the ramp area 7687, every one of
547 FAIL_FELL_OFF events in 48 matches had this two-segment path.
@sunzenshen
sunzenshen force-pushed the bot-trivial-path-unusable branch from f84ef29 to 86828c3 Compare October 12, 2026 03:53
@sunzenshen

Copy link
Copy Markdown
Contributor Author

After everything recently merged, this didn't gain much in recent tests, so closing this time.

@sunzenshen sunzenshen closed this Oct 15, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant