NEO-25: world interactables GET, registry rows, fetch-driven Godot props + glow

pull/48/head
VinPropane 2026-04-24 20:36:06 -04:00
parent 6e40af1a1a
commit ce54fcc44f
24 changed files with 617 additions and 117 deletions

View File

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

View File

@ -78,18 +78,22 @@ With the game server running ([`server/README.md`](../server/README.md)), **WASD
If the server is **down**, boot **`GET`** fails silently (check Output for warnings). **`move-stream`** submissions are skipped while the HTTP client is busy, then resume.
## Interaction + range preview (NEO-9)
## Interaction + range preview (NEO-9 + NEO-25)
The main scene includes a **prototype terminal** at the map center (same world **X/Z** as the servers static registry) and **two glowing markers** driven by `scripts/interaction_radius_indicators.gd`. That script compares the **`CharacterBody3D`** **`global_position`** (after server snap) to the anchor in **`scripts/prototype_interaction_constants.gd`** using **horizontal distance on X/Z** and the same **`interaction_radius`** as **`PrototypeInteractableRegistry.cs`** — **preview only**; the server **`POST /game/players/{id}/interact`** is authoritative.
On boot, **`InteractablesCatalogClient`** **`GET`s** **`/game/world/interactables`**; **`interactable_world_builder.gd`** spawns **walkable** prototype props plus **two** small glow markers per row under **`World/InteractionMarkers`**. **`interaction_radius_indicators.gd`** compares the snapped **`CharacterBody3D`** **`global_position`** to each descriptors **`anchor`** on **X/Z** with the same **`interactionRadius`** and inclusive **`<=`** rule as the server — **preview only**; **`POST /game/players/{id}/interact`** remains authoritative.
- **`InteractionRequestClient`** (child of the main scene root): press **E** to POST interact for the prototype id; **Output** prints `allowed` / `reasonCode` (or HTTP errors). While a request is in flight, further **E** presses are ignored (same pattern as **`PositionAuthorityClient`**).
- **Inspector:** match **`base_url`** / **`dev_player_id`** to **`PositionAuthorityClient`** and the server `Game:DevPlayerId`.
- **`InteractionRequestClient`**: **`interact`** (**E**) → **`prototype_terminal`**; **`interact_secondary`** (**R**) → **`prototype_resource_node_alpha`** (stable ids; must match `PrototypeInteractableRegistry.cs`). **Output** prints `allowed` / `reasonCode`. In-flight requests are ignored (same busy pattern as **`PositionAuthorityClient`**).
- **Inspector:** match **`base_url`** on **`InteractablesCatalogClient`**, **`InteractionRequestClient`**, and **`PositionAuthorityClient`** to the running server.
- **No server:** catalog **GET** fails → **Output** errors; props and glow markers do not spawn (expected prototype degradation).
### Manual check (NEO-9)
### Manual check (NEO-9 + NEO-25)
1. Run the game server and client as in NEO-7.
2. **F5** in Godot: default spawn is out of range of the terminal; markers should stay **dim**. **WASD** toward the center until markers **brighten** (within **3** m on the floor plane).
3. Press **E** (input action **`interact`** in `project.godot`): Output should show **`allowed=true`** when markers glow, **`allowed=false`** with **`reasonCode=out_of_range`** when dim (if you walk back out). Interaction uses **`_input`**, not `_unhandled_input`, so keys register reliably in the embedded **Game** dock; click the game view if the editor had focus elsewhere.
2. **F5** in Godot: after catalog load, two interactable sites appear (center + **+XZ**). Default spawn **(-5, 0.9, -5)** is out of range of the **center** node (3 m); markers near **origin** stay **dim** until you **WASD** within horizontal **3** m of **(0, 0.5, 0)** on X/Z — then that pair **brightens**.
3. Press **E** (**`interact`**): Output **`allowed=true`** when the **terminal** pair glows; **`allowed=false`** / **`out_of_range`** when dim. Walk to **(5, 5)** area until the **second** pair glows; press **R** (**`interact_secondary`**) for **`allowed=true`** on the resource node.
4. Interaction uses **`_input`** (and key fallbacks) so keys register in the embedded **Game** dock; click the game view if the editor had focus elsewhere.
Full checklist: [`docs/manual-qa/NEO-25.md`](../manual-qa/NEO-25.md).
## Target lock + tab cycle (NEO-24)

View File

@ -44,6 +44,11 @@ interact={
"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":69,"physical_keycode":69,"key_label":0,"unicode":101,"location":0,"echo":false,"script":null)
]
}
interact_secondary={
"deadzone": 0.5,
"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":82,"physical_keycode":82,"key_label":0,"unicode":114,"location":0,"echo":false,"script":null)
]
}
target_tab={
"deadzone": 0.5,
"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":4194306,"physical_keycode":4194306,"key_label":0,"unicode":0,"location":0,"echo":false,"script":null)

View File

