From 1a2478b2897a28834c711b840f5c9352e3d67c23 Mon Sep 17 00:00:00 2001 From: VinPropane Date: Sun, 7 Jun 2026 22:59:17 -0400 Subject: [PATCH] NEO-131: Add quest completion reward HUD in Godot client. Parse completionRewardSummary from quest-progress GET and paint QuestRewardDeliveryLabel on in-session completion transitions. --- client/scenes/main.tscn | 11 ++ client/scripts/main.gd | 5 +- client/scripts/quest_hud_controller.gd | 145 +++++++++++++++++++++- client/scripts/quest_progress_client.gd | 18 +++ client/test/quest_hud_controller_test.gd | 91 ++++++++++++++ client/test/quest_progress_client_test.gd | 32 +++++ 6 files changed, 300 insertions(+), 2 deletions(-) diff --git a/client/scenes/main.tscn b/client/scenes/main.tscn index e102e31..0b0f03e 100644 --- a/client/scenes/main.tscn +++ b/client/scenes/main.tscn @@ -1260,6 +1260,17 @@ theme_override_font_sizes/font_size = 22 autowrap_mode = 3 text = "Quest accept: — (Q gather / Shift+Q next)" +[node name="QuestRewardDeliveryLabel" type="Label" parent="UICanvas/HudRootScroll/HudRoot" unique_id=9000027] +layout_mode = 2 +size_flags_horizontal = 3 +theme_override_colors/font_color = Color(0.78, 0.95, 0.82, 1) +theme_override_colors/font_outline_color = Color(0.08, 0.08, 0.1, 1) +theme_override_constants/outline_size = 8 +theme_override_font_sizes/font_size = 22 +autowrap_mode = 3 +text = "Quest rewards: +—" + [node name="NpcStateLabel" type="Label" parent="UICanvas/HudRootScroll/HudRoot" unique_id=9000020] layout_mode = 2 size_flags_horizontal = 3 diff --git a/client/scripts/main.gd b/client/scripts/main.gd index c7e8a18..0e17cd2 100644 --- a/client/scripts/main.gd +++ b/client/scripts/main.gd @@ -136,6 +136,7 @@ var _dev_pulse_bootstrap_attempted: bool = false @onready var _encounter_complete_label: Label = _hud_root.get_node("EncounterCompleteLabel") @onready var _quest_progress_label: Label = _hud_root.get_node("QuestProgressLabel") @onready var _quest_accept_feedback_label: Label = _hud_root.get_node("QuestAcceptFeedbackLabel") +@onready var _quest_reward_delivery_label: Label = _hud_root.get_node("QuestRewardDeliveryLabel") @onready var _npc_state_label: Label = _hud_root.get_node("NpcStateLabel") @onready var _telegraph_label: Label = _hud_root.get_node("TelegraphLabel") @onready var _cooldown_slots_label: Label = _hud_root.get_node("CooldownSlotsLabel") @@ -862,7 +863,9 @@ func _setup_quest_progress_sync() -> void: _quest_defs_client, _quest_progress_label, _quest_accept_feedback_label, - Callable(self, "_apply_authority_http_config_to_client") + Callable(self, "_apply_authority_http_config_to_client"), + _quest_reward_delivery_label, + _item_defs_client ) diff --git a/client/scripts/quest_hud_controller.gd b/client/scripts/quest_hud_controller.gd index 450b479..8328ab0 100644 --- a/client/scripts/quest_hud_controller.gd +++ b/client/scripts/quest_hud_controller.gd @@ -1,20 +1,27 @@ 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( @@ -22,12 +29,16 @@ func setup( defs_client: Node, progress_label: Label, accept_label: Label, - apply_http_config: Callable + 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) @@ -49,6 +60,7 @@ func setup( ) _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() @@ -78,9 +90,11 @@ func try_accept_key_input(event: InputEvent) -> bool: 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: @@ -90,6 +104,7 @@ func _on_sync_failed(reason: String) -> void: 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: @@ -120,6 +135,8 @@ 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: @@ -127,6 +144,73 @@ func _on_definitions_sync_failed(reason: String) -> void: _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 @@ -169,6 +253,65 @@ func _render_progress_label() -> void: _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: diff --git a/client/scripts/quest_progress_client.gd b/client/scripts/quest_progress_client.gd index e2f7fe6..cb084db 100644 --- a/client/scripts/quest_progress_client.gd +++ b/client/scripts/quest_progress_client.gd @@ -1,6 +1,7 @@ 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) @@ -77,6 +78,23 @@ func request_accept(quest_id: String) -> bool: 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: diff --git a/client/test/quest_hud_controller_test.gd b/client/test/quest_hud_controller_test.gd index d7a3338..5cc2f55 100644 --- a/client/test/quest_hud_controller_test.gd +++ b/client/test/quest_hud_controller_test.gd @@ -475,3 +475,94 @@ func test_definitions_ready_repaints_with_display_names_when_progress_error() -> # Assert assert_str(progress_label.text).contains("Intro: Salvage Run") assert_str(progress_label.text).not_contains("prototype_quest_gather_intro") + + +func _active_gather_snapshot() -> Dictionary: + return { + "schemaVersion": 1, + "playerId": "dev-local-1", + "quests": + [ + { + "questId": QuestHudController.GATHER_QUEST_ID, + "status": "active", + "currentStepIndex": 0, + "objectiveCounters": {"gather_intro_obj_scrap": 2}, + } + ], + } + + +func _completed_gather_with_summary_snapshot() -> Dictionary: + return { + "schemaVersion": 1, + "playerId": "dev-local-1", + "quests": + [ + { + "questId": QuestHudController.GATHER_QUEST_ID, + "status": "completed", + "currentStepIndex": 0, + "objectiveCounters": {}, + "completedAt": "2026-06-07T12:00:00Z", + "completionRewardSummary": + { + "itemGrants": [], + "skillXpGrants": [{"skillId": "salvage", "amount": 25}], + }, + } + ], + } + + +func test_completion_transition_paints_reward_label_with_skill_xp() -> void: + # Arrange + var controller: Node = QuestHudController.new() + var progress_label := Label.new() + var reward_label := Label.new() + auto_free(controller) + auto_free(progress_label) + auto_free(reward_label) + add_child(controller) + controller.set("_progress_label", progress_label) + controller.set("_reward_label", reward_label) + # Act + controller.call("_on_progress_received", _active_gather_snapshot()) + controller.call("_on_progress_received", _completed_gather_with_summary_snapshot()) + # Assert + assert_str(reward_label.text).contains("Quest rewards:") + assert_str(reward_label.text).contains("Salvage +25 XP") + + +func test_boot_completed_snapshot_does_not_paint_reward_label() -> void: + # Arrange + var controller: Node = QuestHudController.new() + var reward_label := Label.new() + auto_free(controller) + auto_free(reward_label) + add_child(controller) + controller.set("_reward_label", reward_label) + # Act + controller.call("_on_progress_received", _completed_gather_with_summary_snapshot()) + # Assert + assert_str(reward_label.text).is_equal("Quest rewards:\n—") + + +func test_sync_failure_shows_progress_error_and_idle_reward_label() -> void: + # Arrange + var controller: Node = QuestHudController.new() + var progress_label := Label.new() + var reward_label := Label.new() + auto_free(controller) + auto_free(progress_label) + auto_free(reward_label) + add_child(controller) + controller.set("_progress_label", progress_label) + controller.set("_reward_label", reward_label) + controller.set("_last_snapshot", _active_gather_snapshot()) + controller.call("_on_progress_received", _completed_gather_with_summary_snapshot()) + # Act + controller.call("_on_sync_failed", "HTTP 503") + # Assert + assert_str(progress_label.text).contains("error — HTTP 503") + assert_str(reward_label.text).is_equal("Quest rewards:\n—") diff --git a/client/test/quest_progress_client_test.gd b/client/test/quest_progress_client_test.gd index 9b31a66..616c5b0 100644 --- a/client/test/quest_progress_client_test.gd +++ b/client/test/quest_progress_client_test.gd @@ -75,6 +75,17 @@ static func _completed_json() -> String: ) +static func _completed_with_reward_summary_json() -> String: + return ( + '{"schemaVersion":1,"playerId":"dev-local-1","quests":' + + '[{"questId":"prototype_quest_gather_intro","status":"completed",' + + '"currentStepIndex":0,"objectiveCounters":{},' + + '"completedAt":"2026-06-07T12:00:00Z",' + + '"completionRewardSummary":{"itemGrants":[],"skillXpGrants":' + + '[{"skillId":"salvage","amount":25}]}}]}' + ) + + static func _accept_success_json() -> String: return ( '{"schemaVersion":1,"accepted":true,"quest":' @@ -198,6 +209,27 @@ func test_parse_active_row_has_objective_counters() -> void: assert_that(int((counters as Dictionary).get("gather_intro_obj_scrap", 0))).is_equal(2) +func test_parse_completed_row_has_completion_reward_summary() -> void: + # Arrange + var json := _completed_with_reward_summary_json() + # Act + var snapshot: Variant = QuestProgressClient.parse_quest_progress_json(json) + # Assert + assert_that(snapshot is Dictionary).is_true() + var summary: Dictionary = QuestProgressClient.completion_reward_summary( + PROTOTYPE_QUEST_GATHER_ID, snapshot as Dictionary + ) + var items: Variant = summary.get("itemGrants", null) + assert_that(items is Array).is_true() + assert_that((items as Array).size()).is_equal(0) + var skills: Variant = summary.get("skillXpGrants", null) + assert_that(skills is Array).is_true() + assert_that((skills as Array).size()).is_equal(1) + var skill_row: Dictionary = (skills as Array)[0] as Dictionary + assert_that(str(skill_row.get("skillId", ""))).is_equal("salvage") + assert_that(int(skill_row.get("amount", 0))).is_equal(25) + + func test_parse_completed_row_has_completed_at() -> void: # Arrange var json := _completed_json()