From 01741fed0715b6a45f1a7fbf5b36dc24e9046b3d Mon Sep 17 00:00:00 2001 From: VinPropane Date: Mon, 25 May 2026 13:59:29 -0400 Subject: [PATCH] NEO-86: Wire gig progression client and defeat HUD refresh. Adds GigProgressionClient, GigXpLabel in economy HUD, and event-driven GET refresh when cast accept carries targetDefeated; GdUnit coverage. --- client/scenes/main.tscn | 16 +++ client/scripts/gig_progression_client.gd | 105 +++++++++++++++++++ client/scripts/gig_progression_client.gd.uid | 1 + client/scripts/main.gd | 71 +++++++++++++ client/test/gig_feedback_refresh_test.gd | 103 ++++++++++++++++++ client/test/gig_progression_client_test.gd | 94 +++++++++++++++++ 6 files changed, 390 insertions(+) create mode 100644 client/scripts/gig_progression_client.gd create mode 100644 client/scripts/gig_progression_client.gd.uid create mode 100644 client/test/gig_feedback_refresh_test.gd create mode 100644 client/test/gig_progression_client_test.gd diff --git a/client/scenes/main.tscn b/client/scenes/main.tscn index e87f11d..2489b1c 100644 --- a/client/scenes/main.tscn +++ b/client/scenes/main.tscn @@ -19,6 +19,7 @@ [ext_resource type="Script" path="res://scripts/craft_client.gd" id="18_craft"] [ext_resource type="Script" path="res://scripts/craft_recipe_panel.gd" id="19_craft_panel"] [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"] [sub_resource type="NavigationMesh" id="NavigationMesh_district"] geometry_collision_mask = 1 @@ -1105,6 +1106,9 @@ script = ExtResource("15_inventory") [node name="SkillProgressionClient" type="Node" parent="." unique_id=2500008] script = ExtResource("16_skill_prog") +[node name="GigProgressionClient" type="Node" parent="." unique_id=2500011] +script = ExtResource("21_gig_prog") + [node name="RecipeDefinitionsClient" type="Node" parent="." unique_id=2500009] script = ExtResource("17_recipe_defs") @@ -1269,6 +1273,18 @@ autowrap_mode = 3 text = "Skills: Loading…" +[node name="GigXpLabel" type="Label" parent="UICanvas/EconomyHudSection/Body" unique_id=9000018] +custom_minimum_size = Vector2(512, 48) +layout_mode = 2 +size_flags_horizontal = 3 +theme_override_colors/font_color = Color(0.88, 0.78, 1, 1) +theme_override_colors/font_outline_color = Color(0.08, 0.08, 0.1, 1) +theme_override_constants/outline_size = 6 +theme_override_font_sizes/font_size = 14 +autowrap_mode = 3 +text = "Gig XP: +Loading…" + [node name="CraftRecipePanel" type="Control" parent="UICanvas/EconomyHudSection/Body" unique_id=9000011] custom_minimum_size = Vector2(512, 280) layout_mode = 2 diff --git a/client/scripts/gig_progression_client.gd b/client/scripts/gig_progression_client.gd new file mode 100644 index 0000000..f0b2736 --- /dev/null +++ b/client/scripts/gig_progression_client.gd @@ -0,0 +1,105 @@ +extends Node + +## NEO-86: prototype HTTP client for [code]GET /game/players/{id}/gig-progression[/code] (NEO-44). + +signal progression_received(snapshot: Dictionary) +signal progression_sync_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_http: Node = null + +var _http: Node +var _busy: bool = false + + +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/players/%s/gig-progression" % [_base_root(), _player_path_segment()] + var err: Error = _http.request(url) + if err != OK: + var reason := "GET failed to start (%s)" % err + push_warning("GigProgressionClient: %s" % reason) + _busy = false + progression_sync_failed.emit(reason) + + +func gig_row(gig_id: String, snapshot: Dictionary = {}) -> Dictionary: + var gigs: Variant = snapshot.get("gigs", null) + if gigs == null or not gigs is Array: + return {} + for row_variant in gigs as Array: + if not row_variant is Dictionary: + continue + var row: Dictionary = row_variant + if str(row.get("id", "")) == gig_id: + return row + return {} + + +func _base_root() -> String: + return base_url.strip_edges().rstrip("/") + + +func _player_path_segment() -> String: + return dev_player_id.strip_edges() + + +static func parse_progression_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 gigs: Variant = root.get("gigs", null) + if gigs == null or not gigs is Array: + return null + if str(root.get("mainGigId", "")).strip_edges().is_empty(): + return null + return root + + +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("GigProgressionClient: %s" % reason) + progression_sync_failed.emit(reason) + return + if response_code == 404: + var reason404 := "HTTP 404 (player unknown)" + push_warning("GigProgressionClient: %s" % reason404) + progression_sync_failed.emit(reason404) + return + if response_code < 200 or response_code >= 300: + var reason_code := "HTTP %s" % response_code + push_warning("GigProgressionClient: %s" % reason_code) + progression_sync_failed.emit(reason_code) + return + var text := body.get_string_from_utf8() + var snapshot: Variant = parse_progression_json(text) + if snapshot == null: + var reason_json := "non-JSON body or schemaVersion mismatch" + push_warning("GigProgressionClient: %s" % reason_json) + progression_sync_failed.emit(reason_json) + return + progression_received.emit(snapshot as Dictionary) diff --git a/client/scripts/gig_progression_client.gd.uid b/client/scripts/gig_progression_client.gd.uid new file mode 100644 index 0000000..6bfd63c --- /dev/null +++ b/client/scripts/gig_progression_client.gd.uid @@ -0,0 +1 @@ +uid://bneo86gigprog01 diff --git a/client/scripts/main.gd b/client/scripts/main.gd index 6ca5e80..8301d0b 100644 --- a/client/scripts/main.gd +++ b/client/scripts/main.gd @@ -47,6 +47,7 @@ const DEV_BOOTSTRAP_CAST_ABILITY_ID := "prototype_pulse" const SCRAP_METAL_BULK_ID := "scrap_metal_bulk" const SALVAGE_SKILL_ID := "salvage" const REFINE_SKILL_ID := "refine" +const BREACH_GIG_ID := "breach" ## Bump on each rejection so older one-shot timers do not clear a newer message. var _move_reject_msg_token: int = 0 @@ -86,6 +87,8 @@ var _inventory_error: String = "" var _interactables_catalog: Array = [] var _last_progression_snapshot: Dictionary = {} var _progression_error: String = "" +var _last_gig_snapshot: Dictionary = {} +var _gig_error: String = "" var _gather_pre_scrap_qty: int = 0 var _gather_pending_interactable_id: String = "" var _gather_awaiting_inventory_finalize: bool = false @@ -113,10 +116,12 @@ var _dev_pulse_bootstrap_attempted: bool = false @onready var _craft_feedback_label: Label = $UICanvas/CraftFeedbackLabel @onready var _skill_progression_label: Label = _economy_hud_section.get_node("Body/SkillProgressionLabel") +@onready var _gig_xp_label: Label = _economy_hud_section.get_node("Body/GigXpLabel") @onready var _craft_recipe_panel: Node = _economy_hud_section.get_node("Body/CraftRecipePanel") @onready var _inventory_client: Node = $InventoryClient @onready var _item_defs_client: Node = $ItemDefinitionsClient @onready var _skill_progression_client: Node = $SkillProgressionClient +@onready var _gig_progression_client: Node = $GigProgressionClient @onready var _recipe_defs_client: Node = $RecipeDefinitionsClient @onready var _craft_client: Node = $CraftClient @onready var _target_client: Node = $TargetSelectionClient @@ -163,6 +168,7 @@ func _ready() -> void: _setup_hotbar_loadout_sync() _setup_inventory_sync() _setup_skill_progression_sync() + _setup_gig_progression_sync() _setup_gather_interact_feedback() _setup_craft_ui() @@ -501,6 +507,22 @@ func _setup_skill_progression_sync() -> void: _skill_progression_client.call("request_sync_from_server") +func _setup_gig_progression_sync() -> void: + # NEO-86: breach gig XP HUD row; boot hydrate + refresh after defeat cast. + _apply_authority_http_config_to_client(_gig_progression_client) + if _gig_progression_client.has_signal("progression_received"): + _gig_progression_client.connect( + "progression_received", Callable(self, "_on_gig_progression_received") + ) + if _gig_progression_client.has_signal("progression_sync_failed"): + _gig_progression_client.connect( + "progression_sync_failed", Callable(self, "_on_gig_progression_sync_failed") + ) + _render_gig_xp_label() + if _gig_progression_client.has_method("request_sync_from_server"): + _gig_progression_client.call("request_sync_from_server") + + func _setup_gather_interact_feedback() -> void: if not is_instance_valid(_interaction_client): return @@ -564,6 +586,53 @@ func _skill_row(skill_id: String) -> Dictionary: return row as Dictionary +func _on_gig_progression_received(snapshot: Dictionary) -> void: + _gig_error = "" + _last_gig_snapshot = snapshot.duplicate(true) + _render_gig_xp_label() + + +func _on_gig_progression_sync_failed(reason: String) -> void: + _gig_error = reason + _last_gig_snapshot = {} + _render_gig_xp_label() + + +func _render_gig_xp_label() -> void: + if not is_instance_valid(_gig_xp_label): + return + var header := "Gig XP:" + if not _gig_error.is_empty(): + _gig_xp_label.text = "%s\nerror — %s" % [header, _gig_error] + return + if _last_gig_snapshot.is_empty(): + _gig_xp_label.text = "%s\nLoading…" % header + return + var breach_row: Dictionary = _gig_row(BREACH_GIG_ID) + if breach_row.is_empty(): + _gig_xp_label.text = "%s\nbreach: — (missing row)" % header + return + var level: int = int(breach_row.get("level", 0)) + var xp: int = int(breach_row.get("xp", 0)) + _gig_xp_label.text = "%s\nbreach: L%d · %d xp" % [header, level, xp] + + +func _gig_row(gig_id: String) -> Dictionary: + if not is_instance_valid(_gig_progression_client): + return {} + if not _gig_progression_client.has_method("gig_row"): + return {} + var row: Variant = _gig_progression_client.call("gig_row", gig_id, _last_gig_snapshot) + return row as Dictionary + + +func _request_gig_progression_refresh() -> void: + if not is_instance_valid(_gig_progression_client): + return + if _gig_progression_client.has_method("request_sync_from_server"): + _gig_progression_client.call("request_sync_from_server") + + func _render_gather_feedback_label(text: String = "Gather: —") -> void: if is_instance_valid(_gather_feedback_label): _gather_feedback_label.text = text @@ -859,6 +928,8 @@ func _on_cast_result_received(accepted: bool, reason_code: String, resolution: D if _cooldown_client.has_method("request_sync_from_server"): _cooldown_client.call("request_sync_from_server") _request_combat_targets_refresh() + if bool(resolution.get("targetDefeated", false)): + _request_gig_progression_refresh() return var rc := reason_code.strip_edges() if rc.is_empty(): diff --git a/client/test/gig_feedback_refresh_test.gd b/client/test/gig_feedback_refresh_test.gd new file mode 100644 index 0000000..3e35a24 --- /dev/null +++ b/client/test/gig_feedback_refresh_test.gd @@ -0,0 +1,103 @@ +extends GdUnitTestSuite + +## NEO-86: defeat cast accept triggers gig-progression GET refresh only. + +const CastClient := preload("res://scripts/ability_cast_client.gd") + + +class MockCastTransport: + extends Node + signal request_completed( + result: int, response_code: int, headers: PackedStringArray, body: PackedByteArray + ) + var body_json: String = "" + + func request( + _url: String, + _custom_headers: PackedStringArray = PackedStringArray(), + _method: HTTPClient.Method = HTTPClient.METHOD_POST, + _request_data: String = "" + ) -> Error: + request_completed.emit( + HTTPRequest.RESULT_SUCCESS, 200, PackedStringArray(), body_json.to_utf8_buffer() + ) + return OK + + +class SpyGigProgressionClient: + extends Node + var sync_calls: int = 0 + + func request_sync_from_server() -> void: + sync_calls += 1 + + +class GigRefreshHarness: + extends Node + var gig_sync_calls: int = 0 + var _cast: Node + var _gig: SpyGigProgressionClient + + func setup(parent: Node, cast_transport: Node) -> void: + _gig = SpyGigProgressionClient.new() + _cast = CastClient.new() + _cast.set("injected_http", cast_transport) + parent.add_child(_cast) + parent.add_child(_gig) + _cast.connect("cast_result_received", Callable(self, "_on_cast_result")) + + func _on_cast_result(accepted: bool, _reason_code: String, resolution: Dictionary) -> void: + if not accepted: + return + if bool(resolution.get("targetDefeated", false)): + _request_gig_progression_refresh() + + func _request_gig_progression_refresh() -> void: + gig_sync_calls += 1 + _gig.request_sync_from_server() + + func request_cast() -> void: + _cast.call("request_cast", 0, "prototype_pulse", "prototype_target_alpha") + + +func test_defeat_cast_triggers_gig_progression_sync() -> void: + # Arrange + var transport := MockCastTransport.new() + transport.body_json = ( + '{"schemaVersion":1,"accepted":true,"combatResolution":' + + '{"abilityId":"prototype_pulse","targetId":"prototype_target_alpha",' + + '"damageDealt":25,"targetRemainingHp":0,"targetDefeated":true}}' + ) + var harness := GigRefreshHarness.new() + auto_free(transport) + auto_free(harness) + add_child(harness) + harness.setup(self, transport) + monitor_signals(harness._cast) + # Act + harness.request_cast() + # Assert + await assert_signal(harness._cast).is_emitted("cast_result_received", true, "", any()) + assert_that(harness.gig_sync_calls).is_equal(1) + assert_that(harness._gig.sync_calls).is_equal(1) + + +func test_non_defeat_cast_skips_gig_progression_sync() -> void: + # Arrange + var transport := MockCastTransport.new() + transport.body_json = ( + '{"schemaVersion":1,"accepted":true,"combatResolution":' + + '{"abilityId":"prototype_pulse","targetId":"prototype_target_alpha",' + + '"damageDealt":25,"targetRemainingHp":75,"targetDefeated":false}}' + ) + var harness := GigRefreshHarness.new() + auto_free(transport) + auto_free(harness) + add_child(harness) + harness.setup(self, transport) + monitor_signals(harness._cast) + # Act + harness.request_cast() + # Assert + await assert_signal(harness._cast).is_emitted("cast_result_received", true, "", any()) + assert_that(harness.gig_sync_calls).is_equal(0) diff --git a/client/test/gig_progression_client_test.gd b/client/test/gig_progression_client_test.gd new file mode 100644 index 0000000..4b040ae --- /dev/null +++ b/client/test/gig_progression_client_test.gd @@ -0,0 +1,94 @@ +extends GdUnitTestSuite + +## NEO-86: `GigProgressionClient` GET parse + failure signals. + +const GigProgressionClient := preload("res://scripts/gig_progression_client.gd") + +var _progression_capture: Dictionary = {} + + +func _capture_progression(snapshot: Dictionary) -> void: + _progression_capture = snapshot + + +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 _breach_progression_json() -> String: + return ( + '{"schemaVersion":1,"playerId":"dev-local-1","mainGigId":"breach",' + + '"gigs":[{"id":"breach","xp":25,"level":1}]}' + ) + + +func _make_client(transport: Node) -> Node: + var c: Node = GigProgressionClient.new() + c.set("injected_http", transport) + auto_free(transport) + auto_free(c) + add_child(c) + return c + + +func test_parse_progression_json_reads_breach_row() -> void: + # Arrange + var json := _breach_progression_json() + # Act + var snapshot: Variant = GigProgressionClient.parse_progression_json(json) + # Assert + assert_that(snapshot is Dictionary).is_true() + var c: Node = GigProgressionClient.new() + auto_free(c) + var row: Dictionary = c.call("gig_row", "breach", snapshot as Dictionary) + assert_that(int(row.get("xp", -1))).is_equal(25) + assert_that(int(row.get("level", -1))).is_equal(1) + + +func test_request_sync_emits_progression_received() -> void: + # Arrange + _progression_capture = {} + var transport := MockHttpTransport.new() + transport.body_json = _breach_progression_json() + var c := _make_client(transport) + c.connect("progression_received", Callable(self, "_capture_progression")) + monitor_signals(c) + # Act + c.call("request_sync_from_server") + # Assert + await assert_signal(c).is_emitted("progression_received", any()) + assert_that(_progression_capture.get("mainGigId", "")).is_equal("breach") + assert_that(transport.last_url).contains("/gig-progression") + + +func test_http_404_emits_progression_sync_failed() -> void: + # Arrange + var transport := MockHttpTransport.new() + transport.response_code = 404 + var c := _make_client(transport) + monitor_signals(c) + # Act + c.call("request_sync_from_server") + # Assert + await assert_signal(c).is_emitted("progression_sync_failed", "HTTP 404 (player unknown)")