Merge pull request #120 from ViPro-Technologies/NEO-85-client-combat-feedback-hp-hud

NEO-85: Client combat feedback + target HP HUD
pull/121/head^2
VinPropane 2026-05-25 13:43:10 -04:00 committed by GitHub
commit 87ec781edc
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
34 changed files with 1061 additions and 80 deletions

View File

@ -103,7 +103,7 @@ Full checklist: [`docs/manual-qa/NEO-25.md`](../manual-qa/NEO-25.md).
- **HUD:** `UICanvas/TargetLockLabel` shows the last **server-acknowledged** state: `lockedTargetId` (or **`—`** when no lock), `validity` (`none` / `ok` / `out_of_range` / `invalid_target`), `sequence`, one **display-only** line per known target in the form `<id>: <dist> m / <radius> (in|out)` (computed client-side from the live capsule position against `prototype_target_constants.gd`), and (on denials) `Denied: <reasonCode>` on the last line. **UI never shows an optimistic lock the server denied** — it always paints from the authoritative `targetState` echoed by each `200` response. Distance lines are a lag diagnostic: if the HUD shows `Validity: ok` but `alpha: 7.1 m / 6.0 (out)`, the server's position snapshot is lagging the visible capsule, and the next `authoritative_ack` refresh will reconcile. `UICanvas/PlayerPositionLabel` also shows the last **server-acknowledged** world position + horizontal delta from the visible capsule — when that `Δ` is large, targeting denials are being evaluated against a stale server snapshot.
- **`positionHint` on select (NEO-24 follow-up #5):** when `TargetSelectionClient.set_freshness_kick(authority, player)` is wired (as `main.gd` does), every `POST /target/select` body also carries `positionHint: {x,y,z}` = the live capsule position. The server uses the hint for the radius check and for the `validity` field in the echoed `targetState`, eliminating the cross-request race between `move-stream` and `target/select` POSTs — a Tab pressed several seconds after stopping (with an old stored snap on the server) now still succeeds when you are visibly in range. The hint is advisory (server never writes it to the position store); `move-stream` remains the sole write path. Tests that omit `set_freshness_kick(...)` fall back to the no-hint code path.
- **Freshness kick (ancillary):** alongside the hint, `set_freshness_kick(...)` also has the target client nudge the authority with `submit_stream_targets([player.global_position])` before each select POST. That write is no longer load-bearing for denial correctness (the hint handles that), but it keeps the stored snap reasonably fresh so the movement-triggered validity GET that runs on the next `authoritative_ack` sees current data.
- **Visible anchors:** `scripts/prototype_target_markers.gd` (under `World/PrototypeTargetMarkers`) spawns a small colored mast + a flat translucent ring at every `ANCHORS` entry so the lock radius is visible in-world. All prototype targets share a single radius (`PrototypeTargetConstants.SHARED_LOCK_RADIUS` = `PrototypeTargetRegistry.SharedLockRadius` = **6 m**); anchors are placed at `(-3, 0.5, -3)` and `(3, 0, 3)` so the two rings overlap at origin (~3.5 m wide), letting you flip between targets with Tab without locomotion. The script and `prototype_target_constants.gd` mirror the server's `PrototypeTargetRegistry.cs` — change them together in the same commit so the markers never drift from server authority. Per-target radii will return with real combat design (E5.M1).
- **Visible anchors:** `scripts/prototype_target_markers.gd` (under `World/PrototypeTargetMarkers`) spawns a **capsule dummy body**, floating **Label3D** name (**Dummy α** / **Dummy β**), and a flat translucent **lock ring** at every `ANCHORS` entry. **Orange** = `prototype_target_alpha` at **`(-3, 0.5, -3)`**; **purple** = `prototype_target_beta` at **`(3, 0, 3)`** — both near default spawn **`(-5, 0.9, -5)`** with overlapping 6 m rings at the origin. **Tab** locks a dummy; the locked capsule + ring **brighten**. All prototype targets share `PrototypeTargetConstants.SHARED_LOCK_RADIUS` = **6 m** (mirrors `PrototypeTargetRegistry.cs`). Keep client constants and server registry in the same commit when anchors move.
- **Soft lock refresh:** the client does **not** poll on a timer. It refreshes via `GET …/target` on boot, on every `POST …/target/select` response (body already carries `targetState`), and — while a lock is currently held — on **`authoritative_ack`** from `PositionAuthorityClient`, throttled to at most one GET per **250 ms**. `authoritative_ack` fires on every server-confirmed position (boot `GET` 200 **and** successful `move-stream` 200), so a held lock revalidates during normal WASD locomotion without re-introducing snap rubber-banding. Boot is naturally skipped because no lock exists yet. Purely server-driven state flips (future PvP / combat categories) would require revisiting this policy; today NEO-23 has none.
- **Inspector:** match **`base_url`** / **`dev_player_id`** on the `TargetSelectionClient` node to the other HTTP clients / `Game:DevPlayerId`.
@ -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 scenes 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,21 @@ 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: — (Tab → orange/purple dummy near spawn)`** when unlocked.
- **World dummies:** **`World/PrototypeTargetMarkers`** — orange **Dummy α** west of spawn, purple **Dummy β** south-east; **Tab** locks and highlights the active dummy (see Targeting section above).
- **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).
## 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).

View File

@ -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: — (Tab → dummy)"
[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")

View File

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

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

@ -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"
@ -75,6 +76,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
@ -88,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
@ -101,6 +105,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")
@ -115,6 +120,7 @@ var _skill_progression_label: Label = _economy_hud_section.get_node("Body/SkillP
@onready var _recipe_defs_client: Node = $RecipeDefinitionsClient
@onready var _craft_client: Node = $CraftClient
@onready var _target_client: Node = $TargetSelectionClient
@onready var _target_markers: Node3D = $World/PrototypeTargetMarkers
@onready var _interaction_client: Node = $InteractionRequestClient
@onready var _floor: StaticBody3D = $World/NavigationRegion3D/Floor
@ -265,9 +271,16 @@ 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 is_instance_valid(_target_markers) and _target_markers.has_method("set_locked_target"):
_target_markers.call("set_locked_target", new_lock)
if new_lock != prev_lock:
_request_combat_targets_refresh()
func _setup_hotbar_loadout_sync() -> void:
@ -298,9 +311,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()
@ -319,9 +345,7 @@ func _setup_cooldown_sync() -> void:
"snapshot_received", Callable(self, "_on_cooldown_snapshot_received")
)
if _hotbar_client.has_signal("loadout_changed"):
_hotbar_client.connect(
"loadout_changed", Callable(self, "_on_hotbar_loadout_changed_sync_cooldown")
)
_hotbar_client.connect("loadout_changed", Callable(self, "_on_hotbar_loadout_changed"))
_ensure_cooldown_poll_timer()
@ -728,6 +752,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)
@ -802,14 +847,18 @@ 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:
_cast_feedback_label.text = "Cast: 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 +867,60 @@ 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: — (Tab → orange/purple dummy near spawn)")
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.
@ -910,6 +1013,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 = {}
@ -922,8 +1028,14 @@ 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))
@ -951,6 +1063,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

@ -115,6 +115,17 @@ static func next_in_range_id_after(current: Variant, world: Vector3) -> String:
return ""
## Player-facing short label for world [Label3D] markers (NEO-85 combat UX).
static func display_short_name(target_id: String) -> String:
match target_id:
"prototype_target_alpha":
return "Dummy α"
"prototype_target_beta":
return "Dummy β"
_:
return target_id.strip_edges()
## Returns the next id in [constant ORDERED_IDS] after [param current]. Returns the **first** id
## when [param current] is [code]null[/code], empty, or not found (start of cycle). Wraps from
## last → first.

View File

@ -1,23 +1,25 @@
extends Node3D
## NEO-24 UX: the targeting registry anchors (`prototype_target_alpha`,
## `prototype_target_beta`) are invisible in the world, so "Tab says ok but I feel
## far away" is impossible to reason about without debug markers. This node spawns
## one small mast + a flat radius ring per id in
## `PrototypeTargetConstants.ANCHORS`, so the player can see exactly where each
## anchor sits and how big its lock radius is.
## NEO-24 UX: combat dummy anchors (`prototype_target_alpha`, `prototype_target_beta`) need
## obvious world markers — a capsule body, lock ring, and [Label3D] name per
## `PrototypeTargetConstants.ANCHORS`. NEO-85: highlight the server-locked dummy via
## [method set_locked_target].
##
## Display-only; no game logic hangs off these nodes. If the server registry moves,
## update `prototype_target_constants.gd` — markers respawn on next scene load.
const PrototypeTargetConstants := preload("res://scripts/prototype_target_constants.gd")
## Visual tuning — keep markers unmistakable but not dominant.
const _MAST_HEIGHT: float = 2.5
const _MAST_RADIUS: float = 0.18
## Visual tuning — readable from the default isometric camera at spawn.
const _DUMMY_CAPSULE_HEIGHT: float = 1.75
const _DUMMY_CAPSULE_RADIUS: float = 0.38
const _RING_RADIUS_SEGMENTS: int = 72
const _RING_Y: float = 0.05
const _RING_THICKNESS: float = 0.06
const _RING_THICKNESS: float = 0.08
const _LABEL_Y: float = 2.15
var _markers_by_id: Dictionary = {}
var _locked_target_id: String = ""
func _ready() -> void:
@ -27,6 +29,11 @@ func _ready() -> void:
_spawn_marker(id_variant as String)
func set_locked_target(target_id: String) -> void:
_locked_target_id = target_id.strip_edges()
_apply_highlight_states()
func _spawn_marker(target_id: String) -> void:
var anchor: Vector3 = PrototypeTargetConstants.anchor_position(target_id)
var radius: float = PrototypeTargetConstants.anchor_radius(target_id)
@ -39,23 +46,64 @@ func _spawn_marker(target_id: String) -> void:
group.position = anchor
add_child(group)
var mast := MeshInstance3D.new()
mast.name = "Mast"
var mast_mesh := CylinderMesh.new()
mast_mesh.top_radius = _MAST_RADIUS
mast_mesh.bottom_radius = _MAST_RADIUS
mast_mesh.height = _MAST_HEIGHT
mast.mesh = mast_mesh
# CylinderMesh is Y-centered at its origin; raise so the base sits on the anchor Y.
mast.position = Vector3(0.0, _MAST_HEIGHT * 0.5, 0.0)
mast.material_override = _make_unshaded_material(tint, 1.0)
group.add_child(mast)
var body := MeshInstance3D.new()
body.name = "DummyBody"
var capsule := CapsuleMesh.new()
capsule.radius = _DUMMY_CAPSULE_RADIUS
capsule.height = _DUMMY_CAPSULE_HEIGHT
body.mesh = capsule
body.position = Vector3(0.0, _DUMMY_CAPSULE_HEIGHT * 0.5, 0.0)
var body_mat := _make_unshaded_material(tint, 0.88)
body.material_override = body_mat
group.add_child(body)
var ring: MeshInstance3D = _make_radius_ring(radius, tint)
ring.name = "RadiusRing"
ring.position = Vector3(0.0, _RING_Y - anchor.y, 0.0)
group.add_child(ring)
var label := Label3D.new()
label.name = "NameLabel"
label.text = PrototypeTargetConstants.display_short_name(target_id)
label.font_size = 24
label.outline_size = 6
label.modulate = tint.lightened(0.15)
label.billboard = BaseMaterial3D.BILLBOARD_ENABLED
label.position = Vector3(0.0, _LABEL_Y, 0.0)
group.add_child(label)
_markers_by_id[target_id] = {
"body_mat": body_mat,
"ring_mat": ring.material_override,
"tint": tint,
}
func _apply_highlight_states() -> void:
for id_variant: Variant in _markers_by_id.keys():
if not id_variant is String:
continue
var target_id: String = id_variant as String
var entry: Dictionary = _markers_by_id[target_id]
var tint: Color = entry["tint"]
var body_mat: StandardMaterial3D = entry["body_mat"]
var ring_mat: StandardMaterial3D = entry["ring_mat"]
var highlighted: bool = target_id == _locked_target_id and not _locked_target_id.is_empty()
if highlighted:
body_mat.albedo_color = tint.lightened(0.25)
body_mat.emission_enabled = true
body_mat.emission = tint
body_mat.emission_energy_multiplier = 1.4
var ring_c := tint
ring_c.a = 0.78
ring_mat.albedo_color = ring_c
else:
body_mat.albedo_color = Color(tint.r, tint.g, tint.b, 0.88)
body_mat.emission_enabled = false
var ring_c2 := tint
ring_c2.a = 0.45
ring_mat.albedo_color = ring_c2
func _make_unshaded_material(tint: Color, alpha: float) -> StandardMaterial3D:
var m := StandardMaterial3D.new()
@ -68,9 +116,6 @@ func _make_unshaded_material(tint: Color, alpha: float) -> StandardMaterial3D:
return m
## Builds a thin flat ring (annulus) by triangulating the band between `radius - t/2`
## and `radius + t/2`. `ImmediateMesh` with `SurfaceTool` would also work, but an
## `ArrayMesh` keeps this a static resource with no per-frame cost.
func _make_radius_ring(radius: float, tint: Color) -> MeshInstance3D:
var mi := MeshInstance3D.new()
var arr_mesh := ArrayMesh.new()

View File

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

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
await assert_signal(harness._cast).is_emitted("cast_result_received", true, "", any())
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 @@
uid://bd24nxwx2fju6

View File

@ -0,0 +1,122 @@
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")
func test_http_404_emits_sync_failed() -> void:
# Arrange
var transport := MockHttpTransport.new()
transport.response_code = 404
transport.body_json = ""
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])).is_equal("HTTP 404")

View File

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

View File

@ -72,7 +72,9 @@ func test_successful_craft_triggers_both_refreshes() -> void:
# Act
harness.post_craft("refine_scrap_standard")
# Assert
assert_signal(harness._craft).is_emitted("craft_result_received")
await assert_signal(harness._craft).is_emitted(
"craft_result_received", "refine_scrap_standard", any()
)
assert_that(harness._inventory.sync_calls).is_equal(1)
assert_that(harness._skill.sync_calls).is_equal(1)

View File

@ -109,7 +109,9 @@ func test_allowed_resource_interact_triggers_both_refreshes() -> void:
# Act
harness.post_resource_alpha()
# Assert
assert_signal(harness._ix).is_emitted("interaction_result_received")
await assert_signal(harness._ix).is_emitted(
"interaction_result_received", "prototype_resource_node_alpha", true, ""
)
assert_that(harness._inventory.sync_calls).is_equal(1)
assert_that(harness._skill.sync_calls).is_equal(1)

View File

@ -0,0 +1,78 @@
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

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

View File

@ -156,7 +156,7 @@ func test_denied_update_emits_reason_signal() -> void:
# Act
c.call("request_bind_slot", 1, "not_real")
# Assert
assert_signal(c).is_emitted("loadout_update_denied")
await assert_signal(c).is_emitted("loadout_update_denied", "unknown_ability", any())
func test_bind_slot_while_busy_is_ignored() -> void:

View File

@ -118,7 +118,7 @@ func test_interaction_result_emits_allow_signal() -> void:
# Act
ix.call("post_interact_id", "prototype_resource_node_alpha")
# Assert
assert_signal(ix).is_emitted(
await assert_signal(ix).is_emitted(
"interaction_result_received", "prototype_resource_node_alpha", true, ""
)
@ -132,7 +132,7 @@ func test_interaction_result_emits_deny_signal() -> void:
# Act
ix.call("post_interact_id", "prototype_urban_bulk_delta")
# Assert
assert_signal(ix).is_emitted(
await assert_signal(ix).is_emitted(
"interaction_result_received", "prototype_urban_bulk_delta", false, "node_depleted"
)
@ -146,7 +146,7 @@ func test_interaction_request_failed_emits_on_http_error() -> void:
# Act
ix.call("post_interact_id", "prototype_resource_node_alpha")
# Assert
assert_signal(ix).is_emitted(
await assert_signal(ix).is_emitted(
"interaction_request_failed", "prototype_resource_node_alpha", "HTTP 500"
)

View File

@ -82,7 +82,7 @@ func test_request_sync_emits_inventory_received() -> void:
# Act
c.call("request_sync_from_server")
# Assert
assert_signal(c).is_emitted("inventory_received")
await assert_signal(c).is_emitted("inventory_received", any())
assert_that(_inventory_capture.has("bagSlots")).is_true()
assert_that((_inventory_capture["bagSlots"] as Array).size()).is_equal(24)
assert_that((_inventory_capture["equipmentSlots"] as Array).size()).is_equal(1)
@ -98,7 +98,7 @@ func test_http_404_emits_inventory_sync_failed() -> void:
# Act
c.call("request_sync_from_server")
# Assert
assert_signal(c).is_emitted("inventory_sync_failed", "HTTP 404 (player unknown)")
await assert_signal(c).is_emitted("inventory_sync_failed", "HTTP 404 (player unknown)")
func test_malformed_json_emits_inventory_sync_failed() -> void:
@ -110,4 +110,4 @@ func test_malformed_json_emits_inventory_sync_failed() -> void:
# Act
c.call("request_sync_from_server")
# Assert
assert_signal(c).is_emitted("inventory_sync_failed")
await assert_signal(c).is_emitted("inventory_sync_failed", any())

View File

@ -84,7 +84,7 @@ func test_request_sync_hits_definitions_endpoint_and_emits() -> void:
c.call("request_sync_from_server")
# Assert
assert_that(transport.last_url).contains("/game/world/item-definitions")
assert_signal(c).is_emitted("definitions_ready")
await assert_signal(c).is_emitted("definitions_ready", any())
func test_display_name_for_after_request_sync() -> void:
@ -107,4 +107,4 @@ func test_http_error_does_not_emit_definitions_ready() -> void:
# Act
c.call("request_sync_from_server")
# Assert
assert_signal(c).wait_until(400).is_not_emitted("definitions_ready")
await assert_signal(c).wait_until(400).is_not_emitted("definitions_ready")

View File

@ -65,7 +65,9 @@ func test_sync_boot_get_200_emits_snap() -> void:
# Act
c.sync_from_server()
# Assert
assert_signal(c).is_emitted("authoritative_position_received", Vector3(1.0, 0.9, -5.0), true)
await assert_signal(c).is_emitted(
"authoritative_position_received", Vector3(1.0, 0.9, -5.0), true
)
# NEO-24: boot sync also emits the cooldown-throttled heartbeat.
@ -78,7 +80,7 @@ func test_sync_boot_get_200_emits_authoritative_ack() -> void:
# Act
c.sync_from_server()
# Assert
assert_signal(c).is_emitted("authoritative_ack", Vector3(1.0, 0.9, -5.0))
await assert_signal(c).is_emitted("authoritative_ack", Vector3(1.0, 0.9, -5.0))
func test_sync_boot_get_200_malformed_position_does_not_emit() -> void:
@ -90,7 +92,7 @@ func test_sync_boot_get_200_malformed_position_does_not_emit() -> void:
# Act
c.sync_from_server()
# Assert
assert_signal(c).wait_until(400).is_not_emitted("authoritative_position_received")
await assert_signal(c).wait_until(400).is_not_emitted("authoritative_position_received")
func test_move_stream_post_400_emits_move_rejected_then_boot_get() -> void:
@ -103,8 +105,10 @@ func test_move_stream_post_400_emits_move_rejected_then_boot_get() -> void:
# Act
c.submit_stream_targets([Vector3(1.0, 2.0, 3.0)])
# Assert
assert_signal(c).is_emitted("move_rejected", "vertical_step_exceeded")
assert_signal(c).is_emitted("authoritative_position_received", Vector3(0.0, 0.9, 0.0), true)
await assert_signal(c).is_emitted("move_rejected", "vertical_step_exceeded")
await assert_signal(c).is_emitted(
"authoritative_position_received", Vector3(0.0, 0.9, 0.0), true
)
func test_move_stream_post_400_without_reason_emits_unknown() -> void:
@ -117,8 +121,10 @@ func test_move_stream_post_400_without_reason_emits_unknown() -> void:
# Act
c.submit_stream_targets([Vector3.ZERO])
# Assert
assert_signal(c).is_emitted("move_rejected", "unknown")
assert_signal(c).is_emitted("authoritative_position_received", Vector3(1.0, 0.9, 1.0), true)
await assert_signal(c).is_emitted("move_rejected", "unknown")
await assert_signal(c).is_emitted(
"authoritative_position_received", Vector3(1.0, 0.9, 1.0), true
)
func test_second_sync_while_busy_is_ignored() -> void:
@ -148,7 +154,7 @@ func test_move_stream_post_200_does_not_emit_position() -> void:
# Act
c.submit_stream_targets([Vector3(-4.9, 0.9, -5.0)])
# Assert
assert_signal(c).wait_until(400).is_not_emitted("authoritative_position_received")
await assert_signal(c).wait_until(400).is_not_emitted("authoritative_position_received")
# NEO-24: move-stream 200 must emit `authoritative_ack` so a held target lock can re-validate
@ -169,4 +175,4 @@ func test_move_stream_post_200_emits_authoritative_ack() -> void:
# Act
c.submit_stream_targets([Vector3(-4.9, 0.9, -5.0)])
# Assert
assert_signal(c).is_emitted("authoritative_ack", Vector3(-4.9, 0.9, -5.0))
await assert_signal(c).is_emitted("authoritative_ack", Vector3(-4.9, 0.9, -5.0))

View File

@ -0,0 +1,23 @@
extends GdUnitTestSuite
## NEO-85: prototype target display helpers for combat dummy markers and HUD copy.
const PrototypeTargetConstants := preload("res://scripts/prototype_target_constants.gd")
func test_display_short_name_maps_alpha_and_beta() -> void:
# Arrange
# Act
var alpha: String = PrototypeTargetConstants.display_short_name("prototype_target_alpha")
var beta: String = PrototypeTargetConstants.display_short_name("prototype_target_beta")
# Assert
assert_that(alpha).is_equal("Dummy α")
assert_that(beta).is_equal("Dummy β")
func test_display_short_name_falls_back_to_id() -> void:
# Arrange
# Act
var unknown: String = PrototypeTargetConstants.display_short_name("unknown_target")
# Assert
assert_that(unknown).is_equal("unknown_target")

View File

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

View File

@ -80,7 +80,7 @@ func test_request_sync_emits_recipes_ready_in_order() -> void:
# Act
c.call("request_sync_from_server")
# Assert
assert_signal(c).is_emitted("recipes_ready")
await assert_signal(c).is_emitted("recipes_ready", any())
assert_that(_recipes_capture.size()).is_equal(2)
assert_that(str(_recipes_capture[0].get("id", ""))).is_equal("make_armor_quick")
assert_that(transport.last_url).contains("/recipe-definitions")
@ -95,7 +95,7 @@ func test_schema_mismatch_emits_recipes_sync_failed() -> void:
# Act
c.call("request_sync_from_server")
# Assert
assert_signal(c).is_emitted("recipes_sync_failed")
await assert_signal(c).is_emitted("recipes_sync_failed", any())
func test_http_error_emits_recipes_sync_failed_with_status() -> void:

View File

@ -80,7 +80,7 @@ func test_request_sync_emits_progression_received() -> void:
# Act
c.call("request_sync_from_server")
# Assert
assert_signal(c).is_emitted("progression_received")
await assert_signal(c).is_emitted("progression_received", any())
assert_that(_progression_capture.has("skills")).is_true()
assert_that(transport.last_url).contains("/skill-progression")
@ -94,4 +94,4 @@ func test_http_404_emits_progression_sync_failed() -> void:
# Act
c.call("request_sync_from_server")
# Assert
assert_signal(c).is_emitted("progression_sync_failed", "HTTP 404 (player unknown)")
await assert_signal(c).is_emitted("progression_sync_failed", "HTTP 404 (player unknown)")

View File

@ -91,7 +91,7 @@ func test_sync_get_200_emits_state() -> void:
# Act
c.request_sync_from_server()
# Assert
assert_signal(c).is_emitted("target_state_changed")
await assert_signal(c).is_emitted("target_state_changed", any())
var state: Dictionary = c.cached_state()
assert_that(state.get("validity", "")).is_equal("none")
assert_that(state.get("lockedTargetId", "non-null-sentinel")).is_null()
@ -111,7 +111,7 @@ func test_tab_from_no_lock_selects_first_id() -> void:
assert_that(transport.last_url).contains("/target/select")
assert_that(transport.last_method).is_equal(HTTPClient.METHOD_POST)
assert_that(transport.last_body).contains('"targetId":"%s"' % ALPHA_ID)
assert_signal(c).is_emitted("target_state_changed")
await assert_signal(c).is_emitted("target_state_changed", any())
assert_that(c.cached_state().get("lockedTargetId")).is_equal(ALPHA_ID)

View File

@ -7,7 +7,7 @@
| **Module ID** | E5.M1 |
| **Epic** | [Epic 5 — PvE Combat](../epics/epic_05_pve_combat.md) |
| **Stage target** | Prototype |
| **Status** | In Progress — Slice 1 backlog [E5M1-prototype-backlog.md](../../plans/E5M1-prototype-backlog.md): E5M1-01 [NEO-76](https://linear.app/neon-sprawl/issue/NEO-76) ability catalog + CI landed; E5M1-02 [NEO-77](https://linear.app/neon-sprawl/issue/NEO-77) server load landed; E5M1-03 [NEO-79](https://linear.app/neon-sprawl/issue/NEO-79) registry + hotbar/cast migration landed; E5M1-04 [NEO-78](https://linear.app/neon-sprawl/issue/NEO-78) ability-definitions GET landed; E5M1-05 [NEO-80](https://linear.app/neon-sprawl/issue/NEO-80) combat entity health store landed; E5M1-06 [NEO-81](https://linear.app/neon-sprawl/issue/NEO-81) combat operations + `CombatResult` landed; E5M1-07 [NEO-82](https://linear.app/neon-sprawl/issue/NEO-82) cast POST + wire `combatResolution` landed; E5M1-08 [NEO-83](https://linear.app/neon-sprawl/issue/NEO-83) combat-targets GET landed; E5M1-09 [NEO-84](https://linear.app/neon-sprawl/issue/NEO-84) combat telemetry hook sites landed; E5M1-10 [NEO-44](https://linear.app/neon-sprawl/issue/NEO-44) gig XP on defeat landed; E5M1-11+ pending (see [dependency register](module_dependency_register.md)) |
| **Status** | In Progress — Slice 1 backlog [E5M1-prototype-backlog.md](../../plans/E5M1-prototype-backlog.md): E5M1-01 [NEO-76](https://linear.app/neon-sprawl/issue/NEO-76) ability catalog + CI landed; E5M1-02 [NEO-77](https://linear.app/neon-sprawl/issue/NEO-77) server load landed; E5M1-03 [NEO-79](https://linear.app/neon-sprawl/issue/NEO-79) registry + hotbar/cast migration landed; E5M1-04 [NEO-78](https://linear.app/neon-sprawl/issue/NEO-78) ability-definitions GET landed; E5M1-05 [NEO-80](https://linear.app/neon-sprawl/issue/NEO-80) combat entity health store landed; E5M1-06 [NEO-81](https://linear.app/neon-sprawl/issue/NEO-81) combat operations + `CombatResult` landed; E5M1-07 [NEO-82](https://linear.app/neon-sprawl/issue/NEO-82) cast POST + wire `combatResolution` landed; E5M1-08 [NEO-83](https://linear.app/neon-sprawl/issue/NEO-83) combat-targets GET landed; E5M1-09 [NEO-84](https://linear.app/neon-sprawl/issue/NEO-84) combat telemetry hook sites landed; E5M1-10 [NEO-44](https://linear.app/neon-sprawl/issue/NEO-44) gig XP on defeat landed; E5M1-11 [NEO-85](https://linear.app/neon-sprawl/issue/NEO-85) client combat HUD landed; E5M1-12 pending (see [dependency register](module_dependency_register.md)) |
| **Linear** | Label **`E5.M1`** · [E5M1-prototype-backlog.md](../../plans/E5M1-prototype-backlog.md) **E5M1-01** [NEO-76](https://linear.app/neon-sprawl/issue/NEO-76) → **E5M1-12** [NEO-86](https://linear.app/neon-sprawl/issue/NEO-86); gig XP **E5M1-10** [NEO-44](https://linear.app/neon-sprawl/issue/NEO-44) |
## Purpose
@ -64,6 +64,8 @@ See Epic 5 **Slice 1 — Combat rules MVP**: target lock, basic attacks, 46 a
**E5M1-10 (NEO-44):** **`CombatDefeatGigXpGrant`** on cast accept when **`targetDefeated`** — **25** gig XP to prototype main gig **`breach`** via **`IPlayerGigProgressionStore`** (not E2.M2 skill XP); **`GET /game/players/{id}/gig-progression`** ([NEO-44 plan](../../plans/NEO-44-implementation-plan.md)); [server README — Gig progression snapshot (NEO-44)](../../../server/README.md#gig-progression-snapshot-neo-44); Bruno `bruno/neon-sprawl-server/gig-progression/`.
**E5M1-11 (NEO-85):** client combat HUD — **`ability_cast_client.gd`** parses nested **`combatResolution`** on accept; **`combat_targets_client.gd`** + **`CombatTargetHpLabel`**; **`CastFeedbackLabel`** damage/defeat copy; event-driven **`GET /game/world/combat-targets`** refresh on cast accept and lock change ([NEO-85 plan](../../plans/NEO-85-implementation-plan.md), [`NEO-85` manual QA](../../manual-qa/NEO-85.md)); [client README — Combat feedback + target HP HUD (NEO-85)](../../../client/README.md#combat-feedback--target-hp-hud-neo-85).
## Linear backlog (decomposed)
Full story tables, kickoff defaults, and **`blockedBy` graph:** [E5M1-prototype-backlog.md](../../plans/E5M1-prototype-backlog.md).

View File

@ -56,7 +56,7 @@ Rows appear when work starts; default for unlisted modules is **Planned** / not
| E3.M1 | Ready | **NEO-41 landed (prototype, superseded on interact by NEO-63):** inline XP on **`resource_node`** interact. **NEO-57NEO-61** — content, registry, HTTP defs, depletion store (see prior alignment). **NEO-62 landed:** **`GatherOperations`** + **`GatherResult`**. **NEO-63 landed:** **`POST …/interact`** **`resource_node`** → gather engine; **four** **`PrototypeInteractableRegistry`** anchors; Bruno gather → inventory + depletion deny ([NEO-63](../../plans/NEO-63-implementation-plan.md), [`NEO-63` manual QA](../../manual-qa/NEO-63.md)); [server README — Gather via interact (NEO-63)](../../../server/README.md#gather-via-interact-neo-63). **NEO-64 landed:** comment-only **`resource_gathered`** / **`gather_node_depleted`** hook sites in **`GatherOperations.TryGather`** ([NEO-64](../../plans/NEO-64-implementation-plan.md), [`NEO-64` manual QA](../../manual-qa/NEO-64.md)); Epic 3 Slice 2 backlog **E3M1-01****E3M1-08** complete. **NEO-73 landed:** client gather feedback — **`skill_progression_client.gd`**, **`prototype_interactable_picker.gd`** (nearest in-range **R**), **`GatherFeedbackLabel`** + **`SkillProgressionLabel`**, auto inventory/skill refresh after successful gather ([NEO-73](../../plans/NEO-73-implementation-plan.md), [`NEO-73` manual QA](../../manual-qa/NEO-73.md)); `client/README.md` gather section. **NEO-75 landed:** capstone gather→refine→make loop in Godot — [`NEO-75` manual QA](../../manual-qa/NEO-75.md), `client/README.md` end-to-end section; Epic 3 Slice 5 client complete. | [NEO-41](../../plans/NEO-41-implementation-plan.md) … [NEO-64](../../plans/NEO-64-implementation-plan.md), [NEO-73](../../plans/NEO-73-implementation-plan.md), [NEO-75](../../plans/NEO-75-implementation-plan.md), [E3S5-client-prototype-backlog](../../plans/E3S5-client-prototype-backlog.md), [E3_M1](E3_M1_ResourceNodeAndGatherLoop.md); `server/NeonSprawl.Server/Game/Gathering/`, `Game/Interaction/`, `client/scripts/skill_progression_client.gd`, `client/scripts/prototype_interactable_picker.gd` |
| E3.M3 | In Progress | **NEO-50 landed:** frozen prototype six-item catalog in [`content/items/prototype_items.json`](../../../content/items/prototype_items.json); [`item-def.schema.json`](../../../content/schemas/item-def.schema.json); PR gate + [`validate_content.py`](../../../scripts/validate_content.py). **NEO-51 landed:** fail-fast server load of `content/items/*_items.json` at startup — `server/NeonSprawl.Server/Game/Items/` ([NEO-51](../../plans/NEO-51-implementation-plan.md)); [server README — Item catalog](../../../server/README.md#item-catalog-contentitems-neo-51). **NEO-52 landed:** injectable **`IItemDefinitionRegistry`** + lookup tests ([NEO-52](../../plans/NEO-52-implementation-plan.md)). **NEO-53 landed:** **`GET /game/world/item-definitions`** — `ItemDefinitionsWorldApi` + DTOs in `Game/Items/` ([NEO-53](../../plans/NEO-53-implementation-plan.md), [`NEO-53` manual QA](../../manual-qa/NEO-53.md)); [server README — Item definitions (NEO-53)](../../../server/README.md#item-definitions-neo-53); Bruno `bruno/neon-sprawl-server/item-definitions/`. **NEO-54 landed:** **`IPlayerInventoryStore`** + **`PlayerInventoryOperations`** — 24 bag + 1 equipment slots, stack rules, `V005` migration ([NEO-54](../../plans/NEO-54-implementation-plan.md)). **NEO-55 landed:** **`GET`/`POST /game/players/{id}/inventory`** — `PlayerInventoryApi` + DTOs in `Game/Items/` ([NEO-55](../../plans/NEO-55-implementation-plan.md)); [server README — Player inventory (NEO-54 store, NEO-55 HTTP)](../../../server/README.md#player-inventory-neo-54-store-neo-55-http); Bruno `bruno/neon-sprawl-server/inventory/`. **NEO-56 landed:** comment-only **`item_created`** / **`inventory_transfer_denied`** telemetry hook sites in [`PlayerInventoryOperations`](../../../server/NeonSprawl.Server/Game/Items/PlayerInventoryOperations.cs) ([NEO-56](../../plans/NEO-56-implementation-plan.md), [`NEO-56` manual QA](../../manual-qa/NEO-56.md)); no E9.M1 ingest. **NEO-72 landed:** client inventory HUD — **`inventory_client.gd`**, **`item_definitions_client.gd`**, **`InventoryLabel`**, boot hydrate + **`I`** refresh ([NEO-72](../../plans/NEO-72-implementation-plan.md), [`NEO-72` manual QA](../../manual-qa/NEO-72.md)); `client/README.md` inventory HUD section. **NEO-75 landed:** **`prototype_economy_hud_section.gd`** collapse toggle + capstone manual QA ([NEO-75](../../plans/NEO-75-implementation-plan.md), [`NEO-75` manual QA](../../manual-qa/NEO-75.md)); Epic 3 Slice 5 client complete. | [NEO-50](../../plans/NEO-50-implementation-plan.md), [NEO-51](../../plans/NEO-51-implementation-plan.md), [NEO-52](../../plans/NEO-52-implementation-plan.md), [NEO-53](../../plans/NEO-53-implementation-plan.md), [NEO-54](../../plans/NEO-54-implementation-plan.md), [NEO-55](../../plans/NEO-55-implementation-plan.md), [NEO-56](../../plans/NEO-56-implementation-plan.md), [NEO-72](../../plans/NEO-72-implementation-plan.md), [NEO-75](../../plans/NEO-75-implementation-plan.md), [E3S5-client-prototype-backlog](../../plans/E3S5-client-prototype-backlog.md), [E3M3-prototype-backlog](../../plans/E3M3-prototype-backlog.md), [E3_M3](E3_M3_ItemizationAndInventorySchema.md) |
| E3.M2 | Ready | **NEO-65 landed:** frozen prototype eight-recipe catalog in [`content/recipes/prototype_recipes.json`](../../../content/recipes/prototype_recipes.json); [`recipe-def.schema.json`](../../../content/schemas/recipe-def.schema.json) + [`recipe-io-row.schema.json`](../../../content/schemas/recipe-io-row.schema.json); PR gate + [`validate_content.py`](../../../scripts/validate_content.py) Slice 3 gates ([NEO-65](../../plans/NEO-65-implementation-plan.md)). **NEO-66 landed:** fail-fast server load of `content/recipes/*_recipes.json` at startup — `server/NeonSprawl.Server/Game/Crafting/` ([NEO-66](../../plans/NEO-66-implementation-plan.md)); [server README — Recipe catalog](../../../server/README.md#recipe-catalog-contentrecipes-neo-66). **NEO-67 landed:** injectable **`IRecipeDefinitionRegistry`** + lookup tests ([NEO-67](../../plans/NEO-67-implementation-plan.md)). **NEO-68 landed:** **`GET /game/world/recipe-definitions`** — `RecipeDefinitionsWorldApi` + DTOs in `Game/Crafting/` ([NEO-68](../../plans/NEO-68-implementation-plan.md), [`NEO-68` manual QA](../../manual-qa/NEO-68.md)); [server README — Recipe definitions (NEO-68)](../../../server/README.md#recipe-definitions-neo-68); Bruno `bruno/neon-sprawl-server/recipe-definitions/`. **NEO-69 landed:** **`CraftOperations.TryCraft`** + **`CraftResult`** — pre-flight inputs/outputs, inventory mutation, refine XP ([NEO-69](../../plans/NEO-69-implementation-plan.md)); [server README — Craft engine (NEO-69)](../../../server/README.md#craft-engine-neo-69-and-craft-http-neo-70). **NEO-70 landed:** **`POST /game/players/{id}/craft`** — `PlayerCraftApi` + wire **`CraftResponse`**; Bruno gather→refine→make spine ([NEO-70](../../plans/NEO-70-implementation-plan.md)); Bruno `bruno/neon-sprawl-server/craft/`. **NEO-71 landed:** comment-only **`item_crafted`** / **`craft_failed`** telemetry hook sites in **`CraftOperations.TryCraft`** ([NEO-71](../../plans/NEO-71-implementation-plan.md)); [server README — Craft telemetry hooks (NEO-71)](../../../server/README.md#craft-telemetry-hooks-neo-71). **NEO-42 landed (prep):** **`RefineActivitySkillXpGrant`** / **`RefineSkillXpConstants`** — wired from craft engine success path. Epic 3 Slice 3 server backlog **E3M2-01****E3M2-07** complete. Upstream: E3.M1 **Ready**, E3.M3 inventory landed. **NEO-74 landed:** client craft UI — **`recipe_definitions_client.gd`**, **`craft_client.gd`**, **`craft_recipe_panel.gd`**, **`CraftFeedbackLabel`**, **refine** skill row ([NEO-74](../../plans/NEO-74-implementation-plan.md), [`NEO-74` manual QA](../../manual-qa/NEO-74.md)); `client/README.md` craft section. **NEO-75 landed:** capstone playable gather→refine→make loop — [`NEO-75` manual QA](../../manual-qa/NEO-75.md), **`prototype_economy_hud_section.gd`**; Epic 3 Slice 5 client complete. | [NEO-42](../../plans/NEO-42-implementation-plan.md), [NEO-65](../../plans/NEO-65-implementation-plan.md), [NEO-66](../../plans/NEO-66-implementation-plan.md), [NEO-67](../../plans/NEO-67-implementation-plan.md), [NEO-68](../../plans/NEO-68-implementation-plan.md), [NEO-69](../../plans/NEO-69-implementation-plan.md), [NEO-70](../../plans/NEO-70-implementation-plan.md), [NEO-71](../../plans/NEO-71-implementation-plan.md), [NEO-74](../../plans/NEO-74-implementation-plan.md), [NEO-75](../../plans/NEO-75-implementation-plan.md), [E3M2-prototype-backlog](../../plans/E3M2-prototype-backlog.md), [E3S5-client-prototype-backlog](../../plans/E3S5-client-prototype-backlog.md), [E3_M2](E3_M2_RefinementAndRecipeExecution.md) |
| E5.M1 | In Progress | **NEO-76 landed:** frozen prototype four-ability catalog in [`content/abilities/prototype_abilities.json`](../../../content/abilities/prototype_abilities.json); [`ability-def.schema.json`](../../../content/schemas/ability-def.schema.json); PR gate + [`validate_content.py`](../../../scripts/validate_content.py) E5M1 four-id gate ([NEO-76](../../plans/NEO-76-implementation-plan.md)). **NEO-77 landed:** fail-fast server load of `content/abilities/*_abilities.json` at startup — `server/NeonSprawl.Server/Game/Combat/` ([NEO-77](../../plans/NEO-77-implementation-plan.md)); [server README — Ability catalog](../../../server/README.md#ability-catalog-contentabilities-neo-77). **NEO-79 landed:** injectable **`IAbilityDefinitionRegistry`** + DI; hotbar/cast use **`TryNormalizeKnown`** ([NEO-79](../../plans/NEO-79-implementation-plan.md)); Bruno `bruno/neon-sprawl-server/hotbar-loadout/` + `ability-cast/` unknown-ability deny smokes. **NEO-78 landed:** **`GET /game/world/ability-definitions`** — `AbilityDefinitionsWorldApi` + DTOs in `Game/Combat/` ([NEO-78](../../plans/NEO-78-implementation-plan.md)); [server README — Ability definitions (NEO-78)](../../../server/README.md#ability-definitions-neo-78); Bruno `bruno/neon-sprawl-server/ability-definitions/`. **NEO-80 landed:** **`ICombatEntityHealthStore`** / **`InMemoryCombatEntityHealthStore`** — lazy prototype dummy HP at **`PrototypeCombatConstants.MaxPrototypeTargetHp` (100)**; DI via **`AddCombatEntityHealthStore()`** ([NEO-80](../../plans/NEO-80-implementation-plan.md)); [server README — Combat entity health (NEO-80)](../../../server/README.md#combat-entity-health-neo-80). **NEO-81 landed:** **`CombatOperations.TryResolve`** + **`CombatResult`** + **`CombatReasonCodes`** — defeated pre-check deny, catalog damage ([NEO-81](../../plans/NEO-81-implementation-plan.md)); [server README — Combat engine (NEO-81)](../../../server/README.md#combat-engine-neo-81). **NEO-82 landed:** **`AbilityCastApi`** → **`CombatOperations.TryResolve`**; nested wire **`combatResolution`** on accept; per-ability catalog cooldown; **`target_defeated`** cast deny ([NEO-82](../../plans/NEO-82-implementation-plan.md)); [server README — Ability cast (NEO-82)](../../../server/README.md#ability-cast-neo-31-neo-82); Bruno `bruno/neon-sprawl-server/ability-cast/` defeat spine. **NEO-83 landed:** **`GET /game/world/combat-targets`** — `CombatTargetsWorldApi` + DTOs; authoritative HP snapshot from **`ICombatEntityHealthStore`** ([NEO-83](../../plans/NEO-83-implementation-plan.md)); [server README — Combat targets snapshot (NEO-83)](../../../server/README.md#combat-targets-snapshot-neo-83); Bruno `bruno/neon-sprawl-server/combat-targets/`. **NEO-44 landed:** **`CombatDefeatGigXpGrant`** on cast **`targetDefeated`** — **25** gig XP to **`breach`** via **`IPlayerGigProgressionStore`** (V007); **`GET …/gig-progression`** ([NEO-44](../../plans/NEO-44-implementation-plan.md), [`NEO-44` manual QA](../../manual-qa/NEO-44.md)); [server README — Gig progression (NEO-44)](../../../server/README.md#gig-progression-snapshot-neo-44); Bruno `bruno/neon-sprawl-server/gig-progression/`. **NEO-84 landed:** comment-only **`ability_used`** / **`enemy_defeat`** telemetry hook sites in **`CombatOperations.TryResolve`**; reserved **`encounter_start`** / **`player_death`** ([NEO-84](../../plans/NEO-84-implementation-plan.md)); [server README — Combat telemetry hooks (NEO-84)](../../../server/README.md#combat-telemetry-hooks-neo-84). **Backlog decomposed:** Epic 5 Slice 1 — [E5M1-prototype-backlog.md](../../plans/E5M1-prototype-backlog.md) **E5M1-11** [NEO-85](https://linear.app/neon-sprawl/issue/NEO-85) through **E5M1-12** [NEO-86](https://linear.app/neon-sprawl/issue/NEO-86). **Precursor landed:** E1.M4 cast funnel (NEO-28/31/32). | [E5M1-prototype-backlog.md](../../plans/E5M1-prototype-backlog.md), [E5_M1](E5_M1_CombatRulesEngine.md), [NEO-77](../../plans/NEO-77-implementation-plan.md), [NEO-79](../../plans/NEO-79-implementation-plan.md), [NEO-78](../../plans/NEO-78-implementation-plan.md), [NEO-80](../../plans/NEO-80-implementation-plan.md), [NEO-81](../../plans/NEO-81-implementation-plan.md), [NEO-82](../../plans/NEO-82-implementation-plan.md), [NEO-83](../../plans/NEO-83-implementation-plan.md), [NEO-44](../../plans/NEO-44-implementation-plan.md), [NEO-84](../../plans/NEO-84-implementation-plan.md), [E1M4-prototype-backlog.md](../../plans/E1M4-prototype-backlog.md) |
| E5.M1 | In Progress | **NEO-76 landed:** frozen prototype four-ability catalog in [`content/abilities/prototype_abilities.json`](../../../content/abilities/prototype_abilities.json); [`ability-def.schema.json`](../../../content/schemas/ability-def.schema.json); PR gate + [`validate_content.py`](../../../scripts/validate_content.py) E5M1 four-id gate ([NEO-76](../../plans/NEO-76-implementation-plan.md)). **NEO-77 landed:** fail-fast server load of `content/abilities/*_abilities.json` at startup — `server/NeonSprawl.Server/Game/Combat/` ([NEO-77](../../plans/NEO-77-implementation-plan.md)); [server README — Ability catalog](../../../server/README.md#ability-catalog-contentabilities-neo-77). **NEO-79 landed:** injectable **`IAbilityDefinitionRegistry`** + DI; hotbar/cast use **`TryNormalizeKnown`** ([NEO-79](../../plans/NEO-79-implementation-plan.md)); Bruno `bruno/neon-sprawl-server/hotbar-loadout/` + `ability-cast/` unknown-ability deny smokes. **NEO-78 landed:** **`GET /game/world/ability-definitions`** — `AbilityDefinitionsWorldApi` + DTOs in `Game/Combat/` ([NEO-78](../../plans/NEO-78-implementation-plan.md)); [server README — Ability definitions (NEO-78)](../../../server/README.md#ability-definitions-neo-78); Bruno `bruno/neon-sprawl-server/ability-definitions/`. **NEO-80 landed:** **`ICombatEntityHealthStore`** / **`InMemoryCombatEntityHealthStore`** — lazy prototype dummy HP at **`PrototypeCombatConstants.MaxPrototypeTargetHp` (100)**; DI via **`AddCombatEntityHealthStore()`** ([NEO-80](../../plans/NEO-80-implementation-plan.md)); [server README — Combat entity health (NEO-80)](../../../server/README.md#combat-entity-health-neo-80). **NEO-81 landed:** **`CombatOperations.TryResolve`** + **`CombatResult`** + **`CombatReasonCodes`** — defeated pre-check deny, catalog damage ([NEO-81](../../plans/NEO-81-implementation-plan.md)); [server README — Combat engine (NEO-81)](../../../server/README.md#combat-engine-neo-81). **NEO-82 landed:** **`AbilityCastApi`** → **`CombatOperations.TryResolve`**; nested wire **`combatResolution`** on accept; per-ability catalog cooldown; **`target_defeated`** cast deny ([NEO-82](../../plans/NEO-82-implementation-plan.md)); [server README — Ability cast (NEO-82)](../../../server/README.md#ability-cast-neo-31-neo-82); Bruno `bruno/neon-sprawl-server/ability-cast/` defeat spine. **NEO-83 landed:** **`GET /game/world/combat-targets`** — `CombatTargetsWorldApi` + DTOs; authoritative HP snapshot from **`ICombatEntityHealthStore`** ([NEO-83](../../plans/NEO-83-implementation-plan.md)); [server README — Combat targets snapshot (NEO-83)](../../../server/README.md#combat-targets-snapshot-neo-83); Bruno `bruno/neon-sprawl-server/combat-targets/`. **NEO-44 landed:** **`CombatDefeatGigXpGrant`** on cast **`targetDefeated`** — **25** gig XP to **`breach`** via **`IPlayerGigProgressionStore`** (V007); **`GET …/gig-progression`** ([NEO-44](../../plans/NEO-44-implementation-plan.md), [`NEO-44` manual QA](../../manual-qa/NEO-44.md)); [server README — Gig progression (NEO-44)](../../../server/README.md#gig-progression-snapshot-neo-44); Bruno `bruno/neon-sprawl-server/gig-progression/`. **NEO-84 landed:** comment-only **`ability_used`** / **`enemy_defeat`** telemetry hook sites in **`CombatOperations.TryResolve`**; reserved **`encounter_start`** / **`player_death`** ([NEO-84](../../plans/NEO-84-implementation-plan.md)); [server README — Combat telemetry hooks (NEO-84)](../../../server/README.md#combat-telemetry-hooks-neo-84). **NEO-85 landed:** client combat HUD — **`ability_cast_client.gd`** parses **`combatResolution`**; **`combat_targets_client.gd`**, **`CastFeedbackLabel`** resolution copy, **`CombatTargetHpLabel`**; event-driven GET refresh ([NEO-85](../../plans/NEO-85-implementation-plan.md), [`NEO-85` manual QA](../../manual-qa/NEO-85.md)); `client/README.md` combat HUD section. **Backlog decomposed:** Epic 5 Slice 1 — **E5M1-12** [NEO-86](https://linear.app/neon-sprawl/issue/NEO-86) capstone pending. **Precursor landed:** E1.M4 cast funnel (NEO-28/31/32). | [E5M1-prototype-backlog.md](../../plans/E5M1-prototype-backlog.md), [E5_M1](E5_M1_CombatRulesEngine.md), [NEO-77](../../plans/NEO-77-implementation-plan.md), [NEO-79](../../plans/NEO-79-implementation-plan.md), [NEO-78](../../plans/NEO-78-implementation-plan.md), [NEO-80](../../plans/NEO-80-implementation-plan.md), [NEO-81](../../plans/NEO-81-implementation-plan.md), [NEO-82](../../plans/NEO-82-implementation-plan.md), [NEO-83](../../plans/NEO-83-implementation-plan.md), [NEO-44](../../plans/NEO-44-implementation-plan.md), [NEO-84](../../plans/NEO-84-implementation-plan.md), [NEO-85](../../plans/NEO-85-implementation-plan.md), [E1M4-prototype-backlog.md](../../plans/E1M4-prototype-backlog.md) |
---

View File

@ -76,7 +76,7 @@ Fleshed-out scope, contracts, and integration notes live in **per-module documen
| E5.M3 | EncounterAndRewardTables | E5.M1, E3.M3, E7.M2 | EncounterDef, RewardTable, EncounterCompleteEvent | Prototype | Planned |
| E5.M4 | GroupCombatScaling | E5.M3, E8.M1 | ScalingProfile, PartySizeModifier, CombatDifficultyBand | Pre-production | Planned |
**E5.M1 note:** Epic 5 **Slice 1** backlog in Linear ([Epic 5 — PvE Combat and Encounter Content](https://linear.app/neon-sprawl/project/epic-5-pve-combat-and-encounter-content-cf3c94c3-5346-40e0-8aaf-281e846f2846)): [NEO-76](https://linear.app/neon-sprawl/issue/NEO-76) → [NEO-86](https://linear.app/neon-sprawl/issue/NEO-86); label **`E5.M1`**. Gig XP on defeat: **E5M1-10** [NEO-44](https://linear.app/neon-sprawl/issue/NEO-44). See [E5M1-prototype-backlog.md](../../plans/E5M1-prototype-backlog.md), [E5_M1_CombatRulesEngine.md](E5_M1_CombatRulesEngine.md). Builds on E1.M4 cast funnel (NEO-28/31/32); extends **`POST …/ability-cast`** with **`CombatResolution`**. **NEO-76 landed:** frozen four-ability catalog + CI ([NEO-76 plan](../../plans/NEO-76-implementation-plan.md)). **NEO-77 landed:** fail-fast server load of `content/abilities/*_abilities.json` ([NEO-77 plan](../../plans/NEO-77-implementation-plan.md)). **NEO-79 landed:** injectable **`IAbilityDefinitionRegistry`** + DI; hotbar/cast use **`TryNormalizeKnown`** ([NEO-79 plan](../../plans/NEO-79-implementation-plan.md)). **NEO-78 landed:** **`GET /game/world/ability-definitions`** — `AbilityDefinitionsWorldApi` + DTOs in `Game/Combat/` ([NEO-78 plan](../../plans/NEO-78-implementation-plan.md)); Bruno `bruno/neon-sprawl-server/ability-definitions/`. **NEO-80 landed:** **`ICombatEntityHealthStore`** / **`InMemoryCombatEntityHealthStore`** — lazy prototype dummy HP at **`PrototypeCombatConstants.MaxPrototypeTargetHp` (100)**; DI via **`AddCombatEntityHealthStore()`** ([NEO-80 plan](../../plans/NEO-80-implementation-plan.md)); [server README — Combat entity health (NEO-80)](../../../server/README.md#combat-entity-health-neo-80). **NEO-81 landed:** **`CombatOperations.TryResolve`** + **`CombatResult`** + **`CombatReasonCodes`** — defeated pre-check deny vocabulary (`target_defeated`, `unknown_ability`, `unknown_target`) ([NEO-81 plan](../../plans/NEO-81-implementation-plan.md)); [server README — Combat engine (NEO-81)](../../../server/README.md#combat-engine-neo-81). **NEO-82 landed:** **`AbilityCastApi`** invokes **`CombatOperations.TryResolve`** after E1.M4 gates; nested wire **`combatResolution`** on accept; per-ability catalog **`cooldownSeconds`** on successful resolve; **`target_defeated`** JSON cast deny without cooldown ([NEO-82 plan](../../plans/NEO-82-implementation-plan.md)); [server README — Ability cast (NEO-82)](../../../server/README.md#ability-cast-neo-31-neo-82); Bruno `bruno/neon-sprawl-server/ability-cast/` defeat spine. **NEO-83 landed:** **`GET /game/world/combat-targets`** — `CombatTargetsWorldApi` + DTOs; authoritative HP snapshot from **`ICombatEntityHealthStore`** for client HUD ([NEO-83 plan](../../plans/NEO-83-implementation-plan.md)); [server README — Combat targets snapshot (NEO-83)](../../../server/README.md#combat-targets-snapshot-neo-83); Bruno `bruno/neon-sprawl-server/combat-targets/`. **NEO-44 landed:** **`CombatDefeatGigXpGrant`** on cast **`targetDefeated`** — **25** gig XP to **`breach`** via **`IPlayerGigProgressionStore`** (V007); **`GET …/gig-progression`** ([NEO-44 plan](../../plans/NEO-44-implementation-plan.md)); [server README — Gig progression (NEO-44)](../../../server/README.md#gig-progression-snapshot-neo-44). **NEO-84 landed:** comment-only **`ability_used`** / **`enemy_defeat`** telemetry hook sites in **`CombatOperations.TryResolve`**; reserved **`encounter_start`** / **`player_death`** ([NEO-84 plan](../../plans/NEO-84-implementation-plan.md)); [server README — Combat telemetry hooks (NEO-84)](../../../server/README.md#combat-telemetry-hooks-neo-84). Pending client: **E5M1-11** [NEO-85](https://linear.app/neon-sprawl/issue/NEO-85) → **E5M1-12** [NEO-86](https://linear.app/neon-sprawl/issue/NEO-86).
**E5.M1 note:** Epic 5 **Slice 1** backlog in Linear ([Epic 5 — PvE Combat and Encounter Content](https://linear.app/neon-sprawl/project/epic-5-pve-combat-and-encounter-content-cf3c94c3-5346-40e0-8aaf-281e846f2846)): [NEO-76](https://linear.app/neon-sprawl/issue/NEO-76) → [NEO-86](https://linear.app/neon-sprawl/issue/NEO-86); label **`E5.M1`**. Gig XP on defeat: **E5M1-10** [NEO-44](https://linear.app/neon-sprawl/issue/NEO-44). See [E5M1-prototype-backlog.md](../../plans/E5M1-prototype-backlog.md), [E5_M1_CombatRulesEngine.md](E5_M1_CombatRulesEngine.md). Builds on E1.M4 cast funnel (NEO-28/31/32); extends **`POST …/ability-cast`** with **`CombatResolution`**. **NEO-76 landed:** frozen four-ability catalog + CI ([NEO-76 plan](../../plans/NEO-76-implementation-plan.md)). **NEO-77 landed:** fail-fast server load of `content/abilities/*_abilities.json` ([NEO-77 plan](../../plans/NEO-77-implementation-plan.md)). **NEO-79 landed:** injectable **`IAbilityDefinitionRegistry`** + DI; hotbar/cast use **`TryNormalizeKnown`** ([NEO-79 plan](../../plans/NEO-79-implementation-plan.md)). **NEO-78 landed:** **`GET /game/world/ability-definitions`** — `AbilityDefinitionsWorldApi` + DTOs in `Game/Combat/` ([NEO-78 plan](../../plans/NEO-78-implementation-plan.md)); Bruno `bruno/neon-sprawl-server/ability-definitions/`. **NEO-80 landed:** **`ICombatEntityHealthStore`** / **`InMemoryCombatEntityHealthStore`** — lazy prototype dummy HP at **`PrototypeCombatConstants.MaxPrototypeTargetHp` (100)**; DI via **`AddCombatEntityHealthStore()`** ([NEO-80 plan](../../plans/NEO-80-implementation-plan.md)); [server README — Combat entity health (NEO-80)](../../../server/README.md#combat-entity-health-neo-80). **NEO-81 landed:** **`CombatOperations.TryResolve`** + **`CombatResult`** + **`CombatReasonCodes`** — defeated pre-check deny vocabulary (`target_defeated`, `unknown_ability`, `unknown_target`) ([NEO-81 plan](../../plans/NEO-81-implementation-plan.md)); [server README — Combat engine (NEO-81)](../../../server/README.md#combat-engine-neo-81). **NEO-82 landed:** **`AbilityCastApi`** invokes **`CombatOperations.TryResolve`** after E1.M4 gates; nested wire **`combatResolution`** on accept; per-ability catalog **`cooldownSeconds`** on successful resolve; **`target_defeated`** JSON cast deny without cooldown ([NEO-82 plan](../../plans/NEO-82-implementation-plan.md)); [server README — Ability cast (NEO-82)](../../../server/README.md#ability-cast-neo-31-neo-82); Bruno `bruno/neon-sprawl-server/ability-cast/` defeat spine. **NEO-83 landed:** **`GET /game/world/combat-targets`** — `CombatTargetsWorldApi` + DTOs; authoritative HP snapshot from **`ICombatEntityHealthStore`** for client HUD ([NEO-83 plan](../../plans/NEO-83-implementation-plan.md)); [server README — Combat targets snapshot (NEO-83)](../../../server/README.md#combat-targets-snapshot-neo-83); Bruno `bruno/neon-sprawl-server/combat-targets/`. **NEO-44 landed:** **`CombatDefeatGigXpGrant`** on cast **`targetDefeated`** — **25** gig XP to **`breach`** via **`IPlayerGigProgressionStore`** (V007); **`GET …/gig-progression`** ([NEO-44 plan](../../plans/NEO-44-implementation-plan.md)); [server README — Gig progression (NEO-44)](../../../server/README.md#gig-progression-snapshot-neo-44). **NEO-84 landed:** comment-only **`ability_used`** / **`enemy_defeat`** telemetry hook sites in **`CombatOperations.TryResolve`**; reserved **`encounter_start`** / **`player_death`** ([NEO-84 plan](../../plans/NEO-84-implementation-plan.md)); [server README — Combat telemetry hooks (NEO-84)](../../../server/README.md#combat-telemetry-hooks-neo-84). **NEO-85 landed:** client combat HUD — **`combat_targets_client.gd`**, **`CombatTargetHpLabel`**, **`CastFeedbackLabel`** resolution copy; event-driven **`GET /game/world/combat-targets`** refresh ([NEO-85 plan](../../plans/NEO-85-implementation-plan.md), [`NEO-85` manual QA](../../manual-qa/NEO-85.md)); [client README — Combat feedback + target HP HUD (NEO-85)](../../../client/README.md#combat-feedback--target-hp-hud-neo-85). Pending client capstone: **E5M1-12** [NEO-86](https://linear.app/neon-sprawl/issue/NEO-86).
### Epic 6 — PvP Security

View File

@ -0,0 +1,30 @@
# NEO-85 — Manual QA checklist
| Field | Value |
|-------|-------|
| Key | NEO-85 |
| Title | E5M1-11: Client combat feedback + target HP HUD |
| Linear | https://linear.app/neon-sprawl/issue/NEO-85/e5m1-11-client-combat-feedback-target-hp-hud |
| Plan | `docs/plans/NEO-85-implementation-plan.md` |
## 1) Server
- [ ] Start server: `cd server/NeonSprawl.Server && dotnet run`.
- [ ] `curl -s http://localhost:5253/health` returns JSON with `status: ok`.
- [ ] Optional reset between runs (Development host): `curl -sS -X POST http://localhost:5253/game/__dev/combat-targets-fixture -H 'Content-Type: application/json' -d '{"schemaVersion":1}'` returns **200** when the dev fixture route is enabled.
## 2) Client — combat HUD
- [ ] 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`).
- [ ] Locate combat dummies near spawn: **orange capsule "Dummy α"** at **`(-3, -3)`** (west of player) and **purple "Dummy β"** at **`(3, 3)`**; each has a flat lock ring on the ground. Walk toward the orange dummy if needed.
- [ ] **Tab** to lock **`prototype_target_alpha`** — dummy **brightens**; **`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).
- [ ] Cast **three more** pulse hits (respect cooldown): feedback steps **`50`**, **`25`**, then **`Cast: 25 dmg → 0 HP — defeated!`** on the lethal hit; HP label shows **`0/100 (defeated)`**.
- [ ] Press **1** again on the defeated dummy: **`CastFeedbackLabel`** shows **`ability_cast_denied: target_defeated`**; HP label remains defeated.
- [ ] **Esc** to clear lock: **`CombatTargetHpLabel`** shows **`Target HP: — (no lock)`**.
## 3) Regression
- [ ] Cast without lock still shows **`ability_cast_denied: invalid_target`** on **`CastFeedbackLabel`** (NEO-28).
- [ ] Cooldown HUD still refreshes after accepted casts (NEO-32).

View File

@ -340,9 +340,11 @@ Working backlog for **Epic 5 — Slice 1** ([combat rules MVP](../decomposition/
**Acceptance criteria**
- [ ] Player sees damage/de defeat feedback in HUD after digit-key cast (not Output-only).
- [ ] Target HP label matches server snapshot after cast sequence.
- [ ] Manual QA exercisable without Bruno.
- [x] Player sees damage/defeat feedback in HUD after digit-key cast (not Output-only).
- [x] Target HP label matches server snapshot after cast sequence.
- [x] Manual QA exercisable without Bruno.
**Landed ([NEO-85](https://linear.app/neon-sprawl/issue/NEO-85)):** **`ability_cast_client.gd`** parses **`combatResolution`**; **`combat_targets_client.gd`** + **`CombatTargetHpLabel`**; event-driven GET refresh; plan [NEO-85-implementation-plan.md](NEO-85-implementation-plan.md); manual QA [`NEO-85`](../manual-qa/NEO-85.md).
**Client counterpart:** this issue (**NEO-85**).

View File

@ -0,0 +1,149 @@
# NEO-85 — Implementation plan
## Story reference
| Field | Value |
|--------|--------|
| **Key** | NEO-85 |
| **Title** | E5M1-11: Client combat feedback + target HP HUD |
| **Linear** | https://linear.app/neon-sprawl/issue/NEO-85/e5m1-11-client-combat-feedback-target-hp-hud |
| **Module** | [E5.M1 — CombatRulesEngine](../decomposition/modules/E5_M1_CombatRulesEngine.md) · Epic 5 Slice 1 · backlog **E5M1-11** |
| **Branch** | `NEO-85-client-combat-feedback-hp-hud` |
| **Server deps** | [NEO-82](https://linear.app/neon-sprawl/issue/NEO-82) — cast POST + nested **`combatResolution`** (**Done** on `main`); [NEO-83](https://linear.app/neon-sprawl/issue/NEO-83) — **`GET /game/world/combat-targets`** (**Done** on `main`) |
| **Pattern** | [NEO-28](https://linear.app/neon-sprawl/issue/NEO-28) / [NEO-73](https://linear.app/neon-sprawl/issue/NEO-73) — thin HTTP clients, `main.gd` HUD wiring, GdUnit mock transport |
| **Downstream** | [NEO-86](https://linear.app/neon-sprawl/issue/NEO-86) capstone playable tab-target combat loop (manual QA script) |
| **Server counterpart** | [NEO-82](https://linear.app/neon-sprawl/issue/NEO-82) + [NEO-83](https://linear.app/neon-sprawl/issue/NEO-83) — this issue is the **client** half; Bruno-only verification is not prototype-complete per [full-stack epic decomposition](../../.cursor/rules/full-stack-epic-decomposition.md) |
## Kickoff clarifications
| Topic | Question | Agent recommendation | Answer |
|--------|----------|----------------------|--------|
| **HP refresh** | Event-driven vs periodic poll for **`GET /game/world/combat-targets`**? | **Event-driven only** — refresh after successful cast accept + on target lock change; matches cooldown sync pattern and minimizes HTTP chatter ([E5M1-11 backlog](E5M1-prototype-backlog.md#e5m1-11--client-combat-feedback--target-hp-hud)). | **User:** event-driven only. |
| **Cast feedback surface** | Extend **`CastFeedbackLabel`** vs dedicated combat line? | **Extend `CastFeedbackLabel`** with resolution copy on accept — NEO-28 precedent; keep HUD compact. | **User:** extend `CastFeedbackLabel`. |
| **HP label placement** | Dedicated label vs append to **`TargetLockLabel`**? | **New `CombatTargetHpLabel`** below **`CastFeedbackLabel`** — AC requires readable HP readout; **`TargetLockLabel`** is already dense lock/range debug. | **User:** dedicated `CombatTargetHpLabel`. |
No other blocking decisions — wire field names follow NEO-82 **`combatResolution`** / NEO-83 **`targets`** row vocabulary; zero-damage accepts show **`0 dmg`** from server fields (no local combat math).
## Goal, scope, and out-of-scope
**Goal:** Godot displays authoritative cast outcomes and locked dummy HP without local combat math — player-visible damage/defeat feedback and an HP label that matches the server snapshot after casts.
**In scope (from Linear + [E5M1-11](E5M1-prototype-backlog.md#e5m1-11--client-combat-feedback--target-hp-hud)):**
- Extend **`ability_cast_client.gd`** to parse nested **`combatResolution`** on accept and surface it on **`cast_result_received`**.
- Update **`CastFeedbackLabel`** with damage / defeat messaging on accept (e.g. **`Cast: 25 dmg → 75 HP`**, **`Cast: target defeated!`** when **`targetDefeated`**).
- **`combat_targets_client.gd`** — **`GET /game/world/combat-targets`**; cache snapshot; **`target_row(target_id)`** helper.
- **`CombatTargetHpLabel`** — shows locked target **`currentHp/maxHp`** (+ **`defeated`** suffix); **`—`** when no lock.
- **`main.gd` wiring:** event-driven combat-target refresh on successful cast + **`target_state_changed`**; boot hydrate when first lock arrives.
- GdUnit tests with HTTP doubles ([testing-expectations.md](../../.cursor/rules/testing-expectations.md)).
- **`docs/manual-qa/NEO-85.md`** — Godot steps (server + client running).
- **`client/README.md`** combat HUD section.
**Out of scope (from Linear + backlog):**
- Final combat VFX, floating damage numbers art pass, player HP bar.
- Gig XP HUD row — optional in [NEO-86](https://linear.app/neon-sprawl/issue/NEO-86) capstone.
- Periodic HP polling, local damage prediction, server changes.
## Acceptance criteria checklist
- [x] Player sees damage/defeat feedback in HUD after digit-key cast (not Output-only).
- [x] Target HP label matches server snapshot after cast sequence.
- [x] Manual QA exercisable without Bruno.
## Implementation reconciliation (shipped)
- **Clients:** `ability_cast_client.gd` parses **`combatResolution`** on accept; **`combat_targets_client.gd`** GET world snapshot + **`target_row`**.
- **HUD:** **`CastFeedbackLabel`** resolution copy; **`CombatTargetHpLabel`** for locked target HP; event-driven refresh on cast accept + lock change.
- **Tests:** extended `ability_cast_client_test.gd`; new `combat_targets_client_test.gd`, `combat_feedback_refresh_test.gd`.
- **Docs:** `client/README.md` combat HUD section; [`NEO-85` manual QA](../manual-qa/NEO-85.md); E5M1 backlog + module alignment rows updated.
## Technical approach
1. **`ability_cast_client.gd` (NEO-85)**
- Parse optional **`combatResolution`** dict when **`accepted: true`** (`abilityId`, `targetId`, `damageDealt`, `targetRemainingHp`, `targetDefeated`).
- Extend signal: **`cast_result_received(accepted, reason_code, resolution)`** — **`resolution`** empty **`Dictionary`** on deny or missing block.
- Add static **`parse_cast_response_json(text) -> Dictionary`** for tests (returns `{accepted, reasonCode, resolution}`).
2. **`combat_targets_client.gd` (new)**
- Mirror **`interactables_catalog_client.gd`** / **`skill_progression_client.gd`**: injectable HTTP, **`_busy`** guard.
- **`GET {base}/game/world/combat-targets`** — no player id segment (world route).
- **`parse_targets_json(text)`** static: require **`schemaVersion` 1**, **`targets`** array.
- Signals: **`targets_received(snapshot: Dictionary)`**, **`targets_sync_failed(reason: String)`**.
- **`request_sync_from_server()`** public entry.
- **`target_row(target_id: String, snapshot: Dictionary = {}) -> Dictionary`** — find row by **`targetId`**; uses cached last snapshot when second arg omitted.
3. **Cast feedback copy (`main.gd` → `_on_cast_result_received`)**
- **Deny:** unchanged NEO-28 lines (`ability_cast_denied: {reasonCode}`).
- **Accept + resolution present:**
- If **`targetDefeated`:** **`Cast: {damageDealt} dmg → 0 HP — defeated!`**
- Else: **`Cast: {damageDealt} dmg → {targetRemainingHp} HP`**
- **Accept + empty resolution:** fallback **`Cast: accepted`** (defensive; should not occur post-NEO-82).
- Keep cooldown sync on accept (existing NEO-32 path).
4. **Combat target HP HUD (`main.gd`)**
- Cache **`_combat_targets_snapshot: Dictionary`** on **`targets_received`**.
- **`_render_combat_target_hp_label()`** — read **`_last_target_state.lockedTargetId`**; lookup row; format **`Target HP: {id} {current}/{max}`** + **` (defeated)`** when **`defeated`**; **`Target HP: — (no lock)`** when unlocked.
- **Refresh triggers (event-driven, kickoff decision):**
- **`target_state_changed`** → **`combat_targets_client.request_sync_from_server()`** when lock id changes or validity transitions to a locked id.
- **`cast_result_received`** when **`accepted`** → request combat-target sync (authoritative HP after cast; resolution fields give immediate cast feedback, GET reconciles label).
- Re-render HP label after snapshot received and when target state changes (even if GET still in flight, show last cached row for current lock).
5. **Scene layout (`main.tscn`)**
- Add **`CombatTargetHpLabel`** between **`CastFeedbackLabel`** and **`GatherFeedbackLabel`** (~offset_top 374, shift **`GatherFeedbackLabel`** / **`CraftFeedbackLabel`** / economy block down ~22px if needed for non-overlap).
- Style: match existing left HUD labels (outline, font_size 15, warm tint distinct from cast line).
6. **Client boot wiring (`main.gd`)**
- Instantiate **`CombatTargetsClient`** in hotbar/cast setup block; apply authority **`base_url`** (world GET — no **`dev_player_id`**).
- Connect **`targets_received`** → cache + **`_render_combat_target_hp_label`**.
- Update **`cast_result_received`** connection signature for third **`resolution`** arg.
7. **Tests (GdUnit, AAA)**
- **`ability_cast_client_test.gd`:** extend — accept body with **`combatResolution`** → signal carries resolution dict; deny → empty resolution.
- **`combat_targets_client_test.gd`:** mock GET 200 two-target snapshot → **`target_row("prototype_target_alpha")`** fields; schema mismatch → failure signal.
- **`combat_feedback_refresh_test.gd`:** harness — cast accept signal triggers **`combat_targets_client.request_sync_from_server`** (spy/mock); target lock change triggers sync (mirrors **`craft_feedback_refresh_test.gd`** / **`gather_feedback_refresh_test.gd`**).
8. **Docs on land**
- **`client/README.md`:** combat HUD subsection — cast resolution copy, HP label, event-driven GET refresh, dev fixture reset note (`POST /game/__dev/combat-targets-fixture` when enabled).
- **`docs/manual-qa/NEO-85.md`:** Tab lock alpha → bind slot 0 pulse → cast until defeat; verify **`CastFeedbackLabel`** + **`CombatTargetHpLabel`**; optional fixture reset between runs.
- Reconcile [E5M1-prototype-backlog.md](E5M1-prototype-backlog.md) E5M1-11 checkboxes, [E5_M1_CombatRulesEngine.md](../decomposition/modules/E5_M1_CombatRulesEngine.md), [documentation_and_implementation_alignment.md](../decomposition/modules/documentation_and_implementation_alignment.md) when implementation completes.
## Files to add
| Path | Purpose |
|------|---------|
| `docs/plans/NEO-85-implementation-plan.md` | This plan. |
| `client/scripts/combat_targets_client.gd` | GET world combat-targets snapshot; parse v1; **`targets_received`** / **`targets_sync_failed`**; **`target_row`**. |
| `client/scripts/combat_targets_client.gd.uid` | Godot uid companion (tracked). |
| `client/test/combat_targets_client_test.gd` | GdUnit: parse two-target snapshot; **`target_row`** lookup; HTTP error path. AAA layout. |
| `client/test/combat_feedback_refresh_test.gd` | GdUnit: cast accept + target lock change invoke combat-target sync (spy harness). AAA layout. |
| `docs/manual-qa/NEO-85.md` | Manual QA checklist — Godot combat feedback + HP HUD without Bruno. |
## Files to modify
| Path | Rationale |
|------|-----------|
| `client/scripts/ability_cast_client.gd` | Parse **`combatResolution`**; extend **`cast_result_received`** with resolution dict; static parse helper for tests. |
| `client/test/ability_cast_client_test.gd` | Cover resolution parsing + extended signal payload on accept/deny. |
| `client/scripts/main.gd` | Wire **`CombatTargetsClient`**; combat HP label render; cast feedback copy with resolution; event-driven refresh hooks. |
| `client/scenes/main.tscn` | Add **`CombatTargetHpLabel`** node; adjust downstream label offsets if needed. |
| `client/README.md` | Document combat HUD (cast resolution feedback + target HP label + refresh triggers). |
## Tests
| Path | Change |
|------|--------|
| `client/test/ability_cast_client_test.gd` | **Add/extend:** accept JSON with **`combatResolution`** → signal third arg has **`damageDealt`**, **`targetRemainingHp`**, **`targetDefeated`**; deny → empty resolution. |
| `client/test/combat_targets_client_test.gd` | **Add:** GET parse v1; alpha/beta rows; 404/invalid schema failure signal. |
| `client/test/combat_feedback_refresh_test.gd` | **Add:** successful cast accept triggers combat-target sync; target lock change triggers sync. |
**Manual verification:** [`docs/manual-qa/NEO-85.md`](manual-qa/NEO-85.md) — server + Godot; Tab lock **`prototype_target_alpha`**, digit cast **`prototype_pulse`**, observe stepping HP on label and defeat copy on 4th hit; **`target_defeated`** deny on 5th preserves HUD state.
## Open questions / risks
| Question or risk | Agent recommendation | Status |
|------------------|----------------------|--------|
| **Signal signature break** | Extend **`cast_result_received`** with third arg (empty dict on deny) — update **`main.gd`** + tests in same commit. | **adopted** |
| **HP label stale during in-flight GET** | Show last cached snapshot for locked id immediately after cast feedback; refresh when GET completes. | **adopted** |
| **Layout shift in `main.tscn`** | Insert HP label between cast and gather lines; bump gather/craft offsets minimally. | **adopted** |
| **Dev fixture reset for QA** | Document optional **`POST /game/__dev/combat-targets-fixture`** in manual QA when Development host enabled (NEO-83 README). | **adopted** |

View File

@ -0,0 +1,62 @@
# Code review — NEO-85 client combat feedback + target HP HUD
**Date:** 2026-05-25
**Scope:** Branch `NEO-85-client-combat-feedback-hp-hud` · commits `f03e689``faf3fe1` vs `origin/main`
**Base:** `origin/main`
## Verdict
**Approve with nits**
## Summary
NEO-85 delivers **E5M1-11**: the Godot client parses nested **`combatResolution`** on cast accept, extends **`CastFeedbackLabel`** with damage/defeat copy, adds **`CombatTargetsClient`** for **`GET /game/world/combat-targets`**, and wires **`CombatTargetHpLabel`** with event-driven refresh on successful cast and target lock change. Implementation follows NEO-28/NEO-73 thin-client patterns (injectable HTTP, `_busy` guard, harness tests) and keeps combat math server-authoritative. Fifteen new/extended GdUnit cases pass locally. Residual risk is low — client-only HUD wiring atop landed NEO-82/NEO-83 server contracts; brief HP label lag between cast feedback and GET completion is an adopted trade-off.
## Documentation checked
| Document | Result |
|----------|--------|
| [`docs/plans/NEO-85-implementation-plan.md`](../plans/NEO-85-implementation-plan.md) | **Matches** — kickoff decisions (event-driven refresh, extend `CastFeedbackLabel`, dedicated HP label), scope, acceptance checklist, and reconciliation align with code. |
| [`docs/plans/E5M1-prototype-backlog.md`](../plans/E5M1-prototype-backlog.md) · **E5M1-11** | **Matches** — AC checked; landed note cites clients + manual QA. |
| [`docs/decomposition/modules/E5_M1_CombatRulesEngine.md`](../decomposition/modules/E5_M1_CombatRulesEngine.md) | **Matches** — Status line cites E5M1-11 NEO-85 landed; **Related implementation slices** includes **E5M1-11** entry. Done. |
| [`docs/decomposition/modules/module_dependency_register.md`](../decomposition/modules/module_dependency_register.md) | **Matches** — E5.M1 note cites **NEO-85 landed**; capstone **E5M1-12 NEO-86** pending. Done. |
| [`docs/decomposition/modules/documentation_and_implementation_alignment.md`](../decomposition/modules/documentation_and_implementation_alignment.md) | **Matches** — E5.M1 row cites NEO-85 landed + manual QA + client README. |
| [`docs/decomposition/modules/client_server_authority.md`](../decomposition/modules/client_server_authority.md) | **Matches** — no client-side damage math; HP from server GET snapshot; cast feedback from wire fields only. |
| [`docs/decomposition/modules/documentation_and_implementation_alignment.md`](../decomposition/modules/documentation_and_implementation_alignment.md) · **E1.M4** | **Partially matches** — NEO-85 extends **`ability_cast_client.gd`** / **`CastFeedbackLabel`** (E1.M4 cast funnel) but E1.M4 row stops at NEO-32; optional cross-reference only. |
| [`client/README.md`](../../client/README.md) | **Matches** — combat HUD subsection documents cast resolution copy, HP label, refresh triggers, dev fixture reset. |
| [`docs/manual-qa/NEO-85.md`](../manual-qa/NEO-85.md) | **Matches** — Godot steps cover lock → cast → HP stepping → defeat → deny → clear lock; regression bullets for NEO-28/NEO-32. |
| Full-stack epic decomposition | **Matches** — client half of NEO-82/NEO-83 stack; manual QA is Godot-first (not Bruno-only). |
## Blocking issues
None.
## Suggestions
1. ~~**Stale `module_dependency_register` E5.M1 note** — Replace **Pending client: E5M1-11 NEO-85** with a **NEO-85 landed** one-liner (mirror the E5.M1 alignment row: `combat_targets_client.gd`, `CombatTargetHpLabel`, event-driven GET, manual QA link).~~ **Done.**
2. ~~**Missing E5M1-11 Related implementation slice** — Add an **E5M1-11 (NEO-85)** bullet under **Related implementation slices** in `E5_M1_CombatRulesEngine.md` (client combat HUD; link plan, manual QA, `client/README.md` combat section) so module readers stay consistent with E5M1-04E5M1-10 entries.~~ **Done.**
3. ~~**HTTP error-path test gap** — Plan §Tests lists **404/invalid schema** failure for `combat_targets_client_test.gd`; implementation covers schema mismatch only. Add a mock **404** (or non-2xx) case asserting **`targets_sync_failed`** with **`HTTP 404`** for parity with other world GET clients.~~ **Done.**
## Nits
- Nit: **`CombatRefreshHarness`** duplicates `_locked_target_id_from_state` / refresh logic from `main.gd` rather than testing `main.gd` directly — matches **`craft_feedback_refresh_test.gd`** precedent; acceptable for prototype scope.
- Nit: Clearing lock (Esc) still fires a combat-target GET even though the HP label already renders **no lock** — harmless extra HTTP; skip refresh when `new_lock` is empty if chatter matters later.
- Nit: Between cast accept and GET completion, **`CastFeedbackLabel`** shows authoritative **`targetRemainingHp`** from **`combatResolution`** while **`CombatTargetHpLabel`** may still show the prior snapshot row — adopted plan trade-off; optional future polish could render HP from resolution until GET lands.
## Verification
```bash
cd /home/don/neon-sprawl/client
godot --headless --path . -s res://addons/gdUnit4/bin/GdUnitCmdTool.gd --ignoreHeadlessMode \
-a res://test/ability_cast_client_test.gd \
-a res://test/combat_targets_client_test.gd \
-a res://test/combat_feedback_refresh_test.gd
```
**GdUnit (local run):** 16/16 passed (8 + 5 + 3 suites).
**Manual:** [`docs/manual-qa/NEO-85.md`](../manual-qa/NEO-85.md) — server + Godot; Tab lock **`prototype_target_alpha`**, digit cast **`prototype_pulse`**, verify cast resolution copy and HP label stepping through defeat; optional **`POST /game/__dev/combat-targets-fixture`** between runs.