NEON-29: remove horizontal step limit; add HorizontalStepEnabled flag

Expanded 45×45 district made the 18 m MaxHorizontalStep too restrictive
(floor diagonal ~63 m). Add HorizontalStepEnabled to MovementValidationOptions
(default false, matching DistrictBoundsEnabled pattern) so the check is
skipped unless explicitly opted in. Add test for disabled case (9 pass).
Update README and plan with the decision.
pull/41/head
VinPropane 2026-04-11 18:17:32 -04:00
parent 6879d283a9
commit ac75af9c73
5 changed files with 32 additions and 11 deletions

View File

@ -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 | | `Floor` | `walkable` | 45 × 45 | (0, 0) | Main play surface |
| `PrototypeTerminal` | `walkable` | 0.9 × 0.4 | (0, 0) | Interaction target | | `PrototypeTerminal` | `walkable` | 0.9 × 0.4 | (0, 0) | Interaction target |
| `MoveRejectPedestal` | `walkable` | 1.5 × 1.5 | (7.5, 6.5) | NEON-7 vertical reject | | `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 | | `Obstacle` | `occluder` | 2 × 2 | (6, 5) | Original occlusion / click-through target |
| `ObstacleB` | `occluder` | 3 × 3 | (10, 5) | Second occluder (wider angle from spawn) | | `ObstacleB` | `occluder` | 3 × 3 | (10, 5) | Second occluder (wider angle from spawn) |
| `ObstacleC` | `occluder` | 2 × 4 | (5, 12) | Tall occluder, south quadrant | | `ObstacleC` | `occluder` | 2 × 4 | (5, 12) | Tall occluder, south quadrant |

View File

@ -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 ## 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. - **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. Each terrace transition is 0.30 m; verify in Godot editor nav mesh debug overlay before shipping.
- **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.

View File

@ -5,9 +5,11 @@ namespace NeonSprawl.Server.Tests.Game.PositionState;
public class MoveCommandValidationTests 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() new()
{ {
HorizontalStepEnabled = horizontalEnabled,
MaxHorizontalStep = maxH, MaxHorizontalStep = maxH,
MaxVerticalStep = maxV, MaxVerticalStep = maxV,
DistrictBoundsEnabled = bounds, DistrictBoundsEnabled = bounds,
@ -63,6 +65,15 @@ public class MoveCommandValidationTests
Assert.Equal(MoveCommandReasonCodes.HorizontalStepExceeded, code); 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] [Fact]
public void TryValidate_ShouldIgnoreBounds_WhenDistrictDisabled() public void TryValidate_ShouldIgnoreBounds_WhenDistrictDisabled()
{ {

View File

@ -18,13 +18,16 @@ public static class MoveCommandValidation
{ {
reasonCode = ""; reasonCode = "";
var dx = to.X - from.X; if (rules.HorizontalStepEnabled)
var dz = to.Z - from.Z;
var horizontal = Math.Sqrt(dx * dx + dz * dz);
if (horizontal > rules.MaxHorizontalStep)
{ {
reasonCode = MoveCommandReasonCodes.HorizontalStepExceeded; var dx = to.X - from.X;
return false; 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); var dy = Math.Abs(to.Y - from.Y);

View File

@ -3,7 +3,11 @@ namespace NeonSprawl.Server.Game.PositionState;
/// <summary>Per-command movement sanity limits (NS-19). Bound from <c>Game:MovementValidation</c>.</summary> /// <summary>Per-command movement sanity limits (NS-19). Bound from <c>Game:MovementValidation</c>.</summary>
public sealed class MovementValidationOptions public sealed class MovementValidationOptions
{ {
/// <summary>Max horizontal displacement on X/Z in one move (meters). Inclusive at the limit.</summary> /// <summary>When true, enforces <see cref="MaxHorizontalStep"/>. False (default) means no horizontal limit.</summary>
public bool HorizontalStepEnabled { get; set; }
/// <summary>Max horizontal displacement on X/Z in one move (meters). Inclusive at the limit.
/// Only applied when <see cref="HorizontalStepEnabled"/> is true.</summary>
public double MaxHorizontalStep { get; set; } = 18.0; public double MaxHorizontalStep { get; set; } = 18.0;
/// <summary>Max absolute delta on Y in one move (meters). Inclusive at the limit.</summary> /// <summary>Max absolute delta on Y in one move (meters). Inclusive at the limit.</summary>