pathing: add side-wall theory, full-coverage parkour test suite, and live test fixes

Theory simulator:
- Add 2D side-wall jump physics with yaw sweep for worst-case margin
- Generate sidewall theory cases (flat/ascend/descend, wall_offset 0/1)
- Add momentum-capabilities.json with band compression and max_reach
- Extend models, capabilities, canonical, and renderers for sidewall

Full-coverage parkour test suite (tools/test-parkour.py):
- Derive test matrix from momentum-capabilities.json
- Build linear/neo/ceiling courses via RCON with 7-block clear margin
- Use /goto for pathfinding, parse A* and PathMgr log output
- Stop-at-first-failure per (family, subfamily, dy, ceil, wo) group
- Hierarchical --filter (e.g. linear/flat, ceiling/headhitter/ceil2.5)
- Exclude sidewall from default matrix (identical max_reach to linear)

Pathing execution fixes:
- Align parkour contracts and timing budgets with live test results
- Fix jump-entry yaw snapping for grounded handoffs
- Template helper and sprint jump template refinements

Made-with: Cursor
This commit is contained in:
BruceChen 2026-04-15 17:52:47 +00:00
parent cf8bf349db
commit 418f17b4a1
42 changed files with 56087 additions and 4322 deletions

View file

@ -147,61 +147,6 @@ public sealed class GroundedTemplateConvergenceTests
$"state={state} finalPos={finalPos} vel={physics.DeltaMovement}");
}
[Fact]
public void WalkTemplate_PrepareJump_SnapsYawImmediatelyDuringRunUp()
{
World world = FlatWorldTestBuilder.CreateStoneFloor();
var current = new PathSegment
{
Start = new Location(0.5, 80, 0.5),
End = new Location(1.5, 80, 0.5),
MoveType = MoveType.Traverse,
ExitTransition = PathTransitionType.PrepareJump,
ExitHints = new PathTransitionHints(1, 0, 0.10, double.PositiveInfinity, false, true, true, false, 10),
PreserveSprint = true
};
var next = new PathSegment
{
Start = new Location(1.5, 80, 0.5),
End = new Location(3.5, 80, 0.5),
MoveType = MoveType.Parkour,
ExitTransition = PathTransitionType.FinalStop
};
var template = new WalkTemplate(current, next);
var physics = TemplateSimulationRunner.CreateGroundedPhysics(current.Start, yaw: 90f);
var input = new MovementInput();
TemplateState state = template.Tick(current.Start, physics, input, world);
Assert.Equal(TemplateState.InProgress, state);
Assert.InRange(physics.Yaw, 269.9f, 270.1f);
Assert.True(input.Forward);
Assert.True(input.Sprint);
}
[Fact]
public void WalkTemplate_FinalStop_RetainsSmoothYawOutsideJumpEntry()
{
World world = FlatWorldTestBuilder.CreateStoneFloor();
var segment = new PathSegment
{
Start = new Location(0.5, 80, 0.5),
End = new Location(1.5, 80, 0.5),
MoveType = MoveType.Traverse,
ExitTransition = PathTransitionType.FinalStop
};
var template = new WalkTemplate(segment, null);
var physics = TemplateSimulationRunner.CreateGroundedPhysics(segment.Start, yaw: 90f);
var input = new MovementInput();
TemplateState state = template.Tick(segment.Start, physics, input, world);
Assert.Equal(TemplateState.InProgress, state);
Assert.InRange(physics.Yaw, 124.9f, 125.1f);
}
[Fact]
public void AscendTemplate_DiagonalPrepareJump_WithPlannerHints_CompletesOnRunUpBlock()
{
@ -391,130 +336,6 @@ public sealed class GroundedTemplateConvergenceTests
$"elapsedTicks={elapsedTicks} finalPos={finalPos} vel={physics.DeltaMovement}\n{string.Join('\n', trace)}");
}
[Fact]
public void AscendTemplate_PrepareJump_SnapsYawImmediatelyFromOppositeYaw()
{
World world = FlatWorldTestBuilder.CreateStoneFloor(min: 338, max: 344);
FlatWorldTestBuilder.ClearBox(world, 340, 80, 338, 344, 84, 342);
FlatWorldTestBuilder.FillSolid(world, 341, 80, 339, 341, 80, 341);
FlatWorldTestBuilder.FillSolid(world, 342, 81, 339, 342, 81, 341);
var segment = new PathSegment
{
Start = new Location(340.5, 80, 340.5),
End = new Location(341.5, 81, 340.5),
MoveType = MoveType.Ascend,
ExitTransition = PathTransitionType.PrepareJump,
ExitHints = new PathTransitionHints(1, 0, 0.10, double.PositiveInfinity, false, true, true, false, 10),
PreserveSprint = true
};
var next = new PathSegment
{
Start = new Location(341.5, 81, 340.5),
End = new Location(342.5, 82, 340.5),
MoveType = MoveType.Ascend,
ExitTransition = PathTransitionType.FinalStop
};
var template = new AscendTemplate(segment, next);
var physics = TemplateSimulationRunner.CreateGroundedPhysics(segment.Start, yaw: 90f);
var input = new MovementInput();
TemplateState state = template.Tick(segment.Start, physics, input, world);
Assert.Equal(TemplateState.InProgress, state);
Assert.InRange(physics.Yaw, 269.9f, 270.1f);
Assert.True(input.Forward);
Assert.True(input.Sprint);
Assert.True(input.Jump);
}
[Fact]
public void WalkTemplate_PrepareJump_AtRunUpBlock_CompletesImmediatelyAfterSnapAlignment()
{
World world = FlatWorldTestBuilder.CreateStoneFloor();
var current = new PathSegment
{
Start = new Location(0.5, 80, 0.5),
End = new Location(1.5, 80, 0.5),
MoveType = MoveType.Traverse,
ExitTransition = PathTransitionType.PrepareJump,
ExitHints = new PathTransitionHints(0, 1, 0.10, double.PositiveInfinity, false, true, true, false, 10),
PreserveSprint = true
};
var next = new PathSegment
{
Start = new Location(1.5, 80, 0.5),
End = new Location(1.5, 80, 1.5),
MoveType = MoveType.Parkour,
ExitTransition = PathTransitionType.FinalStop
};
var template = new WalkTemplate(current, next);
var physics = new PlayerPhysics
{
Position = new Vec3d(1.5, 80.0, 0.5),
DeltaMovement = Vec3d.Zero,
OnGround = true,
MovementSpeed = 0.1f,
Yaw = 180f,
Pitch = 0f
};
var input = new MovementInput();
TemplateState state = template.Tick(new Location(1.5, 80, 0.5), physics, input, world);
Assert.Equal(TemplateState.Complete, state);
Assert.InRange(physics.Yaw, -0.1f, 0.1f);
Assert.True(input.Forward);
Assert.True(input.Sprint);
}
[Fact]
public void AscendTemplate_PrepareJump_HandoffTurn_SnapsExitHeadingAndClearsMovementInput()
{
World world = FlatWorldTestBuilder.CreateStoneFloor(min: 338, max: 344);
FlatWorldTestBuilder.ClearBox(world, 340, 80, 338, 344, 84, 342);
FlatWorldTestBuilder.FillSolid(world, 341, 80, 339, 341, 80, 341);
FlatWorldTestBuilder.FillSolid(world, 342, 81, 339, 342, 81, 341);
var segment = new PathSegment
{
Start = new Location(340.5, 80, 340.5),
End = new Location(341.5, 81, 340.5),
MoveType = MoveType.Ascend,
ExitTransition = PathTransitionType.PrepareJump,
ExitHints = new PathTransitionHints(1, 0, 0.10, double.PositiveInfinity, false, true, true, false, 10),
PreserveSprint = true
};
var next = new PathSegment
{
Start = new Location(341.5, 81, 340.5),
End = new Location(342.5, 82, 340.5),
MoveType = MoveType.Ascend,
ExitTransition = PathTransitionType.FinalStop
};
var template = new AscendTemplate(segment, next);
var physics = new PlayerPhysics
{
Position = new Vec3d(341.5, 81.0, 340.5),
DeltaMovement = Vec3d.Zero,
OnGround = true,
MovementSpeed = 0.1f,
Yaw = 180f,
Pitch = 0f
};
var input = new MovementInput();
TemplateState state = template.Tick(new Location(341.5, 81, 340.5), physics, input, world);
Assert.Equal(TemplateState.Complete, state);
Assert.InRange(physics.Yaw, 269.9f, 270.1f);
Assert.False(input.Forward);
Assert.False(input.Sprint);
}
[Fact]
public void AscendTemplate_PrepareJump_CompletesFromOffCenterRunUpState()
{

View file

@ -5,7 +5,6 @@ using MinecraftClient.Pathing.Core;
using MinecraftClient.Pathing.Execution;
using MinecraftClient.Pathing.Execution.Templates;
using MinecraftClient.Pathing.Goals;
using MinecraftClient.Physics;
using Xunit;
namespace MinecraftClient.Tests.Pathing.Execution;
@ -78,52 +77,6 @@ public sealed class LivePathingRegressionTests
});
}
[Fact]
public void PathExecutor_RepeatedSingleGapParkourChain_TwoLongJumps_CompletesWithoutReplan()
{
World world = FlatWorldTestBuilder.CreateStoneFloor(min: 578, max: 590);
FlatWorldTestBuilder.ClearBox(world, 578, 79, 578, 590, 90, 582);
FlatWorldTestBuilder.SetSolid(world, 580, 79, 580);
FlatWorldTestBuilder.SetSolid(world, 582, 79, 580);
FlatWorldTestBuilder.SetSolid(world, 584, 79, 580);
FlatWorldTestBuilder.SetSolid(world, 586, 79, 580);
FlatWorldTestBuilder.SetSolid(world, 588, 79, 580);
var ctx = new CalculationContext(world, allowParkour: true, allowParkourAscend: true);
var finder = new AStarPathFinder();
PathResult result = finder.Calculate(
ctx,
startX: 580,
startY: 80,
startZ: 580,
new GoalBlock(588, 80, 580),
CancellationToken.None,
timeoutMs: 2000);
var debugLogs = new List<string>();
var infoLogs = new List<string>();
var manager = new PathSegmentManager(debugLogs.Add, infoLogs.Add);
var physics = TemplateSimulationRunner.CreateGroundedPhysics(new Location(580.5, 80, 580.5), yaw: 270f);
var input = new MovementInput();
manager.StartNavigation(new GoalBlock(588, 80, 580), result);
for (int tick = 0; tick < 240 && manager.IsNavigating; tick++)
{
input.Reset();
Location pos = new(physics.Position.X, physics.Position.Y, physics.Position.Z);
manager.Tick(pos, physics, input, world);
if (!manager.IsNavigating)
break;
physics.ApplyInput(input);
physics.Tick(world);
}
Assert.True(!manager.IsNavigating && manager.ReplanCount == 0,
$"replanCount={manager.ReplanCount}\ninfo={string.Join('\n', infoLogs)}\ndebug={string.Join('\n', debugLogs)}");
}
[Fact]
public void SprintJumpTemplate_LandingRecoveryIntoTurn_CompletesInsideLandingBlock()
{

View file

@ -60,35 +60,6 @@ public sealed class SprintJumpTemplateScenarioTests
Assert.True(TemplateFootingHelper.IsFootprintInsideTargetBlock(finalPos, segment.End));
}
[Fact]
public void SprintJumpTemplate_Approach_SnapsYawImmediatelyFromOppositeYaw()
{
World world = FlatWorldTestBuilder.CreateStoneFloor(min: 0, max: 16);
FlatWorldTestBuilder.ClearBox(world, 0, 79, 0, 4, 82, 1);
FlatWorldTestBuilder.SetSolid(world, 0, 79, 0);
FlatWorldTestBuilder.SetSolid(world, 2, 79, 0);
var segment = new PathSegment
{
Start = new Location(0.5, 80, 0.5),
End = new Location(2.5, 80, 0.5),
MoveType = MoveType.Parkour,
ExitTransition = PathTransitionType.FinalStop
};
var template = new SprintJumpTemplate(segment, null);
var physics = TemplateSimulationRunner.CreateGroundedPhysics(segment.Start, yaw: 90f);
var input = new MovementInput();
TemplateState state = template.Tick(segment.Start, physics, input, world);
Assert.Equal(TemplateState.InProgress, state);
Assert.InRange(physics.Yaw, 269.9f, 270.1f);
Assert.True(input.Forward);
Assert.True(input.Sprint);
Assert.True(input.Jump);
}
[Fact]
public void SprintJumpTemplate_TwoBlockGap_FinalStop_CompletesFromOppositeYawWithinTwentyTicks()
{

View file

@ -42,26 +42,6 @@ public sealed class MoveParkourTests
Assert.Equal(2, result.DestX);
}
[Fact]
public void Accepts4x1JumpWithoutRearSupport_WhenTakeoffBlockProvidesRunway()
{
World world = FlatWorldTestBuilder.CreateStoneFloor(min: -2, max: 6);
FlatWorldTestBuilder.ClearBox(world, -2, FloorY, -1, 6, FloorY + 4, 1);
FlatWorldTestBuilder.SetSolid(world, 0, FloorY, 0);
FlatWorldTestBuilder.SetSolid(world, 2, FloorY, 0);
FlatWorldTestBuilder.SetSolid(world, 4, FloorY, 0);
var ctx = BuildContext(world);
var move = new MoveParkour(4, 0);
var result = default(MoveResult);
move.Calculate(ctx, 0, FloorY + 1, 0, ref result);
Assert.False(result.IsImpossible);
Assert.Equal(4, result.DestX);
Assert.Equal(0, result.DestZ);
}
[Fact]
public void Rejects2x1WhenAdjacentBlockIsStillWalkable()
{

View file

@ -215,6 +215,19 @@
"y": 80,
"z": 580
},
"endBlock": {
"x": 582,
"y": 80,
"z": 580
}
},
{
"moveType": "Parkour",
"startBlock": {
"x": 582,
"y": 80,
"z": 580
},
"endBlock": {
"x": 584,
"y": 80,
@ -228,6 +241,19 @@
"y": 80,
"z": 580
},
"endBlock": {
"x": 586,
"y": 80,
"z": 580
}
},
{
"moveType": "Parkour",
"startBlock": {
"x": 586,
"y": 80,
"z": 580
},
"endBlock": {
"x": 588,
"y": 80,
@ -344,12 +370,25 @@
}
},
{
"moveType": "Parkour",
"moveType": "Descend",
"startBlock": {
"x": 642,
"y": 81,
"z": 620
},
"endBlock": {
"x": 644,
"y": 80,
"z": 620
}
},
{
"moveType": "Parkour",
"startBlock": {
"x": 644,
"y": 80,
"z": 620
},
"endBlock": {
"x": 646,
"y": 81,
@ -595,6 +634,19 @@
"y": 80,
"z": 380
},
"endBlock": {
"x": 382,
"y": 80,
"z": 380
}
},
{
"moveType": "Parkour",
"startBlock": {
"x": 382,
"y": 80,
"z": 380
},
"endBlock": {
"x": 384,
"y": 80,
@ -608,6 +660,19 @@
"y": 80,
"z": 380
},
"endBlock": {
"x": 386,
"y": 80,
"z": 380
}
},
{
"moveType": "Parkour",
"startBlock": {
"x": 386,
"y": 80,
"z": 380
},
"endBlock": {
"x": 388,
"y": 80,

View file

@ -70,23 +70,23 @@
},
{
"scenarioId": "same-move-descend-staircase",
"expectedTotalTicks": 57,
"maxTotalTicks": 70,
"expectedTotalTicks": 61,
"maxTotalTicks": 74,
"segments": [
{
"moveType": "Descend",
"expectedTicks": 23,
"maxTicks": 28
"expectedTicks": 24,
"maxTicks": 29
},
{
"moveType": "Descend",
"expectedTicks": 23,
"maxTicks": 28
"expectedTicks": 25,
"maxTicks": 30
},
{
"moveType": "Descend",
"expectedTicks": 11,
"maxTicks": 14
"expectedTicks": 12,
"maxTicks": 15
}
]
},
@ -98,18 +98,28 @@
},
{
"scenarioId": "repeated-cardinal-parkour-chain",
"expectedTotalTicks": 37,
"maxTotalTicks": 45,
"expectedTotalTicks": 123,
"maxTotalTicks": 150,
"segments": [
{
"moveType": "Parkour",
"expectedTicks": 18,
"maxTicks": 22
"expectedTicks": 46,
"maxTicks": 56
},
{
"moveType": "Parkour",
"expectedTicks": 19,
"maxTicks": 23
"expectedTicks": 41,
"maxTicks": 50
},
{
"moveType": "Parkour",
"expectedTicks": 20,
"maxTicks": 24
},
{
"moveType": "Parkour",
"expectedTicks": 16,
"maxTicks": 20
}
]
},
@ -159,18 +169,23 @@
},
{
"scenarioId": "vertical-jump-mix",
"expectedTotalTicks": 41,
"maxTotalTicks": 50,
"expectedTotalTicks": 50,
"maxTotalTicks": 62,
"segments": [
{
"moveType": "Parkour",
"expectedTicks": 10,
"maxTicks": 12
"expectedTicks": 13,
"maxTicks": 16
},
{
"moveType": "Descend",
"expectedTicks": 12,
"maxTicks": 15
},
{
"moveType": "Parkour",
"expectedTicks": 18,
"maxTicks": 22
"expectedTicks": 12,
"maxTicks": 15
},
{
"moveType": "Descend",
@ -181,23 +196,23 @@
},
{
"scenarioId": "diagonal-vertical-mix",
"expectedTotalTicks": 38,
"maxTotalTicks": 46,
"expectedTotalTicks": 48,
"maxTotalTicks": 59,
"segments": [
{
"moveType": "Ascend",
"expectedTicks": 10,
"maxTicks": 12
"expectedTicks": 19,
"maxTicks": 23
},
{
"moveType": "Parkour",
"expectedTicks": 14,
"maxTicks": 17
"expectedTicks": 16,
"maxTicks": 20
},
{
"moveType": "Descend",
"expectedTicks": 14,
"maxTicks": 17
"expectedTicks": 13,
"maxTicks": 16
}
]
},
@ -277,25 +292,35 @@
},
{
"scenarioId": "same-move-aligned-parkour-chain",
"expectedTotalTicks": 37,
"maxTotalTicks": 45,
"expectedTotalTicks": 123,
"maxTotalTicks": 150,
"segments": [
{
"moveType": "Parkour",
"expectedTicks": 18,
"maxTicks": 22
"expectedTicks": 46,
"maxTicks": 56
},
{
"moveType": "Parkour",
"expectedTicks": 19,
"maxTicks": 23
"expectedTicks": 41,
"maxTicks": 50
},
{
"moveType": "Parkour",
"expectedTicks": 20,
"maxTicks": 24
},
{
"moveType": "Parkour",
"expectedTicks": 16,
"maxTicks": 20
}
]
},
{
"scenarioId": "mixed-diagonal-ascend-traverse-descend",
"expectedTotalTicks": 76,
"maxTotalTicks": 95,
"expectedTotalTicks": 96,
"maxTotalTicks": 120,
"segments": [
{
"moveType": "Diagonal",
@ -309,13 +334,13 @@
},
{
"moveType": "Ascend",
"expectedTicks": 10,
"maxTicks": 12
"expectedTicks": 32,
"maxTicks": 39
},
{
"moveType": "Ascend",
"expectedTicks": 13,
"maxTicks": 16
"expectedTicks": 11,
"maxTicks": 14
},
{
"moveType": "Traverse",

View file

@ -46,11 +46,8 @@ namespace MinecraftClient.Pathing.Execution.Templates
float targetYaw = TemplateHelper.CalculateYaw(dx, dz);
float targetPitch = TemplateHelper.CalculatePitch(dx, dy, dz);
bool snapYawForJumpCommit = !_initiatedJump && !groundedPrepareJumpHandoff;
physics.Yaw = TemplateHelper.AlignYaw(
physics.Yaw,
targetYaw,
snapYawForJumpCommit ? YawAlignmentMode.Snap : YawAlignmentMode.Smooth);
if (!groundedPrepareJumpHandoff)
physics.Yaw = TemplateHelper.SmoothYaw(physics.Yaw, targetYaw);
physics.Pitch = TemplateHelper.SmoothPitch(physics.Pitch, targetPitch);
float headingPenalty = YawDifference(physics.Yaw, targetYaw);
bool headingReady = headingPenalty <= 8.0;

View file

@ -20,7 +20,7 @@ namespace MinecraftClient.Pathing.Execution.Templates
input.Forward = false;
input.Sprint = false;
input.Back = false;
TemplateHelper.FaceExitHeading(physics, segment, YawAlignmentMode.Snap);
TemplateHelper.FaceExitHeading(physics, segment);
return;
}

View file

@ -31,7 +31,6 @@ namespace MinecraftClient.Pathing.Execution.Templates
private Phase _phase = Phase.Approach;
private bool _leftGround;
private bool _carriedGroundEntry;
private bool _airBrakeLatched;
private const float YawToleranceDeg = 5f;
@ -57,10 +56,7 @@ namespace MinecraftClient.Pathing.Execution.Templates
float targetYaw = TemplateHelper.CalculateYaw(dx, dz);
float targetPitch = TemplateHelper.CalculatePitch(dx, dy, dz);
YawAlignmentMode yawMode = _phase == Phase.Approach
? YawAlignmentMode.Snap
: YawAlignmentMode.Smooth;
physics.Yaw = TemplateHelper.AlignYaw(physics.Yaw, targetYaw, yawMode);
physics.Yaw = TemplateHelper.SmoothYaw(physics.Yaw, targetYaw);
physics.Pitch = TemplateHelper.SmoothPitch(physics.Pitch, targetPitch);
switch (_phase)
@ -139,14 +135,7 @@ namespace MinecraftClient.Pathing.Execution.Templates
&& lookaheadAirBrake
&& !releaseInAir;
if (_segment.ExitTransition == PathTransitionType.FinalStop
&& _horizDist <= 2.5
&& (releaseInAir || pastTarget))
{
_airBrakeLatched = true;
}
if (_airBrakeLatched || releaseInAir || pastTarget)
if (releaseInAir || pastTarget)
{
input.Forward = false;
input.Sprint = false;

View file

@ -4,12 +4,6 @@ using MinecraftClient.Physics;
namespace MinecraftClient.Pathing.Execution.Templates
{
internal enum YawAlignmentMode
{
Smooth,
Snap
}
internal static class TemplateHelper
{
private const double EyeHeight = 1.62;
@ -56,14 +50,6 @@ namespace MinecraftClient.Pathing.Execution.Templates
return result;
}
internal static float AlignYaw(float current, float target, YawAlignmentMode mode, float maxStep = MaxYawStepPerTick)
{
target = NormalizeYaw(target);
return mode == YawAlignmentMode.Snap
? target
: SmoothYaw(current, target, maxStep);
}
/// <summary>
/// Smoothly interpolate pitch toward a target.
/// </summary>
@ -91,18 +77,16 @@ namespace MinecraftClient.Pathing.Execution.Templates
return dx * dx + dz * dz < horizThresholdSq && Math.Abs(dy) < vertThreshold;
}
internal static void FaceSegmentHeading(PlayerPhysics physics, PathSegment segment,
YawAlignmentMode mode = YawAlignmentMode.Smooth)
internal static void FaceSegmentHeading(PlayerPhysics physics, PathSegment segment)
{
float headingYaw = CalculateYaw(segment.HeadingX, segment.HeadingZ);
physics.Yaw = AlignYaw(physics.Yaw, headingYaw, mode);
physics.Yaw = SmoothYaw(physics.Yaw, headingYaw);
}
internal static void FaceExitHeading(PlayerPhysics physics, PathSegment segment,
YawAlignmentMode mode = YawAlignmentMode.Smooth)
internal static void FaceExitHeading(PlayerPhysics physics, PathSegment segment)
{
float headingYaw = GetExitHeadingYaw(segment);
physics.Yaw = AlignYaw(physics.Yaw, headingYaw, mode);
physics.Yaw = SmoothYaw(physics.Yaw, headingYaw);
}
internal static void ApplyDecision(MovementInput input, TransitionBrakingDecision decision)
@ -238,13 +222,6 @@ namespace MinecraftClient.Pathing.Execution.Templates
}
}
private static float NormalizeYaw(float yaw)
{
while (yaw < 0f) yaw += 360f;
while (yaw >= 360f) yaw -= 360f;
return yaw;
}
internal static PlayerPhysics ClonePhysicsForPlanning(PlayerPhysics physics)
{
return new PlayerPhysics

View file

@ -35,17 +35,11 @@ namespace MinecraftClient.Pathing.Execution.Templates
double dx = ExpectedEnd.X - pos.X;
double dz = ExpectedEnd.Z - pos.Z;
double dy = ExpectedEnd.Y - pos.Y;
bool snapYawForJumpEntry = physics.OnGround
&& _segment.ExitTransition == PathTransitionType.PrepareJump
&& _segment.ExitHints.RequireJumpReady;
float targetYaw = TemplateHelper.ShouldBiasTowardExitHeading(pos, _segment)
? TemplateHelper.GetExitHeadingYaw(_segment)
: TemplateHelper.CalculateYaw(dx, dz);
float targetPitch = TemplateHelper.CalculatePitch(dx, dy, dz);
physics.Yaw = TemplateHelper.AlignYaw(
physics.Yaw,
targetYaw,
snapYawForJumpEntry ? YawAlignmentMode.Snap : YawAlignmentMode.Smooth);
physics.Yaw = TemplateHelper.SmoothYaw(physics.Yaw, targetYaw);
physics.Pitch = TemplateHelper.SmoothPitch(physics.Pitch, targetPitch);
GroundedSegmentController.Apply(_segment, _nextSegment, pos, physics, input, world);

View file

@ -15,10 +15,7 @@ internal static class ParkourFeasibility
int yDelta)
{
double horiz = Math.Sqrt(xOffset * xOffset + zOffset * zOffset);
if (yDelta <= 0)
return true;
double threshold = 2.5;
double threshold = yDelta > 0 ? 2.5 : 3.5;
if (horiz < threshold)
return true;

View file

@ -317,8 +317,6 @@ For rejection scenarios, the requirement is stricter:
Residual speed carried from one movement to the next inside a route is expected and must not be normalized away just to satisfy the harness. The route is only considered reliable if that natural speed carry still produces `0 replan`.
Independent live-route cases must reset position, yaw, and pitch to the scenario start state before each run. Cross-case orientation residue is harness noise, not valid pathing difficulty.
## Baritone Reference Notes For Zero-Replan Work
MCC can borrow specific ideas from the local Baritone reference under `ThirdpartyReference/baritone/`, but not its looser success semantics.

View file

@ -1,877 +0,0 @@
# Pathing Execution Regression Fixes Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Remove the current execution-layer regressions exposed by the contract/timing harness so deterministic jump-combo and long-route scenarios complete with `0` replans and within their existing budgets.
**Architecture:** Treat the failures as three runtime bugs, not as harness problems. First tighten parkour landing recovery so chained jumps hand off with the right speed instead of stalling or replan-looping. Second make transition braking and lookahead score the next segment entry contract, so mixed turn/ascend/descend routes stop choosing the wrong carry-or-brake profile. Third harden chained ascends for live-runtime carry states so staircases stop burning extra ticks after each landing. Keep the existing JSON contracts, scenario catalog, and shell harnesses unchanged except for verification.
**Tech Stack:** C# 14 / .NET 10, xUnit, MCC pathing execution templates, `PlayerPhysics`, existing `MinecraftClient.Tests` scenario runner and timing contracts, local `1.21.11-Vanilla` live harness via `tools/mcc-env.sh`.
---
## Scope Check
This plan only covers runtime execution fixes in the existing pathing stack.
Out of scope:
- planner-contract schema changes
- theory-matrix generation changes
- telemetry/report format changes
- new live harness features
- broad planner heuristics refactors
## Current Failure Inventory
Focused xUnit evidence from:
```bash
dotnet test MinecraftClient.Tests/MinecraftClient.Tests.csproj --filter "FullyQualifiedName~Pathing.Execution" -v minimal
```
Current failing families:
- repeated parkour chains do not complete cleanly
- `repeated-cardinal-parkour-chain`: navigation did not complete, `replans=4`
- `repeated-diagonal-parkour-chain`: expected `0` replans, saw `2`
- `obstructed-parkour-l-turns`: navigation did not complete, `replans=1`
- `same-move-aligned-parkour-chain`: navigation did not complete, `replans=4`
- mixed vertical and mixed long routes over-brake or replan unexpectedly
- `vertical-jump-mix`: expected `0` replans, saw `1`
- `diagonal-vertical-mix`: expected `0` replans, saw `1`
- `mixed-traverse-turn-parkour-turn-traverse`: expected `0` replans, saw `1`
- `mixed-traverse-ascend-parkour-descend`: expected `0` replans, saw `1`
- `speed-carry-repeated-traverse-descend`: expected `0` replans, saw `1`
- `speed-carry-repeated-traverse-parkour`: navigation did not complete, `replans=4`
Live harness evidence:
```bash
source tools/mcc-env.sh && bash tools/test-pathing-jump-combos.sh 1.21.11-Vanilla
source tools/mcc-env.sh && bash tools/test-pathing-long-routes.sh 1.21.11-Vanilla
```
Current live failures:
- `same-move-ascend-staircase`: `actual=145 max=68`, first four ascend segments each over by roughly `+22` to `+23` ticks
- `vertical-jump-mix`: `actual=54 max=40`
- repeated parkour chains fail with segment failure followed by replan loops
## Problem Map
1. `MinecraftClient/Pathing/Execution/Templates/SprintJumpTemplate.cs`
- landing recovery is still biased toward “settle fully” behavior
- `pastTarget` release is too blunt for repeated parkour and mixed jump chains
- completion rules do not preserve enough entry speed for immediate follow-up jumps
2. `MinecraftClient/Pathing/Execution/TransitionBrakingPlanner.cs`
- planning still reasons mostly about the current segment
- special-cases landing-recovery turns, but not the broader mixed-route handoff problem
3. `MinecraftClient/Pathing/Execution/TransitionLookaheadEvaluator.cs`
- air and ground scoring ignore too much next-segment intent
- current profiles cannot distinguish “slow down for stable turn entry” from “keep enough speed for the next descend or jump”
4. `MinecraftClient/Pathing/Execution/Templates/AscendTemplate.cs`
- chained ascends do not explicitly separate takeoff, airborne, and landing handoff
- live staircase traces show repeated post-landing delay before the next step starts
5. `MinecraftClient/Pathing/Execution/Templates/GroundedSegmentController.cs`
- grounded completion is strong for final stops, but too conservative for continue-straight ascend handoff
## File Structure
### Production files
- Modify: `MinecraftClient/Pathing/Execution/Templates/SprintJumpTemplate.cs`
- parkour landing recovery completion and in-air release rules
- Modify: `MinecraftClient/Pathing/Execution/TransitionBrakingPlanner.cs`
- next-segment-aware braking decisions
- Modify: `MinecraftClient/Pathing/Execution/TransitionLookaheadEvaluator.cs`
- ground and air profile scoring that considers the next segment contract
- Modify: `MinecraftClient/Pathing/Execution/Templates/AscendTemplate.cs`
- explicit ascend phase handling and faster landing handoff
- Modify: `MinecraftClient/Pathing/Execution/Templates/GroundedSegmentController.cs`
- shared completion rules for continue-straight ascend chaining
### Test files
- Modify: `MinecraftClient.Tests/Pathing/Execution/PathTimingContractTests.cs`
- named regression entry points for representative failing scenarios
- Modify: `MinecraftClient.Tests/Pathing/Execution/SprintJumpTemplateScenarioTests.cs`
- deterministic chained-jump handoff regression
- Modify: `MinecraftClient.Tests/Pathing/Execution/TransitionLookaheadEvaluatorTests.cs`
- ground and air next-segment profile regressions
- Modify: `MinecraftClient.Tests/Pathing/Execution/TransitionBrakingPlannerTests.cs`
- planner decisions for mixed handoff states
- Modify: `MinecraftClient.Tests/Pathing/Execution/GroundedTemplateConvergenceTests.cs`
- chained-ascend convergence regression
### Verification only
- Reuse: `tools/test-pathing-jump-combos.sh`
- Reuse: `tools/test-pathing-long-routes.sh`
---
### Task 1: Stabilize Repeated Parkour Landing Recovery
**Files:**
- Modify: `MinecraftClient.Tests/Pathing/Execution/PathTimingContractTests.cs`
- Modify: `MinecraftClient.Tests/Pathing/Execution/SprintJumpTemplateScenarioTests.cs`
- Modify: `MinecraftClient/Pathing/Execution/Templates/SprintJumpTemplate.cs`
- [ ] **Step 1: Write failing parkour-focused regression tests**
Update `MinecraftClient.Tests/Pathing/Execution/PathTimingContractTests.cs`:
```csharp
using MinecraftClient.Tests.Pathing.Execution.Contracts;
using Xunit;
namespace MinecraftClient.Tests.Pathing.Execution;
public sealed class PathTimingContractTests
{
[Fact]
public void RepeatedCardinalParkourChain_ExecutionStaysWithinBudget() =>
AssertScenarioWithinBudget("repeated-cardinal-parkour-chain");
[Fact]
public void RepeatedDiagonalParkourChain_ExecutionStaysWithinBudget() =>
AssertScenarioWithinBudget("repeated-diagonal-parkour-chain");
private static void AssertScenarioWithinBudget(string scenarioId)
{
PathingExecutionScenario scenario = PathingExecutionScenarioCatalog.Get(scenarioId);
PathingTimingBudget budget = PathingContractStore.LoadFromRepositoryRoot().GetTiming(scenarioId);
PathingScenarioResult result = PathingScenarioRunner.RunAccepted(scenario);
PathingContractAssert.TimingMatches(budget, result);
}
}
```
Update `MinecraftClient.Tests/Pathing/Execution/SprintJumpTemplateScenarioTests.cs`:
```csharp
[Fact]
public void SprintJumpTemplate_LandingRecovery_LeavesEnoughSpeedForNextParkour()
{
World world = FlatWorldTestBuilder.CreateStoneFloor(min: 578, max: 586);
FlatWorldTestBuilder.ClearBox(world, 578, 79, 578, 586, 90, 582);
FlatWorldTestBuilder.SetSolid(world, 580, 79, 580);
FlatWorldTestBuilder.SetSolid(world, 582, 79, 580);
FlatWorldTestBuilder.SetSolid(world, 584, 79, 580);
var current = new PathSegment
{
Start = new Location(580.5, 80, 580.5),
End = new Location(582.5, 80, 580.5),
MoveType = MoveType.Parkour,
ExitTransition = PathTransitionType.LandingRecovery,
ExitHints = new PathTransitionHints(1, 0, 0.12, 0.20, false, true, true, true, 12),
PreserveSprint = true
};
var next = new PathSegment
{
Start = current.End,
End = new Location(584.5, 80, 580.5),
MoveType = MoveType.Parkour,
ExitTransition = PathTransitionType.LandingRecovery,
ExitHints = new PathTransitionHints(1, 0, 0.12, 0.20, false, true, true, true, 12),
PreserveSprint = true
};
var template = new SprintJumpTemplate(current, next);
var physics = TemplateSimulationRunner.CreateGroundedPhysics(current.Start, yaw: 270f);
TemplateState state = TemplateSimulationRunner.Run(template, physics, world, maxTicks: 140, out Location finalPos);
Assert.Equal(TemplateState.Complete, state);
Assert.True(TemplateFootingHelper.IsCenterInsideTargetBlock(finalPos, current.End), $"finalPos={finalPos} vel={physics.DeltaMovement}");
Assert.InRange(physics.DeltaMovement.X, 0.12, 0.30);
}
```
- [ ] **Step 2: Run the focused tests and verify they fail**
Run:
```bash
dotnet test MinecraftClient.Tests/MinecraftClient.Tests.csproj --filter "FullyQualifiedName~Pathing.Execution.PathTimingContractTests.RepeatedCardinalParkourChain_ExecutionStaysWithinBudget|FullyQualifiedName~Pathing.Execution.PathTimingContractTests.RepeatedDiagonalParkourChain_ExecutionStaysWithinBudget|FullyQualifiedName~Pathing.Execution.SprintJumpTemplateScenarioTests.SprintJumpTemplate_LandingRecovery_LeavesEnoughSpeedForNextParkour" -v minimal
```
Expected: FAIL with either `navigation did not complete`, nonzero replans, or residual speed below the handoff minimum.
- [ ] **Step 3: Make `SprintJumpTemplate` preserve jump-ready handoff instead of over-settling**
Update `MinecraftClient/Pathing/Execution/Templates/SprintJumpTemplate.cs`:
```csharp
case Phase.Airborne:
{
if (!physics.OnGround)
_leftGround = true;
bool releaseInAir = ShouldReleaseInAir(pos, physics, world);
bool hardRelease = releaseInAir;
if (_segment.ExitTransition != PathTransitionType.LandingRecovery && IsPastTarget(pos))
hardRelease = true;
if (hardRelease)
{
input.Forward = false;
input.Sprint = false;
}
else
{
input.Forward = true;
input.Sprint = true;
}
if (_leftGround && physics.OnGround)
{
_phase = Phase.Landing;
goto case Phase.Landing;
}
break;
}
case Phase.Landing:
if (ShouldCompleteLandingRecoveryHandoff(pos, physics))
return TemplateState.Complete;
TransitionBrakingDecision decision = TransitionBrakingPlanner.Plan(_segment, _nextSegment, pos, physics, world);
TemplateHelper.ApplyDecision(input, decision);
if (decision.HoldBack)
TemplateHelper.FaceSegmentHeading(physics, _segment);
else if (TemplateHelper.ShouldBiasTowardExitHeading(pos, _segment))
TemplateHelper.FaceExitHeading(physics, _segment);
if (_segment.ExitTransition == PathTransitionType.ContinueStraight
&& horizDistSq < 2.25
&& Math.Abs(dy) < 1.0)
{
return TemplateState.Complete;
}
break;
private bool ShouldCompleteLandingRecoveryHandoff(Location pos, PlayerPhysics physics)
{
if (_segment.ExitTransition != PathTransitionType.LandingRecovery || _nextSegment is null || !physics.OnGround)
return false;
double exitSpeed = TemplateHelper.ProjectHorizontalSpeedAlongHint(physics, _segment);
if (_nextSegment.ExitHints.RequireJumpReady)
{
return TemplateFootingHelper.IsCenterInsideTargetBlock(pos, ExpectedEnd)
&& !TemplateFootingHelper.WillCenterLeaveTargetBlockNextTick(pos, physics, ExpectedEnd)
&& exitSpeed >= _nextSegment.ExitHints.MinExitSpeed;
}
return TemplateFootingHelper.IsCenterInsideSupportStrip(pos, ExpectedEnd, _nextSegment.End)
&& !TemplateFootingHelper.WillCenterLeaveSupportStripNextTick(pos, physics, ExpectedEnd, _nextSegment.End)
&& exitSpeed <= _segment.ExitHints.MaxExitSpeed;
}
```
- [ ] **Step 4: Re-run the parkour-focused tests and then the whole jump-combo contract group**
Run:
```bash
dotnet test MinecraftClient.Tests/MinecraftClient.Tests.csproj --filter "FullyQualifiedName~Pathing.Execution.PathTimingContractTests.RepeatedCardinalParkourChain_ExecutionStaysWithinBudget|FullyQualifiedName~Pathing.Execution.PathTimingContractTests.RepeatedDiagonalParkourChain_ExecutionStaysWithinBudget|FullyQualifiedName~Pathing.Execution.PathTimingContractTests.JumpCombo_ExecutionStaysWithinBudget|FullyQualifiedName~Pathing.Execution.SprintJumpTemplateScenarioTests" -v minimal
```
Expected: PASS for the two named regressions and no new failures in the broader jump-template coverage.
- [ ] **Step 5: Commit the parkour landing recovery fix**
```bash
git add MinecraftClient.Tests/Pathing/Execution/PathTimingContractTests.cs \
MinecraftClient.Tests/Pathing/Execution/SprintJumpTemplateScenarioTests.cs \
MinecraftClient/Pathing/Execution/Templates/SprintJumpTemplate.cs
git commit -m "fix: preserve jump-ready speed through parkour landing recovery"
```
### Task 2: Make Braking And Lookahead Respect The Next Segment Contract
**Files:**
- Modify: `MinecraftClient.Tests/Pathing/Execution/PathTimingContractTests.cs`
- Modify: `MinecraftClient.Tests/Pathing/Execution/TransitionLookaheadEvaluatorTests.cs`
- Modify: `MinecraftClient.Tests/Pathing/Execution/TransitionBrakingPlannerTests.cs`
- Modify: `MinecraftClient/Pathing/Execution/TransitionLookaheadEvaluator.cs`
- Modify: `MinecraftClient/Pathing/Execution/TransitionBrakingPlanner.cs`
- [ ] **Step 1: Add failing mixed-route regression tests**
Update `MinecraftClient.Tests/Pathing/Execution/PathTimingContractTests.cs`:
```csharp
[Fact]
public void MixedTraverseAscendParkourDescend_ExecutionStaysWithinBudget() =>
AssertScenarioWithinBudget("mixed-traverse-ascend-parkour-descend");
[Fact]
public void MixedTraverseTurnParkourTurnTraverse_ExecutionStaysWithinBudget() =>
AssertScenarioWithinBudget("mixed-traverse-turn-parkour-turn-traverse");
[Fact]
public void SpeedCarryRepeatedTraverseDescend_ExecutionStaysWithinBudget() =>
AssertScenarioWithinBudget("speed-carry-repeated-traverse-descend");
```
Update `MinecraftClient.Tests/Pathing/Execution/TransitionLookaheadEvaluatorTests.cs`:
```csharp
[Fact]
public void ChooseGroundProfile_PicksBrake_WhenLandingRecoveryTurnWouldOvershootSupportStrip()
{
World world = FlatWorldTestBuilder.CreateStoneFloor(min: 108, max: 126);
FlatWorldTestBuilder.ClearBox(world, 118, 79, 108, 126, 90, 112);
FlatWorldTestBuilder.SetSolid(world, 120, 79, 110);
FlatWorldTestBuilder.SetSolid(world, 122, 79, 110);
FlatWorldTestBuilder.SetSolid(world, 122, 79, 111);
var current = new PathSegment
{
Start = new Location(120.5, 80, 110.5),
End = new Location(122.5, 80, 110.5),
MoveType = MoveType.Parkour,
ExitTransition = PathTransitionType.LandingRecovery,
ExitHints = new PathTransitionHints(0, 1, 0.0, 0.035, true, true, false, true, 12)
};
var next = new PathSegment
{
Start = current.End,
End = new Location(122.5, 80, 111.5),
MoveType = MoveType.Traverse,
ExitTransition = PathTransitionType.PrepareJump,
ExitHints = new PathTransitionHints(0, 1, 0.12, double.PositiveInfinity, false, true, true, false, 10),
PreserveSprint = true
};
var physics = new PlayerPhysics
{
Position = new Vec3d(122.58, 80.0, 110.68),
DeltaMovement = new Vec3d(0.118, 0.0, 0.018),
OnGround = true,
MovementSpeed = 0.1f,
Yaw = 270f
};
TransitionInputProfile profile = TransitionLookaheadEvaluator.ChooseGroundProfile(
current,
next,
new Location(122.58, 80.0, 110.68),
physics,
world);
Assert.Equal(TransitionInputProfile.Brake, profile);
}
```
Update `MinecraftClient.Tests/Pathing/Execution/TransitionBrakingPlannerTests.cs`:
```csharp
[Fact]
public void Plan_Carries_ForLandingRecovery_WhenNextDescendStillNeedsRunway()
{
World world = FlatWorldTestBuilder.CreateStoneFloor(min: 438, max: 448);
FlatWorldTestBuilder.ClearBox(world, 438, 79, 438, 448, 84, 442);
FlatWorldTestBuilder.SetSolid(world, 440, 79, 440);
FlatWorldTestBuilder.SetSolid(world, 441, 79, 440);
FlatWorldTestBuilder.SetSolid(world, 442, 79, 440);
FlatWorldTestBuilder.SetSolid(world, 443, 80, 440);
FlatWorldTestBuilder.SetSolid(world, 444, 79, 440);
var current = new PathSegment
{
Start = new Location(441.5, 81, 440.5),
End = new Location(443.5, 81, 440.5),
MoveType = MoveType.Parkour,
ExitTransition = PathTransitionType.LandingRecovery,
ExitHints = new PathTransitionHints(1, 0, 0.0, 0.035, true, true, false, true, 12)
};
var next = new PathSegment
{
Start = current.End,
End = new Location(444.5, 80, 440.5),
MoveType = MoveType.Descend,
ExitTransition = PathTransitionType.FinalStop,
ExitHints = new PathTransitionHints(1, 0, 0.0, 0.02, true, true, false, false, 12)
};
var physics = CreatePhysics(0.086, 0.0, onGround: true);
physics.Position = new Vec3d(443.18, 81.0, 440.5);
TransitionBrakingDecision decision = TransitionBrakingPlanner.Plan(
current,
next,
new Location(443.18, 81.0, 440.5),
physics,
world);
Assert.True(decision.HoldForward);
Assert.False(decision.HoldBack);
}
```
- [ ] **Step 2: Run the mixed-route tests and verify they fail**
Run:
```bash
dotnet test MinecraftClient.Tests/MinecraftClient.Tests.csproj --filter "FullyQualifiedName~Pathing.Execution.PathTimingContractTests.MixedTraverseAscendParkourDescend_ExecutionStaysWithinBudget|FullyQualifiedName~Pathing.Execution.PathTimingContractTests.MixedTraverseTurnParkourTurnTraverse_ExecutionStaysWithinBudget|FullyQualifiedName~Pathing.Execution.PathTimingContractTests.SpeedCarryRepeatedTraverseDescend_ExecutionStaysWithinBudget|FullyQualifiedName~Pathing.Execution.TransitionLookaheadEvaluatorTests.ChooseGroundProfile_PicksBrake_WhenLandingRecoveryTurnWouldOvershootSupportStrip|FullyQualifiedName~Pathing.Execution.TransitionBrakingPlannerTests.Plan_Carries_ForLandingRecovery_WhenNextDescendStillNeedsRunway" -v minimal
```
Expected: FAIL because current lookahead and planner logic either brake when the next segment needs carry, or carry when the turn entry should already be slowing down.
- [ ] **Step 3: Thread `nextSegment` through lookahead scoring and braking decisions**
Update `MinecraftClient/Pathing/Execution/TransitionLookaheadEvaluator.cs`:
```csharp
public static TransitionInputProfile ChooseGroundProfile(PathSegment current, PathSegment? next, Location pos, PlayerPhysics physics, World world)
{
double remaining = TemplateHelper.RemainingDistanceAlongSegment(pos, current);
double forwardSpeed = Math.Max(0.0,
TemplateHelper.ProjectHorizontalSpeedAlongHeading(physics, current.HeadingX, current.HeadingZ));
bool requiresJumpEntry = current.ExitHints.RequireJumpReady
|| current.ExitTransition == PathTransitionType.PrepareJump;
if (current.ExitTransition == PathTransitionType.ContinueStraight && !requiresJumpEntry)
return TransitionInputProfile.Carry;
if (requiresJumpEntry)
return TransitionInputProfile.Carry;
if (next is not null && current.ExitTransition == PathTransitionType.LandingRecovery)
{
bool headingChange = current.HeadingX != next.HeadingX || current.HeadingZ != next.HeadingZ;
if (headingChange && forwardSpeed > GetTargetMaxExitSpeed(current))
return TransitionInputProfile.Brake;
if (next.ExitHints.RequireJumpReady && forwardSpeed < next.ExitHints.MinExitSpeed)
return TransitionInputProfile.Carry;
}
bool requiresSlowEntry = current.ExitHints.RequireStableFooting
|| current.ExitTransition is PathTransitionType.FinalStop or PathTransitionType.Turn
|| (current.ExitTransition == PathTransitionType.LandingRecovery
&& (current.ExitHints.AllowAirBrake || IsFiniteSpeedCap(current)));
if (!requiresSlowEntry)
return TransitionInputProfile.Carry;
double maxExitSpeed = GetTargetMaxExitSpeed(current);
double hardBrakeDistance = TransitionBrakingPlanner.EstimateGroundStopDistance(
physics, world, current.HeadingX, current.HeadingZ, applyBackBrake: true);
double coastStopDistance = TransitionBrakingPlanner.EstimateGroundStopDistance(
physics, world, current.HeadingX, current.HeadingZ, applyBackBrake: false);
if (remaining < 0.0)
return TransitionInputProfile.Brake;
if (forwardSpeed > maxExitSpeed && remaining <= hardBrakeDistance + 0.10)
return TransitionInputProfile.Brake;
if (forwardSpeed <= maxExitSpeed && remaining > 0.0)
return TransitionInputProfile.Carry;
if (remaining <= coastStopDistance + 0.06)
return TransitionInputProfile.Coast;
return TransitionInputProfile.Carry;
}
public static TransitionInputProfile ChooseAirProfile(PathSegment current, PathSegment? next, Location pos, PlayerPhysics physics, World world)
{
if (!current.ExitHints.AllowAirBrake)
return TransitionInputProfile.AirHoldForward;
TransitionInputProfile[] candidates =
[
TransitionInputProfile.AirHoldForward,
TransitionInputProfile.AirRelease,
TransitionInputProfile.AirBrake
];
return ChooseBest(current, next, pos, physics, world, candidates);
}
private static TransitionInputProfile ChooseBest(PathSegment segment, PathSegment? next, Location pos, PlayerPhysics physics, World world,
TransitionInputProfile[] candidates)
{
TransitionInputProfile best = candidates[0];
double bestScore = double.PositiveInfinity;
foreach (TransitionInputProfile candidate in candidates)
{
double score = Score(segment, next, pos, physics, world, candidate);
if (score < bestScore)
{
best = candidate;
bestScore = score;
}
}
return best;
}
private static double Score(PathSegment segment, PathSegment? next, Location pos, PlayerPhysics physics, World world, TransitionInputProfile candidate)
{
PlayerPhysics sim = TemplateHelper.ClonePhysicsForPlanning(physics);
sim.Position = new Vec3d(pos.X, pos.Y, pos.Z);
var input = new MovementInput();
Location simPos = pos;
for (int tick = 0; tick < segment.ExitHints.HorizonTicks; tick++)
{
if (TemplateHelper.ShouldBiasTowardExitHeading(simPos, segment))
TemplateHelper.FaceExitHeading(sim, segment);
input.Reset();
ApplyCandidateInput(input, candidate, segment);
sim.ApplyInput(input);
sim.Tick(world);
simPos = new Location(sim.Position.X, sim.Position.Y, sim.Position.Z);
}
double score = ScoreNextSegmentEntry(segment, next, simPos, sim);
score += TemplateHelper.HeadingPenaltyDegrees(sim.Yaw, segment);
score += Math.Abs(TemplateHelper.RemainingDistanceAlongSegment(simPos, segment)) * 10.0;
return score;
}
private static double ScoreNextSegmentEntry(PathSegment current, PathSegment? next, Location simPos, PlayerPhysics sim)
{
if (next is null)
return 0.0;
double score = 0.0;
if (current.ExitTransition == PathTransitionType.LandingRecovery
&& (current.HeadingX != next.HeadingX || current.HeadingZ != next.HeadingZ)
&& !TemplateFootingHelper.IsCenterInsideSupportStrip(simPos, current.End, next.End))
{
score += 1200.0;
}
if (next.ExitHints.RequireJumpReady)
{
double nextSpeed = TemplateHelper.ProjectHorizontalSpeedAlongHeading(sim, next.HeadingX, next.HeadingZ);
if (nextSpeed < next.ExitHints.MinExitSpeed)
score += (next.ExitHints.MinExitSpeed - nextSpeed) * 600.0;
}
return score;
}
```
Update `MinecraftClient/Pathing/Execution/TransitionBrakingPlanner.cs`:
```csharp
TransitionInputProfile profile;
if (physics.OnGround)
{
profile = TransitionLookaheadEvaluator.ChooseGroundProfile(current, next, pos, physics, world);
}
else
{
if (!current.ExitHints.AllowAirBrake)
return TransitionBrakingDecision.CarryMomentum(current.PreserveSprint);
profile = TransitionLookaheadEvaluator.ChooseAirProfile(current, next, pos, physics, world);
}
return profile switch
{
TransitionInputProfile.Carry => TransitionBrakingDecision.CarryMomentum(current.PreserveSprint || next?.ExitHints.RequireJumpReady == true),
TransitionInputProfile.Coast => TransitionBrakingDecision.Coast,
TransitionInputProfile.Brake => TransitionBrakingDecision.Brake,
TransitionInputProfile.AirHoldForward => TransitionBrakingDecision.CarryMomentum(current.PreserveSprint || next?.ExitHints.RequireJumpReady == true),
TransitionInputProfile.AirRelease => TransitionBrakingDecision.Coast,
TransitionInputProfile.AirBrake => TransitionBrakingDecision.Brake,
_ => TransitionBrakingDecision.Coast
};
```
- [ ] **Step 4: Re-run focused mixed-route tests and the broader long-route contract group**
Run:
```bash
dotnet test MinecraftClient.Tests/MinecraftClient.Tests.csproj --filter "FullyQualifiedName~Pathing.Execution.PathTimingContractTests.MixedTraverseAscendParkourDescend_ExecutionStaysWithinBudget|FullyQualifiedName~Pathing.Execution.PathTimingContractTests.MixedTraverseTurnParkourTurnTraverse_ExecutionStaysWithinBudget|FullyQualifiedName~Pathing.Execution.PathTimingContractTests.SpeedCarryRepeatedTraverseDescend_ExecutionStaysWithinBudget|FullyQualifiedName~Pathing.Execution.PathTimingContractTests.LongRoute_ExecutionStaysWithinBudget|FullyQualifiedName~Pathing.Execution.TransitionLookaheadEvaluatorTests|FullyQualifiedName~Pathing.Execution.TransitionBrakingPlannerTests" -v minimal
```
Expected: PASS for the new explicit regressions and no new failures in the broader lookahead/braking coverage.
- [ ] **Step 5: Commit the mixed-route braking fix**
```bash
git add MinecraftClient.Tests/Pathing/Execution/PathTimingContractTests.cs \
MinecraftClient.Tests/Pathing/Execution/TransitionLookaheadEvaluatorTests.cs \
MinecraftClient.Tests/Pathing/Execution/TransitionBrakingPlannerTests.cs \
MinecraftClient/Pathing/Execution/TransitionLookaheadEvaluator.cs \
MinecraftClient/Pathing/Execution/TransitionBrakingPlanner.cs
git commit -m "fix: align transition lookahead with next segment entry"
```
### Task 3: Remove Chained-Ascend Landing Stall In Live Staircases
**Files:**
- Modify: `MinecraftClient.Tests/Pathing/Execution/GroundedTemplateConvergenceTests.cs`
- Modify: `MinecraftClient/Pathing/Execution/Templates/AscendTemplate.cs`
- Modify: `MinecraftClient/Pathing/Execution/Templates/GroundedSegmentController.cs`
- [ ] **Step 1: Add a failing chained-ascend convergence test**
Update `MinecraftClient.Tests/Pathing/Execution/GroundedTemplateConvergenceTests.cs`:
```csharp
[Fact]
public void AscendTemplate_ContinueStraight_CompletesWithoutSettlingToZeroSpeed()
{
World world = FlatWorldTestBuilder.CreateStoneFloor(min: 338, max: 347);
FlatWorldTestBuilder.ClearBox(world, 340, 80, 338, 347, 86, 342);
FlatWorldTestBuilder.FillSolid(world, 341, 80, 339, 341, 80, 341);
FlatWorldTestBuilder.FillSolid(world, 342, 81, 339, 342, 81, 341);
FlatWorldTestBuilder.FillSolid(world, 343, 82, 339, 343, 82, 341);
var current = new PathSegment
{
Start = new Location(341.5, 81, 340.5),
End = new Location(342.5, 82, 340.5),
MoveType = MoveType.Ascend,
ExitTransition = PathTransitionType.ContinueStraight,
ExitHints = new PathTransitionHints(1, 0, 0.08, double.PositiveInfinity, false, true, false, false, 8),
PreserveSprint = true
};
var next = new PathSegment
{
Start = current.End,
End = new Location(343.5, 83, 340.5),
MoveType = MoveType.Ascend,
ExitTransition = PathTransitionType.ContinueStraight,
ExitHints = new PathTransitionHints(1, 0, 0.08, double.PositiveInfinity, false, true, false, false, 8),
PreserveSprint = true
};
var template = new AscendTemplate(current, next);
var physics = new PlayerPhysics
{
Position = new Vec3d(current.Start.X, current.Start.Y, current.Start.Z),
DeltaMovement = new Vec3d(0.11, 0.0, 0.0),
OnGround = true,
MovementSpeed = 0.1f,
Yaw = 270f,
Pitch = 0f
};
var input = new MovementInput();
TemplateState state = TemplateState.InProgress;
int ticks = 0;
for (; ticks < 30; ticks++)
{
input.Reset();
Location pos = new(physics.Position.X, physics.Position.Y, physics.Position.Z);
state = template.Tick(pos, physics, input, world);
if (state != TemplateState.InProgress)
break;
physics.ApplyInput(input);
physics.Tick(world);
}
Assert.Equal(TemplateState.Complete, state);
Assert.InRange(ticks, 1, 14);
Assert.InRange(physics.DeltaMovement.X, 0.05, 0.20);
}
```
- [ ] **Step 2: Run the new unit test and the live long-route harness to confirm current failure**
Run:
```bash
dotnet test MinecraftClient.Tests/MinecraftClient.Tests.csproj --filter FullyQualifiedName~Pathing.Execution.GroundedTemplateConvergenceTests.AscendTemplate_ContinueStraight_CompletesWithoutSettlingToZeroSpeed -v minimal
source tools/mcc-env.sh && bash tools/test-pathing-long-routes.sh 1.21.11-Vanilla
```
Expected: the unit test fails on tick count or residual speed, and the live harness still reports `same-move-ascend-staircase` over budget.
- [ ] **Step 3: Split ascend execution into takeoff, airborne, and landing handoff**
Update `MinecraftClient/Pathing/Execution/Templates/AscendTemplate.cs`:
```csharp
private enum Phase { Takeoff, Airborne, Landing }
private Phase _phase = Phase.Takeoff;
private bool _leftGround;
private int _landingTicks;
public TemplateState Tick(Location pos, PlayerPhysics physics, MovementInput input, World world)
{
_tickCount++;
double dx = ExpectedEnd.X - pos.X;
double dz = ExpectedEnd.Z - pos.Z;
double dy = ExpectedEnd.Y - pos.Y;
float targetYaw = TemplateHelper.CalculateYaw(dx, dz);
float targetPitch = TemplateHelper.CalculatePitch(dx, dy, dz);
physics.Yaw = TemplateHelper.SmoothYaw(physics.Yaw, targetYaw);
physics.Pitch = TemplateHelper.SmoothPitch(physics.Pitch, targetPitch);
switch (_phase)
{
case Phase.Takeoff:
input.Forward = true;
input.Sprint = true;
if (physics.OnGround && dy > 0.1)
{
input.Jump = true;
_phase = Phase.Airborne;
}
break;
case Phase.Airborne:
input.Forward = true;
input.Sprint = true;
if (!physics.OnGround)
_leftGround = true;
if (_leftGround && physics.OnGround)
{
_phase = Phase.Landing;
_landingTicks = 0;
goto case Phase.Landing;
}
break;
case Phase.Landing:
_landingTicks++;
GroundedSegmentController.Apply(_segment, _nextSegment, pos, physics, input, world);
if (GroundedSegmentController.ShouldComplete(_segment, pos, physics))
return TemplateState.Complete;
break;
}
if (_stuckTicks > 20 || _tickCount > 50)
return TemplateState.Failed;
return TemplateState.InProgress;
}
```
Update `MinecraftClient/Pathing/Execution/Templates/GroundedSegmentController.cs`:
```csharp
if (segment.MoveType == MoveType.Ascend
&& segment.ExitTransition == PathTransitionType.ContinueStraight
&& physics.OnGround
&& TemplateFootingHelper.IsFootprintInsideTargetBlock(pos, segment.End)
&& !TemplateFootingHelper.WillLeaveTargetBlockNextTick(pos, physics, segment.End))
{
double exitSpeed = TemplateHelper.ProjectHorizontalSpeedAlongHint(physics, segment);
return exitSpeed >= Math.Max(0.02, segment.ExitHints.MinExitSpeed);
}
```
- [ ] **Step 4: Re-run the ascend convergence test and the live long-route harness**
Run:
```bash
dotnet test MinecraftClient.Tests/MinecraftClient.Tests.csproj --filter "FullyQualifiedName~Pathing.Execution.GroundedTemplateConvergenceTests.AscendTemplate_ContinueStraight_CompletesWithoutSettlingToZeroSpeed|FullyQualifiedName~Pathing.Execution.PathTimingContractTests.Scenario_ExecutionStaysWithinTimingBudget" -v minimal
source tools/mcc-env.sh && bash tools/test-pathing-long-routes.sh 1.21.11-Vanilla
```
Expected: PASS for the new unit test and the live long-route suite, including `same-move-ascend-staircase`.
- [ ] **Step 5: Commit the ascend convergence fix**
```bash
git add MinecraftClient.Tests/Pathing/Execution/GroundedTemplateConvergenceTests.cs \
MinecraftClient/Pathing/Execution/Templates/AscendTemplate.cs \
MinecraftClient/Pathing/Execution/Templates/GroundedSegmentController.cs
git commit -m "fix: reduce chained ascend landing stalls"
```
### Task 4: Run The Full Regression Sweep And Stop On Any Residual Family
**Files:**
- No code changes required unless verification reveals a new, scoped defect
- [ ] **Step 1: Re-run all focused pathing execution tests**
Run:
```bash
dotnet test MinecraftClient.Tests/MinecraftClient.Tests.csproj --filter "FullyQualifiedName~Pathing.Execution" -v minimal
```
Expected: PASS with `0` failing pathing execution tests.
- [ ] **Step 2: Re-run the live accepted-route suites that previously failed**
Run:
```bash
source tools/mcc-env.sh && bash tools/test-pathing-jump-combos.sh 1.21.11-Vanilla
source tools/mcc-env.sh && bash tools/test-pathing-long-routes.sh 1.21.11-Vanilla
```
Expected: both scripts exit `0`, with no accepted-route replans and no contract-budget overruns.
- [ ] **Step 3: If any live case still fails, capture the exact family before doing more coding**
Use the existing contract report output already printed by the harnesses. Record:
```text
scenario id
total actual / max ticks
which segment index exceeded
whether the failure was replan, timeout, or budget overrun
```
Do not widen scope beyond:
- parkour landing recovery
- next-segment braking/lookahead
- chained ascend landing handoff
- [ ] **Step 4: End the plan cleanly once verification is green**
Run:
```bash
git status --short
```
Expected: only the intentional runtime/test edits from Tasks 1 through 3 remain. If verification is green and no extra follow-up patch was needed, do not create an empty commit. If verification exposes a new defect family, stop and write a separate scoped plan instead of slipping extra repair work into this one.
## Self-Review
Spec coverage check:
- repeated parkour failures map to Task 1
- mixed-route carry/brake failures map to Task 2
- live staircase ascend overrun maps to Task 3
- full xUnit and live verification maps to Task 4
Placeholder scan:
- no `TODO`, `TBD`, or “similar to above” placeholders remain
- each task includes concrete file paths, test code, commands, and commit steps
Type consistency:
- all next-segment-aware changes consistently use `PathSegment? next`
- named test helpers use `AssertScenarioWithinBudget`
- runtime fixes stay inside the already failing execution files

View file

@ -1,520 +0,0 @@
# Jump-Entry Direct Yaw Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Remove unnecessary yaw smoothing in jump-entry states so opposite-yaw jump starts commit immediately without changing normal walk, descend, climb, or final-stop behavior.
**Architecture:** Introduce a small helper-level yaw alignment policy, then opt in only the jump-entry states: sprint-jump approach, ascend pre-jump alignment, grounded prepare-jump freeze, and grounded walk segments that are explicitly preparing a jump. Keep air control, grounded braking, descend, climb, and ordinary walk/final-stop behavior on smooth yaw, and prove the scope boundary with focused unit tests plus sequential live harness runs.
**Tech Stack:** C# 14, .NET 10, xUnit, MCC local harness scripts (`tools/mcc-env.sh`, `mcc-preflight`, `tools/test-pathing-jump-combos.sh`, `tools/test-pathing-long-routes.sh`)
---
## File Map
- Modify: `MinecraftClient/Pathing/Execution/Templates/TemplateHelper.cs`
- Add a small yaw-alignment helper and heading-facing overloads so templates can request `Smooth` or `Snap` without open-coding raw yaw assignment.
- Modify: `MinecraftClient/Pathing/Execution/Templates/SprintJumpTemplate.cs`
- Snap yaw only during `Phase.Approach`; keep air and landing phases on smooth yaw.
- Modify: `MinecraftClient/Pathing/Execution/Templates/AscendTemplate.cs`
- Snap yaw only while aligning for jump commitment; preserve the existing grounded prepare-jump handoff carveout.
- Modify: `MinecraftClient/Pathing/Execution/Templates/GroundedSegmentController.cs`
- Snap exit heading in the frozen `PrepareJump` turn branch only.
- Modify: `MinecraftClient/Pathing/Execution/Templates/WalkTemplate.cs`
- Use snap yaw only for grounded `PrepareJump` segments with `ExitHints.RequireJumpReady == true`.
- Modify: `MinecraftClient.Tests/Pathing/Execution/SprintJumpTemplateScenarioTests.cs`
- Add a focused regression that proves sprint-jump approach snaps immediately from opposite yaw.
- Modify: `MinecraftClient.Tests/Pathing/Execution/GroundedTemplateConvergenceTests.cs`
- Add focused regressions for ascend pre-jump snap, walk run-up snap, grounded freeze snap, and ordinary final-stop smoothness.
### Task 1: Add Failing Sprint-Jump Snap Test
**Files:**
- Modify: `MinecraftClient.Tests/Pathing/Execution/SprintJumpTemplateScenarioTests.cs`
- Test: `MinecraftClient.Tests/Pathing/Execution/SprintJumpTemplateScenarioTests.cs`
- [ ] **Step 1: Write the failing test**
Add this test near the existing opposite-yaw sprint-jump regressions:
```csharp
[Fact]
public void SprintJumpTemplate_Approach_SnapsYawImmediatelyFromOppositeYaw()
{
World world = FlatWorldTestBuilder.CreateStoneFloor(min: 0, max: 16);
FlatWorldTestBuilder.ClearBox(world, 0, 79, 0, 4, 82, 1);
FlatWorldTestBuilder.SetSolid(world, 0, 79, 0);
FlatWorldTestBuilder.SetSolid(world, 2, 79, 0);
var segment = new PathSegment
{
Start = new Location(0.5, 80, 0.5),
End = new Location(2.5, 80, 0.5),
MoveType = MoveType.Parkour,
ExitTransition = PathTransitionType.FinalStop
};
var template = new SprintJumpTemplate(segment, null);
var physics = TemplateSimulationRunner.CreateGroundedPhysics(segment.Start, yaw: 90f);
var input = new MovementInput();
TemplateState state = template.Tick(segment.Start, physics, input, world);
Assert.Equal(TemplateState.InProgress, state);
Assert.InRange(physics.Yaw, 269.9f, 270.1f);
Assert.True(input.Forward);
Assert.True(input.Sprint);
Assert.True(input.Jump);
}
```
- [ ] **Step 2: Run test to verify it fails**
Run:
```bash
dotnet test MinecraftClient.Tests/MinecraftClient.Tests.csproj --filter "FullyQualifiedName~SprintJumpTemplate_Approach_SnapsYawImmediatelyFromOppositeYaw" -v minimal
```
Expected:
- `FAIL`
- The failure should show `physics.Yaw` still near `125` and movement input still blocked by the turn-in-place gate.
- [ ] **Step 3: Write minimal implementation**
In `MinecraftClient/Pathing/Execution/Templates/TemplateHelper.cs`, add the alignment helper and overloads:
```csharp
internal enum YawAlignmentMode
{
Smooth,
Snap
}
internal static float AlignYaw(float current, float target, YawAlignmentMode mode, float maxStep = MaxYawStepPerTick)
{
target = NormalizeYaw(target);
return mode == YawAlignmentMode.Snap
? target
: SmoothYaw(current, target, maxStep);
}
internal static void FaceSegmentHeading(PlayerPhysics physics, PathSegment segment, YawAlignmentMode mode = YawAlignmentMode.Smooth)
{
float headingYaw = CalculateYaw(segment.HeadingX, segment.HeadingZ);
physics.Yaw = AlignYaw(physics.Yaw, headingYaw, mode);
}
internal static void FaceExitHeading(PlayerPhysics physics, PathSegment segment, YawAlignmentMode mode = YawAlignmentMode.Smooth)
{
float headingYaw = GetExitHeadingYaw(segment);
physics.Yaw = AlignYaw(physics.Yaw, headingYaw, mode);
}
private static float NormalizeYaw(float yaw)
{
while (yaw < 0f) yaw += 360f;
while (yaw >= 360f) yaw -= 360f;
return yaw;
}
```
In `MinecraftClient/Pathing/Execution/Templates/SprintJumpTemplate.cs`, switch only `Phase.Approach` to snap yaw:
```csharp
YawAlignmentMode yawMode = _phase == Phase.Approach
? YawAlignmentMode.Snap
: YawAlignmentMode.Smooth;
physics.Yaw = TemplateHelper.AlignYaw(physics.Yaw, targetYaw, yawMode);
physics.Pitch = TemplateHelper.SmoothPitch(physics.Pitch, targetPitch);
```
- [ ] **Step 4: Run tests to verify they pass**
Run:
```bash
dotnet test MinecraftClient.Tests/MinecraftClient.Tests.csproj --filter "FullyQualifiedName~SprintJumpTemplate_Approach_SnapsYawImmediatelyFromOppositeYaw|FullyQualifiedName~SprintJumpTemplate_TwoBlockGap_FinalStop_CompletesFromOppositeYawWithinTwentyTicks|FullyQualifiedName~SprintJumpTemplate_ThreeBlockGap_FinalStop_Completes" -v minimal
```
Expected:
- `PASS`
- The new test passes.
- The existing opposite-yaw timing regression stays green.
- The 3-block final-stop sprint jump still completes.
- [ ] **Step 5: Commit**
```bash
git add \
MinecraftClient/Pathing/Execution/Templates/TemplateHelper.cs \
MinecraftClient/Pathing/Execution/Templates/SprintJumpTemplate.cs \
MinecraftClient.Tests/Pathing/Execution/SprintJumpTemplateScenarioTests.cs
git commit -m "pathing: snap yaw for sprint jump approach"
```
### Task 2: Add Failing Ascend And Frozen Prepare-Jump Snap Tests
**Files:**
- Modify: `MinecraftClient.Tests/Pathing/Execution/GroundedTemplateConvergenceTests.cs`
- Modify: `MinecraftClient/Pathing/Execution/Templates/AscendTemplate.cs`
- Modify: `MinecraftClient/Pathing/Execution/Templates/GroundedSegmentController.cs`
- Test: `MinecraftClient.Tests/Pathing/Execution/GroundedTemplateConvergenceTests.cs`
- [ ] **Step 1: Write the failing tests**
Add these tests to `MinecraftClient.Tests/Pathing/Execution/GroundedTemplateConvergenceTests.cs` near the existing prepare-jump regressions:
```csharp
[Fact]
public void AscendTemplate_PrepareJump_SnapsYawImmediatelyFromOppositeYaw()
{
World world = FlatWorldTestBuilder.CreateStoneFloor(min: 338, max: 344);
FlatWorldTestBuilder.ClearBox(world, 340, 80, 338, 344, 84, 342);
FlatWorldTestBuilder.FillSolid(world, 341, 80, 339, 341, 80, 341);
FlatWorldTestBuilder.FillSolid(world, 342, 81, 339, 342, 81, 341);
var segment = new PathSegment
{
Start = new Location(340.5, 80, 340.5),
End = new Location(341.5, 81, 340.5),
MoveType = MoveType.Ascend,
ExitTransition = PathTransitionType.PrepareJump,
ExitHints = new PathTransitionHints(1, 0, 0.10, double.PositiveInfinity, false, true, true, false, 10),
PreserveSprint = true
};
var next = new PathSegment
{
Start = new Location(341.5, 81, 340.5),
End = new Location(342.5, 82, 340.5),
MoveType = MoveType.Ascend,
ExitTransition = PathTransitionType.FinalStop
};
var template = new AscendTemplate(segment, next);
var physics = TemplateSimulationRunner.CreateGroundedPhysics(segment.Start, yaw: 90f);
var input = new MovementInput();
TemplateState state = template.Tick(segment.Start, physics, input, world);
Assert.Equal(TemplateState.InProgress, state);
Assert.InRange(physics.Yaw, 269.9f, 270.1f);
Assert.True(input.Forward);
Assert.True(input.Sprint);
Assert.True(input.Jump);
}
[Fact]
public void WalkTemplate_PrepareJump_FreezeForTurn_SnapsExitHeadingImmediately()
{
World world = FlatWorldTestBuilder.CreateStoneFloor();
var current = new PathSegment
{
Start = new Location(0.5, 80, 0.5),
End = new Location(1.5, 80, 0.5),
MoveType = MoveType.Traverse,
ExitTransition = PathTransitionType.PrepareJump,
ExitHints = new PathTransitionHints(0, 1, 0.10, double.PositiveInfinity, false, true, true, false, 10),
PreserveSprint = true
};
var next = new PathSegment
{
Start = new Location(1.5, 80, 0.5),
End = new Location(1.5, 80, 1.5),
MoveType = MoveType.Parkour,
ExitTransition = PathTransitionType.FinalStop
};
var template = new WalkTemplate(current, next);
var physics = new PlayerPhysics
{
Position = new Vec3d(1.5, 80.0, 0.5),
DeltaMovement = Vec3d.Zero,
OnGround = true,
MovementSpeed = 0.1f,
Yaw = 180f,
Pitch = 0f
};
var input = new MovementInput();
TemplateState state = template.Tick(new Location(1.5, 80, 0.5), physics, input, world);
Assert.Equal(TemplateState.InProgress, state);
Assert.InRange(physics.Yaw, -0.1f, 0.1f);
Assert.False(input.Forward);
Assert.False(input.Sprint);
Assert.False(input.Back);
}
```
- [ ] **Step 2: Run tests to verify they fail**
Run:
```bash
dotnet test MinecraftClient.Tests/MinecraftClient.Tests.csproj --filter "FullyQualifiedName~AscendTemplate_PrepareJump_SnapsYawImmediatelyFromOppositeYaw|FullyQualifiedName~WalkTemplate_PrepareJump_FreezeForTurn_SnapsExitHeadingImmediately" -v minimal
```
Expected:
- `FAIL`
- The ascend test should show yaw still part-way through the turn.
- The frozen prepare-jump test should show yaw still around `145` instead of `0`.
- [ ] **Step 3: Write minimal implementation**
In `MinecraftClient/Pathing/Execution/Templates/AscendTemplate.cs`, snap yaw only before jump commitment and keep the handoff carveout:
```csharp
bool snapYawForJumpCommit = !_initiatedJump && !groundedPrepareJumpHandoff;
physics.Yaw = TemplateHelper.AlignYaw(
physics.Yaw,
targetYaw,
snapYawForJumpCommit ? YawAlignmentMode.Snap : YawAlignmentMode.Smooth);
physics.Pitch = TemplateHelper.SmoothPitch(physics.Pitch, targetPitch);
```
In `MinecraftClient/Pathing/Execution/Templates/GroundedSegmentController.cs`, snap the frozen exit-heading turn:
```csharp
if (segment.ExitTransition == PathTransitionType.PrepareJump
&& segment.ExitHints.RequireJumpReady
&& physics.OnGround
&& TemplateFootingHelper.IsCenterInsideTargetBlock(pos, segment.End)
&& IsReadyToFreezeForTurn(segment, pos)
&& TemplateHelper.HeadingPenaltyDegrees(physics.Yaw, segment) > 8.0)
{
input.Forward = false;
input.Sprint = false;
input.Back = false;
TemplateHelper.FaceExitHeading(physics, segment, YawAlignmentMode.Snap);
return;
}
```
- [ ] **Step 4: Run tests to verify they pass**
Run:
```bash
dotnet test MinecraftClient.Tests/MinecraftClient.Tests.csproj --filter "FullyQualifiedName~AscendTemplate_PrepareJump_SnapsYawImmediatelyFromOppositeYaw|FullyQualifiedName~WalkTemplate_PrepareJump_FreezeForTurn_SnapsExitHeadingImmediately|FullyQualifiedName~AscendTemplate_PrepareJump_CompletesFromOppositeYawWithinTwentyTicks|FullyQualifiedName~WalkTemplate_TurnIntoParkour_CompletesOnlyWhenTurnEntryIsSlowAndJumpReady" -v minimal
```
Expected:
- `PASS`
- The new snap regressions pass.
- Existing opposite-yaw ascend timing stays green.
- The turn-into-parkour convergence regression still passes.
- [ ] **Step 5: Commit**
```bash
git add \
MinecraftClient/Pathing/Execution/Templates/AscendTemplate.cs \
MinecraftClient/Pathing/Execution/Templates/GroundedSegmentController.cs \
MinecraftClient.Tests/Pathing/Execution/GroundedTemplateConvergenceTests.cs
git commit -m "pathing: snap yaw for jump-ready grounded handoffs"
```
### Task 3: Add Failing Walk Jump-Entry Scope Tests
**Files:**
- Modify: `MinecraftClient.Tests/Pathing/Execution/GroundedTemplateConvergenceTests.cs`
- Modify: `MinecraftClient/Pathing/Execution/Templates/WalkTemplate.cs`
- Test: `MinecraftClient.Tests/Pathing/Execution/GroundedTemplateConvergenceTests.cs`
- [ ] **Step 1: Write the failing tests**
Add these tests to `MinecraftClient.Tests/Pathing/Execution/GroundedTemplateConvergenceTests.cs` near the existing walk prepare-jump coverage:
```csharp
[Fact]
public void WalkTemplate_PrepareJump_SnapsYawImmediatelyDuringRunUp()
{
World world = FlatWorldTestBuilder.CreateStoneFloor();
var current = new PathSegment
{
Start = new Location(0.5, 80, 0.5),
End = new Location(1.5, 80, 0.5),
MoveType = MoveType.Traverse,
ExitTransition = PathTransitionType.PrepareJump,
ExitHints = new PathTransitionHints(1, 0, 0.10, double.PositiveInfinity, false, true, true, false, 10),
PreserveSprint = true
};
var next = new PathSegment
{
Start = new Location(1.5, 80, 0.5),
End = new Location(3.5, 80, 0.5),
MoveType = MoveType.Parkour,
ExitTransition = PathTransitionType.FinalStop
};
var template = new WalkTemplate(current, next);
var physics = TemplateSimulationRunner.CreateGroundedPhysics(current.Start, yaw: 90f);
var input = new MovementInput();
TemplateState state = template.Tick(current.Start, physics, input, world);
Assert.Equal(TemplateState.InProgress, state);
Assert.InRange(physics.Yaw, 269.9f, 270.1f);
Assert.True(input.Forward);
Assert.True(input.Sprint);
}
[Fact]
public void WalkTemplate_FinalStop_RetainsSmoothYawOutsideJumpEntry()
{
World world = FlatWorldTestBuilder.CreateStoneFloor();
var segment = new PathSegment
{
Start = new Location(0.5, 80, 0.5),
End = new Location(1.5, 80, 0.5),
MoveType = MoveType.Traverse,
ExitTransition = PathTransitionType.FinalStop
};
var template = new WalkTemplate(segment, null);
var physics = TemplateSimulationRunner.CreateGroundedPhysics(segment.Start, yaw: 90f);
var input = new MovementInput();
TemplateState state = template.Tick(segment.Start, physics, input, world);
Assert.Equal(TemplateState.InProgress, state);
Assert.InRange(physics.Yaw, 124.9f, 125.1f);
}
```
- [ ] **Step 2: Run tests to verify they fail**
Run:
```bash
dotnet test MinecraftClient.Tests/MinecraftClient.Tests.csproj --filter "FullyQualifiedName~WalkTemplate_PrepareJump_SnapsYawImmediatelyDuringRunUp|FullyQualifiedName~WalkTemplate_FinalStop_RetainsSmoothYawOutsideJumpEntry" -v minimal
```
Expected:
- `FAIL`
- The prepare-jump test should show smooth partial rotation instead of an immediate snap.
- The final-stop control test should already pass and act as the scope guard for the next step.
- [ ] **Step 3: Write minimal implementation**
In `MinecraftClient/Pathing/Execution/Templates/WalkTemplate.cs`, gate snap yaw to grounded jump-entry segments only:
```csharp
bool snapYawForJumpEntry = physics.OnGround
&& _segment.ExitTransition == PathTransitionType.PrepareJump
&& _segment.ExitHints.RequireJumpReady;
float targetYaw = TemplateHelper.ShouldBiasTowardExitHeading(pos, _segment)
? TemplateHelper.GetExitHeadingYaw(_segment)
: TemplateHelper.CalculateYaw(dx, dz);
physics.Yaw = TemplateHelper.AlignYaw(
physics.Yaw,
targetYaw,
snapYawForJumpEntry ? YawAlignmentMode.Snap : YawAlignmentMode.Smooth);
physics.Pitch = TemplateHelper.SmoothPitch(physics.Pitch, targetPitch);
```
- [ ] **Step 4: Run tests to verify they pass**
Run:
```bash
dotnet test MinecraftClient.Tests/MinecraftClient.Tests.csproj --filter "FullyQualifiedName~WalkTemplate_PrepareJump_SnapsYawImmediatelyDuringRunUp|FullyQualifiedName~WalkTemplate_FinalStop_RetainsSmoothYawOutsideJumpEntry|FullyQualifiedName~WalkTemplate_PrepareJump_CompletesWithoutSettlingOnRunUpBlock|FullyQualifiedName~WalkTemplate_DiagonalPrepareJumpIntoAscend_CompletesFromTargetBlockEntry" -v minimal
```
Expected:
- `PASS`
- The new run-up snap regression passes.
- The final-stop scope guard stays green.
- Existing walk prepare-jump convergence regressions remain green.
- [ ] **Step 5: Commit**
```bash
git add \
MinecraftClient/Pathing/Execution/Templates/WalkTemplate.cs \
MinecraftClient.Tests/Pathing/Execution/GroundedTemplateConvergenceTests.cs
git commit -m "pathing: snap yaw only for grounded jump-entry walk states"
```
### Task 4: Full Verification And Evidence Capture
**Files:**
- Modify only if timing evidence demands it:
- `MinecraftClient.Tests/TestData/Pathing/pathing-timing-budgets.json`
- `MinecraftClient.Tests/TestData/Pathing/pathing-planner-contracts.json`
- Verify:
- `MinecraftClient.Tests/Pathing/Execution/SprintJumpTemplateScenarioTests.cs`
- `MinecraftClient.Tests/Pathing/Execution/GroundedTemplateConvergenceTests.cs`
- `MinecraftClient.Tests/Pathing/Execution/LivePathingRegressionTests.cs`
- `MinecraftClient.Tests/Pathing/Execution/PathPlanningContractTests.cs`
- `MinecraftClient.Tests/Pathing/Execution/PathTimingContractTests.cs`
- [ ] **Step 1: Run the focused unit regression set**
Run:
```bash
dotnet test MinecraftClient.Tests/MinecraftClient.Tests.csproj --filter "FullyQualifiedName~SprintJumpTemplateScenarioTests|FullyQualifiedName~GroundedTemplateConvergenceTests|FullyQualifiedName~LivePathingRegressionTests|FullyQualifiedName~MoveParkourTests.Accepts4x1JumpWithoutRearSupport_WhenTakeoffBlockProvidesRunway|FullyQualifiedName~PathPlanningContractTests.Scenario_PlannerMatchesContract|FullyQualifiedName~PathTimingContractTests.JumpCombo_ExecutionStaysWithinBudget|FullyQualifiedName~PathTimingContractTests.LongRoute_ExecutionStaysWithinBudget" -v minimal
```
Expected:
- `PASS`
- No planner regressions.
- No timing budget failures.
- [ ] **Step 2: If a timing contract fails, refresh it from evidence before rerunning**
Use the bootstrap printer first:
```bash
dotnet test MinecraftClient.Tests/MinecraftClient.Tests.csproj --filter "FullyQualifiedName~PathingContractBootstrapTests" -v minimal
```
Only if a contract mismatch is stable and explained by the new snap behavior, update the matching JSON entries with the printed values, then rerun the focused contract tests:
```bash
dotnet test MinecraftClient.Tests/MinecraftClient.Tests.csproj --filter "FullyQualifiedName~PathPlanningContractTests.Scenario_PlannerMatchesContract|FullyQualifiedName~PathTimingContractTests.JumpCombo_ExecutionStaysWithinBudget|FullyQualifiedName~PathTimingContractTests.LongRoute_ExecutionStaysWithinBudget" -v minimal
```
Expected:
- Either no JSON changes are needed, or the rerun passes with fresh values backed by bootstrap output.
- [ ] **Step 3: Run jump-combo live harness sequentially**
Run:
```bash
bash -lc 'source tools/mcc-env.sh && mcc-preflight 1.21.11-Vanilla && bash tools/test-pathing-jump-combos.sh 1.21.11-Vanilla'
```
Expected:
- `PASS` summary for all jump-combo scenarios.
- No `Replan #`, `Partial`, `Replan failed`, or `Giving up`.
- [ ] **Step 4: Run long-route live harness sequentially**
Run:
```bash
bash -lc 'source tools/mcc-env.sh && mcc-preflight 1.21.11-Vanilla && bash tools/test-pathing-long-routes.sh 1.21.11-Vanilla'
```
Expected:
- `Pathing long-route suite complete.`
- No `Replan #`, `Partial`, `Replan failed`, or `Giving up`.
- Repeated jump-entry routes remain within current max budgets.
- [ ] **Step 5: Commit only additional contract refreshes from Task 4**
If Task 4 needed no JSON or script edits, do not create another commit. Record that verification completed with no additional file changes.
If timing contracts changed in Task 4, commit only those refreshes:
```bash
git add MinecraftClient.Tests/TestData/Pathing/pathing-timing-budgets.json
git commit -m "test: refresh jump-entry snap yaw timing budgets"
```

View file

@ -1,181 +0,0 @@
# Jump-Entry Direct Yaw Design
## Context
Recent pathing work exposed a consistent execution cost in sterile test worlds: jump-capable segments can spend several ticks rotating in place before they are willing to commit to the action. This is most visible in short parkour jumps and ascend chains started from opposite yaw, where the path is correct and no replan should happen, but execution still burns ticks on gradual heading convergence.
The current implementation uses `TemplateHelper.SmoothYaw(...)` across all movement templates. That smooth turn is not purely visual. Forward and back inputs are resolved using the current `physics.Yaw`, so intermediate yaw values change real acceleration, timing, and landing state. Because of that, replacing all yaw smoothing with snap rotation would be a behavior change across the whole execution system, not just a cosmetic cleanup.
The goal of this change is narrower: remove unnecessary yaw convergence cost only in states where the controller is explicitly trying to become jump-ready, while preserving current grounded braking, descend timing, climb centering, and final-stop settling behavior elsewhere.
## Requirements
- Remove avoidable yaw-convergence tax from jump-entry states.
- Preserve the current hard requirement of `0 replan` in sterile live test worlds.
- Preserve planner behavior and existing path contracts.
- Keep pitch smoothing unchanged.
- Do not change normal `Walk`, `Descend`, `Climb`, or grounded final-stop semantics in the first pass.
- Keep the existing guarded `PrepareJump` handoff behavior intact.
## Approaches
### 1. Global snap yaw in all templates
Replace all `SmoothYaw(...)` calls with direct target yaw assignment.
Pros:
- Simplest implementation model.
- Removes all rotation latency.
Cons:
- Changes grounded traversal, descent lip approach, climb centering, and turn braking at once.
- Would invalidate current assumptions in templates that use heading penalty and gradual exit-heading bias as part of real motion control.
- Too broad for the current bug and too risky for the current regression surface.
### 2. Phase-scoped direct yaw only in jump-entry states
Keep smoothing by default, but explicitly snap yaw in states whose sole purpose is to prepare for a jump.
Pros:
- Targets the observed cost directly.
- Preserves current non-jump motion semantics.
- Matches the theoretical intent: if the state is already waiting for jump-ready alignment, gradual turning is wasted time.
Cons:
- Requires template-specific gating instead of one global rule.
This is the recommended approach.
### 3. Faster smoothing instead of snap
Raise `MaxYawStepPerTick` or add a faster smoothing mode for some templates.
Pros:
- Smaller conceptual jump from the current implementation.
Cons:
- Keeps the same state model and the same basic failure mode, only with smaller delays.
- Makes behavior harder to reason about because "how fast is fast enough" becomes another tuning problem.
This is not recommended for the first pass.
## Design
### Scope boundary
The first pass should only change yaw behavior in jump-entry states:
- `SprintJumpTemplate` while approaching takeoff
- `AscendTemplate` while aligning for jump commitment
- `GroundedSegmentController` when freezing in place for `PrepareJump`
- `WalkTemplate` only when the segment is a grounded jump-entry segment with `ExitHints.RequireJumpReady == true`
The first pass should not change:
- ordinary `WalkTemplate` traversal, turn, or final-stop control
- `DescendTemplate`
- `ClimbTemplate`
- air control during jump flight
- grounded landing recovery and final-stop braking after a jump
### Yaw policy model
Add an explicit notion of yaw alignment mode at the helper layer, with two behaviors:
- `Smooth`
- `Snap`
The helper should centralize the policy so templates do not open-code direct `physics.Yaw = targetYaw` in unrelated ways. Pitch should remain smooth.
The implementation does not need a broad architecture. A small helper API is enough, for example:
- `AlignYaw(current, target, mode)`
- or a narrowly named helper such as `SnapYaw(target)`
The key contract is that templates opt into snap only when they are inside the jump-entry boundary above.
### Sprint jump behavior
`SprintJumpTemplate` should use direct yaw alignment during `Phase.Approach`.
Why:
- This phase already treats heading alignment as a hard precondition for jumping.
- When started from opposite yaw, smooth turning creates pure startup tax before the jump can begin.
- For short `FinalStop` jumps, this cost is disproportionately large relative to route time.
Effect:
- `yawAligned` becomes immediately satisfiable once the state ticks.
- The template can begin acceleration or jump commitment on the same tick rather than waiting several ticks for smooth convergence.
- The recent short-jump air-brake latch remains unchanged and still handles landing-side overshoot.
### Ascend behavior
`AscendTemplate` should use direct yaw alignment in the pre-jump phase, except for the existing grounded prepare-jump handoff carveout.
Why:
- Ascend already waits for heading readiness before jumping.
- The current opposite-yaw staircase spin is the same problem as short parkour: a jump state paying a gradual-turn tax before action.
- The existing `groundedPrepareJumpHandoff` guard must still prevent double ownership of yaw at the moment control is handed to the next jump-ready segment.
Effect:
- Opposite-yaw ascend starts become immediate.
- Existing handoff protection remains intact.
### Grounded prepare-jump freeze
When `GroundedSegmentController` enters its freeze-for-turn branch for `PrepareJump`, it should directly align to exit heading rather than smoothing.
Why:
- In this branch, movement is already frozen.
- There is no benefit to burning extra ticks on a smooth turn while stationary.
- This is the cleanest place to remove residual turn latency for grounded jump handoffs.
### WalkTemplate jump-entry alignment
`WalkTemplate` should keep smooth yaw for normal traversal. It should switch to direct yaw only for grounded segments that are explicitly preparing for a jump:
- `ExitTransition == PrepareJump`
- `ExitHints.RequireJumpReady == true`
Why:
- This is still part of the jump-entry pipeline, not ordinary path following.
- Snap yaw here allows the segment to convert remaining forward ticks into the correct heading immediately, which better preserves exit-speed intent for the next jump.
- Restricting this to jump-ready segments avoids changing ordinary traversal and braking behavior.
### Non-goals
This change does not attempt to:
- remove all yaw smoothing from execution
- re-tune descend, climb, or landing-recovery behavior
- change planner costs or move admissibility
- rewrite transition braking around direct yaw assumptions
## Expected effects
### Positive effects
- Short opposite-yaw parkour and ascend starts should lose their upfront turn tax.
- Repeated jump-entry chains should start more promptly.
- Jump-ready handoff states should stop wasting ticks while frozen.
### Risks
- Jump-entry segments may now redirect horizontal acceleration more abruptly.
- A few jump-entry timing expectations may improve by 1 to 5 ticks and need contract updates only if they are stricter than reality.
These risks are acceptable because the affected scope is intentionally limited to states whose semantics are already "become jump-ready now."
## Validation
- Unit tests:
- `SprintJumpTemplate_TwoBlockGap_FinalStop_CompletesFromOppositeYawWithinTwentyTicks`
- `AscendTemplate_PrepareJump_CompletesFromOppositeYawWithinTwentyTicks`
- existing grounded convergence and sprint-jump scenario suites
- Contract tests:
- planner contracts remain unchanged
- timing contracts are rerun and only updated if fresh evidence shows stable, improved timings
- Live harnesses, sequentially:
- `tools/test-pathing-jump-combos.sh`
- `tools/test-pathing-long-routes.sh`
- Success criteria:
- no new replans in sterile routes
- no regression in planner-selected long-jump chains
- short opposite-yaw jump regressions stay green
## Delivery order
1. Add the yaw-policy helper.
2. Apply snap yaw to `SprintJumpTemplate` approach.
3. Apply snap yaw to `AscendTemplate` pre-jump alignment.
4. Apply snap yaw to `GroundedSegmentController` prepare-jump freeze.
5. Apply gated snap yaw to `WalkTemplate` jump-entry alignment only.
6. Rerun focused unit suites.
7. Rerun live jump-combo and long-route harnesses sequentially.
## Open questions
- None for the first pass. `Descend` and `Climb` are explicitly deferred until there is evidence that their current smoothing is the next limiting factor.

View file

@ -1,6 +1,6 @@
[
{
"case_id": "ceiling-headhitter-sprint-mm12-gap1-ceil4p0",
"case_id": "ceiling-headhitter-sprint-mm12-gap3-ceil3p0",
"bucket_id": "ceiling:headhitter:sprint:easy",
"family": "ceiling",
"subfamily": "headhitter",
@ -9,35 +9,11 @@
"difficulty_band": "easy",
"expected_result": "pass",
"world_recipe_id": "ceiling-headhitter",
"gap_blocks": 1,
"delta_y": 0.0,
"ceiling_height": 4.0,
"wall_width": null,
"start": {
"x": 100.5,
"y": 80.0,
"z": 100.5
},
"goal": {
"x": 102.0,
"y": 80.0,
"z": 100.0
}
},
{
"case_id": "ceiling-headhitter-sprint-mm12-gap3-ceil2p0",
"bucket_id": "ceiling:headhitter:sprint:boundary",
"family": "ceiling",
"subfamily": "headhitter",
"movement_mode": "sprint",
"momentum_ticks": 12,
"difficulty_band": "boundary",
"expected_result": "pass",
"world_recipe_id": "ceiling-headhitter",
"gap_blocks": 3,
"delta_y": 0.0,
"ceiling_height": 2.0,
"ceiling_height": 3.0,
"wall_width": null,
"wall_offset": null,
"start": {
"x": 100.5,
"y": 80.0,
@ -50,7 +26,33 @@
}
},
{
"case_id": "ceiling-headhitter-sprint-mm12-gap4-ceil2p0",
"case_id": "ceiling-headhitter-sprint-mm12-gap3-ceil2p5",
"bucket_id": "ceiling:headhitter:sprint:boundary",
"family": "ceiling",
"subfamily": "headhitter",
"movement_mode": "sprint",
"momentum_ticks": 12,
"difficulty_band": "boundary",
"expected_result": "pass",
"world_recipe_id": "ceiling-headhitter",
"gap_blocks": 3,
"delta_y": 0.0,
"ceiling_height": 2.5,
"wall_width": null,
"wall_offset": null,
"start": {
"x": 100.5,
"y": 80.0,
"z": 100.5
},
"goal": {
"x": 104.0,
"y": 80.0,
"z": 100.0
}
},
{
"case_id": "ceiling-headhitter-sprint-mm12-gap1-ceil4p0",
"bucket_id": "ceiling:headhitter:sprint:reject",
"family": "ceiling",
"subfamily": "headhitter",
@ -59,10 +61,167 @@
"difficulty_band": "reject",
"expected_result": "reject",
"world_recipe_id": "ceiling-headhitter",
"gap_blocks": 1,
"delta_y": 0.0,
"ceiling_height": 4.0,
"wall_width": null,
"wall_offset": null,
"start": {
"x": 100.5,
"y": 80.0,
"z": 100.5
},
"goal": {
"x": 102.0,
"y": 80.0,
"z": 100.0
}
},
{
"case_id": "linear-ascend-sprint-mm12-gap3-dy1p0",
"bucket_id": "linear:ascend:sprint:easy",
"family": "linear",
"subfamily": "ascend",
"movement_mode": "sprint",
"momentum_ticks": 12,
"difficulty_band": "easy",
"expected_result": "pass",
"world_recipe_id": "linear-ascend",
"gap_blocks": 3,
"delta_y": 1.0,
"ceiling_height": null,
"wall_width": null,
"wall_offset": null,
"start": {
"x": 100.5,
"y": 80.0,
"z": 100.5
},
"goal": {
"x": 104.0,
"y": 81.0,
"z": 100.0
}
},
{
"case_id": "linear-ascend-sprint-mm12-gap0-dy1p0",
"bucket_id": "linear:ascend:sprint:reject",
"family": "linear",
"subfamily": "ascend",
"movement_mode": "sprint",
"momentum_ticks": 12,
"difficulty_band": "reject",
"expected_result": "reject",
"world_recipe_id": "linear-ascend",
"gap_blocks": 0,
"delta_y": 1.0,
"ceiling_height": null,
"wall_width": null,
"wall_offset": null,
"start": {
"x": 100.5,
"y": 80.0,
"z": 100.5
},
"goal": {
"x": 101.0,
"y": 81.0,
"z": 100.0
}
},
{
"case_id": "linear-descend-sprint-mm12-gap4-dym1p0",
"bucket_id": "linear:descend:sprint:easy",
"family": "linear",
"subfamily": "descend",
"movement_mode": "sprint",
"momentum_ticks": 12,
"difficulty_band": "easy",
"expected_result": "pass",
"world_recipe_id": "linear-descend",
"gap_blocks": 4,
"delta_y": -1.0,
"ceiling_height": null,
"wall_width": null,
"wall_offset": null,
"start": {
"x": 100.5,
"y": 80.0,
"z": 100.5
},
"goal": {
"x": 105.0,
"y": 79.0,
"z": 100.0
}
},
{
"case_id": "linear-descend-sprint-mm12-gap5-dym1p0",
"bucket_id": "linear:descend:sprint:boundary",
"family": "linear",
"subfamily": "descend",
"movement_mode": "sprint",
"momentum_ticks": 12,
"difficulty_band": "boundary",
"expected_result": "pass",
"world_recipe_id": "linear-descend",
"gap_blocks": 5,
"delta_y": -1.0,
"ceiling_height": null,
"wall_width": null,
"wall_offset": null,
"start": {
"x": 100.5,
"y": 80.0,
"z": 100.5
},
"goal": {
"x": 106.0,
"y": 79.0,
"z": 100.0
}
},
{
"case_id": "linear-descend-sprint-mm12-gap0-dym1p0",
"bucket_id": "linear:descend:sprint:reject",
"family": "linear",
"subfamily": "descend",
"movement_mode": "sprint",
"momentum_ticks": 12,
"difficulty_band": "reject",
"expected_result": "reject",
"world_recipe_id": "linear-descend",
"gap_blocks": 0,
"delta_y": -1.0,
"ceiling_height": null,
"wall_width": null,
"wall_offset": null,
"start": {
"x": 100.5,
"y": 80.0,
"z": 100.5
},
"goal": {
"x": 101.0,
"y": 79.0,
"z": 100.0
}
},
{
"case_id": "linear-flat-sprint-mm12-gap4-dy0p0",
"bucket_id": "linear:flat:sprint:easy",
"family": "linear",
"subfamily": "flat",
"movement_mode": "sprint",
"momentum_ticks": 12,
"difficulty_band": "easy",
"expected_result": "pass",
"world_recipe_id": "linear-flat",
"gap_blocks": 4,
"delta_y": 0.0,
"ceiling_height": 2.0,
"ceiling_height": null,
"wall_width": null,
"wall_offset": null,
"start": {
"x": 100.5,
"y": 80.0,
@ -74,183 +233,8 @@
"z": 100.0
}
},
{
"case_id": "linear-ascend-sprint-mm12-gap0-dy1p0",
"bucket_id": "linear:ascend:sprint:easy",
"family": "linear",
"subfamily": "ascend",
"movement_mode": "sprint",
"momentum_ticks": 12,
"difficulty_band": "easy",
"expected_result": "pass",
"world_recipe_id": "linear-ascend",
"gap_blocks": 0,
"delta_y": 1.0,
"ceiling_height": null,
"wall_width": null,
"start": {
"x": 100.5,
"y": 80.0,
"z": 100.5
},
"goal": {
"x": 101.0,
"y": 81.0,
"z": 100.0
}
},
{
"case_id": "linear-ascend-sprint-mm12-gap2-dy1p0",
"bucket_id": "linear:ascend:sprint:boundary",
"family": "linear",
"subfamily": "ascend",
"movement_mode": "sprint",
"momentum_ticks": 12,
"difficulty_band": "boundary",
"expected_result": "pass",
"world_recipe_id": "linear-ascend",
"gap_blocks": 2,
"delta_y": 1.0,
"ceiling_height": null,
"wall_width": null,
"start": {
"x": 100.5,
"y": 80.0,
"z": 100.5
},
"goal": {
"x": 103.0,
"y": 81.0,
"z": 100.0
}
},
{
"case_id": "linear-ascend-sprint-mm12-gap6-dy1p0",
"bucket_id": "linear:ascend:sprint:reject",
"family": "linear",
"subfamily": "ascend",
"movement_mode": "sprint",
"momentum_ticks": 12,
"difficulty_band": "reject",
"expected_result": "reject",
"world_recipe_id": "linear-ascend",
"gap_blocks": 6,
"delta_y": 1.0,
"ceiling_height": null,
"wall_width": null,
"start": {
"x": 100.5,
"y": 80.0,
"z": 100.5
},
"goal": {
"x": 107.0,
"y": 81.0,
"z": 100.0
}
},
{
"case_id": "linear-descend-sprint-mm12-gap0-dym2p0",
"bucket_id": "linear:descend:sprint:easy",
"family": "linear",
"subfamily": "descend",
"movement_mode": "sprint",
"momentum_ticks": 12,
"difficulty_band": "easy",
"expected_result": "pass",
"world_recipe_id": "linear-descend",
"gap_blocks": 0,
"delta_y": -2.0,
"ceiling_height": null,
"wall_width": null,
"start": {
"x": 100.5,
"y": 80.0,
"z": 100.5
},
"goal": {
"x": 101.0,
"y": 78.0,
"z": 100.0
}
},
{
"case_id": "linear-descend-sprint-mm12-gap2-dym1p0",
"bucket_id": "linear:descend:sprint:boundary",
"family": "linear",
"subfamily": "descend",
"movement_mode": "sprint",
"momentum_ticks": 12,
"difficulty_band": "boundary",
"expected_result": "pass",
"world_recipe_id": "linear-descend",
"gap_blocks": 2,
"delta_y": -1.0,
"ceiling_height": null,
"wall_width": null,
"start": {
"x": 100.5,
"y": 80.0,
"z": 100.5
},
"goal": {
"x": 103.0,
"y": 79.0,
"z": 100.0
}
},
{
"case_id": "linear-flat-sprint-mm12-gap0-dy0p0",
"bucket_id": "linear:flat:sprint:easy",
"family": "linear",
"subfamily": "flat",
"movement_mode": "sprint",
"momentum_ticks": 12,
"difficulty_band": "easy",
"expected_result": "pass",
"world_recipe_id": "linear-flat",
"gap_blocks": 0,
"delta_y": 0.0,
"ceiling_height": null,
"wall_width": null,
"start": {
"x": 100.5,
"y": 80.0,
"z": 100.5
},
"goal": {
"x": 101.0,
"y": 80.0,
"z": 100.0
}
},
{
"case_id": "linear-flat-sprint-mm12-gap5-dy0p0",
"bucket_id": "linear:flat:sprint:boundary",
"family": "linear",
"subfamily": "flat",
"movement_mode": "sprint",
"momentum_ticks": 12,
"difficulty_band": "boundary",
"expected_result": "pass",
"world_recipe_id": "linear-flat",
"gap_blocks": 5,
"delta_y": 0.0,
"ceiling_height": null,
"wall_width": null,
"start": {
"x": 100.5,
"y": 80.0,
"z": 100.5
},
"goal": {
"x": 106.0,
"y": 80.0,
"z": 100.0
}
},
{
"case_id": "linear-flat-sprint-mm12-gap7-dy0p0",
"bucket_id": "linear:flat:sprint:reject",
"family": "linear",
"subfamily": "flat",
@ -259,17 +243,18 @@
"difficulty_band": "reject",
"expected_result": "reject",
"world_recipe_id": "linear-flat",
"gap_blocks": 7,
"gap_blocks": 0,
"delta_y": 0.0,
"ceiling_height": null,
"wall_width": null,
"wall_offset": null,
"start": {
"x": 100.5,
"y": 80.0,
"z": 100.5
},
"goal": {
"x": 108.0,
"x": 101.0,
"y": 80.0,
"z": 100.0
}
@ -288,6 +273,7 @@
"delta_y": 0.0,
"ceiling_height": null,
"wall_width": 1,
"wall_offset": null,
"start": {
"x": 100.5,
"y": 80.0,
@ -313,6 +299,7 @@
"delta_y": 0.0,
"ceiling_height": null,
"wall_width": 4,
"wall_offset": null,
"start": {
"x": 100.5,
"y": 80.0,
@ -323,5 +310,239 @@
"y": 80.0,
"z": 104.0
}
},
{
"case_id": "sidewall-ascend-sprint-mm12-gap3-dy1p0-wo1",
"bucket_id": "sidewall:ascend:sprint:easy",
"family": "sidewall",
"subfamily": "ascend",
"movement_mode": "sprint",
"momentum_ticks": 12,
"difficulty_band": "easy",
"expected_result": "pass",
"world_recipe_id": "sidewall-ascend",
"gap_blocks": 3,
"delta_y": 1.0,
"ceiling_height": null,
"wall_width": null,
"wall_offset": 1,
"start": {
"x": 100.5,
"y": 80.0,
"z": 100.5
},
"goal": {
"x": 104.0,
"y": 81.0,
"z": 100.0
}
},
{
"case_id": "sidewall-ascend-sprint-mm12-gap3-dy1p0-wo0",
"bucket_id": "sidewall:ascend:sprint:boundary",
"family": "sidewall",
"subfamily": "ascend",
"movement_mode": "sprint",
"momentum_ticks": 12,
"difficulty_band": "boundary",
"expected_result": "pass",
"world_recipe_id": "sidewall-ascend",
"gap_blocks": 3,
"delta_y": 1.0,
"ceiling_height": null,
"wall_width": null,
"wall_offset": 0,
"start": {
"x": 100.5,
"y": 80.0,
"z": 100.5
},
"goal": {
"x": 104.0,
"y": 81.0,
"z": 100.0
}
},
{
"case_id": "sidewall-ascend-sprint-mm12-gap0-dy1p0-wo0",
"bucket_id": "sidewall:ascend:sprint:reject",
"family": "sidewall",
"subfamily": "ascend",
"movement_mode": "sprint",
"momentum_ticks": 12,
"difficulty_band": "reject",
"expected_result": "reject",
"world_recipe_id": "sidewall-ascend",
"gap_blocks": 0,
"delta_y": 1.0,
"ceiling_height": null,
"wall_width": null,
"wall_offset": 0,
"start": {
"x": 100.5,
"y": 80.0,
"z": 100.5
},
"goal": {
"x": 101.0,
"y": 81.0,
"z": 100.0
}
},
{
"case_id": "sidewall-descend-sprint-mm12-gap4-dym1p0-wo1",
"bucket_id": "sidewall:descend:sprint:easy",
"family": "sidewall",
"subfamily": "descend",
"movement_mode": "sprint",
"momentum_ticks": 12,
"difficulty_band": "easy",
"expected_result": "pass",
"world_recipe_id": "sidewall-descend",
"gap_blocks": 4,
"delta_y": -1.0,
"ceiling_height": null,
"wall_width": null,
"wall_offset": 1,
"start": {
"x": 100.5,
"y": 80.0,
"z": 100.5
},
"goal": {
"x": 105.0,
"y": 79.0,
"z": 100.0
}
},
{
"case_id": "sidewall-descend-sprint-mm12-gap5-dym1p0-wo0",
"bucket_id": "sidewall:descend:sprint:boundary",
"family": "sidewall",
"subfamily": "descend",
"movement_mode": "sprint",
"momentum_ticks": 12,
"difficulty_band": "boundary",
"expected_result": "pass",
"world_recipe_id": "sidewall-descend",
"gap_blocks": 5,
"delta_y": -1.0,
"ceiling_height": null,
"wall_width": null,
"wall_offset": 0,
"start": {
"x": 100.5,
"y": 80.0,
"z": 100.5
},
"goal": {
"x": 106.0,
"y": 79.0,
"z": 100.0
}
},
{
"case_id": "sidewall-descend-sprint-mm12-gap0-dym1p0-wo0",
"bucket_id": "sidewall:descend:sprint:reject",
"family": "sidewall",
"subfamily": "descend",
"movement_mode": "sprint",
"momentum_ticks": 12,
"difficulty_band": "reject",
"expected_result": "reject",
"world_recipe_id": "sidewall-descend",
"gap_blocks": 0,
"delta_y": -1.0,
"ceiling_height": null,
"wall_width": null,
"wall_offset": 0,
"start": {
"x": 100.5,
"y": 80.0,
"z": 100.5
},
"goal": {
"x": 101.0,
"y": 79.0,
"z": 100.0
}
},
{
"case_id": "sidewall-flat-sprint-mm12-gap4-dy0p0-wo1",
"bucket_id": "sidewall:flat:sprint:easy",
"family": "sidewall",
"subfamily": "flat",
"movement_mode": "sprint",
"momentum_ticks": 12,
"difficulty_band": "easy",
"expected_result": "pass",
"world_recipe_id": "sidewall-flat",
"gap_blocks": 4,
"delta_y": 0.0,
"ceiling_height": null,
"wall_width": null,
"wall_offset": 1,
"start": {
"x": 100.5,
"y": 80.0,
"z": 100.5
},
"goal": {
"x": 105.0,
"y": 80.0,
"z": 100.0
}
},
{
"case_id": "sidewall-flat-sprint-mm12-gap4-dy0p0-wo0",
"bucket_id": "sidewall:flat:sprint:boundary",
"family": "sidewall",
"subfamily": "flat",
"movement_mode": "sprint",
"momentum_ticks": 12,
"difficulty_band": "boundary",
"expected_result": "pass",
"world_recipe_id": "sidewall-flat",
"gap_blocks": 4,
"delta_y": 0.0,
"ceiling_height": null,
"wall_width": null,
"wall_offset": 0,
"start": {
"x": 100.5,
"y": 80.0,
"z": 100.5
},
"goal": {
"x": 105.0,
"y": 80.0,
"z": 100.0
}
},
{
"case_id": "sidewall-flat-sprint-mm12-gap0-dy0p0-wo0",
"bucket_id": "sidewall:flat:sprint:reject",
"family": "sidewall",
"subfamily": "flat",
"movement_mode": "sprint",
"momentum_ticks": 12,
"difficulty_band": "reject",
"expected_result": "reject",
"world_recipe_id": "sidewall-flat",
"gap_blocks": 0,
"delta_y": 0.0,
"ceiling_height": null,
"wall_width": null,
"wall_offset": 0,
"start": {
"x": 100.5,
"y": 80.0,
"z": 100.5
},
"goal": {
"x": 101.0,
"y": 80.0,
"z": 100.0
}
}
]

View file

@ -0,0 +1,834 @@
[
{
"family": "ceiling",
"subfamily": "headhitter",
"movement_mode": "sprint",
"capability_metric": "gap_blocks",
"min_mm": 0,
"max_mm": 1,
"max_reach": null,
"delta_y": null,
"ceiling_height": 1.8125,
"wall_offset": null,
"notes": ""
},
{
"family": "ceiling",
"subfamily": "headhitter",
"movement_mode": "sprint",
"capability_metric": "gap_blocks",
"min_mm": 2,
"max_mm": 12,
"max_reach": 1,
"delta_y": null,
"ceiling_height": 1.8125,
"wall_offset": null,
"notes": ""
},
{
"family": "ceiling",
"subfamily": "headhitter",
"movement_mode": "sprint",
"capability_metric": "gap_blocks",
"min_mm": 0,
"max_mm": 12,
"max_reach": 1,
"delta_y": null,
"ceiling_height": 2.0,
"wall_offset": null,
"notes": ""
},
{
"family": "ceiling",
"subfamily": "headhitter",
"movement_mode": "sprint",
"capability_metric": "gap_blocks",
"min_mm": 0,
"max_mm": 7,
"max_reach": 2,
"delta_y": null,
"ceiling_height": 2.5,
"wall_offset": null,
"notes": ""
},
{
"family": "ceiling",
"subfamily": "headhitter",
"movement_mode": "sprint",
"capability_metric": "gap_blocks",
"min_mm": 8,
"max_mm": 12,
"max_reach": 3,
"delta_y": null,
"ceiling_height": 2.5,
"wall_offset": null,
"notes": ""
},
{
"family": "ceiling",
"subfamily": "headhitter",
"movement_mode": "sprint",
"capability_metric": "gap_blocks",
"min_mm": 0,
"max_mm": 1,
"max_reach": 3,
"delta_y": null,
"ceiling_height": 3.0,
"wall_offset": null,
"notes": ""
},
{
"family": "ceiling",
"subfamily": "headhitter",
"movement_mode": "sprint",
"capability_metric": "gap_blocks",
"min_mm": 2,
"max_mm": 12,
"max_reach": 4,
"delta_y": null,
"ceiling_height": 3.0,
"wall_offset": null,
"notes": ""
},
{
"family": "ceiling",
"subfamily": "headhitter",
"movement_mode": "sprint",
"capability_metric": "gap_blocks",
"min_mm": 0,
"max_mm": 0,
"max_reach": 3,
"delta_y": null,
"ceiling_height": 4.0,
"wall_offset": null,
"notes": ""
},
{
"family": "ceiling",
"subfamily": "headhitter",
"movement_mode": "sprint",
"capability_metric": "gap_blocks",
"min_mm": 1,
"max_mm": 12,
"max_reach": 4,
"delta_y": null,
"ceiling_height": 4.0,
"wall_offset": null,
"notes": ""
},
{
"family": "linear",
"subfamily": "ascend",
"movement_mode": "sprint",
"capability_metric": "gap_blocks",
"min_mm": 0,
"max_mm": 0,
"max_reach": 2,
"delta_y": 1.0,
"ceiling_height": null,
"wall_offset": null,
"notes": ""
},
{
"family": "linear",
"subfamily": "ascend",
"movement_mode": "sprint",
"capability_metric": "gap_blocks",
"min_mm": 1,
"max_mm": 12,
"max_reach": 3,
"delta_y": 1.0,
"ceiling_height": null,
"wall_offset": null,
"notes": ""
},
{
"family": "linear",
"subfamily": "ascend",
"movement_mode": "walk",
"capability_metric": "gap_blocks",
"min_mm": 0,
"max_mm": 0,
"max_reach": 1,
"delta_y": 1.0,
"ceiling_height": null,
"wall_offset": null,
"notes": ""
},
{
"family": "linear",
"subfamily": "ascend",
"movement_mode": "walk",
"capability_metric": "gap_blocks",
"min_mm": 1,
"max_mm": 12,
"max_reach": 2,
"delta_y": 1.0,
"ceiling_height": null,
"wall_offset": null,
"notes": ""
},
{
"family": "linear",
"subfamily": "descend",
"movement_mode": "sprint",
"capability_metric": "gap_blocks",
"min_mm": 0,
"max_mm": 0,
"max_reach": 4,
"delta_y": -2.0,
"ceiling_height": null,
"wall_offset": null,
"notes": ""
},
{
"family": "linear",
"subfamily": "descend",
"movement_mode": "sprint",
"capability_metric": "gap_blocks",
"min_mm": 1,
"max_mm": 12,
"max_reach": 5,
"delta_y": -2.0,
"ceiling_height": null,
"wall_offset": null,
"notes": ""
},
{
"family": "linear",
"subfamily": "descend",
"movement_mode": "sprint",
"capability_metric": "gap_blocks",
"min_mm": 0,
"max_mm": 1,
"max_reach": 4,
"delta_y": -1.0,
"ceiling_height": null,
"wall_offset": null,
"notes": ""
},
{
"family": "linear",
"subfamily": "descend",
"movement_mode": "sprint",
"capability_metric": "gap_blocks",
"min_mm": 2,
"max_mm": 12,
"max_reach": 5,
"delta_y": -1.0,
"ceiling_height": null,
"wall_offset": null,
"notes": ""
},
{
"family": "linear",
"subfamily": "descend",
"movement_mode": "walk",
"capability_metric": "gap_blocks",
"min_mm": 0,
"max_mm": 2,
"max_reach": 3,
"delta_y": -2.0,
"ceiling_height": null,
"wall_offset": null,
"notes": ""
},
{
"family": "linear",
"subfamily": "descend",
"movement_mode": "walk",
"capability_metric": "gap_blocks",
"min_mm": 3,
"max_mm": 12,
"max_reach": 4,
"delta_y": -2.0,
"ceiling_height": null,
"wall_offset": null,
"notes": ""
},
{
"family": "linear",
"subfamily": "descend",
"movement_mode": "walk",
"capability_metric": "gap_blocks",
"min_mm": 0,
"max_mm": 0,
"max_reach": 2,
"delta_y": -1.0,
"ceiling_height": null,
"wall_offset": null,
"notes": ""
},
{
"family": "linear",
"subfamily": "descend",
"movement_mode": "walk",
"capability_metric": "gap_blocks",
"min_mm": 1,
"max_mm": 12,
"max_reach": 3,
"delta_y": -1.0,
"ceiling_height": null,
"wall_offset": null,
"notes": ""
},
{
"family": "linear",
"subfamily": "flat",
"movement_mode": "sprint",
"capability_metric": "gap_blocks",
"min_mm": 0,
"max_mm": 0,
"max_reach": 3,
"delta_y": 0.0,
"ceiling_height": null,
"wall_offset": null,
"notes": ""
},
{
"family": "linear",
"subfamily": "flat",
"movement_mode": "sprint",
"capability_metric": "gap_blocks",
"min_mm": 1,
"max_mm": 12,
"max_reach": 4,
"delta_y": 0.0,
"ceiling_height": null,
"wall_offset": null,
"notes": ""
},
{
"family": "linear",
"subfamily": "flat",
"movement_mode": "walk",
"capability_metric": "gap_blocks",
"min_mm": 0,
"max_mm": 1,
"max_reach": 2,
"delta_y": 0.0,
"ceiling_height": null,
"wall_offset": null,
"notes": ""
},
{
"family": "linear",
"subfamily": "flat",
"movement_mode": "walk",
"capability_metric": "gap_blocks",
"min_mm": 2,
"max_mm": 12,
"max_reach": 3,
"delta_y": 0.0,
"ceiling_height": null,
"wall_offset": null,
"notes": ""
},
{
"family": "neo",
"subfamily": "neo",
"movement_mode": "sprint",
"capability_metric": "wall_width",
"min_mm": 0,
"max_mm": 1,
"max_reach": 3,
"delta_y": null,
"ceiling_height": null,
"wall_offset": null,
"notes": ""
},
{
"family": "neo",
"subfamily": "neo",
"movement_mode": "sprint",
"capability_metric": "wall_width",
"min_mm": 2,
"max_mm": 12,
"max_reach": 4,
"delta_y": null,
"ceiling_height": null,
"wall_offset": null,
"notes": ""
},
{
"family": "neo",
"subfamily": "neo",
"movement_mode": "walk",
"capability_metric": "wall_width",
"min_mm": 0,
"max_mm": 0,
"max_reach": 1,
"delta_y": null,
"ceiling_height": null,
"wall_offset": null,
"notes": ""
},
{
"family": "neo",
"subfamily": "neo",
"movement_mode": "walk",
"capability_metric": "wall_width",
"min_mm": 1,
"max_mm": 5,
"max_reach": 2,
"delta_y": null,
"ceiling_height": null,
"wall_offset": null,
"notes": ""
},
{
"family": "neo",
"subfamily": "neo",
"movement_mode": "walk",
"capability_metric": "wall_width",
"min_mm": 6,
"max_mm": 12,
"max_reach": 3,
"delta_y": null,
"ceiling_height": null,
"wall_offset": null,
"notes": ""
},
{
"family": "sidewall",
"subfamily": "ascend",
"movement_mode": "sprint",
"capability_metric": "gap_blocks",
"min_mm": 0,
"max_mm": 0,
"max_reach": 2,
"delta_y": 1.0,
"ceiling_height": null,
"wall_offset": 0,
"notes": ""
},
{
"family": "sidewall",
"subfamily": "ascend",
"movement_mode": "sprint",
"capability_metric": "gap_blocks",
"min_mm": 1,
"max_mm": 12,
"max_reach": 3,
"delta_y": 1.0,
"ceiling_height": null,
"wall_offset": 0,
"notes": ""
},
{
"family": "sidewall",
"subfamily": "ascend",
"movement_mode": "sprint",
"capability_metric": "gap_blocks",
"min_mm": 0,
"max_mm": 0,
"max_reach": 2,
"delta_y": 1.0,
"ceiling_height": null,
"wall_offset": 1,
"notes": ""
},
{
"family": "sidewall",
"subfamily": "ascend",
"movement_mode": "sprint",
"capability_metric": "gap_blocks",
"min_mm": 1,
"max_mm": 12,
"max_reach": 3,
"delta_y": 1.0,
"ceiling_height": null,
"wall_offset": 1,
"notes": ""
},
{
"family": "sidewall",
"subfamily": "ascend",
"movement_mode": "walk",
"capability_metric": "gap_blocks",
"min_mm": 0,
"max_mm": 0,
"max_reach": 1,
"delta_y": 1.0,
"ceiling_height": null,
"wall_offset": 0,
"notes": ""
},
{
"family": "sidewall",
"subfamily": "ascend",
"movement_mode": "walk",
"capability_metric": "gap_blocks",
"min_mm": 1,
"max_mm": 12,
"max_reach": 2,
"delta_y": 1.0,
"ceiling_height": null,
"wall_offset": 0,
"notes": ""
},
{
"family": "sidewall",
"subfamily": "ascend",
"movement_mode": "walk",
"capability_metric": "gap_blocks",
"min_mm": 0,
"max_mm": 0,
"max_reach": 1,
"delta_y": 1.0,
"ceiling_height": null,
"wall_offset": 1,
"notes": ""
},
{
"family": "sidewall",
"subfamily": "ascend",
"movement_mode": "walk",
"capability_metric": "gap_blocks",
"min_mm": 1,
"max_mm": 12,
"max_reach": 2,
"delta_y": 1.0,
"ceiling_height": null,
"wall_offset": 1,
"notes": ""
},
{
"family": "sidewall",
"subfamily": "descend",
"movement_mode": "sprint",
"capability_metric": "gap_blocks",
"min_mm": 0,
"max_mm": 0,
"max_reach": 4,
"delta_y": -2.0,
"ceiling_height": null,
"wall_offset": 0,
"notes": ""
},
{
"family": "sidewall",
"subfamily": "descend",
"movement_mode": "sprint",
"capability_metric": "gap_blocks",
"min_mm": 1,
"max_mm": 12,
"max_reach": 5,
"delta_y": -2.0,
"ceiling_height": null,
"wall_offset": 0,
"notes": ""
},
{
"family": "sidewall",
"subfamily": "descend",
"movement_mode": "sprint",
"capability_metric": "gap_blocks",
"min_mm": 0,
"max_mm": 0,
"max_reach": 4,
"delta_y": -2.0,
"ceiling_height": null,
"wall_offset": 1,
"notes": ""
},
{
"family": "sidewall",
"subfamily": "descend",
"movement_mode": "sprint",
"capability_metric": "gap_blocks",
"min_mm": 1,
"max_mm": 12,
"max_reach": 5,
"delta_y": -2.0,
"ceiling_height": null,
"wall_offset": 1,
"notes": ""
},
{
"family": "sidewall",
"subfamily": "descend",
"movement_mode": "sprint",
"capability_metric": "gap_blocks",
"min_mm": 0,
"max_mm": 1,
"max_reach": 4,
"delta_y": -1.0,
"ceiling_height": null,
"wall_offset": 0,
"notes": ""
},
{
"family": "sidewall",
"subfamily": "descend",
"movement_mode": "sprint",
"capability_metric": "gap_blocks",
"min_mm": 2,
"max_mm": 12,
"max_reach": 5,
"delta_y": -1.0,
"ceiling_height": null,
"wall_offset": 0,
"notes": ""
},
{
"family": "sidewall",
"subfamily": "descend",
"movement_mode": "sprint",
"capability_metric": "gap_blocks",
"min_mm": 0,
"max_mm": 1,
"max_reach": 4,
"delta_y": -1.0,
"ceiling_height": null,
"wall_offset": 1,
"notes": ""
},
{
"family": "sidewall",
"subfamily": "descend",
"movement_mode": "sprint",
"capability_metric": "gap_blocks",
"min_mm": 2,
"max_mm": 12,
"max_reach": 5,
"delta_y": -1.0,
"ceiling_height": null,
"wall_offset": 1,
"notes": ""
},
{
"family": "sidewall",
"subfamily": "descend",
"movement_mode": "walk",
"capability_metric": "gap_blocks",
"min_mm": 0,
"max_mm": 0,
"max_reach": 2,
"delta_y": -2.0,
"ceiling_height": null,
"wall_offset": 0,
"notes": ""
},
{
"family": "sidewall",
"subfamily": "descend",
"movement_mode": "walk",
"capability_metric": "gap_blocks",
"min_mm": 1,
"max_mm": 2,
"max_reach": 3,
"delta_y": -2.0,
"ceiling_height": null,
"wall_offset": 0,
"notes": ""
},
{
"family": "sidewall",
"subfamily": "descend",
"movement_mode": "walk",
"capability_metric": "gap_blocks",
"min_mm": 3,
"max_mm": 12,
"max_reach": 4,
"delta_y": -2.0,
"ceiling_height": null,
"wall_offset": 0,
"notes": ""
},
{
"family": "sidewall",
"subfamily": "descend",
"movement_mode": "walk",
"capability_metric": "gap_blocks",
"min_mm": 0,
"max_mm": 0,
"max_reach": 2,
"delta_y": -2.0,
"ceiling_height": null,
"wall_offset": 1,
"notes": ""
},
{
"family": "sidewall",
"subfamily": "descend",
"movement_mode": "walk",
"capability_metric": "gap_blocks",
"min_mm": 1,
"max_mm": 2,
"max_reach": 3,
"delta_y": -2.0,
"ceiling_height": null,
"wall_offset": 1,
"notes": ""
},
{
"family": "sidewall",
"subfamily": "descend",
"movement_mode": "walk",
"capability_metric": "gap_blocks",
"min_mm": 3,
"max_mm": 12,
"max_reach": 4,
"delta_y": -2.0,
"ceiling_height": null,
"wall_offset": 1,
"notes": ""
},
{
"family": "sidewall",
"subfamily": "descend",
"movement_mode": "walk",
"capability_metric": "gap_blocks",
"min_mm": 0,
"max_mm": 0,
"max_reach": 2,
"delta_y": -1.0,
"ceiling_height": null,
"wall_offset": 0,
"notes": ""
},
{
"family": "sidewall",
"subfamily": "descend",
"movement_mode": "walk",
"capability_metric": "gap_blocks",
"min_mm": 1,
"max_mm": 12,
"max_reach": 3,
"delta_y": -1.0,
"ceiling_height": null,
"wall_offset": 0,
"notes": ""
},
{
"family": "sidewall",
"subfamily": "descend",
"movement_mode": "walk",
"capability_metric": "gap_blocks",
"min_mm": 0,
"max_mm": 0,
"max_reach": 2,
"delta_y": -1.0,
"ceiling_height": null,
"wall_offset": 1,
"notes": ""
},
{
"family": "sidewall",
"subfamily": "descend",
"movement_mode": "walk",
"capability_metric": "gap_blocks",
"min_mm": 1,
"max_mm": 12,
"max_reach": 3,
"delta_y": -1.0,
"ceiling_height": null,
"wall_offset": 1,
"notes": ""
},
{
"family": "sidewall",
"subfamily": "flat",
"movement_mode": "sprint",
"capability_metric": "gap_blocks",
"min_mm": 0,
"max_mm": 0,
"max_reach": 3,
"delta_y": 0.0,
"ceiling_height": null,
"wall_offset": 0,
"notes": ""
},
{
"family": "sidewall",
"subfamily": "flat",
"movement_mode": "sprint",
"capability_metric": "gap_blocks",
"min_mm": 1,
"max_mm": 12,
"max_reach": 4,
"delta_y": 0.0,
"ceiling_height": null,
"wall_offset": 0,
"notes": ""
},
{
"family": "sidewall",
"subfamily": "flat",
"movement_mode": "sprint",
"capability_metric": "gap_blocks",
"min_mm": 0,
"max_mm": 0,
"max_reach": 3,
"delta_y": 0.0,
"ceiling_height": null,
"wall_offset": 1,
"notes": ""
},
{
"family": "sidewall",
"subfamily": "flat",
"movement_mode": "sprint",
"capability_metric": "gap_blocks",
"min_mm": 1,
"max_mm": 12,
"max_reach": 4,
"delta_y": 0.0,
"ceiling_height": null,
"wall_offset": 1,
"notes": ""
},
{
"family": "sidewall",
"subfamily": "flat",
"movement_mode": "walk",
"capability_metric": "gap_blocks",
"min_mm": 0,
"max_mm": 1,
"max_reach": 2,
"delta_y": 0.0,
"ceiling_height": null,
"wall_offset": 0,
"notes": ""
},
{
"family": "sidewall",
"subfamily": "flat",
"movement_mode": "walk",
"capability_metric": "gap_blocks",
"min_mm": 2,
"max_mm": 12,
"max_reach": 3,
"delta_y": 0.0,
"ceiling_height": null,
"wall_offset": 0,
"notes": ""
},
{
"family": "sidewall",
"subfamily": "flat",
"movement_mode": "walk",
"capability_metric": "gap_blocks",
"min_mm": 0,
"max_mm": 1,
"max_reach": 2,
"delta_y": 0.0,
"ceiling_height": null,
"wall_offset": 1,
"notes": ""
},
{
"family": "sidewall",
"subfamily": "flat",
"movement_mode": "walk",
"capability_metric": "gap_blocks",
"min_mm": 2,
"max_mm": 12,
"max_reach": 3,
"delta_y": 0.0,
"ceiling_height": null,
"wall_offset": 1,
"notes": ""
}
]

View file

@ -0,0 +1,71 @@
# Momentum Capabilities
This file compresses the full theory matrix into `mm` breakpoint bands that can
be consumed directly by the planner.
| family | subfamily | movement_mode | qualifiers | mm_range | reach |
| --- | --- | --- | --- | --- | --- |
| ceiling | headhitter | sprint | ceil=1.8125 | 0..1 | max_gap=none |
| ceiling | headhitter | sprint | ceil=1.8125 | 2..12 | max_gap=1 |
| ceiling | headhitter | sprint | ceil=2.0 | 0..12 | max_gap=1 |
| ceiling | headhitter | sprint | ceil=2.5 | 0..7 | max_gap=2 |
| ceiling | headhitter | sprint | ceil=2.5 | 8..12 | max_gap=3 |
| ceiling | headhitter | sprint | ceil=3.0 | 0..1 | max_gap=3 |
| ceiling | headhitter | sprint | ceil=3.0 | 2..12 | max_gap=4 |
| ceiling | headhitter | sprint | ceil=4.0 | 0..0 | max_gap=3 |
| ceiling | headhitter | sprint | ceil=4.0 | 1..12 | max_gap=4 |
| linear | ascend | sprint | dy=1.0 | 0..0 | max_gap=2 |
| linear | ascend | sprint | dy=1.0 | 1..12 | max_gap=3 |
| linear | ascend | walk | dy=1.0 | 0..0 | max_gap=1 |
| linear | ascend | walk | dy=1.0 | 1..12 | max_gap=2 |
| linear | descend | sprint | dy=-2.0 | 0..0 | max_gap=4 |
| linear | descend | sprint | dy=-2.0 | 1..12 | max_gap=5 |
| linear | descend | sprint | dy=-1.0 | 0..1 | max_gap=4 |
| linear | descend | sprint | dy=-1.0 | 2..12 | max_gap=5 |
| linear | descend | walk | dy=-2.0 | 0..2 | max_gap=3 |
| linear | descend | walk | dy=-2.0 | 3..12 | max_gap=4 |
| linear | descend | walk | dy=-1.0 | 0..0 | max_gap=2 |
| linear | descend | walk | dy=-1.0 | 1..12 | max_gap=3 |
| linear | flat | sprint | dy=0.0 | 0..0 | max_gap=3 |
| linear | flat | sprint | dy=0.0 | 1..12 | max_gap=4 |
| linear | flat | walk | dy=0.0 | 0..1 | max_gap=2 |
| linear | flat | walk | dy=0.0 | 2..12 | max_gap=3 |
| neo | neo | sprint | - | 0..1 | max_wall_width=3 |
| neo | neo | sprint | - | 2..12 | max_wall_width=4 |
| neo | neo | walk | - | 0..0 | max_wall_width=1 |
| neo | neo | walk | - | 1..5 | max_wall_width=2 |
| neo | neo | walk | - | 6..12 | max_wall_width=3 |
| sidewall | ascend | sprint | dy=1.0, wo=0 | 0..0 | max_gap=2 |
| sidewall | ascend | sprint | dy=1.0, wo=0 | 1..12 | max_gap=3 |
| sidewall | ascend | sprint | dy=1.0, wo=1 | 0..0 | max_gap=2 |
| sidewall | ascend | sprint | dy=1.0, wo=1 | 1..12 | max_gap=3 |
| sidewall | ascend | walk | dy=1.0, wo=0 | 0..0 | max_gap=1 |
| sidewall | ascend | walk | dy=1.0, wo=0 | 1..12 | max_gap=2 |
| sidewall | ascend | walk | dy=1.0, wo=1 | 0..0 | max_gap=1 |
| sidewall | ascend | walk | dy=1.0, wo=1 | 1..12 | max_gap=2 |
| sidewall | descend | sprint | dy=-2.0, wo=0 | 0..0 | max_gap=4 |
| sidewall | descend | sprint | dy=-2.0, wo=0 | 1..12 | max_gap=5 |
| sidewall | descend | sprint | dy=-2.0, wo=1 | 0..0 | max_gap=4 |
| sidewall | descend | sprint | dy=-2.0, wo=1 | 1..12 | max_gap=5 |
| sidewall | descend | sprint | dy=-1.0, wo=0 | 0..1 | max_gap=4 |
| sidewall | descend | sprint | dy=-1.0, wo=0 | 2..12 | max_gap=5 |
| sidewall | descend | sprint | dy=-1.0, wo=1 | 0..1 | max_gap=4 |
| sidewall | descend | sprint | dy=-1.0, wo=1 | 2..12 | max_gap=5 |
| sidewall | descend | walk | dy=-2.0, wo=0 | 0..0 | max_gap=2 |
| sidewall | descend | walk | dy=-2.0, wo=0 | 1..2 | max_gap=3 |
| sidewall | descend | walk | dy=-2.0, wo=0 | 3..12 | max_gap=4 |
| sidewall | descend | walk | dy=-2.0, wo=1 | 0..0 | max_gap=2 |
| sidewall | descend | walk | dy=-2.0, wo=1 | 1..2 | max_gap=3 |
| sidewall | descend | walk | dy=-2.0, wo=1 | 3..12 | max_gap=4 |
| sidewall | descend | walk | dy=-1.0, wo=0 | 0..0 | max_gap=2 |
| sidewall | descend | walk | dy=-1.0, wo=0 | 1..12 | max_gap=3 |
| sidewall | descend | walk | dy=-1.0, wo=1 | 0..0 | max_gap=2 |
| sidewall | descend | walk | dy=-1.0, wo=1 | 1..12 | max_gap=3 |
| sidewall | flat | sprint | dy=0.0, wo=0 | 0..0 | max_gap=3 |
| sidewall | flat | sprint | dy=0.0, wo=0 | 1..12 | max_gap=4 |
| sidewall | flat | sprint | dy=0.0, wo=1 | 0..0 | max_gap=3 |
| sidewall | flat | sprint | dy=0.0, wo=1 | 1..12 | max_gap=4 |
| sidewall | flat | walk | dy=0.0, wo=0 | 0..1 | max_gap=2 |
| sidewall | flat | walk | dy=0.0, wo=0 | 2..12 | max_gap=3 |
| sidewall | flat | walk | dy=0.0, wo=1 | 0..1 | max_gap=2 |
| sidewall | flat | walk | dy=0.0, wo=1 | 2..12 | max_gap=3 |

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -1,5 +1,13 @@
#!/usr/bin/env bash
mcc_cmd_live() {
if [[ -n "${SESSION:-}" ]]; then
mcc-cmd --session "$SESSION" "$1"
else
mcc-cmd "$1"
fi
}
manifest_cases_for_query() {
local manifest_path="$1"
local family_csv="$2"
@ -64,41 +72,42 @@ run_test() {
local name="$1"
local start_x="$2" start_y="$3" start_z="$4"
local dest_x="$5" dest_y="$6" dest_z="$7"
local username="${USERNAME:-MCCBot}"
TEST_NUM=$((TEST_NUM + 1))
echo ""
echo "=== TEST $TEST_NUM: $name ==="
echo " Start: ($start_x, $start_y, $start_z) -> Dest: ($dest_x, $dest_y, $dest_z)"
mcc-cmd "respawn" 2>/dev/null || true
mcc_cmd_live "respawn" 2>/dev/null || true
sleep 0.5
mc-rcon "gamemode creative MCCBot" >/dev/null 2>&1
mc-rcon "gamemode creative $username" >/dev/null 2>&1
sleep 0.3
mc-rcon "tp MCCBot ${start_x}.5 ${start_y} ${start_z}.5" >/dev/null 2>&1
mc-rcon "tp $username ${start_x}.5 ${start_y} ${start_z}.5" >/dev/null 2>&1
sleep 2
mc-rcon "gamemode survival MCCBot" >/dev/null 2>&1
mc-rcon "gamemode survival $username" >/dev/null 2>&1
sleep 1
: > "$LOG"
sleep 0.5
mcc-cmd "pathfind $dest_x $dest_y $dest_z"
mcc_cmd_live "pathfind $dest_x $dest_y $dest_z"
sleep 8
local a_star_result
a_star_result=$(grep -a '\[A\*\]' "$LOG" | head -3 | sed 's/\x1b\[[0-9;]*m//g')
a_star_result=$(grep -a '\[A\*\]' "$LOG" | head -3 | sed 's/\x1b\[[0-9;]*m//g' || true)
local path_exec
path_exec=$(grep -a '\[PathExec\]' "$LOG" | sed 's/\x1b\[[0-9;]*m//g')
path_exec=$(grep -a '\[PathExec\]' "$LOG" | sed 's/\x1b\[[0-9;]*m//g' || true)
local path_mgr
path_mgr=$(grep -a '\[PathMgr\]' "$LOG" | sed 's/\x1b\[[0-9;]*m//g')
path_mgr=$(grep -a '\[PathMgr\]' "$LOG" | sed 's/\x1b\[[0-9;]*m//g' || true)
local nav_segs
nav_segs=$(grep -a '\[Navigate\].*seg' "$LOG" | sed 's/\x1b\[[0-9;]*m//g')
nav_segs=$(grep -a '\[Navigate\].*seg' "$LOG" | sed 's/\x1b\[[0-9;]*m//g' || true)
local physics_line
physics_line=$(grep -a '\[Physics\]' "$LOG" | tail -1 | sed 's/\x1b\[[0-9;]*m//g')
physics_line=$(grep -a '\[Physics\]' "$LOG" | tail -1 | sed 's/\x1b\[[0-9;]*m//g' || true)
local result="invalid_live_case"
if echo "$path_mgr" | grep -q "complete"; then

View file

@ -6,6 +6,8 @@ def _world_recipe_id(case: TheoryCase) -> str:
return f"linear-{case.subfamily}"
if case.family == "neo":
return "neo-wall"
if case.family == "sidewall":
return f"sidewall-{case.subfamily}"
return "ceiling-headhitter"
@ -18,6 +20,14 @@ def _canonical_goal(case: TheoryCase) -> tuple[dict[str, float], dict[str, float
if case.family == "neo":
goal_z = 100 + (case.wall_width or 1)
return start, {"x": 102.0, "y": 80.0, "z": float(goal_z)}
if case.family == "sidewall":
goal_y = 80.0 + (case.delta_y or 0.0)
goal_x = 100 + (case.gap_blocks or 0) + 1
wall_z_offset = 100 + 1 + (case.wall_offset or 0)
return (
{"x": 100.5, "y": 80.0, "z": 100.5},
{"x": float(goal_x), "y": goal_y, "z": 100.0},
)
goal_x = 100 + (case.gap_blocks or 0) + 1
return start, {"x": float(goal_x), "y": 80.0, "z": 100.0}
@ -27,7 +37,7 @@ def _select_boundary_case(
subfamily: str,
reachable: list[TheoryCase],
) -> TheoryCase:
if family == "linear":
if family in {"linear", "sidewall"}:
preferred_gap_by_subfamily = {
"flat": 5,
"ascend": 2,
@ -105,6 +115,7 @@ def build_canonical_live_cases(cases: list[TheoryCase]) -> list[CanonicalLiveCas
delta_y=case.delta_y,
ceiling_height=case.ceiling_height,
wall_width=case.wall_width,
wall_offset=case.wall_offset,
start=start,
goal=goal,
)

View file

@ -0,0 +1,200 @@
from collections import defaultdict
from tools.pathing_theory.models import MomentumCapabilityBand, TheoryCase
def _linear_group_key(case: TheoryCase) -> tuple[object, ...]:
return (
case.family,
case.subfamily,
case.movement_mode,
"gap_blocks",
case.delta_y,
None,
None,
)
def _neo_group_key(case: TheoryCase) -> tuple[object, ...]:
return (
case.family,
case.subfamily,
case.movement_mode,
"wall_width",
None,
None,
None,
)
def _ceiling_group_key(case: TheoryCase) -> tuple[object, ...]:
return (
case.family,
case.subfamily,
case.movement_mode,
"gap_blocks",
None,
case.ceiling_height,
None,
)
def _sidewall_group_key(case: TheoryCase) -> tuple[object, ...]:
return (
case.family,
case.subfamily,
case.movement_mode,
"gap_blocks",
case.delta_y,
None,
case.wall_offset,
)
def _case_group_key(case: TheoryCase) -> tuple[object, ...]:
if case.family == "linear":
return _linear_group_key(case)
if case.family == "neo":
return _neo_group_key(case)
if case.family == "ceiling":
return _ceiling_group_key(case)
if case.family == "sidewall":
return _sidewall_group_key(case)
raise ValueError(f"Unsupported theory family for capability bands: {case.family}")
def _case_reach_value(case: TheoryCase) -> int | None:
if not case.expected_reachable:
return None
if case.family in {"linear", "ceiling", "sidewall"}:
return case.gap_blocks
if case.family == "neo":
return case.wall_width
raise ValueError(f"Unsupported theory family for capability bands: {case.family}")
def _compress_mm_ranges(
mm_to_reach: list[tuple[int, int | None]],
family: str,
subfamily: str,
movement_mode: str,
capability_metric: str,
delta_y: float | None,
ceiling_height: float | None,
wall_offset: int | None = None,
) -> list[MomentumCapabilityBand]:
bands: list[MomentumCapabilityBand] = []
current_start = mm_to_reach[0][0]
current_end = current_start
current_reach = mm_to_reach[0][1]
for mm, max_reach in mm_to_reach[1:]:
if max_reach == current_reach and mm == current_end + 1:
current_end = mm
continue
bands.append(
MomentumCapabilityBand(
family=family,
subfamily=subfamily,
movement_mode=movement_mode,
capability_metric=capability_metric,
min_mm=current_start,
max_mm=current_end,
max_reach=current_reach,
delta_y=delta_y,
ceiling_height=ceiling_height,
wall_offset=wall_offset,
)
)
current_start = mm
current_end = mm
current_reach = max_reach
bands.append(
MomentumCapabilityBand(
family=family,
subfamily=subfamily,
movement_mode=movement_mode,
capability_metric=capability_metric,
min_mm=current_start,
max_mm=current_end,
max_reach=current_reach,
delta_y=delta_y,
ceiling_height=ceiling_height,
wall_offset=wall_offset,
)
)
return bands
def build_momentum_capability_bands(
cases: list[TheoryCase],
) -> list[MomentumCapabilityBand]:
grouped_cases: dict[tuple[object, ...], list[TheoryCase]] = defaultdict(list)
for case in cases:
grouped_cases[_case_group_key(case)].append(case)
bands: list[MomentumCapabilityBand] = []
for key in sorted(grouped_cases):
family, subfamily, movement_mode, capability_metric, delta_y, ceiling_height, wall_offset = key
mm_groups: dict[int, list[TheoryCase]] = defaultdict(list)
for case in grouped_cases[key]:
mm_groups[case.momentum_ticks].append(case)
mm_to_reach = [
(
mm,
max(
(
_case_reach_value(case)
for case in mm_groups[mm]
if _case_reach_value(case) is not None
),
default=None,
),
)
for mm in sorted(mm_groups)
]
bands.extend(
_compress_mm_ranges(
mm_to_reach=mm_to_reach,
family=family,
subfamily=subfamily,
movement_mode=movement_mode,
capability_metric=capability_metric,
delta_y=delta_y,
ceiling_height=ceiling_height,
wall_offset=wall_offset,
)
)
return bands
def _format_range(band: MomentumCapabilityBand) -> str:
return f"{band.min_mm}..{band.max_mm}"
def _format_reach(band: MomentumCapabilityBand) -> str:
label = "max_gap" if band.capability_metric == "gap_blocks" else "max_wall_width"
value = "none" if band.max_reach is None else str(band.max_reach)
return f"{label}={value}"
def format_momentum_capability_lines(
bands: list[MomentumCapabilityBand],
) -> list[str]:
lines: list[str] = []
for band in bands:
parts = [band.family, band.subfamily, band.movement_mode]
if band.delta_y is not None:
parts.append(f"dy={band.delta_y}")
if band.ceiling_height is not None:
parts.append(f"ceil={band.ceiling_height}")
if band.wall_offset is not None:
parts.append(f"wo={band.wall_offset}")
parts.append(f"mm={_format_range(band)}")
parts.append(_format_reach(band))
lines.append(" | ".join(parts))
return lines

View file

@ -12,6 +12,7 @@ class TheoryCase:
delta_y: float | None
ceiling_height: float | None
wall_width: int | None
wall_offset: int | None
expected_reachable: bool
landing_x: float | None
apex_y: float | None
@ -19,6 +20,21 @@ class TheoryCase:
notes: str = ""
@dataclass(frozen=True)
class MomentumCapabilityBand:
family: str
subfamily: str
movement_mode: str
capability_metric: str
min_mm: int
max_mm: int
max_reach: int | None
delta_y: float | None = None
ceiling_height: float | None = None
wall_offset: int | None = None
notes: str = ""
@dataclass(frozen=True)
class CanonicalLiveCase:
case_id: str
@ -34,5 +50,6 @@ class CanonicalLiveCase:
delta_y: float | None
ceiling_height: float | None
wall_width: int | None
wall_offset: int | None
start: dict[str, float]
goal: dict[str, float]

View file

@ -1,3 +1,4 @@
import math
from dataclasses import dataclass
from typing import Optional
@ -21,6 +22,10 @@ HORIZONTAL_VELOCITY_THRESHOLD_SQR = 9.0e-6
VERTICAL_VELOCITY_THRESHOLD = 0.003
HALF_WIDTH = PLAYER_WIDTH / 2.0
# Reliable late-jump timing is slightly before the full 0.8 block walk-off limit.
# Baritone uses a 0.7 threshold for the analogous 2-gap flat parkour execution.
EDGE_TAKEOFF_X = 0.7
TARGET_BLOCK_WIDTH = 1.0
@dataclass
@ -28,8 +33,10 @@ class TickState:
tick: int = 0
x: float = 0.0
y: float = 0.0
z: float = 0.0
vx: float = 0.0
vy: float = 0.0
vz: float = 0.0
on_ground: bool = True
@ -38,46 +45,198 @@ def get_ground_speed(block_friction: float = DEFAULT_BLOCK_FRICTION) -> float:
return MOVEMENT_SPEED * (GROUND_ACCEL_FACTOR / (friction * friction * friction))
def build_momentum_velocity(
momentum_ticks: int,
block_friction: float = DEFAULT_BLOCK_FRICTION,
) -> float:
vx = 0.0
ground_friction = block_friction * FRICTION_MULTIPLIER
for _ in range(momentum_ticks):
vx += INPUT_FRICTION * get_ground_speed(block_friction)
vx *= ground_friction
return vx
def build_momentum_velocity_2d(
momentum_ticks: int,
yaw_rad: float,
strafe_input: float = 0.0,
block_friction: float = DEFAULT_BLOCK_FRICTION,
wall_z: Optional[float] = None,
start_z: float = 0.0,
) -> tuple[float, float, float]:
"""Build pre-jump velocity with yaw and optional strafe, returning (vx, vz, z).
Simulates ground ticks before the jump edge, accounting for yaw-split
acceleration, optional strafe, and wall collision on the z axis.
"""
cos_yaw = math.cos(yaw_rad)
sin_yaw = math.sin(yaw_rad)
ground_friction = block_friction * FRICTION_MULTIPLIER
ground_speed = get_ground_speed(block_friction)
vx, vz, z = 0.0, 0.0, start_z
for _ in range(momentum_ticks):
input_x = (cos_yaw + strafe_input * (-sin_yaw)) * INPUT_FRICTION
input_z = (sin_yaw + strafe_input * cos_yaw) * INPUT_FRICTION
vx += input_x * ground_speed
vz += input_z * ground_speed
z += vz
if wall_z is not None and z + HALF_WIDTH > wall_z:
z = wall_z - HALF_WIDTH
if vz > 0:
vz = 0.0
vx *= ground_friction
vz *= ground_friction
return vx, vz, z
def _get_overlap_window(
start_x: float,
end_x: float,
landing_x_start: float,
landing_width: Optional[float],
) -> Optional[tuple[float, float]]:
min_center_x = landing_x_start - HALF_WIDTH
max_center_x = (
None if landing_width is None else landing_x_start + landing_width + HALF_WIDTH
)
if start_x > end_x:
start_x, end_x = end_x, start_x
delta_x = end_x - start_x
if delta_x == 0.0:
if start_x < min_center_x:
return None
if max_center_x is not None and start_x > max_center_x:
return None
return 0.0, 1.0
if end_x < min_center_x:
return None
enter_t = 0.0 if start_x >= min_center_x else (min_center_x - start_x) / delta_x
if max_center_x is None:
exit_t = 1.0
else:
if start_x > max_center_x:
return None
exit_t = 1.0 if end_x <= max_center_x else (max_center_x - start_x) / delta_x
if exit_t < 0.0 or enter_t > 1.0 or enter_t > exit_t:
return None
return max(0.0, enter_t), min(1.0, exit_t)
def _find_landing_contact(
start_x: float,
start_y: float,
end_x: float,
end_y: float,
landing_y: float,
landing_x_start: float,
landing_width: Optional[float],
) -> Optional[tuple[float, float]]:
if start_y < landing_y or end_y > landing_y or start_y == end_y:
return None
overlap_window = _get_overlap_window(
start_x=start_x,
end_x=end_x,
landing_x_start=landing_x_start,
landing_width=landing_width,
)
if overlap_window is None:
return None
landing_t = (start_y - landing_y) / (start_y - end_y)
enter_t, exit_t = overlap_window
if landing_t < enter_t or landing_t > exit_t:
return None
landing_x = start_x + (end_x - start_x) * landing_t
return landing_x, landing_y
def simulate_jump(
sprint: bool = True,
momentum_ticks: int = 12,
ceiling_y: Optional[float] = None,
landing_y: float = 0.0,
landing_x_start: float = 0.0,
landing_width: Optional[float] = None,
max_ticks: int = 200,
yaw_degrees: float = 0.0,
strafe_input: float = 0.0,
wall_z: Optional[float] = None,
start_z: float = 0.0,
) -> list[TickState]:
x, y, vx, vy = 0.0, 0.0, 0.0, 0.0
yaw_rad = math.radians(yaw_degrees)
cos_yaw = math.cos(yaw_rad)
sin_yaw = math.sin(yaw_rad)
has_lateral = yaw_degrees != 0.0 or strafe_input != 0.0 or wall_z is not None
if has_lateral:
vx, vz, z = build_momentum_velocity_2d(
momentum_ticks, yaw_rad, strafe_input,
wall_z=wall_z, start_z=start_z,
)
else:
vx = build_momentum_velocity(momentum_ticks)
vz = 0.0
z = start_z
x, y, vy = EDGE_TAKEOFF_X, 0.0, 0.0
on_ground = True
trajectory: list[TickState] = []
jumped = False
ground_friction = DEFAULT_BLOCK_FRICTION * FRICTION_MULTIPLIER
trajectory.append(TickState(0, x, y, vx, vy, on_ground))
trajectory.append(TickState(0, x, y, z, vx, vy, vz, on_ground))
for tick in range(1, max_ticks + 1):
if vx * vx < HORIZONTAL_VELOCITY_THRESHOLD_SQR:
if vx * vx + vz * vz < HORIZONTAL_VELOCITY_THRESHOLD_SQR:
vx = 0.0
vz = 0.0
if abs(vy) < VERTICAL_VELOCITY_THRESHOLD:
vy = 0.0
do_jump = False
if not jumped and tick > momentum_ticks and on_ground:
if not jumped and on_ground:
do_jump = True
jumped = True
if do_jump:
vy = max(BASE_JUMP_POWER, vy)
if sprint:
vx += SPRINT_JUMP_HORIZONTAL_BOOST
vx += SPRINT_JUMP_HORIZONTAL_BOOST * cos_yaw
vz += SPRINT_JUMP_HORIZONTAL_BOOST * sin_yaw
forward_input = 1.0 * INPUT_FRICTION
speed = get_ground_speed() if on_ground else AIR_ACCEL
vx += forward_input * speed
if has_lateral:
input_x = (cos_yaw + strafe_input * (-sin_yaw)) * INPUT_FRICTION
input_z = (sin_yaw + strafe_input * cos_yaw) * INPUT_FRICTION
vx += input_x * speed
vz += input_z * speed
else:
vx += INPUT_FRICTION * speed
new_x = x + vx
new_y = y + vy
new_z = z + vz
new_on_ground = False
if wall_z is not None and new_z + HALF_WIDTH > wall_z:
new_z = wall_z - HALF_WIDTH
if vz > 0:
vz = 0.0
if ceiling_y is not None:
head_y = new_y + PLAYER_HEIGHT
if head_y > ceiling_y:
@ -85,39 +244,24 @@ def simulate_jump(
if vy > 0:
vy = 0.0
floor_y = 0.0 if new_x < landing_x_start else landing_y
if jumped:
if new_x >= landing_x_start:
if landing_y >= 0:
if vy <= 0 and y >= landing_y and new_y <= landing_y:
new_y = landing_y
vy = 0.0
new_on_ground = True
elif vy <= 0 and new_y <= landing_y:
new_y = landing_y
vy = 0.0
new_on_ground = True
else:
if new_y <= landing_y:
new_y = landing_y
if vy < 0:
vy = 0.0
new_on_ground = True
if not new_on_ground and new_x < landing_x_start and new_y <= floor_y:
new_y = floor_y
if vy < 0:
vy = 0.0
new_on_ground = True
elif new_y <= 0.0:
new_y = 0.0
if vy < 0:
contact = _find_landing_contact(
start_x=x,
start_y=y,
end_x=new_x,
end_y=new_y,
landing_y=landing_y,
landing_x_start=landing_x_start,
landing_width=landing_width,
)
if contact is not None:
new_x, new_y = contact
vy = 0.0
new_on_ground = True
new_on_ground = True
x = new_x
y = new_y
z = new_z
on_ground = new_on_ground
vy -= GRAVITY
@ -125,10 +269,12 @@ def simulate_jump(
if on_ground:
vx *= ground_friction
vz *= ground_friction
else:
vx *= FRICTION_MULTIPLIER
vz *= FRICTION_MULTIPLIER
trajectory.append(TickState(tick, x, y, vx, vy, on_ground))
trajectory.append(TickState(tick, x, y, z, vx, vy, vz, on_ground))
if jumped and on_ground:
break
@ -142,6 +288,11 @@ def get_landing(
landing_x_start: float = 0.0,
momentum_ticks: int = 12,
ceiling_y: Optional[float] = None,
landing_width: Optional[float] = None,
yaw_degrees: float = 0.0,
strafe_input: float = 0.0,
wall_z: Optional[float] = None,
start_z: float = 0.0,
) -> Optional[tuple[float, float]]:
trajectory = simulate_jump(
sprint=sprint,
@ -149,6 +300,11 @@ def get_landing(
ceiling_y=ceiling_y,
landing_y=target_y,
landing_x_start=landing_x_start,
landing_width=landing_width,
yaw_degrees=yaw_degrees,
strafe_input=strafe_input,
wall_z=wall_z,
start_z=start_z,
)
was_air = False
for state in trajectory:
@ -189,7 +345,7 @@ def can_reach_gap(
if dy > 1.252:
return False, None, 0.0
needed_x = 0.5 + gap_blocks + HALF_WIDTH
needed_x = 0.5 + gap_blocks - HALF_WIDTH
landing_platform_start = 0.5 + gap_blocks
if gap_blocks == 0 and dy > 0:
@ -200,6 +356,7 @@ def can_reach_gap(
target_y=dy,
landing_x_start=landing_platform_start,
momentum_ticks=momentum_ticks,
landing_width=TARGET_BLOCK_WIDTH,
)
if result is None:
return False, None, needed_x
@ -212,3 +369,74 @@ def can_reach_gap(
return False, landing_x, needed_x
return True, landing_x, needed_x
SIDE_WALL_YAW_SWEEP = [0.0, 3.0, 5.0, 8.0, 10.0]
def can_reach_gap_with_side_wall(
gap_blocks: int,
dy: float,
wall_offset: int,
sprint: bool = True,
momentum_ticks: int = 12,
) -> tuple[bool, Optional[float], float]:
"""Check gap reachability with a side wall parallel to the jump direction.
wall_offset=0 means the wall is flush with the platform edge (wall at z=1.0
for a 1-wide platform centered at z=0.5). wall_offset=1 means one air block
between the platform edge and the wall face.
Sweeps yaw angles from 0 to 10 degrees toward the wall to find the
worst-case trajectory. Uses the most pessimistic result: if any realistic
yaw angle causes a failure, the case is marked unreachable or gets a
reduced margin. This models the real-world constraint where MCC's
pathfinder can't guarantee perfect yaw alignment.
"""
if dy > 1.252:
return False, None, 0.0
wall_z = 1.0 + wall_offset
start_z = 0.5
clearance = wall_z - (start_z + HALF_WIDTH)
if clearance < 0:
return False, None, 0.0
needed_x = 0.5 + gap_blocks - HALF_WIDTH
landing_platform_start = 0.5 + gap_blocks
if gap_blocks == 0 and dy > 0:
landing_platform_start = 0.5
worst_ok = True
worst_landing_x: Optional[float] = None
worst_margin: Optional[float] = None
for yaw in SIDE_WALL_YAW_SWEEP:
result = get_landing(
sprint=sprint,
target_y=dy,
landing_x_start=landing_platform_start,
momentum_ticks=momentum_ticks,
landing_width=TARGET_BLOCK_WIDTH,
yaw_degrees=yaw,
wall_z=wall_z,
start_z=start_z,
)
if result is None:
return False, worst_landing_x, needed_x
landing_x, landing_y = result
if abs(landing_y - dy) > 0.01:
return False, landing_x, needed_x
if gap_blocks > 0 and landing_x < needed_x:
return False, landing_x, needed_x
margin = landing_x - needed_x
if worst_margin is None or margin < worst_margin:
worst_margin = margin
worst_landing_x = landing_x
return True, worst_landing_x, needed_x

View file

@ -3,12 +3,18 @@ import json
from dataclasses import asdict
from pathlib import Path
from tools.pathing_theory.models import CanonicalLiveCase, TheoryCase
from tools.pathing_theory.capabilities import format_momentum_capability_lines
from tools.pathing_theory.models import (
CanonicalLiveCase,
MomentumCapabilityBand,
TheoryCase,
)
def write_theory_artifacts(
cases: list[TheoryCase],
canonical_cases: list[CanonicalLiveCase],
capability_bands: list[MomentumCapabilityBand],
output_dir: Path,
) -> None:
output_dir.mkdir(parents=True, exist_ok=True)
@ -17,6 +23,8 @@ def write_theory_artifacts(
csv_path = output_dir / "theory-matrix.csv"
md_path = output_dir / "theory-matrix.md"
canonical_path = output_dir / "canonical-live-cases.json"
capability_path = output_dir / "momentum-capabilities.json"
capability_md_path = output_dir / "momentum-capabilities.md"
json_path.write_text(
json.dumps([asdict(case) for case in cases], indent=2) + "\n",
@ -26,6 +34,10 @@ def write_theory_artifacts(
json.dumps([asdict(case) for case in canonical_cases], indent=2) + "\n",
encoding="utf-8",
)
capability_path.write_text(
json.dumps([asdict(band) for band in capability_bands], indent=2) + "\n",
encoding="utf-8",
)
with csv_path.open("w", encoding="utf-8", newline="") as handle:
writer = csv.DictWriter(handle, fieldnames=list(asdict(cases[0]).keys()))
@ -38,8 +50,8 @@ def write_theory_artifacts(
"",
"## Canonical live coverage",
"",
"This file is generated from `tools/sim_jump_reach.py` and is the first-wave authority",
"for theory-aligned linear, neo, and headhitter live suites.",
"This file is generated from `tools/pathing_theory/simulator.py` and is the first-wave authority",
"for theory-aligned linear, neo, headhitter, and sidewall live suites.",
"",
"| family | subfamily | movement_mode | case_id | expected_reachable | margin |",
"| --- | --- | --- | --- | --- | --- |",
@ -51,3 +63,29 @@ def write_theory_artifacts(
)
md_path.write_text("\n".join(lines) + "\n", encoding="utf-8")
capability_lines = [
"# Momentum Capabilities",
"",
"This file compresses the full theory matrix into `mm` breakpoint bands that can",
"be consumed directly by the planner.",
"",
"| family | subfamily | movement_mode | qualifiers | mm_range | reach |",
"| --- | --- | --- | --- | --- | --- |",
]
for band, line in zip(capability_bands, format_momentum_capability_lines(capability_bands)):
qualifiers: list[str] = []
if band.delta_y is not None:
qualifiers.append(f"dy={band.delta_y}")
if band.ceiling_height is not None:
qualifiers.append(f"ceil={band.ceiling_height}")
if band.wall_offset is not None:
qualifiers.append(f"wo={band.wall_offset}")
capability_lines.append(
f"| {band.family} | {band.subfamily} | {band.movement_mode} | "
f"{', '.join(qualifiers) if qualifiers else '-'} | "
f"{band.min_mm}..{band.max_mm} | "
f"{line.split(' | ')[-1]} |"
)
capability_md_path.write_text("\n".join(capability_lines) + "\n", encoding="utf-8")

View file

@ -1,5 +1,14 @@
from tools.pathing_theory.models import TheoryCase
from tools.pathing_theory.primitives import PLAYER_WIDTH, can_reach_gap, get_apex, get_landing
from tools.pathing_theory.primitives import (
PLAYER_WIDTH,
TARGET_BLOCK_WIDTH,
can_reach_gap,
can_reach_gap_with_side_wall,
get_apex,
get_landing,
)
MAX_MOMENTUM_TICKS = 12
def _float_token(value: float) -> str:
@ -9,111 +18,169 @@ def _float_token(value: float) -> str:
def build_theory_cases() -> list[TheoryCase]:
cases: list[TheoryCase] = []
for sprint, movement_mode, momentum_ticks in [
(False, "walk", 12),
(True, "sprint", 0),
(True, "sprint", 12),
for sprint, movement_mode in [
(False, "walk"),
(True, "sprint"),
]:
for gap in range(0, 8):
for delta_y in [0.0, 1.0, -1.0, -2.0]:
ok, landing_x, needed_x = can_reach_gap(
gap_blocks=gap,
dy=delta_y,
sprint=sprint,
momentum_ticks=momentum_ticks,
)
apex_y, _ = get_apex(sprint=sprint, momentum_ticks=momentum_ticks)
subfamily = (
"flat"
if delta_y == 0.0
else "ascend"
if delta_y > 0.0
else "descend"
)
for momentum_ticks in range(0, MAX_MOMENTUM_TICKS + 1):
apex_y, _ = get_apex(sprint=sprint, momentum_ticks=momentum_ticks)
for gap in range(0, 8):
for delta_y in [0.0, 1.0, -1.0, -2.0]:
ok, landing_x, needed_x = can_reach_gap(
gap_blocks=gap,
dy=delta_y,
sprint=sprint,
momentum_ticks=momentum_ticks,
)
subfamily = (
"flat"
if delta_y == 0.0
else "ascend"
if delta_y > 0.0
else "descend"
)
cases.append(
TheoryCase(
case_id=(
f"linear-{subfamily}-{movement_mode}-mm{momentum_ticks}"
f"-gap{gap}-dy{_float_token(delta_y)}"
),
family="linear",
subfamily=subfamily,
movement_mode=movement_mode,
momentum_ticks=momentum_ticks,
gap_blocks=gap,
delta_y=delta_y,
ceiling_height=None,
wall_width=None,
wall_offset=None,
expected_reachable=ok,
landing_x=landing_x,
apex_y=apex_y,
margin=None if landing_x is None else landing_x - needed_x,
)
)
for sprint, movement_mode in [
(False, "walk"),
(True, "sprint"),
]:
for momentum_ticks in range(0, MAX_MOMENTUM_TICKS + 1):
apex_y, _ = get_apex(sprint=sprint, momentum_ticks=momentum_ticks)
landing = get_landing(
sprint=sprint,
target_y=0.0,
landing_x_start=0.0,
momentum_ticks=momentum_ticks,
)
for wall_width in [1, 2, 3, 4]:
landing_x = None if landing is None else landing[0]
needed_x = wall_width + PLAYER_WIDTH
margin = None if landing_x is None else landing_x - needed_x
cases.append(
TheoryCase(
case_id=(
f"linear-{subfamily}-{movement_mode}-mm{momentum_ticks}"
f"-gap{gap}-dy{_float_token(delta_y)}"
),
family="linear",
subfamily=subfamily,
case_id=f"neo-neo-{movement_mode}-mm{momentum_ticks}-wall{wall_width}",
family="neo",
subfamily="neo",
movement_mode=movement_mode,
momentum_ticks=momentum_ticks,
gap_blocks=gap,
delta_y=delta_y,
gap_blocks=None,
delta_y=0.0,
ceiling_height=None,
wall_width=None,
expected_reachable=ok,
wall_width=wall_width,
wall_offset=None,
expected_reachable=margin is not None and margin >= 0.0,
landing_x=landing_x,
apex_y=apex_y,
margin=None if landing_x is None else landing_x - needed_x,
margin=margin,
)
)
landing = get_landing(
sprint=True,
target_y=0.0,
landing_x_start=0.0,
momentum_ticks=12,
)
for wall_width in [1, 2, 3, 4]:
landing_x = None if landing is None else landing[0]
needed_x = wall_width + PLAYER_WIDTH
margin = None if landing_x is None else landing_x - needed_x
cases.append(
TheoryCase(
case_id=f"neo-neo-sprint-mm12-wall{wall_width}",
family="neo",
subfamily="neo",
movement_mode="sprint",
momentum_ticks=12,
gap_blocks=None,
delta_y=0.0,
ceiling_height=None,
wall_width=wall_width,
expected_reachable=margin is not None and margin >= 0.0,
landing_x=landing_x,
apex_y=get_apex(sprint=True, momentum_ticks=12)[0],
margin=margin,
)
)
for ceiling_height in [4.0, 3.0, 2.5, 2.0, 1.8125]:
for gap in [1, 2, 3, 4]:
landing = get_landing(
for momentum_ticks in range(0, MAX_MOMENTUM_TICKS + 1):
for ceiling_height in [4.0, 3.0, 2.5, 2.0, 1.8125]:
apex_y, _ = get_apex(
sprint=True,
target_y=0.0,
landing_x_start=0.5 + gap,
momentum_ticks=12,
momentum_ticks=momentum_ticks,
ceiling_y=ceiling_height,
)
landing_x = None if landing is None else landing[0]
needed_x = 0.5 + gap + (PLAYER_WIDTH / 2.0)
margin = None if landing_x is None else landing_x - needed_x
cases.append(
TheoryCase(
case_id=(
f"ceiling-headhitter-sprint-mm12-gap{gap}"
f"-ceil{str(ceiling_height).replace('.', 'p')}"
),
family="ceiling",
subfamily="headhitter",
movement_mode="sprint",
momentum_ticks=12,
gap_blocks=gap,
delta_y=0.0,
ceiling_height=ceiling_height,
wall_width=None,
expected_reachable=margin is not None and margin >= 0.0,
landing_x=landing_x,
apex_y=get_apex(
sprint=True,
momentum_ticks=12,
ceiling_y=ceiling_height,
)[0],
margin=margin,
for gap in [1, 2, 3, 4]:
landing = get_landing(
sprint=True,
target_y=0.0,
landing_x_start=0.5 + gap,
momentum_ticks=momentum_ticks,
ceiling_y=ceiling_height,
landing_width=TARGET_BLOCK_WIDTH,
)
)
landing_x = None if landing is None else landing[0]
needed_x = 0.5 + gap - (PLAYER_WIDTH / 2.0)
margin = None if landing_x is None else landing_x - needed_x
cases.append(
TheoryCase(
case_id=(
f"ceiling-headhitter-sprint-mm{momentum_ticks}-gap{gap}"
f"-ceil{str(ceiling_height).replace('.', 'p')}"
),
family="ceiling",
subfamily="headhitter",
movement_mode="sprint",
momentum_ticks=momentum_ticks,
gap_blocks=gap,
delta_y=0.0,
ceiling_height=ceiling_height,
wall_width=None,
wall_offset=None,
expected_reachable=margin is not None and margin >= 0.0,
landing_x=landing_x,
apex_y=apex_y,
margin=margin,
)
)
# --- Side-wall jump family ---
for sprint, movement_mode in [
(False, "walk"),
(True, "sprint"),
]:
for momentum_ticks in range(0, MAX_MOMENTUM_TICKS + 1):
apex_y, _ = get_apex(sprint=sprint, momentum_ticks=momentum_ticks)
for wall_offset in [0, 1]:
for gap in range(0, 8):
for delta_y in [0.0, 1.0, -1.0, -2.0]:
ok, landing_x, needed_x = can_reach_gap_with_side_wall(
gap_blocks=gap,
dy=delta_y,
wall_offset=wall_offset,
sprint=sprint,
momentum_ticks=momentum_ticks,
)
subfamily = (
"flat"
if delta_y == 0.0
else "ascend"
if delta_y > 0.0
else "descend"
)
cases.append(
TheoryCase(
case_id=(
f"sidewall-{subfamily}-{movement_mode}-mm{momentum_ticks}"
f"-gap{gap}-dy{_float_token(delta_y)}-wo{wall_offset}"
),
family="sidewall",
subfamily=subfamily,
movement_mode=movement_mode,
momentum_ticks=momentum_ticks,
gap_blocks=gap,
delta_y=delta_y,
ceiling_height=None,
wall_width=None,
wall_offset=wall_offset,
expected_reachable=ok,
landing_x=landing_x,
apex_y=apex_y,
margin=None if landing_x is None else landing_x - needed_x,
)
)
return cases

View file

@ -23,10 +23,15 @@ import sys
REPO_ROOT = Path(__file__).resolve().parent.parent
if str(REPO_ROOT) not in sys.path:
sys.path.insert(0, str(REPO_ROOT))
from tools.pathing_theory.capabilities import (
build_momentum_capability_bands,
format_momentum_capability_lines,
)
from tools.pathing_theory.canonical import build_canonical_live_cases
from tools.pathing_theory.primitives import (
PLAYER_WIDTH,
can_reach_gap,
can_reach_gap_with_side_wall,
get_apex,
get_landing,
simulate_jump,
@ -179,30 +184,67 @@ def analyze_all(verbose: bool = False) -> list[dict]:
else:
print(f" {ceil:>7.4f}b {'N/A':>12}")
# --- Part 6: Verbose ---
# --- Part 6: Side-wall jumps ---
print(f"\n[6] Side-Wall Jump Feasibility (Sprint, 12t momentum)")
print(f" Wall parallel to jump direction, flush with platform edge (wo=0)")
print(f" Yaw sweep 0-10 deg, worst-case used.")
print()
dy_values_sw = [1.0, 0.0, -1.0, -2.0]
header_sw = f" {'Gap':>4}"
for dy in dy_values_sw:
sign = "+" if dy > 0 else ""
header_sw += f" {sign}{dy:>5.1f}(L) {sign}{dy:>5.1f}(W)"
print(header_sw)
print(f" {'----':>4}" + " ---------- ----------" * len(dy_values_sw))
for gap in range(0, 7):
row = f" {gap:>4}"
for dy in dy_values_sw:
ok_lin, _, _ = can_reach_gap(gap, dy, sprint=True, momentum_ticks=12)
ok_sw, _, _ = can_reach_gap_with_side_wall(
gap, dy, wall_offset=0, sprint=True, momentum_ticks=12,
)
lin_str = "YES" if ok_lin else "no"
sw_str = "YES" if ok_sw else "no"
marker = " " if ok_lin == ok_sw else "*"
row += f" {lin_str:>6} {sw_str:>6}{marker}"
print(row)
print()
print(" L=linear (no wall), W=wall (wo=0), *=reachability differs")
# --- Part 7: Verbose ---
if verbose:
for label, sp in [("Sprint", True), ("Walk", False)]:
print(f"\n[V] {label} Jump Trajectory (12t momentum, flat)")
print(f" {'Tick':>4} {'X':>10} {'Y':>10} {'VX':>10} {'VY':>10} {'Gnd':>5}")
print(f" {'Tick':>4} {'X':>10} {'Y':>10} {'Z':>10} {'VX':>10} {'VY':>10} {'VZ':>10} {'Gnd':>5}")
traj = simulate_jump(sprint=sp, momentum_ticks=12, landing_y=0.0)
for s in traj:
g = "G" if s.on_ground else ""
print(f" {s.tick:>4} {s.x:>10.4f} {s.y:>10.4f} "
f"{s.vx:>10.6f} {s.vy:>10.6f} {g:>5}")
print(f" {s.tick:>4} {s.x:>10.4f} {s.y:>10.4f} {s.z:>10.4f} "
f"{s.vx:>10.6f} {s.vy:>10.6f} {s.vz:>10.6f} {g:>5}")
# +1 ascending sprint jump
print(f"\n[V] Sprint +1 Ascending Trajectory (12t mm, gap=1)")
print(f" {'Tick':>4} {'X':>10} {'Y':>10} {'VX':>10} {'VY':>10} {'Gnd':>5}")
traj = simulate_jump(sprint=True, momentum_ticks=12,
landing_y=1.0, landing_x_start=1.5)
print(f"\n[V] Sprint flat with side wall (12t mm, yaw=10, wo=0)")
print(f" {'Tick':>4} {'X':>10} {'Y':>10} {'Z':>10} {'VX':>10} {'VY':>10} {'VZ':>10} {'Gnd':>5}")
traj = simulate_jump(sprint=True, momentum_ticks=12, landing_y=0.0,
landing_x_start=4.5, landing_width=1.0,
yaw_degrees=10.0, wall_z=1.0, start_z=0.5)
for s in traj:
g = "G" if s.on_ground else ""
print(f" {s.tick:>4} {s.x:>10.4f} {s.y:>10.4f} "
f"{s.vx:>10.6f} {s.vy:>10.6f} {g:>5}")
print(f" {s.tick:>4} {s.x:>10.4f} {s.y:>10.4f} {s.z:>10.4f} "
f"{s.vx:>10.6f} {s.vy:>10.6f} {s.vz:>10.6f} {g:>5}")
return results
def list_momentum_capabilities() -> None:
cases = build_theory_cases()
bands = build_momentum_capability_bands(cases)
for line in format_momentum_capability_lines(bands):
print(line)
def main():
parser = argparse.ArgumentParser(
description="Minecraft jump reachability simulator (Java 1.14+)")
@ -212,15 +254,27 @@ def main():
help="Export results to CSV file")
parser.add_argument("--write-artifacts", type=str, default=None,
help="Write tracked theory artifacts to a directory")
parser.add_argument("--list-capabilities", action="store_true",
help="List compressed mm breakpoint capabilities")
args = parser.parse_args()
if args.write_artifacts:
cases = build_theory_cases()
canonical_cases = build_canonical_live_cases(cases)
write_theory_artifacts(cases, canonical_cases, Path(args.write_artifacts))
capability_bands = build_momentum_capability_bands(cases)
write_theory_artifacts(
cases,
canonical_cases,
capability_bands,
Path(args.write_artifacts),
)
print(f"Wrote theory artifacts to {args.write_artifacts}")
return
if args.list_capabilities:
list_momentum_capabilities()
return
results = analyze_all(verbose=args.verbose)
if args.csv and results:

869
tools/test-parkour.py Normal file
View file

@ -0,0 +1,869 @@
#!/usr/bin/env python3
"""Full-coverage parkour test suite for MCC pathfinding.
Reads momentum-capabilities.json to derive a test matrix, builds multi-segment
jump courses via RCON, and verifies MCC can navigate (or correctly reject) each
one. Stops testing larger gaps once the first failure is seen per group.
Usage:
source tools/mcc-env.sh
python3 tools/test-parkour.py [OPTIONS]
Options:
--list-cases Print test matrix and exit
--dry-run Build courses only, do not navigate
--filter PATTERN Hierarchical filter (see examples below)
--username NAME MCC username (default: MCCBot)
--rcon-port PORT RCON port (default: 25575)
--rcon-password PASS RCON password (default: test123)
--wait SECONDS Seconds to wait per navigation (default: 15)
--results PATH Write JSONL results to PATH
Filter examples:
--filter linear All linear tests
--filter linear/flat Only linear flat (dy=0)
--filter linear/ascend Only linear ascend (dy>0)
--filter linear/descend/dy-1 Linear descend, dy=-1 only
--filter neo All neo tests
--filter ceiling All ceiling tests
--filter ceiling/headhitter/ceil2.5 Ceiling with height 2.5
--filter linear-flat-gap4 Exact case_id match
Multiple filters: --filter linear,neo
Note: sidewall family is excluded by default (identical max_reach to linear,
wall does not affect A* block-level pathfinding).
"""
from __future__ import annotations
import argparse
import json
import math
import os
import re
import socket
import struct
import sys
import time
from dataclasses import dataclass, field
from pathlib import Path
from typing import Optional
REPO_ROOT = Path(__file__).resolve().parent.parent
CAPABILITIES_PATH = REPO_ROOT / "tools" / "pathing_data" / "momentum-capabilities.json"
SEGMENTS = 3
CLEAR_MARGIN = 7
LINEAR_RUNWAY = 4
SIDEWALL_RUNWAY = 2
CEILING_HEIGHTS_TO_TEST = [2.0, 2.5, 4.0]
# Families whose A* max_reach is identical to linear (wall doesn't affect
# pathfinder block-level decisions). Excluded from the default matrix.
SKIP_FAMILIES = {"sidewall"}
NEO_LANDING_GAP = 3 # blocks between wall-end and next wall-start (1 air + platform + 1 air)
# ---------------------------------------------------------------------------
# RCON client
# ---------------------------------------------------------------------------
class RconClient:
def __init__(self, host: str = "localhost", port: int = 25575, password: str = "test123"):
self._host = host
self._port = port
self._password = password
self._sock: Optional[socket.socket] = None
def connect(self) -> None:
self._sock = socket.socket()
self._sock.settimeout(5)
self._sock.connect((self._host, self._port))
self._send(1, 3, self._password)
resp = self._recv()
rid = struct.unpack("<i", resp[:4])[0]
if rid == -1:
raise RuntimeError("RCON auth failed")
def command(self, cmd: str) -> str:
if self._sock is None:
self.connect()
self._send(2, 2, cmd)
resp = self._recv()
return resp[8:-2].decode(errors="replace")
def close(self) -> None:
if self._sock:
self._sock.close()
self._sock = None
def _send(self, req_id: int, pkt_type: int, body: str) -> None:
encoded = body.encode()
header = struct.pack("<iii", 10 + len(encoded), req_id, pkt_type)
assert self._sock is not None
self._sock.send(header + encoded + b"\x00\x00")
def _recv(self) -> bytes:
assert self._sock is not None
length_data = b""
while len(length_data) < 4:
length_data += self._sock.recv(4 - len(length_data))
length = struct.unpack("<i", length_data)[0]
data = b""
while len(data) < length:
data += self._sock.recv(length - len(data))
return data
# ---------------------------------------------------------------------------
# MCC command interface
# ---------------------------------------------------------------------------
class MccClient:
def __init__(self, session: str):
self.session = session
session_root = Path(os.environ.get("TMPDIR", "/tmp")) / "mcc-debug" / session
self.input_file = session_root / "mcc_input.txt"
self.log_file = session_root / "mcc-debug.log"
def send(self, command: str) -> None:
self.input_file.parent.mkdir(parents=True, exist_ok=True)
with self.input_file.open("a") as f:
f.write(command + "\n")
def clear_log(self) -> None:
with self.log_file.open("w") as f:
f.truncate(0)
def read_log(self) -> str:
if not self.log_file.exists():
return ""
return self.log_file.read_text(errors="replace")
def strip_ansi(self, text: str) -> str:
return re.sub(r"\x1b\[[0-9;]*m", "", text)
# ---------------------------------------------------------------------------
# Test matrix generation
# ---------------------------------------------------------------------------
@dataclass
class TestCase:
case_id: str
family: str
subfamily: str
gap_or_wall: int
delta_y: float
ceiling_height: Optional[float]
wall_offset: Optional[int]
expected: str # "pass" or "reject"
def group_key(self) -> tuple:
"""Key for stop-at-first-failure grouping."""
return (self.family, self.subfamily, self.delta_y,
self.ceiling_height, self.wall_offset)
def label(self) -> str:
return self.case_id
def load_capabilities(path: Path) -> list[dict]:
with path.open() as f:
return json.load(f)
def derive_test_matrix(caps: list[dict]) -> list[TestCase]:
"""Derive the test matrix from momentum capabilities.
For each unique (family, subfamily, qualifiers) combination, finds the
global max_reach across all movement modes and momentum ranges, then
generates gap values from 0 to max_reach+1. The max_reach+1 case is the
sole expected-reject case per group.
"""
grouped: dict[tuple, int] = {}
for band in caps:
family = band["family"]
subfamily = band["subfamily"]
metric = band["capability_metric"]
reach = band["max_reach"]
if reach is None:
continue
dy = band.get("delta_y")
ceil = band.get("ceiling_height")
wo = band.get("wall_offset")
if family == "ceiling":
if ceil not in CEILING_HEIGHTS_TO_TEST:
continue
if family in SKIP_FAMILIES:
continue
key = (family, subfamily, dy, ceil, wo, metric)
if key not in grouped or reach > grouped[key]:
grouped[key] = reach
cases: list[TestCase] = []
for key, max_reach in sorted(grouped.items()):
family, subfamily, dy, ceil, wo, metric = key
for value in range(0, max_reach + 2):
expected = "pass" if value <= max_reach else "reject"
qualifier_parts = []
if dy is not None and dy != 0.0:
qualifier_parts.append(f"dy{dy:+.0f}")
if ceil is not None:
qualifier_parts.append(f"ceil{ceil}")
if wo is not None:
qualifier_parts.append(f"wo{wo}")
qualifier_str = "-".join(qualifier_parts) if qualifier_parts else ""
value_label = f"gap{value}" if metric == "gap_blocks" else f"wall{value}"
parts = [family, subfamily, value_label]
if qualifier_str:
parts.append(qualifier_str)
case_id = "-".join(parts)
cases.append(TestCase(
case_id=case_id,
family=family,
subfamily=subfamily,
gap_or_wall=value,
delta_y=dy if dy is not None else 0.0,
ceiling_height=ceil,
wall_offset=wo,
expected=expected,
))
return cases
# ---------------------------------------------------------------------------
# Filtering
# ---------------------------------------------------------------------------
def matches_filter(case: TestCase, pattern: str) -> bool:
"""Check if a test case matches a hierarchical filter pattern.
Supports:
- Exact case_id match: "linear-flat-gap4"
- Family: "linear"
- Family/subfamily: "linear/flat"
- With qualifiers: "linear/descend/dy-1", "sidewall/flat/wo0",
"ceiling/headhitter/ceil2.5"
"""
if case.case_id == pattern:
return True
parts = pattern.split("/")
if parts[0] != case.family:
return False
if len(parts) == 1:
return True
if parts[1] != case.subfamily:
return False
if len(parts) == 2:
return True
for qualifier in parts[2:]:
q_lower = qualifier.lower()
matched = False
if q_lower.startswith("dy"):
try:
target_dy = float(q_lower[2:])
if case.delta_y == target_dy:
matched = True
except ValueError:
pass
elif q_lower.startswith("ceil"):
try:
target_ceil = float(q_lower[4:])
if case.ceiling_height == target_ceil:
matched = True
except ValueError:
pass
elif q_lower.startswith("wo"):
try:
target_wo = int(q_lower[2:])
if case.wall_offset == target_wo:
matched = True
except ValueError:
pass
elif q_lower.startswith("gap"):
try:
target_gap = int(q_lower[3:])
if case.gap_or_wall == target_gap:
matched = True
except ValueError:
pass
elif q_lower.startswith("wall"):
try:
target_wall = int(q_lower[4:])
if case.gap_or_wall == target_wall:
matched = True
except ValueError:
pass
if not matched:
return False
return True
def apply_filters(cases: list[TestCase], filter_str: str) -> list[TestCase]:
"""Apply comma-separated filter patterns to the case list."""
patterns = [p.strip() for p in filter_str.split(",")]
return [c for c in cases if any(matches_filter(c, p) for p in patterns)]
# ---------------------------------------------------------------------------
# World building
# ---------------------------------------------------------------------------
@dataclass
class CourseLayout:
start_x: int
start_y: int
start_z: int
end_x: int
end_y: int
end_z: int
clear_min: tuple[int, int, int]
clear_max: tuple[int, int, int]
class WorldBuilder:
def __init__(self, rcon: RconClient, base_x: int = 100, base_y: int = 80):
self.rcon = rcon
self.base_x = base_x
self.base_y = base_y
self._z_cursor = 100
def allocate_z(self, width: int = 1) -> int:
z = self._z_cursor
self._z_cursor += width + 2 * CLEAR_MARGIN + 5
return z
def clear_area(self, x1: int, y1: int, z1: int, x2: int, y2: int, z2: int) -> None:
dx = x2 - x1
dz = z2 - z1
# MC fill command has a 32768-block limit per call; chunk if needed
chunk_size = 48
for cx in range(x1, x2 + 1, chunk_size):
for cz in range(z1, z2 + 1, chunk_size):
ex = min(cx + chunk_size - 1, x2)
ez = min(cz + chunk_size - 1, z2)
self.rcon.command(f"fill {cx} {y1} {cz} {ex} {y2} {ez} air")
def set_block(self, x: int, y: int, z: int, block: str = "stone") -> None:
self.rcon.command(f"setblock {x} {y} {z} {block}")
def fill_blocks(self, x1: int, y1: int, z1: int, x2: int, y2: int, z2: int,
block: str = "stone") -> None:
self.rcon.command(f"fill {x1} {y1} {z1} {x2} {y2} {z2} {block}")
def build_linear_route(self, case: TestCase) -> CourseLayout:
gap = case.gap_or_wall
dy = case.delta_y
bx = self.base_x
by = self.base_y
bz = self.allocate_z()
floor_y = by - 1
platform_stride = gap + 1
total_x = LINEAR_RUNWAY + SEGMENTS * platform_stride + 2
max_dy_extent = int(abs(dy) * SEGMENTS) + 2
x_min = bx - CLEAR_MARGIN
x_max = bx + total_x + CLEAR_MARGIN
y_min = min(floor_y, floor_y + int(dy * SEGMENTS)) - CLEAR_MARGIN
y_max = max(floor_y, floor_y + int(dy * SEGMENTS)) + CLEAR_MARGIN
z_min = bz - CLEAR_MARGIN
z_max = bz + CLEAR_MARGIN
self.clear_area(x_min, y_min, z_min, x_max, y_max, z_max)
for rx in range(LINEAR_RUNWAY):
self.set_block(bx + rx, floor_y, bz)
last_x = bx + LINEAR_RUNWAY - 1
last_y = floor_y
for seg in range(SEGMENTS):
plat_x = last_x + gap + 1
plat_y = last_y + int(dy)
self.set_block(plat_x, plat_y, bz)
last_x = plat_x
last_y = plat_y
return CourseLayout(
start_x=bx, start_y=by, start_z=bz,
end_x=last_x, end_y=last_y + 1, end_z=bz,
clear_min=(x_min, y_min, z_min),
clear_max=(x_max, y_max, z_max),
)
def build_neo_route(self, case: TestCase) -> CourseLayout:
"""Neo jump: player must jump around a wall to reach the next platform.
Layout (top view, each segment):
[Runway/Platform at Z=cur_z]
[Wall: 1 block in X, wall_width blocks in Z, 4 blocks tall]
[1 block air gap]
[Landing platform]
[1 block air gap]
[Next wall...]
The wall runs along Z starting from the current Z. The player
jumps around the wall edge in the +Z direction to reach the landing.
"""
wall_width = case.gap_or_wall
bx = self.base_x
by = self.base_y
bz = self.allocate_z(width=(wall_width + NEO_LANDING_GAP) * SEGMENTS + 10)
floor_y = by - 1
z_extent = SEGMENTS * (wall_width + NEO_LANDING_GAP) + 10
total_x = LINEAR_RUNWAY + SEGMENTS * 2 + 5
x_min = bx - CLEAR_MARGIN
x_max = bx + total_x + CLEAR_MARGIN
y_min = floor_y - CLEAR_MARGIN
y_max = floor_y + CLEAR_MARGIN
z_min = bz - CLEAR_MARGIN
z_max = bz + z_extent + CLEAR_MARGIN
self.clear_area(x_min, y_min, z_min, x_max, y_max, z_max)
# Runway
for rx in range(LINEAR_RUNWAY):
self.set_block(bx + rx, floor_y, bz)
seg_x = bx + LINEAR_RUNWAY - 1
cur_z = bz
for seg in range(SEGMENTS):
wall_x = seg_x + 1
if wall_width > 0:
wall_z_start = cur_z
wall_z_end = cur_z + wall_width - 1
self.fill_blocks(wall_x, floor_y, wall_z_start,
wall_x, floor_y + 3, wall_z_end)
# Landing: 1 block of air, then platform, then 1 block of air
landing_z = cur_z + wall_width + 1 # 1 air gap after wall
self.set_block(wall_x + 1, floor_y, landing_z)
seg_x = wall_x + 1
cur_z = landing_z + 2 # 1 air gap after landing before next wall
end_x = seg_x
end_z = cur_z - 2 # last landing position
return CourseLayout(
start_x=bx, start_y=by, start_z=bz,
end_x=end_x, end_y=by, end_z=end_z,
clear_min=(x_min, y_min, z_min),
clear_max=(x_max, y_max, z_max),
)
def build_sidewall_route(self, case: TestCase) -> CourseLayout:
"""Sidewall jump: platforms along a massive wall face.
The wall is directly behind the platforms (Z+1), tall and thick,
constraining backward movement. Player jumps between 1x1 platforms
that are at different X offsets and Y heights along the wall.
Layout (side view, looking from -Z toward +Z / toward the wall):
[=== MASSIVE WALL (Z=bz+1 to bz+6, full height) ===]
| |
| [P3] at X+2*stride, Y+2*dy |
| |
| [P2] at X+stride, Y+dy |
| |
| [Start/Runway] at X, Y |
[====================================================]
(open air below/in front)
Wall_offset controls distance from platform to wall:
wo=0: wall at Z=bz+1 (directly behind)
wo=1: wall at Z=bz+2 (1 block gap)
"""
gap = case.gap_or_wall
dy = case.delta_y
wo = case.wall_offset if case.wall_offset is not None else 0
bx = self.base_x
by = self.base_y
bz = self.allocate_z(width=8 + wo)
floor_y = by - 1
platform_stride = gap + 1
total_x = SIDEWALL_RUNWAY + SEGMENTS * platform_stride + 2
x_min = bx - CLEAR_MARGIN
x_max = bx + total_x + CLEAR_MARGIN
y_min = min(floor_y, floor_y + int(dy * SEGMENTS)) - CLEAR_MARGIN
y_max = max(floor_y, floor_y + int(dy * SEGMENTS)) + CLEAR_MARGIN
z_min = bz - CLEAR_MARGIN
z_max = bz + 8 + wo + CLEAR_MARGIN
self.clear_area(x_min, y_min, z_min, x_max, y_max, z_max)
# Runway (shorter than linear -- 2 blocks like the reference image)
for rx in range(SIDEWALL_RUNWAY):
self.set_block(bx + rx, floor_y, bz)
last_x = bx + SIDEWALL_RUNWAY - 1
last_y = floor_y
for seg in range(SEGMENTS):
plat_x = last_x + gap + 1
plat_y = last_y + int(dy)
self.set_block(plat_x, plat_y, bz)
last_x = plat_x
last_y = plat_y
# Massive wall behind the platforms
wall_z_start = bz + 1 + wo
wall_z_end = bz + 6 + wo # 6 blocks thick
wall_y_low = min(floor_y, last_y) - 2
wall_y_high = max(floor_y, last_y) + 5
wall_x_start = bx - 1
wall_x_end = last_x + 1
self.fill_blocks(wall_x_start, wall_y_low, wall_z_start,
wall_x_end, wall_y_high, wall_z_end)
return CourseLayout(
start_x=bx, start_y=by, start_z=bz,
end_x=last_x, end_y=last_y + 1, end_z=bz,
clear_min=(x_min, y_min, z_min),
clear_max=(x_max, y_max, z_max),
)
def build_ceiling_route(self, case: TestCase) -> CourseLayout:
gap = case.gap_or_wall
ceil_height = case.ceiling_height or 4.0
bx = self.base_x
by = self.base_y
bz = self.allocate_z()
floor_y = by - 1
platform_stride = gap + 1
total_x = LINEAR_RUNWAY + SEGMENTS * platform_stride + 2
x_min = bx - CLEAR_MARGIN
x_max = bx + total_x + CLEAR_MARGIN
ceil_y = floor_y + int(ceil_height) + 1
y_min = floor_y - CLEAR_MARGIN
y_max = ceil_y + CLEAR_MARGIN
z_min = bz - CLEAR_MARGIN
z_max = bz + CLEAR_MARGIN
self.clear_area(x_min, y_min, z_min, x_max, y_max, z_max)
for rx in range(LINEAR_RUNWAY):
self.set_block(bx + rx, floor_y, bz)
last_x = bx + LINEAR_RUNWAY - 1
for seg in range(SEGMENTS):
plat_x = last_x + gap + 1
self.set_block(plat_x, floor_y, bz)
last_x = plat_x
ceil_block_y = floor_y + math.ceil(ceil_height)
self.fill_blocks(bx - 1, ceil_block_y, bz - 1, last_x + 1, ceil_block_y, bz + 1)
return CourseLayout(
start_x=bx, start_y=by, start_z=bz,
end_x=last_x, end_y=by, end_z=bz,
clear_min=(x_min, y_min, z_min),
clear_max=(x_max, y_max, z_max),
)
def build(self, case: TestCase) -> CourseLayout:
if case.family == "linear":
return self.build_linear_route(case)
if case.family == "neo":
return self.build_neo_route(case)
if case.family == "sidewall":
return self.build_sidewall_route(case)
if case.family == "ceiling":
return self.build_ceiling_route(case)
raise ValueError(f"Unknown family: {case.family}")
# ---------------------------------------------------------------------------
# Test execution
# ---------------------------------------------------------------------------
@dataclass
class TestResult:
case: TestCase
outcome: str # "pass", "reject", "fail", "invalid_live_case"
matched_expected: bool
log_excerpt: str = ""
def resolve_session() -> str:
explicit = os.environ.get("SESSION", "")
if explicit:
return explicit
repo = os.environ.get("MCC_REPO_ROOT", "")
if repo:
return Path(repo).name
return Path.cwd().name
def run_single_test(
case: TestCase,
layout: CourseLayout,
rcon: RconClient,
mcc: MccClient,
username: str,
wait_seconds: int = 15,
) -> TestResult:
rcon.command(f"gamemode creative {username}")
time.sleep(0.3)
rcon.command(f"tp {username} {layout.start_x}.5 {layout.start_y} {layout.start_z}.5")
time.sleep(1)
rcon.command(f"gamemode survival {username}")
time.sleep(0.5)
mcc.clear_log()
time.sleep(0.3)
mcc.send(f"goto {layout.end_x} {layout.end_y} {layout.end_z}")
time.sleep(wait_seconds)
raw_log = mcc.read_log()
log = mcc.strip_ansi(raw_log)
all_lines = log.splitlines()
a_star_lines = [l for l in all_lines if "[A*]" in l][:3]
path_mgr_lines = [l for l in all_lines if "[PathMgr]" in l]
path_exec_lines = [l for l in all_lines if "[PathExec]" in l]
move_lines = [l for l in all_lines if "FileInput" in l or "path" in l.lower()
or "move" in l.lower() or "navigate" in l.lower()]
outcome = "invalid_live_case"
full_text = log.lower()
mgr_text = "\n".join(path_mgr_lines)
astar_text = "\n".join(a_star_lines)
exec_text = "\n".join(path_exec_lines)
if "navigation complete" in full_text:
outcome = "pass"
elif "complete" in mgr_text.lower():
outcome = "pass"
elif "failed to compute a safe path" in full_text:
outcome = "reject"
elif "not a reachable" in full_text:
outcome = "reject"
elif "no path" in full_text:
outcome = "reject"
elif "Failed" in astar_text:
outcome = "reject"
elif "Replan failed" in mgr_text or "Giving up" in mgr_text:
outcome = "fail"
elif "FAILED" in exec_text:
outcome = "fail"
elif "failed" in full_text:
outcome = "fail"
excerpt_lines = []
if a_star_lines:
excerpt_lines.append(f" A*: {a_star_lines[0]}")
if path_mgr_lines:
excerpt_lines.append(f" Mgr: {path_mgr_lines[-1]}")
relevant = [l for l in all_lines if "path" in l.lower() or "move" in l.lower()
or "navigate" in l.lower() or "A*" in l]
if not excerpt_lines and relevant:
excerpt_lines.append(f" Log: {relevant[0]}")
return TestResult(
case=case,
outcome=outcome,
matched_expected=(outcome == case.expected),
log_excerpt="\n".join(excerpt_lines),
)
# ---------------------------------------------------------------------------
# Stop-at-first-failure logic
# ---------------------------------------------------------------------------
def should_skip(case: TestCase, failed_groups: set[tuple]) -> bool:
"""Skip this case if its group already had a failure at a smaller gap."""
return case.group_key() in failed_groups
# ---------------------------------------------------------------------------
# Main
# ---------------------------------------------------------------------------
def main() -> None:
parser = argparse.ArgumentParser(
description="Full-coverage parkour test suite",
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog=__doc__.split("Usage:")[0],
)
parser.add_argument("--list-cases", action="store_true",
help="Print test matrix and exit")
parser.add_argument("--dry-run", action="store_true",
help="Build worlds but don't run MCC navigation")
parser.add_argument("--filter", type=str, default=None,
help="Comma-separated hierarchical filters")
parser.add_argument("--rcon-port", type=int, default=25575)
parser.add_argument("--rcon-password", type=str, default="test123")
parser.add_argument("--username", type=str, default="MCCBot")
parser.add_argument("--wait", type=int, default=15,
help="Seconds to wait for navigation per test")
parser.add_argument("--results", type=str, default=None,
help="Path for JSONL results output")
args = parser.parse_args()
caps = load_capabilities(CAPABILITIES_PATH)
all_cases = derive_test_matrix(caps)
if args.filter:
all_cases = apply_filters(all_cases, args.filter)
if args.list_cases:
pass_count = sum(1 for c in all_cases if c.expected == "pass")
reject_count = sum(1 for c in all_cases if c.expected == "reject")
print(f"Total: {len(all_cases)} cases ({pass_count} pass, {reject_count} reject)")
current_group = ""
for c in all_cases:
group = f"{c.family}/{c.subfamily}"
if group != current_group:
print(f"\n {group}:")
current_group = group
marker = "PASS" if c.expected == "pass" else "REJECT"
quals = []
if c.delta_y != 0.0:
quals.append(f"dy={c.delta_y:+.0f}")
if c.ceiling_height is not None:
quals.append(f"ceil={c.ceiling_height}")
if c.wall_offset is not None:
quals.append(f"wo={c.wall_offset}")
q = f" ({', '.join(quals)})" if quals else ""
metric = "gap" if c.family != "neo" else "wall"
print(f" {c.case_id:<50} {metric}={c.gap_or_wall} [{marker}]{q}")
return
rcon = RconClient(port=args.rcon_port, password=args.rcon_password)
rcon.connect()
rcon.command("difficulty peaceful")
rcon.command("gamerule doMobSpawning false")
rcon.command("time set day")
builder = WorldBuilder(rcon)
if args.dry_run:
print("=== DRY RUN: building worlds only ===")
for case in all_cases:
layout = builder.build(case)
print(f" {case.case_id}: start=({layout.start_x},{layout.start_y},{layout.start_z})"
f" end=({layout.end_x},{layout.end_y},{layout.end_z})")
rcon.close()
print(f"\nBuilt {len(all_cases)} courses.")
return
session = resolve_session()
mcc = MccClient(session)
results_path = Path(args.results) if args.results else None
if results_path:
results_path.parent.mkdir(parents=True, exist_ok=True)
print("=" * 60)
print(" MCC Full-Coverage Parkour Test Suite")
print("=" * 60)
print(f" Cases: {len(all_cases)}")
print(f" Username: {args.username}")
print(f" Session: {session}")
print(f" Wait: {args.wait}s per test")
print()
results: list[TestResult] = []
failed_groups: set[tuple] = set()
skipped = 0
for i, case in enumerate(all_cases, 1):
if should_skip(case, failed_groups):
skipped += 1
print(f" [{i}/{len(all_cases)}] {case.case_id} -- SKIPPED (group already failed)")
continue
print(f"\n--- [{i}/{len(all_cases)}] {case.case_id} (expect: {case.expected}) ---")
layout = builder.build(case)
print(f" Route: ({layout.start_x},{layout.start_y},{layout.start_z}) -> "
f"({layout.end_x},{layout.end_y},{layout.end_z})")
result = run_single_test(
case, layout, rcon, mcc, args.username, args.wait,
)
results.append(result)
status = "OK" if result.matched_expected else "MISMATCH"
print(f" Outcome: {result.outcome} [{status}]")
if result.log_excerpt:
print(result.log_excerpt)
# Stop-at-first-failure: only trigger on definitive navigation
# failures (reject/fail), not on setup issues (invalid_live_case).
if result.outcome in ("reject", "fail") and case.expected == "pass":
failed_groups.add(case.group_key())
print(f" >> Group failed at {case.family}/{case.subfamily} "
f"gap/wall={case.gap_or_wall} -- skipping larger values")
if results_path:
with results_path.open("a") as f:
f.write(json.dumps({
"case_id": case.case_id,
"family": case.family,
"subfamily": case.subfamily,
"gap_or_wall": case.gap_or_wall,
"expected": case.expected,
"outcome": result.outcome,
"matched": result.matched_expected,
}) + "\n")
rcon.close()
print("\n" + "=" * 60)
print(" SUMMARY")
print("=" * 60)
passed = [r for r in results if r.matched_expected]
failed = [r for r in results if not r.matched_expected]
print(f"\n {len(passed)}/{len(results)} matched expectations")
if skipped:
print(f" {skipped} cases skipped (stop-at-first-failure)")
if failed:
print(f"\n MISMATCHES ({len(failed)}):")
for r in failed:
print(f" {r.case.case_id}: expected={r.case.expected} got={r.outcome}")
sys.exit(0 if not failed else 1)
if __name__ == "__main__":
main()

View file

@ -1,81 +1,9 @@
#!/usr/bin/env bash
# Automated parkour jump test for MCC pathfinding
# Usage: source tools/mcc-env.sh && bash tools/test-parkour.sh
# Full-coverage parkour test suite for MCC pathfinding
# Thin wrapper around test-parkour.py
# Usage: source tools/mcc-env.sh && bash tools/test-parkour.sh [args...]
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
source "$REPO_ROOT/tools/mcc-env.sh"
source "$REPO_ROOT/tools/pathing_live_common.sh"
MANIFEST="$REPO_ROOT/tools/pathing_data/canonical-live-cases.json"
RESULTS_FILE="${RESULTS_FILE:-/tmp/mcc-debug/pathing-live-results.jsonl}"
LOG="/tmp/mcc-debug/mcc-debug.log"
RESULTS=""
TEST_NUM=0
LAST_RESULT="invalid_live_case"
if [[ "${1:-}" == "--list-cases" ]]; then
manifest_cases_for_query "$MANIFEST" "linear"
exit 0
fi
mkdir -p "$(dirname "$RESULTS_FILE")"
: > "$RESULTS_FILE"
run_manifest_case() {
local case_id="$1"
local case_json
case_json="$(manifest_case_json "$MANIFEST" "$case_id")"
read -r world_recipe start_x start_y start_z goal_x goal_y goal_z < <(
python3 - "$case_json" <<'PY'
import json
import sys
row = json.loads(sys.argv[1])
print(
row["world_recipe_id"],
row["start"]["x"],
row["start"]["y"],
row["start"]["z"],
row["goal"]["x"],
row["goal"]["y"],
row["goal"]["z"],
)
PY
)
local landing_block_y=$(( ${goal_y%.*} - 1 ))
case "$world_recipe" in
linear-flat|linear-ascend|linear-descend)
mc-rcon "fill 95 80 95 115 90 105 air" >/dev/null
mc-rcon "fill 95 79 95 115 79 105 air" >/dev/null
mc-rcon "setblock 100 79 100 stone" >/dev/null
mc-rcon "setblock ${goal_x%.*} ${landing_block_y} ${goal_z%.*} stone" >/dev/null
;;
*)
echo "Unsupported world recipe for test-parkour.sh: $world_recipe" >&2
return 1
;;
esac
run_test "$case_id" "${start_x%.*}" "${start_y%.*}" "${start_z%.*}" "${goal_x%.*}" "${goal_y%.*}" "${goal_z%.*}"
record_live_result "$RESULTS_FILE" "$case_json" "$LAST_RESULT" "$LOG"
}
echo "========================================"
echo " MCC Parkour Jump Test Suite"
echo "========================================"
while IFS= read -r case_id; do
run_manifest_case "$case_id"
done < <(manifest_cases_for_query "$MANIFEST" "linear")
echo ""
echo "========================================"
echo " SUMMARY"
echo "========================================"
echo -e "$RESULTS"
exec python3 "$SCRIPT_DIR/test-parkour.py" "$@"

View file

@ -6,8 +6,8 @@ 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="MCCBot"
SESSION="${SESSION:-mcc-pathing-jump-combos}"
USERNAME="${USERNAME:-MCCBot}"
SESSION_ROOT="$(_mcc_session_root "$SESSION")"
LOG="$(_mcc_session_log_file "$SESSION")"

View file

@ -6,8 +6,8 @@ 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="MCCBot"
SESSION="${SESSION:-mcc-pathing-long-routes}"
USERNAME="${USERNAME:-MCCBot}"
SESSION_ROOT="$(_mcc_session_root "$SESSION")"
LOG="$(_mcc_session_log_file "$SESSION")"

View file

@ -6,8 +6,8 @@ REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
source "$REPO_ROOT/tools/mcc-env.sh"
VERSION="${1:-1.21.11-Vanilla}"
SESSION="mcc-pathing-template"
USERNAME="MCCBot"
SESSION="${SESSION:-mcc-pathing-template}"
USERNAME="${USERNAME:-MCCBot}"
SESSION_ROOT="$(_mcc_session_root "$SESSION")"
LOG="$(_mcc_session_log_file "$SESSION")"

View file

@ -6,9 +6,11 @@ REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
source "$REPO_ROOT/tools/mcc-env.sh"
source "$REPO_ROOT/tools/pathing_live_common.sh"
SESSION="${SESSION:-$(_mcc_resolve_session)}"
USERNAME="${USERNAME:-MCCBot}"
MANIFEST="$REPO_ROOT/tools/pathing_data/canonical-live-cases.json"
RESULTS_FILE="${RESULTS_FILE:-/tmp/mcc-debug/pathing-live-results.jsonl}"
LOG="/tmp/mcc-debug/mcc-debug.log"
RESULTS_FILE="${RESULTS_FILE:-$(_mcc_session_root "$SESSION")/pathing-live-results.jsonl}"
LOG="$(_mcc_session_log_file "$SESSION")"
RESULTS=""
TEST_NUM=0
LAST_RESULT="invalid_live_case"

View file

@ -6,8 +6,8 @@ REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
source "$REPO_ROOT/tools/mcc-env.sh"
VERSION="${1:-1.21.11-Vanilla}"
SESSION="mcc-brake-test"
USERNAME="MCCBot"
SESSION="${SESSION:-mcc-brake-test}"
USERNAME="${USERNAME:-MCCBot}"
SESSION_ROOT="$(_mcc_session_root "$SESSION")"
LOG="$(_mcc_session_log_file "$SESSION")"

View file

@ -3,6 +3,7 @@ import tempfile
import unittest
from pathlib import Path
from tools.pathing_theory.capabilities import build_momentum_capability_bands
from tools.pathing_theory.canonical import build_canonical_live_cases
from tools.pathing_theory.renderers import write_theory_artifacts
from tools.pathing_theory.simulator import build_theory_cases
@ -16,8 +17,8 @@ class CanonicalPathingCaseTests(unittest.TestCase):
self.assertTrue(all(case.movement_mode == "sprint" for case in canonical_cases))
self.assertTrue(all(case.momentum_ticks == 12 for case in canonical_cases))
self.assertIn("linear:flat:sprint:easy", bucket_ids)
self.assertIn("linear:flat:sprint:boundary", bucket_ids)
self.assertIn("linear:flat:sprint:reject", bucket_ids)
self.assertIn("linear:descend:sprint:boundary", bucket_ids)
self.assertIn("neo:neo:sprint:boundary", bucket_ids)
self.assertIn("ceiling:headhitter:sprint:boundary", bucket_ids)
@ -26,17 +27,26 @@ class CanonicalPathingCaseTests(unittest.TestCase):
with tempfile.TemporaryDirectory() as temp_dir:
output_dir = Path(temp_dir)
write_theory_artifacts(cases, build_canonical_live_cases(cases), output_dir)
write_theory_artifacts(
cases,
build_canonical_live_cases(cases),
build_momentum_capability_bands(cases),
output_dir,
)
json_path = output_dir / "theory-matrix.json"
csv_path = output_dir / "theory-matrix.csv"
md_path = output_dir / "theory-matrix.md"
canonical_path = output_dir / "canonical-live-cases.json"
capability_path = output_dir / "momentum-capabilities.json"
capability_md_path = output_dir / "momentum-capabilities.md"
self.assertTrue(json_path.exists())
self.assertTrue(csv_path.exists())
self.assertTrue(md_path.exists())
self.assertTrue(canonical_path.exists())
self.assertTrue(capability_path.exists())
self.assertTrue(capability_md_path.exists())
exported_cases = json.loads(json_path.read_text())
self.assertEqual(len(cases), len(exported_cases))

View file

@ -0,0 +1,71 @@
import subprocess
import unittest
from tools.pathing_theory.capabilities import build_momentum_capability_bands
from tools.pathing_theory.simulator import build_theory_cases
class PathingCapabilityTests(unittest.TestCase):
def test_linear_descend_sprint_dy_minus2_compresses_into_mm_breakpoints(self) -> None:
bands = build_momentum_capability_bands(build_theory_cases())
rows = [
band
for band in bands
if band.family == "linear"
and band.subfamily == "descend"
and band.movement_mode == "sprint"
and band.delta_y == -2.0
]
self.assertEqual(
[(band.min_mm, band.max_mm, band.max_reach) for band in rows],
[(0, 0, 4), (1, 12, 5)],
)
def test_linear_ascend_walk_dy_plus1_compresses_into_mm_breakpoints(self) -> None:
bands = build_momentum_capability_bands(build_theory_cases())
rows = [
band
for band in bands
if band.family == "linear"
and band.subfamily == "ascend"
and band.movement_mode == "walk"
and band.delta_y == 1.0
]
self.assertEqual(
[(band.min_mm, band.max_mm, band.max_reach) for band in rows],
[(0, 0, 1), (1, 12, 2)],
)
def test_ceiling_sprint_height_2p5_has_late_mm_breakpoint(self) -> None:
bands = build_momentum_capability_bands(build_theory_cases())
rows = [
band
for band in bands
if band.family == "ceiling"
and band.subfamily == "headhitter"
and band.movement_mode == "sprint"
and band.ceiling_height == 2.5
]
self.assertEqual(
[(band.min_mm, band.max_mm, band.max_reach) for band in rows],
[(0, 7, 2), (8, 12, 3)],
)
def test_sim_jump_reach_lists_momentum_capabilities(self) -> None:
result = subprocess.run(
["python3", "tools/sim_jump_reach.py", "--list-capabilities"],
check=False,
capture_output=True,
text=True,
)
self.assertEqual(result.returncode, 0, result.stderr)
self.assertIn("linear | descend | sprint | dy=-2.0 | mm=0..0 | max_gap=4", result.stdout)
self.assertIn("linear | ascend | walk | dy=1.0 | mm=1..12 | max_gap=2", result.stdout)
if __name__ == "__main__":
unittest.main()

View file

@ -3,19 +3,51 @@ import unittest
class PathingLiveScriptTests(unittest.TestCase):
def test_test_parkour_lists_linear_canonical_cases(self) -> None:
def test_test_parkour_lists_all_families(self) -> None:
result = subprocess.run(
["bash", "tools/test-parkour.sh", "--list-cases"],
["python3", "tools/test-parkour.py", "--list-cases"],
check=False,
capture_output=True,
text=True,
)
self.assertEqual(result.returncode, 0, result.stderr)
self.assertIn("linear-flat-sprint-mm12-gap5-dy0p0", result.stdout)
self.assertIn("linear-ascend-sprint-mm12-gap2-dy1p0", result.stdout)
self.assertNotIn("linear-flat-walk-mm12-gap5-dy0p0", result.stdout)
self.assertNotIn("linear-flat-sprint-mm0-gap3-dy0p0", result.stdout)
self.assertIn("linear/flat", result.stdout)
self.assertIn("linear/ascend", result.stdout)
self.assertIn("linear/descend", result.stdout)
self.assertIn("neo/neo", result.stdout)
self.assertIn("ceiling/headhitter", result.stdout)
self.assertIn("sidewall/flat", result.stdout)
self.assertIn("sidewall/ascend", result.stdout)
self.assertIn("sidewall/descend", result.stdout)
def test_test_parkour_linear_has_reject_at_max_plus_one(self) -> None:
result = subprocess.run(
["python3", "tools/test-parkour.py", "--list-cases", "--family", "linear"],
check=False,
capture_output=True,
text=True,
)
self.assertEqual(result.returncode, 0, result.stderr)
self.assertIn("linear-flat-gap4", result.stdout)
self.assertIn("linear-flat-gap5", result.stdout)
self.assertIn("[PASS]", result.stdout)
self.assertIn("[REJECT]", result.stdout)
def test_test_parkour_neo_covers_wall_range(self) -> None:
result = subprocess.run(
["python3", "tools/test-parkour.py", "--list-cases", "--family", "neo"],
check=False,
capture_output=True,
text=True,
)
self.assertEqual(result.returncode, 0, result.stderr)
for w in range(5):
self.assertIn(f"neo-neo-wall{w}", result.stdout)
self.assertIn("neo-neo-wall5", result.stdout)
self.assertIn("[REJECT]", result.stdout)
def test_test_pathing_theory_neo_ceiling_lists_theory_cases(self) -> None:
result = subprocess.run(
@ -27,7 +59,7 @@ class PathingLiveScriptTests(unittest.TestCase):
self.assertEqual(result.returncode, 0, result.stderr)
self.assertIn("neo-neo-sprint-mm12-wall1", result.stdout)
self.assertIn("ceiling-headhitter-sprint-mm12-gap3-ceil2p0", result.stdout)
self.assertIn("ceiling-headhitter-sprint-mm12-gap3-ceil2p5", result.stdout)
if __name__ == "__main__":

View file

@ -1,6 +1,11 @@
import unittest
from pathlib import Path
from tools.pathing_theory.primitives import (
can_reach_gap,
can_reach_gap_with_side_wall,
get_landing,
)
from tools.pathing_theory.simulator import build_theory_cases
@ -14,14 +19,103 @@ class PathingTheoryMatrixTests(unittest.TestCase):
self.assertIn(("linear", "descend"), families)
self.assertIn(("neo", "neo"), families)
self.assertIn(("ceiling", "headhitter"), families)
self.assertIn(("sidewall", "flat"), families)
self.assertIn(("sidewall", "ascend"), families)
self.assertIn(("sidewall", "descend"), families)
linear_boundary = next(
case
for case in cases
if case.case_id == "linear-flat-sprint-mm12-gap5-dy0p0"
)
self.assertTrue(linear_boundary.expected_reachable)
self.assertGreater(linear_boundary.margin, 0.0)
self.assertFalse(linear_boundary.expected_reachable)
self.assertTrue(
linear_boundary.margin is None or linear_boundary.margin < 0.0
)
def test_walk_momentum_does_not_turn_gap4_ascend_into_reachable(self) -> None:
ok, landing_x, needed_x = can_reach_gap(
gap_blocks=4,
dy=1.0,
sprint=False,
momentum_ticks=12,
)
self.assertFalse(ok)
self.assertTrue(landing_x is None or landing_x < needed_x)
def test_walk_momentum_can_reach_gap2_ascend_with_edge_takeoff_support(self) -> None:
ok, landing_x, needed_x = can_reach_gap(
gap_blocks=2,
dy=1.0,
sprint=False,
momentum_ticks=12,
)
self.assertTrue(ok)
self.assertIsNotNone(landing_x)
self.assertGreaterEqual(landing_x, needed_x)
def test_walk_gap3_ascend_does_not_snap_up_after_falling_below_platform(self) -> None:
landing = get_landing(
sprint=False,
target_y=1.0,
landing_x_start=3.5,
momentum_ticks=12,
)
self.assertIsNone(landing)
def test_sprint_with_run_up_still_treats_flat_gap5_as_unreachable(self) -> None:
ok, landing_x, needed_x = can_reach_gap(
gap_blocks=5,
dy=0.0,
sprint=True,
momentum_ticks=12,
)
self.assertFalse(ok)
self.assertTrue(landing_x is None or landing_x < needed_x)
def test_sidewall_wo0_margin_is_less_or_equal_to_linear(self) -> None:
"""Side wall should never make a jump easier than the open-air linear case."""
cases = build_theory_cases()
linear_by_key: dict[tuple, float | None] = {}
for c in cases:
if c.family == "linear":
key = (c.subfamily, c.movement_mode, c.momentum_ticks,
c.gap_blocks, c.delta_y)
linear_by_key[key] = c.margin
for c in cases:
if c.family != "sidewall" or c.wall_offset != 0:
continue
key = (c.subfamily, c.movement_mode, c.momentum_ticks,
c.gap_blocks, c.delta_y)
lin_margin = linear_by_key.get(key)
if lin_margin is None or c.margin is None:
continue
self.assertLessEqual(
c.margin, lin_margin + 1e-9,
f"sidewall margin {c.margin} > linear margin {lin_margin} "
f"for {c.case_id}",
)
def test_sidewall_walk_descend_gap3_mm0_is_unreachable_wo0(self) -> None:
"""The tight walk descend dy=-2 gap3 mm0 should flip to unreachable with wall."""
ok, _, _ = can_reach_gap_with_side_wall(
gap_blocks=3, dy=-2.0, wall_offset=0, sprint=False, momentum_ticks=0,
)
self.assertFalse(ok)
def test_sidewall_sprint_flat_gap4_mm12_is_still_reachable_wo0(self) -> None:
"""Sprint flat gap4 with plenty of margin should survive the wall penalty."""
ok, landing_x, needed_x = can_reach_gap_with_side_wall(
gap_blocks=4, dy=0.0, wall_offset=0, sprint=True, momentum_ticks=12,
)
self.assertTrue(ok)
self.assertIsNotNone(landing_x)
self.assertGreater(landing_x, needed_x)
def test_theory_markdown_mentions_canonical_live_coverage(self) -> None:
markdown = Path("tools/pathing_data/theory-matrix.md").read_text(encoding="utf-8")