NEO-31: ability cast POST, client hotbar keys, tests, Bruno

pull/56/head
VinPropane 2026-04-27 20:17:57 -04:00
parent e0e9f90329
commit 7e97eb4c1d
17 changed files with 808 additions and 15 deletions

View File

@ -0,0 +1,26 @@
meta {
name: ability-cast post bad schema
type: http
seq: 22
}
post {
url: {{baseUrl}}/game/players/dev-local-1/ability-cast
body: json
auth: none
}
body:json {
{
"schemaVersion": 999,
"slotIndex": 0,
"abilityId": "prototype_pulse",
"targetId": null
}
}
tests {
test("status 400", function () {
expect(res.getStatus()).to.equal(400);
});
}

View File

@ -0,0 +1,29 @@
meta {
name: ability-cast post loadout mismatch
type: http
seq: 24
}
post {
url: {{baseUrl}}/game/players/dev-local-1/ability-cast
body: json
auth: none
}
body:json {
{
"schemaVersion": 1,
"slotIndex": 0,
"abilityId": "prototype_burst",
"targetId": null
}
}
tests {
test("status 200 denied", function () {
expect(res.getStatus()).to.equal(200);
const body = res.getBody();
expect(body.accepted).to.equal(false);
expect(body.reasonCode).to.equal("loadout_mismatch");
});
}

View File

@ -0,0 +1,26 @@
meta {
name: ability-cast post missing player
type: http
seq: 23
}
post {
url: {{baseUrl}}/game/players/missing-player/ability-cast
body: json
auth: none
}
body:json {
{
"schemaVersion": 1,
"slotIndex": 0,
"abilityId": "prototype_pulse",
"targetId": null
}
}
tests {
test("status 404", function () {
expect(res.getStatus()).to.equal(404);
});
}

View File

@ -0,0 +1,32 @@
meta {
name: ability-cast post happy
type: http
seq: 21
}
post {
url: {{baseUrl}}/game/players/dev-local-1/ability-cast
body: json
auth: none
}
body:json {
{
"schemaVersion": 1,
"slotIndex": 0,
"abilityId": "prototype_pulse",
"targetId": null
}
}
tests {
test("status 200", function () {
expect(res.getStatus()).to.equal(200);
});
test("accepted true", function () {
const body = res.getBody();
expect(body.schemaVersion).to.equal(1);
expect(body.accepted).to.equal(true);
});
}

View File

