feat(Core/RPG): MoveFarTo flow, quest-pursuit at POI, MoveRandomNear retries

This commit is contained in:
bash 2026-05-10 17:30:56 +02:00
parent 1ae72b0888
commit 3269d1a4b3
11 changed files with 1099 additions and 211 deletions

View File

@ -6,10 +6,10 @@
#include "CheckValuesAction.h"
#include "Event.h"
#include "ObjectGuid.h"
#include "ServerFacade.h"
#include "PlayerbotAI.h"
#include "TravelNode.h"
#include "AiObjectContext.h"
CheckValuesAction::CheckValuesAction(PlayerbotAI* botAI) : Action(botAI, "check values") {}
@ -21,11 +21,6 @@ bool CheckValuesAction::Execute(Event /*event*/)
botAI->Ping(bot->GetPositionX(), bot->GetPositionY());
}
if (botAI->HasStrategy("map", BOT_STATE_NON_COMBAT) || botAI->HasStrategy("map full", BOT_STATE_NON_COMBAT))
{
TravelNodeMap::instance().manageNodes(bot, botAI->HasStrategy("map full", BOT_STATE_NON_COMBAT));
}
GuidVector possible_targets = *context->GetValue<GuidVector>("possible targets");
GuidVector all_targets = *context->GetValue<GuidVector>("all targets");
GuidVector npcs = *context->GetValue<GuidVector>("nearest npcs");

View File

