Merge pull request #161 from ViPro-Technologies/NEO-122-client-quest-progress-accept-hud

NEO-122: Client quest progress and accept HUD (Godot)
pull/162/head
VinPropane 2026-06-07 14:58:58 -04:00 committed by GitHub
commit 316966f447
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
32 changed files with 2514 additions and 105 deletions

View File

@ -207,7 +207,7 @@ Epic 5 Slice 1 capstone — tab-target lock, cast, defeat, and gig XP visibility
**Scripts:** `ability_cast_client.gd`, `combat_targets_client.gd`, `gig_progression_client.gd` — wired from `main.gd`.
**Economy HUD:** **`GigXpLabel`** lives under **`EconomyHudSection/Body/`** (hidden when **`Economy HUD`** collapsed, NEO-75). Gig GET refreshes on boot and when cast accept carries **`targetDefeated: true`** only — not on every hit.
**Economy HUD:** **`GigXpLabel`** lives under **`EconomyHudSection/BodyScroll/Body/`** (hidden when **`Economy HUD`** collapsed, NEO-75). Gig GET refreshes on boot and when cast accept carries **`targetDefeated: true`** only — not on every hit.
**Preconditions:** **Server restart** before capstone run resets in-memory dummy HP and gig XP baseline.
@ -218,13 +218,27 @@ Full capstone checklist: [`docs/manual-qa/NEO-86.md`](../docs/manual-qa/NEO-86.m
- **`GET /game/players/{id}/encounter-progress`** — server-authoritative per-encounter **`state`**, **`defeatedTargetIds`**, and **`rewardGrantSummary`** when **`completed`** (NEO-108); see [server README — Per-player encounter progress](../server/README.md#per-player-encounter-progress-neo-108).
- **Scripts:** `scripts/encounter_progress_client.gd`; wired from `main.gd` in `_setup_encounter_progress_sync()`.
- **HUD:**
- **`UICanvas/HudRoot/EncounterProgressLabel`** — frozen pocket **`prototype_combat_pocket`**: **`not started (0/3)`** → **`{n}/3`** while **`active`** → **`completed (3/3)`**.
- **`UICanvas/HudRoot/EncounterCompleteLabel`** — **`Loot: —`** until **`completed`**; then one line per grant (**`displayName`** via **`ItemDefinitionsClient`**, fallback **`itemId`**).
- **`UICanvas/HudRootScroll/HudRoot/EncounterProgressLabel`** — frozen pocket **`prototype_combat_pocket`**: **`not started (0/3)`** → **`{n}/3`** while **`active`** → **`completed (3/3)`**.
- **`UICanvas/HudRootScroll/HudRoot/EncounterCompleteLabel`** — **`Loot: —`** until **`completed`**; then one line per grant (**`displayName`** via **`ItemDefinitionsClient`**, fallback **`itemId`**).
- **Refresh:** boot hydrate + **`GET`** after cast accept with **`targetDefeated: true`** (alongside gig/combat refreshes). No periodic poll or manual key.
- **Inventory:** on **`completed`** row, **`InventoryLabel`** auto-refreshes (no **I**) so bag shows **`scrap_metal_bulk`** and **`contract_handoff_token`**.
- **Inventory:** on **`completed`** row, **`InventoryLabel`** auto-refreshes (no **I**) so bag shows **`scrap_metal_bulk`** and **`contract_handoff_token`**; **`QuestProgressLabel`** refreshes too so **`inventory_has_item`** / combat-intro steps stay current if the defeat-triggered quest GET was skipped or raced.
Full checklist: [`docs/manual-qa/NEO-110.md`](../docs/manual-qa/NEO-110.md).
## Quest progress + accept HUD (NEO-122)
- **`GET /game/players/{id}/quest-progress`** — server-authoritative per-quest **`status`**, **`currentStepIndex`**, **`objectiveCounters`**, optional **`completedAt`** (NEO-119); see [server README — Per-player quest progress](../server/README.md#per-player-quest-progress-neo-119).
- **`POST /game/players/{id}/quests/{questId}/accept`** — optional v1 body; **`accepted`** + optional **`reasonCode`** + optional **`quest`** row (NEO-120).
- **`GET /game/world/quest-definitions`** — cached on boot for display names and eligible-quest iteration (NEO-115).
- **Scripts:** `scripts/quest_progress_client.gd`, `scripts/quest_definitions_client.gd`; wired from `main.gd` in `_setup_quest_progress_sync()`.
- **HUD:**
- **`UICanvas/HudRootScroll/HudRoot/QuestProgressLabel`** — all four prototype quests in catalog order with compact status/step/counter lines.
- **`UICanvas/HudRootScroll/HudRoot/QuestAcceptFeedbackLabel`** — accept success/deny/failure copy.
- **Accept keys:** **Q** = `prototype_quest_gather_intro`; **Shift+Q** = first catalog-order **`not_started`** quest with prerequisites **`completed`** in current snapshot.
- **Refresh:** boot hydrate + GET after gather (interaction success), craft success, cast with **`targetDefeated: true`**, encounter progress GET when pocket **`completed`** (alongside inventory refresh), and after accept POST. No periodic poll.
Full checklist: [`docs/manual-qa/NEO-122.md`](../docs/manual-qa/NEO-122.md).
## End-to-end encounter clear loop (NEO-111)
Epic 5 Slice 3 capstone — defeat all three prototype NPCs, receive loot + quest token **once**, verify inventory **in Godot** without Bruno.
@ -253,7 +267,7 @@ Full capstone checklist: [`docs/manual-qa/NEO-111.md`](../docs/manual-qa/NEO-111
- **`GET /game/players/{id}/inventory`** — server-authoritative bag (**24** slots) + equipment stub (**1** slot); see [server README — Player inventory](../server/README.md#player-inventory-neo-54-store-neo-55-http).
- **`GET /game/world/item-definitions`** — cached on boot for **`displayName`** labels (NEO-53).
- **Scripts:** `scripts/inventory_client.gd`, `scripts/item_definitions_client.gd`; wired from `main.gd` on boot and on **`inventory_refresh`** (**I**).
- **HUD:** `UICanvas/EconomyHudSection/Body/InventoryLabel` lists **non-empty bag** rows and **equipment slot 0** (shows **`— empty`** when unoccupied). Failed GET paints **`error — …`** on the label and **`push_warning`** in Output. **`Economy HUD`** checkbox (NEO-75) collapses the economy block.
- **HUD:** `UICanvas/EconomyHudSection/BodyScroll/Body/InventoryLabel` lists **non-empty bag** rows and **equipment slot 0** (shows **`— empty`** when unoccupied). Failed GET paints **`error — …`** on the label and **`push_warning`** in Output. **`Economy HUD`** checkbox (NEO-75) collapses the economy block.
- **Refresh:** press **I** (or call **`InventoryClient.request_sync_from_server()`** from `main.gd` helpers — entrypoint for NEO-73 gather/craft refresh).
Full checklist: [`docs/manual-qa/NEO-72.md`](../docs/manual-qa/NEO-72.md).
@ -261,9 +275,9 @@ Full checklist: [`docs/manual-qa/NEO-72.md`](../docs/manual-qa/NEO-72.md).
## Gather feedback on interact (NEO-73)
- **`POST /game/players/{id}/interact`** on **`resource_node`** kinds — server gather engine (NEO-63); no separate gather HTTP on client.
- **`GET /game/players/{id}/skill-progression`** — **`salvage`** row on **`UICanvas/EconomyHudSection/Body/SkillProgressionLabel`**; boot hydrate + refresh after successful gather.
- **`GET /game/players/{id}/skill-progression`** — **`salvage`** row on **`UICanvas/EconomyHudSection/BodyScroll/Body/SkillProgressionLabel`**; boot hydrate + refresh after successful gather.
- **Scripts:** `scripts/skill_progression_client.gd`, `scripts/prototype_interactable_picker.gd`; **`interaction_request_client.gd`** emits **`interaction_result_received`**; wired from `main.gd`.
- **HUD:** `UICanvas/GatherFeedbackLabel` — success delta **`+N {displayName}`** or deny **`Gather: denied — {reasonCode}`** (including **`node_depleted`**). **`UICanvas/EconomyHudSection/Body/InventoryLabel`** refreshes automatically on successful gather (no **I**).
- **HUD:** `UICanvas/GatherFeedbackLabel` — success delta **`+N {displayName}`** or deny **`Gather: denied — {reasonCode}`** (including **`node_depleted`**). **`UICanvas/EconomyHudSection/BodyScroll/Body/InventoryLabel`** refreshes automatically on successful gather (no **I**).
- **R binding:** nearest in-range **`resource_node`** by horizontal distance to catalog anchors (same radius rule as glow preview).
Full checklist: [`docs/manual-qa/NEO-73.md`](../docs/manual-qa/NEO-73.md).
@ -273,7 +287,7 @@ Full checklist: [`docs/manual-qa/NEO-73.md`](../docs/manual-qa/NEO-73.md).
- **`GET /game/world/recipe-definitions`** — eight prototype recipes on boot; scrollable panel inside **`EconomyHudSection`** (below **`SkillProgressionLabel`**).
- **`POST /game/players/{id}/craft`** — JSON body `schemaVersion`, `recipeId`, `quantity` (default **1**); see [server README — Craft HTTP](../server/README.md).
- **Scripts:** `scripts/recipe_definitions_client.gd`, `scripts/craft_client.gd`, `scripts/craft_recipe_panel.gd`; wired from `main.gd`.
- **HUD:** `UICanvas/CraftFeedbackLabel` — success **`Craft: +N {displayName}`** from POST **`outputsGranted`**; deny **`Craft: denied — {reasonCode}`**. **`UICanvas/EconomyHudSection/Body/SkillProgressionLabel`** adds **`refine`** row (boot + post-craft refresh). **`UICanvas/EconomyHudSection/Body/InventoryLabel`** refreshes on craft success only (no **I**).
- **HUD:** `UICanvas/CraftFeedbackLabel` — success **`Craft: +N {displayName}`** from POST **`outputsGranted`**; deny **`Craft: denied — {reasonCode}`**. **`UICanvas/EconomyHudSection/BodyScroll/Body/SkillProgressionLabel`** adds **`refine`** row (boot + post-craft refresh). **`UICanvas/EconomyHudSection/BodyScroll/Body/InventoryLabel`** refreshes on craft success only (no **I**).
- **Panel:** per-recipe **Craft** button (qty **1**); input summary uses cached item **`displayName`** when available.
Full checklist: [`docs/manual-qa/NEO-74.md`](../docs/manual-qa/NEO-74.md).
@ -293,7 +307,7 @@ Capstone proof that Epic 3 prototype gather→craft works **in Godot** without B
**Material minimum:** **11** scrap from one **R** at each of four prototype nodes (+1, +2, +3, +5) → two **`refine_scrap_standard`** → one **`make_field_stim_mk0`**.
**HUD paths:** feedback labels stay on **`UICanvas`**; economy block under **`UICanvas/EconomyHudSection/Body/`** (`InventoryLabel`, `SkillProgressionLabel`, `CraftRecipePanel`).
**HUD paths:** feedback labels stay on **`UICanvas`**; economy block under **`UICanvas/EconomyHudSection/BodyScroll/Body/`** (`InventoryLabel`, `SkillProgressionLabel`, `CraftRecipePanel`).
Full capstone checklist: [`docs/manual-qa/NEO-75.md`](../docs/manual-qa/NEO-75.md).
@ -355,7 +369,7 @@ On **Linux** (including GitHub Actions), the path must be **`gdUnit4`** with a c
**CI:** The **GDScript** workflow fails the job if Godot prints **`SCRIPT ERROR:`** or **`ERROR: Failed to load script`** while running GdUnit. GdUnit alone can still exit **0** when a test file fails to parse (that suite is skipped); the workflow guards against that.
**Scope:** Unit tests cover **`player.gd`**, **`player_locomotion_wasd.gd`** (pure WASD seam helpers), **`position_authority_client.gd`**, **`inventory_client.gd`**, **`item_definitions_client.gd`** (NEO-72), **`skill_progression_client.gd`**, **`gig_progression_client.gd`** (NEO-86), **`prototype_interactable_picker.gd`**, extended **`interaction_request_client_test.gd`**, **`gather_feedback_refresh_test.gd`** (NEO-73), **`craft_client.gd`**, **`recipe_definitions_client.gd`**, **`craft_feedback_refresh_test.gd`** (NEO-74), **`prototype_economy_hud_section.gd`** (NEO-75), **`combat_targets_client.gd`**, **`combat_feedback_refresh_test.gd`** (NEO-85), **`gig_feedback_refresh_test.gd`** (NEO-86), **`isometric_follow_camera.gd`** (eye math + effective zoom distance), **`camera_state.gd`**, and **`zoom_band_config.gd`**. **`main.gd`** and scene wiring are **not** automated here—use manual checks above.
**Scope:** Unit tests cover **`player.gd`**, **`player_locomotion_wasd.gd`** (pure WASD seam helpers), **`position_authority_client.gd`**, **`inventory_client.gd`**, **`item_definitions_client.gd`** (NEO-72), **`skill_progression_client.gd`**, **`gig_progression_client.gd`** (NEO-86), **`quest_progress_client.gd`**, **`quest_definitions_client.gd`** (NEO-122), **`prototype_interactable_picker.gd`**, extended **`interaction_request_client_test.gd`**, **`gather_feedback_refresh_test.gd`** (NEO-73), **`craft_client.gd`**, **`recipe_definitions_client.gd`**, **`craft_recipe_panel_test.gd`** (NEO-122 follow-up), **`craft_feedback_refresh_test.gd`** (NEO-74), **`prototype_economy_hud_section.gd`** (NEO-75), **`combat_targets_client.gd`**, **`combat_feedback_refresh_test.gd`** (NEO-85), **`gig_feedback_refresh_test.gd`** (NEO-86), **`isometric_follow_camera.gd`** (eye math + effective zoom distance), **`camera_state.gd`**, and **`zoom_band_config.gd`**. **`main.gd`** and scene wiring are **not** automated here—use manual checks above.
**Camera zoom:** Input actions **`camera_zoom_in`** / **`camera_zoom_out`** (mouse wheel, **`=`** / **`-`**, keypad **+** / ****) are defined in **`project.godot`**. **`isometric_follow_camera.gd`** also maps **macOS trackpad** two-finger scroll and pinch to the same discrete zoom bands using **accumulated delta** (one band step per ~1.25 pan units, **150 ms** cooldown between trackpad steps) so small gestures do not traverse all bands at once. On layouts where **`+`** requires **Shift**, remap or add a binding under **Project → Project Settings → Input Map** if needed.

View File

@ -21,6 +21,8 @@
[ext_resource type="Script" path="res://scripts/prototype_economy_hud_section.gd" id="20_economy_hud"]
[ext_resource type="Script" path="res://scripts/gig_progression_client.gd" id="21_gig_prog"]
[ext_resource type="Script" path="res://scripts/encounter_progress_client.gd" id="24_enc_prog"]
[ext_resource type="Script" uid="uid://bneo122qstprog01" path="res://scripts/quest_progress_client.gd" id="25_quest_prog"]
[ext_resource type="Script" uid="uid://bneo122qstdefs01" path="res://scripts/quest_definitions_client.gd" id="26_quest_defs"]
[ext_resource type="PackedScene" path="res://assets/district/prototype_district_environment.glb" id="22_district_env"]
[ext_resource type="Script" path="res://scripts/prototype_district_art.gd" id="23_district_art"]
@ -1118,6 +1120,12 @@ script = ExtResource("21_gig_prog")
[node name="EncounterProgressClient" type="Node" parent="." unique_id=2500012]
script = ExtResource("24_enc_prog")
[node name="QuestProgressClient" type="Node" parent="." unique_id=2500013]
script = ExtResource("25_quest_prog")
[node name="QuestDefinitionsClient" type="Node" parent="." unique_id=2500014]
script = ExtResource("26_quest_defs")
[node name="RecipeDefinitionsClient" type="Node" parent="." unique_id=2500009]
script = ExtResource("17_recipe_defs")
@ -1142,13 +1150,20 @@ theme_override_constants/outline_size = 8
theme_override_font_sizes/font_size = 22
autowrap_mode = 3
[node name="HudRoot" type="VBoxContainer" parent="UICanvas" unique_id=9000022]
[node name="HudRootScroll" type="ScrollContainer" parent="UICanvas" unique_id=9000029]
offset_left = 8.0
offset_top = 8.0
offset_right = 664.0
offset_bottom = 392.0
horizontal_scroll_mode = 0
vertical_scroll_mode = 1
[node name="HudRoot" type="VBoxContainer" parent="UICanvas/HudRootScroll" unique_id=9000022]
layout_mode = 2
size_flags_horizontal = 3
theme_override_constants/separation = 12
[node name="PlayerPositionLabel" type="Label" parent="UICanvas/HudRoot" unique_id=9000003]
[node name="PlayerPositionLabel" type="Label" parent="UICanvas/HudRootScroll/HudRoot" unique_id=9000003]
layout_mode = 2
size_flags_horizontal = 3
theme_override_colors/font_color = Color(0.92, 0.95, 0.98, 1)
@ -1161,7 +1176,7 @@ y: —
z: —
srv: —"
[node name="TargetLockLabel" type="Label" parent="UICanvas/HudRoot" unique_id=9000004]
[node name="TargetLockLabel" type="Label" parent="UICanvas/HudRootScroll/HudRoot" unique_id=9000004]
layout_mode = 2
size_flags_horizontal = 3
theme_override_colors/font_color = Color(0.86, 0.94, 1, 1)
@ -1173,7 +1188,7 @@ text = "Target: —
Validity: —
Seq: —"
[node name="CastFeedbackLabel" type="Label" parent="UICanvas/HudRoot" unique_id=9000005]
[node name="CastFeedbackLabel" type="Label" parent="UICanvas/HudRootScroll/HudRoot" unique_id=9000005]
layout_mode = 2
size_flags_horizontal = 3
theme_override_colors/font_color = Color(0.95, 0.88, 0.72, 1)
@ -1183,7 +1198,7 @@ theme_override_font_sizes/font_size = 22
autowrap_mode = 3
text = "Cast: —"
[node name="CombatTargetHpLabel" type="Label" parent="UICanvas/HudRoot" unique_id=9000018]
[node name="CombatTargetHpLabel" type="Label" parent="UICanvas/HudRootScroll/HudRoot" unique_id=9000018]
layout_mode = 2
size_flags_horizontal = 3
theme_override_colors/font_color = Color(0.92, 0.78, 0.82, 1)
@ -1193,7 +1208,7 @@ theme_override_font_sizes/font_size = 22
autowrap_mode = 3
text = "Target HP: — (Tab → dummy)"
[node name="PlayerCombatHpLabel" type="Label" parent="UICanvas/HudRoot" unique_id=9000019]
[node name="PlayerCombatHpLabel" type="Label" parent="UICanvas/HudRootScroll/HudRoot" unique_id=9000019]
layout_mode = 2
size_flags_horizontal = 3
theme_override_colors/font_color = Color(0.82, 0.92, 0.98, 1)
@ -1203,7 +1218,7 @@ theme_override_font_sizes/font_size = 22
autowrap_mode = 3
text = "Player HP: —"
[node name="EncounterProgressLabel" type="Label" parent="UICanvas/HudRoot" unique_id=9000023]
[node name="EncounterProgressLabel" type="Label" parent="UICanvas/HudRootScroll/HudRoot" unique_id=9000023]
layout_mode = 2
size_flags_horizontal = 3
theme_override_colors/font_color = Color(0.9, 0.82, 0.72, 1)
@ -1214,7 +1229,7 @@ autowrap_mode = 3
text = "Encounter:
Loading…"
[node name="EncounterCompleteLabel" type="Label" parent="UICanvas/HudRoot" unique_id=9000024]
[node name="EncounterCompleteLabel" type="Label" parent="UICanvas/HudRootScroll/HudRoot" unique_id=9000024]
layout_mode = 2
size_flags_horizontal = 3
theme_override_colors/font_color = Color(0.95, 0.9, 0.65, 1)
@ -1224,7 +1239,28 @@ theme_override_font_sizes/font_size = 22
autowrap_mode = 3
text = "Loot: —"
[node name="NpcStateLabel" type="Label" parent="UICanvas/HudRoot" unique_id=9000020]
[node name="QuestProgressLabel" type="Label" parent="UICanvas/HudRootScroll/HudRoot" unique_id=9000025]
layout_mode = 2
size_flags_horizontal = 3
theme_override_colors/font_color = Color(0.82, 0.92, 0.98, 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 = "Quests:
Loading…"
[node name="QuestAcceptFeedbackLabel" type="Label" parent="UICanvas/HudRootScroll/HudRoot" unique_id=9000026]
layout_mode = 2
size_flags_horizontal = 3
theme_override_colors/font_color = Color(0.9, 0.86, 1, 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 accept: — (Q gather / Shift+Q next)"
[node name="NpcStateLabel" type="Label" parent="UICanvas/HudRootScroll/HudRoot" unique_id=9000020]
layout_mode = 2
size_flags_horizontal = 3
theme_override_colors/font_color = Color(0.88, 0.82, 0.98, 1)
@ -1234,7 +1270,7 @@ theme_override_font_sizes/font_size = 22
autowrap_mode = 3
text = "NPC state: —"
[node name="TelegraphLabel" type="Label" parent="UICanvas/HudRoot" unique_id=9000021]
[node name="TelegraphLabel" type="Label" parent="UICanvas/HudRootScroll/HudRoot" unique_id=9000021]
layout_mode = 2
size_flags_horizontal = 3
theme_override_colors/font_color = Color(1, 0.72, 0.55, 1)
@ -1244,7 +1280,7 @@ theme_override_font_sizes/font_size = 22
autowrap_mode = 3
text = "Telegraph: —"
[node name="GatherFeedbackLabel" type="Label" parent="UICanvas/HudRoot" unique_id=9000008]
[node name="GatherFeedbackLabel" type="Label" parent="UICanvas/HudRootScroll/HudRoot" unique_id=9000008]
layout_mode = 2
size_flags_horizontal = 3
theme_override_colors/font_color = Color(0.88, 0.95, 0.72, 1)
@ -1254,7 +1290,7 @@ theme_override_font_sizes/font_size = 20
autowrap_mode = 3
text = "Gather: —"
[node name="CraftFeedbackLabel" type="Label" parent="UICanvas/HudRoot" unique_id=9000010]
[node name="CraftFeedbackLabel" type="Label" parent="UICanvas/HudRootScroll/HudRoot" unique_id=9000010]
layout_mode = 2
size_flags_horizontal = 3
theme_override_colors/font_color = Color(0.95, 0.82, 0.72, 1)
@ -1264,7 +1300,7 @@ theme_override_font_sizes/font_size = 20
autowrap_mode = 3
text = "Craft: —"
[node name="CooldownSlotsLabel" type="Label" parent="UICanvas/HudRoot" unique_id=9000006]
[node name="CooldownSlotsLabel" type="Label" parent="UICanvas/HudRootScroll/HudRoot" unique_id=9000006]
layout_mode = 2
size_flags_horizontal = 3
theme_override_colors/font_color = Color(0.78, 0.92, 0.86, 1)
@ -1274,24 +1310,56 @@ theme_override_font_sizes/font_size = 20
autowrap_mode = 3
text = "Cooldowns: —"
[node name="EconomyHudSection" type="VBoxContainer" parent="UICanvas/HudRoot" unique_id=9000014]
layout_mode = 2
size_flags_horizontal = 3
[node name="EconomyHudSection" type="VBoxContainer" parent="UICanvas" unique_id=9000014]
z_index = 10
offset_left = 8.0
offset_top = 400.0
offset_right = 664.0
offset_bottom = 800.0
script = ExtResource("20_economy_hud")
[node name="HeaderRow" type="HBoxContainer" parent="UICanvas/HudRoot/EconomyHudSection" unique_id=9000015]
[node name="HeaderRow" type="HBoxContainer" parent="UICanvas/EconomyHudSection" unique_id=9000015]
layout_mode = 2
[node name="ToggleButton" type="CheckButton" parent="UICanvas/HudRoot/EconomyHudSection/HeaderRow" unique_id=9000016]
[node name="ToggleButton" type="CheckButton" parent="UICanvas/EconomyHudSection/HeaderRow" unique_id=9000016]
layout_mode = 2
theme_override_font_sizes/font_size = 20
text = "Economy HUD"
[node name="Body" type="VBoxContainer" parent="UICanvas/HudRoot/EconomyHudSection" unique_id=9000017]
[node name="BodyScroll" type="ScrollContainer" parent="UICanvas/EconomyHudSection" unique_id=9000027]
layout_mode = 2
size_flags_horizontal = 3
size_flags_vertical = 3
custom_minimum_size = Vector2(656, 380)
horizontal_scroll_mode = 0
vertical_scroll_mode = 1
follow_focus = true
[node name="Body" type="VBoxContainer" parent="UICanvas/EconomyHudSection/BodyScroll" unique_id=9000017]
layout_mode = 2
size_flags_horizontal = 3
theme_override_constants/separation = 8
[node name="InventoryLabel" type="Label" parent="UICanvas/HudRoot/EconomyHudSection/Body" unique_id=9000007]
[node name="RecipesHeaderLabel" type="Label" parent="UICanvas/EconomyHudSection/BodyScroll/Body" unique_id=9000028]
layout_mode = 2
size_flags_horizontal = 3
theme_override_colors/font_color = Color(0.9, 0.86, 0.98, 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 = 20
text = "Recipes:"
[node name="CraftRecipePanel" type="VBoxContainer" parent="UICanvas/EconomyHudSection/BodyScroll/Body" unique_id=9000011]
layout_mode = 2
size_flags_horizontal = 3
script = ExtResource("19_craft_panel")
[node name="RecipeRows" type="VBoxContainer" parent="UICanvas/EconomyHudSection/BodyScroll/Body/CraftRecipePanel" unique_id=9000013]
layout_mode = 2
size_flags_horizontal = 3
theme_override_constants/separation = 8
[node name="InventoryLabel" type="Label" parent="UICanvas/EconomyHudSection/BodyScroll/Body" unique_id=9000007]
custom_minimum_size = Vector2(656, 0)
layout_mode = 2
size_flags_horizontal = 3
@ -1303,7 +1371,7 @@ autowrap_mode = 3
text = "Inventory: (refresh: I)
Loading…"
[node name="SkillProgressionLabel" type="Label" parent="UICanvas/HudRoot/EconomyHudSection/Body" unique_id=9000009]
[node name="SkillProgressionLabel" type="Label" parent="UICanvas/EconomyHudSection/BodyScroll/Body" unique_id=9000009]
custom_minimum_size = Vector2(656, 0)
layout_mode = 2
size_flags_horizontal = 3
@ -1315,7 +1383,7 @@ autowrap_mode = 3
text = "Skills:
Loading…"
[node name="GigXpLabel" type="Label" parent="UICanvas/HudRoot/EconomyHudSection/Body" unique_id=9000018]
[node name="GigXpLabel" type="Label" parent="UICanvas/EconomyHudSection/BodyScroll/Body" unique_id=9000018]
custom_minimum_size = Vector2(656, 0)
layout_mode = 2
size_flags_horizontal = 3
@ -1326,22 +1394,3 @@ theme_override_font_sizes/font_size = 20
autowrap_mode = 3
text = "Gig XP:
Loading…"
[node name="CraftRecipePanel" type="Control" parent="UICanvas/HudRoot/EconomyHudSection/Body" unique_id=9000011]
custom_minimum_size = Vector2(656, 320)
layout_mode = 2
size_flags_horizontal = 3
script = ExtResource("19_craft_panel")
[node name="ScrollContainer" type="ScrollContainer" parent="UICanvas/HudRoot/EconomyHudSection/Body/CraftRecipePanel" unique_id=9000012]
layout_mode = 1
anchors_preset = 15
anchor_right = 1.0
anchor_bottom = 1.0
grow_horizontal = 2
grow_vertical = 2
[node name="VBoxContainer" type="VBoxContainer" parent="UICanvas/HudRoot/EconomyHudSection/Body/CraftRecipePanel/ScrollContainer" unique_id=9000013]
layout_mode = 2
size_flags_horizontal = 3
theme_override_constants/separation = 8

View File

@ -1,15 +1,17 @@
extends Control
extends VBoxContainer
## NEO-74: scrollable prototype recipe list with per-row Craft buttons.
## NEO-74: prototype recipe list with per-row Craft buttons.
## NEO-122: rows live in economy BodyScroll (no nested scroll).
signal craft_requested(recipe_id: String)
const PrototypeHudTheme := preload("res://scripts/prototype_hud_theme.gd")
const HudThemeConstants := preload("res://scripts/prototype_hud_theme_constants.gd")
var _item_defs_client: Node = null
var _craft_buttons: Array[Button] = []
@onready var _vbox: VBoxContainer = $ScrollContainer/VBoxContainer
@onready var _rows: VBoxContainer = $RecipeRows
func setup(item_defs_client: Node) -> void:
@ -17,9 +19,9 @@ func setup(item_defs_client: Node) -> void:
func populate(recipes: Array) -> void:
if not is_instance_valid(_vbox):
if not is_instance_valid(_rows):
return
for child in _vbox.get_children():
for child in _rows.get_children():
child.queue_free()
_craft_buttons.clear()
for row_variant in recipes:
@ -29,21 +31,26 @@ func populate(recipes: Array) -> void:
var recipe_id: String = str(recipe.get("id", ""))
if recipe_id.is_empty():
continue
var row := HBoxContainer.new()
var row := VBoxContainer.new()
row.size_flags_horizontal = Control.SIZE_EXPAND_FILL
row.add_theme_constant_override("separation", 4)
var info := Label.new()
info.size_flags_horizontal = Control.SIZE_EXPAND_FILL
info.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
info.text = _format_recipe_line(recipe)
PrototypeHudTheme.apply_to_label(info, false)
_apply_recipe_row_label(info)
row.add_child(info)
var btn_row := HBoxContainer.new()
btn_row.size_flags_horizontal = Control.SIZE_EXPAND_FILL
btn_row.alignment = BoxContainer.ALIGNMENT_END
var craft_btn := Button.new()
craft_btn.text = "Craft"
PrototypeHudTheme.apply_to_button(craft_btn, false)
craft_btn.pressed.connect(_on_craft_pressed.bind(recipe_id))
row.add_child(craft_btn)
_vbox.add_child(row)
btn_row.add_child(craft_btn)
row.add_child(btn_row)
_rows.add_child(row)
_craft_buttons.append(craft_btn)
call_deferred("_scroll_economy_body_to_recipes")
func set_craft_busy(busy: bool) -> void:
@ -85,3 +92,18 @@ func _item_display_name(item_id: String) -> String:
func _on_craft_pressed(recipe_id: String) -> void:
craft_requested.emit(recipe_id)
func _apply_recipe_row_label(label: Label) -> void:
label.add_theme_font_size_override("font_size", HudThemeConstants.FONT_SIZE_SECONDARY)
label.add_theme_constant_override("outline_size", HudThemeConstants.OUTLINE_SIZE)
label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
func _scroll_economy_body_to_recipes() -> void:
var node: Node = get_parent()
while node != null:
if node.has_method("scroll_body_to_recipes"):
node.call("scroll_body_to_recipes")
return
node = node.get_parent()

View File

@ -17,8 +17,8 @@ extends Node3D
## Steep ramp: **2 m / 1.5 m** rise/run (~53°) so slope exceeds `Player` `floor_max_angle` (~50°)
## and is not walkable; gentle ramp stays shallower than `NavigationMesh.agent_max_climb`. Steep is
## under `World` only (not nav source geometry).
## Prototype HUD: world `CharacterBody3D` position in `UICanvas/HudRoot/PlayerPositionLabel`
## (updated in `_physics_process` so it matches physics ticks).
## Prototype HUD: player position in PlayerPositionLabel (HudRootScroll/HudRoot);
## updated in `_physics_process` to match physics ticks.
const MOVE_REJECT_MSG_SECONDS: float = 4.0
@ -56,6 +56,7 @@ const REFINE_SKILL_ID := "refine"
const BREACH_GIG_ID := "breach"
const PROTOTYPE_ENCOUNTER_ID := "prototype_combat_pocket"
const PROTOTYPE_ENCOUNTER_REQUIRED_TARGETS := 3
const QuestHudController := preload("res://scripts/quest_hud_controller.gd")
## Bump on each rejection so older one-shot timers do not clear a newer message.
var _move_reject_msg_token: int = 0
@ -108,6 +109,7 @@ var _last_gig_snapshot: Dictionary = {}
var _gig_error: String = ""
var _last_encounter_progress_snapshot: Dictionary = {}
var _encounter_progress_error: String = ""
var _quest_hud: Node = null
var _gather_pre_scrap_qty: int = 0
var _gather_pending_interactable_id: String = ""
var _gather_awaiting_inventory_finalize: bool = false
@ -124,30 +126,34 @@ var _dev_pulse_bootstrap_attempted: bool = false
@onready var _interactables_root: Node3D = $World/NavigationRegion3D/InteractablesRoot
@onready var _radius_preview: Node3D = $World/InteractionMarkers
@onready var _move_reject_label: Label = $UICanvas/MoveRejectLabel
@onready var _hud_root: VBoxContainer = $UICanvas/HudRoot
@onready var _player_pos_label: Label = $UICanvas/HudRoot/PlayerPositionLabel
@onready var _target_lock_label: Label = $UICanvas/HudRoot/TargetLockLabel
@onready var _cast_feedback_label: Label = $UICanvas/HudRoot/CastFeedbackLabel
@onready var _combat_target_hp_label: Label = $UICanvas/HudRoot/CombatTargetHpLabel
@onready var _player_combat_hp_label: Label = $UICanvas/HudRoot/PlayerCombatHpLabel
@onready var _encounter_progress_label: Label = $UICanvas/HudRoot/EncounterProgressLabel
@onready var _encounter_complete_label: Label = $UICanvas/HudRoot/EncounterCompleteLabel
@onready var _npc_state_label: Label = $UICanvas/HudRoot/NpcStateLabel
@onready var _telegraph_label: Label = $UICanvas/HudRoot/TelegraphLabel
@onready var _cooldown_slots_label: Label = $UICanvas/HudRoot/CooldownSlotsLabel
@onready var _economy_hud_section: VBoxContainer = $UICanvas/HudRoot/EconomyHudSection
@onready var _inventory_label: Label = _economy_hud_section.get_node("Body/InventoryLabel")
@onready var _gather_feedback_label: Label = $UICanvas/HudRoot/GatherFeedbackLabel
@onready var _craft_feedback_label: Label = $UICanvas/HudRoot/CraftFeedbackLabel
@onready
var _skill_progression_label: Label = _economy_hud_section.get_node("Body/SkillProgressionLabel")
@onready var _gig_xp_label: Label = _economy_hud_section.get_node("Body/GigXpLabel")
@onready var _craft_recipe_panel: Node = _economy_hud_section.get_node("Body/CraftRecipePanel")
@onready var _hud_root: VBoxContainer = $UICanvas/HudRootScroll/HudRoot
@onready var _player_pos_label: Label = _hud_root.get_node("PlayerPositionLabel")
@onready var _target_lock_label: Label = _hud_root.get_node("TargetLockLabel")
@onready var _cast_feedback_label: Label = _hud_root.get_node("CastFeedbackLabel")
@onready var _combat_target_hp_label: Label = _hud_root.get_node("CombatTargetHpLabel")
@onready var _player_combat_hp_label: Label = _hud_root.get_node("PlayerCombatHpLabel")
@onready var _encounter_progress_label: Label = _hud_root.get_node("EncounterProgressLabel")
@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 _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")
@onready var _economy_hud_section: VBoxContainer = $UICanvas/EconomyHudSection
@onready var _economy_body: Node = _economy_hud_section.get_node("BodyScroll/Body")
@onready var _inventory_label: Label = _economy_body.get_node("InventoryLabel")
@onready var _gather_feedback_label: Label = _hud_root.get_node("GatherFeedbackLabel")
@onready var _craft_feedback_label: Label = _hud_root.get_node("CraftFeedbackLabel")
@onready var _skill_progression_label: Label = _economy_body.get_node("SkillProgressionLabel")
@onready var _gig_xp_label: Label = _economy_body.get_node("GigXpLabel")
@onready var _craft_recipe_panel: Node = _economy_body.get_node("CraftRecipePanel")
@onready var _inventory_client: Node = $InventoryClient
@onready var _item_defs_client: Node = $ItemDefinitionsClient
@onready var _skill_progression_client: Node = $SkillProgressionClient
@onready var _gig_progression_client: Node = $GigProgressionClient
@onready var _encounter_progress_client: Node = $EncounterProgressClient
@onready var _quest_progress_client: Node = $QuestProgressClient
@onready var _quest_defs_client: Node = $QuestDefinitionsClient
@onready var _recipe_defs_client: Node = $RecipeDefinitionsClient
@onready var _craft_client: Node = $CraftClient
@onready var _target_client: Node = $TargetSelectionClient
@ -197,6 +203,7 @@ func _ready() -> void:
_setup_skill_progression_sync()
_setup_gig_progression_sync()
_setup_encounter_progress_sync()
_setup_quest_progress_sync()
_setup_gather_interact_feedback()
_setup_craft_ui()
@ -300,6 +307,8 @@ func _apply_prototype_hud_theme() -> void:
PrototypeHudTheme.apply_to_label(_move_reject_label, true)
if is_instance_valid(_hud_root):
PrototypeHudTheme.apply_to_hud_root(_hud_root)
if is_instance_valid(_economy_hud_section):
PrototypeHudTheme.apply_to_economy_section(_economy_hud_section)
func _on_authoritative_ack_for_hud(world: Vector3) -> void:
@ -736,6 +745,7 @@ func _on_encounter_progress_received(snapshot: Dictionary) -> void:
var row: Dictionary = _encounter_row(PROTOTYPE_ENCOUNTER_ID)
if str(row.get("state", "")) == "completed":
_request_inventory_refresh()
_request_quest_progress_refresh()
func _on_encounter_progress_sync_failed(reason: String) -> void:
@ -842,6 +852,31 @@ func _request_encounter_progress_refresh() -> void:
_encounter_progress_client.call("request_sync_from_server")
func _setup_quest_progress_sync() -> void:
# NEO-122: quest progress + accept HUD; boot hydrate + refresh after gather/craft/defeat.
_quest_hud = QuestHudController.new()
add_child(_quest_hud)
_quest_hud.call(
"setup",
_quest_progress_client,
_quest_defs_client,
_quest_progress_label,
_quest_accept_feedback_label,
Callable(self, "_apply_authority_http_config_to_client")
)
func _request_quest_progress_refresh() -> void:
if is_instance_valid(_quest_hud):
_quest_hud.call("request_progress_refresh")
func _try_quest_accept_key_input(event: InputEvent) -> bool:
if is_instance_valid(_quest_hud):
return bool(_quest_hud.call("try_accept_key_input", event))
return false
func _render_gather_feedback_label(text: String = "Gather: —") -> void:
if is_instance_valid(_gather_feedback_label):
_gather_feedback_label.text = text
@ -910,6 +945,7 @@ func _on_craft_result_received(_recipe_id: String, result: Dictionary) -> void:
if bool(result.get("success", false)):
_render_craft_feedback_label(_format_craft_success_line(result.get("outputsGranted", [])))
_request_inventory_refresh()
_request_quest_progress_refresh()
if (
is_instance_valid(_skill_progression_client)
and _skill_progression_client.has_method("request_sync_from_server")
@ -980,6 +1016,7 @@ func _on_interaction_result_for_gather(
return
_gather_awaiting_inventory_finalize = true
_request_inventory_refresh()
_request_quest_progress_refresh()
if (
is_instance_valid(_skill_progression_client)
and _skill_progression_client.has_method("request_sync_from_server")
@ -1143,6 +1180,7 @@ func _on_cast_result_received(accepted: bool, reason_code: String, resolution: D
if bool(resolution.get("targetDefeated", false)):
_request_gig_progression_refresh()
_request_encounter_progress_refresh()
_request_quest_progress_refresh()
return
var rc := reason_code.strip_edges()
if rc.is_empty():
@ -1359,6 +1397,8 @@ func _try_route_gameplay_key_input(event: InputEvent) -> bool:
return true
if _try_interact_key_input(event):
return true
if _try_quest_accept_key_input(event):
return true
if _try_inventory_refresh_input(event):
return true
return false

View File

@ -1,25 +1,97 @@
extends VBoxContainer
## NEO-75: collapsible economy HUD block (inventory, skills, craft panel).
## NEO-122: pinned to viewport bottom-left; clamps HudRootScroll above this panel.
const MAX_VIEWPORT_HEIGHT_FRACTION := 0.42
const MIN_BODY_SCROLL_HEIGHT := 160.0
const BOTTOM_MARGIN := 8.0
const SIDE_MARGIN := 8.0
const PANEL_WIDTH := 656.0
const HUD_TOP_MARGIN := 8.0
const HUD_ECONOMY_GAP := 12.0
const HUD_ROOT_SCROLL_NAME := "HudRootScroll"
const PANEL_FILL := Color(0.06, 0.06, 0.1, 0.94)
@onready var _toggle: CheckButton = $HeaderRow/ToggleButton
@onready var _body: VBoxContainer = $Body
@onready var _body_scroll: ScrollContainer = $BodyScroll
@onready var _recipes_header: Label = $BodyScroll/Body/RecipesHeaderLabel
func _ready() -> void:
z_index = 10
clip_contents = true
_toggle.button_pressed = true
_body.visible = true
_body_scroll.visible = true
_toggle.toggled.connect(_on_toggle_toggled)
get_viewport().size_changed.connect(_apply_viewport_layout)
call_deferred("_apply_viewport_layout")
await get_tree().process_frame
_apply_viewport_layout()
func _draw() -> void:
draw_rect(Rect2(Vector2.ZERO, size), PANEL_FILL)
func _on_toggle_toggled(pressed: bool) -> void:
_body.visible = pressed
_body_scroll.visible = pressed
call_deferred("_apply_viewport_layout")
func set_expanded(expanded: bool) -> void:
_toggle.button_pressed = expanded
_body.visible = expanded
_body_scroll.visible = expanded
call_deferred("_apply_viewport_layout")
func is_body_visible() -> bool:
return _body.visible
return _body_scroll.visible
func scroll_body_to_recipes() -> void:
if not is_instance_valid(_body_scroll) or not is_instance_valid(_recipes_header):
return
_body_scroll.scroll_vertical = int(_recipes_header.position.y)
func panel_top_y() -> float:
return position.y
func _apply_viewport_layout() -> void:
if not is_instance_valid(_body_scroll):
return
var vp_size: Vector2 = get_viewport().get_visible_rect().size
if vp_size.y <= 1.0:
return
var header_height: float = _measure_header_height()
var body_height: float = max(
MIN_BODY_SCROLL_HEIGHT, vp_size.y * MAX_VIEWPORT_HEIGHT_FRACTION - header_height
)
_body_scroll.custom_minimum_size = Vector2(PANEL_WIDTH, body_height)
var panel_height: float = header_height
if _body_scroll.visible:
panel_height += body_height
position = Vector2(SIDE_MARGIN, vp_size.y - panel_height - BOTTOM_MARGIN)
size = Vector2(PANEL_WIDTH, panel_height)
_apply_hud_root_scroll_bounds()
queue_redraw()
func _apply_hud_root_scroll_bounds() -> void:
var hud_scroll := get_parent().get_node_or_null(HUD_ROOT_SCROLL_NAME)
if not hud_scroll is ScrollContainer:
return
var available_height: float = position.y - HUD_TOP_MARGIN - HUD_ECONOMY_GAP
hud_scroll.position = Vector2(SIDE_MARGIN, HUD_TOP_MARGIN)
hud_scroll.size = Vector2(PANEL_WIDTH, maxf(0.0, available_height))
func _measure_header_height() -> float:
if not is_instance_valid(_toggle):
return 32.0
var header_row: Node = _toggle.get_parent()
if header_row is Control:
return max(32.0, (header_row as Control).size.y)
return 32.0

View File

@ -30,8 +30,6 @@ static func apply_to_hud_root(root: VBoxContainer) -> void:
if child is Label:
var label: Label = child as Label
apply_to_label(label, not Constants.SECONDARY_LABEL_NAMES.has(label.name))
elif child.name == "EconomyHudSection":
apply_to_economy_section(child)
static func apply_to_economy_section(section: Node) -> void:

View File

@ -0,0 +1,99 @@
extends Node
## NEO-122: fetches [code]GET /game/world/quest-definitions[/code] (NEO-115).
## Caches ordered quest rows for HUD display names and eligible-quest iteration.
signal definitions_ready(quests: Array)
signal definitions_sync_failed(reason: String)
const SCHEMA_VERSION := 1
@export var base_url: String = "http://127.0.0.1:5253"
@export var injected_http: Node = null
var _http: Node
var _busy: bool = false
var _quests: Array = []
func _ready() -> void:
if injected_http != null:
_http = injected_http
else:
_http = HTTPRequest.new()
add_child(_http)
if _http is HTTPRequest:
(_http as HTTPRequest).timeout = 30.0
@warning_ignore("unsafe_method_access")
_http.request_completed.connect(_on_request_completed)
func request_sync_from_server() -> void:
if _busy:
return
_busy = true
var url := "%s/game/world/quest-definitions" % _base_root()
var err: Error = _http.request(url)
if err != OK:
var reason := "GET failed to start (%s)" % err
push_warning("QuestDefinitionsClient: %s" % reason)
_busy = false
definitions_sync_failed.emit(reason)
func display_name_for(quest_id: String) -> String:
for row_variant in _quests:
if not row_variant is Dictionary:
continue
var row: Dictionary = row_variant
if str(row.get("id", "")) == quest_id:
var dn := str(row.get("displayName", "")).strip_edges()
if not dn.is_empty():
return dn
return quest_id
func quests_snapshot() -> Array:
return _quests.duplicate(true)
func _base_root() -> String:
return base_url.strip_edges().rstrip("/")
static func parse_quests_json(text: String) -> Variant:
var parsed: Variant = JSON.parse_string(text)
if not parsed is Dictionary:
return null
var root: Dictionary = parsed
if int(root.get("schemaVersion", -1)) != SCHEMA_VERSION:
return null
var quests: Variant = root.get("quests", null)
if quests == null or not quests is Array:
return null
return quests as Array
func _on_request_completed(
result: int, response_code: int, _headers: PackedStringArray, body: PackedByteArray
) -> void:
_busy = false
if result != HTTPRequest.RESULT_SUCCESS:
var reason := "HTTP failed (result=%s)" % result
push_warning("QuestDefinitionsClient: %s" % reason)
definitions_sync_failed.emit(reason)
return
if response_code < 200 or response_code >= 300:
var reason_code := "HTTP %s" % response_code
push_warning("QuestDefinitionsClient: %s" % reason_code)
definitions_sync_failed.emit(reason_code)
return
var text := body.get_string_from_utf8()
var quests: Variant = parse_quests_json(text)
if quests == null:
var reason_json := "non-JSON body or schemaVersion mismatch"
push_warning("QuestDefinitionsClient: %s" % reason_json)
definitions_sync_failed.emit(reason_json)
return
_quests = (quests as Array).duplicate(true)
definitions_ready.emit(_quests.duplicate(true))

View File

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

View File

@ -0,0 +1,321 @@
extends Node
## NEO-122: quest progress + accept HUD wiring (extracted from main.gd).
const QuestProgressClient := preload("res://scripts/quest_progress_client.gd")
const GATHER_QUEST_ID := "prototype_quest_gather_intro"
const ACCEPT_IDLE_HINT := "Quest accept: — (Q gather / Shift+Q next)"
const ACCEPT_SENDING_HINT := "Quest accept: sending…"
var _progress_client: Node = null
var _defs_client: Node = null
var _progress_label: Label = null
var _accept_label: Label = null
var _last_snapshot: Dictionary = {}
var _progress_error: String = ""
var _defs_error: String = ""
var _accept_quest_patch: Dictionary = {}
func setup(
progress_client: Node,
defs_client: Node,
progress_label: Label,
accept_label: Label,
apply_http_config: Callable
) -> void:
_progress_client = progress_client
_defs_client = defs_client
_progress_label = progress_label
_accept_label = accept_label
if apply_http_config.is_valid():
apply_http_config.call(progress_client)
apply_http_config.call(defs_client)
if _progress_client.has_signal("quest_progress_received"):
_progress_client.connect("quest_progress_received", Callable(self, "_on_progress_received"))
if _progress_client.has_signal("quest_sync_failed"):
_progress_client.connect("quest_sync_failed", Callable(self, "_on_sync_failed"))
if _progress_client.has_signal("quest_accept_result_received"):
_progress_client.connect(
"quest_accept_result_received", Callable(self, "_on_accept_result_received")
)
if _progress_client.has_signal("quest_accept_failed"):
_progress_client.connect("quest_accept_failed", Callable(self, "_on_accept_failed"))
if _defs_client.has_signal("definitions_ready"):
_defs_client.connect("definitions_ready", Callable(self, "_on_definitions_ready"))
if _defs_client.has_signal("definitions_sync_failed"):
_defs_client.connect(
"definitions_sync_failed", Callable(self, "_on_definitions_sync_failed")
)
_render_progress_label()
_render_accept_feedback_label()
if _defs_client.has_method("request_sync_from_server"):
_defs_client.call("request_sync_from_server")
request_progress_refresh()
func request_progress_refresh() -> void:
if not is_instance_valid(_progress_client):
return
if _progress_client.has_method("request_sync_from_server"):
_progress_client.call("request_sync_from_server")
func try_accept_key_input(event: InputEvent) -> bool:
if not event is InputEventKey:
return false
var k := event as InputEventKey
if not k.pressed or k.echo:
return false
if k.physical_keycode != KEY_Q and k.keycode != KEY_Q:
return false
if k.shift_pressed:
_request_eligible_accept()
else:
_request_accept(GATHER_QUEST_ID)
return true
func _on_progress_received(snapshot: Dictionary) -> void:
_progress_error = ""
_last_snapshot = snapshot.duplicate(true)
_reapply_accept_quest_patch_if_needed()
_render_progress_label()
func _on_sync_failed(reason: String) -> void:
_progress_error = reason
if _accept_quest_patch.is_empty():
_last_snapshot = {}
elif _last_snapshot.is_empty():
_last_snapshot = QuestProgressClient.merge_quest_row_into_snapshot({}, _accept_quest_patch)
_render_progress_label()
func _on_accept_result_received(quest_id: String, result: Dictionary) -> void:
if bool(result.get("accepted", false)):
_render_accept_feedback_label("Quest accept: %s accepted" % _display_name(quest_id))
var quest_variant: Variant = result.get("quest", null)
if quest_variant is Dictionary:
_accept_quest_patch = (quest_variant as Dictionary).duplicate(true)
_progress_error = ""
_last_snapshot = QuestProgressClient.merge_quest_row_into_snapshot(
_last_snapshot, _accept_quest_patch
)
_render_progress_label()
else:
var rc := str(result.get("reasonCode", "")).strip_edges()
if rc.is_empty():
_render_accept_feedback_label("Quest accept: denied (no reasonCode)")
else:
_render_accept_feedback_label("Quest accept: denied — %s" % rc)
request_progress_refresh()
func _on_accept_failed(_quest_id: String, reason: String) -> void:
_render_accept_feedback_label("Quest accept: failed — %s" % reason)
func _on_definitions_ready(_quests: Array) -> void:
_defs_error = ""
if not _last_snapshot.is_empty():
_render_progress_label()
func _on_definitions_sync_failed(reason: String) -> void:
_defs_error = reason
_render_progress_label()
func _render_progress_label() -> void:
if not is_instance_valid(_progress_label):
return
var header := "Quests:"
if _last_snapshot.is_empty():
var lines: PackedStringArray = [header]
if not _progress_error.is_empty():
lines.append("error — %s" % _progress_error)
if not _defs_error.is_empty():
lines.append("definitions error — %s" % _defs_error)
lines.append("Loading…")
_progress_label.text = "\n".join(lines)
return
var lines: PackedStringArray = [header]
if not _progress_error.is_empty():
lines.append("sync error — %s" % _progress_error)
if not _defs_error.is_empty():
lines.append("definitions error — %s" % _defs_error)
var defs: Array = _defs_snapshot()
if defs.is_empty():
var quests: Variant = _last_snapshot.get("quests", null)
if quests is Array:
for row_variant in quests as Array:
if not row_variant is Dictionary:
continue
var row: Dictionary = row_variant
var qid: String = str(row.get("questId", ""))
lines.append(" %s" % _format_status_line(qid, qid, row))
else:
for def_variant in defs:
if not def_variant is Dictionary:
continue
var def: Dictionary = def_variant
var qid: String = str(def.get("id", ""))
if qid.is_empty():
continue
var row: Dictionary = _quest_row(qid)
var label: String = _display_name(qid)
lines.append(" %s" % _format_status_line(qid, label, row))
_progress_label.text = "\n".join(lines)
func _format_status_line(_quest_id: String, display_name: String, row: Dictionary) -> String:
var status: String = str(row.get("status", "not_started"))
match status:
"not_started":
return "%s: not started" % display_name
"active":
var step_index: int = int(row.get("currentStepIndex", 0))
var counter_text: String = _format_objective_counters_summary(
row.get("objectiveCounters", {})
)
if counter_text.is_empty():
return "%s: active step %d" % [display_name, step_index + 1]
return "%s: active step %d %s" % [display_name, step_index + 1, counter_text]
"completed":
var completed_at: String = str(row.get("completedAt", "")).strip_edges()
if completed_at.is_empty():
return "%s: completed" % display_name
return "%s: completed (%s)" % [display_name, completed_at]
_:
return "%s: unknown status (%s)" % [display_name, status]
func _format_objective_counters_summary(counters: Variant) -> String:
if counters == null or not counters is Dictionary:
return ""
var d: Dictionary = counters
if d.is_empty():
return ""
var parts: PackedStringArray = PackedStringArray()
for key in d.keys():
parts.append("%s=%s" % [str(key), str(d[key])])
return "(%s)" % ", ".join(parts)
func _render_accept_feedback_label(text: String = ACCEPT_IDLE_HINT) -> void:
if is_instance_valid(_accept_label):
_accept_label.text = text
func _quest_row(quest_id: String) -> Dictionary:
if not is_instance_valid(_progress_client):
return {}
if not _progress_client.has_method("quest_row"):
return {}
var row: Variant = _progress_client.call("quest_row", quest_id, _last_snapshot)
return row as Dictionary
func _reapply_accept_quest_patch_if_needed() -> void:
if _accept_quest_patch.is_empty():
return
var patch_id := str(_accept_quest_patch.get("questId", "")).strip_edges()
if patch_id.is_empty():
return
var row: Dictionary = _quest_row(patch_id)
if str(row.get("status", "not_started")) == "not_started":
_last_snapshot = QuestProgressClient.merge_quest_row_into_snapshot(
_last_snapshot, _accept_quest_patch
)
else:
_accept_quest_patch = {}
func _display_name(quest_id: String) -> String:
if not is_instance_valid(_defs_client):
return quest_id
if not _defs_client.has_method("display_name_for"):
return quest_id
return str(_defs_client.call("display_name_for", quest_id))
func _defs_snapshot() -> Array:
if not is_instance_valid(_defs_client):
return []
if not _defs_client.has_method("quests_snapshot"):
return []
return _defs_client.call("quests_snapshot") as Array
func _find_first_eligible_quest_id() -> String:
var defs: Array = _defs_snapshot()
for def_variant in defs:
if not def_variant is Dictionary:
continue
var def: Dictionary = def_variant
var qid: String = str(def.get("id", ""))
if qid.is_empty():
continue
if _quest_status(qid) != "not_started":
continue
var prereqs: Variant = def.get("prerequisiteQuestIds", [])
if not prereqs is Array:
continue
var prereqs_met := true
for p_variant in prereqs as Array:
var pid: String = str(p_variant)
if _quest_status(pid) != "completed":
prereqs_met = false
break
if prereqs_met:
return qid
return ""
func _quest_status(quest_id: String) -> String:
var row: Dictionary = _quest_row(quest_id)
if row.is_empty():
return "not_started"
return str(row.get("status", "not_started"))
func _request_accept(quest_id: String) -> bool:
if not is_instance_valid(_progress_client):
_render_accept_feedback_label("Quest accept: failed — client unavailable")
return false
if (
_progress_client.has_method("is_accept_busy")
and bool(_progress_client.call("is_accept_busy"))
):
_render_accept_feedback_label("Quest accept: busy — try again")
return false
if not _progress_client.has_method("request_accept"):
_render_accept_feedback_label("Quest accept: failed — client unavailable")
return false
var started: bool = bool(_progress_client.call("request_accept", quest_id))
if started:
_render_accept_feedback_label(ACCEPT_SENDING_HINT)
else:
_render_accept_feedback_label("Quest accept: failed — could not start")
return started
func _request_eligible_accept() -> void:
if not _progress_error.is_empty():
_render_accept_feedback_label("Quest accept: progress error — try again")
return
if _progress_error.is_empty() and _last_snapshot.is_empty():
_render_accept_feedback_label("Quest accept: progress loading — try again")
return
if not _defs_error.is_empty() and _defs_snapshot().is_empty():
_render_accept_feedback_label("Quest accept: definitions error — try again")
return
if _defs_error.is_empty() and _defs_snapshot().is_empty():
_render_accept_feedback_label("Quest accept: definitions loading — try again")
return
var eligible_id: String = _find_first_eligible_quest_id()
if eligible_id.is_empty():
_render_accept_feedback_label("Quest accept: no eligible quest")
return
_request_accept(eligible_id)

View File

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

View File

@ -0,0 +1,238 @@
extends Node
## NEO-122: HTTP client for quest-progress GET (NEO-119) and quest accept POST (NEO-120).
signal quest_progress_received(snapshot: Dictionary)
signal quest_sync_failed(reason: String)
signal quest_accept_result_received(quest_id: String, result: Dictionary)
signal quest_accept_failed(quest_id: String, reason: String)
const SCHEMA_VERSION := 1
@export var base_url: String = "http://127.0.0.1:5253"
@export var dev_player_id: String = "dev-local-1"
@export var injected_sync_http: Node = null
@export var injected_accept_http: Node = null
var _sync_http: Node
var _accept_http: Node
var _sync_busy: bool = false
var _sync_pending: bool = false
var _accept_busy: bool = false
var _current_accept_quest_id: String = ""
func _ready() -> void:
_sync_http = _resolve_http(injected_sync_http)
_accept_http = _resolve_http(injected_accept_http)
@warning_ignore("unsafe_method_access")
_sync_http.request_completed.connect(_on_sync_request_completed)
@warning_ignore("unsafe_method_access")
_accept_http.request_completed.connect(_on_accept_request_completed)
func is_accept_busy() -> bool:
return _accept_busy
func request_sync_from_server() -> void:
if _sync_busy:
_sync_pending = true
return
_start_sync_request()
func _start_sync_request() -> void:
_sync_busy = true
var url := "%s/game/players/%s/quest-progress" % [_base_root(), _player_path_segment()]
var err: Error = _sync_http.request(url)
if err != OK:
var reason := "GET failed to start (%s)" % err
push_warning("QuestProgressClient: %s" % reason)
_sync_busy = false
quest_sync_failed.emit(reason)
_try_flush_pending_sync()
## Returns [code]true[/code] when the HTTP POST was queued.
func request_accept(quest_id: String) -> bool:
if _accept_busy:
return false
var qid := quest_id.strip_edges()
if qid.is_empty():
return false
_current_accept_quest_id = qid
_accept_busy = true
var payload: Dictionary = {"schemaVersion": SCHEMA_VERSION}
var url := "%s/game/players/%s/quests/%s/accept" % [_base_root(), _player_path_segment(), qid]
var headers := PackedStringArray(["Content-Type: application/json"])
var err: Error = _accept_http.request(
url, headers, HTTPClient.METHOD_POST, JSON.stringify(payload)
)
if err != OK:
_accept_busy = false
_current_accept_quest_id = ""
push_warning("QuestProgressClient: POST failed to start (%s)" % err)
return false
return true
func quest_row(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:
return row
return {}
func _resolve_http(injected: Node) -> Node:
if injected != null:
return injected
var http := HTTPRequest.new()
add_child(http)
http.timeout = 30.0
return http
func _base_root() -> String:
return base_url.strip_edges().rstrip("/")
func _player_path_segment() -> String:
return dev_player_id.strip_edges()
static func parse_quest_progress_json(text: String) -> Variant:
var parsed: Variant = JSON.parse_string(text)
if not parsed is Dictionary:
return null
var root: Dictionary = parsed
if int(root.get("schemaVersion", -1)) != SCHEMA_VERSION:
return null
var quests: Variant = root.get("quests", null)
if quests == null or not quests is Array:
return null
if str(root.get("playerId", "")).strip_edges().is_empty():
return null
return root
static func parse_quest_accept_json(text: String) -> Variant:
var parsed: Variant = JSON.parse_string(text)
if not parsed is Dictionary:
return null
var root: Dictionary = parsed
if int(root.get("schemaVersion", -1)) != SCHEMA_VERSION:
return null
if not root.has("accepted"):
return null
return root
static func merge_quest_row_into_snapshot(snapshot: Dictionary, row: Dictionary) -> Dictionary:
var qid := str(row.get("questId", "")).strip_edges()
if qid.is_empty():
return snapshot.duplicate(true) if not snapshot.is_empty() else {}
var merged: Dictionary
if snapshot.is_empty():
merged = {"schemaVersion": SCHEMA_VERSION, "playerId": "", "quests": []}
else:
merged = snapshot.duplicate(true)
var quests: Array = []
var quests_variant: Variant = merged.get("quests", null)
if quests_variant is Array:
for row_variant in quests_variant as Array:
quests.append(row_variant)
var replaced := false
for i in quests.size():
var existing: Variant = quests[i]
if existing is Dictionary and str((existing as Dictionary).get("questId", "")) == qid:
quests[i] = row.duplicate(true)
replaced = true
break
if not replaced:
quests.append(row.duplicate(true))
merged["quests"] = quests
return merged
func _try_flush_pending_sync() -> void:
if not _sync_pending or _sync_busy:
return
_sync_pending = false
_start_sync_request()
func _on_sync_request_completed(
result: int, response_code: int, _headers: PackedStringArray, body: PackedByteArray
) -> void:
_sync_busy = false
if result != HTTPRequest.RESULT_SUCCESS:
var reason := "HTTP failed (result=%s)" % result
push_warning("QuestProgressClient: %s" % reason)
quest_sync_failed.emit(reason)
_try_flush_pending_sync()
return
if response_code == 404:
var reason404 := "HTTP 404 (player unknown)"
push_warning("QuestProgressClient: %s" % reason404)
quest_sync_failed.emit(reason404)
_try_flush_pending_sync()
return
if response_code < 200 or response_code >= 300:
var reason_code := "HTTP %s" % response_code
push_warning("QuestProgressClient: %s" % reason_code)
quest_sync_failed.emit(reason_code)
_try_flush_pending_sync()
return
var text := body.get_string_from_utf8()
var snapshot: Variant = parse_quest_progress_json(text)
if snapshot == null:
var reason_json := "non-JSON body or schemaVersion mismatch"
push_warning("QuestProgressClient: %s" % reason_json)
quest_sync_failed.emit(reason_json)
_try_flush_pending_sync()
return
quest_progress_received.emit(snapshot as Dictionary)
_try_flush_pending_sync()
func _on_accept_request_completed(
result: int, response_code: int, _headers: PackedStringArray, body: PackedByteArray
) -> void:
var quest_echo: String = _current_accept_quest_id
_current_accept_quest_id = ""
_accept_busy = false
if result != HTTPRequest.RESULT_SUCCESS:
var reason := "HTTP failed (result=%s)" % result
push_warning("QuestProgressClient: %s" % reason)
quest_accept_failed.emit(quest_echo, reason)
return
if response_code == 404:
var reason404 := "HTTP 404 (player unknown)"
push_warning("QuestProgressClient: %s" % reason404)
quest_accept_failed.emit(quest_echo, reason404)
return
if response_code < 200 or response_code >= 300:
var reason_code := "HTTP %s" % response_code
push_warning("QuestProgressClient: %s" % reason_code)
quest_accept_failed.emit(quest_echo, reason_code)
return
var text := body.get_string_from_utf8()
var parsed: Variant = parse_quest_accept_json(text)
if parsed == null:
var reason_json := "non-JSON body or schemaVersion mismatch"
push_warning("QuestProgressClient: %s" % reason_json)
quest_accept_failed.emit(quest_echo, reason_json)
return
var data: Dictionary = parsed as Dictionary
if not bool(data.get("accepted", false)):
var reason_variant: Variant = data.get("reasonCode", "")
var reason_str: String = reason_variant as String if reason_variant is String else ""
push_warning("quest_accept_denied reasonCode=%s" % reason_str)
quest_accept_result_received.emit(quest_echo, data.duplicate(true))

View File

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

View File

@ -0,0 +1,104 @@
extends GdUnitTestSuite
## NEO-122 follow-up: craft recipe panel rows + economy body scroll reset.
const CraftRecipePanel := preload("res://scripts/craft_recipe_panel.gd")
const EconomyHudSection := preload("res://scripts/prototype_economy_hud_section.gd")
static func _sample_recipes() -> Array:
return [
{
"id": "refine_scrap_standard",
"displayName": "Refine Scrap (Standard)",
"inputs": [{"itemId": "scrap_metal_bulk", "quantity": 5}],
},
{
"id": "refine_scrap_efficient",
"displayName": "Refine Scrap (Efficient)",
"inputs": [{"itemId": "scrap_metal_bulk", "quantity": 10}],
},
]
func _build_panel() -> Control:
var panel: Control = CraftRecipePanel.new()
var rows := VBoxContainer.new()
rows.name = "RecipeRows"
panel.add_child(rows)
auto_free(panel)
add_child(panel)
await get_tree().process_frame
return panel
func _build_panel_in_economy_section() -> Dictionary:
var section: VBoxContainer = EconomyHudSection.new()
var header := HBoxContainer.new()
header.name = "HeaderRow"
var toggle := CheckButton.new()
toggle.name = "ToggleButton"
header.add_child(toggle)
var body_scroll := ScrollContainer.new()
body_scroll.name = "BodyScroll"
var body := VBoxContainer.new()
body.name = "Body"
var recipes_header := Label.new()
recipes_header.name = "RecipesHeaderLabel"
body.add_child(recipes_header)
var panel: Control = CraftRecipePanel.new()
panel.name = "CraftRecipePanel"
var rows := VBoxContainer.new()
rows.name = "RecipeRows"
panel.add_child(rows)
body.add_child(panel)
body_scroll.add_child(body)
section.add_child(header)
section.add_child(body_scroll)
auto_free(section)
add_child(section)
await get_tree().process_frame
return {"section": section, "panel": panel, "body_scroll": body_scroll}
func test_populate_builds_one_row_per_recipe() -> void:
# Arrange
var panel := await _build_panel()
# Act
panel.call("populate", _sample_recipes())
await get_tree().process_frame
# Assert
var rows: VBoxContainer = panel.get_node("RecipeRows")
assert_that(rows.get_child_count()).is_equal(2)
func test_populate_scrolls_economy_body_to_recipes() -> void:
# Arrange
var built: Dictionary = await _build_panel_in_economy_section()
var panel: Control = built["panel"]
var body_scroll: ScrollContainer = built["body_scroll"]
body_scroll.scroll_vertical = 999
# Act
panel.call("populate", _sample_recipes())
await get_tree().process_frame
await get_tree().process_frame
# Assert
assert_that(body_scroll.scroll_vertical).is_equal(0)
func test_populate_emits_craft_requested_for_row() -> void:
# Arrange
var panel := await _build_panel()
var captured: Array = []
panel.connect("craft_requested", func(recipe_id: String) -> void: captured.append(recipe_id))
panel.call("populate", _sample_recipes())
await get_tree().process_frame
var rows: VBoxContainer = panel.get_node("RecipeRows")
var first_row: VBoxContainer = rows.get_child(0) as VBoxContainer
var btn_row: HBoxContainer = first_row.get_child(1) as HBoxContainer
var craft_btn: Button = btn_row.get_child(0) as Button
# Act
craft_btn.pressed.emit()
# Assert
assert_that(captured.size()).is_equal(1)
assert_that(str(captured[0])).is_equal("refine_scrap_standard")

View File

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

View File

@ -29,11 +29,13 @@ func test_display_windup_remaining_decreases_over_time() -> void:
state.call("apply_snapshot", _telegraph_snapshot(2.0))
var first: float = float(state.call("display_windup_remaining", "prototype_npc_elite"))
# Act
await (Engine.get_main_loop() as SceneTree).create_timer(0.05).timeout
for _i in 3:
await get_tree().process_frame
await (Engine.get_main_loop() as SceneTree).create_timer(0.1).timeout
var second: float = float(state.call("display_windup_remaining", "prototype_npc_elite"))
# Assert
assert_that(second).is_less(first)
assert_that(second).is_greater(1.9)
assert_that(second).is_greater(1.85)
func test_new_snapshot_reconciles_windup_anchor() -> void:

View File

@ -1,10 +1,12 @@
extends GdUnitTestSuite
## NEO-75: economy HUD collapse toggle shows/hides body container.
## Covers economy HUD node paths via EconomyHudSection.get_node in main.gd.
## NEO-75: economy HUD collapse toggle shows/hides body scroll container.
## NEO-122: BodyScroll/Body paths match main.tscn; HudRootScroll clamp on layout.
const EconomyHudSection := preload("res://scripts/prototype_economy_hud_section.gd")
const TEST_VIEWPORT_SIZE := Vector2i(1280, 720)
func _build_section() -> Node:
var section: VBoxContainer = EconomyHudSection.new()
@ -14,16 +16,27 @@ func _build_section() -> Node:
toggle.name = "ToggleButton"
toggle.text = "Economy HUD"
header.add_child(toggle)
var body_scroll := ScrollContainer.new()
body_scroll.name = "BodyScroll"
var body := VBoxContainer.new()
body.name = "Body"
var recipes_header := Label.new()
recipes_header.name = "RecipesHeaderLabel"
body.add_child(recipes_header)
var inventory := Label.new()
inventory.name = "InventoryLabel"
body.add_child(inventory)
body_scroll.add_child(body)
section.add_child(header)
section.add_child(body)
section.add_child(body_scroll)
return section
func _set_test_viewport_size(size: Vector2i = TEST_VIEWPORT_SIZE) -> void:
get_tree().root.size = size
await get_tree().process_frame
func test_body_visible_when_expanded_by_default() -> void:
# Arrange
var section := _build_section()
@ -59,3 +72,43 @@ func test_toggle_expands_body_again() -> void:
await await_idle_frame()
# Assert
assert_bool(section.call("is_body_visible")).is_true()
func test_viewport_layout_pins_section_above_bottom_margin() -> void:
# Arrange
await _set_test_viewport_size()
var section: Control = _build_section() as Control
add_child(section)
await await_idle_frame()
await get_tree().process_frame
# Act
section.call("_apply_viewport_layout")
await get_tree().process_frame
# Assert
var vp_height: float = get_viewport().get_visible_rect().size.y
assert_that(section.position.y).is_greater_equal(0.0)
assert_that(section.position.y).is_greater(vp_height * 0.2)
assert_that(section.position.y + section.size.y).is_less_equal(vp_height)
func test_viewport_layout_clamps_hud_root_scroll() -> void:
# Arrange
await _set_test_viewport_size()
var canvas := Control.new()
auto_free(canvas)
canvas.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
var hud_scroll := ScrollContainer.new()
hud_scroll.name = "HudRootScroll"
canvas.add_child(hud_scroll)
var section: Control = _build_section() as Control
canvas.add_child(section)
add_child(canvas)
await await_idle_frame()
await get_tree().process_frame
# Act
section.call("_apply_viewport_layout")
await get_tree().process_frame
# Assert
var panel_top: float = section.call("panel_top_y")
assert_that(panel_top).is_greater(0.0)
assert_that(hud_scroll.size.y).is_less_equal(panel_top - 12.0)

View File

@ -0,0 +1,113 @@
extends GdUnitTestSuite
## NEO-122: `QuestDefinitionsClient` GET parse + display names.
const QuestDefinitionsClient := preload("res://scripts/quest_definitions_client.gd")
var _quests_capture: Array = []
func _capture_quests(quests: Array) -> void:
_quests_capture = quests
class MockHttpTransport:
extends Node
signal request_completed(
result: int, response_code: int, headers: PackedStringArray, body: PackedByteArray
)
var last_url: String = ""
var response_code: int = 200
var body_json: String = ""
func request(
url: String,
_custom_headers: PackedStringArray = PackedStringArray(),
_method: HTTPClient.Method = HTTPClient.METHOD_GET,
_request_data: String = ""
) -> Error:
last_url = url
request_completed.emit(
HTTPRequest.RESULT_SUCCESS,
response_code,
PackedStringArray(),
body_json.to_utf8_buffer()
)
return OK
static func _four_quests_json() -> String:
return (
'{"schemaVersion":1,"quests":['
+ '{"id":"prototype_quest_gather_intro","displayName":"Intro: Salvage Run",'
+ '"prerequisiteQuestIds":[],"steps":[]},'
+ '{"id":"prototype_quest_refine_intro","displayName":"Intro: Refine Stock",'
+ '"prerequisiteQuestIds":["prototype_quest_gather_intro"],"steps":[]},'
+ '{"id":"prototype_quest_combat_intro","displayName":"Intro: Clear the Pocket",'
+ '"prerequisiteQuestIds":[],"steps":[]},'
+ '{"id":"prototype_quest_operator_chain","displayName":"Operator Chain",'
+ '"prerequisiteQuestIds":["prototype_quest_gather_intro",'
+ '"prototype_quest_refine_intro","prototype_quest_combat_intro"],"steps":[]}'
+ "]}"
)
func _make_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 test_parse_quests_json_reads_four_quests() -> void:
# Arrange
var json := _four_quests_json()
# Act
var quests: Variant = QuestDefinitionsClient.parse_quests_json(json)
# Assert
assert_that(quests is Array).is_true()
assert_that((quests as Array).size()).is_equal(4)
func test_request_sync_emits_definitions_ready() -> void:
# Arrange
_quests_capture = []
var transport := MockHttpTransport.new()
transport.body_json = _four_quests_json()
var c := _make_client(transport)
c.connect("definitions_ready", Callable(self, "_capture_quests"))
monitor_signals(c)
# Act
c.call("request_sync_from_server")
# Assert
await assert_signal(c).is_emitted("definitions_ready", any())
assert_that(_quests_capture.size()).is_equal(4)
assert_that(transport.last_url).contains("/quest-definitions")
func test_display_name_for_returns_catalog_display_name() -> void:
# Arrange
var transport := MockHttpTransport.new()
transport.body_json = _four_quests_json()
var c := _make_client(transport)
c.call("request_sync_from_server")
await get_tree().process_frame
# Act
var name: String = str(c.call("display_name_for", "prototype_quest_gather_intro"))
# Assert
assert_that(name).is_equal("Intro: Salvage Run")
func test_display_name_for_falls_back_to_raw_id() -> void:
# Arrange
var transport := MockHttpTransport.new()
transport.body_json = _four_quests_json()
var c := _make_client(transport)
c.call("request_sync_from_server")
await get_tree().process_frame
# Act
var name: String = str(c.call("display_name_for", "unknown_quest_id"))
# Assert
assert_that(name).is_equal("unknown_quest_id")

View File

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

View File

@ -0,0 +1,477 @@
extends GdUnitTestSuite
## NEO-122: quest HUD controller accept key + progress label render.
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")
class MockHttpTransport:
extends Node
signal request_completed(
result: int, response_code: int, headers: PackedStringArray, body: PackedByteArray
)
var response_code: int = 200
var body_json: String = ""
func request(
_url: String,
_custom_headers: PackedStringArray = PackedStringArray(),
_method: HTTPClient.Method = HTTPClient.METHOD_GET,
_request_data: String = ""
) -> Error:
request_completed.emit(
HTTPRequest.RESULT_SUCCESS,
response_code,
PackedStringArray(),
body_json.to_utf8_buffer()
)
return OK
class NoopHttpTransport:
extends Node
signal request_completed(
result: int, response_code: int, headers: PackedStringArray, body: PackedByteArray
)
func request(
_url: String,
_custom_headers: PackedStringArray = PackedStringArray(),
_method: HTTPClient.Method = HTTPClient.METHOD_GET,
_request_data: String = ""
) -> Error:
return OK
class PendingAcceptHttpTransport:
extends Node
signal request_completed(
result: int, response_code: int, headers: PackedStringArray, body: PackedByteArray
)
func request(
_url: String,
_custom_headers: PackedStringArray = PackedStringArray(),
_method: HTTPClient.Method = HTTPClient.METHOD_POST,
_request_data: String = ""
) -> Error:
return OK
class FailStartAcceptHttpTransport:
extends Node
signal request_completed(
result: int, response_code: int, headers: PackedStringArray, body: PackedByteArray
)
func request(
_url: String,
_custom_headers: PackedStringArray = PackedStringArray(),
_method: HTTPClient.Method = HTTPClient.METHOD_POST,
_request_data: String = ""
) -> Error:
return ERR_UNAVAILABLE
func _make_progress_client(sync_transport: Node, accept_transport: Node = null) -> Node:
var c: Node = QuestProgressClient.new()
c.set("injected_sync_http", sync_transport)
var accept_http: Node = accept_transport if accept_transport != null else sync_transport
c.set("injected_accept_http", accept_http)
auto_free(sync_transport)
if accept_transport != null and accept_transport != sync_transport:
auto_free(accept_transport)
auto_free(c)
add_child(c)
return c
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 _build_controller(
sync_transport: Node, accept_transport: Node, defs_transport: Node
) -> Dictionary:
var controller: Node = QuestHudController.new()
var progress: Node = _make_progress_client(sync_transport, accept_transport)
var defs: Node = _make_defs_client(defs_transport)
var progress_label := Label.new()
var accept_label := Label.new()
auto_free(controller)
auto_free(progress_label)
auto_free(accept_label)
add_child(controller)
await get_tree().process_frame
controller.call(
"setup", progress, defs, progress_label, accept_label, Callable(self, "_noop_http_config")
)
await get_tree().process_frame
return {
"controller": controller,
"progress": progress,
"accept_label": accept_label,
"progress_label": progress_label,
}
func _noop_http_config(_client: Node) -> void:
pass
func test_try_accept_key_input_handles_q_key() -> void:
# Arrange
var built: Dictionary = await _build_controller(
NoopHttpTransport.new(), PendingAcceptHttpTransport.new(), NoopHttpTransport.new()
)
var event := InputEventKey.new()
event.pressed = true
event.keycode = KEY_Q
# Act
var handled: bool = bool(built["controller"].call("try_accept_key_input", event))
# Assert
assert_bool(handled).is_true()
assert_str(built["accept_label"].text).contains("sending")
func test_try_accept_key_input_shows_busy_while_accept_in_flight() -> void:
# Arrange
var built: Dictionary = await _build_controller(
NoopHttpTransport.new(), PendingAcceptHttpTransport.new(), NoopHttpTransport.new()
)
var event := InputEventKey.new()
event.pressed = true
event.keycode = KEY_Q
built["controller"].call("try_accept_key_input", event)
# Act
var handled: bool = bool(built["controller"].call("try_accept_key_input", event))
# Assert
assert_bool(handled).is_true()
assert_str(built["accept_label"].text).contains("busy")
func test_try_accept_key_input_shows_failed_when_post_wont_start() -> void:
# Arrange
var built: Dictionary = await _build_controller(
NoopHttpTransport.new(), FailStartAcceptHttpTransport.new(), NoopHttpTransport.new()
)
var event := InputEventKey.new()
event.pressed = true
event.keycode = KEY_Q
# Act
var handled: bool = bool(built["controller"].call("try_accept_key_input", event))
# Assert
assert_bool(handled).is_true()
assert_str(built["accept_label"].text).contains("could not start")
func test_progress_label_shows_loading_before_snapshot() -> void:
# Arrange
var built: Dictionary = await _build_controller(
NoopHttpTransport.new(), NoopHttpTransport.new(), NoopHttpTransport.new()
)
# Act
var text: String = str(built["progress_label"].text)
# Assert
assert_str(text).contains("Quests:")
assert_str(text).contains("Loading")
func test_shift_q_shows_progress_loading_before_snapshot() -> void:
# Arrange
var controller: Node = QuestHudController.new()
var accept_label := Label.new()
var progress_client: Node = QuestProgressClient.new()
var defs_client: Node = QuestDefinitionsClient.new()
auto_free(controller)
auto_free(accept_label)
auto_free(progress_client)
auto_free(defs_client)
add_child(controller)
controller.set("_progress_client", progress_client)
controller.set("_defs_client", defs_client)
controller.set("_accept_label", accept_label)
controller.set("_last_snapshot", {})
controller.set("_progress_error", "")
# Act
controller.call("_request_eligible_accept")
# Assert
assert_str(accept_label.text).contains("progress loading")
func test_shift_q_blocks_when_progress_sync_failed() -> void:
# Arrange
var controller: Node = QuestHudController.new()
var accept_label := Label.new()
var progress_client: Node = QuestProgressClient.new()
var defs_client: Node = QuestDefinitionsClient.new()
auto_free(controller)
auto_free(accept_label)
auto_free(progress_client)
auto_free(defs_client)
add_child(controller)
controller.set("_progress_client", progress_client)
controller.set("_defs_client", defs_client)
controller.set("_accept_label", accept_label)
controller.set("_last_snapshot", {})
controller.set("_progress_error", "HTTP 404 (player unknown)")
# Act
controller.call("_request_eligible_accept")
# Assert
assert_str(accept_label.text).contains("progress error")
func test_request_accept_shows_busy_feedback() -> void:
# Arrange
var built: Dictionary = await _build_controller(
NoopHttpTransport.new(), PendingAcceptHttpTransport.new(), NoopHttpTransport.new()
)
var controller: Node = built["controller"]
var accept_label: Label = built["accept_label"]
controller.call("_request_accept", QuestHudController.GATHER_QUEST_ID)
# Act
var started: bool = bool(controller.call("_request_accept", QuestHudController.GATHER_QUEST_ID))
# Assert
assert_bool(started).is_false()
assert_str(accept_label.text).contains("busy")
func test_request_accept_shows_sending_when_post_starts() -> void:
# Arrange
var built: Dictionary = await _build_controller(
NoopHttpTransport.new(), PendingAcceptHttpTransport.new(), NoopHttpTransport.new()
)
var controller: Node = built["controller"]
var accept_label: Label = built["accept_label"]
# Act
var started: bool = bool(controller.call("_request_accept", QuestHudController.GATHER_QUEST_ID))
# Assert
assert_bool(started).is_true()
assert_str(accept_label.text).contains("sending")
func _not_started_snapshot() -> Dictionary:
return {
"schemaVersion": 1,
"playerId": "dev-local-1",
"quests":
[
{
"questId": QuestHudController.GATHER_QUEST_ID,
"status": "not_started",
"currentStepIndex": 0,
"objectiveCounters": {},
}
],
}
func test_accept_success_merges_quest_row_into_progress_label() -> void:
# Arrange
var progress_client: Node = _make_progress_client(NoopHttpTransport.new())
var controller: Node = QuestHudController.new()
var progress_label := Label.new()
auto_free(controller)
auto_free(progress_label)
add_child(controller)
await get_tree().process_frame
controller.set("_progress_client", progress_client)
controller.set("_progress_label", progress_label)
controller.set("_last_snapshot", _not_started_snapshot())
# Act
(
controller
. call(
"_on_accept_result_received",
QuestHudController.GATHER_QUEST_ID,
{
"accepted": true,
"quest":
{
"questId": QuestHudController.GATHER_QUEST_ID,
"status": "active",
"currentStepIndex": 0,
"objectiveCounters": {},
},
}
)
)
# Assert
assert_str(progress_label.text).contains("active step")
func test_stale_sync_reapplies_accept_quest_patch() -> void:
# Arrange
var progress_client: Node = _make_progress_client(NoopHttpTransport.new())
var controller: Node = QuestHudController.new()
var progress_label := Label.new()
auto_free(controller)
auto_free(progress_label)
add_child(controller)
await get_tree().process_frame
controller.set("_progress_client", progress_client)
controller.set("_progress_label", progress_label)
controller.set("_last_snapshot", _not_started_snapshot())
(
controller
. set(
"_accept_quest_patch",
{
"questId": QuestHudController.GATHER_QUEST_ID,
"status": "active",
"currentStepIndex": 0,
"objectiveCounters": {},
}
)
)
# Act
controller.call("_on_progress_received", _not_started_snapshot())
# Assert
assert_str(progress_label.text).contains("active step")
func test_accept_clears_progress_error_and_shows_active_row() -> void:
# Arrange
var progress_client: Node = _make_progress_client(NoopHttpTransport.new())
var controller: Node = QuestHudController.new()
var progress_label := Label.new()
auto_free(controller)
auto_free(progress_label)
add_child(controller)
await get_tree().process_frame
controller.set("_progress_client", progress_client)
controller.set("_progress_label", progress_label)
controller.set("_last_snapshot", _not_started_snapshot())
controller.set("_progress_error", "HTTP 404 (player unknown)")
# Act
(
controller
. call(
"_on_accept_result_received",
QuestHudController.GATHER_QUEST_ID,
{
"accepted": true,
"quest":
{
"questId": QuestHudController.GATHER_QUEST_ID,
"status": "active",
"currentStepIndex": 0,
"objectiveCounters": {},
},
}
)
)
# Assert
assert_str(progress_label.text).contains("active step")
assert_str(progress_label.text).not_contains("error —")
func test_sync_fail_preserves_accept_quest_patch() -> void:
# Arrange
var progress_client: Node = _make_progress_client(NoopHttpTransport.new())
var controller: Node = QuestHudController.new()
var progress_label := Label.new()
auto_free(controller)
auto_free(progress_label)
add_child(controller)
await get_tree().process_frame
controller.set("_progress_client", progress_client)
controller.set("_progress_label", progress_label)
(
controller
. set(
"_accept_quest_patch",
{
"questId": QuestHudController.GATHER_QUEST_ID,
"status": "active",
"currentStepIndex": 0,
"objectiveCounters": {},
}
)
)
(
controller
. set(
"_last_snapshot",
(
QuestProgressClient
. merge_quest_row_into_snapshot(
_not_started_snapshot(),
{
"questId": QuestHudController.GATHER_QUEST_ID,
"status": "active",
"currentStepIndex": 0,
"objectiveCounters": {},
}
)
)
)
)
# Act
controller.call("_on_sync_failed", "HTTP 503")
# Assert
assert_str(progress_label.text).contains("active step")
assert_str(progress_label.text).contains("sync error — HTTP 503")
func test_shift_q_shows_definitions_error_when_defs_failed() -> void:
# Arrange
var controller: Node = QuestHudController.new()
var accept_label := Label.new()
var progress_client: Node = QuestProgressClient.new()
var defs_client: Node = QuestDefinitionsClient.new()
auto_free(controller)
auto_free(accept_label)
auto_free(progress_client)
auto_free(defs_client)
add_child(controller)
controller.set("_progress_client", progress_client)
controller.set("_defs_client", defs_client)
controller.set("_accept_label", accept_label)
controller.set("_last_snapshot", _not_started_snapshot())
controller.set("_progress_error", "")
controller.set("_defs_error", "HTTP 503")
# Act
controller.call("_request_eligible_accept")
# Assert
assert_str(accept_label.text).contains("definitions error")
func _gather_defs_json() -> String:
return (
'{"schemaVersion":1,"quests":['
+ '{"id":"prototype_quest_gather_intro","displayName":"Intro: Salvage Run",'
+ '"prerequisiteQuestIds":[],"steps":[]}]}'
)
func test_definitions_ready_repaints_with_display_names_when_progress_error() -> void:
# Arrange
var defs_transport := MockHttpTransport.new()
defs_transport.body_json = _gather_defs_json()
var defs_client: Node = _make_defs_client(defs_transport)
var progress_client: Node = _make_progress_client(NoopHttpTransport.new())
var controller: Node = QuestHudController.new()
var progress_label := Label.new()
auto_free(controller)
auto_free(progress_label)
add_child(controller)
await get_tree().process_frame
defs_client.call("request_sync_from_server")
controller.set("_progress_client", progress_client)
controller.set("_defs_client", defs_client)
controller.set("_progress_label", progress_label)
controller.set("_last_snapshot", _not_started_snapshot())
controller.set("_progress_error", "HTTP 503")
# Act
controller.call("_on_definitions_ready", [])
# Assert
assert_str(progress_label.text).contains("Intro: Salvage Run")
assert_str(progress_label.text).not_contains("prototype_quest_gather_intro")

View File

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

View File

@ -0,0 +1,361 @@
extends GdUnitTestSuite
## NEO-122: `QuestProgressClient` GET parse, accept POST, and failure signals.
const QuestProgressClient := preload("res://scripts/quest_progress_client.gd")
const PROTOTYPE_QUEST_GATHER_ID := "prototype_quest_gather_intro"
var _progress_capture: Dictionary = {}
var _accept_capture: Dictionary = {}
var _accept_failed_capture: Dictionary = {}
func _capture_progress(snapshot: Dictionary) -> void:
_progress_capture = snapshot
func _capture_accept(quest_id: String, result: Dictionary) -> void:
_accept_capture = {"questId": quest_id, "result": result}
func _capture_accept_failed(quest_id: String, reason: String) -> void:
_accept_failed_capture = {"questId": quest_id, "reason": reason}
class MockHttpTransport:
extends Node
signal request_completed(
result: int, response_code: int, headers: PackedStringArray, body: PackedByteArray
)
var last_url: String = ""
var last_method: int = HTTPClient.METHOD_GET
var response_code: int = 200
var body_json: String = ""
func request(
url: String,
_custom_headers: PackedStringArray = PackedStringArray(),
method: HTTPClient.Method = HTTPClient.METHOD_GET,
_request_data: String = ""
) -> Error:
last_url = url
last_method = method
request_completed.emit(
HTTPRequest.RESULT_SUCCESS,
response_code,
PackedStringArray(),
body_json.to_utf8_buffer()
)
return OK
static func _not_started_progress_json() -> String:
return (
'{"schemaVersion":1,"playerId":"dev-local-1","quests":'
+ '[{"questId":"prototype_quest_gather_intro","status":"not_started",'
+ '"currentStepIndex":0,"objectiveCounters":{}}]}'
)
static func _active_partial_json() -> String:
return (
'{"schemaVersion":1,"playerId":"dev-local-1","quests":'
+ '[{"questId":"prototype_quest_gather_intro","status":"active",'
+ '"currentStepIndex":0,"objectiveCounters":{"gather_intro_obj_scrap":2}}]}'
)
static func _completed_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"}]}'
)
static func _accept_success_json() -> String:
return (
'{"schemaVersion":1,"accepted":true,"quest":'
+ '{"questId":"prototype_quest_gather_intro","status":"active",'
+ '"currentStepIndex":0,"objectiveCounters":{}}}'
)
static func _accept_deny_json() -> String:
return '{"schemaVersion":1,"accepted":false,"reasonCode":"prerequisite_incomplete"}'
func _make_client(sync_transport: Node, accept_transport: Node = null) -> Node:
var c: Node = QuestProgressClient.new()
c.set("injected_sync_http", sync_transport)
var accept_http: Node = accept_transport if accept_transport != null else sync_transport
c.set("injected_accept_http", accept_http)
auto_free(sync_transport)
if accept_transport != null and accept_transport != sync_transport:
auto_free(accept_transport)
auto_free(c)
add_child(c)
return c
class NoopHttpTransport:
extends Node
signal request_completed(
result: int, response_code: int, headers: PackedStringArray, body: PackedByteArray
)
func request(
_url: String,
_custom_headers: PackedStringArray = PackedStringArray(),
_method: HTTPClient.Method = HTTPClient.METHOD_GET,
_request_data: String = ""
) -> Error:
return OK
class PendingAcceptHttpTransport:
extends Node
signal request_completed(
result: int, response_code: int, headers: PackedStringArray, body: PackedByteArray
)
var last_url: String = ""
var response_code: int = 200
var body_json: String = ""
func request(
url: String,
_custom_headers: PackedStringArray = PackedStringArray(),
_method: HTTPClient.Method = HTTPClient.METHOD_POST,
_request_data: String = ""
) -> Error:
last_url = url
return OK
func complete_pending() -> void:
request_completed.emit(
HTTPRequest.RESULT_SUCCESS,
response_code,
PackedStringArray(),
body_json.to_utf8_buffer()
)
class PendingSyncHttpTransport:
extends Node
signal request_completed(
result: int, response_code: int, headers: PackedStringArray, body: PackedByteArray
)
var request_count: int = 0
var response_code: int = 200
var body_json: String = ""
func request(
_url: String,
_custom_headers: PackedStringArray = PackedStringArray(),
_method: HTTPClient.Method = HTTPClient.METHOD_GET,
_request_data: String = ""
) -> Error:
request_count += 1
return OK
func complete_pending() -> void:
request_completed.emit(
HTTPRequest.RESULT_SUCCESS,
response_code,
PackedStringArray(),
body_json.to_utf8_buffer()
)
func test_parse_not_started_quest_row() -> void:
# Arrange
var json := _not_started_progress_json()
# Act
var snapshot: Variant = QuestProgressClient.parse_quest_progress_json(json)
# Assert
assert_that(snapshot is Dictionary).is_true()
var c: Node = QuestProgressClient.new()
auto_free(c)
var row: Dictionary = c.call("quest_row", PROTOTYPE_QUEST_GATHER_ID, snapshot as Dictionary)
assert_that(str(row.get("status", ""))).is_equal("not_started")
func test_parse_active_row_has_objective_counters() -> void:
# Arrange
var json := _active_partial_json()
# Act
var snapshot: Variant = QuestProgressClient.parse_quest_progress_json(json)
# Assert
assert_that(snapshot is Dictionary).is_true()
var c: Node = QuestProgressClient.new()
auto_free(c)
var row: Dictionary = c.call("quest_row", PROTOTYPE_QUEST_GATHER_ID, snapshot as Dictionary)
assert_that(str(row.get("status", ""))).is_equal("active")
var counters: Variant = row.get("objectiveCounters", null)
assert_that(counters is Dictionary).is_true()
assert_that(int((counters as Dictionary).get("gather_intro_obj_scrap", 0))).is_equal(2)
func test_parse_completed_row_has_completed_at() -> void:
# Arrange
var json := _completed_json()
# Act
var snapshot: Variant = QuestProgressClient.parse_quest_progress_json(json)
# Assert
assert_that(snapshot is Dictionary).is_true()
var c: Node = QuestProgressClient.new()
auto_free(c)
var row: Dictionary = c.call("quest_row", PROTOTYPE_QUEST_GATHER_ID, snapshot as Dictionary)
assert_that(str(row.get("status", ""))).is_equal("completed")
assert_that(str(row.get("completedAt", ""))).contains("2026-06-07")
func test_request_sync_emits_quest_progress_received() -> void:
# Arrange
_progress_capture = {}
var transport := MockHttpTransport.new()
transport.body_json = _not_started_progress_json()
var noop := NoopHttpTransport.new()
var c := _make_client(transport, noop)
c.connect("quest_progress_received", Callable(self, "_capture_progress"))
monitor_signals(c)
# Act
c.call("request_sync_from_server")
# Assert
await assert_signal(c).is_emitted("quest_progress_received", any())
assert_that(_progress_capture.get("playerId", "")).is_equal("dev-local-1")
assert_that(transport.last_url).contains("/quest-progress")
func test_http_404_emits_quest_sync_failed() -> void:
# Arrange
var transport := MockHttpTransport.new()
transport.response_code = 404
var noop := NoopHttpTransport.new()
var c := _make_client(transport, noop)
monitor_signals(c)
# Act
c.call("request_sync_from_server")
# Assert
await assert_signal(c).is_emitted("quest_sync_failed", "HTTP 404 (player unknown)")
func test_parse_quest_progress_json_returns_null_for_schema_mismatch() -> void:
# Arrange
var json := '{"schemaVersion":2,"playerId":"dev-local-1","quests":[]}'
# Act
var snapshot: Variant = QuestProgressClient.parse_quest_progress_json(json)
# Assert
assert_that(snapshot).is_null()
func test_request_accept_emits_quest_accept_result_received() -> void:
# Arrange
_accept_capture = {}
var transport := MockHttpTransport.new()
transport.body_json = _accept_success_json()
var noop := NoopHttpTransport.new()
var c := _make_client(noop, transport)
c.connect("quest_accept_result_received", Callable(self, "_capture_accept"))
# Act
var started: bool = bool(c.call("request_accept", PROTOTYPE_QUEST_GATHER_ID))
# Assert
assert_that(started).is_true()
assert_that(_accept_capture.get("questId", "")).is_equal(PROTOTYPE_QUEST_GATHER_ID)
var accept_result: Dictionary = _accept_capture.get("result", {}) as Dictionary
assert_that(bool(accept_result.get("accepted", false))).is_true()
assert_that(transport.last_url).contains("/accept")
assert_that(transport.last_method).is_equal(HTTPClient.METHOD_POST)
func test_request_accept_emits_deny_with_reason_code() -> void:
# Arrange
_accept_capture = {}
var transport := MockHttpTransport.new()
transport.body_json = _accept_deny_json()
var noop := NoopHttpTransport.new()
var c := _make_client(noop, transport)
c.connect("quest_accept_result_received", Callable(self, "_capture_accept"))
# Act
c.call("request_accept", "prototype_quest_refine_intro")
# Assert
var result: Dictionary = _accept_capture.get("result", {}) as Dictionary
assert_that(bool(result.get("accepted", true))).is_false()
assert_that(str(result.get("reasonCode", ""))).is_equal("prerequisite_incomplete")
func test_http_404_on_accept_emits_quest_accept_failed() -> void:
# Arrange
_accept_failed_capture = {}
var transport := MockHttpTransport.new()
transport.response_code = 404
var noop := NoopHttpTransport.new()
var c := _make_client(noop, transport)
c.connect("quest_accept_failed", Callable(self, "_capture_accept_failed"))
# Act
c.call("request_accept", PROTOTYPE_QUEST_GATHER_ID)
# Assert
assert_that(_accept_failed_capture.get("questId", "")).is_equal(PROTOTYPE_QUEST_GATHER_ID)
assert_that(str(_accept_failed_capture.get("reason", ""))).is_equal("HTTP 404 (player unknown)")
func test_request_accept_returns_false_when_accept_busy() -> void:
# Arrange
var pending := PendingAcceptHttpTransport.new()
pending.body_json = _accept_success_json()
var noop := NoopHttpTransport.new()
var c := _make_client(noop, pending)
# Act
var first: bool = bool(c.call("request_accept", PROTOTYPE_QUEST_GATHER_ID))
var second: bool = bool(c.call("request_accept", "prototype_quest_refine_intro"))
# Assert
assert_that(first).is_true()
assert_that(second).is_false()
pending.complete_pending()
func test_merge_quest_row_into_snapshot_replaces_existing_row() -> void:
# Arrange
var snapshot: Dictionary = {
"schemaVersion": 1,
"playerId": "dev-local-1",
"quests":
[
{
"questId": PROTOTYPE_QUEST_GATHER_ID,
"status": "not_started",
"currentStepIndex": 0,
"objectiveCounters": {},
}
],
}
var active_row: Dictionary = {
"questId": PROTOTYPE_QUEST_GATHER_ID,
"status": "active",
"currentStepIndex": 0,
"objectiveCounters": {},
}
# Act
var merged: Dictionary = QuestProgressClient.merge_quest_row_into_snapshot(snapshot, active_row)
# Assert
var c: Node = QuestProgressClient.new()
auto_free(c)
var row: Dictionary = c.call("quest_row", PROTOTYPE_QUEST_GATHER_ID, merged)
assert_that(str(row.get("status", ""))).is_equal("active")
func test_request_sync_queues_while_busy_and_flushes_after_completion() -> void:
# Arrange
var transport := PendingSyncHttpTransport.new()
transport.body_json = _not_started_progress_json()
var noop := NoopHttpTransport.new()
var c := _make_client(transport, noop)
# Act
c.call("request_sync_from_server")
c.call("request_sync_from_server")
# Assert
assert_that(transport.request_count).is_equal(1)
transport.complete_pending()
assert_that(transport.request_count).is_equal(2)

View File

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

View File

@ -7,7 +7,7 @@
| **Module ID** | E7.M1 |
| **Epic** | [Epic 7 — Quest / Faction](../epics/epic_07_quest_faction.md) |
| **Stage target** | Prototype |
| **Status** | Planned — Slice 1 backlog [E7M1-prototype-backlog.md](../../plans/E7M1-prototype-backlog.md): **E7M1-01** [NEO-112](https://linear.app/neon-sprawl/issue/NEO-112) catalog **landed** (schemas + CI); **E7M1-02** [NEO-113](https://linear.app/neon-sprawl/issue/NEO-113) server load **landed**; **E7M1-03** [NEO-114](https://linear.app/neon-sprawl/issue/NEO-114) registry **landed**; **E7M1-04** [NEO-115](https://linear.app/neon-sprawl/issue/NEO-115) HTTP read **landed**; **E7M1-05** [NEO-116](https://linear.app/neon-sprawl/issue/NEO-116) player quest state store **landed**; **E7M1-06** [NEO-117](https://linear.app/neon-sprawl/issue/NEO-117) **`QuestStateOperations`** **landed**; **E7M1-07** [NEO-118](https://linear.app/neon-sprawl/issue/NEO-118) **`QuestObjectiveWiring`** **landed**; **E7M1-08** [NEO-119](https://linear.app/neon-sprawl/issue/NEO-119) per-player GET **landed**; **E7M1-09** [NEO-120](https://linear.app/neon-sprawl/issue/NEO-120) accept POST **landed**; **E7M1-10** [NEO-121](https://linear.app/neon-sprawl/issue/NEO-121) telemetry hooks **landed**; client from **E7M1-11** [NEO-122](https://linear.app/neon-sprawl/issue/NEO-122) capstone **E7M1-12** [NEO-123](https://linear.app/neon-sprawl/issue/NEO-123) |
| **Status** | Planned — Slice 1 backlog [E7M1-prototype-backlog.md](../../plans/E7M1-prototype-backlog.md): **E7M1-01** [NEO-112](https://linear.app/neon-sprawl/issue/NEO-112) catalog **landed** (schemas + CI); **E7M1-02** [NEO-113](https://linear.app/neon-sprawl/issue/NEO-113) server load **landed**; **E7M1-03** [NEO-114](https://linear.app/neon-sprawl/issue/NEO-114) registry **landed**; **E7M1-04** [NEO-115](https://linear.app/neon-sprawl/issue/NEO-115) HTTP read **landed**; **E7M1-05** [NEO-116](https://linear.app/neon-sprawl/issue/NEO-116) player quest state store **landed**; **E7M1-06** [NEO-117](https://linear.app/neon-sprawl/issue/NEO-117) **`QuestStateOperations`** **landed**; **E7M1-07** [NEO-118](https://linear.app/neon-sprawl/issue/NEO-118) **`QuestObjectiveWiring`** **landed**; **E7M1-08** [NEO-119](https://linear.app/neon-sprawl/issue/NEO-119) per-player GET **landed**; **E7M1-09** [NEO-120](https://linear.app/neon-sprawl/issue/NEO-120) accept POST **landed**; **E7M1-10** [NEO-121](https://linear.app/neon-sprawl/issue/NEO-121) telemetry hooks **landed**; **E7M1-11** [NEO-122](https://linear.app/neon-sprawl/issue/NEO-122) client quest HUD **landed**; capstone **E7M1-12** [NEO-123](https://linear.app/neon-sprawl/issue/NEO-123) |
| **Linear** | Label **`E7.M1`** · [E7M1-prototype-backlog.md](../../plans/E7M1-prototype-backlog.md) |
## Purpose
@ -94,6 +94,8 @@ The **first shipped quest spine** is **frozen** for prototype tuning until a del
**Quest telemetry hooks (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).
**Client quest HUD (NEO-122):** **`quest_progress_client.gd`**, **`quest_definitions_client.gd`**, **`QuestProgressLabel`** / **`QuestAcceptFeedbackLabel`**; boot + gather/craft/defeat/accept refresh; **Q** / **Shift+Q** accept bindings ([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).
**Reward policy (Slice 1):** Quest completion updates **`QuestStepState` only** — no duplicate item/XP grants. Gather/craft/encounter paths keep existing payouts; [E7.M2](E7_M2_RewardAndUnlockRouter.md) adds **`QuestRewardBundle`** apply in Slice 2.
## Risks and telemetry

File diff suppressed because one or more lines are too long

View File

@ -37,7 +37,7 @@ Player casts **`prototype_pulse`** (**25** damage, ~**3 s** cooldown).
## Checklist
1. Start server: `cd server/NeonSprawl.Server && dotnet run`.
2. Run Godot main scene (**F5**). Confirm boot lines: **`Encounter: … not started (0/3)`** and **`Loot: —`** on **`EncounterProgressLabel`** / **`EncounterCompleteLabel`** (combat HUD block, below **`PlayerCombatHpLabel`**).
2. Run Godot main scene (**F5**). Confirm boot lines: **`Encounter: … not started (0/3)`** and **`Loot: —`** on **`EncounterProgressLabel`** / **`EncounterCompleteLabel`** (in **`HudRootScroll`**, below **`PlayerCombatHpLabel`**). Scroll **`HudRootScroll`** if the debug stack is taller than the viewport.
3. Defeat **`prototype_npc_melee`**: walk to orange marker, **Tab** lock, press **1** until **`Cast: … — defeated!`**. Verify **`EncounterProgressLabel`** shows **`1/3`** (may lag one GET behind cast — wait ~1 s or defeat next NPC).
4. Defeat **`prototype_npc_ranged`**: lock at SE marker, cast until defeated. Verify **`2/3`**.
5. Defeat **`prototype_npc_elite`**: lock at origin, cast until defeated (**8** pulses). Verify **`completed (3/3)`** and **`EncounterCompleteLabel`** lists **`scrap_metal_bulk ×10`** and **`contract_handoff_token ×1`** (or raw ids if defs still loading).

View File

@ -0,0 +1,55 @@
# NEO-122 — Manual QA checklist
| Field | Value |
|-------|-------|
| Key | NEO-122 |
| Title | E7M1-11: Client quest progress + accept HUD (Godot) |
| Linear | https://linear.app/neon-sprawl/issue/NEO-122/e7m1-11-client-quest-progress-accept-hud-godot |
| Plan | `docs/plans/NEO-122-implementation-plan.md` |
| Branch | `NEO-122-client-quest-progress-accept-hud` |
## Preconditions
- **Fresh dev player:** stop any running server, then start a new instance so in-memory quest progress resets.
- **No Bruno/curl** for this checklist — accept and progress use Godot **Q** / **Shift+Q** and gameplay actions only.
- NEO-119 quest-progress GET, NEO-120 accept POST, and NEO-115 quest-definitions GET landed on `main`.
## Expected quest HUD progression
| After | `QuestProgressLabel` | `QuestAcceptFeedbackLabel` |
|-------|----------------------|----------------------------|
| Boot | All four quests **`not started`** (display names when defs loaded) | **`Quest accept: — (Q gather / Shift+Q next)`** |
| **Q** accept gather | **`Intro: Salvage Run: active step 1`** | **`Quest accept: Intro: Salvage Run accepted`** |
| Gather scrap (**R** on resource node ×3) | Gather objective counter increases (e.g. **`gather_intro_obj_scrap=3`**) | unchanged |
| Gather quest completes | **`Intro: Salvage Run: completed`** | unchanged |
| **Shift+Q** accept refine (after gather complete) | **`Intro: Refine Stock: active step 1`** | **`Quest accept: Intro: Refine Stock accepted`** |
| Craft **`refine_scrap_standard`** (Economy HUD) | Refine quest completes when recipe succeeds | unchanged |
| Duplicate **Q** on gather | Gather row stays **`completed`** | **`Quest accept: denied — already_completed`** (or **`already_active`**) |
## Checklist
1. Start server: `cd server/NeonSprawl.Server && dotnet run`.
2. Run Godot main scene (**F5**). Confirm **`QuestProgressLabel`** shows four **`not started`** lines and **`QuestAcceptFeedbackLabel`** shows the idle hint (in **`HudRootScroll`**, below encounter loot labels). Economy panel pinned bottom-left (not off-screen).
3. Press **Q**. Verify gather intro becomes **`active step 1`** and accept feedback shows success.
4. Gather scrap until gather quest completes (resource node **R**). Verify counter updates on **`QuestProgressLabel`** after each successful gather (may lag one GET).
5. Press **Shift+Q** before gather is complete — verify **`Quest accept: denied — prerequisite_incomplete`** for refine (or **`no eligible quest`** if refine is not yet eligible).
6. After gather completes, press **Shift+Q**. Verify refine quest becomes **`active`**.
7. Open **Economy HUD** recipe panel — **Refine Scrap (Standard)** is the **first** row (scroll starts at top). Craft when **≥5** scrap in bag.
8. Craft **`refine_scrap_standard`** with Economy HUD open. Verify refine quest **`completed`** after successful craft.
9. Press **Q** again on already-completed gather. Verify deny feedback on **`QuestAcceptFeedbackLabel`** without Bruno.
10. Optional: stop server while Godot running, press **Q** — verify **`Quests: error — …`** or **`Quest accept: failed — …`** visible on HUD.
11. Optional: block **`GET /game/world/quest-definitions`** only while progress GET succeeds — verify **`definitions error — …`** under **`QuestProgressLabel`** with raw quest ids as fallback.
12. Toggle **Economy HUD** off/on — debug labels in **`HudRootScroll`** remain; recipe repopulate + scroll reset still works.
## Notes
- Combat intro (**`prototype_quest_combat_intro`**) has no prerequisites — **Shift+Q** may offer it before refine if gather is still active; server deny is authoritative when prerequisites block accept.
- Full four-quest chain capstone: [NEO-123 manual QA](NEO-123.md).
- Encounter progress HUD regression: [NEO-110 manual QA](NEO-110.md).
## Acceptance
- [ ] Steps 19 completable in one session without Bruno/curl.
- [ ] Quest status visible after **Q** accept and updates after gather/craft without manual refresh key.
- [ ] Failed GET/accept shows visible HUD error copy.
- [ ] Economy HUD craft panel visible for step 78 (recipes first row, no overlap with quest labels).

View File

@ -16,8 +16,8 @@
1. Start server: `cd server/NeonSprawl.Server && dotnet run`.
2. Run Godot main scene (**F5**). Confirm **`SkillProgressionLabel`** shows **`salvage`** and **`refine`** rows after boot.
3. Scroll the recipe panel below skills**eight** prototype recipes with **Craft** buttons.
4. On a fresh player (empty bag), click **Craft** on **`refine_scrap_standard`**. **`CraftFeedbackLabel`** shows **`Craft: denied — insufficient_materials`**. Press **I** — inventory unchanged.
3. Scroll the recipe panel in **Economy HUD** (`BodyScroll/Body/CraftRecipePanel`)**eight** prototype recipes with **Craft** buttons. **Refine Scrap (Standard)** is the first row (NEO-122 layout).
4. On a fresh player (empty bag), click **Craft** on **`refine_scrap_standard`**. **`CraftFeedbackLabel`** (in **`HudRootScroll`**) shows **`Craft: denied — insufficient_materials`**. Press **I** — inventory unchanged.
5. Gather **≥5** **`scrap_metal_bulk`** (see [NEO-73 manual QA](NEO-73.md) **R** at resource nodes).
6. Click **Craft** on **`refine_scrap_standard`**. **`CraftFeedbackLabel`** shows **`Craft: +1 …`** (refined plate output). **`InventoryLabel`** updates without **I**. **`refine`** xp increases on **`SkillProgressionLabel`**.
7. Gather **≥1** more scrap if needed; ensure **≥2** **`refined_plate_stock`** and **≥1** **`scrap_metal_bulk`** in bag (craft **`refine_scrap_standard`** again or gather).
@ -28,3 +28,4 @@
- Craft quantity is fixed at **1** per button (no spinner in this story).
- Success copy comes from POST **`outputsGranted`**; inventory and skill rows refresh via GET after success only.
- **NEO-122 layout:** debug feedback labels live in **`HudRootScroll`**; economy block (recipes, inventory, skills) is pinned bottom-left — scroll economy **`BodyScroll`** for inventory/skills below recipes.

View File

@ -26,13 +26,13 @@
## Checklist
1. Start server: `cd server/NeonSprawl.Server && dotnet run`.
2. Run Godot main scene (**F5**). Confirm **`Economy HUD`** toggle is **on**; **`InventoryLabel`** shows empty bag; **`SkillProgressionLabel`** shows **`salvage`** and **`refine`** rows.
2. Run Godot main scene (**F5**). Confirm **`Economy HUD`** toggle is **on**; **`InventoryLabel`** shows empty bag; **`SkillProgressionLabel`** shows **`salvage`** and **`refine`** rows. Scroll **`BodyScroll`** if inventory/skills are below the recipe list (NEO-122 recipes-first order).
3. **WASD** to **`prototype_resource_node_alpha`** anchor **(12, 6)**. Press **R** once. **`GatherFeedbackLabel`** shows **`+1`** scrap delta; inventory scrap updates without **I**; **`salvage`** xp increases.
4. Walk to **`prototype_subsurface_vein_beta`** **(18, 6)**; press **R** (**+2** scrap). Repeat at **`prototype_bio_mat_gamma`** **(12, 0)** (**+3**) and **`prototype_urban_bulk_delta`** **(18, 0)** (**+5**). Inventory shows **11** scrap total.
5. In the recipe panel, click **Craft** on **`refine_scrap_standard`**. **`CraftFeedbackLabel`** shows success; inventory shows **6** scrap and **1** **`refined_plate_stock`**; **`refine`** xp increases.
6. Click **Craft** on **`refine_scrap_standard`** again. Inventory shows **1** scrap and **2** plates.
7. Click **Craft** on **`make_field_stim_mk0`**. Success feedback shows **`field_stim_mk0`** output; **`InventoryLabel`** lists **`Field Stim Mk0`** (or agreed display name) with quantity **≥ 1**; **`refine`** xp increases again.
8. Uncheck **`Economy HUD`** — inventory, skills, and recipe panel hide; gather/craft feedback labels above remain visible. Check **`Economy HUD`** again — panel repopulates with current data.
8. Uncheck **`Economy HUD`** — inventory, skills, and recipe panel hide; gather/craft feedback labels in **`HudRootScroll`** remain visible. Check **`Economy HUD`** again — panel repopulates with current data.
## Notes

View File

@ -344,11 +344,11 @@ Working backlog for **Epic 7 — Slice 1** ([quest core and persistence](../deco
**Acceptance criteria**
- [ ] Player sees quest status change after accept without Bruno.
- [ ] Objective progress updates after gather/craft/encounter in Godot session.
- [ ] Failed GET/accept surfaces visible HUD error (match interaction client pattern).
- [x] Player sees quest status change after accept without Bruno.
- [x] Objective progress updates after gather/craft/encounter in Godot session.
- [x] Failed GET/accept surfaces visible HUD error (match interaction client pattern).
**Client counterpart:** this story **is** the client counterpart for E7M1-08/09.
**Landed (NEO-122):** **`quest_progress_client.gd`**, **`quest_definitions_client.gd`**, **`QuestProgressLabel`** / **`QuestAcceptFeedbackLabel`**; **Q** / **Shift+Q** accept; boot + event-driven GET refresh; GdUnit + [`docs/manual-qa/NEO-122.md`](../manual-qa/NEO-122.md); plan [NEO-122](../plans/NEO-122-implementation-plan.md); [client README — Quest progress + accept HUD (NEO-122)](../../client/README.md#quest-progress--accept-hud-neo-122).
---

View File

@ -0,0 +1,210 @@
# NEO-122 — Implementation plan
## Story reference
| Field | Value |
|--------|--------|
| **Key** | NEO-122 |
| **Title** | E7M1-11: Client quest progress + accept HUD (Godot) |
| **Linear** | https://linear.app/neon-sprawl/issue/NEO-122/e7m1-11-client-quest-progress-accept-hud-godot |
| **Module** | [E7.M1 — QuestStateMachine](../decomposition/modules/E7_M1_QuestStateMachine.md) · Epic 7 Slice 1 · backlog **E7M1-11** |
| **Branch** | `NEO-122-client-quest-progress-accept-hud` |
| **Server deps** | [NEO-119](https://linear.app/neon-sprawl/issue/NEO-119) — `GET /game/players/{id}/quest-progress` (**landed on `main`**); [NEO-120](https://linear.app/neon-sprawl/issue/NEO-120) — `POST …/quests/{questId}/accept` (**landed on `main`**); [NEO-115](https://linear.app/neon-sprawl/issue/NEO-115) — `GET /game/world/quest-definitions` (**landed on `main`**) |
| **Pattern** | [NEO-110](NEO-110-implementation-plan.md) — thin HTTP client, `main.gd` HUD render, event-driven refresh; [NEO-74](NEO-74-implementation-plan.md) — world-definitions client + POST mutation client; [NEO-72](NEO-72-implementation-plan.md) — display-name cache for HUD lines |
| **Blocks** | [NEO-123](https://linear.app/neon-sprawl/issue/NEO-123) — playable onboarding quest chain capstone (Godot manual QA) |
| **Server counterpart** | [NEO-119](https://linear.app/neon-sprawl/issue/NEO-119) + [NEO-120](https://linear.app/neon-sprawl/issue/NEO-120) — authoritative GET/POST; Bruno is **not** prototype-complete per [full-stack epic decomposition](../../.cursor/rules/full-stack-epic-decomposition.md) |
## Kickoff clarifications
| Topic | Question | Agent recommendation | Answer |
|--------|----------|----------------------|--------|
| **Quest definitions client** | Include optional `quest_definitions_client.gd`? | **Include** — NEO-115 GET landed; `recipe_definitions_client.gd` precedent; readable four-quest HUD. | **Adopted** — include |
| **Accept key bindings** | **Q** / **Shift+Q** semantics? | **Q** = explicit `prototype_quest_gather_intro`; **Shift+Q** = first catalog-order `not_started` quest with all prerequisites `completed` in current snapshot. | **Adopted** |
| **HUD quest list scope** | How many quests on **`QuestProgressLabel`**? | **All four** catalog quests, compact status per row (matches GET roster; supports chain visibility for NEO-123). | **Adopted** |
**Additional defaults (no kickoff question — settled by backlog / precedent):**
- **Refresh strategy:** boot hydrate + refresh after gather (interaction success → inventory finalize path), craft success, cast with **`targetDefeated`**, and successful accept POST — mirrors NEO-110 encounter + NEO-73 skill refresh; no periodic poll.
- **Error surfacing:** sync/accept failures paint visible HUD copy (`error — {reason}`) matching interaction/gather feedback pattern.
- **Accept POST body:** optional v1 `{ "schemaVersion": 1 }` (NEO-120); structured deny is HTTP **200** with **`accepted: false`**.
- **HUD placement:** **`UICanvas/HudRoot`** after **`EncounterCompleteLabel`**, before **`NpcStateLabel`** — quest spans gather/craft/combat loop (NEO-110 combat-area precedent extended).
- **Server changes:** none — client-only story.
## Goal, scope, and out-of-scope
**Goal:** Godot shows **server-owned** quest progress and accept feedback using **`GET …/quest-progress`** and **`POST …/quests/{questId}/accept`** — player-visible status after accept and after gather/craft/encounter objective events without Bruno.
**In scope (from Linear + [E7M1-11](E7M1-prototype-backlog.md#e7m1-11--client-quest-progress-accept-hud-godot)):**
- **`quest_progress_client.gd`:** GET snapshot parse v1; **`quest_row(quest_id, snapshot)`** helper; POST accept with optional v1 body; signals **`quest_progress_received`** / **`quest_sync_failed`** / **`quest_accept_result_received`** / **`quest_accept_failed`**; **`request_sync_from_server()`**, **`request_accept(quest_id)`**; **`push_warning`** on failures.
- **`quest_definitions_client.gd`:** GET **`/game/world/quest-definitions`**; cache ordered quest rows; **`display_name_for(quest_id)`**; signals **`definitions_ready`** / **`definitions_sync_failed`**.
- **`QuestProgressLabel`** — all four quests, compact status/step/counter summary.
- **`QuestAcceptFeedbackLabel`** — accept success/deny/failure copy.
- **`main.gd`:** boot hydrate (defs + progress); event-driven GET refresh; **Q** / **Shift+Q** accept bindings in **`_unhandled_key_input`**; eligible-quest picker for Shift+Q (catalog order + prerequisite check against snapshot).
- GdUnit tests with HTTP doubles ([testing-expectations.md](../../.cursor/rules/testing-expectations.md)).
- **`docs/manual-qa/NEO-122.md`** — Godot steps (server + client; no Bruno).
- **`client/README.md`** quest HUD subsection.
**Out of scope (from Linear + backlog):**
- Quest journal art, map pins, NPC dialog; final quest picker UI.
- Abandon/reset POST.
- Server route/DTO changes.
- Full four-quest capstone script (**[NEO-123](https://linear.app/neon-sprawl/issue/NEO-123)**).
- Periodic quest poll timer.
## Acceptance criteria checklist
- [x] Player sees quest status change after accept without Bruno.
- [x] Objective progress updates after gather/craft/encounter in Godot session.
- [x] Failed GET/accept surfaces visible HUD error (match interaction client pattern).
- [x] `docs/manual-qa/NEO-122.md` Godot steps exercisable (human QA passed 2026-06-07).
## Implementation reconciliation (shipped)
- **`quest_progress_client.gd`:** GET parse v1 + POST accept; separate sync/accept HTTP; **`quest_row`**; signals.
- **`quest_definitions_client.gd`:** GET world quest-definitions; **`display_name_for`** + **`quests_snapshot()`**.
- **`main.gd`:** boot hydrate; Q / Shift+Q accept; refresh after gather/craft/**`targetDefeated`**/**encounter `completed`**/accept; HUD render.
- **Scene:** **`QuestProgressClient`**, **`QuestDefinitionsClient`**, **`QuestProgressLabel`**, **`QuestAcceptFeedbackLabel`** after encounter labels.
- **Tests:** `client/test/quest_progress_client_test.gd` (12 cases across both suites), `client/test/quest_definitions_client_test.gd`.
- **Docs:** `client/README.md` quest HUD section; `docs/manual-qa/NEO-122.md`.
### NEO-122 follow-up (economy HUD layout — manual QA step 7)
During NEO-122 QA, **`refine_scrap_standard`** was unreachable when the economy block sat below the viewport inside the tall **`HudRoot`** stack. Follow-up commits (postfirst review) refactored prototype HUD layout **without changing quest contracts**:
- **`HudRootScroll`** — **`ScrollContainer`** wrapping debug/combat/quest labels; height clamped above the economy panel.
- **`EconomyHudSection`** — reparented to **`UICanvas`**; pinned bottom-left via **`prototype_economy_hud_section.gd`** **`_apply_viewport_layout`** (explicit **`position`/`size`**, opaque panel fill, **`z_index`**).
- **`BodyScroll/Body`** — single economy scroll; child order **Recipes → Inventory → Skills → Gig XP** so craft rows appear first.
- **`craft_recipe_panel.gd`** — flattened to **`VBoxContainer`** + **`RecipeRows`** (no nested scroll); populate resets economy body scroll to recipes via parent **`scroll_body_to_recipes()`**.
- **Tests:** `client/test/craft_recipe_panel_test.gd`; viewport layout tests in **`prototype_economy_hud_section_test.gd`**.
- **Regression:** spot-check [NEO-74](../manual-qa/NEO-74.md), [NEO-75](../manual-qa/NEO-75.md), [NEO-110](../manual-qa/NEO-110.md) after merge (collapsed economy toggle, feedback labels in **`HudRootScroll`**, encounter labels scroll when stack is tall).
## Technical approach
### Server contract (landed — NEO-119 / NEO-120 / NEO-115)
- **`GET /game/players/{id}/quest-progress`** → **`schemaVersion` 1**, **`playerId`**, **`quests[]`** with **`questId`**, **`status`** (`not_started` | `active` | `completed`), **`currentStepIndex`**, **`objectiveCounters`**, optional **`completedAt`**.
- **`POST /game/players/{id}/quests/{questId}/accept`** → **`schemaVersion` 1**, **`accepted`**, optional **`reasonCode`**, optional **`quest`** row (same shape as GET entry). HTTP **200** on structured deny.
- **`GET /game/world/quest-definitions`** → **`schemaVersion` 1**, **`quests[]`** with **`id`**, **`displayName`**, **`prerequisiteQuestIds`**, nested **`steps`**.
### Client constants (`main.gd`)
```gdscript
const PROTOTYPE_QUEST_GATHER_ID := "prototype_quest_gather_intro"
```
### 1. `quest_progress_client.gd`
- Mirror **`encounter_progress_client.gd`** for GET: injectable HTTP, **`_sync_busy`** guard, 404 handling.
- Add POST accept on same node with separate **`_accept_busy`** guard (craft-client precedent — do not block GET refresh during accept round-trip).
- **`parse_quest_progress_json(text)`** static: require **`schemaVersion` 1**, **`quests`** array, non-empty **`playerId`**.
- **`parse_quest_accept_json(text)`** static: require **`schemaVersion` 1**, **`accepted`** bool present.
- **`quest_row(quest_id, snapshot)`** → row dict or `{}`.
- **`request_accept(quest_id)`** → POST with `{ "schemaVersion": 1 }`; returns **`true`** when queued.
### 2. `quest_definitions_client.gd`
- Mirror **`recipe_definitions_client.gd`**: GET **`/game/world/quest-definitions`**, cache **`quests`** array in catalog order.
- **`display_name_for(quest_id)`** — fallback raw id.
- **`quests_snapshot()`** → cached array copy for eligible-quest iteration in **`main.gd`**.
### 3. Boot + refresh wiring (`main.gd`)
- **`_setup_quest_progress_sync()`** from **`_ready()`** after authority HTTP config.
- Nodes in **`main.tscn`**: **`QuestProgressClient`**, **`QuestDefinitionsClient`**; **`QuestProgressLabel`**, **`QuestAcceptFeedbackLabel`** in **`HudRoot`** after **`EncounterCompleteLabel`**.
- Boot: **`request_sync_from_server()`** on both clients (parallel with encounter/gig boot).
- **`_request_quest_progress_refresh()`** helper; call from:
- **`_on_interaction_result_for_gather`** when interaction allowed (alongside skill progression refresh).
- **`_on_craft_result_received`** when **`success`**.
- **`_on_cast_result_received`** when **`targetDefeated`** (alongside encounter/gig refresh).
- **`_on_encounter_progress_received`** when prototype pocket row **`state == "completed"`** (alongside inventory refresh — belt-and-suspenders when defeat-triggered quest GET was skipped or raced).
- **`_on_quest_accept_result_received`** after accept (always re-sync full snapshot even when response includes **`quest`** row).
- Re-render quest labels when **`definitions_ready`** if progress snapshot already loaded (NEO-110 item-defs re-paint precedent).
### 4. Accept bindings (`main.gd`)
| Key | Action |
|-----|--------|
| **Q** | **`request_accept(PROTOTYPE_QUEST_GATHER_ID)`** |
| **Shift+Q** | First quest in **`quest_definitions_client.quests_snapshot()`** catalog order where progress row **`status == "not_started"`** and every **`prerequisiteQuestIds`** entry has **`status == "completed"`** in **`_last_quest_progress_snapshot`**; if none eligible, paint feedback **`Quest accept: no eligible quest`**. |
Debounce: skip if **`quest_progress_client`** accept busy (same as craft busy guard).
### 5. HUD rendering
**`QuestProgressLabel`** (header **`Quests:`**):
| State | Copy (per quest, catalog order) |
|-------|----------------------------------|
| sync error | **`Quests: error — {reason}`** |
| loading | **`Quests: Loading…`** |
| **`not_started`** | **`{displayName}: not started`** |
| **`active`** | **`{displayName}: active step {index+1} {counter summary}`** — e.g. objective counter values joined |
| **`completed`** | **`{displayName}: completed`** (+ optional **`completedAt`** suffix) |
**`QuestAcceptFeedbackLabel`** (header **`Quest accept:`**):
| Outcome | Copy |
|---------|------|
| success | **`Quest accept: {displayName} accepted`** |
| deny | **`Quest accept: denied — {reasonCode}`** |
| HTTP/transport fail | **`Quest accept: failed — {reason}`** |
| idle | **`Quest accept: — (Q gather / Shift+Q next)`** |
### 6. Scene
- **`main.tscn`:** add client nodes under root; add two labels in **`HudRoot`** VBox **after** **`EncounterCompleteLabel`**, **before** **`NpcStateLabel`**.
### 7. Tests (GdUnit, AAA)
- Progress client: parse active/completed rows; **`quest_row`** lookup; GET 404 → **`quest_sync_failed`**; accept happy **`accepted: true`**; accept deny **`accepted: false`** + **`reasonCode`**.
- Definitions client: parse four-quest JSON; **`display_name_for`** returns **`displayName`**.
### 8. Docs on land
- **`docs/manual-qa/NEO-122.md`**: fresh server; Godot **F5**; **Q** accept gather intro → HUD shows active; gather/craft/defeat updates counters; **Shift+Q** accept refine after gather complete; deny feedback visible.
- Update [E7M1-prototype-backlog.md](E7M1-prototype-backlog.md) E7M1-11 checkboxes, [E7_M1](../decomposition/modules/E7_M1_QuestStateMachine.md) client slice note, [documentation_and_implementation_alignment.md](../decomposition/modules/documentation_and_implementation_alignment.md) when implementation completes.
## Files to add
| Path | Purpose |
|------|---------|
| `docs/plans/NEO-122-implementation-plan.md` | This plan. |
| `client/scripts/quest_progress_client.gd` | GET quest-progress + POST accept; parse v1; **`quest_row`**; signals. |
| `client/scripts/quest_progress_client.gd.uid` | Godot uid companion (tracked). |
| `client/scripts/quest_definitions_client.gd` | GET world quest-definitions; display-name cache. |
| `client/scripts/quest_definitions_client.gd.uid` | Godot uid companion (tracked). |
| `client/test/quest_progress_client_test.gd` | GdUnit: GET parse, accept happy/deny, 404 failure. |
| `client/test/quest_progress_client_test.gd.uid` | GdUnit uid companion. |
| `client/test/quest_definitions_client_test.gd` | GdUnit: parse defs JSON; **`display_name_for`**. |
| `client/test/quest_definitions_client_test.gd.uid` | GdUnit uid companion. |
| `docs/manual-qa/NEO-122.md` | Godot manual QA (accept + progress refresh; no Bruno). |
## Files to modify
| Path | Rationale |
|------|-----------|
| `client/scenes/main.tscn` | Add **`QuestProgressClient`**, **`QuestDefinitionsClient`**, **`QuestProgressLabel`**, **`QuestAcceptFeedbackLabel`**. |
| `client/scripts/main.gd` | Boot/refresh wiring, Q/Shift+Q accept, HUD render, eligible-quest helper. |
| `client/README.md` | Quest progress + accept HUD subsection: server deps NEO-119/120/115, keys, refresh triggers. |
## Tests
| Test file | What it covers |
|-----------|----------------|
| `client/test/quest_progress_client_test.gd` | Mock GET 200: **`not_started`** / **`active`** with **`objectiveCounters`** / **`completed`** rows; **`quest_row`**; GET 404 → **`quest_sync_failed`**; POST accept **`accepted: true`** and **`accepted: false`** + **`reasonCode`**. AAA layout (`# Arrange` / `# Act` / `# Assert`). |
| `client/test/quest_definitions_client_test.gd` | Parse v1 defs JSON (four quests); **`display_name_for`** returns catalog **`displayName`**; unknown id fallback. AAA layout. |
| `client/test/craft_recipe_panel_test.gd` | Recipe row populate; economy body scroll reset on repopulate; **`craft_requested`** signal. AAA layout. |
| `client/test/prototype_economy_hud_section_test.gd` | Economy collapse toggle; viewport pin + **`HudRootScroll`** clamp (NEO-122 follow-up). AAA layout. |
No new **C#** tests (client-only; server covered by **`QuestProgressApiTests`** / **`QuestAcceptApiTests`**). No Bruno changes.
## Open questions / risks
| Item | Agent recommendation | Status |
|------|----------------------|--------|
| **Concurrent GET + accept** | Separate **`_sync_busy`** / **`_accept_busy`** guards; overlapping GET drops (encounter precedent); accept not blocked by progress poll. | **adopted** |
| **Quest defs arrive after progress snapshot** | Re-render **`QuestProgressLabel`** on **`definitions_ready`** when snapshot non-empty (NEO-110 item-defs re-paint). | **adopted** |
| **Client-side eligible-quest guess vs server deny** | Shift+Q uses snapshot prerequisite check for UX only; server **`TryAccept`** remains authoritative on deny. | **adopted** |
| **NEO-123 capstone overlap** | NEO-122 proves HUD + accept + single-objective refresh; NEO-123 is full four-quest chain manual QA — keep scopes separate. | **deferred** |

View File

@ -0,0 +1,93 @@
# Code review follow-up — NEO-122 (E7M1-11)
**Date:** 2026-06-07
**Scope:** Branch `NEO-122-client-quest-progress-accept-hud` vs `origin/main` — commits `b36fe6d``632a747` (delta after [initial review](2026-06-07-NEO-122.md))
**Base:** `origin/main`
**Prior review:** `docs/reviews/2026-06-07-NEO-122.md` — prior suggestions **1** / **3** and nits **12** addressed in `b36fe6d`; suggestion **2** (extract quest HUD) still deferred.
**Follow-up:** suggestions **12** below are **done** (strikethrough + **Done.**); suggestion **3** is manual regression spot-check for author/reviewer.
## Verdict
**Approve with nits**
## Summary
Seven commits landed after the first review: code-review follow-up (quest-definitions error HUD + accept-failure GdUnit), then a **prototype economy/debug HUD layout refactor** so NEO-122 manual QA step 7 (craft **`refine_scrap_standard`** with recipes visible) is reliable. Changes include **`HudRootScroll`** wrapping debug labels, **`EconomyHudSection`** pinned to the viewport bottom with **`BodyScroll`**, **`craft_recipe_panel.gd`** flattened (no nested scroll; recipes-first in economy body), and layout clamping in **`prototype_economy_hud_section.gd`**. New/extended GdUnit: **`craft_recipe_panel_test.gd`**, viewport layout tests in **`prototype_economy_hud_section_test.gd`**. Quest client behavior from the first review remains sound; risk is mainly **plan/doc drift** and **cross-story HUD regression** (NEO-74/75/110), not quest contract correctness.
## Documentation checked
| Path | Result |
|------|--------|
| `docs/plans/NEO-122-implementation-plan.md` | ~~**Partially matches**~~ **Matches** — NEO-122 follow-up reconciliation added (economy HUD layout refactor). **Done.** |
| `docs/manual-qa/NEO-122.md` | ~~**Partially matches**~~ **Matches** — duplicate step 9 fixed; acceptance includes craft visibility. **Done.** |
| `client/README.md` | ~~**Partially matches**~~ **Matches** — test-scope includes **`craft_recipe_panel_test.gd`**. **Done.** |
| `docs/manual-qa/NEO-74.md` / `NEO-75.md` / `NEO-110.md` | **Updated** — HudRootScroll / economy-pin regression notes added. **Done.** |
| `docs/decomposition/modules/E7_M1_QuestStateMachine.md` | **Matches** — unchanged since first review. |
| Full-stack epic decomposition | **Matches** — still client slice only; NEO-123 capstone deferred. |
## Blocking issues
(none)
## Suggestions
1. ~~**Reconcile implementation plan** — Append a short **NEO-122 follow-up** bullet to `docs/plans/NEO-122-implementation-plan.md` **Implementation reconciliation**: economy HUD pinned layout, `HudRootScroll`, `craft_recipe_panel` VBox + `RecipeRows`, recipes-first in `BodyScroll/Body`, and manual QA step 7 rationale. Keeps [planning-implementation-docs](../../.cursor/rules/planning-implementation-docs.md) honest about scope expansion.~~ **Done.** Follow-up subsection in plan reconciliation; tests table extended.
2. ~~**Fix manual QA numbering** — In `docs/manual-qa/NEO-122.md`, renumber the second **9.** (server-down optional) to **10.** and the definitions-error optional to **11.**; update **Acceptance** if it should include the craft-visibility step (currently “Steps 18” while required steps now include **9**).~~ **Done.** Steps 112; acceptance covers steps 19 + craft visibility.
3. **Regression spot-check** — Run abbreviated passes of [NEO-74](../manual-qa/NEO-74.md), [NEO-75](../manual-qa/NEO-75.md), and [NEO-110](../manual-qa/NEO-110.md) after merge: collapsed economy toggle, gather/craft feedback still visible in **`HudRootScroll`**, encounter labels still scroll when debug stack is tall.
## Nits
1. Nit: `main.tscn` **`EconomyHudSection`** still has editor offsets (`offset_top = 400`) overwritten at runtime by **`_apply_viewport_layout`** — possible one-frame layout flash on boot; acceptable for prototype.
2. Nit: **`prototype_economy_hud_section.gd`** locates **`HudRootScroll`** by sibling name string — works with current scene tree and is covered by GdUnit; brittle if `UICanvas` children reorder.
3. Nit: Economy **`BodyScroll/Body`** child order is now **Recipes → Inventory → Skills → Gig XP** (was inventory-first). NEO-75 steps do not mandate order, but players accustomed to inventory-at-top may need a scroll on first open.
## Bugbot follow-up (PR #161)
1. ~~**Shift+Q ignores progress sync failure** — After GET fails, `_request_eligible_accept` blocked accept when `_progress_error` is set; GdUnit **`test_shift_q_blocks_when_progress_sync_failed`**. **Done.**
2. ~~**Hud scroll minimum causes panel overlap** — Removed 80px floor in `_apply_hud_root_scroll_bounds`; clamp to `maxf(0.0, available_height)`; layout test asserts scroll height ≤ panel top gap. **Done.**
3. ~~**GdUnit CI exit 101 (orphans)** — Rewrote **`quest_hud_controller_test.gd`** with `MockHttpTransport` / `auto_free()` on all nodes; full suite **247 tests, 0 orphans, exit 0**. **Done.**
4. ~~**Quest HUD stale after accept** — Merge accept-response `quest` row into snapshot immediately; re-apply patch when stale GET returns `not_started`; queue pending sync when GET busy. **Done.**
5. ~~**Accept hidden by progress error** — Clear `_progress_error` on successful accept merge; render snapshot rows with `sync error — …` banner instead of error-only early return. **Done.**
6. ~~**Sync fail drops accept merge** — `_on_sync_failed` no longer clears `_accept_quest_patch` or merged snapshot; rebuilds from patch when snapshot empty. **Done.**
7. ~~**Shift+Q hides definitions failure** — Block eligible accept when `_defs_error` is set and catalog is empty. **Done.**
8. ~~**Definitions ready skips HUD repaint** — `_on_definitions_ready` repaints when snapshot exists even if `_progress_error` is set so display names replace raw ids. **Done.**
## Verification
```bash
cd client
godot --headless --path . -s addons/gdUnit4/bin/GdUnitCmdTool.gd \
--ignoreHeadlessMode \
-a res://test
```
Full suite: **255** cases, **0** orphans (GdUnit exit **0**).
```bash
# Focused NEO-122 / layout subset
godot --headless --path . -s addons/gdUnit4/bin/GdUnitCmdTool.gd \
--ignoreHeadlessMode \
-a test/quest_progress_client_test.gd \
test/quest_definitions_client_test.gd \
test/quest_hud_controller_test.gd \
test/craft_recipe_panel_test.gd \
test/prototype_economy_hud_section_test.gd
```
Manual (per updated `docs/manual-qa/NEO-122.md`):
1. **F5** with fresh server — quest labels visible in **`HudRootScroll`**; economy panel pinned bottom-left, not off-screen.
2. Steps 19 (craft step 78: **Refine Scrap (Standard)** first row, scroll at top).
3. Optional error paths (steps 1011).
4. Toggle **Economy HUD** off/on — debug labels in **`HudRootScroll`** remain; recipe repopulate + scroll reset still works.

View File

@ -0,0 +1,78 @@
# Code review — NEO-122 (E7M1-11)
**Date:** 2026-06-07
**Scope:** Branch `NEO-122-client-quest-progress-accept-hud` vs `origin/main` — commits `150cb73``e37671d`
**Base:** `origin/main`
## Verdict
**Approve with nits** — suggestions **1** and **3** and nits **12** below are **done** (strikethrough + **Done.**); suggestion **2** deferred to NEO-123.
## Summary
NEO-122 delivers the Godot client slice for Epic 7 quest visibility: **`quest_progress_client.gd`** (GET progress + POST accept with separate sync/accept HTTP and busy guards), **`quest_definitions_client.gd`** (cached display names + catalog-order iteration), HUD labels **`QuestProgressLabel`** / **`QuestAcceptFeedbackLabel`**, and **`main.gd`** wiring for boot hydrate, **Q** / **Shift+Q** accept, and event-driven GET refresh after gather, craft success, **`targetDefeated`**, and accept. Implementation mirrors **`encounter_progress_client.gd`** / **`recipe_definitions_client.gd`** precedents and respects server authority (client displays GET snapshots only; Shift+Q prerequisite check is UX-only). Twelve GdUnit cases cover parse, sync, 404, accept happy/deny, and definitions display names with full AAA layout. Documentation is thorough: implementation plan reconciliation, backlog checkboxes, E7.M1 module snapshot, alignment register, **`client/README.md`**, and **`docs/manual-qa/NEO-122.md`**. Risk is low; human manual QA remains the main pre-merge gate.
## Documentation checked
| Path | Result |
|------|--------|
| `docs/plans/NEO-122-implementation-plan.md` | **Matches** — kickoff decisions adopted (defs client, Q/Shift+Q semantics, four-quest HUD); reconciliation accurate; manual QA item correctly left open. |
| `docs/plans/E7M1-prototype-backlog.md` (E7M1-11) | **Matches** — acceptance checkboxes checked; landed note + README/manual QA links. |
| `docs/decomposition/modules/E7_M1_QuestStateMachine.md` | **Matches** — NEO-122 client HUD bullet + links. |
| `docs/decomposition/modules/documentation_and_implementation_alignment.md` | **Matches** — E7M1-11 / NEO-122 landed note appended. |
| `docs/decomposition/modules/module_dependency_register.md` | **Matches** — E7.M1 row stays **Planned** until NEO-123 capstone (correct). |
| `docs/decomposition/modules/client_server_authority.md` | **Matches** — client displays server GET snapshot; accept is intent POST; no local objective math. |
| `client/README.md` | **Matches** — quest HUD subsection: routes, keys, refresh triggers, test file list. |
| `docs/manual-qa/NEO-122.md` | **Matches** — Godot-only checklist; Shift+Q combat-intro caveat documented. |
| Full-stack epic decomposition | **Matches** — paired client issue NEO-122; does not claim E7 Slice 1 capstone complete (NEO-123). |
**Register / tracking:** E7.M1 correctly remains **Planned** until client capstone NEO-123; no further register status change required for this merge.
## Blocking issues
(none)
## Suggestions
1. ~~**Surface `definitions_sync_failed` on HUD** — `_quest_defs_error` is set in `_on_quest_definitions_sync_failed` but never read in `_render_quest_progress_label`. Recipe defs follow the visible pattern (`Recipes: error — {reason}` on **`CraftFeedbackLabel`**). Consider painting e.g. `Quests:\nerror — definitions {reason}` (or a suffix on the header) so a defs-only failure is visible; today the HUD silently falls back to raw quest ids from the progress snapshot.~~ **Done.** `_render_quest_progress_label` paints `definitions error — {reason}` when defs sync fails (during loading or alongside progress rows).
2. **Extract quest HUD wiring from `main.gd`** — ~250 lines of quest setup/render/accept logic were added to an already large composer. Per [godot-client-script-organization](../../.cursor/rules/godot-client-script-organization.md), a child **`quest_hud_controller.gd`** (signals in, label text out) would match NEO-110-era precedent deferral but reduce merge conflict surface before NEO-123 capstone work. Non-blocking for this story. **Deferred** to NEO-123 capstone or follow-up refactor.
3. ~~**GdUnit: `quest_accept_failed` + accept-busy guard** — Client tests cover sync 404 and structured deny (`accepted: false`), but not transport/HTTP failure on accept (`quest_accept_failed`) or `request_accept` returning `false` when `_accept_busy`. Small additions would lock the error path NEO-122 manual QA step 9 exercises.~~ **Done.** `test_http_404_on_accept_emits_quest_accept_failed` and `test_request_accept_returns_false_when_accept_busy` in `quest_progress_client_test.gd`.
## Nits
1. ~~Nit: `test_request_accept_emits_deny_with_reason_code` omits `await assert_signal(...)` used elsewhere; safe with the synchronous mock transport today, but inconsistent with `test_request_sync_emits_quest_progress_received`.~~ **Deferred.** Synchronous mock transport emits before `await assert_signal` can observe; deny test keeps sync capture (same pattern as `craft_client_test.gd`).
2. ~~Nit: `_render_quest_progress_labels()` is a one-line wrapper around `_render_quest_progress_label()` — harmless; could inline unless more quest labels land in NEO-123.~~ **Done.** Wrapper removed; callers invoke `_render_quest_progress_label()` directly.
3. Nit: Shift+Q may offer **`prototype_quest_combat_intro`** before refine when gather is still active (documented in manual QA Notes) — server deny is authoritative; acceptable prototype UX.
## Verification
```bash
# GdUnit (requires Godot + gdUnit4 in PATH)
cd client
godot --headless --path . -s addons/gdUnit4/bin/GdUnitCmdTool.gd \
--ignoreHeadlessMode -a test/quest_progress_client_test.gd test/quest_definitions_client_test.gd
# GDScript lint (if .venv-gd installed)
gdlint client/scripts/quest_progress_client.gd client/scripts/quest_definitions_client.gd client/scripts/main.gd
gdformat --check client/scripts/quest_progress_client.gd client/scripts/quest_definitions_client.gd
```
Manual (per `docs/manual-qa/NEO-122.md`):
1. Fresh server + Godot **F5** — four quests **`not started`**; idle accept hint visible.
2. **Q** accept gather → active step 1 + success feedback.
3. Gather ×3 → counter updates; gather **`completed`**.
4. **Shift+Q** before/after gather complete — deny vs refine accept.
5. Craft **`refine_scrap_standard`** → refine **`completed`**.
6. Duplicate **Q** on completed gather → **`denied — already_completed`** (or **`already_active`**).
7. Optional: server down → **`Quests: error — …`** or **`Quest accept: failed — …`**.
Server regression (no server changes, sanity):
```bash
dotnet test NeonSprawl.sln --filter "FullyQualifiedName~Quest"
```