@ -11,6 +11,7 @@
[ext_resource type="Resource" path="res://resources/isometric_zoom_bands.tres" id="8_zoom_bands"]
[ext_resource type="Resource" path="res://resources/isometric_occlusion_policy.tres" id="9_occ_pol"]
[ext_resource type="Script" uid="uid://dqm8s4k2h1xwy" path="res://scripts/prototype_smooth_hill_piece.gd" id="10_smooth_hill"]
[ext_resource type="Script" path="res://scripts/interactables_catalog_client.gd" id="13_ix_cat"]
[sub_resource type="NavigationMesh" id="NavigationMesh_district"]
geometry_collision_mask = 1
@ -30,15 +31,6 @@ albedo_color = Color(0.5, 0.5, 0.52, 1)
[sub_resource type="BoxShape3D" id="BoxShape3D_floor"]
size = Vector3(45, 0.2, 45)
[sub_resource type="BoxMesh" id="BoxMesh_terminal"]
size = Vector3(0.9, 1, 0.4)
[sub_resource type="StandardMaterial3D" id="Mat_terminal"]
albedo_color = Color(0.28, 0.38, 0.45, 1)
[sub_resource type="BoxShape3D" id="BoxShape3D_terminal"]
size = Vector3(0.9, 1, 0.4)
[sub_resource type="BoxMesh" id="BoxMesh_move_reject_pedestal"]
size = Vector3(1.5, 2.5, 1.5)
@ -216,12 +208,6 @@ size = Vector3(2.5, 0.15, 4.5)
[sub_resource type="BoxShape3D" id="BoxShape3D_physics_ramp_test_steep"]
size = Vector3(2.5, 0.15, 4.5)
[sub_resource type="BoxMesh" id="BoxMesh_marker"]
size = Vector3(0.22, 0.35, 0.22)
[sub_resource type="StandardMaterial3D" id="Mat_marker_base"]
albedo_color = Color(0.15, 0.4, 1, 1)
[sub_resource type="CapsuleShape3D" id="CapsuleShape3D_player"]
radius = 0.4
height = 1.0
@ -269,16 +255,7 @@ surface_material_override/0 = SubResource("Mat_floor_slab")
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, -0.1, 0)
shape = SubResource("BoxShape3D_floor")
[node name="PrototypeTerminal" type="StaticBody3D" parent="World/NavigationRegion3D" unique_id=1700001 groups=["walkable"]]
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 0.5, 0)
collision_mask = 3
[node name="MeshInstance3D" type="MeshInstance3D" parent="World/NavigationRegion3D/PrototypeTerminal" unique_id=1700002]
mesh = SubResource("BoxMesh_terminal")
surface_material_override/0 = SubResource("Mat_terminal")
[node name="CollisionShape3D" type="CollisionShape3D" parent="World/NavigationRegion3D/PrototypeTerminal" unique_id=1700006]
shape = SubResource("BoxShape3D_terminal")
[node name="InteractablesRoot" type="Node3D" parent="World/NavigationRegion3D" unique_id=1700011]
[node name="MoveRejectPedestal" type="StaticBody3D" parent="World/NavigationRegion3D" unique_id=1900021 groups=["walkable"]]
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 7.5, 0, -6.5)
@ -1077,16 +1054,6 @@ script = ExtResource("12_tgt_mk")
[node name="InteractionMarkers" type="Node3D" parent="World" unique_id=1700003]
script = ExtResource("6_rad")
[node name="MarkerA" type="MeshInstance3D" parent="World/InteractionMarkers" unique_id=1700004]
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 1.1, 0.25, 0.9)
mesh = SubResource("BoxMesh_marker")
surface_material_override/0 = SubResource("Mat_marker_base")
[node name="MarkerB" type="MeshInstance3D" parent="World/InteractionMarkers" unique_id=1700005]
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, -0.95, 0.22, -1.05)
mesh = SubResource("BoxMesh_marker")
surface_material_override/0 = SubResource("Mat_marker_base")
[node name="Player" type="CharacterBody3D" parent="World" unique_id=352931696]
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, -5, 0.9, -5)
collision_layer = 2
@ -1110,6 +1077,9 @@ cast_shadow = 0
mesh = SubResource("CapsuleMesh_player")
surface_material_override/0 = SubResource("Mat_player_capsule")
[node name="InteractablesCatalogClient" type="Node" parent="." unique_id=2500005]
script = ExtResource("13_ix_cat")
[node name="PositionAuthorityClient" type="Node" parent="." unique_id=2500002]
script = ExtResource("4_auth")

View File