@ -19,6 +19,7 @@
#include "PathGenerator.h"
#include "Player.h"
#include "PlayerbotAI.h"
#include "Playerbots.h"
#include "QuestDef.h"
#include "Random.h"
#include "SharedDefines.h"
@ -120,17 +121,8 @@ bool NewRpgStatusUpdateAction::Execute(Event /*event*/)
}
break;
}
case RPG_TRAVEL_FLIGHT:
{
auto& data = std::get<NewRpgInfo::TravelFlight>(info.data);
if (data.inFlight && !bot->IsInFlight())
{
// flight arrival
info.ChangeToIdle();
return true;
}
break;
}
// RPG_TRAVEL_FLIGHT arrival is handled inside NewRpgTravelFlightAction
// so the flight action owns both take-off and landing transitions.
case RPG_REST:
{
// REST -> IDLE
@ -301,6 +293,9 @@ bool NewRpgDoQuestAction::DoIncompleteQuest(NewRpgInfo::DoQuest& data)
data.lastReachPOI = 0;
data.pos = WorldPosition();
data.objectiveIdx = 0;
data.pursuedLootGO.Clear();
data.pursuedUseGO.Clear();
data.pursuedUseTarget.Clear();
}
}
if (data.pos == WorldPosition())
@ -329,15 +324,31 @@ bool NewRpgDoQuestAction::DoIncompleteQuest(NewRpgInfo::DoQuest& data)
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)
{
// yield to attack-anything if a quest mob is right next to us
if (HasNearbyQuestMob(15.0f))
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))
return true;
// Long-range sampler couldn't land a candidate — nudge the
// bot a short distance so the next tick retries from a
// different position instead of sitting idle.
// sampler found nothing — nudge so next tick tries a new pos
return MoveRandomNear(10.0f);
}
// Now we are near the quest objective
@ -382,14 +393,72 @@ bool NewRpgDoQuestAction::DoIncompleteQuest(NewRpgInfo::DoQuest& data)
data.lastReachPOI = 0;
data.pos = WorldPosition();
data.objectiveIdx = 0;
data.pursuedLootGO.Clear();
data.pursuedUseGO.Clear();
data.pursuedUseTarget.Clear();
return true;
}
// At the POI: keep the bot actively placed but avoid large
// random 20yd hops that look like pacing back and forth. A small
// ~8yd wander reads as the bot looking around while grind/loot
// strategies do their work.
return MoveRandomNear(8.0f);
// at POI: drive toward specific objectives first
if (TryUseQuestItem(data.pursuedUseGO, data.pursuedUseTarget))
return true;
if (TryLootQuestGO(data.pursuedLootGO))
return true;
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: 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
return false;
}
bool NewRpgDoQuestAction::DoCompletedQuest(NewRpgInfo::DoQuest& data)
@ -423,6 +492,15 @@ bool NewRpgDoQuestAction::DoCompletedQuest(NewRpgInfo::DoQuest& data)
data.lastReachPOI = 0;
data.pos = pos;
data.objectiveIdx = -1;
// Drop the spline + lastPath that DoIncompleteQuest committed
// to the now-completed objective. Without this, MoveFarTo on
// the next tick hits the bot->isMoving() / lastPath-reuse
// early-exits at the top of MoveFarTo and rides the stale
// path instead of replanning toward the turn-in POI. (This
// is what `.playerbot bot self` masks by recreating the AI.)
bot->GetMotionMaster()->Clear();
AI_VALUE(LastMovement&, "last movement").clear();
}
if (data.pos == WorldPosition())
@ -453,7 +531,9 @@ bool NewRpgDoQuestAction::DoCompletedQuest(NewRpgInfo::DoQuest& data)
botAI->rpgInfo.ChangeToIdle();
return true;
}
return false;
// waiting for SearchQuestGiverAndAcceptOrReward to pick up the NPC;
// wander instead of false so we don't fall through to grind
return MoveRandomNear(15.0f);
}
bool NewRpgTravelFlightAction::Execute(Event /*event*/)
@ -464,6 +544,22 @@ bool NewRpgTravelFlightAction::Execute(Event /*event*/)
return false;
auto& data = *dataPtr;
// Arrival: we had boarded a flight (data.inFlight) and we're no longer in
// it → we just landed. Special-case Rut'theran: walk to the portal GO so
// it teleports the bot into Darnassus, flipping the zone to AREA_DARNASSUS
// so this branch falls through to ChangeToIdle on the next tick.
if (data.inFlight && !bot->IsInFlight())
{
if (bot->GetZoneId() == AREA_TELDRASSIL)
{
static WorldPosition const rutTheranPortalEntrance(1, 8799.41f, 969.787f, 26.2409f, 0.0f);
return MoveFarTo(rutTheranPortalEntrance);
}
info.ChangeToIdle();
return true;
}
if (bot->IsInFlight())
{
data.inFlight = true;
@ -479,19 +575,9 @@ bool NewRpgTravelFlightAction::Execute(Event /*event*/)
info.ChangeToIdle();
return true;
}
if (bot->GetDistance(flightMaster) > INTERACTION_DISTANCE)
return MoveFarTo(flightMaster);
std::vector<uint32> nodes = data.path;
botAI->RemoveShapeshift();
if (bot->IsMounted())
bot->Dismount();
if (!bot->ActivateTaxiPathTo(nodes, flightMaster, 0))
if (!TakeFlight(data.path, flightMaster))
{
LOG_DEBUG("playerbots", "[New RPG] {} active taxi path {} (from {} to {}) failed", bot->GetName(),
flightMaster->GetEntry(), nodes[0], nodes[nodes.size() - 1]);
info.ChangeToIdle();
return true;
}

File diff suppressed because it is too large Load Diff

View File

