From 493e963a2827c58836aede810cee218f1b64c3de Mon Sep 17 00:00:00 2001 From: VinPropane Date: Mon, 25 May 2026 13:15:59 -0400 Subject: [PATCH] NEO-85: Wire client combat feedback and target HP HUD. Parse combatResolution on cast accept, add combat-targets GET client, CombatTargetHpLabel, and event-driven HP refresh on lock change and cast. --- client/README.md | 15 ++- client/scenes/main.tscn | 30 +++-- client/scripts/ability_cast_client.gd | 31 +++-- client/scripts/combat_targets_client.gd | 98 +++++++++++++++ client/scripts/combat_targets_client.gd.uid | 1 + client/scripts/main.gd | 77 +++++++++++- client/test/ability_cast_client_test.gd | 58 ++++++++- client/test/combat_feedback_refresh_test.gd | 132 ++++++++++++++++++++ client/test/combat_targets_client_test.gd | 107 ++++++++++++++++ 9 files changed, 529 insertions(+), 20 deletions(-) create mode 100644 client/scripts/combat_targets_client.gd create mode 100644 client/scripts/combat_targets_client.gd.uid create mode 100644 client/test/combat_feedback_refresh_test.gd create mode 100644 client/test/combat_targets_client_test.gd diff --git a/client/README.md b/client/README.md index e603a71..03bad5d 100644 --- a/client/README.md +++ b/client/README.md @@ -112,7 +112,7 @@ Full checklist: [`docs/manual-qa/NEO-25.md`](../manual-qa/NEO-25.md). - **`TargetSelectionClient`** emits **`selection_event(event: Dictionary)`** only when the server-acknowledged **`lockedTargetId`** changes (not on **`validity`-only** updates; those stay on **`target_state_changed`**). - Payload keys: **`previous`** ([String] or `null`), **`next`** (same), **`cause`** (`tab` \| `clear` \| `server_correction`), **`sequence`** (int from the new state). **`POST …/target/select`** from **`request_select_target_id`** (including the path used by Tab) uses cause **`tab`**; **`request_clear_target`** uses **`clear`**; every **`GET …/target`** completion uses **`server_correction`** (including boot sync). - **Slice 3 telemetry hook (NEO-27):** this selection transition maps to product event name **`target_changed`**; with **`log_selection_events`** enabled, Godot Output prints the `target_changed` token for manual verification. **TODO(E9.M1):** replace dev log with cataloged telemetry emit. -- **Ability cast hooks (NEO-27 + NEO-31 + NEO-28):** digit keys **`hotbar_slot_1` … `hotbar_slot_8`** (defaults **1**–**8**) issue a prototype cast via `scripts/ability_cast_client.gd`. **`ability_cast_requested`** is printed from `main.gd` only when **`request_cast`** returns **`true`** (HTTP POST successfully queued). **`ability_cast_denied`** is logged as `push_warning` from the client on deny responses, and **NEO-28** mirrors the same outcome on the **`CastFeedbackLabel`** HUD line (`ability_cast_denied: ` or `Cast: accepted`) via **`AbilityCastClient.cast_result_received`** (TODO(E9.M1): telemetry). +- **Ability cast hooks (NEO-27 + NEO-31 + NEO-28):** digit keys **`hotbar_slot_1` … `hotbar_slot_8`** (defaults **1**–**8**) issue a prototype cast via `scripts/ability_cast_client.gd`. **`ability_cast_requested`** is printed from `main.gd` only when **`request_cast`** returns **`true`** (HTTP POST successfully queued). **`ability_cast_denied`** is logged as `push_warning` from the client on deny responses, and **NEO-28** mirrors the same outcome on the **`CastFeedbackLabel`** HUD line (`ability_cast_denied: ` or combat resolution copy on accept) via **`AbilityCastClient.cast_result_received`** (TODO(E9.M1): telemetry). - **E1.M4+:** connect to **`$TargetSelectionClient.selection_event`** (or your scene’s path to that node) without reshaping the payload. Full checklist: [`docs/manual-qa/NEO-26.md`](../docs/manual-qa/NEO-26.md). @@ -134,6 +134,19 @@ This story hydrates bindings only; cast execution and cooldown behavior stay in Full checklist: [`docs/manual-qa/NEO-31.md`](../docs/manual-qa/NEO-31.md) · cast deny/accept HUD: [`docs/manual-qa/NEO-28.md`](../docs/manual-qa/NEO-28.md). +## Combat feedback + target HP HUD (NEO-85) + +- **`POST /game/players/{id}/ability-cast`** accept responses include nested **`combatResolution`** (`damageDealt`, `targetRemainingHp`, `targetDefeated`, …) — see [server README — Ability cast](../server/README.md). +- **`GET /game/world/combat-targets`** — authoritative dummy HP snapshot for the locked target label (NEO-83); no client-side damage math. +- **Scripts:** `scripts/ability_cast_client.gd` (parses **`combatResolution`** on accept), `scripts/combat_targets_client.gd`; wired from `main.gd` in `_setup_hotbar_loadout_sync()` / `_setup_combat_targets_sync()`. +- **HUD:** + - **`UICanvas/CastFeedbackLabel`** — on accept: **`Cast: {damage} dmg → {remaining} HP`** or **`… — defeated!`** when **`targetDefeated`**; denies unchanged from NEO-28. + - **`UICanvas/CombatTargetHpLabel`** — locked target **`currentHp/maxHp`** (+ **` (defeated)`**); **`Target HP: — (no lock)`** when unlocked. +- **Refresh (event-driven):** combat-target GET after successful cast accept and when **`lockedTargetId`** changes (Tab lock / Esc clear / server correction). No periodic HP poll. +- **Dev fixture reset:** when the server exposes **`POST /game/__dev/combat-targets-fixture`** (Development/Testing), reset dummy HP between manual QA runs — see [server README](../server/README.md#combat-entity-health-store-neo-80). + +Full checklist: [`docs/manual-qa/NEO-85.md`](../docs/manual-qa/NEO-85.md). + ## Inventory snapshot HUD (NEO-72) - **`GET /game/players/{id}/inventory`** — server-authoritative bag (**24** slots) + equipment stub (**1** slot); see [server README — Player inventory](../server/README.md#player-inventory-neo-54-store-neo-55-http). diff --git a/client/scenes/main.tscn b/client/scenes/main.tscn index eaa0fc6..b923ff9 100644 --- a/client/scenes/main.tscn +++ b/client/scenes/main.tscn @@ -1170,11 +1170,25 @@ theme_override_font_sizes/font_size = 15 autowrap_mode = 3 text = "Cast: —" +[node name="CombatTargetHpLabel" type="Label" parent="UICanvas" unique_id=9000018] +offset_left = 8.0 +offset_top = 374.0 +offset_right = 520.0 +offset_bottom = 398.0 +grow_horizontal = 0 +grow_vertical = 0 +theme_override_colors/font_color = Color(0.92, 0.78, 0.82, 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 = 15 +autowrap_mode = 3 +text = "Target HP: —" + [node name="GatherFeedbackLabel" type="Label" parent="UICanvas" unique_id=9000008] offset_left = 8.0 -offset_top = 376.0 +offset_top = 402.0 offset_right = 520.0 -offset_bottom = 402.0 +offset_bottom = 428.0 grow_horizontal = 0 grow_vertical = 0 theme_override_colors/font_color = Color(0.88, 0.95, 0.72, 1) @@ -1186,9 +1200,9 @@ text = "Gather: —" [node name="CraftFeedbackLabel" type="Label" parent="UICanvas" unique_id=9000010] offset_left = 8.0 -offset_top = 404.0 +offset_top = 430.0 offset_right = 520.0 -offset_bottom = 430.0 +offset_bottom = 456.0 grow_horizontal = 0 grow_vertical = 0 theme_override_colors/font_color = Color(0.95, 0.82, 0.72, 1) @@ -1200,9 +1214,9 @@ text = "Craft: —" [node name="CooldownSlotsLabel" type="Label" parent="UICanvas" unique_id=9000006] offset_left = 8.0 -offset_top = 436.0 +offset_top = 462.0 offset_right = 520.0 -offset_bottom = 512.0 +offset_bottom = 538.0 grow_horizontal = 0 grow_vertical = 0 theme_override_colors/font_color = Color(0.78, 0.92, 0.86, 1) @@ -1214,9 +1228,9 @@ text = "Cooldowns: —" [node name="EconomyHudSection" type="VBoxContainer" parent="UICanvas" unique_id=9000014] offset_left = 8.0 -offset_top = 524.0 +offset_top = 550.0 offset_right = 520.0 -offset_bottom = 1056.0 +offset_bottom = 1082.0 grow_horizontal = 0 grow_vertical = 0 script = ExtResource("20_economy_hud") diff --git a/client/scripts/ability_cast_client.gd b/client/scripts/ability_cast_client.gd index ca5b535..3a30be0 100644 --- a/client/scripts/ability_cast_client.gd +++ b/client/scripts/ability_cast_client.gd @@ -3,9 +3,10 @@ extends Node ## NEO-31: prototype HTTP client for `POST /game/players/{id}/ability-cast`. ## NEO-28: emits [signal cast_result_received] when the response body is JSON ## [AbilityCastResponse] v1. +## NEO-85: optional nested [code]combatResolution[/code] on accept (NEO-82 wire shape). ## NEO-30: [code]ability_cast_denied[/code] dev hook ([code]push_warning[/code] below). Deny ## [code]reasonCode[/code] is authoritative in [code]AbilityCastApi.cs[/code] (comment hook sites). -signal cast_result_received(accepted: bool, reason_code: String) +signal cast_result_received(accepted: bool, reason_code: String, resolution: Dictionary) @export var base_url: String = "http://127.0.0.1:5253" @export var dev_player_id: String = "dev-local-1" @@ -65,6 +66,22 @@ func _player_path_segment() -> String: return dev_player_id.strip_edges() +static func parse_cast_response_json(text: String) -> Variant: + var parsed: Variant = JSON.parse_string(text) + if not parsed is Dictionary: + return null + var data: Dictionary = parsed + var accepted: bool = bool(data.get("accepted", false)) + var reason_variant: Variant = data.get("reasonCode", "") + var reason: String = reason_variant as String if reason_variant is String else "" + var resolution: Dictionary = {} + if accepted: + var block: Variant = data.get("combatResolution", null) + if block is Dictionary: + resolution = block as Dictionary + return {"accepted": accepted, "reasonCode": reason, "resolution": resolution} + + func _on_request_completed( result: int, response_code: int, _headers: PackedStringArray, body: PackedByteArray ) -> void: @@ -76,15 +93,15 @@ func _on_request_completed( push_warning("AbilityCastClient: HTTP %s" % response_code) return var text := body.get_string_from_utf8() - var parsed: Variant = JSON.parse_string(text) - if not parsed is Dictionary: + var parsed: Variant = parse_cast_response_json(text) + if parsed == null: push_warning("AbilityCastClient: non-JSON body") return - var data: Dictionary = parsed + var data: Dictionary = parsed as Dictionary var accepted: bool = bool(data.get("accepted", false)) - var reason_variant: Variant = data.get("reasonCode", "") - var reason: String = reason_variant as String if reason_variant is String else "" - cast_result_received.emit(accepted, reason) + var reason: String = str(data.get("reasonCode", "")) + var resolution: Dictionary = data.get("resolution", {}) as Dictionary + cast_result_received.emit(accepted, reason, resolution) if not accepted: # NEO-27 / NEO-31 / NEO-28: product hook name for future telemetry (TODO(E9.M1)). push_warning("ability_cast_denied reasonCode=%s" % reason) diff --git a/client/scripts/combat_targets_client.gd b/client/scripts/combat_targets_client.gd new file mode 100644 index 0000000..d9421d9 --- /dev/null +++ b/client/scripts/combat_targets_client.gd @@ -0,0 +1,98 @@ +extends Node + +## NEO-85: prototype HTTP client for [code]GET /game/world/combat-targets[/code] (NEO-83). + +signal targets_received(snapshot: Dictionary) +signal targets_sync_failed(reason: String) + +const SCHEMA_VERSION := 1 + +@export var base_url: String = "http://127.0.0.1:5253" +@export var injected_http: Node = null + +var _http: Node +var _busy: bool = false +var _last_snapshot: Dictionary = {} + + +func _ready() -> void: + if injected_http != null: + _http = injected_http + else: + _http = HTTPRequest.new() + add_child(_http) + if _http is HTTPRequest: + (_http as HTTPRequest).timeout = 30.0 + @warning_ignore("unsafe_method_access") + _http.request_completed.connect(_on_request_completed) + + +func request_sync_from_server() -> void: + if _busy: + return + _busy = true + var url := "%s/game/world/combat-targets" % _base_root() + var err: Error = _http.request(url) + if err != OK: + var reason := "GET failed to start (%s)" % err + push_warning("CombatTargetsClient: %s" % reason) + _busy = false + targets_sync_failed.emit(reason) + + +func target_row(target_id: String, snapshot: Dictionary = {}) -> Dictionary: + var source: Dictionary = snapshot + if source.is_empty(): + source = _last_snapshot + var targets: Variant = source.get("targets", null) + if targets == null or not targets is Array: + return {} + for row_variant in targets as Array: + if not row_variant is Dictionary: + continue + var row: Dictionary = row_variant + if str(row.get("targetId", "")) == target_id: + return row + return {} + + +func _base_root() -> String: + return base_url.strip_edges().rstrip("/") + + +static func parse_targets_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 targets: Variant = root.get("targets", null) + if targets == null or not targets is Array: + 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("CombatTargetsClient: %s" % reason) + targets_sync_failed.emit(reason) + return + if response_code < 200 or response_code >= 300: + var reason_code := "HTTP %s" % response_code + push_warning("CombatTargetsClient: %s" % reason_code) + targets_sync_failed.emit(reason_code) + return + var text := body.get_string_from_utf8() + var snapshot: Variant = parse_targets_json(text) + if snapshot == null: + var reason_json := "non-JSON body or schemaVersion mismatch" + push_warning("CombatTargetsClient: %s" % reason_json) + targets_sync_failed.emit(reason_json) + return + _last_snapshot = snapshot as Dictionary + targets_received.emit(_last_snapshot) diff --git a/client/scripts/combat_targets_client.gd.uid b/client/scripts/combat_targets_client.gd.uid new file mode 100644 index 0000000..096056e --- /dev/null +++ b/client/scripts/combat_targets_client.gd.uid @@ -0,0 +1 @@ +uid://bneo85combtgt01 diff --git a/client/scripts/main.gd b/client/scripts/main.gd index ed43e55..2b1994f 100644 --- a/client/scripts/main.gd +++ b/client/scripts/main.gd @@ -75,6 +75,8 @@ var _dev_obstacle_smoke: Node3D var _hotbar_state: Node = null var _hotbar_client: Node = null var _ability_cast_client: Node = null +var _combat_targets_client: Node = null +var _combat_targets_snapshot: Dictionary = {} var _cooldown_state: RefCounted = null var _cooldown_client: Node = null var _cooldown_poll_timer: Timer = null @@ -101,6 +103,7 @@ var _recipe_defs_error: String = "" @onready var _player_pos_label: Label = $UICanvas/PlayerPositionLabel @onready var _target_lock_label: Label = $UICanvas/TargetLockLabel @onready var _cast_feedback_label: Label = $UICanvas/CastFeedbackLabel +@onready var _combat_target_hp_label: Label = $UICanvas/CombatTargetHpLabel @onready var _cooldown_slots_label: Label = $UICanvas/CooldownSlotsLabel @onready var _economy_hud_section: VBoxContainer = $UICanvas/EconomyHudSection @onready var _inventory_label: Label = _economy_hud_section.get_node("Body/InventoryLabel") @@ -265,9 +268,14 @@ func _on_authoritative_ack_for_hud(world: Vector3) -> void: ## live distance readouts stay in sync with the player capsule even while ## the server state is idle. func _on_target_state_changed(state: Dictionary) -> void: + var prev_lock := _locked_target_id_from_state(_last_target_state) _last_target_state = state.duplicate() if is_instance_valid(_player): _render_target_lock_label(_player.global_position) + _render_combat_target_hp_label() + var new_lock := _locked_target_id_from_state(_last_target_state) + if new_lock != prev_lock: + _request_combat_targets_refresh() func _setup_hotbar_loadout_sync() -> void: @@ -298,9 +306,22 @@ func _setup_hotbar_loadout_sync() -> void: _hotbar_client.call("set_hotbar_state", _hotbar_state) if _hotbar_client.has_method("request_sync_from_server"): _hotbar_client.call("request_sync_from_server") + _setup_combat_targets_sync() _setup_cooldown_sync() +func _setup_combat_targets_sync() -> void: + # NEO-85: authoritative combat dummy HP for locked target HUD line. + _combat_targets_client = load("res://scripts/combat_targets_client.gd").new() + _combat_targets_client.name = "CombatTargetsClient" + add_child(_combat_targets_client) + _apply_authority_http_config_to_client(_combat_targets_client) + if _combat_targets_client.has_signal("targets_received"): + _combat_targets_client.connect( + "targets_received", Callable(self, "_on_combat_targets_received") + ) + + func _setup_cooldown_sync() -> void: # NEO-32: server cooldown snapshot + local mirror for HUD and cast spam guard. _cooldown_state = CooldownStateScript.new() @@ -802,14 +823,20 @@ func _render_target_lock_label(world: Vector3) -> void: _target_lock_label.text = "\n".join(lines) -func _on_cast_result_received(accepted: bool, reason_code: String) -> void: +func _on_cast_result_received( + accepted: bool, reason_code: String, resolution: Dictionary +) -> void: if not is_instance_valid(_cast_feedback_label): return if accepted: - _cast_feedback_label.text = "Cast: accepted" + if not resolution.is_empty(): + _cast_feedback_label.text = _format_cast_resolution_feedback(resolution) + else: + _cast_feedback_label.text = "Cast: accepted" if is_instance_valid(_cooldown_client): if _cooldown_client.has_method("request_sync_from_server"): _cooldown_client.call("request_sync_from_server") + _request_combat_targets_refresh() return var rc := reason_code.strip_edges() if rc.is_empty(): @@ -818,6 +845,52 @@ func _on_cast_result_received(accepted: bool, reason_code: String) -> void: _cast_feedback_label.text = "ability_cast_denied: %s" % rc +func _format_cast_resolution_feedback(resolution: Dictionary) -> String: + var damage: int = int(resolution.get("damageDealt", 0)) + var remaining: int = int(resolution.get("targetRemainingHp", 0)) + if bool(resolution.get("targetDefeated", false)): + return "Cast: %d dmg → 0 HP — defeated!" % damage + return "Cast: %d dmg → %d HP" % [damage, remaining] + + +func _on_combat_targets_received(snapshot: Dictionary) -> void: + _combat_targets_snapshot = snapshot + _render_combat_target_hp_label() + + +func _request_combat_targets_refresh() -> void: + if not is_instance_valid(_combat_targets_client): + return + if _combat_targets_client.has_method("request_sync_from_server"): + _combat_targets_client.call("request_sync_from_server") + + +func _locked_target_id_from_state(state: Dictionary) -> String: + var locked_variant: Variant = state.get("lockedTargetId", null) + if locked_variant is String: + return (locked_variant as String).strip_edges() + return "" + + +func _render_combat_target_hp_label() -> void: + if not is_instance_valid(_combat_target_hp_label): + return + var lock_id := _locked_target_id_from_state(_last_target_state) + if lock_id.is_empty(): + _combat_target_hp_label.text = "Target HP: — (no lock)" + return + var row: Dictionary = {} + if is_instance_valid(_combat_targets_client) and _combat_targets_client.has_method("target_row"): + row = _combat_targets_client.call("target_row", lock_id, _combat_targets_snapshot) as Dictionary + if row.is_empty(): + _combat_target_hp_label.text = "Target HP: %s …" % lock_id + return + var current: int = int(row.get("currentHp", 0)) + var max_hp: int = int(row.get("maxHp", 0)) + var defeated_suffix := " (defeated)" if bool(row.get("defeated", false)) else "" + _combat_target_hp_label.text = "Target HP: %s %d/%d%s" % [lock_id, current, max_hp, defeated_suffix] + + func _on_move_rejected(reason_code: String) -> void: _authority_force_snap_next = true # Rejected stream: server state may differ; next snap is forced. diff --git a/client/test/ability_cast_client_test.gd b/client/test/ability_cast_client_test.gd index 582c3a5..9873e4a 100644 --- a/client/test/ability_cast_client_test.gd +++ b/client/test/ability_cast_client_test.gd @@ -153,7 +153,8 @@ func test_cast_result_received_emits_false_with_reason_on_denied() -> void: var got: Array = [] c.connect( "cast_result_received", - func(accepted: bool, reason: String) -> void: got.append([accepted, reason]) + func(accepted: bool, reason: String, resolution: Dictionary) -> void: + got.append([accepted, reason, resolution]) ) # Act var started: bool = bool(c.call("request_cast", 0, "prototype_pulse", "prototype_target_alpha")) @@ -162,6 +163,7 @@ func test_cast_result_received_emits_false_with_reason_on_denied() -> void: assert_that(got.size()).is_equal(1) assert_that(bool(got[0][0])).is_false() assert_that(str(got[0][1])).is_equal("invalid_target") + assert_that((got[0][2] as Dictionary).is_empty()).is_true() func test_cast_result_received_emits_true_with_empty_reason_on_accepted() -> void: @@ -172,7 +174,8 @@ func test_cast_result_received_emits_true_with_empty_reason_on_accepted() -> voi var got: Array = [] c.connect( "cast_result_received", - func(accepted: bool, reason: String) -> void: got.append([accepted, reason]) + func(accepted: bool, reason: String, resolution: Dictionary) -> void: + got.append([accepted, reason, resolution]) ) # Act var started: bool = bool(c.call("request_cast", 0, "prototype_pulse", null)) @@ -181,3 +184,54 @@ func test_cast_result_received_emits_true_with_empty_reason_on_accepted() -> voi assert_that(got.size()).is_equal(1) assert_that(bool(got[0][0])).is_true() assert_that(str(got[0][1])).is_equal("") + assert_that((got[0][2] as Dictionary).is_empty()).is_true() + + +func test_cast_result_received_emits_combat_resolution_on_accept() -> void: + # Arrange + var transport := MockHttpTransport.new() + transport.enqueue( + HTTPRequest.RESULT_SUCCESS, + 200, + ( + '{"schemaVersion":1,"accepted":true,"combatResolution":' + + '{"abilityId":"prototype_pulse","targetId":"prototype_target_alpha",' + + '"damageDealt":25,"targetRemainingHp":75,"targetDefeated":false}}' + ) + ) + var c := _make_client(transport) + var got: Array = [] + c.connect( + "cast_result_received", + func(accepted: bool, reason: String, resolution: Dictionary) -> void: + got.append([accepted, reason, resolution]) + ) + # Act + var started: bool = bool( + c.call("request_cast", 0, "prototype_pulse", "prototype_target_alpha") + ) + # Assert + assert_that(started).is_true() + assert_that(got.size()).is_equal(1) + assert_that(bool(got[0][0])).is_true() + var resolution: Dictionary = got[0][2] + assert_that(int(resolution.get("damageDealt", 0))).is_equal(25) + assert_that(int(resolution.get("targetRemainingHp", 0))).is_equal(75) + assert_that(bool(resolution.get("targetDefeated", false))).is_false() + + +func test_parse_cast_response_json_maps_combat_resolution() -> void: + # Arrange + var body := ( + '{"schemaVersion":1,"accepted":true,"combatResolution":' + + '{"abilityId":"prototype_pulse","targetId":"prototype_target_alpha",' + + '"damageDealt":25,"targetRemainingHp":75,"targetDefeated":false}}' + ) + # Act + var parsed: Variant = CastClient.parse_cast_response_json(body) + # Assert + assert_that(parsed is Dictionary).is_true() + var data: Dictionary = parsed + assert_that(bool(data.get("accepted", false))).is_true() + var resolution: Dictionary = data.get("resolution", {}) as Dictionary + assert_that(int(resolution.get("damageDealt", 0))).is_equal(25) diff --git a/client/test/combat_feedback_refresh_test.gd b/client/test/combat_feedback_refresh_test.gd new file mode 100644 index 0000000..3eca876 --- /dev/null +++ b/client/test/combat_feedback_refresh_test.gd @@ -0,0 +1,132 @@ +extends GdUnitTestSuite + +## NEO-85: cast accept + target lock change trigger combat-target GET refresh. + +const CastClient := preload("res://scripts/ability_cast_client.gd") +const CombatTargetsClient := preload("res://scripts/combat_targets_client.gd") + + +class MockCastTransport: + extends Node + signal request_completed( + result: int, response_code: int, headers: PackedStringArray, body: PackedByteArray + ) + var body_json: String = ( + '{"schemaVersion":1,"accepted":true,"combatResolution":' + + '{"abilityId":"prototype_pulse","targetId":"prototype_target_alpha",' + + '"damageDealt":25,"targetRemainingHp":75,"targetDefeated":false}}' + ) + + 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 SpyCombatTargetsClient: + extends Node + var sync_calls: int = 0 + + func request_sync_from_server() -> void: + sync_calls += 1 + + +class CombatRefreshHarness: + extends Node + var combat_sync_calls: int = 0 + var last_target_state: Dictionary = {} + var _cast: Node + var _combat: SpyCombatTargetsClient + + func setup(parent: Node, cast_transport: Node) -> void: + _combat = SpyCombatTargetsClient.new() + _cast = CastClient.new() + _cast.set("injected_http", cast_transport) + parent.add_child(_cast) + parent.add_child(_combat) + _cast.connect("cast_result_received", Callable(self, "_on_cast_result")) + last_target_state = {} + + func _on_cast_result(accepted: bool, _reason_code: String, _resolution: Dictionary) -> void: + if not accepted: + return + _request_combat_targets_refresh() + + func _request_combat_targets_refresh() -> void: + combat_sync_calls += 1 + _combat.request_sync_from_server() + + func on_target_state_changed(state: Dictionary) -> void: + var prev_lock := _locked_target_id_from_state(last_target_state) + last_target_state = state.duplicate() + var new_lock := _locked_target_id_from_state(last_target_state) + if new_lock != prev_lock: + _request_combat_targets_refresh() + + func _locked_target_id_from_state(state: Dictionary) -> String: + var locked_variant: Variant = state.get("lockedTargetId", null) + if locked_variant is String: + return (locked_variant as String).strip_edges() + return "" + + func request_cast() -> void: + _cast.call("request_cast", 0, "prototype_pulse", "prototype_target_alpha") + + +func test_successful_cast_triggers_combat_target_sync() -> void: + # Arrange + var transport := MockCastTransport.new() + var harness := CombatRefreshHarness.new() + auto_free(transport) + auto_free(harness) + add_child(harness) + harness.setup(self, transport) + monitor_signals(harness._cast) + # Act + harness.request_cast() + # Assert + assert_signal(harness._cast).is_emitted("cast_result_received") + assert_that(harness.combat_sync_calls).is_equal(1) + assert_that(harness._combat.sync_calls).is_equal(1) + + +func test_target_lock_change_triggers_combat_target_sync() -> void: + # Arrange + var transport := MockCastTransport.new() + var harness := CombatRefreshHarness.new() + auto_free(transport) + auto_free(harness) + add_child(harness) + harness.setup(self, transport) + # Act + harness.on_target_state_changed( + {"lockedTargetId": "prototype_target_alpha", "validity": "ok", "sequence": 1} + ) + # Assert + assert_that(harness.combat_sync_calls).is_equal(1) + assert_that(harness._combat.sync_calls).is_equal(1) + + +func test_target_validity_only_update_skips_combat_target_sync() -> void: + # Arrange + var transport := MockCastTransport.new() + var harness := CombatRefreshHarness.new() + auto_free(transport) + auto_free(harness) + add_child(harness) + harness.setup(self, transport) + harness.on_target_state_changed( + {"lockedTargetId": "prototype_target_alpha", "validity": "ok", "sequence": 1} + ) + # Act + harness.on_target_state_changed( + {"lockedTargetId": "prototype_target_alpha", "validity": "out_of_range", "sequence": 2} + ) + # Assert + assert_that(harness.combat_sync_calls).is_equal(1) diff --git a/client/test/combat_targets_client_test.gd b/client/test/combat_targets_client_test.gd new file mode 100644 index 0000000..9964ffa --- /dev/null +++ b/client/test/combat_targets_client_test.gd @@ -0,0 +1,107 @@ +extends GdUnitTestSuite + +## NEO-85: `CombatTargetsClient` GET parse + target row lookup. + +const CombatTargetsClient := preload("res://scripts/combat_targets_client.gd") + + +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 _two_target_snapshot_json() -> String: + return ( + '{"schemaVersion":1,"targets":[' + + '{"targetId":"prototype_target_alpha","maxHp":100,"currentHp":75,"defeated":false},' + + '{"targetId":"prototype_target_beta","maxHp":100,"currentHp":100,"defeated":false}' + + "]}" + ) + + +func _make_client(transport: Node) -> Node: + var c: Node = CombatTargetsClient.new() + c.set("injected_http", transport) + auto_free(transport) + auto_free(c) + add_child(c) + return c + + +func test_request_sync_gets_combat_targets_url() -> void: + # Arrange + var transport := MockHttpTransport.new() + transport.body_json = _two_target_snapshot_json() + var c := _make_client(transport) + # Act + c.call("request_sync_from_server") + # Assert + assert_that(transport.last_url).contains("/game/world/combat-targets") + + +func test_targets_received_emits_parsed_snapshot() -> void: + # Arrange + var transport := MockHttpTransport.new() + transport.body_json = _two_target_snapshot_json() + var c := _make_client(transport) + var got: Array = [] + c.connect("targets_received", func(snapshot: Dictionary) -> void: got.append(snapshot)) + # Act + c.call("request_sync_from_server") + # Assert + assert_that(got.size()).is_equal(1) + var snapshot: Dictionary = got[0] + var targets: Variant = snapshot.get("targets", null) + assert_that(targets is Array).is_true() + assert_that((targets as Array).size()).is_equal(2) + + +func test_target_row_returns_alpha_hp_fields() -> void: + # Arrange + var transport := MockHttpTransport.new() + transport.body_json = _two_target_snapshot_json() + var c := _make_client(transport) + var got: Array = [] + c.connect("targets_received", func(snapshot: Dictionary) -> void: got.append(snapshot)) + c.call("request_sync_from_server") + var snapshot: Dictionary = got[0] + # Act + var row: Dictionary = c.call("target_row", "prototype_target_alpha", snapshot) as Dictionary + # Assert + assert_that(int(row.get("currentHp", 0))).is_equal(75) + assert_that(int(row.get("maxHp", 0))).is_equal(100) + assert_that(bool(row.get("defeated", false))).is_false() + + +func test_invalid_schema_emits_sync_failed() -> void: + # Arrange + var transport := MockHttpTransport.new() + transport.body_json = '{"schemaVersion":2,"targets":[]}' + var c := _make_client(transport) + var failed: Array = [] + c.connect("targets_sync_failed", func(reason: String) -> void: failed.append(reason)) + # Act + c.call("request_sync_from_server") + # Assert + assert_that(failed.size()).is_equal(1) + assert_that(str(failed[0])).contains("schemaVersion")