Merge pull request #48 from ViPro-Technologies/NEO-25-e1m3-interactable-descriptor-list

NEO-25 e1m3 interactable descriptor list
pull/49/head
VinPropane 2026-04-24 21:00:23 -04:00 committed by GitHub
commit eab048770a
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
37 changed files with 937 additions and 128 deletions

View File

@ -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 GitHubs 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`).

View File

@ -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).

View File

@ -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`**.

View File

@ -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

View File

@ -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 GitHubs default title from the web UI alone.

View File

@ -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).

View File

@ -0,0 +1,11 @@
meta {
name: GET interactables list
type: http
seq: 9
}
get {
url: {{baseUrl}}/game/world/interactables
body: none
auth: none
}

View File

@ -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 servers 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 descriptors **`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` per response (lines prefixed with `Interaction [<id>]: …`). 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).
### 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 + **+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. **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).
## Target lock + tab cycle (NEO-24)

View File

@ -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)

View File

@ -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")

View File

@ -0,0 +1,101 @@
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

View File

@ -0,0 +1 @@
uid://jqrna6yahhgg

View File

@ -0,0 +1,85 @@
extends Node
## 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)
@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

View File

@ -0,0 +1 @@
uid://dw7xslquwep7t

View File

@ -1,40 +1,44 @@
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

View File

@ -1,49 +1,79 @@
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).
## 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 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
## 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:
_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()
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()
func post_interact_terminal() -> void:
_post_interact(ID_TERMINAL)
func post_interact_resource() -> void:
_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:
_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": 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)
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(
@ -51,6 +81,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:
@ -58,8 +89,10 @@ 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()

View File

@ -70,11 +70,14 @@ 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
@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
@ -106,10 +109,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:
@ -274,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):
@ -329,3 +358,22 @@ 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:
# `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 (%s)."
% [reason, readme]
)
)

View File

@ -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

View File

@ -1 +0,0 @@
uid://c8diye55s37rr

View File

@ -0,0 +1,33 @@
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 := (
'{"schemaVersion":1,"interactables":['
+ '{"interactableId":"prototype_resource_node_alpha","kind":"resource_node",'
+ '"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}'
+ "]}"
)
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)
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(12.0)
assert_that(r1.get("interactableId", "")).is_equal("prototype_terminal")
func test_parse_catalog_json_returns_empty_on_garbage() -> void:
var rows2: Variant = _CATALOG_SCRIPT.parse_catalog_json("[]")
assert_that((rows2 as Array).is_empty()).is_true()

View File

@ -0,0 +1 @@
uid://dmehy73a1mqnh

View File

@ -0,0 +1,106 @@
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
## 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)
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")
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")

View File

@ -0,0 +1 @@
uid://5tpu7v4uineq

View File

@ -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

View File

@ -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) |
---

View File

@ -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

View File

@ -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 + **+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 **(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
- Registry source of truth is **C#**; client anchors come only from **`GET /game/world/interactables`**.

View File

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

View File

@ -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 |
|---|--------|-----------|

View File

@ -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).*

View File

@ -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<InteractablesListResponse>();
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(12, alpha.Anchor.X);
Assert.Equal(0.5, alpha.Anchor.Y);
Assert.Equal(-6, 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);
}
}

View File

@ -230,4 +230,51 @@ public class InteractionApiTests
var nearBody = await near.Content.ReadFromJsonAsync<InteractionResponse>();
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 = 12, Y = 0.9, Z = -6 },
};
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<InteractionResponse>();
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<InteractionResponse>();
Assert.NotNull(body);
Assert.False(body!.Allowed);
Assert.Equal(InteractionApi.ReasonOutOfRange, body.ReasonCode);
}
}

View File

