NEO-24: client tab-target + lock HUD synced to server
Adds `TargetSelectionClient` (Godot, HTTP to NEO-23 targeting endpoints): - Tab cycles ids in ascending order matching `PrototypeTargetRegistry` (`prototype_target_alpha`, `prototype_target_beta`) and wraps. - Esc clears by POSTing with `targetId` omitted. - UI always paints from the server's authoritative `targetState` (success and denials alike); `UICanvas/TargetLockLabel` shows `lockedTargetId`, `validity`, `sequence`, and (on deny) `reasonCode`. - Movement-triggered refresh: while a lock is held, subsequent `authoritative_position_received` snaps fire a throttled GET (~250 ms cooldown) so `validity` flips to `out_of_range` after locomotion without duplicating server radius math on the client. Registers `target_tab` (Tab) / `target_clear` (Esc) in `project.godot`; documents bindings + manual QA in `client/README.md`. Tests: `target_selection_client_test.gd` covers GET parse, tab ordering from empty/alpha/wrap, POST body shape, denial preserves server state, clear omits targetId, movement refresh fires only while locked, cooldown collapses bursts to one GET, and `apply_as_snap=false` is ignored. All 85 client GdUnit cases pass; gdlint and gdformat clean. See `docs/plans/NEO-24-implementation-plan.md`.pull/47/head
parent
ddfc78a044
commit
ee572e3661
|
|
@ -91,6 +91,24 @@ The main scene includes a **prototype terminal** at the map center (same world *
|
||||||
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).
|
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.
|
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.
|
||||||
|
|
||||||
|
## Target lock + tab cycle (NEO-24)
|
||||||
|
|
||||||
|
`scripts/target_selection_client.gd` wires the server targeting API from [NEO-23](../docs/plans/NEO-23-implementation-plan.md) to a tab-cycle input and a HUD overlay.
|
||||||
|
|
||||||
|
- **Bindings** (in `project.godot`): **`target_tab`** → **Tab** cycles targets in the order defined by **`scripts/prototype_target_constants.gd`** (`prototype_target_alpha`, then `prototype_target_beta`; matches `PrototypeTargetRegistry.cs` ascending id), wrapping from last → first. **`target_clear`** → **Esc** POSTs with `targetId` omitted to clear the server lock.
|
||||||
|
- **HUD:** `UICanvas/TargetLockLabel` shows the last **server-acknowledged** state: `lockedTargetId` (or **`—`** when no lock), `validity` (`none` / `ok` / `out_of_range` / `invalid_target`), `sequence`, and (on denials) `Denied: <reasonCode>` on the next line. **UI never shows an optimistic lock the server denied** — it always paints from the authoritative `targetState` echoed by each `200` response.
|
||||||
|
- **Soft lock refresh:** the client does **not** poll on a timer. It refreshes via `GET …/target` on boot, on every `POST …/target/select` response (body already carries `targetState`), and — while a lock is currently held — on **`authoritative_position_received`** from `PositionAuthorityClient`, throttled to at most one GET per **250 ms**. Boot is naturally skipped because no lock exists yet. Purely server-driven state flips (future PvP / combat categories) would require revisiting this policy; today NEO-23 has none.
|
||||||
|
- **Inspector:** match **`base_url`** / **`dev_player_id`** on the `TargetSelectionClient` node to the other HTTP clients / `Game:DevPlayerId`.
|
||||||
|
|
||||||
|
### Manual check (NEO-24)
|
||||||
|
|
||||||
|
1. Run the server and client as in NEO-7 / NEO-9; confirm spawn snap.
|
||||||
|
2. Press **Tab**: HUD → `Target: prototype_target_alpha`, `Validity: ok` (spawn is inside alpha's 8 m radius).
|
||||||
|
3. Press **Tab** again: server denies (beta anchor is **(8, 8)** with a **4 m** radius, so from spawn it is out of range). Per NEO-23 soft-lock policy the persisted lock does not change, so HUD stays at `Target: prototype_target_alpha, Validity: ok` with `Denied: out_of_range` appended.
|
||||||
|
4. **WASD** away from spawn past ~8 m horizontally from alpha; HUD flips to `Validity: out_of_range` on the next movement-triggered refresh (≤250 ms cadence) while `lockedTargetId` stays `alpha` — this is the **soft lock** round-tripped from the server.
|
||||||
|
5. Walk toward beta (inside **(8, 8)** within 4 m) and press **Tab**: swap succeeds, HUD shows `Target: prototype_target_beta, Validity: ok`. A further **Tab** from here wraps to `alpha` (and will itself succeed or deny based on distance).
|
||||||
|
6. Press **Esc**: HUD → `Target: —`, `Validity: none`, sequence advances.
|
||||||
|
|
||||||
## Movement prototype (NEO-5 → NEO-11)
|
## Movement prototype (NEO-5 → NEO-11)
|
||||||
|
|
||||||
**`player.gd`** steers **XZ** toward the authoritative goal and uses **`NavigationAgent3D`**’s **current path** only for the **vertical-routing** case above (on sufficiently flat floor); **`move_and_slide()`** does the motion. **`snap_to_server()`** remains for **boot** (and would apply for any future hard reconcile).
|
**`player.gd`** steers **XZ** toward the authoritative goal and uses **`NavigationAgent3D`**’s **current path** only for the **vertical-routing** case above (on sufficiently flat floor); **`move_and_slide()`** does the motion. **`snap_to_server()`** remains for **boot** (and would apply for any future hard reconcile).
|
||||||
|
|
|
||||||
|
|
@ -44,6 +44,16 @@ 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)
|
"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)
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
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)
|
||||||
|
]
|
||||||
|
}
|
||||||
|
target_clear={
|
||||||
|
"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":4194305,"physical_keycode":4194305,"key_label":0,"unicode":0,"location":0,"echo":false,"script":null)
|
||||||
|
]
|
||||||
|
}
|
||||||
camera_zoom_in={
|
camera_zoom_in={
|
||||||
"deadzone": 0.5,
|
"deadzone": 0.5,
|
||||||
"events": [Object(InputEventMouseButton,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"button_mask":0,"position":Vector2(0, 0),"global_position":Vector2(0, 0),"factor":1.0,"button_index":4,"canceled":false,"pressed":false,"double_click":false,"script":null)
|
"events": [Object(InputEventMouseButton,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"button_mask":0,"position":Vector2(0, 0),"global_position":Vector2(0, 0),"factor":1.0,"button_index":4,"canceled":false,"pressed":false,"double_click":false,"script":null)
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@
|
||||||
[ext_resource type="Script" uid="uid://1jimgt3d4bjj" path="res://scripts/player.gd" id="2_player"]
|
[ext_resource type="Script" uid="uid://1jimgt3d4bjj" path="res://scripts/player.gd" id="2_player"]
|
||||||
[ext_resource type="Script" uid="uid://ds5fkbscljkxi" path="res://scripts/position_authority_client.gd" id="4_auth"]
|
[ext_resource type="Script" uid="uid://ds5fkbscljkxi" path="res://scripts/position_authority_client.gd" id="4_auth"]
|
||||||
[ext_resource type="Script" uid="uid://bv0xprp660hib" path="res://scripts/interaction_request_client.gd" id="5_ix"]
|
[ext_resource type="Script" uid="uid://bv0xprp660hib" path="res://scripts/interaction_request_client.gd" id="5_ix"]
|
||||||
|
[ext_resource type="Script" uid="uid://gh6yv5p1y0vq" path="res://scripts/target_selection_client.gd" id="11_tgt_sel"]
|
||||||
[ext_resource type="Script" uid="uid://n3p4f3fky3nv" path="res://scripts/interaction_radius_indicators.gd" id="6_rad"]
|
[ext_resource type="Script" uid="uid://n3p4f3fky3nv" path="res://scripts/interaction_radius_indicators.gd" id="6_rad"]
|
||||||
[ext_resource type="Script" uid="uid://caaj3ohikfdx2" path="res://scripts/isometric_follow_camera.gd" id="7_iso_cam"]
|
[ext_resource type="Script" uid="uid://caaj3ohikfdx2" path="res://scripts/isometric_follow_camera.gd" id="7_iso_cam"]
|
||||||
[ext_resource type="Resource" path="res://resources/isometric_zoom_bands.tres" id="8_zoom_bands"]
|
[ext_resource type="Resource" path="res://resources/isometric_zoom_bands.tres" id="8_zoom_bands"]
|
||||||
|
|
@ -1111,6 +1112,9 @@ script = ExtResource("4_auth")
|
||||||
[node name="InteractionRequestClient" type="Node" parent="." unique_id=2500003]
|
[node name="InteractionRequestClient" type="Node" parent="." unique_id=2500003]
|
||||||
script = ExtResource("5_ix")
|
script = ExtResource("5_ix")
|
||||||
|
|
||||||
|
[node name="TargetSelectionClient" type="Node" parent="." unique_id=2500004]
|
||||||
|
script = ExtResource("11_tgt_sel")
|
||||||
|
|
||||||
[node name="UICanvas" type="CanvasLayer" parent="." unique_id=9000001]
|
[node name="UICanvas" type="CanvasLayer" parent="." unique_id=9000001]
|
||||||
|
|
||||||
[node name="MoveRejectLabel" type="Label" parent="UICanvas" unique_id=9000002]
|
[node name="MoveRejectLabel" type="Label" parent="UICanvas" unique_id=9000002]
|
||||||
|
|
@ -1139,3 +1143,18 @@ theme_override_font_sizes/font_size = 15
|
||||||
text = "x: —
|
text = "x: —
|
||||||
y: —
|
y: —
|
||||||
z: —"
|
z: —"
|
||||||
|
|
||||||
|
[node name="TargetLockLabel" type="Label" parent="UICanvas" unique_id=9000004]
|
||||||
|
offset_left = 8.0
|
||||||
|
offset_top = 82.0
|
||||||
|
offset_right = 280.0
|
||||||
|
offset_bottom = 150.0
|
||||||
|
grow_horizontal = 0
|
||||||
|
grow_vertical = 0
|
||||||
|
theme_override_colors/font_color = Color(0.86, 0.94, 1, 1)
|
||||||
|
theme_override_colors/font_outline_color = Color(0.08, 0.08, 0.1, 1)
|
||||||
|
theme_override_constants/outline_size = 6
|
||||||
|
theme_override_font_sizes/font_size = 15
|
||||||
|
text = "Target: —
|
||||||
|
Validity: —
|
||||||
|
Seq: —"
|
||||||
|
|
|
||||||
|
|
@ -62,6 +62,8 @@ var _dev_obstacle_smoke: Node3D
|
||||||
@onready var _radius_preview: Node3D = $World/InteractionMarkers
|
@onready var _radius_preview: Node3D = $World/InteractionMarkers
|
||||||
@onready var _move_reject_label: Label = $UICanvas/MoveRejectLabel
|
@onready var _move_reject_label: Label = $UICanvas/MoveRejectLabel
|
||||||
@onready var _player_pos_label: Label = $UICanvas/PlayerPositionLabel
|
@onready var _player_pos_label: Label = $UICanvas/PlayerPositionLabel
|
||||||
|
@onready var _target_lock_label: Label = $UICanvas/TargetLockLabel
|
||||||
|
@onready var _target_client: Node = $TargetSelectionClient
|
||||||
@onready var _floor: StaticBody3D = $World/NavigationRegion3D/Floor
|
@onready var _floor: StaticBody3D = $World/NavigationRegion3D/Floor
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -81,7 +83,15 @@ func _ready() -> void:
|
||||||
"authoritative_position_received", Callable(self, "_on_authoritative_position")
|
"authoritative_position_received", Callable(self, "_on_authoritative_position")
|
||||||
)
|
)
|
||||||
_authority.connect("move_rejected", Callable(self, "_on_move_rejected"))
|
_authority.connect("move_rejected", Callable(self, "_on_move_rejected"))
|
||||||
|
# NEO-24: movement-driven refresh — `TargetSelectionClient` no-ops on boot (no lock yet)
|
||||||
|
# and throttles bursts (see Decision 2 in NEO-24 plan).
|
||||||
|
_authority.connect(
|
||||||
|
"authoritative_position_received",
|
||||||
|
Callable(_target_client, "on_authoritative_position_snap")
|
||||||
|
)
|
||||||
|
_target_client.connect("target_state_changed", Callable(self, "_on_target_state_changed"))
|
||||||
_authority.call("sync_from_server")
|
_authority.call("sync_from_server")
|
||||||
|
_target_client.call("request_sync_from_server")
|
||||||
if _radius_preview.has_method("setup_player"):
|
if _radius_preview.has_method("setup_player"):
|
||||||
_radius_preview.call("setup_player", _player)
|
_radius_preview.call("setup_player", _player)
|
||||||
|
|
||||||
|
|
@ -146,6 +156,26 @@ func _on_authoritative_position(world: Vector3, apply_as_snap: bool) -> void:
|
||||||
_player.snap_to_server(world)
|
_player.snap_to_server(world)
|
||||||
|
|
||||||
|
|
||||||
|
## NEO-24: render the last server-acknowledged target lock to the HUD label.
|
||||||
|
## `state` carries `lockedTargetId` (String or `null`), `validity`, `sequence`, and
|
||||||
|
## (on POST responses) `selectionApplied` + optional `reasonCode`.
|
||||||
|
func _on_target_state_changed(state: Dictionary) -> void:
|
||||||
|
if not is_instance_valid(_target_lock_label):
|
||||||
|
return
|
||||||
|
var locked_variant: Variant = state.get("lockedTargetId", null)
|
||||||
|
var locked_text: String = "—"
|
||||||
|
if locked_variant is String and not (locked_variant as String).is_empty():
|
||||||
|
locked_text = locked_variant as String
|
||||||
|
var validity: String = str(state.get("validity", "none"))
|
||||||
|
var sequence: int = int(state.get("sequence", 0))
|
||||||
|
var suffix: String = ""
|
||||||
|
if state.has("reasonCode"):
|
||||||
|
suffix = "\nDenied: %s" % str(state["reasonCode"])
|
||||||
|
_target_lock_label.text = (
|
||||||
|
"Target: %s\nValidity: %s\nSeq: %d%s" % [locked_text, validity, sequence, suffix]
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
func _on_move_rejected(reason_code: String) -> void:
|
func _on_move_rejected(reason_code: String) -> void:
|
||||||
_authority_force_snap_next = true
|
_authority_force_snap_next = true
|
||||||
# Rejected stream: server state may differ; next snap is forced.
|
# Rejected stream: server state may differ; next snap is forced.
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,31 @@
|
||||||
|
extends Object
|
||||||
|
|
||||||
|
## NEO-24: prototype combat target ids in **tab cycle order** — must match
|
||||||
|
## `server/NeonSprawl.Server/Game/Targeting/PrototypeTargetRegistry.cs`
|
||||||
|
## (`PrototypeTargetAlphaId`, `PrototypeTargetBetaId`; ascending id order).
|
||||||
|
## Static accessors only (no `class_name`; headless / CI loads before editor import — see client
|
||||||
|
## README Godot CLI section).
|
||||||
|
|
||||||
|
const ORDERED_IDS: Array[String] = ["prototype_target_alpha", "prototype_target_beta"]
|
||||||
|
|
||||||
|
|
||||||
|
static func ordered_ids() -> Array[String]:
|
||||||
|
return ORDERED_IDS.duplicate()
|
||||||
|
|
||||||
|
|
||||||
|
## Returns the next id in [constant ORDERED_IDS] after [param current]. Returns the **first** id
|
||||||
|
## when [param current] is [code]null[/code], empty, or not found (start of cycle). Wraps from
|
||||||
|
## last → first.
|
||||||
|
static func next_id_after(current: Variant) -> String:
|
||||||
|
if ORDERED_IDS.is_empty():
|
||||||
|
return ""
|
||||||
|
if not current is String:
|
||||||
|
return ORDERED_IDS[0]
|
||||||
|
var current_str: String = (current as String).strip_edges()
|
||||||
|
if current_str.is_empty():
|
||||||
|
return ORDERED_IDS[0]
|
||||||
|
var idx: int = ORDERED_IDS.find(current_str)
|
||||||
|
if idx < 0:
|
||||||
|
return ORDERED_IDS[0]
|
||||||
|
var next_idx: int = (idx + 1) % ORDERED_IDS.size()
|
||||||
|
return ORDERED_IDS[next_idx]
|
||||||
|
|
@ -0,0 +1 @@
|
||||||
|
uid://bvcj2yn4v6nrh
|
||||||
|
|
@ -0,0 +1,228 @@
|
||||||
|
extends Node
|
||||||
|
|
||||||
|
## NEO-24 (E1M3-02): client tab-target + lock UI synced to server (NEO-23 `TargetState` v1).
|
||||||
|
##
|
||||||
|
## Flow:
|
||||||
|
## - `request_sync_from_server()` → `GET …/target` returns `PlayerTargetStateResponse` v1.
|
||||||
|
## - `request_tab_next()` picks the next id from `PrototypeTargetConstants.ORDERED_IDS`
|
||||||
|
## (first id if no lock) and POSTs `TargetSelectRequest` v1.
|
||||||
|
## - `request_select_target_id(id)` POSTs a specific id.
|
||||||
|
## - `request_clear_target()` POSTs with `targetId` omitted (NEO-23 clear rule).
|
||||||
|
## - Movement-triggered refresh: while a lock is held, `on_authoritative_position_snap(...)`
|
||||||
|
## fires a throttled `GET` so `validity` flips to `out_of_range` after locomotion without
|
||||||
|
## duplicating server radius math on the client (plan Decision 2).
|
||||||
|
##
|
||||||
|
## Not an `HTTPRequest` subclass — tests inject a `Node` exposing `request()` +
|
||||||
|
## `request_completed` (same pattern as `position_authority_client.gd`).
|
||||||
|
|
||||||
|
signal target_state_changed(state: Dictionary)
|
||||||
|
|
||||||
|
enum Phase { IDLE, GET, POST_SELECT }
|
||||||
|
|
||||||
|
const PrototypeTargetConstants := preload("res://scripts/prototype_target_constants.gd")
|
||||||
|
|
||||||
|
## Cooldown between movement-triggered refresh GETs. Drops further snaps inside the window so
|
||||||
|
## one `move-stream` burst does not produce one GET per snap.
|
||||||
|
const _REFRESH_COOLDOWN_MSEC: int = 250
|
||||||
|
|
||||||
|
@export var base_url: String = "http://127.0.0.1:5253"
|
||||||
|
@export var dev_player_id: String = "dev-local-1"
|
||||||
|
|
||||||
|
var _http: Node
|
||||||
|
var _busy: bool = false
|
||||||
|
var _phase: Phase = Phase.IDLE
|
||||||
|
|
||||||
|
## Last acknowledged state from the server. Mirrors `PlayerTargetStateResponse` v1:
|
||||||
|
## `schemaVersion` (int), `playerId` (String), `lockedTargetId` (String or `null`),
|
||||||
|
## `validity` (String: `none` | `ok` | `out_of_range` | `invalid_target`),
|
||||||
|
## `sequence` (int). POST responses additionally carry `selectionApplied` (bool) and an optional
|
||||||
|
## `reasonCode` (String).
|
||||||
|
var _state: Dictionary = {}
|
||||||
|
|
||||||
|
## `-_REFRESH_COOLDOWN_MSEC` so the first snap is never blocked by the window on boot.
|
||||||
|
var _last_refresh_msec: int = -_REFRESH_COOLDOWN_MSEC
|
||||||
|
|
||||||
|
|
||||||
|
func _create_http_request() -> Node:
|
||||||
|
return HTTPRequest.new()
|
||||||
|
|
||||||
|
|
||||||
|
func _ready() -> void:
|
||||||
|
_http = _create_http_request()
|
||||||
|
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)
|
||||||
|
|
||||||
|
|
||||||
|
## Matches `interaction_request_client.gd`: `_input` is more reliable than `_unhandled_input`
|
||||||
|
## inside the embedded Game dock / focus quirks. Tab is captured before UI focus navigation,
|
||||||
|
## which is fine while the prototype HUD has no focusable controls.
|
||||||
|
func _input(event: InputEvent) -> void:
|
||||||
|
if event.is_action_pressed("target_tab"):
|
||||||
|
request_tab_next()
|
||||||
|
elif event.is_action_pressed("target_clear"):
|
||||||
|
request_clear_target()
|
||||||
|
|
||||||
|
|
||||||
|
func request_sync_from_server() -> void:
|
||||||
|
if _busy:
|
||||||
|
return
|
||||||
|
_busy = true
|
||||||
|
_phase = Phase.GET
|
||||||
|
_last_refresh_msec = Time.get_ticks_msec()
|
||||||
|
var url := "%s/game/players/%s/target" % [_base_root(), _player_path_segment()]
|
||||||
|
var err: Error = _http.request(url)
|
||||||
|
if err != OK:
|
||||||
|
push_warning("TargetSelectionClient: GET failed to start (%s)" % err)
|
||||||
|
_busy = false
|
||||||
|
_phase = Phase.IDLE
|
||||||
|
|
||||||
|
|
||||||
|
func request_tab_next() -> void:
|
||||||
|
var current: Variant = _state.get("lockedTargetId", null)
|
||||||
|
var next_id: String = PrototypeTargetConstants.next_id_after(current)
|
||||||
|
if next_id.is_empty():
|
||||||
|
return
|
||||||
|
request_select_target_id(next_id)
|
||||||
|
|
||||||
|
|
||||||
|
func request_select_target_id(target_id: String) -> void:
|
||||||
|
var trimmed := target_id.strip_edges()
|
||||||
|
if trimmed.is_empty():
|
||||||
|
push_warning("TargetSelectionClient: refusing empty targetId; use request_clear_target()")
|
||||||
|
return
|
||||||
|
_post_select({"schemaVersion": 1, "targetId": trimmed})
|
||||||
|
|
||||||
|
|
||||||
|
func request_clear_target() -> void:
|
||||||
|
# NEO-23: omit `targetId` to clear. JSON `null` also works; omission keeps the body minimal.
|
||||||
|
_post_select({"schemaVersion": 1})
|
||||||
|
|
||||||
|
|
||||||
|
## Connected to `PositionAuthorityClient.authoritative_position_received` in `main.gd`.
|
||||||
|
## Boot is naturally skipped because no lock is held yet; once a lock exists, subsequent
|
||||||
|
## snaps fire a refresh GET subject to a cooldown so bursts collapse to one GET.
|
||||||
|
func on_authoritative_position_snap(_world: Vector3, apply_as_snap: bool) -> void:
|
||||||
|
if not apply_as_snap:
|
||||||
|
return
|
||||||
|
if not _has_lock():
|
||||||
|
return
|
||||||
|
if _busy:
|
||||||
|
return
|
||||||
|
var now_msec: int = Time.get_ticks_msec()
|
||||||
|
if now_msec - _last_refresh_msec < _REFRESH_COOLDOWN_MSEC:
|
||||||
|
return
|
||||||
|
request_sync_from_server()
|
||||||
|
|
||||||
|
|
||||||
|
func cached_state() -> Dictionary:
|
||||||
|
return _state.duplicate()
|
||||||
|
|
||||||
|
|
||||||
|
func _has_lock() -> bool:
|
||||||
|
if _state.is_empty():
|
||||||
|
return false
|
||||||
|
var v: Variant = _state.get("lockedTargetId", null)
|
||||||
|
return v is String and not (v as String).is_empty()
|
||||||
|
|
||||||
|
|
||||||
|
func _post_select(payload: Dictionary) -> void:
|
||||||
|
if _busy:
|
||||||
|
return
|
||||||
|
_busy = true
|
||||||
|
_phase = Phase.POST_SELECT
|
||||||
|
var url := "%s/game/players/%s/target/select" % [_base_root(), _player_path_segment()]
|
||||||
|
var body := JSON.stringify(payload)
|
||||||
|
var headers := PackedStringArray(["Content-Type: application/json"])
|
||||||
|
var err: Error = _http.request(url, headers, HTTPClient.METHOD_POST, body)
|
||||||
|
if err != OK:
|
||||||
|
push_warning("TargetSelectionClient: POST failed to start (%s)" % err)
|
||||||
|
_busy = false
|
||||||
|
_phase = Phase.IDLE
|
||||||
|
|
||||||
|
|
||||||
|
func _base_root() -> String:
|
||||||
|
return base_url.strip_edges().rstrip("/")
|
||||||
|
|
||||||
|
|
||||||
|
func _player_path_segment() -> String:
|
||||||
|
# ASCII-safe ids (e.g. `dev-local-1`); percent-encode if ids gain reserved URL characters.
|
||||||
|
return dev_player_id.strip_edges()
|
||||||
|
|
||||||
|
|
||||||
|
func _on_request_completed(
|
||||||
|
_result: int, response_code: int, _headers: PackedStringArray, body: PackedByteArray
|
||||||
|
) -> void:
|
||||||
|
var phase: Phase = _phase
|
||||||
|
_busy = false
|
||||||
|
_phase = Phase.IDLE
|
||||||
|
|
||||||
|
if _result != HTTPRequest.RESULT_SUCCESS:
|
||||||
|
push_warning("TargetSelectionClient: HTTP failed (result=%s)" % _result)
|
||||||
|
return
|
||||||
|
|
||||||
|
var text := body.get_string_from_utf8()
|
||||||
|
var parsed: Variant = JSON.parse_string(text)
|
||||||
|
if not parsed is Dictionary:
|
||||||
|
if response_code != 200:
|
||||||
|
push_warning("TargetSelectionClient: HTTP %s non-JSON body" % response_code)
|
||||||
|
return
|
||||||
|
var data: Dictionary = parsed
|
||||||
|
|
||||||
|
match phase:
|
||||||
|
Phase.GET:
|
||||||
|
if response_code == 200:
|
||||||
|
_update_state_from_get(data)
|
||||||
|
Phase.POST_SELECT:
|
||||||
|
# Server includes authoritative `targetState` on 200 (apply and denial). 400 / 404
|
||||||
|
# bodies may lack it — swallow quietly; `_state` keeps last-good.
|
||||||
|
if response_code == 200:
|
||||||
|
_update_state_from_post(data)
|
||||||
|
_:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
func _update_state_from_get(data: Dictionary) -> void:
|
||||||
|
var state := _extract_target_state_fields(data)
|
||||||
|
if state.is_empty():
|
||||||
|
return
|
||||||
|
_state = state
|
||||||
|
target_state_changed.emit(_state.duplicate())
|
||||||
|
|
||||||
|
|
||||||
|
func _update_state_from_post(data: Dictionary) -> void:
|
||||||
|
var target_state_variant: Variant = data.get("targetState", null)
|
||||||
|
if not target_state_variant is Dictionary:
|
||||||
|
push_warning("TargetSelectionClient: POST 200 missing targetState")
|
||||||
|
return
|
||||||
|
var state := _extract_target_state_fields(target_state_variant as Dictionary)
|
||||||
|
if state.is_empty():
|
||||||
|
return
|
||||||
|
state["selectionApplied"] = bool(data.get("selectionApplied", false))
|
||||||
|
var reason_variant: Variant = data.get("reasonCode", null)
|
||||||
|
if reason_variant is String and not (reason_variant as String).is_empty():
|
||||||
|
state["reasonCode"] = reason_variant as String
|
||||||
|
_state = state
|
||||||
|
target_state_changed.emit(_state.duplicate())
|
||||||
|
|
||||||
|
|
||||||
|
## Shape-only parse of the v1 target state payload (works for GET body and POST echo).
|
||||||
|
## `lockedTargetId` is normalized to either a non-empty [String] or Variant [code]null[/code].
|
||||||
|
func _extract_target_state_fields(data: Dictionary) -> Dictionary:
|
||||||
|
if not data.has("validity"):
|
||||||
|
return {}
|
||||||
|
var out: Dictionary = {}
|
||||||
|
out["schemaVersion"] = int(data.get("schemaVersion", 1))
|
||||||
|
var pid_variant: Variant = data.get("playerId", "")
|
||||||
|
out["playerId"] = (pid_variant as String) if pid_variant is String else ""
|
||||||
|
var locked_variant: Variant = data.get("lockedTargetId", null)
|
||||||
|
if locked_variant is String and not (locked_variant as String).is_empty():
|
||||||
|
out["lockedTargetId"] = locked_variant as String
|
||||||
|
else:
|
||||||
|
out["lockedTargetId"] = null
|
||||||
|
var validity_variant: Variant = data.get("validity", "none")
|
||||||
|
out["validity"] = (validity_variant as String) if validity_variant is String else "none"
|
||||||
|
out["sequence"] = int(data.get("sequence", 0))
|
||||||
|
return out
|
||||||
|
|
@ -0,0 +1 @@
|
||||||
|
uid://gh6yv5p1y0vq
|
||||||
|
|
@ -0,0 +1,223 @@
|
||||||
|
extends GdUnitTestSuite
|
||||||
|
|
||||||
|
## NEO-24: GdUnit4 suite for `TargetSelectionClient`. Mirrors
|
||||||
|
## `position_authority_client_test.gd` — mock HTTP transport enqueues JSON responses and
|
||||||
|
## (optionally) tracks request count so the movement-triggered refresh cooldown is testable.
|
||||||
|
|
||||||
|
const TargetSelectionTestDouble := preload("res://test/target_selection_test_double.gd")
|
||||||
|
|
||||||
|
const ALPHA_ID := "prototype_target_alpha"
|
||||||
|
const BETA_ID := "prototype_target_beta"
|
||||||
|
|
||||||
|
|
||||||
|
# In-process transport: not an HTTPRequest subclass (Godot 4 will not call script `request()`
|
||||||
|
# on those). Counts requests so "no lock → no GET" and "cooldown collapses snaps" are testable.
|
||||||
|
class MockHttpTransport:
|
||||||
|
extends Node
|
||||||
|
signal request_completed(
|
||||||
|
result: int, response_code: int, headers: PackedStringArray, body: PackedByteArray
|
||||||
|
)
|
||||||
|
var request_count: int = 0
|
||||||
|
var last_url: String = ""
|
||||||
|
var last_body: String = ""
|
||||||
|
var last_method: HTTPClient.Method = HTTPClient.METHOD_GET
|
||||||
|
var _queue: Array[Dictionary] = []
|
||||||
|
|
||||||
|
func enqueue(result: int, code: int, body: String) -> void:
|
||||||
|
_queue.append({"result": result, "code": code, "body": body})
|
||||||
|
|
||||||
|
func request(
|
||||||
|
url: String,
|
||||||
|
_custom_headers: PackedStringArray = PackedStringArray(),
|
||||||
|
method: HTTPClient.Method = HTTPClient.METHOD_GET,
|
||||||
|
request_data: String = ""
|
||||||
|
) -> Error:
|
||||||
|
request_count += 1
|
||||||
|
last_url = url
|
||||||
|
last_method = method
|
||||||
|
last_body = request_data
|
||||||
|
if _queue.is_empty():
|
||||||
|
return ERR_UNAVAILABLE
|
||||||
|
var r: Dictionary = _queue.pop_front()
|
||||||
|
var body_bytes: PackedByteArray = str(r["body"]).to_utf8_buffer()
|
||||||
|
request_completed.emit(r["result"], r["code"], PackedStringArray(), body_bytes)
|
||||||
|
return OK
|
||||||
|
|
||||||
|
|
||||||
|
func _make_client(http_transport: Node) -> Node:
|
||||||
|
var c: Node = TargetSelectionTestDouble.new()
|
||||||
|
c.set("injected_http", http_transport)
|
||||||
|
auto_free(http_transport)
|
||||||
|
auto_free(c)
|
||||||
|
add_child(c)
|
||||||
|
return c
|
||||||
|
|
||||||
|
|
||||||
|
func _target_state_json(locked_id_or_null: Variant, validity: String, sequence: int = 0) -> String:
|
||||||
|
var locked := "null"
|
||||||
|
if locked_id_or_null is String and not (locked_id_or_null as String).is_empty():
|
||||||
|
locked = '"%s"' % (locked_id_or_null as String)
|
||||||
|
return (
|
||||||
|
'{"schemaVersion":1,"playerId":"dev-local-1","lockedTargetId":%s,' % locked
|
||||||
|
+ '"validity":"%s","sequence":%d}' % [validity, sequence]
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
func _select_response_json(
|
||||||
|
applied: bool,
|
||||||
|
locked_id_or_null: Variant,
|
||||||
|
validity: String,
|
||||||
|
sequence: int = 0,
|
||||||
|
reason: String = ""
|
||||||
|
) -> String:
|
||||||
|
var target_state := _target_state_json(locked_id_or_null, validity, sequence)
|
||||||
|
if applied:
|
||||||
|
return '{"schemaVersion":1,"selectionApplied":true,"targetState":%s}' % target_state
|
||||||
|
var reason_segment := ""
|
||||||
|
if not reason.is_empty():
|
||||||
|
reason_segment = ',"reasonCode":"%s"' % reason
|
||||||
|
return (
|
||||||
|
'{"schemaVersion":1,"selectionApplied":false%s,"targetState":%s}'
|
||||||
|
% [reason_segment, target_state]
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
func test_sync_get_200_emits_state() -> void:
|
||||||
|
var transport := MockHttpTransport.new()
|
||||||
|
transport.enqueue(HTTPRequest.RESULT_SUCCESS, 200, _target_state_json(null, "none"))
|
||||||
|
var c := _make_client(transport)
|
||||||
|
monitor_signals(c)
|
||||||
|
c.request_sync_from_server()
|
||||||
|
assert_signal(c).is_emitted("target_state_changed")
|
||||||
|
var state: Dictionary = c.cached_state()
|
||||||
|
assert_that(state.get("validity", "")).is_equal("none")
|
||||||
|
assert_that(state.get("lockedTargetId", "non-null-sentinel")).is_null()
|
||||||
|
|
||||||
|
|
||||||
|
func test_tab_from_no_lock_selects_first_id() -> void:
|
||||||
|
var transport := MockHttpTransport.new()
|
||||||
|
transport.enqueue(
|
||||||
|
HTTPRequest.RESULT_SUCCESS, 200, _select_response_json(true, ALPHA_ID, "ok", 1)
|
||||||
|
)
|
||||||
|
var c := _make_client(transport)
|
||||||
|
monitor_signals(c)
|
||||||
|
c.request_tab_next()
|
||||||
|
assert_that(transport.last_url).contains("/target/select")
|
||||||
|
assert_that(transport.last_method).is_equal(HTTPClient.METHOD_POST)
|
||||||
|
assert_that(transport.last_body).contains('"targetId":"%s"' % ALPHA_ID)
|
||||||
|
assert_signal(c).is_emitted("target_state_changed")
|
||||||
|
assert_that(c.cached_state().get("lockedTargetId")).is_equal(ALPHA_ID)
|
||||||
|
|
||||||
|
|
||||||
|
func test_tab_from_alpha_requests_beta() -> void:
|
||||||
|
var transport := MockHttpTransport.new()
|
||||||
|
transport.enqueue(
|
||||||
|
HTTPRequest.RESULT_SUCCESS, 200, _select_response_json(true, ALPHA_ID, "ok", 1)
|
||||||
|
)
|
||||||
|
transport.enqueue(
|
||||||
|
HTTPRequest.RESULT_SUCCESS, 200, _select_response_json(true, BETA_ID, "ok", 2)
|
||||||
|
)
|
||||||
|
var c := _make_client(transport)
|
||||||
|
c.request_tab_next()
|
||||||
|
c.request_tab_next()
|
||||||
|
assert_that(transport.last_body).contains('"targetId":"%s"' % BETA_ID)
|
||||||
|
assert_that(c.cached_state().get("lockedTargetId")).is_equal(BETA_ID)
|
||||||
|
|
||||||
|
|
||||||
|
func test_tab_wraps_from_beta_to_alpha() -> void:
|
||||||
|
var transport := MockHttpTransport.new()
|
||||||
|
transport.enqueue(
|
||||||
|
HTTPRequest.RESULT_SUCCESS, 200, _select_response_json(true, ALPHA_ID, "ok", 1)
|
||||||
|
)
|
||||||
|
transport.enqueue(
|
||||||
|
HTTPRequest.RESULT_SUCCESS, 200, _select_response_json(true, BETA_ID, "ok", 2)
|
||||||
|
)
|
||||||
|
transport.enqueue(
|
||||||
|
HTTPRequest.RESULT_SUCCESS, 200, _select_response_json(true, ALPHA_ID, "ok", 3)
|
||||||
|
)
|
||||||
|
var c := _make_client(transport)
|
||||||
|
c.request_tab_next()
|
||||||
|
c.request_tab_next()
|
||||||
|
c.request_tab_next()
|
||||||
|
assert_that(transport.last_body).contains('"targetId":"%s"' % ALPHA_ID)
|
||||||
|
assert_that(c.cached_state().get("lockedTargetId")).is_equal(ALPHA_ID)
|
||||||
|
|
||||||
|
|
||||||
|
func test_denial_reflects_authoritative_target_state() -> void:
|
||||||
|
var transport := MockHttpTransport.new()
|
||||||
|
# Server denies: selectionApplied=false, lockedTargetId=null, reasonCode=out_of_range.
|
||||||
|
transport.enqueue(
|
||||||
|
HTTPRequest.RESULT_SUCCESS,
|
||||||
|
200,
|
||||||
|
_select_response_json(false, null, "none", 0, "out_of_range")
|
||||||
|
)
|
||||||
|
var c := _make_client(transport)
|
||||||
|
c.request_select_target_id(ALPHA_ID)
|
||||||
|
var state: Dictionary = c.cached_state()
|
||||||
|
assert_that(bool(state.get("selectionApplied", true))).is_false()
|
||||||
|
assert_that(state.get("reasonCode", "")).is_equal("out_of_range")
|
||||||
|
assert_that(state.get("lockedTargetId", "sentinel")).is_null()
|
||||||
|
|
||||||
|
|
||||||
|
func test_clear_issues_post_without_target_id() -> void:
|
||||||
|
var transport := MockHttpTransport.new()
|
||||||
|
transport.enqueue(HTTPRequest.RESULT_SUCCESS, 200, _select_response_json(true, null, "none", 4))
|
||||||
|
var c := _make_client(transport)
|
||||||
|
c.request_clear_target()
|
||||||
|
assert_that(transport.last_url).contains("/target/select")
|
||||||
|
assert_that(transport.last_method).is_equal(HTTPClient.METHOD_POST)
|
||||||
|
assert_that(transport.last_body).not_contains("targetId")
|
||||||
|
|
||||||
|
|
||||||
|
func test_position_snap_while_locked_fires_refresh_get() -> void:
|
||||||
|
var transport := MockHttpTransport.new()
|
||||||
|
# Step 1: lock alpha via POST.
|
||||||
|
transport.enqueue(
|
||||||
|
HTTPRequest.RESULT_SUCCESS, 200, _select_response_json(true, ALPHA_ID, "ok", 1)
|
||||||
|
)
|
||||||
|
# Step 2: movement-triggered GET shows out_of_range (soft lock).
|
||||||
|
transport.enqueue(
|
||||||
|
HTTPRequest.RESULT_SUCCESS, 200, _target_state_json(ALPHA_ID, "out_of_range", 1)
|
||||||
|
)
|
||||||
|
var c := _make_client(transport)
|
||||||
|
c.request_select_target_id(ALPHA_ID)
|
||||||
|
var count_before_snap: int = transport.request_count
|
||||||
|
c.on_authoritative_position_snap(Vector3.ZERO, true)
|
||||||
|
assert_that(transport.request_count).is_equal(count_before_snap + 1)
|
||||||
|
assert_that(c.cached_state().get("validity")).is_equal("out_of_range")
|
||||||
|
assert_that(c.cached_state().get("lockedTargetId")).is_equal(ALPHA_ID)
|
||||||
|
|
||||||
|
|
||||||
|
func test_position_snap_without_lock_fires_nothing() -> void:
|
||||||
|
var transport := MockHttpTransport.new()
|
||||||
|
var c := _make_client(transport)
|
||||||
|
c.on_authoritative_position_snap(Vector3.ZERO, true)
|
||||||
|
assert_that(transport.request_count).is_equal(0)
|
||||||
|
|
||||||
|
|
||||||
|
func test_two_snaps_within_cooldown_collapse_to_one_get() -> void:
|
||||||
|
var transport := MockHttpTransport.new()
|
||||||
|
transport.enqueue(
|
||||||
|
HTTPRequest.RESULT_SUCCESS, 200, _select_response_json(true, ALPHA_ID, "ok", 1)
|
||||||
|
)
|
||||||
|
transport.enqueue(
|
||||||
|
HTTPRequest.RESULT_SUCCESS, 200, _target_state_json(ALPHA_ID, "out_of_range", 1)
|
||||||
|
)
|
||||||
|
var c := _make_client(transport)
|
||||||
|
c.request_select_target_id(ALPHA_ID)
|
||||||
|
var count_before: int = transport.request_count
|
||||||
|
c.on_authoritative_position_snap(Vector3.ZERO, true)
|
||||||
|
c.on_authoritative_position_snap(Vector3.ZERO, true)
|
||||||
|
assert_that(transport.request_count).is_equal(count_before + 1)
|
||||||
|
|
||||||
|
|
||||||
|
func test_position_snap_apply_as_snap_false_is_ignored() -> void:
|
||||||
|
var transport := MockHttpTransport.new()
|
||||||
|
transport.enqueue(
|
||||||
|
HTTPRequest.RESULT_SUCCESS, 200, _select_response_json(true, ALPHA_ID, "ok", 1)
|
||||||
|
)
|
||||||
|
var c := _make_client(transport)
|
||||||
|
c.request_select_target_id(ALPHA_ID)
|
||||||
|
var count_before: int = transport.request_count
|
||||||
|
c.on_authoritative_position_snap(Vector3.ZERO, false)
|
||||||
|
assert_that(transport.request_count).is_equal(count_before)
|
||||||
|
|
@ -0,0 +1 @@
|
||||||
|
uid://csqopvd66fsp0
|
||||||
|
|
@ -0,0 +1,9 @@
|
||||||
|
extends "res://scripts/target_selection_client.gd"
|
||||||
|
|
||||||
|
## Test-only: inject transport node before `add_child` (NEO-24; mirrors
|
||||||
|
## `position_authority_test_double.gd`).
|
||||||
|
var injected_http: Node
|
||||||
|
|
||||||
|
|
||||||
|
func _create_http_request() -> Node:
|
||||||
|
return injected_http
|
||||||
|
|
@ -0,0 +1 @@
|
||||||
|
uid://bq6776s8atb8
|
||||||
Loading…
Reference in New Issue