From 513925585699306958a163dbbddc9e7cab3931d2 Mon Sep 17 00:00:00 2001 From: bash Date: Sun, 10 May 2026 17:30:56 +0200 Subject: [PATCH] feat(Core/RPG): MoveFarTo flow, quest-pursuit at POI, MoveRandomNear retries --- src/Ai/Base/Actions/CheckValuesAction.cpp | 7 +- src/Ai/World/Rpg/Action/NewRpgAction.cpp | 148 ++- src/Ai/World/Rpg/Action/NewRpgAction.h | 10 +- src/Ai/World/Rpg/Action/NewRpgBaseAction.cpp | 988 ++++++++++++++++--- src/Ai/World/Rpg/Action/NewRpgBaseAction.h | 38 +- src/Ai/World/Rpg/NewRpgInfo.cpp | 25 +- src/Ai/World/Rpg/NewRpgInfo.h | 19 +- src/Ai/World/Rpg/Strategy/NewRpgStrategy.cpp | 63 +- src/Ai/World/Rpg/Strategy/NewRpgStrategy.h | 7 + src/PlayerbotAIConfig.cpp | 2 +- src/PlayerbotAIConfig.h | 3 +- 11 files changed, 1099 insertions(+), 211 deletions(-) diff --git a/src/Ai/Base/Actions/CheckValuesAction.cpp b/src/Ai/Base/Actions/CheckValuesAction.cpp index dce66bd47..19f8f21f5 100644 --- a/src/Ai/Base/Actions/CheckValuesAction.cpp +++ b/src/Ai/Base/Actions/CheckValuesAction.cpp @@ -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("possible targets"); GuidVector all_targets = *context->GetValue("all targets"); GuidVector npcs = *context->GetValue("nearest npcs"); diff --git a/src/Ai/World/Rpg/Action/NewRpgAction.cpp b/src/Ai/World/Rpg/Action/NewRpgAction.cpp index 290be0c0a..9923130fa 100644 --- a/src/Ai/World/Rpg/Action/NewRpgAction.cpp +++ b/src/Ai/World/Rpg/Action/NewRpgAction.cpp @@ -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(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 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; } diff --git a/src/Ai/World/Rpg/Action/NewRpgAction.h b/src/Ai/World/Rpg/Action/NewRpgAction.h index 83594204b..5433284d1 100644 --- a/src/Ai/World/Rpg/Action/NewRpgAction.h +++ b/src/Ai/World/Rpg/Action/NewRpgAction.h @@ -47,11 +47,11 @@ public: protected: // static NewRpgStatusTransitionProb transitionMat; - const int32 statusWanderNpcDuration = 5 * MINUTE * IN_MILLISECONDS ; - const int32 statusWanderRandomDuration = 5 * MINUTE * IN_MILLISECONDS ; - const int32 statusRestDuration = 30 * IN_MILLISECONDS ; - const int32 statusDoQuestDuration = 30 * MINUTE * IN_MILLISECONDS ; - const int32 statusOutDoorPvPDuration = HOUR * IN_MILLISECONDS ; + const int32 statusWanderNpcDuration = 5 * MINUTE * IN_MILLISECONDS; + const int32 statusWanderRandomDuration = 5 * MINUTE * IN_MILLISECONDS; + const int32 statusRestDuration = 30 * IN_MILLISECONDS; + const int32 statusDoQuestDuration = 30 * MINUTE * IN_MILLISECONDS; + const int32 statusOutDoorPvPDuration = HOUR * IN_MILLISECONDS; }; class NewRpgGoGrindAction : public NewRpgBaseAction diff --git a/src/Ai/World/Rpg/Action/NewRpgBaseAction.cpp b/src/Ai/World/Rpg/Action/NewRpgBaseAction.cpp index 5ae65e14e..a59c16d1a 100644 --- a/src/Ai/World/Rpg/Action/NewRpgBaseAction.cpp +++ b/src/Ai/World/Rpg/Action/NewRpgBaseAction.cpp @@ -1,5 +1,7 @@ #include "NewRpgBaseAction.h" +#include + #include "BroadcastHelper.h" #include "ChatHelper.h" #include "Creature.h" @@ -8,6 +10,13 @@ #include "GossipDef.h" #include "GridTerrainData.h" #include "IVMapMgr.h" +#include "Item.h" +#include "ItemTemplate.h" +#include "LootMgr.h" +#include "Map.h" +#include "ModelIgnoreFlags.h" +#include "MotionMaster.h" +#include "MoveSplineInitArgs.h" #include "NewRpgInfo.h" #include "NewRpgStrategy.h" #include "Object.h" @@ -27,26 +36,22 @@ #include "Random.h" #include "RandomPlayerbotMgr.h" #include "SharedDefines.h" +#include "Spell.h" +#include "SpellInfo.h" +#include "SpellMgr.h" #include "StatsWeightCalculator.h" #include "Timer.h" #include "TravelMgr.h" + bool NewRpgBaseAction::MoveFarTo(WorldPosition dest) { if (dest == WorldPosition()) return false; - if (dest != botAI->rpgInfo.moveFarPos) - { - // clear stuck information if it's a new dest - botAI->rpgInfo.SetMoveFarTo(dest); - } - // performance optimization if (IsWaitingForLastMove(MovementPriority::MOVEMENT_NORMAL)) - { return false; - } // Let previously committed movement finish before recomputing. // @@ -57,152 +62,339 @@ bool NewRpgBaseAction::MoveFarTo(WorldPosition dest) // Without this gate, DoMovePoint would call mm->Clear() and // reissue MovePoint from the new bot position — and from a new // position mmap's partial-path endpoint often differs, so the - // bot gets clobbered mid-walk and ends up oscillating (e.g. - // cave entrance -> inside cave -> cave entrance -> mountain - // base -> cave entrance...) around an unreachable destination. + // bot gets clobbered mid-walk and ends up oscillating around an + // unreachable destination. // // If the bot is still actively walking toward its last // committed point on the same map, just let the current spline - // finish. The stuck counter below continues to track real - // progress toward dest and triggers teleport recovery if the - // committed paths genuinely aren't closing the gap. + // finish. { LastMovement& lastMove = AI_VALUE(LastMovement&, "last movement"); if (bot->isMoving() && lastMove.lastMoveToMapId == bot->GetMapId()) { float remaining = bot->GetExactDist(lastMove.lastMoveToX, lastMove.lastMoveToY, lastMove.lastMoveToZ); if (remaining > 10.0f) - return true; - } - } - - // stuck check - float disToDest = bot->GetDistance(dest); - // Require a meaningful improvement (5yd) to reset the stuck counter. - // The old 1yd threshold was small enough that bots oscillating back - // and forth around an obstacle would keep "making progress" forever - // and never trigger the teleport recovery below. - if (disToDest + 5.0f < botAI->rpgInfo.nearestMoveFarDis) - { - botAI->rpgInfo.nearestMoveFarDis = disToDest; - botAI->rpgInfo.stuckTs = getMSTime(); - botAI->rpgInfo.stuckAttempts = 0; - } - else if (++botAI->rpgInfo.stuckAttempts >= 5 && GetMSTimeDiffToNow(botAI->rpgInfo.stuckTs) >= stuckTime) - { - // No meaningful progress toward dest for `stuckTime`: fall - // back to teleporting directly so the bot can get on with - // its RPG objective instead of oscillating indefinitely. - botAI->rpgInfo.stuckTs = getMSTime(); - botAI->rpgInfo.stuckAttempts = 0; - const AreaTableEntry* entry = sAreaTableStore.LookupEntry(bot->GetZoneId()); - std::string zone_name = PlayerbotAI::GetLocalizedAreaName(entry); - LOG_DEBUG( - "playerbots", - "[New RPG] Teleport {} from ({},{},{},{}) to ({},{},{},{}) as it stuck when moving far - Zone: {} ({})", - bot->GetName(), bot->GetPositionX(), bot->GetPositionY(), bot->GetPositionZ(), bot->GetMapId(), - dest.GetPositionX(), dest.GetPositionY(), dest.GetPositionZ(), dest.GetMapId(), bot->GetZoneId(), - zone_name); - bot->RemoveAurasWithInterruptFlags(AURA_INTERRUPT_FLAG_TELEPORTED | AURA_INTERRUPT_FLAG_CHANGE_MAP); - return bot->TeleportTo(dest); - } - - float dis = bot->GetExactDist(dest); - if (dis < pathFinderDis) - { - return MoveTo(dest.GetMapId(), dest.GetPositionX(), dest.GetPositionY(), dest.GetPositionZ(), false, false, - false, true); - } - - const uint32 typeOk = PATHFIND_NORMAL | PATHFIND_INCOMPLETE | PATHFIND_FARFROMPOLY; - - // Primary strategy: ask mmap for a route to the TRUE destination. - // If mmap can reach it directly (PATHFIND_NORMAL) or partially - // (PATHFIND_INCOMPLETE — destinations beyond the smooth-path cap - // of ~296 yards, or where local geometry blocks the final step), - // walk to the furthest reachable waypoint mmap computed. This - // lets bots follow the real route around obstacles (mountains, - // cave walls, cliffs) instead of trying to cut straight through. - // The spline system walks the whole returned path smoothly, so - // subsequent ticks early-out via IsWaitingForLastMove and no - // further PathGenerator calls fire until the bot arrives. - { - PathGenerator path(bot); - path.CalculatePath(dest.GetPositionX(), dest.GetPositionY(), dest.GetPositionZ()); - PathType type = path.GetPathType(); - bool canReach = !(type & (~typeOk)); - if (canReach) - { - const G3D::Vector3& endPos = path.GetActualEndPosition(); - // Only commit if the mmap endpoint actually makes progress - // toward the destination. For pathological INCOMPLETE - // results (e.g. disconnected polys that still report - // INCOMPLETE) the endpoint can land right under the bot; - // fall through to cone sampling in that case. - float endDistToDest = dest.GetExactDist(endPos.x, endPos.y, endPos.z); - if (endDistToDest + 5.0f < disToDest) { - return MoveTo(bot->GetMapId(), endPos.x, endPos.y, endPos.z, false, false, false, true); + EmitDebugMove("MoveFar", "spline-plan", + lastMove.lastMoveToX, lastMove.lastMoveToY, lastMove.lastMoveToZ); + return true; } } } - // Fallback: mmap couldn't route to the destination. Sample the - // forward cone for a reachable stepping stone so the bot keeps - // moving and can try again from a new vantage point. Cap at 2 - // samples — we already spent one PathGenerator call above and at - // 3000 bots every extra CalculatePath matters. - float minDelta = M_PI; - const float x = bot->GetPositionX(); - const float y = bot->GetPositionY(); - const float z = bot->GetPositionZ(); - const float baseAngle = bot->GetAngle(&dest); - float rx, ry, rz; - bool found = false; - for (int attempt = 0; attempt < 2; ++attempt) + // 10% lastPath reuse — if the cached path's endpoint is still + // close (within 10%) to the new dest, trim the cached path to + // the bot's current position via makeShortCut and re-dispatch. + // Mirrors cmangos ResolveMovePath: per-tick re-dispatch of the + // (trimmed) last path keeps the bot on-route after interrupts + // (knockback, combat, manual move) without needing a full replan. { - float delta = (rand_norm() - 0.5f) * static_cast(M_PI); // ±π/2, forward cone - float sampleDis = (0.5f + rand_norm() * 0.5f) * pathFinderDis; - float angle = baseAngle + delta; - float dx = x + cos(angle) * sampleDis; - float dy = y + sin(angle) * sampleDis; - float dz = z + 0.5f; - PathGenerator path(bot); - path.CalculatePath(dx, dy, dz); - PathType type = path.GetPathType(); - bool canReach = !(type & (~typeOk)); - - if (canReach && fabs(delta) <= minDelta) + LastMovement& lastMove = AI_VALUE(LastMovement&, "last movement"); + if (!lastMove.lastPath.empty()) { - found = true; - const G3D::Vector3& endPos = path.GetActualEndPosition(); - rx = endPos.x; - ry = endPos.y; - rz = endPos.z; - minDelta = fabs(delta); + WorldPosition lastBack = lastMove.lastPath.getBack(); + if (lastBack.GetMapId() == dest.GetMapId()) + { + float totalDist = bot->GetExactDist(dest); + float maxDistChange = totalDist * 0.10f; + float distFromBotToBack = bot->GetExactDist(&lastBack); + if (lastBack.distance(dest) < maxDistChange && distFromBotToBack > 10.0f) + { + WorldPosition botPos(bot); + lastMove.lastPath.makeShortCut(botPos, sPlayerbotAIConfig.reactDistance, bot); + + // makeShortCut may clear the path if the bot drifted + // too far off (>reactDistance from any waypoint). In + // that case fall through to fresh planning. + if (lastMove.lastPath.empty()) + { + EmitDebugMove("MoveFar", "reuse-trim-failed", + dest.GetPositionX(), dest.GetPositionY(), dest.GetPositionZ()); + } + if (!lastMove.lastPath.empty()) + { + std::vector const& pts = lastMove.lastPath.getPointPath(); + if (pts.size() >= 2) + { + Movement::PointsArray points; + points.reserve(pts.size()); + for (auto const& wp : pts) + points.emplace_back(wp.GetPositionX(), wp.GetPositionY(), wp.GetPositionZ()); + for (auto& pt : points) + bot->UpdateAllowedPositionZ(pt.x, pt.y, pt.z); + bot->GetMotionMaster()->Clear(); + bot->GetMotionMaster()->MoveSplinePath(&points, FORCED_MOVEMENT_RUN); + + G3D::Vector3 const& last = points.back(); + float totalChainDist = 0.f; + for (size_t i = 1; i < points.size(); ++i) + totalChainDist += (points[i] - points[i - 1]).length(); + float speed = std::max(bot->GetSpeed(MOVE_RUN), 0.1f); + uint32 expectedMs = static_cast((totalChainDist / speed) * IN_MILLISECONDS); + uint32 cappedMs = std::min(expectedMs, (uint32)sPlayerbotAIConfig.maxWaitForMove); + lastMove.Set(bot->GetMapId(), last.x, last.y, last.z, + bot->GetOrientation(), cappedMs, MovementPriority::MOVEMENT_NORMAL); + + EmitDebugMove("MoveFar", "reuse", + dest.GetPositionX(), dest.GetPositionY(), dest.GetPositionZ()); + return true; + } + } + // Path was cleared or collapsed — fall through to fresh planning. + } + } } } - if (found) + + float disToDest = bot->GetDistance(dest); + float dis = bot->GetExactDist(dest); + + // Decision tree (cmangos ResolveMovePath order — travel nodes first): + // + // 1. Active node plan? Ride it. + // + // 2. Long-distance move (>= nodeFirstDis) and travel nodes + // enabled: try the node graph FIRST. The graph holds + // curated waypoints that avoid known bad terrain. + // + // 3. If no node plan returned: run the 40-step chained mmap + // probe and dispatch its waypoint chain. + // + // 4. Empty / non-progressing probe: fall back to single- + // waypoint spline at dest. + bool tryNodes = (dis >= nodeFirstDis && sPlayerbotAIConfig.enableTravelNodes); + + // If a node plan is already active, ride it — but only if its + // destination still matches the requested dest. Otherwise the + // old plan (e.g. built toward a quest objective POI) would keep + // driving the bot after the caller switched targets (e.g. to a + // turn-in NPC). cmangos's ResolveMovePath dodges this by being + // stateless; we have a long-lived plan flag, so check explicitly. + if (tryNodes && botAI->rpgInfo.HasActiveTravelPlan()) { - return MoveTo(bot->GetMapId(), rx, ry, rz, false, false, false, true); + if (botAI->rpgInfo.travelPlan.destination.distance(dest) > 10.0f) + botAI->rpgInfo.ClearTravel(); + else + return UpdateTravelPlan(); } - return false; + + // PRIORITY: try the travel-node graph FIRST when the move is + // long enough to need it. + if (tryNodes) + { + StartTravelPlan(dest); + if (botAI->rpgInfo.HasActiveTravelPlan()) + { + LOG_INFO("playerbots", "[MoveFar] {} nodetravel | dis={:.0f}", + bot->GetName(), dis); + EmitDebugMove("MoveFar", "travelplan", + dest.GetPositionX(), dest.GetPositionY(), dest.GetPositionZ()); + return UpdateTravelPlan(); + } + // Graph returned no plan — fall through to mmap probe. + } + else if (botAI->rpgInfo.HasActiveTravelPlan()) + { + // Move dropped below node-first threshold — drop any leftover plan. + botAI->rpgInfo.ClearTravel(); + } + + // 40-step chained mmap probe — fallback when the node graph + // returned no plan (or for short moves below nodeFirstDis). + WorldPosition botPos(bot); + std::vector probe = botPos.getPathTo(dest, bot); + + // Regression guard (cmangos ResolveMovePath parity): if a cached + // lastPath ends at least as close to dest as the new probe's + // endpoint, prefer the cached path. The 10% reuse block above + // already returned early when cached was within 10% of dest; + // this catches "cached is far (>10%) but still better than the + // probe" — typically when the probe got blocked by geometry and + // ended much farther from dest than where cached had reached. + { + LastMovement& lastMove = AI_VALUE(LastMovement&, "last movement"); + if (!lastMove.lastPath.empty() && !probe.empty() && probe.size() >= 2) + { + WorldPosition lastBack = lastMove.lastPath.getBack(); + if (lastBack.GetMapId() == dest.GetMapId()) + { + float cachedToDest = lastBack.distance(dest); + float probeToDest = dest.GetExactDist(probe.back().GetPositionX(), + probe.back().GetPositionY(), + probe.back().GetPositionZ()); + if (cachedToDest <= probeToDest) + { + WorldPosition botPosNow(bot); + lastMove.lastPath.makeShortCut(botPosNow, sPlayerbotAIConfig.reactDistance, bot); + if (!lastMove.lastPath.empty()) + { + std::vector const& pts = lastMove.lastPath.getPointPath(); + if (pts.size() >= 2) + { + Movement::PointsArray points; + points.reserve(pts.size()); + for (auto const& wp : pts) + points.emplace_back(wp.GetPositionX(), wp.GetPositionY(), wp.GetPositionZ()); + for (auto& pt : points) + bot->UpdateAllowedPositionZ(pt.x, pt.y, pt.z); + bot->GetMotionMaster()->Clear(); + bot->GetMotionMaster()->MoveSplinePath(&points, FORCED_MOVEMENT_RUN); + + G3D::Vector3 const& last = points.back(); + float totalChainDist = 0.f; + for (size_t i = 1; i < points.size(); ++i) + totalChainDist += (points[i] - points[i - 1]).length(); + float speed = std::max(bot->GetSpeed(MOVE_RUN), 0.1f); + uint32 expectedMs = static_cast((totalChainDist / speed) * IN_MILLISECONDS); + uint32 cappedMs = std::min(expectedMs, (uint32)sPlayerbotAIConfig.maxWaitForMove); + lastMove.Set(bot->GetMapId(), last.x, last.y, last.z, + bot->GetOrientation(), cappedMs, MovementPriority::MOVEMENT_NORMAL); + + EmitDebugMove("MoveFar", "regress-keep", + dest.GetPositionX(), dest.GetPositionY(), dest.GetPositionZ()); + return true; + } + } + } + } + } + } + + // Walk the chained probe's full waypoint chain via MoveSplinePath. + // Handing the full waypoint vector to the motion master removes + // its discretion to introduce a straight-line shortcut between + // intermediate points. + if (!probe.empty() && probe.size() >= 2) + { + WorldPosition stepDest = probe.back(); + float endDistToDest = dest.GetExactDist(stepDest.GetPositionX(), + stepDest.GetPositionY(), stepDest.GetPositionZ()); + if (endDistToDest + 5.0f < disToDest) + { + // Convert WorldPosition probe to G3D::Vector3 array. + Movement::PointsArray points; + points.reserve(probe.size()); + for (auto const& wp : probe) + points.emplace_back(wp.GetPositionX(), wp.GetPositionY(), wp.GetPositionZ()); + + // Per-waypoint Z-snap to current ground. + for (auto& pt : points) + bot->UpdateAllowedPositionZ(pt.x, pt.y, pt.z); + + if (points.size() >= 2) + { + LOG_INFO("playerbots", "[MoveFar] {} mmap-path | dis={:.0f} | endDist={:.0f} | wp={}", + bot->GetName(), dis, endDistToDest, (uint32)points.size()); + EmitDebugMove("MoveFar", "mmap", + points.back().x, points.back().y, points.back().z); + + // Mount up if outdoors and not in combat. + if (!bot->IsMounted() && !bot->IsInCombat() && bot->IsOutdoors() && bot->IsAlive()) + botAI->DoSpecificAction("check mount state", Event(), true); + + // Bulk dispatch: hand the full waypoint chain to the + // motion master via MoveSplinePath. Motion master plays + // every point in sequence — no per-tick re-dispatching. + bot->GetMotionMaster()->Clear(); + bot->GetMotionMaster()->MoveSplinePath(&points, FORCED_MOVEMENT_RUN); + + // Update LastMovement to the chain endpoint so spline- + // active early-exit at the top of MoveFarTo silences + // recompute attempts during the walk. + G3D::Vector3 const& last = points.back(); + float totalDist = 0.f; + for (size_t i = 1; i < points.size(); ++i) + totalDist += (points[i] - points[i - 1]).length(); + float speed = std::max(bot->GetSpeed(MOVE_RUN), 0.1f); + uint32 expectedMs = static_cast((totalDist / speed) * IN_MILLISECONDS); + uint32 cappedMs = std::min(expectedMs, (uint32)sPlayerbotAIConfig.maxWaitForMove); + LastMovement& lastMove = AI_VALUE(LastMovement&, "last movement"); + lastMove.Set(bot->GetMapId(), last.x, last.y, last.z, + bot->GetOrientation(), cappedMs, MovementPriority::MOVEMENT_NORMAL); + + // Cache full chain for downstream consumers + // (LastLongMoveValue) and the lastPath reuse check. + std::vector wpts; + wpts.reserve(points.size()); + for (auto const& pt : points) + wpts.emplace_back(bot->GetMapId(), pt.x, pt.y, pt.z); + lastMove.setPath(TravelPath(wpts)); + + return true; + } + } + } + + // Probe failed or didn't progress — emit visibility whisper so + // the user can see WHY mmap didn't dispatch. + { + bool const probeProgressed = !probe.empty() && probe.size() >= 2 && + (dest.GetExactDist(probe.back().GetPositionX(), + probe.back().GetPositionY(), probe.back().GetPositionZ()) + 5.0f < disToDest); + if (!probeProgressed) + { + char const* reason = (probe.empty() || probe.size() < 2) ? "mmap-empty" : "mmap-noprogress"; + EmitDebugMove("MoveFar", reason, + dest.GetPositionX(), dest.GetPositionY(), + dest.GetPositionZ()); + } + } + + // Empty / non-progressing path falls back to dispatching the + // destination as a single waypoint. Spline only when target is + // line-of-sight: dispatching a straight line through walls + // produces visible clipping/glitching. If LOS is blocked we + // refuse and let UnstuckAction (5/10 min) catch the stuck. + bool const inLOS = bot->IsWithinLOS(dest.GetPositionX(), dest.GetPositionY(), dest.GetPositionZ()); + LOG_INFO("playerbots", "[MoveFar] {} spline | dis={:.0f} | probe.empty={} | LOS={}", + bot->GetName(), dis, + probe.empty() ? "y" : "n", + inLOS ? "y" : "n"); + if (!inLOS) + { + EmitDebugMove("MoveFar", "spline-blocked", + dest.GetPositionX(), dest.GetPositionY(), + dest.GetPositionZ()); + return false; // Refuse to dispatch a straight line through geometry. + } + EmitDebugMove("MoveFar", "spline", + dest.GetPositionX(), dest.GetPositionY(), dest.GetPositionZ()); + // Same exact_waypoint=false rationale as the mmap branch — terrain- + // following spline, not a straight diagonal. + return MoveTo(dest.GetMapId(), dest.GetPositionX(), dest.GetPositionY(), dest.GetPositionZ(), + false, false, false, false); +} + +void NewRpgBaseAction::StartTravelPlan(WorldPosition dest) +{ + TravelPlan& plan = botAI->rpgInfo.travelPlan; + GetTravelPlan(plan, dest); + + LOG_DEBUG("playerbots","[New RPG] Bot {} starting travel plan to ({:.0f},{:.0f},{:.0f}) map={}, {} points", + bot->GetName(), dest.GetPositionX(), dest.GetPositionY(), dest.GetPositionZ(), dest.GetMapId(), plan.steps.size()); +} + +bool NewRpgBaseAction::UpdateTravelPlan() +{ + TravelPlan& plan = botAI->rpgInfo.travelPlan; + + bool result = ExecuteTravelPlan(plan); + + if (!plan.IsActive()) + botAI->rpgInfo.ClearTravel(); + + return result; } bool NewRpgBaseAction::MoveWorldObjectTo(ObjectGuid guid, float distance) { - if (IsWaitingForLastMove(MovementPriority::MOVEMENT_NORMAL)) - { - return false; - } - WorldObject* object = botAI->GetWorldObject(guid); if (!object) return false; + float x = object->GetPositionX(); float y = object->GetPositionY(); float z = object->GetPositionZ(); - float mapId = object->GetMapId(); float angle = 0.f; if (!object->ToUnit() || !object->ToUnit()->isMoving()) @@ -221,7 +413,12 @@ bool NewRpgBaseAction::MoveWorldObjectTo(ObjectGuid guid, float distance) y = object->GetPositionY(); z = object->GetPositionZ(); } - return MoveTo(mapId, x, y, z, false, false, false, true); + // Delegate to MoveFarTo so every approach gets the chained mmap + // probe + spellDistance shortcut + travel-node fallback instead + // of a single direct MoveTo. The debug-move trace then labels + // the actual mechanism (spline / mmap / nodetravel) rather than + // a generic "MoveWorldObjectTo:spline". + return MoveFarTo(WorldPosition(object->GetMapId(), x, y, z)); } bool NewRpgBaseAction::MoveRandomNear(float moveStep, MovementPriority priority, WorldObject* center) @@ -246,13 +443,9 @@ bool NewRpgBaseAction::MoveRandomNear(float moveStep, MovementPriority priority, float dy = y + distance * sin(angle); float dz = z; - PathGenerator path(bot); - path.CalculatePath(dx, dy, dz); - PathType type = path.GetPathType(); - uint32 typeOk = PATHFIND_NORMAL | PATHFIND_INCOMPLETE | PATHFIND_FARFROMPOLY; - bool canReach = !(type & (~typeOk)); + PathResult path = GeneratePath(dx, dy, dz, RELAXED_PATH_ACCEPT_MASK, /*forceDestination=*/false); - if (!canReach) + if (!path.reachable) continue; if (!map->CanReachPositionAndGetValidCoords(bot, dx, dy, dz)) @@ -263,9 +456,13 @@ bool NewRpgBaseAction::MoveRandomNear(float moveStep, MovementPriority priority, bool moved = MoveTo(bot->GetMapId(), dx, dy, dz, false, false, false, true, priority); if (moved) + { + EmitDebugMove("MoveRandomNear", "mmap", dx, dy, dz); return true; + } } + EmitDebugMove("MoveRandomNear", "all-fail", x, y, z); return false; } @@ -277,6 +474,29 @@ bool NewRpgBaseAction::ForceToWait(uint32 duration, MovementPriority priority) return true; } +bool NewRpgBaseAction::TakeFlight(std::vector const& taxiNodes, Creature* flightMaster) +{ + if (taxiNodes.size() < 2 || !flightMaster || !flightMaster->IsAlive()) + return false; + + botAI->RemoveShapeshift(); + if (bot->IsMounted()) + bot->Dismount(); + + if (!bot->ActivateTaxiPathTo(taxiNodes, flightMaster, 0)) + { + LOG_DEBUG("playerbots", "[New RPG] Bot {} flight ({} nodes, {} to {}) failed", + bot->GetName(), taxiNodes.size(), taxiNodes.front(), taxiNodes.back()); + return false; + } + + LOG_DEBUG("playerbots", "[New RPG] Bot {} taking flight ({} nodes, {} to {})", + bot->GetName(), taxiNodes.size(), taxiNodes.front(), taxiNodes.back()); + EmitDebugMove("TravelPlan:flight", "taxi", flightMaster->GetPositionX(), flightMaster->GetPositionY(), + flightMaster->GetPositionZ()); + return true; +} + /// @TODO: Fix redundant code /// Quest related method refer to TalkToQuestGiverAction.h bool NewRpgBaseAction::InteractWithNpcOrGameObjectForQuest(ObjectGuid guid) @@ -688,6 +908,503 @@ bool NewRpgBaseAction::SearchQuestGiverAndAcceptOrReward() return false; } +static bool BotNeedsItemForQuest(Player* bot, uint32 itemId) +{ + for (uint16 slot = 0; slot < MAX_QUEST_LOG_SIZE; ++slot) + { + uint32 questId = bot->GetQuestSlotQuestId(slot); + if (!questId) + continue; + if (bot->GetQuestStatus(questId) != QUEST_STATUS_INCOMPLETE) + continue; + Quest const* quest = sObjectMgr->GetQuestTemplate(questId); + if (!quest) + continue; + QuestStatusData const& qs = bot->getQuestStatusMap().at(questId); + for (int i = 0; i < QUEST_ITEM_OBJECTIVES_COUNT; ++i) + { + if (!quest->RequiredItemCount[i]) + continue; + if (qs.ItemCount[i] >= quest->RequiredItemCount[i]) + continue; + if (quest->RequiredItemId[i] == itemId) + return true; + } + } + return false; +} + +bool NewRpgBaseAction::TryLootQuestGO(ObjectGuid& pursuedGO, float searchRange) +{ + if (!bot->IsAlive() || bot->IsBeingTeleported() || bot->IsInFlight() || + bot->GetVehicle() || bot->GetTransport()) + return false; + + // valid = spawned, selectable, holds a quest item we still need. + // INTERACT_COND is fine — ConditionMgr already gates on quest state. + auto isValidTarget = [&](GameObject* go) -> bool + { + if (!go || !go->IsInWorld() || !go->isSpawned()) + return false; + if (!(go->GetPhaseMask() & bot->GetPhaseMask())) + return false; + if (go->HasGameObjectFlag(GO_FLAG_NOT_SELECTABLE)) + return false; + GameObjectTemplate const* info = go->GetGOInfo(); + if (!info) + return false; + + // per-player quest drops via gameobject_questitem (Webwood Eggs…) + if (GameObjectQuestItemList const* items = + sObjectMgr->GetGameObjectQuestItemList(go->GetEntry())) + { + for (size_t i = 0; i < MAX_GAMEOBJECT_QUEST_ITEMS && i < items->size(); ++i) + { + uint32 itemId = uint32((*items)[i]); + if (!itemId) + continue; + if (BotNeedsItemForQuest(bot, itemId)) + return true; + } + } + + // standard loot template (chests, fishing holes) + if (uint32 lootId = info->GetLootId()) + { + if (LootTemplates_Gameobject.HaveQuestLootForPlayer(lootId, bot)) + return true; + } + return false; + }; + + // 2.5y sits inside the 3.5y loot gate with headroom + const float lootRange = 2.5f; + + // stick with the committed target — re-picking nearest every tick + // causes zig-zag walks in dense spawn clusters + if (pursuedGO) + { + GameObject* existing = botAI->GetGameObject(pursuedGO); + if (existing && isValidTarget(existing) && + bot->GetDistance(existing) <= searchRange) + { + if (bot->GetDistance(existing) > lootRange) + return MoveWorldObjectTo(existing->GetGUID(), lootRange); + // in range — loot strategy opens it + return true; + } + pursuedGO.Clear(); + } + + GuidVector possibleGameObjects = AI_VALUE(GuidVector, "possible new rpg game objects"); + if (possibleGameObjects.empty()) + return false; + + GameObject* best = nullptr; + float bestDist = searchRange; + for (ObjectGuid guid : possibleGameObjects) + { + GameObject* go = botAI->GetGameObject(guid); + if (!isValidTarget(go)) + continue; + float d = bot->GetDistance(go); + if (d >= bestDist) + continue; + best = go; + bestDist = d; + } + if (!best) + return false; + + // commit + pursuedGO = best->GetGUID(); + + if (bot->GetDistance(best) > lootRange) + return MoveWorldObjectTo(best->GetGUID(), lootRange); + + // in range — consume the tick so we don't fall through to wander + return true; +} + +bool NewRpgBaseAction::TryUseQuestGO(ObjectGuid& pursuedGO, float searchRange) +{ + if (!bot->IsAlive() || bot->IsBeingTeleported() || bot->IsInFlight() || + bot->GetVehicle() || bot->GetTransport()) + return false; + + std::unordered_set neededGoEntries; + for (uint16 slot = 0; slot < MAX_QUEST_LOG_SIZE; ++slot) + { + uint32 questId = bot->GetQuestSlotQuestId(slot); + if (!questId) + continue; + if (bot->GetQuestStatus(questId) != QUEST_STATUS_INCOMPLETE) + continue; + Quest const* quest = sObjectMgr->GetQuestTemplate(questId); + if (!quest) + continue; + QuestStatusData const& qs = bot->getQuestStatusMap().at(questId); + for (int i = 0; i < QUEST_OBJECTIVES_COUNT; ++i) + { + int32 entry = quest->RequiredNpcOrGo[i]; + if (entry >= 0) + continue; + if (qs.CreatureOrGOCount[i] >= quest->RequiredNpcOrGoCount[i]) + continue; + neededGoEntries.insert(uint32(-entry)); + } + } + if (neededGoEntries.empty()) + return false; + + auto isValidTarget = [&](GameObject* go) -> bool + { + if (!go || !go->IsInWorld() || !go->isSpawned()) + return false; + if (!(go->GetPhaseMask() & bot->GetPhaseMask())) + return false; + if (go->HasGameObjectFlag(GO_FLAG_NOT_SELECTABLE)) + return false; + return neededGoEntries.count(go->GetEntry()) > 0; + }; + + // commitment first + if (pursuedGO) + { + GameObject* existing = botAI->GetGameObject(pursuedGO); + if (existing && isValidTarget(existing) && + bot->GetDistance(existing) <= searchRange) + { + if (bot->GetDistance(existing) > INTERACTION_DISTANCE) + return MoveWorldObjectTo(existing->GetGUID(), INTERACTION_DISTANCE); + existing->Use(bot); + ForceToWait(2000); + pursuedGO.Clear(); + return true; + } + pursuedGO.Clear(); + } + + GuidVector possibleGameObjects = AI_VALUE(GuidVector, "possible new rpg game objects"); + GameObject* best = nullptr; + float bestDist = searchRange; + for (ObjectGuid guid : possibleGameObjects) + { + GameObject* go = botAI->GetGameObject(guid); + if (!isValidTarget(go)) + continue; + float d = bot->GetDistance(go); + if (d >= bestDist) + continue; + best = go; + bestDist = d; + } + if (!best) + return false; + + pursuedGO = best->GetGUID(); + + if (bot->GetDistance(best) > INTERACTION_DISTANCE) + return MoveWorldObjectTo(best->GetGUID(), INTERACTION_DISTANCE); + + best->Use(bot); + ForceToWait(2000); + pursuedGO.Clear(); + return true; +} + +bool NewRpgBaseAction::TryUseQuestItem(ObjectGuid& pursuedGO, ObjectGuid& pursuedTarget, float searchRange) +{ + if (!bot->IsAlive() || bot->IsBeingTeleported() || bot->IsInFlight() || + bot->GetVehicle() || bot->GetTransport()) + return false; + + std::unordered_set candidateItemEntries; + // src items (the quest gave the bot a single item to use); branch C + // (self/area cast) is only safe to fire on these — auto-firing every + // ItemDrop on self can burn kill-credit sentinels and trigger + // unintended scripted side effects. + std::unordered_set srcItemEntries; + std::unordered_set neededCreatureEntries; + for (uint16 slot = 0; slot < MAX_QUEST_LOG_SIZE; ++slot) + { + uint32 questId = bot->GetQuestSlotQuestId(slot); + if (!questId) + continue; + if (bot->GetQuestStatus(questId) != QUEST_STATUS_INCOMPLETE) + continue; + Quest const* quest = sObjectMgr->GetQuestTemplate(questId); + if (!quest) + continue; + if (uint32 src = quest->GetSrcItemId()) + { + candidateItemEntries.insert(src); + srcItemEntries.insert(src); + } + // handed out by the quest (brands, flares, nets, standards) + for (int i = 0; i < QUEST_SOURCE_ITEM_IDS_COUNT; ++i) + { + if (uint32 drop = quest->ItemDrop[i]) + candidateItemEntries.insert(drop); + } + QuestStatusData const& qs = bot->getQuestStatusMap().at(questId); + for (int i = 0; i < QUEST_OBJECTIVES_COUNT; ++i) + { + int32 entry = quest->RequiredNpcOrGo[i]; + if (entry <= 0) + continue; + if (qs.CreatureOrGOCount[i] >= quest->RequiredNpcOrGoCount[i]) + continue; + neededCreatureEntries.insert(uint32(entry)); + } + } + if (candidateItemEntries.empty()) + return false; + + for (uint32 itemEntry : candidateItemEntries) + { + Item* item = bot->GetItemByEntry(itemEntry); + if (!item) + continue; + ItemTemplate const* proto = item->GetTemplate(); + if (!proto) + continue; + uint32 useSpellId = 0; + for (uint8 i = 0; i < MAX_ITEM_PROTO_SPELLS; ++i) + { + if (proto->Spells[i].SpellTrigger != ITEM_SPELLTRIGGER_ON_USE) + continue; + if (proto->Spells[i].SpellId <= 0) + continue; + useSpellId = proto->Spells[i].SpellId; + break; + } + if (!useSpellId) + continue; + SpellInfo const* spellInfo = sSpellMgr->GetSpellInfo(useSpellId); + if (!spellInfo) + continue; + + // A: spell needs a focus GO (moonwell / lectern / anvil) + if (uint32 focusId = spellInfo->RequiresSpellFocus) + { + auto focusRadius = [](GameObject* go) -> float + { + GameObjectTemplate const* info = go->GetGOInfo(); + // half radius so we end up inside, not on the rim + return std::max(1.0f, float(info->spellFocus.dist) * 0.5f); + }; + auto isValidFocus = [&](GameObject* go) -> bool + { + if (!go || !go->IsInWorld() || !go->isSpawned()) + return false; + if (!(go->GetPhaseMask() & bot->GetPhaseMask())) + return false; + if (go->HasGameObjectFlag(GO_FLAG_NOT_SELECTABLE)) + return false; + GameObjectTemplate const* info = go->GetGOInfo(); + if (!info || info->type != GAMEOBJECT_TYPE_SPELL_FOCUS) + return false; + return info->spellFocus.focusId == focusId; + }; + + // commitment first + if (pursuedGO) + { + GameObject* existing = botAI->GetGameObject(pursuedGO); + if (existing && isValidFocus(existing) && + bot->GetDistance(existing) <= searchRange) + { + float radius = focusRadius(existing); + if (bot->GetDistance(existing) > radius) + return MoveWorldObjectTo(existing->GetGUID(), radius); + SpellCastTargets targets; + bot->CastItemUseSpell(item, targets, 1, 0); + ForceToWait(2000); + pursuedGO.Clear(); + return true; + } + pursuedGO.Clear(); + } + + GuidVector possibleGameObjects = AI_VALUE(GuidVector, "possible new rpg game objects"); + GameObject* best = nullptr; + float bestDist = searchRange; + float bestRadius = INTERACTION_DISTANCE; + for (ObjectGuid guid : possibleGameObjects) + { + GameObject* go = botAI->GetGameObject(guid); + if (!isValidFocus(go)) + continue; + float d = bot->GetDistance(go); + if (d >= bestDist) + continue; + best = go; + bestDist = d; + bestRadius = focusRadius(go); + } + if (best) + { + pursuedGO = best->GetGUID(); + if (bot->GetDistance(best) > bestRadius) + return MoveWorldObjectTo(best->GetGUID(), bestRadius); + SpellCastTargets targets; + bot->CastItemUseSpell(item, targets, 1, 0); + ForceToWait(2000); + pursuedGO.Clear(); + return true; + } + continue; + } + + // B: spell needs a unit target — walk to the required creature + if (spellInfo->NeedsExplicitUnitTarget() && !neededCreatureEntries.empty()) + { + auto isValidCreature = [&](Creature* c) -> bool + { + if (!c || !c->IsInWorld() || !c->IsAlive()) + return false; + if (!(c->GetPhaseMask() & bot->GetPhaseMask())) + return false; + return neededCreatureEntries.count(c->GetEntry()) > 0; + }; + + // commitment first + if (pursuedTarget) + { + Creature* existing = botAI->GetCreature(pursuedTarget); + if (existing && isValidCreature(existing) && + bot->GetDistance(existing) <= searchRange) + { + if (bot->GetDistance(existing) > INTERACTION_DISTANCE) + return MoveWorldObjectTo(existing->GetGUID(), INTERACTION_DISTANCE); + SpellCastTargets targets; + targets.SetUnitTarget(existing); + bot->CastItemUseSpell(item, targets, 1, 0); + ForceToWait(2000); + pursuedTarget.Clear(); + return true; + } + pursuedTarget.Clear(); + } + + GuidVector possibleTargets = AI_VALUE(GuidVector, "possible new rpg targets"); + Creature* best = nullptr; + float bestDist = searchRange; + for (ObjectGuid guid : possibleTargets) + { + Creature* c = botAI->GetCreature(guid); + if (!isValidCreature(c)) + continue; + float d = bot->GetDistance(c); + if (d >= bestDist) + continue; + best = c; + bestDist = d; + } + if (best) + { + pursuedTarget = best->GetGUID(); + if (bot->GetDistance(best) > INTERACTION_DISTANCE) + return MoveWorldObjectTo(best->GetGUID(), INTERACTION_DISTANCE); + SpellCastTargets targets; + targets.SetUnitTarget(best); + bot->CastItemUseSpell(item, targets, 1, 0); + ForceToWait(2000); + pursuedTarget.Clear(); + return true; + } + continue; + } + + // C: self / area — fire at bot's position. Restrict to GetSrcItemId + // items (the single item the quest hands the bot for self-use, e.g. + // a potion). ItemDrop entries can be kill-credit sentinels or + // scripted items that should never be auto-used on self. + if (!srcItemEntries.count(itemEntry)) + continue; + SpellCastTargets targets; + if (spellInfo->IsTargetingArea()) + targets.SetDst(*bot); + else + targets.SetUnitTarget(bot); + bot->CastItemUseSpell(item, targets, 1, 0); + ForceToWait(2000); + return true; + } + + return false; +} + +bool NewRpgBaseAction::HasNearbyQuestMob(float range) +{ + // kill objectives + mobs that drop required quest items + std::unordered_set neededCreatureEntries; + std::unordered_set neededItemIds; + for (uint16 slot = 0; slot < MAX_QUEST_LOG_SIZE; ++slot) + { + uint32 questId = bot->GetQuestSlotQuestId(slot); + if (!questId) + continue; + if (bot->GetQuestStatus(questId) != QUEST_STATUS_INCOMPLETE) + continue; + Quest const* quest = sObjectMgr->GetQuestTemplate(questId); + if (!quest) + continue; + QuestStatusData const& qs = bot->getQuestStatusMap().at(questId); + for (int i = 0; i < QUEST_OBJECTIVES_COUNT; ++i) + { + int32 entry = quest->RequiredNpcOrGo[i]; + if (entry <= 0) + continue; + if (qs.CreatureOrGOCount[i] >= quest->RequiredNpcOrGoCount[i]) + continue; + neededCreatureEntries.insert(uint32(entry)); + } + for (int i = 0; i < QUEST_ITEM_OBJECTIVES_COUNT; ++i) + { + if (!quest->RequiredItemCount[i]) + continue; + if (qs.ItemCount[i] >= quest->RequiredItemCount[i]) + continue; + if (quest->RequiredItemId[i]) + neededItemIds.insert(quest->RequiredItemId[i]); + } + } + if (neededCreatureEntries.empty() && neededItemIds.empty()) + return false; + + GuidVector possibleTargets = AI_VALUE(GuidVector, "possible targets"); + for (ObjectGuid guid : possibleTargets) + { + Creature* c = botAI->GetCreature(guid); + if (!c || !c->IsInWorld() || !c->IsAlive()) + continue; + if (!(c->GetPhaseMask() & bot->GetPhaseMask())) + continue; + if (bot->GetDistance(c) > range) + continue; + + // direct kill objective + if (neededCreatureEntries.count(c->GetEntry())) + return true; + + // drops a required quest item — HaveQuestLootForPlayer + // already filters by what this player still needs + if (!neededItemIds.empty()) + { + CreatureTemplate const* tmpl = c->GetCreatureTemplate(); + if (tmpl && tmpl->lootid && + LootTemplates_Creature.HaveQuestLootForPlayer(tmpl->lootid, bot)) + { + return true; + } + } + } + return false; +} + + ObjectGuid NewRpgBaseAction::ChooseNpcOrGameObjectToInteract(bool questgiverOnly, float distanceLimit) { GuidVector possibleTargets = AI_VALUE(GuidVector, "possible new rpg targets"); @@ -994,6 +1711,10 @@ WorldPosition NewRpgBaseAction::SelectRandomGrindPos(Player* bot) uint32 idx = urand(0, lo_prepared_locs.size() - 1); dest = lo_prepared_locs[idx]; } + + if (!dest.IsValid()) + return dest; + LOG_DEBUG("playerbots", "[New RPG] Bot {} select random grind pos Map:{} X:{} Y:{} Z:{} ({}+{} available in {})", bot->GetName(), dest.GetMapId(), dest.GetPositionX(), dest.GetPositionY(), dest.GetPositionZ(), hi_prepared_locs.size(), lo_prepared_locs.size() - hi_prepared_locs.size(), locs.size()); @@ -1037,6 +1758,10 @@ WorldPosition NewRpgBaseAction::SelectRandomCampPos(Player* bot) uint32 idx = urand(0, prepared_locs.size() - 1); dest = prepared_locs[idx]; } + + if (!dest.IsValid()) + return dest; + LOG_DEBUG("playerbots", "[New RPG] Bot {} select random inn keeper pos Map:{} X:{} Y:{} Z:{} ({} available in {})", bot->GetName(), dest.GetMapId(), dest.GetPositionX(), dest.GetPositionY(), dest.GetPositionZ(), prepared_locs.size(), locs.size()); @@ -1076,7 +1801,6 @@ bool NewRpgBaseAction::RandomChangeStatus(std::vector candidateSta probSum += sPlayerbotAIConfig.RpgStatusProbWeight[status]; } } - // Safety check. Default to "rest" if all RPG weights = 0 if (availableStatus.empty() || probSum == 0) { botAI->rpgInfo.ChangeToRest(); diff --git a/src/Ai/World/Rpg/Action/NewRpgBaseAction.h b/src/Ai/World/Rpg/Action/NewRpgBaseAction.h index e73a23219..2a9df1692 100644 --- a/src/Ai/World/Rpg/Action/NewRpgBaseAction.h +++ b/src/Ai/World/Rpg/Action/NewRpgBaseAction.h @@ -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 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, 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 diff --git a/src/Ai/World/Rpg/NewRpgInfo.cpp b/src/Ai/World/Rpg/NewRpgInfo.cpp index 780430f6d..59eaca93f 100644 --- a/src/Ai/World/Rpg/NewRpgInfo.cpp +++ b/src/Ai/World/Rpg/NewRpgInfo.cpp @@ -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 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() diff --git a/src/Ai/World/Rpg/NewRpgInfo.h b/src/Ai/World/Rpg/NewRpgInfo.h index 5896915a4..97f0d107d 100644 --- a/src/Ai/World/Rpg/NewRpgInfo.h +++ b/src/Ai/World/Rpg/NewRpgInfo.h @@ -1,6 +1,8 @@ #ifndef _PLAYERBOT_NEWRPGINFO_H #define _PLAYERBOT_NEWRPGINFO_H +#include + #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>; @@ -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(); }; diff --git a/src/Ai/World/Rpg/Strategy/NewRpgStrategy.cpp b/src/Ai/World/Rpg/Strategy/NewRpgStrategy.cpp index 521b15c34..660becd72 100644 --- a/src/Ai/World/Rpg/Strategy/NewRpgStrategy.cpp +++ b/src/Ai/World/Rpg/Strategy/NewRpgStrategy.cpp @@ -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(&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 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& 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& triggers) ); } -void NewRpgStrategy::InitMultipliers(std::vector&) +void NewRpgStrategy::InitMultipliers(std::vector& multipliers) { + multipliers.push_back(new NewRpgDoQuestMultiplier(botAI)); } diff --git a/src/Ai/World/Rpg/Strategy/NewRpgStrategy.h b/src/Ai/World/Rpg/Strategy/NewRpgStrategy.h index 47b2a550d..883e6d469 100644 --- a/src/Ai/World/Rpg/Strategy/NewRpgStrategy.h +++ b/src/Ai/World/Rpg/Strategy/NewRpgStrategy.h @@ -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: diff --git a/src/PlayerbotAIConfig.cpp b/src/PlayerbotAIConfig.cpp index b67c1e7e3..8afebf9f1 100644 --- a/src/PlayerbotAIConfig.cpp +++ b/src/PlayerbotAIConfig.cpp @@ -72,7 +72,6 @@ bool PlayerbotAIConfig::Initialize() globalCoolDown = sConfigMgr->GetOption("AiPlayerbot.GlobalCooldown", 500); maxWaitForMove = sConfigMgr->GetOption("AiPlayerbot.MaxWaitForMove", 5000); disableMoveSplinePath = sConfigMgr->GetOption("AiPlayerbot.DisableMoveSplinePath", 0); - maxMovementSearchTime = sConfigMgr->GetOption("AiPlayerbot.MaxMovementSearchTime", 3); expireActionTime = sConfigMgr->GetOption("AiPlayerbot.ExpireActionTime", 5000); dispelAuraDuration = sConfigMgr->GetOption("AiPlayerbot.DispelAuraDuration", 700); reactDelay = sConfigMgr->GetOption("AiPlayerbot.ReactDelay", 100); @@ -674,6 +673,7 @@ bool PlayerbotAIConfig::Initialize() autoTeleportForLevel = sConfigMgr->GetOption("AiPlayerbot.AutoTeleportForLevel", false); autoDoQuests = sConfigMgr->GetOption("AiPlayerbot.AutoDoQuests", true); enableNewRpgStrategy = sConfigMgr->GetOption("AiPlayerbot.EnableNewRpgStrategy", true); + enableTravelNodes = sConfigMgr->GetOption("AiPlayerbot.EnableTravelNodes", false); RpgStatusProbWeight[RPG_WANDER_RANDOM] = sConfigMgr->GetOption("AiPlayerbot.RpgStatusProbWeight.WanderRandom", 15); RpgStatusProbWeight[RPG_WANDER_NPC] = sConfigMgr->GetOption("AiPlayerbot.RpgStatusProbWeight.WanderNpc", 20); diff --git a/src/PlayerbotAIConfig.h b/src/PlayerbotAIConfig.h index 2d9071ead..84c8aec99 100644 --- a/src/PlayerbotAIConfig.h +++ b/src/PlayerbotAIConfig.h @@ -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, @@ -370,6 +370,7 @@ public: bool autoLearnTrainerSpells; bool autoDoQuests; bool enableNewRpgStrategy; + bool enableTravelNodes; std::unordered_map RpgStatusProbWeight; bool syncLevelWithPlayers; bool autoLearnQuestSpells;