Merge pull request #170 from ViPro-Technologies/NEO-131-client-quest-completion-reward-hud

NEO-131: Client quest completion reward HUD (Godot)
pull/172/head
VinPropane 2026-06-07 23:09:18 -04:00 committed by GitHub
commit bc2e9d6045
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
14 changed files with 809 additions and 5 deletions

View File

@ -239,6 +239,18 @@ Full checklist: [`docs/manual-qa/NEO-110.md`](../docs/manual-qa/NEO-110.md).
Full checklist: [`docs/manual-qa/NEO-122.md`](../docs/manual-qa/NEO-122.md). Full checklist: [`docs/manual-qa/NEO-122.md`](../docs/manual-qa/NEO-122.md).
## Quest completion reward HUD (NEO-131)
- **`GET /game/players/{id}/quest-progress`** — completed rows may include **`completionRewardSummary`** with **`itemGrants`** + **`skillXpGrants`** (NEO-129); see [server README — Per-player quest progress](../server/README.md#per-player-quest-progress-neo-119).
- **Scripts:** `scripts/quest_progress_client.gd` (**`completion_reward_summary`** helper), `scripts/quest_hud_controller.gd` (transition detection + reward label render).
- **HUD:**
- **`UICanvas/HudRootScroll/HudRoot/QuestRewardDeliveryLabel`** — **`Quest rewards: —`** until a quest newly becomes **`completed`** in-session; then quest display name + grant lines.
- Item lines use **`ItemDefinitionsClient.display_name_for`** (NEO-110 loot precedent); skill XP lines use title-case **`skillId`** + amount (e.g. **`Salvage +25 XP`**).
- **Refresh:** reuses NEO-122 triggers (boot hydrate + gather/craft/defeat/accept). **Transition-only** — boot with already-completed quests does not replay grant copy (NEO-123 idempotency).
- **Errors:** failed quest-progress GET shows **`sync error — …`** on **`QuestProgressLabel`**; reward label **`—`** (NEO-122 pattern).
Full checklist: [`docs/manual-qa/NEO-131.md`](../docs/manual-qa/NEO-131.md).
## End-to-end onboarding quest loop (NEO-123) ## End-to-end onboarding quest loop (NEO-123)
Epic 7 Slice 1 capstone — complete all four prototype quests (three onboarding intros + operator chain) **in Godot** without Bruno. Epic 7 Slice 1 capstone — complete all four prototype quests (three onboarding intros + operator chain) **in Godot** without Bruno.

View File

@ -1260,6 +1260,17 @@ theme_override_font_sizes/font_size = 22
autowrap_mode = 3 autowrap_mode = 3
text = "Quest accept: — (Q gather / Shift+Q next)" 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] [node name="NpcStateLabel" type="Label" parent="UICanvas/HudRootScroll/HudRoot" unique_id=9000020]
layout_mode = 2 layout_mode = 2
size_flags_horizontal = 3 size_flags_horizontal = 3

View File

@ -136,6 +136,7 @@ var _dev_pulse_bootstrap_attempted: bool = false
@onready var _encounter_complete_label: Label = _hud_root.get_node("EncounterCompleteLabel") @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_progress_label: Label = _hud_root.get_node("QuestProgressLabel")
@onready var _quest_accept_feedback_label: Label = _hud_root.get_node("QuestAcceptFeedbackLabel") @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 _npc_state_label: Label = _hud_root.get_node("NpcStateLabel")
@onready var _telegraph_label: Label = _hud_root.get_node("TelegraphLabel") @onready var _telegraph_label: Label = _hud_root.get_node("TelegraphLabel")
@onready var _cooldown_slots_label: Label = _hud_root.get_node("CooldownSlotsLabel") @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_defs_client,
_quest_progress_label, _quest_progress_label,
_quest_accept_feedback_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
) )

View File

