NEO-28: cast target authority, HUD feedback, tests + docs

pull/57/head
VinPropane 2026-04-27 21:02:10 -04:00
parent d379980c27
commit 7de8e7ecfd
20 changed files with 460 additions and 30 deletions

View File

@ -1,7 +1,7 @@
meta {
name: POST ability cast bad schema
type: http
seq: 22
seq: 23
}
post {

View File

@ -1,7 +1,7 @@
meta {
name: POST ability cast happy
type: http
seq: 21
seq: 22
}
post {
@ -15,7 +15,7 @@ body:json {
"schemaVersion": 1,
"slotIndex": 0,
"abilityId": "prototype_pulse",
"targetId": null
"targetId": "prototype_target_alpha"
}
}

View File

@ -1,7 +1,7 @@
meta {
name: POST ability cast loadout mismatch
type: http
seq: 24
seq: 25
}
post {

View File

@ -1,7 +1,7 @@
meta {
name: POST ability cast missing player
type: http
seq: 23
seq: 24
}
post {

View File

@ -0,0 +1,27 @@
meta {
name: POST target select alpha (cast preset)
type: http
seq: 21
}
post {
url: {{baseUrl}}/game/players/dev-local-1/target/select
body: json
auth: none
}
body:json {
{
"schemaVersion": 1,
"targetId": "prototype_target_alpha"
}
}
tests {
test("status 200 lock applied", function () {
expect(res.getStatus()).to.equal(200);
const body = res.getBody();
expect(body.selectionApplied).to.equal(true);
expect(body.targetState.lockedTargetId).to.equal("prototype_target_alpha");
});
}

View File

@ -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):** 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 (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 `Cast: accepted`) 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).
@ -127,12 +127,12 @@ Full checklist: [`docs/manual-qa/NEO-26.md`](../docs/manual-qa/NEO-26.md).
This story hydrates bindings only; cast execution and cooldown behavior stay in later E1.M4 / E5.M1 stories.
## Ability cast request (NEO-31)
## Ability cast request (NEO-31 + NEO-28)
- **`POST /game/players/{id}/ability-cast`** — JSON body `schemaVersion`, `slotIndex` (07), `abilityId`, optional `targetId` (mirrors server target lock id). Server accepts when the slot matches persisted hotbar bindings; see `server/…/AbilityCastApi.cs` for prototype `reasonCode` values.
- **Client:** `main.gd` reads `HotbarState.slots_snapshot()` and `TargetSelectionClient.cached_state()` (`lockedTargetId`) so cast payloads use **server-acknowledged** target context, not camera guessing.
- **`POST /game/players/{id}/ability-cast`** — JSON body `schemaVersion`, `slotIndex` (07), `abilityId`, **`targetId`** (must match server lock + prototype registry + range for `accepted: true`; see `server/README.md` and `AbilityCastApi.cs`).
- **Client:** `main.gd` reads `HotbarState.slots_snapshot()` and `TargetSelectionClient.cached_state()` (`lockedTargetId`) so cast payloads use **server-acknowledged** target context, not camera guessing. **Tab-lock a target** before expecting a successful cast at the default spawn.
Full checklist: [`docs/manual-qa/NEO-31.md`](../docs/manual-qa/NEO-31.md).
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).
### Manual check (NEO-24)

View File

