NEO-131: Add quest completion reward HUD in Godot client.
Parse completionRewardSummary from quest-progress GET and paint QuestRewardDeliveryLabel on in-session completion transitions.pull/170/head
parent
5578de9de3
commit
1a2478b289
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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—")
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
Loading…
Reference in New Issue