NS-18: add implementation plan for InteractionRequest range check
parent
a2015dd4bf
commit
06f9ce8647
|
|
@ -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.
|
||||
Loading…
Reference in New Issue