NEO-31: request_cast bool for telemetry hook; document cast API in server README

pull/56/head
VinPropane 2026-04-27 20:44:40 -04:00
parent e88e59f3a6
commit cc1c6f711e
8 changed files with 65 additions and 22 deletions

View File

@ -112,7 +112,7 @@ Full checklist: [`docs/manual-qa/NEO-25.md`](../manual-qa/NEO-25.md).
- **`TargetSelectionClient`** emits **`selection_event(event: Dictionary)`** only when the server-acknowledged **`lockedTargetId`** changes (not on **`validity`-only** updates; those stay on **`target_state_changed`**).
- Payload keys: **`previous`** ([String] or `null`), **`next`** (same), **`cause`** (`tab` \| `clear` \| `server_correction`), **`sequence`** (int from the new state). **`POST …/target/select`** from **`request_select_target_id`** (including the path used by Tab) uses cause **`tab`**; **`request_clear_target`** uses **`clear`**; every **`GET …/target`** completion uses **`server_correction`** (including boot sync).
- **Slice 3 telemetry hook (NEO-27):** this selection transition maps to product event name **`target_changed`**; with **`log_selection_events`** enabled, Godot Output prints the `target_changed` token for manual verification. **TODO(E9.M1):** replace dev log with cataloged telemetry emit.
- **Ability cast hooks (NEO-27 + NEO-31):** digit keys **`hotbar_slot_1``hotbar_slot_8`** (defaults **1****8**) issue a prototype cast via `scripts/ability_cast_client.gd`. **`ability_cast_requested`** is printed from `main.gd` immediately before the POST; **`ability_cast_denied`** is logged as `push_warning` from the client on deny responses (TODO(E9.M1): telemetry).
- **Ability cast hooks (NEO-27 + NEO-31):** digit keys **`hotbar_slot_1``hotbar_slot_8`** (defaults **1****8**) issue a prototype cast via `scripts/ability_cast_client.gd`. **`ability_cast_requested`** is printed from `main.gd` only when **`request_cast`** returns **`true`** (HTTP POST successfully queued); **`ability_cast_denied`** is logged as `push_warning` from the client on deny responses (TODO(E9.M1): telemetry).
- **E1.M4+:** connect to **`$TargetSelectionClient.selection_event`** (or your scenes path to that node) without reshaping the payload.
Full checklist: [`docs/manual-qa/NEO-26.md`](../docs/manual-qa/NEO-26.md).

View File

@ -22,9 +22,12 @@ func _ready() -> void:
## [param target_id] server [code]lockedTargetId[/code] string, or [code]null[/code] if none.
func request_cast(slot_index: int, ability_id: String, target_id: Variant) -> void:
## Returns [code]true[/code] when the HTTP POST was **queued** ([method HTTPRequest.request] returned [code]OK[/code]);
## [code]false[/code] if already [member _busy], request failed to start, or client invalid — use for
## [code]ability_cast_requested[/code] dev / future telemetry so hooks match actual submit attempts.
func request_cast(slot_index: int, ability_id: String, target_id: Variant) -> bool:
if _busy:
return
return false
var tid: Variant = null
if target_id is String:
var s: String = target_id as String
@ -36,13 +39,14 @@ func request_cast(slot_index: int, ability_id: String, target_id: Variant) -> vo
"abilityId": ability_id,
"targetId": tid,
}
_busy = true
var url := "%s/game/players/%s/ability-cast" % [_base_root(), _player_path_segment()]
var headers := PackedStringArray(["Content-Type: application/json"])
var err: Error = _http.request(url, headers, HTTPClient.METHOD_POST, JSON.stringify(payload))
if err != OK:
push_warning("AbilityCastClient: POST failed to start (%s)" % err)
_busy = false
return false
_busy = true
return true
func _base_root() -> String:

View File

