neon-sprawl/docs/plans/NS-19-implementation-plan.md

9.6 KiB

NS-19 — Implementation plan

Story reference

Field Value
Key NS-19
Title E1.M1: Server movement validation (speed + bounds)
Jira NS-19
Parent context NS-1 — Epic 1 — Core Player Runtime · NS-10 — E1.M1 InputAndMovementRuntime
Decomposition E1.M1 — InputAndMovementRuntime (MoveCommand / PositionState authority)

Delivery: Ship docs/plans/NS-19-implementation-plan.md on the same branch / PR as the NS-19 implementation (plan + code pushed together).

Goal, scope, and out-of-scope

Goal: Server-side sanity rules on MoveCommand so impossible steps (teleport, out-of-world targets) are not applied to authoritative PositionState. Failures return a stable reasonCode for logs and future telemetry.

Design alignment — floor play (XZ emphasis)

Match NS-18: prototype gameplay is on the floor. Primary move cap should be horizontal displacement on X/Z (same spirit as HorizontalReach). Optionally cap |ΔY| separately so small floor bumps are allowed but vertical teleports are not. Document the exact rule in code and server README.

In scope

  • Configurable limits under the existing Game options section (extend GamePositionOptions or add a nested MovementValidationOptions bound from appsettings / env): at minimum max horizontal step (XZ) and max absolute vertical delta per command; tunable defaults suitable for the current Godot click-to-move stride.
  • Optional axis-aligned “district” bounds (min/max per axis or min/max XZ + Y range): when enabled, reject targets outside the box; when disabled, skip the check (default for minimal friction in local dev).
  • POST /game/players/{id}/move: after resolving the current authoritative position, run validation before TryApplyMoveTarget. On violation, do not mutate position or sequence.
  • Error payload: JSON body with schemaVersion (1) and reasonCode (non-empty, stable string). HTTP status — prefer 400 for “valid request shape but move rejected” vs 404 for unknown player (keep 404 unchanged); finalize in PR if needed.

Out of scope (per Jira)

  • Navmesh, full anti-cheat, client prediction / reconciliation.

Dependencies

Policy — reject (locked)

Invalid moves are rejected: return the error payload; do not change authoritative position or sequence. Client can log reasonCode or show a short label. Clamp (silent or partial apply) is out of scope for NS-19; a future story would need explicit product rules if we ever add it.

Acceptance criteria checklist

  • Oversized move (horizontal and/or vertical per configured rules) is rejected; client can observe failure (existing logging and/or minimal UI).
  • Rules implemented in testable pure logic (e.g. static MoveCommandValidation or small service) with unit tests on the math and edge cases (at limit, just inside, just outside; floating-point safe).
  • Constants / thresholds live in one configuration surface (options pattern bound to Game:), not scattered magic numbers.
  • Integration tests on PositionStateApi / HTTP: valid move still 200 + PositionStateResponse; invalid move returns the agreed status + reasonCode; unknown player still 404.

Technical approach

