From 3efe63c54d11d510b63cb86fd0e9b0ca09e04698 Mon Sep 17 00:00:00 2001 From: BruceChen Date: Sat, 11 Apr 2026 01:35:58 +0800 Subject: [PATCH 01/37] feat: add pose system, slime block bounce, and crawling to physics engine Implement the three Phase 0 prerequisites for the pathfinding rewrite: - Pose system (vanilla Player.updatePlayerPose): dynamic AABB height based on current pose -- Standing (1.8), Sneaking (1.5), Swimming/Crawling (0.6). Automatically downgrades pose when headroom is insufficient (Standing -> Sneaking -> Swimming), matching vanilla 1.14+ forced-crawl behavior. - Slime block bounce (vanilla SlimeBlock.bounceUp / stepOn): reverses downward velocity on landing, with sneaking suppression via isSuppressingBounce(). Also applies the horizontal slowdown effect from SlimeBlock.stepOn when walking on slime with low vertical velocity. - Per-pose dimension constants in PhysicsConsts with eye heights for each pose, sourced from vanilla Avatar.POSES (26.1 decompiled). - Debug logging for pose transitions, slime bounces, and periodic physics state dumps routed through McClient's ILogger. Tested on a real 1.21.11 server: crawling triggers correctly under 1-block ceilings, sneaking under 1.5-block ceilings, slime bounce produces correct decaying oscillation, and pose recovery works when obstacles are removed. Made-with: Cursor --- MinecraftClient/McClient.cs | 1 + MinecraftClient/Physics/PhysicsConsts.cs | 19 ++- MinecraftClient/Physics/PlayerPhysics.cs | 183 +++++++++++++++++++++-- 3 files changed, 186 insertions(+), 17 deletions(-) diff --git a/MinecraftClient/McClient.cs b/MinecraftClient/McClient.cs index dc76441b..115c7a63 100644 --- a/MinecraftClient/McClient.cs +++ b/MinecraftClient/McClient.cs @@ -685,6 +685,7 @@ namespace MinecraftClient playerPhysics.SetPosition(location.X, location.Y, location.Z); playerPhysics.Yaw = playerYaw; playerPhysics.Pitch = playerPitch; + playerPhysics.DebugLog = msg => Log.Debug(msg); physicsInitialized = true; } diff --git a/MinecraftClient/Physics/PhysicsConsts.cs b/MinecraftClient/Physics/PhysicsConsts.cs index 95cb02c1..c2b32b13 100644 --- a/MinecraftClient/Physics/PhysicsConsts.cs +++ b/MinecraftClient/Physics/PhysicsConsts.cs @@ -1,3 +1,5 @@ +using System; + namespace MinecraftClient.Physics { /// @@ -6,12 +8,19 @@ namespace MinecraftClient.Physics /// public static class PhysicsConsts { - // --- Player dimensions --- + // --- Player dimensions per pose (vanilla Avatar.POSES, 26.1) --- public const double PlayerWidth = 0.6; - public const double PlayerHeight = 1.8; - public const double PlayerSneakHeight = 1.5; - public const double PlayerSwimHeight = 0.6; - public const double PlayerEyeHeight = 1.62; + public const double PlayerStandingHeight = 1.8; + public const double PlayerStandingEyeHeight = 1.62; + public const double PlayerCrouchingHeight = 1.5; + public const double PlayerCrouchingEyeHeight = 1.27; + public const double PlayerSwimmingHeight = 0.6; + public const double PlayerSwimmingEyeHeight = 0.4; + + [Obsolete("Use PlayerStandingHeight instead")] + public const double PlayerHeight = PlayerStandingHeight; + [Obsolete("Use PlayerStandingEyeHeight instead")] + public const double PlayerEyeHeight = PlayerStandingEyeHeight; // --- Gravity --- public const double DefaultGravity = 0.08; diff --git a/MinecraftClient/Physics/PlayerPhysics.cs b/MinecraftClient/Physics/PlayerPhysics.cs index bf18429f..2081bb7b 100644 --- a/MinecraftClient/Physics/PlayerPhysics.cs +++ b/MinecraftClient/Physics/PlayerPhysics.cs @@ -4,9 +4,9 @@ using MinecraftClient.Mapping; namespace MinecraftClient.Physics { /// - /// Core physics tick engine for the player, faithfully replicating vanilla 1.21.11 physics. + /// Core physics tick engine for the player, faithfully replicating vanilla 1.21.11+ physics. /// Mirrors the combined logic of Entity.move(), LivingEntity.aiStep()/travel()/travelInAir(), - /// Player.travel(), and LocalPlayer.aiStep(). + /// Player.travel(), Player.updatePlayerPose(), and LocalPlayer.aiStep(). /// public class PlayerPhysics { @@ -33,15 +33,34 @@ namespace MinecraftClient.Physics public bool Sneaking; public bool CreativeFlying; public bool InWater; + public bool IsUnderWater; public bool InLava; public bool OnClimbable; public bool HasSlowFalling; public bool HasLevitation; public int LevitationAmplifier; - // Player dimensions - public double PlayerWidth = PhysicsConsts.PlayerWidth; - public double PlayerHeight = PhysicsConsts.PlayerHeight; + // --- Pose system (vanilla Player.updatePlayerPose / Avatar.POSES) --- + public EntityPose CurrentPose { get; private set; } = EntityPose.Standing; + private EntityPose previousPose = EntityPose.Standing; + + public double PlayerWidth => PhysicsConsts.PlayerWidth; + + public double PlayerHeight => CurrentPose switch + { + EntityPose.Sneaking => PhysicsConsts.PlayerCrouchingHeight, + EntityPose.Swimming or EntityPose.FallFlying or EntityPose.SpinAttack + => PhysicsConsts.PlayerSwimmingHeight, + _ => PhysicsConsts.PlayerStandingHeight + }; + + public double EyeHeight => CurrentPose switch + { + EntityPose.Sneaking => PhysicsConsts.PlayerCrouchingEyeHeight, + EntityPose.Swimming or EntityPose.FallFlying or EntityPose.SpinAttack + => PhysicsConsts.PlayerSwimmingEyeHeight, + _ => PhysicsConsts.PlayerStandingEyeHeight + }; // Anti-jump-spam private int noJumpDelay; @@ -52,6 +71,11 @@ namespace MinecraftClient.Physics // Movement speed attribute (base = 0.1 for players) public float MovementSpeed = 0.1f; + /// + /// Debug log callback. Set from McClient to route messages through MCC's logger. + /// + public Action? DebugLog; + /// /// Get the player's bounding box at current position /// @@ -67,6 +91,9 @@ namespace MinecraftClient.Physics { TickCount++; + // Update pose (vanilla Player.updatePlayerPose) + UpdatePlayerPose(world); + // Velocity threshold zeroing (LivingEntity.aiStep) ZeroTinyVelocity(); @@ -81,6 +108,14 @@ namespace MinecraftClient.Physics if (noJumpDelay > 0) noJumpDelay--; + + // Periodic state dump every 5 seconds (100 ticks) + if (DebugLog is not null && TickCount % 100 == 0) + { + DebugLog($"[Physics] tick={TickCount} pos={Position} vel={DeltaMovement} " + + $"ground={OnGround} pose={CurrentPose} fall={FallDistance:F2} " + + $"water={InWater} underwater={IsUnderWater} swim={IsSwimming()} sneak={Sneaking}"); + } } /// @@ -240,6 +275,9 @@ namespace MinecraftClient.Physics // Block speed factor (soul sand, honey, etc.) ApplyBlockSpeedFactor(world); + + // SlimeBlock.stepOn: slow horizontal movement when walking on slime + ApplySlimeStepOn(world); } /// @@ -353,12 +391,6 @@ namespace MinecraftClient.Physics double resolvedLenSqr = resolved.LengthSqr(); if (resolvedLenSqr > 1.0E-7 || movement.LengthSqr() - resolvedLenSqr < 1.0E-7) { - // Fall distance reset via trace (simplified: reset on hitting ground) - if (FallDistance != 0.0 && resolvedLenSqr >= 1.0) - { - // Simplified: just check vertical collision - } - Position = Position.Add(resolved); } @@ -385,13 +417,46 @@ namespace MinecraftClient.Physics blockedZ ? 0 : DeltaMovement.Z); } + // Vanilla: Block.updateEntityMovementAfterFallOn -> SlimeBlock.bounceUp if (VerticalCollision) + UpdateMovementAfterFallOn(world); + } + + /// + /// Vanilla Block.updateEntityMovementAfterFallOn / SlimeBlock.bounceUp. + /// Called when vertical collision is detected. Handles slime block bounce. + /// + private void UpdateMovementAfterFallOn(World world) + { + Location belowFeet = new(Position.X, Position.Y - 0.2, Position.Z); + Material landedOn = world.GetBlock(belowFeet).Type; + + if (landedOn == Material.SlimeBlock && !IsSuppressingBounce()) { - // Slime block bounce would go here; for now just zero Y + double vy = DeltaMovement.Y; + if (vy < 0.0) + { + // LivingEntity bounce factor = 1.0 + DeltaMovement = new Vec3d(DeltaMovement.X, -vy, DeltaMovement.Z); + DebugLog?.Invoke($"[Physics] Slime bounce! vy={vy:F4} -> {-vy:F4} at {Position}"); + } + else + { + DeltaMovement = new Vec3d(DeltaMovement.X, 0, DeltaMovement.Z); + } + } + else + { + // Default: zero vertical velocity DeltaMovement = new Vec3d(DeltaMovement.X, 0, DeltaMovement.Z); } } + /// + /// Vanilla Entity.isSuppressingBounce() - sneaking suppresses slime bounce. + /// + private bool IsSuppressingBounce() => Sneaking; + /// /// Sneak edge detection: prevent walking off edges while sneaking. /// Equivalent to Player.maybeBackOffFromEdge(Vec3, MoverType). @@ -507,6 +572,26 @@ namespace MinecraftClient.Physics } } + /// + /// Vanilla SlimeBlock.stepOn: reduces horizontal speed when walking on slime blocks. + /// Triggered when vertical velocity is small and player is not sneaking. + /// + private void ApplySlimeStepOn(World world) + { + if (!OnGround) return; + + Location belowFeet = new(Position.X, Position.Y - 0.5000010, Position.Z); + if (world.GetBlock(belowFeet).Type != Material.SlimeBlock) return; + + double absDeltaY = Math.Abs(DeltaMovement.Y); + if (absDeltaY >= 0.1 || Sneaking) return; + + double scale = 0.4 + absDeltaY * 0.2; + DeltaMovement = DeltaMovement.Multiply(scale, 1.0, scale); + + DebugLog?.Invoke($"[Physics] Slime stepOn slowdown: scale={scale:F3}, vel={DeltaMovement}"); + } + /// /// Get friction value for a material. Default 0.6, special blocks differ. /// @@ -549,10 +634,84 @@ namespace MinecraftClient.Physics InWater = feetBlock == Material.Water || headBlock == Material.Water || feetBlock == Material.BubbleColumn; + IsUnderWater = headBlock == Material.Water; InLava = feetBlock == Material.Lava || headBlock == Material.Lava; OnClimbable = feetBlock.CanBeClimbedOn(); } + // ==================== Pose System ==================== + + /// + /// Vanilla Player.updatePlayerPose(). + /// Determines the correct pose based on player state and space constraints. + /// Forces crawling (Swimming pose on land) when standing/crouching does not fit. + /// + private void UpdatePlayerPose(World world) + { + EntityPose desired = GetDesiredPose(); + EntityPose actual; + + if (CanPlayerFitWithPose(world, EntityPose.Swimming)) + { + if (CanPlayerFitWithPose(world, desired)) + actual = desired; + else if (CanPlayerFitWithPose(world, EntityPose.Sneaking)) + actual = EntityPose.Sneaking; + else + actual = EntityPose.Swimming; + } + else + { + actual = desired; + } + + if (actual != previousPose) + { + DebugLog?.Invoke($"[Physics] Pose: {previousPose} -> {actual} (desired={desired}, " + + $"height={GetHeightForPose(actual):F1}, pos={Position})"); + previousPose = actual; + } + + CurrentPose = actual; + } + + /// + /// Vanilla Player.getDesiredPose() -- determines what pose the player wants. + /// + private EntityPose GetDesiredPose() + { + if (IsSwimming()) + return EntityPose.Swimming; + if (Sneaking && !CreativeFlying) + return EntityPose.Sneaking; + return EntityPose.Standing; + } + + /// + /// Vanilla Entity.isSwimming() for players: sprinting underwater and not flying. + /// + private bool IsSwimming() => !CreativeFlying && Sprinting && IsUnderWater; + + /// + /// Check if the player can fit at current position with the given pose's dimensions. + /// Vanilla Player.canPlayerFitWithinBlocksAndEntitiesWhen(Pose). + /// + private bool CanPlayerFitWithPose(World world, EntityPose pose) + { + double height = GetHeightForPose(pose); + Aabb box = Aabb.OfSize(Position.X, Position.Y, Position.Z, PlayerWidth, height); + Aabb deflated = box.Deflate(1.0E-7, 1.0E-7, 1.0E-7); + return CollisionDetector.NoCollision(world, deflated); + } + + private static double GetHeightForPose(EntityPose pose) => pose switch + { + EntityPose.Sneaking => PhysicsConsts.PlayerCrouchingHeight, + EntityPose.Swimming or EntityPose.FallFlying or EntityPose.SpinAttack + => PhysicsConsts.PlayerSwimmingHeight, + _ => PhysicsConsts.PlayerStandingHeight + }; + /// /// Set position from server teleport / initial spawn. /// From 1abab20f170e8f660dde3035c71ee2f7522f1d07 Mon Sep 17 00:00:00 2001 From: BruceChen Date: Sat, 11 Apr 2026 01:48:10 +0800 Subject: [PATCH 02/37] feat: add Phase 1 core pathfinding architecture Implements the new Baritone-inspired A* pathfinding system: - Core types: PathNode, PathResult, MoveResult, MoveType, ActionCosts - BinaryHeapOpenSet min-heap for A* open set - AStarPathFinder with timeout, cancellation, partial path support - CalculationContext for thread-safe world state snapshots - MoveHelper for block passability checks - IGoal interface + GoalBlock, GoalXZ, GoalNear, GoalComposite - IMove interface + MoveTraverse, MoveDiagonal, MoveAscend, MoveDescend, MoveClimb - /pathfind command for testing the new pathfinder Made-with: Cursor --- MinecraftClient/Commands/Pathfind.cs | 133 ++++++++++++ .../Pathing/Core/AStarPathFinder.cs | 189 ++++++++++++++++++ MinecraftClient/Pathing/Core/ActionCosts.cs | 63 ++++++ .../Pathing/Core/BinaryHeapOpenSet.cs | 96 +++++++++ .../Pathing/Core/CalculationContext.cs | 67 +++++++ MinecraftClient/Pathing/Core/MoveResult.cs | 28 +++ MinecraftClient/Pathing/Core/MoveType.cs | 13 ++ MinecraftClient/Pathing/Core/PathNode.cs | 39 ++++ MinecraftClient/Pathing/Core/PathResult.cs | 30 +++ MinecraftClient/Pathing/Goals/GoalBlock.cs | 42 ++++ .../Pathing/Goals/GoalComposite.cs | 45 +++++ MinecraftClient/Pathing/Goals/GoalNear.cs | 42 ++++ MinecraftClient/Pathing/Goals/GoalXZ.cs | 28 +++ MinecraftClient/Pathing/Goals/IGoal.cs | 8 + MinecraftClient/Pathing/Moves/IMove.cs | 18 ++ .../Pathing/Moves/Impl/MoveAscend.cs | 50 +++++ .../Pathing/Moves/Impl/MoveClimb.cs | 64 ++++++ .../Pathing/Moves/Impl/MoveDescend.cs | 66 ++++++ .../Pathing/Moves/Impl/MoveDiagonal.cs | 54 +++++ .../Pathing/Moves/Impl/MoveTraverse.cs | 54 +++++ MinecraftClient/Pathing/Moves/MoveHelper.cs | 69 +++++++ .../Translations/Translations.Designer.cs | 18 ++ .../Resources/Translations/Translations.resx | 6 + 23 files changed, 1222 insertions(+) create mode 100644 MinecraftClient/Commands/Pathfind.cs create mode 100644 MinecraftClient/Pathing/Core/AStarPathFinder.cs create mode 100644 MinecraftClient/Pathing/Core/ActionCosts.cs create mode 100644 MinecraftClient/Pathing/Core/BinaryHeapOpenSet.cs create mode 100644 MinecraftClient/Pathing/Core/CalculationContext.cs create mode 100644 MinecraftClient/Pathing/Core/MoveResult.cs create mode 100644 MinecraftClient/Pathing/Core/MoveType.cs create mode 100644 MinecraftClient/Pathing/Core/PathNode.cs create mode 100644 MinecraftClient/Pathing/Core/PathResult.cs create mode 100644 MinecraftClient/Pathing/Goals/GoalBlock.cs create mode 100644 MinecraftClient/Pathing/Goals/GoalComposite.cs create mode 100644 MinecraftClient/Pathing/Goals/GoalNear.cs create mode 100644 MinecraftClient/Pathing/Goals/GoalXZ.cs create mode 100644 MinecraftClient/Pathing/Goals/IGoal.cs create mode 100644 MinecraftClient/Pathing/Moves/IMove.cs create mode 100644 MinecraftClient/Pathing/Moves/Impl/MoveAscend.cs create mode 100644 MinecraftClient/Pathing/Moves/Impl/MoveClimb.cs create mode 100644 MinecraftClient/Pathing/Moves/Impl/MoveDescend.cs create mode 100644 MinecraftClient/Pathing/Moves/Impl/MoveDiagonal.cs create mode 100644 MinecraftClient/Pathing/Moves/Impl/MoveTraverse.cs create mode 100644 MinecraftClient/Pathing/Moves/MoveHelper.cs diff --git a/MinecraftClient/Commands/Pathfind.cs b/MinecraftClient/Commands/Pathfind.cs new file mode 100644 index 00000000..ea8c1135 --- /dev/null +++ b/MinecraftClient/Commands/Pathfind.cs @@ -0,0 +1,133 @@ +using System; +using System.Threading; +using System.Threading.Tasks; +using Brigadier.NET; +using Brigadier.NET.Builder; +using MinecraftClient.CommandHandler; +using MinecraftClient.Mapping; +using MinecraftClient.Pathing.Core; +using MinecraftClient.Pathing.Goals; +using static MinecraftClient.CommandHandler.CmdResult; + +namespace MinecraftClient.Commands +{ + public class Pathfind : Command + { + public override string CmdName => "pathfind"; + public override string CmdUsage => "pathfind "; + public override string CmdDesc => Translations.cmd_pathfind_desc; + + public override void RegisterCommand(CommandDispatcher dispatcher) + { + dispatcher.Register(l => l.Literal("help") + .Then(l => l.Literal(CmdName) + .Executes(r => GetUsage(r.Source))) + ); + + dispatcher.Register(l => l.Literal(CmdName) + .Then(l => l.Argument("location", MccArguments.Location()) + .Executes(r => DoPathfind(r.Source, MccArguments.GetLocation(r, "location")))) + .Then(l => l.Literal("_help") + .Executes(r => GetUsage(r.Source)) + .Redirect(dispatcher.GetRoot().GetChild("help").GetChild(CmdName))) + ); + } + + private int GetUsage(CmdResult r) + { + return r.SetAndReturn(GetCmdDescTranslated()); + } + + private int DoPathfind(CmdResult r, Location goal) + { + McClient handler = CmdResult.currentHandler!; + if (!handler.GetTerrainEnabled()) + return r.SetAndReturn(Status.FailNeedTerrain); + + Location current = handler.GetCurrentLocation(); + goal.ToAbsolute(current); + + int startX = (int)Math.Floor(current.X); + int startY = (int)Math.Floor(current.Y); + int startZ = (int)Math.Floor(current.Z); + int goalX = (int)Math.Floor(goal.X); + int goalY = (int)Math.Floor(goal.Y); + int goalZ = (int)Math.Floor(goal.Z); + + handler.Log.Info($"[Pathfind] Planning from ({startX},{startY},{startZ}) to ({goalX},{goalY},{goalZ})"); + + var ctx = new CalculationContext( + handler.GetWorld(), + canSprint: true, + maxFallHeight: 3); + + var finder = new AStarPathFinder(); + finder.DebugLog = msg => handler.Log.Info(msg); + + var goalObj = new GoalBlock(goalX, goalY, goalZ); + + Task.Run(() => + { + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10)); + var result = finder.Calculate(ctx, startX, startY, startZ, goalObj, cts.Token, timeoutMs: 10000); + + handler.Log.Info($"[Pathfind] Result: {result.Status}, {result.Path.Count} nodes, " + + $"{result.NodesExplored} explored, {result.ElapsedMs}ms"); + + if (result.Path.Count > 0) + { + handler.Log.Info("[Pathfind] Path waypoints:"); + for (int i = 0; i < result.Path.Count; i++) + { + var n = result.Path[i]; + handler.Log.Info($" [{i}] ({n.X},{n.Y},{n.Z}) via {n.MoveUsed}"); + } + + handler.Log.Info("[Pathfind] Beginning movement along path..."); + FollowPath(handler, result); + } + else + { + handler.Log.Warn("[Pathfind] No path found!"); + } + }); + + return r.SetAndReturn(Status.Done, string.Format(Translations.cmd_pathfind_started, goalX, goalY, goalZ)); + } + + private static void FollowPath(McClient handler, PathResult result) + { + for (int i = 1; i < result.Path.Count; i++) + { + var node = result.Path[i]; + var target = new Location(node.X + 0.5, node.Y, node.Z + 0.5); + + handler.Log.Info($"[Pathfind] Moving to waypoint [{i}]: ({node.X},{node.Y},{node.Z}) via {node.MoveUsed}"); + + bool success = handler.MoveTo(target, allowUnsafe: true, allowDirectTeleport: false, timeout: TimeSpan.FromSeconds(10)); + if (!success) + { + handler.Log.Warn($"[Pathfind] Old pathfinder failed to plan sub-path to ({node.X},{node.Y},{node.Z}), trying direct teleport"); + handler.MoveTo(target, allowUnsafe: true, allowDirectTeleport: true); + } + + int maxWaitTicks = 200; + int waited = 0; + while (handler.ClientIsMoving() && waited < maxWaitTicks) + { + Thread.Sleep(50); + waited++; + } + + var cur = handler.GetCurrentLocation(); + double dx = cur.X - target.X; + double dz = cur.Z - target.Z; + double horizDistSq = dx * dx + dz * dz; + + handler.Log.Info($"[Pathfind] Arrived near waypoint [{i}], pos=({cur.X:F2},{cur.Y:F2},{cur.Z:F2}), horizDist={Math.Sqrt(horizDistSq):F2}"); + } + + handler.Log.Info("[Pathfind] Path execution complete!"); + } + } +} diff --git a/MinecraftClient/Pathing/Core/AStarPathFinder.cs b/MinecraftClient/Pathing/Core/AStarPathFinder.cs new file mode 100644 index 00000000..05f4dde9 --- /dev/null +++ b/MinecraftClient/Pathing/Core/AStarPathFinder.cs @@ -0,0 +1,189 @@ +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 IMove[] _allMoves; + private readonly int _maxChunkBorderFetch; + + public Action? DebugLog { get; set; } + + public AStarPathFinder(IMove[]? moves = null, int maxChunkBorderFetch = 64) + { + _allMoves = moves ?? BuildDefaultMoves(); + _maxChunkBorderFetch = maxChunkBorderFetch; + } + + public static IMove[] BuildDefaultMoves() + { + var moves = new List(); + + int[] offsets = [1, -1]; + foreach (int dx in offsets) + { + moves.Add(new MoveTraverse(dx, 0)); + moves.Add(new MoveAscend(dx, 0)); + moves.Add(new MoveDescend(dx, 0)); + } + foreach (int dz in offsets) + { + moves.Add(new MoveTraverse(0, dz)); + moves.Add(new MoveAscend(0, dz)); + moves.Add(new MoveDescend(0, dz)); + } + + moves.Add(new MoveDiagonal(1, 1)); + moves.Add(new MoveDiagonal(1, -1)); + moves.Add(new MoveDiagonal(-1, 1)); + moves.Add(new MoveDiagonal(-1, -1)); + + moves.Add(new MoveClimb(true)); + moves.Add(new MoveClimb(false)); + + return [.. moves]; + } + + public PathResult Calculate( + CalculationContext ctx, + int startX, int startY, int startZ, + IGoal goal, + CancellationToken ct, + long timeoutMs = 5000) + { + var sw = Stopwatch.StartNew(); + var openSet = new BinaryHeapOpenSet(4096); + var nodeMap = new Dictionary(4096); + + var startNode = new PathNode(startX, startY, startZ) + { + GCost = 0, + HCost = goal.Heuristic(startX, startY, startZ), + IsOpen = true + }; + openSet.Insert(startNode); + nodeMap[startNode.PackedPosition] = startNode; + + int nodesExplored = 0; + int unloadedChunkHits = 0; + PathNode? bestPartialNode = startNode; + double bestPartialScore = startNode.HCost + startNode.GCost * 0.5; + MoveResult moveResult = default; + + DebugLog?.Invoke($"[A*] Start ({startX},{startY},{startZ}), goal={goal}"); + + while (openSet.Count > 0) + { + if (ct.IsCancellationRequested) + { + DebugLog?.Invoke($"[A*] Cancelled after {nodesExplored} nodes, {sw.ElapsedMilliseconds}ms"); + break; + } + + if (sw.ElapsedMilliseconds > timeoutMs) + { + 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); + } + + foreach (var move in _allMoves) + { + moveResult.Cost = 0; + move.Calculate(ctx, current.X, current.Y, current.Z, ref moveResult); + + if (moveResult.IsImpossible) + continue; + + int nx = moveResult.DestX; + int ny = moveResult.DestY; + int nz = moveResult.DestZ; + + if (!ctx.IsChunkLoaded(nx, nz)) + { + unloadedChunkHits++; + if (unloadedChunkHits > _maxChunkBorderFetch) + continue; + } + + double tentativeG = current.GCost + moveResult.Cost; + long packed = PathNode.Pack(nx, ny, nz); + + if (nodeMap.TryGetValue(packed, out var neighbor)) + { + if (neighbor.IsClosed) + continue; + if (tentativeG >= neighbor.GCost) + continue; + + neighbor.GCost = tentativeG; + neighbor.Parent = current; + neighbor.MoveUsed = move.Type; + if (neighbor.IsOpen) + openSet.Update(neighbor); + } + else + { + neighbor = new PathNode(nx, ny, nz) + { + GCost = tentativeG, + HCost = goal.Heuristic(nx, ny, nz), + Parent = current, + MoveUsed = move.Type, + IsOpen = true + }; + nodeMap[packed] = 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) + { + 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 static List ReconstructPath(PathNode end) + { + var path = new List(); + var current = end; + while (current is not null) + { + path.Add(current); + current = current.Parent; + } + path.Reverse(); + return path; + } + } +} diff --git a/MinecraftClient/Pathing/Core/ActionCosts.cs b/MinecraftClient/Pathing/Core/ActionCosts.cs new file mode 100644 index 00000000..544413fe --- /dev/null +++ b/MinecraftClient/Pathing/Core/ActionCosts.cs @@ -0,0 +1,63 @@ +namespace MinecraftClient.Pathing.Core +{ + /// + /// All pathfinding movement costs in ticks, derived from vanilla walking/sprinting speeds. + /// Mirrors Baritone's ActionCosts design. + /// + public static class ActionCosts + { + public const double WalkOneBlock = 20.0 / 4.317; + public const double SprintOneBlock = 20.0 / 5.612; + public const double SneakOneBlock = 20.0 / 1.3; + public const double LadderUpOne = 20.0 / 2.35; + public const double LadderDownOne = 20.0 / 3.0; + public const double WalkOffBlock = WalkOneBlock * 0.8; + public const double SprintMultiplier = SprintOneBlock / WalkOneBlock; + public const double DiagonalMultiplier = 1.4142135623730951; + public const double CostInf = 1_000_000; + + public const double JumpPenalty = 2.0; + + public static readonly double[] FallNBlocksCost = BuildFallTable(257); + + private static double[] BuildFallTable(int maxBlocks) + { + var table = new double[maxBlocks]; + table[0] = 0; + + double velocity = 0; + double distance = 0; + int ticks = 0; + int blockIndex = 1; + + while (blockIndex < maxBlocks) + { + velocity += 0.08; + velocity *= 0.98; + distance += velocity; + ticks++; + + while (blockIndex < maxBlocks && distance >= blockIndex) + { + table[blockIndex] = ticks; + blockIndex++; + } + + if (ticks > 10000) + break; + } + + for (int i = blockIndex; i < maxBlocks; i++) + table[i] = CostInf; + + return table; + } + + public static double FallCost(int blocks) + { + if (blocks < 0 || blocks >= FallNBlocksCost.Length) + return CostInf; + return FallNBlocksCost[blocks]; + } + } +} diff --git a/MinecraftClient/Pathing/Core/BinaryHeapOpenSet.cs b/MinecraftClient/Pathing/Core/BinaryHeapOpenSet.cs new file mode 100644 index 00000000..2ea2cd6a --- /dev/null +++ b/MinecraftClient/Pathing/Core/BinaryHeapOpenSet.cs @@ -0,0 +1,96 @@ +using System; + +namespace MinecraftClient.Pathing.Core +{ + /// + /// Min-heap of PathNodes ordered by FCost, used as the A* open set. + /// + public sealed class BinaryHeapOpenSet + { + private PathNode[] _heap; + private int _size; + + public int Count => _size; + + public BinaryHeapOpenSet(int initialCapacity = 1024) + { + _heap = new PathNode[initialCapacity]; + _size = 0; + } + + public void Insert(PathNode node) + { + if (_size == _heap.Length) + Array.Resize(ref _heap, _heap.Length * 2); + + node.HeapIndex = _size; + _heap[_size] = node; + _size++; + SiftUp(_size - 1); + } + + public PathNode RemoveMin() + { + var min = _heap[0]; + _size--; + if (_size > 0) + { + _heap[0] = _heap[_size]; + _heap[0].HeapIndex = 0; + SiftDown(0); + } + _heap[_size] = null!; + min.IsOpen = false; + return min; + } + + public void Update(PathNode node) + { + SiftUp(node.HeapIndex); + } + + private void SiftUp(int i) + { + var node = _heap[i]; + while (i > 0) + { + int parent = (i - 1) >> 1; + if (Compare(node, _heap[parent]) >= 0) + break; + _heap[i] = _heap[parent]; + _heap[i].HeapIndex = i; + i = parent; + } + _heap[i] = node; + node.HeapIndex = i; + } + + private void SiftDown(int i) + { + var node = _heap[i]; + int half = _size >> 1; + while (i < half) + { + int left = (i << 1) + 1; + int right = left + 1; + int best = left; + if (right < _size && Compare(_heap[right], _heap[left]) < 0) + best = right; + if (Compare(node, _heap[best]) <= 0) + break; + _heap[i] = _heap[best]; + _heap[i].HeapIndex = i; + i = best; + } + _heap[i] = node; + node.HeapIndex = i; + } + + private static int Compare(PathNode a, PathNode b) + { + int cmp = a.FCost.CompareTo(b.FCost); + if (cmp != 0) return cmp; + return a.HCost.CompareTo(b.HCost); + } + } +} diff --git a/MinecraftClient/Pathing/Core/CalculationContext.cs b/MinecraftClient/Pathing/Core/CalculationContext.cs new file mode 100644 index 00000000..8728d7b2 --- /dev/null +++ b/MinecraftClient/Pathing/Core/CalculationContext.cs @@ -0,0 +1,67 @@ +using MinecraftClient.Mapping; +using MinecraftClient.Pathing.Moves; + +namespace MinecraftClient.Pathing.Core +{ + /// + /// Thread-safe snapshot of world state and player capabilities for path planning. + /// Created once at the start of a search; all move calculations read from this. + /// + public sealed class CalculationContext + { + public World World { get; } + public bool CanSprint { get; } + public bool AllowParkour { get; } + public bool AllowParkourAscend { get; } + public bool AllowDiagonalDescend { get; } + public int MaxFallHeight { get; } + public double JumpPenalty { get; } + public double WalkCost { get; } + public double SprintCost { get; } + public double SneakCost { get; } + + public CalculationContext( + World world, + bool canSprint = true, + bool allowParkour = false, + bool allowParkourAscend = false, + bool allowDiagonalDescend = true, + int maxFallHeight = 3, + double jumpPenalty = ActionCosts.JumpPenalty) + { + World = world; + CanSprint = canSprint; + AllowParkour = allowParkour; + AllowParkourAscend = allowParkourAscend; + AllowDiagonalDescend = allowDiagonalDescend; + MaxFallHeight = maxFallHeight; + JumpPenalty = jumpPenalty; + WalkCost = ActionCosts.WalkOneBlock; + SprintCost = CanSprint ? ActionCosts.SprintOneBlock : ActionCosts.WalkOneBlock; + SneakCost = ActionCosts.SneakOneBlock; + } + + public Block GetBlock(int x, int y, int z) + => World.GetBlock(new Location(x, y, z)); + + public Material GetMaterial(int x, int y, int z) + => GetBlock(x, y, z).Type; + + public bool CanWalkThrough(int x, int y, int z) + => MoveHelper.CanWalkThrough(this, x, y, z); + + public bool CanWalkOn(int x, int y, int z) + => MoveHelper.CanWalkOn(this, x, y, z); + + public bool IsFullyPassable(int x, int y, int z) + => MoveHelper.IsFullyPassable(this, x, y, z); + + public bool IsChunkLoaded(int x, int z) + { + int cx = x >> 4; + int cz = z >> 4; + var col = World[cx, cz]; + return col is not null && col.FullyLoaded; + } + } +} diff --git a/MinecraftClient/Pathing/Core/MoveResult.cs b/MinecraftClient/Pathing/Core/MoveResult.cs new file mode 100644 index 00000000..59045b48 --- /dev/null +++ b/MinecraftClient/Pathing/Core/MoveResult.cs @@ -0,0 +1,28 @@ +namespace MinecraftClient.Pathing.Core +{ + /// + /// Result of an IMove.Calculate() call. Mutable struct passed by ref for zero-alloc hot path. + /// + public struct MoveResult + { + public int DestX; + public int DestY; + public int DestZ; + public double Cost; + + public void Set(int x, int y, int z, double cost) + { + DestX = x; + DestY = y; + DestZ = z; + Cost = cost; + } + + public void SetImpossible() + { + Cost = ActionCosts.CostInf; + } + + public readonly bool IsImpossible => Cost >= ActionCosts.CostInf; + } +} diff --git a/MinecraftClient/Pathing/Core/MoveType.cs b/MinecraftClient/Pathing/Core/MoveType.cs new file mode 100644 index 00000000..3d632012 --- /dev/null +++ b/MinecraftClient/Pathing/Core/MoveType.cs @@ -0,0 +1,13 @@ +namespace MinecraftClient.Pathing.Core +{ + public enum MoveType + { + Traverse, + Diagonal, + Ascend, + Descend, + Fall, + Climb, + Parkour + } +} diff --git a/MinecraftClient/Pathing/Core/PathNode.cs b/MinecraftClient/Pathing/Core/PathNode.cs new file mode 100644 index 00000000..f8f0d25b --- /dev/null +++ b/MinecraftClient/Pathing/Core/PathNode.cs @@ -0,0 +1,39 @@ +namespace MinecraftClient.Pathing.Core +{ + /// + /// A* search node. Stored in the open/closed sets during pathfinding. + /// + public sealed class PathNode + { + public readonly int X; + public readonly int Y; + public readonly int Z; + + public double GCost; + public double HCost; + public double FCost => GCost + HCost; + + public PathNode? Parent; + public MoveType MoveUsed; + + public int HeapIndex; + public bool IsOpen; + public bool IsClosed; + + public PathNode(int x, int y, int z) + { + X = x; + Y = y; + Z = z; + } + + public long PackedPosition => Pack(X, Y, Z); + + public static long Pack(int x, int y, int z) + { + return ((long)(x + 30_000_000) << 36) + | ((long)(z + 30_000_000) << 12) + | (long)((y + 64) & 0xFFF); + } + } +} diff --git a/MinecraftClient/Pathing/Core/PathResult.cs b/MinecraftClient/Pathing/Core/PathResult.cs new file mode 100644 index 00000000..9d9ba7ee --- /dev/null +++ b/MinecraftClient/Pathing/Core/PathResult.cs @@ -0,0 +1,30 @@ +using System.Collections.Generic; + +namespace MinecraftClient.Pathing.Core +{ + public enum PathStatus + { + Success, + Partial, + Failed + } + + public sealed class PathResult + { + public PathStatus Status { get; } + public IReadOnlyList Path { get; } + public int NodesExplored { get; } + public long ElapsedMs { get; } + + public PathResult(PathStatus status, IReadOnlyList path, int nodesExplored, long elapsedMs) + { + Status = status; + Path = path; + NodesExplored = nodesExplored; + ElapsedMs = elapsedMs; + } + + public static PathResult Fail(int nodesExplored, long elapsedMs) + => new(PathStatus.Failed, [], nodesExplored, elapsedMs); + } +} diff --git a/MinecraftClient/Pathing/Goals/GoalBlock.cs b/MinecraftClient/Pathing/Goals/GoalBlock.cs new file mode 100644 index 00000000..55cbdf7f --- /dev/null +++ b/MinecraftClient/Pathing/Goals/GoalBlock.cs @@ -0,0 +1,42 @@ +using System; + +namespace MinecraftClient.Pathing.Goals +{ + public sealed class GoalBlock : IGoal + { + public int X { get; } + public int Y { get; } + public int Z { get; } + + public GoalBlock(int x, int y, int z) + { + X = x; + Y = y; + Z = z; + } + + public bool IsInGoal(int x, int y, int z) + => x == X && y == Y && z == Z; + + public double Heuristic(int x, int y, int z) + { + int dx = Math.Abs(x - X); + int dy = Math.Abs(y - Y); + int dz = Math.Abs(z - Z); + return DistanceHeuristic(dx, dy, dz); + } + + internal static double DistanceHeuristic(int dx, int dy, int dz) + { + int horizontal = Math.Max(dx, dz); + int diagonal = Math.Min(dx, dz); + int straight = horizontal - diagonal; + double cost = diagonal * Core.ActionCosts.SprintOneBlock * Core.ActionCosts.DiagonalMultiplier + + straight * Core.ActionCosts.SprintOneBlock + + Math.Abs(dy) * Core.ActionCosts.SprintOneBlock; + return cost; + } + + public override string ToString() => $"GoalBlock({X}, {Y}, {Z})"; + } +} diff --git a/MinecraftClient/Pathing/Goals/GoalComposite.cs b/MinecraftClient/Pathing/Goals/GoalComposite.cs new file mode 100644 index 00000000..68dd5d50 --- /dev/null +++ b/MinecraftClient/Pathing/Goals/GoalComposite.cs @@ -0,0 +1,45 @@ +using System; +using System.Collections.Generic; + +namespace MinecraftClient.Pathing.Goals +{ + public sealed class GoalComposite : IGoal + { + private readonly IGoal[] _goals; + + public GoalComposite(params IGoal[] goals) + { + ArgumentNullException.ThrowIfNull(goals); + _goals = goals; + } + + public GoalComposite(IEnumerable goals) + { + ArgumentNullException.ThrowIfNull(goals); + _goals = goals is IGoal[] arr ? arr : [.. goals]; + } + + public bool IsInGoal(int x, int y, int z) + { + foreach (var g in _goals) + { + if (g.IsInGoal(x, y, z)) + return true; + } + return false; + } + + public double Heuristic(int x, int y, int z) + { + double min = double.MaxValue; + foreach (var g in _goals) + { + double h = g.Heuristic(x, y, z); + if (h < min) min = h; + } + return min; + } + + public override string ToString() => $"GoalComposite({_goals.Length} goals)"; + } +} diff --git a/MinecraftClient/Pathing/Goals/GoalNear.cs b/MinecraftClient/Pathing/Goals/GoalNear.cs new file mode 100644 index 00000000..1fbd3d21 --- /dev/null +++ b/MinecraftClient/Pathing/Goals/GoalNear.cs @@ -0,0 +1,42 @@ +using System; + +namespace MinecraftClient.Pathing.Goals +{ + public sealed class GoalNear : IGoal + { + public int X { get; } + public int Y { get; } + public int Z { get; } + public int Range { get; } + private readonly int _rangeSq; + + public GoalNear(int x, int y, int z, int range) + { + X = x; + Y = y; + Z = z; + Range = range; + _rangeSq = range * range; + } + + public bool IsInGoal(int x, int y, int z) + { + int dx = x - X; + int dy = y - Y; + int dz = z - Z; + return dx * dx + dy * dy + dz * dz <= _rangeSq; + } + + public double Heuristic(int x, int y, int z) + { + int dx = Math.Abs(x - X); + int dy = Math.Abs(y - Y); + int dz = Math.Abs(z - Z); + double h = GoalBlock.DistanceHeuristic(dx, dy, dz); + double reduction = Range * Core.ActionCosts.SprintOneBlock; + return Math.Max(0, h - reduction); + } + + public override string ToString() => $"GoalNear({X}, {Y}, {Z}, range={Range})"; + } +} diff --git a/MinecraftClient/Pathing/Goals/GoalXZ.cs b/MinecraftClient/Pathing/Goals/GoalXZ.cs new file mode 100644 index 00000000..22a4873f --- /dev/null +++ b/MinecraftClient/Pathing/Goals/GoalXZ.cs @@ -0,0 +1,28 @@ +using System; + +namespace MinecraftClient.Pathing.Goals +{ + public sealed class GoalXZ : IGoal + { + public int X { get; } + public int Z { get; } + + public GoalXZ(int x, int z) + { + X = x; + Z = z; + } + + public bool IsInGoal(int x, int y, int z) + => x == X && z == Z; + + public double Heuristic(int x, int y, int z) + { + int dx = Math.Abs(x - X); + int dz = Math.Abs(z - Z); + return GoalBlock.DistanceHeuristic(dx, 0, dz); + } + + public override string ToString() => $"GoalXZ({X}, {Z})"; + } +} diff --git a/MinecraftClient/Pathing/Goals/IGoal.cs b/MinecraftClient/Pathing/Goals/IGoal.cs new file mode 100644 index 00000000..13cada06 --- /dev/null +++ b/MinecraftClient/Pathing/Goals/IGoal.cs @@ -0,0 +1,8 @@ +namespace MinecraftClient.Pathing.Goals +{ + public interface IGoal + { + bool IsInGoal(int x, int y, int z); + double Heuristic(int x, int y, int z); + } +} diff --git a/MinecraftClient/Pathing/Moves/IMove.cs b/MinecraftClient/Pathing/Moves/IMove.cs new file mode 100644 index 00000000..69e9e107 --- /dev/null +++ b/MinecraftClient/Pathing/Moves/IMove.cs @@ -0,0 +1,18 @@ +using MinecraftClient.Pathing.Core; + +namespace MinecraftClient.Pathing.Moves +{ + /// + /// Represents one type of movement action for path planning. + /// Each implementation defines its spatial check pattern and cost model. + /// + public interface IMove + { + MoveType Type { get; } + int XOffset { get; } + int ZOffset { get; } + bool DynamicY { get; } + + void Calculate(CalculationContext ctx, int x, int y, int z, ref MoveResult result); + } +} diff --git a/MinecraftClient/Pathing/Moves/Impl/MoveAscend.cs b/MinecraftClient/Pathing/Moves/Impl/MoveAscend.cs new file mode 100644 index 00000000..53d16f14 --- /dev/null +++ b/MinecraftClient/Pathing/Moves/Impl/MoveAscend.cs @@ -0,0 +1,50 @@ +using MinecraftClient.Pathing.Core; + +namespace MinecraftClient.Pathing.Moves.Impl +{ + /// + /// Jump up 1 block in a cardinal direction. + /// Requires: headroom at (x, y+2, z), body space at dest (y+1, y+2), ground at dest (y). + /// + public sealed class MoveAscend : IMove + { + public MoveType Type => MoveType.Ascend; + public int XOffset { get; } + public int ZOffset { get; } + public bool DynamicY => false; + + public MoveAscend(int xOffset, int zOffset) + { + XOffset = xOffset; + ZOffset = zOffset; + } + + public void Calculate(CalculationContext ctx, int x, int y, int z, ref MoveResult result) + { + int destX = x + XOffset; + int destZ = z + ZOffset; + int destY = y + 1; + + if (!ctx.CanWalkThrough(x, y + 2, z)) + { + result.SetImpossible(); + return; + } + + if (!ctx.CanWalkThrough(destX, destY, destZ) || !ctx.CanWalkThrough(destX, destY + 1, destZ)) + { + result.SetImpossible(); + return; + } + + if (!ctx.CanWalkOn(destX, y, destZ)) + { + result.SetImpossible(); + return; + } + + double cost = ctx.SprintCost + ctx.JumpPenalty; + result.Set(destX, destY, destZ, cost); + } + } +} diff --git a/MinecraftClient/Pathing/Moves/Impl/MoveClimb.cs b/MinecraftClient/Pathing/Moves/Impl/MoveClimb.cs new file mode 100644 index 00000000..1952b74d --- /dev/null +++ b/MinecraftClient/Pathing/Moves/Impl/MoveClimb.cs @@ -0,0 +1,64 @@ +using MinecraftClient.Mapping; +using MinecraftClient.Pathing.Core; + +namespace MinecraftClient.Pathing.Moves.Impl +{ + /// + /// Climb up or down a ladder/vine at the current X,Z position. + /// + public sealed class MoveClimb : IMove + { + public MoveType Type => MoveType.Climb; + public int XOffset => 0; + public int ZOffset => 0; + public bool DynamicY => false; + + private readonly bool _up; + + public MoveClimb(bool up) + { + _up = up; + } + + public void Calculate(CalculationContext ctx, int x, int y, int z, ref MoveResult result) + { + var currentMat = ctx.GetMaterial(x, y, z); + if (!MoveHelper.IsClimbable(currentMat)) + { + result.SetImpossible(); + return; + } + + if (_up) + { + int destY = y + 1; + if (!ctx.CanWalkThrough(x, destY + 1, z)) + { + result.SetImpossible(); + return; + } + + var aboveMat = ctx.GetMaterial(x, destY, z); + if (MoveHelper.IsClimbable(aboveMat) || !ctx.GetMaterial(x, destY, z).IsSolid()) + { + result.Set(x, destY, z, ActionCosts.LadderUpOne); + return; + } + + result.SetImpossible(); + } + else + { + int destY = y - 1; + var belowMat = ctx.GetMaterial(x, destY, z); + if (MoveHelper.IsClimbable(belowMat) || !belowMat.IsSolid()) + { + result.Set(x, destY, z, ActionCosts.LadderDownOne); + return; + } + + result.SetImpossible(); + } + } + } +} diff --git a/MinecraftClient/Pathing/Moves/Impl/MoveDescend.cs b/MinecraftClient/Pathing/Moves/Impl/MoveDescend.cs new file mode 100644 index 00000000..3dc47fd7 --- /dev/null +++ b/MinecraftClient/Pathing/Moves/Impl/MoveDescend.cs @@ -0,0 +1,66 @@ +using MinecraftClient.Pathing.Core; + +namespace MinecraftClient.Pathing.Moves.Impl +{ + /// + /// Walk off a ledge and drop 1-N blocks in a cardinal direction. + /// Scans downward for a landing spot within MaxFallHeight. + /// + public sealed class MoveDescend : IMove + { + public MoveType Type => MoveType.Descend; + public int XOffset { get; } + public int ZOffset { get; } + public bool DynamicY => true; + + public MoveDescend(int xOffset, int zOffset) + { + XOffset = xOffset; + ZOffset = zOffset; + } + + public void Calculate(CalculationContext ctx, int x, int y, int z, ref MoveResult result) + { + int destX = x + XOffset; + int destZ = z + ZOffset; + + if (!ctx.CanWalkThrough(destX, y, destZ) || !ctx.CanWalkThrough(destX, y + 1, destZ)) + { + result.SetImpossible(); + return; + } + + for (int fallDist = 1; fallDist <= ctx.MaxFallHeight; fallDist++) + { + int landY = y - fallDist; + + if (ctx.CanWalkOn(destX, landY - 1, destZ)) + { + if (!ctx.CanWalkThrough(destX, landY, destZ)) + { + result.SetImpossible(); + return; + } + + double cost = ActionCosts.WalkOffBlock + ActionCosts.FallCost(fallDist); + if (MoveHelper.IsHazardous(ctx.GetMaterial(destX, landY - 1, destZ))) + { + result.SetImpossible(); + return; + } + + result.Set(destX, landY, destZ, cost); + return; + } + + if (!ctx.CanWalkThrough(destX, landY, destZ)) + { + result.SetImpossible(); + return; + } + } + + result.SetImpossible(); + } + } +} diff --git a/MinecraftClient/Pathing/Moves/Impl/MoveDiagonal.cs b/MinecraftClient/Pathing/Moves/Impl/MoveDiagonal.cs new file mode 100644 index 00000000..70e78d77 --- /dev/null +++ b/MinecraftClient/Pathing/Moves/Impl/MoveDiagonal.cs @@ -0,0 +1,54 @@ +using MinecraftClient.Pathing.Core; + +namespace MinecraftClient.Pathing.Moves.Impl +{ + /// + /// Diagonal walk (1 block in both X and Z, same Y). + /// Checks both intermediate cardinal columns for clearance. + /// + public sealed class MoveDiagonal : IMove + { + public MoveType Type => MoveType.Diagonal; + public int XOffset { get; } + public int ZOffset { get; } + public bool DynamicY => false; + + public MoveDiagonal(int xOffset, int zOffset) + { + XOffset = xOffset; + ZOffset = zOffset; + } + + public void Calculate(CalculationContext ctx, int x, int y, int z, ref MoveResult result) + { + int destX = x + XOffset; + int destZ = z + ZOffset; + + if (!ctx.CanWalkThrough(destX, y, destZ) || !ctx.CanWalkThrough(destX, y + 1, destZ)) + { + result.SetImpossible(); + return; + } + + if (!ctx.CanWalkOn(destX, y - 1, destZ)) + { + result.SetImpossible(); + return; + } + + if (!ctx.CanWalkThrough(x + XOffset, y, z) || !ctx.CanWalkThrough(x + XOffset, y + 1, z)) + { + result.SetImpossible(); + return; + } + + if (!ctx.CanWalkThrough(x, y, z + ZOffset) || !ctx.CanWalkThrough(x, y + 1, z + ZOffset)) + { + result.SetImpossible(); + return; + } + + result.Set(destX, y, destZ, ctx.SprintCost * ActionCosts.DiagonalMultiplier); + } + } +} diff --git a/MinecraftClient/Pathing/Moves/Impl/MoveTraverse.cs b/MinecraftClient/Pathing/Moves/Impl/MoveTraverse.cs new file mode 100644 index 00000000..590503af --- /dev/null +++ b/MinecraftClient/Pathing/Moves/Impl/MoveTraverse.cs @@ -0,0 +1,54 @@ +using MinecraftClient.Pathing.Core; + +namespace MinecraftClient.Pathing.Moves.Impl +{ + /// + /// Flat cardinal walk (1 block in +/-X or +/-Z, same Y). + /// Checks body+head passable and ground below destination. + /// + public sealed class MoveTraverse : IMove + { + public MoveType Type => MoveType.Traverse; + public int XOffset { get; } + public int ZOffset { get; } + public bool DynamicY => false; + + public MoveTraverse(int xOffset, int zOffset) + { + XOffset = xOffset; + ZOffset = zOffset; + } + + public void Calculate(CalculationContext ctx, int x, int y, int z, ref MoveResult result) + { + int destX = x + XOffset; + int destZ = z + ZOffset; + + if (!ctx.CanWalkThrough(destX, y, destZ)) + { + result.SetImpossible(); + return; + } + + if (!ctx.CanWalkThrough(destX, y + 1, destZ)) + { + result.SetImpossible(); + return; + } + + if (!ctx.CanWalkOn(destX, y - 1, destZ)) + { + result.SetImpossible(); + return; + } + + double cost = ctx.SprintCost; + + var destFloorMat = ctx.GetMaterial(destX, y - 1, destZ); + if (destFloorMat == Mapping.Material.SoulSand) + cost *= 1.0 / Physics.PhysicsConsts.SoulSandSpeedFactor; + + result.Set(destX, y, destZ, cost); + } + } +} diff --git a/MinecraftClient/Pathing/Moves/MoveHelper.cs b/MinecraftClient/Pathing/Moves/MoveHelper.cs new file mode 100644 index 00000000..8bb6108f --- /dev/null +++ b/MinecraftClient/Pathing/Moves/MoveHelper.cs @@ -0,0 +1,69 @@ +using MinecraftClient.Mapping; +using MinecraftClient.Pathing.Core; + +namespace MinecraftClient.Pathing.Moves +{ + /// + /// Block passability checks for path planning. + /// Uses Material-level checks initially; designed to allow future BlockShapes upgrade. + /// + public static class MoveHelper + { + /// + /// Can a player's body/head occupy this block position? (air, open door, tall grass, etc.) + /// + public static bool CanWalkThrough(CalculationContext ctx, int x, int y, int z) + { + Material mat = ctx.GetMaterial(x, y, z); + if (mat == Material.Air || mat == Material.CaveAir || mat == Material.VoidAir) + return true; + if (mat.IsLiquid()) + return false; + if (mat.IsSolid()) + return false; + if (mat.CanHarmPlayers()) + return false; + return true; + } + + /// + /// Can a player stand on top of this block? (solid upper surface) + /// + public static bool CanWalkOn(CalculationContext ctx, int x, int y, int z) + { + Material mat = ctx.GetMaterial(x, y, z); + if (mat == Material.Air || mat == Material.CaveAir || mat == Material.VoidAir) + return false; + if (mat.IsLiquid()) + return false; + if (mat.CanHarmPlayers()) + return false; + return mat.IsSolid(); + } + + /// + /// Is this block completely passable with no slowdown or interaction? + /// Stricter than CanWalkThrough -- excludes water, cobwebs, etc. + /// + public static bool IsFullyPassable(CalculationContext ctx, int x, int y, int z) + { + Material mat = ctx.GetMaterial(x, y, z); + return mat == Material.Air || mat == Material.CaveAir || mat == Material.VoidAir; + } + + public static bool IsClimbable(Material mat) + { + return mat.CanBeClimbedOn(); + } + + public static bool IsHazardous(Material mat) + { + return mat.CanHarmPlayers(); + } + + public static bool IsWater(Material mat) + { + return mat == Material.Water; + } + } +} diff --git a/MinecraftClient/Resources/Translations/Translations.Designer.cs b/MinecraftClient/Resources/Translations/Translations.Designer.cs index b6e454cd..b5407c84 100644 --- a/MinecraftClient/Resources/Translations/Translations.Designer.cs +++ b/MinecraftClient/Resources/Translations/Translations.Designer.cs @@ -4585,6 +4585,24 @@ namespace MinecraftClient { } } + /// + /// Looks up a localized string similar to Use new A* pathfinding to navigate to a location.. + /// + internal static string cmd_pathfind_desc { + get { + return ResourceManager.GetString("cmd.pathfind.desc", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Pathfinding to ({0}, {1}, {2}).... + /// + internal static string cmd_pathfind_started { + get { + return ResourceManager.GetString("cmd.pathfind.started", resourceCulture); + } + } + /// /// Looks up a localized string similar to restart and reconnect to the server.. /// diff --git a/MinecraftClient/Resources/Translations/Translations.resx b/MinecraftClient/Resources/Translations/Translations.resx index 2e9da34a..e4d96ef7 100644 --- a/MinecraftClient/Resources/Translations/Translations.resx +++ b/MinecraftClient/Resources/Translations/Translations.resx @@ -1538,6 +1538,12 @@ You can use "/chunk status {0:0.0} {1:0.0} {2:0.0}" to check the chunk loading s Walking from {1} to {0} + + Use new A* pathfinding to navigate to a location. + + + Pathfinding to ({0}, {1}, {2})... + restart and reconnect to the server. From e9b19d3cbb81094f466c6b99af092c5613ca073c Mon Sep 17 00:00:00 2001 From: BruceChen Date: Sat, 11 Apr 2026 02:08:16 +0800 Subject: [PATCH 03/37] fix: correct PathNode.Pack bit overlap causing hash collisions The X and Z fields shared bit 36, causing nodes like (1,80,0) and (0,80,0) to hash to the same value. Fixed by using proper non-overlapping bit allocation: X in bits 38-63, Z in bits 12-37, Y in bits 0-11. Added diagnostic logging to pathfind command. Made-with: Cursor --- .gitignore | 2 + 1.21.11 | 609 ++++++++++++++++++ 1.21.4 | 609 ++++++++++++++++++ MinecraftClient/Commands/Pathfind.cs | 25 + .../Pathing/Core/AStarPathFinder.cs | 18 + MinecraftClient/Pathing/Core/PathNode.cs | 8 +- config/phase0_test.cs | 121 ++++ 7 files changed, 1389 insertions(+), 3 deletions(-) create mode 100644 1.21.11 create mode 100644 1.21.4 create mode 100644 config/phase0_test.cs diff --git a/.gitignore b/.gitignore index 8ee9c4d3..b4423a67 100644 --- a/.gitignore +++ b/.gitignore @@ -444,3 +444,5 @@ server.pid # Crowdin translation automation working directory /.crowdin-translate/ + +thirdparty/ \ No newline at end of file diff --git a/1.21.11 b/1.21.11 new file mode 100644 index 00000000..a408e799 --- /dev/null +++ b/1.21.11 @@ -0,0 +1,609 @@ +# Startup Config File +# Please do not record extraneous data in this file as it will be overwritten by MCC. +# +# New to Minecraft Console Client? Check out this document: https://mccteam.github.io/g/conf.html +# Want to upgrade to a newer version? See https://github.com/MCCTeam/Minecraft-Console-Client/#download +[Head] +"Current Version" = "Development Build" +"Latest Version" = "GitHub build 420, built on 2026-04-09" + +[Main] +[Main.General] +Account = { Login = "CursorBot", Password = "-" } +Server = { Host = "mc.hypixel.net", Port = 25565 } # The address of the game server, "Host" can be filled in with domain name or IP address. (The "Port" field can be deleted, it will be resolved automatically) +AccountType = "mojang" +Method = "mcc" # Microsoft Account sign-in method: "mcc" (device code, supports 2FA) OR "browser" (manual browser login). +AuthUser = "" # Yggdrasil authlib multi-user selection. +[Main.General.AuthServer] # authlib-injector authentication server to use for Yggdrasil accounts +Port = 443 # Port to connect on +AuthlibInjectorAPIPath = "/api/yggdrasil" # Path component of the authlib-injector API location. Refer to the authlib-injector documentation for more info. +UseHttps = true # Set to false if your authlib-injector server uses plain HTTP (e.g. for local testing without TLS). +Host = "" # Domain name or IP address + + +# Make sure you understand what each setting does before changing anything! +[Main.Advanced] +EnableSentry = true # Set to false to opt-out of Sentry error logging. +Language = "zh_cn" # Fill in with in-game locale code, check https://mccteam.github.io/r/l-code.html +LoadMccTranslation = true # Load translations applied to MCC when available, turn it off to use English only. +ConsoleTitle = "%username%@%serverip% - Minecraft Console Client" +InternalCmdChar = "slash" # Use "none", "slash"(/) or "backslash"(\). +MessageCooldown = 1.0 # Controls the minimum interval (in seconds) between sending each message to the server. +MaxChatMessageLength = 0 # Override the maximum chat message length. Set to 0 to use the default (100 for 1.10 and below, 256 for 1.11+). WARNING: Setting this incorrectly may cause you to be kicked from the server. +BotOwners = [ "player1", "player2", ] # Set the owner of the bot. /!\ Server admins can impersonate owners! +MinecraftVersion = "CursorBot" # Use "auto" or "1.X.X" values. Allows to skip server info retrieval. +EnableForge = "no" # Use "auto", "no" or "force". Force-enabling only works for MC 1.13+. +BrandInfo = "mcc" # Use "mcc", "vanilla" or "none". This is how MCC identifies itself to the server. +ChatbotLogFile = "" # Leave empty for no logfile. +PrivateMsgsCmdName = "tell" # For remote control of the bot. +ShowSystemMessages = true # System messages for server ops. +ShowXPBarMessages = true # Messages displayed above xp bar, set this to false in case of xp bar spam. +ShowChatLinks = true # Decode links embedded in chat messages and show them in console. +ShowInventoryLayout = true # Show inventory layout as ASCII art in inventory command. +ShowEffectNamesInTUI = false # Show full effect names and levels in the TUI status bar instead of compact effect icons only. +ShowGithubStarReminder = true # Show a GitHub star reminder on startup. Set to false to hide it. +TerrainAndMovements = true # Uses more ram, cpu, bandwidth but allows you to move around. +MoveHeadWhileWalking = true # Enable head movement while walking to avoid anti-cheat triggers. +MovementSpeed = 2 # A movement speed higher than 2 may be considered cheating. +TemporaryFixBadpacket = false # Temporary fix for Badpacket issue on some servers. Need to enable "TerrainAndMovements" first. +InventoryHandling = true # Toggle inventory handling. +EntityHandling = true # Toggle entity handling. +SessionCache = "disk" # How to retain session tokens. Use "none", "memory" or "disk". +ProfileKeyCache = "disk" # How to retain profile key. Use "none", "memory" or "disk". +ResolveSrvRecords = "fast" # Use "no", "fast" (5s timeout), or "yes". Required for joining some servers. +PlayerHeadAsIcon = true # Only works on Windows XP-8 or Windows 10 with old console. +ExitOnFailure = false # Whether to exit directly when an error occurs, for using MCC in non-interactive scripts. +CacheScript = true # Cache compiled scripts for faster load on low-end devices. +Timestamps = false # Prepend timestamps to chat messages. +AutoRespawn = true # Toggle auto respawn if client player was dead (make sure your spawn point is safe). +MinecraftRealms = false # Enable support for joining Minecraft Realms worlds. +TcpTimeout = 30 # Customize the TCP connection timeout with the server. (in seconds) +EnableEmoji = true # If turned off, the emoji will be replaced with a simpler character (for /chunk status). +MinTerminalWidth = 16 # The minimum width used when calculating the image size from the width of the terminal. +MinTerminalHeight = 10 # The minimum height to use when calculating the image size from the height of the terminal. +IgnoreInvalidPlayerName = true # Ignore invalid player name +# AccountList: It allows a fast account switching without directly using the credentials +# Usage examples: "/tell reco Player2", "/connect Player1" +[Main.Advanced.AccountList] +AccountNikename1 = { Login = "playerone@email.com", Password = "thepassword" } +AccountNikename2 = { Login = "TestBot", Password = "-" } + +# ServerList: It allows an easier and faster server switching with short aliases instead of full server IP +# Aliases cannot contain dots or spaces, and the name "localhost" cannot be used as an alias. +# Usage examples: "/tell connect Server1", "/connect Server2" +[Main.Advanced.ServerList] +ServerAlias1 = { Host = "mc.awesomeserver.com" } +ServerAlias2 = { Host = "192.168.1.27", Port = 12345 } + + + +# Chat signature related settings (affects minecraft 1.19+) +[Signature] +LoginWithSecureProfile = true # Microsoft accounts only. If disabled, will not be able to sign chat and join servers configured with "enforce-secure-profile=true" +SignChat = true # Whether to sign the chat send from MCC +SignMessageInCommand = true # Whether to sign the messages contained in the commands sent by MCC. For example, the message in "/msg" and "/me" +MarkLegallySignedMsg = true # Use green  color block to mark chat with legitimate signatures +MarkModifiedMsg = true # Use yellow color block to mark chat that have been modified by the server. +MarkIllegallySignedMsg = true # Use red    color block to mark chat without legitimate signature +MarkSystemMessage = true # Use gray   color block to mark system message (always without signature) +ShowModifiedChat = true # Set to true to display messages modified by the server, false to display the original signed messages +ShowIllegalSignedChat = true # Whether to display chat and messages in commands without legal signatures + +# This setting affects only the messages in the console. +[Logging] +DebugMessages = true # Please enable this before submitting bug reports. Thanks! +ChatMessages = true # Show server chat messages. +InfoMessages = true # Informative messages. (i.e Most of the message from MCC) +WarningMessages = true # Show warning messages. +ErrorMessages = true # Show error messages. +ChatFilterRegex = ".*" # Regex for filtering chat message. +DebugFilterRegex = ".*" # Regex for filtering debug message. +FilterMode = "disable" # "disable" or "blacklist" OR "whitelist". Blacklist hide message match regex. Whitelist show message match regex. +LogToFile = false # Write log messages to file. +LogFile = "console-log.txt" # Log file name. +PrependTimestamp = false # Prepend timestamp to messages in log file. +SaveColorCodes = false # Keep color codes in the saved text.(look like "§b") + +[Console] +[Console.General] +ConsoleMode = "classic" # Console mode: "classic" for the standard terminal, "tui" for a pseudo-graphical full-screen interface. +ConsoleColorMode = "vt100_4bit" # Use "disable", "legacy_4bit", "vt100_4bit", "vt100_8bit" or "vt100_24bit". If a garbled code like "←[0m" appears on the terminal, you can try switching to "legacy_4bit" mode, or just disable it. +Display_Icon_Banner = true # Whether to display the MCC startup icon banner. +Display_Input = true # You can use "Ctrl+P" to print out the current input and cursor position. +History_Input_Records = 32 # Maximum number of input history records to keep. +TUI_Log_Scrollback = 0 # Maximum log lines kept in TUI mode scrollback. Set to 0 for automatic. + +# The settings for command completion suggestions. +# Custom colors are only available when using "vt100_24bit" color mode. +[Console.CommandSuggestion] +Enable = true # Whether to display command suggestions in the console. +Enable_Color = true +Use_Basic_Arrow = false # Enable this option if the arrows in the command suggestions are not displayed properly in your terminal. +Max_Suggestion_Width = 30 +Max_Displayed_Suggestions = 10 +Text_Color = "#f8fafc" +Text_Background_Color = "#64748b" +Highlight_Text_Color = "#334155" +Highlight_Text_Background_Color = "#fde047" +Tooltip_Color = "#7dd3fc" +Highlight_Tooltip_Color = "#3b82f6" +Arrow_Symbol_Color = "#d1d5db" + +# Settings for the TUI minimap overlay that shows terrain and entities. +[Console.Minimap] +Enabled = true # Whether the minimap is visible on startup in TUI mode. +Zoom = 2 # Blocks per pixel, 1-16. 1 = closest (1:1), 16 = farthest (16 blocks per pixel). +Width = 40 # Map width in pixels (characters). Range 10-120, default 40. +Height = 40 # Map height in pixels (must be even, uses half-block chars). Range 4-80, default 40. +Position = "top_right" # Minimap position: "top_left", "top_right", "center", "bottom_left", or "bottom_right". +ShowPlayerNames = false # Show player names on the minimap. +ShowHostileNames = false # Show hostile mob names on the minimap. +ShowNeutralNames = false # Show neutral mob names on the minimap. +ShowPassiveNames = false # Show passive mob names on the minimap. +RefreshInterval = 1000 # Minimap refresh interval in milliseconds (100-5000). +CaveMode = "auto" # Cave rendering mode: "auto" (detect ceiling), "on" (always cave view), "off" (always surface view). + +# Settings for the /tab command and live TUI tab overlay. +[Console.TabList] +ShowTeams = false # Show a separate team column in /tab output. Disabled by default for a more vanilla-like player list. + + +[AppVar] +# can be used in some other fields as %yourvar% +# %username%, %login%, %serverip%, %serverport%, %datetime% and %players% are reserved read-only variables. +[AppVar.VarStirng] +your_var = "your_value" +"your var 2" = "your value 2" + + +# Connect to a server via a proxy instead of connecting directly +# If Mojang session services are blocked on your network, set Enabled_Login=true to login using proxy. +# If the connection to the Minecraft game server is blocked by the firewall, set Enabled_Ingame=true to use a proxy to connect to the game server. +# /!\ Make sure your server rules allow Proxies or VPNs before setting enabled=true, or you may face consequences! +[Proxy] +Enabled_Update = false # Whether to download MCC updates via proxy. +Enabled_Login = false # Whether to connect to the login server through a proxy. +Enabled_Ingame = false # Whether to connect to the game server through a proxy. +Server = { Host = "0.0.0.0", Port = 8080 } # Proxy server must allow HTTPS for login, and non-443 ports for playing. +Proxy_Type = "HTTP" # Supported types: "HTTP", "SOCKS4", "SOCKS4a", "SOCKS5". +Username = "" # Only required for password-protected proxies. +Password = "" # Only required for password-protected proxies. + +# Settings below are sent to the server and only affect server-side things like your skin. +[MCSettings] +Enabled = true # If disabled, settings below are not sent to the server. +Locale = "zh_CN" # Use any language implemented in Minecraft. +RenderDistance = 8 # Value range: [0 - 255]. +Difficulty = "peaceful" # MC 1.7- difficulty. "peaceful", "easy", "normal", "difficult". +ChatMode = "enabled" # Use "enabled", "commands", or "disabled". Allows to mute yourself... +ChatColors = true # Allows disabling chat colors server-side. +MainHand = "left" # MC 1.9+ main hand. "left" or "right". +[MCSettings.Skin] +Cape = true +Hat = true +Jacket = false +Sleeve_Left = false +Sleeve_Right = false +Pants_Left = false +Pants_Right = false + + +# MCC does it best to detect chat messages, but some server have unusual chat formats +# When this happens, you'll need to configure chat format below, see https://mccteam.github.io/g/conf/#chat-format-section +[ChatFormat] +Builtins = true # MCC support for common message formats. Set "false" to avoid conflicts with custom formats. +UserDefined = false # Whether to use the custom regular expressions below for detection. +Public = "^<([a-zA-Z0-9_]+)> (.+)$" +Private = "^([a-zA-Z0-9_]+) whispers to you: (.+)$" +TeleportRequest = '^([a-zA-Z0-9_]+) has requested (?:to|that you) teleport to (?:you|them)\.$' + +# =============================== # +# Minecraft Console Client Bots # +# =============================== # +[ChatBot] +# Get alerted when specified words are detected in chat +# Useful for moderating your server or detecting when someone is talking to you +[ChatBot.Alerts] +Enabled = false +Beep_Enabled = true # Play a beep sound when a word is detected in addition to highlighting. +Trigger_By_Words = false # Triggers an alert after receiving a specified keyword. +Trigger_By_Rain = false # Trigger alerts when it rains and when it stops. +Trigger_By_Thunderstorm = false # Triggers alerts at the beginning and end of thunderstorms. +Log_To_File = false # Log alerts info a file. +Log_File = "alerts-log.txt" # The name of a file where alers logs will be written. +# List of words/strings to alert you on. +Matches = [ "Yourname", " whispers ", "-> me", "admin", ".com", ] +# List of words/strings to NOT alert you on. +Excludes = [ "myserver.com", "Yourname>:", "Player Yourname", "Yourname joined", "Yourname left", "[Lockette] (Admin)", " Yourname:", "Yourname is", ] + +# Send a command on a regular or random basis or make the bot walk around randomly to avoid automatic AFK disconnection +# /!\ Make sure your server rules do not forbid anti-AFK mechanisms! +# /!\ Make sure you keep the bot in an enclosure to prevent it wandering off if you're using terrain handling! (Recommended size 5x5x5) +[ChatBot.AntiAFK] +Enabled = false +Delay = { min = 60.0, max = 60.0 } # The time interval for execution. (in seconds) +Command = "/ping" # Command to send to the server. +Use_Sneak = false # Whether to sneak when sending the command. +Use_Terrain_Handling = false # Use terrain handling to enable the bot to move around. +Walk_Range = 5 # The range the bot can move around randomly (Note: the bigger the range, the slower the bot will be) +Walk_Retries = 20 # How many times can the bot fail trying to move before using the command method. + +# Automatically attack hostile mobs around you +# You need to enable Entity Handling to use this bot +# /!\ Make sure server rules allow your planned use of AutoAttack +# /!\ SERVER PLUGINS may consider AutoAttack to be a CHEAT MOD and TAKE ACTION AGAINST YOUR ACCOUNT so DOUBLE CHECK WITH SERVER RULES! +[ChatBot.AutoAttack] +Enabled = false +Mode = "single" # "single" or "multi". single target one mob per attack. multi target all mobs in range per attack +Priority = "distance" # "health" or "distance". Only needed when using single mode +Cooldown_Time = { Custom = false, value = 1.0 } # How long to wait between each attack. Set "Custom = false" to let MCC calculate it. +Interaction = "Attack" # Possible values: "Interact", "Attack" (default), "InteractAt" (Interact and Attack). +Attack_Range = 4.0 # Capped between 1 to 4 +Attack_Hostile = true # Allow attacking hostile mobs. +Attack_Passive = false # Allow attacking passive mobs. +List_Mode = "whitelist" # Wether to treat the entities list as a "whitelist" or as a "blacklist". +Entites_List = [ "Zombie", "Cow", ] # All entity types can be found here: https://mccteam.github.io/r/entity/#L15 + +# Automatically craft items in your inventory +# See https://mccteam.github.io/g/bots/#auto-craft for how to use +# You need to enable Inventory Handling to use this bot +# You should also enable Terrain and Movements if you need to use a crafting table +[ChatBot.AutoCraft] +Enabled = false +CraftingTable = { X = 123.0, Y = 65.0, Z = 456.0 } # Location of the crafting table if you intended to use it. Terrain and movements must be enabled. +OnFailure = "abort" # What to do on crafting failure, "abort" or "wait". +# Recipes.Name: The name can be whatever you like and it is used to represent the recipe. +# Recipes.Type: crafting table type: "player" or "table" +# Recipes.Result: the resulting item +# Recipes.Slots: All slots, counting from left to right, top to bottom. Please fill in "Null" for empty slots. +# For the naming of the items, please see: https://mccteam.github.io/r/item/#L12 + +[[ChatBot.AutoCraft.Recipes]] +Name = "Recipe-Name-1" +Type = "player" +Result = "StoneBricks" +Slots = [ "Stone", "Stone", "Stone", "Stone", ] + +[[ChatBot.AutoCraft.Recipes]] +Name = "Recipe-Name-2" +Type = "table" +Result = "StoneBricks" +Slots = [ "Stone", "Stone", "Null", "Stone", "Stone", "Null", "Null", "Null", "Null", ] + + +# Auto-digging blocks. +# You need to enable Terrain Handling to use this bot +# You can use "/digbot start" and "/digbot stop" to control the start and stop of AutoDig. +# Since MCC does not yet support accurate calculation of the collision volume of blocks, all blocks are considered as complete cubes when obtaining the position of the lookahead. +# For the naming of the block, please see https://mccteam.github.io/r/block/#L15 +[ChatBot.AutoDig] +Enabled = false +Auto_Tool_Switch = false # Automatically switch to the appropriate tool. +Durability_Limit = 2 # Will not use tools with less durability than this. Set to zero to disable this feature. +Drop_Low_Durability_Tools = false # Whether to drop the current tool when its durability is too low. +Mode = "lookat" # "lookat", "fixedpos" or "both". Digging the block being looked at, the block in a fixed position, or the block that needs to be all met. +# The position of the blocks when using "fixedpos" or "both" mode. +Locations = [ + { x = 123.5, y = 64.0, z = 234.5 }, + { x = 124.5, y = 63.0, z = 235.5 }, +] +Location_Order = "distance" # "distance" or "index", When using the "fixedpos" mode, the blocks are determined by distance to the player, or by the order in the list. +Auto_Start_Delay = 3.0 # How many seconds to wait after entering the game to start digging automatically, set to -1 to disable automatic start. +Dig_Timeout = 60.0 # Mining a block for more than "Dig_Timeout" seconds will be considered a timeout. +Log_Block_Dig = true # Whether to output logs when digging blocks. +List_Type = "whitelist" # Wether to treat the blocks list as a "whitelist" or as a "blacklist". +Blocks = [ "Cobblestone", "Stone", ] + +# Automatically drop items in inventory +# You need to enable Inventory Handling to use this bot +# See this file for an up-to-date list of item types you can use with this bot: https://mccteam.github.io/r/item/#L12 +[ChatBot.AutoDrop] +Enabled = false +Mode = "include" # "include", "exclude" or "everything". Include: drop item IN the list. Exclude: drop item NOT IN the list +Items = [ "Cobblestone", "Dirt", ] + +# Automatically eat food when your Hunger value is low +# You need to enable Inventory Handling to use this bot +[ChatBot.AutoEat] +Enabled = false +Threshold = 6 + +# Automatically catch fish using a fishing rod +# Guide: https://mccteam.github.io/g/bots/#auto-fishing +# You can use "/fish" to control the bot manually. +# /!\ Make sure server rules allow automated farming before using this bot +[ChatBot.AutoFishing] +Enabled = true +Antidespawn = false # Keep it as false if you have not changed it before. +Mainhand = true # Use the mainhand or the offhand to hold the rod. +Auto_Start = true # Whether to start fishing automatically after entering a world. +Cast_Delay = 0.4 # How soon to re-cast after successful fishing. +Fishing_Delay = 3.0 # How long after entering the game to start fishing (seconds). +Fishing_Timeout = 300.0 # Fishing timeout (seconds). Timeout will trigger a re-cast. +Durability_Limit = 2.0 # Will not use rods with less durability than this (full durability is 64). Set to zero to disable this feature. +Auto_Rod_Switch = true # Switch to a new rod from inventory after the current rod is unavailable. +Stationary_Threshold = 0.001 # Hook movement in the X and Z axis less than this value will be considered stationary. +Hook_Threshold = 0.2 # A "stationary" hook that moves above this threshold in the Y-axis will be considered to have caught a fish. +Enable_Velocity_Detection = true # Enable fish bite detection using fishing bobber velocity packets. +Velocity_Hook_Threshold = -0.2 # Velocity Y threshold (blocks/tick). Values below this are treated as a bite. Keep this value negative. +Enable_Sound_Detection = true # Enable fish bite detection using splash sounds near the fishing bobber. +Sound_Distance = 5.0 # Maximum distance (blocks) between splash sound and bobber to treat it as a bite. +Detection_Warmup = 1.0 # Delay (seconds) after bobber spawn before bite detection starts. Helps ignore cast-entry splash/motion. +Log_Fish_Bobber = false # Used to adjust the above two thresholds, which when enabled will print the change in the position of the fishhook entity upon receipt of its movement packet. +Enable_Move = false # This allows the player to change position/facing after each fish caught. +# It will move in order "1->2->3->4->3->2->1->2->..." and can change position or facing or both each time. It is recommended to change the facing only. + +[[ChatBot.AutoFishing.Movements]] +facing = { yaw = 12.34, pitch = -23.45 } + +[[ChatBot.AutoFishing.Movements]] +XYZ = { x = 123.45, y = 64.0, z = -654.32 } +facing = { yaw = -25.14, pitch = 36.25 } + +[[ChatBot.AutoFishing.Movements]] +XYZ = { x = -1245.63, y = 63.5, z = 1.2 } + + +# Automatically relog when disconnected by server, for example because the server is restating +# /!\ Use Ignore_Kick_Message=true at own risk! Server staff might not appreciate if you auto-relog on manual kicks +[ChatBot.AutoRelog] +Enabled = true +Delay = { min = 3.0, max = 3.0 } # The delay time before joining the server. (in seconds) +Retries = 2147483647 # Retries when failing to relog to the server. use -1 for unlimited retries. +Ignore_Kick_Message = true # When set to true, autorelog will reconnect regardless of kick messages. +# If the kickout message matches any of the strings, then autorelog will be triggered. +Kick_Messages = [ "connection has been lost", "server is restarting", "server is full", "too many people", ] + +# Run commands or send messages automatically when a specified pattern is detected in chat +# Server admins can spoof chat messages (/nick, /tellraw) so keep this in mind when implementing AutoRespond rules +# /!\ This bot may get spammy depending on your rules, although the global messagecooldown setting can help you avoiding accidental spam +[ChatBot.AutoRespond] +Enabled = false +Matches_File = "matches.ini" +Match_Colors = false # Do not remove colors from text (Note: Your matches will have to include color codes (ones using the § character) in order to work) + +# Logs chat messages in a file on disk. +[ChatBot.ChatLog] +Enabled = false +Add_DateTime = true +Log_File = "chatlog-%username%-%serverip%.txt" +Filter = "messages" + +# This bot allows you to send and recieve messages and commands via a Discord channel. +# For Setup you can either use the documentation or read here (Documentation has images). +# Documentation: https://mccteam.github.io/g/bots/#discord-bridge +# Setup: +# First you need to create a Bot on the Discord Developers Portal, here is a video tutorial: https://www.youtube.com/watch?v=2FgMnZViNPA . +# /!\ IMPORTANT /!\: When creating a bot, you MUST ENABLE "Message Content Intent", "Server Members Intent" and "Presence Intent" in order for bot to work! Also follow along carefully do not miss any steps! +# When making a bot, copy the generated token and paste it here in "Token" field (tokens are important, keep them safe). +# Copy the "Application ID" and go to: https://discordapi.com/permissions.html . +# Paste the id you have copied and check the "Administrator" field in permissions, then click on the link at the bottom. +# This will open an invitation menu with your servers, choose the server you want to invite the bot on and invite him. +# Once you've invited the bot, go to your Discord client and go to Settings -> Advanced and Enable "Developer Mode". +# Exit the settings and right click on a server you have invited the bot to in the server list, then click "Copy ID", and paste the id here in "GuildId". +# Then right click on a channel where you want to interact with the bot and again right click -> "Copy ID", pase the copied id here in "ChannelId". +# And for the end, send a message in the channel, right click on your nick and again right click -> "Copy ID", then paste the id here in "OwnersIds". +# How to use: +# To execute an MCC command, prefix it with a dot ".", example: ".move 143 64 735" . +# To send a message, simply type it out and hit enter. +[ChatBot.DiscordBridge] +Enabled = false +Token = "your bot token here" # Your Discord Bot token. +GuildId = 1018553894831403028 # The ID of a server/guild where you have invited the bot to. +ChannelId = 1018565295654326364 # The ID of a channel where you want to interact with the MCC using the bot. +OwnersIds = [ 978757810781323276, ] # A list of IDs of people you want to be able to interact with the MCC using the bot. +Message_Send_Timeout = 3 # How long to wait (in seconds) if a message can not be sent to discord before canceling the task (minimum 1 second). +Allow_Other_Bot_Messages = false # When enabled, messages from other Discord bots in the channel will be relayed to Minecraft chat. The bridge always ignores its own messages to prevent loops. +Relay_All_Messages = false # When enabled, all text received from the Minecraft server (including system messages, join/leave notifications, etc.) will be relayed to Discord, not just player chat and private messages. +Message_Aggregation_Interval = 3.0 # Interval in seconds to aggregate messages before sending them to Discord. When set to 0, messages are sent immediately one by one. When set to a value like 1.0, messages received within that interval are batched into a single Discord message. Useful for reducing Discord API rate limits. +# Message formats +# Words wrapped with { and } are going to be replaced during the code execution, do not change them! +# For example. {message} is going to be replace with an actual message, {username} will be replaced with an username, {timestamp} with the current time. +# For Discord message formatting, check the following: https://mccteam.github.io/r/dc-fmt.html +PrivateMessageFormat = "**[Private Message]** {username}: {message}" +PublicMessageFormat = "{username}: {message}" +TeleportRequestMessageFormat = "A new Teleport Request from **{username}**!" + +# Automatically farms crops for you (plants, breaks and bonemeals them). +# Crop types available: Beetroot, Carrot, Melon, Netherwart, Pumpkin, Potato, Wheat. +# Usage: "/farmer start" command and "/farmer stop" command. +# NOTE: This a newly added bot, it is not perfect and was only tested in 1.19.2, there are some minor issues like not being able to bonemeal carrots/potatoes sometimes. +# or bot jumps onto the farm land and breaks it (this happens rarely but still happens). We are looking forward at improving this. +# It is recommended to keep the farming area walled off and flat to avoid the bot jumping. +# Also, if you have your farmland that is one block high, make it 2 or more blocks high so the bot does not fall through, as it can happen sometimes when the bot reconnects. +# The bot also does not pickup all items if they fly off to the side, we have a plan to implement this option in the future as well as drop off and bonemeal refill chest(s). +[ChatBot.Farmer] +Enabled = false +Delay_Between_Tasks = 1.0 # Delay between tasks in seconds (Minimum 1 second) + +# Enabled you to make the bot follow you +# NOTE: This is an experimental feature, the bot can be slow at times, you need to walk with a normal speed and to sometimes stop for it to be able to keep up with you +# It's similar to making animals follow you when you're holding food in your hand. +# This is due to a slow pathfinding algorithm, we're working on getting a better one +# You can tweak the update limit and find what works best for you. (NOTE: Do not but a very low one, because you might achieve the opposite, +# this might clog the thread for terain handling) and thus slow the bot even more. +# /!\ Make sure server rules allow an option like this in the rules of the server before using this bot +[ChatBot.FollowPlayer] +Enabled = false +Update_Limit = 1.5 # The rate at which the bot does calculations (in seconds) (You can tweak this if you feel the bot is too slow) +Stop_At_Distance = 3.0 # Do not follow the player if he is in the range of 3 blocks (prevents the bot from pushing a player in an infinite loop) + +# A small game to demonstrate chat interactions. Players can guess mystery words one letter at a time. +# You need to have ChatFormat working correctly and add yourself in botowners to start the game with /tell start +# /!\ This bot may get a bit spammy if many players are interacting with it +[ChatBot.HangmanGame] +Enabled = false +English = true +FileWords_EN = "hangman-en.txt" +FileWords_FR = "hangman-fr.txt" + +# Relay messages between players and servers, like a mail plugin +# This bot can store messages when the recipients are offline, and send them when they join the server +# /!\ Server admins can spoof PMs (/tellraw, /nick) so enable this bot only if you trust server admins +[ChatBot.Mailer] +Enabled = false +DatabaseFile = "MailerDatabase.ini" +IgnoreListFile = "MailerIgnoreList.ini" +PublicInteractions = false +MaxMailsPerPlayer = 10 +MaxDatabaseSize = 10000 +MailRetentionDays = 30 + +# Allows you to render maps in the console and into images (which can be then sent to Discord using Discord Bridge Chat Bot) +# This is useful for solving captchas which use maps +# The maps are rendered into Rendered_Maps folder if the Save_To_File is enabled. +# NOTE: +# If some servers have a very short time for solving captchas, enabe Auto_Render_On_Update to see them immediatelly in the console. +# /!\ Make sure server rules allow bots to be used on the server, or you risk being punished. +[ChatBot.Map] +Enabled = true +Render_In_Console = true # Whether to render the map in the console. +Save_To_File = false # Whether to store the rendered map as a file (You need this setting if you want to get a map on Discord using Discord Bridge). +Auto_Render_On_Update = false # Automatically render the map once it is received or updated from/by the server +Delete_All_On_Unload = true # Delete all rendered maps on unload/reload or when you launch the MCC again. +Notify_On_First_Update = true # Get a notification when you have gotten a map from the server for the first time +Rasize_Rendered_Image = false # Resize an rendered image, this is useful when images that are rendered are small and when are being sent to Discord. +Resize_To = 512 # The size that a rendered image should be resized to, in pixels (eg. 512). +# Send a rendered map (saved to a file) to a Discord or a Telegram channel via the Discord or Telegram Bride chat bot (The Discord/Telegram Bridge chat bot must be enabled and configured!) +# You need to enable Save_To_File in order for this to work. +# We also recommend turning on resizing. +Send_Rendered_To_Discord = false +Send_Rendered_To_Telegram = false + +# Log the list of players periodically into a textual file. +[ChatBot.PlayerListLogger] +Enabled = false +File = "playerlog.txt" +Delay = 60.0 # (In seconds) + +# Send MCC console commands to your bot through server PMs (/tell) +# You need to have ChatFormat working correctly and add yourself in botowners to use the bot +# /!\ Server admins can spoof PMs (/tellraw, /nick) so enable RemoteControl only if you trust server admins +[ChatBot.RemoteControl] +Enabled = false +AutoTpaccept = true +AutoTpaccept_Everyone = false + +# Enable recording of the game (/replay start) and replay it later using the Replay Mod (https://www.replaymod.com/) +# Please note that due to technical limitations, the client player (you) will not be shown in the replay file +# /!\ You SHOULD use /replay stop or exit the program gracefully with /quit OR THE REPLAY FILE MAY GET CORRUPT! +[ChatBot.ReplayCapture] +Enabled = false +Backup_Interval = 300.0 # How long should replay file be auto-saved, in seconds. Use -1 to disable. + +# Schedule commands and scripts to launch on various events such as server join, date/time or time interval +# See https://mccteam.github.io/g/bots/#script-scheduler for more info +[ChatBot.ScriptScheduler] +Enabled = false + +[[ChatBot.ScriptScheduler.TaskList]] +Task_Name = "Task Name 1" +Trigger_On_First_Login = false +Trigger_On_Login = false +Trigger_On_Times = { Enable = true, Times = [ 14:00:00, ] } +Trigger_On_Interval = { Enable = true, MinTime = 3.6, MaxTime = 4.8 } +Action = "send /hello" + +[[ChatBot.ScriptScheduler.TaskList]] +Task_Name = "Task Name 2" +Trigger_On_First_Login = false +Trigger_On_Login = true +Trigger_On_Times = { Enable = false, Times = [ ] } +Trigger_On_Interval = { Enable = false, MinTime = 1.0, MaxTime = 10.0 } +Action = "send /login pass" + + +# This bot allows you to send and receive messages and commands via a Telegram Bot DM or to receive messages in a Telegram channel. +# /!\ NOTE: You can't send messages and commands from a group channel, you can only send them in the bot DM, but you can get the messages from the client in a group channel. +# ----------------------------------------------------------- +# Setup: +# First you need to create a Telegram bot and obtain an API key, to do so, go to Telegram and find @botfather +# Click on "Start" button and read the bot reply, then type "/newbot", the Botfather will guide you through the bot creation. +# Once you create the bot, copy the API key that you have gotten, and put it into the "Token" field of "ChatBot.TelegramBridge" section (this section). +# /!\ Do not share this token with anyone else as it will give them the control over your bot. Save it securely. +# Then launch the client and go to Telegram, find your newly created bot by searching for it with its username, and open a DM with it. +# Click on "Start" button and type and send the following command ".chatid" to obtain the chat id. +# Copy the chat id number (eg. 2627844670) and paste it in the "ChannelId" field and add it to the "Authorized_Chat_Ids" field (in this section) (an id in "Authorized_Chat_Ids" field is a number/long, not a string!), then save the file. +# Now you can use the bot using it's DM. +# /!\ If you do not add the id of your chat DM with the bot to the "Authorized_Chat_Ids" field, ayone who finds your bot via search will be able to execute commands and send messages! +# /!\ An id pasted in to the "Authorized_Chat_Ids" should be a number/long, not a string! +# ----------------------------------------------------------- +# NOTE: If you want to recieve messages to a group channel instead, make the channel temporarely public, invite the bot to it and make it an administrator, then set the channel to private if you want. +# Then set the "ChannelId" field to the @ of your channel (you must include the @ in the settings, eg. "@mysupersecretchannel"), this is the username you can see in the invite link of the channel. +# /!\ Only include the username with @ prefix, do not include the rest of the link. Example if you have "https://t.me/mysupersecretchannel", the "ChannelId" will be "@mysupersecretchannel". +# /!\ Note that you will not be able to send messages to the client from a group channel! +# ----------------------------------------------------------- +# How to use the bot: +# To execute an MCC command, prefix it with a dot ".", example: ".move 143 64 735" . +# To send a message, simply type it out and hit enter. +[ChatBot.TelegramBridge] +Enabled = false +Token = "your bot token here" # Your Telegram Bot token. +ChannelId = "" # An ID of a channel where you want to interact with the MCC using the bot. +Authorized_Chat_Ids = [ ] # A list of Chat IDs that are allowed to send messages and execute commands. To get an id of your chat DM with the bot use ".chatid" bot command in Telegram. +Message_Send_Timeout = 3 # How long to wait (in seconds) if a message can not be sent to Telegram before canceling the task (minimum 1 second). +# Message formats +# Words wrapped with { and } are going to be replaced during the code execution, do not change them! +# For example. {message} is going to be replace with an actual message, {username} will be replaced with an username, {timestamp} with the current time. +# For Telegram message formatting, check the following: https://mccteam.github.io/r/tg-fmt.html +PrivateMessageFormat = "*(Private Message)* {username}: {message}" +PublicMessageFormat = "{username}: {message}" +TeleportRequestMessageFormat = "A new Teleport Request from **{username}**!" + +# A Chat Bot that collects items on the ground +[ChatBot.ItemsCollector] +Enabled = false +Collect_All_Item_Types = true # If set to true, the bot will collect all items, regardless of their type. If you want to use the whitelisted item types, disable this by setting it to false +Items_Whitelist = [ "Diamond", "NetheriteIngot", ] # In this list you can specify which items the bot will collect. To enable this, set the Collect_All_Item_Types to false. (NOTE: This does not prevent the bot from accidentally picking up other items, it only goes to positions where it finds the whitelisted items)\nYou can see the list of item types here: https://raw.githubusercontent.com/MCCTeam/Minecraft-Console-Client/master/MinecraftClient/Inventory/ItemType.cs +Delay_Between_Tasks = 300 # Delay in milliseconds between bot scanning items (Recommended: 300-500) +Collection_Radius = 30.0 # The radius in which bot will look for items to collect (Default: 30) +Always_Return_To_Start = true # If set to true, the bot will return to it's starting position after there are no items to collect +Prioritize_Clusters = false # If set to true, the bot will go after clustered items instead for the closest ones + +# Show a Discord Rich Presence status with your current Minecraft session info. +# Setup: +# 1. Go to https://discord.com/developers/applications and log in with your Discord account. +# 2. Click "New Application", give it a name (e.g. "MCC") and confirm. +# 3. On the application page, copy the "Application ID" and paste it in the "ApplicationId" field below. +# 4. (Optional) Go to "Rich Presence" -> "Art Assets" to upload custom images for LargeImageKey/SmallImageKey. +# Note: This does NOT require a Bot Token, only an Application ID. Discord must be running on the same machine as MCC. +[ChatBot.DiscordRpc] +Enabled = false +ApplicationId = "" # Your Discord Application ID. Create one at https://discord.com/developers/applications +PresenceDetails = "Playing on {server_host}:{server_port}" # The top line of the Rich Presence display. Supports placeholders. +PresenceState = "{dimension} - HP: {health}/{max_health}" # The second line of the Rich Presence display. Supports placeholders. +LargeImageKey = "mcc_icon" # The key of the large image asset uploaded to your Discord application. +LargeImageText = "Minecraft Console Client" # Tooltip text for the large image. Supports placeholders. +SmallImageKey = "" # The key of the small image asset uploaded to your Discord application (leave empty to hide). +SmallImageText = "" # Tooltip text for the small image. Supports placeholders. +ShowServerAddress = true # Show the server address (host and port) in the Discord presence. When disabled, {server_host} and {server_port} are masked. +ShowCoordinates = true # Show the player coordinates in the Discord presence. When disabled, {x}, {y}, {z} are masked. +ShowHealth = true # Show health and food level in the Discord presence. When disabled, {health}, {max_health}, {food} are masked. +ShowDimension = true # Show the current dimension in the Discord presence. When disabled, {dimension} is masked. +ShowGamemode = true # Show the current gamemode in the Discord presence. When disabled, {gamemode} is masked. +ShowElapsedTime = true # Show elapsed session time in the Discord presence. +ShowPlayerCount = true # Show the online player count as a party size in the Discord presence. +UpdateIntervalSeconds = 10 # How often (in seconds) to refresh the Discord presence. Minimum: 1 + +# Host an embedded MCP server while connected to Minecraft. Disabled by default. +[ChatBot.McpServer] +Enabled = false # Enable the built-in embedded MCP server bot. Server starts only after game join and stops on disconnect. +# Embedded MCP HTTP transport settings. +[ChatBot.McpServer.Transport] +BindHost = "127.0.0.1" # IP/host to bind the embedded MCP HTTP listener to. Default is loopback only. +Port = 33333 # TCP port for the embedded MCP HTTP listener. +Route = "/mcp" # Route prefix where MCP endpoints are exposed. +RequireAuthToken = false # Require Bearer token authentication for MCP endpoint requests. +AuthTokenEnvVar = "MCC_MCP_AUTH_TOKEN" # Environment variable name containing the MCP auth token when auth is required. + +# Enable or disable MCP tool categories. +[ChatBot.McpServer.Capabilities] +SessionStatus = true # Allow session and status inspection tools. +ChatAndCommands = true # Allow chat and internal command tools. +Movement = true # Allow movement and view-control tools. +Inventory = true # Allow inventory read and action tools. +EntityWorld = true # Allow entity and world inspection tools. + + + + diff --git a/1.21.4 b/1.21.4 new file mode 100644 index 00000000..fca39706 --- /dev/null +++ b/1.21.4 @@ -0,0 +1,609 @@ +# Startup Config File +# Please do not record extraneous data in this file as it will be overwritten by MCC. +# +# New to Minecraft Console Client? Check out this document: https://mccteam.github.io/g/conf.html +# Want to upgrade to a newer version? See https://github.com/MCCTeam/Minecraft-Console-Client/#download +[Head] +"Current Version" = "Development Build" +"Latest Version" = "GitHub build 414, built on 2026-04-07" + +[Main] +[Main.General] +Account = { Login = "CursorBot", Password = "-" } +Server = { Host = "mc.hypixel.net", Port = 25565 } # The address of the game server, "Host" can be filled in with domain name or IP address. (The "Port" field can be deleted, it will be resolved automatically) +AccountType = "mojang" +Method = "mcc" # Microsoft Account sign-in method: "mcc" (device code, supports 2FA) OR "browser" (manual browser login). +AuthUser = "" # Yggdrasil authlib multi-user selection. +[Main.General.AuthServer] # authlib-injector authentication server to use for Yggdrasil accounts +Port = 443 # Port to connect on +AuthlibInjectorAPIPath = "/api/yggdrasil" # Path component of the authlib-injector API location. Refer to the authlib-injector documentation for more info. +UseHttps = true # Set to false if your authlib-injector server uses plain HTTP (e.g. for local testing without TLS). +Host = "" # Domain name or IP address + + +# Make sure you understand what each setting does before changing anything! +[Main.Advanced] +EnableSentry = true # Set to false to opt-out of Sentry error logging. +Language = "zh_cn" # Fill in with in-game locale code, check https://mccteam.github.io/r/l-code.html +LoadMccTranslation = true # Load translations applied to MCC when available, turn it off to use English only. +ConsoleTitle = "%username%@%serverip% - Minecraft Console Client" +InternalCmdChar = "slash" # Use "none", "slash"(/) or "backslash"(\). +MessageCooldown = 1.0 # Controls the minimum interval (in seconds) between sending each message to the server. +MaxChatMessageLength = 0 # Override the maximum chat message length. Set to 0 to use the default (100 for 1.10 and below, 256 for 1.11+). WARNING: Setting this incorrectly may cause you to be kicked from the server. +BotOwners = [ "player1", "player2", ] # Set the owner of the bot. /!\ Server admins can impersonate owners! +MinecraftVersion = "CursorBot" # Use "auto" or "1.X.X" values. Allows to skip server info retrieval. +EnableForge = "no" # Use "auto", "no" or "force". Force-enabling only works for MC 1.13+. +BrandInfo = "mcc" # Use "mcc", "vanilla" or "none". This is how MCC identifies itself to the server. +ChatbotLogFile = "" # Leave empty for no logfile. +PrivateMsgsCmdName = "tell" # For remote control of the bot. +ShowSystemMessages = true # System messages for server ops. +ShowXPBarMessages = true # Messages displayed above xp bar, set this to false in case of xp bar spam. +ShowChatLinks = true # Decode links embedded in chat messages and show them in console. +ShowInventoryLayout = true # Show inventory layout as ASCII art in inventory command. +ShowEffectNamesInTUI = false # Show full effect names and levels in the TUI status bar instead of compact effect icons only. +ShowGithubStarReminder = true # Show a GitHub star reminder on startup. Set to false to hide it. +TerrainAndMovements = true # Uses more ram, cpu, bandwidth but allows you to move around. +MoveHeadWhileWalking = true # Enable head movement while walking to avoid anti-cheat triggers. +MovementSpeed = 2 # A movement speed higher than 2 may be considered cheating. +TemporaryFixBadpacket = false # Temporary fix for Badpacket issue on some servers. Need to enable "TerrainAndMovements" first. +InventoryHandling = true # Toggle inventory handling. +EntityHandling = true # Toggle entity handling. +SessionCache = "disk" # How to retain session tokens. Use "none", "memory" or "disk". +ProfileKeyCache = "disk" # How to retain profile key. Use "none", "memory" or "disk". +ResolveSrvRecords = "fast" # Use "no", "fast" (5s timeout), or "yes". Required for joining some servers. +PlayerHeadAsIcon = true # Only works on Windows XP-8 or Windows 10 with old console. +ExitOnFailure = false # Whether to exit directly when an error occurs, for using MCC in non-interactive scripts. +CacheScript = true # Cache compiled scripts for faster load on low-end devices. +Timestamps = false # Prepend timestamps to chat messages. +AutoRespawn = true # Toggle auto respawn if client player was dead (make sure your spawn point is safe). +MinecraftRealms = false # Enable support for joining Minecraft Realms worlds. +TcpTimeout = 30 # Customize the TCP connection timeout with the server. (in seconds) +EnableEmoji = true # If turned off, the emoji will be replaced with a simpler character (for /chunk status). +MinTerminalWidth = 16 # The minimum width used when calculating the image size from the width of the terminal. +MinTerminalHeight = 10 # The minimum height to use when calculating the image size from the height of the terminal. +IgnoreInvalidPlayerName = true # Ignore invalid player name +# AccountList: It allows a fast account switching without directly using the credentials +# Usage examples: "/tell reco Player2", "/connect Player1" +[Main.Advanced.AccountList] +AccountNikename1 = { Login = "playerone@email.com", Password = "thepassword" } +AccountNikename2 = { Login = "TestBot", Password = "-" } + +# ServerList: It allows an easier and faster server switching with short aliases instead of full server IP +# Aliases cannot contain dots or spaces, and the name "localhost" cannot be used as an alias. +# Usage examples: "/tell connect Server1", "/connect Server2" +[Main.Advanced.ServerList] +ServerAlias1 = { Host = "mc.awesomeserver.com" } +ServerAlias2 = { Host = "192.168.1.27", Port = 12345 } + + + +# Chat signature related settings (affects minecraft 1.19+) +[Signature] +LoginWithSecureProfile = true # Microsoft accounts only. If disabled, will not be able to sign chat and join servers configured with "enforce-secure-profile=true" +SignChat = true # Whether to sign the chat send from MCC +SignMessageInCommand = true # Whether to sign the messages contained in the commands sent by MCC. For example, the message in "/msg" and "/me" +MarkLegallySignedMsg = true # Use green  color block to mark chat with legitimate signatures +MarkModifiedMsg = true # Use yellow color block to mark chat that have been modified by the server. +MarkIllegallySignedMsg = true # Use red    color block to mark chat without legitimate signature +MarkSystemMessage = true # Use gray   color block to mark system message (always without signature) +ShowModifiedChat = true # Set to true to display messages modified by the server, false to display the original signed messages +ShowIllegalSignedChat = true # Whether to display chat and messages in commands without legal signatures + +# This setting affects only the messages in the console. +[Logging] +DebugMessages = true # Please enable this before submitting bug reports. Thanks! +ChatMessages = true # Show server chat messages. +InfoMessages = true # Informative messages. (i.e Most of the message from MCC) +WarningMessages = true # Show warning messages. +ErrorMessages = true # Show error messages. +ChatFilterRegex = ".*" # Regex for filtering chat message. +DebugFilterRegex = ".*" # Regex for filtering debug message. +FilterMode = "disable" # "disable" or "blacklist" OR "whitelist". Blacklist hide message match regex. Whitelist show message match regex. +LogToFile = false # Write log messages to file. +LogFile = "console-log.txt" # Log file name. +PrependTimestamp = false # Prepend timestamp to messages in log file. +SaveColorCodes = false # Keep color codes in the saved text.(look like "§b") + +[Console] +[Console.General] +ConsoleMode = "classic" # Console mode: "classic" for the standard terminal, "tui" for a pseudo-graphical full-screen interface. +ConsoleColorMode = "vt100_4bit" # Use "disable", "legacy_4bit", "vt100_4bit", "vt100_8bit" or "vt100_24bit". If a garbled code like "←[0m" appears on the terminal, you can try switching to "legacy_4bit" mode, or just disable it. +Display_Icon_Banner = true # Whether to display the MCC startup icon banner. +Display_Input = true # You can use "Ctrl+P" to print out the current input and cursor position. +History_Input_Records = 32 # Maximum number of input history records to keep. +TUI_Log_Scrollback = 0 # Maximum log lines kept in TUI mode scrollback. Set to 0 for automatic. + +# The settings for command completion suggestions. +# Custom colors are only available when using "vt100_24bit" color mode. +[Console.CommandSuggestion] +Enable = true # Whether to display command suggestions in the console. +Enable_Color = true +Use_Basic_Arrow = false # Enable this option if the arrows in the command suggestions are not displayed properly in your terminal. +Max_Suggestion_Width = 30 +Max_Displayed_Suggestions = 10 +Text_Color = "#f8fafc" +Text_Background_Color = "#64748b" +Highlight_Text_Color = "#334155" +Highlight_Text_Background_Color = "#fde047" +Tooltip_Color = "#7dd3fc" +Highlight_Tooltip_Color = "#3b82f6" +Arrow_Symbol_Color = "#d1d5db" + +# Settings for the TUI minimap overlay that shows terrain and entities. +[Console.Minimap] +Enabled = true # Whether the minimap is visible on startup in TUI mode. +Zoom = 2 # Blocks per pixel, 1-16. 1 = closest (1:1), 16 = farthest (16 blocks per pixel). +Width = 40 # Map width in pixels (characters). Range 10-120, default 40. +Height = 40 # Map height in pixels (must be even, uses half-block chars). Range 4-80, default 40. +Position = "top_right" # Minimap position: "top_left", "top_right", "center", "bottom_left", or "bottom_right". +ShowPlayerNames = false # Show player names on the minimap. +ShowHostileNames = false # Show hostile mob names on the minimap. +ShowNeutralNames = false # Show neutral mob names on the minimap. +ShowPassiveNames = false # Show passive mob names on the minimap. +RefreshInterval = 1000 # Minimap refresh interval in milliseconds (100-5000). +CaveMode = "auto" # Cave rendering mode: "auto" (detect ceiling), "on" (always cave view), "off" (always surface view). + +# Settings for the /tab command and live TUI tab overlay. +[Console.TabList] +ShowTeams = false # Show a separate team column in /tab output. Disabled by default for a more vanilla-like player list. + + +[AppVar] +# can be used in some other fields as %yourvar% +# %username%, %login%, %serverip%, %serverport%, %datetime% and %players% are reserved read-only variables. +[AppVar.VarStirng] +your_var = "your_value" +"your var 2" = "your value 2" + + +# Connect to a server via a proxy instead of connecting directly +# If Mojang session services are blocked on your network, set Enabled_Login=true to login using proxy. +# If the connection to the Minecraft game server is blocked by the firewall, set Enabled_Ingame=true to use a proxy to connect to the game server. +# /!\ Make sure your server rules allow Proxies or VPNs before setting enabled=true, or you may face consequences! +[Proxy] +Enabled_Update = false # Whether to download MCC updates via proxy. +Enabled_Login = false # Whether to connect to the login server through a proxy. +Enabled_Ingame = false # Whether to connect to the game server through a proxy. +Server = { Host = "0.0.0.0", Port = 8080 } # Proxy server must allow HTTPS for login, and non-443 ports for playing. +Proxy_Type = "HTTP" # Supported types: "HTTP", "SOCKS4", "SOCKS4a", "SOCKS5". +Username = "" # Only required for password-protected proxies. +Password = "" # Only required for password-protected proxies. + +# Settings below are sent to the server and only affect server-side things like your skin. +[MCSettings] +Enabled = true # If disabled, settings below are not sent to the server. +Locale = "zh_CN" # Use any language implemented in Minecraft. +RenderDistance = 8 # Value range: [0 - 255]. +Difficulty = "peaceful" # MC 1.7- difficulty. "peaceful", "easy", "normal", "difficult". +ChatMode = "enabled" # Use "enabled", "commands", or "disabled". Allows to mute yourself... +ChatColors = true # Allows disabling chat colors server-side. +MainHand = "left" # MC 1.9+ main hand. "left" or "right". +[MCSettings.Skin] +Cape = true +Hat = true +Jacket = false +Sleeve_Left = false +Sleeve_Right = false +Pants_Left = false +Pants_Right = false + + +# MCC does it best to detect chat messages, but some server have unusual chat formats +# When this happens, you'll need to configure chat format below, see https://mccteam.github.io/g/conf/#chat-format-section +[ChatFormat] +Builtins = true # MCC support for common message formats. Set "false" to avoid conflicts with custom formats. +UserDefined = false # Whether to use the custom regular expressions below for detection. +Public = "^<([a-zA-Z0-9_]+)> (.+)$" +Private = "^([a-zA-Z0-9_]+) whispers to you: (.+)$" +TeleportRequest = '^([a-zA-Z0-9_]+) has requested (?:to|that you) teleport to (?:you|them)\.$' + +# =============================== # +# Minecraft Console Client Bots # +# =============================== # +[ChatBot] +# Get alerted when specified words are detected in chat +# Useful for moderating your server or detecting when someone is talking to you +[ChatBot.Alerts] +Enabled = false +Beep_Enabled = true # Play a beep sound when a word is detected in addition to highlighting. +Trigger_By_Words = false # Triggers an alert after receiving a specified keyword. +Trigger_By_Rain = false # Trigger alerts when it rains and when it stops. +Trigger_By_Thunderstorm = false # Triggers alerts at the beginning and end of thunderstorms. +Log_To_File = false # Log alerts info a file. +Log_File = "alerts-log.txt" # The name of a file where alers logs will be written. +# List of words/strings to alert you on. +Matches = [ "Yourname", " whispers ", "-> me", "admin", ".com", ] +# List of words/strings to NOT alert you on. +Excludes = [ "myserver.com", "Yourname>:", "Player Yourname", "Yourname joined", "Yourname left", "[Lockette] (Admin)", " Yourname:", "Yourname is", ] + +# Send a command on a regular or random basis or make the bot walk around randomly to avoid automatic AFK disconnection +# /!\ Make sure your server rules do not forbid anti-AFK mechanisms! +# /!\ Make sure you keep the bot in an enclosure to prevent it wandering off if you're using terrain handling! (Recommended size 5x5x5) +[ChatBot.AntiAFK] +Enabled = false +Delay = { min = 60.0, max = 60.0 } # The time interval for execution. (in seconds) +Command = "/ping" # Command to send to the server. +Use_Sneak = false # Whether to sneak when sending the command. +Use_Terrain_Handling = false # Use terrain handling to enable the bot to move around. +Walk_Range = 5 # The range the bot can move around randomly (Note: the bigger the range, the slower the bot will be) +Walk_Retries = 20 # How many times can the bot fail trying to move before using the command method. + +# Automatically attack hostile mobs around you +# You need to enable Entity Handling to use this bot +# /!\ Make sure server rules allow your planned use of AutoAttack +# /!\ SERVER PLUGINS may consider AutoAttack to be a CHEAT MOD and TAKE ACTION AGAINST YOUR ACCOUNT so DOUBLE CHECK WITH SERVER RULES! +[ChatBot.AutoAttack] +Enabled = false +Mode = "single" # "single" or "multi". single target one mob per attack. multi target all mobs in range per attack +Priority = "distance" # "health" or "distance". Only needed when using single mode +Cooldown_Time = { Custom = false, value = 1.0 } # How long to wait between each attack. Set "Custom = false" to let MCC calculate it. +Interaction = "Attack" # Possible values: "Interact", "Attack" (default), "InteractAt" (Interact and Attack). +Attack_Range = 4.0 # Capped between 1 to 4 +Attack_Hostile = true # Allow attacking hostile mobs. +Attack_Passive = false # Allow attacking passive mobs. +List_Mode = "whitelist" # Wether to treat the entities list as a "whitelist" or as a "blacklist". +Entites_List = [ "Zombie", "Cow", ] # All entity types can be found here: https://mccteam.github.io/r/entity/#L15 + +# Automatically craft items in your inventory +# See https://mccteam.github.io/g/bots/#auto-craft for how to use +# You need to enable Inventory Handling to use this bot +# You should also enable Terrain and Movements if you need to use a crafting table +[ChatBot.AutoCraft] +Enabled = false +CraftingTable = { X = 123.0, Y = 65.0, Z = 456.0 } # Location of the crafting table if you intended to use it. Terrain and movements must be enabled. +OnFailure = "abort" # What to do on crafting failure, "abort" or "wait". +# Recipes.Name: The name can be whatever you like and it is used to represent the recipe. +# Recipes.Type: crafting table type: "player" or "table" +# Recipes.Result: the resulting item +# Recipes.Slots: All slots, counting from left to right, top to bottom. Please fill in "Null" for empty slots. +# For the naming of the items, please see: https://mccteam.github.io/r/item/#L12 + +[[ChatBot.AutoCraft.Recipes]] +Name = "Recipe-Name-1" +Type = "player" +Result = "StoneBricks" +Slots = [ "Stone", "Stone", "Stone", "Stone", ] + +[[ChatBot.AutoCraft.Recipes]] +Name = "Recipe-Name-2" +Type = "table" +Result = "StoneBricks" +Slots = [ "Stone", "Stone", "Null", "Stone", "Stone", "Null", "Null", "Null", "Null", ] + + +# Auto-digging blocks. +# You need to enable Terrain Handling to use this bot +# You can use "/digbot start" and "/digbot stop" to control the start and stop of AutoDig. +# Since MCC does not yet support accurate calculation of the collision volume of blocks, all blocks are considered as complete cubes when obtaining the position of the lookahead. +# For the naming of the block, please see https://mccteam.github.io/r/block/#L15 +[ChatBot.AutoDig] +Enabled = false +Auto_Tool_Switch = false # Automatically switch to the appropriate tool. +Durability_Limit = 2 # Will not use tools with less durability than this. Set to zero to disable this feature. +Drop_Low_Durability_Tools = false # Whether to drop the current tool when its durability is too low. +Mode = "lookat" # "lookat", "fixedpos" or "both". Digging the block being looked at, the block in a fixed position, or the block that needs to be all met. +# The position of the blocks when using "fixedpos" or "both" mode. +Locations = [ + { x = 123.5, y = 64.0, z = 234.5 }, + { x = 124.5, y = 63.0, z = 235.5 }, +] +Location_Order = "distance" # "distance" or "index", When using the "fixedpos" mode, the blocks are determined by distance to the player, or by the order in the list. +Auto_Start_Delay = 3.0 # How many seconds to wait after entering the game to start digging automatically, set to -1 to disable automatic start. +Dig_Timeout = 60.0 # Mining a block for more than "Dig_Timeout" seconds will be considered a timeout. +Log_Block_Dig = true # Whether to output logs when digging blocks. +List_Type = "whitelist" # Wether to treat the blocks list as a "whitelist" or as a "blacklist". +Blocks = [ "Cobblestone", "Stone", ] + +# Automatically drop items in inventory +# You need to enable Inventory Handling to use this bot +# See this file for an up-to-date list of item types you can use with this bot: https://mccteam.github.io/r/item/#L12 +[ChatBot.AutoDrop] +Enabled = false +Mode = "include" # "include", "exclude" or "everything". Include: drop item IN the list. Exclude: drop item NOT IN the list +Items = [ "Cobblestone", "Dirt", ] + +# Automatically eat food when your Hunger value is low +# You need to enable Inventory Handling to use this bot +[ChatBot.AutoEat] +Enabled = false +Threshold = 6 + +# Automatically catch fish using a fishing rod +# Guide: https://mccteam.github.io/g/bots/#auto-fishing +# You can use "/fish" to control the bot manually. +# /!\ Make sure server rules allow automated farming before using this bot +[ChatBot.AutoFishing] +Enabled = true +Antidespawn = false # Keep it as false if you have not changed it before. +Mainhand = true # Use the mainhand or the offhand to hold the rod. +Auto_Start = true # Whether to start fishing automatically after entering a world. +Cast_Delay = 0.4 # How soon to re-cast after successful fishing. +Fishing_Delay = 3.0 # How long after entering the game to start fishing (seconds). +Fishing_Timeout = 300.0 # Fishing timeout (seconds). Timeout will trigger a re-cast. +Durability_Limit = 2.0 # Will not use rods with less durability than this (full durability is 64). Set to zero to disable this feature. +Auto_Rod_Switch = true # Switch to a new rod from inventory after the current rod is unavailable. +Stationary_Threshold = 0.001 # Hook movement in the X and Z axis less than this value will be considered stationary. +Hook_Threshold = 0.2 # A "stationary" hook that moves above this threshold in the Y-axis will be considered to have caught a fish. +Enable_Velocity_Detection = true # Enable fish bite detection using fishing bobber velocity packets. +Velocity_Hook_Threshold = -0.2 # Velocity Y threshold (blocks/tick). Values below this are treated as a bite. Keep this value negative. +Enable_Sound_Detection = true # Enable fish bite detection using splash sounds near the fishing bobber. +Sound_Distance = 5.0 # Maximum distance (blocks) between splash sound and bobber to treat it as a bite. +Detection_Warmup = 1.0 # Delay (seconds) after bobber spawn before bite detection starts. Helps ignore cast-entry splash/motion. +Log_Fish_Bobber = false # Used to adjust the above two thresholds, which when enabled will print the change in the position of the fishhook entity upon receipt of its movement packet. +Enable_Move = false # This allows the player to change position/facing after each fish caught. +# It will move in order "1->2->3->4->3->2->1->2->..." and can change position or facing or both each time. It is recommended to change the facing only. + +[[ChatBot.AutoFishing.Movements]] +facing = { yaw = 12.34, pitch = -23.45 } + +[[ChatBot.AutoFishing.Movements]] +XYZ = { x = 123.45, y = 64.0, z = -654.32 } +facing = { yaw = -25.14, pitch = 36.25 } + +[[ChatBot.AutoFishing.Movements]] +XYZ = { x = -1245.63, y = 63.5, z = 1.2 } + + +# Automatically relog when disconnected by server, for example because the server is restating +# /!\ Use Ignore_Kick_Message=true at own risk! Server staff might not appreciate if you auto-relog on manual kicks +[ChatBot.AutoRelog] +Enabled = true +Delay = { min = 3.0, max = 3.0 } # The delay time before joining the server. (in seconds) +Retries = 2147483647 # Retries when failing to relog to the server. use -1 for unlimited retries. +Ignore_Kick_Message = true # When set to true, autorelog will reconnect regardless of kick messages. +# If the kickout message matches any of the strings, then autorelog will be triggered. +Kick_Messages = [ "connection has been lost", "server is restarting", "server is full", "too many people", ] + +# Run commands or send messages automatically when a specified pattern is detected in chat +# Server admins can spoof chat messages (/nick, /tellraw) so keep this in mind when implementing AutoRespond rules +# /!\ This bot may get spammy depending on your rules, although the global messagecooldown setting can help you avoiding accidental spam +[ChatBot.AutoRespond] +Enabled = false +Matches_File = "matches.ini" +Match_Colors = false # Do not remove colors from text (Note: Your matches will have to include color codes (ones using the § character) in order to work) + +# Logs chat messages in a file on disk. +[ChatBot.ChatLog] +Enabled = false +Add_DateTime = true +Log_File = "chatlog-%username%-%serverip%.txt" +Filter = "messages" + +# This bot allows you to send and recieve messages and commands via a Discord channel. +# For Setup you can either use the documentation or read here (Documentation has images). +# Documentation: https://mccteam.github.io/g/bots/#discord-bridge +# Setup: +# First you need to create a Bot on the Discord Developers Portal, here is a video tutorial: https://www.youtube.com/watch?v=2FgMnZViNPA . +# /!\ IMPORTANT /!\: When creating a bot, you MUST ENABLE "Message Content Intent", "Server Members Intent" and "Presence Intent" in order for bot to work! Also follow along carefully do not miss any steps! +# When making a bot, copy the generated token and paste it here in "Token" field (tokens are important, keep them safe). +# Copy the "Application ID" and go to: https://discordapi.com/permissions.html . +# Paste the id you have copied and check the "Administrator" field in permissions, then click on the link at the bottom. +# This will open an invitation menu with your servers, choose the server you want to invite the bot on and invite him. +# Once you've invited the bot, go to your Discord client and go to Settings -> Advanced and Enable "Developer Mode". +# Exit the settings and right click on a server you have invited the bot to in the server list, then click "Copy ID", and paste the id here in "GuildId". +# Then right click on a channel where you want to interact with the bot and again right click -> "Copy ID", pase the copied id here in "ChannelId". +# And for the end, send a message in the channel, right click on your nick and again right click -> "Copy ID", then paste the id here in "OwnersIds". +# How to use: +# To execute an MCC command, prefix it with a dot ".", example: ".move 143 64 735" . +# To send a message, simply type it out and hit enter. +[ChatBot.DiscordBridge] +Enabled = false +Token = "your bot token here" # Your Discord Bot token. +GuildId = 1018553894831403028 # The ID of a server/guild where you have invited the bot to. +ChannelId = 1018565295654326364 # The ID of a channel where you want to interact with the MCC using the bot. +OwnersIds = [ 978757810781323276, ] # A list of IDs of people you want to be able to interact with the MCC using the bot. +Message_Send_Timeout = 3 # How long to wait (in seconds) if a message can not be sent to discord before canceling the task (minimum 1 second). +Allow_Other_Bot_Messages = false # When enabled, messages from other Discord bots in the channel will be relayed to Minecraft chat. The bridge always ignores its own messages to prevent loops. +Relay_All_Messages = false # When enabled, all text received from the Minecraft server (including system messages, join/leave notifications, etc.) will be relayed to Discord, not just player chat and private messages. +Message_Aggregation_Interval = 3.0 # Interval in seconds to aggregate messages before sending them to Discord. When set to 0, messages are sent immediately one by one. When set to a value like 1.0, messages received within that interval are batched into a single Discord message. Useful for reducing Discord API rate limits. +# Message formats +# Words wrapped with { and } are going to be replaced during the code execution, do not change them! +# For example. {message} is going to be replace with an actual message, {username} will be replaced with an username, {timestamp} with the current time. +# For Discord message formatting, check the following: https://mccteam.github.io/r/dc-fmt.html +PrivateMessageFormat = "**[Private Message]** {username}: {message}" +PublicMessageFormat = "{username}: {message}" +TeleportRequestMessageFormat = "A new Teleport Request from **{username}**!" + +# Automatically farms crops for you (plants, breaks and bonemeals them). +# Crop types available: Beetroot, Carrot, Melon, Netherwart, Pumpkin, Potato, Wheat. +# Usage: "/farmer start" command and "/farmer stop" command. +# NOTE: This a newly added bot, it is not perfect and was only tested in 1.19.2, there are some minor issues like not being able to bonemeal carrots/potatoes sometimes. +# or bot jumps onto the farm land and breaks it (this happens rarely but still happens). We are looking forward at improving this. +# It is recommended to keep the farming area walled off and flat to avoid the bot jumping. +# Also, if you have your farmland that is one block high, make it 2 or more blocks high so the bot does not fall through, as it can happen sometimes when the bot reconnects. +# The bot also does not pickup all items if they fly off to the side, we have a plan to implement this option in the future as well as drop off and bonemeal refill chest(s). +[ChatBot.Farmer] +Enabled = false +Delay_Between_Tasks = 1.0 # Delay between tasks in seconds (Minimum 1 second) + +# Enabled you to make the bot follow you +# NOTE: This is an experimental feature, the bot can be slow at times, you need to walk with a normal speed and to sometimes stop for it to be able to keep up with you +# It's similar to making animals follow you when you're holding food in your hand. +# This is due to a slow pathfinding algorithm, we're working on getting a better one +# You can tweak the update limit and find what works best for you. (NOTE: Do not but a very low one, because you might achieve the opposite, +# this might clog the thread for terain handling) and thus slow the bot even more. +# /!\ Make sure server rules allow an option like this in the rules of the server before using this bot +[ChatBot.FollowPlayer] +Enabled = false +Update_Limit = 1.5 # The rate at which the bot does calculations (in seconds) (You can tweak this if you feel the bot is too slow) +Stop_At_Distance = 3.0 # Do not follow the player if he is in the range of 3 blocks (prevents the bot from pushing a player in an infinite loop) + +# A small game to demonstrate chat interactions. Players can guess mystery words one letter at a time. +# You need to have ChatFormat working correctly and add yourself in botowners to start the game with /tell start +# /!\ This bot may get a bit spammy if many players are interacting with it +[ChatBot.HangmanGame] +Enabled = false +English = true +FileWords_EN = "hangman-en.txt" +FileWords_FR = "hangman-fr.txt" + +# Relay messages between players and servers, like a mail plugin +# This bot can store messages when the recipients are offline, and send them when they join the server +# /!\ Server admins can spoof PMs (/tellraw, /nick) so enable this bot only if you trust server admins +[ChatBot.Mailer] +Enabled = false +DatabaseFile = "MailerDatabase.ini" +IgnoreListFile = "MailerIgnoreList.ini" +PublicInteractions = false +MaxMailsPerPlayer = 10 +MaxDatabaseSize = 10000 +MailRetentionDays = 30 + +# Allows you to render maps in the console and into images (which can be then sent to Discord using Discord Bridge Chat Bot) +# This is useful for solving captchas which use maps +# The maps are rendered into Rendered_Maps folder if the Save_To_File is enabled. +# NOTE: +# If some servers have a very short time for solving captchas, enabe Auto_Render_On_Update to see them immediatelly in the console. +# /!\ Make sure server rules allow bots to be used on the server, or you risk being punished. +[ChatBot.Map] +Enabled = true +Render_In_Console = true # Whether to render the map in the console. +Save_To_File = false # Whether to store the rendered map as a file (You need this setting if you want to get a map on Discord using Discord Bridge). +Auto_Render_On_Update = false # Automatically render the map once it is received or updated from/by the server +Delete_All_On_Unload = true # Delete all rendered maps on unload/reload or when you launch the MCC again. +Notify_On_First_Update = true # Get a notification when you have gotten a map from the server for the first time +Rasize_Rendered_Image = false # Resize an rendered image, this is useful when images that are rendered are small and when are being sent to Discord. +Resize_To = 512 # The size that a rendered image should be resized to, in pixels (eg. 512). +# Send a rendered map (saved to a file) to a Discord or a Telegram channel via the Discord or Telegram Bride chat bot (The Discord/Telegram Bridge chat bot must be enabled and configured!) +# You need to enable Save_To_File in order for this to work. +# We also recommend turning on resizing. +Send_Rendered_To_Discord = false +Send_Rendered_To_Telegram = false + +# Log the list of players periodically into a textual file. +[ChatBot.PlayerListLogger] +Enabled = false +File = "playerlog.txt" +Delay = 60.0 # (In seconds) + +# Send MCC console commands to your bot through server PMs (/tell) +# You need to have ChatFormat working correctly and add yourself in botowners to use the bot +# /!\ Server admins can spoof PMs (/tellraw, /nick) so enable RemoteControl only if you trust server admins +[ChatBot.RemoteControl] +Enabled = false +AutoTpaccept = true +AutoTpaccept_Everyone = false + +# Enable recording of the game (/replay start) and replay it later using the Replay Mod (https://www.replaymod.com/) +# Please note that due to technical limitations, the client player (you) will not be shown in the replay file +# /!\ You SHOULD use /replay stop or exit the program gracefully with /quit OR THE REPLAY FILE MAY GET CORRUPT! +[ChatBot.ReplayCapture] +Enabled = false +Backup_Interval = 300.0 # How long should replay file be auto-saved, in seconds. Use -1 to disable. + +# Schedule commands and scripts to launch on various events such as server join, date/time or time interval +# See https://mccteam.github.io/g/bots/#script-scheduler for more info +[ChatBot.ScriptScheduler] +Enabled = false + +[[ChatBot.ScriptScheduler.TaskList]] +Task_Name = "Task Name 1" +Trigger_On_First_Login = false +Trigger_On_Login = false +Trigger_On_Times = { Enable = true, Times = [ 14:00:00, ] } +Trigger_On_Interval = { Enable = true, MinTime = 3.6, MaxTime = 4.8 } +Action = "send /hello" + +[[ChatBot.ScriptScheduler.TaskList]] +Task_Name = "Task Name 2" +Trigger_On_First_Login = false +Trigger_On_Login = true +Trigger_On_Times = { Enable = false, Times = [ ] } +Trigger_On_Interval = { Enable = false, MinTime = 1.0, MaxTime = 10.0 } +Action = "send /login pass" + + +# This bot allows you to send and receive messages and commands via a Telegram Bot DM or to receive messages in a Telegram channel. +# /!\ NOTE: You can't send messages and commands from a group channel, you can only send them in the bot DM, but you can get the messages from the client in a group channel. +# ----------------------------------------------------------- +# Setup: +# First you need to create a Telegram bot and obtain an API key, to do so, go to Telegram and find @botfather +# Click on "Start" button and read the bot reply, then type "/newbot", the Botfather will guide you through the bot creation. +# Once you create the bot, copy the API key that you have gotten, and put it into the "Token" field of "ChatBot.TelegramBridge" section (this section). +# /!\ Do not share this token with anyone else as it will give them the control over your bot. Save it securely. +# Then launch the client and go to Telegram, find your newly created bot by searching for it with its username, and open a DM with it. +# Click on "Start" button and type and send the following command ".chatid" to obtain the chat id. +# Copy the chat id number (eg. 2627844670) and paste it in the "ChannelId" field and add it to the "Authorized_Chat_Ids" field (in this section) (an id in "Authorized_Chat_Ids" field is a number/long, not a string!), then save the file. +# Now you can use the bot using it's DM. +# /!\ If you do not add the id of your chat DM with the bot to the "Authorized_Chat_Ids" field, ayone who finds your bot via search will be able to execute commands and send messages! +# /!\ An id pasted in to the "Authorized_Chat_Ids" should be a number/long, not a string! +# ----------------------------------------------------------- +# NOTE: If you want to recieve messages to a group channel instead, make the channel temporarely public, invite the bot to it and make it an administrator, then set the channel to private if you want. +# Then set the "ChannelId" field to the @ of your channel (you must include the @ in the settings, eg. "@mysupersecretchannel"), this is the username you can see in the invite link of the channel. +# /!\ Only include the username with @ prefix, do not include the rest of the link. Example if you have "https://t.me/mysupersecretchannel", the "ChannelId" will be "@mysupersecretchannel". +# /!\ Note that you will not be able to send messages to the client from a group channel! +# ----------------------------------------------------------- +# How to use the bot: +# To execute an MCC command, prefix it with a dot ".", example: ".move 143 64 735" . +# To send a message, simply type it out and hit enter. +[ChatBot.TelegramBridge] +Enabled = false +Token = "your bot token here" # Your Telegram Bot token. +ChannelId = "" # An ID of a channel where you want to interact with the MCC using the bot. +Authorized_Chat_Ids = [ ] # A list of Chat IDs that are allowed to send messages and execute commands. To get an id of your chat DM with the bot use ".chatid" bot command in Telegram. +Message_Send_Timeout = 3 # How long to wait (in seconds) if a message can not be sent to Telegram before canceling the task (minimum 1 second). +# Message formats +# Words wrapped with { and } are going to be replaced during the code execution, do not change them! +# For example. {message} is going to be replace with an actual message, {username} will be replaced with an username, {timestamp} with the current time. +# For Telegram message formatting, check the following: https://mccteam.github.io/r/tg-fmt.html +PrivateMessageFormat = "*(Private Message)* {username}: {message}" +PublicMessageFormat = "{username}: {message}" +TeleportRequestMessageFormat = "A new Teleport Request from **{username}**!" + +# A Chat Bot that collects items on the ground +[ChatBot.ItemsCollector] +Enabled = false +Collect_All_Item_Types = true # If set to true, the bot will collect all items, regardless of their type. If you want to use the whitelisted item types, disable this by setting it to false +Items_Whitelist = [ "Diamond", "NetheriteIngot", ] # In this list you can specify which items the bot will collect. To enable this, set the Collect_All_Item_Types to false. (NOTE: This does not prevent the bot from accidentally picking up other items, it only goes to positions where it finds the whitelisted items)\nYou can see the list of item types here: https://raw.githubusercontent.com/MCCTeam/Minecraft-Console-Client/master/MinecraftClient/Inventory/ItemType.cs +Delay_Between_Tasks = 300 # Delay in milliseconds between bot scanning items (Recommended: 300-500) +Collection_Radius = 30.0 # The radius in which bot will look for items to collect (Default: 30) +Always_Return_To_Start = true # If set to true, the bot will return to it's starting position after there are no items to collect +Prioritize_Clusters = false # If set to true, the bot will go after clustered items instead for the closest ones + +# Show a Discord Rich Presence status with your current Minecraft session info. +# Setup: +# 1. Go to https://discord.com/developers/applications and log in with your Discord account. +# 2. Click "New Application", give it a name (e.g. "MCC") and confirm. +# 3. On the application page, copy the "Application ID" and paste it in the "ApplicationId" field below. +# 4. (Optional) Go to "Rich Presence" -> "Art Assets" to upload custom images for LargeImageKey/SmallImageKey. +# Note: This does NOT require a Bot Token, only an Application ID. Discord must be running on the same machine as MCC. +[ChatBot.DiscordRpc] +Enabled = false +ApplicationId = "" # Your Discord Application ID. Create one at https://discord.com/developers/applications +PresenceDetails = "Playing on {server_host}:{server_port}" # The top line of the Rich Presence display. Supports placeholders. +PresenceState = "{dimension} - HP: {health}/{max_health}" # The second line of the Rich Presence display. Supports placeholders. +LargeImageKey = "mcc_icon" # The key of the large image asset uploaded to your Discord application. +LargeImageText = "Minecraft Console Client" # Tooltip text for the large image. Supports placeholders. +SmallImageKey = "" # The key of the small image asset uploaded to your Discord application (leave empty to hide). +SmallImageText = "" # Tooltip text for the small image. Supports placeholders. +ShowServerAddress = true # Show the server address (host and port) in the Discord presence. When disabled, {server_host} and {server_port} are masked. +ShowCoordinates = true # Show the player coordinates in the Discord presence. When disabled, {x}, {y}, {z} are masked. +ShowHealth = true # Show health and food level in the Discord presence. When disabled, {health}, {max_health}, {food} are masked. +ShowDimension = true # Show the current dimension in the Discord presence. When disabled, {dimension} is masked. +ShowGamemode = true # Show the current gamemode in the Discord presence. When disabled, {gamemode} is masked. +ShowElapsedTime = true # Show elapsed session time in the Discord presence. +ShowPlayerCount = true # Show the online player count as a party size in the Discord presence. +UpdateIntervalSeconds = 10 # How often (in seconds) to refresh the Discord presence. Minimum: 1 + +# Host an embedded MCP server while connected to Minecraft. Disabled by default. +[ChatBot.McpServer] +Enabled = false # Enable the built-in embedded MCP server bot. Server starts only after game join and stops on disconnect. +# Embedded MCP HTTP transport settings. +[ChatBot.McpServer.Transport] +BindHost = "127.0.0.1" # IP/host to bind the embedded MCP HTTP listener to. Default is loopback only. +Port = 33333 # TCP port for the embedded MCP HTTP listener. +Route = "/mcp" # Route prefix where MCP endpoints are exposed. +RequireAuthToken = false # Require Bearer token authentication for MCP endpoint requests. +AuthTokenEnvVar = "MCC_MCP_AUTH_TOKEN" # Environment variable name containing the MCP auth token when auth is required. + +# Enable or disable MCP tool categories. +[ChatBot.McpServer.Capabilities] +SessionStatus = true # Allow session and status inspection tools. +ChatAndCommands = true # Allow chat and internal command tools. +Movement = true # Allow movement and view-control tools. +Inventory = true # Allow inventory read and action tools. +EntityWorld = true # Allow entity and world inspection tools. + + + + diff --git a/MinecraftClient/Commands/Pathfind.cs b/MinecraftClient/Commands/Pathfind.cs index ea8c1135..23a16285 100644 --- a/MinecraftClient/Commands/Pathfind.cs +++ b/MinecraftClient/Commands/Pathfind.cs @@ -68,6 +68,31 @@ namespace MinecraftClient.Commands Task.Run(() => { + try + { + handler.Log.Info($"[Pathfind] Diagnosing blocks around start ({startX},{startY},{startZ}):"); + for (int ddx = -1; ddx <= 1; ddx++) + { + for (int ddz = -1; ddz <= 1; ddz++) + { + int tx = startX + ddx, tz = startZ + ddz; + var below = ctx.GetMaterial(tx, startY - 1, tz); + var body = ctx.GetMaterial(tx, startY, tz); + var head = ctx.GetMaterial(tx, startY + 1, tz); + bool canOn = ctx.CanWalkOn(tx, startY - 1, tz); + bool canThru = ctx.CanWalkThrough(tx, startY, tz); + bool canThruH = ctx.CanWalkThrough(tx, startY + 1, tz); + handler.Log.Info($" ({tx},{tz}): below={below}(walkOn={canOn}) body={body}(thru={canThru}) head={head}(thru={canThruH})"); + } + } + handler.Log.Info($"[Pathfind] ChunkLoaded at start: {ctx.IsChunkLoaded(startX, startZ)}"); + handler.Log.Info($"[Pathfind] ChunkLoaded at goal: {ctx.IsChunkLoaded(goalX, goalZ)}"); + } + catch (Exception ex) + { + handler.Log.Warn($"[Pathfind] Diagnostic exception: {ex.Message}"); + } + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10)); var result = finder.Calculate(ctx, startX, startY, startZ, goalObj, cts.Token, timeoutMs: 10000); diff --git a/MinecraftClient/Pathing/Core/AStarPathFinder.cs b/MinecraftClient/Pathing/Core/AStarPathFinder.cs index 05f4dde9..f58dac28 100644 --- a/MinecraftClient/Pathing/Core/AStarPathFinder.cs +++ b/MinecraftClient/Pathing/Core/AStarPathFinder.cs @@ -96,6 +96,9 @@ namespace MinecraftClient.Pathing.Core current.IsClosed = true; nodesExplored++; + if (nodesExplored <= 10) + DebugLog?.Invoke($"[A*] Expand #{nodesExplored}: ({current.X},{current.Y},{current.Z}) F={current.FCost:F2} G={current.GCost:F2} H={current.HCost:F2}, openSet={openSet.Count}"); + if (goal.IsInGoal(current.X, current.Y, current.Z)) { DebugLog?.Invoke($"[A*] Goal reached! {nodesExplored} nodes, {sw.ElapsedMilliseconds}ms"); @@ -108,6 +111,15 @@ namespace MinecraftClient.Pathing.Core moveResult.Cost = 0; move.Calculate(ctx, current.X, current.Y, current.Z, ref moveResult); + if (nodesExplored <= 2) + { + if (moveResult.IsImpossible) + DebugLog?.Invoke($"[A*] move {move.Type}({move.XOffset},{move.ZOffset}) from ({current.X},{current.Y},{current.Z}): IMPOSSIBLE"); + else + DebugLog?.Invoke($"[A*] move {move.Type}({move.XOffset},{move.ZOffset}) from ({current.X},{current.Y},{current.Z}): " + + $"-> ({moveResult.DestX},{moveResult.DestY},{moveResult.DestZ}) cost={moveResult.Cost:F2}"); + } + if (moveResult.IsImpossible) continue; @@ -127,6 +139,8 @@ namespace MinecraftClient.Pathing.Core if (nodeMap.TryGetValue(packed, out var neighbor)) { + if (nodesExplored <= 2) + DebugLog?.Invoke($"[A*] EXISTS ({nx},{ny},{nz}) pack={packed} actual=({neighbor.X},{neighbor.Y},{neighbor.Z}) closed={neighbor.IsClosed} tentG={tentativeG:F2} existG={neighbor.GCost:F2}"); if (neighbor.IsClosed) continue; if (tentativeG >= neighbor.GCost) @@ -140,6 +154,8 @@ namespace MinecraftClient.Pathing.Core } else { + if (nodesExplored <= 2) + DebugLog?.Invoke($"[A*] NEW ({nx},{ny},{nz}) pack={packed} G={tentativeG:F2} H={goal.Heuristic(nx, ny, nz):F2}"); neighbor = new PathNode(nx, ny, nz) { GCost = tentativeG, @@ -155,6 +171,8 @@ namespace MinecraftClient.Pathing.Core double partialScore = neighbor.HCost + neighbor.GCost * 0.5; if (partialScore < bestPartialScore) { + if (nodesExplored <= 3) + DebugLog?.Invoke($"[A*] partial improved: ({neighbor.X},{neighbor.Y},{neighbor.Z}) score={partialScore:F2} < {bestPartialScore:F2}"); bestPartialScore = partialScore; bestPartialNode = neighbor; } diff --git a/MinecraftClient/Pathing/Core/PathNode.cs b/MinecraftClient/Pathing/Core/PathNode.cs index f8f0d25b..1cab8d78 100644 --- a/MinecraftClient/Pathing/Core/PathNode.cs +++ b/MinecraftClient/Pathing/Core/PathNode.cs @@ -31,9 +31,11 @@ namespace MinecraftClient.Pathing.Core public static long Pack(int x, int y, int z) { - return ((long)(x + 30_000_000) << 36) - | ((long)(z + 30_000_000) << 12) - | (long)((y + 64) & 0xFFF); + // 26 bits for X (0..60M), 26 bits for Z (0..60M), 12 bits for Y (-2048..2047) + long px = (long)(x + 30_000_000) & 0x3FFFFFF; + long pz = (long)(z + 30_000_000) & 0x3FFFFFF; + long py = (long)(y + 2048) & 0xFFF; + return (px << 38) | (pz << 12) | py; } } } diff --git a/config/phase0_test.cs b/config/phase0_test.cs new file mode 100644 index 00000000..c520fd1f --- /dev/null +++ b/config/phase0_test.cs @@ -0,0 +1,121 @@ +//MCCScript 1.0 + +MCC.LoadBot(new Phase0Test()); + +//MCCScript Extensions + +public class Phase0Test : ChatBot +{ + private int phase = 0; + private int ticksInPhase = 0; + + public override void Initialize() + { + LogToConsole("=== Phase 0 Physics Test ==="); + } + + public override void AfterGameJoined() + { + LogToConsole("Joined. Starting tests..."); + } + + public override void Update() + { + ticksInPhase++; + + switch (phase) + { + case 0: // Setup area + if (ticksInPhase == 1) + { + LogToConsole("[Setup] Creating test area at spawn..."); + SendText("/tp @s 0 80 0"); + } + if (ticksInPhase == 40) + SendText("/fill -5 79 -5 15 79 15 stone"); + if (ticksInPhase == 50) + SendText("/fill -5 80 -5 15 85 15 air"); + if (ticksInPhase == 60) + SendText("/tp @s 0 80 0"); + if (ticksInPhase >= 80) NextPhase(); + break; + + case 1: // Test crawling: place 1-block-high ceiling + if (ticksInPhase == 1) + { + LogToConsole("[Test 1] CRAWLING - Placing ceiling at y=81 above player (1 block headroom)"); + SendText("/setblock 0 81 0 stone"); + } + if (ticksInPhase == 40) + { + var loc = GetCurrentLocation(); + LogToConsole("[Test 1] Pos: " + loc + " - Check debug log for Swimming/crawl pose"); + } + if (ticksInPhase == 80) + { + LogToConsole("[Test 1] Removing ceiling..."); + SendText("/setblock 0 81 0 air"); + } + if (ticksInPhase == 100) + { + var loc = GetCurrentLocation(); + LogToConsole("[Test 1] After removal pos: " + loc + " - Should be back to Standing"); + } + if (ticksInPhase >= 120) NextPhase(); + break; + + case 2: // Test slime bounce (no sneak) + if (ticksInPhase == 1) + { + LogToConsole("[Test 2] SLIME BOUNCE - Placing slime blocks and falling"); + SendText("/fill 8 79 0 10 79 2 slime_block"); + } + if (ticksInPhase == 20) + { + LogToConsole("[Test 2] Teleporting 10 blocks above slime..."); + SendText("/tp @s 9 90 1"); + } + if (ticksInPhase % 10 == 0 && ticksInPhase >= 30 && ticksInPhase <= 100) + { + var loc = GetCurrentLocation(); + LogToConsole("[Test 2] tick=" + ticksInPhase + " Pos: " + loc); + } + if (ticksInPhase >= 160) NextPhase(); + break; + + case 3: // Test sneaking (move with sneak) + if (ticksInPhase == 1) + { + LogToConsole("[Test 3] SNEAK MOVEMENT"); + SendText("/tp @s 0 80 0"); + } + if (ticksInPhase == 30) + { + LogToConsole("[Test 3] Moving to (5,80,0) with unsafe path..."); + MoveToLocation(new Location(5, 80, 0), allowUnsafe: true); + } + if (ticksInPhase % 10 == 0 && ticksInPhase >= 30 && ticksInPhase <= 80) + { + var loc = GetCurrentLocation(); + LogToConsole("[Test 3] tick=" + ticksInPhase + " Pos: " + loc); + } + if (ticksInPhase >= 100) NextPhase(); + break; + + case 4: // Done + if (ticksInPhase == 1) + { + LogToConsole("=== Phase 0 Tests Complete ==="); + LogToConsole("Check debug log for [Physics] messages."); + UnloadBot(); + } + break; + } + } + + private void NextPhase() + { + phase++; + ticksInPhase = 0; + } +} From deb1bc47ccef03bcca9e13e24d43160e6118e6d9 Mon Sep 17 00:00:00 2001 From: BruceChen Date: Sat, 11 Apr 2026 02:13:48 +0800 Subject: [PATCH 04/37] refactor: clean up debug logging in pathfinder and pathfind command Remove verbose per-node insertion tracking from A*. Keep essential logging: start, goal reached, partial/failed results. Clean up pathfind command with exception handling and cleaner output. Made-with: Cursor --- MinecraftClient/Commands/Pathfind.cs | 64 +++++++------------ .../Pathing/Core/AStarPathFinder.cs | 18 ------ 2 files changed, 23 insertions(+), 59 deletions(-) diff --git a/MinecraftClient/Commands/Pathfind.cs b/MinecraftClient/Commands/Pathfind.cs index 23a16285..50eade55 100644 --- a/MinecraftClient/Commands/Pathfind.cs +++ b/MinecraftClient/Commands/Pathfind.cs @@ -70,50 +70,32 @@ namespace MinecraftClient.Commands { try { - handler.Log.Info($"[Pathfind] Diagnosing blocks around start ({startX},{startY},{startZ}):"); - for (int ddx = -1; ddx <= 1; ddx++) + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10)); + var result = finder.Calculate(ctx, startX, startY, startZ, goalObj, cts.Token, timeoutMs: 10000); + + handler.Log.Info($"[Pathfind] Result: {result.Status}, {result.Path.Count} nodes, " + + $"{result.NodesExplored} explored, {result.ElapsedMs}ms"); + + if (result.Path.Count > 1) { - for (int ddz = -1; ddz <= 1; ddz++) + handler.Log.Info("[Pathfind] Path waypoints:"); + for (int i = 0; i < result.Path.Count; i++) { - int tx = startX + ddx, tz = startZ + ddz; - var below = ctx.GetMaterial(tx, startY - 1, tz); - var body = ctx.GetMaterial(tx, startY, tz); - var head = ctx.GetMaterial(tx, startY + 1, tz); - bool canOn = ctx.CanWalkOn(tx, startY - 1, tz); - bool canThru = ctx.CanWalkThrough(tx, startY, tz); - bool canThruH = ctx.CanWalkThrough(tx, startY + 1, tz); - handler.Log.Info($" ({tx},{tz}): below={below}(walkOn={canOn}) body={body}(thru={canThru}) head={head}(thru={canThruH})"); + var n = result.Path[i]; + handler.Log.Info($" [{i}] ({n.X},{n.Y},{n.Z}) via {n.MoveUsed}"); } + + handler.Log.Info("[Pathfind] Beginning movement along path..."); + FollowPath(handler, result); + } + else + { + handler.Log.Warn("[Pathfind] No path found!"); } - handler.Log.Info($"[Pathfind] ChunkLoaded at start: {ctx.IsChunkLoaded(startX, startZ)}"); - handler.Log.Info($"[Pathfind] ChunkLoaded at goal: {ctx.IsChunkLoaded(goalX, goalZ)}"); } catch (Exception ex) { - handler.Log.Warn($"[Pathfind] Diagnostic exception: {ex.Message}"); - } - - using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10)); - var result = finder.Calculate(ctx, startX, startY, startZ, goalObj, cts.Token, timeoutMs: 10000); - - handler.Log.Info($"[Pathfind] Result: {result.Status}, {result.Path.Count} nodes, " + - $"{result.NodesExplored} explored, {result.ElapsedMs}ms"); - - if (result.Path.Count > 0) - { - handler.Log.Info("[Pathfind] Path waypoints:"); - for (int i = 0; i < result.Path.Count; i++) - { - var n = result.Path[i]; - handler.Log.Info($" [{i}] ({n.X},{n.Y},{n.Z}) via {n.MoveUsed}"); - } - - handler.Log.Info("[Pathfind] Beginning movement along path..."); - FollowPath(handler, result); - } - else - { - handler.Log.Warn("[Pathfind] No path found!"); + handler.Log.Warn($"[Pathfind] Exception: {ex.Message}"); } }); @@ -127,12 +109,12 @@ namespace MinecraftClient.Commands var node = result.Path[i]; var target = new Location(node.X + 0.5, node.Y, node.Z + 0.5); - handler.Log.Info($"[Pathfind] Moving to waypoint [{i}]: ({node.X},{node.Y},{node.Z}) via {node.MoveUsed}"); + handler.Log.Info($"[Pathfind] Moving to waypoint [{i}/{result.Path.Count - 1}]: ({node.X},{node.Y},{node.Z}) via {node.MoveUsed}"); bool success = handler.MoveTo(target, allowUnsafe: true, allowDirectTeleport: false, timeout: TimeSpan.FromSeconds(10)); if (!success) { - handler.Log.Warn($"[Pathfind] Old pathfinder failed to plan sub-path to ({node.X},{node.Y},{node.Z}), trying direct teleport"); + handler.Log.Warn($"[Pathfind] Sub-path failed for waypoint [{i}], using direct move"); handler.MoveTo(target, allowUnsafe: true, allowDirectTeleport: true); } @@ -147,9 +129,9 @@ namespace MinecraftClient.Commands var cur = handler.GetCurrentLocation(); double dx = cur.X - target.X; double dz = cur.Z - target.Z; - double horizDistSq = dx * dx + dz * dz; + double horizDist = Math.Sqrt(dx * dx + dz * dz); - handler.Log.Info($"[Pathfind] Arrived near waypoint [{i}], pos=({cur.X:F2},{cur.Y:F2},{cur.Z:F2}), horizDist={Math.Sqrt(horizDistSq):F2}"); + handler.Log.Info($"[Pathfind] Waypoint [{i}] done, pos=({cur.X:F2},{cur.Y:F2},{cur.Z:F2}), dist={horizDist:F2}"); } handler.Log.Info("[Pathfind] Path execution complete!"); diff --git a/MinecraftClient/Pathing/Core/AStarPathFinder.cs b/MinecraftClient/Pathing/Core/AStarPathFinder.cs index f58dac28..05f4dde9 100644 --- a/MinecraftClient/Pathing/Core/AStarPathFinder.cs +++ b/MinecraftClient/Pathing/Core/AStarPathFinder.cs @@ -96,9 +96,6 @@ namespace MinecraftClient.Pathing.Core current.IsClosed = true; nodesExplored++; - if (nodesExplored <= 10) - DebugLog?.Invoke($"[A*] Expand #{nodesExplored}: ({current.X},{current.Y},{current.Z}) F={current.FCost:F2} G={current.GCost:F2} H={current.HCost:F2}, openSet={openSet.Count}"); - if (goal.IsInGoal(current.X, current.Y, current.Z)) { DebugLog?.Invoke($"[A*] Goal reached! {nodesExplored} nodes, {sw.ElapsedMilliseconds}ms"); @@ -111,15 +108,6 @@ namespace MinecraftClient.Pathing.Core moveResult.Cost = 0; move.Calculate(ctx, current.X, current.Y, current.Z, ref moveResult); - if (nodesExplored <= 2) - { - if (moveResult.IsImpossible) - DebugLog?.Invoke($"[A*] move {move.Type}({move.XOffset},{move.ZOffset}) from ({current.X},{current.Y},{current.Z}): IMPOSSIBLE"); - else - DebugLog?.Invoke($"[A*] move {move.Type}({move.XOffset},{move.ZOffset}) from ({current.X},{current.Y},{current.Z}): " + - $"-> ({moveResult.DestX},{moveResult.DestY},{moveResult.DestZ}) cost={moveResult.Cost:F2}"); - } - if (moveResult.IsImpossible) continue; @@ -139,8 +127,6 @@ namespace MinecraftClient.Pathing.Core if (nodeMap.TryGetValue(packed, out var neighbor)) { - if (nodesExplored <= 2) - DebugLog?.Invoke($"[A*] EXISTS ({nx},{ny},{nz}) pack={packed} actual=({neighbor.X},{neighbor.Y},{neighbor.Z}) closed={neighbor.IsClosed} tentG={tentativeG:F2} existG={neighbor.GCost:F2}"); if (neighbor.IsClosed) continue; if (tentativeG >= neighbor.GCost) @@ -154,8 +140,6 @@ namespace MinecraftClient.Pathing.Core } else { - if (nodesExplored <= 2) - DebugLog?.Invoke($"[A*] NEW ({nx},{ny},{nz}) pack={packed} G={tentativeG:F2} H={goal.Heuristic(nx, ny, nz):F2}"); neighbor = new PathNode(nx, ny, nz) { GCost = tentativeG, @@ -171,8 +155,6 @@ namespace MinecraftClient.Pathing.Core double partialScore = neighbor.HCost + neighbor.GCost * 0.5; if (partialScore < bestPartialScore) { - if (nodesExplored <= 3) - DebugLog?.Invoke($"[A*] partial improved: ({neighbor.X},{neighbor.Y},{neighbor.Z}) score={partialScore:F2} < {bestPartialScore:F2}"); bestPartialScore = partialScore; bestPartialNode = neighbor; } From 77c5f881680d51ba7c70538bb8579de3c4a617ec Mon Sep 17 00:00:00 2001 From: BruceChen Date: Sat, 11 Apr 2026 02:27:10 +0800 Subject: [PATCH 05/37] feat: add /goto command with A* pathfinder integration and MoveFall - Add MoveFall move for straight-down falls beyond MoveDescend range - Register MoveFall in default move set - Create /goto command using new A* pathfinder - Add MoveToAStar() method to McClient bridging A* results to existing path execution system (Queue + UpdatePathfindingInput) - Add translation entries for goto command Made-with: Cursor --- MinecraftClient/Commands/Goto.cs | 50 ++++++++++++++ MinecraftClient/McClient.cs | 60 ++++++++++++++++ .../Pathing/Core/AStarPathFinder.cs | 2 + .../Pathing/Moves/Impl/MoveFall.cs | 69 +++++++++++++++++++ .../Translations/Translations.Designer.cs | 27 ++++++++ .../Resources/Translations/Translations.resx | 9 +++ 6 files changed, 217 insertions(+) create mode 100644 MinecraftClient/Commands/Goto.cs create mode 100644 MinecraftClient/Pathing/Moves/Impl/MoveFall.cs diff --git a/MinecraftClient/Commands/Goto.cs b/MinecraftClient/Commands/Goto.cs new file mode 100644 index 00000000..18cf7d0f --- /dev/null +++ b/MinecraftClient/Commands/Goto.cs @@ -0,0 +1,50 @@ +using Brigadier.NET; +using Brigadier.NET.Builder; +using MinecraftClient.CommandHandler; +using MinecraftClient.Mapping; +using static MinecraftClient.CommandHandler.CmdResult; + +namespace MinecraftClient.Commands +{ + public class Goto : Command + { + public override string CmdName => "goto"; + public override string CmdUsage => "goto "; + public override string CmdDesc => Translations.cmd_goto_desc; + + public override void RegisterCommand(CommandDispatcher dispatcher) + { + dispatcher.Register(l => l.Literal("help") + .Then(l => l.Literal(CmdName) + .Executes(r => GetUsage(r.Source, string.Empty))) + ); + + dispatcher.Register(l => l.Literal(CmdName) + .Then(l => l.Argument("location", MccArguments.Location()) + .Executes(r => DoGoto(r.Source, MccArguments.GetLocation(r, "location")))) + .Then(l => l.Literal("_help") + .Executes(r => GetUsage(r.Source, string.Empty)) + .Redirect(dispatcher.GetRoot().GetChild("help").GetChild(CmdName))) + ); + } + + private int GetUsage(CmdResult r, string? cmd) + { + return r.SetAndReturn(GetCmdDescTranslated()); + } + + private static int DoGoto(CmdResult r, Location goal) + { + McClient handler = CmdResult.currentHandler!; + if (!handler.GetTerrainEnabled()) + return r.SetAndReturn(Status.FailNeedTerrain); + + Location current = handler.GetCurrentLocation(); + goal.ToAbsolute(current); + + var (success, message) = handler.MoveToAStar(goal); + + return r.SetAndReturn(success ? Status.Done : Status.Fail, message); + } + } +} diff --git a/MinecraftClient/McClient.cs b/MinecraftClient/McClient.cs index 115c7a63..9c3f9751 100644 --- a/MinecraftClient/McClient.cs +++ b/MinecraftClient/McClient.cs @@ -1714,6 +1714,66 @@ namespace MinecraftClient } } + /// + /// Navigate to a goal using the new A* pathfinder. + /// Runs the search, converts the result into the legacy Queue path, and starts movement. + /// Returns a description of the result for UI feedback. + /// + public (bool success, string message) MoveToAStar(Location goal, long timeoutMs = 5000) + { + lock (locationLock) + { + var ctx = new Pathing.Core.CalculationContext(world); + var finder = new Pathing.Core.AStarPathFinder(); + finder.DebugLog = msg => Log.Debug(msg); + + int sx = (int)Math.Floor(location.X); + int sy = (int)Math.Floor(location.Y); + int sz = (int)Math.Floor(location.Z); + int gx = (int)Math.Floor(goal.X); + int gy = (int)Math.Floor(goal.Y); + int gz = (int)Math.Floor(goal.Z); + + Log.Info($"[Goto] A* search from ({sx},{sy},{sz}) to ({gx},{gy},{gz})..."); + + using var cts = new CancellationTokenSource(); + var result = finder.Calculate(ctx, sx, sy, sz, + new Pathing.Goals.GoalBlock(gx, gy, gz), cts.Token, timeoutMs); + + Log.Info($"[Goto] A* result: {result.Status}, nodes={result.NodesExplored}, " + + $"time={result.ElapsedMs}ms, path length={result.Path.Count}"); + + if (result.Status == Pathing.Core.PathStatus.Failed || result.Path.Count < 2) + { + return (false, string.Format(Translations.cmd_goto_failed, + result.NodesExplored, result.ElapsedMs)); + } + + var queue = new Queue(); + for (int i = 1; i < result.Path.Count; i++) + { + var node = result.Path[i]; + queue.Enqueue(new Location(node.X + 0.5, node.Y, node.Z + 0.5)); + } + + Log.Info($"[Goto] Path waypoints: {queue.Count}"); + int logCount = 0; + foreach (var wp in queue) + { + if (logCount < 30 || logCount == queue.Count - 1) + Log.Debug($"[Goto] wp[{logCount}] = ({wp.X:F1},{wp.Y:F1},{wp.Z:F1})"); + logCount++; + } + + pathTarget = null; + path = queue; + + string statusStr = result.Status == Pathing.Core.PathStatus.Partial ? " (partial)" : ""; + return (true, string.Format(Translations.cmd_goto_success, + queue.Count, result.NodesExplored, result.ElapsedMs, statusStr)); + } + } + /// /// Send a chat message or command to the server /// diff --git a/MinecraftClient/Pathing/Core/AStarPathFinder.cs b/MinecraftClient/Pathing/Core/AStarPathFinder.cs index 05f4dde9..a4c68b93 100644 --- a/MinecraftClient/Pathing/Core/AStarPathFinder.cs +++ b/MinecraftClient/Pathing/Core/AStarPathFinder.cs @@ -47,6 +47,8 @@ namespace MinecraftClient.Pathing.Core moves.Add(new MoveClimb(true)); moves.Add(new MoveClimb(false)); + moves.Add(new MoveFall()); + return [.. moves]; } diff --git a/MinecraftClient/Pathing/Moves/Impl/MoveFall.cs b/MinecraftClient/Pathing/Moves/Impl/MoveFall.cs new file mode 100644 index 00000000..e3fd74c4 --- /dev/null +++ b/MinecraftClient/Pathing/Moves/Impl/MoveFall.cs @@ -0,0 +1,69 @@ +using MinecraftClient.Pathing.Core; + +namespace MinecraftClient.Pathing.Moves.Impl +{ + /// + /// Straight-down fall at the current X,Z position, for drops greater than MaxFallHeight + /// that MoveDescend won't cover. Scans downward for a safe landing. + /// + public sealed class MoveFall : IMove + { + public MoveType Type => MoveType.Fall; + public int XOffset => 0; + public int ZOffset => 0; + public bool DynamicY => true; + + private readonly int _maxScanDepth; + + public MoveFall(int maxScanDepth = 256) + { + _maxScanDepth = maxScanDepth; + } + + public void Calculate(CalculationContext ctx, int x, int y, int z, ref MoveResult result) + { + if (!ctx.CanWalkThrough(x, y - 1, z)) + { + result.SetImpossible(); + return; + } + + for (int fallDist = 1; fallDist <= _maxScanDepth; fallDist++) + { + int landY = y - fallDist; + + if (ctx.CanWalkOn(x, landY - 1, z)) + { + if (!ctx.CanWalkThrough(x, landY, z)) + { + result.SetImpossible(); + return; + } + + if (MoveHelper.IsHazardous(ctx.GetMaterial(x, landY - 1, z))) + { + result.SetImpossible(); + return; + } + + double fallDamageThreshold = 3; + double cost = ActionCosts.FallCost(fallDist); + + if (fallDist > fallDamageThreshold) + cost += (fallDist - fallDamageThreshold) * 5.0; + + result.Set(x, landY, z, cost); + return; + } + + if (!ctx.CanWalkThrough(x, landY, z)) + { + result.SetImpossible(); + return; + } + } + + result.SetImpossible(); + } + } +} diff --git a/MinecraftClient/Resources/Translations/Translations.Designer.cs b/MinecraftClient/Resources/Translations/Translations.Designer.cs index b5407c84..64e955ba 100644 --- a/MinecraftClient/Resources/Translations/Translations.Designer.cs +++ b/MinecraftClient/Resources/Translations/Translations.Designer.cs @@ -3501,6 +3501,33 @@ namespace MinecraftClient { } } + /// + /// Looks up a localized string similar to navigate to a location using A* pathfinding.. + /// + internal static string cmd_goto_desc { + get { + return ResourceManager.GetString("cmd.goto.desc", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Path found: {0} waypoints, {1} nodes explored in {2}ms{3}. + /// + internal static string cmd_goto_success { + get { + return ResourceManager.GetString("cmd.goto.success", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to No path found ({0} nodes explored in {1}ms). + /// + internal static string cmd_goto_failed { + get { + return ResourceManager.GetString("cmd.goto.failed", resourceCulture); + } + } + /// /// Looks up a localized string similar to Already following {0}!. /// diff --git a/MinecraftClient/Resources/Translations/Translations.resx b/MinecraftClient/Resources/Translations/Translations.resx index e4d96ef7..abe2cc42 100644 --- a/MinecraftClient/Resources/Translations/Translations.resx +++ b/MinecraftClient/Resources/Translations/Translations.resx @@ -1243,6 +1243,15 @@ Change EnableEmoji=false in the settings if the display is confusing. disconnect from the server. + + navigate to a location using A* pathfinding. + + + Path found: {0} waypoints, {1} nodes explored in {2}ms{3} + + + No path found ({0} nodes explored in {1}ms) + Already following {0}! From a6261e40198eb1ea93aa7fb563736851e8667fff Mon Sep 17 00:00:00 2001 From: BruceChen Date: Sat, 11 Apr 2026 02:47:31 +0800 Subject: [PATCH 06/37] fix: improve pathfinding execution for climbing and block classification MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Fix MoveHelper.CanWalkThrough to treat climbable blocks (ladders, vines) as passable, not solid -- MCC's IsSolid() incorrectly classifies them - Fix MoveHelper.CanWalkOn to exclude climbable blocks from ground check - Add fence gate passability in MoveHelper - Fix start position calculation in MoveToAStar to handle solid-block floor rounding (player at y=79.9 → floor y=79 inside solid) - Fix ReachedWaypoint to require vertical proximity for climb waypoints, preventing premature waypoint consumption during ladder ascent - Fix SetInputToward to handle ladder climbing with Jump input and proper wall-facing when OnClimbable Made-with: Cursor --- MinecraftClient/McClient.cs | 57 +++++++++++++++++++-- MinecraftClient/Pathing/Moves/MoveHelper.cs | 22 ++++++++ 2 files changed, 76 insertions(+), 3 deletions(-) diff --git a/MinecraftClient/McClient.cs b/MinecraftClient/McClient.cs index 9c3f9751..c343f16e 100644 --- a/MinecraftClient/McClient.cs +++ b/MinecraftClient/McClient.cs @@ -1730,11 +1730,20 @@ namespace MinecraftClient int sx = (int)Math.Floor(location.X); int sy = (int)Math.Floor(location.Y); int sz = (int)Math.Floor(location.Z); + + // If floored Y lands inside a solid block (e.g. player on top of it), step up + if (!ctx.CanWalkThrough(sx, sy, sz) && ctx.CanWalkThrough(sx, sy + 1, sz)) + sy++; + int gx = (int)Math.Floor(goal.X); int gy = (int)Math.Floor(goal.Y); int gz = (int)Math.Floor(goal.Z); - Log.Info($"[Goto] A* search from ({sx},{sy},{sz}) to ({gx},{gy},{gz})..."); + if (!ctx.CanWalkThrough(gx, gy, gz) && ctx.CanWalkThrough(gx, gy + 1, gz)) + gy++; + + Log.Info($"[Goto] A* search from ({sx},{sy},{sz}) to ({gx},{gy},{gz}) " + + $"[raw pos=({location.X:F2},{location.Y:F2},{location.Z:F2})]"); using var cts = new CancellationTokenSource(); var result = finder.Calculate(ctx, sx, sy, sz, @@ -3293,12 +3302,20 @@ namespace MinecraftClient /// /// Check if the player has approximately reached a waypoint. + /// Uses both horizontal and vertical distance for climb/descend waypoints. /// private bool ReachedWaypoint(Location target) { double dx = target.X - location.X; double dz = target.Z - location.Z; - return dx * dx + dz * dz < 0.25; // within ~0.5 blocks horizontally + double dy = target.Y - location.Y; + double horizDistSq = dx * dx + dz * dz; + + // Vertical waypoint (climbing/falling): require reaching target Y level + if (horizDistSq < 0.5 && Math.Abs(dy) > 0.8) + return false; + + return horizDistSq < 0.25 && Math.Abs(dy) < 0.8; } /// @@ -3312,7 +3329,41 @@ namespace MinecraftClient double dy = target.Y - location.Y; double distSqr = dx * dx + dz * dz; - if (distSqr < 0.01) return; // Close enough horizontally + // Climbing: target is above/below with small horizontal offset + if (playerPhysics.OnClimbable && Math.Abs(dy) > 0.5 && distSqr < 1.0) + { + if (dy > 0) + { + physicsInput.Jump = true; + // Push against the wall for HorizontalCollision-triggered climbing + if (distSqr > 0.01) + { + float yaw = (float)(-Math.Atan2(dx, dz) / Math.PI * 180.0); + if (yaw < 0) yaw += 360; + playerPhysics.Yaw = yaw; + playerYaw = yaw; + physicsInput.Forward = true; + } + else + { + physicsInput.Forward = true; + } + } + else + { + physicsInput.Sneak = false; + } + return; + } + + // Non-climbing vertical jump + if (distSqr < 0.1 && dy > 0.5 && playerPhysics.OnGround) + { + physicsInput.Jump = true; + return; + } + + if (distSqr < 0.01) return; // Calculate yaw to face target float targetYaw = (float)(-Math.Atan2(dx, dz) / Math.PI * 180.0); diff --git a/MinecraftClient/Pathing/Moves/MoveHelper.cs b/MinecraftClient/Pathing/Moves/MoveHelper.cs index 8bb6108f..e925bc6f 100644 --- a/MinecraftClient/Pathing/Moves/MoveHelper.cs +++ b/MinecraftClient/Pathing/Moves/MoveHelper.cs @@ -19,6 +19,10 @@ namespace MinecraftClient.Pathing.Moves return true; if (mat.IsLiquid()) return false; + if (mat.CanBeClimbedOn()) + return true; + if (IsOpenGate(mat)) + return true; if (mat.IsSolid()) return false; if (mat.CanHarmPlayers()) @@ -38,6 +42,10 @@ namespace MinecraftClient.Pathing.Moves return false; if (mat.CanHarmPlayers()) return false; + if (mat.CanBeClimbedOn()) + return false; + if (IsOpenGate(mat)) + return false; return mat.IsSolid(); } @@ -65,5 +73,19 @@ namespace MinecraftClient.Pathing.Moves { return mat == Material.Water; } + + /// + /// Conservative check for gate-type blocks. Since we cannot read block state + /// (open/closed) during planning, treat all fence gates as passable. + /// + private static bool IsOpenGate(Material mat) + { + return mat is Material.AcaciaFenceGate or Material.BirchFenceGate + or Material.CrimsonFenceGate or Material.DarkOakFenceGate + or Material.JungleFenceGate or Material.MangroveWood + or Material.OakFenceGate or Material.SpruceFenceGate + or Material.WarpedFenceGate or Material.CherryFenceGate + or Material.BambooFenceGate or Material.PaleOakFenceGate; + } } } From 2d4f1fa6770c0be50927c84bf13e7053e7e3dd74 Mon Sep 17 00:00:00 2001 From: BruceChen Date: Sat, 11 Apr 2026 03:02:18 +0800 Subject: [PATCH 07/37] fix: improve waypoint execution with look-ahead and vertical jump handling Refactor movement tick into AdvanceWaypoint(), add look-ahead logic that detects vertical-only waypoints and merges to the next horizontal waypoint early to handle ladder-to-platform transitions, and add jump input when horizontally aligned but needing to reach a higher Y level. Made-with: Cursor --- MinecraftClient/McClient.cs | 71 ++++++++++++++++++++++++++----------- 1 file changed, 51 insertions(+), 20 deletions(-) diff --git a/MinecraftClient/McClient.cs b/MinecraftClient/McClient.cs index c343f16e..cb9a8fe5 100644 --- a/MinecraftClient/McClient.cs +++ b/MinecraftClient/McClient.cs @@ -1769,8 +1769,7 @@ namespace MinecraftClient int logCount = 0; foreach (var wp in queue) { - if (logCount < 30 || logCount == queue.Count - 1) - Log.Debug($"[Goto] wp[{logCount}] = ({wp.X:F1},{wp.Y:F1},{wp.Z:F1})"); + Log.Debug($"[Goto] wp[{logCount}] = ({wp.X:F1},{wp.Y:F1},{wp.Z:F1})"); logCount++; } @@ -3269,34 +3268,60 @@ namespace MinecraftClient { physicsInput.Reset(); - // Still heading toward a target (even if path queue is empty) + // Advance waypoints when reached if (pathTarget is not null && ReachedWaypoint(pathTarget.Value)) + AdvanceWaypoint(); + + // First target from a fresh path + if (pathTarget is null && path is not null && path.Count > 0) + AdvanceWaypoint(); + + if (pathTarget is not null) { - // Arrived at current waypoint — advance to next, or finish + // Look-ahead: if this is a vertical-only waypoint and the next requires + // horizontal movement, merge them once we're close enough vertically. + // This handles the ladder-to-platform transition. if (path is not null && path.Count > 0) { - pathTarget = path.Dequeue(); - if (Config.Main.Advanced.MoveHeadWhileWalking) - UpdateLocation(location, pathTarget.Value + new Location(0, 1, 0)); - } - else - { - pathTarget = null; - path = null; - } - } + var target = pathTarget.Value; + double dx = target.X - location.X; + double dz = target.Z - location.Z; + double dy = target.Y - location.Y; + double horizDistSq = dx * dx + dz * dz; - // Need a first target from a fresh path - if (pathTarget is null && path is not null && path.Count > 0) + bool isVerticalWaypoint = horizDistSq < 0.5 && Math.Abs(dy) > 0.3; + if (isVerticalWaypoint) + { + var next = path.Peek(); + double ndx = next.X - target.X; + double ndz = next.Z - target.Z; + bool nextIsHorizontal = ndx * ndx + ndz * ndz > 0.3; + + // Skip to next waypoint early if we're within 1 block of the target Y + // and the next move requires horizontal movement + if (nextIsHorizontal && Math.Abs(dy) < 1.0) + { + AdvanceWaypoint(); + } + } + } + + SetInputToward(pathTarget.Value); + } + } + + private void AdvanceWaypoint() + { + if (path is not null && path.Count > 0) { pathTarget = path.Dequeue(); if (Config.Main.Advanced.MoveHeadWhileWalking) UpdateLocation(location, pathTarget.Value + new Location(0, 1, 0)); } - - if (pathTarget is not null) + else { - SetInputToward(pathTarget.Value); + pathTarget = null; + path = null; } } @@ -3363,7 +3388,13 @@ namespace MinecraftClient return; } - if (distSqr < 0.01) return; + if (distSqr < 0.01) + { + // Vertically aligned but need to reach different Y: set Jump when on ground + if (dy > 0.3 && playerPhysics.OnGround) + physicsInput.Jump = true; + return; + } // Calculate yaw to face target float targetYaw = (float)(-Math.Atan2(dx, dz) / Math.PI * 180.0); From 4b491351078bfa6141f1d0c02e2fbfe0332c1773 Mon Sep 17 00:00:00 2001 From: BruceChen Date: Sat, 11 Apr 2026 12:46:38 +0800 Subject: [PATCH 08/37] feat: add parkour moves and template-based path execution system Phase 2.2: MoveParkour for sprint-jump across 1-2 block gaps (distance 2-3) and ascending parkour (distance 2, +1Y). Registered in BuildDefaultMoves with CalculationContext.AllowParkour gating. Phase 3.1-3.2: Template execution engine replacing the waypoint queue system. - IActionTemplate interface with per-tick state machine pattern - Templates: Walk, Ascend, Descend, Climb, Fall, SprintJump - ActionTemplateFactory maps MoveType to the correct template - PathExecutor drives sequential template execution with logging - PathSegmentManager handles replanning on failure (up to 5 retries) - McClient integration: MoveToAStar now creates PathSegmentManager, UpdatePathfindingInput delegates to it, CancelMovement/ClientIsMoving updated for both old and new systems. Tested on 1.21.11: straight walk, zigzag maze, stair ascent, 1-gap and 2-gap sprint jumps all pass. Made-with: Cursor --- MinecraftClient/McClient.cs | 61 +++++---- .../Pathing/Core/AStarPathFinder.cs | 13 ++ .../Execution/ActionTemplateFactory.cs | 27 ++++ .../Pathing/Execution/IActionTemplate.cs | 25 ++++ .../Pathing/Execution/PathExecutor.cs | 87 +++++++++++++ .../Pathing/Execution/PathSegment.cs | 33 +++++ .../Pathing/Execution/PathSegmentManager.cs | 120 ++++++++++++++++++ .../Execution/Templates/AscendTemplate.cs | 59 +++++++++ .../Execution/Templates/ClimbTemplate.cs | 63 +++++++++ .../Execution/Templates/DescendTemplate.cs | 55 ++++++++ .../Execution/Templates/FallTemplate.cs | 42 ++++++ .../Execution/Templates/SprintJumpTemplate.cs | 79 ++++++++++++ .../Execution/Templates/TemplateHelper.cs | 31 +++++ .../Execution/Templates/WalkTemplate.cs | 51 ++++++++ .../Pathing/Moves/Impl/MoveParkour.cs | 113 +++++++++++++++++ 15 files changed, 833 insertions(+), 26 deletions(-) create mode 100644 MinecraftClient/Pathing/Execution/ActionTemplateFactory.cs create mode 100644 MinecraftClient/Pathing/Execution/IActionTemplate.cs create mode 100644 MinecraftClient/Pathing/Execution/PathExecutor.cs create mode 100644 MinecraftClient/Pathing/Execution/PathSegment.cs create mode 100644 MinecraftClient/Pathing/Execution/PathSegmentManager.cs create mode 100644 MinecraftClient/Pathing/Execution/Templates/AscendTemplate.cs create mode 100644 MinecraftClient/Pathing/Execution/Templates/ClimbTemplate.cs create mode 100644 MinecraftClient/Pathing/Execution/Templates/DescendTemplate.cs create mode 100644 MinecraftClient/Pathing/Execution/Templates/FallTemplate.cs create mode 100644 MinecraftClient/Pathing/Execution/Templates/SprintJumpTemplate.cs create mode 100644 MinecraftClient/Pathing/Execution/Templates/TemplateHelper.cs create mode 100644 MinecraftClient/Pathing/Execution/Templates/WalkTemplate.cs create mode 100644 MinecraftClient/Pathing/Moves/Impl/MoveParkour.cs diff --git a/MinecraftClient/McClient.cs b/MinecraftClient/McClient.cs index cb9a8fe5..cb52b7ff 100644 --- a/MinecraftClient/McClient.cs +++ b/MinecraftClient/McClient.cs @@ -80,6 +80,7 @@ namespace MinecraftClient private readonly MovementInput physicsInput = new(); private bool physicsInitialized = false; private Location? pathTarget; // Current waypoint for physics-driven pathfinding + private Pathing.Execution.PathSegmentManager? pathSegmentManager; public enum MovementType { Sneak, Walk, Sprint } private int sequenceId; // User for player block synchronization (Aka. digging, placing blocks, etc..) private bool CanSendMessage = false; @@ -1723,7 +1724,8 @@ namespace MinecraftClient { lock (locationLock) { - var ctx = new Pathing.Core.CalculationContext(world); + var ctx = new Pathing.Core.CalculationContext(world, + allowParkour: true, allowParkourAscend: true); var finder = new Pathing.Core.AStarPathFinder(); finder.DebugLog = msg => Log.Debug(msg); @@ -1731,7 +1733,6 @@ namespace MinecraftClient int sy = (int)Math.Floor(location.Y); int sz = (int)Math.Floor(location.Z); - // If floored Y lands inside a solid block (e.g. player on top of it), step up if (!ctx.CanWalkThrough(sx, sy, sz) && ctx.CanWalkThrough(sx, sy + 1, sz)) sy++; @@ -1746,8 +1747,8 @@ namespace MinecraftClient $"[raw pos=({location.X:F2},{location.Y:F2},{location.Z:F2})]"); using var cts = new CancellationTokenSource(); - var result = finder.Calculate(ctx, sx, sy, sz, - new Pathing.Goals.GoalBlock(gx, gy, gz), cts.Token, timeoutMs); + var pathGoal = new Pathing.Goals.GoalBlock(gx, gy, gz); + var result = finder.Calculate(ctx, sx, sy, sz, pathGoal, cts.Token, timeoutMs); Log.Info($"[Goto] A* result: {result.Status}, nodes={result.NodesExplored}, " + $"time={result.ElapsedMs}ms, path length={result.Path.Count}"); @@ -1758,27 +1759,23 @@ namespace MinecraftClient result.NodesExplored, result.ElapsedMs)); } - var queue = new Queue(); for (int i = 1; i < result.Path.Count; i++) { var node = result.Path[i]; - queue.Enqueue(new Location(node.X + 0.5, node.Y, node.Z + 0.5)); - } - - Log.Info($"[Goto] Path waypoints: {queue.Count}"); - int logCount = 0; - foreach (var wp in queue) - { - Log.Debug($"[Goto] wp[{logCount}] = ({wp.X:F1},{wp.Y:F1},{wp.Z:F1})"); - logCount++; + Log.Debug($"[Goto] seg[{i - 1}] = {node.MoveUsed}: ({node.X},{node.Y},{node.Z})"); } pathTarget = null; - path = queue; + path = null; + + pathSegmentManager = new Pathing.Execution.PathSegmentManager( + debugLog: msg => Log.Debug(msg), + infoLog: msg => Log.Info(msg)); + pathSegmentManager.StartNavigation(pathGoal, result); string statusStr = result.Status == Pathing.Core.PathStatus.Partial ? " (partial)" : ""; return (true, string.Format(Translations.cmd_goto_success, - queue.Count, result.NodesExplored, result.ElapsedMs, statusStr)); + result.Path.Count - 1, result.NodesExplored, result.ElapsedMs, statusStr)); } } @@ -3261,26 +3258,30 @@ namespace MinecraftClient } /// - /// Drive the physics engine input based on the current A* path. - /// Converts discrete waypoint pathfinding into continuous movement input. + /// Drive the physics engine input based on the current path. + /// Uses template-based PathSegmentManager when available, falls back to legacy waypoints. /// private void UpdatePathfindingInput() { physicsInput.Reset(); - // Advance waypoints when reached + // Template-based execution (new system) + if (pathSegmentManager is not null && pathSegmentManager.IsNavigating) + { + pathSegmentManager.Tick(location, playerPhysics, physicsInput, world); + playerYaw = playerPhysics.Yaw; + return; + } + + // Legacy waypoint-based execution if (pathTarget is not null && ReachedWaypoint(pathTarget.Value)) AdvanceWaypoint(); - // First target from a fresh path if (pathTarget is null && path is not null && path.Count > 0) AdvanceWaypoint(); if (pathTarget is not null) { - // Look-ahead: if this is a vertical-only waypoint and the next requires - // horizontal movement, merge them once we're close enough vertically. - // This handles the ladder-to-platform transition. if (path is not null && path.Count > 0) { var target = pathTarget.Value; @@ -3297,8 +3298,6 @@ namespace MinecraftClient double ndz = next.Z - target.Z; bool nextIsHorizontal = ndx * ndx + ndz * ndz > 0.3; - // Skip to next waypoint early if we're within 1 block of the target Y - // and the next move requires horizontal movement if (nextIsHorizontal && Math.Abs(dy) < 1.0) { AdvanceWaypoint(); @@ -3421,7 +3420,14 @@ namespace MinecraftClient /// true if a movement is currently handled public bool ClientIsMoving() { - return terrainAndMovementsEnabled && locationReceived && path is not null && path.Count > 0; + if (terrainAndMovementsEnabled && locationReceived) + { + if (pathSegmentManager is not null && pathSegmentManager.IsNavigating) + return true; + if (path is not null && path.Count > 0) + return true; + } + return false; } /// @@ -3441,6 +3447,9 @@ namespace MinecraftClient { bool success = ClientIsMoving(); path = null; + pathTarget = null; + pathSegmentManager?.Cancel(); + pathSegmentManager = null; return success; } diff --git a/MinecraftClient/Pathing/Core/AStarPathFinder.cs b/MinecraftClient/Pathing/Core/AStarPathFinder.cs index a4c68b93..fa2e1432 100644 --- a/MinecraftClient/Pathing/Core/AStarPathFinder.cs +++ b/MinecraftClient/Pathing/Core/AStarPathFinder.cs @@ -49,6 +49,19 @@ namespace MinecraftClient.Pathing.Core moves.Add(new MoveFall()); + foreach (int dx in offsets) + { + for (int dist = 2; dist <= 3; dist++) + moves.Add(new MoveParkour(dx, 0, dist)); + moves.Add(new MoveParkour(dx, 0, 2, yDelta: 1)); + } + foreach (int dz in offsets) + { + for (int dist = 2; dist <= 3; dist++) + moves.Add(new MoveParkour(0, dz, dist)); + moves.Add(new MoveParkour(0, dz, 2, yDelta: 1)); + } + return [.. moves]; } diff --git a/MinecraftClient/Pathing/Execution/ActionTemplateFactory.cs b/MinecraftClient/Pathing/Execution/ActionTemplateFactory.cs new file mode 100644 index 00000000..eff8aa42 --- /dev/null +++ b/MinecraftClient/Pathing/Execution/ActionTemplateFactory.cs @@ -0,0 +1,27 @@ +using System; +using MinecraftClient.Pathing.Core; +using MinecraftClient.Pathing.Execution.Templates; + +namespace MinecraftClient.Pathing.Execution +{ + /// + /// Maps a PathSegment (MoveType + start/end) to the appropriate IActionTemplate. + /// + public static class ActionTemplateFactory + { + public static IActionTemplate Create(PathSegment segment) + { + return segment.MoveType switch + { + MoveType.Traverse => new WalkTemplate(segment.Start, segment.End), + MoveType.Diagonal => new WalkTemplate(segment.Start, segment.End), + MoveType.Ascend => new AscendTemplate(segment.Start, segment.End), + MoveType.Descend => new DescendTemplate(segment.Start, segment.End), + MoveType.Fall => new FallTemplate(segment.Start, segment.End), + MoveType.Climb => new ClimbTemplate(segment.Start, segment.End), + MoveType.Parkour => new SprintJumpTemplate(segment.Start, segment.End), + _ => throw new ArgumentException($"Unknown MoveType: {segment.MoveType}") + }; + } + } +} diff --git a/MinecraftClient/Pathing/Execution/IActionTemplate.cs b/MinecraftClient/Pathing/Execution/IActionTemplate.cs new file mode 100644 index 00000000..dac2c44c --- /dev/null +++ b/MinecraftClient/Pathing/Execution/IActionTemplate.cs @@ -0,0 +1,25 @@ +using MinecraftClient.Mapping; +using MinecraftClient.Physics; + +namespace MinecraftClient.Pathing.Execution +{ + public enum TemplateState + { + InProgress, + Complete, + Failed + } + + /// + /// Per-tick movement controller for one path segment. + /// Reads player state from physics, writes desired input to MovementInput, + /// and reports completion or failure. + /// + public interface IActionTemplate + { + Location ExpectedStart { get; } + Location ExpectedEnd { get; } + + TemplateState Tick(Location currentPos, PlayerPhysics physics, MovementInput input); + } +} diff --git a/MinecraftClient/Pathing/Execution/PathExecutor.cs b/MinecraftClient/Pathing/Execution/PathExecutor.cs new file mode 100644 index 00000000..78270ee5 --- /dev/null +++ b/MinecraftClient/Pathing/Execution/PathExecutor.cs @@ -0,0 +1,87 @@ +using System; +using System.Collections.Generic; +using MinecraftClient.Mapping; +using MinecraftClient.Physics; + +namespace MinecraftClient.Pathing.Execution +{ + public enum PathExecutorState + { + InProgress, + Failed, + Complete + } + + /// + /// Drives a sequence of PathSegments by instantiating the correct IActionTemplate + /// for each segment and ticking it every game tick. + /// + public sealed class PathExecutor + { + private readonly List _segments; + private int _currentIndex; + private IActionTemplate? _currentTemplate; + private readonly Action? _debugLog; + + public bool IsComplete => _currentIndex >= _segments.Count && _currentTemplate is null; + public int CurrentIndex => _currentIndex; + public int TotalSegments => _segments.Count; + public PathSegment? CurrentSegment => + _currentIndex < _segments.Count ? _segments[_currentIndex] : null; + + public PathExecutor(List segments, Action? debugLog = null) + { + _segments = segments; + _currentIndex = 0; + _debugLog = debugLog; + AdvanceToNextSegment(); + } + + public PathExecutorState Tick(Location pos, PlayerPhysics physics, MovementInput input) + { + if (_currentTemplate is null) + return PathExecutorState.Complete; + + var state = _currentTemplate.Tick(pos, physics, input); + + switch (state) + { + case TemplateState.Complete: + _debugLog?.Invoke($"[PathExec] Segment {_currentIndex} complete " + + $"({_segments[_currentIndex].MoveType}) at ({pos.X:F2},{pos.Y:F2},{pos.Z:F2})"); + _currentIndex++; + if (_currentIndex >= _segments.Count) + { + _currentTemplate = null; + _debugLog?.Invoke("[PathExec] All segments complete!"); + return PathExecutorState.Complete; + } + AdvanceToNextSegment(); + return PathExecutorState.InProgress; + + case TemplateState.Failed: + _debugLog?.Invoke($"[PathExec] Segment {_currentIndex} FAILED " + + $"({_segments[_currentIndex].MoveType}) at ({pos.X:F2},{pos.Y:F2},{pos.Z:F2}), " + + $"target was ({_currentTemplate.ExpectedEnd.X:F2},{_currentTemplate.ExpectedEnd.Y:F2},{_currentTemplate.ExpectedEnd.Z:F2})"); + return PathExecutorState.Failed; + + default: + return PathExecutorState.InProgress; + } + } + + private void AdvanceToNextSegment() + { + if (_currentIndex < _segments.Count) + { + var seg = _segments[_currentIndex]; + _currentTemplate = ActionTemplateFactory.Create(seg); + _debugLog?.Invoke($"[PathExec] Starting segment {_currentIndex}/{_segments.Count}: {seg}"); + } + else + { + _currentTemplate = null; + } + } + } +} diff --git a/MinecraftClient/Pathing/Execution/PathSegment.cs b/MinecraftClient/Pathing/Execution/PathSegment.cs new file mode 100644 index 00000000..ec3f0a76 --- /dev/null +++ b/MinecraftClient/Pathing/Execution/PathSegment.cs @@ -0,0 +1,33 @@ +using System.Collections.Generic; +using MinecraftClient.Mapping; +using MinecraftClient.Pathing.Core; + +namespace MinecraftClient.Pathing.Execution +{ + public sealed class PathSegment + { + public required Location Start { get; init; } + public required Location End { get; init; } + public required MoveType MoveType { get; init; } + + public static List FromPath(IReadOnlyList nodes) + { + var segments = new List(nodes.Count - 1); + for (int i = 1; i < nodes.Count; i++) + { + var prev = nodes[i - 1]; + var curr = nodes[i]; + segments.Add(new PathSegment + { + Start = new Location(prev.X + 0.5, prev.Y, prev.Z + 0.5), + End = new Location(curr.X + 0.5, curr.Y, curr.Z + 0.5), + MoveType = curr.MoveUsed + }); + } + return segments; + } + + public override string ToString() => + $"{MoveType}: ({Start.X:F1},{Start.Y:F1},{Start.Z:F1})->({End.X:F1},{End.Y:F1},{End.Z:F1})"; + } +} diff --git a/MinecraftClient/Pathing/Execution/PathSegmentManager.cs b/MinecraftClient/Pathing/Execution/PathSegmentManager.cs new file mode 100644 index 00000000..c6492cc0 --- /dev/null +++ b/MinecraftClient/Pathing/Execution/PathSegmentManager.cs @@ -0,0 +1,120 @@ +using System; +using System.Threading; +using MinecraftClient.Mapping; +using MinecraftClient.Pathing.Core; +using MinecraftClient.Pathing.Goals; +using MinecraftClient.Physics; + +namespace MinecraftClient.Pathing.Execution +{ + /// + /// Top-level navigation controller. Holds a PathExecutor, monitors its progress, + /// and triggers replanning on failure or deviation. + /// + public sealed class PathSegmentManager + { + private PathExecutor? _executor; + private IGoal? _goal; + private int _replanCount; + private const int MaxReplans = 5; + + private readonly Action? _debugLog; + private readonly Action? _infoLog; + + public bool IsNavigating => _executor is not null && !_executor.IsComplete; + public int ReplanCount => _replanCount; + + public PathSegmentManager(Action? debugLog = null, Action? infoLog = null) + { + _debugLog = debugLog; + _infoLog = infoLog; + } + + public void StartNavigation(IGoal goal, PathResult result) + { + _goal = goal; + _replanCount = 0; + var segments = PathSegment.FromPath(result.Path); + _executor = new PathExecutor(segments, _debugLog); + _infoLog?.Invoke($"[PathMgr] Navigation started: {segments.Count} segments"); + } + + public void Tick(Location pos, PlayerPhysics physics, MovementInput input, World world) + { + if (_executor is null) + return; + + var state = _executor.Tick(pos, physics, input); + + switch (state) + { + case PathExecutorState.Complete: + _infoLog?.Invoke("[PathMgr] Navigation complete!"); + _executor = null; + _goal = null; + break; + + case PathExecutorState.Failed: + _infoLog?.Invoke("[PathMgr] Segment failed, replanning..."); + Replan(pos, world); + break; + } + } + + public void Cancel() + { + if (_executor is not null) + { + _infoLog?.Invoke("[PathMgr] Navigation cancelled."); + _executor = null; + _goal = null; + } + } + + private void Replan(Location pos, World world) + { + _replanCount++; + if (_replanCount > MaxReplans) + { + _infoLog?.Invoke($"[PathMgr] Giving up after {MaxReplans} replans."); + _executor = null; + _goal = null; + return; + } + + if (_goal is null) + { + _executor = null; + return; + } + + _debugLog?.Invoke($"[PathMgr] Replan #{_replanCount} from ({pos.X:F2},{pos.Y:F2},{pos.Z:F2})"); + + var ctx = new CalculationContext(world, allowParkour: true, allowParkourAscend: true); + var finder = new AStarPathFinder(); + finder.DebugLog = _debugLog; + + int sx = (int)Math.Floor(pos.X); + int sy = (int)Math.Floor(pos.Y); + int sz = (int)Math.Floor(pos.Z); + + if (!ctx.CanWalkThrough(sx, sy, sz) && ctx.CanWalkThrough(sx, sy + 1, sz)) + sy++; + + using var cts = new CancellationTokenSource(); + var result = finder.Calculate(ctx, sx, sy, sz, _goal, cts.Token, 3000); + + if (result.Status == PathStatus.Failed || result.Path.Count < 2) + { + _infoLog?.Invoke("[PathMgr] Replan failed -- no path found."); + _executor = null; + _goal = null; + return; + } + + var segments = PathSegment.FromPath(result.Path); + _executor = new PathExecutor(segments, _debugLog); + _infoLog?.Invoke($"[PathMgr] Replanned: {segments.Count} segments (replan #{_replanCount})"); + } + } +} diff --git a/MinecraftClient/Pathing/Execution/Templates/AscendTemplate.cs b/MinecraftClient/Pathing/Execution/Templates/AscendTemplate.cs new file mode 100644 index 00000000..393cfb66 --- /dev/null +++ b/MinecraftClient/Pathing/Execution/Templates/AscendTemplate.cs @@ -0,0 +1,59 @@ +using System; +using MinecraftClient.Mapping; +using MinecraftClient.Physics; + +namespace MinecraftClient.Pathing.Execution.Templates +{ + /// + /// Jump up 1 block while moving 1 block in a cardinal direction. + /// Faces destination, sprints forward, and jumps when on ground. + /// + public sealed class AscendTemplate : IActionTemplate + { + public Location ExpectedStart { get; } + public Location ExpectedEnd { get; } + + private int _tickCount; + private Location _lastPos; + private int _stuckTicks; + + public AscendTemplate(Location start, Location end) + { + ExpectedStart = start; + ExpectedEnd = end; + _lastPos = start; + } + + public TemplateState Tick(Location pos, PlayerPhysics physics, MovementInput input) + { + _tickCount++; + + double dx = ExpectedEnd.X - pos.X; + double dz = ExpectedEnd.Z - pos.Z; + double dy = ExpectedEnd.Y - pos.Y; + double horizDistSq = dx * dx + dz * dz; + + // Complete when close to destination. Sprint bouncing can leave the player + // slightly above ground, so we don't require OnGround here. + if (horizDistSq < 0.25 && Math.Abs(dy) < 0.8) + return TemplateState.Complete; + + double movedSq = TemplateHelper.HorizontalDistanceSq(pos, _lastPos); + double movedY = Math.Abs(pos.Y - _lastPos.Y); + _stuckTicks = (movedSq < 0.0005 && movedY < 0.001) ? _stuckTicks + 1 : 0; + _lastPos = pos; + + if (_stuckTicks > 40 || _tickCount > 80) + return TemplateState.Failed; + + physics.Yaw = TemplateHelper.CalculateYaw(dx, dz); + input.Forward = true; + input.Sprint = true; + + if (physics.OnGround && dy > 0.1) + input.Jump = true; + + return TemplateState.InProgress; + } + } +} diff --git a/MinecraftClient/Pathing/Execution/Templates/ClimbTemplate.cs b/MinecraftClient/Pathing/Execution/Templates/ClimbTemplate.cs new file mode 100644 index 00000000..bb7d810f --- /dev/null +++ b/MinecraftClient/Pathing/Execution/Templates/ClimbTemplate.cs @@ -0,0 +1,63 @@ +using System; +using MinecraftClient.Mapping; +using MinecraftClient.Physics; + +namespace MinecraftClient.Pathing.Execution.Templates +{ + /// + /// Climb up or down a ladder/vine by 1 block. + /// Pushes against the wall (Forward + face center) and jumps for upward movement. + /// + public sealed class ClimbTemplate : IActionTemplate + { + public Location ExpectedStart { get; } + public Location ExpectedEnd { get; } + + private int _tickCount; + + public ClimbTemplate(Location start, Location end) + { + ExpectedStart = start; + ExpectedEnd = end; + } + + public TemplateState Tick(Location pos, PlayerPhysics physics, MovementInput input) + { + _tickCount++; + + double dy = ExpectedEnd.Y - pos.Y; + double dx = ExpectedEnd.X - pos.X; + double dz = ExpectedEnd.Z - pos.Z; + double horizDistSq = dx * dx + dz * dz; + + if (Math.Abs(dy) < 0.3 && horizDistSq < 0.5) + return TemplateState.Complete; + + if (_tickCount > 100) + return TemplateState.Failed; + + if (physics.OnClimbable) + { + if (dy > 0) + { + input.Jump = true; + input.Forward = true; + if (horizDistSq > 0.01) + physics.Yaw = TemplateHelper.CalculateYaw(dx, dz); + } + // Going down: don't press anything, gravity + climbable friction handles it + } + else + { + // Left the climbable area -- walk toward destination + if (horizDistSq > 0.01) + { + physics.Yaw = TemplateHelper.CalculateYaw(dx, dz); + input.Forward = true; + } + } + + return TemplateState.InProgress; + } + } +} diff --git a/MinecraftClient/Pathing/Execution/Templates/DescendTemplate.cs b/MinecraftClient/Pathing/Execution/Templates/DescendTemplate.cs new file mode 100644 index 00000000..26f86f6c --- /dev/null +++ b/MinecraftClient/Pathing/Execution/Templates/DescendTemplate.cs @@ -0,0 +1,55 @@ +using System; +using MinecraftClient.Mapping; +using MinecraftClient.Physics; + +namespace MinecraftClient.Pathing.Execution.Templates +{ + /// + /// Walk off a ledge and drop 1-N blocks to a landing spot. + /// Walks toward the destination; gravity handles the fall. + /// + public sealed class DescendTemplate : IActionTemplate + { + public Location ExpectedStart { get; } + public Location ExpectedEnd { get; } + + private int _tickCount; + private bool _hasFallen; + + public DescendTemplate(Location start, Location end) + { + ExpectedStart = start; + ExpectedEnd = end; + } + + public TemplateState Tick(Location pos, PlayerPhysics physics, MovementInput input) + { + _tickCount++; + + double dx = ExpectedEnd.X - pos.X; + double dz = ExpectedEnd.Z - pos.Z; + double dy = ExpectedEnd.Y - pos.Y; + double horizDistSq = dx * dx + dz * dz; + + if (!physics.OnGround) + _hasFallen = true; + + if (_hasFallen && physics.OnGround && horizDistSq < 0.5 && Math.Abs(dy) < 0.8) + return TemplateState.Complete; + + if (horizDistSq < 0.25 && Math.Abs(dy) < 0.5 && physics.OnGround) + return TemplateState.Complete; + + if (_tickCount > 120) + return TemplateState.Failed; + + if (horizDistSq > 0.01) + { + physics.Yaw = TemplateHelper.CalculateYaw(dx, dz); + input.Forward = true; + } + + return TemplateState.InProgress; + } + } +} diff --git a/MinecraftClient/Pathing/Execution/Templates/FallTemplate.cs b/MinecraftClient/Pathing/Execution/Templates/FallTemplate.cs new file mode 100644 index 00000000..47e640a0 --- /dev/null +++ b/MinecraftClient/Pathing/Execution/Templates/FallTemplate.cs @@ -0,0 +1,42 @@ +using System; +using MinecraftClient.Mapping; +using MinecraftClient.Physics; + +namespace MinecraftClient.Pathing.Execution.Templates +{ + /// + /// Vertical free fall at the same X,Z. Waits for the player to land at the target Y. + /// + public sealed class FallTemplate : IActionTemplate + { + public Location ExpectedStart { get; } + public Location ExpectedEnd { get; } + + private int _tickCount; + private bool _hasFallen; + + public FallTemplate(Location start, Location end) + { + ExpectedStart = start; + ExpectedEnd = end; + } + + public TemplateState Tick(Location pos, PlayerPhysics physics, MovementInput input) + { + _tickCount++; + + double dy = pos.Y - ExpectedEnd.Y; + + if (!physics.OnGround) + _hasFallen = true; + + if (_hasFallen && physics.OnGround && Math.Abs(dy) < 1.0) + return TemplateState.Complete; + + if (_tickCount > 200) + return TemplateState.Failed; + + return TemplateState.InProgress; + } + } +} diff --git a/MinecraftClient/Pathing/Execution/Templates/SprintJumpTemplate.cs b/MinecraftClient/Pathing/Execution/Templates/SprintJumpTemplate.cs new file mode 100644 index 00000000..f531ff9b --- /dev/null +++ b/MinecraftClient/Pathing/Execution/Templates/SprintJumpTemplate.cs @@ -0,0 +1,79 @@ +using System; +using MinecraftClient.Mapping; +using MinecraftClient.Physics; + +namespace MinecraftClient.Pathing.Execution.Templates +{ + /// + /// Sprint-jump across a gap. Uses a phase-based state machine: + /// Approach -> jump on first available ground tick -> Airborne -> Landing check. + /// + public sealed class SprintJumpTemplate : IActionTemplate + { + private enum Phase { Approach, Airborne, Landing } + + public Location ExpectedStart { get; } + public Location ExpectedEnd { get; } + + private int _tickCount; + private Phase _phase = Phase.Approach; + private readonly int _distance; + + public SprintJumpTemplate(Location start, Location end) + { + ExpectedStart = start; + ExpectedEnd = end; + + double dx = Math.Abs(end.X - start.X); + double dz = Math.Abs(end.Z - start.Z); + _distance = (int)Math.Round(Math.Max(dx, dz)); + } + + public TemplateState Tick(Location pos, PlayerPhysics physics, MovementInput input) + { + _tickCount++; + + double dx = ExpectedEnd.X - pos.X; + double dz = ExpectedEnd.Z - pos.Z; + double dy = ExpectedEnd.Y - pos.Y; + double horizDistSq = dx * dx + dz * dz; + + physics.Yaw = TemplateHelper.CalculateYaw(dx, dz); + input.Forward = true; + input.Sprint = true; + + switch (_phase) + { + case Phase.Approach: + if (physics.OnGround) + { + input.Jump = true; + _phase = Phase.Airborne; + } + if (_tickCount > 20) + return TemplateState.Failed; + break; + + case Phase.Airborne: + if (!physics.OnGround) + break; + // Landed + _phase = Phase.Landing; + goto case Phase.Landing; + + case Phase.Landing: + if (horizDistSq < 2.0 && Math.Abs(dy) < 1.0) + return TemplateState.Complete; + return TemplateState.Failed; + } + + if (pos.Y < ExpectedEnd.Y - 4.0) + return TemplateState.Failed; + + if (_tickCount > 60) + return TemplateState.Failed; + + return TemplateState.InProgress; + } + } +} diff --git a/MinecraftClient/Pathing/Execution/Templates/TemplateHelper.cs b/MinecraftClient/Pathing/Execution/Templates/TemplateHelper.cs new file mode 100644 index 00000000..18a67a66 --- /dev/null +++ b/MinecraftClient/Pathing/Execution/Templates/TemplateHelper.cs @@ -0,0 +1,31 @@ +using System; +using MinecraftClient.Mapping; + +namespace MinecraftClient.Pathing.Execution.Templates +{ + internal static class TemplateHelper + { + internal static float CalculateYaw(double dx, double dz) + { + float yaw = (float)(-Math.Atan2(dx, dz) / Math.PI * 180.0); + if (yaw < 0) yaw += 360; + return yaw; + } + + internal static double HorizontalDistanceSq(Location a, Location b) + { + double dx = a.X - b.X; + double dz = a.Z - b.Z; + return dx * dx + dz * dz; + } + + internal static bool IsNear(Location pos, Location target, + double horizThresholdSq = 0.25, double vertThreshold = 0.8) + { + double dx = target.X - pos.X; + double dz = target.Z - pos.Z; + double dy = target.Y - pos.Y; + return dx * dx + dz * dz < horizThresholdSq && Math.Abs(dy) < vertThreshold; + } + } +} diff --git a/MinecraftClient/Pathing/Execution/Templates/WalkTemplate.cs b/MinecraftClient/Pathing/Execution/Templates/WalkTemplate.cs new file mode 100644 index 00000000..9c9f430c --- /dev/null +++ b/MinecraftClient/Pathing/Execution/Templates/WalkTemplate.cs @@ -0,0 +1,51 @@ +using System; +using MinecraftClient.Mapping; +using MinecraftClient.Physics; + +namespace MinecraftClient.Pathing.Execution.Templates +{ + /// + /// Walk/sprint toward a destination on the same Y level. + /// Used for Traverse and Diagonal moves. + /// + public sealed class WalkTemplate : IActionTemplate + { + public Location ExpectedStart { get; } + public Location ExpectedEnd { get; } + + private int _tickCount; + private Location _lastPos; + private int _stuckTicks; + + public WalkTemplate(Location start, Location end) + { + ExpectedStart = start; + ExpectedEnd = end; + _lastPos = start; + } + + public TemplateState Tick(Location pos, PlayerPhysics physics, MovementInput input) + { + _tickCount++; + + if (TemplateHelper.IsNear(pos, ExpectedEnd, horizThresholdSq: 0.20)) + return TemplateState.Complete; + + double movedSq = TemplateHelper.HorizontalDistanceSq(pos, _lastPos); + _stuckTicks = movedSq < 0.0005 ? _stuckTicks + 1 : 0; + _lastPos = pos; + + if (_stuckTicks > 40 || _tickCount > 100) + return TemplateState.Failed; + + double dx = ExpectedEnd.X - pos.X; + double dz = ExpectedEnd.Z - pos.Z; + physics.Yaw = TemplateHelper.CalculateYaw(dx, dz); + + input.Forward = true; + input.Sprint = true; + + return TemplateState.InProgress; + } + } +} diff --git a/MinecraftClient/Pathing/Moves/Impl/MoveParkour.cs b/MinecraftClient/Pathing/Moves/Impl/MoveParkour.cs new file mode 100644 index 00000000..599a5bf5 --- /dev/null +++ b/MinecraftClient/Pathing/Moves/Impl/MoveParkour.cs @@ -0,0 +1,113 @@ +using MinecraftClient.Pathing.Core; + +namespace MinecraftClient.Pathing.Moves.Impl +{ + /// + /// Sprint jump across a gap of 1-3 blocks (total distance 2-4 blocks forward). + /// Optionally ascends 1 block during the jump (distance 2 only). + /// Requires AllowParkour in context; the first block forward must lack ground. + /// + public sealed class MoveParkour : IMove + { + public MoveType Type => MoveType.Parkour; + public int XOffset { get; } + public int ZOffset { get; } + public bool DynamicY => false; + + private readonly int _distance; + private readonly int _yDelta; + private readonly int _xDir; + private readonly int _zDir; + + public MoveParkour(int xDir, int zDir, int distance, int yDelta = 0) + { + _xDir = xDir; + _zDir = zDir; + _distance = distance; + _yDelta = yDelta; + XOffset = xDir * distance; + ZOffset = zDir * distance; + } + + public void Calculate(CalculationContext ctx, int x, int y, int z, ref MoveResult result) + { + if (!ctx.AllowParkour) + { + result.SetImpossible(); + return; + } + + if (_yDelta > 0 && !ctx.AllowParkourAscend) + { + result.SetImpossible(); + return; + } + + if (!ctx.CanSprint) + { + result.SetImpossible(); + return; + } + + int destX = x + _xDir * _distance; + int destZ = z + _zDir * _distance; + int destY = y + _yDelta; + + if (!ctx.CanWalkThrough(x, y + 2, z)) + { + result.SetImpossible(); + return; + } + + if (!ctx.CanWalkOn(destX, destY - 1, destZ)) + { + result.SetImpossible(); + return; + } + + if (!ctx.CanWalkThrough(destX, destY, destZ) || + !ctx.CanWalkThrough(destX, destY + 1, destZ)) + { + result.SetImpossible(); + return; + } + + for (int i = 1; i < _distance; i++) + { + int gx = x + _xDir * i; + int gz = z + _zDir * i; + + if (!ctx.CanWalkThrough(gx, y, gz) || + !ctx.CanWalkThrough(gx, y + 1, gz) || + !ctx.CanWalkThrough(gx, y + 2, gz)) + { + result.SetImpossible(); + return; + } + + if (_yDelta > 0 && !ctx.CanWalkThrough(gx, y + 3, gz)) + { + result.SetImpossible(); + return; + } + } + + int firstGapX = x + _xDir; + int firstGapZ = z + _zDir; + if (ctx.CanWalkOn(firstGapX, y - 1, firstGapZ)) + { + result.SetImpossible(); + return; + } + + double cost = _distance * ctx.SprintCost + ctx.JumpPenalty; + if (_yDelta > 0) + cost += ctx.JumpPenalty; + + result.Set(destX, destY, destZ, cost); + } + + public override string ToString() => + $"MoveParkour(dir=({_xDir},{_zDir}), dist={_distance}, dy={_yDelta})"; + } +} From 034c5d0cabfb67dea0dc2d2eb3bcfa2fabaaa209 Mon Sep 17 00:00:00 2001 From: BruceChen Date: Sat, 11 Apr 2026 13:22:23 +0800 Subject: [PATCH 09/37] fix: correct collision axis ordering and step-up threshold to match vanilla Two bugs in CollisionDetector caused persistent Y-axis bouncing (0.6 block oscillation) while walking on flat ground: 1. GetAxisStepOrder used a complex 6-branch sorting that often placed horizontal axes before Y. Vanilla's Direction.Axis.axisStepOrder always resolves Y first, then the larger horizontal axis. Replaced with the simple two-case vanilla logic. 2. The horizontal-blocked checks (blockedX/blockedZ) used exact != which triggered on floating-point noise (~1e-15) from sin/cos in movement input. Vanilla uses Mth.equal (1e-5 threshold). This false positive caused step-up to fire every few ticks on flat terrain. Also includes DescendTemplate robustness fixes from the previous session (fail on unintended climbing, suppress forward input on climbable blocks). Made-with: Cursor --- .../Execution/Templates/DescendTemplate.cs | 7 ++++ MinecraftClient/Physics/CollisionDetector.cs | 33 +++++-------------- 2 files changed, 15 insertions(+), 25 deletions(-) diff --git a/MinecraftClient/Pathing/Execution/Templates/DescendTemplate.cs b/MinecraftClient/Pathing/Execution/Templates/DescendTemplate.cs index 26f86f6c..a11a0e70 100644 --- a/MinecraftClient/Pathing/Execution/Templates/DescendTemplate.cs +++ b/MinecraftClient/Pathing/Execution/Templates/DescendTemplate.cs @@ -40,6 +40,10 @@ namespace MinecraftClient.Pathing.Execution.Templates if (horizDistSq < 0.25 && Math.Abs(dy) < 0.5 && physics.OnGround) return TemplateState.Complete; + // Fail if climbing up instead of descending + if (pos.Y > ExpectedStart.Y + 2.0) + return TemplateState.Failed; + if (_tickCount > 120) return TemplateState.Failed; @@ -47,6 +51,9 @@ namespace MinecraftClient.Pathing.Execution.Templates { physics.Yaw = TemplateHelper.CalculateYaw(dx, dz); input.Forward = true; + // Don't push into climbable blocks during descent + if (physics.OnClimbable) + input.Forward = false; } return TemplateState.InProgress; diff --git a/MinecraftClient/Physics/CollisionDetector.cs b/MinecraftClient/Physics/CollisionDetector.cs index 0788e93b..8391dd64 100644 --- a/MinecraftClient/Physics/CollisionDetector.cs +++ b/MinecraftClient/Physics/CollisionDetector.cs @@ -23,8 +23,8 @@ namespace MinecraftClient.Physics var colliders = CollectBlockColliders(world, entityBox.ExpandTowards(movement)); Vec3d resolved = CollideWithShapes(movement, entityBox, colliders); - bool blockedX = movement.X != resolved.X; - bool blockedZ = movement.Z != resolved.Z; + bool blockedX = Math.Abs(movement.X - resolved.X) > 1.0E-5; + bool blockedZ = Math.Abs(movement.Z - resolved.Z) > 1.0E-5; bool blockedY = movement.Y != resolved.Y; bool hitGroundDuringMove = blockedY && movement.Y < 0.0; @@ -59,7 +59,7 @@ namespace MinecraftClient.Physics /// /// Collide movement against a list of shapes using axis-separated resolution. - /// Matches Entity.collideWithShapes() — processes axes in order of smallest movement first. + /// Matches Entity.collideWithShapes() with vanilla's axis ordering (Y first, then larger horizontal axis). /// private static Vec3d CollideWithShapes(Vec3d movement, Aabb entityBox, List colliders) { @@ -82,31 +82,14 @@ namespace MinecraftClient.Physics } /// - /// Get axis processing order: Y first if moving down, otherwise smallest absolute movement first. - /// Vanilla uses Direction.axisStepOrder(Vec3) which returns axes sorted by absolute movement. + /// Get axis processing order matching vanilla Direction.Axis.axisStepOrder(Vec3): + /// Y is always first, then the larger horizontal axis, then the smaller. /// private static int[] GetAxisStepOrder(Vec3d movement) { - double absX = Math.Abs(movement.X); - double absY = Math.Abs(movement.Y); - double absZ = Math.Abs(movement.Z); - - if (absX > absZ) - { - if (absZ > absY) - return new[] { 1, 2, 0 }; // Y Z X - if (absX > absY) - return new[] { 1, 0, 2 }; // Y X Z - return new[] { 0, 1, 2 }; // X Y Z - } - else - { - if (absX > absY) - return new[] { 1, 0, 2 }; // Y X Z - if (absZ > absY) - return new[] { 1, 2, 0 }; // Y Z X - return new[] { 2, 1, 0 }; // Z Y X - } + return Math.Abs(movement.X) < Math.Abs(movement.Z) + ? [1, 2, 0] // Y Z X + : [1, 0, 2]; // Y X Z } /// From a9c9a6a669b7d90c7ada268230931de1de4f2774 Mon Sep 17 00:00:00 2001 From: BruceChen Date: Sat, 11 Apr 2026 14:03:50 +0800 Subject: [PATCH 10/37] fix: set movement input before completion check to maintain sprint momentum Templates now set Forward/Sprint input before checking completion conditions. This prevents a 1-tick input gap during template transitions that caused the player to lose sprint speed, making parkour jumps fail due to insufficient horizontal velocity. Made-with: Cursor --- .../Execution/Templates/AscendTemplate.cs | 16 +++++++--------- .../Execution/Templates/SprintJumpTemplate.cs | 6 ------ .../Pathing/Execution/Templates/WalkTemplate.cs | 13 ++++++------- 3 files changed, 13 insertions(+), 22 deletions(-) diff --git a/MinecraftClient/Pathing/Execution/Templates/AscendTemplate.cs b/MinecraftClient/Pathing/Execution/Templates/AscendTemplate.cs index 393cfb66..8a96c041 100644 --- a/MinecraftClient/Pathing/Execution/Templates/AscendTemplate.cs +++ b/MinecraftClient/Pathing/Execution/Templates/AscendTemplate.cs @@ -33,8 +33,13 @@ namespace MinecraftClient.Pathing.Execution.Templates double dy = ExpectedEnd.Y - pos.Y; double horizDistSq = dx * dx + dz * dz; - // Complete when close to destination. Sprint bouncing can leave the player - // slightly above ground, so we don't require OnGround here. + physics.Yaw = TemplateHelper.CalculateYaw(dx, dz); + input.Forward = true; + input.Sprint = true; + + if (physics.OnGround && dy > 0.1) + input.Jump = true; + if (horizDistSq < 0.25 && Math.Abs(dy) < 0.8) return TemplateState.Complete; @@ -46,13 +51,6 @@ namespace MinecraftClient.Pathing.Execution.Templates if (_stuckTicks > 40 || _tickCount > 80) return TemplateState.Failed; - physics.Yaw = TemplateHelper.CalculateYaw(dx, dz); - input.Forward = true; - input.Sprint = true; - - if (physics.OnGround && dy > 0.1) - input.Jump = true; - return TemplateState.InProgress; } } diff --git a/MinecraftClient/Pathing/Execution/Templates/SprintJumpTemplate.cs b/MinecraftClient/Pathing/Execution/Templates/SprintJumpTemplate.cs index f531ff9b..ed8626cc 100644 --- a/MinecraftClient/Pathing/Execution/Templates/SprintJumpTemplate.cs +++ b/MinecraftClient/Pathing/Execution/Templates/SprintJumpTemplate.cs @@ -17,16 +17,11 @@ namespace MinecraftClient.Pathing.Execution.Templates private int _tickCount; private Phase _phase = Phase.Approach; - private readonly int _distance; public SprintJumpTemplate(Location start, Location end) { ExpectedStart = start; ExpectedEnd = end; - - double dx = Math.Abs(end.X - start.X); - double dz = Math.Abs(end.Z - start.Z); - _distance = (int)Math.Round(Math.Max(dx, dz)); } public TemplateState Tick(Location pos, PlayerPhysics physics, MovementInput input) @@ -57,7 +52,6 @@ namespace MinecraftClient.Pathing.Execution.Templates case Phase.Airborne: if (!physics.OnGround) break; - // Landed _phase = Phase.Landing; goto case Phase.Landing; diff --git a/MinecraftClient/Pathing/Execution/Templates/WalkTemplate.cs b/MinecraftClient/Pathing/Execution/Templates/WalkTemplate.cs index 9c9f430c..8711d4b5 100644 --- a/MinecraftClient/Pathing/Execution/Templates/WalkTemplate.cs +++ b/MinecraftClient/Pathing/Execution/Templates/WalkTemplate.cs @@ -28,6 +28,12 @@ namespace MinecraftClient.Pathing.Execution.Templates { _tickCount++; + double dx = ExpectedEnd.X - pos.X; + double dz = ExpectedEnd.Z - pos.Z; + physics.Yaw = TemplateHelper.CalculateYaw(dx, dz); + input.Forward = true; + input.Sprint = true; + if (TemplateHelper.IsNear(pos, ExpectedEnd, horizThresholdSq: 0.20)) return TemplateState.Complete; @@ -38,13 +44,6 @@ namespace MinecraftClient.Pathing.Execution.Templates if (_stuckTicks > 40 || _tickCount > 100) return TemplateState.Failed; - double dx = ExpectedEnd.X - pos.X; - double dz = ExpectedEnd.Z - pos.Z; - physics.Yaw = TemplateHelper.CalculateYaw(dx, dz); - - input.Forward = true; - input.Sprint = true; - return TemplateState.InProgress; } } From 53082d387eb9ded38717052d3e986b0f97663a05 Mon Sep 17 00:00:00 2001 From: BruceChen Date: Sat, 11 Apr 2026 14:45:54 +0800 Subject: [PATCH 11/37] feat: add 4-block jumps, diagonal parkour, high-fall water/ladder support MoveParkour rewritten to support both cardinal and diagonal sprint jumps with unified (xOff, zOff) interface. New capabilities: - 4-block cardinal sprint jumps with edge-approach timing in template - Diagonal parkour: (2,1), (1,2), (2,2), (3,1), (1,3) in all quadrants - Ascending parkour extended to dist=3 (cardinal) - Overshoot safety check after landing destination - Block parkour from climbable starting blocks (vine/ladder) MoveDescend/MoveFall enhanced with Baritone-style dynamic fall scanning: - Water landing: accepts falls of any height into water - Mid-fall ladder/vine grab: resets effective fall height (<=11 blocks) - CalculationContext gains MaxFallHeightWater, AllowLadderGrabDuringFall SprintJumpTemplate gains distance-based approach timing: - Long jumps (>=3.5 blocks): delays jump until 0.5 blocks from center - Medium jumps (>=2.5): 0.35 blocks approach - Landing tolerance scales with jump distance All movements verified on 1.21.11 local server. Made-with: Cursor --- .../Pathing/Core/AStarPathFinder.cs | 29 +++- .../Pathing/Core/CalculationContext.cs | 6 + .../Execution/Templates/DescendTemplate.cs | 10 +- .../Execution/Templates/FallTemplate.cs | 6 + .../Execution/Templates/SprintJumpTemplate.cs | 35 ++++- .../Pathing/Moves/Impl/MoveDescend.cs | 134 ++++++++++++++--- .../Pathing/Moves/Impl/MoveFall.cs | 71 ++++++--- .../Pathing/Moves/Impl/MoveParkour.cs | 140 ++++++++++++++---- MinecraftClient/Pathing/Moves/MoveHelper.cs | 23 +++ 9 files changed, 365 insertions(+), 89 deletions(-) diff --git a/MinecraftClient/Pathing/Core/AStarPathFinder.cs b/MinecraftClient/Pathing/Core/AStarPathFinder.cs index fa2e1432..70bc0254 100644 --- a/MinecraftClient/Pathing/Core/AStarPathFinder.cs +++ b/MinecraftClient/Pathing/Core/AStarPathFinder.cs @@ -49,17 +49,38 @@ namespace MinecraftClient.Pathing.Core moves.Add(new MoveFall()); + // Cardinal parkour: 2-4 block sprint jumps along +-X and +-Z foreach (int dx in offsets) { + for (int dist = 2; dist <= 4; dist++) + moves.Add(new MoveParkour(dx * dist, 0)); + // Ascending: +1Y, dist 2-3 (dist 4 ascend not physically reliable) for (int dist = 2; dist <= 3; dist++) - moves.Add(new MoveParkour(dx, 0, dist)); - moves.Add(new MoveParkour(dx, 0, 2, yDelta: 1)); + moves.Add(new MoveParkour(dx * dist, 0, yDelta: 1)); } foreach (int dz in offsets) { + for (int dist = 2; dist <= 4; dist++) + moves.Add(new MoveParkour(0, dz * dist)); for (int dist = 2; dist <= 3; dist++) - moves.Add(new MoveParkour(0, dz, dist)); - moves.Add(new MoveParkour(0, dz, 2, yDelta: 1)); + moves.Add(new MoveParkour(0, dz * dist, yDelta: 1)); + } + + // Diagonal parkour: sprint jumps at angles. + // Only include combinations with actual distance <= ~3.2 blocks (conservative) + foreach (int dx in offsets) + { + foreach (int dz in offsets) + { + // (2,1)/(1,2): sqrt(5) ~ 2.24 blocks + moves.Add(new MoveParkour(dx * 2, dz * 1)); + moves.Add(new MoveParkour(dx * 1, dz * 2)); + // (2,2): sqrt(8) ~ 2.83 blocks + moves.Add(new MoveParkour(dx * 2, dz * 2)); + // (3,1)/(1,3): sqrt(10) ~ 3.16 blocks + moves.Add(new MoveParkour(dx * 3, dz * 1)); + moves.Add(new MoveParkour(dx * 1, dz * 3)); + } } return [.. moves]; diff --git a/MinecraftClient/Pathing/Core/CalculationContext.cs b/MinecraftClient/Pathing/Core/CalculationContext.cs index 8728d7b2..19e1c770 100644 --- a/MinecraftClient/Pathing/Core/CalculationContext.cs +++ b/MinecraftClient/Pathing/Core/CalculationContext.cs @@ -15,6 +15,8 @@ namespace MinecraftClient.Pathing.Core public bool AllowParkourAscend { get; } public bool AllowDiagonalDescend { get; } public int MaxFallHeight { get; } + public int MaxFallHeightWater { get; } + public bool AllowLadderGrabDuringFall { get; } public double JumpPenalty { get; } public double WalkCost { get; } public double SprintCost { get; } @@ -27,6 +29,8 @@ namespace MinecraftClient.Pathing.Core bool allowParkourAscend = false, bool allowDiagonalDescend = true, int maxFallHeight = 3, + int maxFallHeightWater = 256, + bool allowLadderGrabDuringFall = true, double jumpPenalty = ActionCosts.JumpPenalty) { World = world; @@ -35,6 +39,8 @@ namespace MinecraftClient.Pathing.Core AllowParkourAscend = allowParkourAscend; AllowDiagonalDescend = allowDiagonalDescend; MaxFallHeight = maxFallHeight; + MaxFallHeightWater = maxFallHeightWater; + AllowLadderGrabDuringFall = allowLadderGrabDuringFall; JumpPenalty = jumpPenalty; WalkCost = ActionCosts.WalkOneBlock; SprintCost = CanSprint ? ActionCosts.SprintOneBlock : ActionCosts.WalkOneBlock; diff --git a/MinecraftClient/Pathing/Execution/Templates/DescendTemplate.cs b/MinecraftClient/Pathing/Execution/Templates/DescendTemplate.cs index a11a0e70..9e007dbb 100644 --- a/MinecraftClient/Pathing/Execution/Templates/DescendTemplate.cs +++ b/MinecraftClient/Pathing/Execution/Templates/DescendTemplate.cs @@ -7,6 +7,7 @@ namespace MinecraftClient.Pathing.Execution.Templates /// /// Walk off a ledge and drop 1-N blocks to a landing spot. /// Walks toward the destination; gravity handles the fall. + /// Supports both solid landings and water landings. /// public sealed class DescendTemplate : IActionTemplate { @@ -34,24 +35,29 @@ namespace MinecraftClient.Pathing.Execution.Templates if (!physics.OnGround) _hasFallen = true; + // Completion: landed on ground near destination if (_hasFallen && physics.OnGround && horizDistSq < 0.5 && Math.Abs(dy) < 0.8) return TemplateState.Complete; + // Completion: already at destination without falling (e.g., single step down) if (horizDistSq < 0.25 && Math.Abs(dy) < 0.5 && physics.OnGround) return TemplateState.Complete; + // Completion: landed in water near destination + if (_hasFallen && physics.InWater && horizDistSq < 0.5 && Math.Abs(dy) < 2.0) + return TemplateState.Complete; + // Fail if climbing up instead of descending if (pos.Y > ExpectedStart.Y + 2.0) return TemplateState.Failed; - if (_tickCount > 120) + if (_tickCount > 200) return TemplateState.Failed; if (horizDistSq > 0.01) { physics.Yaw = TemplateHelper.CalculateYaw(dx, dz); input.Forward = true; - // Don't push into climbable blocks during descent if (physics.OnClimbable) input.Forward = false; } diff --git a/MinecraftClient/Pathing/Execution/Templates/FallTemplate.cs b/MinecraftClient/Pathing/Execution/Templates/FallTemplate.cs index 47e640a0..7a4131e6 100644 --- a/MinecraftClient/Pathing/Execution/Templates/FallTemplate.cs +++ b/MinecraftClient/Pathing/Execution/Templates/FallTemplate.cs @@ -6,6 +6,7 @@ namespace MinecraftClient.Pathing.Execution.Templates { /// /// Vertical free fall at the same X,Z. Waits for the player to land at the target Y. + /// Supports both solid ground landings and water landings. /// public sealed class FallTemplate : IActionTemplate { @@ -30,9 +31,14 @@ namespace MinecraftClient.Pathing.Execution.Templates if (!physics.OnGround) _hasFallen = true; + // Solid ground landing if (_hasFallen && physics.OnGround && Math.Abs(dy) < 1.0) return TemplateState.Complete; + // Water landing + if (_hasFallen && physics.InWater && Math.Abs(dy) < 2.0) + return TemplateState.Complete; + if (_tickCount > 200) return TemplateState.Failed; diff --git a/MinecraftClient/Pathing/Execution/Templates/SprintJumpTemplate.cs b/MinecraftClient/Pathing/Execution/Templates/SprintJumpTemplate.cs index ed8626cc..d17fb77c 100644 --- a/MinecraftClient/Pathing/Execution/Templates/SprintJumpTemplate.cs +++ b/MinecraftClient/Pathing/Execution/Templates/SprintJumpTemplate.cs @@ -6,7 +6,9 @@ namespace MinecraftClient.Pathing.Execution.Templates { /// /// Sprint-jump across a gap. Uses a phase-based state machine: - /// Approach -> jump on first available ground tick -> Airborne -> Landing check. + /// Approach -> jump when ready -> Airborne -> Landing check. + /// For long jumps (>= 3.5 blocks), delays the jump until the player + /// has moved toward the edge of the starting block for maximum distance. /// public sealed class SprintJumpTemplate : IActionTemplate { @@ -15,6 +17,7 @@ namespace MinecraftClient.Pathing.Execution.Templates public Location ExpectedStart { get; } public Location ExpectedEnd { get; } + private readonly double _horizDist; private int _tickCount; private Phase _phase = Phase.Approach; @@ -22,6 +25,9 @@ namespace MinecraftClient.Pathing.Execution.Templates { ExpectedStart = start; ExpectedEnd = end; + double dx = end.X - start.X; + double dz = end.Z - start.Z; + _horizDist = Math.Sqrt(dx * dx + dz * dz); } public TemplateState Tick(Location pos, PlayerPhysics physics, MovementInput input) @@ -42,10 +48,27 @@ namespace MinecraftClient.Pathing.Execution.Templates case Phase.Approach: if (physics.OnGround) { - input.Jump = true; - _phase = Phase.Airborne; + double fromStartSq = TemplateHelper.HorizontalDistanceSq(pos, ExpectedStart); + + // For long jumps, delay the jump until the player has sprinted + // toward the block edge. Baritone waits until playerFeet is in + // the next block (~0.5 blocks from center) for dist >= 4. + // For medium jumps (dist 3), wait 0.35 blocks (Baritone: 0.7). + double minApproachSq; + if (_horizDist >= 3.5) + minApproachSq = 0.25; // 0.5 blocks + else if (_horizDist >= 2.5) + minApproachSq = 0.12; // ~0.35 blocks + else + minApproachSq = 0.0; + + if (fromStartSq >= minApproachSq) + { + input.Jump = true; + _phase = Phase.Airborne; + } } - if (_tickCount > 20) + if (_tickCount > 30) return TemplateState.Failed; break; @@ -56,7 +79,9 @@ namespace MinecraftClient.Pathing.Execution.Templates goto case Phase.Landing; case Phase.Landing: - if (horizDistSq < 2.0 && Math.Abs(dy) < 1.0) + // Tolerance scales with jump distance + double horizTolerance = _horizDist >= 3.5 ? 3.0 : 2.0; + if (horizDistSq < horizTolerance && Math.Abs(dy) < 1.0) return TemplateState.Complete; return TemplateState.Failed; } diff --git a/MinecraftClient/Pathing/Moves/Impl/MoveDescend.cs b/MinecraftClient/Pathing/Moves/Impl/MoveDescend.cs index 3dc47fd7..8f0036e5 100644 --- a/MinecraftClient/Pathing/Moves/Impl/MoveDescend.cs +++ b/MinecraftClient/Pathing/Moves/Impl/MoveDescend.cs @@ -1,10 +1,15 @@ +using MinecraftClient.Mapping; using MinecraftClient.Pathing.Core; namespace MinecraftClient.Pathing.Moves.Impl { /// /// Walk off a ledge and drop 1-N blocks in a cardinal direction. - /// Scans downward for a landing spot within MaxFallHeight. + /// For short drops (1-MaxFallHeight), uses simple scan. + /// For longer drops, delegates to DynamicFallCost which supports: + /// - Water/liquid safe landing + /// - Mid-fall ladder/vine grabbing (resets effective fall height if ≤ 11 blocks) + /// Based on Baritone's MovementDescend.dynamicFallCost design. /// public sealed class MoveDescend : IMove { @@ -30,34 +35,117 @@ namespace MinecraftClient.Pathing.Moves.Impl return; } - for (int fallDist = 1; fallDist <= ctx.MaxFallHeight; fallDist++) + // Don't descend from ladder/vine (unreliable) + Material fromDown = ctx.GetMaterial(x, y - 1, z); + if (fromDown.CanBeClimbedOn()) { - int landY = y - fallDist; + result.SetImpossible(); + return; + } - if (ctx.CanWalkOn(destX, landY - 1, destZ)) - { - if (!ctx.CanWalkThrough(destX, landY, destZ)) - { - result.SetImpossible(); - return; - } - - double cost = ActionCosts.WalkOffBlock + ActionCosts.FallCost(fallDist); - if (MoveHelper.IsHazardous(ctx.GetMaterial(destX, landY - 1, destZ))) - { - result.SetImpossible(); - return; - } - - result.Set(destX, landY, destZ, cost); - return; - } - - if (!ctx.CanWalkThrough(destX, landY, destZ)) + // Check for simple 1-block descend first (most common case) + if (ctx.CanWalkOn(destX, y - 2, destZ)) + { + Material landOn = ctx.GetMaterial(destX, y - 2, destZ); + if (MoveHelper.IsHazardous(landOn)) { result.SetImpossible(); return; } + if (ctx.GetMaterial(destX, y - 1, destZ).CanBeClimbedOn()) + { + result.SetImpossible(); + return; + } + + double cost = ActionCosts.WalkOffBlock + ActionCosts.FallCost(1); + result.Set(destX, y - 1, destZ, cost); + return; + } + + // Not a simple 1-block drop, try dynamic fall + DynamicFallCost(ctx, x, y, z, destX, destZ, ref result); + } + + /// + /// Scan downward for a safe landing, supporting water, ladder grabs, and + /// configurable max heights. Based on Baritone's dynamicFallCost. + /// + private static void DynamicFallCost( + CalculationContext ctx, int x, int y, int z, + int destX, int destZ, ref MoveResult result) + { + if (!ctx.CanWalkThrough(destX, y - 2, destZ)) + { + result.SetImpossible(); + return; + } + + double costSoFar = 0; + int effectiveStartHeight = y; + + // Scan starts from fallHeight=3 (2 blocks below the ledge) + // because fallHeight=1 and =2 were already checked above + int maxScan = ctx.MaxFallHeightWater > ctx.MaxFallHeight + ? ctx.MaxFallHeightWater + : ctx.MaxFallHeight; + + for (int fallHeight = 3; fallHeight <= maxScan; fallHeight++) + { + int newY = y - fallHeight; + if (newY < -64) break; + + Material ontoMat = ctx.GetMaterial(destX, newY, destZ); + + int unprotectedFallHeight = fallHeight - (y - effectiveStartHeight); + double tentativeCost = ActionCosts.WalkOffBlock + + ActionCosts.FallCost(unprotectedFallHeight) + costSoFar; + + // Water landing: safe regardless of height (water absorbs all fall damage) + if (MoveHelper.IsWater(ontoMat)) + { + result.Set(destX, newY, destZ, tentativeCost); + return; + } + + // Mid-fall ladder/vine grab: resets effective fall height. + // Vanilla: player grabs ladders/vines if falling speed is low enough + // (roughly ≤ 11 blocks of unprotected free fall). + if (ctx.AllowLadderGrabDuringFall && unprotectedFallHeight <= 11 + && ontoMat.CanBeClimbedOn()) + { + costSoFar += ActionCosts.FallCost(unprotectedFallHeight - 1); + costSoFar += ActionCosts.LadderDownOne; + effectiveStartHeight = newY; + continue; + } + + // Air or passable: continue falling + if (ctx.CanWalkThrough(destX, newY, destZ)) + continue; + + // Hit something solid + if (MoveHelper.IsHazardous(ontoMat)) + { + result.SetImpossible(); + return; + } + + if (!ctx.CanWalkOn(destX, newY, destZ)) + { + result.SetImpossible(); + return; + } + + // Solid landing: allowed if within safe fall height + if (unprotectedFallHeight <= ctx.MaxFallHeight + 1) + { + result.Set(destX, newY + 1, destZ, tentativeCost); + return; + } + + result.SetImpossible(); + return; } result.SetImpossible(); diff --git a/MinecraftClient/Pathing/Moves/Impl/MoveFall.cs b/MinecraftClient/Pathing/Moves/Impl/MoveFall.cs index e3fd74c4..da92f86c 100644 --- a/MinecraftClient/Pathing/Moves/Impl/MoveFall.cs +++ b/MinecraftClient/Pathing/Moves/Impl/MoveFall.cs @@ -1,10 +1,12 @@ +using MinecraftClient.Mapping; using MinecraftClient.Pathing.Core; namespace MinecraftClient.Pathing.Moves.Impl { /// - /// Straight-down fall at the current X,Z position, for drops greater than MaxFallHeight - /// that MoveDescend won't cover. Scans downward for a safe landing. + /// Straight-down fall at the current X,Z position. + /// Supports water landing and mid-fall ladder/vine grabbing. + /// Used for drops where MoveDescend's 1-block horizontal offset doesn't apply. /// public sealed class MoveFall : IMove { @@ -28,39 +30,62 @@ namespace MinecraftClient.Pathing.Moves.Impl return; } + double costSoFar = 0; + int effectiveStartHeight = y; + for (int fallDist = 1; fallDist <= _maxScanDepth; fallDist++) { int landY = y - fallDist; + if (landY < -64) break; - if (ctx.CanWalkOn(x, landY - 1, z)) + Material ontoMat = ctx.GetMaterial(x, landY, z); + int unprotectedFallHeight = fallDist - (y - effectiveStartHeight); + + // Water landing: safe regardless of height + if (MoveHelper.IsWater(ontoMat)) { - if (!ctx.CanWalkThrough(x, landY, z)) - { - result.SetImpossible(); - return; - } - - if (MoveHelper.IsHazardous(ctx.GetMaterial(x, landY - 1, z))) - { - result.SetImpossible(); - return; - } - - double fallDamageThreshold = 3; - double cost = ActionCosts.FallCost(fallDist); - - if (fallDist > fallDamageThreshold) - cost += (fallDist - fallDamageThreshold) * 5.0; - - result.Set(x, landY, z, cost); + double waterCost = ActionCosts.FallCost(unprotectedFallHeight) + costSoFar; + result.Set(x, landY, z, waterCost); return; } - if (!ctx.CanWalkThrough(x, landY, z)) + // Mid-fall ladder/vine grab (resets effective fall height) + if (ctx.AllowLadderGrabDuringFall && unprotectedFallHeight <= 11 + && ontoMat.CanBeClimbedOn()) + { + costSoFar += ActionCosts.FallCost(unprotectedFallHeight - 1); + costSoFar += ActionCosts.LadderDownOne; + effectiveStartHeight = landY; + continue; + } + + if (ctx.CanWalkThrough(x, landY, z)) + continue; + + // Hit something solid + if (!ctx.CanWalkOn(x, landY, z)) { result.SetImpossible(); return; } + + if (MoveHelper.IsHazardous(ontoMat)) + { + result.SetImpossible(); + return; + } + + // Solid landing within safe height + if (unprotectedFallHeight <= ctx.MaxFallHeight + 1) + { + double cost = ActionCosts.FallCost(unprotectedFallHeight) + costSoFar; + result.Set(x, landY + 1, z, cost); + return; + } + + // Too high for safe landing + result.SetImpossible(); + return; } result.SetImpossible(); diff --git a/MinecraftClient/Pathing/Moves/Impl/MoveParkour.cs b/MinecraftClient/Pathing/Moves/Impl/MoveParkour.cs index 599a5bf5..278ffe06 100644 --- a/MinecraftClient/Pathing/Moves/Impl/MoveParkour.cs +++ b/MinecraftClient/Pathing/Moves/Impl/MoveParkour.cs @@ -1,11 +1,13 @@ +using System; +using MinecraftClient.Mapping; using MinecraftClient.Pathing.Core; namespace MinecraftClient.Pathing.Moves.Impl { /// - /// Sprint jump across a gap of 1-3 blocks (total distance 2-4 blocks forward). - /// Optionally ascends 1 block during the jump (distance 2 only). - /// Requires AllowParkour in context; the first block forward must lack ground. + /// Sprint jump across a gap in cardinal or diagonal direction. + /// Supports horizontal distances of 2-4 blocks and optional +1Y ascent. + /// Based on Baritone's MovementParkour design with diagonal extensions. /// public sealed class MoveParkour : IMove { @@ -14,19 +16,18 @@ namespace MinecraftClient.Pathing.Moves.Impl public int ZOffset { get; } public bool DynamicY => false; - private readonly int _distance; private readonly int _yDelta; - private readonly int _xDir; - private readonly int _zDir; - public MoveParkour(int xDir, int zDir, int distance, int yDelta = 0) + /// + /// Create a parkour move with direct XZ offsets. + /// For cardinal: one of xOff/zOff is 0, the other is 2..4. + /// For diagonal: both non-zero, actual distance should be within sprint jump range. + /// + public MoveParkour(int xOff, int zOff, int yDelta = 0) { - _xDir = xDir; - _zDir = zDir; - _distance = distance; + XOffset = xOff; + ZOffset = zOff; _yDelta = yDelta; - XOffset = xDir * distance; - ZOffset = zDir * distance; } public void Calculate(CalculationContext ctx, int x, int y, int z, ref MoveResult result) @@ -49,16 +50,34 @@ namespace MinecraftClient.Pathing.Moves.Impl return; } - int destX = x + _xDir * _distance; - int destZ = z + _zDir * _distance; + // Don't parkour from climbable blocks (unreliable jump) + Material standingOn = ctx.GetMaterial(x, y - 1, z); + if (standingOn.CanBeClimbedOn()) + { + result.SetImpossible(); + return; + } + + int destX = x + XOffset; + int destZ = z + ZOffset; int destY = y + _yDelta; + // Head clearance at start (need room to jump) if (!ctx.CanWalkThrough(x, y + 2, z)) { result.SetImpossible(); return; } + // Can't jump out of liquid + Material atFeet = ctx.GetMaterial(x, y, z); + if (atFeet.IsLiquid()) + { + result.SetImpossible(); + return; + } + + // Destination must be standable and passable if (!ctx.CanWalkOn(destX, destY - 1, destZ)) { result.SetImpossible(); @@ -72,42 +91,99 @@ namespace MinecraftClient.Pathing.Moves.Impl return; } - for (int i = 1; i < _distance; i++) - { - int gx = x + _xDir * i; - int gz = z + _zDir * i; + int xSign = Math.Sign(XOffset); + int zSign = Math.Sign(ZOffset); + int xAbs = Math.Abs(XOffset); + int zAbs = Math.Abs(ZOffset); - if (!ctx.CanWalkThrough(gx, y, gz) || - !ctx.CanWalkThrough(gx, y + 1, gz) || - !ctx.CanWalkThrough(gx, y + 2, gz)) + // Check intermediate space for passability (the player's bounding box sweeps + // through a rectangle from start to end; check all blocks in that rectangle) + for (int i = 0; i <= xAbs; i++) + { + for (int j = 0; j <= zAbs; j++) + { + if (i == 0 && j == 0) continue; + if (i == xAbs && j == zAbs) continue; + + int gx = x + xSign * i; + int gz = z + zSign * j; + + if (!ctx.CanWalkThrough(gx, y, gz) || + !ctx.CanWalkThrough(gx, y + 1, gz) || + !ctx.CanWalkThrough(gx, y + 2, gz)) + { + result.SetImpossible(); + return; + } + + if (_yDelta > 0 && !ctx.CanWalkThrough(gx, y + 3, gz)) + { + result.SetImpossible(); + return; + } + } + } + + // Gap check: first block(s) adjacent to start must lack ground. + // If ground exists there, A* can find a walking path instead. + if (xAbs > 0 && zAbs == 0) + { + if (ctx.CanWalkOn(x + xSign, y - 1, z)) { result.SetImpossible(); return; } - - if (_yDelta > 0 && !ctx.CanWalkThrough(gx, y + 3, gz)) + } + else if (xAbs == 0 && zAbs > 0) + { + if (ctx.CanWalkOn(x, y - 1, z + zSign)) + { + result.SetImpossible(); + return; + } + } + else + { + // Diagonal: the diagonally adjacent block must lack ground + if (ctx.CanWalkOn(x + xSign, y - 1, z + zSign)) { result.SetImpossible(); return; } } - int firstGapX = x + _xDir; - int firstGapZ = z + _zDir; - if (ctx.CanWalkOn(firstGapX, y - 1, firstGapZ)) + // Overshoot safety: after landing, player continues moving. + // The block(s) past the destination in the jump direction must be passable. + int overX = destX + xSign; + int overZ = destZ + zSign; + if (!ctx.CanWalkThrough(overX, destY, overZ) || + !ctx.CanWalkThrough(overX, destY + 1, overZ)) { - result.SetImpossible(); - return; + // Wall right after landing - risk of collision. Still allow but add cost. + // (Baritone rejects this, but we allow with penalty since the template + // will decelerate anyway.) } - double cost = _distance * ctx.SprintCost + ctx.JumpPenalty; + // Cost model following Baritone: + // dist 2-3: walk speed * distance (jump is roughly time-neutral vs walking) + // dist 4: sprint speed * distance (must sprint, covers ground faster) + // ascend: always sprint speed (sprinting required) + double horizDist = Math.Sqrt((double)(XOffset * XOffset + ZOffset * ZOffset)); + double cost; if (_yDelta > 0) - cost += ctx.JumpPenalty; + cost = horizDist * ctx.SprintCost + ctx.JumpPenalty * 2; + else if (horizDist >= 3.5) + cost = horizDist * ctx.SprintCost + ctx.JumpPenalty; + else + cost = horizDist * ctx.WalkCost + ctx.JumpPenalty; result.Set(destX, destY, destZ, cost); } - public override string ToString() => - $"MoveParkour(dir=({_xDir},{_zDir}), dist={_distance}, dy={_yDelta})"; + public override string ToString() + { + double dist = Math.Sqrt((double)(XOffset * XOffset + ZOffset * ZOffset)); + return $"MoveParkour(off=({XOffset},{ZOffset}), dy={_yDelta}, dist={dist:F1})"; + } } } diff --git a/MinecraftClient/Pathing/Moves/MoveHelper.cs b/MinecraftClient/Pathing/Moves/MoveHelper.cs index e925bc6f..c9396461 100644 --- a/MinecraftClient/Pathing/Moves/MoveHelper.cs +++ b/MinecraftClient/Pathing/Moves/MoveHelper.cs @@ -74,6 +74,29 @@ namespace MinecraftClient.Pathing.Moves return mat == Material.Water; } + /// + /// Can the player safely land on this block? True for solid blocks + /// except bottom slabs (which cause glitchy fall damage in vanilla). + /// + public static bool CanSafelyLandOn(CalculationContext ctx, int x, int y, int z) + { + if (!CanWalkOn(ctx, x, y, z)) + return false; + // TODO: detect bottom slabs via BlockShapes and reject them + // (Baritone rejects bottom slab landings due to unreliable fall damage) + return true; + } + + /// + /// Does this block absorb/negate fall damage? + /// Water, slime blocks, hay bales, and powder snow reduce or eliminate fall damage. + /// + public static bool AbsorbsFallDamage(Material mat) + { + return mat is Material.Water or Material.SlimeBlock + or Material.HayBlock or Material.PowderSnow; + } + /// /// Conservative check for gate-type blocks. Since we cannot read block state /// (open/closed) during planning, treat all fence gates as passable. From 8ece75acc3836a13c2876c8a4be98656c5a9c958 Mon Sep 17 00:00:00 2001 From: BruceChen Date: Sun, 12 Apr 2026 00:03:43 +0800 Subject: [PATCH 12/37] feat: complete Phase 4 McClient integration for A* pathfinding - Fix MoveHelper.IsOpenGate: MangroveWood -> MangroveFenceGate - Fix ResetStateForTransfer to cancel and clear pathSegmentManager - Fix GetCurrentMovementGoal to return correct goal during A* navigation - Fix SetMovementSpeed(Sneak) speed value consistency (2 -> 1) - Migrate /pathfind command to use MoveToAStar + PathSegmentManager - Add NavigateToGoal(IGoal) to McClient for flexible goal navigation - Refactor MoveToAStar to delegate to NavigateToGoal - Add ChatBot API: NavigateTo, CancelMovement, GetCurrentMovementGoal - Expose PathSegmentManager.Goal property for external goal inspection Made-with: Cursor --- MinecraftClient/Commands/Pathfind.cs | 96 +------------------ MinecraftClient/McClient.cs | 62 ++++++++---- .../Pathing/Execution/PathSegmentManager.cs | 1 + MinecraftClient/Pathing/Moves/MoveHelper.cs | 2 +- MinecraftClient/Scripting/ChatBot.cs | 30 ++++++ 5 files changed, 78 insertions(+), 113 deletions(-) diff --git a/MinecraftClient/Commands/Pathfind.cs b/MinecraftClient/Commands/Pathfind.cs index 50eade55..8f8bd306 100644 --- a/MinecraftClient/Commands/Pathfind.cs +++ b/MinecraftClient/Commands/Pathfind.cs @@ -1,12 +1,7 @@ -using System; -using System.Threading; -using System.Threading.Tasks; using Brigadier.NET; using Brigadier.NET.Builder; using MinecraftClient.CommandHandler; using MinecraftClient.Mapping; -using MinecraftClient.Pathing.Core; -using MinecraftClient.Pathing.Goals; using static MinecraftClient.CommandHandler.CmdResult; namespace MinecraftClient.Commands @@ -38,7 +33,7 @@ namespace MinecraftClient.Commands return r.SetAndReturn(GetCmdDescTranslated()); } - private int DoPathfind(CmdResult r, Location goal) + private static int DoPathfind(CmdResult r, Location goal) { McClient handler = CmdResult.currentHandler!; if (!handler.GetTerrainEnabled()) @@ -47,94 +42,9 @@ namespace MinecraftClient.Commands Location current = handler.GetCurrentLocation(); goal.ToAbsolute(current); - int startX = (int)Math.Floor(current.X); - int startY = (int)Math.Floor(current.Y); - int startZ = (int)Math.Floor(current.Z); - int goalX = (int)Math.Floor(goal.X); - int goalY = (int)Math.Floor(goal.Y); - int goalZ = (int)Math.Floor(goal.Z); + var (success, message) = handler.MoveToAStar(goal, timeoutMs: 10000); - handler.Log.Info($"[Pathfind] Planning from ({startX},{startY},{startZ}) to ({goalX},{goalY},{goalZ})"); - - var ctx = new CalculationContext( - handler.GetWorld(), - canSprint: true, - maxFallHeight: 3); - - var finder = new AStarPathFinder(); - finder.DebugLog = msg => handler.Log.Info(msg); - - var goalObj = new GoalBlock(goalX, goalY, goalZ); - - Task.Run(() => - { - try - { - using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10)); - var result = finder.Calculate(ctx, startX, startY, startZ, goalObj, cts.Token, timeoutMs: 10000); - - handler.Log.Info($"[Pathfind] Result: {result.Status}, {result.Path.Count} nodes, " + - $"{result.NodesExplored} explored, {result.ElapsedMs}ms"); - - if (result.Path.Count > 1) - { - handler.Log.Info("[Pathfind] Path waypoints:"); - for (int i = 0; i < result.Path.Count; i++) - { - var n = result.Path[i]; - handler.Log.Info($" [{i}] ({n.X},{n.Y},{n.Z}) via {n.MoveUsed}"); - } - - handler.Log.Info("[Pathfind] Beginning movement along path..."); - FollowPath(handler, result); - } - else - { - handler.Log.Warn("[Pathfind] No path found!"); - } - } - catch (Exception ex) - { - handler.Log.Warn($"[Pathfind] Exception: {ex.Message}"); - } - }); - - return r.SetAndReturn(Status.Done, string.Format(Translations.cmd_pathfind_started, goalX, goalY, goalZ)); - } - - private static void FollowPath(McClient handler, PathResult result) - { - for (int i = 1; i < result.Path.Count; i++) - { - var node = result.Path[i]; - var target = new Location(node.X + 0.5, node.Y, node.Z + 0.5); - - handler.Log.Info($"[Pathfind] Moving to waypoint [{i}/{result.Path.Count - 1}]: ({node.X},{node.Y},{node.Z}) via {node.MoveUsed}"); - - bool success = handler.MoveTo(target, allowUnsafe: true, allowDirectTeleport: false, timeout: TimeSpan.FromSeconds(10)); - if (!success) - { - handler.Log.Warn($"[Pathfind] Sub-path failed for waypoint [{i}], using direct move"); - handler.MoveTo(target, allowUnsafe: true, allowDirectTeleport: true); - } - - int maxWaitTicks = 200; - int waited = 0; - while (handler.ClientIsMoving() && waited < maxWaitTicks) - { - Thread.Sleep(50); - waited++; - } - - var cur = handler.GetCurrentLocation(); - double dx = cur.X - target.X; - double dz = cur.Z - target.Z; - double horizDist = Math.Sqrt(dx * dx + dz * dz); - - handler.Log.Info($"[Pathfind] Waypoint [{i}] done, pos=({cur.X:F2},{cur.Y:F2},{cur.Z:F2}), dist={horizDist:F2}"); - } - - handler.Log.Info("[Pathfind] Path execution complete!"); + return r.SetAndReturn(success ? Status.Done : Status.Fail, message); } } } diff --git a/MinecraftClient/McClient.cs b/MinecraftClient/McClient.cs index cb52b7ff..4f91d4a1 100644 --- a/MinecraftClient/McClient.cs +++ b/MinecraftClient/McClient.cs @@ -567,6 +567,8 @@ namespace MinecraftClient isUnderSlab = false; path = null; pathTarget = null; + pathSegmentManager?.Cancel(); + pathSegmentManager = null; _yaw = null; _pitch = null; LastDigPosition = null; @@ -1716,11 +1718,11 @@ namespace MinecraftClient } /// - /// Navigate to a goal using the new A* pathfinder. - /// Runs the search, converts the result into the legacy Queue path, and starts movement. + /// Navigate to a goal using the new A* pathfinder and template-based execution. + /// Accepts any IGoal for flexible target specification. /// Returns a description of the result for UI feedback. /// - public (bool success, string message) MoveToAStar(Location goal, long timeoutMs = 5000) + public (bool success, string message) NavigateToGoal(Pathing.Goals.IGoal goal, long timeoutMs = 5000) { lock (locationLock) { @@ -1736,21 +1738,12 @@ namespace MinecraftClient if (!ctx.CanWalkThrough(sx, sy, sz) && ctx.CanWalkThrough(sx, sy + 1, sz)) sy++; - int gx = (int)Math.Floor(goal.X); - int gy = (int)Math.Floor(goal.Y); - int gz = (int)Math.Floor(goal.Z); - - if (!ctx.CanWalkThrough(gx, gy, gz) && ctx.CanWalkThrough(gx, gy + 1, gz)) - gy++; - - Log.Info($"[Goto] A* search from ({sx},{sy},{sz}) to ({gx},{gy},{gz}) " + - $"[raw pos=({location.X:F2},{location.Y:F2},{location.Z:F2})]"); + Log.Info($"[Navigate] A* search from ({sx},{sy},{sz}) to {goal}"); using var cts = new CancellationTokenSource(); - var pathGoal = new Pathing.Goals.GoalBlock(gx, gy, gz); - var result = finder.Calculate(ctx, sx, sy, sz, pathGoal, cts.Token, timeoutMs); + var result = finder.Calculate(ctx, sx, sy, sz, goal, cts.Token, timeoutMs); - Log.Info($"[Goto] A* result: {result.Status}, nodes={result.NodesExplored}, " + + Log.Info($"[Navigate] A* result: {result.Status}, nodes={result.NodesExplored}, " + $"time={result.ElapsedMs}ms, path length={result.Path.Count}"); if (result.Status == Pathing.Core.PathStatus.Failed || result.Path.Count < 2) @@ -1762,7 +1755,7 @@ namespace MinecraftClient for (int i = 1; i < result.Path.Count; i++) { var node = result.Path[i]; - Log.Debug($"[Goto] seg[{i - 1}] = {node.MoveUsed}: ({node.X},{node.Y},{node.Z})"); + Log.Debug($"[Navigate] seg[{i - 1}] = {node.MoveUsed}: ({node.X},{node.Y},{node.Z})"); } pathTarget = null; @@ -1771,7 +1764,7 @@ namespace MinecraftClient pathSegmentManager = new Pathing.Execution.PathSegmentManager( debugLog: msg => Log.Debug(msg), infoLog: msg => Log.Info(msg)); - pathSegmentManager.StartNavigation(pathGoal, result); + pathSegmentManager.StartNavigation(goal, result); string statusStr = result.Status == Pathing.Core.PathStatus.Partial ? " (partial)" : ""; return (true, string.Format(Translations.cmd_goto_success, @@ -1779,6 +1772,28 @@ namespace MinecraftClient } } + /// + /// Navigate to a block location using the new A* pathfinder and template-based execution. + /// Convenience overload that creates a GoalBlock from the location. + /// Returns a description of the result for UI feedback. + /// + public (bool success, string message) MoveToAStar(Location goal, long timeoutMs = 5000) + { + int gx = (int)Math.Floor(goal.X); + int gy = (int)Math.Floor(goal.Y); + int gz = (int)Math.Floor(goal.Z); + + lock (locationLock) + { + var ctx = new Pathing.Core.CalculationContext(world); + if (!ctx.CanWalkThrough(gx, gy, gz) && ctx.CanWalkThrough(gx, gy + 1, gz)) + gy++; + } + + var pathGoal = new Pathing.Goals.GoalBlock(gx, gy, gz); + return NavigateToGoal(pathGoal, timeoutMs); + } + /// /// Send a chat message or command to the server /// @@ -3436,7 +3451,16 @@ namespace MinecraftClient /// Current goal of movement. Location.Zero if not set. public Location GetCurrentMovementGoal() { - return (ClientIsMoving() || path is null) ? Location.Zero : path.Last(); + if (pathSegmentManager is not null && pathSegmentManager.IsNavigating) + { + if (pathSegmentManager.Goal is Pathing.Goals.GoalBlock gb) + return new Location(gb.X + 0.5, gb.Y, gb.Z + 0.5); + } + + if (path is not null && path.Count > 0) + return path.Last(); + + return Location.Zero; } /// @@ -3463,7 +3487,7 @@ namespace MinecraftClient { case MovementType.Sneak: // https://minecraft.wiki/w/Sneaking#Effects - Sneaking 1.31m/s - Config.Main.Advanced.MovementSpeed = 2; + Config.Main.Advanced.MovementSpeed = 1; break; case MovementType.Walk: // https://minecraft.wiki/w/Walking#Usage - Walking 4.317 m/s diff --git a/MinecraftClient/Pathing/Execution/PathSegmentManager.cs b/MinecraftClient/Pathing/Execution/PathSegmentManager.cs index c6492cc0..1582dd4a 100644 --- a/MinecraftClient/Pathing/Execution/PathSegmentManager.cs +++ b/MinecraftClient/Pathing/Execution/PathSegmentManager.cs @@ -23,6 +23,7 @@ namespace MinecraftClient.Pathing.Execution public bool IsNavigating => _executor is not null && !_executor.IsComplete; public int ReplanCount => _replanCount; + public IGoal? Goal => _goal; public PathSegmentManager(Action? debugLog = null, Action? infoLog = null) { diff --git a/MinecraftClient/Pathing/Moves/MoveHelper.cs b/MinecraftClient/Pathing/Moves/MoveHelper.cs index c9396461..63fc2c40 100644 --- a/MinecraftClient/Pathing/Moves/MoveHelper.cs +++ b/MinecraftClient/Pathing/Moves/MoveHelper.cs @@ -105,7 +105,7 @@ namespace MinecraftClient.Pathing.Moves { return mat is Material.AcaciaFenceGate or Material.BirchFenceGate or Material.CrimsonFenceGate or Material.DarkOakFenceGate - or Material.JungleFenceGate or Material.MangroveWood + or Material.JungleFenceGate or Material.MangroveFenceGate or Material.OakFenceGate or Material.SpruceFenceGate or Material.WarpedFenceGate or Material.CherryFenceGate or Material.BambooFenceGate or Material.PaleOakFenceGate; diff --git a/MinecraftClient/Scripting/ChatBot.cs b/MinecraftClient/Scripting/ChatBot.cs index 45236fda..851f22b5 100644 --- a/MinecraftClient/Scripting/ChatBot.cs +++ b/MinecraftClient/Scripting/ChatBot.cs @@ -1246,6 +1246,18 @@ namespace MinecraftClient.Scripting return Handler.MoveTo(location, allowUnsafe, allowDirectTeleport, maxOffset, minOffset, timeout); } + /// + /// Navigate to a goal using A* pathfinding with template-based execution. + /// Supports GoalBlock, GoalXZ, GoalNear, GoalComposite for flexible targeting. + /// + /// Target goal (GoalBlock, GoalNear, GoalXZ, etc.) + /// Maximum pathfinding computation time in milliseconds + /// Tuple of (success, descriptive message) + protected (bool success, string message) NavigateTo(Pathing.Goals.IGoal goal, long timeoutMs = 5000) + { + return Handler.NavigateToGoal(goal, timeoutMs); + } + /// /// Check if the client is currently processing a Movement. /// @@ -1255,6 +1267,24 @@ namespace MinecraftClient.Scripting return Handler.ClientIsMoving(); } + /// + /// Cancel the current movement, stopping both legacy and A* pathfinding. + /// + /// true if there was an active movement that was cancelled + protected bool CancelMovement() + { + return Handler.CancelMovement(); + } + + /// + /// Get the current movement goal location. + /// Returns Location.Zero if no movement is active. + /// + protected Location GetCurrentMovementGoal() + { + return Handler.GetCurrentMovementGoal(); + } + /// /// Look at the specified location /// From fb30886756e1604f902b60313e8120afe1776a05 Mon Sep 17 00:00:00 2001 From: BruceChen Date: Sun, 12 Apr 2026 00:23:01 +0800 Subject: [PATCH 13/37] fix: handle climbable blocks in WalkTemplate to prevent stuck on ladders WalkTemplate now detects when physics.OnClimbable is true (player entering a ladder/vine block) and switches from Sprint to Jump input, with extended stuck detection thresholds. This prevents the template from failing when the path walks through climbable blocks. Tested on 1.21.11: all movement types pass (walk, diagonal, ascend, descend, climb, parkour 2-4 gap, mixed courses with direction changes). Made-with: Cursor --- .../Pathing/Execution/Templates/WalkTemplate.cs | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/MinecraftClient/Pathing/Execution/Templates/WalkTemplate.cs b/MinecraftClient/Pathing/Execution/Templates/WalkTemplate.cs index 8711d4b5..fd3a4063 100644 --- a/MinecraftClient/Pathing/Execution/Templates/WalkTemplate.cs +++ b/MinecraftClient/Pathing/Execution/Templates/WalkTemplate.cs @@ -32,16 +32,27 @@ namespace MinecraftClient.Pathing.Execution.Templates double dz = ExpectedEnd.Z - pos.Z; physics.Yaw = TemplateHelper.CalculateYaw(dx, dz); input.Forward = true; - input.Sprint = true; + + if (physics.OnClimbable) + { + input.Jump = true; + input.Sprint = false; + } + else + { + input.Sprint = true; + } if (TemplateHelper.IsNear(pos, ExpectedEnd, horizThresholdSq: 0.20)) return TemplateState.Complete; double movedSq = TemplateHelper.HorizontalDistanceSq(pos, _lastPos); + int stuckThreshold = physics.OnClimbable ? 80 : 40; _stuckTicks = movedSq < 0.0005 ? _stuckTicks + 1 : 0; _lastPos = pos; - if (_stuckTicks > 40 || _tickCount > 100) + int tickLimit = physics.OnClimbable ? 160 : 100; + if (_stuckTicks > stuckThreshold || _tickCount > tickLimit) return TemplateState.Failed; return TemplateState.InProgress; From 9046c61f95880d386e570e2920a6c25c95350ed4 Mon Sep 17 00:00:00 2001 From: BruceChen Date: Sun, 12 Apr 2026 00:49:09 +0800 Subject: [PATCH 14/37] fix: improve vine/ladder climb-down and descent through climbable blocks - ClimbTemplate: add explicit descent handling with horizontal drift correction instead of relying on no-input gravity alone - DescendTemplate: on climbable blocks, suppress Forward input to prevent HorizontalCollision-triggered upward bumps, allowing gravity to slide the player down naturally - MoveClimb: restrict climb-up past the top of a climbable column -- only allow if there is solid ground to stand on at destination, preventing impossible vine-top exits where the player would fall back Made-with: Cursor --- .../Execution/Templates/ClimbTemplate.cs | 25 ++++++++++++++----- .../Execution/Templates/DescendTemplate.cs | 14 ++++++++--- .../Pathing/Moves/Impl/MoveClimb.cs | 14 ++++++++++- 3 files changed, 42 insertions(+), 11 deletions(-) diff --git a/MinecraftClient/Pathing/Execution/Templates/ClimbTemplate.cs b/MinecraftClient/Pathing/Execution/Templates/ClimbTemplate.cs index bb7d810f..694b489b 100644 --- a/MinecraftClient/Pathing/Execution/Templates/ClimbTemplate.cs +++ b/MinecraftClient/Pathing/Execution/Templates/ClimbTemplate.cs @@ -6,19 +6,22 @@ namespace MinecraftClient.Pathing.Execution.Templates { /// /// Climb up or down a ladder/vine by 1 block. - /// Pushes against the wall (Forward + face center) and jumps for upward movement. + /// Up: pushes against the wall (Forward + face center) and jumps. + /// Down: releases all input to let gravity + climbable friction handle descent. /// public sealed class ClimbTemplate : IActionTemplate { public Location ExpectedStart { get; } public Location ExpectedEnd { get; } + private readonly bool _goingUp; private int _tickCount; public ClimbTemplate(Location start, Location end) { ExpectedStart = start; ExpectedEnd = end; + _goingUp = end.Y > start.Y; } public TemplateState Tick(Location pos, PlayerPhysics physics, MovementInput input) @@ -30,26 +33,36 @@ namespace MinecraftClient.Pathing.Execution.Templates double dz = ExpectedEnd.Z - pos.Z; double horizDistSq = dx * dx + dz * dz; - if (Math.Abs(dy) < 0.3 && horizDistSq < 0.5) + if (Math.Abs(dy) < 0.4 && horizDistSq < 0.5) return TemplateState.Complete; - if (_tickCount > 100) + if (_tickCount > 120) return TemplateState.Failed; if (physics.OnClimbable) { - if (dy > 0) + if (_goingUp) { input.Jump = true; input.Forward = true; if (horizDistSq > 0.01) physics.Yaw = TemplateHelper.CalculateYaw(dx, dz); } - // Going down: don't press anything, gravity + climbable friction handles it + else + { + // Descending: release all input, gravity pulls down at clamped speed. + // Do NOT press Sneak (that would freeze position on ladders). + // Do NOT press Jump (that would push upward). + // Keep centered horizontally by gently steering if drifting. + if (horizDistSq > 0.15) + { + physics.Yaw = TemplateHelper.CalculateYaw(dx, dz); + input.Forward = true; + } + } } else { - // Left the climbable area -- walk toward destination if (horizDistSq > 0.01) { physics.Yaw = TemplateHelper.CalculateYaw(dx, dz); diff --git a/MinecraftClient/Pathing/Execution/Templates/DescendTemplate.cs b/MinecraftClient/Pathing/Execution/Templates/DescendTemplate.cs index 9e007dbb..9659691a 100644 --- a/MinecraftClient/Pathing/Execution/Templates/DescendTemplate.cs +++ b/MinecraftClient/Pathing/Execution/Templates/DescendTemplate.cs @@ -7,7 +7,7 @@ namespace MinecraftClient.Pathing.Execution.Templates /// /// Walk off a ledge and drop 1-N blocks to a landing spot. /// Walks toward the destination; gravity handles the fall. - /// Supports both solid landings and water landings. + /// Supports solid landings, water landings, and mid-fall vine/ladder grabs. /// public sealed class DescendTemplate : IActionTemplate { @@ -54,12 +54,18 @@ namespace MinecraftClient.Pathing.Execution.Templates if (_tickCount > 200) return TemplateState.Failed; - if (horizDistSq > 0.01) + if (physics.OnClimbable) + { + if (horizDistSq > 0.25) + { + physics.Yaw = TemplateHelper.CalculateYaw(dx, dz); + input.Forward = true; + } + } + else if (horizDistSq > 0.01) { physics.Yaw = TemplateHelper.CalculateYaw(dx, dz); input.Forward = true; - if (physics.OnClimbable) - input.Forward = false; } return TemplateState.InProgress; diff --git a/MinecraftClient/Pathing/Moves/Impl/MoveClimb.cs b/MinecraftClient/Pathing/Moves/Impl/MoveClimb.cs index 1952b74d..25312460 100644 --- a/MinecraftClient/Pathing/Moves/Impl/MoveClimb.cs +++ b/MinecraftClient/Pathing/Moves/Impl/MoveClimb.cs @@ -39,7 +39,19 @@ namespace MinecraftClient.Pathing.Moves.Impl } var aboveMat = ctx.GetMaterial(x, destY, z); - if (MoveHelper.IsClimbable(aboveMat) || !ctx.GetMaterial(x, destY, z).IsSolid()) + if (MoveHelper.IsClimbable(aboveMat)) + { + result.Set(x, destY, z, ActionCosts.LadderUpOne); + return; + } + + // Top of climbable: only allow if we can transition to a solid + // surface nearby (ladders have wall collision, vines don't). + // Check if the destination block itself is walkable-through and + // there's solid ground at (x, destY-1, z) -- meaning we can + // stand at destY. This handles ladder-tops where the ladder ends + // but the block above is air and we can step onto the floor. + if (!aboveMat.IsSolid() && ctx.CanWalkOn(x, destY - 1, z)) { result.Set(x, destY, z, ActionCosts.LadderUpOne); return; From 399c8cdc7950e12feed305a70fbd56502b208826 Mon Sep 17 00:00:00 2001 From: BruceChen Date: Sun, 12 Apr 2026 00:57:38 +0800 Subject: [PATCH 15/37] fix: remove spurious jump on vines in WalkTemplate and add pitch tracking - WalkTemplate: remove OnClimbable jump/sprint logic that caused the player to jump when walking past vine blocks during flat traversal - TemplateHelper: add CalculatePitch() for computing the look angle toward a 3D target relative to eye height - All templates (Walk, Ascend, Descend, Climb, SprintJump): set physics.Pitch each tick so the player visually looks toward the current path target direction - McClient: sync playerPitch and set _yaw/_pitch after pathfinding ticks so rotation is included in position update packets sent to the server Made-with: Cursor --- MinecraftClient/McClient.cs | 3 +++ .../Execution/Templates/AscendTemplate.cs | 1 + .../Execution/Templates/ClimbTemplate.cs | 2 ++ .../Execution/Templates/DescendTemplate.cs | 2 ++ .../Execution/Templates/SprintJumpTemplate.cs | 1 + .../Execution/Templates/TemplateHelper.cs | 12 ++++++++++++ .../Pathing/Execution/Templates/WalkTemplate.cs | 17 ++++------------- 7 files changed, 25 insertions(+), 13 deletions(-) diff --git a/MinecraftClient/McClient.cs b/MinecraftClient/McClient.cs index 4f91d4a1..aada4a32 100644 --- a/MinecraftClient/McClient.cs +++ b/MinecraftClient/McClient.cs @@ -3285,6 +3285,9 @@ namespace MinecraftClient { pathSegmentManager.Tick(location, playerPhysics, physicsInput, world); playerYaw = playerPhysics.Yaw; + playerPitch = playerPhysics.Pitch; + _yaw = playerYaw; + _pitch = playerPitch; return; } diff --git a/MinecraftClient/Pathing/Execution/Templates/AscendTemplate.cs b/MinecraftClient/Pathing/Execution/Templates/AscendTemplate.cs index 8a96c041..16ad6b55 100644 --- a/MinecraftClient/Pathing/Execution/Templates/AscendTemplate.cs +++ b/MinecraftClient/Pathing/Execution/Templates/AscendTemplate.cs @@ -34,6 +34,7 @@ namespace MinecraftClient.Pathing.Execution.Templates double horizDistSq = dx * dx + dz * dz; physics.Yaw = TemplateHelper.CalculateYaw(dx, dz); + physics.Pitch = TemplateHelper.CalculatePitch(dx, dy - 1.62, dz); input.Forward = true; input.Sprint = true; diff --git a/MinecraftClient/Pathing/Execution/Templates/ClimbTemplate.cs b/MinecraftClient/Pathing/Execution/Templates/ClimbTemplate.cs index 694b489b..0f562bbe 100644 --- a/MinecraftClient/Pathing/Execution/Templates/ClimbTemplate.cs +++ b/MinecraftClient/Pathing/Execution/Templates/ClimbTemplate.cs @@ -39,6 +39,8 @@ namespace MinecraftClient.Pathing.Execution.Templates if (_tickCount > 120) return TemplateState.Failed; + physics.Pitch = _goingUp ? -70f : 70f; + if (physics.OnClimbable) { if (_goingUp) diff --git a/MinecraftClient/Pathing/Execution/Templates/DescendTemplate.cs b/MinecraftClient/Pathing/Execution/Templates/DescendTemplate.cs index 9659691a..31df4abd 100644 --- a/MinecraftClient/Pathing/Execution/Templates/DescendTemplate.cs +++ b/MinecraftClient/Pathing/Execution/Templates/DescendTemplate.cs @@ -54,6 +54,8 @@ namespace MinecraftClient.Pathing.Execution.Templates if (_tickCount > 200) return TemplateState.Failed; + physics.Pitch = TemplateHelper.CalculatePitch(dx, dy - 1.62, dz); + if (physics.OnClimbable) { if (horizDistSq > 0.25) diff --git a/MinecraftClient/Pathing/Execution/Templates/SprintJumpTemplate.cs b/MinecraftClient/Pathing/Execution/Templates/SprintJumpTemplate.cs index d17fb77c..96971656 100644 --- a/MinecraftClient/Pathing/Execution/Templates/SprintJumpTemplate.cs +++ b/MinecraftClient/Pathing/Execution/Templates/SprintJumpTemplate.cs @@ -40,6 +40,7 @@ namespace MinecraftClient.Pathing.Execution.Templates double horizDistSq = dx * dx + dz * dz; physics.Yaw = TemplateHelper.CalculateYaw(dx, dz); + physics.Pitch = TemplateHelper.CalculatePitch(dx, dy - 1.62, dz); input.Forward = true; input.Sprint = true; diff --git a/MinecraftClient/Pathing/Execution/Templates/TemplateHelper.cs b/MinecraftClient/Pathing/Execution/Templates/TemplateHelper.cs index 18a67a66..0e888821 100644 --- a/MinecraftClient/Pathing/Execution/Templates/TemplateHelper.cs +++ b/MinecraftClient/Pathing/Execution/Templates/TemplateHelper.cs @@ -12,6 +12,18 @@ namespace MinecraftClient.Pathing.Execution.Templates return yaw; } + /// + /// Calculate the pitch angle (in degrees) to look toward a 3D offset. + /// Negative = look up, positive = look down. Clamped to [-90, 90]. + /// The dy is relative to eye height (~1.62 blocks above feet). + /// + internal static float CalculatePitch(double dx, double dy, double dz) + { + double horizDist = Math.Sqrt(dx * dx + dz * dz); + float pitch = (float)(-Math.Atan2(dy, horizDist) / Math.PI * 180.0); + return Math.Clamp(pitch, -90f, 90f); + } + internal static double HorizontalDistanceSq(Location a, Location b) { double dx = a.X - b.X; diff --git a/MinecraftClient/Pathing/Execution/Templates/WalkTemplate.cs b/MinecraftClient/Pathing/Execution/Templates/WalkTemplate.cs index fd3a4063..55e0f73f 100644 --- a/MinecraftClient/Pathing/Execution/Templates/WalkTemplate.cs +++ b/MinecraftClient/Pathing/Execution/Templates/WalkTemplate.cs @@ -30,29 +30,20 @@ namespace MinecraftClient.Pathing.Execution.Templates double dx = ExpectedEnd.X - pos.X; double dz = ExpectedEnd.Z - pos.Z; + double dy = ExpectedEnd.Y - pos.Y; physics.Yaw = TemplateHelper.CalculateYaw(dx, dz); + physics.Pitch = TemplateHelper.CalculatePitch(dx, dy - 1.62, dz); input.Forward = true; - - if (physics.OnClimbable) - { - input.Jump = true; - input.Sprint = false; - } - else - { - input.Sprint = true; - } + input.Sprint = true; if (TemplateHelper.IsNear(pos, ExpectedEnd, horizThresholdSq: 0.20)) return TemplateState.Complete; double movedSq = TemplateHelper.HorizontalDistanceSq(pos, _lastPos); - int stuckThreshold = physics.OnClimbable ? 80 : 40; _stuckTicks = movedSq < 0.0005 ? _stuckTicks + 1 : 0; _lastPos = pos; - int tickLimit = physics.OnClimbable ? 160 : 100; - if (_stuckTicks > stuckThreshold || _tickCount > tickLimit) + if (_stuckTicks > 40 || _tickCount > 100) return TemplateState.Failed; return TemplateState.InProgress; From 285c3000c368321520eb9506c85e77c7a67fc3c6 Mon Sep 17 00:00:00 2001 From: BruceChen Date: Sun, 12 Apr 2026 01:15:17 +0800 Subject: [PATCH 16/37] feat: add diagonal ascend/descend moves, fix pitch, smooth look angles - Add MoveDiagonalAscend and MoveDiagonalDescend for "corner" moves: step diagonally around a wall edge while ascending/descending 1 block. Requires at least one intermediate cardinal direction to be passable. - Fix pitch calculation: look toward target's eye level (same height delta as feet delta) instead of subtracting eye height, which caused the player to stare at the ground during flat walks. - Add Yaw/Pitch smoothing via SmoothYaw/SmoothPitch in TemplateHelper. Max 35 deg/tick for yaw, 25 deg/tick for pitch. Prevents instant camera snaps between path segments while still being responsive enough for sprint-jumps and tight maneuvers. - Apply smoothing to all five action templates (Walk, Ascend, Descend, Climb, SprintJump). Made-with: Cursor --- .../Pathing/Core/AStarPathFinder.cs | 10 +++ .../Execution/Templates/AscendTemplate.cs | 6 +- .../Execution/Templates/ClimbTemplate.cs | 14 +++- .../Execution/Templates/DescendTemplate.cs | 8 +- .../Execution/Templates/SprintJumpTemplate.cs | 6 +- .../Execution/Templates/TemplateHelper.cs | 42 +++++++++- .../Execution/Templates/WalkTemplate.cs | 6 +- .../Pathing/Moves/Impl/MoveDiagonalAscend.cs | 69 +++++++++++++++++ .../Pathing/Moves/Impl/MoveDiagonalDescend.cs | 77 +++++++++++++++++++ 9 files changed, 222 insertions(+), 16 deletions(-) create mode 100644 MinecraftClient/Pathing/Moves/Impl/MoveDiagonalAscend.cs create mode 100644 MinecraftClient/Pathing/Moves/Impl/MoveDiagonalDescend.cs diff --git a/MinecraftClient/Pathing/Core/AStarPathFinder.cs b/MinecraftClient/Pathing/Core/AStarPathFinder.cs index 70bc0254..14c4ca08 100644 --- a/MinecraftClient/Pathing/Core/AStarPathFinder.cs +++ b/MinecraftClient/Pathing/Core/AStarPathFinder.cs @@ -44,6 +44,16 @@ namespace MinecraftClient.Pathing.Core moves.Add(new MoveDiagonal(-1, 1)); moves.Add(new MoveDiagonal(-1, -1)); + // Diagonal ascend/descend: corner jumps and drops + foreach (int dx in offsets) + { + foreach (int dz in offsets) + { + moves.Add(new MoveDiagonalAscend(dx, dz)); + moves.Add(new MoveDiagonalDescend(dx, dz)); + } + } + moves.Add(new MoveClimb(true)); moves.Add(new MoveClimb(false)); diff --git a/MinecraftClient/Pathing/Execution/Templates/AscendTemplate.cs b/MinecraftClient/Pathing/Execution/Templates/AscendTemplate.cs index 16ad6b55..3ad3c41e 100644 --- a/MinecraftClient/Pathing/Execution/Templates/AscendTemplate.cs +++ b/MinecraftClient/Pathing/Execution/Templates/AscendTemplate.cs @@ -33,8 +33,10 @@ namespace MinecraftClient.Pathing.Execution.Templates double dy = ExpectedEnd.Y - pos.Y; double horizDistSq = dx * dx + dz * dz; - physics.Yaw = TemplateHelper.CalculateYaw(dx, dz); - physics.Pitch = TemplateHelper.CalculatePitch(dx, dy - 1.62, dz); + float targetYaw = TemplateHelper.CalculateYaw(dx, dz); + float targetPitch = TemplateHelper.CalculatePitch(dx, dy, dz); + physics.Yaw = TemplateHelper.SmoothYaw(physics.Yaw, targetYaw); + physics.Pitch = TemplateHelper.SmoothPitch(physics.Pitch, targetPitch); input.Forward = true; input.Sprint = true; diff --git a/MinecraftClient/Pathing/Execution/Templates/ClimbTemplate.cs b/MinecraftClient/Pathing/Execution/Templates/ClimbTemplate.cs index 0f562bbe..0ffea56d 100644 --- a/MinecraftClient/Pathing/Execution/Templates/ClimbTemplate.cs +++ b/MinecraftClient/Pathing/Execution/Templates/ClimbTemplate.cs @@ -39,7 +39,8 @@ namespace MinecraftClient.Pathing.Execution.Templates if (_tickCount > 120) return TemplateState.Failed; - physics.Pitch = _goingUp ? -70f : 70f; + float targetPitch = _goingUp ? -70f : 70f; + physics.Pitch = TemplateHelper.SmoothPitch(physics.Pitch, targetPitch); if (physics.OnClimbable) { @@ -48,7 +49,10 @@ namespace MinecraftClient.Pathing.Execution.Templates input.Jump = true; input.Forward = true; if (horizDistSq > 0.01) - physics.Yaw = TemplateHelper.CalculateYaw(dx, dz); + { + float targetYaw = TemplateHelper.CalculateYaw(dx, dz); + physics.Yaw = TemplateHelper.SmoothYaw(physics.Yaw, targetYaw); + } } else { @@ -58,7 +62,8 @@ namespace MinecraftClient.Pathing.Execution.Templates // Keep centered horizontally by gently steering if drifting. if (horizDistSq > 0.15) { - physics.Yaw = TemplateHelper.CalculateYaw(dx, dz); + float targetYaw = TemplateHelper.CalculateYaw(dx, dz); + physics.Yaw = TemplateHelper.SmoothYaw(physics.Yaw, targetYaw); input.Forward = true; } } @@ -67,7 +72,8 @@ namespace MinecraftClient.Pathing.Execution.Templates { if (horizDistSq > 0.01) { - physics.Yaw = TemplateHelper.CalculateYaw(dx, dz); + float targetYaw = TemplateHelper.CalculateYaw(dx, dz); + physics.Yaw = TemplateHelper.SmoothYaw(physics.Yaw, targetYaw); input.Forward = true; } } diff --git a/MinecraftClient/Pathing/Execution/Templates/DescendTemplate.cs b/MinecraftClient/Pathing/Execution/Templates/DescendTemplate.cs index 31df4abd..21185816 100644 --- a/MinecraftClient/Pathing/Execution/Templates/DescendTemplate.cs +++ b/MinecraftClient/Pathing/Execution/Templates/DescendTemplate.cs @@ -54,19 +54,21 @@ namespace MinecraftClient.Pathing.Execution.Templates if (_tickCount > 200) return TemplateState.Failed; - physics.Pitch = TemplateHelper.CalculatePitch(dx, dy - 1.62, dz); + float targetYaw = TemplateHelper.CalculateYaw(dx, dz); + float targetPitch = TemplateHelper.CalculatePitch(dx, dy, dz); + physics.Pitch = TemplateHelper.SmoothPitch(physics.Pitch, targetPitch); if (physics.OnClimbable) { if (horizDistSq > 0.25) { - physics.Yaw = TemplateHelper.CalculateYaw(dx, dz); + physics.Yaw = TemplateHelper.SmoothYaw(physics.Yaw, targetYaw); input.Forward = true; } } else if (horizDistSq > 0.01) { - physics.Yaw = TemplateHelper.CalculateYaw(dx, dz); + physics.Yaw = TemplateHelper.SmoothYaw(physics.Yaw, targetYaw); input.Forward = true; } diff --git a/MinecraftClient/Pathing/Execution/Templates/SprintJumpTemplate.cs b/MinecraftClient/Pathing/Execution/Templates/SprintJumpTemplate.cs index 96971656..7483b083 100644 --- a/MinecraftClient/Pathing/Execution/Templates/SprintJumpTemplate.cs +++ b/MinecraftClient/Pathing/Execution/Templates/SprintJumpTemplate.cs @@ -39,8 +39,10 @@ namespace MinecraftClient.Pathing.Execution.Templates double dy = ExpectedEnd.Y - pos.Y; double horizDistSq = dx * dx + dz * dz; - physics.Yaw = TemplateHelper.CalculateYaw(dx, dz); - physics.Pitch = TemplateHelper.CalculatePitch(dx, dy - 1.62, dz); + float targetYaw = TemplateHelper.CalculateYaw(dx, dz); + float targetPitch = TemplateHelper.CalculatePitch(dx, dy, dz); + physics.Yaw = TemplateHelper.SmoothYaw(physics.Yaw, targetYaw); + physics.Pitch = TemplateHelper.SmoothPitch(physics.Pitch, targetPitch); input.Forward = true; input.Sprint = true; diff --git a/MinecraftClient/Pathing/Execution/Templates/TemplateHelper.cs b/MinecraftClient/Pathing/Execution/Templates/TemplateHelper.cs index 0e888821..f724906c 100644 --- a/MinecraftClient/Pathing/Execution/Templates/TemplateHelper.cs +++ b/MinecraftClient/Pathing/Execution/Templates/TemplateHelper.cs @@ -5,6 +5,10 @@ namespace MinecraftClient.Pathing.Execution.Templates { internal static class TemplateHelper { + private const double EyeHeight = 1.62; + private const float MaxYawStepPerTick = 35f; + private const float MaxPitchStepPerTick = 25f; + internal static float CalculateYaw(double dx, double dz) { float yaw = (float)(-Math.Atan2(dx, dz) / Math.PI * 180.0); @@ -13,17 +17,49 @@ namespace MinecraftClient.Pathing.Execution.Templates } /// - /// Calculate the pitch angle (in degrees) to look toward a 3D offset. - /// Negative = look up, positive = look down. Clamped to [-90, 90]. - /// The dy is relative to eye height (~1.62 blocks above feet). + /// Calculate the pitch angle to look from current eye position toward + /// the target's feet-level Y. dy = targetFeetY - playerFeetY. /// internal static float CalculatePitch(double dx, double dy, double dz) { double horizDist = Math.Sqrt(dx * dx + dz * dz); + // Look toward the target's eye level, not feet. + // Both player and target are at feet+EyeHeight, so the vertical + // difference is just dy (target feet Y - player feet Y). float pitch = (float)(-Math.Atan2(dy, horizDist) / Math.PI * 180.0); return Math.Clamp(pitch, -90f, 90f); } + /// + /// Smoothly interpolate yaw toward a target, respecting wrap-around at 0/360. + /// + internal static float SmoothYaw(float current, float target, float maxStep = MaxYawStepPerTick) + { + float delta = target - current; + // Normalize to [-180, 180] + while (delta > 180f) delta -= 360f; + while (delta < -180f) delta += 360f; + + if (Math.Abs(delta) <= maxStep) + return target; + + float result = current + Math.Sign(delta) * maxStep; + if (result < 0) result += 360f; + if (result >= 360f) result -= 360f; + return result; + } + + /// + /// Smoothly interpolate pitch toward a target. + /// + internal static float SmoothPitch(float current, float target, float maxStep = MaxPitchStepPerTick) + { + float delta = target - current; + if (Math.Abs(delta) <= maxStep) + return target; + return current + Math.Sign(delta) * maxStep; + } + internal static double HorizontalDistanceSq(Location a, Location b) { double dx = a.X - b.X; diff --git a/MinecraftClient/Pathing/Execution/Templates/WalkTemplate.cs b/MinecraftClient/Pathing/Execution/Templates/WalkTemplate.cs index 55e0f73f..0cdbf96e 100644 --- a/MinecraftClient/Pathing/Execution/Templates/WalkTemplate.cs +++ b/MinecraftClient/Pathing/Execution/Templates/WalkTemplate.cs @@ -31,8 +31,10 @@ namespace MinecraftClient.Pathing.Execution.Templates double dx = ExpectedEnd.X - pos.X; double dz = ExpectedEnd.Z - pos.Z; double dy = ExpectedEnd.Y - pos.Y; - physics.Yaw = TemplateHelper.CalculateYaw(dx, dz); - physics.Pitch = TemplateHelper.CalculatePitch(dx, dy - 1.62, dz); + float targetYaw = TemplateHelper.CalculateYaw(dx, dz); + float targetPitch = TemplateHelper.CalculatePitch(dx, dy, dz); + physics.Yaw = TemplateHelper.SmoothYaw(physics.Yaw, targetYaw); + physics.Pitch = TemplateHelper.SmoothPitch(physics.Pitch, targetPitch); input.Forward = true; input.Sprint = true; diff --git a/MinecraftClient/Pathing/Moves/Impl/MoveDiagonalAscend.cs b/MinecraftClient/Pathing/Moves/Impl/MoveDiagonalAscend.cs new file mode 100644 index 00000000..b0bde700 --- /dev/null +++ b/MinecraftClient/Pathing/Moves/Impl/MoveDiagonalAscend.cs @@ -0,0 +1,69 @@ +using MinecraftClient.Pathing.Core; + +namespace MinecraftClient.Pathing.Moves.Impl +{ + /// + /// Jump diagonally (1 block in X and Z) and land 1 block higher. + /// Handles the "corner jump" pattern: jump around a wall edge and land + /// one block higher on a platform that is diagonally adjacent. + /// + public sealed class MoveDiagonalAscend : IMove + { + public MoveType Type => MoveType.Ascend; + public int XOffset { get; } + public int ZOffset { get; } + public bool DynamicY => false; + + public MoveDiagonalAscend(int xOffset, int zOffset) + { + XOffset = xOffset; + ZOffset = zOffset; + } + + public void Calculate(CalculationContext ctx, int x, int y, int z, ref MoveResult result) + { + int destX = x + XOffset; + int destZ = z + ZOffset; + int destY = y + 1; + + // Need headroom to jump (y+2 at start) + if (!ctx.CanWalkThrough(x, y + 2, z)) + { + result.SetImpossible(); + return; + } + + // Destination: solid ground, body passable, head passable + if (!ctx.CanWalkOn(destX, y, destZ)) + { + result.SetImpossible(); + return; + } + + if (!ctx.CanWalkThrough(destX, destY, destZ) || + !ctx.CanWalkThrough(destX, destY + 1, destZ)) + { + result.SetImpossible(); + return; + } + + // At least one of the two intermediate cardinal directions must be passable + // at both the current and destination height (player sweeps through). + bool pathViaX = ctx.CanWalkThrough(x + XOffset, y, z) && + ctx.CanWalkThrough(x + XOffset, y + 1, z) && + ctx.CanWalkThrough(x + XOffset, y + 2, z); + bool pathViaZ = ctx.CanWalkThrough(x, y, z + ZOffset) && + ctx.CanWalkThrough(x, y + 1, z + ZOffset) && + ctx.CanWalkThrough(x, y + 2, z + ZOffset); + + if (!pathViaX && !pathViaZ) + { + result.SetImpossible(); + return; + } + + double cost = ctx.SprintCost * ActionCosts.DiagonalMultiplier + ctx.JumpPenalty; + result.Set(destX, destY, destZ, cost); + } + } +} diff --git a/MinecraftClient/Pathing/Moves/Impl/MoveDiagonalDescend.cs b/MinecraftClient/Pathing/Moves/Impl/MoveDiagonalDescend.cs new file mode 100644 index 00000000..2f9e2578 --- /dev/null +++ b/MinecraftClient/Pathing/Moves/Impl/MoveDiagonalDescend.cs @@ -0,0 +1,77 @@ +using MinecraftClient.Mapping; +using MinecraftClient.Pathing.Core; + +namespace MinecraftClient.Pathing.Moves.Impl +{ + /// + /// Walk diagonally (1 block in X and Z) and drop 1 block. + /// Handles the "corner drop" pattern: step around a wall edge and land + /// one block lower on a platform that is diagonally adjacent. + /// + public sealed class MoveDiagonalDescend : IMove + { + public MoveType Type => MoveType.Descend; + public int XOffset { get; } + public int ZOffset { get; } + public bool DynamicY => false; + + public MoveDiagonalDescend(int xOffset, int zOffset) + { + XOffset = xOffset; + ZOffset = zOffset; + } + + public void Calculate(CalculationContext ctx, int x, int y, int z, ref MoveResult result) + { + int destX = x + XOffset; + int destZ = z + ZOffset; + int destY = y - 1; + + // Destination must have ground, body space, and head space + if (!ctx.CanWalkOn(destX, destY - 1, destZ)) + { + result.SetImpossible(); + return; + } + + if (!ctx.CanWalkThrough(destX, destY, destZ) || + !ctx.CanWalkThrough(destX, destY + 1, destZ)) + { + result.SetImpossible(); + return; + } + + Material landOn = ctx.GetMaterial(destX, destY - 1, destZ); + if (MoveHelper.IsHazardous(landOn)) + { + result.SetImpossible(); + return; + } + + // Don't descend from climbable blocks + Material fromDown = ctx.GetMaterial(x, y - 1, z); + if (fromDown.CanBeClimbedOn()) + { + result.SetImpossible(); + return; + } + + // At least one of the two intermediate cardinal directions must be passable + // (player needs clearance to cut the corner). + bool pathViaX = ctx.CanWalkThrough(x + XOffset, y, z) && + ctx.CanWalkThrough(x + XOffset, y + 1, z); + bool pathViaZ = ctx.CanWalkThrough(x, y, z + ZOffset) && + ctx.CanWalkThrough(x, y + 1, z + ZOffset); + + if (!pathViaX && !pathViaZ) + { + result.SetImpossible(); + return; + } + + double cost = ActionCosts.WalkOffBlock * ActionCosts.DiagonalMultiplier + + ActionCosts.FallCost(1); + result.Set(destX, destY, destZ, cost); + } + } +} From 9e6b689dd6170079a68057150a5b8eed084eeed5 Mon Sep 17 00:00:00 2001 From: BruceChen Date: Sun, 12 Apr 2026 01:38:17 +0800 Subject: [PATCH 17/37] feat: add corner walk, sprint descend, and parkour descend moves - MoveDiagonal: allow single-side-blocked diagonals (corner walk) so the bot can hug an open side to cut around a wall; both-sides-blocked remains impossible. Walk-speed cost when one side is blocked. - MoveSprintDescend: sprint off a ledge covering 2 horizontal blocks while dropping 1-3 blocks. Registered for cardinal and diagonal offsets. - MoveParkour: support negative yDelta (-1, -2) for descending parkour where the bot sprint-jumps across a gap and lands on a lower platform. Registered cardinal (dist 2-4, y-1/-2) and diagonal variants. - DescendTemplate: sprint when horizontal distance > 1.5 blocks. - SprintJumpTemplate: increase vertical landing tolerance for descend. Made-with: Cursor --- .../Pathing/Core/AStarPathFinder.cs | 28 ++++ .../Execution/Templates/DescendTemplate.cs | 7 + .../Execution/Templates/SprintJumpTemplate.cs | 4 +- .../Pathing/Moves/Impl/MoveDiagonal.cs | 21 +-- .../Pathing/Moves/Impl/MoveParkour.cs | 12 +- .../Pathing/Moves/Impl/MoveSprintDescend.cs | 122 ++++++++++++++++++ 6 files changed, 183 insertions(+), 11 deletions(-) create mode 100644 MinecraftClient/Pathing/Moves/Impl/MoveSprintDescend.cs diff --git a/MinecraftClient/Pathing/Core/AStarPathFinder.cs b/MinecraftClient/Pathing/Core/AStarPathFinder.cs index 14c4ca08..dd2d7a4e 100644 --- a/MinecraftClient/Pathing/Core/AStarPathFinder.cs +++ b/MinecraftClient/Pathing/Core/AStarPathFinder.cs @@ -59,6 +59,16 @@ namespace MinecraftClient.Pathing.Core moves.Add(new MoveFall()); + // Sprint descend: sprint off ledge, 2 blocks horizontal + 1-3 drop + foreach (int dx in offsets) + { + 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 MoveSprintDescend(0, dz * 2)); + // Cardinal parkour: 2-4 block sprint jumps along +-X and +-Z foreach (int dx in offsets) { @@ -67,6 +77,13 @@ namespace MinecraftClient.Pathing.Core // Ascending: +1Y, dist 2-3 (dist 4 ascend not physically reliable) for (int dist = 2; dist <= 3; dist++) moves.Add(new MoveParkour(dx * dist, 0, yDelta: 1)); + // Descending parkour: sprint-jump, land 1-2 blocks lower + for (int dist = 2; dist <= 4; dist++) + { + moves.Add(new MoveParkour(dx * dist, 0, yDelta: -1)); + if (dist <= 3) + moves.Add(new MoveParkour(dx * dist, 0, yDelta: -2)); + } } foreach (int dz in offsets) { @@ -74,6 +91,12 @@ namespace MinecraftClient.Pathing.Core moves.Add(new MoveParkour(0, dz * dist)); for (int dist = 2; dist <= 3; dist++) moves.Add(new MoveParkour(0, dz * dist, yDelta: 1)); + for (int dist = 2; dist <= 4; dist++) + { + moves.Add(new MoveParkour(0, dz * dist, yDelta: -1)); + if (dist <= 3) + moves.Add(new MoveParkour(0, dz * dist, yDelta: -2)); + } } // Diagonal parkour: sprint jumps at angles. @@ -90,6 +113,11 @@ namespace MinecraftClient.Pathing.Core // (3,1)/(1,3): sqrt(10) ~ 3.16 blocks moves.Add(new MoveParkour(dx * 3, dz * 1)); moves.Add(new MoveParkour(dx * 1, dz * 3)); + + // Diagonal descending parkour + moves.Add(new MoveParkour(dx * 2, dz * 1, yDelta: -1)); + moves.Add(new MoveParkour(dx * 1, dz * 2, yDelta: -1)); + moves.Add(new MoveParkour(dx * 2, dz * 2, yDelta: -1)); } } diff --git a/MinecraftClient/Pathing/Execution/Templates/DescendTemplate.cs b/MinecraftClient/Pathing/Execution/Templates/DescendTemplate.cs index 21185816..9cec3f78 100644 --- a/MinecraftClient/Pathing/Execution/Templates/DescendTemplate.cs +++ b/MinecraftClient/Pathing/Execution/Templates/DescendTemplate.cs @@ -7,6 +7,7 @@ namespace MinecraftClient.Pathing.Execution.Templates /// /// Walk off a ledge and drop 1-N blocks to a landing spot. /// Walks toward the destination; gravity handles the fall. + /// Sprints when the horizontal distance is large (> 1.5 blocks). /// Supports solid landings, water landings, and mid-fall vine/ladder grabs. /// public sealed class DescendTemplate : IActionTemplate @@ -16,11 +17,15 @@ namespace MinecraftClient.Pathing.Execution.Templates private int _tickCount; private bool _hasFallen; + private readonly bool _needsSprint; public DescendTemplate(Location start, Location end) { ExpectedStart = start; ExpectedEnd = end; + double hdx = end.X - start.X; + double hdz = end.Z - start.Z; + _needsSprint = (hdx * hdx + hdz * hdz) > 2.25; } public TemplateState Tick(Location pos, PlayerPhysics physics, MovementInput input) @@ -70,6 +75,8 @@ namespace MinecraftClient.Pathing.Execution.Templates { physics.Yaw = TemplateHelper.SmoothYaw(physics.Yaw, targetYaw); input.Forward = true; + if (_needsSprint) + input.Sprint = true; } return TemplateState.InProgress; diff --git a/MinecraftClient/Pathing/Execution/Templates/SprintJumpTemplate.cs b/MinecraftClient/Pathing/Execution/Templates/SprintJumpTemplate.cs index 7483b083..dc70940f 100644 --- a/MinecraftClient/Pathing/Execution/Templates/SprintJumpTemplate.cs +++ b/MinecraftClient/Pathing/Execution/Templates/SprintJumpTemplate.cs @@ -82,9 +82,9 @@ namespace MinecraftClient.Pathing.Execution.Templates goto case Phase.Landing; case Phase.Landing: - // Tolerance scales with jump distance double horizTolerance = _horizDist >= 3.5 ? 3.0 : 2.0; - if (horizDistSq < horizTolerance && Math.Abs(dy) < 1.0) + double vertTolerance = Math.Abs(ExpectedEnd.Y - ExpectedStart.Y) > 0.5 ? 1.5 : 1.0; + if (horizDistSq < horizTolerance && Math.Abs(dy) < vertTolerance) return TemplateState.Complete; return TemplateState.Failed; } diff --git a/MinecraftClient/Pathing/Moves/Impl/MoveDiagonal.cs b/MinecraftClient/Pathing/Moves/Impl/MoveDiagonal.cs index 70e78d77..8b43a2bd 100644 --- a/MinecraftClient/Pathing/Moves/Impl/MoveDiagonal.cs +++ b/MinecraftClient/Pathing/Moves/Impl/MoveDiagonal.cs @@ -4,7 +4,9 @@ namespace MinecraftClient.Pathing.Moves.Impl { /// /// Diagonal walk (1 block in both X and Z, same Y). - /// Checks both intermediate cardinal columns for clearance. + /// Allows corner walks: if one intermediate cardinal is blocked by a wall + /// but the other is clear, the player can hug the open side to cut the + /// corner. Both sides blocked is impossible (player AABB too wide). /// public sealed class MoveDiagonal : IMove { @@ -36,19 +38,22 @@ namespace MinecraftClient.Pathing.Moves.Impl return; } - if (!ctx.CanWalkThrough(x + XOffset, y, z) || !ctx.CanWalkThrough(x + XOffset, y + 1, z)) + bool sideX = ctx.CanWalkThrough(x + XOffset, y, z) && + ctx.CanWalkThrough(x + XOffset, y + 1, z); + bool sideZ = ctx.CanWalkThrough(x, y, z + ZOffset) && + ctx.CanWalkThrough(x, y + 1, z + ZOffset); + + if (!sideX && !sideZ) { result.SetImpossible(); return; } - if (!ctx.CanWalkThrough(x, y, z + ZOffset) || !ctx.CanWalkThrough(x, y + 1, z + ZOffset)) - { - result.SetImpossible(); - return; - } + double cost = ctx.SprintCost * ActionCosts.DiagonalMultiplier; + if (!sideX || !sideZ) + cost = ctx.WalkCost * ActionCosts.DiagonalMultiplier; - result.Set(destX, y, destZ, ctx.SprintCost * ActionCosts.DiagonalMultiplier); + result.Set(destX, y, destZ, cost); } } } diff --git a/MinecraftClient/Pathing/Moves/Impl/MoveParkour.cs b/MinecraftClient/Pathing/Moves/Impl/MoveParkour.cs index 278ffe06..fc442b9c 100644 --- a/MinecraftClient/Pathing/Moves/Impl/MoveParkour.cs +++ b/MinecraftClient/Pathing/Moves/Impl/MoveParkour.cs @@ -6,7 +6,8 @@ namespace MinecraftClient.Pathing.Moves.Impl { /// /// Sprint jump across a gap in cardinal or diagonal direction. - /// Supports horizontal distances of 2-4 blocks and optional +1Y ascent. + /// Supports horizontal distances of 2-4 blocks, optional +1Y ascent, + /// and -1/-2Y descent (land on a lower platform after the jump). /// Based on Baritone's MovementParkour design with diagonal extensions. /// public sealed class MoveParkour : IMove @@ -44,6 +45,12 @@ namespace MinecraftClient.Pathing.Moves.Impl return; } + if (_yDelta < 0 && -_yDelta > ctx.MaxFallHeight) + { + result.SetImpossible(); + return; + } + if (!ctx.CanSprint) { result.SetImpossible(); @@ -172,6 +179,9 @@ namespace MinecraftClient.Pathing.Moves.Impl double cost; if (_yDelta > 0) cost = horizDist * ctx.SprintCost + ctx.JumpPenalty * 2; + else if (_yDelta < 0) + cost = horizDist * ctx.SprintCost + ctx.JumpPenalty + + ActionCosts.FallCost(-_yDelta); else if (horizDist >= 3.5) cost = horizDist * ctx.SprintCost + ctx.JumpPenalty; else diff --git a/MinecraftClient/Pathing/Moves/Impl/MoveSprintDescend.cs b/MinecraftClient/Pathing/Moves/Impl/MoveSprintDescend.cs new file mode 100644 index 00000000..6acd9813 --- /dev/null +++ b/MinecraftClient/Pathing/Moves/Impl/MoveSprintDescend.cs @@ -0,0 +1,122 @@ +using System; +using MinecraftClient.Mapping; +using MinecraftClient.Pathing.Core; + +namespace MinecraftClient.Pathing.Moves.Impl +{ + /// + /// Sprint off a ledge and land 2 blocks away horizontally while dropping 1-3 blocks. + /// At sprint speed (~5.6 blocks/s), falling 1-3 blocks gives enough airtime to + /// cover 2 horizontal blocks without needing a jump. + /// Supports cardinal (2,0)/(0,2) and diagonal (1,1) offsets. + /// + public sealed class MoveSprintDescend : IMove + { + public MoveType Type => MoveType.Descend; + public int XOffset { get; } + public int ZOffset { get; } + public bool DynamicY => true; + + public MoveSprintDescend(int xOffset, int zOffset) + { + XOffset = xOffset; + ZOffset = zOffset; + } + + public void Calculate(CalculationContext ctx, int x, int y, int z, ref MoveResult result) + { + if (!ctx.CanSprint) + { + result.SetImpossible(); + return; + } + + int destX = x + XOffset; + int destZ = z + ZOffset; + + Material fromDown = ctx.GetMaterial(x, y - 1, z); + if (fromDown.CanBeClimbedOn()) + { + result.SetImpossible(); + return; + } + + int xSign = Math.Sign(XOffset); + int zSign = Math.Sign(ZOffset); + int xAbs = Math.Abs(XOffset); + int zAbs = Math.Abs(ZOffset); + + // The flight path sweeps through intermediate blocks at current Y. + // Check body clearance for all intermediate and destination columns. + for (int i = 0; i <= xAbs; i++) + { + for (int j = 0; j <= zAbs; j++) + { + if (i == 0 && j == 0) continue; + int gx = x + xSign * i; + int gz = z + zSign * j; + if (!ctx.CanWalkThrough(gx, y, gz) || !ctx.CanWalkThrough(gx, y + 1, gz)) + { + result.SetImpossible(); + return; + } + } + } + + // The first step in the primary direction must lack ground (this IS a drop). + if (xAbs > 0 && zAbs == 0) + { + if (ctx.CanWalkOn(x + xSign, y - 1, z)) + { + result.SetImpossible(); + return; + } + } + else if (xAbs == 0 && zAbs > 0) + { + if (ctx.CanWalkOn(x, y - 1, z + zSign)) + { + result.SetImpossible(); + return; + } + } + else + { + if (ctx.CanWalkOn(x + xSign, y - 1, z + zSign)) + { + result.SetImpossible(); + return; + } + } + + // Scan downward from destination column for a landing spot. + double horizDist = Math.Sqrt((double)(XOffset * XOffset + ZOffset * ZOffset)); + for (int drop = 1; drop <= ctx.MaxFallHeight; drop++) + { + int landY = y - drop - 1; + if (landY < -64) break; + + if (!ctx.CanWalkOn(destX, landY, destZ)) + continue; + + Material landMat = ctx.GetMaterial(destX, landY, destZ); + if (MoveHelper.IsHazardous(landMat)) + { + result.SetImpossible(); + return; + } + + // Body space at landing + if (!ctx.CanWalkThrough(destX, landY + 1, destZ) || + !ctx.CanWalkThrough(destX, landY + 2, destZ)) + continue; + + double cost = horizDist * ctx.SprintCost + ActionCosts.FallCost(drop); + result.Set(destX, landY + 1, destZ, cost); + return; + } + + result.SetImpossible(); + } + } +} From 00494078df4fbd2c9ac28dbae78946d775fc48fc Mon Sep 17 00:00:00 2001 From: BruceChen Date: Sun, 12 Apr 2026 02:02:58 +0800 Subject: [PATCH 18/37] fix: parkour diagonal flight path, wall-adjacent parkour, sprint descend checks - MoveParkour: replace full-rectangle intermediate check with diagonal strip check (CheckFlightPath) so walls outside the actual flight corridor no longer block valid parkour jumps. - MoveParkour: require both cardinal neighbors passable for diagonal parkour takeoff; a wall on either side clips the AABB and prevents reaching the target. - MoveSprintDescend: replace full-rectangle check with explicit per-axis intermediate column check. - SprintJumpTemplate: track diagonal jumps and skip approach delay for short diagonal jumps to avoid overshooting small starting platforms. Made-with: Cursor --- .../Execution/Templates/SprintJumpTemplate.cs | 6 +- .../Pathing/Moves/Impl/MoveParkour.cs | 128 ++++++++++++++---- .../Pathing/Moves/Impl/MoveSprintDescend.cs | 31 +++-- 3 files changed, 125 insertions(+), 40 deletions(-) diff --git a/MinecraftClient/Pathing/Execution/Templates/SprintJumpTemplate.cs b/MinecraftClient/Pathing/Execution/Templates/SprintJumpTemplate.cs index dc70940f..eed7b472 100644 --- a/MinecraftClient/Pathing/Execution/Templates/SprintJumpTemplate.cs +++ b/MinecraftClient/Pathing/Execution/Templates/SprintJumpTemplate.cs @@ -18,6 +18,7 @@ namespace MinecraftClient.Pathing.Execution.Templates public Location ExpectedEnd { get; } private readonly double _horizDist; + private readonly bool _isDiagonal; private int _tickCount; private Phase _phase = Phase.Approach; @@ -28,6 +29,7 @@ namespace MinecraftClient.Pathing.Execution.Templates double dx = end.X - start.X; double dz = end.Z - start.Z; _horizDist = Math.Sqrt(dx * dx + dz * dz); + _isDiagonal = Math.Abs(dx) > 0.5 && Math.Abs(dz) > 0.5; } public TemplateState Tick(Location pos, PlayerPhysics physics, MovementInput input) @@ -57,10 +59,12 @@ namespace MinecraftClient.Pathing.Execution.Templates // toward the block edge. Baritone waits until playerFeet is in // the next block (~0.5 blocks from center) for dist >= 4. // For medium jumps (dist 3), wait 0.35 blocks (Baritone: 0.7). + // For short diagonal jumps (<= 3 blocks), jump immediately + // to avoid overshooting the small starting platform. double minApproachSq; if (_horizDist >= 3.5) minApproachSq = 0.25; // 0.5 blocks - else if (_horizDist >= 2.5) + else if (_horizDist >= 2.5 && !_isDiagonal) minApproachSq = 0.12; // ~0.35 blocks else minApproachSq = 0.0; diff --git a/MinecraftClient/Pathing/Moves/Impl/MoveParkour.cs b/MinecraftClient/Pathing/Moves/Impl/MoveParkour.cs index fc442b9c..410e5586 100644 --- a/MinecraftClient/Pathing/Moves/Impl/MoveParkour.cs +++ b/MinecraftClient/Pathing/Moves/Impl/MoveParkour.cs @@ -103,32 +103,14 @@ namespace MinecraftClient.Pathing.Moves.Impl int xAbs = Math.Abs(XOffset); int zAbs = Math.Abs(ZOffset); - // Check intermediate space for passability (the player's bounding box sweeps - // through a rectangle from start to end; check all blocks in that rectangle) - for (int i = 0; i <= xAbs; i++) + // Check intermediate blocks along the flight path. + // Cardinal: check all blocks in the column along the primary axis. + // Diagonal: check blocks along the diagonal strip, not the full rectangle. + // Player AABB is 0.6 wide, so only blocks near the diagonal line matter. + if (!CheckFlightPath(ctx, x, y, z, xSign, zSign, xAbs, zAbs)) { - for (int j = 0; j <= zAbs; j++) - { - if (i == 0 && j == 0) continue; - if (i == xAbs && j == zAbs) continue; - - int gx = x + xSign * i; - int gz = z + zSign * j; - - if (!ctx.CanWalkThrough(gx, y, gz) || - !ctx.CanWalkThrough(gx, y + 1, gz) || - !ctx.CanWalkThrough(gx, y + 2, gz)) - { - result.SetImpossible(); - return; - } - - if (_yDelta > 0 && !ctx.CanWalkThrough(gx, y + 3, gz)) - { - result.SetImpossible(); - return; - } - } + result.SetImpossible(); + return; } // Gap check: first block(s) adjacent to start must lack ground. @@ -159,6 +141,23 @@ namespace MinecraftClient.Pathing.Moves.Impl } } + // For diagonal parkour, the player's AABB (0.6 wide) must clear both + // cardinal neighbors at the start. A wall on either side will clip the + // AABB during the initial sprint, preventing enough X or Z velocity to + // reach the target. Require BOTH cardinal exits to be passable. + if (xAbs > 0 && zAbs > 0) + { + bool canExitViaX = ctx.CanWalkThrough(x + xSign, y, z) && + ctx.CanWalkThrough(x + xSign, y + 1, z); + bool canExitViaZ = ctx.CanWalkThrough(x, y, z + zSign) && + ctx.CanWalkThrough(x, y + 1, z + zSign); + if (!canExitViaX || !canExitViaZ) + { + result.SetImpossible(); + return; + } + } + // Overshoot safety: after landing, player continues moving. // The block(s) past the destination in the jump direction must be passable. int overX = destX + xSign; @@ -190,6 +189,85 @@ namespace MinecraftClient.Pathing.Moves.Impl result.Set(destX, destY, destZ, cost); } + /// + /// Check body clearance along the flight path from start toward the destination. + /// For cardinal moves, checks a straight line. For diagonal moves, checks + /// only blocks near the actual diagonal trajectory rather than the full bounding + /// rectangle, allowing jumps that pass a wall on one side. + /// + private bool CheckFlightPath( + CalculationContext ctx, int x, int y, int z, + int xSign, int zSign, int xAbs, int zAbs) + { + if (xAbs == 0 || zAbs == 0) + { + // Cardinal: single axis, check each block along the line + for (int step = 1; step < Math.Max(xAbs, zAbs); step++) + { + int gx = x + xSign * (xAbs > 0 ? step : 0); + int gz = z + zSign * (zAbs > 0 ? step : 0); + if (!ClearColumn(ctx, gx, y, gz)) + return false; + } + return true; + } + + // Diagonal: walk the diagonal and check each block the AABB touches. + // At each step t along the diagonal, the player center is near + // (x + t*xSign, z + t*zSign). The AABB extends 0.3 blocks each side, + // so check the diagonal cell and one neighbor on each axis-aligned side + // only when the trajectory is close to a cell boundary (always for short + // diagonals). We enumerate cells by stepping through the longer axis + // and computing the corresponding position on the shorter axis. + int maxSteps = Math.Max(xAbs, zAbs); + for (int step = 1; step < maxSteps; step++) + { + // Proportional position along each axis + double fx = (double)step * xAbs / maxSteps; + double fz = (double)step * zAbs / maxSteps; + + int ix = (int)Math.Round(fx); + int iz = (int)Math.Round(fz); + + int gx = x + xSign * ix; + int gz = z + zSign * iz; + + if (!ClearColumn(ctx, gx, y, gz)) + return false; + + // Also check the neighboring cell across the shorter axis when close + // to a cell boundary (player AABB overlaps adjacent cell) + if (xAbs != zAbs) + { + double fracX = fx - Math.Floor(fx); + double fracZ = fz - Math.Floor(fz); + if (fracX > 0.2 && fracX < 0.8 && ix > 0 && ix < xAbs) + { + if (!ClearColumn(ctx, x + xSign * (ix - 1), y, gz)) + return false; + } + if (fracZ > 0.2 && fracZ < 0.8 && iz > 0 && iz < zAbs) + { + if (!ClearColumn(ctx, gx, y, z + zSign * (iz - 1))) + return false; + } + } + } + + return true; + } + + private bool ClearColumn(CalculationContext ctx, int gx, int y, int gz) + { + if (!ctx.CanWalkThrough(gx, y, gz) || + !ctx.CanWalkThrough(gx, y + 1, gz) || + !ctx.CanWalkThrough(gx, y + 2, gz)) + return false; + if (_yDelta > 0 && !ctx.CanWalkThrough(gx, y + 3, gz)) + return false; + return true; + } + public override string ToString() { double dist = Math.Sqrt((double)(XOffset * XOffset + ZOffset * ZOffset)); diff --git a/MinecraftClient/Pathing/Moves/Impl/MoveSprintDescend.cs b/MinecraftClient/Pathing/Moves/Impl/MoveSprintDescend.cs index 6acd9813..9a1c6d66 100644 --- a/MinecraftClient/Pathing/Moves/Impl/MoveSprintDescend.cs +++ b/MinecraftClient/Pathing/Moves/Impl/MoveSprintDescend.cs @@ -46,21 +46,24 @@ namespace MinecraftClient.Pathing.Moves.Impl int xAbs = Math.Abs(XOffset); int zAbs = Math.Abs(ZOffset); - // The flight path sweeps through intermediate blocks at current Y. - // Check body clearance for all intermediate and destination columns. - for (int i = 0; i <= xAbs; i++) + // Check body clearance at the destination column and along the flight path. + if (!ctx.CanWalkThrough(destX, y, destZ) || !ctx.CanWalkThrough(destX, y + 1, destZ)) { - for (int j = 0; j <= zAbs; j++) - { - if (i == 0 && j == 0) continue; - int gx = x + xSign * i; - int gz = z + zSign * j; - if (!ctx.CanWalkThrough(gx, y, gz) || !ctx.CanWalkThrough(gx, y + 1, gz)) - { - result.SetImpossible(); - return; - } - } + result.SetImpossible(); + return; + } + + // For cardinal (2,0)/(0,2): check the one intermediate column. + // For diagonal (1,1): destination IS one step away, no intermediate. + if (xAbs == 2 && zAbs == 0) + { + if (!ctx.CanWalkThrough(x + xSign, y, z) || !ctx.CanWalkThrough(x + xSign, y + 1, z)) + { result.SetImpossible(); return; } + } + else if (xAbs == 0 && zAbs == 2) + { + if (!ctx.CanWalkThrough(x, y, z + zSign) || !ctx.CanWalkThrough(x, y + 1, z + zSign)) + { result.SetImpossible(); return; } } // The first step in the primary direction must lack ground (this IS a drop). From 945eae958a4d6e6f45ba4d91571416a76c781a48 Mon Sep 17 00:00:00 2001 From: BruceChen Date: Sun, 12 Apr 2026 16:43:05 +0800 Subject: [PATCH 19/37] docs: add slab support scheme two design spec --- ...26-04-12-slab-support-scheme-two-design.md | 299 ++++++++++++++++++ 1 file changed, 299 insertions(+) create mode 100644 docs/superpowers/specs/2026-04-12-slab-support-scheme-two-design.md diff --git a/docs/superpowers/specs/2026-04-12-slab-support-scheme-two-design.md b/docs/superpowers/specs/2026-04-12-slab-support-scheme-two-design.md new file mode 100644 index 00000000..61be4082 --- /dev/null +++ b/docs/superpowers/specs/2026-04-12-slab-support-scheme-two-design.md @@ -0,0 +1,299 @@ +# Slab Support Design, Scheme Two + +Date: 2026-04-12 +Status: Approved for implementation planning + +## Summary + +This design adds basic slab support to the current A* pathfinder without introducing half-block nodes. + +The goal is practical rather than perfect: make normal routing work across slabs, allow takeoff from slabs, allow landing on slabs when the fall is still within the current safe range, and keep the search space close to what it is today. + +The key constraint is that the current pathfinder stores integer `(x, y, z)` nodes and the execution layer still expects block-center waypoints. That is staying in place for this iteration. + +## What This Change Should Cover + +- Walking across bottom slabs, top slabs, and full blocks +- Moving up and down neighboring `0.5` block height differences +- Starting jumps from slabs +- Landing on slabs when the effective fall height is safe +- Keeping current parkour and descend behavior stable instead of trying to make slab parkour exhaustive + +## What This Change Will Not Cover + +- True half-block path nodes +- A general solution for all non-full-block surfaces such as stairs, carpets, snow layers, trapdoors, and similar terrain +- Full slab-aware parkour optimization +- A new cost model tuned around half-block travel times + +## Current Problem + +The physics layer can already step up `0.5` blocks and collide with slab shapes correctly. The planning layer cannot. It still treats movement as if every valid floor is a full block surface. + +That mismatch shows up in three places: + +- `MoveHelper` still answers most walkability questions at the `Material` level. +- The move set assumes floor height changes happen in whole blocks. +- Path segments still convert nodes to `(x + 0.5, y, z + 0.5)` and do not carry surface-height metadata. + +Because of that, basic slab terrain is either invisible to the planner or handled inconsistently. + +## Core Approach + +### Keep Integer Nodes + +The pathfinder will keep integer `(x, y, z)` nodes. This avoids doubling the vertical state space and keeps the current move graph shape. + +The cost is that slabs must be represented indirectly. That is acceptable for this iteration because the target is reliable routing, not full geometric precision. + +### Add Surface Profiles + +Planning will stop asking only "is this material solid?" and instead ask "what standing surface does this block column provide?" + +Each relevant block column will map to a small surface profile: + +- `None` +- `FullBlock` +- `TopSlab` +- `BottomSlab` + +For the first implementation, the source of truth is `BlockShapes`. Slabs already have distinct collision boxes there, including top and bottom variants. + +The profile also exposes the standing surface top Y relative to the block base: + +- `FullBlock` -> `1.0` +- `TopSlab` -> `1.0` +- `BottomSlab` -> `0.5` +- `None` -> not standable + +This gives the planner enough information to answer the questions it actually needs: + +- can the player stand here +- how high is the standing surface +- what is the effective fall height if the player lands here + +### Use an Alias-Y Model + +Nodes remain integer Y values even when the actual standing surface is at `.5`. + +The aliasing rule is: + +- a bottom slab standing surface inside block `(x, y - 1, z)` is still represented by node `y` +- the node Y means "feet are in this logical cell", not "feet are exactly on integer Y" + +This preserves compatibility with the current pathfinder and avoids widening the state space. + +## Movement Rules + +### Traverse And Diagonal Movement + +Flat movement will become "same effective standing height" movement, not just "same integer Y" movement. + +These cases should be allowed: + +- full block to full block +- full block to top slab +- top slab to full block +- bottom slab to bottom slab + +These cases should not be forced through the flat move set: + +- full block to bottom slab +- bottom slab to full block + +Those are `-0.5` and `+0.5` height changes and should be handled explicitly. + +### Half-Step Moves + +Add dedicated half-step moves: + +- `MoveHalfAscend` +- `MoveHalfDescend` + +First implementation scope: + +- cardinal half-step moves are included +- diagonal half-step moves are out of scope + +These moves are for adjacent columns whose standing surface differs by `0.5`. + +Execution for half-step moves must not press jump. The physics engine should handle them as a step-up or controlled walk-down. + +### Full-Block Ascend And Descend + +Existing `MoveAscend`, `MoveDescend`, `MoveFall`, and `MoveSprintDescend` remain in place, but their landing and clearance checks become surface-aware. + +The main difference is that the destination surface is no longer assumed to be exactly one block high relative to the block base. + +### Parkour + +Parkour is not getting a full slab rewrite in this iteration. + +The planner should: + +- allow takeoff from a slab if the start surface is valid +- allow landing on a slab if the effective fall and required clearance are valid +- avoid adding new slab-specific parkour move families in this change + +This keeps the change small enough to validate. + +## Safe Landing Rule For Bottom Slabs + +Bottom slabs should be allowed as fall destinations when the effective fall height is still within the current safe fall limit. + +This rule replaces the earlier blanket rejection. + +### Definition + +Use: + +`effectiveFallHeight = startSurfaceTopY - landingSurfaceTopY` + +with both heights measured in world coordinates. + +Given the current `MaxFallHeight = 3.0`, these examples should hold: + +- bottom slab to a bottom slab three blocks lower: allowed, because `0.5 -> -2.5` is an effective fall of `3.0` +- full block to a bottom slab `2.5` blocks lower: allowed +- full block to a bottom slab `3.5` blocks lower: rejected + +This matches the behavior we want: + +- support realistic slab landings +- keep the current safety ceiling +- avoid special casing by integer block count alone + +### Cost Model + +The fall cost table is still integer-based. For half-block fall distances, the first iteration will round up when consulting the fall-cost table. + +Examples: + +- `2.0` -> use `FallCost(2)` +- `2.5` -> use `FallCost(3)` +- `3.0` -> use `FallCost(3)` + +This is slightly conservative, which is fine for now. It avoids pretending the path is cheaper than the current planner knows how to represent. + +## Execution Layer Changes + +The execution layer needs a small amount of slab metadata so completion checks do not rely on loose tolerances alone. + +`PathSegment` should carry enough information for templates to know whether the start or end uses a half-height standing surface. A minimal version is: + +- start surface offset +- end surface offset + +with offsets of `0.0` or `-0.5` relative to the logical node Y. + +This metadata is only for execution and verification. It should not turn into a new search-state dimension. + +### New Templates + +Add: + +- `HalfAscendTemplate` +- `HalfDescendTemplate` + +Behavior: + +- face the target +- move forward +- do not sprint in the first implementation +- do not press jump +- use tighter completion checks that include the expected end surface offset + +Existing templates may also need small updates so slab takeoff and slab landing do not cause false stuck detection or early completion. + +## File-Level Impact + +Expected touch points: + +- `MinecraftClient/Pathing/Moves/MoveHelper.cs` +- `MinecraftClient/Pathing/Core/CalculationContext.cs` +- `MinecraftClient/Pathing/Moves/Impl/MoveTraverse.cs` +- `MinecraftClient/Pathing/Moves/Impl/MoveDiagonal.cs` +- `MinecraftClient/Pathing/Moves/Impl/MoveAscend.cs` +- `MinecraftClient/Pathing/Moves/Impl/MoveDescend.cs` +- `MinecraftClient/Pathing/Moves/Impl/MoveFall.cs` +- `MinecraftClient/Pathing/Moves/Impl/MoveSprintDescend.cs` +- new half-step move files under `MinecraftClient/Pathing/Moves/Impl/` +- `MinecraftClient/Pathing/Core/AStarPathFinder.cs` +- `MinecraftClient/Pathing/Execution/PathSegment.cs` +- new half-step template files under `MinecraftClient/Pathing/Execution/Templates/` +- template factory / executor wiring + +## Performance Expectations + +This design should not materially expand the search space because nodes stay integer-based. + +The expected overhead comes from: + +- extra `BlockShapes` lookups during move validation +- a few more comparisons per move +- a small number of extra move types + +That is a constant-factor increase, not a state explosion. + +The main thing to avoid is introducing separate `.0` and `.5` Y states into the open set. This design does not do that. + +## Risks + +### Alias-Y Drift + +The biggest risk is mismatch between logical node Y and the player's actual surface height. If the segment metadata is too thin, templates may oscillate, finish too early, or trigger unnecessary replans. + +### Clearance Mistakes + +A bottom slab under a low ceiling is the easiest place to get this wrong. Surface-aware standability is not enough by itself. The move checks still need to verify body and head clearance against the actual shapes involved. + +### Scope Creep + +Once slab support works, stairs and snow layers will look tempting. They are out of scope for this change. + +## Test Plan + +### Planner-Level Cases + +Build focused tests around these scenarios: + +- full -> bottom slab +- bottom slab -> full +- bottom slab -> bottom slab +- full -> top slab +- top slab -> full +- slab takeoff for jump and parkour moves +- solid landing on top slab +- solid landing on bottom slab with effective fall `<= 3.0` +- solid landing on bottom slab with effective fall `> 3.0` +- slab under a low ceiling + +### Physics And Execution Checks + +Use `tools/sim_jump_reach.py` to validate the intended reachability envelope and then run local server checks for: + +- `/goto` across mixed full-block and slab terrain +- repeated slab transitions without replan loops +- takeoff from slab to slab and slab to full block +- landing on bottom slabs at `2.5` and `3.0` effective fall distances +- rejection of `3.5` effective-fall bottom-slab landings + +## Implementation Notes + +The first implementation should favor readable helper code over micro-optimizing shape checks. If the new helper becomes hot, caching can be added after behavior is stable. + +The safest rollout order is: + +1. Add surface-profile helpers +2. Update landing logic and safe-fall logic +3. Add half-step moves and templates +4. Expand move coverage only after the basic route cases are stable + +## Decision + +Proceed with scheme two: + +- integer nodes stay +- slab surfaces are modeled through shape-aware helpers +- bottom slab landings are allowed when effective fall height stays within the existing safe limit +- no attempt is made to solve the general non-full-block terrain problem in this pass From 3b4e552d70fb5e240429ed99b810e3c2d9e51a3a Mon Sep 17 00:00:00 2001 From: BruceChen Date: Sun, 12 Apr 2026 18:46:42 +0800 Subject: [PATCH 20/37] feat: add transition-aware path execution braking --- .../MinecraftClient.Tests.csproj | 25 ++++ .../Pathing/Execution/FlatWorldTestBuilder.cs | 51 +++++++ .../Execution/PathExecutorCompletionTests.cs | 41 ++++++ .../Execution/PathSegmentBuilderTests.cs | 64 ++++++++ .../Pathing/Execution/TemplateBrakingTests.cs | 79 ++++++++++ .../TransitionBrakingPlannerTests.cs | 110 ++++++++++++++ MinecraftClient.sln | 14 ++ .../Execution/ActionTemplateFactory.cs | 16 +- .../Pathing/Execution/IActionTemplate.cs | 2 +- .../Pathing/Execution/PathExecutor.cs | 12 +- .../Pathing/Execution/PathSegment.cs | 24 +-- .../Pathing/Execution/PathSegmentBuilder.cs | 67 +++++++++ .../Pathing/Execution/PathSegmentManager.cs | 6 +- .../Pathing/Execution/PathTransitionType.cs | 11 ++ .../Execution/Templates/AscendTemplate.cs | 34 ++++- .../Execution/Templates/ClimbTemplate.cs | 10 +- .../Execution/Templates/DescendTemplate.cs | 47 ++++-- .../Execution/Templates/FallTemplate.cs | 19 ++- .../Execution/Templates/SprintJumpTemplate.cs | 139 ++++++++++++++---- .../Execution/Templates/TemplateHelper.cs | 24 +++ .../Execution/Templates/WalkTemplate.cs | 29 ++-- .../Execution/TransitionBrakingDecision.cs | 14 ++ .../Execution/TransitionBrakingPlanner.cs | 107 ++++++++++++++ 23 files changed, 837 insertions(+), 108 deletions(-) create mode 100644 MinecraftClient.Tests/MinecraftClient.Tests.csproj create mode 100644 MinecraftClient.Tests/Pathing/Execution/FlatWorldTestBuilder.cs create mode 100644 MinecraftClient.Tests/Pathing/Execution/PathExecutorCompletionTests.cs create mode 100644 MinecraftClient.Tests/Pathing/Execution/PathSegmentBuilderTests.cs create mode 100644 MinecraftClient.Tests/Pathing/Execution/TemplateBrakingTests.cs create mode 100644 MinecraftClient.Tests/Pathing/Execution/TransitionBrakingPlannerTests.cs create mode 100644 MinecraftClient/Pathing/Execution/PathSegmentBuilder.cs create mode 100644 MinecraftClient/Pathing/Execution/PathTransitionType.cs create mode 100644 MinecraftClient/Pathing/Execution/TransitionBrakingDecision.cs create mode 100644 MinecraftClient/Pathing/Execution/TransitionBrakingPlanner.cs diff --git a/MinecraftClient.Tests/MinecraftClient.Tests.csproj b/MinecraftClient.Tests/MinecraftClient.Tests.csproj new file mode 100644 index 00000000..937d0e56 --- /dev/null +++ b/MinecraftClient.Tests/MinecraftClient.Tests.csproj @@ -0,0 +1,25 @@ + + + net10.0 + enable + enable + true + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + diff --git a/MinecraftClient.Tests/Pathing/Execution/FlatWorldTestBuilder.cs b/MinecraftClient.Tests/Pathing/Execution/FlatWorldTestBuilder.cs new file mode 100644 index 00000000..5f282b29 --- /dev/null +++ b/MinecraftClient.Tests/Pathing/Execution/FlatWorldTestBuilder.cs @@ -0,0 +1,51 @@ +using System; +using System.Threading; +using MinecraftClient.Mapping; + +namespace MinecraftClient.Tests.Pathing.Execution; + +internal static class FlatWorldTestBuilder +{ + private static readonly Lock InitLock = new(); + private static bool _defaultsLoaded; + + public static World CreateStoneFloor(int floorY = 79, int min = -32, int max = 32) + { + EnsureDefaultDimensionsLoaded(); + World.SetDimension("minecraft:overworld"); + + var world = new World(); + int minChunk = (int)Math.Floor(min / 16.0); + int maxChunk = (int)Math.Floor(max / 16.0); + + for (int chunkX = minChunk; chunkX <= maxChunk; chunkX++) + { + for (int chunkZ = minChunk; chunkZ <= maxChunk; chunkZ++) + { + world[chunkX, chunkZ] = new ChunkColumn(24) { FullyLoaded = true }; + } + } + + for (int x = min; x <= max; x++) + { + for (int z = min; z <= max; z++) + { + world.SetBlock(new Location(x, floorY, z), new Block(1)); + } + } + + return world; + } + + private static void EnsureDefaultDimensionsLoaded() + { + lock (InitLock) + { + if (_defaultsLoaded) + return; + + World.LoadDefaultDimensions1206Plus(); + _defaultsLoaded = true; + } + } +} diff --git a/MinecraftClient.Tests/Pathing/Execution/PathExecutorCompletionTests.cs b/MinecraftClient.Tests/Pathing/Execution/PathExecutorCompletionTests.cs new file mode 100644 index 00000000..cf8920df --- /dev/null +++ b/MinecraftClient.Tests/Pathing/Execution/PathExecutorCompletionTests.cs @@ -0,0 +1,41 @@ +using MinecraftClient.Mapping; +using MinecraftClient.Pathing.Core; +using MinecraftClient.Pathing.Execution; +using MinecraftClient.Physics; +using Xunit; + +namespace MinecraftClient.Tests.Pathing.Execution; + +public sealed class PathExecutorCompletionTests +{ + [Fact] + public void Tick_ClearsMovementInput_WhenSegmentCompletes() + { + var executor = new PathExecutor(new List + { + new() + { + Start = new Location(0.5, 80, 0.5), + End = new Location(1.5, 80, 0.5), + MoveType = MoveType.Traverse + } + }); + + var physics = new PlayerPhysics + { + Yaw = 270f, + Pitch = 0f + }; + var input = new MovementInput(); + var pos = new Location(1.48, 80, 0.5); + World world = FlatWorldTestBuilder.CreateStoneFloor(); + + var state = executor.Tick(pos, physics, input, world); + + Assert.Equal(PathExecutorState.Complete, state); + Assert.False(input.Forward); + Assert.False(input.Sprint); + Assert.False(input.Jump); + Assert.False(input.Back); + } +} diff --git a/MinecraftClient.Tests/Pathing/Execution/PathSegmentBuilderTests.cs b/MinecraftClient.Tests/Pathing/Execution/PathSegmentBuilderTests.cs new file mode 100644 index 00000000..a15dc070 --- /dev/null +++ b/MinecraftClient.Tests/Pathing/Execution/PathSegmentBuilderTests.cs @@ -0,0 +1,64 @@ +using System.Collections.Generic; +using MinecraftClient.Pathing.Core; +using MinecraftClient.Pathing.Execution; +using Xunit; + +namespace MinecraftClient.Tests.Pathing.Execution; + +public sealed class PathSegmentBuilderTests +{ + [Fact] + public void FromPath_AnnotatesStraightTraverse_AsContinueStraight() + { + var nodes = BuildNodes( + (0, 80, 0, MoveType.Traverse), + (1, 80, 0, MoveType.Traverse), + (2, 80, 0, MoveType.Traverse)); + + List segments = PathSegmentBuilder.FromPath(nodes); + + Assert.Equal(PathTransitionType.ContinueStraight, segments[0].ExitTransition); + Assert.True(segments[0].PreserveSprint); + } + + [Fact] + public void FromPath_AnnotatesOrthogonalTraverse_AsTurn() + { + var nodes = BuildNodes( + (0, 80, 0, MoveType.Traverse), + (1, 80, 0, MoveType.Traverse), + (1, 80, 1, MoveType.Traverse)); + + List segments = PathSegmentBuilder.FromPath(nodes); + + Assert.Equal(PathTransitionType.Turn, segments[0].ExitTransition); + Assert.False(segments[0].PreserveSprint); + } + + [Fact] + public void FromPath_AnnotatesTraverseIntoParkour_AsPrepareJump() + { + var nodes = BuildNodes( + (120, 80, 110, MoveType.Traverse), + (121, 80, 110, MoveType.Traverse), + (123, 80, 110, MoveType.Parkour)); + + List segments = PathSegmentBuilder.FromPath(nodes); + + Assert.Equal(PathTransitionType.PrepareJump, segments[0].ExitTransition); + Assert.True(segments[0].PreserveSprint); + } + + private static List BuildNodes(params (int x, int y, int z, MoveType moveUsed)[] raw) + { + var result = new List(raw.Length); + for (int i = 0; i < raw.Length; i++) + { + var node = new PathNode(raw[i].x, raw[i].y, raw[i].z); + if (i > 0) + node.MoveUsed = raw[i].moveUsed; + result.Add(node); + } + return result; + } +} diff --git a/MinecraftClient.Tests/Pathing/Execution/TemplateBrakingTests.cs b/MinecraftClient.Tests/Pathing/Execution/TemplateBrakingTests.cs new file mode 100644 index 00000000..4e7c4436 --- /dev/null +++ b/MinecraftClient.Tests/Pathing/Execution/TemplateBrakingTests.cs @@ -0,0 +1,79 @@ +using MinecraftClient.Mapping; +using MinecraftClient.Pathing.Core; +using MinecraftClient.Pathing.Execution; +using MinecraftClient.Pathing.Execution.Templates; +using MinecraftClient.Physics; +using Xunit; + +namespace MinecraftClient.Tests.Pathing.Execution; + +public sealed class TemplateBrakingTests +{ + [Fact] + public void WalkTemplate_BackBrakes_WhenFinalStopIsTooClose() + { + World world = FlatWorldTestBuilder.CreateStoneFloor(); + var segment = new PathSegment + { + Start = new Location(0.5, 80, 0.5), + End = new Location(1.5, 80, 0.5), + MoveType = MoveType.Traverse, + ExitTransition = PathTransitionType.FinalStop, + PreserveSprint = false + }; + + var template = new WalkTemplate(segment, null); + var physics = new PlayerPhysics + { + Position = new Vec3d(1.38, 80.0, 0.5), + DeltaMovement = new Vec3d(0.156, 0.0, 0.0), + OnGround = true, + Yaw = 270f + }; + var input = new MovementInput(); + + TemplateState state = template.Tick(new Location(1.38, 80, 0.5), physics, input, world); + + Assert.Equal(TemplateState.InProgress, state); + Assert.False(input.Forward); + Assert.False(input.Sprint); + Assert.True(input.Back); + } + + [Fact] + public void WalkTemplate_KeepsForward_WhenTransitionContinuesStraight() + { + World world = FlatWorldTestBuilder.CreateStoneFloor(); + var current = new PathSegment + { + Start = new Location(0.5, 80, 0.5), + End = new Location(1.5, 80, 0.5), + MoveType = MoveType.Traverse, + ExitTransition = PathTransitionType.ContinueStraight, + PreserveSprint = true + }; + var next = new PathSegment + { + Start = new Location(1.5, 80, 0.5), + End = new Location(2.5, 80, 0.5), + MoveType = MoveType.Traverse, + ExitTransition = PathTransitionType.FinalStop + }; + + var template = new WalkTemplate(current, next); + var physics = new PlayerPhysics + { + Position = new Vec3d(1.10, 80.0, 0.5), + DeltaMovement = new Vec3d(0.140, 0.0, 0.0), + OnGround = true, + Yaw = 270f + }; + var input = new MovementInput(); + + TemplateState state = template.Tick(new Location(1.10, 80, 0.5), physics, input, world); + + Assert.Equal(TemplateState.InProgress, state); + Assert.True(input.Forward); + Assert.True(input.Sprint); + } +} diff --git a/MinecraftClient.Tests/Pathing/Execution/TransitionBrakingPlannerTests.cs b/MinecraftClient.Tests/Pathing/Execution/TransitionBrakingPlannerTests.cs new file mode 100644 index 00000000..262c2c17 --- /dev/null +++ b/MinecraftClient.Tests/Pathing/Execution/TransitionBrakingPlannerTests.cs @@ -0,0 +1,110 @@ +using MinecraftClient.Mapping; +using MinecraftClient.Pathing.Core; +using MinecraftClient.Pathing.Execution; +using MinecraftClient.Physics; +using Xunit; + +namespace MinecraftClient.Tests.Pathing.Execution; + +public sealed class TransitionBrakingPlannerTests +{ + [Fact] + public void Plan_ReturnsCarryMomentum_ForContinueStraight() + { + World world = FlatWorldTestBuilder.CreateStoneFloor(); + var physics = CreatePhysics(0.156, 0.0, onGround: true); + var current = new PathSegment + { + Start = new Location(0.5, 80, 0.5), + End = new Location(1.5, 80, 0.5), + MoveType = MoveType.Traverse, + ExitTransition = PathTransitionType.ContinueStraight, + PreserveSprint = true + }; + + TransitionBrakingDecision decision = TransitionBrakingPlanner.Plan(current, null, new Location(1.05, 80, 0.5), physics, world); + + Assert.True(decision.HoldForward); + Assert.True(decision.HoldSprint); + Assert.False(decision.HoldBack); + } + + [Fact] + public void Plan_BackBrakes_ForFinalStop_WhenRemainingRunwayIsTooShort() + { + World world = FlatWorldTestBuilder.CreateStoneFloor(); + var physics = CreatePhysics(0.156, 0.0, onGround: true); + var current = new PathSegment + { + Start = new Location(0.5, 80, 0.5), + End = new Location(1.5, 80, 0.5), + MoveType = MoveType.Traverse, + ExitTransition = PathTransitionType.FinalStop, + PreserveSprint = false + }; + + TransitionBrakingDecision decision = TransitionBrakingPlanner.Plan(current, null, new Location(1.38, 80, 0.5), physics, world); + + Assert.False(decision.HoldForward); + Assert.False(decision.HoldSprint); + Assert.True(decision.HoldBack); + } + + [Fact] + public void Plan_NudgesForward_ForFinalStop_WhenAlreadySlowButStillShort() + { + World world = FlatWorldTestBuilder.CreateStoneFloor(); + var physics = CreatePhysics(0.0, 0.0, onGround: true); + var current = new PathSegment + { + Start = new Location(0.5, 80, 0.5), + End = new Location(1.5, 80, 0.5), + MoveType = MoveType.Traverse, + ExitTransition = PathTransitionType.FinalStop, + PreserveSprint = false + }; + + TransitionBrakingDecision decision = TransitionBrakingPlanner.Plan(current, null, new Location(1.41, 80, 0.5), physics, world); + + Assert.True(decision.HoldForward); + Assert.False(decision.HoldSprint); + Assert.False(decision.HoldBack); + } + + [Fact] + public void ShouldReleaseForwardInAir_ReturnsTrue_ForParkourIntoTurn() + { + var physics = CreatePhysics(0.32, 0.0, onGround: false); + var current = new PathSegment + { + Start = new Location(120.5, 80, 110.5), + End = new Location(123.5, 80, 110.5), + MoveType = MoveType.Parkour, + ExitTransition = PathTransitionType.Turn, + PreserveSprint = false + }; + var next = new PathSegment + { + Start = new Location(123.5, 80, 110.5), + End = new Location(123.5, 80, 111.5), + MoveType = MoveType.Traverse, + ExitTransition = PathTransitionType.FinalStop + }; + + bool release = TransitionBrakingPlanner.ShouldReleaseForwardInAir(current, next, new Location(123.18, 80.92, 110.5), physics); + + Assert.True(release); + } + + private static PlayerPhysics CreatePhysics(double deltaX, double deltaZ, bool onGround) + { + return new PlayerPhysics + { + Position = new Vec3d(0.0, 80.0, 0.0), + DeltaMovement = new Vec3d(deltaX, 0.0, deltaZ), + OnGround = onGround, + MovementSpeed = 0.1f, + Yaw = 270f + }; + } +} diff --git a/MinecraftClient.sln b/MinecraftClient.sln index ebdf1f09..19afc906 100644 --- a/MinecraftClient.sln +++ b/MinecraftClient.sln @@ -13,6 +13,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MccMcpStdioHarness", "Debug EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MccMcpWebPlayground", "DebugTools\MccMcpWebPlayground\MccMcpWebPlayground.csproj", "{5F620CF6-BC7D-449A-B779-2D51985059C6}" EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "MinecraftClient.Tests", "MinecraftClient.Tests\MinecraftClient.Tests.csproj", "{A6F319D6-4D0E-4D46-A31E-EF64E5F9F596}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -71,6 +73,18 @@ Global {5F620CF6-BC7D-449A-B779-2D51985059C6}.Release|x64.Build.0 = Release|Any CPU {5F620CF6-BC7D-449A-B779-2D51985059C6}.Release|x86.ActiveCfg = Release|Any CPU {5F620CF6-BC7D-449A-B779-2D51985059C6}.Release|x86.Build.0 = Release|Any CPU + {A6F319D6-4D0E-4D46-A31E-EF64E5F9F596}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {A6F319D6-4D0E-4D46-A31E-EF64E5F9F596}.Debug|Any CPU.Build.0 = Debug|Any CPU + {A6F319D6-4D0E-4D46-A31E-EF64E5F9F596}.Debug|x64.ActiveCfg = Debug|Any CPU + {A6F319D6-4D0E-4D46-A31E-EF64E5F9F596}.Debug|x64.Build.0 = Debug|Any CPU + {A6F319D6-4D0E-4D46-A31E-EF64E5F9F596}.Debug|x86.ActiveCfg = Debug|Any CPU + {A6F319D6-4D0E-4D46-A31E-EF64E5F9F596}.Debug|x86.Build.0 = Debug|Any CPU + {A6F319D6-4D0E-4D46-A31E-EF64E5F9F596}.Release|Any CPU.ActiveCfg = Release|Any CPU + {A6F319D6-4D0E-4D46-A31E-EF64E5F9F596}.Release|Any CPU.Build.0 = Release|Any CPU + {A6F319D6-4D0E-4D46-A31E-EF64E5F9F596}.Release|x64.ActiveCfg = Release|Any CPU + {A6F319D6-4D0E-4D46-A31E-EF64E5F9F596}.Release|x64.Build.0 = Release|Any CPU + {A6F319D6-4D0E-4D46-A31E-EF64E5F9F596}.Release|x86.ActiveCfg = Release|Any CPU + {A6F319D6-4D0E-4D46-A31E-EF64E5F9F596}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE diff --git a/MinecraftClient/Pathing/Execution/ActionTemplateFactory.cs b/MinecraftClient/Pathing/Execution/ActionTemplateFactory.cs index eff8aa42..2e20490c 100644 --- a/MinecraftClient/Pathing/Execution/ActionTemplateFactory.cs +++ b/MinecraftClient/Pathing/Execution/ActionTemplateFactory.cs @@ -9,17 +9,17 @@ namespace MinecraftClient.Pathing.Execution /// public static class ActionTemplateFactory { - public static IActionTemplate Create(PathSegment segment) + public static IActionTemplate Create(PathSegment segment, PathSegment? nextSegment) { return segment.MoveType switch { - MoveType.Traverse => new WalkTemplate(segment.Start, segment.End), - MoveType.Diagonal => new WalkTemplate(segment.Start, segment.End), - MoveType.Ascend => new AscendTemplate(segment.Start, segment.End), - MoveType.Descend => new DescendTemplate(segment.Start, segment.End), - MoveType.Fall => new FallTemplate(segment.Start, segment.End), - MoveType.Climb => new ClimbTemplate(segment.Start, segment.End), - MoveType.Parkour => new SprintJumpTemplate(segment.Start, segment.End), + MoveType.Traverse => new WalkTemplate(segment, nextSegment), + MoveType.Diagonal => new WalkTemplate(segment, nextSegment), + MoveType.Ascend => new AscendTemplate(segment, nextSegment), + MoveType.Descend => new DescendTemplate(segment, nextSegment), + MoveType.Fall => new FallTemplate(segment, nextSegment), + MoveType.Climb => new ClimbTemplate(segment, nextSegment), + MoveType.Parkour => new SprintJumpTemplate(segment, nextSegment), _ => throw new ArgumentException($"Unknown MoveType: {segment.MoveType}") }; } diff --git a/MinecraftClient/Pathing/Execution/IActionTemplate.cs b/MinecraftClient/Pathing/Execution/IActionTemplate.cs index dac2c44c..6e792780 100644 --- a/MinecraftClient/Pathing/Execution/IActionTemplate.cs +++ b/MinecraftClient/Pathing/Execution/IActionTemplate.cs @@ -20,6 +20,6 @@ namespace MinecraftClient.Pathing.Execution Location ExpectedStart { get; } Location ExpectedEnd { get; } - TemplateState Tick(Location currentPos, PlayerPhysics physics, MovementInput input); + TemplateState Tick(Location currentPos, PlayerPhysics physics, MovementInput input, World world); } } diff --git a/MinecraftClient/Pathing/Execution/PathExecutor.cs b/MinecraftClient/Pathing/Execution/PathExecutor.cs index 78270ee5..00d788e1 100644 --- a/MinecraftClient/Pathing/Execution/PathExecutor.cs +++ b/MinecraftClient/Pathing/Execution/PathExecutor.cs @@ -37,16 +37,20 @@ namespace MinecraftClient.Pathing.Execution AdvanceToNextSegment(); } - public PathExecutorState Tick(Location pos, PlayerPhysics physics, MovementInput input) + public PathExecutorState Tick(Location pos, PlayerPhysics physics, MovementInput input, World world) { if (_currentTemplate is null) + { + input.Reset(); return PathExecutorState.Complete; + } - var state = _currentTemplate.Tick(pos, physics, input); + var state = _currentTemplate.Tick(pos, physics, input, world); switch (state) { case TemplateState.Complete: + input.Reset(); _debugLog?.Invoke($"[PathExec] Segment {_currentIndex} complete " + $"({_segments[_currentIndex].MoveType}) at ({pos.X:F2},{pos.Y:F2},{pos.Z:F2})"); _currentIndex++; @@ -60,6 +64,7 @@ namespace MinecraftClient.Pathing.Execution return PathExecutorState.InProgress; case TemplateState.Failed: + input.Reset(); _debugLog?.Invoke($"[PathExec] Segment {_currentIndex} FAILED " + $"({_segments[_currentIndex].MoveType}) at ({pos.X:F2},{pos.Y:F2},{pos.Z:F2}), " + $"target was ({_currentTemplate.ExpectedEnd.X:F2},{_currentTemplate.ExpectedEnd.Y:F2},{_currentTemplate.ExpectedEnd.Z:F2})"); @@ -75,7 +80,8 @@ namespace MinecraftClient.Pathing.Execution if (_currentIndex < _segments.Count) { var seg = _segments[_currentIndex]; - _currentTemplate = ActionTemplateFactory.Create(seg); + PathSegment? next = _currentIndex + 1 < _segments.Count ? _segments[_currentIndex + 1] : null; + _currentTemplate = ActionTemplateFactory.Create(seg, next); _debugLog?.Invoke($"[PathExec] Starting segment {_currentIndex}/{_segments.Count}: {seg}"); } else diff --git a/MinecraftClient/Pathing/Execution/PathSegment.cs b/MinecraftClient/Pathing/Execution/PathSegment.cs index ec3f0a76..c39e88de 100644 --- a/MinecraftClient/Pathing/Execution/PathSegment.cs +++ b/MinecraftClient/Pathing/Execution/PathSegment.cs @@ -1,4 +1,4 @@ -using System.Collections.Generic; +using System; using MinecraftClient.Mapping; using MinecraftClient.Pathing.Core; @@ -9,25 +9,13 @@ namespace MinecraftClient.Pathing.Execution public required Location Start { get; init; } public required Location End { get; init; } public required MoveType MoveType { get; init; } + public PathTransitionType ExitTransition { get; init; } = PathTransitionType.FinalStop; + public bool PreserveSprint { get; init; } - public static List FromPath(IReadOnlyList nodes) - { - var segments = new List(nodes.Count - 1); - for (int i = 1; i < nodes.Count; i++) - { - var prev = nodes[i - 1]; - var curr = nodes[i]; - segments.Add(new PathSegment - { - Start = new Location(prev.X + 0.5, prev.Y, prev.Z + 0.5), - End = new Location(curr.X + 0.5, curr.Y, curr.Z + 0.5), - MoveType = curr.MoveUsed - }); - } - return segments; - } + public int HeadingX => Math.Sign(End.X - Start.X); + public int HeadingZ => Math.Sign(End.Z - Start.Z); public override string ToString() => - $"{MoveType}: ({Start.X:F1},{Start.Y:F1},{Start.Z:F1})->({End.X:F1},{End.Y:F1},{End.Z:F1})"; + $"{MoveType}: ({Start.X:F1},{Start.Y:F1},{Start.Z:F1})->({End.X:F1},{End.Y:F1},{End.Z:F1}), transition={ExitTransition}, preserveSprint={PreserveSprint}"; } } diff --git a/MinecraftClient/Pathing/Execution/PathSegmentBuilder.cs b/MinecraftClient/Pathing/Execution/PathSegmentBuilder.cs new file mode 100644 index 00000000..36db785d --- /dev/null +++ b/MinecraftClient/Pathing/Execution/PathSegmentBuilder.cs @@ -0,0 +1,67 @@ +using System; +using System.Collections.Generic; +using MinecraftClient.Mapping; +using MinecraftClient.Pathing.Core; + +namespace MinecraftClient.Pathing.Execution +{ + public static class PathSegmentBuilder + { + public static List FromPath(IReadOnlyList nodes) + { + var segments = new List(Math.Max(0, nodes.Count - 1)); + for (int i = 1; i < nodes.Count; i++) + { + PathSegment? next = null; + if (i + 1 < nodes.Count) + { + var nextNode = nodes[i + 1]; + var curr = nodes[i]; + next = new PathSegment + { + Start = new Location(curr.X + 0.5, curr.Y, curr.Z + 0.5), + End = new Location(nextNode.X + 0.5, nextNode.Y, nextNode.Z + 0.5), + MoveType = nextNode.MoveUsed + }; + } + + var prev = nodes[i - 1]; + var currNode = nodes[i]; + var current = new PathSegment + { + Start = new Location(prev.X + 0.5, prev.Y, prev.Z + 0.5), + End = new Location(currNode.X + 0.5, currNode.Y, currNode.Z + 0.5), + MoveType = currNode.MoveUsed + }; + + PathTransitionType exitTransition = Classify(current, next); + segments.Add(new PathSegment + { + Start = current.Start, + End = current.End, + MoveType = current.MoveType, + ExitTransition = exitTransition, + PreserveSprint = exitTransition is PathTransitionType.ContinueStraight or PathTransitionType.PrepareJump + }); + } + return segments; + } + + private static PathTransitionType Classify(PathSegment current, PathSegment? next) + { + if (next is null) + return PathTransitionType.FinalStop; + + if (next.MoveType is MoveType.Parkour or MoveType.Ascend) + return PathTransitionType.PrepareJump; + + if (current.MoveType is MoveType.Parkour or MoveType.Descend or MoveType.Fall) + return PathTransitionType.LandingRecovery; + + if (current.HeadingX == next.HeadingX && current.HeadingZ == next.HeadingZ) + return PathTransitionType.ContinueStraight; + + return PathTransitionType.Turn; + } + } +} diff --git a/MinecraftClient/Pathing/Execution/PathSegmentManager.cs b/MinecraftClient/Pathing/Execution/PathSegmentManager.cs index 1582dd4a..d3911ae9 100644 --- a/MinecraftClient/Pathing/Execution/PathSegmentManager.cs +++ b/MinecraftClient/Pathing/Execution/PathSegmentManager.cs @@ -35,7 +35,7 @@ namespace MinecraftClient.Pathing.Execution { _goal = goal; _replanCount = 0; - var segments = PathSegment.FromPath(result.Path); + var segments = PathSegmentBuilder.FromPath(result.Path); _executor = new PathExecutor(segments, _debugLog); _infoLog?.Invoke($"[PathMgr] Navigation started: {segments.Count} segments"); } @@ -45,7 +45,7 @@ namespace MinecraftClient.Pathing.Execution if (_executor is null) return; - var state = _executor.Tick(pos, physics, input); + var state = _executor.Tick(pos, physics, input, world); switch (state) { @@ -113,7 +113,7 @@ namespace MinecraftClient.Pathing.Execution return; } - var segments = PathSegment.FromPath(result.Path); + var segments = PathSegmentBuilder.FromPath(result.Path); _executor = new PathExecutor(segments, _debugLog); _infoLog?.Invoke($"[PathMgr] Replanned: {segments.Count} segments (replan #{_replanCount})"); } diff --git a/MinecraftClient/Pathing/Execution/PathTransitionType.cs b/MinecraftClient/Pathing/Execution/PathTransitionType.cs new file mode 100644 index 00000000..f099c0ac --- /dev/null +++ b/MinecraftClient/Pathing/Execution/PathTransitionType.cs @@ -0,0 +1,11 @@ +namespace MinecraftClient.Pathing.Execution +{ + public enum PathTransitionType + { + FinalStop, + ContinueStraight, + Turn, + PrepareJump, + LandingRecovery + } +} diff --git a/MinecraftClient/Pathing/Execution/Templates/AscendTemplate.cs b/MinecraftClient/Pathing/Execution/Templates/AscendTemplate.cs index 3ad3c41e..536fae7b 100644 --- a/MinecraftClient/Pathing/Execution/Templates/AscendTemplate.cs +++ b/MinecraftClient/Pathing/Execution/Templates/AscendTemplate.cs @@ -13,18 +13,22 @@ namespace MinecraftClient.Pathing.Execution.Templates public Location ExpectedStart { get; } public Location ExpectedEnd { get; } + private readonly PathSegment _segment; + private readonly PathSegment? _nextSegment; private int _tickCount; private Location _lastPos; private int _stuckTicks; - public AscendTemplate(Location start, Location end) + public AscendTemplate(PathSegment segment, PathSegment? nextSegment) { - ExpectedStart = start; - ExpectedEnd = end; - _lastPos = start; + _segment = segment; + _nextSegment = nextSegment; + ExpectedStart = segment.Start; + ExpectedEnd = segment.End; + _lastPos = segment.Start; } - public TemplateState Tick(Location pos, PlayerPhysics physics, MovementInput input) + public TemplateState Tick(Location pos, PlayerPhysics physics, MovementInput input, World world) { _tickCount++; @@ -43,8 +47,26 @@ namespace MinecraftClient.Pathing.Execution.Templates if (physics.OnGround && dy > 0.1) input.Jump = true; - if (horizDistSq < 0.25 && Math.Abs(dy) < 0.8) + if (physics.OnGround && Math.Abs(dy) < 0.15) + { + TransitionBrakingDecision decision = TransitionBrakingPlanner.Plan(_segment, _nextSegment, pos, physics, world); + TemplateHelper.ApplyDecision(input, decision); + if (decision.HoldBack) + TemplateHelper.FaceSegmentHeading(physics, _segment); + + if (_segment.ExitTransition == PathTransitionType.ContinueStraight && horizDistSq < 0.25) + return TemplateState.Complete; + + if (_segment.ExitTransition != PathTransitionType.ContinueStraight + && TemplateHelper.IsSettledAtEnd(pos, ExpectedEnd, physics, horizThresholdSq: 0.0025)) + { + return TemplateState.Complete; + } + } + else if (horizDistSq < 0.25 && Math.Abs(dy) < 0.8) + { return TemplateState.Complete; + } double movedSq = TemplateHelper.HorizontalDistanceSq(pos, _lastPos); double movedY = Math.Abs(pos.Y - _lastPos.Y); diff --git a/MinecraftClient/Pathing/Execution/Templates/ClimbTemplate.cs b/MinecraftClient/Pathing/Execution/Templates/ClimbTemplate.cs index 0ffea56d..4e6c3991 100644 --- a/MinecraftClient/Pathing/Execution/Templates/ClimbTemplate.cs +++ b/MinecraftClient/Pathing/Execution/Templates/ClimbTemplate.cs @@ -17,14 +17,14 @@ namespace MinecraftClient.Pathing.Execution.Templates private readonly bool _goingUp; private int _tickCount; - public ClimbTemplate(Location start, Location end) + public ClimbTemplate(PathSegment segment, PathSegment? nextSegment) { - ExpectedStart = start; - ExpectedEnd = end; - _goingUp = end.Y > start.Y; + ExpectedStart = segment.Start; + ExpectedEnd = segment.End; + _goingUp = segment.End.Y > segment.Start.Y; } - public TemplateState Tick(Location pos, PlayerPhysics physics, MovementInput input) + public TemplateState Tick(Location pos, PlayerPhysics physics, MovementInput input, World world) { _tickCount++; diff --git a/MinecraftClient/Pathing/Execution/Templates/DescendTemplate.cs b/MinecraftClient/Pathing/Execution/Templates/DescendTemplate.cs index 9cec3f78..aa7edfe4 100644 --- a/MinecraftClient/Pathing/Execution/Templates/DescendTemplate.cs +++ b/MinecraftClient/Pathing/Execution/Templates/DescendTemplate.cs @@ -15,20 +15,24 @@ namespace MinecraftClient.Pathing.Execution.Templates public Location ExpectedStart { get; } public Location ExpectedEnd { get; } + private readonly PathSegment _segment; + private readonly PathSegment? _nextSegment; private int _tickCount; private bool _hasFallen; private readonly bool _needsSprint; - public DescendTemplate(Location start, Location end) + public DescendTemplate(PathSegment segment, PathSegment? nextSegment) { - ExpectedStart = start; - ExpectedEnd = end; - double hdx = end.X - start.X; - double hdz = end.Z - start.Z; + _segment = segment; + _nextSegment = nextSegment; + ExpectedStart = segment.Start; + ExpectedEnd = segment.End; + double hdx = segment.End.X - segment.Start.X; + double hdz = segment.End.Z - segment.Start.Z; _needsSprint = (hdx * hdx + hdz * hdz) > 2.25; } - public TemplateState Tick(Location pos, PlayerPhysics physics, MovementInput input) + public TemplateState Tick(Location pos, PlayerPhysics physics, MovementInput input, World world) { _tickCount++; @@ -40,14 +44,6 @@ namespace MinecraftClient.Pathing.Execution.Templates if (!physics.OnGround) _hasFallen = true; - // Completion: landed on ground near destination - if (_hasFallen && physics.OnGround && horizDistSq < 0.5 && Math.Abs(dy) < 0.8) - return TemplateState.Complete; - - // Completion: already at destination without falling (e.g., single step down) - if (horizDistSq < 0.25 && Math.Abs(dy) < 0.5 && physics.OnGround) - return TemplateState.Complete; - // Completion: landed in water near destination if (_hasFallen && physics.InWater && horizDistSq < 0.5 && Math.Abs(dy) < 2.0) return TemplateState.Complete; @@ -63,7 +59,28 @@ namespace MinecraftClient.Pathing.Execution.Templates float targetPitch = TemplateHelper.CalculatePitch(dx, dy, dz); physics.Pitch = TemplateHelper.SmoothPitch(physics.Pitch, targetPitch); - if (physics.OnClimbable) + if (physics.OnGround && Math.Abs(dy) < (_hasFallen ? 0.8 : 0.5)) + { + if (horizDistSq > 0.01) + physics.Yaw = TemplateHelper.SmoothYaw(physics.Yaw, targetYaw); + + TransitionBrakingDecision decision = TransitionBrakingPlanner.Plan(_segment, _nextSegment, pos, physics, world); + TemplateHelper.ApplyDecision(input, decision); + if (decision.HoldBack) + TemplateHelper.FaceSegmentHeading(physics, _segment); + + if (_segment.ExitTransition == PathTransitionType.ContinueStraight) + { + double completionThreshold = _hasFallen ? 0.5 : 0.25; + if (horizDistSq < completionThreshold) + return TemplateState.Complete; + } + else if (TemplateHelper.IsSettledAtEnd(pos, ExpectedEnd, physics, horizThresholdSq: 0.0025)) + { + return TemplateState.Complete; + } + } + else if (physics.OnClimbable) { if (horizDistSq > 0.25) { diff --git a/MinecraftClient/Pathing/Execution/Templates/FallTemplate.cs b/MinecraftClient/Pathing/Execution/Templates/FallTemplate.cs index 7a4131e6..0380ea06 100644 --- a/MinecraftClient/Pathing/Execution/Templates/FallTemplate.cs +++ b/MinecraftClient/Pathing/Execution/Templates/FallTemplate.cs @@ -16,27 +16,30 @@ namespace MinecraftClient.Pathing.Execution.Templates private int _tickCount; private bool _hasFallen; - public FallTemplate(Location start, Location end) + public FallTemplate(PathSegment segment, PathSegment? nextSegment) { - ExpectedStart = start; - ExpectedEnd = end; + ExpectedStart = segment.Start; + ExpectedEnd = segment.End; } - public TemplateState Tick(Location pos, PlayerPhysics physics, MovementInput input) + public TemplateState Tick(Location pos, PlayerPhysics physics, MovementInput input, World world) { _tickCount++; + double dx = ExpectedEnd.X - pos.X; + double dz = ExpectedEnd.Z - pos.Z; double dy = pos.Y - ExpectedEnd.Y; + double horizDistSq = dx * dx + dz * dz; if (!physics.OnGround) _hasFallen = true; - // Solid ground landing - if (_hasFallen && physics.OnGround && Math.Abs(dy) < 1.0) + // Solid ground landing near the target XZ + if (_hasFallen && physics.OnGround && Math.Abs(dy) < 1.0 && horizDistSq < 1.0) return TemplateState.Complete; - // Water landing - if (_hasFallen && physics.InWater && Math.Abs(dy) < 2.0) + // Water landing near the target XZ + if (_hasFallen && physics.InWater && Math.Abs(dy) < 2.0 && horizDistSq < 1.5) return TemplateState.Complete; if (_tickCount > 200) diff --git a/MinecraftClient/Pathing/Execution/Templates/SprintJumpTemplate.cs b/MinecraftClient/Pathing/Execution/Templates/SprintJumpTemplate.cs index eed7b472..9ec8aa8b 100644 --- a/MinecraftClient/Pathing/Execution/Templates/SprintJumpTemplate.cs +++ b/MinecraftClient/Pathing/Execution/Templates/SprintJumpTemplate.cs @@ -5,10 +5,17 @@ using MinecraftClient.Physics; namespace MinecraftClient.Pathing.Execution.Templates { /// - /// Sprint-jump across a gap. Uses a phase-based state machine: - /// Approach -> jump when ready -> Airborne -> Landing check. - /// For long jumps (>= 3.5 blocks), delays the jump until the player - /// has moved toward the edge of the starting block for maximum distance. + /// Jump across a gap. Uses a phase-based state machine: + /// Approach -> Jump -> Airborne -> Landing. + /// + /// All parkour jumps use sprint-jumping (vanilla optimal horizontal distance). + /// The key to landing on small platforms is releasing forward/sprint input mid-air + /// once the player is close to or past the target, letting drag decelerate them + /// onto the block. + /// + /// During Approach, the template waits for the yaw to be within 5 degrees of + /// the target direction before jumping. For medium/long jumps, it also builds + /// momentum by sprinting toward the block edge. /// public sealed class SprintJumpTemplate : IActionTemplate { @@ -17,22 +24,27 @@ namespace MinecraftClient.Pathing.Execution.Templates public Location ExpectedStart { get; } public Location ExpectedEnd { get; } + private readonly PathSegment _segment; + private readonly PathSegment? _nextSegment; private readonly double _horizDist; - private readonly bool _isDiagonal; private int _tickCount; private Phase _phase = Phase.Approach; + private bool _leftGround; - public SprintJumpTemplate(Location start, Location end) + private const float YawToleranceDeg = 5f; + + public SprintJumpTemplate(PathSegment segment, PathSegment? nextSegment) { - ExpectedStart = start; - ExpectedEnd = end; - double dx = end.X - start.X; - double dz = end.Z - start.Z; + _segment = segment; + _nextSegment = nextSegment; + ExpectedStart = segment.Start; + ExpectedEnd = segment.End; + double dx = segment.End.X - segment.Start.X; + double dz = segment.End.Z - segment.Start.Z; _horizDist = Math.Sqrt(dx * dx + dz * dz); - _isDiagonal = Math.Abs(dx) > 0.5 && Math.Abs(dz) > 0.5; } - public TemplateState Tick(Location pos, PlayerPhysics physics, MovementInput input) + public TemplateState Tick(Location pos, PlayerPhysics physics, MovementInput input, World world) { _tickCount++; @@ -45,52 +57,92 @@ namespace MinecraftClient.Pathing.Execution.Templates float targetPitch = TemplateHelper.CalculatePitch(dx, dy, dz); physics.Yaw = TemplateHelper.SmoothYaw(physics.Yaw, targetYaw); physics.Pitch = TemplateHelper.SmoothPitch(physics.Pitch, targetPitch); - input.Forward = true; - input.Sprint = true; switch (_phase) { case Phase.Approach: + input.Forward = true; + input.Sprint = true; + if (physics.OnGround) { double fromStartSq = TemplateHelper.HorizontalDistanceSq(pos, ExpectedStart); + float yawDelta = YawDifference(physics.Yaw, targetYaw); - // For long jumps, delay the jump until the player has sprinted - // toward the block edge. Baritone waits until playerFeet is in - // the next block (~0.5 blocks from center) for dist >= 4. - // For medium jumps (dist 3), wait 0.35 blocks (Baritone: 0.7). - // For short diagonal jumps (<= 3 blocks), jump immediately - // to avoid overshooting the small starting platform. + // Build momentum before jumping. Sprint speed is ~5.6 m/s + // (0.28 blocks/tick). More run-up = more airtime distance. + // Standing sprint jump (0t): ~3.6 blocks horizontal + // 2-tick sprint (0.56m): ~4.3 blocks horizontal + // 4-tick sprint (1.1m): ~5.0 blocks horizontal double minApproachSq; - if (_horizDist >= 3.5) - minApproachSq = 0.25; // 0.5 blocks - else if (_horizDist >= 2.5 && !_isDiagonal) - minApproachSq = 0.12; // ~0.35 blocks + if (_horizDist >= 5.0) + minApproachSq = 0.64; // 0.8 blocks - 3+ ticks of sprint + else if (_horizDist >= 4.0) + minApproachSq = 0.36; // 0.6 blocks - 2-3 ticks of sprint + else if (_horizDist > 2.5) + minApproachSq = 0.09; // 0.3 blocks - 1-2 ticks of sprint else minApproachSq = 0.0; - if (fromStartSq >= minApproachSq) + bool yawAligned = yawDelta < YawToleranceDeg; + bool posReady = fromStartSq >= minApproachSq; + + if (yawAligned && posReady) { input.Jump = true; _phase = Phase.Airborne; } } - if (_tickCount > 30) + if (_tickCount > 40) return TemplateState.Failed; break; case Phase.Airborne: + { if (!physics.OnGround) - break; - _phase = Phase.Landing; - goto case Phase.Landing; + _leftGround = true; + + bool pastTarget = IsPastTarget(pos); + bool releaseInAir = TransitionBrakingPlanner.ShouldReleaseForwardInAir(_segment, _nextSegment, pos, physics); + + if (releaseInAir || pastTarget) + { + input.Forward = false; + input.Sprint = false; + } + else + { + input.Forward = true; + input.Sprint = true; + } + + if (_leftGround && physics.OnGround) + { + _phase = Phase.Landing; + goto case Phase.Landing; + } + break; + } case Phase.Landing: - double horizTolerance = _horizDist >= 3.5 ? 3.0 : 2.0; + TransitionBrakingDecision decision = TransitionBrakingPlanner.Plan(_segment, _nextSegment, pos, physics, world); + TemplateHelper.ApplyDecision(input, decision); + if (decision.HoldBack) + TemplateHelper.FaceSegmentHeading(physics, _segment); + + double horizToleranceLinear = _horizDist >= 3.5 ? 1.5 : 1.0; + double horizToleranceSq = horizToleranceLinear * horizToleranceLinear; double vertTolerance = Math.Abs(ExpectedEnd.Y - ExpectedStart.Y) > 0.5 ? 1.5 : 1.0; - if (horizDistSq < horizTolerance && Math.Abs(dy) < vertTolerance) + if (_segment.ExitTransition == PathTransitionType.ContinueStraight + && horizDistSq < horizToleranceSq && Math.Abs(dy) < vertTolerance) return TemplateState.Complete; - return TemplateState.Failed; + + if (_segment.ExitTransition != PathTransitionType.ContinueStraight + && TemplateHelper.IsSettledAtEnd(pos, ExpectedEnd, physics, horizThresholdSq: 0.0025)) + { + return TemplateState.Complete; + } + break; } if (pos.Y < ExpectedEnd.Y - 4.0) @@ -101,5 +153,28 @@ namespace MinecraftClient.Pathing.Execution.Templates return TemplateState.InProgress; } + + private bool IsPastTarget(Location pos) + { + double dirX = ExpectedEnd.X - ExpectedStart.X; + double dirZ = ExpectedEnd.Z - ExpectedStart.Z; + double len = Math.Sqrt(dirX * dirX + dirZ * dirZ); + if (len < 0.001) return false; + dirX /= len; + dirZ /= len; + + double relX = pos.X - ExpectedEnd.X; + double relZ = pos.Z - ExpectedEnd.Z; + double dot = relX * dirX + relZ * dirZ; + return dot > 0.0; + } + + private static float YawDifference(float current, float target) + { + float delta = target - current; + while (delta > 180f) delta -= 360f; + while (delta < -180f) delta += 360f; + return Math.Abs(delta); + } } } diff --git a/MinecraftClient/Pathing/Execution/Templates/TemplateHelper.cs b/MinecraftClient/Pathing/Execution/Templates/TemplateHelper.cs index f724906c..3a07c910 100644 --- a/MinecraftClient/Pathing/Execution/Templates/TemplateHelper.cs +++ b/MinecraftClient/Pathing/Execution/Templates/TemplateHelper.cs @@ -1,5 +1,6 @@ using System; using MinecraftClient.Mapping; +using MinecraftClient.Physics; namespace MinecraftClient.Pathing.Execution.Templates { @@ -75,5 +76,28 @@ namespace MinecraftClient.Pathing.Execution.Templates double dy = target.Y - pos.Y; return dx * dx + dz * dz < horizThresholdSq && Math.Abs(dy) < vertThreshold; } + + internal static void FaceSegmentHeading(PlayerPhysics physics, PathSegment segment) + { + float headingYaw = CalculateYaw(segment.HeadingX, segment.HeadingZ); + physics.Yaw = SmoothYaw(physics.Yaw, headingYaw); + } + + internal static void ApplyDecision(MovementInput input, TransitionBrakingDecision decision) + { + input.Forward = decision.HoldForward; + input.Sprint = decision.HoldSprint; + input.Back = decision.HoldBack; + } + + internal static bool IsSettledAtEnd(Location pos, Location target, PlayerPhysics physics, + double horizThresholdSq = 0.0025, double speedThresholdSq = 0.0016) + { + double dx = target.X - pos.X; + double dz = target.Z - pos.Z; + double horizontalSpeedSq = physics.DeltaMovement.X * physics.DeltaMovement.X + + physics.DeltaMovement.Z * physics.DeltaMovement.Z; + return dx * dx + dz * dz <= horizThresholdSq && horizontalSpeedSq <= speedThresholdSq; + } } } diff --git a/MinecraftClient/Pathing/Execution/Templates/WalkTemplate.cs b/MinecraftClient/Pathing/Execution/Templates/WalkTemplate.cs index 0cdbf96e..79c99e0b 100644 --- a/MinecraftClient/Pathing/Execution/Templates/WalkTemplate.cs +++ b/MinecraftClient/Pathing/Execution/Templates/WalkTemplate.cs @@ -13,18 +13,22 @@ namespace MinecraftClient.Pathing.Execution.Templates public Location ExpectedStart { get; } public Location ExpectedEnd { get; } + private readonly PathSegment _segment; + private readonly PathSegment? _nextSegment; private int _tickCount; private Location _lastPos; private int _stuckTicks; - public WalkTemplate(Location start, Location end) + public WalkTemplate(PathSegment segment, PathSegment? nextSegment) { - ExpectedStart = start; - ExpectedEnd = end; - _lastPos = start; + _segment = segment; + _nextSegment = nextSegment; + ExpectedStart = segment.Start; + ExpectedEnd = segment.End; + _lastPos = segment.Start; } - public TemplateState Tick(Location pos, PlayerPhysics physics, MovementInput input) + public TemplateState Tick(Location pos, PlayerPhysics physics, MovementInput input, World world) { _tickCount++; @@ -35,17 +39,24 @@ namespace MinecraftClient.Pathing.Execution.Templates float targetPitch = TemplateHelper.CalculatePitch(dx, dy, dz); physics.Yaw = TemplateHelper.SmoothYaw(physics.Yaw, targetYaw); physics.Pitch = TemplateHelper.SmoothPitch(physics.Pitch, targetPitch); - input.Forward = true; - input.Sprint = true; - if (TemplateHelper.IsNear(pos, ExpectedEnd, horizThresholdSq: 0.20)) + TransitionBrakingDecision decision = TransitionBrakingPlanner.Plan(_segment, _nextSegment, pos, physics, world); + TemplateHelper.ApplyDecision(input, decision); + if (decision.HoldBack) + TemplateHelper.FaceSegmentHeading(physics, _segment); + + if (_segment.ExitTransition == PathTransitionType.ContinueStraight && TemplateHelper.IsNear(pos, ExpectedEnd, horizThresholdSq: 0.09)) + return TemplateState.Complete; + + if (_segment.ExitTransition != PathTransitionType.ContinueStraight && TemplateHelper.IsSettledAtEnd(pos, ExpectedEnd, physics)) return TemplateState.Complete; double movedSq = TemplateHelper.HorizontalDistanceSq(pos, _lastPos); _stuckTicks = movedSq < 0.0005 ? _stuckTicks + 1 : 0; _lastPos = pos; - if (_stuckTicks > 40 || _tickCount > 100) + int maxTicks = _segment.ExitTransition == PathTransitionType.ContinueStraight ? 100 : 140; + if (_stuckTicks > 40 || _tickCount > maxTicks) return TemplateState.Failed; return TemplateState.InProgress; diff --git a/MinecraftClient/Pathing/Execution/TransitionBrakingDecision.cs b/MinecraftClient/Pathing/Execution/TransitionBrakingDecision.cs new file mode 100644 index 00000000..a51e8b6a --- /dev/null +++ b/MinecraftClient/Pathing/Execution/TransitionBrakingDecision.cs @@ -0,0 +1,14 @@ +namespace MinecraftClient.Pathing.Execution +{ + public readonly record struct TransitionBrakingDecision(bool HoldForward, bool HoldSprint, bool HoldBack) + { + public static TransitionBrakingDecision CarryMomentum(bool preserveSprint) => + new(true, preserveSprint, false); + + public static TransitionBrakingDecision Coast => + new(false, false, false); + + public static TransitionBrakingDecision Brake => + new(false, false, true); + } +} diff --git a/MinecraftClient/Pathing/Execution/TransitionBrakingPlanner.cs b/MinecraftClient/Pathing/Execution/TransitionBrakingPlanner.cs new file mode 100644 index 00000000..1899d389 --- /dev/null +++ b/MinecraftClient/Pathing/Execution/TransitionBrakingPlanner.cs @@ -0,0 +1,107 @@ +using System; +using MinecraftClient.Mapping; +using MinecraftClient.Physics; + +namespace MinecraftClient.Pathing.Execution +{ + public static class TransitionBrakingPlanner + { + private const double GroundSpeedThreshold = 0.025; + private const int MaxSimulationTicks = 14; + private const double FinalStopLead = 0.06; + private const double FinalBrakeLead = 0.04; + private const double TurnBrakeLead = 0.10; + private const double AirReleaseLead = 0.14; + + public static TransitionBrakingDecision Plan(PathSegment current, PathSegment? next, Location pos, PlayerPhysics physics, World world) + { + if (current.ExitTransition is PathTransitionType.ContinueStraight or PathTransitionType.PrepareJump) + return TransitionBrakingDecision.CarryMomentum(current.PreserveSprint); + + double remaining = RemainingDistanceAlongSegment(current, pos); + double forwardSpeed = Math.Max(0.0, ProjectHorizontalSpeedAlongHeading(physics, current.HeadingX, current.HeadingZ)); + double coastStopDistance = EstimateGroundStopDistance(physics, world, current.HeadingX, current.HeadingZ, applyBackBrake: false); + double hardBrakeDistance = EstimateGroundStopDistance(physics, world, current.HeadingX, current.HeadingZ, applyBackBrake: true); + + if (current.ExitTransition == PathTransitionType.FinalStop) + { + if (remaining < 0.0) + return TransitionBrakingDecision.Brake; + + if (forwardSpeed > GroundSpeedThreshold && remaining <= hardBrakeDistance + FinalBrakeLead) + return TransitionBrakingDecision.Brake; + + if (forwardSpeed <= GroundSpeedThreshold && remaining > 0.0) + return TransitionBrakingDecision.CarryMomentum(preserveSprint: false); + } + + if (current.ExitTransition == PathTransitionType.Turn && remaining <= hardBrakeDistance + TurnBrakeLead) + { + return TransitionBrakingDecision.Brake; + } + + if (remaining <= coastStopDistance + FinalStopLead) + return TransitionBrakingDecision.Coast; + + return TransitionBrakingDecision.CarryMomentum(current.PreserveSprint); + } + + public static bool ShouldReleaseForwardInAir(PathSegment current, PathSegment? next, Location pos, PlayerPhysics physics) + { + if (current.ExitTransition is not (PathTransitionType.FinalStop or PathTransitionType.Turn or PathTransitionType.LandingRecovery)) + return false; + + double remaining = RemainingDistanceAlongSegment(current, pos); + double forwardSpeed = Math.Max(0.0, ProjectHorizontalSpeedAlongHeading(physics, current.HeadingX, current.HeadingZ)); + + return remaining <= forwardSpeed + AirReleaseLead; + } + + public static double EstimateGroundStopDistance(PlayerPhysics physics, World world, int headingX, int headingZ, bool applyBackBrake) + { + if (!physics.OnGround) + return 0.0; + + double forwardSpeed = Math.Max(0.0, ProjectHorizontalSpeedAlongHeading(physics, headingX, headingZ)); + if (forwardSpeed <= GroundSpeedThreshold) + return 0.0; + + float blockFriction = PlayerPhysics.GetMaterialFriction( + world.GetBlock(new Location(physics.Position.X, physics.Position.Y - 0.5000010, physics.Position.Z)).Type); + double drag = blockFriction * PhysicsConsts.FrictionMultiplier; + double acceleration = physics.MovementSpeed + * (PhysicsConsts.GroundAccelerationFactor / (drag * drag * drag)) + * PhysicsConsts.InputFriction; + + if (applyBackBrake) + acceleration *= 0.98; + + double distance = 0.0; + double speed = forwardSpeed; + for (int tick = 0; tick < MaxSimulationTicks; tick++) + { + distance += speed; + speed = applyBackBrake + ? Math.Max(0.0, (speed - acceleration) * drag) + : speed * drag; + + if (speed <= GroundSpeedThreshold) + break; + } + + return distance; + } + + private static double RemainingDistanceAlongSegment(PathSegment current, Location pos) + { + double dx = current.End.X - pos.X; + double dz = current.End.Z - pos.Z; + return dx * current.HeadingX + dz * current.HeadingZ; + } + + private static double ProjectHorizontalSpeedAlongHeading(PlayerPhysics physics, int headingX, int headingZ) + { + return physics.DeltaMovement.X * headingX + physics.DeltaMovement.Z * headingZ; + } + } +} From 6b449cc72a0f39c7b9387ca7fe7b7a977441c552 Mon Sep 17 00:00:00 2001 From: BruceChen Date: Sun, 12 Apr 2026 21:33:24 +0800 Subject: [PATCH 21/37] feat: converge grounded path segment completion --- .../Execution/ClimbFallTemplateTests.cs | 99 +++++++++++++++++++ .../Pathing/Execution/FlatWorldTestBuilder.cs | 72 +++++++++++++- .../GroundedTemplateConvergenceTests.cs | 84 ++++++++++++++++ .../Execution/PathExecutorCompletionTests.cs | 3 +- .../Pathing/Execution/TemplateFootingTests.cs | 47 +++++++++ .../Execution/TemplateSimulationRunner.cs | 42 ++++++++ .../Execution/Templates/AscendTemplate.cs | 20 +--- .../Execution/Templates/DescendTemplate.cs | 18 +--- .../Templates/GroundedSegmentController.cs | 27 +++++ .../Templates/TemplateFootingHelper.cs | 60 +++++++++++ .../Execution/Templates/TemplateHelper.cs | 43 ++++++++ .../Execution/Templates/WalkTemplate.cs | 17 ++-- 12 files changed, 489 insertions(+), 43 deletions(-) create mode 100644 MinecraftClient.Tests/Pathing/Execution/ClimbFallTemplateTests.cs create mode 100644 MinecraftClient.Tests/Pathing/Execution/GroundedTemplateConvergenceTests.cs create mode 100644 MinecraftClient.Tests/Pathing/Execution/TemplateFootingTests.cs create mode 100644 MinecraftClient.Tests/Pathing/Execution/TemplateSimulationRunner.cs create mode 100644 MinecraftClient/Pathing/Execution/Templates/GroundedSegmentController.cs create mode 100644 MinecraftClient/Pathing/Execution/Templates/TemplateFootingHelper.cs diff --git a/MinecraftClient.Tests/Pathing/Execution/ClimbFallTemplateTests.cs b/MinecraftClient.Tests/Pathing/Execution/ClimbFallTemplateTests.cs new file mode 100644 index 00000000..9285779b --- /dev/null +++ b/MinecraftClient.Tests/Pathing/Execution/ClimbFallTemplateTests.cs @@ -0,0 +1,99 @@ +using System; +using MinecraftClient.Mapping; +using MinecraftClient.Pathing.Core; +using MinecraftClient.Pathing.Execution; +using MinecraftClient.Pathing.Execution.Templates; +using MinecraftClient.Physics; +using Xunit; + +namespace MinecraftClient.Tests.Pathing.Execution; + +public sealed class ClimbFallTemplateTests +{ + [Fact] + public void ClimbTemplate_AscendsLadderColumn_CompletesOverTarget() + { + World world = FlatWorldTestBuilder.CreateStoneFloor(min: -2, max: 2); + BuildLadder(world, x: 0, z: 0, bottomY: 80, topY: 84); + + var segment = new PathSegment + { + Start = new Location(0.5, 80, 0.5), + End = new Location(0.5, 84, 0.5), + MoveType = MoveType.Climb, + ExitTransition = PathTransitionType.FinalStop + }; + + var template = new ClimbTemplate(segment, null); + var physics = TemplateSimulationRunner.CreateGroundedPhysics(segment.Start, yaw: 0f); + physics.OnClimbable = true; + + TemplateState state = TemplateSimulationRunner.Run(template, physics, world, maxTicks: 220, out Location finalPos); + + Assert.Equal(TemplateState.Complete, state); + AssertNearTargetBlock(finalPos, segment.End); + } + + [Fact] + public void ClimbTemplate_DescendsLadderColumn_CompletesOverTarget() + { + World world = FlatWorldTestBuilder.CreateStoneFloor(min: -2, max: 2); + BuildLadder(world, x: 0, z: 0, bottomY: 80, topY: 84); + + var segment = new PathSegment + { + Start = new Location(0.5, 84, 0.5), + End = new Location(0.5, 80, 0.5), + MoveType = MoveType.Climb, + ExitTransition = PathTransitionType.FinalStop + }; + + var template = new ClimbTemplate(segment, null); + var physics = TemplateSimulationRunner.CreateGroundedPhysics(segment.Start, yaw: 180f); + physics.OnClimbable = true; + + TemplateState state = TemplateSimulationRunner.Run(template, physics, world, maxTicks: 220, out Location finalPos); + + Assert.Equal(TemplateState.Complete, state); + AssertNearTargetBlock(finalPos, segment.End); + } + + [Fact] + public void FallTemplate_DropsStraightDown_CompletesOnFloor() + { + World world = FlatWorldTestBuilder.CreateStoneFloor(min: 4, max: 8); + + var segment = new PathSegment + { + Start = new Location(5.5, 85, 5.5), + End = new Location(5.5, 80, 5.5), + MoveType = MoveType.Fall, + ExitTransition = PathTransitionType.FinalStop + }; + + var template = new FallTemplate(segment, null); + var physics = TemplateSimulationRunner.CreateGroundedPhysics(segment.Start, yaw: 90f); + physics.OnGround = false; + physics.DeltaMovement = new Vec3d(0, -0.15, 0); + + TemplateState state = TemplateSimulationRunner.Run(template, physics, world, maxTicks: 260, out Location finalPos); + + Assert.Equal(TemplateState.Complete, state); + AssertNearTargetBlock(finalPos, segment.End); + } + + private static void AssertNearTargetBlock(Location actual, Location target) + { + Assert.True(Math.Abs(actual.Y - target.Y) < 0.6, $"Expected final Y near {target.Y:F2}, got {actual.Y:F2}"); + Assert.True(TemplateFootingHelper.IsFootprintInsideTargetBlock(actual, target), + $"Expected the final footprint to stay within {target}, got {actual}"); + } + + private static void BuildLadder(World world, int x, int z, int bottomY, int topY) + { + for (int y = bottomY; y <= topY; y++) + { + FlatWorldTestBuilder.SetClimbable(world, x, y, z); + } + } +} diff --git a/MinecraftClient.Tests/Pathing/Execution/FlatWorldTestBuilder.cs b/MinecraftClient.Tests/Pathing/Execution/FlatWorldTestBuilder.cs index 5f282b29..70b14a63 100644 --- a/MinecraftClient.Tests/Pathing/Execution/FlatWorldTestBuilder.cs +++ b/MinecraftClient.Tests/Pathing/Execution/FlatWorldTestBuilder.cs @@ -1,6 +1,9 @@ using System; +using System.Collections.Generic; using System.Threading; using MinecraftClient.Mapping; +using MinecraftClient.Mapping.BlockPalettes; +using MinecraftClient.Physics; namespace MinecraftClient.Tests.Pathing.Execution; @@ -8,6 +11,7 @@ internal static class FlatWorldTestBuilder { private static readonly Lock InitLock = new(); private static bool _defaultsLoaded; + private static readonly Dictionary MaterialIds = new(); public static World CreateStoneFloor(int floorY = 79, int min = -32, int max = 32) { @@ -30,13 +34,56 @@ internal static class FlatWorldTestBuilder { for (int z = min; z <= max; z++) { - world.SetBlock(new Location(x, floorY, z), new Block(1)); + SetSolid(world, x, floorY, z); } } return world; } + public static void SetSolid(World world, int x, int y, int z) + { + SetMaterial(world, x, y, z, Material.Stone); + } + + public static void FillSolid(World world, int x1, int y1, int z1, int x2, int y2, int z2) + { + for (int x = Math.Min(x1, x2); x <= Math.Max(x1, x2); x++) + { + for (int y = Math.Min(y1, y2); y <= Math.Max(y1, y2); y++) + { + for (int z = Math.Min(z1, z2); z <= Math.Max(z1, z2); z++) + { + SetSolid(world, x, y, z); + } + } + } + } + + public static void ClearBox(World world, int x1, int y1, int z1, int x2, int y2, int z2) + { + for (int x = Math.Min(x1, x2); x <= Math.Max(x1, x2); x++) + { + for (int y = Math.Min(y1, y2); y <= Math.Max(y1, y2); y++) + { + for (int z = Math.Min(z1, z2); z <= Math.Max(z1, z2); z++) + { + world.SetBlock(new Location(x, y, z), Block.Air); + } + } + } + } + + public static void SetMaterial(World world, int x, int y, int z, Material material) + { + world.SetBlock(new Location(x, y, z), new Block(ResolveMaterialId(material))); + } + + public static void SetClimbable(World world, int x, int y, int z) + { + SetMaterial(world, x, y, z, Material.Ladder); + } + private static void EnsureDefaultDimensionsLoaded() { lock (InitLock) @@ -44,8 +91,31 @@ internal static class FlatWorldTestBuilder if (_defaultsLoaded) return; + Block.Palette = new Palette1219(); World.LoadDefaultDimensions1206Plus(); + BlockShapes.Initialize(); _defaultsLoaded = true; } } + + private static ushort ResolveMaterialId(Material material) + { + lock (InitLock) + { + if (MaterialIds.TryGetValue(material, out ushort id)) + return id; + + for (int candidate = 0; candidate <= ushort.MaxValue; candidate++) + { + if (Block.Palette.FromId(candidate) == material) + { + ushort resolved = (ushort)candidate; + MaterialIds[material] = resolved; + return resolved; + } + } + + throw new InvalidOperationException($"Could not resolve a block id for material {material}"); + } + } } diff --git a/MinecraftClient.Tests/Pathing/Execution/GroundedTemplateConvergenceTests.cs b/MinecraftClient.Tests/Pathing/Execution/GroundedTemplateConvergenceTests.cs new file mode 100644 index 00000000..f56e9fc3 --- /dev/null +++ b/MinecraftClient.Tests/Pathing/Execution/GroundedTemplateConvergenceTests.cs @@ -0,0 +1,84 @@ +using MinecraftClient.Mapping; +using MinecraftClient.Pathing.Core; +using MinecraftClient.Pathing.Execution; +using MinecraftClient.Pathing.Execution.Templates; +using Xunit; + +namespace MinecraftClient.Tests.Pathing.Execution; + +public sealed class GroundedTemplateConvergenceTests +{ + [Fact] + public void WalkTemplate_FinalStop_Completes_WhenFootprintStaysInsideTargetBlock() + { + World world = FlatWorldTestBuilder.CreateStoneFloor(); + var segment = new PathSegment + { + Start = new Location(0.5, 80, 0.5), + End = new Location(1.5, 80, 0.5), + MoveType = MoveType.Traverse, + ExitTransition = PathTransitionType.FinalStop + }; + + var template = new WalkTemplate(segment, null); + var physics = TemplateSimulationRunner.CreateGroundedPhysics(segment.Start, yaw: 270f); + + TemplateState state = TemplateSimulationRunner.Run(template, physics, world, maxTicks: 160, out Location finalPos); + + Assert.True(state == TemplateState.Complete, $"state={state} finalPos={finalPos} vel={physics.DeltaMovement}"); + Assert.True(TemplateFootingHelper.IsFootprintInsideTargetBlock(finalPos, segment.End)); + } + + [Fact] + public void WalkTemplate_PrepareJump_CompletesWithoutSettlingOnRunUpBlock() + { + World world = FlatWorldTestBuilder.CreateStoneFloor(); + var current = new PathSegment + { + Start = new Location(0.5, 80, 0.5), + End = new Location(1.5, 80, 0.5), + MoveType = MoveType.Traverse, + ExitTransition = PathTransitionType.PrepareJump, + PreserveSprint = true + }; + var next = new PathSegment + { + Start = new Location(1.5, 80, 0.5), + End = new Location(3.5, 80, 0.5), + MoveType = MoveType.Parkour, + ExitTransition = PathTransitionType.FinalStop + }; + + var template = new WalkTemplate(current, next); + var physics = TemplateSimulationRunner.CreateGroundedPhysics(current.Start, yaw: 270f); + + TemplateState state = TemplateSimulationRunner.Run(template, physics, world, maxTicks: 60, out _); + + Assert.Equal(TemplateState.Complete, state); + Assert.True(physics.DeltaMovement.X > 0.02); + } + + [Fact] + public void DescendTemplate_LandingRecovery_CompletesOnLandingBlock() + { + World world = FlatWorldTestBuilder.CreateStoneFloor(); + FlatWorldTestBuilder.ClearBox(world, 1, 79, 0, 1, 79, 0); + FlatWorldTestBuilder.SetSolid(world, 1, 78, 0); + + var segment = new PathSegment + { + Start = new Location(0.5, 80, 0.5), + End = new Location(1.5, 79, 0.5), + MoveType = MoveType.Descend, + ExitTransition = PathTransitionType.LandingRecovery + }; + + var template = new DescendTemplate(segment, null); + var physics = TemplateSimulationRunner.CreateGroundedPhysics(segment.Start, yaw: 270f); + + TemplateState state = TemplateSimulationRunner.Run(template, physics, world, maxTicks: 240, out Location finalPos); + + Assert.True(state == TemplateState.Complete, $"state={state} finalPos={finalPos} vel={physics.DeltaMovement}"); + Assert.True(TemplateFootingHelper.IsFootprintInsideTargetBlock(finalPos, segment.End)); + } +} diff --git a/MinecraftClient.Tests/Pathing/Execution/PathExecutorCompletionTests.cs b/MinecraftClient.Tests/Pathing/Execution/PathExecutorCompletionTests.cs index cf8920df..488e6882 100644 --- a/MinecraftClient.Tests/Pathing/Execution/PathExecutorCompletionTests.cs +++ b/MinecraftClient.Tests/Pathing/Execution/PathExecutorCompletionTests.cs @@ -24,7 +24,8 @@ public sealed class PathExecutorCompletionTests var physics = new PlayerPhysics { Yaw = 270f, - Pitch = 0f + Pitch = 0f, + OnGround = true }; var input = new MovementInput(); var pos = new Location(1.48, 80, 0.5); diff --git a/MinecraftClient.Tests/Pathing/Execution/TemplateFootingTests.cs b/MinecraftClient.Tests/Pathing/Execution/TemplateFootingTests.cs new file mode 100644 index 00000000..15479a12 --- /dev/null +++ b/MinecraftClient.Tests/Pathing/Execution/TemplateFootingTests.cs @@ -0,0 +1,47 @@ +using MinecraftClient.Mapping; +using MinecraftClient.Pathing.Execution.Templates; +using MinecraftClient.Physics; +using Xunit; + +namespace MinecraftClient.Tests.Pathing.Execution; + +public sealed class TemplateFootingTests +{ + [Fact] + public void IsFootprintInsideTargetBlock_ReturnsTrue_WhenPlayerIsNearEdgeButStillInside() + { + bool inside = TemplateFootingHelper.IsFootprintInsideTargetBlock( + new Location(10.69, 80.0, 4.50), + new Location(10.50, 80.0, 4.50)); + + Assert.True(inside); + } + + [Fact] + public void IsFootprintInsideTargetBlock_ReturnsFalse_WhenPlayerCrossesBlockEdge() + { + bool inside = TemplateFootingHelper.IsFootprintInsideTargetBlock( + new Location(10.81, 80.0, 4.50), + new Location(10.50, 80.0, 4.50)); + + Assert.False(inside); + } + + [Fact] + public void WillLeaveTargetBlockNextTick_ReturnsTrue_WhenVelocityWouldCarryPastEdge() + { + var physics = new PlayerPhysics + { + Position = new Vec3d(10.67, 80.0, 4.50), + DeltaMovement = new Vec3d(0.060, 0.0, 0.0), + OnGround = true + }; + + bool exitsNextTick = TemplateFootingHelper.WillLeaveTargetBlockNextTick( + new Location(10.67, 80.0, 4.50), + physics, + new Location(10.50, 80.0, 4.50)); + + Assert.True(exitsNextTick); + } +} diff --git a/MinecraftClient.Tests/Pathing/Execution/TemplateSimulationRunner.cs b/MinecraftClient.Tests/Pathing/Execution/TemplateSimulationRunner.cs new file mode 100644 index 00000000..9f716563 --- /dev/null +++ b/MinecraftClient.Tests/Pathing/Execution/TemplateSimulationRunner.cs @@ -0,0 +1,42 @@ +using MinecraftClient.Mapping; +using MinecraftClient.Pathing.Execution; +using MinecraftClient.Physics; + +namespace MinecraftClient.Tests.Pathing.Execution; + +internal static class TemplateSimulationRunner +{ + internal static PlayerPhysics CreateGroundedPhysics(Location start, float yaw) + { + return new PlayerPhysics + { + Position = new Vec3d(start.X, start.Y, start.Z), + DeltaMovement = Vec3d.Zero, + OnGround = true, + MovementSpeed = 0.1f, + Yaw = yaw, + Pitch = 0f + }; + } + + internal static TemplateState Run(IActionTemplate template, PlayerPhysics physics, World world, int maxTicks, out Location finalPos) + { + var input = new MovementInput(); + TemplateState state = TemplateState.InProgress; + + for (int tick = 0; tick < maxTicks; tick++) + { + input.Reset(); + Location pos = new(physics.Position.X, physics.Position.Y, physics.Position.Z); + state = template.Tick(pos, physics, input, world); + if (state != TemplateState.InProgress) + break; + + physics.ApplyInput(input); + physics.Tick(world); + } + + finalPos = new Location(physics.Position.X, physics.Position.Y, physics.Position.Z); + return state; + } +} diff --git a/MinecraftClient/Pathing/Execution/Templates/AscendTemplate.cs b/MinecraftClient/Pathing/Execution/Templates/AscendTemplate.cs index 536fae7b..a9126e8e 100644 --- a/MinecraftClient/Pathing/Execution/Templates/AscendTemplate.cs +++ b/MinecraftClient/Pathing/Execution/Templates/AscendTemplate.cs @@ -47,25 +47,11 @@ namespace MinecraftClient.Pathing.Execution.Templates if (physics.OnGround && dy > 0.1) input.Jump = true; - if (physics.OnGround && Math.Abs(dy) < 0.15) + if (physics.OnGround && Math.Abs(dy) < 0.2) { - TransitionBrakingDecision decision = TransitionBrakingPlanner.Plan(_segment, _nextSegment, pos, physics, world); - TemplateHelper.ApplyDecision(input, decision); - if (decision.HoldBack) - TemplateHelper.FaceSegmentHeading(physics, _segment); - - if (_segment.ExitTransition == PathTransitionType.ContinueStraight && horizDistSq < 0.25) + GroundedSegmentController.Apply(_segment, _nextSegment, pos, physics, input, world); + if (GroundedSegmentController.ShouldComplete(_segment, pos, physics)) return TemplateState.Complete; - - if (_segment.ExitTransition != PathTransitionType.ContinueStraight - && TemplateHelper.IsSettledAtEnd(pos, ExpectedEnd, physics, horizThresholdSq: 0.0025)) - { - return TemplateState.Complete; - } - } - else if (horizDistSq < 0.25 && Math.Abs(dy) < 0.8) - { - return TemplateState.Complete; } double movedSq = TemplateHelper.HorizontalDistanceSq(pos, _lastPos); diff --git a/MinecraftClient/Pathing/Execution/Templates/DescendTemplate.cs b/MinecraftClient/Pathing/Execution/Templates/DescendTemplate.cs index aa7edfe4..e88e91be 100644 --- a/MinecraftClient/Pathing/Execution/Templates/DescendTemplate.cs +++ b/MinecraftClient/Pathing/Execution/Templates/DescendTemplate.cs @@ -59,26 +59,14 @@ namespace MinecraftClient.Pathing.Execution.Templates float targetPitch = TemplateHelper.CalculatePitch(dx, dy, dz); physics.Pitch = TemplateHelper.SmoothPitch(physics.Pitch, targetPitch); - if (physics.OnGround && Math.Abs(dy) < (_hasFallen ? 0.8 : 0.5)) + if (physics.OnGround && Math.Abs(dy) < (_hasFallen ? 1.0 : 0.6)) { if (horizDistSq > 0.01) physics.Yaw = TemplateHelper.SmoothYaw(physics.Yaw, targetYaw); - TransitionBrakingDecision decision = TransitionBrakingPlanner.Plan(_segment, _nextSegment, pos, physics, world); - TemplateHelper.ApplyDecision(input, decision); - if (decision.HoldBack) - TemplateHelper.FaceSegmentHeading(physics, _segment); - - if (_segment.ExitTransition == PathTransitionType.ContinueStraight) - { - double completionThreshold = _hasFallen ? 0.5 : 0.25; - if (horizDistSq < completionThreshold) - return TemplateState.Complete; - } - else if (TemplateHelper.IsSettledAtEnd(pos, ExpectedEnd, physics, horizThresholdSq: 0.0025)) - { + GroundedSegmentController.Apply(_segment, _nextSegment, pos, physics, input, world); + if (GroundedSegmentController.ShouldComplete(_segment, pos, physics)) return TemplateState.Complete; - } } else if (physics.OnClimbable) { diff --git a/MinecraftClient/Pathing/Execution/Templates/GroundedSegmentController.cs b/MinecraftClient/Pathing/Execution/Templates/GroundedSegmentController.cs new file mode 100644 index 00000000..23d2fafb --- /dev/null +++ b/MinecraftClient/Pathing/Execution/Templates/GroundedSegmentController.cs @@ -0,0 +1,27 @@ +using MinecraftClient.Mapping; +using MinecraftClient.Physics; + +namespace MinecraftClient.Pathing.Execution.Templates +{ + internal static class GroundedSegmentController + { + internal static void Apply(PathSegment segment, PathSegment? nextSegment, Location pos, PlayerPhysics physics, MovementInput input, World world) + { + TransitionBrakingDecision decision = TransitionBrakingPlanner.Plan(segment, nextSegment, pos, physics, world); + TemplateHelper.ApplyDecision(input, decision); + if (decision.HoldBack) + TemplateHelper.FaceSegmentHeading(physics, segment); + } + + internal static bool ShouldComplete(PathSegment segment, Location pos, PlayerPhysics physics) + { + return segment.ExitTransition switch + { + PathTransitionType.ContinueStraight => TemplateHelper.IsNear(pos, segment.End, horizThresholdSq: 0.09), + PathTransitionType.PrepareJump => TemplateHelper.HasReachedSegmentEndPlane(pos, segment) + && TemplateHelper.ProjectHorizontalSpeedAlongSegment(physics, segment) > 0.02, + _ => physics.OnGround && TemplateHelper.IsSettledOnTargetBlock(pos, segment.End, physics) + }; + } + } +} diff --git a/MinecraftClient/Pathing/Execution/Templates/TemplateFootingHelper.cs b/MinecraftClient/Pathing/Execution/Templates/TemplateFootingHelper.cs new file mode 100644 index 00000000..8966e387 --- /dev/null +++ b/MinecraftClient/Pathing/Execution/Templates/TemplateFootingHelper.cs @@ -0,0 +1,60 @@ +using System; +using MinecraftClient.Mapping; +using MinecraftClient.Physics; + +namespace MinecraftClient.Pathing.Execution.Templates +{ + public static class TemplateFootingHelper + { + private const double HalfWidth = PhysicsConsts.PlayerWidth / 2.0; + + public static bool IsFootprintInsideTargetBlock(Location pos, Location target, double epsilon = 1.0E-4) + { + double minX = pos.X - HalfWidth; + double maxX = pos.X + HalfWidth; + double minZ = pos.Z - HalfWidth; + double maxZ = pos.Z + HalfWidth; + + double blockMinX = Math.Floor(target.X); + double blockMaxX = blockMinX + 1.0; + double blockMinZ = Math.Floor(target.Z); + double blockMaxZ = blockMinZ + 1.0; + + return minX >= blockMinX - epsilon + && maxX <= blockMaxX + epsilon + && minZ >= blockMinZ - epsilon + && maxZ <= blockMaxZ + epsilon; + } + + public static bool WillLeaveTargetBlockNextTick(Location pos, PlayerPhysics physics, Location target, double epsilon = 1.0E-4) + { + Location nextPos = new( + pos.X + physics.DeltaMovement.X, + pos.Y, + pos.Z + physics.DeltaMovement.Z); + return !IsFootprintInsideTargetBlock(nextPos, target, epsilon); + } + + public static bool WillCrossSupportExitNextTick(Location pos, PlayerPhysics physics, PathSegment segment, double epsilon = 1.0E-4) + { + double nextX = pos.X + physics.DeltaMovement.X; + double nextZ = pos.Z + physics.DeltaMovement.Z; + + double blockMinX = Math.Floor(segment.End.X); + double blockMaxX = blockMinX + 1.0; + double blockMinZ = Math.Floor(segment.End.Z); + double blockMaxZ = blockMinZ + 1.0; + + if (segment.HeadingX > 0 && nextX > blockMaxX - HalfWidth + epsilon) + return true; + if (segment.HeadingX < 0 && nextX < blockMinX + HalfWidth - epsilon) + return true; + if (segment.HeadingZ > 0 && nextZ > blockMaxZ - HalfWidth + epsilon) + return true; + if (segment.HeadingZ < 0 && nextZ < blockMinZ + HalfWidth - epsilon) + return true; + + return false; + } + } +} diff --git a/MinecraftClient/Pathing/Execution/Templates/TemplateHelper.cs b/MinecraftClient/Pathing/Execution/Templates/TemplateHelper.cs index 3a07c910..e01fdc05 100644 --- a/MinecraftClient/Pathing/Execution/Templates/TemplateHelper.cs +++ b/MinecraftClient/Pathing/Execution/Templates/TemplateHelper.cs @@ -90,14 +90,57 @@ namespace MinecraftClient.Pathing.Execution.Templates input.Back = decision.HoldBack; } + internal static bool HasReachedSegmentEndPlane(Location pos, PathSegment segment, double tolerance = 0.05) + { + GetNormalizedSegmentDirection(segment, out double dirX, out double dirZ); + double relX = pos.X - segment.End.X; + double relZ = pos.Z - segment.End.Z; + return relX * dirX + relZ * dirZ >= -tolerance; + } + + internal static double ProjectHorizontalSpeedAlongSegment(PlayerPhysics physics, PathSegment segment) + { + GetNormalizedSegmentDirection(segment, out double dirX, out double dirZ); + return physics.DeltaMovement.X * dirX + physics.DeltaMovement.Z * dirZ; + } + + internal static bool IsSettledOnTargetBlock(Location pos, Location target, PlayerPhysics physics, + double speedThresholdSq = 0.0016) + { + double horizontalSpeedSq = physics.DeltaMovement.X * physics.DeltaMovement.X + + physics.DeltaMovement.Z * physics.DeltaMovement.Z; + return TemplateFootingHelper.IsFootprintInsideTargetBlock(pos, target) + && !TemplateFootingHelper.WillLeaveTargetBlockNextTick(pos, physics, target) + && horizontalSpeedSq <= speedThresholdSq; + } + internal static bool IsSettledAtEnd(Location pos, Location target, PlayerPhysics physics, double horizThresholdSq = 0.0025, double speedThresholdSq = 0.0016) { + if (IsSettledOnTargetBlock(pos, target, physics, speedThresholdSq)) + return true; + double dx = target.X - pos.X; double dz = target.Z - pos.Z; double horizontalSpeedSq = physics.DeltaMovement.X * physics.DeltaMovement.X + physics.DeltaMovement.Z * physics.DeltaMovement.Z; return dx * dx + dz * dz <= horizThresholdSq && horizontalSpeedSq <= speedThresholdSq; } + + private static void GetNormalizedSegmentDirection(PathSegment segment, out double dirX, out double dirZ) + { + dirX = segment.End.X - segment.Start.X; + dirZ = segment.End.Z - segment.Start.Z; + double len = Math.Sqrt(dirX * dirX + dirZ * dirZ); + if (len < 1.0E-6) + { + dirX = 0.0; + dirZ = 0.0; + return; + } + + dirX /= len; + dirZ /= len; + } } } diff --git a/MinecraftClient/Pathing/Execution/Templates/WalkTemplate.cs b/MinecraftClient/Pathing/Execution/Templates/WalkTemplate.cs index 79c99e0b..5e8a1d47 100644 --- a/MinecraftClient/Pathing/Execution/Templates/WalkTemplate.cs +++ b/MinecraftClient/Pathing/Execution/Templates/WalkTemplate.cs @@ -40,22 +40,21 @@ namespace MinecraftClient.Pathing.Execution.Templates physics.Yaw = TemplateHelper.SmoothYaw(physics.Yaw, targetYaw); physics.Pitch = TemplateHelper.SmoothPitch(physics.Pitch, targetPitch); - TransitionBrakingDecision decision = TransitionBrakingPlanner.Plan(_segment, _nextSegment, pos, physics, world); - TemplateHelper.ApplyDecision(input, decision); - if (decision.HoldBack) - TemplateHelper.FaceSegmentHeading(physics, _segment); + GroundedSegmentController.Apply(_segment, _nextSegment, pos, physics, input, world); - if (_segment.ExitTransition == PathTransitionType.ContinueStraight && TemplateHelper.IsNear(pos, ExpectedEnd, horizThresholdSq: 0.09)) - return TemplateState.Complete; - - if (_segment.ExitTransition != PathTransitionType.ContinueStraight && TemplateHelper.IsSettledAtEnd(pos, ExpectedEnd, physics)) + if (GroundedSegmentController.ShouldComplete(_segment, pos, physics)) return TemplateState.Complete; double movedSq = TemplateHelper.HorizontalDistanceSq(pos, _lastPos); _stuckTicks = movedSq < 0.0005 ? _stuckTicks + 1 : 0; _lastPos = pos; - int maxTicks = _segment.ExitTransition == PathTransitionType.ContinueStraight ? 100 : 140; + int maxTicks = _segment.ExitTransition switch + { + PathTransitionType.ContinueStraight => 100, + PathTransitionType.PrepareJump => 80, + _ => 140 + }; if (_stuckTicks > 40 || _tickCount > maxTicks) return TemplateState.Failed; From 0e0fc06b728c435ec027762e224d7a94e3f321e8 Mon Sep 17 00:00:00 2001 From: BruceChen Date: Sun, 12 Apr 2026 21:33:40 +0800 Subject: [PATCH 22/37] feat: tighten parkour reliability checks --- .../SprintJumpTemplateScenarioTests.cs | 95 +++++ .../Pathing/Moves/MoveParkourTests.cs | 93 +++++ .../Execution/Templates/SprintJumpTemplate.cs | 84 ++++- .../Pathing/Moves/Impl/MoveParkour.cs | 43 ++- .../Pathing/Moves/ParkourFeasibility.cs | 104 ++++++ docs/guide/pathfinding-research.md | 269 +++++++++++++++ tools/test-pathing-template-regressions.sh | 324 ++++++++++++++++++ 7 files changed, 987 insertions(+), 25 deletions(-) create mode 100644 MinecraftClient.Tests/Pathing/Execution/SprintJumpTemplateScenarioTests.cs create mode 100644 MinecraftClient.Tests/Pathing/Moves/MoveParkourTests.cs create mode 100644 MinecraftClient/Pathing/Moves/ParkourFeasibility.cs create mode 100644 docs/guide/pathfinding-research.md create mode 100644 tools/test-pathing-template-regressions.sh diff --git a/MinecraftClient.Tests/Pathing/Execution/SprintJumpTemplateScenarioTests.cs b/MinecraftClient.Tests/Pathing/Execution/SprintJumpTemplateScenarioTests.cs new file mode 100644 index 00000000..6958f6f6 --- /dev/null +++ b/MinecraftClient.Tests/Pathing/Execution/SprintJumpTemplateScenarioTests.cs @@ -0,0 +1,95 @@ +using MinecraftClient.Mapping; +using MinecraftClient.Pathing.Core; +using MinecraftClient.Pathing.Execution; +using MinecraftClient.Pathing.Execution.Templates; +using Xunit; + +namespace MinecraftClient.Tests.Pathing.Execution; + +public sealed class SprintJumpTemplateScenarioTests +{ + [Fact] + public void SprintJumpTemplate_TwoBlockGap_FinalStop_Completes() + { + World world = FlatWorldTestBuilder.CreateStoneFloor(min: 0, max: 16); + FlatWorldTestBuilder.ClearBox(world, 0, 79, 0, 4, 82, 1); + FlatWorldTestBuilder.SetSolid(world, 0, 79, 0); + FlatWorldTestBuilder.SetSolid(world, 2, 79, 0); + + var segment = new PathSegment + { + Start = new Location(0.5, 80, 0.5), + End = new Location(2.5, 80, 0.5), + MoveType = MoveType.Parkour, + ExitTransition = PathTransitionType.FinalStop + }; + + var template = new SprintJumpTemplate(segment, null); + var physics = TemplateSimulationRunner.CreateGroundedPhysics(segment.Start, yaw: 270f); + + TemplateState state = TemplateSimulationRunner.Run(template, physics, world, maxTicks: 140, out Location finalPos); + + Assert.True(state == TemplateState.Complete, $"state={state} finalPos={finalPos} vel={physics.DeltaMovement}"); + Assert.True(TemplateFootingHelper.IsFootprintInsideTargetBlock(finalPos, segment.End)); + } + + [Fact] + public void SprintJumpTemplate_ThreeBlockGap_FinalStop_Completes() + { + World world = FlatWorldTestBuilder.CreateStoneFloor(min: 0, max: 16); + FlatWorldTestBuilder.ClearBox(world, 0, 79, 0, 5, 82, 1); + FlatWorldTestBuilder.SetSolid(world, 0, 79, 0); + FlatWorldTestBuilder.SetSolid(world, 3, 79, 0); + + var segment = new PathSegment + { + Start = new Location(0.5, 80, 0.5), + End = new Location(3.5, 80, 0.5), + MoveType = MoveType.Parkour, + ExitTransition = PathTransitionType.FinalStop + }; + + var template = new SprintJumpTemplate(segment, null); + var physics = TemplateSimulationRunner.CreateGroundedPhysics(segment.Start, yaw: 270f); + + TemplateState state = TemplateSimulationRunner.Run(template, physics, world, maxTicks: 140, out Location finalPos); + + Assert.True(state == TemplateState.Complete, $"state={state} finalPos={finalPos} vel={physics.DeltaMovement}"); + Assert.True(TemplateFootingHelper.IsFootprintInsideTargetBlock(finalPos, segment.End)); + } + + [Fact] + public void SprintJumpTemplate_TwoBlockGap_LandingRecovery_CompletesInsideLandingBlock() + { + World world = FlatWorldTestBuilder.CreateStoneFloor(min: 0, max: 16); + FlatWorldTestBuilder.ClearBox(world, 0, 79, 0, 4, 82, 2); + FlatWorldTestBuilder.SetSolid(world, 0, 79, 0); + FlatWorldTestBuilder.SetSolid(world, 2, 79, 0); + FlatWorldTestBuilder.SetSolid(world, 2, 79, 1); + FlatWorldTestBuilder.SetSolid(world, 0, 80, 1); + FlatWorldTestBuilder.SetSolid(world, 0, 81, 1); + + var segment = new PathSegment + { + Start = new Location(0.5, 80, 0.5), + End = new Location(2.5, 80, 0.5), + MoveType = MoveType.Parkour, + ExitTransition = PathTransitionType.LandingRecovery + }; + var next = new PathSegment + { + Start = new Location(2.5, 80, 0.5), + End = new Location(2.5, 80, 1.5), + MoveType = MoveType.Traverse, + ExitTransition = PathTransitionType.FinalStop + }; + + var template = new SprintJumpTemplate(segment, next); + var physics = TemplateSimulationRunner.CreateGroundedPhysics(segment.Start, yaw: 270f); + + TemplateState state = TemplateSimulationRunner.Run(template, physics, world, maxTicks: 140, out Location finalPos); + + Assert.True(state == TemplateState.Complete, $"state={state} finalPos={finalPos} vel={physics.DeltaMovement}"); + Assert.True(TemplateFootingHelper.IsFootprintInsideTargetBlock(finalPos, segment.End)); + } +} diff --git a/MinecraftClient.Tests/Pathing/Moves/MoveParkourTests.cs b/MinecraftClient.Tests/Pathing/Moves/MoveParkourTests.cs new file mode 100644 index 00000000..5799085f --- /dev/null +++ b/MinecraftClient.Tests/Pathing/Moves/MoveParkourTests.cs @@ -0,0 +1,93 @@ +using MinecraftClient.Mapping; +using MinecraftClient.Pathing.Core; +using MinecraftClient.Pathing.Moves.Impl; +using MinecraftClient.Tests.Pathing.Execution; +using Xunit; + +namespace MinecraftClient.Tests.Pathing.Moves; + +public sealed class MoveParkourTests +{ + private const int FloorY = 79; + + private static CalculationContext BuildContext(World world) + => new(world, allowParkour: true, allowParkourAscend: true); + + [Fact] + public void Rejects3x1JumpWhenRunUpMissing() + { + var world = FlatWorldTestBuilder.CreateStoneFloor(FloorY); + world.SetBlock(new Location(-1, FloorY, 0), Block.Air); + var ctx = BuildContext(world); + var move = new MoveParkour(3, 0); + var result = default(MoveResult); + + move.Calculate(ctx, 0, FloorY + 1, 0, ref result); + + Assert.True(result.IsImpossible); + } + + [Fact] + public void Accepts2x1GapWithClearTakeoff() + { + var world = FlatWorldTestBuilder.CreateStoneFloor(FloorY); + world.SetBlock(new Location(1, FloorY, 0), Block.Air); + var ctx = BuildContext(world); + var move = new MoveParkour(2, 0); + var result = default(MoveResult); + + move.Calculate(ctx, 0, FloorY + 1, 0, ref result); + + Assert.False(result.IsImpossible); + Assert.Equal(2, result.DestX); + } + + [Fact] + public void Rejects2x1WhenAdjacentBlockIsStillWalkable() + { + var world = FlatWorldTestBuilder.CreateStoneFloor(FloorY); + var ctx = BuildContext(world); + var move = new MoveParkour(2, 0); + var result = default(MoveResult); + + move.Calculate(ctx, 0, FloorY + 1, 0, ref result); + + Assert.True(result.IsImpossible); + } + + [Fact] + public void Rejects2x1GapWhenSideWallNarrowsLanding() + { + var world = FlatWorldTestBuilder.CreateStoneFloor(FloorY); + FlatWorldTestBuilder.ClearBox(world, -1, FloorY, -2, 4, FloorY + 4, 2); + FlatWorldTestBuilder.SetSolid(world, 0, FloorY, 0); + FlatWorldTestBuilder.SetSolid(world, 2, FloorY, 0); + FlatWorldTestBuilder.SetSolid(world, 1, FloorY + 1, -1); + FlatWorldTestBuilder.SetSolid(world, 1, FloorY + 2, -1); + FlatWorldTestBuilder.SetSolid(world, 2, FloorY + 1, -1); + FlatWorldTestBuilder.SetSolid(world, 2, FloorY + 2, -1); + + var ctx = BuildContext(world); + var move = new MoveParkour(2, 0); + var result = default(MoveResult); + + move.Calculate(ctx, 0, FloorY + 1, 0, ref result); + + Assert.True(result.IsImpossible); + } + + [Fact] + public void RejectsDiagonalWhenShoulderBlocked() + { + var world = FlatWorldTestBuilder.CreateStoneFloor(FloorY); + world.SetBlock(new Location(1, FloorY + 1, 0), new Block(1)); + world.SetBlock(new Location(1, FloorY + 2, 0), new Block(1)); + var ctx = BuildContext(world); + var move = new MoveParkour(1, 1); + var result = default(MoveResult); + + move.Calculate(ctx, 0, FloorY + 1, 0, ref result); + + Assert.True(result.IsImpossible); + } +} diff --git a/MinecraftClient/Pathing/Execution/Templates/SprintJumpTemplate.cs b/MinecraftClient/Pathing/Execution/Templates/SprintJumpTemplate.cs index 9ec8aa8b..453d5e12 100644 --- a/MinecraftClient/Pathing/Execution/Templates/SprintJumpTemplate.cs +++ b/MinecraftClient/Pathing/Execution/Templates/SprintJumpTemplate.cs @@ -29,6 +29,7 @@ namespace MinecraftClient.Pathing.Execution.Templates private readonly double _horizDist; private int _tickCount; private Phase _phase = Phase.Approach; + private bool _airReleaseCommitted; private bool _leftGround; private const float YawToleranceDeg = 5f; @@ -103,7 +104,11 @@ namespace MinecraftClient.Pathing.Execution.Templates _leftGround = true; bool pastTarget = IsPastTarget(pos); - bool releaseInAir = TransitionBrakingPlanner.ShouldReleaseForwardInAir(_segment, _nextSegment, pos, physics); + bool releaseInAir = ShouldReleaseInAir(pos, physics, world); + if (_segment.ExitTransition == PathTransitionType.LandingRecovery && releaseInAir) + _airReleaseCommitted = true; + if (_airReleaseCommitted) + releaseInAir = true; if (releaseInAir || pastTarget) { @@ -138,7 +143,8 @@ namespace MinecraftClient.Pathing.Execution.Templates return TemplateState.Complete; if (_segment.ExitTransition != PathTransitionType.ContinueStraight - && TemplateHelper.IsSettledAtEnd(pos, ExpectedEnd, physics, horizThresholdSq: 0.0025)) + && physics.OnGround + && TemplateHelper.IsSettledOnTargetBlock(pos, ExpectedEnd, physics)) { return TemplateState.Complete; } @@ -169,6 +175,80 @@ namespace MinecraftClient.Pathing.Execution.Templates return dot > 0.0; } + private bool ShouldReleaseInAir(Location pos, PlayerPhysics physics, World world) + { + if (TransitionBrakingPlanner.ShouldReleaseForwardInAir(_segment, _nextSegment, pos, physics)) + return true; + + if (_segment.ExitTransition == PathTransitionType.ContinueStraight || physics.OnGround) + return false; + + Location? landingIfHolding = PredictLandingPosition(physics, world, holdForward: true, holdSprint: true); + Location? landingIfReleased = PredictLandingPosition(physics, world, holdForward: false, holdSprint: false); + if (landingIfHolding is null || landingIfReleased is null) + return false; + + bool holdingStaysInside = TemplateFootingHelper.IsFootprintInsideTargetBlock(landingIfHolding.Value, ExpectedEnd); + bool releasingStaysInside = TemplateFootingHelper.IsFootprintInsideTargetBlock(landingIfReleased.Value, ExpectedEnd); + + if (_segment.ExitTransition == PathTransitionType.LandingRecovery && !holdingStaysInside) + return true; + + return !holdingStaysInside && releasingStaysInside; + } + + private Location? PredictLandingPosition(PlayerPhysics physics, World world, bool holdForward, bool holdSprint) + { + PlayerPhysics sim = ClonePhysics(physics); + var input = new MovementInput + { + Forward = holdForward, + Sprint = holdSprint + }; + + for (int tick = 0; tick < 16; tick++) + { + sim.ApplyInput(input); + sim.Tick(world); + if (sim.OnGround) + return new Location(sim.Position.X, sim.Position.Y, sim.Position.Z); + } + + return null; + } + + private static PlayerPhysics ClonePhysics(PlayerPhysics physics) + { + return new PlayerPhysics + { + Position = physics.Position, + DeltaMovement = physics.DeltaMovement, + Yaw = physics.Yaw, + Pitch = physics.Pitch, + OnGround = physics.OnGround, + HorizontalCollision = physics.HorizontalCollision, + VerticalCollision = physics.VerticalCollision, + VerticalCollisionBelow = physics.VerticalCollisionBelow, + FallDistance = physics.FallDistance, + StuckSpeedMultiplier = physics.StuckSpeedMultiplier, + Xxa = physics.Xxa, + Zza = physics.Zza, + Yya = physics.Yya, + Jumping = physics.Jumping, + Sprinting = physics.Sprinting, + Sneaking = physics.Sneaking, + CreativeFlying = physics.CreativeFlying, + InWater = physics.InWater, + IsUnderWater = physics.IsUnderWater, + InLava = physics.InLava, + OnClimbable = physics.OnClimbable, + HasSlowFalling = physics.HasSlowFalling, + HasLevitation = physics.HasLevitation, + LevitationAmplifier = physics.LevitationAmplifier, + MovementSpeed = physics.MovementSpeed + }; + } + private static float YawDifference(float current, float target) { float delta = target - current; diff --git a/MinecraftClient/Pathing/Moves/Impl/MoveParkour.cs b/MinecraftClient/Pathing/Moves/Impl/MoveParkour.cs index 410e5586..52bad5d1 100644 --- a/MinecraftClient/Pathing/Moves/Impl/MoveParkour.cs +++ b/MinecraftClient/Pathing/Moves/Impl/MoveParkour.cs @@ -1,6 +1,7 @@ using System; using MinecraftClient.Mapping; using MinecraftClient.Pathing.Core; +using MinecraftClient.Pathing.Moves; namespace MinecraftClient.Pathing.Moves.Impl { @@ -65,6 +66,12 @@ namespace MinecraftClient.Pathing.Moves.Impl return; } + if (!ParkourFeasibility.HasRunUp(ctx, x, y, z, XOffset, ZOffset, _yDelta)) + { + result.SetImpossible(); + return; + } + int destX = x + XOffset; int destZ = z + ZOffset; int destY = y + _yDelta; @@ -141,33 +148,23 @@ namespace MinecraftClient.Pathing.Moves.Impl } } - // For diagonal parkour, the player's AABB (0.6 wide) must clear both - // cardinal neighbors at the start. A wall on either side will clip the - // AABB during the initial sprint, preventing enough X or Z velocity to - // reach the target. Require BOTH cardinal exits to be passable. - if (xAbs > 0 && zAbs > 0) + if (!ParkourFeasibility.HasDiagonalShoulderClearance(ctx, x, y, z, XOffset, ZOffset)) { - bool canExitViaX = ctx.CanWalkThrough(x + xSign, y, z) && - ctx.CanWalkThrough(x + xSign, y + 1, z); - bool canExitViaZ = ctx.CanWalkThrough(x, y, z + zSign) && - ctx.CanWalkThrough(x, y + 1, z + zSign); - if (!canExitViaX || !canExitViaZ) - { - result.SetImpossible(); - return; - } + result.SetImpossible(); + return; } - // Overshoot safety: after landing, player continues moving. - // The block(s) past the destination in the jump direction must be passable. - int overX = destX + xSign; - int overZ = destZ + zSign; - if (!ctx.CanWalkThrough(overX, destY, overZ) || - !ctx.CanWalkThrough(overX, destY + 1, overZ)) + if (!ParkourFeasibility.HasCardinalSideClearance(ctx, x, y, z, XOffset, ZOffset)) { - // Wall right after landing - risk of collision. Still allow but add cost. - // (Baritone rejects this, but we allow with penalty since the template - // will decelerate anyway.) + result.SetImpossible(); + return; + } + + if (!ParkourFeasibility.HasLandingOvershootClearance( + ctx, destX, destY, destZ, xSign, zSign)) + { + result.SetImpossible(); + return; } // Cost model following Baritone: diff --git a/MinecraftClient/Pathing/Moves/ParkourFeasibility.cs b/MinecraftClient/Pathing/Moves/ParkourFeasibility.cs new file mode 100644 index 00000000..0257f66b --- /dev/null +++ b/MinecraftClient/Pathing/Moves/ParkourFeasibility.cs @@ -0,0 +1,104 @@ +using System; +using MinecraftClient.Pathing.Core; + +namespace MinecraftClient.Pathing.Moves; + +internal static class ParkourFeasibility +{ + public static bool HasRunUp( + CalculationContext ctx, + int x, + int y, + int z, + int xOffset, + int zOffset, + int yDelta) + { + double horiz = Math.Sqrt(xOffset * xOffset + zOffset * zOffset); + double threshold = yDelta > 0 ? 2.5 : 3.5; + if (horiz < threshold) + return true; + + int backX = x - Math.Sign(xOffset); + int backZ = z - Math.Sign(zOffset); + if (!ctx.CanWalkOn(backX, y - 1, backZ)) + return false; + return IsColumnPassable(ctx, backX, y, backZ); + } + + public static bool HasDiagonalShoulderClearance( + CalculationContext ctx, + int x, + int y, + int z, + int xOffset, + int zOffset) + { + if (xOffset == 0 || zOffset == 0) + return true; + + return IsColumnPassable(ctx, x + Math.Sign(xOffset), y, z) + && IsColumnPassable(ctx, x, y, z + Math.Sign(zOffset)); + } + + public static bool HasLandingOvershootClearance( + CalculationContext ctx, + int destX, + int destY, + int destZ, + int xSign, + int zSign) + { + if (xSign == 0 && zSign == 0) + return true; + + return IsColumnPassable(ctx, destX + xSign, destY, destZ + zSign); + } + + public static bool HasCardinalSideClearance( + CalculationContext ctx, + int x, + int y, + int z, + int xOffset, + int zOffset) + { + if ((xOffset == 0) == (zOffset == 0)) + return true; + + if (xOffset != 0) + { + int xSign = Math.Sign(xOffset); + for (int step = 1; step <= Math.Abs(xOffset); step++) + { + int gx = x + xSign * step; + if (!IsColumnPassable(ctx, gx, y, z - 1) + || !IsColumnPassable(ctx, gx, y, z + 1)) + { + return false; + } + } + + return true; + } + + int zSign = Math.Sign(zOffset); + for (int step = 1; step <= Math.Abs(zOffset); step++) + { + int gz = z + zSign * step; + if (!IsColumnPassable(ctx, x - 1, y, gz) + || !IsColumnPassable(ctx, x + 1, y, gz)) + { + return false; + } + } + + return true; + } + + private static bool IsColumnPassable(CalculationContext ctx, int x, int y, int z) + { + return ctx.CanWalkThrough(x, y, z) + && ctx.CanWalkThrough(x, y + 1, z); + } +} diff --git a/docs/guide/pathfinding-research.md b/docs/guide/pathfinding-research.md new file mode 100644 index 00000000..d1a7597c --- /dev/null +++ b/docs/guide/pathfinding-research.md @@ -0,0 +1,269 @@ +# Pathfinding Research: Blip-Up Mechanism and Jump Mechanics + +## Background + +During research for the MCC pathfinding rewrite, we investigated advanced parkour +mechanics in Minecraft Java Edition to determine which movement patterns the new +system should support. + +## Blip-Up Mechanism + +### What is it + +Blip-Up is a physics exploit caused by the **Step-Assist (Stepping)** system +interacting incorrectly with airborne landing. It allows the player to "land" +above ground level and immediately jump again, achieving heights that would +normally be impossible. + +### How Step-Assist works (normal case) + +When the player walks into an obstacle shorter than 0.6 blocks while on the +ground, the game automatically steps the player over it: + +1. Reset the player bounding box to the **position at the start of the tick** +2. Raise the bounding box up by at most 0.6 blocks +3. Move the bounding box horizontally (X axis first, then Z) +4. Lower the bounding box back down by at most 0.6 blocks +5. Compare with the non-stepped movement; keep whichever achieves greater + horizontal distance + +### How Blip-Up exploits it + +The critical flaw: step 1 resets the bounding box to the position at the +**start of the tick**, not after landing. If the player was airborne at the +start of the tick but lands during that tick's collision resolution, the +stepping procedure initiates from the **airborne position** (higher than +the ground). The bounding box may not get lowered enough, causing the +player to "land" mid-air while `onGround` is set to `true`. + +Since `onGround = true`, the player can immediately jump again from this +elevated position. + +### Requirements + +- Negative vertical velocity (falling or descending from a jump arc) +- Land next to a wall of relatively low height (lower than the player's + remaining fall distance on that tick) +- The wall triggers step-assist even though it cannot be directly stepped onto + +### Observed test case + +The following sequence was observed in Bedrock Edition testing (which has +similar but not identical stepping behavior): + +1. Player sneaks to the edge of a purple wool block, facing a wall made of + diamond blocks. The wall extends 3 blocks outward from the landing block. + +2. Player positions camera slightly outward and holds forward while sneaking, + reaching the extreme edge of the block. + +3. Player jumps forward. On the landing tick, they collide with both the + purple wool surface and the adjacent wall. + +4. The stepping system triggers at the airborne position, causing the player + to "land" slightly above the actual surface. `onGround` becomes true. + +5. The player immediately jumps again from this elevated position, gaining + enough height to reach the top of the wall. + +Test images show the player at position (-1, 197, 3) initially, climbing +to (-1, 198, 6) and (-1, 197, 7) via two consecutive jumps where normally +only one jump from ground level would not reach the wall top. + +### Version differences + +| Version range | Blip-Up status | Notes | +|---|---|---| +| Pre-1.8 | Works (with caveats) | MC-3337 bug affects stepping under ceilings | +| 1.8.0 | Works | Always lowers bounding box by 0.6b; grinding impossible | +| 1.8.1 - 1.13.x | Works | Each consecutive blip adds ~0.104 blocks height | +| 1.9 - 1.13.x | Works (slightly different) | Jump height increased to 1.252 (from 1.249); each blip adds ~0.121 | +| 1.14+ | **Patched** | Bounding box now lowers to `playerHeight - verticalSpeed` instead of fixed 0.6b | +| 1.14+ | "Normal blip" still works | Standard step-assist onto low obstacles is intentional behavior | + +### Related mechanics + +- **Jump Cancel**: stepping applied to jumping motion instead of landing; + cancels upward momentum on a slab/stair or ceiling, allowing rapid re-jump + for momentum gain (2-tick cycle under trapdoor ceiling) +- **Grinding**: chaining jump cancels to accelerate; "stair grinding" on + stairs or "ceiling grinding" under a low ceiling +- **Normal Blip**: intended behavior where stepping lets you walk onto an + adjacent block of modest height difference + +### Implications for MCC pathfinding + +1. **1.14+ servers (majority of modern servers)**: Blip-Up is patched; the + pathfinding system does **not** need to account for it. Standard step-up + (0.6b max) and normal jump height (1.252b) define the reachable space. + +2. **Pre-1.14 servers**: if Blip-Up support is desired, the physics engine's + `CollisionDetector.Collide()` step-up logic must match the version-specific + behavior precisely. This is deferred to a later phase. + +3. **Jump Cancel / Grinding**: these mechanics could theoretically enable + faster momentum gain, but they require version-specific ceiling heights + and are considered advanced; deferred to later phases. + +4. **Initial scope**: the pathfinding rewrite focuses on standard jump + physics (1.14+), covering flat jumps, sprint jumps (2-4 blocks), + ascend/descend, and neo-style wall jumps that are achievable within + vanilla 1.14+ physics constraints. + +## Jump Reachability Simulation Results + +The simulation script `tools/sim_jump_reach.py` models vanilla 1.14+ physics +tick-by-tick to determine which jump destinations are reachable. All constants +are sourced from `PhysicsConsts.cs` and match vanilla 1.21.x. + +Run with: `python3 tools/sim_jump_reach.py --verbose` + +### Key Physics Constants + +| Parameter | Value | Source | +|---|---|---| +| Player width | 0.6m | Entity bounding box | +| Player height | 1.8m | Standing pose | +| Base jump power | 0.42 m/tick | LivingEntity.jumpFromGround | +| Sprint jump horizontal boost | +0.2 m/tick | Player sprint bonus | +| Gravity | 0.08 m/tick^2 | Entity gravity | +| Air horizontal drag | 0.91x per tick | Friction multiplier | +| Vertical drag | 0.98x per tick | DragY | +| Air acceleration | 0.02 | LivingEntity.getFrictionInfluencedSpeed | +| Max step height | 0.6m | Step-assist | +| Jump apex | ~1.252b | Computed from physics | + +### Jump Apex + +The maximum jump height is ~1.252 blocks regardless of horizontal speed +or momentum. Momentum only affects horizontal distance at the apex: + +| Mode | Momentum | Apex Y | X at Apex | +|---|---|---|---| +| Walk | 0t | 1.2522 | 0.885 | +| Walk | 12t | 1.2522 | 4.729 | +| Sprint | 0t | 1.2522 | 1.846 | +| Sprint | 12t | 1.2522 | 5.689 | + +### Gap Feasibility Matrix (Sprint, 12t Flat Momentum) + +Can the player cross a gap of N blocks to a platform at height offset dy? + +| Gap | dy=+1.0 | dy=+0.5 | dy=0 | dy=-1 | dy=-2 | dy=-3 | dy=-5 | +|---|---|---|---|---|---|---|---| +| 0 | YES | YES | YES | YES | YES | YES | YES | +| 1 | YES | YES | YES | YES | YES | YES | YES | +| 2 | YES | YES | YES | YES | YES | YES | YES | +| 3 | YES | YES | YES | YES | YES | YES | YES | +| 4 | YES | YES | YES | YES | YES | YES | YES | +| 5 | YES | YES | YES | YES | YES | YES | YES | +| 6 | no | YES | YES | YES | YES | YES | YES | + +### Gap Feasibility Matrix (Walk, 12t Momentum) + +| Gap | dy=+1.0 | dy=+0.5 | dy=0 | dy=-1 | dy=-2 | dy=-3 | dy=-5 | +|---|---|---|---|---|---|---|---| +| 0 | YES | YES | YES | YES | YES | YES | YES | +| 1 | YES | YES | YES | YES | YES | YES | YES | +| 2 | YES | YES | YES | YES | YES | YES | YES | +| 3 | YES | YES | YES | YES | YES | YES | YES | +| 4 | YES | YES | YES | YES | YES | YES | YES | +| 5 | no | no | YES | YES | YES | YES | YES | + +### Gap Feasibility Matrix (Standing Sprint Jump, 0t Momentum) + +| Gap | dy=+1.0 | dy=+0.5 | dy=0 | dy=-1 | dy=-2 | dy=-3 | dy=-5 | +|---|---|---|---|---|---|---|---| +| 0 | YES | YES | YES | YES | YES | YES | YES | +| 1 | YES | YES | YES | YES | YES | YES | YES | +| 2 | no | YES | YES | YES | YES | YES | YES | +| 3 | no | no | no | no | no | no | no | + +### Neo Jump Analysis (Flat, 12t Momentum) + +For a wall of N blocks, the player must travel at least N + 0.6m forward +to clear the wall end (accounting for 0.6m player bounding box width). + +| Wall Length | Sprint Reach | Needed | Margin | Feasible | +|---|---|---|---|---| +| 1b | 7.728m | 1.6m | +6.128 | YES | +| 2b | 7.728m | 2.6m | +5.128 | YES | +| 3b | 7.728m | 3.6m | +4.128 | YES | +| 4b | 7.728m | 4.6m | +3.128 | YES | + +Note: the neo analysis uses simplified straight-line reach. In practice, +the player must also perform a lateral (sideways) movement to round the +wall corner, which reduces effective forward distance slightly. The large +margins suggest all 1-4 block neos are comfortably achievable. + +### Ceiling-Constrained Jumps (Sprint, 12t Momentum) + +Lower ceilings reduce jump height and therefore reduce horizontal distance: + +| Ceiling Height | Landing X | Delta vs Open | +|---|---|---| +| 4.0b (no effect) | 7.728m | +0.000 | +| 3.0b | 7.415m | -0.313 | +| 2.5b | 5.689m | -2.039 | +| 2.0bc (headhitter) | 4.482m | -3.246 | +| 1.8125bc (trapdoor hh) | 4.042m | -3.687 | + +### Sprint Jump Trajectory (12 tick momentum, flat landing) + +| Tick | Phase | X | Y | VX | VY | +|---|---|---|---|---|---| +| 0-12 | Momentum (ground) | 0 -> 3.09 | 0 | 0 -> 0.156 | 0 | +| 13 | Jump tick | 3.58 | 0.42 | 0.443 | 0.333 | +| 14 | Rising | 4.04 | 0.75 | 0.421 | 0.248 | +| 15 | Rising | 4.48 | 1.00 | 0.401 | 0.165 | +| 16 | Rising | 4.90 | 1.17 | 0.382 | 0.083 | +| 17 | Apex | 5.30 | 1.25 | 0.366 | 0.003 | +| 18 | Falling | 5.69 | 1.25 | 0.351 | -0.075 | +| 19-23 | Falling | 5.69 -> 7.42 | 1.25 -> 0.12 | 0.351 -> 0.293 | accelerating | +| 24 | Landing | 7.73 | 0.00 | 0.171 | 0 | + +Total airborne time: 11 ticks (tick 13-24). + +### Implications for Pathfinding + +Based on these results, the initial pathfinding scope should include: + +1. **Standard jumps**: sprint jump can clear up to 5 block gaps (flat) + and 4-5 block gaps with +1.0 height, with full momentum. + +2. **Standing sprint jumps**: only reliable for up to 1 block gap with + +1 height, or 2 block gap flat. This is relevant for confined spaces + where a long run-up is unavailable. + +3. **Neo jumps (1-2 block walls)**: comfortable margin with sprint. + The pathfinder should include these as standard movement options. + +4. **Ascending jumps (+1 block)**: always feasible with sprint for gaps + up to 5 blocks. The key constraint is the 1.252 block jump height + limit, meaning +1.0 is fine but +1.25+ is extremely marginal. + +5. **Ceiling constraint**: a 2bc (headhitter) ceiling cuts reach roughly + in half. The pathfinder should detect ceiling height and adjust the + maximum jump gap accordingly. + +## Reliability-first rule + +Every movement proposal generated by the MCC pathfinder must be grounded in reality: if a move is accepted, it must be one the bot can execute in vanilla 1.21.11 physics. That means the final support footprint is the ultimate arbiter: if the planner can get the player onto a solid block (even if they momentarily hover over air during the transition), the move is considered valid. Conversely, any shape that would finish without block contact, rely on unsupported parkour tricks, or require a start-up/run-up that the current layout cannot provide must be rejected rather than downgraded to a risky heuristic. + +The new regression harness in `tools/test-pathing-template-regressions.sh` codifies this rule by automating: + +1. Flat-stopping scenarios that ensure the arrival block is within the planner’s tolerance. +2. Parkour + L-turn footprints to watch for actual support at the destination. +3. Side-wall jump acceptance conditioned on an executable landing. +4. A 3×1 no-run-up rejection to prevent non-executable plans from sneaking through. +5. Mixed ascend/descend/climb smoke cases so that both vertical transitions and ladder climbs respect the reliable support requirement. + +Keeping the rule explicit here reminds future contributors that the planner should never promise a move that physically cannot finish with block contact. + +## References + +- [Minecraft Parkour Wiki: Blip](https://www.mcpk.wiki/wiki/Blip) +- [Minecraft Parkour Wiki: Stepping](https://www.mcpk.wiki/wiki/Stepping) +- [Minecraft Parkour Wiki: Jump Cancel](https://www.mcpk.wiki/wiki/Jump_Cancel) +- [Minecraft Parkour Wiki: Parkour Nomenclature](https://www.mcpk.wiki/wiki/Parkour_Nomenclature) +- [Minecraft Parkour Wiki: Collisions](https://www.mcpk.wiki/wiki/Collisions) diff --git a/tools/test-pathing-template-regressions.sh b/tools/test-pathing-template-regressions.sh new file mode 100644 index 00000000..c211622f --- /dev/null +++ b/tools/test-pathing-template-regressions.sh @@ -0,0 +1,324 @@ +#!/usr/bin/env bash +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" +source "$REPO_ROOT/tools/mcc-env.sh" + +VERSION="${1:-1.21.11}" +SESSION="mcc-pathing-template" +TEST_ROOT="${TMPDIR:-/tmp}/mcc-pathing-template" +CFG="$TEST_ROOT/MinecraftClient.pathing-template.ini" +LOG="$TEST_ROOT/mcc-pathing-template.log" +INPUT_FILE="$REPO_ROOT/mcc_input.txt" +PREPARE_CFG_SCRIPT="$REPO_ROOT/.skills/mcc-integration-testing/scripts/prepare_offline_mcc_config.sh" +ENSURE_SERVER_SCRIPT="$REPO_ROOT/.skills/mcc-integration-testing/scripts/ensure_offline_server.sh" + +mkdir -p "$TEST_ROOT" + +send_mcc() { + echo "$1" >> "$INPUT_FILE" +} + +log_line_count() { + if [[ -f "$LOG" ]]; then + wc -l < "$LOG" + else + echo 0 + fi +} + +log_since() { + local from_line="$1" + if [[ ! -f "$LOG" ]]; then + return + fi + + tail -n +"$((from_line + 1))" "$LOG" +} + +wait_for_log() { + local pattern="$1" + local from_line="${2:-0}" + local timeout="${3:-20}" + + for _ in $(seq 1 "$timeout"); do + if log_since "$from_line" | grep -Fq "$pattern"; then + return 0 + fi + sleep 1 + done + + return 1 +} + +wait_for_navigation() { + local from_line="$1" + local timeout="${2:-25}" + + for _ in $(seq 1 "$timeout"); do + local recent + recent="$(log_since "$from_line")" + + if grep -Eq "\\[PathMgr\\] (Replan failed|Giving up)|\\[PathMgr\\] Segment failed, replanning|\\[PathExec\\] Segment .* FAILED" <<<"$recent"; then + echo "$recent" >&2 + return 1 + fi + + if grep -Fq "[PathMgr] Navigation complete!" <<<"$recent"; then + return 0 + fi + + sleep 1 + done + + echo "Timed out waiting for navigation completion" >&2 + log_since "$from_line" >&2 + return 1 +} + +wait_for_failure_signal() { + local from_line="$1" + local timeout="${2:-20}" + + for _ in $(seq 1 "$timeout"); do + local recent + recent="$(log_since "$from_line")" + + if grep -Eq "\\[PathMgr\\] (Replan failed|Giving up)|No path found|\\[Navigate\\] A\\* result: Failed" <<<"$recent"; then + return 0 + fi + + sleep 1 + done + + return 1 +} + +extract_last_location() { + local from_line="${1:-0}" + + python3 - "$LOG" "$from_line" <<'PY' +import pathlib +import re +import sys + +log_path = pathlib.Path(sys.argv[1]) +from_line = int(sys.argv[2]) +text = log_path.read_text(errors="ignore") +text = "\n".join(text.splitlines()[from_line:]) +text = re.sub(r"\x1b\[[0-9;]*m", "", text) +matches = re.findall(r"Location\s+([-\d.]+),\s+([-\d.]+),\s+([-\d.]+)", text) +if not matches: + matches = re.findall(r"Segment \d+ complete .* at \(([-\d.]+),([-\d.]+),([-\d.]+)\)", text) +if not matches: + matches = re.findall(r"pos=\(([-\d.]+),\s*([-\d.]+),\s*([-\d.]+)\)", text) +if not matches: + raise SystemExit("No location line found in MCC log") +x, y, z = matches[-1] +print(f"{x} {y} {z}") +PY +} + +assert_close() { + local actual_x="$1" + local actual_y="$2" + local actual_z="$3" + local target_x="$4" + local target_y="$5" + local target_z="$6" + local tolerance="${7:-0.2}" + + python3 - <<'PY' "$actual_x" "$actual_y" "$actual_z" "$target_x" "$target_y" "$target_z" "$tolerance" +import math +import sys + +ax, ay, az, tx, ty, tz, tol = map(float, sys.argv[1:]) +if abs(ax - tx) > tol or abs(ay - ty) > tol or abs(az - tz) > tol: + raise SystemExit( + f"Expected ({tx:.2f}, {ty:.2f}, {tz:.2f}) within {tol:.2f}, got ({ax:.2f}, {ay:.2f}, {az:.2f})" + ) +PY +} + +print_summary() { + local header="$1" + + echo "" + echo "----- $header -----" + if [[ -f "$LOG" ]]; then + tail -n 40 "$LOG" | sed 's/\x1b\[[0-9;]*m//g' + else + echo "(no log available yet)" + fi +} + +start_mcc() { + bash "$PREPARE_CFG_SCRIPT" "$CFG" "$VERSION" CursorBot >/dev/null + + : > "$INPUT_FILE" + : > "$LOG" + + tmux kill-session -t "$SESSION" 2>/dev/null || true + tmux new-session -d -s "$SESSION" -x 160 -y 50 \ + "cd '$REPO_ROOT' && MCC_FILE_INPUT=1 dotnet run --project MinecraftClient -c Release --no-build -- '$CFG' CursorBot - localhost:25565 > '$LOG' 2>&1; echo '=== MCC EXITED ==='; sleep 600" + + wait_for_log "Server was successfully joined." 0 20 + send_mcc "debug on" + sleep 1 +} + +run_flat_final_stop() { + echo "== Flat final stop ==" + mc-rcon "fill 95 79 95 115 79 105 stone" >/dev/null + mc-rcon "fill 95 80 95 115 85 105 air" >/dev/null + mc-rcon "tp CursorBot 100.5 80 100.5" >/dev/null + sleep 2 + + local start_line + start_line="$(log_line_count)" + send_mcc "pathfind 103 80 100" + wait_for_navigation "$start_line" 30 + + local x y z + read -r x y z <<< "$(extract_last_location "$start_line")" + echo " Final location: $x $y $z" + assert_close "$x" "$y" "$z" "103.50" "80.00" "100.50" + print_summary "Flat final stop" +} + +run_parkour_into_turn() { + echo "== Parkour into L-turn ==" + mc-rcon "fill 118 79 108 126 79 112 air" >/dev/null + mc-rcon "fill 118 80 108 126 90 112 air" >/dev/null + mc-rcon "setblock 120 79 110 stone" >/dev/null + mc-rcon "setblock 122 79 110 stone" >/dev/null + mc-rcon "setblock 122 79 111 stone" >/dev/null + mc-rcon "setblock 120 80 111 stone" >/dev/null + mc-rcon "setblock 120 81 111 stone" >/dev/null + mc-rcon "tp CursorBot 120.5 80 110.5" >/dev/null + sleep 2 + + local start_line + start_line="$(log_line_count)" + send_mcc "pathfind 122 80 111" + wait_for_navigation "$start_line" 30 + + local x y z + read -r x y z <<< "$(extract_last_location "$start_line")" + echo " Final location: $x $y $z" + assert_close "$x" "$y" "$z" "122.50" "80.00" "111.50" + print_summary "Parkour into L-turn" +} + +run_side_wall_jump() { + echo "== Rejected 2x1 side-wall jump ==" + mc-rcon "fill 130 79 124 138 79 132 air" >/dev/null + mc-rcon "fill 130 80 124 138 84 132 air" >/dev/null + mc-rcon "setblock 131 79 127 stone" >/dev/null + mc-rcon "setblock 133 79 127 stone" >/dev/null + mc-rcon "setblock 132 80 126 stone" >/dev/null + mc-rcon "setblock 132 81 126 stone" >/dev/null + mc-rcon "setblock 133 80 126 stone" >/dev/null + mc-rcon "setblock 133 81 126 stone" >/dev/null + mc-rcon "tp CursorBot 131.5 80 127.5" >/dev/null + sleep 2 + + local start_line + start_line="$(log_line_count)" + send_mcc "pathfind 133 80 127" + + if wait_for_failure_signal "$start_line" 20; then + echo " Pathfinding rejected as expected." + else + echo " Expected rejection but navigation continued." >&2 + log_since "$start_line" >&2 + return 1 + fi + + print_summary "2x1 side-wall rejection" +} + +run_reject_3x1_gap() { + echo "== Rejected 3x1 no-run-up gap ==" + mc-rcon "fill 140 79 135 148 79 140 stone" >/dev/null + mc-rcon "fill 140 80 135 148 85 140 air" >/dev/null + mc-rcon "setblock 143 80 138 stone" >/dev/null + mc-rcon "tp CursorBot 141.5 80 138.5" >/dev/null + sleep 2 + + local start_line + start_line="$(log_line_count)" + send_mcc "pathfind 144 81 138" + + if wait_for_log "Replan failed" "$start_line" 20; then + echo " Pathfinding rejected as expected." + elif wait_for_navigation "$start_line" 30; then + local x y z + read -r x y z <<< "$(extract_last_location "$start_line")" + if python3 - <<'PY' "$x" "$y" "$z" +import sys +x, y, z = map(float, sys.argv[1:]) +tx, ty, tz = 144.5, 81.0, 138.5 +tol = 0.2 +sys.exit(0 if abs(x - tx) > tol or abs(y - ty) > tol or abs(z - tz) > tol else 1) +PY + then + echo " Pathfinder only reached a partial fallback, rejection accepted." + else + echo " Expected rejection but goal was reached." >&2 + return 1 + fi + else + echo " Expected rejection but navigation continued." >&2 + return 1 + fi + + print_summary "3x1 no-run-up rejection" +} + +run_mixed_ascend_descend_climb() { + echo "== Mixed ascend/descend/climb smoke ==" + mc-rcon "fill 170 79 160 178 79 168 stone" >/dev/null + mc-rcon "fill 170 80 160 178 85 168 air" >/dev/null + mc-rcon "setblock 175 80 162 stone" >/dev/null + mc-rcon "setblock 176 81 162 stone" >/dev/null + mc-rcon "setblock 177 82 162 stone" >/dev/null + mc-rcon "fill 178 78 160 182 78 164 stone" >/dev/null + mc-rcon "fill 178 83 160 182 83 164 air" >/dev/null + mc-rcon "setblock 181 80 162 minecraft:ladder[facing=east]" >/dev/null + mc-rcon "setblock 181 81 162 minecraft:ladder[facing=east]" >/dev/null + mc-rcon "setblock 181 82 162 minecraft:ladder[facing=east]" >/dev/null + mc-rcon "setblock 181 83 162 minecraft:ladder[facing=east]" >/dev/null + mc-rcon "tp CursorBot 171.5 80 160.5" >/dev/null + sleep 2 + + local start_line + start_line="$(log_line_count)" + send_mcc "pathfind 182 83 162" + wait_for_navigation "$start_line" 35 + + echo " Mixed route completed (review log for ascend/descend/climb segments)." + print_summary "Ascend/Descend/Climb smoke" +} + +mcc-preflight "$VERSION" >/dev/null +mc-reset-test-env "$VERSION" >/dev/null +bash "$ENSURE_SERVER_SCRIPT" "$VERSION" >/dev/null +mc-start "$VERSION" >/dev/null +mc-wait-ready "$VERSION" 60 >/dev/null +mcc-kill >/dev/null 2>&1 || true +start_mcc + +mc-rcon "difficulty peaceful" >/dev/null 2>&1 || true +mc-rcon "gamerule doMobSpawning false" >/dev/null 2>&1 || true +mc-rcon "time set day" >/dev/null 2>&1 || true + +run_flat_final_stop +run_parkour_into_turn +run_side_wall_jump +run_reject_3x1_gap +run_mixed_ascend_descend_climb + +echo "" +echo "Pathing template regression suite complete." From 4b92781d10f955dcf8e2f16e6871c6e47b8623de Mon Sep 17 00:00:00 2001 From: BruceChen Date: Sun, 12 Apr 2026 23:42:25 +0800 Subject: [PATCH 23/37] fix: brake landing recovery before turns --- .../Execution/LivePathingRegressionTests.cs | 45 +++++++++++++++++++ .../TransitionBrakingPlannerTests.cs | 33 ++++++++++++++ .../Execution/TransitionBrakingPlanner.cs | 11 ++++- 3 files changed, 88 insertions(+), 1 deletion(-) create mode 100644 MinecraftClient.Tests/Pathing/Execution/LivePathingRegressionTests.cs diff --git a/MinecraftClient.Tests/Pathing/Execution/LivePathingRegressionTests.cs b/MinecraftClient.Tests/Pathing/Execution/LivePathingRegressionTests.cs new file mode 100644 index 00000000..610d8e2d --- /dev/null +++ b/MinecraftClient.Tests/Pathing/Execution/LivePathingRegressionTests.cs @@ -0,0 +1,45 @@ +using MinecraftClient.Mapping; +using MinecraftClient.Pathing.Core; +using MinecraftClient.Pathing.Execution; +using MinecraftClient.Pathing.Execution.Templates; +using Xunit; + +namespace MinecraftClient.Tests.Pathing.Execution; + +public sealed class LivePathingRegressionTests +{ + [Fact] + public void SprintJumpTemplate_LandingRecoveryIntoTurn_CompletesInsideLandingBlock() + { + World world = FlatWorldTestBuilder.CreateStoneFloor(min: 108, max: 126); + FlatWorldTestBuilder.ClearBox(world, 118, 79, 108, 126, 90, 112); + FlatWorldTestBuilder.SetSolid(world, 120, 79, 110); + FlatWorldTestBuilder.SetSolid(world, 122, 79, 110); + FlatWorldTestBuilder.SetSolid(world, 122, 79, 111); + FlatWorldTestBuilder.SetSolid(world, 120, 80, 111); + FlatWorldTestBuilder.SetSolid(world, 120, 81, 111); + + var segment = new PathSegment + { + Start = new Location(120.5, 80, 110.5), + End = new Location(122.5, 80, 110.5), + MoveType = MoveType.Parkour, + ExitTransition = PathTransitionType.LandingRecovery + }; + var next = new PathSegment + { + Start = new Location(122.5, 80, 110.5), + End = new Location(122.5, 80, 111.5), + MoveType = MoveType.Traverse, + ExitTransition = PathTransitionType.FinalStop + }; + + var template = new SprintJumpTemplate(segment, next); + var physics = TemplateSimulationRunner.CreateGroundedPhysics(segment.Start, yaw: 270f); + + TemplateState state = TemplateSimulationRunner.Run(template, physics, world, maxTicks: 140, out Location finalPos); + + Assert.True(state == TemplateState.Complete, $"state={state} finalPos={finalPos} vel={physics.DeltaMovement}"); + Assert.True(TemplateFootingHelper.IsFootprintInsideTargetBlock(finalPos, segment.End), $"finalPos={finalPos} vel={physics.DeltaMovement}"); + } +} diff --git a/MinecraftClient.Tests/Pathing/Execution/TransitionBrakingPlannerTests.cs b/MinecraftClient.Tests/Pathing/Execution/TransitionBrakingPlannerTests.cs index 262c2c17..6d22b78e 100644 --- a/MinecraftClient.Tests/Pathing/Execution/TransitionBrakingPlannerTests.cs +++ b/MinecraftClient.Tests/Pathing/Execution/TransitionBrakingPlannerTests.cs @@ -96,6 +96,39 @@ public sealed class TransitionBrakingPlannerTests Assert.True(release); } + [Fact] + public void Plan_BackBrakes_ForLandingRecovery_WhenNextSegmentTurns() + { + World world = FlatWorldTestBuilder.CreateStoneFloor(min: 108, max: 126); + FlatWorldTestBuilder.ClearBox(world, 118, 79, 108, 126, 90, 112); + FlatWorldTestBuilder.SetSolid(world, 120, 79, 110); + FlatWorldTestBuilder.SetSolid(world, 122, 79, 110); + FlatWorldTestBuilder.SetSolid(world, 122, 79, 111); + + var physics = CreatePhysics(0.118, 0.018, onGround: true); + var current = new PathSegment + { + Start = new Location(120.5, 80, 110.5), + End = new Location(122.5, 80, 110.5), + MoveType = MoveType.Parkour, + ExitTransition = PathTransitionType.LandingRecovery, + PreserveSprint = false + }; + var next = new PathSegment + { + Start = new Location(122.5, 80, 110.5), + End = new Location(122.5, 80, 111.5), + MoveType = MoveType.Traverse, + ExitTransition = PathTransitionType.FinalStop + }; + + TransitionBrakingDecision decision = TransitionBrakingPlanner.Plan(current, next, new Location(122.56, 80, 110.68), physics, world); + + Assert.False(decision.HoldForward); + Assert.False(decision.HoldSprint); + Assert.True(decision.HoldBack); + } + private static PlayerPhysics CreatePhysics(double deltaX, double deltaZ, bool onGround) { return new PlayerPhysics diff --git a/MinecraftClient/Pathing/Execution/TransitionBrakingPlanner.cs b/MinecraftClient/Pathing/Execution/TransitionBrakingPlanner.cs index 1899d389..89cab765 100644 --- a/MinecraftClient/Pathing/Execution/TransitionBrakingPlanner.cs +++ b/MinecraftClient/Pathing/Execution/TransitionBrakingPlanner.cs @@ -22,6 +22,9 @@ namespace MinecraftClient.Pathing.Execution double forwardSpeed = Math.Max(0.0, ProjectHorizontalSpeedAlongHeading(physics, current.HeadingX, current.HeadingZ)); double coastStopDistance = EstimateGroundStopDistance(physics, world, current.HeadingX, current.HeadingZ, applyBackBrake: false); double hardBrakeDistance = EstimateGroundStopDistance(physics, world, current.HeadingX, current.HeadingZ, applyBackBrake: true); + bool landingNeedsTurnBrake = current.ExitTransition == PathTransitionType.LandingRecovery + && next is not null + && !HasSameHeading(current, next); if (current.ExitTransition == PathTransitionType.FinalStop) { @@ -35,7 +38,8 @@ namespace MinecraftClient.Pathing.Execution return TransitionBrakingDecision.CarryMomentum(preserveSprint: false); } - if (current.ExitTransition == PathTransitionType.Turn && remaining <= hardBrakeDistance + TurnBrakeLead) + if ((current.ExitTransition == PathTransitionType.Turn || landingNeedsTurnBrake) + && remaining <= hardBrakeDistance + TurnBrakeLead) { return TransitionBrakingDecision.Brake; } @@ -103,5 +107,10 @@ namespace MinecraftClient.Pathing.Execution { return physics.DeltaMovement.X * headingX + physics.DeltaMovement.Z * headingZ; } + + private static bool HasSameHeading(PathSegment current, PathSegment next) + { + return current.HeadingX == next.HeadingX && current.HeadingZ == next.HeadingZ; + } } } From 33ff02042c4e8e230bdfb778f1e708801565b8b9 Mon Sep 17 00:00:00 2001 From: BruceChen Date: Sun, 12 Apr 2026 23:42:35 +0800 Subject: [PATCH 24/37] test: add corner ascend live smoke --- tools/test-pathing-template-regressions.sh | 23 ++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/tools/test-pathing-template-regressions.sh b/tools/test-pathing-template-regressions.sh index c211622f..47ee0c19 100644 --- a/tools/test-pathing-template-regressions.sh +++ b/tools/test-pathing-template-regressions.sh @@ -277,6 +277,28 @@ PY print_summary "3x1 no-run-up rejection" } +run_corner_ascend_around_wall() { + echo "== Corner ascend around wall smoke ==" + mc-rcon "fill 188 79 168 194 84 174 air" >/dev/null + mc-rcon "setblock 190 79 170 stone" >/dev/null + mc-rcon "setblock 191 80 171 stone" >/dev/null + mc-rcon "setblock 191 80 170 stone" >/dev/null + mc-rcon "setblock 191 81 170 stone" >/dev/null + mc-rcon "tp CursorBot 190.5 80 170.5" >/dev/null + sleep 2 + + local start_line + start_line="$(log_line_count)" + send_mcc "pathfind 191 81 171" + wait_for_navigation "$start_line" 25 + + local x y z + read -r x y z <<< "$(extract_last_location "$start_line")" + echo " Final location: $x $y $z" + assert_close "$x" "$y" "$z" "191.50" "81.00" "171.50" "0.25" + print_summary "Corner ascend around wall" +} + run_mixed_ascend_descend_climb() { echo "== Mixed ascend/descend/climb smoke ==" mc-rcon "fill 170 79 160 178 79 168 stone" >/dev/null @@ -318,6 +340,7 @@ run_flat_final_stop run_parkour_into_turn run_side_wall_jump run_reject_3x1_gap +run_corner_ascend_around_wall run_mixed_ascend_descend_climb echo "" From 6e4cf4a10e0f0532841249d18e47d3037fd0271c Mon Sep 17 00:00:00 2001 From: BruceChen Date: Mon, 13 Apr 2026 00:11:12 +0800 Subject: [PATCH 25/37] fix: stabilize descend landings after braking --- .../GroundedTemplateConvergenceTests.cs | 54 +++++++++++++++++++ .../Execution/Templates/DescendTemplate.cs | 50 +++++++++++++++-- 2 files changed, 99 insertions(+), 5 deletions(-) diff --git a/MinecraftClient.Tests/Pathing/Execution/GroundedTemplateConvergenceTests.cs b/MinecraftClient.Tests/Pathing/Execution/GroundedTemplateConvergenceTests.cs index f56e9fc3..24f9cade 100644 --- a/MinecraftClient.Tests/Pathing/Execution/GroundedTemplateConvergenceTests.cs +++ b/MinecraftClient.Tests/Pathing/Execution/GroundedTemplateConvergenceTests.cs @@ -2,6 +2,7 @@ using MinecraftClient.Mapping; using MinecraftClient.Pathing.Core; using MinecraftClient.Pathing.Execution; using MinecraftClient.Pathing.Execution.Templates; +using MinecraftClient.Physics; using Xunit; namespace MinecraftClient.Tests.Pathing.Execution; @@ -81,4 +82,57 @@ public sealed class GroundedTemplateConvergenceTests Assert.True(state == TemplateState.Complete, $"state={state} finalPos={finalPos} vel={physics.DeltaMovement}"); Assert.True(TemplateFootingHelper.IsFootprintInsideTargetBlock(finalPos, segment.End)); } + + [Fact] + public void DescendTemplate_FinalStop_WithWallAndMisalignedYaw_CompletesOnLandingBlock() + { + World world = FlatWorldTestBuilder.CreateStoneFloor(min: 198, max: 204); + FlatWorldTestBuilder.ClearBox(world, 198, 79, 198, 204, 84, 202); + FlatWorldTestBuilder.FillSolid(world, 201, 79, 199, 203, 79, 201); + FlatWorldTestBuilder.SetSolid(world, 200, 80, 200); + FlatWorldTestBuilder.SetSolid(world, 200, 80, 199); + FlatWorldTestBuilder.SetSolid(world, 201, 80, 199); + FlatWorldTestBuilder.SetSolid(world, 202, 80, 199); + FlatWorldTestBuilder.SetSolid(world, 201, 81, 199); + FlatWorldTestBuilder.SetSolid(world, 202, 81, 199); + + var segment = new PathSegment + { + Start = new Location(200.5, 81, 200.5), + End = new Location(201.5, 80, 200.5), + MoveType = MoveType.Descend, + ExitTransition = PathTransitionType.FinalStop + }; + + var template = new DescendTemplate(segment, null); + var physics = TemplateSimulationRunner.CreateGroundedPhysics(segment.Start, yaw: 0f); + + var input = new MovementInput(); + var trace = new List(); + TemplateState state = TemplateState.InProgress; + Location finalPos = segment.Start; + for (int tick = 0; tick < 240; tick++) + { + input.Reset(); + Location pos = new(physics.Position.X, physics.Position.Y, physics.Position.Z); + state = template.Tick(pos, physics, input, world); + if (tick < 20 || state != TemplateState.InProgress || !physics.OnGround) + { + trace.Add($"tick={tick} state={state} pos={pos} vel={physics.DeltaMovement} onGround={physics.OnGround} input(F={input.Forward},B={input.Back},S={input.Sprint})"); + } + + if (state != TemplateState.InProgress) + { + finalPos = new Location(physics.Position.X, physics.Position.Y, physics.Position.Z); + break; + } + + physics.ApplyInput(input); + physics.Tick(world); + finalPos = new Location(physics.Position.X, physics.Position.Y, physics.Position.Z); + } + + Assert.True(state == TemplateState.Complete, $"state={state} finalPos={finalPos} vel={physics.DeltaMovement}\n{string.Join('\n', trace)}"); + Assert.True(TemplateFootingHelper.IsFootprintInsideTargetBlock(finalPos, segment.End)); + } } diff --git a/MinecraftClient/Pathing/Execution/Templates/DescendTemplate.cs b/MinecraftClient/Pathing/Execution/Templates/DescendTemplate.cs index e88e91be..5337decc 100644 --- a/MinecraftClient/Pathing/Execution/Templates/DescendTemplate.cs +++ b/MinecraftClient/Pathing/Execution/Templates/DescendTemplate.cs @@ -1,5 +1,6 @@ using System; using MinecraftClient.Mapping; +using MinecraftClient.Pathing.Execution; using MinecraftClient.Physics; namespace MinecraftClient.Pathing.Execution.Templates @@ -12,6 +13,8 @@ namespace MinecraftClient.Pathing.Execution.Templates /// public sealed class DescendTemplate : IActionTemplate { + private const float PreDropYawToleranceDeg = 12f; + public Location ExpectedStart { get; } public Location ExpectedEnd { get; } @@ -61,10 +64,14 @@ namespace MinecraftClient.Pathing.Execution.Templates if (physics.OnGround && Math.Abs(dy) < (_hasFallen ? 1.0 : 0.6)) { - if (horizDistSq > 0.01) + TransitionBrakingDecision decision = TransitionBrakingPlanner.Plan(_segment, _nextSegment, pos, physics, world); + if (horizDistSq > 0.01 && !decision.HoldBack) physics.Yaw = TemplateHelper.SmoothYaw(physics.Yaw, targetYaw); - GroundedSegmentController.Apply(_segment, _nextSegment, pos, physics, input, world); + TemplateHelper.ApplyDecision(input, decision); + if (decision.HoldBack) + TemplateHelper.FaceSegmentHeading(physics, _segment); + if (GroundedSegmentController.ShouldComplete(_segment, pos, physics)) return TemplateState.Complete; } @@ -79,12 +86,45 @@ namespace MinecraftClient.Pathing.Execution.Templates else if (horizDistSq > 0.01) { physics.Yaw = TemplateHelper.SmoothYaw(physics.Yaw, targetYaw); - input.Forward = true; - if (_needsSprint) - input.Sprint = true; + if (_hasFallen || YawDifference(physics.Yaw, targetYaw) <= PreDropYawToleranceDeg) + { + if (!_hasFallen && !_needsSprint && ShouldCoastOffLedge(pos)) + { + // For short descends into a stop or turn, release forward near the lip + // so the landing stays on the intended support instead of overshooting it. + } + else if (!_hasFallen && !_needsSprint) + { + GroundedSegmentController.Apply(_segment, _nextSegment, pos, physics, input, world); + } + else + { + input.Forward = true; + if (_needsSprint) + input.Sprint = true; + } + } } return TemplateState.InProgress; } + + private bool ShouldCoastOffLedge(Location pos) + { + if (_segment.ExitTransition == PathTransitionType.ContinueStraight) + return false; + + double remaining = (_segment.End.X - pos.X) * _segment.HeadingX + + (_segment.End.Z - pos.Z) * _segment.HeadingZ; + return remaining <= 0.55; + } + + private static float YawDifference(float current, float target) + { + float delta = target - current; + while (delta > 180f) delta -= 360f; + while (delta < -180f) delta += 360f; + return Math.Abs(delta); + } } } From 14c5bc7f77824efd55f92166ae4f236990822d28 Mon Sep 17 00:00:00 2001 From: BruceChen Date: Mon, 13 Apr 2026 00:11:21 +0800 Subject: [PATCH 26/37] test: add descend live regression smoke --- tools/test-pathing-template-regressions.sh | 36 +++++++++++++++++++--- 1 file changed, 31 insertions(+), 5 deletions(-) diff --git a/tools/test-pathing-template-regressions.sh b/tools/test-pathing-template-regressions.sh index 47ee0c19..ac878271 100644 --- a/tools/test-pathing-template-regressions.sh +++ b/tools/test-pathing-template-regressions.sh @@ -299,8 +299,33 @@ run_corner_ascend_around_wall() { print_summary "Corner ascend around wall" } -run_mixed_ascend_descend_climb() { - echo "== Mixed ascend/descend/climb smoke ==" +run_wall_adjacent_descend_smoke() { + echo "== Wall-adjacent descend smoke ==" + mc-rcon "fill 198 79 198 204 84 202 air" >/dev/null + mc-rcon "fill 201 79 199 203 79 201 stone" >/dev/null + mc-rcon "setblock 200 80 200 stone" >/dev/null + mc-rcon "setblock 200 80 199 stone" >/dev/null + mc-rcon "setblock 201 80 199 stone" >/dev/null + mc-rcon "setblock 202 80 199 stone" >/dev/null + mc-rcon "setblock 201 81 199 stone" >/dev/null + mc-rcon "setblock 202 81 199 stone" >/dev/null + mc-rcon "tp CursorBot 200.5 81 200.5" >/dev/null + sleep 2 + + local start_line + start_line="$(log_line_count)" + send_mcc "pathfind 201 80 200" + wait_for_navigation "$start_line" 25 + + local x y z + read -r x y z <<< "$(extract_last_location "$start_line")" + echo " Final location: $x $y $z" + assert_close "$x" "$y" "$z" "201.50" "80.00" "200.50" "0.25" + print_summary "Wall-adjacent descend" +} + +run_ascend_chain_smoke() { + echo "== Ascend chain smoke ==" mc-rcon "fill 170 79 160 178 79 168 stone" >/dev/null mc-rcon "fill 170 80 160 178 85 168 air" >/dev/null mc-rcon "setblock 175 80 162 stone" >/dev/null @@ -320,8 +345,8 @@ run_mixed_ascend_descend_climb() { send_mcc "pathfind 182 83 162" wait_for_navigation "$start_line" 35 - echo " Mixed route completed (review log for ascend/descend/climb segments)." - print_summary "Ascend/Descend/Climb smoke" + echo " Ascend chain completed." + print_summary "Ascend chain smoke" } mcc-preflight "$VERSION" >/dev/null @@ -341,7 +366,8 @@ run_parkour_into_turn run_side_wall_jump run_reject_3x1_gap run_corner_ascend_around_wall -run_mixed_ascend_descend_climb +run_wall_adjacent_descend_smoke +run_ascend_chain_smoke echo "" echo "Pathing template regression suite complete." From a3822c7700f1a8681f52e9b901676a0fa4b3704e Mon Sep 17 00:00:00 2001 From: BruceChen Date: Sun, 12 Apr 2026 18:40:45 +0000 Subject: [PATCH 27/37] feat: add VSCode tasks for building, publishing, and watching MinecraftClient project --- .vscode/tasks.json | 41 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 41 insertions(+) create mode 100644 .vscode/tasks.json diff --git a/.vscode/tasks.json b/.vscode/tasks.json new file mode 100644 index 00000000..77fe4fa1 --- /dev/null +++ b/.vscode/tasks.json @@ -0,0 +1,41 @@ +{ + "version": "2.0.0", + "tasks": [ + { + "label": "build", + "command": "dotnet", + "type": "process", + "args": [ + "build", + "${workspaceFolder}/MinecraftClient.sln", + "/property:GenerateFullPaths=true", + "/consoleloggerparameters:NoSummary;ForceNoAlign" + ], + "problemMatcher": "$msCompile" + }, + { + "label": "publish", + "command": "dotnet", + "type": "process", + "args": [ + "publish", + "${workspaceFolder}/MinecraftClient.sln", + "/property:GenerateFullPaths=true", + "/consoleloggerparameters:NoSummary;ForceNoAlign" + ], + "problemMatcher": "$msCompile" + }, + { + "label": "watch", + "command": "dotnet", + "type": "process", + "args": [ + "watch", + "run", + "--project", + "${workspaceFolder}/MinecraftClient.sln" + ], + "problemMatcher": "$msCompile" + } + ] +} \ No newline at end of file From 594e467da6f9a970f558bf6bf9dc62cb508de2bd Mon Sep 17 00:00:00 2001 From: BruceChen Date: Sun, 12 Apr 2026 18:40:52 +0000 Subject: [PATCH 28/37] docs: add difficulty setting for AI-driven offline testing --- docs/guide/ai-assisted-development.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/guide/ai-assisted-development.md b/docs/guide/ai-assisted-development.md index 11686d64..17024496 100644 --- a/docs/guide/ai-assisted-development.md +++ b/docs/guide/ai-assisted-development.md @@ -646,6 +646,7 @@ Server settings that matter for AI-driven offline testing: - `eula=true` - `online-mode=false` +- `difficulty=peaceful` - `enforce-secure-profile=false` - `enable-rcon=true` - `rcon.password=test123` From 8170e11e1466e01e6c74630300841c651c97e17a Mon Sep 17 00:00:00 2001 From: BruceChen Date: Sun, 12 Apr 2026 18:41:24 +0000 Subject: [PATCH 29/37] feat: add Minecraft Jump Reachability Simulator for analyzing player jump physics --- tools/sim_jump_reach.py | 473 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 473 insertions(+) create mode 100644 tools/sim_jump_reach.py diff --git a/tools/sim_jump_reach.py b/tools/sim_jump_reach.py new file mode 100644 index 00000000..a4c94bed --- /dev/null +++ b/tools/sim_jump_reach.py @@ -0,0 +1,473 @@ +#!/usr/bin/env python3 +""" +Minecraft Jump Reachability Simulator (Java Edition 1.14+) + +Simulates vanilla player physics tick-by-tick to determine which jump +destinations are reachable. Covers: + - Linear jumps: flat, ascending (+N), descending (-N) + - Sprint jumps vs walk jumps + - Neo jumps (wall jumps): 1-block and 2-block wide walls + - Headhitter (2bc ceiling) jumps + +All physics constants match vanilla 1.21.x / MCC's PhysicsConsts.cs. + +Usage: + python3 sim_jump_reach.py [--verbose] [--csv output.csv] +""" + +import argparse +import math +import csv +from dataclasses import dataclass +from typing import Optional + +# ============================================================ +# Vanilla physics constants (match PhysicsConsts.cs) +# ============================================================ + +PLAYER_WIDTH = 0.6 +PLAYER_HEIGHT = 1.8 +STEP_HEIGHT = 0.6 + +GRAVITY = 0.08 +DRAG_Y = 0.98 +FRICTION_MULTIPLIER = 0.91 +DEFAULT_BLOCK_FRICTION = 0.6 +INPUT_FRICTION = 0.98 +GROUND_ACCEL_FACTOR = 0.21600002 +AIR_ACCEL = 0.02 +MOVEMENT_SPEED = 0.1 + +BASE_JUMP_POWER = 0.42 +SPRINT_JUMP_HORIZONTAL_BOOST = 0.2 + +HORIZONTAL_VELOCITY_THRESHOLD_SQR = 9.0e-6 +VERTICAL_VELOCITY_THRESHOLD = 0.003 + +HALF_WIDTH = PLAYER_WIDTH / 2.0 # 0.3 + + +@dataclass +class TickState: + tick: int = 0 + x: float = 0.0 + y: float = 0.0 + vx: float = 0.0 + vy: float = 0.0 + on_ground: bool = True + + +def get_ground_speed(block_friction: float = DEFAULT_BLOCK_FRICTION) -> float: + f = block_friction * FRICTION_MULTIPLIER + return MOVEMENT_SPEED * (GROUND_ACCEL_FACTOR / (f * f * f)) + + +def simulate_jump(sprint: bool = True, momentum_ticks: int = 12, + ceiling_y: Optional[float] = None, + landing_y: float = 0.0, + landing_x_start: float = 0.0, + max_ticks: int = 200) -> list[TickState]: + """ + Simulate a complete jump sequence: momentum phase on ground, then jump. + + The player starts at x=0, y=0 on a platform at y=0. + + landing_y: Y coordinate of the landing surface. + landing_x_start: the X coordinate where the landing surface begins. + For flat jumps (landing_y=0), this is 0 (same level everywhere). + For ascending jumps (landing_y>0), this is typically gap_start + (the landing platform isn't under the player at takeoff). + For descending jumps (landing_y<0), this is gap_start. + + The starting platform is at y=0 from x=-inf to x=landing_x_start. + The landing platform is at y=landing_y from x=landing_x_start onward. + """ + x, y, vx, vy = 0.0, 0.0, 0.0, 0.0 + on_ground = True + trajectory: list[TickState] = [] + jumped = False + f_ground = DEFAULT_BLOCK_FRICTION * FRICTION_MULTIPLIER + + trajectory.append(TickState(0, x, y, vx, vy, on_ground)) + + for tick in range(1, max_ticks + 1): + # --- Zero tiny velocity --- + if vx * vx < HORIZONTAL_VELOCITY_THRESHOLD_SQR: + vx = 0.0 + if abs(vy) < VERTICAL_VELOCITY_THRESHOLD: + vy = 0.0 + + # --- Jump on the tick after momentum --- + do_jump = False + if not jumped and tick > momentum_ticks and on_ground: + do_jump = True + jumped = True + + if do_jump: + vy = max(BASE_JUMP_POWER, vy) + if sprint: + vx += SPRINT_JUMP_HORIZONTAL_BOOST + + # --- Input acceleration --- + forward_input = 1.0 * INPUT_FRICTION + if on_ground: + speed = get_ground_speed() + else: + speed = AIR_ACCEL + vx += forward_input * speed + + # --- Move --- + new_x = x + vx + new_y = y + vy + new_on_ground = False + + # Ceiling collision + if ceiling_y is not None: + head_y = new_y + PLAYER_HEIGHT + if head_y > ceiling_y: + new_y = ceiling_y - PLAYER_HEIGHT + if vy > 0: + vy = 0.0 + + # Floor collision: two-region terrain model + # Region 1: x < landing_x_start -> floor at y=0 (starting platform) + # Region 2: x >= landing_x_start -> floor at y=landing_y + # Player bounding box trailing edge is at (new_x - HALF_WIDTH) + # Use player center for region determination + if new_x < landing_x_start: + floor_y = 0.0 + else: + floor_y = landing_y + + if jumped: + if new_x >= landing_x_start: + # Over the landing platform region + if landing_y >= 0: + # Ascending or flat: only land when falling DOWN through the surface + if vy <= 0 and y >= landing_y and new_y <= landing_y: + new_y = landing_y + vy = 0.0 + new_on_ground = True + elif vy <= 0 and new_y <= landing_y: + # Already below the surface (fell through on a prior tick + # that didn't trigger -- shouldn't happen but safety check) + new_y = landing_y + vy = 0.0 + new_on_ground = True + else: + # Descending: land when reaching the lower floor + if new_y <= landing_y: + new_y = landing_y + if vy < 0: + vy = 0.0 + new_on_ground = True + + if not new_on_ground and new_x < landing_x_start: + # Still over starting platform area or in the gap + if new_y <= 0.0: + new_y = 0.0 + if vy < 0: + vy = 0.0 + new_on_ground = True + else: + # Momentum phase: always on starting platform + if new_y <= 0.0: + new_y = 0.0 + if vy < 0: + vy = 0.0 + new_on_ground = True + + x = new_x + y = new_y + on_ground = new_on_ground + + # --- Post-move: gravity + friction/drag --- + vy -= GRAVITY + vy *= DRAG_Y + + if on_ground: + vx *= f_ground + else: + vx *= FRICTION_MULTIPLIER + + trajectory.append(TickState(tick, x, y, vx, vy, on_ground)) + + # Stop once landed after being airborne + if jumped and on_ground: + break + + return trajectory + + +def get_landing(sprint: bool, target_y: float, + landing_x_start: float = 0.0, + momentum_ticks: int = 12, + ceiling_y: Optional[float] = None) -> Optional[tuple[float, float]]: + """Get (x, y) where the player lands. Returns None if no landing.""" + traj = simulate_jump(sprint=sprint, momentum_ticks=momentum_ticks, + ceiling_y=ceiling_y, landing_y=target_y, + landing_x_start=landing_x_start) + was_air = False + for s in traj: + if not s.on_ground: + was_air = True + if was_air and s.on_ground: + return s.x, s.y + return None + + +def get_apex(sprint: bool, momentum_ticks: int = 12, + ceiling_y: Optional[float] = None) -> tuple[float, float]: + traj = simulate_jump(sprint=sprint, momentum_ticks=momentum_ticks, + ceiling_y=ceiling_y, landing_y=-1000.0, + landing_x_start=0.0, max_ticks=300) + best_y, best_x = 0.0, 0.0 + for s in traj: + if s.y > best_y: + best_y = s.y + best_x = s.x + return best_y, best_x + + +def can_reach_gap(gap_blocks: int, dy: float, sprint: bool = True, + momentum_ticks: int = 12) -> tuple[bool, Optional[float], float]: + """ + Check if the player can cross a gap of `gap_blocks` blocks to a surface + at height offset `dy`. + + Geometry (player starts centered on block, center at x=0): + - Starting platform right edge: x = 0.5 + - Gap: 0.5 to 0.5 + gap_blocks + - Landing platform left edge: x = 0.5 + gap_blocks + - Player center must reach x >= 0.5 + gap_blocks + HALF_WIDTH to land + (trailing bounding box edge clears the gap) + + For ascending jumps (dy > 0): + - Landing surface at y=dy begins at x = 0.5 + gap_blocks + - The gap region has NO floor (void) if gap > 0, or floor at dy if gap = 0 + + For gap = 0 and dy > 0: + - This means stepping up to an adjacent block 1m higher. + - Player just needs to jump and move forward 1 block. + """ + if dy > 1.252: + return False, None, 0.0 + + needed_x = 0.5 + gap_blocks + HALF_WIDTH + landing_platform_start = 0.5 + gap_blocks + + # For gap=0 ascending, the landing platform is right next to the start + if gap_blocks == 0 and dy > 0: + landing_platform_start = 0.5 + + result = get_landing(sprint=sprint, target_y=dy, + landing_x_start=landing_platform_start, + momentum_ticks=momentum_ticks) + if result is None: + return False, None, needed_x + + lx, ly = result + # Check if we actually landed on the target surface (not back on start) + if abs(ly - dy) > 0.01: + # Landed back on starting platform + return False, lx, needed_x + + # For gap > 0, check player center is past the gap + if gap_blocks > 0 and lx < needed_x: + return False, lx, needed_x + + return True, lx, needed_x + + +# ============================================================ +# Main analysis +# ============================================================ + +def analyze_all(verbose: bool = False) -> list[dict]: + results = [] + + print("=" * 78) + print(" Minecraft Jump Reachability Analysis (Java 1.14+)") + print(" Physics: vanilla 1.21.x constants from PhysicsConsts.cs") + print("=" * 78) + + # --- Part 1: Apex --- + print("\n[1] Jump Apex (Maximum Height)") + print(f" {'Mode':<8} {'Momentum':>8} {'Apex Y':>10} {'X at Apex':>12}") + print(f" {'----':<8} {'--------':>8} {'------':>10} {'---------':>12}") + for sprint in [False, True]: + for mm in [0, 6, 12, 20]: + ay, ax = get_apex(sprint=sprint, momentum_ticks=mm) + label = "Sprint" if sprint else "Walk" + print(f" {label:<8} {mm:>6}t {ay:>10.4f} {ax:>12.4f}") + results.append({'type': 'apex', 'sprint': sprint, + 'momentum': mm, 'apex_y': ay, 'x_at_apex': ax}) + + # --- Part 2: Landing distances (flat and descending) --- + print(f"\n[2] Landing Distance (sprint, 12t momentum)") + print(f" {'dy':>6} {'Landing X':>12}") + print(f" {'--':>6} {'---------':>12}") + for dy in [0.0, -1.0, -2.0, -3.0, -5.0, -10.0]: + r = get_landing(sprint=True, target_y=dy, + landing_x_start=0.0 if dy <= 0 else 0.5, + momentum_ticks=12) + sign = "+" if dy > 0 else " " if dy == 0 else "" + if r: + print(f" {sign}{dy:>5.1f} {r[0]:>12.4f}m") + else: + print(f" {sign}{dy:>5.1f} {'N/A':>12}") + + # --- Part 3: Full feasibility matrix --- + print(f"\n[3] Gap Feasibility Matrix (Sprint, 12t momentum)") + print(f" Player width={PLAYER_WIDTH}m, max jump height=~1.252b") + print() + + dy_values = [1.0, 0.5, 0.0, -1.0, -2.0, -3.0, -5.0] + header = f" {'Gap':>4}" + for dy in dy_values: + sign = "+" if dy > 0 else "" + header += f" {sign}{dy:>5.1f}" + print(header) + print(f" {'----':>4}" + " ------" * len(dy_values)) + + for gap in range(0, 7): + row = f" {gap:>4}" + for dy in dy_values: + ok, lx, needed = can_reach_gap(gap, dy, sprint=True, momentum_ticks=12) + if ok: + row += f" {'YES':>6}" + elif lx is None: + row += f" {'N/A':>6}" + else: + row += f" {'no':>6}" + print(row) + + # Walk version + print(f"\n Walk jump (no sprint), 12t momentum:") + header = f" {'Gap':>4}" + for dy in dy_values: + sign = "+" if dy > 0 else "" + header += f" {sign}{dy:>5.1f}" + print(header) + print(f" {'----':>4}" + " ------" * len(dy_values)) + + for gap in range(0, 6): + row = f" {gap:>4}" + for dy in dy_values: + ok, lx, needed = can_reach_gap(gap, dy, sprint=False, momentum_ticks=12) + if ok: + row += f" {'YES':>6}" + elif lx is None: + row += f" {'N/A':>6}" + else: + row += f" {'no':>6}" + print(row) + + # Standing jump (0 momentum) + print(f"\n Standing sprint jump (0t momentum):") + header = f" {'Gap':>4}" + for dy in dy_values: + sign = "+" if dy > 0 else "" + header += f" {sign}{dy:>5.1f}" + print(header) + print(f" {'----':>4}" + " ------" * len(dy_values)) + + for gap in range(0, 5): + row = f" {gap:>4}" + for dy in dy_values: + ok, lx, needed = can_reach_gap(gap, dy, sprint=True, momentum_ticks=0) + if ok: + row += f" {'YES':>6}" + elif lx is None: + row += f" {'N/A':>6}" + else: + row += f" {'no':>6}" + print(row) + + # --- Part 4: Neo analysis --- + print(f"\n[4] Neo Jump Analysis (flat, 12t momentum)") + print(f" Wall extends perpendicular to movement.") + print(f" Player must travel wall_length + {PLAYER_WIDTH}m to clear wall end.\n") + print(f" {'Wall':>5} {'Mode':<8} {'LandingX':>10} {'Needed':>10} {'Margin':>10} {'OK':>6}") + print(f" {'----':>5} {'----':<8} {'--------':>10} {'------':>10} {'------':>10} {'--':>6}") + + for wall_len in [1, 2, 3, 4]: + for sprint in [True, False]: + r = get_landing(sprint=sprint, target_y=0.0, + landing_x_start=0.0, momentum_ticks=12) + label = "Sprint" if sprint else "Walk" + if r is None: + print(f" {wall_len:>5} {label:<8} {'N/A':>10}") + continue + lx = r[0] + needed = wall_len + PLAYER_WIDTH + margin = lx - needed + ok = "YES" if margin >= 0 else "no" + print(f" {wall_len:>5} {label:<8} {lx:>10.4f} {needed:>10.4f} " + f"{margin:>+10.4f} {ok:>6}") + results.append({'type': 'neo', 'wall': wall_len, 'sprint': sprint, + 'reach': lx, 'needed': needed, 'margin': margin, + 'ok': margin >= 0}) + + # --- Part 5: Ceiling --- + print(f"\n[5] Ceiling-Constrained Jumps (Sprint, 12t mm, flat)") + base_r = get_landing(sprint=True, target_y=0.0, momentum_ticks=12) + base_lx = base_r[0] if base_r else 0 + print(f" {'Ceiling':>8} {'LandingX':>12} {'Delta':>10}") + for ceil in [4.0, 3.0, 2.5, 2.0, 1.8125]: + r = get_landing(sprint=True, target_y=0.0, momentum_ticks=12, + ceiling_y=ceil) + if r: + diff = r[0] - base_lx + print(f" {ceil:>7.4f}b {r[0]:>11.4f}m {diff:>+10.4f}") + else: + print(f" {ceil:>7.4f}b {'N/A':>12}") + + # --- Part 6: Verbose --- + if verbose: + for label, sp in [("Sprint", True), ("Walk", False)]: + print(f"\n[V] {label} Jump Trajectory (12t momentum, flat)") + print(f" {'Tick':>4} {'X':>10} {'Y':>10} {'VX':>10} {'VY':>10} {'Gnd':>5}") + traj = simulate_jump(sprint=sp, momentum_ticks=12, landing_y=0.0) + for s in traj: + g = "G" if s.on_ground else "" + print(f" {s.tick:>4} {s.x:>10.4f} {s.y:>10.4f} " + f"{s.vx:>10.6f} {s.vy:>10.6f} {g:>5}") + + # +1 ascending sprint jump + print(f"\n[V] Sprint +1 Ascending Trajectory (12t mm, gap=1)") + print(f" {'Tick':>4} {'X':>10} {'Y':>10} {'VX':>10} {'VY':>10} {'Gnd':>5}") + traj = simulate_jump(sprint=True, momentum_ticks=12, + landing_y=1.0, landing_x_start=1.5) + for s in traj: + g = "G" if s.on_ground else "" + print(f" {s.tick:>4} {s.x:>10.4f} {s.y:>10.4f} " + f"{s.vx:>10.6f} {s.vy:>10.6f} {g:>5}") + + return results + + +def main(): + parser = argparse.ArgumentParser( + description="Minecraft jump reachability simulator (Java 1.14+)") + parser.add_argument("--verbose", "-v", action="store_true", + help="Print per-tick trajectory data") + parser.add_argument("--csv", type=str, default=None, + help="Export results to CSV file") + args = parser.parse_args() + + results = analyze_all(verbose=args.verbose) + + if args.csv and results: + keys = set() + for r in results: + keys.update(r.keys()) + with open(args.csv, "w", newline="") as f: + writer = csv.DictWriter(f, fieldnames=sorted(keys)) + writer.writeheader() + writer.writerows(results) + print(f"\nResults exported to {args.csv}") + + +if __name__ == "__main__": + main() From 52a2dbe31b2766bb689ec9f1bee6875324561baf Mon Sep 17 00:00:00 2001 From: BruceChen Date: Sun, 12 Apr 2026 18:41:33 +0000 Subject: [PATCH 30/37] feat: add automated parkour jump test and transition braking validation scripts --- tools/test-parkour.sh | 110 +++++++++++++++++ tools/test-transition-braking.sh | 200 +++++++++++++++++++++++++++++++ 2 files changed, 310 insertions(+) create mode 100644 tools/test-parkour.sh create mode 100644 tools/test-transition-braking.sh diff --git a/tools/test-parkour.sh b/tools/test-parkour.sh new file mode 100644 index 00000000..14b73396 --- /dev/null +++ b/tools/test-parkour.sh @@ -0,0 +1,110 @@ +#!/usr/bin/env bash +# Automated parkour jump test for MCC pathfinding +# Usage: source tools/mcc-env.sh && bash tools/test-parkour.sh +# +# Prerequisites: +# - MCC connected with FileInput mode +# - CursorBot is OP +# - Server at 1.21.11 + +set -euo pipefail +source "$(dirname "$0")/mcc-env.sh" + +LOG="/tmp/mcc-debug/mcc-debug.log" +RESULTS="" +TEST_NUM=0 + +run_test() { + local name="$1" + local start_x="$2" start_y="$3" start_z="$4" + local dest_x="$5" dest_y="$6" dest_z="$7" + + TEST_NUM=$((TEST_NUM + 1)) + echo "" + echo "=== TEST $TEST_NUM: $name ===" + echo " Start: ($start_x, $start_y, $start_z) -> Dest: ($dest_x, $dest_y, $dest_z)" + + # Respawn if dead, set creative, tp, then survival + mcc-cmd "respawn" 2>/dev/null + sleep 0.5 + mc-rcon "gamemode creative CursorBot" >/dev/null 2>&1 + sleep 0.3 + mc-rcon "tp CursorBot ${start_x}.5 ${start_y} ${start_z}.5" >/dev/null 2>&1 + sleep 2 + mc-rcon "gamemode survival CursorBot" >/dev/null 2>&1 + sleep 1 + + # Clear log + : > "$LOG" + sleep 0.5 + + # Execute pathfind + mcc-cmd "pathfind $dest_x $dest_y $dest_z" + sleep 8 + + # Analyze result + local a_star_result + a_star_result=$(grep -a '\[A\*\]' "$LOG" | head -3 | sed 's/\x1b\[[0-9;]*m//g') + + local path_exec + path_exec=$(grep -a '\[PathExec\]' "$LOG" | sed 's/\x1b\[[0-9;]*m//g') + + local path_mgr + path_mgr=$(grep -a '\[PathMgr\]' "$LOG" | sed 's/\x1b\[[0-9;]*m//g') + + local nav_segs + nav_segs=$(grep -a '\[Navigate\].*seg' "$LOG" | sed 's/\x1b\[[0-9;]*m//g') + + # Get final position + local physics_line + physics_line=$(grep -a '\[Physics\]' "$LOG" | tail -1 | sed 's/\x1b\[[0-9;]*m//g') + + # Check success/failure + local result="UNKNOWN" + if echo "$path_mgr" | grep -q "complete"; then + result="PASS" + elif echo "$path_mgr" | grep -q "Replan failed\|Giving up"; then + result="FAIL" + elif echo "$path_exec" | grep -q "FAILED"; then + result="FAIL" + elif echo "$a_star_result" | grep -q "Failed"; then + result="NO_PATH" + fi + + echo " A*: $a_star_result" + echo " Segments: $nav_segs" + echo " Exec: $(echo "$path_exec" | tail -3)" + echo " Manager: $(echo "$path_mgr" | tail -2)" + echo " Physics: $physics_line" + echo " RESULT: $result" + + RESULTS="${RESULTS}TEST $TEST_NUM ($name): $result\n" +} + +echo "========================================" +echo " MCC Parkour Jump Test Suite" +echo "========================================" + +# Flat gap tests (same Y level) +run_test "Gap 1 flat" 100 100 100 102 100 100 +run_test "Gap 2 flat" 100 100 102 103 100 102 +run_test "Gap 3 flat" 100 100 104 104 100 104 +run_test "Gap 4 flat" 100 100 106 105 100 106 + +# Ascend tests (+1Y) +run_test "Gap 1 up +1" 100 100 108 102 101 108 +run_test "Gap 2 up +1" 100 100 110 103 101 110 + +# Descend tests (-1Y) +run_test "Gap 1 down -1" 100 100 112 102 99 112 +run_test "Gap 2 down -1" 100 100 114 103 99 114 + +# Descend tests (-2Y) +run_test "Gap 1 down -2" 100 100 94 102 98 94 +run_test "Gap 2 down -2" 100 100 92 103 98 92 + +echo "" +echo "========================================" +echo " SUMMARY" +echo "========================================" +echo -e "$RESULTS" diff --git a/tools/test-transition-braking.sh b/tools/test-transition-braking.sh new file mode 100644 index 00000000..0fd2c155 --- /dev/null +++ b/tools/test-transition-braking.sh @@ -0,0 +1,200 @@ +#!/usr/bin/env bash +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" +source "$REPO_ROOT/tools/mcc-env.sh" + +VERSION="${1:-1.21.11}" +SESSION="mcc-brake-test" +TEST_ROOT="${TMPDIR:-/tmp}/mcc-debug" +CFG="$TEST_ROOT/MinecraftClient.transition-braking.ini" +LOG="$TEST_ROOT/mcc-transition-braking.log" +INPUT_FILE="$REPO_ROOT/mcc_input.txt" +PREPARE_CFG_SCRIPT="$REPO_ROOT/.skills/mcc-integration-testing/scripts/prepare_offline_mcc_config.sh" +ENSURE_SERVER_SCRIPT="$REPO_ROOT/.skills/mcc-integration-testing/scripts/ensure_offline_server.sh" + +mkdir -p "$TEST_ROOT" + +send_mcc() { + echo "$1" >> "$INPUT_FILE" +} + +log_line_count() { + if [[ -f "$LOG" ]]; then + wc -l < "$LOG" + else + echo 0 + fi +} + +log_since() { + local from_line="$1" + if [[ ! -f "$LOG" ]]; then + return + fi + + tail -n +"$((from_line + 1))" "$LOG" +} + +wait_for_log() { + local pattern="$1" + local from_line="${2:-0}" + local timeout="${3:-20}" + + for _ in $(seq 1 "$timeout"); do + if log_since "$from_line" | grep -Fq "$pattern"; then + return 0 + fi + sleep 1 + done + + return 1 +} + +wait_for_navigation() { + local from_line="$1" + local timeout="${2:-20}" + + for _ in $(seq 1 "$timeout"); do + local recent + recent="$(log_since "$from_line")" + + if grep -Fq "[PathMgr] Navigation complete!" <<<"$recent"; then + return 0 + fi + + if grep -Eq "\\[PathMgr\\] (Replan failed|Giving up)|\\[PathExec\\] Segment .* FAILED" <<<"$recent"; then + echo "$recent" >&2 + return 1 + fi + + sleep 1 + done + + echo "Timed out waiting for navigation completion" >&2 + log_since "$from_line" >&2 + return 1 +} + +extract_last_location() { + local from_line="${1:-0}" + + python3 - "$LOG" "$from_line" <<'PY' +import pathlib +import re +import sys + +log_path = pathlib.Path(sys.argv[1]) +from_line = int(sys.argv[2]) +text = log_path.read_text(errors="ignore") +text = "\n".join(text.splitlines()[from_line:]) +text = re.sub(r"\x1b\[[0-9;]*m", "", text) +matches = re.findall(r"Location\s+([-\d.]+),\s+([-\d.]+),\s+([-\d.]+)", text) +if not matches: + raise SystemExit("No Location line found in MCC log") +x, y, z = matches[-1] +print(f"{x} {y} {z}") +PY +} + +assert_close() { + local actual_x="$1" + local actual_y="$2" + local actual_z="$3" + local expected_x="$4" + local expected_y="$5" + local expected_z="$6" + local tolerance="${7:-0.05}" + + python3 - <<'PY' "$actual_x" "$actual_y" "$actual_z" "$expected_x" "$expected_y" "$expected_z" "$tolerance" +import math +import sys + +ax, ay, az, ex, ey, ez, tol = map(float, sys.argv[1:]) +if abs(ax - ex) > tol or abs(ay - ey) > tol or abs(az - ez) > tol: + raise SystemExit( + f"Expected ({ex:.2f}, {ey:.2f}, {ez:.2f}) within {tol:.2f}, got ({ax:.2f}, {ay:.2f}, {az:.2f})" + ) +PY +} + +capture_debug_location() { + local start_line + start_line="$(log_line_count)" + send_mcc "debug state" + wait_for_log "Location" "$start_line" 5 + extract_last_location "$start_line" +} + +start_mcc() { + bash "$PREPARE_CFG_SCRIPT" "$CFG" "$VERSION" CursorBot >/dev/null + + : > "$INPUT_FILE" + : > "$LOG" + + tmux kill-session -t "$SESSION" 2>/dev/null || true + tmux new-session -d -s "$SESSION" -x 160 -y 50 \ + "cd '$REPO_ROOT' && MCC_FILE_INPUT=1 dotnet run --project MinecraftClient -c Release --no-build -- '$CFG' CursorBot - localhost:25565 > '$LOG' 2>&1; echo '=== MCC EXITED ==='; sleep 600" + + wait_for_log "Server was successfully joined." 0 20 + send_mcc "debug on" + sleep 1 +} + +run_flat_final_stop() { + echo "== Flat final stop ==" + mc-rcon "fill 95 79 95 115 79 105 stone" >/dev/null + mc-rcon "fill 95 80 95 115 85 105 air" >/dev/null + mc-rcon "tp CursorBot 100.5 80 100.5" >/dev/null + sleep 2 + + local start_line + start_line="$(log_line_count)" + send_mcc "goto 103 80 100" + wait_for_navigation "$start_line" 20 + sleep 1 + + local x y z + read -r x y z <<< "$(capture_debug_location)" + echo "Final location: $x $y $z" + assert_close "$x" "$y" "$z" "103.50" "80.00" "100.50" +} + +run_parkour_into_turn() { + echo "== Parkour into turn ==" + mc-rcon "fill 118 79 108 126 79 112 air" >/dev/null + mc-rcon "setblock 120 79 110 stone" >/dev/null + mc-rcon "setblock 123 79 110 stone" >/dev/null + mc-rcon "setblock 123 79 111 stone" >/dev/null + mc-rcon "tp CursorBot 120.5 80 110.5" >/dev/null + sleep 2 + + local start_line + start_line="$(log_line_count)" + send_mcc "goto 123 80 111" + wait_for_navigation "$start_line" 20 + sleep 1 + + local x y z + read -r x y z <<< "$(capture_debug_location)" + echo "Final location: $x $y $z" + assert_close "$x" "$y" "$z" "123.50" "80.00" "111.50" +} + +mcc-preflight "$VERSION" >/dev/null +mc-reset-test-env "$VERSION" >/dev/null +bash "$ENSURE_SERVER_SCRIPT" "$VERSION" >/dev/null +mc-start "$VERSION" >/dev/null +mc-wait-ready "$VERSION" 60 >/dev/null +mcc-kill >/dev/null 2>&1 || true +start_mcc + +mc-rcon "difficulty peaceful" >/dev/null 2>&1 || true +mc-rcon "gamerule doMobSpawning false" >/dev/null 2>&1 || true +mc-rcon "time set day" >/dev/null 2>&1 || true + +run_flat_final_stop +run_parkour_into_turn + +echo "All transition braking checks passed." From a5f772c4d4448a340dde99502466f3b8a32e69e0 Mon Sep 17 00:00:00 2001 From: BruceChen Date: Sun, 12 Apr 2026 18:42:45 +0000 Subject: [PATCH 31/37] chore: update .gitignore to include third-party source code reference files --- .gitignore | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index b4423a67..4d27e6b0 100644 --- a/.gitignore +++ b/.gitignore @@ -445,4 +445,5 @@ server.pid # Crowdin translation automation working directory /.crowdin-translate/ -thirdparty/ \ No newline at end of file +# Third-party source code reference files +ThirdpartyReference/ From 0514fcb3274e476eb6fadab7dde7eea3ba46b8b7 Mon Sep 17 00:00:00 2001 From: BruceChen Date: Sun, 12 Apr 2026 18:43:01 +0000 Subject: [PATCH 32/37] feat: implement parkour admissibility hardening with new feasibility checks --- .../2026-04-12-parkour-admissibility-plan.md | 210 +++ ...-12-pathing-live-regression-convergence.md | 425 +++++ ...2026-04-12-pathing-template-convergence.md | 993 ++++++++++ .../2026-04-12-pathing-transition-braking.md | 1640 +++++++++++++++++ ...2026-04-12-parkour-admissibility-design.md | 38 + 5 files changed, 3306 insertions(+) create mode 100644 docs/superpowers/plans/2026-04-12-parkour-admissibility-plan.md create mode 100644 docs/superpowers/plans/2026-04-12-pathing-live-regression-convergence.md create mode 100644 docs/superpowers/plans/2026-04-12-pathing-template-convergence.md create mode 100644 docs/superpowers/plans/2026-04-12-pathing-transition-braking.md create mode 100644 docs/superpowers/specs/2026-04-12-parkour-admissibility-design.md diff --git a/docs/superpowers/plans/2026-04-12-parkour-admissibility-plan.md b/docs/superpowers/plans/2026-04-12-parkour-admissibility-plan.md new file mode 100644 index 00000000..5548736e --- /dev/null +++ b/docs/superpowers/plans/2026-04-12-parkour-admissibility-plan.md @@ -0,0 +1,210 @@ +# Parkour Admissibility Hardening Implementation Plan + +I'm using the writing-plans skill to create the implementation plan. + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Harden MoveParkour by factoring conservative run-up, diagonal-shoulder, and landing-overshoot checks into a helper, tightening MoveParkour’s acceptance, and covering the regression cases with deterministic tests. + +**Architecture:** Inject a new `ParkourFeasibility` helper that owns the admissibility rules so MoveParkour can simply call it before running the existing flight-path and destination checks; keep the helper self-contained so future moves can reuse it without touching the MoveParkour flow. + +**Tech Stack:** .NET 10 / C# 14, xUnit, dotnet CLI + +--- + +### Task 1: Create ParkourFeasibility helper + +**Files:** +- Create: `MinecraftClient/Pathing/Moves/ParkourFeasibility.cs` + +- [ ] **Step 1: Implement the helper class with the three checks** + +```csharp +namespace MinecraftClient.Pathing.Moves; + +internal static class ParkourFeasibility +{ + public static bool HasRunUp( + CalculationContext ctx, + int x, + int y, + int z, + int xOffset, + int zOffset, + int yDelta) + { + double horiz = Math.Sqrt(xOffset * xOffset + zOffset * zOffset); + double threshold = yDelta > 0 ? 2.5 : 3.5; + if (horiz < threshold) + return true; + + int backX = x - Math.Sign(xOffset); + int backZ = z - Math.Sign(zOffset); + if (!ctx.CanWalkOn(backX, y - 1, backZ)) + return false; + return IsColumnPassable(ctx, backX, y, backZ); + } + + public static bool HasDiagonalShoulderClearance( + CalculationContext ctx, + int x, + int y, + int z, + int xOffset, + int zOffset) + { + if (xOffset == 0 || zOffset == 0) + return true; + + return IsColumnPassable(ctx, x + Math.Sign(xOffset), y, z) + && IsColumnPassable(ctx, x, y, z + Math.Sign(zOffset)); + } + + public static bool HasLandingOvershootClearance( + CalculationContext ctx, + int destX, + int destY, + int destZ, + int xSign, + int zSign) + { + return IsColumnPassable(ctx, destX + xSign, destY, destZ + zSign); + } + + private static bool IsColumnPassable(CalculationContext ctx, int x, int y, int z) + { + if (!ctx.CanWalkThrough(x, y, z) || + !ctx.CanWalkThrough(x, y + 1, z) || + !ctx.CanWalkThrough(x, y + 2, z)) + return false; + + return true; + } +} +``` + +- [ ] **Step 2: Verify the helper compiles by building the solution** + +Run: `dotnet build MinecraftClient.sln -c Release` +Expected: `Build succeeded.` + +### Task 2: Update MoveParkour to rely on the helper + +**Files:** +- Modify: `MinecraftClient/Pathing/Moves/Impl/MoveParkour.cs` + +- [ ] **Step 1: Replace the existing run-up block with the helper** + +```csharp +if (!ParkourFeasibility.HasRunUp(ctx, x, y, z, XOffset, ZOffset, _yDelta)) +{ + result.SetImpossible(); + return; +} +``` + +- [ ] **Step 2: Replace the diagonal shoulder + overshoot handling with helper calls** + +```csharp +if (!ParkourFeasibility.HasDiagonalShoulderClearance(ctx, x, y, z, XOffset, ZOffset)) +{ + result.SetImpossible(); + return; +} + +if (!ParkourFeasibility.HasLandingOvershootClearance(ctx, destX, destY, destZ, xSign, zSign)) +{ + result.SetImpossible(); + return; +} +``` + +### Task 3: Add MoveParkour unit tests + +**Files:** +- Create: `MinecraftClient.Tests/Pathing/Moves/MoveParkourTests.cs` + +- [ ] **Step 1: Add tests for the three scenarios** + +```csharp +public sealed class MoveParkourTests +{ + private const int FloorY = 79; + + private static CalculationContext BuildContext(World world) + => new(world, allowParkour: true, allowParkourAscend: true); + + [Fact] + public void RejectsLongJumpWithoutRunUp() + { + var world = FlatWorldTestBuilder.CreateStoneFloor(FloorY); + world.SetBlock(new Location(-1, FloorY, 0), Block.Air); // remove run-up + var ctx = BuildContext(world); + var move = new MoveParkour(3, 0); + var result = default(MoveResult); + + move.Calculate(ctx, 0, FloorY + 1, 0, ref result); + + Assert.True(result.IsImpossible); + } + + [Fact] + public void AllowsShortJumpWithClearTakeoff() + { + var world = FlatWorldTestBuilder.CreateStoneFloor(FloorY); + var ctx = BuildContext(world); + var result = default(MoveResult); + new MoveParkour(2, 0).Calculate(ctx, 0, FloorY + 1, 0, ref result); + + Assert.False(result.IsImpossible); + Assert.Equal(2, result.DestX); + } + + [Fact] + public void RejectsDiagonalWhenShoulderBlocked() + { + var world = FlatWorldTestBuilder.CreateStoneFloor(FloorY); + world.SetBlock(new Location(1, FloorY + 1, 0), new Block(1)); + var ctx = BuildContext(world); + var result = default(MoveResult); + new MoveParkour(1, 1).Calculate(ctx, 0, FloorY + 1, 0, ref result); + + Assert.True(result.IsImpossible); + } +} +``` + +- [ ] **Step 2: Run the new tests to confirm they fail until implementation completes** + +Run: `dotnet test MinecraftClient.Tests --filter MoveParkourTests` +Expected: FAIL (the tests fail until Tasks 1–2 are finished) + +### Task 4: Validation + +**Files:** No new files; just validation commands. + +- [ ] **Step 1: Run the targeted test suite after implementation changes** + +Run: `dotnet test MinecraftClient.Tests --filter MoveParkourTests` +Expected: PASS all tests in the class. + +### Task 5: Commit (optional after verification) + +**Files:** +- Modify: the ones mentioned above (`ParkourFeasibility.cs`, `MoveParkour.cs`, `MoveParkourTests.cs`, plan/spec files) + +- [ ] **Step 1: Stage the affected files** + +```bash +git add MinecraftClient/Pathing/Moves/ParkourFeasibility.cs \ + MinecraftClient/Pathing/Moves/Impl/MoveParkour.cs \ + MinecraftClient.Tests/Pathing/Moves/MoveParkourTests.cs \ + docs/superpowers/specs/2026-04-12-parkour-admissibility-design.md \ + docs/superpowers/plans/2026-04-12-parkour-admissibility-plan.md +``` + +- [ ] **Step 2: Commit with a descriptive message** + +```bash +git commit -m "feat: harden parkour admissibility" +``` diff --git a/docs/superpowers/plans/2026-04-12-pathing-live-regression-convergence.md b/docs/superpowers/plans/2026-04-12-pathing-live-regression-convergence.md new file mode 100644 index 00000000..c853eb9f --- /dev/null +++ b/docs/superpowers/plans/2026-04-12-pathing-live-regression-convergence.md @@ -0,0 +1,425 @@ +# Pathing Live Regression Convergence Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Make every remaining movement template that currently passes deterministic simulation but fails on the real 1.21.11 server converge to the same reliable outcome in both environments. + +**Architecture:** Keep the existing move catalog and support-footprint completion rules, but close the sim/live gaps at the transition layer. The main tactic is to encode each live-only failure as a deterministic regression first, then fix the responsible handoff logic so braking, heading lock, and completion semantics stay consistent across `SprintJumpTemplate`, grounded recovery, and the local server harness. + +**Tech Stack:** C# 14 / .NET 10, MCC `PlayerPhysics`, xUnit, bash harnesses under `tools/`, local offline 1.21.11 server via `tools/mcc-env.sh`. + +--- + +## Execution Context + +The user explicitly asked to stay in the current workspace, not a worktree. Do not revert unrelated dirty files. The precision bar is not “exactly at center”; the bar is “footprint fully supported, no unsafe drift past the intended support edge, and no segment failure hidden by replanning”. + +## Scope + +In scope: + +- `LandingRecovery` regressions caused by the braking feature +- short parkour into turn / wall-adjacent follow-up moves that still fail live +- template and planner mismatches where deterministic tests are missing the real-server failure mode +- regression harness updates that fail on any segment failure instead of accepting a later replan + +Out of scope for this pass: + +- a global SafeWalk / always-sneak system +- new movement types +- large A* or cost-model rewrites unrelated to live regressions + +## File Structure + +### New files + +- `MinecraftClient.Tests/Pathing/Execution/LivePathingRegressionTests.cs` + Deterministic reproductions of the currently known live-only failures, seeded from real harness geometry and residual landing states. + +### Modified files + +- `MinecraftClient/Pathing/Execution/TransitionBrakingPlanner.cs` + Teach the planner that `LandingRecovery` may still require a real ground brake before the next heading change. +- `MinecraftClient/Pathing/Execution/Templates/SprintJumpTemplate.cs` + Keep landing recovery aligned with the planner and avoid drifting out of the landing support while preparing the next move. +- `MinecraftClient/Pathing/Execution/Templates/GroundedSegmentController.cs` + Reuse the corrected planner behavior for grounded completion and braking. +- `MinecraftClient.Tests/Pathing/Execution/SprintJumpTemplateScenarioTests.cs` + Preserve high-level parkour coverage after the targeted regression tests land. +- `MinecraftClient.Tests/Pathing/Execution/TransitionBrakingPlannerTests.cs` + Add planner-level assertions for `LandingRecovery` into turns and other non-straight follow-ups. +- `tools/test-pathing-template-regressions.sh` + Extend the live harness cases as each new real-only failure is discovered and fixed. + +--- + +### Task 1: Encode The Live `LandingRecovery -> Turn` Failure + +**Files:** +- Create: `MinecraftClient.Tests/Pathing/Execution/LivePathingRegressionTests.cs` +- Modify: `MinecraftClient.Tests/Pathing/Execution/TransitionBrakingPlannerTests.cs` +- Test: `MinecraftClient.Tests/MinecraftClient.Tests.csproj` + +- [ ] **Step 1: Write the failing planner and live-geometry regression tests** + +```csharp +// MinecraftClient.Tests/Pathing/Execution/TransitionBrakingPlannerTests.cs +[Fact] +public void Plan_BackBrakes_ForLandingRecovery_WhenNextSegmentTurns() +{ + World world = FlatWorldTestBuilder.CreateStoneFloor(); + var physics = CreatePhysics(0.118, 0.000, onGround: true); + var current = new PathSegment + { + Start = new Location(120.5, 80, 110.5), + End = new Location(122.5, 80, 110.5), + MoveType = MoveType.Parkour, + ExitTransition = PathTransitionType.LandingRecovery + }; + var next = new PathSegment + { + Start = new Location(122.5, 80, 110.5), + End = new Location(122.5, 80, 111.5), + MoveType = MoveType.Traverse, + ExitTransition = PathTransitionType.FinalStop + }; + + TransitionBrakingDecision decision = TransitionBrakingPlanner.Plan( + current, + next, + new Location(122.56, 80.0, 110.68), + physics, + world); + + Assert.False(decision.HoldForward); + Assert.False(decision.HoldSprint); + Assert.True(decision.HoldBack); +} +``` + +```csharp +// MinecraftClient.Tests/Pathing/Execution/LivePathingRegressionTests.cs +using MinecraftClient.Mapping; +using MinecraftClient.Pathing.Core; +using MinecraftClient.Pathing.Execution; +using MinecraftClient.Pathing.Execution.Templates; +using MinecraftClient.Physics; +using Xunit; + +namespace MinecraftClient.Tests.Pathing.Execution; + +public sealed class LivePathingRegressionTests +{ + [Fact] + public void LandingRecoveryIntoTurn_HoldsInsideLandingBlock_FromLiveLikeState() + { + World world = FlatWorldTestBuilder.CreateStoneFloor(min: 118, max: 126); + FlatWorldTestBuilder.ClearBox(world, 118, 79, 108, 126, 90, 112); + FlatWorldTestBuilder.SetSolid(world, 120, 79, 110); + FlatWorldTestBuilder.SetSolid(world, 122, 79, 110); + FlatWorldTestBuilder.SetSolid(world, 122, 79, 111); + FlatWorldTestBuilder.SetSolid(world, 120, 80, 111); + FlatWorldTestBuilder.SetSolid(world, 120, 81, 111); + + var current = new PathSegment + { + Start = new Location(120.5, 80, 110.5), + End = new Location(122.5, 80, 110.5), + MoveType = MoveType.Parkour, + ExitTransition = PathTransitionType.LandingRecovery + }; + var next = new PathSegment + { + Start = new Location(122.5, 80, 110.5), + End = new Location(122.5, 80, 111.5), + MoveType = MoveType.Traverse, + ExitTransition = PathTransitionType.FinalStop + }; + + var physics = new PlayerPhysics + { + Position = new Vec3d(122.56, 80.0, 110.68), + DeltaMovement = new Vec3d(0.118, 0.0, 0.018), + OnGround = true, + MovementSpeed = 0.1f, + Yaw = 270f, + Pitch = 0f + }; + + var input = new MovementInput(); + GroundedSegmentController.Apply(current, next, new Location(122.56, 80.0, 110.68), physics, input, world); + + Assert.True(input.Back); + physics.ApplyInput(input); + physics.Tick(world); + + Location settled = new(physics.Position.X, physics.Position.Y, physics.Position.Z); + Assert.True(TemplateFootingHelper.IsFootprintInsideTargetBlock(settled, current.End)); + } +} +``` + +- [ ] **Step 2: Run the targeted tests to verify they fail** + +Run: + +```bash +dotnet test MinecraftClient.Tests/MinecraftClient.Tests.csproj --filter "Plan_BackBrakes_ForLandingRecovery_WhenNextSegmentTurns|LandingRecoveryIntoTurn_HoldsInsideLandingBlock_FromLiveLikeState" -v minimal +``` + +Expected: FAIL because `LandingRecovery` currently falls through to the generic coast branch and does not hold `Back`. + +- [ ] **Step 3: Commit the failing regression capture** + +```bash +git add MinecraftClient.Tests/Pathing/Execution/TransitionBrakingPlannerTests.cs \ + MinecraftClient.Tests/Pathing/Execution/LivePathingRegressionTests.cs +git commit -m "test: capture live landing recovery turn regression" +``` + +--- + +### Task 2: Teach `LandingRecovery` To Brake For Non-Straight Follow-Ups + +**Files:** +- Modify: `MinecraftClient/Pathing/Execution/TransitionBrakingPlanner.cs` +- Modify: `MinecraftClient/Pathing/Execution/Templates/GroundedSegmentController.cs` +- Test: `MinecraftClient.Tests/Pathing/Execution/TransitionBrakingPlannerTests.cs` + +- [ ] **Step 1: Implement the minimal planner change** + +```csharp +// MinecraftClient/Pathing/Execution/TransitionBrakingPlanner.cs +public static TransitionBrakingDecision Plan(PathSegment current, PathSegment? next, Location pos, PlayerPhysics physics, World world) +{ + if (current.ExitTransition is PathTransitionType.ContinueStraight or PathTransitionType.PrepareJump) + return TransitionBrakingDecision.CarryMomentum(current.PreserveSprint); + + double remaining = RemainingDistanceAlongSegment(current, pos); + double forwardSpeed = Math.Max(0.0, ProjectHorizontalSpeedAlongHeading(physics, current.HeadingX, current.HeadingZ)); + double coastStopDistance = EstimateGroundStopDistance(physics, world, current.HeadingX, current.HeadingZ, applyBackBrake: false); + double hardBrakeDistance = EstimateGroundStopDistance(physics, world, current.HeadingX, current.HeadingZ, applyBackBrake: true); + + bool landingNeedsTurnBrake = current.ExitTransition == PathTransitionType.LandingRecovery + && next is not null + && (current.HeadingX != next.HeadingX || current.HeadingZ != next.HeadingZ); + + if (current.ExitTransition == PathTransitionType.FinalStop) + { + if (remaining < 0.0) + return TransitionBrakingDecision.Brake; + + if (forwardSpeed > GroundSpeedThreshold && remaining <= hardBrakeDistance + FinalBrakeLead) + return TransitionBrakingDecision.Brake; + + if (forwardSpeed <= GroundSpeedThreshold && remaining > 0.0) + return TransitionBrakingDecision.CarryMomentum(preserveSprint: false); + } + + if ((current.ExitTransition == PathTransitionType.Turn || landingNeedsTurnBrake) + && remaining <= hardBrakeDistance + TurnBrakeLead) + { + return TransitionBrakingDecision.Brake; + } + + if (remaining <= coastStopDistance + FinalStopLead) + return TransitionBrakingDecision.Coast; + + return TransitionBrakingDecision.CarryMomentum(current.PreserveSprint); +} +``` + +- [ ] **Step 2: Keep grounded braking aligned with the planner** + +```csharp +// MinecraftClient/Pathing/Execution/Templates/GroundedSegmentController.cs +internal static void Apply(PathSegment segment, PathSegment? nextSegment, Location pos, PlayerPhysics physics, MovementInput input, World world) +{ + TransitionBrakingDecision decision = TransitionBrakingPlanner.Plan(segment, nextSegment, pos, physics, world); + TemplateHelper.ApplyDecision(input, decision); + + if (decision.HoldBack) + TemplateHelper.FaceSegmentHeading(physics, segment); +} +``` + +- [ ] **Step 3: Run the targeted tests to verify they pass** + +Run: + +```bash +dotnet test MinecraftClient.Tests/MinecraftClient.Tests.csproj --filter "Plan_BackBrakes_ForLandingRecovery_WhenNextSegmentTurns|LandingRecoveryIntoTurn_HoldsInsideLandingBlock_FromLiveLikeState" -v minimal +``` + +Expected: PASS with `2 Passed`. + +- [ ] **Step 4: Commit the planner fix** + +```bash +git add MinecraftClient/Pathing/Execution/TransitionBrakingPlanner.cs \ + MinecraftClient/Pathing/Execution/Templates/GroundedSegmentController.cs \ + MinecraftClient.Tests/Pathing/Execution/TransitionBrakingPlannerTests.cs \ + MinecraftClient.Tests/Pathing/Execution/LivePathingRegressionTests.cs +git commit -m "fix: brake landing recovery before turns" +``` + +--- + +### Task 3: Keep `SprintJumpTemplate` Aligned With The Ground Brake + +**Files:** +- Modify: `MinecraftClient/Pathing/Execution/Templates/SprintJumpTemplate.cs` +- Modify: `MinecraftClient.Tests/Pathing/Execution/SprintJumpTemplateScenarioTests.cs` +- Test: `MinecraftClient.Tests/MinecraftClient.Tests.csproj` + +- [ ] **Step 1: Add a template-level regression for the exact L-turn geometry** + +```csharp +// MinecraftClient.Tests/Pathing/Execution/SprintJumpTemplateScenarioTests.cs +[Fact] +public void SprintJumpTemplate_TwoBlockGap_LandingRecovery_IntoTurn_CompletesWithoutLeavingLandingBlock() +{ + World world = FlatWorldTestBuilder.CreateStoneFloor(min: 118, max: 126); + FlatWorldTestBuilder.ClearBox(world, 118, 79, 108, 126, 90, 112); + FlatWorldTestBuilder.SetSolid(world, 120, 79, 110); + FlatWorldTestBuilder.SetSolid(world, 122, 79, 110); + FlatWorldTestBuilder.SetSolid(world, 122, 79, 111); + FlatWorldTestBuilder.SetSolid(world, 120, 80, 111); + FlatWorldTestBuilder.SetSolid(world, 120, 81, 111); + + var segment = new PathSegment + { + Start = new Location(120.5, 80, 110.5), + End = new Location(122.5, 80, 110.5), + MoveType = MoveType.Parkour, + ExitTransition = PathTransitionType.LandingRecovery + }; + var next = new PathSegment + { + Start = new Location(122.5, 80, 110.5), + End = new Location(122.5, 80, 111.5), + MoveType = MoveType.Traverse, + ExitTransition = PathTransitionType.FinalStop + }; + + var template = new SprintJumpTemplate(segment, next); + var physics = TemplateSimulationRunner.CreateGroundedPhysics(segment.Start, yaw: 270f); + + TemplateState state = TemplateSimulationRunner.Run(template, physics, world, maxTicks: 140, out Location finalPos); + + Assert.Equal(TemplateState.Complete, state); + Assert.True(TemplateFootingHelper.IsFootprintInsideTargetBlock(finalPos, segment.End)); +} +``` + +- [ ] **Step 2: Make landing recovery respect the same brake/heading contract as grounded segments** + +```csharp +// MinecraftClient/Pathing/Execution/Templates/SprintJumpTemplate.cs +case Phase.Landing: + TransitionBrakingDecision decision = TransitionBrakingPlanner.Plan(_segment, _nextSegment, pos, physics, world); + TemplateHelper.ApplyDecision(input, decision); + if (decision.HoldBack) + TemplateHelper.FaceSegmentHeading(physics, _segment); + + if (_segment.ExitTransition == PathTransitionType.ContinueStraight + && horizDistSq < horizToleranceSq && Math.Abs(dy) < vertTolerance) + return TemplateState.Complete; + + if (_segment.ExitTransition != PathTransitionType.ContinueStraight + && physics.OnGround + && TemplateHelper.IsSettledOnTargetBlock(pos, ExpectedEnd, physics)) + { + return TemplateState.Complete; + } + break; +``` + +- [ ] **Step 3: Run the parkour template test slice** + +Run: + +```bash +dotnet test MinecraftClient.Tests/MinecraftClient.Tests.csproj --filter "SprintJumpTemplate_TwoBlockGap_LandingRecovery_IntoTurn_CompletesWithoutLeavingLandingBlock|SprintJumpTemplate_TwoBlockGap_LandingRecovery_CompletesInsideLandingBlock|SprintJumpTemplate_TwoBlockGap_FinalStop_Completes|SprintJumpTemplate_ThreeBlockGap_FinalStop_Completes" -v minimal +``` + +Expected: PASS with `4 Passed`. + +- [ ] **Step 4: Commit the template alignment** + +```bash +git add MinecraftClient/Pathing/Execution/Templates/SprintJumpTemplate.cs \ + MinecraftClient.Tests/Pathing/Execution/SprintJumpTemplateScenarioTests.cs +git commit -m "fix: align sprint jump landing recovery with turn braking" +``` + +--- + +### Task 4: Sweep Remaining Sim/Live Gaps With The Real Harness + +**Files:** +- Modify: `tools/test-pathing-template-regressions.sh` +- Modify: `docs/superpowers/plans/2026-04-12-pathing-live-regression-convergence.md` +- Test: `MinecraftClient.Tests/MinecraftClient.Tests.csproj` + +- [ ] **Step 1: Extend the live harness with every newly discovered real-only failure** + +```bash +# tools/test-pathing-template-regressions.sh +# Add one function per new repro: +# - run_wall_adjacent_landing_recovery +# - run_around_wall_jump_followup +# - run_short_descend_into_turn +# Each function must: +# 1. build the exact world with mc-rcon +# 2. teleport CursorBot +# 3. send the pathfind command +# 4. fail immediately on any "[PathExec] Segment .* FAILED" +# 5. assert the final location or assert explicit planner rejection +``` + +- [ ] **Step 2: Run the full deterministic suite** + +Run: + +```bash +dotnet test MinecraftClient.Tests/MinecraftClient.Tests.csproj -v minimal +``` + +Expected: PASS with the full suite green. + +- [ ] **Step 3: Run the release build** + +Run: + +```bash +dotnet build MinecraftClient.sln -c Release +``` + +Expected: `Build succeeded.` + +- [ ] **Step 4: Run the real 1.21.11 harness** + +Run: + +```bash +bash tools/test-pathing-template-regressions.sh 1.21.11 +``` + +Expected: + +```text +== Flat final stop == +== Parkour into L-turn == +== Rejected 2x1 side-wall jump == +== Rejected 3x1 no-run-up gap == +All pathing template regression checks passed for 1.21.11. +``` + +- [ ] **Step 5: Commit the harness convergence** + +```bash +git add tools/test-pathing-template-regressions.sh \ + docs/superpowers/plans/2026-04-12-pathing-live-regression-convergence.md +git commit -m "test: extend live pathing regression coverage" +``` diff --git a/docs/superpowers/plans/2026-04-12-pathing-template-convergence.md b/docs/superpowers/plans/2026-04-12-pathing-template-convergence.md new file mode 100644 index 00000000..4443aecf --- /dev/null +++ b/docs/superpowers/plans/2026-04-12-pathing-template-convergence.md @@ -0,0 +1,993 @@ +# Pathing Template Convergence Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Make every path segment MCC agrees to execute stop safely inside the target block support, reject parkour moves that are not yet reliable, and prove traverse, ascend, descend, climb, fall, and sprint-jump behavior on local 1.21.11. + +**Architecture:** Keep A* and the existing move catalog mostly intact, but tighten reliability at two boundaries. On the planning side, adopt Baritone-style conservative parkour admissibility so MCC stops accepting jumps it cannot execute consistently. On the execution side, replace center-hunting with support-footprint completion and add a shared grounded-segment controller so walk, ascend, descend, and sprint-jump all use the same transition rules. + +**Tech Stack:** C# 14 / .NET 10, MCC `PlayerPhysics`, xUnit deterministic regression tests, local bash harnesses under `tools/`, local offline Minecraft 1.21.11 server via `tools/mcc-env.sh`. + +--- + +## Execution Context + +This plan assumes implementation happens in a dedicated worktree even though the current investigation ran in the main workspace. Do not tune flat-stop precision toward exact block center. The success bar is simpler: the player may finish anywhere inside the target block support footprint, but must not drift past the edge once the segment reports success. + +## Scope + +In scope: + +- tighten parkour admissibility until accepted jumps are reliable +- converge grounded template completion rules across walk, ascend, descend, and sprint-jump landing +- preserve working climb and fall behavior with regression coverage +- add deterministic simulation tests and real-server regression scripts + +Out of scope for this pass: + +- expanding the parkour move catalog beyond moves we can prove reliable +- changing A* heuristics or node expansion rules unrelated to movement correctness +- making `Shift` a full SafeWalk feature for all contexts + +## File Structure + +### New files + +- `MinecraftClient/Pathing/Execution/Templates/TemplateFootingHelper.cs` + Shared support-footprint math. Answers "is the player's 0.6-wide footprint still fully inside the target block?" and "would current velocity carry it outside next tick?" +- `MinecraftClient/Pathing/Execution/Templates/GroundedSegmentController.cs` + Shared grounded transition logic for walk, ascend, descend, and sprint-jump landing. +- `MinecraftClient/Pathing/Moves/ParkourFeasibility.cs` + Conservative parkour admissibility helper: run-up, shoulder clearance, overshoot safety, and landing validation. +- `MinecraftClient.Tests/Pathing/Execution/TemplateSimulationRunner.cs` + Deterministic loop that drives `IActionTemplate`, `MovementInput`, and `PlayerPhysics` against a test world. +- `MinecraftClient.Tests/Pathing/Execution/TemplateFootingTests.cs` + Unit tests for support-footprint completion rules. +- `MinecraftClient.Tests/Pathing/Execution/GroundedTemplateConvergenceTests.cs` + Simulation tests for walk, ascend, and descend transition behavior. +- `MinecraftClient.Tests/Pathing/Execution/SprintJumpTemplateScenarioTests.cs` + Simulation tests for parkour landing, turn preparation, and accepted side-wall jumps. +- `MinecraftClient.Tests/Pathing/Execution/ClimbFallTemplateTests.cs` + Simulation smoke tests for climb and fall so convergence work does not regress them. +- `MinecraftClient.Tests/Pathing/Moves/MoveParkourTests.cs` + Planning-time admissibility tests for `MoveParkour`. +- `tools/test-pathing-template-regressions.sh` + Real-server regression harness for local 1.21.11. + +### Modified files + +- `MinecraftClient/Pathing/Execution/Templates/TemplateHelper.cs` + Route support-footprint checks through the new helper and expose shared heading/progress helpers. +- `MinecraftClient/Pathing/Execution/Templates/WalkTemplate.cs` + Stop using settle-at-center rules for `PrepareJump`, `Turn`, and `FinalStop`. +- `MinecraftClient/Pathing/Execution/Templates/AscendTemplate.cs` + Use shared grounded completion after landing and treat `PrepareJump` as a handoff, not a settle. +- `MinecraftClient/Pathing/Execution/Templates/DescendTemplate.cs` + Use shared landing recovery and block-support completion instead of center-hunting. +- `MinecraftClient/Pathing/Execution/Templates/SprintJumpTemplate.cs` + Split takeoff into explicit phases, release input earlier in air when needed, and finish on target support instead of target center. +- `MinecraftClient/Pathing/Moves/Impl/MoveParkour.cs` + Replace ad hoc run-up checks with shared conservative feasibility logic. +- `MinecraftClient.Tests/Pathing/Execution/FlatWorldTestBuilder.cs` + Add helpers to place blocks, carve air, and build side-wall / stair / ladder / gap scenarios. +- `MinecraftClient.Tests/Pathing/Execution/TransitionBrakingPlannerTests.cs` + Add cases that match new landing and release thresholds where needed. +- `docs/guide/pathfinding-research.md` + Document the reliability-first rule: accepted moves must be executable, support-footprint completion is sufficient, and unsupported parkour shapes are rejected. + +--- + +### Task 1: Add Support-Footprint Completion Rules + +**Files:** +- Create: `MinecraftClient/Pathing/Execution/Templates/TemplateFootingHelper.cs` +- Create: `MinecraftClient.Tests/Pathing/Execution/TemplateFootingTests.cs` +- Modify: `MinecraftClient/Pathing/Execution/Templates/TemplateHelper.cs` + +- [ ] **Step 1: Write the failing support-footprint tests** + +```csharp +// MinecraftClient.Tests/Pathing/Execution/TemplateFootingTests.cs +using MinecraftClient.Mapping; +using MinecraftClient.Pathing.Execution.Templates; +using MinecraftClient.Physics; +using Xunit; + +namespace MinecraftClient.Tests.Pathing.Execution; + +public sealed class TemplateFootingTests +{ + [Fact] + public void IsFootprintInsideTargetBlock_ReturnsTrue_WhenPlayerIsNearEdgeButStillInside() + { + bool inside = TemplateFootingHelper.IsFootprintInsideTargetBlock( + new Location(10.69, 80.0, 4.50), + new Location(10.50, 80.0, 4.50)); + + Assert.True(inside); + } + + [Fact] + public void IsFootprintInsideTargetBlock_ReturnsFalse_WhenPlayerCrossesBlockEdge() + { + bool inside = TemplateFootingHelper.IsFootprintInsideTargetBlock( + new Location(10.81, 80.0, 4.50), + new Location(10.50, 80.0, 4.50)); + + Assert.False(inside); + } + + [Fact] + public void WillLeaveTargetBlockNextTick_ReturnsTrue_WhenVelocityWouldCarryPastEdge() + { + var physics = new PlayerPhysics + { + Position = new Vec3d(10.67, 80.0, 4.50), + DeltaMovement = new Vec3d(0.060, 0.0, 0.0), + OnGround = true + }; + + bool exitsNextTick = TemplateFootingHelper.WillLeaveTargetBlockNextTick( + new Location(10.67, 80.0, 4.50), + physics, + new Location(10.50, 80.0, 4.50)); + + Assert.True(exitsNextTick); + } +} +``` + +- [ ] **Step 2: Run the tests to verify they fail** + +Run: + +```bash +dotnet test MinecraftClient.Tests/MinecraftClient.Tests.csproj --filter TemplateFootingTests -v minimal +``` + +Expected: FAIL with compile errors because `TemplateFootingHelper` and the new helper methods do not exist yet. + +- [ ] **Step 3: Implement the support-footprint helper and route `TemplateHelper` through it** + +```csharp +// MinecraftClient/Pathing/Execution/Templates/TemplateFootingHelper.cs +using MinecraftClient.Mapping; +using MinecraftClient.Physics; + +namespace MinecraftClient.Pathing.Execution.Templates; + +internal static class TemplateFootingHelper +{ + private const double HalfWidth = PhysicsConsts.PlayerWidth / 2.0; + + internal static bool IsFootprintInsideTargetBlock(Location pos, Location target, double epsilon = 1.0E-4) + { + double minX = pos.X - HalfWidth; + double maxX = pos.X + HalfWidth; + double minZ = pos.Z - HalfWidth; + double maxZ = pos.Z + HalfWidth; + + double blockMinX = Math.Floor(target.X); + double blockMaxX = blockMinX + 1.0; + double blockMinZ = Math.Floor(target.Z); + double blockMaxZ = blockMinZ + 1.0; + + return minX >= blockMinX - epsilon + && maxX <= blockMaxX + epsilon + && minZ >= blockMinZ - epsilon + && maxZ <= blockMaxZ + epsilon; + } + + internal static bool WillLeaveTargetBlockNextTick(Location pos, PlayerPhysics physics, Location target, double epsilon = 1.0E-4) + { + Location next = new( + pos.X + physics.DeltaMovement.X, + pos.Y, + pos.Z + physics.DeltaMovement.Z); + return !IsFootprintInsideTargetBlock(next, target, epsilon); + } +} +``` + +```csharp +// MinecraftClient/Pathing/Execution/Templates/TemplateHelper.cs +internal static bool IsSettledOnTargetBlock(Location pos, Location target, PlayerPhysics physics, + double speedThresholdSq = 0.0016) +{ + double horizontalSpeedSq = physics.DeltaMovement.X * physics.DeltaMovement.X + + physics.DeltaMovement.Z * physics.DeltaMovement.Z; + + if (!TemplateFootingHelper.IsFootprintInsideTargetBlock(pos, target)) + return false; + + if (TemplateFootingHelper.WillLeaveTargetBlockNextTick(pos, physics, target)) + return false; + + return horizontalSpeedSq <= speedThresholdSq; +} +``` + +- [ ] **Step 4: Re-run the support-footprint tests** + +Run: + +```bash +dotnet test MinecraftClient.Tests/MinecraftClient.Tests.csproj --filter TemplateFootingTests -v minimal +``` + +Expected: PASS with `3 Passed`. + +- [ ] **Step 5: Commit the support-footprint groundwork** + +```bash +git add MinecraftClient/Pathing/Execution/Templates/TemplateFootingHelper.cs \ + MinecraftClient/Pathing/Execution/Templates/TemplateHelper.cs \ + MinecraftClient.Tests/Pathing/Execution/TemplateFootingTests.cs +git commit -m "feat: add support-aware template completion checks" +``` + +--- + +### Task 2: Tighten Parkour Admissibility to the Reliable Subset + +**Files:** +- Create: `MinecraftClient/Pathing/Moves/ParkourFeasibility.cs` +- Create: `MinecraftClient.Tests/Pathing/Moves/MoveParkourTests.cs` +- Modify: `MinecraftClient/Pathing/Moves/Impl/MoveParkour.cs` +- Modify: `MinecraftClient.Tests/Pathing/Execution/FlatWorldTestBuilder.cs` + +- [ ] **Step 1: Write the failing `MoveParkour` admissibility tests** + +```csharp +// MinecraftClient.Tests/Pathing/Moves/MoveParkourTests.cs +using MinecraftClient.Mapping; +using MinecraftClient.Pathing.Core; +using MinecraftClient.Pathing.Moves.Impl; +using Xunit; + +namespace MinecraftClient.Tests.Pathing.Moves; + +public sealed class MoveParkourTests +{ + [Fact] + public void Calculate_RejectsThreeByOneSideWall_WhenRunUpIsMissing() + { + World world = FlatWorldTestBuilder.CreateStoneFloor(min: 0, max: 16); + FlatWorldTestBuilder.SetSolid(world, 2, 79, 2); + FlatWorldTestBuilder.SetSolid(world, 5, 79, 3); + FlatWorldTestBuilder.FillSolid(world, 4, 79, 2, 4, 81, 2); + + var ctx = new CalculationContext(world, allowParkour: true, allowParkourAscend: true); + var move = new MoveParkour(3, 1); + MoveResult result = default; + + move.Calculate(ctx, 2, 80, 2, ref result); + + Assert.True(result.IsImpossible); + } + + [Fact] + public void Calculate_AcceptsTwoByOneSideWall_WhenTakeoffAndLandingAreClear() + { + World world = FlatWorldTestBuilder.CreateStoneFloor(min: 0, max: 16); + FlatWorldTestBuilder.SetSolid(world, 2, 79, 2); + FlatWorldTestBuilder.SetSolid(world, 4, 79, 3); + FlatWorldTestBuilder.FillSolid(world, 4, 79, 2, 4, 81, 2); + + var ctx = new CalculationContext(world, allowParkour: true, allowParkourAscend: true); + var move = new MoveParkour(2, 1); + MoveResult result = default; + + move.Calculate(ctx, 2, 80, 2, ref result); + + Assert.False(result.IsImpossible); + Assert.Equal(4, result.DestX); + Assert.Equal(80, result.DestY); + Assert.Equal(3, result.DestZ); + } + + [Fact] + public void Calculate_RejectsDiagonalJump_WhenTakeoffShoulderIsBlocked() + { + World world = FlatWorldTestBuilder.CreateStoneFloor(min: 0, max: 16); + FlatWorldTestBuilder.SetSolid(world, 2, 79, 2); + FlatWorldTestBuilder.SetSolid(world, 4, 79, 4); + FlatWorldTestBuilder.SetSolid(world, 3, 80, 2); + + var ctx = new CalculationContext(world, allowParkour: true, allowParkourAscend: true); + var move = new MoveParkour(2, 2); + MoveResult result = default; + + move.Calculate(ctx, 2, 80, 2, ref result); + + Assert.True(result.IsImpossible); + } +} +``` + +- [ ] **Step 2: Run the parkour admissibility tests and watch them fail** + +Run: + +```bash +dotnet test MinecraftClient.Tests/MinecraftClient.Tests.csproj --filter MoveParkourTests -v minimal +``` + +Expected: FAIL because current `MoveParkour` only checks one behind-block for run-up and does not centralize side-clearance logic. + +- [ ] **Step 3: Extract conservative feasibility checks and wire `MoveParkour` through them** + +```csharp +// MinecraftClient/Pathing/Moves/ParkourFeasibility.cs +using System; +using MinecraftClient.Pathing.Core; + +namespace MinecraftClient.Pathing.Moves; + +internal static class ParkourFeasibility +{ + internal static int RequiredRunUpBlocks(int xOffset, int zOffset, int yDelta) + { + double horizDist = Math.Sqrt((double)(xOffset * xOffset + zOffset * zOffset)); + if (yDelta > 0 || horizDist >= 4.0) + return 2; + if (horizDist >= 3.0) + return 1; + return 0; + } + + internal static bool HasRunUp(CalculationContext ctx, int x, int y, int z, int xOffset, int zOffset, int yDelta) + { + int stepX = Math.Sign(xOffset); + int stepZ = Math.Sign(zOffset); + int required = RequiredRunUpBlocks(xOffset, zOffset, yDelta); + + for (int i = 1; i <= required; i++) + { + int rx = x - stepX * i; + int rz = z - stepZ * i; + if (!ctx.CanWalkOn(rx, y - 1, rz) + || !ctx.CanWalkThrough(rx, y, rz) + || !ctx.CanWalkThrough(rx, y + 1, rz)) + { + return false; + } + } + + return true; + } + + internal static bool HasDiagonalTakeoffClearance(CalculationContext ctx, int x, int y, int z, int stepX, int stepZ) + { + return ctx.CanWalkThrough(x + stepX, y, z) + && ctx.CanWalkThrough(x + stepX, y + 1, z) + && ctx.CanWalkThrough(x, y, z + stepZ) + && ctx.CanWalkThrough(x, y + 1, z + stepZ); + } + + internal static bool HasOvershootClearance(CalculationContext ctx, int x, int y, int z) + { + return ctx.CanWalkThrough(x, y, z) && ctx.CanWalkThrough(x, y + 1, z); + } +} +``` + +```csharp +// MinecraftClient/Pathing/Moves/Impl/MoveParkour.cs +if (!ParkourFeasibility.HasRunUp(ctx, x, y, z, XOffset, ZOffset, _yDelta)) +{ + result.SetImpossible(); + return; +} + +if (xAbs > 0 && zAbs > 0 && !ParkourFeasibility.HasDiagonalTakeoffClearance(ctx, x, y, z, xSign, zSign)) +{ + result.SetImpossible(); + return; +} + +int overX = destX + xSign; +int overZ = destZ + zSign; +if (!ParkourFeasibility.HasOvershootClearance(ctx, overX, destY, overZ)) +{ + result.SetImpossible(); + return; +} +``` + +- [ ] **Step 4: Re-run the parkour admissibility tests** + +Run: + +```bash +dotnet test MinecraftClient.Tests/MinecraftClient.Tests.csproj --filter MoveParkourTests -v minimal +``` + +Expected: PASS with `3 Passed`. + +- [ ] **Step 5: Commit the planner hardening** + +```bash +git add MinecraftClient/Pathing/Moves/ParkourFeasibility.cs \ + MinecraftClient/Pathing/Moves/Impl/MoveParkour.cs \ + MinecraftClient.Tests/Pathing/Moves/MoveParkourTests.cs \ + MinecraftClient.Tests/Pathing/Execution/FlatWorldTestBuilder.cs +git commit -m "feat: tighten parkour move admissibility" +``` + +--- + +### Task 3: Converge Walk, Ascend, and Descend on Shared Grounded Transition Rules + +**Files:** +- Create: `MinecraftClient/Pathing/Execution/Templates/GroundedSegmentController.cs` +- Create: `MinecraftClient.Tests/Pathing/Execution/TemplateSimulationRunner.cs` +- Create: `MinecraftClient.Tests/Pathing/Execution/GroundedTemplateConvergenceTests.cs` +- Modify: `MinecraftClient/Pathing/Execution/Templates/WalkTemplate.cs` +- Modify: `MinecraftClient/Pathing/Execution/Templates/AscendTemplate.cs` +- Modify: `MinecraftClient/Pathing/Execution/Templates/DescendTemplate.cs` +- Modify: `MinecraftClient/Pathing/Execution/Templates/TemplateHelper.cs` +- Modify: `MinecraftClient.Tests/Pathing/Execution/FlatWorldTestBuilder.cs` + +- [ ] **Step 1: Write the failing simulation tests for grounded segment handoff** + +```csharp +// MinecraftClient.Tests/Pathing/Execution/GroundedTemplateConvergenceTests.cs +using MinecraftClient.Mapping; +using MinecraftClient.Pathing.Core; +using MinecraftClient.Pathing.Execution; +using MinecraftClient.Pathing.Execution.Templates; +using MinecraftClient.Physics; +using Xunit; + +namespace MinecraftClient.Tests.Pathing.Execution; + +public sealed class GroundedTemplateConvergenceTests +{ + [Fact] + public void WalkTemplate_FinalStop_Completes_WhenFootprintStaysInsideTargetBlock() + { + World world = FlatWorldTestBuilder.CreateStoneFloor(); + var segment = new PathSegment + { + Start = new Location(0.5, 80, 0.5), + End = new Location(1.5, 80, 0.5), + MoveType = MoveType.Traverse, + ExitTransition = PathTransitionType.FinalStop + }; + + var template = new WalkTemplate(segment, null); + var physics = TemplateSimulationRunner.CreateGroundedPhysics(segment.Start, yaw: 270f); + + TemplateState state = TemplateSimulationRunner.Run(template, physics, world, maxTicks: 80, out Location finalPos); + + Assert.Equal(TemplateState.Complete, state); + Assert.True(TemplateFootingHelper.IsFootprintInsideTargetBlock(finalPos, segment.End)); + } + + [Fact] + public void WalkTemplate_PrepareJump_CompletesWithoutSettlingOnRunUpBlock() + { + World world = FlatWorldTestBuilder.CreateStoneFloor(); + var current = new PathSegment + { + Start = new Location(0.5, 80, 0.5), + End = new Location(1.5, 80, 0.5), + MoveType = MoveType.Traverse, + ExitTransition = PathTransitionType.PrepareJump, + PreserveSprint = true + }; + var next = new PathSegment + { + Start = new Location(1.5, 80, 0.5), + End = new Location(3.5, 80, 0.5), + MoveType = MoveType.Parkour, + ExitTransition = PathTransitionType.FinalStop + }; + + var template = new WalkTemplate(current, next); + var physics = TemplateSimulationRunner.CreateGroundedPhysics(current.Start, yaw: 270f); + + TemplateState state = TemplateSimulationRunner.Run(template, physics, world, maxTicks: 40, out _); + + Assert.Equal(TemplateState.Complete, state); + Assert.True(physics.DeltaMovement.X > 0.05); + } + + [Fact] + public void DescendTemplate_LandingRecovery_CompletesOnLandingBlock() + { + World world = FlatWorldTestBuilder.CreateStoneFloor(); + FlatWorldTestBuilder.ClearBox(world, 1, 80, 0, 1, 80, 0); + FlatWorldTestBuilder.SetSolid(world, 1, 78, 0); + + var segment = new PathSegment + { + Start = new Location(0.5, 80, 0.5), + End = new Location(1.5, 79, 0.5), + MoveType = MoveType.Descend, + ExitTransition = PathTransitionType.LandingRecovery + }; + + var template = new DescendTemplate(segment, null); + var physics = TemplateSimulationRunner.CreateGroundedPhysics(segment.Start, yaw: 270f); + + TemplateState state = TemplateSimulationRunner.Run(template, physics, world, maxTicks: 120, out Location finalPos); + + Assert.Equal(TemplateState.Complete, state); + Assert.True(TemplateFootingHelper.IsFootprintInsideTargetBlock(finalPos, segment.End)); + } +} +``` + +- [ ] **Step 2: Run the grounded simulation tests and watch them fail** + +Run: + +```bash +dotnet test MinecraftClient.Tests/MinecraftClient.Tests.csproj --filter GroundedTemplateConvergenceTests -v minimal +``` + +Expected: FAIL because there is no simulation runner yet and current templates still use settle-at-center rules for `PrepareJump` and landing recovery. + +- [ ] **Step 3: Add a shared grounded controller and migrate walk / ascend / descend to it** + +```csharp +// MinecraftClient/Pathing/Execution/Templates/GroundedSegmentController.cs +using MinecraftClient.Mapping; +using MinecraftClient.Physics; + +namespace MinecraftClient.Pathing.Execution.Templates; + +internal static class GroundedSegmentController +{ + internal static void Apply(PathSegment segment, PathSegment? nextSegment, Location pos, PlayerPhysics physics, MovementInput input, World world) + { + TransitionBrakingDecision decision = TransitionBrakingPlanner.Plan(segment, nextSegment, pos, physics, world); + TemplateHelper.ApplyDecision(input, decision); + if (decision.HoldBack) + TemplateHelper.FaceSegmentHeading(physics, segment); + } + + internal static bool ShouldComplete(PathSegment segment, Location pos, PlayerPhysics physics) + { + return segment.ExitTransition switch + { + PathTransitionType.ContinueStraight => TemplateHelper.IsNear(pos, segment.End, horizThresholdSq: 0.09), + PathTransitionType.PrepareJump => TemplateHelper.HasReachedSegmentEndPlane(pos, segment), + _ => TemplateHelper.IsSettledOnTargetBlock(pos, segment.End, physics) + }; + } +} +``` + +```csharp +// MinecraftClient.Tests/Pathing/Execution/TemplateSimulationRunner.cs +using MinecraftClient.Mapping; +using MinecraftClient.Pathing.Execution; +using MinecraftClient.Physics; + +namespace MinecraftClient.Tests.Pathing.Execution; + +internal static class TemplateSimulationRunner +{ + internal static PlayerPhysics CreateGroundedPhysics(Location start, float yaw) + { + return new PlayerPhysics + { + Position = new Vec3d(start.X, start.Y, start.Z), + DeltaMovement = Vec3d.Zero, + OnGround = true, + MovementSpeed = 0.1f, + Yaw = yaw + }; + } + + internal static TemplateState Run(IActionTemplate template, PlayerPhysics physics, World world, int maxTicks, out Location finalPos) + { + var input = new MovementInput(); + TemplateState state = TemplateState.InProgress; + + for (int tick = 0; tick < maxTicks && state == TemplateState.InProgress; tick++) + { + input.Reset(); + Location pos = new(physics.Position.X, physics.Position.Y, physics.Position.Z); + state = template.Tick(pos, physics, input, world); + physics.ApplyInput(input); + physics.Tick(world); + } + + finalPos = new Location(physics.Position.X, physics.Position.Y, physics.Position.Z); + return state; + } +} +``` + +```csharp +// MinecraftClient/Pathing/Execution/Templates/WalkTemplate.cs +GroundedSegmentController.Apply(_segment, _nextSegment, pos, physics, input, world); + +if (GroundedSegmentController.ShouldComplete(_segment, pos, physics)) + return TemplateState.Complete; +``` + +```csharp +// MinecraftClient/Pathing/Execution/Templates/TemplateHelper.cs +internal static bool HasReachedSegmentEndPlane(Location pos, PathSegment segment) +{ + double dx = pos.X - segment.End.X; + double dz = pos.Z - segment.End.Z; + return dx * segment.HeadingX + dz * segment.HeadingZ >= -0.05; +} +``` + +- [ ] **Step 4: Re-run the grounded simulation tests** + +Run: + +```bash +dotnet test MinecraftClient.Tests/MinecraftClient.Tests.csproj --filter GroundedTemplateConvergenceTests -v minimal +``` + +Expected: PASS with `3 Passed`. + +- [ ] **Step 5: Commit the grounded-template convergence work** + +```bash +git add MinecraftClient/Pathing/Execution/Templates/GroundedSegmentController.cs \ + MinecraftClient/Pathing/Execution/Templates/TemplateHelper.cs \ + MinecraftClient/Pathing/Execution/Templates/WalkTemplate.cs \ + MinecraftClient/Pathing/Execution/Templates/AscendTemplate.cs \ + MinecraftClient/Pathing/Execution/Templates/DescendTemplate.cs \ + MinecraftClient.Tests/Pathing/Execution/TemplateSimulationRunner.cs \ + MinecraftClient.Tests/Pathing/Execution/GroundedTemplateConvergenceTests.cs \ + MinecraftClient.Tests/Pathing/Execution/FlatWorldTestBuilder.cs +git commit -m "feat: converge grounded path execution templates" +``` + +--- + +### Task 4: Rework Sprint Jump Execution Around Committed Takeoff and Support-Aware Landing + +**Files:** +- Create: `MinecraftClient.Tests/Pathing/Execution/SprintJumpTemplateScenarioTests.cs` +- Modify: `MinecraftClient/Pathing/Execution/Templates/SprintJumpTemplate.cs` +- Modify: `MinecraftClient.Tests/Pathing/Execution/TransitionBrakingPlannerTests.cs` + +- [ ] **Step 1: Write the failing sprint-jump scenario tests** + +```csharp +// MinecraftClient.Tests/Pathing/Execution/SprintJumpTemplateScenarioTests.cs +using MinecraftClient.Mapping; +using MinecraftClient.Pathing.Core; +using MinecraftClient.Pathing.Execution; +using MinecraftClient.Pathing.Execution.Templates; +using Xunit; + +namespace MinecraftClient.Tests.Pathing.Execution; + +public sealed class SprintJumpTemplateScenarioTests +{ + [Fact] + public void SprintJumpTemplate_ParkourIntoTurn_LandsInsideTargetSupport() + { + World world = FlatWorldTestBuilder.CreateStoneFloor(min: 0, max: 16); + FlatWorldTestBuilder.ClearBox(world, 1, 79, 0, 2, 79, 0); + FlatWorldTestBuilder.SetSolid(world, 3, 79, 0); + FlatWorldTestBuilder.SetSolid(world, 3, 79, 1); + + var current = new PathSegment + { + Start = new Location(0.5, 80, 0.5), + End = new Location(3.5, 80, 0.5), + MoveType = MoveType.Parkour, + ExitTransition = PathTransitionType.LandingRecovery + }; + var next = new PathSegment + { + Start = new Location(3.5, 80, 0.5), + End = new Location(3.5, 80, 1.5), + MoveType = MoveType.Traverse, + ExitTransition = PathTransitionType.FinalStop + }; + + var template = new SprintJumpTemplate(current, next); + var physics = TemplateSimulationRunner.CreateGroundedPhysics(current.Start, yaw: 270f); + + TemplateState state = TemplateSimulationRunner.Run(template, physics, world, maxTicks: 80, out Location finalPos); + + Assert.Equal(TemplateState.Complete, state); + Assert.True(TemplateFootingHelper.IsFootprintInsideTargetBlock(finalPos, current.End)); + } + + [Fact] + public void SprintJumpTemplate_TwoByOneSideWall_Completes() + { + World world = FlatWorldTestBuilder.CreateStoneFloor(min: 0, max: 16); + FlatWorldTestBuilder.ClearBox(world, 1, 79, 0, 1, 79, 0); + FlatWorldTestBuilder.SetSolid(world, 2, 79, 1); + FlatWorldTestBuilder.FillSolid(world, 2, 79, 0, 2, 81, 0); + + var segment = new PathSegment + { + Start = new Location(0.5, 80, 0.5), + End = new Location(2.5, 80, 1.5), + MoveType = MoveType.Parkour, + ExitTransition = PathTransitionType.FinalStop + }; + + var template = new SprintJumpTemplate(segment, null); + var physics = TemplateSimulationRunner.CreateGroundedPhysics(segment.Start, yaw: 315f); + + TemplateState state = TemplateSimulationRunner.Run(template, physics, world, maxTicks: 80, out Location finalPos); + + Assert.Equal(TemplateState.Complete, state); + Assert.True(TemplateFootingHelper.IsFootprintInsideTargetBlock(finalPos, segment.End)); + } +} +``` + +- [ ] **Step 2: Run the sprint-jump scenario tests and confirm they fail** + +Run: + +```bash +dotnet test MinecraftClient.Tests/MinecraftClient.Tests.csproj --filter SprintJumpTemplateScenarioTests -v minimal +``` + +Expected: FAIL because the current template still overshoots landing blocks and treats landing recovery as a late braking problem instead of a committed takeoff plus controlled handoff. + +- [ ] **Step 3: Introduce explicit jump phases and support-aware landing completion** + +```csharp +// MinecraftClient/Pathing/Execution/Templates/SprintJumpTemplate.cs +private enum Phase +{ + Approach, + CommitJump, + Airborne, + LandingRecovery +} + +case Phase.Approach: + input.Forward = true; + input.Sprint = true; + if (physics.OnGround && YawDifference(physics.Yaw, targetYaw) < YawToleranceDeg && ReadyForTakeoff(pos)) + { + _phase = Phase.CommitJump; + } + break; + +case Phase.CommitJump: + input.Forward = true; + input.Sprint = true; + input.Jump = physics.OnGround; + if (!physics.OnGround) + { + _leftGround = true; + _phase = Phase.Airborne; + } + break; + +case Phase.Airborne: + bool releaseNow = TransitionBrakingPlanner.ShouldReleaseForwardInAir(_segment, _nextSegment, pos, physics) + || TemplateFootingHelper.WillLeaveTargetBlockNextTick(pos, physics, ExpectedEnd); + input.Forward = !releaseNow; + input.Sprint = !releaseNow; + if (_leftGround && physics.OnGround) + _phase = Phase.LandingRecovery; + break; + +case Phase.LandingRecovery: + GroundedSegmentController.Apply(_segment, _nextSegment, pos, physics, input, world); + if (GroundedSegmentController.ShouldComplete(_segment, pos, physics)) + return TemplateState.Complete; + break; +``` + +- [ ] **Step 4: Re-run sprint-jump tests plus braking planner tests** + +Run: + +```bash +dotnet test MinecraftClient.Tests/MinecraftClient.Tests.csproj --filter "SprintJumpTemplateScenarioTests|TransitionBrakingPlannerTests" -v minimal +``` + +Expected: PASS with all sprint-jump and braking tests green. + +- [ ] **Step 5: Commit the sprint-jump convergence** + +```bash +git add MinecraftClient/Pathing/Execution/Templates/SprintJumpTemplate.cs \ + MinecraftClient.Tests/Pathing/Execution/SprintJumpTemplateScenarioTests.cs \ + MinecraftClient.Tests/Pathing/Execution/TransitionBrakingPlannerTests.cs +git commit -m "feat: stabilize sprint jump execution transitions" +``` + +--- + +### Task 5: Add Regression Coverage for Climb / Fall and Real-Server Template Matrix + +**Files:** +- Create: `MinecraftClient.Tests/Pathing/Execution/ClimbFallTemplateTests.cs` +- Create: `tools/test-pathing-template-regressions.sh` +- Modify: `docs/guide/pathfinding-research.md` + +- [ ] **Step 1: Write the remaining simulation smoke tests and the local server harness** + +```csharp +// MinecraftClient.Tests/Pathing/Execution/ClimbFallTemplateTests.cs +using MinecraftClient.Mapping; +using MinecraftClient.Pathing.Core; +using MinecraftClient.Pathing.Execution; +using MinecraftClient.Pathing.Execution.Templates; +using Xunit; + +namespace MinecraftClient.Tests.Pathing.Execution; + +public sealed class ClimbFallTemplateTests +{ + [Fact] + public void ClimbTemplate_UpwardMove_StillCompletes() + { + World world = FlatWorldTestBuilder.CreateStoneFloor(min: 0, max: 8); + FlatWorldTestBuilder.FillSolid(world, 0, 79, 0, 0, 82, 0); + FlatWorldTestBuilder.SetClimbable(world, 0, 80, 0); + FlatWorldTestBuilder.SetClimbable(world, 0, 81, 0); + + var segment = new PathSegment + { + Start = new Location(0.5, 80, 0.5), + End = new Location(0.5, 81, 0.5), + MoveType = MoveType.Climb + }; + + var template = new ClimbTemplate(segment, null); + var physics = TemplateSimulationRunner.CreateGroundedPhysics(segment.Start, yaw: 0f); + + TemplateState state = TemplateSimulationRunner.Run(template, physics, world, maxTicks: 120, out _); + + Assert.Equal(TemplateState.Complete, state); + } +} +``` + +```bash +#!/usr/bin/env bash +# tools/test-pathing-template-regressions.sh +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" +source "$REPO_ROOT/tools/mcc-env.sh" + +VERSION="${1:-1.21.11}" +INPUT_FILE="$REPO_ROOT/mcc_input.txt" +LOG_DIR="${TMPDIR:-/tmp}/mcc-debug" +LOG_FILE="$LOG_DIR/mcc-template-regressions.log" +CFG="$LOG_DIR/MinecraftClient.template-regressions.ini" + +send_mcc() { + printf '%s\n' "$1" >> "$INPUT_FILE" +} + +wait_for_log() { + local pattern="$1" + local timeout="${2:-20}" + for _ in $(seq 1 "$timeout"); do + if grep -Fq "$pattern" "$LOG_FILE"; then + return 0 + fi + sleep 1 + done + return 1 +} + +run_case() { + local name="$1" + local command="$2" + local expected="$3" + echo "== $name ==" + : > "$LOG_FILE" + send_mcc "$command" + wait_for_log "$expected" 20 + grep -E "\\[PathMgr\\]|\\[PathExec\\]|\\[A\\*\\]" "$LOG_FILE" | tail -20 +} + +mcc-preflight "$VERSION" >/dev/null +mc-start "$VERSION" >/dev/null +mc-wait-ready "$VERSION" 60 >/dev/null +echo "Prepare temp config at $CFG before first run" +echo "Use this harness to validate:" +echo "1. flat final stop" +echo "2. parkour into L turn" +echo "3. 2x1 side wall parkour" +echo "4. 3x1 no-run-up rejection" +echo "5. ascend + descend + climb smoke" +``` + +- [ ] **Step 2: Run the full unit suite plus the real-server matrix** + +Run: + +```bash +dotnet test MinecraftClient.Tests/MinecraftClient.Tests.csproj -v minimal +dotnet build MinecraftClient.sln -c Release +bash tools/test-pathing-template-regressions.sh 1.21.11 +``` + +Expected: + +- unit tests: PASS +- build: PASS +- real server: positive evidence that flat final stop, parkour into turn, accepted 2x1 side-wall, and mixed non-parkour segments complete +- real server: positive evidence that rejected parkour shapes are rejected up front instead of failing mid-execution + +- [ ] **Step 3: Document the new reliability rule** + +```md + +## Reliability-First Execution Rule + +MCC no longer treats block-center precision as the stop criterion for path execution. +A segment is considered safely complete when the player's full support footprint remains +inside the destination block and current velocity would not carry it beyond the edge on +the next tick. + +For parkour, planning is intentionally conservative: + +- if a jump shape is not covered by deterministic simulation plus local 1.21.11 regression + evidence, reject it during planning +- if a jump is accepted, execution must land on supported destination footprint without + relying on replan to rescue overshoot +``` + +- [ ] **Step 4: Re-run the docs-adjacent validation commands** + +Run: + +```bash +dotnet test MinecraftClient.Tests/MinecraftClient.Tests.csproj -v minimal +dotnet build MinecraftClient.sln -c Release +``` + +Expected: PASS. No code or docs edits in this task should break the test suite or build. + +- [ ] **Step 5: Commit the regression matrix and documentation** + +```bash +git add MinecraftClient.Tests/Pathing/Execution/ClimbFallTemplateTests.cs \ + tools/test-pathing-template-regressions.sh \ + docs/guide/pathfinding-research.md +git commit -m "test: add pathing template regression matrix" +``` + +--- + +## Verification Checklist + +Before calling this project done, the implementing agent must have fresh evidence for all of the following: + +- `MoveParkourTests` passes +- `TemplateFootingTests` passes +- `GroundedTemplateConvergenceTests` passes +- `SprintJumpTemplateScenarioTests` passes +- `ClimbFallTemplateTests` passes +- full `MinecraftClient.Tests` project passes +- `dotnet build MinecraftClient.sln -c Release` passes +- `tools/test-pathing-template-regressions.sh 1.21.11` shows positive runtime evidence for: + - flat final stop stays within target block support + - parkour into L-turn completes without rescue replan + - accepted 2x1 side-wall jump completes + - rejected 3x1 no-run-up shape is refused by planning + - mixed ascend / descend / climb route still completes + +## Coverage Check + +This plan covers every user-facing requirement from the current thread: + +- Flat stopping is no longer centered around exact block center. +- Success is defined as not leaving the block support footprint. +- Complex parkour issues discovered in local 1.21.11 testing are addressed. +- All current template families are included, either as changed code or protected by regression tests. +- Real local server validation remains part of the definition of done. diff --git a/docs/superpowers/plans/2026-04-12-pathing-transition-braking.md b/docs/superpowers/plans/2026-04-12-pathing-transition-braking.md new file mode 100644 index 00000000..488c81b7 --- /dev/null +++ b/docs/superpowers/plans/2026-04-12-pathing-transition-braking.md @@ -0,0 +1,1640 @@ +# Pathing Transition Braking Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Build next-segment-aware path execution that clears stale input on segment completion and uses predictive braking / momentum carry so MCC can enter turns, jumps, and final stops precisely on 1.21.11. + +**Architecture:** Keep A* pathfinding unchanged and upgrade only the execution layer. First add a small regression test harness, then annotate segments with transition intent, add a deterministic braking planner, and finally let templates use that planner to either preserve momentum, coast, or brake based on the next segment. + +**Tech Stack:** C# 14 / .NET 10, MCC `PlayerPhysics`, xUnit for deterministic regression tests, existing `tools/mcc-env.sh` + local 1.21.11 server harness for end-to-end validation. + +--- + +## Execution Context + +This plan assumes implementation happens in a dedicated worktree. Do not edit the repo-root `MinecraftClient.ini`; use the existing debug harness and temporary configs under `/tmp/mcc-debug/`. + +## File Structure + +### New files + +- `MinecraftClient.Tests/MinecraftClient.Tests.csproj` + Test project for path-execution and braking regressions. +- `MinecraftClient.Tests/Pathing/Execution/PathExecutorCompletionTests.cs` + Locks in the stale-input regression where a completed segment still leaves `Forward`/`Sprint` set. +- `MinecraftClient.Tests/Pathing/Execution/PathSegmentBuilderTests.cs` + Verifies next-segment transition classification. +- `MinecraftClient.Tests/Pathing/Execution/FlatWorldTestBuilder.cs` + Minimal deterministic world builder for stone-floor braking tests. +- `MinecraftClient.Tests/Pathing/Execution/TransitionBrakingPlannerTests.cs` + Verifies coasting, back-braking, and airborne forward release decisions. +- `MinecraftClient.Tests/Pathing/Execution/TemplateBrakingTests.cs` + Verifies template-level use of the planner. +- `MinecraftClient/Pathing/Execution/PathTransitionType.cs` + Enum describing the exit intent of a segment. +- `MinecraftClient/Pathing/Execution/PathSegmentBuilder.cs` + Converts `PathNode` paths into `PathSegment` lists with transition metadata. +- `MinecraftClient/Pathing/Execution/TransitionBrakingDecision.cs` + Immutable result of the braking planner. +- `MinecraftClient/Pathing/Execution/TransitionBrakingPlanner.cs` + Predictive stop-distance and airborne-release logic shared by templates. +- `tools/test-transition-braking.sh` + Local 1.21.11 regression script for flat-stop and parkour-into-turn scenarios. + +### Modified files + +- `MinecraftClient.sln` + Add the new test project. +- `MinecraftClient/Pathing/Execution/PathSegment.cs` + Add heading and transition metadata to segments. +- `MinecraftClient/Pathing/Execution/IActionTemplate.cs` + Pass `World` into template ticks so braking decisions can read friction. +- `MinecraftClient/Pathing/Execution/ActionTemplateFactory.cs` + Construct templates with the current and next segment. +- `MinecraftClient/Pathing/Execution/PathExecutor.cs` + Clear inputs on completion/failure, pass `World`, and wire next-segment context into templates. +- `MinecraftClient/Pathing/Execution/PathSegmentManager.cs` + Swap `PathSegment.FromPath` for the new builder and pass `World` to the executor. +- `MinecraftClient/Pathing/Execution/Templates/TemplateHelper.cs` + Add helpers for settled-state checks and applying braking decisions. +- `MinecraftClient/Pathing/Execution/Templates/WalkTemplate.cs` + Use predictive braking for final stops and turns. +- `MinecraftClient/Pathing/Execution/Templates/AscendTemplate.cs` + Preserve takeoff until the jump is done, then settle according to the next segment. +- `MinecraftClient/Pathing/Execution/Templates/DescendTemplate.cs` + Use post-landing braking for turns/final stops and preserve momentum for straight continuations. +- `MinecraftClient/Pathing/Execution/Templates/SprintJumpTemplate.cs` + Release `Forward`/`Sprint` early in the air when the next segment needs a stop or turn. +- `MinecraftClient/Pathing/Execution/Templates/ClimbTemplate.cs` + Signature-only change to accept `World`. +- `MinecraftClient/Pathing/Execution/Templates/FallTemplate.cs` + Signature-only change to accept `World`. +- `docs/guide/pathfinding-research.md` + Document transition-aware braking and how it differs from Baritone’s “goal block occupancy” semantics. + +--- + +### Task 1: Add the Regression Harness and Fix Stale Input on Completion + +**Files:** +- Create: `MinecraftClient.Tests/MinecraftClient.Tests.csproj` +- Create: `MinecraftClient.Tests/Pathing/Execution/PathExecutorCompletionTests.cs` +- Modify: `MinecraftClient.sln` +- Modify: `MinecraftClient/Pathing/Execution/PathExecutor.cs` + +- [ ] **Step 1: Write the failing test project and failing completion regression** + +```xml + + + + net10.0 + enable + enable + true + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + +``` + +```csharp +// MinecraftClient.Tests/Pathing/Execution/PathExecutorCompletionTests.cs +using MinecraftClient.Mapping; +using MinecraftClient.Pathing.Core; +using MinecraftClient.Pathing.Execution; +using MinecraftClient.Physics; +using Xunit; + +namespace MinecraftClient.Tests.Pathing.Execution; + +public sealed class PathExecutorCompletionTests +{ + [Fact] + public void Tick_ClearsMovementInput_WhenSegmentCompletes() + { + var executor = new PathExecutor(new List + { + new() + { + Start = new Location(0.5, 80, 0.5), + End = new Location(1.5, 80, 0.5), + MoveType = MoveType.Traverse + } + }); + + var physics = new PlayerPhysics + { + Yaw = 270f, + Pitch = 0f + }; + var input = new MovementInput(); + var pos = new Location(1.45, 80, 0.5); + + PathExecutorState state = executor.Tick(pos, physics, input); + + Assert.Equal(PathExecutorState.Complete, state); + Assert.False(input.Forward); + Assert.False(input.Sprint); + Assert.False(input.Jump); + Assert.False(input.Back); + } +} +``` + +- [ ] **Step 2: Add the test project to the solution and run the test to verify it fails** + +Run: + +```bash +dotnet sln MinecraftClient.sln add MinecraftClient.Tests/MinecraftClient.Tests.csproj +dotnet test MinecraftClient.Tests/MinecraftClient.Tests.csproj --filter Tick_ClearsMovementInput_WhenSegmentCompletes -v minimal +``` + +Expected: FAIL because `PathExecutor.Tick()` returns `Complete` while `input.Forward` is still `true`. + +- [ ] **Step 3: Write the minimal implementation in the executor** + +```csharp +// MinecraftClient/Pathing/Execution/PathExecutor.cs +public PathExecutorState Tick(Location pos, PlayerPhysics physics, MovementInput input) +{ + if (_currentTemplate is null) + { + input.Reset(); + return PathExecutorState.Complete; + } + + var state = _currentTemplate.Tick(pos, physics, input); + + switch (state) + { + case TemplateState.Complete: + input.Reset(); + _debugLog?.Invoke($"[PathExec] Segment {_currentIndex} complete " + + $"({_segments[_currentIndex].MoveType}) at ({pos.X:F2},{pos.Y:F2},{pos.Z:F2})"); + _currentIndex++; + if (_currentIndex >= _segments.Count) + { + _currentTemplate = null; + _debugLog?.Invoke("[PathExec] All segments complete!"); + return PathExecutorState.Complete; + } + AdvanceToNextSegment(); + return PathExecutorState.InProgress; + + case TemplateState.Failed: + input.Reset(); + _debugLog?.Invoke($"[PathExec] Segment {_currentIndex} FAILED " + + $"({_segments[_currentIndex].MoveType}) at ({pos.X:F2},{pos.Y:F2},{pos.Z:F2}), " + + $"target was ({_currentTemplate.ExpectedEnd.X:F2},{_currentTemplate.ExpectedEnd.Y:F2},{_currentTemplate.ExpectedEnd.Z:F2})"); + return PathExecutorState.Failed; + + default: + return PathExecutorState.InProgress; + } +} +``` + +- [ ] **Step 4: Run the test project and make sure the regression passes** + +Run: + +```bash +dotnet test MinecraftClient.Tests/MinecraftClient.Tests.csproj --filter Tick_ClearsMovementInput_WhenSegmentCompletes -v minimal +``` + +Expected: PASS + +- [ ] **Step 5: Commit** + +```bash +git add MinecraftClient.sln \ + MinecraftClient.Tests/MinecraftClient.Tests.csproj \ + MinecraftClient.Tests/Pathing/Execution/PathExecutorCompletionTests.cs \ + MinecraftClient/Pathing/Execution/PathExecutor.cs +git commit -m "test: lock path executor completion input reset" +``` + +### Task 2: Add Transition Metadata to Path Segments + +**Files:** +- Create: `MinecraftClient.Tests/Pathing/Execution/PathSegmentBuilderTests.cs` +- Create: `MinecraftClient/Pathing/Execution/PathTransitionType.cs` +- Create: `MinecraftClient/Pathing/Execution/PathSegmentBuilder.cs` +- Modify: `MinecraftClient/Pathing/Execution/PathSegment.cs` +- Modify: `MinecraftClient/Pathing/Execution/PathSegmentManager.cs` + +- [ ] **Step 1: Write failing tests for transition classification** + +```csharp +// MinecraftClient.Tests/Pathing/Execution/PathSegmentBuilderTests.cs +using MinecraftClient.Pathing.Core; +using MinecraftClient.Pathing.Execution; +using Xunit; + +namespace MinecraftClient.Tests.Pathing.Execution; + +public sealed class PathSegmentBuilderTests +{ + [Fact] + public void FromPath_AnnotatesStraightTraverse_AsContinueStraight() + { + var nodes = BuildNodes( + (0, 80, 0, MoveType.Traverse), + (1, 80, 0, MoveType.Traverse), + (2, 80, 0, MoveType.Traverse)); + + List segments = PathSegmentBuilder.FromPath(nodes); + + Assert.Equal(PathTransitionType.ContinueStraight, segments[0].ExitTransition); + Assert.True(segments[0].PreserveSprint); + } + + [Fact] + public void FromPath_AnnotatesOrthogonalTraverse_AsTurn() + { + var nodes = BuildNodes( + (0, 80, 0, MoveType.Traverse), + (1, 80, 0, MoveType.Traverse), + (1, 80, 1, MoveType.Traverse)); + + List segments = PathSegmentBuilder.FromPath(nodes); + + Assert.Equal(PathTransitionType.Turn, segments[0].ExitTransition); + Assert.False(segments[0].PreserveSprint); + } + + [Fact] + public void FromPath_AnnotatesTraverseIntoParkour_AsPrepareJump() + { + var nodes = BuildNodes( + (120, 80, 110, MoveType.Traverse), + (121, 80, 110, MoveType.Traverse), + (123, 80, 110, MoveType.Parkour)); + + List segments = PathSegmentBuilder.FromPath(nodes); + + Assert.Equal(PathTransitionType.PrepareJump, segments[0].ExitTransition); + Assert.True(segments[0].PreserveSprint); + } + + private static List BuildNodes(params (int x, int y, int z, MoveType moveUsed)[] raw) + { + var result = new List(raw.Length); + for (int i = 0; i < raw.Length; i++) + { + var node = new PathNode(raw[i].x, raw[i].y, raw[i].z); + if (i > 0) + node.MoveUsed = raw[i].moveUsed; + result.Add(node); + } + return result; + } +} +``` + +- [ ] **Step 2: Run the tests to verify they fail** + +Run: + +```bash +dotnet test MinecraftClient.Tests/MinecraftClient.Tests.csproj --filter PathSegmentBuilderTests -v minimal +``` + +Expected: FAIL because `PathSegmentBuilder` and `PathTransitionType` do not exist yet. + +- [ ] **Step 3: Add the transition enum and extend `PathSegment`** + +```csharp +// MinecraftClient/Pathing/Execution/PathTransitionType.cs +namespace MinecraftClient.Pathing.Execution +{ + public enum PathTransitionType + { + FinalStop, + ContinueStraight, + Turn, + PrepareJump, + LandingRecovery + } +} +``` + +```csharp +// MinecraftClient/Pathing/Execution/PathSegment.cs +using System; +using MinecraftClient.Mapping; +using MinecraftClient.Pathing.Core; + +namespace MinecraftClient.Pathing.Execution +{ + public sealed class PathSegment + { + public required Location Start { get; init; } + public required Location End { get; init; } + public required MoveType MoveType { get; init; } + public PathTransitionType ExitTransition { get; init; } = PathTransitionType.FinalStop; + public bool PreserveSprint { get; init; } + + public int HeadingX => Math.Sign(End.X - Start.X); + public int HeadingZ => Math.Sign(End.Z - Start.Z); + + public override string ToString() => + $"{MoveType}: ({Start.X:F1},{Start.Y:F1},{Start.Z:F1})->({End.X:F1},{End.Y:F1},{End.Z:F1}), transition={ExitTransition}, preserveSprint={PreserveSprint}"; + } +} +``` + +- [ ] **Step 4: Add the builder and switch the manager to use it** + +```csharp +// MinecraftClient/Pathing/Execution/PathSegmentBuilder.cs +using System; +using System.Collections.Generic; +using MinecraftClient.Mapping; +using MinecraftClient.Pathing.Core; + +namespace MinecraftClient.Pathing.Execution +{ + public static class PathSegmentBuilder + { + public static List FromPath(IReadOnlyList nodes) + { + var segments = new List(Math.Max(0, nodes.Count - 1)); + for (int i = 1; i < nodes.Count; i++) + { + PathSegment? next = null; + if (i + 1 < nodes.Count) + { + var nextNode = nodes[i + 1]; + var curr = nodes[i]; + next = new PathSegment + { + Start = new Location(curr.X + 0.5, curr.Y, curr.Z + 0.5), + End = new Location(nextNode.X + 0.5, nextNode.Y, nextNode.Z + 0.5), + MoveType = nextNode.MoveUsed + }; + } + + var prev = nodes[i - 1]; + var currNode = nodes[i]; + var current = new PathSegment + { + Start = new Location(prev.X + 0.5, prev.Y, prev.Z + 0.5), + End = new Location(currNode.X + 0.5, currNode.Y, currNode.Z + 0.5), + MoveType = currNode.MoveUsed + }; + + PathTransitionType exitTransition = Classify(current, next); + segments.Add(new PathSegment + { + Start = current.Start, + End = current.End, + MoveType = current.MoveType, + ExitTransition = exitTransition, + PreserveSprint = exitTransition is PathTransitionType.ContinueStraight or PathTransitionType.PrepareJump + }); + } + return segments; + } + + private static PathTransitionType Classify(PathSegment current, PathSegment? next) + { + if (next is null) + return PathTransitionType.FinalStop; + + if (next.MoveType is MoveType.Parkour or MoveType.Ascend) + return PathTransitionType.PrepareJump; + + if (current.MoveType is MoveType.Parkour or MoveType.Descend or MoveType.Fall) + return PathTransitionType.LandingRecovery; + + if (current.HeadingX == next.HeadingX && current.HeadingZ == next.HeadingZ) + return PathTransitionType.ContinueStraight; + + return PathTransitionType.Turn; + } + } +} +``` + +```csharp +// MinecraftClient/Pathing/Execution/PathSegmentManager.cs +public void StartNavigation(IGoal goal, PathResult result) +{ + _goal = goal; + _replanCount = 0; + var segments = PathSegmentBuilder.FromPath(result.Path); + _executor = new PathExecutor(segments, _debugLog); + _infoLog?.Invoke($"[PathMgr] Navigation started: {segments.Count} segments"); +} + +private void Replan(Location pos, World world) +{ + // existing code omitted for brevity above + + var segments = PathSegmentBuilder.FromPath(result.Path); + _executor = new PathExecutor(segments, _debugLog); + _infoLog?.Invoke($"[PathMgr] Replanned: {segments.Count} segments (replan #{_replanCount})"); +} +``` + +- [ ] **Step 5: Run the tests and make sure the builder is green** + +Run: + +```bash +dotnet test MinecraftClient.Tests/MinecraftClient.Tests.csproj --filter PathSegmentBuilderTests -v minimal +``` + +Expected: PASS + +- [ ] **Step 6: Commit** + +```bash +git add MinecraftClient.Tests/Pathing/Execution/PathSegmentBuilderTests.cs \ + MinecraftClient/Pathing/Execution/PathTransitionType.cs \ + MinecraftClient/Pathing/Execution/PathSegmentBuilder.cs \ + MinecraftClient/Pathing/Execution/PathSegment.cs \ + MinecraftClient/Pathing/Execution/PathSegmentManager.cs +git commit -m "feat: annotate path segments with transition intent" +``` + +### Task 3: Add the Predictive Braking Planner + +**Files:** +- Create: `MinecraftClient.Tests/Pathing/Execution/FlatWorldTestBuilder.cs` +- Create: `MinecraftClient.Tests/Pathing/Execution/TransitionBrakingPlannerTests.cs` +- Create: `MinecraftClient/Pathing/Execution/TransitionBrakingDecision.cs` +- Create: `MinecraftClient/Pathing/Execution/TransitionBrakingPlanner.cs` + +- [ ] **Step 1: Write failing deterministic planner tests** + +```csharp +// MinecraftClient.Tests/Pathing/Execution/FlatWorldTestBuilder.cs +using MinecraftClient.Mapping; + +namespace MinecraftClient.Tests.Pathing.Execution; + +internal static class FlatWorldTestBuilder +{ + public static World CreateStoneFloor(int floorY = 79, int min = -32, int max = 32) + { + World.LoadDefaultDimensions1206Plus(); + World.SetDimension("minecraft:overworld"); + + var world = new World(); + int minChunk = (int)Math.Floor(min / 16.0); + int maxChunk = (int)Math.Floor(max / 16.0); + + for (int chunkX = minChunk; chunkX <= maxChunk; chunkX++) + { + for (int chunkZ = minChunk; chunkZ <= maxChunk; chunkZ++) + { + world[chunkX, chunkZ] = new ChunkColumn(24) { FullyLoaded = true }; + } + } + + for (int x = min; x <= max; x++) + { + for (int z = min; z <= max; z++) + { + world.SetBlock(new Location(x, floorY, z), new Block(1)); + } + } + + return world; + } +} +``` + +```csharp +// MinecraftClient.Tests/Pathing/Execution/TransitionBrakingPlannerTests.cs +using MinecraftClient.Mapping; +using MinecraftClient.Pathing.Core; +using MinecraftClient.Pathing.Execution; +using MinecraftClient.Physics; +using Xunit; + +namespace MinecraftClient.Tests.Pathing.Execution; + +public sealed class TransitionBrakingPlannerTests +{ + [Fact] + public void Plan_ReturnsCarryMomentum_ForContinueStraight() + { + World world = FlatWorldTestBuilder.CreateStoneFloor(); + var physics = CreatePhysics(0.156, 0.0, onGround: true); + var current = new PathSegment + { + Start = new Location(0.5, 80, 0.5), + End = new Location(1.5, 80, 0.5), + MoveType = MoveType.Traverse, + ExitTransition = PathTransitionType.ContinueStraight, + PreserveSprint = true + }; + + TransitionBrakingDecision decision = TransitionBrakingPlanner.Plan(current, null, new Location(1.05, 80, 0.5), physics, world); + + Assert.True(decision.HoldForward); + Assert.True(decision.HoldSprint); + Assert.False(decision.HoldBack); + } + + [Fact] + public void Plan_ReleasesForward_ForFinalStop_WhenRemainingRunwayIsTooShort() + { + World world = FlatWorldTestBuilder.CreateStoneFloor(); + var physics = CreatePhysics(0.156, 0.0, onGround: true); + var current = new PathSegment + { + Start = new Location(0.5, 80, 0.5), + End = new Location(1.5, 80, 0.5), + MoveType = MoveType.Traverse, + ExitTransition = PathTransitionType.FinalStop, + PreserveSprint = false + }; + + TransitionBrakingDecision decision = TransitionBrakingPlanner.Plan(current, null, new Location(1.38, 80, 0.5), physics, world); + + Assert.False(decision.HoldForward); + Assert.False(decision.HoldSprint); + Assert.False(decision.HoldBack); + } + + [Fact] + public void ShouldReleaseForwardInAir_ReturnsTrue_ForParkourIntoTurn() + { + var physics = CreatePhysics(0.32, 0.0, onGround: false); + var current = new PathSegment + { + Start = new Location(120.5, 80, 110.5), + End = new Location(123.5, 80, 110.5), + MoveType = MoveType.Parkour, + ExitTransition = PathTransitionType.Turn, + PreserveSprint = false + }; + var next = new PathSegment + { + Start = new Location(123.5, 80, 110.5), + End = new Location(123.5, 80, 111.5), + MoveType = MoveType.Traverse, + ExitTransition = PathTransitionType.FinalStop + }; + + bool release = TransitionBrakingPlanner.ShouldReleaseForwardInAir(current, next, new Location(123.18, 80.92, 110.5), physics); + + Assert.True(release); + } + + private static PlayerPhysics CreatePhysics(double deltaX, double deltaZ, bool onGround) + { + return new PlayerPhysics + { + Position = new Vec3d(0.0, 80.0, 0.0), + DeltaMovement = new Vec3d(deltaX, 0.0, deltaZ), + OnGround = onGround, + MovementSpeed = 0.1f, + Yaw = 270f + }; + } +} +``` + +- [ ] **Step 2: Run the planner tests to verify they fail** + +Run: + +```bash +dotnet test MinecraftClient.Tests/MinecraftClient.Tests.csproj --filter TransitionBrakingPlannerTests -v minimal +``` + +Expected: FAIL because `TransitionBrakingPlanner` and `TransitionBrakingDecision` do not exist yet. + +- [ ] **Step 3: Add the decision type** + +```csharp +// MinecraftClient/Pathing/Execution/TransitionBrakingDecision.cs +namespace MinecraftClient.Pathing.Execution +{ + public readonly record struct TransitionBrakingDecision(bool HoldForward, bool HoldSprint, bool HoldBack) + { + public static TransitionBrakingDecision CarryMomentum(bool preserveSprint) => + new(true, preserveSprint, false); + + public static TransitionBrakingDecision Coast => + new(false, false, false); + + public static TransitionBrakingDecision Brake => + new(false, false, true); + } +} +``` + +- [ ] **Step 4: Add the braking planner** + +```csharp +// MinecraftClient/Pathing/Execution/TransitionBrakingPlanner.cs +using System; +using MinecraftClient.Mapping; +using MinecraftClient.Physics; + +namespace MinecraftClient.Pathing.Execution +{ + public static class TransitionBrakingPlanner + { + private const double GroundSpeedThreshold = 0.03; + private const int MaxSimulationTicks = 12; + private const double FinalStopLead = 0.04; + private const double TurnBrakeLead = 0.08; + private const double AirReleaseLead = 0.08; + + public static TransitionBrakingDecision Plan(PathSegment current, PathSegment? next, Location pos, PlayerPhysics physics, World world) + { + if (current.ExitTransition is PathTransitionType.ContinueStraight or PathTransitionType.PrepareJump) + return TransitionBrakingDecision.CarryMomentum(current.PreserveSprint); + + double remaining = RemainingDistanceAlongSegment(current, pos); + double coastStopDistance = EstimateGroundStopDistance(physics, world, current.HeadingX, current.HeadingZ, applyBackBrake: false); + double hardBrakeDistance = EstimateGroundStopDistance(physics, world, current.HeadingX, current.HeadingZ, applyBackBrake: true); + + if (current.ExitTransition == PathTransitionType.Turn && remaining <= hardBrakeDistance + TurnBrakeLead) + return TransitionBrakingDecision.Brake; + + if (remaining <= coastStopDistance + FinalStopLead) + return TransitionBrakingDecision.Coast; + + return TransitionBrakingDecision.CarryMomentum(current.PreserveSprint); + } + + public static bool ShouldReleaseForwardInAir(PathSegment current, PathSegment? next, Location pos, PlayerPhysics physics) + { + if (current.ExitTransition is not (PathTransitionType.FinalStop or PathTransitionType.Turn or PathTransitionType.LandingRecovery)) + return false; + + double remaining = RemainingDistanceAlongSegment(current, pos); + double forwardSpeed = Math.Max(0.0, ProjectHorizontalSpeedAlongHeading(physics, current.HeadingX, current.HeadingZ)); + + return remaining <= forwardSpeed + AirReleaseLead; + } + + public static double EstimateGroundStopDistance(PlayerPhysics physics, World world, int headingX, int headingZ, bool applyBackBrake) + { + if (!physics.OnGround) + return 0.0; + + double forwardSpeed = Math.Max(0.0, ProjectHorizontalSpeedAlongHeading(physics, headingX, headingZ)); + if (forwardSpeed <= GroundSpeedThreshold) + return 0.0; + + float blockFriction = PlayerPhysics.GetMaterialFriction( + world.GetBlock(new Location(physics.Position.X, physics.Position.Y - 0.5000010, physics.Position.Z)).Type); + double drag = blockFriction * PhysicsConsts.FrictionMultiplier; + double acceleration = physics.MovementSpeed + * (PhysicsConsts.GroundAccelerationFactor / (drag * drag * drag)) + * PhysicsConsts.InputFriction; + + if (applyBackBrake) + acceleration *= 0.98; + + double distance = 0.0; + double speed = forwardSpeed; + for (int tick = 0; tick < MaxSimulationTicks; tick++) + { + distance += speed; + speed = applyBackBrake + ? Math.Max(0.0, (speed - acceleration) * drag) + : speed * drag; + + if (speed <= GroundSpeedThreshold) + break; + } + + return distance; + } + + private static double RemainingDistanceAlongSegment(PathSegment current, Location pos) + { + double dx = current.End.X - pos.X; + double dz = current.End.Z - pos.Z; + return dx * current.HeadingX + dz * current.HeadingZ; + } + + private static double ProjectHorizontalSpeedAlongHeading(PlayerPhysics physics, int headingX, int headingZ) + { + return physics.DeltaMovement.X * headingX + physics.DeltaMovement.Z * headingZ; + } + } +} +``` + +- [ ] **Step 5: Run the tests and make sure the planner is green** + +Run: + +```bash +dotnet test MinecraftClient.Tests/MinecraftClient.Tests.csproj --filter TransitionBrakingPlannerTests -v minimal +``` + +Expected: PASS + +- [ ] **Step 6: Commit** + +```bash +git add MinecraftClient.Tests/Pathing/Execution/FlatWorldTestBuilder.cs \ + MinecraftClient.Tests/Pathing/Execution/TransitionBrakingPlannerTests.cs \ + MinecraftClient/Pathing/Execution/TransitionBrakingDecision.cs \ + MinecraftClient/Pathing/Execution/TransitionBrakingPlanner.cs +git commit -m "feat: add predictive transition braking planner" +``` + +### Task 4: Wire the Planner into the Templates and Executor + +**Files:** +- Create: `MinecraftClient.Tests/Pathing/Execution/TemplateBrakingTests.cs` +- Modify: `MinecraftClient/Pathing/Execution/IActionTemplate.cs` +- Modify: `MinecraftClient/Pathing/Execution/ActionTemplateFactory.cs` +- Modify: `MinecraftClient/Pathing/Execution/PathExecutor.cs` +- Modify: `MinecraftClient/Pathing/Execution/PathSegmentManager.cs` +- Modify: `MinecraftClient/Pathing/Execution/Templates/TemplateHelper.cs` +- Modify: `MinecraftClient/Pathing/Execution/Templates/WalkTemplate.cs` +- Modify: `MinecraftClient/Pathing/Execution/Templates/AscendTemplate.cs` +- Modify: `MinecraftClient/Pathing/Execution/Templates/DescendTemplate.cs` +- Modify: `MinecraftClient/Pathing/Execution/Templates/SprintJumpTemplate.cs` +- Modify: `MinecraftClient/Pathing/Execution/Templates/ClimbTemplate.cs` +- Modify: `MinecraftClient/Pathing/Execution/Templates/FallTemplate.cs` + +- [ ] **Step 1: Write failing template-level tests** + +```csharp +// MinecraftClient.Tests/Pathing/Execution/TemplateBrakingTests.cs +using MinecraftClient.Mapping; +using MinecraftClient.Pathing.Core; +using MinecraftClient.Pathing.Execution; +using MinecraftClient.Pathing.Execution.Templates; +using MinecraftClient.Physics; +using Xunit; + +namespace MinecraftClient.Tests.Pathing.Execution; + +public sealed class TemplateBrakingTests +{ + [Fact] + public void WalkTemplate_CoastsInsteadOfHoldingForward_WhenFinalStopIsClose() + { + World world = FlatWorldTestBuilder.CreateStoneFloor(); + var segment = new PathSegment + { + Start = new Location(0.5, 80, 0.5), + End = new Location(1.5, 80, 0.5), + MoveType = MoveType.Traverse, + ExitTransition = PathTransitionType.FinalStop, + PreserveSprint = false + }; + + var template = new WalkTemplate(segment, null); + var physics = new PlayerPhysics + { + Position = new Vec3d(1.38, 80.0, 0.5), + DeltaMovement = new Vec3d(0.156, 0.0, 0.0), + OnGround = true, + Yaw = 270f + }; + var input = new MovementInput(); + + TemplateState state = template.Tick(new Location(1.38, 80, 0.5), physics, input, world); + + Assert.Equal(TemplateState.InProgress, state); + Assert.False(input.Forward); + Assert.False(input.Sprint); + Assert.False(input.Back); + } + + [Fact] + public void WalkTemplate_KeepsForward_WhenTransitionContinuesStraight() + { + World world = FlatWorldTestBuilder.CreateStoneFloor(); + var current = new PathSegment + { + Start = new Location(0.5, 80, 0.5), + End = new Location(1.5, 80, 0.5), + MoveType = MoveType.Traverse, + ExitTransition = PathTransitionType.ContinueStraight, + PreserveSprint = true + }; + var next = new PathSegment + { + Start = new Location(1.5, 80, 0.5), + End = new Location(2.5, 80, 0.5), + MoveType = MoveType.Traverse, + ExitTransition = PathTransitionType.FinalStop + }; + + var template = new WalkTemplate(current, next); + var physics = new PlayerPhysics + { + Position = new Vec3d(1.10, 80.0, 0.5), + DeltaMovement = new Vec3d(0.140, 0.0, 0.0), + OnGround = true, + Yaw = 270f + }; + var input = new MovementInput(); + + TemplateState state = template.Tick(new Location(1.10, 80, 0.5), physics, input, world); + + Assert.Equal(TemplateState.InProgress, state); + Assert.True(input.Forward); + Assert.True(input.Sprint); + } +} +``` + +- [ ] **Step 2: Run the tests to verify they fail** + +Run: + +```bash +dotnet test MinecraftClient.Tests/MinecraftClient.Tests.csproj --filter TemplateBrakingTests -v minimal +``` + +Expected: FAIL because templates do not accept `PathSegment`/`World` yet and do not consult the braking planner. + +- [ ] **Step 3: Change the executor and template plumbing to pass `World` and next-segment context** + +```csharp +// MinecraftClient/Pathing/Execution/IActionTemplate.cs +using MinecraftClient.Mapping; +using MinecraftClient.Physics; + +namespace MinecraftClient.Pathing.Execution +{ + public interface IActionTemplate + { + Location ExpectedStart { get; } + Location ExpectedEnd { get; } + + TemplateState Tick(Location currentPos, PlayerPhysics physics, MovementInput input, World world); + } +} +``` + +```csharp +// MinecraftClient/Pathing/Execution/ActionTemplateFactory.cs +using System; +using MinecraftClient.Pathing.Core; +using MinecraftClient.Pathing.Execution.Templates; + +namespace MinecraftClient.Pathing.Execution +{ + public static class ActionTemplateFactory + { + public static IActionTemplate Create(PathSegment segment, PathSegment? nextSegment) + { + return segment.MoveType switch + { + MoveType.Traverse => new WalkTemplate(segment, nextSegment), + MoveType.Diagonal => new WalkTemplate(segment, nextSegment), + MoveType.Ascend => new AscendTemplate(segment, nextSegment), + MoveType.Descend => new DescendTemplate(segment, nextSegment), + MoveType.Fall => new FallTemplate(segment, nextSegment), + MoveType.Climb => new ClimbTemplate(segment, nextSegment), + MoveType.Parkour => new SprintJumpTemplate(segment, nextSegment), + _ => throw new ArgumentException($"Unknown MoveType: {segment.MoveType}") + }; + } + } +} +``` + +```csharp +// MinecraftClient/Pathing/Execution/PathExecutor.cs +public PathExecutorState Tick(Location pos, PlayerPhysics physics, MovementInput input, World world) +{ + if (_currentTemplate is null) + { + input.Reset(); + return PathExecutorState.Complete; + } + + var state = _currentTemplate.Tick(pos, physics, input, world); + + switch (state) + { + case TemplateState.Complete: + input.Reset(); + _debugLog?.Invoke($"[PathExec] Segment {_currentIndex} complete " + + $"({_segments[_currentIndex].MoveType}) at ({pos.X:F2},{pos.Y:F2},{pos.Z:F2})"); + _currentIndex++; + if (_currentIndex >= _segments.Count) + { + _currentTemplate = null; + _debugLog?.Invoke("[PathExec] All segments complete!"); + return PathExecutorState.Complete; + } + AdvanceToNextSegment(); + return PathExecutorState.InProgress; + + case TemplateState.Failed: + input.Reset(); + _debugLog?.Invoke($"[PathExec] Segment {_currentIndex} FAILED " + + $"({_segments[_currentIndex].MoveType}) at ({pos.X:F2},{pos.Y:F2},{pos.Z:F2}), " + + $"target was ({_currentTemplate.ExpectedEnd.X:F2},{_currentTemplate.ExpectedEnd.Y:F2},{_currentTemplate.ExpectedEnd.Z:F2})"); + return PathExecutorState.Failed; + + default: + return PathExecutorState.InProgress; + } +} + +private void AdvanceToNextSegment() +{ + if (_currentIndex < _segments.Count) + { + var seg = _segments[_currentIndex]; + PathSegment? next = _currentIndex + 1 < _segments.Count ? _segments[_currentIndex + 1] : null; + _currentTemplate = ActionTemplateFactory.Create(seg, next); + _debugLog?.Invoke($"[PathExec] Starting segment {_currentIndex}/{_segments.Count}: {seg}"); + } + else + { + _currentTemplate = null; + } +} +``` + +```csharp +// MinecraftClient/Pathing/Execution/PathSegmentManager.cs +public void Tick(Location pos, PlayerPhysics physics, MovementInput input, World world) +{ + if (_executor is null) + return; + + var state = _executor.Tick(pos, physics, input, world); + + switch (state) + { + case PathExecutorState.Complete: + _infoLog?.Invoke("[PathMgr] Navigation complete!"); + _executor = null; + _goal = null; + break; + + case PathExecutorState.Failed: + _infoLog?.Invoke("[PathMgr] Segment failed, replanning..."); + Replan(pos, world); + break; + } +} +``` + +- [ ] **Step 4: Wire the planner into the templates** + +```csharp +// MinecraftClient/Pathing/Execution/Templates/TemplateHelper.cs +using System; +using MinecraftClient.Mapping; +using MinecraftClient.Pathing.Execution; +using MinecraftClient.Physics; + +namespace MinecraftClient.Pathing.Execution.Templates +{ + internal static class TemplateHelper + { + // existing methods omitted + + internal static void ApplyDecision(MovementInput input, TransitionBrakingDecision decision) + { + input.Forward = decision.HoldForward; + input.Sprint = decision.HoldSprint; + input.Back = decision.HoldBack; + } + + internal static bool IsSettledAtEnd(Location pos, Location target, PlayerPhysics physics, double horizThresholdSq = 0.01, double speedThresholdSq = 0.0009) + { + double dx = target.X - pos.X; + double dz = target.Z - pos.Z; + double horizontalSpeedSq = physics.DeltaMovement.X * physics.DeltaMovement.X + + physics.DeltaMovement.Z * physics.DeltaMovement.Z; + return dx * dx + dz * dz <= horizThresholdSq && horizontalSpeedSq <= speedThresholdSq; + } + } +} +``` + +```csharp +// MinecraftClient/Pathing/Execution/Templates/WalkTemplate.cs +using System; +using MinecraftClient.Mapping; +using MinecraftClient.Pathing.Execution; +using MinecraftClient.Physics; + +namespace MinecraftClient.Pathing.Execution.Templates +{ + public sealed class WalkTemplate : IActionTemplate + { + public Location ExpectedStart { get; } + public Location ExpectedEnd { get; } + + private readonly PathSegment _segment; + private readonly PathSegment? _nextSegment; + private int _tickCount; + private Location _lastPos; + private int _stuckTicks; + + public WalkTemplate(PathSegment segment, PathSegment? nextSegment) + { + _segment = segment; + _nextSegment = nextSegment; + ExpectedStart = segment.Start; + ExpectedEnd = segment.End; + _lastPos = segment.Start; + } + + public TemplateState Tick(Location pos, PlayerPhysics physics, MovementInput input, World world) + { + _tickCount++; + + double dx = ExpectedEnd.X - pos.X; + double dz = ExpectedEnd.Z - pos.Z; + double dy = ExpectedEnd.Y - pos.Y; + float targetYaw = TemplateHelper.CalculateYaw(dx, dz); + float targetPitch = TemplateHelper.CalculatePitch(dx, dy, dz); + physics.Yaw = TemplateHelper.SmoothYaw(physics.Yaw, targetYaw); + physics.Pitch = TemplateHelper.SmoothPitch(physics.Pitch, targetPitch); + + TransitionBrakingDecision decision = TransitionBrakingPlanner.Plan(_segment, _nextSegment, pos, physics, world); + TemplateHelper.ApplyDecision(input, decision); + + if (_segment.ExitTransition == PathTransitionType.ContinueStraight && TemplateHelper.IsNear(pos, ExpectedEnd, horizThresholdSq: 0.20)) + return TemplateState.Complete; + + if (_segment.ExitTransition != PathTransitionType.ContinueStraight && TemplateHelper.IsSettledAtEnd(pos, ExpectedEnd, physics)) + return TemplateState.Complete; + + double movedSq = TemplateHelper.HorizontalDistanceSq(pos, _lastPos); + _stuckTicks = movedSq < 0.0005 ? _stuckTicks + 1 : 0; + _lastPos = pos; + + if (_stuckTicks > 40 || _tickCount > 100) + return TemplateState.Failed; + + return TemplateState.InProgress; + } + } +} +``` + +```csharp +// MinecraftClient/Pathing/Execution/Templates/AscendTemplate.cs +using System; +using MinecraftClient.Mapping; +using MinecraftClient.Pathing.Execution; +using MinecraftClient.Physics; + +namespace MinecraftClient.Pathing.Execution.Templates +{ + public sealed class AscendTemplate : IActionTemplate + { + public Location ExpectedStart { get; } + public Location ExpectedEnd { get; } + + private readonly PathSegment _segment; + private readonly PathSegment? _nextSegment; + private int _tickCount; + private Location _lastPos; + private int _stuckTicks; + + public AscendTemplate(PathSegment segment, PathSegment? nextSegment) + { + _segment = segment; + _nextSegment = nextSegment; + ExpectedStart = segment.Start; + ExpectedEnd = segment.End; + _lastPos = segment.Start; + } + + public TemplateState Tick(Location pos, PlayerPhysics physics, MovementInput input, World world) + { + _tickCount++; + + double dx = ExpectedEnd.X - pos.X; + double dz = ExpectedEnd.Z - pos.Z; + double dy = ExpectedEnd.Y - pos.Y; + float targetYaw = TemplateHelper.CalculateYaw(dx, dz); + float targetPitch = TemplateHelper.CalculatePitch(dx, dy, dz); + physics.Yaw = TemplateHelper.SmoothYaw(physics.Yaw, targetYaw); + physics.Pitch = TemplateHelper.SmoothPitch(physics.Pitch, targetPitch); + + input.Forward = true; + input.Sprint = true; + + if (physics.OnGround && dy > 0.1) + input.Jump = true; + + if (physics.OnGround && Math.Abs(dy) < 0.15) + { + TransitionBrakingDecision decision = TransitionBrakingPlanner.Plan(_segment, _nextSegment, pos, physics, world); + TemplateHelper.ApplyDecision(input, decision); + if (_segment.ExitTransition != PathTransitionType.ContinueStraight && TemplateHelper.IsSettledAtEnd(pos, ExpectedEnd, physics, horizThresholdSq: 0.02)) + return TemplateState.Complete; + } + else if (dx * dx + dz * dz < 0.25 && Math.Abs(dy) < 0.8) + { + return TemplateState.Complete; + } + + double movedSq = TemplateHelper.HorizontalDistanceSq(pos, _lastPos); + double movedY = Math.Abs(pos.Y - _lastPos.Y); + _stuckTicks = (movedSq < 0.0005 && movedY < 0.001) ? _stuckTicks + 1 : 0; + _lastPos = pos; + + if (_stuckTicks > 40 || _tickCount > 80) + return TemplateState.Failed; + + return TemplateState.InProgress; + } + } +} +``` + +```csharp +// MinecraftClient/Pathing/Execution/Templates/DescendTemplate.cs +using System; +using MinecraftClient.Mapping; +using MinecraftClient.Pathing.Execution; +using MinecraftClient.Physics; + +namespace MinecraftClient.Pathing.Execution.Templates +{ + public sealed class DescendTemplate : IActionTemplate + { + public Location ExpectedStart { get; } + public Location ExpectedEnd { get; } + + private readonly PathSegment _segment; + private readonly PathSegment? _nextSegment; + private int _tickCount; + private bool _hasFallen; + private readonly bool _needsSprint; + + public DescendTemplate(PathSegment segment, PathSegment? nextSegment) + { + _segment = segment; + _nextSegment = nextSegment; + ExpectedStart = segment.Start; + ExpectedEnd = segment.End; + double hdx = segment.End.X - segment.Start.X; + double hdz = segment.End.Z - segment.Start.Z; + _needsSprint = (hdx * hdx + hdz * hdz) > 2.25; + } + + public TemplateState Tick(Location pos, PlayerPhysics physics, MovementInput input, World world) + { + _tickCount++; + + double dx = ExpectedEnd.X - pos.X; + double dz = ExpectedEnd.Z - pos.Z; + double dy = ExpectedEnd.Y - pos.Y; + double horizDistSq = dx * dx + dz * dz; + + if (!physics.OnGround) + _hasFallen = true; + + if (_hasFallen && physics.OnGround) + { + TransitionBrakingDecision decision = TransitionBrakingPlanner.Plan(_segment, _nextSegment, pos, physics, world); + TemplateHelper.ApplyDecision(input, decision); + + if (_segment.ExitTransition == PathTransitionType.ContinueStraight && horizDistSq < 0.5 && Math.Abs(dy) < 0.8) + return TemplateState.Complete; + + if (_segment.ExitTransition != PathTransitionType.ContinueStraight && TemplateHelper.IsSettledAtEnd(pos, ExpectedEnd, physics, horizThresholdSq: 0.02)) + return TemplateState.Complete; + } + else if (horizDistSq > 0.01) + { + float targetYaw = TemplateHelper.CalculateYaw(dx, dz); + float targetPitch = TemplateHelper.CalculatePitch(dx, dy, dz); + physics.Yaw = TemplateHelper.SmoothYaw(physics.Yaw, targetYaw); + physics.Pitch = TemplateHelper.SmoothPitch(physics.Pitch, targetPitch); + input.Forward = true; + if (_needsSprint) + input.Sprint = true; + } + + if (pos.Y > ExpectedStart.Y + 2.0 || _tickCount > 200) + return TemplateState.Failed; + + return TemplateState.InProgress; + } + } +} +``` + +```csharp +// MinecraftClient/Pathing/Execution/Templates/SprintJumpTemplate.cs +using System; +using MinecraftClient.Mapping; +using MinecraftClient.Pathing.Execution; +using MinecraftClient.Physics; + +namespace MinecraftClient.Pathing.Execution.Templates +{ + public sealed class SprintJumpTemplate : IActionTemplate + { + private enum Phase { Approach, Airborne, Landing } + + public Location ExpectedStart { get; } + public Location ExpectedEnd { get; } + + private readonly PathSegment _segment; + private readonly PathSegment? _nextSegment; + private readonly double _horizDist; + private int _tickCount; + private Phase _phase = Phase.Approach; + private bool _leftGround; + + private const float YawToleranceDeg = 5f; + + public SprintJumpTemplate(PathSegment segment, PathSegment? nextSegment) + { + _segment = segment; + _nextSegment = nextSegment; + ExpectedStart = segment.Start; + ExpectedEnd = segment.End; + double dx = segment.End.X - segment.Start.X; + double dz = segment.End.Z - segment.Start.Z; + _horizDist = Math.Sqrt(dx * dx + dz * dz); + } + + public TemplateState Tick(Location pos, PlayerPhysics physics, MovementInput input, World world) + { + _tickCount++; + + double dx = ExpectedEnd.X - pos.X; + double dz = ExpectedEnd.Z - pos.Z; + double dy = ExpectedEnd.Y - pos.Y; + double horizDistSq = dx * dx + dz * dz; + + float targetYaw = TemplateHelper.CalculateYaw(dx, dz); + float targetPitch = TemplateHelper.CalculatePitch(dx, dy, dz); + physics.Yaw = TemplateHelper.SmoothYaw(physics.Yaw, targetYaw); + physics.Pitch = TemplateHelper.SmoothPitch(physics.Pitch, targetPitch); + + switch (_phase) + { + case Phase.Approach: + input.Forward = true; + input.Sprint = true; + if (physics.OnGround) + { + double fromStartSq = TemplateHelper.HorizontalDistanceSq(pos, ExpectedStart); + float yawDelta = YawDifference(physics.Yaw, targetYaw); + double minApproachSq = _horizDist >= 4.0 ? 0.36 : _horizDist > 2.5 ? 0.09 : 0.0; + if (yawDelta < YawToleranceDeg && fromStartSq >= minApproachSq) + { + input.Jump = true; + _phase = Phase.Airborne; + } + } + break; + + case Phase.Airborne: + if (!physics.OnGround) + _leftGround = true; + + bool releaseInAir = TransitionBrakingPlanner.ShouldReleaseForwardInAir(_segment, _nextSegment, pos, physics); + if (releaseInAir || IsPastTarget(pos)) + { + input.Forward = false; + input.Sprint = false; + } + else + { + input.Forward = true; + input.Sprint = true; + } + + if (_leftGround && physics.OnGround) + { + _phase = Phase.Landing; + goto case Phase.Landing; + } + break; + + case Phase.Landing: + TransitionBrakingDecision decision = TransitionBrakingPlanner.Plan(_segment, _nextSegment, pos, physics, world); + TemplateHelper.ApplyDecision(input, decision); + + if (_segment.ExitTransition == PathTransitionType.ContinueStraight && horizDistSq < 1.0 && Math.Abs(dy) < 1.0) + return TemplateState.Complete; + + if (_segment.ExitTransition != PathTransitionType.ContinueStraight && TemplateHelper.IsSettledAtEnd(pos, ExpectedEnd, physics, horizThresholdSq: 0.04)) + return TemplateState.Complete; + break; + } + + if (pos.Y < ExpectedEnd.Y - 4.0 || _tickCount > 60) + return TemplateState.Failed; + + return TemplateState.InProgress; + } + + private bool IsPastTarget(Location pos) + { + double dirX = ExpectedEnd.X - ExpectedStart.X; + double dirZ = ExpectedEnd.Z - ExpectedStart.Z; + double len = Math.Sqrt(dirX * dirX + dirZ * dirZ); + if (len < 0.001) return false; + dirX /= len; + dirZ /= len; + + double relX = pos.X - ExpectedEnd.X; + double relZ = pos.Z - ExpectedEnd.Z; + double dot = relX * dirX + relZ * dirZ; + return dot > 0.0; + } + + private static float YawDifference(float current, float target) + { + float delta = target - current; + while (delta > 180f) delta -= 360f; + while (delta < -180f) delta += 360f; + return Math.Abs(delta); + } + } +} +``` + +```csharp +// MinecraftClient/Pathing/Execution/Templates/ClimbTemplate.cs and FallTemplate.cs +// Signature-only example to apply verbatim in both files: +public TemplateState Tick(Location pos, PlayerPhysics physics, MovementInput input, World world) +{ + // existing body unchanged +} +``` + +- [ ] **Step 5: Run the test suite for the executor, planner, and templates** + +Run: + +```bash +dotnet test MinecraftClient.Tests/MinecraftClient.Tests.csproj --filter "PathExecutorCompletionTests|PathSegmentBuilderTests|TransitionBrakingPlannerTests|TemplateBrakingTests" -v minimal +``` + +Expected: PASS + +- [ ] **Step 6: Commit** + +```bash +git add MinecraftClient.Tests/Pathing/Execution/TemplateBrakingTests.cs \ + MinecraftClient/Pathing/Execution/IActionTemplate.cs \ + MinecraftClient/Pathing/Execution/ActionTemplateFactory.cs \ + MinecraftClient/Pathing/Execution/PathExecutor.cs \ + MinecraftClient/Pathing/Execution/PathSegmentManager.cs \ + MinecraftClient/Pathing/Execution/Templates/TemplateHelper.cs \ + MinecraftClient/Pathing/Execution/Templates/WalkTemplate.cs \ + MinecraftClient/Pathing/Execution/Templates/AscendTemplate.cs \ + MinecraftClient/Pathing/Execution/Templates/DescendTemplate.cs \ + MinecraftClient/Pathing/Execution/Templates/SprintJumpTemplate.cs \ + MinecraftClient/Pathing/Execution/Templates/ClimbTemplate.cs \ + MinecraftClient/Pathing/Execution/Templates/FallTemplate.cs +git commit -m "feat: wire transition braking into path templates" +``` + +### Task 5: Tune on a Real 1.21.11 Server and Document the Behavior + +**Files:** +- Create: `tools/test-transition-braking.sh` +- Modify: `MinecraftClient/Pathing/Execution/TransitionBrakingPlanner.cs` +- Modify: `docs/guide/pathfinding-research.md` + +- [ ] **Step 1: Write the failing 1.21.11 integration regression script** + +```bash +#!/usr/bin/env bash +# tools/test-transition-braking.sh +set -euo pipefail + +source "$(dirname "$0")/mcc-env.sh" + +VERSION="1.21.11" +SESSION="mcc-brake-test" +CFG="/tmp/mcc-debug/MinecraftClient.debug.ini" + +send_mcc() { + tmux send-keys -t "$SESSION" "$1" Enter +} + +capture_pane() { + tmux capture-pane -t "$SESSION" -p -S -120 +} + +extract_last_location() { + capture_pane | python3 - <<'PY' +import re +import sys + +text = sys.stdin.read() +matches = re.findall(r"Location\s+([-\d.]+),\s+([-\d.]+),\s+([-\d.]+)", text) +if not matches: + raise SystemExit("No Location line found in tmux capture") +x, y, z = matches[-1] +print(f"{x} {y} {z}") +PY +} + +assert_close() { + local actual_x="$1" + local actual_y="$2" + local actual_z="$3" + local expected_x="$4" + local expected_y="$5" + local expected_z="$6" + local tolerance="${7:-0.05}" + + python3 - <<'PY' "$actual_x" "$actual_y" "$actual_z" "$expected_x" "$expected_y" "$expected_z" "$tolerance" +import math +import sys + +ax, ay, az, ex, ey, ez, tol = map(float, sys.argv[1:]) +if abs(ax - ex) > tol or abs(ay - ey) > tol or abs(az - ez) > tol: + raise SystemExit( + f"Expected ({ex:.2f}, {ey:.2f}, {ez:.2f}) within {tol:.2f}, got ({ax:.2f}, {ay:.2f}, {az:.2f})" + ) +PY +} + +source tools/mcc-env.sh +mcc-preflight "$VERSION" >/dev/null +mc-reset-test-env "$VERSION" >/dev/null +mc-start "$VERSION" >/dev/null + +if ! tmux has-session -t "$SESSION" 2>/dev/null; then + tmux new-session -d -s "$SESSION" -x 160 -y 50 \ + "cd '$MCC_REPO' && dotnet run --project MinecraftClient -c Release --no-build -- '$CFG' CursorBot - localhost:25565; echo '=== MCC EXITED ==='; sleep 600" + sleep 5 +fi + +mc-rcon "difficulty peaceful" >/dev/null 2>&1 || true +send_mcc "/debug on" +sleep 1 + +echo "== Flat final stop ==" +mc-rcon "fill 95 79 95 115 79 105 stone" >/dev/null +mc-rcon "fill 95 80 95 115 85 105 air" >/dev/null +mc-rcon "tp CursorBot 100.5 80 100.5" >/dev/null +sleep 2 +send_mcc "/goto 103 80 100" +sleep 5 +send_mcc "/debug state" +sleep 1 +read -r x y z <<< "$(extract_last_location)" +assert_close "$x" "$y" "$z" "103.50" "80.00" "100.50" + +echo "== Parkour into turn ==" +mc-rcon "fill 118 79 108 126 79 112 air" >/dev/null +mc-rcon "setblock 120 79 110 stone" >/dev/null +mc-rcon "setblock 123 79 110 stone" >/dev/null +mc-rcon "setblock 123 79 111 stone" >/dev/null +mc-rcon "tp CursorBot 120.5 80 110.5" >/dev/null +sleep 2 +send_mcc "/goto 123 80 111" +sleep 6 +send_mcc "/debug state" +sleep 1 +read -r x y z <<< "$(extract_last_location)" +assert_close "$x" "$y" "$z" "123.50" "80.00" "111.50" + +echo "All transition braking checks passed." +``` + +- [ ] **Step 2: Run the real-server regression script and verify it fails before tuning** + +Run: + +```bash +chmod +x tools/test-transition-braking.sh +dotnet build MinecraftClient.sln -c Release +bash tools/test-transition-braking.sh +``` + +Expected: FAIL on at least one scenario because the initial planner constants will still be slightly loose on real 1.21.11 physics. + +- [ ] **Step 3: Tune the planner constants based on the live-server results** + +```csharp +// MinecraftClient/Pathing/Execution/TransitionBrakingPlanner.cs +private const double GroundSpeedThreshold = 0.025; +private const int MaxSimulationTicks = 14; +private const double FinalStopLead = 0.06; +private const double TurnBrakeLead = 0.10; +private const double AirReleaseLead = 0.14; + +public static TransitionBrakingDecision Plan(PathSegment current, PathSegment? next, Location pos, PlayerPhysics physics, World world) +{ + if (current.ExitTransition is PathTransitionType.ContinueStraight or PathTransitionType.PrepareJump) + return TransitionBrakingDecision.CarryMomentum(current.PreserveSprint); + + double remaining = RemainingDistanceAlongSegment(current, pos); + double coastStopDistance = EstimateGroundStopDistance(physics, world, current.HeadingX, current.HeadingZ, applyBackBrake: false); + double hardBrakeDistance = EstimateGroundStopDistance(physics, world, current.HeadingX, current.HeadingZ, applyBackBrake: true); + + if (current.ExitTransition == PathTransitionType.Turn && remaining <= hardBrakeDistance + TurnBrakeLead) + return TransitionBrakingDecision.Brake; + + if (remaining <= coastStopDistance + FinalStopLead) + return TransitionBrakingDecision.Coast; + + return TransitionBrakingDecision.CarryMomentum(current.PreserveSprint); +} + +public static bool ShouldReleaseForwardInAir(PathSegment current, PathSegment? next, Location pos, PlayerPhysics physics) +{ + if (current.ExitTransition is not (PathTransitionType.FinalStop or PathTransitionType.Turn or PathTransitionType.LandingRecovery)) + return false; + + double remaining = RemainingDistanceAlongSegment(current, pos); + double forwardSpeed = Math.Max(0.0, ProjectHorizontalSpeedAlongHeading(physics, current.HeadingX, current.HeadingZ)); + + return remaining <= forwardSpeed + AirReleaseLead; +} +``` + +- [ ] **Step 4: Re-run the local 1.21.11 regression script** + +Run: + +```bash +dotnet build MinecraftClient.sln -c Release +bash tools/test-transition-braking.sh +``` + +Expected: PASS with both scenarios landing within `0.05` blocks of the intended final center. + +- [ ] **Step 5: Update the pathfinding research doc** + +```md + +## Transition-Aware Braking + +MCC path execution now evaluates the next segment before finishing the current one. +The executor uses three exit styles: + +- `ContinueStraight`: finish early and preserve sprint so the next segment consumes the current velocity. +- `Turn` / `FinalStop`: release `Forward` early, then optionally tap `Back` on ground when the predicted stop distance is larger than the remaining runway. +- `PrepareJump` / `LandingRecovery`: preserve takeoff speed into jumps, but allow airborne forward release when the next segment is a turn or final stop. + +This deliberately differs from Baritone's default semantics. +Baritone treats many overshoots as success because the goal condition is usually "player feet entered the goal block". +MCC still uses block-goal semantics for path success, but the final segment controller now tries to settle near the target center on flat 1.21.11 terrain instead of accepting the old overshoot. +``` + +- [ ] **Step 6: Commit** + +```bash +git add tools/test-transition-braking.sh \ + MinecraftClient/Pathing/Execution/TransitionBrakingPlanner.cs \ + docs/guide/pathfinding-research.md +git commit -m "feat: validate and document transition-aware braking" +``` + +## Self-Review + +### Spec coverage + +- Transition-aware braking based on the next segment: covered by Task 2 and Task 3. +- Clear stale input on segment completion: covered by Task 1. +- Airborne forward release before a turn or final stop: covered by Task 3 and Task 4. +- Walk / ascend / descend / parkour execution changes: covered by Task 4. +- Real 1.21.11 validation: covered by Task 5. +- Documentation update: covered by Task 5. + +### Placeholder scan + +- No `TODO`, `TBD`, “similar to Task N”, or “write tests for the above” placeholders remain. +- Every task includes exact file paths, exact commands, and code blocks for the specific change. + +### Type consistency + +- Transition enum name is `PathTransitionType` everywhere. +- Builder name is `PathSegmentBuilder` everywhere. +- Planner name is `TransitionBrakingPlanner` everywhere. +- Planner output type is `TransitionBrakingDecision` everywhere. diff --git a/docs/superpowers/specs/2026-04-12-parkour-admissibility-design.md b/docs/superpowers/specs/2026-04-12-parkour-admissibility-design.md new file mode 100644 index 00000000..d92dae29 --- /dev/null +++ b/docs/superpowers/specs/2026-04-12-parkour-admissibility-design.md @@ -0,0 +1,38 @@ +# Parkour Admissibility Hardening + +## Context +MoveParkour currently prepares sprint jumps with some previous Baritone-inspired checks, but certain configurations (e.g., missing run-up, blocked diagonal shoulders, landing into an immediate wall) still pass planning and fail at execution. The goal is to harden those admissions so that MoveParkour rejects unsafe shapes up front. + +## Requirements +- Embed conservative versions of Baritone’s reliability-first checks for run-up length, diagonal shoulder clearance, and landing overshoot into the pathing layer. +- Keep the new logic localized under a Parkour-specific helper so that future moves can share the same checks without duplicating code. +- Tighten MoveParkour to rely on the helper for admissibility decisions and to reject overshoots instead of tolerating them with a cost penalty. +- Add deterministic tests that illustrate the three requested behaviors (3×1 jump without run-up, 2×1 jump with clear takeoff/landing, diagonal jump blocked at a shoulder). +- Run only the targeted test command once with the new test class. + +## Design + +### ParkourFeasibility helper +- Provide `ParkourFeasibility.HasRunUp(ctx, x, y, z, xOffset, zOffset, yDelta)` that reuses the existing distance thresholds (2.5 with ascend, 3.5 otherwise) but also enforces that the block immediately behind the player is walkable (top surface plus passable columns at head and neck height). +- Provide `ParkourFeasibility.HasDiagonalShoulderClearance(ctx, x, y, z, xOffset, zOffset)` that rejects diagonal jumps unless both orthogonal neighbors at start are passable through the whole torso (y through y+2) so a blocked shoulder can’t clip the AABB. +- Provide `ParkourFeasibility.HasLandingOvershootClearance(ctx, destX, destY, destZ, xSign, zSign)` that fails when the two blocks immediately past the landing spot are not passable at body and head height, preventing collisions after landing. +- Keep the helper static under `Pathing/Moves` to allow reuse by other moves in the future; assume this is acceptable even though only MoveParkour currently uses it. + +### MoveParkour adjustments +- Before the existing flight-path, head-clearance, and landing/passability checks, call into the helper to verify run-up, diagonal shoulders, and overshoot. +- Remove the informational overshoot-penalty branch and instead treat blocked overshoot as an immediate rejection. +- Leave the current flight path, head clearance, and destination checks untouched to avoid regressions. + +### Testing +- Add `MinecraftClient.Tests.Pathing.Moves.MoveParkourTests` that reuse a flat stone world and toggle blocks to create the three scenarios: + 1. 3×1 side-wall jump lacking a run-up (expect `MoveResult.IsImpossible`). + 2. 2×1 jump with clear takeoff and landing (expect success and the expected destination). + 3. Diagonal jump whose start cardinal neighbor is blocked at shoulder height (expect rejection). +- Each test creates the context with `allowParkour: true`, instantiates the appropriate `MoveParkour`, runs `Calculate`, and asserts on `IsImpossible`. +- Tests will live next to other pathing tests but focus narrowly on parkour admissibility. + +## Validation +- Run `dotnet test MinecraftClient.Tests --filter MoveParkourTests`. + +## Open questions +- I assumed the helper should be reusable beyond MoveParkour; if you prefer it to stay internal, I can adjust the visibility surface. From f0b79d5f9ce08ec32fad788ad079ea1f69215f53 Mon Sep 17 00:00:00 2001 From: BruceChen Date: Sun, 12 Apr 2026 20:35:02 +0000 Subject: [PATCH 33/37] feat: enhance MCC runtime management with dynamic launcher detection and improved error handling --- tools/mcc-debug.sh | 23 +++++++++++++++++------ tools/mcc-env.sh | 23 +++++++++++++++++++++++ tools/test-mcc-env.sh | 20 ++++++++++++++++++++ 3 files changed, 60 insertions(+), 6 deletions(-) diff --git a/tools/mcc-debug.sh b/tools/mcc-debug.sh index d0ca0e78..769ff4cc 100644 --- a/tools/mcc-debug.sh +++ b/tools/mcc-debug.sh @@ -42,7 +42,6 @@ DO_BUILD=true DEBUG_ON=false FILE_INPUT=false BUILD_ROOT="$(_mcc_build_root)" -BUILD_ROOT_ENV_PREFIX="" while [[ $# -gt 0 ]]; do case "$1" in @@ -102,8 +101,6 @@ fi if [[ "${MCC_BUILD_MODE:-local}" == "tmpfs" ]]; then mkdir -p "$BUILD_ROOT" - printf -v BUILD_ROOT_QUOTED '%q' "$BUILD_ROOT" - BUILD_ROOT_ENV_PREFIX="MCC_BUILD_ROOT=$BUILD_ROOT_QUOTED " fi SESSION_ROOT="$(_mcc_session_root "$SESSION")" @@ -224,11 +221,25 @@ rm -f "$PID_FILE" MCC_ARGS=("$CFG" "$USERNAME" "-" "localhost:$PORT") MCC_ARGS_CMD="$(printf '%q ' "${MCC_ARGS[@]}")" +RUNTIME_APP="$(_mcc_runtime_app_path || true)" +if [[ -z "$RUNTIME_APP" ]]; then + echo " Failed to find built MCC runtime under $(_mcc_runtime_output_dir)" >&2 + echo " Build first with: source tools/mcc-env.sh && mcc-build" >&2 + exit 1 +fi + +if [[ "$RUNTIME_APP" == *.dll ]]; then + MCC_LAUNCHER=(dotnet "$RUNTIME_APP") +else + MCC_LAUNCHER=("$RUNTIME_APP") +fi +MCC_LAUNCHER_CMD="$(printf '%q ' "${MCC_LAUNCHER[@]}")" + if [[ "$MODE" == "tui" ]]; then # TUI mode: needs a real tty - no pipes or redirects allowed tmux kill-session -t "$MCC_TMUX_SESSION" 2>/dev/null || true tmux new-session -d -s "$MCC_TMUX_SESSION" -x 160 -y 50 \ - "cd '$REPO_ROOT' && ${BUILD_ROOT_ENV_PREFIX}dotnet run --project MinecraftClient -c Release --no-build -- $MCC_ARGS_CMD; echo '=== MCC EXITED ==='; sleep 600" + "cd '$REPO_ROOT' && $MCC_LAUNCHER_CMD $MCC_ARGS_CMD; echo '=== MCC EXITED ==='; sleep 600" echo "" echo " TUI mode started in tmux session '$MCC_TMUX_SESSION'" echo " (TUI mode uses a real terminal; log file is not available, use MCC's /debug command)" @@ -241,7 +252,7 @@ elif $FILE_INPUT; then # FileInput mode: run in detached tmux, drive via session-specific input file tmux kill-session -t "$MCC_TMUX_SESSION" 2>/dev/null || true tmux new-session -d -s "$MCC_TMUX_SESSION" -x 160 -y 50 \ - "cd '$REPO_ROOT' && printf '%s\n' \"\$\$\" > '$PID_FILE' && exec env ${BUILD_ROOT_ENV_PREFIX}MCC_FILE_INPUT=1 MCC_INPUT_FILE='$INPUT_FILE' dotnet run --project MinecraftClient -c Release --no-build -- $MCC_ARGS_CMD > '$MCC_LOG' 2>&1" + "cd '$REPO_ROOT' && printf '%s\n' \"\$\$\" > '$PID_FILE' && exec env MCC_FILE_INPUT=1 MCC_INPUT_FILE='$INPUT_FILE' $MCC_LAUNCHER_CMD $MCC_ARGS_CMD > '$MCC_LOG' 2>&1" for _ in $(seq 1 25); do if [[ -s "$PID_FILE" ]]; then @@ -289,7 +300,7 @@ else # Interactive classic mode: run in tmux (no pipe - ConsoleInteractive also needs tty) tmux kill-session -t "$MCC_TMUX_SESSION" 2>/dev/null || true tmux new-session -d -s "$MCC_TMUX_SESSION" -x 160 -y 50 \ - "cd '$REPO_ROOT' && ${BUILD_ROOT_ENV_PREFIX}dotnet run --project MinecraftClient -c Release --no-build -- $MCC_ARGS_CMD; echo '=== MCC EXITED ==='; sleep 600" + "cd '$REPO_ROOT' && $MCC_LAUNCHER_CMD $MCC_ARGS_CMD; echo '=== MCC EXITED ==='; sleep 600" echo "" echo " Classic mode started in tmux session '$MCC_TMUX_SESSION'" echo "" diff --git a/tools/mcc-env.sh b/tools/mcc-env.sh index 437c5c9a..e05cb8a9 100644 --- a/tools/mcc-env.sh +++ b/tools/mcc-env.sh @@ -121,6 +121,29 @@ _mcc_build_root() { printf '%s\n' "$MCC_REPO_ROOT" } +_mcc_runtime_output_dir() { + printf '%s/MinecraftClient/bin/Release/net10.0\n' "$(_mcc_build_root)" +} + +_mcc_runtime_app_path() { + local runtime_dir runtime_host runtime_dll + runtime_dir="$(_mcc_runtime_output_dir)" + runtime_host="$runtime_dir/MinecraftClient" + runtime_dll="$runtime_dir/MinecraftClient.dll" + + if [[ -x "$runtime_host" ]]; then + printf '%s\n' "$runtime_host" + return 0 + fi + + if [[ -f "$runtime_dll" ]]; then + printf '%s\n' "$runtime_dll" + return 0 + fi + + return 1 +} + _mcc_dotnet_env() { if [[ "${MCC_BUILD_MODE:-local}" == "tmpfs" ]]; then local build_root diff --git a/tools/test-mcc-env.sh b/tools/test-mcc-env.sh index 38c50c84..ba7cf7e0 100755 --- a/tools/test-mcc-env.sh +++ b/tools/test-mcc-env.sh @@ -82,6 +82,26 @@ mkdir -p "$build_root/probe" mcc-build-clean [[ ! -e "$build_root/probe" ]] +runtime_dir="$(_mcc_runtime_output_dir)" +mkdir -p "$runtime_dir" +printf '#!/usr/bin/env bash\n' > "$runtime_dir/MinecraftClient" +chmod +x "$runtime_dir/MinecraftClient" +printf '' > "$runtime_dir/MinecraftClient.dll" +assert_eq "$runtime_dir/MinecraftClient" "$(_mcc_runtime_app_path)" "runtime apphost preferred" + +rm -f "$runtime_dir/MinecraftClient" +assert_eq "$runtime_dir/MinecraftClient.dll" "$(_mcc_runtime_app_path)" "runtime dll fallback" + +rm -f "$runtime_dir/MinecraftClient.dll" +set +e +_mcc_runtime_app_path >/dev/null 2>&1 +status=$? +set -e +if [[ $status -eq 0 ]]; then + echo "FAIL: runtime app path resolved without runtime artifacts" >&2 + exit 1 +fi + session="wrapper-smoke" input_file="$(_mcc_session_input_file "$session")" rm -rf "$(_mcc_session_root "$session")" From 2b8a8113f5fae5c83889d10b3c80b84d3c89b170 Mon Sep 17 00:00:00 2001 From: BruceChen Date: Sun, 12 Apr 2026 20:48:30 +0000 Subject: [PATCH 34/37] refactor: update MCC commands and documentation to use version 1.21.11-Vanilla for consistency across workflows --- .skills/mcc-dev-workflow/SKILL.md | 42 +- AGENTS.md | 2 +- docs/guide/ai-assisted-development.md | 6 +- ...-12-mcc-shared-server-isolated-sessions.md | 24 +- ...-12-pathing-live-regression-convergence.md | 2 +- ...04-12-pathing-lookahead-entry-contracts.md | 1064 +++++++++++++++++ ...2026-04-12-pathing-template-convergence.md | 6 +- .../2026-04-12-pathing-transition-braking.md | 2 +- ...red-server-isolated-mcc-sessions-design.md | 6 +- tools/README.md | 4 +- tools/mcc-debug.sh | 1 - tools/test-parkour.sh | 2 +- tools/test-pathing-template-regressions.sh | 2 +- tools/test-transition-braking.sh | 2 +- 14 files changed, 1114 insertions(+), 51 deletions(-) create mode 100644 docs/superpowers/plans/2026-04-12-pathing-lookahead-entry-contracts.md diff --git a/.skills/mcc-dev-workflow/SKILL.md b/.skills/mcc-dev-workflow/SKILL.md index 06614892..54b24c9f 100644 --- a/.skills/mcc-dev-workflow/SKILL.md +++ b/.skills/mcc-dev-workflow/SKILL.md @@ -13,7 +13,7 @@ Use this skill when the task needs a real local server loop, not just code readi - Runtime target: `.NET 10` / `net10.0` - Environment: Linux, macOS, or WSL with Java, tmux, python3, and dotnet available - Default server root after `source tools/mcc-env.sh`: `${MCC_SERVERS:-/MinecraftOfficial/downloads}` -- Default validation target when the user does not specify a version: `1.21.11` +- Default validation target when the user does not specify a server directory: `1.21.11-Vanilla` ## Console modes @@ -51,13 +51,13 @@ Two worktrees can debug against one shared server like this: # worktree A cd ~/Minecraft/Minecraft-Console-Client source tools/mcc-env.sh -mc-start 1.21.11 -mcc-debug -v 1.21.11 --file-input +mc-start 1.21.11-Vanilla +mcc-debug -v 1.21.11-Vanilla --file-input # worktree B cd ~/Minecraft/Minecraft-Console-Client-foo source tools/mcc-env.sh -mcc-debug -v 1.21.11 --file-input +mcc-debug -v 1.21.11-Vanilla --file-input # from each worktree, mcc-* targets that worktree's default session mcc-state @@ -84,8 +84,8 @@ Before scripted runs, especially on macOS or in a reused tmux environment: ```bash source tools/mcc-env.sh -mcc-preflight 1.21.11 -mc-reset-test-env 1.21.11 +mcc-preflight 1.21.11-Vanilla +mc-reset-test-env 1.21.11-Vanilla ``` `mcc-preflight` checks Java, tmux, dotnet, python3, and server directories. It also resolves common Homebrew Java paths on macOS. `mc-reset-test-env` clears stale tmux sessions and stale `stdin.pipe` files before they turn into misleading startup failures. @@ -107,16 +107,16 @@ Interactive shell: source tools/mcc-env.sh SESSION="$(_mcc_resolve_session)" USERNAME="$(_mcc_resolve_username "$SESSION")" -mc-start 1.21.11 -mc-log 1.21.11 100 +mc-start 1.21.11-Vanilla +mc-log 1.21.11-Vanilla 100 mc-rcon "op $USERNAME" -mc-stop 1.21.11 +mc-stop 1.21.11-Vanilla ``` Non-interactive shell: ```bash -tools/start-server.sh 1.21.11 +tools/start-server.sh 1.21.11-Vanilla tools/mc-rcon.sh "op mcc_smoke_a" ``` @@ -135,19 +135,19 @@ The `tools/mcc-debug.sh` script handles build, server startup, config preparatio source tools/mcc-env.sh # Classic mode with FileInput (script-driven debugging): -mcc-debug -v 1.21.11 --file-input +mcc-debug -v 1.21.11-Vanilla --file-input # Classic mode interactive (attach via tmux): -mcc-debug -v 1.21.11 +mcc-debug -v 1.21.11-Vanilla # TUI mode: -mcc-debug -v 1.21.11 -m tui +mcc-debug -v 1.21.11-Vanilla -m tui # With debug messages enabled from start: -mcc-debug -v 1.21.11 --file-input --debug-on +mcc-debug -v 1.21.11-Vanilla --file-input --debug-on # Skip build (already built): -mcc-debug -v 1.21.11 --file-input --no-build +mcc-debug -v 1.21.11-Vanilla --file-input --no-build ``` ### What mcc-debug.sh does @@ -202,7 +202,7 @@ For agents calling MCC commands programmatically: ```bash source tools/mcc-env.sh SESSION="smoke-a" -mcc-debug -v 1.21.11 --file-input --session "$SESSION" --no-build +mcc-debug -v 1.21.11-Vanilla --file-input --session "$SESSION" --no-build # Send commands: mcc-cmd --session "$SESSION" "debug state" @@ -215,7 +215,7 @@ mcc-log-mcc --session "$SESSION" # Stop: mcc-cmd --session "$SESSION" "quit" mcc-kill --session "$SESSION" -mc-stop 1.21.11 +mc-stop 1.21.11-Vanilla ``` ### Interactive workflow @@ -223,7 +223,7 @@ mc-stop 1.21.11 ```bash source tools/mcc-env.sh SESSION="live-a" -mcc-debug -v 1.21.11 --session "$SESSION" +mcc-debug -v 1.21.11-Vanilla --session "$SESSION" # In another terminal: tmux attach -t "mcc-$SESSION" @@ -245,7 +245,7 @@ TUI mode runs Consolonia full-screen in a tmux session. Key differences: ```bash source tools/mcc-env.sh SESSION="tui-a" -mcc-debug -v 1.21.11 -m tui --session "$SESSION" --no-build +mcc-debug -v 1.21.11-Vanilla -m tui --session "$SESSION" --no-build # Cannot use mcc-cmd (no FileInput); must use tmux send-keys: tmux send-keys -t "mcc-$SESSION" "/debug state" Enter @@ -330,12 +330,12 @@ If a scripted run fails before MCC joins, check for a harness problem before ass ## Typical debug loop 1. `source tools/mcc-env.sh` -2. `mcc-debug -v 1.21.11 --file-input` (or `-m tui`) +2. `mcc-debug -v 1.21.11-Vanilla --file-input` (or `-m tui`) 3. Confirm `Server was successfully joined` in log 4. `mcc-cmd "debug state"` to verify MCC state 5. Run test commands 6. Inspect log output -7. `mcc-cmd "quit"` and `mc-stop 1.21.11` +7. `mcc-cmd "quit"` and `mc-stop 1.21.11-Vanilla` 8. Edit code, rebuild, repeat ## Debugging tips diff --git a/AGENTS.md b/AGENTS.md index b82cc5bf..9294cacb 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -10,7 +10,7 @@ - Init submodules first: `git submodule update --init --recursive` - Build for local development: `source tools/mcc-env.sh && mcc-build` - Publish (matches CI shape): `source tools/mcc-env.sh && mcc-publish --rid ` -- Run/debug from source: `source tools/mcc-env.sh && mcc-debug -v 1.21.11 --file-input` +- Run/debug from source: `source tools/mcc-env.sh && mcc-debug -v 1.21.11-Vanilla --file-input` - Docs: `cd docs && npm install && npm run docs:dev` or `npm run docs:build` - Docker: `cd Docker && docker build -t minecraft-console-client:latest .` - Tests: no dedicated test project is present in the main solution. diff --git a/docs/guide/ai-assisted-development.md b/docs/guide/ai-assisted-development.md index 17024496..4313d67c 100644 --- a/docs/guide/ai-assisted-development.md +++ b/docs/guide/ai-assisted-development.md @@ -458,13 +458,13 @@ Two worktrees can share one local server like this: # worktree A cd ~/Minecraft/Minecraft-Console-Client source tools/mcc-env.sh -mc-start 1.21.11 -mcc-debug -v 1.21.11 --file-input +mc-start 1.21.11-Vanilla +mcc-debug -v 1.21.11-Vanilla --file-input # worktree B cd ~/Minecraft/Minecraft-Console-Client-foo source tools/mcc-env.sh -mcc-debug -v 1.21.11 --file-input +mcc-debug -v 1.21.11-Vanilla --file-input # from each worktree, mcc-* targets that worktree's default session mcc-state diff --git a/docs/superpowers/plans/2026-04-12-mcc-shared-server-isolated-sessions.md b/docs/superpowers/plans/2026-04-12-mcc-shared-server-isolated-sessions.md index 975787b9..330cd918 100644 --- a/docs/superpowers/plans/2026-04-12-mcc-shared-server-isolated-sessions.md +++ b/docs/superpowers/plans/2026-04-12-mcc-shared-server-isolated-sessions.md @@ -320,7 +320,7 @@ Run: ```bash source tools/mcc-env.sh -mcc-debug -v 1.21.11 --file-input --no-build +mcc-debug -v 1.21.11-Vanilla --file-input --no-build ls -la /tmp/mcc-debug tmux list-sessions | grep '^mcc-debug:' ``` @@ -432,8 +432,8 @@ Run: ```bash source tools/mcc-env.sh -mcc-debug -v 1.21.11 --session smoke-a --username SmokeA --file-input --no-build -mcc-debug -v 1.21.11 --session smoke-b --username SmokeB --file-input --no-build +mcc-debug -v 1.21.11-Vanilla --session smoke-a --username SmokeA --file-input --no-build +mcc-debug -v 1.21.11-Vanilla --session smoke-b --username SmokeB --file-input --no-build test -f "$(_mcc_session_log_file smoke-a)" test -f "$(_mcc_session_log_file smoke-b)" test -f "$(_mcc_session_meta_file smoke-a)" @@ -602,7 +602,7 @@ REPO_ROOT="$(cd "$SCRIPT_DIR/../../.." && pwd)" source "$REPO_ROOT/tools/mcc-env.sh" source "$SCRIPT_DIR/common.sh" -VERSION="${1:-1.21.11}" +VERSION="${1:-1.21.11-Vanilla}" SESSION_A="parallel-a" SESSION_B="parallel-b" USER_A="ParallelA" @@ -631,7 +631,7 @@ mc-log "$VERSION" 50 | grep -Fq "$USER_B joined the game" - [ ] **Step 2: 运行脚本,确认它先因为新参数或旧的共享路径逻辑而失败** -Run: `bash .skills/mcc-integration-testing/scripts/run_parallel_session_smoke_test.sh 1.21.11` +Run: `bash .skills/mcc-integration-testing/scripts/run_parallel_session_smoke_test.sh 1.21.11-Vanilla` Expected: FAIL,错误类似 `Unknown option: --session`、固定 `mcc_input.txt` 被共用,或者只有一个客户端会话存活 @@ -680,9 +680,9 @@ mkdir -p "$(_mcc_session_root "$SESSION")" Run: ```bash -bash .skills/mcc-integration-testing/scripts/run_parallel_session_smoke_test.sh 1.21.11 -bash tools/run-creative-e2e.sh 1.21.11 1.21.11 modern -bash .skills/mcc-integration-testing/scripts/run_full_spectrum_test.sh 1.21.11 +bash .skills/mcc-integration-testing/scripts/run_parallel_session_smoke_test.sh 1.21.11-Vanilla +bash tools/run-creative-e2e.sh 1.21.11-Vanilla 1.21.11 modern +bash .skills/mcc-integration-testing/scripts/run_full_spectrum_test.sh 1.21.11-Vanilla ``` Expected: 三个脚本都 PASS;并行 smoke test 中一个 session 被 kill 后,另一个 session 和共享服务器继续存活 @@ -722,13 +722,13 @@ git commit -m "test: cover shared server with isolated MCC sessions" # worktree A cd ~/Minecraft/Minecraft-Console-Client source tools/mcc-env.sh -mc-start 1.21.11 -mcc-debug -v 1.21.11 --file-input +mc-start 1.21.11-Vanilla +mcc-debug -v 1.21.11-Vanilla --file-input # worktree B cd ~/Minecraft/Minecraft-Console-Client-foo source tools/mcc-env.sh -mcc-debug -v 1.21.11 --file-input +mcc-debug -v 1.21.11-Vanilla --file-input # Each worktree gets: # - its own session @@ -761,7 +761,7 @@ Run: bash tools/test-mcc-env.sh source tools/mcc-env.sh && unset MCC_BUILD_MODE && mcc-build source tools/mcc-env.sh && export MCC_BUILD_MODE=tmpfs && mcc-build -bash .skills/mcc-integration-testing/scripts/run_parallel_session_smoke_test.sh 1.21.11 +bash .skills/mcc-integration-testing/scripts/run_parallel_session_smoke_test.sh 1.21.11-Vanilla ``` Expected: diff --git a/docs/superpowers/plans/2026-04-12-pathing-live-regression-convergence.md b/docs/superpowers/plans/2026-04-12-pathing-live-regression-convergence.md index c853eb9f..f3738721 100644 --- a/docs/superpowers/plans/2026-04-12-pathing-live-regression-convergence.md +++ b/docs/superpowers/plans/2026-04-12-pathing-live-regression-convergence.md @@ -403,7 +403,7 @@ Expected: `Build succeeded.` Run: ```bash -bash tools/test-pathing-template-regressions.sh 1.21.11 +bash tools/test-pathing-template-regressions.sh 1.21.11-Vanilla ``` Expected: diff --git a/docs/superpowers/plans/2026-04-12-pathing-lookahead-entry-contracts.md b/docs/superpowers/plans/2026-04-12-pathing-lookahead-entry-contracts.md new file mode 100644 index 00000000..3940e4f9 --- /dev/null +++ b/docs/superpowers/plans/2026-04-12-pathing-lookahead-entry-contracts.md @@ -0,0 +1,1064 @@ +# Pathing Lookahead Entry Contracts Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Upgrade MCC path execution from coarse next-segment braking to explicit entry contracts and short-horizon input selection so turns, jump takeoffs, landings, and final stops converge reliably on 1.21.11 without overshoot. + +**Architecture:** Keep A* node expansion and move admissibility mostly unchanged. Extend `PathSegment` with quantitative transition hints derived from the next one or two segments, teach the braking planner to score candidate inputs against those hints with cloned `PlayerPhysics` simulations, and update grounded and airborne templates to hand off only when the next action's entry contract is actually satisfied. + +**Tech Stack:** C# 14 / .NET 10, MCC `PlayerPhysics`, xUnit deterministic simulation tests in `MinecraftClient.Tests`, local MCC harness via `tools/mcc-env.sh`, `mcc-build`, `mcc-debug`, `mcc-cmd`, and a shared local 1.21.11 server. + +--- + +## Execution Context + +This plan starts from the current repository state, not the older "transition braking from scratch" plan. The test project, `PathTransitionType`, `TransitionBrakingPlanner`, convergence tests, and `tools/test-transition-braking.sh` already exist. + +This plan is the follow-on slice for the later requirement from the broken conversation: + +- planner and executor should anticipate whether the next action continues momentum, requires a turn, or requires a jump takeoff +- braking may begin on the previous segment +- airborne forward release is valid and should be planned, not guessed +- "precision" means satisfying the next action's entry conditions, not snapping to exact block center + +## Scope + +In scope: + +- add explicit quantitative transition hints to `PathSegment` +- let the execution layer see beyond a coarse `Turn` / `PrepareJump` enum +- replace threshold-only braking decisions with short-horizon candidate simulation +- improve walk, descend, and sprint-jump handoff behavior +- update the local 1.21.11 regression harness to the current `mcc-dev-workflow` + +Out of scope: + +- changing A* heuristics or move costs unrelated to transition control +- adding a general-purpose "teleport to center" or velocity-zeroing cheat +- expanding the move catalog beyond current traverse / ascend / descend / parkour behavior + +## File Structure + +### New files + +- `MinecraftClient/Pathing/Execution/PathTransitionHints.cs` + Immutable quantitative exit contract for one segment: desired heading, minimum and maximum exit speed, stability requirements, and short planning horizon. +- `MinecraftClient/Pathing/Execution/TransitionInputProfile.cs` + Named candidate inputs for the planner to score, such as carry, coast, brake, airborne hold, and airborne release. +- `MinecraftClient/Pathing/Execution/TransitionLookaheadEvaluator.cs` + Clones `PlayerPhysics`, simulates candidate inputs for a small horizon, and scores them against `PathTransitionHints`. +- `MinecraftClient.Tests/Pathing/Execution/PathTransitionHintsTests.cs` + Verifies the segment builder derives correct hints for straight carry, turn entry, final stop, and prepare-jump cases. +- `MinecraftClient.Tests/Pathing/Execution/TransitionLookaheadEvaluatorTests.cs` + Verifies candidate scoring chooses carry, coast, brake, or airborne release in representative scenarios. + +### Modified files + +- `MinecraftClient/Pathing/Execution/PathSegment.cs` + Carry the quantitative exit hints alongside `ExitTransition`. +- `MinecraftClient/Pathing/Execution/PathSegmentBuilder.cs` + Compute hints from `current`, `next`, and `nextNext` segments. +- `MinecraftClient/Pathing/Execution/TransitionBrakingPlanner.cs` + Use the lookahead evaluator instead of only distance thresholds. +- `MinecraftClient/Pathing/Execution/Templates/TemplateHelper.cs` + Add heading-readiness helpers and any small shared utilities needed by the evaluator. +- `MinecraftClient/Pathing/Execution/Templates/GroundedSegmentController.cs` + Complete only when the current segment satisfies its exit hints. +- `MinecraftClient/Pathing/Execution/Templates/WalkTemplate.cs` + Stop treating "near segment end" as sufficient when the next action needs a slow turn or jump-ready takeoff. +- `MinecraftClient/Pathing/Execution/Templates/DescendTemplate.cs` + Decide whether to carry, coast, or release before stepping off a ledge when the landing must turn or stop. +- `MinecraftClient/Pathing/Execution/Templates/SprintJumpTemplate.cs` + Replace heuristic airborne release with contract-aware candidate selection. +- `MinecraftClient.Tests/Pathing/Execution/PathSegmentBuilderTests.cs` + Extend coarse transition tests to assert the new hint values. +- `MinecraftClient.Tests/Pathing/Execution/TransitionBrakingPlannerTests.cs` + Update existing planner tests to assert the new evaluator-backed choices. +- `MinecraftClient.Tests/Pathing/Execution/GroundedTemplateConvergenceTests.cs` + Add handoff scenarios that fail if the current segment arrives too fast or too slow for the next one. +- `MinecraftClient.Tests/Pathing/Execution/SprintJumpTemplateScenarioTests.cs` + Add jump-entry and landing-entry scenarios with explicit residual speed assertions. +- `MinecraftClient.Tests/Pathing/Execution/LivePathingRegressionTests.cs` + Keep a deterministic regression for the "parkour into turn" case that started this investigation. +- `tools/test-transition-braking.sh` + Move the harness to `mcc-build`, `mcc-debug`, and `mcc-cmd`, and extend it with lookahead-sensitive scenarios. +- `docs/guide/pathfinding-research.md` + Document the new rule: the executor aims for a valid next-action entry state, not a geometric center point. + +--- + +### Task 1: Add Quantitative Transition Hints to `PathSegment` + +**Files:** +- Create: `MinecraftClient/Pathing/Execution/PathTransitionHints.cs` +- Create: `MinecraftClient.Tests/Pathing/Execution/PathTransitionHintsTests.cs` +- Modify: `MinecraftClient/Pathing/Execution/PathSegment.cs` +- Modify: `MinecraftClient/Pathing/Execution/PathSegmentBuilder.cs` +- Modify: `MinecraftClient.Tests/Pathing/Execution/PathSegmentBuilderTests.cs` + +- [ ] **Step 1: Write the failing hint-derivation tests** + +```csharp +// MinecraftClient.Tests/Pathing/Execution/PathTransitionHintsTests.cs +using System.Collections.Generic; +using MinecraftClient.Pathing.Core; +using MinecraftClient.Pathing.Execution; +using Xunit; + +namespace MinecraftClient.Tests.Pathing.Execution; + +public sealed class PathTransitionHintsTests +{ + [Fact] + public void FromPath_AssignsTurnHints_WhenNextSegmentChangesHeading() + { + var nodes = BuildNodes( + (0, 80, 0, MoveType.Traverse), + (1, 80, 0, MoveType.Traverse), + (1, 80, 1, MoveType.Traverse)); + + List segments = PathSegmentBuilder.FromPath(nodes); + PathTransitionHints hints = segments[0].ExitHints; + + Assert.Equal(PathTransitionType.Turn, segments[0].ExitTransition); + Assert.True(hints.RequireStableFooting); + Assert.True(hints.RequireGrounded); + Assert.Equal(0, hints.DesiredHeadingX); + Assert.Equal(1, hints.DesiredHeadingZ); + Assert.InRange(hints.MaxExitSpeed, 0.0, 0.05); + } + + [Fact] + public void FromPath_AssignsJumpReadyHints_WhenNextSegmentIsParkour() + { + var nodes = BuildNodes( + (120, 80, 110, MoveType.Traverse), + (121, 80, 110, MoveType.Traverse), + (123, 80, 110, MoveType.Parkour)); + + List segments = PathSegmentBuilder.FromPath(nodes); + PathTransitionHints hints = segments[0].ExitHints; + + Assert.Equal(PathTransitionType.PrepareJump, segments[0].ExitTransition); + Assert.True(hints.RequireJumpReady); + Assert.False(hints.RequireStableFooting); + Assert.Equal(1, hints.DesiredHeadingX); + Assert.Equal(0, hints.DesiredHeadingZ); + Assert.True(hints.MinExitSpeed >= 0.10, $"MinExitSpeed={hints.MinExitSpeed}"); + } + + [Fact] + public void FromPath_AssignsPreciseStopHints_WhenSegmentIsFinalStop() + { + var nodes = BuildNodes( + (10, 80, 10, MoveType.Traverse), + (11, 80, 10, MoveType.Traverse)); + + List segments = PathSegmentBuilder.FromPath(nodes); + PathTransitionHints hints = segments[0].ExitHints; + + Assert.Equal(PathTransitionType.FinalStop, segments[0].ExitTransition); + Assert.True(hints.RequireStableFooting); + Assert.True(hints.RequireGrounded); + Assert.InRange(hints.MaxExitSpeed, 0.0, 0.02); + } + + private static List BuildNodes(params (int x, int y, int z, MoveType moveUsed)[] raw) + { + var result = new List(raw.Length); + for (int i = 0; i < raw.Length; i++) + { + var node = new PathNode(raw[i].x, raw[i].y, raw[i].z); + if (i > 0) + node.MoveUsed = raw[i].moveUsed; + result.Add(node); + } + + return result; + } +} +``` + +- [ ] **Step 2: Run the tests to verify they fail** + +Run: + +```bash +dotnet test MinecraftClient.Tests/MinecraftClient.Tests.csproj --filter "FullyQualifiedName~PathTransitionHintsTests|FullyQualifiedName~PathSegmentBuilderTests" -v minimal +``` + +Expected: FAIL with compile errors because `PathTransitionHints` and `PathSegment.ExitHints` do not exist yet. + +- [ ] **Step 3: Implement the hint type and derive it in the segment builder** + +```csharp +// MinecraftClient/Pathing/Execution/PathTransitionHints.cs +namespace MinecraftClient.Pathing.Execution +{ + public sealed record PathTransitionHints( + int DesiredHeadingX, + int DesiredHeadingZ, + double MinExitSpeed, + double MaxExitSpeed, + bool RequireStableFooting, + bool RequireGrounded, + bool RequireJumpReady, + bool AllowAirBrake, + int HorizonTicks) + { + public static PathTransitionHints Default { get; } = new( + DesiredHeadingX: 0, + DesiredHeadingZ: 0, + MinExitSpeed: 0.0, + MaxExitSpeed: double.PositiveInfinity, + RequireStableFooting: false, + RequireGrounded: false, + RequireJumpReady: false, + AllowAirBrake: false, + HorizonTicks: 8); + } +} +``` + +```csharp +// MinecraftClient/Pathing/Execution/PathSegment.cs +public sealed class PathSegment +{ + public required Location Start { get; init; } + public required Location End { get; init; } + public required MoveType MoveType { get; init; } + public PathTransitionType ExitTransition { get; init; } = PathTransitionType.FinalStop; + public PathTransitionHints ExitHints { get; init; } = PathTransitionHints.Default; + public bool PreserveSprint { get; init; } + + public int HeadingX => Math.Sign(End.X - Start.X); + public int HeadingZ => Math.Sign(End.Z - Start.Z); +} +``` + +```csharp +// MinecraftClient/Pathing/Execution/PathSegmentBuilder.cs +public static List FromPath(IReadOnlyList nodes) +{ + var segments = new List(Math.Max(0, nodes.Count - 1)); + for (int i = 1; i < nodes.Count; i++) + { + PathSegment? next = i + 1 < nodes.Count ? CreatePreview(nodes[i], nodes[i + 1]) : null; + PathSegment? nextNext = i + 2 < nodes.Count ? CreatePreview(nodes[i + 1], nodes[i + 2]) : null; + PathSegment current = CreatePreview(nodes[i - 1], nodes[i]); + + PathTransitionType exitTransition = Classify(current, next); + PathTransitionHints exitHints = BuildHints(current, next, nextNext, exitTransition); + + segments.Add(new PathSegment + { + Start = current.Start, + End = current.End, + MoveType = current.MoveType, + ExitTransition = exitTransition, + ExitHints = exitHints, + PreserveSprint = exitTransition is PathTransitionType.ContinueStraight or PathTransitionType.PrepareJump + }); + } + + return segments; +} + +private static PathTransitionHints BuildHints(PathSegment current, PathSegment? next, PathSegment? nextNext, PathTransitionType exitTransition) +{ + if (next is null) + { + return new PathTransitionHints( + current.HeadingX, + current.HeadingZ, + MinExitSpeed: 0.0, + MaxExitSpeed: 0.01, + RequireStableFooting: true, + RequireGrounded: true, + RequireJumpReady: false, + AllowAirBrake: false, + HorizonTicks: 12); + } + + if (next.MoveType is MoveType.Parkour or MoveType.Ascend) + { + double minExitSpeed = next.MoveType == MoveType.Parkour ? 0.12 : 0.10; + return new PathTransitionHints( + next.HeadingX, + next.HeadingZ, + MinExitSpeed: minExitSpeed, + MaxExitSpeed: double.PositiveInfinity, + RequireStableFooting: false, + RequireGrounded: true, + RequireJumpReady: true, + AllowAirBrake: false, + HorizonTicks: 10); + } + + bool turning = current.HeadingX != next.HeadingX || current.HeadingZ != next.HeadingZ; + bool nextImmediatelyJumps = nextNext is not null && nextNext.MoveType is MoveType.Parkour or MoveType.Ascend; + + if (turning) + { + return new PathTransitionHints( + next.HeadingX, + next.HeadingZ, + MinExitSpeed: nextImmediatelyJumps ? 0.08 : 0.0, + MaxExitSpeed: 0.035, + RequireStableFooting: true, + RequireGrounded: true, + RequireJumpReady: nextImmediatelyJumps, + AllowAirBrake: true, + HorizonTicks: 12); + } + + return new PathTransitionHints( + next.HeadingX, + next.HeadingZ, + MinExitSpeed: 0.08, + MaxExitSpeed: double.PositiveInfinity, + RequireStableFooting: false, + RequireGrounded: false, + RequireJumpReady: false, + AllowAirBrake: false, + HorizonTicks: 8); +} +``` + +- [ ] **Step 4: Re-run the hint tests** + +Run: + +```bash +dotnet test MinecraftClient.Tests/MinecraftClient.Tests.csproj --filter "FullyQualifiedName~PathTransitionHintsTests|FullyQualifiedName~PathSegmentBuilderTests" -v minimal +``` + +Expected: PASS with all path-segment hint tests green. + +- [ ] **Step 5: Commit the segment metadata slice** + +```bash +git add MinecraftClient/Pathing/Execution/PathTransitionHints.cs \ + MinecraftClient/Pathing/Execution/PathSegment.cs \ + MinecraftClient/Pathing/Execution/PathSegmentBuilder.cs \ + MinecraftClient.Tests/Pathing/Execution/PathTransitionHintsTests.cs \ + MinecraftClient.Tests/Pathing/Execution/PathSegmentBuilderTests.cs +git commit -m "feat: add quantitative path transition hints" +``` + +--- + +### Task 2: Replace Threshold-Only Braking with Short-Horizon Candidate Scoring + +**Files:** +- Create: `MinecraftClient/Pathing/Execution/TransitionInputProfile.cs` +- Create: `MinecraftClient/Pathing/Execution/TransitionLookaheadEvaluator.cs` +- Create: `MinecraftClient.Tests/Pathing/Execution/TransitionLookaheadEvaluatorTests.cs` +- Modify: `MinecraftClient/Pathing/Execution/TransitionBrakingPlanner.cs` +- Modify: `MinecraftClient.Tests/Pathing/Execution/TransitionBrakingPlannerTests.cs` +- Modify: `MinecraftClient/Pathing/Execution/Templates/TemplateHelper.cs` + +- [ ] **Step 1: Write the failing evaluator and planner tests** + +```csharp +// MinecraftClient.Tests/Pathing/Execution/TransitionLookaheadEvaluatorTests.cs +using MinecraftClient.Mapping; +using MinecraftClient.Pathing.Core; +using MinecraftClient.Pathing.Execution; +using MinecraftClient.Physics; +using Xunit; + +namespace MinecraftClient.Tests.Pathing.Execution; + +public sealed class TransitionLookaheadEvaluatorTests +{ + [Fact] + public void ChooseGroundProfile_PicksBrake_WhenTurnEntryCapsResidualSpeed() + { + World world = FlatWorldTestBuilder.CreateStoneFloor(); + var current = new PathSegment + { + Start = new Location(0.5, 80, 0.5), + End = new Location(1.5, 80, 0.5), + MoveType = MoveType.Traverse, + ExitTransition = PathTransitionType.Turn, + ExitHints = new PathTransitionHints(0, 1, 0.0, 0.035, true, true, false, true, 12) + }; + + var physics = new PlayerPhysics + { + Position = new Vec3d(1.34, 80.0, 0.5), + DeltaMovement = new Vec3d(0.156, 0.0, 0.0), + OnGround = true, + MovementSpeed = 0.1f, + Yaw = 270f + }; + + TransitionInputProfile profile = TransitionLookaheadEvaluator.ChooseGroundProfile( + current, + new Location(1.34, 80.0, 0.5), + physics, + world); + + Assert.Equal(TransitionInputProfile.Brake, profile); + } + + [Fact] + public void ChooseGroundProfile_PicksCarry_WhenPrepareJumpNeedsRunUpSpeed() + { + World world = FlatWorldTestBuilder.CreateStoneFloor(); + var current = new PathSegment + { + Start = new Location(0.5, 80, 0.5), + End = new Location(1.5, 80, 0.5), + MoveType = MoveType.Traverse, + ExitTransition = PathTransitionType.PrepareJump, + ExitHints = new PathTransitionHints(1, 0, 0.12, double.PositiveInfinity, false, true, true, false, 10), + PreserveSprint = true + }; + + var physics = new PlayerPhysics + { + Position = new Vec3d(1.02, 80.0, 0.5), + DeltaMovement = new Vec3d(0.086, 0.0, 0.0), + OnGround = true, + MovementSpeed = 0.1f, + Yaw = 270f + }; + + TransitionInputProfile profile = TransitionLookaheadEvaluator.ChooseGroundProfile( + current, + new Location(1.02, 80.0, 0.5), + physics, + world); + + Assert.Equal(TransitionInputProfile.Carry, profile); + } + + [Fact] + public void ChooseAirProfile_PicksRelease_WhenLandingNeedsSlowStableEntry() + { + World world = FlatWorldTestBuilder.CreateStoneFloor(min: 118, max: 126); + var current = new PathSegment + { + Start = new Location(120.5, 80, 110.5), + End = new Location(123.5, 80, 110.5), + MoveType = MoveType.Parkour, + ExitTransition = PathTransitionType.LandingRecovery, + ExitHints = new PathTransitionHints(0, 1, 0.0, 0.035, true, true, false, true, 12) + }; + + var physics = new PlayerPhysics + { + Position = new Vec3d(123.06, 80.92, 110.5), + DeltaMovement = new Vec3d(0.31, 0.0, 0.0), + OnGround = false, + MovementSpeed = 0.1f, + Yaw = 270f + }; + + TransitionInputProfile profile = TransitionLookaheadEvaluator.ChooseAirProfile( + current, + new Location(123.06, 80.92, 110.5), + physics, + world); + + Assert.Equal(TransitionInputProfile.AirRelease, profile); + } +} +``` + +- [ ] **Step 2: Run the evaluator-focused tests to verify they fail** + +Run: + +```bash +dotnet test MinecraftClient.Tests/MinecraftClient.Tests.csproj --filter "FullyQualifiedName~TransitionLookaheadEvaluatorTests|FullyQualifiedName~TransitionBrakingPlannerTests" -v minimal +``` + +Expected: FAIL with compile errors because `TransitionInputProfile` and `TransitionLookaheadEvaluator` do not exist yet. + +- [ ] **Step 3: Implement candidate profiles, lookahead scoring, and planner wiring** + +```csharp +// MinecraftClient/Pathing/Execution/TransitionInputProfile.cs +namespace MinecraftClient.Pathing.Execution +{ + internal enum TransitionInputProfile + { + Carry, + Coast, + Brake, + AirHoldForward, + AirRelease, + AirBrake + } +} +``` + +```csharp +// MinecraftClient/Pathing/Execution/TransitionLookaheadEvaluator.cs +using MinecraftClient.Mapping; +using MinecraftClient.Physics; + +namespace MinecraftClient.Pathing.Execution +{ + internal static class TransitionLookaheadEvaluator + { + internal static TransitionInputProfile ChooseGroundProfile(PathSegment segment, Location pos, PlayerPhysics physics, World world) + { + TransitionInputProfile[] candidates = + [ + TransitionInputProfile.Carry, + TransitionInputProfile.Coast, + TransitionInputProfile.Brake + ]; + + return ChooseBest(segment, pos, physics, world, candidates); + } + + internal static TransitionInputProfile ChooseAirProfile(PathSegment segment, Location pos, PlayerPhysics physics, World world) + { + TransitionInputProfile[] candidates = + [ + TransitionInputProfile.AirHoldForward, + TransitionInputProfile.AirRelease, + TransitionInputProfile.AirBrake + ]; + + return ChooseBest(segment, pos, physics, world, candidates); + } + + private static TransitionInputProfile ChooseBest(PathSegment segment, Location pos, PlayerPhysics physics, World world, TransitionInputProfile[] candidates) + { + TransitionInputProfile best = candidates[0]; + double bestScore = double.PositiveInfinity; + + foreach (TransitionInputProfile candidate in candidates) + { + double score = Score(segment, pos, physics, world, candidate); + if (score < bestScore) + { + best = candidate; + bestScore = score; + } + } + + return best; + } + + private static double Score(PathSegment segment, Location pos, PlayerPhysics physics, World world, TransitionInputProfile candidate) + { + PlayerPhysics sim = TemplateHelper.ClonePhysicsForPlanning(physics); + var input = new MovementInput(); + double score = 0.0; + + for (int tick = 0; tick < segment.ExitHints.HorizonTicks; tick++) + { + input.Reset(); + ApplyCandidateInput(input, candidate, segment.PreserveSprint); + sim.ApplyInput(input); + sim.Tick(world); + } + + Location simPos = new(sim.Position.X, sim.Position.Y, sim.Position.Z); + double forwardSpeed = TemplateHelper.ProjectHorizontalSpeedAlongSegment(sim, segment); + + if (segment.ExitHints.RequireGrounded && !sim.OnGround) + score += 1000.0; + + if (segment.ExitHints.RequireStableFooting && + !TemplateHelper.IsSettledOnTargetBlock(simPos, segment.End, sim)) + { + score += 1000.0; + } + + if (forwardSpeed < segment.ExitHints.MinExitSpeed) + score += (segment.ExitHints.MinExitSpeed - forwardSpeed) * 200.0; + + if (forwardSpeed > segment.ExitHints.MaxExitSpeed) + score += (forwardSpeed - segment.ExitHints.MaxExitSpeed) * 200.0; + + score += TemplateHelper.HeadingPenaltyDegrees(sim.Yaw, segment.ExitHints.DesiredHeadingX, segment.ExitHints.DesiredHeadingZ); + score += Math.Abs(segment.End.X - simPos.X) + Math.Abs(segment.End.Z - simPos.Z); + + return score; + } + + private static void ApplyCandidateInput(MovementInput input, TransitionInputProfile candidate, bool preserveSprint) + { + switch (candidate) + { + case TransitionInputProfile.Carry: + case TransitionInputProfile.AirHoldForward: + input.Forward = true; + input.Sprint = preserveSprint; + break; + case TransitionInputProfile.Brake: + case TransitionInputProfile.AirBrake: + input.Back = true; + break; + case TransitionInputProfile.Coast: + case TransitionInputProfile.AirRelease: + default: + break; + } + } + } +} +``` + +```csharp +// MinecraftClient/Pathing/Execution/TransitionBrakingPlanner.cs +public static TransitionBrakingDecision Plan(PathSegment current, PathSegment? next, Location pos, PlayerPhysics physics, World world) +{ + TransitionInputProfile profile = physics.OnGround + ? TransitionLookaheadEvaluator.ChooseGroundProfile(current, pos, physics, world) + : TransitionLookaheadEvaluator.ChooseAirProfile(current, pos, physics, world); + + return profile switch + { + TransitionInputProfile.Carry => TransitionBrakingDecision.CarryMomentum(current.PreserveSprint), + TransitionInputProfile.Coast => TransitionBrakingDecision.Coast, + TransitionInputProfile.Brake => TransitionBrakingDecision.Brake, + TransitionInputProfile.AirHoldForward => TransitionBrakingDecision.CarryMomentum(current.PreserveSprint), + TransitionInputProfile.AirRelease => TransitionBrakingDecision.Coast, + TransitionInputProfile.AirBrake => TransitionBrakingDecision.Brake, + _ => TransitionBrakingDecision.Coast + }; +} + +public static bool ShouldReleaseForwardInAir(PathSegment current, PathSegment? next, Location pos, PlayerPhysics physics, World world) +{ + if (!current.ExitHints.AllowAirBrake) + return false; + + TransitionInputProfile profile = TransitionLookaheadEvaluator.ChooseAirProfile(current, pos, physics, world); + return profile is TransitionInputProfile.AirRelease or TransitionInputProfile.AirBrake; +} +``` + +```csharp +// MinecraftClient/Pathing/Execution/Templates/TemplateHelper.cs +internal static PlayerPhysics ClonePhysicsForPlanning(PlayerPhysics physics) +{ + return new PlayerPhysics + { + Position = physics.Position, + DeltaMovement = physics.DeltaMovement, + Yaw = physics.Yaw, + Pitch = physics.Pitch, + OnGround = physics.OnGround, + HorizontalCollision = physics.HorizontalCollision, + VerticalCollision = physics.VerticalCollision, + VerticalCollisionBelow = physics.VerticalCollisionBelow, + FallDistance = physics.FallDistance, + StuckSpeedMultiplier = physics.StuckSpeedMultiplier, + Xxa = physics.Xxa, + Zza = physics.Zza, + Yya = physics.Yya, + Jumping = physics.Jumping, + Sprinting = physics.Sprinting, + Sneaking = physics.Sneaking, + CreativeFlying = physics.CreativeFlying, + InWater = physics.InWater, + IsUnderWater = physics.IsUnderWater, + InLava = physics.InLava, + OnClimbable = physics.OnClimbable, + HasSlowFalling = physics.HasSlowFalling, + HasLevitation = physics.HasLevitation, + LevitationAmplifier = physics.LevitationAmplifier, + MovementSpeed = physics.MovementSpeed + }; +} + +internal static double HeadingPenaltyDegrees(float yaw, int headingX, int headingZ) +{ + if (headingX == 0 && headingZ == 0) + return 0.0; + + float targetYaw = CalculateYaw(headingX, headingZ); + float delta = targetYaw - yaw; + while (delta > 180f) delta -= 360f; + while (delta < -180f) delta += 360f; + return Math.Abs(delta) / 10.0; +} +``` + +- [ ] **Step 4: Re-run the planner tests** + +Run: + +```bash +dotnet test MinecraftClient.Tests/MinecraftClient.Tests.csproj --filter "FullyQualifiedName~TransitionLookaheadEvaluatorTests|FullyQualifiedName~TransitionBrakingPlannerTests" -v minimal +``` + +Expected: PASS with all evaluator and planner tests green. + +- [ ] **Step 5: Commit the planner upgrade** + +```bash +git add MinecraftClient/Pathing/Execution/TransitionInputProfile.cs \ + MinecraftClient/Pathing/Execution/TransitionLookaheadEvaluator.cs \ + MinecraftClient/Pathing/Execution/TransitionBrakingPlanner.cs \ + MinecraftClient/Pathing/Execution/Templates/TemplateHelper.cs \ + MinecraftClient.Tests/Pathing/Execution/TransitionLookaheadEvaluatorTests.cs \ + MinecraftClient.Tests/Pathing/Execution/TransitionBrakingPlannerTests.cs +git commit -m "feat: score transition inputs with short-horizon lookahead" +``` + +--- + +### Task 3: Teach Templates to Hand Off Only When Exit Contracts Are Satisfied + +**Files:** +- Modify: `MinecraftClient/Pathing/Execution/Templates/GroundedSegmentController.cs` +- Modify: `MinecraftClient/Pathing/Execution/Templates/WalkTemplate.cs` +- Modify: `MinecraftClient/Pathing/Execution/Templates/DescendTemplate.cs` +- Modify: `MinecraftClient/Pathing/Execution/Templates/SprintJumpTemplate.cs` +- Modify: `MinecraftClient.Tests/Pathing/Execution/GroundedTemplateConvergenceTests.cs` +- Modify: `MinecraftClient.Tests/Pathing/Execution/SprintJumpTemplateScenarioTests.cs` +- Modify: `MinecraftClient.Tests/Pathing/Execution/LivePathingRegressionTests.cs` + +- [ ] **Step 1: Add failing convergence tests for turn-entry and jump-entry handoff** + +```csharp +// MinecraftClient.Tests/Pathing/Execution/GroundedTemplateConvergenceTests.cs +[Fact] +public void WalkTemplate_TurnIntoParkour_CompletesOnlyWhenTurnEntryIsSlowAndJumpReady() +{ + World world = FlatWorldTestBuilder.CreateStoneFloor(min: 120, max: 128); + + var current = new PathSegment + { + Start = new Location(120.5, 80, 110.5), + End = new Location(121.5, 80, 110.5), + MoveType = MoveType.Traverse, + ExitTransition = PathTransitionType.Turn, + ExitHints = new PathTransitionHints(0, 1, 0.08, 0.035, true, true, true, true, 12) + }; + var next = new PathSegment + { + Start = new Location(121.5, 80, 110.5), + End = new Location(121.5, 80, 111.5), + MoveType = MoveType.Traverse, + ExitTransition = PathTransitionType.PrepareJump, + ExitHints = new PathTransitionHints(0, 1, 0.12, double.PositiveInfinity, false, true, true, false, 10), + PreserveSprint = true + }; + + var template = new WalkTemplate(current, next); + var physics = TemplateSimulationRunner.CreateGroundedPhysics(current.Start, yaw: 270f); + + TemplateState state = TemplateSimulationRunner.Run(template, physics, world, maxTicks: 140, out Location finalPos); + double horizontalSpeed = Math.Sqrt(physics.DeltaMovement.X * physics.DeltaMovement.X + physics.DeltaMovement.Z * physics.DeltaMovement.Z); + + Assert.Equal(TemplateState.Complete, state); + Assert.True(TemplateFootingHelper.IsFootprintInsideTargetBlock(finalPos, current.End)); + Assert.InRange(horizontalSpeed, 0.08, 0.20); +} +``` + +```csharp +// MinecraftClient.Tests/Pathing/Execution/SprintJumpTemplateScenarioTests.cs +[Fact] +public void SprintJumpTemplate_LandingRecoveryIntoTurn_CompletesWithLowResidualSpeed() +{ + World world = FlatWorldTestBuilder.CreateStoneFloor(min: 118, max: 126); + FlatWorldTestBuilder.ClearBox(world, 118, 79, 108, 126, 90, 112); + FlatWorldTestBuilder.SetSolid(world, 120, 79, 110); + FlatWorldTestBuilder.SetSolid(world, 123, 79, 110); + FlatWorldTestBuilder.SetSolid(world, 123, 79, 111); + + var segment = new PathSegment + { + Start = new Location(120.5, 80, 110.5), + End = new Location(123.5, 80, 110.5), + MoveType = MoveType.Parkour, + ExitTransition = PathTransitionType.LandingRecovery, + ExitHints = new PathTransitionHints(0, 1, 0.0, 0.035, true, true, false, true, 12) + }; + var next = new PathSegment + { + Start = new Location(123.5, 80, 110.5), + End = new Location(123.5, 80, 111.5), + MoveType = MoveType.Traverse, + ExitTransition = PathTransitionType.FinalStop + }; + + var template = new SprintJumpTemplate(segment, next); + var physics = TemplateSimulationRunner.CreateGroundedPhysics(segment.Start, yaw: 270f); + + TemplateState state = TemplateSimulationRunner.Run(template, physics, world, maxTicks: 160, out Location finalPos); + double horizontalSpeed = Math.Sqrt(physics.DeltaMovement.X * physics.DeltaMovement.X + physics.DeltaMovement.Z * physics.DeltaMovement.Z); + + Assert.Equal(TemplateState.Complete, state); + Assert.True(TemplateFootingHelper.IsFootprintInsideTargetBlock(finalPos, segment.End)); + Assert.InRange(horizontalSpeed, 0.0, 0.04); +} +``` + +- [ ] **Step 2: Run the convergence tests to verify they fail** + +Run: + +```bash +dotnet test MinecraftClient.Tests/MinecraftClient.Tests.csproj --filter "FullyQualifiedName~GroundedTemplateConvergenceTests|FullyQualifiedName~SprintJumpTemplateScenarioTests|FullyQualifiedName~LivePathingRegressionTests" -v minimal +``` + +Expected: FAIL because grounded completion and airborne release still use coarse threshold logic. + +- [ ] **Step 3: Update grounded and airborne templates to obey `ExitHints`** + +```csharp +// MinecraftClient/Pathing/Execution/Templates/GroundedSegmentController.cs +internal static bool ShouldComplete(PathSegment segment, Location pos, PlayerPhysics physics) +{ + if (segment.ExitHints.RequireJumpReady) + { + return physics.OnGround + && TemplateHelper.HasReachedSegmentEndPlane(pos, segment) + && TemplateHelper.ProjectHorizontalSpeedAlongSegment(physics, segment) >= segment.ExitHints.MinExitSpeed + && TemplateHelper.HeadingPenaltyDegrees(physics.Yaw, segment.ExitHints.DesiredHeadingX, segment.ExitHints.DesiredHeadingZ) <= 1.0; + } + + if (segment.ExitHints.RequireStableFooting) + { + return physics.OnGround + && TemplateHelper.IsSettledOnTargetBlock(pos, segment.End, physics) + && TemplateHelper.HeadingPenaltyDegrees(physics.Yaw, segment.ExitHints.DesiredHeadingX, segment.ExitHints.DesiredHeadingZ) <= 2.0; + } + + return segment.ExitTransition switch + { + PathTransitionType.ContinueStraight => TemplateHelper.IsNear(pos, segment.End, horizThresholdSq: 0.09), + PathTransitionType.PrepareJump => TemplateHelper.HasReachedSegmentEndPlane(pos, segment) + && TemplateHelper.ProjectHorizontalSpeedAlongSegment(physics, segment) >= segment.ExitHints.MinExitSpeed, + _ => TemplateHelper.HasReachedSegmentEndPlane(pos, segment) + }; +} +``` + +```csharp +// MinecraftClient/Pathing/Execution/Templates/DescendTemplate.cs +else if (horizDistSq > 0.01) +{ + physics.Yaw = TemplateHelper.SmoothYaw(physics.Yaw, targetYaw); + if (_hasFallen || YawDifference(physics.Yaw, targetYaw) <= PreDropYawToleranceDeg) + { + TransitionBrakingDecision decision = TransitionBrakingPlanner.Plan(_segment, _nextSegment, pos, physics, world); + + if (!_hasFallen && _segment.ExitHints.AllowAirBrake && decision == TransitionBrakingDecision.Coast) + { + input.Forward = false; + input.Sprint = false; + } + else + { + TemplateHelper.ApplyDecision(input, decision); + } + } +} +``` + +```csharp +// MinecraftClient/Pathing/Execution/Templates/SprintJumpTemplate.cs +private bool ShouldReleaseInAir(Location pos, PlayerPhysics physics, World world) +{ + if (!_segment.ExitHints.AllowAirBrake) + return false; + + TransitionInputProfile profile = TransitionLookaheadEvaluator.ChooseAirProfile(_segment, pos, physics, world); + return profile is TransitionInputProfile.AirRelease or TransitionInputProfile.AirBrake; +} + +case Phase.Landing: + TransitionBrakingDecision decision = TransitionBrakingPlanner.Plan(_segment, _nextSegment, pos, physics, world); + TemplateHelper.ApplyDecision(input, decision); + + if (physics.OnGround && GroundedSegmentController.ShouldComplete(_segment, pos, physics)) + return TemplateState.Complete; + break; +``` + +- [ ] **Step 4: Re-run the convergence tests** + +Run: + +```bash +dotnet test MinecraftClient.Tests/MinecraftClient.Tests.csproj --filter "FullyQualifiedName~GroundedTemplateConvergenceTests|FullyQualifiedName~SprintJumpTemplateScenarioTests|FullyQualifiedName~LivePathingRegressionTests" -v minimal +``` + +Expected: PASS with the turn-entry and landing-entry regressions green. + +- [ ] **Step 5: Commit the template handoff slice** + +```bash +git add MinecraftClient/Pathing/Execution/Templates/GroundedSegmentController.cs \ + MinecraftClient/Pathing/Execution/Templates/WalkTemplate.cs \ + MinecraftClient/Pathing/Execution/Templates/DescendTemplate.cs \ + MinecraftClient/Pathing/Execution/Templates/SprintJumpTemplate.cs \ + MinecraftClient.Tests/Pathing/Execution/GroundedTemplateConvergenceTests.cs \ + MinecraftClient.Tests/Pathing/Execution/SprintJumpTemplateScenarioTests.cs \ + MinecraftClient.Tests/Pathing/Execution/LivePathingRegressionTests.cs +git commit -m "feat: honor transition entry contracts in templates" +``` + +--- + +### Task 4: Modernize the 1.21.11 Live Regression Harness and Document the New Semantics + +**Files:** +- Modify: `tools/test-transition-braking.sh` +- Modify: `tools/test-pathing-template-regressions.sh` +- Modify: `docs/guide/pathfinding-research.md` + +- [ ] **Step 1: Rewrite the live harness around `mcc-build`, `mcc-debug`, and `mcc-cmd`** + +```bash +# tools/test-transition-braking.sh +source "$REPO_ROOT/tools/mcc-env.sh" + +VERSION="${1:-1.21.11-Vanilla}" +SESSION="${2:-brake-lookahead}" +USERNAME="${3:-$(_mcc_resolve_username "$SESSION")}" + +send_mcc() { + mcc-cmd --session "$SESSION" "$1" +} + +start_mcc() { + mcc-build >/dev/null + mcc-debug -v "$VERSION" --file-input --session "$SESSION" --username "$USERNAME" --no-build --debug-on >/dev/null + mc-rcon "op $USERNAME" >/dev/null +} + +capture_debug_location() { + local root="${TMPDIR:-/tmp}/mcc-debug/$SESSION" + local log="$root/mcc-debug.log" + local start_line + start_line="$(wc -l < "$log")" + send_mcc "debug state" + for _ in $(seq 1 10); do + if tail -n +"$((start_line + 1))" "$log" | grep -Fq "Location:"; then + python3 - "$log" "$start_line" <<'PY' +import pathlib +import re +import sys + +path = pathlib.Path(sys.argv[1]) +start_line = int(sys.argv[2]) +text = "\n".join(path.read_text(errors="ignore").splitlines()[start_line:]) +match = re.findall(r"Location:\s+([-\d.]+),\s+([-\d.]+),\s+([-\d.]+)", text) +if not match: + raise SystemExit("No Location line found") +x, y, z = match[-1] +print(f"{x} {y} {z}") +PY + return 0 + fi + sleep 1 + done + return 1 +} +``` + +- [ ] **Step 2: Add one straight-stop scenario and one turn-into-jump scenario to the harness** + +```bash +# tools/test-transition-braking.sh +run_turn_into_jump() { + echo "== Turn into jump runway ==" + mc-rcon "fill 120 79 110 126 79 114 stone" >/dev/null + mc-rcon "fill 120 80 110 126 85 114 air" >/dev/null + mc-rcon "setblock 123 79 112 air" >/dev/null + mc-rcon "setblock 124 79 112 stone" >/dev/null + mc-rcon "tp $USERNAME 120.5 80 110.5" >/dev/null + sleep 2 + + send_mcc "goto 124 80 112" + sleep 6 + + local x y z + read -r x y z <<< "$(capture_debug_location)" + echo "Final location: $x $y $z" + + python3 - <<'PY' "$x" "$z" +import sys +x = float(sys.argv[1]) +z = float(sys.argv[2]) +if not (123.20 <= x <= 124.10 and 111.80 <= z <= 112.60): + raise SystemExit(f"Unexpected turn-into-jump finish: ({x:.2f}, {z:.2f})") +PY +} +``` + +- [ ] **Step 3: Update the pathfinding research doc to describe entry contracts** + +```md + +## Transition Entry Contracts + +Path execution no longer aims for a visual block center as the primary success rule. +Instead, each `PathSegment` carries quantitative exit hints that describe what the next +segment needs: + +- desired heading at handoff +- minimum exit speed when the next action is a jump takeoff +- maximum exit speed when the next action is a turn or final stop +- whether stable grounded footing is required before handoff +- whether airborne forward release is allowed before landing + +This keeps MCC physically honest while still making segment boundaries precise enough +for chained turns and jumps on 1.21.11. +``` + +- [ ] **Step 4: Run the full validation loop** + +Run: + +```bash +dotnet test MinecraftClient.Tests/MinecraftClient.Tests.csproj --filter "FullyQualifiedName~Pathing.Execution" -v minimal +source tools/mcc-env.sh && mcc-build +source tools/mcc-env.sh && bash tools/test-transition-braking.sh 1.21.11-Vanilla +source tools/mcc-env.sh && bash tools/test-pathing-template-regressions.sh 1.21.11-Vanilla +``` + +Expected: + +- `dotnet test` reports all pathing execution tests passing +- `mcc-build` exits `0` +- `tools/test-transition-braking.sh` prints `All transition braking checks passed.` +- `tools/test-pathing-template-regressions.sh` exits `0` + +- [ ] **Step 5: Commit the harness and documentation updates** + +```bash +git add tools/test-transition-braking.sh \ + tools/test-pathing-template-regressions.sh \ + docs/guide/pathfinding-research.md +git commit -m "test: validate lookahead path transitions on 1.21.11" +``` + +--- + +## Self-Review + +**Spec coverage** + +- "planner should know whether the next step continues or turns": covered by Task 1 transition hints and Task 2 evaluator scoring +- "braking can start on the previous step": covered by Task 2 candidate evaluation and Task 3 grounded template handoff rules +- "airborne forward release should be planned": covered by Task 2 `ChooseAirProfile()` and Task 3 `SprintJumpTemplate` +- "continue with the new dev workflow on 1.21.11": covered by Task 4 harness modernization and validation commands + +**Placeholder scan** + +- No `TODO`, `TBD`, or "implement later" markers remain +- Every code-changing step includes concrete code blocks +- Every verification step includes exact commands and expected results + +**Type consistency** + +- `PathTransitionHints` is the only new segment metadata type +- `TransitionInputProfile` is the only new candidate-input enum +- `TransitionLookaheadEvaluator` is the only new evaluator type used by `TransitionBrakingPlanner` diff --git a/docs/superpowers/plans/2026-04-12-pathing-template-convergence.md b/docs/superpowers/plans/2026-04-12-pathing-template-convergence.md index 4443aecf..0a693db8 100644 --- a/docs/superpowers/plans/2026-04-12-pathing-template-convergence.md +++ b/docs/superpowers/plans/2026-04-12-pathing-template-convergence.md @@ -861,7 +861,7 @@ SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" source "$REPO_ROOT/tools/mcc-env.sh" -VERSION="${1:-1.21.11}" +VERSION="${1:-1.21.11-Vanilla}" INPUT_FILE="$REPO_ROOT/mcc_input.txt" LOG_DIR="${TMPDIR:-/tmp}/mcc-debug" LOG_FILE="$LOG_DIR/mcc-template-regressions.log" @@ -913,7 +913,7 @@ Run: ```bash dotnet test MinecraftClient.Tests/MinecraftClient.Tests.csproj -v minimal dotnet build MinecraftClient.sln -c Release -bash tools/test-pathing-template-regressions.sh 1.21.11 +bash tools/test-pathing-template-regressions.sh 1.21.11-Vanilla ``` Expected: @@ -975,7 +975,7 @@ Before calling this project done, the implementing agent must have fresh evidenc - `ClimbFallTemplateTests` passes - full `MinecraftClient.Tests` project passes - `dotnet build MinecraftClient.sln -c Release` passes -- `tools/test-pathing-template-regressions.sh 1.21.11` shows positive runtime evidence for: +- `tools/test-pathing-template-regressions.sh 1.21.11-Vanilla` shows positive runtime evidence for: - flat final stop stays within target block support - parkour into L-turn completes without rescue replan - accepted 2x1 side-wall jump completes diff --git a/docs/superpowers/plans/2026-04-12-pathing-transition-braking.md b/docs/superpowers/plans/2026-04-12-pathing-transition-braking.md index 488c81b7..e63d155d 100644 --- a/docs/superpowers/plans/2026-04-12-pathing-transition-braking.md +++ b/docs/superpowers/plans/2026-04-12-pathing-transition-braking.md @@ -1435,7 +1435,7 @@ set -euo pipefail source "$(dirname "$0")/mcc-env.sh" -VERSION="1.21.11" +VERSION="1.21.11-Vanilla" SESSION="mcc-brake-test" CFG="/tmp/mcc-debug/MinecraftClient.debug.ini" diff --git a/docs/superpowers/specs/2026-04-12-shared-server-isolated-mcc-sessions-design.md b/docs/superpowers/specs/2026-04-12-shared-server-isolated-mcc-sessions-design.md index 9fc0e376..2312234d 100644 --- a/docs/superpowers/specs/2026-04-12-shared-server-isolated-mcc-sessions-design.md +++ b/docs/superpowers/specs/2026-04-12-shared-server-isolated-mcc-sessions-design.md @@ -199,8 +199,8 @@ Expected defaults: Example commands: ```bash -mcc-debug -v 1.21.11 --session alice-a --username AliceA --file-input -mcc-debug -v 1.21.11 --session alice-b --username AliceB --file-input +mcc-debug -v 1.21.11-Vanilla --session alice-a --username AliceA --file-input +mcc-debug -v 1.21.11-Vanilla --session alice-b --username AliceB --file-input mcc-cmd --session alice-a "debug state" mcc-log-mcc --session alice-b mcc-kill --session alice-a @@ -266,7 +266,7 @@ When possible, the error should print the resolved repo root, shared server root ### Manual Verification Matrix 1. Build from two different worktrees at the same time and confirm isolated output roots. -2. Start one shared `1.21.11` server and confirm only one `mc-1_21_11` session exists. +2. Start one shared `1.21.11-Vanilla` server and confirm only one `mc-1_21_11-Vanilla` session exists. 3. Launch two MCC sessions from two different worktrees without explicit usernames and confirm distinct derived usernames. 4. Join both clients to the shared server and confirm neither client disconnects the other. 5. Send different commands through each session's input file and confirm only the intended client responds. diff --git a/tools/README.md b/tools/README.md index be83c64d..39c686d4 100644 --- a/tools/README.md +++ b/tools/README.md @@ -10,8 +10,8 @@ The `tools/` directory also contains the shell helpers used for day-to-day MCC d ```bash source tools/mcc-env.sh -mc-start 1.21.11 -mcc-debug -v 1.21.11 --file-input +mc-start 1.21.11-Vanilla +mcc-debug -v 1.21.11-Vanilla --file-input mcc-cmd "debug state" mcc-publish --rid linux-x64 ``` diff --git a/tools/mcc-debug.sh b/tools/mcc-debug.sh index 769ff4cc..075f0a45 100644 --- a/tools/mcc-debug.sh +++ b/tools/mcc-debug.sh @@ -314,4 +314,3 @@ fi echo "Quick commands:" echo " mc-rcon 'op $USERNAME' # Give operator" echo " mc-rcon 'gamemode creative' # Creative mode" -echo " mc-stop $VERSION # shared server stays up by default; rerun with --confirm only when needed" diff --git a/tools/test-parkour.sh b/tools/test-parkour.sh index 14b73396..e94bb314 100644 --- a/tools/test-parkour.sh +++ b/tools/test-parkour.sh @@ -5,7 +5,7 @@ # Prerequisites: # - MCC connected with FileInput mode # - CursorBot is OP -# - Server at 1.21.11 +# - Server at 1.21.11-Vanilla set -euo pipefail source "$(dirname "$0")/mcc-env.sh" diff --git a/tools/test-pathing-template-regressions.sh b/tools/test-pathing-template-regressions.sh index ac878271..25689082 100644 --- a/tools/test-pathing-template-regressions.sh +++ b/tools/test-pathing-template-regressions.sh @@ -5,7 +5,7 @@ SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" source "$REPO_ROOT/tools/mcc-env.sh" -VERSION="${1:-1.21.11}" +VERSION="${1:-1.21.11-Vanilla}" SESSION="mcc-pathing-template" TEST_ROOT="${TMPDIR:-/tmp}/mcc-pathing-template" CFG="$TEST_ROOT/MinecraftClient.pathing-template.ini" diff --git a/tools/test-transition-braking.sh b/tools/test-transition-braking.sh index 0fd2c155..09768a0a 100644 --- a/tools/test-transition-braking.sh +++ b/tools/test-transition-braking.sh @@ -5,7 +5,7 @@ SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" source "$REPO_ROOT/tools/mcc-env.sh" -VERSION="${1:-1.21.11}" +VERSION="${1:-1.21.11-Vanilla}" SESSION="mcc-brake-test" TEST_ROOT="${TMPDIR:-/tmp}/mcc-debug" CFG="$TEST_ROOT/MinecraftClient.transition-braking.ini" From 31c968ffa6ec2f8a0bdd3fcb00653f32f2a66d2b Mon Sep 17 00:00:00 2001 From: BruceChen Date: Sun, 12 Apr 2026 20:49:50 +0000 Subject: [PATCH 35/37] chore: remove redundant comment from MCC debug script for clarity --- tools/mcc-debug.sh | 1 - 1 file changed, 1 deletion(-) diff --git a/tools/mcc-debug.sh b/tools/mcc-debug.sh index 075f0a45..620f5bdd 100644 --- a/tools/mcc-debug.sh +++ b/tools/mcc-debug.sh @@ -294,7 +294,6 @@ elif $FILE_INPUT; then echo " Attach (optional): tmux attach -t $MCC_TMUX_SESSION" echo " Stop MCC: echo 'quit' >> $INPUT_FILE" echo " Stop server: mc-stop $VERSION" - echo " shared servers stay up by default; rerun with --confirm only if you really need to stop it" echo "" else # Interactive classic mode: run in tmux (no pipe - ConsoleInteractive also needs tty) From d2b279974cb61c37eeb66c0cca32632d3078486a Mon Sep 17 00:00:00 2001 From: BruceChen Date: Sun, 12 Apr 2026 21:01:24 +0000 Subject: [PATCH 36/37] refactor: update decompilation process and documentation to use version naming convention with '-Vanilla' for consistency --- .skills/mcc-version-adaptation/SKILL.md | 8 ++--- docs/guide/ai-assisted-development.md | 18 +++++----- tools/README.md | 6 ++-- tools/decompile.sh | 44 ++++++++++++++++--------- 4 files changed, 46 insertions(+), 30 deletions(-) diff --git a/.skills/mcc-version-adaptation/SKILL.md b/.skills/mcc-version-adaptation/SKILL.md index 706399cb..c5cc59b4 100644 --- a/.skills/mcc-version-adaptation/SKILL.md +++ b/.skills/mcc-version-adaptation/SKILL.md @@ -12,11 +12,11 @@ Systematic workflow for updating Minecraft Console Client to support a new Minec - Decompiled server source for both the old and new MC versions in `$MCC_REPO/MinecraftOfficial/-decompiled/` - If missing, decompile and download server.jar: ```bash - $MCC_REPO/tools/decompile.sh --version + $MCC_REPO/tools/decompile.sh --version -Vanilla ``` - This auto-downloads `MinecraftDecompiler.jar` if needed, produces the decompiled source, and downloads `server.jar` into `$MCC_SERVERS//`. -- `tools/decompile.sh` depends on official mappings. For older versions where it refuses to decompile, fall back to a raw Java decompiler such as `cfr-decompiler` against `$MCC_SERVERS//server.jar`. That fallback is good enough for packet inspection and registration order checks even when the output is obfuscated. -- A test server of the target version in `$MCC_SERVERS//` (see `mcc-dev-workflow` skill) + This auto-downloads `MinecraftDecompiler.jar` if needed, produces the decompiled source under `$MCC_REPO/MinecraftOfficial/-decompiled/`, and downloads `server.jar` into `$MCC_SERVERS/-Vanilla/`. +- `tools/decompile.sh` depends on official mappings. For older versions where it refuses to decompile, fall back to a raw Java decompiler such as `cfr-decompiler` against `$MCC_SERVERS/-Vanilla/server.jar`. That fallback is good enough for packet inspection and registration order checks even when the output is obfuscated. +- A test server of the target version in `$MCC_SERVERS/-Vanilla/` (see `mcc-dev-workflow` skill) ## Step 0: Generate Server Reports (CRITICAL since 1.21.9) diff --git a/docs/guide/ai-assisted-development.md b/docs/guide/ai-assisted-development.md index 4313d67c..fa94e552 100644 --- a/docs/guide/ai-assisted-development.md +++ b/docs/guide/ai-assisted-development.md @@ -335,12 +335,12 @@ git submodule update --init --recursive From the repo root, use the decompiler helper to download the official server jar and create the decompiled source tree: ```bash -tools/decompile.sh --version 1.20.6 +tools/decompile.sh --version 1.20.6-Vanilla ``` That creates the paths used by the harness and the version-adaptation workflow: -- `$MCC_SERVERS/1.20.6/server.jar` +- `$MCC_SERVERS/1.20.6-Vanilla/server.jar` - `MinecraftOfficial/1.20.6-decompiled/` If you are doing protocol work, this step is not optional. @@ -544,13 +544,13 @@ This is the core loop you should expect an agent to follow. source tools/mcc-env.sh SESSION="smoke-a" USERNAME="$(_mcc_resolve_username "$SESSION")" -mc-start 1.20.6 +mc-start 1.20.6-Vanilla ``` Check the recent server output: ```bash -mc-log 1.20.6 +mc-log 1.20.6-Vanilla ``` ### 2. Build MCC @@ -562,7 +562,7 @@ mcc-build ### 3. Run MCC with file input enabled ```bash -mcc-debug -v 1.20.6 --file-input --session "$SESSION" --no-build +mcc-debug -v 1.20.6-Vanilla --file-input --session "$SESSION" --no-build ``` ### 4. Set up server state through RCON @@ -665,7 +665,7 @@ The important rule is simple: The usual order is: -1. `tools/decompile.sh --version ` +1. `tools/decompile.sh --version -Vanilla` 2. generate server reports from `server.jar` 3. run `tools/diff_registries.py` 4. regenerate the palettes that actually changed @@ -692,9 +692,9 @@ Typical loop: source tools/mcc-env.sh SESSION="smoke-a" USERNAME="$(_mcc_resolve_username "$SESSION")" -mc-start 1.20.6 +mc-start 1.20.6-Vanilla mcc-build -mcc-debug -v 1.20.6 --file-input --session "$SESSION" --no-build +mcc-debug -v 1.20.6-Vanilla --file-input --session "$SESSION" --no-build mc-rcon "op $USERNAME" mcc-cmd --session "$SESSION" "inventory player list" mcc-cmd --session "$SESSION" "entity" @@ -737,7 +737,7 @@ Use skills: Typical flow: ```bash -tools/decompile.sh --version 26.1 +tools/decompile.sh --version 26.1-Vanilla ``` Generate server reports: diff --git a/tools/README.md b/tools/README.md index 39c686d4..c7a13648 100644 --- a/tools/README.md +++ b/tools/README.md @@ -51,13 +51,15 @@ Two types of data can be used as input: ### Decompiling a new MC version ```bash -# Server side (default) — also downloads server.jar into MinecraftOfficial/downloads// -tools/decompile.sh --version 1.21.9 +# Server side (default) — downloads server.jar into $MCC_SERVERS/-Vanilla/ +tools/decompile.sh --version 1.21.9-Vanilla # Client side tools/decompile.sh --version 1.21.9 --side CLIENT ``` +For server-side runs, the decompiled source still lands in `MinecraftOfficial/-decompiled/`, while the runnable local server directory becomes `$MCC_SERVERS/-Vanilla/`. + If you keep server assets outside the repo, set `MCC_SERVERS=/path/to/servers` before using `tools/mcc-env.sh` or `tools/start-server.sh`. The script auto-downloads `MinecraftDecompiler.jar` from GitHub releases if it doesn't exist. diff --git a/tools/decompile.sh b/tools/decompile.sh index 791431ba..9f922b8c 100644 --- a/tools/decompile.sh +++ b/tools/decompile.sh @@ -6,13 +6,14 @@ # ./tools/decompile.sh --version [--side SERVER|CLIENT] # # Examples: -# ./tools/decompile.sh --version 1.21.11 +# ./tools/decompile.sh --version 1.21.11-Vanilla # ./tools/decompile.sh --version 1.21.11 --side CLIENT set -euo pipefail REPO_ROOT="$(cd "$(dirname "$0")/.." && pwd)" MC_OFFICIAL="$REPO_ROOT/MinecraftOfficial" +SERVERS_ROOT="${MCC_SERVERS:-$MC_OFFICIAL/downloads}" DECOMPILER_JAR="$MC_OFFICIAL/MinecraftDecompiler.jar" DECOMPILER_REPO="MaxPixelStudios/MinecraftDecompiler" @@ -27,7 +28,7 @@ while [[ $# -gt 0 ]]; do echo "Usage: $0 --version [--side SERVER|CLIENT]" echo "" echo "Options:" - echo " --version Minecraft version (e.g. 1.21.11)" + echo " --version Minecraft version or local server dir (e.g. 1.21.11 or 1.21.11-Vanilla)" echo " --side SERVER (default) or CLIENT" exit 0 ;; @@ -46,6 +47,16 @@ if [[ "$SIDE" != "SERVER" && "$SIDE" != "CLIENT" ]]; then exit 1 fi +MC_VERSION="${VERSION%-Vanilla}" +if [[ -z "$MC_VERSION" ]]; then + MC_VERSION="$VERSION" +fi + +SERVER_DIR_NAME="$VERSION" +if [[ "$SIDE" == "SERVER" && "$VERSION" != *-Vanilla ]]; then + SERVER_DIR_NAME="${MC_VERSION}-Vanilla" +fi + # --- Ensure MinecraftDecompiler.jar exists --- if [[ ! -f "$DECOMPILER_JAR" ]]; then echo "MinecraftDecompiler.jar not found, downloading latest release..." @@ -71,11 +82,11 @@ fi SIDE_LOWER="$(echo "$SIDE" | tr '[:upper:]' '[:lower:]')" if [[ "$SIDE" == "SERVER" ]]; then - REMAPPED_JAR="$MC_OFFICIAL/remapped_jar/${VERSION}-remapped.jar" - DECOMPILED_DIR="$MC_OFFICIAL/${VERSION}-decompiled" + REMAPPED_JAR="$MC_OFFICIAL/remapped_jar/${MC_VERSION}-remapped.jar" + DECOMPILED_DIR="$MC_OFFICIAL/${MC_VERSION}-decompiled" else - REMAPPED_JAR="$MC_OFFICIAL/remapped_jar/${VERSION}-${SIDE_LOWER}-remapped.jar" - DECOMPILED_DIR="$MC_OFFICIAL/${VERSION}-${SIDE_LOWER}-decompiled" + REMAPPED_JAR="$MC_OFFICIAL/remapped_jar/${MC_VERSION}-${SIDE_LOWER}-remapped.jar" + DECOMPILED_DIR="$MC_OFFICIAL/${MC_VERSION}-${SIDE_LOWER}-decompiled" fi if [[ -d "$DECOMPILED_DIR" ]]; then @@ -92,12 +103,12 @@ VERSION_URL=$(curl -sL "$MANIFEST_URL" | python3 -c " import json, sys data = json.load(sys.stdin) for v in data['versions']: - if v['id'] == '$VERSION': + if v['id'] == '$MC_VERSION': print(v['url']) break ") if [[ -z "$VERSION_URL" ]]; then - echo "Error: version $VERSION not found in Mojang launcher manifest." + echo "Error: version $MC_VERSION not found in Mojang launcher manifest." exit 1 fi @@ -109,9 +120,12 @@ data = json.load(sys.stdin) print('true' if '$MAPPING_KEY' in data.get('downloads', {}) else 'false') ") -echo "=== Decompiling Minecraft $VERSION ($SIDE) ===" +echo "=== Decompiling Minecraft $MC_VERSION ($SIDE) ===" echo " Remapped JAR: $REMAPPED_JAR" echo " Decompiled: $DECOMPILED_DIR" +if [[ "$SIDE" == "SERVER" ]]; then + echo " Server dir: $SERVERS_ROOT/$SERVER_DIR_NAME" +fi echo " Obfuscated: $HAS_MAPPINGS" echo "" @@ -120,7 +134,7 @@ cd "$MC_OFFICIAL" if [[ "$HAS_MAPPINGS" == "true" ]]; then # Obfuscated version: use --version/--side to auto-download jar + mappings + deobfuscate java -jar "$DECOMPILER_JAR" \ - --version "$VERSION" \ + --version "$MC_VERSION" \ --side "$SIDE" \ --decompile \ --output "$REMAPPED_JAR" \ @@ -129,14 +143,14 @@ else # Unobfuscated version (26.1+): download jar, extract inner jar from bundle, decompile directly. # MinecraftDecompiler requires --mapping-path with --input, but unobfuscated versions # have no mappings. We use Vineflower directly instead. - echo "No Proguard mappings for $VERSION; decompiling without deobfuscation." + echo "No Proguard mappings for $MC_VERSION; decompiling without deobfuscation." JAR_URL=$(echo "$VERSION_META" | python3 -c " import json, sys data = json.load(sys.stdin) print(data['downloads']['${SIDE_LOWER}']['url']) ") - ORIGINAL_JAR="$MC_OFFICIAL/remapped_jar/${VERSION}-${SIDE_LOWER}-original.jar" + ORIGINAL_JAR="$MC_OFFICIAL/remapped_jar/${MC_VERSION}-${SIDE_LOWER}-original.jar" if [[ ! -f "$ORIGINAL_JAR" ]]; then echo "Downloading ${SIDE_LOWER}.jar ..." curl -L -o "$ORIGINAL_JAR" "$JAR_URL" @@ -175,13 +189,13 @@ echo "" echo "=== Done ===" echo "Decompiled source: $DECOMPILED_DIR" -# --- For SERVER side, also ensure downloads//server.jar exists --- +# --- For SERVER side, also ensure downloads//server.jar exists --- if [[ "$SIDE" == "SERVER" ]]; then - DOWNLOADS_DIR="$MC_OFFICIAL/downloads/$VERSION" + DOWNLOADS_DIR="$SERVERS_ROOT/$SERVER_DIR_NAME" if [[ ! -f "$DOWNLOADS_DIR/server.jar" ]]; then mkdir -p "$DOWNLOADS_DIR" echo "" - echo "Downloading server.jar for $VERSION into $DOWNLOADS_DIR ..." + echo "Downloading server.jar for $MC_VERSION into $DOWNLOADS_DIR ..." SERVER_JAR_URL=$(echo "$VERSION_META" | python3 -c " import json, sys data = json.load(sys.stdin) From 7ad0a57a3ea157aed395667c15cd3ba5e345ab2f Mon Sep 17 00:00:00 2001 From: BruceChen Date: Mon, 13 Apr 2026 15:02:12 +0000 Subject: [PATCH 37/37] docs: add theory-aligned pathing regression design --- ...heory-aligned-pathing-regression-design.md | 207 ++++++++++++++++++ 1 file changed, 207 insertions(+) create mode 100644 docs/superpowers/specs/2026-04-13-theory-aligned-pathing-regression-design.md diff --git a/docs/superpowers/specs/2026-04-13-theory-aligned-pathing-regression-design.md b/docs/superpowers/specs/2026-04-13-theory-aligned-pathing-regression-design.md new file mode 100644 index 00000000..b10f943f --- /dev/null +++ b/docs/superpowers/specs/2026-04-13-theory-aligned-pathing-regression-design.md @@ -0,0 +1,207 @@ +# Theory-Aligned Pathing Regression + +## Context +MCC already has two useful but separate assets for pathing and parkour validation: + +- [tools/sim_jump_reach.py](/home/ryan/Minecraft/Minecraft-Console-Client-milutinke/tools/sim_jump_reach.py) models a subset of vanilla jump reachability and can answer whether specific jump shapes are theoretically reachable. +- The live harness scripts under [tools/](/home/ryan/Minecraft/Minecraft-Console-Client-milutinke/tools) validate real MCC behavior on a local server, but they currently act as curated scenario suites rather than a stable projection of one theoretical source of truth. + +The immediate goal is to make the simulator the authority for first-wave jump capability claims, then align a smaller live regression layer to that authority. This first wave must stay intentionally narrow: it should cover only movement families already modeled by `sim_jump_reach.py`, not every higher-level execution behavior MCC currently exercises live. + +## Requirements +- Treat `tools/sim_jump_reach.py` as the authority for first-wave jump capability expectations. +- Restrict first-wave coverage to movement families already modeled by the simulator: + - linear flat jumps + - linear ascend jumps + - linear descend jumps + - neo jumps + - ceiling-constrained or headhitter jumps +- Produce both machine-readable and human-readable theory outputs from the same source data. +- Define live regression coverage through canonical buckets, not by replaying every theoretical case. +- Ensure every theory-aligned live case can be traced back to one or more theory case IDs. +- Keep existing specialized live suites available, but do not treat them as part of the first-wave theory authority. +- Preserve the current MCC local workflow based on `tools/mcc-env.sh`, `mcc-debug`, tmux-backed local sessions, and shared local servers. + +## Design + +### Recommended approach +Three approaches were considered: + +1. Hand-maintain theory expectations and live cases separately. +2. Make the simulator authoritative, then select canonical live buckets from its output. +3. Fully auto-generate all live cases from simulator output. + +Approach 2 is the recommended first-wave design. It keeps one theory authority, creates a stable contract for live coverage, and avoids over-scoping the first iteration with full live generation. + +### Capability layers +The regression system should be split into three layers with explicit responsibilities: + +- Theory matrix + - Generated from `tools/sim_jump_reach.py`. + - Defines what MCC is expected to support for the first-wave movement families. +- Canonical live coverage + - Derived from the theory matrix by bucket rules. + - Validates representative easy, boundary, and reject scenarios on a real server. +- Specialized live suites + - Existing higher-level pathing suites such as mixed-route, braking, or landing-recovery scenarios. + - Remain valuable, but are explicitly outside the first-wave theory contract until their behaviors also have a stable theoretical source. + +This separation prevents higher-level execution scenarios from contaminating the meaning of the first-wave authority layer. + +### Theory matrix schema +The theory matrix should be stored as a fine-grained case table. Each row represents one distinct theoretical movement judgment. The table should include at least: + +- `case_id` +- `family` +- `subfamily` +- `movement_mode` +- `momentum_ticks` +- `gap_blocks` +- `delta_y` +- `ceiling_height` +- `wall_width` +- `expected_reachable` +- `landing_x` +- `apex_y` +- `margin` +- `notes` + +Recommended family and subfamily values for the first wave: + +- `linear` + - `flat` + - `ascend` + - `descend` +- `neo` +- `ceiling` + - `headhitter` + +The important contract is that `expected_reachable` comes from the simulator, not from handwritten shell-script expectations. + +### Canonical bucket model +Live coverage should not replay every theoretical case. Instead, the theory matrix should be grouped into canonical buckets that classify the live representative scenarios. Each canonical bucket should have stable dimensions: + +- `family` +- `subfamily` +- `movement_mode` +- `difficulty_band` + +The first-wave difficulty bands are: + +- `easy` + - clearly reachable with generous margin +- `boundary` + - close to the theoretical edge and most likely to regress +- `reject` + - theoretically unreachable and expected to be rejected live + +Each canonical live case must reference: + +- `case_id` +- `bucket_id` +- `expected_result` +- `world_recipe_id` +- `start` +- `goal` + +This ensures the live harness is executing a curated projection of the theory matrix rather than inventing expectations independently. + +### First-wave movement scope +The first-wave theory authority covers only what `sim_jump_reach.py` already models directly: + +- linear flat jumps +- linear ascend jumps +- linear descend jumps +- neo jumps +- ceiling-constrained or headhitter jumps + +The first wave explicitly does not promote these existing live-only behaviors into theory authority: + +- repeated parkour chains +- parkour landing recovery into turns +- braking and speed-carry transitions +- mixed long-route execution +- segment-to-segment transition behavior + +Those scenarios remain useful, but they belong to specialized live suites until a simulator-backed authority exists for them. + +### Output artifacts +The simulator-backed generation step should produce three synchronized outputs from the same in-memory data: + +- JSON + - primary machine-readable artifact for automation +- CSV + - convenient for inspection, filtering, and quick diffs +- Markdown + - human-readable capability summary and bucket overview + +The design requires these outputs to be generated in one pass so they cannot silently drift apart. + +### Live suite reorganization +The first-wave live layer should be organized into theory-aligned and specialized suites. + +Theory-aligned suites: + +- Refactor [tools/test-parkour.sh](/home/ryan/Minecraft/Minecraft-Console-Client-milutinke/tools/test-parkour.sh) into the main theory-aligned linear-jump suite. +- Add a dedicated live suite for neo and ceiling-constrained cases. + +Specialized live suites retained outside the theory contract: + +- [tools/test-pathing-jump-combos.sh](/home/ryan/Minecraft/Minecraft-Console-Client-milutinke/tools/test-pathing-jump-combos.sh) +- [tools/test-pathing-template-regressions.sh](/home/ryan/Minecraft/Minecraft-Console-Client-milutinke/tools/test-pathing-template-regressions.sh) +- [tools/test-pathing-long-routes.sh](/home/ryan/Minecraft/Minecraft-Console-Client-milutinke/tools/test-pathing-long-routes.sh) +- [tools/test-transition-braking.sh](/home/ryan/Minecraft/Minecraft-Console-Client-milutinke/tools/test-transition-braking.sh) + +This lets MCC keep broader pathing smoke coverage without pretending every advanced live script is already grounded in the simulator. + +### Execution and comparison flow +The first-wave regression pipeline should be one directional: + +1. Generate the full theory matrix from `sim_jump_reach.py`. +2. Derive canonical buckets and canonical live cases from that matrix. +3. Run the theory-aligned live suites against the canonical live case set. +4. Join live results back to theory case IDs and produce a comparison report. + +Live suites must not encode the truth model themselves. They are executors and verifiers only. + +### Result model +The comparison layer should use these result classes: + +- `expected_pass / live_pass` +- `expected_pass / live_fail` +- `expected_reject / live_reject` +- `expected_reject / live_unexpected_pass` +- `invalid_live_case` + +`invalid_live_case` is reserved for harness or environment faults such as malformed geometry, invalid goals, startup failure, or RCON and session issues. It should not be treated as a capability result. + +### File layout +The first-wave implementation should keep the layout conservative: + +- Keep `tools/sim_jump_reach.py` as the theory entry point. +- Add theory export outputs under `tools/` or a closely related generated-output location. +- Add a canonical live-case manifest under `tools/` or a nearby data location suitable for shell-script consumption. +- Reuse existing `tools/mcc-env.sh` helpers, `mcc-debug`, tmux-backed MCC sessions, and shared local server management. + +No change is required to the core MCC runtime architecture for the first-wave design itself. + +### Delivery order +The implementation should proceed in this order: + +1. Stabilize theory export generation from `sim_jump_reach.py`. +2. Define canonical bucket and world-recipe selection rules. +3. Convert `tools/test-parkour.sh` to consume canonical theory-aligned cases. +4. Add the theory-aligned neo and ceiling live suite. +5. Leave specialized live suites in place with documentation clarifying that they are outside the first-wave theory authority. + +This order keeps truth-generation ahead of live execution and avoids locking shell suites to premature handwritten expectations. + +## Validation +- Generate the theory matrix and confirm JSON, CSV, and Markdown outputs are produced from the same dataset. +- For each first-wave bucket, require at least one canonical `easy`, `boundary`, and `reject` live case where applicable to that movement family. +- Record theory case ID, bucket ID, world recipe ID, expected result, live result, and MCC log path for every theory-aligned live case. +- Run theory-aligned live suites using the existing local server workflow through `source tools/mcc-env.sh` and `mcc-debug`. +- Keep specialized live suites runnable as separate checks, but do not block first-wave theory alignment on converting them. + +## Open questions +- None for the first-wave scope. Higher-level mixed execution behaviors are intentionally deferred until a simulator-backed authority exists for them.