@ -207,7 +207,8 @@ func _on_authoritative_position(world: Vector3, apply_as_snap: bool) -> void:
## (that is `authoritative_position_received`'s job on boot + move rejection).
##
## NEO-27 / NEO-31: `ability_cast_requested` is emitted as a dev [code]print[/code] from
## [method _request_hotbar_cast_slot] immediately before the cast HTTP POST starts.
## [method _request_hotbar_cast_slot] only after [code]request_cast[/code] on
## [code]AbilityCastClient[/code] returns [code]true[/code] (POST successfully queued on [code]HTTPRequest[/code]).
## [code]ability_cast_denied[/code] is surfaced via [code]push_warning[/code] from
## `ability_cast_client.gd` on deny responses (TODO(E9.M1): telemetry schema + ingest).
func _on_authoritative_ack_for_hud(world: Vector3) -> void:
@ -367,15 +368,17 @@ func _request_hotbar_cast_slot(slot_index: int) -> void:
var ability_id: String = outcome[HotbarCastSlotResolver.KEY_ABILITY_ID] as String
var resolved_slot: int = int(outcome.get(HotbarCastSlotResolver.KEY_SLOT_INDEX, slot_index))
var target_id: Variant = outcome.get(HotbarCastSlotResolver.KEY_TARGET_ID, null)
var target_label: String = "null"
if target_id != null:
target_label = str(target_id)
print(
"ability_cast_requested slot=%d ability_id=%s targetId=%s"
% [resolved_slot, ability_id, target_label]
)
var started: bool = false
if is_instance_valid(_ability_cast_client) and _ability_cast_client.has_method("request_cast"):
_ability_cast_client.call("request_cast", resolved_slot, ability_id, target_id)
started = bool(_ability_cast_client.call("request_cast", resolved_slot, ability_id, target_id))
if started:
var target_label: String = "null"
if target_id != null:
target_label = str(target_id)
print(
"ability_cast_requested slot=%d ability_id=%s targetId=%s"
% [resolved_slot, ability_id, target_label]
)
func _forward_interact_post(method: String) -> void:

View File

@ -77,7 +77,8 @@ func test_request_cast_posts_expected_payload_with_null_target() -> void:
var transport := MockHttpTransport.new()
transport.enqueue(HTTPRequest.RESULT_SUCCESS, 200, '{"schemaVersion":1,"accepted":true}')
var c := _make_client(transport)
c.call("request_cast", 2, "prototype_guard", null)
var started: bool = bool(c.call("request_cast", 2, "prototype_guard", null))
assert_that(started).is_true()
assert_that(transport.last_url).contains("/ability-cast")
assert_that(transport.last_method).is_equal(HTTPClient.METHOD_POST)
var parsed: Variant = JSON.parse_string(transport.last_body)
@ -93,17 +94,27 @@ func test_request_cast_posts_target_id_string_when_set() -> void:
var transport := MockHttpTransport.new()
transport.enqueue(HTTPRequest.RESULT_SUCCESS, 200, '{"schemaVersion":1,"accepted":true}')
var c := _make_client(transport)
c.call("request_cast", 0, "prototype_pulse", "prototype_target_alpha")
assert_that(bool(c.call("request_cast", 0, "prototype_pulse", "prototype_target_alpha"))).is_true()
var parsed: Variant = JSON.parse_string(transport.last_body)
var body: Dictionary = parsed
assert_that(body.get("targetId")).is_equal("prototype_target_alpha")
func test_request_cast_returns_false_when_http_request_fails_to_start() -> void:
# Arrange: empty queue → transport.request returns ERR_UNAVAILABLE (same as failed start).
var transport := MockHttpTransport.new()
var c := _make_client(transport)
# Act
var started: bool = bool(c.call("request_cast", 0, "prototype_pulse", null))
# Assert
assert_that(started).is_false()
func test_request_cast_while_busy_is_ignored() -> void:
var transport := HoldFirstThenAutoTransport.new()
var c := _make_client(transport)
c.call("request_cast", 0, "prototype_pulse", null)
c.call("request_cast", 1, "prototype_guard", null)
assert_that(bool(c.call("request_cast", 0, "prototype_pulse", null))).is_true()
assert_that(bool(c.call("request_cast", 1, "prototype_guard", null))).is_false()
await get_tree().process_frame
await get_tree().process_frame
assert_that(transport.request_count).is_equal(1)

View File

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

View File

@ -43,7 +43,7 @@
2. **Server** — New `POST /game/players/{id}/ability-cast` (name fixed in implementation; document in plan **Decisions** if adjusted) mapped in `Program.cs`, implemented beside existing `Game/AbilityInput` types. Validate: player exists (same **player id** gate as hotbar APIs via `IPositionStateStore` or equivalent), slot in range, **non-empty ability** on that slot from **`IPlayerHotbarLoadoutStore`** matches request `abilityId`. Do **not** implement full combat engine; optionally shallow-check `targetId` against current lock store if low-cost, otherwise accept client-provided target for v1 and let NEO-28 tighten.
3. **Client** — New `AbilityCastClient` (or equivalent) mirroring `HotbarLoadoutClient`: `base_url`, `dev_player_id`, injectable HTTP, `request_cast(slot_index, ability_id, target_id_variant)` that POSTs JSON once. **`main.gd`** (or dedicated small coordinator): on new InputMap actions **`hotbar_slot_1``hotbar_slot_8`** (digits 18), resolve slot index, read **`HotbarState.slots_snapshot()`**; if empty, warn and return; else read **`TargetSelectionClient.cached_state()`** for `lockedTargetId`, build payload, call cast client. Replace NEO-27 **reserve-only** comment block with a real **`ability_cast_requested`** hook site (still `TODO(E9.M1)` for ingest) immediately **before** successful POST start, per `main.gd` notes.
3. **Client** — New `AbilityCastClient` (or equivalent) mirroring `HotbarLoadoutClient`: `base_url`, `dev_player_id`, injectable HTTP, **`request_cast(...) -> bool`** (true when `HTTPRequest.request` queued the POST). **`main.gd`**: digit keys **`hotbar_slot_1``hotbar_slot_8`**, **`HotbarCastSlotResolver`**, then cast client; **`ability_cast_requested`** dev print only when **`request_cast`** returns **`true`** (still `TODO(E9.M1)` for ingest).
4. **Tests****Server:** xUnit suite posting valid/invalid bodies (unknown player, empty slot, ability mismatch). **Client:** GdUnit suite with mock HTTP verifying URL/method/body for bound vs unbound paths and correct target id propagation from a stub target state provider.

