extends Node ## `GET` position for boot snap; `submit_stream_targets` POSTs `move-stream` (WASD locomotion). ## NS-19: failed validation (HTTP 400 + rejection body) emits `move_rejected`. ## NS-23: second signal arg on `authoritative_position_received` — always `true` (boot snap only). ## A successful `move-stream` 200 is ack-only (no snap signal; avoids RTT rubber-band). ## NEO-24: `authoritative_ack` fires on **every** server-confirmed position (BOOT_GET 200 **and** ## successful move-stream 200), giving target / cooldown-throttled systems a heartbeat during ## normal locomotion without re-introducing snap rubber-banding. ## (No `class_name` so headless/CI can load the project before `.godot` import exists.) signal authoritative_position_received(world: Vector3, apply_as_snap: bool) signal authoritative_ack(world: Vector3) signal move_rejected(reason_code: String) enum Phase { BOOT_GET, STREAM_POST } const MOVE_STREAM_MAX_PER_REQUEST: int = 12 @export var base_url: String = "http://127.0.0.1:5253" @export var dev_player_id: String = "dev-local-1" ## `HTTPRequest` at runtime; tests may inject a `Node` with `request()` + `request_completed`. var _http: Node var _busy: bool = false var _phase: Phase = Phase.BOOT_GET ## Queued world samples for NEO-22 move-stream (FIFO). var _stream_queue: Array[Vector3] = [] var _stream_in_flight_count: int = 0 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) func sync_from_server() -> void: if _busy: return _busy = true _phase = Phase.BOOT_GET _request_get() ## NEO-22: append samples; each POST sends **only the latest** queued position so slow HTTP cannot ## apply a stale multi-step chain after the capsule has already moved (rubber-band / bounce). func submit_stream_targets(targets: Array) -> void: if _busy: # In-flight POST already encodes an older chain; drop queued crumbs so we do not replay # past intent after this response lands. _stream_queue.clear() for t: Variant in targets: if t is Vector3: _stream_queue.append(t as Vector3) while _stream_queue.size() > 48: _stream_queue.pop_front() _try_flush_pending() func _try_flush_pending() -> void: if _busy: return if not _stream_queue.is_empty(): var latest: Vector3 = _stream_queue[_stream_queue.size() - 1] _stream_queue.clear() _start_stream_post([latest]) func _start_stream_post(batch: Array) -> void: if batch.is_empty(): return _busy = true _stream_in_flight_count = batch.size() _phase = Phase.STREAM_POST var targets_json: Array = [] for t: Variant in batch: if t is Vector3: var v: Vector3 = t as Vector3 targets_json.append({"x": v.x, "y": v.y, "z": v.z}) var payload := {"schemaVersion": 1, "targets": targets_json} var body := JSON.stringify(payload) var headers := PackedStringArray(["Content-Type: application/json"]) var url := "%s/game/players/%s/move-stream" % [_base_root(), _player_path_segment()] var err: Error = _http.request(url, headers, HTTPClient.METHOD_POST, body) if err != OK: push_warning("PositionAuthorityClient: move-stream POST failed to start (%s)" % err) _busy = false _stream_in_flight_count = 0 _try_flush_pending() func _base_root() -> String: return base_url.strip_edges().rstrip("/") func _player_path_segment() -> String: # ASCII-safe ids (e.g. dev-local-1); use percent-encoding if ids gain reserved URL characters. return dev_player_id.strip_edges() func _request_get() -> void: var url := "%s/game/players/%s/position" % [_base_root(), _player_path_segment()] var err: Error = _http.request(url) if err != OK: push_warning("PositionAuthorityClient: GET failed to start (%s)" % err) _busy = false _try_flush_pending() func _on_request_completed( _result: int, response_code: int, _headers: PackedStringArray, body: PackedByteArray ) -> void: if _result != HTTPRequest.RESULT_SUCCESS: push_warning("PositionAuthorityClient: HTTP failed (result=%s); releasing busy." % _result) _busy = false _stream_in_flight_count = 0 _try_flush_pending() return var text := body.get_string_from_utf8() match _phase: Phase.BOOT_GET: _busy = false if response_code == 200: _emit_position_from_response(text, true) _try_flush_pending() Phase.STREAM_POST: if response_code == 400: _emit_move_rejection_if_present(text) _stream_queue.clear() _stream_in_flight_count = 0 _busy = false sync_from_server() return if response_code != 200: push_warning( "PositionAuthorityClient: move-stream unexpected code %s" % response_code ) _stream_in_flight_count = 0 _busy = false _try_flush_pending() return _stream_in_flight_count = 0 _busy = false # Do not emit `authoritative_position_received` here. The body echoes server position # after applying the batch, which typically lags the client's integrated capsule by # roughly one RTT — snapping caused visible rubber-banding during WASD. We do emit # `authoritative_ack` (NEO-24) so cooldown-throttled consumers like # `TargetSelectionClient` get a heartbeat during normal locomotion. _emit_ack_if_position_present(text) _try_flush_pending() func _emit_move_rejection_if_present(json_text: String) -> void: var parsed: Variant = JSON.parse_string(json_text) if parsed != null and parsed is Dictionary: var data: Dictionary = parsed var code_variant: Variant = data.get("reasonCode", "") if code_variant is String: var code: String = code_variant as String if code.is_empty(): move_rejected.emit("unknown") else: move_rejected.emit(code) return move_rejected.emit("unknown") func _emit_position_from_response(json_text: String, apply_as_snap: bool) -> void: var world_variant: Variant = _parse_world_from_response(json_text, true) if world_variant == null: return var world: Vector3 = world_variant authoritative_position_received.emit(world, apply_as_snap) # NEO-24: boot / rejection-resync is also an authoritative ack for cooldown-throttled # consumers. Emit both so subscribers do not have to dedupe across two code paths. authoritative_ack.emit(world) ## NEO-24: `move-stream` 200 path — echo position is authoritative but intentionally NOT snapped ## (see rubber-band comment in `_on_request_completed`). Still emit `authoritative_ack` so ## `TargetSelectionClient` can run its throttled refresh during ordinary locomotion. func _emit_ack_if_position_present(json_text: String) -> void: var world_variant: Variant = _parse_world_from_response(json_text, false) if world_variant == null: return authoritative_ack.emit(world_variant as Vector3) ## Shared `{"position":{"x","y","z"}}` parser. `warn_on_missing` lets BOOT_GET complain about ## malformed boot responses while STREAM_POST stays quiet (some 200 bodies may legitimately ## omit `position` in future response shapes; the ack then simply does not fire). func _parse_world_from_response(json_text: String, warn_on_missing: bool) -> Variant: var parsed: Variant = JSON.parse_string(json_text) if parsed == null: if warn_on_missing: push_warning("PositionAuthorityClient: position response JSON parse failed") return null if not parsed is Dictionary: if warn_on_missing: push_warning("PositionAuthorityClient: position response is not a JSON object") return null var data: Dictionary = parsed var pos_variant: Variant = data.get("position", null) if not pos_variant is Dictionary: if warn_on_missing: push_warning("PositionAuthorityClient: position response missing `position` object") return null var pos: Dictionary = pos_variant var x: float = float(pos.get("x", 0.0)) var y: float = float(pos.get("y", 0.0)) var z: float = float(pos.get("z", 0.0)) return Vector3(x, y, z)