diff --git a/MinecraftClient.Tests/Pathing/Execution/Contracts/PathingContractStore.cs b/MinecraftClient.Tests/Pathing/Execution/Contracts/PathingContractStore.cs index 060e1e3f..b53d5e9a 100644 --- a/MinecraftClient.Tests/Pathing/Execution/Contracts/PathingContractStore.cs +++ b/MinecraftClient.Tests/Pathing/Execution/Contracts/PathingContractStore.cs @@ -150,6 +150,8 @@ public sealed class PathingContractStore throw new InvalidDataException($"Timing budget '{budget.ScenarioId}' must use zero totals when it has no segments."); var normalizedSegments = new List(budget.Segments.Count); + int expectedSegmentTicksSum = 0; + int maxSegmentTicksSum = 0; for (int i = 0; i < budget.Segments.Count; i++) { PathingSegmentTimingBudget segment = budget.Segments[i]; @@ -159,9 +161,23 @@ public sealed class PathingContractStore if (segment.ExpectedTicks > segment.MaxTicks) throw new InvalidDataException($"Timing budget '{budget.ScenarioId}' segment {i} has ExpectedTicks greater than MaxTicks."); + expectedSegmentTicksSum = checked(expectedSegmentTicksSum + segment.ExpectedTicks); + maxSegmentTicksSum = checked(maxSegmentTicksSum + segment.MaxTicks); normalizedSegments.Add(segment); } + if (budget.ExpectedTotalTicks != expectedSegmentTicksSum) + { + throw new InvalidDataException( + $"Timing budget '{budget.ScenarioId}' ExpectedTotalTicks mismatch. Total={budget.ExpectedTotalTicks}, segmentSum={expectedSegmentTicksSum}."); + } + + if (budget.MaxTotalTicks != maxSegmentTicksSum) + { + throw new InvalidDataException( + $"Timing budget '{budget.ScenarioId}' MaxTotalTicks mismatch. Total={budget.MaxTotalTicks}, segmentSum={maxSegmentTicksSum}."); + } + return budget with { Segments = normalizedSegments.AsReadOnly() }; } diff --git a/MinecraftClient.Tests/Pathing/Execution/LivePathingRegressionTests.cs b/MinecraftClient.Tests/Pathing/Execution/LivePathingRegressionTests.cs index 1385b245..fb08bda4 100644 --- a/MinecraftClient.Tests/Pathing/Execution/LivePathingRegressionTests.cs +++ b/MinecraftClient.Tests/Pathing/Execution/LivePathingRegressionTests.cs @@ -5,6 +5,7 @@ using MinecraftClient.Pathing.Core; using MinecraftClient.Pathing.Execution; using MinecraftClient.Pathing.Execution.Templates; using MinecraftClient.Pathing.Goals; +using MinecraftClient.Physics; using Xunit; namespace MinecraftClient.Tests.Pathing.Execution; @@ -35,6 +36,94 @@ public sealed class LivePathingRegressionTests Assert.Empty(PathSegmentBuilder.FromPath(result.Path)); } + [Fact] + public void AStar_RepeatedSingleGapParkourChain_PrefersTwoLongJumpsOverFourShortJumps() + { + World world = FlatWorldTestBuilder.CreateStoneFloor(min: 578, max: 590); + FlatWorldTestBuilder.ClearBox(world, 578, 79, 578, 590, 90, 582); + FlatWorldTestBuilder.SetSolid(world, 580, 79, 580); + FlatWorldTestBuilder.SetSolid(world, 582, 79, 580); + FlatWorldTestBuilder.SetSolid(world, 584, 79, 580); + FlatWorldTestBuilder.SetSolid(world, 586, 79, 580); + FlatWorldTestBuilder.SetSolid(world, 588, 79, 580); + + var ctx = new CalculationContext(world, allowParkour: true, allowParkourAscend: true); + var finder = new AStarPathFinder(); + + PathResult result = finder.Calculate( + ctx, + startX: 580, + startY: 80, + startZ: 580, + new GoalBlock(588, 80, 580), + CancellationToken.None, + timeoutMs: 2000); + + List segments = PathSegmentBuilder.FromPath(result.Path); + + Assert.Equal(PathStatus.Success, result.Status); + Assert.Collection( + segments, + first => + { + Assert.Equal(MoveType.Parkour, first.MoveType); + Assert.Equal(new Location(580.5, 80, 580.5), first.Start); + Assert.Equal(new Location(584.5, 80, 580.5), first.End); + }, + second => + { + Assert.Equal(MoveType.Parkour, second.MoveType); + Assert.Equal(new Location(584.5, 80, 580.5), second.Start); + Assert.Equal(new Location(588.5, 80, 580.5), second.End); + }); + } + + [Fact] + public void PathExecutor_RepeatedSingleGapParkourChain_TwoLongJumps_CompletesWithoutReplan() + { + World world = FlatWorldTestBuilder.CreateStoneFloor(min: 578, max: 590); + FlatWorldTestBuilder.ClearBox(world, 578, 79, 578, 590, 90, 582); + FlatWorldTestBuilder.SetSolid(world, 580, 79, 580); + FlatWorldTestBuilder.SetSolid(world, 582, 79, 580); + FlatWorldTestBuilder.SetSolid(world, 584, 79, 580); + FlatWorldTestBuilder.SetSolid(world, 586, 79, 580); + FlatWorldTestBuilder.SetSolid(world, 588, 79, 580); + + var ctx = new CalculationContext(world, allowParkour: true, allowParkourAscend: true); + var finder = new AStarPathFinder(); + PathResult result = finder.Calculate( + ctx, + startX: 580, + startY: 80, + startZ: 580, + new GoalBlock(588, 80, 580), + CancellationToken.None, + timeoutMs: 2000); + + var debugLogs = new List(); + var infoLogs = new List(); + var manager = new PathSegmentManager(debugLogs.Add, infoLogs.Add); + var physics = TemplateSimulationRunner.CreateGroundedPhysics(new Location(580.5, 80, 580.5), yaw: 270f); + var input = new MovementInput(); + + manager.StartNavigation(new GoalBlock(588, 80, 580), result); + + for (int tick = 0; tick < 240 && manager.IsNavigating; tick++) + { + input.Reset(); + Location pos = new(physics.Position.X, physics.Position.Y, physics.Position.Z); + manager.Tick(pos, physics, input, world); + if (!manager.IsNavigating) + break; + + physics.ApplyInput(input); + physics.Tick(world); + } + + Assert.True(!manager.IsNavigating && manager.ReplanCount == 0, + $"replanCount={manager.ReplanCount}\ninfo={string.Join('\n', infoLogs)}\ndebug={string.Join('\n', debugLogs)}"); + } + [Fact] public void SprintJumpTemplate_LandingRecoveryIntoTurn_CompletesInsideLandingBlock() { diff --git a/MinecraftClient.Tests/Pathing/Execution/PathPlanningContractTests.cs b/MinecraftClient.Tests/Pathing/Execution/PathPlanningContractTests.cs index 1d718fef..fa6b525a 100644 --- a/MinecraftClient.Tests/Pathing/Execution/PathPlanningContractTests.cs +++ b/MinecraftClient.Tests/Pathing/Execution/PathPlanningContractTests.cs @@ -86,6 +86,49 @@ public sealed class PathPlanningContractTests Assert.Contains("manager-accepted-ascend-chain", error.Message); } + [Fact] + public void LoadFromJson_RejectsTimingBudget_WhenTotalsDoNotMatchSegments() + { + const string plannerJson = """ +[ + { + "scenarioId": "totals-mismatch", + "expectedStatus": "Success", + "segments": [ + { + "moveType": "Traverse", + "startBlock": { "x": 0, "y": 80, "z": 0 }, + "endBlock": { "x": 1, "y": 80, "z": 0 } + }, + { + "moveType": "Ascend", + "startBlock": { "x": 1, "y": 80, "z": 0 }, + "endBlock": { "x": 2, "y": 81, "z": 0 } + } + ] + } +] +"""; + const string timingJson = """ +[ + { + "scenarioId": "totals-mismatch", + "expectedTotalTicks": 1, + "maxTotalTicks": 2, + "segments": [ + { "moveType": "Traverse", "expectedTicks": 2, "maxTicks": 3 }, + { "moveType": "Ascend", "expectedTicks": 3, "maxTicks": 4 } + ] + } +] +"""; + + InvalidDataException error = Assert.Throws( + () => PathingContractStore.LoadFromJson(plannerJson, timingJson)); + Assert.Contains("totals-mismatch", error.Message); + Assert.Contains("ExpectedTotalTicks mismatch", error.Message); + } + [Fact] public void LoadFromJson_Rejects_WhenPlannerAndTimingScenarioSetsMismatch() { @@ -214,25 +257,26 @@ public sealed class PathPlanningContractTests } [Theory] + [InlineData("manager-accepted-ascend-chain")] [InlineData("same-move-ascend-staircase")] [InlineData("same-move-descend-staircase")] [InlineData("rejected-3x1-invalid-goal")] - public void Scenario_PlannerMatchesContract(string scenarioId) - { - PathingExecutionScenario scenario = PathingExecutionScenarioCatalog.Get(scenarioId); - PathResult planResult = PathingScenarioRunner.PlanOnly(scenario); - PathingPlannerContract contract = PathingContractStore.LoadFromRepositoryRoot().GetPlanner(scenarioId); - - PathingContractAssert.PlannerMatches(contract, PathSegmentBuilder.FromPath(planResult.Path), planResult); - } - - [Theory] [InlineData("repeated-cardinal-parkour-chain")] [InlineData("repeated-diagonal-parkour-chain")] [InlineData("obstructed-parkour-l-turns")] [InlineData("vertical-jump-mix")] [InlineData("diagonal-vertical-mix")] - public void JumpCombo_PlannerMatchesContract(string scenarioId) + [InlineData("turn-density-alternating-traverse-diagonal-chain")] + [InlineData("mixed-traverse-ascend-parkour-descend")] + [InlineData("same-move-aligned-parkour-chain")] + [InlineData("mixed-diagonal-ascend-traverse-descend")] + [InlineData("speed-carry-repeated-traverse-ascend")] + [InlineData("speed-carry-repeated-traverse-descend")] + [InlineData("speed-carry-repeated-traverse-parkour")] + [InlineData("same-move-diagonal-chain")] + [InlineData("same-move-straight-traverse-chain")] + [InlineData("mixed-traverse-turn-parkour-turn-traverse")] + public void Scenario_PlannerMatchesContract(string scenarioId) { PathingExecutionScenario scenario = PathingExecutionScenarioCatalog.Get(scenarioId); PathResult planResult = PathingScenarioRunner.PlanOnly(scenario); diff --git a/MinecraftClient.Tests/Pathing/Execution/Support/PathingContractAssert.cs b/MinecraftClient.Tests/Pathing/Execution/Support/PathingContractAssert.cs index f80a05a3..42e2873e 100644 --- a/MinecraftClient.Tests/Pathing/Execution/Support/PathingContractAssert.cs +++ b/MinecraftClient.Tests/Pathing/Execution/Support/PathingContractAssert.cs @@ -62,9 +62,28 @@ internal static class PathingContractAssert sb.AppendLine($"seg[{i}] move={actual.MoveType} actual={actual.ElapsedTicks} expected={expected.ExpectedTicks} max={expected.MaxTicks}"); } + if (result.InfoLogs.Count > 0) + { + sb.AppendLine("info tail:"); + AppendTail(sb, result.InfoLogs, maxLines: 8); + } + + if (result.DebugLogs.Count > 0) + { + sb.AppendLine("debug tail:"); + AppendTail(sb, result.DebugLogs, maxLines: 12); + } + return sb.ToString(); } + private static void AppendTail(StringBuilder sb, IReadOnlyList lines, int maxLines) + { + int start = Math.Max(0, lines.Count - maxLines); + for (int i = start; i < lines.Count; i++) + sb.AppendLine(lines[i]); + } + private static PathingBlock ToBlock(Location location) => new((int)Math.Floor(location.X), (int)Math.Floor(location.Y), (int)Math.Floor(location.Z)); } diff --git a/MinecraftClient.Tests/Pathing/Moves/MoveParkourTests.cs b/MinecraftClient.Tests/Pathing/Moves/MoveParkourTests.cs index 5799085f..483eacc6 100644 --- a/MinecraftClient.Tests/Pathing/Moves/MoveParkourTests.cs +++ b/MinecraftClient.Tests/Pathing/Moves/MoveParkourTests.cs @@ -42,6 +42,26 @@ public sealed class MoveParkourTests Assert.Equal(2, result.DestX); } + [Fact] + public void Accepts4x1JumpWithoutRearSupport_WhenTakeoffBlockProvidesRunway() + { + World world = FlatWorldTestBuilder.CreateStoneFloor(min: -2, max: 6); + FlatWorldTestBuilder.ClearBox(world, -2, FloorY, -1, 6, FloorY + 4, 1); + FlatWorldTestBuilder.SetSolid(world, 0, FloorY, 0); + FlatWorldTestBuilder.SetSolid(world, 2, FloorY, 0); + FlatWorldTestBuilder.SetSolid(world, 4, FloorY, 0); + + var ctx = BuildContext(world); + var move = new MoveParkour(4, 0); + var result = default(MoveResult); + + move.Calculate(ctx, 0, FloorY + 1, 0, ref result); + + Assert.False(result.IsImpossible); + Assert.Equal(4, result.DestX); + Assert.Equal(0, result.DestZ); + } + [Fact] public void Rejects2x1WhenAdjacentBlockIsStillWalkable() { diff --git a/MinecraftClient.Tests/TestData/Pathing/pathing-planner-contracts.json b/MinecraftClient.Tests/TestData/Pathing/pathing-planner-contracts.json index 4be87817..060fe357 100644 --- a/MinecraftClient.Tests/TestData/Pathing/pathing-planner-contracts.json +++ b/MinecraftClient.Tests/TestData/Pathing/pathing-planner-contracts.json @@ -215,19 +215,6 @@ "y": 80, "z": 580 }, - "endBlock": { - "x": 582, - "y": 80, - "z": 580 - } - }, - { - "moveType": "Parkour", - "startBlock": { - "x": 582, - "y": 80, - "z": 580 - }, "endBlock": { "x": 584, "y": 80, @@ -241,19 +228,6 @@ "y": 80, "z": 580 }, - "endBlock": { - "x": 586, - "y": 80, - "z": 580 - } - }, - { - "moveType": "Parkour", - "startBlock": { - "x": 586, - "y": 80, - "z": 580 - }, "endBlock": { "x": 588, "y": 80, @@ -370,25 +344,12 @@ } }, { - "moveType": "Descend", + "moveType": "Parkour", "startBlock": { "x": 642, "y": 81, "z": 620 }, - "endBlock": { - "x": 644, - "y": 80, - "z": 620 - } - }, - { - "moveType": "Parkour", - "startBlock": { - "x": 644, - "y": 80, - "z": 620 - }, "endBlock": { "x": 646, "y": 81, @@ -634,19 +595,6 @@ "y": 80, "z": 380 }, - "endBlock": { - "x": 382, - "y": 80, - "z": 380 - } - }, - { - "moveType": "Parkour", - "startBlock": { - "x": 382, - "y": 80, - "z": 380 - }, "endBlock": { "x": 384, "y": 80, @@ -660,19 +608,6 @@ "y": 80, "z": 380 }, - "endBlock": { - "x": 386, - "y": 80, - "z": 380 - } - }, - { - "moveType": "Parkour", - "startBlock": { - "x": 386, - "y": 80, - "z": 380 - }, "endBlock": { "x": 388, "y": 80, diff --git a/MinecraftClient.Tests/TestData/Pathing/pathing-timing-budgets.json b/MinecraftClient.Tests/TestData/Pathing/pathing-timing-budgets.json index 729119bc..36615867 100644 --- a/MinecraftClient.Tests/TestData/Pathing/pathing-timing-budgets.json +++ b/MinecraftClient.Tests/TestData/Pathing/pathing-timing-budgets.json @@ -38,55 +38,55 @@ }, { "scenarioId": "same-move-ascend-staircase", - "expectedTotalTicks": 56, - "maxTotalTicks": 68, + "expectedTotalTicks": 60, + "maxTotalTicks": 74, "segments": [ { "moveType": "Ascend", - "expectedTicks": 11, - "maxTicks": 14 - }, - { - "moveType": "Ascend", - "expectedTicks": 11, - "maxTicks": 14 - }, - { - "moveType": "Ascend", - "expectedTicks": 11, - "maxTicks": 14 - }, - { - "moveType": "Ascend", - "expectedTicks": 11, - "maxTicks": 14 + "expectedTicks": 10, + "maxTicks": 12 }, { "moveType": "Ascend", "expectedTicks": 12, "maxTicks": 15 + }, + { + "moveType": "Ascend", + "expectedTicks": 12, + "maxTicks": 15 + }, + { + "moveType": "Ascend", + "expectedTicks": 12, + "maxTicks": 15 + }, + { + "moveType": "Ascend", + "expectedTicks": 14, + "maxTicks": 17 } ] }, { "scenarioId": "same-move-descend-staircase", - "expectedTotalTicks": 61, - "maxTotalTicks": 74, + "expectedTotalTicks": 57, + "maxTotalTicks": 70, "segments": [ { "moveType": "Descend", - "expectedTicks": 24, - "maxTicks": 29 + "expectedTicks": 23, + "maxTicks": 28 }, { "moveType": "Descend", - "expectedTicks": 25, - "maxTicks": 30 + "expectedTicks": 23, + "maxTicks": 28 }, { "moveType": "Descend", - "expectedTicks": 12, - "maxTicks": 15 + "expectedTicks": 11, + "maxTicks": 14 } ] }, @@ -98,79 +98,47 @@ }, { "scenarioId": "repeated-cardinal-parkour-chain", - "expectedTotalTicks": 0, - "maxTotalTicks": 2, + "expectedTotalTicks": 37, + "maxTotalTicks": 45, "segments": [ { "moveType": "Parkour", - "expectedTicks": 61, - "maxTicks": 74 + "expectedTicks": 18, + "maxTicks": 22 }, { "moveType": "Parkour", - "expectedTicks": 61, - "maxTicks": 74 - }, - { - "moveType": "Parkour", - "expectedTicks": 61, - "maxTicks": 74 - }, - { - "moveType": "Parkour", - "expectedTicks": 27, - "maxTicks": 33 + "expectedTicks": 19, + "maxTicks": 23 } ] }, { "scenarioId": "repeated-diagonal-parkour-chain", - "expectedTotalTicks": 20, - "maxTotalTicks": 24, + "expectedTotalTicks": 67, + "maxTotalTicks": 82, "segments": [ { "moveType": "Parkour", - "expectedTicks": 61, - "maxTicks": 74 + "expectedTicks": 14, + "maxTicks": 17 }, { "moveType": "Parkour", - "expectedTicks": 61, - "maxTicks": 74 + "expectedTicks": 36, + "maxTicks": 44 }, { "moveType": "Parkour", - "expectedTicks": 20, - "maxTicks": 24 + "expectedTicks": 17, + "maxTicks": 21 } ] }, { "scenarioId": "obstructed-parkour-l-turns", - "expectedTotalTicks": 0, - "maxTotalTicks": 2, - "segments": [ - { - "moveType": "Parkour", - "expectedTicks": 27, - "maxTicks": 33 - }, - { - "moveType": "Parkour", - "expectedTicks": 27, - "maxTicks": 33 - }, - { - "moveType": "Parkour", - "expectedTicks": 27, - "maxTicks": 33 - } - ] - }, - { - "scenarioId": "vertical-jump-mix", - "expectedTotalTicks": 33, - "maxTotalTicks": 40, + "expectedTotalTicks": 50, + "maxTotalTicks": 62, "segments": [ { "moveType": "Parkour", @@ -178,14 +146,53 @@ "maxTicks": 16 }, { - "moveType": "Descend", - "expectedTicks": 201, - "maxTicks": 242 + "moveType": "Parkour", + "expectedTicks": 21, + "maxTicks": 26 }, { "moveType": "Parkour", - "expectedTicks": 19, - "maxTicks": 23 + "expectedTicks": 16, + "maxTicks": 20 + } + ] + }, + { + "scenarioId": "vertical-jump-mix", + "expectedTotalTicks": 41, + "maxTotalTicks": 50, + "segments": [ + { + "moveType": "Parkour", + "expectedTicks": 10, + "maxTicks": 12 + }, + { + "moveType": "Parkour", + "expectedTicks": 18, + "maxTicks": 22 + }, + { + "moveType": "Descend", + "expectedTicks": 13, + "maxTicks": 16 + } + ] + }, + { + "scenarioId": "diagonal-vertical-mix", + "expectedTotalTicks": 38, + "maxTotalTicks": 46, + "segments": [ + { + "moveType": "Ascend", + "expectedTicks": 10, + "maxTicks": 12 + }, + { + "moveType": "Parkour", + "expectedTicks": 14, + "maxTicks": 17 }, { "moveType": "Descend", @@ -194,32 +201,10 @@ } ] }, - { - "scenarioId": "diagonal-vertical-mix", - "expectedTotalTicks": 31, - "maxTotalTicks": 38, - "segments": [ - { - "moveType": "Ascend", - "expectedTicks": 81, - "maxTicks": 98 - }, - { - "moveType": "Parkour", - "expectedTicks": 16, - "maxTicks": 20 - }, - { - "moveType": "Descend", - "expectedTicks": 15, - "maxTicks": 18 - } - ] - }, { "scenarioId": "turn-density-alternating-traverse-diagonal-chain", "expectedTotalTicks": 47, - "maxTotalTicks": 57, + "maxTotalTicks": 59, "segments": [ { "moveType": "Diagonal", @@ -255,72 +240,62 @@ }, { "scenarioId": "mixed-traverse-ascend-parkour-descend", - "expectedTotalTicks": 40, - "maxTotalTicks": 48, + "expectedTotalTicks": 70, + "maxTotalTicks": 88, "segments": [ { "moveType": "Traverse", - "expectedTicks": 6, - "maxTicks": 8 + "expectedTicks": 5, + "maxTicks": 7 }, { "moveType": "Ascend", - "expectedTicks": 11, - "maxTicks": 14 + "expectedTicks": 12, + "maxTicks": 15 }, { "moveType": "Ascend", - "expectedTicks": 11, - "maxTicks": 14 + "expectedTicks": 13, + "maxTicks": 16 }, { "moveType": "Traverse", - "expectedTicks": 81, - "maxTicks": 98 + "expectedTicks": 5, + "maxTicks": 7 }, + { + "moveType": "Parkour", + "expectedTicks": 14, + "maxTicks": 17 + }, + { + "moveType": "Descend", + "expectedTicks": 21, + "maxTicks": 26 + } + ] + }, + { + "scenarioId": "same-move-aligned-parkour-chain", + "expectedTotalTicks": 37, + "maxTotalTicks": 45, + "segments": [ { "moveType": "Parkour", "expectedTicks": 18, "maxTicks": 22 }, - { - "moveType": "Descend", - "expectedTicks": 22, - "maxTicks": 27 - } - ] - }, - { - "scenarioId": "same-move-aligned-parkour-chain", - "expectedTotalTicks": 0, - "maxTotalTicks": 2, - "segments": [ { "moveType": "Parkour", - "expectedTicks": 61, - "maxTicks": 74 - }, - { - "moveType": "Parkour", - "expectedTicks": 61, - "maxTicks": 74 - }, - { - "moveType": "Parkour", - "expectedTicks": 61, - "maxTicks": 74 - }, - { - "moveType": "Parkour", - "expectedTicks": 27, - "maxTicks": 33 + "expectedTicks": 19, + "maxTicks": 23 } ] }, { "scenarioId": "mixed-diagonal-ascend-traverse-descend", - "expectedTotalTicks": 96, - "maxTotalTicks": 116, + "expectedTotalTicks": 76, + "maxTotalTicks": 95, "segments": [ { "moveType": "Diagonal", @@ -334,13 +309,13 @@ }, { "moveType": "Ascend", - "expectedTicks": 32, - "maxTicks": 39 + "expectedTicks": 10, + "maxTicks": 12 }, { "moveType": "Ascend", - "expectedTicks": 11, - "maxTicks": 14 + "expectedTicks": 13, + "maxTicks": 16 }, { "moveType": "Traverse", @@ -361,19 +336,9 @@ }, { "scenarioId": "speed-carry-repeated-traverse-ascend", - "expectedTotalTicks": 66, - "maxTotalTicks": 80, + "expectedTotalTicks": 70, + "maxTotalTicks": 90, "segments": [ - { - "moveType": "Traverse", - "expectedTicks": 6, - "maxTicks": 8 - }, - { - "moveType": "Ascend", - "expectedTicks": 11, - "maxTicks": 14 - }, { "moveType": "Traverse", "expectedTicks": 5, @@ -381,18 +346,8 @@ }, { "moveType": "Ascend", - "expectedTicks": 11, - "maxTicks": 14 - }, - { - "moveType": "Traverse", - "expectedTicks": 5, - "maxTicks": 7 - }, - { - "moveType": "Ascend", - "expectedTicks": 11, - "maxTicks": 14 + "expectedTicks": 13, + "maxTicks": 16 }, { "moveType": "Traverse", @@ -403,23 +358,43 @@ "moveType": "Ascend", "expectedTicks": 12, "maxTicks": 15 + }, + { + "moveType": "Traverse", + "expectedTicks": 5, + "maxTicks": 7 + }, + { + "moveType": "Ascend", + "expectedTicks": 12, + "maxTicks": 15 + }, + { + "moveType": "Traverse", + "expectedTicks": 5, + "maxTicks": 7 + }, + { + "moveType": "Ascend", + "expectedTicks": 13, + "maxTicks": 16 } ] }, { "scenarioId": "speed-carry-repeated-traverse-descend", - "expectedTotalTicks": 41, - "maxTotalTicks": 50, + "expectedTotalTicks": 45, + "maxTotalTicks": 57, "segments": [ { "moveType": "Traverse", - "expectedTicks": 81, - "maxTicks": 98 + "expectedTicks": 5, + "maxTicks": 7 }, { "moveType": "Parkour", - "expectedTicks": 22, - "maxTicks": 27 + "expectedTicks": 21, + "maxTicks": 26 }, { "moveType": "Traverse", @@ -435,45 +410,45 @@ }, { "scenarioId": "speed-carry-repeated-traverse-parkour", - "expectedTotalTicks": 0, - "maxTotalTicks": 2, + "expectedTotalTicks": 58, + "maxTotalTicks": 73, "segments": [ { "moveType": "Traverse", - "expectedTicks": 81, - "maxTicks": 98 + "expectedTicks": 5, + "maxTicks": 7 }, { "moveType": "Parkour", - "expectedTicks": 18, - "maxTicks": 22 + "expectedTicks": 14, + "maxTicks": 17 }, { "moveType": "Traverse", - "expectedTicks": 81, - "maxTicks": 98 + "expectedTicks": 5, + "maxTicks": 7 + }, + { + "moveType": "Parkour", + "expectedTicks": 14, + "maxTicks": 17 + }, + { + "moveType": "Traverse", + "expectedTicks": 5, + "maxTicks": 7 }, { "moveType": "Parkour", "expectedTicks": 15, "maxTicks": 18 - }, - { - "moveType": "Traverse", - "expectedTicks": 81, - "maxTicks": 98 - }, - { - "moveType": "Parkour", - "expectedTicks": 29, - "maxTicks": 35 } ] }, { "scenarioId": "same-move-diagonal-chain", "expectedTotalTicks": 55, - "maxTotalTicks": 66, + "maxTotalTicks": 69, "segments": [ { "moveType": "Diagonal", @@ -515,7 +490,7 @@ { "scenarioId": "same-move-straight-traverse-chain", "expectedTotalTicks": 70, - "maxTotalTicks": 84, + "maxTotalTicks": 94, "segments": [ { "moveType": "Traverse", @@ -581,8 +556,8 @@ }, { "scenarioId": "mixed-traverse-turn-parkour-turn-traverse", - "expectedTotalTicks": 46, - "maxTotalTicks": 56, + "expectedTotalTicks": 60, + "maxTotalTicks": 75, "segments": [ { "moveType": "Traverse", @@ -591,8 +566,8 @@ }, { "moveType": "Diagonal", - "expectedTicks": 81, - "maxTicks": 98 + "expectedTicks": 6, + "maxTicks": 8 }, { "moveType": "Parkour", @@ -601,8 +576,8 @@ }, { "moveType": "Traverse", - "expectedTicks": 7, - "maxTicks": 9 + "expectedTicks": 8, + "maxTicks": 10 }, { "moveType": "Diagonal", @@ -616,8 +591,8 @@ }, { "moveType": "Traverse", - "expectedTicks": 8, - "maxTicks": 10 + "expectedTicks": 7, + "maxTicks": 9 } ] } diff --git a/MinecraftClient/Pathing/Moves/ParkourFeasibility.cs b/MinecraftClient/Pathing/Moves/ParkourFeasibility.cs index 0257f66b..475877ae 100644 --- a/MinecraftClient/Pathing/Moves/ParkourFeasibility.cs +++ b/MinecraftClient/Pathing/Moves/ParkourFeasibility.cs @@ -15,7 +15,10 @@ internal static class ParkourFeasibility int yDelta) { double horiz = Math.Sqrt(xOffset * xOffset + zOffset * zOffset); - double threshold = yDelta > 0 ? 2.5 : 3.5; + if (yDelta <= 0) + return true; + + double threshold = 2.5; if (horiz < threshold) return true; diff --git a/docs/guide/pathfinding-research.md b/docs/guide/pathfinding-research.md index 926e7bb0..d03c1145 100644 --- a/docs/guide/pathfinding-research.md +++ b/docs/guide/pathfinding-research.md @@ -317,6 +317,8 @@ For rejection scenarios, the requirement is stricter: Residual speed carried from one movement to the next inside a route is expected and must not be normalized away just to satisfy the harness. The route is only considered reliable if that natural speed carry still produces `0 replan`. +Independent live-route cases must reset position, yaw, and pitch to the scenario start state before each run. Cross-case orientation residue is harness noise, not valid pathing difficulty. + ## Baritone Reference Notes For Zero-Replan Work MCC can borrow specific ideas from the local Baritone reference under `ThirdpartyReference/baritone/`, but not its looser success semantics. diff --git a/docs/superpowers/plans/2026-04-13-theory-aligned-pathing-regression.md b/docs/superpowers/plans/2026-04-13-theory-aligned-pathing-regression.md new file mode 100644 index 00000000..4c70a0ed --- /dev/null +++ b/docs/superpowers/plans/2026-04-13-theory-aligned-pathing-regression.md @@ -0,0 +1,1360 @@ +# Theory-Aligned Pathing Regression 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 a first-wave pathing regression workflow where `tools/sim_jump_reach.py` generates the authoritative theory matrix plus canonical live cases, and theory-aligned live harness scripts validate representative linear, neo, and ceiling-constrained jumps against that authority. + +**Architecture:** Split the work into three layers. First, extract a reusable Python theory module from `tools/sim_jump_reach.py` so it can generate a stable case table instead of only printing ad-hoc console output. Second, generate versioned theory artifacts and canonical live-case manifests under `tools/pathing_data/`, then add a report layer that joins live results back to theory case IDs. Third, refactor the linear live harness and add a new neo and headhitter harness that consume canonical cases instead of hardcoding expected outcomes. + +**Tech Stack:** Python 3 standard library (`argparse`, `csv`, `json`, `dataclasses`, `unittest`), Bash harness scripts on top of `tools/mcc-env.sh`, versioned JSON/CSV/Markdown artifacts under `tools/pathing_data/`, existing MCC live debug loop on `1.21.11-Vanilla`. + +--- + +## Scope Check + +This plan intentionally covers only the first-wave scope from [theory-aligned-pathing-regression-design.md](/home/ryan/Minecraft/Minecraft-Console-Client-milutinke/docs/superpowers/specs/2026-04-13-theory-aligned-pathing-regression-design.md): + +- theory authority from `tools/sim_jump_reach.py` +- first-wave movement families only: + - linear flat + - linear ascend + - linear descend + - neo + - ceiling-constrained or headhitter +- canonical live coverage only +- specialized live suites stay out of scope except for documentation positioning + +Do not expand this plan to repeated parkour chains, landing-recovery into turns, braking metrics, long-route mixed execution, or C# runtime refactors. Those already have their own committed work and separate plans. + +## File Structure + +### Python theory layer + +- Create: `tools/pathing_theory/__init__.py` + - package marker for the reusable theory/export code +- Create: `tools/pathing_theory/models.py` + - dataclasses for theory cases, canonical live cases, live results, and report rows +- Create: `tools/pathing_theory/primitives.py` + - extracted jump physics constants and low-level reachability helpers moved out of the CLI entry point +- Create: `tools/pathing_theory/simulator.py` + - reusable case generation built on `tools/pathing_theory/primitives.py` without importing the CLI entry point +- Create: `tools/pathing_theory/canonical.py` + - bucket selection and canonical live-case derivation +- Create: `tools/pathing_theory/renderers.py` + - JSON/CSV/Markdown writers for theory outputs +- Create: `tools/pathing_theory/report.py` + - join live result rows back to canonical cases and render summary outputs +- Modify: `tools/sim_jump_reach.py` + - keep as the public CLI entry point, but delegate to the new reusable modules +- Create: `tools/pathing_theory_report.py` + - small CLI wrapper around `tools/pathing_theory/report.py` + +### Versioned data artifacts + +- Create: `tools/pathing_data/theory-matrix.json` + - full machine-readable theory matrix +- Create: `tools/pathing_data/theory-matrix.csv` + - CSV view of the same matrix +- Create: `tools/pathing_data/theory-matrix.md` + - human-readable summary from the same in-memory data +- Create: `tools/pathing_data/canonical-live-cases.json` + - versioned canonical live cases consumed by shell harnesses + +These files are intentionally tracked. Regeneration happens explicitly when theory changes, so running the live harnesses does not dirty the worktree. + +### Python tests + +- Create: `tools/tests/__init__.py` + - package marker for `unittest` discovery +- Create: `tools/tests/test_pathing_theory_matrix.py` + - verifies case generation and output file contents +- Create: `tools/tests/test_pathing_canonical_cases.py` + - verifies deterministic bucket selection +- Create: `tools/tests/test_pathing_theory_report.py` + - verifies theory/live join and summary classification +- Create: `tools/tests/test_pathing_live_scripts.py` + - subprocess-based checks for `--list-cases` support and manifest consumption + +### Live harness layer + +- Create: `tools/pathing_live_common.sh` + - shared manifest parsing, per-case recording, and common MCC session helpers for the theory-aligned suites +- Modify: `tools/test-parkour.sh` + - turn into the main theory-aligned linear-jump suite +- Create: `tools/test-pathing-theory-neo-ceiling.sh` + - theory-aligned suite for canonical `neo` and `ceiling` buckets + +### Documentation + +- Modify: `docs/guide/pathfinding-research.md` + - document the theory matrix workflow, canonical live coverage, regeneration commands, and how specialized live suites differ from theory-aligned suites + +--- + +### Task 1: Extract Reusable Theory Case Generation + +**Files:** +- Create: `tools/pathing_theory/__init__.py` +- Create: `tools/pathing_theory/models.py` +- Create: `tools/pathing_theory/primitives.py` +- Create: `tools/pathing_theory/simulator.py` +- Modify: `tools/sim_jump_reach.py` +- Create: `tools/tests/__init__.py` +- Test: `tools/tests/test_pathing_theory_matrix.py` + +- [ ] **Step 1: Write the failing theory-matrix generation test** + +Create `tools/tests/test_pathing_theory_matrix.py`: + +```python +import unittest + +from tools.pathing_theory.simulator import build_theory_cases + + +class PathingTheoryMatrixTests(unittest.TestCase): + def test_build_theory_cases_returns_first_wave_families(self) -> None: + cases = build_theory_cases() + families = {(case.family, case.subfamily) for case in cases} + + self.assertIn(("linear", "flat"), families) + self.assertIn(("linear", "ascend"), families) + self.assertIn(("linear", "descend"), families) + self.assertIn(("neo", "neo"), families) + self.assertIn(("ceiling", "headhitter"), families) + + linear_boundary = next( + case for case in cases + if case.case_id == "linear-flat-sprint-mm12-gap5-dy0p0" + ) + self.assertTrue(linear_boundary.expected_reachable) + self.assertGreater(linear_boundary.margin, 0.0) + + +if __name__ == "__main__": + unittest.main() +``` + +- [ ] **Step 2: Run the test to verify it fails** + +Run: + +```bash +python3 -m unittest tools.tests.test_pathing_theory_matrix -v +``` + +Expected: FAIL with `ModuleNotFoundError: No module named 'tools.pathing_theory'`. + +- [ ] **Step 3: Implement the reusable theory models and case generator** + +Create `tools/pathing_theory/models.py`: + +```python +from dataclasses import dataclass + + +@dataclass(frozen=True) +class TheoryCase: + case_id: str + family: str + subfamily: str + movement_mode: str + momentum_ticks: int + gap_blocks: int | None + delta_y: float | None + ceiling_height: float | None + wall_width: int | None + expected_reachable: bool + landing_x: float | None + apex_y: float | None + margin: float | None + notes: str = "" +``` + +Create `tools/pathing_theory/primitives.py`: + +```python +from dataclasses import dataclass +from typing import Optional + +# Move these symbols from `tools/sim_jump_reach.py` into this module without +# changing their behavior: +# - PLAYER_WIDTH, PLAYER_HEIGHT, STEP_HEIGHT +# - GRAVITY, DRAG_Y, FRICTION_MULTIPLIER, DEFAULT_BLOCK_FRICTION +# - INPUT_FRICTION, GROUND_ACCEL_FACTOR, AIR_ACCEL, MOVEMENT_SPEED +# - BASE_JUMP_POWER, SPRINT_JUMP_HORIZONTAL_BOOST +# - HORIZONTAL_VELOCITY_THRESHOLD_SQR, VERTICAL_VELOCITY_THRESHOLD, HALF_WIDTH +# - TickState +# - get_ground_speed() +# - simulate_jump() +# - get_landing() +# - get_apex() +# - can_reach_gap() +``` + +Create `tools/pathing_theory/simulator.py`: + +```python +from tools.pathing_theory.models import TheoryCase +from tools.pathing_theory.primitives import PLAYER_WIDTH, can_reach_gap, get_apex, get_landing + + +def _float_token(value: float) -> str: + token = f"{value:.1f}".replace("-", "m").replace(".", "p") + return token + + +def build_theory_cases() -> list[TheoryCase]: + cases: list[TheoryCase] = [] + + for sprint, movement_mode, momentum_ticks in [ + (False, "walk", 12), + (True, "sprint", 0), + (True, "sprint", 12), + ]: + for gap in range(0, 7): + for delta_y in [0.0, 1.0, -1.0, -2.0]: + ok, landing_x, needed_x = can_reach_gap( + gap_blocks=gap, + dy=delta_y, + sprint=sprint, + momentum_ticks=momentum_ticks, + ) + apex_y, _ = get_apex(sprint=sprint, momentum_ticks=momentum_ticks) + subfamily = ( + "flat" if delta_y == 0.0 + else "ascend" if delta_y > 0.0 + else "descend" + ) + cases.append( + TheoryCase( + case_id=f"linear-{subfamily}-{movement_mode}-mm{momentum_ticks}-gap{gap}-dy{_float_token(delta_y)}", + family="linear", + subfamily=subfamily, + movement_mode=movement_mode, + momentum_ticks=momentum_ticks, + gap_blocks=gap, + delta_y=delta_y, + ceiling_height=None, + wall_width=None, + expected_reachable=ok, + landing_x=landing_x, + apex_y=apex_y, + margin=None if landing_x is None else landing_x - needed_x, + ) + ) + + landing = get_landing(sprint=True, target_y=0.0, landing_x_start=0.0, momentum_ticks=12) + for wall_width in [1, 2, 3, 4]: + landing_x = None if landing is None else landing[0] + needed_x = wall_width + PLAYER_WIDTH + margin = None if landing_x is None else landing_x - needed_x + cases.append( + TheoryCase( + case_id=f"neo-neo-sprint-mm12-wall{wall_width}", + family="neo", + subfamily="neo", + movement_mode="sprint", + momentum_ticks=12, + gap_blocks=None, + delta_y=0.0, + ceiling_height=None, + wall_width=wall_width, + expected_reachable=margin is not None and margin >= 0.0, + landing_x=landing_x, + apex_y=get_apex(sprint=True, momentum_ticks=12)[0], + margin=margin, + ) + ) + + for ceiling_height in [4.0, 3.0, 2.5, 2.0, 1.8125]: + for gap in [1, 2, 3, 4]: + landing = get_landing( + sprint=True, + target_y=0.0, + landing_x_start=0.5 + gap, + momentum_ticks=12, + ceiling_y=ceiling_height, + ) + landing_x = None if landing is None else landing[0] + needed_x = 0.5 + gap + (PLAYER_WIDTH / 2.0) + margin = None if landing_x is None else landing_x - needed_x + cases.append( + TheoryCase( + case_id=f"ceiling-headhitter-sprint-mm12-gap{gap}-ceil{str(ceiling_height).replace('.', 'p')}", + family="ceiling", + subfamily="headhitter", + movement_mode="sprint", + momentum_ticks=12, + gap_blocks=gap, + delta_y=0.0, + ceiling_height=ceiling_height, + wall_width=None, + expected_reachable=margin is not None and margin >= 0.0, + landing_x=landing_x, + apex_y=get_apex(sprint=True, momentum_ticks=12, ceiling_y=ceiling_height)[0], + margin=margin, + ) + ) + + return cases +``` + +Modify the top of `tools/sim_jump_reach.py` so the CLI imports the extracted primitives and the new case builder without creating a circular import: + +```python +from tools.pathing_theory.primitives import PLAYER_WIDTH, can_reach_gap, get_apex, get_landing +from tools.pathing_theory.simulator import build_theory_cases +``` + +- [ ] **Step 4: Run the theory-matrix test to verify it passes** + +Run: + +```bash +python3 -m unittest tools.tests.test_pathing_theory_matrix -v +``` + +Expected: PASS with `test_build_theory_cases_returns_first_wave_families ... ok`. + +- [ ] **Step 5: Commit** + +```bash +git add tools/pathing_theory/__init__.py \ + tools/pathing_theory/models.py \ + tools/pathing_theory/primitives.py \ + tools/pathing_theory/simulator.py \ + tools/sim_jump_reach.py \ + tools/tests/__init__.py \ + tools/tests/test_pathing_theory_matrix.py +git commit -m "feat: extract reusable pathing theory generator" +``` + +### Task 2: Generate Versioned Theory Artifacts And Canonical Live Cases + +**Files:** +- Create: `tools/pathing_theory/canonical.py` +- Create: `tools/pathing_theory/renderers.py` +- Modify: `tools/pathing_theory/models.py` +- Modify: `tools/sim_jump_reach.py` +- Create: `tools/pathing_data/theory-matrix.json` +- Create: `tools/pathing_data/theory-matrix.csv` +- Create: `tools/pathing_data/theory-matrix.md` +- Create: `tools/pathing_data/canonical-live-cases.json` +- Test: `tools/tests/test_pathing_canonical_cases.py` +- Test: `tools/tests/test_pathing_theory_matrix.py` + +- [ ] **Step 1: Write the failing canonical-selection and export tests** + +Create `tools/tests/test_pathing_canonical_cases.py`: + +```python +import json +import tempfile +import unittest +from pathlib import Path + +from tools.pathing_theory.canonical import build_canonical_live_cases +from tools.pathing_theory.renderers import write_theory_artifacts +from tools.pathing_theory.simulator import build_theory_cases + + +class CanonicalPathingCaseTests(unittest.TestCase): + def test_build_canonical_live_cases_picks_easy_boundary_and_reject(self) -> None: + canonical_cases = build_canonical_live_cases(build_theory_cases()) + bucket_ids = {case.bucket_id for case in canonical_cases} + + self.assertTrue(all(case.movement_mode == "sprint" for case in canonical_cases)) + self.assertTrue(all(case.momentum_ticks == 12 for case in canonical_cases)) + self.assertIn("linear:flat:sprint:easy", bucket_ids) + self.assertIn("linear:flat:sprint:boundary", bucket_ids) + self.assertIn("linear:flat:sprint:reject", bucket_ids) + self.assertIn("neo:neo:sprint:boundary", bucket_ids) + self.assertIn("ceiling:headhitter:sprint:boundary", bucket_ids) + + def test_write_theory_artifacts_writes_json_csv_and_markdown_from_same_cases(self) -> None: + cases = build_theory_cases() + + with tempfile.TemporaryDirectory() as temp_dir: + output_dir = Path(temp_dir) + write_theory_artifacts(cases, build_canonical_live_cases(cases), output_dir) + + json_path = output_dir / "theory-matrix.json" + csv_path = output_dir / "theory-matrix.csv" + md_path = output_dir / "theory-matrix.md" + canonical_path = output_dir / "canonical-live-cases.json" + + self.assertTrue(json_path.exists()) + self.assertTrue(csv_path.exists()) + self.assertTrue(md_path.exists()) + self.assertTrue(canonical_path.exists()) + + exported_cases = json.loads(json_path.read_text()) + self.assertEqual(len(cases), len(exported_cases)) + self.assertIn("| family | subfamily | movement_mode |", md_path.read_text()) + + +if __name__ == "__main__": + unittest.main() +``` + +- [ ] **Step 2: Run the new tests to verify they fail** + +Run: + +```bash +python3 -m unittest tools.tests.test_pathing_canonical_cases -v +``` + +Expected: FAIL with `ModuleNotFoundError` for `tools.pathing_theory.canonical` or `renderers`. + +- [ ] **Step 3: Implement deterministic canonical selection and artifact rendering** + +Extend `tools/pathing_theory/models.py`: + +```python +@dataclass(frozen=True) +class CanonicalLiveCase: + case_id: str + bucket_id: str + family: str + subfamily: str + movement_mode: str + momentum_ticks: int + difficulty_band: str + expected_result: str + world_recipe_id: str + gap_blocks: int | None + delta_y: float | None + ceiling_height: float | None + wall_width: int | None + start: dict[str, float] + goal: dict[str, float] +``` + +Project the full theory matrix down to the sprint, 12-tick momentum lane for live execution. Keep walk and standing-sprint rows in `theory-matrix.*`, but do not emit them into `canonical-live-cases.json` until the live harness can intentionally force those movement modes. + +Create `tools/pathing_theory/canonical.py`: + +```python +from tools.pathing_theory.models import CanonicalLiveCase, TheoryCase + + +def _world_recipe_id(case: TheoryCase) -> str: + if case.family == "linear": + return f"linear-{case.subfamily}" + if case.family == "neo": + return "neo-wall" + return "ceiling-headhitter" + + +def _canonical_goal(case: TheoryCase) -> tuple[dict[str, float], dict[str, float]]: + start = {"x": 100.5, "y": 80.0, "z": 100.5} + if case.family == "linear": + goal_y = 80.0 + (case.delta_y or 0.0) + goal_x = 100 + (case.gap_blocks or 0) + 1 + return start, {"x": float(goal_x), "y": goal_y, "z": 100.0} + if case.family == "neo": + goal_z = 100 + (case.wall_width or 1) + return start, {"x": 102.0, "y": 80.0, "z": float(goal_z)} + goal_x = 100 + (case.gap_blocks or 0) + 1 + return start, {"x": float(goal_x), "y": 80.0, "z": 100.0} + + +def build_canonical_live_cases(cases: list[TheoryCase]) -> list[CanonicalLiveCase]: + live_candidate_cases = [ + case for case in cases + if case.movement_mode == "sprint" and case.momentum_ticks == 12 + ] + + by_bucket: dict[tuple[str, str, str], list[TheoryCase]] = {} + for case in live_candidate_cases: + by_bucket.setdefault((case.family, case.subfamily, case.movement_mode), []).append(case) + + canonical_cases: list[CanonicalLiveCase] = [] + for family, subfamily, movement_mode in sorted(by_bucket): + bucket_cases = by_bucket[(family, subfamily, movement_mode)] + reachable = sorted( + [case for case in bucket_cases if case.expected_reachable and case.margin is not None], + key=lambda case: case.margin, + ) + unreachable = sorted( + [case for case in bucket_cases if not case.expected_reachable], + key=lambda case: float("-inf") if case.margin is None else abs(case.margin), + ) + + selected: list[tuple[str, TheoryCase]] = [] + if reachable: + easy = next((case for case in reversed(reachable) if (case.margin or 0.0) >= 0.50), reachable[-1]) + boundary = reachable[0] + selected.append(("easy", easy)) + if boundary.case_id != easy.case_id: + selected.append(("boundary", boundary)) + if unreachable: + reject = unreachable[0] + selected.append(("reject", reject)) + + for difficulty_band, case in selected: + start, goal = _canonical_goal(case) + canonical_cases.append( + CanonicalLiveCase( + case_id=case.case_id, + bucket_id=f"{family}:{subfamily}:{movement_mode}:{difficulty_band}", + family=family, + subfamily=subfamily, + movement_mode=movement_mode, + momentum_ticks=case.momentum_ticks, + difficulty_band=difficulty_band, + expected_result="pass" if case.expected_reachable else "reject", + world_recipe_id=_world_recipe_id(case), + gap_blocks=case.gap_blocks, + delta_y=case.delta_y, + ceiling_height=case.ceiling_height, + wall_width=case.wall_width, + start=start, + goal=goal, + ) + ) + + return canonical_cases +``` + +Create `tools/pathing_theory/renderers.py`: + +```python +import csv +import json +from dataclasses import asdict +from pathlib import Path + +from tools.pathing_theory.models import CanonicalLiveCase, TheoryCase + + +def write_theory_artifacts( + cases: list[TheoryCase], + canonical_cases: list[CanonicalLiveCase], + output_dir: Path, +) -> None: + output_dir.mkdir(parents=True, exist_ok=True) + + json_path = output_dir / "theory-matrix.json" + csv_path = output_dir / "theory-matrix.csv" + md_path = output_dir / "theory-matrix.md" + canonical_path = output_dir / "canonical-live-cases.json" + + json_path.write_text(json.dumps([asdict(case) for case in cases], indent=2) + "\n") + canonical_path.write_text(json.dumps([asdict(case) for case in canonical_cases], indent=2) + "\n") + + with csv_path.open("w", newline="") as handle: + writer = csv.DictWriter(handle, fieldnames=list(asdict(cases[0]).keys())) + writer.writeheader() + for case in cases: + writer.writerow(asdict(case)) + + lines = [ + "# Theory Matrix", + "", + "| family | subfamily | movement_mode | case_id | expected_reachable | margin |", + "| --- | --- | --- | --- | --- | --- |", + ] + for case in cases: + lines.append( + f"| {case.family} | {case.subfamily} | {case.movement_mode} | {case.case_id} | " + f"{case.expected_reachable} | {case.margin} |" + ) + md_path.write_text("\n".join(lines) + "\n") +``` + +Modify `tools/sim_jump_reach.py` to add an explicit generation command: + +```python +from pathlib import Path + +from tools.pathing_theory.canonical import build_canonical_live_cases +from tools.pathing_theory.renderers import write_theory_artifacts +from tools.pathing_theory.simulator import build_theory_cases + + +def main() -> None: + 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") + parser.add_argument("--write-artifacts", type=str, default=None, help="Write tracked theory artifacts to a directory") + args = parser.parse_args() + + if args.write_artifacts: + cases = build_theory_cases() + canonical_cases = build_canonical_live_cases(cases) + write_theory_artifacts(cases, canonical_cases, Path(args.write_artifacts)) + print(f"Wrote theory artifacts to {args.write_artifacts}") + return + + results = analyze_all(verbose=args.verbose) + if args.csv and results: + keys = set() + for row in results: + keys.update(row.keys()) + with open(args.csv, "w", newline="") as handle: + writer = csv.DictWriter(handle, fieldnames=sorted(keys)) + writer.writeheader() + writer.writerows(results) + print(f"\nResults exported to {args.csv}") +``` + +- [ ] **Step 4: Run the tests, then generate the tracked artifacts** + +Run: + +```bash +python3 -m unittest tools.tests.test_pathing_canonical_cases -v +python3 tools/sim_jump_reach.py --write-artifacts tools/pathing_data +``` + +Expected: + +- the unit test passes +- the CLI prints `Wrote theory artifacts to tools/pathing_data` +- the following files exist: + - `tools/pathing_data/theory-matrix.json` + - `tools/pathing_data/theory-matrix.csv` + - `tools/pathing_data/theory-matrix.md` + - `tools/pathing_data/canonical-live-cases.json` + +- [ ] **Step 5: Commit** + +```bash +git add tools/pathing_theory/models.py \ + tools/pathing_theory/canonical.py \ + tools/pathing_theory/renderers.py \ + tools/sim_jump_reach.py \ + tools/tests/test_pathing_canonical_cases.py \ + tools/pathing_data/theory-matrix.json \ + tools/pathing_data/theory-matrix.csv \ + tools/pathing_data/theory-matrix.md \ + tools/pathing_data/canonical-live-cases.json +git commit -m "feat: generate theory-aligned pathing artifacts" +``` + +### Task 3: Add Theory-To-Live Comparison Reporting + +**Files:** +- Create: `tools/pathing_theory/report.py` +- Create: `tools/pathing_theory_report.py` +- Create: `tools/tests/test_pathing_theory_report.py` + +- [ ] **Step 1: Write the failing report-classification test** + +Create `tools/tests/test_pathing_theory_report.py`: + +```python +import json +import tempfile +import unittest +from pathlib import Path + +from tools.pathing_theory.report import build_report, classify_live_result, summarize_results + + +class PathingTheoryReportTests(unittest.TestCase): + def test_classify_live_result_distinguishes_expected_pass_and_reject(self) -> None: + self.assertEqual(classify_live_result("pass", "pass"), "expected_pass/live_pass") + self.assertEqual(classify_live_result("pass", "fail"), "expected_pass/live_fail") + self.assertEqual(classify_live_result("reject", "reject"), "expected_reject/live_reject") + self.assertEqual(classify_live_result("reject", "pass"), "expected_reject/live_unexpected_pass") + + def test_summarize_results_counts_each_status(self) -> None: + rows = [ + {"case_id": "a", "expected_result": "pass", "live_result": "pass"}, + {"case_id": "b", "expected_result": "pass", "live_result": "fail"}, + {"case_id": "c", "expected_result": "reject", "live_result": "reject"}, + ] + + summary = summarize_results(rows) + + self.assertEqual(summary["expected_pass/live_pass"], 1) + self.assertEqual(summary["expected_pass/live_fail"], 1) + self.assertEqual(summary["expected_reject/live_reject"], 1) + + def test_build_report_keeps_case_traceability_fields(self) -> None: + manifest_rows = [ + { + "case_id": "linear-flat-sprint-mm12-gap5-dy0p0", + "bucket_id": "linear:flat:sprint:boundary", + "world_recipe_id": "linear-flat", + "expected_result": "pass", + } + ] + result_row = { + "case_id": "linear-flat-sprint-mm12-gap5-dy0p0", + "live_result": "pass", + "log_path": "/tmp/mcc-debug/mcc-debug.log", + } + + with tempfile.TemporaryDirectory() as temp_dir: + manifest_path = Path(temp_dir) / "manifest.json" + results_path = Path(temp_dir) / "results.jsonl" + manifest_path.write_text(json.dumps(manifest_rows), encoding="utf-8") + results_path.write_text(json.dumps(result_row) + "\n", encoding="utf-8") + + report = build_report(manifest_path, results_path) + + row = report["rows"][0] + self.assertEqual(row["bucket_id"], "linear:flat:sprint:boundary") + self.assertEqual(row["world_recipe_id"], "linear-flat") + self.assertEqual(row["log_path"], "/tmp/mcc-debug/mcc-debug.log") + self.assertEqual(row["classification"], "expected_pass/live_pass") +``` + +- [ ] **Step 2: Run the test to verify it fails** + +Run: + +```bash +python3 -m unittest tools.tests.test_pathing_theory_report -v +``` + +Expected: FAIL with `ModuleNotFoundError: No module named 'tools.pathing_theory.report'`. + +- [ ] **Step 3: Implement report classification, joining, and CLI output** + +Create `tools/pathing_theory/report.py`: + +```python +import json +from pathlib import Path + + +def classify_live_result(expected_result: str, live_result: str) -> str: + if live_result == "invalid_live_case": + return "invalid_live_case" + if expected_result == "pass" and live_result == "pass": + return "expected_pass/live_pass" + if expected_result == "pass" and live_result == "fail": + return "expected_pass/live_fail" + if expected_result == "reject" and live_result == "reject": + return "expected_reject/live_reject" + if expected_result == "reject" and live_result == "pass": + return "expected_reject/live_unexpected_pass" + return "invalid_live_case" + + +def summarize_results(rows: list[dict]) -> dict[str, int]: + summary: dict[str, int] = {} + for row in rows: + key = classify_live_result(row["expected_result"], row["live_result"]) + summary[key] = summary.get(key, 0) + 1 + return summary + + +def build_report(manifest_path: Path, results_path: Path) -> dict: + manifest_rows = json.loads(manifest_path.read_text(encoding="utf-8")) + manifest_by_case = {row["case_id"]: row for row in manifest_rows} + result_rows = [ + json.loads(line) + for line in results_path.read_text(encoding="utf-8").splitlines() + if line.strip() + ] + + joined_rows: list[dict] = [] + for row in result_rows: + manifest = manifest_by_case.get(row["case_id"]) + if manifest is None: + joined_rows.append({**row, "classification": "invalid_live_case"}) + continue + joined_rows.append( + { + **manifest, + **row, + "classification": classify_live_result(manifest["expected_result"], row["live_result"]), + } + ) + + return { + "rows": joined_rows, + "summary": summarize_results(joined_rows), + } +``` + +Create `tools/pathing_theory_report.py`: + +```python +#!/usr/bin/env python3 +import argparse +import json +from pathlib import Path + +from tools.pathing_theory.report import build_report + + +def main() -> None: + parser = argparse.ArgumentParser(description="Join theory-aligned live results back to canonical cases.") + parser.add_argument("--manifest", required=True) + parser.add_argument("--results", required=True) + parser.add_argument("--json-out", required=True) + args = parser.parse_args() + + report = build_report(Path(args.manifest), Path(args.results)) + Path(args.json_out).write_text(json.dumps(report, indent=2) + "\n") + print(f"Wrote report to {args.json_out}") + + +if __name__ == "__main__": + main() +``` + +- [ ] **Step 4: Run the report test to verify it passes** + +Run: + +```bash +python3 -m unittest tools.tests.test_pathing_theory_report -v +``` + +Expected: PASS with both tests green. + +- [ ] **Step 5: Commit** + +```bash +git add tools/pathing_theory/report.py \ + tools/pathing_theory_report.py \ + tools/tests/test_pathing_theory_report.py +git commit -m "feat: add theory-to-live pathing report" +``` + +### Task 4: Refactor The Linear Live Harness To Consume Canonical Cases + +**Files:** +- Create: `tools/pathing_live_common.sh` +- Modify: `tools/test-parkour.sh` +- Create: `tools/tests/test_pathing_live_scripts.py` + +- [ ] **Step 1: Write the failing linear-suite manifest smoke test** + +Create `tools/tests/test_pathing_live_scripts.py`: + +```python +import subprocess +import unittest + + +class PathingLiveScriptTests(unittest.TestCase): + def test_test_parkour_lists_linear_canonical_cases(self) -> None: + result = subprocess.run( + ["bash", "tools/test-parkour.sh", "--list-cases"], + check=False, + capture_output=True, + text=True, + ) + + self.assertEqual(result.returncode, 0, result.stderr) + self.assertIn("linear-flat-sprint-mm12-gap5-dy0p0", result.stdout) + self.assertIn("linear-ascend-sprint-mm12-gap2-dy1p0", result.stdout) + self.assertNotIn("linear-flat-walk-mm12-gap5-dy0p0", result.stdout) + self.assertNotIn("linear-flat-sprint-mm0-gap3-dy0p0", result.stdout) + + +if __name__ == "__main__": + unittest.main() +``` + +- [ ] **Step 2: Run the smoke test to verify it fails** + +Run: + +```bash +python3 -m unittest tools.tests.test_pathing_live_scripts.PathingLiveScriptTests.test_test_parkour_lists_linear_canonical_cases -v +``` + +Expected: FAIL because `tools/test-parkour.sh` does not understand `--list-cases`. + +- [ ] **Step 3: Add shared manifest helpers and make `test-parkour.sh` data-driven** + +Create `tools/pathing_live_common.sh`: + +```bash +#!/usr/bin/env bash + +manifest_cases_for_query() { + local manifest_path="$1" + local family_csv="$2" + + python3 - "$manifest_path" "$family_csv" <<'PY' +import json +import sys + +manifest = json.load(open(sys.argv[1], "r", encoding="utf-8")) +families = {item for item in sys.argv[2].split(",") if item} +for row in manifest: + if row["family"] in families and row["movement_mode"] == "sprint" and row["momentum_ticks"] == 12: + print(row["case_id"]) +PY +} + +manifest_case_json() { + local manifest_path="$1" + local case_id="$2" + + python3 - "$manifest_path" "$case_id" <<'PY' +import json +import sys + +manifest = json.load(open(sys.argv[1], "r", encoding="utf-8")) +case_id = sys.argv[2] +row = next(row for row in manifest if row["case_id"] == case_id) +print(json.dumps(row)) +PY +} + +record_live_result() { + local results_path="$1" + local case_json="$2" + local live_result="$3" + local log_path="$4" + + python3 - "$results_path" "$case_json" "$live_result" "$log_path" <<'PY' +import json +import sys + +row = json.loads(sys.argv[2]) +record = { + "case_id": row["case_id"], + "bucket_id": row["bucket_id"], + "world_recipe_id": row["world_recipe_id"], + "expected_result": row["expected_result"], + "live_result": sys.argv[3], + "log_path": sys.argv[4], +} +with open(sys.argv[1], "a", encoding="utf-8") as handle: + handle.write(json.dumps(record) + "\n") +PY +} +``` + +Modify the top of `tools/test-parkour.sh`: + +```bash +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" +source "$REPO_ROOT/tools/mcc-env.sh" +source "$REPO_ROOT/tools/pathing_live_common.sh" + +MANIFEST="$REPO_ROOT/tools/pathing_data/canonical-live-cases.json" +RESULTS_FILE="${RESULTS_FILE:-/tmp/mcc-debug/pathing-live-results.jsonl}" +LOG="/tmp/mcc-debug/mcc-debug.log" + +if [[ "${1:-}" == "--list-cases" ]]; then + manifest_cases_for_query "$MANIFEST" "linear" + exit 0 +fi + +: > "$RESULTS_FILE" +``` + +Add a data-driven runner to `tools/test-parkour.sh`. + +First, keep the existing `run_test()` helper but replace the old hardcoded result enum with these normalized live-result values before it returns: + +```bash + local result="invalid_live_case" + if echo "$path_mgr" | grep -q "complete"; then + result="pass" + elif echo "$a_star_result" | grep -q "Failed"; then + result="reject" + elif echo "$path_mgr" | grep -q "Replan failed\|Giving up"; then + result="fail" + elif echo "$path_exec" | grep -q "FAILED"; then + result="fail" + fi + LAST_RESULT="$result" +``` + +Next, move the finalized `run_test()` helper into `tools/pathing_live_common.sh` so both theory-aligned shell suites reuse the same MCC log parsing logic and the same `LAST_RESULT` contract. + +Then replace the hardcoded case list in `tools/test-parkour.sh` with: + +```bash +run_manifest_case() { + local case_id="$1" + local case_json + case_json="$(manifest_case_json "$MANIFEST" "$case_id")" + + read -r world_recipe start_x start_y start_z goal_x goal_y goal_z < <( + python3 - "$case_json" <<'PY' +import json +import sys + +row = json.loads(sys.argv[1]) +print( + row["world_recipe_id"], + row["start"]["x"], + row["start"]["y"], + row["start"]["z"], + row["goal"]["x"], + row["goal"]["y"], + row["goal"]["z"], +) +PY + ) + + local landing_block_y=$(( ${goal_y%.*} - 1 )) + + case "$world_recipe" in + linear-flat|linear-ascend|linear-descend) + mc-rcon "fill 95 80 95 115 90 105 air" >/dev/null + mc-rcon "fill 95 79 95 115 79 105 air" >/dev/null + mc-rcon "setblock 100 79 100 stone" >/dev/null + mc-rcon "setblock ${goal_x%.*} ${landing_block_y} ${goal_z%.*} stone" >/dev/null + ;; + *) + echo "Unsupported world recipe for test-parkour.sh: $world_recipe" >&2 + return 1 + ;; + esac + + run_test "$case_id" "${start_x%.*}" "${start_y%.*}" "${start_z%.*}" "${goal_x%.*}" "${goal_y%.*}" "${goal_z%.*}" + record_live_result "$RESULTS_FILE" "$case_json" "$LAST_RESULT" "$LOG" +} + +while IFS= read -r case_id; do + run_manifest_case "$case_id" +done < <(manifest_cases_for_query "$MANIFEST" "linear") +``` + +Leave the existing low-level MCC log parsing logic intact apart from the normalized result names. This task changes case sourcing and result recording, not the underlying MCC log parsing heuristics. + +- [ ] **Step 4: Run the smoke test and shell syntax check** + +Run: + +```bash +python3 -m unittest tools.tests.test_pathing_live_scripts.PathingLiveScriptTests.test_test_parkour_lists_linear_canonical_cases -v +bash -n tools/pathing_live_common.sh tools/test-parkour.sh +``` + +Expected: + +- the unit test passes +- `bash -n` prints nothing and exits `0` + +- [ ] **Step 5: Commit** + +```bash +git add tools/pathing_live_common.sh \ + tools/test-parkour.sh \ + tools/tests/test_pathing_live_scripts.py +git commit -m "test: make linear pathing suite manifest-driven" +``` + +### Task 5: Add The Theory-Aligned Neo And Ceiling Suite + +**Files:** +- Modify: `tools/tests/test_pathing_live_scripts.py` +- Create: `tools/test-pathing-theory-neo-ceiling.sh` + +- [ ] **Step 1: Write the failing neo and ceiling listing test** + +Append to `tools/tests/test_pathing_live_scripts.py`: + +```python + def test_test_pathing_theory_neo_ceiling_lists_theory_cases(self) -> None: + result = subprocess.run( + ["bash", "tools/test-pathing-theory-neo-ceiling.sh", "--list-cases"], + check=False, + capture_output=True, + text=True, + ) + + self.assertEqual(result.returncode, 0, result.stderr) + self.assertIn("neo-neo-sprint-mm12-wall1", result.stdout) + self.assertIn("ceiling-headhitter-sprint-mm12-gap3-ceil2p0", result.stdout) +``` + +- [ ] **Step 2: Run the listing tests to verify the new one fails** + +Run: + +```bash +python3 -m unittest tools.tests.test_pathing_live_scripts -v +``` + +Expected: FAIL because `tools/test-pathing-theory-neo-ceiling.sh` does not exist yet. + +- [ ] **Step 3: Implement the theory-aligned neo and ceiling suite** + +Create `tools/test-pathing-theory-neo-ceiling.sh`: + +```bash +#!/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" +source "$REPO_ROOT/tools/pathing_live_common.sh" + +MANIFEST="$REPO_ROOT/tools/pathing_data/canonical-live-cases.json" +RESULTS_FILE="${RESULTS_FILE:-/tmp/mcc-debug/pathing-live-results.jsonl}" +LOG="/tmp/mcc-debug/mcc-debug.log" + +if [[ "${1:-}" == "--list-cases" ]]; then + manifest_cases_for_query "$MANIFEST" "neo,ceiling" + exit 0 +fi + +: > "$RESULTS_FILE" + +setup_neo_wall() { + local wall_width="$1" + local goal_z="$2" + mc-rcon "fill 95 79 95 115 90 115 air" >/dev/null + mc-rcon "setblock 100 79 100 stone" >/dev/null + mc-rcon "fill 101 79 100 101 79 $((99 + wall_width)) stone" >/dev/null + mc-rcon "setblock 102 79 ${goal_z} stone" >/dev/null +} + +setup_ceiling_headhitter() { + local goal_x="$1" + local ceiling_y="$2" + mc-rcon "fill 95 79 95 115 90 105 air" >/dev/null + mc-rcon "setblock 100 79 100 stone" >/dev/null + mc-rcon "setblock ${goal_x} 79 100 stone" >/dev/null + mc-rcon "fill 100 ${ceiling_y} 100 ${goal_x} ${ceiling_y} 100 stone" >/dev/null +} +``` + +Because Task 4 moved `run_test()` into `tools/pathing_live_common.sh`, this script can reuse that helper directly. Add the per-case runner: + +```bash +run_manifest_case() { + local case_id="$1" + local case_json + case_json="$(manifest_case_json "$MANIFEST" "$case_id")" + + read -r world_recipe start_x start_y start_z goal_x goal_y goal_z ceiling_height wall_width < <( + python3 - "$case_json" <<'PY' +import json +import sys + +row = json.loads(sys.argv[1]) +print( + row["world_recipe_id"], + row["start"]["x"], + row["start"]["y"], + row["start"]["z"], + row["goal"]["x"], + row["goal"]["y"], + row["goal"]["z"], + row.get("ceiling_height", "null"), + row.get("wall_width", "null"), +) +PY + ) + + case "$world_recipe" in + neo-wall) + setup_neo_wall "${wall_width%.*}" "${goal_z%.*}" + ;; + ceiling-headhitter) + setup_ceiling_headhitter "${goal_x%.*}" "${ceiling_height%.*}" + ;; + *) + echo "Unsupported world recipe for theory neo/ceiling suite: $world_recipe" >&2 + return 1 + ;; + esac + + run_test "$case_id" "${start_x%.*}" "${start_y%.*}" "${start_z%.*}" "${goal_x%.*}" "${goal_y%.*}" "${goal_z%.*}" + record_live_result "$RESULTS_FILE" "$case_json" "$LAST_RESULT" "$LOG" +} + +while IFS= read -r case_id; do + run_manifest_case "$case_id" +done < <(manifest_cases_for_query "$MANIFEST" "neo,ceiling") +``` + +Keep the suite scoped to listing plus canonical execution. Do not add mixed-route or braking scenarios here. + +- [ ] **Step 4: Run the listing tests and syntax check** + +Run: + +```bash +python3 -m unittest tools.tests.test_pathing_live_scripts -v +bash -n tools/test-pathing-theory-neo-ceiling.sh +``` + +Expected: + +- all tests in `tools.tests.test_pathing_live_scripts` pass +- shell syntax check exits `0` + +- [ ] **Step 5: Commit** + +```bash +git add tools/tests/test_pathing_live_scripts.py \ + tools/test-pathing-theory-neo-ceiling.sh +git commit -m "test: add theory-aligned neo and ceiling suite" +``` + +### Task 6: Document The Workflow And Run Final Regeneration Checks + +**Files:** +- Modify: `docs/guide/pathfinding-research.md` +- Modify: `tools/pathing_data/theory-matrix.json` +- Modify: `tools/pathing_data/theory-matrix.csv` +- Modify: `tools/pathing_data/theory-matrix.md` +- Modify: `tools/pathing_data/canonical-live-cases.json` + +- [ ] **Step 1: Add the failing documentation check** + +Append to `tools/tests/test_pathing_theory_matrix.py`: + +```python + def test_theory_markdown_mentions_canonical_live_coverage(self) -> None: + markdown = Path("tools/pathing_data/theory-matrix.md").read_text() + self.assertIn("Canonical live coverage", markdown) +``` + +Also add the missing import at the top of the test file: + +```python +from pathlib import Path +``` + +- [ ] **Step 2: Run the documentation check to verify it fails** + +Run: + +```bash +python3 -m unittest tools.tests.test_pathing_theory_matrix.PathingTheoryMatrixTests.test_theory_markdown_mentions_canonical_live_coverage -v +``` + +Expected: FAIL because the generated Markdown does not yet include that section. + +- [ ] **Step 3: Update the Markdown renderer, regenerate artifacts, and document the workflow** + +Modify the Markdown generation in `tools/pathing_theory/renderers.py`: + +```python + lines = [ + "# Theory Matrix", + "", + "## Canonical live coverage", + "", + "This file is generated from `tools/sim_jump_reach.py` and is the first-wave authority", + "for theory-aligned linear, neo, and headhitter live suites.", + "", + "| family | subfamily | movement_mode | case_id | expected_reachable | margin |", + "| --- | --- | --- | --- | --- | --- |", + ] +``` + +Add this section to `docs/guide/pathfinding-research.md`: + +```md +## Theory-Aligned Regression Workflow + +The first-wave authority now comes from `tools/sim_jump_reach.py`, which writes: + +- `tools/pathing_data/theory-matrix.json` +- `tools/pathing_data/theory-matrix.csv` +- `tools/pathing_data/theory-matrix.md` +- `tools/pathing_data/canonical-live-cases.json` + +Regenerate them with: + +```bash +python3 tools/sim_jump_reach.py --write-artifacts tools/pathing_data +``` + +Theory-aligned live suites consume the canonical manifest instead of embedding +their own pass and reject expectations: + +- `tools/test-parkour.sh` +- `tools/test-pathing-theory-neo-ceiling.sh` + +Each theory-aligned live run appends ephemeral JSONL rows to +`/tmp/mcc-debug/pathing-live-results.jsonl`. Join them back to theory with: + +```bash +python3 tools/pathing_theory_report.py \ + --manifest tools/pathing_data/canonical-live-cases.json \ + --results /tmp/mcc-debug/pathing-live-results.jsonl \ + --json-out /tmp/mcc-debug/pathing-theory-report.json +``` + +The specialized live suites remain useful, but they are not part of the +first-wave theory contract: + +- `tools/test-pathing-jump-combos.sh` +- `tools/test-pathing-template-regressions.sh` +- `tools/test-pathing-long-routes.sh` +- `tools/test-transition-braking.sh` +``` + +Regenerate the tracked artifacts: + +```bash +python3 tools/sim_jump_reach.py --write-artifacts tools/pathing_data +``` + +- [ ] **Step 4: Run the full first-wave verification set** + +Run: + +```bash +python3 -m unittest discover -s tools/tests -p 'test_*.py' -v +python3 tools/sim_jump_reach.py --write-artifacts tools/pathing_data +bash -n tools/pathing_live_common.sh tools/test-parkour.sh tools/test-pathing-theory-neo-ceiling.sh +``` + +Expected: + +- all Python tests pass +- theory artifacts regenerate cleanly +- all three shell scripts pass syntax checks + +- [ ] **Step 5: Commit** + +```bash +git add docs/guide/pathfinding-research.md \ + tools/pathing_theory/renderers.py \ + tools/pathing_data/theory-matrix.json \ + tools/pathing_data/theory-matrix.csv \ + tools/pathing_data/theory-matrix.md \ + tools/pathing_data/canonical-live-cases.json +git commit -m "docs: document theory-aligned pathing workflow" +``` + +## Self-Review + +### Spec coverage + +- Theory authority from `tools/sim_jump_reach.py` + - Covered by Task 1 and Task 2 +- Machine-readable and human-readable outputs from one source + - Covered by Task 2 and Task 6 +- Canonical live coverage instead of replaying every theory case + - Covered by Task 2, Task 4, and Task 5 +- Traceability from live cases back to theory case IDs + - Covered by Task 2, Task 3, Task 4, and Task 5 +- Specialized live suites remain out of the first-wave theory contract + - Covered by Task 6 documentation +- Current MCC local workflow preserved + - Covered by Task 4 and Task 5 by reusing `tools/mcc-env.sh` + +No uncovered spec requirements remain. + +### Placeholder scan + +- Searched this plan for `TBD`, `TODO`, and “implement later” +- Replaced vague “refactor harness” wording with concrete files, CLI flags, dataclasses, and commands +- Repeated the exact file paths and commands for every task instead of using “similar to previous task” + +### Type consistency + +- `TheoryCase`, `CanonicalLiveCase`, and report rows are introduced before any later task consumes them +- `tools/pathing_theory/primitives.py` prevents `tools/sim_jump_reach.py` and `tools/pathing_theory/simulator.py` from importing each other +- `build_theory_cases`, `build_canonical_live_cases`, `write_theory_artifacts`, `classify_live_result`, and `build_report` use consistent names throughout +- `CanonicalLiveCase` now carries `momentum_ticks`, `gap_blocks`, `delta_y`, `ceiling_height`, and `wall_width`, which are the same geometry fields the live suites consume +- The live scripts always consume `tools/pathing_data/canonical-live-cases.json`, not mixed manifest names diff --git a/docs/superpowers/plans/2026-04-14-pathing-execution-regression-fixes.md b/docs/superpowers/plans/2026-04-14-pathing-execution-regression-fixes.md new file mode 100644 index 00000000..afecc0e2 --- /dev/null +++ b/docs/superpowers/plans/2026-04-14-pathing-execution-regression-fixes.md @@ -0,0 +1,877 @@ +# Pathing Execution Regression Fixes 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:** Remove the current execution-layer regressions exposed by the contract/timing harness so deterministic jump-combo and long-route scenarios complete with `0` replans and within their existing budgets. + +**Architecture:** Treat the failures as three runtime bugs, not as harness problems. First tighten parkour landing recovery so chained jumps hand off with the right speed instead of stalling or replan-looping. Second make transition braking and lookahead score the next segment entry contract, so mixed turn/ascend/descend routes stop choosing the wrong carry-or-brake profile. Third harden chained ascends for live-runtime carry states so staircases stop burning extra ticks after each landing. Keep the existing JSON contracts, scenario catalog, and shell harnesses unchanged except for verification. + +**Tech Stack:** C# 14 / .NET 10, xUnit, MCC pathing execution templates, `PlayerPhysics`, existing `MinecraftClient.Tests` scenario runner and timing contracts, local `1.21.11-Vanilla` live harness via `tools/mcc-env.sh`. + +--- + +## Scope Check + +This plan only covers runtime execution fixes in the existing pathing stack. + +Out of scope: + +- planner-contract schema changes +- theory-matrix generation changes +- telemetry/report format changes +- new live harness features +- broad planner heuristics refactors + +## Current Failure Inventory + +Focused xUnit evidence from: + +```bash +dotnet test MinecraftClient.Tests/MinecraftClient.Tests.csproj --filter "FullyQualifiedName~Pathing.Execution" -v minimal +``` + +Current failing families: + +- repeated parkour chains do not complete cleanly + - `repeated-cardinal-parkour-chain`: navigation did not complete, `replans=4` + - `repeated-diagonal-parkour-chain`: expected `0` replans, saw `2` + - `obstructed-parkour-l-turns`: navigation did not complete, `replans=1` + - `same-move-aligned-parkour-chain`: navigation did not complete, `replans=4` +- mixed vertical and mixed long routes over-brake or replan unexpectedly + - `vertical-jump-mix`: expected `0` replans, saw `1` + - `diagonal-vertical-mix`: expected `0` replans, saw `1` + - `mixed-traverse-turn-parkour-turn-traverse`: expected `0` replans, saw `1` + - `mixed-traverse-ascend-parkour-descend`: expected `0` replans, saw `1` + - `speed-carry-repeated-traverse-descend`: expected `0` replans, saw `1` + - `speed-carry-repeated-traverse-parkour`: navigation did not complete, `replans=4` + +Live harness evidence: + +```bash +source tools/mcc-env.sh && bash tools/test-pathing-jump-combos.sh 1.21.11-Vanilla +source tools/mcc-env.sh && bash tools/test-pathing-long-routes.sh 1.21.11-Vanilla +``` + +Current live failures: + +- `same-move-ascend-staircase`: `actual=145 max=68`, first four ascend segments each over by roughly `+22` to `+23` ticks +- `vertical-jump-mix`: `actual=54 max=40` +- repeated parkour chains fail with segment failure followed by replan loops + +## Problem Map + +1. `MinecraftClient/Pathing/Execution/Templates/SprintJumpTemplate.cs` + - landing recovery is still biased toward “settle fully” behavior + - `pastTarget` release is too blunt for repeated parkour and mixed jump chains + - completion rules do not preserve enough entry speed for immediate follow-up jumps + +2. `MinecraftClient/Pathing/Execution/TransitionBrakingPlanner.cs` + - planning still reasons mostly about the current segment + - special-cases landing-recovery turns, but not the broader mixed-route handoff problem + +3. `MinecraftClient/Pathing/Execution/TransitionLookaheadEvaluator.cs` + - air and ground scoring ignore too much next-segment intent + - current profiles cannot distinguish “slow down for stable turn entry” from “keep enough speed for the next descend or jump” + +4. `MinecraftClient/Pathing/Execution/Templates/AscendTemplate.cs` + - chained ascends do not explicitly separate takeoff, airborne, and landing handoff + - live staircase traces show repeated post-landing delay before the next step starts + +5. `MinecraftClient/Pathing/Execution/Templates/GroundedSegmentController.cs` + - grounded completion is strong for final stops, but too conservative for continue-straight ascend handoff + +## File Structure + +### Production files + +- Modify: `MinecraftClient/Pathing/Execution/Templates/SprintJumpTemplate.cs` + - parkour landing recovery completion and in-air release rules +- Modify: `MinecraftClient/Pathing/Execution/TransitionBrakingPlanner.cs` + - next-segment-aware braking decisions +- Modify: `MinecraftClient/Pathing/Execution/TransitionLookaheadEvaluator.cs` + - ground and air profile scoring that considers the next segment contract +- Modify: `MinecraftClient/Pathing/Execution/Templates/AscendTemplate.cs` + - explicit ascend phase handling and faster landing handoff +- Modify: `MinecraftClient/Pathing/Execution/Templates/GroundedSegmentController.cs` + - shared completion rules for continue-straight ascend chaining + +### Test files + +- Modify: `MinecraftClient.Tests/Pathing/Execution/PathTimingContractTests.cs` + - named regression entry points for representative failing scenarios +- Modify: `MinecraftClient.Tests/Pathing/Execution/SprintJumpTemplateScenarioTests.cs` + - deterministic chained-jump handoff regression +- Modify: `MinecraftClient.Tests/Pathing/Execution/TransitionLookaheadEvaluatorTests.cs` + - ground and air next-segment profile regressions +- Modify: `MinecraftClient.Tests/Pathing/Execution/TransitionBrakingPlannerTests.cs` + - planner decisions for mixed handoff states +- Modify: `MinecraftClient.Tests/Pathing/Execution/GroundedTemplateConvergenceTests.cs` + - chained-ascend convergence regression + +### Verification only + +- Reuse: `tools/test-pathing-jump-combos.sh` +- Reuse: `tools/test-pathing-long-routes.sh` + +--- + +### Task 1: Stabilize Repeated Parkour Landing Recovery + +**Files:** +- Modify: `MinecraftClient.Tests/Pathing/Execution/PathTimingContractTests.cs` +- Modify: `MinecraftClient.Tests/Pathing/Execution/SprintJumpTemplateScenarioTests.cs` +- Modify: `MinecraftClient/Pathing/Execution/Templates/SprintJumpTemplate.cs` + +- [ ] **Step 1: Write failing parkour-focused regression tests** + +Update `MinecraftClient.Tests/Pathing/Execution/PathTimingContractTests.cs`: + +```csharp +using MinecraftClient.Tests.Pathing.Execution.Contracts; +using Xunit; + +namespace MinecraftClient.Tests.Pathing.Execution; + +public sealed class PathTimingContractTests +{ + [Fact] + public void RepeatedCardinalParkourChain_ExecutionStaysWithinBudget() => + AssertScenarioWithinBudget("repeated-cardinal-parkour-chain"); + + [Fact] + public void RepeatedDiagonalParkourChain_ExecutionStaysWithinBudget() => + AssertScenarioWithinBudget("repeated-diagonal-parkour-chain"); + + private static void AssertScenarioWithinBudget(string scenarioId) + { + PathingExecutionScenario scenario = PathingExecutionScenarioCatalog.Get(scenarioId); + PathingTimingBudget budget = PathingContractStore.LoadFromRepositoryRoot().GetTiming(scenarioId); + PathingScenarioResult result = PathingScenarioRunner.RunAccepted(scenario); + + PathingContractAssert.TimingMatches(budget, result); + } +} +``` + +Update `MinecraftClient.Tests/Pathing/Execution/SprintJumpTemplateScenarioTests.cs`: + +```csharp +[Fact] +public void SprintJumpTemplate_LandingRecovery_LeavesEnoughSpeedForNextParkour() +{ + World world = FlatWorldTestBuilder.CreateStoneFloor(min: 578, max: 586); + FlatWorldTestBuilder.ClearBox(world, 578, 79, 578, 586, 90, 582); + FlatWorldTestBuilder.SetSolid(world, 580, 79, 580); + FlatWorldTestBuilder.SetSolid(world, 582, 79, 580); + FlatWorldTestBuilder.SetSolid(world, 584, 79, 580); + + var current = new PathSegment + { + Start = new Location(580.5, 80, 580.5), + End = new Location(582.5, 80, 580.5), + MoveType = MoveType.Parkour, + ExitTransition = PathTransitionType.LandingRecovery, + ExitHints = new PathTransitionHints(1, 0, 0.12, 0.20, false, true, true, true, 12), + PreserveSprint = true + }; + var next = new PathSegment + { + Start = current.End, + End = new Location(584.5, 80, 580.5), + MoveType = MoveType.Parkour, + ExitTransition = PathTransitionType.LandingRecovery, + ExitHints = new PathTransitionHints(1, 0, 0.12, 0.20, false, true, true, true, 12), + PreserveSprint = true + }; + + var template = new SprintJumpTemplate(current, next); + var physics = TemplateSimulationRunner.CreateGroundedPhysics(current.Start, yaw: 270f); + + TemplateState state = TemplateSimulationRunner.Run(template, physics, world, maxTicks: 140, out Location finalPos); + + Assert.Equal(TemplateState.Complete, state); + Assert.True(TemplateFootingHelper.IsCenterInsideTargetBlock(finalPos, current.End), $"finalPos={finalPos} vel={physics.DeltaMovement}"); + Assert.InRange(physics.DeltaMovement.X, 0.12, 0.30); +} +``` + +- [ ] **Step 2: Run the focused tests and verify they fail** + +Run: + +```bash +dotnet test MinecraftClient.Tests/MinecraftClient.Tests.csproj --filter "FullyQualifiedName~Pathing.Execution.PathTimingContractTests.RepeatedCardinalParkourChain_ExecutionStaysWithinBudget|FullyQualifiedName~Pathing.Execution.PathTimingContractTests.RepeatedDiagonalParkourChain_ExecutionStaysWithinBudget|FullyQualifiedName~Pathing.Execution.SprintJumpTemplateScenarioTests.SprintJumpTemplate_LandingRecovery_LeavesEnoughSpeedForNextParkour" -v minimal +``` + +Expected: FAIL with either `navigation did not complete`, nonzero replans, or residual speed below the handoff minimum. + +- [ ] **Step 3: Make `SprintJumpTemplate` preserve jump-ready handoff instead of over-settling** + +Update `MinecraftClient/Pathing/Execution/Templates/SprintJumpTemplate.cs`: + +```csharp +case Phase.Airborne: +{ + if (!physics.OnGround) + _leftGround = true; + + bool releaseInAir = ShouldReleaseInAir(pos, physics, world); + bool hardRelease = releaseInAir; + if (_segment.ExitTransition != PathTransitionType.LandingRecovery && IsPastTarget(pos)) + hardRelease = true; + + if (hardRelease) + { + 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: + if (ShouldCompleteLandingRecoveryHandoff(pos, physics)) + return TemplateState.Complete; + + TransitionBrakingDecision decision = TransitionBrakingPlanner.Plan(_segment, _nextSegment, pos, physics, world); + TemplateHelper.ApplyDecision(input, decision); + if (decision.HoldBack) + TemplateHelper.FaceSegmentHeading(physics, _segment); + else if (TemplateHelper.ShouldBiasTowardExitHeading(pos, _segment)) + TemplateHelper.FaceExitHeading(physics, _segment); + + if (_segment.ExitTransition == PathTransitionType.ContinueStraight + && horizDistSq < 2.25 + && Math.Abs(dy) < 1.0) + { + return TemplateState.Complete; + } + break; + +private bool ShouldCompleteLandingRecoveryHandoff(Location pos, PlayerPhysics physics) +{ + if (_segment.ExitTransition != PathTransitionType.LandingRecovery || _nextSegment is null || !physics.OnGround) + return false; + + double exitSpeed = TemplateHelper.ProjectHorizontalSpeedAlongHint(physics, _segment); + if (_nextSegment.ExitHints.RequireJumpReady) + { + return TemplateFootingHelper.IsCenterInsideTargetBlock(pos, ExpectedEnd) + && !TemplateFootingHelper.WillCenterLeaveTargetBlockNextTick(pos, physics, ExpectedEnd) + && exitSpeed >= _nextSegment.ExitHints.MinExitSpeed; + } + + return TemplateFootingHelper.IsCenterInsideSupportStrip(pos, ExpectedEnd, _nextSegment.End) + && !TemplateFootingHelper.WillCenterLeaveSupportStripNextTick(pos, physics, ExpectedEnd, _nextSegment.End) + && exitSpeed <= _segment.ExitHints.MaxExitSpeed; +} +``` + +- [ ] **Step 4: Re-run the parkour-focused tests and then the whole jump-combo contract group** + +Run: + +```bash +dotnet test MinecraftClient.Tests/MinecraftClient.Tests.csproj --filter "FullyQualifiedName~Pathing.Execution.PathTimingContractTests.RepeatedCardinalParkourChain_ExecutionStaysWithinBudget|FullyQualifiedName~Pathing.Execution.PathTimingContractTests.RepeatedDiagonalParkourChain_ExecutionStaysWithinBudget|FullyQualifiedName~Pathing.Execution.PathTimingContractTests.JumpCombo_ExecutionStaysWithinBudget|FullyQualifiedName~Pathing.Execution.SprintJumpTemplateScenarioTests" -v minimal +``` + +Expected: PASS for the two named regressions and no new failures in the broader jump-template coverage. + +- [ ] **Step 5: Commit the parkour landing recovery fix** + +```bash +git add MinecraftClient.Tests/Pathing/Execution/PathTimingContractTests.cs \ + MinecraftClient.Tests/Pathing/Execution/SprintJumpTemplateScenarioTests.cs \ + MinecraftClient/Pathing/Execution/Templates/SprintJumpTemplate.cs +git commit -m "fix: preserve jump-ready speed through parkour landing recovery" +``` + +### Task 2: Make Braking And Lookahead Respect The Next Segment Contract + +**Files:** +- Modify: `MinecraftClient.Tests/Pathing/Execution/PathTimingContractTests.cs` +- Modify: `MinecraftClient.Tests/Pathing/Execution/TransitionLookaheadEvaluatorTests.cs` +- Modify: `MinecraftClient.Tests/Pathing/Execution/TransitionBrakingPlannerTests.cs` +- Modify: `MinecraftClient/Pathing/Execution/TransitionLookaheadEvaluator.cs` +- Modify: `MinecraftClient/Pathing/Execution/TransitionBrakingPlanner.cs` + +- [ ] **Step 1: Add failing mixed-route regression tests** + +Update `MinecraftClient.Tests/Pathing/Execution/PathTimingContractTests.cs`: + +```csharp +[Fact] +public void MixedTraverseAscendParkourDescend_ExecutionStaysWithinBudget() => + AssertScenarioWithinBudget("mixed-traverse-ascend-parkour-descend"); + +[Fact] +public void MixedTraverseTurnParkourTurnTraverse_ExecutionStaysWithinBudget() => + AssertScenarioWithinBudget("mixed-traverse-turn-parkour-turn-traverse"); + +[Fact] +public void SpeedCarryRepeatedTraverseDescend_ExecutionStaysWithinBudget() => + AssertScenarioWithinBudget("speed-carry-repeated-traverse-descend"); +``` + +Update `MinecraftClient.Tests/Pathing/Execution/TransitionLookaheadEvaluatorTests.cs`: + +```csharp +[Fact] +public void ChooseGroundProfile_PicksBrake_WhenLandingRecoveryTurnWouldOvershootSupportStrip() +{ + 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 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, + ExitHints = new PathTransitionHints(0, 1, 0.0, 0.035, true, true, false, true, 12) + }; + var next = new PathSegment + { + Start = current.End, + End = new Location(122.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 physics = new PlayerPhysics + { + Position = new Vec3d(122.58, 80.0, 110.68), + DeltaMovement = new Vec3d(0.118, 0.0, 0.018), + OnGround = true, + MovementSpeed = 0.1f, + Yaw = 270f + }; + + TransitionInputProfile profile = TransitionLookaheadEvaluator.ChooseGroundProfile( + current, + next, + new Location(122.58, 80.0, 110.68), + physics, + world); + + Assert.Equal(TransitionInputProfile.Brake, profile); +} +``` + +Update `MinecraftClient.Tests/Pathing/Execution/TransitionBrakingPlannerTests.cs`: + +```csharp +[Fact] +public void Plan_Carries_ForLandingRecovery_WhenNextDescendStillNeedsRunway() +{ + World world = FlatWorldTestBuilder.CreateStoneFloor(min: 438, max: 448); + FlatWorldTestBuilder.ClearBox(world, 438, 79, 438, 448, 84, 442); + FlatWorldTestBuilder.SetSolid(world, 440, 79, 440); + FlatWorldTestBuilder.SetSolid(world, 441, 79, 440); + FlatWorldTestBuilder.SetSolid(world, 442, 79, 440); + FlatWorldTestBuilder.SetSolid(world, 443, 80, 440); + FlatWorldTestBuilder.SetSolid(world, 444, 79, 440); + + var current = new PathSegment + { + Start = new Location(441.5, 81, 440.5), + End = new Location(443.5, 81, 440.5), + MoveType = MoveType.Parkour, + ExitTransition = PathTransitionType.LandingRecovery, + ExitHints = new PathTransitionHints(1, 0, 0.0, 0.035, true, true, false, true, 12) + }; + var next = new PathSegment + { + Start = current.End, + End = new Location(444.5, 80, 440.5), + MoveType = MoveType.Descend, + ExitTransition = PathTransitionType.FinalStop, + ExitHints = new PathTransitionHints(1, 0, 0.0, 0.02, true, true, false, false, 12) + }; + + var physics = CreatePhysics(0.086, 0.0, onGround: true); + physics.Position = new Vec3d(443.18, 81.0, 440.5); + + TransitionBrakingDecision decision = TransitionBrakingPlanner.Plan( + current, + next, + new Location(443.18, 81.0, 440.5), + physics, + world); + + Assert.True(decision.HoldForward); + Assert.False(decision.HoldBack); +} +``` + +- [ ] **Step 2: Run the mixed-route tests and verify they fail** + +Run: + +```bash +dotnet test MinecraftClient.Tests/MinecraftClient.Tests.csproj --filter "FullyQualifiedName~Pathing.Execution.PathTimingContractTests.MixedTraverseAscendParkourDescend_ExecutionStaysWithinBudget|FullyQualifiedName~Pathing.Execution.PathTimingContractTests.MixedTraverseTurnParkourTurnTraverse_ExecutionStaysWithinBudget|FullyQualifiedName~Pathing.Execution.PathTimingContractTests.SpeedCarryRepeatedTraverseDescend_ExecutionStaysWithinBudget|FullyQualifiedName~Pathing.Execution.TransitionLookaheadEvaluatorTests.ChooseGroundProfile_PicksBrake_WhenLandingRecoveryTurnWouldOvershootSupportStrip|FullyQualifiedName~Pathing.Execution.TransitionBrakingPlannerTests.Plan_Carries_ForLandingRecovery_WhenNextDescendStillNeedsRunway" -v minimal +``` + +Expected: FAIL because current lookahead and planner logic either brake when the next segment needs carry, or carry when the turn entry should already be slowing down. + +- [ ] **Step 3: Thread `nextSegment` through lookahead scoring and braking decisions** + +Update `MinecraftClient/Pathing/Execution/TransitionLookaheadEvaluator.cs`: + +```csharp +public static TransitionInputProfile ChooseGroundProfile(PathSegment current, PathSegment? next, Location pos, PlayerPhysics physics, World world) +{ + double remaining = TemplateHelper.RemainingDistanceAlongSegment(pos, current); + double forwardSpeed = Math.Max(0.0, + TemplateHelper.ProjectHorizontalSpeedAlongHeading(physics, current.HeadingX, current.HeadingZ)); + + bool requiresJumpEntry = current.ExitHints.RequireJumpReady + || current.ExitTransition == PathTransitionType.PrepareJump; + + if (current.ExitTransition == PathTransitionType.ContinueStraight && !requiresJumpEntry) + return TransitionInputProfile.Carry; + + if (requiresJumpEntry) + return TransitionInputProfile.Carry; + + if (next is not null && current.ExitTransition == PathTransitionType.LandingRecovery) + { + bool headingChange = current.HeadingX != next.HeadingX || current.HeadingZ != next.HeadingZ; + if (headingChange && forwardSpeed > GetTargetMaxExitSpeed(current)) + return TransitionInputProfile.Brake; + + if (next.ExitHints.RequireJumpReady && forwardSpeed < next.ExitHints.MinExitSpeed) + return TransitionInputProfile.Carry; + } + + bool requiresSlowEntry = current.ExitHints.RequireStableFooting + || current.ExitTransition is PathTransitionType.FinalStop or PathTransitionType.Turn + || (current.ExitTransition == PathTransitionType.LandingRecovery + && (current.ExitHints.AllowAirBrake || IsFiniteSpeedCap(current))); + + if (!requiresSlowEntry) + return TransitionInputProfile.Carry; + + double maxExitSpeed = GetTargetMaxExitSpeed(current); + double hardBrakeDistance = TransitionBrakingPlanner.EstimateGroundStopDistance( + physics, world, current.HeadingX, current.HeadingZ, applyBackBrake: true); + double coastStopDistance = TransitionBrakingPlanner.EstimateGroundStopDistance( + physics, world, current.HeadingX, current.HeadingZ, applyBackBrake: false); + + if (remaining < 0.0) + return TransitionInputProfile.Brake; + + if (forwardSpeed > maxExitSpeed && remaining <= hardBrakeDistance + 0.10) + return TransitionInputProfile.Brake; + + if (forwardSpeed <= maxExitSpeed && remaining > 0.0) + return TransitionInputProfile.Carry; + + if (remaining <= coastStopDistance + 0.06) + return TransitionInputProfile.Coast; + + return TransitionInputProfile.Carry; +} + +public static TransitionInputProfile ChooseAirProfile(PathSegment current, PathSegment? next, Location pos, PlayerPhysics physics, World world) +{ + if (!current.ExitHints.AllowAirBrake) + return TransitionInputProfile.AirHoldForward; + + TransitionInputProfile[] candidates = + [ + TransitionInputProfile.AirHoldForward, + TransitionInputProfile.AirRelease, + TransitionInputProfile.AirBrake + ]; + + return ChooseBest(current, next, pos, physics, world, candidates); +} + +private static TransitionInputProfile ChooseBest(PathSegment segment, PathSegment? next, 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, next, pos, physics, world, candidate); + if (score < bestScore) + { + best = candidate; + bestScore = score; + } + } + + return best; +} + +private static double Score(PathSegment segment, PathSegment? next, Location pos, PlayerPhysics physics, World world, TransitionInputProfile candidate) +{ + PlayerPhysics sim = TemplateHelper.ClonePhysicsForPlanning(physics); + sim.Position = new Vec3d(pos.X, pos.Y, pos.Z); + + var input = new MovementInput(); + Location simPos = pos; + + for (int tick = 0; tick < segment.ExitHints.HorizonTicks; tick++) + { + if (TemplateHelper.ShouldBiasTowardExitHeading(simPos, segment)) + TemplateHelper.FaceExitHeading(sim, segment); + + input.Reset(); + ApplyCandidateInput(input, candidate, segment); + sim.ApplyInput(input); + sim.Tick(world); + simPos = new Location(sim.Position.X, sim.Position.Y, sim.Position.Z); + } + + double score = ScoreNextSegmentEntry(segment, next, simPos, sim); + score += TemplateHelper.HeadingPenaltyDegrees(sim.Yaw, segment); + score += Math.Abs(TemplateHelper.RemainingDistanceAlongSegment(simPos, segment)) * 10.0; + return score; +} + +private static double ScoreNextSegmentEntry(PathSegment current, PathSegment? next, Location simPos, PlayerPhysics sim) +{ + if (next is null) + return 0.0; + + double score = 0.0; + + if (current.ExitTransition == PathTransitionType.LandingRecovery + && (current.HeadingX != next.HeadingX || current.HeadingZ != next.HeadingZ) + && !TemplateFootingHelper.IsCenterInsideSupportStrip(simPos, current.End, next.End)) + { + score += 1200.0; + } + + if (next.ExitHints.RequireJumpReady) + { + double nextSpeed = TemplateHelper.ProjectHorizontalSpeedAlongHeading(sim, next.HeadingX, next.HeadingZ); + if (nextSpeed < next.ExitHints.MinExitSpeed) + score += (next.ExitHints.MinExitSpeed - nextSpeed) * 600.0; + } + + return score; +} +``` + +Update `MinecraftClient/Pathing/Execution/TransitionBrakingPlanner.cs`: + +```csharp +TransitionInputProfile profile; +if (physics.OnGround) +{ + profile = TransitionLookaheadEvaluator.ChooseGroundProfile(current, next, pos, physics, world); +} +else +{ + if (!current.ExitHints.AllowAirBrake) + return TransitionBrakingDecision.CarryMomentum(current.PreserveSprint); + + profile = TransitionLookaheadEvaluator.ChooseAirProfile(current, next, pos, physics, world); +} + +return profile switch +{ + TransitionInputProfile.Carry => TransitionBrakingDecision.CarryMomentum(current.PreserveSprint || next?.ExitHints.RequireJumpReady == true), + TransitionInputProfile.Coast => TransitionBrakingDecision.Coast, + TransitionInputProfile.Brake => TransitionBrakingDecision.Brake, + TransitionInputProfile.AirHoldForward => TransitionBrakingDecision.CarryMomentum(current.PreserveSprint || next?.ExitHints.RequireJumpReady == true), + TransitionInputProfile.AirRelease => TransitionBrakingDecision.Coast, + TransitionInputProfile.AirBrake => TransitionBrakingDecision.Brake, + _ => TransitionBrakingDecision.Coast +}; +``` + +- [ ] **Step 4: Re-run focused mixed-route tests and the broader long-route contract group** + +Run: + +```bash +dotnet test MinecraftClient.Tests/MinecraftClient.Tests.csproj --filter "FullyQualifiedName~Pathing.Execution.PathTimingContractTests.MixedTraverseAscendParkourDescend_ExecutionStaysWithinBudget|FullyQualifiedName~Pathing.Execution.PathTimingContractTests.MixedTraverseTurnParkourTurnTraverse_ExecutionStaysWithinBudget|FullyQualifiedName~Pathing.Execution.PathTimingContractTests.SpeedCarryRepeatedTraverseDescend_ExecutionStaysWithinBudget|FullyQualifiedName~Pathing.Execution.PathTimingContractTests.LongRoute_ExecutionStaysWithinBudget|FullyQualifiedName~Pathing.Execution.TransitionLookaheadEvaluatorTests|FullyQualifiedName~Pathing.Execution.TransitionBrakingPlannerTests" -v minimal +``` + +Expected: PASS for the new explicit regressions and no new failures in the broader lookahead/braking coverage. + +- [ ] **Step 5: Commit the mixed-route braking fix** + +```bash +git add MinecraftClient.Tests/Pathing/Execution/PathTimingContractTests.cs \ + MinecraftClient.Tests/Pathing/Execution/TransitionLookaheadEvaluatorTests.cs \ + MinecraftClient.Tests/Pathing/Execution/TransitionBrakingPlannerTests.cs \ + MinecraftClient/Pathing/Execution/TransitionLookaheadEvaluator.cs \ + MinecraftClient/Pathing/Execution/TransitionBrakingPlanner.cs +git commit -m "fix: align transition lookahead with next segment entry" +``` + +### Task 3: Remove Chained-Ascend Landing Stall In Live Staircases + +**Files:** +- Modify: `MinecraftClient.Tests/Pathing/Execution/GroundedTemplateConvergenceTests.cs` +- Modify: `MinecraftClient/Pathing/Execution/Templates/AscendTemplate.cs` +- Modify: `MinecraftClient/Pathing/Execution/Templates/GroundedSegmentController.cs` + +- [ ] **Step 1: Add a failing chained-ascend convergence test** + +Update `MinecraftClient.Tests/Pathing/Execution/GroundedTemplateConvergenceTests.cs`: + +```csharp +[Fact] +public void AscendTemplate_ContinueStraight_CompletesWithoutSettlingToZeroSpeed() +{ + World world = FlatWorldTestBuilder.CreateStoneFloor(min: 338, max: 347); + FlatWorldTestBuilder.ClearBox(world, 340, 80, 338, 347, 86, 342); + FlatWorldTestBuilder.FillSolid(world, 341, 80, 339, 341, 80, 341); + FlatWorldTestBuilder.FillSolid(world, 342, 81, 339, 342, 81, 341); + FlatWorldTestBuilder.FillSolid(world, 343, 82, 339, 343, 82, 341); + + var current = new PathSegment + { + Start = new Location(341.5, 81, 340.5), + End = new Location(342.5, 82, 340.5), + MoveType = MoveType.Ascend, + ExitTransition = PathTransitionType.ContinueStraight, + ExitHints = new PathTransitionHints(1, 0, 0.08, double.PositiveInfinity, false, true, false, false, 8), + PreserveSprint = true + }; + var next = new PathSegment + { + Start = current.End, + End = new Location(343.5, 83, 340.5), + MoveType = MoveType.Ascend, + ExitTransition = PathTransitionType.ContinueStraight, + ExitHints = new PathTransitionHints(1, 0, 0.08, double.PositiveInfinity, false, true, false, false, 8), + PreserveSprint = true + }; + + var template = new AscendTemplate(current, next); + var physics = new PlayerPhysics + { + Position = new Vec3d(current.Start.X, current.Start.Y, current.Start.Z), + DeltaMovement = new Vec3d(0.11, 0.0, 0.0), + OnGround = true, + MovementSpeed = 0.1f, + Yaw = 270f, + Pitch = 0f + }; + + var input = new MovementInput(); + TemplateState state = TemplateState.InProgress; + int ticks = 0; + for (; ticks < 30; ticks++) + { + 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); + } + + Assert.Equal(TemplateState.Complete, state); + Assert.InRange(ticks, 1, 14); + Assert.InRange(physics.DeltaMovement.X, 0.05, 0.20); +} +``` + +- [ ] **Step 2: Run the new unit test and the live long-route harness to confirm current failure** + +Run: + +```bash +dotnet test MinecraftClient.Tests/MinecraftClient.Tests.csproj --filter FullyQualifiedName~Pathing.Execution.GroundedTemplateConvergenceTests.AscendTemplate_ContinueStraight_CompletesWithoutSettlingToZeroSpeed -v minimal +source tools/mcc-env.sh && bash tools/test-pathing-long-routes.sh 1.21.11-Vanilla +``` + +Expected: the unit test fails on tick count or residual speed, and the live harness still reports `same-move-ascend-staircase` over budget. + +- [ ] **Step 3: Split ascend execution into takeoff, airborne, and landing handoff** + +Update `MinecraftClient/Pathing/Execution/Templates/AscendTemplate.cs`: + +```csharp +private enum Phase { Takeoff, Airborne, Landing } + +private Phase _phase = Phase.Takeoff; +private bool _leftGround; +private int _landingTicks; + +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); + + switch (_phase) + { + case Phase.Takeoff: + input.Forward = true; + input.Sprint = true; + if (physics.OnGround && dy > 0.1) + { + input.Jump = true; + _phase = Phase.Airborne; + } + break; + + case Phase.Airborne: + input.Forward = true; + input.Sprint = true; + if (!physics.OnGround) + _leftGround = true; + if (_leftGround && physics.OnGround) + { + _phase = Phase.Landing; + _landingTicks = 0; + goto case Phase.Landing; + } + break; + + case Phase.Landing: + _landingTicks++; + GroundedSegmentController.Apply(_segment, _nextSegment, pos, physics, input, world); + if (GroundedSegmentController.ShouldComplete(_segment, pos, physics)) + return TemplateState.Complete; + break; + } + + if (_stuckTicks > 20 || _tickCount > 50) + return TemplateState.Failed; + + return TemplateState.InProgress; +} +``` + +Update `MinecraftClient/Pathing/Execution/Templates/GroundedSegmentController.cs`: + +```csharp +if (segment.MoveType == MoveType.Ascend + && segment.ExitTransition == PathTransitionType.ContinueStraight + && physics.OnGround + && TemplateFootingHelper.IsFootprintInsideTargetBlock(pos, segment.End) + && !TemplateFootingHelper.WillLeaveTargetBlockNextTick(pos, physics, segment.End)) +{ + double exitSpeed = TemplateHelper.ProjectHorizontalSpeedAlongHint(physics, segment); + return exitSpeed >= Math.Max(0.02, segment.ExitHints.MinExitSpeed); +} +``` + +- [ ] **Step 4: Re-run the ascend convergence test and the live long-route harness** + +Run: + +```bash +dotnet test MinecraftClient.Tests/MinecraftClient.Tests.csproj --filter "FullyQualifiedName~Pathing.Execution.GroundedTemplateConvergenceTests.AscendTemplate_ContinueStraight_CompletesWithoutSettlingToZeroSpeed|FullyQualifiedName~Pathing.Execution.PathTimingContractTests.Scenario_ExecutionStaysWithinTimingBudget" -v minimal +source tools/mcc-env.sh && bash tools/test-pathing-long-routes.sh 1.21.11-Vanilla +``` + +Expected: PASS for the new unit test and the live long-route suite, including `same-move-ascend-staircase`. + +- [ ] **Step 5: Commit the ascend convergence fix** + +```bash +git add MinecraftClient.Tests/Pathing/Execution/GroundedTemplateConvergenceTests.cs \ + MinecraftClient/Pathing/Execution/Templates/AscendTemplate.cs \ + MinecraftClient/Pathing/Execution/Templates/GroundedSegmentController.cs +git commit -m "fix: reduce chained ascend landing stalls" +``` + +### Task 4: Run The Full Regression Sweep And Stop On Any Residual Family + +**Files:** +- No code changes required unless verification reveals a new, scoped defect + +- [ ] **Step 1: Re-run all focused pathing execution tests** + +Run: + +```bash +dotnet test MinecraftClient.Tests/MinecraftClient.Tests.csproj --filter "FullyQualifiedName~Pathing.Execution" -v minimal +``` + +Expected: PASS with `0` failing pathing execution tests. + +- [ ] **Step 2: Re-run the live accepted-route suites that previously failed** + +Run: + +```bash +source tools/mcc-env.sh && bash tools/test-pathing-jump-combos.sh 1.21.11-Vanilla +source tools/mcc-env.sh && bash tools/test-pathing-long-routes.sh 1.21.11-Vanilla +``` + +Expected: both scripts exit `0`, with no accepted-route replans and no contract-budget overruns. + +- [ ] **Step 3: If any live case still fails, capture the exact family before doing more coding** + +Use the existing contract report output already printed by the harnesses. Record: + +```text +scenario id +total actual / max ticks +which segment index exceeded +whether the failure was replan, timeout, or budget overrun +``` + +Do not widen scope beyond: + +- parkour landing recovery +- next-segment braking/lookahead +- chained ascend landing handoff + +- [ ] **Step 4: End the plan cleanly once verification is green** + +Run: + +```bash +git status --short +``` + +Expected: only the intentional runtime/test edits from Tasks 1 through 3 remain. If verification is green and no extra follow-up patch was needed, do not create an empty commit. If verification exposes a new defect family, stop and write a separate scoped plan instead of slipping extra repair work into this one. + +## Self-Review + +Spec coverage check: + +- repeated parkour failures map to Task 1 +- mixed-route carry/brake failures map to Task 2 +- live staircase ascend overrun maps to Task 3 +- full xUnit and live verification maps to Task 4 + +Placeholder scan: + +- no `TODO`, `TBD`, or “similar to above” placeholders remain +- each task includes concrete file paths, test code, commands, and commit steps + +Type consistency: + +- all next-segment-aware changes consistently use `PathSegment? next` +- named test helpers use `AssertScenarioWithinBudget` +- runtime fixes stay inside the already failing execution files diff --git a/docs/superpowers/plans/2026-04-15-jump-entry-direct-yaw.md b/docs/superpowers/plans/2026-04-15-jump-entry-direct-yaw.md new file mode 100644 index 00000000..e459a037 --- /dev/null +++ b/docs/superpowers/plans/2026-04-15-jump-entry-direct-yaw.md @@ -0,0 +1,520 @@ +# Jump-Entry Direct Yaw 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:** Remove unnecessary yaw smoothing in jump-entry states so opposite-yaw jump starts commit immediately without changing normal walk, descend, climb, or final-stop behavior. + +**Architecture:** Introduce a small helper-level yaw alignment policy, then opt in only the jump-entry states: sprint-jump approach, ascend pre-jump alignment, grounded prepare-jump freeze, and grounded walk segments that are explicitly preparing a jump. Keep air control, grounded braking, descend, climb, and ordinary walk/final-stop behavior on smooth yaw, and prove the scope boundary with focused unit tests plus sequential live harness runs. + +**Tech Stack:** C# 14, .NET 10, xUnit, MCC local harness scripts (`tools/mcc-env.sh`, `mcc-preflight`, `tools/test-pathing-jump-combos.sh`, `tools/test-pathing-long-routes.sh`) + +--- + +## File Map + +- Modify: `MinecraftClient/Pathing/Execution/Templates/TemplateHelper.cs` + - Add a small yaw-alignment helper and heading-facing overloads so templates can request `Smooth` or `Snap` without open-coding raw yaw assignment. +- Modify: `MinecraftClient/Pathing/Execution/Templates/SprintJumpTemplate.cs` + - Snap yaw only during `Phase.Approach`; keep air and landing phases on smooth yaw. +- Modify: `MinecraftClient/Pathing/Execution/Templates/AscendTemplate.cs` + - Snap yaw only while aligning for jump commitment; preserve the existing grounded prepare-jump handoff carveout. +- Modify: `MinecraftClient/Pathing/Execution/Templates/GroundedSegmentController.cs` + - Snap exit heading in the frozen `PrepareJump` turn branch only. +- Modify: `MinecraftClient/Pathing/Execution/Templates/WalkTemplate.cs` + - Use snap yaw only for grounded `PrepareJump` segments with `ExitHints.RequireJumpReady == true`. +- Modify: `MinecraftClient.Tests/Pathing/Execution/SprintJumpTemplateScenarioTests.cs` + - Add a focused regression that proves sprint-jump approach snaps immediately from opposite yaw. +- Modify: `MinecraftClient.Tests/Pathing/Execution/GroundedTemplateConvergenceTests.cs` + - Add focused regressions for ascend pre-jump snap, walk run-up snap, grounded freeze snap, and ordinary final-stop smoothness. + +### Task 1: Add Failing Sprint-Jump Snap Test + +**Files:** +- Modify: `MinecraftClient.Tests/Pathing/Execution/SprintJumpTemplateScenarioTests.cs` +- Test: `MinecraftClient.Tests/Pathing/Execution/SprintJumpTemplateScenarioTests.cs` + +- [ ] **Step 1: Write the failing test** + +Add this test near the existing opposite-yaw sprint-jump regressions: + +```csharp +[Fact] +public void SprintJumpTemplate_Approach_SnapsYawImmediatelyFromOppositeYaw() +{ + 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: 90f); + var input = new MovementInput(); + + TemplateState state = template.Tick(segment.Start, physics, input, world); + + Assert.Equal(TemplateState.InProgress, state); + Assert.InRange(physics.Yaw, 269.9f, 270.1f); + Assert.True(input.Forward); + Assert.True(input.Sprint); + Assert.True(input.Jump); +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: + +```bash +dotnet test MinecraftClient.Tests/MinecraftClient.Tests.csproj --filter "FullyQualifiedName~SprintJumpTemplate_Approach_SnapsYawImmediatelyFromOppositeYaw" -v minimal +``` + +Expected: +- `FAIL` +- The failure should show `physics.Yaw` still near `125` and movement input still blocked by the turn-in-place gate. + +- [ ] **Step 3: Write minimal implementation** + +In `MinecraftClient/Pathing/Execution/Templates/TemplateHelper.cs`, add the alignment helper and overloads: + +```csharp +internal enum YawAlignmentMode +{ + Smooth, + Snap +} + +internal static float AlignYaw(float current, float target, YawAlignmentMode mode, float maxStep = MaxYawStepPerTick) +{ + target = NormalizeYaw(target); + return mode == YawAlignmentMode.Snap + ? target + : SmoothYaw(current, target, maxStep); +} + +internal static void FaceSegmentHeading(PlayerPhysics physics, PathSegment segment, YawAlignmentMode mode = YawAlignmentMode.Smooth) +{ + float headingYaw = CalculateYaw(segment.HeadingX, segment.HeadingZ); + physics.Yaw = AlignYaw(physics.Yaw, headingYaw, mode); +} + +internal static void FaceExitHeading(PlayerPhysics physics, PathSegment segment, YawAlignmentMode mode = YawAlignmentMode.Smooth) +{ + float headingYaw = GetExitHeadingYaw(segment); + physics.Yaw = AlignYaw(physics.Yaw, headingYaw, mode); +} + +private static float NormalizeYaw(float yaw) +{ + while (yaw < 0f) yaw += 360f; + while (yaw >= 360f) yaw -= 360f; + return yaw; +} +``` + +In `MinecraftClient/Pathing/Execution/Templates/SprintJumpTemplate.cs`, switch only `Phase.Approach` to snap yaw: + +```csharp +YawAlignmentMode yawMode = _phase == Phase.Approach + ? YawAlignmentMode.Snap + : YawAlignmentMode.Smooth; + +physics.Yaw = TemplateHelper.AlignYaw(physics.Yaw, targetYaw, yawMode); +physics.Pitch = TemplateHelper.SmoothPitch(physics.Pitch, targetPitch); +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: + +```bash +dotnet test MinecraftClient.Tests/MinecraftClient.Tests.csproj --filter "FullyQualifiedName~SprintJumpTemplate_Approach_SnapsYawImmediatelyFromOppositeYaw|FullyQualifiedName~SprintJumpTemplate_TwoBlockGap_FinalStop_CompletesFromOppositeYawWithinTwentyTicks|FullyQualifiedName~SprintJumpTemplate_ThreeBlockGap_FinalStop_Completes" -v minimal +``` + +Expected: +- `PASS` +- The new test passes. +- The existing opposite-yaw timing regression stays green. +- The 3-block final-stop sprint jump still completes. + +- [ ] **Step 5: Commit** + +```bash +git add \ + MinecraftClient/Pathing/Execution/Templates/TemplateHelper.cs \ + MinecraftClient/Pathing/Execution/Templates/SprintJumpTemplate.cs \ + MinecraftClient.Tests/Pathing/Execution/SprintJumpTemplateScenarioTests.cs +git commit -m "pathing: snap yaw for sprint jump approach" +``` + +### Task 2: Add Failing Ascend And Frozen Prepare-Jump Snap Tests + +**Files:** +- Modify: `MinecraftClient.Tests/Pathing/Execution/GroundedTemplateConvergenceTests.cs` +- Modify: `MinecraftClient/Pathing/Execution/Templates/AscendTemplate.cs` +- Modify: `MinecraftClient/Pathing/Execution/Templates/GroundedSegmentController.cs` +- Test: `MinecraftClient.Tests/Pathing/Execution/GroundedTemplateConvergenceTests.cs` + +- [ ] **Step 1: Write the failing tests** + +Add these tests to `MinecraftClient.Tests/Pathing/Execution/GroundedTemplateConvergenceTests.cs` near the existing prepare-jump regressions: + +```csharp +[Fact] +public void AscendTemplate_PrepareJump_SnapsYawImmediatelyFromOppositeYaw() +{ + World world = FlatWorldTestBuilder.CreateStoneFloor(min: 338, max: 344); + FlatWorldTestBuilder.ClearBox(world, 340, 80, 338, 344, 84, 342); + FlatWorldTestBuilder.FillSolid(world, 341, 80, 339, 341, 80, 341); + FlatWorldTestBuilder.FillSolid(world, 342, 81, 339, 342, 81, 341); + + var segment = new PathSegment + { + Start = new Location(340.5, 80, 340.5), + End = new Location(341.5, 81, 340.5), + MoveType = MoveType.Ascend, + ExitTransition = PathTransitionType.PrepareJump, + ExitHints = new PathTransitionHints(1, 0, 0.10, double.PositiveInfinity, false, true, true, false, 10), + PreserveSprint = true + }; + var next = new PathSegment + { + Start = new Location(341.5, 81, 340.5), + End = new Location(342.5, 82, 340.5), + MoveType = MoveType.Ascend, + ExitTransition = PathTransitionType.FinalStop + }; + + var template = new AscendTemplate(segment, next); + var physics = TemplateSimulationRunner.CreateGroundedPhysics(segment.Start, yaw: 90f); + var input = new MovementInput(); + + TemplateState state = template.Tick(segment.Start, physics, input, world); + + Assert.Equal(TemplateState.InProgress, state); + Assert.InRange(physics.Yaw, 269.9f, 270.1f); + Assert.True(input.Forward); + Assert.True(input.Sprint); + Assert.True(input.Jump); +} + +[Fact] +public void WalkTemplate_PrepareJump_FreezeForTurn_SnapsExitHeadingImmediately() +{ + 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(0, 1, 0.10, double.PositiveInfinity, false, true, true, false, 10), + PreserveSprint = true + }; + var next = new PathSegment + { + Start = new Location(1.5, 80, 0.5), + End = new Location(1.5, 80, 1.5), + MoveType = MoveType.Parkour, + ExitTransition = PathTransitionType.FinalStop + }; + + var template = new WalkTemplate(current, next); + var physics = new PlayerPhysics + { + Position = new Vec3d(1.5, 80.0, 0.5), + DeltaMovement = Vec3d.Zero, + OnGround = true, + MovementSpeed = 0.1f, + Yaw = 180f, + Pitch = 0f + }; + var input = new MovementInput(); + + TemplateState state = template.Tick(new Location(1.5, 80, 0.5), physics, input, world); + + Assert.Equal(TemplateState.InProgress, state); + Assert.InRange(physics.Yaw, -0.1f, 0.1f); + Assert.False(input.Forward); + Assert.False(input.Sprint); + Assert.False(input.Back); +} +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: + +```bash +dotnet test MinecraftClient.Tests/MinecraftClient.Tests.csproj --filter "FullyQualifiedName~AscendTemplate_PrepareJump_SnapsYawImmediatelyFromOppositeYaw|FullyQualifiedName~WalkTemplate_PrepareJump_FreezeForTurn_SnapsExitHeadingImmediately" -v minimal +``` + +Expected: +- `FAIL` +- The ascend test should show yaw still part-way through the turn. +- The frozen prepare-jump test should show yaw still around `145` instead of `0`. + +- [ ] **Step 3: Write minimal implementation** + +In `MinecraftClient/Pathing/Execution/Templates/AscendTemplate.cs`, snap yaw only before jump commitment and keep the handoff carveout: + +```csharp +bool snapYawForJumpCommit = !_initiatedJump && !groundedPrepareJumpHandoff; +physics.Yaw = TemplateHelper.AlignYaw( + physics.Yaw, + targetYaw, + snapYawForJumpCommit ? YawAlignmentMode.Snap : YawAlignmentMode.Smooth); +physics.Pitch = TemplateHelper.SmoothPitch(physics.Pitch, targetPitch); +``` + +In `MinecraftClient/Pathing/Execution/Templates/GroundedSegmentController.cs`, snap the frozen exit-heading turn: + +```csharp +if (segment.ExitTransition == PathTransitionType.PrepareJump + && segment.ExitHints.RequireJumpReady + && physics.OnGround + && TemplateFootingHelper.IsCenterInsideTargetBlock(pos, segment.End) + && IsReadyToFreezeForTurn(segment, pos) + && TemplateHelper.HeadingPenaltyDegrees(physics.Yaw, segment) > 8.0) +{ + input.Forward = false; + input.Sprint = false; + input.Back = false; + TemplateHelper.FaceExitHeading(physics, segment, YawAlignmentMode.Snap); + return; +} +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: + +```bash +dotnet test MinecraftClient.Tests/MinecraftClient.Tests.csproj --filter "FullyQualifiedName~AscendTemplate_PrepareJump_SnapsYawImmediatelyFromOppositeYaw|FullyQualifiedName~WalkTemplate_PrepareJump_FreezeForTurn_SnapsExitHeadingImmediately|FullyQualifiedName~AscendTemplate_PrepareJump_CompletesFromOppositeYawWithinTwentyTicks|FullyQualifiedName~WalkTemplate_TurnIntoParkour_CompletesOnlyWhenTurnEntryIsSlowAndJumpReady" -v minimal +``` + +Expected: +- `PASS` +- The new snap regressions pass. +- Existing opposite-yaw ascend timing stays green. +- The turn-into-parkour convergence regression still passes. + +- [ ] **Step 5: Commit** + +```bash +git add \ + MinecraftClient/Pathing/Execution/Templates/AscendTemplate.cs \ + MinecraftClient/Pathing/Execution/Templates/GroundedSegmentController.cs \ + MinecraftClient.Tests/Pathing/Execution/GroundedTemplateConvergenceTests.cs +git commit -m "pathing: snap yaw for jump-ready grounded handoffs" +``` + +### Task 3: Add Failing Walk Jump-Entry Scope Tests + +**Files:** +- Modify: `MinecraftClient.Tests/Pathing/Execution/GroundedTemplateConvergenceTests.cs` +- Modify: `MinecraftClient/Pathing/Execution/Templates/WalkTemplate.cs` +- Test: `MinecraftClient.Tests/Pathing/Execution/GroundedTemplateConvergenceTests.cs` + +- [ ] **Step 1: Write the failing tests** + +Add these tests to `MinecraftClient.Tests/Pathing/Execution/GroundedTemplateConvergenceTests.cs` near the existing walk prepare-jump coverage: + +```csharp +[Fact] +public void WalkTemplate_PrepareJump_SnapsYawImmediatelyDuringRunUp() +{ + 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.10, double.PositiveInfinity, false, true, true, false, 10), + 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: 90f); + var input = new MovementInput(); + + TemplateState state = template.Tick(current.Start, physics, input, world); + + Assert.Equal(TemplateState.InProgress, state); + Assert.InRange(physics.Yaw, 269.9f, 270.1f); + Assert.True(input.Forward); + Assert.True(input.Sprint); +} + +[Fact] +public void WalkTemplate_FinalStop_RetainsSmoothYawOutsideJumpEntry() +{ + 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: 90f); + var input = new MovementInput(); + + TemplateState state = template.Tick(segment.Start, physics, input, world); + + Assert.Equal(TemplateState.InProgress, state); + Assert.InRange(physics.Yaw, 124.9f, 125.1f); +} +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: + +```bash +dotnet test MinecraftClient.Tests/MinecraftClient.Tests.csproj --filter "FullyQualifiedName~WalkTemplate_PrepareJump_SnapsYawImmediatelyDuringRunUp|FullyQualifiedName~WalkTemplate_FinalStop_RetainsSmoothYawOutsideJumpEntry" -v minimal +``` + +Expected: +- `FAIL` +- The prepare-jump test should show smooth partial rotation instead of an immediate snap. +- The final-stop control test should already pass and act as the scope guard for the next step. + +- [ ] **Step 3: Write minimal implementation** + +In `MinecraftClient/Pathing/Execution/Templates/WalkTemplate.cs`, gate snap yaw to grounded jump-entry segments only: + +```csharp +bool snapYawForJumpEntry = physics.OnGround + && _segment.ExitTransition == PathTransitionType.PrepareJump + && _segment.ExitHints.RequireJumpReady; + +float targetYaw = TemplateHelper.ShouldBiasTowardExitHeading(pos, _segment) + ? TemplateHelper.GetExitHeadingYaw(_segment) + : TemplateHelper.CalculateYaw(dx, dz); + +physics.Yaw = TemplateHelper.AlignYaw( + physics.Yaw, + targetYaw, + snapYawForJumpEntry ? YawAlignmentMode.Snap : YawAlignmentMode.Smooth); +physics.Pitch = TemplateHelper.SmoothPitch(physics.Pitch, targetPitch); +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: + +```bash +dotnet test MinecraftClient.Tests/MinecraftClient.Tests.csproj --filter "FullyQualifiedName~WalkTemplate_PrepareJump_SnapsYawImmediatelyDuringRunUp|FullyQualifiedName~WalkTemplate_FinalStop_RetainsSmoothYawOutsideJumpEntry|FullyQualifiedName~WalkTemplate_PrepareJump_CompletesWithoutSettlingOnRunUpBlock|FullyQualifiedName~WalkTemplate_DiagonalPrepareJumpIntoAscend_CompletesFromTargetBlockEntry" -v minimal +``` + +Expected: +- `PASS` +- The new run-up snap regression passes. +- The final-stop scope guard stays green. +- Existing walk prepare-jump convergence regressions remain green. + +- [ ] **Step 5: Commit** + +```bash +git add \ + MinecraftClient/Pathing/Execution/Templates/WalkTemplate.cs \ + MinecraftClient.Tests/Pathing/Execution/GroundedTemplateConvergenceTests.cs +git commit -m "pathing: snap yaw only for grounded jump-entry walk states" +``` + +### Task 4: Full Verification And Evidence Capture + +**Files:** +- Modify only if timing evidence demands it: + - `MinecraftClient.Tests/TestData/Pathing/pathing-timing-budgets.json` + - `MinecraftClient.Tests/TestData/Pathing/pathing-planner-contracts.json` +- Verify: + - `MinecraftClient.Tests/Pathing/Execution/SprintJumpTemplateScenarioTests.cs` + - `MinecraftClient.Tests/Pathing/Execution/GroundedTemplateConvergenceTests.cs` + - `MinecraftClient.Tests/Pathing/Execution/LivePathingRegressionTests.cs` + - `MinecraftClient.Tests/Pathing/Execution/PathPlanningContractTests.cs` + - `MinecraftClient.Tests/Pathing/Execution/PathTimingContractTests.cs` + +- [ ] **Step 1: Run the focused unit regression set** + +Run: + +```bash +dotnet test MinecraftClient.Tests/MinecraftClient.Tests.csproj --filter "FullyQualifiedName~SprintJumpTemplateScenarioTests|FullyQualifiedName~GroundedTemplateConvergenceTests|FullyQualifiedName~LivePathingRegressionTests|FullyQualifiedName~MoveParkourTests.Accepts4x1JumpWithoutRearSupport_WhenTakeoffBlockProvidesRunway|FullyQualifiedName~PathPlanningContractTests.Scenario_PlannerMatchesContract|FullyQualifiedName~PathTimingContractTests.JumpCombo_ExecutionStaysWithinBudget|FullyQualifiedName~PathTimingContractTests.LongRoute_ExecutionStaysWithinBudget" -v minimal +``` + +Expected: +- `PASS` +- No planner regressions. +- No timing budget failures. + +- [ ] **Step 2: If a timing contract fails, refresh it from evidence before rerunning** + +Use the bootstrap printer first: + +```bash +dotnet test MinecraftClient.Tests/MinecraftClient.Tests.csproj --filter "FullyQualifiedName~PathingContractBootstrapTests" -v minimal +``` + +Only if a contract mismatch is stable and explained by the new snap behavior, update the matching JSON entries with the printed values, then rerun the focused contract tests: + +```bash +dotnet test MinecraftClient.Tests/MinecraftClient.Tests.csproj --filter "FullyQualifiedName~PathPlanningContractTests.Scenario_PlannerMatchesContract|FullyQualifiedName~PathTimingContractTests.JumpCombo_ExecutionStaysWithinBudget|FullyQualifiedName~PathTimingContractTests.LongRoute_ExecutionStaysWithinBudget" -v minimal +``` + +Expected: +- Either no JSON changes are needed, or the rerun passes with fresh values backed by bootstrap output. + +- [ ] **Step 3: Run jump-combo live harness sequentially** + +Run: + +```bash +bash -lc 'source tools/mcc-env.sh && mcc-preflight 1.21.11-Vanilla && bash tools/test-pathing-jump-combos.sh 1.21.11-Vanilla' +``` + +Expected: +- `PASS` summary for all jump-combo scenarios. +- No `Replan #`, `Partial`, `Replan failed`, or `Giving up`. + +- [ ] **Step 4: Run long-route live harness sequentially** + +Run: + +```bash +bash -lc 'source tools/mcc-env.sh && mcc-preflight 1.21.11-Vanilla && bash tools/test-pathing-long-routes.sh 1.21.11-Vanilla' +``` + +Expected: +- `Pathing long-route suite complete.` +- No `Replan #`, `Partial`, `Replan failed`, or `Giving up`. +- Repeated jump-entry routes remain within current max budgets. + +- [ ] **Step 5: Commit only additional contract refreshes from Task 4** + +If Task 4 needed no JSON or script edits, do not create another commit. Record that verification completed with no additional file changes. + +If timing contracts changed in Task 4, commit only those refreshes: + +```bash +git add MinecraftClient.Tests/TestData/Pathing/pathing-timing-budgets.json +git commit -m "test: refresh jump-entry snap yaw timing budgets" +``` diff --git a/tools/test-pathing-jump-combos.sh b/tools/test-pathing-jump-combos.sh index 23062b0a..e8652b74 100644 --- a/tools/test-pathing-jump-combos.sh +++ b/tools/test-pathing-jump-combos.sh @@ -183,11 +183,13 @@ prepare_independent_route() { local start_x="$2" local start_y="$3" local start_z="$4" + local start_yaw="${5:-270}" + local start_pitch="${6:-0}" echo "" echo "Preparing independent route: $label" mc-rcon "effect clear $USERNAME" >/dev/null 2>&1 || true - mc-rcon "tp $USERNAME $start_x $start_y $start_z" >/dev/null + mc-rcon "tp $USERNAME $start_x $start_y $start_z $start_yaw $start_pitch" >/dev/null wait_for_location_in_block "$start_x" "$start_y" "$start_z" 10 } @@ -261,9 +263,11 @@ run_accepted_route() { local goal_x="$6" local goal_y="$7" local goal_z="$8" - local timeout="${9:-45}" + local start_yaw="${9:-270}" + local start_pitch="${10:-0}" + local timeout="${11:-45}" - prepare_independent_route "$label" "$start_x" "$start_y" "$start_z" + prepare_independent_route "$label" "$start_x" "$start_y" "$start_z" "$start_yaw" "$start_pitch" capture_debug_state_before_route "$label" local start_line @@ -327,7 +331,7 @@ scenario_repeated_cardinal_parkour() { set_stone 584 79 580 set_stone 586 79 580 set_stone 588 79 580 - run_accepted_route "repeated-cardinal-parkour-chain" "Repeated jump - cardinal parkour chain" "580.5" "80" "580.5" "588" "80.00" "580" + run_accepted_route "repeated-cardinal-parkour-chain" "Repeated jump - cardinal parkour chain" "580.5" "80" "580.5" "588" "80.00" "580" "270" } scenario_repeated_diagonal_parkour() { @@ -337,7 +341,7 @@ scenario_repeated_diagonal_parkour() { set_stone 602 79 602 set_stone 604 79 604 set_stone 606 79 606 - run_accepted_route "repeated-diagonal-parkour-chain" "Repeated jump - diagonal parkour chain" "600.5" "80" "600.5" "606" "80.00" "606" + run_accepted_route "repeated-diagonal-parkour-chain" "Repeated jump - diagonal parkour chain" "600.5" "80" "600.5" "606" "80.00" "606" "315" } scenario_obstructed_parkour_turn_mix() { @@ -353,7 +357,7 @@ scenario_obstructed_parkour_turn_mix() { set_stone 620 81 621 set_stone 622 80 622 set_stone 622 81 622 - run_accepted_route "obstructed-parkour-l-turns" "Obstructed jump mix - repeated parkour L-turns" "620.5" "80" "620.5" "626" "80.00" "622" + run_accepted_route "obstructed-parkour-l-turns" "Obstructed jump mix - repeated parkour L-turns" "620.5" "80" "620.5" "626" "80.00" "622" "270" } scenario_parkour_ascend_descend_chain() { @@ -364,7 +368,7 @@ scenario_parkour_ascend_descend_chain() { set_stone 644 79 620 set_stone 646 80 620 set_stone 648 79 620 - run_accepted_route "vertical-jump-mix" "Vertical jump mix - parkour ascend descend chain" "640.5" "80" "620.5" "648" "80.00" "620" + run_accepted_route "vertical-jump-mix" "Vertical jump mix - parkour ascend descend chain" "640.5" "80" "620.5" "648" "80.00" "620" "270" } scenario_diagonal_ascend_descend_chain() { @@ -375,7 +379,7 @@ scenario_diagonal_ascend_descend_chain() { set_stone 682 79 622 set_stone 683 80 623 set_stone 684 79 624 - run_accepted_route "diagonal-vertical-mix" "Diagonal vertical mix - ascend descend chain" "680.5" "80" "620.5" "684" "80.00" "624" + run_accepted_route "diagonal-vertical-mix" "Diagonal vertical mix - ascend descend chain" "680.5" "80" "620.5" "684" "80.00" "624" "315" } start_mcc diff --git a/tools/test-pathing-long-routes.sh b/tools/test-pathing-long-routes.sh index 061fbe8e..0aa880d5 100644 --- a/tools/test-pathing-long-routes.sh +++ b/tools/test-pathing-long-routes.sh @@ -144,11 +144,13 @@ prepare_independent_route() { local start_x="$2" local start_y="$3" local start_z="$4" + local start_yaw="${5:-270}" + local start_pitch="${6:-0}" echo "" echo "Preparing independent route: $label" mc-rcon "effect clear $USERNAME" >/dev/null 2>&1 || true - mc-rcon "tp $USERNAME $start_x $start_y $start_z" >/dev/null + mc-rcon "tp $USERNAME $start_x $start_y $start_z $start_yaw $start_pitch" >/dev/null wait_for_location_in_block "$start_x" "$start_y" "$start_z" 10 } @@ -258,9 +260,11 @@ run_accepted_route() { local goal_x="$6" local goal_y="$7" local goal_z="$8" - local timeout="${9:-45}" + local start_yaw="${9:-270}" + local start_pitch="${10:-0}" + local timeout="${11:-45}" - prepare_independent_route "$label" "$start_x" "$start_y" "$start_z" + prepare_independent_route "$label" "$start_x" "$start_y" "$start_z" "$start_yaw" "$start_pitch" capture_debug_state_before_route "$label" local start_line @@ -299,7 +303,7 @@ run_same_move_routes() { fill_box 298 79 298 314 79 302 air fill_box 298 80 298 314 90 302 air fill_box 300 79 300 312 79 300 stone - run_accepted_route "same-move-straight-traverse-chain" "Same move - straight traverse chain" "300.5" "80" "300.5" "312" "80.00" "300" + run_accepted_route "same-move-straight-traverse-chain" "Same move - straight traverse chain" "300.5" "80" "300.5" "312" "80.00" "300" "270" fill_box 318 79 318 330 79 330 air fill_box 318 80 318 330 90 330 air @@ -311,7 +315,7 @@ run_same_move_routes() { set_stone 325 79 325 set_stone 326 79 326 set_stone 327 79 327 - run_accepted_route "same-move-diagonal-chain" "Same move - diagonal chain" "320.5" "80" "320.5" "327" "80.00" "327" + run_accepted_route "same-move-diagonal-chain" "Same move - diagonal chain" "320.5" "80" "320.5" "327" "80.00" "327" "315" fill_box 338 79 338 347 85 342 air fill_box 338 80 338 347 90 342 air @@ -321,7 +325,7 @@ run_same_move_routes() { fill_box 343 82 339 343 82 341 stone fill_box 344 83 339 344 83 341 stone fill_box 345 84 339 345 84 341 stone - run_accepted_route "same-move-ascend-staircase" "Same move - ascend staircase" "340.5" "80" "340.5" "345" "85.00" "340" + run_accepted_route "same-move-ascend-staircase" "Same move - ascend staircase" "340.5" "80" "340.5" "345" "85.00" "340" "270" fill_box 360 79 358 369 85 362 air fill_box 360 80 358 369 90 362 air @@ -331,7 +335,7 @@ run_same_move_routes() { fill_box 365 81 359 365 81 361 stone fill_box 366 80 359 366 80 361 stone fill_box 367 79 359 367 79 361 stone - run_accepted_route "same-move-descend-staircase" "Same move - descend staircase" "362.5" "85" "360.5" "367" "80.00" "360" + run_accepted_route "same-move-descend-staircase" "Same move - descend staircase" "362.5" "85" "360.5" "367" "80.00" "360" "270" fill_box 378 79 378 390 79 382 air fill_box 378 80 378 390 90 382 air @@ -340,7 +344,7 @@ run_same_move_routes() { set_stone 384 79 380 set_stone 386 79 380 set_stone 388 79 380 - run_accepted_route "same-move-aligned-parkour-chain" "Same move - aligned parkour chain" "380.5" "80" "380.5" "388" "80.00" "380" + run_accepted_route "same-move-aligned-parkour-chain" "Same move - aligned parkour chain" "380.5" "80" "380.5" "388" "80.00" "380" "270" } run_mixed_move_routes() { @@ -360,7 +364,7 @@ run_mixed_move_routes() { set_stone 406 79 404 set_stone 407 79 404 set_stone 408 79 404 - run_accepted_route "mixed-traverse-turn-parkour-turn-traverse" "Mixed - traverse turn parkour turn traverse" "400.5" "80" "400.5" "408" "80.00" "404" + run_accepted_route "mixed-traverse-turn-parkour-turn-traverse" "Mixed - traverse turn parkour turn traverse" "400.5" "80" "400.5" "408" "80.00" "404" "270" fill_box 418 79 418 430 82 424 air fill_box 418 80 418 430 92 424 air @@ -373,7 +377,7 @@ run_mixed_move_routes() { set_stone 426 81 422 set_stone 427 80 422 set_stone 428 79 422 - run_accepted_route "mixed-diagonal-ascend-traverse-descend" "Mixed - diagonal ascend traverse descend" "420.5" "80" "420.5" "428" "80.00" "422" + run_accepted_route "mixed-diagonal-ascend-traverse-descend" "Mixed - diagonal ascend traverse descend" "420.5" "80" "420.5" "428" "80.00" "422" "315" fill_box 438 79 438 450 82 442 air fill_box 438 80 438 450 92 442 air @@ -385,7 +389,7 @@ run_mixed_move_routes() { set_stone 446 81 440 set_stone 447 80 440 set_stone 448 79 440 - run_accepted_route "mixed-traverse-ascend-parkour-descend" "Mixed - traverse ascend parkour descend" "440.5" "80" "440.5" "448" "80.00" "440" + run_accepted_route "mixed-traverse-ascend-parkour-descend" "Mixed - traverse ascend parkour descend" "440.5" "80" "440.5" "448" "80.00" "440" "270" } run_turn_density_routes() { @@ -403,7 +407,7 @@ run_turn_density_routes() { set_stone 465 79 464 set_stone 465 79 465 set_stone 466 79 466 - run_accepted_route "turn-density-alternating-traverse-diagonal-chain" "Turn density - alternating traverse diagonal chain" "460.5" "80" "460.5" "466" "80.00" "466" + run_accepted_route "turn-density-alternating-traverse-diagonal-chain" "Turn density - alternating traverse diagonal chain" "460.5" "80" "460.5" "466" "80.00" "466" "270" } run_speed_carry_routes() { @@ -420,7 +424,7 @@ run_speed_carry_routes() { set_stone 486 82 480 set_stone 487 82 480 set_stone 488 83 480 - run_accepted_route "speed-carry-repeated-traverse-ascend" "Speed carry - repeated traverse ascend" "480.5" "80" "480.5" "488" "84.00" "480" + run_accepted_route "speed-carry-repeated-traverse-ascend" "Speed carry - repeated traverse ascend" "480.5" "80" "480.5" "488" "84.00" "480" "270" fill_box 498 79 498 510 82 502 air fill_box 498 80 498 510 94 502 air @@ -432,7 +436,7 @@ run_speed_carry_routes() { set_stone 505 80 500 set_stone 506 79 500 set_stone 507 79 500 - run_accepted_route "speed-carry-repeated-traverse-descend" "Speed carry - repeated traverse descend" "500.5" "83" "500.5" "507" "80.00" "500" + run_accepted_route "speed-carry-repeated-traverse-descend" "Speed carry - repeated traverse descend" "500.5" "83" "500.5" "507" "80.00" "500" "270" fill_box 518 79 518 532 79 522 air fill_box 518 80 518 532 90 522 air @@ -443,7 +447,7 @@ run_speed_carry_routes() { set_stone 526 79 520 set_stone 527 79 520 set_stone 529 79 520 - run_accepted_route "speed-carry-repeated-traverse-parkour" "Speed carry - repeated traverse parkour" "520.5" "80" "520.5" "529" "80.00" "520" + run_accepted_route "speed-carry-repeated-traverse-parkour" "Speed carry - repeated traverse parkour" "520.5" "80" "520.5" "529" "80.00" "520" "270" } start_mcc diff --git a/tools/test-pathing-template-regressions.sh b/tools/test-pathing-template-regressions.sh index 2b7abfca..5aa38be8 100644 --- a/tools/test-pathing-template-regressions.sh +++ b/tools/test-pathing-template-regressions.sh @@ -160,11 +160,13 @@ prepare_independent_route() { local start_x="$2" local start_y="$3" local start_z="$4" + local start_yaw="${5:-270}" + local start_pitch="${6:-0}" echo "" echo "Preparing independent route: $label" mc-rcon "effect clear $USERNAME" >/dev/null 2>&1 || true - mc-rcon "tp $USERNAME $start_x $start_y $start_z" >/dev/null + mc-rcon "tp $USERNAME $start_x $start_y $start_z $start_yaw $start_pitch" >/dev/null wait_for_location_in_block "$start_x" "$start_y" "$start_z" 10 } @@ -289,7 +291,7 @@ 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 - prepare_independent_route "Flat final stop" "100.5" "80" "100.5" + prepare_independent_route "Flat final stop" "100.5" "80" "100.5" "270" capture_debug_state_before_route "Flat final stop" local start_line start_line="$(log_line_count)" @@ -315,7 +317,7 @@ run_parkour_into_turn() { 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 - prepare_independent_route "Parkour into L-turn" "120.5" "80" "110.5" + prepare_independent_route "Parkour into L-turn" "120.5" "80" "110.5" "270" capture_debug_state_before_route "Parkour into L-turn" local start_line start_line="$(log_line_count)" @@ -342,7 +344,7 @@ run_side_wall_jump() { 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 - prepare_independent_route "Rejected 2x1 side-wall jump" "131.5" "80" "127.5" + prepare_independent_route "Rejected 2x1 side-wall jump" "131.5" "80" "127.5" "270" capture_debug_state_before_route "Rejected 2x1 side-wall jump" local start_line start_line="$(log_line_count)" @@ -365,7 +367,7 @@ run_reject_3x1_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 - prepare_independent_route "Rejected 3x1 no-run-up gap" "141.5" "80" "138.5" + prepare_independent_route "Rejected 3x1 no-run-up gap" "141.5" "80" "138.5" "270" capture_debug_state_before_route "Rejected 3x1 gap" local start_line start_line="$(log_line_count)" @@ -390,7 +392,7 @@ run_corner_ascend_around_wall() { 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 - prepare_independent_route "Corner ascend around wall" "190.5" "80" "170.5" + prepare_independent_route "Corner ascend around wall" "190.5" "80" "170.5" "315" capture_debug_state_before_route "Corner ascend around wall" local start_line start_line="$(log_line_count)" @@ -417,7 +419,7 @@ run_wall_adjacent_descend_smoke() { 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 - prepare_independent_route "Wall-adjacent descend" "200.5" "81" "200.5" + prepare_independent_route "Wall-adjacent descend" "200.5" "81" "200.5" "270" capture_debug_state_before_route "Wall-adjacent descend" local start_line start_line="$(log_line_count)" @@ -441,7 +443,7 @@ run_ascend_chain_smoke() { 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 - prepare_independent_route "Ascend chain smoke" "171.5" "80" "160.5" + prepare_independent_route "Ascend chain smoke" "171.5" "80" "160.5" "315" capture_debug_state_before_route "Ascend chain smoke" local start_line start_line="$(log_line_count)" diff --git a/tools/test-transition-braking.sh b/tools/test-transition-braking.sh index f5d1c89d..1182ed85 100644 --- a/tools/test-transition-braking.sh +++ b/tools/test-transition-braking.sh @@ -142,11 +142,13 @@ prepare_independent_route() { local start_x="$2" local start_y="$3" local start_z="$4" + local start_yaw="${5:-270}" + local start_pitch="${6:-0}" echo "" echo "Preparing independent route: $label" mc-rcon "effect clear $USERNAME" >/dev/null 2>&1 || true - mc-rcon "tp $USERNAME $start_x $start_y $start_z" >/dev/null + mc-rcon "tp $USERNAME $start_x $start_y $start_z $start_yaw $start_pitch" >/dev/null wait_for_location_in_block "$start_x" "$start_y" "$start_z" 10 } @@ -240,7 +242,7 @@ 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 - prepare_independent_route "Flat final stop" "100.5" "80" "100.5" + prepare_independent_route "Flat final stop" "100.5" "80" "100.5" "270" capture_debug_state_before_route "Flat final stop" local start_line start_line="$(log_line_count)" @@ -263,7 +265,7 @@ run_parkour_into_turn() { 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 - prepare_independent_route "Parkour into turn" "120.5" "80" "110.5" + prepare_independent_route "Parkour into turn" "120.5" "80" "110.5" "270" capture_debug_state_before_route "Parkour into turn" local start_line start_line="$(log_line_count)"