@ -1,20 +1,28 @@
extends Node extends Node
## NEO-122: quest progress + accept HUD wiring (extracted from main.gd). ## 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 QuestProgressClient := preload("res://scripts/quest_progress_client.gd")
const GATHER_QUEST_ID := "prototype_quest_gather_intro" const GATHER_QUEST_ID := "prototype_quest_gather_intro"
const ACCEPT_IDLE_HINT := "Quest accept: — (Q gather / Shift+Q next)" const ACCEPT_IDLE_HINT := "Quest accept: — (Q gather / Shift+Q next)"
const ACCEPT_SENDING_HINT := "Quest accept: sending…" const ACCEPT_SENDING_HINT := "Quest accept: sending…"
const REWARD_HEADER := "Quest rewards:"
var _progress_client: Node = null var _progress_client: Node = null
var _defs_client: Node = null var _defs_client: Node = null
var _item_defs_client: Node = null
var _progress_label: Label = null var _progress_label: Label = null
var _accept_label: Label = null var _accept_label: Label = null
var _reward_label: Label = null
var _last_snapshot: Dictionary = {} var _last_snapshot: Dictionary = {}
var _progress_error: String = "" var _progress_error: String = ""
var _defs_error: String = "" var _defs_error: String = ""
var _accept_quest_patch: Dictionary = {} 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 = {}
func setup( func setup(
@ -22,12 +30,16 @@ func setup(
defs_client: Node, defs_client: Node,
progress_label: Label, progress_label: Label,
accept_label: Label, accept_label: Label,
apply_http_config: Callable apply_http_config: Callable,
reward_label: Label = null,
item_defs_client: Node = null
) -> void: ) -> void:
_progress_client = progress_client _progress_client = progress_client
_defs_client = defs_client _defs_client = defs_client
_item_defs_client = item_defs_client
_progress_label = progress_label _progress_label = progress_label
_accept_label = accept_label _accept_label = accept_label
_reward_label = reward_label
if apply_http_config.is_valid(): if apply_http_config.is_valid():
apply_http_config.call(progress_client) apply_http_config.call(progress_client)
apply_http_config.call(defs_client) apply_http_config.call(defs_client)
@ -47,8 +59,11 @@ func setup(
_defs_client.connect( _defs_client.connect(
"definitions_sync_failed", Callable(self, "_on_definitions_sync_failed") "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_progress_label()
_render_accept_feedback_label() _render_accept_feedback_label()
_render_reward_label()
if _defs_client.has_method("request_sync_from_server"): if _defs_client.has_method("request_sync_from_server"):
_defs_client.call("request_sync_from_server") _defs_client.call("request_sync_from_server")
request_progress_refresh() request_progress_refresh()
@ -78,9 +93,11 @@ func try_accept_key_input(event: InputEvent) -> bool:
func _on_progress_received(snapshot: Dictionary) -> void: func _on_progress_received(snapshot: Dictionary) -> void:
_progress_error = "" _progress_error = ""
_detect_and_apply_completion_transitions(snapshot)
_last_snapshot = snapshot.duplicate(true) _last_snapshot = snapshot.duplicate(true)
_reapply_accept_quest_patch_if_needed() _reapply_accept_quest_patch_if_needed()
_render_progress_label() _render_progress_label()
_render_reward_label()
func _on_sync_failed(reason: String) -> void: func _on_sync_failed(reason: String) -> void:
@ -90,10 +107,12 @@ func _on_sync_failed(reason: String) -> void:
elif _last_snapshot.is_empty(): elif _last_snapshot.is_empty():
_last_snapshot = QuestProgressClient.merge_quest_row_into_snapshot({}, _accept_quest_patch) _last_snapshot = QuestProgressClient.merge_quest_row_into_snapshot({}, _accept_quest_patch)
_render_progress_label() _render_progress_label()
_render_reward_label()
func _on_accept_result_received(quest_id: String, result: Dictionary) -> void: func _on_accept_result_received(quest_id: String, result: Dictionary) -> void:
if bool(result.get("accepted", false)): 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)) _render_accept_feedback_label("Quest accept: %s accepted" % _display_name(quest_id))
var quest_variant: Variant = result.get("quest", null) var quest_variant: Variant = result.get("quest", null)
if quest_variant is Dictionary: if quest_variant is Dictionary:
@ -120,6 +139,8 @@ func _on_definitions_ready(_quests: Array) -> void:
_defs_error = "" _defs_error = ""
if not _last_snapshot.is_empty(): if not _last_snapshot.is_empty():
_render_progress_label() _render_progress_label()
if not _last_reward_quest_id.is_empty():
_render_reward_label()
func _on_definitions_sync_failed(reason: String) -> void: func _on_definitions_sync_failed(reason: String) -> void:
@ -127,6 +148,87 @@ func _on_definitions_sync_failed(reason: String) -> void:
_render_progress_label() _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 = []
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"))
if new_status == "active":
_mark_quest_active_in_session(qid)
var prev_status := str(_previous_status_by_quest.get(qid, "not_started"))
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})
_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: func _render_progress_label() -> void:
if not is_instance_valid(_progress_label): if not is_instance_valid(_progress_label):
return return
@ -169,6 +271,65 @@ func _render_progress_label() -> void:
_progress_label.text = "\n".join(lines) _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: func _format_status_line(_quest_id: String, display_name: String, row: Dictionary) -> String:
var status: String = str(row.get("status", "not_started")) var status: String = str(row.get("status", "not_started"))
match status: match status:

View File

