From 6e40af1a1a87a1276ffc7451841ba8eb2c658c66 Mon Sep 17 00:00:00 2001 From: VinPropane Date: Fri, 24 Apr 2026 20:24:00 -0400 Subject: [PATCH 1/9] NEO-25: add implementation plan (descriptor list + fetch-driven client) --- docs/plans/NEO-25-implementation-plan.md | 125 +++++++++++++++++++++++ 1 file changed, 125 insertions(+) create mode 100644 docs/plans/NEO-25-implementation-plan.md diff --git a/docs/plans/NEO-25-implementation-plan.md b/docs/plans/NEO-25-implementation-plan.md new file mode 100644 index 0000000..5880ba6 --- /dev/null +++ b/docs/plans/NEO-25-implementation-plan.md @@ -0,0 +1,125 @@ +# 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 + +- [ ] **≥2 interactables** in **`PrototypeInteractableRegistry`** with distinct ids, anchors, radii, and **kinds** (`terminal` vs `resource_node`). +- [ ] **`GET /game/world/interactables`** returns versioned JSON whose **`interactables`** entries each include **`interactableId`**, **`anchor`** (world), **`interactionRadius`**, **`kind`**. +- [ ] **`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). +- [ ] **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. +- [ ] **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. From ce54fcc44f55baec02621c3ebe3b246651c4ed77 Mon Sep 17 00:00:00 2001 From: VinPropane Date: Fri, 24 Apr 2026 20:36:06 -0400 Subject: [PATCH 2/9] NEO-25: world interactables GET, registry rows, fetch-driven Godot props + glow --- .../interaction/Get interactables list.bru | 11 +++ client/README.md | 18 ++-- client/project.godot | 5 + client/scenes/main.tscn | 40 +------- client/scripts/interactable_world_builder.gd | 99 +++++++++++++++++++ .../scripts/interactables_catalog_client.gd | 84 ++++++++++++++++ .../scripts/interaction_radius_indicators.gd | 49 ++++----- client/scripts/interaction_request_client.gd | 35 ++++--- client/scripts/main.gd | 21 +++- .../prototype_interaction_constants.gd | 25 ----- .../prototype_interaction_constants.gd.uid | 1 - .../test/interactables_catalog_client_test.gd | 31 ++++++ .../test/interaction_request_client_test.gd | 52 ++++++++++ .../E1_M3_InteractionAndTargetingLayer.md | 4 +- docs/manual-qa/NEO-25.md | 20 ++++ docs/plans/NEO-25-implementation-plan.md | 10 +- docs/plans/NEO-9-implementation-plan.md | 2 +- .../Interaction/InteractablesWorldApiTests.cs | 43 ++++++++ .../Game/Interaction/InteractionApiTests.cs | 47 +++++++++ .../Game/Interaction/InteractablesListDtos.cs | 35 +++++++ .../Game/Interaction/InteractablesWorldApi.cs | 23 +++++ .../PrototypeInteractableRegistry.cs | 51 +++++++++- server/NeonSprawl.Server/Program.cs | 1 + server/README.md | 27 +++++ 24 files changed, 617 insertions(+), 117 deletions(-) create mode 100644 bruno/neon-sprawl-server/interaction/Get interactables list.bru create mode 100644 client/scripts/interactable_world_builder.gd create mode 100644 client/scripts/interactables_catalog_client.gd delete mode 100644 client/scripts/prototype_interaction_constants.gd delete mode 100644 client/scripts/prototype_interaction_constants.gd.uid create mode 100644 client/test/interactables_catalog_client_test.gd create mode 100644 client/test/interaction_request_client_test.gd create mode 100644 docs/manual-qa/NEO-25.md create mode 100644 server/NeonSprawl.Server.Tests/Game/Interaction/InteractablesWorldApiTests.cs create mode 100644 server/NeonSprawl.Server/Game/Interaction/InteractablesListDtos.cs create mode 100644 server/NeonSprawl.Server/Game/Interaction/InteractablesWorldApi.cs diff --git a/bruno/neon-sprawl-server/interaction/Get interactables list.bru b/bruno/neon-sprawl-server/interaction/Get interactables list.bru new file mode 100644 index 0000000..6ba0276 --- /dev/null +++ b/bruno/neon-sprawl-server/interaction/Get interactables list.bru @@ -0,0 +1,11 @@ +meta { + name: GET interactables list + type: http + seq: 9 +} + +get { + url: {{baseUrl}}/game/world/interactables + body: none + auth: none +} diff --git a/client/README.md b/client/README.md index ca66072..78d89b5 100644 --- a/client/README.md +++ b/client/README.md @@ -78,18 +78,22 @@ With the game server running ([`server/README.md`](../server/README.md)), **WASD If the server is **down**, boot **`GET`** fails silently (check Output for warnings). **`move-stream`** submissions are skipped while the HTTP client is busy, then resume. -## Interaction + range preview (NEO-9) +## Interaction + range preview (NEO-9 + NEO-25) -The main scene includes a **prototype terminal** at the map center (same world **X/Z** as the server’s static registry) and **two glowing markers** driven by `scripts/interaction_radius_indicators.gd`. That script compares the **`CharacterBody3D`** **`global_position`** (after server snap) to the anchor in **`scripts/prototype_interaction_constants.gd`** using **horizontal distance on X/Z** and the same **`interaction_radius`** as **`PrototypeInteractableRegistry.cs`** — **preview only**; the server **`POST /game/players/{id}/interact`** is authoritative. +On boot, **`InteractablesCatalogClient`** **`GET`s** **`/game/world/interactables`**; **`interactable_world_builder.gd`** spawns **walkable** prototype props plus **two** small glow markers per row under **`World/InteractionMarkers`**. **`interaction_radius_indicators.gd`** compares the snapped **`CharacterBody3D`** **`global_position`** to each descriptor’s **`anchor`** on **X/Z** with the same **`interactionRadius`** and inclusive **`<=`** rule as the server — **preview only**; **`POST /game/players/{id}/interact`** remains authoritative. -- **`InteractionRequestClient`** (child of the main scene root): press **E** to POST interact for the prototype id; **Output** prints `allowed` / `reasonCode` (or HTTP errors). While a request is in flight, further **E** presses are ignored (same pattern as **`PositionAuthorityClient`**). -- **Inspector:** match **`base_url`** / **`dev_player_id`** to **`PositionAuthorityClient`** and the server `Game:DevPlayerId`. +- **`InteractionRequestClient`**: **`interact`** (**E**) → **`prototype_terminal`**; **`interact_secondary`** (**R**) → **`prototype_resource_node_alpha`** (stable ids; must match `PrototypeInteractableRegistry.cs`). **Output** prints `allowed` / `reasonCode`. In-flight requests are ignored (same busy pattern as **`PositionAuthorityClient`**). +- **Inspector:** match **`base_url`** on **`InteractablesCatalogClient`**, **`InteractionRequestClient`**, and **`PositionAuthorityClient`** to the running server. +- **No server:** catalog **GET** fails → **Output** errors; props and glow markers do not spawn (expected prototype degradation). -### Manual check (NEO-9) +### Manual check (NEO-9 + NEO-25) 1. Run the game server and client as in NEO-7. -2. **F5** in Godot: default spawn is out of range of the terminal; markers should stay **dim**. **WASD** toward the center until markers **brighten** (within **3** m on the floor plane). -3. Press **E** (input action **`interact`** in `project.godot`): Output should show **`allowed=true`** when markers glow, **`allowed=false`** with **`reasonCode=out_of_range`** when dim (if you walk back out). Interaction uses **`_input`**, not `_unhandled_input`, so keys register reliably in the embedded **Game** dock; click the game view if the editor had focus elsewhere. +2. **F5** in Godot: after catalog load, two interactable sites appear (center + **+XZ**). Default spawn **(-5, 0.9, -5)** is out of range of the **center** node (3 m); markers near **origin** stay **dim** until you **WASD** within horizontal **3** m of **(0, 0.5, 0)** on X/Z — then that pair **brightens**. +3. Press **E** (**`interact`**): Output **`allowed=true`** when the **terminal** pair glows; **`allowed=false`** / **`out_of_range`** when dim. Walk to **(5, 5)** area until the **second** pair glows; press **R** (**`interact_secondary`**) for **`allowed=true`** on the resource node. +4. Interaction uses **`_input`** (and key fallbacks) so keys register in the embedded **Game** dock; click the game view if the editor had focus elsewhere. + +Full checklist: [`docs/manual-qa/NEO-25.md`](../manual-qa/NEO-25.md). ## Target lock + tab cycle (NEO-24) diff --git a/client/project.godot b/client/project.godot index 29dfcbd..207e57a 100644 --- a/client/project.godot +++ b/client/project.godot @@ -44,6 +44,11 @@ interact={ "events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":69,"physical_keycode":69,"key_label":0,"unicode":101,"location":0,"echo":false,"script":null) ] } +interact_secondary={ +"deadzone": 0.5, +"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":82,"physical_keycode":82,"key_label":0,"unicode":114,"location":0,"echo":false,"script":null) +] +} target_tab={ "deadzone": 0.5, "events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":4194306,"physical_keycode":4194306,"key_label":0,"unicode":0,"location":0,"echo":false,"script":null) diff --git a/client/scenes/main.tscn b/client/scenes/main.tscn index 866dcbd..8564075 100644 --- a/client/scenes/main.tscn +++ b/client/scenes/main.tscn @@ -11,6 +11,7 @@ [ext_resource type="Resource" path="res://resources/isometric_zoom_bands.tres" id="8_zoom_bands"] [ext_resource type="Resource" path="res://resources/isometric_occlusion_policy.tres" id="9_occ_pol"] [ext_resource type="Script" uid="uid://dqm8s4k2h1xwy" path="res://scripts/prototype_smooth_hill_piece.gd" id="10_smooth_hill"] +[ext_resource type="Script" path="res://scripts/interactables_catalog_client.gd" id="13_ix_cat"] [sub_resource type="NavigationMesh" id="NavigationMesh_district"] geometry_collision_mask = 1 @@ -30,15 +31,6 @@ albedo_color = Color(0.5, 0.5, 0.52, 1) [sub_resource type="BoxShape3D" id="BoxShape3D_floor"] size = Vector3(45, 0.2, 45) -[sub_resource type="BoxMesh" id="BoxMesh_terminal"] -size = Vector3(0.9, 1, 0.4) - -[sub_resource type="StandardMaterial3D" id="Mat_terminal"] -albedo_color = Color(0.28, 0.38, 0.45, 1) - -[sub_resource type="BoxShape3D" id="BoxShape3D_terminal"] -size = Vector3(0.9, 1, 0.4) - [sub_resource type="BoxMesh" id="BoxMesh_move_reject_pedestal"] size = Vector3(1.5, 2.5, 1.5) @@ -216,12 +208,6 @@ size = Vector3(2.5, 0.15, 4.5) [sub_resource type="BoxShape3D" id="BoxShape3D_physics_ramp_test_steep"] size = Vector3(2.5, 0.15, 4.5) -[sub_resource type="BoxMesh" id="BoxMesh_marker"] -size = Vector3(0.22, 0.35, 0.22) - -[sub_resource type="StandardMaterial3D" id="Mat_marker_base"] -albedo_color = Color(0.15, 0.4, 1, 1) - [sub_resource type="CapsuleShape3D" id="CapsuleShape3D_player"] radius = 0.4 height = 1.0 @@ -269,16 +255,7 @@ surface_material_override/0 = SubResource("Mat_floor_slab") transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, -0.1, 0) shape = SubResource("BoxShape3D_floor") -[node name="PrototypeTerminal" type="StaticBody3D" parent="World/NavigationRegion3D" unique_id=1700001 groups=["walkable"]] -transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 0.5, 0) -collision_mask = 3 - -[node name="MeshInstance3D" type="MeshInstance3D" parent="World/NavigationRegion3D/PrototypeTerminal" unique_id=1700002] -mesh = SubResource("BoxMesh_terminal") -surface_material_override/0 = SubResource("Mat_terminal") - -[node name="CollisionShape3D" type="CollisionShape3D" parent="World/NavigationRegion3D/PrototypeTerminal" unique_id=1700006] -shape = SubResource("BoxShape3D_terminal") +[node name="InteractablesRoot" type="Node3D" parent="World/NavigationRegion3D" unique_id=1700011] [node name="MoveRejectPedestal" type="StaticBody3D" parent="World/NavigationRegion3D" unique_id=1900021 groups=["walkable"]] transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 7.5, 0, -6.5) @@ -1077,16 +1054,6 @@ script = ExtResource("12_tgt_mk") [node name="InteractionMarkers" type="Node3D" parent="World" unique_id=1700003] script = ExtResource("6_rad") -[node name="MarkerA" type="MeshInstance3D" parent="World/InteractionMarkers" unique_id=1700004] -transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 1.1, 0.25, 0.9) -mesh = SubResource("BoxMesh_marker") -surface_material_override/0 = SubResource("Mat_marker_base") - -[node name="MarkerB" type="MeshInstance3D" parent="World/InteractionMarkers" unique_id=1700005] -transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, -0.95, 0.22, -1.05) -mesh = SubResource("BoxMesh_marker") -surface_material_override/0 = SubResource("Mat_marker_base") - [node name="Player" type="CharacterBody3D" parent="World" unique_id=352931696] transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, -5, 0.9, -5) collision_layer = 2 @@ -1110,6 +1077,9 @@ cast_shadow = 0 mesh = SubResource("CapsuleMesh_player") surface_material_override/0 = SubResource("Mat_player_capsule") +[node name="InteractablesCatalogClient" type="Node" parent="." unique_id=2500005] +script = ExtResource("13_ix_cat") + [node name="PositionAuthorityClient" type="Node" parent="." unique_id=2500002] script = ExtResource("4_auth") diff --git a/client/scripts/interactable_world_builder.gd b/client/scripts/interactable_world_builder.gd new file mode 100644 index 0000000..3ce00b7 --- /dev/null +++ b/client/scripts/interactable_world_builder.gd @@ -0,0 +1,99 @@ +extends Object + +## NEO-25: spawns walkable interactable props + glow marker pairs from server descriptor dicts. +## Returns glow groups for [method InteractionRadiusIndicators.setup_glow_groups]. + +const _PROP_SIZE := Vector3(0.9, 1.0, 0.4) +const _MARKER_MESH_SIZE := Vector3(0.22, 0.35, 0.22) +const _MARKER_OFFSET_A := Vector3(1.1, 0.25, 0.9) +const _MARKER_OFFSET_B := Vector3(-0.95, 0.22, -1.05) + + +static func build_from_catalog( + descriptors: Array, interactables_root: Node3D, markers_root: Node3D +) -> Array: + for c in interactables_root.get_children(): + c.free() + for c in markers_root.get_children(): + c.free() + + var glow_groups: Array = [] + + for row in descriptors: + if not row is Dictionary: + continue + var rd: Dictionary = row + var id: String = str(rd.get("interactableId", "")) + var kind: String = str(rd.get("kind", "")) + var anchor: Vector3 = _anchor_from_row(rd) + var radius: float = float(rd.get("interactionRadius", 0.0)) + + var body := StaticBody3D.new() + body.name = "Interactable_%s" % id + body.collision_mask = 3 + body.add_to_group("walkable") + interactables_root.add_child(body) + body.global_position = anchor + + var prop_mesh := MeshInstance3D.new() + var box_mesh := BoxMesh.new() + box_mesh.size = _PROP_SIZE + prop_mesh.mesh = box_mesh + prop_mesh.position = Vector3.ZERO + var prop_mat := StandardMaterial3D.new() + if kind == "resource_node": + prop_mat.albedo_color = Color(0.18, 0.55, 0.32, 1.0) + else: + prop_mat.albedo_color = Color(0.28, 0.38, 0.45, 1.0) + prop_mesh.material_override = prop_mat + body.add_child(prop_mesh) + + var col := CollisionShape3D.new() + var box_shape := BoxShape3D.new() + box_shape.size = _PROP_SIZE + col.shape = box_shape + body.add_child(col) + + var marker_parent := Node3D.new() + marker_parent.name = "Markers_%s" % id + markers_root.add_child(marker_parent) + marker_parent.global_position = anchor + + var mi_a := _make_marker_mesh() + mi_a.position = _MARKER_OFFSET_A + marker_parent.add_child(mi_a) + var mi_b := _make_marker_mesh() + mi_b.position = _MARKER_OFFSET_B + marker_parent.add_child(mi_b) + + var glow_mat := StandardMaterial3D.new() + glow_mat.albedo_color = Color(0.15, 0.4, 1.0, 1.0) + glow_mat.emission_enabled = true + glow_mat.emission = Color(0.3, 0.6, 1.0) + glow_mat.emission_energy_multiplier = 0.12 + mi_a.material_override = glow_mat + mi_b.material_override = glow_mat + + glow_groups.append( + {"anchor": anchor, "radius": radius, "material": glow_mat, "markers": [mi_a, mi_b]} + ) + + return glow_groups + + +static func _anchor_from_row(rd: Dictionary) -> Vector3: + var a: Variant = rd.get("anchor", null) + if not a is Dictionary: + return Vector3.ZERO + var ad: Dictionary = a + return Vector3( + float(ad.get("x", 0.0)), float(ad.get("y", 0.0)), float(ad.get("z", 0.0))) + ) + + +static func _make_marker_mesh() -> MeshInstance3D: + var mi := MeshInstance3D.new() + var m := BoxMesh.new() + m.size = _MARKER_MESH_SIZE + mi.mesh = m + return mi diff --git a/client/scripts/interactables_catalog_client.gd b/client/scripts/interactables_catalog_client.gd new file mode 100644 index 0000000..1c011b5 --- /dev/null +++ b/client/scripts/interactables_catalog_client.gd @@ -0,0 +1,84 @@ +extends Node + +## NEO-25: fetches `GET /game/world/interactables` and exposes descriptor rows for world build + glow preview. + +signal catalog_ready(descriptors: Array) +signal catalog_failed(reason: String) + +@export var base_url: String = "http://127.0.0.1:5253" + +var _http: HTTPRequest +var _busy: bool = false + + +func _ready() -> void: + _http = HTTPRequest.new() + add_child(_http) + _http.timeout = 30.0 + _http.request_completed.connect(_on_request_completed) + + +func request_catalog() -> void: + if _busy: + return + _busy = true + var url := "%s/game/world/interactables" % _base_root() + var err: Error = _http.request(url) + if err != OK: + _busy = false + var msg := "InteractablesCatalogClient: GET failed to start (%s)" % err + push_error(msg) + catalog_failed.emit(msg) + + +func _base_root() -> String: + return base_url.strip_edges().rstrip("/") + + +func _on_request_completed( + _result: int, response_code: int, _headers: PackedStringArray, body: PackedByteArray +) -> void: + _busy = false + var text := body.get_string_from_utf8() + if response_code != 200: + var msg := "InteractablesCatalogClient: HTTP %s body=%s" % [response_code, text] + push_error(msg) + catalog_failed.emit(msg) + return + var parsed: Variant = JSON.parse_string(text) + if not parsed is Dictionary: + var msg := "InteractablesCatalogClient: expected JSON object" + push_error(msg) + catalog_failed.emit(msg) + return + var root: Dictionary = parsed + var descriptors: Array = _parse_interactables_array(root) + if descriptors.is_empty(): + var msg2 := "InteractablesCatalogClient: interactables missing or empty" + push_error(msg2) + catalog_failed.emit(msg2) + return + catalog_ready.emit(descriptors) + + +## Returns an [Array] of [Dictionary] rows: [code]interactableId[/code], [code]kind[/code], +## [code]anchor[/code] ([Dictionary] x,y,z), [code]interactionRadius[/code] ([float]). +static func parse_catalog_json(text: String) -> Array: + var parsed: Variant = JSON.parse_string(text) + if not parsed is Dictionary: + return [] + return _parse_interactables_array(parsed) + + +static func _parse_interactables_array(root: Variant) -> Array: + if not root is Dictionary: + return [] + var d: Dictionary = root + var raw: Variant = d.get("interactables", null) + if raw == null or not raw is Array: + return [] + var out_arr: Array = [] + for item in raw as Array: + if item is Dictionary: + out_arr.append(item) + return out_arr diff --git a/client/scripts/interaction_radius_indicators.gd b/client/scripts/interaction_radius_indicators.gd index 591043d..823d64c 100644 --- a/client/scripts/interaction_radius_indicators.gd +++ b/client/scripts/interaction_radius_indicators.gd @@ -1,40 +1,43 @@ extends Node3D -## NS-18: **Client preview only** — glow when `CharacterBody3D` is within horizontal radius of the -## prototype terminal (X/Z, inclusive). Server `POST …/interact` remains authoritative. +## NS-18 / NEO-25: **Client preview only** — glow per interactable when [CharacterBody3D] is within +## horizontal radius (X/Z, inclusive). Data comes from [method setup_glow_groups] after +## `GET /game/world/interactables`. Server `POST …/interact` remains authoritative. -const ProtoIx = preload("res://scripts/prototype_interaction_constants.gd") const EMISSION_DIM := 0.12 const EMISSION_BRIGHT := 7.0 -@export var marker_paths: Array[NodePath] = [^"MarkerA", ^"MarkerB"] - var _player: CharacterBody3D -var _mat: StandardMaterial3D +## Each element: [code]anchor[/code] [Vector3], [code]radius[/code] [float], [code]material[/code] +## [StandardMaterial3D], [code]markers[/code] [Array] of [MeshInstance3D] (optional; emission driven via material). +var _glow_groups: Array = [] func setup_player(player: CharacterBody3D) -> void: _player = player -func _ready() -> void: - _mat = StandardMaterial3D.new() - _mat.albedo_color = Color(0.1, 0.32, 0.9) - _mat.emission_enabled = true - _mat.emission = Color(0.3, 0.6, 1.0) - _mat.emission_energy_multiplier = EMISSION_DIM - for p in marker_paths: - var mi := get_node_or_null(p) as MeshInstance3D - if mi: - mi.material_override = _mat +func setup_glow_groups(groups: Array) -> void: + _glow_groups = groups func _process(_delta: float) -> void: - if _player == null or _mat == null: + if _player == null or _glow_groups.is_empty(): return - var dx: float = _player.global_position.x - ProtoIx.anchor_x() - var dz: float = _player.global_position.z - ProtoIx.anchor_z() - var dist_sq: float = dx * dx + dz * dz - var r: float = ProtoIx.interaction_radius() - var in_range: bool = dist_sq <= r * r - _mat.emission_energy_multiplier = EMISSION_BRIGHT if in_range else EMISSION_DIM + var px: float = _player.global_position.x + var pz: float = _player.global_position.z + for g in _glow_groups: + if not g is Dictionary: + continue + var d: Dictionary = g + var anchor: Variant = d.get("anchor", null) + var mat: Variant = d.get("material", null) + if not anchor is Vector3 or not mat is StandardMaterial3D: + continue + var a: Vector3 = anchor + var m: StandardMaterial3D = mat + var r: float = float(d.get("radius", 0.0)) + var dx: float = px - a.x + var dz: float = pz - a.z + var in_range: bool = (dx * dx + dz * dz) <= r * r + m.emission_energy_multiplier = EMISSION_BRIGHT if in_range else EMISSION_DIM diff --git a/client/scripts/interaction_request_client.gd b/client/scripts/interaction_request_client.gd index 667855e..30f973f 100644 --- a/client/scripts/interaction_request_client.gd +++ b/client/scripts/interaction_request_client.gd @@ -1,43 +1,56 @@ extends Node -## NS-18: POST InteractionRequest; prints server allow/deny to Output (prototype). +## NS-18 / NEO-25: POST InteractionRequest; prints server allow/deny to Output (prototype). +## [kbd]E[/kbd] → [code]prototype_terminal[/code]; [kbd]R[/kbd] → [code]prototype_resource_node_alpha[/code] +## (stable contract ids — must match server registry). -const ProtoIx = preload("res://scripts/prototype_interaction_constants.gd") +const ID_TERMINAL := "prototype_terminal" +const ID_RESOURCE_NODE := "prototype_resource_node_alpha" @export var base_url: String = "http://127.0.0.1:5253" @export var dev_player_id: String = "dev-local-1" +## GdUnit: inject a mock transport (same pattern as [code]TargetSelectionClient[/code] test double). +@export var injected_http: Node = null -var _http: HTTPRequest +var _http: Node var _busy: bool = false func _ready() -> void: - _http = HTTPRequest.new() - add_child(_http) + if injected_http != null: + _http = injected_http + else: + _http = HTTPRequest.new() + add_child(_http) + if _http is HTTPRequest: + (_http as HTTPRequest).timeout = 30.0 + @warning_ignore("unsafe_method_access") _http.request_completed.connect(_on_request_completed) func _input(event: InputEvent) -> void: - # Use _input: _unhandled_input often misses keys in the embedded Game dock / focus quirks. - # `interact` is in project.godot; KEY_E fallback for odd layouts. if event.is_action_pressed("interact"): - _post_interact() + _post_interact(ID_TERMINAL) + elif event.is_action_pressed("interact_secondary"): + _post_interact(ID_RESOURCE_NODE) elif event is InputEventKey: var k := event as InputEventKey if k.pressed and not k.echo and (k.physical_keycode == KEY_E or k.keycode == KEY_E): - _post_interact() + _post_interact(ID_TERMINAL) + elif k.pressed and not k.echo and (k.physical_keycode == KEY_R or k.keycode == KEY_R): + _post_interact(ID_RESOURCE_NODE) func _base_root() -> String: return base_url.strip_edges().rstrip("/") -func _post_interact() -> void: +func _post_interact(interactable_id: String) -> void: if _busy: return _busy = true var url := "%s/game/players/%s/interact" % [_base_root(), dev_player_id.strip_edges()] - var payload := {"schemaVersion": 1, "interactableId": ProtoIx.interactable_id()} + var payload := {"schemaVersion": 1, "interactableId": interactable_id} var body := JSON.stringify(payload) var headers := PackedStringArray(["Content-Type: application/json"]) var err: Error = _http.request(url, headers, HTTPClient.METHOD_POST, body) diff --git a/client/scripts/main.gd b/client/scripts/main.gd index 2050318..978982a 100644 --- a/client/scripts/main.gd +++ b/client/scripts/main.gd @@ -70,6 +70,8 @@ var _dev_obstacle_smoke: Node3D @onready var _nav_region: NavigationRegion3D = $World/NavigationRegion3D @onready var _player: CharacterBody3D = $World/Player @onready var _authority: Node = $PositionAuthorityClient +@onready var _catalog: Node = $InteractablesCatalogClient +@onready var _interactables_root: Node3D = $World/NavigationRegion3D/InteractablesRoot @onready var _radius_preview: Node3D = $World/InteractionMarkers @onready var _move_reject_label: Label = $UICanvas/MoveRejectLabel @onready var _player_pos_label: Label = $UICanvas/PlayerPositionLabel @@ -106,10 +108,13 @@ func _ready() -> void: # stopping" false-denial race by making the server's stored snapshot as fresh as possible. if _target_client.has_method("set_freshness_kick"): _target_client.call("set_freshness_kick", _authority, _player) - _authority.call("sync_from_server") - _target_client.call("request_sync_from_server") + _catalog.connect("catalog_ready", Callable(self, "_on_interactables_catalog_ready")) + _catalog.connect("catalog_failed", Callable(self, "_on_interactables_catalog_failed")) if _radius_preview.has_method("setup_player"): _radius_preview.call("setup_player", _player) + _catalog.call("request_catalog") + _authority.call("sync_from_server") + _target_client.call("request_sync_from_server") func _physics_process(_delta: float) -> void: @@ -329,3 +334,15 @@ func _dev_collision_shapes_set_disabled(root: Node, disabled: bool) -> void: (root as CollisionShape3D).disabled = disabled for c in root.get_children(): _dev_collision_shapes_set_disabled(c, disabled) + + +func _on_interactables_catalog_ready(descriptors: Array) -> void: + var groups: Variant = load("res://scripts/interactable_world_builder.gd").call( + "build_from_catalog", descriptors, _interactables_root, _radius_preview + ) + if _radius_preview.has_method("setup_glow_groups"): + _radius_preview.call("setup_glow_groups", groups as Array) + + +func _on_interactables_catalog_failed(_reason: String) -> void: + pass diff --git a/client/scripts/prototype_interaction_constants.gd b/client/scripts/prototype_interaction_constants.gd deleted file mode 100644 index 62bc4e6..0000000 --- a/client/scripts/prototype_interaction_constants.gd +++ /dev/null @@ -1,25 +0,0 @@ -extends Object - -## Prototype interactable anchor + reach — **must match** -## `server/NeonSprawl.Server/Game/Interaction/PrototypeInteractableRegistry.cs`. -## Static accessors only (no `class_name`; see client README / NS-18 plan). - - -static func interactable_id() -> String: - return "prototype_terminal" - - -static func anchor_x() -> float: - return 0.0 - - -static func anchor_y() -> float: - return 0.5 - - -static func anchor_z() -> float: - return 0.0 - - -static func interaction_radius() -> float: - return 3.0 diff --git a/client/scripts/prototype_interaction_constants.gd.uid b/client/scripts/prototype_interaction_constants.gd.uid deleted file mode 100644 index 15069b4..0000000 --- a/client/scripts/prototype_interaction_constants.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://c8diye55s37rr diff --git a/client/test/interactables_catalog_client_test.gd b/client/test/interactables_catalog_client_test.gd new file mode 100644 index 0000000..84872d8 --- /dev/null +++ b/client/test/interactables_catalog_client_test.gd @@ -0,0 +1,31 @@ +extends GdUnitTestSuite + +## NEO-25: parse `GET /game/world/interactables` JSON into descriptor rows. + + +func test_parse_catalog_json_orders_and_fields() -> void: + var json := ( + '{"schemaVersion":1,"interactables":[' + + '{"interactableId":"prototype_resource_node_alpha","kind":"resource_node",' + + '"anchor":{"x":5,"y":0.5,"z":5},"interactionRadius":3},' + + '{"interactableId":"prototype_terminal","kind":"terminal",' + + '"anchor":{"x":0,"y":0.5,"z":0},"interactionRadius":3}' + + "]}" + ) + var rows: Variant = load("res://scripts/interactables_catalog_client.gd").call("parse_catalog_json", json) + assert_that(rows is Array).is_true() + var arr: Array = rows + assert_that(arr.size()).is_equal(2) + var r0: Dictionary = arr[0] + var r1: Dictionary = arr[1] + assert_that(r0.get("interactableId", "")).is_equal("prototype_resource_node_alpha") + assert_that(r0.get("kind", "")).is_equal("resource_node") + var a0: Variant = r0.get("anchor", null) + assert_that(a0 is Dictionary).is_true() + assert_that(float((a0 as Dictionary).get("x", -1.0))).is_equal(5.0) + assert_that(r1.get("interactableId", "")).is_equal("prototype_terminal") + + +func test_parse_catalog_json_returns_empty_on_garbage() -> void: + var rows2: Variant = load("res://scripts/interactables_catalog_client.gd").call("parse_catalog_json", "[]") + assert_that((rows2 as Array).is_empty()).is_true() diff --git a/client/test/interaction_request_client_test.gd b/client/test/interaction_request_client_test.gd new file mode 100644 index 0000000..753ba46 --- /dev/null +++ b/client/test/interaction_request_client_test.gd @@ -0,0 +1,52 @@ +extends GdUnitTestSuite + +## NEO-25: `InteractionRequestClient` POST bodies for E vs R contract ids. + +const IxClient := preload("res://scripts/interaction_request_client.gd") + + +class MockHttpTransport: + extends Node + signal request_completed( + result: int, response_code: int, headers: PackedStringArray, body: PackedByteArray + ) + var last_url: String = "" + var last_body: String = "" + var last_method: HTTPClient.Method = HTTPClient.METHOD_GET + + func request( + url: String, + _custom_headers: PackedStringArray = PackedStringArray(), + method: HTTPClient.Method = HTTPClient.METHOD_GET, + request_data: String = "" + ) -> Error: + last_url = url + last_body = request_data + last_method = method + var body_bytes: PackedByteArray = '{"schemaVersion":1,"allowed":true}'.to_utf8_buffer() + request_completed.emit(HTTPRequest.RESULT_SUCCESS, 200, PackedStringArray(), body_bytes) + return OK + + +func _make_ix(transport: Node) -> Node: + var ix: Node = IxClient.new() + ix.set("injected_http", transport) + auto_free(transport) + auto_free(ix) + add_child(ix) + return ix + + +func test_post_terminal_body_contains_prototype_terminal() -> void: + var transport := MockHttpTransport.new() + var ix := _make_ix(transport) + ix.call("_post_interact", "prototype_terminal") + assert_that(transport.last_body).contains("prototype_terminal") + assert_that(transport.last_url).contains("/interact") + + +func test_post_resource_body_contains_resource_node_id() -> void: + var transport := MockHttpTransport.new() + var ix := _make_ix(transport) + ix.call("_post_interact", "prototype_resource_node_alpha") + assert_that(transport.last_body).contains("prototype_resource_node_alpha") diff --git a/docs/decomposition/modules/E1_M3_InteractionAndTargetingLayer.md b/docs/decomposition/modules/E1_M3_InteractionAndTargetingLayer.md index 85fd57d..f9611a7 100644 --- a/docs/decomposition/modules/E1_M3_InteractionAndTargetingLayer.md +++ b/docs/decomposition/modules/E1_M3_InteractionAndTargetingLayer.md @@ -100,8 +100,8 @@ flowchart LR ## Implementation snapshot -- **`TargetState` (prototype JSON v1 — NEO-23):** server **`GET /game/players/{id}/target`** and **`POST /game/players/{id}/target/select`** with versioned **`PlayerTargetStateResponse`** / **`TargetSelectRequest`** / **`TargetSelectResponse`**; stub registry + in-memory lock under `server/NeonSprawl.Server/Game/Targeting/`; horizontal reach + soft lock semantics — [NEO-23](../../plans/NEO-23-implementation-plan.md), [server README — Targeting](../../../server/README.md#targeting-neo-23). **Still open on later issues:** **`InteractableDescriptor`**, **`SelectionEvent`**, Godot tab-target ([NEO-24](https://linear.app/neon-sprawl/issue/NEO-24)). **Story backlog:** [E1M3 prototype backlog](../../plans/E1M3-prototype-backlog.md). -- **Precursor under E1.M1 (NEO-9):** server **`POST /game/players/{id}/interact`** with **`InteractionRequest`** / **`InteractionResponse`**; **`HorizontalReach`** (`server/NeonSprawl.Server/Game/World/HorizontalReach.cs`); prototype registry and API under `server/NeonSprawl.Server/Game/Interaction/`; client **`interaction_request_client.gd`**, **`interaction_radius_indicators.gd`**, **`prototype_interaction_constants.gd`** — see [NEO-9](../../plans/NEO-9-implementation-plan.md) and [server README — Interaction](../../../server/README.md#interaction-neo-9). +- **`TargetState` (prototype JSON v1 — NEO-23):** server **`GET /game/players/{id}/target`** and **`POST /game/players/{id}/target/select`** with versioned **`PlayerTargetStateResponse`** / **`TargetSelectRequest`** / **`TargetSelectResponse`**; stub registry + in-memory lock under `server/NeonSprawl.Server/Game/Targeting/`; horizontal reach + soft lock semantics — [NEO-23](../../plans/NEO-23-implementation-plan.md), [server README — Targeting](../../../server/README.md#targeting-neo-23). Godot tab-target: [NEO-24](https://linear.app/neon-sprawl/issue/NEO-24). **`InteractableDescriptor` list:** **`GET /game/world/interactables`** (NEO-25). **Still open on later issues:** **`SelectionEvent`**. **Story backlog:** [E1M3 prototype backlog](../../plans/E1M3-prototype-backlog.md). +- **Precursor under E1.M1 (NEO-9 + NEO-25):** server **`POST /game/players/{id}/interact`** with **`InteractionRequest`** / **`InteractionResponse`**; **`GET /game/world/interactables`** (versioned descriptor list); **`HorizontalReach`** (`server/NeonSprawl.Server/Game/World/HorizontalReach.cs`); prototype registry under `server/NeonSprawl.Server/Game/Interaction/`; client **`interactables_catalog_client.gd`**, **`interactable_world_builder.gd`**, **`interaction_request_client.gd`**, **`interaction_radius_indicators.gd`** — see [NEO-9](../../plans/NEO-9-implementation-plan.md), [NEO-25](../../plans/NEO-25-implementation-plan.md), and [server README — Interaction](../../../server/README.md#interaction-neo-9). - **Alignment:** [Documentation and implementation alignment](documentation_and_implementation_alignment.md). ## Risks and telemetry diff --git a/docs/manual-qa/NEO-25.md b/docs/manual-qa/NEO-25.md new file mode 100644 index 0000000..6b7c13a --- /dev/null +++ b/docs/manual-qa/NEO-25.md @@ -0,0 +1,20 @@ +# Manual QA — NEO-25 (InteractableDescriptor list + fetch-driven client) + +**Story:** [NEO-25](https://linear.app/neon-sprawl/issue/NEO-25) · Plan: [`docs/plans/NEO-25-implementation-plan.md`](../plans/NEO-25-implementation-plan.md) + +## Preconditions + +- Game server running (`dotnet run` from `server/NeonSprawl.Server`; note host/port). +- Godot client `base_url` matches server (default `http://127.0.0.1:5253` on catalog + interaction clients). + +## Checks + +1. **Descriptor GET:** `curl -s http://localhost:5253/game/world/interactables` returns JSON with `schemaVersion: 1`, `interactables` length ≥ 2, ascending `interactableId`, each row has `kind`, `anchor` `{x,y,z}`, `interactionRadius`. +2. **F5 with server up:** After load, **two** prototype props appear (center + **+XZ** corner) and **two pairs** of small blue glow markers (offset from each anchor). Default spawn **(-5, 0.9, -5)**: markers near **origin** glow when in 3 m radius; corner node stays dim until you walk toward **(5, 5)**. +3. **E** (`interact`): at origin in range → Output `allowed=true` for terminal; walk out past 3 m horizontal → `allowed=false`, `out_of_range`. +4. **R** (`interact_secondary`): stand within 3 m of **(5, 0.5, 5)** on X/Z → `allowed=true` for resource node; far from that anchor → `out_of_range`. +5. **Server down / wrong port:** catalog request errors in Output; scene still runs (no props / no glow groups — acceptable prototype degradation). + +## Notes + +- Registry source of truth is **C#**; client anchors come only from **`GET /game/world/interactables`**. diff --git a/docs/plans/NEO-25-implementation-plan.md b/docs/plans/NEO-25-implementation-plan.md index 5880ba6..e14c789 100644 --- a/docs/plans/NEO-25-implementation-plan.md +++ b/docs/plans/NEO-25-implementation-plan.md @@ -36,11 +36,11 @@ ## Acceptance criteria checklist -- [ ] **≥2 interactables** in **`PrototypeInteractableRegistry`** with distinct ids, anchors, radii, and **kinds** (`terminal` vs `resource_node`). -- [ ] **`GET /game/world/interactables`** returns versioned JSON whose **`interactables`** entries each include **`interactableId`**, **`anchor`** (world), **`interactionRadius`**, **`kind`**. -- [ ] **`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). -- [ ] **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. -- [ ] **Bruno** + **server README** document the new GET. +- [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 diff --git a/docs/plans/NEO-9-implementation-plan.md b/docs/plans/NEO-9-implementation-plan.md index 026c4cd..60dd21d 100644 --- a/docs/plans/NEO-9-implementation-plan.md +++ b/docs/plans/NEO-9-implementation-plan.md @@ -58,7 +58,7 @@ The game is **3D**, but **prototype interaction and movement tuning** treat all **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. +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 | |---|--------|-----------| diff --git a/server/NeonSprawl.Server.Tests/Game/Interaction/InteractablesWorldApiTests.cs b/server/NeonSprawl.Server.Tests/Game/Interaction/InteractablesWorldApiTests.cs new file mode 100644 index 0000000..0d631cf --- /dev/null +++ b/server/NeonSprawl.Server.Tests/Game/Interaction/InteractablesWorldApiTests.cs @@ -0,0 +1,43 @@ +using System.Linq; +using System.Net; +using System.Net.Http.Json; +using NeonSprawl.Server.Game.Interaction; +using Xunit; + +namespace NeonSprawl.Server.Tests.Game.Interaction; + +public class InteractablesWorldApiTests +{ + [Fact] + public async Task GetInteractables_ShouldReturnOrderedDescriptors_WithSchemaV1() + { + await using var factory = new InMemoryWebApplicationFactory(); + var client = factory.CreateClient(); + + var response = await client.GetAsync("/game/world/interactables"); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + var body = await response.Content.ReadFromJsonAsync(); + Assert.NotNull(body); + Assert.Equal(InteractablesListResponse.CurrentSchemaVersion, body!.SchemaVersion); + Assert.NotNull(body.Interactables); + Assert.True(body.Interactables.Count >= 2); + + var ids = body.Interactables.Select(static x => x.InteractableId).ToList(); + Assert.Equal(ids.OrderBy(static x => x, StringComparer.Ordinal).ToList(), ids); + + var alpha = body.Interactables.Single(x => x.InteractableId == PrototypeInteractableRegistry.PrototypeResourceNodeAlphaId); + Assert.Equal("resource_node", alpha.Kind); + Assert.Equal(5, alpha.Anchor.X); + Assert.Equal(0.5, alpha.Anchor.Y); + Assert.Equal(5, alpha.Anchor.Z); + Assert.Equal(3.0, alpha.InteractionRadius); + + var term = body.Interactables.Single(x => x.InteractableId == PrototypeInteractableRegistry.PrototypeTerminalId); + Assert.Equal("terminal", term.Kind); + Assert.Equal(0, term.Anchor.X); + Assert.Equal(0.5, term.Anchor.Y); + Assert.Equal(0, term.Anchor.Z); + Assert.Equal(3.0, term.InteractionRadius); + } +} diff --git a/server/NeonSprawl.Server.Tests/Game/Interaction/InteractionApiTests.cs b/server/NeonSprawl.Server.Tests/Game/Interaction/InteractionApiTests.cs index e8c8997..b08a1c1 100644 --- a/server/NeonSprawl.Server.Tests/Game/Interaction/InteractionApiTests.cs +++ b/server/NeonSprawl.Server.Tests/Game/Interaction/InteractionApiTests.cs @@ -230,4 +230,51 @@ public class InteractionApiTests var nearBody = await near.Content.ReadFromJsonAsync(); Assert.True(nearBody!.Allowed); } + + [Fact] + public async Task PostInteract_ShouldAllowResourceNode_WhenPlayerInRangeOfResourceAnchor() + { + await using var factory = new InMemoryWebApplicationFactory(); + var client = factory.CreateClient(); + var move = new MoveCommandRequest + { + SchemaVersion = MoveCommandRequest.CurrentSchemaVersion, + Target = new PositionVector { X = 5, Y = 0.9, Z = 5 }, + }; + Assert.Equal(HttpStatusCode.OK, (await client.PostAsJsonAsync("/game/players/dev-local-1/move", move)).StatusCode); + + var response = await client.PostAsJsonAsync( + "/game/players/dev-local-1/interact", + new InteractionRequest + { + SchemaVersion = InteractionRequest.CurrentSchemaVersion, + InteractableId = PrototypeInteractableRegistry.PrototypeResourceNodeAlphaId, + }); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + var body = await response.Content.ReadFromJsonAsync(); + Assert.NotNull(body); + Assert.True(body!.Allowed); + Assert.Equal(PrototypeInteractableRegistry.PrototypeResourceNodeAlphaId, body.InteractableId); + } + + [Fact] + public async Task PostInteract_ShouldDenyOutOfRange_ForResourceNode_WhenPlayerFar() + { + await using var factory = new InMemoryWebApplicationFactory(); + var client = factory.CreateClient(); + var response = await client.PostAsJsonAsync( + "/game/players/dev-local-1/interact", + new InteractionRequest + { + SchemaVersion = InteractionRequest.CurrentSchemaVersion, + InteractableId = PrototypeInteractableRegistry.PrototypeResourceNodeAlphaId, + }); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + var body = await response.Content.ReadFromJsonAsync(); + Assert.NotNull(body); + Assert.False(body!.Allowed); + Assert.Equal(InteractionApi.ReasonOutOfRange, body.ReasonCode); + } } diff --git a/server/NeonSprawl.Server/Game/Interaction/InteractablesListDtos.cs b/server/NeonSprawl.Server/Game/Interaction/InteractablesListDtos.cs new file mode 100644 index 0000000..934d36b --- /dev/null +++ b/server/NeonSprawl.Server/Game/Interaction/InteractablesListDtos.cs @@ -0,0 +1,35 @@ +using System.Text.Json.Serialization; +using NeonSprawl.Server.Game.PositionState; + +namespace NeonSprawl.Server.Game.Interaction; + +/// JSON body for GET /game/world/interactables (NEO-25). +public sealed class InteractablesListResponse +{ + public const int CurrentSchemaVersion = 1; + + [JsonPropertyName("schemaVersion")] + public int SchemaVersion { get; init; } = CurrentSchemaVersion; + + /// Stable ascending interactableId order (ordinal). + [JsonPropertyName("interactables")] + public required IReadOnlyList Interactables { get; init; } +} + +/// One row in the prototype interactable projection. +public sealed class InteractableDescriptorJson +{ + [JsonPropertyName("interactableId")] + public required string InteractableId { get; init; } + + /// Stable machine string for branching (e.g. terminal, resource_node). + [JsonPropertyName("kind")] + public required string Kind { get; init; } + + [JsonPropertyName("anchor")] + public required PositionVector Anchor { get; init; } + + /// Horizontal reach on X/Z; inclusive boundary (same as NEO-9). + [JsonPropertyName("interactionRadius")] + public double InteractionRadius { get; init; } +} diff --git a/server/NeonSprawl.Server/Game/Interaction/InteractablesWorldApi.cs b/server/NeonSprawl.Server/Game/Interaction/InteractablesWorldApi.cs new file mode 100644 index 0000000..2e1fb8b --- /dev/null +++ b/server/NeonSprawl.Server/Game/Interaction/InteractablesWorldApi.cs @@ -0,0 +1,23 @@ +namespace NeonSprawl.Server.Game.Interaction; + +/// Maps GET /game/world/interactables (NEO-25). +public static class InteractablesWorldApi +{ + public static WebApplication MapInteractablesWorldApi(this WebApplication app) + { + app.MapGet( + "/game/world/interactables", + () => + { + var rows = PrototypeInteractableRegistry.GetOrderedDescriptors(); + return Results.Json( + new InteractablesListResponse + { + SchemaVersion = InteractablesListResponse.CurrentSchemaVersion, + Interactables = rows, + }); + }); + + return app; + } +} diff --git a/server/NeonSprawl.Server/Game/Interaction/PrototypeInteractableRegistry.cs b/server/NeonSprawl.Server/Game/Interaction/PrototypeInteractableRegistry.cs index b97e6fc..010b5cf 100644 --- a/server/NeonSprawl.Server/Game/Interaction/PrototypeInteractableRegistry.cs +++ b/server/NeonSprawl.Server/Game/Interaction/PrototypeInteractableRegistry.cs @@ -1,22 +1,63 @@ +using System.Linq; +using NeonSprawl.Server.Game.PositionState; + namespace NeonSprawl.Server.Game.Interaction; -/// Prototype id → world anchor + reach. Keys are lowercase; lookup after trim + case fold (NS-18). +/// Prototype id → world anchor + reach + kind. Keys are lowercase; lookup after trim + case fold (NS-18). public static class PrototypeInteractableRegistry { - /// Canonical lowercase id for the single prototype row. + /// Canonical lowercase id for the center prototype row (NEO-9). public const string PrototypeTerminalId = "prototype_terminal"; - /// Source-of-truth anchor and radius; keep in sync with client/scripts/prototype_interaction_constants.gd. - public static readonly PrototypeInteractableEntry PrototypeTerminal = new(0, 0.5, 0, 3.0); + /// Second stub row for multi-entity projection (NEO-25). + public const string PrototypeResourceNodeAlphaId = "prototype_resource_node_alpha"; + + /// Source-of-truth anchor and radius; client discovers via GET /game/world/interactables (NEO-25). + public static readonly PrototypeInteractableEntry PrototypeTerminal = new(0, 0.5, 0, 3.0, "terminal"); + + /// +XZ corner walk demo; same radius as terminal for predictable reach tuning. + public static readonly PrototypeInteractableEntry PrototypeResourceNodeAlpha = new(5, 0.5, 5, 3.0, "resource_node"); private static readonly Dictionary ById = new(StringComparer.Ordinal) { [PrototypeTerminalId] = PrototypeTerminal, + [PrototypeResourceNodeAlphaId] = PrototypeResourceNodeAlpha, }; public static bool TryGet(string interactableIdLowercase, out PrototypeInteractableEntry entry) => ById.TryGetValue(interactableIdLowercase, out entry); + + /// Ascending interactableId for stable JSON and tests. + public static IReadOnlyList GetOrderedDescriptors() + { + var list = new List(ById.Count); + foreach (var kv in ById.OrderBy(static x => x.Key, StringComparer.Ordinal)) + { + var e = kv.Value; + list.Add( + new InteractableDescriptorJson + { + InteractableId = kv.Key, + Kind = e.Kind, + Anchor = new PositionVector + { + X = e.X, + Y = e.Y, + Z = e.Z, + }, + InteractionRadius = e.InteractionRadius, + }); + } + + return list; + } } /// Horizontal reach on X/Z; inclusive boundary. -public readonly record struct PrototypeInteractableEntry(double X, double Y, double Z, double InteractionRadius); +/// Stable machine category for clients and future rules (NEO-25). +public readonly record struct PrototypeInteractableEntry( + double X, + double Y, + double Z, + double InteractionRadius, + string Kind); diff --git a/server/NeonSprawl.Server/Program.cs b/server/NeonSprawl.Server/Program.cs index ee66d44..fe7e2c7 100644 --- a/server/NeonSprawl.Server/Program.cs +++ b/server/NeonSprawl.Server/Program.cs @@ -20,6 +20,7 @@ app.MapGet("/health", () => Results.Json(new app.MapPositionStateApi(); app.MapInteractionApi(); +app.MapInteractablesWorldApi(); app.MapTargetingApi(); app.Run(); diff --git a/server/README.md b/server/README.md index 48a1eb4..05a505c 100644 --- a/server/README.md +++ b/server/README.md @@ -125,6 +125,33 @@ When **`allowed` is `true`**, **`reasonCode` is omitted** (clients must not requ Contract details and PR blurb: [NEO-9 implementation plan](../../docs/plans/NEO-9-implementation-plan.md). +## Interactable descriptors (NEO-25) + +**`GET /game/world/interactables`** returns a versioned **projection** of the prototype **`PrototypeInteractableRegistry`** (no player id; global list). Use this for client discovery of anchors, radii, and **`kind`** instead of duplicating registry constants in GDScript. + +Example: + +```bash +curl -s http://localhost:5253/game/world/interactables +``` + +**HTTP** + +| Status | Meaning | +|--------|---------| +| **200** | Body is **`InteractablesListResponse`** v1: `schemaVersion` (`1`) + **`interactables`** array. | + +**`interactables[]` fields (v1)** + +| Field | Meaning | +|--------|---------| +| `interactableId` | Lowercase canonical id (matches **`POST …/interact`**). | +| `kind` | Stable machine string (`terminal`, `resource_node`, …). | +| `anchor` | Object with **`x`**, **`y`**, **`z`** (world meters). | +| `interactionRadius` | Horizontal reach on X/Z; inclusive boundary (same rule as NEO-9). | + +Rows are sorted by ascending **`interactableId`**. Plan: [NEO-25 implementation plan](../../docs/plans/NEO-25-implementation-plan.md). + ## Targeting (NEO-23) Authoritative **combat target lock** (prototype): **`GET /game/players/{id}/target`** returns **`PlayerTargetStateResponse`** v1; **`POST /game/players/{id}/target/select`** accepts **`TargetSelectRequest`** v1 and returns **`TargetSelectResponse`** v1. Player id rules match position/interact (trim + ordinal case-insensitive lookup in the in-memory store). **Horizontal lock range** uses **`HorizontalReach`** on **X/Z only** (inclusive radius), same floor-plane policy as NEO-9. From 90bd71d336ebbf898871a4c02bd2cd4817e3607f Mon Sep 17 00:00:00 2001 From: VinPropane Date: Fri, 24 Apr 2026 20:39:29 -0400 Subject: [PATCH 3/9] NEO-25: fix Vector3 anchor parse (extra paren) in interactable_world_builder --- client/scripts/interactable_world_builder.gd | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/client/scripts/interactable_world_builder.gd b/client/scripts/interactable_world_builder.gd index 3ce00b7..c4968bd 100644 --- a/client/scripts/interactable_world_builder.gd +++ b/client/scripts/interactable_world_builder.gd @@ -87,7 +87,9 @@ static func _anchor_from_row(rd: Dictionary) -> Vector3: return Vector3.ZERO var ad: Dictionary = a return Vector3( - float(ad.get("x", 0.0)), float(ad.get("y", 0.0)), float(ad.get("z", 0.0))) + float(ad.get("x", 0.0)), + float(ad.get("y", 0.0)), + float(ad.get("z", 0.0)), ) From 0e72d981775f96794c1282966ce51cb65d90eccf Mon Sep 17 00:00:00 2001 From: VinPropane Date: Fri, 24 Apr 2026 20:42:12 -0400 Subject: [PATCH 4/9] NEO-25: move resource_node anchor clear of Obstacle at (6,1,5) --- client/README.md | 4 ++-- client/scripts/interactable_world_builder.gd.uid | 1 + client/scripts/interactables_catalog_client.gd.uid | 1 + client/test/interactables_catalog_client_test.gd | 4 ++-- client/test/interactables_catalog_client_test.gd.uid | 1 + client/test/interaction_request_client_test.gd.uid | 1 + docs/manual-qa/NEO-25.md | 4 ++-- docs/plans/NEO-25-implementation-plan.md | 4 ++-- .../Game/Interaction/InteractablesWorldApiTests.cs | 4 ++-- .../Game/Interaction/InteractionApiTests.cs | 2 +- .../Game/Interaction/PrototypeInteractableRegistry.cs | 4 ++-- 11 files changed, 17 insertions(+), 13 deletions(-) create mode 100644 client/scripts/interactable_world_builder.gd.uid create mode 100644 client/scripts/interactables_catalog_client.gd.uid create mode 100644 client/test/interactables_catalog_client_test.gd.uid create mode 100644 client/test/interaction_request_client_test.gd.uid diff --git a/client/README.md b/client/README.md index 78d89b5..98680a6 100644 --- a/client/README.md +++ b/client/README.md @@ -89,8 +89,8 @@ On boot, **`InteractablesCatalogClient`** **`GET`s** **`/game/world/interactable ### Manual check (NEO-9 + NEO-25) 1. Run the game server and client as in NEO-7. -2. **F5** in Godot: after catalog load, two interactable sites appear (center + **+XZ**). Default spawn **(-5, 0.9, -5)** is out of range of the **center** node (3 m); markers near **origin** stay **dim** until you **WASD** within horizontal **3** m of **(0, 0.5, 0)** on X/Z — then that pair **brightens**. -3. Press **E** (**`interact`**): Output **`allowed=true`** when the **terminal** pair glows; **`allowed=false`** / **`out_of_range`** when dim. Walk to **(5, 5)** area until the **second** pair glows; press **R** (**`interact_secondary`**) for **`allowed=true`** on the resource node. +2. **F5** in Godot: after catalog load, two interactable sites appear (center + **+X / −Z** floor, anchor **(12, 0.5, −6)** — clear of the gray **Obstacle** near **(6, 1, 5)**). Default spawn **(-5, 0.9, -5)** is out of range of the **center** node (3 m); markers near **origin** stay **dim** until you **WASD** within horizontal **3** m of **(0, 0.5, 0)** on X/Z — then that pair **brightens**. +3. Press **E** (**`interact`**): Output **`allowed=true`** when the **terminal** pair glows; **`allowed=false`** / **`out_of_range`** when dim. Walk toward **(12, −6)** until the **second** pair glows; press **R** (**`interact_secondary`**) for **`allowed=true`** on the resource node. 4. Interaction uses **`_input`** (and key fallbacks) so keys register in the embedded **Game** dock; click the game view if the editor had focus elsewhere. Full checklist: [`docs/manual-qa/NEO-25.md`](../manual-qa/NEO-25.md). diff --git a/client/scripts/interactable_world_builder.gd.uid b/client/scripts/interactable_world_builder.gd.uid new file mode 100644 index 0000000..15cf31d --- /dev/null +++ b/client/scripts/interactable_world_builder.gd.uid @@ -0,0 +1 @@ +uid://jqrna6yahhgg diff --git a/client/scripts/interactables_catalog_client.gd.uid b/client/scripts/interactables_catalog_client.gd.uid new file mode 100644 index 0000000..9d01604 --- /dev/null +++ b/client/scripts/interactables_catalog_client.gd.uid @@ -0,0 +1 @@ +uid://dw7xslquwep7t diff --git a/client/test/interactables_catalog_client_test.gd b/client/test/interactables_catalog_client_test.gd index 84872d8..b8cedc2 100644 --- a/client/test/interactables_catalog_client_test.gd +++ b/client/test/interactables_catalog_client_test.gd @@ -7,7 +7,7 @@ func test_parse_catalog_json_orders_and_fields() -> void: var json := ( '{"schemaVersion":1,"interactables":[' + '{"interactableId":"prototype_resource_node_alpha","kind":"resource_node",' - + '"anchor":{"x":5,"y":0.5,"z":5},"interactionRadius":3},' + + '"anchor":{"x":12,"y":0.5,"z":-6},"interactionRadius":3},' + '{"interactableId":"prototype_terminal","kind":"terminal",' + '"anchor":{"x":0,"y":0.5,"z":0},"interactionRadius":3}' + "]}" @@ -22,7 +22,7 @@ func test_parse_catalog_json_orders_and_fields() -> void: assert_that(r0.get("kind", "")).is_equal("resource_node") var a0: Variant = r0.get("anchor", null) assert_that(a0 is Dictionary).is_true() - assert_that(float((a0 as Dictionary).get("x", -1.0))).is_equal(5.0) + assert_that(float((a0 as Dictionary).get("x", -1.0))).is_equal(12.0) assert_that(r1.get("interactableId", "")).is_equal("prototype_terminal") diff --git a/client/test/interactables_catalog_client_test.gd.uid b/client/test/interactables_catalog_client_test.gd.uid new file mode 100644 index 0000000..8168f06 --- /dev/null +++ b/client/test/interactables_catalog_client_test.gd.uid @@ -0,0 +1 @@ +uid://dmehy73a1mqnh diff --git a/client/test/interaction_request_client_test.gd.uid b/client/test/interaction_request_client_test.gd.uid new file mode 100644 index 0000000..94ee647 --- /dev/null +++ b/client/test/interaction_request_client_test.gd.uid @@ -0,0 +1 @@ +uid://5tpu7v4uineq diff --git a/docs/manual-qa/NEO-25.md b/docs/manual-qa/NEO-25.md index 6b7c13a..3b291c7 100644 --- a/docs/manual-qa/NEO-25.md +++ b/docs/manual-qa/NEO-25.md @@ -10,9 +10,9 @@ ## Checks 1. **Descriptor GET:** `curl -s http://localhost:5253/game/world/interactables` returns JSON with `schemaVersion: 1`, `interactables` length ≥ 2, ascending `interactableId`, each row has `kind`, `anchor` `{x,y,z}`, `interactionRadius`. -2. **F5 with server up:** After load, **two** prototype props appear (center + **+XZ** corner) and **two pairs** of small blue glow markers (offset from each anchor). Default spawn **(-5, 0.9, -5)**: markers near **origin** glow when in 3 m radius; corner node stays dim until you walk toward **(5, 5)**. +2. **F5 with server up:** After load, **two** prototype props appear (center + **+X / −Z** open floor) and **two pairs** of small blue glow markers (offset from each anchor). Default spawn **(-5, 0.9, -5)**: markers near **origin** glow when in 3 m radius; second node stays dim until you walk near **(12, 0.5, −6)** (kept clear of the gray **Obstacle** at ~(6, 1, 5)). 3. **E** (`interact`): at origin in range → Output `allowed=true` for terminal; walk out past 3 m horizontal → `allowed=false`, `out_of_range`. -4. **R** (`interact_secondary`): stand within 3 m of **(5, 0.5, 5)** on X/Z → `allowed=true` for resource node; far from that anchor → `out_of_range`. +4. **R** (`interact_secondary`): stand within 3 m of **(12, 0.5, −6)** on X/Z → `allowed=true` for resource node; far from that anchor → `out_of_range`. 5. **Server down / wrong port:** catalog request errors in Output; scene still runs (no props / no glow groups — acceptable prototype degradation). ## Notes diff --git a/docs/plans/NEO-25-implementation-plan.md b/docs/plans/NEO-25-implementation-plan.md index e14c789..639f847 100644 --- a/docs/plans/NEO-25-implementation-plan.md +++ b/docs/plans/NEO-25-implementation-plan.md @@ -18,7 +18,7 @@ **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). +- **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 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. @@ -65,7 +65,7 @@ | 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). | +| **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. | diff --git a/server/NeonSprawl.Server.Tests/Game/Interaction/InteractablesWorldApiTests.cs b/server/NeonSprawl.Server.Tests/Game/Interaction/InteractablesWorldApiTests.cs index 0d631cf..3193f52 100644 --- a/server/NeonSprawl.Server.Tests/Game/Interaction/InteractablesWorldApiTests.cs +++ b/server/NeonSprawl.Server.Tests/Game/Interaction/InteractablesWorldApiTests.cs @@ -28,9 +28,9 @@ public class InteractablesWorldApiTests var alpha = body.Interactables.Single(x => x.InteractableId == PrototypeInteractableRegistry.PrototypeResourceNodeAlphaId); Assert.Equal("resource_node", alpha.Kind); - Assert.Equal(5, alpha.Anchor.X); + Assert.Equal(12, alpha.Anchor.X); Assert.Equal(0.5, alpha.Anchor.Y); - Assert.Equal(5, alpha.Anchor.Z); + Assert.Equal(-6, alpha.Anchor.Z); Assert.Equal(3.0, alpha.InteractionRadius); var term = body.Interactables.Single(x => x.InteractableId == PrototypeInteractableRegistry.PrototypeTerminalId); diff --git a/server/NeonSprawl.Server.Tests/Game/Interaction/InteractionApiTests.cs b/server/NeonSprawl.Server.Tests/Game/Interaction/InteractionApiTests.cs index b08a1c1..70f4dd8 100644 --- a/server/NeonSprawl.Server.Tests/Game/Interaction/InteractionApiTests.cs +++ b/server/NeonSprawl.Server.Tests/Game/Interaction/InteractionApiTests.cs @@ -239,7 +239,7 @@ public class InteractionApiTests var move = new MoveCommandRequest { SchemaVersion = MoveCommandRequest.CurrentSchemaVersion, - Target = new PositionVector { X = 5, Y = 0.9, Z = 5 }, + Target = new PositionVector { X = 12, Y = 0.9, Z = -6 }, }; Assert.Equal(HttpStatusCode.OK, (await client.PostAsJsonAsync("/game/players/dev-local-1/move", move)).StatusCode); diff --git a/server/NeonSprawl.Server/Game/Interaction/PrototypeInteractableRegistry.cs b/server/NeonSprawl.Server/Game/Interaction/PrototypeInteractableRegistry.cs index 010b5cf..460f26b 100644 --- a/server/NeonSprawl.Server/Game/Interaction/PrototypeInteractableRegistry.cs +++ b/server/NeonSprawl.Server/Game/Interaction/PrototypeInteractableRegistry.cs @@ -15,8 +15,8 @@ public static class PrototypeInteractableRegistry /// Source-of-truth anchor and radius; client discovers via GET /game/world/interactables (NEO-25). public static readonly PrototypeInteractableEntry PrototypeTerminal = new(0, 0.5, 0, 3.0, "terminal"); - /// +XZ corner walk demo; same radius as terminal for predictable reach tuning. - public static readonly PrototypeInteractableEntry PrototypeResourceNodeAlpha = new(5, 0.5, 5, 3.0, "resource_node"); + /// +X / −Z open floor (avoids overlap with scene Obstacle at ~(6,1,5)); same radius as terminal. + public static readonly PrototypeInteractableEntry PrototypeResourceNodeAlpha = new(12, 0.5, -6, 3.0, "resource_node"); private static readonly Dictionary ById = new(StringComparer.Ordinal) { From 06d9104157ed31a5cbebdb4c097386ae459b9cce Mon Sep 17 00:00:00 2001 From: VinPropane Date: Fri, 24 Apr 2026 20:44:43 -0400 Subject: [PATCH 5/9] NEO-25: route E/R interact via main _unhandled_key_input for reliable input --- client/README.md | 2 +- client/scripts/interaction_request_client.gd | 21 ++++++++--------- client/scripts/main.gd | 24 ++++++++++++++++++++ 3 files changed, 34 insertions(+), 13 deletions(-) diff --git a/client/README.md b/client/README.md index 98680a6..3f8325a 100644 --- a/client/README.md +++ b/client/README.md @@ -91,7 +91,7 @@ On boot, **`InteractablesCatalogClient`** **`GET`s** **`/game/world/interactable 1. Run the game server and client as in NEO-7. 2. **F5** in Godot: after catalog load, two interactable sites appear (center + **+X / −Z** floor, anchor **(12, 0.5, −6)** — clear of the gray **Obstacle** near **(6, 1, 5)**). Default spawn **(-5, 0.9, -5)** is out of range of the **center** node (3 m); markers near **origin** stay **dim** until you **WASD** within horizontal **3** m of **(0, 0.5, 0)** on X/Z — then that pair **brightens**. 3. Press **E** (**`interact`**): Output **`allowed=true`** when the **terminal** pair glows; **`allowed=false`** / **`out_of_range`** when dim. Walk toward **(12, −6)** until the **second** pair glows; press **R** (**`interact_secondary`**) for **`allowed=true`** on the resource node. -4. Interaction uses **`_input`** (and key fallbacks) so keys register in the embedded **Game** dock; click the game view if the editor had focus elsewhere. +4. **E** / **R** are handled in **`main.gd`** **`_unhandled_key_input`** and forwarded to **`InteractionRequestClient`** (same pattern as dev keys) so they still register in the embedded **Game** dock; click the game view if the editor had focus elsewhere. Full checklist: [`docs/manual-qa/NEO-25.md`](../manual-qa/NEO-25.md). diff --git a/client/scripts/interaction_request_client.gd b/client/scripts/interaction_request_client.gd index 30f973f..60f9f8d 100644 --- a/client/scripts/interaction_request_client.gd +++ b/client/scripts/interaction_request_client.gd @@ -2,7 +2,9 @@ extends Node ## NS-18 / NEO-25: POST InteractionRequest; prints server allow/deny to Output (prototype). ## [kbd]E[/kbd] → [code]prototype_terminal[/code]; [kbd]R[/kbd] → [code]prototype_resource_node_alpha[/code] -## (stable contract ids — must match server registry). +## (stable contract ids — must match server registry). Key handling is delegated from +## [code]main.gd[/code] [method Node._unhandled_key_input] (see [method post_interact_terminal] / +## [method post_interact_resource]) so the embedded Game dock still receives E/R reliably. const ID_TERMINAL := "prototype_terminal" const ID_RESOURCE_NODE := "prototype_resource_node_alpha" @@ -28,17 +30,12 @@ func _ready() -> void: _http.request_completed.connect(_on_request_completed) -func _input(event: InputEvent) -> void: - if event.is_action_pressed("interact"): - _post_interact(ID_TERMINAL) - elif event.is_action_pressed("interact_secondary"): - _post_interact(ID_RESOURCE_NODE) - elif event is InputEventKey: - var k := event as InputEventKey - if k.pressed and not k.echo and (k.physical_keycode == KEY_E or k.keycode == KEY_E): - _post_interact(ID_TERMINAL) - elif k.pressed and not k.echo and (k.physical_keycode == KEY_R or k.keycode == KEY_R): - _post_interact(ID_RESOURCE_NODE) +func post_interact_terminal() -> void: + _post_interact(ID_TERMINAL) + + +func post_interact_resource() -> void: + _post_interact(ID_RESOURCE_NODE) func _base_root() -> String: diff --git a/client/scripts/main.gd b/client/scripts/main.gd index 978982a..82fb063 100644 --- a/client/scripts/main.gd +++ b/client/scripts/main.gd @@ -77,6 +77,7 @@ var _dev_obstacle_smoke: Node3D @onready var _player_pos_label: Label = $UICanvas/PlayerPositionLabel @onready var _target_lock_label: Label = $UICanvas/TargetLockLabel @onready var _target_client: Node = $TargetSelectionClient +@onready var _interaction_client: Node = $InteractionRequestClient @onready var _floor: StaticBody3D = $World/NavigationRegion3D/Floor @@ -279,11 +280,34 @@ func _on_move_rejected(reason_code: String) -> void: ## **F9** / **F10** are debugger shortcuts in the embedded game; use the Input Map action. ## For a true **freed-node** occluder test, reload the scene or remove the body in the editor. func _unhandled_key_input(event: InputEvent) -> void: + # NEO-25: route interact keys here — `InteractionRequestClient._input` is unreliable in the + # embedded Game dock / focus order; `_unhandled_key_input` on the scene root still fires. + if event.is_action_pressed("interact"): + _forward_interact_post("post_interact_terminal") + return + if event.is_action_pressed("interact_secondary"): + _forward_interact_post("post_interact_resource") + return + if event is InputEventKey: + var k := event as InputEventKey + if k.pressed and not k.echo and (k.physical_keycode == KEY_E or k.keycode == KEY_E): + _forward_interact_post("post_interact_terminal") + return + if k.pressed and not k.echo and (k.physical_keycode == KEY_R or k.keycode == KEY_R): + _forward_interact_post("post_interact_resource") + return if not event.is_action_pressed("dev_toggle_occluder_obstacle"): return call_deferred("_dev_toggle_obstacle_smoke_deferred") +func _forward_interact_post(method: String) -> void: + if not is_instance_valid(_interaction_client): + return + if _interaction_client.has_method(method): + _interaction_client.call(method) + + func _dev_toggle_obstacle_smoke_deferred() -> void: var obstacle := _dev_obstacle_smoke if obstacle == null or not is_instance_valid(obstacle): From ba667f158131148b95ed03c54ffe2967bc202790 Mon Sep 17 00:00:00 2001 From: VinPropane Date: Fri, 24 Apr 2026 20:48:50 -0400 Subject: [PATCH 6/9] NEO-25: queue last-wins interact POST when HTTP client is busy --- client/README.md | 2 +- client/scripts/interaction_request_client.gd | 31 ++++++++++++++++++-- 2 files changed, 29 insertions(+), 4 deletions(-) diff --git a/client/README.md b/client/README.md index 3f8325a..333b82e 100644 --- a/client/README.md +++ b/client/README.md @@ -82,7 +82,7 @@ If the server is **down**, boot **`GET`** fails silently (check Output for warni On boot, **`InteractablesCatalogClient`** **`GET`s** **`/game/world/interactables`**; **`interactable_world_builder.gd`** spawns **walkable** prototype props plus **two** small glow markers per row under **`World/InteractionMarkers`**. **`interaction_radius_indicators.gd`** compares the snapped **`CharacterBody3D`** **`global_position`** to each descriptor’s **`anchor`** on **X/Z** with the same **`interactionRadius`** and inclusive **`<=`** rule as the server — **preview only**; **`POST /game/players/{id}/interact`** remains authoritative. -- **`InteractionRequestClient`**: **`interact`** (**E**) → **`prototype_terminal`**; **`interact_secondary`** (**R**) → **`prototype_resource_node_alpha`** (stable ids; must match `PrototypeInteractableRegistry.cs`). **Output** prints `allowed` / `reasonCode`. In-flight requests are ignored (same busy pattern as **`PositionAuthorityClient`**). +- **`InteractionRequestClient`**: **`interact`** (**E**) → **`prototype_terminal`**; **`interact_secondary`** (**R**) → **`prototype_resource_node_alpha`** (stable ids; must match `PrototypeInteractableRegistry.cs`). **Output** prints `allowed` / `reasonCode` per response (lines prefixed with `Interaction []: …`). Only one HTTP POST runs at a time; if you press **E** then **R** while the first request is still in flight, the **latest** id is remembered and sent **immediately after** the first completes (last-wins coalescing — not a silent drop). - **Inspector:** match **`base_url`** on **`InteractablesCatalogClient`**, **`InteractionRequestClient`**, and **`PositionAuthorityClient`** to the running server. - **No server:** catalog **GET** fails → **Output** errors; props and glow markers do not spawn (expected prototype degradation). diff --git a/client/scripts/interaction_request_client.gd b/client/scripts/interaction_request_client.gd index 60f9f8d..85aebec 100644 --- a/client/scripts/interaction_request_client.gd +++ b/client/scripts/interaction_request_client.gd @@ -16,6 +16,10 @@ const ID_RESOURCE_NODE := "prototype_resource_node_alpha" var _http: Node var _busy: bool = false +## While an HTTP POST is in flight, the latest [code]interactableId[/code] from a new press overwrites +## this slot (last wins); [method _try_flush_pending] runs the next POST after [signal HTTPRequest.request_completed]. +var _pending_interactable_id: String = "" +var _current_interactable_id: String = "" func _ready() -> void: @@ -44,7 +48,13 @@ func _base_root() -> String: func _post_interact(interactable_id: String) -> void: if _busy: + _pending_interactable_id = interactable_id return + _start_post(interactable_id) + + +func _start_post(interactable_id: String) -> void: + _current_interactable_id = interactable_id _busy = true var url := "%s/game/players/%s/interact" % [_base_root(), dev_player_id.strip_edges()] var payload := {"schemaVersion": 1, "interactableId": interactable_id} @@ -54,6 +64,14 @@ func _post_interact(interactable_id: String) -> void: if err != OK: push_warning("InteractionRequestClient: POST failed to start (%s)" % err) _busy = false + _try_flush_pending() + + +func _try_flush_pending() -> void: + var next: String = _pending_interactable_id + _pending_interactable_id = "" + if not next.is_empty() and not _busy: + _start_post(next) func _on_request_completed( @@ -61,6 +79,7 @@ func _on_request_completed( ) -> void: _busy = false var text := body.get_string_from_utf8() + var id_echo: String = _current_interactable_id if response_code == 200: var parsed: Variant = JSON.parse_string(text) if parsed is Dictionary: @@ -68,8 +87,14 @@ func _on_request_completed( var allowed: bool = bool(d.get("allowed", false)) var reason: Variant = d.get("reasonCode", null) if allowed: - print("Interaction: allowed=true (reasonCode omitted on success)") + print( + "Interaction [%s]: allowed=true (reasonCode omitted on success)" % id_echo + ) else: - print("Interaction: allowed=false reasonCode=%s" % str(reason)) + print( + "Interaction [%s]: allowed=false reasonCode=%s" % [id_echo, str(reason)] + ) + _try_flush_pending() return - print("Interaction: HTTP %s body=%s" % [response_code, text]) + print("Interaction [%s]: HTTP %s body=%s" % [id_echo, response_code, text]) + _try_flush_pending() From c17526e611845f998cae2f27e8b15710918f8f16 Mon Sep 17 00:00:00 2001 From: VinPropane Date: Fri, 24 Apr 2026 20:54:18 -0400 Subject: [PATCH 7/9] NEO-25: close code review follow-up (alignment, warning, tests) --- client/scripts/main.gd | 8 ++- .../test/interaction_request_client_test.gd | 54 +++++++++++++++++++ ...umentation_and_implementation_alignment.md | 2 +- .../modules/module_dependency_register.md | 2 +- docs/reviews/2026-04-24-NEO-25.md | 53 ++++++++++++++++++ 5 files changed, 115 insertions(+), 4 deletions(-) create mode 100644 docs/reviews/2026-04-24-NEO-25.md diff --git a/client/scripts/main.gd b/client/scripts/main.gd index 82fb063..922e142 100644 --- a/client/scripts/main.gd +++ b/client/scripts/main.gd @@ -368,5 +368,9 @@ func _on_interactables_catalog_ready(descriptors: Array) -> void: _radius_preview.call("setup_glow_groups", groups as Array) -func _on_interactables_catalog_failed(_reason: String) -> void: - pass +func _on_interactables_catalog_failed(reason: String) -> void: + # `InteractablesCatalogClient` already `push_error`s; this hook exists for wiring clarity. + push_warning( + "Interactables catalog failed (%s) — start the game server first; see client README section \"Interaction + range preview (NEO-9 + NEO-25)\"." + % reason + ) diff --git a/client/test/interaction_request_client_test.gd b/client/test/interaction_request_client_test.gd index 753ba46..57f5128 100644 --- a/client/test/interaction_request_client_test.gd +++ b/client/test/interaction_request_client_test.gd @@ -28,6 +28,34 @@ class MockHttpTransport: return OK +## First POST defers completion one frame so a second press can queue as `_pending_interactable_id`. +class HoldFirstThenAutoTransport: + extends Node + signal request_completed( + result: int, response_code: int, headers: PackedStringArray, body: PackedByteArray + ) + var bodies: Array[String] = [] + var _first: bool = true + + func request( + _url: String, + _custom_headers: PackedStringArray = PackedStringArray(), + _method: HTTPClient.Method = HTTPClient.METHOD_GET, + request_data: String = "" + ) -> Error: + bodies.append(request_data) + var body_bytes: PackedByteArray = '{"schemaVersion":1,"allowed":true}'.to_utf8_buffer() + if _first: + _first = false + call_deferred("_emit", body_bytes) + else: + request_completed.emit(HTTPRequest.RESULT_SUCCESS, 200, PackedStringArray(), body_bytes) + return OK + + func _emit(body_bytes: PackedByteArray) -> void: + request_completed.emit(HTTPRequest.RESULT_SUCCESS, 200, PackedStringArray(), body_bytes) + + func _make_ix(transport: Node) -> Node: var ix: Node = IxClient.new() ix.set("injected_http", transport) @@ -50,3 +78,29 @@ func test_post_resource_body_contains_resource_node_id() -> void: var ix := _make_ix(transport) ix.call("_post_interact", "prototype_resource_node_alpha") assert_that(transport.last_body).contains("prototype_resource_node_alpha") + + +func test_post_interact_terminal_public_entrypoint() -> void: + var transport := MockHttpTransport.new() + var ix := _make_ix(transport) + ix.call("post_interact_terminal") + assert_that(transport.last_body).contains("prototype_terminal") + + +func test_post_interact_resource_public_entrypoint() -> void: + var transport := MockHttpTransport.new() + var ix := _make_ix(transport) + ix.call("post_interact_resource") + assert_that(transport.last_body).contains("prototype_resource_node_alpha") + + +func test_last_wins_second_press_before_first_completes() -> void: + var transport := HoldFirstThenAutoTransport.new() + var ix := _make_ix(transport) + ix.call("_post_interact", "prototype_terminal") + ix.call("_post_interact", "prototype_resource_node_alpha") + await get_tree().process_frame + await get_tree().process_frame + assert_that(transport.bodies.size()).is_equal(2) + assert_that(transport.bodies[0]).contains("prototype_terminal") + assert_that(transport.bodies[1]).contains("prototype_resource_node_alpha") diff --git a/docs/decomposition/modules/documentation_and_implementation_alignment.md b/docs/decomposition/modules/documentation_and_implementation_alignment.md index 05413b1..2470e59 100644 --- a/docs/decomposition/modules/documentation_and_implementation_alignment.md +++ b/docs/decomposition/modules/documentation_and_implementation_alignment.md @@ -48,7 +48,7 @@ Rows appear when work starts; default for unlisted modules is **Planned** / not |--------|-----------------|----------|-------------------| | E1.M1 | Ready | Prototype milestone **Done** ([E1.M1](https://linear.app/neon-sprawl/project/e1m1-inputandmovementruntime-20eb28dd3db4)). Authoritative `PositionState` + **MoveCommand** over HTTP (JSON v1 snap); **in-memory** store by default, **Postgres** when configured ([NEO-8](../../plans/NEO-8-implementation-plan.md)); shared **NpgsqlDataSource** disposed on host shutdown ([NEO-13](../../plans/NEO-13-implementation-plan.md)); Godot sync + path-follow ([NEO-7](../../plans/NEO-7-implementation-plan.md), [NEO-11](../../plans/NEO-11-implementation-plan.md)); **InteractionRequest** + horizontal range ([NEO-9](../../plans/NEO-9-implementation-plan.md)). Follow-on: prediction/reconciliation, Slice 1 telemetry, Protobuf wire per [contracts.md](contracts.md). | [NEO-6](../../plans/NEO-6-implementation-plan.md), [NEO-7](../../plans/NEO-7-implementation-plan.md), [NEO-8](../../plans/NEO-8-implementation-plan.md), [NEO-9](../../plans/NEO-9-implementation-plan.md), [NEO-10](../../plans/NEO-10-implementation-plan.md), [NEO-11](../../plans/NEO-11-implementation-plan.md), [NEO-13](../../plans/NEO-13-implementation-plan.md); `server/NeonSprawl.Server/Game/PositionState/`, `Game/Interaction/`; [server README](../../../server/README.md) | | E1.M2 | Ready | **Slice 2 prototype slice closed:** follow + `CameraState` ([NEO-15](https://linear.app/neon-sprawl/issue/NEO-15)); zoom bands ([NEO-16](https://linear.app/neon-sprawl/issue/NEO-16)); occlusion ([NEO-17](https://linear.app/neon-sprawl/issue/NEO-17)); occluder pick-through ([NEO-20](https://linear.app/neon-sprawl/issue/NEO-20)); contracts + hardening ([NEO-18](https://linear.app/neon-sprawl/issue/NEO-18)). Client-local; no server use of camera pose. | [NEO-15](../../plans/NEO-15-implementation-plan.md), [NEO-16](../../plans/NEO-16-implementation-plan.md), [NEO-17](../../plans/NEO-17-implementation-plan.md), [NEO-18](../../plans/NEO-18-implementation-plan.md), [NEO-20](../../plans/NEO-20-implementation-plan.md); `client/scripts/isometric_follow_camera.gd`, `camera_state.gd`, `zoom_band_config.gd`, `occlusion_policy.gd`, `ground_pick.gd`; [E1_M2_IsometricCameraController.md](E1_M2_IsometricCameraController.md) | -| E1.M3 | In Progress | **NEO-23 landed:** JSON v1 targeting read + select (`GET`/`POST …/target`, `Game/Targeting/`, [NEO-23](../../plans/NEO-23-implementation-plan.md)) — `PlayerTargetStateResponse` / soft lock / `reasonCode` denials; wire name differs from illustrative **`TargetState`** in module doc (called out in plan + README). **NEO-24 landed:** client tab-target + lock HUD synced to server ([NEO-24](../../plans/NEO-24-implementation-plan.md)) — `TargetSelectionClient` ([`client/scripts/target_selection_client.gd`](../../../client/scripts/target_selection_client.gd)), Tab / Esc bindings, `TargetLockLabel` HUD, hybrid movement-triggered refresh via new `PositionAuthorityClient.authoritative_ack` signal (fires on every server-confirmed position, boot + `move-stream` 200) with a 250 ms cooldown; manual QA in [`docs/manual-qa/NEO-24.md`](../../manual-qa/NEO-24.md). **`InteractableDescriptor`** and **`SelectionEvent`** still open. **Precursor (E1.M1):** [NEO-9](../../plans/NEO-9-implementation-plan.md) `POST …/interact`. | Linear [NEO-24](https://linear.app/neon-sprawl/issue/NEO-24)–[NEO-27](https://linear.app/neon-sprawl/issue/NEO-27); [E1_M3_InteractionAndTargetingLayer.md](E1_M3_InteractionAndTargetingLayer.md); [E1M3 prototype backlog](../../plans/E1M3-prototype-backlog.md); [server README — Targeting](../../../server/README.md#targeting-neo-23), [Interaction](../../../server/README.md#interaction-neo-9) | +| E1.M3 | In Progress | **NEO-23 landed:** JSON v1 targeting read + select (`GET`/`POST …/target`, `Game/Targeting/`, [NEO-23](../../plans/NEO-23-implementation-plan.md)) — `PlayerTargetStateResponse` / soft lock / `reasonCode` denials; wire name differs from illustrative **`TargetState`** in module doc (called out in plan + README). **NEO-24 landed:** client tab-target + lock HUD synced to server ([NEO-24](../../plans/NEO-24-implementation-plan.md)) — `TargetSelectionClient` ([`client/scripts/target_selection_client.gd`](../../../client/scripts/target_selection_client.gd)), Tab / Esc bindings, `TargetLockLabel` HUD, hybrid movement-triggered refresh via new `PositionAuthorityClient.authoritative_ack` signal (fires on every server-confirmed position, boot + `move-stream` 200) with a 250 ms cooldown; manual QA in [`docs/manual-qa/NEO-24.md`](../../manual-qa/NEO-24.md). **NEO-25 landed:** versioned **`GET /game/world/interactables`** ([NEO-25](../../plans/NEO-25-implementation-plan.md)) — `InteractablesListResponse` / `InteractablesWorldApi` in `server/NeonSprawl.Server/Game/Interaction/`; Godot `interactables_catalog_client.gd` + `interactable_world_builder.gd` (fetch-driven props + glow); [server README — Interactable descriptors (NEO-25)](../../../server/README.md#interactable-descriptors-neo-25). **Still open:** **`SelectionEvent`** and richer **`InteractableDescriptor`** consumers beyond list projection + existing `POST …/interact` (see [E1M3 prototype backlog](../../plans/E1M3-prototype-backlog.md)). **Precursor (E1.M1):** [NEO-9](../../plans/NEO-9-implementation-plan.md) `POST …/interact`. | Linear [NEO-24](https://linear.app/neon-sprawl/issue/NEO-24)–[NEO-27](https://linear.app/neon-sprawl/issue/NEO-27); [E1_M3_InteractionAndTargetingLayer.md](E1_M3_InteractionAndTargetingLayer.md); [E1M3 prototype backlog](../../plans/E1M3-prototype-backlog.md); [server README — Targeting](../../../server/README.md#targeting-neo-23), [Interactable descriptors (NEO-25)](../../../server/README.md#interactable-descriptors-neo-25), [Interaction](../../../server/README.md#interaction-neo-9) | --- diff --git a/docs/decomposition/modules/module_dependency_register.md b/docs/decomposition/modules/module_dependency_register.md index ec8f712..e28eed2 100644 --- a/docs/decomposition/modules/module_dependency_register.md +++ b/docs/decomposition/modules/module_dependency_register.md @@ -19,7 +19,7 @@ Fleshed-out scope, contracts, and integration notes live in **per-module documen **E1.M2 note:** Prototype slice **Ready**: `client/scripts/isometric_follow_camera.gd` + child `Camera3D` on **`World/IsometricFollowCamera`** in `client/scenes/main.tscn`; per-tick **`CameraState`** (`client/scripts/camera_state.gd`); **`ZoomBandConfig`** ([NEO-16](https://linear.app/neon-sprawl/issue/NEO-16)); **`OcclusionPolicy`** ([NEO-17](https://linear.app/neon-sprawl/issue/NEO-17)); pick-through **`"occluder"`** convention ([NEO-20](https://linear.app/neon-sprawl/issue/NEO-20)); consumer contract + occluder lifecycle hardening ([NEO-18](https://linear.app/neon-sprawl/issue/NEO-18)). See [E1_M2_IsometricCameraController.md](E1_M2_IsometricCameraController.md). -**E1.M3 note:** Decomposition expanded in [E1_M3_InteractionAndTargetingLayer.md](E1_M3_InteractionAndTargetingLayer.md) (E1.M1 vs E1.M3 boundary, contract sketches, Slice 3 alignment). **Partial (NEO-23):** versioned JSON v1 **`PlayerTargetStateResponse`** + **`POST …/target/select`** under `server/NeonSprawl.Server/Game/Targeting/` ([NEO-23](../../plans/NEO-23-implementation-plan.md); [server README — Targeting](../../../server/README.md#targeting-neo-23)) — throwaway HTTP spike per [contracts.md](contracts.md). **`InteractableDescriptor`** / **`SelectionEvent`** and a Godot consumer for targeting remain **in progress** on later Linear issues. **Precursor (E1.M1):** **`InteractionRequest`** + horizontal reach ([NEO-9](../../plans/NEO-9-implementation-plan.md); `Game/Interaction/`, `Game/World/HorizontalReach.cs`). +**E1.M3 note:** Decomposition expanded in [E1_M3_InteractionAndTargetingLayer.md](E1_M3_InteractionAndTargetingLayer.md) (E1.M1 vs E1.M3 boundary, contract sketches, Slice 3 alignment). **Partial (NEO-23):** versioned JSON v1 **`PlayerTargetStateResponse`** + **`POST …/target/select`** under `server/NeonSprawl.Server/Game/Targeting/` ([NEO-23](../../plans/NEO-23-implementation-plan.md); [server README — Targeting](../../../server/README.md#targeting-neo-23)) — throwaway HTTP spike per [contracts.md](contracts.md). **Partial (NEO-25):** versioned **`GET /game/world/interactables`** + `InteractablesListDtos` / `InteractablesWorldApi` in `Game/Interaction/`; Godot `interactables_catalog_client.gd` + `interactable_world_builder.gd` ([NEO-25](../../plans/NEO-25-implementation-plan.md); [server README — Interactable descriptors](../../../server/README.md#interactable-descriptors-neo-25)). **`SelectionEvent`** and richer multi-consumer **`InteractableDescriptor`** work remain **in progress** on later Linear issues. **Precursor (E1.M1):** **`InteractionRequest`** + horizontal reach ([NEO-9](../../plans/NEO-9-implementation-plan.md); `Game/Interaction/`, `Game/World/HorizontalReach.cs`). ### Epic 2 — Skills and Progression Framework diff --git a/docs/reviews/2026-04-24-NEO-25.md b/docs/reviews/2026-04-24-NEO-25.md new file mode 100644 index 0000000..1e524c3 --- /dev/null +++ b/docs/reviews/2026-04-24-NEO-25.md @@ -0,0 +1,53 @@ +# Code review — NEO-25 (InteractableDescriptor list / fetch-driven client) + +**Date:** 2026-04-24 + +**Scope:** Branch `NEO-25-e1m3-interactable-descriptor-list` vs `main` (full story: server `GET /game/world/interactables`, registry + two rows, Godot catalog + world build + dual keys, tests, Bruno, docs). + +**Base:** `main` (inferred from `git diff main`). + +**Follow-up:** Suggestions below addressed in-repo (decomposition alignment, catalog-failure warning, GdUnit public + last-wins tests). + +## Verdict + +**Approve with nits** + +## Summary + +The branch delivers the NEO-25 plan end-to-end: versioned `GET /game/world/interactables` with stable ascending ids, `PrototypeInteractableRegistry` extended with `kind` and a second anchor clear of the scene obstacle, integration tests for GET and per-id `POST …/interact` range behavior, Godot fetch-driven props and multi-row glow preview, `interact` / `interact_secondary` wired to fixed ids with `main.gd` forwarding keys through `_unhandled_key_input` (reasonable fix for embedded dock focus), and last-wins queuing on the interaction client when POST overlaps. Overall risk is **low** for a prototype spike; server authority for interact outcomes is unchanged. All **65** `dotnet test` solutions tests pass in this environment. + +## Documentation checked + +| Path | Assessment | +|------|------------| +| `docs/plans/NEO-25-implementation-plan.md` | **Matches** — acceptance criteria, route shape, anchors, client option A, tests, Bruno, manual QA path align with the diff. | +| `docs/manual-qa/NEO-25.md` | **Matches** — steps match implemented behavior and anchors. | +| `docs/decomposition/modules/E1_M3_InteractionAndTargetingLayer.md` | **Matches** — implementation snapshot updated to cite `GET /game/world/interactables` and client scripts. | +| `docs/decomposition/modules/client_server_authority.md` | **Matches** (spot-check) — client preview + server `POST …/interact` authority preserved. | +| `docs/decomposition/modules/contracts.md` | **N/A** for blocking — JSON v1 spike discipline unchanged; no contradiction. | +| `docs/decomposition/modules/module_dependency_register.md` | **Matches** — E1.M3 note now includes **NEO-25** partial (`GET /game/world/interactables`, DTOs/API, Godot catalog + builder); **SelectionEvent** / richer descriptor consumers called out as still in progress. *(Was **Partially matches** at review time; register updated in follow-up.)* | +| `docs/decomposition/modules/documentation_and_implementation_alignment.md` | **Matches** — E1.M3 snapshot documents **NEO-25** list projection + open **SelectionEvent** / richer consumers. *(Was **Partially matches** at review time; tracking row updated in follow-up.)* | + +## Blocking issues + +*(none)* + +## Suggestions + +1. ~~**Tracking table / register copy** — Update `documentation_and_implementation_alignment.md` (E1.M3 row) and optionally `module_dependency_register.md` (E1.M3 note) to record NEO-25: versioned **`GET /game/world/interactables`**, `InteractablesListDtos` / `InteractablesWorldApi`, and Godot `interactables_catalog_client.gd` + `interactable_world_builder.gd`, clarifying that full **`SelectionEvent`** / richer descriptor consumers remain future work.~~ **Done.** Both files updated (E1.M3 snapshot + E1.M3 note; server README anchor links). +2. ~~**`main.gd` catalog failure handler** — `_on_interactables_catalog_failed` is intentionally empty (errors already come from `InteractablesCatalogClient`). A one-line `push_warning` or comment pointing to README “server first” would make the no-op obvious to future readers.~~ **Done.** `push_warning` with reason + pointer to client README section “Interaction + range preview (NEO-9 + NEO-25)”; comment notes catalog client already `push_error`s. + +## Nits + +- **Nit:** `InteractableWorldBuilder` uses `extends Object` and is invoked via `load(...).call("build_from_catalog", …)` from `main.gd`; a `RefCounted` or `Node`-attached helper would avoid `load` at runtime, but this is consistent enough for prototype scope. +- ~~**Nit:** GdUnit `interaction_request_client_test.gd` calls `_post_interact` directly; optionally add cases for `post_interact_terminal` / `post_interact_resource` public entrypoints and for last-wins `_pending_interactable_id` behavior if regressions become a concern.~~ **Done.** Added `test_post_interact_*_public_entrypoint` and `test_last_wins_second_press_before_first_completes` (`HoldFirstThenAutoTransport`). + +## Verification + +- `dotnet test /path/to/NeonSprawl.sln` (from repo root) — expect all tests green. +- Manual: follow `docs/manual-qa/NEO-25.md` — curl GET, F5 with server, E/R at both anchors, server-down degradation. +- Optional: run GdUnit client suites if your CI does not already. + +--- + +*Review produced per [code review agent](.cursor/rules/code-review-agent.md).* From ac2916bfee30fe0ab085b83307a9ded63abf9757 Mon Sep 17 00:00:00 2001 From: VinPropane Date: Fri, 24 Apr 2026 20:56:04 -0400 Subject: [PATCH 8/9] NEO-25: satisfy gdlint/gdformat for pre-push hook --- client/scripts/interactables_catalog_client.gd | 3 ++- .../scripts/interaction_radius_indicators.gd | 5 +++-- client/scripts/interaction_request_client.gd | 18 ++++++++---------- client/scripts/main.gd | 7 +++++-- .../test/interactables_catalog_client_test.gd | 6 ++++-- 5 files changed, 22 insertions(+), 17 deletions(-) diff --git a/client/scripts/interactables_catalog_client.gd b/client/scripts/interactables_catalog_client.gd index 1c011b5..3a70f0e 100644 --- a/client/scripts/interactables_catalog_client.gd +++ b/client/scripts/interactables_catalog_client.gd @@ -1,6 +1,7 @@ extends Node -## NEO-25: fetches `GET /game/world/interactables` and exposes descriptor rows for world build + glow preview. +## NEO-25: fetches `GET /game/world/interactables`; exposes descriptor rows for world build and +## glow preview. signal catalog_ready(descriptors: Array) signal catalog_failed(reason: String) diff --git a/client/scripts/interaction_radius_indicators.gd b/client/scripts/interaction_radius_indicators.gd index 823d64c..86ebe4d 100644 --- a/client/scripts/interaction_radius_indicators.gd +++ b/client/scripts/interaction_radius_indicators.gd @@ -8,8 +8,9 @@ const EMISSION_DIM := 0.12 const EMISSION_BRIGHT := 7.0 var _player: CharacterBody3D -## Each element: [code]anchor[/code] [Vector3], [code]radius[/code] [float], [code]material[/code] -## [StandardMaterial3D], [code]markers[/code] [Array] of [MeshInstance3D] (optional; emission driven via material). +## Each element: [code]anchor[/code] [Vector3], [code]radius[/code] [float], +## [code]material[/code] [StandardMaterial3D], [code]markers[/code] [Array] of [MeshInstance3D] +## (optional; emission driven via material). var _glow_groups: Array = [] diff --git a/client/scripts/interaction_request_client.gd b/client/scripts/interaction_request_client.gd index 85aebec..a815bd9 100644 --- a/client/scripts/interaction_request_client.gd +++ b/client/scripts/interaction_request_client.gd @@ -1,8 +1,9 @@ extends Node ## NS-18 / NEO-25: POST InteractionRequest; prints server allow/deny to Output (prototype). -## [kbd]E[/kbd] → [code]prototype_terminal[/code]; [kbd]R[/kbd] → [code]prototype_resource_node_alpha[/code] -## (stable contract ids — must match server registry). Key handling is delegated from +## [kbd]E[/kbd] → [code]prototype_terminal[/code]; [kbd]R[/kbd] → +## [code]prototype_resource_node_alpha[/code] (stable contract ids — must match server registry). +## Key handling is delegated from ## [code]main.gd[/code] [method Node._unhandled_key_input] (see [method post_interact_terminal] / ## [method post_interact_resource]) so the embedded Game dock still receives E/R reliably. @@ -16,8 +17,9 @@ const ID_RESOURCE_NODE := "prototype_resource_node_alpha" var _http: Node var _busy: bool = false -## While an HTTP POST is in flight, the latest [code]interactableId[/code] from a new press overwrites -## this slot (last wins); [method _try_flush_pending] runs the next POST after [signal HTTPRequest.request_completed]. +## While an HTTP POST is in flight, the latest [code]interactableId[/code] from a new press +## overwrites this slot (last wins); [method _try_flush_pending] runs the next POST after +## [signal HTTPRequest.request_completed]. var _pending_interactable_id: String = "" var _current_interactable_id: String = "" @@ -87,13 +89,9 @@ func _on_request_completed( var allowed: bool = bool(d.get("allowed", false)) var reason: Variant = d.get("reasonCode", null) if allowed: - print( - "Interaction [%s]: allowed=true (reasonCode omitted on success)" % id_echo - ) + print("Interaction [%s]: allowed=true (reasonCode omitted on success)" % id_echo) else: - print( - "Interaction [%s]: allowed=false reasonCode=%s" % [id_echo, str(reason)] - ) + print("Interaction [%s]: allowed=false reasonCode=%s" % [id_echo, str(reason)]) _try_flush_pending() return print("Interaction [%s]: HTTP %s body=%s" % [id_echo, response_code, text]) diff --git a/client/scripts/main.gd b/client/scripts/main.gd index 922e142..09a3d71 100644 --- a/client/scripts/main.gd +++ b/client/scripts/main.gd @@ -370,7 +370,10 @@ func _on_interactables_catalog_ready(descriptors: Array) -> void: func _on_interactables_catalog_failed(reason: String) -> void: # `InteractablesCatalogClient` already `push_error`s; this hook exists for wiring clarity. + var readme := "Interaction + range preview (NEO-9 + NEO-25)" push_warning( - "Interactables catalog failed (%s) — start the game server first; see client README section \"Interaction + range preview (NEO-9 + NEO-25)\"." - % reason + ( + "Interactables catalog failed (%s) — start the game server first; see client README (%s)." + % [reason, readme] + ) ) diff --git a/client/test/interactables_catalog_client_test.gd b/client/test/interactables_catalog_client_test.gd index b8cedc2..bf2e70d 100644 --- a/client/test/interactables_catalog_client_test.gd +++ b/client/test/interactables_catalog_client_test.gd @@ -2,6 +2,8 @@ extends GdUnitTestSuite ## NEO-25: parse `GET /game/world/interactables` JSON into descriptor rows. +const _CATALOG_SCRIPT := preload("res://scripts/interactables_catalog_client.gd") + func test_parse_catalog_json_orders_and_fields() -> void: var json := ( @@ -12,7 +14,7 @@ func test_parse_catalog_json_orders_and_fields() -> void: + '"anchor":{"x":0,"y":0.5,"z":0},"interactionRadius":3}' + "]}" ) - var rows: Variant = load("res://scripts/interactables_catalog_client.gd").call("parse_catalog_json", json) + var rows: Variant = _CATALOG_SCRIPT.parse_catalog_json(json) assert_that(rows is Array).is_true() var arr: Array = rows assert_that(arr.size()).is_equal(2) @@ -27,5 +29,5 @@ func test_parse_catalog_json_orders_and_fields() -> void: func test_parse_catalog_json_returns_empty_on_garbage() -> void: - var rows2: Variant = load("res://scripts/interactables_catalog_client.gd").call("parse_catalog_json", "[]") + var rows2: Variant = _CATALOG_SCRIPT.parse_catalog_json("[]") assert_that((rows2 as Array).is_empty()).is_true() From 8be7618e22adf5bec92ff2b6219cdb11572964f4 Mon Sep 17 00:00:00 2001 From: VinPropane Date: Fri, 24 Apr 2026 20:59:44 -0400 Subject: [PATCH 9/9] =?UTF-8?q?chore:=20story=20workflow=20=E2=80=94=20gh?= =?UTF-8?q?=20pr=20create=20with=20NEO-*=20title=20when=20user=20opens=20P?= =?UTF-8?q?R?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .cursor/rules/commit-and-review.md | 15 +++++++++++++-- .cursor/rules/git-workflow.md | 4 +++- .cursor/rules/linear-git-naming.md | 1 + .cursor/rules/story-end.md | 4 ++-- .cursor/rules/story-kickoff.md | 2 ++ AGENTS.md | 2 +- 6 files changed, 22 insertions(+), 6 deletions(-) diff --git a/.cursor/rules/commit-and-review.md b/.cursor/rules/commit-and-review.md index 3d148a4..c9e4a2b 100644 --- a/.cursor/rules/commit-and-review.md +++ b/.cursor/rules/commit-and-review.md @@ -1,5 +1,5 @@ --- -description: Commits at agent discretion on story work; never git push; PR text without tool boilerplate +description: Commits at agent discretion; push only when user asks; gh pr create with NEO-* title when opening PRs; PR text without tool boilerplate alwaysApply: true --- @@ -13,7 +13,7 @@ alwaysApply: true ## Never push -- Do **not** run **`git push`**, **`git push --force`**, or any command that updates **remote** refs. The user publishes branches and opens PRs. +- Do **not** run **`git push`**, **`git push --force`**, or any command that updates **remote** refs **unless** the user **explicitly** asks to publish (e.g. “push”, “push the branch”, “open a PR” / “create a PR” when the branch is not yet on `origin`). In those cases you may run a normal **`git push -u origin HEAD`** (or the named branch) so the remote exists—still **never** `--force` to rewrite published history. - Do not configure remotes or credentials to bypass this. ## Pull request and push descriptions @@ -21,6 +21,17 @@ alwaysApply: true - Do **not** add **“Made-with: Cursor”**, **“Generated with Cursor”**, tool co-author lines, or similar AI/IDE boilerplate to **PR descriptions**, **GitHub merge/squash commit bodies** you draft, or other **remote-facing** narrative unless the user explicitly requests it. - Keep PR text to scope, verification, and project-required contract snippets (e.g. from `docs/plans/`). +### Opening a PR when the user asks + +When the user asks to **open**, **create**, or **file** a pull request (or clearly wants one opened for the current branch): + +1. **Use the GitHub CLI** — Run **`gh pr create`** (not only a browser **`/pull/new/...`** link). That way you control the **title** and avoid GitHub’s default, which title-cases the branch name and turns **`NEO-25`** into **`Neo 25`**. +2. **Title** — **`--title`** must **begin** with the **Linear issue key** in canonical form: **`NEO-123:`** (see [linear-git-naming](linear-git-naming.md): uppercase team prefix, hyphen, issue number), then a short human summary. Example: **`NEO-25: InteractableDescriptor list / fetch-driven client`**. +3. **Infer the key** — In order: the **current branch**’s leading segment (e.g. `NEO-25-my-slug` → `NEO-25`); the matching **`docs/plans/{KEY}-implementation-plan.md`** on the branch; Linear MCP / issue in chat; or **ask** the user if ambiguous. +4. **Body** — Pass **`--body`** or **`--body-file`** with scope, how to verify (`dotnet test`, manual QA path, etc.), and pointers to `docs/plans/` when useful. +5. **Upstream** — If **`gh pr create`** fails because the branch is not on `origin`, push first (**only** as allowed under **Never push** above—i.e. the same user message asked to open the PR or explicitly to push). +6. **Fallback** — If **`gh`** is missing or not authenticated (`gh auth status`), tell the user and optionally still give the **`pull/new/...`** URL, with an explicit reminder to **edit the title** so it starts with **`NEO-123:`** (not `Neo 123`). + ## Commit message format when a Linear issue applies - Any commit that is **part of implementing or delivering a Linear issue or task** must put the **Linear issue id first** in the subject line, then **`:`**, then the summary (e.g. `NEO-8: persist position state in PostgreSQL`). diff --git a/.cursor/rules/git-workflow.md b/.cursor/rules/git-workflow.md index 91ce486..d687da0 100644 --- a/.cursor/rules/git-workflow.md +++ b/.cursor/rules/git-workflow.md @@ -5,7 +5,7 @@ alwaysApply: true # Git workflow (Neon Sprawl) -**Agent:** You may **`git commit`** at your discretion while on story/ticket work; do **not** **`git push`**. See [commit-and-review](commit-and-review.md). +**Agent:** You may **`git commit`** at your discretion while on story/ticket work; do **not** **`git push`** except when the user explicitly asks (see [commit-and-review](commit-and-review.md) **Never push** and **Opening a PR when the user asks**). - **Beginning work on a new Linear issue** — Create a **new branch** as soon as story work starts (planning, implementation, or both). **Branch names must start with the Linear issue id** (e.g. `NEO-6-position-state-api`); see [linear-git-naming](linear-git-naming.md). Stay on that branch for everything scoped to that issue, including **`docs/plans/{KEY}-implementation-plan.md`**, until the story is merged. Do not put ticketed story plans only on `main` while implementation lives on a branch. @@ -16,3 +16,5 @@ alwaysApply: true When a change mixes documentation and code, treat it as a **code change** (use a branch). Per-story implementation plans are part of the story branch, not an exception for `main`. After the PR for a story branch is **merged**, clean up locally per [story-end](story-end.md) (`checkout main`, `pull`, `branch -d`). + +When the user asks the agent to **open a PR**, use **`gh pr create`** with a **`NEO-*:`** title prefix per [commit-and-review](commit-and-review.md) **Opening a PR when the user asks**—not only a GitHub **Compare & pull request** link (the web UI mangles issue-key casing in the default title). diff --git a/.cursor/rules/linear-git-naming.md b/.cursor/rules/linear-git-naming.md index fcd88ed..8dca119 100644 --- a/.cursor/rules/linear-git-naming.md +++ b/.cursor/rules/linear-git-naming.md @@ -32,3 +32,4 @@ When suggesting or creating a branch for **NEO-5**, use something like **`NEO-5- ## Agent behavior - When creating a **branch** or **commit** for tracked work, **infer the id** from the current story (e.g. user says NEO-5), the branch already in use, or the Linear issue fetched via MCP (`plugin-linear-linear`, e.g. `get_issue` / `list_issues`). +- When the user asks the agent to **open a PR**, the PR **title** must start with that same **`NEO-*:`** form (use **`gh pr create --title`** per [commit-and-review](commit-and-review.md)); do not ship a default web-only title that lowercases the key to **`Neo`**. diff --git a/.cursor/rules/story-end.md b/.cursor/rules/story-end.md index 5e02d5c..aa91b3e 100644 --- a/.cursor/rules/story-end.md +++ b/.cursor/rules/story-end.md @@ -20,7 +20,7 @@ When the user **ends story work** on a ticketed branch—phrases like “end sto ## 2a. When `main` is push-protected -If the user asks to **push** changes that sit on **`main`** and **`git push origin main` fails** (required PR / status checks), create a branch from current `main` (e.g. `chore/…`), **`git push -u origin`** that branch, give them the PR link, then **`git reset --hard origin/main`** if you had temporarily advanced local `main` so local `main` matches remote until the PR merges. +If the user asks to **push** changes that sit on **`main`** and **`git push origin main` fails** (required PR / status checks), create a branch from current `main` (e.g. `chore/…`), **`git push -u origin`** that branch, then open the PR with **`gh pr create`** and a title keyed per [commit-and-review](commit-and-review.md) **Opening a PR when the user asks** (or give the **`pull/new/...`** URL **and** remind them to fix the title to **`NEO-123:`**). **`git reset --hard origin/main`** if you had temporarily advanced local `main` so local `main` matches remote until the PR merges. ## 3. What not to do @@ -40,4 +40,4 @@ If the user asks to **push** changes that sit on **`main`** and **`git push orig - [story-kickoff](story-kickoff.md) — start of story workflow - [git-workflow](git-workflow.md) — branch vs `main` - [linear-git-naming](linear-git-naming.md) — branch names -- [commit-and-review](commit-and-review.md) — push only when the user requests it +- [commit-and-review](commit-and-review.md) — push and **`gh pr create`** only when the user requests it; PR title **`NEO-*:`** prefix diff --git a/.cursor/rules/story-kickoff.md b/.cursor/rules/story-kickoff.md index d7bc635..789718d 100644 --- a/.cursor/rules/story-kickoff.md +++ b/.cursor/rules/story-kickoff.md @@ -65,3 +65,5 @@ These three lists (**files to add**, **files to modify**, **tests**) must **alwa - Before handoff or merge, **reconcile the plan** with what shipped (acceptance checkboxes, **Decisions**, open questions) per [planning-implementation-docs](planning-implementation-docs.md). When the story is **done** and merged to `main`, follow [story-end](story-end.md) to return to `main`, pull, and remove the local story branch. + +When the user asks to **open or create a PR** for this story, follow [commit-and-review](commit-and-review.md) **Opening a PR when the user asks**: use **`gh pr create`** with a title that **starts with the Linear key** (e.g. **`NEO-25:`**), not GitHub’s default title from the web UI alone. diff --git a/AGENTS.md b/AGENTS.md index b8db36f..2704c47 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -7,6 +7,6 @@ Optional **personas** for Cursor. Enable by **@ mentioning** the rule in chat or | **Code review** | [`.cursor/rules/code-review-agent.md`](.cursor/rules/code-review-agent.md) | PR / diff / pre-merge review; **always writes** `docs/reviews/YYYY-MM-DD-{slug}.md` with **Documentation checked** vs `docs/plans/` and `docs/decomposition/modules/`; **when suggestions are fixed, strike through those bullets + `Done.` in that file** ([planning-implementation-docs](.cursor/rules/planning-implementation-docs.md)); short chat pointer | | **Docs review** | [`.cursor/rules/docs-review-agent.md`](.cursor/rules/docs-review-agent.md) | Coherence / links / dev-guide fitness for `docs/` (especially `docs/game-design/` + decomposition); **writes** `docs/reviews/YYYY-MM-DD-{slug}.md`; **when suggestions are fixed, strike through + `Done.` in that file** ([planning-implementation-docs](.cursor/rules/planning-implementation-docs.md)); use when working in **documents** or @ mention | -Project-wide conventions live under [`.cursor/rules/`](.cursor/rules/) (many are `alwaysApply`). **Planning / implementation decisions** must be written into `docs/plans/` (and related docs)—[`.cursor/rules/planning-implementation-docs.md`](.cursor/rules/planning-implementation-docs.md). **Manual QA checklists** for user-visible ticketed work are generated during implementation at `docs/manual-qa/{KEY}.md` (same rule). **Godot client layout** (thin `main.gd`, split by concern): [`.cursor/rules/godot-client-script-organization.md`](.cursor/rules/godot-client-script-organization.md). **Git:** agents may **commit** at discretion on story/ticket work; **never** `git push` unless the user asks — [`.cursor/rules/commit-and-review.md`](.cursor/rules/commit-and-review.md). **Story lifecycle:** kickoff [`.cursor/rules/story-kickoff.md`](.cursor/rules/story-kickoff.md); after merge / end of story — `checkout main`, `pull`, delete local story branch — [`.cursor/rules/story-end.md`](.cursor/rules/story-end.md). **Linear:** ask which **state** to set (e.g. **In Test** vs **Done**), or wait for the user to say which in the same message, **before** updating the issue; do not guess. **PR / push text:** no “Made-with: Cursor” boilerplate (same file). +Project-wide conventions live under [`.cursor/rules/`](.cursor/rules/) (many are `alwaysApply`). **Planning / implementation decisions** must be written into `docs/plans/` (and related docs)—[`.cursor/rules/planning-implementation-docs.md`](.cursor/rules/planning-implementation-docs.md). **Manual QA checklists** for user-visible ticketed work are generated during implementation at `docs/manual-qa/{KEY}.md` (same rule). **Godot client layout** (thin `main.gd`, split by concern): [`.cursor/rules/godot-client-script-organization.md`](.cursor/rules/godot-client-script-organization.md). **Git:** agents may **commit** at discretion on story/ticket work; **never** `git push` unless the user asks — [`.cursor/rules/commit-and-review.md`](.cursor/rules/commit-and-review.md). **Open PR:** when the user asks, use **`gh pr create`** with a title starting **`NEO-123:`** (same rule file). **Story lifecycle:** kickoff [`.cursor/rules/story-kickoff.md`](.cursor/rules/story-kickoff.md); after merge / end of story — `checkout main`, `pull`, delete local story branch — [`.cursor/rules/story-end.md`](.cursor/rules/story-end.md). **Linear:** ask which **state** to set (e.g. **In Test** vs **Done**), or wait for the user to say which in the same message, **before** updating the issue; do not guess. **PR / push text:** no “Made-with: Cursor” boilerplate (same file). **Commits tied to a Linear issue** must start the subject with the **issue id** and a colon (e.g. `NEO-8: …`). Branch naming and exceptions (`chore:` when there is no ticket) — [`.cursor/rules/linear-git-naming.md`](.cursor/rules/linear-git-naming.md).