extends Node ## NEO-24 (E1M3-02): client tab-target + lock UI synced to server (NEO-23 `TargetState` v1). ## NEO-26 (E1M3-04): emits [signal selection_event] when server-acknowledged ## [code]lockedTargetId[/code] changes (see [member log_selection_events]). ## ## 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_ack(...)` fires a ## throttled `GET` so `validity` flips to `out_of_range` after locomotion without duplicating ## server radius math on the client (plan Decision 2). The ack signal fires on every ## server-confirmed position — both boot and successful `move-stream` — so a held lock is ## revalidated during normal WASD locomotion without re-introducing snap rubber-banding. ## ## 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) ## NEO-26: fired only when normalized [code]lockedTargetId[/code] changes after a GET/POST. ## Payload: [code]previous[/code] ([String] or [code]null[/code]), [code]next[/code] (same), ## [code]cause[/code] ([code]"tab"[/code] | [code]"clear"[/code] | ## [code]"server_correction"[/code]), [code]sequence[/code] ([int], from new state). ## E1.M4+ may [code]connect[/code] here without reshaping. signal selection_event(event: 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" ## When true, each [signal selection_event] is also printed to the Godot output ## (dev aid only). @export var log_selection_events: bool = false var _http: Node var _busy: bool = false var _phase: Phase = Phase.IDLE ## Optional "freshness kick": before each `POST /target/select` we ask the authority to ## submit a `move-stream` with the current player position so the server's stored snapshot ## catches up with the visible capsule. Since NEO-24 follow-up #5 the `target/select` decision ## itself is made against the `positionHint` in the request body (race-free), so the kick is ## no longer load-bearing for denial correctness — its role is to keep the stored snap fresh ## for the movement-triggered validity GET that runs on the next `authoritative_ack`. Both ## refs are optional so tests that inject a plain script can still run without wiring. var _freshness_authority: Node = null var _freshness_player: Node3D = null ## 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 ## Cause for the in-flight HTTP request (GET → [code]server_correction[/code]; ## POST → [code]tab[/code] / [code]clear[/code]). ## Set immediately before a request starts; cleared when the response is applied or dropped. var _http_selection_cause: String = "" 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 _http_selection_cause = "server_correction" _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 _http_selection_cause = "" func request_tab_next() -> void: var current: Variant = _state.get("lockedTargetId", null) var next_id: String = "" # Prefer a client-side in-range pick when the player ref is wired so Tab locks the # anchor the user is visibly standing next to instead of cycling to whatever happens # to come first in `ORDERED_IDS`. Fall back to the plain cycle order (and let the # server pick the denial reason) when nothing is in range or no player is wired. if _freshness_player != null and is_instance_valid(_freshness_player): next_id = PrototypeTargetConstants.next_in_range_id_after( current, _freshness_player.global_position ) if next_id.is_empty(): next_id = 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 var payload: Dictionary = {"schemaVersion": 1, "targetId": trimmed} _maybe_attach_position_hint(payload) _post_select(payload, "tab") func request_clear_target() -> void: # NEO-23: omit `targetId` to clear. JSON `null` also works; omission keeps the body minimal. _post_select({"schemaVersion": 1}, "clear") ## NEO-24 follow-up #5: attach the live capsule position as an advisory `positionHint` so the ## server's range check runs against what the player sees *right now* instead of the last ## `move-stream` sample the server happens to have stored. Only populated when the player ref ## is wired (via [method set_freshness_kick]) — tests and other consumers that skip the wiring ## fall through to today's stored-snap behavior. Server remains authoritative (hint is advisory ## and does not write to the position store). func _maybe_attach_position_hint(payload: Dictionary) -> void: if _freshness_player == null or not is_instance_valid(_freshness_player): return var p: Vector3 = _freshness_player.global_position payload["positionHint"] = {"x": p.x, "y": p.y, "z": p.z} ## Connected to `PositionAuthorityClient.authoritative_ack` in `main.gd`. ## Boot is naturally skipped because no lock is held yet; once a lock exists, subsequent acks ## (boot resync or `move-stream` 200) fire a refresh GET subject to a cooldown so bursts ## collapse to one GET per window. func on_authoritative_ack(_world: Vector3) -> void: 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() ## Wire the authority + player so each `POST /target/select` is preceded by a fresh ## `move-stream` submit (see `_freshness_authority`). Pass `null` for either to disable. ## Safe to call multiple times; the last wiring wins. func set_freshness_kick(authority: Node, player: Node3D) -> void: _freshness_authority = authority _freshness_player = player 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, selection_cause: String) -> void: if _busy: return _http_selection_cause = selection_cause _kick_freshness_stream() _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 _http_selection_cause = "" ## Nudges the authority with the current capsule position so the server's stored snapshot ## catches up. Paired with the `positionHint` on the select POST (follow-up #5), this keeps ## subsequent validity GETs honest; the select itself no longer depends on which request ## arrives at the server first. func _kick_freshness_stream() -> void: if _freshness_authority == null or _freshness_player == null: return if not is_instance_valid(_freshness_authority) or not is_instance_valid(_freshness_player): return if not _freshness_authority.has_method("submit_stream_targets"): return _freshness_authority.call("submit_stream_targets", [_freshness_player.global_position]) 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) _http_selection_cause = "" 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) _http_selection_cause = "" return var data: Dictionary = parsed match phase: Phase.GET: if response_code == 200: _update_state_from_get(data) else: _http_selection_cause = "" 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) else: _http_selection_cause = "" _: _http_selection_cause = "" func _update_state_from_get(data: Dictionary) -> void: var state := _extract_target_state_fields(data) var cause := _http_selection_cause _http_selection_cause = "" if state.is_empty(): return var prev: Dictionary = _state.duplicate() _state = state target_state_changed.emit(_state.duplicate()) _maybe_emit_selection_event(prev, state, cause) func _update_state_from_post(data: Dictionary) -> void: var target_state_variant: Variant = data.get("targetState", null) if not target_state_variant is Dictionary: _http_selection_cause = "" push_warning("TargetSelectionClient: POST 200 missing targetState") return var state := _extract_target_state_fields(target_state_variant as Dictionary) if state.is_empty(): _http_selection_cause = "" return var cause := _http_selection_cause _http_selection_cause = "" 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 var prev: Dictionary = _state.duplicate() _state = state target_state_changed.emit(_state.duplicate()) _maybe_emit_selection_event(prev, state, cause) ## 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 func _normalized_lock_id(state: Dictionary) -> Variant: if state.is_empty(): return null var v: Variant = state.get("lockedTargetId", null) if v is String and not (v as String).is_empty(): return v as String return null func _maybe_emit_selection_event( prev_state: Dictionary, new_state: Dictionary, cause: String ) -> void: var prev_id: Variant = _normalized_lock_id(prev_state) var next_id: Variant = _normalized_lock_id(new_state) if prev_id == next_id: return if cause.is_empty(): return var ev: Dictionary = { "previous": prev_id, "next": next_id, "cause": cause, "sequence": int(new_state.get("sequence", 0)), } selection_event.emit(ev) if log_selection_events: print("[TargetSelectionClient] SelectionEvent ", ev)