1. Validation helper (C#)

  • Inputs: from PositionSnapshot (or x,y,z + sequence if only coords matter), to PositionVector target, options snapshot.
  • Outputs: bool ok + reasonCode when not ok.
  • Horizontal step: \sqrt{(x_t-x_f)^2 + (z_t-z_f)^2} compared to MaxHorizontalStep (or chosen name).
  • Vertical: |y_t - y_f| compared to MaxVerticalStep.
  • Bounds: when enabled, require min.X <= x_t <= max.X (and Y, Z similarly) — document inclusive vs exclusive boundaries (recommend inclusive min/max for a closed district box).

2. Wire-up in PositionStateApi

  • Inject IOptions<GamePositionOptions> (or a dedicated options type) into the minimal API delegate via method injection / scoped helper already used elsewhere in the app.
  • Flow: validate body (existing) → TryGetPosition → if missing 404validate move → if fail return error JSON → else TryApplyMoveTarget200 + response.

3. Response DTO for failures

  • New type e.g. MoveCommandRejectedResponse with schemaVersion, reasonCode.
  • Stable reasonCode values (v1 draft — finalize in PR): e.g. horizontal_step_exceeded, vertical_step_exceeded, out_of_bounds (names snake_case, stable for telemetry).

4. Configuration defaults

  • Document in server/README.md (Position / move section): keys, units (world meters), and example appsettings.Development.json overrides.
  • Defaults should allow normal prototype play from current spawn / interactable layout; tighten in tests to force rejections without flaky timing.

5. Client (minimal)

  • If the Godot client treats every 200 as success today, add a branch for 400 (or chosen code): log reasonCode and optionally show a Label or status text — enough to satisfy “client can surface failure” without building full UI.

6. Documentation hygiene

Files to add

Path Purpose
server/NeonSprawl.Server/Game/PositionState/MoveCommandValidation.cs Pure validation API: current snapshot + target + options → pass/fail + reasonCode (horizontal step, vertical step, optional bounds).
server/NeonSprawl.Server/Game/PositionState/MoveCommandRejectedResponse.cs JSON DTO for failed moves: schemaVersion, reasonCode.
server/NeonSprawl.Server.Tests/Game/PositionState/MoveCommandValidationTests.cs Unit tests for validation math (at limit, inside, outside; float-safe; bounds on/off).

Files to modify

Path Rationale
server/NeonSprawl.Server/Game/PositionState/GamePositionOptions.cs Add nested MovementValidation (or sibling options type still bound under Game:): max horizontal step, max vertical delta, district bounds enable + min/max vector (or per-axis), defaults for prototype.
server/NeonSprawl.Server/Game/PositionState/PositionStateApi.cs After TryGetPosition, run validation before TryApplyMoveTarget; on failure return 400 + MoveCommandRejectedResponse; inject IOptions<GamePositionOptions> (or IOptionsSnapshot<>).
server/NeonSprawl.Server/Game/PositionState/PositionStateServiceCollectionExtensions.cs Only if binding shape changes (e.g. new subsection); ensure new options properties bind from configuration.
server/NeonSprawl.Server/appsettings.json and/or appsettings.Development.json Document example keys for movement limits and optional bounds (sane defaults for click-to-move).
server/README.md Document move validation, config keys, rejection status/body, and reasonCode values.
server/NeonSprawl.Server.Tests/InMemoryWebApplicationFactory.cs Optional: small helper or documented pattern to override Game options with strict limits for HTTP tests without relying on production defaults.
client/scripts/position_authority_client.gd Handle non-200 from move POST: log reasonCode (and optional user-visible hint); do not emit authoritative snap as success.
docs/decomposition/modules/E1_M1_InputAndMovementRuntime.md Implementation snapshot: NS-19 move validation + link to this plan.

Tests

Action Path What to cover
Add server/NeonSprawl.Server.Tests/Game/PositionState/MoveCommandValidationTests.cs Horizontal step: at MaxHorizontalStep, epsilon inside, epsilon outside; vertical `
Change server/NeonSprawl.Server.Tests/Game/PositionState/MoveCommandApiTests.cs Existing happy-path moves still 200 under new defaults; add cases: step too large → 400 + body contains expected reasonCode; unknown player still 404; schema/body errors unchanged 400 (no reasonCode vs rejection body—document chosen behavior).
Optional server/NeonSprawl.Server.Tests/Game/PositionState/PostgresPositionStateIntegrationTests.cs No change expected — validation runs before store write; skip unless a regression appears.

Manual (if needed): Run Godot client, attempt an oversized click-to-move (after tightening limits in dev config) and confirm log/UI surfaces rejection.

Open questions / risks

None.

PR / review

Paste or adapt when opening the PR; align reasonCode strings and HTTP status with final DTOs (reject-only behavior). Cross-check server README and E1.M1 for consistency.