# NEO-9 — Implementation plan ## Story reference | Field | Value | |--------|--------| | **Key** | NEO-9 | | **Title** | E1.M1: InteractionRequest with server-side range check | | **Linear** | [NEO-9](https://linear.app/neon-sprawl/issue/NEO-9) | | **Parent context** | [Epic 1 — Core Player Runtime](https://linear.app/neon-sprawl/project/epic-1-core-player-runtime-client-controls-character-loop-66bd590cd016) · [E1.M1 InputAndMovementRuntime](https://linear.app/neon-sprawl/project/e1m1-inputandmovementruntime-20eb28dd3db4) | | **Decomposition** | [E1.M1 — InputAndMovementRuntime](../decomposition/modules/E1_M1_InputAndMovementRuntime.md) (range / authority); seeds [E1.M3 — InteractionAndTargetingLayer](../decomposition/modules/E1_M3_InteractionAndTargetingLayer.md) contracts | **Delivery:** Ship **`docs/plans/NEO-9-implementation-plan.md`** on the **same branch / PR** as the NEO-9 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 ([NEO-6](NEO-6-implementation-plan.md) / [NEO-7](NEO-7-implementation-plan.md) / [NEO-8](NEO-8-implementation-plan.md)). **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 Linear)** - 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 ([NEO-6](NEO-6-implementation-plan.md), [NEO-7](NEO-7-implementation-plan.md)); optional Postgres ([NEO-8](NEO-8-implementation-plan.md)) unchanged—interaction reads current store only. ## Acceptance criteria checklist - [x] **In range** → **200** + `allowed: true`, optional **`payload`**, **`reasonCode` omitted** (**exactly at** radius counts as in range — **inclusive** `<=`). - [x] **Out of range** → **200** + `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. - [x] **Moving** the player via existing **POST move → GET position** flow changes the outcome—**no** client-only bypass (server always re-reads store). - [x] **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. - [x] **Unit tests** for distance helper: **at** threshold, **just inside**, **just outside** (floating-point safe). - [x] **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)`** → `bool` — **inclusive 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`**. **Historical note:** the prototype client originally duplicated anchors in `prototype_interaction_constants.gd` (NEO-9). **NEO-25** replaced that with **`GET /game/world/interactables`** + runtime spawn; tune interactables by editing **C# first**, then verifying the GET JSON and client visuals. | | 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 trim** → **400** (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.). - **404** — **Unknown 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](../../.cursor/rules/godot-client-script-organization.md)). ### 7. Documentation - **`server/README.md`:** subsection **Interaction (NEO-9)** — 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 (NEO-9). ## 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` | NEO-9 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 NEO-9. | | `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 range** → **200**, `allowed: true`, **no** `reasonCode` (or null per serializer policy—document); **out of range** / **unknown interactable** → **200**, `allowed: false`, **non-empty** `reasonCode`; **`interactableId` missing or whitespace-only** → **400**; **unknown player** → **404**; **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 (Linear 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):** ```json { "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 trim** → **400**. Unknown id after normalization → **200** + `unknown_interactable`. **InteractionResponse v1 (example allowed)** ```json { "schemaVersion": 1, "allowed": true, "interactableId": "prototype_terminal", "payload": { "kind": "placeholder" } } ``` **InteractionResponse v1 (example denied)** ```json { "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.