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
commit
316966f447
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
|
@ -0,0 +1 @@
|
|||
uid://bneo122qstdefs01
|
||||
|
|
@ -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)
|
||||
|
|
@ -0,0 +1 @@
|
|||
uid://c8k3m2questhudctrl01
|
||||
|
|
@ -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))
|
||||
|
|
@ -0,0 +1 @@
|
|||
uid://bneo122qstprog01
|
||||
|
|
@ -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")
|
||||
|
|
@ -0,0 +1 @@
|
|||
uid://bneo122craftpanel1
|
||||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
@ -0,0 +1 @@
|
|||
uid://bneo122qstdeft01
|
||||
|
|
@ -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")
|
||||
|
|
@ -0,0 +1 @@
|
|||
uid://c8k3m2questhudtest01
|
||||
|
|
@ -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)
|
||||
|
|
@ -0,0 +1 @@
|
|||
uid://bneo122qsttest01
|
||||
|
|
@ -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
|
|
@ -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).
|
||||
|
|
|
|||
|
|
@ -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 1–9 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 7–8 (recipes first row, no overlap with quest labels).
|
||||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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).
|
||||
|
||||
---
|
||||
|
||||
|
|
|
|||
|
|
@ -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 (post–first 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** |
|
||||
|
|
@ -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 **1–2** addressed in `b36fe6d`; suggestion **2** (extract quest HUD) still deferred.
|
||||
**Follow-up:** suggestions **1–2** 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 1–8” while required steps now include **9**).~~ **Done.** Steps 1–12; acceptance covers steps 1–9 + 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 1–9 (craft step 7–8: **Refine Scrap (Standard)** first row, scroll at top).
|
||||
3. Optional error paths (steps 10–11).
|
||||
4. Toggle **Economy HUD** off/on — debug labels in **`HudRootScroll`** remain; recipe repopulate + scroll reset still works.
|
||||
|
|
@ -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 **1–2** 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"
|
||||
```
|
||||
Loading…
Reference in New Issue