diff --git a/client/README.md b/client/README.md index 03bad5d..ba64eb3 100644 --- a/client/README.md +++ b/client/README.md @@ -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). diff --git a/client/scripts/main.gd b/client/scripts/main.gd index 2b1994f..10bb98e 100644 --- a/client/scripts/main.gd +++ b/client/scripts/main.gd @@ -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: diff --git a/client/test/hotbar_dev_bootstrap_test.gd b/client/test/hotbar_dev_bootstrap_test.gd new file mode 100644 index 0000000..7cf1618 --- /dev/null +++ b/client/test/hotbar_dev_bootstrap_test.gd @@ -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() diff --git a/docs/manual-qa/NEO-85.md b/docs/manual-qa/NEO-85.md index d4c5e07..9d3ae44 100644 --- a/docs/manual-qa/NEO-85.md +++ b/docs/manual-qa/NEO-85.md @@ -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).