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); bool result = DoLoot(lootObject);
if (result) 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()); context->GetValue<LootObject>("loot target")->Set(LootObject());
} }
return result; return result;
@ -514,7 +518,7 @@ bool StoreLootAction::Execute(Event event)
BroadcastHelper::BroadcastLootingItem(botAI, bot, proto); BroadcastHelper::BroadcastLootingItem(botAI, bot, proto);
} }
AI_VALUE(LootObjectStack*, "available loot")->Remove(guid); AI_VALUE(LootObjectStack*, "available loot")->MarkCompleted(guid);
// release loot // release loot
WorldPacket* packet = new WorldPacket(CMSG_LOOT_RELEASE, 8); WorldPacket* packet = new WorldPacket(CMSG_LOOT_RELEASE, 8);

View File

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

View File

@ -492,6 +492,15 @@ bool NewRpgDoQuestAction::DoCompletedQuest(NewRpgInfo::DoQuest& data)
data.lastReachPOI = 0; data.lastReachPOI = 0;
data.pos = pos; data.pos = pos;
data.objectiveIdx = -1; 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()) 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); float remaining = bot->GetExactDist(lastMove.lastMoveToX, lastMove.lastMoveToY, lastMove.lastMoveToZ);
if (remaining > 10.0f) if (remaining > 10.0f)
{
EmitDebugMove("MoveFar", "spline-plan",
lastMove.lastMoveToX, lastMove.lastMoveToY, lastMove.lastMoveToZ);
return true; return true;
}
} }
} }
// 10% lastPath reuse — route commitment across combat // 10% lastPath reuse — if the cached path's endpoint is still
// interruptions. If the cached path's endpoint is still close // close (within 10%) to the new dest, trim the cached path to
// (within 10%) to the new dest AND bot is mid-flight toward it // the bot's current position via makeShortCut and re-dispatch.
// (>10y away AND currently moving), reuse silently. The // Mirrors cmangos ResolveMovePath: per-tick re-dispatch of the
// bot->isMoving() guard prevents reuse from short-circuiting // (trimmed) last path keeps the bot on-route after interrupts
// when bot is stopped between dispatches — in that case we // (knockback, combat, manual move) without needing a full replan.
// MUST fall through to dispatch the next leg, not return true
// and stand still.
{ {
LastMovement& lastMove = AI_VALUE(LastMovement&, "last movement"); LastMovement& lastMove = AI_VALUE(LastMovement&, "last movement");
if (bot->isMoving() && !lastMove.lastPath.empty()) if (!lastMove.lastPath.empty())
{ {
WorldPosition lastBack = lastMove.lastPath.getBack(); WorldPosition lastBack = lastMove.lastPath.getBack();
if (lastBack.GetMapId() == dest.GetMapId()) if (lastBack.GetMapId() == dest.GetMapId())
@ -97,13 +99,47 @@ bool NewRpgBaseAction::MoveFarTo(WorldPosition dest)
float distFromBotToBack = bot->GetExactDist(&lastBack); float distFromBotToBack = bot->GetExactDist(&lastBack);
if (lastBack.distance(dest) < maxDistChange && distFromBotToBack > 10.0f) if (lastBack.distance(dest) < maxDistChange && distFromBotToBack > 10.0f)
{ {
char fails[32]; WorldPosition botPos(bot);
snprintf(fails, sizeof(fails), "mF=%d nF=%d", lastMove.lastPath.makeShortCut(botPos, sPlayerbotAIConfig.reactDistance, bot);
botAI->rpgInfo.CountRecentAttempts(dest, false),
botAI->rpgInfo.CountRecentAttempts(dest, true)); // makeShortCut may clear the path if the bot drifted
EmitDebugMove("MoveFar", "reuse", // too far off (>reactDistance from any waypoint). In
dest.GetPositionX(), dest.GetPositionY(), dest.GetPositionZ(), fails); // that case fall through to fresh planning.
return true; 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());
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 // 2. Long-distance move (>= nodeFirstDis) and travel nodes
// enabled: try the node graph FIRST. The graph holds // enabled: try the node graph FIRST. The graph holds
// curated waypoints that avoid known bad terrain (fence // curated waypoints that avoid known bad terrain.
// edges, off-mesh holes, etc.); the chained mmap probe
// doesn't and routinely picks "longest reachable mesh"
// over "geometrically toward dest".
// //
// 3. If no node plan returned (or loop-breaker forced mmap): // 3. If no node plan returned: run the 40-step chained mmap
// run the 40-step chained mmap probe and dispatch its // probe and dispatch its waypoint chain.
// waypoint chain.
// //
// 4. Empty / non-progressing probe: fall back to single- // 4. Empty / non-progressing probe: fall back to single-
// waypoint spline at dest. // waypoint spline at dest.
bool tryNodes = (dis >= nodeFirstDis && sPlayerbotAIConfig.enableTravelNodes); bool tryNodes = (dis >= nodeFirstDis && sPlayerbotAIConfig.enableTravelNodes);
// Loop-breaker: count recent attempts of each strategy to this // If a node plan is already active, ride it — but only if its
// dest. If 3 of one strategy → flip to the other. If both have // destination still matches the requested dest. Otherwise the
// failed 3 times each → both exhausted; fall through to // old plan (e.g. built toward a quest objective POI) would keep
// MoveFar:spline and rely on UnstuckAction (5/10 min) for the // driving the bot after the caller switched targets (e.g. to a
// eventual hearthstone-out. Without the "both exhausted" branch // turn-in NPC). cmangos's ResolveMovePath dodges this by being
// we'd flip-flop forever as the buffer evicts. // stateless; we have a long-lived plan flag, so check explicitly.
bool forceMmapOverNodes = false; // 3 nodes failed -> try mmap if (tryNodes && botAI->rpgInfo.HasActiveTravelPlan())
bool forceNodesOverMmap = false; // 3 mmap failed -> try nodes
bool bothExhausted = false;
if (tryNodes)
{ {
int nodeFails = botAI->rpgInfo.CountRecentAttempts(dest, /*wasNodeTravel=*/true); if (botAI->rpgInfo.travelPlan.destination.distance(dest) > 10.0f)
int mmapFails = botAI->rpgInfo.CountRecentAttempts(dest, /*wasNodeTravel=*/false); botAI->rpgInfo.ClearTravel();
else
if (nodeFails >= 3 && mmapFails >= 3) return UpdateTravelPlan();
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())
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())
return UpdateTravelPlan();
// PRIORITY: try the travel-node graph FIRST when the move is // PRIORITY: try the travel-node graph FIRST when the move is
// long enough to need it. Mirrors cmangos ResolveMovePath: // long enough to need it.
// curated graph paths avoid the "longest reachable mesh" if (tryNodes)
// 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)
{ {
StartTravelPlan(dest); StartTravelPlan(dest);
if (botAI->rpgInfo.HasActiveTravelPlan()) if (botAI->rpgInfo.HasActiveTravelPlan())
{ {
LOG_INFO("playerbots", "[MoveFar] {} nodetravel | dis={:.0f} | mmapFails={} nodeFails={} | flags={}{}{}", LOG_INFO("playerbots", "[MoveFar] {} nodetravel | dis={:.0f}",
bot->GetName(), dis, 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));
EmitDebugMove("MoveFar", "travelplan", EmitDebugMove("MoveFar", "travelplan",
dest.GetPositionX(), dest.GetPositionY(), dest.GetPositionZ(), fails); dest.GetPositionX(), dest.GetPositionY(), dest.GetPositionZ());
botAI->rpgInfo.RecordMoveFarAttempt(dest, /*wasNodeTravel=*/true);
return UpdateTravelPlan(); return UpdateTravelPlan();
} }
// Graph returned no plan — fall through to mmap probe. // Graph returned no plan — fall through to mmap probe.
} }
else if (botAI->rpgInfo.HasActiveTravelPlan()) else if (botAI->rpgInfo.HasActiveTravelPlan())
{ {
// We're forcing mmap (loop-breaker) or move dropped below // Move dropped below node-first threshold — drop any leftover plan.
// node-first threshold — drop any leftover plan.
botAI->rpgInfo.ClearTravel(); botAI->rpgInfo.ClearTravel();
} }
@ -210,14 +203,67 @@ bool NewRpgBaseAction::MoveFarTo(WorldPosition dest)
WorldPosition botPos(bot); WorldPosition botPos(bot);
std::vector<WorldPosition> probe = botPos.getPathTo(dest, 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. // Walk the chained probe's full waypoint chain via MoveSplinePath.
// Handing the full waypoint vector to the motion master removes // Handing the full waypoint vector to the motion master removes
// its discretion to introduce a straight-line shortcut between // its discretion to introduce a straight-line shortcut between
// intermediate points — that shortcut produced the diagonal- // intermediate points.
// through-air bug when we used MoveTo(endpoint) and let the if (!probe.empty() && probe.size() >= 2)
// motion master replan.
// Skip when both routing strategies have failed 3 times each.
if (!probe.empty() && !bothExhausted && probe.size() >= 2)
{ {
WorldPosition stepDest = probe.back(); WorldPosition stepDest = probe.back();
float endDistToDest = dest.GetExactDist(stepDest.GetPositionX(), float endDistToDest = dest.GetExactDist(stepDest.GetPositionX(),
@ -236,46 +282,10 @@ bool NewRpgBaseAction::MoveFarTo(WorldPosition dest)
if (points.size() >= 2) if (points.size() >= 2)
{ {
// Cap dispatched path length at ~70y. MoveFarTo's LOG_INFO("playerbots", "[MoveFar] {} mmap-path | dis={:.0f} | endDist={:.0f} | wp={}",
// early-exit (top of function) lets the active spline bot->GetName(), dis, endDistToDest, (uint32)points.size());
// run until bot is within 10y of its endpoint, then EmitDebugMove("MoveFar", "mmap",
// replans from the new position. Capping per-dispatch points.back().x, points.back().y, points.back().z);
// 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));
EmitDebugMove("MoveFar", "mmap",
points.back().x, points.back().y, points.back().z, fails);
}
botAI->rpgInfo.RecordMoveFarAttempt(dest, /*wasNodeTravel=*/false);
// Mount up if outdoors and not in combat. // Mount up if outdoors and not in combat.
if (!bot->IsMounted() && !bot->IsInCombat() && bot->IsOutdoors() && bot->IsAlive()) 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 // Probe failed or didn't progress — emit visibility whisper so
// the user can see WHY mmap didn't dispatch. Without this the // the user can see WHY mmap didn't dispatch.
// do-quest action's `MoveRandomNear` nudge appears with no
// preceding MoveFar whisper, and the failure mode is invisible.
{ {
bool const probeProgressed = !probe.empty() && probe.size() >= 2 && bool const probeProgressed = !probe.empty() && probe.size() >= 2 &&
(dest.GetExactDist(probe.back().GetPositionX(), (dest.GetExactDist(probe.back().GetPositionX(),
probe.back().GetPositionY(), probe.back().GetPositionZ()) + 5.0f < disToDest); probe.back().GetPositionY(), probe.back().GetPositionZ()) + 5.0f < disToDest);
if (!probeProgressed) 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"; char const* reason = (probe.empty() || probe.size() < 2) ? "mmap-empty" : "mmap-noprogress";
EmitDebugMove("MoveFar", reason, EmitDebugMove("MoveFar", reason,
dest.GetPositionX(), dest.GetPositionY(), 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 // produces visible clipping/glitching. If LOS is blocked we
// refuse and let UnstuckAction (5/10 min) catch the stuck. // refuse and let UnstuckAction (5/10 min) catch the stuck.
bool const inLOS = bot->IsWithinLOS(dest.GetPositionX(), dest.GetPositionY(), dest.GetPositionZ()); 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, bot->GetName(), dis,
probe.empty() ? "y" : "n", probe.empty() ? "y" : "n",
inLOS ? "y" : "n", inLOS ? "y" : "n");
botAI->rpgInfo.CountRecentAttempts(dest, false),
botAI->rpgInfo.CountRecentAttempts(dest, true),
forceMmapOverNodes ? "F-mmap " : "",
forceNodesOverMmap ? "F-nodes " : "",
bothExhausted ? "EXHAUST " : "");
if (!inLOS) 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", EmitDebugMove("MoveFar", "spline-blocked",
dest.GetPositionX(), dest.GetPositionY(), dest.GetPositionX(), dest.GetPositionY(),
dest.GetPositionZ(), fails); dest.GetPositionZ());
return false; // Refuse to dispatch a straight line through geometry. return false; // Refuse to dispatch a straight line through geometry.
} }
{ EmitDebugMove("MoveFar", "spline",
char fails[32]; dest.GetPositionX(), dest.GetPositionY(), dest.GetPositionZ());
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);
// Same exact_waypoint=false rationale as the mmap branch — terrain- // Same exact_waypoint=false rationale as the mmap branch — terrain-
// following spline, not a straight diagonal. // following spline, not a straight diagonal.
return MoveTo(dest.GetMapId(), dest.GetPositionX(), dest.GetPositionY(), dest.GetPositionZ(), 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; return false;
} }

View File

@ -78,50 +78,6 @@ void NewRpgInfo::Reset()
data = Idle{}; data = Idle{};
startT = getMSTime(); startT = getMSTime();
ClearTravel(); 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() NewRpgStatus NewRpgInfo::GetStatus()

View File

@ -83,22 +83,6 @@ struct NewRpgInfo
bool HasActiveTravelPlan() const { return travelPlan.IsActive(); } bool HasActiveTravelPlan() const { return travelPlan.IsActive(); }
void ClearTravel() { travelPlan.Reset(); } 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< using RpgData = std::variant<
Idle, Idle,
GoGrind, GoGrind,

View File

@ -362,6 +362,13 @@ bool LootObject::IsLootPossible(Player* bot)
bool LootObjectStack::Add(ObjectGuid guid) 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) if (availableLoot.size() >= MAX_LOOT_OBJECT_COUNT)
{ {
availableLoot.shrink(time(nullptr) - 30); availableLoot.shrink(time(nullptr) - 30);
@ -385,7 +392,17 @@ void LootObjectStack::Remove(ObjectGuid guid)
availableLoot.erase(i); 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) bool LootObjectStack::CanLoot(float maxDistance)
{ {

View File

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

View File

@ -678,7 +678,7 @@ void TravelNode::print([[maybe_unused]] bool printFailed)
} }
// Attempts to move ahead of the path. // 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()) if (GetPath().empty())
return false; return false;
@ -691,10 +691,10 @@ bool TravelPath::makeShortCut(WorldPosition startPos, float maxDist)
for (auto& p : fullPath) // cycle over the full path for (auto& p : fullPath) // cycle over the full path
{ {
// if (p.point.GetMapId() != startPos.GetMapId()) // Walkability filter (cmangos parity): portals/transports/taxis
// continue; // 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()) if (p.point.GetMapId() == startPos.GetMapId() && p.isWalkable())
{ {
float curDist = p.point.sqDistance(startPos); float curDist = p.point.sqDistance(startPos);
@ -737,8 +737,10 @@ bool TravelPath::makeShortCut(WorldPosition startPos, float maxDist)
WorldPosition beginPos = newPath.begin()->point; WorldPosition beginPos = newPath.begin()->point;
// The old path seems to be the best. // The old path seems to be the best — either the closest walkable
if (beginPos.distance(firstNode) < sPlayerbotAIConfig.tooCloseDistance) // point IS the original front, or it's within tooCloseDistance.
if (newPath.front() == fullPath.front() ||
beginPos.distance(firstNode) < sPlayerbotAIConfig.tooCloseDistance)
return false; return false;
// We are (nearly) on the new path. Just follow the rest. // We are (nearly) on the new path. Just follow the rest.
@ -748,7 +750,11 @@ bool TravelPath::makeShortCut(WorldPosition startPos, float maxDist)
return true; 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. // We can not reach the new begin position. Follow the complete path.
if (!beginPos.isPathTo(toPath)) if (!beginPos.isPathTo(toPath))

View File

@ -427,6 +427,16 @@ struct PathNodePoint
WorldPosition point; WorldPosition point;
PathNodeType type = PathNodeType::NODE_PATH; PathNodeType type = PathNodeType::NODE_PATH;
uint32 entry = 0; 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. // A complete list of points the bots has to walk to or teleport to.
@ -481,7 +491,7 @@ public:
return retVec; return retVec;
} }
bool makeShortCut(WorldPosition startPos, float maxDist); bool makeShortCut(WorldPosition startPos, float maxDist, Unit* bot = nullptr);
std::ostringstream const print(); std::ostringstream const print();