From 5578de9de393dcabbf9cf332e407694640e99b01 Mon Sep 17 00:00:00 2001 From: VinPropane Date: Sun, 7 Jun 2026 22:57:23 -0400 Subject: [PATCH 1/8] NEO-131: Add kickoff implementation plan for quest reward HUD. --- docs/plans/NEO-131-implementation-plan.md | 171 ++++++++++++++++++++++ 1 file changed, 171 insertions(+) create mode 100644 docs/plans/NEO-131-implementation-plan.md diff --git a/docs/plans/NEO-131-implementation-plan.md b/docs/plans/NEO-131-implementation-plan.md new file mode 100644 index 0000000..56a18f8 --- /dev/null +++ b/docs/plans/NEO-131-implementation-plan.md @@ -0,0 +1,171 @@ +# 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 + +- [ ] Completing a quest shows readable reward copy on HUD. +- [ ] Failed GET surfaces visible error (NEO-122 pattern). + +## 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 (no “replay” grants). +- 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_hud_controller_test.gd` | **Add:** transition **`active` → `completed`** paints reward label with skill XP line; boot with already-**`completed`** does not paint; sync failure leaves progress error + reward **`—`**. | + +**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** | From 1a2478b2897a28834c711b840f5c9352e3d67c23 Mon Sep 17 00:00:00 2001 From: VinPropane Date: Sun, 7 Jun 2026 22:59:17 -0400 Subject: [PATCH 2/8] NEO-131: Add quest completion reward HUD in Godot client. Parse completionRewardSummary from quest-progress GET and paint QuestRewardDeliveryLabel on in-session completion transitions. --- client/scenes/main.tscn | 11 ++ client/scripts/main.gd | 5 +- client/scripts/quest_hud_controller.gd | 145 +++++++++++++++++++++- client/scripts/quest_progress_client.gd | 18 +++ client/test/quest_hud_controller_test.gd | 91 ++++++++++++++ client/test/quest_progress_client_test.gd | 32 +++++ 6 files changed, 300 insertions(+), 2 deletions(-) diff --git a/client/scenes/main.tscn b/client/scenes/main.tscn index e102e31..0b0f03e 100644 --- a/client/scenes/main.tscn +++ b/client/scenes/main.tscn @@ -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 diff --git a/client/scripts/main.gd b/client/scripts/main.gd index c7e8a18..0e17cd2 100644 --- a/client/scripts/main.gd +++ b/client/scripts/main.gd @@ -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 ) diff --git a/client/scripts/quest_hud_controller.gd b/client/scripts/quest_hud_controller.gd index 450b479..8328ab0 100644 --- a/client/scripts/quest_hud_controller.gd +++ b/client/scripts/quest_hud_controller.gd @@ -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: diff --git a/client/scripts/quest_progress_client.gd b/client/scripts/quest_progress_client.gd index e2f7fe6..cb084db 100644 --- a/client/scripts/quest_progress_client.gd +++ b/client/scripts/quest_progress_client.gd @@ -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: diff --git a/client/test/quest_hud_controller_test.gd b/client/test/quest_hud_controller_test.gd index d7a3338..5cc2f55 100644 --- a/client/test/quest_hud_controller_test.gd +++ b/client/test/quest_hud_controller_test.gd @@ -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—") diff --git a/client/test/quest_progress_client_test.gd b/client/test/quest_progress_client_test.gd index 9b31a66..616c5b0 100644 --- a/client/test/quest_progress_client_test.gd +++ b/client/test/quest_progress_client_test.gd @@ -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() From ed7975ca54b656e5909e16a006e4d12456609bce Mon Sep 17 00:00:00 2001 From: VinPropane Date: Sun, 7 Jun 2026 22:59:19 -0400 Subject: [PATCH 3/8] NEO-131: Document quest reward HUD and reconcile plan. --- client/README.md | 12 ++++++ .../modules/E7_M2_RewardAndUnlockRouter.md | 2 + ...umentation_and_implementation_alignment.md | 2 +- docs/manual-qa/NEO-131.md | 41 +++++++++++++++++++ docs/plans/E7M2-prototype-backlog.md | 6 ++- docs/plans/NEO-131-implementation-plan.md | 12 +++++- 6 files changed, 70 insertions(+), 5 deletions(-) create mode 100644 docs/manual-qa/NEO-131.md diff --git a/client/README.md b/client/README.md index ff9fc75..0fca811 100644 --- a/client/README.md +++ b/client/README.md @@ -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). +## 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) Epic 7 Slice 1 capstone — complete all four prototype quests (three onboarding intros + operator chain) **in Godot** without Bruno. diff --git a/docs/decomposition/modules/E7_M2_RewardAndUnlockRouter.md b/docs/decomposition/modules/E7_M2_RewardAndUnlockRouter.md index 6875691..ca5d52b 100644 --- a/docs/decomposition/modules/E7_M2_RewardAndUnlockRouter.md +++ b/docs/decomposition/modules/E7_M2_RewardAndUnlockRouter.md @@ -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-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). diff --git a/docs/decomposition/modules/documentation_and_implementation_alignment.md b/docs/decomposition/modules/documentation_and_implementation_alignment.md index 5e41fa8..14cb53f 100644 --- a/docs/decomposition/modules/documentation_and_implementation_alignment.md +++ b/docs/decomposition/modules/documentation_and_implementation_alignment.md @@ -59,7 +59,7 @@ Rows appear when work starts; default for unlisted modules is **Planned** / not | E5.M1 | Ready | **NEO-76 landed:** frozen prototype four-ability catalog in [`content/abilities/prototype_abilities.json`](../../../content/abilities/prototype_abilities.json); [`ability-def.schema.json`](../../../content/schemas/ability-def.schema.json); PR gate + [`validate_content.py`](../../../scripts/validate_content.py) E5M1 four-id gate ([NEO-76](../../plans/NEO-76-implementation-plan.md)). **NEO-77 landed:** fail-fast server load of `content/abilities/*_abilities.json` at startup — `server/NeonSprawl.Server/Game/Combat/` ([NEO-77](../../plans/NEO-77-implementation-plan.md)); [server README — Ability catalog](../../../server/README.md#ability-catalog-contentabilities-neo-77). **NEO-79 landed:** injectable **`IAbilityDefinitionRegistry`** + DI; hotbar/cast use **`TryNormalizeKnown`** ([NEO-79](../../plans/NEO-79-implementation-plan.md)); Bruno `bruno/neon-sprawl-server/hotbar-loadout/` + `ability-cast/` unknown-ability deny smokes. **NEO-78 landed:** **`GET /game/world/ability-definitions`** — `AbilityDefinitionsWorldApi` + DTOs in `Game/Combat/` ([NEO-78](../../plans/NEO-78-implementation-plan.md)); [server README — Ability definitions (NEO-78)](../../../server/README.md#ability-definitions-neo-78); Bruno `bruno/neon-sprawl-server/ability-definitions/`. **NEO-80 landed:** **`ICombatEntityHealthStore`** / **`InMemoryCombatEntityHealthStore`** — lazy init (Slice 1 flat 100 HP; **superseded by [NEO-91](../../plans/NEO-91-implementation-plan.md)** per-archetype catalog max HP + three NPC instance ids); DI via **`AddCombatEntityHealthStore()`** ([NEO-80](../../plans/NEO-80-implementation-plan.md)); [server README — Combat entity health (NEO-80, NEO-91)](../../../server/README.md#combat-entity-health-neo-80-neo-91). **NEO-81 landed:** **`CombatOperations.TryResolve`** + **`CombatResult`** + **`CombatReasonCodes`** — defeated pre-check deny, catalog damage ([NEO-81](../../plans/NEO-81-implementation-plan.md)); [server README — Combat engine (NEO-81)](../../../server/README.md#combat-engine-neo-81). **NEO-82 landed:** **`AbilityCastApi`** → **`CombatOperations.TryResolve`**; nested wire **`combatResolution`** on accept; per-ability catalog cooldown; **`target_defeated`** cast deny ([NEO-82](../../plans/NEO-82-implementation-plan.md)); [server README — Ability cast (NEO-82)](../../../server/README.md#ability-cast-neo-31-neo-82); Bruno `bruno/neon-sprawl-server/ability-cast/` defeat spine. **NEO-83 landed:** **`GET /game/world/combat-targets`** — `CombatTargetsWorldApi` + DTOs; authoritative HP snapshot from **`ICombatEntityHealthStore`** ([NEO-83](../../plans/NEO-83-implementation-plan.md)); [server README — Combat targets snapshot (NEO-83)](../../../server/README.md#combat-targets-snapshot-neo-83); Bruno `bruno/neon-sprawl-server/combat-targets/`. **NEO-44 landed:** **`CombatDefeatGigXpGrant`** on cast **`targetDefeated`** — **25** gig XP to **`breach`** via **`IPlayerGigProgressionStore`** (V007); **`GET …/gig-progression`** ([NEO-44](../../plans/NEO-44-implementation-plan.md), [`NEO-44` manual QA](../../manual-qa/NEO-44.md)); [server README — Gig progression (NEO-44)](../../../server/README.md#gig-progression-snapshot-neo-44); Bruno `bruno/neon-sprawl-server/gig-progression/`. **NEO-84 landed:** comment-only **`ability_used`** / **`enemy_defeat`** telemetry hook sites in **`CombatOperations.TryResolve`**; reserved **`encounter_start`** / **`player_death`** ([NEO-84](../../plans/NEO-84-implementation-plan.md)); [server README — Combat telemetry hooks (NEO-84)](../../../server/README.md#combat-telemetry-hooks-neo-84). **NEO-85 landed:** client combat HUD — **`ability_cast_client.gd`** parses **`combatResolution`**; **`combat_targets_client.gd`**, **`CastFeedbackLabel`** resolution copy, **`CombatTargetHpLabel`**; event-driven GET refresh ([NEO-85](../../plans/NEO-85-implementation-plan.md), [`NEO-85` manual QA](../../manual-qa/NEO-85.md)); `client/README.md` combat HUD section. **NEO-86 landed:** playable combat capstone — **`gig_progression_client.gd`**, **`GigXpLabel`**, defeat-triggered gig GET refresh; capstone manual QA [`NEO-86`](../../manual-qa/NEO-86.md); `client/README.md` end-to-end combat loop section ([NEO-86](../../plans/NEO-86-implementation-plan.md)). **Epic 5 Slice 1 client capstone complete.** **Backlog decomposed:** Epic 5 Slice 1 — **E5M1-01**–**E5M1-12** landed. **Precursor landed:** E1.M4 cast funnel (NEO-28/31/32). | [E5M1-prototype-backlog.md](../../plans/E5M1-prototype-backlog.md), [E5_M1](E5_M1_CombatRulesEngine.md), [NEO-77](../../plans/NEO-77-implementation-plan.md), [NEO-79](../../plans/NEO-79-implementation-plan.md), [NEO-78](../../plans/NEO-78-implementation-plan.md), [NEO-80](../../plans/NEO-80-implementation-plan.md), [NEO-81](../../plans/NEO-81-implementation-plan.md), [NEO-82](../../plans/NEO-82-implementation-plan.md), [NEO-83](../../plans/NEO-83-implementation-plan.md), [NEO-44](../../plans/NEO-44-implementation-plan.md), [NEO-84](../../plans/NEO-84-implementation-plan.md), [NEO-85](../../plans/NEO-85-implementation-plan.md), [NEO-86](../../plans/NEO-86-implementation-plan.md), [E1M4-prototype-backlog.md](../../plans/E1M4-prototype-backlog.md) | | E5.M2 | Ready | **NEO-87 landed:** frozen prototype three-behavior catalog in [`content/npc-behaviors/prototype_npc_behaviors.json`](../../../content/npc-behaviors/prototype_npc_behaviors.json); [`npc-behavior-def.schema.json`](../../../content/schemas/npc-behavior-def.schema.json); PR gate + [`validate_content.py`](../../../scripts/validate_content.py) E5M2 three-id gate ([NEO-87](../../plans/NEO-87-implementation-plan.md)). **NEO-88 landed:** fail-fast server load of `content/npc-behaviors/*_npc_behaviors.json` at startup — `server/NeonSprawl.Server/Game/Npc/` ([NEO-88](../../plans/NEO-88-implementation-plan.md)); [server README — NPC behavior catalog](../../../server/README.md#npc-behavior-catalog-contentnpc-behaviors-neo-88). **NEO-89 landed:** injectable **`INpcBehaviorDefinitionRegistry`** + DI; **`PrototypeNpcBehaviorRegistry`** id constants ([NEO-89](../../plans/NEO-89-implementation-plan.md)). **NEO-90 landed:** **`GET /game/world/npc-behavior-definitions`** — `NpcBehaviorDefinitionsWorldApi` + DTOs in `Game/Npc/` ([NEO-90](../../plans/NEO-90-implementation-plan.md)); [server README — NPC behavior definitions (NEO-90)](../../../server/README.md#npc-behavior-definitions-neo-90); Bruno `bruno/neon-sprawl-server/npc-behavior-definitions/`. **NEO-91 landed:** **`PrototypeNpcRegistry`** + combat-target migration — three NPC instance ids replace alpha/beta; per-archetype max HP from behavior catalog ([NEO-91](../../plans/NEO-91-implementation-plan.md)); [server README — Combat entity health (NEO-91)](../../../server/README.md#combat-entity-health-neo-80-neo-91). **NEO-92 landed:** **`IThreatStateStore`** + **`AggroOperations`** — first-hit acquire on damaging cast, leash clear on move/cast; cast/move/fixture hooks ([NEO-92](../../plans/NEO-92-implementation-plan.md)); [server README — Threat / aggro state (NEO-92)](../../../server/README.md#threat--aggro-state-neo-92). **NEO-93 landed:** **`INpcRuntimeStateStore`** + **`NpcRuntimeOperations.AdvanceAll`** — catalog-driven behavior state machine, lazy tick advance with delta cap; dev fixture reset ([NEO-93](../../plans/NEO-93-implementation-plan.md)); [server README — NPC runtime behavior state (NEO-93)](../../../server/README.md#npc-runtime-behavior-state-neo-93). **NEO-94 landed:** **`GET /game/world/npc-runtime-snapshot`** — `NpcRuntimeSnapshotWorldApi` + DTOs; lazy **`AdvanceAll`** on poll; nested **`activeTelegraph`** with server-computed **`windupRemainingSeconds`** ([NEO-94](../../plans/NEO-94-implementation-plan.md)); [server README — NPC runtime snapshot (NEO-94)](../../../server/README.md#npc-runtime-snapshot-neo-94); Bruno `bruno/neon-sprawl-server/npc-runtime-snapshot/`. **NEO-95 landed:** **`IPlayerCombatHealthStore`** + **`NpcAttackOperations.TryResolveTelegraphComplete`** — telegraph resolve via behavior **`attackAbilityId`** + ability **`maxRange`** / **`baseDamage`** ([NEO-95](../../plans/NEO-95-implementation-plan.md), [NEO-98](../../plans/NEO-98-implementation-plan.md)); [server README — Session player combat HP (NEO-95)](../../../server/README.md#session-player-combat-hp-neo-95); Bruno `bruno/neon-sprawl-server/combat-health/`. **NEO-96 landed:** comment-only **`telegraph_fired`** + **`npc_state_transition`** hook sites in **`NpcRuntimeOperations.AdvanceAll`** ([NEO-96](../../plans/NEO-96-implementation-plan.md)); [server README — NPC runtime telemetry hooks (NEO-96)](../../../server/README.md#npc-runtime-telemetry-hooks-neo-96). **NEO-97 landed:** client telegraph HUD — **`npc_runtime_client.gd`**, **`player_combat_health_client.gd`**, **`npc_runtime_hud_state.gd`**, **`TelegraphLabel`** / **`NpcStateLabel`** / **`PlayerCombatHpLabel`**; combat-active ~1 Hz poll ([NEO-97](../../plans/NEO-97-implementation-plan.md), [`NEO-97` manual QA](../../manual-qa/NEO-97.md)); [client README — NPC runtime + telegraph HUD (NEO-97)](../../../client/README.md#npc-runtime--telegraph-hud-neo-97). **NEO-98 landed:** playable NPC telegraph combat capstone — [`NEO-98` manual QA](../../manual-qa/NEO-98.md); `client/README.md` end-to-end NPC telegraph combat loop section ([NEO-98](../../plans/NEO-98-implementation-plan.md)). **NEO-98 server integration:** defeat clears aggro/runtime (**`TryStopOnTargetDefeat`**); telegraph damage range-gated via [`prototype_npc_abilities.json`](../../../content/abilities/prototype_npc_abilities.json) + behavior **`attackAbilityId`**. **Epic 5 Slice 2 client capstone complete.** **Backlog decomposed:** Epic 5 Slice 2 — [E5M2-prototype-backlog.md](../../plans/E5M2-prototype-backlog.md) **E5M2-01** [NEO-87](https://linear.app/neon-sprawl/issue/NEO-87) → **E5M2-12** [NEO-98](https://linear.app/neon-sprawl/issue/NEO-98) **landed**. Three archetypes (`prototype_melee_pressure`, `prototype_ranged_control`, `prototype_elite_mini_boss`); replaces alpha/beta dummies with **`prototype_npc_melee` / `ranged` / `elite`**. Owns **`ThreatState`**, **`TelegraphEvent`**, **`GET …/npc-runtime-snapshot`**, session **`IPlayerCombatHealthStore`**. Upstream **E5.M1 Ready**. | [E5M2-prototype-backlog.md](../../plans/E5M2-prototype-backlog.md), [E5_M2](E5_M2_NpcAiAndBehaviorProfiles.md), [NEO-87](../../plans/NEO-87-implementation-plan.md), [NEO-88](../../plans/NEO-88-implementation-plan.md), [NEO-89](../../plans/NEO-89-implementation-plan.md), [NEO-90](../../plans/NEO-90-implementation-plan.md), [NEO-91](../../plans/NEO-91-implementation-plan.md), [NEO-92](../../plans/NEO-92-implementation-plan.md), [NEO-93](../../plans/NEO-93-implementation-plan.md), [NEO-94](../../plans/NEO-94-implementation-plan.md), [NEO-95](../../plans/NEO-95-implementation-plan.md), [NEO-96](../../plans/NEO-96-implementation-plan.md), [NEO-97](../../plans/NEO-97-implementation-plan.md), [NEO-98](../../plans/NEO-98-implementation-plan.md), label **`E5.M2`** on NEO-87–NEO-98 | | E5.M3 | Ready | **E5M3-01 catalog landed ([NEO-100](https://linear.app/neon-sprawl/issue/NEO-100)):** encounter + reward-table schemas, `prototype_encounters.json`, `prototype_reward_tables.json`, CI gates. **E5M3-02 server load ([NEO-101](https://linear.app/neon-sprawl/issue/NEO-101)):** fail-fast startup loaders for encounter + reward-table catalogs (CI parity). **E5M3-03 registries ([NEO-102](https://linear.app/neon-sprawl/issue/NEO-102)):** `IEncounterDefinitionRegistry` + `IRewardTableDefinitionRegistry` + DI. **E5M3-04 HTTP read ([NEO-103](https://linear.app/neon-sprawl/issue/NEO-103)):** **`GET /game/world/encounter-definitions`** — `EncounterDefinitionsWorldApi` + nested **`rewardTable`** summary ([NEO-103](../../plans/NEO-103-implementation-plan.md)); [server README — Encounter definitions (NEO-103)](../../../server/README.md#encounter-definitions-neo-103); Bruno `bruno/neon-sprawl-server/encounter-definitions/`. **E5M3-05 stores ([NEO-104](https://linear.app/neon-sprawl/issue/NEO-104)):** **`IEncounterProgressStore`** + **`IEncounterCompletionStore`** + **`EncounterProgressOperations`** ([NEO-104](../../plans/NEO-104-implementation-plan.md)); [server README — Encounter progress (NEO-104)](../../../server/README.md#encounter-progress--completion-stores-neo-104). **E5M3-06 completion grants ([NEO-105](https://linear.app/neon-sprawl/issue/NEO-105)):** **`EncounterCompletionOperations`** + **`EncounterCompleteEvent`** result payload ([NEO-105](../../plans/NEO-105-implementation-plan.md)); [server README — Encounter completion (NEO-105)](../../../server/README.md#encounter-completion--inventory-grants-neo-105). **E5M3-07 combat wiring ([NEO-106](https://linear.app/neon-sprawl/issue/NEO-106)):** **`EncounterCombatWiring`** on **`AbilityCastApi`** ([NEO-106](../../plans/NEO-106-implementation-plan.md)); [server README — Encounter combat wiring (NEO-106)](../../../server/README.md#encounter-combat-wiring-neo-106). **E5M3-09 event record ([NEO-107](https://linear.app/neon-sprawl/issue/NEO-107)):** **`IEncounterCompleteEventStore`** + E7.M2 hook stub ([NEO-107](../../plans/NEO-107-implementation-plan.md)); [server README — Encounter complete event (NEO-107)](../../../server/README.md#encounter-complete-event-record-neo-107). **E5M3-08 per-player GET ([NEO-108](https://linear.app/neon-sprawl/issue/NEO-108)):** **`GET /game/players/{id}/encounter-progress`** — `EncounterProgressApi` + DTOs ([NEO-108](../../plans/NEO-108-implementation-plan.md)); [server README — Per-player encounter progress (NEO-108)](../../../server/README.md#per-player-encounter-progress-neo-108); Bruno `bruno/neon-sprawl-server/encounter-progress/`. **E5M3-10 telemetry ([NEO-109](https://linear.app/neon-sprawl/issue/NEO-109)):** comment-only **`encounter_start`**, **`encounter_complete`**, **`reward_attribution`**, **`encounter_complete_denied`** in **`EncounterProgressOperations`** / **`EncounterCompletionOperations`** ([NEO-109](../../plans/NEO-109-implementation-plan.md)); [server README — Encounter telemetry hooks (NEO-109)](../../../server/README.md#encounter-telemetry-hooks-neo-109). **E5M3-11 client HUD ([NEO-110](https://linear.app/neon-sprawl/issue/NEO-110)):** **`encounter_progress_client.gd`**, **`EncounterProgressLabel`** / **`EncounterCompleteLabel`**; boot + defeat-triggered GET; inventory refresh on **`completed`** ([NEO-110](../../plans/NEO-110-implementation-plan.md), [`NEO-110` manual QA](../../manual-qa/NEO-110.md)); [client README — Encounter progress + loot HUD (NEO-110)](../../../client/README.md#encounter-progress--loot-hud-neo-110). **E5M3-12 client capstone ([NEO-111](https://linear.app/neon-sprawl/issue/NEO-111)):** playable encounter clear loop — [`NEO-111` manual QA](../../manual-qa/NEO-111.md); [client README — End-to-end encounter clear loop (NEO-111)](../../../client/README.md#end-to-end-encounter-clear-loop-neo-111); plan [NEO-111](../../plans/NEO-111-implementation-plan.md). **Epic 5 Slice 3 client capstone complete.** Epic 5 Slice 3 backlog [E5M3-prototype-backlog.md](../../plans/E5M3-prototype-backlog.md) **E5M3-01** [NEO-100](https://linear.app/neon-sprawl/issue/NEO-100) → **E5M3-12** [NEO-111](https://linear.app/neon-sprawl/issue/NEO-111) **landed**. Prototype spine: **`prototype_combat_pocket`** → **`prototype_combat_pocket_clear`**; idempotent completion; per-defeat gig XP unchanged ([NEO-44](../../plans/NEO-44-implementation-plan.md)). Upstream **E5.M2 Ready**, **E3.M3** inventory landed. | [NEO-111 plan](../../plans/NEO-111-implementation-plan.md), [NEO-110 plan](../../plans/NEO-110-implementation-plan.md), [NEO-109 plan](../../plans/NEO-109-implementation-plan.md), [NEO-108 plan](../../plans/NEO-108-implementation-plan.md), [NEO-107 plan](../../plans/NEO-107-implementation-plan.md), [NEO-106 plan](../../plans/NEO-106-implementation-plan.md), [NEO-105 plan](../../plans/NEO-105-implementation-plan.md), [NEO-104 plan](../../plans/NEO-104-implementation-plan.md), [NEO-103 plan](../../plans/NEO-103-implementation-plan.md), [NEO-102 plan](../../plans/NEO-102-implementation-plan.md), [E5_M3](E5_M3_EncounterAndRewardTables.md), label **`E5.M3`** on NEO-100–NEO-111 | -| E7.M2 | Planned | **E7M2-01 catalog landed ([NEO-124](https://linear.app/neon-sprawl/issue/NEO-124)):** `quest-reward-bundle` + `quest-skill-xp-grant` schemas; **`completionRewardBundle`** on four frozen E7.M1 quests; CI bundle freeze + cross-ref gates. **E7M2-02 server load ([NEO-125](https://linear.app/neon-sprawl/issue/NEO-125)):** fail-fast quest catalog **`completionRewardBundle`** validation at startup — `PrototypeE7M2QuestCatalogRules` + bundle schema `$ref` registration ([NEO-125](../../plans/NEO-125-implementation-plan.md)); [server README — Quest catalog (NEO-125)](../../../server/README.md#quest-catalog-contentquests-neo-113). **E7M2-03 delivery store ([NEO-126](https://linear.app/neon-sprawl/issue/NEO-126)):** idempotent **`IRewardDeliveryStore`** + **`RewardDeliveryEvent`** in `Game/Rewards/` — in-memory prototype ([NEO-126](../../plans/NEO-126-implementation-plan.md)); [server README — Reward delivery store (NEO-126)](../../../server/README.md#reward-delivery-store-neo-126). **E7M2-04 router apply ([NEO-127](https://linear.app/neon-sprawl/issue/NEO-127)):** **`RewardRouterOperations.TryDeliverQuestCompletion`** — item + skill XP bundle apply with compensating rollback + store record ([NEO-127](../../plans/NEO-127-implementation-plan.md)); [server README — Reward router (NEO-127)](../../../server/README.md#reward-router-neo-127). **E7M2-05 quest-state wiring ([NEO-128](https://linear.app/neon-sprawl/issue/NEO-128)):** **`QuestStateOperations.TryMarkComplete`** + **`QuestObjectiveWiring`** deliver-then-mark via router ([NEO-128](../../plans/NEO-128-implementation-plan.md)); [server README — Quest state operations (NEO-117)](../../../server/README.md#quest-state-operations-neo-117). **E7M2-06 HTTP read ([NEO-129](https://linear.app/neon-sprawl/issue/NEO-129)):** **`GET …/quest-progress`** **`completionRewardSummary`** from **`IRewardDeliveryStore`** ([NEO-129](../../plans/NEO-129-implementation-plan.md)); Bruno `bruno/neon-sprawl-server/quest-progress/Get quest progress after gather intro complete.bru`. **E7M2-07 telemetry ([NEO-130](https://linear.app/neon-sprawl/issue/NEO-130)):** comment-only **`reward_delivery`** + **`unlock_granted`** stub hook sites in **`RewardRouterOperations.TryDeliverQuestCompletion`** ([NEO-130](../../plans/NEO-130-implementation-plan.md)); [server README — Reward telemetry hooks (NEO-130)](../../../server/README.md#reward-telemetry-hooks-neo-130). **Backlog decomposed:** Epic 7 Slice 2 — [E7M2-prototype-backlog.md](../../plans/E7M2-prototype-backlog.md) **E7M2-03** [NEO-126](https://linear.app/neon-sprawl/issue/NEO-126) → **E7M2-09** [NEO-132](https://linear.app/neon-sprawl/issue/NEO-132); label **`E7.M2`**. Client capstone **E7M2-09** [NEO-132](https://linear.app/neon-sprawl/issue/NEO-132). **NEO-43 prep landed:** **`MissionRewardSkillXpGrant`**. **Encounter loot unchanged** (E5.M3 direct grants). Upstream: E7.M1 **Ready**, E2.M2 grant stack, E3.M3 inventory **Ready**. Register row stays **Planned** until capstone **NEO-132**. | [E7M2-prototype-backlog.md](../../plans/E7M2-prototype-backlog.md), [E7_M2](E7_M2_RewardAndUnlockRouter.md), [NEO-124](../../plans/NEO-124-implementation-plan.md), [NEO-125](../../plans/NEO-125-implementation-plan.md), [NEO-126](../../plans/NEO-126-implementation-plan.md), [NEO-127](../../plans/NEO-127-implementation-plan.md), [NEO-128](../../plans/NEO-128-implementation-plan.md), [NEO-129](../../plans/NEO-129-implementation-plan.md), [NEO-130](../../plans/NEO-130-implementation-plan.md), [NEO-43](../../plans/NEO-43-implementation-plan.md), label **`E7.M2`** on NEO-124–NEO-132 | +| E7.M2 | Planned | **E7M2-01 catalog landed ([NEO-124](https://linear.app/neon-sprawl/issue/NEO-124)):** `quest-reward-bundle` + `quest-skill-xp-grant` schemas; **`completionRewardBundle`** on four frozen E7.M1 quests; CI bundle freeze + cross-ref gates. **E7M2-02 server load ([NEO-125](https://linear.app/neon-sprawl/issue/NEO-125)):** fail-fast quest catalog **`completionRewardBundle`** validation at startup — `PrototypeE7M2QuestCatalogRules` + bundle schema `$ref` registration ([NEO-125](../../plans/NEO-125-implementation-plan.md)); [server README — Quest catalog (NEO-125)](../../../server/README.md#quest-catalog-contentquests-neo-113). **E7M2-03 delivery store ([NEO-126](https://linear.app/neon-sprawl/issue/NEO-126)):** idempotent **`IRewardDeliveryStore`** + **`RewardDeliveryEvent`** in `Game/Rewards/` — in-memory prototype ([NEO-126](../../plans/NEO-126-implementation-plan.md)); [server README — Reward delivery store (NEO-126)](../../../server/README.md#reward-delivery-store-neo-126). **E7M2-04 router apply ([NEO-127](https://linear.app/neon-sprawl/issue/NEO-127)):** **`RewardRouterOperations.TryDeliverQuestCompletion`** — item + skill XP bundle apply with compensating rollback + store record ([NEO-127](../../plans/NEO-127-implementation-plan.md)); [server README — Reward router (NEO-127)](../../../server/README.md#reward-router-neo-127). **E7M2-05 quest-state wiring ([NEO-128](https://linear.app/neon-sprawl/issue/NEO-128)):** **`QuestStateOperations.TryMarkComplete`** + **`QuestObjectiveWiring`** deliver-then-mark via router ([NEO-128](../../plans/NEO-128-implementation-plan.md)); [server README — Quest state operations (NEO-117)](../../../server/README.md#quest-state-operations-neo-117). **E7M2-06 HTTP read ([NEO-129](https://linear.app/neon-sprawl/issue/NEO-129)):** **`GET …/quest-progress`** **`completionRewardSummary`** from **`IRewardDeliveryStore`** ([NEO-129](../../plans/NEO-129-implementation-plan.md)); Bruno `bruno/neon-sprawl-server/quest-progress/Get quest progress after gather intro complete.bru`. **E7M2-07 telemetry ([NEO-130](https://linear.app/neon-sprawl/issue/NEO-130)):** comment-only **`reward_delivery`** + **`unlock_granted`** stub hook sites in **`RewardRouterOperations.TryDeliverQuestCompletion`** ([NEO-130](../../plans/NEO-130-implementation-plan.md)); [server README — Reward telemetry hooks (NEO-130)](../../../server/README.md#reward-telemetry-hooks-neo-130). **E7M2-08 client HUD ([NEO-131](https://linear.app/neon-sprawl/issue/NEO-131)):** Godot **`QuestRewardDeliveryLabel`** paints **`completionRewardSummary`** on in-session completion transition ([NEO-131](../../plans/NEO-131-implementation-plan.md)); [client README — Quest completion reward HUD (NEO-131)](../../../client/README.md#quest-completion-reward-hud-neo-131). **Backlog decomposed:** Epic 7 Slice 2 — [E7M2-prototype-backlog.md](../../plans/E7M2-prototype-backlog.md) **E7M2-03** [NEO-126](https://linear.app/neon-sprawl/issue/NEO-126) → **E7M2-09** [NEO-132](https://linear.app/neon-sprawl/issue/NEO-132); label **`E7.M2`**. Client capstone **E7M2-09** [NEO-132](https://linear.app/neon-sprawl/issue/NEO-132). **NEO-43 prep landed:** **`MissionRewardSkillXpGrant`**. **Encounter loot unchanged** (E5.M3 direct grants). Upstream: E7.M1 **Ready**, E2.M2 grant stack, E3.M3 inventory **Ready**. Register row stays **Planned** until capstone **NEO-132**. | [E7M2-prototype-backlog.md](../../plans/E7M2-prototype-backlog.md), [E7_M2](E7_M2_RewardAndUnlockRouter.md), [NEO-124](../../plans/NEO-124-implementation-plan.md), [NEO-125](../../plans/NEO-125-implementation-plan.md), [NEO-126](../../plans/NEO-126-implementation-plan.md), [NEO-127](../../plans/NEO-127-implementation-plan.md), [NEO-128](../../plans/NEO-128-implementation-plan.md), [NEO-129](../../plans/NEO-129-implementation-plan.md), [NEO-130](../../plans/NEO-130-implementation-plan.md), [NEO-43](../../plans/NEO-43-implementation-plan.md), label **`E7.M2`** on NEO-124–NEO-132 | | E7.M1 | Ready | **E7M1-01 catalog landed ([NEO-112](https://linear.app/neon-sprawl/issue/NEO-112)):** quest schemas, `prototype_quests.json`, CI gates (four frozen quest ids, objective cross-refs, acyclic prerequisites, chain terminal token). **E7M1-02 server load ([NEO-113](https://linear.app/neon-sprawl/issue/NEO-113)):** fail-fast startup load of `content/quests/*_quests.json` — `server/NeonSprawl.Server/Game/Quests/` ([NEO-113](../../plans/NEO-113-implementation-plan.md)); [server README — Quest catalog](../../../server/README.md#quest-catalog-contentquests-neo-113). **E7M1-03 registry ([NEO-114](https://linear.app/neon-sprawl/issue/NEO-114)):** **`IQuestDefinitionRegistry`** + DI ([NEO-114](../../plans/NEO-114-implementation-plan.md)). **E7M1-04 HTTP read ([NEO-115](https://linear.app/neon-sprawl/issue/NEO-115)):** **`GET /game/world/quest-definitions`** — `QuestDefinitionsWorldApi` + DTOs ([NEO-115](../../plans/NEO-115-implementation-plan.md)); [server README — Quest definitions (NEO-115)](../../../server/README.md#quest-definitions-neo-115); Bruno `bruno/neon-sprawl-server/quest-definitions/`. **E7M1-05 store ([NEO-116](https://linear.app/neon-sprawl/issue/NEO-116)):** **`IPlayerQuestStateStore`** + in-memory/Postgres persistence ([NEO-116](../../plans/NEO-116-implementation-plan.md)); [server README — Quest progress store (NEO-116)](../../../server/README.md#quest-progress-store-neo-116). **E7M1-06 operations ([NEO-117](https://linear.app/neon-sprawl/issue/NEO-117)):** **`QuestStateOperations`** + reason codes ([NEO-117](../../plans/NEO-117-implementation-plan.md)); [server README — Quest state operations (NEO-117)](../../../server/README.md#quest-state-operations-neo-117). **E7M1-07 objective wiring ([NEO-118](https://linear.app/neon-sprawl/issue/NEO-118)):** **`QuestObjectiveWiring`** on gather/craft/encounter + **`inventory_has_item`** snapshot passes ([NEO-118](../../plans/NEO-118-implementation-plan.md)); [server README — Quest objective wiring (NEO-118)](../../../server/README.md#quest-objective-wiring-neo-118). **E7M1-08 per-player GET ([NEO-119](https://linear.app/neon-sprawl/issue/NEO-119)):** **`GET /game/players/{id}/quest-progress`** — `QuestProgressApi` + DTOs; GET-side inventory refresh ([NEO-119](../../plans/NEO-119-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/`. **E7M1-09 accept POST ([NEO-120](https://linear.app/neon-sprawl/issue/NEO-120)):** **`POST /game/players/{id}/quests/{questId}/accept`** — `QuestAcceptApi` + DTOs; **`QuestStateOperations.TryAccept`** ([NEO-120](../../plans/NEO-120-implementation-plan.md)); [server README — Quest accept POST (NEO-120)](../../../server/README.md#quest-accept-post-neo-120); Bruno accept spine in `bruno/neon-sprawl-server/quest-progress/`. **E7M1-10 telemetry ([NEO-121](https://linear.app/neon-sprawl/issue/NEO-121)):** comment-only **`quest_start`**, **`quest_step_complete`**, **`quest_complete`**, **`quest_accept_denied`** hook sites in **`QuestStateOperations`** ([NEO-121](../../plans/NEO-121-implementation-plan.md)); [server README — Quest telemetry hooks (NEO-121)](../../../server/README.md#quest-telemetry-hooks-neo-121). **E7M1-11 client HUD ([NEO-122](https://linear.app/neon-sprawl/issue/NEO-122)):** **`quest_progress_client.gd`**, **`quest_definitions_client.gd`**, **`QuestProgressLabel`** / **`QuestAcceptFeedbackLabel`**; boot + event-driven GET refresh; **Q** / **Shift+Q** accept ([NEO-122](../../plans/NEO-122-implementation-plan.md), [`NEO-122` manual QA](../../manual-qa/NEO-122.md)); [client README — Quest progress + accept HUD (NEO-122)](../../../client/README.md#quest-progress--accept-hud-neo-122). **E7M1-12 client capstone ([NEO-123](https://linear.app/neon-sprawl/issue/NEO-123)):** playable four-quest onboarding chain — [`NEO-123` manual QA](../../manual-qa/NEO-123.md); [client README — End-to-end onboarding quest loop (NEO-123)](../../../client/README.md#end-to-end-onboarding-quest-loop-neo-123); plan [NEO-123](../../plans/NEO-123-implementation-plan.md). **Epic 7 Slice 1 client capstone complete.** Backlog **E7M1-01** [NEO-112](https://linear.app/neon-sprawl/issue/NEO-112) → **E7M1-12** [NEO-123](https://linear.app/neon-sprawl/issue/NEO-123) **landed**. Upstream: E3.M1 gather **Ready**, E3.M2 craft **Ready**, E5.M3 encounter **`contract_handoff_token`** + **`EncounterCompleteEvent`** **Ready**. | [NEO-123 plan](../../plans/NEO-123-implementation-plan.md), [NEO-122 plan](../../plans/NEO-122-implementation-plan.md), [NEO-121 plan](../../plans/NEO-121-implementation-plan.md), [NEO-120 plan](../../plans/NEO-120-implementation-plan.md), [NEO-119 plan](../../plans/NEO-119-implementation-plan.md), [NEO-118 plan](../../plans/NEO-118-implementation-plan.md), [NEO-117 plan](../../plans/NEO-117-implementation-plan.md), [NEO-116 plan](../../plans/NEO-116-implementation-plan.md), [NEO-115 plan](../../plans/NEO-115-implementation-plan.md), [NEO-114 plan](../../plans/NEO-114-implementation-plan.md), [NEO-113 plan](../../plans/NEO-113-implementation-plan.md), [NEO-112 plan](../../plans/NEO-112-implementation-plan.md), [E7M1-prototype-backlog.md](../../plans/E7M1-prototype-backlog.md), [E7_M1](E7_M1_QuestStateMachine.md), label **`E7.M1`** on NEO-112–NEO-123 | --- diff --git a/docs/manual-qa/NEO-131.md b/docs/manual-qa/NEO-131.md new file mode 100644 index 0000000..d9e9643 --- /dev/null +++ b/docs/manual-qa/NEO-131.md @@ -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. diff --git a/docs/plans/E7M2-prototype-backlog.md b/docs/plans/E7M2-prototype-backlog.md index f3c8f41..171b2ee 100644 --- a/docs/plans/E7M2-prototype-backlog.md +++ b/docs/plans/E7M2-prototype-backlog.md @@ -267,8 +267,10 @@ Combat intro bundle is **skill XP only** — encounter loot already granted **`c **Acceptance criteria** -- [ ] Completing a quest shows readable reward copy on HUD. -- [ ] Failed GET surfaces visible error (NEO-122 pattern). +- [x] Completing a quest shows readable reward copy on HUD. +- [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). diff --git a/docs/plans/NEO-131-implementation-plan.md b/docs/plans/NEO-131-implementation-plan.md index 56a18f8..3d87fa3 100644 --- a/docs/plans/NEO-131-implementation-plan.md +++ b/docs/plans/NEO-131-implementation-plan.md @@ -53,8 +53,16 @@ ## Acceptance criteria checklist -- [ ] Completing a quest shows readable reward copy on HUD. -- [ ] Failed GET surfaces visible error (NEO-122 pattern). +- [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, sync error). +- **Docs:** `client/README.md`; `docs/manual-qa/NEO-131.md`; E7.M2 module + backlog alignment. ## Technical approach From ca9a3f1b388da6f635af9c88d8cc35f23c9c2c41 Mon Sep 17 00:00:00 2001 From: VinPropane Date: Sun, 7 Jun 2026 23:01:27 -0400 Subject: [PATCH 4/8] NEO-131: Add code review for quest completion reward HUD. --- docs/reviews/2026-06-07-NEO-131.md | 68 ++++++++++++++++++++++++++++++ 1 file changed, 68 insertions(+) create mode 100644 docs/reviews/2026-06-07-NEO-131.md diff --git a/docs/reviews/2026-06-07-NEO-131.md b/docs/reviews/2026-06-07-NEO-131.md new file mode 100644 index 0000000..493f74e --- /dev/null +++ b/docs/reviews/2026-06-07-NEO-131.md @@ -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` | **Partially matches** — E7M2-08 / NEO-131 landed note in row body; references column omits explicit [NEO-131 plan](../../plans/NEO-131-implementation-plan.md) link (nit). | +| `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. + +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. + +## 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-124–NEO-130 plan links but not **`NEO-131-implementation-plan.md`** — row body already cites NEO-131; add link for parity with other landed slices. + +- 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. From 0e13a10b79c4a1a79f299a93265a488f45a7a60a Mon Sep 17 00:00:00 2001 From: VinPropane Date: Sun, 7 Jun 2026 23:02:14 -0400 Subject: [PATCH 5/8] =?UTF-8?q?NEO-131:=20Address=20review=20=E2=80=94=20i?= =?UTF-8?q?tem=20grant=20and=20defs=20repaint=20tests.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add operator-chain item display name and definitions_ready reward label GdUnit cases; add NEO-131 plan link to alignment register. --- client/test/quest_hud_controller_test.gd | 93 +++++++++++++++++++ ...umentation_and_implementation_alignment.md | 2 +- docs/plans/NEO-131-implementation-plan.md | 4 +- docs/reviews/2026-06-07-NEO-131.md | 8 +- 4 files changed, 100 insertions(+), 7 deletions(-) diff --git a/client/test/quest_hud_controller_test.gd b/client/test/quest_hud_controller_test.gd index 5cc2f55..f91ae6d 100644 --- a/client/test/quest_hud_controller_test.gd +++ b/client/test/quest_hud_controller_test.gd @@ -6,6 +6,8 @@ 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 OPERATOR_CHAIN_QUEST_ID := "prototype_quest_operator_chain" + class MockHttpTransport: extends Node @@ -75,6 +77,15 @@ class FailStartAcceptHttpTransport: return ERR_UNAVAILABLE +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_progress_client(sync_transport: Node, accept_transport: Node = null) -> Node: var c: Node = QuestProgressClient.new() c.set("injected_sync_http", sync_transport) @@ -548,6 +559,88 @@ func test_boot_completed_snapshot_does_not_paint_reward_label() -> void: assert_str(reward_label.text).is_equal("Quest rewards:\n—") +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 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_sync_failure_shows_progress_error_and_idle_reward_label() -> void: # Arrange var controller: Node = QuestHudController.new() diff --git a/docs/decomposition/modules/documentation_and_implementation_alignment.md b/docs/decomposition/modules/documentation_and_implementation_alignment.md index 14cb53f..c64b474 100644 --- a/docs/decomposition/modules/documentation_and_implementation_alignment.md +++ b/docs/decomposition/modules/documentation_and_implementation_alignment.md @@ -59,7 +59,7 @@ Rows appear when work starts; default for unlisted modules is **Planned** / not | E5.M1 | Ready | **NEO-76 landed:** frozen prototype four-ability catalog in [`content/abilities/prototype_abilities.json`](../../../content/abilities/prototype_abilities.json); [`ability-def.schema.json`](../../../content/schemas/ability-def.schema.json); PR gate + [`validate_content.py`](../../../scripts/validate_content.py) E5M1 four-id gate ([NEO-76](../../plans/NEO-76-implementation-plan.md)). **NEO-77 landed:** fail-fast server load of `content/abilities/*_abilities.json` at startup — `server/NeonSprawl.Server/Game/Combat/` ([NEO-77](../../plans/NEO-77-implementation-plan.md)); [server README — Ability catalog](../../../server/README.md#ability-catalog-contentabilities-neo-77). **NEO-79 landed:** injectable **`IAbilityDefinitionRegistry`** + DI; hotbar/cast use **`TryNormalizeKnown`** ([NEO-79](../../plans/NEO-79-implementation-plan.md)); Bruno `bruno/neon-sprawl-server/hotbar-loadout/` + `ability-cast/` unknown-ability deny smokes. **NEO-78 landed:** **`GET /game/world/ability-definitions`** — `AbilityDefinitionsWorldApi` + DTOs in `Game/Combat/` ([NEO-78](../../plans/NEO-78-implementation-plan.md)); [server README — Ability definitions (NEO-78)](../../../server/README.md#ability-definitions-neo-78); Bruno `bruno/neon-sprawl-server/ability-definitions/`. **NEO-80 landed:** **`ICombatEntityHealthStore`** / **`InMemoryCombatEntityHealthStore`** — lazy init (Slice 1 flat 100 HP; **superseded by [NEO-91](../../plans/NEO-91-implementation-plan.md)** per-archetype catalog max HP + three NPC instance ids); DI via **`AddCombatEntityHealthStore()`** ([NEO-80](../../plans/NEO-80-implementation-plan.md)); [server README — Combat entity health (NEO-80, NEO-91)](../../../server/README.md#combat-entity-health-neo-80-neo-91). **NEO-81 landed:** **`CombatOperations.TryResolve`** + **`CombatResult`** + **`CombatReasonCodes`** — defeated pre-check deny, catalog damage ([NEO-81](../../plans/NEO-81-implementation-plan.md)); [server README — Combat engine (NEO-81)](../../../server/README.md#combat-engine-neo-81). **NEO-82 landed:** **`AbilityCastApi`** → **`CombatOperations.TryResolve`**; nested wire **`combatResolution`** on accept; per-ability catalog cooldown; **`target_defeated`** cast deny ([NEO-82](../../plans/NEO-82-implementation-plan.md)); [server README — Ability cast (NEO-82)](../../../server/README.md#ability-cast-neo-31-neo-82); Bruno `bruno/neon-sprawl-server/ability-cast/` defeat spine. **NEO-83 landed:** **`GET /game/world/combat-targets`** — `CombatTargetsWorldApi` + DTOs; authoritative HP snapshot from **`ICombatEntityHealthStore`** ([NEO-83](../../plans/NEO-83-implementation-plan.md)); [server README — Combat targets snapshot (NEO-83)](../../../server/README.md#combat-targets-snapshot-neo-83); Bruno `bruno/neon-sprawl-server/combat-targets/`. **NEO-44 landed:** **`CombatDefeatGigXpGrant`** on cast **`targetDefeated`** — **25** gig XP to **`breach`** via **`IPlayerGigProgressionStore`** (V007); **`GET …/gig-progression`** ([NEO-44](../../plans/NEO-44-implementation-plan.md), [`NEO-44` manual QA](../../manual-qa/NEO-44.md)); [server README — Gig progression (NEO-44)](../../../server/README.md#gig-progression-snapshot-neo-44); Bruno `bruno/neon-sprawl-server/gig-progression/`. **NEO-84 landed:** comment-only **`ability_used`** / **`enemy_defeat`** telemetry hook sites in **`CombatOperations.TryResolve`**; reserved **`encounter_start`** / **`player_death`** ([NEO-84](../../plans/NEO-84-implementation-plan.md)); [server README — Combat telemetry hooks (NEO-84)](../../../server/README.md#combat-telemetry-hooks-neo-84). **NEO-85 landed:** client combat HUD — **`ability_cast_client.gd`** parses **`combatResolution`**; **`combat_targets_client.gd`**, **`CastFeedbackLabel`** resolution copy, **`CombatTargetHpLabel`**; event-driven GET refresh ([NEO-85](../../plans/NEO-85-implementation-plan.md), [`NEO-85` manual QA](../../manual-qa/NEO-85.md)); `client/README.md` combat HUD section. **NEO-86 landed:** playable combat capstone — **`gig_progression_client.gd`**, **`GigXpLabel`**, defeat-triggered gig GET refresh; capstone manual QA [`NEO-86`](../../manual-qa/NEO-86.md); `client/README.md` end-to-end combat loop section ([NEO-86](../../plans/NEO-86-implementation-plan.md)). **Epic 5 Slice 1 client capstone complete.** **Backlog decomposed:** Epic 5 Slice 1 — **E5M1-01**–**E5M1-12** landed. **Precursor landed:** E1.M4 cast funnel (NEO-28/31/32). | [E5M1-prototype-backlog.md](../../plans/E5M1-prototype-backlog.md), [E5_M1](E5_M1_CombatRulesEngine.md), [NEO-77](../../plans/NEO-77-implementation-plan.md), [NEO-79](../../plans/NEO-79-implementation-plan.md), [NEO-78](../../plans/NEO-78-implementation-plan.md), [NEO-80](../../plans/NEO-80-implementation-plan.md), [NEO-81](../../plans/NEO-81-implementation-plan.md), [NEO-82](../../plans/NEO-82-implementation-plan.md), [NEO-83](../../plans/NEO-83-implementation-plan.md), [NEO-44](../../plans/NEO-44-implementation-plan.md), [NEO-84](../../plans/NEO-84-implementation-plan.md), [NEO-85](../../plans/NEO-85-implementation-plan.md), [NEO-86](../../plans/NEO-86-implementation-plan.md), [E1M4-prototype-backlog.md](../../plans/E1M4-prototype-backlog.md) | | E5.M2 | Ready | **NEO-87 landed:** frozen prototype three-behavior catalog in [`content/npc-behaviors/prototype_npc_behaviors.json`](../../../content/npc-behaviors/prototype_npc_behaviors.json); [`npc-behavior-def.schema.json`](../../../content/schemas/npc-behavior-def.schema.json); PR gate + [`validate_content.py`](../../../scripts/validate_content.py) E5M2 three-id gate ([NEO-87](../../plans/NEO-87-implementation-plan.md)). **NEO-88 landed:** fail-fast server load of `content/npc-behaviors/*_npc_behaviors.json` at startup — `server/NeonSprawl.Server/Game/Npc/` ([NEO-88](../../plans/NEO-88-implementation-plan.md)); [server README — NPC behavior catalog](../../../server/README.md#npc-behavior-catalog-contentnpc-behaviors-neo-88). **NEO-89 landed:** injectable **`INpcBehaviorDefinitionRegistry`** + DI; **`PrototypeNpcBehaviorRegistry`** id constants ([NEO-89](../../plans/NEO-89-implementation-plan.md)). **NEO-90 landed:** **`GET /game/world/npc-behavior-definitions`** — `NpcBehaviorDefinitionsWorldApi` + DTOs in `Game/Npc/` ([NEO-90](../../plans/NEO-90-implementation-plan.md)); [server README — NPC behavior definitions (NEO-90)](../../../server/README.md#npc-behavior-definitions-neo-90); Bruno `bruno/neon-sprawl-server/npc-behavior-definitions/`. **NEO-91 landed:** **`PrototypeNpcRegistry`** + combat-target migration — three NPC instance ids replace alpha/beta; per-archetype max HP from behavior catalog ([NEO-91](../../plans/NEO-91-implementation-plan.md)); [server README — Combat entity health (NEO-91)](../../../server/README.md#combat-entity-health-neo-80-neo-91). **NEO-92 landed:** **`IThreatStateStore`** + **`AggroOperations`** — first-hit acquire on damaging cast, leash clear on move/cast; cast/move/fixture hooks ([NEO-92](../../plans/NEO-92-implementation-plan.md)); [server README — Threat / aggro state (NEO-92)](../../../server/README.md#threat--aggro-state-neo-92). **NEO-93 landed:** **`INpcRuntimeStateStore`** + **`NpcRuntimeOperations.AdvanceAll`** — catalog-driven behavior state machine, lazy tick advance with delta cap; dev fixture reset ([NEO-93](../../plans/NEO-93-implementation-plan.md)); [server README — NPC runtime behavior state (NEO-93)](../../../server/README.md#npc-runtime-behavior-state-neo-93). **NEO-94 landed:** **`GET /game/world/npc-runtime-snapshot`** — `NpcRuntimeSnapshotWorldApi` + DTOs; lazy **`AdvanceAll`** on poll; nested **`activeTelegraph`** with server-computed **`windupRemainingSeconds`** ([NEO-94](../../plans/NEO-94-implementation-plan.md)); [server README — NPC runtime snapshot (NEO-94)](../../../server/README.md#npc-runtime-snapshot-neo-94); Bruno `bruno/neon-sprawl-server/npc-runtime-snapshot/`. **NEO-95 landed:** **`IPlayerCombatHealthStore`** + **`NpcAttackOperations.TryResolveTelegraphComplete`** — telegraph resolve via behavior **`attackAbilityId`** + ability **`maxRange`** / **`baseDamage`** ([NEO-95](../../plans/NEO-95-implementation-plan.md), [NEO-98](../../plans/NEO-98-implementation-plan.md)); [server README — Session player combat HP (NEO-95)](../../../server/README.md#session-player-combat-hp-neo-95); Bruno `bruno/neon-sprawl-server/combat-health/`. **NEO-96 landed:** comment-only **`telegraph_fired`** + **`npc_state_transition`** hook sites in **`NpcRuntimeOperations.AdvanceAll`** ([NEO-96](../../plans/NEO-96-implementation-plan.md)); [server README — NPC runtime telemetry hooks (NEO-96)](../../../server/README.md#npc-runtime-telemetry-hooks-neo-96). **NEO-97 landed:** client telegraph HUD — **`npc_runtime_client.gd`**, **`player_combat_health_client.gd`**, **`npc_runtime_hud_state.gd`**, **`TelegraphLabel`** / **`NpcStateLabel`** / **`PlayerCombatHpLabel`**; combat-active ~1 Hz poll ([NEO-97](../../plans/NEO-97-implementation-plan.md), [`NEO-97` manual QA](../../manual-qa/NEO-97.md)); [client README — NPC runtime + telegraph HUD (NEO-97)](../../../client/README.md#npc-runtime--telegraph-hud-neo-97). **NEO-98 landed:** playable NPC telegraph combat capstone — [`NEO-98` manual QA](../../manual-qa/NEO-98.md); `client/README.md` end-to-end NPC telegraph combat loop section ([NEO-98](../../plans/NEO-98-implementation-plan.md)). **NEO-98 server integration:** defeat clears aggro/runtime (**`TryStopOnTargetDefeat`**); telegraph damage range-gated via [`prototype_npc_abilities.json`](../../../content/abilities/prototype_npc_abilities.json) + behavior **`attackAbilityId`**. **Epic 5 Slice 2 client capstone complete.** **Backlog decomposed:** Epic 5 Slice 2 — [E5M2-prototype-backlog.md](../../plans/E5M2-prototype-backlog.md) **E5M2-01** [NEO-87](https://linear.app/neon-sprawl/issue/NEO-87) → **E5M2-12** [NEO-98](https://linear.app/neon-sprawl/issue/NEO-98) **landed**. Three archetypes (`prototype_melee_pressure`, `prototype_ranged_control`, `prototype_elite_mini_boss`); replaces alpha/beta dummies with **`prototype_npc_melee` / `ranged` / `elite`**. Owns **`ThreatState`**, **`TelegraphEvent`**, **`GET …/npc-runtime-snapshot`**, session **`IPlayerCombatHealthStore`**. Upstream **E5.M1 Ready**. | [E5M2-prototype-backlog.md](../../plans/E5M2-prototype-backlog.md), [E5_M2](E5_M2_NpcAiAndBehaviorProfiles.md), [NEO-87](../../plans/NEO-87-implementation-plan.md), [NEO-88](../../plans/NEO-88-implementation-plan.md), [NEO-89](../../plans/NEO-89-implementation-plan.md), [NEO-90](../../plans/NEO-90-implementation-plan.md), [NEO-91](../../plans/NEO-91-implementation-plan.md), [NEO-92](../../plans/NEO-92-implementation-plan.md), [NEO-93](../../plans/NEO-93-implementation-plan.md), [NEO-94](../../plans/NEO-94-implementation-plan.md), [NEO-95](../../plans/NEO-95-implementation-plan.md), [NEO-96](../../plans/NEO-96-implementation-plan.md), [NEO-97](../../plans/NEO-97-implementation-plan.md), [NEO-98](../../plans/NEO-98-implementation-plan.md), label **`E5.M2`** on NEO-87–NEO-98 | | E5.M3 | Ready | **E5M3-01 catalog landed ([NEO-100](https://linear.app/neon-sprawl/issue/NEO-100)):** encounter + reward-table schemas, `prototype_encounters.json`, `prototype_reward_tables.json`, CI gates. **E5M3-02 server load ([NEO-101](https://linear.app/neon-sprawl/issue/NEO-101)):** fail-fast startup loaders for encounter + reward-table catalogs (CI parity). **E5M3-03 registries ([NEO-102](https://linear.app/neon-sprawl/issue/NEO-102)):** `IEncounterDefinitionRegistry` + `IRewardTableDefinitionRegistry` + DI. **E5M3-04 HTTP read ([NEO-103](https://linear.app/neon-sprawl/issue/NEO-103)):** **`GET /game/world/encounter-definitions`** — `EncounterDefinitionsWorldApi` + nested **`rewardTable`** summary ([NEO-103](../../plans/NEO-103-implementation-plan.md)); [server README — Encounter definitions (NEO-103)](../../../server/README.md#encounter-definitions-neo-103); Bruno `bruno/neon-sprawl-server/encounter-definitions/`. **E5M3-05 stores ([NEO-104](https://linear.app/neon-sprawl/issue/NEO-104)):** **`IEncounterProgressStore`** + **`IEncounterCompletionStore`** + **`EncounterProgressOperations`** ([NEO-104](../../plans/NEO-104-implementation-plan.md)); [server README — Encounter progress (NEO-104)](../../../server/README.md#encounter-progress--completion-stores-neo-104). **E5M3-06 completion grants ([NEO-105](https://linear.app/neon-sprawl/issue/NEO-105)):** **`EncounterCompletionOperations`** + **`EncounterCompleteEvent`** result payload ([NEO-105](../../plans/NEO-105-implementation-plan.md)); [server README — Encounter completion (NEO-105)](../../../server/README.md#encounter-completion--inventory-grants-neo-105). **E5M3-07 combat wiring ([NEO-106](https://linear.app/neon-sprawl/issue/NEO-106)):** **`EncounterCombatWiring`** on **`AbilityCastApi`** ([NEO-106](../../plans/NEO-106-implementation-plan.md)); [server README — Encounter combat wiring (NEO-106)](../../../server/README.md#encounter-combat-wiring-neo-106). **E5M3-09 event record ([NEO-107](https://linear.app/neon-sprawl/issue/NEO-107)):** **`IEncounterCompleteEventStore`** + E7.M2 hook stub ([NEO-107](../../plans/NEO-107-implementation-plan.md)); [server README — Encounter complete event (NEO-107)](../../../server/README.md#encounter-complete-event-record-neo-107). **E5M3-08 per-player GET ([NEO-108](https://linear.app/neon-sprawl/issue/NEO-108)):** **`GET /game/players/{id}/encounter-progress`** — `EncounterProgressApi` + DTOs ([NEO-108](../../plans/NEO-108-implementation-plan.md)); [server README — Per-player encounter progress (NEO-108)](../../../server/README.md#per-player-encounter-progress-neo-108); Bruno `bruno/neon-sprawl-server/encounter-progress/`. **E5M3-10 telemetry ([NEO-109](https://linear.app/neon-sprawl/issue/NEO-109)):** comment-only **`encounter_start`**, **`encounter_complete`**, **`reward_attribution`**, **`encounter_complete_denied`** in **`EncounterProgressOperations`** / **`EncounterCompletionOperations`** ([NEO-109](../../plans/NEO-109-implementation-plan.md)); [server README — Encounter telemetry hooks (NEO-109)](../../../server/README.md#encounter-telemetry-hooks-neo-109). **E5M3-11 client HUD ([NEO-110](https://linear.app/neon-sprawl/issue/NEO-110)):** **`encounter_progress_client.gd`**, **`EncounterProgressLabel`** / **`EncounterCompleteLabel`**; boot + defeat-triggered GET; inventory refresh on **`completed`** ([NEO-110](../../plans/NEO-110-implementation-plan.md), [`NEO-110` manual QA](../../manual-qa/NEO-110.md)); [client README — Encounter progress + loot HUD (NEO-110)](../../../client/README.md#encounter-progress--loot-hud-neo-110). **E5M3-12 client capstone ([NEO-111](https://linear.app/neon-sprawl/issue/NEO-111)):** playable encounter clear loop — [`NEO-111` manual QA](../../manual-qa/NEO-111.md); [client README — End-to-end encounter clear loop (NEO-111)](../../../client/README.md#end-to-end-encounter-clear-loop-neo-111); plan [NEO-111](../../plans/NEO-111-implementation-plan.md). **Epic 5 Slice 3 client capstone complete.** Epic 5 Slice 3 backlog [E5M3-prototype-backlog.md](../../plans/E5M3-prototype-backlog.md) **E5M3-01** [NEO-100](https://linear.app/neon-sprawl/issue/NEO-100) → **E5M3-12** [NEO-111](https://linear.app/neon-sprawl/issue/NEO-111) **landed**. Prototype spine: **`prototype_combat_pocket`** → **`prototype_combat_pocket_clear`**; idempotent completion; per-defeat gig XP unchanged ([NEO-44](../../plans/NEO-44-implementation-plan.md)). Upstream **E5.M2 Ready**, **E3.M3** inventory landed. | [NEO-111 plan](../../plans/NEO-111-implementation-plan.md), [NEO-110 plan](../../plans/NEO-110-implementation-plan.md), [NEO-109 plan](../../plans/NEO-109-implementation-plan.md), [NEO-108 plan](../../plans/NEO-108-implementation-plan.md), [NEO-107 plan](../../plans/NEO-107-implementation-plan.md), [NEO-106 plan](../../plans/NEO-106-implementation-plan.md), [NEO-105 plan](../../plans/NEO-105-implementation-plan.md), [NEO-104 plan](../../plans/NEO-104-implementation-plan.md), [NEO-103 plan](../../plans/NEO-103-implementation-plan.md), [NEO-102 plan](../../plans/NEO-102-implementation-plan.md), [E5_M3](E5_M3_EncounterAndRewardTables.md), label **`E5.M3`** on NEO-100–NEO-111 | -| E7.M2 | Planned | **E7M2-01 catalog landed ([NEO-124](https://linear.app/neon-sprawl/issue/NEO-124)):** `quest-reward-bundle` + `quest-skill-xp-grant` schemas; **`completionRewardBundle`** on four frozen E7.M1 quests; CI bundle freeze + cross-ref gates. **E7M2-02 server load ([NEO-125](https://linear.app/neon-sprawl/issue/NEO-125)):** fail-fast quest catalog **`completionRewardBundle`** validation at startup — `PrototypeE7M2QuestCatalogRules` + bundle schema `$ref` registration ([NEO-125](../../plans/NEO-125-implementation-plan.md)); [server README — Quest catalog (NEO-125)](../../../server/README.md#quest-catalog-contentquests-neo-113). **E7M2-03 delivery store ([NEO-126](https://linear.app/neon-sprawl/issue/NEO-126)):** idempotent **`IRewardDeliveryStore`** + **`RewardDeliveryEvent`** in `Game/Rewards/` — in-memory prototype ([NEO-126](../../plans/NEO-126-implementation-plan.md)); [server README — Reward delivery store (NEO-126)](../../../server/README.md#reward-delivery-store-neo-126). **E7M2-04 router apply ([NEO-127](https://linear.app/neon-sprawl/issue/NEO-127)):** **`RewardRouterOperations.TryDeliverQuestCompletion`** — item + skill XP bundle apply with compensating rollback + store record ([NEO-127](../../plans/NEO-127-implementation-plan.md)); [server README — Reward router (NEO-127)](../../../server/README.md#reward-router-neo-127). **E7M2-05 quest-state wiring ([NEO-128](https://linear.app/neon-sprawl/issue/NEO-128)):** **`QuestStateOperations.TryMarkComplete`** + **`QuestObjectiveWiring`** deliver-then-mark via router ([NEO-128](../../plans/NEO-128-implementation-plan.md)); [server README — Quest state operations (NEO-117)](../../../server/README.md#quest-state-operations-neo-117). **E7M2-06 HTTP read ([NEO-129](https://linear.app/neon-sprawl/issue/NEO-129)):** **`GET …/quest-progress`** **`completionRewardSummary`** from **`IRewardDeliveryStore`** ([NEO-129](../../plans/NEO-129-implementation-plan.md)); Bruno `bruno/neon-sprawl-server/quest-progress/Get quest progress after gather intro complete.bru`. **E7M2-07 telemetry ([NEO-130](https://linear.app/neon-sprawl/issue/NEO-130)):** comment-only **`reward_delivery`** + **`unlock_granted`** stub hook sites in **`RewardRouterOperations.TryDeliverQuestCompletion`** ([NEO-130](../../plans/NEO-130-implementation-plan.md)); [server README — Reward telemetry hooks (NEO-130)](../../../server/README.md#reward-telemetry-hooks-neo-130). **E7M2-08 client HUD ([NEO-131](https://linear.app/neon-sprawl/issue/NEO-131)):** Godot **`QuestRewardDeliveryLabel`** paints **`completionRewardSummary`** on in-session completion transition ([NEO-131](../../plans/NEO-131-implementation-plan.md)); [client README — Quest completion reward HUD (NEO-131)](../../../client/README.md#quest-completion-reward-hud-neo-131). **Backlog decomposed:** Epic 7 Slice 2 — [E7M2-prototype-backlog.md](../../plans/E7M2-prototype-backlog.md) **E7M2-03** [NEO-126](https://linear.app/neon-sprawl/issue/NEO-126) → **E7M2-09** [NEO-132](https://linear.app/neon-sprawl/issue/NEO-132); label **`E7.M2`**. Client capstone **E7M2-09** [NEO-132](https://linear.app/neon-sprawl/issue/NEO-132). **NEO-43 prep landed:** **`MissionRewardSkillXpGrant`**. **Encounter loot unchanged** (E5.M3 direct grants). Upstream: E7.M1 **Ready**, E2.M2 grant stack, E3.M3 inventory **Ready**. Register row stays **Planned** until capstone **NEO-132**. | [E7M2-prototype-backlog.md](../../plans/E7M2-prototype-backlog.md), [E7_M2](E7_M2_RewardAndUnlockRouter.md), [NEO-124](../../plans/NEO-124-implementation-plan.md), [NEO-125](../../plans/NEO-125-implementation-plan.md), [NEO-126](../../plans/NEO-126-implementation-plan.md), [NEO-127](../../plans/NEO-127-implementation-plan.md), [NEO-128](../../plans/NEO-128-implementation-plan.md), [NEO-129](../../plans/NEO-129-implementation-plan.md), [NEO-130](../../plans/NEO-130-implementation-plan.md), [NEO-43](../../plans/NEO-43-implementation-plan.md), label **`E7.M2`** on NEO-124–NEO-132 | +| E7.M2 | Planned | **E7M2-01 catalog landed ([NEO-124](https://linear.app/neon-sprawl/issue/NEO-124)):** `quest-reward-bundle` + `quest-skill-xp-grant` schemas; **`completionRewardBundle`** on four frozen E7.M1 quests; CI bundle freeze + cross-ref gates. **E7M2-02 server load ([NEO-125](https://linear.app/neon-sprawl/issue/NEO-125)):** fail-fast quest catalog **`completionRewardBundle`** validation at startup — `PrototypeE7M2QuestCatalogRules` + bundle schema `$ref` registration ([NEO-125](../../plans/NEO-125-implementation-plan.md)); [server README — Quest catalog (NEO-125)](../../../server/README.md#quest-catalog-contentquests-neo-113). **E7M2-03 delivery store ([NEO-126](https://linear.app/neon-sprawl/issue/NEO-126)):** idempotent **`IRewardDeliveryStore`** + **`RewardDeliveryEvent`** in `Game/Rewards/` — in-memory prototype ([NEO-126](../../plans/NEO-126-implementation-plan.md)); [server README — Reward delivery store (NEO-126)](../../../server/README.md#reward-delivery-store-neo-126). **E7M2-04 router apply ([NEO-127](https://linear.app/neon-sprawl/issue/NEO-127)):** **`RewardRouterOperations.TryDeliverQuestCompletion`** — item + skill XP bundle apply with compensating rollback + store record ([NEO-127](../../plans/NEO-127-implementation-plan.md)); [server README — Reward router (NEO-127)](../../../server/README.md#reward-router-neo-127). **E7M2-05 quest-state wiring ([NEO-128](https://linear.app/neon-sprawl/issue/NEO-128)):** **`QuestStateOperations.TryMarkComplete`** + **`QuestObjectiveWiring`** deliver-then-mark via router ([NEO-128](../../plans/NEO-128-implementation-plan.md)); [server README — Quest state operations (NEO-117)](../../../server/README.md#quest-state-operations-neo-117). **E7M2-06 HTTP read ([NEO-129](https://linear.app/neon-sprawl/issue/NEO-129)):** **`GET …/quest-progress`** **`completionRewardSummary`** from **`IRewardDeliveryStore`** ([NEO-129](../../plans/NEO-129-implementation-plan.md)); Bruno `bruno/neon-sprawl-server/quest-progress/Get quest progress after gather intro complete.bru`. **E7M2-07 telemetry ([NEO-130](https://linear.app/neon-sprawl/issue/NEO-130)):** comment-only **`reward_delivery`** + **`unlock_granted`** stub hook sites in **`RewardRouterOperations.TryDeliverQuestCompletion`** ([NEO-130](../../plans/NEO-130-implementation-plan.md)); [server README — Reward telemetry hooks (NEO-130)](../../../server/README.md#reward-telemetry-hooks-neo-130). **E7M2-08 client HUD ([NEO-131](https://linear.app/neon-sprawl/issue/NEO-131)):** Godot **`QuestRewardDeliveryLabel`** paints **`completionRewardSummary`** on in-session completion transition ([NEO-131](../../plans/NEO-131-implementation-plan.md)); [client README — Quest completion reward HUD (NEO-131)](../../../client/README.md#quest-completion-reward-hud-neo-131). **Backlog decomposed:** Epic 7 Slice 2 — [E7M2-prototype-backlog.md](../../plans/E7M2-prototype-backlog.md) **E7M2-03** [NEO-126](https://linear.app/neon-sprawl/issue/NEO-126) → **E7M2-09** [NEO-132](https://linear.app/neon-sprawl/issue/NEO-132); label **`E7.M2`**. Client capstone **E7M2-09** [NEO-132](https://linear.app/neon-sprawl/issue/NEO-132). **NEO-43 prep landed:** **`MissionRewardSkillXpGrant`**. **Encounter loot unchanged** (E5.M3 direct grants). Upstream: E7.M1 **Ready**, E2.M2 grant stack, E3.M3 inventory **Ready**. Register row stays **Planned** until capstone **NEO-132**. | [E7M2-prototype-backlog.md](../../plans/E7M2-prototype-backlog.md), [E7_M2](E7_M2_RewardAndUnlockRouter.md), [NEO-124](../../plans/NEO-124-implementation-plan.md), [NEO-125](../../plans/NEO-125-implementation-plan.md), [NEO-126](../../plans/NEO-126-implementation-plan.md), [NEO-127](../../plans/NEO-127-implementation-plan.md), [NEO-128](../../plans/NEO-128-implementation-plan.md), [NEO-129](../../plans/NEO-129-implementation-plan.md), [NEO-130](../../plans/NEO-130-implementation-plan.md), [NEO-131](../../plans/NEO-131-implementation-plan.md), [NEO-43](../../plans/NEO-43-implementation-plan.md), label **`E7.M2`** on NEO-124–NEO-132 | | E7.M1 | Ready | **E7M1-01 catalog landed ([NEO-112](https://linear.app/neon-sprawl/issue/NEO-112)):** quest schemas, `prototype_quests.json`, CI gates (four frozen quest ids, objective cross-refs, acyclic prerequisites, chain terminal token). **E7M1-02 server load ([NEO-113](https://linear.app/neon-sprawl/issue/NEO-113)):** fail-fast startup load of `content/quests/*_quests.json` — `server/NeonSprawl.Server/Game/Quests/` ([NEO-113](../../plans/NEO-113-implementation-plan.md)); [server README — Quest catalog](../../../server/README.md#quest-catalog-contentquests-neo-113). **E7M1-03 registry ([NEO-114](https://linear.app/neon-sprawl/issue/NEO-114)):** **`IQuestDefinitionRegistry`** + DI ([NEO-114](../../plans/NEO-114-implementation-plan.md)). **E7M1-04 HTTP read ([NEO-115](https://linear.app/neon-sprawl/issue/NEO-115)):** **`GET /game/world/quest-definitions`** — `QuestDefinitionsWorldApi` + DTOs ([NEO-115](../../plans/NEO-115-implementation-plan.md)); [server README — Quest definitions (NEO-115)](../../../server/README.md#quest-definitions-neo-115); Bruno `bruno/neon-sprawl-server/quest-definitions/`. **E7M1-05 store ([NEO-116](https://linear.app/neon-sprawl/issue/NEO-116)):** **`IPlayerQuestStateStore`** + in-memory/Postgres persistence ([NEO-116](../../plans/NEO-116-implementation-plan.md)); [server README — Quest progress store (NEO-116)](../../../server/README.md#quest-progress-store-neo-116). **E7M1-06 operations ([NEO-117](https://linear.app/neon-sprawl/issue/NEO-117)):** **`QuestStateOperations`** + reason codes ([NEO-117](../../plans/NEO-117-implementation-plan.md)); [server README — Quest state operations (NEO-117)](../../../server/README.md#quest-state-operations-neo-117). **E7M1-07 objective wiring ([NEO-118](https://linear.app/neon-sprawl/issue/NEO-118)):** **`QuestObjectiveWiring`** on gather/craft/encounter + **`inventory_has_item`** snapshot passes ([NEO-118](../../plans/NEO-118-implementation-plan.md)); [server README — Quest objective wiring (NEO-118)](../../../server/README.md#quest-objective-wiring-neo-118). **E7M1-08 per-player GET ([NEO-119](https://linear.app/neon-sprawl/issue/NEO-119)):** **`GET /game/players/{id}/quest-progress`** — `QuestProgressApi` + DTOs; GET-side inventory refresh ([NEO-119](../../plans/NEO-119-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/`. **E7M1-09 accept POST ([NEO-120](https://linear.app/neon-sprawl/issue/NEO-120)):** **`POST /game/players/{id}/quests/{questId}/accept`** — `QuestAcceptApi` + DTOs; **`QuestStateOperations.TryAccept`** ([NEO-120](../../plans/NEO-120-implementation-plan.md)); [server README — Quest accept POST (NEO-120)](../../../server/README.md#quest-accept-post-neo-120); Bruno accept spine in `bruno/neon-sprawl-server/quest-progress/`. **E7M1-10 telemetry ([NEO-121](https://linear.app/neon-sprawl/issue/NEO-121)):** comment-only **`quest_start`**, **`quest_step_complete`**, **`quest_complete`**, **`quest_accept_denied`** hook sites in **`QuestStateOperations`** ([NEO-121](../../plans/NEO-121-implementation-plan.md)); [server README — Quest telemetry hooks (NEO-121)](../../../server/README.md#quest-telemetry-hooks-neo-121). **E7M1-11 client HUD ([NEO-122](https://linear.app/neon-sprawl/issue/NEO-122)):** **`quest_progress_client.gd`**, **`quest_definitions_client.gd`**, **`QuestProgressLabel`** / **`QuestAcceptFeedbackLabel`**; boot + event-driven GET refresh; **Q** / **Shift+Q** accept ([NEO-122](../../plans/NEO-122-implementation-plan.md), [`NEO-122` manual QA](../../manual-qa/NEO-122.md)); [client README — Quest progress + accept HUD (NEO-122)](../../../client/README.md#quest-progress--accept-hud-neo-122). **E7M1-12 client capstone ([NEO-123](https://linear.app/neon-sprawl/issue/NEO-123)):** playable four-quest onboarding chain — [`NEO-123` manual QA](../../manual-qa/NEO-123.md); [client README — End-to-end onboarding quest loop (NEO-123)](../../../client/README.md#end-to-end-onboarding-quest-loop-neo-123); plan [NEO-123](../../plans/NEO-123-implementation-plan.md). **Epic 7 Slice 1 client capstone complete.** Backlog **E7M1-01** [NEO-112](https://linear.app/neon-sprawl/issue/NEO-112) → **E7M1-12** [NEO-123](https://linear.app/neon-sprawl/issue/NEO-123) **landed**. Upstream: E3.M1 gather **Ready**, E3.M2 craft **Ready**, E5.M3 encounter **`contract_handoff_token`** + **`EncounterCompleteEvent`** **Ready**. | [NEO-123 plan](../../plans/NEO-123-implementation-plan.md), [NEO-122 plan](../../plans/NEO-122-implementation-plan.md), [NEO-121 plan](../../plans/NEO-121-implementation-plan.md), [NEO-120 plan](../../plans/NEO-120-implementation-plan.md), [NEO-119 plan](../../plans/NEO-119-implementation-plan.md), [NEO-118 plan](../../plans/NEO-118-implementation-plan.md), [NEO-117 plan](../../plans/NEO-117-implementation-plan.md), [NEO-116 plan](../../plans/NEO-116-implementation-plan.md), [NEO-115 plan](../../plans/NEO-115-implementation-plan.md), [NEO-114 plan](../../plans/NEO-114-implementation-plan.md), [NEO-113 plan](../../plans/NEO-113-implementation-plan.md), [NEO-112 plan](../../plans/NEO-112-implementation-plan.md), [E7M1-prototype-backlog.md](../../plans/E7M1-prototype-backlog.md), [E7_M1](E7_M1_QuestStateMachine.md), label **`E7.M1`** on NEO-112–NEO-123 | --- diff --git a/docs/plans/NEO-131-implementation-plan.md b/docs/plans/NEO-131-implementation-plan.md index 3d87fa3..546b4f8 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). +- **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). - **Docs:** `client/README.md`; `docs/manual-qa/NEO-131.md`; E7.M2 module + backlog alignment. ## Technical approach @@ -165,7 +165,7 @@ Combat intro completion is **skill XP only** in quest bundle — encounter token | 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_hud_controller_test.gd` | **Add:** transition **`active` → `completed`** paints reward label with skill XP line; boot with already-**`completed`** does not paint; sync failure leaves progress error + reward **`—`**. | +| `client/test/quest_hud_controller_test.gd` | **Add:** transition **`active` → `completed`** paints reward label with skill XP line; boot with already-**`completed`** does not paint; sync failure leaves progress error + reward **`—`**; operator-chain item grant + display name; **`definitions_ready`** reward label repaint. | **Regression:** existing NEO-122 GdUnit suites unchanged; `dotnet test` N/A (client-only). diff --git a/docs/reviews/2026-06-07-NEO-131.md b/docs/reviews/2026-06-07-NEO-131.md index 493f74e..f0812af 100644 --- a/docs/reviews/2026-06-07-NEO-131.md +++ b/docs/reviews/2026-06-07-NEO-131.md @@ -19,7 +19,7 @@ NEO-131 adds Godot client surfacing for server-owned quest completion grants: ** | `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` | **Partially matches** — E7M2-08 / NEO-131 landed note in row body; references column omits explicit [NEO-131 plan](../../plans/NEO-131-implementation-plan.md) link (nit). | +| `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. | @@ -34,15 +34,15 @@ NEO-131 adds Godot client surfacing for server-owned quest completion grants: ** ## 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. +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. +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-124–NEO-130 plan links but not **`NEO-131-implementation-plan.md`** — row body already cites NEO-131; add link for parity with other landed slices. +- ~~Nit: **`documentation_and_implementation_alignment.md`** E7.M2 references pipe lists NEO-124–NEO-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. From 7e3507088b45b16717cce2b35e07c766c3752441 Mon Sep 17 00:00:00 2001 From: VinPropane Date: Sun, 7 Jun 2026 23:06:39 -0400 Subject: [PATCH 6/8] NEO-131: Fix Bugbot boot-seed and item-defs reward repaint. Track in-session active quests so first successful GET after failed syncs can paint rewards; repaint reward label on item definitions_ready. --- client/scripts/quest_hud_controller.gd | 24 ++++++++++- client/test/quest_hud_controller_test.gd | 51 +++++++++++++++++++++++ docs/plans/NEO-131-implementation-plan.md | 4 +- 3 files changed, 76 insertions(+), 3 deletions(-) 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 From dc39250e9616533b531baf732228a67288042cc4 Mon Sep 17 00:00:00 2001 From: VinPropane Date: Sun, 7 Jun 2026 23:07:18 -0400 Subject: [PATCH 7/8] NEO-131: Split reward HUD tests to satisfy gdlint limit. --- client/test/quest_hud_controller_test.gd | 235 ------------------ client/test/quest_reward_hud_test.gd | 277 ++++++++++++++++++++++ client/test/quest_reward_hud_test.gd.uid | 1 + docs/plans/NEO-131-implementation-plan.md | 2 +- 4 files changed, 279 insertions(+), 236 deletions(-) create mode 100644 client/test/quest_reward_hud_test.gd create mode 100644 client/test/quest_reward_hud_test.gd.uid diff --git a/client/test/quest_hud_controller_test.gd b/client/test/quest_hud_controller_test.gd index bd936eb..d7a3338 100644 --- a/client/test/quest_hud_controller_test.gd +++ b/client/test/quest_hud_controller_test.gd @@ -5,9 +5,6 @@ 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" class MockHttpTransport: @@ -78,15 +75,6 @@ class FailStartAcceptHttpTransport: return ERR_UNAVAILABLE -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_progress_client(sync_transport: Node, accept_transport: Node = null) -> Node: var c: Node = QuestProgressClient.new() c.set("injected_sync_http", sync_transport) @@ -487,226 +475,3 @@ 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_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() - 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 _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 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 _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() - 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—") diff --git a/client/test/quest_reward_hud_test.gd b/client/test/quest_reward_hud_test.gd new file mode 100644 index 0000000..0dae2df --- /dev/null +++ b/client/test/quest_reward_hud_test.gd @@ -0,0 +1,277 @@ +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—") diff --git a/client/test/quest_reward_hud_test.gd.uid b/client/test/quest_reward_hud_test.gd.uid new file mode 100644 index 0000000..c3cfaa3 --- /dev/null +++ b/client/test/quest_reward_hud_test.gd.uid @@ -0,0 +1 @@ +uid://d4n7q1rewardhudtest01 diff --git a/docs/plans/NEO-131-implementation-plan.md b/docs/plans/NEO-131-implementation-plan.md index 03b051e..95ef497 100644 --- a/docs/plans/NEO-131-implementation-plan.md +++ b/docs/plans/NEO-131-implementation-plan.md @@ -165,7 +165,7 @@ Combat intro completion is **skill XP only** in quest bundle — encounter token | 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_hud_controller_test.gd` | **Add:** transition **`active` → `completed`** paints reward label with skill XP line; boot with already-**`completed`** does not paint; sync failure leaves progress error + reward **`—`**; operator-chain item grant + display name; **`definitions_ready`** reward label repaint. | +| `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). From 3c3170c73157e66ec2230ef55ae023fcaa3a93b0 Mon Sep 17 00:00:00 2001 From: VinPropane Date: Sun, 7 Jun 2026 23:07:32 -0400 Subject: [PATCH 8/8] NEO-131: gdformat quest HUD scripts after Bugbot fixes. --- client/scripts/quest_hud_controller.gd | 8 ++------ client/test/quest_reward_hud_test.gd | 5 +---- 2 files changed, 3 insertions(+), 10 deletions(-) diff --git a/client/scripts/quest_hud_controller.gd b/client/scripts/quest_hud_controller.gd index b6a3e81..6fba53e 100644 --- a/client/scripts/quest_hud_controller.gd +++ b/client/scripts/quest_hud_controller.gd @@ -60,9 +60,7 @@ func setup( "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") - ) + _item_defs_client.connect("definitions_ready", Callable(self, "_on_item_definitions_ready")) _render_progress_label() _render_accept_feedback_label() _render_reward_label() @@ -177,9 +175,7 @@ func _detect_and_apply_completion_transitions(snapshot: Dictionary) -> void: 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)) - ) + 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(): diff --git a/client/test/quest_reward_hud_test.gd b/client/test/quest_reward_hud_test.gd index 0dae2df..dd8bb14 100644 --- a/client/test/quest_reward_hud_test.gd +++ b/client/test/quest_reward_hud_test.gd @@ -23,10 +23,7 @@ class MockHttpTransport: _request_data: String = "" ) -> Error: request_completed.emit( - HTTPRequest.RESULT_SUCCESS, - 200, - PackedStringArray(), - body_json.to_utf8_buffer() + HTTPRequest.RESULT_SUCCESS, 200, PackedStringArray(), body_json.to_utf8_buffer() ) return OK