@ -1133,3 +1133,17 @@ theme_override_font_sizes/font_size = 15
text = "Target: —
Validity: —
Seq: —"
[node name="CastFeedbackLabel" type="Label" parent="UICanvas" unique_id=9000005]
offset_left = 8.0
offset_top = 318.0
offset_right = 520.0
offset_bottom = 372.0
grow_horizontal = 0
grow_vertical = 0
theme_override_colors/font_color = Color(0.95, 0.88, 0.72, 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 = "Cast: —"

View File

@ -1,6 +1,9 @@
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.
signal cast_result_received(accepted: bool, reason_code: String)
@export var base_url: String = "http://127.0.0.1:5253"
@export var dev_player_id: String = "dev-local-1"
@export var injected_http: Node = null
@ -76,8 +79,9 @@ func _on_request_completed(
return
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 ""
cast_result_received.emit(accepted, reason)
if not accepted:
var reason_variant: Variant = data.get("reasonCode", "")
var reason: String = reason_variant as String if reason_variant is String else ""
# NEO-27 / NEO-31: product hook name for future telemetry (TODO(E9.M1)).
# 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

@ -81,6 +81,7 @@ var _ability_cast_client: Node = null
@onready var _move_reject_label: Label = $UICanvas/MoveRejectLabel
@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 _target_client: Node = $TargetSelectionClient
@onready var _interaction_client: Node = $InteractionRequestClient
@onready var _floor: StaticBody3D = $World/NavigationRegion3D/Floor
@ -211,8 +212,9 @@ func _on_authoritative_position(world: Vector3, apply_as_snap: bool) -> void:
## [method _request_hotbar_cast_slot] only after [code]request_cast[/code] on
## [code]AbilityCastClient[/code] returns [code]true[/code]
## (POST successfully queued on [code]HTTPRequest[/code]).
## [code]ability_cast_denied[/code] is surfaced via [code]push_warning[/code] from
## `ability_cast_client.gd` on deny responses (TODO(E9.M1): telemetry schema + ingest).
## [code]ability_cast_denied[/code] is logged via [code]push_warning[/code] from
## `ability_cast_client.gd` on deny responses; NEO-28 also surfaces the same outcome on
## [member _cast_feedback_label] (TODO(E9.M1): telemetry schema + ingest).
func _on_authoritative_ack_for_hud(world: Vector3) -> void:
_last_ack_world = world
_have_last_ack = true
@ -249,6 +251,10 @@ func _setup_hotbar_loadout_sync() -> void:
if authority_player_id is String and not (authority_player_id as String).is_empty():
_hotbar_client.set("dev_player_id", authority_player_id)
_ability_cast_client.set("dev_player_id", authority_player_id)
if _ability_cast_client.has_signal("cast_result_received"):
_ability_cast_client.connect(
"cast_result_received", Callable(self, "_on_cast_result_received")
)
if _hotbar_client.has_method("set_hotbar_state"):
_hotbar_client.call("set_hotbar_state", _hotbar_state)
if _hotbar_client.has_method("request_sync_from_server"):
@ -288,6 +294,19 @@ 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:
if not is_instance_valid(_cast_feedback_label):
return
if accepted:
_cast_feedback_label.text = "Cast: accepted"
return
var rc := reason_code.strip_edges()
if rc.is_empty():
_cast_feedback_label.text = "Cast: denied (no reasonCode)"
else:
_cast_feedback_label.text = "ability_cast_denied: %s" % rc
func _on_move_rejected(reason_code: String) -> void:
_authority_force_snap_next = true
# Rejected stream: server state may differ; next snap is forced.

View File

@ -127,3 +127,35 @@ func test_request_cast_while_busy_is_ignored() -> void:
var parsed: Variant = JSON.parse_string(transport.last_body)
var body: Dictionary = parsed
assert_that(int(body.get("slotIndex", -1))).is_equal(0)
func test_cast_result_received_emits_false_with_reason_on_denied() -> void:
var transport := MockHttpTransport.new()
transport.enqueue(
HTTPRequest.RESULT_SUCCESS,
200,
'{"schemaVersion":1,"accepted":false,"reasonCode":"invalid_target"}'
)
var c := _make_client(transport)
var got: Array = []
c.connect("cast_result_received", func(accepted: bool, reason: String): got.append([accepted, reason]))
# Act
assert_that(bool(c.call("request_cast", 0, "prototype_pulse", "prototype_target_alpha"))).is_true()
# Assert
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")
func test_cast_result_received_emits_true_with_empty_reason_on_accepted() -> void:
var transport := MockHttpTransport.new()
transport.enqueue(HTTPRequest.RESULT_SUCCESS, 200, '{"schemaVersion":1,"accepted":true}')
var c := _make_client(transport)
var got: Array = []
c.connect("cast_result_received", func(accepted: bool, reason: String): got.append([accepted, reason]))
# Act
assert_that(bool(c.call("request_cast", 0, "prototype_pulse", null))).is_true()
# Assert
assert_that(got.size()).is_equal(1)
assert_that(bool(got[0][0])).is_true()
assert_that(str(got[0][1])).is_equal("")

View File

@ -42,7 +42,7 @@ Epic 1 **Slice 3** — `ability_cast_requested`, `ability_cast_denied` with reas
## Implementation snapshot
- **Current state:** NEO-29 landed prototype `HotbarLoadout` v1 contract and hydration path (`GET`/`POST /game/players/{id}/hotbar-loadout`, fixed 8 slots, deny reason codes). NEO-31 landed prototype **`POST /game/players/{id}/ability-cast`** plus client digit hotbar → cast payload wiring (target id from server-ack target state). `CooldownSnapshot` wiring is still pending.
- **Current state:** NEO-29 landed prototype `HotbarLoadout` v1 contract and hydration path (`GET`/`POST /game/players/{id}/hotbar-loadout`, fixed 8 slots, deny reason codes). NEO-31 landed prototype **`POST /game/players/{id}/ability-cast`** plus client digit hotbar → cast payload wiring (target id from server-ack target state). **NEO-28** extends that cast POST with **server lock + registry + range** validation (`invalid_target`, `out_of_range`) and a **HUD cast feedback** line on deny/accept. `CooldownSnapshot` wiring is still pending.
- **Prototype persistence policy (NEO-29):** per-player loadout key; Postgres when `ConnectionStrings:NeonSprawl` exists, in-memory fallback otherwise.
- **Prototype backlog:** [E1M4 prototype backlog](../../plans/E1M4-prototype-backlog.md) defines five vertical slices from hotbar loadout contract to cast telemetry hooks.
- **Dependency expectation:** first implementation stories assume E1.M3 target selection flow exists and E5.M1 provides minimum cast accept/deny contract.
@ -57,7 +57,7 @@ Parent epic: [Epic 1 — Core Player Runtime](https://linear.app/neon-sprawl/pro
|------|--------|
| **E1M4-01** | [NEO-29](https://linear.app/neon-sprawl/issue/NEO-29/e1m4-01-hotbarloadout-v1-contract-baseline-persistence-path) — `HotbarLoadout` v1 contract + baseline persistence path |
| **E1M4-02** | [NEO-31](https://linear.app/neon-sprawl/issue/NEO-31/e1m4-02-input-to-abilitycastrequest-path) — Input to `AbilityCastRequest` path |
| **E1M4-03** | TBD — Combat accept/deny integration + reason-code UX |
| **E1M4-03** | [NEO-28](https://linear.app/neon-sprawl/issue/NEO-28/e1m4-03-combat-acceptdeny-integration-reason-code-ux) — Combat accept/deny integration + reason-code UX |
| **E1M4-04** | TBD — `CooldownSnapshot` sync + slot presentation |
| **E1M4-05** | TBD — Slice 3 telemetry hooks for cast funnel |

View File

@ -49,6 +49,10 @@ Core tab-target combat resolution: valid actions against a locked target, hit re
See Epic 5 **Slice 1 — Combat rules MVP**: target lock, basic attacks, 46 abilities, cooldowns/resources; telemetry `encounter_start`, `ability_used`, `player_death`, `enemy_defeat`.
## Prototype notes (NEO-28)
Epic 1 **Slice 3** exercises a minimal cast accept/deny surface on **`POST /game/players/{id}/ability-cast`** before the full engine exists: after hotbar/loadout validation ([E1.M4](E1_M4_AbilityInputScaffold.md)), the server requires **`targetId`** to match the persisted combat lock, resolve in the prototype target registry, and sit within horizontal lock radius against authoritative **`PositionState`**. Deny codes **`invalid_target`** and **`out_of_range`** reuse the same strings as E1.M3 **`TargetState.validity`** for vocabulary alignment. **`accepted: true`** still does not imply damage, cooldown commit, or a full **`CombatResolution`** payload — those remain E5.M1 scope.
## Risks and telemetry
- Sluggish or opaque tab-target: prototype readability gate; instrument time-in-combat and deny reasons.

View File

@ -49,7 +49,7 @@ Rows appear when work starts; default for unlisted modules is **Planned** / not
| E1.M1 | Ready | Prototype milestone **Done** ([E1.M1](https://linear.app/neon-sprawl/project/e1m1-inputandmovementruntime-20eb28dd3db4)). Authoritative `PositionState` + **MoveCommand** over HTTP (JSON v1 snap); **in-memory** store by default, **Postgres** when configured ([NEO-8](../../plans/NEO-8-implementation-plan.md)); shared **NpgsqlDataSource** disposed on host shutdown ([NEO-13](../../plans/NEO-13-implementation-plan.md)); Godot sync + path-follow ([NEO-7](../../plans/NEO-7-implementation-plan.md), [NEO-11](../../plans/NEO-11-implementation-plan.md)); **InteractionRequest** + horizontal range ([NEO-9](../../plans/NEO-9-implementation-plan.md)). Follow-on: prediction/reconciliation, Slice 1 telemetry, Protobuf wire per [contracts.md](contracts.md). | [NEO-6](../../plans/NEO-6-implementation-plan.md), [NEO-7](../../plans/NEO-7-implementation-plan.md), [NEO-8](../../plans/NEO-8-implementation-plan.md), [NEO-9](../../plans/NEO-9-implementation-plan.md), [NEO-10](../../plans/NEO-10-implementation-plan.md), [NEO-11](../../plans/NEO-11-implementation-plan.md), [NEO-13](../../plans/NEO-13-implementation-plan.md); `server/NeonSprawl.Server/Game/PositionState/`, `Game/Interaction/`; [server README](../../../server/README.md) |
| E1.M2 | Ready | **Slice 2 prototype slice closed:** follow + `CameraState` ([NEO-15](https://linear.app/neon-sprawl/issue/NEO-15)); zoom bands ([NEO-16](https://linear.app/neon-sprawl/issue/NEO-16)); occlusion ([NEO-17](https://linear.app/neon-sprawl/issue/NEO-17)); occluder pick-through ([NEO-20](https://linear.app/neon-sprawl/issue/NEO-20)); contracts + hardening ([NEO-18](https://linear.app/neon-sprawl/issue/NEO-18)). Client-local; no server use of camera pose. | [NEO-15](../../plans/NEO-15-implementation-plan.md), [NEO-16](../../plans/NEO-16-implementation-plan.md), [NEO-17](../../plans/NEO-17-implementation-plan.md), [NEO-18](../../plans/NEO-18-implementation-plan.md), [NEO-20](../../plans/NEO-20-implementation-plan.md); `client/scripts/isometric_follow_camera.gd`, `camera_state.gd`, `zoom_band_config.gd`, `occlusion_policy.gd`, `ground_pick.gd`; [E1_M2_IsometricCameraController.md](E1_M2_IsometricCameraController.md) |
| E1.M3 | In Progress | **NEO-23 landed:** JSON v1 targeting read + select (`GET`/`POST …/target`, `Game/Targeting/`, [NEO-23](../../plans/NEO-23-implementation-plan.md)) — `PlayerTargetStateResponse` / soft lock / `reasonCode` denials; wire name differs from illustrative **`TargetState`** in module doc (called out in plan + README). **NEO-24 landed:** client tab-target + lock HUD synced to server ([NEO-24](../../plans/NEO-24-implementation-plan.md)) — `TargetSelectionClient` ([`client/scripts/target_selection_client.gd`](../../../client/scripts/target_selection_client.gd)), Tab / Esc bindings, `TargetLockLabel` HUD, hybrid movement-triggered refresh via new `PositionAuthorityClient.authoritative_ack` signal (fires on every server-confirmed position, boot + `move-stream` 200) with a 250 ms cooldown; manual QA in [`docs/manual-qa/NEO-24.md`](../../manual-qa/NEO-24.md). **NEO-25 landed:** versioned **`GET /game/world/interactables`** ([NEO-25](../../plans/NEO-25-implementation-plan.md)) — `InteractablesListResponse` / `InteractablesWorldApi` in `server/NeonSprawl.Server/Game/Interaction/`; Godot `interactables_catalog_client.gd` + `interactable_world_builder.gd` (fetch-driven props + glow); [server README — Interactable descriptors (NEO-25)](../../../server/README.md#interactable-descriptors-neo-25). **NEO-26 landed:** prototype **`SelectionEvent`** — **`TargetSelectionClient.selection_event`** ([NEO-26](../../plans/NEO-26-implementation-plan.md)) when **`lockedTargetId`** changes; optional **`log_selection_events`**. **NEO-27 landed:** Slice 3 telemetry hook sites documented — `selection_event` maps to product `target_changed` in `target_selection_client.gd`; server lock transition hook comments in `InMemoryPlayerTargetLockStore`; reserved `ability_cast_requested` / `ability_cast_denied` comments in `client/scripts/main.gd` (all TODO(E9.M1)); see [NEO-27](../../plans/NEO-27-implementation-plan.md) and [`docs/manual-qa/NEO-27.md`](../../manual-qa/NEO-27.md). **Follow-on / still open:** richer **`InteractableDescriptor`** consumers beyond list projection + existing `POST …/interact`; production telemetry ingest/catalog after E9.M1. **Precursor (E1.M1):** [NEO-9](../../plans/NEO-9-implementation-plan.md) `POST …/interact`. | Linear [NEO-24](https://linear.app/neon-sprawl/issue/NEO-24)[NEO-27](https://linear.app/neon-sprawl/issue/NEO-27); [E1_M3_InteractionAndTargetingLayer.md](E1_M3_InteractionAndTargetingLayer.md); [E1M3 prototype backlog](../../plans/E1M3-prototype-backlog.md); [server README — Targeting](../../../server/README.md#targeting-neo-23), [Interactable descriptors (NEO-25)](../../../server/README.md#interactable-descriptors-neo-25), [Interaction](../../../server/README.md#interaction-neo-9) |
| E1.M4 | In Progress | **NEO-29 landed:** prototype `HotbarLoadout` v1 server contract (`GET`/`POST /game/players/{id}/hotbar-loadout`) with stable deny reason codes, per-player scope, and persistence policy matching NEO-8/NS-17 (Postgres when configured, in-memory fallback otherwise). Client boot hydration is wired via `hotbar_loadout_client.gd` + `hotbar_state.gd` from `main.gd`; manual QA checklist created. **NEO-31 landed:** prototype **`POST /game/players/{id}/ability-cast`**, digit-key cast wiring from `main.gd`, `ability_cast_client.gd`, and `hotbar_cast_slot_resolver.gd` (target id from `lockedTargetId` in cached target state); GdUnit covers resolver + HTTP client; manual QA [NEO-31](../../manual-qa/NEO-31.md). **Still open:** combat accept/deny integration UX, cooldown snapshot sync, and telemetry hooks from later E1.M4 stories. | [NEO-29](../../plans/NEO-29-implementation-plan.md), [NEO-31](../../plans/NEO-31-implementation-plan.md); [E1_M4_AbilityInputScaffold.md](E1_M4_AbilityInputScaffold.md); [E1M4 prototype backlog](../../plans/E1M4-prototype-backlog.md); `server/NeonSprawl.Server/Game/AbilityInput/`; [`client/scripts/hotbar_loadout_client.gd`](../../../client/scripts/hotbar_loadout_client.gd), [`client/scripts/hotbar_state.gd`](../../../client/scripts/hotbar_state.gd), [`client/scripts/ability_cast_client.gd`](../../../client/scripts/ability_cast_client.gd), [`client/scripts/hotbar_cast_slot_resolver.gd`](../../../client/scripts/hotbar_cast_slot_resolver.gd); [server README — Hotbar loadout](../../../server/README.md#hotbar-loadout-neo-29), [Ability cast (NEO-31)](../../../server/README.md#ability-cast-neo-31) |
| E1.M4 | In Progress | **NEO-29 landed:** prototype `HotbarLoadout` v1 server contract (`GET`/`POST /game/players/{id}/hotbar-loadout`) with stable deny reason codes, per-player scope, and persistence policy matching NEO-8/NS-17 (Postgres when configured, in-memory fallback otherwise). Client boot hydration is wired via `hotbar_loadout_client.gd` + `hotbar_state.gd` from `main.gd`; manual QA checklist created. **NEO-31 landed:** prototype **`POST /game/players/{id}/ability-cast`**, digit-key cast wiring from `main.gd`, `ability_cast_client.gd`, and `hotbar_cast_slot_resolver.gd` (target id from `lockedTargetId` in cached target state); GdUnit covers resolver + HTTP client; manual QA [NEO-31](../../manual-qa/NEO-31.md). **NEO-28 landed:** cast POST validates **lock + registry + range** (`invalid_target`, `out_of_range`); **`AbilityCastClient.cast_result_received`** + **`CastFeedbackLabel`** HUD line; manual QA [NEO-28](../../manual-qa/NEO-28.md). **Still open:** `CooldownSnapshot` sync and telemetry hooks from later E1.M4 stories. | [NEO-29](../../plans/NEO-29-implementation-plan.md), [NEO-31](../../plans/NEO-31-implementation-plan.md), [NEO-28](../../plans/NEO-28-implementation-plan.md); [E1_M4_AbilityInputScaffold.md](E1_M4_AbilityInputScaffold.md); [E1M4 prototype backlog](../../plans/E1M4-prototype-backlog.md); `server/NeonSprawl.Server/Game/AbilityInput/`; [`client/scripts/hotbar_loadout_client.gd`](../../../client/scripts/hotbar_loadout_client.gd), [`client/scripts/hotbar_state.gd`](../../../client/scripts/hotbar_state.gd), [`client/scripts/ability_cast_client.gd`](../../../client/scripts/ability_cast_client.gd), [`client/scripts/hotbar_cast_slot_resolver.gd`](../../../client/scripts/hotbar_cast_slot_resolver.gd); [server README — Hotbar loadout](../../../server/README.md#hotbar-loadout-neo-29), [Ability cast (NEO-31)](../../../server/README.md#ability-cast-neo-31) |
---

View File

@ -0,0 +1,24 @@
# NEO-28 — Manual QA checklist
| Field | Value |
|-------|-------|
| Key | NEO-28 |
| Title | E1M4-03: Combat accept/deny integration + reason-code UX |
| Linear | https://linear.app/neon-sprawl/issue/NEO-28/e1m4-03-combat-acceptdeny-integration-reason-code-ux |
| Plan | `docs/plans/NEO-28-implementation-plan.md` |
## 1) Server + Bruno
- [ ] Start server: `cd server/NeonSprawl.Server && dotnet run`.
- [ ] `curl -s http://localhost:5253/health` returns JSON with `status: ok`.
- [ ] Happy path (same order as folder **seq**): **`Post hotbar bind slot0 pulse`** → **`Post target select alpha before cast`** → **`Post cast happy`** — cast **`accepted: true`**.
- [ ] **`Post cast happy`** alone (no target select in session) — expect **`accepted: false`**, **`reasonCode: invalid_target`** (or run `curl` with loadout + no lock).
- [ ] After lock + loadout, **`curl`** cast with **`targetId: prototype_target_beta`** while lock is alpha — **`invalid_target`**.
- [ ] After lock + loadout, move player far from alpha (`POST …/move-stream` with a distant position), then cast with alpha — **`out_of_range`**.
## 2) Client — HUD
- [ ] With server running, open main scene; hydrate hotbar (NEO-29); bind slot **0** to **`prototype_pulse`** if needed.
- [ ] **Without** Tab lock, press **1**: left HUD **`CastFeedbackLabel`** shows **`ability_cast_denied: invalid_target`** (or equivalent); Output may still show **`ability_cast_denied`** warning.
- [ ] **Tab** to lock **alpha**; press **1**: **`CastFeedbackLabel`** shows **`Cast: accepted`**; Output still prints **`ability_cast_requested`** when POST queued.
- [ ] **WASD** until target HUD shows **`Validity: out_of_range`** (soft lock); press **1**: **`CastFeedbackLabel`** shows **`ability_cast_denied: out_of_range`**.

View File

@ -12,10 +12,10 @@
- [ ] Start server: `cd server/NeonSprawl.Server && dotnet run`.
- [ ] `curl -s http://localhost:5253/health` returns JSON with `status: ok`.
- [ ] Run Bruno collection (or at least): `ability-cast/Post hotbar bind slot0 pulse` (seq **20**) then `ability-cast/Post cast happy` (seq **21**) — expect cast `accepted: true`.
- [ ] Run Bruno collection (or at least): `ability-cast/Post hotbar bind slot0 pulse` (seq **20**), **`ability-cast/Post target select alpha before cast`** (seq **21**), then **`ability-cast/Post cast happy`** (seq **22**) — expect cast `accepted: true`.
- [ ] `ability-cast/Post cast bad schema` → HTTP **400**.
- [ ] `ability-cast/Post cast missing player` → HTTP **404**.
- [ ] **`ability-cast/Post cast loadout mismatch`** (seq **24**): run **`ability-cast/Post hotbar bind slot0 pulse`** in the same session first so slot 0 is `prototype_pulse`; then expect `accepted: false` and `reasonCode == "loadout_mismatch"`.
- [ ] **`ability-cast/Post cast loadout mismatch`** (seq **25**): run **`ability-cast/Post hotbar bind slot0 pulse`** in the same session first so slot 0 is `prototype_pulse`; then expect `accepted: false` and `reasonCode == "loadout_mismatch"`.
## 2) Client — bound slot

View File

@ -0,0 +1,91 @@
# NEO-28 — Implementation plan
## Story reference
| Field | Value |
|--------|--------|
| **Key** | NEO-28 |
| **Title** | E1M4-03: Combat accept/deny integration + reason-code UX |
| **Linear** | [NEO-28](https://linear.app/neon-sprawl/issue/NEO-28/e1m4-03-combat-acceptdeny-integration-reason-code-ux) |
| **Slug** | E1M4-03 |
| **Git branch** | `NEO-28-combat-accept-deny-reason-ux` |
| **Parent context** | [E1.M4 prototype backlog](E1M4-prototype-backlog.md) · [E1.M4 — AbilityInputScaffold](../decomposition/modules/E1_M4_AbilityInputScaffold.md) |
| **Related modules** | [E1.M3 — InteractionAndTargetingLayer](../decomposition/modules/E1_M3_InteractionAndTargetingLayer.md), [E5.M1 — CombatRulesEngine](../decomposition/modules/E5_M1_CombatRulesEngine.md) |
## Kickoff clarifications
- **UX surface:** Asked which prototype surface should render denied cast reasons. User chose a **HUD/debug label**: add a small on-screen cast feedback/reason line near the existing target/hotbar debug UI.
- **Validation scope:** Asked how strict the prototype server-side cast denial contract should be. Recommendation was accepted: use the **stricter lock contract**. `POST /game/players/{id}/ability-cast` should validate the request target against the server-acknowledged lock, prototype target registry, and current server position; return `invalid_target` for missing/unknown/mismatch and `out_of_range` when the locked target is too far.
- **Linear blocker:** Linear showed NEO-28 blocked by NEO-31. User confirmed NEO-31 is merged and Done; kickoff removed the `blockedBy: NEO-31` relation from NEO-28 and left NEO-28 **In Progress**.
## Goal, scope, and out-of-scope
**Goal:** Consume authoritative cast accept/deny responses in the client cast path and make rejected casts visible through stable reason-code UX.
**In scope**
- Extend the current prototype ability-cast response contract with target-related denial reasons needed by E1.M4/E5.M1 integration.
- Server-side cast validation for target presence, registry membership, server lock match, and range against the authoritative position snapshot.
- Client-side handling for accepted and denied cast responses, including HUD/debug feedback for reason codes.
- Documentation of the denial contract and reason list so E1.M4 and E5.M1 do not drift.
**Out of scope**
- Rich combat VFX, animation, final combat log styling, damage/healing resolution, cooldown presentation, and final E5.M1 `CombatResolution` semantics.
- Production telemetry ingestion; keep existing `TODO(E9.M1)` hook notes.
## Acceptance criteria checklist
- [x] Denied casts render a player-visible reason path for at least `invalid_target` and `out_of_range`.
- [x] Successful casts transition to post-request state without conflicting local rollback behavior.
- [x] Denial contract and reason list are documented in plan/module docs to prevent drift.
## Technical approach
1. **Server contract** — Extend `AbilityCastApi` from NEO-31 rather than adding a parallel endpoint. Keep the v1 JSON shape (`schemaVersion`, `accepted`, optional `reasonCode`) and add stable target denial constants: `invalid_target` and `out_of_range`.
2. **Server validation order** — Preserve existing request/loadout/ability validation first. After the loadout match succeeds, validate target authority: non-empty request `targetId`, known `PrototypeTargetRegistry` id, request target equals `IPlayerTargetLockStore` current lock, and target is within horizontal range of the authoritative `IPositionStateStore` snapshot. Return a JSON deny response with `accepted: false` for target denials; reserve transport status codes for malformed/missing-player cases already established by NEO-31.
3. **Client response handling** — Make `AbilityCastClient` emit a signal for completed cast responses so `main.gd` can update a HUD/debug cast feedback label. Denies should display `ability_cast_denied: <reasonCode>` or equivalent player-visible text; accepts should clear or replace stale deny text with a lightweight accepted/post-request message.
4. **Optimistic state reconciliation** — Keep the current prototype behavior conservative: `ability_cast_requested` only logs once the POST starts, and no local cooldown/rollback state is introduced in this story. On deny, the feedback label becomes the reconciliation surface; on accept, the client avoids any rollback behavior that would conflict with later cooldown work in NEO-32.
5. **Documentation** — Update E1.M4 and E5.M1 docs with the prototype accept/deny contract, including the reason list and which reasons belong to hotbar/loadout validation versus combat target validation.
## Files to add
| Path | Rationale |
|------|-----------|
| `docs/manual-qa/NEO-28.md` | Manual QA checklist for accepted cast, `invalid_target`, `out_of_range`, and HUD/debug feedback visibility. |
## Files to modify
| Path | Rationale |
|------|-----------|
| `server/NeonSprawl.Server/Game/AbilityInput/AbilityCastApi.cs` | Add target authority validation, reason constants, and `IPlayerTargetLockStore` / `PrototypeTargetRegistry` use. |
| `server/NeonSprawl.Server/Game/AbilityInput/AbilityCastDtos.cs` | Update summary/comments if needed to describe accept/deny v1 target semantics. |
| `server/NeonSprawl.Server.Tests/Game/AbilityInput/AbilityCastApiTests.cs` | Cover `invalid_target`, `out_of_range`, and strict lock-match behavior. |
| `client/scripts/ability_cast_client.gd` | Surface parsed accept/deny responses via signal instead of only `push_warning`. |
| `client/scripts/main.gd` | Add/update HUD/debug cast feedback label wiring and reconcile accepted/denied response display. |
| `client/README.md` | Document cast deny feedback behavior for manual prototype use. |
| `server/README.md` | Document the extended ability-cast denial contract and reason codes. |
| `docs/decomposition/modules/E1_M4_AbilityInputScaffold.md` | Update implementation snapshot, Linear row, and denial contract notes for E1M4-03. |
| `docs/decomposition/modules/E5_M1_CombatRulesEngine.md` | Document the prototype accept/deny handoff and deferred final `CombatResolution` scope. |
| `bruno/neon-sprawl-server/ability-cast/Post cast happy.bru` | Ensure existing happy path still asserts accepted response after target validation setup. |
| `bruno/neon-sprawl-server/ability-cast/Post cast loadout mismatch.bru` | Keep non-target denial regression aligned with response contract. |
## Tests
| Suite | What it covers |
|--------|----------------|
| `server/NeonSprawl.Server.Tests/Game/AbilityInput/AbilityCastApiTests.cs` | Accepted cast with locked in-range target; `invalid_target` for missing/unknown/mismatched target; `out_of_range` when authoritative position is outside prototype lock radius; existing loadout denies remain stable. |
| `client/test/ability_cast_client_test.gd` | Response parsing and signal emission for accepted and denied JSON bodies, including `invalid_target` / `out_of_range`. |
| `client/test/hotbar_cast_slot_resolver_test.gd` | No required change expected unless target payload rules change; keep as regression for request formation from server-ack target state. |
| `docs/manual-qa/NEO-28.md` | Human verification of HUD/debug feedback, accepted cast display, and denial reason visibility. |
| Bruno `ability-cast/*.bru` | Add or adjust requests/tests if needed for target validation preconditions and stable response assertions. |
## Open questions / risks
- **Server lock setup in tests / Bruno:** Existing happy-path requests may need a target-select setup step before cast because the stricter contract requires a server lock.
- **Out-of-range reproduction:** Current target selection rejects out-of-range locks, so `out_of_range` cast tests may need to lock while in range, then move the authoritative position out of range before casting.
- **UI scene edit size:** Adding a HUD/debug label may touch `client/main.tscn`; keep it minimal and aligned with existing debug labels.

View File

@ -2,6 +2,8 @@ using System.Net;
using System.Net.Http.Json;
using System.Text;
using NeonSprawl.Server.Game.AbilityInput;
using NeonSprawl.Server.Game.PositionState;
using NeonSprawl.Server.Game.Targeting;
using Xunit;
namespace NeonSprawl.Server.Tests.Game.AbilityInput;
@ -20,13 +22,41 @@ public sealed class AbilityCastApiTests
post.EnsureSuccessStatusCode();
}
private static async Task LockPrototypeTargetAlphaAsync(HttpClient client)
{
var response = await client.PostAsJsonAsync(
"/game/players/dev-local-1/target/select",
new TargetSelectRequest
{
SchemaVersion = TargetSelectRequest.CurrentSchemaVersion,
TargetId = PrototypeTargetRegistry.PrototypeTargetAlphaId,
});
response.EnsureSuccessStatusCode();
var body = await response.Content.ReadFromJsonAsync<TargetSelectResponse>();
Assert.NotNull(body);
Assert.True(body!.SelectionApplied);
}
private static async Task TeleportDevPlayerAsync(HttpClient client, double x, double y, double z)
{
var response = await client.PostAsJsonAsync(
"/game/players/dev-local-1/move-stream",
new MoveStreamRequest
{
SchemaVersion = MoveStreamRequest.CurrentSchemaVersion,
Targets = [new PositionVector { X = x, Y = y, Z = z }],
});
response.EnsureSuccessStatusCode();
}
[Fact]
public async Task PostAbilityCast_ShouldAccept_WhenLoadoutMatches()
public async Task PostAbilityCast_ShouldAccept_WhenLoadoutMatchesAndLockInRange()
{
// Arrange
await using var factory = new InMemoryWebApplicationFactory();
var client = factory.CreateClient();
await BindSlot0PulseAsync(client);
await LockPrototypeTargetAlphaAsync(client);
// Act
var response = await client.PostAsJsonAsync(
@ -36,7 +66,7 @@ public sealed class AbilityCastApiTests
SchemaVersion = AbilityCastRequest.CurrentSchemaVersion,
SlotIndex = 0,
AbilityId = PrototypeAbilityRegistry.PrototypePulse,
TargetId = null,
TargetId = PrototypeTargetRegistry.PrototypeTargetAlphaId,
});
var body = await response.Content.ReadFromJsonAsync<AbilityCastResponse>();
@ -54,6 +84,7 @@ public sealed class AbilityCastApiTests
await using var factory = new InMemoryWebApplicationFactory();
var client = factory.CreateClient();
await BindSlot0PulseAsync(client);
await LockPrototypeTargetAlphaAsync(client);
// Act
var response = await client.PostAsJsonAsync(
@ -73,6 +104,146 @@ public sealed class AbilityCastApiTests
Assert.True(body!.Accepted);
}
[Fact]
public async Task PostAbilityCast_ShouldDenyInvalidTarget_WhenTargetIdMissing()
{
// Arrange
await using var factory = new InMemoryWebApplicationFactory();
var client = factory.CreateClient();
await BindSlot0PulseAsync(client);
await LockPrototypeTargetAlphaAsync(client);
// Act
var response = await client.PostAsJsonAsync(
"/game/players/dev-local-1/ability-cast",
new AbilityCastRequest
{
SchemaVersion = AbilityCastRequest.CurrentSchemaVersion,
SlotIndex = 0,
AbilityId = PrototypeAbilityRegistry.PrototypePulse,
TargetId = null,
});
var body = await response.Content.ReadFromJsonAsync<AbilityCastResponse>();
// Assert
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
Assert.NotNull(body);
Assert.False(body!.Accepted);
Assert.Equal(AbilityCastApi.ReasonInvalidTarget, body.ReasonCode);
}
[Fact]
public async Task PostAbilityCast_ShouldDenyInvalidTarget_WhenUnknownTargetId()
{
// Arrange
await using var factory = new InMemoryWebApplicationFactory();
var client = factory.CreateClient();
await BindSlot0PulseAsync(client);
await LockPrototypeTargetAlphaAsync(client);
// Act
var response = await client.PostAsJsonAsync(
"/game/players/dev-local-1/ability-cast",
new AbilityCastRequest
{
SchemaVersion = AbilityCastRequest.CurrentSchemaVersion,
SlotIndex = 0,
AbilityId = PrototypeAbilityRegistry.PrototypePulse,
TargetId = "not_a_registered_combat_target",
});
var body = await response.Content.ReadFromJsonAsync<AbilityCastResponse>();
// Assert
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
Assert.NotNull(body);
Assert.False(body!.Accepted);
Assert.Equal(AbilityCastApi.ReasonInvalidTarget, body.ReasonCode);
}
[Fact]
public async Task PostAbilityCast_ShouldDenyInvalidTarget_WhenTargetDoesNotMatchServerLock()
{
// Arrange
await using var factory = new InMemoryWebApplicationFactory();
var client = factory.CreateClient();
await BindSlot0PulseAsync(client);
await LockPrototypeTargetAlphaAsync(client);
// Act — server lock is alpha; client sends beta id
var response = await client.PostAsJsonAsync(
"/game/players/dev-local-1/ability-cast",
new AbilityCastRequest
{
SchemaVersion = AbilityCastRequest.CurrentSchemaVersion,
SlotIndex = 0,
AbilityId = PrototypeAbilityRegistry.PrototypePulse,
TargetId = PrototypeTargetRegistry.PrototypeTargetBetaId,
});
var body = await response.Content.ReadFromJsonAsync<AbilityCastResponse>();
// Assert
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
Assert.NotNull(body);
Assert.False(body!.Accepted);
Assert.Equal(AbilityCastApi.ReasonInvalidTarget, body.ReasonCode);
}
[Fact]
public async Task PostAbilityCast_ShouldDenyInvalidTarget_WhenNoServerLock()
{
// Arrange
await using var factory = new InMemoryWebApplicationFactory();
var client = factory.CreateClient();
await BindSlot0PulseAsync(client);
// Act
var response = await client.PostAsJsonAsync(
"/game/players/dev-local-1/ability-cast",
new AbilityCastRequest
{
SchemaVersion = AbilityCastRequest.CurrentSchemaVersion,
SlotIndex = 0,
AbilityId = PrototypeAbilityRegistry.PrototypePulse,
TargetId = PrototypeTargetRegistry.PrototypeTargetAlphaId,
});
var body = await response.Content.ReadFromJsonAsync<AbilityCastResponse>();
// Assert
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
Assert.NotNull(body);
Assert.False(body!.Accepted);
Assert.Equal(AbilityCastApi.ReasonInvalidTarget, body.ReasonCode);
}
[Fact]
public async Task PostAbilityCast_ShouldDenyOutOfRange_WhenAuthoritativePositionTooFarFromLock()
{
// Arrange
await using var factory = new InMemoryWebApplicationFactory();
var client = factory.CreateClient();
await BindSlot0PulseAsync(client);
await LockPrototypeTargetAlphaAsync(client);
await TeleportDevPlayerAsync(client, 50.0, 0.9, 50.0);
// Act
var response = await client.PostAsJsonAsync(
"/game/players/dev-local-1/ability-cast",
new AbilityCastRequest
{
SchemaVersion = AbilityCastRequest.CurrentSchemaVersion,
SlotIndex = 0,
AbilityId = PrototypeAbilityRegistry.PrototypePulse,
TargetId = PrototypeTargetRegistry.PrototypeTargetAlphaId,
});
var body = await response.Content.ReadFromJsonAsync<AbilityCastResponse>();
// Assert
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
Assert.NotNull(body);
Assert.False(body!.Accepted);
Assert.Equal(AbilityCastApi.ReasonOutOfRange, body.ReasonCode);
}
[Fact]
public async Task PostAbilityCast_ShouldDenySlotUnbound_WhenSlotEmpty()
{

View File

@ -1,8 +1,10 @@
using NeonSprawl.Server.Game.PositionState;
using NeonSprawl.Server.Game.Targeting;
using NeonSprawl.Server.Game.World;
namespace NeonSprawl.Server.Game.AbilityInput;
/// <summary>Maps prototype ability cast POST (NEO-31).</summary>
/// <summary>Maps prototype ability cast POST (NEO-31 + NEO-28 target authority).</summary>
public static class AbilityCastApi
{
public const string ReasonSlotOutOfBounds = "slot_out_of_bounds";
@ -10,18 +12,29 @@ public static class AbilityCastApi
public const string ReasonLoadoutMismatch = "loadout_mismatch";
public const string ReasonUnknownAbility = "unknown_ability";
/// <summary>Request target missing, unknown to the prototype registry, or not the server lock (NEO-28).</summary>
public const string ReasonInvalidTarget = TargetValidity.InvalidTarget;
/// <summary>Locked prototype target is outside horizontal reach of authoritative position (NEO-28).</summary>
public const string ReasonOutOfRange = TargetValidity.OutOfRange;
public static WebApplication MapAbilityCastApi(this WebApplication app)
{
app.MapPost(
"/game/players/{id}/ability-cast",
(string id, AbilityCastRequest? body, IPositionStateStore positions, IPlayerHotbarLoadoutStore store) =>
(
string id,
AbilityCastRequest? body,
IPositionStateStore positions,
IPlayerHotbarLoadoutStore store,
IPlayerTargetLockStore locks) =>
{
if (body is null || body.SchemaVersion != AbilityCastRequest.CurrentSchemaVersion)
{
return Results.BadRequest();
}
if (!positions.TryGetPosition(id, out _))
if (!positions.TryGetPosition(id, out var snap))
{
return Results.NotFound();
}
@ -73,7 +86,33 @@ public static class AbilityCastApi
});
}
// TargetId echoed for NEO-28 / E5.M1; server does not validate against lock store in NEO-31.
if (string.IsNullOrWhiteSpace(body.TargetId))
{
return Results.Json(
new AbilityCastResponse { Accepted = false, ReasonCode = ReasonInvalidTarget });
}
var lookupKey = body.TargetId.Trim().ToLowerInvariant();
if (!PrototypeTargetRegistry.TryGet(lookupKey, out var entry))
{
return Results.Json(
new AbilityCastResponse { Accepted = false, ReasonCode = ReasonInvalidTarget });
}
var (lockIdLowercase, _) = locks.GetLockState(id);
if (string.IsNullOrEmpty(lockIdLowercase) ||
!string.Equals(lookupKey, lockIdLowercase, StringComparison.Ordinal))
{
return Results.Json(
new AbilityCastResponse { Accepted = false, ReasonCode = ReasonInvalidTarget });
}
if (!HorizontalReach.IsWithinHorizontalRadius(snap.X, snap.Z, entry.X, entry.Z, entry.LockRadius))
{
return Results.Json(
new AbilityCastResponse { Accepted = false, ReasonCode = ReasonOutOfRange });
}
return Results.Json(new AbilityCastResponse { Accepted = true });
});

View File

@ -16,12 +16,15 @@ public sealed class AbilityCastRequest
[JsonPropertyName("abilityId")]
public string? AbilityId { get; init; }
/// <summary>Optional; mirrors <c>lockedTargetId</c> from target state v1.</summary>
/// <summary>
/// Must match the server's current combat lock (same string as <c>GET …/target</c> <c>lockedTargetId</c>) for
/// <c>accepted: true</c>; NEO-28 adds <c>invalid_target</c> / <c>out_of_range</c> denies when it does not.
/// </summary>
[JsonPropertyName("targetId")]
public string? TargetId { get; init; }
}
/// <summary>POST response for cast submit (prototype accept/deny only; NEO-31).</summary>
/// <summary>POST response for cast submit (prototype accept/deny; NEO-31 + NEO-28 target rules).</summary>
public sealed class AbilityCastResponse
{
public const int CurrentSchemaVersion = 1;

View File

@ -225,9 +225,9 @@ Current prototype allowlist: `prototype_pulse`, `prototype_guard`, `prototype_da
## Ability cast (NEO-31)
Prototype **cast intent** (no combat resolution yet — see E1.M4 / E5.M1 follow-on stories):
Prototype **cast intent** (no full combat resolution — see E5.M1). **NEO-28** adds server-side target lock + registry + range gates and documents `invalid_target` / `out_of_range` denies; the Godot client surfaces accept/deny on **`CastFeedbackLabel`**.
- **`POST /game/players/{id}/ability-cast`** with `AbilityCastRequest` v1 (`schemaVersion`, `slotIndex` `0..7`, `abilityId`, optional nullable `targetId` mirroring client `lockedTargetId`) validates the player exists, the slot is bound in persisted hotbar loadout, and `abilityId` matches that binding and the prototype allowlist. Returns **`AbilityCastResponse` v1** JSON (`schemaVersion`, `accepted`, optional `reasonCode`).
- **`POST /game/players/{id}/ability-cast`** with `AbilityCastRequest` v1 (`schemaVersion`, `slotIndex` `0..7`, `abilityId`, `targetId`) validates the player exists, the slot is bound in persisted hotbar loadout, and `abilityId` matches that binding and the prototype allowlist. **NEO-28:** `targetId` must be **non-empty**, exist in **`PrototypeTargetRegistry`**, **match** the server's current combat lock (`IPlayerTargetLockStore`), and the lock must be **within horizontal reach** of the authoritative position snapshot (same XZ radius rule as targeting). Returns **`AbilityCastResponse` v1** JSON (`schemaVersion`, `accepted`, optional `reasonCode`).
**HTTP status:**
@ -245,6 +245,8 @@ Prototype **cast intent** (no combat resolution yet — see E1.M4 / E5.M1 follow
| `slot_unbound` | No ability bound on that slot in stored loadout. |
| `loadout_mismatch` | `abilityId` does not match the ability bound on that slot. |
| `unknown_ability` | `abilityId` failed registry normalization (empty/garbage/off-list). |
| `invalid_target` | `targetId` missing/empty, unknown prototype id, or not equal to the server's locked target id (NEO-28). |
| `out_of_range` | Locked target is outside horizontal reach of authoritative player position vs. prototype anchor (NEO-28). |
Implementation: `server/NeonSprawl.Server/Game/AbilityInput/AbilityCastApi.cs`, `AbilityCastDtos.cs`.