feat(Core/RPG): Per-spawn destination pattern for incomplete quests (drops POI roam)

This commit is contained in:
bash 2026-06-01 00:22:00 +02:00
parent 5ffd2ad89c
commit 61067a302e
7 changed files with 321 additions and 157 deletions

View File

@ -287,14 +287,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)
{
@ -307,96 +316,103 @@ 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;
}
// Retry counter (reference pattern): on N consecutive
// failures, drop this objective and go idle so the picker can
// try another quest / state.
// 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)
botAI->rpgInfo.ChangeToIdle();
{
++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])
@ -408,28 +424,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))
@ -437,96 +452,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;
}

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

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

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