Compare commits

..

12 Commits

Author SHA1 Message Date
bash
b1fab01c48 fix(Core/Travel): Port cmangos ResolveMovePath regression guard for cached lastPath 2026-05-08 23:29:54 +02:00
bash
9464d5cc98 fix(Core/Travel): Port cmangos makeShortCut walkability filter and bot context 2026-05-08 23:25:43 +02:00
bash
de64d9780c fix(Core/Travel): Drop stale TravelPlan when destination shifts 2026-05-08 23:16:51 +02:00
bash
7b60310c4e Revert "fix(Core/Movement): Force replan when dest shifts >40y from cached spline endpoint"
This reverts commit 82bf33f3d40fcf95deb7469f36401d1534718db9.
2026-05-08 23:06:25 +02:00
bash
82bf33f3d4 fix(Core/Movement): Force replan when dest shifts >40y from cached spline endpoint 2026-05-08 22:58:15 +02:00
bash
21b68caffc fix(Core/RPG): Clear stale spline + lastPath on quest-complete transition 2026-05-08 22:40:34 +02:00
bash
9d2416a40a chore(Core/Debug): Rename MoveFar:spline-active to MoveFar:spline-plan 2026-05-08 22:34:37 +02:00
bash
e892d10eaa feat(Core/Debug): Emit MoveFar:spline-active, reuse-trim-failed, MoveRandomNear:all-fail 2026-05-08 22:27:36 +02:00
bash
cfd5012b13 fix(Core/Loot): Prevent re-loot of same corpse via completed-guid set 2026-05-08 22:17:52 +02:00
bash
edd27ee8e1 feat(Core/RPG): Wire makeShortCut into lastPath reuse for interrupt recovery 2026-05-08 21:32:38 +02:00
bash
eb416ca7e5 refactor(Core/Movement): Remove 70y dispatch cap and isMoving lastPath guard 2026-05-08 21:14:19 +02:00
bash
bbd814347c refactor(Core/RPG): Remove MoveFarTo loop-breaker (cmangos has no equivalent) 2026-05-08 21:07:28 +02:00
10 changed files with 209 additions and 245 deletions

View File

@ -79,7 +79,11 @@ bool OpenLootAction::Execute(Event /*event*/)
bool result = DoLoot(lootObject);
if (result)
{
AI_VALUE(LootObjectStack*, "available loot")->Remove(lootObject.guid);
// MarkCompleted (not Remove) — "add all loot" reads
// "nearest corpses" without a lootable filter, so a plain
// Remove lets the same corpse re-enter the stack on the next
// tick. The completed set blocks re-add for ~5 min.
AI_VALUE(LootObjectStack*, "available loot")->MarkCompleted(lootObject.guid);
context->GetValue<LootObject>("loot target")->Set(LootObject());
}
return result;
@ -514,7 +518,7 @@ bool StoreLootAction::Execute(Event event)
BroadcastHelper::BroadcastLootingItem(botAI, bot, proto);
}
AI_VALUE(LootObjectStack*, "available loot")->Remove(guid);
AI_VALUE(LootObjectStack*, "available loot")->MarkCompleted(guid);
// release loot
WorldPacket* packet = new WorldPacket(CMSG_LOOT_RELEASE, 8);

View File

@ -3334,33 +3334,18 @@ bool MovementAction::ExecuteTravelPlan(TravelPlan& state)
case PathNodeType::NODE_NODE:
{
// Batch consecutive walk points into one spline. Capped at
// 20 points OR ~70y of accumulated distance — whichever
// comes first. The distance cap gives the planner regular
// re-evaluation points without committing the whole
// remaining route up front; stepIdx advances exactly in
// step with what's actually dispatched, so the next tick
// picks up from the truncation point.
// 20 points per dispatch as a cheap upper bound on per-tick
// work; stepIdx advances exactly in step with what's
// dispatched, so the next tick picks up from the cutoff.
static constexpr uint32 MAX_SPLINE_POINTS = 20;
static constexpr float MAX_BATCH_LENGTH = 70.0f;
state.walkPoints.clear();
float accumulated = 0.f;
while (state.stepIdx < state.steps.size() && state.walkPoints.size() < MAX_SPLINE_POINTS)
{
const PathNodePoint& wp = state.steps[state.stepIdx];
if (wp.type != PathNodeType::NODE_PATH && wp.type != PathNodeType::NODE_NODE)
break;
G3D::Vector3 next(wp.point.GetPositionX(), wp.point.GetPositionY(), wp.point.GetPositionZ());
if (!state.walkPoints.empty())
{
accumulated += (next - state.walkPoints.back()).length();
if (accumulated >= MAX_BATCH_LENGTH)
{
state.walkPoints.push_back(next);
state.stepIdx++;
break;
}
}
state.walkPoints.push_back(next);
state.walkPoints.push_back(G3D::Vector3(wp.point.GetPositionX(),
wp.point.GetPositionY(), wp.point.GetPositionZ()));
state.stepIdx++;
}

View File

@ -492,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())

