Compare commits

..

No commits in common. "ba1b429bc54fbc5d4a69d7430301ea94cee59b81" and "9cc0e5cbb0bc2d32451c2289a2d79887777d705c" have entirely different histories.

6 changed files with 144 additions and 131 deletions

View File

@ -173,7 +173,7 @@ std::vector<uint32> const vFlagsIC = {GO_HORDE_BANNER,
GO_HORDE_BANNER_GRAVEYARD_H, GO_HORDE_BANNER_GRAVEYARD_H,
GO_HORDE_BANNER_GRAVEYARD_H_CONT}; GO_HORDE_BANNER_GRAVEYARD_H_CONT};
// BG Waypoints // BG Waypoints (vmangos)
// Horde Flag Room to Horde Graveyard // Horde Flag Room to Horde Graveyard
BattleBotPath vPath_WSG_HordeFlagRoom_to_HordeGraveyard = { BattleBotPath vPath_WSG_HordeFlagRoom_to_HordeGraveyard = {

View File

@ -386,8 +386,14 @@ bool MovementAction::MoveTo(uint32 mapId, float x, float y, float z, bool idle,
else else
{ {
// Direct dispatch — engine MovePoint(generatePath=true) handles // Direct dispatch — engine MovePoint(generatePath=true) handles
// pathfinding. Avoid ±z probes: their "shortest path" preference // path-finding internally. Previously called SearchForBestPath
// can pick an unreachable ledge and air-walk via NOPATH fallback. // here to probe ±step around the target z; that helped find
// polygons when the input z was several yards off the navmesh,
// but its "shortest path" preference would shift modifiedZ to
// an unreachable nearby polygon (upper terrace, ledge above)
// and then the engine's straight-spline NOPATH fallback would
// air-walk the bot up to it. cmangos doesn't have an
// equivalent — single-z PathFinder call is sufficient.
float distance = bot->GetExactDist(x, y, z); float distance = bot->GetExactDist(x, y, z);
if (distance > 0.01f) if (distance > 0.01f)
{ {
@ -1173,7 +1179,7 @@ void MovementAction::UpdateMovementState()
// { // {
// bot->SetSpeedRate(MOVE_RUN, 1.0f); // bot->SetSpeedRate(MOVE_RUN, 1.0f);
// } // }
// check if target is not reachable // check if target is not reachable (from Vmangos)
// if (bot->GetMotionMaster()->GetCurrentMovementGeneratorType() == CHASE_MOTION_TYPE && bot->CanNotReachTarget() && // if (bot->GetMotionMaster()->GetCurrentMovementGeneratorType() == CHASE_MOTION_TYPE && bot->CanNotReachTarget() &&
// !bot->InBattleground()) // !bot->InBattleground())
// { // {
@ -3215,12 +3221,22 @@ bool MovementAction::RefineWalkPoints(std::vector<G3D::Vector3>& walkPoints)
WorldPosition aPos(mapId, a.x, a.y, a.z); WorldPosition aPos(mapId, a.x, a.y, a.z);
WorldPosition bPos(mapId, b.x, b.y, b.z); WorldPosition bPos(mapId, b.x, b.y, b.z);
// Per-segment mmap query: routes around geometry the offline // Per-segment mmap query against the live navmesh. The
// graph didn't account for, or returns empty if unreachable. // travel-node graph stores offline-baked waypoints; if the
// straight line A->B crosses geometry the live navmesh has
// (mountain, ledge, model edit since offline gen), this
// returns either an mmap-routed path around it (NORMAL/
// INCOMPLETE) or empty (NOT_USING_PATH was rejected as
// "would walk through walls").
std::vector<WorldPosition> segPath = bPos.getPathStepFrom(aPos, bot); std::vector<WorldPosition> segPath = bPos.getPathStepFrom(aPos, bot);
// Trust the raw waypoint pair when mmap can't validate it — // Travelnode waypoints are authoritative once a plan is
// navmesh gaps/tile-edge artifacts shouldn't kill an active plan. // active. When AC mmap can't validate the segment (empty
// result, or IsPathCheating rejects a 2-point shortcut /
// steep hop), fall back to dispatching the raw (A, B) pair
// instead of aborting the plan. Common cases: cmangos
// waypoints landing in 1y navmesh gaps from AC extractor
// differences, tile-edge artifacts at zone borders.
bool const trustRaw = segPath.empty() || bool const trustRaw = segPath.empty() ||
TravelPath::IsPathCheating(segPath, aPos.distance(bPos)); TravelPath::IsPathCheating(segPath, aPos.distance(bPos));
@ -3232,8 +3248,9 @@ bool MovementAction::RefineWalkPoints(std::vector<G3D::Vector3>& walkPoints)
continue; continue;
} }
// Include the first segment's start; skip subsequent starts // First segment: include its start point so the spline
// to avoid duplicating the prior segment's tail. // begins from the original A. Later segments: skip the first
// point — it duplicates the previous segment's tail.
size_t startK = (i == 0) ? 0 : 1; size_t startK = (i == 0) ? 0 : 1;
for (size_t k = startK; k < segPath.size(); ++k) for (size_t k = startK; k < segPath.size(); ++k)
refined.emplace_back(segPath[k].GetPositionX(), refined.emplace_back(segPath[k].GetPositionX(),
@ -3284,6 +3301,15 @@ bool MovementAction::GetTravelPlan(TravelPlan& plan, WorldPosition destination)
{ {
WorldPosition botPos(bot->GetMapId(), bot->GetPositionX(), WorldPosition botPos(bot->GetMapId(), bot->GetPositionX(),
bot->GetPositionY(), bot->GetPositionZ()); bot->GetPositionY(), bot->GetPositionZ());
LOG_DEBUG("playerbots",
"[TravelPlan] {} requesting plan: from ({:.0f},{:.0f},{:.0f}) map={} zone={} → "
"({:.0f},{:.0f},{:.0f}) map={} (straight={:.0f}yd)",
bot->GetName(), botPos.GetPositionX(), botPos.GetPositionY(), botPos.GetPositionZ(),
bot->GetMapId(), bot->GetZoneId(),
destination.GetPositionX(), destination.GetPositionY(), destination.GetPositionZ(),
destination.GetMapId(), botPos.fDist(destination));
return sTravelNodeMap.GetFullPath(plan, botPos, bot->GetZoneId(), destination, bot); return sTravelNodeMap.GetFullPath(plan, botPos, bot->GetZoneId(), destination, bot);
} }
@ -3447,8 +3473,14 @@ bool MovementAction::ExecuteTravelPlan(TravelPlan& state)
return true; return true;
} }
// Re-validate each segment against the live navmesh and // Re-validate each consecutive (A, B) pair against the
// substitute mmap-routed sub-paths where needed. // live navmesh. The graph's offline-baked coords can
// produce a chain whose straight-line interpolation
// passes through geometry (mountains, ledges, model
// edits). RefineWalkPoints substitutes mmap-routed
// sub-paths between each pair; if any segment is
// unwalkable, abort the plan so MoveFarTo's own probe
// can re-derive a route.
if (!RefineWalkPoints(state.walkPoints)) if (!RefineWalkPoints(state.walkPoints))
{ {
G3D::Vector3 const& failPt = state.walkPoints.empty() G3D::Vector3 const& failPt = state.walkPoints.empty()
@ -3607,7 +3639,8 @@ bool MovementAction::ExecuteTravelPlan(TravelPlan& state)
if (bot->IsMounted()) if (bot->IsMounted())
bot->Dismount(); bot->Dismount();
bot->ActivateTaxiPathTo(state.route, flightMaster, 0); if (bot->ActivateTaxiPathTo(state.route, flightMaster, 0))
LOG_DEBUG("playerbots","[TravelPlan] Bot {} taking flight ({} nodes)", bot->GetName(), state.route.size());
state.route.clear(); state.route.clear();
state.stepIdx += 2; state.stepIdx += 2;

View File

@ -433,45 +433,6 @@ bool NewRpgDoQuestAction::DoIncompleteQuest(NewRpgInfo::DoQuest& data)
return MoveRandomNear(20.0f); return MoveRandomNear(20.0f);
} }
// Kill-quest scout: at POI for 30s+ with no quest mob in sight
// means this cluster is empty. Switch to a different POI candidate
// (>50y away) if one exists; otherwise roam in place.
constexpr uint32 scoutTimeoutMs = 30 * 1000;
if (data.lastReachPOI && GetMSTimeDiffToNow(data.lastReachPOI) >= scoutTimeoutMs &&
!HasNearbyQuestMob(30.0f))
{
std::vector<POIInfo> poiInfo;
if (GetQuestPOIPosAndObjectiveIdx(questId, poiInfo))
{
std::vector<size_t> alternatives;
for (size_t i = 0; i < poiInfo.size(); ++i)
{
float dx = poiInfo[i].pos.x - data.pos.GetPositionX();
float dy = poiInfo[i].pos.y - data.pos.GetPositionY();
if (dx * dx + dy * dy > 50.0f * 50.0f)
alternatives.push_back(i);
}
if (!alternatives.empty())
{
size_t pickIdx = alternatives[urand(0, alternatives.size() - 1)];
G3D::Vector2 newPoi = poiInfo[pickIdx].pos;
float dz = std::max(bot->GetMap()->GetHeight(newPoi.x, newPoi.y, MAX_HEIGHT),
bot->GetMap()->GetWaterLevel(newPoi.x, newPoi.y));
if (dz != INVALID_HEIGHT && dz != VMAP_INVALID_HEIGHT_VALUE)
{
data.pos = WorldPosition(bot->GetMapId(), newPoi.x, newPoi.y, dz);
data.objectiveIdx = poiInfo[pickIdx].objectiveIdx;
data.lastReachPOI = 0;
data.pursuedLootGO.Clear();
data.pursuedUseGO.Clear();
data.pursuedUseTarget.Clear();
return true;
}
}
}
return MoveRandomNear(20.0f);
}
// kill quest: walk toward the marker before handing off to grind. // kill quest: walk toward the marker before handing off to grind.
// lastReachPOI trips at ~10y so without this the bot fights on the // 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 // edge and never reaches the dense cluster. Skip if a quest mob is

View File

@ -70,8 +70,11 @@ bool NewRpgBaseAction::MoveFarTo(WorldPosition dest)
} }
} }
// Let an in-flight spline finish before recomputing — prevents // Let previously committed movement finish before recomputing.
// oscillation when re-resolve produces a slightly different endpoint. // If the bot is still actively walking toward its last committed
// point on the same map, just let the current spline finish.
// Prevents oscillation when a re-resolve produces a slightly
// different partial-path endpoint mid-walk.
{ {
LastMovement& lastMove = AI_VALUE(LastMovement&, "last movement"); LastMovement& lastMove = AI_VALUE(LastMovement&, "last movement");
if (bot->isMoving() && lastMove.lastMoveToMapId == bot->GetMapId()) if (bot->isMoving() && lastMove.lastMoveToMapId == bot->GetMapId())
@ -89,9 +92,9 @@ bool NewRpgBaseAction::MoveFarTo(WorldPosition dest)
// 10% lastPath reuse — if the cached path's endpoint is still // 10% lastPath reuse — if the cached path's endpoint is still
// close (within 10%) to the new dest, trim the cached path to // close (within 10%) to the new dest, trim the cached path to
// the bot's current position via makeShortCut and re-dispatch. // the bot's current position via makeShortCut and re-dispatch.
// Per-tick re-dispatch of the (trimmed) last path keeps the bot // Mirrors cmangos ResolveMovePath: per-tick re-dispatch of the
// on-route after interrupts (knockback, combat, manual move) // (trimmed) last path keeps the bot on-route after interrupts
// without needing a full replan. // (knockback, combat, manual move) without needing a full replan.
{ {
LastMovement& lastMove = AI_VALUE(LastMovement&, "last movement"); LastMovement& lastMove = AI_VALUE(LastMovement&, "last movement");
if (!lastMove.lastPath.empty()) if (!lastMove.lastPath.empty())
@ -136,16 +139,26 @@ bool NewRpgBaseAction::MoveFarTo(WorldPosition dest)
float disToDest = bot->GetDistance(dest); float disToDest = bot->GetDistance(dest);
float dis = bot->GetExactDist(dest); float dis = bot->GetExactDist(dest);
// Try the travel-node graph first for cross-map or > 50y moves; // Decision tree:
// fall back to chained mmap probe otherwise. BGs skip the graph. // 1. Active node plan with matching dest → ride it.
constexpr float TRAVELNODE_THRESHOLD = 50.0f; // 2. Long-distance / cross-map: try the node graph FIRST.
// Graph internally probes mmap and falls back to A* route.
// 3. Else: 40-step chained mmap probe + regression guard.
// 4. Empty / non-progressing probe: single-waypoint MoveTo.
//
// needsLongPath gate — cross-map or > 75y go to graph.
// BG gating: graph holds open-world routes only.
constexpr float TRAVELNODE_THRESHOLD = 75.0f;
bool tryNodes = sPlayerbotAIConfig.enableTravelNodes && bool tryNodes = sPlayerbotAIConfig.enableTravelNodes &&
!bot->InBattleground() && !bot->InBattleground() &&
((bot->GetMapId() != dest.GetMapId()) || ((bot->GetMapId() != dest.GetMapId()) ||
(dis > TRAVELNODE_THRESHOLD)); (dis > TRAVELNODE_THRESHOLD));
// Ride the active node plan only if its dest still matches. // If a node plan is already active, ride it — but only if its
// A stale plan would steer the bot past a new target. // 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).
if (tryNodes && botAI->rpgInfo.HasActiveTravelPlan()) if (tryNodes && botAI->rpgInfo.HasActiveTravelPlan())
{ {
if (botAI->rpgInfo.travelPlan.destination.distance(dest) > 10.0f) if (botAI->rpgInfo.travelPlan.destination.distance(dest) > 10.0f)
@ -161,6 +174,8 @@ bool NewRpgBaseAction::MoveFarTo(WorldPosition dest)
StartTravelPlan(dest); StartTravelPlan(dest);
if (botAI->rpgInfo.HasActiveTravelPlan()) if (botAI->rpgInfo.HasActiveTravelPlan())
{ {
LOG_INFO("playerbots", "[MoveFar] {} nodetravel | dis={:.0f}",
bot->GetName(), dis);
EmitDebugMove("MoveFar", "travelplan", EmitDebugMove("MoveFar", "travelplan",
dest.GetPositionX(), dest.GetPositionY(), dest.GetPositionZ()); dest.GetPositionX(), dest.GetPositionY(), dest.GetPositionZ());
return UpdateTravelPlan(); return UpdateTravelPlan();
@ -178,8 +193,13 @@ 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: prefer cached lastPath if it still ends closer // Regression guard (cmangos ResolveMovePath parity): if a cached
// to dest than the new probe — catches probes blocked by geometry. // 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"); LastMovement& lastMove = AI_VALUE(LastMovement&, "last movement");
if (!lastMove.lastPath.empty() && !probe.empty() && probe.size() >= 2) if (!lastMove.lastPath.empty() && !probe.empty() && probe.size() >= 2)
@ -227,6 +247,9 @@ bool NewRpgBaseAction::MoveFarTo(WorldPosition dest)
if (points.size() >= 2) if (points.size() >= 2)
{ {
LOG_INFO("playerbots", "[MoveFar] {} mmap-path | dis={:.0f} | endDist={:.0f} | wp={}",
bot->GetName(), dis, endDistToDest, (uint32)points.size());
// 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())
botAI->DoSpecificAction("check mount state", Event(), true); botAI->DoSpecificAction("check mount state", Event(), true);
@ -251,20 +274,13 @@ bool NewRpgBaseAction::MoveFarTo(WorldPosition dest)
} }
} }
// Empty-probe fallback: single-waypoint MoveTo via engine PathGenerator. // Empty-probe fallback — single-waypoint MoveTo. Engine
// Cross-map can't be served by a single-map spline — bail. // MovePoint(generatePath=true) does the local PathGenerator
// resolution. Cross-map can't be served by a single-map spline
// — bail rather than dispatching toward unreachable coords.
if (bot->GetMapId() != dest.GetMapId()) if (bot->GetMapId() != dest.GetMapId())
return false; return false;
// LOS gate: don't air-walk through trees/walls when the engine
// would otherwise drop to a straight-line BuildShortcut spline.
if (!bot->IsWithinLOS(dest.GetPositionX(), dest.GetPositionY(), dest.GetPositionZ()))
{
EmitDebugMove("MoveFar", "spline-blocked",
dest.GetPositionX(), dest.GetPositionY(), dest.GetPositionZ());
return false;
}
EmitDebugMove("MoveFar", "spline", EmitDebugMove("MoveFar", "spline",
dest.GetPositionX(), dest.GetPositionY(), dest.GetPositionZ()); dest.GetPositionX(), dest.GetPositionY(), dest.GetPositionZ());
return MoveTo(dest.GetMapId(), dest.GetPositionX(), dest.GetPositionY(), dest.GetPositionZ(), return MoveTo(dest.GetMapId(), dest.GetPositionX(), dest.GetPositionY(), dest.GetPositionZ(),
@ -278,30 +294,9 @@ bool NewRpgBaseAction::DispatchPathPoints(WorldPosition const& dest,
if (points.size() < 2) if (points.size() < 2)
return false; return false;
// LOS gate: reject paths whose segments pass through visual // Save planner output BEFORE any clip/fixup mutation so next-tick
// geometry. mmap is blind to M2 models (trees, decorative props) // reuse/regress branches see the original intent, not a clip-
// and will route through them; vmap LOS catches the cases that // truncated tail.
// matter — solid trunks, walls, terrain features.
if (Map* map = bot->GetMap())
{
float const eye = bot->GetCollisionHeight();
for (size_t i = 0; i + 1 < points.size(); ++i)
{
if (!map->isInLineOfSight(points[i].x, points[i].y, points[i].z + eye,
points[i + 1].x, points[i + 1].y, points[i + 1].z + eye,
bot->GetPhaseMask(),
LINEOFSIGHT_ALL_CHECKS,
VMAP::ModelIgnoreFlags::Nothing))
{
EmitDebugMove("MoveFar", "blocked",
dest.GetPositionX(), dest.GetPositionY(), dest.GetPositionZ());
return false;
}
}
}
// Save planner output before clip/fixup so next-tick reuse sees
// the original intent, not a truncated tail.
{ {
LastMovement& lm = AI_VALUE(LastMovement&, "last movement"); LastMovement& lm = AI_VALUE(LastMovement&, "last movement");
std::vector<WorldPosition> wpts; std::vector<WorldPosition> wpts;
@ -311,8 +306,9 @@ bool NewRpgBaseAction::DispatchPathPoints(WorldPosition const& dest,
lm.setPath(TravelPath(wpts)); lm.setPath(TravelPath(wpts));
} }
// Underwater fixup: lift submerged waypoints to the surface, // Underwater fixup — push waypoints submerged below the water
// unless the destination is itself underwater. // surface up to the surface itself, unless the destination is
// itself underwater.
if (Map* map = bot->GetMap()) if (Map* map = bot->GetMap())
{ {
WorldPosition destWp = dest; WorldPosition destWp = dest;
@ -382,8 +378,10 @@ bool NewRpgBaseAction::DispatchPathPoints(WorldPosition const& dest,
LastMovement& lastMove = AI_VALUE(LastMovement&, "last movement"); LastMovement& lastMove = AI_VALUE(LastMovement&, "last movement");
// Skip cosmetic walking for random bots with no nearby player — // Inactive-bot teleport — when the path is longer than reactDistance
// teleport to the path tail and schedule a cooldown instead. // and no real player is around to witness, jump to the path tail and
// schedule a cooldown. Skips cosmetic walking for unobserved random
// bots. Self-bots are excluded so observed sessions always walk.
if (sRandomPlayerbotMgr.IsRandomBot(bot)) if (sRandomPlayerbotMgr.IsRandomBot(bot))
{ {
WorldPosition tail(dest.GetMapId(), last.x, last.y, last.z); WorldPosition tail(dest.GetMapId(), last.x, last.y, last.z);
@ -406,7 +404,9 @@ bool NewRpgBaseAction::DispatchPathPoints(WorldPosition const& dest,
} }
} }
// Match master's walk pace when they're nearby and walking. // masterWalking — match the master's walk pace when they're nearby
// and walking. Lets a follower bot trail at walk speed instead of
// sprinting past. No-op for masterless RPG bots.
ForcedMovement moveMode = FORCED_MOVEMENT_RUN; ForcedMovement moveMode = FORCED_MOVEMENT_RUN;
if (sPlayerbotAIConfig.walkDistance > 0.0f) if (sPlayerbotAIConfig.walkDistance > 0.0f)
{ {
@ -420,7 +420,8 @@ bool NewRpgBaseAction::DispatchPathPoints(WorldPosition const& dest,
} }
} }
// Clear emote/sit/cast so the spline can begin cleanly. // Pre-dispatch state cleanup. Clear emote / stand up / interrupt
// any non-melee cast so the spline can begin without state conflicts.
bot->ClearEmoteState(); bot->ClearEmoteState();
if (!bot->IsStandState()) if (!bot->IsStandState())
bot->SetStandState(UNIT_STAND_STATE_STAND); bot->SetStandState(UNIT_STAND_STATE_STAND);
@ -432,7 +433,9 @@ bool NewRpgBaseAction::DispatchPathPoints(WorldPosition const& dest,
EmitDebugMove("MoveFar", label, last.x, last.y, last.z); EmitDebugMove("MoveFar", label, last.x, last.y, last.z);
// WaitForReach: leave ~10y headroom on long paths. // WaitForReach scheduling.
// waitDist = (totalDist > reactDistance) ? totalDist - 10 : totalDist
// duration = 1000 * (waitDist / speed) + reactDelay, capped at maxWaitForMove
float waitDist = totalDist > sPlayerbotAIConfig.reactDistance float waitDist = totalDist > sPlayerbotAIConfig.reactDistance
? std::max(totalDist - 10.0f, 0.0f) : totalDist; ? std::max(totalDist - 10.0f, 0.0f) : totalDist;
UnitMoveType const speedType = (moveMode == FORCED_MOVEMENT_WALK) ? MOVE_WALK : MOVE_RUN; UnitMoveType const speedType = (moveMode == FORCED_MOVEMENT_WALK) ? MOVE_WALK : MOVE_RUN;
@ -453,6 +456,9 @@ void NewRpgBaseAction::StartTravelPlan(WorldPosition dest)
{ {
TravelPlan& plan = botAI->rpgInfo.travelPlan; TravelPlan& plan = botAI->rpgInfo.travelPlan;
GetTravelPlan(plan, dest); 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() bool NewRpgBaseAction::UpdateTravelPlan()
@ -484,9 +490,7 @@ bool NewRpgBaseAction::MoveWorldObjectTo(ObjectGuid guid, float distance)
angle = object->GetOrientation() + angle = object->GetOrientation() +
(M_PI * irand(-25, 25) / 100.0); // 45 degrees infront of target (leading it's movement) (M_PI * irand(-25, 25) / 100.0); // 45 degrees infront of target (leading it's movement)
// Bias toward the full radius so the bot stops next to the object, float rnd = rand_norm();
// not on top of it. Uniform rnd would put dest anywhere in [0, distance].
float rnd = 0.85f + 0.15f * rand_norm();
x += cos(angle) * distance * rnd; x += cos(angle) * distance * rnd;
y += sin(angle) * distance * rnd; y += sin(angle) * distance * rnd;
if (!object->GetMap()->CheckCollisionAndGetValidCoords(object, object->GetPositionX(), object->GetPositionY(), if (!object->GetMap()->CheckCollisionAndGetValidCoords(object, object->GetPositionX(), object->GetPositionY(),
@ -496,8 +500,11 @@ bool NewRpgBaseAction::MoveWorldObjectTo(ObjectGuid guid, float distance)
y = object->GetPositionY(); y = object->GetPositionY();
z = object->GetPositionZ(); z = object->GetPositionZ();
} }
// Route through MoveFarTo so every approach gets the full probe // Delegate to MoveFarTo so every approach gets the chained mmap
// + travel-node fallback (and a precise debug label). // 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)); return MoveFarTo(WorldPosition(object->GetMapId(), x, y, z));
} }
@ -510,7 +517,11 @@ bool NewRpgBaseAction::MoveRandomNear(float moveStep, MovementPriority priority,
const float x = bot->GetPositionX(); const float x = bot->GetPositionX();
const float y = bot->GetPositionY(); const float y = bot->GetPositionY();
const float z = bot->GetPositionZ(); const float z = bot->GetPositionZ();
// Retry random samples so one bad roll doesn't lock the bot in place. // Previously: attempts = 1. A single random sample often landed in
// water / blocked geometry / unreachable poly, the function returned
// false, and the caller had no fallback — bot stood still. Retry a
// handful of times with a fresh distance each loop so a bad roll
// doesn't lock the bot in place.
for (int attempt = 0; attempt < 8; ++attempt) for (int attempt = 0; attempt < 8; ++attempt)
{ {
float distance = (0.4f + rand_norm() * 0.6f) * moveStep; float distance = (0.4f + rand_norm() * 0.6f) * moveStep;
@ -530,13 +541,6 @@ bool NewRpgBaseAction::MoveRandomNear(float moveStep, MovementPriority priority,
if (map->IsInWater(bot->GetPhaseMask(), dx, dy, dz, bot->GetCollisionHeight())) if (map->IsInWater(bot->GetPhaseMask(), dx, dy, dz, bot->GetCollisionHeight()))
continue; continue;
// Reject samples whose straight-line passes through visual
// obstacles (trees, models) that aren't in the navmesh. The
// smooth-path step can otherwise interpolate a waypoint inside
// a tree, making the bot visibly walk through it.
if (!bot->IsWithinLOS(dx, dy, dz))
continue;
bool moved = MoveTo(bot->GetMapId(), dx, dy, dz, false, false, false, true, priority); bool moved = MoveTo(bot->GetMapId(), dx, dy, dz, false, false, false, true, priority);
if (moved) if (moved)
{ {

View File

@ -226,8 +226,11 @@ TravelNodePath* TravelNode::BuildPath(TravelNode* endNode, Unit* bot, bool postP
bool canPath = endPos->isPathTo(path); // Check if we reached our destination. bool canPath = endPos->isPathTo(path); // Check if we reached our destination.
// Reject too-short or too-steep results — geometry shortcut that // Reject "pathfinder cheating" — too-short or too-steep results
// mmap returns but a player can't actually walk. // that mmap accepts but a player can't actually walk. Without this,
// the segment gets cached + saved to playerbots_travelnode_path
// and dispatched at runtime as straight-line spline through whatever
// mountain/cliff sat between A and B (cmangos parity).
if (canPath && TravelPath::IsPathCheating(path, getPosition()->distance(endNode->getPosition()))) if (canPath && TravelPath::IsPathCheating(path, getPosition()->distance(endNode->getPosition())))
canPath = false; canPath = false;
@ -729,9 +732,9 @@ bool TravelPath::makeShortCut(WorldPosition startPos, float maxDist, Unit* bot)
for (auto& p : fullPath) // cycle over the full path for (auto& p : fullPath) // cycle over the full path
{ {
// Walkability filter: portals/transports/taxis aren't valid // Walkability filter (cmangos parity): portals/transports/taxis
// anchor points — picking one as the new start of the trimmed // aren't valid anchor points — picking one as the new start of
// path would leave the bot anchored on a hop. // the trimmed path would leave the bot anchored on a hop.
if (p.point.GetMapId() == startPos.GetMapId() && p.isWalkable()) if (p.point.GetMapId() == startPos.GetMapId() && p.isWalkable())
{ {
float curDist = p.point.sqDistance(startPos); float curDist = p.point.sqDistance(startPos);
@ -789,8 +792,9 @@ bool TravelPath::makeShortCut(WorldPosition startPos, float maxDist, Unit* bot)
} }
// Pass the bot into getPathTo so PathGenerator picks up its // Pass the bot into getPathTo so PathGenerator picks up its
// collision/swim/fly state. nullptr defaults to a generic mover // collision / swimming / flying flags. cmangos parity — passing
// which can produce paths the bot can't actually walk. // 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); 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.
@ -1286,8 +1290,10 @@ bool TravelNodeMap::GetFullPath(TravelPlan& plan,
plan.Reset(); plan.Reset();
plan.destination = destination; plan.destination = destination;
// mmap-probe first: if a 40-step probe reaches dest, skip the // mmap-probe-first. Run a 40-step chained probe; if it gets within
// graph entirely — a direct walk beats a node hop. // spellDistance of dest, emit it as plan steps and skip the graph
// entirely (a short walk is always better than a node hop). When
// the probe falls short, fall through to graph routing.
if (botPos.GetMapId() == destination.GetMapId()) if (botPos.GetMapId() == destination.GetMapId())
{ {
std::vector<WorldPosition> probe = destination.getPathFromPath({botPos}, bot, 40); std::vector<WorldPosition> probe = destination.getPathFromPath({botPos}, bot, 40);

View File

@ -13,7 +13,8 @@
// THEORY // THEORY
// //
// Pathfinding uses the detour recast navmesh engine for mob, npc, and bot movement. // Pathfinding in (c)mangos is based on detour recast, an opensource navmesh creation and pathfinding codebase.
// This system is used for mob and npc pathfinding and in this codebase also for bots.
// Because mobs and npc movement is based on following a player or a set path the PathGenerator is limited to 296y. // Because mobs and npc movement is based on following a player or a set path the PathGenerator is limited to 296y.
// This means that when trying to find a path from A to B distances beyond 296y will be a best guess often moving in a // This means that when trying to find a path from A to B distances beyond 296y will be a best guess often moving in a
// straight path. Bots would get stuck moving from Northshire to Stormwind because there is no 296y path that doesn't // straight path. Bots would get stuck moving from Northshire to Stormwind because there is no 296y path that doesn't
@ -492,8 +493,16 @@ public:
bool makeShortCut(WorldPosition startPos, float maxDist, Unit* bot = nullptr); bool makeShortCut(WorldPosition startPos, float maxDist, Unit* bot = nullptr);
// Reject paths the navmesh accepts but a player can't walk: // Detect "pathfinder cheating" — paths that PathGenerator accepts
// 2-point shortcut over 5y, or > 10y vertical drop with slope steeper than 2:1. // but a player can't actually walk:
// * a 2-point path for an endpoint distance > 5y means navmesh
// gave up and returned the straight A->B line.
// * a vertical drop > 10y combined with a slope steeper than
// 2:1 at either start or end means the pathfinder hopped
// through a near-vertical step the navmesh permits but a
// player wouldn't survive.
// cmangos applies the same two checks in TravelNode::buildPath
// before caching a node-to-node segment.
static bool IsPathCheating(std::vector<WorldPosition> const& path, static bool IsPathCheating(std::vector<WorldPosition> const& path,
float endpointDistance); float endpointDistance);