From 365e57799a5a9bec5c4cbf4e3772a3cfa708c07c Mon Sep 17 00:00:00 2001 From: VinPropane Date: Sun, 28 Jun 2026 13:49:14 -0400 Subject: [PATCH] NEO-153: add contract issue + progress HUD in Godot client. Shift+C issues prototype contract via contract_client; contract_hud_controller shows encounter objective, deny copy, and completion reward transition. Includes scene wiring, GdUnit tests, manual QA, and README. --- client/README.md | 17 +- client/scenes/main.tscn | 36 ++ client/scripts/contract_client.gd | 265 ++++++++++++++ client/scripts/contract_client.gd.uid | 1 + client/scripts/contract_hud_controller.gd | 329 ++++++++++++++++++ client/scripts/contract_hud_controller.gd.uid | 1 + client/scripts/main.gd | 39 +++ client/test/contract_client_test.gd | 221 ++++++++++++ client/test/contract_client_test.gd.uid | 1 + client/test/contract_hud_controller_test.gd | 220 ++++++++++++ .../test/contract_hud_controller_test.gd.uid | 1 + docs/manual-qa/NEO-153.md | 46 +++ docs/plans/NEO-153-implementation-plan.md | 12 +- 13 files changed, 1186 insertions(+), 3 deletions(-) create mode 100644 client/scripts/contract_client.gd create mode 100644 client/scripts/contract_client.gd.uid create mode 100644 client/scripts/contract_hud_controller.gd create mode 100644 client/scripts/contract_hud_controller.gd.uid create mode 100644 client/test/contract_client_test.gd create mode 100644 client/test/contract_client_test.gd.uid create mode 100644 client/test/contract_hud_controller_test.gd create mode 100644 client/test/contract_hud_controller_test.gd.uid create mode 100644 docs/manual-qa/NEO-153.md diff --git a/client/README.md b/client/README.md index 959d9c7..d6a2227 100644 --- a/client/README.md +++ b/client/README.md @@ -251,6 +251,21 @@ Full checklist: [`docs/manual-qa/NEO-122.md`](../docs/manual-qa/NEO-122.md). Full checklist: [`docs/manual-qa/NEO-131.md`](../docs/manual-qa/NEO-131.md). +## Contract issue + progress HUD (NEO-153) + +- **`POST /game/players/{id}/contracts/issue`** — **`issued`** + optional **`reasonCode`** + optional **`contract`** row (NEO-151); see [server README — Contract issue HTTP](../server/README.md#contract-issue-http-neo-151). +- **`GET /game/players/{id}/contracts`** — active row (0–1) + recent completed rows with **`encounterTemplateId`** and optional **`completionRewardSummary`** (NEO-151). +- **Scripts:** `scripts/contract_client.gd`, `scripts/contract_hud_controller.gd`; wired from `main.gd` in `_setup_contract_hud()`. +- **HUD:** + - **`UICanvas/HudRootScroll/HudRoot/ContractActiveLabel`** — active contract with **`encounterTemplateId`**; **`completed`** after pocket clear in-session. + - **`UICanvas/HudRootScroll/HudRoot/ContractIssueFeedbackLabel`** — issue success/deny/failure copy. + - **`UICanvas/HudRootScroll/HudRoot/ContractRewardDeliveryLabel`** — grant lines on in-session **`active` → `completed`** transition (NEO-131 pattern). +- **Issue key:** **Shift+C** — frozen prototype template **`prototype_contract_clear_combat_pocket`**, seed **`prototype_contract_dev_seed`**. +- **Refresh:** boot hydrate + GET after issue POST + encounter progress GET when pocket **`completed`** (alongside inventory/quest refresh). No periodic poll. +- **Deny copy:** readable strings for **`active_contract_exists`**, **`no_eligible_template`**, **`economy_cap_exceeded`**, **`invalid_reward_bundle`**, **`unknown_template`**. + +Full checklist: [`docs/manual-qa/NEO-153.md`](../docs/manual-qa/NEO-153.md). + ## Faction standing + gate feedback HUD (NEO-142) - **`GET /game/players/{id}/faction-standing`** — server-authoritative per-faction **`standing`** snapshot (NEO-139); see [server README — Faction standing read](../server/README.md#faction-standing-read-neo-139). @@ -474,7 +489,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), **`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. +**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), **`contract_client.gd`**, **`contract_hud_controller.gd`** (NEO-153), **`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 82a18e4..4c25960 100644 --- a/client/scenes/main.tscn +++ b/client/scenes/main.tscn @@ -24,6 +24,7 @@ [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="Script" uid="uid://bneo142factionst01" path="res://scripts/faction_standing_client.gd" id="27_faction_standing"] +[ext_resource type="Script" uid="uid://bneo153contract01" path="res://scripts/contract_client.gd" id="28_contract_client"] [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"] @@ -1124,6 +1125,9 @@ script = ExtResource("24_enc_prog") [node name="QuestProgressClient" type="Node" parent="." unique_id=2500013] script = ExtResource("25_quest_prog") +[node name="ContractClient" type="Node" parent="." unique_id=2500016] +script = ExtResource("28_contract_client") + [node name="QuestDefinitionsClient" type="Node" parent="." unique_id=2500014] script = ExtResource("26_quest_defs") @@ -1275,6 +1279,38 @@ autowrap_mode = 3 text = "Quest rewards: —" +[node name="ContractActiveLabel" type="Label" parent="UICanvas/HudRootScroll/HudRoot" unique_id=9000029] +layout_mode = 2 +size_flags_horizontal = 3 +theme_override_colors/font_color = Color(0.98, 0.88, 0.62, 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 = "Contract: +—" + +[node name="ContractIssueFeedbackLabel" type="Label" parent="UICanvas/HudRootScroll/HudRoot" unique_id=9000030] +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 = "Contract issue: — (Shift+C prototype)" + +[node name="ContractRewardDeliveryLabel" type="Label" parent="UICanvas/HudRootScroll/HudRoot" unique_id=9000031] +layout_mode = 2 +size_flags_horizontal = 3 +theme_override_colors/font_color = Color(0.78, 0.95, 0.82, 1) +theme_override_colors/font_outline_color = Color(0.08, 0.08, 0.1, 1) +theme_override_constants/outline_size = 8 +theme_override_font_sizes/font_size = 22 +autowrap_mode = 3 +text = "Contract rewards: +—" + [node name="FactionStandingLabel" type="Label" parent="UICanvas/HudRootScroll/HudRoot" unique_id=9000028] layout_mode = 2 size_flags_horizontal = 3 diff --git a/client/scripts/contract_client.gd b/client/scripts/contract_client.gd new file mode 100644 index 0000000..fc129b1 --- /dev/null +++ b/client/scripts/contract_client.gd @@ -0,0 +1,265 @@ +extends Node + +## NEO-153: HTTP client for contract list GET (NEO-151) and contract issue POST (NEO-151). + +signal contracts_received(snapshot: Dictionary) +signal contracts_sync_failed(reason: String) +signal contract_issue_result_received(result: Dictionary) +signal contract_issue_failed(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_issue_http: Node = null + +var _sync_http: Node +var _issue_http: Node +var _sync_busy: bool = false +var _sync_pending: bool = false +var _issue_busy: bool = false + + +func _ready() -> void: + _sync_http = _resolve_http(injected_sync_http) + _issue_http = _resolve_http(injected_issue_http) + @warning_ignore("unsafe_method_access") + _sync_http.request_completed.connect(_on_sync_request_completed) + @warning_ignore("unsafe_method_access") + _issue_http.request_completed.connect(_on_issue_request_completed) + + +func is_issue_busy() -> bool: + return _issue_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/contracts" % [_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("ContractClient: %s" % reason) + _sync_busy = false + contracts_sync_failed.emit(reason) + _try_flush_pending_sync() + + +## Returns [code]true[/code] when the HTTP POST was queued. +func request_issue(template_id: String, seed_bucket: String) -> bool: + if _issue_busy: + return false + var tid := template_id.strip_edges() + var seed := seed_bucket.strip_edges() + if tid.is_empty() or seed.is_empty(): + return false + _issue_busy = true + var payload: Dictionary = { + "schemaVersion": SCHEMA_VERSION, + "playerId": _player_path_segment(), + "templateId": tid, + "seedBucket": seed, + } + var url := "%s/game/players/%s/contracts/issue" % [_base_root(), _player_path_segment()] + var headers := PackedStringArray(["Content-Type: application/json"]) + var err: Error = _issue_http.request( + url, headers, HTTPClient.METHOD_POST, JSON.stringify(payload) + ) + if err != OK: + _issue_busy = false + push_warning("ContractClient: POST failed to start (%s)" % err) + return false + return true + + +static func active_contract_row(snapshot: Dictionary = {}) -> Dictionary: + var contracts: Variant = snapshot.get("contracts", null) + if contracts == null or not contracts is Array: + return {} + for row_variant in contracts as Array: + if not row_variant is Dictionary: + continue + var row: Dictionary = row_variant + if str(row.get("status", "")) == "active": + return row.duplicate(true) + return {} + + +static func contract_row(instance_id: String, snapshot: Dictionary = {}) -> Dictionary: + var iid := instance_id.strip_edges() + if iid.is_empty(): + return {} + var contracts: Variant = snapshot.get("contracts", null) + if contracts == null or not contracts is Array: + return {} + for row_variant in contracts as Array: + if not row_variant is Dictionary: + continue + var row: Dictionary = row_variant + if str(row.get("contractInstanceId", "")) == iid: + return row.duplicate(true) + return {} + + +static func completion_reward_summary(instance_id: String, snapshot: Dictionary = {}) -> Dictionary: + var row: Dictionary = contract_row(instance_id, snapshot) + if row.is_empty() or str(row.get("status", "")) != "completed": + return {} + var summary_variant: Variant = row.get("completionRewardSummary", null) + if summary_variant is Dictionary: + return (summary_variant as Dictionary).duplicate(true) + return {} + + +static func parse_contracts_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 contracts: Variant = root.get("contracts", null) + if contracts == null or not contracts is Array: + return null + if str(root.get("playerId", "")).strip_edges().is_empty(): + return null + return root + + +static func parse_contract_issue_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("issued"): + return null + return root + + +static func merge_contract_row_into_snapshot(snapshot: Dictionary, row: Dictionary) -> Dictionary: + var iid := str(row.get("contractInstanceId", "")).strip_edges() + if iid.is_empty(): + return snapshot.duplicate(true) if not snapshot.is_empty() else {} + var merged: Dictionary + if snapshot.is_empty(): + merged = {"schemaVersion": SCHEMA_VERSION, "playerId": "", "contracts": []} + else: + merged = snapshot.duplicate(true) + var contracts: Array = [] + var contracts_variant: Variant = merged.get("contracts", null) + if contracts_variant is Array: + for row_variant in contracts_variant as Array: + contracts.append(row_variant) + var replaced := false + for i in contracts.size(): + var existing: Variant = contracts[i] + if existing is Dictionary and str((existing as Dictionary).get("contractInstanceId", "")) == iid: + contracts[i] = row.duplicate(true) + replaced = true + break + if not replaced: + contracts.append(row.duplicate(true)) + merged["contracts"] = contracts + return merged + + +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() + + +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("ContractClient: %s" % reason) + contracts_sync_failed.emit(reason) + _try_flush_pending_sync() + return + if response_code == 404: + var reason404 := "HTTP 404 (player unknown)" + push_warning("ContractClient: %s" % reason404) + contracts_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("ContractClient: %s" % reason_code) + contracts_sync_failed.emit(reason_code) + _try_flush_pending_sync() + return + var text := body.get_string_from_utf8() + var snapshot: Variant = parse_contracts_json(text) + if snapshot == null: + var reason_json := "non-JSON body or schemaVersion mismatch" + push_warning("ContractClient: %s" % reason_json) + contracts_sync_failed.emit(reason_json) + _try_flush_pending_sync() + return + contracts_received.emit(snapshot as Dictionary) + _try_flush_pending_sync() + + +func _on_issue_request_completed( + result: int, response_code: int, _headers: PackedStringArray, body: PackedByteArray +) -> void: + _issue_busy = false + if result != HTTPRequest.RESULT_SUCCESS: + var reason := "HTTP failed (result=%s)" % result + push_warning("ContractClient: %s" % reason) + contract_issue_failed.emit(reason) + return + if response_code == 404: + var reason404 := "HTTP 404 (player unknown)" + push_warning("ContractClient: %s" % reason404) + contract_issue_failed.emit(reason404) + return + if response_code < 200 or response_code >= 300: + var reason_code := "HTTP %s" % response_code + push_warning("ContractClient: %s" % reason_code) + contract_issue_failed.emit(reason_code) + return + var text := body.get_string_from_utf8() + var parsed: Variant = parse_contract_issue_json(text) + if parsed == null: + var reason_json := "non-JSON body or schemaVersion mismatch" + push_warning("ContractClient: %s" % reason_json) + contract_issue_failed.emit(reason_json) + return + var data: Dictionary = parsed as Dictionary + if not bool(data.get("issued", false)): + var reason_variant: Variant = data.get("reasonCode", "") + var reason_str: String = reason_variant as String if reason_variant is String else "" + push_warning("contract_issue_denied reasonCode=%s" % reason_str) + contract_issue_result_received.emit(data.duplicate(true)) diff --git a/client/scripts/contract_client.gd.uid b/client/scripts/contract_client.gd.uid new file mode 100644 index 0000000..dd21dd8 --- /dev/null +++ b/client/scripts/contract_client.gd.uid @@ -0,0 +1 @@ +uid://bneo153contract01 diff --git a/client/scripts/contract_hud_controller.gd b/client/scripts/contract_hud_controller.gd new file mode 100644 index 0000000..ddd452e --- /dev/null +++ b/client/scripts/contract_hud_controller.gd @@ -0,0 +1,329 @@ +extends Node + +## NEO-153: contract issue + progress HUD wiring (Shift+C prototype issue). + +const ContractClient := preload("res://scripts/contract_client.gd") +const FactionStandingClient := preload("res://scripts/faction_standing_client.gd") + +const PROTOTYPE_CONTRACT_TEMPLATE_ID := "prototype_contract_clear_combat_pocket" +const PROTOTYPE_CONTRACT_SEED_BUCKET := "prototype_contract_dev_seed" +const ISSUE_IDLE_HINT := "Contract issue: — (Shift+C prototype)" +const ISSUE_SENDING_HINT := "Contract issue: sending…" +const REWARD_HEADER := "Contract rewards:" + +var _contract_client: Node = null +var _item_defs_client: Node = null +var _inventory_client: Node = null +var _skill_progression_client: Node = null +var _faction_standing_client: Node = null +var _active_label: Label = null +var _issue_label: Label = null +var _reward_label: Label = null +var _last_snapshot: Dictionary = {} +var _sync_error: String = "" +var _issue_contract_patch: Dictionary = {} +var _previous_status_by_instance: Dictionary = {} +var _contract_was_active_in_session: Dictionary = {} +var _tracked_instance_id: String = "" +var _last_reward_instance_id: String = "" +var _last_reward_summary: Dictionary = {} + + +func setup( + contract_client: Node, + active_label: Label, + issue_label: Label, + reward_label: Label, + apply_http_config: Callable, + item_defs_client: Node = null, + inventory_client: Node = null, + skill_progression_client: Node = null, + faction_standing_client: Node = null +) -> void: + _contract_client = contract_client + _active_label = active_label + _issue_label = issue_label + _reward_label = reward_label + _item_defs_client = item_defs_client + _inventory_client = inventory_client + _skill_progression_client = skill_progression_client + _faction_standing_client = faction_standing_client + if apply_http_config.is_valid(): + apply_http_config.call(contract_client) + if _contract_client.has_signal("contracts_received"): + _contract_client.connect("contracts_received", Callable(self, "_on_contracts_received")) + if _contract_client.has_signal("contracts_sync_failed"): + _contract_client.connect("contracts_sync_failed", Callable(self, "_on_sync_failed")) + if _contract_client.has_signal("contract_issue_result_received"): + _contract_client.connect( + "contract_issue_result_received", Callable(self, "_on_issue_result_received") + ) + if _contract_client.has_signal("contract_issue_failed"): + _contract_client.connect("contract_issue_failed", Callable(self, "_on_issue_failed")) + if is_instance_valid(_item_defs_client) and _item_defs_client.has_signal("definitions_ready"): + _item_defs_client.connect("definitions_ready", Callable(self, "_on_item_definitions_ready")) + _render_active_label() + _render_issue_feedback_label(ISSUE_IDLE_HINT) + _render_reward_label() + request_contract_refresh() + + +func request_contract_refresh() -> void: + if not is_instance_valid(_contract_client): + return + if _contract_client.has_method("request_sync_from_server"): + _contract_client.call("request_sync_from_server") + + +func try_issue_key_input(event: InputEvent) -> bool: + if not event is InputEventKey: + return false + var k := event as InputEventKey + if not k.pressed or k.echo or not k.shift_pressed: + return false + if k.physical_keycode != KEY_C and k.keycode != KEY_C: + return false + if not is_instance_valid(_contract_client): + return false + if not _contract_client.has_method("request_issue"): + return false + if bool(_contract_client.call("is_issue_busy")): + return true + _render_issue_feedback_label(ISSUE_SENDING_HINT) + var queued: bool = bool( + _contract_client.call( + "request_issue", PROTOTYPE_CONTRACT_TEMPLATE_ID, PROTOTYPE_CONTRACT_SEED_BUCKET + ) + ) + if not queued: + _render_issue_feedback_label("Contract issue: failed — request not queued") + return true + + +func _on_contracts_received(snapshot: Dictionary) -> void: + _sync_error = "" + _detect_and_apply_completion_transitions(snapshot) + _last_snapshot = snapshot.duplicate(true) + _reapply_issue_contract_patch_if_needed() + _render_active_label() + _render_reward_label() + + +func _on_sync_failed(reason: String) -> void: + _sync_error = reason + if _issue_contract_patch.is_empty(): + _last_snapshot = {} + elif _last_snapshot.is_empty(): + _last_snapshot = ContractClient.merge_contract_row_into_snapshot({}, _issue_contract_patch) + _render_active_label() + _render_reward_label() + + +func _on_issue_result_received(result: Dictionary) -> void: + if bool(result.get("issued", false)): + var contract_variant: Variant = result.get("contract", null) + if contract_variant is Dictionary: + var row: Dictionary = contract_variant as Dictionary + var iid := str(row.get("contractInstanceId", "")).strip_edges() + if not iid.is_empty(): + _mark_contract_active_in_session(iid) + _tracked_instance_id = iid + _issue_contract_patch = row.duplicate(true) + _sync_error = "" + _last_snapshot = ContractClient.merge_contract_row_into_snapshot( + _last_snapshot, _issue_contract_patch + ) + var display := str(row.get("templateDisplayName", "")).strip_edges() + if display.is_empty(): + display = str(row.get("templateId", PROTOTYPE_CONTRACT_TEMPLATE_ID)) + _render_issue_feedback_label("Contract issue: %s issued" % display) + _render_active_label() + else: + _render_issue_feedback_label("Contract issue: issued") + else: + var rc := str(result.get("reasonCode", "")).strip_edges() + _render_issue_feedback_label(_format_issue_deny(rc)) + request_contract_refresh() + + +func _on_issue_failed(reason: String) -> void: + _render_issue_feedback_label("Contract issue: failed — %s" % reason) + + +func _on_item_definitions_ready(_definitions_by_id: Dictionary) -> void: + if not _last_reward_instance_id.is_empty(): + _render_reward_label() + + +func _mark_contract_active_in_session(instance_id: String) -> void: + var iid := instance_id.strip_edges() + if not iid.is_empty(): + _contract_was_active_in_session[iid] = true + + +func _detect_and_apply_completion_transitions(snapshot: Dictionary) -> void: + var boot_seed := _previous_status_by_instance.is_empty() + var contracts_variant: Variant = snapshot.get("contracts", null) + if contracts_variant == null or not contracts_variant is Array: + return + for row_variant in contracts_variant as Array: + if not row_variant is Dictionary: + continue + var row: Dictionary = row_variant + var iid := str(row.get("contractInstanceId", "")).strip_edges() + if iid.is_empty(): + continue + var new_status := str(row.get("status", "")) + if new_status == "active": + _mark_contract_active_in_session(iid) + if _tracked_instance_id.is_empty(): + _tracked_instance_id = iid + var prev_status := str(_previous_status_by_instance.get(iid, "")) + var can_detect := not boot_seed or bool(_contract_was_active_in_session.get(iid, false)) + if can_detect and prev_status == "active" and new_status == "completed": + var summary := ContractClient.completion_reward_summary(iid, snapshot) + if not summary.is_empty(): + _last_reward_instance_id = iid + _last_reward_summary = summary.duplicate(true) + _refresh_economy_hud() + _previous_status_by_instance[iid] = new_status + + +func _reapply_issue_contract_patch_if_needed() -> void: + if _issue_contract_patch.is_empty(): + return + _last_snapshot = ContractClient.merge_contract_row_into_snapshot( + _last_snapshot, _issue_contract_patch + ) + + +func _refresh_economy_hud() -> void: + if ( + is_instance_valid(_inventory_client) + and _inventory_client.has_method("request_sync_from_server") + ): + _inventory_client.call("request_sync_from_server") + if ( + is_instance_valid(_skill_progression_client) + and _skill_progression_client.has_method("request_sync_from_server") + ): + _skill_progression_client.call("request_sync_from_server") + + +func _render_active_label() -> void: + if not is_instance_valid(_active_label): + return + var header := "Contract:" + if not _sync_error.is_empty(): + _active_label.text = "%s\nerror — %s" % [header, _sync_error] + return + var row: Dictionary = ContractClient.active_contract_row(_last_snapshot) + if row.is_empty() and not _tracked_instance_id.is_empty(): + row = ContractClient.contract_row(_tracked_instance_id, _last_snapshot) + if row.is_empty(): + _active_label.text = "%s\n—" % header + return + var display := str(row.get("templateDisplayName", "")).strip_edges() + if display.is_empty(): + display = str(row.get("templateId", "")) + var status := str(row.get("status", "")) + var encounter := str(row.get("encounterTemplateId", "")).strip_edges() + if encounter.is_empty(): + encounter = "—" + _active_label.text = "%s\n%s: %s — objective %s" % [header, display, status, encounter] + + +func _render_issue_feedback_label(text: String = ISSUE_IDLE_HINT) -> void: + if is_instance_valid(_issue_label): + _issue_label.text = text + + +func _render_reward_label() -> void: + if not is_instance_valid(_reward_label): + return + if not _sync_error.is_empty(): + _reward_label.text = "%s\n—" % REWARD_HEADER + return + if _last_reward_instance_id.is_empty() or _last_reward_summary.is_empty(): + _reward_label.text = "%s\n—" % REWARD_HEADER + return + var row: Dictionary = ContractClient.contract_row(_last_reward_instance_id, _last_snapshot) + var display := str(row.get("templateDisplayName", "")).strip_edges() + if display.is_empty(): + display = str(row.get("templateId", "")) + var lines: PackedStringArray = [REWARD_HEADER, display] + lines.append_array(_format_reward_summary_lines(_last_reward_summary)) + _reward_label.text = "\n".join(lines) + + +func _format_issue_deny(reason_code: String) -> String: + match reason_code: + "active_contract_exists": + return "Contract issue: denied — active contract already issued" + "no_eligible_template": + return "Contract issue: denied — no eligible template (standing/band)" + "economy_cap_exceeded": + return "Contract issue: denied — economy cap exceeded" + "invalid_reward_bundle": + return "Contract issue: denied — invalid reward bundle" + "unknown_template": + return "Contract issue: denied — unknown template" + "": + return "Contract issue: denied (no reasonCode)" + _: + return "Contract issue: denied — %s" % reason_code + + +func _format_reward_summary_lines(summary: Dictionary) -> PackedStringArray: + var lines: PackedStringArray = PackedStringArray() + var items_variant: Variant = summary.get("itemGrants", null) + if items_variant is Array: + for grant_variant in items_variant as Array: + if not grant_variant is Dictionary: + continue + var grant: Dictionary = grant_variant + var item_id := str(grant.get("itemId", "")).strip_edges() + var qty: int = int(grant.get("quantity", 0)) + if item_id.is_empty() or qty <= 0: + continue + lines.append(" %s ×%d" % [_item_display_name(item_id), qty]) + var skills_variant: Variant = summary.get("skillXpGrants", null) + if skills_variant is Array: + for grant_variant in skills_variant as Array: + if not grant_variant is Dictionary: + continue + var grant: Dictionary = grant_variant + var skill_id := str(grant.get("skillId", "")).strip_edges() + var amount: int = int(grant.get("amount", 0)) + if skill_id.is_empty() or amount <= 0: + continue + lines.append(" %s +%d XP" % [_title_case_token(skill_id), amount]) + var rep_variant: Variant = summary.get("reputationGrants", null) + if rep_variant is Array: + for grant_variant in rep_variant as Array: + if not grant_variant is Dictionary: + continue + var grant: Dictionary = grant_variant + var faction_id := str(grant.get("factionId", "")).strip_edges() + var amount: int = int(grant.get("amount", 0)) + if faction_id.is_empty() or amount <= 0: + continue + lines.append(" %s +%d rep" % [FactionStandingClient.display_name_for(faction_id), amount]) + if lines.is_empty(): + lines.append(" (no grants)") + return lines + + +func _item_display_name(item_id: String) -> String: + if not is_instance_valid(_item_defs_client): + return item_id + if not _item_defs_client.has_method("display_name_for"): + return item_id + return str(_item_defs_client.call("display_name_for", item_id)) + + +static func _title_case_token(token: String) -> String: + var trimmed := token.strip_edges() + if trimmed.is_empty(): + return trimmed + return trimmed.substr(0, 1).to_upper() + trimmed.substr(1) diff --git a/client/scripts/contract_hud_controller.gd.uid b/client/scripts/contract_hud_controller.gd.uid new file mode 100644 index 0000000..e46c21d --- /dev/null +++ b/client/scripts/contract_hud_controller.gd.uid @@ -0,0 +1 @@ +uid://bneo153contract02 diff --git a/client/scripts/main.gd b/client/scripts/main.gd index 3b95e65..f542edb 100644 --- a/client/scripts/main.gd +++ b/client/scripts/main.gd @@ -57,6 +57,7 @@ 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") +const ContractHudController := preload("res://scripts/contract_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 @@ -110,6 +111,7 @@ var _gig_error: String = "" var _last_encounter_progress_snapshot: Dictionary = {} var _encounter_progress_error: String = "" var _quest_hud: Node = null +var _contract_hud: Node = null var _gather_pre_scrap_qty: int = 0 var _gather_pending_interactable_id: String = "" var _gather_awaiting_inventory_finalize: bool = false @@ -137,6 +139,9 @@ var _dev_pulse_bootstrap_attempted: bool = false @onready var _quest_progress_label: Label = _hud_root.get_node("QuestProgressLabel") @onready var _quest_accept_feedback_label: Label = _hud_root.get_node("QuestAcceptFeedbackLabel") @onready var _quest_reward_delivery_label: Label = _hud_root.get_node("QuestRewardDeliveryLabel") +@onready var _contract_active_label: Label = _hud_root.get_node("ContractActiveLabel") +@onready var _contract_issue_feedback_label: Label = _hud_root.get_node("ContractIssueFeedbackLabel") +@onready var _contract_reward_delivery_label: Label = _hud_root.get_node("ContractRewardDeliveryLabel") @onready var _faction_standing_label: Label = _hud_root.get_node("FactionStandingLabel") @onready var _npc_state_label: Label = _hud_root.get_node("NpcStateLabel") @onready var _telegraph_label: Label = _hud_root.get_node("TelegraphLabel") @@ -155,6 +160,7 @@ var _dev_pulse_bootstrap_attempted: bool = false @onready var _gig_progression_client: Node = $GigProgressionClient @onready var _encounter_progress_client: Node = $EncounterProgressClient @onready var _quest_progress_client: Node = $QuestProgressClient +@onready var _contract_client: Node = $ContractClient @onready var _quest_defs_client: Node = $QuestDefinitionsClient @onready var _faction_standing_client: Node = $FactionStandingClient @onready var _recipe_defs_client: Node = $RecipeDefinitionsClient @@ -207,6 +213,7 @@ func _ready() -> void: _setup_gig_progression_sync() _setup_encounter_progress_sync() _setup_quest_progress_sync() + _setup_contract_hud() _setup_gather_interact_feedback() _setup_craft_ui() @@ -749,6 +756,7 @@ func _on_encounter_progress_received(snapshot: Dictionary) -> void: if str(row.get("state", "")) == "completed": _request_inventory_refresh() _request_quest_progress_refresh() + _request_contract_refresh() func _on_encounter_progress_sync_failed(reason: String) -> void: @@ -885,6 +893,35 @@ func _try_quest_accept_key_input(event: InputEvent) -> bool: return false +func _setup_contract_hud() -> void: + # NEO-153: contract issue + progress HUD; boot hydrate + refresh after issue/encounter clear. + _contract_hud = ContractHudController.new() + add_child(_contract_hud) + _contract_hud.call( + "setup", + _contract_client, + _contract_active_label, + _contract_issue_feedback_label, + _contract_reward_delivery_label, + Callable(self, "_apply_authority_http_config_to_client"), + _item_defs_client, + _inventory_client, + _skill_progression_client, + _faction_standing_client + ) + + +func _request_contract_refresh() -> void: + if is_instance_valid(_contract_hud): + _contract_hud.call("request_contract_refresh") + + +func _try_contract_issue_key_input(event: InputEvent) -> bool: + if is_instance_valid(_contract_hud): + return bool(_contract_hud.call("try_issue_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 @@ -1407,6 +1444,8 @@ func _try_route_gameplay_key_input(event: InputEvent) -> bool: return true if _try_quest_accept_key_input(event): return true + if _try_contract_issue_key_input(event): + return true if _try_inventory_refresh_input(event): return true return false diff --git a/client/test/contract_client_test.gd b/client/test/contract_client_test.gd new file mode 100644 index 0000000..c64bc7c --- /dev/null +++ b/client/test/contract_client_test.gd @@ -0,0 +1,221 @@ +extends GdUnitTestSuite + +## NEO-153: `ContractClient` GET parse, issue POST, and failure signals. + +const ContractClient := preload("res://scripts/contract_client.gd") + +const INSTANCE_ID := "ci_test_instance" +const TEMPLATE_ID := "prototype_contract_clear_combat_pocket" +const ENCOUNTER_ID := "prototype_combat_pocket" + +var _contracts_capture: Dictionary = {} +var _issue_capture: Dictionary = {} +var _issue_failed_capture: String = "" + + +func _capture_contracts(snapshot: Dictionary) -> void: + _contracts_capture = snapshot + + +func _capture_issue(result: Dictionary) -> void: + _issue_capture = result + + +func _capture_issue_failed(reason: String) -> void: + _issue_failed_capture = 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 + + +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 + + +static func _active_contracts_json() -> String: + var body := ( + '{"schemaVersion":1,"playerId":"dev-local-1","contracts":' + + '[{"contractInstanceId":"%s","templateId":"%s",' + + '"templateDisplayName":"Clear Combat Pocket (Repeat)","status":"active",' + + '"encounterTemplateId":"%s","seedBucket":"prototype_contract_dev_seed",' + + '"issuedAt":"2026-06-28T12:00:00Z"}]}' + ) + return body % [INSTANCE_ID, TEMPLATE_ID, ENCOUNTER_ID] + + +static func _completed_with_summary_json() -> String: + var body := ( + '{"schemaVersion":1,"playerId":"dev-local-1","contracts":' + + '[{"contractInstanceId":"%s","templateId":"%s",' + + '"templateDisplayName":"Clear Combat Pocket (Repeat)","status":"completed",' + + '"encounterTemplateId":"%s","seedBucket":"prototype_contract_dev_seed",' + + '"issuedAt":"2026-06-28T12:00:00Z","completedAt":"2026-06-28T13:00:00Z",' + + '"completionRewardSummary":{"itemGrants":[{"itemId":"scrap_metal_bulk","quantity":5}],' + + '"skillXpGrants":[{"skillId":"salvage","amount":15}]}}]}' + ) + return body % [INSTANCE_ID, TEMPLATE_ID, ENCOUNTER_ID] + + +static func _issue_success_json() -> String: + var body := ( + '{"schemaVersion":1,"issued":true,"contract":' + + '{"contractInstanceId":"%s","templateId":"%s",' + + '"templateDisplayName":"Clear Combat Pocket (Repeat)","status":"active",' + + '"encounterTemplateId":"%s","seedBucket":"prototype_contract_dev_seed",' + + '"issuedAt":"2026-06-28T12:00:00Z"}}' + ) + return body % [INSTANCE_ID, TEMPLATE_ID, ENCOUNTER_ID] + + +static func _issue_deny_json(reason_code: String) -> String: + return '{"schemaVersion":1,"issued":false,"reasonCode":"%s"}' % reason_code + + +func _make_client(sync_transport: Node, issue_transport: Node = null) -> Node: + var c: Node = ContractClient.new() + c.set("injected_sync_http", sync_transport) + var issue_http: Node = issue_transport if issue_transport != null else sync_transport + c.set("injected_issue_http", issue_http) + auto_free(sync_transport) + if issue_transport != null and issue_transport != sync_transport: + auto_free(issue_transport) + auto_free(c) + add_child(c) + return c + + +func test_parse_active_contract_row_includes_encounter_id() -> void: + # Arrange + var json := _active_contracts_json() + # Act + var snapshot: Variant = ContractClient.parse_contracts_json(json) + # Assert + assert_that(snapshot is Dictionary).is_true() + var row: Dictionary = ContractClient.active_contract_row(snapshot as Dictionary) + assert_that(str(row.get("status", ""))).is_equal("active") + assert_that(str(row.get("encounterTemplateId", ""))).is_equal(ENCOUNTER_ID) + + +func test_parse_completed_row_has_completion_reward_summary() -> void: + # Arrange + var json := _completed_with_summary_json() + # Act + var snapshot: Variant = ContractClient.parse_contracts_json(json) + # Assert + assert_that(snapshot is Dictionary).is_true() + var summary: Dictionary = ContractClient.completion_reward_summary( + INSTANCE_ID, snapshot as Dictionary + ) + var items: Variant = summary.get("itemGrants", null) + assert_that(items is Array).is_true() + assert_that((items as Array).size()).is_equal(1) + + +func test_parse_contracts_json_rejects_schema_mismatch() -> void: + # Arrange + var json := '{"schemaVersion":99,"playerId":"dev-local-1","contracts":[]}' + # Act + var snapshot: Variant = ContractClient.parse_contracts_json(json) + # Assert + assert_that(snapshot).is_null() + + +func test_request_sync_emits_contracts_received() -> void: + # Arrange + _contracts_capture = {} + var transport := MockHttpTransport.new() + transport.body_json = _active_contracts_json() + var c := _make_client(transport, NoopHttpTransport.new()) + c.connect("contracts_received", Callable(self, "_capture_contracts")) + monitor_signals(c) + # Act + c.call("request_sync_from_server") + # Assert + await assert_signal(c).is_emitted("contracts_received", any()) + assert_that(_contracts_capture.get("playerId", "")).is_equal("dev-local-1") + assert_that(transport.last_url).contains("/contracts") + + +func test_http_404_emits_contracts_sync_failed() -> void: + # Arrange + _issue_failed_capture = "" + var transport := MockHttpTransport.new() + transport.response_code = 404 + var c := _make_client(transport, NoopHttpTransport.new()) + c.connect("contracts_sync_failed", Callable(self, "_capture_issue_failed")) + monitor_signals(c) + # Act + c.call("request_sync_from_server") + # Assert + await assert_signal(c).is_emitted("contracts_sync_failed", any()) + assert_that(_issue_failed_capture).contains("404") + + +func test_issue_success_emits_contract_issue_result_received() -> void: + # Arrange + _issue_capture = {} + var sync := NoopHttpTransport.new() + var issue := MockHttpTransport.new() + issue.body_json = _issue_success_json() + var c := _make_client(sync, issue) + c.connect("contract_issue_result_received", Callable(self, "_capture_issue")) + monitor_signals(c) + # Act + c.call("request_issue", TEMPLATE_ID, "prototype_contract_dev_seed") + # Assert + await assert_signal(c).is_emitted("contract_issue_result_received", any()) + assert_bool(_issue_capture.get("issued", false)).is_true() + assert_that(issue.last_url).contains("/contracts/issue") + assert_that(issue.last_method).is_equal(HTTPClient.METHOD_POST) + + +func test_issue_deny_emits_result_with_reason_code() -> void: + # Arrange + _issue_capture = {} + var sync := NoopHttpTransport.new() + var issue := MockHttpTransport.new() + issue.body_json = _issue_deny_json("economy_cap_exceeded") + var c := _make_client(sync, issue) + c.connect("contract_issue_result_received", Callable(self, "_capture_issue")) + monitor_signals(c) + # Act + c.call("request_issue", TEMPLATE_ID, "prototype_contract_dev_seed") + # Assert + await assert_signal(c).is_emitted("contract_issue_result_received", any()) + assert_bool(_issue_capture.get("issued", true)).is_false() + assert_that(str(_issue_capture.get("reasonCode", ""))).is_equal("economy_cap_exceeded") diff --git a/client/test/contract_client_test.gd.uid b/client/test/contract_client_test.gd.uid new file mode 100644 index 0000000..64e6141 --- /dev/null +++ b/client/test/contract_client_test.gd.uid @@ -0,0 +1 @@ +uid://bneo153ctest01 diff --git a/client/test/contract_hud_controller_test.gd b/client/test/contract_hud_controller_test.gd new file mode 100644 index 0000000..2d93398 --- /dev/null +++ b/client/test/contract_hud_controller_test.gd @@ -0,0 +1,220 @@ +extends GdUnitTestSuite + +## NEO-153: contract HUD controller issue key, active label, reward transition. + +const ContractHudController := preload("res://scripts/contract_hud_controller.gd") +const ContractClient := preload("res://scripts/contract_client.gd") + +const INSTANCE_ID := "ci_test_instance" +const ENCOUNTER_ID := "prototype_combat_pocket" + + +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 PendingIssueHttpTransport: + 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 + + +static func _active_snapshot() -> Dictionary: + return { + "schemaVersion": 1, + "playerId": "dev-local-1", + "contracts": + [ + { + "contractInstanceId": INSTANCE_ID, + "templateId": ContractHudController.PROTOTYPE_CONTRACT_TEMPLATE_ID, + "templateDisplayName": "Clear Combat Pocket (Repeat)", + "status": "active", + "encounterTemplateId": ENCOUNTER_ID, + "seedBucket": ContractHudController.PROTOTYPE_CONTRACT_SEED_BUCKET, + "issuedAt": "2026-06-28T12:00:00Z", + } + ], + } + + +static func _completed_snapshot() -> Dictionary: + return { + "schemaVersion": 1, + "playerId": "dev-local-1", + "contracts": + [ + { + "contractInstanceId": INSTANCE_ID, + "templateId": ContractHudController.PROTOTYPE_CONTRACT_TEMPLATE_ID, + "templateDisplayName": "Clear Combat Pocket (Repeat)", + "status": "completed", + "encounterTemplateId": ENCOUNTER_ID, + "seedBucket": ContractHudController.PROTOTYPE_CONTRACT_SEED_BUCKET, + "issuedAt": "2026-06-28T12:00:00Z", + "completedAt": "2026-06-28T13:00:00Z", + "completionRewardSummary": + { + "itemGrants": [{"itemId": "scrap_metal_bulk", "quantity": 5}], + "skillXpGrants": [{"skillId": "salvage", "amount": 15}], + }, + } + ], + } + + +func _make_contract_client(sync_transport: Node, issue_transport: Node = null) -> Node: + var c: Node = ContractClient.new() + c.set("injected_sync_http", sync_transport) + var issue_http: Node = issue_transport if issue_transport != null else sync_transport + c.set("injected_issue_http", issue_http) + auto_free(sync_transport) + if issue_transport != null and issue_transport != sync_transport: + auto_free(issue_transport) + auto_free(c) + add_child(c) + return c + + +func _build_controller(sync_transport: Node, issue_transport: Node) -> Dictionary: + var controller: Node = ContractHudController.new() + var contract: Node = _make_contract_client(sync_transport, issue_transport) + var active_label := Label.new() + var issue_label := Label.new() + var reward_label := Label.new() + auto_free(controller) + auto_free(active_label) + auto_free(issue_label) + auto_free(reward_label) + add_child(controller) + await get_tree().process_frame + controller.call( + "setup", + contract, + active_label, + issue_label, + reward_label, + Callable(self, "_noop_http_config") + ) + await get_tree().process_frame + return { + "controller": controller, + "contract": contract, + "active_label": active_label, + "issue_label": issue_label, + "reward_label": reward_label, + } + + +func _noop_http_config(_client: Node) -> void: + pass + + +func test_try_issue_key_input_handles_shift_c() -> void: + # Arrange + var built: Dictionary = await _build_controller( + NoopHttpTransport.new(), PendingIssueHttpTransport.new() + ) + var event := InputEventKey.new() + event.pressed = true + event.shift_pressed = true + event.keycode = KEY_C + # Act + var handled: bool = bool(built["controller"].call("try_issue_key_input", event)) + # Assert + assert_bool(handled).is_true() + assert_str(built["issue_label"].text).contains("sending") + + +func test_active_label_shows_encounter_objective_id() -> void: + # Arrange + var built: Dictionary = await _build_controller( + NoopHttpTransport.new(), NoopHttpTransport.new() + ) + # Act + built["controller"].call("_on_contracts_received", _active_snapshot()) + # Assert + assert_str(built["active_label"].text).contains(ENCOUNTER_ID) + assert_str(built["active_label"].text).contains("active") + + +func test_issue_deny_shows_economy_cap_copy() -> void: + # Arrange + var built: Dictionary = await _build_controller( + NoopHttpTransport.new(), NoopHttpTransport.new() + ) + # Act + built["controller"].call( + "_on_issue_result_received", {"issued": false, "reasonCode": "economy_cap_exceeded"} + ) + # Assert + assert_str(built["issue_label"].text).contains("economy cap exceeded") + + +func test_issue_deny_shows_active_contract_exists_copy() -> void: + # Arrange + var built: Dictionary = await _build_controller( + NoopHttpTransport.new(), NoopHttpTransport.new() + ) + # Act + built["controller"].call( + "_on_issue_result_received", {"issued": false, "reasonCode": "active_contract_exists"} + ) + # Assert + assert_str(built["issue_label"].text).contains("active contract already issued") + + +func test_reward_label_populates_on_active_to_completed_transition() -> void: + # Arrange + var built: Dictionary = await _build_controller( + NoopHttpTransport.new(), NoopHttpTransport.new() + ) + built["controller"].call("_on_contracts_received", _active_snapshot()) + # Act + built["controller"].call("_on_contracts_received", _completed_snapshot()) + # Assert + assert_str(built["reward_label"].text).contains("Contract rewards:") + assert_str(built["reward_label"].text).contains("Salvage +15 XP") diff --git a/client/test/contract_hud_controller_test.gd.uid b/client/test/contract_hud_controller_test.gd.uid new file mode 100644 index 0000000..3ed294d --- /dev/null +++ b/client/test/contract_hud_controller_test.gd.uid @@ -0,0 +1 @@ +uid://bneo153ctest02 diff --git a/docs/manual-qa/NEO-153.md b/docs/manual-qa/NEO-153.md new file mode 100644 index 0000000..041d3e4 --- /dev/null +++ b/docs/manual-qa/NEO-153.md @@ -0,0 +1,46 @@ +# NEO-153 — Manual QA checklist + +| Field | Value | +|-------|-------| +| Key | NEO-153 | +| Title | E7M4-10: Client contract issue + progress HUD (Godot) | +| Linear | https://linear.app/neon-sprawl/issue/NEO-153/e7m4-10-client-contract-issue-progress-hud-godot | +| Plan | `docs/plans/NEO-153-implementation-plan.md` | +| Branch | `NEO-153-e7m4-10-client-contract-issue-progress-hud-godot` | + +## Preconditions + +- **Fresh dev player:** restart server so contract instance state is clean (or use dev fixture reset if a prior contract exists). +- **Server deps:** NEO-151 **`POST …/contracts/issue`** and **`GET …/contracts`** on `main`. +- **No Bruno/curl** for this checklist — issue and progress use Godot **Shift+C** and combat pocket clear only. + +## Expected contract HUD progression + +| After | `ContractActiveLabel` | `ContractIssueFeedbackLabel` | `ContractRewardDeliveryLabel` | +|-------|----------------------|------------------------------|-------------------------------| +| Boot | **`Contract: —`** | **`Contract issue: — (Shift+C prototype)`** | **`Contract rewards: —`** | +| **Shift+C** issue | **`Clear Combat Pocket (Repeat): active — objective prototype_combat_pocket`** | **`Contract issue: Clear Combat Pocket (Repeat) issued`** | **`—`** | +| Defeat ×3 NPCs (combat pocket) | **`…: completed — objective prototype_combat_pocket`** | unchanged | **`scrap_metal_bulk ×5`** + **`Salvage +15 XP`** (display names when item defs loaded) | +| Duplicate **Shift+C** while active | unchanged active line | **`Contract issue: denied — active contract already issued`** | unchanged | + +## Checklist + +1. Start server: `cd server/NeonSprawl.Server && dotnet run`. +2. Run Godot main scene (**F5**). Confirm contract labels appear in **`HudRootScroll`** below quest reward labels. +3. Press **Shift+C**. Verify **`ContractActiveLabel`** shows **`active`** with **`prototype_combat_pocket`** objective id and issue feedback shows success. +4. Defeat all three combat pocket NPCs (hotbar casts). Verify encounter **`completed (3/3)`** and contract GET refresh shows **`completed`** on **`ContractActiveLabel`**. +5. Verify **`ContractRewardDeliveryLabel`** lists contract completion grants (**`scrap_metal_bulk ×5`**, **`Salvage +15 XP`**) after pocket clear — not on boot alone. +6. Press **Shift+C** again while contract still active (before clear) or after issuing once — verify readable deny when **`active_contract_exists`**. +7. Optional: stop server while Godot running, press **Shift+C** — verify **`Contract: error — …`** or **`Contract issue: failed — …`** on HUD. + +## Notes + +- Prototype flow is **issue then clear** the bound encounter. Issuing after the pocket is already cleared leaves the contract **`active`** until a future matching clear (NEO-149 v1 edge case). +- Fixed seed **`prototype_contract_dev_seed`** — repeat re-issue after complete requires **NEO-154** capstone (seed rotation / fixture reset). +- Encounter + quest HUD regression: [NEO-110 manual QA](NEO-110.md), [NEO-122 manual QA](NEO-122.md). + +## Acceptance + +- [ ] Steps 1–5 completable in one session without Bruno/curl. +- [ ] Active contract shows encounter objective id after **Shift+C** issue. +- [ ] Completed contract shows reward summary line on HUD after pocket clear. diff --git a/docs/plans/NEO-153-implementation-plan.md b/docs/plans/NEO-153-implementation-plan.md index 7e4f666..0d392b8 100644 --- a/docs/plans/NEO-153-implementation-plan.md +++ b/docs/plans/NEO-153-implementation-plan.md @@ -52,8 +52,16 @@ Player can **issue** and **track** an active contract in Godot without Bruno — ## Acceptance criteria checklist -- [ ] Active contract visible with encounter objective id after issue. -- [ ] Completed contract shows reward summary line on HUD. +- [x] Active contract visible with encounter objective id after issue. +- [x] Completed contract shows reward summary line on HUD. + +## Implementation reconciliation (shipped) + +- **`contract_client.gd`:** GET/POST parse v1; **`active_contract_row`**, **`completion_reward_summary`**; separate sync/issue HTTP guards. +- **`contract_hud_controller.gd`:** **Shift+C** prototype issue; active/completed label with **`encounterTemplateId`**; deny copy; in-session reward transition + economy refresh. +- **`main.tscn` / `main.gd`:** **`ContractClient`** node + three HUD labels; encounter-complete contract refresh hook. +- **Tests:** `contract_client_test.gd` (7 cases), `contract_hud_controller_test.gd` (5 cases) — GdUnit green. +- **Docs:** `client/README.md` contract HUD section; `docs/manual-qa/NEO-153.md`. ## Technical approach