@ -0,0 +1,35 @@
using System.Text.Json.Serialization;
using NeonSprawl.Server.Game.PositionState;
namespace NeonSprawl.Server.Game.Interaction;
/// <summary>JSON body for <c>GET /game/world/interactables</c> (NEO-25).</summary>
public sealed class InteractablesListResponse
{
public const int CurrentSchemaVersion = 1;
[JsonPropertyName("schemaVersion")]
public int SchemaVersion { get; init; } = CurrentSchemaVersion;
/// <summary>Stable ascending <c>interactableId</c> order (ordinal).</summary>
[JsonPropertyName("interactables")]
public required IReadOnlyList<InteractableDescriptorJson> Interactables { get; init; }
}
/// <summary>One row in the prototype interactable projection.</summary>
public sealed class InteractableDescriptorJson
{
[JsonPropertyName("interactableId")]
public required string InteractableId { get; init; }
/// <summary>Stable machine string for branching (e.g. <c>terminal</c>, <c>resource_node</c>).</summary>
[JsonPropertyName("kind")]
public required string Kind { get; init; }
[JsonPropertyName("anchor")]
public required PositionVector Anchor { get; init; }
/// <summary>Horizontal reach on X/Z; inclusive boundary (same as NEO-9).</summary>
[JsonPropertyName("interactionRadius")]
public double InteractionRadius { get; init; }
}

View File

@ -0,0 +1,23 @@
namespace NeonSprawl.Server.Game.Interaction;
/// <summary>Maps <c>GET /game/world/interactables</c> (NEO-25).</summary>
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;
}
}

View File

@ -1,22 +1,63 @@
using System.Linq;
using NeonSprawl.Server.Game.PositionState;
namespace NeonSprawl.Server.Game.Interaction;
/// <summary>Prototype id → world anchor + reach. Keys are lowercase; lookup after trim + case fold (NS-18).</summary>
/// <summary>Prototype id → world anchor + reach + kind. Keys are lowercase; lookup after trim + case fold (NS-18).</summary>
public static class PrototypeInteractableRegistry
{
/// <summary>Canonical lowercase id for the single prototype row.</summary>
/// <summary>Canonical lowercase id for the center prototype row (NEO-9).</summary>
public const string PrototypeTerminalId = "prototype_terminal";
/// <summary>Source-of-truth anchor and radius; keep in sync with <c>client/scripts/prototype_interaction_constants.gd</c>.</summary>
public static readonly PrototypeInteractableEntry PrototypeTerminal = new(0, 0.5, 0, 3.0);
/// <summary>Second stub row for multi-entity projection (NEO-25).</summary>
public const string PrototypeResourceNodeAlphaId = "prototype_resource_node_alpha";
/// <summary>Source-of-truth anchor and radius; client discovers via <c>GET /game/world/interactables</c> (NEO-25).</summary>
public static readonly PrototypeInteractableEntry PrototypeTerminal = new(0, 0.5, 0, 3.0, "terminal");
/// <summary>+X / Z open floor (avoids overlap with scene <c>Obstacle</c> at ~(6,1,5)); same radius as terminal.</summary>
public static readonly PrototypeInteractableEntry PrototypeResourceNodeAlpha = new(12, 0.5, -6, 3.0, "resource_node");
private static readonly Dictionary<string, PrototypeInteractableEntry> ById = new(StringComparer.Ordinal)
{
[PrototypeTerminalId] = PrototypeTerminal,
[PrototypeResourceNodeAlphaId] = PrototypeResourceNodeAlpha,
};
public static bool TryGet(string interactableIdLowercase, out PrototypeInteractableEntry entry) =>
ById.TryGetValue(interactableIdLowercase, out entry);
/// <summary>Ascending <c>interactableId</c> for stable JSON and tests.</summary>
public static IReadOnlyList<InteractableDescriptorJson> GetOrderedDescriptors()
{
var list = new List<InteractableDescriptorJson>(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;
}
}
/// <param name="InteractionRadius">Horizontal reach on X/Z; inclusive boundary.</param>
public readonly record struct PrototypeInteractableEntry(double X, double Y, double Z, double InteractionRadius);
/// <param name="Kind">Stable machine category for clients and future rules (NEO-25).</param>
public readonly record struct PrototypeInteractableEntry(
double X,
double Y,
double Z,
double InteractionRadius,
string Kind);

View File

@ -20,6 +20,7 @@ app.MapGet("/health", () => Results.Json(new
app.MapPositionStateApi();
app.MapInteractionApi();
app.MapInteractablesWorldApi();
app.MapTargetingApi();
app.Run();

View File

@ -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.