diff --git a/client/README.md b/client/README.md
index 256ae1d..2b2d434 100644
--- a/client/README.md
+++ b/client/README.md
@@ -26,7 +26,7 @@ The default play space is a **45 × 45** unit flat floor (~2 000 sq units, ~5×
| `Floor` | `walkable` | 45 × 45 | (0, 0) | Main play surface |
| `PrototypeTerminal` | `walkable` | 0.9 × 0.4 | (0, 0) | Interaction target |
| `MoveRejectPedestal` | `walkable` | 1.5 × 1.5 | (7.5, −6.5) | NEON-7 vertical reject |
-| `MoveRejectFarPad` | `walkable` | 2.5 × 2.5 | (9, 9) | NEON-7 range reject |
+| `MoveRejectFarPad` | `walkable` | 2.5 × 2.5 | (9, 9) | NEON-7 vertical reject pad (horizontal limit removed NEON-29) |
| `Obstacle` | `occluder` | 2 × 2 | (6, 5) | Original occlusion / click-through target |
| `ObstacleB` | `occluder` | 3 × 3 | (−10, 5) | Second occluder (wider angle from spawn) |
| `ObstacleC` | `occluder` | 2 × 4 | (5, −12) | Tall occluder, south quadrant |
diff --git a/docs/plans/NEON-29-implementation-plan.md b/docs/plans/NEON-29-implementation-plan.md
index 0a379da..1415701 100644
--- a/docs/plans/NEON-29-implementation-plan.md
+++ b/docs/plans/NEON-29-implementation-plan.md
@@ -102,8 +102,11 @@ No new automated GDScript tests are added for this story — there is no new GDS
---
+## Decisions
+
+- **No horizontal step limit (resolved):** The server's `MaxHorizontalStep = 18 m` was too restrictive for the expanded 45 × 45 m floor (diagonal ≈ 63 m). Added `HorizontalStepEnabled` bool to `MovementValidationOptions` (defaults `false`), matching the `DistrictBoundsEnabled` pattern. The horizontal check is now skipped by default; the property remains available to opt back in via config. `MoveRejectFarPad` no longer exercises a horizontal range reject; it retains its walkable surface for vertical-step regression only.
+
## Open questions / risks
- **NavMesh bake time:** A 45×45 floor with several terraces will bake more polygons than the 20×20 baseline. Expected to be negligible for `bake_navigation_mesh(false)` on desktop, but monitor Output for a perceptible pause.
-- **NEON-7 reject range change:** The server's `MaxDistance` is currently tuned for the old district. Clicks from the far corners of the new floor will be within server range (they're not "too far" in server terms — the server checks xz step size from _current_ position to _target_, not total map size). Confirm no new silent-reject edge cases; document if found.
-- **Agent climb on terraces:** `agent_max_climb = 0.35` limits single-step height. Ramps must grade gently enough for the bake to include them as traversable, or use stacked step geometry (~0.30 m rise per step). Verify in Godot editor nav mesh debug overlay before shipping.
+- **Agent climb on terraces:** `agent_max_climb = 0.35` limits single-step height. Each terrace transition is 0.30 m; verify in Godot editor nav mesh debug overlay before shipping.
diff --git a/server/NeonSprawl.Server.Tests/Game/PositionState/MoveCommandValidationTests.cs b/server/NeonSprawl.Server.Tests/Game/PositionState/MoveCommandValidationTests.cs
index 7614c42..4d45c09 100644
--- a/server/NeonSprawl.Server.Tests/Game/PositionState/MoveCommandValidationTests.cs
+++ b/server/NeonSprawl.Server.Tests/Game/PositionState/MoveCommandValidationTests.cs
@@ -5,9 +5,11 @@ namespace NeonSprawl.Server.Tests.Game.PositionState;
public class MoveCommandValidationTests
{
- private static MovementValidationOptions Rules(double maxH, double maxV, bool bounds = false) =>
+ private static MovementValidationOptions Rules(
+ double maxH, double maxV, bool bounds = false, bool horizontalEnabled = true) =>
new()
{
+ HorizontalStepEnabled = horizontalEnabled,
MaxHorizontalStep = maxH,
MaxVerticalStep = maxV,
DistrictBoundsEnabled = bounds,
@@ -63,6 +65,15 @@ public class MoveCommandValidationTests
Assert.Equal(MoveCommandReasonCodes.HorizontalStepExceeded, code);
}
+ [Fact]
+ public void TryValidate_ShouldAllow_WhenHorizontalDisabled()
+ {
+ var from = new PositionSnapshot(0, 0, 0, 0);
+ var to = new PositionVector { X = 1000, Y = 0, Z = 1000 };
+ Assert.True(MoveCommandValidation.TryValidate(from, to, Rules(1, 10, horizontalEnabled: false), out var code));
+ Assert.Equal("", code);
+ }
+
[Fact]
public void TryValidate_ShouldIgnoreBounds_WhenDistrictDisabled()
{
diff --git a/server/NeonSprawl.Server/Game/PositionState/MoveCommandValidation.cs b/server/NeonSprawl.Server/Game/PositionState/MoveCommandValidation.cs
index ef75be9..1cadafa 100644
--- a/server/NeonSprawl.Server/Game/PositionState/MoveCommandValidation.cs
+++ b/server/NeonSprawl.Server/Game/PositionState/MoveCommandValidation.cs
@@ -18,13 +18,16 @@ public static class MoveCommandValidation
{
reasonCode = "";
- var dx = to.X - from.X;
- var dz = to.Z - from.Z;
- var horizontal = Math.Sqrt(dx * dx + dz * dz);
- if (horizontal > rules.MaxHorizontalStep)
+ if (rules.HorizontalStepEnabled)
{
- reasonCode = MoveCommandReasonCodes.HorizontalStepExceeded;
- return false;
+ var dx = to.X - from.X;
+ var dz = to.Z - from.Z;
+ var horizontal = Math.Sqrt(dx * dx + dz * dz);
+ if (horizontal > rules.MaxHorizontalStep)
+ {
+ reasonCode = MoveCommandReasonCodes.HorizontalStepExceeded;
+ return false;
+ }
}
var dy = Math.Abs(to.Y - from.Y);
diff --git a/server/NeonSprawl.Server/Game/PositionState/MovementValidationOptions.cs b/server/NeonSprawl.Server/Game/PositionState/MovementValidationOptions.cs
index 51111e1..2d80cc3 100644
--- a/server/NeonSprawl.Server/Game/PositionState/MovementValidationOptions.cs
+++ b/server/NeonSprawl.Server/Game/PositionState/MovementValidationOptions.cs
@@ -3,7 +3,11 @@ namespace NeonSprawl.Server.Game.PositionState;
/// Per-command movement sanity limits (NS-19). Bound from Game:MovementValidation.
public sealed class MovementValidationOptions
{
- /// Max horizontal displacement on X/Z in one move (meters). Inclusive at the limit.
+ /// When true, enforces . False (default) means no horizontal limit.
+ public bool HorizontalStepEnabled { get; set; }
+
+ /// Max horizontal displacement on X/Z in one move (meters). Inclusive at the limit.
+ /// Only applied when is true.
public double MaxHorizontalStep { get; set; } = 18.0;
/// Max absolute delta on Y in one move (meters). Inclusive at the limit.