View File

@ -73,21 +73,23 @@ bool NewRpgBaseAction::MoveFarTo(WorldPosition dest)
{
float remaining = bot->GetExactDist(lastMove.lastMoveToX, lastMove.lastMoveToY, lastMove.lastMoveToZ);
if (remaining > 10.0f)
{
EmitDebugMove("MoveFar", "spline-plan",
lastMove.lastMoveToX, lastMove.lastMoveToY, lastMove.lastMoveToZ);
return true;
}
}
}
// 10% lastPath reuse — route commitment across combat
// interruptions. If the cached path's endpoint is still close
// (within 10%) to the new dest AND bot is mid-flight toward it
// (>10y away AND currently moving), reuse silently. The
// bot->isMoving() guard prevents reuse from short-circuiting
// when bot is stopped between dispatches — in that case we
// MUST fall through to dispatch the next leg, not return true
// and stand still.
// 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.
{
LastMovement& lastMove = AI_VALUE(LastMovement&, "last movement");
if (bot->isMoving() && !lastMove.lastPath.empty())
if (!lastMove.lastPath.empty())
{
WorldPosition lastBack = lastMove.lastPath.getBack();
if (lastBack.GetMapId() == dest.GetMapId())
@ -97,15 +99,49 @@ bool NewRpgBaseAction::MoveFarTo(WorldPosition dest)
float distFromBotToBack = bot->GetExactDist(&lastBack);
if (lastBack.distance(dest) < maxDistChange && distFromBotToBack > 10.0f)
{
char fails[32];
snprintf(fails, sizeof(fails), "mF=%d nF=%d",
botAI->rpgInfo.CountRecentAttempts(dest, false),
botAI->rpgInfo.CountRecentAttempts(dest, true));
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<WorldPosition> 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<uint32>((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(), fails);
dest.GetPositionX(), dest.GetPositionY(), dest.GetPositionZ());
return true;
}
}
// Path was cleared or collapsed — fall through to fresh planning.
}
}
}
}
@ -118,90 +154,47 @@ bool NewRpgBaseAction::MoveFarTo(WorldPosition dest)
//
// 2. Long-distance move (>= nodeFirstDis) and travel nodes
// enabled: try the node graph FIRST. The graph holds
// curated waypoints that avoid known bad terrain (fence
// edges, off-mesh holes, etc.); the chained mmap probe
// doesn't and routinely picks "longest reachable mesh"
// over "geometrically toward dest".
// curated waypoints that avoid known bad terrain.
//
// 3. If no node plan returned (or loop-breaker forced mmap):
// run the 40-step chained mmap probe and dispatch its
// waypoint chain.
// 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);
// Loop-breaker: count recent attempts of each strategy to this
// dest. If 3 of one strategy → flip to the other. If both have
// failed 3 times each → both exhausted; fall through to
// MoveFar:spline and rely on UnstuckAction (5/10 min) for the
// eventual hearthstone-out. Without the "both exhausted" branch
// we'd flip-flop forever as the buffer evicts.
bool forceMmapOverNodes = false; // 3 nodes failed -> try mmap
bool forceNodesOverMmap = false; // 3 mmap failed -> try nodes
bool bothExhausted = false;
if (tryNodes)
// 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())
{
int nodeFails = botAI->rpgInfo.CountRecentAttempts(dest, /*wasNodeTravel=*/true);
int mmapFails = botAI->rpgInfo.CountRecentAttempts(dest, /*wasNodeTravel=*/false);
if (nodeFails >= 3 && mmapFails >= 3)
bothExhausted = true; // give up, spline at dest
else if (nodeFails >= 3)
forceMmapOverNodes = true;
else if (mmapFails >= 3)
forceNodesOverMmap = true;
if (forceMmapOverNodes || forceNodesOverMmap || bothExhausted)
{
// Drop the in-flight plan if any; we're about to flip
// (or give up). Buffer is intentionally NOT cleared so
// we remember which strategies have already been tried
// — otherwise we'd flip-flop indefinitely as the buffer
// evicts old entries.
if (botAI->rpgInfo.HasActiveTravelPlan())
if (botAI->rpgInfo.travelPlan.destination.distance(dest) > 10.0f)
botAI->rpgInfo.ClearTravel();
}
}
// If a node plan is already active, ride it. The plan executor
// owns its own per-step transitions.
if (tryNodes && !forceMmapOverNodes && !bothExhausted && botAI->rpgInfo.HasActiveTravelPlan())
else
return UpdateTravelPlan();
}
// PRIORITY: try the travel-node graph FIRST when the move is
// long enough to need it. Mirrors cmangos ResolveMovePath:
// curated graph paths avoid the "longest reachable mesh"
// failure mode of the raw chained mmap probe (e.g. routing
// a bot up a tree because the wooden road extends 191y while
// the leftward terrain has a navmesh seam).
if (tryNodes && !forceMmapOverNodes && !bothExhausted)
// long enough to need it.
if (tryNodes)
{
StartTravelPlan(dest);
if (botAI->rpgInfo.HasActiveTravelPlan())
{
LOG_INFO("playerbots", "[MoveFar] {} nodetravel | dis={:.0f} | mmapFails={} nodeFails={} | flags={}{}{}",
bot->GetName(), dis,
botAI->rpgInfo.CountRecentAttempts(dest, false),
botAI->rpgInfo.CountRecentAttempts(dest, true),
forceMmapOverNodes ? "F-mmap " : "",
forceNodesOverMmap ? "F-nodes " : "",
bothExhausted ? "EXHAUST " : "");
char fails[32];
snprintf(fails, sizeof(fails), "mF=%d nF=%d",
botAI->rpgInfo.CountRecentAttempts(dest, false),
botAI->rpgInfo.CountRecentAttempts(dest, true));
LOG_INFO("playerbots", "[MoveFar] {} nodetravel | dis={:.0f}",
bot->GetName(), dis);
EmitDebugMove("MoveFar", "travelplan",
dest.GetPositionX(), dest.GetPositionY(), dest.GetPositionZ(), fails);
botAI->rpgInfo.RecordMoveFarAttempt(dest, /*wasNodeTravel=*/true);
dest.GetPositionX(), dest.GetPositionY(), dest.GetPositionZ());
return UpdateTravelPlan();
}
// Graph returned no plan — fall through to mmap probe.
}
else if (botAI->rpgInfo.HasActiveTravelPlan())
{
// We're forcing mmap (loop-breaker) or move dropped below
// node-first threshold — drop any leftover plan.
// Move dropped below node-first threshold — drop any leftover plan.
botAI->rpgInfo.ClearTravel();
}
@ -210,14 +203,67 @@ bool NewRpgBaseAction::MoveFarTo(WorldPosition dest)
WorldPosition botPos(bot);
std::vector<WorldPosition> 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<WorldPosition> 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<uint32>((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 — that shortcut produced the diagonal-
// through-air bug when we used MoveTo(endpoint) and let the
// motion master replan.
// Skip when both routing strategies have failed 3 times each.
if (!probe.empty() && !bothExhausted && probe.size() >= 2)
// intermediate points.
if (!probe.empty() && probe.size() >= 2)
{
WorldPosition stepDest = probe.back();
float endDistToDest = dest.GetExactDist(stepDest.GetPositionX(),
@ -236,46 +282,10 @@ bool NewRpgBaseAction::MoveFarTo(WorldPosition dest)
if (points.size() >= 2)
{
// Cap dispatched path length at ~70y. MoveFarTo's
// early-exit (top of function) lets the active spline
// run until bot is within 10y of its endpoint, then
// replans from the new position. Capping per-dispatch
// distance gives the planner regular re-evaluation
// points without the per-tick replan cost of fully
// unbounded chunks.
{
constexpr float maxDispatchLength = 70.0f;
float accumulated = 0.f;
size_t cutoff = points.size();
for (size_t i = 1; i < points.size(); ++i)
{
accumulated += (points[i] - points[i - 1]).length();
if (accumulated >= maxDispatchLength)
{
cutoff = i + 1;
break;
}
}
if (cutoff < points.size())
points.resize(cutoff);
}
LOG_INFO("playerbots", "[MoveFar] {} mmap-path | dis={:.0f} | endDist={:.0f} | wp={} | mmapFails={} nodeFails={} | flags={}{}{}",
bot->GetName(), dis, endDistToDest, (uint32)points.size(),
botAI->rpgInfo.CountRecentAttempts(dest, false),
botAI->rpgInfo.CountRecentAttempts(dest, true),
forceMmapOverNodes ? "F-mmap " : "",
forceNodesOverMmap ? "F-nodes " : "",
bothExhausted ? "EXHAUST " : "");
{
char fails[32];
snprintf(fails, sizeof(fails), "mF=%d nF=%d",
botAI->rpgInfo.CountRecentAttempts(dest, false),
botAI->rpgInfo.CountRecentAttempts(dest, true));
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, fails);
}
botAI->rpgInfo.RecordMoveFarAttempt(dest, /*wasNodeTravel=*/false);
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())
@ -315,23 +325,17 @@ bool NewRpgBaseAction::MoveFarTo(WorldPosition dest)
}
// Probe failed or didn't progress — emit visibility whisper so
// the user can see WHY mmap didn't dispatch. Without this the
// do-quest action's `MoveRandomNear` nudge appears with no
// preceding MoveFar whisper, and the failure mode is invisible.
// 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 fails[32];
snprintf(fails, sizeof(fails), "mF=%d nF=%d",
botAI->rpgInfo.CountRecentAttempts(dest, false),
botAI->rpgInfo.CountRecentAttempts(dest, true));
char const* reason = (probe.empty() || probe.size() < 2) ? "mmap-empty" : "mmap-noprogress";
EmitDebugMove("MoveFar", reason,
dest.GetPositionX(), dest.GetPositionY(),
dest.GetPositionZ(), fails);
dest.GetPositionZ());
}
}
@ -341,35 +345,19 @@ bool NewRpgBaseAction::MoveFarTo(WorldPosition dest)
// 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={} | mmapFails={} nodeFails={} | flags={}{}{}",
LOG_INFO("playerbots", "[MoveFar] {} spline | dis={:.0f} | probe.empty={} | LOS={}",
bot->GetName(), dis,
probe.empty() ? "y" : "n",
inLOS ? "y" : "n",
botAI->rpgInfo.CountRecentAttempts(dest, false),
botAI->rpgInfo.CountRecentAttempts(dest, true),
forceMmapOverNodes ? "F-mmap " : "",
forceNodesOverMmap ? "F-nodes " : "",
bothExhausted ? "EXHAUST " : "");
inLOS ? "y" : "n");
if (!inLOS)
{
char fails[32];
snprintf(fails, sizeof(fails), "mF=%d nF=%d",
botAI->rpgInfo.CountRecentAttempts(dest, false),
botAI->rpgInfo.CountRecentAttempts(dest, true));
EmitDebugMove("MoveFar", "spline-blocked",
dest.GetPositionX(), dest.GetPositionY(),
dest.GetPositionZ(), fails);
dest.GetPositionZ());
return false; // Refuse to dispatch a straight line through geometry.
}
{
char fails[32];
snprintf(fails, sizeof(fails), "mF=%d nF=%d",
botAI->rpgInfo.CountRecentAttempts(dest, false),
botAI->rpgInfo.CountRecentAttempts(dest, true));
EmitDebugMove("MoveFar", "spline",
dest.GetPositionX(), dest.GetPositionY(), dest.GetPositionZ(), fails);
}
botAI->rpgInfo.RecordMoveFarAttempt(dest, /*wasNodeTravel=*/false);
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(),
@ -473,6 +461,7 @@ bool NewRpgBaseAction::MoveRandomNear(float moveStep, MovementPriority priority,
}
}
EmitDebugMove("MoveRandomNear", "all-fail", x, y, z);
return false;
}