@ -0,0 +1,31 @@
meta {
name: ability-cast preset slot0 pulse
type: http
seq: 20
}
post {
url: {{baseUrl}}/game/players/dev-local-1/hotbar-loadout
body: json
auth: none
}
body:json {
{
"schemaVersion": 1,
"slots": [
{
"slotIndex": 0,
"abilityId": "prototype_pulse"
}
]
}
}
tests {
test("status 200 and updated", function () {
expect(res.getStatus()).to.equal(200);
const body = res.getBody();
expect(body.updated).to.equal(true);
});
}

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 telemetry reserves (NEO-27):** `scripts/main.gd` documents future hook sites for **`ability_cast_requested`** / **`ability_cast_denied`** (implemented in E1.M4 + E5.M1, not in this story).
- **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).
- **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,6 +127,13 @@ 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)
- **`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.
Full checklist: [`docs/manual-qa/NEO-31.md`](../docs/manual-qa/NEO-31.md).
### Manual check (NEO-24)
1. Run the server and client as in NEO-7 / NEO-9; confirm spawn snap at `(-5, 0.9, -5)`.

View File

@ -98,6 +98,46 @@ move_back={
"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":83,"physical_keycode":83,"key_label":0,"unicode":115,"location":0,"echo":false,"script":null)
]
}
hotbar_slot_1={
"deadzone": 0.5,
"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":49,"physical_keycode":49,"key_label":0,"unicode":49,"location":0,"echo":false,"script":null)
]
}
hotbar_slot_2={
"deadzone": 0.5,
"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":50,"physical_keycode":50,"key_label":0,"unicode":50,"location":0,"echo":false,"script":null)
]
}
hotbar_slot_3={
"deadzone": 0.5,
"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":51,"physical_keycode":51,"key_label":0,"unicode":51,"location":0,"echo":false,"script":null)
]
}
hotbar_slot_4={
"deadzone": 0.5,
"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":52,"physical_keycode":52,"key_label":0,"unicode":52,"location":0,"echo":false,"script":null)
]
}
hotbar_slot_5={
"deadzone": 0.5,
"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":53,"physical_keycode":53,"key_label":0,"unicode":53,"location":0,"echo":false,"script":null)
]
}
hotbar_slot_6={
"deadzone": 0.5,
"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":54,"physical_keycode":54,"key_label":0,"unicode":54,"location":0,"echo":false,"script":null)
]
}
hotbar_slot_7={
"deadzone": 0.5,
"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":55,"physical_keycode":55,"key_label":0,"unicode":55,"location":0,"echo":false,"script":null)
]
}
hotbar_slot_8={
"deadzone": 0.5,
"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":56,"physical_keycode":56,"key_label":0,"unicode":56,"location":0,"echo":false,"script":null)
]
}
[physics]

View File

@ -0,0 +1,77 @@
extends Node
## NEO-31: prototype HTTP client for `POST /game/players/{id}/ability-cast`.
@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
var _http: Node
var _busy: bool = false
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)
## [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:
if _busy:
return
var tid: Variant = null
if target_id is String:
var s: String = target_id as String
if not s.is_empty():
tid = s
var payload: Dictionary = {
"schemaVersion": 1,
"slotIndex": slot_index,
"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
func _base_root() -> String:
return base_url.strip_edges().rstrip("/")
func _player_path_segment() -> String:
return dev_player_id.strip_edges()
func _on_request_completed(
result: int, response_code: int, _headers: PackedStringArray, body: PackedByteArray
) -> void:
_busy = false
if result != HTTPRequest.RESULT_SUCCESS:
push_warning("AbilityCastClient: HTTP failed (result=%s)" % result)
return
if response_code < 200 or response_code >= 300:
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:
push_warning("AbilityCastClient: non-JSON body")
return
var data: Dictionary = parsed
var accepted: bool = bool(data.get("accepted", false))
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)).
push_warning("ability_cast_denied reasonCode=%s" % reason)

View File

@ -66,6 +66,7 @@ var _dev_saved_obstacle_process_mode: Node.ProcessMode = Node.PROCESS_MODE_INHER
var _dev_obstacle_smoke: Node3D
var _hotbar_state: Node = null
var _hotbar_client: Node = null
var _ability_cast_client: Node = null
@onready var _camera: Camera3D = $World/IsometricFollowCamera/Camera3D
@onready var _world: Node3D = $World
@ -204,10 +205,10 @@ func _on_authoritative_position(world: Vector3, apply_as_snap: bool) -> void:
## hooks for movement-driven lock refresh — we do not snap the capsule to this value
## (that is `authoritative_position_received`'s job on boot + move rejection).
##
## NEO-27 telemetry reserve (input/cast path not implemented in E1.M3):
## - `ability_cast_requested` hook will attach in E1.M4 input wiring before cast submit.
## - `ability_cast_denied` hook will attach on cast-response denial (`reasonCode`) in E1.M4+E5.M1.
## TODO(E9.M1): map both to the telemetry schema/catalog once available.
## 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.
## [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:
_last_ack_world = world
_have_last_ack = true
@ -243,6 +244,16 @@ 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")
_ability_cast_client = load("res://scripts/ability_cast_client.gd").new()
_ability_cast_client.name = "AbilityCastClient"
add_child(_ability_cast_client)
if is_instance_valid(_authority):
var authority_base_url2: Variant = _authority.get("base_url")
var authority_player_id2: Variant = _authority.get("dev_player_id")
if authority_base_url2 is String and not (authority_base_url2 as String).is_empty():
_ability_cast_client.set("base_url", authority_base_url2)
if authority_player_id2 is String and not (authority_player_id2 as String).is_empty():
_ability_cast_client.set("dev_player_id", authority_player_id2)
## Combines cached server state (`_last_target_state`) with per-anchor horizontal
@ -309,6 +320,11 @@ func _on_move_rejected(reason_code: String) -> void:
## **F9** / **F10** are debugger shortcuts in the embedded game; use the Input Map action.
## For a true **freed-node** occluder test, reload the scene or remove the body in the editor.
func _unhandled_key_input(event: InputEvent) -> void:
for slot_digit in range(1, 9):
var action_name := "hotbar_slot_%d" % slot_digit
if event.is_action_pressed(action_name):
_request_hotbar_cast_slot(slot_digit - 1)
return
# NEO-25: route interact keys here — `InteractionRequestClient._input` is unreliable in the
# embedded Game dock / focus order; `_unhandled_key_input` on the scene root still fires.
if event.is_action_pressed("interact"):
@ -330,6 +346,33 @@ func _unhandled_key_input(event: InputEvent) -> void:
call_deferred("_dev_toggle_obstacle_smoke_deferred")
func _request_hotbar_cast_slot(slot_index: int) -> void:
if not is_instance_valid(_hotbar_state) or not _hotbar_state.has_method("slots_snapshot"):
return
var slots: Array = _hotbar_state.call("slots_snapshot") as Array
if slot_index < 0 or slot_index >= slots.size():
push_warning("Hotbar cast ignored: slot %d invalid_slot_index" % slot_index)
return
var ability_variant: Variant = slots[slot_index]
if ability_variant == null or not (ability_variant is String) or (ability_variant as String).strip_edges().is_empty():
push_warning("Hotbar cast ignored: slot %d empty_slot" % slot_index)
return
var ability_id: String = ability_variant as String
var target_id: Variant = null
if is_instance_valid(_target_client) and _target_client.has_method("cached_state"):
var st: Variant = _target_client.call("cached_state")
if st is Dictionary:
var locked: Variant = (st as Dictionary).get("lockedTargetId", null)
if locked is String and not (locked as String).is_empty():
target_id = locked
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" % [slot_index, ability_id, target_label])
if is_instance_valid(_ability_cast_client) and _ability_cast_client.has_method("request_cast"):
_ability_cast_client.call("request_cast", slot_index, ability_id, target_id)
func _forward_interact_post(method: String) -> void:
if not is_instance_valid(_interaction_client):
return

View File

@ -0,0 +1,112 @@
extends GdUnitTestSuite
const CastClient := preload("res://scripts/ability_cast_client.gd")
class MockHttpTransport:
extends Node
signal request_completed(
result: int, response_code: int, headers: PackedStringArray, body: PackedByteArray
)
var last_url: String = ""
var last_body: String = ""
var last_method: HTTPClient.Method = HTTPClient.METHOD_GET
var _queue: Array[Dictionary] = []
func enqueue(result: int, code: int, body: String) -> void:
_queue.append({"result": result, "code": code, "body": body})
func request(
url: String,
_custom_headers: PackedStringArray = PackedStringArray(),
method: HTTPClient.Method = HTTPClient.METHOD_GET,
request_data: String = ""
) -> Error:
last_url = url
last_method = method
last_body = request_data
if _queue.is_empty():
return ERR_UNAVAILABLE
var r: Dictionary = _queue.pop_front()
request_completed.emit(
r["result"], r["code"], PackedStringArray(), str(r["body"]).to_utf8_buffer()
)
return OK
class HoldFirstThenAutoTransport:
extends Node
signal request_completed(
result: int, response_code: int, headers: PackedStringArray, body: PackedByteArray
)
var request_count: int = 0
var last_body: String = ""
var _first: bool = true
func request(
_url: String,
_custom_headers: PackedStringArray = PackedStringArray(),
_method: HTTPClient.Method = HTTPClient.METHOD_GET,
request_data: String = ""
) -> Error:
request_count += 1
last_body = request_data
var body_json := '{"schemaVersion":1,"accepted":true}'
var body_bytes: PackedByteArray = body_json.to_utf8_buffer()
if _first:
_first = false
call_deferred("_emit", body_bytes)
else:
request_completed.emit(HTTPRequest.RESULT_SUCCESS, 200, PackedStringArray(), body_bytes)
return OK
func _emit(body_bytes: PackedByteArray) -> void:
request_completed.emit(HTTPRequest.RESULT_SUCCESS, 200, PackedStringArray(), body_bytes)
func _make_client(transport: Node) -> Node:
var c: Node = CastClient.new()
c.set("injected_http", transport)
auto_free(transport)
auto_free(c)
add_child(c)
return c
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)
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)
assert_that(parsed is Dictionary).is_true()
var body: Dictionary = parsed
assert_that(int(body.get("schemaVersion", 0))).is_equal(1)
assert_that(int(body.get("slotIndex", -1))).is_equal(2)
assert_that(body.get("abilityId")).is_equal("prototype_guard")
assert_that(body.get("targetId", "missing")).is_equal(null)
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")
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_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)
await get_tree().process_frame
await get_tree().process_frame
assert_that(transport.request_count).is_equal(1)
var parsed: Variant = JSON.parse_string(transport.last_body)
var body: Dictionary = parsed
assert_that(int(body.get("slotIndex", -1))).is_equal(0)

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). `AbilityCastRequest` and `CooldownSnapshot` wiring are 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). `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.
@ -56,7 +56,7 @@ Parent epic: [Epic 1 — Core Player Runtime](https://linear.app/neon-sprawl/pro
| Slug | Linear |
|------|--------|
| **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** | TBD — Input to `AbilityCastRequest` 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-04** | TBD — `CooldownSnapshot` sync + slot presentation |
| **E1M4-05** | TBD — Slice 3 telemetry hooks for cast funnel |

View File

@ -0,0 +1,34 @@
# NEO-31 — Manual QA checklist
| Field | Value |
|-------|-------|
| Key | NEO-31 |
| Title | E1M4-02: Input to AbilityCastRequest path |
| Linear | https://linear.app/neon-sprawl/issue/NEO-31/e1m4-02-input-to-abilitycastrequest-path |
| Plan | `docs/plans/NEO-31-implementation-plan.md` |
| Branch | `NEO-31-e1m4-02-input-to-abilitycastrequest-path` |
## 1) Server + Bruno
- [ ] 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-preset-slot0` (seq **20**) then `ability-cast-post` (seq **21**) — expect cast `accepted: true`.
- [ ] `ability-cast-post-bad-schema` → HTTP **400**.
- [ ] `ability-cast-post-missing-player` → HTTP **404**.
- [ ] **`ability-cast-post-loadout-mismatch`** (seq **24**): run **`ability-cast-preset-slot0`** in the same session first so slot 0 is `prototype_pulse`; then expect `accepted: false` and `reasonCode == "loadout_mismatch"`.
## 2) Client — bound slot
- [ ] With server running, open main scene; confirm hotbar hydrated (NEO-29).
- [ ] Bind slot **0** to `prototype_pulse` (Bruno `hotbar-loadout-post` or equivalent `curl`) if not already.
- [ ] Press **1** (InputMap `hotbar_slot_1`): Godot Output shows a line starting with **`ability_cast_requested`** including `slot=0`, `ability_id=prototype_pulse`, and `targetId=` matching current HUD lock (`null` / `—` when cleared, or the locked id after Tab).
- [ ] Server log or network trace shows **one** `POST …/ability-cast` per key press (no spam while key held; Godot `is_action_pressed` fires once per press).
## 3) Client — unbound slot
- [ ] Ensure slot **2** has no ability (default empty).
- [ ] Press **3** (`hotbar_slot_3`): **no** cast POST; Output shows **`Hotbar cast ignored: slot 2 empty_slot`** (or equivalent `push_warning` text).
## 4) Deny UX
- [ ] Force a server deny (e.g. cast wrong ability for bound slot via `curl`): response `accepted: false`; if exercised through the client, Output includes **`ability_cast_denied`** in a warning.

View File

@ -33,9 +33,9 @@
## Acceptance criteria checklist
- [ ] Pressing a bound slot emits **one** cast request with expected payload fields (`schemaVersion`, `slotIndex`, `abilityId`, `targetId` or explicit null/absent rule).
- [ ] Request **target** field is populated from **`TargetSelectionClient`** last acknowledged state (`cached_state()` / `lockedTargetId`), not from camera-only picking.
- [ ] **Unbound** slots: no HTTP cast POST; clear **`push_warning`** or dev log line naming slot + reason (e.g. `empty_slot`) so QA is unambiguous.
- [x] Pressing a bound slot emits **one** cast request with expected payload fields (`schemaVersion`, `slotIndex`, `abilityId`, `targetId` or explicit null/absent rule).
- [x] Request **target** field is populated from **`TargetSelectionClient`** last acknowledged state (`cached_state()` / `lockedTargetId`), not from camera-only picking.
- [x] **Unbound** slots: no HTTP cast POST; clear **`push_warning`** or dev log line naming slot + reason (e.g. `empty_slot`) so QA is unambiguous.
## Technical approach
@ -61,10 +61,11 @@
| `client/scripts/ability_cast_client.gd` | Thin HTTP client for cast POST (injection pattern matches hotbar client). |
| `client/test/ability_cast_client_test.gd` | Mock-transport tests for payload formation and unbound-slot no-op. |
| `docs/manual-qa/NEO-31.md` | Manual QA checklist (slot keys, bound vs unbound, target id from HUD lock). |
| `bruno/neon-sprawl-server/ability-cast-post.bru` | Happy-path `POST …/ability-cast` after loadout binds slot 0 (same workflow as `hotbar-loadout-post.bru`). |
| `bruno/neon-sprawl-server/ability-cast-post-bad-schema.bru` | Wrong/missing `schemaVersion` or malformed body; expect deny + stable shape. |
| `bruno/neon-sprawl-server/ability-cast-post-missing-player.bru` | Unknown `playerId` path segment; parity with `hotbar-loadout-get-missing-player.bru`. |
| `bruno/neon-sprawl-server/ability-cast-post-loadout-mismatch.bru` | Ability/slot does not match persisted hotbar (or unbound slot); documents deny `reasonCode`. |
| `bruno/neon-sprawl-server/ability-cast-preset-slot0.bru` | POST hotbar bind slot 0 → `prototype_pulse` so cast happy/mismatch requests have deterministic loadout state. |
| `bruno/neon-sprawl-server/ability-cast-post.bru` | Happy-path `POST …/ability-cast` (run after preset when exercising alone). |
| `bruno/neon-sprawl-server/ability-cast-post-bad-schema.bru` | Wrong `schemaVersion` → HTTP 400. |
| `bruno/neon-sprawl-server/ability-cast-post-missing-player.bru` | Unknown player path → HTTP 404. |
| `bruno/neon-sprawl-server/ability-cast-post-loadout-mismatch.bru` | Wrong ability for slot 0 → `loadout_mismatch` (requires preset in same run). |
## Files to modify
@ -73,7 +74,8 @@
| `server/NeonSprawl.Server/Program.cs` | Register `MapAbilityCastApi()` (or named extension) alongside existing maps. |
| `client/project.godot` | Add `hotbar_slot_1``hotbar_slot_8` (or compact equivalent) bound to digit keys for prototype. |
| `client/scripts/main.gd` | Wire input → hotbar snapshot + `TargetSelectionClient` + cast client; real `ability_cast_requested` hook site before submit. |
| `docs/decomposition/modules/E1_M4_AbilityInputScaffold.md` | Optional: snapshot + Linear link for E1M4-02 after implementation lands. |
| `client/README.md` | Document digit hotbar keys, cast endpoint, and manual QA link. |
| `docs/decomposition/modules/E1_M4_AbilityInputScaffold.md` | Snapshot + Linear link for E1M4-02. |
## Tests

View File

@ -0,0 +1,213 @@
using System.Net;
using System.Net.Http.Json;
using System.Text;
using NeonSprawl.Server.Game.AbilityInput;
using Xunit;
namespace NeonSprawl.Server.Tests.Game.AbilityInput;
public sealed class AbilityCastApiTests
{
private static async Task BindSlot0PulseAsync(HttpClient client)
{
var post = await client.PostAsJsonAsync(
"/game/players/dev-local-1/hotbar-loadout",
new HotbarLoadoutUpdateRequest
{
SchemaVersion = HotbarLoadoutUpdateRequest.CurrentSchemaVersion,
Slots = [new HotbarSlotBindingJson { SlotIndex = 0, AbilityId = PrototypeAbilityRegistry.PrototypePulse }],
});
post.EnsureSuccessStatusCode();
}
[Fact]
public async Task PostAbilityCast_ShouldAccept_WhenLoadoutMatches()
{
await using var factory = new InMemoryWebApplicationFactory();
var client = factory.CreateClient();
await BindSlot0PulseAsync(client);
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.Equal(HttpStatusCode.OK, response.StatusCode);
Assert.NotNull(body);
Assert.True(body!.Accepted);
Assert.Null(body.ReasonCode);
}
[Fact]
public async Task PostAbilityCast_ShouldAccept_WithTargetId_WhenLoadoutMatches()
{
await using var factory = new InMemoryWebApplicationFactory();
var client = factory.CreateClient();
await BindSlot0PulseAsync(client);
var response = await client.PostAsJsonAsync(
"/game/players/dev-local-1/ability-cast",
new AbilityCastRequest
{
SchemaVersion = AbilityCastRequest.CurrentSchemaVersion,
SlotIndex = 0,
AbilityId = PrototypeAbilityRegistry.PrototypePulse,
TargetId = "prototype_target_alpha",
});
var body = await response.Content.ReadFromJsonAsync<AbilityCastResponse>();
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
Assert.NotNull(body);
Assert.True(body!.Accepted);
}
[Fact]
public async Task PostAbilityCast_ShouldDenySlotUnbound_WhenSlotEmpty()
{
await using var factory = new InMemoryWebApplicationFactory();
var client = factory.CreateClient();
var response = await client.PostAsJsonAsync(
"/game/players/dev-local-1/ability-cast",
new AbilityCastRequest
{
SchemaVersion = AbilityCastRequest.CurrentSchemaVersion,
SlotIndex = 2,
AbilityId = PrototypeAbilityRegistry.PrototypeGuard,
TargetId = null,
});
var body = await response.Content.ReadFromJsonAsync<AbilityCastResponse>();
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
Assert.NotNull(body);
Assert.False(body!.Accepted);
Assert.Equal(AbilityCastApi.ReasonSlotUnbound, body.ReasonCode);
}
[Fact]
public async Task PostAbilityCast_ShouldDenyLoadoutMismatch_WhenAbilityDoesNotMatchSlot()
{
await using var factory = new InMemoryWebApplicationFactory();
var client = factory.CreateClient();
await BindSlot0PulseAsync(client);
var response = await client.PostAsJsonAsync(
"/game/players/dev-local-1/ability-cast",
new AbilityCastRequest
{
SchemaVersion = AbilityCastRequest.CurrentSchemaVersion,
SlotIndex = 0,
AbilityId = PrototypeAbilityRegistry.PrototypeBurst,
TargetId = null,
});
var body = await response.Content.ReadFromJsonAsync<AbilityCastResponse>();
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
Assert.NotNull(body);
Assert.False(body!.Accepted);
Assert.Equal(AbilityCastApi.ReasonLoadoutMismatch, body.ReasonCode);
}
[Fact]
public async Task PostAbilityCast_ShouldDenySlotOutOfBounds()
{
await using var factory = new InMemoryWebApplicationFactory();
var client = factory.CreateClient();
await BindSlot0PulseAsync(client);
var response = await client.PostAsJsonAsync(
"/game/players/dev-local-1/ability-cast",
new AbilityCastRequest
{
SchemaVersion = AbilityCastRequest.CurrentSchemaVersion,
SlotIndex = 8,
AbilityId = PrototypeAbilityRegistry.PrototypePulse,
TargetId = null,
});
var body = await response.Content.ReadFromJsonAsync<AbilityCastResponse>();
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
Assert.NotNull(body);
Assert.False(body!.Accepted);
Assert.Equal(AbilityCastApi.ReasonSlotOutOfBounds, body.ReasonCode);
}
[Fact]
public async Task PostAbilityCast_ShouldDenyUnknownAbility_WhenAbilityIdNotInRegistry()
{
await using var factory = new InMemoryWebApplicationFactory();
var client = factory.CreateClient();
await BindSlot0PulseAsync(client);
var response = await client.PostAsJsonAsync(
"/game/players/dev-local-1/ability-cast",
new AbilityCastRequest
{
SchemaVersion = AbilityCastRequest.CurrentSchemaVersion,
SlotIndex = 0,
AbilityId = "not_a_real_ability",
TargetId = null,
});
var body = await response.Content.ReadFromJsonAsync<AbilityCastResponse>();
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
Assert.NotNull(body);
Assert.False(body!.Accepted);
Assert.Equal(AbilityCastApi.ReasonUnknownAbility, body.ReasonCode);
}
[Fact]
public async Task PostAbilityCast_ShouldReturnBadRequest_WhenBodyMalformed()
{
await using var factory = new InMemoryWebApplicationFactory();
var client = factory.CreateClient();
var response = await client.PostAsync(
"/game/players/dev-local-1/ability-cast",
new StringContent("{\"schemaVersion\":1,\"slotIndex\":", Encoding.UTF8, "application/json"));
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
}
[Fact]
public async Task PostAbilityCast_ShouldReturnBadRequest_WhenSchemaVersionWrong()
{
await using var factory = new InMemoryWebApplicationFactory();
var client = factory.CreateClient();
var response = await client.PostAsJsonAsync(
"/game/players/dev-local-1/ability-cast",
new AbilityCastRequest
{
SchemaVersion = 999,
SlotIndex = 0,
AbilityId = PrototypeAbilityRegistry.PrototypePulse,
});
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
}
[Fact]
public async Task PostAbilityCast_ShouldReturnNotFound_WhenPlayerUnknown()
{
await using var factory = new InMemoryWebApplicationFactory();
var client = factory.CreateClient();
var response = await client.PostAsJsonAsync(
"/game/players/missing-player/ability-cast",
new AbilityCastRequest
{
SchemaVersion = AbilityCastRequest.CurrentSchemaVersion,
SlotIndex = 0,
AbilityId = PrototypeAbilityRegistry.PrototypePulse,
});
Assert.Equal(HttpStatusCode.NotFound, response.StatusCode);
}
}

View File

@ -0,0 +1,82 @@
using NeonSprawl.Server.Game.PositionState;
namespace NeonSprawl.Server.Game.AbilityInput;
/// <summary>Maps prototype ability cast POST (NEO-31).</summary>
public static class AbilityCastApi
{
public const string ReasonSlotOutOfBounds = "slot_out_of_bounds";
public const string ReasonSlotUnbound = "slot_unbound";
public const string ReasonLoadoutMismatch = "loadout_mismatch";
public const string ReasonUnknownAbility = "unknown_ability";
public static WebApplication MapAbilityCastApi(this WebApplication app)
{
app.MapPost(
"/game/players/{id}/ability-cast",
(string id, AbilityCastRequest? body, IPositionStateStore positions, IPlayerHotbarLoadoutStore store) =>
{
if (body is null || body.SchemaVersion != AbilityCastRequest.CurrentSchemaVersion)
{
return Results.BadRequest();
}
if (!positions.TryGetPosition(id, out _))
{
return Results.NotFound();
}
if (!store.TryGetBindings(id, out var bindings))
{
return Results.NotFound();
}
if (body.SlotIndex < 0 || body.SlotIndex >= HotbarLoadoutResponse.SlotCountV1)
{
return Results.Json(
new AbilityCastResponse
{
Accepted = false,
ReasonCode = ReasonSlotOutOfBounds,
});
}
if (!bindings.TryGetValue(body.SlotIndex, out var boundAbility) ||
string.IsNullOrWhiteSpace(boundAbility))
{
return Results.Json(
new AbilityCastResponse
{
Accepted = false,
ReasonCode = ReasonSlotUnbound,
});
}
if (string.IsNullOrWhiteSpace(body.AbilityId) ||
!PrototypeAbilityRegistry.TryNormalizeKnown(body.AbilityId, out var normalizedRequest))
{
return Results.Json(
new AbilityCastResponse
{
Accepted = false,
ReasonCode = ReasonUnknownAbility,
});
}
if (!string.Equals(normalizedRequest, boundAbility, StringComparison.Ordinal))
{
return Results.Json(
new AbilityCastResponse
{
Accepted = false,
ReasonCode = ReasonLoadoutMismatch,
});
}
// TargetId echoed for NEO-28 / E5.M1; server does not validate against lock store in NEO-31.
return Results.Json(new AbilityCastResponse { Accepted = true });
});
return app;
}
}

View File

@ -0,0 +1,38 @@
using System.Text.Json.Serialization;
namespace NeonSprawl.Server.Game.AbilityInput;
/// <summary>POST body for ability cast intent (NEO-31).</summary>
public sealed class AbilityCastRequest
{
public const int CurrentSchemaVersion = 1;
[JsonPropertyName("schemaVersion")]
public int SchemaVersion { get; init; }
[JsonPropertyName("slotIndex")]
public int SlotIndex { get; init; }
[JsonPropertyName("abilityId")]
public string? AbilityId { get; init; }
/// <summary>Optional; mirrors <c>lockedTargetId</c> from target state v1.</summary>
[JsonPropertyName("targetId")]
public string? TargetId { get; init; }
}
/// <summary>POST response for cast submit (prototype accept/deny only; NEO-31).</summary>
public sealed class AbilityCastResponse
{
public const int CurrentSchemaVersion = 1;
[JsonPropertyName("schemaVersion")]
public int SchemaVersion { get; init; } = CurrentSchemaVersion;
[JsonPropertyName("accepted")]
public bool Accepted { get; init; }
[JsonPropertyName("reasonCode")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public string? ReasonCode { get; init; }
}

View File

@ -25,5 +25,6 @@ app.MapInteractionApi();
app.MapInteractablesWorldApi();
app.MapTargetingApi();
app.MapHotbarLoadoutApi();
app.MapAbilityCastApi();
app.Run();