Repository navigation
Keep bot death areas out of the stuck area penalty - #2215
Merged
sunzenshen merged 3 commits intoOct 12, 2026
Merged
Conversation
A bot's death added to the same per-area penalty as a bot getting stuck. Cover and retreat searches skip an area whose penalty reaches one stuck, and Attack's cover search ranks candidates by it before their cover, so an area where bots died lost to any area with fewer deaths even when it gave better cover. Keep deaths in their own penalty, read only as the path cost it was before; cover and retreat searches read only where bots got stuck.
A bot getting stuck once made its area unusable as cover, retreat goal or safe area for every bot until the map changed: 9-21 % of cover and retreat picks on ntre_apparatus_dm skipped such an area. One stuck can be a crowd or a pushed prop. Clear stuck areas with the other round state, and skip an area only after bots got stuck there twice in the round. Below that, the stucks are a cost: Attack already ranks cover by them, RetreatToCover counts the area as exposed to one more threat, grenade cover adds them as path cost does, and a hazard retreat uses such areas only as a fallback.
The stuck and death penalties lived in two maps, so path cost and the cover and retreat searches looked an area up once per penalty type, two or three times per area. Keep both in one map of per-area structs: each reader looks the area up once and reads the sum or the type it needs, and a new penalty type is one more field. A new round clears the stuck field in place and map load clears the map, as before. The hazard marks stay apart: they are per team and expire by time, not by round.
sunmachine
approved these changes
Oct 11, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Description
Before, an area avoid penalty that was added when a bot dies at a location was invalidating potential cover areas when bots were looking for cover during retreats and attacks. This PR separates those two penalty types to allow area penalty checks to distinguish between them.
Since #1739, a bot's death adds 10 to the same per-area penalty that a bot getting stuck adds 1000 to. Path cost reads the sum as a soft cost. The cover and retreat searches read it differently: RetreatToCover, the grenade retreat and the hazard retreat's safe-area search skip any area whose penalty reaches one stuck, and Attack's leapfrog cover search ranks candidates by the penalty before their cover. Stuck areas are already skipped, so in Attack the ranking key is the death count alone: an area where one bot died loses to every area with no deaths that gives even partial cover, and the search does not stop at hard cover that has a death.
On
ntre_apparatus_dm(30 bots), 9 % of Attack's cover picks pass over a death area for a cleaner one, and 1.7 % pass over hard cover nearer the goal for it. Deaths alone never reach the skip threshold (at most 17 deaths in one area a match, 170 of the 1000 needed). The skip that does act is the stuck one: 8-22 % of Attack, RetreatToCover and grenade cover picks skip an area where a bot got stuck, and that area stays skipped until the map changes.What this PR does
Deaths get their own penalty (
neo_bot_path_reservation.{h,cpp},neo_bot_behavior.cpp,neo_bot_path_cost.cpp).OnKilledaddsneo_bot_path_reservation_killed_penaltyto a separate death penalty, which path cost adds exactly as it added the shared one. The cover, retreat and safe-area searches read only where bots got stuck. Attack's cover ranking then compares stucks only, which the skip leaves at zero, so it falls through to the friendly-reservation and visibility tie-breaks it already had.Stuck areas are forgotten each round and skipped only on a repeat (second commit;
neo_bot_path_reservation.{h,cpp}, the four searches). The stuck penalty is cleared with the other round state inClearRound(hazards and reservations already reset there), so a stuck in round 1 no longer shapes round 9: without it, by round 5 of a CTG match most RetreatToCover picks skip a stuck area (see Results). The four searches skip an area only once bots got stuck in it twice in the round (NEO_BOT_STUCK_PRONE_AREA_COUNT); below that the stucks are a cost: Attack's cover search ranks by them as before, RetreatToCover counts the area as exposed to one more threat (NEO_BOT_STUCK_AREA_EXPOSURE), grenade cover adds the penalty to its candidate cost as path cost does, and the hazard retreat uses such an area only when no area free of stucks is found. A round clear was chosen over a time decay: it is one moved line beside state that already resets per round, needs no new timestamp or tuning constant, and the stuck areas measured are repeat spots (up to 5-10 stucks in one area a match) that a decay would forget while bots still get stuck there.An area's penalties live in one struct (third commit;
neo_bot_path_reservation.{h,cpp}, path cost and the four searches).AreaPenalty_tholds the stuck and death penalties of an area in one map, so path cost and each search look an area up once and read the sum or the one type they need, and a new penalty type is one more field. A new round zeroes the stuck field in place and map load empties the map, as in the first two commits. The hazard marks stay apart: they are per team and expire by time.How to test
map ntre_apparatus_dmwith ntre_apparatus_dm: first navmesh neoAssets#157's navmesh,sv_cheats 1,neo_bot_quota 12,sv_neo_bot_attack_debug_cover 1, and join spectator.Cost
One
CUtlMaplookup per area in path cost and in each cover or safe-area search, as upstream has for the shared penalty.Results
Every match below: Linux dedicated servers built from upstream master at #2175 with #2198, #2176 and #2212, without this PR, with the first commit, and with both; the same test-only logging (kills, stuck events, move failures, round and match end, and a cover-pick log of what each cover and retreat search picked and skipped) in every arm; that logging is a harness patch and not part of this PR. Release builds for the rates. The Debug build for the assert check is upstream master with this PR alone, plus the same logging. Default weapon damage (1.485), 15 s pre-round freeze; the arms compared in a table ran interleaved in the same batches of 8 parallel servers. Every match reached match end with 0 asserts and 0 crashes. "Stuck" is
OnStuckplusFAIL_STUCK, without the round-start spawn crowd,SeekWeaponand dead bots; an episode is one bot's stuck events less than 2 s apart. Intervals are 95 % bootstrap intervals (4000 draws) of the paired ratio.ntre_apparatus_dm, #157's navmesh head, one-round 5-minute DM; 48 pairs at 30 bots; at 12 bots 24 pairs for the first commit and 48 for both (a repeat batch of 24 pairs of the two-commit build, pooled; the "without" means, the cover rows and the deaths row are from the first 24):FAIL_NO_PATHCTG (both commits;
ntre_dusk_ctg,ntre_isolation_ctg, ghost spawn 0, 3-minute round, 1 round, 30 bots, 8 pairs each): stuck episodes x1.01 [0.72, 1.43] and x1.09 [0.68, 1.51], kills x1.06 [0.97, 1.16] and x1.03 [0.94, 1.13], no fall deaths; captures 1 -> 0 on each map.Debug build (upstream master with this PR): 8
ntre_apparatus_dmmatches (#157's navmesh head),ntre_dusk_ctgandntre_isolation_ctg(ghost spawn 0) andntre_sentinel_jgr(juggernaut spawn 0), 30 bots: 11 of 11 at match end, 0 asserts, 0 crashes.Five-round CTG (both commits;
ntre_dusk_ctg,ntre_isolation_ctg, ghost spawn 0, 4 pairs each): without this PR, the share of RetreatToCover picks that skip an area where a bot got stuck grows each round, from 16 % to 87 % by round 5 onntre_dusk_ctgand from 11 % to 72 % onntre_isolation_ctg; with it, it stays between 1 % and 9 %. Stuck events a round 11.6 -> 8.8 and 8.5 -> 6.8 (stuck events after match end left out), kills a round 24.3 -> 23.2 and 23.1 -> 24.8.Third commit (storage only): the build with all three commits against the build with the first two, 30 bots, 8 pairs on
ntre_apparatus_dm: stuck episodes x0.98 [0.65, 1.41], kills x1.01 [0.97, 1.06],FAIL_NO_PATHx1.09 [0.76, 1.69], cover-pick rates within a few points. Debug build of all three commits: 4ntre_apparatus_dm,ntre_dusk_ctg(ghost spawns 0 and 2) andntre_sentinel_jgr(juggernaut spawns 0 and 4), 30 bots, 0 asserts, 0 crashes.Not run: TDM and JGR maps in Release, the other
ntre_maps, and a Windows build.Toolchain
Linked Issues