mirror of
https://github.com/MCCTeam/Minecraft-Console-Client
synced 2026-08-15 13:04:36 +00:00
Fix a cluster of execution-layer issues that caused replans and void falls when traversing narrow ledges and multi-block descents between (251.5,141,210.5) and (252.5,138,220.5): - WalkTemplate / GroundedSegmentController: suppress the pre-rotation bias toward the next segment's exit heading on stable-footing Turn exits where the next segment is not a jump. The next template snaps yaw on its first tick anyway, and pre-rotating mid-stride on a 1-block walkway pushes sprint drift perpendicular to the path and walks the bot off the edge. Turn exits into a jump still get the bias so the takeoff direction stays aligned. - GroundedSegmentController.ShouldComplete: relax the headingReady gate for Turn exits with stable footing so the segment can complete once yaw is aligned with either the current or the next segment heading (within 25/15 deg). Without this the removed bias would leave the bot stuck at the end of a walkway waiting for a rotation that never happens. - DescendTemplate: restrict the airborne exit-heading bias so it only kicks in when the footprint is inside the landing block, or on single-step drops where the fall is too short for lateral drift to miss the landing column. On 2+ block drops the bot now keeps yaw pointed at the landing center for the whole fall. - DescendTemplate: add a multi-block overshoot guard on PrepareJump exits. Once airborne and past the landing end-plane on a 2+ Y drop, release forward/sprint and press back briefly so air drag pulls the bot back into the 1x1 landing column instead of sailing one block past it into the neighbouring void. Live round-trip between the two goal coordinates now completes with zero replans in three consecutive runs in each direction. Full unit test suite is unchanged from the pre-existing baseline (22 failing tests, all orthogonal to this change). Made-with: Cursor
520 lines
20 KiB
C#
520 lines
20 KiB
C#
using System;
|
|
using System.Collections.Generic;
|
|
using System.Diagnostics;
|
|
using System.Threading;
|
|
using MinecraftClient.Pathing.Goals;
|
|
using MinecraftClient.Pathing.Moves;
|
|
using MinecraftClient.Pathing.Moves.Impl;
|
|
|
|
namespace MinecraftClient.Pathing.Core
|
|
{
|
|
public sealed class AStarPathFinder
|
|
{
|
|
private readonly record struct NodeKey(long PackedPosition, EntryPreparationState EntryPreparation);
|
|
|
|
private readonly IMove[] _allMoves;
|
|
private readonly IMoveExpander[] _expanders;
|
|
private readonly int _totalExpanderCapacity;
|
|
private readonly int _maxChunkBorderFetch;
|
|
|
|
public Action<string>? DebugLog { get; set; }
|
|
|
|
public AStarPathFinder(IMove[]? moves = null, int maxChunkBorderFetch = 64)
|
|
: this(BuildExpanders(moves), moves ?? BuildDefaultMoves(), maxChunkBorderFetch)
|
|
{
|
|
}
|
|
|
|
public AStarPathFinder(IMoveExpander[] expanders, int maxChunkBorderFetch = 64)
|
|
: this(expanders, System.Array.Empty<IMove>(), maxChunkBorderFetch)
|
|
{
|
|
}
|
|
|
|
private AStarPathFinder(IMoveExpander[] expanders, IMove[] allMoves, int maxChunkBorderFetch)
|
|
{
|
|
_expanders = expanders;
|
|
_allMoves = allMoves;
|
|
_maxChunkBorderFetch = maxChunkBorderFetch;
|
|
|
|
int total = 0;
|
|
for (int i = 0; i < expanders.Length; i++)
|
|
total += expanders[i].MaxNeighbors;
|
|
_totalExpanderCapacity = total;
|
|
}
|
|
|
|
private static IMoveExpander[] BuildExpanders(IMove[]? explicitMoves)
|
|
{
|
|
if (explicitMoves is null)
|
|
{
|
|
return BuildDefaultExpanders();
|
|
}
|
|
|
|
// Caller supplied a specific move set (e.g. tests). Wrap it as a
|
|
// legacy expander so the old API keeps working.
|
|
return [new LegacyMoveExpander(explicitMoves)];
|
|
}
|
|
|
|
public static IMoveExpander[] BuildDefaultExpanders()
|
|
{
|
|
IMove[] legacyMoves =
|
|
[
|
|
new MoveDescend(1, 0),
|
|
new MoveDescend(-1, 0),
|
|
new MoveDescend(0, 1),
|
|
new MoveDescend(0, -1),
|
|
new MoveSprintDescend(2, 0),
|
|
new MoveSprintDescend(-2, 0),
|
|
new MoveSprintDescend(0, 2),
|
|
new MoveSprintDescend(0, -2),
|
|
new MoveSprintDescend(1, 1),
|
|
new MoveSprintDescend(1, -1),
|
|
new MoveSprintDescend(-1, 1),
|
|
new MoveSprintDescend(-1, -1),
|
|
new MoveClimb(true),
|
|
new MoveClimb(false),
|
|
new MoveFall(),
|
|
];
|
|
|
|
return [new JumpExpander(), new LegacyMoveExpander(legacyMoves)];
|
|
}
|
|
|
|
public static IMove[] BuildDefaultMoves()
|
|
{
|
|
var moves = new List<IMove>();
|
|
|
|
int[] offsets = [1, -1];
|
|
|
|
// ---- jump family (all unified as MoveJump with a JumpDescriptor) ----
|
|
|
|
// Cardinal walk + 1-block ascend
|
|
foreach (int dx in offsets)
|
|
{
|
|
moves.Add(MoveJump.Traverse(dx, 0));
|
|
moves.Add(MoveJump.Ascend(dx, 0));
|
|
}
|
|
foreach (int dz in offsets)
|
|
{
|
|
moves.Add(MoveJump.Traverse(0, dz));
|
|
moves.Add(MoveJump.Ascend(0, dz));
|
|
}
|
|
|
|
// Diagonal walk + diagonal ascend/descend (corner cases)
|
|
foreach (int dx in offsets)
|
|
{
|
|
foreach (int dz in offsets)
|
|
{
|
|
moves.Add(MoveJump.Diagonal(dx, dz));
|
|
moves.Add(MoveJump.DiagonalAscend(dx, dz));
|
|
moves.Add(MoveJump.DiagonalDescend(dx, dz));
|
|
}
|
|
}
|
|
|
|
// Cardinal parkour (flat / +1 ascend / -1 -2 descend)
|
|
foreach (int dx in offsets)
|
|
{
|
|
for (int dist = 2; dist <= 5; dist++)
|
|
moves.Add(MoveJump.Parkour(dx * dist, 0));
|
|
for (int dist = 2; dist <= 3; dist++)
|
|
moves.Add(MoveJump.Parkour(dx * dist, 0, yDelta: 1));
|
|
for (int dist = 2; dist <= 5; dist++)
|
|
moves.Add(MoveJump.Parkour(dx * dist, 0, yDelta: -1));
|
|
for (int dist = 2; dist <= 5; dist++)
|
|
moves.Add(MoveJump.Parkour(dx * dist, 0, yDelta: -2));
|
|
}
|
|
foreach (int dz in offsets)
|
|
{
|
|
for (int dist = 2; dist <= 5; dist++)
|
|
moves.Add(MoveJump.Parkour(0, dz * dist));
|
|
for (int dist = 2; dist <= 3; dist++)
|
|
moves.Add(MoveJump.Parkour(0, dz * dist, yDelta: 1));
|
|
for (int dist = 2; dist <= 5; dist++)
|
|
moves.Add(MoveJump.Parkour(0, dz * dist, yDelta: -1));
|
|
for (int dist = 2; dist <= 5; dist++)
|
|
moves.Add(MoveJump.Parkour(0, dz * dist, yDelta: -2));
|
|
}
|
|
|
|
// Diagonal parkour (flat + diagonal ascending/descending)
|
|
foreach (int dx in offsets)
|
|
{
|
|
foreach (int dz in offsets)
|
|
{
|
|
moves.Add(MoveJump.Parkour(dx * 2, dz * 1));
|
|
moves.Add(MoveJump.Parkour(dx * 1, dz * 2));
|
|
moves.Add(MoveJump.Parkour(dx * 2, dz * 2));
|
|
moves.Add(MoveJump.Parkour(dx * 3, dz * 1));
|
|
moves.Add(MoveJump.Parkour(dx * 1, dz * 3));
|
|
|
|
moves.Add(MoveJump.Parkour(dx * 2, dz * 1, yDelta: -1));
|
|
moves.Add(MoveJump.Parkour(dx * 1, dz * 2, yDelta: -1));
|
|
moves.Add(MoveJump.Parkour(dx * 2, dz * 2, yDelta: -1));
|
|
|
|
moves.Add(MoveJump.Parkour(dx * 2, dz * 1, yDelta: 1));
|
|
moves.Add(MoveJump.Parkour(dx * 1, dz * 2, yDelta: 1));
|
|
moves.Add(MoveJump.Parkour(dx * 2, dz * 2, yDelta: 1));
|
|
}
|
|
}
|
|
|
|
// Sidewall parkour (dominant-axis sprint jumps using an inner wall)
|
|
foreach (int dx in offsets)
|
|
{
|
|
foreach (int dz in offsets)
|
|
{
|
|
foreach (int distance in new[] { 2, 3, 4, 5 })
|
|
{
|
|
moves.Add(MoveJump.Sidewall(dx, dz * distance));
|
|
moves.Add(MoveJump.Sidewall(dx * distance, dz));
|
|
|
|
if (distance <= 3)
|
|
{
|
|
moves.Add(MoveJump.Sidewall(dx, dz * distance, yDelta: 1));
|
|
moves.Add(MoveJump.Sidewall(dx * distance, dz, yDelta: 1));
|
|
}
|
|
|
|
moves.Add(MoveJump.Sidewall(dx, dz * distance, yDelta: -1));
|
|
moves.Add(MoveJump.Sidewall(dx * distance, dz, yDelta: -1));
|
|
moves.Add(MoveJump.Sidewall(dx, dz * distance, yDelta: -2));
|
|
moves.Add(MoveJump.Sidewall(dx * distance, dz, yDelta: -2));
|
|
}
|
|
}
|
|
}
|
|
|
|
// ---- dynamic-landing family (kept separate: variable landing depth) ----
|
|
foreach (int dx in offsets)
|
|
{
|
|
moves.Add(new MoveDescend(dx, 0));
|
|
moves.Add(new MoveSprintDescend(dx * 2, 0));
|
|
moves.Add(new MoveSprintDescend(dx, dx));
|
|
moves.Add(new MoveSprintDescend(dx, -dx));
|
|
}
|
|
foreach (int dz in offsets)
|
|
{
|
|
moves.Add(new MoveDescend(0, dz));
|
|
moves.Add(new MoveSprintDescend(0, dz * 2));
|
|
}
|
|
|
|
// ---- vertical / free fall ----
|
|
moves.Add(new MoveClimb(true));
|
|
moves.Add(new MoveClimb(false));
|
|
moves.Add(new MoveFall());
|
|
|
|
return [.. moves];
|
|
}
|
|
|
|
public PathResult Calculate(
|
|
CalculationContext ctx,
|
|
int startX, int startY, int startZ,
|
|
IGoal goal,
|
|
CancellationToken ct,
|
|
long timeoutMs = 5000)
|
|
{
|
|
if (goal.IsInGoal(startX, startY, startZ))
|
|
{
|
|
DebugLog?.Invoke($"[A*] Already in goal at ({startX},{startY},{startZ})");
|
|
return new PathResult(
|
|
PathStatus.Success,
|
|
[new PathNode(startX, startY, startZ)],
|
|
nodesExplored: 0,
|
|
elapsedMs: 0);
|
|
}
|
|
|
|
if (!IsGoalReachableFootPosition(ctx, goal))
|
|
{
|
|
DebugLog?.Invoke($"[A*] Goal {goal} is not a reachable foot position");
|
|
return PathResult.Fail(nodesExplored: 0, elapsedMs: 0);
|
|
}
|
|
|
|
var sw = Stopwatch.StartNew();
|
|
var openSet = new BinaryHeapOpenSet(4096);
|
|
var nodeMap = new Dictionary<NodeKey, PathNode>(4096);
|
|
|
|
var startNode = new PathNode(startX, startY, startZ)
|
|
{
|
|
GCost = 0,
|
|
HCost = goal.Heuristic(startX, startY, startZ),
|
|
IsOpen = true
|
|
};
|
|
openSet.Insert(startNode);
|
|
nodeMap[new NodeKey(startNode.PackedPosition, startNode.EntryPreparation)] = startNode;
|
|
|
|
int nodesExplored = 0;
|
|
int unloadedChunkHits = 0;
|
|
bool searchAborted = false;
|
|
PathNode? bestPartialNode = startNode;
|
|
double bestPartialScore = startNode.HCost + startNode.GCost * 0.5;
|
|
|
|
// Per-node scratch buffer for IMoveExpander output. Size = sum of
|
|
// MaxNeighbors across all expanders so no expander can overflow.
|
|
Span<MoveNeighbor> neighborBuffer = _totalExpanderCapacity <= 512
|
|
? stackalloc MoveNeighbor[_totalExpanderCapacity]
|
|
: new MoveNeighbor[_totalExpanderCapacity];
|
|
|
|
DebugLog?.Invoke($"[A*] Start ({startX},{startY},{startZ}), goal={goal}");
|
|
|
|
while (openSet.Count > 0)
|
|
{
|
|
if (ct.IsCancellationRequested)
|
|
{
|
|
searchAborted = true;
|
|
DebugLog?.Invoke($"[A*] Cancelled after {nodesExplored} nodes, {sw.ElapsedMilliseconds}ms");
|
|
break;
|
|
}
|
|
|
|
if (sw.ElapsedMilliseconds > timeoutMs)
|
|
{
|
|
searchAborted = true;
|
|
DebugLog?.Invoke($"[A*] Timeout ({timeoutMs}ms) after {nodesExplored} nodes");
|
|
break;
|
|
}
|
|
|
|
var current = openSet.RemoveMin();
|
|
current.IsClosed = true;
|
|
nodesExplored++;
|
|
|
|
if (goal.IsInGoal(current.X, current.Y, current.Z))
|
|
{
|
|
DebugLog?.Invoke($"[A*] Goal reached! {nodesExplored} nodes, {sw.ElapsedMilliseconds}ms");
|
|
var path = ReconstructPath(current);
|
|
return new PathResult(PathStatus.Success, path, nodesExplored, sw.ElapsedMilliseconds);
|
|
}
|
|
|
|
ctx.PreviousMoveType = current.MoveUsed;
|
|
ctx.CurrentEntryPreparation = current.EntryPreparation;
|
|
|
|
int bufferOffset = 0;
|
|
for (int ex = 0; ex < _expanders.Length; ex++)
|
|
{
|
|
IMoveExpander expander = _expanders[ex];
|
|
Span<MoveNeighbor> slot = neighborBuffer.Slice(bufferOffset, expander.MaxNeighbors);
|
|
int produced = expander.Expand(ctx, current.X, current.Y, current.Z, slot);
|
|
bufferOffset += expander.MaxNeighbors;
|
|
|
|
for (int i = 0; i < produced; i++)
|
|
{
|
|
MoveNeighbor emitted = slot[i];
|
|
int nx = emitted.DestX;
|
|
int ny = emitted.DestY;
|
|
int nz = emitted.DestZ;
|
|
|
|
if (!ctx.IsChunkLoaded(nx, nz))
|
|
{
|
|
unloadedChunkHits++;
|
|
if (unloadedChunkHits > _maxChunkBorderFetch)
|
|
continue;
|
|
}
|
|
|
|
double tentativeG = current.GCost + emitted.Cost;
|
|
EntryPreparationState nextPreparation = ResolveEntryPreparation(
|
|
current, emitted.MoveType, emitted.DestX, emitted.DestY, emitted.DestZ);
|
|
var key = new NodeKey(PathNode.Pack(nx, ny, nz), nextPreparation);
|
|
|
|
if (nodeMap.TryGetValue(key, out var neighbor))
|
|
{
|
|
if (neighbor.IsClosed)
|
|
continue;
|
|
if (tentativeG >= neighbor.GCost)
|
|
continue;
|
|
|
|
neighbor.GCost = tentativeG;
|
|
neighbor.Parent = current;
|
|
neighbor.MoveUsed = emitted.MoveType;
|
|
neighbor.ParkourProfile = emitted.ParkourProfile;
|
|
neighbor.EntryPreparation = nextPreparation;
|
|
if (neighbor.IsOpen)
|
|
openSet.Update(neighbor);
|
|
}
|
|
else
|
|
{
|
|
neighbor = new PathNode(nx, ny, nz)
|
|
{
|
|
GCost = tentativeG,
|
|
HCost = goal.Heuristic(nx, ny, nz),
|
|
Parent = current,
|
|
MoveUsed = emitted.MoveType,
|
|
ParkourProfile = emitted.ParkourProfile,
|
|
EntryPreparation = nextPreparation,
|
|
IsOpen = true
|
|
};
|
|
nodeMap[key] = neighbor;
|
|
openSet.Insert(neighbor);
|
|
}
|
|
|
|
double partialScore = neighbor.HCost + neighbor.GCost * 0.5;
|
|
if (partialScore < bestPartialScore)
|
|
{
|
|
bestPartialScore = partialScore;
|
|
bestPartialNode = neighbor;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
if (bestPartialNode is not null
|
|
&& bestPartialNode != startNode
|
|
&& (searchAborted || unloadedChunkHits > 0))
|
|
{
|
|
DebugLog?.Invoke($"[A*] Partial path to ({bestPartialNode.X},{bestPartialNode.Y},{bestPartialNode.Z}), " +
|
|
$"{nodesExplored} nodes, {sw.ElapsedMilliseconds}ms");
|
|
var path = ReconstructPath(bestPartialNode);
|
|
return new PathResult(PathStatus.Partial, path, nodesExplored, sw.ElapsedMilliseconds);
|
|
}
|
|
|
|
DebugLog?.Invoke($"[A*] Failed, {nodesExplored} nodes, {sw.ElapsedMilliseconds}ms");
|
|
return PathResult.Fail(nodesExplored, sw.ElapsedMilliseconds);
|
|
}
|
|
|
|
private EntryPreparationState ResolveEntryPreparation(
|
|
PathNode current, MoveType moveType, int destX, int destY, int destZ)
|
|
{
|
|
EntryPreparationState advanced = AdvanceExistingPreparation(current, moveType, destX, destY, destZ);
|
|
if (!advanced.IsNone)
|
|
return advanced;
|
|
|
|
if (TryStartSidewallRunupPreparation(current, moveType, destX, destY, destZ, out EntryPreparationState started))
|
|
return started;
|
|
|
|
return EntryPreparationState.None;
|
|
}
|
|
|
|
private static EntryPreparationState AdvanceExistingPreparation(
|
|
PathNode current, MoveType moveType, int destX, int destY, int destZ)
|
|
{
|
|
EntryPreparationState state = current.EntryPreparation;
|
|
if (state.IsNone)
|
|
return EntryPreparationState.None;
|
|
|
|
if (moveType != MoveType.Traverse || destY != current.Y)
|
|
return EntryPreparationState.None;
|
|
|
|
int stepX = destX - current.X;
|
|
int stepZ = destZ - current.Z;
|
|
|
|
if (state.BackwardSteps < state.RequiredSteps
|
|
&& stepX == -state.ForwardX
|
|
&& stepZ == -state.ForwardZ)
|
|
{
|
|
return state.AdvanceBackward();
|
|
}
|
|
|
|
if (state.BackwardSteps == state.RequiredSteps
|
|
&& state.ReturnSteps < state.RequiredSteps
|
|
&& stepX == state.ForwardX
|
|
&& stepZ == state.ForwardZ)
|
|
{
|
|
EntryPreparationState nextState = state.AdvanceReturn();
|
|
if (nextState.IsPrepared
|
|
&& (destX != state.OriginX
|
|
|| destY != state.OriginY
|
|
|| destZ != state.OriginZ))
|
|
{
|
|
return EntryPreparationState.None;
|
|
}
|
|
|
|
return nextState;
|
|
}
|
|
|
|
return EntryPreparationState.None;
|
|
}
|
|
|
|
private static bool TryStartSidewallRunupPreparation(
|
|
PathNode current, MoveType moveType, int destX, int destY, int destZ, out EntryPreparationState state)
|
|
{
|
|
state = EntryPreparationState.None;
|
|
|
|
if (!current.EntryPreparation.IsNone
|
|
|| moveType != MoveType.Traverse
|
|
|| destY != current.Y)
|
|
{
|
|
return false;
|
|
}
|
|
|
|
int stepX = destX - current.X;
|
|
int stepZ = destZ - current.Z;
|
|
|
|
// Sidewall candidates are generated dynamically by JumpExpander's
|
|
// cardinal probe now, so we probe each cardinal forward direction
|
|
// directly instead of scanning a descriptor table. The only shape
|
|
// TryGetRequiredStaticEntryRunupSteps flags as needing a static
|
|
// runup today is (major=5, minor=1, yDelta=-1) -- we use that
|
|
// canonical shape as the query (lateral=+1 is arbitrary; the
|
|
// helper only looks at yDelta and major).
|
|
ReadOnlySpan<(int fx, int fz)> forwards =
|
|
[
|
|
(1, 0),
|
|
(-1, 0),
|
|
(0, 1),
|
|
(0, -1),
|
|
];
|
|
|
|
for (int i = 0; i < forwards.Length; i++)
|
|
{
|
|
(int forwardX, int forwardZ) = forwards[i];
|
|
if (stepX != -forwardX || stepZ != -forwardZ)
|
|
continue;
|
|
|
|
int xOffset, zOffset;
|
|
if (forwardX != 0)
|
|
{
|
|
xOffset = forwardX * 5;
|
|
zOffset = 1;
|
|
}
|
|
else
|
|
{
|
|
xOffset = 1;
|
|
zOffset = forwardZ * 5;
|
|
}
|
|
|
|
if (!ParkourFeasibility.TryGetRequiredStaticEntryRunupSteps(
|
|
current.MoveUsed,
|
|
xOffset,
|
|
zOffset,
|
|
yDelta: -1,
|
|
out int requiredSteps))
|
|
{
|
|
continue;
|
|
}
|
|
|
|
state = new EntryPreparationState(
|
|
EntryPreparationKind.SidewallRunup,
|
|
current.X,
|
|
current.Y,
|
|
current.Z,
|
|
forwardX,
|
|
forwardZ,
|
|
(byte)requiredSteps,
|
|
BackwardSteps: 1,
|
|
ReturnSteps: 0);
|
|
return true;
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
private static List<PathNode> ReconstructPath(PathNode end)
|
|
{
|
|
var path = new List<PathNode>();
|
|
var current = end;
|
|
while (current is not null)
|
|
{
|
|
path.Add(current);
|
|
current = current.Parent;
|
|
}
|
|
path.Reverse();
|
|
return path;
|
|
}
|
|
|
|
private static bool IsGoalReachableFootPosition(CalculationContext ctx, IGoal goal)
|
|
{
|
|
if (goal is not GoalBlock blockGoal)
|
|
return true;
|
|
|
|
if (!ctx.IsChunkLoaded(blockGoal.X, blockGoal.Z))
|
|
return true;
|
|
|
|
if (blockGoal.Y == int.MinValue)
|
|
return false;
|
|
|
|
return ctx.CanWalkOn(blockGoal.X, blockGoal.Y - 1, blockGoal.Z)
|
|
&& ctx.CanWalkThrough(blockGoal.X, blockGoal.Y, blockGoal.Z)
|
|
&& ctx.CanWalkThrough(blockGoal.X, blockGoal.Y + 1, blockGoal.Z);
|
|
}
|
|
}
|
|
}
|