View File

@ -29,9 +29,9 @@ NEO-31 adds the prototype `POST /game/players/{id}/ability-cast` route, client d
1. ~~Update `docs/decomposition/modules/documentation_and_implementation_alignment.md` for NEO-31. The module page now says NEO-31 landed, but the tracking table still says "Still open: cast request path"; this will mislead the next implementation/review pass.~~ **Done.**
2. Consider making `AbilityCastClient.request_cast()` return whether a POST actually started, then log `ability_cast_requested` only on success. Today `client/scripts/main.gd` prints `ability_cast_requested` before calling the client, while `client/scripts/ability_cast_client.gd` silently returns when `_busy` is true or when the request fails to start. That can over-count cast requests in the future telemetry hook and conflicts with the plan wording that the hook sits immediately before a successful POST start.
2. ~~Consider making `AbilityCastClient.request_cast()` return whether a POST actually started, then log `ability_cast_requested` only on success. Today `client/scripts/main.gd` prints `ability_cast_requested` before calling the client, while `client/scripts/ability_cast_client.gd` silently returns when `_busy` is true or when the request fails to start. That can over-count cast requests in the future telemetry hook and conflicts with the plan wording that the hook sits immediately before a successful POST start.~~ **Done.** `request_cast``bool`; `_busy` set only after `HTTPRequest.request` returns `OK`; `main.gd` prints only when `true`.
3. Consider adding a short `server/README.md` section for `POST /game/players/{id}/ability-cast`, matching the existing server-side endpoint documentation pattern used for hotbar loadout and targeting. The client README and Bruno collection cover manual usage, but the server API index currently has no NEO-31 entry.
3. ~~Consider adding a short `server/README.md` section for `POST /game/players/{id}/ability-cast`, matching the existing server-side endpoint documentation pattern used for hotbar loadout and targeting. The client README and Bruno collection cover manual usage, but the server API index currently has no NEO-31 entry.~~ **Done.** See [server README — Ability cast (NEO-31)](../../server/README.md#ability-cast-neo-31).
## Nits

View File

@ -223,6 +223,31 @@ Prototype server-owned hotbar bindings are available at:
Current prototype allowlist: `prototype_pulse`, `prototype_guard`, `prototype_dash`, `prototype_burst`.
## Ability cast (NEO-31)
Prototype **cast intent** (no combat resolution yet — see E1.M4 / E5.M1 follow-on stories):
- **`POST /game/players/{id}/ability-cast`** with `AbilityCastRequest` v1 (`schemaVersion`, `slotIndex` `0..7`, `abilityId`, optional nullable `targetId` mirroring client `lockedTargetId`) validates the player exists, the slot is bound in persisted hotbar loadout, and `abilityId` matches that binding and the prototype allowlist. Returns **`AbilityCastResponse` v1** JSON (`schemaVersion`, `accepted`, optional `reasonCode`).
**HTTP status:**
| Status | When |
|--------|------|
| **200** | Body parses as `AbilityCastResponse`; `accepted` is `true` or `false` with a stable `reasonCode` on deny. |
| **400** | Missing body or wrong `schemaVersion`. |
| **404** | Unknown player id (no position row / unknown player for this prototype). |
**Deny `reasonCode` values (200, `accepted=false`):**
| Code | Meaning |
|------|---------|
| `slot_out_of_bounds` | `slotIndex` outside `0..7`. |
| `slot_unbound` | No ability bound on that slot in stored loadout. |
| `loadout_mismatch` | `abilityId` does not match the ability bound on that slot. |
| `unknown_ability` | `abilityId` failed registry normalization (empty/garbage/off-list). |
Implementation: `server/NeonSprawl.Server/Game/AbilityInput/AbilityCastApi.cs`, `AbilityCastDtos.cs`.
## Solution
From repo root: `dotnet build NeonSprawl.sln` and `dotnet test NeonSprawl.sln`.