@ -0,0 +1,99 @@
extends Object
## NEO-25: spawns walkable interactable props + glow marker pairs from server descriptor dicts.
## Returns glow groups for [method InteractionRadiusIndicators.setup_glow_groups].
const _PROP_SIZE := Vector3(0.9, 1.0, 0.4)
const _MARKER_MESH_SIZE := Vector3(0.22, 0.35, 0.22)
const _MARKER_OFFSET_A := Vector3(1.1, 0.25, 0.9)
const _MARKER_OFFSET_B := Vector3(-0.95, 0.22, -1.05)
static func build_from_catalog(
descriptors: Array, interactables_root: Node3D, markers_root: Node3D
) -> Array:
for c in interactables_root.get_children():
c.free()
for c in markers_root.get_children():
c.free()
var glow_groups: Array = []
for row in descriptors:
if not row is Dictionary:
continue
var rd: Dictionary = row
var id: String = str(rd.get("interactableId", ""))
var kind: String = str(rd.get("kind", ""))
var anchor: Vector3 = _anchor_from_row(rd)
var radius: float = float(rd.get("interactionRadius", 0.0))
var body := StaticBody3D.new()
body.name = "Interactable_%s" % id
body.collision_mask = 3
body.add_to_group("walkable")
interactables_root.add_child(body)
body.global_position = anchor
var prop_mesh := MeshInstance3D.new()
var box_mesh := BoxMesh.new()
box_mesh.size = _PROP_SIZE
prop_mesh.mesh = box_mesh
prop_mesh.position = Vector3.ZERO
var prop_mat := StandardMaterial3D.new()
if kind == "resource_node":
prop_mat.albedo_color = Color(0.18, 0.55, 0.32, 1.0)
else:
prop_mat.albedo_color = Color(0.28, 0.38, 0.45, 1.0)
prop_mesh.material_override = prop_mat
body.add_child(prop_mesh)
var col := CollisionShape3D.new()
var box_shape := BoxShape3D.new()
box_shape.size = _PROP_SIZE
col.shape = box_shape
body.add_child(col)
var marker_parent := Node3D.new()
marker_parent.name = "Markers_%s" % id
markers_root.add_child(marker_parent)
marker_parent.global_position = anchor
var mi_a := _make_marker_mesh()
mi_a.position = _MARKER_OFFSET_A
marker_parent.add_child(mi_a)
var mi_b := _make_marker_mesh()
mi_b.position = _MARKER_OFFSET_B
marker_parent.add_child(mi_b)
var glow_mat := StandardMaterial3D.new()
glow_mat.albedo_color = Color(0.15, 0.4, 1.0, 1.0)
glow_mat.emission_enabled = true
glow_mat.emission = Color(0.3, 0.6, 1.0)
glow_mat.emission_energy_multiplier = 0.12
mi_a.material_override = glow_mat
mi_b.material_override = glow_mat
glow_groups.append(
{"anchor": anchor, "radius": radius, "material": glow_mat, "markers": [mi_a, mi_b]}
)
return glow_groups
static func _anchor_from_row(rd: Dictionary) -> Vector3:
var a: Variant = rd.get("anchor", null)
if not a is Dictionary:
return Vector3.ZERO
var ad: Dictionary = a
return Vector3(
float(ad.get("x", 0.0)), float(ad.get("y", 0.0)), float(ad.get("z", 0.0)))
)
static func _make_marker_mesh() -> MeshInstance3D:
var mi := MeshInstance3D.new()
var m := BoxMesh.new()
m.size = _MARKER_MESH_SIZE
mi.mesh = m
return mi

View File

@ -0,0 +1,84 @@
extends Node
## NEO-25: fetches `GET /game/world/interactables` and exposes descriptor rows for world build + glow preview.
signal catalog_ready(descriptors: Array)
signal catalog_failed(reason: String)
@export var base_url: String = "http://127.0.0.1:5253"
var _http: HTTPRequest
var _busy: bool = false
func _ready() -> void:
_http = HTTPRequest.new()
add_child(_http)
_http.timeout = 30.0
_http.request_completed.connect(_on_request_completed)
func request_catalog() -> void:
if _busy:
return
_busy = true
var url := "%s/game/world/interactables" % _base_root()
var err: Error = _http.request(url)
if err != OK:
_busy = false
var msg := "InteractablesCatalogClient: GET failed to start (%s)" % err
push_error(msg)
catalog_failed.emit(msg)
func _base_root() -> String:
return base_url.strip_edges().rstrip("/")
func _on_request_completed(
_result: int, response_code: int, _headers: PackedStringArray, body: PackedByteArray
) -> void:
_busy = false
var text := body.get_string_from_utf8()
if response_code != 200:
var msg := "InteractablesCatalogClient: HTTP %s body=%s" % [response_code, text]
push_error(msg)
catalog_failed.emit(msg)
return
var parsed: Variant = JSON.parse_string(text)
if not parsed is Dictionary:
var msg := "InteractablesCatalogClient: expected JSON object"
push_error(msg)
catalog_failed.emit(msg)
return
var root: Dictionary = parsed
var descriptors: Array = _parse_interactables_array(root)
if descriptors.is_empty():
var msg2 := "InteractablesCatalogClient: interactables missing or empty"
push_error(msg2)
catalog_failed.emit(msg2)
return
catalog_ready.emit(descriptors)
## Returns an [Array] of [Dictionary] rows: [code]interactableId[/code], [code]kind[/code],
## [code]anchor[/code] ([Dictionary] x,y,z), [code]interactionRadius[/code] ([float]).
static func parse_catalog_json(text: String) -> Array:
var parsed: Variant = JSON.parse_string(text)
if not parsed is Dictionary:
return []
return _parse_interactables_array(parsed)
static func _parse_interactables_array(root: Variant) -> Array:
if not root is Dictionary:
return []
var d: Dictionary = root
var raw: Variant = d.get("interactables", null)
if raw == null or not raw is Array:
return []
var out_arr: Array = []
for item in raw as Array:
if item is Dictionary:
out_arr.append(item)
return out_arr

View File

@ -1,40 +1,43 @@
extends Node3D
## NS-18: **Client preview only** — glow when `CharacterBody3D` is within horizontal radius of the
## prototype terminal (X/Z, inclusive). Server `POST …/interact` remains authoritative.
## NS-18 / NEO-25: **Client preview only** — glow per interactable when [CharacterBody3D] is within
## horizontal radius (X/Z, inclusive). Data comes from [method setup_glow_groups] after
## `GET /game/world/interactables`. Server `POST …/interact` remains authoritative.
const ProtoIx = preload("res://scripts/prototype_interaction_constants.gd")
const EMISSION_DIM := 0.12
const EMISSION_BRIGHT := 7.0
@export var marker_paths: Array[NodePath] = [^"MarkerA", ^"MarkerB"]
var _player: CharacterBody3D
var _mat: StandardMaterial3D
## Each element: [code]anchor[/code] [Vector3], [code]radius[/code] [float], [code]material[/code]
## [StandardMaterial3D], [code]markers[/code] [Array] of [MeshInstance3D] (optional; emission driven via material).
var _glow_groups: Array = []
func setup_player(player: CharacterBody3D) -> void:
_player = player
func _ready() -> void:
_mat = StandardMaterial3D.new()
_mat.albedo_color = Color(0.1, 0.32, 0.9)
_mat.emission_enabled = true
_mat.emission = Color(0.3, 0.6, 1.0)
_mat.emission_energy_multiplier = EMISSION_DIM
for p in marker_paths:
var mi := get_node_or_null(p) as MeshInstance3D
if mi:
mi.material_override = _mat
func setup_glow_groups(groups: Array) -> void:
_glow_groups = groups
func _process(_delta: float) -> void:
if _player == null or _mat == null:
if _player == null or _glow_groups.is_empty():
return
var dx: float = _player.global_position.x - ProtoIx.anchor_x()
var dz: float = _player.global_position.z - ProtoIx.anchor_z()
var dist_sq: float = dx * dx + dz * dz
var r: float = ProtoIx.interaction_radius()
var in_range: bool = dist_sq <= r * r
_mat.emission_energy_multiplier = EMISSION_BRIGHT if in_range else EMISSION_DIM
var px: float = _player.global_position.x
var pz: float = _player.global_position.z
for g in _glow_groups:
if not g is Dictionary:
continue
var d: Dictionary = g
var anchor: Variant = d.get("anchor", null)
var mat: Variant = d.get("material", null)
if not anchor is Vector3 or not mat is StandardMaterial3D:
continue
var a: Vector3 = anchor
var m: StandardMaterial3D = mat
var r: float = float(d.get("radius", 0.0))
var dx: float = px - a.x
var dz: float = pz - a.z
var in_range: bool = (dx * dx + dz * dz) <= r * r
m.emission_energy_multiplier = EMISSION_BRIGHT if in_range else EMISSION_DIM

View File

@ -1,43 +1,56 @@
extends Node
## NS-18: POST InteractionRequest; prints server allow/deny to Output (prototype).
## NS-18 / NEO-25: POST InteractionRequest; prints server allow/deny to Output (prototype).
## [kbd]E[/kbd] → [code]prototype_terminal[/code]; [kbd]R[/kbd] → [code]prototype_resource_node_alpha[/code]
## (stable contract ids — must match server registry).
const ProtoIx = preload("res://scripts/prototype_interaction_constants.gd")
const ID_TERMINAL := "prototype_terminal"
const ID_RESOURCE_NODE := "prototype_resource_node_alpha"
@export var base_url: String = "http://127.0.0.1:5253"
@export var dev_player_id: String = "dev-local-1"
## GdUnit: inject a mock transport (same pattern as [code]TargetSelectionClient[/code] test double).
@export var injected_http: Node = null
var _http: HTTPRequest
var _http: Node
var _busy: bool = false
func _ready() -> void:
_http = HTTPRequest.new()
add_child(_http)
if injected_http != null:
_http = injected_http
else:
_http = HTTPRequest.new()
add_child(_http)
if _http is HTTPRequest:
(_http as HTTPRequest).timeout = 30.0
@warning_ignore("unsafe_method_access")
_http.request_completed.connect(_on_request_completed)
func _input(event: InputEvent) -> void:
# Use _input: _unhandled_input often misses keys in the embedded Game dock / focus quirks.
# `interact` is in project.godot; KEY_E fallback for odd layouts.
if event.is_action_pressed("interact"):
_post_interact()
_post_interact(ID_TERMINAL)
elif event.is_action_pressed("interact_secondary"):
_post_interact(ID_RESOURCE_NODE)
elif event is InputEventKey:
var k := event as InputEventKey
if k.pressed and not k.echo and (k.physical_keycode == KEY_E or k.keycode == KEY_E):
_post_interact()
_post_interact(ID_TERMINAL)
elif k.pressed and not k.echo and (k.physical_keycode == KEY_R or k.keycode == KEY_R):
_post_interact(ID_RESOURCE_NODE)
func _base_root() -> String:
return base_url.strip_edges().rstrip("/")
func _post_interact() -> void:
func _post_interact(interactable_id: String) -> void:
if _busy:
return
_busy = true
var url := "%s/game/players/%s/interact" % [_base_root(), dev_player_id.strip_edges()]
var payload := {"schemaVersion": 1, "interactableId": ProtoIx.interactable_id()}
var payload := {"schemaVersion": 1, "interactableId": interactable_id}
var body := JSON.stringify(payload)
var headers := PackedStringArray(["Content-Type: application/json"])
var err: Error = _http.request(url, headers, HTTPClient.METHOD_POST, body)

