Repository navigation
Bots anticipate interception path of enemy ghost carrier - #2174
sunzenshen wants to merge 4 commits into
Conversation
…into the research branch # Conflicts: # src/game/server/neo/bot/behavior/neo_bot_ctg_enemy.cpp
6b423c2 to
1da58b7
Compare
1da58b7 to
a278fa2
Compare
228bfe1 to
c37443f
Compare
sunmachine
left a comment
There was a problem hiding this comment.
The cut-off race measures the carrier with a shortest path but the bot with a breadth-first flood, so the bot can look slower than it is. One comment on where it shows up, one with a sketch of a fix.
| // One flood prices every area of the route by our own travel. It must come after the route is | ||
| // copied out: both searches use the nav areas' shared search state. | ||
| CNEOBotTraversableFlood flood( me ); | ||
| SearchSurroundingAreas( pMyArea, flood, carrierRoute.travel.Tail() ); |
There was a problem hiding this comment.
Question on the two sides of this race: the carrier's distance comes from NavAreaBuildPath with ShortestPathCost, but ours comes from SearchSurroundingAreas, which is breadth-first. IncludeInSearch sets every total cost to 0, so the open list runs first in, first out, and it writes CostSoFar only the first time it reaches an area and never lowers it. So each area ends up with the length of the route with the fewest hops, which can be longer than the shortest one.
Say the bot's short way onto the carrier's route goes through a stairway cut into lots of small areas, and a longer detour crosses a few big ones. The flood reaches the route through the detour first and keeps that longer distance, so the bot thinks it loses a race it would win, and it chases or takes a later cut-off.
It only ever errs toward the bot looking slower, so it never picks a cut-off it can't make. Is that bias something you want here, or should both sides use the same shortest-distance measure? (From reading the code, haven't run it.)
| virtual bool ShouldSearch( CNavArea *adjArea, CNavArea *currentArea, float travelDistanceSoFar ) override | ||
| { | ||
| return m_me->GetLocomotionInterface()->IsAreaTraversable( adjArea ); | ||
| } |
There was a problem hiding this comment.
If you want both sides exact, the flood can do Dijkstra by overriding IterateAdjacentAreas: keep the open list sorted by distance and lower an area's distance when a shorter way in turns up. Untested sketch. It keeps your ShouldSearch and still skips ladders like the base version:
| virtual bool ShouldSearch( CNavArea *adjArea, CNavArea *currentArea, float travelDistanceSoFar ) override | |
| { | |
| return m_me->GetLocomotionInterface()->IsAreaTraversable( adjArea ); | |
| } | |
| virtual bool ShouldSearch( CNavArea *adjArea, CNavArea *currentArea, float travelDistanceSoFar ) override | |
| { | |
| return m_me->GetLocomotionInterface()->IsAreaTraversable( adjArea ); | |
| } | |
| // Dijkstra in place of the base breadth-first flood: the open list is ordered by distance and an | |
| // area's distance drops when a shorter way in turns up, so GetCostSoFar is the shortest travel | |
| virtual void IterateAdjacentAreas( CNavArea *area, CNavArea *priorArea, float travelDistanceSoFar ) override | |
| { | |
| for ( int dir = 0; dir < NUM_DIRECTIONS; ++dir ) | |
| { | |
| const int count = area->GetAdjacentCount( (NavDirType)dir ); | |
| for ( int i = 0; i < count; ++i ) | |
| { | |
| CNavArea *adjArea = area->GetAdjacentArea( (NavDirType)dir, i ); | |
| if ( !ShouldSearch( adjArea, area, travelDistanceSoFar ) ) | |
| { | |
| continue; | |
| } | |
| const float flCost = travelDistanceSoFar + ( adjArea->GetCenter() - area->GetCenter() ).Length(); | |
| if ( !adjArea->IsMarked() ) | |
| { | |
| adjArea->Mark(); | |
| adjArea->SetParent( area ); | |
| adjArea->SetCostSoFar( flCost ); | |
| adjArea->SetTotalCost( flCost ); | |
| adjArea->AddToOpenList(); | |
| } | |
| else if ( adjArea->IsOpen() && flCost < adjArea->GetCostSoFar() ) | |
| { | |
| adjArea->SetParent( area ); | |
| adjArea->SetCostSoFar( flCost ); | |
| adjArea->SetTotalCost( flCost ); | |
| adjArea->UpdateOnOpenList(); | |
| } | |
| } | |
| } | |
| } |
With the list sorted by distance, the travelDistanceLimit cut in SearchSurroundingAreas becomes exact too.
| ShortestPathCost cost; | ||
| if ( !NavAreaBuildPath( pStartArea, pGoalArea, &vecGoal, cost ) ) | ||
| { | ||
| return false; | ||
| } | ||
|
|
||
| // The parent chain runs goal -> start | ||
| for ( CNavArea *pArea = pGoalArea; pArea; pArea = pArea->GetParent() ) |
There was a problem hiding this comment.
Rare one, but cheap to close: if the cap's nearest area is blocked, NavAreaBuildPath drops its copy of the goal (nav_pathfind.h) and can still succeed by reaching another area that contains vecGoal (here). This loop then walks parent links from pGoalArea, which that search never reached, so the links are left over from an earlier search and the route isn't the carrier's. The search hands back the area it actually reached through closestArea, so walking from that fixes it:
| ShortestPathCost cost; | |
| if ( !NavAreaBuildPath( pStartArea, pGoalArea, &vecGoal, cost ) ) | |
| { | |
| return false; | |
| } | |
| // The parent chain runs goal -> start | |
| for ( CNavArea *pArea = pGoalArea; pArea; pArea = pArea->GetParent() ) | |
| ShortestPathCost cost; | |
| CNavArea *pReachedArea = nullptr; | |
| if ( !NavAreaBuildPath( pStartArea, pGoalArea, &vecGoal, cost, &pReachedArea ) ) | |
| { | |
| return false; | |
| } | |
| // The parent chain runs goal -> start, from the area the search reached: a blocked pGoalArea is | |
| // passed over for another area containing vecGoal, and its own parent links are stale | |
| for ( CNavArea *pArea = pReachedArea; pArea; pArea = pArea->GetParent() ) |
When an enemy picks up the ghost, CNEOBotCtgEnemy now decides once whether this bot can get in front of the carrier and hands over: CNEOBotCtgEnemyCutOff paths to the earliest area on the carrier's predicted route that the bot reaches first, then chases from there; CNEOBotCtgEnemyChase runs the carrier down, by the fastest route from behind and the default route from ahead. The route is a naive shortest path to the nearest cap the carrier can score in, from what any player sees: the ghost marker and the cap zones. One SearchSurroundingAreas flood prices the whole route by the bot's own travel. CNEOBotCtgSeek makes the enemy-carrier decision before UpdateCommon's combat suspend, so a defender that sees an escort still goes for the carrier. The ShouldHurry rule moves to the two children, since the decider no longer stays on the stack.
The cut-off and chase re-plan intervals (5 s and 20 s) are settled, so they become named constants. The cut-off lead stayed at 1.0 after a looser lead made bots commit to cut-offs they lost; at 1.0 it is no factor at all, so the race is now plainly "reach it no later than the carrier".
RepathToCutOff passed a bare false for Path::Compute's includeGoalIfPathFails argument. Spell it PATH_TRUNCATE_INCOMPLETE_PATH, as CNEOBotSeekWeapon does, so the call says what it asks for.
The bot's side of the race used SearchSurroundingAreas, a breadth-first flood that keeps the fewest-hops distance to each area, while the carrier's side uses a shortest path. Override IterateAdjacentAreas so the flood runs Dijkstra and both sides use the same measure.
334fa6a to
f94ac12
Compare
Description
Replaces #2036.
When an enemy picked up the ghost, every defender ran the same chase straight at the carrier. Now
each defender first asks whether it can get in front of it:
CNEOBotCtgEnemybecomes a one-shot decider.FindCutOffpredicts the carrier's shortest routeto the nearest cap it can score in, from what any player sees (the ghost marker and the cap
zones), floods outward from the bot once with
SearchSurroundingAreas, and takes the earliestarea on that route the bot reaches no later than the carrier.
CNEOBotCtgEnemyCutOffpaths there and chases on arrival. It re-picks the area every 5 s,since the route is only a guess. It never suspends for
CNEOBotAttack, unlike master'sCNEOBotCtgEnemy: the ground it wins is the point, so it keeps running, and the main actionstill fires at a visible threat on the way.
CNEOBotCtgEnemyChaseruns the carrier down: the fastest route from behind it, the default routefrom ahead, where the reservation penalty spreads defenders over different lines. It re-checks
which route type every 20 s (it does not go back to a cut-off while that carrier holds the
ghost) and still suspends for
CNEOBotAttackon a visible threat.CNEOBotCtgEnemyChasedrops its path when it resumes from a suspension, as Bots: repath Attack and ctgEnemy's chase when they resume from a suspension #2158 madeCNEOBotCtgEnemydo before the chase moved here.CNEOBotCtgSeekmakes the enemy-carrier decision beforeUpdateCommon's combat suspend, so adefender that sees an escort still goes for the carrier. That also puts it ahead of the
lone-wolf check: a last defender goes for the carrier rather than an ambush. It is also ahead of
UpdateCommon's give-up timer andneo_bot_disable_seek_and_destroyreturns, so disabling seekno longer stops a defender from going for the carrier.
ShouldHurryrule moves fromCNEOBotCtgEnemy, which no longer stays on the stack, toits two children through
CNEOBotCtgEnemy::CarrierUrgency.The two re-plan intervals are named constants. A looser race (letting a bot claim an area it
reaches somewhat after the carrier) was tried during development and made bots commit to cut-offs
they then lost, so the race is plain "no later than the carrier".
Overlap with #2222
#2222 also edits the enemy-carrier branch of
CNEOBotCtgSeekand the threat check inCNEOBotCtgEnemy::Update. The two do not depend on each other. If this PR merges first, #2222moves its ATK-defender threat filter into
CNEOBotCtgEnemyChase::Update(the only place the threatcheck lives now) and keeps its guard-round branch inside the friendly-carrier branch here; a trial
merge resolved that way builds clean.
Navmesh PRs that rely on this
None of the open navmesh PRs needs this. It changes how CTG defenders go after a ghost carrier,
which no navmesh feature depends on; it acts on every CTG map.
Measurements
Re-measured on master
f426c222bagainstf426c222bplus this PR, Release dedicated-server builds,ntre_ballistrade_ctgandntre_oilstain_ctg, default 12-bot roster, one round per match with theghost pinned to each spawn in turn (7 + 5 spawns), every player at one XP rank, rotated across the
spawns so each spawn plays every rank equally, weapon damage multiplier 1.0 (the default is
1.485). 288 matches an arm, each scenario played by both arms.
ntre_ballistrade_ctgntre_oilstain_ctgCompared scenario by scenario (same map, spawn and rank, both arms carried, 279 pairs), carriers
capture 9.3 points fewer with this PR (95 % CI 3.5 to 15.1; 48 scenarios captured only on master,
22 only with this PR, p = 0.0025). It points the same way at every rank. One round at 12 bots is
noisy: the same 96-match design run twice gave +23 and -6 points, so only the pooled number means
much. An earlier measurement on an older master (3-round matches, ballistrade, 410 carried rounds
an arm) gave 11.5 points (CI 5.3 to 17.7).
What the defenders do: on ballistrade the clearest spawn is the one where NSF picks the ghost up
in the south and walks it north-east to its cap (24 rounds an arm: master 17 captured, this PR 6).
With this PR the defenders pick areas on that walk, get there first and kill the carrier there,
the same way at every rank. One ballistrade spawn leans the other way (11 -> 16 of 24 captured,
p = 0.24): there the carrier starts ahead of nearly every defender, so nearly all of them chase.
Of the carriers killed with this PR, the killer had arrived at its cut-off in 46 of 110 on
ballistrade (38 more were chasing) and 59 of 96 on oilstain (17 more were still on the way to
one). Over all matches defenders chose 1159 cut-offs and 582 chases; 616 cut-offs were reached,
56 were given up when a re-pick found none left, 91 when the path to them failed. Oilstain's
carriers rarely score with or without this PR.
Cost:
FindCutOff(oneNavAreaBuildPathplus one flood) runs when a bot decides, then every 5 sper cut-off and every 20 s per chase, never per tick: about 18 calls in a 1-round match. Release:
76 us median, 224 us p99, 396 us worst per call; the worst single tick (several defenders deciding
at one pickup) summed to 0.94 ms. Debug: 244 us median, 571 us worst.
No asserts: a Debug build played every spawn of both maps once (12 matches), and all 578 Release
matches reached the match end and exited cleanly.
Follow-up ideas, not in this PR
bDontSuspendForGhosterinCNEOBotSeekAndDestroy::UpdateCommonno longer fires in CTG: theenemy-carrier case now returns before it. Its carrier check could also reuse
CNEOBotCtgEnemy::EnemyGhostCarrier.route is a ladder chases instead of cutting off.
ShouldHurryinto account in the cut-off-vs-chase choice.Toolchain
Linked Issues