NEO-85: Auto-bind pulse to slot 0 and show cast HUD errors.
Fresh hotbar starts empty so digit 1 was a silent no-op; bootstrap prototype_pulse on first sync and surface empty-slot/send failures on CastFeedbackLabel.pull/120/head
parent
504d3228dd
commit
35ff76b532
|
|
@ -143,6 +143,7 @@ Full checklist: [`docs/manual-qa/NEO-31.md`](../docs/manual-qa/NEO-31.md) · cas
|
|||
- **`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 hotbar bootstrap (NEO-85):** on first hotbar sync, if slot **0** is empty the client POSTs a bind for **`prototype_pulse`** once (same preset as Bruno `Post hotbar bind slot0 pulse`) so digit **1** works without a manual bind step when the server is up.
|
||||
- **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).
|
||||
|
|
|
|||
|
|
@ -43,6 +43,7 @@ const PrototypeInteractablePicker := preload("res://scripts/prototype_interactab
|
|||
const HotbarCastSlotResolver := preload("res://scripts/hotbar_cast_slot_resolver.gd")
|
||||
const CooldownStateScript := preload("res://scripts/cooldown_state.gd")
|
||||
const COOLDOWN_REASON_ON_CD := "on_cooldown"
|
||||
const DEV_BOOTSTRAP_CAST_ABILITY_ID := "prototype_pulse"
|
||||
const SCRAP_METAL_BULK_ID := "scrap_metal_bulk"
|
||||
const SALVAGE_SKILL_ID := "salvage"
|
||||
const REFINE_SKILL_ID := "refine"
|
||||
|
|
@ -90,6 +91,7 @@ var _gather_pending_interactable_id: String = ""
|
|||
var _gather_awaiting_inventory_finalize: bool = false
|
||||
var _cached_recipe_rows: Array = []
|
||||
var _recipe_defs_error: String = ""
|
||||
var _dev_pulse_bootstrap_attempted: bool = false
|
||||
|
||||
@onready var _camera: Camera3D = $World/IsometricFollowCamera/Camera3D
|
||||
@onready var _world: Node3D = $World
|
||||
|
|
@ -341,7 +343,7 @@ func _setup_cooldown_sync() -> void:
|
|||
)
|
||||
if _hotbar_client.has_signal("loadout_changed"):
|
||||
_hotbar_client.connect(
|
||||
"loadout_changed", Callable(self, "_on_hotbar_loadout_changed_sync_cooldown")
|
||||
"loadout_changed", Callable(self, "_on_hotbar_loadout_changed")
|
||||
)
|
||||
_ensure_cooldown_poll_timer()
|
||||
|
||||
|
|
@ -749,6 +751,27 @@ func _finalize_pending_gather_feedback() -> void:
|
|||
_render_gather_feedback_label("Gather: +%d %s" % [delta, label])
|
||||
|
||||
|
||||
func _on_hotbar_loadout_changed(loadout: Dictionary) -> void:
|
||||
_on_hotbar_loadout_changed_sync_cooldown(loadout)
|
||||
_maybe_dev_bind_pulse_slot0()
|
||||
|
||||
|
||||
func _maybe_dev_bind_pulse_slot0() -> void:
|
||||
if _dev_pulse_bootstrap_attempted:
|
||||
return
|
||||
if not is_instance_valid(_hotbar_state) or not is_instance_valid(_hotbar_client):
|
||||
return
|
||||
var slots: Array = _hotbar_state.call("slots_snapshot") as Array
|
||||
if slots.is_empty():
|
||||
return
|
||||
var slot0: Variant = slots[0]
|
||||
if slot0 is String and not (slot0 as String).strip_edges().is_empty():
|
||||
return
|
||||
_dev_pulse_bootstrap_attempted = true
|
||||
if _hotbar_client.has_method("request_bind_slot"):
|
||||
_hotbar_client.call("request_bind_slot", 0, DEV_BOOTSTRAP_CAST_ABILITY_ID)
|
||||
|
||||
|
||||
func _on_hotbar_loadout_changed_sync_cooldown(_loadout: Dictionary) -> void:
|
||||
if (
|
||||
is_instance_valid(_cooldown_client)
|
||||
|
|
@ -983,6 +1006,9 @@ func _try_inventory_refresh_input(event: InputEvent) -> bool:
|
|||
|
||||
func _request_hotbar_cast_slot(slot_index: int) -> void:
|
||||
if not is_instance_valid(_hotbar_state) or not _hotbar_state.has_method("slots_snapshot"):
|
||||
if is_instance_valid(_cast_feedback_label):
|
||||
_cast_feedback_label.text = "Hotbar: not ready"
|
||||
push_warning("Hotbar cast ignored: hotbar_state unavailable")
|
||||
return
|
||||
var slots: Array = _hotbar_state.call("slots_snapshot") as Array
|
||||
var target_state: Dictionary = {}
|
||||
|
|
@ -995,8 +1021,15 @@ func _request_hotbar_cast_slot(slot_index: int) -> void:
|
|||
var reason: String = str(outcome.get(HotbarCastSlotResolver.KEY_REASON, ""))
|
||||
if reason == HotbarCastSlotResolver.REASON_INVALID_SLOT:
|
||||
push_warning("Hotbar cast ignored: slot %d invalid_slot_index" % slot_index)
|
||||
if is_instance_valid(_cast_feedback_label):
|
||||
_cast_feedback_label.text = "Hotbar: slot %d invalid" % (slot_index + 1)
|
||||
elif reason == HotbarCastSlotResolver.REASON_EMPTY_SLOT:
|
||||
push_warning("Hotbar cast ignored: slot %d empty_slot" % slot_index)
|
||||
if is_instance_valid(_cast_feedback_label):
|
||||
_cast_feedback_label.text = (
|
||||
"Hotbar: slot %d empty (bind ability or wait for sync)"
|
||||
% (slot_index + 1)
|
||||
)
|
||||
return
|
||||
var ability_id: String = outcome[HotbarCastSlotResolver.KEY_ABILITY_ID] as String
|
||||
var resolved_slot: int = int(outcome.get(HotbarCastSlotResolver.KEY_SLOT_INDEX, slot_index))
|
||||
|
|
@ -1024,6 +1057,8 @@ func _request_hotbar_cast_slot(slot_index: int) -> void:
|
|||
% [resolved_slot, ability_id, target_label]
|
||||
)
|
||||
)
|
||||
elif is_instance_valid(_cast_feedback_label):
|
||||
_cast_feedback_label.text = "Cast: failed to send (busy or server offline?)"
|
||||
|
||||
|
||||
func _forward_interact_post(method: String) -> void:
|
||||
|
|
|
|||
|
|
@ -0,0 +1,76 @@
|
|||
extends GdUnitTestSuite
|
||||
|
||||
## NEO-85: dev hotbar bootstrap binds prototype_pulse to slot 0 when empty.
|
||||
|
||||
const HotbarState := preload("res://scripts/hotbar_state.gd")
|
||||
const DEV_BOOTSTRAP_CAST_ABILITY_ID := "prototype_pulse"
|
||||
|
||||
|
||||
class SpyHotbarClient:
|
||||
extends Node
|
||||
var bind_calls: Array = []
|
||||
|
||||
func request_bind_slot(slot_index: int, ability_id: String) -> void:
|
||||
bind_calls.append([slot_index, ability_id])
|
||||
|
||||
|
||||
class BootstrapHarness:
|
||||
extends Node
|
||||
var bootstrap_attempted: bool = false
|
||||
var _hotbar_state: Node
|
||||
var _hotbar_client: SpyHotbarClient
|
||||
|
||||
func setup(parent: Node, slot0_ability: Variant) -> void:
|
||||
_hotbar_state = HotbarState.new()
|
||||
_hotbar_client = SpyHotbarClient.new()
|
||||
parent.add_child(_hotbar_state)
|
||||
parent.add_child(_hotbar_client)
|
||||
var rows: Array = []
|
||||
for i in range(8):
|
||||
var row: Dictionary = {"slotIndex": i}
|
||||
if i == 0 and slot0_ability is String:
|
||||
row["abilityId"] = slot0_ability
|
||||
rows.append(row)
|
||||
_hotbar_state.call("apply_slots", rows)
|
||||
|
||||
func maybe_dev_bind_pulse_slot0() -> void:
|
||||
if bootstrap_attempted:
|
||||
return
|
||||
if not is_instance_valid(_hotbar_state) or not is_instance_valid(_hotbar_client):
|
||||
return
|
||||
var slots: Array = _hotbar_state.call("slots_snapshot") as Array
|
||||
if slots.is_empty():
|
||||
return
|
||||
var slot0: Variant = slots[0]
|
||||
if slot0 is String and not (slot0 as String).strip_edges().is_empty():
|
||||
return
|
||||
bootstrap_attempted = true
|
||||
_hotbar_client.request_bind_slot(0, DEV_BOOTSTRAP_CAST_ABILITY_ID)
|
||||
|
||||
|
||||
func test_empty_slot0_triggers_pulse_bind_once() -> void:
|
||||
# Arrange
|
||||
var harness := BootstrapHarness.new()
|
||||
auto_free(harness)
|
||||
add_child(harness)
|
||||
harness.setup(self, null)
|
||||
# Act
|
||||
harness.maybe_dev_bind_pulse_slot0()
|
||||
harness.maybe_dev_bind_pulse_slot0()
|
||||
# Assert
|
||||
assert_that(harness._hotbar_client.bind_calls.size()).is_equal(1)
|
||||
assert_that(int(harness._hotbar_client.bind_calls[0][0])).is_equal(0)
|
||||
assert_that(str(harness._hotbar_client.bind_calls[0][1])).is_equal(DEV_BOOTSTRAP_CAST_ABILITY_ID)
|
||||
|
||||
|
||||
func test_slot0_already_bound_skips_bootstrap() -> void:
|
||||
# Arrange
|
||||
var harness := BootstrapHarness.new()
|
||||
auto_free(harness)
|
||||
add_child(harness)
|
||||
harness.setup(self, "prototype_pulse")
|
||||
# Act
|
||||
harness.maybe_dev_bind_pulse_slot0()
|
||||
# Assert
|
||||
assert_that(harness._hotbar_client.bind_calls.size()).is_equal(0)
|
||||
assert_that(harness.bootstrap_attempted).is_false()
|
||||
|
|
@ -15,7 +15,7 @@
|
|||
|
||||
## 2) Client — combat HUD
|
||||
|
||||
- [ ] Open Godot main scene with server running; ensure hotbar slot **0** is bound to **`prototype_pulse`** (NEO-29 hydrate or bind via Bruno/API if needed).
|
||||
- [ ] Open Godot main scene with server running; after hotbar sync, slot **0** auto-binds to **`prototype_pulse`** when empty (NEO-85 dev bootstrap — same as Bruno `Post hotbar bind slot0 pulse`).
|
||||
- [ ] **Tab** to lock **`prototype_target_alpha`**; **`CombatTargetHpLabel`** shows **`Target HP: prototype_target_alpha 100/100`** (or **`…`** briefly until GET completes).
|
||||
- [ ] Press **1** (hotbar slot 1 → index 0): **`CastFeedbackLabel`** shows **`Cast: 25 dmg → 75 HP`** (not Output-only).
|
||||
- [ ] **`CombatTargetHpLabel`** updates to **`75/100`** after the cast (event-driven GET refresh).
|
||||
|
|
|
|||
Loading…
Reference in New Issue