neon-sprawl/docs/plans/NEO-9-implementation-plan.md

213 lines
17 KiB
Markdown

# 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.