extends Node ## NEO-122: HTTP client for quest-progress GET (NEO-119) and quest accept POST (NEO-120). ## NEO-131: rows may include optional completionRewardSummary (NEO-129) on completed quests. signal quest_progress_received(snapshot: Dictionary) signal quest_sync_failed(reason: String) signal quest_accept_result_received(quest_id: String, result: Dictionary) signal quest_accept_failed(quest_id: String, 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_accept_http: Node = null var _sync_http: Node var _accept_http: Node var _sync_busy: bool = false var _sync_pending: bool = false var _accept_busy: bool = false var _current_accept_quest_id: String = "" func _ready() -> void: _sync_http = _resolve_http(injected_sync_http) _accept_http = _resolve_http(injected_accept_http) @warning_ignore("unsafe_method_access") _sync_http.request_completed.connect(_on_sync_request_completed) @warning_ignore("unsafe_method_access") _accept_http.request_completed.connect(_on_accept_request_completed) func is_accept_busy() -> bool: return _accept_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/quest-progress" % [_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("QuestProgressClient: %s" % reason) _sync_busy = false quest_sync_failed.emit(reason) _try_flush_pending_sync() ## Returns [code]true[/code] when the HTTP POST was queued. func request_accept(quest_id: String) -> bool: if _accept_busy: return false var qid := quest_id.strip_edges() if qid.is_empty(): return false _current_accept_quest_id = qid _accept_busy = true var payload: Dictionary = {"schemaVersion": SCHEMA_VERSION} var url := "%s/game/players/%s/quests/%s/accept" % [_base_root(), _player_path_segment(), qid] var headers := PackedStringArray(["Content-Type: application/json"]) var err: Error = _accept_http.request( url, headers, HTTPClient.METHOD_POST, JSON.stringify(payload) ) if err != OK: _accept_busy = false _current_accept_quest_id = "" push_warning("QuestProgressClient: POST failed to start (%s)" % err) return false return true static func completion_reward_summary(quest_id: String, snapshot: Dictionary = {}) -> Dictionary: var quests: Variant = snapshot.get("quests", null) if quests == null or not quests is Array: return {} for row_variant in quests as Array: if not row_variant is Dictionary: continue var row: Dictionary = row_variant if str(row.get("questId", "")) != quest_id: continue var summary_variant: Variant = row.get("completionRewardSummary", null) if summary_variant is Dictionary: return (summary_variant as Dictionary).duplicate(true) return {} return {} func quest_row(quest_id: String, snapshot: Dictionary = {}) -> Dictionary: var quests: Variant = snapshot.get("quests", null) if quests == null or not quests is Array: return {} for row_variant in quests as Array: if not row_variant is Dictionary: continue var row: Dictionary = row_variant if str(row.get("questId", "")) == quest_id: return row return {} 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() static func parse_quest_progress_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 quests: Variant = root.get("quests", null) if quests == null or not quests is Array: return null if str(root.get("playerId", "")).strip_edges().is_empty(): return null return root static func parse_quest_accept_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("accepted"): return null return root static func merge_quest_row_into_snapshot(snapshot: Dictionary, row: Dictionary) -> Dictionary: var qid := str(row.get("questId", "")).strip_edges() if qid.is_empty(): return snapshot.duplicate(true) if not snapshot.is_empty() else {} var merged: Dictionary if snapshot.is_empty(): merged = {"schemaVersion": SCHEMA_VERSION, "playerId": "", "quests": []} else: merged = snapshot.duplicate(true) var quests: Array = [] var quests_variant: Variant = merged.get("quests", null) if quests_variant is Array: for row_variant in quests_variant as Array: quests.append(row_variant) var replaced := false for i in quests.size(): var existing: Variant = quests[i] if existing is Dictionary and str((existing as Dictionary).get("questId", "")) == qid: quests[i] = row.duplicate(true) replaced = true break if not replaced: quests.append(row.duplicate(true)) merged["quests"] = quests return merged 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("QuestProgressClient: %s" % reason) quest_sync_failed.emit(reason) _try_flush_pending_sync() return if response_code == 404: var reason404 := "HTTP 404 (player unknown)" push_warning("QuestProgressClient: %s" % reason404) quest_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("QuestProgressClient: %s" % reason_code) quest_sync_failed.emit(reason_code) _try_flush_pending_sync() return var text := body.get_string_from_utf8() var snapshot: Variant = parse_quest_progress_json(text) if snapshot == null: var reason_json := "non-JSON body or schemaVersion mismatch" push_warning("QuestProgressClient: %s" % reason_json) quest_sync_failed.emit(reason_json) _try_flush_pending_sync() return quest_progress_received.emit(snapshot as Dictionary) _try_flush_pending_sync() func _on_accept_request_completed( result: int, response_code: int, _headers: PackedStringArray, body: PackedByteArray ) -> void: var quest_echo: String = _current_accept_quest_id _current_accept_quest_id = "" _accept_busy = false if result != HTTPRequest.RESULT_SUCCESS: var reason := "HTTP failed (result=%s)" % result push_warning("QuestProgressClient: %s" % reason) quest_accept_failed.emit(quest_echo, reason) return if response_code == 404: var reason404 := "HTTP 404 (player unknown)" push_warning("QuestProgressClient: %s" % reason404) quest_accept_failed.emit(quest_echo, reason404) return if response_code < 200 or response_code >= 300: var reason_code := "HTTP %s" % response_code push_warning("QuestProgressClient: %s" % reason_code) quest_accept_failed.emit(quest_echo, reason_code) return var text := body.get_string_from_utf8() var parsed: Variant = parse_quest_accept_json(text) if parsed == null: var reason_json := "non-JSON body or schemaVersion mismatch" push_warning("QuestProgressClient: %s" % reason_json) quest_accept_failed.emit(quest_echo, reason_json) return var data: Dictionary = parsed as Dictionary if not bool(data.get("accepted", false)): var reason_variant: Variant = data.get("reasonCode", "") var reason_str: String = reason_variant as String if reason_variant is String else "" push_warning("quest_accept_denied reasonCode=%s" % reason_str) quest_accept_result_received.emit(quest_echo, data.duplicate(true))