# NEO-25 — Implementation plan ## Story reference | Field | Value | |--------|--------| | **Key** | NEO-25 | | **Title** | E1.M3: InteractableDescriptor list / projection (multi-entity) | | **Linear** | [NEO-25](https://linear.app/neon-sprawl/issue/NEO-25/e1m3-interactabledescriptor-list-projection-multi-entity) | | **Slug** | E1M3-03 | | **Git branch** | `NEO-25-e1m3-interactable-descriptor-list` | | **Parent context** | [Epic 1 — Core Player Runtime](https://linear.app/neon-sprawl/project/epic-1-core-player-runtime-client-controls-character-loop-66bd590cd016) · [E1M3 prototype backlog](E1M3-prototype-backlog.md) | | **Decomposition** | [E1.M3 — InteractionAndTargetingLayer](../decomposition/modules/E1_M3_InteractionAndTargetingLayer.md) | ## Goal, scope, and out-of-scope **Goal:** The server exposes a **versioned JSON** snapshot of **`InteractableDescriptor`** rows for **≥2** prototype interactables so the client can discover anchors, radii, and **kind** without treating GDScript as the long-term registry. The existing NEO-9 **`POST /game/players/{id}/interact`** path remains authoritative per id (horizontal X/Z reach, inclusive radius). **In scope** - **Registry:** Extend `PrototypeInteractableRegistry` with a **second** row: stable id **`prototype_resource_node_alpha`**, world anchor **`(5, 0.5, 5)`**, **`interactionRadius`** (same **3.0** m as the terminal unless testing needs otherwise — document in registry comments), **`kind`** string **`resource_node`**. Keep **`prototype_terminal`** at **`(0, 0.5, 0)`**, **`kind`** **`terminal`**, radius **3.0** (unchanged NEO-9 defaults). - **HTTP:** **`GET /game/world/interactables`** returns **`InteractablesListResponse`** v1: top-level **`schemaVersion`**, **`interactables`** array of descriptors. Each element includes **`interactableId`** (lowercase canonical), **`kind`**, **`anchor`** (`x`, `y`, `z`), **`interactionRadius`**. **Stable JSON order:** ascending **`interactableId`** so tests and docs stay deterministic (`prototype_resource_node_alpha` before `prototype_terminal`). - **Client (Decision — option A):** On boot (after or in parallel with position authority wiring — see Technical approach), **`GET`** the list and treat it as the **only** source for anchor/radius/kind used by **preview glow** and **world meshes**. **Remove** `prototype_interaction_constants.gd`; no duplicated anchor/radius constants for gameplay preview. - **Client visuals:** For **each** descriptor, spawn **one** visible prop mesh at the anchor (distinct materials by **`kind`** for readability) plus **two** small glow marker meshes per interactable (same emission pattern as today’s `interaction_radius_indicators.gd`, but **N** interactables × 2 markers). Preview still compares player **`global_position`** to each descriptor’s anchor on **X/Z** only with **inclusive** `<=` vs that row’s **`interactionRadius`** — server **`POST …/interact`** remains authoritative. - **Client interaction QA:** Two input actions so each id is explicitly reachable without cycling ambiguity: **`interact`** (default **E**) → **`prototype_terminal`**; **`interact_secondary`** (default **R**) → **`prototype_resource_node_alpha`**. Document in **`client/project.godot`** and **`client/README.md`**. - **Tests:** C# integration tests for the new GET (schema, count ≥ 2, field presence, ordering). Extend or add interaction tests proving **`POST …/interact`** allows each id when the seeded player is moved in range and denies when out of range. - **Bruno:** New request(s) under **`bruno/neon-sprawl-server/`** for the GET (per [testing-expectations](../../.cursor/rules/testing-expectations.md)). - **Manual QA:** During implementation, add **`docs/manual-qa/NEO-25.md`** per [planning-implementation-docs](../../.cursor/rules/planning-implementation-docs.md). **Out of scope (per Linear / backlog)** - Full data-driven content pipeline ([contracts.md](../decomposition/modules/contracts.md) content kind). - Gather loop / rewards ([E3.M1](../decomposition/modules/E3_M1_ResourceNodeAndGatherLoop.md)). - Per-player or zone-filtered lists (registry is global; route is world-scoped intentionally — future visibility can add query params or a different resource without breaking this v1 list). - **`positionHint`** on interact (NEO-24-style) — not required for NEO-25; interact still reads **`IPositionStateStore`** only. ## Acceptance criteria checklist - [x] **≥2 interactables** in **`PrototypeInteractableRegistry`** with distinct ids, anchors, radii, and **kinds** (`terminal` vs `resource_node`). - [x] **`GET /game/world/interactables`** returns versioned JSON whose **`interactables`** entries each include **`interactableId`**, **`anchor`** (world), **`interactionRadius`**, **`kind`**. - [x] **`POST /game/players/{id}/interact`** with **`InteractionRequest`** v1 succeeds for **each** id when the player is in horizontal range; denies with stable **`reasonCode`** when out of range (NEO-9 semantics preserved). - [x] **Godot:** preview glow + scene props are driven from the **fetched** descriptor list (no `prototype_interaction_constants.gd`). **`interact`** / **`interact_secondary`** each hit the correct id for manual QA. - [x] **Bruno** + **server README** document the new GET. ## Technical approach 1. **C# registry model:** Extend **`PrototypeInteractableEntry`** (or equivalent) with **`Kind`** (`string`, stable machine values **`terminal`**, **`resource_node`** for v1). Add **`IReadOnlyList`** or a static **`GetAllDescriptors()`** that projects registry rows in ascending id order for the HTTP handler. 2. **DTOs:** New types under `NeonSprawl.Server/Game/Interaction/` (e.g. **`InteractableDescriptorResponse`**, **`InteractablesListResponse`**) with XML/summary comments matching the README field table. Reuse **`PositionVector`** from targeting if it already serializes `{x,y,z}` cleanly; otherwise a small **`AnchorVector`** DTO co-located with interactables. 3. **Route registration:** `app.MapGet("/game/world/interactables", …)` — **no player id**; **no** store dependency (static registry only). If we later need player-scoped visibility, add a new route rather than overloading this one. 4. **Godot catalog client:** New script (e.g. **`interactables_catalog_client.gd`**) with the same HTTP patterns as **`target_selection_client.gd`** / **`interaction_request_client.gd`**: `HTTPRequest`, `_busy`, `base_url`, `request_catalog()` on `_ready` or when **`main.gd`** triggers after first frame. Signal **`catalog_ready(descriptors: Array)`** where each element is a **`Dictionary`** mirroring JSON (or a small typed helper). On failure: `push_error` + leave **`InteractionMarkers`** empty (document: run server first for full scene). 5. **World build-out:** Replace the static **`World/NavigationRegion3D/PrototypeTerminal`** + static **`World/InteractionMarkers/MarkerA|B`** pattern with either: - **Preferred:** an empty **`World/InteractablesRoot`** `Node3D` + child script **`interactable_scene_builder.gd`** (or merged into catalog client) that on **`catalog_ready`** spawns **`StaticBody3D`** (walkable group) + mesh per descriptor at **`anchor`**, and spawns **two** `MeshInstance3D` markers per descriptor under **`World/InteractionMarkers`**, then calls into the existing per-frame glow logic (refactor **`interaction_radius_indicators.gd`** into a **`Node3D`** that owns **arrays** of marker nodes + descriptor metrics, **or** one material per interactable row). - **Scene cleanup:** Remove hard-coded terminal + two markers from **`main.tscn`** once runtime spawn is wired; keep **`main.gd`** thin — connect catalog → builder → `interaction_radius_indicators` equivalent. 6. **`main.gd` sequencing:** Call **`request_catalog()`** early enough that glow has data before the player walks (e.g. same `_ready` block after nav bake, **before** or **after** `sync_from_server` — if after, document one-frame dim state). Ensure **`InteractionRequestClient`** does not POST until catalog is ready **or** hard-bind the two actions to the **stable ids** above (catalog still drives visuals; keys use fixed id strings matching server contract). 7. **Docs:** **`server/README.md`** — subsection **Interactable descriptors (NEO-25)** with curl example and field table. **`client/README.md`** — replace NEO-9 “constants file” language with “fetched from **`GET /game/world/interactables`**”. Optionally one line in **[E1_M3_InteractionAndTargetingLayer.md](../decomposition/modules/E1_M3_InteractionAndTargetingLayer.md)** implementation snapshot when the story merges. ## Decisions (kickoff — locked) | Topic | Choice | |--------|--------| | **List route** | **`GET /game/world/interactables`** (global registry projection). | | **Second row** | **`prototype_resource_node_alpha`** @ **`(5, 0.5, 5)`**, **`kind`:** **`resource_node`**, radius **3.0** (align with terminal unless implementation discovers a reason to differ). | | **Client registry source** | **Option A:** fetch list on boot; **no** `prototype_interaction_constants.gd`. | | **Client visuals** | Full parity: **one** prop + **two** glow markers **per** descriptor. | | **Interaction keys** | **`interact`** → terminal; **`interact_secondary`** → resource node id. | ## Files to add | Path | Purpose | |------|---------| | `server/NeonSprawl.Server/Game/Interaction/InteractablesListDtos.cs` | Versioned **`InteractablesListResponse`** + nested **`InteractableDescriptor`** JSON shape for GET. | | `server/NeonSprawl.Server/Game/Interaction/InteractablesWorldApi.cs` | `MapInteractablesWorldApi` — registers **`GET /game/world/interactables`**. | | `client/scripts/interactables_catalog_client.gd` | HTTP GET catalog; cache; **`catalog_ready`** signal. | | `client/scripts/interactable_world_builder.gd` | On catalog: spawn walkable meshes + marker children from descriptor data (keeps **`main.gd`** thin per [godot-client-script-organization](../../.cursor/rules/godot-client-script-organization.md)). | | `client/test/interactables_catalog_client_test.gd` | GdUnit: mock transport returns JSON; assert parse + ordering + field extraction. | | `client/test/interactables_catalog_test_double.gd` | Optional: mock HTTP helper mirroring other test doubles. | | `server/NeonSprawl.Server.Tests/Game/Interaction/InteractablesWorldApiTests.cs` | Integration: GET returns **200**, schema **1**, **≥2** items, ascending ids, required fields. | | `bruno/neon-sprawl-server/interaction/Get interactables list.bru` | (Or under `bruno/neon-sprawl-server/world/` if we prefer grouping) — documents GET contract. | | `docs/manual-qa/NEO-25.md` | Story manual QA checklist (generated during implementation per project rule). | ## Files to modify | Path | Rationale | |------|-----------| | `server/NeonSprawl.Server/Game/Interaction/PrototypeInteractableRegistry.cs` | Add second entry + **`Kind`** on entries; expose enumeration / projection for GET; update XML to drop “sync GDScript constants” wording in favor of NEO-25 GET. | | `server/NeonSprawl.Server/Game/Interaction/InteractionApi.cs` | No path change to POST; only if shared helpers are needed for descriptor projection (otherwise leave untouched). | | `server/NeonSprawl.Server/Program.cs` | Register **`MapInteractablesWorldApi()`** (or equivalent one-liner). | | `server/README.md` | Document NEO-25 GET + example JSON + field table next to Interaction (NEO-9). | | `server/NeonSprawl.Server.Tests/Game/Interaction/InteractionApiTests.cs` | Add/adjust cases: interact allowed for **each** id in range; unknown id unchanged; optional ordering smoke for registry if useful. | | `client/scripts/interaction_radius_indicators.gd` | Refactor to **multi-descriptor** input (arrays / setup from builder) or merge into builder with the same emission rules as today. | | `client/scripts/interaction_request_client.gd` | Remove preload of deleted constants; wire **`interact`** / **`interact_secondary`** to the two stable ids; optionally block POST until catalog loaded (minimal: document that keys use contract ids). | | `client/scripts/main.gd` | Instantiate/connect **`InteractablesCatalogClient`**, call **`request_catalog`**, connect **`catalog_ready`** → world builder + indicator setup; remove references to deleted constants script. | | `client/scenes/main.tscn` | Remove static **`PrototypeTerminal`** + static **`MarkerA`/`MarkerB`** once runtime spawn replaces them; add **`InteractablesCatalogClient`** node + empty roots as needed. | | `client/project.godot` | Register **`interact_secondary`** (default **R**). | | `client/README.md` | NEO-9/NEO-25 manual flow: fetch-driven preview, dual keys, server-first tuning. | | `docs/decomposition/modules/E1_M3_InteractionAndTargetingLayer.md` | Optional one-line implementation snapshot update when NEO-25 merges (descriptor list live). | ## Files to remove | Path | Rationale | |------|-----------| | `client/scripts/prototype_interaction_constants.gd` | Superseded by server-backed catalog (**Option A**); delete **.gd** and **.uid** companion if present. | ## Tests | Test file | What to cover | |-----------|----------------| | `server/NeonSprawl.Server.Tests/Game/Interaction/InteractablesWorldApiTests.cs` | **GET** returns **200**; **`schemaVersion`** **1**; **`interactables`** length **≥ 2**; sorted by **`interactableId`**; each entry has **`kind`**, **`anchor`**, **`interactionRadius`**; spot-check values for **`prototype_terminal`** and **`prototype_resource_node_alpha`**. | | `server/NeonSprawl.Server.Tests/Game/Interaction/InteractionApiTests.cs` | For **each** registry id: seed player position **in range** → **`allowed: true`**; move **out of range** → **`allowed: false`**, **`reasonCode: out_of_range`**; preserve **400**/unknown interactable behaviors from NEO-9. | | `client/test/interactables_catalog_client_test.gd` | Parse happy-path JSON; assert signal payload shape and stable ordering. | | `client/test/interaction_request_client_test.gd` | **Add** if missing: when adding/changed `interaction_request_client.gd`, per [testing-expectations](../../.cursor/rules/testing-expectations.md) — mock HTTP; assert correct **`interactableId`** in POST bodies for **E** vs **R** actions. *(If file does not exist yet, create minimal suite alongside the client change.)* | **Manual:** `docs/manual-qa/NEO-25.md` — server + client; confirm GET in browser/curl; walk to each anchor; glow per row; **E** / **R** posts match **`allowed`** / **`reasonCode`** in Output. ## Open questions / risks - **Boot without server:** Descriptor GET fails → empty interactables / error log. Acceptable for prototype; document in README (run server before F5 for full layout). - **Collision / nav:** New **`StaticBody3D`** props must remain **`walkable`** (or not block nav) — place on floor; use same collision pattern as the old **`PrototypeTerminal`**. - **Id strings in `interaction_request_client.gd`:** Stable **`interact`** / **`interact_secondary`** bindings reference canonical ids as **literals** — not geometry duplication, but if registry ids rename, update client + tests together.