From ee572e366111e98d9b7087913f084c6475bf5cab Mon Sep 17 00:00:00 2001 From: VinPropane Date: Tue, 21 Apr 2026 22:14:16 -0400 Subject: [PATCH] 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`. --- client/README.md | 18 ++ client/project.godot | 10 + client/scenes/main.tscn | 19 ++ client/scripts/main.gd | 30 +++ client/scripts/prototype_target_constants.gd | 31 +++ .../scripts/prototype_target_constants.gd.uid | 1 + client/scripts/target_selection_client.gd | 228 ++++++++++++++++++ client/scripts/target_selection_client.gd.uid | 1 + client/test/target_selection_client_test.gd | 223 +++++++++++++++++ .../test/target_selection_client_test.gd.uid | 1 + client/test/target_selection_test_double.gd | 9 + .../test/target_selection_test_double.gd.uid | 1 + 12 files changed, 572 insertions(+) create mode 100644 client/scripts/prototype_target_constants.gd create mode 100644 client/scripts/prototype_target_constants.gd.uid create mode 100644 client/scripts/target_selection_client.gd create mode 100644 client/scripts/target_selection_client.gd.uid create mode 100644 client/test/target_selection_client_test.gd create mode 100644 client/test/target_selection_client_test.gd.uid create mode 100644 client/test/target_selection_test_double.gd create mode 100644 client/test/target_selection_test_double.gd.uid diff --git a/client/README.md b/client/README.md index e0bf5ae..49c07ae 100644 --- a/client/README.md +++ b/client/README.md @@ -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). 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: ` 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) **`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). diff --git a/client/project.godot b/client/project.godot index 05e57ce..29dfcbd 100644 --- a/client/project.godot +++ b/client/project.godot @@ -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) ] } +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={ "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) diff --git a/client/scenes/main.tscn b/client/scenes/main.tscn index cd0fc52..47ec26b 100644 --- a/client/scenes/main.tscn +++ b/client/scenes/main.tscn @@ -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://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://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://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"] @@ -1111,6 +1112,9 @@ script = ExtResource("4_auth") [node name="InteractionRequestClient" type="Node" parent="." unique_id=2500003] 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="MoveRejectLabel" type="Label" parent="UICanvas" unique_id=9000002] @@ -1139,3 +1143,18 @@ theme_override_font_sizes/font_size = 15 text = "x: — y: — 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: —" diff --git a/client/scripts/main.gd b/client/scripts/main.gd index 438bf09..63c69c3 100644 --- a/client/scripts/main.gd +++ b/client/scripts/main.gd @@ -62,6 +62,8 @@ var _dev_obstacle_smoke: Node3D @onready var _radius_preview: Node3D = $World/InteractionMarkers @onready var _move_reject_label: Label = $UICanvas/MoveRejectLabel @onready var _player_pos_label: Label = $UICanvas/PlayerPositionLabel +@onready var _target_lock_label: Label = $UICanvas/TargetLockLabel +@onready var _target_client: Node = $TargetSelectionClient @onready var _floor: StaticBody3D = $World/NavigationRegion3D/Floor @@ -81,7 +83,15 @@ func _ready() -> void: "authoritative_position_received", Callable(self, "_on_authoritative_position") ) _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") + _target_client.call("request_sync_from_server") if _radius_preview.has_method("setup_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) +## 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: _authority_force_snap_next = true # Rejected stream: server state may differ; next snap is forced. diff --git a/client/scripts/prototype_target_constants.gd b/client/scripts/prototype_target_constants.gd new file mode 100644 index 0000000..e47e477 --- /dev/null +++ b/client/scripts/prototype_target_constants.gd @@ -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] diff --git a/client/scripts/prototype_target_constants.gd.uid b/client/scripts/prototype_target_constants.gd.uid new file mode 100644 index 0000000..dfdcbbd --- /dev/null +++ b/client/scripts/prototype_target_constants.gd.uid @@ -0,0 +1 @@ +uid://bvcj2yn4v6nrh diff --git a/client/scripts/target_selection_client.gd b/client/scripts/target_selection_client.gd new file mode 100644 index 0000000..3bc86b2 --- /dev/null +++ b/client/scripts/target_selection_client.gd @@ -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 diff --git a/client/scripts/target_selection_client.gd.uid b/client/scripts/target_selection_client.gd.uid new file mode 100644 index 0000000..b1b0e9c --- /dev/null +++ b/client/scripts/target_selection_client.gd.uid @@ -0,0 +1 @@ +uid://gh6yv5p1y0vq diff --git a/client/test/target_selection_client_test.gd b/client/test/target_selection_client_test.gd new file mode 100644 index 0000000..3e9a802 --- /dev/null +++ b/client/test/target_selection_client_test.gd @@ -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) diff --git a/client/test/target_selection_client_test.gd.uid b/client/test/target_selection_client_test.gd.uid new file mode 100644 index 0000000..5a94851 --- /dev/null +++ b/client/test/target_selection_client_test.gd.uid @@ -0,0 +1 @@ +uid://csqopvd66fsp0 diff --git a/client/test/target_selection_test_double.gd b/client/test/target_selection_test_double.gd new file mode 100644 index 0000000..8c2105c --- /dev/null +++ b/client/test/target_selection_test_double.gd @@ -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 diff --git a/client/test/target_selection_test_double.gd.uid b/client/test/target_selection_test_double.gd.uid new file mode 100644 index 0000000..19d5433 --- /dev/null +++ b/client/test/target_selection_test_double.gd.uid @@ -0,0 +1 @@ +uid://bq6776s8atb8