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.
pull/161/head
VinPropane 2026-06-07 14:45:56 -04:00
parent bfded69b3b
commit 91bdb18357
5 changed files with 226 additions and 0 deletions

View File

@ -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

View File

@ -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(

View File

@ -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")

View File

@ -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)

View File

@ -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