NEO-25: world interactables GET, registry rows, fetch-driven Godot props + glow
parent
6e40af1a1a
commit
ce54fcc44f
|
|
@ -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`. 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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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,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
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,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()
|
||||
|
|
@ -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")
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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`**.
|
||||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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,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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>+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);
|
||||
|
|
|
|||
|
|
@ -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