Compare commits
22 Commits
b91d0c8b65
...
807812148f
| Author | SHA1 | Date |
|---|---|---|
|
|
807812148f | |
|
|
753f622602 | |
|
|
ed89f8b1b1 | |
|
|
76fd1cc70a | |
|
|
75eec11f0d | |
|
|
ee953cbdfd | |
|
|
c83d8c8ed2 | |
|
|
cdf7c61ce4 | |
|
|
27daef70a7 | |
|
|
7d64ed2adc | |
|
|
7ddfb53219 | |
|
|
56f7ae8a81 | |
|
|
f4ba4cdd92 | |
|
|
ae74f570dc | |
|
|
361a6800d2 | |
|
|
b4b67bccfd | |
|
|
0790a3398d | |
|
|
94cec78053 | |
|
|
8a9a9bcf78 | |
|
|
deec789af3 | |
|
|
b5478782a8 | |
|
|
0e95918b56 |
|
|
@ -27,6 +27,10 @@ Use **sparingly** (e.g. shared config many scenes need). Default to **scene-loca
|
|||
- **One obvious concern per script** (picking vs network vs locomotion)—not one file per tiny helper.
|
||||
- **New work:** add `res://scripts/<feature>.gd` (or a subfolder when a feature set grows) rather than extending `main.gd` by default.
|
||||
|
||||
## Godot `.uid` companion files (Godot 4)
|
||||
|
||||
Godot writes a **`*.gd.uid`** next to each script (editor import / asset scan). **Commit it with the `.gd`** in the same change: do not leave new scripts with untracked `.uid` files, and do **not** add `*.uid` to `.gitignore`—this repo already tracks hundreds of them for stable `uid://` references. Applies to `client/scripts/` and `client/test/` the same way.
|
||||
|
||||
## Examples
|
||||
|
||||
```text
|
||||
|
|
|
|||
|
|
@ -16,3 +16,5 @@ Optional **personas** for Cursor. Enable by **@ mentioning** the rule in chat or
|
|||
Project-wide conventions live under [`.cursor/rules/`](.cursor/rules/) (many are `alwaysApply`). **Planning / implementation decisions** must be written into `docs/plans/` (and related docs)—[`.cursor/rules/planning-implementation-docs.md`](.cursor/rules/planning-implementation-docs.md). **Manual QA checklists** for user-visible ticketed work are generated during implementation at `docs/manual-qa/{KEY}.md` (same rule). **Automated tests (AAA, mandatory):** **C#** — every new or changed **`[Fact]` / `[Theory]`** method in **`*Tests.cs`** must use full **Arrange → Act → Assert**: the three **`// Arrange` / `// Act` / `// Assert`** labels, **Act** = behavior under test only, **Assert** = all expectations including **`ReadFromJsonAsync`** (etc.) for verification (no blank line required after the phase comments) — [csharp-style](.cursor/rules/csharp-style.md#unit-and-integration-tests-arrange-act-assert), [testing-expectations](.cursor/rules/testing-expectations.md); snippets **`xut`** / **`xutc`**. **GdUnit (`client/test/`)** — same AAA with **`# Arrange` / `# Act` / `# Assert`** — [gdscript-style](.cursor/rules/gdscript-style.md#gdunit-test-layout-aaa). **Godot client layout** (thin `main.gd`, split by concern): [`.cursor/rules/godot-client-script-organization.md`](.cursor/rules/godot-client-script-organization.md). **Git:** agents may **commit** at discretion on story/ticket work; **never** `git push` unless the user asks — [`.cursor/rules/commit-and-review.md`](.cursor/rules/commit-and-review.md). **Open PR:** when the user asks, use the **GitHub MCP** **`create_pull_request`** tool (`user-github`) with a title starting **`NEO-123:`** ([commit-and-review](.cursor/rules/commit-and-review.md)); **do not use `gh` CLI for GitHub operations in this repo**. **Story lifecycle:** kickoff [`.cursor/rules/story-kickoff.md`](.cursor/rules/story-kickoff.md); after merge / end of story — `checkout main`, `pull`, delete local story branch — [`.cursor/rules/story-end.md`](.cursor/rules/story-end.md). **Linear:** on **end story**, use **`AskQuestion`** multiple choice for **In Test** / **Done** / **Skip** when Agent mode supports it ([story-end](.cursor/rules/story-end.md) §4a); otherwise ask in prose. Wait for the choice (or the same message naming the state) **before** `save_issue`; do not guess. **PR / push text:** no “Made-with: Cursor” boilerplate (same file).
|
||||
|
||||
**Commits tied to a Linear issue** must start the subject with the **issue id** and a colon (e.g. `NEO-8: …`). Branch naming and exceptions (`chore:` when there is no ticket) — [`.cursor/rules/linear-git-naming.md`](.cursor/rules/linear-git-naming.md).
|
||||
|
||||
**Godot `*.uid`:** When adding or renaming scripts under `client/`, stage and commit the matching **`*.gd.uid`** in the **same commit** as the `.gd` file. These companion files are **tracked in git** (not gitignored) so `uid://` references stay stable across clones. See [godot-client-script-organization](.cursor/rules/godot-client-script-organization.md).
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
meta {
|
||||
name: POST ability cast happy
|
||||
name: POST ability cast happy (NEO-30 cast accept path)
|
||||
type: http
|
||||
seq: 22
|
||||
}
|
||||
|
|
@ -21,6 +21,8 @@ body:json {
|
|||
|
||||
tests {
|
||||
test("status 200", function () {
|
||||
// NEO-30 review follow-up: cast JSON contract unchanged (server comments only).
|
||||
// Optional: NEON_SPRAWL_API_LOG=1 on server prints ability_cast_requested for accepted JSON.
|
||||
expect(res.getStatus()).to.equal(200);
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,29 @@
|
|||
meta {
|
||||
name: GET cooldown snapshot
|
||||
type: http
|
||||
seq: 1
|
||||
}
|
||||
|
||||
docs {
|
||||
NEO-32: expired slot entries are pruned when building the snapshot (server-side store hygiene).
|
||||
}
|
||||
|
||||
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);
|
||||
});
|
||||
}
|
||||
|
|
@ -1147,3 +1147,17 @@ theme_override_constants/outline_size = 6
|
|||
theme_override_font_sizes/font_size = 15
|
||||
autowrap_mode = 3
|
||||
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: —"
|
||||
|
|
|
|||
|
|
@ -3,6 +3,8 @@ 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.
|
||||
## NEO-30: [code]ability_cast_denied[/code] dev hook ([code]push_warning[/code] below). Deny
|
||||
## [code]reasonCode[/code] is authoritative in [code]AbilityCastApi.cs[/code] (comment hook sites).
|
||||
signal cast_result_received(accepted: bool, reason_code: String)
|
||||
|
||||
@export var base_url: String = "http://127.0.0.1:5253"
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
@ -0,0 +1 @@
|
|||
uid://p8w2fcyiuvws
|
||||
|
|
@ -0,0 +1,87 @@
|
|||
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
|
||||
## Reject bogus [method Time.get_unix_time_from_datetime_string] results (failed parse → 0).
|
||||
const MIN_VALID_UNIX_ANCHOR: float = 1_000_000_000.0
|
||||
|
||||
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", null)
|
||||
var anchor: float = 0.0
|
||||
if st is String:
|
||||
var su: String = (st as String).strip_edges()
|
||||
if not su.is_empty():
|
||||
anchor = Time.get_unix_time_from_datetime_string(su)
|
||||
elif st is float:
|
||||
anchor = st as float
|
||||
elif st is int:
|
||||
anchor = float(st as int)
|
||||
if anchor < MIN_VALID_UNIX_ANCHOR:
|
||||
anchor = Time.get_unix_time_from_system()
|
||||
_server_unix_at_receive = anchor
|
||||
_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).strip_edges()
|
||||
var end_unix: float = Time.get_unix_time_from_datetime_string(end_s)
|
||||
if end_unix >= MIN_VALID_UNIX_ANCHOR:
|
||||
_slot_end_unix[idx] = end_unix
|
||||
|
||||
|
||||
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)
|
||||
|
|
@ -0,0 +1 @@
|
|||
uid://cl1m1cpb6dsua
|
||||
|
|
@ -40,6 +40,8 @@ const AUTH_SNAP_LOCOMOTION_VEL_HZ_SQ: float = 0.12
|
|||
|
||||
const PrototypeTargetConstants := preload("res://scripts/prototype_target_constants.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.
|
||||
var _move_reject_msg_token: int = 0
|
||||
|
|
@ -69,6 +71,9 @@ var _dev_obstacle_smoke: Node3D
|
|||
var _hotbar_state: Node = null
|
||||
var _hotbar_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 _world: Node3D = $World
|
||||
|
|
@ -82,6 +87,7 @@ var _ability_cast_client: Node = null
|
|||
@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 _cooldown_slots_label: Label = $UICanvas/CooldownSlotsLabel
|
||||
@onready var _target_client: Node = $TargetSelectionClient
|
||||
@onready var _interaction_client: Node = $InteractionRequestClient
|
||||
@onready var _floor: StaticBody3D = $World/NavigationRegion3D/Floor
|
||||
|
|
@ -143,6 +149,9 @@ func _physics_process(_delta: float) -> void:
|
|||
var p: Vector3 = _player.global_position
|
||||
_player_pos_label.text = _build_player_position_text(p)
|
||||
_render_target_lock_label(p)
|
||||
if _cooldown_state != null 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
|
||||
|
|
@ -208,13 +217,13 @@ func _on_authoritative_position(world: Vector3, apply_as_snap: bool) -> void:
|
|||
## hooks for movement-driven lock refresh — we do not snap the capsule to this value
|
||||
## (that is `authoritative_position_received`'s job on boot + move rejection).
|
||||
##
|
||||
## NEO-27 / NEO-31: `ability_cast_requested` is emitted as a dev [code]print[/code] from
|
||||
## [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 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).
|
||||
## NEO-27 / NEO-31 / NEO-30: Slice 3 [code]ability_cast_requested[/code] — [code]print[/code] from
|
||||
## [method _request_hotbar_cast_slot] after [code]request_cast[/code] returns [code]true[/code]
|
||||
## (POST queued on [code]HTTPRequest[/code]).
|
||||
## Server hooks: [code]AbilityCastApi.cs[/code] (accept path). [code]ability_cast_denied[/code]:
|
||||
## [code]push_warning[/code] in [code]ability_cast_client.gd[/code].
|
||||
## HUD [member _cast_feedback_label] (NEO-28). Deny branches in [code]AbilityCastApi.cs[/code].
|
||||
## TODO(E9.M1): catalog + ingest.
|
||||
func _on_authoritative_ack_for_hud(world: Vector3) -> void:
|
||||
_last_ack_world = world
|
||||
_have_last_ack = true
|
||||
|
|
@ -259,6 +268,83 @@ func _setup_hotbar_loadout_sync() -> void:
|
|||
_hotbar_client.call("set_hotbar_state", _hotbar_state)
|
||||
if _hotbar_client.has_method("request_sync_from_server"):
|
||||
_hotbar_client.call("request_sync_from_server")
|
||||
_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 _cooldown_state == null 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 _cooldown_state == null 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
|
||||
|
|
@ -299,6 +385,9 @@ func _on_cast_result_received(accepted: bool, reason_code: String) -> void:
|
|||
return
|
||||
if accepted:
|
||||
_cast_feedback_label.text = "Cast: accepted"
|
||||
if is_instance_valid(_cooldown_client):
|
||||
if _cooldown_client.has_method("request_sync_from_server"):
|
||||
_cooldown_client.call("request_sync_from_server")
|
||||
return
|
||||
var rc := reason_code.strip_edges()
|
||||
if rc.is_empty():
|
||||
|
|
@ -384,6 +473,14 @@ 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)
|
||||
if _cooldown_state != null 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
|
||||
if is_instance_valid(_ability_cast_client) and _ability_cast_client.has_method("request_cast"):
|
||||
started = bool(
|
||||
|
|
|
|||
|
|
@ -2,6 +2,8 @@ extends GdUnitTestSuite
|
|||
|
||||
# main.gd sets base_url/dev_player_id on AbilityCastClient from the same authority block as
|
||||
# HotbarLoadoutClient (NEO-31).
|
||||
# NEO-30: Slice 3 ability_cast_denied dev warning includes reasonCode from AbilityCastResponse;
|
||||
# server deny hook sites are documented in AbilityCastApi.cs (gdlint: script doc lines <= 100 cols).
|
||||
|
||||
const CastClient := preload("res://scripts/ability_cast_client.gd")
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,51 @@
|
|||
extends GdUnitTestSuite
|
||||
|
||||
## Covers [code]cooldown_state.gd[/code] snapshot math (NEO-32).
|
||||
## Paired with [code]main.gd[/code] wiring when that file changes (pre-commit).
|
||||
const CooldownStateScript := preload("res://scripts/cooldown_state.gd")
|
||||
|
||||
|
||||
func test_remaining_is_zero_when_end_before_server_time() -> void:
|
||||
# Arrange — server anchor after end → slot reads as ready.
|
||||
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)
|
||||
|
||||
|
||||
func test_invalid_server_time_string_does_not_lock_slot_ready() -> void:
|
||||
# Arrange: bogus ISO parses as 0; anchor must fall back so slot 0 is not stuck cooling.
|
||||
var state: RefCounted = CooldownStateScript.new()
|
||||
var snap := {"serverTimeUtc": "__not_iso__", "slots": [{"slotIndex": 0}]}
|
||||
|
||||
# Act
|
||||
state.call("apply_snapshot", snap)
|
||||
var cooling: bool = bool(state.call("is_slot_cooling", 0))
|
||||
|
||||
# Assert
|
||||
assert_that(cooling).is_false()
|
||||
|
|
@ -0,0 +1 @@
|
|||
uid://bkbdb41gqg7g
|
||||
|
|
@ -22,6 +22,7 @@ Provide a data-driven **non-combat skill** layer: `SkillDef` registry, **skill**
|
|||
- Key contracts: `SkillDef`, `SkillCategory`, `UnlockRequirement`; prototype schema requires **`allowedXpSourceKinds`** per skill.
|
||||
- Dependencies: None
|
||||
- Stage target: Prototype
|
||||
- **Linear (Slice 1 prototype):** [Epic 2 project](https://linear.app/neon-sprawl/project/epic-2-classless-skill-and-progression-framework-5200f84bf8b6) — [NEO-33](https://linear.app/neon-sprawl/issue/NEO-33) → [NEO-36](https://linear.app/neon-sprawl/issue/NEO-36) (label **`E2.M1`**); table and ordering in the module doc.
|
||||
|
||||
### E2.M2 - XpAwardAndLevelEngine
|
||||
|
||||
|
|
|
|||
|
|
@ -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). **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.
|
||||
- **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 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.
|
||||
|
|
@ -58,8 +58,8 @@ 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** | [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 |
|
||||
| **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 |
|
||||
|
||||
Issues should carry label **`E1.M4`** per [Linear alignment](../README.md#linear-alignment).
|
||||
|
||||
|
|
|
|||
|
|
@ -6,9 +6,23 @@
|
|||
|--------|--------|
|
||||
| **Module ID** | E2.M1 |
|
||||
| **Epic** | [Epic 2 — Skills and Progression Framework](../epics/epic_02_skills_and_progression.md) |
|
||||
| **Linear (Epic 2 project)** | [Epic 2 — Classless Skill and Progression Framework](https://linear.app/neon-sprawl/project/epic-2-classless-skill-and-progression-framework-5200f84bf8b6) |
|
||||
| **Stage target** | Prototype |
|
||||
| **Status** | Planned (see [dependency register](module_dependency_register.md)) |
|
||||
|
||||
## Linear backlog (Epic 2 Slice 1)
|
||||
|
||||
Issues use label **`E2.M1`**. Dependency order: **NEO-33 → NEO-34 → NEO-35 → NEO-36** (each issue’s description is canonical).
|
||||
|
||||
| Order | Issue | Summary |
|
||||
|------:|-------|---------|
|
||||
| 1 | [NEO-33](https://linear.app/neon-sprawl/issue/NEO-33/e2m1-lock-prototype-skilldef-trio-ci-catalog-contract) | Lock prototype `SkillDef` trio + CI catalog contract + designer note |
|
||||
| 2 | [NEO-34](https://linear.app/neon-sprawl/issue/NEO-34/e2m1-server-loads-skilldef-catalog-at-startup-fail-fast) | Server loads catalog at startup (fail-fast) |
|
||||
| 3 | [NEO-35](https://linear.app/neon-sprawl/issue/NEO-35/e2m1-skilldefinitionregistry-service-lookup-tests) | `SkillDefinitionRegistry` service + lookup tests |
|
||||
| 4 | [NEO-36](https://linear.app/neon-sprawl/issue/NEO-36/e2m1-read-only-skill-catalog-http-projection-bruno) | Read-only skill catalog HTTP projection + Bruno |
|
||||
|
||||
`docs/plans/NEO-*-implementation-plan.md` files should be added when implementation kicks off (per [planning-implementation-docs](../../../.cursor/rules/planning-implementation-docs.md)).
|
||||
|
||||
## Purpose
|
||||
|
||||
Central catalog for **non-combat** skill metadata: **stable IDs**, **categories**, **allowed XP sources**, and unlock prerequisites. All prototype activities reference skills by **`SkillDef.id`** validated against this registry. **Combat role progression** is **not** a `SkillDef`—use gig progression ([`docs/game-design/gigs.md`](../../game-design/gigs.md)).
|
||||
|
|
|
|||
|
|
@ -49,7 +49,8 @@ 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). **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) |
|
||||
| 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) |
|
||||
| E2.M1 | Planned | **Linear backlog (Todo):** [NEO-33](https://linear.app/neon-sprawl/issue/NEO-33) → [NEO-36](https://linear.app/neon-sprawl/issue/NEO-36) under [Epic 2 project](https://linear.app/neon-sprawl/project/epic-2-classless-skill-and-progression-framework-5200f84bf8b6); label **`E2.M1`**. `content/schemas/skill-def.schema.json`, `content/skills/prototype_skills.json`, and PR-gate `validate_content.py` already exist; **server registry, fail-fast boot load, and read HTTP + Bruno** are not implemented yet. | [E2_M1_SkillDefinitionRegistry.md](E2_M1_SkillDefinitionRegistry.md); [module_dependency_register.md](module_dependency_register.md) **E2.M1 note** |
|
||||
|
||||
---
|
||||
|
||||
|
|
|
|||
|
|
@ -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.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 — module status remains **In Progress**; see [E1_M4_AbilityInputScaffold.md](E1_M4_AbilityInputScaffold.md), [NEO-29 plan](../../plans/NEO-29-implementation-plan.md), and [NEO-31 plan](../../plans/NEO-31-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
|
||||
|
||||
|
|
@ -32,6 +32,8 @@ Fleshed-out scope, contracts, and integration notes live in **per-module documen
|
|||
| E2.M3 | MasteryAndPerkUnlocks | E2.M2 | MasteryTrack, PerkUnlockEvent, PerkState | Pre-production | Planned |
|
||||
| E2.M4 | ProgressionPacingControls | E2.M2, E9.M2 | XpModifierProfile, CatchUpRule, PacingPolicy | Pre-production | Planned |
|
||||
|
||||
**E2.M1 note:** Epic 2 Slice 1 backlog in Linear ([Epic 2 — Classless Skill and Progression Framework](https://linear.app/neon-sprawl/project/epic-2-classless-skill-and-progression-framework-5200f84bf8b6)): [NEO-33](https://linear.app/neon-sprawl/issue/NEO-33) → [NEO-34](https://linear.app/neon-sprawl/issue/NEO-34) → [NEO-35](https://linear.app/neon-sprawl/issue/NEO-35) → [NEO-36](https://linear.app/neon-sprawl/issue/NEO-36); label **`E2.M1`**. See [E2_M1_SkillDefinitionRegistry.md](E2_M1_SkillDefinitionRegistry.md). Register stays **Planned** until implementation merges; update [documentation and implementation alignment](documentation_and_implementation_alignment.md) when code lands.
|
||||
|
||||
### Epic 3 — Crafting Economy
|
||||
|
||||
| Module ID | Module Name | Depends On | Contract Needed | Phase Required | Status |
|
||||
|
|
|
|||
|
|
@ -0,0 +1,25 @@
|
|||
# NEO-30 — Manual QA checklist
|
||||
|
||||
| Field | Value |
|
||||
|-------|-------|
|
||||
| Key | NEO-30 |
|
||||
| Title | E1M4-05: Slice 3 telemetry hooks for cast funnel |
|
||||
| Linear | https://linear.app/neon-sprawl/issue/NEO-30/e1m4-05-slice-3-telemetry-hooks-for-cast-funnel |
|
||||
| Plan | `docs/plans/NEO-30-implementation-plan.md` |
|
||||
| Branch | `NEO-30-cast-funnel-telemetry-hooks` |
|
||||
|
||||
## 1) Code / docs sanity
|
||||
|
||||
- [ ] Open `server/NeonSprawl.Server/Game/AbilityInput/AbilityCastApi.cs` and confirm **NEO-30** comments mark **`ability_cast_denied`** on each JSON deny return and **`ability_cast_requested`** on the final accept return; each deny branch notes **TODO(E9.M1)**.
|
||||
- [ ] Confirm **400** / **404** early returns are documented as **not** Slice 3 `ability_cast_denied` JSON paths.
|
||||
- [ ] Skim `docs/decomposition/modules/E1_M4_AbilityInputScaffold.md` — snapshot mentions NEO-30 hook file paths; **E1M4-05** row links to Linear **NEO-30**.
|
||||
|
||||
## 2) Client hook vocabulary (regression with NEO-31 / NEO-28)
|
||||
|
||||
- [ ] With server running and slot **0** bound to `prototype_pulse`, **Tab** lock **alpha**, press **1**: Godot Output line starts with **`ability_cast_requested`** and includes `slot=0`, `ability_id=prototype_pulse`, `targetId=` consistent with HUD lock.
|
||||
- [ ] **Optional (server stdout):** same scenario with **`NEON_SPRAWL_API_LOG=1`** (see [server README — Run](../../server/README.md)) — server console shows **`[ability-cast] ability_cast_requested`** for that accept (Slice 3 hook name alignment).
|
||||
- [ ] **Without** lock (or wrong target), press **1**: **`CastFeedbackLabel`** shows **`ability_cast_denied: …`** with a **non-empty** reason; Output warning includes **`ability_cast_denied`** and **`reasonCode=`** (see [`docs/manual-qa/NEO-28.md`](NEO-28.md) for concrete cases).
|
||||
|
||||
## 3) Server JSON deny (reason non-empty)
|
||||
|
||||
- [ ] `curl` or Bruno: provoke a **200** deny (e.g. `loadout_mismatch` per [`docs/manual-qa/NEO-31.md`](NEO-31.md)) — response JSON has **`accepted": false`** and a **non-empty** **`reasonCode`**.
|
||||
|
|
@ -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` repeatedly. Expect **`ability_cast_denied: on_cooldown (client guard)`** on **`CastFeedbackLabel`** on most presses (local mirror blocks the POST). **Optional:** run the server with **`NEON_SPRAWL_API_LOG=1`** (or launch profile **`http+apiLog`**) — each cast that reaches the server and returns JSON prints **`[ability-cast] ability_cast_denied …`** or **`ability_cast_requested`** to the process console, so rapid `1` with the guard on should produce **fewer** lines than without the guard. **Optional proof the server still enforces cooldown:** in Bruno (or `curl`), send **`POST …/ability-cast`** twice in a row with the same valid body while the snapshot still shows slot 0 cooling — second response **`accepted": false`**, **`reasonCode": "on_cooldown"`** (same contract as step 2).
|
||||
|
||||
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).
|
||||
|
|
@ -0,0 +1,80 @@
|
|||
# NEO-30 — Implementation plan
|
||||
|
||||
## Story reference
|
||||
|
||||
| Field | Value |
|
||||
|--------|--------|
|
||||
| **Key** | NEO-30 |
|
||||
| **Title** | E1M4-05: Slice 3 telemetry hooks for cast funnel |
|
||||
| **Linear** | [NEO-30](https://linear.app/neon-sprawl/issue/NEO-30/e1m4-05-slice-3-telemetry-hooks-for-cast-funnel) |
|
||||
| **Slug** | E1M4-05 |
|
||||
| **Git branch** | `NEO-30-cast-funnel-telemetry-hooks` |
|
||||
| **Parent context** | [E1M4 prototype backlog](E1M4-prototype-backlog.md) · [E1.M4 — AbilityInputScaffold](../decomposition/modules/E1_M4_AbilityInputScaffold.md) |
|
||||
| **Decomposition** | [Epic 1 Slice 3 — telemetry hooks](../decomposition/epics/epic_01_core_player_runtime.md#epic-1-slice-3); [E9.M1 — TelemetryEventSchema](../decomposition/modules/E9_M1_TelemetryEventSchema.md) (deferred catalog) |
|
||||
|
||||
## Kickoff clarifications
|
||||
|
||||
- **Docs scope (resolved):** Update **`docs/decomposition/modules/E1_M4_AbilityInputScaffold.md`** and **`docs/decomposition/modules/documentation_and_implementation_alignment.md`** (E1.M4 snapshot row) so hook locations and E9.M1 deferrals are discoverable from module + alignment table.
|
||||
- **Server hook style (resolved):** **Comment-only hook sites** in `AbilityCastApi` (plus `TODO(E9.M1)`), **no new server runtime logging** — matches NEO-27 pattern, avoids duplicate noise with client dev prints/warnings and keeps PII/log-policy questions out of this slice.
|
||||
- **Why no further blocking questions:** Linear goal, in/out of scope, and acceptance criteria are explicit; cast HTTP + client hook behavior already exists from NEO-31/NEO-28.
|
||||
|
||||
## Goal, scope, and out-of-scope
|
||||
|
||||
**Goal:** Complete Slice 3 **telemetry hook sites** for product names **`ability_cast_requested`** and **`ability_cast_denied`** (with reason metadata in comments / existing dev surfaces), and **`TODO(E9.M1)`** alignment until catalog/schema exists.
|
||||
|
||||
**In scope**
|
||||
|
||||
- Add or verify **hook-site documentation** on the **server** cast funnel (`POST /game/players/{id}/ability-cast`): where **`ability_cast_requested`** (successful intent / accept path) and **`ability_cast_denied`** (each deny branch with stable `reasonCode`) would attach for E9.M1 ingest — **comments only**, no production pipeline.
|
||||
- **Payload notes** in those comments: player id (route), `slotIndex`, `abilityId`, `targetId`, and deny **`reasonCode`** (and note HTTP **400/404** paths are not Slice 3 “denied cast” JSON — no `AbilityCastResponse`).
|
||||
- **Client:** verify existing dev **`print`** / **`push_warning`** / HUD path still matches vocabulary from [NEO-31](NEO-31-implementation-plan.md) / [NEO-28](NEO-28-implementation-plan.md); tighten comments only if anything drifted.
|
||||
|
||||
**Out of scope**
|
||||
|
||||
- Production telemetry ingest, dashboards, retention (per Linear).
|
||||
- New OpenTelemetry / structured server logs for this story (explicit kickoff decision).
|
||||
|
||||
## Acceptance criteria checklist
|
||||
|
||||
- [x] Hook names match Epic 1 Slice 3 telemetry vocabulary (`ability_cast_requested`, `ability_cast_denied`).
|
||||
- [x] Denied cast hook path carries a **non-empty** `reasonCode` on **`AbilityCastResponse`** denies (already enforced by server + tests; verify + document in module plan).
|
||||
- [x] Module doc + **`documentation_and_implementation_alignment.md`** reference **where** hooks are emitted / annotated and what remains deferred to **E9.M1**.
|
||||
|
||||
## Technical approach
|
||||
|
||||
1. **`AbilityCastApi.cs`** — At the single map handler: document **authoritative server** hook sites — e.g. after validation passes and before returning **`Accepted: true`** as the future **`ability_cast_requested`** emit point; each `Results.Json` deny as a future **`ability_cast_denied`** emit point with the same **`reasonCode`** string already returned. Note payload fields for catalog rows. **`TODO(E9.M1)`** on each site.
|
||||
|
||||
2. **Client (`main.gd`, `ability_cast_client.gd`)** — Confirm **`ability_cast_requested`** only fires when POST is queued (`request_cast` → `true`) and **`ability_cast_denied`** warning includes `reasonCode` on JSON deny; align comment blocks with server anchor paths if needed.
|
||||
|
||||
3. **Docs** — **`E1_M4_AbilityInputScaffold.md`:** implementation snapshot + Linear table row **E1M4-05 → NEO-30** (replace TBD). **`documentation_and_implementation_alignment.md`:** E1.M4 row cites NEO-30 and “telemetry hook sites” for cast funnel (still no ingest).
|
||||
|
||||
4. **Optional README** — One-line under server “Ability cast” if it helps discoverability without duplicating the module doc.
|
||||
|
||||
## Files to add
|
||||
|
||||
| Path | Rationale |
|
||||
|------|-----------|
|
||||
| `docs/manual-qa/NEO-30.md` | Thin checklist: confirm Godot Output / HUD still matches hook vocabulary after doc-only server changes; points to NEO-28/NEO-31 steps where behavior is shared. |
|
||||
|
||||
## Files to modify
|
||||
|
||||
| Path | Rationale |
|
||||
|------|-----------|
|
||||
| `server/NeonSprawl.Server/Game/AbilityInput/AbilityCastApi.cs` | NEO-30: comment-only Slice 3 hook sites + payload notes + `TODO(E9.M1)` on accept and each deny branch. |
|
||||
| `client/scripts/main.gd` | Verify/update comment block for `ability_cast_requested` / cross-reference server hook file if useful. |
|
||||
| `client/scripts/ability_cast_client.gd` | Verify/update `ability_cast_denied` hook comment + `TODO(E9.M1)` next to warning emit. |
|
||||
| `docs/decomposition/modules/E1_M4_AbilityInputScaffold.md` | Snapshot + E1M4-05 Linear link; source anchors for cast telemetry hooks. |
|
||||
| `docs/decomposition/modules/documentation_and_implementation_alignment.md` | E1.M4 row: NEO-30 telemetry hook sites for cast funnel. |
|
||||
| `server/README.md` | Optional one-line under Ability cast: hook sites documented in `AbilityCastApi` (NEO-30). |
|
||||
|
||||
## Tests
|
||||
|
||||
| File | Action |
|
||||
|------|--------|
|
||||
| `server/NeonSprawl.Server.Tests/Game/AbilityInput/AbilityCastApiTests.cs` | Class `<remarks>` documents NEO-30 / Slice 3 `ability_cast_denied` contract; existing `[Fact]` methods already assert non-empty `ReasonCode` on every JSON deny. |
|
||||
| `client/test/ability_cast_client_test.gd` | Comment tying client `ability_cast_denied` warning to server hook docs (pre-commit requires a staged test file with `client/scripts` edits). |
|
||||
| `bruno/neon-sprawl-server/ability-cast/Post cast happy.bru` | Meta name touch so pre-commit stages Bruno with `AbilityCastApi.cs` edits. |
|
||||
| `docs/manual-qa/NEO-30.md` | Human verification of hook tokens / HUD line after implementation. |
|
||||
|
||||
## Open questions / risks
|
||||
|
||||
None.
|
||||
|
|
@ -0,0 +1,110 @@
|
|||
# NEO-32 implementation plan — CooldownSnapshot sync + slot presentation
|
||||
|
||||
## Story reference
|
||||
|
||||
- **Key:** NEO-32
|
||||
- **Title:** E1M4-04: CooldownSnapshot sync + slot presentation
|
||||
- **Linear:** https://linear.app/neon-sprawl/issue/NEO-32/e1m4-04-cooldownsnapshot-sync-slot-presentation
|
||||
- **Project:** E1.M4 — AbilityInputScaffold · **Label:** E1.M4
|
||||
|
||||
## Kickoff clarifications
|
||||
|
||||
**Asked (transport):** Initial multiple-choice offered “dedicated GET”, “embed in `HotbarLoadoutResponse`”, or “hybrid”. User asked for a recommendation; agent recommended **dedicated GET** (smaller poll payload, keeps hotbar loadout schema focused on bindings). User then **confirmed dedicated GET** for the plan.
|
||||
|
||||
**Asked (duration):** Global constant vs per-ability registry. User selected **one global prototype cooldown duration** for all known abilities (server-side constant until E5.M1 catalog).
|
||||
|
||||
**Linear note:** Issue still lists **blocked by NEO-28**; repo work for NEO-28 is already described as landed in decomposition/docs. Clear the Linear blocker when you consider that dependency satisfied.
|
||||
|
||||
## Goal, scope, and out-of-scope
|
||||
|
||||
**Goal:** Add a `CooldownSnapshot` wire contract and a server-backed update path so the hotbar reflects cooldown truth; gate repeat casts while a slot is cooling with synchronized server deny behavior and client presentation.
|
||||
|
||||
**In scope (from Linear):**
|
||||
|
||||
- Define `CooldownSnapshot` prototype fields and a **GET** route that returns authoritative state.
|
||||
- Update hotbar slot presentation (ready vs cooling; remaining time or clear non-animated indicator).
|
||||
- Server: reject casts for a slot on active cooldown with a stable **reasonCode**; client: optional local guard so spam does not flood HTTP (must still align with server deny).
|
||||
|
||||
**Out of scope (from Linear):**
|
||||
|
||||
- Final polished animation treatments for cooldown visuals.
|
||||
|
||||
## Acceptance criteria checklist
|
||||
|
||||
- [x] After a successful cast, the affected slot shows a cooling state until the server snapshot says the cooldown has completed.
|
||||
- [x] The client cannot repeatedly fire a cooling ability without server deny (and/or aligned guard UX) — `reasonCode` for cooldown documented and tested.
|
||||
- [x] After reconnect, cooldown UI matches server state by **re-fetching** the snapshot (same process lifetime as today’s prototype stores).
|
||||
|
||||
## Technical approach
|
||||
|
||||
1. **Wire contract (`CooldownSnapshot` v1)**
|
||||
- New JSON DTOs (C#) with `schemaVersion`, `playerId`, **`serverTimeUtc`** (ISO-8601) for client remaining-time math, and a fixed **8-slot** list aligned with `HotbarLoadoutResponse.SlotCountV1`.
|
||||
- Per slot: `slotIndex`, optional `abilityId` echo (or omit if redundant — prefer mirror loadout binding from client cache only; snapshot focuses on **cooldownEndsAtUtc** nullable). When `cooldownEndsAtUtc` is null or ≤ `serverTimeUtc`, slot is **ready** for cast from a cooldown perspective.
|
||||
|
||||
2. **Server authority**
|
||||
- **`IPlayerAbilityCooldownStore`** (or equivalent name): in-memory per-player map `slotIndex → cooldown end (UTC)`. Registered as **singleton** in DI (prototype: no Postgres; resets on process restart, consistent with ephemeral combat-adjacent state).
|
||||
- **Global constant** `TimeSpan` (e.g. 3s — pick one value in implementation) applied on **accepted** cast after all existing NEO-28 gates.
|
||||
- **`AbilityCastApi`:** before accept, if slot has active cooldown → JSON deny with new **`reasonCode`** (e.g. `on_cooldown`). On accept → record cooldown end for that slot.
|
||||
- **`GET /game/players/{id}/cooldown-snapshot`:** 404 if player unknown to `IPositionStateStore` (same pattern as other player routes); otherwise return computed snapshot from the cooldown store + `UtcNow`.
|
||||
|
||||
3. **Client**
|
||||
- New **`CooldownSnapshotClient`** (parallel to `HotbarLoadoutClient`): GET on boot after loadout sync (or chained on loadout success), plus a **Timer** or low-rate poll (e.g. 200–500ms while any slot cooling, idle when all ready — implementation detail) to refresh remaining UI.
|
||||
- New **`CooldownState`** (or extend `HotbarState` minimally): hold per-slot `cooldownEndsAt` / “is cooling” derived from last snapshot + local clock vs `serverTimeUtc` offset.
|
||||
- **Presentation:** minimal HUD per slot (e.g. extend existing labels or a single debug row listing cooling slots + seconds remaining — avoid “final animation” scope).
|
||||
- **`main.gd`:** before `request_cast`, if local cooldown mirror says slot still cooling, **skip POST** and optionally mirror deny UX (must not contradict server; server remains source of truth). On `cast_result_received` accepted, optionally trigger immediate snapshot GET to avoid poll latency.
|
||||
|
||||
4. **Bruno**
|
||||
- Request folder for GET cooldown snapshot + optional flow after cast POST.
|
||||
|
||||
5. **Manual QA**
|
||||
- Add `docs/manual-qa/NEO-32.md` during implementation (cast → cooling → expiry; spam during cooldown; reconnect + GET).
|
||||
|
||||
## Files to add
|
||||
|
||||
| Path | Purpose |
|
||||
|------|---------|
|
||||
| `server/NeonSprawl.Server/Game/AbilityInput/CooldownSnapshotDtos.cs` | `CooldownSnapshotResponse` + per-slot JSON records (`schemaVersion`, `serverTimeUtc`, slots). |
|
||||
| `server/NeonSprawl.Server/Game/AbilityInput/IPlayerAbilityCooldownStore.cs` | Interface: try get / set cooldown end per player+slot; clear or implicit expiry by time comparison. |
|
||||
| `server/NeonSprawl.Server/Game/AbilityInput/InMemoryPlayerAbilityCooldownStore.cs` | Singleton in-memory implementation. |
|
||||
| `server/NeonSprawl.Server/Game/AbilityInput/AbilityCooldownServiceCollectionExtensions.cs` | `AddAbilityCooldownStore()` registration (in-memory only for prototype). |
|
||||
| `server/NeonSprawl.Server/Game/AbilityInput/CooldownSnapshotApi.cs` | `MapCooldownSnapshotApi` — GET route. |
|
||||
| `server/NeonSprawl.Server.Tests/Game/AbilityInput/CooldownSnapshotApiTests.cs` | HTTP tests for GET shape, 404, and snapshot after cast. |
|
||||
| `client/scripts/cooldown_snapshot_client.gd` | HTTP GET + signal for parsed snapshot. |
|
||||
| `client/scripts/cooldown_state.gd` | Local mirror + helpers for “slot cooling” / remaining seconds. |
|
||||
| `client/test/cooldown_snapshot_client_test.gd` | GdUnit: parse / apply snapshot behavior (mock HTTP if pattern exists in repo). |
|
||||
| `bruno/neon-sprawl-server/...` (GET cooldown-snapshot `.bru`) | Manual API verification. |
|
||||
| `docs/manual-qa/NEO-32.md` | Checklist: cooling display, deny on spam, reconnect hydration. |
|
||||
|
||||
## Files to modify
|
||||
|
||||
| Path | Rationale |
|
||||
|------|-----------|
|
||||
| `server/NeonSprawl.Server/Program.cs` | Register cooldown store DI; `MapCooldownSnapshotApi()`. |
|
||||
| `server/NeonSprawl.Server/Game/AbilityInput/AbilityCastApi.cs` | Inject cooldown store; `on_cooldown` deny; start cooldown on accept. |
|
||||
| `server/NeonSprawl.Server.Tests/Game/AbilityInput/AbilityCastApiTests.cs` | Assert cooldown deny + accept commits cooldown (via follow-up GET or store-visible behavior through API). |
|
||||
| `client/scripts/main.gd` | Instantiate client/state; boot + poll wiring; gate `_request_hotbar_cast_slot` when cooling; connect cast result → refresh snapshot. |
|
||||
| `docs/decomposition/modules/E1_M4_AbilityInputScaffold.md` | Document `CooldownSnapshot` route + wire fields (module “current state”). |
|
||||
| `docs/decomposition/modules/documentation_and_implementation_alignment.md` | E1.M4 row: NEO-32 landed note when implemented. |
|
||||
|
||||
## Tests
|
||||
|
||||
| Test file | Coverage |
|
||||
|-----------|----------|
|
||||
| `CooldownSnapshotApiTests.cs` | GET returns v1 schema; unknown player 404; known player empty snapshot; after accepted cast, slot shows future `cooldownEndsAtUtc`; after wall-clock / fake time if we use injectable clock — **if no injectable clock**, assert ordering with short duration + spin wait or expose test hook only if justified. Prefer **`TimeProvider`** / injectable time abstraction on store if tests need deterministic expiry without flakiness. |
|
||||
| `AbilityCastApiTests.cs` | Second POST to same slot while cooling → `accepted: false`, `reasonCode` `on_cooldown` (exact string documented in plan + API const). |
|
||||
| `client/test/cooldown_snapshot_client_test.gd` | JSON → state: ready vs cooling; `serverTimeUtc` offset handling for remaining display helper. |
|
||||
|
||||
**If time injection is too large for this slice:** document in **Open questions** and use integration-style test with real short duration (e.g. 100ms) + generous timeout — only as fallback.
|
||||
|
||||
## Open questions / risks
|
||||
|
||||
- **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.
|
||||
- **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`.
|
||||
|
|
@ -0,0 +1,50 @@
|
|||
# Code review — NEO-30 (cast funnel telemetry hooks)
|
||||
|
||||
**Date:** 2026-04-30
|
||||
|
||||
**Scope:** Branch `NEO-30-cast-funnel-telemetry-hooks`; head `b547878` (subject: `NEO-30: cast funnel telemetry hook sites and docs`). Review vs `origin/main` (implicit).
|
||||
|
||||
**Base:** `origin/main`
|
||||
|
||||
**Follow-up:** Suggestions and nits below are **done** (strikethrough + **Done.**); register row and XML doc tweaks landed in the same change-set.
|
||||
|
||||
## Verdict
|
||||
|
||||
**Approve**
|
||||
|
||||
## Summary
|
||||
|
||||
The branch delivers NEO-30 as agreed: **comment-only** telemetry hook sites on the authoritative cast handler (`AbilityCastApi`), explicit exclusion of HTTP 400/404 from Slice 3 `ability_cast_denied` JSON semantics, client/doc cross-links, alignment and module snapshot updates, a thin manual QA checklist, and Bruno meta touch for pre-commit pairing. No runtime behavior change beyond documentation strings; risk is low.
|
||||
|
||||
## Documentation checked
|
||||
|
||||
| Path | Result |
|
||||
|------|--------|
|
||||
| `docs/plans/NEO-30-implementation-plan.md` | **Matches** — kickoff decisions (comment-only server hooks, no new server logging), file list, acceptance criteria, and technical approach align with the diff. |
|
||||
| `docs/decomposition/modules/E1_M4_AbilityInputScaffold.md` | **Matches** — snapshot and E1M4-05 row cite NEO-30, hook anchors, and E9.M1 deferral. |
|
||||
| `docs/decomposition/modules/documentation_and_implementation_alignment.md` | **Matches** — E1.M4 row documents NEO-30 telemetry hook sites. |
|
||||
| `docs/decomposition/modules/module_dependency_register.md` | **Matches** (post-review) — E1.M4 note now includes NEO-28 + NEO-30 with plan links. |
|
||||
| `docs/decomposition/modules/E9_M1_TelemetryEventSchema.md` | **N/A** — deferred per plan; `TODO(E9.M1)` in code matches. |
|
||||
| `docs/decomposition/modules/client_server_authority.md` / `contracts.md` | **Matches** — no change to authority or wire contracts; comments reinforce server as authoritative deny/reason source. |
|
||||
| `docs/manual-qa/NEO-30.md` | **Matches** — concrete checks for comments, 400/404 exclusion, and shared NEO-28/31 vocabulary. |
|
||||
|
||||
**Register / tracking table:** E1.M4 narrative note updated to include NEO-30 (and NEO-28) with plan links — see **Suggestions** resolution above.
|
||||
|
||||
## Blocking issues
|
||||
|
||||
(none)
|
||||
|
||||
## Suggestions
|
||||
|
||||
1. ~~**Module dependency register (optional):** Add **NEO-30** to the **E1.M4 note** paragraph in `docs/decomposition/modules/module_dependency_register.md` so readers who start from the register see hook-site documentation alongside NEO-29/31/28 — mirrors the alignment table and avoids “only in alignment doc” drift.~~ **Done.** E1.M4 note now includes **NEO-28** and **NEO-30** with plan links (`module_dependency_register.md`).
|
||||
|
||||
## Nits
|
||||
|
||||
1. ~~**Nit:** `AbilityCastApi` class `<remarks>` uses `<c>AbilityCastResponse.reasonCode</c>` (JSON casing). C# readers may expect `ReasonCode`; harmless for intent.~~ **Done.** Remarks now use `<see cref="AbilityCastResponse.ReasonCode"/>` and note wire JSON `reasonCode` (`AbilityCastApi.cs`).
|
||||
|
||||
2. ~~**Nit:** `AbilityCastApiTests` `<see cref="AbilityCastApi.MapAbilityCastApi"/>` targets an extension method; some doc generators are picky. If XML doc warnings appear in CI later, switch to a fully qualified `cref` or drop the `see` to the containing type only.~~ **Done.** `<see>` targets `AbilityCastApi` only; extension name in plain `<c>MapAbilityCastApi</c>` (`AbilityCastApiTests.cs`).
|
||||
|
||||
## Verification
|
||||
|
||||
- `dotnet test server/NeonSprawl.Server.Tests/NeonSprawl.Server.Tests.csproj --filter "FullyQualifiedName~AbilityCastApiTests"` — **14 passed** (2026-04-30).
|
||||
- Execute `docs/manual-qa/NEO-30.md` for Godot Output / HUD vocabulary before merge if this is the final story handoff.
|
||||
|
|
@ -0,0 +1,47 @@
|
|||
# Code review — NEO-32 (CooldownSnapshot sync + slot presentation)
|
||||
|
||||
**Date:** 2026-04-30
|
||||
|
||||
**Scope:** Branch `NEO-32-cooldown-snapshot-sync-slot-presentation` vs merge-base with `origin/main` (commits through `56f7ae8`: cooldown snapshot API, cast `on_cooldown`, client HUD/sync, optional `NEON_SPRAWL_API_LOG`, HUD anchor fix for bad `serverTimeUtc`).
|
||||
|
||||
**Base:** `origin/main` at merge-base `0790a3398d1b7138643e8e91f4b28db4fd7c554e`.
|
||||
|
||||
## Verdict
|
||||
|
||||
**Approve with nits**
|
||||
|
||||
## Summary
|
||||
|
||||
The branch delivers the planned NEO-32 slice: authoritative in-memory per-player slot cooldowns after an accepted cast, JSON deny `on_cooldown`, `GET /game/players/{id}/cooldown-snapshot` with v1 DTOs, `FakeTimeProvider` in the test host for deterministic time, Godot `CooldownSnapshotClient` + `CooldownState` with poll-while-cooling and a client-side cast guard aligned with the same reason string, plus Bruno and manual QA. Server tests pass (`dotnet test`). Overall risk is low for a prototype vertical slice; remaining notes are test-structure polish and a minor store hygiene detail.
|
||||
|
||||
## Documentation checked
|
||||
|
||||
| Document | Result |
|
||||
|----------|--------|
|
||||
| `docs/plans/NEO-32-implementation-plan.md` | **Matches** — dedicated GET, global duration, `on_cooldown`, in-memory store, client poll + guard, `FakeTimeProvider`, reconciliation section matches shipped filenames (`cooldown_state_test.gd` vs earlier table mentioning `cooldown_snapshot_client_test.gd`; reconciliation already reflects state tests). |
|
||||
| `docs/decomposition/modules/E1_M4_AbilityInputScaffold.md` | **Matches** — current state paragraph describes route, DTOs, client scripts, HUD, and plan link. |
|
||||
| `docs/decomposition/modules/module_dependency_register.md` | **Matches** — NEO-32 note alongside prior E1.M4 slices. |
|
||||
| `docs/decomposition/modules/documentation_and_implementation_alignment.md` | **Matches** — E1.M4 row includes NEO-32 pointers. |
|
||||
| `docs/decomposition/modules/client_server_authority.md` | **Matches** — cooldown enforced on server; client guard is optional spam/UX layer, consistent with “server validates every intent.” |
|
||||
| `docs/decomposition/modules/E5_M1_CombatRulesEngine.md` | **N/A** for conflict — still notes prototype accept does not imply full combat resolution; NEO-32 does not claim engine-level cooldown catalog. |
|
||||
|
||||
**Register / tracking:** No change required beyond what the branch already updates; E1.M4 correctly stays **In Progress**.
|
||||
|
||||
## Blocking issues
|
||||
|
||||
(none)
|
||||
|
||||
## Suggestions
|
||||
|
||||
1. ~~**C# AAA layout on new integration tests** — `CooldownSnapshotApiTests.GetCooldownSnapshot_ShouldExposeSlotEnd_WhenCastAccepted_ThenClear_WhenAdvancedPastDuration` and the two new cooldown tests in `AbilityCastApiTests` bundle HTTP calls and `FakeClock.Advance` inside a single `// Act` block. Per [csharp-style](.cursor/rules/csharp-style.md#unit-and-integration-tests-arrange-act-assert), split so **Act** is only the invocations under assertion (or use clearly separated Act steps per phase) and keep clock advances in Arrange or interleaved with labeled sections. Low priority but keeps CI lint/review consistency for future edits.~~ **Deferred (won’t do).** Scenario-style integration tests read better with one narrated timeline; strict per-call Act splits are optional here.
|
||||
|
||||
2. ~~**Stale cooldown entries in the in-memory store** — `TryGetCooldownEndUtc` does not prune expired ends; `BuildSnapshot` omits them from JSON, so wire behavior is correct. Expired rows linger until `IsOnCooldown` runs (e.g. next cast to that slot). For a long-lived prototype process this is negligible; if you ever extend GET-heavy paths, consider pruning in `TryGetCooldownEndUtc` or snapshot build for symmetry with `IsOnCooldown`.~~ **Done.** `TryGetCooldownEndUtc` now takes `DateTimeOffset now` and shares pruning with `IsOnCooldown`; `InMemoryPlayerAbilityCooldownStoreTests` covers prune-on-read.
|
||||
|
||||
## Nits
|
||||
|
||||
- ~~**Nit:** Untracked Godot `.uid` files for new scripts appeared in the working tree (`cooldown_snapshot_client.gd.uid`, etc.). Decide whether to commit them (team Godot 4 convention) or ignore via `.gitignore` so CI/agents do not see noise.~~ **Done.** Companion `*.gd.uid` committed; convention documented in `AGENTS.md` and [godot-client-script-organization.md](../../.cursor/rules/godot-client-script-organization.md).
|
||||
|
||||
## Verification
|
||||
|
||||
- `cd server && dotnet test NeonSprawl.Server.Tests/NeonSprawl.Server.Tests.csproj` — all tests passed (96) at review time.
|
||||
- Manual: follow `docs/manual-qa/NEO-32.md` (HUD countdown, server deny, client guard, rehydrate); optional `NEON_SPRAWL_API_LOG=1` / `http+apiLog` profile for stdout tracing.
|
||||
|
|
@ -8,6 +8,13 @@ using Xunit;
|
|||
|
||||
namespace NeonSprawl.Server.Tests.Game.AbilityInput;
|
||||
|
||||
/// <summary>HTTP integration tests for <see cref="AbilityCastApi"/>.</summary>
|
||||
/// <remarks>
|
||||
/// NEO-30: every JSON deny (<see cref="AbilityCastResponse.Accepted"/> <c>false</c>) asserts a non-whitespace
|
||||
/// <see cref="AbilityCastResponse.ReasonCode"/> — matches Slice 3 <c>ability_cast_denied</c> payload expectations;
|
||||
/// authoritative hook-site comments live on <see cref="AbilityCastApi"/> (cast route: <c>MapAbilityCastApi</c> extension in the same source file).
|
||||
/// Optional dev: <c>NEON_SPRAWL_API_LOG=1</c> enables one-line <c>ability_cast_*</c> stdout per JSON cast response (see <see cref="Diagnostics.PrototypeApiConsoleLog"/>).
|
||||
/// </remarks>
|
||||
public sealed class AbilityCastApiTests
|
||||
{
|
||||
private static async Task BindSlot0PulseAsync(HttpClient client)
|
||||
|
|
@ -408,4 +415,69 @@ public sealed class AbilityCastApiTests
|
|||
// Assert
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,41 @@
|
|||
using NeonSprawl.Server.Game.AbilityInput;
|
||||
using Xunit;
|
||||
|
||||
namespace NeonSprawl.Server.Tests.Game.AbilityInput;
|
||||
|
||||
public sealed class InMemoryPlayerAbilityCooldownStoreTests
|
||||
{
|
||||
[Fact]
|
||||
public void TryGetCooldownEndUtc_ShouldReturnEnd_WhenNowBeforeCooldownEnd()
|
||||
{
|
||||
// Arrange
|
||||
var store = new InMemoryPlayerAbilityCooldownStore();
|
||||
var t0 = new DateTimeOffset(2026, 4, 30, 12, 0, 0, TimeSpan.Zero);
|
||||
store.StartCooldown("p1", 2, t0, TimeSpan.FromSeconds(3));
|
||||
|
||||
// Act
|
||||
var has = store.TryGetCooldownEndUtc("p1", 2, t0 + TimeSpan.FromSeconds(1), out var endUtc);
|
||||
|
||||
// Assert
|
||||
Assert.True(has);
|
||||
Assert.Equal(t0 + TimeSpan.FromSeconds(3), endUtc);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryGetCooldownEndUtc_ShouldPruneAndReturnFalse_WhenNowAtOrAfterEnd()
|
||||
{
|
||||
// Arrange
|
||||
var store = new InMemoryPlayerAbilityCooldownStore();
|
||||
var t0 = new DateTimeOffset(2026, 4, 30, 12, 0, 0, TimeSpan.Zero);
|
||||
store.StartCooldown("p1", 0, t0, TimeSpan.FromSeconds(3));
|
||||
var after = t0 + TimeSpan.FromSeconds(5);
|
||||
|
||||
// Act
|
||||
var pruned = store.TryGetCooldownEndUtc("p1", 0, after, out _);
|
||||
var second = store.TryGetCooldownEndUtc("p1", 0, after, out _);
|
||||
|
||||
// Assert
|
||||
Assert.False(pruned);
|
||||
Assert.False(second);
|
||||
}
|
||||
}
|
||||
|
|
@ -3,6 +3,7 @@ using Microsoft.AspNetCore.Mvc.Testing;
|
|||
using Microsoft.AspNetCore.TestHost;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Microsoft.Extensions.Time.Testing;
|
||||
using NeonSprawl.Server.Game.AbilityInput;
|
||||
using NeonSprawl.Server.Game.PositionState;
|
||||
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>
|
||||
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)
|
||||
{
|
||||
builder.ConfigureTestServices(services =>
|
||||
|
|
@ -21,6 +25,8 @@ public sealed class InMemoryWebApplicationFactory : WebApplicationFactory<Progra
|
|||
var d = services[i];
|
||||
if (d.ServiceType == typeof(IPositionStateStore) ||
|
||||
d.ServiceType == typeof(IPlayerHotbarLoadoutStore) ||
|
||||
d.ServiceType == typeof(TimeProvider) ||
|
||||
d.ServiceType == typeof(IPlayerAbilityCooldownStore) ||
|
||||
d.ServiceType == typeof(NpgsqlDataSource) ||
|
||||
(d.ServiceType == typeof(IHostedService) &&
|
||||
(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<IPlayerHotbarLoadoutStore, InMemoryPlayerHotbarLoadoutStore>();
|
||||
services.AddSingleton<IPlayerAbilityCooldownStore, InMemoryPlayerAbilityCooldownStore>();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@
|
|||
<ItemGroup>
|
||||
<PackageReference Include="Npgsql" 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="xunit" Version="2.9.3" />
|
||||
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2">
|
||||
|
|
|
|||
|
|
@ -0,0 +1,55 @@
|
|||
namespace NeonSprawl.Server.Diagnostics;
|
||||
|
||||
/// <summary>Optional one-line stdout traces for prototype HTTP routes (manual QA / dev until E9.M1).</summary>
|
||||
/// <remarks>
|
||||
/// Enable with environment variable <c>NEON_SPRAWL_API_LOG</c> set to <c>1</c>, <c>true</c>, <c>yes</c>, or <c>on</c> (case-insensitive).
|
||||
/// </remarks>
|
||||
public static class PrototypeApiConsoleLog
|
||||
{
|
||||
/// <summary>Slice 3 hook names; matches product telemetry vocabulary in <c>AbilityCastApi</c> comments.</summary>
|
||||
public static bool IsAbilityCastStdoutEnabled()
|
||||
{
|
||||
var raw = Environment.GetEnvironmentVariable("NEON_SPRAWL_API_LOG");
|
||||
if (string.IsNullOrWhiteSpace(raw))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var v = raw.Trim();
|
||||
return v is "1" or "true" or "yes"
|
||||
|| string.Equals(v, "on", StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
/// <summary>One line per JSON ability-cast response (HTTP 200 with <c>AbilityCastResponse</c>).</summary>
|
||||
public static void WriteAbilityCastLine(
|
||||
string playerId,
|
||||
int slotIndex,
|
||||
string? abilityId,
|
||||
string? targetId,
|
||||
bool accepted,
|
||||
string? reasonCode)
|
||||
{
|
||||
if (!IsAbilityCastStdoutEnabled())
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (accepted)
|
||||
{
|
||||
Console.WriteLine(
|
||||
"[ability-cast] ability_cast_requested player={0} slot={1} ability={2} target={3}",
|
||||
playerId,
|
||||
slotIndex,
|
||||
abilityId ?? "",
|
||||
targetId ?? "");
|
||||
}
|
||||
else
|
||||
{
|
||||
Console.WriteLine(
|
||||
"[ability-cast] ability_cast_denied player={0} slot={1} reason={2}",
|
||||
playerId,
|
||||
slotIndex,
|
||||
reasonCode ?? "");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,3 +1,4 @@
|
|||
using NeonSprawl.Server.Diagnostics;
|
||||
using NeonSprawl.Server.Game.PositionState;
|
||||
using NeonSprawl.Server.Game.Targeting;
|
||||
using NeonSprawl.Server.Game.World;
|
||||
|
|
@ -5,6 +6,14 @@ using NeonSprawl.Server.Game.World;
|
|||
namespace NeonSprawl.Server.Game.AbilityInput;
|
||||
|
||||
/// <summary>Maps prototype ability cast POST (NEO-31 + NEO-28 target authority).</summary>
|
||||
/// <remarks>
|
||||
/// Epic 1 Slice 3 product telemetry names <c>ability_cast_requested</c> and <c>ability_cast_denied</c>
|
||||
/// (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).
|
||||
/// Optional dev stdout: set <c>NEON_SPRAWL_API_LOG=1</c> (see <see cref="Diagnostics.PrototypeApiConsoleLog"/>) for one line per JSON cast response.
|
||||
/// 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>
|
||||
public static class AbilityCastApi
|
||||
{
|
||||
public const string ReasonSlotOutOfBounds = "slot_out_of_bounds";
|
||||
|
|
@ -18,6 +27,25 @@ public static class AbilityCastApi
|
|||
/// <summary>Locked prototype target is outside horizontal reach of authoritative position (NEO-28).</summary>
|
||||
public const string ReasonOutOfRange = TargetValidity.OutOfRange;
|
||||
|
||||
/// <summary>Slot still inside server-authoritative cooldown window (NEO-32).</summary>
|
||||
public const string ReasonOnCooldown = "on_cooldown";
|
||||
|
||||
private static IResult JsonAbilityCast(
|
||||
string id,
|
||||
AbilityCastRequest body,
|
||||
AbilityCastResponse response,
|
||||
string? abilityIdForLog = null)
|
||||
{
|
||||
PrototypeApiConsoleLog.WriteAbilityCastLine(
|
||||
id,
|
||||
body.SlotIndex,
|
||||
abilityIdForLog ?? body.AbilityId,
|
||||
body.TargetId,
|
||||
response.Accepted,
|
||||
response.ReasonCode);
|
||||
return Results.Json(response);
|
||||
}
|
||||
|
||||
public static WebApplication MapAbilityCastApi(this WebApplication app)
|
||||
{
|
||||
app.MapPost(
|
||||
|
|
@ -27,18 +55,23 @@ public static class AbilityCastApi
|
|||
AbilityCastRequest? body,
|
||||
IPositionStateStore positions,
|
||||
IPlayerHotbarLoadoutStore store,
|
||||
IPlayerTargetLockStore locks) =>
|
||||
IPlayerTargetLockStore locks,
|
||||
IPlayerAbilityCooldownStore cooldowns,
|
||||
TimeProvider clock) =>
|
||||
{
|
||||
// NEO-30: not a Slice 3 `ability_cast_denied` site — no AbilityCastResponse body.
|
||||
if (body is null || body.SchemaVersion != AbilityCastRequest.CurrentSchemaVersion)
|
||||
{
|
||||
return Results.BadRequest();
|
||||
}
|
||||
|
||||
// NEO-30: not a Slice 3 `ability_cast_denied` site — unknown player for cast prototype.
|
||||
if (!positions.TryGetPosition(id, out var snap))
|
||||
{
|
||||
return Results.NotFound();
|
||||
}
|
||||
|
||||
// NEO-30: not a Slice 3 `ability_cast_denied` site — no persisted loadout row.
|
||||
if (!store.TryGetBindings(id, out var bindings))
|
||||
{
|
||||
return Results.NotFound();
|
||||
|
|
@ -46,7 +79,10 @@ public static class AbilityCastApi
|
|||
|
||||
if (body.SlotIndex < 0 || body.SlotIndex >= HotbarLoadoutResponse.SlotCountV1)
|
||||
{
|
||||
return Results.Json(
|
||||
// NEO-30 telemetry hook site: `ability_cast_denied` + reasonCode (TODO(E9.M1): catalog emit).
|
||||
return JsonAbilityCast(
|
||||
id,
|
||||
body,
|
||||
new AbilityCastResponse
|
||||
{
|
||||
Accepted = false,
|
||||
|
|
@ -57,7 +93,10 @@ public static class AbilityCastApi
|
|||
if (!bindings.TryGetValue(body.SlotIndex, out var boundAbility) ||
|
||||
string.IsNullOrWhiteSpace(boundAbility))
|
||||
{
|
||||
return Results.Json(
|
||||
// NEO-30 telemetry hook site: `ability_cast_denied` + reasonCode (TODO(E9.M1): catalog emit).
|
||||
return JsonAbilityCast(
|
||||
id,
|
||||
body,
|
||||
new AbilityCastResponse
|
||||
{
|
||||
Accepted = false,
|
||||
|
|
@ -68,7 +107,10 @@ public static class AbilityCastApi
|
|||
if (string.IsNullOrWhiteSpace(body.AbilityId) ||
|
||||
!PrototypeAbilityRegistry.TryNormalizeKnown(body.AbilityId, out var normalizedRequest))
|
||||
{
|
||||
return Results.Json(
|
||||
// NEO-30 telemetry hook site: `ability_cast_denied` + reasonCode (TODO(E9.M1): catalog emit).
|
||||
return JsonAbilityCast(
|
||||
id,
|
||||
body,
|
||||
new AbilityCastResponse
|
||||
{
|
||||
Accepted = false,
|
||||
|
|
@ -78,7 +120,10 @@ public static class AbilityCastApi
|
|||
|
||||
if (!string.Equals(normalizedRequest, boundAbility, StringComparison.Ordinal))
|
||||
{
|
||||
return Results.Json(
|
||||
// NEO-30 telemetry hook site: `ability_cast_denied` + reasonCode (TODO(E9.M1): catalog emit).
|
||||
return JsonAbilityCast(
|
||||
id,
|
||||
body,
|
||||
new AbilityCastResponse
|
||||
{
|
||||
Accepted = false,
|
||||
|
|
@ -88,14 +133,20 @@ public static class AbilityCastApi
|
|||
|
||||
if (string.IsNullOrWhiteSpace(body.TargetId))
|
||||
{
|
||||
return Results.Json(
|
||||
// NEO-30 telemetry hook site: `ability_cast_denied` + reasonCode (TODO(E9.M1): catalog emit).
|
||||
return JsonAbilityCast(
|
||||
id,
|
||||
body,
|
||||
new AbilityCastResponse { Accepted = false, ReasonCode = ReasonInvalidTarget });
|
||||
}
|
||||
|
||||
var lookupKey = body.TargetId.Trim().ToLowerInvariant();
|
||||
if (!PrototypeTargetRegistry.TryGet(lookupKey, out var entry))
|
||||
{
|
||||
return Results.Json(
|
||||
// NEO-30 telemetry hook site: `ability_cast_denied` + reasonCode (TODO(E9.M1): catalog emit).
|
||||
return JsonAbilityCast(
|
||||
id,
|
||||
body,
|
||||
new AbilityCastResponse { Accepted = false, ReasonCode = ReasonInvalidTarget });
|
||||
}
|
||||
|
||||
|
|
@ -103,17 +154,41 @@ public static class AbilityCastApi
|
|||
if (string.IsNullOrEmpty(lockIdLowercase) ||
|
||||
!string.Equals(lookupKey, lockIdLowercase, StringComparison.Ordinal))
|
||||
{
|
||||
return Results.Json(
|
||||
// NEO-30 telemetry hook site: `ability_cast_denied` + reasonCode (TODO(E9.M1): catalog emit).
|
||||
return JsonAbilityCast(
|
||||
id,
|
||||
body,
|
||||
new AbilityCastResponse { Accepted = false, ReasonCode = ReasonInvalidTarget });
|
||||
}
|
||||
|
||||
if (!HorizontalReach.IsWithinHorizontalRadius(snap.X, snap.Z, entry.X, entry.Z, entry.LockRadius))
|
||||
{
|
||||
return Results.Json(
|
||||
// NEO-30 telemetry hook site: `ability_cast_denied` + reasonCode (TODO(E9.M1): catalog emit).
|
||||
return JsonAbilityCast(
|
||||
id,
|
||||
body,
|
||||
new AbilityCastResponse { Accepted = false, ReasonCode = ReasonOutOfRange });
|
||||
}
|
||||
|
||||
return Results.Json(new AbilityCastResponse { Accepted = true });
|
||||
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 JsonAbilityCast(
|
||||
id,
|
||||
body,
|
||||
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).
|
||||
// Payload notes for E9.M1: player id = route `id`, body.SlotIndex, normalized ability id, body.TargetId (lock-aligned).
|
||||
return JsonAbilityCast(
|
||||
id,
|
||||
body,
|
||||
new AbilityCastResponse { Accepted = true },
|
||||
normalizedRequest);
|
||||
});
|
||||
|
||||
return app;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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, now, out var endUtc))
|
||||
{
|
||||
end = endUtc;
|
||||
}
|
||||
|
||||
slots.Add(new CooldownSlotSnapshotJson { SlotIndex = i, CooldownEndsAtUtc = end });
|
||||
}
|
||||
|
||||
return new CooldownSnapshotResponse
|
||||
{
|
||||
PlayerId = playerId,
|
||||
ServerTimeUtc = now,
|
||||
Slots = slots,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -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; }
|
||||
}
|
||||
|
|
@ -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 active cooldown end when <paramref name="now"/> is strictly before that end; removes expired entries (same hygiene as <see cref="IsOnCooldown"/>).</summary>
|
||||
bool TryGetCooldownEndUtc(string playerId, int slotIndex, DateTimeOffset now, out DateTimeOffset endUtc);
|
||||
}
|
||||
|
|
@ -0,0 +1,59 @@
|
|||
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)
|
||||
{
|
||||
return TryGetActiveCooldownEnd(playerId, slotIndex, now, out _);
|
||||
}
|
||||
|
||||
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, DateTimeOffset now, out DateTimeOffset endUtc)
|
||||
{
|
||||
return TryGetActiveCooldownEnd(playerId, slotIndex, now, out endUtc);
|
||||
}
|
||||
|
||||
/// <summary>Shared read path: returns true with <paramref name="endUtc"/> when cooldown is active; removes slot entry when <paramref name="now"/> >= stored end.</summary>
|
||||
private static bool TryGetActiveCooldownEnd(
|
||||
ConcurrentDictionary<string, ConcurrentDictionary<int, DateTimeOffset>> ends,
|
||||
string playerId,
|
||||
int slotIndex,
|
||||
DateTimeOffset now,
|
||||
out DateTimeOffset endUtc)
|
||||
{
|
||||
endUtc = default;
|
||||
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;
|
||||
}
|
||||
|
||||
endUtc = end;
|
||||
return true;
|
||||
}
|
||||
|
||||
private bool TryGetActiveCooldownEnd(string playerId, int slotIndex, DateTimeOffset now, out DateTimeOffset endUtc)
|
||||
{
|
||||
return TryGetActiveCooldownEnd(ends, playerId, slotIndex, now, out endUtc);
|
||||
}
|
||||
}
|
||||
|
|
@ -6,6 +6,7 @@ using NeonSprawl.Server.Game.Targeting;
|
|||
var builder = WebApplication.CreateBuilder(args);
|
||||
builder.Services.AddPositionStateStore(builder.Configuration);
|
||||
builder.Services.AddHotbarLoadoutStore(builder.Configuration);
|
||||
builder.Services.AddAbilityCooldownStore();
|
||||
builder.Services.AddSingleton<IPlayerTargetLockStore, InMemoryPlayerTargetLockStore>();
|
||||
|
||||
var app = builder.Build();
|
||||
|
|
@ -25,6 +26,7 @@ app.MapInteractionApi();
|
|||
app.MapInteractablesWorldApi();
|
||||
app.MapTargetingApi();
|
||||
app.MapHotbarLoadoutApi();
|
||||
app.MapCooldownSnapshotApi();
|
||||
app.MapAbilityCastApi();
|
||||
|
||||
app.Run();
|
||||
|
|
|
|||
|
|
@ -18,6 +18,16 @@
|
|||
"ASPNETCORE_ENVIRONMENT": "Development"
|
||||
}
|
||||
},
|
||||
"http+apiLog": {
|
||||
"commandName": "Project",
|
||||
"dotnetRunMessages": true,
|
||||
"launchBrowser": true,
|
||||
"applicationUrl": "http://localhost:5253",
|
||||
"environmentVariables": {
|
||||
"ASPNETCORE_ENVIRONMENT": "Development",
|
||||
"NEON_SPRAWL_API_LOG": "1"
|
||||
}
|
||||
},
|
||||
"IIS Express": {
|
||||
"commandName": "IISExpress",
|
||||
"launchBrowser": true,
|
||||
|
|
|
|||
|
|
@ -16,6 +16,14 @@ dotnet run
|
|||
- Default URL is printed by Kestrel (see `Properties/launchSettings.json`, typically `http://localhost:5253`).
|
||||
- Check `GET /health` for a JSON heartbeat.
|
||||
|
||||
**Optional prototype API stdout (manual QA / dev):** set **`NEON_SPRAWL_API_LOG`** to **`1`**, **`true`**, **`yes`**, or **`on`** (case-insensitive). While enabled, each **`POST …/ability-cast`** that returns JSON **`AbilityCastResponse`** (HTTP 200) prints one line to the process console: either **`ability_cast_requested`** (accept) or **`ability_cast_denied`** with **`reason=`** (deny). **`400`** / **`404`** paths do not emit these lines. Implementation: `Diagnostics/PrototypeApiConsoleLog.cs` (wired from `AbilityCastApi`). Example:
|
||||
|
||||
```bash
|
||||
NEON_SPRAWL_API_LOG=1 dotnet run --project server/NeonSprawl.Server/NeonSprawl.Server.csproj
|
||||
```
|
||||
|
||||
IDE: use launch profile **`http+apiLog`** (same URL as **`http`**, sets the variable).
|
||||
|
||||
## Position persistence (NEO-8)
|
||||
|
||||
When **`ConnectionStrings:NeonSprawl`** is set to a valid PostgreSQL connection string, the server uses the **`player_position`** table (relational columns `pos_x` … `sequence`, not JSONB). DDL lives in [`server/db/migrations/V001__player_position.sql`](../db/migrations/V001__player_position.sql); the app applies that script on startup and on first store use (idempotent `CREATE TABLE IF NOT EXISTS`). The configured dev player (`Game:DevPlayerId` / `Game:DefaultPosition`) is seeded once at host startup, matching the in-memory store’s constructor seed (not on every HTTP request).
|
||||
|
|
@ -227,7 +235,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`**.
|
||||
|
||||
- **`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:**
|
||||
|
||||
|
|
@ -247,8 +255,19 @@ Prototype **cast intent** (no full combat resolution — see E5.M1). **NEO-28**
|
|||
| `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). |
|
||||
| `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`. Optional **Slice 3–named** one-line stdout per JSON response when **`NEON_SPRAWL_API_LOG`** is set (see **Optional prototype API stdout** under **Run** above).
|
||||
|
||||
## 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).
|
||||
|
||||
## Solution
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue