diff --git a/bruno/neon-sprawl-server/interaction/Get interactables list.bru b/bruno/neon-sprawl-server/interaction/Get interactables list.bru new file mode 100644 index 0000000..6ba0276 --- /dev/null +++ b/bruno/neon-sprawl-server/interaction/Get interactables list.bru @@ -0,0 +1,11 @@ +meta { + name: GET interactables list + type: http + seq: 9 +} + +get { + url: {{baseUrl}}/game/world/interactables + body: none + auth: none +} diff --git a/client/README.md b/client/README.md index ca66072..78d89b5 100644 --- a/client/README.md +++ b/client/README.md @@ -78,18 +78,22 @@ With the game server running ([`server/README.md`](../server/README.md)), **WASD If the server is **down**, boot **`GET`** fails silently (check Output for warnings). **`move-stream`** submissions are skipped while the HTTP client is busy, then resume. -## Interaction + range preview (NEO-9) +## Interaction + range preview (NEO-9 + NEO-25) -The main scene includes a **prototype terminal** at the map center (same world **X/Z** as the server’s static registry) and **two glowing markers** driven by `scripts/interaction_radius_indicators.gd`. That script compares the **`CharacterBody3D`** **`global_position`** (after server snap) to the anchor in **`scripts/prototype_interaction_constants.gd`** using **horizontal distance on X/Z** and the same **`interaction_radius`** as **`PrototypeInteractableRegistry.cs`** — **preview only**; the server **`POST /game/players/{id}/interact`** is authoritative. +On boot, **`InteractablesCatalogClient`** **`GET`s** **`/game/world/interactables`**; **`interactable_world_builder.gd`** spawns **walkable** prototype props plus **two** small glow markers per row under **`World/InteractionMarkers`**. **`interaction_radius_indicators.gd`** compares the snapped **`CharacterBody3D`** **`global_position`** to each descriptor’s **`anchor`** on **X/Z** with the same **`interactionRadius`** and inclusive **`<=`** rule as the server — **preview only**; **`POST /game/players/{id}/interact`** remains authoritative. -- **`InteractionRequestClient`** (child of the main scene root): press **E** to POST interact for the prototype id; **Output** prints `allowed` / `reasonCode` (or HTTP errors). While a request is in flight, further **E** presses are ignored (same pattern as **`PositionAuthorityClient`**). -- **Inspector:** match **`base_url`** / **`dev_player_id`** to **`PositionAuthorityClient`** and the server `Game:DevPlayerId`. +- **`InteractionRequestClient`**: **`interact`** (**E**) → **`prototype_terminal`**; **`interact_secondary`** (**R**) → **`prototype_resource_node_alpha`** (stable ids; must match `PrototypeInteractableRegistry.cs`). **Output** prints `allowed` / `reasonCode`. In-flight requests are ignored (same busy pattern as **`PositionAuthorityClient`**). +- **Inspector:** match **`base_url`** on **`InteractablesCatalogClient`**, **`InteractionRequestClient`**, and **`PositionAuthorityClient`** to the running server. +- **No server:** catalog **GET** fails → **Output** errors; props and glow markers do not spawn (expected prototype degradation). -### Manual check (NEO-9) +### Manual check (NEO-9 + NEO-25) 1. Run the game server and client as in NEO-7. -2. **F5** in Godot: default spawn is out of range of the terminal; markers should stay **dim**. **WASD** toward the center until markers **brighten** (within **3** m on the floor plane). -3. Press **E** (input action **`interact`** in `project.godot`): Output should show **`allowed=true`** when markers glow, **`allowed=false`** with **`reasonCode=out_of_range`** when dim (if you walk back out). Interaction uses **`_input`**, not `_unhandled_input`, so keys register reliably in the embedded **Game** dock; click the game view if the editor had focus elsewhere. +2. **F5** in Godot: after catalog load, two interactable sites appear (center + **+XZ**). Default spawn **(-5, 0.9, -5)** is out of range of the **center** node (3 m); markers near **origin** stay **dim** until you **WASD** within horizontal **3** m of **(0, 0.5, 0)** on X/Z — then that pair **brightens**. +3. Press **E** (**`interact`**): Output **`allowed=true`** when the **terminal** pair glows; **`allowed=false`** / **`out_of_range`** when dim. Walk to **(5, 5)** area until the **second** pair glows; press **R** (**`interact_secondary`**) for **`allowed=true`** on the resource node. +4. Interaction uses **`_input`** (and key fallbacks) so keys register in the embedded **Game** dock; click the game view if the editor had focus elsewhere. + +Full checklist: [`docs/manual-qa/NEO-25.md`](../manual-qa/NEO-25.md). ## Target lock + tab cycle (NEO-24) diff --git a/client/project.godot b/client/project.godot index 29dfcbd..207e57a 100644 --- a/client/project.godot +++ b/client/project.godot @@ -44,6 +44,11 @@ interact={ "events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":69,"physical_keycode":69,"key_label":0,"unicode":101,"location":0,"echo":false,"script":null) ] } +interact_secondary={ +"deadzone": 0.5, +"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":82,"physical_keycode":82,"key_label":0,"unicode":114,"location":0,"echo":false,"script":null) +] +} target_tab={ "deadzone": 0.5, "events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":4194306,"physical_keycode":4194306,"key_label":0,"unicode":0,"location":0,"echo":false,"script":null) diff --git a/client/scenes/main.tscn b/client/scenes/main.tscn index 866dcbd..8564075 100644 --- a/client/scenes/main.tscn +++ b/client/scenes/main.tscn @@ -11,6 +11,7 @@ [ext_resource type="Resource" path="res://resources/isometric_zoom_bands.tres" id="8_zoom_bands"] [ext_resource type="Resource" path="res://resources/isometric_occlusion_policy.tres" id="9_occ_pol"] [ext_resource type="Script" uid="uid://dqm8s4k2h1xwy" path="res://scripts/prototype_smooth_hill_piece.gd" id="10_smooth_hill"] +[ext_resource type="Script" path="res://scripts/interactables_catalog_client.gd" id="13_ix_cat"] [sub_resource type="NavigationMesh" id="NavigationMesh_district"] geometry_collision_mask = 1 @@ -30,15 +31,6 @@ albedo_color = Color(0.5, 0.5, 0.52, 1) [sub_resource type="BoxShape3D" id="BoxShape3D_floor"] size = Vector3(45, 0.2, 45) -[sub_resource type="BoxMesh" id="BoxMesh_terminal"] -size = Vector3(0.9, 1, 0.4) - -[sub_resource type="StandardMaterial3D" id="Mat_terminal"] -albedo_color = Color(0.28, 0.38, 0.45, 1) - -[sub_resource type="BoxShape3D" id="BoxShape3D_terminal"] -size = Vector3(0.9, 1, 0.4) - [sub_resource type="BoxMesh" id="BoxMesh_move_reject_pedestal"] size = Vector3(1.5, 2.5, 1.5) @@ -216,12 +208,6 @@ size = Vector3(2.5, 0.15, 4.5) [sub_resource type="BoxShape3D" id="BoxShape3D_physics_ramp_test_steep"] size = Vector3(2.5, 0.15, 4.5) -[sub_resource type="BoxMesh" id="BoxMesh_marker"] -size = Vector3(0.22, 0.35, 0.22) - -[sub_resource type="StandardMaterial3D" id="Mat_marker_base"] -albedo_color = Color(0.15, 0.4, 1, 1) - [sub_resource type="CapsuleShape3D" id="CapsuleShape3D_player"] radius = 0.4 height = 1.0 @@ -269,16 +255,7 @@ surface_material_override/0 = SubResource("Mat_floor_slab") transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, -0.1, 0) shape = SubResource("BoxShape3D_floor") -[node name="PrototypeTerminal" type="StaticBody3D" parent="World/NavigationRegion3D" unique_id=1700001 groups=["walkable"]] -transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 0.5, 0) -collision_mask = 3 - -[node name="MeshInstance3D" type="MeshInstance3D" parent="World/NavigationRegion3D/PrototypeTerminal" unique_id=1700002] -mesh = SubResource("BoxMesh_terminal") -surface_material_override/0 = SubResource("Mat_terminal") - -[node name="CollisionShape3D" type="CollisionShape3D" parent="World/NavigationRegion3D/PrototypeTerminal" unique_id=1700006] -shape = SubResource("BoxShape3D_terminal") +[node name="InteractablesRoot" type="Node3D" parent="World/NavigationRegion3D" unique_id=1700011] [node name="MoveRejectPedestal" type="StaticBody3D" parent="World/NavigationRegion3D" unique_id=1900021 groups=["walkable"]] transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 7.5, 0, -6.5) @@ -1077,16 +1054,6 @@ script = ExtResource("12_tgt_mk") [node name="InteractionMarkers" type="Node3D" parent="World" unique_id=1700003] script = ExtResource("6_rad") -[node name="MarkerA" type="MeshInstance3D" parent="World/InteractionMarkers" unique_id=1700004] -transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 1.1, 0.25, 0.9) -mesh = SubResource("BoxMesh_marker") -surface_material_override/0 = SubResource("Mat_marker_base") - -[node name="MarkerB" type="MeshInstance3D" parent="World/InteractionMarkers" unique_id=1700005] -transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, -0.95, 0.22, -1.05) -mesh = SubResource("BoxMesh_marker") -surface_material_override/0 = SubResource("Mat_marker_base") - [node name="Player" type="CharacterBody3D" parent="World" unique_id=352931696] transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, -5, 0.9, -5) collision_layer = 2 @@ -1110,6 +1077,9 @@ cast_shadow = 0 mesh = SubResource("CapsuleMesh_player") surface_material_override/0 = SubResource("Mat_player_capsule") +[node name="InteractablesCatalogClient" type="Node" parent="." unique_id=2500005] +script = ExtResource("13_ix_cat") + [node name="PositionAuthorityClient" type="Node" parent="." unique_id=2500002] script = ExtResource("4_auth") diff --git a/client/scripts/interactable_world_builder.gd b/client/scripts/interactable_world_builder.gd new file mode 100644 index 0000000..3ce00b7 --- /dev/null +++ b/client/scripts/interactable_world_builder.gd @@ -0,0 +1,99 @@ +extends Object + +## NEO-25: spawns walkable interactable props + glow marker pairs from server descriptor dicts. +## Returns glow groups for [method InteractionRadiusIndicators.setup_glow_groups]. + +const _PROP_SIZE := Vector3(0.9, 1.0, 0.4) +const _MARKER_MESH_SIZE := Vector3(0.22, 0.35, 0.22) +const _MARKER_OFFSET_A := Vector3(1.1, 0.25, 0.9) +const _MARKER_OFFSET_B := Vector3(-0.95, 0.22, -1.05) + + +static func build_from_catalog( + descriptors: Array, interactables_root: Node3D, markers_root: Node3D +) -> Array: + for c in interactables_root.get_children(): + c.free() + for c in markers_root.get_children(): + c.free() + + var glow_groups: Array = [] + + for row in descriptors: + if not row is Dictionary: + continue + var rd: Dictionary = row + var id: String = str(rd.get("interactableId", "")) + var kind: String = str(rd.get("kind", "")) + var anchor: Vector3 = _anchor_from_row(rd) + var radius: float = float(rd.get("interactionRadius", 0.0)) + + var body := StaticBody3D.new() + body.name = "Interactable_%s" % id + body.collision_mask = 3 + body.add_to_group("walkable") + interactables_root.add_child(body) + body.global_position = anchor + + var prop_mesh := MeshInstance3D.new() + var box_mesh := BoxMesh.new() + box_mesh.size = _PROP_SIZE + prop_mesh.mesh = box_mesh + prop_mesh.position = Vector3.ZERO + var prop_mat := StandardMaterial3D.new() + if kind == "resource_node": + prop_mat.albedo_color = Color(0.18, 0.55, 0.32, 1.0) + else: + prop_mat.albedo_color = Color(0.28, 0.38, 0.45, 1.0) + prop_mesh.material_override = prop_mat + body.add_child(prop_mesh) + + var col := CollisionShape3D.new() + var box_shape := BoxShape3D.new() + box_shape.size = _PROP_SIZE + col.shape = box_shape + body.add_child(col) + + var marker_parent := Node3D.new() + marker_parent.name = "Markers_%s" % id + markers_root.add_child(marker_parent) + marker_parent.global_position = anchor + + var mi_a := _make_marker_mesh() + mi_a.position = _MARKER_OFFSET_A + marker_parent.add_child(mi_a) + var mi_b := _make_marker_mesh() + mi_b.position = _MARKER_OFFSET_B + marker_parent.add_child(mi_b) + + var glow_mat := StandardMaterial3D.new() + glow_mat.albedo_color = Color(0.15, 0.4, 1.0, 1.0) + glow_mat.emission_enabled = true + glow_mat.emission = Color(0.3, 0.6, 1.0) + glow_mat.emission_energy_multiplier = 0.12 + mi_a.material_override = glow_mat + mi_b.material_override = glow_mat + + glow_groups.append( + {"anchor": anchor, "radius": radius, "material": glow_mat, "markers": [mi_a, mi_b]} + ) + + return glow_groups + + +static func _anchor_from_row(rd: Dictionary) -> Vector3: + var a: Variant = rd.get("anchor", null) + if not a is Dictionary: + return Vector3.ZERO + var ad: Dictionary = a + return Vector3( + float(ad.get("x", 0.0)), float(ad.get("y", 0.0)), float(ad.get("z", 0.0))) + ) + + +static func _make_marker_mesh() -> MeshInstance3D: + var mi := MeshInstance3D.new() + var m := BoxMesh.new() + m.size = _MARKER_MESH_SIZE + mi.mesh = m + return mi diff --git a/client/scripts/interactables_catalog_client.gd b/client/scripts/interactables_catalog_client.gd new file mode 100644 index 0000000..1c011b5 --- /dev/null +++ b/client/scripts/interactables_catalog_client.gd @@ -0,0 +1,84 @@ +extends Node + +## NEO-25: fetches `GET /game/world/interactables` and exposes descriptor rows for world build + glow preview. + +signal catalog_ready(descriptors: Array) +signal catalog_failed(reason: String) + +@export var base_url: String = "http://127.0.0.1:5253" + +var _http: HTTPRequest +var _busy: bool = false + + +func _ready() -> void: + _http = HTTPRequest.new() + add_child(_http) + _http.timeout = 30.0 + _http.request_completed.connect(_on_request_completed) + + +func request_catalog() -> void: + if _busy: + return + _busy = true + var url := "%s/game/world/interactables" % _base_root() + var err: Error = _http.request(url) + if err != OK: + _busy = false + var msg := "InteractablesCatalogClient: GET failed to start (%s)" % err + push_error(msg) + catalog_failed.emit(msg) + + +func _base_root() -> String: + return base_url.strip_edges().rstrip("/") + + +func _on_request_completed( + _result: int, response_code: int, _headers: PackedStringArray, body: PackedByteArray +) -> void: + _busy = false + var text := body.get_string_from_utf8() + if response_code != 200: + var msg := "InteractablesCatalogClient: HTTP %s body=%s" % [response_code, text] + push_error(msg) + catalog_failed.emit(msg) + return + var parsed: Variant = JSON.parse_string(text) + if not parsed is Dictionary: + var msg := "InteractablesCatalogClient: expected JSON object" + push_error(msg) + catalog_failed.emit(msg) + return + var root: Dictionary = parsed + var descriptors: Array = _parse_interactables_array(root) + if descriptors.is_empty(): + var msg2 := "InteractablesCatalogClient: interactables missing or empty" + push_error(msg2) + catalog_failed.emit(msg2) + return + catalog_ready.emit(descriptors) + + +## Returns an [Array] of [Dictionary] rows: [code]interactableId[/code], [code]kind[/code], +## [code]anchor[/code] ([Dictionary] x,y,z), [code]interactionRadius[/code] ([float]). +static func parse_catalog_json(text: String) -> Array: + var parsed: Variant = JSON.parse_string(text) + if not parsed is Dictionary: + return [] + return _parse_interactables_array(parsed) + + +static func _parse_interactables_array(root: Variant) -> Array: + if not root is Dictionary: + return [] + var d: Dictionary = root + var raw: Variant = d.get("interactables", null) + if raw == null or not raw is Array: + return [] + var out_arr: Array = [] + for item in raw as Array: + if item is Dictionary: + out_arr.append(item) + return out_arr diff --git a/client/scripts/interaction_radius_indicators.gd b/client/scripts/interaction_radius_indicators.gd index 591043d..823d64c 100644 --- a/client/scripts/interaction_radius_indicators.gd +++ b/client/scripts/interaction_radius_indicators.gd @@ -1,40 +1,43 @@ extends Node3D -## NS-18: **Client preview only** — glow when `CharacterBody3D` is within horizontal radius of the -## prototype terminal (X/Z, inclusive). Server `POST …/interact` remains authoritative. +## NS-18 / NEO-25: **Client preview only** — glow per interactable when [CharacterBody3D] is within +## horizontal radius (X/Z, inclusive). Data comes from [method setup_glow_groups] after +## `GET /game/world/interactables`. Server `POST …/interact` remains authoritative. -const ProtoIx = preload("res://scripts/prototype_interaction_constants.gd") const EMISSION_DIM := 0.12 const EMISSION_BRIGHT := 7.0 -@export var marker_paths: Array[NodePath] = [^"MarkerA", ^"MarkerB"] - var _player: CharacterBody3D -var _mat: StandardMaterial3D +## Each element: [code]anchor[/code] [Vector3], [code]radius[/code] [float], [code]material[/code] +## [StandardMaterial3D], [code]markers[/code] [Array] of [MeshInstance3D] (optional; emission driven via material). +var _glow_groups: Array = [] func setup_player(player: CharacterBody3D) -> void: _player = player -func _ready() -> void: - _mat = StandardMaterial3D.new() - _mat.albedo_color = Color(0.1, 0.32, 0.9) - _mat.emission_enabled = true - _mat.emission = Color(0.3, 0.6, 1.0) - _mat.emission_energy_multiplier = EMISSION_DIM - for p in marker_paths: - var mi := get_node_or_null(p) as MeshInstance3D - if mi: - mi.material_override = _mat +func setup_glow_groups(groups: Array) -> void: + _glow_groups = groups func _process(_delta: float) -> void: - if _player == null or _mat == null: + if _player == null or _glow_groups.is_empty(): return - var dx: float = _player.global_position.x - ProtoIx.anchor_x() - var dz: float = _player.global_position.z - ProtoIx.anchor_z() - var dist_sq: float = dx * dx + dz * dz - var r: float = ProtoIx.interaction_radius() - var in_range: bool = dist_sq <= r * r - _mat.emission_energy_multiplier = EMISSION_BRIGHT if in_range else EMISSION_DIM + var px: float = _player.global_position.x + var pz: float = _player.global_position.z + for g in _glow_groups: + if not g is Dictionary: + continue + var d: Dictionary = g + var anchor: Variant = d.get("anchor", null) + var mat: Variant = d.get("material", null) + if not anchor is Vector3 or not mat is StandardMaterial3D: + continue + var a: Vector3 = anchor + var m: StandardMaterial3D = mat + var r: float = float(d.get("radius", 0.0)) + var dx: float = px - a.x + var dz: float = pz - a.z + var in_range: bool = (dx * dx + dz * dz) <= r * r + m.emission_energy_multiplier = EMISSION_BRIGHT if in_range else EMISSION_DIM diff --git a/client/scripts/interaction_request_client.gd b/client/scripts/interaction_request_client.gd index 667855e..30f973f 100644 --- a/client/scripts/interaction_request_client.gd +++ b/client/scripts/interaction_request_client.gd @@ -1,43 +1,56 @@ extends Node -## NS-18: POST InteractionRequest; prints server allow/deny to Output (prototype). +## NS-18 / NEO-25: POST InteractionRequest; prints server allow/deny to Output (prototype). +## [kbd]E[/kbd] → [code]prototype_terminal[/code]; [kbd]R[/kbd] → [code]prototype_resource_node_alpha[/code] +## (stable contract ids — must match server registry). -const ProtoIx = preload("res://scripts/prototype_interaction_constants.gd") +const ID_TERMINAL := "prototype_terminal" +const ID_RESOURCE_NODE := "prototype_resource_node_alpha" @export var base_url: String = "http://127.0.0.1:5253" @export var dev_player_id: String = "dev-local-1" +## GdUnit: inject a mock transport (same pattern as [code]TargetSelectionClient[/code] test double). +@export var injected_http: Node = null -var _http: HTTPRequest +var _http: Node var _busy: bool = false func _ready() -> void: - _http = HTTPRequest.new() - add_child(_http) + if injected_http != null: + _http = injected_http + else: + _http = HTTPRequest.new() + add_child(_http) + if _http is HTTPRequest: + (_http as HTTPRequest).timeout = 30.0 + @warning_ignore("unsafe_method_access") _http.request_completed.connect(_on_request_completed) func _input(event: InputEvent) -> void: - # Use _input: _unhandled_input often misses keys in the embedded Game dock / focus quirks. - # `interact` is in project.godot; KEY_E fallback for odd layouts. if event.is_action_pressed("interact"): - _post_interact() + _post_interact(ID_TERMINAL) + elif event.is_action_pressed("interact_secondary"): + _post_interact(ID_RESOURCE_NODE) elif event is InputEventKey: var k := event as InputEventKey if k.pressed and not k.echo and (k.physical_keycode == KEY_E or k.keycode == KEY_E): - _post_interact() + _post_interact(ID_TERMINAL) + elif k.pressed and not k.echo and (k.physical_keycode == KEY_R or k.keycode == KEY_R): + _post_interact(ID_RESOURCE_NODE) func _base_root() -> String: return base_url.strip_edges().rstrip("/") -func _post_interact() -> void: +func _post_interact(interactable_id: String) -> void: if _busy: return _busy = true var url := "%s/game/players/%s/interact" % [_base_root(), dev_player_id.strip_edges()] - var payload := {"schemaVersion": 1, "interactableId": ProtoIx.interactable_id()} + var payload := {"schemaVersion": 1, "interactableId": interactable_id} var body := JSON.stringify(payload) var headers := PackedStringArray(["Content-Type: application/json"]) var err: Error = _http.request(url, headers, HTTPClient.METHOD_POST, body) diff --git a/client/scripts/main.gd b/client/scripts/main.gd index 2050318..978982a 100644 --- a/client/scripts/main.gd +++ b/client/scripts/main.gd @@ -70,6 +70,8 @@ var _dev_obstacle_smoke: Node3D @onready var _nav_region: NavigationRegion3D = $World/NavigationRegion3D @onready var _player: CharacterBody3D = $World/Player @onready var _authority: Node = $PositionAuthorityClient +@onready var _catalog: Node = $InteractablesCatalogClient +@onready var _interactables_root: Node3D = $World/NavigationRegion3D/InteractablesRoot @onready var _radius_preview: Node3D = $World/InteractionMarkers @onready var _move_reject_label: Label = $UICanvas/MoveRejectLabel @onready var _player_pos_label: Label = $UICanvas/PlayerPositionLabel @@ -106,10 +108,13 @@ func _ready() -> void: # stopping" false-denial race by making the server's stored snapshot as fresh as possible. if _target_client.has_method("set_freshness_kick"): _target_client.call("set_freshness_kick", _authority, _player) - _authority.call("sync_from_server") - _target_client.call("request_sync_from_server") + _catalog.connect("catalog_ready", Callable(self, "_on_interactables_catalog_ready")) + _catalog.connect("catalog_failed", Callable(self, "_on_interactables_catalog_failed")) if _radius_preview.has_method("setup_player"): _radius_preview.call("setup_player", _player) + _catalog.call("request_catalog") + _authority.call("sync_from_server") + _target_client.call("request_sync_from_server") func _physics_process(_delta: float) -> void: @@ -329,3 +334,15 @@ func _dev_collision_shapes_set_disabled(root: Node, disabled: bool) -> void: (root as CollisionShape3D).disabled = disabled for c in root.get_children(): _dev_collision_shapes_set_disabled(c, disabled) + + +func _on_interactables_catalog_ready(descriptors: Array) -> void: + var groups: Variant = load("res://scripts/interactable_world_builder.gd").call( + "build_from_catalog", descriptors, _interactables_root, _radius_preview + ) + if _radius_preview.has_method("setup_glow_groups"): + _radius_preview.call("setup_glow_groups", groups as Array) + + +func _on_interactables_catalog_failed(_reason: String) -> void: + pass diff --git a/client/scripts/prototype_interaction_constants.gd b/client/scripts/prototype_interaction_constants.gd deleted file mode 100644 index 62bc4e6..0000000 --- a/client/scripts/prototype_interaction_constants.gd +++ /dev/null @@ -1,25 +0,0 @@ -extends Object - -## Prototype interactable anchor + reach — **must match** -## `server/NeonSprawl.Server/Game/Interaction/PrototypeInteractableRegistry.cs`. -## Static accessors only (no `class_name`; see client README / NS-18 plan). - - -static func interactable_id() -> String: - return "prototype_terminal" - - -static func anchor_x() -> float: - return 0.0 - - -static func anchor_y() -> float: - return 0.5 - - -static func anchor_z() -> float: - return 0.0 - - -static func interaction_radius() -> float: - return 3.0 diff --git a/client/scripts/prototype_interaction_constants.gd.uid b/client/scripts/prototype_interaction_constants.gd.uid deleted file mode 100644 index 15069b4..0000000 --- a/client/scripts/prototype_interaction_constants.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://c8diye55s37rr diff --git a/client/test/interactables_catalog_client_test.gd b/client/test/interactables_catalog_client_test.gd new file mode 100644 index 0000000..84872d8 --- /dev/null +++ b/client/test/interactables_catalog_client_test.gd @@ -0,0 +1,31 @@ +extends GdUnitTestSuite + +## NEO-25: parse `GET /game/world/interactables` JSON into descriptor rows. + + +func test_parse_catalog_json_orders_and_fields() -> void: + var json := ( + '{"schemaVersion":1,"interactables":[' + + '{"interactableId":"prototype_resource_node_alpha","kind":"resource_node",' + + '"anchor":{"x":5,"y":0.5,"z":5},"interactionRadius":3},' + + '{"interactableId":"prototype_terminal","kind":"terminal",' + + '"anchor":{"x":0,"y":0.5,"z":0},"interactionRadius":3}' + + "]}" + ) + var rows: Variant = load("res://scripts/interactables_catalog_client.gd").call("parse_catalog_json", json) + assert_that(rows is Array).is_true() + var arr: Array = rows + assert_that(arr.size()).is_equal(2) + var r0: Dictionary = arr[0] + var r1: Dictionary = arr[1] + assert_that(r0.get("interactableId", "")).is_equal("prototype_resource_node_alpha") + assert_that(r0.get("kind", "")).is_equal("resource_node") + var a0: Variant = r0.get("anchor", null) + assert_that(a0 is Dictionary).is_true() + assert_that(float((a0 as Dictionary).get("x", -1.0))).is_equal(5.0) + assert_that(r1.get("interactableId", "")).is_equal("prototype_terminal") + + +func test_parse_catalog_json_returns_empty_on_garbage() -> void: + var rows2: Variant = load("res://scripts/interactables_catalog_client.gd").call("parse_catalog_json", "[]") + assert_that((rows2 as Array).is_empty()).is_true() diff --git a/client/test/interaction_request_client_test.gd b/client/test/interaction_request_client_test.gd new file mode 100644 index 0000000..753ba46 --- /dev/null +++ b/client/test/interaction_request_client_test.gd @@ -0,0 +1,52 @@ +extends GdUnitTestSuite + +## NEO-25: `InteractionRequestClient` POST bodies for E vs R contract ids. + +const IxClient := preload("res://scripts/interaction_request_client.gd") + + +class MockHttpTransport: + extends Node + signal request_completed( + result: int, response_code: int, headers: PackedStringArray, body: PackedByteArray + ) + var last_url: String = "" + var last_body: String = "" + var last_method: HTTPClient.Method = HTTPClient.METHOD_GET + + func request( + url: String, + _custom_headers: PackedStringArray = PackedStringArray(), + method: HTTPClient.Method = HTTPClient.METHOD_GET, + request_data: String = "" + ) -> Error: + last_url = url + last_body = request_data + last_method = method + var body_bytes: PackedByteArray = '{"schemaVersion":1,"allowed":true}'.to_utf8_buffer() + request_completed.emit(HTTPRequest.RESULT_SUCCESS, 200, PackedStringArray(), body_bytes) + return OK + + +func _make_ix(transport: Node) -> Node: + var ix: Node = IxClient.new() + ix.set("injected_http", transport) + auto_free(transport) + auto_free(ix) + add_child(ix) + return ix + + +func test_post_terminal_body_contains_prototype_terminal() -> void: + var transport := MockHttpTransport.new() + var ix := _make_ix(transport) + ix.call("_post_interact", "prototype_terminal") + assert_that(transport.last_body).contains("prototype_terminal") + assert_that(transport.last_url).contains("/interact") + + +func test_post_resource_body_contains_resource_node_id() -> void: + var transport := MockHttpTransport.new() + var ix := _make_ix(transport) + ix.call("_post_interact", "prototype_resource_node_alpha") + assert_that(transport.last_body).contains("prototype_resource_node_alpha") diff --git a/docs/decomposition/modules/E1_M3_InteractionAndTargetingLayer.md b/docs/decomposition/modules/E1_M3_InteractionAndTargetingLayer.md index 85fd57d..f9611a7 100644 --- a/docs/decomposition/modules/E1_M3_InteractionAndTargetingLayer.md +++ b/docs/decomposition/modules/E1_M3_InteractionAndTargetingLayer.md @@ -100,8 +100,8 @@ flowchart LR ## Implementation snapshot -- **`TargetState` (prototype JSON v1 — NEO-23):** server **`GET /game/players/{id}/target`** and **`POST /game/players/{id}/target/select`** with versioned **`PlayerTargetStateResponse`** / **`TargetSelectRequest`** / **`TargetSelectResponse`**; stub registry + in-memory lock under `server/NeonSprawl.Server/Game/Targeting/`; horizontal reach + soft lock semantics — [NEO-23](../../plans/NEO-23-implementation-plan.md), [server README — Targeting](../../../server/README.md#targeting-neo-23). **Still open on later issues:** **`InteractableDescriptor`**, **`SelectionEvent`**, Godot tab-target ([NEO-24](https://linear.app/neon-sprawl/issue/NEO-24)). **Story backlog:** [E1M3 prototype backlog](../../plans/E1M3-prototype-backlog.md). -- **Precursor under E1.M1 (NEO-9):** server **`POST /game/players/{id}/interact`** with **`InteractionRequest`** / **`InteractionResponse`**; **`HorizontalReach`** (`server/NeonSprawl.Server/Game/World/HorizontalReach.cs`); prototype registry and API under `server/NeonSprawl.Server/Game/Interaction/`; client **`interaction_request_client.gd`**, **`interaction_radius_indicators.gd`**, **`prototype_interaction_constants.gd`** — see [NEO-9](../../plans/NEO-9-implementation-plan.md) and [server README — Interaction](../../../server/README.md#interaction-neo-9). +- **`TargetState` (prototype JSON v1 — NEO-23):** server **`GET /game/players/{id}/target`** and **`POST /game/players/{id}/target/select`** with versioned **`PlayerTargetStateResponse`** / **`TargetSelectRequest`** / **`TargetSelectResponse`**; stub registry + in-memory lock under `server/NeonSprawl.Server/Game/Targeting/`; horizontal reach + soft lock semantics — [NEO-23](../../plans/NEO-23-implementation-plan.md), [server README — Targeting](../../../server/README.md#targeting-neo-23). Godot tab-target: [NEO-24](https://linear.app/neon-sprawl/issue/NEO-24). **`InteractableDescriptor` list:** **`GET /game/world/interactables`** (NEO-25). **Still open on later issues:** **`SelectionEvent`**. **Story backlog:** [E1M3 prototype backlog](../../plans/E1M3-prototype-backlog.md). +- **Precursor under E1.M1 (NEO-9 + NEO-25):** server **`POST /game/players/{id}/interact`** with **`InteractionRequest`** / **`InteractionResponse`**; **`GET /game/world/interactables`** (versioned descriptor list); **`HorizontalReach`** (`server/NeonSprawl.Server/Game/World/HorizontalReach.cs`); prototype registry under `server/NeonSprawl.Server/Game/Interaction/`; client **`interactables_catalog_client.gd`**, **`interactable_world_builder.gd`**, **`interaction_request_client.gd`**, **`interaction_radius_indicators.gd`** — see [NEO-9](../../plans/NEO-9-implementation-plan.md), [NEO-25](../../plans/NEO-25-implementation-plan.md), and [server README — Interaction](../../../server/README.md#interaction-neo-9). - **Alignment:** [Documentation and implementation alignment](documentation_and_implementation_alignment.md). ## Risks and telemetry diff --git a/docs/manual-qa/NEO-25.md b/docs/manual-qa/NEO-25.md new file mode 100644 index 0000000..6b7c13a --- /dev/null +++ b/docs/manual-qa/NEO-25.md @@ -0,0 +1,20 @@ +# Manual QA — NEO-25 (InteractableDescriptor list + fetch-driven client) + +**Story:** [NEO-25](https://linear.app/neon-sprawl/issue/NEO-25) · Plan: [`docs/plans/NEO-25-implementation-plan.md`](../plans/NEO-25-implementation-plan.md) + +## Preconditions + +- Game server running (`dotnet run` from `server/NeonSprawl.Server`; note host/port). +- Godot client `base_url` matches server (default `http://127.0.0.1:5253` on catalog + interaction clients). + +## Checks + +1. **Descriptor GET:** `curl -s http://localhost:5253/game/world/interactables` returns JSON with `schemaVersion: 1`, `interactables` length ≥ 2, ascending `interactableId`, each row has `kind`, `anchor` `{x,y,z}`, `interactionRadius`. +2. **F5 with server up:** After load, **two** prototype props appear (center + **+XZ** corner) and **two pairs** of small blue glow markers (offset from each anchor). Default spawn **(-5, 0.9, -5)**: markers near **origin** glow when in 3 m radius; corner node stays dim until you walk toward **(5, 5)**. +3. **E** (`interact`): at origin in range → Output `allowed=true` for terminal; walk out past 3 m horizontal → `allowed=false`, `out_of_range`. +4. **R** (`interact_secondary`): stand within 3 m of **(5, 0.5, 5)** on X/Z → `allowed=true` for resource node; far from that anchor → `out_of_range`. +5. **Server down / wrong port:** catalog request errors in Output; scene still runs (no props / no glow groups — acceptable prototype degradation). + +## Notes + +- Registry source of truth is **C#**; client anchors come only from **`GET /game/world/interactables`**. diff --git a/docs/plans/NEO-25-implementation-plan.md b/docs/plans/NEO-25-implementation-plan.md index 5880ba6..e14c789 100644 --- a/docs/plans/NEO-25-implementation-plan.md +++ b/docs/plans/NEO-25-implementation-plan.md @@ -36,11 +36,11 @@ ## Acceptance criteria checklist -- [ ] **≥2 interactables** in **`PrototypeInteractableRegistry`** with distinct ids, anchors, radii, and **kinds** (`terminal` vs `resource_node`). -- [ ] **`GET /game/world/interactables`** returns versioned JSON whose **`interactables`** entries each include **`interactableId`**, **`anchor`** (world), **`interactionRadius`**, **`kind`**. -- [ ] **`POST /game/players/{id}/interact`** with **`InteractionRequest`** v1 succeeds for **each** id when the player is in horizontal range; denies with stable **`reasonCode`** when out of range (NEO-9 semantics preserved). -- [ ] **Godot:** preview glow + scene props are driven from the **fetched** descriptor list (no `prototype_interaction_constants.gd`). **`interact`** / **`interact_secondary`** each hit the correct id for manual QA. -- [ ] **Bruno** + **server README** document the new GET. +- [x] **≥2 interactables** in **`PrototypeInteractableRegistry`** with distinct ids, anchors, radii, and **kinds** (`terminal` vs `resource_node`). +- [x] **`GET /game/world/interactables`** returns versioned JSON whose **`interactables`** entries each include **`interactableId`**, **`anchor`** (world), **`interactionRadius`**, **`kind`**. +- [x] **`POST /game/players/{id}/interact`** with **`InteractionRequest`** v1 succeeds for **each** id when the player is in horizontal range; denies with stable **`reasonCode`** when out of range (NEO-9 semantics preserved). +- [x] **Godot:** preview glow + scene props are driven from the **fetched** descriptor list (no `prototype_interaction_constants.gd`). **`interact`** / **`interact_secondary`** each hit the correct id for manual QA. +- [x] **Bruno** + **server README** document the new GET. ## Technical approach diff --git a/docs/plans/NEO-9-implementation-plan.md b/docs/plans/NEO-9-implementation-plan.md index 026c4cd..60dd21d 100644 --- a/docs/plans/NEO-9-implementation-plan.md +++ b/docs/plans/NEO-9-implementation-plan.md @@ -58,7 +58,7 @@ The game is **3D**, but **prototype interaction and movement tuning** treat all **Prototype defaults (source of truth: C# registry)** -Authoritative values live in **`PrototypeInteractableRegistry.cs`**. **Godot** and any client preview script **must match** (duplicate constants in `prototype_interaction_constants.gd` with a comment pointing at that file). Tune by editing **C# first**, then syncing the client. +Authoritative values live in **`PrototypeInteractableRegistry.cs`**. **Historical note:** the prototype client originally duplicated anchors in `prototype_interaction_constants.gd` (NEO-9). **NEO-25** replaced that with **`GET /game/world/interactables`** + runtime spawn; tune interactables by editing **C# first**, then verifying the GET JSON and client visuals. | | Value | Rationale | |---|--------|-----------| diff --git a/server/NeonSprawl.Server.Tests/Game/Interaction/InteractablesWorldApiTests.cs b/server/NeonSprawl.Server.Tests/Game/Interaction/InteractablesWorldApiTests.cs new file mode 100644 index 0000000..0d631cf --- /dev/null +++ b/server/NeonSprawl.Server.Tests/Game/Interaction/InteractablesWorldApiTests.cs @@ -0,0 +1,43 @@ +using System.Linq; +using System.Net; +using System.Net.Http.Json; +using NeonSprawl.Server.Game.Interaction; +using Xunit; + +namespace NeonSprawl.Server.Tests.Game.Interaction; + +public class InteractablesWorldApiTests +{ + [Fact] + public async Task GetInteractables_ShouldReturnOrderedDescriptors_WithSchemaV1() + { + await using var factory = new InMemoryWebApplicationFactory(); + var client = factory.CreateClient(); + + var response = await client.GetAsync("/game/world/interactables"); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + var body = await response.Content.ReadFromJsonAsync(); + Assert.NotNull(body); + Assert.Equal(InteractablesListResponse.CurrentSchemaVersion, body!.SchemaVersion); + Assert.NotNull(body.Interactables); + Assert.True(body.Interactables.Count >= 2); + + var ids = body.Interactables.Select(static x => x.InteractableId).ToList(); + Assert.Equal(ids.OrderBy(static x => x, StringComparer.Ordinal).ToList(), ids); + + var alpha = body.Interactables.Single(x => x.InteractableId == PrototypeInteractableRegistry.PrototypeResourceNodeAlphaId); + Assert.Equal("resource_node", alpha.Kind); + Assert.Equal(5, alpha.Anchor.X); + Assert.Equal(0.5, alpha.Anchor.Y); + Assert.Equal(5, alpha.Anchor.Z); + Assert.Equal(3.0, alpha.InteractionRadius); + + var term = body.Interactables.Single(x => x.InteractableId == PrototypeInteractableRegistry.PrototypeTerminalId); + Assert.Equal("terminal", term.Kind); + Assert.Equal(0, term.Anchor.X); + Assert.Equal(0.5, term.Anchor.Y); + Assert.Equal(0, term.Anchor.Z); + Assert.Equal(3.0, term.InteractionRadius); + } +} diff --git a/server/NeonSprawl.Server.Tests/Game/Interaction/InteractionApiTests.cs b/server/NeonSprawl.Server.Tests/Game/Interaction/InteractionApiTests.cs index e8c8997..b08a1c1 100644 --- a/server/NeonSprawl.Server.Tests/Game/Interaction/InteractionApiTests.cs +++ b/server/NeonSprawl.Server.Tests/Game/Interaction/InteractionApiTests.cs @@ -230,4 +230,51 @@ public class InteractionApiTests var nearBody = await near.Content.ReadFromJsonAsync(); Assert.True(nearBody!.Allowed); } + + [Fact] + public async Task PostInteract_ShouldAllowResourceNode_WhenPlayerInRangeOfResourceAnchor() + { + await using var factory = new InMemoryWebApplicationFactory(); + var client = factory.CreateClient(); + var move = new MoveCommandRequest + { + SchemaVersion = MoveCommandRequest.CurrentSchemaVersion, + Target = new PositionVector { X = 5, Y = 0.9, Z = 5 }, + }; + Assert.Equal(HttpStatusCode.OK, (await client.PostAsJsonAsync("/game/players/dev-local-1/move", move)).StatusCode); + + var response = await client.PostAsJsonAsync( + "/game/players/dev-local-1/interact", + new InteractionRequest + { + SchemaVersion = InteractionRequest.CurrentSchemaVersion, + InteractableId = PrototypeInteractableRegistry.PrototypeResourceNodeAlphaId, + }); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + var body = await response.Content.ReadFromJsonAsync(); + Assert.NotNull(body); + Assert.True(body!.Allowed); + Assert.Equal(PrototypeInteractableRegistry.PrototypeResourceNodeAlphaId, body.InteractableId); + } + + [Fact] + public async Task PostInteract_ShouldDenyOutOfRange_ForResourceNode_WhenPlayerFar() + { + await using var factory = new InMemoryWebApplicationFactory(); + var client = factory.CreateClient(); + var response = await client.PostAsJsonAsync( + "/game/players/dev-local-1/interact", + new InteractionRequest + { + SchemaVersion = InteractionRequest.CurrentSchemaVersion, + InteractableId = PrototypeInteractableRegistry.PrototypeResourceNodeAlphaId, + }); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + var body = await response.Content.ReadFromJsonAsync(); + Assert.NotNull(body); + Assert.False(body!.Allowed); + Assert.Equal(InteractionApi.ReasonOutOfRange, body.ReasonCode); + } } diff --git a/server/NeonSprawl.Server/Game/Interaction/InteractablesListDtos.cs b/server/NeonSprawl.Server/Game/Interaction/InteractablesListDtos.cs new file mode 100644 index 0000000..934d36b --- /dev/null +++ b/server/NeonSprawl.Server/Game/Interaction/InteractablesListDtos.cs @@ -0,0 +1,35 @@ +using System.Text.Json.Serialization; +using NeonSprawl.Server.Game.PositionState; + +namespace NeonSprawl.Server.Game.Interaction; + +/// JSON body for GET /game/world/interactables (NEO-25). +public sealed class InteractablesListResponse +{ + public const int CurrentSchemaVersion = 1; + + [JsonPropertyName("schemaVersion")] + public int SchemaVersion { get; init; } = CurrentSchemaVersion; + + /// Stable ascending interactableId order (ordinal). + [JsonPropertyName("interactables")] + public required IReadOnlyList Interactables { get; init; } +} + +/// One row in the prototype interactable projection. +public sealed class InteractableDescriptorJson +{ + [JsonPropertyName("interactableId")] + public required string InteractableId { get; init; } + + /// Stable machine string for branching (e.g. terminal, resource_node). + [JsonPropertyName("kind")] + public required string Kind { get; init; } + + [JsonPropertyName("anchor")] + public required PositionVector Anchor { get; init; } + + /// Horizontal reach on X/Z; inclusive boundary (same as NEO-9). + [JsonPropertyName("interactionRadius")] + public double InteractionRadius { get; init; } +} diff --git a/server/NeonSprawl.Server/Game/Interaction/InteractablesWorldApi.cs b/server/NeonSprawl.Server/Game/Interaction/InteractablesWorldApi.cs new file mode 100644 index 0000000..2e1fb8b --- /dev/null +++ b/server/NeonSprawl.Server/Game/Interaction/InteractablesWorldApi.cs @@ -0,0 +1,23 @@ +namespace NeonSprawl.Server.Game.Interaction; + +/// Maps GET /game/world/interactables (NEO-25). +public static class InteractablesWorldApi +{ + public static WebApplication MapInteractablesWorldApi(this WebApplication app) + { + app.MapGet( + "/game/world/interactables", + () => + { + var rows = PrototypeInteractableRegistry.GetOrderedDescriptors(); + return Results.Json( + new InteractablesListResponse + { + SchemaVersion = InteractablesListResponse.CurrentSchemaVersion, + Interactables = rows, + }); + }); + + return app; + } +} diff --git a/server/NeonSprawl.Server/Game/Interaction/PrototypeInteractableRegistry.cs b/server/NeonSprawl.Server/Game/Interaction/PrototypeInteractableRegistry.cs index b97e6fc..010b5cf 100644 --- a/server/NeonSprawl.Server/Game/Interaction/PrototypeInteractableRegistry.cs +++ b/server/NeonSprawl.Server/Game/Interaction/PrototypeInteractableRegistry.cs @@ -1,22 +1,63 @@ +using System.Linq; +using NeonSprawl.Server.Game.PositionState; + namespace NeonSprawl.Server.Game.Interaction; -/// Prototype id → world anchor + reach. Keys are lowercase; lookup after trim + case fold (NS-18). +/// Prototype id → world anchor + reach + kind. Keys are lowercase; lookup after trim + case fold (NS-18). public static class PrototypeInteractableRegistry { - /// Canonical lowercase id for the single prototype row. + /// Canonical lowercase id for the center prototype row (NEO-9). public const string PrototypeTerminalId = "prototype_terminal"; - /// Source-of-truth anchor and radius; keep in sync with client/scripts/prototype_interaction_constants.gd. - public static readonly PrototypeInteractableEntry PrototypeTerminal = new(0, 0.5, 0, 3.0); + /// Second stub row for multi-entity projection (NEO-25). + public const string PrototypeResourceNodeAlphaId = "prototype_resource_node_alpha"; + + /// Source-of-truth anchor and radius; client discovers via GET /game/world/interactables (NEO-25). + public static readonly PrototypeInteractableEntry PrototypeTerminal = new(0, 0.5, 0, 3.0, "terminal"); + + /// +XZ corner walk demo; same radius as terminal for predictable reach tuning. + public static readonly PrototypeInteractableEntry PrototypeResourceNodeAlpha = new(5, 0.5, 5, 3.0, "resource_node"); private static readonly Dictionary ById = new(StringComparer.Ordinal) { [PrototypeTerminalId] = PrototypeTerminal, + [PrototypeResourceNodeAlphaId] = PrototypeResourceNodeAlpha, }; public static bool TryGet(string interactableIdLowercase, out PrototypeInteractableEntry entry) => ById.TryGetValue(interactableIdLowercase, out entry); + + /// Ascending interactableId for stable JSON and tests. + public static IReadOnlyList GetOrderedDescriptors() + { + var list = new List(ById.Count); + foreach (var kv in ById.OrderBy(static x => x.Key, StringComparer.Ordinal)) + { + var e = kv.Value; + list.Add( + new InteractableDescriptorJson + { + InteractableId = kv.Key, + Kind = e.Kind, + Anchor = new PositionVector + { + X = e.X, + Y = e.Y, + Z = e.Z, + }, + InteractionRadius = e.InteractionRadius, + }); + } + + return list; + } } /// Horizontal reach on X/Z; inclusive boundary. -public readonly record struct PrototypeInteractableEntry(double X, double Y, double Z, double InteractionRadius); +/// Stable machine category for clients and future rules (NEO-25). +public readonly record struct PrototypeInteractableEntry( + double X, + double Y, + double Z, + double InteractionRadius, + string Kind); diff --git a/server/NeonSprawl.Server/Program.cs b/server/NeonSprawl.Server/Program.cs index ee66d44..fe7e2c7 100644 --- a/server/NeonSprawl.Server/Program.cs +++ b/server/NeonSprawl.Server/Program.cs @@ -20,6 +20,7 @@ app.MapGet("/health", () => Results.Json(new app.MapPositionStateApi(); app.MapInteractionApi(); +app.MapInteractablesWorldApi(); app.MapTargetingApi(); app.Run(); diff --git a/server/README.md b/server/README.md index 48a1eb4..05a505c 100644 --- a/server/README.md +++ b/server/README.md @@ -125,6 +125,33 @@ When **`allowed` is `true`**, **`reasonCode` is omitted** (clients must not requ Contract details and PR blurb: [NEO-9 implementation plan](../../docs/plans/NEO-9-implementation-plan.md). +## Interactable descriptors (NEO-25) + +**`GET /game/world/interactables`** returns a versioned **projection** of the prototype **`PrototypeInteractableRegistry`** (no player id; global list). Use this for client discovery of anchors, radii, and **`kind`** instead of duplicating registry constants in GDScript. + +Example: + +```bash +curl -s http://localhost:5253/game/world/interactables +``` + +**HTTP** + +| Status | Meaning | +|--------|---------| +| **200** | Body is **`InteractablesListResponse`** v1: `schemaVersion` (`1`) + **`interactables`** array. | + +**`interactables[]` fields (v1)** + +| Field | Meaning | +|--------|---------| +| `interactableId` | Lowercase canonical id (matches **`POST …/interact`**). | +| `kind` | Stable machine string (`terminal`, `resource_node`, …). | +| `anchor` | Object with **`x`**, **`y`**, **`z`** (world meters). | +| `interactionRadius` | Horizontal reach on X/Z; inclusive boundary (same rule as NEO-9). | + +Rows are sorted by ascending **`interactableId`**. Plan: [NEO-25 implementation plan](../../docs/plans/NEO-25-implementation-plan.md). + ## Targeting (NEO-23) Authoritative **combat target lock** (prototype): **`GET /game/players/{id}/target`** returns **`PlayerTargetStateResponse`** v1; **`POST /game/players/{id}/target/select`** accepts **`TargetSelectRequest`** v1 and returns **`TargetSelectResponse`** v1. Player id rules match position/interact (trim + ordinal case-insensitive lookup in the in-memory store). **Horizontal lock range** uses **`HorizontalReach`** on **X/Z only** (inclusive radius), same floor-plane policy as NEO-9.