diff --git a/client/README.md b/client/README.md index 9a3dd03..85731ed 100644 --- a/client/README.md +++ b/client/README.md @@ -225,6 +225,20 @@ Full capstone checklist: [`docs/manual-qa/NEO-86.md`](../docs/manual-qa/NEO-86.m 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/HudRoot/QuestProgressLabel`** — all four prototype quests in catalog order with compact status/step/counter lines. + - **`UICanvas/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`**, 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. @@ -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_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..f9e0f44 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") @@ -1224,6 +1232,27 @@ theme_override_font_sizes/font_size = 22 autowrap_mode = 3 text = "Loot: —" +[node name="QuestProgressLabel" type="Label" parent="UICanvas/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/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/HudRoot" unique_id=9000020] layout_mode = 2 size_flags_horizontal = 3 diff --git a/client/scripts/main.gd b/client/scripts/main.gd index ca30d44..40cc223 100644 --- a/client/scripts/main.gd +++ b/client/scripts/main.gd @@ -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 PROTOTYPE_QUEST_GATHER_ID := "prototype_quest_gather_intro" ## 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,9 @@ var _last_gig_snapshot: Dictionary = {} var _gig_error: String = "" var _last_encounter_progress_snapshot: Dictionary = {} var _encounter_progress_error: String = "" +var _last_quest_progress_snapshot: Dictionary = {} +var _quest_progress_error: String = "" +var _quest_defs_error: String = "" var _gather_pre_scrap_qty: int = 0 var _gather_pending_interactable_id: String = "" var _gather_awaiting_inventory_finalize: bool = false @@ -132,6 +136,8 @@ var _dev_pulse_bootstrap_attempted: bool = false @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 _quest_progress_label: Label = $UICanvas/HudRoot/QuestProgressLabel +@onready var _quest_accept_feedback_label: Label = $UICanvas/HudRoot/QuestAcceptFeedbackLabel @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 @@ -148,6 +154,8 @@ var _skill_progression_label: Label = _economy_hud_section.get_node("Body/SkillP @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 +205,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() @@ -842,6 +851,251 @@ 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. + _apply_authority_http_config_to_client(_quest_progress_client) + _apply_authority_http_config_to_client(_quest_defs_client) + if _quest_progress_client.has_signal("quest_progress_received"): + _quest_progress_client.connect( + "quest_progress_received", Callable(self, "_on_quest_progress_received") + ) + if _quest_progress_client.has_signal("quest_sync_failed"): + _quest_progress_client.connect( + "quest_sync_failed", Callable(self, "_on_quest_progress_sync_failed") + ) + if _quest_progress_client.has_signal("quest_accept_result_received"): + _quest_progress_client.connect( + "quest_accept_result_received", Callable(self, "_on_quest_accept_result_received") + ) + if _quest_progress_client.has_signal("quest_accept_failed"): + _quest_progress_client.connect( + "quest_accept_failed", Callable(self, "_on_quest_accept_failed") + ) + if _quest_defs_client.has_signal("definitions_ready"): + _quest_defs_client.connect("definitions_ready", Callable(self, "_on_quest_definitions_ready")) + if _quest_defs_client.has_signal("definitions_sync_failed"): + _quest_defs_client.connect( + "definitions_sync_failed", Callable(self, "_on_quest_definitions_sync_failed") + ) + _render_quest_progress_labels() + _render_quest_accept_feedback_label() + if _quest_defs_client.has_method("request_sync_from_server"): + _quest_defs_client.call("request_sync_from_server") + if _quest_progress_client.has_method("request_sync_from_server"): + _quest_progress_client.call("request_sync_from_server") + + +func _on_quest_progress_received(snapshot: Dictionary) -> void: + _quest_progress_error = "" + _last_quest_progress_snapshot = snapshot.duplicate(true) + _render_quest_progress_labels() + + +func _on_quest_progress_sync_failed(reason: String) -> void: + _quest_progress_error = reason + _last_quest_progress_snapshot = {} + _render_quest_progress_labels() + + +func _on_quest_accept_result_received(quest_id: String, result: Dictionary) -> void: + if bool(result.get("accepted", false)): + _render_quest_accept_feedback_label( + "Quest accept: %s accepted" % _quest_display_name(quest_id) + ) + else: + var rc := str(result.get("reasonCode", "")).strip_edges() + if rc.is_empty(): + _render_quest_accept_feedback_label("Quest accept: denied (no reasonCode)") + else: + _render_quest_accept_feedback_label("Quest accept: denied — %s" % rc) + _request_quest_progress_refresh() + + +func _on_quest_accept_failed(_quest_id: String, reason: String) -> void: + _render_quest_accept_feedback_label("Quest accept: failed — %s" % reason) + + +func _on_quest_definitions_ready(_quests: Array) -> void: + _quest_defs_error = "" + if _quest_progress_error.is_empty() and not _last_quest_progress_snapshot.is_empty(): + _render_quest_progress_labels() + + +func _on_quest_definitions_sync_failed(reason: String) -> void: + _quest_defs_error = reason + _render_quest_progress_labels() + + +func _render_quest_progress_labels() -> void: + _render_quest_progress_label() + + +func _render_quest_progress_label() -> void: + if not is_instance_valid(_quest_progress_label): + return + var header := "Quests:" + if not _quest_progress_error.is_empty(): + _quest_progress_label.text = "%s\nerror — %s" % [header, _quest_progress_error] + return + if _last_quest_progress_snapshot.is_empty(): + _quest_progress_label.text = "%s\nLoading…" % header + return + var lines: PackedStringArray = [header] + var defs: Array = _quest_defs_snapshot() + if defs.is_empty(): + var quests: Variant = _last_quest_progress_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_quest_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 = _quest_display_name(qid) + lines.append(" %s" % _format_quest_status_line(qid, label, row)) + _quest_progress_label.text = "\n".join(lines) + + +func _format_quest_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_quest_accept_feedback_label(text: String = "Quest accept: — (Q gather / Shift+Q next)") -> void: + if is_instance_valid(_quest_accept_feedback_label): + _quest_accept_feedback_label.text = text + + +func _quest_row(quest_id: String) -> Dictionary: + if not is_instance_valid(_quest_progress_client): + return {} + if not _quest_progress_client.has_method("quest_row"): + return {} + var row: Variant = _quest_progress_client.call( + "quest_row", quest_id, _last_quest_progress_snapshot + ) + return row as Dictionary + + +func _quest_display_name(quest_id: String) -> String: + if not is_instance_valid(_quest_defs_client): + return quest_id + if not _quest_defs_client.has_method("display_name_for"): + return quest_id + return str(_quest_defs_client.call("display_name_for", quest_id)) + + +func _quest_defs_snapshot() -> Array: + if not is_instance_valid(_quest_defs_client): + return [] + if not _quest_defs_client.has_method("quests_snapshot"): + return [] + return _quest_defs_client.call("quests_snapshot") as Array + + +func _request_quest_progress_refresh() -> void: + if not is_instance_valid(_quest_progress_client): + return + if _quest_progress_client.has_method("request_sync_from_server"): + _quest_progress_client.call("request_sync_from_server") + + +func _find_first_eligible_quest_id() -> String: + var defs: Array = _quest_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 + var row: Dictionary = _quest_row(qid) + if str(row.get("status", "")) != "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 str(_quest_row(pid).get("status", "")) != "completed": + prereqs_met = false + break + if prereqs_met: + return qid + return "" + + +func _request_quest_accept(quest_id: String) -> void: + if not is_instance_valid(_quest_progress_client): + return + if _quest_progress_client.has_method("is_accept_busy") and bool(_quest_progress_client.call("is_accept_busy")): + return + if _quest_progress_client.has_method("request_accept"): + _quest_progress_client.call("request_accept", quest_id) + + +func _request_eligible_quest_accept() -> void: + var eligible_id: String = _find_first_eligible_quest_id() + if eligible_id.is_empty(): + _render_quest_accept_feedback_label("Quest accept: no eligible quest") + return + _request_quest_accept(eligible_id) + + +func _try_quest_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_quest_accept() + else: + _request_quest_accept(PROTOTYPE_QUEST_GATHER_ID) + return true + + func _render_gather_feedback_label(text: String = "Gather: —") -> void: if is_instance_valid(_gather_feedback_label): _gather_feedback_label.text = text @@ -910,6 +1164,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 +1235,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 +1399,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 +1616,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/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_progress_client.gd b/client/scripts/quest_progress_client.gd new file mode 100644 index 0000000..7cff662 --- /dev/null +++ b/client/scripts/quest_progress_client.gd @@ -0,0 +1,194 @@ +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 _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: + return + _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) + + +## 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 + + +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) + return + if response_code == 404: + var reason404 := "HTTP 404 (player unknown)" + push_warning("QuestProgressClient: %s" % reason404) + quest_sync_failed.emit(reason404) + 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) + 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) + return + quest_progress_received.emit(snapshot as Dictionary) + + +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/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_progress_client_test.gd b/client/test/quest_progress_client_test.gd new file mode 100644 index 0000000..6c26456 --- /dev/null +++ b/client/test/quest_progress_client_test.gd @@ -0,0 +1,228 @@ +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 = {} + + +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} + + +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 + + +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) + assert_that(bool((_accept_capture.get("result", {}) as Dictionary).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") 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-122.md b/docs/manual-qa/NEO-122.md new file mode 100644 index 0000000..3849421 --- /dev/null +++ b/docs/manual-qa/NEO-122.md @@ -0,0 +1,51 @@ +# 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 (below encounter loot labels). +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. Craft **`refine_scrap_standard`** with Economy HUD open. Verify refine quest **`completed`** after successful craft. +8. Press **Q** again on already-completed gather. Verify deny feedback on **`QuestAcceptFeedbackLabel`** without Bruno. +9. Optional: stop server while Godot running, press **Q** — verify **`Quests: error — …`** or accept **`failed — …`** visible on HUD. + +## 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–8 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. 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 index f0c8f81..0c1057f 100644 --- a/docs/plans/NEO-122-implementation-plan.md +++ b/docs/plans/NEO-122-implementation-plan.md @@ -55,10 +55,19 @@ ## Acceptance criteria checklist -- [ ] 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). -- [ ] `docs/manual-qa/NEO-122.md` Godot steps exercisable. +- [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). +- [ ] `docs/manual-qa/NEO-122.md` Godot steps exercisable (human QA pending). + +## 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`**/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`. ## Technical approach