From 06f9ce86478694d043d5e3402a573269d2b4087d Mon Sep 17 00:00:00 2001 From: VinPropane Date: Sat, 4 Apr 2026 18:55:13 -0400 Subject: [PATCH] NS-18: add implementation plan for InteractionRequest range check --- docs/plans/NS-18-implementation-plan.md | 208 ++++++++++++++++++++++++ 1 file changed, 208 insertions(+) create mode 100644 docs/plans/NS-18-implementation-plan.md diff --git a/docs/plans/NS-18-implementation-plan.md b/docs/plans/NS-18-implementation-plan.md new file mode 100644 index 0000000..0394410 --- /dev/null +++ b/docs/plans/NS-18-implementation-plan.md @@ -0,0 +1,208 @@ +# NS-18 — Implementation plan + +## Story reference + +| Field | Value | +|--------|--------| +| **Key** | NS-18 | +| **Title** | E1.M1: InteractionRequest with server-side range check | +| **Jira** | [NS-18](https://neon-sprawl.atlassian.net/browse/NS-18) | +| **Parent context** | [NS-1 — Epic 1 — Core Player Runtime](https://neon-sprawl.atlassian.net/browse/NS-1) · [NS-10 — E1.M1 InputAndMovementRuntime](https://neon-sprawl.atlassian.net/browse/NS-10) | +| **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/NS-18-implementation-plan.md`** on the **same branch / PR** as the NS-18 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 ([NS-15](NS-15-implementation-plan.md) / [NS-16](NS-16-implementation-plan.md) / [NS-17](NS-17-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 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 ([NS-15](NS-15-implementation-plan.md), [NS-16](NS-16-implementation-plan.md)); optional Postgres ([NS-17](NS-17-implementation-plan.md)) unchanged—interaction reads current store only. + +## Acceptance criteria checklist + +- [ ] **In range** → **200** + `allowed: true`, optional **`payload`**, **`reasonCode` omitted** (**exactly at** radius counts as in range — **inclusive** `<=`). +- [ ] **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. +- [ ] **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)`** → `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`**. **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 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 (NS-18)** — 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 (NS-18). + +## 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` | NS-18 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 NS-18. | +| `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 (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):** + +```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). + +Paste or adapt when opening the PR; align field names with final DTOs if they differ slightly.