diff --git a/client/README.md b/client/README.md index 9a3dd03..77ee6e2 100644 --- a/client/README.md +++ b/client/README.md @@ -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. diff --git a/client/scenes/main.tscn b/client/scenes/main.tscn index 8380733..e102e31 100644 --- a/client/scenes/main.tscn +++ b/client/scenes/main.tscn @@ -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 diff --git a/client/scripts/craft_recipe_panel.gd b/client/scripts/craft_recipe_panel.gd index 0d10dab..3351c60 100644 --- a/client/scripts/craft_recipe_panel.gd +++ b/client/scripts/craft_recipe_panel.gd @@ -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() diff --git a/client/scripts/main.gd b/client/scripts/main.gd index ca30d44..c7e8a18 100644 --- a/client/scripts/main.gd +++ b/client/scripts/main.gd @@ -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 diff --git a/client/scripts/prototype_economy_hud_section.gd b/client/scripts/prototype_economy_hud_section.gd index ec35e92..368a341 100644 --- a/client/scripts/prototype_economy_hud_section.gd +++ b/client/scripts/prototype_economy_hud_section.gd @@ -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 diff --git a/client/scripts/prototype_hud_theme.gd b/client/scripts/prototype_hud_theme.gd index c5c3fd3..525e791 100644 --- a/client/scripts/prototype_hud_theme.gd +++ b/client/scripts/prototype_hud_theme.gd @@ -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: diff --git a/client/scripts/quest_definitions_client.gd b/client/scripts/quest_definitions_client.gd new file mode 100644 index 0000000..d1783f0 --- /dev/null +++ b/client/scripts/quest_definitions_client.gd @@ -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)) diff --git a/client/scripts/quest_definitions_client.gd.uid b/client/scripts/quest_definitions_client.gd.uid new file mode 100644 index 0000000..50b0a60 --- /dev/null +++ b/client/scripts/quest_definitions_client.gd.uid @@ -0,0 +1 @@ +uid://bneo122qstdefs01 diff --git a/client/scripts/quest_hud_controller.gd b/client/scripts/quest_hud_controller.gd new file mode 100644 index 0000000..450b479 --- /dev/null +++ b/client/scripts/quest_hud_controller.gd @@ -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) diff --git a/client/scripts/quest_hud_controller.gd.uid b/client/scripts/quest_hud_controller.gd.uid new file mode 100644 index 0000000..008829b --- /dev/null +++ b/client/scripts/quest_hud_controller.gd.uid @@ -0,0 +1 @@ +uid://c8k3m2questhudctrl01 diff --git a/client/scripts/quest_progress_client.gd b/client/scripts/quest_progress_client.gd new file mode 100644 index 0000000..e2f7fe6 --- /dev/null +++ b/client/scripts/quest_progress_client.gd @@ -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)) diff --git a/client/scripts/quest_progress_client.gd.uid b/client/scripts/quest_progress_client.gd.uid new file mode 100644 index 0000000..f3cf732 --- /dev/null +++ b/client/scripts/quest_progress_client.gd.uid @@ -0,0 +1 @@ +uid://bneo122qstprog01 diff --git a/client/test/craft_recipe_panel_test.gd b/client/test/craft_recipe_panel_test.gd new file mode 100644 index 0000000..06a098f --- /dev/null +++ b/client/test/craft_recipe_panel_test.gd @@ -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") diff --git a/client/test/craft_recipe_panel_test.gd.uid b/client/test/craft_recipe_panel_test.gd.uid new file mode 100644 index 0000000..f0a76e0 --- /dev/null +++ b/client/test/craft_recipe_panel_test.gd.uid @@ -0,0 +1 @@ +uid://bneo122craftpanel1 diff --git a/client/test/npc_runtime_hud_state_test.gd b/client/test/npc_runtime_hud_state_test.gd index c29249a..2a8165c 100644 --- a/client/test/npc_runtime_hud_state_test.gd +++ b/client/test/npc_runtime_hud_state_test.gd @@ -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: diff --git a/client/test/prototype_economy_hud_section_test.gd b/client/test/prototype_economy_hud_section_test.gd index 46ed2d5..61ccb3d 100644 --- a/client/test/prototype_economy_hud_section_test.gd +++ b/client/test/prototype_economy_hud_section_test.gd @@ -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) diff --git a/client/test/quest_definitions_client_test.gd b/client/test/quest_definitions_client_test.gd new file mode 100644 index 0000000..65d4cfd --- /dev/null +++ b/client/test/quest_definitions_client_test.gd @@ -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") diff --git a/client/test/quest_definitions_client_test.gd.uid b/client/test/quest_definitions_client_test.gd.uid new file mode 100644 index 0000000..6284cef --- /dev/null +++ b/client/test/quest_definitions_client_test.gd.uid @@ -0,0 +1 @@ +uid://bneo122qstdeft01 diff --git a/client/test/quest_hud_controller_test.gd b/client/test/quest_hud_controller_test.gd new file mode 100644 index 0000000..d7a3338 --- /dev/null +++ b/client/test/quest_hud_controller_test.gd @@ -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") diff --git a/client/test/quest_hud_controller_test.gd.uid b/client/test/quest_hud_controller_test.gd.uid new file mode 100644 index 0000000..b9c15e5 --- /dev/null +++ b/client/test/quest_hud_controller_test.gd.uid @@ -0,0 +1 @@ +uid://c8k3m2questhudtest01 diff --git a/client/test/quest_progress_client_test.gd b/client/test/quest_progress_client_test.gd new file mode 100644 index 0000000..9b31a66 --- /dev/null +++ b/client/test/quest_progress_client_test.gd @@ -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) diff --git a/client/test/quest_progress_client_test.gd.uid b/client/test/quest_progress_client_test.gd.uid new file mode 100644 index 0000000..155ab0f --- /dev/null +++ b/client/test/quest_progress_client_test.gd.uid @@ -0,0 +1 @@ +uid://bneo122qsttest01 diff --git a/docs/decomposition/modules/E7_M1_QuestStateMachine.md b/docs/decomposition/modules/E7_M1_QuestStateMachine.md index e68087d..e9057fc 100644 --- a/docs/decomposition/modules/E7_M1_QuestStateMachine.md +++ b/docs/decomposition/modules/E7_M1_QuestStateMachine.md @@ -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 diff --git a/docs/decomposition/modules/documentation_and_implementation_alignment.md b/docs/decomposition/modules/documentation_and_implementation_alignment.md index a102ed3..7f35890 100644 --- a/docs/decomposition/modules/documentation_and_implementation_alignment.md +++ b/docs/decomposition/modules/documentation_and_implementation_alignment.md @@ -59,7 +59,7 @@ Rows appear when work starts; default for unlisted modules is **Planned** / not | E5.M1 | Ready | **NEO-76 landed:** frozen prototype four-ability catalog in [`content/abilities/prototype_abilities.json`](../../../content/abilities/prototype_abilities.json); [`ability-def.schema.json`](../../../content/schemas/ability-def.schema.json); PR gate + [`validate_content.py`](../../../scripts/validate_content.py) E5M1 four-id gate ([NEO-76](../../plans/NEO-76-implementation-plan.md)). **NEO-77 landed:** fail-fast server load of `content/abilities/*_abilities.json` at startup — `server/NeonSprawl.Server/Game/Combat/` ([NEO-77](../../plans/NEO-77-implementation-plan.md)); [server README — Ability catalog](../../../server/README.md#ability-catalog-contentabilities-neo-77). **NEO-79 landed:** injectable **`IAbilityDefinitionRegistry`** + DI; hotbar/cast use **`TryNormalizeKnown`** ([NEO-79](../../plans/NEO-79-implementation-plan.md)); Bruno `bruno/neon-sprawl-server/hotbar-loadout/` + `ability-cast/` unknown-ability deny smokes. **NEO-78 landed:** **`GET /game/world/ability-definitions`** — `AbilityDefinitionsWorldApi` + DTOs in `Game/Combat/` ([NEO-78](../../plans/NEO-78-implementation-plan.md)); [server README — Ability definitions (NEO-78)](../../../server/README.md#ability-definitions-neo-78); Bruno `bruno/neon-sprawl-server/ability-definitions/`. **NEO-80 landed:** **`ICombatEntityHealthStore`** / **`InMemoryCombatEntityHealthStore`** — lazy init (Slice 1 flat 100 HP; **superseded by [NEO-91](../../plans/NEO-91-implementation-plan.md)** per-archetype catalog max HP + three NPC instance ids); DI via **`AddCombatEntityHealthStore()`** ([NEO-80](../../plans/NEO-80-implementation-plan.md)); [server README — Combat entity health (NEO-80, NEO-91)](../../../server/README.md#combat-entity-health-neo-80-neo-91). **NEO-81 landed:** **`CombatOperations.TryResolve`** + **`CombatResult`** + **`CombatReasonCodes`** — defeated pre-check deny, catalog damage ([NEO-81](../../plans/NEO-81-implementation-plan.md)); [server README — Combat engine (NEO-81)](../../../server/README.md#combat-engine-neo-81). **NEO-82 landed:** **`AbilityCastApi`** → **`CombatOperations.TryResolve`**; nested wire **`combatResolution`** on accept; per-ability catalog cooldown; **`target_defeated`** cast deny ([NEO-82](../../plans/NEO-82-implementation-plan.md)); [server README — Ability cast (NEO-82)](../../../server/README.md#ability-cast-neo-31-neo-82); Bruno `bruno/neon-sprawl-server/ability-cast/` defeat spine. **NEO-83 landed:** **`GET /game/world/combat-targets`** — `CombatTargetsWorldApi` + DTOs; authoritative HP snapshot from **`ICombatEntityHealthStore`** ([NEO-83](../../plans/NEO-83-implementation-plan.md)); [server README — Combat targets snapshot (NEO-83)](../../../server/README.md#combat-targets-snapshot-neo-83); Bruno `bruno/neon-sprawl-server/combat-targets/`. **NEO-44 landed:** **`CombatDefeatGigXpGrant`** on cast **`targetDefeated`** — **25** gig XP to **`breach`** via **`IPlayerGigProgressionStore`** (V007); **`GET …/gig-progression`** ([NEO-44](../../plans/NEO-44-implementation-plan.md), [`NEO-44` manual QA](../../manual-qa/NEO-44.md)); [server README — Gig progression (NEO-44)](../../../server/README.md#gig-progression-snapshot-neo-44); Bruno `bruno/neon-sprawl-server/gig-progression/`. **NEO-84 landed:** comment-only **`ability_used`** / **`enemy_defeat`** telemetry hook sites in **`CombatOperations.TryResolve`**; reserved **`encounter_start`** / **`player_death`** ([NEO-84](../../plans/NEO-84-implementation-plan.md)); [server README — Combat telemetry hooks (NEO-84)](../../../server/README.md#combat-telemetry-hooks-neo-84). **NEO-85 landed:** client combat HUD — **`ability_cast_client.gd`** parses **`combatResolution`**; **`combat_targets_client.gd`**, **`CastFeedbackLabel`** resolution copy, **`CombatTargetHpLabel`**; event-driven GET refresh ([NEO-85](../../plans/NEO-85-implementation-plan.md), [`NEO-85` manual QA](../../manual-qa/NEO-85.md)); `client/README.md` combat HUD section. **NEO-86 landed:** playable combat capstone — **`gig_progression_client.gd`**, **`GigXpLabel`**, defeat-triggered gig GET refresh; capstone manual QA [`NEO-86`](../../manual-qa/NEO-86.md); `client/README.md` end-to-end combat loop section ([NEO-86](../../plans/NEO-86-implementation-plan.md)). **Epic 5 Slice 1 client capstone complete.** **Backlog decomposed:** Epic 5 Slice 1 — **E5M1-01**–**E5M1-12** landed. **Precursor landed:** E1.M4 cast funnel (NEO-28/31/32). | [E5M1-prototype-backlog.md](../../plans/E5M1-prototype-backlog.md), [E5_M1](E5_M1_CombatRulesEngine.md), [NEO-77](../../plans/NEO-77-implementation-plan.md), [NEO-79](../../plans/NEO-79-implementation-plan.md), [NEO-78](../../plans/NEO-78-implementation-plan.md), [NEO-80](../../plans/NEO-80-implementation-plan.md), [NEO-81](../../plans/NEO-81-implementation-plan.md), [NEO-82](../../plans/NEO-82-implementation-plan.md), [NEO-83](../../plans/NEO-83-implementation-plan.md), [NEO-44](../../plans/NEO-44-implementation-plan.md), [NEO-84](../../plans/NEO-84-implementation-plan.md), [NEO-85](../../plans/NEO-85-implementation-plan.md), [NEO-86](../../plans/NEO-86-implementation-plan.md), [E1M4-prototype-backlog.md](../../plans/E1M4-prototype-backlog.md) | | E5.M2 | Ready | **NEO-87 landed:** frozen prototype three-behavior catalog in [`content/npc-behaviors/prototype_npc_behaviors.json`](../../../content/npc-behaviors/prototype_npc_behaviors.json); [`npc-behavior-def.schema.json`](../../../content/schemas/npc-behavior-def.schema.json); PR gate + [`validate_content.py`](../../../scripts/validate_content.py) E5M2 three-id gate ([NEO-87](../../plans/NEO-87-implementation-plan.md)). **NEO-88 landed:** fail-fast server load of `content/npc-behaviors/*_npc_behaviors.json` at startup — `server/NeonSprawl.Server/Game/Npc/` ([NEO-88](../../plans/NEO-88-implementation-plan.md)); [server README — NPC behavior catalog](../../../server/README.md#npc-behavior-catalog-contentnpc-behaviors-neo-88). **NEO-89 landed:** injectable **`INpcBehaviorDefinitionRegistry`** + DI; **`PrototypeNpcBehaviorRegistry`** id constants ([NEO-89](../../plans/NEO-89-implementation-plan.md)). **NEO-90 landed:** **`GET /game/world/npc-behavior-definitions`** — `NpcBehaviorDefinitionsWorldApi` + DTOs in `Game/Npc/` ([NEO-90](../../plans/NEO-90-implementation-plan.md)); [server README — NPC behavior definitions (NEO-90)](../../../server/README.md#npc-behavior-definitions-neo-90); Bruno `bruno/neon-sprawl-server/npc-behavior-definitions/`. **NEO-91 landed:** **`PrototypeNpcRegistry`** + combat-target migration — three NPC instance ids replace alpha/beta; per-archetype max HP from behavior catalog ([NEO-91](../../plans/NEO-91-implementation-plan.md)); [server README — Combat entity health (NEO-91)](../../../server/README.md#combat-entity-health-neo-80-neo-91). **NEO-92 landed:** **`IThreatStateStore`** + **`AggroOperations`** — first-hit acquire on damaging cast, leash clear on move/cast; cast/move/fixture hooks ([NEO-92](../../plans/NEO-92-implementation-plan.md)); [server README — Threat / aggro state (NEO-92)](../../../server/README.md#threat--aggro-state-neo-92). **NEO-93 landed:** **`INpcRuntimeStateStore`** + **`NpcRuntimeOperations.AdvanceAll`** — catalog-driven behavior state machine, lazy tick advance with delta cap; dev fixture reset ([NEO-93](../../plans/NEO-93-implementation-plan.md)); [server README — NPC runtime behavior state (NEO-93)](../../../server/README.md#npc-runtime-behavior-state-neo-93). **NEO-94 landed:** **`GET /game/world/npc-runtime-snapshot`** — `NpcRuntimeSnapshotWorldApi` + DTOs; lazy **`AdvanceAll`** on poll; nested **`activeTelegraph`** with server-computed **`windupRemainingSeconds`** ([NEO-94](../../plans/NEO-94-implementation-plan.md)); [server README — NPC runtime snapshot (NEO-94)](../../../server/README.md#npc-runtime-snapshot-neo-94); Bruno `bruno/neon-sprawl-server/npc-runtime-snapshot/`. **NEO-95 landed:** **`IPlayerCombatHealthStore`** + **`NpcAttackOperations.TryResolveTelegraphComplete`** — telegraph resolve via behavior **`attackAbilityId`** + ability **`maxRange`** / **`baseDamage`** ([NEO-95](../../plans/NEO-95-implementation-plan.md), [NEO-98](../../plans/NEO-98-implementation-plan.md)); [server README — Session player combat HP (NEO-95)](../../../server/README.md#session-player-combat-hp-neo-95); Bruno `bruno/neon-sprawl-server/combat-health/`. **NEO-96 landed:** comment-only **`telegraph_fired`** + **`npc_state_transition`** hook sites in **`NpcRuntimeOperations.AdvanceAll`** ([NEO-96](../../plans/NEO-96-implementation-plan.md)); [server README — NPC runtime telemetry hooks (NEO-96)](../../../server/README.md#npc-runtime-telemetry-hooks-neo-96). **NEO-97 landed:** client telegraph HUD — **`npc_runtime_client.gd`**, **`player_combat_health_client.gd`**, **`npc_runtime_hud_state.gd`**, **`TelegraphLabel`** / **`NpcStateLabel`** / **`PlayerCombatHpLabel`**; combat-active ~1 Hz poll ([NEO-97](../../plans/NEO-97-implementation-plan.md), [`NEO-97` manual QA](../../manual-qa/NEO-97.md)); [client README — NPC runtime + telegraph HUD (NEO-97)](../../../client/README.md#npc-runtime--telegraph-hud-neo-97). **NEO-98 landed:** playable NPC telegraph combat capstone — [`NEO-98` manual QA](../../manual-qa/NEO-98.md); `client/README.md` end-to-end NPC telegraph combat loop section ([NEO-98](../../plans/NEO-98-implementation-plan.md)). **NEO-98 server integration:** defeat clears aggro/runtime (**`TryStopOnTargetDefeat`**); telegraph damage range-gated via [`prototype_npc_abilities.json`](../../../content/abilities/prototype_npc_abilities.json) + behavior **`attackAbilityId`**. **Epic 5 Slice 2 client capstone complete.** **Backlog decomposed:** Epic 5 Slice 2 — [E5M2-prototype-backlog.md](../../plans/E5M2-prototype-backlog.md) **E5M2-01** [NEO-87](https://linear.app/neon-sprawl/issue/NEO-87) → **E5M2-12** [NEO-98](https://linear.app/neon-sprawl/issue/NEO-98) **landed**. Three archetypes (`prototype_melee_pressure`, `prototype_ranged_control`, `prototype_elite_mini_boss`); replaces alpha/beta dummies with **`prototype_npc_melee` / `ranged` / `elite`**. Owns **`ThreatState`**, **`TelegraphEvent`**, **`GET …/npc-runtime-snapshot`**, session **`IPlayerCombatHealthStore`**. Upstream **E5.M1 Ready**. | [E5M2-prototype-backlog.md](../../plans/E5M2-prototype-backlog.md), [E5_M2](E5_M2_NpcAiAndBehaviorProfiles.md), [NEO-87](../../plans/NEO-87-implementation-plan.md), [NEO-88](../../plans/NEO-88-implementation-plan.md), [NEO-89](../../plans/NEO-89-implementation-plan.md), [NEO-90](../../plans/NEO-90-implementation-plan.md), [NEO-91](../../plans/NEO-91-implementation-plan.md), [NEO-92](../../plans/NEO-92-implementation-plan.md), [NEO-93](../../plans/NEO-93-implementation-plan.md), [NEO-94](../../plans/NEO-94-implementation-plan.md), [NEO-95](../../plans/NEO-95-implementation-plan.md), [NEO-96](../../plans/NEO-96-implementation-plan.md), [NEO-97](../../plans/NEO-97-implementation-plan.md), [NEO-98](../../plans/NEO-98-implementation-plan.md), label **`E5.M2`** on NEO-87–NEO-98 | | E5.M3 | Ready | **E5M3-01 catalog landed ([NEO-100](https://linear.app/neon-sprawl/issue/NEO-100)):** encounter + reward-table schemas, `prototype_encounters.json`, `prototype_reward_tables.json`, CI gates. **E5M3-02 server load ([NEO-101](https://linear.app/neon-sprawl/issue/NEO-101)):** fail-fast startup loaders for encounter + reward-table catalogs (CI parity). **E5M3-03 registries ([NEO-102](https://linear.app/neon-sprawl/issue/NEO-102)):** `IEncounterDefinitionRegistry` + `IRewardTableDefinitionRegistry` + DI. **E5M3-04 HTTP read ([NEO-103](https://linear.app/neon-sprawl/issue/NEO-103)):** **`GET /game/world/encounter-definitions`** — `EncounterDefinitionsWorldApi` + nested **`rewardTable`** summary ([NEO-103](../../plans/NEO-103-implementation-plan.md)); [server README — Encounter definitions (NEO-103)](../../../server/README.md#encounter-definitions-neo-103); Bruno `bruno/neon-sprawl-server/encounter-definitions/`. **E5M3-05 stores ([NEO-104](https://linear.app/neon-sprawl/issue/NEO-104)):** **`IEncounterProgressStore`** + **`IEncounterCompletionStore`** + **`EncounterProgressOperations`** ([NEO-104](../../plans/NEO-104-implementation-plan.md)); [server README — Encounter progress (NEO-104)](../../../server/README.md#encounter-progress--completion-stores-neo-104). **E5M3-06 completion grants ([NEO-105](https://linear.app/neon-sprawl/issue/NEO-105)):** **`EncounterCompletionOperations`** + **`EncounterCompleteEvent`** result payload ([NEO-105](../../plans/NEO-105-implementation-plan.md)); [server README — Encounter completion (NEO-105)](../../../server/README.md#encounter-completion--inventory-grants-neo-105). **E5M3-07 combat wiring ([NEO-106](https://linear.app/neon-sprawl/issue/NEO-106)):** **`EncounterCombatWiring`** on **`AbilityCastApi`** ([NEO-106](../../plans/NEO-106-implementation-plan.md)); [server README — Encounter combat wiring (NEO-106)](../../../server/README.md#encounter-combat-wiring-neo-106). **E5M3-09 event record ([NEO-107](https://linear.app/neon-sprawl/issue/NEO-107)):** **`IEncounterCompleteEventStore`** + E7.M2 hook stub ([NEO-107](../../plans/NEO-107-implementation-plan.md)); [server README — Encounter complete event (NEO-107)](../../../server/README.md#encounter-complete-event-record-neo-107). **E5M3-08 per-player GET ([NEO-108](https://linear.app/neon-sprawl/issue/NEO-108)):** **`GET /game/players/{id}/encounter-progress`** — `EncounterProgressApi` + DTOs ([NEO-108](../../plans/NEO-108-implementation-plan.md)); [server README — Per-player encounter progress (NEO-108)](../../../server/README.md#per-player-encounter-progress-neo-108); Bruno `bruno/neon-sprawl-server/encounter-progress/`. **E5M3-10 telemetry ([NEO-109](https://linear.app/neon-sprawl/issue/NEO-109)):** comment-only **`encounter_start`**, **`encounter_complete`**, **`reward_attribution`**, **`encounter_complete_denied`** in **`EncounterProgressOperations`** / **`EncounterCompletionOperations`** ([NEO-109](../../plans/NEO-109-implementation-plan.md)); [server README — Encounter telemetry hooks (NEO-109)](../../../server/README.md#encounter-telemetry-hooks-neo-109). **E5M3-11 client HUD ([NEO-110](https://linear.app/neon-sprawl/issue/NEO-110)):** **`encounter_progress_client.gd`**, **`EncounterProgressLabel`** / **`EncounterCompleteLabel`**; boot + defeat-triggered GET; inventory refresh on **`completed`** ([NEO-110](../../plans/NEO-110-implementation-plan.md), [`NEO-110` manual QA](../../manual-qa/NEO-110.md)); [client README — Encounter progress + loot HUD (NEO-110)](../../../client/README.md#encounter-progress--loot-hud-neo-110). **E5M3-12 client capstone ([NEO-111](https://linear.app/neon-sprawl/issue/NEO-111)):** playable encounter clear loop — [`NEO-111` manual QA](../../manual-qa/NEO-111.md); [client README — End-to-end encounter clear loop (NEO-111)](../../../client/README.md#end-to-end-encounter-clear-loop-neo-111); plan [NEO-111](../../plans/NEO-111-implementation-plan.md). **Epic 5 Slice 3 client capstone complete.** Epic 5 Slice 3 backlog [E5M3-prototype-backlog.md](../../plans/E5M3-prototype-backlog.md) **E5M3-01** [NEO-100](https://linear.app/neon-sprawl/issue/NEO-100) → **E5M3-12** [NEO-111](https://linear.app/neon-sprawl/issue/NEO-111) **landed**. Prototype spine: **`prototype_combat_pocket`** → **`prototype_combat_pocket_clear`**; idempotent completion; per-defeat gig XP unchanged ([NEO-44](../../plans/NEO-44-implementation-plan.md)). Upstream **E5.M2 Ready**, **E3.M3** inventory landed. | [NEO-111 plan](../../plans/NEO-111-implementation-plan.md), [NEO-110 plan](../../plans/NEO-110-implementation-plan.md), [NEO-109 plan](../../plans/NEO-109-implementation-plan.md), [NEO-108 plan](../../plans/NEO-108-implementation-plan.md), [NEO-107 plan](../../plans/NEO-107-implementation-plan.md), [NEO-106 plan](../../plans/NEO-106-implementation-plan.md), [NEO-105 plan](../../plans/NEO-105-implementation-plan.md), [NEO-104 plan](../../plans/NEO-104-implementation-plan.md), [NEO-103 plan](../../plans/NEO-103-implementation-plan.md), [NEO-102 plan](../../plans/NEO-102-implementation-plan.md), [E5_M3](E5_M3_EncounterAndRewardTables.md), label **`E5.M3`** on NEO-100–NEO-111 | -| E7.M1 | Planned | **E7M1-01 catalog landed ([NEO-112](https://linear.app/neon-sprawl/issue/NEO-112)):** quest schemas, `prototype_quests.json`, CI gates (four frozen quest ids, objective cross-refs, acyclic prerequisites, chain terminal token). **E7M1-02 server load ([NEO-113](https://linear.app/neon-sprawl/issue/NEO-113)):** fail-fast startup load of `content/quests/*_quests.json` — `server/NeonSprawl.Server/Game/Quests/` ([NEO-113](../../plans/NEO-113-implementation-plan.md)); [server README — Quest catalog](../../../server/README.md#quest-catalog-contentquests-neo-113). **E7M1-03 registry ([NEO-114](https://linear.app/neon-sprawl/issue/NEO-114)):** **`IQuestDefinitionRegistry`** + DI ([NEO-114](../../plans/NEO-114-implementation-plan.md)). **E7M1-04 HTTP read ([NEO-115](https://linear.app/neon-sprawl/issue/NEO-115)):** **`GET /game/world/quest-definitions`** — `QuestDefinitionsWorldApi` + DTOs ([NEO-115](../../plans/NEO-115-implementation-plan.md)); [server README — Quest definitions (NEO-115)](../../../server/README.md#quest-definitions-neo-115); Bruno `bruno/neon-sprawl-server/quest-definitions/`. **E7M1-05 store ([NEO-116](https://linear.app/neon-sprawl/issue/NEO-116)):** **`IPlayerQuestStateStore`** + in-memory/Postgres persistence ([NEO-116](../../plans/NEO-116-implementation-plan.md)); [server README — Quest progress store (NEO-116)](../../../server/README.md#quest-progress-store-neo-116). **E7M1-06 operations ([NEO-117](https://linear.app/neon-sprawl/issue/NEO-117)):** **`QuestStateOperations`** + reason codes ([NEO-117](../../plans/NEO-117-implementation-plan.md)); [server README — Quest state operations (NEO-117)](../../../server/README.md#quest-state-operations-neo-117). **E7M1-07 objective wiring ([NEO-118](https://linear.app/neon-sprawl/issue/NEO-118)):** **`QuestObjectiveWiring`** on gather/craft/encounter + **`inventory_has_item`** snapshot passes ([NEO-118](../../plans/NEO-118-implementation-plan.md)); [server README — Quest objective wiring (NEO-118)](../../../server/README.md#quest-objective-wiring-neo-118). **E7M1-08 per-player GET ([NEO-119](https://linear.app/neon-sprawl/issue/NEO-119)):** **`GET /game/players/{id}/quest-progress`** — `QuestProgressApi` + DTOs; GET-side inventory refresh ([NEO-119](../../plans/NEO-119-implementation-plan.md)); [server README — Per-player quest progress (NEO-119)](../../../server/README.md#per-player-quest-progress-neo-119); Bruno `bruno/neon-sprawl-server/quest-progress/`. **E7M1-09 accept POST ([NEO-120](https://linear.app/neon-sprawl/issue/NEO-120)):** **`POST /game/players/{id}/quests/{questId}/accept`** — `QuestAcceptApi` + DTOs; **`QuestStateOperations.TryAccept`** ([NEO-120](../../plans/NEO-120-implementation-plan.md)); [server README — Quest accept POST (NEO-120)](../../../server/README.md#quest-accept-post-neo-120); Bruno accept spine in `bruno/neon-sprawl-server/quest-progress/`. **E7M1-10 telemetry ([NEO-121](https://linear.app/neon-sprawl/issue/NEO-121)):** comment-only **`quest_start`**, **`quest_step_complete`**, **`quest_complete`**, **`quest_accept_denied`** hook sites in **`QuestStateOperations`** ([NEO-121](../../plans/NEO-121-implementation-plan.md)); [server README — Quest telemetry hooks (NEO-121)](../../../server/README.md#quest-telemetry-hooks-neo-121). **Backlog decomposed (client from E7M1-11+):** Epic 7 Slice 1 — [E7M1-prototype-backlog.md](../../plans/E7M1-prototype-backlog.md) **E7M1-01** [NEO-112](https://linear.app/neon-sprawl/issue/NEO-112) → **E7M1-12** [NEO-123](https://linear.app/neon-sprawl/issue/NEO-123). Four frozen quests (3 onboarding + 1 gather→craft→combat chain); solo only; state-only completion in Slice 1 (rewards deferred to E7.M2). Client capstone **E7M1-12** [NEO-123](https://linear.app/neon-sprawl/issue/NEO-123). Upstream: E3.M1 gather **Ready**, E3.M2 craft **Ready**, E5.M3 encounter **`contract_handoff_token`** + **`EncounterCompleteEvent`** **Ready**. Register row stays **Planned** until client capstone **E7M1-12** [NEO-123](https://linear.app/neon-sprawl/issue/NEO-123). | [NEO-121 plan](../../plans/NEO-121-implementation-plan.md), [NEO-120 plan](../../plans/NEO-120-implementation-plan.md), [NEO-119 plan](../../plans/NEO-119-implementation-plan.md), [NEO-118 plan](../../plans/NEO-118-implementation-plan.md), [NEO-117 plan](../../plans/NEO-117-implementation-plan.md), [NEO-116 plan](../../plans/NEO-116-implementation-plan.md), [NEO-115 plan](../../plans/NEO-115-implementation-plan.md), [NEO-114 plan](../../plans/NEO-114-implementation-plan.md), [NEO-113 plan](../../plans/NEO-113-implementation-plan.md), [NEO-112 plan](../../plans/NEO-112-implementation-plan.md), [E7M1-prototype-backlog.md](../../plans/E7M1-prototype-backlog.md), [E7_M1](E7_M1_QuestStateMachine.md), label **`E7.M1`** on NEO-112–NEO-123 | +| E7.M1 | Planned | **E7M1-01 catalog landed ([NEO-112](https://linear.app/neon-sprawl/issue/NEO-112)):** quest schemas, `prototype_quests.json`, CI gates (four frozen quest ids, objective cross-refs, acyclic prerequisites, chain terminal token). **E7M1-02 server load ([NEO-113](https://linear.app/neon-sprawl/issue/NEO-113)):** fail-fast startup load of `content/quests/*_quests.json` — `server/NeonSprawl.Server/Game/Quests/` ([NEO-113](../../plans/NEO-113-implementation-plan.md)); [server README — Quest catalog](../../../server/README.md#quest-catalog-contentquests-neo-113). **E7M1-03 registry ([NEO-114](https://linear.app/neon-sprawl/issue/NEO-114)):** **`IQuestDefinitionRegistry`** + DI ([NEO-114](../../plans/NEO-114-implementation-plan.md)). **E7M1-04 HTTP read ([NEO-115](https://linear.app/neon-sprawl/issue/NEO-115)):** **`GET /game/world/quest-definitions`** — `QuestDefinitionsWorldApi` + DTOs ([NEO-115](../../plans/NEO-115-implementation-plan.md)); [server README — Quest definitions (NEO-115)](../../../server/README.md#quest-definitions-neo-115); Bruno `bruno/neon-sprawl-server/quest-definitions/`. **E7M1-05 store ([NEO-116](https://linear.app/neon-sprawl/issue/NEO-116)):** **`IPlayerQuestStateStore`** + in-memory/Postgres persistence ([NEO-116](../../plans/NEO-116-implementation-plan.md)); [server README — Quest progress store (NEO-116)](../../../server/README.md#quest-progress-store-neo-116). **E7M1-06 operations ([NEO-117](https://linear.app/neon-sprawl/issue/NEO-117)):** **`QuestStateOperations`** + reason codes ([NEO-117](../../plans/NEO-117-implementation-plan.md)); [server README — Quest state operations (NEO-117)](../../../server/README.md#quest-state-operations-neo-117). **E7M1-07 objective wiring ([NEO-118](https://linear.app/neon-sprawl/issue/NEO-118)):** **`QuestObjectiveWiring`** on gather/craft/encounter + **`inventory_has_item`** snapshot passes ([NEO-118](../../plans/NEO-118-implementation-plan.md)); [server README — Quest objective wiring (NEO-118)](../../../server/README.md#quest-objective-wiring-neo-118). **E7M1-08 per-player GET ([NEO-119](https://linear.app/neon-sprawl/issue/NEO-119)):** **`GET /game/players/{id}/quest-progress`** — `QuestProgressApi` + DTOs; GET-side inventory refresh ([NEO-119](../../plans/NEO-119-implementation-plan.md)); [server README — Per-player quest progress (NEO-119)](../../../server/README.md#per-player-quest-progress-neo-119); Bruno `bruno/neon-sprawl-server/quest-progress/`. **E7M1-09 accept POST ([NEO-120](https://linear.app/neon-sprawl/issue/NEO-120)):** **`POST /game/players/{id}/quests/{questId}/accept`** — `QuestAcceptApi` + DTOs; **`QuestStateOperations.TryAccept`** ([NEO-120](../../plans/NEO-120-implementation-plan.md)); [server README — Quest accept POST (NEO-120)](../../../server/README.md#quest-accept-post-neo-120); Bruno accept spine in `bruno/neon-sprawl-server/quest-progress/`. **E7M1-10 telemetry ([NEO-121](https://linear.app/neon-sprawl/issue/NEO-121)):** comment-only **`quest_start`**, **`quest_step_complete`**, **`quest_complete`**, **`quest_accept_denied`** hook sites in **`QuestStateOperations`** ([NEO-121](../../plans/NEO-121-implementation-plan.md)); [server README — Quest telemetry hooks (NEO-121)](../../../server/README.md#quest-telemetry-hooks-neo-121). **E7M1-11 client HUD ([NEO-122](https://linear.app/neon-sprawl/issue/NEO-122)):** **`quest_progress_client.gd`**, **`quest_definitions_client.gd`**, **`QuestProgressLabel`** / **`QuestAcceptFeedbackLabel`**; boot + event-driven GET refresh; **Q** / **Shift+Q** accept ([NEO-122](../../plans/NEO-122-implementation-plan.md), [`NEO-122` manual QA](../../manual-qa/NEO-122.md)); [client README — Quest progress + accept HUD (NEO-122)](../../../client/README.md#quest-progress--accept-hud-neo-122). **Backlog decomposed (capstone E7M1-12):** Epic 7 Slice 1 — [E7M1-prototype-backlog.md](../../plans/E7M1-prototype-backlog.md) **E7M1-01** [NEO-112](https://linear.app/neon-sprawl/issue/NEO-112) → **E7M1-12** [NEO-123](https://linear.app/neon-sprawl/issue/NEO-123). Four frozen quests (3 onboarding + 1 gather→craft→combat chain); solo only; state-only completion in Slice 1 (rewards deferred to E7.M2). Client capstone **E7M1-12** [NEO-123](https://linear.app/neon-sprawl/issue/NEO-123). Upstream: E3.M1 gather **Ready**, E3.M2 craft **Ready**, E5.M3 encounter **`contract_handoff_token`** + **`EncounterCompleteEvent`** **Ready**. Register row stays **Planned** until client capstone **E7M1-12** [NEO-123](https://linear.app/neon-sprawl/issue/NEO-123). | [NEO-122 plan](../../plans/NEO-122-implementation-plan.md), [NEO-121 plan](../../plans/NEO-121-implementation-plan.md), [NEO-120 plan](../../plans/NEO-120-implementation-plan.md), [NEO-119 plan](../../plans/NEO-119-implementation-plan.md), [NEO-118 plan](../../plans/NEO-118-implementation-plan.md), [NEO-117 plan](../../plans/NEO-117-implementation-plan.md), [NEO-116 plan](../../plans/NEO-116-implementation-plan.md), [NEO-115 plan](../../plans/NEO-115-implementation-plan.md), [NEO-114 plan](../../plans/NEO-114-implementation-plan.md), [NEO-113 plan](../../plans/NEO-113-implementation-plan.md), [NEO-112 plan](../../plans/NEO-112-implementation-plan.md), [E7M1-prototype-backlog.md](../../plans/E7M1-prototype-backlog.md), [E7_M1](E7_M1_QuestStateMachine.md), label **`E7.M1`** on NEO-112–NEO-123 | --- diff --git a/docs/manual-qa/NEO-110.md b/docs/manual-qa/NEO-110.md index 1164e13..eef18fa 100644 --- a/docs/manual-qa/NEO-110.md +++ b/docs/manual-qa/NEO-110.md @@ -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). diff --git a/docs/manual-qa/NEO-122.md b/docs/manual-qa/NEO-122.md new file mode 100644 index 0000000..8cf94ba --- /dev/null +++ b/docs/manual-qa/NEO-122.md @@ -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). diff --git a/docs/manual-qa/NEO-74.md b/docs/manual-qa/NEO-74.md index 974808e..9db5b73 100644 --- a/docs/manual-qa/NEO-74.md +++ b/docs/manual-qa/NEO-74.md @@ -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. diff --git a/docs/manual-qa/NEO-75.md b/docs/manual-qa/NEO-75.md index d7c43b0..32c9d16 100644 --- a/docs/manual-qa/NEO-75.md +++ b/docs/manual-qa/NEO-75.md @@ -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 diff --git a/docs/plans/E7M1-prototype-backlog.md b/docs/plans/E7M1-prototype-backlog.md index 3372add..a147e83 100644 --- a/docs/plans/E7M1-prototype-backlog.md +++ b/docs/plans/E7M1-prototype-backlog.md @@ -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). --- diff --git a/docs/plans/NEO-122-implementation-plan.md b/docs/plans/NEO-122-implementation-plan.md new file mode 100644 index 0000000..41f582d --- /dev/null +++ b/docs/plans/NEO-122-implementation-plan.md @@ -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** | diff --git a/docs/reviews/2026-06-07-NEO-122-follow-up.md b/docs/reviews/2026-06-07-NEO-122-follow-up.md new file mode 100644 index 0000000..0c11e04 --- /dev/null +++ b/docs/reviews/2026-06-07-NEO-122-follow-up.md @@ -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. diff --git a/docs/reviews/2026-06-07-NEO-122.md b/docs/reviews/2026-06-07-NEO-122.md new file mode 100644 index 0000000..7c4cd47 --- /dev/null +++ b/docs/reviews/2026-06-07-NEO-122.md @@ -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" +```