View File

@ -78,50 +78,6 @@ void NewRpgInfo::Reset()
data = Idle{};
startT = getMSTime();
ClearTravel();
// recentMoveFarAttempts is intentionally NOT cleared. Reset() runs
// on every state change (ChangeToDoQuest, ChangeToIdle, etc.) and
// the do-quest action oscillates through transitions during a
// failure cycle — wiping the deque here would prevent the
// MoveFarTo loop-breaker (nF >= 3 AND mF >= 3 → bothExhausted)
// from converging. CountRecentAttempts already filters by
// destination (within 10y), so stale entries for previous quests
// don't affect new ones.
}
void NewRpgInfo::RecordMoveFarAttempt(WorldPosition const& dest, bool wasNodeTravel)
{
// Cap at 6 (3 node + 3 mmap). The loop-breaker in MoveFarTo
// requires nF >= 3 AND mF >= 3 to declare bothExhausted. Each
// MoveFarTo failure cycle records BOTH a node attempt and a mmap
// attempt, so a single 3-cap deque would pop the older type
// before its count reached 3, structurally preventing
// bothExhausted from triggering.
if (recentMoveFarAttempts.size() >= 6)
recentMoveFarAttempts.pop_front();
MoveFarAttempt a;
a.dest = dest;
a.wasNodeTravel = wasNodeTravel;
a.timestamp = getMSTime();
recentMoveFarAttempts.push_back(a);
}
int NewRpgInfo::CountRecentAttempts(WorldPosition const& dest, bool wasNodeTravel) const
{
int count = 0;
for (auto const& a : recentMoveFarAttempts)
{
if (a.wasNodeTravel != wasNodeTravel)
continue;
// Treat destinations within 10y as "same dest" — small jitter
// from quest objective re-resolution shouldn't reset the loop
// detector.
if (a.dest.GetMapId() != dest.GetMapId())
continue;
if (a.dest.GetExactDist2dSq(&dest) > 10.0f * 10.0f)
continue;
++count;
}
return count;
}
NewRpgStatus NewRpgInfo::GetStatus()

