247 lines
7.5 KiB
GDScript
247 lines
7.5 KiB
GDScript
extends Node
|
|
|
|
## NS-16: POST MoveCommand then GET PositionState; emits authoritative world position for the
|
|
## avatar. NS-19: failed validation (HTTP 400 + rejection body) emits `move_rejected`.
|
|
## NS-23: second signal arg — `true` = boot snap, `false` = post-move (nav goal, no teleport).
|
|
## NEO-22: `submit_stream_targets` POSTs `move-stream` (batched small moves); no VERIFY GET.
|
|
## (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 move_rejected(reason_code: String)
|
|
|
|
const MOVE_STREAM_MAX_PER_REQUEST: int = 12
|
|
|
|
enum Phase { BOOT_GET, POST_MOVE, VERIFY_GET, STREAM_POST }
|
|
|
|
@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
|
|
|
|
## Latest click target while [_busy] (POST or VERIFY in flight); coalesced so clicks are not lost.
|
|
var _pending_move_target: Variant = null
|
|
|
|
## 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()
|
|
|
|
|
|
func submit_move_target(world: Vector3) -> void:
|
|
if _busy:
|
|
_pending_move_target = world
|
|
return
|
|
_pending_move_target = null
|
|
_start_move_post(world)
|
|
|
|
|
|
## NEO-22: append samples; sends up to [constant MOVE_STREAM_MAX_PER_REQUEST] per POST when idle.
|
|
func submit_stream_targets(targets: Array) -> void:
|
|
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 n: int = mini(_stream_queue.size(), MOVE_STREAM_MAX_PER_REQUEST)
|
|
var batch: Array = []
|
|
for i in range(n):
|
|
batch.append(_stream_queue[i])
|
|
_start_stream_post(batch)
|
|
return
|
|
if _pending_move_target is Vector3:
|
|
var w: Vector3 = _pending_move_target as Vector3
|
|
_pending_move_target = null
|
|
_start_move_post(w)
|
|
|
|
|
|
func _start_move_post(world: Vector3) -> void:
|
|
_busy = true
|
|
_phase = Phase.POST_MOVE
|
|
var url := "%s/game/players/%s/move" % [_base_root(), _player_path_segment()]
|
|
var payload := {
|
|
"schemaVersion": 1,
|
|
"target": {"x": world.x, "y": world.y, "z": world.z},
|
|
}
|
|
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("PositionAuthorityClient: POST failed to start (%s)" % err)
|
|
_busy = false
|
|
_try_flush_pending()
|
|
|
|
|
|
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.POST_MOVE:
|
|
if response_code == 400:
|
|
_emit_move_rejection_if_present(text)
|
|
_busy = false
|
|
_try_flush_pending()
|
|
return
|
|
if response_code != 200:
|
|
push_warning(
|
|
"PositionAuthorityClient: move POST unexpected code %s" % response_code
|
|
)
|
|
_busy = false
|
|
_try_flush_pending()
|
|
return
|
|
_phase = Phase.VERIFY_GET
|
|
_request_get()
|
|
Phase.VERIFY_GET:
|
|
_busy = false
|
|
if response_code == 200:
|
|
_emit_position_from_response(text, false)
|
|
else:
|
|
push_warning(
|
|
"PositionAuthorityClient: VERIFY GET unexpected code %s" % response_code
|
|
)
|
|
_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
|
|
# Drop the batch we sent (validated + applied as a unit on the server).
|
|
var drop: int = mini(_stream_in_flight_count, _stream_queue.size())
|
|
if drop > 0:
|
|
_stream_queue = _stream_queue.slice(drop)
|
|
_stream_in_flight_count = 0
|
|
_busy = false
|
|
if response_code == 200:
|
|
_emit_position_from_response(text, true)
|
|
_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 parsed: Variant = JSON.parse_string(json_text)
|
|
if parsed == null:
|
|
push_warning("PositionAuthorityClient: position response JSON parse failed")
|
|
return
|
|
if not parsed is Dictionary:
|
|
push_warning("PositionAuthorityClient: position response is not a JSON object")
|
|
return
|
|
var data: Dictionary = parsed
|
|
var pos_variant: Variant = data.get("position", null)
|
|
if not pos_variant is Dictionary:
|
|
push_warning("PositionAuthorityClient: position response missing `position` object")
|
|
return
|
|
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))
|
|
authoritative_position_received.emit(Vector3(x, y, z), apply_as_snap)
|