@ -1,6 +1,7 @@
extends Node extends Node
## NEO-122: HTTP client for quest-progress GET (NEO-119) and quest accept POST (NEO-120). ## 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_progress_received(snapshot: Dictionary)
signal quest_sync_failed(reason: String) signal quest_sync_failed(reason: String)
@ -77,6 +78,23 @@ func request_accept(quest_id: String) -> bool:
return true 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: func quest_row(quest_id: String, snapshot: Dictionary = {}) -> Dictionary:
var quests: Variant = snapshot.get("quests", null) var quests: Variant = snapshot.get("quests", null)
if quests == null or not quests is Array: if quests == null or not quests is Array:

View File

@ -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: static func _accept_success_json() -> String:
return ( return (
'{"schemaVersion":1,"accepted":true,"quest":' '{"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) 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: func test_parse_completed_row_has_completed_at() -> void:
# Arrange # Arrange
var json := _completed_json() var json := _completed_json()

View File

@ -0,0 +1,274 @@
extends GdUnitTestSuite
## NEO-131: quest completion reward label HUD tests.
const QuestHudController := preload("res://scripts/quest_hud_controller.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"
class MockHttpTransport:
extends Node
signal request_completed(
result: int, response_code: int, headers: PackedStringArray, body: PackedByteArray
)
var body_json: String = ""
func request(
_url: String,
_custom_headers: PackedStringArray = PackedStringArray(),
_method: HTTPClient.Method = HTTPClient.METHOD_GET,
_request_data: String = ""
) -> Error:
request_completed.emit(
HTTPRequest.RESULT_SUCCESS, 200, PackedStringArray(), body_json.to_utf8_buffer()
)
return OK
class StubItemDefsClient:
extends Node
func display_name_for(item_id: String) -> String:
if item_id == "survey_drone_kit":
return "Survey Drone Kit"
return item_id
func _make_defs_client(transport: Node) -> Node:
var c: Node = QuestDefinitionsClient.new()
c.set("injected_http", transport)
auto_free(transport)
auto_free(c)
add_child(c)
return c
func _gather_defs_json() -> String:
return (
'{"schemaVersion":1,"quests":['
+ '{"id":"prototype_quest_gather_intro","displayName":"Intro: Salvage Run",'
+ '"prerequisiteQuestIds":[],"steps":[]}]}'
)
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 _active_operator_chain_snapshot() -> Dictionary:
return {
"schemaVersion": 1,
"playerId": "dev-local-1",
"quests":
[
{
"questId": OPERATOR_CHAIN_QUEST_ID,
"status": "active",
"currentStepIndex": 3,
"objectiveCounters": {},
}
],
}
func _completed_operator_chain_with_summary_snapshot() -> Dictionary:
return {
"schemaVersion": 1,
"playerId": "dev-local-1",
"quests":
[
{
"questId": OPERATOR_CHAIN_QUEST_ID,
"status": "completed",
"currentStepIndex": 3,
"objectiveCounters": {},
"completedAt": "2026-06-07T14:00:00Z",
"completionRewardSummary":
{
"itemGrants": [{"itemId": "survey_drone_kit", "quantity": 1}],
"skillXpGrants": [{"skillId": "salvage", "amount": 50}],
},
}
],
}
func _survey_drone_item_defs_json() -> String:
return (
'{"schemaVersion":1,"items":[{"id":"survey_drone_kit",'
+ '"displayName":"Survey Drone Kit","inventorySlotKind":"bag"}]}'
)
func test_completion_transition_paints_reward_label_with_skill_xp() -> 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", _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_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
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()
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_completion_transition_paints_item_grant_with_display_name() -> void:
# Arrange
var controller: Node = QuestHudController.new()
var reward_label := Label.new()
var item_defs := StubItemDefsClient.new()
auto_free(controller)
auto_free(reward_label)
auto_free(item_defs)
add_child(controller)
add_child(item_defs)
controller.set("_reward_label", reward_label)
controller.set("_item_defs_client", item_defs)
# Act
controller.call("_on_progress_received", _active_operator_chain_snapshot())
controller.call("_on_progress_received", _completed_operator_chain_with_summary_snapshot())
# Assert
assert_str(reward_label.text).contains("Survey Drone Kit ×1")
assert_str(reward_label.text).contains("Salvage +50 XP")
func test_definitions_ready_repaints_reward_label_with_display_name() -> void:
# Arrange
var defs_transport := MockHttpTransport.new()
defs_transport.body_json = _gather_defs_json()
var defs_client: Node = _make_defs_client(defs_transport)
var controller: Node = QuestHudController.new()
var reward_label := Label.new()
auto_free(controller)
auto_free(reward_label)
add_child(controller)
controller.set("_defs_client", defs_client)
controller.set("_reward_label", reward_label)
controller.call("_on_progress_received", _active_gather_snapshot())
controller.call("_on_progress_received", _completed_gather_with_summary_snapshot())
assert_str(reward_label.text).contains(QuestHudController.GATHER_QUEST_ID)
# Act
defs_client.call("request_sync_from_server")
controller.call("_on_definitions_ready", [])
# Assert
assert_str(reward_label.text).contains("Intro: Salvage Run")
assert_str(reward_label.text).contains("Salvage +25 XP")
assert_str(reward_label.text).not_contains("prototype_quest_gather_intro")
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()
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")

View File

@ -0,0 +1 @@
uid://d4n7q1rewardhudtest01

View File

@ -77,3 +77,5 @@ Epic 7 **Slice 2** — `reward_delivery`, `unlock_granted`; no double-claim on r
**NEO-129 (E7M2-06 HTTP read):** **`GET /game/players/{id}/quest-progress`** projects optional **`completionRewardSummary`** on **`completed`** rows from **`IRewardDeliveryStore.TryGet`** — nested **`itemGrants`** + **`skillXpGrants`** mirroring delivery event snapshots; omitted when no delivery record. **`QuestAcceptApi`** embed rows use the same mapper. See [NEO-129 implementation plan](../../plans/NEO-129-implementation-plan.md); [server README — Per-player quest progress (NEO-119)](../../../server/README.md#per-player-quest-progress-neo-119); Bruno `bruno/neon-sprawl-server/quest-progress/Get quest progress after gather intro complete.bru`. **NEO-129 (E7M2-06 HTTP read):** **`GET /game/players/{id}/quest-progress`** projects optional **`completionRewardSummary`** on **`completed`** rows from **`IRewardDeliveryStore.TryGet`** — nested **`itemGrants`** + **`skillXpGrants`** mirroring delivery event snapshots; omitted when no delivery record. **`QuestAcceptApi`** embed rows use the same mapper. See [NEO-129 implementation plan](../../plans/NEO-129-implementation-plan.md); [server README — Per-player quest progress (NEO-119)](../../../server/README.md#per-player-quest-progress-neo-119); Bruno `bruno/neon-sprawl-server/quest-progress/Get quest progress after gather intro complete.bru`.
**NEO-130 (E7M2-07 telemetry hooks):** comment-only **`reward_delivery`** + **`unlock_granted`** stub hook sites in **`RewardRouterOperations.TryDeliverQuestCompletion`** — first-time **`TryRecord`** success and future **`UnlockGrant`** apply region respectively; no production ingest. See [NEO-130 implementation plan](../../plans/NEO-130-implementation-plan.md); [server README — Reward telemetry hooks (NEO-130)](../../../server/README.md#reward-telemetry-hooks-neo-130). **NEO-130 (E7M2-07 telemetry hooks):** comment-only **`reward_delivery`** + **`unlock_granted`** stub hook sites in **`RewardRouterOperations.TryDeliverQuestCompletion`** — first-time **`TryRecord`** success and future **`UnlockGrant`** apply region respectively; no production ingest. See [NEO-130 implementation plan](../../plans/NEO-130-implementation-plan.md); [server README — Reward telemetry hooks (NEO-130)](../../../server/README.md#reward-telemetry-hooks-neo-130).
**NEO-131 (E7M2-08 client HUD):** Godot **`QuestRewardDeliveryLabel`** + **`quest_hud_controller.gd`** transition detection paints **`completionRewardSummary`** grant lines when a quest newly becomes **`completed`** in-session (parse via **`quest_progress_client.gd`**). See [NEO-131 implementation plan](../../plans/NEO-131-implementation-plan.md); [client README — Quest completion reward HUD (NEO-131)](../../../client/README.md#quest-completion-reward-hud-neo-131).

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1,41 @@
# NEO-131 — Manual QA checklist
| Field | Value |
|-------|-------|
| Key | NEO-131 |
| Title | E7M2-08: Client quest completion reward HUD (Godot) |
| Linear | https://linear.app/neon-sprawl/issue/NEO-131/e7m2-08-client-quest-completion-reward-hud-godot |
| Plan | `docs/plans/NEO-131-implementation-plan.md` |
| Branch | `NEO-131-client-quest-completion-reward-hud` |
## Preconditions
- **Fresh dev player:** stop any running server, then start a new instance so in-memory quest progress and delivery store reset.
- **No Bruno/curl** for this checklist — use Godot gameplay only.
- NEO-129 **`completionRewardSummary`** on quest-progress GET landed on `main`.
- NEO-122 quest HUD + NEO-128 reward delivery wiring landed on `main`.
## Expected reward HUD progression
| After | `QuestRewardDeliveryLabel` |
|-------|----------------------------|
| Boot (no completions yet) | `Quest rewards:` / `—` |
| Gather intro completes (3× scrap) | `Intro: Salvage Run` + `Salvage +25 XP` |
| Godot restart (server still running, quests already completed) | `—` (transition-only; no replay) |
| Operator chain completes | item line (`Survey Drone Kit ×1` when item defs loaded) + `Salvage +50 XP` |
| Quest-progress GET fails (server stopped) | `QuestProgressLabel` shows `sync error — …`; reward label `—` |
## Checklist
1. Start server: `cd server/NeonSprawl.Server && dotnet run`.
2. Run Godot main scene (**F5**). Confirm **`QuestRewardDeliveryLabel`** shows **`Quest rewards:`** / **`—`** below **`QuestAcceptFeedbackLabel`** in **`HudRootScroll`**.
3. Press **Q** to accept gather intro. Gather scrap (**R** on resource node ×3) until **`Intro: Salvage Run: completed`** on **`QuestProgressLabel`**.
4. Verify **`QuestRewardDeliveryLabel`** updates to **`Salvage +25 XP`** (quest display name on the line above when defs loaded).
5. Stop Godot and press **F5** again (server still running). Verify reward label stays **`—`** while progress still shows gather **`completed`** (idempotent boot — no transition replay).
6. Optional — operator chain: follow [NEO-123](NEO-123.md) through **`prototype_quest_operator_chain`** completion. Verify reward label shows **`Survey Drone Kit ×1`** (or raw id fallback) and **`Salvage +50 XP`** on chain completion transition only.
7. Optional: stop server while Godot running; trigger a quest-progress refresh (gather or **Q** accept). Verify **`QuestProgressLabel`** shows **`sync error — …`** and reward label **`—`**.
## Regression spot-check
- [NEO-122](NEO-122.md) — quest progress + accept HUD unchanged except new label below accept feedback.
- [NEO-110](NEO-110.md) — encounter loot label still independent from quest completion bundles.

View File

@ -267,8 +267,10 @@ Combat intro bundle is **skill XP only** — encounter loot already granted **`c
**Acceptance criteria** **Acceptance criteria**
- [ ] Completing a quest shows readable reward copy on HUD. - [x] Completing a quest shows readable reward copy on HUD.
- [ ] Failed GET surfaces visible error (NEO-122 pattern). - [x] Failed GET surfaces visible error (NEO-122 pattern).
**Landed ([NEO-131](https://linear.app/neon-sprawl/issue/NEO-131)):** **`QuestRewardDeliveryLabel`** + **`quest_hud_controller.gd`** transition detection for **`completionRewardSummary`**; [client README — Quest completion reward HUD (NEO-131)](../../client/README.md#quest-completion-reward-hud-neo-131); plan [NEO-131](../../plans/NEO-131-implementation-plan.md); manual QA [`NEO-131`](../../manual-qa/NEO-131.md).
**Server counterpart:** [NEO-129](https://linear.app/neon-sprawl/issue/NEO-129). **Server counterpart:** [NEO-129](https://linear.app/neon-sprawl/issue/NEO-129).

View File

@ -0,0 +1,179 @@
# NEO-131 — Implementation plan
## Story reference
| Field | Value |
|--------|--------|
| **Key** | NEO-131 |
| **Title** | E7M2-08: Client quest completion reward HUD (Godot) |
| **Linear** | https://linear.app/neon-sprawl/issue/NEO-131/e7m2-08-client-quest-completion-reward-hud-godot |
| **Module** | [E7.M2 — RewardAndUnlockRouter](../decomposition/modules/E7_M2_RewardAndUnlockRouter.md) · Epic 7 Slice 2 · backlog **E7M2-08** |
| **Branch** | `NEO-131-client-quest-completion-reward-hud` |
| **Server deps** | [NEO-129](https://linear.app/neon-sprawl/issue/NEO-129) **`Done`** on `main``completionRewardSummary` on completed quest-progress rows |
| **Pattern** | [NEO-110](NEO-110-implementation-plan.md) — separate completion grant label + item-def display names; [NEO-122](NEO-122-implementation-plan.md) — `quest_hud_controller.gd` + event-driven GET refresh |
| **Blocks** | [NEO-132](https://linear.app/neon-sprawl/issue/NEO-132) — playable quest reward delivery capstone (E7M2-09) |
| **Server counterpart** | [NEO-129](https://linear.app/neon-sprawl/issue/NEO-129) — authoritative GET; Bruno is **not** prototype-complete per [full-stack epic decomposition](../../.cursor/rules/full-stack-epic-decomposition.md) |
## Kickoff clarifications
| Topic | Question | Agent recommendation | Answer |
|--------|----------|----------------------|--------|
| **HUD component** | Separate reward label vs inline on progress list? | **Separate `QuestRewardDeliveryLabel`** after **`QuestAcceptFeedbackLabel`** — NEO-110 **`EncounterCompleteLabel`** precedent; keeps four-quest progress compact. | **Adopted** — separate label |
| **Reward surfacing** | When to paint grant copy? | **Transition-only** — detect quest status → **`completed`** on refresh; matches Linear AC (“completing a quest shows…”); avoids replaying all summaries on boot/idempotency (NEO-123). | **Adopted** — transition-only |
| **Skill XP copy** | How to label skill lines without a skill-definitions client? | **Title-case `skillId` + amount** (e.g. **`Salvage +25 XP`**) — no skill-def client in repo; item lines use **`ItemDefinitionsClient`** like encounter loot. | **Adopted** (no kickoff question — settled by precedent) |
**Additional defaults (no kickoff question — settled by backlog / landed code):**
- **JSON shape:** nested **`completionRewardSummary`** with **`itemGrants: [{ itemId, quantity }]`** + **`skillXpGrants: [{ skillId, amount }]`** per NEO-129; empty **`itemGrants: []`** included when summary present.
- **Missing summary:** when **`status: completed`** but **`completionRewardSummary`** omitted (no delivery record), reward label stays **`—`** on transition — server omits field per NEO-129 kickoff.
- **Refresh strategy:** reuse NEO-122 triggers (boot hydrate + gather/craft/**`targetDefeated`**/encounter **`completed`**/accept POST) — no new poll or key.
- **Error surfacing:** failed GET continues **`sync error — {reason}`** on **`QuestProgressLabel`** (NEO-122); reward label shows **`—`** while sync error set (NEO-110 encounter loot precedent).
- **Server changes:** none — client-only story.
## Goal, scope, and out-of-scope
**Goal:** Godot shows **server-owned** quest completion reward grants using **`completionRewardSummary`** on **`GET /game/players/{id}/quest-progress`** — player-visible readable copy when a quest newly completes, without Bruno.
**In scope (from Linear + [E7M2-08](E7M2-prototype-backlog.md#e7m2-08--client-quest-completion-reward-hud-godot)):**
- Parse and retain **`completionRewardSummary`** on quest-progress rows (existing top-level parse passes through; add tests).
- **`QuestRewardDeliveryLabel`** under **`HudRoot`** after **`QuestAcceptFeedbackLabel`** — compact grant lines on in-session completion transition.
- Extend **`quest_hud_controller.gd`**: transition detection, reward label render, optional **`ItemDefinitionsClient`** for item display names.
- **`main.gd` / `main.tscn`:** wire reward label + item-defs client into controller **`setup`**.
- GdUnit parse + HUD render tests ([testing-expectations.md](../../.cursor/rules/testing-expectations.md)).
- **`docs/manual-qa/NEO-131.md`** — component checklist (gather intro skill XP; operator chain item + XP).
- **`client/README.md`** quest reward HUD subsection.
**Out of scope (from Linear + backlog):**
- Final quest journal UI; reward VFX.
- Skill-definitions client; server route/DTO changes.
- Full Slice 2 capstone script (**[NEO-132](https://linear.app/neon-sprawl/issue/NEO-132)**).
- Inventory/skills auto-refresh on quest complete (optional follow-up; NEO-132 capstone may assert economy HUD separately).
## Acceptance criteria checklist
- [x] Completing a quest shows readable reward copy on HUD.
- [x] Failed GET surfaces visible error (NEO-122 pattern).
## Implementation reconciliation (shipped)
- **`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, 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
### Server contract (landed — NEO-129)
Per completed quest row when delivery recorded:
```json
{
"questId": "prototype_quest_gather_intro",
"status": "completed",
"completionRewardSummary": {
"itemGrants": [],
"skillXpGrants": [{ "skillId": "salvage", "amount": 25 }]
}
}
```
Operator chain adds item line: **`survey_drone_kit` ×1** + **`salvage` 50** XP.
### 1. Parse layer (`quest_progress_client.gd`)
- No parser change required — **`parse_quest_progress_json`** already accepts arbitrary quest row fields.
- Optional static helper **`completion_reward_summary(quest_id, snapshot) -> Dictionary`** returning **`{}`** when absent — keeps HUD code readable (mirror **`encounter_row`** usage).
- GdUnit: completed row JSON with nested summary preserves **`itemGrants`** / **`skillXpGrants`** through parse + **`quest_row`**.
### 2. Transition detection (`quest_hud_controller.gd`)
- Maintain **`_previous_status_by_quest: Dictionary`** (questId → last known status string).
- 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 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
**Header:** **`Quest rewards:`** (idle **`Quest rewards:\n—`**).
When **`_last_reward_quest_id`** set and summary present:
```
Quest rewards:
Intro: Salvage Run
Salvage +25 XP
```
Item lines: **`{displayName} ×{quantity}`** via **`ItemDefinitionsClient.display_name_for`**, fallback **`itemId`**.
Skill lines: **`{_title_case_skill_id(skillId)} +{amount} XP`**.
When **`completed`** transition but summary omitted: show quest display name + **`(rewards pending)`** or **`—`** — prefer **`—`** only (no delivery record = no copy; keeps label honest).
### 4. Scene + wiring
- **`main.tscn`:** add **`QuestRewardDeliveryLabel`** after **`QuestAcceptFeedbackLabel`** (same font/theme as quest labels).
- **`main.gd`:** `@onready` reward label; extend **`_setup_quest_progress_sync()`** to pass reward label + **`_item_defs_client`** into controller **`setup`** (new optional params at end for backward-compatible test calls).
### 5. Display copy reference (freeze table)
| Quest id | Expected reward lines |
|----------|----------------------|
| `prototype_quest_gather_intro` | Salvage +25 XP |
| `prototype_quest_refine_intro` | Refine +25 XP |
| `prototype_quest_combat_intro` | Salvage +25 XP |
| `prototype_quest_operator_chain` | Survey Drone Kit ×1; Salvage +50 XP |
### 6. Relationship to adjacent HUD
| HUD element | Story | Role |
|-------------|-------|------|
| **`QuestProgressLabel`** | NEO-122 | Status/step/counters for all four quests |
| **`QuestRewardDeliveryLabel`** | NEO-131 | Grant copy on completion transition |
| **`EncounterCompleteLabel`** | NEO-110 | Encounter loot (separate from quest completion bundles) |
Combat intro completion is **skill XP only** in quest bundle — encounter token loot remains on encounter label (E7M2 freeze).
## Files to add
| Path | Purpose |
|------|---------|
| `docs/plans/NEO-131-implementation-plan.md` | This plan. |
| `docs/manual-qa/NEO-131.md` | Manual QA: gather complete → skill XP line; operator chain item + XP; sync error visible. |
## Files to modify
| Path | Rationale |
|------|-----------|
| `client/scripts/quest_hud_controller.gd` | Transition detection, reward label render, item-def display names, sync-error idle state. |
| `client/scripts/quest_progress_client.gd` | Optional **`completion_reward_summary`** helper; class doc cites NEO-131 field. |
| `client/scripts/main.gd` | Wire **`QuestRewardDeliveryLabel`** + **`ItemDefinitionsClient`** into quest HUD **`setup`**. |
| `client/scenes/main.tscn` | Add **`QuestRewardDeliveryLabel`** node after accept feedback label. |
| `client/README.md` | Quest completion reward HUD subsection; cross-link NEO-129/131. |
| `docs/plans/E7M2-prototype-backlog.md` | E7M2-08 checkboxes + landed note when complete. |
| `docs/decomposition/modules/E7_M2_RewardAndUnlockRouter.md` | Client implementation anchor (NEO-131). |
| `docs/decomposition/modules/documentation_and_implementation_alignment.md` | E7.M2 row: NEO-131 client HUD. |
## Tests
| Test file | What it covers |
|-----------|----------------|
| `client/test/quest_progress_client_test.gd` | **Add:** parse completed row with **`completionRewardSummary`** (empty **`itemGrants`**, skill XP row); helper returns summary dict. |
| `client/test/quest_reward_hud_test.gd` | **Add:** transition **`active``completed`** paints reward label with skill XP line; boot with already-**`completed`** does not paint; first-success-after-active; sync failure idle reward; operator-chain item grant + display name; quest/item **`definitions_ready`** reward repaint. |
**Regression:** existing NEO-122 GdUnit suites unchanged; `dotnet test` N/A (client-only).
## Open questions / risks
| Question / risk | Agent recommendation | Status |
|-----------------|----------------------|--------|
| **Multiple quests complete in one GET** | Paint **last catalog-order** transition in snapshot iteration — rare in prototype; document in manual QA. | **adopted** |
| **Inventory deny on complete (NEO-128)** | Quest may stay **`active`** when router denies — no **`completed`** transition, no reward label; acceptable for prototype. | **adopted** |
| **Skill display without defs client** | Title-case **`skillId`**; defer skill-def client to pre-production. | **adopted** |
| **NEO-132 capstone overlap** | NEO-131 proves component HUD; NEO-132 owns end-to-end reward idempotency manual QA. | **deferred** |

View File

@ -0,0 +1,68 @@
# Code review — NEO-131 (E7M2-08)
**Date:** 2026-06-07
**Scope:** Branch `NEO-131-client-quest-completion-reward-hud` vs `origin/main` — commits `5578de9``ed7975c`
**Base:** `origin/main`
## Verdict
**Approve with nits**
## Summary
NEO-131 adds Godot client surfacing for server-owned quest completion grants: **`completion_reward_summary`** helper on **`quest_progress_client.gd`**, transition-only detection in **`quest_hud_controller.gd`** (`_previous_status_by_quest` boot-seed + catalog-order tie-break for multi-complete GETs), and **`QuestRewardDeliveryLabel`** wired from **`main.gd` / `main.tscn`** with optional **`ItemDefinitionsClient`** display names. Behavior matches adopted kickoff decisions (separate label, transition-only paint, title-case skill XP, sync-error idle **`—`** on reward label while NEO-122 progress error persists). Four new GdUnit cases use full AAA layout. Documentation is complete: implementation plan reconciliation, E7M2-08 backlog checkboxes, E7.M2 module anchor, alignment register note, **`client/README.md`**, and **`docs/manual-qa/NEO-131.md`**. Client-only slice with NEO-129 server dependency already on `main`; does not claim E7 Slice 2 capstone (NEO-132). Risk is low.
## Documentation checked
| Path | Result |
|------|--------|
| `docs/plans/NEO-131-implementation-plan.md` | **Matches** — kickoff decisions adopted; acceptance checklist checked; reconciliation accurate. |
| `docs/plans/E7M2-prototype-backlog.md` (E7M2-08) | **Matches** — acceptance checkboxes checked; landed note + README/plan/manual QA links. |
| `docs/decomposition/modules/E7_M2_RewardAndUnlockRouter.md` | **Matches** — NEO-131 client HUD snapshot under implementation anchor. |
| `docs/decomposition/modules/documentation_and_implementation_alignment.md` | **Matches** — E7M2-08 / NEO-131 landed note in row body; references column includes [NEO-131 plan](../../plans/NEO-131-implementation-plan.md). |
| `docs/decomposition/modules/module_dependency_register.md` | **Matches** — no register row change required. |
| `docs/decomposition/modules/client_server_authority.md` | **Matches** — client displays GET snapshot only; no local grant math. |
| `client/README.md` | **Matches** — quest completion reward HUD subsection with routes, triggers, error pattern. |
| `docs/manual-qa/NEO-131.md` | **Matches** — Godot-only checklist; boot idempotency + operator chain optional steps. |
| Full-stack epic decomposition | **Matches** — paired client issue NEO-131; server counterpart NEO-129 documented; does not claim E7M2-09 capstone complete. |
**Register / tracking:** E7.M2 correctly remains **Planned** until client capstone NEO-132; no further status change required for this merge.
## Blocking issues
(none)
## Suggestions
1. ~~**GdUnit: item grant + display name** — New HUD tests cover skill XP transition, boot idempotency, and sync-error idle state. A small case with a mock **`ItemDefinitionsClient`** (or stub **`display_name_for`**) asserting **`Survey Drone Kit ×1`** would lock the operator-chain path referenced in manual QA and the freeze table without requiring Godot gameplay.~~ **Done.** `test_completion_transition_paints_item_grant_with_display_name` + **`StubItemDefsClient`** in `client/test/quest_hud_controller_test.gd`.
2. ~~**GdUnit: `definitions_ready` reward repaint** — **`_on_definitions_ready`** repaints the reward label when **`_last_reward_quest_id`** is set (quest display name upgrade). Mirror the existing NEO-122 progress-label test pattern for the reward label so regressions in **`_display_name`** wiring are caught.~~ **Done.** `test_definitions_ready_repaints_reward_label_with_display_name` in `client/test/quest_hud_controller_test.gd`.
## Nits
- Nit: **`_format_reward_summary_lines`** appends **`(no grants)`** when summary dict is present but all grant rows are filtered out (empty arrays or zero qty). Server delivery snapshots should always include at least one grant when **`completionRewardSummary`** is emitted; harmless defensive copy.
- ~~Nit: **`documentation_and_implementation_alignment.md`** E7.M2 references pipe lists NEO-124NEO-130 plan links but not **`NEO-131-implementation-plan.md`** — row body already cites NEO-131; add link for parity with other landed slices.~~ **Done.** References column now includes [NEO-131 plan](../../plans/NEO-131-implementation-plan.md).
- Nit: Encounter loot (**NEO-110**) repaints on every **`completed`** snapshot; quest rewards intentionally use transition-only detection — correct per plan; keep the distinction in mind when NEO-132 capstone asserts end-to-end economy HUD refresh.
## Verification
```bash
# GdUnit (requires GODOT_BIN)
export GODOT_BIN=/path/to/godot # or pass --godot_binary
cd client
./addons/gdUnit4/runtest.sh -a test/quest_progress_client_test.gd -a test/quest_hud_controller_test.gd
# GDScript lint (if .venv-gd installed)
gdlint client/scripts/quest_hud_controller.gd client/scripts/quest_progress_client.gd client/scripts/main.gd
```
Manual (per `docs/manual-qa/NEO-131.md`):
1. Fresh server + Godot **F5** — reward label **`Quest rewards:` / `—`** below accept feedback.
2. Accept gather intro, complete 3× scrap — **`Salvage +25 XP`** on transition; progress label shows **`completed`**.
3. Godot restart (server still running) — reward label stays **`—`** (no replay).
4. Optional: operator chain completion — item + skill XP lines; optional server stop — progress **`sync error`** + reward **`—`**.
Regression: NEO-122 quest progress/accept HUD and NEO-110 encounter loot label remain independent.