View File

@ -70,6 +70,8 @@ var _dev_obstacle_smoke: Node3D
@onready var _nav_region: NavigationRegion3D = $World/NavigationRegion3D
@onready var _player: CharacterBody3D = $World/Player
@onready var _authority: Node = $PositionAuthorityClient
@onready var _catalog: Node = $InteractablesCatalogClient
@onready var _interactables_root: Node3D = $World/NavigationRegion3D/InteractablesRoot
@onready var _radius_preview: Node3D = $World/InteractionMarkers
@onready var _move_reject_label: Label = $UICanvas/MoveRejectLabel
@onready var _player_pos_label: Label = $UICanvas/PlayerPositionLabel
@ -106,10 +108,13 @@ func _ready() -> void:
# stopping" false-denial race by making the server's stored snapshot as fresh as possible.
if _target_client.has_method("set_freshness_kick"):
_target_client.call("set_freshness_kick", _authority, _player)
_authority.call("sync_from_server")
_target_client.call("request_sync_from_server")
_catalog.connect("catalog_ready", Callable(self, "_on_interactables_catalog_ready"))
_catalog.connect("catalog_failed", Callable(self, "_on_interactables_catalog_failed"))
if _radius_preview.has_method("setup_player"):
_radius_preview.call("setup_player", _player)
_catalog.call("request_catalog")
_authority.call("sync_from_server")
_target_client.call("request_sync_from_server")
func _physics_process(_delta: float) -> void:
@ -329,3 +334,15 @@ func _dev_collision_shapes_set_disabled(root: Node, disabled: bool) -> void:
(root as CollisionShape3D).disabled = disabled
for c in root.get_children():
_dev_collision_shapes_set_disabled(c, disabled)
func _on_interactables_catalog_ready(descriptors: Array) -> void:
var groups: Variant = load("res://scripts/interactable_world_builder.gd").call(
"build_from_catalog", descriptors, _interactables_root, _radius_preview
)
if _radius_preview.has_method("setup_glow_groups"):
_radius_preview.call("setup_glow_groups", groups as Array)
func _on_interactables_catalog_failed(_reason: String) -> void:
pass

View File

@ -1,25 +0,0 @@
extends Object
## Prototype interactable anchor + reach — **must match**
## `server/NeonSprawl.Server/Game/Interaction/PrototypeInteractableRegistry.cs`.
## Static accessors only (no `class_name`; see client README / NS-18 plan).
static func interactable_id() -> String:
return "prototype_terminal"
static func anchor_x() -> float:
return 0.0
static func anchor_y() -> float:
return 0.5
static func anchor_z() -> float:
return 0.0
static func interaction_radius() -> float:
return 3.0

View File

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

View File

@ -0,0 +1,31 @@
extends GdUnitTestSuite
## NEO-25: parse `GET /game/world/interactables` JSON into descriptor rows.
func test_parse_catalog_json_orders_and_fields() -> void:
var json := (
'{"schemaVersion":1,"interactables":['
+ '{"interactableId":"prototype_resource_node_alpha","kind":"resource_node",'
+ '"anchor":{"x":5,"y":0.5,"z":5},"interactionRadius":3},'
+ '{"interactableId":"prototype_terminal","kind":"terminal",'
+ '"anchor":{"x":0,"y":0.5,"z":0},"interactionRadius":3}'
+ "]}"
)
var rows: Variant = load("res://scripts/interactables_catalog_client.gd").call("parse_catalog_json", json)
assert_that(rows is Array).is_true()
var arr: Array = rows
assert_that(arr.size()).is_equal(2)
var r0: Dictionary = arr[0]
var r1: Dictionary = arr[1]
assert_that(r0.get("interactableId", "")).is_equal("prototype_resource_node_alpha")
assert_that(r0.get("kind", "")).is_equal("resource_node")
var a0: Variant = r0.get("anchor", null)
assert_that(a0 is Dictionary).is_true()
assert_that(float((a0 as Dictionary).get("x", -1.0))).is_equal(5.0)
assert_that(r1.get("interactableId", "")).is_equal("prototype_terminal")
func test_parse_catalog_json_returns_empty_on_garbage() -> void:
var rows2: Variant = load("res://scripts/interactables_catalog_client.gd").call("parse_catalog_json", "[]")
assert_that((rows2 as Array).is_empty()).is_true()

