neon-sprawl/docs/plans/NEON-6-implementation-plan.md

17 KiB

NEON-6 — Implementation plan

Story reference

Field Value
Key NEON-6
Title E1.M1: InteractionRequest with server-side range check
Jira NEON-6
Parent context NEON-1 — Epic 1 — Core Player Runtime · NEON-9 — E1.M1 InputAndMovementRuntime
Decomposition E1.M1 — InputAndMovementRuntime (range / authority); seeds E1.M3 — InteractionAndTargetingLayer contracts

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

Goal, scope, and out-of-scope

Goal: Server-authoritative answer to “can this player use this interactable now?” using the same position source of truth as movement (NEON-3 / NEON-4 / NEON-5).

Design decision — floor plane only (Y ignored for reach)

The game is 3D, but prototype interaction and movement tuning treat all meaningful play as on the floor. Reach / range checks use X and Z only; Y is ignored for distance (positions may still carry Y for rendering and future use). There is no vertical stacking, mezzanine, or multi-floor requirement in this story. When we add multiple floors or vertical gameplay, we will revisit distance rules (e.g. per-floor partition, vertical tolerance, or full 3D radius) and update contracts—out of scope until then.

In scope

  • Shared distance helper (server library): single place for horizontal reach on X/Z only (Y not used for threshold), documented for reuse. Matches player.gd horizontal steering and keeps server logic aligned with “actions on the floor.”
  • Static prototype interactable table in code (id → world position + max interaction radius). One row is enough (e.g. prototype_terminal near the play space).
  • HTTP API: versioned InteractionRequest JSON; server loads authoritative PositionState for {id} from IPositionStateStore, compares to interactable position via the helper, returns allowed + optional small payload, or denied + stable reasonCode string.
  • Godot: one placeholder interactable in the world (visible mesh + collision optional); minimal client path that POSTs an interaction for a fixed id after a key press (e.g. E) so QA can stand in range vs out of range without building full targeting.

Out of scope (per Jira)

  • Inventory, rich UI prompts, full E1.M3 selection / hover / target-lock polish, multiple interactable types or data-driven defs beyond the static table.

Dependencies

  • Authoritative PositionState + move path (NEON-3, NEON-4); optional Postgres (NEON-5) unchanged—interaction reads current store only.

Acceptance criteria checklist

  • In range200 + allowed: true, optional payload, reasonCode omitted (exactly at radius counts as in range — inclusive <=).
  • Out of range200 + allowed: false and required reasonCode (e.g. out_of_range) — whenever allowed is false, reasonCode must always be present (non-empty). Document in DTO XML + README.
  • Moving the player via existing POST move → GET position flow changes the outcome—no client-only bypass (server always re-reads store).
  • Client scene: placeholder interactable plus two or more small marker objects that glow (e.g. emission / bright modulate) when the player is within interactionRadius on X/Z (inclusive <=), and appear dim when out of range—so you can see proximity while walking without a floor ring. Glow logic is client preview only (see §6); POST /interact remains authoritative.
  • Unit tests for distance helper: at threshold, just inside, just outside (floating-point safe).
  • Integration: unknown player → 404; unknown interactable → 200 + allowed: false, unknown_interactable.

Technical approach

