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

126 lines
15 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

# 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 **`(12, 0.5, 6)`** (open floor; avoids **`Obstacle`** in `main.tscn` at ~(6, 1, 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 todays `interaction_radius_indicators.gd`, but **N** interactables × 2 markers). Preview still compares player **`global_position`** to each descriptors anchor on **X/Z** only with **inclusive** `<=` vs that rows **`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<InteractableDescriptorDto>`** 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`** @ **`(12, 0.5, 6)`**, **`kind`:** **`resource_node`**, radius **3.0** (moved from +XZ prototype to stay clear of scene **`Obstacle`**). |
| **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.