Files
playerbot-v2/src/modules/PlayerbotV2/Bot/States/State_InGroup.cpp
T
devbox dffa54c44e Playerbot: bots hold position while owner rides a vehicle; re-summon to owner's location+phase on vehicle exit
- State_InGroup follow-recall: skip following when the anchor is on a vehicle
  (Exile's Reach boar ride) so bots don't trail an on-rails ride.
- New OnPlayerVehicleExit hook (wired in the core-hooks patch at
  Unit::_ExitVehicle): after the owner leaves a vehicle, teleport owned bots
  to the owner's position and inherit the owner's PhaseShift so they actually
  appear (bots were spawning at coords but not in the player's phase).
2026-08-30 13:49:45 +10:00

1072 lines
54 KiB
C++
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// State_InGroup - Cross-cutting layer that runs alongside the primary state
// whenever the bot is grouped. Today: keep within follow distance of the
// leader when idle. Future: ready-checks, raid mark targeting, role hand-off.
#include "StateBase.h"
#include "MaintainHelpers.h"
#include "Bot/BotAI.h"
#include "Bot/BotSnapshotView.h"
#include "Bot/BotIntentEmitter.h"
#include "Group/GroupSnapshot.h"
#include "Bot/Formation.h"
#include "Bot/Dungeon/DungeonScript.h"
#include "Bot/BotRegistry.h"
#include "Services.h"
#include "Travel/UnifiedTravelGraph.h"
#include "SharedDefines.h"
#include "Player.h"
#include "ObjectAccessor.h"
#include <cmath>
#include <limits>
namespace Playerbot::States {
namespace {
float DistanceXY(float ax, float ay, float bx, float by)
{
const float dx = ax - bx;
const float dy = ay - by;
return std::sqrt(dx * dx + dy * dy);
}
// Same melee-spec table as State_InCombat::IsMeleeSpec. Duplicated locally
// rather than adding a cross-state header — both pieces are small and the
// definitions need to stay in sync per spec.
bool IsMeleeClass(uint8 cls, uint32 spec)
{
switch (cls)
{
case CLASS_WARRIOR:
case CLASS_PALADIN:
case CLASS_DEATH_KNIGHT:
case CLASS_ROGUE:
case CLASS_DEMON_HUNTER:
case CLASS_MONK:
return true;
case CLASS_HUNTER:
return spec == 255; // Survival
case CLASS_DRUID:
return spec == 103 || spec == 104; // Feral / Guardian
case CLASS_SHAMAN:
return spec == 263; // Enhancement
default:
return false;
}
}
} // anonymous
void DispatchInGroup(BotAI& ai,
BotSnapshotView snapshot,
GroupSnapshotView group,
BotIntentEmitter& emit)
{
if (!group.exists()) return;
// Auto-accept any pending player-summon dialog (warlock ritual, meeting
// stone, BG / LFG summon). Runs before the alive check so dead bots being
// summoned to a graveyard / instance entrance still accept (the API
// forwards to Player::SummonIfPossible which doesn't require alive).
// Edge-trigger isn't necessary: snapshot.has_summon_pending flips false
// server-side as soon as SummonIfPossible runs, so the next snapshot
// won't re-trigger.
// Auto-respond to LFG role-check ping. Server is waiting for every
// member to declare a role before pushing the queue. Bot's spec maps
// 1:1 to a role (Tank/Healer/Dps); add LEADER bit when bot is the
// group leader so the role-check accepts. Without this the whole
// group blocks indefinitely if any bot member doesn't respond.
if (snapshot.lfg_role_check_pending())
{
uint8 lfg_role = 0;
switch (ai.effective_role(snapshot))
{
case Role::Tank: lfg_role = /*TANK*/ 2; break;
case Role::Healer: lfg_role = /*HEALER*/ 4; break;
case Role::Dps: lfg_role = /*DAMAGE*/ 8; break;
default: lfg_role = /*DAMAGE*/ 8; break;
}
if (group.leader() == snapshot.raw().guid)
lfg_role |= /*LEADER*/ 1;
emit.lfg_role_check(lfg_role);
ai.set_last_rule_fired("ingroup:lfg_role_check");
}
// Proactive role-set for manually-formed dungeon groups (e.g.
// `;all run` builds a party with bots; no LFG round-trip happens
// so `lfg_role_check_pending` is always false and roles never get
// assigned). When dungeon_active and the bot hasn't published its
// role to the group, push it once via the same API (the API now
// falls back to Group::SetLfgRoles direct when no LFG check is
// pending). One-shot: `lfg_role_published_` BotAI flag dedups
// across ticks. Resets when the bot's effective role changes
// (spec swap) or the group changes (re-form).
else if (ai.dungeon_active() && snapshot.lfg_published_role() == 0)
{
uint8 lfg_role = 0;
switch (ai.effective_role(snapshot))
{
case Role::Tank: lfg_role = /*TANK*/ 2; break;
case Role::Healer: lfg_role = /*HEALER*/ 4; break;
case Role::Dps: lfg_role = /*DAMAGE*/ 8; break;
default: lfg_role = /*DAMAGE*/ 8; break;
}
if (group.leader() == snapshot.raw().guid)
lfg_role |= /*LEADER*/ 1;
emit.lfg_role_check(lfg_role);
ai.set_last_rule_fired("ingroup:set_role_no_lfg");
}
// Auto-leave on vote-kick. When the player initiates an LFG
// vote-to-kick on someone in the group, the bot bows out
// voluntarily so the kick succeeds and LFG backfills the slot
// with a fresh bot. TC's LFG vote-kick API doesn't expose WHO
// the target is, so we accept the false-positive risk: if the
// vote targeted a human / different bot, this bot leaves anyway
// and the player just gets a refreshed group. Player intent of
// "kick a bot" still resolves correctly.
// Per-vote-kick lockout via BgPort ActionKind (re-used since
// vote-kicks and BG ports never overlap in the same group).
if (snapshot.lfg_vote_kick_active())
{
const uint32 vk_now = GameTime::GetGameTimeMS();
const uint64 vk_key = uint64(group.leader().GetCounter()) ^ 0xDEADBEEFu;
if (!ai.action_recently_tried(BotAI::ActionKind::BgPort,
vk_key, vk_now))
{
emit.group_leave();
ai.note_action_retry(BotAI::ActionKind::BgPort, vk_key, vk_now);
ai.set_last_rule_fired("ingroup:vote_kick_auto_leave");
return;
}
}
// Auto-promote-human-to-leader. Bots end up as group leader when
// LFG promotes the first-spawned bot, when the human gets kicked
// and reformed, or when bots form a group among themselves and a
// human later joins. The human should drive — they manage pulls.
// When THIS bot is leader AND any non-bot human is in the group,
// hand over leadership. Picks the first non-bot human in the
// member list (stable enough; could be refined to prefer owner).
if (group.leader() == snapshot.raw().guid)
{
BotRegistry& reg_promote = Services::Registry();
ObjectGuid promote_target;
if (auto const* mems_promote = group.members())
{
for (auto const& m : *mems_promote)
{
if (!m.online || m.guid == snapshot.raw().guid) continue;
if (reg_promote.has(m.guid.GetCounter())) continue; // is a bot
promote_target = m.guid;
break;
}
}
if (!promote_target.IsEmpty())
{
// Edge-trigger: without this dedup the rule re-emits
// CMSG_SET_PARTY_LEADER every snapshot tick (~3-5 Hz) for the
// window between intent emit and the leader hand-off taking
// effect. Server treats subsequent emits as no-ops but each
// still pays packet-queue + lock cost. ActionKind::BgPort
// slot is reused (mutually exclusive with vote-kick/promote).
const uint32 promote_now_ms = GameTime::GetGameTimeMS();
const uint64 promote_key =
uint64(promote_target.GetCounter()) ^ 0xCAFEBABEu;
if (!ai.action_recently_tried(BotAI::ActionKind::BgPort,
promote_key, promote_now_ms))
{
emit.group_promote_to_leader(promote_target);
ai.note_action_retry(BotAI::ActionKind::BgPort,
promote_key, promote_now_ms);
ai.set_last_rule_fired("ingroup:promote_human_leader");
}
}
}
// Auto-share newly accepted quests with the party. Each shared quest
// is recorded in BotAI's shared_ring_ (16-slot circular buffer). Walk
// the log in order; share the first incomplete quest not already in
// the ring. Old single-id tracking would alternate sharing two quests
// forever — the ring fixes that. Skip repeatable/dungeon/raid quests
// (already universal or party-context-specific).
// Suppress entirely when the bot is in a BG raid OR a dungeon
// instance: prep-phase quest-share spam clutters the popup queue for
// the real player; bots can't progress open-world quests inside
// either type of instance anyway. The BG raid is recreated per
// match. Dungeon groups are also typically formed via LFG and the
// user just wants to run the dungeon — share spam is pure noise.
// Also suppress for owned bots: the player leads the party and
// decides which quests to share. (f47eaa0740)
if (group.exists() && snapshot.is_alive() &&
!snapshot.in_battleground() &&
!snapshot.is_in_dungeon() &&
!ai.is_owned())
{
for (auto const& q : snapshot.raw().quest_log.quests)
{
if (q.state == 1) continue;
if (q.flags & (0x01 | 0x08 | 0x10)) continue;
if (ai.quest_recently_shared(q.quest_id)) continue;
emit.share_quest(q.quest_id);
ai.note_shared_quest(q.quest_id);
ai.set_last_rule_fired("ingroup:share_quest");
break;
}
}
if (snapshot.has_quest_share())
{
// Auto-accept group-shared quests. Server pre-validated eligibility
// (CanTakeQuest, CanAddQuest, level / class / faction gates) before
// the share popup ever fires; the API call here just commits the
// accept and clears m_playerSharingQuest. ServerRefused on the rare
// race where state changed between share and our accept (log filled
// up between ticks, etc.) — the next snapshot will drop the share
// and we won't re-emit.
//
// Edge-trigger: m_playerSharingQuest is cleared by the server one
// tick AFTER the accept lands, so without a dedup we re-emit
// accept_shared_quest every tick for the round-trip window.
// Mirror the promote_human_leader pattern using the BgPort
// ActionKind slot (mutually exclusive with vote-kick/promote).
const uint32 sq_now_ms = GameTime::GetGameTimeMS();
constexpr uint64 sq_key = uint64(0xC0DECAFE'1u);
if (!ai.action_recently_tried(BotAI::ActionKind::BgPort, sq_key, sq_now_ms))
{
emit.accept_shared_quest();
ai.note_action_retry(BotAI::ActionKind::BgPort, sq_key, sq_now_ms);
ai.set_last_rule_fired("ingroup:share_quest_accept");
}
}
if (snapshot.has_summon_pending())
{
// Decline summons while the bot is in a battleground / dungeon
// instance — accepting would yank the bot out and forfeit its slot.
// The owner can re-summon after the bot leaves the instance. Use
// map-based detection: snapshot.in_battleground() covers PvP, and
// any encounter-active state (active_encounter_npc_id != 0) means
// we're mid-fight in a scripted dungeon and shouldn't port out.
if (snapshot.in_battleground() ||
snapshot.raw().dungeon_exec.active_encounter_npc_id != 0)
{
emit.summon_decline();
ai.set_last_rule_fired("ingroup:summon_decline");
}
else
{
emit.summon_accept();
ai.set_last_rule_fired("ingroup:summon_accept");
}
// Don't return — let the rest of the InGroup dispatch run too. The
// teleport happens on the world thread; this tick's other emits stay
// valid (target the bot's current position) and naturally lapse on
// arrival when the next snapshot inverts the relevant ranges.
}
if (!snapshot.is_alive()) return;
// Auto-respond ready when the leader starts a ready check. Bots that don't
// answer block the rest of the group from pulling. Edge-triggered: emit
// exactly once per active window (cleared when the snapshot reports the
// check no longer active). Server-side response is idempotent but the
// intent still costs queue slots, so dedup is worth it.
if (group.ready_check_active())
{
if (!ai.ready_check_acked())
{
emit.emit(GroupReadyResponseIntent{true});
ai.set_ready_check_acked(true);
ai.set_last_rule_fired("ingroup:ready_check_ack");
}
}
else if (ai.ready_check_acked())
{
ai.set_ready_check_acked(false);
}
// Auto-skull: when this bot is the group leader (or assistant in raids),
// and the group is engaged, mark the lowest-HP attacking enemy with skull
// (icon 7). Picks the lowest-HP target from the union of "things attacking
// me" and "things attacking my groupmates", because that's the kill order
// a competent leader would call. Skip if a live skull is already set —
// re-marking mid-fight thrashes assist targets. Trigger only on the
// leader's tick; the published skull propagates to all bot members
// through the next group snapshot.
if (snapshot.in_combat() && !group.leader().IsEmpty() &&
group.leader() == snapshot.raw().guid)
{
const ObjectGuid current_skull = group.skull_target();
bool skull_alive = false;
if (!current_skull.IsEmpty())
for (auto const& u : snapshot.raw().combat.nearby_enemies)
if (u.guid == current_skull && u.hp > 0) { skull_alive = true; break; }
if (!skull_alive)
{
// Skull-pick preference order:
// 1. attackers (units in combat with us) whose entry is in
// the per-dungeon script's high_priority_kill_entries —
// these are the "kill first" adds the script knows about
// (e.g. Cult Adherent on Deathwhisper, Bone Spike on
// Marrowgar).
// 2. lowest-HP among generic attackers (no priority entry hit).
// 3. lowest-HP among nearby_enemies (out-of-combat trash hint).
DungeonAdvice const advice = Services::Initialized()
? Services::Dungeons().GetAdvice(snapshot)
: DungeonAdvice{};
ObjectGuid pick;
int32 best_hp = std::numeric_limits<int32>::max();
// Priority pass.
if (!advice.high_priority_kill_entries.empty())
{
for (auto const& u : snapshot.raw().combat.attackers)
{
if (u.hp <= 0) continue;
bool prio = false;
for (uint32_t e : advice.high_priority_kill_entries)
if (e == u.entry) { prio = true; break; }
if (!prio) continue;
if (u.hp < best_hp) { best_hp = u.hp; pick = u.guid; }
}
}
auto is_env_ignored = [&](uint32_t entry) -> bool
{
for (uint32_t ie : advice.ignore_entries)
if (ie == entry) return true;
return false;
};
// Fallback: lowest-HP attacker.
if (pick.IsEmpty())
{
for (auto const& u : snapshot.raw().combat.attackers)
{
if (u.hp <= 0) continue;
if (is_env_ignored(u.entry)) continue;
if (u.hp < best_hp) { best_hp = u.hp; pick = u.guid; }
}
}
if (pick.IsEmpty())
for (auto const& u : snapshot.raw().combat.nearby_enemies)
{
if (u.hp <= 0) continue;
if (is_env_ignored(u.entry)) continue;
if (u.hp < best_hp) { best_hp = u.hp; pick = u.guid; }
}
if (!pick.IsEmpty())
{
// Retry pacing: when the executor refuses the set (Locked —
// bad group state / invalid target), an unthrottled emit
// re-fired the SAME pick every tick (~6/sec, observed on the
// 2026-06-11 BG orphans). One attempt per pick per 10s.
const uint32 icon_now = GameTime::GetGameTimeMS();
if (!ai.action_recently_tried(BotAI::ActionKind::RaidIcon,
pick.GetCounter(), icon_now))
{
emit.set_raid_target_icon(/*SKULL=*/7, pick);
ai.note_action_retry(BotAI::ActionKind::RaidIcon,
pick.GetCounter(), icon_now);
ai.set_last_rule_fired("ingroup:auto_skull");
}
}
}
}
// Don't override combat-state actions — the primary InCombat dispatch is
// already running the APL.
if (ai.state() == BotState::InCombat) return;
const ObjectGuid leader = group.leader();
if (leader.IsEmpty() || leader == snapshot.raw().guid) return;
auto const* members = group.members();
if (!members) return;
GroupMemberSummary const* leader_member = nullptr;
for (auto const& m : *members)
if (m.guid == leader && m.online) { leader_member = &m; break; }
if (!leader_member) return;
if (leader_member->map_id != snapshot.map_id()) return;
// Bot's current world position — needed for both anchor sanity checks
// and the follow-recall distance test further below. Computed once and
// reused so the anchor-on-tank fallback can compare distances.
float bx, by, bz;
snapshot.position(bx, by, bz);
// Off-mesh crossing commitment — honored FIRST, before any anchor/assist/follow.
// A follower mid-jump over the Gap-1 void that gets routed to the InGroup layer
// (assist/follow alongside the primary state) must COMPLETE the hop, not chase a
// target across the gap — chasing replaces the off-mesh spline and strands it on
// the off-mesh poly (NoPath thereafter; observed live 2026-06-26). Shared with
// the idle/InCombat paths so the crossing is uninterruptible in every state.
if (ai.dungeon_active() &&
DungeonHonorCross(snapshot, ai, emit, snapshot.published_at_ms()))
return;
// Anchor selection:
// * Healer (always): stick to the tank. Being out of range to heal
// is the most common cause of group wipes from bot positioning.
// * DPS in dungeon-run mode: also stick to the tank. The tank drives
// the pull cadence and the pull location; DPS following the human
// leader instead would leave them out of melee on the wrong mob
// when the leader wanders. Outside dungeon-run mode DPS stays on
// the leader so /follow during travel still works.
// * Otherwise: anchor on the group leader.
GroupMemberSummary const* anchor = leader_member;
Role const my_role = ai.effective_role(snapshot);
bool const anchor_on_tank =
my_role == Role::Healer ||
(my_role == Role::Dps && ai.dungeon_active());
if (anchor_on_tank)
{
if (auto const* t = group.tank())
{
if (t->online && t->map_id == snapshot.map_id() && t->guid != snapshot.raw().guid)
{
// Sanity gate: only commit to anchoring on the tank when the
// tank is plausibly reachable. Same-map alone isn't enough —
// a tank that zoned ahead through a portal we haven't crossed
// yet, or one that ran far ahead through a door, leaves the
// bot pathfinding through walls because the navmesh can't
// reach the tank's tile. Heuristic: tank within 60yd OR not
// far behind the leader. If both fail, anchor on the leader
// instead — leader-anchor is the safe default that always
// produces sane follow paths. Observed 2026-05-15 after
// `;all run`: 3 bots wall-ran trying to reach a tank that
// had already pulled into the next room.
const float tank_dist = DistanceXY(bx, by, t->x, t->y);
const float lead_dist = DistanceXY(bx, by,
leader_member->x,
leader_member->y);
if (tank_dist <= 60.0f || tank_dist <= lead_dist + 20.0f)
anchor = t;
}
}
}
// Stranded-member recovery runs BEFORE the per-state rejoin: a follower that
// respawned at the far entrance graveyard (or wedged on a disconnected poly)
// cannot traverse back on foot and would otherwise oscillate the ingroup rejoin
// below forever while the group holds for it (the sole-rezzer-healer harbor
// stall, 2026-06-27). Relocate it onto the group so it can rejoin / rez.
if (ai.dungeon_active() &&
DungeonRecoverStrandedFollower(snapshot, ai, group, emit,
snapshot.published_at_ms()))
return;
// Converge-to-fight: a DPS lagging in the 37-40y dead-band while the tank
// is soloing a mob it cannot finish is walked into the fight (off-mesh
// aware) so the assist below engages the tank's victim. Runs after strand
// recovery and before the >40y rejoin (which only fires past the dead-band).
if (ai.dungeon_active() &&
DungeonConvergeToFight(snapshot, ai, group, emit,
snapshot.published_at_ms()))
return;
// ── Dungeon cohesion: rejoin a far tank BEFORE fighting ──────────────
// A follower stranded far from the tank — the Gap-1 mouth case, where the
// only visible enemies are ACROSS the offmesh bridge — must CROSS to the tank
// first. Assisting a target it can't reach pins it at the chokepoint while
// the tank fights the boss-room trash UNHEALED and the group wipes (observed
// live 2026-06-26 Deadmines: healer + 2 DPS stuck at the mouth running
// assist/opener across the bridge, tank died, 3/5 dead). Cross now; the assist
// and follow below resume the instant the bot has rejoined the tank. Tank-
// distance (not anchor) so it fires even when the >60y anchor sanity gate
// above fell back to the leader. The tank's heal-leash holds it for us.
if (ai.dungeon_active() && my_role != Role::Tank)
{
if (auto const* tk = group.tank())
{
if (tk->online && tk->is_alive && tk->guid != snapshot.raw().guid &&
tk->map_id == snapshot.map_id() &&
DistanceXY(bx, by, tk->x, tk->y) > 40.0f)
{
if (!snapshot.victim().IsEmpty())
emit.stop_attack();
// Emit the SAME off-mesh-aware far-vertex goal the idle
// regroup-cross (and in-combat rule 0b) emits — NOT a plain
// move_to(tank). When a follower oscillates between Idle
// (regroup → far vertex of the Gap-1 bridge) and InGroup
// (this rejoin), two DIFFERENT goal-keys reach the spline:
// move_to dedups on the goal, so each state-switch restarts
// the spline at the bridge mouth and the follower never
// completes the off-mesh hop — the observed Gap-1 flip-flop
// (healer + hunter pinned at -214.7,-490.7 alternating
// ingroup:dungeon_rejoin_tank / idle regroup-cross, never
// crossing). DungeonStepTowardTank returns the bridge's SOLID
// far vertex when the step crosses the off-mesh, so this
// rejoin and the idle cross now share one stable goal-key and
// the commit-crossing completes. Falls back to a plain
// move_to(tank) only when no NORMAL path step resolves
// (same-stratum follow, no bridge to commit to).
{
float rx, ry, rz; bool roff = false;
if (DungeonStepTowardTank(snapshot.raw().guid,
tk->x, tk->y, tk->z, 45.0f,
rx, ry, rz, roff))
{
// When the step crosses an off-mesh bridge, COMMIT: store
// the far vertex so DungeonHonorCross (run first in every
// state) keeps re-asserting it and the jump completes even
// if combat would otherwise interrupt it mid-span.
if (roff)
ai.set_dungeon_cross(rx, ry, rz,
snapshot.published_at_ms() + 12000);
emit.move_to(rx, ry, rz, /*run=*/true);
}
// Direct path to the tank is gap-broken (NoPath). Before the
// doomed beeline, try to rejoin ALONG the crumb-route — the
// tank reached its spot by route-following down a descent the
// straight line NoPaths (RFC Adarogg pit); the follower must
// descend the SAME navigable crumbs, not beeline across the
// gap and strand the whole group on the cohesion gate.
else if (DungeonRouteStepTowardPos(snapshot,
tk->x, tk->y, tk->z, 45.0f,
rx, ry, rz))
{
emit.move_to(rx, ry, rz, /*run=*/true);
ai.set_last_rule_fired("ingroup:dungeon_rejoin_tank_route");
return;
}
else
emit.move_to(tk->x, tk->y, tk->z, /*run=*/true);
}
ai.set_last_rule_fired("ingroup:dungeon_rejoin_tank");
return;
}
}
}
// Combat assist: if a group member is in combat with a target and we
// don't have one ourselves, engage their target. Skull-marked target
// beats both — DPS coordination convention is "kill skull first."
// Tanks pick their own targets via threat; they ignore assist but DO
// honor skull (so they can pull/taunt the focus mob).
//
// Aggression personality gates whether the bot proactively engages:
// - Passive: never assist on its own. Owner-pinned focus_target still
// wins (focus is an explicit owner directive, not assist), and the
// bot still defends itself via the InCombat dispatch when attacked.
// Useful for "follow but don't fight" RP / escort scenarios.
// - Defensive: assist only when the group is taking attrition damage
// (any same-map member <50% HP). Skips skull-mark assist and the
// leader-victim pile-on when the group looks healthy — Defensive
// bots conserve resources and let the tank manage trash. Owner
// focus + dropping below the wound threshold still engages.
// - Normal / Aggressive: full assist chain (focus → skull → leader
// victim → any in-combat member's victim). Identical for now;
// Aggressive is reserved for future opportunistic-pulling rules.
const Aggression agg = ai.personality().aggression;
const bool has_focus_pin = !ai.focus_target().IsEmpty();
bool group_is_wounded = false;
if (agg == Aggression::Defensive)
{
for (auto const& m : *members)
{
if (!m.online || m.map_id != snapshot.map_id() || m.hp <= 0) continue;
if (m.max_hp > 0 && (int64(m.hp) * 100) < (int64(m.max_hp) * 50))
{ group_is_wounded = true; break; }
}
}
// Combat-in-progress override: if ANY same-map group member is
// currently in combat, every non-Passive bot assists. This is a
// symptom-driven signal — we *see* combat happening — and is more
// reliable than the dungeon-active flag (which can be inconsistent
// across the squad when `;all run` doesn't land on every bot, or
// when the squad is fighting world-content without the dungeon
// mode set). Real-player intent: "the group is fighting, I help."
// Defensive personality stops being "wait for HP to drop below 50%"
// and becomes "join the fight already in progress." Passive stays
// opted out (owner explicitly told it to never assist).
bool any_member_in_combat = false;
for (auto const& m : *members)
{
if (!m.online || m.map_id != snapshot.map_id()) continue;
if (m.guid == snapshot.raw().guid) continue; // skip self
if (m.in_combat) { any_member_in_combat = true; break; }
}
const bool engage_allowed =
// Owned bots: do NOT autonomously engage via InGroup assist.
// They get combat orders from the companion loop COMBAT_ASSIST
// (which fires when the owner is fighting). This prevents the
// reaction-gate whack-a-mole: without this, any group member
// entering combat cascades to all bots who then pick targets
// from nearby_enemies. (f47eaa0740)
!ai.is_owned() &&
(has_focus_pin ||
(agg != Aggression::Passive &&
(agg != Aggression::Defensive || group_is_wounded ||
any_member_in_combat)));
if (engage_allowed)
{
if (snapshot.victim().IsEmpty())
{
ObjectGuid assist_target;
// Owner-pinned focus target wins over everything. /focus <name>
// sets ai.focus_target() so the owner can micromanage which add the
// bot prioritizes (kicks, dispels, spike damage). Cleared via
// /focus clear or when the focus dies (drops out of nearby_enemies).
if (ObjectGuid focus = ai.focus_target(); !focus.IsEmpty())
{
for (auto const& u : snapshot.raw().combat.nearby_enemies)
if (u.guid == focus && u.hp > 0 && u.reaction < 3) { assist_target = focus; break; }
if (assist_target.IsEmpty())
for (auto const& u : snapshot.raw().combat.attackers)
if (u.guid == focus && u.hp > 0) { assist_target = focus; break; }
}
// Skull mark wins for everyone (after focus). Only use it if it's
// actually nearby — out-of-vision skulls are stale. Defensive bots
// skip skull pile-ons (the tank can manage their own kill order)
// unless the group is already wounded enough to need extra DPS.
//
// CRITICAL: non-tanks must wait for the tank (or another group
// member) to be engaged with the skull before opening on it.
// Skull is "kill priority", NOT "fire at will" — a DPS bot
// opening on skull before tank engages is the #1 overpull
// source. Tanks always honor skull (they're the ones who
// should be pulling it). Observed 2026-05-15: Defensive
// warlock attacked skull immediately on mark, before tank
// had any threat. Causes loss-of-aggro wipes.
if (assist_target.IsEmpty() &&
(agg != Aggression::Defensive || group_is_wounded))
{
if (ObjectGuid skull = group.skull_target(); !skull.IsEmpty())
{
const bool is_tank = ai.effective_role(snapshot) == Role::Tank;
bool skull_engaged_by_member = false;
if (!is_tank)
{
for (auto const& m : *members)
{
if (!m.online || m.map_id != snapshot.map_id()) continue;
if (m.guid == snapshot.raw().guid) continue;
if (m.in_combat && m.victim == skull)
{ skull_engaged_by_member = true; break; }
}
}
if (is_tank || skull_engaged_by_member)
{
// Skip dead skull — corpses linger in nearby_enemies and
// we'd start_attack a body until raid leader re-marks.
for (auto const& u : snapshot.raw().combat.nearby_enemies)
if (u.guid == skull && u.hp > 0 && u.reaction < 3) { assist_target = skull; break; }
if (assist_target.IsEmpty())
for (auto const& u : snapshot.raw().combat.attackers)
if (u.guid == skull && u.hp > 0) { assist_target = skull; break; }
}
}
}
// Non-tanks fall back to leader's / any in-combat member's victim.
// The copied guid MUST resolve against our own hostile lists
// (nearby_enemies / attackers — both IsValidAttackTarget-gated at
// build time) before we engage it. member.victim is published
// verbatim from Unit::GetVictim(), and a member wedged on an
// INVALID victim (2026-06-11 Deadmines: healer stuck swinging at
// a groupmate's hunter pet) would otherwise propagate that victim
// to the whole party — bots visibly "fighting each other" while
// every cast rejects BAD_TARGETS and the run stalls.
if (assist_target.IsEmpty() && ai.effective_role(snapshot) != Role::Tank)
{
auto resolves_hostile = [&](ObjectGuid v) -> bool
{
if (v.IsEmpty()) return false;
for (auto const& u : snapshot.raw().combat.nearby_enemies)
if (u.guid == v && u.hp > 0 && u.reaction < 3) return true;
for (auto const& u : snapshot.raw().combat.attackers)
if (u.guid == v && u.hp > 0) return true;
// Creature-typed victims are trusted even when outside our
// own capped 16-entry/40y scan (big pulls evict, range/LoS
// hide) — refusing them left bots standing idle mid-fight
// (user re-report 2026-06-11 Stockades). The friendly-fire
// wedge class this guard exists for involved Player/Pet
// guids only, and start_attack's IsValidAttackTarget gate
// refuses anything that slips through at engage time.
return v.IsCreature();
};
if (resolves_hostile(leader_member->victim))
assist_target = leader_member->victim;
if (assist_target.IsEmpty())
for (auto const& m : *members)
if (m.online && m.in_combat && resolves_hostile(m.victim))
{ assist_target = m.victim; break; }
}
// Defend-member engage (ALL roles, tanks very much included): a mob
// whose victim is a group member is a pull in progress even when NO
// member's own victim pointer is set — ranged/spell pulls never call
// Unit::Attack on the puller, so a hunter (e.g. the user pulling
// manually with Auto Shot) has GetVictim()==null and the whole
// member-victim chain above sees nothing while the mob runs the
// puller down (user report 2026-06-11 Stockades: "even if I start
// pulling mobs the whole group doesn't react"). The enemy scan
// publishes each mob's victim; engage the nearest visible mob
// that's attacking one of ours.
if (assist_target.IsEmpty())
{
float sx, sy, sz; snapshot.position(sx, sy, sz);
float best_dsq = std::numeric_limits<float>::max();
for (auto const& u : snapshot.raw().combat.nearby_enemies)
{
if (u.hp <= 0 || u.victim.IsEmpty() || !u.in_los || u.reaction >= 3) continue;
bool attacks_member = (u.victim == snapshot.raw().guid);
if (!attacks_member)
for (auto const& m : *members)
if (m.guid == u.victim) { attacks_member = true; break; }
if (!attacks_member) continue;
const float ddx = u.x - sx, ddy = u.y - sy, ddz = u.z - sz;
const float dsq = ddx*ddx + ddy*ddy + ddz*ddz;
if (dsq < best_dsq) { best_dsq = dsq; assist_target = u.guid; }
}
}
if (!assist_target.IsEmpty() && assist_target != snapshot.victim())
{
emit.start_attack(assist_target);
ai.set_last_rule_fired("ingroup:assist");
}
}
else
{
// Bot already has a victim. Honor focus pivots first, then skull
// pivots. Focus is owner-pinned and outranks the raid leader's
// skull mark; both override the bot's pre-engaged target. Defensive
// bots still pivot off focus (owner directive) but skip skull
// pivots when the group is healthy — same conserve-resource logic
// as the open-engage path.
if (ObjectGuid focus = ai.focus_target();
!focus.IsEmpty() && focus != snapshot.victim())
{
for (auto const& u : snapshot.raw().combat.nearby_enemies)
if (u.guid == focus && u.hp > 0 && u.reaction < 3)
{
emit.start_attack(focus);
ai.set_last_rule_fired("ingroup:focus_pivot");
return;
}
}
if (agg != Aggression::Defensive || group_is_wounded)
{
if (ObjectGuid skull = group.skull_target();
!skull.IsEmpty() && skull != snapshot.victim())
{
// Same tank-or-member-engaged gate as the open-engage path.
// Pivoting off the current target onto skull before anyone
// has aggro on it is just as bad as opening on it cold.
const bool is_tank = ai.effective_role(snapshot) == Role::Tank;
bool skull_engaged_by_member = false;
if (!is_tank)
{
for (auto const& m : *members)
{
if (!m.online || m.map_id != snapshot.map_id()) continue;
if (m.guid == snapshot.raw().guid) continue;
if (m.in_combat && m.victim == skull)
{ skull_engaged_by_member = true; break; }
}
}
if (is_tank || skull_engaged_by_member)
{
for (auto const& u : snapshot.raw().combat.nearby_enemies)
if (u.guid == skull && u.hp > 0)
{
emit.start_attack(skull);
ai.set_last_rule_fired("ingroup:skull_pivot");
break;
}
}
}
}
}
}
// bx/by/bz already computed above at anchor-selection time.
(void)bz;
const float dist = DistanceXY(bx, by, anchor->x, anchor->y);
// Anchor has moved past the slack distance — issue a follow. The follow
// movement generator is sticky, so re-issuing every tick is wasteful;
// we only emit when the gap exceeds the bot's preferred follow distance.
// Healers/casters trail further so they don't eat melee cleaves; melee /
// tanks stack tight to be in their threat range. Owner can override the
// role-based default via /follow_distance N — pinned value wins (lets
// the owner micro-position around encounter mechanics).
const bool ranged = snapshot.my_role() == Role::Healer
|| (snapshot.my_role() == Role::Dps && !IsMeleeClass(snapshot.cls(), snapshot.spec()));
float kFollowDistance = ranged ? 10.0f : 5.0f;
float kRecallSlack = ranged ? 16.0f : 8.0f;
if (ai.follow_distance() > 0.f)
{
kFollowDistance = ai.follow_distance();
// Recall slack scales proportionally — pin 8yd → ~12yd recall, etc.
kRecallSlack = kFollowDistance * 1.5f;
}
// In a battleground, the BG-objective rules in State_Idle drive
// movement (node capture, flag carry, role patrol). Following the BG
// raid leader here would stomp those move_to emits and pile all bots
// onto whichever player the BG queue auto-assigned as leader, which
// is the bug behind "bots stay idle at the BG start area". Skip the
// follow when bg_active so the BG rules win. Combat assist / skull
// pivots above still fire, and the InCombat dispatch still drives
// attack rotations once a target is acquired.
const bool in_active_bg = snapshot.in_battleground() && ai.bg_active();
// Dungeon-run movement ownership: while dungeon-run mode is Active, the
// dungeon dispatch in State_Idle owns ALL squad movement — the tank via
// idle:dungeon_tank_advance/boss_nav/tank_pull_next, and every non-tank
// via idle:dungeon_regroup_follow_tank (a navmesh-validated move_to that
// advances on partial paths and CROSSES off-mesh bridges) plus the
// idle:dungeon_hold cohesion floor. State_InGroup's generic follow must
// stand down for the WHOLE squad, not just the tank.
//
// For the TANK: follow-recall would yank it back to the LFG group leader
// (often a DPS), fighting tank_advance — the observed
// `follow_recall <-> tank_waypoint` oscillation that stalls the tank.
//
// For NON-TANKS: dispatch_layers (this code) runs AFTER dispatch_primary
// (State_Idle), so a follow-recall here re-emits MoveFollow every tick and
// OVERRIDES the regroup move_to that State_Idle just issued. MoveFollow has
// no partial-path fallback and PINS against an off-mesh bridge — observed
// live 2026-06-25 at the Deadmines foundry Gap-1 bridge: tank + 1 dps
// crossed, healer + 2 dps stuck 55y back on ingroup:follow_recall while
// their dungeon_regroup move_to (which one bot used to cross successfully)
// was clobbered the very next tick. Generic follow also anchors on the LFG
// leader rather than the tank, which is wrong for a dungeon. Below the
// 22y regroup threshold the regroup is silent and idle:dungeon_hold simply
// holds position — that IS the dungeon cohesion target, so no follow is
// needed there either. (The dungeon dispatch always re-anchors on g.tank().)
const bool dungeon_run_owns_movement = ai.dungeon_active();
// Detour lease: a purposeful idle move (loot/skin a corpse, walk to an
// in-dungeon quest giver) granted itself a few seconds of slack — honor
// it instead of yanking the bot back mid-errand (the follow_recall <->
// move_to_corpse ping-pong starved looting for whole runs). Bounded:
// a fight in the group or a real separation (>45y) overrides the lease.
constexpr float kDetourMaxRange = 45.0f;
const bool detour_leased = ai.detour_active(snapshot.published_at_ms()) &&
!any_member_in_combat && !snapshot.in_combat() &&
dist < kDetourMaxRange;
if (!in_active_bg && !dungeon_run_owns_movement && !detour_leased &&
dist > kRecallSlack && !snapshot.is_rooted())
{
// Vehicle rides (boar ride, cannons, ...): while the leader is on a
// vehicle, hold position instead of chasing the moving vehicle (bots
// would otherwise trail hopelessly behind an on-rails ride). The bots
// are re-summoned to the leader once the ride ends (vehicle dismount).
if (Player* anchor_player = ObjectAccessor::FindConnectedPlayer(anchor->guid))
if (anchor_player->GetVehicle())
return;
// Follow uses MotionMaster MoveFollow, which silently re-emits forever
// when the leader is unreachable (across a gap / closed door) — and
// grouped bots are exempt from GlobalStuckRescue, so they have no other
// recovery. Track whether the gap is actually closing; if it stalls for
// ~5s, fall back from follow to an explicit, navmesh-validated move_to
// toward the leader. A failed move_to (Locked) feeds path_blocked +
// last_move_to, which engages the walk-first unstick ladder (walk-escape
// toward the nearest known-good node). No teleport.
constexpr uint32 kFollowStaleTicks = 25; // ~5s at 5 Hz — gap not closing
constexpr uint32 kFollowEscapeTicks = 100; // ~20s — leader unreachable
// M-P2b: note_follow_dist counts a tick "stale" whenever the gap
// doesn't shrink by ≥2y. But when the leader is MOVING and the
// follower is chasing at a matched pace, the gap holds roughly
// constant even though MoveFollow is working perfectly — the
// generator is tracking a moving target. The old code let stale
// climb in that case and then yanked the bot off MoveFollow into
// an explicit move_to (and eventually the escape-node path),
// interrupting a follow that was actually fine.
//
// The bot being IN MOTION is the direct symptom of "following is
// working": MoveFollow is producing movement toward the (moving)
// leader. So while the bot is moving, treat the follow as healthy —
// reset the stale counter and stay on the cheap MoveFollow path.
// The stale escalation only fires when the bot is NOT moving (truly
// wedged: MoveFollow re-emitting forever against an unreachable
// leader, bot pinned in place). The proper, position-exact fix
// (reset on leader-position-changed-since-last-tick) needs a
// per-bot last-anchor-XY field on BotAI, which this file cannot add.
// // M-P2b: precise leader-moved reset needs last_anchor_x/y on BotAI.
const bool bot_in_motion = snapshot.raw().movement.is_moving;
uint32 stale;
if (bot_in_motion)
{
// Following is actively progressing — don't let a constant gap
// accrue false stale ticks. Re-seed the tracker at the current
// gap so a genuine stall (motion stops, gap stops closing) still
// escalates from a clean baseline.
ai.reset_follow_progress();
stale = ai.note_follow_dist(anchor->guid.GetCounter(), dist);
}
else
{
stale = ai.note_follow_dist(anchor->guid.GetCounter(), dist);
}
// Throttle the validated repath to ~every 2.5s; between attempts use the
// cheap MotionMaster MoveFollow so we don't pathfind + log every tick.
if (!bot_in_motion && stale >= kFollowStaleTicks &&
ai.follow_repath_due(snapshot.published_at_ms(), 2500))
{
float tx = anchor->x, ty = anchor->y, tz = anchor->z;
if (stale >= kFollowEscapeTicks)
{
// Stalled for ~20s with the gap not closing → the leader is
// genuinely unreachable (across a gap / closed door). Aim the
// move_to at the nearest known-good navmesh node instead of
// hammering the unreachable leader, giving the bot a chance to
// route onto valid mesh. No teleport. Falls back to the leader
// position when no node is near.
if (auto const* node = Services::TravelGraph().FindNearestNodeOnMap(
snapshot.map_id(), bx, by, 800.f))
{
const float ndx = node->x - bx, ndy = node->y - by;
// M-P2a: the nearest node to the BOT can sit on the far
// side of the bot from the leader — routing onto it would
// walk the follower AWAY from the group (observed: a dock
// bot recalled to a pier node behind it, increasing the
// gap every escape cycle). Only accept the node when it
// makes positive progress toward the anchor: its XY
// distance to the anchor must be strictly less than the
// bot's current gap. Otherwise keep aiming the move_to at
// the anchor itself (the navmesh-validated move_to + the
// walk-first unstick ladder still handle a truly blocked
// leader without sending the bot backward).
const float node_to_anchor =
DistanceXY(node->x, node->y, anchor->x, anchor->y);
if (ndx * ndx + ndy * ndy > 25.f && node_to_anchor < dist)
{
tx = node->x; ty = node->y; tz = node->z;
}
}
}
emit.move_to(tx, ty, tz, /*run*/ true);
ai.set_last_rule_fired("ingroup:follow_recall_repath");
}
else
{
// M-P1a: follow into the bot's FORMATION SLOT, not generically
// behind the anchor. Without the offset the whole squad piles onto
// a single point behind the leader (clumping = AoE bait + obvious
// bot tell). ComputeFormationOffset yields the per-slot
// distance+angle for the owner-configured formation; Free/slot-0
// collapses to a plain follow so unconfigured squads are unchanged.
FormationOffset const off = ComputeFormationOffset(
ai.effective_formation_type(snapshot.in_combat()),
ai.formation_slot(),
kFollowDistance);
emit.follow(anchor->guid, off.distance, off.angle_radians);
ai.set_last_rule_fired("ingroup:follow_recall");
}
}
else
{
// Within slack (or following suppressed) — not separated, clear the
// stale-progress counter so a future real separation starts fresh.
ai.reset_follow_progress();
}
// Auto-mount when the leader is far ahead and we're outdoors — closes
// the gap quickly. We only mount if the bot isn't already mounted, isn't
// in combat (handled by InCombat short-circuit above), isn't swimming /
// flying, and the leader is mounted (a strong signal we should be too).
constexpr float kMountThreshold = 30.0f;
// Same in_active_bg gate applies to leader-driven mount/dismount —
// BG maps generally disallow mounts and the InCombat dispatch already
// dismounts on attack. Letting these fire would spam intents the
// spell engine rejects.
const bool want_mount = !in_active_bg &&
dist > kMountThreshold &&
leader_member->is_mounted &&
!snapshot.is_mounted() &&
!snapshot.is_indoors() &&
!snapshot.raw().movement.is_swimming &&
!snapshot.raw().movement.is_flying;
// M-P2e: dismount jitter shares the per-bot mount-timer field. Mount and
// dismount are mutually exclusive in a single tick (want_mount requires
// !is_mounted(); the dismount block below requires is_mounted()), so the
// field is never contended. Computed here so the mount-else clear below
// knows not to wipe a pending dismount deadline.
const bool want_dismount = !in_active_bg && snapshot.is_mounted() &&
!leader_member->is_mounted &&
dist <= kMountThreshold;
if (want_mount)
{
// Edge-triggered: a mount cast takes ~1.5s; without dedup we'd spam
// MountIntent every tick during the cast and the API would just
// reject all but the first. Cleared once the bot is mounted or the
// condition lapses (e.g. leader dismounted, gap closed).
if (!ai.mount_pending())
{
// Reaction-time hesitation: 300–1200ms per bot. Without this
// a 5-bot raid all mounts on the same frame as the leader,
// which is impossible for human-played followers.
const uint32 now_ms = snapshot.published_at_ms();
uint32 ready_at = ai.pending_follow_mount_at_ms();
if (ready_at == 0)
{
const uint32 jitter =
300u + (uint32(snapshot.bot_id()) * 2654435761u) % 900u;
ai.set_pending_follow_mount_at_ms(now_ms + jitter);
}
else if (now_ms >= ready_at)
{
emit.mount_appropriate();
ai.set_mount_pending(true);
ai.set_last_rule_fired("ingroup:mount");
}
}
}
else
{
if (ai.mount_pending()) ai.set_mount_pending(false);
// Don't wipe the shared timer when a dismount is pending — the
// dismount block (M-P2e) is using it for its own stagger this tick.
if (!want_dismount && ai.pending_follow_mount_at_ms() != 0)
ai.set_pending_follow_mount_at_ms(0);
}
// Auto-dismount when the leader dismounts and we're close enough that
// running speed catches up. Without this the bot stays mounted
// indefinitely after the auto-mount fired earlier — mounted bots can't
// cast or interact, so leader's "we're at the quest hub" signal needs
// the bots to be ready too. dist gate avoids dismounting mid-travel
// when the leader briefly dismounts to interact with something while
// we're still 50yd back.
if (want_dismount)
{
// M-P2e: reaction-time stagger, mirroring the mount path. Without it
// the whole squad dismounts on the exact frame the leader does —
// impossible for human-played followers and an obvious bot tell.
// Reuse the per-bot mount-timer field (mount/dismount never overlap
// in a tick; the mount-else above is taught not to wipe it while a
// dismount is pending). Deterministic 300–1200ms per-bot offset keyed
// on bot_id so the same bot is consistent run-to-run but the squad
// spreads out. The action-retry dedup below still suppresses
// in-flight duplicate packets once the stagger elapses.
const uint32 dm_now_ms = GameTime::GetGameTimeMS();
const uint32 sched_now_ms = snapshot.published_at_ms();
uint32 dm_ready_at = ai.pending_follow_mount_at_ms();
if (dm_ready_at == 0)
{
const uint32 jitter =
300u + (uint32(snapshot.bot_id()) * 2654435761u) % 900u;
ai.set_pending_follow_mount_at_ms(sched_now_ms + jitter);
}
else if (sched_now_ms >= dm_ready_at)
{
// Edge-trigger: dismount is a server-validated packet; the
// `is_mounted()` snapshot field flips false the same tick the
// SPELL_AURA_MOUNTED aura is removed but BEFORE the dismount
// round-trip completes if invoked twice. Without dedup, a fresh
// dismount intent in flight gets duplicated. Same pattern as
// mount above.
constexpr uint64 dm_key = uint64(0xD150FF1'1u);
if (!ai.action_recently_tried(BotAI::ActionKind::BgPort, dm_key, dm_now_ms))
{
emit.dismount();
ai.note_action_retry(BotAI::ActionKind::BgPort, dm_key, dm_now_ms);
ai.set_last_rule_fired("ingroup:dismount");
}
// Stagger satisfied — release the shared timer so the next
// mount/dismount cycle re-rolls a fresh offset.
ai.set_pending_follow_mount_at_ms(0);
}
}
}
} // namespace Playerbot::States