@ -33,6 +33,7 @@ protected:
bool MoveWorldObjectTo(ObjectGuid guid, float distance = INTERACTION_DISTANCE);
bool MoveRandomNear(float moveStep = 50.0f, MovementPriority priority = MovementPriority::MOVEMENT_NORMAL, WorldObject* center = nullptr);
bool ForceToWait(uint32 duration, MovementPriority priority = MovementPriority::MOVEMENT_NORMAL);
bool TakeFlight(std::vector<uint32> const& taxiNodes, Creature* flightMaster);
/* QUEST RELATED CHECK */
ObjectGuid ChooseNpcOrGameObjectToInteract(bool questgiverOnly = false, float distanceLimit = 0.0f);
@ -50,6 +51,23 @@ protected:
bool TurnInQuest(Quest const* quest, ObjectGuid guid);
bool OrganizeQuestLog();
/* QUEST PROGRESSION HELPERS (at POI) */
// Walk to a GO that drops a needed quest item. The loot strategy
// opens and loots it once in range.
bool TryLootQuestGO(ObjectGuid& pursuedGO, float searchRange = 60.0f);
// Walk to / use a GO that is itself the objective (rune, lever,
// altar, coffin — RequiredNpcOrGo with a negative entry).
bool TryUseQuestGO(ObjectGuid& pursuedGO, float searchRange = 60.0f);
// Fire a quest item's OnUse spell at the right target: a spell-focus
// GO (moonwell), a required creature, or the bot itself.
bool TryUseQuestItem(ObjectGuid& pursuedGO, ObjectGuid& pursuedTarget, float searchRange = 60.0f);
// True when a quest-relevant mob is within range — used during
// travel so we yield to attack-anything instead of running past.
bool HasNearbyQuestMob(float range = 20.0f);
protected:
bool GetQuestPOIPosAndObjectiveIdx(uint32 questId, std::vector<POIInfo>& poiInfo, bool toComplete = false);
static WorldPosition SelectRandomGrindPos(Player* bot);
@ -60,15 +78,17 @@ protected:
protected:
/* FOR MOVE FAR */
const float pathFinderDis = 70.0f;
// Time without real progress toward dest before MoveFarTo
// falls back to teleport recovery. Kept short enough that a
// bot truly oscillating around an unreachable destination
// (mmap returning non-progressing partial paths, or NOPATH +
// cone fallback wandering) doesn't spin for 5 minutes before
// the teleport fires, but long enough that a genuine long
// walk that is slowly making progress never triggers it.
const uint32 stuckTime = 90 * 1000;
// Distance at which MoveFarTo considers the travel-node graph as
// a routing option. Below this, the move is short enough that
// mmap handles it directly. Above this, mmap is *still probed
// first* via the 40-step chained pathfinder; the node graph
// only takes over if mmap can't get within spellDistance of
// the destination.
const float nodeFirstDis = 75.0f;
private:
void StartTravelPlan(WorldPosition dest);
bool UpdateTravelPlan();
};
#endif

View File

@ -6,31 +6,31 @@
void NewRpgInfo::ChangeToGoGrind(WorldPosition pos)
{
startT = getMSTime();
Reset();
data = GoGrind{pos};
}
void NewRpgInfo::ChangeToGoCamp(WorldPosition pos)
{
startT = getMSTime();
Reset();
data = GoCamp{pos};
}
void NewRpgInfo::ChangeToWanderNpc()
{
startT = getMSTime();
Reset();
data = WanderNpc{};
}
void NewRpgInfo::ChangeToWanderRandom()
{
startT = getMSTime();
Reset();
data = WanderRandom{};
}
void NewRpgInfo::ChangeToDoQuest(uint32 questId, const Quest* quest)
{
startT = getMSTime();
Reset();
DoQuest do_quest;
do_quest.questId = questId;
do_quest.quest = quest;
@ -39,7 +39,7 @@ void NewRpgInfo::ChangeToDoQuest(uint32 questId, const Quest* quest)
void NewRpgInfo::ChangeToTravelFlight(uint32 flightMasterEntry, WorldPosition flightMasterPos, std::vector<uint32> path)
{
startT = getMSTime();
Reset();
TravelFlight flight;
flight.flightMasterEntry = flightMasterEntry;
flight.flightMasterPos = flightMasterPos;
@ -58,13 +58,13 @@ void NewRpgInfo::ChangeToOutdoorPvp(ObjectGuid::LowType capturePointSpawnId)
void NewRpgInfo::ChangeToRest()
{
startT = getMSTime();
Reset();
data = Rest{};
}
void NewRpgInfo::ChangeToIdle()
{
startT = getMSTime();
Reset();
data = Idle{};
}
@ -77,14 +77,7 @@ void NewRpgInfo::Reset()
{
data = Idle{};
startT = getMSTime();
}
void NewRpgInfo::SetMoveFarTo(WorldPosition pos)
{
nearestMoveFarDis = FLT_MAX;
stuckTs = 0;
stuckAttempts = 0;
moveFarPos = pos;
ClearTravel();
}
NewRpgStatus NewRpgInfo::GetStatus()

View File

@ -1,6 +1,8 @@
#ifndef _PLAYERBOT_NEWRPGINFO_H
#define _PLAYERBOT_NEWRPGINFO_H
#include <deque>
#include "Define.h"
#include "ObjectGuid.h"
#include "ObjectMgr.h"
@ -8,6 +10,7 @@
#include "Strategy.h"
#include "Timer.h"
#include "TravelMgr.h"
#include "TravelNode.h"
using NewRpgStatusTransitionProb = std::vector<std::vector<int>>;
@ -45,6 +48,11 @@ struct NewRpgInfo
int32 objectiveIdx{0};
WorldPosition pos{};
uint32 lastReachPOI{0};
// committed target per objective type. stops zig-zagging in
// dense spawn clusters when "nearest" would flip each tick.
ObjectGuid pursuedLootGO{}; // GOs we loot (lilies, eggs)
ObjectGuid pursuedUseGO{}; // GOs we click or focus on
ObjectGuid pursuedUseTarget{}; // creature we apply an item to
};
// RPG_TRAVEL_FLIGHT
struct TravelFlight
@ -70,12 +78,10 @@ struct NewRpgInfo
uint32 startT{0}; // start timestamp of the current status
// MOVE_FAR
float nearestMoveFarDis{FLT_MAX};
uint32 stuckTs{0};
uint32 stuckAttempts{0};
WorldPosition moveFarPos;
// END MOVE_FAR
// Travel Node System
TravelPlan travelPlan;
bool HasActiveTravelPlan() const { return travelPlan.IsActive(); }
void ClearTravel() { travelPlan.Reset(); }
using RpgData = std::variant<
Idle,
@ -103,7 +109,6 @@ struct NewRpgInfo
void ChangeToIdle();
bool CanChangeTo(NewRpgStatus status);
void Reset();
void SetMoveFarTo(WorldPosition pos);
std::string ToString();
};

