extends Node ## NEO-153: HTTP client for contract list GET (NEO-151) and contract issue POST (NEO-151). signal contracts_received(snapshot: Dictionary) signal contracts_sync_failed(reason: String) signal contract_issue_result_received(result: Dictionary) signal contract_issue_failed(reason: String) const SCHEMA_VERSION := 1 @export var base_url: String = "http://127.0.0.1:5253" @export var dev_player_id: String = "dev-local-1" @export var injected_sync_http: Node = null @export var injected_issue_http: Node = null var _sync_http: Node var _issue_http: Node var _sync_busy: bool = false var _sync_pending: bool = false var _issue_busy: bool = false func _ready() -> void: _sync_http = _resolve_http(injected_sync_http) _issue_http = _resolve_http(injected_issue_http) @warning_ignore("unsafe_method_access") _sync_http.request_completed.connect(_on_sync_request_completed) @warning_ignore("unsafe_method_access") _issue_http.request_completed.connect(_on_issue_request_completed) func is_issue_busy() -> bool: return _issue_busy func request_sync_from_server() -> void: if _sync_busy: _sync_pending = true return _start_sync_request() func _start_sync_request() -> void: _sync_busy = true var url := "%s/game/players/%s/contracts" % [_base_root(), _player_path_segment()] var err: Error = _sync_http.request(url) if err != OK: var reason := "GET failed to start (%s)" % err push_warning("ContractClient: %s" % reason) _sync_busy = false contracts_sync_failed.emit(reason) _try_flush_pending_sync() ## Returns [code]true[/code] when the HTTP POST was queued. func request_issue(template_id: String, seed_bucket: String) -> bool: if _issue_busy: return false var tid := template_id.strip_edges() var seed := seed_bucket.strip_edges() if tid.is_empty() or seed.is_empty(): return false _issue_busy = true var payload: Dictionary = { "schemaVersion": SCHEMA_VERSION, "playerId": _player_path_segment(), "templateId": tid, "seedBucket": seed, } var url := "%s/game/players/%s/contracts/issue" % [_base_root(), _player_path_segment()] var headers := PackedStringArray(["Content-Type: application/json"]) var err: Error = _issue_http.request( url, headers, HTTPClient.METHOD_POST, JSON.stringify(payload) ) if err != OK: _issue_busy = false push_warning("ContractClient: POST failed to start (%s)" % err) return false return true static func active_contract_row(snapshot: Dictionary = {}) -> Dictionary: var contracts: Variant = snapshot.get("contracts", null) if contracts == null or not contracts is Array: return {} for row_variant in contracts as Array: if not row_variant is Dictionary: continue var row: Dictionary = row_variant if str(row.get("status", "")) == "active": return row.duplicate(true) return {} static func contract_row(instance_id: String, snapshot: Dictionary = {}) -> Dictionary: var iid := instance_id.strip_edges() if iid.is_empty(): return {} var contracts: Variant = snapshot.get("contracts", null) if contracts == null or not contracts is Array: return {} for row_variant in contracts as Array: if not row_variant is Dictionary: continue var row: Dictionary = row_variant if str(row.get("contractInstanceId", "")) == iid: return row.duplicate(true) return {} static func completion_reward_summary(instance_id: String, snapshot: Dictionary = {}) -> Dictionary: var row: Dictionary = contract_row(instance_id, snapshot) if row.is_empty() or str(row.get("status", "")) != "completed": return {} var summary_variant: Variant = row.get("completionRewardSummary", null) if summary_variant is Dictionary: return (summary_variant as Dictionary).duplicate(true) return {} static func parse_contracts_json(text: String) -> Variant: var parsed: Variant = JSON.parse_string(text) if not parsed is Dictionary: return null var root: Dictionary = parsed if int(root.get("schemaVersion", -1)) != SCHEMA_VERSION: return null var contracts: Variant = root.get("contracts", null) if contracts == null or not contracts is Array: return null if str(root.get("playerId", "")).strip_edges().is_empty(): return null return root static func parse_contract_issue_json(text: String) -> Variant: var parsed: Variant = JSON.parse_string(text) if not parsed is Dictionary: return null var root: Dictionary = parsed if int(root.get("schemaVersion", -1)) != SCHEMA_VERSION: return null if not root.has("issued"): return null return root static func merge_contract_row_into_snapshot(snapshot: Dictionary, row: Dictionary) -> Dictionary: var iid := str(row.get("contractInstanceId", "")).strip_edges() if iid.is_empty(): return snapshot.duplicate(true) if not snapshot.is_empty() else {} var merged: Dictionary if snapshot.is_empty(): merged = {"schemaVersion": SCHEMA_VERSION, "playerId": "", "contracts": []} else: merged = snapshot.duplicate(true) var contracts: Array = [] var contracts_variant: Variant = merged.get("contracts", null) if contracts_variant is Array: for row_variant in contracts_variant as Array: contracts.append(row_variant) var replaced := false for i in contracts.size(): var existing: Variant = contracts[i] if ( existing is Dictionary and str((existing as Dictionary).get("contractInstanceId", "")) == iid ): contracts[i] = row.duplicate(true) replaced = true break if not replaced: contracts.append(row.duplicate(true)) merged["contracts"] = contracts return merged func _resolve_http(injected: Node) -> Node: if injected != null: return injected var http := HTTPRequest.new() add_child(http) http.timeout = 30.0 return http func _base_root() -> String: return base_url.strip_edges().rstrip("/") func _player_path_segment() -> String: return dev_player_id.strip_edges() func _try_flush_pending_sync() -> void: if not _sync_pending or _sync_busy: return _sync_pending = false _start_sync_request() func _on_sync_request_completed( result: int, response_code: int, _headers: PackedStringArray, body: PackedByteArray ) -> void: _sync_busy = false if result != HTTPRequest.RESULT_SUCCESS: var reason := "HTTP failed (result=%s)" % result push_warning("ContractClient: %s" % reason) contracts_sync_failed.emit(reason) _try_flush_pending_sync() return if response_code == 404: var reason404 := "HTTP 404 (player unknown)" push_warning("ContractClient: %s" % reason404) contracts_sync_failed.emit(reason404) _try_flush_pending_sync() return if response_code < 200 or response_code >= 300: var reason_code := "HTTP %s" % response_code push_warning("ContractClient: %s" % reason_code) contracts_sync_failed.emit(reason_code) _try_flush_pending_sync() return var text := body.get_string_from_utf8() var snapshot: Variant = parse_contracts_json(text) if snapshot == null: var reason_json := "non-JSON body or schemaVersion mismatch" push_warning("ContractClient: %s" % reason_json) contracts_sync_failed.emit(reason_json) _try_flush_pending_sync() return contracts_received.emit(snapshot as Dictionary) _try_flush_pending_sync() func _on_issue_request_completed( result: int, response_code: int, _headers: PackedStringArray, body: PackedByteArray ) -> void: _issue_busy = false if result != HTTPRequest.RESULT_SUCCESS: var reason := "HTTP failed (result=%s)" % result push_warning("ContractClient: %s" % reason) contract_issue_failed.emit(reason) return if response_code == 404: var reason404 := "HTTP 404 (player unknown)" push_warning("ContractClient: %s" % reason404) contract_issue_failed.emit(reason404) return if response_code < 200 or response_code >= 300: var reason_code := "HTTP %s" % response_code push_warning("ContractClient: %s" % reason_code) contract_issue_failed.emit(reason_code) return var text := body.get_string_from_utf8() var parsed: Variant = parse_contract_issue_json(text) if parsed == null: var reason_json := "non-JSON body or schemaVersion mismatch" push_warning("ContractClient: %s" % reason_json) contract_issue_failed.emit(reason_json) return var data: Dictionary = parsed as Dictionary if not bool(data.get("issued", false)): var reason_variant: Variant = data.get("reasonCode", "") var reason_str: String = reason_variant as String if reason_variant is String else "" push_warning("contract_issue_denied reasonCode=%s" % reason_str) contract_issue_result_received.emit(data.duplicate(true))