diff --git a/client/scenes/main.tscn b/client/scenes/main.tscn index 7237929..8380733 100644 --- a/client/scenes/main.tscn +++ b/client/scenes/main.tscn @@ -20,6 +20,7 @@ [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"] +[ext_resource type="Script" path="res://scripts/encounter_progress_client.gd" id="24_enc_prog"] [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"] @@ -1114,6 +1115,9 @@ script = ExtResource("16_skill_prog") [node name="GigProgressionClient" type="Node" parent="." unique_id=2500011] script = ExtResource("21_gig_prog") +[node name="EncounterProgressClient" type="Node" parent="." unique_id=2500012] +script = ExtResource("24_enc_prog") + [node name="RecipeDefinitionsClient" type="Node" parent="." unique_id=2500009] script = ExtResource("17_recipe_defs") @@ -1199,6 +1203,27 @@ theme_override_font_sizes/font_size = 22 autowrap_mode = 3 text = "Player HP: —" +[node name="EncounterProgressLabel" type="Label" parent="UICanvas/HudRoot" unique_id=9000023] +layout_mode = 2 +size_flags_horizontal = 3 +theme_override_colors/font_color = Color(0.9, 0.82, 0.72, 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 = "Encounter: +Loading…" + +[node name="EncounterCompleteLabel" type="Label" parent="UICanvas/HudRoot" unique_id=9000024] +layout_mode = 2 +size_flags_horizontal = 3 +theme_override_colors/font_color = Color(0.95, 0.9, 0.65, 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 = "Loot: —" + [node name="NpcStateLabel" type="Label" parent="UICanvas/HudRoot" unique_id=9000020] layout_mode = 2 size_flags_horizontal = 3 diff --git a/client/scripts/encounter_progress_client.gd b/client/scripts/encounter_progress_client.gd new file mode 100644 index 0000000..e720722 --- /dev/null +++ b/client/scripts/encounter_progress_client.gd @@ -0,0 +1,105 @@ +extends Node + +## NEO-110: prototype HTTP client for [code]GET /game/players/{id}/encounter-progress[/code] (NEO-108). + +signal encounter_progress_received(snapshot: Dictionary) +signal encounter_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/encounter-progress" % [_base_root(), _player_path_segment()] + var err: Error = _http.request(url) + if err != OK: + var reason := "GET failed to start (%s)" % err + push_warning("EncounterProgressClient: %s" % reason) + _busy = false + encounter_sync_failed.emit(reason) + + +func encounter_row(encounter_id: String, snapshot: Dictionary = {}) -> Dictionary: + var encounters: Variant = snapshot.get("encounters", null) + if encounters == null or not encounters is Array: + return {} + for row_variant in encounters as Array: + if not row_variant is Dictionary: + continue + var row: Dictionary = row_variant + if str(row.get("encounterId", "")) == encounter_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_encounter_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 encounters: Variant = root.get("encounters", null) + if encounters == null or not encounters is Array: + return null + if str(root.get("playerId", "")).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("EncounterProgressClient: %s" % reason) + encounter_sync_failed.emit(reason) + return + if response_code == 404: + var reason404 := "HTTP 404 (player unknown)" + push_warning("EncounterProgressClient: %s" % reason404) + encounter_sync_failed.emit(reason404) + return + if response_code < 200 or response_code >= 300: + var reason_code := "HTTP %s" % response_code + push_warning("EncounterProgressClient: %s" % reason_code) + encounter_sync_failed.emit(reason_code) + return + var text := body.get_string_from_utf8() + var snapshot: Variant = parse_encounter_progress_json(text) + if snapshot == null: + var reason_json := "non-JSON body or schemaVersion mismatch" + push_warning("EncounterProgressClient: %s" % reason_json) + encounter_sync_failed.emit(reason_json) + return + encounter_progress_received.emit(snapshot as Dictionary) diff --git a/client/scripts/encounter_progress_client.gd.uid b/client/scripts/encounter_progress_client.gd.uid new file mode 100644 index 0000000..64d48f6 --- /dev/null +++ b/client/scripts/encounter_progress_client.gd.uid @@ -0,0 +1 @@ +uid://bneo110encprog01 diff --git a/client/scripts/main.gd b/client/scripts/main.gd index e0c8d64..ad9a7c1 100644 --- a/client/scripts/main.gd +++ b/client/scripts/main.gd @@ -54,6 +54,8 @@ const SCRAP_METAL_BULK_ID := "scrap_metal_bulk" const SALVAGE_SKILL_ID := "salvage" const REFINE_SKILL_ID := "refine" const BREACH_GIG_ID := "breach" +const PROTOTYPE_ENCOUNTER_ID := "prototype_combat_pocket" +const PROTOTYPE_ENCOUNTER_REQUIRED_TARGETS := 3 ## Bump on each rejection so older one-shot timers do not clear a newer message. var _move_reject_msg_token: int = 0 @@ -104,6 +106,8 @@ var _last_progression_snapshot: Dictionary = {} var _progression_error: String = "" var _last_gig_snapshot: Dictionary = {} var _gig_error: String = "" +var _last_encounter_progress_snapshot: Dictionary = {} +var _encounter_progress_error: String = "" var _gather_pre_scrap_qty: int = 0 var _gather_pending_interactable_id: String = "" var _gather_awaiting_inventory_finalize: bool = false @@ -126,6 +130,8 @@ var _dev_pulse_bootstrap_attempted: bool = false @onready var _cast_feedback_label: Label = $UICanvas/HudRoot/CastFeedbackLabel @onready var _combat_target_hp_label: Label = $UICanvas/HudRoot/CombatTargetHpLabel @onready var _player_combat_hp_label: Label = $UICanvas/HudRoot/PlayerCombatHpLabel +@onready var _encounter_progress_label: Label = $UICanvas/HudRoot/EncounterProgressLabel +@onready var _encounter_complete_label: Label = $UICanvas/HudRoot/EncounterCompleteLabel @onready var _npc_state_label: Label = $UICanvas/HudRoot/NpcStateLabel @onready var _telegraph_label: Label = $UICanvas/HudRoot/TelegraphLabel @onready var _cooldown_slots_label: Label = $UICanvas/HudRoot/CooldownSlotsLabel @@ -141,6 +147,7 @@ var _skill_progression_label: Label = _economy_hud_section.get_node("Body/SkillP @onready var _item_defs_client: Node = $ItemDefinitionsClient @onready var _skill_progression_client: Node = $SkillProgressionClient @onready var _gig_progression_client: Node = $GigProgressionClient +@onready var _encounter_progress_client: Node = $EncounterProgressClient @onready var _recipe_defs_client: Node = $RecipeDefinitionsClient @onready var _craft_client: Node = $CraftClient @onready var _target_client: Node = $TargetSelectionClient @@ -189,6 +196,7 @@ func _ready() -> void: _setup_inventory_sync() _setup_skill_progression_sync() _setup_gig_progression_sync() + _setup_encounter_progress_sync() _setup_gather_interact_feedback() _setup_craft_ui() @@ -475,6 +483,10 @@ func _on_item_definitions_ready(_definitions_by_id: Dictionary) -> void: return if not _last_inventory_snapshot.is_empty(): _render_inventory_label() + if not _encounter_progress_error.is_empty(): + return + if not _last_encounter_progress_snapshot.is_empty(): + _render_encounter_progress_labels() func _on_inventory_received(snapshot: Dictionary) -> void: @@ -705,6 +717,131 @@ func _request_gig_progression_refresh() -> void: _gig_progression_client.call("request_sync_from_server") +func _setup_encounter_progress_sync() -> void: + # NEO-110: encounter progress + loot HUD; boot hydrate + refresh after NPC defeat cast. + _apply_authority_http_config_to_client(_encounter_progress_client) + if _encounter_progress_client.has_signal("encounter_progress_received"): + _encounter_progress_client.connect( + "encounter_progress_received", Callable(self, "_on_encounter_progress_received") + ) + if _encounter_progress_client.has_signal("encounter_sync_failed"): + _encounter_progress_client.connect( + "encounter_sync_failed", Callable(self, "_on_encounter_progress_sync_failed") + ) + _render_encounter_progress_labels() + if _encounter_progress_client.has_method("request_sync_from_server"): + _encounter_progress_client.call("request_sync_from_server") + + +func _on_encounter_progress_received(snapshot: Dictionary) -> void: + _encounter_progress_error = "" + _last_encounter_progress_snapshot = snapshot.duplicate(true) + _render_encounter_progress_labels() + var row: Dictionary = _encounter_row(PROTOTYPE_ENCOUNTER_ID) + if str(row.get("state", "")) == "completed": + _request_inventory_refresh() + + +func _on_encounter_progress_sync_failed(reason: String) -> void: + _encounter_progress_error = reason + _last_encounter_progress_snapshot = {} + _render_encounter_progress_labels() + + +func _render_encounter_progress_labels() -> void: + _render_encounter_progress_label() + _render_encounter_complete_label() + + +func _render_encounter_progress_label() -> void: + if not is_instance_valid(_encounter_progress_label): + return + var header := "Encounter:" + if not _encounter_progress_error.is_empty(): + _encounter_progress_label.text = "%s\nerror — %s" % [header, _encounter_progress_error] + return + if _last_encounter_progress_snapshot.is_empty(): + _encounter_progress_label.text = "%s\nLoading…" % header + return + var row: Dictionary = _encounter_row(PROTOTYPE_ENCOUNTER_ID) + if row.is_empty(): + _encounter_progress_label.text = ( + "%s\n%s: — (missing row)" % [header, PROTOTYPE_ENCOUNTER_ID] + ) + return + var state: String = str(row.get("state", "")) + var body: String = "" + match state: + "inactive": + body = "%s: not started (0/%d)" % [ + PROTOTYPE_ENCOUNTER_ID, PROTOTYPE_ENCOUNTER_REQUIRED_TARGETS + ] + "active": + var defeated: Variant = row.get("defeatedTargetIds", []) + var n: int = defeated.size() if defeated is Array else 0 + body = "%s: %d/%d" % [PROTOTYPE_ENCOUNTER_ID, n, PROTOTYPE_ENCOUNTER_REQUIRED_TARGETS] + "completed": + body = "%s: completed (%d/%d)" % [ + PROTOTYPE_ENCOUNTER_ID, + PROTOTYPE_ENCOUNTER_REQUIRED_TARGETS, + PROTOTYPE_ENCOUNTER_REQUIRED_TARGETS, + ] + _: + body = "%s: unknown state (%s)" % [PROTOTYPE_ENCOUNTER_ID, state] + _encounter_progress_label.text = "%s\n%s" % [header, body] + + +func _render_encounter_complete_label() -> void: + if not is_instance_valid(_encounter_complete_label): + return + var header := "Loot:" + if not _encounter_progress_error.is_empty(): + _encounter_complete_label.text = "%s\n—" % header + return + if _last_encounter_progress_snapshot.is_empty(): + _encounter_complete_label.text = "%s\n—" % header + return + var row: Dictionary = _encounter_row(PROTOTYPE_ENCOUNTER_ID) + if row.is_empty() or str(row.get("state", "")) != "completed": + _encounter_complete_label.text = "%s\n—" % header + return + var lines: PackedStringArray = [header] + var completed_at: String = str(row.get("completedAt", "")).strip_edges() + if not completed_at.is_empty(): + lines[0] = "%s (completed %s)" % [header, completed_at] + var grants: Variant = row.get("rewardGrantSummary", []) + if grants is Array: + for grant_variant in grants as Array: + if not grant_variant is Dictionary: + continue + var grant: Dictionary = grant_variant + var item_id: String = str(grant.get("itemId", "")) + var qty: int = int(grant.get("quantity", 0)) + var label: String = _item_display_name(item_id) if not item_id.is_empty() else "?" + lines.append(" %s ×%d" % [label, qty]) + if lines.size() == 1: + lines.append(" (no grants)") + _encounter_complete_label.text = "\n".join(lines) + + +func _encounter_row(encounter_id: String) -> Dictionary: + if not is_instance_valid(_encounter_progress_client): + return {} + if not _encounter_progress_client.has_method("encounter_row"): + return {} + var row: Variant = _encounter_progress_client.call( + "encounter_row", encounter_id, _last_encounter_progress_snapshot + ) + return row as Dictionary + + +func _request_encounter_progress_refresh() -> void: + if not is_instance_valid(_encounter_progress_client): + return + if _encounter_progress_client.has_method("request_sync_from_server"): + _encounter_progress_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 @@ -1005,6 +1142,7 @@ func _on_cast_result_received(accepted: bool, reason_code: String, resolution: D _request_npc_combat_sync() if bool(resolution.get("targetDefeated", false)): _request_gig_progression_refresh() + _request_encounter_progress_refresh() return var rc := reason_code.strip_edges() if rc.is_empty(): diff --git a/client/test/encounter_progress_client_test.gd b/client/test/encounter_progress_client_test.gd new file mode 100644 index 0000000..2d8845d --- /dev/null +++ b/client/test/encounter_progress_client_test.gd @@ -0,0 +1,151 @@ +extends GdUnitTestSuite + +## NEO-110: `EncounterProgressClient` GET parse + failure signals. + +const EncounterProgressClient := preload("res://scripts/encounter_progress_client.gd") + +const PROTOTYPE_ENCOUNTER_ID := "prototype_combat_pocket" + +var _progress_capture: Dictionary = {} + + +func _capture_progress(snapshot: Dictionary) -> void: + _progress_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 _inactive_progression_json() -> String: + return ( + '{"schemaVersion":1,"playerId":"dev-local-1","encounters":' + + '[{"encounterId":"prototype_combat_pocket","state":"inactive","defeatedTargetIds":[]}]}' + ) + + +static func _active_two_defeats_json() -> String: + return ( + '{"schemaVersion":1,"playerId":"dev-local-1","encounters":' + + '[{"encounterId":"prototype_combat_pocket","state":"active",' + + '"defeatedTargetIds":["prototype_npc_melee","prototype_npc_ranged"]}]}' + ) + + +static func _completed_with_grants_json() -> String: + return ( + '{"schemaVersion":1,"playerId":"dev-local-1","encounters":' + + '[{"encounterId":"prototype_combat_pocket","state":"completed",' + + '"defeatedTargetIds":["prototype_npc_elite","prototype_npc_melee","prototype_npc_ranged"],' + + '"completedAt":"2026-05-31T12:00:00Z",' + + '"rewardGrantSummary":[{"itemId":"scrap_metal_bulk","quantity":10},' + + '{"itemId":"contract_handoff_token","quantity":1}]}]}' + ) + + +func _make_client(transport: Node) -> Node: + var c: Node = EncounterProgressClient.new() + c.set("injected_http", transport) + auto_free(transport) + auto_free(c) + add_child(c) + return c + + +func test_parse_inactive_encounter_row() -> void: + # Arrange + var json := _inactive_progression_json() + # Act + var snapshot: Variant = EncounterProgressClient.parse_encounter_progress_json(json) + # Assert + assert_that(snapshot is Dictionary).is_true() + var c: Node = EncounterProgressClient.new() + auto_free(c) + var row: Dictionary = c.call( + "encounter_row", PROTOTYPE_ENCOUNTER_ID, snapshot as Dictionary + ) + assert_that(str(row.get("state", ""))).is_equal("inactive") + assert_that((row.get("defeatedTargetIds", []) as Array).size()).is_equal(0) + + +func test_parse_active_row_has_two_defeats() -> void: + # Arrange + var json := _active_two_defeats_json() + # Act + var snapshot: Variant = EncounterProgressClient.parse_encounter_progress_json(json) + # Assert + assert_that(snapshot is Dictionary).is_true() + var c: Node = EncounterProgressClient.new() + auto_free(c) + var row: Dictionary = c.call( + "encounter_row", PROTOTYPE_ENCOUNTER_ID, snapshot as Dictionary + ) + assert_that(str(row.get("state", ""))).is_equal("active") + assert_that((row.get("defeatedTargetIds", []) as Array).size()).is_equal(2) + + +func test_parse_completed_row_has_reward_grant_summary() -> void: + # Arrange + var json := _completed_with_grants_json() + # Act + var snapshot: Variant = EncounterProgressClient.parse_encounter_progress_json(json) + # Assert + assert_that(snapshot is Dictionary).is_true() + var c: Node = EncounterProgressClient.new() + auto_free(c) + var row: Dictionary = c.call( + "encounter_row", PROTOTYPE_ENCOUNTER_ID, snapshot as Dictionary + ) + assert_that(str(row.get("state", ""))).is_equal("completed") + var grants: Variant = row.get("rewardGrantSummary", null) + assert_that(grants is Array).is_true() + assert_that((grants as Array).size()).is_equal(2) + + +func test_request_sync_emits_encounter_progress_received() -> void: + # Arrange + _progress_capture = {} + var transport := MockHttpTransport.new() + transport.body_json = _inactive_progression_json() + var c := _make_client(transport) + c.connect("encounter_progress_received", Callable(self, "_capture_progress")) + monitor_signals(c) + # Act + c.call("request_sync_from_server") + # Assert + await assert_signal(c).is_emitted("encounter_progress_received", any()) + assert_that(_progress_capture.get("playerId", "")).is_equal("dev-local-1") + assert_that(transport.last_url).contains("/encounter-progress") + + +func test_http_404_emits_encounter_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("encounter_sync_failed", "HTTP 404 (player unknown)")