extends Node ## NEO-122: quest progress + accept HUD wiring (extracted from main.gd). ## NEO-131: quest completion reward label on in-session completed transition. const QuestProgressClient := preload("res://scripts/quest_progress_client.gd") const GATHER_QUEST_ID := "prototype_quest_gather_intro" const ACCEPT_IDLE_HINT := "Quest accept: — (Q gather / Shift+Q next)" const ACCEPT_SENDING_HINT := "Quest accept: sending…" const REWARD_HEADER := "Quest rewards:" var _progress_client: Node = null var _defs_client: Node = null var _item_defs_client: Node = null var _progress_label: Label = null var _accept_label: Label = null var _reward_label: Label = null var _last_snapshot: Dictionary = {} var _progress_error: String = "" var _defs_error: String = "" var _accept_quest_patch: Dictionary = {} var _previous_status_by_quest: Dictionary = {} var _last_reward_quest_id: String = "" var _last_reward_summary: Dictionary = {} func setup( progress_client: Node, defs_client: Node, progress_label: Label, accept_label: Label, apply_http_config: Callable, reward_label: Label = null, item_defs_client: Node = null ) -> void: _progress_client = progress_client _defs_client = defs_client _item_defs_client = item_defs_client _progress_label = progress_label _accept_label = accept_label _reward_label = reward_label if apply_http_config.is_valid(): apply_http_config.call(progress_client) apply_http_config.call(defs_client) if _progress_client.has_signal("quest_progress_received"): _progress_client.connect("quest_progress_received", Callable(self, "_on_progress_received")) if _progress_client.has_signal("quest_sync_failed"): _progress_client.connect("quest_sync_failed", Callable(self, "_on_sync_failed")) if _progress_client.has_signal("quest_accept_result_received"): _progress_client.connect( "quest_accept_result_received", Callable(self, "_on_accept_result_received") ) if _progress_client.has_signal("quest_accept_failed"): _progress_client.connect("quest_accept_failed", Callable(self, "_on_accept_failed")) if _defs_client.has_signal("definitions_ready"): _defs_client.connect("definitions_ready", Callable(self, "_on_definitions_ready")) if _defs_client.has_signal("definitions_sync_failed"): _defs_client.connect( "definitions_sync_failed", Callable(self, "_on_definitions_sync_failed") ) _render_progress_label() _render_accept_feedback_label() _render_reward_label() if _defs_client.has_method("request_sync_from_server"): _defs_client.call("request_sync_from_server") request_progress_refresh() func request_progress_refresh() -> void: if not is_instance_valid(_progress_client): return if _progress_client.has_method("request_sync_from_server"): _progress_client.call("request_sync_from_server") func try_accept_key_input(event: InputEvent) -> bool: if not event is InputEventKey: return false var k := event as InputEventKey if not k.pressed or k.echo: return false if k.physical_keycode != KEY_Q and k.keycode != KEY_Q: return false if k.shift_pressed: _request_eligible_accept() else: _request_accept(GATHER_QUEST_ID) return true func _on_progress_received(snapshot: Dictionary) -> void: _progress_error = "" _detect_and_apply_completion_transitions(snapshot) _last_snapshot = snapshot.duplicate(true) _reapply_accept_quest_patch_if_needed() _render_progress_label() _render_reward_label() func _on_sync_failed(reason: String) -> void: _progress_error = reason if _accept_quest_patch.is_empty(): _last_snapshot = {} elif _last_snapshot.is_empty(): _last_snapshot = QuestProgressClient.merge_quest_row_into_snapshot({}, _accept_quest_patch) _render_progress_label() _render_reward_label() func _on_accept_result_received(quest_id: String, result: Dictionary) -> void: if bool(result.get("accepted", false)): _render_accept_feedback_label("Quest accept: %s accepted" % _display_name(quest_id)) var quest_variant: Variant = result.get("quest", null) if quest_variant is Dictionary: _accept_quest_patch = (quest_variant as Dictionary).duplicate(true) _progress_error = "" _last_snapshot = QuestProgressClient.merge_quest_row_into_snapshot( _last_snapshot, _accept_quest_patch ) _render_progress_label() else: var rc := str(result.get("reasonCode", "")).strip_edges() if rc.is_empty(): _render_accept_feedback_label("Quest accept: denied (no reasonCode)") else: _render_accept_feedback_label("Quest accept: denied — %s" % rc) request_progress_refresh() func _on_accept_failed(_quest_id: String, reason: String) -> void: _render_accept_feedback_label("Quest accept: failed — %s" % reason) func _on_definitions_ready(_quests: Array) -> void: _defs_error = "" if not _last_snapshot.is_empty(): _render_progress_label() if not _last_reward_quest_id.is_empty(): _render_reward_label() func _on_definitions_sync_failed(reason: String) -> void: _defs_error = reason _render_progress_label() func _detect_and_apply_completion_transitions(snapshot: Dictionary) -> void: var boot_seed := _previous_status_by_quest.is_empty() var transitions: Array = [] var quests_variant: Variant = snapshot.get("quests", null) if quests_variant is Array: for row_variant in quests_variant as Array: if not row_variant is Dictionary: continue var row: Dictionary = row_variant var qid := str(row.get("questId", "")).strip_edges() if qid.is_empty(): continue var new_status := str(row.get("status", "not_started")) var prev_status := str(_previous_status_by_quest.get(qid, "not_started")) if not boot_seed and prev_status != "completed" and new_status == "completed": var summary := QuestProgressClient.completion_reward_summary(qid, snapshot) if not summary.is_empty(): transitions.append({"questId": qid, "summary": summary}) _previous_status_by_quest[qid] = new_status if transitions.is_empty(): return var ordered_ids: Array = _quest_ids_in_render_order(snapshot) var best_idx := -1 var best_transition: Dictionary = {} for transition_variant in transitions: if not transition_variant is Dictionary: continue var transition: Dictionary = transition_variant var quest_id := str(transition.get("questId", "")) var idx: int = ordered_ids.find(quest_id) if idx < 0: idx = ordered_ids.size() if idx >= best_idx: best_idx = idx best_transition = transition if best_transition.is_empty(): return _last_reward_quest_id = str(best_transition.get("questId", "")) var summary_variant: Variant = best_transition.get("summary", null) if summary_variant is Dictionary: _last_reward_summary = (summary_variant as Dictionary).duplicate(true) else: _last_reward_summary = {} func _quest_ids_in_render_order(snapshot: Dictionary) -> Array: var ordered: Array = [] var defs: Array = _defs_snapshot() if not defs.is_empty(): for def_variant in defs: if not def_variant is Dictionary: continue var qid := str((def_variant as Dictionary).get("id", "")).strip_edges() if not qid.is_empty(): ordered.append(qid) return ordered var quests_variant: Variant = snapshot.get("quests", null) if quests_variant is Array: for row_variant in quests_variant as Array: if not row_variant is Dictionary: continue var qid := str((row_variant as Dictionary).get("questId", "")).strip_edges() if not qid.is_empty(): ordered.append(qid) return ordered func _render_progress_label() -> void: if not is_instance_valid(_progress_label): return var header := "Quests:" if _last_snapshot.is_empty(): var lines: PackedStringArray = [header] if not _progress_error.is_empty(): lines.append("error — %s" % _progress_error) if not _defs_error.is_empty(): lines.append("definitions error — %s" % _defs_error) lines.append("Loading…") _progress_label.text = "\n".join(lines) return var lines: PackedStringArray = [header] if not _progress_error.is_empty(): lines.append("sync error — %s" % _progress_error) if not _defs_error.is_empty(): lines.append("definitions error — %s" % _defs_error) var defs: Array = _defs_snapshot() if defs.is_empty(): var quests: Variant = _last_snapshot.get("quests", null) if quests is Array: for row_variant in quests as Array: if not row_variant is Dictionary: continue var row: Dictionary = row_variant var qid: String = str(row.get("questId", "")) lines.append(" %s" % _format_status_line(qid, qid, row)) else: for def_variant in defs: if not def_variant is Dictionary: continue var def: Dictionary = def_variant var qid: String = str(def.get("id", "")) if qid.is_empty(): continue var row: Dictionary = _quest_row(qid) var label: String = _display_name(qid) lines.append(" %s" % _format_status_line(qid, label, row)) _progress_label.text = "\n".join(lines) func _render_reward_label() -> void: if not is_instance_valid(_reward_label): return if not _progress_error.is_empty(): _reward_label.text = "%s\n—" % REWARD_HEADER return if _last_reward_quest_id.is_empty() or _last_reward_summary.is_empty(): _reward_label.text = "%s\n—" % REWARD_HEADER return var lines: PackedStringArray = [REWARD_HEADER, _display_name(_last_reward_quest_id)] lines.append_array(_format_reward_summary_lines(_last_reward_summary)) _reward_label.text = "\n".join(lines) func _format_reward_summary_lines(summary: Dictionary) -> PackedStringArray: var lines: PackedStringArray = PackedStringArray() var items_variant: Variant = summary.get("itemGrants", null) if items_variant is Array: for grant_variant in items_variant as Array: if not grant_variant is Dictionary: continue var grant: Dictionary = grant_variant var item_id := str(grant.get("itemId", "")).strip_edges() var qty: int = int(grant.get("quantity", 0)) if item_id.is_empty() or qty <= 0: continue var label := _item_display_name(item_id) lines.append(" %s ×%d" % [label, qty]) var skills_variant: Variant = summary.get("skillXpGrants", null) if skills_variant is Array: for grant_variant in skills_variant as Array: if not grant_variant is Dictionary: continue var grant: Dictionary = grant_variant var skill_id := str(grant.get("skillId", "")).strip_edges() var amount: int = int(grant.get("amount", 0)) if skill_id.is_empty() or amount <= 0: continue lines.append(" %s +%d XP" % [_title_case_token(skill_id), amount]) if lines.is_empty(): lines.append(" (no grants)") return lines func _item_display_name(item_id: String) -> String: if not is_instance_valid(_item_defs_client): return item_id if not _item_defs_client.has_method("display_name_for"): return item_id return str(_item_defs_client.call("display_name_for", item_id)) static func _title_case_token(token: String) -> String: var trimmed := token.strip_edges() if trimmed.is_empty(): return trimmed return trimmed.substr(0, 1).to_upper() + trimmed.substr(1) func _format_status_line(_quest_id: String, display_name: String, row: Dictionary) -> String: var status: String = str(row.get("status", "not_started")) match status: "not_started": return "%s: not started" % display_name "active": var step_index: int = int(row.get("currentStepIndex", 0)) var counter_text: String = _format_objective_counters_summary( row.get("objectiveCounters", {}) ) if counter_text.is_empty(): return "%s: active step %d" % [display_name, step_index + 1] return "%s: active step %d %s" % [display_name, step_index + 1, counter_text] "completed": var completed_at: String = str(row.get("completedAt", "")).strip_edges() if completed_at.is_empty(): return "%s: completed" % display_name return "%s: completed (%s)" % [display_name, completed_at] _: return "%s: unknown status (%s)" % [display_name, status] func _format_objective_counters_summary(counters: Variant) -> String: if counters == null or not counters is Dictionary: return "" var d: Dictionary = counters if d.is_empty(): return "" var parts: PackedStringArray = PackedStringArray() for key in d.keys(): parts.append("%s=%s" % [str(key), str(d[key])]) return "(%s)" % ", ".join(parts) func _render_accept_feedback_label(text: String = ACCEPT_IDLE_HINT) -> void: if is_instance_valid(_accept_label): _accept_label.text = text func _quest_row(quest_id: String) -> Dictionary: if not is_instance_valid(_progress_client): return {} if not _progress_client.has_method("quest_row"): return {} var row: Variant = _progress_client.call("quest_row", quest_id, _last_snapshot) return row as Dictionary func _reapply_accept_quest_patch_if_needed() -> void: if _accept_quest_patch.is_empty(): return var patch_id := str(_accept_quest_patch.get("questId", "")).strip_edges() if patch_id.is_empty(): return var row: Dictionary = _quest_row(patch_id) if str(row.get("status", "not_started")) == "not_started": _last_snapshot = QuestProgressClient.merge_quest_row_into_snapshot( _last_snapshot, _accept_quest_patch ) else: _accept_quest_patch = {} func _display_name(quest_id: String) -> String: if not is_instance_valid(_defs_client): return quest_id if not _defs_client.has_method("display_name_for"): return quest_id return str(_defs_client.call("display_name_for", quest_id)) func _defs_snapshot() -> Array: if not is_instance_valid(_defs_client): return [] if not _defs_client.has_method("quests_snapshot"): return [] return _defs_client.call("quests_snapshot") as Array func _find_first_eligible_quest_id() -> String: var defs: Array = _defs_snapshot() for def_variant in defs: if not def_variant is Dictionary: continue var def: Dictionary = def_variant var qid: String = str(def.get("id", "")) if qid.is_empty(): continue if _quest_status(qid) != "not_started": continue var prereqs: Variant = def.get("prerequisiteQuestIds", []) if not prereqs is Array: continue var prereqs_met := true for p_variant in prereqs as Array: var pid: String = str(p_variant) if _quest_status(pid) != "completed": prereqs_met = false break if prereqs_met: return qid return "" func _quest_status(quest_id: String) -> String: var row: Dictionary = _quest_row(quest_id) if row.is_empty(): return "not_started" return str(row.get("status", "not_started")) func _request_accept(quest_id: String) -> bool: if not is_instance_valid(_progress_client): _render_accept_feedback_label("Quest accept: failed — client unavailable") return false if ( _progress_client.has_method("is_accept_busy") and bool(_progress_client.call("is_accept_busy")) ): _render_accept_feedback_label("Quest accept: busy — try again") return false if not _progress_client.has_method("request_accept"): _render_accept_feedback_label("Quest accept: failed — client unavailable") return false var started: bool = bool(_progress_client.call("request_accept", quest_id)) if started: _render_accept_feedback_label(ACCEPT_SENDING_HINT) else: _render_accept_feedback_label("Quest accept: failed — could not start") return started func _request_eligible_accept() -> void: if not _progress_error.is_empty(): _render_accept_feedback_label("Quest accept: progress error — try again") return if _progress_error.is_empty() and _last_snapshot.is_empty(): _render_accept_feedback_label("Quest accept: progress loading — try again") return if not _defs_error.is_empty() and _defs_snapshot().is_empty(): _render_accept_feedback_label("Quest accept: definitions error — try again") return if _defs_error.is_empty() and _defs_snapshot().is_empty(): _render_accept_feedback_label("Quest accept: definitions loading — try again") return var eligible_id: String = _find_first_eligible_quest_id() if eligible_id.is_empty(): _render_accept_feedback_label("Quest accept: no eligible quest") return _request_accept(eligible_id)