Files
playerbot-v2/src/modules/PlayerbotV2/PlayerbotV2.cpp
T
devbox e565bd9734 Register connected altbots missing intents in the follow loop
Connected altbots whose AI was never attached by OnPlayerLogin (stale
core hook or is_bot false at login time) never got has_intents, so the
companion loop skipped them forever. Late-register them idempotently on
the next companion tick so they can follow without a relog.
2026-08-14 12:22:23 +10:00

4666 lines
205 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.
// Playerbot V2 - Module entry implementation
// All TrinityCore hook surfaces from PlayerbotV2.h are wired here. Some carry
// dispatch (login/logout register the bot; OnDeath taps loot; DamageTaken /
// HealReceived push event-bus entries; Whisper routes through the command
// parser). Hooks marked "Intentionally a no-op" rely on snapshot deltas
// instead of event pushes — see per-handler comments for the rationale.
#include "PlayerbotV2.h"
#include "PlayerbotHooks.h"
#include "Services.h"
#include "Bot/BotRegistry.h"
#include "Bot/BotSnapshot.h"
#include "Bot/ClassTables.h"
#include "Bot/World/LearnFlightpaths.h"
#include "Bot/BotSnapshotBuilder.h"
#include "Bot/QuestReverseIndex.h"
#include "Bot/World/NavmeshPrewarm.h"
#include "Session/BotSessionMgr.h"
#include "Fleet/BotNamePool.h"
#include "Fleet/BotPopulationManager.h"
#include "Fleet/BotGuildMgr.h"
#include "Fleet/BotCoordinationBus.h"
#include "Fleet/CraftOrderBoard.h"
#include "Fleet/BotQueueFiller.h"
#include "Fleet/JunkQuestResolver.h"
#include "Bot/Battleground/BgTeamCoordinator.h"
#include "Bot/Dungeon/PveGroupCoordinator.h"
#include "Quest/BotQuestResolve.h"
#include "DB2Stores.h"
#include "World.h"
#include "WorldSession.h"
#include "DungeonFinding/LFGMgr.h"
#include "Battleground.h"
#include "BattlegroundMgr.h"
#include "PhasingHandler.h"
#include "Unit.h"
#include "Bot/BotPersonality.h"
#include "Bot/BotArchetype.h"
#include "Bot/BotRng.h"
#include "Bot/BotCommandParser.h"
#include "Bot/BotChatReactor.h"
#include "Bot/BotIntent.h"
#include "Bot/Formation.h"
#include "Threading/IntentQueue.h"
#include "Creature.h"
#include "Group/GroupSnapshotBuilder.h"
#include "Group.h"
#include "GroupReference.h"
#include "GroupMgr.h"
#include "ObjectAccessor.h"
#include "WorldSession.h"
#include "Bot/BotCommandParser.h"
#include "Threading/SnapshotPublisher.h"
#include "Threading/TickScheduler.h"
#include "Threading/SnapshotBuildPool.h"
#include "Persistence/PlayerbotMigrationMgr.h"
#include "Diagnostics/PerfCounters.h"
#include "Util/ConfigReader.h"
#include "World/WorldMetadata.h"
#include "Fleet/BotIdentityRegistry.h"
#include "Fleet/OwnerRegistry.h"
#include "Altbot/AltbotRegistry.h"
#include "Bot/BotAI.h"
#include "Gear/BotGear.h"
#include "Fleet/BotCharacterFactory.h"
#include "Fleet/BotComposition.h"
#include "Combat/ApRegistry.h"
#include "Player.h"
#include "Map.h"
#include "ObjectAccessor.h"
#include "Loot.h"
#include "Log.h"
#include "Timer.h"
#include "GameTime.h" // GameTime::GetGameTimeMS for FleetStatus stuck-bot check
#include "DatabaseEnv.h" // CharacterDatabase — R7 leveling-target hydration + #1C vitals row
#include "fmt/format.h" // #1C fleet-vitals async row INSERT formatting
#include "Config.h" // sConfigMgr — V1-active coexistence warning at boot
#include "WorldSession.h" // HandleMoveTeleportAck / HandleMoveWorldportAck for headless bots
#include "WorldPacket.h"
#include "MovementPackets.h"
#include "Opcodes.h"
#include "MotionMaster.h"
#include "PlayerbotMovement.h" // BotMovement::SafeTeleport for rescue paths
#include "ObjectMgr.h"
#include <shared_mutex>
#include <unordered_map>
#include <unordered_set>
#include <mutex>
#include <atomic>
#include <memory>
#include <vector>
#include <cmath>
#if !TRINITY_PLAYERBOT_V2
#error "PlayerbotV2 module compiled without TRINITY_PLAYERBOT_V2 defined; CMake misconfiguration"
#endif
namespace Playerbot {
// Defined in Bot/BotSnapshotResetCheck.cpp. Aborts at boot if
// BotSnapshot::reset_for_reuse() leaves any container/field non-default —
// guards the recycle pool (SNAPSHOT_PERF_BACKLOG Tier 3.1) against drift.
bool VerifyResetClearsAll();
}
namespace {
bool EnsureAltInOwnerGroup(Player* owner, Player* bot)
{
if (!owner || !bot || !owner->IsInWorld() || !bot->IsInWorld())
return false;
if (bot->GetTeam() != owner->GetTeam())
return false;
if (Group* bg = bot->GetGroup())
{
if (Group* og = owner->GetGroup(); og && bg == og)
return true;
bot->RemoveFromGroup();
}
if (bot->GetGroupInvite())
bot->UninviteFromGroup();
Group* g = owner->GetGroup();
if (!g)
{
g = new Group;
if (!g->Create(owner))
{
delete g;
return false;
}
sGroupMgr->AddGroup(g);
}
if (g->IsFull())
return false;
if (g->IsMember(bot->GetGUID()))
return true;
return g->AddMember(bot);
}
Unit* ResolveOwnerAssistTarget(Player* owner)
{
if (!owner || !owner->IsInWorld())
return nullptr;
Unit* t = owner->GetSelectedUnit();
if (t)
{
if (!t->IsAlive() || t->GetMap() != owner->GetMap())
t = nullptr;
else if (!owner->IsValidAttackTarget(t))
t = nullptr;
}
if (!t)
{
t = owner->GetVictim();
if (t && (!t->IsAlive() || t->GetMap() != owner->GetMap()))
t = nullptr;
if (t && !owner->IsValidAttackTarget(t))
t = nullptr;
}
return t;
}
void AssistOwnerTarget(Player* owner, Player* bot, Unit* victim)
{
if (!owner || !bot || !victim)
return;
if (!bot->IsInWorld() || !bot->IsAlive())
return;
if (!owner->IsInWorld())
return;
if (bot->GetMapId() != owner->GetMapId())
return;
if (victim->GetMap() != bot->GetMap())
return;
if (!victim->IsAlive())
return;
if (victim->GetTypeId() == TYPEID_UNIT)
{
if (Creature* c = victim->ToCreature(); c && c->IsTotem())
return;
}
if (!bot->IsValidAttackTarget(victim))
return;
if (bot->GetVictim() == victim)
return;
bot->SetSelection(victim->GetGUID());
bot->Attack(victim, true); // NOT AssistOwnerTarget(...)
}
bool BotIsHealerPlayer(Player* bot)
{
if (!bot)
return false;
return Playerbot::IsHealerSpec(
bot->GetClass(),
static_cast<uint16>(bot->GetPrimarySpecialization()));
}
} // anonymous namespace
namespace Playerbot::V2 {
namespace {
// Coarse spatial bucket key for the per-tick real-player proximity set.
// Quantises a world position to a ~106 yd grid cell (well above the
// classifier's "near a real player" intent of ≤~100 yd) and packs it with
// map_id into a single uint64. Building one such set per tick (over the
// handful of connected real players) lets the tier classifier answer
// "is this bot near a real player?" with an O(1) set lookup instead of a
// per-bot grid search. To keep the boundary honest (two entities ≤100 yd
// apart can straddle a cell edge), each real player stamps its own cell
// AND the 8 neighbouring cells; a bot is then "near" when its single cell
// is occupied. That guarantees any bot within one cell (~106 yd) of a real
// player matches, with no per-bot radius test — the cost (9 inserts per
// real player) is trivial since real players are few.
constexpr float kRealPlayerCellSize = 106.0f; // ~1.5× SIZE_OF_GRID-cell granularity, > 100y intent
inline uint64 PackPlayerCellKey(uint32 mapId, int32 cx, int32 cy)
{
// 16 bits map | 24 bits cx | 24 bits cy (cells, biased to unsigned).
// World coords span ±17066 → /106 ≈ ±161 cells, far inside 24 bits.
uint64 const ux = uint64(uint32(cx + (1 << 23))) & 0xFFFFFF;
uint64 const uy = uint64(uint32(cy + (1 << 23))) & 0xFFFFFF;
return (uint64(mapId) << 48) | (ux << 24) | uy;
}
inline int32 PlayerCellCoord(float v) { return int32(std::floor(v / kRealPlayerCellSize)); }
// Drives any pending teleport ack on a headless bot. Real clients send
// CMSG_MOVE_TELEPORT_ACK / CMSG_MOVE_WORLDPORT_ACK to finalize teleports;
// without those acks the player sits in WaitingForTeleportAck forever and
// Player::TeleportTo's effect is silently dropped (the m_position never
// advances to m_teleport_dest). The most visible symptom is the corpse-run
// flow: Player::RepopAtGraveyard issues TeleportTo, the bot stays at the
// death location instead of the graveyard, then ResurrectPlayer revives in
// place. We close the loop by manufacturing the ack on the world thread
// — same call the network handler would make on a real packet.
void DriveTeleportAck(Player* p)
{
if (!p) return;
WorldSession* sess = p->GetSession();
if (!sess) return;
// Selfbot guard: only manufacture acks for HEADLESS sessions. A bot
// driven through a REAL client (`.playerbot self`) performs the genuine
// teleport handshake — forging the worldport ack advances the server's
// packet sequence while the client is still loading, desyncing it
// permanently (live 2026-06-11: user's selfbot LFG-ported into Ragefire
// Chasm, server completed the transfer, client stuck on the loading
// screen forever).
if (!Services::SessionMgr().IsHeadless(p->GetGUID()))
return;
// Far teleport: fully synchronous server-side. The intermediate
// SuspendTokenResponse step is a network-only round-trip; we can
// jump straight to the worldport ack which performs the map-change
// and packet sequence.
if (p->GetTeleportState() == TeleportState::WaitingForWorldPortAck ||
p->GetTeleportState() == TeleportState::WaitingForSuspendTokenResponse)
{
sess->HandleMoveWorldportAck();
return;
}
// Near teleport (same map). HandleMoveTeleportAck only validates
// MoverGUID + the WaitingForTeleportAck state; AckIndex / MoveTime
// are unused beyond the debug log. Forge the minimum payload.
if (p->GetTeleportState() == TeleportState::WaitingForTeleportAck)
{
WorldPacket data(CMSG_MOVE_TELEPORT_ACK, 8 + 4 + 4);
data << p->GetGUID();
data << int32(0);
data << int32(GameTime::GetGameTimeMS());
WorldPackets::Movement::MoveTeleportAck ackPacket(std::move(data));
ackPacket.Read();
sess->HandleMoveTeleportAck(ackPacket);
}
}
// Recover Z when the bot has fallen below the world. Conservative on
// purpose: we only act when the bot is more than kBelowGroundCutoff
// yards under the navmesh ground level, which catches "popped below
// map" and severe underground-feet glitches but ignores cosmetic
// 1-2y drift. Snapping aggressively (small threshold, both
// directions) caused visible twitching: a moving bot finishes its
// spline → Idle generator → we pull Z down a fraction → next tick
// the AI emits move_to and the spline starts again, repeat. Real
// players get small Z corrections via client-side ground snap and
// MSG_MOVE_HEARTBEAT updates; we approximate the catastrophic case
// only.
constexpr float kBelowGroundCutoff = 5.0f;
// High-Z stuck rescue threshold. Bots whose stored position landed them
// far ABOVE the actual ground (typical case: pre-fix ZonePicker data
// row with a wrong Z value baked into characters.position_z) sit forever
// at e.g. (1129, -4828, 205) on Northrend while real ground is Z~39.
// Path queries fail (no nearby poly) and the bot ends up an idle
// scarecrow. We only act when the delta exceeds kHighAboveCutoff so
// legitimate above-floor positions (rooftops, mountain ledges, multi-
// story buildings — typically ≤30y above local ground) aren't yanked
// down. Idle/not-flying/not-falling/not-transport gates above still
// apply, so legitimate flight is never disturbed.
constexpr float kHighAboveCutoff = 100.0f;
// Rescue bots whose saved position landed them in an orphaned BG / arena
// instance (the prior server crashed mid-BG before SaveToDB returned them
// to their entry point). Symptoms: bot is in a BG map but has no queue
// invite, no team set, no encounter — purely a ghost spawn. The BG init
// script still spawns flags/doors → BIH churn → SnapToGroundIfDrifted
// can crash (see project_v2_bih_crash.md). Teleport to entry point if
// known, else to Stormwind/Orgrimmar by faction.
//
// Fires at most once per bot per server uptime — once they're out of the
// BG map they stay out. Edge-triggered via Player m_bgData state: if the
// bot is in a BG map but Player::InBattleground() is false (no proper
// queue/instance association), they're orphaned.
void RescueOrphanedBgBot(Player* p)
{
if (!p) return;
Map const* m = p->FindMap();
if (!m) return;
if (!m->IsBattlegroundOrArena()) return;
if (p->InBattleground()) return; // legitimately in BG
if (p->IsBeingTeleported()) return; // mid-teleport, leave alone
// Try entry point first — the pre-BG location saved into m_bgData.joinPos.
WorldLocation entry = p->GetBattlegroundEntryPoint();
if (entry.GetMapId() != 0 && entry.GetMapId() != MAPID_INVALID
&& entry.GetMapId() != m->GetId())
{
TC_LOG_WARN("playerbot.v2",
"[RescueOrphanedBgBot] {} stuck in BG map {}; teleporting to entry point",
p->GetName(), m->GetId());
Playerbot::BotMovement::SafeTeleport(p, entry, /*options*/ 0);
return;
}
// No valid entry point — fall back to faction capital.
const bool alliance = (p->GetTeam() == ALLIANCE);
WorldLocation safe = alliance
? WorldLocation(/*EasternKingdoms*/ 0, Position(-8443.0f, 335.0f, 121.0f, 0.f)) // Stormwind Keep front
: WorldLocation(/*Kalimdor*/ 1, Position( 1924.0f, -4147.0f, 40.0f, 0.f)); // Orgrimmar Grommash Hold
TC_LOG_WARN("playerbot.v2",
"[RescueOrphanedBgBot] {} stuck in BG map {} with no entry point; teleporting to capital",
p->GetName(), m->GetId());
Playerbot::BotMovement::SafeTeleport(p, safe, /*options*/ 0);
}
// Per-bot last-attempted-snap timestamp. Throttles the per-tick BIH
// height query that happens for every idle bot every world tick — at
// 2000 bots × 50Hz = 100K UpdateAllowedPositionZ calls/sec which fan
// out into Map::GetHeight + DynamicMapTree probes. Parked idle bots
// don't move, so once-every-2-seconds Z-snap is more than enough; an
// actively-moving bot is filtered out earlier by isMoving() so the
// throttle never trips for them. Keyed by Player GUID counter.
constexpr uint32 kSnapZIntervalMs = 2000;
// Per-bot 2s throttle for SnapToGroundIfDrifted. Single-threaded
// access — called only from Module::OnWorldUpdate's reg.for_each loop
// on the world thread, so no synchronization is needed. (Pre-2026-05-21
// this was guarded by std::shared_mutex; the mutex was unused because
// the only caller is single-threaded, costing ~2000 lock/unlock per
// tick at fleet scale for nothing.)
std::unordered_map<uint64, uint32> g_last_snap_z_ms;
void SnapToGroundIfDrifted(Player* p)
{
if (!p || !p->IsAlive()) return;
// Selfbot guard: a real client's Z is authoritative from its own
// movement packets — server-side relocation rubber-bands the player.
// Only headless sessions need the drift rescue.
if (!Services::SessionMgr().IsHeadless(p->GetGUID())) return;
if (p->IsBeingTeleported()) return;
if (p->isMoving()) return;
if (p->GetMotionMaster()->GetCurrentMovementGeneratorType() != IDLE_MOTION_TYPE)
return;
if (p->GetTransport() || p->GetVehicle()) return;
if (p->IsFlying() || p->IsFalling()) return; // legitimate Z != ground
// Per-bot 2s throttle. Single-threaded (world-tick ForEachBot
// caller), no mutex required.
const uint64 key = p->GetGUID().GetCounter();
const uint32 now = getMSTime();
{
auto it = g_last_snap_z_ms.find(key);
if (it != g_last_snap_z_ms.end() && getMSTimeDiff(it->second, now) < kSnapZIntervalMs)
return;
}
g_last_snap_z_ms[key] = now;
// Skip ground-snap on BG maps. BG instances cycle dynamic GameObjects
// (flags, doors, capture-point gobs) rapidly during prep/start/end,
// and DynamicMapTree::getHeight occasionally hits a "invalid node
// overlap" exception when the BIH is mid-rebuild from concurrent
// GO add/remove. A crashing 1052-bot fleet doesn't deserve a Z snap;
// BG terrain is well-curated so drift is rare anyway.
if (p->InBattleground()) return;
const float curZ = p->GetPositionZ();
float groundZ = curZ;
// Defensive: DynamicMapTree::getHeight can throw std::logic_error
// ("invalid node overlap") from the BIH balance path when collision
// data is mid-rebuild. Catching here keeps a single bot's Z-snap
// from taking down the world thread. Crash 2026-05-13 05:59 had
// this exception escape into World::Update.
try
{
p->UpdateAllowedPositionZ(p->GetPositionX(), p->GetPositionY(), groundZ);
}
catch (std::exception const& e)
{
TC_LOG_WARN("playerbot.v2",
"[SnapToGroundIfDrifted] {} caught {} during UpdateAllowedPositionZ; skipping",
p->GetName(), e.what());
return;
}
catch (...)
{
TC_LOG_WARN("playerbot.v2",
"[SnapToGroundIfDrifted] {} unknown exception in UpdateAllowedPositionZ; skipping",
p->GetName());
return;
}
// Rescue from below-ground glitches.
if (curZ < groundZ - kBelowGroundCutoff)
{
p->NearTeleportTo(p->GetPositionX(), p->GetPositionY(), groundZ, p->GetOrientation());
return;
}
// Rescue from far-above-ground stuck positions (stale stored Z from
// pre-fix ZonePicker data, off-mesh spawns on top of a sealed building,
// etc.). All the idle/not-flying/not-falling/not-transport gates above
// ensure we never disturb a legitimately-airborne bot. The 100y delta
// avoids snapping bots off rooftops, towers, or mountain platforms.
if (curZ > groundZ + kHighAboveCutoff)
{
TC_LOG_WARN("playerbot.v2",
"[SnapToGroundIfDrifted] {} stuck high (Z={:.1f} vs ground={:.1f}); teleporting down",
p->GetName(), curZ, groundZ);
p->NearTeleportTo(p->GetPositionX(), p->GetPositionY(), groundZ, p->GetOrientation());
}
}
// Global off-mesh stuck rescue. Per-rule wedge guards (Pass 14 on
// idle:wander, plus existing ones on travel/hub/portal/dock rules) only
// fire when the rule itself sees the failures — but higher-priority
// emergency rules (idle:watchdog_escape, idle:flee_hazard,
// dead:walking_to_corpse) grab the tick first and never escalate.
// Bot 88584 on 2026-05-18 logged idle:watchdog_escape at blocks=8,10,12
// with no rescue. Result: 1500+ wasted move_to/sec from a handful of
// off-mesh bots.
//
// This catches the symptom at the bot level regardless of which rule
// emitted: count path failures in a 30s window, and after a threshold,
// teleport to homebind. Homebind innkeepers always sit on navmesh-valid
// polys, so the bot lands somewhere it can pathfind from. Cooldown
// between rescues prevents teleport-spam if homebind itself is somehow
// bad (rare but theoretically possible if owner moved homebind to a
// broken spawn point).
struct GlobalStuckState
{
uint32 baseline_blocks = 0;
uint32 baseline_ms = 0;
uint32 last_rescue_ms = 0;
};
// When the same bot needs rescue again within this window, its homebind
// is probably itself unreachable (observed 2026-05-18: bot Marinon
// rescued twice to map=2081 1621,536,Z=201.5 — Forbidden Reach Dracthyr
// starter, a flying-only zone with sparse navmesh). Escalate the second
// rescue to faction capital, which sits on dense navmesh-valid plaza.
constexpr uint32 kRescueRepeatWindowMs = 10u * 60u * 1000u; // 10 min
// Single-threaded access — GlobalStuckRescue is only called from
// Module::OnWorldUpdate's reg.for_each loop on the world thread.
// No synchronization needed. (Pre-2026-05-21 was guarded by
// std::shared_mutex; the mutex was unused because the only caller
// path is single-threaded.)
std::unordered_map<uint64, GlobalStuckState> g_global_stuck;
std::unordered_map<uint64, uint32> g_lastLevelSyncMs; // companion level-sync throttle
constexpr uint32 kStuckThreshold = 40; // blocks in window (raised from 15 — favour path retries over rescue)
constexpr uint32 kStuckWindowMs = 60u * 1000u; // 60 s (raised from 30 s)
constexpr uint32 kStuckRescueCooldown = 300u * 1000u; // 5 min (raised from 120 s)
struct CapitalInnkeeperCache
{
bool loaded = false;
bool alliance_valid = false;
float alliance_x = 0.f, alliance_y = 0.f, alliance_z = 0.f;
bool horde_valid = false;
float horde_x = 0.f, horde_y = 0.f, horde_z = 0.f;
};
static CapitalInnkeeperCache s_inn_cache;
static std::once_flag s_inn_cache_once;
WorldLocation FindCapitalRescuePos(bool alliance)
{
std::call_once(s_inn_cache_once, []()
{
constexpr float kSW_cx = -8850.f, kSW_cy = 660.f;
constexpr float kOR_cx = 1900.f, kOR_cy = -4300.f;
float best_sw_dsq = 600.f * 600.f;
float best_or_dsq = 600.f * 600.f;
auto const& all = sObjectMgr->GetAllCreatureData();
for (auto const& [spawn_id, cd] : all)
{
if (cd.mapId != 0 && cd.mapId != 1) continue;
auto const* ct = sObjectMgr->GetCreatureTemplate(cd.id);
if (!ct) continue;
if (!(ct->npcflag & UNIT_NPC_FLAG_INNKEEPER)) continue;
if (cd.mapId == 0)
{
const float dx = cd.spawnPoint.GetPositionX() - kSW_cx;
const float dy = cd.spawnPoint.GetPositionY() - kSW_cy;
const float dsq = dx*dx + dy*dy;
if (dsq < best_sw_dsq)
{
best_sw_dsq = dsq;
s_inn_cache.alliance_x = cd.spawnPoint.GetPositionX();
s_inn_cache.alliance_y = cd.spawnPoint.GetPositionY();
s_inn_cache.alliance_z = cd.spawnPoint.GetPositionZ();
s_inn_cache.alliance_valid = true;
}
}
else if (cd.mapId == 1)
{
const float dx = cd.spawnPoint.GetPositionX() - kOR_cx;
const float dy = cd.spawnPoint.GetPositionY() - kOR_cy;
const float dsq = dx*dx + dy*dy;
if (dsq < best_or_dsq)
{
best_or_dsq = dsq;
s_inn_cache.horde_x = cd.spawnPoint.GetPositionX();
s_inn_cache.horde_y = cd.spawnPoint.GetPositionY();
s_inn_cache.horde_z = cd.spawnPoint.GetPositionZ();
s_inn_cache.horde_valid = true;
}
}
}
s_inn_cache.loaded = true;
TC_LOG_INFO("playerbot.v2",
"[GlobalStuckRescue] Capital innkeeper cache: alliance={} ({:.1f},{:.1f},{:.1f}), horde={} ({:.1f},{:.1f},{:.1f})",
s_inn_cache.alliance_valid, s_inn_cache.alliance_x, s_inn_cache.alliance_y, s_inn_cache.alliance_z,
s_inn_cache.horde_valid, s_inn_cache.horde_x, s_inn_cache.horde_y, s_inn_cache.horde_z);
});
if (alliance && s_inn_cache.alliance_valid)
return WorldLocation(0, Position(s_inn_cache.alliance_x, s_inn_cache.alliance_y, s_inn_cache.alliance_z, 0.f));
if (!alliance && s_inn_cache.horde_valid)
return WorldLocation(1, Position(s_inn_cache.horde_x, s_inn_cache.horde_y, s_inn_cache.horde_z, 0.f));
return alliance
? WorldLocation(0, Position(-8868.f, 671.f, 98.f, 0.f))
: WorldLocation(1, Position(1633.f, -4439.f, 17.f, 0.f));
}
void GlobalStuckRescue(Player* p, BotAI* ai)
{
if (!p || !ai) return;
if (!p->IsInWorld()) return;
if (p->IsBeingTeleported()) return;
if (p->IsFlying() || p->IsFalling()) return;
// Taxi flight (UNIT_STATE_IN_FLIGHT) — the core owns movement; a homebind
// teleport here would abort the flight and strand the bot. IsFlying() above
// is a movement FLAG (free-flight mounts) and is NOT set during a taxi
// spline, so this needs its own guard.
if (p->IsInFlight()) return;
if (p->InBattleground()) return; // BG handles its own respawn loop
if (p->GetMap() && p->GetMap()->IsDungeon()) return; // dungeon party in progress
if (p->GetMap() && p->GetMap()->IsRaid()) return; // raid encounter — never yank
if (p->GetTransport() || p->GetVehicle()) return;
if (p->GetGroup() != nullptr) return; // grouped bot — leader-follow handles travel
// LFG-pipeline guard. The IsDungeon()/IsRaid()/group guards above only cover a
// bot that is CURRENTLY on the instance map AND still grouped. A dungeon-finder
// member that is path-blocked at the instance ENTRANCE (the portal sits on a
// world map — e.g. Ragefire's is inside Orgrimmar), mid-teleport between world
// and instance, or transiently solo for a tick while the LFG group settles is
// NONE of those — so a homebind teleport here yanks it out of the run and
// collapses the whole group (observed live: a path-blocked squad member was
// teleported to Stormwind and the Deadmines run dissolved). A teleport-rescue
// is the wrong tool for a dungeon-runner anyway ([[feedback_no_teleport_rescue]]).
// Skip the rescue for any bot in the LFG pipeline (queued → proposal → role
// check → in-dungeon → finished) or with dungeon-run mode active; the LFG /
// dungeon system owns its location and a stuck member re-paths toward the group.
if (ai->dungeon_active()) return;
if (sLFGMgr->GetState(p->GetGUID()) != lfg::LFG_STATE_NONE) return;
// Combat: flee_hazard legitimately fires mid-encounter for world-boss
// ground AoEs, elite quest mob cleave puddles, etc. Teleporting out
// would ruin the fight (the bot abandons combat AND the player's
// expectation that the bot is helping). Block while in combat; the
// path failures will continue but the per-rule wedges still apply,
// and once combat ends the threshold can trip on the next window.
if (p->IsInCombat()) return;
// Corpse run / ghost: dead:walking_to_corpse fires for a legitimate
// post-wipe recovery. The State_Dead 5-min timeout already escalates
// to SpiritResurrect when the corpse path is truly unreachable; we
// mustn't pre-empt that path with a homebind teleport (which would
// strand the corpse and not actually resurrect the bot).
if (!p->IsAlive()) return;
if (p->HasPlayerFlag(PLAYER_FLAGS_GHOST)) return;
// Charter founders + signers were just deliberately teleported onto
// the petitioner plaza by BotGuildMgr. Their path_blocks counter
// grows from in-plaza walks (5y movement to petitioner / signer ring
// jitter); homebind-rescuing them sends the founder cross-continent
// and the FSM aborts. The 20-min charter-grace fully covers the
// FSM budget — skip the global rescue while it's armed.
if (ai->in_charter_grace(getMSTime())) return;
// No-teleport-rescue for objective-unreachable bots. When the bot is wedged
// because its CURRENT quest objective is genuinely unpathable, the fast
// NoPath blacklist (note_obj_observed → ~6s) has already / is about to flag
// the objective so the snapshot Builder's picker moves to a reachable quest.
// That reroute is the correct fix (the bot walks somewhere it CAN path and
// path_blocked_count resets) — yanking it to homebind/capital instead would
// violate the no-teleport-rescue principle and strand it mid-zone. Defer the
// rescue while the wedge is attributable to the objective walk AND that
// objective is currently blacklisted; genuine off-mesh / fall-through cases
// (last rule was wander / watchdog_escape / hub travel, no objective
// blacklist) still escalate to the teleport below as a last resort.
{
char const* lr = ai->last_rule_fired();
const bool on_objective_walk =
lr != nullptr && std::string_view(lr) == "idle:quest_path";
if (on_objective_walk && ai->current_objective_blacklisted(getMSTime()))
return;
}
const uint32 cur = ai->path_blocked_count();
const uint32 now = getMSTime();
const uint64 key = p->GetGUID().GetCounter();
GlobalStuckState st{};
{
auto it = g_global_stuck.find(key);
if (it != g_global_stuck.end()) st = it->second;
}
// First observation OR baseline aged out → reset baseline.
if (st.baseline_ms == 0 || getMSTimeDiff(st.baseline_ms, now) > kStuckWindowMs)
{
st.baseline_blocks = cur;
st.baseline_ms = now;
g_global_stuck[key] = st;
return;
}
// Counter regressed below the baseline. path_blocked_count_ now resets to
// 0 on every successful move (note_move_succeeded), so `cur < baseline`
// means the bot made real progress since the window opened — it is NOT
// wedged. Re-baseline and bail; without this guard the uint32 subtraction
// below underflows to ~4e9 and trips an immediate false rescue.
if (cur < st.baseline_blocks)
{
st.baseline_blocks = cur;
st.baseline_ms = now;
g_global_stuck[key] = st;
return;
}
// Within the cooldown window after a recent rescue → don't re-rescue.
if (st.last_rescue_ms != 0 && getMSTimeDiff(st.last_rescue_ms, now) < kStuckRescueCooldown)
return;
const uint32 grew = cur - st.baseline_blocks;
if (grew < kStuckThreshold) return;
// If we already rescued this bot recently (within 10 min), its
// homebind is itself unreachable — observed for Dracthyr bots whose
// homebind landed on map 2081 (Forbidden Reach), a flying-only zone
// with sparse navmesh. Escalate to faction capital, which always sits
// on dense navmesh-valid terrain (Stormwind / Orgrimmar plaza).
const bool homebind_proven_bad =
st.last_rescue_ms != 0 &&
getMSTimeDiff(st.last_rescue_ms, now) < kRescueRepeatWindowMs;
WorldLocation tgt;
auto snap = Services::Snapshots().latest(key);
if (!homebind_proven_bad &&
snap && snap->travel.homebind_map_id != 0)
{
tgt = WorldLocation(snap->travel.homebind_map_id,
snap->travel.homebind_x,
snap->travel.homebind_y,
snap->travel.homebind_z, 0.f);
}
else
{
const bool alliance = (p->GetTeam() == ALLIANCE);
tgt = FindCapitalRescuePos(alliance);
if (homebind_proven_bad)
{
TC_LOG_WARN("playerbot.v2",
"[GlobalStuckRescue] {} previous homebind rescue didn't stick — "
"escalating to faction capital instead", p->GetName());
}
}
TC_LOG_WARN("playerbot.v2",
"[GlobalStuckRescue] {} path_blocks grew {}→{} in {}ms; teleporting to homebind (map={} {:.1f},{:.1f},{:.1f})",
p->GetName(), st.baseline_blocks, cur,
getMSTimeDiff(st.baseline_ms, now),
tgt.GetMapId(), tgt.GetPositionX(), tgt.GetPositionY(), tgt.GetPositionZ());
Playerbot::BotMovement::SafeTeleport(p, tgt, /*options*/ 0);
// Post-teleport state reset. Without this, the rules' next tick
// would carry forward:
// - the IntentQueue's pending move_to entries from the prior
// stuck position (now firing against fresh coords, which fails
// just as fast),
// - the rule-wedge slots still marking "idle:wander is wedged",
// - the monotonic path_blocked_count primed near the rescue
// threshold, so the very next failure would re-rescue.
// Drain the intent queue inline (single consumer = world thread =
// here), then ai.reset_after_rescue clears the wedge/counter state
// and arms the grace timer that travel rules consult.
if (auto* q = Services::Registry().intents(key))
{
Intent discarded;
while (q->pop(discarded)) { /* drop */ }
}
ai->reset_after_rescue(now);
st.last_rescue_ms = now;
st.baseline_blocks = cur;
st.baseline_ms = now;
g_global_stuck[key] = st;
}
} // namespace
Module& Module::instance()
{
static Module m;
return m;
}
void Module::Init()
{
if (initialized_)
return;
TC_LOG_INFO("server.loading", "[PlayerbotV2] Initializing module.");
// Apply schema migrations before bringing up Services. If this fails,
// refuse to initialize so the server doesn't run with a corrupt schema.
PlayerbotMigrationMgr migrations;
if (!migrations.run_all())
{
TC_LOG_ERROR("server.loading", "[PlayerbotV2] Schema migration failed; module disabled.");
return;
}
// Bring up Services (SnapshotPublisher, IntentQueue, AiWorkerPool,
// FleetThread, TickScheduler, BotRegistry). Adds threads.
Services::Init();
// Register class/spec rotations (single-threaded; runs before AI workers
// dispatch ticks). Read-only afterwards; safe for concurrent lookup.
Combat::RegisterAllRotations();
// Tier 3.1 guard: prove BotSnapshot::reset_for_reuse() clears every
// member before we start recycling snapshots in Build(). A leak here is a
// silent stale-data correctness bug; abort at boot instead.
Playerbot::VerifyResetClearsAll();
// Pre-warm grids + nav tiles for high-traffic bot zones (8 capitals + 3
// class-starter maps). Sync; ~1-3s of disk I/O on cold cache, paid once
// at startup so the population manager's batched logins don't cascade
// into per-tick navmesh loads on the map worker thread.
// Disabled by default — enable with PlayerbotV2.PrewarmCommonZones.
if (sConfigMgr->GetBoolDefault("PlayerbotV2.PrewarmCommonZones", false))
World::PrewarmCommonZones();
// Pin all battleground terrain (grid maps + vmap tiles + mmap tiles)
// resident for the server's lifetime. BG maps are instanced; without
// the pin their tiles unload when the last match ends and the next
// match re-reads them from disk synchronously on map-update threads,
// which showed up as 4-10s world-tick spikes around match boundaries
// once the 2026-06-12 nav regen gave BG maps real vmap/mmap data.
//
// WARNING: this loads EVERY BG map's terrain into memory at startup.
// On modern expansions with 200+ BG maps this can consume 8-10 GB.
// Disabled by default — enable with PlayerbotV2.PinBattlegroundTerrain.
if (sConfigMgr->GetBoolDefault("PlayerbotV2.PinBattlegroundTerrain", false))
World::PinBattlegroundTerrain();
// Reconcile BotNamePool against the characters table — any name with
// is_used=1 whose used_by_guid no longer exists in characters gets
// released. Catches SQL-wipe / mid-create-crash orphans so a fresh
// boot doesn't see a phantom-claimed pool.
Fleet::BotNamePool::ReconcileOnBoot();
// Load operator-curated world knowledge (roads, cities, danger zones,
// hubs, vendors, mailboxes, innkeepers, crossroads). Populated via
// `.playerbot meta add` GM commands during play; consumed by snapshot
// builder + mmaps_generator. Synchronous one-shot load — table is
// bounded at hundreds-to-thousands of rows, takes <100ms. Future
// mutations through HandleMeta update both DB and in-memory cache
// directly so the cache doesn't drift from the source of truth.
Playerbot::V2::World::WorldMetadataStore::Instance().ReloadFromDb();
// Future inits (as subsystems land):
// - ConfigReader::load(...) (Util/)
// - PopulationManager / LfgMediator / BgFiller (Fleet/)
// - EncounterRegistry::RegisterAll() (Combat/Encounters/)
initialized_ = true;
if (!Services::Config().fleet_bots())
{
// PlayerbotsV2.FleetBots=0 (DEFAULT) — alt-bot-only mode. The
// ambient fleet does not run: no auto-resume / auto-spawn of
// population bots at boot, and the fleet subsystems (population
// shaper, guilds, craft-order board, BG/LFG auto-fill) are all
// skipped in OnWorldUpdate. The only bots that enter the world
// are those a player explicitly brings up via the alt creation
// path (.playerbot summon / login / alt ...).
auto_resume_pending_ = 0;
auto_spawn_pending_ = 0;
TC_LOG_INFO("server.loading",
"[PlayerbotV2] PlayerbotsV2.FleetBots=0: ambient fleet disabled — "
"alt-bot creation path only (summon/login/alt). "
"AutoResumeOnBoot/AutoSpawnOnBoot ignored.");
}
else if (Services::Config().auto_resume_on_boot())
{
// B-2: clamp the boot fill to Population.TotalTarget. AutoResumeCap
// is an independent knob (default 100), so a 10-bot target still
// boot-logged 100+ sessions which then sat overshot until the
// population manager trimmed them — boot now respects the target
// immediately and Reconcile only ever tops UP from here.
auto_resume_pending_ = Services::Config().auto_resume_cap();
if (uint32 target = Services::Config().population_total_target())
auto_resume_pending_ = std::min(auto_resume_pending_, target);
TC_LOG_INFO("server.loading",
"[PlayerbotV2] AutoResumeOnBoot=true; will batch-login marked bots over multiple ticks (cap {}).",
auto_resume_pending_);
}
else
{
auto_resume_pending_ = 0;
}
if (Services::Config().fleet_bots())
{
auto_spawn_pending_ = Services::Config().auto_spawn_on_boot();
if (auto_spawn_pending_)
{
TC_LOG_INFO("server.loading",
"[PlayerbotV2] AutoSpawnOnBoot={}; will create new bots on first world tick to reach that target (hard cap 200).",
auto_spawn_pending_);
}
}
else
{
auto_spawn_pending_ = 0;
}
// #1B: apply the configured wedge-confirm thresholds + displacement gate.
wedge_watchdog_.set_threshold_ms(Services::Config().wedge_watchdog_threshold_ms());
wedge_watchdog_.set_combat_threshold_ms(Services::Config().wedge_watchdog_combat_threshold_ms());
wedge_watchdog_.set_min_displacement(Services::Config().wedge_watchdog_min_displacement());
wedge_watchdog_.set_noprogress_enabled(Services::Config().wedge_noprogress_enabled());
wedge_watchdog_.set_noprogress_ms(Services::Config().wedge_noprogress_ms());
wedge_watchdog_.set_noprogress_radius(Services::Config().wedge_noprogress_radius());
TC_LOG_INFO("server.loading", "[PlayerbotV2] Initialization complete.");
}
// ---- Bot-group hygiene -----------------------------------------------------
//
// A group of pure bots must NOT persist across a server restart: it reloads
// with stale leader/position state and the followers wedge chasing a leader
// that's nowhere near where it was (the follow_recall spam in the logs). Only
// a group that contains a human is allowed to persist. And once that human has
// been logged out for more than 30 minutes, the group is disbanded too — the
// bots would otherwise follow an absent leader forever.
//
// Two entry points:
// * on_shutdown=true (Module::Shutdown): disband every pure-bot group so
// none survive to the next boot. Human groups persist.
// * on_shutdown=false (periodic OnWorldUpdate): disband human groups whose
// human member(s) have ALL been offline ≥30 min. Pure-bot groups are left
// alone at runtime — they're legitimate (dungeon / BG / quest) and the
// walk-first follow recovery handles any transient wedging.
namespace
{
constexpr uint32 kHumanOfflineDisbandMs = 30u * 60u * 1000u;
// group-id → first tick we observed ALL its humans offline. Cleared when a
// human comes back online or the group is disbanded. World-thread only.
std::unordered_map<ObjectGuid::LowType, uint32> g_group_humans_offline_since;
// purge_pure_bot=true → disband every pure-bot group (used at shutdown and
// once at boot, so none persist across a restart /
// survive a crash). Human groups are left intact.
// purge_pure_bot=false → runtime pass: leave pure-bot groups (legit dungeon
// / BG / quest groups) and only disband human groups
// whose human member(s) have ALL been offline ≥30min.
void DisbandStaleBotGroups(bool purge_pure_bot, uint32 now_ms)
{
if (!Services::Initialized())
return;
std::vector<std::pair<Group*, char const*>> to_disband;
for (auto const& [gid, group] : sGroupMgr->GetGroupStore())
{
if (!group)
continue;
bool has_bot = false, has_human = false, any_human_online = false;
for (auto const& slot : group->GetMemberSlots())
{
if (Services::Lifecycle().is_bot(slot.guid.GetCounter()))
{
has_bot = true;
}
else
{
has_human = true;
if (ObjectAccessor::FindConnectedPlayer(slot.guid))
any_human_online = true;
}
}
// Groups with no bot member are not our concern.
if (!has_bot)
continue;
if (!has_human)
{
g_group_humans_offline_since.erase(gid);
if (purge_pure_bot)
{
// LFG dungeon groups that are actively inside an instance
// are NOT stale persisted leftovers — they're live runs
// (e.g. a pure-bot dungeon test that queued via LFG). Skip
// them at boot so the boot purge doesn't eject bots from a
// dungeon they just entered. The purge targets groups that
// survived a hard-kill/crash without running Shutdown, which
// means they exist in the DB but have no live members — those
// groups will have no connected players in their slots.
if (group->isLFGGroup())
{
bool any_connected = false;
for (auto const& slot : group->GetMemberSlots())
if (ObjectAccessor::FindConnectedPlayer(slot.guid))
{ any_connected = true; break; }
if (any_connected)
continue; // live LFG run — leave it alone
}
to_disband.emplace_back(group, "pure-bot");
}
continue;
}
// Human-containing group: persists across shutdown/boot by design.
if (purge_pure_bot)
continue;
if (any_human_online)
{
g_group_humans_offline_since.erase(gid); // reset the AFK timer
continue;
}
// All humans offline — start / check the 30-min disband timer.
auto it = g_group_humans_offline_since.find(gid);
if (it == g_group_humans_offline_since.end())
g_group_humans_offline_since[gid] = now_ms;
else if (now_ms - it->second >= kHumanOfflineDisbandMs)
to_disband.emplace_back(group, "human-afk-30min");
}
// Disband AFTER iterating — Group::Disband() mutates the GroupStore.
for (auto const& [g, reason] : to_disband)
{
g_group_humans_offline_since.erase(g->GetGUID().GetCounter());
TC_LOG_INFO("playerbot.v2",
"[group_hygiene] disbanding bot group {} reason={}",
g->GetGUID().GetCounter(), reason);
g->Disband();
}
}
}
void Module::Shutdown()
{
if (!initialized_)
return;
TC_LOG_INFO("server.loading", "[PlayerbotV2] Shutting down module.");
// Disband pure-bot groups BEFORE Services::Shutdown() (which tears down the
// bot-identity registry is_bot() relies on) so they don't persist to the
// next boot and reload into wedged follow loops.
DisbandStaleBotGroups(/*purge_pure_bot=*/true, getMSTime());
// #5 Phase 4: join the parallel snapshot-build workers before tearing down
// Services — the build tasks call into Services (Perf / Registry) and must
// be quiesced first. stop() joins all worker threads; destruction would
// also join, but doing it here makes the ordering explicit and ensures no
// worker is mid-Build when Services::Shutdown() runs.
if (snapshot_build_pool_)
{
snapshot_build_pool_->stop();
snapshot_build_pool_.reset();
}
// Stop threads first; then tear down state.
Services::Shutdown();
initialized_ = false;
}
void Module::OnWorldUpdate(std::chrono::milliseconds diff)
{
if (!initialized_) return;
static std::chrono::milliseconds total{0};
// #5 Phase 4: next_version is read-modify-write per built snapshot. Under
// parallel-by-Map* Build a plain ++ is a torn counter (duplicate/garbage
// versions break the AI worker's staleness/version checks). Versions need
// only be UNIQUE + monotonic, not contiguous, so an atomic fetch_add is
// the race-free fix. Pre-assigned per bot before the parallel region, so
// the parallel tasks never touch this counter themselves.
static std::atomic<SnapshotVer> next_version{1};
static TickId next_tick = 1;
total += diff;
// First-tick: build quest reverse-indices (KillCredit aliases + creature
// labels). Templates are guaranteed loaded by world-update time; doing
// this once on first tick avoids a per-snapshot lazy build under contention.
{
static bool reverse_indices_built = false;
if (!reverse_indices_built)
{
EnsureQuestReverseIndicesBuilt();
reverse_indices_built = true;
}
}
// Boot-time human-login priority gate. AutoResume / AutoSpawn are
// deferred until either (a) a non-bot WorldSession is in-world, or
// (b) kBootGraceMs has elapsed since the first OnWorldUpdate.
//
// Without this gate, AutoSpawn fired synchronously on tick #1, doing
// up to 200 × BotCharacterFactory::Create (each SaveToDB ~30ms) on
// the world thread — ~6s of blocking during which inbound human
// login packets sat in the session queue and the player's client
// timed out / appeared frozen. Observed 2026-05-15 right after the
// V2 wipe: server came up, AutoSpawn ran, owner couldn't log in.
//
// Sticky once opened — first human triggers it for the rest of boot.
constexpr uint32 kBootGraceMs = 60'000; // 60s lights-out fallback
if (!boot_gate_open_)
{
if (boot_first_tick_at_.count() == 0)
boot_first_tick_at_ = total;
const uint32 since_first_tick_ms =
uint32((total - boot_first_tick_at_).count());
// Scan WorldSessionMgr for any non-bot session that has loaded
// a Player (i.e. real human is in-world). Bot sessions live in
// BotSessionMgr's own map; sWorld->m_sessions covers humans.
bool human_present = false;
// Fully qualify ::World — Playerbot::V2::World is a sibling
// namespace (CapitalsTable etc.) and shadows the global class.
for (auto const& kv : ::World::instance()->GetAllSessions())
{
WorldSession* session = kv.second;
if (!session) continue;
Player* p = session->GetPlayer();
if (!p) continue;
// BotSession reuses real account ids, so account-id alone can't
// distinguish a bot from a human; check the V2 registry instead.
if (Services::Lifecycle().is_bot(p->GetGUID().GetCounter()))
continue;
human_present = true;
break;
}
if (human_present || since_first_tick_ms >= kBootGraceMs)
{
boot_gate_open_ = true;
TC_LOG_INFO("playerbot.v2",
"[PlayerbotV2] boot gate OPEN ({}); bot spawn/login passes resume.",
human_present ? "human in-world" : "grace expired");
}
}
// One-time boot purge: a hard crash (Shutdown never ran) can leave
// persisted pure-bot groups in the DB; they reload with stale state and
// wedge. Once the registry is ready (boot gate open), disband any that
// survived. Bots haven't formed new groups yet at this point, so every
// pure-bot group present is a stale persisted one.
{
static bool boot_group_purge_done = false;
if (!boot_group_purge_done && boot_gate_open_)
{
boot_group_purge_done = true;
DisbandStaleBotGroups(/*purge_pure_bot=*/true, getMSTime());
}
}
// Periodic bot-group hygiene (every 60s): disband human groups whose
// human member(s) have all been offline ≥30 min so the bots stop
// following an absent leader. Pure-bot runtime groups are left alone
// (handled at shutdown / boot). Cheap — a few dozen groups, once a minute.
{
static std::chrono::milliseconds last_group_hygiene{0};
constexpr std::chrono::milliseconds kGroupHygieneInterval{60'000};
if (total - last_group_hygiene >= kGroupHygieneInterval)
{
last_group_hygiene = total;
DisbandStaleBotGroups(/*purge_pure_bot=*/false, getMSTime());
}
}
// Auto-resume: log in marked bots up to AutoResumeCap, spread over
// many ticks. Previously this fired LoginAll(cap) on a single tick,
// submitting 100+ Player::SaveToDB calls simultaneously. Each save
// ends with REPLACE INTO battlenet_account_mounts keyed by the bot's
// shared bnet account row — multiple bots per pool account race on
// the same row lock. Innodb_lock_wait_timeout=50s, FreezeDetector
// fires at 60s. Observed 2026-05-16 crash: "MySQL errno 1205 Lock
// wait timeout exceeded" → world thread hung 60s.
//
// Spreading the resume kBootSpawnPerTick at a time keeps the bnet
// write rate well below the row-lock contention threshold.
constexpr uint32 kBootResumePerTick = 5;
if (auto_resume_pending_ > 0 && boot_gate_open_ && Services::Config().fleet_bots())
{
const uint32 batch = std::min(auto_resume_pending_, kBootResumePerTick);
// LoginAll's cap is "stop when active_count >= cap". Active count
// grows as we resume; passing (current + batch) acts as a per-call
// batch limit. Re-evaluates each tick.
const uint32 active_now = Services::SessionMgr().active_count();
auto r = Services::SessionMgr().LoginAll(active_now + batch);
if (r.attempted > 0)
TC_LOG_INFO("playerbot.v2",
"[PlayerbotV2] AutoResume batch: attempted={} submitted={} already_in_world={} remaining={}.",
r.attempted, r.succeeded, r.skipped_already_in_world,
auto_resume_pending_ - r.attempted);
// Decrement by attempts (not just successes) so a steady stream
// of "already in world" entries doesn't starve us into an infinite
// loop. After cap ticks, pending hits 0 either way.
if (r.attempted >= auto_resume_pending_)
auto_resume_pending_ = 0;
else
auto_resume_pending_ -= r.attempted;
// If no attempts AND no progress, the registry is exhausted —
// drain so we don't spin forever.
if (r.attempted == 0)
auto_resume_pending_ = 0;
}
// Auto-spawn: top up the marked-bot count to AutoSpawnOnBoot. Spread
// over multiple ticks (kBootSpawnPerTick per tick) so each tick's
// synchronous SaveToDB cost (~30ms × per-tick limit) leaves headroom
// for human-login packet processing on the world thread. At 5/tick
// and 1Hz module tick that's ~150ms/sec of DB work — invisible to
// humans. A 200-bot boot completes in ~40s instead of a 6s freeze.
// Hard-capped at AUTO_SPAWN_HARD_CAP overall.
constexpr uint32 AUTO_SPAWN_HARD_CAP = 200;
constexpr uint32 kBootSpawnPerTick = 5;
if (auto_spawn_pending_ && boot_gate_open_ && Services::Config().fleet_bots())
{
const uint32 currentMarked = uint32(Services::Lifecycle().size());
const uint32 target_total = std::min(
auto_spawn_pending_ + currentMarked, currentMarked + AUTO_SPAWN_HARD_CAP);
if (currentMarked >= target_total)
{
auto_spawn_pending_ = 0;
TC_LOG_INFO("playerbot.v2",
"[PlayerbotV2] AutoSpawn: target reached (marked={}).", currentMarked);
}
else
{
const uint32 remaining = target_total - currentMarked;
const uint32 batch = std::min(remaining, kBootSpawnPerTick);
uint32 created = 0, login_ok = 0, login_fail = 0;
for (uint32 i = 0; i < batch; ++i)
{
auto picked = BotComposition::Roll();
if (picked.race == 0) break;
auto r = BotCharacterFactory::Create(
/*ownerSession*/ nullptr, picked.name, picked.race, picked.cls, picked.gender);
if (!r.ok) continue;
++created;
auto login = Services::SessionMgr().LoginBot(r.guid);
if (login.ok) ++login_ok; else ++login_fail;
if (auto_spawn_pending_ > 0) --auto_spawn_pending_;
}
if (created > 0)
TC_LOG_INFO("playerbot.v2",
"[PlayerbotV2] AutoSpawn batch: created={} login_ok={} login_fail={} "
"remaining_target={} marked_now={}.",
created, login_ok, login_fail, auto_spawn_pending_,
uint32(Services::Lifecycle().size()));
}
}
// Companion bot maintenance: level sync, assist, combat stop, formation.
// ~250ms cadence.
{
static uint32 lastCompanionTick = 0;
uint32 const now_ms = static_cast<uint32>(total.count());
if (now_ms - lastCompanionTick >= 250)
{
lastCompanionTick = now_ms;
DrainCompanionFinalizes(now_ms);
DrainAltFinalizes(now_ms);
uint32 levelSyncDone = 0;
for (auto const& kv : ::World::instance()->GetAllSessions())
{
WorldSession* sess = kv.second;
if (!sess || sess->IsBot())
continue;
Player* owner = sess->GetPlayer();
if (!owner)
continue;
uint32 const account = sess->GetAccountId();
auto alts = Services::Altbots().AltsOfAccount(account);
for (BotId const botId : alts)
{
if (!Services::Registry().has(botId))
{
Player* hbot = ObjectAccessor::FindConnectedPlayer(
ObjectGuid::Create<HighGuid::Player>(botId));
if (hbot)
{
BotPersonality personality =
Services::Config().random_personality()
? RandomPersonality(SeedForBot(botId))
: DefaultPersonality();
Services::Registry().register_bot(
botId, personality, BotRng{SeedForBot(botId)});
Services::Scheduler().register_bot(botId, ActivityTier::Idle);
TC_LOG_INFO("playerbot.v2",
"[AltFollow] {} late-registered connected altbot "
"(no intents), follow resumes next tick",
hbot->GetName());
}
}
/*
if (levelSyncDone < 3)
{
auto it = g_lastLevelSyncMs.find(uint64(botId));
if (it == g_lastLevelSyncMs.end() ||
(now_ms - it->second) >= 10000)
{
if (Player* bot = ObjectAccessor::FindConnectedPlayer(
ObjectGuid::Create<HighGuid::Player>(botId)))
{
uint8 const ownerLevel = owner->GetLevel();
if (bot->GetLevel() != ownerLevel)
{
bot->GiveLevel(ownerLevel);
g_lastLevelSyncMs[uint64(botId)] = now_ms;
++levelSyncDone;
}
}
}
}
*/
{
uint8 formationIdx = 0;
for (BotId b_id : Services::Altbots().AltsOfAccount(account))
{
if (!Services::Registry().has(b_id))
continue;
if (BotAI* b_ai = Services::Registry().ai(b_id))
{
if (b_ai->formation_type() == FormationType::Free)
b_ai->set_formation_type(FormationType::Line);
b_ai->set_formation_slot(formationIdx++);
}
}
}
Player* bot = ObjectAccessor::FindConnectedPlayer(
ObjectGuid::Create<HighGuid::Player>(botId));
if (!bot)
continue;
bot->RemovePlayerFlag(PLAYER_FLAGS_AFK);
if (!bot->GetGroup() || bot->GetGroup() != owner->GetGroup())
EnsureAltInOwnerGroup(owner, bot);
// Assist — only here (owner + bot in scope)
Unit* assist = nullptr;
if (bot->GetMapId() == owner->GetMapId() &&
bot->IsAlive() && !bot->IsBeingTeleported())
{
assist = ResolveOwnerAssistTarget(owner);
if (assist && !BotIsHealerPlayer(bot))
AssistOwnerTarget(owner, bot, assist);
}
bool const ownerHasFight =
(assist != nullptr) ||
owner->IsInCombat() ||
(owner->GetVictim() && owner->GetVictim()->IsAlive());
bool const botFighting =
(bot->GetVictim() && bot->GetVictim()->IsAlive()) ||
bot->IsInCombat();
if (bot->GetMapId() != owner->GetMapId() ||
!Services::HasIntents(botId))
{
static std::unordered_map<uint32, uint32> s_skipDbg;
uint32 const now_dbg = getMSTime();
if (now_dbg - s_skipDbg[botId] >= 5000)
{
s_skipDbg[botId] = now_dbg;
TC_LOG_ERROR("playerbot.v2",
"[AltFollow] {} skip reason={} map_bot={} map_owner={} has_intents={}",
bot->GetName(),
(bot->GetMapId() != owner->GetMapId()) ? "different_map" : "no_intents",
bot->GetMapId(), owner->GetMapId(),
Services::HasIntents(botId));
}
continue;
}
// Emergency heal on owner
if (owner->GetHealth() > 0 &&
owner->GetHealth() <= owner->GetMaxHealth() * 0.35f)
{
uint32 healId = 0;
switch (bot->GetClass())
{
case CLASS_PRIEST:
if (bot->HasSpell(2061)) healId = 2061; break;
case CLASS_DRUID:
if (bot->HasSpell(8936)) healId = 8936; break;
case CLASS_PALADIN:
if (bot->HasSpell(19750)) healId = 19750; break;
case CLASS_SHAMAN:
if (bot->HasSpell(8004)) healId = 8004; break;
case CLASS_MONK:
if (bot->HasSpell(116670)) healId = 116670; break;
case CLASS_EVOKER:
if (bot->HasSpell(361195)) healId = 361195; break;
default: break;
}
if (healId != 0)
{
Intent it{};
it.bot_id = botId;
it.body = CastSpellIntent{healId, owner->GetGUID()};
Services::Intents(botId).push(std::move(it));
}
}
// Loot trim + stop only when owner has no fight
{
static std::unordered_map<uint32, uint32> s_trim_last;
static std::unordered_map<uint32, uint32> s_incombat_since;
uint32 const tn = getMSTime();
if (tn - s_trim_last[botId] > 10000)
{
s_trim_last[botId] = tn;
Services::Registry().with_loot(botId,
[&](std::deque<ObjectGuid>& q) {
while (q.size() > 8)
q.pop_front();
});
}
if (bot->IsInCombat() && !ownerHasFight)
{
if (!bot->GetVictim())
{
bot->CombatStop(/*includingCast*/ false);
s_incombat_since[botId] = 0;
}
else
{
if (s_incombat_since[botId] == 0)
s_incombat_since[botId] = tn;
else if (tn - s_incombat_since[botId] > 3000)
{
Intent it{};
it.bot_id = botId;
it.body = StopAttackIntent{true};
Services::Intents(botId).push(std::move(it));
s_incombat_since[botId] = tn + 4000;
}
}
}
else
s_incombat_since[botId] = 0;
}
// Taxi
if (owner->IsInFlight() && !bot->IsInFlight() &&
bot->IsAlive() && !botFighting)
{
std::deque<uint32> const& path = owner->m_taxi.GetPath();
if (path.size() >= 2)
{
std::vector<uint32> nodes(path.begin(), path.end());
bot->SetTaxiCheater(true);
bot->ActivateTaxiPathTo(nodes);
}
}
// Formation / follow — never while fighting
if (botFighting || ownerHasFight || bot->IsNonMeleeSpellCast(false))
{
static std::unordered_map<uint32, uint32> s_combatDbg;
uint32 const now_dbg = getMSTime();
if (now_dbg - s_combatDbg[botId] >= 5000)
{
s_combatDbg[botId] = now_dbg;
TC_LOG_ERROR("playerbot.v2",
"[AltFollow] {} skip reason=combat bot_fight={} owner_fight={} casting={}",
bot->GetName(), botFighting, ownerHasFight,
bot->IsNonMeleeSpellCast(false));
}
continue;
}
constexpr float kFormDist = 2.0f;
constexpr float kSlotOkSq = 4.0f * 4.0f;
constexpr float kSlotCorrectSq = 14.0f * 14.0f;
constexpr uint32 kSnapCD = 1500;
float followDist = kFormDist;
float followAngle = 0.f;
FormationType ft = FormationType::Free;
uint8 ai_slot = 0;
if (BotAI* ai = Services::Registry().ai(botId))
{
ft = ai->effective_formation_type(false);
if (ft != FormationType::Free)
{
ai_slot = ai->formation_slot();
FormationOffset const off =
ComputeFormationOffset(ft, ai_slot, kFormDist);
followDist = off.distance;
followAngle = off.angle_radians;
}
}
float const face = owner->GetOrientation();
float const sx = owner->GetPositionX()
+ followDist * std::cos(face + followAngle + float(M_PI));
float const sy = owner->GetPositionY()
+ followDist * std::sin(face + followAngle + float(M_PI));
float const sz = owner->GetPositionZ();
float const dx = sx - bot->GetPositionX();
float const dy = sy - bot->GetPositionY();
float const dz = sz - bot->GetPositionZ();
float const slotDistSq = dx * dx + dy * dy + dz * dz;
if (ft != FormationType::Free && slotDistSq > kSlotCorrectSq)
{
static std::unordered_map<uint32, uint32> s_snap;
uint32 const n = getMSTime();
if (n - s_snap[botId] >= kSnapCD)
{
s_snap[botId] = n;
Intent s{};
s.bot_id = botId;
s.body = MoveToIntent{sx, sy, sz, true, false};
Services::Intents(botId).push(std::move(s));
}
}
else
{
float const ox = bot->GetPositionX() - owner->GetPositionX();
float const oy = bot->GetPositionY() - owner->GetPositionY();
float const oz = bot->GetPositionZ() - owner->GetPositionZ();
float const odSq = ox * ox + oy * oy + oz * oz;
float const rad = (ft == FormationType::Free)
? followDist
: std::max(followDist, 4.0f);
bool const inSlot =
(ft == FormationType::Free) || (slotDistSq <= kSlotOkSq);
if (!(inSlot && odSq < rad * rad))
{
Intent it{};
it.bot_id = botId;
it.body = FollowIntent{
owner->GetGUID(), followDist, followAngle};
Services::Intents(botId).push(std::move(it));
}
static std::unordered_map<uint32, uint32> s_followDbg;
uint32 const now_dbg = getMSTime();
if (now_dbg - s_followDbg[botId] >= 5000)
{
s_followDbg[botId] = now_dbg;
TC_LOG_ERROR("playerbot.v2",
"[AltFollow] {} decision={} ft={} slot={} slot_dist={:.1f} owner_dist={:.1f} rad={:.1f}",
bot->GetName(),
(ft == FormationType::Free) ? "free" : "formed",
int(ft), uint32(ai_slot),
std::sqrt(slotDistSq), std::sqrt(odSq), rad);
}
}
}
}
}
}
// Global AFK clear for ALL bot sessions (not just companion-owned).
// Without this, pool bots (auto-logged healers) get AFK-flagged after
// combat and appear with the AFK status until manually un-flagged.
{
static uint32 s_afk_clear_last = 0;
uint32 const afk_now = static_cast<uint32>(total.count());
if (afk_now - s_afk_clear_last > 5000)
{
s_afk_clear_last = afk_now;
for (auto const& [_, afk_sess] : ::World::instance()->GetAllSessions())
{
if (!afk_sess || !afk_sess->IsBot()) continue;
if (Player* bp = afk_sess->GetPlayer())
bp->RemovePlayerFlag(PLAYER_FLAGS_AFK);
}
}
}
const uint32 tick_start_ms = getMSTime();
// Per-phase latency capture so the FleetStatus log line surfaces which
// subsystem owns the world-thread cost. Without this we triage perf at
// scale by guesswork. Phases sum to ~tick latency; gap is unaccounted-for
// (TC core, scripts, etc).
uint32 t_snap_start_ms = 0, t_snap_ms = 0;
uint32 t_snap_setup_ms = 0, t_snap_build_ms = 0, t_group_build_ms = 0;
uint32 t_bgport_ms = 0, t_drain_ms = 0, t_session_ms = 0;
uint32 t_population_ms = 0, t_scheduler_ms = 0;
uint32 snaps_built_this_tick = 0;
uint32 snaps_skipped_this_tick = 0;
size_t intents_drained_this_tick = 0;
// Per ARCHITECTURE.md §1.1:
// 1. publish per-bot snapshots
// 2. (intent draining via PlayerbotAPI lands when API surface widens)
// 3. let TickScheduler decide which bots run this tick
// 1. Publish snapshots for every registered bot whose Player* is in-world.
t_snap_start_ms = getMSTime();
auto& reg = Services::Registry();
auto& pub = Services::Snapshots();
auto& sch = Services::Scheduler();
const TickId tick = next_tick++;
// Per-tick group snapshot dedup cache. GroupSnapshotBuilder::Build
// walks every group member (HP/mana/casting/auras) so for a 5-bot
// party each bot was paying 5× the work (and a 25-bot raid 25×).
// Cache the first build per group-guid per tick; subsequent bots in
// the same group reuse the same shared_ptr.
// Pre-dedup cost: N_groups × N_members² member-walks per tick.
// Post-dedup: N_groups × N_members member-walks per tick.
// Cache lifetime is the for_each lambda; reset between ticks. Single-
// threaded access (for_each runs synchronously on the world thread).
// Empty pointer sentinel marks "no group" so we don't keep re-calling
// GroupSnapshotBuilder for solo bots whose Build returns null.
std::unordered_map<ObjectGuid, std::shared_ptr<GroupSnapshot const>>
tick_group_cache;
// --- Per-tick real-player proximity/group precompute (Tier 2.1) ------
// The tier classifier below must answer "is this bot near, or grouped
// with, a REAL (non-bot) player?" without doing a per-bot grid search
// (that would re-introduce the build cost we're trying to shed). So we
// enumerate the connected real players ONCE here — there are only a
// handful even on a busy server — and stash:
// * real_player_cells : the coarse grid cell of every real player
// (see PackPlayerCellKey). A bot is "near" a
// real player when its own cell is occupied.
// * real_player_groups : the GUID of every group that contains at
// least one real player. A bot is "grouped
// with a real player" when its group_guid is
// in this set.
// Real players are enumerated from World::GetAllSessions() (the global
// human session map — bot sessions are flagged via the V2 lifecycle
// registry's is_bot()), same pattern as the human-present boot gate
// above. Runs on the world thread before the bot loop; both containers
// are read-only when consumed by the tier classifier.
//
// #5 Phase 4: these are plain function-local (NOT thread_local). The tier
// classifier that reads them runs in the POST-BARRIER world-thread pass
// (set_tier is world-thread-only anyway), so a single world-thread-visible
// copy is exactly what's needed. They were thread_local before parallel
// Build, which would have read EMPTY on worker threads — the audit's
// thread_local trap. Keeping them local also means zero cross-thread
// sharing during the parallel region.
std::unordered_set<uint64> real_player_cells;
std::unordered_set<ObjectGuid> real_player_groups;
{
const uint32 rp_t0 = getMSTime();
auto& lifecycle = Services::Lifecycle();
for (auto const& kv : ::World::instance()->GetAllSessions())
{
WorldSession* session = kv.second;
if (!session) continue;
Player* rp = session->GetPlayer();
if (!rp) continue;
if (lifecycle.is_bot(rp->GetGUID().GetCounter()))
continue; // a V2 bot, not a real human
const uint32 rp_map = rp->GetMapId();
const int32 rp_cx = PlayerCellCoord(rp->GetPositionX());
const int32 rp_cy = PlayerCellCoord(rp->GetPositionY());
for (int32 dx = -1; dx <= 1; ++dx)
for (int32 dy = -1; dy <= 1; ++dy)
real_player_cells.insert(
PackPlayerCellKey(rp_map, rp_cx + dx, rp_cy + dy));
if (Group const* rg = rp->GetGroup())
real_player_groups.insert(rg->GetGUID());
}
t_snap_setup_ms += getMSTimeDiff(rp_t0, getMSTime());
}
// ---------------------------------------------------------------------
// Team-level BG coordinator (BG audit N60) + group-level dungeon/raid
// coordinator. MUST run before the snapshot pass below so the orders
// they compute are copied into THIS tick's snapshots by the builder.
// Both are internally throttled (750ms / 500ms plan cadence); the
// off-cadence cost is one timestamp compare each.
{
const uint32 coord_t0 = getMSTime();
Services::BgCoordinator().Update(coord_t0);
Services::PveCoordinator().Update(coord_t0);
t_snap_setup_ms += getMSTimeDiff(coord_t0, getMSTime());
}
// ============================ #5 Phase 4 =============================
// PARALLEL-BY-Map* SNAPSHOT BUILD.
//
// Pipeline (see the audit for the per-surface rationale):
// (A) WORLD THREAD, per bot, in registry order:
// DriveTeleportAck (every tick), should_build_snapshot tier gate
// (mutates per-bot deadline — TickScheduler is world-thread-only),
// then the latency-tolerant rescue trio (RescueOrphanedBgBot /
// SnapToGroundIfDrifted / GlobalStuckRescue — all touch live core
// mutating state). Bots that pass the gate are collected, grouped
// by Map* POINTER, each pre-assigned a UNIQUE snapshot version.
// (B) PARALLEL: one task per Map* partition builds ONLY the per-bot
// BotSnapshots (pure per-bot CPU + read-only core reads). Each
// Map's bots build sequentially on a single worker, so a given bot
// / BotAI is touched by exactly one thread. Results are staged.
// (C) BARRIER join.
// (D) WORLD THREAD, in the SAME registry order as (A): tier classify +
// set_tier, group-snapshot dedup + GroupSnapshotBuilder::Build +
// publish_group, per-bot publish, perf. Everything the audit marks
// must-stay-world-thread lives here, after the barrier.
//
// Order preservation: (A) appends to build_order in registry order; (D)
// iterates build_order in that same order, so set_tier / publish happen in
// the identical sequence as the old serial loop → byte-identical behavior.
// The only data that crosses the barrier is each bot's own freshly-built
// BotSnapshot (single-owner) plus scalar timing.
struct BuiltSlot
{
BotId bot_id = 0;
Player* player = nullptr; // valid for this tick (world quiescent)
BotAI* ai = nullptr; // pre-resolved on world thread (#5 Phase 4)
std::shared_ptr<BotSnapshot const> snap; // null = Build returned {}
};
// Partition: Map* -> the slots whose bots live on that map. A vector of
// (Map*, slots) preserves a stable iteration; build_order records the
// global registry order for the post-barrier serial pass.
std::vector<BuiltSlot> build_order;
std::unordered_map<Map const*, std::vector<size_t>> by_map; // Map* -> indices into build_order
build_order.reserve(256);
{
const uint32 selectA_t0 = getMSTime();
reg.for_each([&](BotId id, BotRegistryEntry const& e)
{
Player* p = ObjectAccessor::FindConnectedPlayer(
ObjectGuid::Create<HighGuid::Player>(id));
// Drive any outstanding teleport ack BEFORE building the snapshot
// so position-based fields reflect the post-teleport location.
// This MUST run every tick: it advances ack/packet state for the
// teleport handshake and is latency-sensitive (a dropped/late ack
// wedges the bot in WORLD_STATE_LOGGEDIN), so it stays outside the
// tier-throttle gate below. World-thread (mutates packet state).
if (p)
DriveTeleportAck(p);
// Per-bot tier-driven snapshot cadence gate. Mutates the per-bot
// deadline inside TickScheduler (world-thread-only), so it runs
// here in the serial selection pass, never on a worker.
if (!sch.should_build_snapshot(id, total)) { ++snaps_skipped_this_tick; return; }
// Latency-tolerant per-bot setup. All three touch live core
// mutating state (teleport, ground snap, motion master) and stay
// on the world thread. Gated behind should_build_snapshot so they
// run at the bot's tier rate. (Subsumes PERF-P1b + PERF-P3b.)
if (p)
{
// Rescue ghost-BG bots BEFORE SnapToGroundIfDrifted — the
// BG map's BIH can crash that call. RescueOrphanedBgBot is
// a no-op for bots not in an orphaned BG map.
RescueOrphanedBgBot(p);
SnapToGroundIfDrifted(p);
// Resolve the bot's AI ONCE here on the world thread (registry
// quiescent under for_each's shared_lock) and thread it through
// the BuiltSlot. Build workers MUST NOT call Registry().ai()
// (unlocked map lookup races a concurrent rehash → garbage
// pointer → AV). Same pointer GlobalStuckRescue used before.
if (BotAI* ai = e.ai.get())
GlobalStuckRescue(p, ai);
}
// Bot is due to build this tick. Record it in registry order and
// bucket it by its Map* pointer for the parallel partition. A bot
// with no in-world Player still gets a slot so Build (which returns
// {} for null/!IsInWorld) is invoked uniformly and the post-barrier
// pass sees the same null result it would have serially.
const size_t slot = build_order.size();
build_order.push_back(BuiltSlot{ id, p, e.ai.get(), {} });
Map const* m = p ? p->GetMap() : nullptr;
by_map[m].push_back(slot);
});
t_snap_setup_ms += getMSTimeDiff(selectA_t0, getMSTime());
}
// Pre-assign a unique, monotonic version to each due bot BEFORE the
// parallel region so the build tasks never touch the shared counter.
// Contiguity is not required — only uniqueness + monotonicity — so a
// single fetch_add reserving the whole block is sufficient and race-free.
const SnapshotVer version_base =
next_version.fetch_add(build_order.size(), std::memory_order_relaxed);
// Decide serial vs. parallel for THIS tick. Read the kill-switch every
// tick so the operator can flip it live. The pool is created + started
// lazily on first parallel tick (a box that never enables pays nothing).
const bool want_parallel =
Services::Config().parallel_snapshot_build() && build_order.size() > 1 && by_map.size() > 1;
if (want_parallel && !snapshot_build_pool_)
{
snapshot_build_pool_ = std::make_unique<Playerbot::SnapshotBuildPool>(
Services::Config().snapshot_build_threads());
snapshot_build_pool_->start();
TC_LOG_INFO("playerbot.v2",
"[PlayerbotV2] #5 Phase 4 parallel snapshot Build ENABLED "
"(build workers={}, world thread participates).",
snapshot_build_pool_->worker_count());
}
// Per-task accumulated build time, summed into t_snap_build_ms after the
// barrier (the world-thread accumulators are NOT touched from workers).
std::atomic<uint32> par_build_ms_acc{0};
{
const uint32 build_phase_t0 = getMSTime();
// Build closure for one Map*'s slots [start,end). ONE task == one Map*
// == one worker thread for the whole batch. This partition-by-map rule is
// LOAD-BEARING for thread-safety, not just cache warmth:
// BotSnapshotBuilder::Build runs Detour pathfinding (route-feasibility
// probes → PathGenerator::CalculatePath → dtNavMeshQuery::findPath), and
// TrinityCore keeps a SINGLE dtNavMeshQuery per (mapId, instanceId) that
// is explicitly NOT thread-safe (MMapManager.h: "the returned
// dtNavMeshQuery const* is NOT threadsafe"; MMapManager.cpp: "single
// dtNavMeshQuery for every instance, since those are not thread safe").
// findPath mutates that query's node pool / open list, so two workers
// pathing for bots on the SAME map instance concurrently corrupts it and
// BuildPolyPath spins forever → world thread hangs in run_and_wait → 60s
// FreezeDetector abort (observed 2026-06-16 after a chunking experiment
// split one map across workers; reverted). Keeping all of a map's bots on
// one worker serializes that map's pathfinding, which is the invariant the
// shared query requires. (start/end kept as params for the serial path;
// the parallel path always passes the full range.)
auto build_chunk =
[&](std::vector<size_t> const& slots, size_t start, size_t end)
{
const uint32 part_t0 = getMSTime();
for (size_t i = start; i < end; ++i)
{
BuiltSlot& bs = build_order[slots[i]];
// Version is pre-assigned by slot index (unique + monotonic).
const SnapshotVer ver = version_base + static_cast<SnapshotVer>(slots[i]);
bs.snap = BotSnapshotBuilder::Build(bs.player, bs.ai, ver, tick);
}
par_build_ms_acc.fetch_add(
getMSTimeDiff(part_t0, getMSTime()), std::memory_order_relaxed);
};
if (want_parallel && snapshot_build_pool_)
{
// Fan out ONE task per Map* on that map's STABLE sticky lane
// (map_worker_slot_) so the builder's thread_local recycle/LoS pools
// stay warm via ping-pong reuse AND each map-instance's shared
// dtNavMeshQuery is only ever touched by one thread per batch. Do NOT
// split a single map across lanes — see the build_chunk note above
// (concurrent findPath on the shared per-instance query → freeze).
std::vector<Playerbot::SnapshotBuildPool::LaneTask> tasks;
tasks.reserve(by_map.size());
for (auto& [m, slots] : by_map)
{
if (slots.empty()) continue;
// Sticky lane for this Map*. New maps get the next round-robin
// slot; despawned maps leave a harmless stale key.
uint32 lane;
auto it = map_worker_slot_.find(static_cast<void const*>(m));
if (it != map_worker_slot_.end())
lane = it->second;
else
{
lane = next_worker_slot_++;
map_worker_slot_.emplace(static_cast<void const*>(m), lane);
}
// Capture a POINTER to the actual by_map element (which outlives
// run_and_wait), NOT a reference to the per-iteration `slots`
// binding — that would dangle once the loop iteration ends. by_map
// is not mutated during the batch, so &slots is stable for the run.
tasks.push_back({ lane,
[&build_chunk, sp = &slots]() {
build_chunk(*sp, 0, sp->size());
} });
}
snapshot_build_pool_->run_and_wait(tasks);
}
else
{
// Serial fallback (kill-switch off, or <=1 partition): build every
// partition inline on the world thread. Identical behavior + same
// thread_local pools (the world thread's) as the pre-Phase-4 path.
for (auto& [m, slots] : by_map)
build_chunk(slots, 0, slots.size());
}
t_snap_build_ms += par_build_ms_acc.load(std::memory_order_relaxed);
// The build-phase wall time minus accounted per-partition build time
// is fan-out/barrier overhead; fold it into setup so the phase sums
// still reconcile against total. (Cheap; diagnostics only.)
const uint32 build_phase_ms = getMSTimeDiff(build_phase_t0, getMSTime());
const uint32 acc = par_build_ms_acc.load(std::memory_order_relaxed);
if (build_phase_ms > acc)
t_snap_setup_ms += (build_phase_ms - acc);
}
// ---- (D) POST-BARRIER WORLD-THREAD PASS (registry order) ------------
// Tier classify + set_tier, group dedup/build + publish_group, per-bot
// publish, perf. Every item here is must-stay-world-thread per the audit.
{
const uint32 publish_t0 = getMSTime();
// Group-build sub-span is accumulated separately into t_group_build_ms
// below; track it here too so it can be subtracted out of the publish
// span when folding into t_snap_build_ms (keeps the TickPerf
// build/group sub-buckets non-overlapping, as in the serial path).
const uint32 group_ms_before = t_group_build_ms;
for (BuiltSlot& bs : build_order)
{
BotId id = bs.bot_id;
Player* p = bs.player;
auto& snap = bs.snap;
if (!snap)
continue; // Build returned {} (null/!IsInWorld) — nothing to publish.
++snaps_built_this_tick;
// Promote/demote the scheduler tier from the freshly built
// snapshot. The world thread is the single writer to the
// scheduler, so this is safe without synchronization.
//
// Tier 2.1 classifier. The OLD code pinned EVERY alive OOC bot to
// Active (150 ms), so the snapshot-build throttle was effectively
// dead (build_rate ~100%, snaps_skipped=0). The classifier below
// keeps every bot that needs prompt reactivity on a fast cadence,
// and lets the genuinely-AFK long tail fall to Idle → Parked:
//
// dead -> Idle (500 ms)
// in combat -> Combat (~100 ms)
// owner-controlled / grouped-with-real- -> Active (150 ms)
// player / near-real-player / (responsiveness-
// path-blocked / casting critical set)
// else, solo open-world, moving OR -> Cruise (300 ms)
// has-objective (dominant long-haul
// travel/quest pop;
// never ramps to Parked)
// alive, OOC, stationary, solo, no real -> Idle (500 ms)
// player nearby, no objective → ramps to Parked
// (Hibernate, 2 s)
// after N idle frames
// (handled in set_tier)
//
// Cruise (#5 b1) splits the old all-or-nothing Active gate: the
// OLD code pinned every (moving OR has_objective) bot to Active
// (150 ms), so a 230-bot questing fleet ran build_rate ~93%. Solo
// travellers now build at half rate (300 ms) while the latency-
// sensitive set above is untouched.
//
// Every input is an ALREADY-POPULATED snapshot field plus the
// O(1) per-tick real-player set lookups built above — no new
// per-bot grid search or builder cost. set_tier resets
// next_snapshot=now on any tier change, so a bot that re-enters
// combat / gets controlled / starts moving / has a real player
// walk up rebuilds on the very next frame (no reactivity loss).
ActivityTier tier;
if (!snap->vitals.is_alive)
{
tier = ActivityTier::Idle;
}
else if (snap->vitals.in_combat)
{
tier = ActivityTier::Combat;
}
else
{
// "Owner-controlled" proxy: the bot is bound to an owner
// (owner_name populated by the builder). Owned bots take
// squad/whisper commands and follow the owner, so they must
// stay responsive even while standing still.
const bool owner_controlled = !snap->owner_name.empty();
const bool grouped_with_real =
!snap->group.group_guid.IsEmpty() &&
real_player_groups.find(snap->group.group_guid) != real_player_groups.end();
const bool near_real_player =
real_player_cells.find(PackPlayerCellKey(
snap->position.map_id,
PlayerCellCoord(snap->position.x),
PlayerCellCoord(snap->position.y))) != real_player_cells.end();
const bool moving =
snap->movement.is_moving || snap->movement.is_swimming;
// ACTIVE INTENT — a bot that wants to act but is momentarily
// stationary must NOT be parked. is_moving alone mis-classified
// these as idle (2026-06-01 regression): a path-BLOCKED bot
// (stuck — its unstick recovery ladder must run at full cadence),
// a bot chasing an unreached quest OBJECTIVE, and a bot mid-CAST
// all have is_moving=false. Parking them starved the unstick
// counters (they accrue inside the tier-gated snapshot build) and
// froze them — the worker skips stale parked snapshots, so a
// half-finished move stalled and a cast never completed (the
// "kneel when idle" + "stuck casting" + worsened back-and-forth
// the owner observed). Only a truly objective-less, unblocked,
// non-casting idle AFK bot (the L80 city-filler tail) still falls
// through to Idle->Parked, preserving the build-throttle win.
const bool path_blocked = snap->path_telemetry.count > 0;
const bool has_objective = snap->quest_log.current_quest_id != 0;
const bool casting = snap->cast.is_casting;
// INSTANCE GROUP RUN — a bot doing coordinated 5-man/raid content
// must react at full cadence even while momentarily stationary
// (a tank holding between pulls, a DPS waiting on a cohesion gate).
// The tank-advance / cohesion rules read the SHARED GroupSnapshot,
// which is only re-published to THIS bot on the ticks it is
// processed. A parked dungeon tank demoted to Idle/Parked sees a
// FROZEN group view — a healer that already ran up still reads
// "healer_far 52y", a member whose combat tag already dropped still
// reads "in combat" — so the advance gate blocks, the tank stays
// parked, and the stale view never refreshes: a self-reinforcing
// wedge (live 2026-06-28: pure-bot Deadmines squad stuck at the
// entrance with all 5 stacked + out of combat while the gate read a
// phantom far-healer / in-combat). Mirrors grouped_with_real, for
// bot-only instance groups. Instances are bounded so the cost is
// negligible. Open-world groups are NOT included (is_in_instance).
const bool instance_group_run =
!snap->group.group_guid.IsEmpty() &&
snap->instance_ctx.is_in_instance;
// RESPONSIVENESS-CRITICAL set keeps the full 150 ms Active
// cadence:
// owner_controlled / grouped_with_real / near_real_player —
// human-facing: a real player is watching or depending on
// this bot, so it must react at full speed.
// path_blocked — the unstick recovery ladder accrues its
// counters inside the tier-gated snapshot build and must
// run at full cadence or the bot wedges (see the 2026-06-01
// regression note above).
// casting — a slower cadence can let a cast stall / never
// complete ("stuck casting" regression); keep it Active so
// casts finish.
// (in_combat already routed to Combat above; dead to Idle.)
const bool responsiveness_critical =
owner_controlled || grouped_with_real || near_real_player ||
path_blocked || casting || instance_group_run;
if (responsiveness_critical)
{
tier = ActivityTier::Active;
}
else if (moving || has_objective)
{
// CRUISE (300 ms) — the dominant long-haul population: a
// solo, non-human-facing bot that is merely travelling or
// questing in the open world. Its movement spline runs
// server-side; the AI only needs to issue the next waypoint
// a touch less often, so half-rate snapshot builds are
// tolerable. NOT path_blocked and NOT casting (those are
// Active above), so no unstick/cast starvation. Cruise never
// ramps to Parked (set_tier resets the idle streak), so the
// bot can't freeze mid-travel; a block / combat / nearby
// real player promotes it to Active/Combat next frame.
tier = ActivityTier::Cruise;
}
else
{
tier = ActivityTier::Idle; // set_tier ramps to Parked after N
}
}
sch.set_tier(id, tier, total);
// Build a fresh group snapshot when the bot is in a group; clear
// the slot otherwise so a former groupmate doesn't see stale data.
// Dedup: reuse a per-tick cached snapshot keyed by the group's
// GUID. Multi-bot groups (parties, raids, full BG raids) thereby
// pay the heavy GroupSnapshotBuilder::Build once per tick instead
// of once per bot. Solo / no-group bots get a null publish.
// World-thread-only (RULE 5): tick_group_cache is a shared
// cross-bot view and GroupSnapshotBuilder walks live group members.
const uint32 group_t0 = getMSTime();
if (Group const* g = p ? p->GetGroup() : nullptr)
{
ObjectGuid const ggid = g->GetGUID();
auto cache_it = tick_group_cache.find(ggid);
if (cache_it != tick_group_cache.end())
{
// Cache hit — reuse the shared_ptr. publish_group takes
// a const shared_ptr by value so the refcount bumps and
// both bots end up pointing at the same snapshot.
pub.publish_group(id, cache_it->second);
}
else
{
auto gsnap = GroupSnapshotBuilder::Build(p, snap->version);
if (gsnap)
{
tick_group_cache.emplace(ggid, gsnap);
pub.publish_group(id, gsnap);
}
else
{
pub.publish_group(id, {});
}
}
}
else
{
pub.publish_group(id, {});
}
t_group_build_ms += getMSTimeDiff(group_t0, getMSTime());
pub.publish(id, std::move(snap));
Services::Perf().record_snapshot_publish();
}
// Publish/classify time is world-thread post-barrier cost; fold into
// the build sub-bucket so the existing TickPerf line still attributes
// it to the snapshot phase. Subtract the group sub-span (already in
// t_group_build_ms) so build/group stay non-overlapping.
const uint32 publish_span = getMSTimeDiff(publish_t0, getMSTime());
const uint32 group_span = t_group_build_ms - group_ms_before;
t_snap_build_ms += (publish_span > group_span) ? (publish_span - group_span) : 0u;
}
t_snap_ms = getMSTimeDiff(t_snap_start_ms, getMSTime());
// 1.5. Stagger-fire any deferred BG ports + LFG proposal accepts whose
// delay has elapsed (see OnBGInvitationReceived /
// OnLfgProposalReceived). MUST run before DrainIntents so the
// freshly-pushed intents fire this same tick. Fleet-only: the
// BG/LFG queue machinery recruits population bots, so in
// alt-bot-only mode (PlayerbotsV2.FleetBots=0) the whole block
// is skipped.
if (Services::Config().fleet_bots())
{
const uint32 t0 = getMSTime();
FireDueBgPorts();
FireDueLfgAccepts();
TopUpPendingLfg(getMSTime());
t_bgport_ms = getMSTimeDiff(t0, getMSTime());
}
// 2. Drain pending intents and execute them via PlayerbotAPI on this
// (world) thread. The BotIntentExecutor file owns the variant visitor.
{
const uint32 t0 = getMSTime();
intents_drained_this_tick = DrainIntents();
t_drain_ms = getMSTimeDiff(t0, getMSTime());
}
// 2.5. Tick headless bot sessions so async login callbacks land and
// WorldSession query holders progress. Sessions are NOT in
// sWorld->m_sessions (would kick the GM's session sharing the
// account), so we drive their per-tick Update ourselves.
{
const uint32 t0 = getMSTime();
Services::SessionMgr().Update(static_cast<uint32>(diff.count()));
t_session_ms = getMSTimeDiff(t0, getMSTime());
}
// 2.6. Population shaper tick (Phase A of WORLD_POPULATION_PLAN).
// Internally rate-limits to its tick interval (default 60s) so
// this is cheap to call every world frame. Fleet-only — the
// shaper owns the ambient world population (JIT spawn / login /
// logout / BG+arena seeding), so it is fully disabled in
// alt-bot-only mode (PlayerbotsV2.FleetBots=0).
if (Services::Config().fleet_bots())
{
const uint32 t0 = getMSTime();
Services::Population().OnWorldTick(static_cast<uint32>(total.count()));
t_population_ms = getMSTimeDiff(t0, getMSTime());
}
// 2b. Bot guild manager tick (GUILD_PLAN.md Phase A.2). Internally
// 60s rate-limited; cheap to call every frame. Elects founders
// when guild slots are open + sweeps stale name reservations
// + aborts founders that exceeded the 30-min FSM budget. This is
// the bot-guild ecosystem — a fleet feature — so it is skipped in
// alt-bot-only mode.
if (Services::Config().fleet_bots())
Services::Guilds().Tick(getMSTime());
// 2c. Craft-order board tick (#4B-2). Ages out stale Claimed orders
// (timeout -> Fail + refund so escrow can't be trapped behind a stuck
// crafter) and prunes finished rows. Self-rate-limited cheaply via a
// local accumulator — the board only needs minute-granularity since
// the claim timeout is 30 min. Fleet bot-to-bot economy — skipped in
// alt-bot-only mode.
craft_board_total_ += diff;
if (craft_board_total_ - last_craft_board_at_ >= kCraftBoardIntervalMs)
{
last_craft_board_at_ = craft_board_total_;
if (Services::Initialized() && Services::Config().fleet_bots())
Services::CraftOrders().Tick(GameTime::GetGameTimeMS());
}
// 3. Schedule tick for due bots.
{
const uint32 t0 = getMSTime();
Services::Scheduler().on_world_tick(total);
t_scheduler_ms = getMSTimeDiff(t0, getMSTime());
}
// 3b. Runtime wedge watchdog (#1B). Slow cadence (kWedgeWatchdogIntervalMs)
// because it only reads cheap per-bot fields + the eventually-consistent
// snapshot. Walks the registry, classifies each in-world bot's wedge
// state, emits ONE structured [wedge] line per stuck episode, records
// the category in PerfCounters, and rebuilds the active-wedge list the
// `.playerbot wedges` digest reads. World-thread only — same lifecycle
// slot as GlobalStuckRescue + the fleet log.
wedge_wd_total_ += diff;
if (wedge_wd_total_ - last_wedge_wd_at_ >= kWedgeWatchdogIntervalMs)
{
last_wedge_wd_at_ = wedge_wd_total_;
wedge_watchdog_.Tick(GameTime::GetGameTimeMS());
}
// 3c. Group-level no-progress DIAGNOSE watchdog (nav-robustness program).
// READ-ONLY: classifies a wedged dungeon group (stranded / split /
// cohered-idle / false-combat) and emits one [group_wedge] line — the
// "diagnose" stage the remediation pass is built on. Self-throttled, so
// it's safe to call every world tick.
group_wedge_watchdog_.Update(GameTime::GetGameTimeMS());
// 4. Periodic fleet-status log (overnight friendly). Walks the registry +
// snapshots once per kFleetLogIntervalMs and emits one INFO line so the
// operator can see fleet health by tailing worldserver.log without
// needing a GM session. Cheap aggregation; capped to a single log
// line so it doesn't spam.
constexpr std::chrono::milliseconds kFleetLogIntervalMs{60 * 1000}; // 60s — gives readable TickPerf data without log spam
fleet_log_total_ += diff;
if (fleet_log_total_ - last_fleet_log_at_ >= kFleetLogIntervalMs)
{
last_fleet_log_at_ = fleet_log_total_;
uint32 in_world = 0, alive = 0, in_combat = 0, with_quest = 0, stuck = 0;
uint32 levels_total = 0, level_max = 0;
uint32 quests_total = 0;
const uint32 now_ms = GameTime::GetGameTimeMS();
Services::Registry().for_each([&](BotId id, BotRegistryEntry const& e)
{
if (!e.ai) return;
++in_world;
if (auto snap = Services::Snapshots().latest(id))
{
if (snap->vitals.is_alive) ++alive;
if (snap->vitals.in_combat) ++in_combat;
if (snap->identity.level > level_max) level_max = snap->identity.level;
levels_total += snap->identity.level;
if (snap->quest_log.current_quest_id != 0) ++with_quest;
quests_total += static_cast<uint32>(snap->quest_log.quests.size());
}
// Currently blacklisted = deadline in future. Bots whose timer
// has expired aren't actively stuck (they're allowed to retry).
const uint32 dead = e.ai->objective_track().blacklisted_until_ms;
if (dead != 0 && dead > now_ms) ++stuck;
});
const uint32 avg_level = in_world > 0 ? levels_total / in_world : 0;
TC_LOG_INFO("playerbot.v2",
"[PlayerbotV2] FleetStatus: in_world={} alive={} in_combat={} avg_lvl={} max_lvl={} "
"with_current_quest={} quests_in_log={} stuck_bots={} marked={}",
in_world, alive, in_combat, avg_level, level_max,
with_quest, quests_total, stuck, Services::Lifecycle().size());
// #1C fleet-vitals sample (rolling window + persistence + alerting).
// Runs on the SAME 60s cadence; reuses the census just aggregated so
// the registry isn't walked twice. avg_level passed x100 fixed-point.
const uint32 avg_level_x100 = in_world > 0
? static_cast<uint32>((uint64(levels_total) * 100ull) / in_world)
: 0u;
SampleFleetVitals(total, now_ms, in_world, alive, in_combat, avg_level_x100);
// Per-phase tick-cost averages + worst-case latency for the window.
// Sum-of-phases vs total reveals unaccounted-for cost (TC core,
// script callbacks, other modules). Aim: total avg < 100ms, max
// < 200ms at 2000-bot scale. Reset window after emit.
if (perf_ticks_in_window_ > 0)
{
const uint32 n = perf_ticks_in_window_;
const uint32 sum_phases =
perf_snap_ms_total_ + perf_bgport_ms_total_ +
perf_drain_ms_total_ + perf_session_ms_total_ +
perf_pop_ms_total_ + perf_sched_ms_total_;
TC_LOG_INFO("playerbot.v2",
"[PlayerbotV2] TickPerf (avg ms over {} ticks): "
"snap={:.2f} (setup={:.2f} build={:.2f} group={:.2f}) "
"bgport={:.2f} drain={:.2f} session={:.2f} "
"pop={:.2f} sched={:.2f} | phases_sum={:.2f} total={:.2f} "
"max={}ms",
n,
double(perf_snap_ms_total_) / n,
double(perf_snap_setup_ms_total_) / n,
double(perf_snap_build_ms_total_) / n,
double(perf_group_build_ms_total_) / n,
double(perf_bgport_ms_total_) / n,
double(perf_drain_ms_total_) / n,
double(perf_session_ms_total_) / n,
double(perf_pop_ms_total_) / n,
double(perf_sched_ms_total_) / n,
double(sum_phases) / n,
double(perf_total_ms_total_) / n,
perf_total_ms_max_);
// Throughput counters — exposes "how much work happened" vs the
// ms timings above which show "how long it took". A high
// snap_built rate at a low snap_build_ms means Builder is fast;
// a low snap_built rate at a high snap_build_ms means a few
// bots are dominating cost. Same for intents.
const uint32 snap_total = perf_snap_built_count_ + perf_snap_skipped_count_;
TC_LOG_INFO("playerbot.v2",
"[PlayerbotV2] TickPerf throughput: snaps_built={} snaps_skipped={} "
"(build_rate={:.1f}%) intents_drained={} ({:.1f}/tick)",
perf_snap_built_count_, perf_snap_skipped_count_,
snap_total > 0 ? 100.0 * double(perf_snap_built_count_) / double(snap_total) : 0.0,
perf_intents_drained_,
double(perf_intents_drained_) / double(n));
// BG advice cache hit rate. Sustained hit-rate < 90% means a
// script's snapshot consumption isn't in the cache key and
// advice is rebuilding too often. Total grows with BG-bot
// count × ticks, so high totals are expected at scale; the
// RATE is the signal.
auto perf_snap_diag = Playerbot::Services::Initialized()
? Playerbot::Services::Perf().snapshot()
: Playerbot::PerfCounters::Snapshot{};
uint64_t adv_hits = perf_snap_diag.bg_advice_cache_hits_total;
uint64_t adv_misses = perf_snap_diag.bg_advice_cache_misses_total;
uint64_t adv_total = adv_hits + adv_misses;
TC_LOG_INFO("playerbot.v2",
"[PlayerbotV2] BgAdviceCache: hits={} misses={} (hit_rate={:.1f}%)",
adv_hits, adv_misses,
adv_total > 0 ? 100.0 * double(adv_hits) / double(adv_total) : 0.0);
perf_ticks_in_window_ = 0;
perf_snap_ms_total_ = 0;
perf_snap_setup_ms_total_ = 0;
perf_snap_build_ms_total_ = 0;
perf_group_build_ms_total_ = 0;
perf_snap_built_count_ = 0;
perf_snap_skipped_count_ = 0;
perf_intents_drained_ = 0;
perf_bgport_ms_total_ = 0;
perf_drain_ms_total_ = 0;
perf_session_ms_total_ = 0;
perf_pop_ms_total_ = 0;
perf_sched_ms_total_ = 0;
perf_total_ms_total_ = 0;
perf_total_ms_max_ = 0;
}
}
// Record world-update wall-clock latency for diagnostics.
const uint32 elapsed = getMSTimeDiff(tick_start_ms, getMSTime());
Services::Perf().record_world_update_latency(std::chrono::milliseconds{elapsed});
// Accumulate per-phase costs for the next FleetStatus emit window.
++perf_ticks_in_window_;
perf_snap_ms_total_ += t_snap_ms;
perf_snap_setup_ms_total_ += t_snap_setup_ms;
perf_snap_build_ms_total_ += t_snap_build_ms;
perf_group_build_ms_total_ += t_group_build_ms;
perf_snap_built_count_ += snaps_built_this_tick;
perf_snap_skipped_count_ += snaps_skipped_this_tick;
perf_intents_drained_ += static_cast<uint32>(intents_drained_this_tick);
perf_bgport_ms_total_ += t_bgport_ms;
perf_drain_ms_total_ += t_drain_ms;
perf_session_ms_total_ += t_session_ms;
perf_pop_ms_total_ += t_population_ms;
perf_sched_ms_total_ += t_scheduler_ms;
perf_total_ms_total_ += elapsed;
if (elapsed > perf_total_ms_max_) perf_total_ms_max_ = elapsed;
}
V2::Module::TickPerfSnapshot V2::Module::tickperf_snapshot() const
{
TickPerfSnapshot s{};
s.ticks_in_window = perf_ticks_in_window_;
s.snap_ms_total = perf_snap_ms_total_;
s.snap_setup_ms_total = perf_snap_setup_ms_total_;
s.snap_build_ms_total = perf_snap_build_ms_total_;
s.group_build_ms_total = perf_group_build_ms_total_;
s.snap_built_count = perf_snap_built_count_;
s.snap_skipped_count = perf_snap_skipped_count_;
s.intents_drained = perf_intents_drained_;
s.bgport_ms_total = perf_bgport_ms_total_;
s.drain_ms_total = perf_drain_ms_total_;
s.session_ms_total = perf_session_ms_total_;
s.pop_ms_total = perf_pop_ms_total_;
s.sched_ms_total = perf_sched_ms_total_;
s.total_ms_total = perf_total_ms_total_;
s.total_ms_max = perf_total_ms_max_;
return s;
}
// #1C fleet-vitals sampler. World-thread, 60s cadence (driven from the
// FleetStatus block). Builds one VitalsBucket from the supplied census + the
// live PerfCounters / wedge-watchdog state, pushes it into the in-memory
// rolling window, async-persists one row (survives restart -> real trend), and
// evaluates the config-driven alert thresholds with per-metric throttling.
void Module::SampleFleetVitals(std::chrono::milliseconds total, uint32 now_ms,
uint32 in_world, uint32 alive, uint32 in_combat,
uint32 avg_level_x100)
{
if (!Services::Initialized())
return;
PerfCounters& perf = Services::Perf();
PerfCounters::Snapshot const ps = perf.snapshot();
// ---- Derive per-window RATES from cumulative-counter deltas. ----
// Path-fail = NoPath + FarFromPolyStart + FarFromPolyEnd (PathOutcome
// indices 1/2/3; the array is fixed at 6 — guarded below).
uint64 path_fail_total = 0;
if (ps.path_outcomes.size() >= 4)
path_fail_total = ps.path_outcomes[1] + ps.path_outcomes[2] + ps.path_outcomes[3];
const uint64 intents_exec_total = ps.intents_executed_total;
const uint64 intents_drop_total = ps.intents_dropped_total;
// Elapsed wall-clock since the previous sample (ms). First sample has no
// baseline -> rates emit 0 and we just seed the cumulative anchors.
const uint64 elapsed_ms = vitals_sample_ready_
? static_cast<uint64>((total - last_vitals_sample_at_).count())
: 0ull;
uint32 path_fail_per_min = 0;
uint32 intents_per_sec = 0;
uint32 intents_dropped = 0; // delta over the window
uint32 intents_executed = 0; // delta over the window
if (vitals_sample_ready_ && elapsed_ms > 0)
{
const uint64 d_pathfail = path_fail_total >= last_path_fail_total_
? path_fail_total - last_path_fail_total_ : 0;
const uint64 d_exec = intents_exec_total >= last_intents_exec_total_
? intents_exec_total - last_intents_exec_total_ : 0;
const uint64 d_drop = intents_drop_total >= last_intents_drop_total_
? intents_drop_total - last_intents_drop_total_ : 0;
path_fail_per_min = static_cast<uint32>((d_pathfail * 60000ull) / elapsed_ms);
intents_per_sec = static_cast<uint32>((d_exec * 1000ull) / elapsed_ms);
intents_dropped = static_cast<uint32>(d_drop);
intents_executed = static_cast<uint32>(d_exec);
}
// Roll the cumulative anchors forward for the next sample.
last_path_fail_total_ = path_fail_total;
last_intents_exec_total_ = intents_exec_total;
last_intents_drop_total_ = intents_drop_total;
last_vitals_sample_at_ = total;
vitals_sample_ready_ = true;
// ---- Wedge census from the watchdog's most-recent active list. ----
PerfCounters::VitalsBucket b;
b.sample_at_ms = now_ms;
b.in_world = in_world;
b.alive = alive;
b.in_combat = in_combat;
{
auto const& active = wedge_watchdog_.active();
b.wedged = static_cast<uint32>(active.size());
for (auto const& wi : active)
{
const size_t ci = static_cast<size_t>(wi.cat);
if (ci < b.wedged_by_category.size())
++b.wedged_by_category[ci];
}
}
b.path_fail_per_min = path_fail_per_min;
b.intents_per_sec = intents_per_sec;
b.intents_dropped = intents_dropped;
b.intents_executed = intents_executed;
b.tick_p50_us = perf.tick_latency_percentile_us(0.50);
b.tick_p99_us = perf.tick_latency_percentile_us(0.99);
b.avg_level_x100 = avg_level_x100;
// ---- Push into the in-memory rolling window. ----
perf.push_vitals_bucket(b);
// ---- Persist one row (async; survives restart -> real trend). ----
// CharacterDatabase hosts the playerbot_v2_* schema (see
// PlayerbotMigrationMgr::Db()), so the table is referenced unqualified.
// Execute() is non-blocking; ordering doesn't matter for an append-only
// sample table. avg_level written as a float (x100 -> /100.0).
CharacterDatabase.Execute(fmt::format(
"INSERT INTO playerbot_v2_fleet_vitals_sample "
"(in_world, alive, in_combat, wedged, "
" wedged_navmesh, wedged_offmesh, wedged_travel, "
" wedged_combatloop, wedged_pickernone, wedged_goalunreach, "
" tick_p50_us, tick_p99_us, intents_per_sec, intents_dropped, "
" path_fail_per_min, avg_level) "
"VALUES ({}, {}, {}, {}, {}, {}, {}, {}, {}, {}, {}, {}, {}, {}, {}, {:.2f})",
b.in_world, b.alive, b.in_combat, b.wedged,
b.wedged_by_category[static_cast<size_t>(Diagnostics::WedgeCategory::Navmesh)],
b.wedged_by_category[static_cast<size_t>(Diagnostics::WedgeCategory::OffMesh)],
b.wedged_by_category[static_cast<size_t>(Diagnostics::WedgeCategory::Travel)],
b.wedged_by_category[static_cast<size_t>(Diagnostics::WedgeCategory::CombatLoop)],
b.wedged_by_category[static_cast<size_t>(Diagnostics::WedgeCategory::PickerNone)],
b.wedged_by_category[static_cast<size_t>(Diagnostics::WedgeCategory::GoalUnreachable)],
b.tick_p50_us, b.tick_p99_us, b.intents_per_sec, b.intents_dropped,
b.path_fail_per_min, double(b.avg_level_x100) / 100.0).c_str());
// ---- Alerting: throttled [fleet_alert] on threshold breach. ----
// A threshold of 0 disables that metric. The first sample (no rate
// baseline) still alerts on the gauge metrics (wedged / tick p99) but the
// rate metrics are 0 so they can't false-fire.
ConfigReader const& cfg = Services::Config();
const uint32 throttle_ms = cfg.alert_throttle_ms();
auto fire = [&](uint32& last_ms, uint32 thresh, uint32 value,
char const* metric, char const* unit)
{
if (thresh == 0 || value <= thresh)
return;
// Per-metric throttle: at most one line per throttle window.
if (last_ms != 0 && throttle_ms != 0 && (now_ms - last_ms) < throttle_ms)
return;
last_ms = now_ms;
TC_LOG_ERROR("playerbot.v2",
"[fleet_alert] {} breached: value={}{} threshold={}{} "
"(in_world={} wedged={} tick_p99={}us intents_dropped/win={} path_fail/min={})",
metric, value, unit, thresh, unit,
b.in_world, b.wedged, b.tick_p99_us, b.intents_dropped, b.path_fail_per_min);
};
fire(last_alert_wedged_ms_, cfg.alert_wedged_bots(), b.wedged, "wedged_bots", "");
fire(last_alert_p99_ms_, cfg.alert_tick_p99_us(), b.tick_p99_us, "tick_p99_us", "us");
fire(last_alert_drop_ms_, cfg.alert_intent_drop_per_min(),b.intents_dropped, "intent_drop/win", "");
fire(last_alert_pathfail_ms_, cfg.alert_path_fail_per_min(), b.path_fail_per_min, "path_fail/min", "");
}
// Hook handler bodies — concrete for login/logout so a connected real player
// (or eventually a bot character) registers with the bot registry.
//
// Note: these handlers fire for ALL Player logins, not just bots. For now,
// every connected player gets a registry entry — once Fleet::BotLifecycleManager
// lands, registration is gated to characters flagged as bots.
// ---- Hook handler stubs ---------------------------------------------------
// Every hook is wired but inert until the corresponding subsystem lands.
// Each one will dispatch to a Fleet or Bot-layer component per FEATURE_MATRIX.md.
void Module::OnPlayerLogin(Player* p)
{
if (!initialized_ || !p) return;
const BotId id = p->GetGUID().GetCounter();
// Real players are not driven by V2 but DO get owner-login greetings
// from any of their currently-online bots. Bots queue a one-shot
// WhisperIntent (executed on the next world-thread drain) so real
// players see "Welcome back, <player>!" from their squad on relog —
// small touch that makes the owner→bot relationship feel alive.
if (!Services::Lifecycle().is_bot(id))
{
if (WorldSession const* sess = p->GetSession();
sess && !sess->IsBot())
{
const uint32 owner_account = sess->GetAccountId();
auto owned = Services::Altbots().AltsOfAccount(owner_account);
std::string const& owner_name = p->GetName();
uint8 formationIdx = 0;
for (BotId bot_id : owned)
{
if (!Services::Registry().has(bot_id)) continue; // not online
Player* obot = ObjectAccessor::FindConnectedPlayer(
ObjectGuid::Create<HighGuid::Player>(bot_id));
if (!obot) continue;
// Assign formation slots so bots spread out when following
// instead of stacking on each other. Slot order matches the
// order bots were created. Defaults to Line (behind leader
// with lateral fan).
if (BotAI* ai = Services::Registry().ai(bot_id))
{
if (ai->formation_type() == FormationType::Free)
ai->set_formation_type(FormationType::Line);
ai->set_formation_slot(formationIdx++);
}
// Queue via the bot's intent queue — runs on the next
// world-tick drain (so we don't whisper inside the login
// path while the new player's session is still mid-init).
if (Services::HasIntents(bot_id))
{
Intent it{};
it.bot_id = bot_id;
it.body = ChatIntent{WhisperIntent{
owner_name,
std::string("Welcome back!")}};
Services::Intents(bot_id).push(std::move(it));
}
}
}
// Restart resilience: a server restart logs every bot out, and the
// world-population pipeline respawns the fleet in arbitrary order —
// a human's dungeon group could sit half-empty for many minutes
// (2026-06-11: user back in Deadmines, tank + leader offline, run
// dead). Group membership persists in group_member, so when the
// human logs back in, spawn-login every offline bot member of their
// group immediately. LoginBot is idempotent (refuses while a login
// is in flight), so racing the population pipeline is harmless.
if (Group const* g = p->GetGroup())
{
for (Group::MemberSlot const& slot : g->GetMemberSlots())
{
const BotId member_id = slot.guid.GetCounter();
if (member_id == id) continue;
if (!Services::Lifecycle().is_bot(member_id)) continue;
if (ObjectAccessor::FindConnectedPlayer(slot.guid)) continue;
auto res = Services::SessionMgr().LoginBot(slot.guid);
TC_LOG_INFO("playerbot.v2",
"[GroupRelogin] {} logged in with offline bot groupmate {} — spawn-login {}",
p->GetName(), slot.name, res.ok ? "submitted" : res.reason);
}
}
return;
}
if (Services::Registry().has(id)) return;
// Real clients drive Player::CanNeverSee past its first guard by sending
// CMSG_MOVE_INIT_ACTIVE_MOVER_COMPLETE, which sets PLAYER_LOCAL_FLAG_OVERRIDE_TRANSPORT_SERVER_TIME.
// Without it, Player::CanNeverSee always returns true, CanSeeOrDetect
// always returns false, and every offensive spell on a creature fails
// SPELL_FAILED_BAD_TARGETS — IsValidAttackTarget can't see the target.
// Bots have no client to send that opcode, so set the flag here once the
// bot is fully in-world. (CharacterHandler.cpp:1176 is the equivalent set
// for real players, gated on the time-sync queue which never populates
// for sessionless bots.)
p->SetPlayerLocalFlag(PLAYER_LOCAL_FLAG_OVERRIDE_TRANSPORT_SERVER_TIME);
// Cross-map travel on-ramp (2026-06-16): ensure the bot KNOWS its faction's
// flight paths. The travel graph prunes every taxi edge to a flight master
// NOT in the bot's taximask (UnifiedTravelGraph EdgeUsable → IsTaximaskNodeKnown),
// so a bot with an empty mask sees an EDGELESS flight-master subgraph and
// FindRoute returns 0 legs — the ~87% "both ends attached, legs=0" route
// failures that blocked all cross-continent travel/relocation. The setup
// pipeline only learned at SETUP and skipped sub-10 bots, so every
// already-distributed bot had an empty mask. LearnAllFactionFlightpaths is
// idempotent (SetTaximaskNode only flips unset bits) + cheap (~600 nodes), and
// the mask persists in the character taximask column, so a one-time learn on
// login repairs the whole fleet. (Sub-10 bots stay same-map via the relocation
// band gate regardless, so a full mask doesn't send them cross-continent.)
if (uint32 fp = ::Playerbot::V2::World::LearnAllFactionFlightpaths(p))
TC_LOG_INFO("playerbot.v2",
"[FlightLearn] {} learned {} flight paths on login (taxi network now routable)",
p->GetName(), fp);
// CRITICAL: Initialize phasing data for the bot.
// 2026-05-20 — confirmed via [neutral_scan_miss] phases=0 diagnostic:
// 77% of stuck quest-kill bots had EMPTY PhaseShift containers.
// SendInitialPacketsAfterAddToMap (Player.cpp:25224) is supposed to
// run PhasingHandler::OnMapChange during HandlePlayerLogin, but for
// headless bot sessions some pre-condition (m_seer? grid registration?
// session client-time-sync?) doesn't satisfy and the PhaseShift never
// populates. Result: bot is in NO phase → quest creatures in
// conditional phases (post-Shattering Cataclysm+, MoP+, BfA daily
// hubs, etc.) are invisible → 80y scan finds 0 matching entries →
// bot wanders forever. V1 had the same issue (BotWorldEntry.cpp:881);
// explicit re-init here matches that fix. Both calls are idempotent
// — if HandlePlayerLogin DID populate phases, this is a no-op.
p->UpdateZone(p->GetZoneId(), p->GetAreaId());
PhasingHandler::OnMapChange(p);
// Quest-log hygiene at login. TC core Player::PushQuests() re-pushes
// auto-granted feature quests (55660 "Time Trials", 84224 "To Delves!",
// …) at EVERY login — a bot can never turn them in, and abandoning them
// doesn't stick (CanTakeQuest passes for a non-rewarded quest, so the next
// login re-pushes). Force-complete them to REWARDED now (SatisfyQuestStatus
// then rejects them, ending the re-push), and resolve profession-spec
// choice quests. Doing it at login — the exact moment the junk is
// (re-)pushed — paces 1:1 with the re-push, which the idle-rule path alone
// could not keep up with (2026-06-15: online holders grew faster than the
// throttled rule cleared them). Bounded drain; one resolvable quest/call.
for (int guard = 0; guard < 40; ++guard)
if (V2::Fleet::JunkQuestResolver::RunFor(p).done)
break;
BotPersonality personality = Services::Config().random_personality()
? RandomPersonality(SeedForBot(id))
: DefaultPersonality();
Services::Registry().register_bot(id, personality, BotRng{SeedForBot(id)});
Services::Scheduler().register_bot(id, ActivityTier::Idle);
// Re-apply persisted squad state (formation type/slot, follow
// distance, verbose flag) so a relog comes back with the same
// owner-tunable preferences that were active before logout.
if (BotAI* ai = Services::Registry().ai(id))
{
// Mark owned bots so their AI knows to always follow the owner.
OwnerBinding const owner = Services::Owners().GetOwner(id);
ai->set_owned(owner.account_id != 0);
// Determine bot role: Altbot if in altbot table, otherwise Fleet.
bool const isAlt = Services::Altbots().IsAltbot(id);
ai->set_role(isAlt ? BotRole::Altbot : BotRole::Fleet);
OwnerRegistry::SquadState const s = Services::Owners().LoadSquadState(id);
ai->set_formation_type(static_cast<FormationType>(s.formation_type));
ai->set_formation_slot(s.formation_slot);
ai->set_follow_distance(s.follow_distance);
ai->set_verbose_logging(s.owner_verbose);
// R7: hydrate the sticky leveling-zone relocation target (0010 columns)
// so a bot resumes traveling toward the SAME hub it picked before the
// restart instead of re-picking (distance-ranked → would flip-flop). A
// one-shot read; set_leveling_target marks the target loaded so the
// builder won't clobber a persisted choice. hub_id 0 = never picked.
if (auto res = CharacterDatabase.PQuery(
"SELECT leveling_target_hub, leveling_target_map, leveling_bracket_lo, "
"leveling_bracket_hi, UNIX_TIMESTAMP(leveling_chosen_at) "
"FROM playerbot_v2_character WHERE character_guid_low={}", id))
{
Field* f = res->Fetch();
BotAI::LevelingZoneTarget t;
t.hub_id = f[0].GetUInt32();
t.map_id = f[1].GetUInt32();
t.bracket_lo = f[2].GetUInt8();
t.bracket_hi = f[3].GetUInt8();
t.chosen_at = f[4].IsNull() ? 0 : f[4].GetUInt64();
ai->set_leveling_target(t);
}
else
ai->mark_leveling_target_loaded();
// ---- #4A: per-bot ARCHETYPE (WHAT/WHEN the bot plays) ----
// Gate behind PlayerbotV2.Archetype.Enabled. When disabled every bot
// reads as the default CasualSolo (id 0) so behavior is uniform for
// controlled testing.
if (Services::Config().archetype_enabled())
{
// Deterministic roll from the per-bot seed (same seed used for
// personality + rng) so a bot's archetype is stable across
// restarts. We read the persisted archetype_id to decide whether
// a write-back is needed: on first spawn (or after a DB wipe) the
// column is still the schema default and differs from the roll, so
// we persist it once; thereafter the read matches and we skip the
// UPDATE. RollArchetype is idempotent, so even a stale persisted
// value converges to the deterministic roll without churn.
const BotArchetype rolled = RollArchetype(SeedForBot(id));
ai->set_archetype(rolled);
bool need_write = true;
if (auto ares = CharacterDatabase.PQuery(
"SELECT archetype_id FROM playerbot_v2_character "
"WHERE character_guid_low={}", id))
{
Field* af = ares->Fetch();
if (af[0].GetUInt8() == rolled.archetype_id)
need_write = false;
}
if (need_write)
CharacterDatabase.PExecute(
"UPDATE playerbot_v2_character SET archetype_id={} "
"WHERE character_guid_low={}",
uint32(rolled.archetype_id), id);
}
}
// INFO-level so the operator can see which bots actually attached AI
// when bringing up a fresh worldserver / spawning via .playerbot login.
// The DEBUG history can be inspected via /history or /inspect later.
TC_LOG_INFO("playerbot.v2", "[PlayerbotV2] Bot AI attached: {} (id {}, class {} race {} level {})",
p->GetName(), id, uint32(p->GetClass()), uint32(p->GetRace()), uint32(p->GetLevel()));
}
void Module::OnPlayerLogout(Player* p)
{
if (!initialized_ || !p) return;
const BotId id = p->GetGUID().GetCounter();
// Companion bot auto-logout: when a human owner logs out, kick all
// online ALTBOTS BEFORE the owner's data is invalidated so
// the companion maintenance loop doesn't access a dangling Player*.
if (!Services::Lifecycle().is_bot(id))
{
if (WorldSession const* sess = p->GetSession(); sess && !sess->IsBot())
{
uint32 const owner_account = sess->GetAccountId();
auto alts = Services::Altbots().AltsOfAccount(owner_account);
for (BotId bot_id : alts)
{
ObjectGuid const botGuid = ObjectGuid::Create<HighGuid::Player>(bot_id);
// LogoutBot handles pet dismissal and synchronous logout.
Services::SessionMgr().LogoutBot(botGuid);
}
}
return; // not a bot — nothing else to clean up
}
if (!Services::Registry().has(id)) return; // not one of ours
Services::Scheduler().unregister_bot(id);
Services::Snapshots().remove(id);
Services::Registry().unregister_bot(id);
// Clean per-bot entries in file-scope maps. These keys are uint64
// player guid_low; without erase, entries accumulate forever on
// populations that rotate bots in/out (BotPopulationManager
// spawning fresh alts as old ones level up). At ~24 bytes/entry
// the leak is slow but real for long-running servers — and the
// entries are dead data (guid_low is unique per character).
g_last_snap_z_ms.erase(id);
g_global_stuck.erase(id);
g_lastLevelSyncMs.erase(id);
TC_LOG_INFO("playerbot.v2", "[PlayerbotV2] Bot AI detached: {} (id {})", p->GetName(), id);
}
void Module::OnLevelUp(Player* /*p*/, uint8 /*new_level*/)
{
if (!initialized_) return;
// Intentionally a no-op. Snapshot's `level` field updates next tick;
// newly-trained spells appear in `known_spells` as the bot learns them.
// Rules already gate on `knows_spell` so unlocks light up automatically.
}
void Module::OnDeath(Unit* victim, Unit* /*killer*/)
{
if (!initialized_ || !victim) return;
// When a creature dies, queue its corpse on the pending-loot list of
// every registered bot in its tap list. State_Idle walks the bot toward
// the closest entry next tick and emits LootIntent once in range. We
// queue here (rather than emit Intent immediately) because the bot is
// typically still in combat with adds — looting now would interrupt
// their rotation. The queue persists until State_Idle drains it.
Creature* c = victim->ToCreature();
if (!c || !c->hasLootRecipient()) return;
auto const& tap = c->GetTapList();
if (tap.empty()) return;
auto& reg = Services::Registry();
const ObjectGuid corpse_guid = c->GetGUID();
for (ObjectGuid const& guid : tap)
{
if (!guid.IsPlayer()) continue;
const BotId id = guid.GetCounter();
if (!reg.has(id)) continue;
// Owned bots: skip auto-queue. The owner dictates looting by
// clicking a corpse — that fires the OnLootUnit hook which
// pushes the specific corpse to ALL owned bots.
if (Services::Owners().GetOwner(id).account_id != 0)
continue;
// Queue the corpse onto the bot's pending-loot list. State_Idle
// walks the bot to the closest entry and emits a LootIntent each
// tick; the entry is popped on success. The push helper is mutex-
// protected since the AI worker drains concurrently.
reg.push_loot(id, corpse_guid);
}
}
// Returns false only if a required item could not be granted.
// Does NOT mean "can complete quest".
static bool EnsureBotQuestItems(Player* bot, Quest const* quest)
{
if (!bot || !quest)
return true;
for (QuestObjective const& obj : quest->GetObjectives())
{
if (obj.Type != QUEST_OBJECTIVE_ITEM)
continue;
if (obj.Amount <= 0)
continue;
if (obj.Flags & QUEST_OBJECTIVE_FLAG_OPTIONAL)
continue;
uint32 const itemId = uint32(obj.ObjectID);
uint32 const need = uint32(obj.Amount);
uint32 const have = bot->GetItemCount(itemId, /*inBank*/ false);
if (have >= need)
continue;
uint32 const give = need - have;
ItemPosCountVec dest;
uint32 noSpace = 0;
if (bot->CanStoreNewItem(NULL_BAG, NULL_SLOT, dest, itemId, give, &noSpace) != EQUIP_ERR_OK)
{
TC_LOG_ERROR("playerbot.v2",
"[EnsureBotQuestItems] {} CanStoreNewItem fail item={} give={} noSpace={} — trying AddItem directly",
bot->GetName(), itemId, give, noSpace);
}
if (!bot->AddItem(itemId, give))
{
uint32 freeBackpack = 0;
for (uint8 s = INVENTORY_SLOT_ITEM_START; s < INVENTORY_SLOT_ITEM_END; ++s)
if (!bot->GetItemByPos(INVENTORY_SLOT_BAG_0, s))
++freeBackpack;
TC_LOG_ERROR("playerbot.v2",
"[EnsureBotQuestItems] {} FAIL item={} need={} have={} freeSlots={}",
bot->GetName(), itemId, need, have, freeBackpack);
return false;
}
TC_LOG_ERROR("playerbot.v2",
"[EnsureBotQuestItems] {} +{}x item={} quest={} ({}/{})",
bot->GetName(), give, itemId, quest->GetQuestId(), have + give, need);
}
return true;
}
static void ForceBotQuestComplete(Player* bot, Quest const* quest)
{
if (!bot || !quest)
return;
uint32 const questId = quest->GetQuestId();
// Fill objective counters so CanCompleteQuest / CompleteQuest succeed
for (QuestObjective const& obj : quest->GetObjectives())
{
if (obj.Amount <= 0)
continue;
if (obj.Flags & QUEST_OBJECTIVE_FLAG_OPTIONAL)
continue;
// API names vary slightly by TC revision — adjust if compile fails:
// SetQuestObjectiveData / UpdateQuestObjective / SetObjectiveProgress
int32 current = bot->GetQuestObjectiveData(obj);
if (current < obj.Amount)
bot->SetQuestObjectiveData(obj, obj.Amount);
}
if (bot->GetQuestStatus(questId) == QUEST_STATUS_INCOMPLETE)
{
if (bot->CanCompleteQuest(questId))
bot->CompleteQuest(questId);
else
{
// Last resort for companion push after owner turn-in
bot->CompleteQuest(questId);
if (bot->GetQuestStatus(questId) != QUEST_STATUS_COMPLETE)
bot->SetQuestStatus(questId, QUEST_STATUS_COMPLETE);
}
}
TC_LOG_ERROR("playerbot.v2",
"[ForceBotQuestComplete] {} quest={} status={}",
bot->GetName(), questId, uint32(bot->GetQuestStatus(questId)));
}
// Centralised quest-item grant helper. Returns true if item was stored.
static bool GiveQuestItem(Player* bot, uint32 itemId, uint32 count)
{
if (!bot || !itemId || !count)
return false;
uint32 have = bot->GetItemCount(itemId, false);
uint32 need = 0;
for (uint8 qs = 0; qs < MAX_QUEST_LOG_SIZE; ++qs)
{
uint32 qid = bot->GetQuestSlotQuestId(qs);
if (!qid)
continue;
if (bot->GetQuestStatus(qid) != QUEST_STATUS_INCOMPLETE)
continue;
Quest const* q = sObjectMgr->GetQuestTemplate(qid);
if (!q)
continue;
for (QuestObjective const& obj : q->GetObjectives())
{
if (obj.Type != QUEST_OBJECTIVE_ITEM)
continue;
if (uint32(obj.ObjectID) != itemId)
continue;
if (obj.Amount <= 0)
continue;
if (obj.Flags & QUEST_OBJECTIVE_FLAG_OPTIONAL)
continue;
need = std::max(need, uint32(obj.Amount));
}
}
if (need == 0)
{
TC_LOG_ERROR("playerbot.v2",
"[QuestItemCopy] NONEED {} item={}", bot->GetName(), itemId);
return false;
}
if (have >= need)
{
TC_LOG_ERROR("playerbot.v2",
"[QuestItemCopy] FULL {} item={} have={}/{}",
bot->GetName(), itemId, have, need);
return false;
}
// GiveQuestItem uses count as-is — OnLootItem provides
// the actual number taken by the owner. OnLootUnit is not
// used for quest items (its li->count is unreliable).
uint32 give = std::min(count, need - have);
ItemPosCountVec dest;
uint32 noSpace = 0;
InventoryResult ir = bot->CanStoreNewItem(NULL_BAG, NULL_SLOT, dest, itemId, give, &noSpace);
if (ir != EQUIP_ERR_OK)
{
TC_LOG_ERROR("playerbot.v2",
"[QuestItemCopy] {} CanStoreNewItem fail item={} give={} ir={} noSpace={} — trying AddItem directly",
bot->GetName(), itemId, give, uint32(ir), noSpace);
}
if (!bot->AddItem(itemId, give))
{
uint32 freeBackpack = 0;
for (uint8 s = INVENTORY_SLOT_ITEM_START; s < INVENTORY_SLOT_ITEM_END; ++s)
if (!bot->GetItemByPos(INVENTORY_SLOT_BAG_0, s))
++freeBackpack;
TC_LOG_ERROR("playerbot.v2",
"[QuestItemCopy] {} FAIL item={} give={} have={}/{} freeSlots={} ir={} — inventory full or unplaceable",
bot->GetName(), itemId, give, have, need, freeBackpack, uint32(ir));
return false;
}
TC_LOG_ERROR("playerbot.v2",
"[QuestItemCopy] OK {} +{}x item={} ({}/{})",
bot->GetName(), give, itemId, have + give, need);
return true;
}
void Module::OnLootUnit(Player* player, ObjectGuid creature_guid)
{
if (!initialized_ || !player)
return;
WorldSession* sess = player->GetSession();
if (!sess || sess->IsBot())
return;
uint32 const account_id = sess->GetAccountId();
if (account_id == 0)
return;
auto bots = ::Playerbot::Services::Altbots().AltsOfAccount(account_id);
if (bots.empty())
return;
auto& reg = ::Playerbot::Services::Registry();
// QUEST-ITEM SCAN: copy needed quest items to online alts
{
Creature* c = ObjectAccessor::GetCreature(*player, creature_guid);
if (c && !c->IsAlive())
{
Loot* loot = c->GetLootForPlayer(player);
if (loot)
{
for (uint8 i = 0; i < loot->items.size(); ++i)
{
LootItem* li = loot->LootItemInSlot(i, player);
if (!li || li->is_blocked)
continue;
for (BotId id : bots)
{
Player* bot = ObjectAccessor::FindConnectedPlayer(
ObjectGuid::Create<HighGuid::Player>(id));
if (!bot || !bot->IsInWorld())
continue;
if (bot->GetMapId() != player->GetMapId())
continue;
GiveQuestItem(bot, li->itemid, li->count);
}
}
}
}
}
// AI corpse walk: only bots with AI registry
for (BotId id : bots)
{
if (!reg.has(id))
continue;
reg.push_loot(id, creature_guid);
}
}
void Module::OnGossipHello(Player* /*player*/, ObjectGuid /*npc_guid*/)
{
// Intentionally reduced: the primary quest-credit mimic now lives in
// OnTalkToCreature (hooks Player::TalkedToCreature). Gossip hooks
// are kept as stubs for future gossip-menu-specific automation.
}
void Module::OnGossipSelect(Player* /*player*/, ObjectGuid /*npc_guid*/, uint32 /*gossip_id*/, uint32 /*option_id*/)
{
// Same rationale as OnGossipHello — quest credit handled by
// OnTalkToCreature. Future: propagate gossip-menu-only actions here.
}
void Module::OnTalkToCreature(Player* player, uint32 entry, ObjectGuid guid)
{
if (!initialized_ || !player) return;
TC_LOG_ERROR("playerbot.v2", "[DIAG] OnTalkToCreature player={} entry={} guid={}", player->GetName(), entry, guid.ToString());
if (player->GetSession()->IsBot()) return;
WorldSession* sess = player->GetSession();
if (!sess) return;
uint32 const account_id = sess->GetAccountId();
if (account_id == 0) return;
auto bots = Services::Altbots().AltsOfAccount(account_id);
if (bots.empty()) return;
auto& reg = Services::Registry();
for (BotId id : bots)
{
if (!reg.has(id)) continue;
Player* bot = ObjectAccessor::FindConnectedPlayer(
ObjectGuid::Create<HighGuid::Player>(id));
if (!bot || !bot->IsInWorld()) continue;
if (bot->GetMapId() != player->GetMapId()) continue;
// Replicate the quest credit — same as if the bot had
// talked to the creature directly.
bot->TalkedToCreature(entry, guid);
}
}
void Module::OnKillCredit(Player* player, uint32 entry, ObjectGuid guid)
{
if (!initialized_ || !player)
return;
TC_LOG_ERROR("playerbot.v2", "[DIAG] OnKillCredit player={} entry={} guid={}", player->GetName(), entry, guid.ToString());
WorldSession* sess = player->GetSession();
if (!sess || sess->IsBot())
return;
uint32 const account_id = sess->GetAccountId();
if (account_id == 0)
return;
std::vector<BotId> bots = Services::Altbots().AltsOfAccount(account_id);
{
auto owned = Services::Owners().BotsOwnedBy(account_id);
for (BotId id : owned)
if (std::find(bots.begin(), bots.end(), id) == bots.end())
bots.push_back(id);
}
if (bots.empty())
return;
Group* og = player->GetGroup();
for (BotId id : bots)
{
Player* bot = ObjectAccessor::FindConnectedPlayer(
ObjectGuid::Create<HighGuid::Player>(id));
if (!bot || !bot->IsInWorld())
continue;
if (bot->GetMapId() != player->GetMapId())
continue;
// Block combat kills only when the owner is actively fighting.
// NPC interactions (click injured soldier) happen outside combat.
if (og && og->IsMember(bot->GetGUID()) && player->IsInCombat())
continue;
TC_LOG_ERROR("playerbot.v2",
"[OnKillCredit] push {} entry={} (not in owner group)",
bot->GetName(), entry);
bot->KilledMonsterCredit(entry, guid);
}
}
void Module::OnPlayerAttack(Player* player, Unit* victim)
{
if (!initialized_ || !player || !victim)
return;
WorldSession* sess = player->GetSession();
if (!sess || sess->IsBot())
return;
uint32 const account_id = sess->GetAccountId();
if (account_id == 0)
return;
if (!victim->IsAlive() || !player->IsValidAttackTarget(victim))
return;
auto bots = Services::Altbots().AltsOfAccount(account_id);
if (bots.empty())
return;
for (BotId id : bots)
{
Player* bot = ObjectAccessor::FindConnectedPlayer(
ObjectGuid::Create<HighGuid::Player>(id));
if (!bot)
continue;
AssistOwnerTarget(player, bot, victim);
}
}
void Module::OnAcceptQuest(Player* player, uint32 quest_id)
{
if (!initialized_ || !player)
return;
WorldSession* sess = player->GetSession();
if (!sess || sess->IsBot())
return;
Quest const* quest = sObjectMgr->GetQuestTemplate(quest_id);
if (!quest)
return;
uint32 const account_id = sess->GetAccountId();
std::vector<BotId> bots = Services::Altbots().AltsOfAccount(account_id);
{
auto owned = Services::Owners().BotsOwnedBy(account_id);
for (BotId id : owned)
if (std::find(bots.begin(), bots.end(), id) == bots.end())
bots.push_back(id);
}
TC_LOG_ERROR("playerbot.v2",
"[OnAcceptQuest] owner={} quest={} targets={}",
player->GetName(), quest_id, bots.size());
if (bots.empty())
return;
// Ensure owner has a group so party UI can show shared progress when ids match
if (!player->GetGroup())
{
Group* ng = new Group();
if (ng->Create(player))
{
sGroupMgr->AddGroup(ng);
ng->SendUpdate();
}
else
delete ng;
}
uint32 pushed = 0;
for (BotId id : bots)
{
Player* bot = ObjectAccessor::FindConnectedPlayer(
ObjectGuid::Create<HighGuid::Player>(id));
if (!bot || !bot->IsInWorld())
{
TC_LOG_ERROR("playerbot.v2",
"[OnAcceptQuest] skip guid={} (not in world)", id);
continue;
}
// Class/race variant of owner's quest (e.g. 28767 → 28762 for another class)
uint32 const botQuestId = ResolveBotQuestId(bot, quest_id);
if (!botQuestId)
{
TC_LOG_ERROR("playerbot.v2",
"[OnAcceptQuest] skip {} (no class-equivalent for quest {})",
bot->GetName(), quest_id);
continue;
}
Quest const* botQuest = sObjectMgr->GetQuestTemplate(botQuestId);
if (!botQuest)
continue;
QuestStatus const st = bot->GetQuestStatus(botQuestId);
if (st == QUEST_STATUS_INCOMPLETE ||
st == QUEST_STATUS_COMPLETE ||
st == QUEST_STATUS_REWARDED)
{
TC_LOG_ERROR("playerbot.v2",
"[OnAcceptQuest] {} already status={} on quest {}",
bot->GetName(), uint32(st), botQuestId);
continue;
}
/*
if (bot->GetLevel() < player->GetLevel())
{
bot->GiveLevel(player->GetLevel());
bot->InitTalentForLevel();
// do not SetXP(0)
}
*/
if (!bot->CanAddQuest(botQuest, false))
{
TC_LOG_ERROR("playerbot.v2",
"[OnAcceptQuest] skip {} quest {} (CanAddQuest)",
bot->GetName(), botQuestId);
continue;
}
if (!bot->SatisfyQuestClass(botQuest, false) ||
!bot->SatisfyQuestRace(botQuest, false))
{
TC_LOG_ERROR("playerbot.v2",
"[OnAcceptQuest] {} class/race soft-mismatch quest {} — pushing anyway",
bot->GetName(), botQuestId);
}
bot->AddQuestAndCheckCompletion(botQuest, nullptr);
if (botQuest->GetSrcSpell() > 0)
bot->CastSpell(bot, botQuest->GetSrcSpell(), true);
QuestStatus const after = bot->GetQuestStatus(botQuestId);
TC_LOG_ERROR("playerbot.v2",
"[OnAcceptQuest] {} ownerQ={} botQ={} status_after={}",
bot->GetName(), quest_id, botQuestId, uint32(after));
if (after == QUEST_STATUS_INCOMPLETE || after == QUEST_STATUS_COMPLETE)
++pushed;
// Keep bot in owner's group
if (Group* og = player->GetGroup())
{
if (!bot->GetGroup())
{
if (!og->IsFull())
{
og->AddMember(bot);
og->SendUpdate();
}
}
else if (bot->GetGroup() != og)
{
bot->RemoveFromGroup();
if (!og->IsFull())
{
og->AddMember(bot);
og->SendUpdate();
}
}
}
}
TC_LOG_ERROR("playerbot.v2",
"[OnAcceptQuest] owner={} quest={} pushed={}",
player->GetName(), quest_id, pushed);
}
void Module::OnAreaExplored(Player* player, uint32 quest_id)
{
if (!initialized_ || !player) return;
if (player->GetSession()->IsBot()) return;
WorldSession* sess = player->GetSession();
if (!sess) return;
uint32 const account_id = sess->GetAccountId();
if (account_id == 0) return;
auto bots = Services::Altbots().AltsOfAccount(account_id);
if (bots.empty()) return;
auto& reg = Services::Registry();
for (BotId id : bots)
{
if (!reg.has(id)) continue;
Player* bot = ObjectAccessor::FindConnectedPlayer(
ObjectGuid::Create<HighGuid::Player>(id));
if (!bot || !bot->IsInWorld()) continue;
if (bot->GetMapId() != player->GetMapId()) continue;
bot->AreaExploredOrEventHappens(quest_id);
}
}
void Module::OnAbandonQuest(Player* player, uint32 quest_id)
{
if (!initialized_ || !player) return;
if (player->GetSession()->IsBot()) return;
Quest const* quest = sObjectMgr->GetQuestTemplate(quest_id);
WorldSession* sess = player->GetSession();
if (!sess) return;
uint32 const account_id = sess->GetAccountId();
if (account_id == 0) return;
auto bots = Services::Altbots().AltsOfAccount(account_id);
if (bots.empty()) return;
auto& reg = Services::Registry();
for (BotId id : bots)
{
if (!reg.has(id)) continue;
Player* bot = ObjectAccessor::FindConnectedPlayer(
ObjectGuid::Create<HighGuid::Player>(id));
if (!bot || !bot->IsInWorld()) continue;
QuestStatus const st = bot->GetQuestStatus(quest_id);
if (st != QUEST_STATUS_INCOMPLETE &&
st != QUEST_STATUS_COMPLETE &&
st != QUEST_STATUS_FAILED)
continue;
// Mirror TC HandleQuestLogRemoveQuest
bot->TakeQuestSourceItem(quest_id, true);
bot->RemoveActiveQuest(quest_id);
if (quest)
{
if (quest->GetLimitTime())
bot->RemoveTimedQuest(quest_id);
if (quest->HasFlag(QUEST_FLAGS_FLAGS_PVP))
{
bot->pvpInfo.IsHostile =
bot->pvpInfo.IsInHostileArea || bot->HasPvPForcingQuest();
bot->UpdatePvPState();
}
}
bot->SendForceSpawnTrackingUpdate(quest_id);
bot->AbandonQuest(quest_id); // destroy BIND_QUEST items
}
}
void Module::OnSetHomebind(Player* player, WorldLocation const& loc, uint32 areaId)
{
if (!initialized_ || !player) return;
if (player->GetSession()->IsBot()) return;
WorldSession* sess = player->GetSession();
if (!sess) return;
uint32 const account_id = sess->GetAccountId();
auto bots = Services::Altbots().AltsOfAccount(account_id);
if (bots.empty()) return;
for (BotId id : bots)
{
Player* bot = ObjectAccessor::FindConnectedPlayer(
ObjectGuid::Create<HighGuid::Player>(id));
if (!bot || !bot->IsInWorld()) continue;
bot->SetHomebind(loc, areaId);
TC_LOG_INFO("playerbot.v2",
"[HomebindSync] {} synced to owner's homebind map={} area={}",
bot->GetName(), loc.GetMapId(), areaId);
}
}
void Module::OnSellAllJunk(Player* player, ObjectGuid vendor_guid)
{
if (!initialized_ || !player) return;
if (player->GetSession()->IsBot()) return;
WorldSession* sess = player->GetSession();
if (!sess) return;
uint32 const account_id = sess->GetAccountId();
if (account_id == 0) return;
auto bots = Services::Altbots().AltsOfAccount(account_id);
if (bots.empty()) return;
auto& reg = Services::Registry();
for (BotId id : bots)
{
if (!reg.has(id)) continue;
Intent it{};
it.bot_id = id;
it.body = VendorIntent{VendorSellTrashIntent{vendor_guid}};
Services::Intents(id).push(std::move(it));
}
}
void Module::OnLootItem(Player* player, uint32 item_id, uint32 count)
{
if (!initialized_ || !player)
return;
WorldSession* sess = player->GetSession();
if (!sess || sess->IsBot())
return;
if (!item_id || !count)
return;
uint32 const account_id = sess->GetAccountId();
if (account_id == 0)
return;
std::vector<BotId> bots = Services::Altbots().AltsOfAccount(account_id);
{
auto owned = Services::Owners().BotsOwnedBy(account_id);
for (BotId id : owned)
if (std::find(bots.begin(), bots.end(), id) == bots.end())
bots.push_back(id);
}
TC_LOG_ERROR("playerbot.v2",
"[OnLootItem] owner={} item={} count={} targets={}",
player->GetName(), item_id, count, bots.size());
for (BotId id : bots)
{
Player* bot = ObjectAccessor::FindConnectedPlayer(
ObjectGuid::Create<HighGuid::Player>(id));
if (!bot || !bot->IsInWorld())
continue;
// Optional: allow different map if you want remote grant
if (bot->GetMapId() != player->GetMapId())
continue;
GiveQuestItem(bot, item_id, count);
}
}
void Module::QueueCompanionFinalize(ObjectGuid botGuid, ObjectGuid ownerGuid, uint8 level)
{
if (botGuid.IsEmpty() || ownerGuid.IsEmpty()) return;
PendingCompanionFinalize p;
p.botGuid = botGuid;
p.ownerGuid = ownerGuid;
p.level = level ? level : 1;
p.queued_ms = getMSTime();
pending_companion_finalize_.push_back(p);
}
void Module::DrainCompanionFinalizes(uint32 now_ms)
{
if (pending_companion_finalize_.empty()) return;
constexpr uint32 kMaxWaitMs = 30000;
constexpr uint8 kMaxAttempts = 40;
std::vector<PendingCompanionFinalize> still;
still.reserve(pending_companion_finalize_.size());
for (auto& entry : pending_companion_finalize_)
{
Player* bot = ObjectAccessor::FindConnectedPlayer(entry.botGuid);
Player* owner = ObjectAccessor::FindConnectedPlayer(entry.ownerGuid);
if (!bot || !bot->IsInWorld() || !owner || !owner->IsInWorld())
{
if (++entry.attempts < kMaxAttempts &&
getMSTimeDiff(entry.queued_ms, now_ms) < kMaxWaitMs)
still.push_back(entry);
continue;
}
BotId const id = bot->GetGUID().GetCounter();
if (!Services::Registry().has(id))
{
if (++entry.attempts < kMaxAttempts)
still.push_back(entry);
continue;
}
uint8 const targetLevel = entry.level ? entry.level : owner->GetLevel();
if (bot->GetLevel() != targetLevel)
{
bot->GiveLevel(targetLevel);
bot->InitTalentForLevel();
bot->SetXP(0);
}
float const ang = owner->GetOrientation() + float(M_PI) +
(float(id % 7) - 3.f) * 0.25f;
float const dist = 2.0f + float(id % 3) * 0.75f;
float const x = owner->GetPositionX() + dist * std::cos(ang);
float const y = owner->GetPositionY() + dist * std::sin(ang);
float const z = owner->GetPositionZ();
if (!bot->IsBeingTeleported())
Playerbot::BotMovement::SafeTeleport(bot, owner->GetMapId(), x, y, z, owner->GetOrientation(), 0);
if (BotAI* ai = Services::Registry().ai(id))
{
if (ai->formation_type() == FormationType::Free)
ai->set_formation_type(FormationType::Line);
}
}
pending_companion_finalize_.swap(still);
}
void Module::QueueAltFinalize(ObjectGuid botGuid, ObjectGuid ownerGuid, uint8 level)
{
if (botGuid.IsEmpty() || ownerGuid.IsEmpty())
return;
// Avoid duplicate finalize entries for the same bot
for (auto const& e : pending_alt_finalize_)
if (e.botGuid == botGuid)
return;
PendingAltFinalize p;
p.botGuid = botGuid;
p.ownerGuid = ownerGuid;
p.level = level ? level : 1;
p.queued_ms = getMSTime();
p.attempts = 0;
pending_alt_finalize_.push_back(p);
TC_LOG_ERROR("playerbot.v2",
"[AltFinalize] queued bot={} owner={} level={}",
botGuid.ToString(), ownerGuid.ToString(), uint32(p.level));
}
void Module::DrainAltFinalizes(uint32 /*now_ms*/)
{
if (pending_alt_finalize_.empty())
return;
// MUST use getMSTime() — same clock as QueueAltFinalize's queued_ms.
// The world-update "now_ms" is a different timeline and caused instant timeouts.
uint32 const now = getMSTime();
constexpr uint32 kMaxWaitMs = 120000; // 2 minutes
constexpr uint8 kMaxAttempts = 240; // ~250ms tick → ~60s+; hard cap
// A login that is still in flight this long after the queue entry was
// created is a zombie: BeginLogin was submitted against an uncommitted
// character row (or a wedged holder), so the BotSession never completes
// and never leaves sessions_ — every later `.playerbot login` then fails
// with "login already in flight". Reap it and resubmit on the now
// committed row. Normal logins land in well under this window.
constexpr uint32 kZombieReapMs = 25000;
std::vector<PendingAltFinalize> still;
still.reserve(pending_alt_finalize_.size());
for (auto& e : pending_alt_finalize_)
{
Player* bot = ObjectAccessor::FindConnectedPlayer(e.botGuid);
Player* owner = ObjectAccessor::FindConnectedPlayer(e.ownerGuid);
BotId const id = e.botGuid.GetCounter();
auto requeue = [&](char const* why)
{
uint32 const elapsed = getMSTimeDiff(e.queued_ms, now);
TC_LOG_ERROR("playerbot.v2",
"[AltFinalize] wait bot={} attempt={} elapsed_ms={} why={}",
e.botGuid.ToString(), uint32(e.attempts), elapsed, why);
if (e.attempts < kMaxAttempts && elapsed < kMaxWaitMs)
{
++e.attempts;
still.push_back(e);
}
else
{
TC_LOG_ERROR("playerbot.v2",
"[AltFinalize] timeout bot={} last_why={} attempts={} elapsed_ms={}",
e.botGuid.ToString(), why, uint32(e.attempts), elapsed);
}
};
if (!owner || !owner->IsInWorld())
{
requeue("owner_missing");
continue;
}
// ----- not online yet -----
if (!bot)
{
if (!::Playerbot::Services::Lifecycle().is_bot(id))
::Playerbot::Services::Lifecycle().mark_as_bot(id);
bool const inFlight =
::Playerbot::Services::SessionMgr().HasSession(e.botGuid);
if (inFlight)
{
// Login already submitted — do NOT call LoginBot again. But a
// login can wedge permanently (see kZombieReapMs): if BeginLogin
// ran against an uncommitted character row the holder loads
// nothing, the session never completes, and the bot stays
// "login already in flight" forever. Reap it once the sanity
// window elapses and resubmit against the committed row.
if (getMSTimeDiff(e.queued_ms, now) >= kZombieReapMs)
{
::Playerbot::Services::SessionMgr().LogoutBot(e.botGuid);
TC_LOG_ERROR("playerbot.v2",
"[AltFinalize] reaped zombie login bot={} elapsed_ms={}",
e.botGuid.ToString(), getMSTimeDiff(e.queued_ms, now));
auto lr = ::Playerbot::Services::SessionMgr().LoginBot(e.botGuid);
TC_LOG_ERROR("playerbot.v2",
"[AltFinalize] LoginBot retry bot={} ok={} reason={}",
e.botGuid.ToString(), lr.ok, lr.reason);
if (lr.ok)
{
requeue("login_submitted");
continue;
}
}
requeue("login_in_flight");
continue;
}
auto lr = ::Playerbot::Services::SessionMgr().LoginBot(e.botGuid);
TC_LOG_ERROR("playerbot.v2",
"[AltFinalize] LoginBot bot={} ok={} reason={}",
e.botGuid.ToString(), lr.ok, lr.reason);
if (lr.ok)
{
requeue("login_submitted");
continue;
}
// Benign: another path started login between HasSession and LoginBot
if (lr.reason == "login already in flight" ||
lr.reason == "character already in-world")
{
requeue(lr.reason.c_str());
continue;
}
// SaveToDB / cache race — keep trying
requeue(lr.reason.c_str());
continue;
}
if (!bot->IsInWorld())
{
requeue("not_in_world");
continue;
}
if (!::Playerbot::Services::Registry().has(id))
{
requeue("no_ai_registry");
continue;
}
if (bot->IsBeingTeleported())
{
requeue("teleporting");
continue;
}
// ----- success path -----
uint8 const lvl = e.level ? e.level : owner->GetLevel();
if (bot->GetLevel() != lvl)
{
bot->GiveLevel(lvl);
bot->InitTalentForLevel();
bot->SetXP(0);
}
float const ang = owner->GetOrientation() + float(M_PI)
+ (float(id % 7) - 3.f) * 0.25f;
float const dist = 2.0f + float(id % 3) * 0.75f;
float const x = owner->GetPositionX() + dist * std::cos(ang);
float const y = owner->GetPositionY() + dist * std::sin(ang);
float const z = owner->GetPositionZ();
Playerbot::BotMovement::SafeTeleport(
bot, owner->GetMapId(), x, y, z, owner->GetOrientation(), 0);
// Group membership
if (Group* g = owner->GetGroup())
{
if (bot->GetGroup() != g)
{
if (bot->GetGroup())
bot->RemoveFromGroup();
if (bot->GetGroupInvite())
bot->UninviteFromGroup();
if (!g->IsFull() && !g->IsMember(bot->GetGUID()))
g->AddMember(bot);
}
}
else
{
Group* ng = new Group;
if (ng->Create(owner))
{
sGroupMgr->AddGroup(ng);
ng->AddMember(bot);
}
else
delete ng;
}
if (BotAI* ai = ::Playerbot::Services::Registry().ai(id))
{
ai->set_owned(true);
ai->set_role(BotRole::Altbot);
if (ai->formation_type() == FormationType::Free)
ai->set_formation_type(FormationType::Line);
}
ApplyAltGear(bot, owner, false);
TC_LOG_ERROR("playerbot.v2",
"[AltFinalize] OK {} L{} at {} map={} grouped={}",
bot->GetName(), uint32(bot->GetLevel()),
owner->GetName(), owner->GetMapId(),
bot->GetGroup() && owner->GetGroup() &&
bot->GetGroup() == owner->GetGroup());
// success — do not push to still
}
pending_alt_finalize_.swap(still);
}
void Module::CancelAltFinalize(ObjectGuid botGuid)
{
if (botGuid.IsEmpty() || pending_alt_finalize_.empty())
return;
pending_alt_finalize_.erase(
std::remove_if(pending_alt_finalize_.begin(), pending_alt_finalize_.end(),
[&](PendingAltFinalize const& e) { return e.botGuid == botGuid; }),
pending_alt_finalize_.end());
}
void Module::OnResurrect(Player* /*p*/)
{
if (!initialized_) return;
// Intentionally a no-op. BotAI::tick() reacts to `is_alive` going true
// by transitioning Dead → Idle on the next snapshot, which restarts the
// dispatch chain naturally. No event-bus push needed.
}
void Module::OnSpecChanged(Player* /*p*/, uint8 /*new_spec*/)
{
if (!initialized_) return;
// Intentionally a no-op. Rotation lookup is `Combat::GetRotation(cls,
// spec)` every tick, so spec changes pick up the new APL within one
// tick of the snapshot updating `spec`. No state to invalidate.
}
void Module::OnDamageDealt(Unit* /*attacker*/, Unit* /*victim*/, int32 /*amount*/, uint32 /*spell_id*/)
{
if (!initialized_) return;
// Intentionally a no-op. DamageDealt fires every swing/tick/DoT and
// would saturate the 256-slot inbox ring. Snapshot deltas (in_combat,
// victim hp) cover the cases rules actually need without burning event
// capacity. Combat-tier promotion happens in OnWorldUpdate from the
// freshly built snapshot's `in_combat` flag.
}
void Module::OnDamageTaken(Unit* /*attacker*/, Unit* /*victim*/, int32 /*amount*/, uint32 /*spell_id*/)
{
if (!initialized_) return;
// Intentionally a no-op. The BotEventInbox was retired 2026-05-21
// after audit found zero consumers + the snapshot's in_combat /
// hp / attackers vector already covers every actual rule need.
// V1 had an event bus with real subscribers (174 publishers, 210
// subscribers across 7 domains); V2's snapshot-then-AI-worker
// distribution model fills that role differently. If event-driven
// dispatch becomes needed for a specific feature (e.g. kick-then-
// pause), introduce a focused per-event hook then.
}
void Module::OnHealReceived(Unit* /*healer*/, Unit* /*target*/, int32 /*amount*/, uint32 /*spell_id*/)
{
if (!initialized_) return;
// See OnDamageTaken — same rationale. Snapshot's hp_pct + group
// member HP tracking cover all current consumer cases.
}
void Module::OnAuraApplied(Unit* /*target*/, Aura* /*aura*/)
{
if (!initialized_) return;
// Intentionally a no-op. Aura applications fire constantly during combat
// (every DoT tick stack, every refresh, every proc-aura) and would
// saturate the 256-slot inbox ring. The snapshot's `own_auras` list (with
// `mechanic` populated) lets rules reason about active CC within ~100ms
// of application — fast enough for reactive defensives without the
// event-bus overhead. If a use case ever needs sub-tick latency on a
// specific aura, gate the push on that spell ID here.
}
void Module::OnAuraRemoved(Unit* /*target*/, Aura* /*aura*/)
{
if (!initialized_) return;
// Intentionally a no-op. Same rationale as OnAuraApplied — aura removals
// fire too often to event-bus blindly. Snapshot delta on `own_auras` /
// outbound aura tables covers refresh-detection use cases.
}
void Module::OnGroupMemberJoined(Group* /*g*/, Player* /*p*/)
{
if (!initialized_) return;
// Intentionally a no-op. The world-tick rebuilds GroupSnapshot every tick
// for grouped bots, so newly-joined members appear in the next snapshot
// without a hook-driven invalidation. If we ever cache snapshots across
// ticks, this is the place to invalidate.
}
void Module::OnGroupMemberLeft(Group* /*g*/, Player* /*p*/)
{
if (!initialized_) return;
// Intentionally a no-op. Same rationale as OnGroupMemberJoined — the
// next world-tick GroupSnapshot rebuild reflects departures. Leadership
// re-election lives in TrinityCore's Group code; no V2 action needed.
}
void Module::OnWhisperReceived(Player* sender, Player* receiver, std::string const& msg)
{
if (!initialized_ || !sender || !receiver) return;
const BotId id = receiver->GetGUID().GetCounter();
if (!Services::Registry().has(id)) return; // not one of ours
// Try the command parser first — if it recognized a verb it returns
// true and we're done. Otherwise fall through to the social reactor
// so non-command whispers ("hi", "ty for the help") still get a
// friendly reply. Without this fallthrough, strangers whispering a
// bot get silence, which reads as broken.
if (BotCommandParser::Dispatch(sender, receiver, msg)) return;
BotChatReactor::ReactWhisper(sender, receiver, msg);
}
void Module::OnGuildChat(Player* sender, uint64 guild_id, std::string const& msg)
{
// Phase C.3: route to BotChatReactor::ReactGuild. The reactor
// handles all gating (per-guild throttle, bot-self filter, online-
// officer pick).
if (!initialized_ || !sender || guild_id == 0 || msg.empty()) return;
BotChatReactor::ReactGuild(sender, guild_id, msg);
}
void Module::OnSayChat(Player* sender, std::string const& msg)
{
// SC-P1a: a real player spoke in /say. Let the reactor pick at most one
// nearby bot to answer (range-gated by CONFIG_LISTEN_RANGE_SAY, per-bot
// + per-area cooldown). All gating lives in the reactor.
if (!initialized_ || !sender || msg.empty()) return;
BotChatReactor::ReactSay(sender, msg);
}
void Module::OnYellChat(Player* sender, std::string const& msg)
{
// SC-P1a: /yell variant — wider range (CONFIG_LISTEN_RANGE_YELL) but a
// lower reply chance (the reactor keeps yell answers rarer than say).
if (!initialized_ || !sender || msg.empty()) return;
BotChatReactor::ReactYell(sender, msg);
}
void Module::OnTextEmote(Player* sender, uint32 emote_id, ObjectGuid target)
{
// SC-P2c: a player performed a text emote; a nearby bot reciprocates.
if (!initialized_ || !sender) return;
BotChatReactor::ReactEmote(sender, emote_id, target);
}
void Module::OnGuildMemberAdded(uint64 guild_id, ObjectGuid joiner_guid, std::string const& joiner_name)
{
// SC-P2b: welcome a new guild member from one online bot guildmate.
if (!initialized_ || guild_id == 0) return;
BotChatReactor::ReactGuildJoin(guild_id, joiner_guid, joiner_name);
}
void Module::OnPartyChat(Player* sender, Group* group, std::string const& msg)
{
// Squad-chat command surface. Only messages prefixed with `;` are
// routed; every other party-chat line is ignored so we don't spam
// bots with the human chatter that fills /p during dungeons.
//
// The leading `;` is stripped, then the rest is treated like a
// whisper to the first owned bot in the group — the dispatch path
// resolves any address prefix (`tank:`, `mage:`, `Areon:`, ...)
// and applies to all matching bots. The whispered "primary" bot
// is the messenger that emits the single summary reply.
if (!initialized_ || !sender || !group || msg.empty()) return;
// Social reaction layer: lets bots respond to "ty"/"gz"/"lol"/their
// own name with short conversational replies so group chat reads like
// a room of real players. Runs for every message; the reactor itself
// gates on bot verbosity, per-bot throttle, and skips bot-sent lines.
BotChatReactor::React(sender, group, msg);
if (msg.front() != ';') return;
const std::string body = msg.substr(1);
if (body.empty()) return;
// Walk the group for the sender's first owned bot. We need a
// primary bot to attach the reply whisper to; if the owner has
// none in the group, route to the first owned-on-realm fallback.
const uint32 sender_account =
sender->GetSession() ? sender->GetSession()->GetAccountId() : 0;
if (sender_account == 0) return;
Player* primary = nullptr;
// Pass 1: prefer an EXPLICITLY OWNED bot in the group. This is the
// canonical squad-control path — the player has bound bots via
// /squad mark or similar and wants commands routed to their squad.
Player* any_group_bot = nullptr;
for (GroupReference const& itr : group->GetMembers())
{
Player* member = itr.GetSource();
if (!member || member == sender) continue;
const BotId mid = member->GetGUID().GetCounter();
if (!Services::Registry().has(mid)) continue;
if (!any_group_bot) any_group_bot = member; // remember for pass 2
if (Services::Owners().IsOwner(mid, sender_account,
sender->GetGUID().GetCounter()))
{ primary = member; break; }
}
// Pass 2: any owned bot online (off-tank parked elsewhere, etc.).
if (!primary)
{
std::vector<BotId> const owned =
Services::Owners().BotsOwnedBy(sender_account);
for (BotId mid : owned)
{
ObjectGuid g = ObjectGuid::Create<HighGuid::Player>(mid);
if (Player* p = ObjectAccessor::FindConnectedPlayer(g))
{ primary = p; break; }
}
}
// Pass 3: any V2 bot in the group. Covers the LFG-formed-group case
// where the player ran the dungeon-finder UI and joined alongside
// auto-spawned bots they don't formally own — the player is sharing
// the dungeon with them and should be able to drive their behavior
// (run/stop/pull/etc.). Authority is still scoped to the group: a
// bot in someone else's group can't be commanded from outside.
if (!primary)
primary = any_group_bot;
if (!primary) return;
BotCommandParser::Dispatch(sender, primary, body);
}
void Module::OnPlayerJoinedBgQueue(Player* player, uint32 bg_type_id, uint8 bracket)
{
if (!initialized_ || !player) return;
// Fleet-only: BG queue filling recruits population bots. In alt-bot-only
// mode (PlayerbotsV2.FleetBots=0) there is no ambient fleet to fill a
// queue with, so the whole fill is skipped.
if (!Services::Config().fleet_bots())
return;
// SOLO bots ignore (they're queued by Filler intent emit, not by core
// path — re-firing the fill for each would recurse). A bot GROUP LEADER
// is the exception (audit B25): a premade (guild BG-night /
// BgTeamForming) that just queued needs the OPPOSING faction filled or
// the match can never form — the human-player path never fires for it
// and SeedBgMatches only seeds ambient matches when no BG is active.
if (player->GetSession() && player->GetSession()->IsBot())
{
Group const* g = player->GetGroup();
if (!g || g->GetLeaderGUID() != player->GetGUID())
return;
// fall through: bot premade leader — fill both factions below
}
Fleet::BotQueueFiller filler;
Fleet::BotQueueFiller::FillRequest req{};
req.kind = Fleet::BotQueueFiller::QueueKind::Bg;
req.bracket = bracket;
req.faction = uint32(player->GetTeam());
req.instance_id = bg_type_id;
req.requesting_player = player;
// Override target level to the queuing player's actual level. The
// PVPDifficultyEntry bracket ID is NOT level/10 — for AV bracket 0
// corresponds to L51-60 (or similar), not L0-9 as BracketMidpoint
// would compute. Without this override the filler searches the wrong
// level pool and returns no candidates. Observed 2026-05-13: AV queue
// logged "kind=0 bracket=0 instance=1 needs=3T/3H/30D" with horde
// online_seen=250 but only 7 actually queued because the level-15
// midpoint with ±15 window only matched L0-30 bots, when the user
// (and the bracket's actual range) was L60+. Same issue as LFG —
// same fix.
req.target_level_override = uint8(std::clamp(int(player->GetLevel()), 1, 80));
// Cap initial queue fill at max_per_team for the BG so we never
// overpopulate. The BattlegroundTemplate exposes the cap; for a
// 10v10 BG (WSG/TP/AB/etc.) this is 10. Without the cap, the
// default NeedsFor(Bg) of 3T/3H/30D = 36 per side overshot —
// observed WSG with 13 alliance vs 7 horde when surplus invites
// landed before TC's per-team check could reject them.
if (BattlegroundTemplate const* tmpl =
sBattlegroundMgr->GetBattlegroundTemplateByTypeId(BattlegroundTypeId(bg_type_id)))
{
if (uint16 const max_per_team = tmpl->GetMaxPlayersPerTeam())
req.max_total_bots = uint8(std::min<uint16>(max_per_team, 255));
// Look up the actual bracket level range on this BG's map so the
// filler can reject bots whose level is outside [min, max]. Without
// this, the ±15 coarse window pulled in bots (e.g., L10-17 for an
// L22 user) that AV's PVPDifficulty doesn't have a bracket for —
// every queue intent then fails `no_bracket` in API::bg_queue and
// TC's matchmaker never gets enough valid candidates to create
// the BG. Crash 2026-05-13: AV stuck not starting.
if (!tmpl->MapIDs.empty())
{
if (PVPDifficultyEntry const* diff =
DB2Manager::GetBattlegroundBracketById(tmpl->MapIDs.front(),
BattlegroundBracketId(bracket)))
{
req.bracket_min_level = uint8(std::clamp<int32>(diff->MinLevel, 1, 80));
req.bracket_max_level = uint8(std::clamp<int32>(diff->MaxLevel, 1, 80));
}
}
}
filler.Fill(req);
// BotCoordinationBus publish — BG team formation signal. Existing
// BotQueueFiller path stays the primary filler (battle-tested);
// bus subscribers (BotGroupBuilder) form an additional premade
// 5-bot squad on the same faction so the queue has a cohesive
// group entry alongside the solo invites.
if (Services::Initialized())
{
V2::CoordEvent ev{};
ev.kind = V2::CoordSignal::BgTeamForming;
ev.origin_low = player->GetGUID().GetCounter();
ev.content_id = bg_type_id;
ev.level_min = req.bracket_min_level;
ev.level_max = req.bracket_max_level;
ev.faction_mask = (player->GetTeam() == ALLIANCE) ? 0x1u : 0x2u;
Services::Coordination().Publish(ev);
}
}
void Module::OnPlayerJoinedLfg(Player* player, uint32 dungeon_id, uint8 /*role_mask*/)
{
if (!initialized_ || !player) return;
if (player->GetSession() && player->GetSession()->IsBot()) return;
// Fleet-only: LFG fill drafts population bots (and can JIT-spawn new ones).
// In alt-bot-only mode (PlayerbotsV2.FleetBots=0) there is no ambient
// fleet, so bots for a player's queue must come from their own alts —
// skip the auto-fill entirely.
if (!Services::Config().fleet_bots())
return;
Fleet::BotQueueFiller filler;
Fleet::BotQueueFiller::FillRequest req{};
// Detect raid vs 5-man dungeon from the LFGDungeons DB2 entry. LFR/raid
// queues require Raid10 / Raid20 composition (2T/3H/5D or 2T/4H/14D)
// rather than 1T/1H/3D. Pulling group-size from CountTank+CountHealer+
// CountDamage covers any future content without a hard-coded type map.
req.kind = Fleet::BotQueueFiller::QueueKind::Dungeon5;
// Target level: derived from the DUNGEON's level range, falling back
// to the QUEUEING PLAYER's level. Both bypass the coarse
// BracketMidpoint mapping (which rounds level/10 to a midpoint that's
// off by 10+ for low/mid brackets — e.g. L21 → bracket 2 → midpoint
// 35, far too high for L17-24 dungeons). Empirical log confirmation
// from a L21 Balastan in Wailing Caverns:
// [QueueFill] kind=1 bracket=2 needs=1T/1H/3D
// pool faction=HORDE … level=175 → 175 horde bots rejected by ±5
// filter around midpoint 35. ContentTuningID was 0 for that dungeon
// (vanilla data) so the prior ContentTuning lookup didn't fire.
// Use the player's exact level as a robust fallback — level-scaling
// and ±15 LFG window will absorb minor under/over-match.
req.bracket = uint8(player->GetLevel() / 10);
req.target_level_override = uint8(std::clamp(int(player->GetLevel()), 1, 80));
if (LFGDungeonsEntry const* d = sLFGDungeonsStore.LookupEntry(dungeon_id))
{
const uint32 size = uint32(d->CountTank) + uint32(d->CountHealer) +
uint32(d->CountDamage);
if (size > 5 && size <= 12) req.kind = Fleet::BotQueueFiller::QueueKind::Raid10;
else if (size > 12) req.kind = Fleet::BotQueueFiller::QueueKind::Raid20;
// size <= 5 keeps Dungeon5 (scenarios use size 3 — still Dungeon5
// composition-wise, the filler clamps to <=5 fill).
if (d->ContentTuningID)
{
if (Optional<ContentTuningLevels> lv =
sDB2Manager.GetContentTuningData(d->ContentTuningID, {}))
{
const int16 mid = (lv->TargetLevelMin > 0 || lv->TargetLevelMax > 0)
? int16((lv->TargetLevelMin + lv->TargetLevelMax) / 2)
: int16((lv->MinLevel + lv->MaxLevel) / 2);
if (mid > 0)
req.target_level_override = uint8(std::clamp(int(mid), 1, 80));
}
}
}
req.faction = uint32(player->GetTeam());
req.instance_id = dungeon_id;
req.requesting_player = player;
filler.Fill(req);
// Enqueue for periodic top-up. Subsequent Fill retries strip the
// requesting_player pointer (it could go stale across 30s) and
// also clear the role-deficit at refill time by counting bots
// already in the queue for this dungeon.
{
Fleet::BotQueueFiller::FillRequest persisted = req;
persisted.requesting_player = nullptr;
const uint32 now_ms = getMSTime();
std::lock_guard lk(pending_lfg_refills_mtx_);
// Replace any existing entry for this player (re-queue resets).
const uint64 pguid = player->GetGUID().GetCounter();
std::erase_if(pending_lfg_refills_,
[pguid](PendingLfgRefill const& e){ return e.player_guid_low == pguid; });
pending_lfg_refills_.push_back(PendingLfgRefill{
pguid, std::move(persisted), now_ms, now_ms});
}
// BotCoordinationBus publish — fan out role-need signals so any
// subscriber (BotGroupBuilder, future analytics) can react.
// The original BotQueueFiller path above still handles the
// primary fill; this publishes a coarse "tank/healer/dps needed"
// event with the requesting player's level as the target so role-
// specific subscribers can pick targeted bots.
if (Services::Initialized())
{
V2::CoordEvent ev{};
ev.origin_low = player->GetGUID().GetCounter();
ev.content_id = dungeon_id;
ev.level_min = uint8(std::max(1, int(player->GetLevel()) - 5));
ev.level_max = uint8(std::min(80, int(player->GetLevel()) + 5));
ev.faction_mask = (player->GetTeam() == ALLIANCE) ? 0x1u : 0x2u;
// Publish all three role signals so subscribers can pick what
// they handle. Subscriber filtering happens at the handler
// level — cheap.
ev.kind = V2::CoordSignal::LfgTankNeeded;
Services::Coordination().Publish(ev);
ev.kind = V2::CoordSignal::LfgHealerNeeded;
Services::Coordination().Publish(ev);
ev.kind = V2::CoordSignal::LfgDpsNeeded;
Services::Coordination().Publish(ev);
}
}
void Module::OnBGInvitationReceived(Player* player, uint32 bg_instance_id, uint32 bg_type_id)
{
// Synchronous handler fired from BattlegroundQueue::InviteGroupToBG /
// BGQueueInviteEvent::Execute the instant TC sends the
// BattlefieldStatusNeedConfirmation packet. The queue is mid-iteration
// here — we MUST NOT mutate BG queue data, AND we MUST NOT port bots
// in the same tick (or even seconds-later) as the invite if a real
// human is also invited — bots filling the BG can advance its state
// and silently invalidate the human's invite.
//
// Strategy:
// - Human invitee: just bump expected_humans for this instance, return.
// - Bot invitee: enqueue deferred port; FireDueBgPorts (run each tick
// before DrainIntents) fires it once gating passes:
// * 0 expected humans → fire after a 0-1500ms stagger.
// * 1+ expected humans → fire only once a human is in the BG
// instance (sticky bool, then small per-bot stagger).
// * 80s hard expiry → drop (TC's 90s INVITE_ACCEPT_WAIT_TIME -10s).
// - Snapshot-poll idle:bg_port_accept rule is suppressed for bg_type_id
// via BgPort cooldown stamp so it can't preempt the gating.
if (!initialized_ || !player) return;
uint32 const now_ms = GameTime::GetGameTimeMS();
bool const is_bot = player->GetSession() && player->GetSession()->IsBot();
if (!is_bot)
{
std::lock_guard lk(pending_bg_ports_mtx_);
++bg_invite_state_[bg_instance_id].expected_humans;
return;
}
BotId const id = player->GetGUID().GetCounter();
auto& reg = Services::Registry();
if (!reg.has(id)) return;
// CRASH FIX 2026-05-13: TC fires this hook from BOTH
// BattlegroundQueue::InviteGroupToBG (initial) AND BGQueueInviteEvent
// ::Execute (~30s reminder). Without per-(bot,bg_instance) dedup we'd
// queue two staggered ports for the same bot — the second fires after
// the first port succeeded, re-entering API::bg_port while the bot is
// mid-teleport, tripping Map::RemovePlayerFromMap's ASSERT(remove)
// when the bot isn't in any grid (Map.cpp:935). Two guards:
// (a) skip if a pending entry for (bot, bg_instance) already exists
// (b) skip if the bot is ALREADY in the target BG instance (port
// already completed; the reminder is just informational).
if (player->InBattleground())
{
Battleground const* current = player->GetBattleground();
if (current && current->GetInstanceID() == bg_instance_id)
return; // already in this BG — nothing to do
}
{
std::lock_guard lk(pending_bg_ports_mtx_);
for (auto const& p : pending_bg_ports_)
{
if (p.bot_id == uint64(id) && p.bg_instance_id == bg_instance_id)
return; // dedup
}
pending_bg_ports_.push_back({ now_ms, bg_instance_id, uint64(id),
uint16(bg_type_id),
/*bg_template_type_id*/ uint32(bg_type_id) });
}
if (BotAI* ai = reg.ai(id))
ai->note_action_retry(BotAI::ActionKind::BgPort,
uint64(bg_type_id), now_ms);
TC_LOG_INFO("playerbot.v2",
"[OnBGInvitationReceived] {} deferred port bg_inst={} bg_type_id={} (gating on human-first)",
player->GetName(), bg_instance_id, bg_type_id);
}
void Module::FireDueBgPorts()
{
uint32 const now_ms = GameTime::GetGameTimeMS();
std::vector<PendingBgPort> to_fire;
{
std::lock_guard lk(pending_bg_ports_mtx_);
if (pending_bg_ports_.empty()) return;
auto it = pending_bg_ports_.begin();
while (it != pending_bg_ports_.end())
{
// Expire stale entries (TC's INVITE_ACCEPT_WAIT_TIME is 90s).
if (now_ms - it->created_at_ms > 80u * 1000u)
{
it = pending_bg_ports_.erase(it);
continue;
}
auto state_it = bg_invite_state_.find(it->bg_instance_id);
uint32 const expected_humans = (state_it != bg_invite_state_.end())
? state_it->second.expected_humans : 0u;
bool human_in_bg = (state_it != bg_invite_state_.end())
? state_it->second.any_human_seen : false;
// No humans invited → fire after light per-bot stagger so the BG
// doesn't flood-start the same tick.
if (expected_humans == 0)
{
uint32 const stagger_ms = uint32(it->bot_id % 1500u);
if (now_ms - it->created_at_ms >= stagger_ms)
{
to_fire.push_back(*it);
it = pending_bg_ports_.erase(it);
}
else
++it;
continue;
}
// Humans invited → only fire once a human is actually IN the BG.
// Once flagged sticky, the gate stays open for that instance.
if (!human_in_bg)
{
Battleground* bg = sBattlegroundMgr->GetBattleground(
it->bg_instance_id,
BattlegroundTypeId(it->bg_template_type_id));
if (bg)
{
for (auto const& [guid, _] : bg->GetPlayers())
{
Player* p = ObjectAccessor::FindPlayer(guid);
if (p && p->GetSession() && !p->GetSession()->IsBot())
{
human_in_bg = true;
if (state_it != bg_invite_state_.end())
state_it->second.any_human_seen = true;
break;
}
}
}
if (!human_in_bg) { ++it; continue; } // wait
}
// Gate open. Apply a small 0-1500ms per-bot stagger from the
// moment the gate opened (created_at + 0 if gate already open
// at invite-time, or current tick-time otherwise — using created_at
// here gives a deterministic spread per bot anchored to invite).
uint32 const stagger_ms = uint32(it->bot_id % 1500u);
if (now_ms - it->created_at_ms >= stagger_ms)
{
to_fire.push_back(*it);
it = pending_bg_ports_.erase(it);
}
else
++it;
}
}
if (to_fire.empty()) return;
auto& reg = Services::Registry();
for (auto const& p : to_fire)
{
BotId const id = BotId(p.bot_id);
if (!reg.has(id)) continue;
// CRASH FIX 2026-05-13: belt-and-braces re-check at fire time —
// between push and fire, the bot may have already entered the BG
// (via the snapshot-poll fallback or a prior hook fire). Pushing
// another BgPortIntent in that state re-enters API::bg_port with
// currentBg == target_bg and races the teleport flow, tripping
// Map::RemovePlayerFromMap ASSERT. Skip if already in target.
Player* bot = ObjectAccessor::FindConnectedPlayer(
ObjectGuid::Create<HighGuid::Player>(uint64(p.bot_id)));
if (bot)
{
if (Battleground const* cur = bot->GetBattleground())
if (cur->GetInstanceID() == p.bg_instance_id)
continue;
}
IntentQueue* iq = reg.intents(id);
IntentId* nid = reg.next_intent_id(id);
if (!iq || !nid) continue;
Intent intent;
intent.id = ++(*nid);
intent.bot_id = id;
intent.source_snapshot = 0;
intent.body = QueueIntent{BgPortIntent{p.bg_type_id, /*accept*/ true}};
iq->push(std::move(intent));
if (bot)
{
TC_LOG_INFO("playerbot.v2",
"[BgPortFire] {} faction={} bg_inst={} bg_type={} (deferred {}ms)",
bot->GetName(),
bot->GetTeam() == ALLIANCE ? "ALLIANCE" : "HORDE",
p.bg_instance_id, p.bg_type_id,
now_ms - p.created_at_ms);
}
}
}
void Module::OnLfgProposalReceived(Player* player, uint32 proposal_id)
{
// Synchronous handler fired from LFGMgr::AddProposal. The proposal table
// is mid-iteration here so we MUST NOT call sLFGMgr->UpdateProposal
// directly — push an LfgProposalRespondIntent which drains safely on
// the next world-tick DrainIntents pass.
if (!initialized_ || !player) return;
if (!player->GetSession() || !player->GetSession()->IsBot()) return;
if (proposal_id == 0) return;
BotId const id = player->GetGUID().GetCounter();
auto& reg = Services::Registry();
if (!reg.has(id)) return;
uint32 const now_ms = GameTime::GetGameTimeMS();
{
std::lock_guard lk(pending_lfg_accepts_mtx_);
pending_lfg_accepts_.push_back({ now_ms, uint64(id), proposal_id });
}
// Suppress snapshot-poll idle:lfg_proposal_accept so it can't double-fire.
if (BotAI* ai = reg.ai(id))
ai->set_lfg_proposal_acked_id(proposal_id);
TC_LOG_INFO("playerbot.v2",
"[OnLfgProposalReceived] {} deferred accept proposal_id={}",
player->GetName(), proposal_id);
}
void Module::FireDueLfgAccepts()
{
uint32 const now_ms = GameTime::GetGameTimeMS();
std::vector<PendingLfgAccept> to_fire;
{
std::lock_guard lk(pending_lfg_accepts_mtx_);
if (pending_lfg_accepts_.empty()) return;
auto it = pending_lfg_accepts_.begin();
while (it != pending_lfg_accepts_.end())
{
// Expire after 35s (TC's LFG_TIME_PROPOSAL is 40s).
if (now_ms - it->created_at_ms > 35u * 1000u)
{
it = pending_lfg_accepts_.erase(it);
continue;
}
// 0-800ms per-bot stagger so 5 (or 25) bots don't all fire intents
// the exact same tick. Deterministic per bot_id.
uint32 const stagger_ms = uint32(it->bot_id % 800u);
if (now_ms - it->created_at_ms >= stagger_ms)
{
to_fire.push_back(*it);
it = pending_lfg_accepts_.erase(it);
}
else
++it;
}
}
if (to_fire.empty()) return;
auto& reg = Services::Registry();
for (auto const& p : to_fire)
{
BotId const id = BotId(p.bot_id);
if (!reg.has(id)) continue;
IntentQueue* iq = reg.intents(id);
IntentId* nid = reg.next_intent_id(id);
if (!iq || !nid) continue;
Intent intent;
intent.id = ++(*nid);
intent.bot_id = id;
intent.source_snapshot = 0;
intent.body = QueueIntent{LfgProposalRespondIntent{p.proposal_id, /*accept*/ true}};
iq->push(std::move(intent));
}
}
void Module::TopUpPendingLfg(uint32 now_ms)
{
constexpr uint32 kLfgTopUpIntervalMs = 30u * 1000u;
// Snapshot the entries to refill outside the lock so the Fill calls
// (which may take >1ms each) don't hold the mutex.
std::vector<PendingLfgRefill> to_refill;
{
std::lock_guard lk(pending_lfg_refills_mtx_);
auto it = pending_lfg_refills_.begin();
while (it != pending_lfg_refills_.end())
{
// Drop entries older than 15 min — by then the queue has
// either popped, been canceled, or gone stale (the player
// moved on). Without this entries leak forever on logout.
if (now_ms - it->created_at_ms > 15u * 60u * 1000u)
{
it = pending_lfg_refills_.erase(it);
continue;
}
// Drop entries whose player is no longer in LFG_STATE_QUEUED.
// sLFGMgr->GetState returns LFG_STATE_NONE for offline / not-
// queued, LFG_STATE_PROPOSAL once a match formed, etc. Either
// way the top-up is no longer needed.
ObjectGuid pguid = ObjectGuid::Create<HighGuid::Player>(it->player_guid_low);
lfg::LfgState const st = sLFGMgr->GetState(pguid);
if (st != lfg::LFG_STATE_QUEUED)
{
it = pending_lfg_refills_.erase(it);
continue;
}
// Cron cadence: refill at most every 30s per entry.
if (now_ms - it->last_refill_ms < kLfgTopUpIntervalMs)
{
++it;
continue;
}
it->last_refill_ms = now_ms;
to_refill.push_back(*it);
++it;
}
}
if (to_refill.empty()) return;
auto& reg = Services::Lifecycle();
auto ids = reg.snapshot_ids();
for (auto& entry : to_refill)
{
// Count our online V2 bots already in LFG queue for this dungeon
// by role. Subtract from the kind's default composition to get
// the deficit. The walk is O(online bots) — bounded by what's
// logged in, scales fine.
uint32 q_t = 0, q_h = 0, q_d = 0;
for (BotId id : ids)
{
ObjectGuid g = ObjectGuid::Create<HighGuid::Player>(id);
Player* bot = ObjectAccessor::FindConnectedPlayer(g);
if (!bot) continue;
if (sLFGMgr->GetState(g) != lfg::LFG_STATE_QUEUED) continue;
lfg::LfgDungeonSet const& dgs = sLFGMgr->GetSelectedDungeons(g);
if (!dgs.count(entry.req.instance_id)) continue;
uint8 const roles = sLFGMgr->GetRoles(g);
if (roles & lfg::PLAYER_ROLE_TANK) ++q_t;
else if (roles & lfg::PLAYER_ROLE_HEALER) ++q_h;
else ++q_d;
}
// Composition target. 5-man dungeon default: 1T/1H/3D minus
// human player (-1 to whichever role they queued as — but
// approximate by subtracting from DPS since most humans queue
// DPS; if T/H human, we'd over-spawn by one which the LFG
// queue de-dups anyway). For raids the kind sets bigger values
// via NeedsFor in BotQueueFiller — here we hard-code 5-man
// since that's what dungeon top-ups are about.
uint8 target_t = 1, target_h = 1, target_d = 3;
if (entry.req.kind == Fleet::BotQueueFiller::QueueKind::Raid10)
{ target_t = 2; target_h = 3; target_d = 5; }
else if (entry.req.kind == Fleet::BotQueueFiller::QueueKind::Raid20)
{ target_t = 2; target_h = 4; target_d = 14; }
const uint8 need_t = (q_t >= target_t) ? 0 : uint8(target_t - q_t);
const uint8 need_h = (q_h >= target_h) ? 0 : uint8(target_h - q_h);
const uint8 need_d = (q_d >= target_d) ? 0 : uint8(target_d - q_d);
if (need_t + need_h + need_d == 0)
{
TC_LOG_INFO("playerbot.v2",
"[LfgTopUp] dungeon={} queue full ({}T/{}H/{}D); skipping",
entry.req.instance_id, q_t, q_h, q_d);
continue;
}
Fleet::BotQueueFiller::FillRequest req = entry.req;
req.needs_tank_override = need_t;
req.needs_healer_override = need_h;
req.needs_dps_override = need_d;
// Pass null player — the original is potentially offline / moved
// by now. BotQueueFiller doesn't strictly need the pointer; it
// uses req.faction and target_level_override for filtering.
req.requesting_player = nullptr;
TC_LOG_INFO("playerbot.v2",
"[LfgTopUp] dungeon={} bot-queued so far {}T/{}H/{}D; refilling {}T/{}H/{}D",
entry.req.instance_id, q_t, q_h, q_d, need_t, need_h, need_d);
Fleet::BotQueueFiller filler;
filler.Fill(req);
}
}
void Module::OnRewardQuest(Player* player, uint32 quest_id,
uint32 rewardType, uint32 rewardId)
{
if (!initialized_ || !player)
return;
WorldSession* sess = player->GetSession();
if (!sess || sess->IsBot())
return;
Quest const* quest = sObjectMgr->GetQuestTemplate(quest_id);
if (!quest)
return;
LootItemType const type = static_cast<LootItemType>(rewardType);
uint32 const account_id = sess->GetAccountId();
std::vector<BotId> bots = Services::Altbots().AltsOfAccount(account_id);
{
auto owned = Services::Owners().BotsOwnedBy(account_id);
for (BotId id : owned)
if (std::find(bots.begin(), bots.end(), id) == bots.end())
bots.push_back(id);
}
TC_LOG_ERROR("playerbot.v2",
"[OnRewardQuest] owner={} quest={} type={} id={} targets={} ownerLvl={} ownerXP={} ownerMoney={} ownerXPVal={}",
player->GetName(), quest_id, rewardType, rewardId, bots.size(),
uint32(player->GetLevel()), player->GetXP(), player->GetMoney(),
quest->XPValue(player));
uint32 done = 0;
for (BotId id : bots)
{
Player* bot = ObjectAccessor::FindConnectedPlayer(
ObjectGuid::Create<HighGuid::Player>(id));
if (!bot || !bot->IsInWorld())
continue;
// Class-equivalent quest (e.g. 28767 → 28757)
uint32 botQuestId = FindActiveEquivalentOnBot(bot, quest_id);
if (!botQuestId)
botQuestId = ResolveBotQuestId(bot, quest_id);
if (!botQuestId)
{
TC_LOG_ERROR("playerbot.v2",
"[OnRewardQuest] skip {} (no class-equivalent for quest {})",
bot->GetName(), quest_id);
continue;
}
Quest const* botQuest = sObjectMgr->GetQuestTemplate(botQuestId);
if (!botQuest)
continue;
// Already rewarded — blocks double turn-in
if (bot->IsQuestRewarded(botQuestId) ||
bot->GetQuestStatus(botQuestId) == QUEST_STATUS_REWARDED)
{
TC_LOG_ERROR("playerbot.v2",
"[OnRewardQuest] skip {} already rewarded botQ={}",
bot->GetName(), botQuestId);
continue;
}
QuestStatus st = bot->GetQuestStatus(botQuestId);
if (st == QUEST_STATUS_NONE)
{
if (!bot->CanAddQuest(botQuest, false))
continue;
bot->AddQuestAndCheckCompletion(botQuest, nullptr);
st = bot->GetQuestStatus(botQuestId);
}
if (st == QUEST_STATUS_REWARDED || bot->IsQuestRewarded(botQuestId))
continue;
// Bag items for ITEM objectives only
if (!EnsureBotQuestItems(bot, botQuest))
{
TC_LOG_ERROR("playerbot.v2",
"[OnRewardQuest] skip {} item grant failed botQ={}",
bot->GetName(), botQuestId);
continue;
}
st = bot->GetQuestStatus(botQuestId);
if (st == QUEST_STATUS_INCOMPLETE)
{
if (bot->CanCompleteQuest(botQuestId))
bot->CompleteQuest(botQuestId);
else
{
// Owner already turned in — force objectives + complete
TC_LOG_ERROR("playerbot.v2",
"[OnRewardQuest] {} forcing complete botQ={} (owner turned in)",
bot->GetName(), botQuestId);
ForceBotQuestComplete(bot, botQuest);
}
st = bot->GetQuestStatus(botQuestId);
}
if (st != QUEST_STATUS_COMPLETE)
{
TC_LOG_ERROR("playerbot.v2",
"[OnRewardQuest] skip {} botQ={} status={}",
bot->GetName(), botQuestId, uint32(st));
continue;
}
if (!bot->CanRewardQuest(botQuest, false))
{
TC_LOG_ERROR("playerbot.v2",
"[OnRewardQuest] skip {} CanRewardQuest botQ={}",
bot->GetName(), botQuestId);
continue;
}
uint32 choice = rewardId;
if (!bot->CanRewardQuest(botQuest, type, choice, false))
{
TC_LOG_ERROR("playerbot.v2",
"[OnRewardQuest] skip {} no valid reward choice botQ={} ownerChoice={}",
bot->GetName(), botQuestId, rewardId);
continue;
}
uint8 const lvlBefore = bot->GetLevel();
uint32 const xpBefore = bot->GetXP();
uint32 const moneyBefore = bot->GetMoney();
bot->RewardQuest(botQuest, type, choice, nullptr, false);
TC_LOG_ERROR("playerbot.v2",
"[OnRewardQuest] {} OK ownerQ={} botQ={} choice={} lvl {}->{} xp {}->{} money {}->{}",
bot->GetName(), quest_id, botQuestId, choice,
uint32(lvlBefore), uint32(bot->GetLevel()),
xpBefore, bot->GetXP(), moneyBefore, bot->GetMoney());
++done;
}
TC_LOG_ERROR("playerbot.v2",
"[OnRewardQuest] owner={} quest={} done={}",
player->GetName(), quest_id, done);
}
uint32 Module::ApplyAltGear(Player* bot, Player* owner, bool replaceExisting)
{
if (!bot)
return 0;
return ApplyStarterGear(bot, owner, replaceExisting);
}
void Module::OnBGPortFailed(Player* player, uint8 reason_code, uint32 bg_instance_id)
{
if (!initialized_ || !player) return;
// Inert for bots — they port via the V2 BgPortIntent pathway and don't
// use HandleBattleFieldPortOpcode. Diagnostic is for human players.
if (player->GetSession() && player->GetSession()->IsBot()) return;
char const* reason_name = "unknown";
switch (reason_code)
{
case 1: reason_name = "not_in_queue"; break;
case 2: reason_name = "invalid_queue_slot"; break;
case 3: reason_name = "no_group_info"; break;
case 4: reason_name = "ginfo_not_invited"; break;
case 5: reason_name = "bg_instance_gone"; break;
case 6: reason_name = "no_bracket_entry"; break;
case 7: reason_name = "freeze_debuff"; break;
case 8: reason_name = "player_invite_flag_cleared"; break;
}
TC_LOG_WARN("playerbot.v2",
"[OnBGPortFailed] player={} reason={} ({}) bg_instance={}",
player->GetName(), uint32(reason_code), reason_name, bg_instance_id);
}
void Module::OnPathOutcome(uint8 outcome)
{
if (!initialized_) return;
Services::Perf().record_path_outcome(static_cast<PerfCounters::PathOutcome>(outcome));
}
} // namespace Playerbot::V2