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
VinPropane 2026-05-25 13:22:06 -04:00
parent 504d3228dd
commit 35ff76b532
4 changed files with 114 additions and 2 deletions

View File

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

View File

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

View File

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

View File

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