diff --git a/docs/guide/pathfinding-research.md b/docs/guide/pathfinding-research.md index d1a7597c..f55c0123 100644 --- a/docs/guide/pathfinding-research.md +++ b/docs/guide/pathfinding-research.md @@ -258,8 +258,57 @@ The new regression harness in `tools/test-pathing-template-regressions.sh` codif 4. A 3×1 no-run-up rejection to prevent non-executable plans from sneaking through. 5. Mixed ascend/descend/climb smoke cases so that both vertical transitions and ladder climbs respect the reliable support requirement. +## Deterministic live route contract + +For the short-route and long-route `1.21.11-Vanilla` live harnesses, accepted routes must complete with all of the following: + +- `A* result: Success` +- `0 replan` +- `0` template segment failures +- final position inside the intended goal support block +- `PathMgr` reporting `Navigation complete!` + +For rejection scenarios, the requirement is stricter: + +- `A* result: Failed` or `No path found` +- no navigation start +- no executor-driven `replan` + +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`. + +## 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. + +Borrow: + +- landing-aware completion, where movement logic keeps controlling after touchdown instead of failing immediately +- next-movement-aware descend and ascend handoff behavior +- conservative parkour admissibility, especially around run-up, overshoot, and blocked landing shapes +- executor timeout and movement-stuck heuristics as diagnostic input, not as acceptance criteria + +Do not borrow: + +- `GoalBlock` occupancy semantics as a substitute for deterministic execution quality +- executor repath tolerance as proof that a movement is reliable +- any behavior that lets accepted deterministic harness routes succeed only by falling back to `replan` + +For this work, Baritone is a movement-control reference, not a correctness oracle. MCC's accepted live routes must still finish with `0 replan` in the deterministic harness. + Keeping the rule explicit here reminds future contributors that the planner should never promise a move that physically cannot finish with block contact. +## Regression Harness Workflow + +The scripts in `tools/` now match the `mcc-dev-workflow` defaults: they call +`source tools/mcc-env.sh`, rely on a shared `mc-*` server running `1.21.11-Vanilla`, +and launch MCC through `mcc-build`, `mcc-debug`, and `mcc-cmd` wrappers. The +harnesses reuse the existing server session instead of stopping and restarting +it, which keeps shared test infrastructure stable and honors the instruction to +keep `mc-*` servers running unless another version or explicit reset is required. +When editing or extending the harness, preserve the `mcc-*` invocation pattern +and the existing log/tail helpers so the scripts stay compatible with the updated +workflow. + ## References - [Minecraft Parkour Wiki: Blip](https://www.mcpk.wiki/wiki/Blip) diff --git a/docs/superpowers/plans/2026-04-13-pathing-contract-metrics-harness.md b/docs/superpowers/plans/2026-04-13-pathing-contract-metrics-harness.md new file mode 100644 index 00000000..a279033c --- /dev/null +++ b/docs/superpowers/plans/2026-04-13-pathing-contract-metrics-harness.md @@ -0,0 +1,1466 @@ +# Pathing Contract Metrics Harness 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 pathing test harness that treats planner failure or any replan as a hard failure, and reports per-route plus per-segment tick budgets so slow steps are visible immediately. + +**Architecture:** Split the problem into three layers. First, define scenario contracts that describe the expected planner output for each sterile route. Second, add execution telemetry that records actual segment durations and total route ticks without relying on fragile log scraping in unit tests. Third, reuse the same contracts and telemetry output in the live shell harness so unit tests and real-server runs speak the same language: planner contract, zero replan, total ticks, and which segment exceeded budget. + +**Tech Stack:** C# 14 / .NET 10, xUnit, MCC pathing execution stack (`PathSegmentManager`, `PathExecutor`, templates), JSON contract files under `MinecraftClient.Tests/TestData`, Bash + Python 3 live harness helpers under `tools/`, local `1.21.11-Vanilla` server via `tools/mcc-env.sh`. + +--- + +## Measurement Contract + +- Accepted deterministic routes fail immediately on any of: + - planner `Failed` + - planner `Partial` + - any executor `Replan #` + - any `[PathExec] Segment ... FAILED` + - total route ticks above budget + - any segment ticks above budget +- Rejected routes fail immediately on any of: + - planner `Success` or `Partial` + - navigation starting at all + - any replan attempt +- Timing uses two numbers per route and per segment: + - `expectedTicks`: best known sterile baseline + - `maxTicks`: enforced ceiling +- Initial `maxTicks` seeding rule: + - `maxTicks = expectedTicks + max(2, ceil(expectedTicks * 0.20))` +- After the first bootstrap pass, keep `expectedTicks` fixed in JSON and tune `maxTicks` only where the measured deterministic baseline proves the generic 20% rule is too loose or too tight. + +## File Structure + +- Create: `MinecraftClient.Tests/Pathing/Execution/Scenarios/PathingExecutionScenario.cs` + - immutable scenario definition: id, world builder, start, goal, initial yaw, execution cap +- Create: `MinecraftClient.Tests/Pathing/Execution/Scenarios/PathingExecutionScenarioCatalog.cs` + - sterile test worlds for short routes, jump combos, and long routes +- Create: `MinecraftClient.Tests/Pathing/Execution/Contracts/PathingPlannerContract.cs` + - expected planner result and exact segment sequence +- Create: `MinecraftClient.Tests/Pathing/Execution/Contracts/PathingTimingBudget.cs` + - route and segment tick budgets +- Create: `MinecraftClient.Tests/Pathing/Execution/Contracts/PathingContractStore.cs` + - JSON loader for planner contracts and timing budgets +- Create: `MinecraftClient.Tests/TestData/Pathing/pathing-planner-contracts.json` + - expected move sequence per scenario +- Create: `MinecraftClient.Tests/TestData/Pathing/pathing-timing-budgets.json` + - expected ticks and max ticks per route and per segment +- Create: `MinecraftClient/Pathing/Execution/Telemetry/IPathExecutionObserver.cs` + - execution observer API +- Create: `MinecraftClient/Pathing/Execution/Telemetry/PathExecutionLogObserver.cs` + - machine-readable live telemetry lines for shell parsing +- Modify: `MinecraftClient/Pathing/Execution/PathExecutor.cs` + - emit segment start, complete, fail, and per-segment elapsed ticks +- Modify: `MinecraftClient/Pathing/Execution/PathSegmentManager.cs` + - emit navigation start, complete, replan start, replan success, replan failure, and total route ticks +- Create: `MinecraftClient.Tests/Pathing/Execution/Support/RecordingPathExecutionObserver.cs` + - in-memory capture of planner/executor events for assertions +- Create: `MinecraftClient.Tests/Pathing/Execution/Support/PathingScenarioRunner.cs` + - deterministic runner that plans, executes, and returns trace + logs +- Create: `MinecraftClient.Tests/Pathing/Execution/Support/PathingContractAssert.cs` + - compares actual plan/timing to JSON contracts and prints slow-step tables +- Create: `MinecraftClient.Tests/Pathing/Execution/Support/PathingContractBootstrapWriter.cs` + - emits ready-to-paste JSON fragments from observed planner/timing traces +- Create: `MinecraftClient.Tests/Pathing/Execution/PathingContractBootstrapTests.cs` + - explicit bootstrap tests used only to seed contracts +- Create: `MinecraftClient.Tests/Pathing/Execution/PathPlanningContractTests.cs` + - exact planner contract assertions +- Create: `MinecraftClient.Tests/Pathing/Execution/PathTimingContractTests.cs` + - zero-replan and timing-budget assertions +- Modify: `MinecraftClient.Tests/Pathing/Execution/PathSegmentManagerTests.cs` + - migrate existing manager smoke tests to the shared runner where useful +- Create: `tools/pathing_contract_report.py` + - shell-side parser for planner contracts, timing budgets, and MCC telemetry lines +- Modify: `tools/test-pathing-jump-combos.sh` + - call the report helper after each accepted route +- Modify: `tools/test-pathing-long-routes.sh` + - call the report helper after each accepted route +- Modify: `docs/guide/pathfinding-research.md` + - document planner vetoes, zero-replan rule, and timing metrics workflow + +### Task 1: Create Contract Types And Seed One Known Scenario + +**Files:** +- Create: `MinecraftClient.Tests/Pathing/Execution/Contracts/PathingPlannerContract.cs` +- Create: `MinecraftClient.Tests/Pathing/Execution/Contracts/PathingTimingBudget.cs` +- Create: `MinecraftClient.Tests/Pathing/Execution/Contracts/PathingContractStore.cs` +- Create: `MinecraftClient.Tests/TestData/Pathing/pathing-planner-contracts.json` +- Create: `MinecraftClient.Tests/TestData/Pathing/pathing-timing-budgets.json` +- Test: `MinecraftClient.Tests/Pathing/Execution/PathPlanningContractTests.cs` + +- [ ] **Step 1: Write the failing contract-loader test** + +```csharp +using MinecraftClient.Pathing.Core; +using Xunit; + +namespace MinecraftClient.Tests.Pathing.Execution; + +public sealed class PathPlanningContractTests +{ + [Fact] + public void Get_ManagerAcceptedAscendChain_LoadsExactPlannerContract() + { + PathingPlannerContract contract = PathingContractStore.GetPlanner("manager-accepted-ascend-chain"); + + Assert.Equal(PathStatus.Success, contract.ExpectedStatus); + Assert.Equal(6, contract.Segments.Length); + Assert.Collection(contract.Segments, + segment => + { + Assert.Equal(MoveType.Diagonal, segment.MoveType); + Assert.Equal(new PathingBlock(171, 80, 160), segment.StartBlock); + Assert.Equal(new PathingBlock(172, 80, 161), segment.EndBlock); + }, + segment => + { + Assert.Equal(MoveType.Ascend, segment.MoveType); + Assert.Equal(new PathingBlock(176, 82, 162), segment.StartBlock); + Assert.Equal(new PathingBlock(177, 83, 162), segment.EndBlock); + }); + } +} +``` + +- [ ] **Step 2: Run the test to verify it fails** + +Run: `dotnet test MinecraftClient.Tests/MinecraftClient.Tests.csproj --filter FullyQualifiedName~Pathing.Execution.PathPlanningContractTests.Get_ManagerAcceptedAscendChain_LoadsExactPlannerContract -v minimal` + +Expected: FAIL with a compile error because `PathingPlannerContract`, `PathingContractStore`, and `PathingBlock` do not exist yet. + +- [ ] **Step 3: Implement the contract models, store, and the first JSON entries** + +`MinecraftClient.Tests/Pathing/Execution/Contracts/PathingPlannerContract.cs` + +```csharp +using MinecraftClient.Pathing.Core; + +namespace MinecraftClient.Tests.Pathing.Execution; + +internal sealed record PathingBlock(int X, int Y, int Z); + +internal sealed record PathingPlannerSegmentContract +{ + public required MoveType MoveType { get; init; } + public required PathingBlock StartBlock { get; init; } + public required PathingBlock EndBlock { get; init; } +} + +internal sealed record PathingPlannerContract +{ + public required string ScenarioId { get; init; } + public required PathStatus ExpectedStatus { get; init; } + public required PathingPlannerSegmentContract[] Segments { get; init; } +} + +internal sealed record PathingSegmentTimingBudget +{ + public required MoveType MoveType { get; init; } + public required int ExpectedTicks { get; init; } + public required int MaxTicks { get; init; } +} + +internal sealed record PathingTimingBudget +{ + public required string ScenarioId { get; init; } + public required int ExpectedTotalTicks { get; init; } + public required int MaxTotalTicks { get; init; } + public required PathingSegmentTimingBudget[] Segments { get; init; } +} +``` + +`MinecraftClient.Tests/Pathing/Execution/Contracts/PathingContractStore.cs` + +```csharp +using System.Text.Json; + +namespace MinecraftClient.Tests.Pathing.Execution; + +internal static class PathingContractStore +{ + private static readonly Lazy> PlannerContracts = new(LoadPlanner); + private static readonly Lazy> TimingBudgets = new(LoadTiming); + + internal static PathingPlannerContract GetPlanner(string scenarioId) => PlannerContracts.Value[scenarioId]; + internal static PathingTimingBudget GetTiming(string scenarioId) => TimingBudgets.Value[scenarioId]; + + private static IReadOnlyDictionary LoadPlanner() => + Load("pathing-planner-contracts.json"); + + private static IReadOnlyDictionary LoadTiming() => + Load("pathing-timing-budgets.json"); + + private static IReadOnlyDictionary Load(string fileName) where TContract : class + { + string repoRoot = Path.GetFullPath(Path.Combine(AppContext.BaseDirectory, "..", "..", "..", "..")); + string path = Path.Combine(repoRoot, "MinecraftClient.Tests", "TestData", "Pathing", fileName); + + using FileStream stream = File.OpenRead(path); + var contracts = JsonSerializer.Deserialize(stream, new JsonSerializerOptions(JsonSerializerDefaults.Web)) + ?? throw new InvalidOperationException($"Failed to deserialize {path}"); + + return contracts.ToDictionary( + contract => (string)typeof(TContract).GetProperty("ScenarioId")!.GetValue(contract)!, + StringComparer.Ordinal); + } +} +``` + +`MinecraftClient.Tests/TestData/Pathing/pathing-planner-contracts.json` + +```json +[ + { + "scenarioId": "manager-accepted-ascend-chain", + "expectedStatus": "Success", + "segments": [ + { "moveType": "Diagonal", "startBlock": { "x": 171, "y": 80, "z": 160 }, "endBlock": { "x": 172, "y": 80, "z": 161 } }, + { "moveType": "Diagonal", "startBlock": { "x": 172, "y": 80, "z": 161 }, "endBlock": { "x": 173, "y": 80, "z": 162 } }, + { "moveType": "Traverse", "startBlock": { "x": 173, "y": 80, "z": 162 }, "endBlock": { "x": 174, "y": 80, "z": 162 } }, + { "moveType": "Ascend", "startBlock": { "x": 174, "y": 80, "z": 162 }, "endBlock": { "x": 175, "y": 81, "z": 162 } }, + { "moveType": "Ascend", "startBlock": { "x": 175, "y": 81, "z": 162 }, "endBlock": { "x": 176, "y": 82, "z": 162 } }, + { "moveType": "Ascend", "startBlock": { "x": 176, "y": 82, "z": 162 }, "endBlock": { "x": 177, "y": 83, "z": 162 } } + ] + } +] +``` + +`MinecraftClient.Tests/TestData/Pathing/pathing-timing-budgets.json` + +```json +[ + { + "scenarioId": "manager-accepted-ascend-chain", + "expectedTotalTicks": 0, + "maxTotalTicks": 0, + "segments": [ + { "moveType": "Diagonal", "expectedTicks": 0, "maxTicks": 0 }, + { "moveType": "Ascend", "expectedTicks": 0, "maxTicks": 0 } + ] + } +] +``` + +Notes: +- `pathing-timing-budgets.json` is intentionally seeded with zeroes only for the one scaffold scenario. Task 3 replaces them with measured values before timing assertions start. +- Keep planner contracts and timing budgets separate so segment sequence can be locked before budgets are calibrated. + +- [ ] **Step 4: Run the contract-loader test to verify it passes** + +Run: `dotnet test MinecraftClient.Tests/MinecraftClient.Tests.csproj --filter FullyQualifiedName~Pathing.Execution.PathPlanningContractTests.Get_ManagerAcceptedAscendChain_LoadsExactPlannerContract -v minimal` + +Expected: PASS + +- [ ] **Step 5: Commit** + +```bash +git add MinecraftClient.Tests/Pathing/Execution/Contracts/PathingPlannerContract.cs \ + MinecraftClient.Tests/Pathing/Execution/Contracts/PathingContractStore.cs \ + MinecraftClient.Tests/TestData/Pathing/pathing-planner-contracts.json \ + MinecraftClient.Tests/TestData/Pathing/pathing-timing-budgets.json \ + MinecraftClient.Tests/Pathing/Execution/PathPlanningContractTests.cs +git commit -m "test: add pathing contract store scaffold" +``` + +### Task 2: Add Execution Telemetry And A Deterministic Scenario Runner + +**Files:** +- Create: `MinecraftClient/Pathing/Execution/Telemetry/IPathExecutionObserver.cs` +- Modify: `MinecraftClient/Pathing/Execution/PathExecutor.cs` +- Modify: `MinecraftClient/Pathing/Execution/PathSegmentManager.cs` +- Create: `MinecraftClient.Tests/Pathing/Execution/Scenarios/PathingExecutionScenario.cs` +- Create: `MinecraftClient.Tests/Pathing/Execution/Scenarios/PathingExecutionScenarioCatalog.cs` +- Create: `MinecraftClient.Tests/Pathing/Execution/Support/RecordingPathExecutionObserver.cs` +- Create: `MinecraftClient.Tests/Pathing/Execution/Support/PathingScenarioRunner.cs` +- Test: `MinecraftClient.Tests/Pathing/Execution/PathTimingContractTests.cs` + +- [ ] **Step 1: Write the failing runner test** + +```csharp +using Xunit; + +namespace MinecraftClient.Tests.Pathing.Execution; + +public sealed class PathTimingContractTests +{ + [Fact] + public void Run_ManagerAcceptedAscendChain_CapturesPerSegmentTicks_AndZeroReplan() + { + PathingExecutionScenario scenario = PathingExecutionScenarioCatalog.Get("manager-accepted-ascend-chain"); + + PathingScenarioResult result = PathingScenarioRunner.RunAccepted(scenario); + + Assert.Equal(0, result.ReplanCount); + Assert.True(result.Completed); + Assert.Equal(6, result.SegmentRuns.Count); + Assert.All(result.SegmentRuns, run => Assert.True(run.ElapsedTicks > 0)); + } +} +``` + +- [ ] **Step 2: Run the test to verify it fails** + +Run: `dotnet test MinecraftClient.Tests/MinecraftClient.Tests.csproj --filter FullyQualifiedName~Pathing.Execution.PathTimingContractTests.Run_ManagerAcceptedAscendChain_CapturesPerSegmentTicks_AndZeroReplan -v minimal` + +Expected: FAIL with missing type errors for `PathingExecutionScenarioCatalog`, `PathingScenarioRunner`, and `PathingScenarioResult`. + +- [ ] **Step 3: Implement the observer API, wire it into path execution, and add the first scenario runner** + +`MinecraftClient/Pathing/Execution/Telemetry/IPathExecutionObserver.cs` + +```csharp +using MinecraftClient.Mapping; + +namespace MinecraftClient.Pathing.Execution.Telemetry; + +public interface IPathExecutionObserver +{ + void OnNavigationStarted(IReadOnlyList segments); + void OnSegmentStarted(int segmentIndex, int totalSegments, PathSegment segment); + void OnSegmentCompleted(int segmentIndex, int totalSegments, PathSegment segment, int elapsedTicks, Location position); + void OnSegmentFailed(int segmentIndex, int totalSegments, PathSegment segment, int elapsedTicks, Location position); + void OnNavigationCompleted(int totalTicks); + void OnReplanStarted(int replanCount, Location position); + void OnReplanSucceeded(int replanCount, IReadOnlyList segments); + void OnReplanFailed(int replanCount, Location position); +} +``` + +`MinecraftClient/Pathing/Execution/PathExecutor.cs` + +```csharp +using MinecraftClient.Pathing.Execution.Telemetry; + +private readonly IPathExecutionObserver? _observer; +private int _segmentTicks; +private int _totalTicks; +public int TotalTicks => _totalTicks; + +public PathExecutor(List segments, Action? debugLog = null, IPathExecutionObserver? observer = null) +{ + _segments = segments; + _currentIndex = 0; + _debugLog = debugLog; + _observer = observer; + _observer?.OnNavigationStarted(segments); + AdvanceToNextSegment(); +} + +public PathExecutorState Tick(Location pos, PlayerPhysics physics, MovementInput input, World world) +{ + if (_currentTemplate is null) + { + input.Reset(); + return PathExecutorState.Complete; + } + + _segmentTicks++; + _totalTicks++; + var state = _currentTemplate.Tick(pos, physics, input, world); + + switch (state) + { + case TemplateState.Complete: + _observer?.OnSegmentCompleted(_currentIndex, _segments.Count, _segments[_currentIndex], _segmentTicks, pos); + input.Reset(); + _currentIndex++; + _segmentTicks = 0; + if (_currentIndex >= _segments.Count) + { + _currentTemplate = null; + return PathExecutorState.Complete; + } + AdvanceToNextSegment(); + return PathExecutorState.InProgress; + + case TemplateState.Failed: + _observer?.OnSegmentFailed(_currentIndex, _segments.Count, _segments[_currentIndex], _segmentTicks, pos); + input.Reset(); + return PathExecutorState.Failed; + + default: + return PathExecutorState.InProgress; + } +} + +private void AdvanceToNextSegment() +{ + if (_currentIndex < _segments.Count) + { + var seg = _segments[_currentIndex]; + PathSegment? next = _currentIndex + 1 < _segments.Count ? _segments[_currentIndex + 1] : null; + _currentTemplate = ActionTemplateFactory.Create(seg, next); + _observer?.OnSegmentStarted(_currentIndex, _segments.Count, seg); + } + else + { + _currentTemplate = null; + } +} +``` + +`MinecraftClient/Pathing/Execution/PathSegmentManager.cs` + +```csharp +using MinecraftClient.Pathing.Execution.Telemetry; + +private readonly IPathExecutionObserver? _observer; + +public PathSegmentManager(Action? debugLog = null, Action? infoLog = null, IPathExecutionObserver? observer = null) +{ + _debugLog = debugLog; + _infoLog = infoLog; + _observer = observer; +} + +public void StartNavigation(IGoal goal, PathResult result) +{ + _goal = goal; + _replanCount = 0; + var segments = PathSegmentBuilder.FromPath(result.Path); + _executor = new PathExecutor(segments, _debugLog, _observer); + _infoLog?.Invoke($"[PathMgr] Navigation started: {segments.Count} segments"); +} + +public void Tick(Location pos, PlayerPhysics physics, MovementInput input, World world) +{ + if (_executor is null) + return; + + var state = _executor.Tick(pos, physics, input, world); + + switch (state) + { + case PathExecutorState.Complete: + _observer?.OnNavigationCompleted(_executor.TotalTicks); + _infoLog?.Invoke("[PathMgr] Navigation complete!"); + _executor = null; + _goal = null; + break; + + case PathExecutorState.Failed: + _infoLog?.Invoke("[PathMgr] Segment failed, replanning..."); + Replan(pos, world); + break; + } +} + +private void Replan(Location pos, World world) +{ + _replanCount++; + _observer?.OnReplanStarted(_replanCount, pos); + // existing logic... + if (result.Status == PathStatus.Failed || result.Path.Count < 2) + { + _observer?.OnReplanFailed(_replanCount, pos); + _executor = null; + _goal = null; + return; + } + + var segments = PathSegmentBuilder.FromPath(result.Path); + _observer?.OnReplanSucceeded(_replanCount, segments); + _executor = new PathExecutor(segments, _debugLog, _observer); +} +``` + +`MinecraftClient.Tests/Pathing/Execution/Scenarios/PathingExecutionScenario.cs` + +```csharp +using MinecraftClient.Mapping; +using MinecraftClient.Pathing.Goals; + +namespace MinecraftClient.Tests.Pathing.Execution; + +internal sealed record PathingExecutionScenario +{ + public required string Id { get; init; } + public required Func BuildWorld { get; init; } + public required Location Start { get; init; } + public required GoalBlock Goal { get; init; } + public required float StartYaw { get; init; } + public required int MaxExecutionTicks { get; init; } +} +``` + +`MinecraftClient.Tests/Pathing/Execution/Scenarios/PathingExecutionScenarioCatalog.cs` + +```csharp +using MinecraftClient.Mapping; +using MinecraftClient.Pathing.Goals; + +namespace MinecraftClient.Tests.Pathing.Execution; + +internal static class PathingExecutionScenarioCatalog +{ + internal static PathingExecutionScenario Get(string scenarioId) => scenarioId switch + { + "manager-accepted-ascend-chain" => new PathingExecutionScenario + { + Id = scenarioId, + BuildWorld = BuildManagerAcceptedAscendChain, + Start = new Location(171.5, 80, 160.5), + Goal = new GoalBlock(177, 83, 162), + StartYaw = 315f, + MaxExecutionTicks = 420 + }, + _ => throw new ArgumentOutOfRangeException(nameof(scenarioId), scenarioId, null) + }; + + private static World BuildManagerAcceptedAscendChain() + { + World world = FlatWorldTestBuilder.CreateStoneFloor(min: 158, max: 180); + FlatWorldTestBuilder.ClearBox(world, 170, 80, 160, 178, 85, 168); + FlatWorldTestBuilder.SetSolid(world, 175, 80, 162); + FlatWorldTestBuilder.SetSolid(world, 176, 81, 162); + FlatWorldTestBuilder.SetSolid(world, 177, 82, 162); + return world; + } +} +``` + +`MinecraftClient.Tests/Pathing/Execution/Support/RecordingPathExecutionObserver.cs` + +```csharp +using MinecraftClient.Mapping; +using MinecraftClient.Pathing.Execution.Telemetry; + +namespace MinecraftClient.Tests.Pathing.Execution; + +internal sealed record PathSegmentRun(int SegmentIndex, MoveType MoveType, int ElapsedTicks, Location Position); + +internal sealed class RecordingPathExecutionObserver : IPathExecutionObserver +{ + internal List SegmentRuns { get; } = new(); + internal int ReplanCount { get; private set; } + internal int TotalTicks { get; private set; } + + public void OnNavigationStarted(IReadOnlyList segments) { } + public void OnSegmentStarted(int segmentIndex, int totalSegments, PathSegment segment) { } + + public void OnSegmentCompleted(int segmentIndex, int totalSegments, PathSegment segment, int elapsedTicks, Location position) + => SegmentRuns.Add(new PathSegmentRun(segmentIndex, segment.MoveType, elapsedTicks, position)); + + public void OnSegmentFailed(int segmentIndex, int totalSegments, PathSegment segment, int elapsedTicks, Location position) + => SegmentRuns.Add(new PathSegmentRun(segmentIndex, segment.MoveType, elapsedTicks, position)); + + public void OnNavigationCompleted(int totalTicks) => TotalTicks = totalTicks; + public void OnReplanStarted(int replanCount, Location position) => ReplanCount = replanCount; + public void OnReplanSucceeded(int replanCount, IReadOnlyList segments) { } + public void OnReplanFailed(int replanCount, Location position) { } +} +``` + +`MinecraftClient.Tests/Pathing/Execution/Support/PathingScenarioRunner.cs` + +```csharp +using System.Threading; +using MinecraftClient.Mapping; +using MinecraftClient.Pathing.Core; +using MinecraftClient.Physics; + +namespace MinecraftClient.Tests.Pathing.Execution; + +internal sealed record PathingScenarioResult( + bool Completed, + int ReplanCount, + int TotalTicks, + IReadOnlyList SegmentRuns, + IReadOnlyList DebugLogs, + IReadOnlyList InfoLogs, + Location FinalPosition, + PathResult PlanResult); + +internal static class PathingScenarioRunner +{ + internal static PathResult PlanOnly(PathingExecutionScenario scenario) + { + World world = scenario.BuildWorld(); + var ctx = new CalculationContext(world, allowParkour: true, allowParkourAscend: true); + var finder = new AStarPathFinder(); + + return finder.Calculate( + ctx, + (int)Math.Floor(scenario.Start.X), + (int)Math.Floor(scenario.Start.Y), + (int)Math.Floor(scenario.Start.Z), + scenario.Goal, + CancellationToken.None, + timeoutMs: 3000); + } + + internal static PathingScenarioResult RunAccepted(PathingExecutionScenario scenario) + { + World world = scenario.BuildWorld(); + var debugLogs = new List(); + var infoLogs = new List(); + var observer = new RecordingPathExecutionObserver(); + var manager = new PathSegmentManager(debugLogs.Add, infoLogs.Add, observer); + var physics = TemplateSimulationRunner.CreateGroundedPhysics(scenario.Start, scenario.StartYaw); + var input = new MovementInput(); + + PathResult planResult = PlanOnly(scenario); + + manager.StartNavigation(scenario.Goal, planResult); + + int tick = 0; + for (; tick < scenario.MaxExecutionTicks && 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); + } + + return new PathingScenarioResult( + Completed: !manager.IsNavigating, + ReplanCount: manager.ReplanCount, + TotalTicks: tick, + SegmentRuns: observer.SegmentRuns, + DebugLogs: debugLogs, + InfoLogs: infoLogs, + FinalPosition: new Location(physics.Position.X, physics.Position.Y, physics.Position.Z), + PlanResult: planResult); + } +} +``` + +- [ ] **Step 4: Run the runner test to verify it passes** + +Run: `dotnet test MinecraftClient.Tests/MinecraftClient.Tests.csproj --filter FullyQualifiedName~Pathing.Execution.PathTimingContractTests.Run_ManagerAcceptedAscendChain_CapturesPerSegmentTicks_AndZeroReplan -v minimal` + +Expected: PASS + +- [ ] **Step 5: Commit** + +```bash +git add MinecraftClient/Pathing/Execution/Telemetry/IPathExecutionObserver.cs \ + MinecraftClient/Pathing/Execution/PathExecutor.cs \ + MinecraftClient/Pathing/Execution/PathSegmentManager.cs \ + MinecraftClient.Tests/Pathing/Execution/Scenarios/PathingExecutionScenario.cs \ + MinecraftClient.Tests/Pathing/Execution/Scenarios/PathingExecutionScenarioCatalog.cs \ + MinecraftClient.Tests/Pathing/Execution/Support/RecordingPathExecutionObserver.cs \ + MinecraftClient.Tests/Pathing/Execution/Support/PathingScenarioRunner.cs \ + MinecraftClient.Tests/Pathing/Execution/PathTimingContractTests.cs +git commit -m "test: record path execution segment timings" +``` + +### Task 3: Lock Planner Vetoes And Short-Route Timing Budgets + +**Files:** +- Create: `MinecraftClient.Tests/Pathing/Execution/Support/PathingContractAssert.cs` +- Create: `MinecraftClient.Tests/Pathing/Execution/Support/PathingContractBootstrapWriter.cs` +- Create: `MinecraftClient.Tests/Pathing/Execution/PathingContractBootstrapTests.cs` +- Modify: `MinecraftClient.Tests/Pathing/Execution/Scenarios/PathingExecutionScenarioCatalog.cs` +- Modify: `MinecraftClient.Tests/TestData/Pathing/pathing-planner-contracts.json` +- Modify: `MinecraftClient.Tests/TestData/Pathing/pathing-timing-budgets.json` +- Modify: `MinecraftClient.Tests/Pathing/Execution/PathPlanningContractTests.cs` +- Modify: `MinecraftClient.Tests/Pathing/Execution/PathTimingContractTests.cs` + +- [ ] **Step 1: Write bootstrap tests that print planner and timing JSON fragments for the known short routes** + +```csharp +using Xunit.Abstractions; + +namespace MinecraftClient.Tests.Pathing.Execution; + +internal static PathingExecutionScenario Get(string scenarioId) => scenarioId switch +{ + "same-move-ascend-staircase" => new PathingExecutionScenario + { + Id = scenarioId, + BuildWorld = BuildSameMoveAscendStaircase, + Start = new Location(340.5, 80, 340.5), + Goal = new GoalBlock(345, 85, 340), + StartYaw = 270f, + MaxExecutionTicks = 420 + }, + "same-move-descend-staircase" => new PathingExecutionScenario + { + Id = scenarioId, + BuildWorld = BuildSameMoveDescendStaircase, + Start = new Location(362.5, 85, 360.5), + Goal = new GoalBlock(367, 80, 360), + StartYaw = 270f, + MaxExecutionTicks = 420 + }, + "rejected-3x1-invalid-goal" => new PathingExecutionScenario + { + Id = scenarioId, + BuildWorld = BuildRejectedThreeByOneInvalidGoal, + Start = new Location(141.5, 80, 138.5), + Goal = new GoalBlock(144, 81, 138), + StartYaw = 270f, + MaxExecutionTicks = 80 + }, + _ => throw new ArgumentOutOfRangeException(nameof(scenarioId), scenarioId, null) +}; + +public sealed class PathingContractBootstrapTests +{ + private readonly ITestOutputHelper _output; + + public PathingContractBootstrapTests(ITestOutputHelper output) => _output = output; + + [Theory] + [InlineData("same-move-ascend-staircase")] + [InlineData("same-move-descend-staircase")] + [InlineData("rejected-3x1-invalid-goal")] + public void PrintShortRouteContractFragments(string scenarioId) + { + PathingExecutionScenario scenario = PathingExecutionScenarioCatalog.Get(scenarioId); + PathResult planResult = PathingScenarioRunner.PlanOnly(scenario); + + _output.WriteLine(PathingContractBootstrapWriter.WritePlannerFragment(scenarioId, planResult)); + if (planResult.Status == PathStatus.Success) + _output.WriteLine(PathingContractBootstrapWriter.WriteTimingFragment(scenarioId, PathingScenarioRunner.RunAccepted(scenario))); + } +} +``` + +- [ ] **Step 2: Run the bootstrap tests and capture the JSON fragments** + +Run: `dotnet test MinecraftClient.Tests/MinecraftClient.Tests.csproj --filter FullyQualifiedName~Pathing.Execution.PathingContractBootstrapTests.PrintShortRouteContractFragments -v minimal` + +Expected: +- PASS +- output contains ready-to-paste JSON for: + - `same-move-ascend-staircase` + - `same-move-descend-staircase` + - `rejected-3x1-invalid-goal` + +- [ ] **Step 3: Paste the emitted fragments into the contract files, then write the enforcing assertions** + +`MinecraftClient.Tests/Pathing/Execution/Support/PathingContractAssert.cs` + +```csharp +using System.Text; +using MinecraftClient.Mapping; +using MinecraftClient.Pathing.Execution; +using MinecraftClient.Pathing.Core; +using Xunit; +using Xunit.Sdk; + +namespace MinecraftClient.Tests.Pathing.Execution; + +internal static class PathingContractAssert +{ + internal static void PlannerMatches(PathingPlannerContract contract, IReadOnlyList segments, PathResult result) + { + if (result.Status != contract.ExpectedStatus) + throw new XunitException($"planner status mismatch: expected {contract.ExpectedStatus}, got {result.Status}"); + + if (segments.Count != contract.Segments.Length) + throw new XunitException($"segment count mismatch: expected {contract.Segments.Length}, got {segments.Count}"); + + for (int i = 0; i < segments.Count; i++) + { + PathSegment actual = segments[i]; + PathingPlannerSegmentContract expected = contract.Segments[i]; + + Assert.Equal(expected.MoveType, actual.MoveType); + Assert.Equal(expected.StartBlock, ToBlock(actual.Start)); + Assert.Equal(expected.EndBlock, ToBlock(actual.End)); + } + } + + internal static void TimingMatches(PathingTimingBudget budget, PathingScenarioResult result) + { + if (!result.Completed) + throw new XunitException("navigation did not complete"); + if (result.ReplanCount != 0) + throw new XunitException($"expected 0 replans, saw {result.ReplanCount}\n{Format(result, budget)}"); + if (result.TotalTicks > budget.MaxTotalTicks) + throw new XunitException($"route exceeded budget: actual={result.TotalTicks} max={budget.MaxTotalTicks}\n{Format(result, budget)}"); + if (result.SegmentRuns.Count != budget.Segments.Length) + throw new XunitException($"segment timing count mismatch: actual={result.SegmentRuns.Count} expected={budget.Segments.Length}"); + + for (int i = 0; i < budget.Segments.Length; i++) + { + if (result.SegmentRuns[i].ElapsedTicks > budget.Segments[i].MaxTicks) + throw new XunitException($"segment {i} exceeded budget\n{Format(result, budget)}"); + } + } + + private static string Format(PathingScenarioResult result, PathingTimingBudget budget) + { + var sb = new StringBuilder(); + sb.AppendLine($"route actual={result.TotalTicks} expected={budget.ExpectedTotalTicks} max={budget.MaxTotalTicks}"); + for (int i = 0; i < result.SegmentRuns.Count; i++) + { + var actual = result.SegmentRuns[i]; + var expected = budget.Segments[i]; + sb.AppendLine($"seg[{i}] move={actual.MoveType} actual={actual.ElapsedTicks} expected={expected.ExpectedTicks} max={expected.MaxTicks}"); + } + return sb.ToString(); + } + + private static PathingBlock ToBlock(Location location) => + new((int)Math.Floor(location.X), (int)Math.Floor(location.Y), (int)Math.Floor(location.Z)); +} +``` + +`MinecraftClient.Tests/Pathing/Execution/PathPlanningContractTests.cs` + +```csharp +[Theory] +[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.GetPlanner(scenarioId); + + PathingContractAssert.PlannerMatches(contract, PathSegmentBuilder.FromPath(planResult.Path), planResult); +} +``` + +`MinecraftClient.Tests/Pathing/Execution/PathTimingContractTests.cs` + +```csharp +[Theory] +[InlineData("same-move-ascend-staircase")] +[InlineData("same-move-descend-staircase")] +public void Scenario_ExecutionStaysWithinTimingBudget(string scenarioId) +{ + PathingExecutionScenario scenario = PathingExecutionScenarioCatalog.Get(scenarioId); + PathingTimingBudget budget = PathingContractStore.GetTiming(scenarioId); + PathingScenarioResult result = PathingScenarioRunner.RunAccepted(scenario); + + PathingContractAssert.TimingMatches(budget, result); +} +``` + +- [ ] **Step 4: Run the short-route planner and timing tests** + +Run: `dotnet test MinecraftClient.Tests/MinecraftClient.Tests.csproj --filter "FullyQualifiedName~Pathing.Execution.PathPlanningContractTests|FullyQualifiedName~Pathing.Execution.PathTimingContractTests" -v minimal` + +Expected: +- planner tests PASS for the two accepted short routes and the direct planner rejection +- timing tests PASS for ascend and descend staircase + +- [ ] **Step 5: Commit** + +```bash +git add MinecraftClient.Tests/Pathing/Execution/Support/PathingContractAssert.cs \ + MinecraftClient.Tests/Pathing/Execution/Support/PathingContractBootstrapWriter.cs \ + MinecraftClient.Tests/Pathing/Execution/PathingContractBootstrapTests.cs \ + MinecraftClient.Tests/Pathing/Execution/Scenarios/PathingExecutionScenarioCatalog.cs \ + MinecraftClient.Tests/TestData/Pathing/pathing-planner-contracts.json \ + MinecraftClient.Tests/TestData/Pathing/pathing-timing-budgets.json \ + MinecraftClient.Tests/Pathing/Execution/PathPlanningContractTests.cs \ + MinecraftClient.Tests/Pathing/Execution/PathTimingContractTests.cs +git commit -m "test: lock short route planner and timing contracts" +``` + +### Task 4: Bootstrap And Lock Complex Jump-Combo Contracts + +**Files:** +- Modify: `MinecraftClient.Tests/Pathing/Execution/Scenarios/PathingExecutionScenarioCatalog.cs` +- Modify: `MinecraftClient.Tests/TestData/Pathing/pathing-planner-contracts.json` +- Modify: `MinecraftClient.Tests/TestData/Pathing/pathing-timing-budgets.json` +- Modify: `MinecraftClient.Tests/Pathing/Execution/PathingContractBootstrapTests.cs` +- Modify: `MinecraftClient.Tests/Pathing/Execution/PathPlanningContractTests.cs` +- Modify: `MinecraftClient.Tests/Pathing/Execution/PathTimingContractTests.cs` + +- [ ] **Step 1: Add the jump-combo sterile worlds and bootstrap coverage** + +`MinecraftClient.Tests/Pathing/Execution/Scenarios/PathingExecutionScenarioCatalog.cs` + +```csharp +internal static PathingExecutionScenario Get(string scenarioId) => scenarioId switch +{ + "repeated-cardinal-parkour-chain" => new PathingExecutionScenario + { + Id = scenarioId, + BuildWorld = BuildRepeatedCardinalParkourChain, + Start = new Location(580.5, 80, 580.5), + Goal = new GoalBlock(588, 80, 580), + StartYaw = 270f, + MaxExecutionTicks = 420 + }, + "repeated-diagonal-parkour-chain" => new PathingExecutionScenario + { + Id = scenarioId, + BuildWorld = BuildRepeatedDiagonalParkourChain, + Start = new Location(600.5, 80, 600.5), + Goal = new GoalBlock(606, 80, 606), + StartYaw = 315f, + MaxExecutionTicks = 420 + }, + "obstructed-parkour-l-turns" => new PathingExecutionScenario + { + Id = scenarioId, + BuildWorld = BuildObstructedParkourLTurns, + Start = new Location(620.5, 80, 620.5), + Goal = new GoalBlock(626, 80, 622), + StartYaw = 270f, + MaxExecutionTicks = 420 + }, + "vertical-jump-mix" => new PathingExecutionScenario + { + Id = scenarioId, + BuildWorld = BuildVerticalJumpMix, + Start = new Location(640.5, 80, 620.5), + Goal = new GoalBlock(648, 80, 620), + StartYaw = 270f, + MaxExecutionTicks = 420 + }, + "diagonal-vertical-mix" => new PathingExecutionScenario + { + Id = scenarioId, + BuildWorld = BuildDiagonalVerticalMix, + Start = new Location(680.5, 80, 620.5), + Goal = new GoalBlock(684, 80, 624), + StartYaw = 315f, + MaxExecutionTicks = 420 + }, + _ => throw new ArgumentOutOfRangeException(nameof(scenarioId), scenarioId, null) +}; +``` + +Add world builders that mirror the live harness coordinates exactly: + +```csharp +private static World BuildRepeatedCardinalParkourChain() +{ + 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); + return world; +} + +private static World BuildRepeatedDiagonalParkourChain() +{ + World world = FlatWorldTestBuilder.CreateStoneFloor(min: 598, max: 608); + FlatWorldTestBuilder.ClearBox(world, 598, 79, 598, 608, 90, 608); + FlatWorldTestBuilder.SetSolid(world, 600, 79, 600); + FlatWorldTestBuilder.SetSolid(world, 602, 79, 602); + FlatWorldTestBuilder.SetSolid(world, 604, 79, 604); + FlatWorldTestBuilder.SetSolid(world, 606, 79, 606); + return world; +} +``` + +Also port the exact geometry from: +- `tools/test-pathing-jump-combos.sh:264` +- `tools/test-pathing-jump-combos.sh:275` +- `tools/test-pathing-jump-combos.sh:285` + +Update bootstrap coverage: + +```csharp +[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 PrintJumpComboContractFragments(string scenarioId) +{ + PathingExecutionScenario scenario = PathingExecutionScenarioCatalog.Get(scenarioId); + PathResult planResult = PathingScenarioRunner.PlanOnly(scenario); + + _output.WriteLine(PathingContractBootstrapWriter.WritePlannerFragment(scenarioId, planResult)); + _output.WriteLine(PathingContractBootstrapWriter.WriteTimingFragment(scenarioId, PathingScenarioRunner.RunAccepted(scenario))); +} +``` + +- [ ] **Step 2: Run the bootstrap tests and paste the emitted planner contracts and timing budgets** + +Run: `dotnet test MinecraftClient.Tests/MinecraftClient.Tests.csproj --filter FullyQualifiedName~Pathing.Execution.PathingContractBootstrapTests.PrintJumpComboContractFragments -v minimal` + +Expected: +- PASS +- output contains planner JSON and timing JSON for all five jump-combo scenarios + +- [ ] **Step 3: Write enforcing theories for the jump-combo planner and timing contracts** + +```csharp +[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) +{ + PathingExecutionScenario scenario = PathingExecutionScenarioCatalog.Get(scenarioId); + PathResult planResult = PathingScenarioRunner.PlanOnly(scenario); + PathingContractAssert.PlannerMatches( + PathingContractStore.GetPlanner(scenarioId), + 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_ExecutionStaysWithinBudget(string scenarioId) +{ + PathingExecutionScenario scenario = PathingExecutionScenarioCatalog.Get(scenarioId); + PathingTimingBudget budget = PathingContractStore.GetTiming(scenarioId); + PathingScenarioResult result = PathingScenarioRunner.RunAccepted(scenario); + + PathingContractAssert.TimingMatches(budget, result); +} +``` + +- [ ] **Step 4: Run the jump-combo contract suite** + +Run: `dotnet test MinecraftClient.Tests/MinecraftClient.Tests.csproj --filter "FullyQualifiedName~Pathing.Execution.JumpCombo_" -v minimal` + +Expected: +- currently this suite may FAIL before later implementation work +- failure output must now show exactly which segment exceeded budget or triggered replan + +- [ ] **Step 5: Commit** + +```bash +git add MinecraftClient.Tests/Pathing/Execution/Scenarios/PathingExecutionScenarioCatalog.cs \ + MinecraftClient.Tests/TestData/Pathing/pathing-planner-contracts.json \ + MinecraftClient.Tests/TestData/Pathing/pathing-timing-budgets.json \ + MinecraftClient.Tests/Pathing/Execution/PathingContractBootstrapTests.cs \ + MinecraftClient.Tests/Pathing/Execution/PathPlanningContractTests.cs \ + MinecraftClient.Tests/Pathing/Execution/PathTimingContractTests.cs +git commit -m "test: add jump combo planner and timing contracts" +``` + +### Task 5: Bootstrap And Lock Long-Route Contracts + +**Files:** +- Modify: `MinecraftClient.Tests/Pathing/Execution/Scenarios/PathingExecutionScenarioCatalog.cs` +- Modify: `MinecraftClient.Tests/TestData/Pathing/pathing-planner-contracts.json` +- Modify: `MinecraftClient.Tests/TestData/Pathing/pathing-timing-budgets.json` +- Modify: `MinecraftClient.Tests/Pathing/Execution/PathingContractBootstrapTests.cs` +- Modify: `MinecraftClient.Tests/Pathing/Execution/PathPlanningContractTests.cs` +- Modify: `MinecraftClient.Tests/Pathing/Execution/PathTimingContractTests.cs` + +- [ ] **Step 1: Port the long-route geometry and bootstrap the planner/timing JSON** + +Add scenario ids that mirror the shell suite exactly: + +```csharp +"same-move-straight-traverse-chain", +"same-move-diagonal-chain", +"same-move-ascend-staircase", +"same-move-descend-staircase", +"same-move-aligned-parkour-chain", +"mixed-traverse-turn-parkour-turn-traverse", +"mixed-diagonal-ascend-traverse-descend", +"mixed-traverse-ascend-parkour-descend", +"turn-density-alternating-traverse-diagonal-chain", +"speed-carry-repeated-traverse-ascend", +"speed-carry-repeated-traverse-descend", +"speed-carry-repeated-traverse-parkour" +``` + +Mirror the shell layouts from: +- `tools/test-pathing-long-routes.sh:64` +- `tools/test-pathing-long-routes.sh:88` +- `tools/test-pathing-long-routes.sh:134` +- `tools/test-pathing-long-routes.sh:171` + +Update bootstrap coverage: + +```csharp +[Theory] +[InlineData("same-move-straight-traverse-chain")] +[InlineData("same-move-diagonal-chain")] +[InlineData("same-move-ascend-staircase")] +[InlineData("same-move-descend-staircase")] +[InlineData("same-move-aligned-parkour-chain")] +[InlineData("mixed-traverse-turn-parkour-turn-traverse")] +[InlineData("mixed-diagonal-ascend-traverse-descend")] +[InlineData("mixed-traverse-ascend-parkour-descend")] +[InlineData("turn-density-alternating-traverse-diagonal-chain")] +[InlineData("speed-carry-repeated-traverse-ascend")] +[InlineData("speed-carry-repeated-traverse-descend")] +[InlineData("speed-carry-repeated-traverse-parkour")] +public void PrintLongRouteContractFragments(string scenarioId) +{ + PathingExecutionScenario scenario = PathingExecutionScenarioCatalog.Get(scenarioId); + PathResult planResult = PathingScenarioRunner.PlanOnly(scenario); + + _output.WriteLine(PathingContractBootstrapWriter.WritePlannerFragment(scenarioId, planResult)); + _output.WriteLine(PathingContractBootstrapWriter.WriteTimingFragment(scenarioId, PathingScenarioRunner.RunAccepted(scenario))); +} +``` + +- [ ] **Step 2: Run the bootstrap tests and paste the long-route JSON** + +Run: `dotnet test MinecraftClient.Tests/MinecraftClient.Tests.csproj --filter FullyQualifiedName~Pathing.Execution.PathingContractBootstrapTests.PrintLongRouteContractFragments -v minimal` + +Expected: +- PASS +- output contains planner and timing fragments for all long-route scenarios + +- [ ] **Step 3: Add enforcing theories for the long-route contracts** + +```csharp +[Theory] +[InlineData("same-move-straight-traverse-chain")] +[InlineData("same-move-diagonal-chain")] +[InlineData("same-move-ascend-staircase")] +[InlineData("same-move-descend-staircase")] +[InlineData("same-move-aligned-parkour-chain")] +[InlineData("mixed-traverse-turn-parkour-turn-traverse")] +[InlineData("mixed-diagonal-ascend-traverse-descend")] +[InlineData("mixed-traverse-ascend-parkour-descend")] +[InlineData("turn-density-alternating-traverse-diagonal-chain")] +[InlineData("speed-carry-repeated-traverse-ascend")] +[InlineData("speed-carry-repeated-traverse-descend")] +[InlineData("speed-carry-repeated-traverse-parkour")] +public void LongRoute_ExecutionStaysWithinBudget(string scenarioId) +{ + PathingExecutionScenario scenario = PathingExecutionScenarioCatalog.Get(scenarioId); + PathingTimingBudget budget = PathingContractStore.GetTiming(scenarioId); + PathingScenarioResult result = PathingScenarioRunner.RunAccepted(scenario); + + PathingContractAssert.TimingMatches(budget, result); +} +``` + +- [ ] **Step 4: Run the long-route timing suite** + +Run: `dotnet test MinecraftClient.Tests/MinecraftClient.Tests.csproj --filter FullyQualifiedName~Pathing.Execution.LongRoute_ -v minimal` + +Expected: +- initially some cases may FAIL +- every failure message must identify the route total and the exact slow segment index/move + +- [ ] **Step 5: Commit** + +```bash +git add MinecraftClient.Tests/Pathing/Execution/Scenarios/PathingExecutionScenarioCatalog.cs \ + MinecraftClient.Tests/TestData/Pathing/pathing-planner-contracts.json \ + MinecraftClient.Tests/TestData/Pathing/pathing-timing-budgets.json \ + MinecraftClient.Tests/Pathing/Execution/PathingContractBootstrapTests.cs \ + MinecraftClient.Tests/Pathing/Execution/PathTimingContractTests.cs +git commit -m "test: add long route timing contracts" +``` + +### Task 6: Expose The Same Metrics In The Live Harness + +**Files:** +- Create: `MinecraftClient/Pathing/Execution/Telemetry/PathExecutionLogObserver.cs` +- Modify: `MinecraftClient/McClient.cs` +- Modify: `MinecraftClient/Resources/Translations/Translations.resx` +- Modify: `MinecraftClient/Resources/Translations/Translations.Designer.cs` +- Create: `tools/pathing_contract_report.py` +- Create: `tools/tests/test_pathing_contract_report.py` +- Modify: `tools/test-pathing-jump-combos.sh` +- Modify: `tools/test-pathing-long-routes.sh` + +- [ ] **Step 1: Write the failing live-report parser test against a saved log slice** + +Create a tiny fixture log under `tools/testdata/pathing-contract-report.sample.log` and a Python test: + +```python +from pathing_contract_report import parse_metrics + +def test_parse_metrics_reads_route_and_segment_ticks(tmp_path): + log = tmp_path / "sample.log" + log.write_text( + "[PathMetric] routeStart segments=4\n" + "[PathMetric] segmentComplete index=0 move=Parkour ticks=17\n" + "[PathMetric] segmentComplete index=1 move=Parkour ticks=16\n" + "[PathMetric] routeComplete totalTicks=70 replans=0\n", + encoding="utf-8", + ) + + report = parse_metrics(log.read_text(encoding="utf-8")) + + assert report.total_ticks == 70 + assert [segment.ticks for segment in report.segments] == [17, 16] +``` + +- [ ] **Step 2: Run the parser test to verify it fails** + +Run: `python3 -m pytest tools/tests/test_pathing_contract_report.py -q` + +Expected: FAIL because `pathing_contract_report.py` does not exist yet. + +- [ ] **Step 3: Implement a machine-readable log observer and the shell report helper** + +`MinecraftClient/Pathing/Execution/Telemetry/PathExecutionLogObserver.cs` + +```csharp +using MinecraftClient.Mapping; +using MinecraftClient.Resources; + +namespace MinecraftClient.Pathing.Execution.Telemetry; + +public sealed class PathExecutionLogObserver : IPathExecutionObserver +{ + private readonly Action? _debug; + private int _routeTicks; + + public PathExecutionLogObserver(Action? debug) => _debug = debug; + + public void OnNavigationStarted(IReadOnlyList segments) + => _debug?.Invoke(string.Format(Translations.pathing_metric_route_start, segments.Count)); + + public void OnSegmentStarted(int segmentIndex, int totalSegments, PathSegment segment) + => _debug?.Invoke(string.Format(Translations.pathing_metric_segment_start, + segmentIndex, totalSegments, segment.MoveType, segment.ExitTransition)); + + public void OnSegmentCompleted(int segmentIndex, int totalSegments, PathSegment segment, int elapsedTicks, Location position) + => _debug?.Invoke(string.Format(Translations.pathing_metric_segment_complete, + segmentIndex, totalSegments, segment.MoveType, elapsedTicks, position.X, position.Y, position.Z)); + + public void OnSegmentFailed(int segmentIndex, int totalSegments, PathSegment segment, int elapsedTicks, Location position) + => _debug?.Invoke(string.Format(Translations.pathing_metric_segment_failed, + segmentIndex, totalSegments, segment.MoveType, elapsedTicks, position.X, position.Y, position.Z)); + + public void OnNavigationCompleted(int totalTicks) + => _debug?.Invoke(string.Format(Translations.pathing_metric_route_complete, totalTicks)); + + public void OnReplanStarted(int replanCount, Location position) + => _debug?.Invoke(string.Format(Translations.pathing_metric_replan_start, + replanCount, position.X, position.Y, position.Z)); + + public void OnReplanSucceeded(int replanCount, IReadOnlyList segments) + => _debug?.Invoke(string.Format(Translations.pathing_metric_replan_success, replanCount, segments.Count)); + + public void OnReplanFailed(int replanCount, Location position) + => _debug?.Invoke(string.Format(Translations.pathing_metric_replan_failed, + replanCount, position.X, position.Y, position.Z)); +} +``` + +Add the matching translation entries so the new user-visible log lines stay inside the localization system: + +```xml +[PathMetric] routeStart segments={0} +[PathMetric] segmentStart index={0} total={1} move={2} transition={3} +[PathMetric] segmentComplete index={0} total={1} move={2} ticks={3} x={4:F2} y={5:F2} z={6:F2} +[PathMetric] segmentFailed index={0} total={1} move={2} ticks={3} x={4:F2} y={5:F2} z={6:F2} +[PathMetric] routeComplete totalTicks={0} +[PathMetric] replanStart count={0} x={1:F2} y={2:F2} z={3:F2} +[PathMetric] replanSuccess count={0} segments={1} +[PathMetric] replanFailed count={0} x={1:F2} y={2:F2} z={3:F2} +``` + +`MinecraftClient/McClient.cs` + +```csharp +pathSegmentManager = new Pathing.Execution.PathSegmentManager( + debugLog: msg => Log.Debug(msg), + infoLog: msg => Log.Info(msg), + observer: new Pathing.Execution.Telemetry.PathExecutionLogObserver(msg => Log.Debug(msg))); +``` + +`tools/pathing_contract_report.py` + +```python +from __future__ import annotations + +import json +import math +import pathlib +import re +from dataclasses import dataclass + +SEGMENT_RE = re.compile(r"\[PathMetric\] segmentComplete index=(?P\d+) total=(?P\d+) move=(?P\w+) ticks=(?P\d+)") +ROUTE_RE = re.compile(r"\[PathMetric\] routeComplete totalTicks=(?P\d+)") +PLAN_RE = re.compile(r"\[Navigate\]\s+seg\[(?P\d+)\] = (?P\w+): \((?P-?\d+),(?P-?\d+),(?P-?\d+)\)") + +@dataclass +class SegmentMetric: + index: int + move: str + ticks: int + +def load_json(path: pathlib.Path): + return {entry["scenarioId"]: entry for entry in json.loads(path.read_text(encoding="utf-8"))} + +def parse_metrics(text: str): + segments = [SegmentMetric(int(m["index"]), m["move"], int(m["ticks"])) for m in SEGMENT_RE.finditer(text)] + route_match = ROUTE_RE.search(text) + total_ticks = int(route_match["ticks"]) if route_match else None + planned = [(int(m["index"]), m["move"], (int(m["x"]), int(m["y"]), int(m["z"]))) for m in PLAN_RE.finditer(text)] + return {"segments": segments, "totalTicks": total_ticks, "planned": planned} + +def main() -> int: + import argparse + + parser = argparse.ArgumentParser() + parser.add_argument("--scenario-id", required=True) + parser.add_argument("--log-file", required=True) + parser.add_argument("--from-line", type=int, required=True) + parser.add_argument("--planner-contracts", required=True) + parser.add_argument("--timing-budgets", required=True) + args = parser.parse_args() + + log_path = pathlib.Path(args.log_file) + text = "\n".join(log_path.read_text(encoding="utf-8", errors="ignore").splitlines()[args.from_line:]) + planner = load_json(pathlib.Path(args.planner_contracts))[args.scenario_id] + timing = load_json(pathlib.Path(args.timing_budgets))[args.scenario_id] + actual = parse_metrics(text) + + if actual["totalTicks"] is None: + raise SystemExit("Missing [PathMetric] routeComplete line") + if actual["totalTicks"] > timing["maxTotalTicks"]: + raise SystemExit(f"Route exceeded budget: actual={actual['totalTicks']} max={timing['maxTotalTicks']}") + + for expected, actual_segment in zip(timing["segments"], actual["segments"], strict=True): + if actual_segment.ticks > expected["maxTicks"]: + raise SystemExit( + f"Segment {actual_segment.index} slow: move={actual_segment.move} actual={actual_segment.ticks} max={expected['maxTicks']}" + ) + + print(f"Route {args.scenario_id}: actual={actual['totalTicks']} expected={timing['expectedTotalTicks']} max={timing['maxTotalTicks']}") + for expected, actual_segment in zip(timing["segments"], actual["segments"], strict=True): + delta = actual_segment.ticks - expected["expectedTicks"] + print( + f" seg[{actual_segment.index}] move={actual_segment.move} actual={actual_segment.ticks} " + f"expected={expected['expectedTicks']} max={expected['maxTicks']} delta={delta:+d}" + ) + return 0 + +if __name__ == "__main__": + raise SystemExit(main()) +``` + +- [ ] **Step 4: Integrate the report helper into both live scripts** + +Add to `tools/test-pathing-jump-combos.sh` and `tools/test-pathing-long-routes.sh` inside `run_accepted_route()` after `assert_no_replans_since`: + +```bash +python3 "$REPO_ROOT/tools/pathing_contract_report.py" \ + --scenario-id "$scenario_id" \ + --log-file "$LOG" \ + --from-line "$start_line" \ + --planner-contracts "$REPO_ROOT/MinecraftClient.Tests/TestData/Pathing/pathing-planner-contracts.json" \ + --timing-budgets "$REPO_ROOT/MinecraftClient.Tests/TestData/Pathing/pathing-timing-budgets.json" +``` + +Change the route helper signature so each case passes both a display label and the stable contract id: + +```bash +run_accepted_route() { + local scenario_id="$1" + local label="$2" + local start_x="$3" + local start_y="$4" + local start_z="$5" + local goal_x="$6" + local goal_y="$7" + local goal_z="$8" + local timeout="${9:-45}" + # existing body... +} +``` + +- [ ] **Step 5: Run the parser tests and both live suites** + +Run: + +```bash +python3 -m pytest tools/tests/test_pathing_contract_report.py -q +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: +- parser test PASS +- live harness now prints route totals and per-segment slow-step tables +- accepted routes fail on planner mismatch, replan, or budget overrun + +- [ ] **Step 6: Commit** + +```bash +git add MinecraftClient/Pathing/Execution/Telemetry/PathExecutionLogObserver.cs \ + MinecraftClient/McClient.cs \ + MinecraftClient/Resources/Translations/Translations.resx \ + MinecraftClient/Resources/Translations/Translations.Designer.cs \ + tools/pathing_contract_report.py \ + tools/test-pathing-jump-combos.sh \ + tools/test-pathing-long-routes.sh +git commit -m "test: surface path timing contracts in live harness" +``` + +### Task 7: Document The Workflow And Final Verification + +**Files:** +- Modify: `docs/guide/pathfinding-research.md` + +- [ ] **Step 1: Add a short contract section to the docs** + +```md +### Deterministic pathing contract + +Accepted sterile routes must satisfy all of the following: + +- planner result is `Success` +- planner result is not `Partial` +- navigation completes with `0 replan` +- every executed segment stays within its checked-in tick budget +- total route ticks stay within the checked-in route budget + +Rejected routes must fail during planning and must never start navigation. + +The authoritative contract files are: + +- `MinecraftClient.Tests/TestData/Pathing/pathing-planner-contracts.json` +- `MinecraftClient.Tests/TestData/Pathing/pathing-timing-budgets.json` +``` + +- [ ] **Step 2: Run the full focused verification set** + +Run: + +```bash +dotnet test MinecraftClient.Tests/MinecraftClient.Tests.csproj --filter "FullyQualifiedName~Pathing.Execution" -v minimal +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: +- xUnit pathing suite passes for the currently fixed scenarios +- live suites print per-route and per-segment timing metrics +- any remaining pathing regressions now fail with explicit slow-step or replan diagnostics + +- [ ] **Step 3: Commit** + +```bash +git add docs/guide/pathfinding-research.md +git commit -m "docs: document pathing contract metrics workflow" +``` + +## Self-Review + +- Spec coverage: + - planner failure and partial path are hard vetoes: covered in Tasks 3, 4, 5, and 6 + - zero replan for sterile accepted routes: covered in Tasks 2, 3, 4, 5, and 6 + - total route timing constraint: covered in Tasks 3, 4, 5, and 6 + - per-step timing visibility: covered in `PathingContractAssert` and `tools/pathing_contract_report.py` + - ability to identify which action is slow: covered by segment-level contract assertions and live-shell report output +- Placeholder scan: + - no `TODO`, `TBD`, or “similar to task N” references remain + - the only “measure then paste” flow is explicit bootstrap output, not an unspecified placeholder +- Type consistency: + - shared names are fixed across tasks: `PathingExecutionScenario`, `PathingScenarioResult`, `PathingPlannerContract`, `PathingTimingBudget`, `PathingContractStore`, `PathingContractAssert` + +Plan complete and saved to `docs/superpowers/plans/2026-04-13-pathing-contract-metrics-harness.md`. Two execution options: + +**1. Subagent-Driven (recommended)** - I dispatch a fresh subagent per task, review between tasks, fast iteration + +**2. Inline Execution** - Execute tasks in this session using executing-plans, batch execution with checkpoints + +**Which approach?** diff --git a/docs/superpowers/plans/2026-04-13-zero-replan-live-pathing.md b/docs/superpowers/plans/2026-04-13-zero-replan-live-pathing.md new file mode 100644 index 00000000..26ef3436 --- /dev/null +++ b/docs/superpowers/plans/2026-04-13-zero-replan-live-pathing.md @@ -0,0 +1,736 @@ +# Zero Replan Live Pathing 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:** Eliminate executor-driven replans in deterministic live harness scenarios on `1.21.11-Vanilla`, and add longer-route stability coverage that proves accepted routes finish with `0 replan`. + +**Architecture:** Treat any `replan` in accepted deterministic harness scenarios as a bug, not an acceptable recovery path. First lock down harness isolation and `0 replan` assertions so failures are explicit. Then remove per-move-family completion/failure gaps in `WalkTemplate`, `AscendTemplate`, and `DescendTemplate`, and finally add longer multi-segment live routes that combine operations without planner partials or executor retries. + +**Tech Stack:** C# 14 / .NET 10, xUnit, MCC local `1.21.11-Vanilla` harness via `tools/mcc-env.sh`, tmux/RCON-driven live tests, MCC pathing core and execution templates. + +--- + +## Current Facts + +Latest live evidence from `/tmp/mcc-debug/*/mcc-debug.log`: + +- `Flat final stop`: `2` replans + - `Traverse` failed at `(101.56, 80.00, 100.74)` + - `Traverse/FinalStop` failed at `(103.36, 80.00, 100.50)` +- `Parkour into turn`: `1` replan + - final `Traverse/FinalStop` failed around `(122.50, 80.00, 111.36)` +- `Corner ascend around wall`: `1` replan + - single `Ascend/FinalStop` failed around `(191.38, 81.00, 171.38)` +- `Wall-adjacent descend`: `1` replan + - single `Descend/FinalStop` failed at `(201.50, 80.00, 200.50)` +- `Ascend chain smoke`: `3` replans + - all failures occur on chained `Ascend` segments before the partial fallback stops at `(177.46, 83.00, 162.50)` +- `Rejected 3x1 no-run-up gap`: currently not a clean reject + - planner returns a `Partial` path, then execution replans before failing +- `Rejected 2x1 side-wall jump`: already correct, `0 replan`, direct `A* Failed` + +## User Constraints + +These constraints override earlier assumptions in this plan: + +- MCC currently has no entity collision implementation that would make other players or mobs perturb these tests. +- Other players being online is not itself a movement interference source for this work. +- Residual yaw/pitch between independent scenarios is not a fix target for this plan; templates already steer every tick. +- Residual speed should be recorded for diagnosis, but not “normalized away” inside a route. +- For long accepted routes, residual speed between internal actions is expected and must not be treated as test interference. The route should still finish with `0 replan`. + +## Interference Inventory + +The harness already disables or controls the environmental variables that matter for this work: + +- `difficulty peaceful` +- `gamerule doMobSpawning false` +- fixed test geometry with `fill`/`setblock` +- explicit `tp` before each scenario + +Remaining sources of ambiguity that still matter before claiming `0 replan`: + +1. Shared live server state + - The workflow uses shared `mc-*` tmux sessions. + - This matters for repeatability and logging, but not because of entity collision. + +2. Reused MCC session state across scenarios + - The harness runs multiple scenarios in the same MCC session. + - Residual speed can carry across scenario boundaries if the next scenario starts too early. + - Yaw/pitch carryover is not considered a blocker for this plan. + +3. Planner timeout / partial-path behavior + - Some “reject” or long-route scenarios currently hit `A* result: Partial`. + - Those cases cannot be used as `0 replan` executor proofs until the route size is kept below timeout, or the test is explicitly categorized as a planner-partial case. + +4. Current live harness acceptance is weaker than target behavior + - The harness was updated to accept “already in goal block” completion. + - That is useful for keeping live validation running, but it currently masks the stronger requirement that accepted deterministic routes should need `0 replan`. + +5. Residual-speed observability is currently weak + - The harness captures final location, but it does not systematically record pre-route speed and per-route terminal speed. + - We should measure speed, not try to zero it between internal actions. + +## Zero-Replan Contract + +For this plan, an accepted live pathing scenario passes only if all of the following are true: + +- `A* result: Success` +- no `A* result: Partial` +- no `Replan #` +- no `Segment .* FAILED` +- no `Replan failed -- no path found` +- final MCC location is inside the intended goal support block +- `PathMgr` reaches `Navigation complete!` + +For rejection scenarios, pass only if: + +- `A* result: Failed` or `No path found` +- no `Navigation started` +- no `Replan #` + +## File Structure + +### Production code + +- `MinecraftClient/Pathing/Execution/Templates/WalkTemplate.cs` + - Traverse and diagonal segment runtime; primary target for straight-line and turn-entry `0 replan`. +- `MinecraftClient/Pathing/Execution/Templates/AscendTemplate.cs` + - Single-step ascend execution; primary target for ascend landing and chained ascend stability. +- `MinecraftClient/Pathing/Execution/Templates/DescendTemplate.cs` + - Landing/final-stop descend execution. +- `MinecraftClient/Pathing/Execution/Templates/GroundedSegmentController.cs` + - Shared grounded completion and braking gate; likely the common source of “already good enough, but segment still fails one tick later”. +- `MinecraftClient/Pathing/Execution/Templates/TemplateHelper.cs` + - Shared geometry and settle helpers. +- `MinecraftClient/Pathing/Execution/PathSegmentManager.cs` + - Replan handling and live navigation orchestration. +- `MinecraftClient/Pathing/Core/AStarPathFinder.cs` + - Planner timeout and partial-path behavior. +- `MinecraftClient/Pathing/Moves/Impl/MoveParkour.cs` + - Run-up / jump-feasibility checks for the `3x1` rejection case. +- `MinecraftClient/Pathing/Moves/ParkourFeasibility.cs` + - Supporting parkour admissibility logic. +- `ThirdpartyReference/baritone/src/main/java/baritone/pathing/path/PathExecutor.java` + - Reference executor behavior for timeout, splice/repath, and movement handoff semantics. +- `ThirdpartyReference/baritone/src/main/java/baritone/pathing/movement/movements/MovementTraverse.java` + - Reference traverse completion semantics. +- `ThirdpartyReference/baritone/src/main/java/baritone/pathing/movement/movements/MovementAscend.java` + - Reference ascend landing and post-jump settle logic. +- `ThirdpartyReference/baritone/src/main/java/baritone/pathing/movement/movements/MovementDescend.java` + - Reference descend safe-mode and landing behavior. +- `ThirdpartyReference/baritone/src/main/java/baritone/pathing/movement/movements/MovementParkour.java` + - Reference parkour admissibility and handoff behavior. + +### Tests and harness + +- `MinecraftClient.Tests/Pathing/Execution/GroundedTemplateConvergenceTests.cs` + - Deterministic convergence tests for walk, descend, and future ascend/diagonal cases. +- `MinecraftClient.Tests/Pathing/Execution/PathSegmentManagerTests.cs` + - Manager-level replan and already-in-goal behavior. +- `MinecraftClient.Tests/Pathing/Execution/PathExecutorCompletionTests.cs` + - Multi-segment executor behavior. +- `MinecraftClient.Tests/Pathing/Execution/SprintJumpTemplateScenarioTests.cs` + - Parkour and landing handoff cases. +- `tools/test-transition-braking.sh` + - Short accepted-route live harness. +- `tools/test-pathing-template-regressions.sh` + - Current mixed live regression suite. +- `tools/test-pathing-long-routes.sh` + - New long-route `0 replan` live suite. +- `docs/guide/pathfinding-research.md` + - Pathing behavior contract and live test expectations. + +--- + +### Task 1: Freeze the Zero-Replan Harness Contract + +**Files:** +- Modify: `tools/test-transition-braking.sh` +- Modify: `tools/test-pathing-template-regressions.sh` +- Create: `tools/test-pathing-long-routes.sh` +- Modify: `docs/guide/pathfinding-research.md` + +- [ ] **Step 1: Add live log helpers that fail on any accepted-route replan** + +Add shell helpers to both existing harness scripts: + +```bash +count_replans_since() { + local from_line="$1" + log_since "$from_line" | grep -Ec "\\[PathMgr\\] Replan #|\\[PathExec\\] Segment .* FAILED" || true +} + +assert_no_replans_since() { + local from_line="$1" + local count + count="$(count_replans_since "$from_line")" + if [[ "$count" != "0" ]]; then + echo "Expected 0 replans, saw $count" >&2 + log_since "$from_line" >&2 + return 1 + fi +} + +assert_no_partial_since() { + local from_line="$1" + if log_since "$from_line" | grep -Fq "[Navigate] A* result: Partial"; then + echo "Expected full success path, saw partial path" >&2 + log_since "$from_line" >&2 + return 1 + fi +} +``` + +- [ ] **Step 2: Normalize only the state that should differ between independent scenarios** + +For accepted live scenarios, add: + +```bash +mc-rcon "effect clear $USERNAME" >/dev/null 2>&1 || true +mc-rcon "tp $USERNAME 100.5 80 100.5" >/dev/null +sleep 2 +send_mcc "debug state" +``` + +Do not add special yaw/pitch normalization. Record the pre-route state instead. +Do reset position and allow enough time that residual speed from the previous independent scenario is observable in logs. + +- [ ] **Step 3: Separate accepted-route assertions from rejection-route assertions** + +Update accepted-route scenarios to require: + +```bash +wait_for_navigation "$start_line" 30 +assert_no_partial_since "$start_line" +assert_no_replans_since "$start_line" +``` + +Update rejection scenarios to require: + +```bash +wait_for_failure_signal "$start_line" 20 +if log_since "$start_line" | grep -Eq "\\[PathMgr\\] Replan #|\\[PathExec\\] Segment .* FAILED"; then + echo "Expected direct rejection, saw execution replan" >&2 + return 1 +fi +``` + +- [ ] **Step 4: Create a new long-route live harness** + +Create `tools/test-pathing-long-routes.sh` with three accepted-route buckets: + +```bash +run_same_move_routes +run_mixed_move_routes +run_turn_density_routes +``` + +Each accepted route must assert: + +```bash +wait_for_navigation "$start_line" 45 +assert_no_partial_since "$start_line" +assert_no_replans_since "$start_line" +read -r x y z <<< "$(capture_debug_location)" +assert_inside_goal_block "$x" "$y" "$z" "$goal_x" "$goal_y" "$goal_z" +``` + +Each accepted route must also log: + +```bash +capture_debug_state_before_route +capture_debug_state_after_route +``` + +to record start/end speed and location for diagnosis. Do not reset any state between internal actions of a single route. + +- [ ] **Step 5: Document the zero-replan live contract** + +Add a short section to `docs/guide/pathfinding-research.md`: + +```md +### Deterministic live route contract + +For the short-route and long-route 1.21.11 live harnesses, accepted routes must complete with: + +- `A* result: Success` +- `0 replan` +- `0` template segment failures +- final position inside the goal support block + +Rejection scenarios must fail before execution starts. +``` + +- [ ] **Step 6: Run harness baselines and record current failures** + +Run: + +```bash +source tools/mcc-env.sh +bash tools/test-transition-braking.sh 1.21.11-Vanilla +bash tools/test-pathing-template-regressions.sh 1.21.11-Vanilla +``` + +Expected right now: FAIL because the accepted scenarios still produce replans. + +--- + +### Task 2: Baritone Reference Pass Before Code Changes + +**Files:** +- Read: `ThirdpartyReference/baritone/src/main/java/baritone/pathing/path/PathExecutor.java` +- Read: `ThirdpartyReference/baritone/src/main/java/baritone/pathing/movement/movements/MovementTraverse.java` +- Read: `ThirdpartyReference/baritone/src/main/java/baritone/pathing/movement/movements/MovementAscend.java` +- Read: `ThirdpartyReference/baritone/src/main/java/baritone/pathing/movement/movements/MovementDescend.java` +- Read: `ThirdpartyReference/baritone/src/main/java/baritone/pathing/movement/movements/MovementParkour.java` +- Modify: `docs/guide/pathfinding-research.md` + +- [ ] **Step 1: Extract only the behavior that applies to MCC’s zero-replan goal** + +Summarize, in MCC terms: + +- when Baritone considers a movement complete +- when it keeps controlling after landing instead of immediately failing +- how it handles path executor timeout and repath +- which parkour and descend transitions depend on next-movement awareness + +- [ ] **Step 2: Write down the allowed and disallowed Baritone borrow list** + +Add to `docs/guide/pathfinding-research.md`: + +```md +### Baritone reference notes for zero-replan work + +Borrow: +- landing-aware completion +- next-movement-aware descend/ascend handoff +- conservative parkour admissibility + +Do not borrow: +- GoalBlock occupancy semantics that allow success with sloppy live execution +- executor repath tolerance as a substitute for deterministic harness stability +``` + +- [ ] **Step 3: Do not change MCC code in this task** + +This task is design grounding only. The output is a short written comparison that later tasks can cite. + +--- + +### Task 3: Reproduce Each Replan Family With Deterministic Tests + +**Files:** +- Modify: `MinecraftClient.Tests/Pathing/Execution/GroundedTemplateConvergenceTests.cs` +- Modify: `MinecraftClient.Tests/Pathing/Execution/PathExecutorCompletionTests.cs` +- Modify: `MinecraftClient.Tests/Pathing/Execution/PathSegmentManagerTests.cs` +- Modify: `MinecraftClient.Tests/Pathing/Execution/SprintJumpTemplateScenarioTests.cs` + +- [ ] **Step 1: Add a walk final-stop red test for the live `(103.36, 80.00, 100.50)` case** + +Add a test that seeds physics at the live near-goal position and expects completion without failure: + +```csharp +[Fact] +public void WalkTemplate_FinalStop_Completes_FromLiveNearGoalState_WithoutFailure() +{ + World world = FlatWorldTestBuilder.CreateStoneFloor(min: 95, max: 115); + var segment = new PathSegment + { + Start = new Location(102.5, 80, 100.5), + End = new Location(103.5, 80, 100.5), + MoveType = MoveType.Traverse, + ExitTransition = PathTransitionType.FinalStop + }; + + var template = new WalkTemplate(segment, null); + var physics = new PlayerPhysics + { + Position = new Vec3d(103.36, 80.0, 100.50), + DeltaMovement = new Vec3d(0.0346, 0.0, 0.0), + OnGround = true, + MovementSpeed = 0.1f, + Yaw = 270f + }; + + TemplateState state = TemplateSimulationRunner.Run(template, physics, world, 40, out _); + Assert.Equal(TemplateState.Complete, state); +} +``` + +- [ ] **Step 2: Add an ascend final-stop red test for the live `(191.38, 81.00, 171.38)` case** + +```csharp +[Fact] +public void AscendTemplate_FinalStop_Completes_FromLiveLandingState_WithoutFailure() +{ + // build the same corner-ascend world as the live harness + // seed physics at the live failure position + // expect TemplateState.Complete within a short horizon +} +``` + +- [ ] **Step 3: Add a descend final-stop red test for the live `(201.50, 80.00, 200.50)` case** + +```csharp +[Fact] +public void DescendTemplate_FinalStop_Completes_FromLiveLandingState_WithoutFailure() +{ + // build the wall-adjacent descend world + // seed physics at the live failure position + // expect TemplateState.Complete +} +``` + +- [ ] **Step 4: Add a manager-level red test that accepted routes finish with zero replans** + +Extend `PathSegmentManagerTests.cs` with a short accepted path: + +```csharp +[Fact] +public void Tick_ShortAcceptedPath_CompletesWithoutIncrementingReplanCount() +{ + // start manager with a 3-node flat path + // run ticks until completion + // assert manager.ReplanCount == 0 +} +``` + +- [ ] **Step 5: Run the focused red test set** + +Run: + +```bash +dotnet test MinecraftClient.Tests/MinecraftClient.Tests.csproj --filter "FullyQualifiedName~GroundedTemplateConvergenceTests|FullyQualifiedName~PathExecutorCompletionTests|FullyQualifiedName~PathSegmentManagerTests|FullyQualifiedName~SprintJumpTemplateScenarioTests" -v minimal +``` + +Expected initially: FAIL on the new live-parity cases. + +--- + +### Task 4: Remove Zero-Replan Gaps In Walk And FinalStop Execution + +**Files:** +- Modify: `MinecraftClient/Pathing/Execution/Templates/WalkTemplate.cs` +- Modify: `MinecraftClient/Pathing/Execution/Templates/GroundedSegmentController.cs` +- Modify: `MinecraftClient/Pathing/Execution/Templates/TemplateHelper.cs` +- Modify: `MinecraftClient/Pathing/Execution/TransitionBrakingPlanner.cs` + +- [ ] **Step 1: Make `WalkTemplate` prefer completion over fail once goal support is already valid** + +Before the template returns `TemplateState.Failed`, ensure it checks an explicit “good enough for this segment” predicate first: + +```csharp +if (GroundedSegmentController.ShouldComplete(_segment, pos, physics)) + return TemplateState.Complete; + +if (_stuckTicks > 40 || _tickCount > maxTicks) + return TemplateState.Failed; +``` + +The final implementation must preserve that order even when the bot is slightly off center but already inside valid goal support. + +- [ ] **Step 2: Tighten final-stop control so the last traverse segment stops without lateral drift** + +Update planner/controller logic for `PathTransitionType.FinalStop` to penalize cross-axis drift near the end plane: + +```csharp +double lateralError = TemplateHelper.CrossTrackDistance(pos, segment); +if (segment.ExitTransition == PathTransitionType.FinalStop && lateralError > 0.10) +{ + // reduce forward carry and bias facing back to segment heading +} +``` + +- [ ] **Step 3: Ensure `ContinueStraight -> FinalStop` handoff drops sprint early enough** + +Use the final live traces as the acceptance reference: + +- flat final stop must not replan at segment `0` +- final stop segment must complete before the `(103.36, 80.00, 100.50)` failure state +- do not rely on yaw/pitch reset to make this pass +- residual speed within the route is allowed as long as the route still completes with `0 replan` + +- [ ] **Step 4: Run focused deterministic tests** + +Run: + +```bash +dotnet test MinecraftClient.Tests/MinecraftClient.Tests.csproj --filter "FullyQualifiedName~WalkTemplate_FinalStop|FullyQualifiedName~PathExecutorCompletionTests|FullyQualifiedName~PathSegmentManagerTests" -v minimal +``` + +Expected: PASS with the new walk/final-stop tests green. + +--- + +### Task 5: Remove Zero-Replan Gaps In Ascend Execution + +**Files:** +- Modify: `MinecraftClient/Pathing/Execution/Templates/AscendTemplate.cs` +- Modify: `MinecraftClient/Pathing/Execution/Templates/GroundedSegmentController.cs` +- Modify: `MinecraftClient/Pathing/Execution/Templates/TemplateHelper.cs` +- Modify: `MinecraftClient.Tests/Pathing/Execution/GroundedTemplateConvergenceTests.cs` + +- [ ] **Step 1: Add a dedicated post-landing settle phase in `AscendTemplate`** + +Current code always sets: + +```csharp +input.Forward = true; +input.Sprint = true; +``` + +Replace that with a landing-aware phase: + +```csharp +bool landedOnTargetLevel = physics.OnGround && Math.Abs(dy) < 0.2; +if (landedOnTargetLevel) +{ + GroundedSegmentController.Apply(_segment, _nextSegment, pos, physics, input, world); + if (GroundedSegmentController.ShouldComplete(_segment, pos, physics)) + return TemplateState.Complete; +} +else +{ + input.Forward = true; + input.Sprint = true; + if (physics.OnGround && dy > 0.1) + input.Jump = true; +} +``` + +The acceptance bar is not “zero residual speed after each ascend”. The acceptance bar is “the accepted route continues without a replan”. + +- [ ] **Step 2: Add live-parity ascend tests** + +Add deterministic tests for: + +- corner ascend final stop +- chained ascend middle segment +- chained ascend final segment + +- [ ] **Step 3: Run the focused ascend suite** + +Run: + +```bash +dotnet test MinecraftClient.Tests/MinecraftClient.Tests.csproj --filter "FullyQualifiedName~Ascend|FullyQualifiedName~Corner ascend|FullyQualifiedName~PathSegmentManagerTests" -v minimal +``` + +Expected: PASS with no new regressions in existing ascend tests. + +--- + +### Task 6: Remove Zero-Replan Gaps In Descend Execution + +**Files:** +- Modify: `MinecraftClient/Pathing/Execution/Templates/DescendTemplate.cs` +- Modify: `MinecraftClient/Pathing/Execution/Templates/TemplateHelper.cs` +- Modify: `MinecraftClient.Tests/Pathing/Execution/GroundedTemplateConvergenceTests.cs` + +- [ ] **Step 1: Make landing-state final stop complete immediately when support is already valid** + +Preserve the landing-phase completion ordering: + +```csharp +if (physics.OnGround && Math.Abs(dy) < 1.0) +{ + GroundedSegmentController.Apply(_segment, _nextSegment, pos, physics, input, world); + if (GroundedSegmentController.ShouldComplete(_segment, pos, physics)) + return TemplateState.Complete; +} +``` + +The acceptance case is the exact live failure state at `(201.50, 80.00, 200.50)`. + +- [ ] **Step 2: Add descend live-parity tests** + +Add tests that seed the exact live landing state and assert `TemplateState.Complete` rather than `Failed`. + +- [ ] **Step 3: Run the focused descend suite** + +Run: + +```bash +dotnet test MinecraftClient.Tests/MinecraftClient.Tests.csproj --filter "FullyQualifiedName~DescendTemplate|FullyQualifiedName~GroundedTemplateConvergenceTests" -v minimal +``` + +Expected: PASS with the new descend test green. + +--- + +### Task 7: Make Rejection Scenarios Reject Before Execution Starts + +**Files:** +- Modify: `MinecraftClient/Pathing/Core/AStarPathFinder.cs` +- Modify: `MinecraftClient/Pathing/Moves/Impl/MoveParkour.cs` +- Modify: `MinecraftClient/Pathing/Moves/ParkourFeasibility.cs` +- Modify: `tools/test-pathing-template-regressions.sh` + +- [ ] **Step 1: Add a deterministic rejection test for the `3x1 no-run-up` case** + +Create or extend a pathfinder-level test that asserts: + +```csharp +Assert.Equal(PathStatus.Failed, result.Status); +Assert.Empty(PathSegmentBuilder.FromPath(result.Path)); +``` + +for the exact live `141.5 -> 144.5/81/138.5` layout. + +- [ ] **Step 2: Tighten parkour admissibility before planner partial fallback is considered acceptable** + +Review the current path: + +- `Traverse -> Ascend` to a partial fallback at `(143,81,138)` + +The fix should make this route non-admissible in the first place if the intended gap cannot be completed with valid run-up. +- Use `ThirdpartyReference/baritone/.../MovementParkour.java` and the prior admissibility spec as references, but keep MCC’s execution contract stricter: direct reject in this harness. + +- [ ] **Step 3: Re-run rejection-only live validation** + +Run: + +```bash +source tools/mcc-env.sh +bash tools/test-pathing-template-regressions.sh 1.21.11-Vanilla +``` + +Expected for rejection scenarios: + +- `2x1 side-wall jump`: direct reject, `0 replan` +- `3x1 no-run-up gap`: direct reject, `0 replan` + +--- + +### Task 8: Add Long-Route Zero-Replan Stability Coverage + +**Files:** +- Create: `tools/test-pathing-long-routes.sh` +- Modify: `docs/guide/pathfinding-research.md` + +- [ ] **Step 1: Add same-operation long routes** + +Use route lengths that remain comfortably below planner timeout: + +- straight traverse chain: `8-12` blocks +- diagonal zig-zag chain: `6-8` segments +- ascend staircase: `4-6` ascends +- descend staircase: `4-6` descends +- aligned parkour chain: `3-4` jumps + +Each must require: + +- `A* result: Success` +- `0 replan` +- final location inside goal block + +- [ ] **Step 2: Add mixed-operation long routes** + +Recommended mixed routes: + +- `Traverse -> Turn -> Parkour -> Turn -> Traverse -> FinalStop` +- `Diagonal -> Ascend -> Traverse -> Descend -> FinalStop` +- `Traverse -> Ascend -> Traverse -> Parkour -> Descend -> FinalStop` + +Keep all mixed routes inside a small pre-cleared test region so chunk loading is not the variable under test. +Do not reset speed or orientation between internal actions of a route. A route only passes if the naturally carried speed across those actions still yields `0 replan`. + +- [ ] **Step 3: Add turn-density routes** + +Add one route with frequent heading changes and no jumps: + +- `8-10` short traverse/diagonal segments +- every segment should change heading +- must still finish with `0 replan` + +- [ ] **Step 4: Add speed-carry long routes** + +Add one route each for: + +- repeated `Traverse -> Ascend` +- repeated `Traverse -> Descend` +- repeated `Traverse -> Parkour` + +These routes exist specifically to prove that residual speed between actions does not force replans in deterministic conditions. + +- [ ] **Step 5: Run the long-route harness** + +Run: + +```bash +source tools/mcc-env.sh +bash tools/test-pathing-long-routes.sh 1.21.11-Vanilla +``` + +Expected: PASS with every accepted route showing `0 replan`. + +--- + +### Task 9: Full Verification + +**Files:** +- No new files + +- [ ] **Step 1: Run deterministic test coverage** + +```bash +dotnet test MinecraftClient.Tests/MinecraftClient.Tests.csproj --filter "FullyQualifiedName~Pathing.Execution" -v minimal +``` + +Expected: PASS, `0 failed`. + +- [ ] **Step 2: Run release build** + +```bash +source tools/mcc-env.sh +mcc-build +``` + +Expected: `Build succeeded. 0 Warning(s), 0 Error(s)`. + +- [ ] **Step 3: Run short live suites** + +```bash +source tools/mcc-env.sh +bash tools/test-transition-braking.sh 1.21.11-Vanilla +bash tools/test-pathing-template-regressions.sh 1.21.11-Vanilla +``` + +Expected: + +- accepted scenarios: `0 replan` +- rejection scenarios: direct reject, `0 replan` + +- [ ] **Step 4: Run long-route live suite** + +```bash +source tools/mcc-env.sh +bash tools/test-pathing-long-routes.sh 1.21.11-Vanilla +``` + +Expected: PASS, no accepted route triggers `replan`. + +## Self-Review + +Spec coverage: + +- `0 replan` short deterministic live scenarios: covered by Tasks 1-6 +- interference inventory first: covered near top of this plan, revised to remove non-applicable entity and yaw/pitch concerns +- Baritone comparison before code changes: covered by Task 2 +- rejection cleanup: covered by Task 7 +- long-path stability coverage, including speed-carry routes: covered by Task 8 + +Placeholder scan: + +- No `TODO` or `TBD` placeholders remain +- Concrete files, scenarios, and commands are included + +Type consistency: + +- File paths and move/template names match current codebase names: + - `WalkTemplate` + - `AscendTemplate` + - `DescendTemplate` + - `GroundedSegmentController` + - `AStarPathFinder` diff --git a/tools/test-pathing-jump-combos.sh b/tools/test-pathing-jump-combos.sh new file mode 100644 index 00000000..ca03d056 --- /dev/null +++ b/tools/test-pathing-jump-combos.sh @@ -0,0 +1,398 @@ +#!/usr/bin/env bash +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" +source "$REPO_ROOT/tools/mcc-env.sh" + +VERSION="${1:-1.21.11-Vanilla}" +SESSION="mcc-pathing-jump-combos" +USERNAME="CursorBot" + +SESSION_ROOT="$(_mcc_session_root "$SESSION")" +LOG="$(_mcc_session_log_file "$SESSION")" + +PASSED_CASES=() +FAILED_CASES=() + +cleanup() { + mcc-kill --session "$SESSION" >/dev/null 2>&1 || true +} + +trap cleanup EXIT + +send_mcc() { + mcc-cmd --session "$SESSION" "$1" +} + +log_line_count() { + if [[ -f "$LOG" ]]; then + wc -l < "$LOG" + else + echo 0 + fi +} + +log_since() { + local from_line="$1" + if [[ ! -f "$LOG" ]]; then + return + fi + + tail -n +"$((from_line + 1))" "$LOG" +} + +log_since_clean() { + log_since "$1" | sed 's/\x1b\[[0-9;]*m//g' +} + +wait_for_log() { + local pattern="$1" + local from_line="${2:-0}" + local timeout="${3:-30}" + + for _ in $(seq 1 "$timeout"); do + if log_since_clean "$from_line" | grep -Fq "$pattern"; then + return 0 + fi + sleep 1 + done + + return 1 +} + +wait_for_navigation() { + local from_line="$1" + local timeout="${2:-45}" + local saw_start=0 + + for _ in $(seq 1 "$timeout"); do + local recent + recent="$(log_since_clean "$from_line")" + + if grep -Fq "[PathMgr] Navigation started" <<<"$recent"; then + saw_start=1 + fi + + if (( saw_start )) && grep -Fq "[PathMgr] Navigation complete!" <<<"$recent"; then + return 0 + fi + + if grep -Eq "\[PathMgr\] (Replan failed|Giving up)" <<<"$recent"; then + echo "$recent" >&2 + return 1 + fi + + if grep -Eq "No path found|\[Navigate\] A\* result: Failed" <<<"$recent"; then + echo "$recent" >&2 + return 1 + fi + + sleep 1 + done + + echo "Timed out waiting for navigation completion" >&2 + log_since_clean "$from_line" >&2 + return 1 +} + +count_replans_since() { + local from_line="$1" + log_since_clean "$from_line" | grep -Ec '\[PathMgr\] Replan #|\[PathExec\] Segment .* FAILED' || true +} + +assert_no_replans_since() { + local from_line="$1" + local count + count="$(count_replans_since "$from_line")" + if [[ "$count" != "0" ]]; then + echo "Expected 0 replans, saw $count" >&2 + log_since_clean "$from_line" >&2 + return 1 + fi +} + +assert_no_partial_since() { + local from_line="$1" + if log_since_clean "$from_line" | grep -Fq "[Navigate] A* result: Partial"; then + echo "Expected full success path, saw partial path" >&2 + log_since_clean "$from_line" >&2 + return 1 + fi +} + +debug_state_snapshot() { + local label="$1" + local from_line + from_line="$(log_line_count)" + send_mcc "debug state" + wait_for_log "Location" "$from_line" 10 + echo "" + echo "=== Debug state: $label ===" + log_since_clean "$from_line" +} + +capture_debug_state_before_route() { + debug_state_snapshot "before route - $1" +} + +capture_debug_state_after_route() { + debug_state_snapshot "after route - $1" +} + +capture_debug_location() { + local start_line + start_line="$(log_line_count)" + send_mcc "debug state" + wait_for_log "Location" "$start_line" 10 + extract_last_location "$start_line" +} + +wait_for_location_in_block() { + local expected_x="$1" + local expected_y="$2" + local expected_z="$3" + local timeout="${4:-10}" + + for _ in $(seq 1 "$timeout"); do + local actual_x actual_y actual_z + read -r actual_x actual_y actual_z <<< "$(capture_debug_location)" + if python3 - <<'PY' "$actual_x" "$actual_y" "$actual_z" "$expected_x" "$expected_y" "$expected_z" +import math +import sys + +ax, ay, az, ex, ey, ez = map(float, sys.argv[1:]) +if math.floor(ax) == int(ex) and math.floor(az) == int(ez) and abs(ay - ey) <= 0.05: + raise SystemExit(0) +raise SystemExit(1) +PY + then + return 0 + fi + sleep 1 + done + + echo "Timed out waiting for player to reach start block ($expected_x, $expected_y, $expected_z)" >&2 + return 1 +} + +prepare_independent_route() { + local label="$1" + local start_x="$2" + local start_y="$3" + local start_z="$4" + + 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 + wait_for_location_in_block "$start_x" "$start_y" "$start_z" 10 +} + +extract_last_location() { + local from_line="${1:-0}" + + python3 - "$LOG" "$from_line" <<'PY' +import pathlib +import re +import sys + +log_path = pathlib.Path(sys.argv[1]) +from_line = int(sys.argv[2]) +text = log_path.read_text(errors="ignore") +text = "\n".join(text.splitlines()[from_line:]) +text = re.sub(r"\x1b\[[0-9;]*m", "", text) +matches = re.findall(r"Location\s+([-,0-9.]+),\s+([-,0-9.]+),\s+([-,0-9.]+)", text) +if not matches: + raise SystemExit("No Location line found in MCC log") +x, y, z = matches[-1] +print(f"{x} {y} {z}") +PY +} + +assert_inside_goal_block() { + local actual_x="$1" + local actual_y="$2" + local actual_z="$3" + local expected_x="$4" + local expected_y="$5" + local expected_z="$6" + + python3 - <<'PY' "$actual_x" "$actual_y" "$actual_z" "$expected_x" "$expected_y" "$expected_z" +import math +import sys + +ax, ay, az, ex, ey, ez = map(float, sys.argv[1:]) +if math.floor(ax) != int(ex) or math.floor(az) != int(ez) or abs(ay - ey) > 0.05: + raise SystemExit( + f"Expected location inside goal block ({int(ex)}, {ey:.2f}, {int(ez)}), got ({ax:.2f}, {ay:.2f}, {az:.2f})" + ) +PY +} + +print_summary() { + local header="$1" + + echo "" + echo "----- $header -----" + if [[ -f "$LOG" ]]; then + tail -n 40 "$LOG" | sed 's/\x1b\[[0-9;]*m//g' + else + echo "(no log available yet)" + fi +} + +fill_box() { + mc-rcon "fill $1 $2 $3 $4 $5 $6 $7" >/dev/null +} + +set_stone() { + mc-rcon "setblock $1 $2 $3 stone" >/dev/null +} + +run_accepted_route() { + local label="$1" + local start_x="$2" + local start_y="$3" + local start_z="$4" + local goal_x="$5" + local goal_y="$6" + local goal_z="$7" + local timeout="${8:-45}" + + prepare_independent_route "$label" "$start_x" "$start_y" "$start_z" + capture_debug_state_before_route "$label" + + local start_line + start_line="$(log_line_count)" + send_mcc "pathfind $goal_x $goal_y $goal_z" + wait_for_navigation "$start_line" "$timeout" + assert_no_partial_since "$start_line" + assert_no_replans_since "$start_line" + capture_debug_state_after_route "$label" + + local x y z + read -r x y z <<< "$(capture_debug_location)" + echo " Final location: $x $y $z" + assert_inside_goal_block "$x" "$y" "$z" "$goal_x" "$goal_y" "$goal_z" + print_summary "$label" +} + +run_case() { + local label="$1" + shift + + echo "" + echo "== $label ==" + set +e + ( + set -e + "$@" + ) + local status=$? + set -e + + if [[ $status -eq 0 ]]; then + PASSED_CASES+=("$label") + echo "RESULT: PASS - $label" + else + FAILED_CASES+=("$label") + echo "RESULT: FAIL - $label" + fi +} + +start_mcc() { + mkdir -p "$SESSION_ROOT" + mcc-kill --session "$SESSION" >/dev/null 2>&1 || true + mcc-build >/dev/null + mcc-debug -v "$VERSION" --session "$SESSION" --username "$USERNAME" --file-input --debug-on --no-build >/dev/null + wait_for_log "Server was successfully joined." 0 40 + send_mcc "debug on" +} + +scenario_repeated_cardinal_parkour() { + fill_box 578 79 578 590 79 582 air + fill_box 578 80 578 590 90 582 air + set_stone 580 79 580 + set_stone 582 79 580 + set_stone 584 79 580 + set_stone 586 79 580 + set_stone 588 79 580 + run_accepted_route "Repeated jump - cardinal parkour chain" "580.5" "80" "580.5" "588" "80.00" "580" +} + +scenario_repeated_diagonal_parkour() { + fill_box 598 79 598 608 79 608 air + fill_box 598 80 598 608 90 608 air + set_stone 600 79 600 + set_stone 602 79 602 + set_stone 604 79 604 + set_stone 606 79 606 + run_accepted_route "Repeated jump - diagonal parkour chain" "600.5" "80" "600.5" "606" "80.00" "606" +} + +scenario_obstructed_parkour_turn_mix() { + fill_box 618 79 618 628 79 624 air + fill_box 618 80 618 628 90 624 air + set_stone 620 79 620 + set_stone 622 79 620 + set_stone 622 79 621 + set_stone 624 79 621 + set_stone 624 79 622 + set_stone 626 79 622 + set_stone 620 80 621 + set_stone 620 81 621 + set_stone 622 80 622 + set_stone 622 81 622 + run_accepted_route "Obstructed jump mix - repeated parkour L-turns" "620.5" "80" "620.5" "626" "80.00" "622" +} + +scenario_parkour_ascend_descend_chain() { + fill_box 638 79 618 650 80 622 air + fill_box 638 81 618 650 92 622 air + set_stone 640 79 620 + set_stone 642 80 620 + set_stone 644 79 620 + set_stone 646 80 620 + set_stone 648 79 620 + run_accepted_route "Vertical jump mix - parkour ascend descend chain" "640.5" "80" "620.5" "648" "80.00" "620" +} + +scenario_diagonal_ascend_descend_chain() { + fill_box 678 79 618 686 80 626 air + fill_box 678 81 618 686 92 626 air + set_stone 680 79 620 + set_stone 681 80 621 + set_stone 682 79 622 + set_stone 683 80 623 + set_stone 684 79 624 + run_accepted_route "Diagonal vertical mix - ascend descend chain" "680.5" "80" "620.5" "684" "80.00" "624" +} + +start_mcc + +mc-rcon "difficulty peaceful" >/dev/null 2>&1 || true +mc-rcon "gamerule doMobSpawning false" >/dev/null 2>&1 || true +mc-rcon "time set day" >/dev/null 2>&1 || true + +run_case "Repeated jump - cardinal parkour chain" scenario_repeated_cardinal_parkour +run_case "Repeated jump - diagonal parkour chain" scenario_repeated_diagonal_parkour +run_case "Obstructed jump mix - repeated parkour L-turns" scenario_obstructed_parkour_turn_mix +run_case "Vertical jump mix - parkour ascend descend chain" scenario_parkour_ascend_descend_chain +run_case "Diagonal vertical mix - ascend descend chain" scenario_diagonal_ascend_descend_chain + +echo "" +echo "Jump combo summary:" +for label in "${PASSED_CASES[@]}"; do + echo " PASS $label" +done +for label in "${FAILED_CASES[@]}"; do + echo " FAIL $label" +done + +if [[ ${#FAILED_CASES[@]} -ne 0 ]]; then + exit 1 +fi + +echo "" +echo "Pathing jump-combo suite complete." diff --git a/tools/test-pathing-long-routes.sh b/tools/test-pathing-long-routes.sh new file mode 100644 index 00000000..8f7f492d --- /dev/null +++ b/tools/test-pathing-long-routes.sh @@ -0,0 +1,454 @@ +#!/usr/bin/env bash +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" +source "$REPO_ROOT/tools/mcc-env.sh" + +VERSION="${1:-1.21.11-Vanilla}" +SESSION="mcc-pathing-long-routes" +USERNAME="CursorBot" + +SESSION_ROOT="$(_mcc_session_root "$SESSION")" +LOG="$(_mcc_session_log_file "$SESSION")" + +cleanup() { + mcc-kill --session "$SESSION" >/dev/null 2>&1 || true +} + +trap cleanup EXIT + +send_mcc() { + mcc-cmd --session "$SESSION" "$1" +} + +log_line_count() { + if [[ -f "$LOG" ]]; then + wc -l < "$LOG" + else + echo 0 + fi +} + +log_since() { + local from_line="$1" + if [[ ! -f "$LOG" ]]; then + return + fi + + tail -n +"$((from_line + 1))" "$LOG" +} + +wait_for_log() { + local pattern="$1" + local from_line="${2:-0}" + local timeout="${3:-30}" + + for _ in $(seq 1 "$timeout"); do + if log_since_clean "$from_line" | grep -Fq "$pattern"; then + return 0 + fi + sleep 1 + done + + return 1 +} + +wait_for_navigation() { + local from_line="$1" + local timeout="${2:-45}" + local saw_start=0 + + for _ in $(seq 1 "$timeout"); do + local recent + recent="$(log_since_clean "$from_line")" + + if grep -Fq "[PathMgr] Navigation started" <<<"$recent"; then + saw_start=1 + fi + + if (( saw_start )) && grep -Fq "[PathMgr] Navigation complete!" <<<"$recent"; then + return 0 + fi + + if grep -Eq "\[PathMgr\] (Replan failed|Giving up)" <<<"$recent"; then + echo "$recent" >&2 + return 1 + fi + + if grep -Eq "No path found|\[Navigate\] A\* result: Failed" <<<"$recent"; then + echo "$recent" >&2 + return 1 + fi + + sleep 1 + done + + echo "Timed out waiting for navigation completion" >&2 + log_since_clean "$from_line" >&2 + return 1 +} + +log_since_clean() { + log_since "$1" | sed 's/\x1b\[[0-9;]*m//g' +} + +count_replans_since() { + local from_line="$1" + log_since_clean "$from_line" | grep -Ec '\[PathMgr\] Replan #|\[PathExec\] Segment .* FAILED' || true +} + +assert_no_replans_since() { + local from_line="$1" + local count + count="$(count_replans_since "$from_line")" + if [[ "$count" != "0" ]]; then + echo "Expected 0 replans, saw $count" >&2 + log_since_clean "$from_line" >&2 + return 1 + fi +} + +assert_no_partial_since() { + local from_line="$1" + if log_since_clean "$from_line" | grep -Fq "[Navigate] A* result: Partial"; then + echo "Expected full success path, saw partial path" >&2 + log_since_clean "$from_line" >&2 + return 1 + fi +} + +debug_state_snapshot() { + local label="$1" + local from_line + from_line="$(log_line_count)" + send_mcc "debug state" + wait_for_log "Location" "$from_line" 10 + echo "" + echo "=== Debug state: $label ===" + log_since_clean "$from_line" +} + +capture_debug_state_before_route() { + debug_state_snapshot "before route - $1" +} + +capture_debug_state_after_route() { + debug_state_snapshot "after route - $1" +} + +prepare_independent_route() { + local label="$1" + local start_x="$2" + local start_y="$3" + local start_z="$4" + + 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 + wait_for_location_in_block "$start_x" "$start_y" "$start_z" 10 +} + +capture_debug_location() { + local start_line + start_line="$(log_line_count)" + send_mcc "debug state" + wait_for_log "Location" "$start_line" 10 + extract_last_location "$start_line" +} + +wait_for_location_in_block() { + local expected_x="$1" + local expected_y="$2" + local expected_z="$3" + local timeout="${4:-10}" + + for _ in $(seq 1 "$timeout"); do + local actual_x actual_y actual_z + read -r actual_x actual_y actual_z <<< "$(capture_debug_location)" + if python3 - <<'PY' "$actual_x" "$actual_y" "$actual_z" "$expected_x" "$expected_y" "$expected_z" +import math +import sys + +ax, ay, az, ex, ey, ez = map(float, sys.argv[1:]) +if math.floor(ax) == int(ex) and math.floor(az) == int(ez) and abs(ay - ey) <= 0.05: + raise SystemExit(0) +raise SystemExit(1) +PY + then + return 0 + fi + sleep 1 + done + + echo "Timed out waiting for player to reach start block ($expected_x, $expected_y, $expected_z)" >&2 + return 1 +} + +extract_last_location() { + local from_line="${1:-0}" + + python3 - "$LOG" "$from_line" <<'PY' +import pathlib +import re +import sys + +log_path = pathlib.Path(sys.argv[1]) +from_line = int(sys.argv[2]) +text = log_path.read_text(errors="ignore") +text = "\n".join(text.splitlines()[from_line:]) +text = re.sub(r"\x1b\[[0-9;]*m", "", text) +matches = re.findall(r"Location\s+([-,0-9.]+),\s+([-,0-9.]+),\s+([-,0-9.]+)", text) +if not matches: + raise SystemExit("No Location line found in MCC log") +x, y, z = matches[-1] +print(f"{x} {y} {z}") +PY +} + +assert_inside_goal_block() { + local actual_x="$1" + local actual_y="$2" + local actual_z="$3" + local expected_x="$4" + local expected_y="$5" + local expected_z="$6" + + python3 - <<'PY' "$actual_x" "$actual_y" "$actual_z" "$expected_x" "$expected_y" "$expected_z" +import math +import sys + +ax, ay, az, ex, ey, ez = map(float, sys.argv[1:]) +if math.floor(ax) != int(ex) or math.floor(az) != int(ez) or abs(ay - ey) > 0.05: + raise SystemExit( + f"Expected location inside goal block ({int(ex)}, {ey:.2f}, {int(ez)}), got ({ax:.2f}, {ay:.2f}, {az:.2f})" + ) +PY +} + +print_summary() { + local header="$1" + + echo "" + echo "----- $header -----" + if [[ -f "$LOG" ]]; then + tail -n 40 "$LOG" | sed 's/\x1b\[[0-9;]*m//g' + else + echo "(no log available yet)" + fi +} + +fill_box() { + mc-rcon "fill $1 $2 $3 $4 $5 $6 $7" >/dev/null +} + +set_stone() { + mc-rcon "setblock $1 $2 $3 stone" >/dev/null +} + +run_accepted_route() { + local label="$1" + local start_x="$2" + local start_y="$3" + local start_z="$4" + local goal_x="$5" + local goal_y="$6" + local goal_z="$7" + local timeout="${8:-45}" + + prepare_independent_route "$label" "$start_x" "$start_y" "$start_z" + capture_debug_state_before_route "$label" + + local start_line + start_line="$(log_line_count)" + send_mcc "pathfind $goal_x $goal_y $goal_z" + wait_for_navigation "$start_line" "$timeout" + assert_no_partial_since "$start_line" + assert_no_replans_since "$start_line" + capture_debug_state_after_route "$label" + + local x y z + read -r x y z <<< "$(capture_debug_location)" + echo " Final location: $x $y $z" + assert_inside_goal_block "$x" "$y" "$z" "$goal_x" "$goal_y" "$goal_z" + print_summary "$label" +} + +start_mcc() { + mkdir -p "$SESSION_ROOT" + mcc-kill --session "$SESSION" >/dev/null 2>&1 || true + mcc-build >/dev/null + mcc-debug -v "$VERSION" --session "$SESSION" --username "$USERNAME" --file-input --debug-on --no-build >/dev/null + wait_for_log "Server was successfully joined." 0 40 + send_mcc "debug on" +} + +run_same_move_routes() { + echo "== 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" "300.5" "80" "300.5" "312" "80.00" "300" + + fill_box 318 79 318 330 79 330 air + fill_box 318 80 318 330 90 330 air + set_stone 320 79 320 + set_stone 321 79 321 + set_stone 322 79 322 + set_stone 323 79 323 + set_stone 324 79 324 + set_stone 325 79 325 + set_stone 326 79 326 + set_stone 327 79 327 + run_accepted_route "Same move - diagonal chain" "320.5" "80" "320.5" "327" "80.00" "327" + + fill_box 338 79 338 347 85 342 air + fill_box 338 80 338 347 90 342 air + fill_box 340 79 339 340 79 341 stone + fill_box 341 80 339 341 80 341 stone + fill_box 342 81 339 342 81 341 stone + 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" "340.5" "80" "340.5" "345" "85.00" "340" + + fill_box 360 79 358 369 85 362 air + fill_box 360 80 358 369 90 362 air + fill_box 362 84 359 362 84 361 stone + fill_box 363 83 359 363 83 361 stone + fill_box 364 82 359 364 82 361 stone + 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" "362.5" "85" "360.5" "367" "80.00" "360" + + fill_box 378 79 378 390 79 382 air + fill_box 378 80 378 390 90 382 air + set_stone 380 79 380 + set_stone 382 79 380 + set_stone 384 79 380 + set_stone 386 79 380 + set_stone 388 79 380 + run_accepted_route "Same move - aligned parkour chain" "380.5" "80" "380.5" "388" "80.00" "380" +} + +run_mixed_move_routes() { + echo "== Mixed move routes ==" + + fill_box 398 79 398 410 79 406 air + fill_box 398 80 398 410 90 406 air + set_stone 400 79 400 + set_stone 401 79 400 + set_stone 402 79 400 + set_stone 402 79 401 + set_stone 402 79 402 + set_stone 404 79 402 + set_stone 405 79 402 + set_stone 406 79 402 + set_stone 406 79 403 + set_stone 406 79 404 + set_stone 407 79 404 + set_stone 408 79 404 + run_accepted_route "Mixed - traverse turn parkour turn traverse" "400.5" "80" "400.5" "408" "80.00" "404" + + fill_box 418 79 418 430 82 424 air + fill_box 418 80 418 430 92 424 air + set_stone 420 79 420 + set_stone 421 79 421 + set_stone 422 79 422 + set_stone 423 80 422 + set_stone 424 81 422 + set_stone 425 81 422 + set_stone 426 81 422 + set_stone 427 80 422 + set_stone 428 79 422 + run_accepted_route "Mixed - diagonal ascend traverse descend" "420.5" "80" "420.5" "428" "80.00" "422" + + fill_box 438 79 438 450 82 442 air + fill_box 438 80 438 450 92 442 air + set_stone 440 79 440 + set_stone 441 79 440 + set_stone 442 80 440 + set_stone 443 81 440 + set_stone 444 81 440 + set_stone 446 81 440 + set_stone 447 80 440 + set_stone 448 79 440 + run_accepted_route "Mixed - traverse ascend parkour descend" "440.5" "80" "440.5" "448" "80.00" "440" +} + +run_turn_density_routes() { + echo "== Turn density routes ==" + + fill_box 458 79 458 468 79 468 air + fill_box 458 80 458 468 90 468 air + set_stone 460 79 460 + set_stone 461 79 460 + set_stone 461 79 461 + set_stone 462 79 462 + set_stone 463 79 462 + set_stone 463 79 463 + set_stone 464 79 464 + set_stone 465 79 464 + set_stone 465 79 465 + set_stone 466 79 466 + run_accepted_route "Turn density - alternating traverse diagonal chain" "460.5" "80" "460.5" "466" "80.00" "466" +} + +run_speed_carry_routes() { + echo "== Speed carry routes ==" + + fill_box 478 79 478 490 83 482 air + fill_box 478 80 478 490 94 482 air + set_stone 480 79 480 + set_stone 481 79 480 + set_stone 482 80 480 + set_stone 483 80 480 + set_stone 484 81 480 + set_stone 485 81 480 + set_stone 486 82 480 + set_stone 487 82 480 + set_stone 488 83 480 + run_accepted_route "Speed carry - repeated traverse ascend" "480.5" "80" "480.5" "488" "84.00" "480" + + fill_box 498 79 498 510 82 502 air + fill_box 498 80 498 510 94 502 air + set_stone 500 82 500 + set_stone 501 82 500 + set_stone 502 81 500 + set_stone 503 81 500 + set_stone 504 80 500 + set_stone 505 80 500 + set_stone 506 79 500 + set_stone 507 79 500 + run_accepted_route "Speed carry - repeated traverse descend" "500.5" "83" "500.5" "507" "80.00" "500" + + fill_box 518 79 518 532 79 522 air + fill_box 518 80 518 532 90 522 air + set_stone 520 79 520 + set_stone 521 79 520 + set_stone 523 79 520 + set_stone 524 79 520 + set_stone 526 79 520 + set_stone 527 79 520 + set_stone 529 79 520 + run_accepted_route "Speed carry - repeated traverse parkour" "520.5" "80" "520.5" "529" "80.00" "520" +} + +start_mcc + +mc-rcon "difficulty peaceful" >/dev/null 2>&1 || true +mc-rcon "gamerule doMobSpawning false" >/dev/null 2>&1 || true +mc-rcon "time set day" >/dev/null 2>&1 || true + +run_same_move_routes +run_mixed_move_routes +run_turn_density_routes +run_speed_carry_routes + +mcc-kill --session "$SESSION" >/dev/null 2>&1 || true + +echo "" +echo "Pathing long-route suite complete." diff --git a/tools/test-pathing-template-regressions.sh b/tools/test-pathing-template-regressions.sh index 25689082..81262b66 100644 --- a/tools/test-pathing-template-regressions.sh +++ b/tools/test-pathing-template-regressions.sh @@ -7,17 +7,19 @@ source "$REPO_ROOT/tools/mcc-env.sh" VERSION="${1:-1.21.11-Vanilla}" SESSION="mcc-pathing-template" -TEST_ROOT="${TMPDIR:-/tmp}/mcc-pathing-template" -CFG="$TEST_ROOT/MinecraftClient.pathing-template.ini" -LOG="$TEST_ROOT/mcc-pathing-template.log" -INPUT_FILE="$REPO_ROOT/mcc_input.txt" -PREPARE_CFG_SCRIPT="$REPO_ROOT/.skills/mcc-integration-testing/scripts/prepare_offline_mcc_config.sh" -ENSURE_SERVER_SCRIPT="$REPO_ROOT/.skills/mcc-integration-testing/scripts/ensure_offline_server.sh" +USERNAME="CursorBot" -mkdir -p "$TEST_ROOT" +SESSION_ROOT="$(_mcc_session_root "$SESSION")" +LOG="$(_mcc_session_log_file "$SESSION")" + +cleanup() { + mcc-kill --session "$SESSION" >/dev/null 2>&1 || true +} + +trap cleanup EXIT send_mcc() { - echo "$1" >> "$INPUT_FILE" + mcc-cmd --session "$SESSION" "$1" } log_line_count() { @@ -40,10 +42,10 @@ log_since() { wait_for_log() { local pattern="$1" local from_line="${2:-0}" - local timeout="${3:-20}" + local timeout="${3:-30}" for _ in $(seq 1 "$timeout"); do - if log_since "$from_line" | grep -Fq "$pattern"; then + if log_since_clean "$from_line" | grep -Fq "$pattern"; then return 0 fi sleep 1 @@ -55,25 +57,35 @@ wait_for_log() { wait_for_navigation() { local from_line="$1" local timeout="${2:-25}" + local saw_start=0 for _ in $(seq 1 "$timeout"); do local recent - recent="$(log_since "$from_line")" + recent="$(log_since_clean "$from_line")" - if grep -Eq "\\[PathMgr\\] (Replan failed|Giving up)|\\[PathMgr\\] Segment failed, replanning|\\[PathExec\\] Segment .* FAILED" <<<"$recent"; then + if grep -Fq "[PathMgr] Navigation started" <<<"$recent"; then + saw_start=1 + fi + + if (( saw_start )) && grep -Fq "[PathMgr] Navigation complete!" <<<"$recent"; then + return 0 + fi + + if grep -Eq "\[PathMgr\] (Replan failed|Giving up)" <<<"$recent"; then echo "$recent" >&2 return 1 fi - if grep -Fq "[PathMgr] Navigation complete!" <<<"$recent"; then - return 0 + if grep -Eq "No path found|\[Navigate\] A\* result: Failed" <<<"$recent"; then + echo "$recent" >&2 + return 1 fi sleep 1 done echo "Timed out waiting for navigation completion" >&2 - log_since "$from_line" >&2 + log_since_clean "$from_line" >&2 return 1 } @@ -83,9 +95,9 @@ wait_for_failure_signal() { for _ in $(seq 1 "$timeout"); do local recent - recent="$(log_since "$from_line")" + recent="$(log_since_clean "$from_line")" - if grep -Eq "\\[PathMgr\\] (Replan failed|Giving up)|No path found|\\[Navigate\\] A\\* result: Failed" <<<"$recent"; then + if grep -Eq "No path found|\[Navigate\] A\* result: Failed" <<<"$recent"; then return 0 fi @@ -95,6 +107,82 @@ wait_for_failure_signal() { return 1 } +log_since_clean() { + log_since "$1" | sed 's/\x1b\[[0-9;]*m//g' +} + +count_replans_since() { + local from_line="$1" + log_since_clean "$from_line" | grep -Ec '\[PathMgr\] Replan #|\[PathExec\] Segment .* FAILED' || true +} + +assert_no_replans_since() { + local from_line="$1" + local count + count="$(count_replans_since "$from_line")" + if [[ "$count" != "0" ]]; then + echo "Expected 0 replans, saw $count" >&2 + log_since_clean "$from_line" >&2 + return 1 + fi +} + +assert_no_partial_since() { + local from_line="$1" + if log_since_clean "$from_line" | grep -Fq "[Navigate] A* result: Partial"; then + echo "Expected full success path, saw partial path" >&2 + log_since_clean "$from_line" >&2 + return 1 + fi +} + +debug_state_snapshot() { + local label="$1" + local from_line + from_line="$(log_line_count)" + send_mcc "debug state" + wait_for_log "Location" "$from_line" 10 + echo "" + echo "=== Debug state: $label ===" + log_since_clean "$from_line" +} + +capture_debug_state_before_route() { + debug_state_snapshot "before route - $1" +} + +capture_debug_state_after_route() { + debug_state_snapshot "after route - $1" +} + +prepare_independent_route() { + local label="$1" + local start_x="$2" + local start_y="$3" + local start_z="$4" + + 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 + wait_for_location_in_block "$start_x" "$start_y" "$start_z" 10 +} + +assert_direct_rejection_since() { + local from_line="$1" + if ! log_since_clean "$from_line" | grep -Eq "No path found|\[Navigate\] A\* result: Failed"; then + echo "Expected direct rejection before navigation execution" >&2 + log_since_clean "$from_line" >&2 + return 1 + fi + if log_since_clean "$from_line" | grep -Fq "[PathMgr] Navigation started"; then + echo "Expected rejection before navigation started" >&2 + log_since_clean "$from_line" >&2 + return 1 + fi + assert_no_replans_since "$from_line" +} + extract_last_location() { local from_line="${1:-0}" @@ -108,11 +196,11 @@ from_line = int(sys.argv[2]) text = log_path.read_text(errors="ignore") text = "\n".join(text.splitlines()[from_line:]) text = re.sub(r"\x1b\[[0-9;]*m", "", text) -matches = re.findall(r"Location\s+([-\d.]+),\s+([-\d.]+),\s+([-\d.]+)", text) +matches = re.findall(r"Location\s+([-,0-9.]+),\s+([-,0-9.]+),\s+([-,0-9.]+)", text) if not matches: - matches = re.findall(r"Segment \d+ complete .* at \(([-\d.]+),([-\d.]+),([-\d.]+)\)", text) + matches = re.findall(r"Segment \d+ complete .* at \(([-,0-9.]+),([-,0-9.]+),([-,0-9.]+)\)", text) if not matches: - matches = re.findall(r"pos=\(([-\d.]+),\s*([-\d.]+),\s*([-\d.]+)\)", text) + matches = re.findall(r"pos=\(([-,0-9.]+),\s*([-,0-9.]+),\s*([-,0-9.]+)\)", text) if not matches: raise SystemExit("No location line found in MCC log") x, y, z = matches[-1] @@ -120,23 +208,22 @@ print(f"{x} {y} {z}") PY } -assert_close() { +assert_inside_goal_block() { local actual_x="$1" local actual_y="$2" local actual_z="$3" local target_x="$4" local target_y="$5" local target_z="$6" - local tolerance="${7:-0.2}" - python3 - <<'PY' "$actual_x" "$actual_y" "$actual_z" "$target_x" "$target_y" "$target_z" "$tolerance" + python3 - <<'PY' "$actual_x" "$actual_y" "$actual_z" "$target_x" "$target_y" "$target_z" import math import sys -ax, ay, az, tx, ty, tz, tol = map(float, sys.argv[1:]) -if abs(ax - tx) > tol or abs(ay - ty) > tol or abs(az - tz) > tol: +ax, ay, az, tx, ty, tz = map(float, sys.argv[1:]) +if math.floor(ax) != int(tx) or math.floor(az) != int(tz) or abs(ay - ty) > 0.05: raise SystemExit( - f"Expected ({tx:.2f}, {ty:.2f}, {tz:.2f}) within {tol:.2f}, got ({ax:.2f}, {ay:.2f}, {az:.2f})" + f"Expected location inside goal block ({int(tx)}, {ty:.2f}, {int(tz)}), got ({ax:.2f}, {ay:.2f}, {az:.2f})" ) PY } @@ -153,37 +240,69 @@ print_summary() { fi } +capture_debug_location() { + local start_line + start_line="$(log_line_count)" + send_mcc "debug state" + wait_for_log "Location" "$start_line" 10 + extract_last_location "$start_line" +} + +wait_for_location_in_block() { + local expected_x="$1" + local expected_y="$2" + local expected_z="$3" + local timeout="${4:-10}" + + for _ in $(seq 1 "$timeout"); do + local actual_x actual_y actual_z + read -r actual_x actual_y actual_z <<< "$(capture_debug_location)" + if python3 - <<'PY' "$actual_x" "$actual_y" "$actual_z" "$expected_x" "$expected_y" "$expected_z" +import math +import sys + +ax, ay, az, ex, ey, ez = map(float, sys.argv[1:]) +if math.floor(ax) == int(ex) and math.floor(az) == int(ez) and abs(ay - ey) <= 0.05: + raise SystemExit(0) +raise SystemExit(1) +PY + then + return 0 + fi + sleep 1 + done + + echo "Timed out waiting for player to reach start block ($expected_x, $expected_y, $expected_z)" >&2 + return 1 +} + start_mcc() { - bash "$PREPARE_CFG_SCRIPT" "$CFG" "$VERSION" CursorBot >/dev/null - - : > "$INPUT_FILE" - : > "$LOG" - - tmux kill-session -t "$SESSION" 2>/dev/null || true - tmux new-session -d -s "$SESSION" -x 160 -y 50 \ - "cd '$REPO_ROOT' && MCC_FILE_INPUT=1 dotnet run --project MinecraftClient -c Release --no-build -- '$CFG' CursorBot - localhost:25565 > '$LOG' 2>&1; echo '=== MCC EXITED ==='; sleep 600" - - wait_for_log "Server was successfully joined." 0 20 + mkdir -p "$SESSION_ROOT" + mcc-kill --session "$SESSION" >/dev/null 2>&1 || true + mcc-build >/dev/null + mcc-debug -v "$VERSION" --session "$SESSION" --username "$USERNAME" --file-input --debug-on --no-build >/dev/null + wait_for_log "Server was successfully joined." 0 40 send_mcc "debug on" - sleep 1 } run_flat_final_stop() { echo "== Flat final stop ==" mc-rcon "fill 95 79 95 115 79 105 stone" >/dev/null mc-rcon "fill 95 80 95 115 85 105 air" >/dev/null - mc-rcon "tp CursorBot 100.5 80 100.5" >/dev/null - sleep 2 - + prepare_independent_route "Flat final stop" "100.5" "80" "100.5" + capture_debug_state_before_route "Flat final stop" local start_line start_line="$(log_line_count)" send_mcc "pathfind 103 80 100" wait_for_navigation "$start_line" 30 + assert_no_partial_since "$start_line" + assert_no_replans_since "$start_line" + capture_debug_state_after_route "Flat final stop" local x y z - read -r x y z <<< "$(extract_last_location "$start_line")" + read -r x y z <<< "$(capture_debug_location)" echo " Final location: $x $y $z" - assert_close "$x" "$y" "$z" "103.50" "80.00" "100.50" + assert_inside_goal_block "$x" "$y" "$z" "103" "80.00" "100" print_summary "Flat final stop" } @@ -196,18 +315,20 @@ 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 - mc-rcon "tp CursorBot 120.5 80 110.5" >/dev/null - sleep 2 - + prepare_independent_route "Parkour into L-turn" "120.5" "80" "110.5" + capture_debug_state_before_route "Parkour into L-turn" local start_line start_line="$(log_line_count)" send_mcc "pathfind 122 80 111" wait_for_navigation "$start_line" 30 + assert_no_partial_since "$start_line" + assert_no_replans_since "$start_line" + capture_debug_state_after_route "Parkour into L-turn" local x y z - read -r x y z <<< "$(extract_last_location "$start_line")" + read -r x y z <<< "$(capture_debug_location)" echo " Final location: $x $y $z" - assert_close "$x" "$y" "$z" "122.50" "80.00" "111.50" + assert_inside_goal_block "$x" "$y" "$z" "122" "80.00" "111" print_summary "Parkour into L-turn" } @@ -221,14 +342,14 @@ 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 - mc-rcon "tp CursorBot 131.5 80 127.5" >/dev/null - sleep 2 - + prepare_independent_route "Rejected 2x1 side-wall jump" "131.5" "80" "127.5" + capture_debug_state_before_route "Rejected 2x1 side-wall jump" local start_line start_line="$(log_line_count)" send_mcc "pathfind 133 80 127" if wait_for_failure_signal "$start_line" 20; then + assert_direct_rejection_since "$start_line" echo " Pathfinding rejected as expected." else echo " Expected rejection but navigation continued." >&2 @@ -244,33 +365,18 @@ 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 - mc-rcon "tp CursorBot 141.5 80 138.5" >/dev/null - sleep 2 - + prepare_independent_route "Rejected 3x1 no-run-up gap" "141.5" "80" "138.5" + capture_debug_state_before_route "Rejected 3x1 gap" local start_line start_line="$(log_line_count)" send_mcc "pathfind 144 81 138" - if wait_for_log "Replan failed" "$start_line" 20; then + if wait_for_failure_signal "$start_line" 20; then + assert_direct_rejection_since "$start_line" echo " Pathfinding rejected as expected." - elif wait_for_navigation "$start_line" 30; then - local x y z - read -r x y z <<< "$(extract_last_location "$start_line")" - if python3 - <<'PY' "$x" "$y" "$z" -import sys -x, y, z = map(float, sys.argv[1:]) -tx, ty, tz = 144.5, 81.0, 138.5 -tol = 0.2 -sys.exit(0 if abs(x - tx) > tol or abs(y - ty) > tol or abs(z - tz) > tol else 1) -PY - then - echo " Pathfinder only reached a partial fallback, rejection accepted." - else - echo " Expected rejection but goal was reached." >&2 - return 1 - fi else echo " Expected rejection but navigation continued." >&2 + log_since "$start_line" >&2 return 1 fi @@ -284,18 +390,20 @@ 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 - mc-rcon "tp CursorBot 190.5 80 170.5" >/dev/null - sleep 2 - + prepare_independent_route "Corner ascend around wall" "190.5" "80" "170.5" + capture_debug_state_before_route "Corner ascend around wall" local start_line start_line="$(log_line_count)" send_mcc "pathfind 191 81 171" wait_for_navigation "$start_line" 25 + assert_no_partial_since "$start_line" + assert_no_replans_since "$start_line" + capture_debug_state_after_route "Corner ascend around wall" local x y z - read -r x y z <<< "$(extract_last_location "$start_line")" + read -r x y z <<< "$(capture_debug_location)" echo " Final location: $x $y $z" - assert_close "$x" "$y" "$z" "191.50" "81.00" "171.50" "0.25" + assert_inside_goal_block "$x" "$y" "$z" "191" "81.00" "171" print_summary "Corner ascend around wall" } @@ -309,18 +417,20 @@ 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 - mc-rcon "tp CursorBot 200.5 81 200.5" >/dev/null - sleep 2 - + prepare_independent_route "Wall-adjacent descend" "200.5" "81" "200.5" + capture_debug_state_before_route "Wall-adjacent descend" local start_line start_line="$(log_line_count)" send_mcc "pathfind 201 80 200" wait_for_navigation "$start_line" 25 + assert_no_partial_since "$start_line" + assert_no_replans_since "$start_line" + capture_debug_state_after_route "Wall-adjacent descend" local x y z - read -r x y z <<< "$(extract_last_location "$start_line")" + read -r x y z <<< "$(capture_debug_location)" echo " Final location: $x $y $z" - assert_close "$x" "$y" "$z" "201.50" "80.00" "200.50" "0.25" + assert_inside_goal_block "$x" "$y" "$z" "201" "80.00" "200" print_summary "Wall-adjacent descend" } @@ -331,30 +441,24 @@ 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 - mc-rcon "fill 178 78 160 182 78 164 stone" >/dev/null - mc-rcon "fill 178 83 160 182 83 164 air" >/dev/null - mc-rcon "setblock 181 80 162 minecraft:ladder[facing=east]" >/dev/null - mc-rcon "setblock 181 81 162 minecraft:ladder[facing=east]" >/dev/null - mc-rcon "setblock 181 82 162 minecraft:ladder[facing=east]" >/dev/null - mc-rcon "setblock 181 83 162 minecraft:ladder[facing=east]" >/dev/null - mc-rcon "tp CursorBot 171.5 80 160.5" >/dev/null - sleep 2 - + prepare_independent_route "Ascend chain smoke" "171.5" "80" "160.5" + capture_debug_state_before_route "Ascend chain smoke" local start_line start_line="$(log_line_count)" - send_mcc "pathfind 182 83 162" + send_mcc "pathfind 177 83 162" wait_for_navigation "$start_line" 35 + assert_no_partial_since "$start_line" + assert_no_replans_since "$start_line" + capture_debug_state_after_route "Ascend chain smoke" + + local x y z + read -r x y z <<< "$(capture_debug_location)" + echo " Final location: $x $y $z" + assert_inside_goal_block "$x" "$y" "$z" "177" "83.00" "162" - echo " Ascend chain completed." print_summary "Ascend chain smoke" } -mcc-preflight "$VERSION" >/dev/null -mc-reset-test-env "$VERSION" >/dev/null -bash "$ENSURE_SERVER_SCRIPT" "$VERSION" >/dev/null -mc-start "$VERSION" >/dev/null -mc-wait-ready "$VERSION" 60 >/dev/null -mcc-kill >/dev/null 2>&1 || true start_mcc mc-rcon "difficulty peaceful" >/dev/null 2>&1 || true diff --git a/tools/test-transition-braking.sh b/tools/test-transition-braking.sh index 09768a0a..922e6cf9 100644 --- a/tools/test-transition-braking.sh +++ b/tools/test-transition-braking.sh @@ -7,17 +7,19 @@ source "$REPO_ROOT/tools/mcc-env.sh" VERSION="${1:-1.21.11-Vanilla}" SESSION="mcc-brake-test" -TEST_ROOT="${TMPDIR:-/tmp}/mcc-debug" -CFG="$TEST_ROOT/MinecraftClient.transition-braking.ini" -LOG="$TEST_ROOT/mcc-transition-braking.log" -INPUT_FILE="$REPO_ROOT/mcc_input.txt" -PREPARE_CFG_SCRIPT="$REPO_ROOT/.skills/mcc-integration-testing/scripts/prepare_offline_mcc_config.sh" -ENSURE_SERVER_SCRIPT="$REPO_ROOT/.skills/mcc-integration-testing/scripts/ensure_offline_server.sh" +USERNAME="CursorBot" -mkdir -p "$TEST_ROOT" +SESSION_ROOT="$(_mcc_session_root "$SESSION")" +LOG="$(_mcc_session_log_file "$SESSION")" + +cleanup() { + mcc-kill --session "$SESSION" >/dev/null 2>&1 || true +} + +trap cleanup EXIT send_mcc() { - echo "$1" >> "$INPUT_FILE" + mcc-cmd --session "$SESSION" "$1" } log_line_count() { @@ -40,10 +42,10 @@ log_since() { wait_for_log() { local pattern="$1" local from_line="${2:-0}" - local timeout="${3:-20}" + local timeout="${3:-30}" for _ in $(seq 1 "$timeout"); do - if log_since "$from_line" | grep -Fq "$pattern"; then + if log_since_clean "$from_line" | grep -Fq "$pattern"; then return 0 fi sleep 1 @@ -55,16 +57,26 @@ wait_for_log() { wait_for_navigation() { local from_line="$1" local timeout="${2:-20}" + local saw_start=0 for _ in $(seq 1 "$timeout"); do local recent - recent="$(log_since "$from_line")" + recent="$(log_since_clean "$from_line")" - if grep -Fq "[PathMgr] Navigation complete!" <<<"$recent"; then + if grep -Fq "[PathMgr] Navigation started" <<<"$recent"; then + saw_start=1 + fi + + if (( saw_start )) && grep -Fq "[PathMgr] Navigation complete!" <<<"$recent"; then return 0 fi - if grep -Eq "\\[PathMgr\\] (Replan failed|Giving up)|\\[PathExec\\] Segment .* FAILED" <<<"$recent"; then + if grep -Eq "\[PathMgr\] (Replan failed|Giving up)" <<<"$recent"; then + echo "$recent" >&2 + return 1 + fi + + if grep -Eq "No path found|\[Navigate\] A\* result: Failed" <<<"$recent"; then echo "$recent" >&2 return 1 fi @@ -73,10 +85,71 @@ wait_for_navigation() { done echo "Timed out waiting for navigation completion" >&2 - log_since "$from_line" >&2 + log_since_clean "$from_line" >&2 return 1 } +log_since_clean() { + log_since "$1" | sed 's/\x1b\[[0-9;]*m//g' +} + +count_replans_since() { + local from_line="$1" + log_since_clean "$from_line" | grep -Ec '\[PathMgr\] Replan #|\[PathExec\] Segment .* FAILED' || true +} + +assert_no_replans_since() { + local from_line="$1" + local count + count="$(count_replans_since "$from_line")" + if [[ "$count" != "0" ]]; then + echo "Expected 0 replans, saw $count" >&2 + log_since_clean "$from_line" >&2 + return 1 + fi +} + +assert_no_partial_since() { + local from_line="$1" + if log_since_clean "$from_line" | grep -Fq "[Navigate] A* result: Partial"; then + echo "Expected full success path, saw partial path" >&2 + log_since_clean "$from_line" >&2 + return 1 + fi +} + +debug_state_snapshot() { + local label="$1" + local from_line + from_line="$(log_line_count)" + send_mcc "debug state" + wait_for_log "Location" "$from_line" 10 + echo "" + echo "=== Debug state: $label ===" + log_since_clean "$from_line" +} + +capture_debug_state_before_route() { + debug_state_snapshot "before route - $1" +} + +capture_debug_state_after_route() { + debug_state_snapshot "after route - $1" +} + +prepare_independent_route() { + local label="$1" + local start_x="$2" + local start_y="$3" + local start_z="$4" + + 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 + wait_for_location_in_block "$start_x" "$start_y" "$start_z" 10 +} + extract_last_location() { local from_line="${1:-0}" @@ -90,7 +163,7 @@ from_line = int(sys.argv[2]) text = log_path.read_text(errors="ignore") text = "\n".join(text.splitlines()[from_line:]) text = re.sub(r"\x1b\[[0-9;]*m", "", text) -matches = re.findall(r"Location\s+([-\d.]+),\s+([-\d.]+),\s+([-\d.]+)", text) +matches = re.findall(r"Location\s+([-,0-9.]+),\s+([-,0-9.]+),\s+([-,0-9.]+)", text) if not matches: raise SystemExit("No Location line found in MCC log") x, y, z = matches[-1] @@ -98,23 +171,22 @@ print(f"{x} {y} {z}") PY } -assert_close() { +assert_inside_goal_block() { local actual_x="$1" local actual_y="$2" local actual_z="$3" local expected_x="$4" local expected_y="$5" local expected_z="$6" - local tolerance="${7:-0.05}" - python3 - <<'PY' "$actual_x" "$actual_y" "$actual_z" "$expected_x" "$expected_y" "$expected_z" "$tolerance" + python3 - <<'PY' "$actual_x" "$actual_y" "$actual_z" "$expected_x" "$expected_y" "$expected_z" import math import sys -ax, ay, az, ex, ey, ez, tol = map(float, sys.argv[1:]) -if abs(ax - ex) > tol or abs(ay - ey) > tol or abs(az - ez) > tol: +ax, ay, az, ex, ey, ez = map(float, sys.argv[1:]) +if math.floor(ax) != int(ex) or math.floor(az) != int(ez) or abs(ay - ey) > 0.05: raise SystemExit( - f"Expected ({ex:.2f}, {ey:.2f}, {ez:.2f}) within {tol:.2f}, got ({ax:.2f}, {ay:.2f}, {az:.2f})" + f"Expected location inside goal block ({int(ex)}, {ey:.2f}, {int(ez)}), got ({ax:.2f}, {ay:.2f}, {az:.2f})" ) PY } @@ -123,71 +195,90 @@ capture_debug_location() { local start_line start_line="$(log_line_count)" send_mcc "debug state" - wait_for_log "Location" "$start_line" 5 + wait_for_log "Location" "$start_line" 10 extract_last_location "$start_line" } +wait_for_location_in_block() { + local expected_x="$1" + local expected_y="$2" + local expected_z="$3" + local timeout="${4:-10}" + + for _ in $(seq 1 "$timeout"); do + local actual_x actual_y actual_z + read -r actual_x actual_y actual_z <<< "$(capture_debug_location)" + if python3 - <<'PY' "$actual_x" "$actual_y" "$actual_z" "$expected_x" "$expected_y" "$expected_z" +import math +import sys + +ax, ay, az, ex, ey, ez = map(float, sys.argv[1:]) +if math.floor(ax) == int(ex) and math.floor(az) == int(ez) and abs(ay - ey) <= 0.05: + raise SystemExit(0) +raise SystemExit(1) +PY + then + return 0 + fi + sleep 1 + done + + echo "Timed out waiting for player to reach start block ($expected_x, $expected_y, $expected_z)" >&2 + return 1 +} + start_mcc() { - bash "$PREPARE_CFG_SCRIPT" "$CFG" "$VERSION" CursorBot >/dev/null - - : > "$INPUT_FILE" - : > "$LOG" - - tmux kill-session -t "$SESSION" 2>/dev/null || true - tmux new-session -d -s "$SESSION" -x 160 -y 50 \ - "cd '$REPO_ROOT' && MCC_FILE_INPUT=1 dotnet run --project MinecraftClient -c Release --no-build -- '$CFG' CursorBot - localhost:25565 > '$LOG' 2>&1; echo '=== MCC EXITED ==='; sleep 600" - - wait_for_log "Server was successfully joined." 0 20 + mcc-kill --session "$SESSION" >/dev/null 2>&1 || true + mkdir -p "$SESSION_ROOT" + mcc-build >/dev/null + mcc-debug -v "$VERSION" --session "$SESSION" --username "$USERNAME" --file-input --no-build --debug-on >/dev/null + wait_for_log "Server was successfully joined." 0 30 send_mcc "debug on" - sleep 1 } run_flat_final_stop() { echo "== Flat final stop ==" mc-rcon "fill 95 79 95 115 79 105 stone" >/dev/null mc-rcon "fill 95 80 95 115 85 105 air" >/dev/null - mc-rcon "tp CursorBot 100.5 80 100.5" >/dev/null - sleep 2 - + prepare_independent_route "Flat final stop" "100.5" "80" "100.5" + capture_debug_state_before_route "Flat final stop" local start_line start_line="$(log_line_count)" send_mcc "goto 103 80 100" wait_for_navigation "$start_line" 20 - sleep 1 + assert_no_partial_since "$start_line" + assert_no_replans_since "$start_line" + capture_debug_state_after_route "Flat final stop" local x y z read -r x y z <<< "$(capture_debug_location)" echo "Final location: $x $y $z" - assert_close "$x" "$y" "$z" "103.50" "80.00" "100.50" + assert_inside_goal_block "$x" "$y" "$z" "103" "80.00" "100" } run_parkour_into_turn() { echo "== Parkour into turn ==" mc-rcon "fill 118 79 108 126 79 112 air" >/dev/null + mc-rcon "fill 118 80 108 126 85 112 air" >/dev/null mc-rcon "setblock 120 79 110 stone" >/dev/null mc-rcon "setblock 123 79 110 stone" >/dev/null mc-rcon "setblock 123 79 111 stone" >/dev/null - mc-rcon "tp CursorBot 120.5 80 110.5" >/dev/null - sleep 2 - + prepare_independent_route "Parkour into turn" "120.5" "80" "110.5" + capture_debug_state_before_route "Parkour into turn" local start_line start_line="$(log_line_count)" send_mcc "goto 123 80 111" wait_for_navigation "$start_line" 20 - sleep 1 + assert_no_partial_since "$start_line" + assert_no_replans_since "$start_line" + capture_debug_state_after_route "Parkour into turn" local x y z read -r x y z <<< "$(capture_debug_location)" echo "Final location: $x $y $z" - assert_close "$x" "$y" "$z" "123.50" "80.00" "111.50" + assert_inside_goal_block "$x" "$y" "$z" "123" "80.00" "111" } -mcc-preflight "$VERSION" >/dev/null -mc-reset-test-env "$VERSION" >/dev/null -bash "$ENSURE_SERVER_SCRIPT" "$VERSION" >/dev/null -mc-start "$VERSION" >/dev/null -mc-wait-ready "$VERSION" 60 >/dev/null -mcc-kill >/dev/null 2>&1 || true start_mcc mc-rcon "difficulty peaceful" >/dev/null 2>&1 || true