1. Distance helper (C#)

  • New static or injectable type, e.g. WorldReach or InteractionRangeMath, in a small folder under NeonSprawl.Server/Game/ (e.g. Game/World/ or next to interaction feature).
  • HorizontalDistance(a, b)double using only X and Z (Y intentionally ignored per design decision above).
  • IsWithinHorizontalRadius(player, target, radius)boolinclusive boundary: distance <= radius counts as in range (exactly on the radius is allowed). Deny only when distance > radius.

2. Prototype interactable registry

  • Static read-only map: interactableId (string, lowercase canonical keys) → position (x,y,z) + interactionRadius (double).

Prototype defaults (source of truth: C# registry)

Authoritative values live in PrototypeInteractableRegistry.cs. Godot and any client preview script must match (duplicate constants in prototype_interaction_constants.gd with a comment pointing at that file). Tune by editing C# first, then syncing the client.

Value Rationale
interactableId prototype_terminal Single prototype row.
World position x = 0, y = 0.5, z = 0 Map center on X/Z (floor ±10); Y ~mid prop above floor. Default spawn is (-5, 0.9, -5) — horizontal distance to (0, 0) is √50 ≈ 7.07, so a short walk tests out of range → in range.
interactionRadius 3.0 ~3 m reach on the floor plane; easy to see glow / E flip without covering the whole arena.

interactableId matching (locked — default)

  • Trim leading/trailing whitespace on the request value.
  • Case-insensitive lookup: compare using ordinal ignore-case (or normalize to lowercase before dictionary lookup); registry keys remain lowercase.
  • Missing interactableId, wrong type, or empty after trim400 (not unknown_interactable).
  • After trim + case fold, if no registry entry → 200 + allowed: false, reasonCode: unknown_interactable.

3. Request / response DTOs (JSON v1)

  • InteractionRequest (POST body): schemaVersion (1), interactableId (string, required).
  • InteractionResponse (200 body): schemaVersion (1), allowed (bool). Field rules (locked):
    • allowed == false: reasonCode is required — non-empty stable string (unknown_interactable, out_of_range, …). Never return a denial without it.
    • allowed == true: reasonCode omitted (or explicitly documented as unused on success—clients must not require it). payload optional.
    • Do not use reasonCode for unknown player—that case is 404, not a JSON denial body.
  • Route: POST /game/players/{id}/interact — player id in path (trim + case-insensitive match consistent with position APIs).

HTTP status (locked)

  • 400 — Request not processed as a valid v1 interaction attempt (missing/invalid schemaVersion, malformed JSON, missing interactableId, interactableId empty after trim, etc.).
  • 404Unknown player only (no row in IPositionStateStore after id normalization)—same spirit as GET / POST position APIs.
  • 200 — Request accepted and handled without throwing: body is always InteractionResponse v1, including allowed: false + reasonCode for unknown interactable, out of range, or any other logical denial. No 403 for “denied” outcomes in v1.

4. Handler flow

  1. Validate JSON body and schemaVersion; require interactableId present and non-empty after trim → else 400.
  2. Normalize interactableId for lookup (trim + case-insensitive match against lowercase registry keys).
  3. Normalize player id; TryGetPosition from IPositionStateStore. If missing → 404 (end; no interaction body).
  4. Resolve interactable from static table; if missing → 200 + allowed: false, reasonCode: unknown_interactable.
  5. Else compute horizontal distance; if > radius (boundary inclusive <= is in range) → 200 + allowed: false, reasonCode: out_of_range.
  6. Else → 200 + allowed: true + optional placeholder payload.

5. Composition

  • New InteractionApi.MapInteractionApi(this WebApplication app) (or merge into a thin GameApi later); Program.cs adds one line.
  • Inject or pass IPositionStateStore + registry (static for prototype).

6. Godot client (minimal)

  • Add a visible placeholder for the interactable (e.g. StaticBody3D + mesh, or MeshInstance3D) at the same X/Z (and floor Y) as the server registry row (document in client/README.md).
  • Radius QA — glowing markers (required for manual test): Add two or more decorative meshes (small boxes, orbs, pylons—no collision needed) near the interactable so they read clearly in the isometric view. A small script (e.g. interaction_radius_indicators.gd) each frame (or when the player moves) compares the authoritative player position — the CharacterBody3D global_position after server snap — to the interactable anchor in X/Z only, using the same interactionRadius and inclusive <= rule as the server. When in range, bump StandardMaterial3D.emission (or unshaded + modulate) on those meshes to glow; when out of range, dim them. This is preview-only for human testing; POST /interact still decides allow/deny. Document in client/README.md: glow uses prototype_interaction_constants.gd — same numbers as §2 prototype defaults / PrototypeInteractableRegistry.cs (see table above).
  • New small script (e.g. interaction_request_client.gd) or extend position_authority_client.gd: on key (e.g. ui_accept / E), POST /game/players/{dev_player_id}/interact with fixed interactableId; print allowed / reasonCode to Output for prototype verification (no HUD required).
  • main.gd: wire exports/node paths for player, interactable anchor, indicator script, interaction client; keep thin composer rule (godot-client-script-organization.md).

7. Documentation

  • server/README.md: subsection Interaction (NEON-6) — endpoint, curl example, reasonCode table, note on horizontal distance.
  • docs/decomposition/modules/E1_M1_InputAndMovementRuntime.md (and optionally documentation_and_implementation_alignment.md): one-line snapshot that InteractionRequest + range check landed (NEON-6).

Files to add

Path Purpose
server/NeonSprawl.Server/Game/Interaction/InteractionRequest.cs Versioned POST body DTO + XML.
server/NeonSprawl.Server/Game/Interaction/InteractionResponse.cs Versioned response DTO; XML: reasonCode required when allowed is false; omit on success.
server/NeonSprawl.Server/Game/Interaction/PrototypeInteractableRegistry.cs Static id → position + radius.
server/NeonSprawl.Server/Game/World/HorizontalReach.cs (or under Interaction/) Shared horizontal distance + within-radius helpers.
server/NeonSprawl.Server/Game/Interaction/InteractionApi.cs MapInteractionApi route registration.
server/NeonSprawl.Server.Tests/Game/Interaction/HorizontalReachTests.cs Boundary unit tests.
server/NeonSprawl.Server.Tests/Game/Interaction/InteractionApiTests.cs Integration tests via WebApplicationFactory.
client/scripts/interaction_request_client.gd (or name aligned with repo) HTTP POST interact + debug print.
client/scripts/prototype_interaction_constants.gd const anchor X/Z/Y + interaction_radius + id string — must match PrototypeInteractableRegistry.cs.
client/scripts/interaction_radius_indicators.gd (or merged name) Drive emission/modulate on marker meshes from player snap position vs prototype_interaction_constants.

Files to modify

Path Rationale
server/NeonSprawl.Server/Program.cs Register MapInteractionApi().
client/scenes/main.tscn Add prototype interactable + 2+ glow marker meshes (no collision); materials suitable for emission toggle.
client/scripts/main.gd Compose interaction client (signals/exports).
client/README.md NEON-6 manual check: markers glow in-range / dim out-of-range (preview = same XZ+radius as server); walk + E; compare Output to glow.
server/README.md Interaction API + reason codes + distance rule.
docs/decomposition/modules/E1_M1_InputAndMovementRuntime.md Implementation snapshot for NEON-6.
docs/decomposition/modules/documentation_and_implementation_alignment.md Optional tracking row update for E1.M1.

Tests

  • Unit: HorizontalReach / distance helper—exactly at radius → in range (<=); just outside (slightly > radius) → out of range; document float tolerance if any.
  • Integration: seeded player in range200, allowed: true, no reasonCode (or null per serializer policy—document); out of range / unknown interactable200, allowed: false, non-empty reasonCode; interactableId missing or whitespace-only400; unknown player404; POST move then POST interact updates outcome.
  • Manual: Godot F5 + server; walk until markers glow (in range) vs dim (out); press interact on E; confirm Output allowed / reasonCode matches (glow ⇒ expect allow at same constants; if mismatch, fix shared numbers or server bug).

Deferred (not this story)

  • Multi-floor / vertical placement: Revisit distance math, interactable placement, and any Y-aware rules when stacked levels matter; until then floor-plane reach only.

Pull request description (Jira AC) — draft for merge

InteractionRequest v1

  • Endpoint: POST /game/players/{id}/interact
  • HTTP: 404 = unknown player only. 400 = malformed / invalid v1 request. Any handled attempt for a known player returns 200 with InteractionResponse (including allowed: false and reasonCode).
  • Body (example):
{
  "schemaVersion": 1,
  "interactableId": "prototype_terminal"
}
JSON property Type (v1) Meaning
schemaVersion integer Must be 1 for v1.
interactableId string Prototype interactable id (server static table).

interactableId rules (v1): Trim whitespace; case-insensitive lookup vs lowercase registry keys. Missing or empty after trim400. Unknown id after normalization → 200 + unknown_interactable.

InteractionResponse v1 (example allowed)

{
  "schemaVersion": 1,
  "allowed": true,
  "interactableId": "prototype_terminal",
  "payload": { "kind": "placeholder" }
}

InteractionResponse v1 (example denied)

{
  "schemaVersion": 1,
  "allowed": false,
  "reasonCode": "out_of_range"
}
reasonCode (v1) When HTTP
out_of_range Horizontal distance > interactionRadius (on the radius is allowed — inclusive <=). 200
unknown_interactable Id not in prototype table. 200

Not in body: Unknown player → 404 (no InteractionResponse). Malformed request → 400.

allowed: false: body must include reasonCode. allowed: true: omit reasonCode; payload optional.

Distance rule (v1): Horizontal distance on X/Z only; in range iff sqrt(dx² + dz²) <= interactionRadius (inclusive boundary). Y is not used for reach (floor-level actions; multi-floor is a future change).

Note for reviewers — dotnet test and Postgres

The test project uses committed postgres.runsettings (RunSettingsFilePath in NeonSprawl.Server.Tests.csproj), so ConnectionStrings__NeonSprawl is always set during dotnet test / Rider. The three Postgres integration tests execute (they do not skip). Without a reachable database—and without local Docker for the harness to run docker compose up—those tests fail. That is intentional; CI supplies Postgres via the workflow service container. See server/README.md (Postgres integration tests + Contributors / PRs).

Paste or adapt when opening the PR; align field names with final DTOs if they differ slightly.