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."