Skip to content

Bots anticipate interception path of enemy ghost carrier - #2174

Open
sunzenshen wants to merge 4 commits into
NeotokyoRebuild:masterfrom
sunzenshen:bot-ctg-fallback-to-cap-v2
Open

sunzenshen wants to merge 4 commits into
NeotokyoRebuild:masterfrom
sunzenshen:bot-ctg-fallback-to-cap-v2

Conversation

@sunzenshen

@sunzenshen sunzenshen commented Sep 27, 2026 •

Copy link
Copy Markdown
Contributor

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:

  • CNEOBotCtgEnemy becomes a one-shot decider. FindCutOff predicts the carrier's shortest route
    to 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 earliest
    area on that route the bot reaches no later than the carrier.
  • CNEOBotCtgEnemyCutOff paths 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's
    CNEOBotCtgEnemy: the ground it wins is the point, so it keeps running, and the main action
    still fires at a visible threat on the way.
  • CNEOBotCtgEnemyChase runs the carrier down: the fastest route from behind it, the default route
    from 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 CNEOBotAttack on a visible threat.
  • CNEOBotCtgEnemyChase drops its path when it resumes from a suspension, as Bots: repath Attack and ctgEnemy's chase when they resume from a suspension #2158 made
    CNEOBotCtgEnemy do before the chase moved here.
  • CNEOBotCtgSeek makes the enemy-carrier decision before UpdateCommon's combat suspend, so a
    defender 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 and neo_bot_disable_seek_and_destroy returns, so disabling seek
    no longer stops a defender from going for the carrier.
  • Bots: redefine ShouldHurry around objective urgency #2166's ShouldHurry rule moves from CNEOBotCtgEnemy, which no longer stays on the stack, to
    its 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 CNEOBotCtgSeek and the threat check in
CNEOBotCtgEnemy::Update. The two do not depend on each other. If this PR merges first, #2222
moves its ATK-defender threat filter into CNEOBotCtgEnemyChase::Update (the only place the threat
check 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 f426c222b against f426c222b plus this PR, Release dedicated-server builds,
ntre_ballistrade_ctg and ntre_oilstain_ctg, default 12-bot roster, one round per match with the
ghost 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.

master this PR
rounds with a carried ghost 281 284
captured, of carried 108 (38.4 %) 82 (28.9 %)
ntre_ballistrade_ctg 101 / 167 (60.5 %) 80 / 168 (47.6 %)
ntre_oilstain_ctg 7 / 114 (6.1 %) 2 / 116 (1.7 %)
carriers killed per carried round 0.67 0.73

Compared 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 (one NavAreaBuildPath plus one flood) runs when a bot decides, then every 5 s
per 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

  • bDontSuspendForGhoster in CNEOBotSeekAndDestroy::UpdateCommon no longer fires in CTG: the
    enemy-carrier case now returns before it. Its carrier check could also reuse
    CNEOBotCtgEnemy::EnemyGhostCarrier.
  • The flood does not follow ladders while the carrier's route does, so a bot whose way onto the
    route is a ladder chases instead of cutting off.
  • Taking ShouldHurry into account in the cut-off-vs-chase choice.

Toolchain

  • Windows MSVC VS2022
  • Linux GCC Distro Native (Linux Mint 22.3, GCC 13.3.0)

Linked Issues

sunzenshen added a commit to sunzenshen/neo that referenced this pull request Oct 1, 2026
…into the research branch

# Conflicts:
#	src/game/server/neo/bot/behavior/neo_bot_ctg_enemy.cpp
@sunzenshen sunzenshen added the Conflict conflicts with master label Oct 3, 2026
@sunzenshen
sunzenshen force-pushed the bot-ctg-fallback-to-cap-v2 branch from 6b423c2 to 1da58b7 Compare October 3, 2026 18:55
@sunzenshen sunzenshen removed the Conflict conflicts with master label Oct 3, 2026
@sunzenshen
sunzenshen force-pushed the bot-ctg-fallback-to-cap-v2 branch from 1da58b7 to a278fa2 Compare October 12, 2026 03:56
@sunzenshen sunzenshen added the Bots Related to bot players label Oct 15, 2026
@sunzenshen
sunzenshen requested a review from sunmachine October 15, 2026 04:07
@sunzenshen sunzenshen changed the title Bots cut off the enemy ghost carrier Bots anticipate interception path of enemy ghost carrier Oct 15, 2026
@sunzenshen
sunzenshen marked this pull request as ready for review October 15, 2026 04:08
@sunzenshen
sunzenshen force-pushed the bot-ctg-fallback-to-cap-v2 branch from 228bfe1 to c37443f Compare October 15, 2026 04:08

@sunmachine sunmachine left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment on lines +129 to +132
// 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() );

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.)

Comment on lines +68 to +71
virtual bool ShouldSearch( CNavArea *adjArea, CNavArea *currentArea, float travelDistanceSoFar ) override
{
return m_me->GetLocomotionInterface()->IsAreaTraversable( adjArea );
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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:

Suggested change
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.

Comment on lines +31 to +38
ShortestPathCost cost;
if ( !NavAreaBuildPath( pStartArea, pGoalArea, &vecGoal, cost ) )
{
return false;
}

// The parent chain runs goal -> start
for ( CNavArea *pArea = pGoalArea; pArea; pArea = pArea->GetParent() )

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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:

Suggested change
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() )

@sunzenshen
sunzenshen requested a review from sunmachine October 17, 2026 05:53
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.
@sunzenshen
sunzenshen force-pushed the bot-ctg-fallback-to-cap-v2 branch from 334fa6a to f94ac12 Compare October 26, 2026 03:31
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Bots Related to bot players

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants