Compare commits

..

3 Commits

9 changed files with 419 additions and 172 deletions

View File

@ -62,7 +62,9 @@ void MovementAction::EmitDebugMove(char const* method, char const* generator, fl
NewRpgInfo& info = botAI->rpgInfo;
NewRpgStatus status = info.GetStatus();
bool const inCombat = botAI->GetState() == BOT_STATE_COMBAT;
char const* statusName =
inCombat ? "combat" :
status == RPG_IDLE ? "idle" :
status == RPG_GO_GRIND ? "go-grind" :
status == RPG_GO_CAMP ? "go-camp" :
@ -73,14 +75,22 @@ void MovementAction::EmitDebugMove(char const* method, char const* generator, fl
status == RPG_TRAVEL_FLIGHT ? "travel-flight" :
status == RPG_OUTDOOR_PVP ? "outdoor-pvp" : "?";
// Resolve a human-readable target name from the RPG context. When
// we can name the target (quest objective, wander NPC, flight
// master, travel-node hop, etc.), it replaces the loc=(x,y,z)
// field — names are far more useful than coordinates. When no
// target can be named (combat moves, follow, flee, ad-hoc), we
// fall through to loc=(x,y,z).
// Resolve a human-readable target name. In combat, the bot is
// actively engaging an enemy that is unrelated to the RPG state's
// target — show that enemy instead of the now-stale RPG goal.
// Out of combat, fall back to the RPG context: quest objective,
// wander NPC, flight master, etc. Names are far more useful than
// coordinates; loc=(x,y,z) only when nothing nameable applies.
std::string targetName;
switch (status)
if (inCombat)
{
Unit* current = *botAI->GetAiObjectContext()->GetValue<Unit*>("current target");
Unit* enemyPlayer = *botAI->GetAiObjectContext()->GetValue<Unit*>("enemy player target");
Unit* enemy = current ? current : enemyPlayer;
if (enemy)
targetName = std::string("vs:") + enemy->GetName();
}
else switch (status)
{
case RPG_DO_QUEST:
if (auto* data = std::get_if<NewRpgInfo::DoQuest>(&info.data))
@ -164,6 +174,11 @@ void MovementAction::EmitDebugMove(char const* method, char const* generator, fl
<< " | " << statusName
<< " | " << std::fixed << std::setprecision(2) << dis << " yard"
<< " | " << (targetName.empty() ? "-" : targetName.c_str());
// Surface the RPG MoveFarTo retry counter so when bots get stuck
// it's obvious from the whisper alone (retry=N/MAX) — and the
// "give-up" event emitters below show retry=MAX/MAX explicitly.
if (info.moveRetryCount > 0)
out << " | retry=" << uint32(info.moveRetryCount) << "/" << uint32(NewRpgInfo::MAX_MOVE_RETRIES);
if (extra && *extra)
out << " | " << extra;
botAI->TellMasterNoFacing(out);

View File

@ -155,11 +155,22 @@ bool NewRpgGoGrindAction::Execute(Event /*event*/)
if (auto* data = std::get_if<NewRpgInfo::GoGrind>(&botAI->rpgInfo.data))
{
if (MoveFarTo(data->pos))
{
botAI->rpgInfo.moveRetryCount = 0;
return true;
// Small nudge so the next tick's MoveFarTo starts from a
// slightly different position. Kept small so it doesn't look
// like the bot is abandoning its destination.
return MoveRandomNear(10.0f);
}
// Reference pattern (TravelTarget retry counter): count
// consecutive MoveFarTo failures, give up after N tries by
// transitioning out of the stuck state instead of nudging in
// place. Idle lets the status picker rotate to a new state.
if (++botAI->rpgInfo.moveRetryCount >= NewRpgInfo::MAX_MOVE_RETRIES)
{
EmitDebugMove("MoveFar", "give-up",
data->pos.GetPositionX(), data->pos.GetPositionY(), data->pos.GetPositionZ(),
"idle");
botAI->rpgInfo.ChangeToIdle();
}
return true; // consume tick, no nudge
}
return false;
@ -173,8 +184,18 @@ bool NewRpgGoCampAction::Execute(Event /*event*/)
if (auto* data = std::get_if<NewRpgInfo::GoCamp>(&botAI->rpgInfo.data))
{
if (MoveFarTo(data->pos))
{
botAI->rpgInfo.moveRetryCount = 0;
return true;
return MoveRandomNear(10.0f);
}
if (++botAI->rpgInfo.moveRetryCount >= NewRpgInfo::MAX_MOVE_RETRIES)
{
EmitDebugMove("MoveFar", "give-up",
data->pos.GetPositionX(), data->pos.GetPositionY(), data->pos.GetPositionZ(),
"idle");
botAI->rpgInfo.ChangeToIdle();
}
return true;
}
return false;
@ -230,11 +251,23 @@ bool NewRpgWanderNpcAction::Execute(Event /*event*/)
else
{
if (MoveWorldObjectTo(data.npcOrGo))
{
botAI->rpgInfo.moveRetryCount = 0;
return true;
// NPC pathing failed (random offset in a wall, mmap hiccup, etc).
// Take a small random step so the next tick retries from a
// different spot instead of staring at the NPC from afar.
return MoveRandomNear(15.0f);
}
// Retry counter (reference pattern): give up after N failures
// by clearing the picked NPC so next tick picks a different
// one. No nudge — stand still until retry.
if (++botAI->rpgInfo.moveRetryCount >= NewRpgInfo::MAX_MOVE_RETRIES)
{
EmitDebugMove("MoveFar", "give-up",
bot->GetPositionX(), bot->GetPositionY(), bot->GetPositionZ(),
"drop-npc");
data.npcOrGo = ObjectGuid();
data.lastReach = 0;
botAI->rpgInfo.moveRetryCount = 0;
}
return true;
}
return true;
@ -267,14 +300,23 @@ bool NewRpgDoQuestAction::Execute(Event /*event*/)
bool NewRpgDoQuestAction::DoIncompleteQuest(NewRpgInfo::DoQuest& data)
{
uint32 questId = data.questId;
if (data.pos != WorldPosition())
uint32 const questId = data.questId;
// === Spawn-index pipeline ===
// Reference (cmangos) per-spawn pattern: walk to specific known
// spawns of the current objective one by one, advance through the
// candidate list on per-spawn timeout, refresh the list when the
// objective makes progress (so the list reflects what's still
// needed). No POI cluster roam, no random nudging.
// 1. Detect objective completion. If the current objective is done,
// drop the cached spawn list so we re-fetch for the next
// incomplete objective on this tick.
if (!data.candidateSpawns.empty())
{
/// @TODO: extract to a new function
int32 currentObjective = data.objectiveIdx;
// check if the objective has completed
int32 const currentObjective = data.objectiveIdx;
Quest const* quest = sObjectMgr->GetQuestTemplate(questId);
const QuestStatusData& q_status = bot->getQuestStatusMap().at(questId);
QuestStatusData const& q_status = bot->getQuestStatusMap().at(questId);
bool completed = true;
if (currentObjective < QUEST_OBJECTIVES_COUNT)
{
@ -287,89 +329,109 @@ bool NewRpgDoQuestAction::DoIncompleteQuest(NewRpgInfo::DoQuest& data)
quest->RequiredItemCount[currentObjective - QUEST_OBJECTIVES_COUNT])
completed = false;
}
// the current objective is completed, clear and find a new objective later
if (completed)
{
data.candidateSpawns.clear();
data.currentSpawnIdx = 0;
data.lastReachPOI = 0;
data.pos = WorldPosition();
data.objectiveIdx = 0;
data.pursuedLootGO.Clear();
data.pursuedUseGO.Clear();
data.pursuedUseTarget.Clear();
}
}
if (data.pos == WorldPosition())
// 2. Fetch spawn candidates if we don't have any. Abandon the
// quest if no spawns are indexed on the bot's current map (the
// quest is for another zone or our index is missing them).
if (data.candidateSpawns.empty())
{
std::vector<POIInfo> poiInfo;
if (!GetQuestPOIPosAndObjectiveIdx(questId, poiInfo))
std::vector<WorldPosition> spawns;
int32 objectiveIdx = 0;
if (!FetchQuestSpawnsForObjective(questId, spawns, objectiveIdx))
{
// can't find a poi pos to go, stop doing quest for now
botAI->lowPriorityQuest.insert(questId);
botAI->rpgStatistic.questAbandoned++;
LOG_DEBUG("playerbots", "[New RPG] {} abandoned quest {} — no spawns indexed",
bot->GetName(), questId);
botAI->rpgInfo.ChangeToIdle();
return true;
}
uint32 rndIdx = urand(0, poiInfo.size() - 1);
G3D::Vector2 nearestPoi = poiInfo[rndIdx].pos;
int32 objectiveIdx = poiInfo[rndIdx].objectiveIdx;
float dx = nearestPoi.x, dy = nearestPoi.y;
// z = MAX_HEIGHT as we do not know accurate z
float dz = std::max(bot->GetMap()->GetHeight(dx, dy, MAX_HEIGHT), bot->GetMap()->GetWaterLevel(dx, dy));
// double check for GetQuestPOIPosAndObjectiveIdx
if (dz == INVALID_HEIGHT || dz == VMAP_INVALID_HEIGHT_VALUE)
return false;
WorldPosition pos(bot->GetMapId(), dx, dy, dz);
data.candidateSpawns = std::move(spawns);
data.currentSpawnIdx = 0;
data.lastReachPOI = 0;
data.pos = pos;
data.objectiveIdx = objectiveIdx;
data.pursuedLootGO.Clear();
data.pursuedUseGO.Clear();
data.pursuedUseTarget.Clear();
}
if (bot->GetDistance(data.pos) > 10.0f && !data.lastReachPOI)
// 3. If we've exhausted the candidate list, abandon (the spawn
// list was sorted by distance and we tried each).
if (data.currentSpawnIdx >= data.candidateSpawns.size())
{
botAI->lowPriorityQuest.insert(questId);
botAI->rpgStatistic.questAbandoned++;
LOG_DEBUG("playerbots", "[New RPG] {} abandoned quest {} — exhausted all {} candidate spawns",
bot->GetName(), questId, static_cast<uint32>(data.candidateSpawns.size()));
botAI->rpgInfo.ChangeToIdle();
return true;
}
WorldPosition const& target = data.candidateSpawns[data.currentSpawnIdx];
// 4. Walk to the current target spawn. Yield to attack-anything
// only if a quest mob for this specific objective is adjacent
// (so we don't walk past the target we just spawned next to).
if (bot->GetDistance(target) > 10.0f && !data.lastReachPOI)
{
// Yield to attack-anything ONLY if a mob needed by this exact
// quest+objective is right next to us. The broad variant (any
// quest in the log) yielded for every nearby mob and derailed
// turn-ins / cross-zone travel through other quests' clusters.
if (HasNearbyQuestMobForObjective(15.0f, data.questId, data.objectiveIdx))
return false;
// Note: previously yielded ~10%/tick when any hostile was
// within 25y. That overrode the do-quest multiplier in
// practice (combined with bots getting aggroed on the way,
// which ALSO bypasses the multiplier via combat engine) and
// bots ended up grinding their way to POIs instead of
// travelling. Quest-mob exception above is kept so we don't
// walk past a quest target while gathering. Anything else
// hostile is the multiplier's job to throttle — and bots
// that DO get aggroed switch to combat engine where the
// class strategy handles it.
if (MoveFarTo(data.pos))
if (MoveFarTo(target))
{
botAI->rpgInfo.moveRetryCount = 0;
return true;
// sampler found nothing — nudge so next tick tries a new pos
return MoveRandomNear(10.0f);
}
// Retry counter: on N consecutive MoveFarTo failures, advance
// to the next candidate spawn rather than sit on an unreachable
// one. If that exhausts the list the abandon branch above
// catches it next tick.
if (++botAI->rpgInfo.moveRetryCount >= NewRpgInfo::MAX_MOVE_RETRIES)
{
std::ostringstream nx;
nx << "next-spawn(" << (data.currentSpawnIdx + 1) << "/"
<< data.candidateSpawns.size() << ")";
EmitDebugMove("MoveFar", "give-up",
target.GetPositionX(), target.GetPositionY(), target.GetPositionZ(),
nx.str().c_str());
++data.currentSpawnIdx;
data.lastReachPOI = 0;
botAI->rpgInfo.moveRetryCount = 0;
}
return true;
}
// Now we are near the quest objective
// kill mobs and looting quest should be done automatically by grind strategy
// 5. At the spawn. Stamp arrival on first reach so the per-spawn
// timeout below has a baseline.
if (!data.lastReachPOI)
{
data.lastReachPOI = getMSTime();
return true;
}
// stayed at this POI for more than 5 minutes
if (GetMSTimeDiffToNow(data.lastReachPOI) >= poiStayTime)
// 6. Per-spawn timeout. The reference's TravelTarget expires after
// a configurable window; we use 30s — long enough to finish a
// melee pull, short enough to advance off an empty/dead spawn.
// On any progression since the list was fetched, refresh so we
// re-sort by distance and pick the next nearest live spawn.
constexpr uint32 perSpawnTimeoutMs = 30 * 1000;
if (GetMSTimeDiffToNow(data.lastReachPOI) >= perSpawnTimeoutMs)
{
bool hasProgression = false;
int32 currentObjective = data.objectiveIdx;
// check if the objective has progression
int32 const currentObjective = data.objectiveIdx;
Quest const* quest = sObjectMgr->GetQuestTemplate(questId);
const QuestStatusData& q_status = bot->getQuestStatusMap().at(questId);
QuestStatusData const& q_status = bot->getQuestStatusMap().at(questId);
if (currentObjective < QUEST_OBJECTIVES_COUNT)
{
if (q_status.CreatureOrGOCount[currentObjective] != 0 && quest->RequiredNpcOrGoCount[currentObjective])
@ -381,28 +443,27 @@ bool NewRpgDoQuestAction::DoIncompleteQuest(NewRpgInfo::DoQuest& data)
quest->RequiredItemCount[currentObjective - QUEST_OBJECTIVES_COUNT])
hasProgression = true;
}
if (!hasProgression)
if (hasProgression)
{
// we has reach the poi for more than 5 mins but no progession
// may not be able to complete this quest, marked as abandoned
/// @TODO: It may be better to make lowPriorityQuest a global set shared by all bots (or saved in db)
botAI->lowPriorityQuest.insert(questId);
botAI->rpgStatistic.questAbandoned++;
LOG_DEBUG("playerbots", "[New RPG] {} marked as abandoned quest {}", bot->GetName(), questId);
botAI->rpgInfo.ChangeToIdle();
// Refresh: re-fetch candidates so the list reflects what's
// still needed and is sorted from the bot's new position.
data.candidateSpawns.clear();
data.currentSpawnIdx = 0;
data.lastReachPOI = 0;
return true;
}
// clear and select another poi later
// No progression at this spawn — advance to the next candidate.
++data.currentSpawnIdx;
data.lastReachPOI = 0;
data.pos = WorldPosition();
data.objectiveIdx = 0;
data.pursuedLootGO.Clear();
data.pursuedUseGO.Clear();
data.pursuedUseTarget.Clear();
return true;
}
// at POI: drive toward specific objectives first
// 7. At spawn, within timeout: drive toward specific objectives.
// Combat strategy engages adjacent quest mobs; loot/use
// actions handle quest GOs and quest items.
if (TryUseQuestItem(data.pursuedUseGO, data.pursuedUseTarget))
return true;
if (TryLootQuestGO(data.pursuedLootGO))
@ -410,96 +471,9 @@ bool NewRpgDoQuestAction::DoIncompleteQuest(NewRpgInfo::DoQuest& data)
if (TryUseQuestGO(data.pursuedUseGO))
return true;
// gather quests: roam for spawns. kill quests: yield to grind.
Quest const* quest = sObjectMgr->GetQuestTemplate(questId);
if (quest)
{
int32 obj = data.objectiveIdx;
bool isGatherObjective = false;
if (obj < QUEST_OBJECTIVES_COUNT)
{
int32 entry = quest->RequiredNpcOrGo[obj];
if (entry < 0) // GO objective
isGatherObjective = true;
if (entry == 0 && obj < QUEST_ITEM_OBJECTIVES_COUNT && quest->RequiredItemId[obj])
isGatherObjective = true;
}
else if (obj < QUEST_OBJECTIVES_COUNT + QUEST_ITEM_OBJECTIVES_COUNT)
{
isGatherObjective = true;
}
// source-item quest: need to find the target to use it on
if (quest->GetSrcItemId())
isGatherObjective = true;
if (isGatherObjective)
return MoveRandomNear(20.0f);
}
// Kill-quest scout: at POI for 30s+ with no quest mob in sight
// means this cluster is empty. Switch to a different POI candidate
// (>50y away) if one exists; otherwise roam in place.
constexpr uint32 scoutTimeoutMs = 30 * 1000;
if (data.lastReachPOI && GetMSTimeDiffToNow(data.lastReachPOI) >= scoutTimeoutMs &&
!HasNearbyQuestMob(30.0f))
{
std::vector<POIInfo> poiInfo;
if (GetQuestPOIPosAndObjectiveIdx(questId, poiInfo))
{
std::vector<size_t> alternatives;
for (size_t i = 0; i < poiInfo.size(); ++i)
{
float dx = poiInfo[i].pos.x - data.pos.GetPositionX();
float dy = poiInfo[i].pos.y - data.pos.GetPositionY();
if (dx * dx + dy * dy > 50.0f * 50.0f)
alternatives.push_back(i);
}
if (!alternatives.empty())
{
size_t pickIdx = alternatives[urand(0, alternatives.size() - 1)];
G3D::Vector2 newPoi = poiInfo[pickIdx].pos;
float dz = std::max(bot->GetMap()->GetHeight(newPoi.x, newPoi.y, MAX_HEIGHT),
bot->GetMap()->GetWaterLevel(newPoi.x, newPoi.y));
if (dz != INVALID_HEIGHT && dz != VMAP_INVALID_HEIGHT_VALUE)
{
data.pos = WorldPosition(bot->GetMapId(), newPoi.x, newPoi.y, dz);
data.objectiveIdx = poiInfo[pickIdx].objectiveIdx;
data.lastReachPOI = 0;
data.pursuedLootGO.Clear();
data.pursuedUseGO.Clear();
data.pursuedUseTarget.Clear();
return true;
}
}
}
return MoveRandomNear(20.0f);
}
// kill quest: walk toward the marker before handing off to grind.
// lastReachPOI trips at ~10y so without this the bot fights on the
// edge and never reaches the dense cluster. Skip if a quest mob is
// in sight (might be the target) or a hostile is mid-pull.
if (bot->GetDistance(data.pos) > 5.0f)
{
if (HasNearbyQuestMob(30.0f))
return false;
GuidVector nearby = AI_VALUE(GuidVector, "possible targets");
bool hostileClose = false;
for (ObjectGuid guid : nearby)
{
Unit* u = botAI->GetUnit(guid);
if (u && u->IsAlive() && bot->GetDistance(u) < 15.0f)
{
hostileClose = true;
break;
}
}
if (!hostileClose)
return MoveFarTo(data.pos);
}
// yield to grind
// Yield this tick to combat/grind. No POI roam, no MoveRandomNear:
// bot stays at the spawn until either combat engages or the
// per-spawn timeout expires.
return false;
}
@ -551,8 +525,21 @@ bool NewRpgDoQuestAction::DoCompletedQuest(NewRpgInfo::DoQuest& data)
if (bot->GetDistance(data.pos) > 10.0f && !data.lastReachPOI)
{
if (MoveFarTo(data.pos))
{
botAI->rpgInfo.moveRetryCount = 0;
return true;
return MoveRandomNear(10.0f);
}
// Retry counter (reference pattern): mark quest as abandoned
// if turn-in POI is unreachable repeatedly so the bot doesn't
// sit on a broken handler.
if (++botAI->rpgInfo.moveRetryCount >= NewRpgInfo::MAX_MOVE_RETRIES)
{
EmitDebugMove("MoveFar", "give-up",
data.pos.GetPositionX(), data.pos.GetPositionY(), data.pos.GetPositionZ(),
"idle(turn-in)");
botAI->rpgInfo.ChangeToIdle();
}
return true;
}
// Now we are near the qoi of reward

View File

@ -20,6 +20,7 @@
#include "Object.h"
#include "ObjectAccessor.h"
#include "OutdoorPvPMgr.h"
#include "QuestSpawnIndex.h"
#include "ObjectDefines.h"
#include "ObjectGuid.h"
#include "ObjectMgr.h"
@ -1368,6 +1369,89 @@ bool NewRpgBaseAction::GetQuestPOIPosAndObjectiveIdx(uint32 questId, std::vector
return true;
}
bool NewRpgBaseAction::FetchQuestSpawnsForObjective(uint32 questId,
std::vector<WorldPosition>& outSpawns,
int32& outObjectiveIdx)
{
outSpawns.clear();
Quest const* quest = sObjectMgr->GetQuestTemplate(questId);
if (!quest)
return false;
auto qStatusIt = bot->getQuestStatusMap().find(questId);
if (qStatusIt == bot->getQuestStatusMap().end())
return false;
QuestStatusData const& q_status = qStatusIt->second;
if (q_status.Status != QUEST_STATUS_INCOMPLETE)
return false;
uint32 const botMapId = bot->GetMapId();
// Iterate creature/GO objectives first, then required-item drops.
// The first one with at least one indexed spawn on the bot's map
// wins. Subsequent objectives are picked next call after this one
// completes or the spawn list is exhausted.
auto tryFetch = [&](int32 entry, int32 idx) -> bool
{
if (!entry)
return false;
auto const& spawns = sQuestSpawnIndex->GetSpawns(botMapId, entry);
if (spawns.empty())
return false;
outSpawns = spawns;
outObjectiveIdx = idx;
return true;
};
for (int i = 0; i < QUEST_OBJECTIVES_COUNT; ++i)
{
int32 npcOrGo = quest->RequiredNpcOrGo[i];
if (!npcOrGo)
continue;
if (q_status.CreatureOrGOCount[i] >= quest->RequiredNpcOrGoCount[i])
continue; // objective complete
if (tryFetch(npcOrGo, i))
break;
}
if (outSpawns.empty())
{
// Required-item drops: source creature is encoded on the
// template (the loot-source mob). Use the creature spawn
// index for each ItemDrop entry.
for (int i = 0; i < QUEST_ITEM_OBJECTIVES_COUNT; ++i)
{
uint32 itemId = quest->RequiredItemId[i];
if (!itemId)
continue;
if (q_status.ItemCount[i] >= quest->RequiredItemCount[i])
continue;
// ItemDrop entries are encoded as the drop-source creature
// template id (positive). Fall back to scanning sObjectMgr
// creature-loot if/when we need stricter sourcing; for now
// this is best-effort.
int32 dropFrom = static_cast<int32>(quest->ItemDrop[i]);
if (dropFrom && tryFetch(dropFrom, QUEST_OBJECTIVES_COUNT + i))
break;
}
}
if (outSpawns.empty())
return false;
// Sort by distance from the bot so currentSpawnIdx=0 is the
// nearest. Reference's getNextDestination effectively does this
// each pick.
WorldPosition botPos(bot);
std::sort(outSpawns.begin(), outSpawns.end(),
[&botPos](WorldPosition const& a, WorldPosition const& b)
{ return botPos.sqDistance(a) < botPos.sqDistance(b); });
return true;
}
WorldPosition NewRpgBaseAction::SelectRandomGrindPos(Player* bot)
{
const std::vector<WorldLocation>& locs = sTravelMgr.GetLocsPerLevelCache(bot->GetLevel());

View File

@ -76,6 +76,16 @@ protected:
protected:
bool GetQuestPOIPosAndObjectiveIdx(uint32 questId, std::vector<POIInfo>& poiInfo, bool toComplete = false);
// Reference per-spawn destination pattern: pick the first
// incomplete objective on `questId`, look up its spawns
// (creature OR gameobject — RequiredNpcOrGo encodes both) on
// the bot's current map, sort by distance from the bot, and
// return them in `outSpawns` with the resolved `outObjectiveIdx`.
// Returns false if no incomplete objective has spawns on the
// current map.
bool FetchQuestSpawnsForObjective(uint32 questId,
std::vector<WorldPosition>& outSpawns,
int32& outObjectiveIdx);
static WorldPosition SelectRandomGrindPos(Player* bot);
static WorldPosition SelectRandomCampPos(Player* bot);
bool SelectRandomFlightTaxiNode(uint32& flightMasterEntry, WorldPosition& flightMasterPos, std::vector<uint32>& path);

View File

@ -77,6 +77,7 @@ void NewRpgInfo::Reset()
{
data = Idle{};
startT = getMSTime();
moveRetryCount = 0;
}
NewRpgStatus NewRpgInfo::GetStatus()

View File

@ -46,7 +46,17 @@ struct NewRpgInfo
const Quest* quest{nullptr};
uint32 questId{0};
int32 objectiveIdx{0};
// Turn-in POI (DoCompletedQuest). Kept POI-based since this is
// the quest-giver location, not a mob spawn.
WorldPosition pos{};
// Reference (cmangos) per-spawn destination pattern for
// incomplete objectives: candidate spawn positions sorted by
// distance, walked one-by-one (current =
// candidateSpawns[currentSpawnIdx]) instead of POI-wander.
// Refreshed when the objective changes or the list is
// exhausted.
std::vector<WorldPosition> candidateSpawns;
uint32 currentSpawnIdx{0};
uint32 lastReachPOI{0};
// committed target per objective type. stops zig-zagging in
// dense spawn clusters when "nearest" would flip each tick.
@ -78,6 +88,14 @@ struct NewRpgInfo
uint32 startT{0}; // start timestamp of the current status
// Counts consecutive MoveFarTo failures for the current state.
// Reset on every status change (via Reset) and on every successful
// MoveFarTo. When it crosses MAX_MOVE_RETRIES the failing action
// gives up and transitions out of the current state instead of
// sitting on a stuck objective forever.
uint8 moveRetryCount{0};
static constexpr uint8 MAX_MOVE_RETRIES = 10;
using RpgData = std::variant<
Idle,
GoGrind,

View File

@ -0,0 +1,60 @@
/*
* Copyright (C) 2016+ AzerothCore <www.azerothcore.org>, released under GNU AGPL v3 license, you may redistribute it
* and/or modify it under version 3 of the License, or (at your option), any later version.
*/
#include "QuestSpawnIndex.h"
#include "CreatureData.h"
#include "GameObjectData.h"
#include "Log.h"
#include "ObjectMgr.h"
QuestSpawnIndex* QuestSpawnIndex::instance()
{
static QuestSpawnIndex inst;
return &inst;
}
void QuestSpawnIndex::Init()
{
if (_initialized)
return;
uint32 creatures = 0;
uint32 gos = 0;
for (auto const& kv : sObjectMgr->GetAllCreatureData())
{
CreatureData const& cd = kv.second;
if (!cd.id1)
continue;
Key const key{cd.mapid, static_cast<int32>(cd.id1)};
_index[key].emplace_back(cd.mapid, cd.posX, cd.posY, cd.posZ, cd.orientation);
++creatures;
}
for (auto const& kv : sObjectMgr->GetAllGOData())
{
GameObjectData const& gd = kv.second;
if (!gd.id)
continue;
// Negative entry encodes GO (matches Quest::RequiredNpcOrGo
// convention used by the do-quest action callers).
Key const key{gd.mapid, -static_cast<int32>(gd.id)};
_index[key].emplace_back(gd.mapid, gd.posX, gd.posY, gd.posZ, gd.orientation);
++gos;
}
_initialized = true;
LOG_INFO("playerbots",
">> QuestSpawnIndex: indexed {} creature spawns + {} GO spawns ({} unique keys).",
creatures, gos, static_cast<uint32>(_index.size()));
}
std::vector<WorldPosition> const& QuestSpawnIndex::GetSpawns(uint32 mapId, int32 entry) const
{
auto it = _index.find(Key{mapId, entry});
return (it != _index.end()) ? it->second : _empty;
}

View File

@ -0,0 +1,70 @@
/*
* Copyright (C) 2016+ AzerothCore <www.azerothcore.org>, released under GNU AGPL v3 license, you may redistribute it
* and/or modify it under version 3 of the License, or (at your option), any later version.
*/
#ifndef _PLAYERBOT_QUESTSPAWNINDEX_H
#define _PLAYERBOT_QUESTSPAWNINDEX_H
#include <unordered_map>
#include <vector>
#include "Define.h"
#include "TravelMgr.h"
// Maps `(mapId, RequiredNpcOrGo-style entry)` → list of spawn
// positions for that template on that map. The entry convention
// matches Quest::RequiredNpcOrGo: positive value = creature template
// id, negative value = gameobject template id (use the absolute
// value to look up in gameobject_template).
//
// Built once at module startup by scanning sObjectMgr's
// CreatureDataStore + GameObjectDataStore. Read-only thereafter.
//
// Used by the RPG do-quest action to walk directly to specific known
// spawns of a quest objective instead of wandering inside a POI
// cluster. Mirrors the reference's TravelMgr per-spawn destination
// indexing.
class QuestSpawnIndex
{
public:
static QuestSpawnIndex* instance();
// Build the index from sObjectMgr's spawn data. Safe to call
// multiple times — second+ calls are no-ops. Call once after
// sObjectMgr->LoadCreatures / LoadGameObjects have populated
// their stores.
void Init();
// Returns spawns of `entry` on `mapId`. Empty list if none
// indexed. Stable reference for the lifetime of the index.
std::vector<WorldPosition> const& GetSpawns(uint32 mapId, int32 entry) const;
[[nodiscard]] bool IsInitialized() const { return _initialized; }
private:
QuestSpawnIndex() = default;
bool _initialized{false};
struct Key
{
uint32 mapId;
int32 entry;
bool operator==(Key const& o) const { return mapId == o.mapId && entry == o.entry; }
};
struct KeyHash
{
std::size_t operator()(Key const& k) const noexcept
{
return (std::size_t(k.mapId) << 32) ^ std::size_t(uint32(k.entry));
}
};
std::unordered_map<Key, std::vector<WorldPosition>, KeyHash> _index;
std::vector<WorldPosition> _empty;
};
#define sQuestSpawnIndex QuestSpawnIndex::instance()
#endif

View File

@ -20,6 +20,7 @@
#include "ModelIgnoreFlags.h"
#include "PathGenerator.h"
#include "Playerbots.h"
#include "QuestSpawnIndex.h"
#include "RaceMgr.h"
#include "TransportMgr.h"
#include "VMapFactory.h"
@ -3584,6 +3585,7 @@ void TravelMgr::Init()
PrepareDestinationCache();
}
sTravelNodeMap.Init();
sQuestSpawnIndex->Init();
}
TravelMgr::FlightMasterInfo const* TravelMgr::GetNearestFlightMasterInfo(Player* bot) const