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.
pull/120/head
VinPropane 2026-05-25 13:15:59 -04:00
parent f03e689ce9
commit 493e963a28
9 changed files with 529 additions and 20 deletions

View File

@ -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`**). - **`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). - 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. - **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: <reasonCode>` 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: <reasonCode>` or combat resolution copy on accept) via **`AbilityCastClient.cast_result_received`** (TODO(E9.M1): telemetry).
- **E1.M4+:** connect to **`$TargetSelectionClient.selection_event`** (or your scenes path to that node) without reshaping the payload. - **E1.M4+:** connect to **`$TargetSelectionClient.selection_event`** (or your scenes path to that node) without reshaping the payload.
Full checklist: [`docs/manual-qa/NEO-26.md`](../docs/manual-qa/NEO-26.md). 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). 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) ## 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). - **`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).

View File

@ -1170,11 +1170,25 @@ theme_override_font_sizes/font_size = 15
autowrap_mode = 3 autowrap_mode = 3
text = "Cast: —" 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] [node name="GatherFeedbackLabel" type="Label" parent="UICanvas" unique_id=9000008]
offset_left = 8.0 offset_left = 8.0
offset_top = 376.0 offset_top = 402.0
offset_right = 520.0 offset_right = 520.0
offset_bottom = 402.0 offset_bottom = 428.0
grow_horizontal = 0 grow_horizontal = 0
grow_vertical = 0 grow_vertical = 0
theme_override_colors/font_color = Color(0.88, 0.95, 0.72, 1) 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] [node name="CraftFeedbackLabel" type="Label" parent="UICanvas" unique_id=9000010]
offset_left = 8.0 offset_left = 8.0
offset_top = 404.0 offset_top = 430.0
offset_right = 520.0 offset_right = 520.0
offset_bottom = 430.0 offset_bottom = 456.0
grow_horizontal = 0 grow_horizontal = 0
grow_vertical = 0 grow_vertical = 0
theme_override_colors/font_color = Color(0.95, 0.82, 0.72, 1) 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] [node name="CooldownSlotsLabel" type="Label" parent="UICanvas" unique_id=9000006]
offset_left = 8.0 offset_left = 8.0
offset_top = 436.0 offset_top = 462.0
offset_right = 520.0 offset_right = 520.0
offset_bottom = 512.0 offset_bottom = 538.0
grow_horizontal = 0 grow_horizontal = 0
grow_vertical = 0 grow_vertical = 0
theme_override_colors/font_color = Color(0.78, 0.92, 0.86, 1) 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] [node name="EconomyHudSection" type="VBoxContainer" parent="UICanvas" unique_id=9000014]
offset_left = 8.0 offset_left = 8.0
offset_top = 524.0 offset_top = 550.0
offset_right = 520.0 offset_right = 520.0
offset_bottom = 1056.0 offset_bottom = 1082.0
grow_horizontal = 0 grow_horizontal = 0
grow_vertical = 0 grow_vertical = 0
script = ExtResource("20_economy_hud") script = ExtResource("20_economy_hud")

View File

@ -3,9 +3,10 @@ extends Node
## NEO-31: prototype HTTP client for `POST /game/players/{id}/ability-cast`. ## 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 ## NEO-28: emits [signal cast_result_received] when the response body is JSON
## [AbilityCastResponse] v1. ## [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 ## 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). ## [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 base_url: String = "http://127.0.0.1:5253"
@export var dev_player_id: String = "dev-local-1" @export var dev_player_id: String = "dev-local-1"
@ -65,6 +66,22 @@ func _player_path_segment() -> String:
return dev_player_id.strip_edges() 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( func _on_request_completed(
result: int, response_code: int, _headers: PackedStringArray, body: PackedByteArray result: int, response_code: int, _headers: PackedStringArray, body: PackedByteArray
) -> void: ) -> void:
@ -76,15 +93,15 @@ func _on_request_completed(
push_warning("AbilityCastClient: HTTP %s" % response_code) push_warning("AbilityCastClient: HTTP %s" % response_code)
return return
var text := body.get_string_from_utf8() var text := body.get_string_from_utf8()
var parsed: Variant = JSON.parse_string(text) var parsed: Variant = parse_cast_response_json(text)
if not parsed is Dictionary: if parsed == null:
push_warning("AbilityCastClient: non-JSON body") push_warning("AbilityCastClient: non-JSON body")
return return
var data: Dictionary = parsed var data: Dictionary = parsed as Dictionary
var accepted: bool = bool(data.get("accepted", false)) var accepted: bool = bool(data.get("accepted", false))
var reason_variant: Variant = data.get("reasonCode", "") var reason: String = str(data.get("reasonCode", ""))
var reason: String = reason_variant as String if reason_variant is String else "" var resolution: Dictionary = data.get("resolution", {}) as Dictionary
cast_result_received.emit(accepted, reason) cast_result_received.emit(accepted, reason, resolution)
if not accepted: if not accepted:
# NEO-27 / NEO-31 / NEO-28: product hook name for future telemetry (TODO(E9.M1)). # NEO-27 / NEO-31 / NEO-28: product hook name for future telemetry (TODO(E9.M1)).
push_warning("ability_cast_denied reasonCode=%s" % reason) push_warning("ability_cast_denied reasonCode=%s" % reason)

View File

@ -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)

