neon-sprawl/client/scripts/position_authority_client.gd

139 lines
4.0 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).
## (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)
enum Phase { BOOT_GET, POST_MOVE, VERIFY_GET }
@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
func _create_http_request() -> Node:
return HTTPRequest.new()
func _ready() -> void:
_http = _create_http_request()
add_child(_http)
@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:
return
_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
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
func _on_request_completed(
_result: int, response_code: int, _headers: PackedStringArray, body: PackedByteArray
) -> void:
if _result != HTTPRequest.RESULT_SUCCESS:
_busy = false
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)
Phase.POST_MOVE:
if response_code == 400:
_emit_move_rejection_if_present(text)
_busy = false
return
if response_code != 200:
push_warning(
"PositionAuthorityClient: move POST unexpected code %s" % response_code
)
_busy = false
return
_phase = Phase.VERIFY_GET
_request_get()
Phase.VERIFY_GET:
_busy = false
if response_code == 200:
_emit_position_from_response(text, false)
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:
return
if not parsed is Dictionary:
return
var data: Dictionary = parsed
var pos_variant: Variant = data.get("position", null)
if not pos_variant is Dictionary:
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)