View File

@ -0,0 +1,52 @@
extends GdUnitTestSuite
## NEO-25: `InteractionRequestClient` POST bodies for E vs R contract ids.
const IxClient := preload("res://scripts/interaction_request_client.gd")
class MockHttpTransport:
extends Node
signal request_completed(
result: int, response_code: int, headers: PackedStringArray, body: PackedByteArray
)
var last_url: String = ""
var last_body: String = ""
var last_method: HTTPClient.Method = HTTPClient.METHOD_GET
func request(
url: String,
_custom_headers: PackedStringArray = PackedStringArray(),
method: HTTPClient.Method = HTTPClient.METHOD_GET,
request_data: String = ""
) -> Error:
last_url = url
last_body = request_data
last_method = method
var body_bytes: PackedByteArray = '{"schemaVersion":1,"allowed":true}'.to_utf8_buffer()
request_completed.emit(HTTPRequest.RESULT_SUCCESS, 200, PackedStringArray(), body_bytes)
return OK
func _make_ix(transport: Node) -> Node:
var ix: Node = IxClient.new()
ix.set("injected_http", transport)
auto_free(transport)
auto_free(ix)
add_child(ix)
return ix
func test_post_terminal_body_contains_prototype_terminal() -> void:
var transport := MockHttpTransport.new()
var ix := _make_ix(transport)
ix.call("_post_interact", "prototype_terminal")
assert_that(transport.last_body).contains("prototype_terminal")
assert_that(transport.last_url).contains("/interact")
func test_post_resource_body_contains_resource_node_id() -> void:
var transport := MockHttpTransport.new()
var ix := _make_ix(transport)
ix.call("_post_interact", "prototype_resource_node_alpha")
assert_that(transport.last_body).contains("prototype_resource_node_alpha")

View File

