neon-sprawl/client/scripts/quest_hud_controller.gd

508 lines
17 KiB
GDScript
Raw Blame History

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

extends Node
## NEO-122: quest progress + accept HUD wiring (extracted from main.gd).
## NEO-131: quest completion reward label on in-session completed transition.
## NEO-132: emits quest_completion_reward_transition for economy HUD refresh.
signal quest_completion_reward_transition(quest_id: String, summary: Dictionary)
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 _quest_was_active_in_session: Dictionary = {}
var _last_reward_quest_id: String = ""
var _last_reward_summary: Dictionary = {}
var _inventory_client: Node = null
var _skill_progression_client: Node = null
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,
inventory_client: Node = null,
skill_progression_client: Node = null
) -> void:
_progress_client = progress_client
_defs_client = defs_client
_item_defs_client = item_defs_client
_inventory_client = inventory_client
_skill_progression_client = skill_progression_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")
)
if is_instance_valid(_item_defs_client) and _item_defs_client.has_signal("definitions_ready"):
_item_defs_client.connect("definitions_ready", Callable(self, "_on_item_definitions_ready"))
_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)):
_mark_quest_active_in_session(quest_id)
_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 _on_item_definitions_ready(_definitions_by_id: Dictionary) -> void:
if not _last_reward_quest_id.is_empty():
_render_reward_label()
func _mark_quest_active_in_session(quest_id: String) -> void:
var qid := quest_id.strip_edges()
if not qid.is_empty():
_quest_was_active_in_session[qid] = true
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"))
if new_status == "active":
_mark_quest_active_in_session(qid)
var prev_status := str(_previous_status_by_quest.get(qid, "not_started"))
var can_detect := not boot_seed or bool(_quest_was_active_in_session.get(qid, false))
if can_detect 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 = {}
if not _last_reward_quest_id.is_empty() and not _last_reward_summary.is_empty():
quest_completion_reward_transition.emit(_last_reward_quest_id, _last_reward_summary)
_refresh_economy_hud()
func _refresh_economy_hud() -> void:
if (
is_instance_valid(_inventory_client)
and _inventory_client.has_method("request_sync_from_server")
):
_inventory_client.call("request_sync_from_server")
if (
is_instance_valid(_skill_progression_client)
and _skill_progression_client.has_method("request_sync_from_server")
):
_skill_progression_client.call("request_sync_from_server")
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)