From cc1c6f711e6a96b2d7a80db906e09f3bb1d61a1a Mon Sep 17 00:00:00 2001 From: VinPropane Date: Mon, 27 Apr 2026 20:44:40 -0400 Subject: [PATCH] NEO-31: request_cast bool for telemetry hook; document cast API in server README --- client/README.md | 2 +- client/scripts/ability_cast_client.gd | 12 ++++++--- client/scripts/main.gd | 21 +++++++++------- client/test/ability_cast_client_test.gd | 19 +++++++++++--- ...umentation_and_implementation_alignment.md | 2 +- docs/plans/NEO-31-implementation-plan.md | 2 +- docs/reviews/2026-04-27-NEO-31.md | 4 +-- server/README.md | 25 +++++++++++++++++++ 8 files changed, 65 insertions(+), 22 deletions(-) diff --git a/client/README.md b/client/README.md index b47d147..b366707 100644 --- a/client/README.md +++ b/client/README.md @@ -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` immediately before the POST; **`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):** 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). - **E1.M4+:** connect to **`$TargetSelectionClient.selection_event`** (or your scene’s path to that node) without reshaping the payload. Full checklist: [`docs/manual-qa/NEO-26.md`](../docs/manual-qa/NEO-26.md). diff --git a/client/scripts/ability_cast_client.gd b/client/scripts/ability_cast_client.gd index 471a7e0..b3b3350 100644 --- a/client/scripts/ability_cast_client.gd +++ b/client/scripts/ability_cast_client.gd @@ -22,9 +22,12 @@ func _ready() -> void: ## [param target_id] server [code]lockedTargetId[/code] string, or [code]null[/code] if none. -func request_cast(slot_index: int, ability_id: String, target_id: Variant) -> void: +## Returns [code]true[/code] when the HTTP POST was **queued** ([method HTTPRequest.request] returned [code]OK[/code]); +## [code]false[/code] if already [member _busy], request failed to start, or client invalid — use for +## [code]ability_cast_requested[/code] dev / future telemetry so hooks match actual submit attempts. +func request_cast(slot_index: int, ability_id: String, target_id: Variant) -> bool: if _busy: - return + return false var tid: Variant = null if target_id is String: var s: String = target_id as String @@ -36,13 +39,14 @@ func request_cast(slot_index: int, ability_id: String, target_id: Variant) -> vo "abilityId": ability_id, "targetId": tid, } - _busy = true var url := "%s/game/players/%s/ability-cast" % [_base_root(), _player_path_segment()] var headers := PackedStringArray(["Content-Type: application/json"]) var err: Error = _http.request(url, headers, HTTPClient.METHOD_POST, JSON.stringify(payload)) if err != OK: push_warning("AbilityCastClient: POST failed to start (%s)" % err) - _busy = false + return false + _busy = true + return true func _base_root() -> String: diff --git a/client/scripts/main.gd b/client/scripts/main.gd index b35bd0a..141d23b 100644 --- a/client/scripts/main.gd +++ b/client/scripts/main.gd @@ -207,7 +207,8 @@ func _on_authoritative_position(world: Vector3, apply_as_snap: bool) -> void: ## (that is `authoritative_position_received`'s job on boot + move rejection). ## ## NEO-27 / NEO-31: `ability_cast_requested` is emitted as a dev [code]print[/code] from -## [method _request_hotbar_cast_slot] immediately before the cast HTTP POST starts. +## [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). func _on_authoritative_ack_for_hud(world: Vector3) -> void: @@ -367,15 +368,17 @@ func _request_hotbar_cast_slot(slot_index: int) -> void: var ability_id: String = outcome[HotbarCastSlotResolver.KEY_ABILITY_ID] as String var resolved_slot: int = int(outcome.get(HotbarCastSlotResolver.KEY_SLOT_INDEX, slot_index)) var target_id: Variant = outcome.get(HotbarCastSlotResolver.KEY_TARGET_ID, null) - var target_label: String = "null" - if target_id != null: - target_label = str(target_id) - print( - "ability_cast_requested slot=%d ability_id=%s targetId=%s" - % [resolved_slot, ability_id, target_label] - ) + var started: bool = false if is_instance_valid(_ability_cast_client) and _ability_cast_client.has_method("request_cast"): - _ability_cast_client.call("request_cast", resolved_slot, ability_id, target_id) + started = bool(_ability_cast_client.call("request_cast", resolved_slot, ability_id, target_id)) + if started: + var target_label: String = "null" + if target_id != null: + target_label = str(target_id) + print( + "ability_cast_requested slot=%d ability_id=%s targetId=%s" + % [resolved_slot, ability_id, target_label] + ) func _forward_interact_post(method: String) -> void: diff --git a/client/test/ability_cast_client_test.gd b/client/test/ability_cast_client_test.gd index 5f23329..8b92f69 100644 --- a/client/test/ability_cast_client_test.gd +++ b/client/test/ability_cast_client_test.gd @@ -77,7 +77,8 @@ func test_request_cast_posts_expected_payload_with_null_target() -> void: var transport := MockHttpTransport.new() transport.enqueue(HTTPRequest.RESULT_SUCCESS, 200, '{"schemaVersion":1,"accepted":true}') var c := _make_client(transport) - c.call("request_cast", 2, "prototype_guard", null) + var started: bool = bool(c.call("request_cast", 2, "prototype_guard", null)) + assert_that(started).is_true() assert_that(transport.last_url).contains("/ability-cast") assert_that(transport.last_method).is_equal(HTTPClient.METHOD_POST) var parsed: Variant = JSON.parse_string(transport.last_body) @@ -93,17 +94,27 @@ func test_request_cast_posts_target_id_string_when_set() -> void: var transport := MockHttpTransport.new() transport.enqueue(HTTPRequest.RESULT_SUCCESS, 200, '{"schemaVersion":1,"accepted":true}') var c := _make_client(transport) - c.call("request_cast", 0, "prototype_pulse", "prototype_target_alpha") + assert_that(bool(c.call("request_cast", 0, "prototype_pulse", "prototype_target_alpha"))).is_true() var parsed: Variant = JSON.parse_string(transport.last_body) var body: Dictionary = parsed assert_that(body.get("targetId")).is_equal("prototype_target_alpha") +func test_request_cast_returns_false_when_http_request_fails_to_start() -> void: + # Arrange: empty queue → transport.request returns ERR_UNAVAILABLE (same as failed start). + var transport := MockHttpTransport.new() + var c := _make_client(transport) + # Act + var started: bool = bool(c.call("request_cast", 0, "prototype_pulse", null)) + # Assert + assert_that(started).is_false() + + func test_request_cast_while_busy_is_ignored() -> void: var transport := HoldFirstThenAutoTransport.new() var c := _make_client(transport) - c.call("request_cast", 0, "prototype_pulse", null) - c.call("request_cast", 1, "prototype_guard", null) + assert_that(bool(c.call("request_cast", 0, "prototype_pulse", null))).is_true() + assert_that(bool(c.call("request_cast", 1, "prototype_guard", null))).is_false() await get_tree().process_frame await get_tree().process_frame assert_that(transport.request_count).is_equal(1) diff --git a/docs/decomposition/modules/documentation_and_implementation_alignment.md b/docs/decomposition/modules/documentation_and_implementation_alignment.md index cb557c2..8e4396a 100644 --- a/docs/decomposition/modules/documentation_and_implementation_alignment.md +++ b/docs/decomposition/modules/documentation_and_implementation_alignment.md @@ -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) | +| 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) | --- diff --git a/docs/plans/NEO-31-implementation-plan.md b/docs/plans/NEO-31-implementation-plan.md index 11d8259..d00fd33 100644 --- a/docs/plans/NEO-31-implementation-plan.md +++ b/docs/plans/NEO-31-implementation-plan.md @@ -43,7 +43,7 @@ 2. **Server** — New `POST /game/players/{id}/ability-cast` (name fixed in implementation; document in plan **Decisions** if adjusted) mapped in `Program.cs`, implemented beside existing `Game/AbilityInput` types. Validate: player exists (same **player id** gate as hotbar APIs via `IPositionStateStore` or equivalent), slot in range, **non-empty ability** on that slot from **`IPlayerHotbarLoadoutStore`** matches request `abilityId`. Do **not** implement full combat engine; optionally shallow-check `targetId` against current lock store if low-cost, otherwise accept client-provided target for v1 and let NEO-28 tighten. -3. **Client** — New `AbilityCastClient` (or equivalent) mirroring `HotbarLoadoutClient`: `base_url`, `dev_player_id`, injectable HTTP, `request_cast(slot_index, ability_id, target_id_variant)` that POSTs JSON once. **`main.gd`** (or dedicated small coordinator): on new InputMap actions **`hotbar_slot_1` … `hotbar_slot_8`** (digits 1–8), resolve slot index, read **`HotbarState.slots_snapshot()`**; if empty, warn and return; else read **`TargetSelectionClient.cached_state()`** for `lockedTargetId`, build payload, call cast client. Replace NEO-27 **reserve-only** comment block with a real **`ability_cast_requested`** hook site (still `TODO(E9.M1)` for ingest) immediately **before** successful POST start, per `main.gd` notes. +3. **Client** — New `AbilityCastClient` (or equivalent) mirroring `HotbarLoadoutClient`: `base_url`, `dev_player_id`, injectable HTTP, **`request_cast(...) -> bool`** (true when `HTTPRequest.request` queued the POST). **`main.gd`**: digit keys **`hotbar_slot_1` … `hotbar_slot_8`**, **`HotbarCastSlotResolver`**, then cast client; **`ability_cast_requested`** dev print only when **`request_cast`** returns **`true`** (still `TODO(E9.M1)` for ingest). 4. **Tests** — **Server:** xUnit suite posting valid/invalid bodies (unknown player, empty slot, ability mismatch). **Client:** GdUnit suite with mock HTTP verifying URL/method/body for bound vs unbound paths and correct target id propagation from a stub target state provider. diff --git a/docs/reviews/2026-04-27-NEO-31.md b/docs/reviews/2026-04-27-NEO-31.md index 86a0476..c8114e8 100644 --- a/docs/reviews/2026-04-27-NEO-31.md +++ b/docs/reviews/2026-04-27-NEO-31.md @@ -29,9 +29,9 @@ NEO-31 adds the prototype `POST /game/players/{id}/ability-cast` route, client d 1. ~~Update `docs/decomposition/modules/documentation_and_implementation_alignment.md` for NEO-31. The module page now says NEO-31 landed, but the tracking table still says "Still open: cast request path"; this will mislead the next implementation/review pass.~~ **Done.** -2. Consider making `AbilityCastClient.request_cast()` return whether a POST actually started, then log `ability_cast_requested` only on success. Today `client/scripts/main.gd` prints `ability_cast_requested` before calling the client, while `client/scripts/ability_cast_client.gd` silently returns when `_busy` is true or when the request fails to start. That can over-count cast requests in the future telemetry hook and conflicts with the plan wording that the hook sits immediately before a successful POST start. +2. ~~Consider making `AbilityCastClient.request_cast()` return whether a POST actually started, then log `ability_cast_requested` only on success. Today `client/scripts/main.gd` prints `ability_cast_requested` before calling the client, while `client/scripts/ability_cast_client.gd` silently returns when `_busy` is true or when the request fails to start. That can over-count cast requests in the future telemetry hook and conflicts with the plan wording that the hook sits immediately before a successful POST start.~~ **Done.** `request_cast` → `bool`; `_busy` set only after `HTTPRequest.request` returns `OK`; `main.gd` prints only when `true`. -3. Consider adding a short `server/README.md` section for `POST /game/players/{id}/ability-cast`, matching the existing server-side endpoint documentation pattern used for hotbar loadout and targeting. The client README and Bruno collection cover manual usage, but the server API index currently has no NEO-31 entry. +3. ~~Consider adding a short `server/README.md` section for `POST /game/players/{id}/ability-cast`, matching the existing server-side endpoint documentation pattern used for hotbar loadout and targeting. The client README and Bruno collection cover manual usage, but the server API index currently has no NEO-31 entry.~~ **Done.** See [server README — Ability cast (NEO-31)](../../server/README.md#ability-cast-neo-31). ## Nits diff --git a/server/README.md b/server/README.md index 4ad782f..bd5a3dd 100644 --- a/server/README.md +++ b/server/README.md @@ -223,6 +223,31 @@ Prototype server-owned hotbar bindings are available at: Current prototype allowlist: `prototype_pulse`, `prototype_guard`, `prototype_dash`, `prototype_burst`. +## Ability cast (NEO-31) + +Prototype **cast intent** (no combat resolution yet — see E1.M4 / E5.M1 follow-on stories): + +- **`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`). + +**HTTP status:** + +| Status | When | +|--------|------| +| **200** | Body parses as `AbilityCastResponse`; `accepted` is `true` or `false` with a stable `reasonCode` on deny. | +| **400** | Missing body or wrong `schemaVersion`. | +| **404** | Unknown player id (no position row / unknown player for this prototype). | + +**Deny `reasonCode` values (200, `accepted=false`):** + +| Code | Meaning | +|------|---------| +| `slot_out_of_bounds` | `slotIndex` outside `0..7`. | +| `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). | + +Implementation: `server/NeonSprawl.Server/Game/AbilityInput/AbilityCastApi.cs`, `AbilityCastDtos.cs`. + ## Solution From repo root: `dotnet build NeonSprawl.sln` and `dotnet test NeonSprawl.sln`.