From 7de8e7ecfd5a069ccaae140a181774e33bc40b0b Mon Sep 17 00:00:00 2001 From: VinPropane Date: Mon, 27 Apr 2026 21:02:10 -0400 Subject: [PATCH] NEO-28: cast target authority, HUD feedback, tests + docs --- .../ability-cast/Post cast bad schema.bru | 2 +- .../ability-cast/Post cast happy.bru | 4 +- .../Post cast loadout mismatch.bru | 2 +- .../ability-cast/Post cast missing player.bru | 2 +- .../Post target select alpha before cast.bru | 27 +++ client/README.md | 10 +- client/scenes/main.tscn | 14 ++ client/scripts/ability_cast_client.gd | 10 +- client/scripts/main.gd | 23 ++- client/test/ability_cast_client_test.gd | 32 ++++ .../modules/E1_M4_AbilityInputScaffold.md | 4 +- .../modules/E5_M1_CombatRulesEngine.md | 4 + ...umentation_and_implementation_alignment.md | 2 +- docs/manual-qa/NEO-28.md | 24 +++ docs/manual-qa/NEO-31.md | 4 +- docs/plans/NEO-28-implementation-plan.md | 91 +++++++++ .../Game/AbilityInput/AbilityCastApiTests.cs | 175 +++++++++++++++++- .../Game/AbilityInput/AbilityCastApi.cs | 47 ++++- .../Game/AbilityInput/AbilityCastDtos.cs | 7 +- server/README.md | 6 +- 20 files changed, 460 insertions(+), 30 deletions(-) create mode 100644 bruno/neon-sprawl-server/ability-cast/Post target select alpha before cast.bru create mode 100644 docs/manual-qa/NEO-28.md create mode 100644 docs/plans/NEO-28-implementation-plan.md diff --git a/bruno/neon-sprawl-server/ability-cast/Post cast bad schema.bru b/bruno/neon-sprawl-server/ability-cast/Post cast bad schema.bru index cc55b1b..af7ef4c 100644 --- a/bruno/neon-sprawl-server/ability-cast/Post cast bad schema.bru +++ b/bruno/neon-sprawl-server/ability-cast/Post cast bad schema.bru @@ -1,7 +1,7 @@ meta { name: POST ability cast bad schema type: http - seq: 22 + seq: 23 } post { diff --git a/bruno/neon-sprawl-server/ability-cast/Post cast happy.bru b/bruno/neon-sprawl-server/ability-cast/Post cast happy.bru index 39ad219..49956c4 100644 --- a/bruno/neon-sprawl-server/ability-cast/Post cast happy.bru +++ b/bruno/neon-sprawl-server/ability-cast/Post cast happy.bru @@ -1,7 +1,7 @@ meta { name: POST ability cast happy type: http - seq: 21 + seq: 22 } post { @@ -15,7 +15,7 @@ body:json { "schemaVersion": 1, "slotIndex": 0, "abilityId": "prototype_pulse", - "targetId": null + "targetId": "prototype_target_alpha" } } diff --git a/bruno/neon-sprawl-server/ability-cast/Post cast loadout mismatch.bru b/bruno/neon-sprawl-server/ability-cast/Post cast loadout mismatch.bru index 81e0341..6537e0a 100644 --- a/bruno/neon-sprawl-server/ability-cast/Post cast loadout mismatch.bru +++ b/bruno/neon-sprawl-server/ability-cast/Post cast loadout mismatch.bru @@ -1,7 +1,7 @@ meta { name: POST ability cast loadout mismatch type: http - seq: 24 + seq: 25 } post { diff --git a/bruno/neon-sprawl-server/ability-cast/Post cast missing player.bru b/bruno/neon-sprawl-server/ability-cast/Post cast missing player.bru index a765d2a..34741f9 100644 --- a/bruno/neon-sprawl-server/ability-cast/Post cast missing player.bru +++ b/bruno/neon-sprawl-server/ability-cast/Post cast missing player.bru @@ -1,7 +1,7 @@ meta { name: POST ability cast missing player type: http - seq: 23 + seq: 24 } post { diff --git a/bruno/neon-sprawl-server/ability-cast/Post target select alpha before cast.bru b/bruno/neon-sprawl-server/ability-cast/Post target select alpha before cast.bru new file mode 100644 index 0000000..d906269 --- /dev/null +++ b/bruno/neon-sprawl-server/ability-cast/Post target select alpha before cast.bru @@ -0,0 +1,27 @@ +meta { + name: POST target select alpha (cast preset) + type: http + seq: 21 +} + +post { + url: {{baseUrl}}/game/players/dev-local-1/target/select + body: json + auth: none +} + +body:json { + { + "schemaVersion": 1, + "targetId": "prototype_target_alpha" + } +} + +tests { + test("status 200 lock applied", function () { + expect(res.getStatus()).to.equal(200); + const body = res.getBody(); + expect(body.selectionApplied).to.equal(true); + expect(body.targetState.lockedTargetId).to.equal("prototype_target_alpha"); + }); +} diff --git a/client/README.md b/client/README.md index b366707..ab3af26 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` only when **`request_cast`** returns **`true`** (HTTP POST successfully queued); **`ability_cast_denied`** is logged as `push_warning` from the client on deny responses (TODO(E9.M1): telemetry). +- **Ability cast hooks (NEO-27 + NEO-31 + NEO-28):** digit keys **`hotbar_slot_1` … `hotbar_slot_8`** (defaults **1**–**8**) issue a prototype cast via `scripts/ability_cast_client.gd`. **`ability_cast_requested`** is printed from `main.gd` only when **`request_cast`** returns **`true`** (HTTP POST successfully queued). **`ability_cast_denied`** is logged as `push_warning` from the client on deny responses, and **NEO-28** mirrors the same outcome on the **`CastFeedbackLabel`** HUD line (`ability_cast_denied: ` or `Cast: accepted`) via **`AbilityCastClient.cast_result_received`** (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,12 +127,12 @@ Full checklist: [`docs/manual-qa/NEO-26.md`](../docs/manual-qa/NEO-26.md). This story hydrates bindings only; cast execution and cooldown behavior stay in later E1.M4 / E5.M1 stories. -## Ability cast request (NEO-31) +## Ability cast request (NEO-31 + NEO-28) -- **`POST /game/players/{id}/ability-cast`** — JSON body `schemaVersion`, `slotIndex` (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. +- **`POST /game/players/{id}/ability-cast`** — JSON body `schemaVersion`, `slotIndex` (0–7), `abilityId`, **`targetId`** (must match server lock + prototype registry + range for `accepted: true`; see `server/README.md` and `AbilityCastApi.cs`). +- **Client:** `main.gd` reads `HotbarState.slots_snapshot()` and `TargetSelectionClient.cached_state()` (`lockedTargetId`) so cast payloads use **server-acknowledged** target context, not camera guessing. **Tab-lock a target** before expecting a successful cast at the default spawn. -Full checklist: [`docs/manual-qa/NEO-31.md`](../docs/manual-qa/NEO-31.md). +Full checklist: [`docs/manual-qa/NEO-31.md`](../docs/manual-qa/NEO-31.md) · cast deny/accept HUD: [`docs/manual-qa/NEO-28.md`](../docs/manual-qa/NEO-28.md). ### Manual check (NEO-24) diff --git a/client/scenes/main.tscn b/client/scenes/main.tscn index 8564075..ee4f6ad 100644 --- a/client/scenes/main.tscn +++ b/client/scenes/main.tscn @@ -1133,3 +1133,17 @@ theme_override_font_sizes/font_size = 15 text = "Target: — Validity: — Seq: —" + +[node name="CastFeedbackLabel" type="Label" parent="UICanvas" unique_id=9000005] +offset_left = 8.0 +offset_top = 318.0 +offset_right = 520.0 +offset_bottom = 372.0 +grow_horizontal = 0 +grow_vertical = 0 +theme_override_colors/font_color = Color(0.95, 0.88, 0.72, 1) +theme_override_colors/font_outline_color = Color(0.08, 0.08, 0.1, 1) +theme_override_constants/outline_size = 6 +theme_override_font_sizes/font_size = 15 +autowrap_mode = 3 +text = "Cast: —" diff --git a/client/scripts/ability_cast_client.gd b/client/scripts/ability_cast_client.gd index de21fba..6a6bc53 100644 --- a/client/scripts/ability_cast_client.gd +++ b/client/scripts/ability_cast_client.gd @@ -1,6 +1,9 @@ extends Node ## NEO-31: prototype HTTP client for `POST /game/players/{id}/ability-cast`. +## NEO-28: emits [signal cast_result_received] when the response body is JSON [AbilityCastResponse] v1. +signal cast_result_received(accepted: bool, reason_code: String) + @export var base_url: String = "http://127.0.0.1:5253" @export var dev_player_id: String = "dev-local-1" @export var injected_http: Node = null @@ -76,8 +79,9 @@ func _on_request_completed( return var data: Dictionary = parsed var accepted: bool = bool(data.get("accepted", false)) + var reason_variant: Variant = data.get("reasonCode", "") + var reason: String = reason_variant as String if reason_variant is String else "" + cast_result_received.emit(accepted, reason) if not accepted: - var reason_variant: Variant = data.get("reasonCode", "") - var reason: String = reason_variant as String if reason_variant is String else "" - # NEO-27 / NEO-31: product hook name for future telemetry (TODO(E9.M1)). + # NEO-27 / NEO-31 / NEO-28: product hook name for future telemetry (TODO(E9.M1)). push_warning("ability_cast_denied reasonCode=%s" % reason) diff --git a/client/scripts/main.gd b/client/scripts/main.gd index 3ee0362..d9e82b4 100644 --- a/client/scripts/main.gd +++ b/client/scripts/main.gd @@ -81,6 +81,7 @@ var _ability_cast_client: Node = null @onready var _move_reject_label: Label = $UICanvas/MoveRejectLabel @onready var _player_pos_label: Label = $UICanvas/PlayerPositionLabel @onready var _target_lock_label: Label = $UICanvas/TargetLockLabel +@onready var _cast_feedback_label: Label = $UICanvas/CastFeedbackLabel @onready var _target_client: Node = $TargetSelectionClient @onready var _interaction_client: Node = $InteractionRequestClient @onready var _floor: StaticBody3D = $World/NavigationRegion3D/Floor @@ -211,8 +212,9 @@ func _on_authoritative_position(world: Vector3, apply_as_snap: bool) -> void: ## [method _request_hotbar_cast_slot] only after [code]request_cast[/code] on ## [code]AbilityCastClient[/code] returns [code]true[/code] ## (POST successfully queued on [code]HTTPRequest[/code]). -## [code]ability_cast_denied[/code] is surfaced via [code]push_warning[/code] from -## `ability_cast_client.gd` on deny responses (TODO(E9.M1): telemetry schema + ingest). +## [code]ability_cast_denied[/code] is logged via [code]push_warning[/code] from +## `ability_cast_client.gd` on deny responses; NEO-28 also surfaces the same outcome on +## [member _cast_feedback_label] (TODO(E9.M1): telemetry schema + ingest). func _on_authoritative_ack_for_hud(world: Vector3) -> void: _last_ack_world = world _have_last_ack = true @@ -249,6 +251,10 @@ func _setup_hotbar_loadout_sync() -> void: if authority_player_id is String and not (authority_player_id as String).is_empty(): _hotbar_client.set("dev_player_id", authority_player_id) _ability_cast_client.set("dev_player_id", authority_player_id) + if _ability_cast_client.has_signal("cast_result_received"): + _ability_cast_client.connect( + "cast_result_received", Callable(self, "_on_cast_result_received") + ) if _hotbar_client.has_method("set_hotbar_state"): _hotbar_client.call("set_hotbar_state", _hotbar_state) if _hotbar_client.has_method("request_sync_from_server"): @@ -288,6 +294,19 @@ func _render_target_lock_label(world: Vector3) -> void: _target_lock_label.text = "\n".join(lines) +func _on_cast_result_received(accepted: bool, reason_code: String) -> void: + if not is_instance_valid(_cast_feedback_label): + return + if accepted: + _cast_feedback_label.text = "Cast: accepted" + return + var rc := reason_code.strip_edges() + if rc.is_empty(): + _cast_feedback_label.text = "Cast: denied (no reasonCode)" + else: + _cast_feedback_label.text = "ability_cast_denied: %s" % rc + + func _on_move_rejected(reason_code: String) -> void: _authority_force_snap_next = true # Rejected stream: server state may differ; next snap is forced. diff --git a/client/test/ability_cast_client_test.gd b/client/test/ability_cast_client_test.gd index 53dbdcc..1e6bb85 100644 --- a/client/test/ability_cast_client_test.gd +++ b/client/test/ability_cast_client_test.gd @@ -127,3 +127,35 @@ func test_request_cast_while_busy_is_ignored() -> void: var parsed: Variant = JSON.parse_string(transport.last_body) var body: Dictionary = parsed assert_that(int(body.get("slotIndex", -1))).is_equal(0) + + +func test_cast_result_received_emits_false_with_reason_on_denied() -> void: + var transport := MockHttpTransport.new() + transport.enqueue( + HTTPRequest.RESULT_SUCCESS, + 200, + '{"schemaVersion":1,"accepted":false,"reasonCode":"invalid_target"}' + ) + var c := _make_client(transport) + var got: Array = [] + c.connect("cast_result_received", func(accepted: bool, reason: String): got.append([accepted, reason])) + # Act + assert_that(bool(c.call("request_cast", 0, "prototype_pulse", "prototype_target_alpha"))).is_true() + # Assert + assert_that(got.size()).is_equal(1) + assert_that(bool(got[0][0])).is_false() + assert_that(str(got[0][1])).is_equal("invalid_target") + + +func test_cast_result_received_emits_true_with_empty_reason_on_accepted() -> void: + var transport := MockHttpTransport.new() + transport.enqueue(HTTPRequest.RESULT_SUCCESS, 200, '{"schemaVersion":1,"accepted":true}') + var c := _make_client(transport) + var got: Array = [] + c.connect("cast_result_received", func(accepted: bool, reason: String): got.append([accepted, reason])) + # Act + assert_that(bool(c.call("request_cast", 0, "prototype_pulse", null))).is_true() + # Assert + assert_that(got.size()).is_equal(1) + assert_that(bool(got[0][0])).is_true() + assert_that(str(got[0][1])).is_equal("") diff --git a/docs/decomposition/modules/E1_M4_AbilityInputScaffold.md b/docs/decomposition/modules/E1_M4_AbilityInputScaffold.md index aa9ba8a..a48356e 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). NEO-31 landed prototype **`POST /game/players/{id}/ability-cast`** plus client digit hotbar → cast payload wiring (target id from server-ack target state). `CooldownSnapshot` wiring is still pending. +- **Current state:** NEO-29 landed prototype `HotbarLoadout` v1 contract and hydration path (`GET`/`POST /game/players/{id}/hotbar-loadout`, fixed 8 slots, deny reason codes). NEO-31 landed prototype **`POST /game/players/{id}/ability-cast`** plus client digit hotbar → cast payload wiring (target id from server-ack target state). **NEO-28** extends that cast POST with **server lock + registry + range** validation (`invalid_target`, `out_of_range`) and a **HUD cast feedback** line on deny/accept. `CooldownSnapshot` wiring is still pending. - **Prototype persistence policy (NEO-29):** per-player loadout key; Postgres when `ConnectionStrings:NeonSprawl` exists, in-memory fallback otherwise. - **Prototype backlog:** [E1M4 prototype backlog](../../plans/E1M4-prototype-backlog.md) defines five vertical slices from hotbar loadout contract to cast telemetry hooks. - **Dependency expectation:** first implementation stories assume E1.M3 target selection flow exists and E5.M1 provides minimum cast accept/deny contract. @@ -57,7 +57,7 @@ Parent epic: [Epic 1 — Core Player Runtime](https://linear.app/neon-sprawl/pro |------|--------| | **E1M4-01** | [NEO-29](https://linear.app/neon-sprawl/issue/NEO-29/e1m4-01-hotbarloadout-v1-contract-baseline-persistence-path) — `HotbarLoadout` v1 contract + baseline persistence path | | **E1M4-02** | [NEO-31](https://linear.app/neon-sprawl/issue/NEO-31/e1m4-02-input-to-abilitycastrequest-path) — Input to `AbilityCastRequest` path | -| **E1M4-03** | TBD — Combat accept/deny integration + reason-code UX | +| **E1M4-03** | [NEO-28](https://linear.app/neon-sprawl/issue/NEO-28/e1m4-03-combat-acceptdeny-integration-reason-code-ux) — Combat accept/deny integration + reason-code UX | | **E1M4-04** | TBD — `CooldownSnapshot` sync + slot presentation | | **E1M4-05** | TBD — Slice 3 telemetry hooks for cast funnel | diff --git a/docs/decomposition/modules/E5_M1_CombatRulesEngine.md b/docs/decomposition/modules/E5_M1_CombatRulesEngine.md index 722e3d4..f24c03f 100644 --- a/docs/decomposition/modules/E5_M1_CombatRulesEngine.md +++ b/docs/decomposition/modules/E5_M1_CombatRulesEngine.md @@ -49,6 +49,10 @@ Core tab-target combat resolution: valid actions against a locked target, hit re See Epic 5 **Slice 1 — Combat rules MVP**: target lock, basic attacks, 4–6 abilities, cooldowns/resources; telemetry `encounter_start`, `ability_used`, `player_death`, `enemy_defeat`. +## Prototype notes (NEO-28) + +Epic 1 **Slice 3** exercises a minimal cast accept/deny surface on **`POST /game/players/{id}/ability-cast`** before the full engine exists: after hotbar/loadout validation ([E1.M4](E1_M4_AbilityInputScaffold.md)), the server requires **`targetId`** to match the persisted combat lock, resolve in the prototype target registry, and sit within horizontal lock radius against authoritative **`PositionState`**. Deny codes **`invalid_target`** and **`out_of_range`** reuse the same strings as E1.M3 **`TargetState.validity`** for vocabulary alignment. **`accepted: true`** still does not imply damage, cooldown commit, or a full **`CombatResolution`** payload — those remain E5.M1 scope. + ## Risks and telemetry - Sluggish or opaque tab-target: prototype readability gate; instrument time-in-combat and deny reasons. diff --git a/docs/decomposition/modules/documentation_and_implementation_alignment.md b/docs/decomposition/modules/documentation_and_implementation_alignment.md index 8e4396a..694948d 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), [Ability cast (NEO-31)](../../../server/README.md#ability-cast-neo-31) | +| E1.M4 | In Progress | **NEO-29 landed:** prototype `HotbarLoadout` v1 server contract (`GET`/`POST /game/players/{id}/hotbar-loadout`) with stable deny reason codes, per-player scope, and persistence policy matching NEO-8/NS-17 (Postgres when configured, in-memory fallback otherwise). Client boot hydration is wired via `hotbar_loadout_client.gd` + `hotbar_state.gd` from `main.gd`; manual QA checklist created. **NEO-31 landed:** prototype **`POST /game/players/{id}/ability-cast`**, digit-key cast wiring from `main.gd`, `ability_cast_client.gd`, and `hotbar_cast_slot_resolver.gd` (target id from `lockedTargetId` in cached target state); GdUnit covers resolver + HTTP client; manual QA [NEO-31](../../manual-qa/NEO-31.md). **NEO-28 landed:** cast POST validates **lock + registry + range** (`invalid_target`, `out_of_range`); **`AbilityCastClient.cast_result_received`** + **`CastFeedbackLabel`** HUD line; manual QA [NEO-28](../../manual-qa/NEO-28.md). **Still open:** `CooldownSnapshot` sync and telemetry hooks from later E1.M4 stories. | [NEO-29](../../plans/NEO-29-implementation-plan.md), [NEO-31](../../plans/NEO-31-implementation-plan.md), [NEO-28](../../plans/NEO-28-implementation-plan.md); [E1_M4_AbilityInputScaffold.md](E1_M4_AbilityInputScaffold.md); [E1M4 prototype backlog](../../plans/E1M4-prototype-backlog.md); `server/NeonSprawl.Server/Game/AbilityInput/`; [`client/scripts/hotbar_loadout_client.gd`](../../../client/scripts/hotbar_loadout_client.gd), [`client/scripts/hotbar_state.gd`](../../../client/scripts/hotbar_state.gd), [`client/scripts/ability_cast_client.gd`](../../../client/scripts/ability_cast_client.gd), [`client/scripts/hotbar_cast_slot_resolver.gd`](../../../client/scripts/hotbar_cast_slot_resolver.gd); [server README — Hotbar loadout](../../../server/README.md#hotbar-loadout-neo-29), [Ability cast (NEO-31)](../../../server/README.md#ability-cast-neo-31) | --- diff --git a/docs/manual-qa/NEO-28.md b/docs/manual-qa/NEO-28.md new file mode 100644 index 0000000..264b7f1 --- /dev/null +++ b/docs/manual-qa/NEO-28.md @@ -0,0 +1,24 @@ +# NEO-28 — Manual QA checklist + +| Field | Value | +|-------|-------| +| Key | NEO-28 | +| Title | E1M4-03: Combat accept/deny integration + reason-code UX | +| Linear | https://linear.app/neon-sprawl/issue/NEO-28/e1m4-03-combat-acceptdeny-integration-reason-code-ux | +| Plan | `docs/plans/NEO-28-implementation-plan.md` | + +## 1) Server + Bruno + +- [ ] Start server: `cd server/NeonSprawl.Server && dotnet run`. +- [ ] `curl -s http://localhost:5253/health` returns JSON with `status: ok`. +- [ ] Happy path (same order as folder **seq**): **`Post hotbar bind slot0 pulse`** → **`Post target select alpha before cast`** → **`Post cast happy`** — cast **`accepted: true`**. +- [ ] **`Post cast happy`** alone (no target select in session) — expect **`accepted: false`**, **`reasonCode: invalid_target`** (or run `curl` with loadout + no lock). +- [ ] After lock + loadout, **`curl`** cast with **`targetId: prototype_target_beta`** while lock is alpha — **`invalid_target`**. +- [ ] After lock + loadout, move player far from alpha (`POST …/move-stream` with a distant position), then cast with alpha — **`out_of_range`**. + +## 2) Client — HUD + +- [ ] With server running, open main scene; hydrate hotbar (NEO-29); bind slot **0** to **`prototype_pulse`** if needed. +- [ ] **Without** Tab lock, press **1**: left HUD **`CastFeedbackLabel`** shows **`ability_cast_denied: invalid_target`** (or equivalent); Output may still show **`ability_cast_denied`** warning. +- [ ] **Tab** to lock **alpha**; press **1**: **`CastFeedbackLabel`** shows **`Cast: accepted`**; Output still prints **`ability_cast_requested`** when POST queued. +- [ ] **WASD** until target HUD shows **`Validity: out_of_range`** (soft lock); press **1**: **`CastFeedbackLabel`** shows **`ability_cast_denied: out_of_range`**. diff --git a/docs/manual-qa/NEO-31.md b/docs/manual-qa/NEO-31.md index 245a5b0..640a718 100644 --- a/docs/manual-qa/NEO-31.md +++ b/docs/manual-qa/NEO-31.md @@ -12,10 +12,10 @@ - [ ] Start server: `cd server/NeonSprawl.Server && dotnet run`. - [ ] `curl -s http://localhost:5253/health` returns JSON with `status: ok`. -- [ ] Run Bruno collection (or at least): `ability-cast/Post hotbar bind slot0 pulse` (seq **20**) then `ability-cast/Post cast happy` (seq **21**) — expect cast `accepted: true`. +- [ ] Run Bruno collection (or at least): `ability-cast/Post hotbar bind slot0 pulse` (seq **20**), **`ability-cast/Post target select alpha before cast`** (seq **21**), then **`ability-cast/Post cast happy`** (seq **22**) — expect cast `accepted: true`. - [ ] `ability-cast/Post cast bad schema` → HTTP **400**. - [ ] `ability-cast/Post cast missing player` → HTTP **404**. -- [ ] **`ability-cast/Post cast loadout mismatch`** (seq **24**): run **`ability-cast/Post hotbar bind slot0 pulse`** in the same session first so slot 0 is `prototype_pulse`; then expect `accepted: false` and `reasonCode == "loadout_mismatch"`. +- [ ] **`ability-cast/Post cast loadout mismatch`** (seq **25**): run **`ability-cast/Post hotbar bind slot0 pulse`** in the same session first so slot 0 is `prototype_pulse`; then expect `accepted: false` and `reasonCode == "loadout_mismatch"`. ## 2) Client — bound slot diff --git a/docs/plans/NEO-28-implementation-plan.md b/docs/plans/NEO-28-implementation-plan.md new file mode 100644 index 0000000..343d8c8 --- /dev/null +++ b/docs/plans/NEO-28-implementation-plan.md @@ -0,0 +1,91 @@ +# NEO-28 — Implementation plan + +## Story reference + +| Field | Value | +|--------|--------| +| **Key** | NEO-28 | +| **Title** | E1M4-03: Combat accept/deny integration + reason-code UX | +| **Linear** | [NEO-28](https://linear.app/neon-sprawl/issue/NEO-28/e1m4-03-combat-acceptdeny-integration-reason-code-ux) | +| **Slug** | E1M4-03 | +| **Git branch** | `NEO-28-combat-accept-deny-reason-ux` | +| **Parent context** | [E1.M4 prototype backlog](E1M4-prototype-backlog.md) · [E1.M4 — AbilityInputScaffold](../decomposition/modules/E1_M4_AbilityInputScaffold.md) | +| **Related modules** | [E1.M3 — InteractionAndTargetingLayer](../decomposition/modules/E1_M3_InteractionAndTargetingLayer.md), [E5.M1 — CombatRulesEngine](../decomposition/modules/E5_M1_CombatRulesEngine.md) | + +## Kickoff clarifications + +- **UX surface:** Asked which prototype surface should render denied cast reasons. User chose a **HUD/debug label**: add a small on-screen cast feedback/reason line near the existing target/hotbar debug UI. +- **Validation scope:** Asked how strict the prototype server-side cast denial contract should be. Recommendation was accepted: use the **stricter lock contract**. `POST /game/players/{id}/ability-cast` should validate the request target against the server-acknowledged lock, prototype target registry, and current server position; return `invalid_target` for missing/unknown/mismatch and `out_of_range` when the locked target is too far. +- **Linear blocker:** Linear showed NEO-28 blocked by NEO-31. User confirmed NEO-31 is merged and Done; kickoff removed the `blockedBy: NEO-31` relation from NEO-28 and left NEO-28 **In Progress**. + +## Goal, scope, and out-of-scope + +**Goal:** Consume authoritative cast accept/deny responses in the client cast path and make rejected casts visible through stable reason-code UX. + +**In scope** + +- Extend the current prototype ability-cast response contract with target-related denial reasons needed by E1.M4/E5.M1 integration. +- Server-side cast validation for target presence, registry membership, server lock match, and range against the authoritative position snapshot. +- Client-side handling for accepted and denied cast responses, including HUD/debug feedback for reason codes. +- Documentation of the denial contract and reason list so E1.M4 and E5.M1 do not drift. + +**Out of scope** + +- Rich combat VFX, animation, final combat log styling, damage/healing resolution, cooldown presentation, and final E5.M1 `CombatResolution` semantics. +- Production telemetry ingestion; keep existing `TODO(E9.M1)` hook notes. + +## Acceptance criteria checklist + +- [x] Denied casts render a player-visible reason path for at least `invalid_target` and `out_of_range`. +- [x] Successful casts transition to post-request state without conflicting local rollback behavior. +- [x] Denial contract and reason list are documented in plan/module docs to prevent drift. + +## Technical approach + +1. **Server contract** — Extend `AbilityCastApi` from NEO-31 rather than adding a parallel endpoint. Keep the v1 JSON shape (`schemaVersion`, `accepted`, optional `reasonCode`) and add stable target denial constants: `invalid_target` and `out_of_range`. + +2. **Server validation order** — Preserve existing request/loadout/ability validation first. After the loadout match succeeds, validate target authority: non-empty request `targetId`, known `PrototypeTargetRegistry` id, request target equals `IPlayerTargetLockStore` current lock, and target is within horizontal range of the authoritative `IPositionStateStore` snapshot. Return a JSON deny response with `accepted: false` for target denials; reserve transport status codes for malformed/missing-player cases already established by NEO-31. + +3. **Client response handling** — Make `AbilityCastClient` emit a signal for completed cast responses so `main.gd` can update a HUD/debug cast feedback label. Denies should display `ability_cast_denied: ` or equivalent player-visible text; accepts should clear or replace stale deny text with a lightweight accepted/post-request message. + +4. **Optimistic state reconciliation** — Keep the current prototype behavior conservative: `ability_cast_requested` only logs once the POST starts, and no local cooldown/rollback state is introduced in this story. On deny, the feedback label becomes the reconciliation surface; on accept, the client avoids any rollback behavior that would conflict with later cooldown work in NEO-32. + +5. **Documentation** — Update E1.M4 and E5.M1 docs with the prototype accept/deny contract, including the reason list and which reasons belong to hotbar/loadout validation versus combat target validation. + +## Files to add + +| Path | Rationale | +|------|-----------| +| `docs/manual-qa/NEO-28.md` | Manual QA checklist for accepted cast, `invalid_target`, `out_of_range`, and HUD/debug feedback visibility. | + +## Files to modify + +| Path | Rationale | +|------|-----------| +| `server/NeonSprawl.Server/Game/AbilityInput/AbilityCastApi.cs` | Add target authority validation, reason constants, and `IPlayerTargetLockStore` / `PrototypeTargetRegistry` use. | +| `server/NeonSprawl.Server/Game/AbilityInput/AbilityCastDtos.cs` | Update summary/comments if needed to describe accept/deny v1 target semantics. | +| `server/NeonSprawl.Server.Tests/Game/AbilityInput/AbilityCastApiTests.cs` | Cover `invalid_target`, `out_of_range`, and strict lock-match behavior. | +| `client/scripts/ability_cast_client.gd` | Surface parsed accept/deny responses via signal instead of only `push_warning`. | +| `client/scripts/main.gd` | Add/update HUD/debug cast feedback label wiring and reconcile accepted/denied response display. | +| `client/README.md` | Document cast deny feedback behavior for manual prototype use. | +| `server/README.md` | Document the extended ability-cast denial contract and reason codes. | +| `docs/decomposition/modules/E1_M4_AbilityInputScaffold.md` | Update implementation snapshot, Linear row, and denial contract notes for E1M4-03. | +| `docs/decomposition/modules/E5_M1_CombatRulesEngine.md` | Document the prototype accept/deny handoff and deferred final `CombatResolution` scope. | +| `bruno/neon-sprawl-server/ability-cast/Post cast happy.bru` | Ensure existing happy path still asserts accepted response after target validation setup. | +| `bruno/neon-sprawl-server/ability-cast/Post cast loadout mismatch.bru` | Keep non-target denial regression aligned with response contract. | + +## Tests + +| Suite | What it covers | +|--------|----------------| +| `server/NeonSprawl.Server.Tests/Game/AbilityInput/AbilityCastApiTests.cs` | Accepted cast with locked in-range target; `invalid_target` for missing/unknown/mismatched target; `out_of_range` when authoritative position is outside prototype lock radius; existing loadout denies remain stable. | +| `client/test/ability_cast_client_test.gd` | Response parsing and signal emission for accepted and denied JSON bodies, including `invalid_target` / `out_of_range`. | +| `client/test/hotbar_cast_slot_resolver_test.gd` | No required change expected unless target payload rules change; keep as regression for request formation from server-ack target state. | +| `docs/manual-qa/NEO-28.md` | Human verification of HUD/debug feedback, accepted cast display, and denial reason visibility. | +| Bruno `ability-cast/*.bru` | Add or adjust requests/tests if needed for target validation preconditions and stable response assertions. | + +## Open questions / risks + +- **Server lock setup in tests / Bruno:** Existing happy-path requests may need a target-select setup step before cast because the stricter contract requires a server lock. +- **Out-of-range reproduction:** Current target selection rejects out-of-range locks, so `out_of_range` cast tests may need to lock while in range, then move the authoritative position out of range before casting. +- **UI scene edit size:** Adding a HUD/debug label may touch `client/main.tscn`; keep it minimal and aligned with existing debug labels. diff --git a/server/NeonSprawl.Server.Tests/Game/AbilityInput/AbilityCastApiTests.cs b/server/NeonSprawl.Server.Tests/Game/AbilityInput/AbilityCastApiTests.cs index 812d40b..156a4f1 100644 --- a/server/NeonSprawl.Server.Tests/Game/AbilityInput/AbilityCastApiTests.cs +++ b/server/NeonSprawl.Server.Tests/Game/AbilityInput/AbilityCastApiTests.cs @@ -2,6 +2,8 @@ using System.Net; using System.Net.Http.Json; using System.Text; using NeonSprawl.Server.Game.AbilityInput; +using NeonSprawl.Server.Game.PositionState; +using NeonSprawl.Server.Game.Targeting; using Xunit; namespace NeonSprawl.Server.Tests.Game.AbilityInput; @@ -20,13 +22,41 @@ public sealed class AbilityCastApiTests post.EnsureSuccessStatusCode(); } + private static async Task LockPrototypeTargetAlphaAsync(HttpClient client) + { + var response = await client.PostAsJsonAsync( + "/game/players/dev-local-1/target/select", + new TargetSelectRequest + { + SchemaVersion = TargetSelectRequest.CurrentSchemaVersion, + TargetId = PrototypeTargetRegistry.PrototypeTargetAlphaId, + }); + response.EnsureSuccessStatusCode(); + var body = await response.Content.ReadFromJsonAsync(); + Assert.NotNull(body); + Assert.True(body!.SelectionApplied); + } + + private static async Task TeleportDevPlayerAsync(HttpClient client, double x, double y, double z) + { + var response = await client.PostAsJsonAsync( + "/game/players/dev-local-1/move-stream", + new MoveStreamRequest + { + SchemaVersion = MoveStreamRequest.CurrentSchemaVersion, + Targets = [new PositionVector { X = x, Y = y, Z = z }], + }); + response.EnsureSuccessStatusCode(); + } + [Fact] - public async Task PostAbilityCast_ShouldAccept_WhenLoadoutMatches() + public async Task PostAbilityCast_ShouldAccept_WhenLoadoutMatchesAndLockInRange() { // Arrange await using var factory = new InMemoryWebApplicationFactory(); var client = factory.CreateClient(); await BindSlot0PulseAsync(client); + await LockPrototypeTargetAlphaAsync(client); // Act var response = await client.PostAsJsonAsync( @@ -36,7 +66,7 @@ public sealed class AbilityCastApiTests SchemaVersion = AbilityCastRequest.CurrentSchemaVersion, SlotIndex = 0, AbilityId = PrototypeAbilityRegistry.PrototypePulse, - TargetId = null, + TargetId = PrototypeTargetRegistry.PrototypeTargetAlphaId, }); var body = await response.Content.ReadFromJsonAsync(); @@ -54,6 +84,7 @@ public sealed class AbilityCastApiTests await using var factory = new InMemoryWebApplicationFactory(); var client = factory.CreateClient(); await BindSlot0PulseAsync(client); + await LockPrototypeTargetAlphaAsync(client); // Act var response = await client.PostAsJsonAsync( @@ -73,6 +104,146 @@ public sealed class AbilityCastApiTests Assert.True(body!.Accepted); } + [Fact] + public async Task PostAbilityCast_ShouldDenyInvalidTarget_WhenTargetIdMissing() + { + // Arrange + await using var factory = new InMemoryWebApplicationFactory(); + var client = factory.CreateClient(); + await BindSlot0PulseAsync(client); + await LockPrototypeTargetAlphaAsync(client); + + // Act + var response = await client.PostAsJsonAsync( + "/game/players/dev-local-1/ability-cast", + new AbilityCastRequest + { + SchemaVersion = AbilityCastRequest.CurrentSchemaVersion, + SlotIndex = 0, + AbilityId = PrototypeAbilityRegistry.PrototypePulse, + TargetId = null, + }); + var body = await response.Content.ReadFromJsonAsync(); + + // Assert + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + Assert.NotNull(body); + Assert.False(body!.Accepted); + Assert.Equal(AbilityCastApi.ReasonInvalidTarget, body.ReasonCode); + } + + [Fact] + public async Task PostAbilityCast_ShouldDenyInvalidTarget_WhenUnknownTargetId() + { + // Arrange + await using var factory = new InMemoryWebApplicationFactory(); + var client = factory.CreateClient(); + await BindSlot0PulseAsync(client); + await LockPrototypeTargetAlphaAsync(client); + + // Act + var response = await client.PostAsJsonAsync( + "/game/players/dev-local-1/ability-cast", + new AbilityCastRequest + { + SchemaVersion = AbilityCastRequest.CurrentSchemaVersion, + SlotIndex = 0, + AbilityId = PrototypeAbilityRegistry.PrototypePulse, + TargetId = "not_a_registered_combat_target", + }); + var body = await response.Content.ReadFromJsonAsync(); + + // Assert + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + Assert.NotNull(body); + Assert.False(body!.Accepted); + Assert.Equal(AbilityCastApi.ReasonInvalidTarget, body.ReasonCode); + } + + [Fact] + public async Task PostAbilityCast_ShouldDenyInvalidTarget_WhenTargetDoesNotMatchServerLock() + { + // Arrange + await using var factory = new InMemoryWebApplicationFactory(); + var client = factory.CreateClient(); + await BindSlot0PulseAsync(client); + await LockPrototypeTargetAlphaAsync(client); + + // Act — server lock is alpha; client sends beta id + var response = await client.PostAsJsonAsync( + "/game/players/dev-local-1/ability-cast", + new AbilityCastRequest + { + SchemaVersion = AbilityCastRequest.CurrentSchemaVersion, + SlotIndex = 0, + AbilityId = PrototypeAbilityRegistry.PrototypePulse, + TargetId = PrototypeTargetRegistry.PrototypeTargetBetaId, + }); + var body = await response.Content.ReadFromJsonAsync(); + + // Assert + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + Assert.NotNull(body); + Assert.False(body!.Accepted); + Assert.Equal(AbilityCastApi.ReasonInvalidTarget, body.ReasonCode); + } + + [Fact] + public async Task PostAbilityCast_ShouldDenyInvalidTarget_WhenNoServerLock() + { + // Arrange + await using var factory = new InMemoryWebApplicationFactory(); + var client = factory.CreateClient(); + await BindSlot0PulseAsync(client); + + // Act + var response = await client.PostAsJsonAsync( + "/game/players/dev-local-1/ability-cast", + new AbilityCastRequest + { + SchemaVersion = AbilityCastRequest.CurrentSchemaVersion, + SlotIndex = 0, + AbilityId = PrototypeAbilityRegistry.PrototypePulse, + TargetId = PrototypeTargetRegistry.PrototypeTargetAlphaId, + }); + var body = await response.Content.ReadFromJsonAsync(); + + // Assert + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + Assert.NotNull(body); + Assert.False(body!.Accepted); + Assert.Equal(AbilityCastApi.ReasonInvalidTarget, body.ReasonCode); + } + + [Fact] + public async Task PostAbilityCast_ShouldDenyOutOfRange_WhenAuthoritativePositionTooFarFromLock() + { + // Arrange + await using var factory = new InMemoryWebApplicationFactory(); + var client = factory.CreateClient(); + await BindSlot0PulseAsync(client); + await LockPrototypeTargetAlphaAsync(client); + await TeleportDevPlayerAsync(client, 50.0, 0.9, 50.0); + + // Act + var response = await client.PostAsJsonAsync( + "/game/players/dev-local-1/ability-cast", + new AbilityCastRequest + { + SchemaVersion = AbilityCastRequest.CurrentSchemaVersion, + SlotIndex = 0, + AbilityId = PrototypeAbilityRegistry.PrototypePulse, + TargetId = PrototypeTargetRegistry.PrototypeTargetAlphaId, + }); + var body = await response.Content.ReadFromJsonAsync(); + + // Assert + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + Assert.NotNull(body); + Assert.False(body!.Accepted); + Assert.Equal(AbilityCastApi.ReasonOutOfRange, body.ReasonCode); + } + [Fact] public async Task PostAbilityCast_ShouldDenySlotUnbound_WhenSlotEmpty() { diff --git a/server/NeonSprawl.Server/Game/AbilityInput/AbilityCastApi.cs b/server/NeonSprawl.Server/Game/AbilityInput/AbilityCastApi.cs index ba3cf6c..b136565 100644 --- a/server/NeonSprawl.Server/Game/AbilityInput/AbilityCastApi.cs +++ b/server/NeonSprawl.Server/Game/AbilityInput/AbilityCastApi.cs @@ -1,8 +1,10 @@ using NeonSprawl.Server.Game.PositionState; +using NeonSprawl.Server.Game.Targeting; +using NeonSprawl.Server.Game.World; namespace NeonSprawl.Server.Game.AbilityInput; -/// Maps prototype ability cast POST (NEO-31). +/// Maps prototype ability cast POST (NEO-31 + NEO-28 target authority). public static class AbilityCastApi { public const string ReasonSlotOutOfBounds = "slot_out_of_bounds"; @@ -10,18 +12,29 @@ public static class AbilityCastApi public const string ReasonLoadoutMismatch = "loadout_mismatch"; public const string ReasonUnknownAbility = "unknown_ability"; + /// Request target missing, unknown to the prototype registry, or not the server lock (NEO-28). + public const string ReasonInvalidTarget = TargetValidity.InvalidTarget; + + /// Locked prototype target is outside horizontal reach of authoritative position (NEO-28). + public const string ReasonOutOfRange = TargetValidity.OutOfRange; + public static WebApplication MapAbilityCastApi(this WebApplication app) { app.MapPost( "/game/players/{id}/ability-cast", - (string id, AbilityCastRequest? body, IPositionStateStore positions, IPlayerHotbarLoadoutStore store) => + ( + string id, + AbilityCastRequest? body, + IPositionStateStore positions, + IPlayerHotbarLoadoutStore store, + IPlayerTargetLockStore locks) => { if (body is null || body.SchemaVersion != AbilityCastRequest.CurrentSchemaVersion) { return Results.BadRequest(); } - if (!positions.TryGetPosition(id, out _)) + if (!positions.TryGetPosition(id, out var snap)) { return Results.NotFound(); } @@ -73,7 +86,33 @@ public static class AbilityCastApi }); } - // TargetId echoed for NEO-28 / E5.M1; server does not validate against lock store in NEO-31. + if (string.IsNullOrWhiteSpace(body.TargetId)) + { + return Results.Json( + new AbilityCastResponse { Accepted = false, ReasonCode = ReasonInvalidTarget }); + } + + var lookupKey = body.TargetId.Trim().ToLowerInvariant(); + if (!PrototypeTargetRegistry.TryGet(lookupKey, out var entry)) + { + return Results.Json( + new AbilityCastResponse { Accepted = false, ReasonCode = ReasonInvalidTarget }); + } + + var (lockIdLowercase, _) = locks.GetLockState(id); + if (string.IsNullOrEmpty(lockIdLowercase) || + !string.Equals(lookupKey, lockIdLowercase, StringComparison.Ordinal)) + { + return Results.Json( + new AbilityCastResponse { Accepted = false, ReasonCode = ReasonInvalidTarget }); + } + + if (!HorizontalReach.IsWithinHorizontalRadius(snap.X, snap.Z, entry.X, entry.Z, entry.LockRadius)) + { + return Results.Json( + new AbilityCastResponse { Accepted = false, ReasonCode = ReasonOutOfRange }); + } + return Results.Json(new AbilityCastResponse { Accepted = true }); }); diff --git a/server/NeonSprawl.Server/Game/AbilityInput/AbilityCastDtos.cs b/server/NeonSprawl.Server/Game/AbilityInput/AbilityCastDtos.cs index ff08086..e34d56c 100644 --- a/server/NeonSprawl.Server/Game/AbilityInput/AbilityCastDtos.cs +++ b/server/NeonSprawl.Server/Game/AbilityInput/AbilityCastDtos.cs @@ -16,12 +16,15 @@ public sealed class AbilityCastRequest [JsonPropertyName("abilityId")] public string? AbilityId { get; init; } - /// Optional; mirrors lockedTargetId from target state v1. + /// + /// Must match the server's current combat lock (same string as GET …/target lockedTargetId) for + /// accepted: true; NEO-28 adds invalid_target / out_of_range denies when it does not. + /// [JsonPropertyName("targetId")] public string? TargetId { get; init; } } -/// POST response for cast submit (prototype accept/deny only; NEO-31). +/// POST response for cast submit (prototype accept/deny; NEO-31 + NEO-28 target rules). public sealed class AbilityCastResponse { public const int CurrentSchemaVersion = 1; diff --git a/server/README.md b/server/README.md index bd5a3dd..9418d60 100644 --- a/server/README.md +++ b/server/README.md @@ -225,9 +225,9 @@ Current prototype allowlist: `prototype_pulse`, `prototype_guard`, `prototype_da ## Ability cast (NEO-31) -Prototype **cast intent** (no combat resolution yet — see E1.M4 / E5.M1 follow-on stories): +Prototype **cast intent** (no full combat resolution — see E5.M1). **NEO-28** adds server-side target lock + registry + range gates and documents `invalid_target` / `out_of_range` denies; the Godot client surfaces accept/deny on **`CastFeedbackLabel`**. -- **`POST /game/players/{id}/ability-cast`** with `AbilityCastRequest` v1 (`schemaVersion`, `slotIndex` `0..7`, `abilityId`, optional nullable `targetId` mirroring client `lockedTargetId`) validates the player exists, the slot is bound in persisted hotbar loadout, and `abilityId` matches that binding and the prototype allowlist. Returns **`AbilityCastResponse` v1** JSON (`schemaVersion`, `accepted`, optional `reasonCode`). +- **`POST /game/players/{id}/ability-cast`** with `AbilityCastRequest` v1 (`schemaVersion`, `slotIndex` `0..7`, `abilityId`, `targetId`) validates the player exists, the slot is bound in persisted hotbar loadout, and `abilityId` matches that binding and the prototype allowlist. **NEO-28:** `targetId` must be **non-empty**, exist in **`PrototypeTargetRegistry`**, **match** the server's current combat lock (`IPlayerTargetLockStore`), and the lock must be **within horizontal reach** of the authoritative position snapshot (same XZ radius rule as targeting). Returns **`AbilityCastResponse` v1** JSON (`schemaVersion`, `accepted`, optional `reasonCode`). **HTTP status:** @@ -245,6 +245,8 @@ Prototype **cast intent** (no combat resolution yet — see E1.M4 / E5.M1 follow | `slot_unbound` | No ability bound on that slot in stored loadout. | | `loadout_mismatch` | `abilityId` does not match the ability bound on that slot. | | `unknown_ability` | `abilityId` failed registry normalization (empty/garbage/off-list). | +| `invalid_target` | `targetId` missing/empty, unknown prototype id, or not equal to the server's locked target id (NEO-28). | +| `out_of_range` | Locked target is outside horizontal reach of authoritative player position vs. prototype anchor (NEO-28). | Implementation: `server/NeonSprawl.Server/Game/AbilityInput/AbilityCastApi.cs`, `AbilityCastDtos.cs`.