diff --git a/bruno/neon-sprawl-server/ability-cast-post-bad-schema.bru b/bruno/neon-sprawl-server/ability-cast-post-bad-schema.bru new file mode 100644 index 0000000..f32a9db --- /dev/null +++ b/bruno/neon-sprawl-server/ability-cast-post-bad-schema.bru @@ -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); + }); +} diff --git a/bruno/neon-sprawl-server/ability-cast-post-loadout-mismatch.bru b/bruno/neon-sprawl-server/ability-cast-post-loadout-mismatch.bru new file mode 100644 index 0000000..87e889d --- /dev/null +++ b/bruno/neon-sprawl-server/ability-cast-post-loadout-mismatch.bru @@ -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"); + }); +} diff --git a/bruno/neon-sprawl-server/ability-cast-post-missing-player.bru b/bruno/neon-sprawl-server/ability-cast-post-missing-player.bru new file mode 100644 index 0000000..88235ce --- /dev/null +++ b/bruno/neon-sprawl-server/ability-cast-post-missing-player.bru @@ -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); + }); +} diff --git a/bruno/neon-sprawl-server/ability-cast-post.bru b/bruno/neon-sprawl-server/ability-cast-post.bru new file mode 100644 index 0000000..e30940d --- /dev/null +++ b/bruno/neon-sprawl-server/ability-cast-post.bru @@ -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); + }); +} diff --git a/bruno/neon-sprawl-server/ability-cast-preset-slot0.bru b/bruno/neon-sprawl-server/ability-cast-preset-slot0.bru new file mode 100644 index 0000000..0095c85 --- /dev/null +++ b/bruno/neon-sprawl-server/ability-cast-preset-slot0.bru @@ -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); + }); +} diff --git a/client/README.md b/client/README.md index a67be42..c09a16e 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 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 scene’s 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` (0–7), `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)`. diff --git a/client/project.godot b/client/project.godot index 207e57a..ce46c18 100644 --- a/client/project.godot +++ b/client/project.godot @@ -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] diff --git a/client/scripts/ability_cast_client.gd b/client/scripts/ability_cast_client.gd new file mode 100644 index 0000000..471a7e0 --- /dev/null +++ b/client/scripts/ability_cast_client.gd @@ -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) diff --git a/client/scripts/main.gd b/client/scripts/main.gd index 2a0f465..d2ccf67 100644 --- a/client/scripts/main.gd +++ b/client/scripts/main.gd @@ -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 diff --git a/client/test/ability_cast_client_test.gd b/client/test/ability_cast_client_test.gd new file mode 100644 index 0000000..5f23329 --- /dev/null +++ b/client/test/ability_cast_client_test.gd @@ -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) diff --git a/docs/decomposition/modules/E1_M4_AbilityInputScaffold.md b/docs/decomposition/modules/E1_M4_AbilityInputScaffold.md index 1205ac5..aa9ba8a 100644 --- a/docs/decomposition/modules/E1_M4_AbilityInputScaffold.md +++ b/docs/decomposition/modules/E1_M4_AbilityInputScaffold.md @@ -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 | diff --git a/docs/manual-qa/NEO-31.md b/docs/manual-qa/NEO-31.md new file mode 100644 index 0000000..1a44ba1 --- /dev/null +++ b/docs/manual-qa/NEO-31.md @@ -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. diff --git a/docs/plans/NEO-31-implementation-plan.md b/docs/plans/NEO-31-implementation-plan.md index c29bdda..db1bbc8 100644 --- a/docs/plans/NEO-31-implementation-plan.md +++ b/docs/plans/NEO-31-implementation-plan.md @@ -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 diff --git a/server/NeonSprawl.Server.Tests/Game/AbilityInput/AbilityCastApiTests.cs b/server/NeonSprawl.Server.Tests/Game/AbilityInput/AbilityCastApiTests.cs new file mode 100644 index 0000000..2962d91 --- /dev/null +++ b/server/NeonSprawl.Server.Tests/Game/AbilityInput/AbilityCastApiTests.cs @@ -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(); + + 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(); + + 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(); + + 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(); + + 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(); + + 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(); + + 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); + } +} diff --git a/server/NeonSprawl.Server/Game/AbilityInput/AbilityCastApi.cs b/server/NeonSprawl.Server/Game/AbilityInput/AbilityCastApi.cs new file mode 100644 index 0000000..ba3cf6c --- /dev/null +++ b/server/NeonSprawl.Server/Game/AbilityInput/AbilityCastApi.cs @@ -0,0 +1,82 @@ +using NeonSprawl.Server.Game.PositionState; + +namespace NeonSprawl.Server.Game.AbilityInput; + +/// Maps prototype ability cast POST (NEO-31). +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; + } +} diff --git a/server/NeonSprawl.Server/Game/AbilityInput/AbilityCastDtos.cs b/server/NeonSprawl.Server/Game/AbilityInput/AbilityCastDtos.cs new file mode 100644 index 0000000..ff08086 --- /dev/null +++ b/server/NeonSprawl.Server/Game/AbilityInput/AbilityCastDtos.cs @@ -0,0 +1,38 @@ +using System.Text.Json.Serialization; + +namespace NeonSprawl.Server.Game.AbilityInput; + +/// POST body for ability cast intent (NEO-31). +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; } + + /// Optional; mirrors lockedTargetId from target state v1. + [JsonPropertyName("targetId")] + public string? TargetId { get; init; } +} + +/// POST response for cast submit (prototype accept/deny only; NEO-31). +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; } +} diff --git a/server/NeonSprawl.Server/Program.cs b/server/NeonSprawl.Server/Program.cs index 0664a57..e4b1fe9 100644 --- a/server/NeonSprawl.Server/Program.cs +++ b/server/NeonSprawl.Server/Program.cs @@ -25,5 +25,6 @@ app.MapInteractionApi(); app.MapInteractablesWorldApi(); app.MapTargetingApi(); app.MapHotbarLoadoutApi(); +app.MapAbilityCastApi(); app.Run();