Merge pull request #48 from ViPro-Technologies/NEO-25-e1m3-interactable-descriptor-list
NEO-25 e1m3 interactable descriptor listpull/49/head
commit
eab048770a
|
|
@ -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`).
|
||||
|
|
|
|||
|
|
@ -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).
|
||||
|
|
|
|||
|
|
@ -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`**.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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).
|
||||
|
|
|
|||
|
|
@ -0,0 +1,11 @@
|
|||
meta {
|
||||
name: GET interactables list
|
||||
type: http
|
||||
seq: 9
|
||||
}
|
||||
|
||||
get {
|
||||
url: {{baseUrl}}/game/world/interactables
|
||||
body: none
|
||||
auth: none
|
||||
}
|
||||
|
|
@ -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` 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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -0,0 +1 @@
|
|||
uid://jqrna6yahhgg
|
||||
|
|
@ -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
|
||||
|
|
@ -0,0 +1 @@
|
|||
uid://dw7xslquwep7t
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
)
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -1 +0,0 @@
|
|||
uid://c8diye55s37rr
|
||||
|
|
@ -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()
|
||||
|
|
@ -0,0 +1 @@
|
|||
uid://dmehy73a1mqnh
|
||||
|
|
@ -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")
|
||||
|
|
@ -0,0 +1 @@
|
|||
uid://5tpu7v4uineq
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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) |
|
||||
|
||||
---
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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`**.
|
||||
|
|
@ -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 today’s `interaction_radius_indicators.gd`, but **N** interactables × 2 markers). Preview still compares player **`global_position`** to each descriptor’s anchor on **X/Z** only with **inclusive** `<=` vs that row’s **`interactionRadius`** — server **`POST …/interact`** remains authoritative.
|
||||
- **Client interaction QA:** Two input actions so each id is explicitly reachable without cycling ambiguity: **`interact`** (default **E**) → **`prototype_terminal`**; **`interact_secondary`** (default **R**) → **`prototype_resource_node_alpha`**. Document in **`client/project.godot`** and **`client/README.md`**.
|
||||
- **Tests:** C# integration tests for the new GET (schema, count ≥ 2, field presence, ordering). Extend or add interaction tests proving **`POST …/interact`** allows each id when the seeded player is moved in range and denies when out of range.
|
||||
- **Bruno:** New request(s) under **`bruno/neon-sprawl-server/`** for the GET (per [testing-expectations](../../.cursor/rules/testing-expectations.md)).
|
||||
- **Manual QA:** During implementation, add **`docs/manual-qa/NEO-25.md`** per [planning-implementation-docs](../../.cursor/rules/planning-implementation-docs.md).
|
||||
|
||||
**Out of scope (per Linear / backlog)**
|
||||
|
||||
- Full data-driven content pipeline ([contracts.md](../decomposition/modules/contracts.md) content kind).
|
||||
- Gather loop / rewards ([E3.M1](../decomposition/modules/E3_M1_ResourceNodeAndGatherLoop.md)).
|
||||
- Per-player or zone-filtered lists (registry is global; route is world-scoped intentionally — future visibility can add query params or a different resource without breaking this v1 list).
|
||||
- **`positionHint`** on interact (NEO-24-style) — not required for NEO-25; interact still reads **`IPositionStateStore`** only.
|
||||
|
||||
## Acceptance criteria checklist
|
||||
|
||||
- [x] **≥2 interactables** in **`PrototypeInteractableRegistry`** with distinct ids, anchors, radii, and **kinds** (`terminal` vs `resource_node`).
|
||||
- [x] **`GET /game/world/interactables`** returns versioned JSON whose **`interactables`** entries each include **`interactableId`**, **`anchor`** (world), **`interactionRadius`**, **`kind`**.
|
||||
- [x] **`POST /game/players/{id}/interact`** with **`InteractionRequest`** v1 succeeds for **each** id when the player is in horizontal range; denies with stable **`reasonCode`** when out of range (NEO-9 semantics preserved).
|
||||
- [x] **Godot:** preview glow + scene props are driven from the **fetched** descriptor list (no `prototype_interaction_constants.gd`). **`interact`** / **`interact_secondary`** each hit the correct id for manual QA.
|
||||
- [x] **Bruno** + **server README** document the new GET.
|
||||
|
||||
## Technical approach
|
||||
|
||||
1. **C# registry model:** Extend **`PrototypeInteractableEntry`** (or equivalent) with **`Kind`** (`string`, stable machine values **`terminal`**, **`resource_node`** for v1). Add **`IReadOnlyList<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.
|
||||
|
|
@ -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 |
|
||||
|---|--------|-----------|
|
||||
|
|
|
|||
|
|
@ -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).*
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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; }
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -20,6 +20,7 @@ app.MapGet("/health", () => Results.Json(new
|
|||
|
||||
app.MapPositionStateApi();
|
||||
app.MapInteractionApi();
|
||||
app.MapInteractablesWorldApi();
|
||||
app.MapTargetingApi();
|
||||
|
||||
app.Run();
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
Loading…
Reference in New Issue