View File

@ -5,11 +5,66 @@
#include "NewRpgStrategy.h"
#include "Action.h"
#include "NewRpgInfo.h"
#include "Player.h"
#include "PlayerbotAI.h"
static bool IsGatherObjectiveForDoQuest(NewRpgInfo::DoQuest const* data)
{
if (!data || !data->quest)
return false;
Quest const* q = data->quest;
int32 obj = data->objectiveIdx;
if (obj < QUEST_OBJECTIVES_COUNT)
{
int32 entry = q->RequiredNpcOrGo[obj];
if (entry < 0) // GO objective
return true;
if (entry == 0 && obj < QUEST_ITEM_OBJECTIVES_COUNT && q->RequiredItemId[obj])
return true;
}
else if (obj < QUEST_OBJECTIVES_COUNT + QUEST_ITEM_OBJECTIVES_COUNT)
{
return true;
}
// source-item quest: need to find the right target to use it on
if (q->GetSrcItemId())
return true;
return false;
}
float NewRpgDoQuestMultiplier::GetValue(Action* action)
{
if (!action || action->getName() != "attack anything")
return 1.0f;
NewRpgInfo& info = botAI->rpgInfo;
if (info.GetStatus() != RPG_DO_QUEST)
return 1.0f;
auto* data = std::get_if<NewRpgInfo::DoQuest>(&info.data);
if (!data)
return 1.0f;
// heading back to turn in, don't get sidetracked
if (data->questId && bot->GetQuestStatus(data->questId) == QUEST_STATUS_COMPLETE)
return 0.15f;
// at POI: gather stays low so mobs don't pull us off the cluster;
// kill runs full so attack-anything drives behavior
if (data->lastReachPOI)
return IsGatherObjectiveForDoQuest(data) ? 0.30f : 1.0f;
// traveling
return 0.20f;
}
NewRpgStrategy::NewRpgStrategy(PlayerbotAI* botAI) : Strategy(botAI) {}
std::vector<NextAction> NewRpgStrategy::getDefaultActions()
{
// the releavance should be greater than grind
// must outrank grind
return {
NextAction("new rpg status update", 11.0f)
};
@ -53,7 +108,8 @@ void NewRpgStrategy::InitTriggers(std::vector<TriggerNode*>& triggers)
new TriggerNode(
"do quest status",
{
NextAction("new rpg do quest", 3.0f)
// 4.5: above attack-anything (4.0), below loot (5.0+)
NextAction("new rpg do quest", 4.5f)
}
)
);
@ -75,6 +131,7 @@ void NewRpgStrategy::InitTriggers(std::vector<TriggerNode*>& triggers)
);
}
void NewRpgStrategy::InitMultipliers(std::vector<Multiplier*>&)
void NewRpgStrategy::InitMultipliers(std::vector<Multiplier*>& multipliers)
{
multipliers.push_back(new NewRpgDoQuestMultiplier(botAI));
}

