273 lines
10 KiB
GDScript
273 lines
10 KiB
GDScript
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_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)
|
|
|
|
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
|
|
|
|
## 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
|
|
## is as close to the visible capsule as possible when it runs the radius check. Without
|
|
## this, a Tab press right after you stop moving can be validated against the last
|
|
## 20 Hz sample (which trailed the capsule) and deny at a position that is visibly in
|
|
## range. Both refs are optional so tests that inject a plain script can still run.
|
|
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
|
|
|
|
|
|
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 = ""
|
|
# 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
|
|
_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_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) -> void:
|
|
if _busy:
|
|
return
|
|
_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
|
|
|
|
|
|
## Nudges the authority with the current capsule position so the server's stored
|
|
## snapshot is as fresh as possible when the select POST lands. Does **not** wait
|
|
## for the move-stream response — both requests race on separate connections — but
|
|
## in practice this collapses the common "Tab right after stopping" false-denial
|
|
## window because the authority flushes queued crumbs synchronously on submit.
|
|
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)
|
|
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
|