@ -100,8 +100,8 @@ flowchart LR
## Implementation snapshot
- **`TargetState` (prototype JSON v1 — NEO-23):** server **`GET /game/players/{id}/target`** and **`POST /game/players/{id}/target/select`** with versioned **`PlayerTargetStateResponse`** / **`TargetSelectRequest`** / **`TargetSelectResponse`**; stub registry + in-memory lock under `server/NeonSprawl.Server/Game/Targeting/`; horizontal reach + soft lock semantics — [NEO-23](../../plans/NEO-23-implementation-plan.md), [server README — Targeting](../../../server/README.md#targeting-neo-23). **Still open on later issues:** **`InteractableDescriptor`**, **`SelectionEvent`**, Godot tab-target ([NEO-24](https://linear.app/neon-sprawl/issue/NEO-24)). **Story backlog:** [E1M3 prototype backlog](../../plans/E1M3-prototype-backlog.md).
- **Precursor under E1.M1 (NEO-9):** server **`POST /game/players/{id}/interact`** with **`InteractionRequest`** / **`InteractionResponse`**; **`HorizontalReach`** (`server/NeonSprawl.Server/Game/World/HorizontalReach.cs`); prototype registry and API under `server/NeonSprawl.Server/Game/Interaction/`; client **`interaction_request_client.gd`**, **`interaction_radius_indicators.gd`**, **`prototype_interaction_constants.gd`** — see [NEO-9](../../plans/NEO-9-implementation-plan.md) and [server README — Interaction](../../../server/README.md#interaction-neo-9).
- **`TargetState` (prototype JSON v1 — NEO-23):** server **`GET /game/players/{id}/target`** and **`POST /game/players/{id}/target/select`** with versioned **`PlayerTargetStateResponse`** / **`TargetSelectRequest`** / **`TargetSelectResponse`**; stub registry + in-memory lock under `server/NeonSprawl.Server/Game/Targeting/`; horizontal reach + soft lock semantics — [NEO-23](../../plans/NEO-23-implementation-plan.md), [server README — Targeting](../../../server/README.md#targeting-neo-23). Godot tab-target: [NEO-24](https://linear.app/neon-sprawl/issue/NEO-24). **`InteractableDescriptor` list:** **`GET /game/world/interactables`** (NEO-25). **Still open on later issues:** **`SelectionEvent`**. **Story backlog:** [E1M3 prototype backlog](../../plans/E1M3-prototype-backlog.md).
- **Precursor under E1.M1 (NEO-9 + NEO-25):** server **`POST /game/players/{id}/interact`** with **`InteractionRequest`** / **`InteractionResponse`**; **`GET /game/world/interactables`** (versioned descriptor list); **`HorizontalReach`** (`server/NeonSprawl.Server/Game/World/HorizontalReach.cs`); prototype registry under `server/NeonSprawl.Server/Game/Interaction/`; client **`interactables_catalog_client.gd`**, **`interactable_world_builder.gd`**, **`interaction_request_client.gd`**, **`interaction_radius_indicators.gd`** — see [NEO-9](../../plans/NEO-9-implementation-plan.md), [NEO-25](../../plans/NEO-25-implementation-plan.md), and [server README — Interaction](../../../server/README.md#interaction-neo-9).
- **Alignment:** [Documentation and implementation alignment](documentation_and_implementation_alignment.md).
## Risks and telemetry

View File

@ -0,0 +1,20 @@
# Manual QA — NEO-25 (InteractableDescriptor list + fetch-driven client)
**Story:** [NEO-25](https://linear.app/neon-sprawl/issue/NEO-25) · Plan: [`docs/plans/NEO-25-implementation-plan.md`](../plans/NEO-25-implementation-plan.md)
## Preconditions
- Game server running (`dotnet run` from `server/NeonSprawl.Server`; note host/port).
- Godot client `base_url` matches server (default `http://127.0.0.1:5253` on catalog + interaction clients).
## Checks
1. **Descriptor GET:** `curl -s http://localhost:5253/game/world/interactables` returns JSON with `schemaVersion: 1`, `interactables` length ≥ 2, ascending `interactableId`, each row has `kind`, `anchor` `{x,y,z}`, `interactionRadius`.
2. **F5 with server up:** After load, **two** prototype props appear (center + **+XZ** corner) and **two pairs** of small blue glow markers (offset from each anchor). Default spawn **(-5, 0.9, -5)**: markers near **origin** glow when in 3 m radius; corner node stays dim until you walk toward **(5, 5)**.
3. **E** (`interact`): at origin in range → Output `allowed=true` for terminal; walk out past 3 m horizontal → `allowed=false`, `out_of_range`.
4. **R** (`interact_secondary`): stand within 3 m of **(5, 0.5, 5)** on X/Z → `allowed=true` for resource node; far from that anchor → `out_of_range`.
5. **Server down / wrong port:** catalog request errors in Output; scene still runs (no props / no glow groups — acceptable prototype degradation).
## Notes
- Registry source of truth is **C#**; client anchors come only from **`GET /game/world/interactables`**.

View File

@ -36,11 +36,11 @@
## Acceptance criteria checklist
- [ ] **≥2 interactables** in **`PrototypeInteractableRegistry`** with distinct ids, anchors, radii, and **kinds** (`terminal` vs `resource_node`).
- [ ] **`GET /game/world/interactables`** returns versioned JSON whose **`interactables`** entries each include **`interactableId`**, **`anchor`** (world), **`interactionRadius`**, **`kind`**.
- [ ] **`POST /game/players/{id}/interact`** with **`InteractionRequest`** v1 succeeds for **each** id when the player is in horizontal range; denies with stable **`reasonCode`** when out of range (NEO-9 semantics preserved).
- [ ] **Godot:** preview glow + scene props are driven from the **fetched** descriptor list (no `prototype_interaction_constants.gd`). **`interact`** / **`interact_secondary`** each hit the correct id for manual QA.
- [ ] **Bruno** + **server README** document the new GET.
- [x] **≥2 interactables** in **`PrototypeInteractableRegistry`** with distinct ids, anchors, radii, and **kinds** (`terminal` vs `resource_node`).
- [x] **`GET /game/world/interactables`** returns versioned JSON whose **`interactables`** entries each include **`interactableId`**, **`anchor`** (world), **`interactionRadius`**, **`kind`**.
- [x] **`POST /game/players/{id}/interact`** with **`InteractionRequest`** v1 succeeds for **each** id when the player is in horizontal range; denies with stable **`reasonCode`** when out of range (NEO-9 semantics preserved).
- [x] **Godot:** preview glow + scene props are driven from the **fetched** descriptor list (no `prototype_interaction_constants.gd`). **`interact`** / **`interact_secondary`** each hit the correct id for manual QA.
- [x] **Bruno** + **server README** document the new GET.
## Technical approach

View File

@ -58,7 +58,7 @@ The game is **3D**, but **prototype interaction and movement tuning** treat all
**Prototype defaults (source of truth: C# registry)**
Authoritative values live in **`PrototypeInteractableRegistry.cs`**. **Godot** and any client preview script **must match** (duplicate constants in `prototype_interaction_constants.gd` with a comment pointing at that file). Tune by editing **C# first**, then syncing the client.
Authoritative values live in **`PrototypeInteractableRegistry.cs`**. **Historical note:** the prototype client originally duplicated anchors in `prototype_interaction_constants.gd` (NEO-9). **NEO-25** replaced that with **`GET /game/world/interactables`** + runtime spawn; tune interactables by editing **C# first**, then verifying the GET JSON and client visuals.
| | Value | Rationale |
|---|--------|-----------|

View File

@ -0,0 +1,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(5, alpha.Anchor.X);
Assert.Equal(0.5, alpha.Anchor.Y);
Assert.Equal(5, alpha.Anchor.Z);
Assert.Equal(3.0, alpha.InteractionRadius);
var term = body.Interactables.Single(x => x.InteractableId == PrototypeInteractableRegistry.PrototypeTerminalId);
Assert.Equal("terminal", term.Kind);
Assert.Equal(0, term.Anchor.X);
Assert.Equal(0.5, term.Anchor.Y);
Assert.Equal(0, term.Anchor.Z);
Assert.Equal(3.0, term.InteractionRadius);
}
}

View File

@ -230,4 +230,51 @@ public class InteractionApiTests
var nearBody = await near.Content.ReadFromJsonAsync<InteractionResponse>();
Assert.True(nearBody!.Allowed);
}
[Fact]
public async Task PostInteract_ShouldAllowResourceNode_WhenPlayerInRangeOfResourceAnchor()
{
await using var factory = new InMemoryWebApplicationFactory();
var client = factory.CreateClient();
var move = new MoveCommandRequest
{
SchemaVersion = MoveCommandRequest.CurrentSchemaVersion,
Target = new PositionVector { X = 5, Y = 0.9, Z = 5 },
};
Assert.Equal(HttpStatusCode.OK, (await client.PostAsJsonAsync("/game/players/dev-local-1/move", move)).StatusCode);
var response = await client.PostAsJsonAsync(
"/game/players/dev-local-1/interact",
new InteractionRequest
{
SchemaVersion = InteractionRequest.CurrentSchemaVersion,
InteractableId = PrototypeInteractableRegistry.PrototypeResourceNodeAlphaId,
});
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
var body = await response.Content.ReadFromJsonAsync<InteractionResponse>();
Assert.NotNull(body);
Assert.True(body!.Allowed);
Assert.Equal(PrototypeInteractableRegistry.PrototypeResourceNodeAlphaId, body.InteractableId);
}
[Fact]
public async Task PostInteract_ShouldDenyOutOfRange_ForResourceNode_WhenPlayerFar()
{
await using var factory = new InMemoryWebApplicationFactory();
var client = factory.CreateClient();
var response = await client.PostAsJsonAsync(
"/game/players/dev-local-1/interact",
new InteractionRequest
{
SchemaVersion = InteractionRequest.CurrentSchemaVersion,
InteractableId = PrototypeInteractableRegistry.PrototypeResourceNodeAlphaId,
});
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
var body = await response.Content.ReadFromJsonAsync<InteractionResponse>();
Assert.NotNull(body);
Assert.False(body!.Allowed);
Assert.Equal(InteractionApi.ReasonOutOfRange, body.ReasonCode);
}
}

View File

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

View File

@ -0,0 +1,23 @@
namespace NeonSprawl.Server.Game.Interaction;
/// <summary>Maps <c>GET /game/world/interactables</c> (NEO-25).</summary>
public static class InteractablesWorldApi
{
public static WebApplication MapInteractablesWorldApi(this WebApplication app)
{
app.MapGet(
"/game/world/interactables",
() =>
{
var rows = PrototypeInteractableRegistry.GetOrderedDescriptors();
return Results.Json(
new InteractablesListResponse
{
SchemaVersion = InteractablesListResponse.CurrentSchemaVersion,
Interactables = rows,
});
});
return app;
}
}

View File

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

View File

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

View File

@ -125,6 +125,33 @@ When **`allowed` is `true`**, **`reasonCode` is omitted** (clients must not requ
Contract details and PR blurb: [NEO-9 implementation plan](../../docs/plans/NEO-9-implementation-plan.md).
## Interactable descriptors (NEO-25)
**`GET /game/world/interactables`** returns a versioned **projection** of the prototype **`PrototypeInteractableRegistry`** (no player id; global list). Use this for client discovery of anchors, radii, and **`kind`** instead of duplicating registry constants in GDScript.
Example:
```bash
curl -s http://localhost:5253/game/world/interactables
```
**HTTP**
| Status | Meaning |
|--------|---------|
| **200** | Body is **`InteractablesListResponse`** v1: `schemaVersion` (`1`) + **`interactables`** array. |
**`interactables[]` fields (v1)**
| Field | Meaning |
|--------|---------|
| `interactableId` | Lowercase canonical id (matches **`POST …/interact`**). |
| `kind` | Stable machine string (`terminal`, `resource_node`, …). |
| `anchor` | Object with **`x`**, **`y`**, **`z`** (world meters). |
| `interactionRadius` | Horizontal reach on X/Z; inclusive boundary (same rule as NEO-9). |
Rows are sorted by ascending **`interactableId`**. Plan: [NEO-25 implementation plan](../../docs/plans/NEO-25-implementation-plan.md).
## Targeting (NEO-23)
Authoritative **combat target lock** (prototype): **`GET /game/players/{id}/target`** returns **`PlayerTargetStateResponse`** v1; **`POST /game/players/{id}/target/select`** accepts **`TargetSelectRequest`** v1 and returns **`TargetSelectResponse`** v1. Player id rules match position/interact (trim + ordinal case-insensitive lookup in the in-memory store). **Horizontal lock range** uses **`HorizontalReach`** on **X/Z only** (inclusive radius), same floor-plane policy as NEO-9.