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
Gameoptions section (extendGamePositionOptionsor add a nestedMovementValidationOptionsbound fromappsettings/ 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 beforeTryApplyMoveTarget. On violation, do not mutate position or sequence.- Error payload: JSON body with
schemaVersion(1) andreasonCode(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
MoveCommandValidationor 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), toPositionVectortarget, options snapshot. - Outputs:
bool ok+reasonCodewhen not ok. - Horizontal step:
\sqrt{(x_t-x_f)^2 + (z_t-z_f)^2}compared toMaxHorizontalStep(or chosen name). - Vertical:
|y_t - y_f|compared toMaxVerticalStep. - 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 404 → validate move → if fail return error JSON → elseTryApplyMoveTarget→ 200 + response.
3. Response DTO for failures
- New type e.g.
MoveCommandRejectedResponsewithschemaVersion,reasonCode. - Stable
reasonCodevalues (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 exampleappsettings.Development.jsonoverrides. - 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
reasonCodeand optionally show a Label or status text — enough to satisfy “client can surface failure” without building full UI.
6. Documentation hygiene
- After merge: add one line to E1_M1_InputAndMovementRuntime.md implementation snapshot (movement validation + link to this plan).
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.