View File

@ -12,6 +12,13 @@
class PlayerbotAI;
class NewRpgDoQuestMultiplier : public Multiplier
{
public:
NewRpgDoQuestMultiplier(PlayerbotAI* botAI) : Multiplier(botAI, "new rpg do quest") {}
float GetValue(Action* action) override;
};
class NewRpgStrategy : public Strategy
{
public:

View File

@ -72,7 +72,6 @@ bool PlayerbotAIConfig::Initialize()
globalCoolDown = sConfigMgr->GetOption<int32>("AiPlayerbot.GlobalCooldown", 500);
maxWaitForMove = sConfigMgr->GetOption<int32>("AiPlayerbot.MaxWaitForMove", 5000);
disableMoveSplinePath = sConfigMgr->GetOption<int32>("AiPlayerbot.DisableMoveSplinePath", 0);
maxMovementSearchTime = sConfigMgr->GetOption<int32>("AiPlayerbot.MaxMovementSearchTime", 3);
expireActionTime = sConfigMgr->GetOption<int32>("AiPlayerbot.ExpireActionTime", 5000);
dispelAuraDuration = sConfigMgr->GetOption<int32>("AiPlayerbot.DispelAuraDuration", 700);
reactDelay = sConfigMgr->GetOption<int32>("AiPlayerbot.ReactDelay", 100);
@ -678,6 +677,7 @@ bool PlayerbotAIConfig::Initialize()
autoTeleportForLevel = sConfigMgr->GetOption<bool>("AiPlayerbot.AutoTeleportForLevel", false);
autoDoQuests = sConfigMgr->GetOption<bool>("AiPlayerbot.AutoDoQuests", true);
enableNewRpgStrategy = sConfigMgr->GetOption<bool>("AiPlayerbot.EnableNewRpgStrategy", true);
enableTravelNodes = sConfigMgr->GetOption<bool>("AiPlayerbot.EnableTravelNodes", false);
RpgStatusProbWeight[RPG_WANDER_RANDOM] = sConfigMgr->GetOption<int32>("AiPlayerbot.RpgStatusProbWeight.WanderRandom", 15);
RpgStatusProbWeight[RPG_WANDER_NPC] = sConfigMgr->GetOption<int32>("AiPlayerbot.RpgStatusProbWeight.WanderNpc", 20);

View File

@ -91,7 +91,7 @@ public:
bool EnableICCBuffs;
bool allowAccountBots, allowGuildBots, allowTrustedAccountBots;
bool randomBotGuildNearby, randomBotInvitePlayer, inviteChat;
uint32 globalCoolDown, reactDelay, maxWaitForMove, disableMoveSplinePath, maxMovementSearchTime, expireActionTime,
uint32 globalCoolDown, reactDelay, maxWaitForMove, disableMoveSplinePath, expireActionTime,
dispelAuraDuration, passiveDelay, repeatDelay, errorDelay, rpgDelay, sitDelay, returnDelay, lootDelay;
bool dynamicReactDelay;
float sightDistance, spellDistance, reactDistance, grindDistance, lootDistance, shootDistance, fleeDistance,
@ -375,6 +375,7 @@ public:
bool autoLearnTrainerSpells;
bool autoDoQuests;
bool enableNewRpgStrategy;
bool enableTravelNodes;
std::unordered_map<NewRpgStatus, uint32> RpgStatusProbWeight;
bool syncLevelWithPlayers;
bool autoLearnQuestSpells;