View File

@ -0,0 +1 @@
uid://bneo85combtgt01

View File

@ -75,6 +75,8 @@ var _dev_obstacle_smoke: Node3D
var _hotbar_state: Node = null var _hotbar_state: Node = null
var _hotbar_client: Node = null var _hotbar_client: Node = null
var _ability_cast_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_state: RefCounted = null
var _cooldown_client: Node = null var _cooldown_client: Node = null
var _cooldown_poll_timer: Timer = 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 _player_pos_label: Label = $UICanvas/PlayerPositionLabel
@onready var _target_lock_label: Label = $UICanvas/TargetLockLabel @onready var _target_lock_label: Label = $UICanvas/TargetLockLabel
@onready var _cast_feedback_label: Label = $UICanvas/CastFeedbackLabel @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 _cooldown_slots_label: Label = $UICanvas/CooldownSlotsLabel
@onready var _economy_hud_section: VBoxContainer = $UICanvas/EconomyHudSection @onready var _economy_hud_section: VBoxContainer = $UICanvas/EconomyHudSection
@onready var _inventory_label: Label = _economy_hud_section.get_node("Body/InventoryLabel") @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 ## live distance readouts stay in sync with the player capsule even while
## the server state is idle. ## the server state is idle.
func _on_target_state_changed(state: Dictionary) -> void: func _on_target_state_changed(state: Dictionary) -> void:
var prev_lock := _locked_target_id_from_state(_last_target_state)
_last_target_state = state.duplicate() _last_target_state = state.duplicate()
if is_instance_valid(_player): if is_instance_valid(_player):
_render_target_lock_label(_player.global_position) _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: func _setup_hotbar_loadout_sync() -> void:
@ -298,9 +306,22 @@ func _setup_hotbar_loadout_sync() -> void:
_hotbar_client.call("set_hotbar_state", _hotbar_state) _hotbar_client.call("set_hotbar_state", _hotbar_state)
if _hotbar_client.has_method("request_sync_from_server"): if _hotbar_client.has_method("request_sync_from_server"):
_hotbar_client.call("request_sync_from_server") _hotbar_client.call("request_sync_from_server")
_setup_combat_targets_sync()
_setup_cooldown_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: func _setup_cooldown_sync() -> void:
# NEO-32: server cooldown snapshot + local mirror for HUD and cast spam guard. # NEO-32: server cooldown snapshot + local mirror for HUD and cast spam guard.
_cooldown_state = CooldownStateScript.new() _cooldown_state = CooldownStateScript.new()
@ -802,14 +823,20 @@ func _render_target_lock_label(world: Vector3) -> void:
_target_lock_label.text = "\n".join(lines) _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): if not is_instance_valid(_cast_feedback_label):
return return
if accepted: if accepted:
if not resolution.is_empty():
_cast_feedback_label.text = _format_cast_resolution_feedback(resolution)
else:
_cast_feedback_label.text = "Cast: accepted" _cast_feedback_label.text = "Cast: accepted"
if is_instance_valid(_cooldown_client): if is_instance_valid(_cooldown_client):
if _cooldown_client.has_method("request_sync_from_server"): if _cooldown_client.has_method("request_sync_from_server"):
_cooldown_client.call("request_sync_from_server") _cooldown_client.call("request_sync_from_server")
_request_combat_targets_refresh()
return return
var rc := reason_code.strip_edges() var rc := reason_code.strip_edges()
if rc.is_empty(): 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 _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: func _on_move_rejected(reason_code: String) -> void:
_authority_force_snap_next = true _authority_force_snap_next = true
# Rejected stream: server state may differ; next snap is forced. # Rejected stream: server state may differ; next snap is forced.

View File

@ -153,7 +153,8 @@ func test_cast_result_received_emits_false_with_reason_on_denied() -> void:
var got: Array = [] var got: Array = []
c.connect( c.connect(
"cast_result_received", "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 # Act
var started: bool = bool(c.call("request_cast", 0, "prototype_pulse", "prototype_target_alpha")) 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(got.size()).is_equal(1)
assert_that(bool(got[0][0])).is_false() assert_that(bool(got[0][0])).is_false()
assert_that(str(got[0][1])).is_equal("invalid_target") 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: 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 = [] var got: Array = []
c.connect( c.connect(
"cast_result_received", "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 # Act
var started: bool = bool(c.call("request_cast", 0, "prototype_pulse", null)) 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(got.size()).is_equal(1)
assert_that(bool(got[0][0])).is_true() assert_that(bool(got[0][0])).is_true()
assert_that(str(got[0][1])).is_equal("") 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)

View File

@ -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)

View File

@ -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")