NEO-32: cooldown snapshot API, on_cooldown deny, client HUD sync

pull/59/head
VinPropane 2026-04-30 21:19:32 -04:00
parent b4b67bccfd
commit 361a6800d2
24 changed files with 703 additions and 10 deletions

View File

@ -0,0 +1,25 @@
meta {
name: GET cooldown snapshot
type: http
seq: 1
}
get {
url: {{baseUrl}}/game/players/dev-local-1/cooldown-snapshot
body: none
auth: none
}
tests {
test("status 200", function () {
expect(res.getStatus()).to.equal(200);
});
test("schema and eight slots", function () {
const body = res.getBody();
expect(body.schemaVersion).to.equal(1);
expect(body.playerId).to.equal("dev-local-1");
expect(Array.isArray(body.slots)).to.equal(true);
expect(body.slots.length).to.equal(8);
});
}

View File

@ -1147,3 +1147,17 @@ theme_override_constants/outline_size = 6
theme_override_font_sizes/font_size = 15 theme_override_font_sizes/font_size = 15
autowrap_mode = 3 autowrap_mode = 3
text = "Cast: —" text = "Cast: —"
[node name="CooldownSlotsLabel" type="Label" parent="UICanvas" unique_id=9000006]
offset_left = 8.0
offset_top = 384.0
offset_right = 520.0
offset_bottom = 460.0
grow_horizontal = 0
grow_vertical = 0
theme_override_colors/font_color = Color(0.78, 0.92, 0.86, 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 = 14
autowrap_mode = 3
text = "Cooldowns: —"

View File

@ -0,0 +1,63 @@
extends Node
## NEO-32: prototype HTTP client for [code]GET /game/players/{id}/cooldown-snapshot[/code].
signal snapshot_received(snapshot: Dictionary)
@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)
func request_sync_from_server() -> void:
if _busy:
return
_busy = true
var url := "%s/game/players/%s/cooldown-snapshot" % [_base_root(), _player_path_segment()]
var err: Error = _http.request(url)
if err != OK:
push_warning("CooldownSnapshotClient: GET 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("CooldownSnapshotClient: HTTP failed (result=%s)" % result)
return
if response_code == 404:
push_warning("CooldownSnapshotClient: HTTP 404 (player unknown)")
return
if response_code < 200 or response_code >= 300:
push_warning("CooldownSnapshotClient: 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("CooldownSnapshotClient: non-JSON body")
return
snapshot_received.emit(parsed as Dictionary)

View File

@ -0,0 +1,75 @@
extends RefCounted
## Local mirror for GET [code]/game/players/{id}/cooldown-snapshot[/code] (NEO-32).
## Uses [code]serverTimeUtc[/code] + receive tick to approximate server "now" between polls.
const SLOT_COUNT: int = 8
var _slot_end_unix: PackedFloat64Array = PackedFloat64Array()
var _server_unix_at_receive: float = 0.0
var _ticks_msec_at_receive: int = 0
func _init() -> void:
_slot_end_unix.resize(SLOT_COUNT)
for i in range(SLOT_COUNT):
_slot_end_unix[i] = 0.0
func apply_snapshot(snapshot: Dictionary) -> void:
for i in range(SLOT_COUNT):
_slot_end_unix[i] = 0.0
var st: Variant = snapshot.get("serverTimeUtc", "")
if st is String:
var su: String = st as String
if not su.is_empty():
_server_unix_at_receive = Time.get_unix_time_from_datetime_string(su)
_ticks_msec_at_receive = Time.get_ticks_msec()
var slots: Variant = snapshot.get("slots", [])
if slots is Array:
for item in slots as Array:
if not item is Dictionary:
continue
var d: Dictionary = item
var idx: int = int(d.get("slotIndex", -1))
if idx < 0 or idx >= SLOT_COUNT:
continue
var end_v: Variant = d.get("cooldownEndsAtUtc", null)
if end_v is String and not (end_v as String).is_empty():
var end_s: String = end_v as String
_slot_end_unix[idx] = Time.get_unix_time_from_datetime_string(end_s)
func _effective_server_unix() -> float:
return _server_unix_at_receive + (Time.get_ticks_msec() - _ticks_msec_at_receive) * 0.001
func remaining_for_slot(slot_index: int) -> float:
if slot_index < 0 or slot_index >= _slot_end_unix.size():
return 0.0
var end_u: float = _slot_end_unix[slot_index]
if end_u <= 0.0:
return 0.0
return maxf(0.0, end_u - _effective_server_unix())
func is_slot_cooling(slot_index: int) -> bool:
return remaining_for_slot(slot_index) > 0.0
func any_slot_cooling() -> bool:
for i in range(_slot_end_unix.size()):
if remaining_for_slot(i) > 0.0:
return true
return false
func build_hud_line() -> String:
var parts: PackedStringArray = PackedStringArray()
for i in range(_slot_end_unix.size()):
var rem: float = remaining_for_slot(i)
if rem > 0.0:
parts.append("slot %d: %.1fs" % [i, rem])
if parts.is_empty():
return "Cooldowns: (ready)"
return "Cooldowns: " + ", ".join(parts)

View File

@ -40,6 +40,8 @@ const AUTH_SNAP_LOCOMOTION_VEL_HZ_SQ: float = 0.12
const PrototypeTargetConstants := preload("res://scripts/prototype_target_constants.gd") const PrototypeTargetConstants := preload("res://scripts/prototype_target_constants.gd")
const HotbarCastSlotResolver := preload("res://scripts/hotbar_cast_slot_resolver.gd") const HotbarCastSlotResolver := preload("res://scripts/hotbar_cast_slot_resolver.gd")
const CooldownStateScript := preload("res://scripts/cooldown_state.gd")
const COOLDOWN_REASON_ON_CD := "on_cooldown"
## Bump on each rejection so older one-shot timers do not clear a newer message. ## Bump on each rejection so older one-shot timers do not clear a newer message.
var _move_reject_msg_token: int = 0 var _move_reject_msg_token: int = 0
@ -69,6 +71,9 @@ var _dev_obstacle_smoke: Node3D
var _hotbar_state: Node = null var _hotbar_state: Node = null
var _hotbar_client: Node = null var _hotbar_client: Node = null
var _ability_cast_client: Node = null var _ability_cast_client: Node = null
var _cooldown_state: RefCounted = null
var _cooldown_client: Node = null
var _cooldown_poll_timer: Timer = null
@onready var _camera: Camera3D = $World/IsometricFollowCamera/Camera3D @onready var _camera: Camera3D = $World/IsometricFollowCamera/Camera3D
@onready var _world: Node3D = $World @onready var _world: Node3D = $World
@ -82,6 +87,7 @@ var _ability_cast_client: Node = null
@onready var _player_pos_label: Label = $UICanvas/PlayerPositionLabel @onready var _player_pos_label: Label = $UICanvas/PlayerPositionLabel
@onready var _target_lock_label: Label = $UICanvas/TargetLockLabel @onready var _target_lock_label: Label = $UICanvas/TargetLockLabel
@onready var _cast_feedback_label: Label = $UICanvas/CastFeedbackLabel @onready var _cast_feedback_label: Label = $UICanvas/CastFeedbackLabel
@onready var _cooldown_slots_label: Label = $UICanvas/CooldownSlotsLabel
@onready var _target_client: Node = $TargetSelectionClient @onready var _target_client: Node = $TargetSelectionClient
@onready var _interaction_client: Node = $InteractionRequestClient @onready var _interaction_client: Node = $InteractionRequestClient
@onready var _floor: StaticBody3D = $World/NavigationRegion3D/Floor @onready var _floor: StaticBody3D = $World/NavigationRegion3D/Floor
@ -143,6 +149,9 @@ func _physics_process(_delta: float) -> void:
var p: Vector3 = _player.global_position var p: Vector3 = _player.global_position
_player_pos_label.text = _build_player_position_text(p) _player_pos_label.text = _build_player_position_text(p)
_render_target_lock_label(p) _render_target_lock_label(p)
if is_instance_valid(_cooldown_state) and _cooldown_state.has_method("any_slot_cooling"):
if bool(_cooldown_state.call("any_slot_cooling")):
_render_cooldown_slots_label()
## HUD: client position on top, and (once a `move-stream` / boot GET has landed) the ## HUD: client position on top, and (once a `move-stream` / boot GET has landed) the
@ -259,6 +268,77 @@ func _setup_hotbar_loadout_sync() -> void:
_hotbar_client.call("set_hotbar_state", _hotbar_state) _hotbar_client.call("set_hotbar_state", _hotbar_state)
if _hotbar_client.has_method("request_sync_from_server"): if _hotbar_client.has_method("request_sync_from_server"):
_hotbar_client.call("request_sync_from_server") _hotbar_client.call("request_sync_from_server")
_setup_cooldown_sync()
func _setup_cooldown_sync() -> void:
# NEO-32: server cooldown snapshot + local mirror for HUD and cast spam guard.
_cooldown_state = CooldownStateScript.new()
_cooldown_client = load("res://scripts/cooldown_snapshot_client.gd").new()
_cooldown_client.name = "CooldownSnapshotClient"
add_child(_cooldown_client)
if is_instance_valid(_authority):
var authority_base_url: Variant = _authority.get("base_url")
var authority_player_id: Variant = _authority.get("dev_player_id")
if authority_base_url is String and not (authority_base_url as String).is_empty():
_cooldown_client.set("base_url", authority_base_url)
if authority_player_id is String and not (authority_player_id as String).is_empty():
_cooldown_client.set("dev_player_id", authority_player_id)
if _cooldown_client.has_signal("snapshot_received"):
_cooldown_client.connect(
"snapshot_received", Callable(self, "_on_cooldown_snapshot_received")
)
if _hotbar_client.has_signal("loadout_changed"):
_hotbar_client.connect(
"loadout_changed", Callable(self, "_on_hotbar_loadout_changed_sync_cooldown")
)
_ensure_cooldown_poll_timer()
func _ensure_cooldown_poll_timer() -> void:
if is_instance_valid(_cooldown_poll_timer):
return
_cooldown_poll_timer = Timer.new()
_cooldown_poll_timer.name = "CooldownPollTimer"
_cooldown_poll_timer.one_shot = true
_cooldown_poll_timer.wait_time = 0.25
_cooldown_poll_timer.timeout.connect(_on_cooldown_poll_timeout)
add_child(_cooldown_poll_timer)
func _on_hotbar_loadout_changed_sync_cooldown(_loadout: Dictionary) -> void:
if is_instance_valid(_cooldown_client) and _cooldown_client.has_method("request_sync_from_server"):
_cooldown_client.call("request_sync_from_server")
func _on_cooldown_poll_timeout() -> void:
if is_instance_valid(_cooldown_client) and _cooldown_client.has_method("request_sync_from_server"):
_cooldown_client.call("request_sync_from_server")
func _on_cooldown_snapshot_received(snapshot: Dictionary) -> void:
if not is_instance_valid(_cooldown_state) or not _cooldown_state.has_method("apply_snapshot"):
return
_cooldown_state.call("apply_snapshot", snapshot)
_render_cooldown_slots_label()
if not _cooldown_state.has_method("any_slot_cooling"):
return
if bool(_cooldown_state.call("any_slot_cooling")):
if is_instance_valid(_cooldown_poll_timer) and _cooldown_poll_timer.is_stopped():
_cooldown_poll_timer.start()
return
if is_instance_valid(_cooldown_poll_timer):
_cooldown_poll_timer.stop()
func _render_cooldown_slots_label() -> void:
if not is_instance_valid(_cooldown_slots_label):
return
if not is_instance_valid(_cooldown_state) or not _cooldown_state.has_method("build_hud_line"):
return
var line: Variant = _cooldown_state.call("build_hud_line")
if line is String:
_cooldown_slots_label.text = line as String
## Combines cached server state (`_last_target_state`) with per-anchor horizontal ## Combines cached server state (`_last_target_state`) with per-anchor horizontal
@ -299,6 +379,8 @@ func _on_cast_result_received(accepted: bool, reason_code: String) -> void:
return return
if accepted: if accepted:
_cast_feedback_label.text = "Cast: accepted" _cast_feedback_label.text = "Cast: accepted"
if is_instance_valid(_cooldown_client) and _cooldown_client.has_method("request_sync_from_server"):
_cooldown_client.call("request_sync_from_server")
return return
var rc := reason_code.strip_edges() var rc := reason_code.strip_edges()
if rc.is_empty(): if rc.is_empty():
@ -384,6 +466,12 @@ func _request_hotbar_cast_slot(slot_index: int) -> void:
var ability_id: String = outcome[HotbarCastSlotResolver.KEY_ABILITY_ID] as String 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 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_id: Variant = outcome.get(HotbarCastSlotResolver.KEY_TARGET_ID, null)
if is_instance_valid(_cooldown_state) and _cooldown_state.has_method("is_slot_cooling"):
if bool(_cooldown_state.call("is_slot_cooling", resolved_slot)):
if is_instance_valid(_cast_feedback_label):
_cast_feedback_label.text = "ability_cast_denied: %s (client guard)" % COOLDOWN_REASON_ON_CD
push_warning("ability_cast_denied reasonCode=%s (client guard)" % COOLDOWN_REASON_ON_CD)
return
var started: bool = false var started: bool = false
if is_instance_valid(_ability_cast_client) and _ability_cast_client.has_method("request_cast"): if is_instance_valid(_ability_cast_client) and _ability_cast_client.has_method("request_cast"):
started = bool( started = bool(

View File

@ -0,0 +1,36 @@
extends GdUnitTestSuite
const CooldownStateScript := preload("res://scripts/cooldown_state.gd")
func test_remaining_is_zero_when_end_before_server_time() -> void:
# Arrange
var state: RefCounted = CooldownStateScript.new()
var snap := {
"serverTimeUtc": "2026-01-01T12:01:00.000Z",
"slots": [{"slotIndex": 0, "cooldownEndsAtUtc": "2026-01-01T12:00:05.000Z"}],
}
# Act
state.call("apply_snapshot", snap)
var rem: float = state.call("remaining_for_slot", 0) as float
# Assert
assert_that(rem).is_equal(0.0)
func test_remaining_positive_when_end_after_server_time() -> void:
# Arrange
var state: RefCounted = CooldownStateScript.new()
var snap := {
"serverTimeUtc": "2026-01-01T12:00:00.000Z",
"slots": [{"slotIndex": 0, "cooldownEndsAtUtc": "2026-01-01T12:00:10.000Z"}],
}
# Act
state.call("apply_snapshot", snap)
var rem: float = state.call("remaining_for_slot", 0) as float
# Assert
assert_that(rem).is_greater(9.0)
assert_that(rem).is_less(11.0)

View File

@ -42,7 +42,7 @@ Epic 1 **Slice 3** — `ability_cast_requested`, `ability_cast_denied` with reas
## Implementation snapshot ## 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). **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. **NEO-30** documents Epic 1 Slice 3 **telemetry hook sites** for `ability_cast_requested` / `ability_cast_denied`: comment markers + payload notes in [`AbilityCastApi.cs`](../../../server/NeonSprawl.Server/Game/AbilityInput/AbilityCastApi.cs); client dev **`print`** / **`push_warning`** in [`main.gd`](../../../client/scripts/main.gd) and [`ability_cast_client.gd`](../../../client/scripts/ability_cast_client.gd) (all **TODO(E9.M1)** for cataloged ingest). `CooldownSnapshot` wiring is still pending ([NEO-32](https://linear.app/neon-sprawl/issue/NEO-32/e1m4-04-cooldownsnapshot-sync-slot-presentation)). - **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. **NEO-30** documents Epic 1 Slice 3 **telemetry hook sites** for `ability_cast_requested` / `ability_cast_denied`: comment markers + payload notes in [`AbilityCastApi.cs`](../../../server/NeonSprawl.Server/Game/AbilityInput/AbilityCastApi.cs); client dev **`print`** / **`push_warning`** in [`main.gd`](../../../client/scripts/main.gd) and [`ability_cast_client.gd`](../../../client/scripts/ability_cast_client.gd) (all **TODO(E9.M1)** for cataloged ingest). **NEO-32** landed **`CooldownSnapshot`**: `GET /game/players/{id}/cooldown-snapshot` (`CooldownSnapshotResponse` v1: `serverTimeUtc`, eight `slots` with optional `cooldownEndsAtUtc`), prototype **global cooldown** on successful accept, JSON deny **`on_cooldown`**, Godot [`cooldown_snapshot_client.gd`](../../../client/scripts/cooldown_snapshot_client.gd) + [`cooldown_state.gd`](../../../client/scripts/cooldown_state.gd) + **`CooldownSlotsLabel`** HUD + client cast guard ([`main.gd`](../../../client/scripts/main.gd)); plan [NEO-32](../../plans/NEO-32-implementation-plan.md), manual QA [`NEO-32.md`](../../manual-qa/NEO-32.md).
- **Prototype persistence policy (NEO-29):** per-player loadout key; Postgres when `ConnectionStrings:NeonSprawl` exists, in-memory fallback otherwise. - **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. - **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. - **Dependency expectation:** first implementation stories assume E1.M3 target selection flow exists and E5.M1 provides minimum cast accept/deny contract.
@ -58,7 +58,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-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-02** | [NEO-31](https://linear.app/neon-sprawl/issue/NEO-31/e1m4-02-input-to-abilitycastrequest-path) — Input to `AbilityCastRequest` path |
| **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-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-04** | [NEO-32](https://linear.app/neon-sprawl/issue/NEO-32/e1m4-04-cooldownsnapshot-sync-slot-presentation)`CooldownSnapshot` sync + slot presentation |
| **E1M4-05** | [NEO-30](https://linear.app/neon-sprawl/issue/NEO-30/e1m4-05-slice-3-telemetry-hooks-for-cast-funnel) — Slice 3 telemetry hooks for cast funnel | | **E1M4-05** | [NEO-30](https://linear.app/neon-sprawl/issue/NEO-30/e1m4-05-slice-3-telemetry-hooks-for-cast-funnel) — Slice 3 telemetry hooks for cast funnel |
Issues should carry label **`E1.M4`** per [Linear alignment](../README.md#linear-alignment). Issues should carry label **`E1.M4`** per [Linear alignment](../README.md#linear-alignment).

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.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.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.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). **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). **NEO-30 landed:** Slice 3 cast funnel telemetry **hook-site comments** in [`AbilityCastApi.cs`](../../../server/NeonSprawl.Server/Game/AbilityInput/AbilityCastApi.cs) (`ability_cast_requested` on authoritative accept, `ability_cast_denied` + non-empty `reasonCode` on each JSON deny branch); client dev hooks unchanged; manual QA [NEO-30](../../manual-qa/NEO-30.md); plan [NEO-30](../../plans/NEO-30-implementation-plan.md). **Still open:** `CooldownSnapshot` sync ([NEO-32](https://linear.app/neon-sprawl/issue/NEO-32/e1m4-04-cooldownsnapshot-sync-slot-presentation)). | [NEO-29](../../plans/NEO-29-implementation-plan.md), [NEO-31](../../plans/NEO-31-implementation-plan.md), [NEO-28](../../plans/NEO-28-implementation-plan.md), [NEO-30](../../plans/NEO-30-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). **NEO-30 landed:** Slice 3 cast funnel telemetry **hook-site comments** in [`AbilityCastApi.cs`](../../../server/NeonSprawl.Server/Game/AbilityInput/AbilityCastApi.cs) (`ability_cast_requested` on authoritative accept, `ability_cast_denied` + non-empty `reasonCode` on each JSON deny branch); client dev hooks unchanged; manual QA [NEO-30](../../manual-qa/NEO-30.md); plan [NEO-30](../../plans/NEO-30-implementation-plan.md). **NEO-32 landed:** `GET /game/players/{id}/cooldown-snapshot` + `on_cooldown` cast deny + prototype global cooldown commit; [`cooldown_snapshot_client.gd`](../../../client/scripts/cooldown_snapshot_client.gd), [`cooldown_state.gd`](../../../client/scripts/cooldown_state.gd), **`CooldownSlotsLabel`** in [`main.gd`](../../../client/scripts/main.gd); manual QA [NEO-32](../../manual-qa/NEO-32.md); plan [NEO-32](../../plans/NEO-32-implementation-plan.md). | [NEO-29](../../plans/NEO-29-implementation-plan.md), [NEO-31](../../plans/NEO-31-implementation-plan.md), [NEO-28](../../plans/NEO-28-implementation-plan.md), [NEO-30](../../plans/NEO-30-implementation-plan.md), [NEO-32](../../plans/NEO-32-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), [`client/scripts/cooldown_snapshot_client.gd`](../../../client/scripts/cooldown_snapshot_client.gd), [`client/scripts/cooldown_state.gd`](../../../client/scripts/cooldown_state.gd); [server README — Hotbar loadout](../../../server/README.md#hotbar-loadout-neo-29), [Ability cast (NEO-31)](../../../server/README.md#ability-cast-neo-31), [Cooldown snapshot (NEO-32)](../../../server/README.md#cooldown-snapshot-neo-32) |
--- ---

View File

@ -21,7 +21,7 @@ Fleshed-out scope, contracts, and integration notes live in **per-module documen
**E1.M3 note:** Decomposition expanded in [E1_M3_InteractionAndTargetingLayer.md](E1_M3_InteractionAndTargetingLayer.md) (E1.M1 vs E1.M3 boundary, contract sketches, Slice 3 alignment). **Partial (NEO-23):** versioned JSON v1 **`PlayerTargetStateResponse`** + **`POST …/target/select`** under `server/NeonSprawl.Server/Game/Targeting/` ([NEO-23](../../plans/NEO-23-implementation-plan.md); [server README — Targeting](../../../server/README.md#targeting-neo-23)) — throwaway HTTP spike per [contracts.md](contracts.md). **Partial (NEO-25):** versioned **`GET /game/world/interactables`** + `InteractablesListDtos` / `InteractablesWorldApi` in `Game/Interaction/`; Godot `interactables_catalog_client.gd` + `interactable_world_builder.gd` ([NEO-25](../../plans/NEO-25-implementation-plan.md); [server README — Interactable descriptors](../../../server/README.md#interactable-descriptors-neo-25)). **NEO-26 (prototype `SelectionEvent`):** Godot **`TargetSelectionClient.selection_event`** on **`lockedTargetId`** changes ([NEO-26](../../plans/NEO-26-implementation-plan.md)). **Follow-on / in progress:** richer multi-consumer **`InteractableDescriptor`** targeting on later Linear issues. **Precursor (E1.M1):** **`InteractionRequest`** + horizontal reach ([NEO-9](../../plans/NEO-9-implementation-plan.md); `Game/Interaction/`, `Game/World/HorizontalReach.cs`). **E1.M3 note:** Decomposition expanded in [E1_M3_InteractionAndTargetingLayer.md](E1_M3_InteractionAndTargetingLayer.md) (E1.M1 vs E1.M3 boundary, contract sketches, Slice 3 alignment). **Partial (NEO-23):** versioned JSON v1 **`PlayerTargetStateResponse`** + **`POST …/target/select`** under `server/NeonSprawl.Server/Game/Targeting/` ([NEO-23](../../plans/NEO-23-implementation-plan.md); [server README — Targeting](../../../server/README.md#targeting-neo-23)) — throwaway HTTP spike per [contracts.md](contracts.md). **Partial (NEO-25):** versioned **`GET /game/world/interactables`** + `InteractablesListDtos` / `InteractablesWorldApi` in `Game/Interaction/`; Godot `interactables_catalog_client.gd` + `interactable_world_builder.gd` ([NEO-25](../../plans/NEO-25-implementation-plan.md); [server README — Interactable descriptors](../../../server/README.md#interactable-descriptors-neo-25)). **NEO-26 (prototype `SelectionEvent`):** Godot **`TargetSelectionClient.selection_event`** on **`lockedTargetId`** changes ([NEO-26](../../plans/NEO-26-implementation-plan.md)). **Follow-on / in progress:** richer multi-consumer **`InteractableDescriptor`** targeting on later Linear issues. **Precursor (E1.M1):** **`InteractionRequest`** + horizontal reach ([NEO-9](../../plans/NEO-9-implementation-plan.md); `Game/Interaction/`, `Game/World/HorizontalReach.cs`).
**E1.M4 note:** Vertical-slice decomposition is defined in [E1M4-prototype-backlog.md](../../plans/E1M4-prototype-backlog.md) with story slugs **E1M4-01** through **E1M4-05** (loadout contract, cast path, accept/deny UX, cooldown sync, telemetry hooks). **NEO-29** started implementation with `HotbarLoadout` v1 server APIs + persistence wiring and client hydration; **NEO-31** added the prototype cast POST + client digit-key path and resolver tests; **NEO-28** added server target lock + range gates and client cast feedback UX; **NEO-30** documented Epic 1 Slice 3 cast-funnel telemetry hook sites (comment-only, `AbilityCastApi`) — module status remains **In Progress**; see [E1_M4_AbilityInputScaffold.md](E1_M4_AbilityInputScaffold.md), [NEO-29 plan](../../plans/NEO-29-implementation-plan.md), [NEO-31 plan](../../plans/NEO-31-implementation-plan.md), [NEO-28 plan](../../plans/NEO-28-implementation-plan.md), and [NEO-30 plan](../../plans/NEO-30-implementation-plan.md). **E1.M4 note:** Vertical-slice decomposition is defined in [E1M4-prototype-backlog.md](../../plans/E1M4-prototype-backlog.md) with story slugs **E1M4-01** through **E1M4-05** (loadout contract, cast path, accept/deny UX, cooldown sync, telemetry hooks). **NEO-29** started implementation with `HotbarLoadout` v1 server APIs + persistence wiring and client hydration; **NEO-31** added the prototype cast POST + client digit-key path and resolver tests; **NEO-28** added server target lock + range gates and client cast feedback UX; **NEO-30** documented Epic 1 Slice 3 cast-funnel telemetry hook sites (comment-only, `AbilityCastApi`); **NEO-32** added `GET …/cooldown-snapshot`, `on_cooldown` deny, and client HUD + poll sync — module status remains **In Progress**; see [E1_M4_AbilityInputScaffold.md](E1_M4_AbilityInputScaffold.md), [NEO-29 plan](../../plans/NEO-29-implementation-plan.md), [NEO-31 plan](../../plans/NEO-31-implementation-plan.md), [NEO-28 plan](../../plans/NEO-28-implementation-plan.md), [NEO-30 plan](../../plans/NEO-30-implementation-plan.md), and [NEO-32 plan](../../plans/NEO-32-implementation-plan.md).
### Epic 2 — Skills and Progression Framework ### Epic 2 — Skills and Progression Framework

View File

@ -0,0 +1,11 @@
# Manual QA — NEO-32 (CooldownSnapshot + slot HUD)
Prereq: game server running (`NeonSprawl.Server`), client pointed at same `base_url` / `dev_player_id` as dev player with position + hotbar seed.
1. **Cooling HUD after accept** — Tab-lock a prototype target in range, bind slot 0 to `prototype_pulse` if needed, press `1` to cast. Expect `Cast: accepted` and **Cooldowns:** line to show `slot 0: ~3.0s` (counts down). After ~3s expect `Cooldowns: (ready)`.
2. **Server deny on spam** — Immediately double-tap cast during cooling (or remove client guard temporarily): second attempt should show `ability_cast_denied: on_cooldown` (server) on the cast feedback line when POST completes.
3. **Client guard** — While slot 0 shows cooling, press `1` rapidly; expect `(client guard)` line without flooding the server (optional: watch server logs / Bruno).
4. **Reconnect / rehydrate** — With slot 0 mid-cooldown, restart the client (or reload scene) while server keeps running; after boot expect cooldown line to match server within one GET cycle (same dev player id).

View File

@ -31,9 +31,9 @@
## Acceptance criteria checklist ## Acceptance criteria checklist
- [ ] After a successful cast, the affected slot shows a cooling state until the server snapshot says the cooldown has completed. - [x] After a successful cast, the affected slot shows a cooling state until the server snapshot says the cooldown has completed.
- [ ] The client cannot repeatedly fire a cooling ability without server deny (and/or aligned guard UX) — `reasonCode` for cooldown documented and tested. - [x] The client cannot repeatedly fire a cooling ability without server deny (and/or aligned guard UX) — `reasonCode` for cooldown documented and tested.
- [ ] After reconnect, cooldown UI matches server state by **re-fetching** the snapshot (same process lifetime as todays prototype stores). - [x] After reconnect, cooldown UI matches server state by **re-fetching** the snapshot (same process lifetime as todays prototype stores).
## Technical approach ## Technical approach
@ -98,6 +98,13 @@
## Open questions / risks ## Open questions / risks
- **Flaky timing in CI:** Mitigate with injectable `TimeProvider` on the cooldown store or very short real durations; decide during implementation. - **Flaky timing in CI:** Resolved with **`FakeTimeProvider`** in `InMemoryWebApplicationFactory` + `TimeProvider` injection on cast and snapshot routes.
- **Linear blocked-by NEO-28:** Remove relation in Linear when you accept NEO-28 as done for scheduling. - **Linear blocked-by NEO-28:** Remove relation in Linear when you accept NEO-28 as done for scheduling.
- **None** for wire shape or duration model — locked in kickoff. - **None** for wire shape or duration model — locked in kickoff.
## Implementation reconciliation (shipped)
- **Wire:** `CooldownSnapshotResponse` v1 + `GET /game/players/{id}/cooldown-snapshot`; cast deny **`on_cooldown`** (`AbilityCastApi.ReasonOnCooldown`); global duration `AbilityPrototypeCooldown.GlobalDuration` (3s).
- **Client:** `cooldown_snapshot_client.gd`, `cooldown_state.gd`, `CooldownSlotsLabel` + poll timer + loadout-changed sync + cast-accept refresh in `main.gd`.
- **Tests:** `CooldownSnapshotApiTests.cs`, cooldown cases in `AbilityCastApiTests.cs`, `client/test/cooldown_state_test.gd`.
- **Bruno:** `bruno/neon-sprawl-server/cooldown-snapshot/Get cooldown snapshot.bru`.

View File

@ -414,4 +414,69 @@ public sealed class AbilityCastApiTests
// Assert // Assert
Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); Assert.Equal(HttpStatusCode.NotFound, response.StatusCode);
} }
[Fact]
public async Task PostAbilityCast_ShouldDenyOnCooldown_WhenRepeatedWhileCooling()
{
// Arrange
await using var factory = new InMemoryWebApplicationFactory();
var client = factory.CreateClient();
await BindSlot0PulseAsync(client);
await LockPrototypeTargetAlphaAsync(client);
var cast = new AbilityCastRequest
{
SchemaVersion = AbilityCastRequest.CurrentSchemaVersion,
SlotIndex = 0,
AbilityId = PrototypeAbilityRegistry.PrototypePulse,
TargetId = PrototypeTargetRegistry.PrototypeTargetAlphaId,
};
// Act
var first = await client.PostAsJsonAsync("/game/players/dev-local-1/ability-cast", cast);
var second = await client.PostAsJsonAsync("/game/players/dev-local-1/ability-cast", cast);
// Assert
var body1 = await first.Content.ReadFromJsonAsync<AbilityCastResponse>();
var body2 = await second.Content.ReadFromJsonAsync<AbilityCastResponse>();
Assert.Equal(HttpStatusCode.OK, first.StatusCode);
Assert.Equal(HttpStatusCode.OK, second.StatusCode);
Assert.NotNull(body1);
Assert.NotNull(body2);
Assert.True(body1!.Accepted);
Assert.False(body2!.Accepted);
Assert.Equal(AbilityCastApi.ReasonOnCooldown, body2.ReasonCode);
}
[Fact]
public async Task PostAbilityCast_ShouldAcceptAgain_WhenCooldownElapsed()
{
// Arrange
await using var factory = new InMemoryWebApplicationFactory();
var client = factory.CreateClient();
Assert.NotNull(factory.FakeClock);
await BindSlot0PulseAsync(client);
await LockPrototypeTargetAlphaAsync(client);
var cast = new AbilityCastRequest
{
SchemaVersion = AbilityCastRequest.CurrentSchemaVersion,
SlotIndex = 0,
AbilityId = PrototypeAbilityRegistry.PrototypePulse,
TargetId = PrototypeTargetRegistry.PrototypeTargetAlphaId,
};
// Act
var first = await client.PostAsJsonAsync("/game/players/dev-local-1/ability-cast", cast);
factory.FakeClock!.Advance(AbilityPrototypeCooldown.GlobalDuration + TimeSpan.FromMilliseconds(50));
var second = await client.PostAsJsonAsync("/game/players/dev-local-1/ability-cast", cast);
// Assert
var body1 = await first.Content.ReadFromJsonAsync<AbilityCastResponse>();
var body2 = await second.Content.ReadFromJsonAsync<AbilityCastResponse>();
Assert.Equal(HttpStatusCode.OK, first.StatusCode);
Assert.Equal(HttpStatusCode.OK, second.StatusCode);
Assert.NotNull(body1);
Assert.NotNull(body2);
Assert.True(body1!.Accepted);
Assert.True(body2!.Accepted);
}
} }

View File

@ -0,0 +1,105 @@
using System.Net;
using System.Net.Http.Json;
using NeonSprawl.Server.Game.AbilityInput;
using NeonSprawl.Server.Game.Targeting;
using NeonSprawl.Server.Tests;
using Xunit;
namespace NeonSprawl.Server.Tests.Game.AbilityInput;
public sealed class CooldownSnapshotApiTests
{
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();
}
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();
}
[Fact]
public async Task GetCooldownSnapshot_ShouldReturnNotFound_WhenPlayerUnknown()
{
// Arrange
await using var factory = new InMemoryWebApplicationFactory();
var client = factory.CreateClient();
// Act
var response = await client.GetAsync("/game/players/missing-player/cooldown-snapshot");
// Assert
Assert.Equal(HttpStatusCode.NotFound, response.StatusCode);
}
[Fact]
public async Task GetCooldownSnapshot_ShouldReturnV1Schema_WithEightSlots_WhenPlayerKnown()
{
// Arrange
await using var factory = new InMemoryWebApplicationFactory();
var client = factory.CreateClient();
// Act
var response = await client.GetAsync("/game/players/dev-local-1/cooldown-snapshot");
// Assert
var body = await response.Content.ReadFromJsonAsync<CooldownSnapshotResponse>();
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
Assert.NotNull(body);
Assert.Equal(CooldownSnapshotResponse.CurrentSchemaVersion, body!.SchemaVersion);
Assert.Equal("dev-local-1", body.PlayerId);
Assert.Equal(HotbarLoadoutResponse.SlotCountV1, body.Slots.Count);
Assert.All(body.Slots, s => Assert.Null(s.CooldownEndsAtUtc));
}
[Fact]
public async Task GetCooldownSnapshot_ShouldExposeSlotEnd_WhenCastAccepted_ThenClear_WhenAdvancedPastDuration()
{
// Arrange
await using var factory = new InMemoryWebApplicationFactory();
var client = factory.CreateClient();
Assert.NotNull(factory.FakeClock);
await BindSlot0PulseAsync(client);
await LockPrototypeTargetAlphaAsync(client);
var cast = new AbilityCastRequest
{
SchemaVersion = AbilityCastRequest.CurrentSchemaVersion,
SlotIndex = 0,
AbilityId = PrototypeAbilityRegistry.PrototypePulse,
TargetId = PrototypeTargetRegistry.PrototypeTargetAlphaId,
};
// Act
var castResponse = await client.PostAsJsonAsync("/game/players/dev-local-1/ability-cast", cast);
var snapDuring = await client.GetAsync("/game/players/dev-local-1/cooldown-snapshot");
factory.FakeClock!.Advance(AbilityPrototypeCooldown.GlobalDuration + TimeSpan.FromMilliseconds(50));
var snapAfter = await client.GetAsync("/game/players/dev-local-1/cooldown-snapshot");
// Assert
castResponse.EnsureSuccessStatusCode();
var during = await snapDuring.Content.ReadFromJsonAsync<CooldownSnapshotResponse>();
var after = await snapAfter.Content.ReadFromJsonAsync<CooldownSnapshotResponse>();
Assert.NotNull(during);
Assert.NotNull(after);
var slot0During = during!.Slots.Single(s => s.SlotIndex == 0);
Assert.NotNull(slot0During.CooldownEndsAtUtc);
Assert.True(slot0During.CooldownEndsAtUtc > during.ServerTimeUtc);
var slot0After = after!.Slots.Single(s => s.SlotIndex == 0);
Assert.Null(slot0After.CooldownEndsAtUtc);
}
}

View File

@ -3,6 +3,7 @@ using Microsoft.AspNetCore.Mvc.Testing;
using Microsoft.AspNetCore.TestHost; using Microsoft.AspNetCore.TestHost;
using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Time.Testing;
using NeonSprawl.Server.Game.AbilityInput; using NeonSprawl.Server.Game.AbilityInput;
using NeonSprawl.Server.Game.PositionState; using NeonSprawl.Server.Game.PositionState;
using Npgsql; using Npgsql;
@ -12,6 +13,9 @@ namespace NeonSprawl.Server.Tests;
/// <summary>Forces the in-memory position store by replacing Postgres registrations after the host builds services (overrides process env e.g. CI <c>ConnectionStrings__NeonSprawl</c>).</summary> /// <summary>Forces the in-memory position store by replacing Postgres registrations after the host builds services (overrides process env e.g. CI <c>ConnectionStrings__NeonSprawl</c>).</summary>
public sealed class InMemoryWebApplicationFactory : WebApplicationFactory<Program> public sealed class InMemoryWebApplicationFactory : WebApplicationFactory<Program>
{ {
/// <summary>Controllable UTC clock shared by cast + cooldown routes in tests (NEO-32).</summary>
public FakeTimeProvider? FakeClock { get; private set; }
protected override void ConfigureWebHost(IWebHostBuilder builder) protected override void ConfigureWebHost(IWebHostBuilder builder)
{ {
builder.ConfigureTestServices(services => builder.ConfigureTestServices(services =>
@ -21,6 +25,8 @@ public sealed class InMemoryWebApplicationFactory : WebApplicationFactory<Progra
var d = services[i]; var d = services[i];
if (d.ServiceType == typeof(IPositionStateStore) || if (d.ServiceType == typeof(IPositionStateStore) ||
d.ServiceType == typeof(IPlayerHotbarLoadoutStore) || d.ServiceType == typeof(IPlayerHotbarLoadoutStore) ||
d.ServiceType == typeof(TimeProvider) ||
d.ServiceType == typeof(IPlayerAbilityCooldownStore) ||
d.ServiceType == typeof(NpgsqlDataSource) || d.ServiceType == typeof(NpgsqlDataSource) ||
(d.ServiceType == typeof(IHostedService) && (d.ServiceType == typeof(IHostedService) &&
(d.ImplementationType == typeof(PostgresDevPlayerSeedHostedService) || (d.ImplementationType == typeof(PostgresDevPlayerSeedHostedService) ||
@ -30,8 +36,12 @@ public sealed class InMemoryWebApplicationFactory : WebApplicationFactory<Progra
} }
} }
var fakeTime = new FakeTimeProvider(new DateTimeOffset(2026, 4, 30, 12, 0, 0, TimeSpan.Zero));
FakeClock = fakeTime;
services.AddSingleton<TimeProvider>(fakeTime);
services.AddSingleton<IPositionStateStore, InMemoryPositionStateStore>(); services.AddSingleton<IPositionStateStore, InMemoryPositionStateStore>();
services.AddSingleton<IPlayerHotbarLoadoutStore, InMemoryPlayerHotbarLoadoutStore>(); services.AddSingleton<IPlayerHotbarLoadoutStore, InMemoryPlayerHotbarLoadoutStore>();
services.AddSingleton<IPlayerAbilityCooldownStore, InMemoryPlayerAbilityCooldownStore>();
}); });
} }
} }

View File

@ -13,6 +13,7 @@
<ItemGroup> <ItemGroup>
<PackageReference Include="Npgsql" Version="10.0.0" /> <PackageReference Include="Npgsql" Version="10.0.0" />
<PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" Version="10.0.0" /> <PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" Version="10.0.0" />
<PackageReference Include="Microsoft.Extensions.TimeProvider.Testing" Version="10.0.0" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.1" /> <PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.1" />
<PackageReference Include="xunit" Version="2.9.3" /> <PackageReference Include="xunit" Version="2.9.3" />
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2"> <PackageReference Include="xunit.runner.visualstudio" Version="2.8.2">

View File

@ -10,6 +10,7 @@ namespace NeonSprawl.Server.Game.AbilityInput;
/// (on JSON denies, <see cref="AbilityCastResponse.ReasonCode"/> — wire JSON <c>reasonCode</c>) — hook sites are marked inline below. /// (on JSON denies, <see cref="AbilityCastResponse.ReasonCode"/> — wire JSON <c>reasonCode</c>) — hook sites are marked inline below.
/// TODO(E9.M1): emit cataloged events (payload: route player id, slotIndex, abilityId, targetId; on deny include reasonCode). /// TODO(E9.M1): emit cataloged events (payload: route player id, slotIndex, abilityId, targetId; on deny include reasonCode).
/// HTTP 400/404 here are outside the v1 cast deny contract (no JSON <c>AbilityCastResponse</c>). /// HTTP 400/404 here are outside the v1 cast deny contract (no JSON <c>AbilityCastResponse</c>).
/// NEO-32: JSON deny <c>on_cooldown</c> when the slot is inside the prototype global cooldown window.
/// </remarks> /// </remarks>
public static class AbilityCastApi public static class AbilityCastApi
{ {
@ -24,6 +25,9 @@ public static class AbilityCastApi
/// <summary>Locked prototype target is outside horizontal reach of authoritative position (NEO-28).</summary> /// <summary>Locked prototype target is outside horizontal reach of authoritative position (NEO-28).</summary>
public const string ReasonOutOfRange = TargetValidity.OutOfRange; public const string ReasonOutOfRange = TargetValidity.OutOfRange;
/// <summary>Slot still inside server-authoritative cooldown window (NEO-32).</summary>
public const string ReasonOnCooldown = "on_cooldown";
public static WebApplication MapAbilityCastApi(this WebApplication app) public static WebApplication MapAbilityCastApi(this WebApplication app)
{ {
app.MapPost( app.MapPost(
@ -33,7 +37,9 @@ public static class AbilityCastApi
AbilityCastRequest? body, AbilityCastRequest? body,
IPositionStateStore positions, IPositionStateStore positions,
IPlayerHotbarLoadoutStore store, IPlayerHotbarLoadoutStore store,
IPlayerTargetLockStore locks) => IPlayerTargetLockStore locks,
IPlayerAbilityCooldownStore cooldowns,
TimeProvider clock) =>
{ {
// NEO-30: not a Slice 3 `ability_cast_denied` site — no AbilityCastResponse body. // NEO-30: not a Slice 3 `ability_cast_denied` site — no AbilityCastResponse body.
if (body is null || body.SchemaVersion != AbilityCastRequest.CurrentSchemaVersion) if (body is null || body.SchemaVersion != AbilityCastRequest.CurrentSchemaVersion)
@ -130,6 +136,16 @@ public static class AbilityCastApi
new AbilityCastResponse { Accepted = false, ReasonCode = ReasonOutOfRange }); new AbilityCastResponse { Accepted = false, ReasonCode = ReasonOutOfRange });
} }
var now = clock.GetUtcNow();
if (cooldowns.IsOnCooldown(id, body.SlotIndex, now))
{
// NEO-30 telemetry hook site: `ability_cast_denied` + reasonCode (TODO(E9.M1): catalog emit).
return Results.Json(
new AbilityCastResponse { Accepted = false, ReasonCode = ReasonOnCooldown });
}
cooldowns.StartCooldown(id, body.SlotIndex, now, AbilityPrototypeCooldown.GlobalDuration);
// NEO-30 telemetry hook site: `ability_cast_requested` at authoritative accept (TODO(E9.M1): catalog emit). // NEO-30 telemetry hook site: `ability_cast_requested` at authoritative accept (TODO(E9.M1): catalog emit).
// Payload notes for E9.M1: player id = route `id`, body.SlotIndex, normalized ability id, body.TargetId (lock-aligned). // Payload notes for E9.M1: player id = route `id`, body.SlotIndex, normalized ability id, body.TargetId (lock-aligned).
return Results.Json(new AbilityCastResponse { Accepted = true }); return Results.Json(new AbilityCastResponse { Accepted = true });

View File

@ -0,0 +1,12 @@
namespace NeonSprawl.Server.Game.AbilityInput;
/// <summary>Registers prototype ability cooldown store + <see cref="TimeProvider"/> (NEO-32).</summary>
public static class AbilityCooldownServiceCollectionExtensions
{
public static IServiceCollection AddAbilityCooldownStore(this IServiceCollection services)
{
services.AddSingleton(TimeProvider.System);
services.AddSingleton<IPlayerAbilityCooldownStore, InMemoryPlayerAbilityCooldownStore>();
return services;
}
}

View File

@ -0,0 +1,8 @@
namespace NeonSprawl.Server.Game.AbilityInput;
/// <summary>Prototype global cooldown applied on successful cast (NEO-32) until E5.M1 ability catalog.</summary>
public static class AbilityPrototypeCooldown
{
/// <summary>Single duration for every known prototype ability on accept.</summary>
public static readonly TimeSpan GlobalDuration = TimeSpan.FromSeconds(3);
}

View File

@ -0,0 +1,50 @@
using NeonSprawl.Server.Game.PositionState;
namespace NeonSprawl.Server.Game.AbilityInput;
/// <summary>GET <c>/game/players/{{id}}/cooldown-snapshot</c> (NEO-32).</summary>
public static class CooldownSnapshotApi
{
public static WebApplication MapCooldownSnapshotApi(this WebApplication app)
{
app.MapGet(
"/game/players/{id}/cooldown-snapshot",
(string id, IPositionStateStore positions, IPlayerAbilityCooldownStore cooldowns, TimeProvider clock) =>
{
if (!positions.TryGetPosition(id, out _))
{
return Results.NotFound();
}
return Results.Json(BuildSnapshot(id, cooldowns, clock));
});
return app;
}
internal static CooldownSnapshotResponse BuildSnapshot(
string playerId,
IPlayerAbilityCooldownStore cooldowns,
TimeProvider clock)
{
var now = clock.GetUtcNow();
var slots = new List<CooldownSlotSnapshotJson>(HotbarLoadoutResponse.SlotCountV1);
for (var i = 0; i < HotbarLoadoutResponse.SlotCountV1; i++)
{
DateTimeOffset? end = null;
if (cooldowns.TryGetCooldownEndUtc(playerId, i, out var endUtc) && endUtc > now)
{
end = endUtc;
}
slots.Add(new CooldownSlotSnapshotJson { SlotIndex = i, CooldownEndsAtUtc = end });
}
return new CooldownSnapshotResponse
{
PlayerId = playerId,
ServerTimeUtc = now,
Slots = slots,
};
}
}

View File

@ -0,0 +1,35 @@
using System.Text.Json.Serialization;
namespace NeonSprawl.Server.Game.AbilityInput;
/// <summary>GET response for per-slot cooldown ends (NEO-32).</summary>
public sealed class CooldownSnapshotResponse
{
public const int CurrentSchemaVersion = 1;
[JsonPropertyName("schemaVersion")]
public int SchemaVersion { get; init; } = CurrentSchemaVersion;
[JsonPropertyName("playerId")]
public required string PlayerId { get; init; }
/// <summary>Authoritative instant for remaining-time math on the client.</summary>
[JsonPropertyName("serverTimeUtc")]
public DateTimeOffset ServerTimeUtc { get; init; }
/// <summary>Slots 0..7 in ascending order.</summary>
[JsonPropertyName("slots")]
public required IReadOnlyList<CooldownSlotSnapshotJson> Slots { get; init; }
}
/// <summary>Cooldown end for one hotbar slot; <see cref="CooldownEndsAtUtc"/> null when ready.</summary>
public sealed class CooldownSlotSnapshotJson
{
[JsonPropertyName("slotIndex")]
public required int SlotIndex { get; init; }
/// <summary>UTC instant when the slot leaves cooldown, or null when not cooling.</summary>
[JsonPropertyName("cooldownEndsAtUtc")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public DateTimeOffset? CooldownEndsAtUtc { get; init; }
}

View File

@ -0,0 +1,14 @@
namespace NeonSprawl.Server.Game.AbilityInput;
/// <summary>In-memory per-player hotbar slot cooldown ends (NEO-32).</summary>
public interface IPlayerAbilityCooldownStore
{
/// <summary>Returns true when the slot has a cooldown end strictly after <paramref name="now"/>.</summary>
bool IsOnCooldown(string playerId, int slotIndex, DateTimeOffset now);
/// <summary>Sets cooldown end for a slot to <paramref name="now"/> + <paramref name="duration"/>.</summary>
void StartCooldown(string playerId, int slotIndex, DateTimeOffset now, TimeSpan duration);
/// <summary>Gets stored end instant for the slot when an entry exists (may be in the past).</summary>
bool TryGetCooldownEndUtc(string playerId, int slotIndex, out DateTimeOffset endUtc);
}

View File

@ -0,0 +1,47 @@
using System.Collections.Concurrent;
namespace NeonSprawl.Server.Game.AbilityInput;
/// <summary>Thread-safe in-memory cooldown map (NEO-32); not persisted across process restarts.</summary>
public sealed class InMemoryPlayerAbilityCooldownStore : IPlayerAbilityCooldownStore
{
private readonly ConcurrentDictionary<string, ConcurrentDictionary<int, DateTimeOffset>> ends = new(StringComparer.Ordinal);
public bool IsOnCooldown(string playerId, int slotIndex, DateTimeOffset now)
{
if (!ends.TryGetValue(playerId, out var perSlot))
{
return false;
}
if (!perSlot.TryGetValue(slotIndex, out var end))
{
return false;
}
if (now >= end)
{
perSlot.TryRemove(slotIndex, out _);
return false;
}
return true;
}
public void StartCooldown(string playerId, int slotIndex, DateTimeOffset now, TimeSpan duration)
{
var perSlot = ends.GetOrAdd(playerId, _ => new ConcurrentDictionary<int, DateTimeOffset>());
perSlot[slotIndex] = now + duration;
}
public bool TryGetCooldownEndUtc(string playerId, int slotIndex, out DateTimeOffset endUtc)
{
endUtc = default;
if (!ends.TryGetValue(playerId, out var perSlot))
{
return false;
}
return perSlot.TryGetValue(slotIndex, out endUtc);
}
}

View File

@ -6,6 +6,7 @@ using NeonSprawl.Server.Game.Targeting;
var builder = WebApplication.CreateBuilder(args); var builder = WebApplication.CreateBuilder(args);
builder.Services.AddPositionStateStore(builder.Configuration); builder.Services.AddPositionStateStore(builder.Configuration);
builder.Services.AddHotbarLoadoutStore(builder.Configuration); builder.Services.AddHotbarLoadoutStore(builder.Configuration);
builder.Services.AddAbilityCooldownStore();
builder.Services.AddSingleton<IPlayerTargetLockStore, InMemoryPlayerTargetLockStore>(); builder.Services.AddSingleton<IPlayerTargetLockStore, InMemoryPlayerTargetLockStore>();
var app = builder.Build(); var app = builder.Build();
@ -25,6 +26,7 @@ app.MapInteractionApi();
app.MapInteractablesWorldApi(); app.MapInteractablesWorldApi();
app.MapTargetingApi(); app.MapTargetingApi();
app.MapHotbarLoadoutApi(); app.MapHotbarLoadoutApi();
app.MapCooldownSnapshotApi();
app.MapAbilityCastApi(); app.MapAbilityCastApi();
app.Run(); app.Run();

View File

@ -227,7 +227,7 @@ Current prototype allowlist: `prototype_pulse`, `prototype_guard`, `prototype_da
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`**. 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`, `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`). - **`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). **NEO-32:** rejects with `on_cooldown` when the slot is still cooling; on accept, starts the prototype global cooldown for that slot. Returns **`AbilityCastResponse` v1** JSON (`schemaVersion`, `accepted`, optional `reasonCode`).
**HTTP status:** **HTTP status:**
@ -247,9 +247,18 @@ Prototype **cast intent** (no full combat resolution — see E5.M1). **NEO-28**
| `unknown_ability` | `abilityId` failed registry normalization (empty/garbage/off-list). | | `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). | | `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). | | `out_of_range` | Locked target is outside horizontal reach of authoritative player position vs. prototype anchor (NEO-28). |
| `on_cooldown` | Slot is still inside the server-authoritative cooldown window after a prior successful accept (NEO-32). |
Implementation: `server/NeonSprawl.Server/Game/AbilityInput/AbilityCastApi.cs`, `AbilityCastDtos.cs`. Implementation: `server/NeonSprawl.Server/Game/AbilityInput/AbilityCastApi.cs`, `AbilityCastDtos.cs`.
## Cooldown snapshot (NEO-32)
Prototype **per-slot cooldown ends** (in-memory per process; not persisted to Postgres). **Global duration** `AbilityPrototypeCooldown.GlobalDuration` applies on each successful cast after all NEO-28 gates pass.
- **`GET /game/players/{id}/cooldown-snapshot`** → `CooldownSnapshotResponse` v1 (`schemaVersion`, `playerId`, `serverTimeUtc`, `slots[]` with `slotIndex` and optional `cooldownEndsAtUtc` per slot). **404** when the player id is unknown to position state (same rule as loadout GET).
Implementation: `CooldownSnapshotApi.cs`, `CooldownSnapshotDtos.cs`, `IPlayerAbilityCooldownStore` / `InMemoryPlayerAbilityCooldownStore.cs`.
**NEO-30 (Slice 3 telemetry):** authoritative hook-site comments in `AbilityCastApi.cs` for product names `ability_cast_requested` (accept) and `ability_cast_denied` (each JSON deny + `reasonCode`); cataloged emit deferred to **E9.M1** (`TODO` in source). **NEO-30 (Slice 3 telemetry):** authoritative hook-site comments in `AbilityCastApi.cs` for product names `ability_cast_requested` (accept) and `ability_cast_denied` (each JSON deny + `reasonCode`); cataloged emit deferred to **E9.M1** (`TODO` in source).
## Solution ## Solution