diff --git a/client/scripts/quest_hud_controller.gd b/client/scripts/quest_hud_controller.gd index 8328ab0..b6a3e81 100644 --- a/client/scripts/quest_hud_controller.gd +++ b/client/scripts/quest_hud_controller.gd @@ -20,6 +20,7 @@ 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 = {} @@ -58,6 +59,10 @@ func setup( _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() @@ -109,6 +114,7 @@ func _on_sync_failed(reason: String) -> void: 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: @@ -144,6 +150,17 @@ func _on_definitions_sync_failed(reason: String) -> void: _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 = [] @@ -157,8 +174,13 @@ func _detect_and_apply_completion_transitions(snapshot: Dictionary) -> void: 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")) - if not boot_seed and prev_status != "completed" and new_status == "completed": + 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}) diff --git a/client/test/quest_hud_controller_test.gd b/client/test/quest_hud_controller_test.gd index f91ae6d..bd936eb 100644 --- a/client/test/quest_hud_controller_test.gd +++ b/client/test/quest_hud_controller_test.gd @@ -5,6 +5,7 @@ extends GdUnitTestSuite const QuestHudController := preload("res://scripts/quest_hud_controller.gd") const QuestProgressClient := preload("res://scripts/quest_progress_client.gd") const QuestDefinitionsClient := preload("res://scripts/quest_definitions_client.gd") +const ItemDefsClient := preload("res://scripts/item_definitions_client.gd") const OPERATOR_CHAIN_QUEST_ID := "prototype_quest_operator_chain" @@ -545,6 +546,21 @@ func test_completion_transition_paints_reward_label_with_skill_xp() -> void: assert_str(reward_label.text).contains("Salvage +25 XP") +func test_first_success_paints_reward_when_quest_was_active_in_session() -> 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) + controller.call("_mark_quest_active_in_session", QuestHudController.GATHER_QUEST_ID) + # Act — first successful GET already completed (missed intermediate snapshots) + controller.call("_on_progress_received", _completed_gather_with_summary_snapshot()) + # Assert + 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() @@ -641,6 +657,41 @@ func test_definitions_ready_repaints_reward_label_with_display_name() -> void: assert_str(reward_label.text).not_contains("prototype_quest_gather_intro") +func _survey_drone_item_defs_json() -> String: + return ( + '{"schemaVersion":1,"items":[{"id":"survey_drone_kit",' + + '"displayName":"Survey Drone Kit","inventorySlotKind":"bag"}]}' + ) + + +func test_item_definitions_ready_repaints_reward_item_display_name() -> void: + # Arrange + var item_transport := MockHttpTransport.new() + item_transport.body_json = _survey_drone_item_defs_json() + var item_defs: Node = ItemDefsClient.new() + item_defs.set("injected_http", item_transport) + auto_free(item_transport) + auto_free(item_defs) + add_child(item_defs) + await get_tree().process_frame + 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) + controller.set("_item_defs_client", item_defs) + controller.call("_on_progress_received", _active_operator_chain_snapshot()) + controller.call("_on_progress_received", _completed_operator_chain_with_summary_snapshot()) + assert_str(reward_label.text).contains("survey_drone_kit") + # Act + item_defs.call("request_sync_from_server") + controller.call("_on_item_definitions_ready", {}) + # Assert + assert_str(reward_label.text).contains("Survey Drone Kit ×1") + assert_str(reward_label.text).not_contains("survey_drone_kit ×1") + + func test_sync_failure_shows_progress_error_and_idle_reward_label() -> void: # Arrange var controller: Node = QuestHudController.new() diff --git a/docs/plans/NEO-131-implementation-plan.md b/docs/plans/NEO-131-implementation-plan.md index 546b4f8..03b051e 100644 --- a/docs/plans/NEO-131-implementation-plan.md +++ b/docs/plans/NEO-131-implementation-plan.md @@ -61,7 +61,7 @@ - **`quest_progress_client.gd`:** **`completion_reward_summary`** static helper; parse preserves NEO-129 nested summary on completed rows. - **`quest_hud_controller.gd`:** transition-only detection (**`_previous_status_by_quest`**); **`QuestRewardDeliveryLabel`** render with item-def display names + title-case skill XP lines; sync error idle state. - **`main.gd` / `main.tscn`:** **`QuestRewardDeliveryLabel`** after accept feedback; **`ItemDefinitionsClient`** threaded into controller **`setup`**. -- **Tests:** `quest_progress_client_test.gd` (summary parse); `quest_hud_controller_test.gd` (transition, boot idempotency, sync error, item grant display name, `definitions_ready` reward repaint). +- **Tests:** `quest_progress_client_test.gd` (summary parse); `quest_hud_controller_test.gd` (transition, boot idempotency, first-success-after-active, sync error, item grant display name, quest/item `definitions_ready` reward repaint). - **Docs:** `client/README.md`; `docs/manual-qa/NEO-131.md`; E7.M2 module + backlog alignment. ## Technical approach @@ -95,7 +95,7 @@ Operator chain adds item line: **`survey_drone_kit` ×1** + **`salvage` 50** XP. - On **`_on_progress_received`**, before overwriting snapshot: - For each quest row in new snapshot, compare **`status`** to previous. - When previous ∉ **`{completed}`** and new **`status == "completed"`** with non-empty **`completionRewardSummary`**, set **`_last_reward_quest_id`** + **`_last_reward_summary`**. -- On boot first snapshot: seed **`_previous_status_by_quest`** without firing transition (no “replay” grants). +- On boot first snapshot: seed **`_previous_status_by_quest`** without firing transition unless the quest was **`active` in-session** (accept or prior active row) — covers failed GET then first success already **`completed`**; Godot restart idempotency unchanged. - On sync failure: clear **`_last_reward_summary`** paint to **`—`**; preserve NEO-122 progress error lines. ### 3. Reward label render