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
parent
f03e689ce9
commit
493e963a28
|
|
@ -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: <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 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).
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
@ -0,0 +1 @@
|
|||
uid://bneo85combtgt01
|
||||
|
|
@ -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:
|
||||
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.
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
@ -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")
|
||||
Loading…
Reference in New Issue