From 91bdb18357ec90c560ffee077ad637689d5ddf92 Mon Sep 17 00:00:00 2001 From: VinPropane Date: Sun, 7 Jun 2026 14:45:56 -0400 Subject: [PATCH] NEO-122: Keep quest HUD fresh after accept. Merge accept-response quest row into the HUD snapshot immediately, re-apply when a stale GET returns not_started, and queue pending sync when progress GET is already in flight. --- client/scripts/quest_hud_controller.gd | 26 +++++++ client/scripts/quest_progress_client.gd | 46 +++++++++++ client/test/quest_hud_controller_test.gd | 80 ++++++++++++++++++++ client/test/quest_progress_client_test.gd | 72 ++++++++++++++++++ docs/reviews/2026-06-07-NEO-122-follow-up.md | 2 + 5 files changed, 226 insertions(+) diff --git a/client/scripts/quest_hud_controller.gd b/client/scripts/quest_hud_controller.gd index a563583..5b9cd97 100644 --- a/client/scripts/quest_hud_controller.gd +++ b/client/scripts/quest_hud_controller.gd @@ -2,6 +2,7 @@ extends Node ## NEO-122: quest progress + accept HUD wiring (extracted from main.gd). +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)" @@ -12,6 +13,7 @@ var _accept_label: Label = null var _last_snapshot: Dictionary = {} var _progress_error: String = "" var _defs_error: String = "" +var _accept_quest_patch: Dictionary = {} func setup( @@ -76,18 +78,27 @@ func try_accept_key_input(event: InputEvent) -> bool: func _on_progress_received(snapshot: Dictionary) -> void: _progress_error = "" _last_snapshot = snapshot.duplicate(true) + _reapply_accept_quest_patch_if_needed() _render_progress_label() func _on_sync_failed(reason: String) -> void: _progress_error = reason _last_snapshot = {} + _accept_quest_patch = {} _render_progress_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) + _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(): @@ -200,6 +211,21 @@ func _quest_row(quest_id: String) -> Dictionary: 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 diff --git a/client/scripts/quest_progress_client.gd b/client/scripts/quest_progress_client.gd index 867b647..e2f7fe6 100644 --- a/client/scripts/quest_progress_client.gd +++ b/client/scripts/quest_progress_client.gd @@ -17,6 +17,7 @@ const SCHEMA_VERSION := 1 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 = "" @@ -36,7 +37,12 @@ func is_accept_busy() -> bool: 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) @@ -45,6 +51,7 @@ func request_sync_from_server() -> void: 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. @@ -127,6 +134,40 @@ static func parse_quest_accept_json(text: String) -> Variant: 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: @@ -135,16 +176,19 @@ func _on_sync_request_completed( 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) @@ -152,8 +196,10 @@ func _on_sync_request_completed( 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( diff --git a/client/test/quest_hud_controller_test.gd b/client/test/quest_hud_controller_test.gd index e919628..906c890 100644 --- a/client/test/quest_hud_controller_test.gd +++ b/client/test/quest_hud_controller_test.gd @@ -194,3 +194,83 @@ func test_request_accept_shows_busy_feedback() -> void: # Assert assert_bool(started).is_false() assert_str(accept_label.text).contains("busy") + + +func _not_started_snapshot() -> Dictionary: + return { + "schemaVersion": 1, + "playerId": "dev-local-1", + "quests": + [ + { + "questId": QuestHudController.GATHER_QUEST_ID, + "status": "not_started", + "currentStepIndex": 0, + "objectiveCounters": {}, + } + ], + } + + +func test_accept_success_merges_quest_row_into_progress_label() -> void: + # Arrange + var progress_client: Node = _make_progress_client(NoopHttpTransport.new()) + var controller: Node = QuestHudController.new() + var progress_label := Label.new() + auto_free(controller) + auto_free(progress_label) + add_child(controller) + await get_tree().process_frame + controller.set("_progress_client", progress_client) + controller.set("_progress_label", progress_label) + controller.set("_last_snapshot", _not_started_snapshot()) + # Act + ( + controller + . call( + "_on_accept_result_received", + QuestHudController.GATHER_QUEST_ID, + { + "accepted": true, + "quest": + { + "questId": QuestHudController.GATHER_QUEST_ID, + "status": "active", + "currentStepIndex": 0, + "objectiveCounters": {}, + }, + } + ) + ) + # Assert + assert_str(progress_label.text).contains("active step") + + +func test_stale_sync_reapplies_accept_quest_patch() -> void: + # Arrange + var progress_client: Node = _make_progress_client(NoopHttpTransport.new()) + var controller: Node = QuestHudController.new() + var progress_label := Label.new() + auto_free(controller) + auto_free(progress_label) + add_child(controller) + await get_tree().process_frame + controller.set("_progress_client", progress_client) + controller.set("_progress_label", progress_label) + controller.set("_last_snapshot", _not_started_snapshot()) + ( + controller + . set( + "_accept_quest_patch", + { + "questId": QuestHudController.GATHER_QUEST_ID, + "status": "active", + "currentStepIndex": 0, + "objectiveCounters": {}, + } + ) + ) + # Act + controller.call("_on_progress_received", _not_started_snapshot()) + # Assert + assert_str(progress_label.text).contains("active step") diff --git a/client/test/quest_progress_client_test.gd b/client/test/quest_progress_client_test.gd index d5cd2a8..9b31a66 100644 --- a/client/test/quest_progress_client_test.gd +++ b/client/test/quest_progress_client_test.gd @@ -142,6 +142,33 @@ class PendingAcceptHttpTransport: ) +class PendingSyncHttpTransport: + extends Node + signal request_completed( + result: int, response_code: int, headers: PackedStringArray, body: PackedByteArray + ) + var request_count: int = 0 + var response_code: int = 200 + var body_json: String = "" + + func request( + _url: String, + _custom_headers: PackedStringArray = PackedStringArray(), + _method: HTTPClient.Method = HTTPClient.METHOD_GET, + _request_data: String = "" + ) -> Error: + request_count += 1 + return OK + + func complete_pending() -> void: + request_completed.emit( + HTTPRequest.RESULT_SUCCESS, + response_code, + PackedStringArray(), + body_json.to_utf8_buffer() + ) + + func test_parse_not_started_quest_row() -> void: # Arrange var json := _not_started_progress_json() @@ -287,3 +314,48 @@ func test_request_accept_returns_false_when_accept_busy() -> void: assert_that(first).is_true() assert_that(second).is_false() pending.complete_pending() + + +func test_merge_quest_row_into_snapshot_replaces_existing_row() -> void: + # Arrange + var snapshot: Dictionary = { + "schemaVersion": 1, + "playerId": "dev-local-1", + "quests": + [ + { + "questId": PROTOTYPE_QUEST_GATHER_ID, + "status": "not_started", + "currentStepIndex": 0, + "objectiveCounters": {}, + } + ], + } + var active_row: Dictionary = { + "questId": PROTOTYPE_QUEST_GATHER_ID, + "status": "active", + "currentStepIndex": 0, + "objectiveCounters": {}, + } + # Act + var merged: Dictionary = QuestProgressClient.merge_quest_row_into_snapshot(snapshot, active_row) + # Assert + var c: Node = QuestProgressClient.new() + auto_free(c) + var row: Dictionary = c.call("quest_row", PROTOTYPE_QUEST_GATHER_ID, merged) + assert_that(str(row.get("status", ""))).is_equal("active") + + +func test_request_sync_queues_while_busy_and_flushes_after_completion() -> void: + # Arrange + var transport := PendingSyncHttpTransport.new() + transport.body_json = _not_started_progress_json() + var noop := NoopHttpTransport.new() + var c := _make_client(transport, noop) + # Act + c.call("request_sync_from_server") + c.call("request_sync_from_server") + # Assert + assert_that(transport.request_count).is_equal(1) + transport.complete_pending() + assert_that(transport.request_count).is_equal(2) diff --git a/docs/reviews/2026-06-07-NEO-122-follow-up.md b/docs/reviews/2026-06-07-NEO-122-follow-up.md index 6d9a431..6696057 100644 --- a/docs/reviews/2026-06-07-NEO-122-follow-up.md +++ b/docs/reviews/2026-06-07-NEO-122-follow-up.md @@ -53,6 +53,8 @@ Seven commits landed after the first review: code-review follow-up (quest-defini 3. ~~**GdUnit CI exit 101 (orphans)** — Rewrote **`quest_hud_controller_test.gd`** with `MockHttpTransport` / `auto_free()` on all nodes; full suite **247 tests, 0 orphans, exit 0**. **Done.** +4. ~~**Quest HUD stale after accept** — Merge accept-response `quest` row into snapshot immediately; re-apply patch when stale GET returns `not_started`; queue pending sync when GET busy. **Done.** + ## Verification ```bash