View File

@ -83,22 +83,6 @@ struct NewRpgInfo
bool HasActiveTravelPlan() const { return travelPlan.IsActive(); }
void ClearTravel() { travelPlan.Reset(); }
// MoveFar attempt history. Records the last 3 path commits
// (node plan or mmap) so MoveFarTo can detect when the same
// dest + strategy has failed repeatedly and force the
// alternative routing this tick. Breaks deterministic-loop
// scenarios where the chained probe (or node graph) keeps
// returning the same dead-end path.
struct MoveFarAttempt
{
WorldPosition dest; // requested destination
bool wasNodeTravel{false}; // true=node plan, false=mmap/spline
uint32 timestamp{0};
};
std::deque<MoveFarAttempt> recentMoveFarAttempts;
void RecordMoveFarAttempt(WorldPosition const& dest, bool wasNodeTravel);
int CountRecentAttempts(WorldPosition const& dest, bool wasNodeTravel) const;
using RpgData = std::variant<
Idle,
GoGrind,

View File

@ -362,6 +362,13 @@ bool LootObject::IsLootPossible(Player* bot)
bool LootObjectStack::Add(ObjectGuid guid)
{
// expire old completed entries so a despawn/respawn with a reused
// guid can still be looted later
completedLoot.shrink(time(nullptr) - 300);
if (completedLoot.find(guid) != completedLoot.end())
return false;
if (availableLoot.size() >= MAX_LOOT_OBJECT_COUNT)
{
availableLoot.shrink(time(nullptr) - 30);
@ -385,7 +392,17 @@ void LootObjectStack::Remove(ObjectGuid guid)
availableLoot.erase(i);
}
void LootObjectStack::Clear() { availableLoot.clear(); }
void LootObjectStack::MarkCompleted(ObjectGuid guid)
{
Remove(guid);
completedLoot.insert(guid);
}
void LootObjectStack::Clear()
{
availableLoot.clear();
completedLoot.clear();
}
bool LootObjectStack::CanLoot(float maxDistance)
{

View File

@ -76,6 +76,7 @@ public:
bool Add(ObjectGuid guid);
void Remove(ObjectGuid guid);
void MarkCompleted(ObjectGuid guid);
void Clear();
bool CanLoot(float maxDistance);
LootObject GetLoot(float maxDistance = 0);
@ -85,6 +86,9 @@ private:
Player* bot;
LootTargetList availableLoot;
// Guids we already opened loot on; blocks "add all loot" from
// re-adding the same corpse before it despawns.
LootTargetList completedLoot;
};
#endif

View File

@ -678,7 +678,7 @@ void TravelNode::print([[maybe_unused]] bool printFailed)
}
// Attempts to move ahead of the path.
bool TravelPath::makeShortCut(WorldPosition startPos, float maxDist)
bool TravelPath::makeShortCut(WorldPosition startPos, float maxDist, Unit* bot)
{
if (GetPath().empty())
return false;
@ -691,10 +691,10 @@ bool TravelPath::makeShortCut(WorldPosition startPos, float maxDist)
for (auto& p : fullPath) // cycle over the full path
{
// if (p.point.GetMapId() != startPos.GetMapId())
// continue;
if (p.point.GetMapId() == startPos.GetMapId())
// Walkability filter (cmangos parity): portals/transports/taxis
// aren't valid anchor points — picking one as the new start of
// the trimmed path would leave the bot anchored on a hop.
if (p.point.GetMapId() == startPos.GetMapId() && p.isWalkable())
{
float curDist = p.point.sqDistance(startPos);
@ -737,8 +737,10 @@ bool TravelPath::makeShortCut(WorldPosition startPos, float maxDist)
WorldPosition beginPos = newPath.begin()->point;
// The old path seems to be the best.
if (beginPos.distance(firstNode) < sPlayerbotAIConfig.tooCloseDistance)
// The old path seems to be the best — either the closest walkable
// point IS the original front, or it's within tooCloseDistance.
if (newPath.front() == fullPath.front() ||
beginPos.distance(firstNode) < sPlayerbotAIConfig.tooCloseDistance)
return false;
// We are (nearly) on the new path. Just follow the rest.
@ -748,7 +750,11 @@ bool TravelPath::makeShortCut(WorldPosition startPos, float maxDist)
return true;
}
std::vector<WorldPosition> toPath = startPos.getPathTo(beginPos, nullptr);
// Pass the bot into getPathTo so PathGenerator picks up its
// collision / swimming / flying flags. cmangos parity — passing
// nullptr here drops to a default mover and can produce a path
// the bot itself can't actually walk.
std::vector<WorldPosition> toPath = startPos.getPathTo(beginPos, bot);
// We can not reach the new begin position. Follow the complete path.
if (!beginPos.isPathTo(toPath))

View File

@ -427,6 +427,16 @@ struct PathNodePoint
WorldPosition point;
PathNodeType type = PathNodeType::NODE_PATH;
uint32 entry = 0;
bool operator==(const PathNodePoint& p1) const
{
return point == p1.point && type == p1.type && entry == p1.entry;
}
// A "walkable" node is one we traverse on foot. Portals/transports/
// taxis/teleports are entry/exit hops, not points to anchor a
// shortcut on. Used by makeShortCut to skip them when picking the
// closest-point-on-path to the bot.
bool isWalkable() const { return (uint8)type <= (uint8)PathNodeType::NODE_NODE; }
};
// A complete list of points the bots has to walk to or teleport to.
@ -481,7 +491,7 @@ public:
return retVec;
}
bool makeShortCut(WorldPosition startPos, float maxDist);
bool makeShortCut(WorldPosition startPos, float maxDist, Unit* bot = nullptr);
std::ostringstream const print();