NEO-29: add HotbarLoadout v1 contract and hydration path
Ship a server-owned hotbar loadout API with Postgres-or-memory persistence so slot bindings survive reconnects in configured environments. Wire client boot hydration and add tests/docs/manual QA to keep the prototype contract and policy explicit.pull/54/head
parent
09e160e955
commit
acde9e8145
|
|
@ -117,6 +117,16 @@ Full checklist: [`docs/manual-qa/NEO-25.md`](../manual-qa/NEO-25.md).
|
||||||
|
|
||||||
Full checklist: [`docs/manual-qa/NEO-26.md`](../docs/manual-qa/NEO-26.md).
|
Full checklist: [`docs/manual-qa/NEO-26.md`](../docs/manual-qa/NEO-26.md).
|
||||||
|
|
||||||
|
## Hotbar loadout hydration (NEO-29)
|
||||||
|
|
||||||
|
`scripts/hotbar_loadout_client.gd` fetches server-owned `HotbarLoadout` state and applies it to `scripts/hotbar_state.gd`:
|
||||||
|
|
||||||
|
- On `main.gd` startup, `_setup_hotbar_loadout_sync()` creates `HotbarState` + `HotbarLoadoutClient` nodes, mirrors `base_url`/`dev_player_id` from `PositionAuthorityClient`, and calls `request_sync_from_server()`.
|
||||||
|
- `HotbarState` keeps a fixed **8-slot** local mirror (`Array` indexed `0..7`) with nullable ability ids.
|
||||||
|
- `HotbarLoadoutClient.request_bind_slot(slot_index, ability_id)` POSTs one slot update to `/game/players/{id}/hotbar-loadout`; denials emit `loadout_update_denied(reason_code, loadout)` so future UI can surface machine-readable errors directly.
|
||||||
|
|
||||||
|
This story hydrates bindings only; cast execution and cooldown behavior stay in later E1.M4 / E5.M1 stories.
|
||||||
|
|
||||||
### Manual check (NEO-24)
|
### Manual check (NEO-24)
|
||||||
|
|
||||||
1. Run the server and client as in NEO-7 / NEO-9; confirm spawn snap at `(-5, 0.9, -5)`.
|
1. Run the server and client as in NEO-7 / NEO-9; confirm spawn snap at `(-5, 0.9, -5)`.
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,112 @@
|
||||||
|
extends Node
|
||||||
|
|
||||||
|
## NEO-29: client bridge for server-owned `HotbarLoadout` v1 fetch/update.
|
||||||
|
signal loadout_changed(loadout: Dictionary)
|
||||||
|
signal loadout_update_denied(reason_code: String, loadout: 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
|
||||||
|
var _last_loadout: Dictionary = {}
|
||||||
|
var _hotbar_state: Node = null
|
||||||
|
|
||||||
|
|
||||||
|
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 set_hotbar_state(state_node: Node) -> void:
|
||||||
|
_hotbar_state = state_node
|
||||||
|
|
||||||
|
|
||||||
|
func request_sync_from_server() -> void:
|
||||||
|
if _busy:
|
||||||
|
return
|
||||||
|
_busy = true
|
||||||
|
var url := "%s/game/players/%s/hotbar-loadout" % [_base_root(), _player_path_segment()]
|
||||||
|
var err: Error = _http.request(url)
|
||||||
|
if err != OK:
|
||||||
|
push_warning("HotbarLoadoutClient: GET failed to start (%s)" % err)
|
||||||
|
_busy = false
|
||||||
|
|
||||||
|
|
||||||
|
func request_bind_slot(slot_index: int, ability_id: String) -> void:
|
||||||
|
if _busy:
|
||||||
|
return
|
||||||
|
var payload: Dictionary = {
|
||||||
|
"schemaVersion": 1,
|
||||||
|
"slots": [{"slotIndex": slot_index, "abilityId": ability_id.strip_edges()}],
|
||||||
|
}
|
||||||
|
_post_update(payload)
|
||||||
|
|
||||||
|
|
||||||
|
func cached_loadout() -> Dictionary:
|
||||||
|
return _last_loadout.duplicate(true)
|
||||||
|
|
||||||
|
|
||||||
|
func _post_update(payload: Dictionary) -> void:
|
||||||
|
_busy = true
|
||||||
|
var url := "%s/game/players/%s/hotbar-loadout" % [_base_root(), _player_path_segment()]
|
||||||
|
var headers := PackedStringArray(["Content-Type: application/json"])
|
||||||
|
var err: Error = _http.request(url, headers, HTTPClient.METHOD_POST, JSON.stringify(payload))
|
||||||
|
if err != OK:
|
||||||
|
push_warning("HotbarLoadoutClient: POST failed to start (%s)" % err)
|
||||||
|
_busy = false
|
||||||
|
|
||||||
|
|
||||||
|
func _base_root() -> String:
|
||||||
|
return base_url.strip_edges().rstrip("/")
|
||||||
|
|
||||||
|
|
||||||
|
func _player_path_segment() -> String:
|
||||||
|
return dev_player_id.strip_edges()
|
||||||
|
|
||||||
|
|
||||||
|
func _on_request_completed(
|
||||||
|
result: int, response_code: int, _headers: PackedStringArray, body: PackedByteArray
|
||||||
|
) -> void:
|
||||||
|
_busy = false
|
||||||
|
if result != HTTPRequest.RESULT_SUCCESS:
|
||||||
|
push_warning("HotbarLoadoutClient: HTTP failed (result=%s)" % result)
|
||||||
|
return
|
||||||
|
var text := body.get_string_from_utf8()
|
||||||
|
var parsed: Variant = JSON.parse_string(text)
|
||||||
|
if not parsed is Dictionary:
|
||||||
|
if response_code != 200:
|
||||||
|
push_warning("HotbarLoadoutClient: HTTP %s non-JSON body" % response_code)
|
||||||
|
return
|
||||||
|
var data: Dictionary = parsed
|
||||||
|
var loadout: Dictionary = data
|
||||||
|
if data.has("loadout") and data["loadout"] is Dictionary:
|
||||||
|
loadout = data["loadout"]
|
||||||
|
if loadout.is_empty() or not loadout.has("slots"):
|
||||||
|
return
|
||||||
|
_last_loadout = loadout.duplicate(true)
|
||||||
|
_apply_state(loadout)
|
||||||
|
loadout_changed.emit(_last_loadout.duplicate(true))
|
||||||
|
if data.has("updated") and not bool(data.get("updated", false)):
|
||||||
|
var reason_variant: Variant = data.get("reasonCode", "")
|
||||||
|
var reason: String = reason_variant as String if reason_variant is String else ""
|
||||||
|
if not reason.is_empty():
|
||||||
|
loadout_update_denied.emit(reason, _last_loadout.duplicate(true))
|
||||||
|
|
||||||
|
|
||||||
|
func _apply_state(loadout: Dictionary) -> void:
|
||||||
|
if _hotbar_state == null or not is_instance_valid(_hotbar_state):
|
||||||
|
return
|
||||||
|
if not _hotbar_state.has_method("apply_slots"):
|
||||||
|
return
|
||||||
|
var slots_variant: Variant = loadout.get("slots", [])
|
||||||
|
if slots_variant is Array:
|
||||||
|
_hotbar_state.call("apply_slots", slots_variant as Array)
|
||||||
|
|
@ -0,0 +1,41 @@
|
||||||
|
extends Node
|
||||||
|
|
||||||
|
## Local hotbar slot state mirror for NEO-29. Holds slot -> ability mapping from
|
||||||
|
## server-owned `HotbarLoadout` payloads.
|
||||||
|
signal hotbar_changed(slots: Array)
|
||||||
|
|
||||||
|
const SLOT_COUNT: int = 8
|
||||||
|
|
||||||
|
var _slots: Array = []
|
||||||
|
|
||||||
|
|
||||||
|
func _ready() -> void:
|
||||||
|
if _slots.is_empty():
|
||||||
|
_slots.resize(SLOT_COUNT)
|
||||||
|
for i in range(SLOT_COUNT):
|
||||||
|
_slots[i] = null
|
||||||
|
|
||||||
|
|
||||||
|
func apply_slots(slots: Array) -> void:
|
||||||
|
var normalized: Array = []
|
||||||
|
normalized.resize(SLOT_COUNT)
|
||||||
|
for i in range(SLOT_COUNT):
|
||||||
|
normalized[i] = null
|
||||||
|
for slot_variant in slots:
|
||||||
|
if not slot_variant is Dictionary:
|
||||||
|
continue
|
||||||
|
var slot: Dictionary = slot_variant
|
||||||
|
var idx: int = int(slot.get("slotIndex", -1))
|
||||||
|
if idx < 0 or idx >= SLOT_COUNT:
|
||||||
|
continue
|
||||||
|
var ability_variant: Variant = slot.get("abilityId", null)
|
||||||
|
if ability_variant is String and not (ability_variant as String).is_empty():
|
||||||
|
normalized[idx] = ability_variant as String
|
||||||
|
else:
|
||||||
|
normalized[idx] = null
|
||||||
|
_slots = normalized
|
||||||
|
hotbar_changed.emit(_slots.duplicate())
|
||||||
|
|
||||||
|
|
||||||
|
func slots_snapshot() -> Array:
|
||||||
|
return _slots.duplicate()
|
||||||
|
|
@ -79,6 +79,8 @@ var _dev_obstacle_smoke: Node3D
|
||||||
@onready var _target_client: Node = $TargetSelectionClient
|
@onready var _target_client: Node = $TargetSelectionClient
|
||||||
@onready var _interaction_client: Node = $InteractionRequestClient
|
@onready var _interaction_client: Node = $InteractionRequestClient
|
||||||
@onready var _floor: StaticBody3D = $World/NavigationRegion3D/Floor
|
@onready var _floor: StaticBody3D = $World/NavigationRegion3D/Floor
|
||||||
|
var _hotbar_state: Node = null
|
||||||
|
var _hotbar_client: Node = null
|
||||||
|
|
||||||
|
|
||||||
func _ready() -> void:
|
func _ready() -> void:
|
||||||
|
|
@ -116,6 +118,7 @@ func _ready() -> void:
|
||||||
_catalog.call("request_catalog")
|
_catalog.call("request_catalog")
|
||||||
_authority.call("sync_from_server")
|
_authority.call("sync_from_server")
|
||||||
_target_client.call("request_sync_from_server")
|
_target_client.call("request_sync_from_server")
|
||||||
|
_setup_hotbar_loadout_sync()
|
||||||
|
|
||||||
|
|
||||||
func _physics_process(_delta: float) -> void:
|
func _physics_process(_delta: float) -> void:
|
||||||
|
|
@ -221,6 +224,27 @@ func _on_target_state_changed(state: Dictionary) -> void:
|
||||||
_render_target_lock_label(_player.global_position)
|
_render_target_lock_label(_player.global_position)
|
||||||
|
|
||||||
|
|
||||||
|
func _setup_hotbar_loadout_sync() -> void:
|
||||||
|
# NEO-29: hydrate a local hotbar state mirror from server-owned HotbarLoadout on boot.
|
||||||
|
_hotbar_state = load("res://scripts/hotbar_state.gd").new()
|
||||||
|
_hotbar_state.name = "HotbarState"
|
||||||
|
add_child(_hotbar_state)
|
||||||
|
_hotbar_client = load("res://scripts/hotbar_loadout_client.gd").new()
|
||||||
|
_hotbar_client.name = "HotbarLoadoutClient"
|
||||||
|
add_child(_hotbar_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():
|
||||||
|
_hotbar_client.set("base_url", authority_base_url)
|
||||||
|
if authority_player_id is String and not (authority_player_id as String).is_empty():
|
||||||
|
_hotbar_client.set("dev_player_id", authority_player_id)
|
||||||
|
if _hotbar_client.has_method("set_hotbar_state"):
|
||||||
|
_hotbar_client.call("set_hotbar_state", _hotbar_state)
|
||||||
|
if _hotbar_client.has_method("request_sync_from_server"):
|
||||||
|
_hotbar_client.call("request_sync_from_server")
|
||||||
|
|
||||||
|
|
||||||
## Combines cached server state (`_last_target_state`) with per-anchor horizontal
|
## Combines cached server state (`_last_target_state`) with per-anchor horizontal
|
||||||
## distance computed from the live capsule position. Distances are a **display-only**
|
## distance computed from the live capsule position. Distances are a **display-only**
|
||||||
## hint — the server remains authoritative for `validity`. If the HUD shows
|
## hint — the server remains authoritative for `validity`. If the HUD shows
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,109 @@
|
||||||
|
extends GdUnitTestSuite
|
||||||
|
|
||||||
|
const HotbarClient := preload("res://scripts/hotbar_loadout_client.gd")
|
||||||
|
const HotbarState := preload("res://scripts/hotbar_state.gd")
|
||||||
|
|
||||||
|
|
||||||
|
class MockHttpTransport:
|
||||||
|
extends Node
|
||||||
|
signal request_completed(
|
||||||
|
result: int, response_code: int, headers: PackedStringArray, body: PackedByteArray
|
||||||
|
)
|
||||||
|
var last_url: String = ""
|
||||||
|
var last_body: String = ""
|
||||||
|
var last_method: HTTPClient.Method = HTTPClient.METHOD_GET
|
||||||
|
var _queue: Array[Dictionary] = []
|
||||||
|
|
||||||
|
func enqueue(result: int, code: int, body: String) -> void:
|
||||||
|
_queue.append({"result": result, "code": code, "body": body})
|
||||||
|
|
||||||
|
func request(
|
||||||
|
url: String,
|
||||||
|
_custom_headers: PackedStringArray = PackedStringArray(),
|
||||||
|
method: HTTPClient.Method = HTTPClient.METHOD_GET,
|
||||||
|
request_data: String = ""
|
||||||
|
) -> Error:
|
||||||
|
last_url = url
|
||||||
|
last_method = method
|
||||||
|
last_body = request_data
|
||||||
|
if _queue.is_empty():
|
||||||
|
return ERR_UNAVAILABLE
|
||||||
|
var r: Dictionary = _queue.pop_front()
|
||||||
|
request_completed.emit(
|
||||||
|
r["result"], r["code"], PackedStringArray(), str(r["body"]).to_utf8_buffer()
|
||||||
|
)
|
||||||
|
return OK
|
||||||
|
|
||||||
|
|
||||||
|
func _make_client(transport: Node) -> Node:
|
||||||
|
var c: Node = HotbarClient.new()
|
||||||
|
c.set("injected_http", transport)
|
||||||
|
auto_free(transport)
|
||||||
|
auto_free(c)
|
||||||
|
add_child(c)
|
||||||
|
return c
|
||||||
|
|
||||||
|
|
||||||
|
func _sample_slots_json() -> String:
|
||||||
|
return (
|
||||||
|
'{"schemaVersion":1,"playerId":"dev-local-1","slotCount":8,"slots":['
|
||||||
|
+ '{"slotIndex":0,"abilityId":"prototype_pulse"},'
|
||||||
|
+ '{"slotIndex":1,"abilityId":null},'
|
||||||
|
+ '{"slotIndex":2,"abilityId":"prototype_guard"},'
|
||||||
|
+ '{"slotIndex":3,"abilityId":null},'
|
||||||
|
+ '{"slotIndex":4,"abilityId":null},'
|
||||||
|
+ '{"slotIndex":5,"abilityId":null},'
|
||||||
|
+ '{"slotIndex":6,"abilityId":null},'
|
||||||
|
+ '{"slotIndex":7,"abilityId":null}'
|
||||||
|
+ "]}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
func test_sync_get_applies_slots_to_hotbar_state() -> void:
|
||||||
|
var transport := MockHttpTransport.new()
|
||||||
|
transport.enqueue(HTTPRequest.RESULT_SUCCESS, 200, _sample_slots_json())
|
||||||
|
var c := _make_client(transport)
|
||||||
|
var state := HotbarState.new()
|
||||||
|
auto_free(state)
|
||||||
|
add_child(state)
|
||||||
|
c.call("set_hotbar_state", state)
|
||||||
|
c.call("request_sync_from_server")
|
||||||
|
var slots: Array = state.call("slots_snapshot")
|
||||||
|
assert_that(slots.size()).is_equal(8)
|
||||||
|
assert_that(slots[0]).is_equal("prototype_pulse")
|
||||||
|
assert_that(slots[2]).is_equal("prototype_guard")
|
||||||
|
assert_that(slots[1]).is_null()
|
||||||
|
|
||||||
|
|
||||||
|
func test_bind_slot_posts_expected_payload() -> void:
|
||||||
|
var transport := MockHttpTransport.new()
|
||||||
|
transport.enqueue(
|
||||||
|
HTTPRequest.RESULT_SUCCESS,
|
||||||
|
200,
|
||||||
|
'{"schemaVersion":1,"updated":true,"loadout":{"schemaVersion":1,"playerId":"dev-local-1","slotCount":8,"slots":[]}}'
|
||||||
|
)
|
||||||
|
var c := _make_client(transport)
|
||||||
|
c.call("request_bind_slot", 3, "prototype_burst")
|
||||||
|
assert_that(transport.last_url).contains("/hotbar-loadout")
|
||||||
|
assert_that(transport.last_method).is_equal(HTTPClient.METHOD_POST)
|
||||||
|
var parsed: Variant = JSON.parse_string(transport.last_body)
|
||||||
|
assert_that(parsed is Dictionary).is_true()
|
||||||
|
var body: Dictionary = parsed
|
||||||
|
assert_that(int(body.get("schemaVersion", 0))).is_equal(1)
|
||||||
|
var slots: Array = body.get("slots", [])
|
||||||
|
assert_that(slots.size()).is_equal(1)
|
||||||
|
assert_that(int((slots[0] as Dictionary).get("slotIndex", -1))).is_equal(3)
|
||||||
|
assert_that((slots[0] as Dictionary).get("abilityId")).is_equal("prototype_burst")
|
||||||
|
|
||||||
|
|
||||||
|
func test_denied_update_emits_reason_signal() -> void:
|
||||||
|
var transport := MockHttpTransport.new()
|
||||||
|
transport.enqueue(
|
||||||
|
HTTPRequest.RESULT_SUCCESS,
|
||||||
|
200,
|
||||||
|
'{"schemaVersion":1,"updated":false,"reasonCode":"unknown_ability","loadout":{"schemaVersion":1,"playerId":"dev-local-1","slotCount":8,"slots":[]}}'
|
||||||
|
)
|
||||||
|
var c := _make_client(transport)
|
||||||
|
monitor_signals(c)
|
||||||
|
c.call("request_bind_slot", 1, "not_real")
|
||||||
|
assert_signal(c).is_emitted("loadout_update_denied")
|
||||||
|
|
@ -42,7 +42,8 @@ Epic 1 **Slice 3** — `ability_cast_requested`, `ability_cast_denied` with reas
|
||||||
|
|
||||||
## Implementation snapshot
|
## Implementation snapshot
|
||||||
|
|
||||||
- **Current state:** decomposition and story scaffolding only; implementation has not started for `HotbarLoadout`, `AbilityCastRequest`, or `CooldownSnapshot` contract wiring.
|
- **Current state:** NEO-29 landed prototype `HotbarLoadout` v1 contract and hydration path (`GET`/`POST /game/players/{id}/hotbar-loadout`, fixed 8 slots, deny reason codes). `AbilityCastRequest` and `CooldownSnapshot` wiring are still pending.
|
||||||
|
- **Prototype persistence policy (NEO-29):** per-player loadout key; Postgres when `ConnectionStrings:NeonSprawl` exists, in-memory fallback otherwise.
|
||||||
- **Prototype backlog:** [E1M4 prototype backlog](../../plans/E1M4-prototype-backlog.md) defines five vertical slices from hotbar loadout contract to cast telemetry hooks.
|
- **Prototype backlog:** [E1M4 prototype backlog](../../plans/E1M4-prototype-backlog.md) defines five vertical slices from hotbar loadout contract to cast telemetry hooks.
|
||||||
- **Dependency expectation:** first implementation stories assume E1.M3 target selection flow exists and E5.M1 provides minimum cast accept/deny contract.
|
- **Dependency expectation:** first implementation stories assume E1.M3 target selection flow exists and E5.M1 provides minimum cast accept/deny contract.
|
||||||
|
|
||||||
|
|
@ -54,7 +55,7 @@ Parent epic: [Epic 1 — Core Player Runtime](https://linear.app/neon-sprawl/pro
|
||||||
|
|
||||||
| Slug | Linear |
|
| Slug | Linear |
|
||||||
|------|--------|
|
|------|--------|
|
||||||
| **E1M4-01** | TBD — `HotbarLoadout` v1 contract + baseline persistence path |
|
| **E1M4-01** | [NEO-29](https://linear.app/neon-sprawl/issue/NEO-29/e1m4-01-hotbarloadout-v1-contract-baseline-persistence-path) — `HotbarLoadout` v1 contract + baseline persistence path |
|
||||||
| **E1M4-02** | TBD — Input to `AbilityCastRequest` path |
|
| **E1M4-02** | TBD — Input to `AbilityCastRequest` path |
|
||||||
| **E1M4-03** | TBD — Combat accept/deny integration + reason-code UX |
|
| **E1M4-03** | TBD — Combat accept/deny integration + reason-code UX |
|
||||||
| **E1M4-04** | TBD — `CooldownSnapshot` sync + slot presentation |
|
| **E1M4-04** | TBD — `CooldownSnapshot` sync + slot presentation |
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,38 @@
|
||||||
|
# NEO-29 — Manual QA checklist
|
||||||
|
|
||||||
|
| Field | Value |
|
||||||
|
|-------|-------|
|
||||||
|
| Key | NEO-29 |
|
||||||
|
| Title | E1M4-01: HotbarLoadout v1 contract + baseline persistence path |
|
||||||
|
| Linear | https://linear.app/neon-sprawl/issue/NEO-29/e1m4-01-hotbarloadout-v1-contract-baseline-persistence-path |
|
||||||
|
| Plan | `docs/plans/NEO-29-implementation-plan.md` |
|
||||||
|
| Branch | `NEO-29-hotbarloadout-v1-contract-baseline-persistence-path` |
|
||||||
|
|
||||||
|
## 1) Setup sanity
|
||||||
|
|
||||||
|
- [ ] Start server: `cd server/NeonSprawl.Server && dotnet run`.
|
||||||
|
- [ ] Confirm health endpoint responds: `curl -s http://localhost:5253/health`.
|
||||||
|
- [ ] Confirm baseline loadout for dev player is reachable: `curl -s http://localhost:5253/game/players/dev-local-1/hotbar-loadout`.
|
||||||
|
|
||||||
|
## 2) Happy path (bind and re-read)
|
||||||
|
|
||||||
|
- [ ] Bind slot 0 + slot 3 in one request:
|
||||||
|
`curl -s -X POST http://localhost:5253/game/players/dev-local-1/hotbar-loadout -H "Content-Type: application/json" -d '{"schemaVersion":1,"slots":[{"slotIndex":0,"abilityId":"prototype_pulse"},{"slotIndex":3,"abilityId":"prototype_burst"}]}'`
|
||||||
|
- [ ] Verify response contains `updated=true` and `loadout.slots[0].abilityId == "prototype_pulse"` and `loadout.slots[3].abilityId == "prototype_burst"`.
|
||||||
|
- [ ] Run GET again and verify the same two bindings are still present.
|
||||||
|
|
||||||
|
## 3) Reconnect / persistence behavior
|
||||||
|
|
||||||
|
- [ ] If Postgres is configured (`ConnectionStrings__NeonSprawl` set), stop and restart the server, then GET loadout and verify slot 0/3 bindings survived restart.
|
||||||
|
- [ ] If Postgres is not configured (in-memory fallback), verify loadout behavior in the same process is stable and document that restart reset is expected for this mode.
|
||||||
|
|
||||||
|
## 4) Deny paths and reason codes
|
||||||
|
|
||||||
|
- [ ] Send out-of-bounds slot (`slotIndex: 8`) and confirm response has `updated=false` with `reasonCode == "slot_out_of_bounds"`.
|
||||||
|
- [ ] Send unknown ability id (`abilityId: "not_real"`) and confirm response has `updated=false` with `reasonCode == "unknown_ability"`.
|
||||||
|
- [ ] Send duplicate slot index entries in one POST and confirm response has `updated=false` with `reasonCode == "duplicate_slot"`.
|
||||||
|
|
||||||
|
## 5) Client hydration sanity
|
||||||
|
|
||||||
|
- [ ] Run `client/scenes/main.tscn` in Godot after slot bindings exist on server.
|
||||||
|
- [ ] Confirm no startup warnings from `HotbarLoadoutClient` and verify `HotbarState` node exists under the scene root with 8-slot mirror populated from server response.
|
||||||
|
|
@ -53,6 +53,8 @@ Working backlog for **Epic 1 — Slice 3** ([interaction, targeting, ability inp
|
||||||
- [ ] Invalid slot/ability input is rejected with a stable machine-readable reason code.
|
- [ ] Invalid slot/ability input is rejected with a stable machine-readable reason code.
|
||||||
- [ ] Module docs and per-story implementation plan describe the chosen prototype persistence policy.
|
- [ ] Module docs and per-story implementation plan describe the chosen prototype persistence policy.
|
||||||
|
|
||||||
|
**Implementation note (NEO-29):** Prototype policy is **per-player** loadout key, persisted via **Postgres when configured** with **in-memory fallback**.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### E1M4-02 — Input to `AbilityCastRequest` path (hotbar activation)
|
### E1M4-02 — Input to `AbilityCastRequest` path (hotbar activation)
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,119 @@
|
||||||
|
# NEO-29 — Implementation plan
|
||||||
|
|
||||||
|
## Story reference
|
||||||
|
|
||||||
|
| Field | Value |
|
||||||
|
|--------|--------|
|
||||||
|
| **Key** | NEO-29 |
|
||||||
|
| **Title** | E1M4-01: HotbarLoadout v1 contract + baseline persistence path |
|
||||||
|
| **Linear** | [NEO-29](https://linear.app/neon-sprawl/issue/NEO-29/e1m4-01-hotbarloadout-v1-contract-baseline-persistence-path) |
|
||||||
|
| **Slug** | E1M4-01 |
|
||||||
|
| **Git branch** | `NEO-29-hotbarloadout-v1-contract-baseline-persistence-path` |
|
||||||
|
| **Parent context** | [Epic 1 — Core Player Runtime](https://linear.app/neon-sprawl/project/epic-1-core-player-runtime-client-controls-character-loop-66bd590cd016) · [E1M4 prototype backlog](E1M4-prototype-backlog.md) |
|
||||||
|
| **Decomposition** | [E1.M4 — AbilityInputScaffold](../decomposition/modules/E1_M4_AbilityInputScaffold.md), [E1.M3 — InteractionAndTargetingLayer](../decomposition/modules/E1_M3_InteractionAndTargetingLayer.md), [E5.M1 — CombatRulesEngine](../decomposition/modules/E5_M1_CombatRulesEngine.md) |
|
||||||
|
|
||||||
|
## Kickoff clarifications
|
||||||
|
|
||||||
|
- **Q1 (blocking):** What should prototype `HotbarLoadout` scope be keyed to (player, character, or session)?
|
||||||
|
- **Answer:** **Per player id** for the prototype.
|
||||||
|
- **Q2 (blocking):** What baseline persistence backend should NEO-29 use?
|
||||||
|
- **Answer:** Mirror position persistence pattern: use **Postgres when `ConnectionStrings:NeonSprawl` exists**, otherwise **in-memory fallback**.
|
||||||
|
- **Q3 (blocking):** What fixed slot count should v1 expose?
|
||||||
|
- **Answer:** **8 slots**.
|
||||||
|
|
||||||
|
## Goal, scope, and out-of-scope
|
||||||
|
|
||||||
|
**Goal:** Define and ship a prototype `HotbarLoadout` v1 read/write contract with server-owned validation and persistence, then hydrate client hotbar state from that server contract on boot/reconnect.
|
||||||
|
|
||||||
|
**In scope**
|
||||||
|
|
||||||
|
- New server API surface for loadout fetch + update with explicit `slotIndex` and `abilityId`.
|
||||||
|
- Validation denies for out-of-bounds slots and unknown ability ids with stable machine-readable `reasonCode`.
|
||||||
|
- Prototype persistence policy documentation and implementation: per-player loadout, Postgres when configured, in-memory fallback otherwise.
|
||||||
|
- Client bootstrap path to fetch and apply hotbar loadout state.
|
||||||
|
|
||||||
|
**Out of scope**
|
||||||
|
|
||||||
|
- Final account-vs-character policy hardening beyond this prototype decision.
|
||||||
|
- Cast execution, cooldown timing, and full combat authorization flow (follow-up stories in E1.M4 / E5.M1).
|
||||||
|
|
||||||
|
## Acceptance criteria checklist
|
||||||
|
|
||||||
|
- [x] A player can bind at least two slots and retrieve the same bindings after reconnect/reload.
|
||||||
|
- [x] Invalid slot/ability input is rejected with a stable machine-readable reason code.
|
||||||
|
- [x] Module docs and this plan describe the chosen prototype persistence policy.
|
||||||
|
|
||||||
|
## Technical approach
|
||||||
|
|
||||||
|
1. Add a dedicated `HotbarLoadout` v1 API to the server with `GET` (current loadout) and `POST` (replace/update slots) routes under `/game/players/{id}/hotbar-loadout`.
|
||||||
|
2. Keep the response shape versioned (`schemaVersion`) and explicit per-slot entries (`slotIndex`, `abilityId`) so E1.M4 follow-up stories can extend without breaking the v1 envelope.
|
||||||
|
3. Validate slot index range against fixed v1 slot count (**8**) and validate `abilityId` against a prototype server-side registry/list; return deterministic `reasonCode` values on deny (for example `slot_out_of_bounds`, `unknown_ability`).
|
||||||
|
4. Mirror `PositionState` persistence wiring: add an abstraction with in-memory and Postgres implementations selected from configuration, so reconnect/reload behavior is stable in both local-no-db and db-backed runs.
|
||||||
|
5. Hydrate the client hotbar model on startup/reconnect by requesting the loadout from server, then applying slot->ability bindings to local state without introducing cast execution behavior in this story.
|
||||||
|
6. Update decomposition/module docs and server/client README notes so the prototype persistence choice (per-player + Postgres-or-memory) is explicit and discoverable.
|
||||||
|
|
||||||
|
## Files to add
|
||||||
|
|
||||||
|
| Path | Rationale |
|
||||||
|
|------|-----------|
|
||||||
|
| `server/NeonSprawl.Server/Game/AbilityInput/HotbarLoadoutDtos.cs` | New v1 DTO contracts for loadout read/write API payloads. |
|
||||||
|
| `server/NeonSprawl.Server/Game/AbilityInput/HotbarLoadoutApi.cs` | New route mapping and validation/deny response logic for loadout endpoints. |
|
||||||
|
| `server/NeonSprawl.Server/Game/AbilityInput/IPlayerHotbarLoadoutStore.cs` | Persistence abstraction to mirror existing position store pattern. |
|
||||||
|
| `server/NeonSprawl.Server/Game/AbilityInput/InMemoryPlayerHotbarLoadoutStore.cs` | In-memory fallback implementation for prototype/dev without DB. |
|
||||||
|
| `server/NeonSprawl.Server/Game/AbilityInput/PostgresPlayerHotbarLoadoutStore.cs` | Postgres-backed persistence path when connection string is configured. |
|
||||||
|
| `server/NeonSprawl.Server/Game/AbilityInput/PostgresHotbarLoadoutBootstrap.cs` | One-time migration bootstrap for hotbar table DDL. |
|
||||||
|
| `server/NeonSprawl.Server/Game/AbilityInput/HotbarLoadoutServiceCollectionExtensions.cs` | DI registration and backend selection wiring. |
|
||||||
|
| `server/NeonSprawl.Server/Game/AbilityInput/PrototypeAbilityRegistry.cs` | Canonical prototype ability-id allowlist for validation. |
|
||||||
|
| `server/db/migrations/V002__player_hotbar_loadout.sql` | Postgres schema for per-player slot bindings persistence. |
|
||||||
|
| `server/NeonSprawl.Server.Tests/Game/AbilityInput/HotbarLoadoutApiTests.cs` | Server contract + validation tests for loadout endpoints. |
|
||||||
|
| `server/NeonSprawl.Server.Tests/Game/AbilityInput/HotbarLoadoutPersistenceIntegrationTests.cs` | Postgres persistence survives process restart for bound slots. |
|
||||||
|
| `client/scripts/hotbar_loadout_client.gd` | Client HTTP wrapper for fetch/update loadout API. |
|
||||||
|
| `client/scripts/hotbar_state.gd` | Client-side hotbar state holder that applies hydrated slot bindings. |
|
||||||
|
| `client/test/hotbar_loadout_client_test.gd` | Client tests for hydration/apply and update request behavior. |
|
||||||
|
| `docs/manual-qa/NEO-29.md` | Story-specific manual checklist for bind/reconnect/deny validation. |
|
||||||
|
|
||||||
|
## Files to modify
|
||||||
|
|
||||||
|
| Path | Rationale |
|
||||||
|
|------|-----------|
|
||||||
|
| `server/NeonSprawl.Server/Program.cs` | Register hotbar loadout store/services and map new API routes. |
|
||||||
|
| `server/NeonSprawl.Server.Tests/InMemoryWebApplicationFactory.cs` | Keep in-memory test harness forcing both position and hotbar stores in CI/local. |
|
||||||
|
| `server/README.md` | Document new loadout endpoints and prototype persistence behavior. |
|
||||||
|
| `client/scripts/main.gd` | Wire initial hotbar hydration call on boot/reconnect path. |
|
||||||
|
| `client/README.md` | Document hotbar loadout hydration/update flow and debug expectations. |
|
||||||
|
| `docs/decomposition/modules/E1_M4_AbilityInputScaffold.md` | Record NEO-29 implementation snapshot + persistence policy decision. |
|
||||||
|
| `docs/plans/E1M4-prototype-backlog.md` | Mark NEO-29 implementation notes/decision alignment if scope wording changes. |
|
||||||
|
| `docs/plans/NEO-29-implementation-plan.md` | Keep decisions and file/test plan aligned as implementation choices evolve. |
|
||||||
|
|
||||||
|
## Tests
|
||||||
|
|
||||||
|
| File | Coverage |
|
||||||
|
|------|----------|
|
||||||
|
| `server/NeonSprawl.Server.Tests/Game/AbilityInput/HotbarLoadoutApiTests.cs` | `GET`/`POST` contract shape, slot bounds validation, unknown ability validation, stable deny `reasonCode`, and successful bind persistence round-trip. |
|
||||||
|
| `server/NeonSprawl.Server.Tests/Game/AbilityInput/HotbarLoadoutPersistenceIntegrationTests.cs` | Postgres-enabled persistence survives host restart/reconnect; in-memory fallback behavior remains consistent when DB is absent. |
|
||||||
|
| `client/test/hotbar_loadout_client_test.gd` | Client hydration applies server slot bindings, preserves empty slots, and surfaces server deny reason codes from failed updates. |
|
||||||
|
| `docs/manual-qa/NEO-29.md` | Manual checklist for two-slot bind + reconnect/reload verification and deny-path UX/log visibility. |
|
||||||
|
|
||||||
|
**Executed during implementation**
|
||||||
|
|
||||||
|
- `dotnet test NeonSprawl.sln --filter "FullyQualifiedName~HotbarLoadoutApiTests"` (passed)
|
||||||
|
- `dotnet test NeonSprawl.sln --filter "FullyQualifiedName~HotbarLoadoutPersistenceIntegrationTests"` (passed)
|
||||||
|
- `godot --headless --path . -s res://addons/gdUnit4/bin/GdUnitCmdTool.gd --ignoreHeadlessMode -a res://test/hotbar_loadout_client_test.gd` (passed)
|
||||||
|
|
||||||
|
## Open questions / risks
|
||||||
|
|
||||||
|
- **Risk:** Ability-id allowlist source may drift before full E5.M1 cast contract lands; keep a single prototype registry and document ownership to avoid client/server mismatch.
|
||||||
|
- **Risk:** First cut may choose full-replace update semantics for simplicity; if partial patch semantics are needed for E1M4-02+, call that out as a follow-up to avoid contract churn.
|
||||||
|
|
||||||
|
## Decisions (implementation updates)
|
||||||
|
|
||||||
|
- **Update semantics:** NEO-29 uses **slot upsert semantics** (specified slots are updated; unspecified slots remain unchanged) to keep v1 payload compact while preserving server ownership.
|
||||||
|
- **Deny shape:** Validation denials return HTTP 200 with `updated=false`, stable `reasonCode`, and authoritative `loadout` snapshot so clients can reconcile immediately.
|
||||||
|
|
||||||
|
## Decisions
|
||||||
|
|
||||||
|
| Topic | Decision |
|
||||||
|
|-------|----------|
|
||||||
|
| Persistence scope | Per-player loadout for prototype v1. |
|
||||||
|
| Persistence backend | Postgres when `ConnectionStrings:NeonSprawl` is configured; otherwise in-memory fallback. |
|
||||||
|
| Slot count (v1) | Fixed 8-slot hotbar contract. |
|
||||||
|
|
@ -0,0 +1,100 @@
|
||||||
|
using System.Net;
|
||||||
|
using System.Net.Http.Json;
|
||||||
|
using NeonSprawl.Server.Game.AbilityInput;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace NeonSprawl.Server.Tests.Game.AbilityInput;
|
||||||
|
|
||||||
|
public sealed class HotbarLoadoutApiTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public async Task GetHotbarLoadout_ShouldReturnEightSlots_WhenPlayerKnown()
|
||||||
|
{
|
||||||
|
await using var factory = new InMemoryWebApplicationFactory();
|
||||||
|
var client = factory.CreateClient();
|
||||||
|
|
||||||
|
var response = await client.GetAsync("/game/players/dev-local-1/hotbar-loadout");
|
||||||
|
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||||
|
|
||||||
|
var body = await response.Content.ReadFromJsonAsync<HotbarLoadoutResponse>();
|
||||||
|
Assert.NotNull(body);
|
||||||
|
Assert.Equal(HotbarLoadoutResponse.CurrentSchemaVersion, body!.SchemaVersion);
|
||||||
|
Assert.Equal(HotbarLoadoutResponse.SlotCountV1, body.SlotCount);
|
||||||
|
Assert.Equal(HotbarLoadoutResponse.SlotCountV1, body.Slots.Count);
|
||||||
|
Assert.All(body.Slots, slot => Assert.Null(slot.AbilityId));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task PostHotbarLoadout_ShouldPersistBindings_AndRoundTripInGet()
|
||||||
|
{
|
||||||
|
await using var factory = new InMemoryWebApplicationFactory();
|
||||||
|
var client = factory.CreateClient();
|
||||||
|
|
||||||
|
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 },
|
||||||
|
new HotbarSlotBindingJson { SlotIndex = 3, AbilityId = PrototypeAbilityRegistry.PrototypeBurst },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
Assert.Equal(HttpStatusCode.OK, post.StatusCode);
|
||||||
|
|
||||||
|
var postBody = await post.Content.ReadFromJsonAsync<HotbarLoadoutUpdateResponse>();
|
||||||
|
Assert.NotNull(postBody);
|
||||||
|
Assert.True(postBody!.Updated);
|
||||||
|
Assert.Null(postBody.ReasonCode);
|
||||||
|
Assert.Equal(PrototypeAbilityRegistry.PrototypePulse, postBody.Loadout.Slots[0].AbilityId);
|
||||||
|
Assert.Equal(PrototypeAbilityRegistry.PrototypeBurst, postBody.Loadout.Slots[3].AbilityId);
|
||||||
|
|
||||||
|
var get = await client.GetFromJsonAsync<HotbarLoadoutResponse>("/game/players/dev-local-1/hotbar-loadout");
|
||||||
|
Assert.NotNull(get);
|
||||||
|
Assert.Equal(PrototypeAbilityRegistry.PrototypePulse, get!.Slots[0].AbilityId);
|
||||||
|
Assert.Equal(PrototypeAbilityRegistry.PrototypeBurst, get.Slots[3].AbilityId);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task PostHotbarLoadout_ShouldDenyOutOfBounds_WithReasonCode()
|
||||||
|
{
|
||||||
|
await using var factory = new InMemoryWebApplicationFactory();
|
||||||
|
var client = factory.CreateClient();
|
||||||
|
|
||||||
|
var post = await client.PostAsJsonAsync(
|
||||||
|
"/game/players/dev-local-1/hotbar-loadout",
|
||||||
|
new HotbarLoadoutUpdateRequest
|
||||||
|
{
|
||||||
|
SchemaVersion = HotbarLoadoutUpdateRequest.CurrentSchemaVersion,
|
||||||
|
Slots = [new HotbarSlotBindingJson { SlotIndex = 8, AbilityId = PrototypeAbilityRegistry.PrototypePulse }],
|
||||||
|
});
|
||||||
|
Assert.Equal(HttpStatusCode.OK, post.StatusCode);
|
||||||
|
|
||||||
|
var body = await post.Content.ReadFromJsonAsync<HotbarLoadoutUpdateResponse>();
|
||||||
|
Assert.NotNull(body);
|
||||||
|
Assert.False(body!.Updated);
|
||||||
|
Assert.Equal(HotbarLoadoutApi.ReasonSlotOutOfBounds, body.ReasonCode);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task PostHotbarLoadout_ShouldDenyUnknownAbility_WithReasonCode()
|
||||||
|
{
|
||||||
|
await using var factory = new InMemoryWebApplicationFactory();
|
||||||
|
var client = factory.CreateClient();
|
||||||
|
|
||||||
|
var post = await client.PostAsJsonAsync(
|
||||||
|
"/game/players/dev-local-1/hotbar-loadout",
|
||||||
|
new HotbarLoadoutUpdateRequest
|
||||||
|
{
|
||||||
|
SchemaVersion = HotbarLoadoutUpdateRequest.CurrentSchemaVersion,
|
||||||
|
Slots = [new HotbarSlotBindingJson { SlotIndex = 2, AbilityId = "missing_ability" }],
|
||||||
|
});
|
||||||
|
Assert.Equal(HttpStatusCode.OK, post.StatusCode);
|
||||||
|
|
||||||
|
var body = await post.Content.ReadFromJsonAsync<HotbarLoadoutUpdateResponse>();
|
||||||
|
Assert.NotNull(body);
|
||||||
|
Assert.False(body!.Updated);
|
||||||
|
Assert.Equal(HotbarLoadoutApi.ReasonUnknownAbility, body.ReasonCode);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,80 @@
|
||||||
|
using System.Net;
|
||||||
|
using System.Net.Http.Json;
|
||||||
|
using NeonSprawl.Server.Game.AbilityInput;
|
||||||
|
using NeonSprawl.Server.Tests.Game.PositionState;
|
||||||
|
using Npgsql;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace NeonSprawl.Server.Tests.Game.AbilityInput;
|
||||||
|
|
||||||
|
[Collection("Postgres integration")]
|
||||||
|
public sealed class HotbarLoadoutPersistenceIntegrationTests(PostgresIntegrationHarness harness)
|
||||||
|
{
|
||||||
|
private PostgresWebApplicationFactory Factory => harness.Factory;
|
||||||
|
|
||||||
|
[RequirePostgresFact]
|
||||||
|
public async Task PostThenGetAcrossNewFactory_ShouldPersistHotbarBindings()
|
||||||
|
{
|
||||||
|
await ResetHotbarTableAsync();
|
||||||
|
|
||||||
|
using (var firstClient = Factory.CreateClient())
|
||||||
|
{
|
||||||
|
var post = await firstClient.PostAsJsonAsync(
|
||||||
|
"/game/players/dev-local-1/hotbar-loadout",
|
||||||
|
new HotbarLoadoutUpdateRequest
|
||||||
|
{
|
||||||
|
SchemaVersion = HotbarLoadoutUpdateRequest.CurrentSchemaVersion,
|
||||||
|
Slots =
|
||||||
|
[
|
||||||
|
new HotbarSlotBindingJson
|
||||||
|
{
|
||||||
|
SlotIndex = 1,
|
||||||
|
AbilityId = PrototypeAbilityRegistry.PrototypePulse,
|
||||||
|
},
|
||||||
|
new HotbarSlotBindingJson
|
||||||
|
{
|
||||||
|
SlotIndex = 4,
|
||||||
|
AbilityId = PrototypeAbilityRegistry.PrototypeGuard,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
Assert.Equal(HttpStatusCode.OK, post.StatusCode);
|
||||||
|
}
|
||||||
|
|
||||||
|
await using var secondFactory = new PostgresWebApplicationFactory();
|
||||||
|
using var secondClient = secondFactory.CreateClient();
|
||||||
|
var loadout = await secondClient.GetFromJsonAsync<HotbarLoadoutResponse>(
|
||||||
|
"/game/players/dev-local-1/hotbar-loadout");
|
||||||
|
Assert.NotNull(loadout);
|
||||||
|
Assert.Equal(PrototypeAbilityRegistry.PrototypePulse, loadout!.Slots[1].AbilityId);
|
||||||
|
Assert.Equal(PrototypeAbilityRegistry.PrototypeGuard, loadout.Slots[4].AbilityId);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task ResetHotbarTableAsync()
|
||||||
|
{
|
||||||
|
var cs = Environment.GetEnvironmentVariable("ConnectionStrings__NeonSprawl");
|
||||||
|
if (string.IsNullOrWhiteSpace(cs))
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException("ConnectionStrings__NeonSprawl is not set.");
|
||||||
|
}
|
||||||
|
|
||||||
|
var ddlPath = Path.Combine(AppContext.BaseDirectory, "db", "migrations", "V002__player_hotbar_loadout.sql");
|
||||||
|
if (!File.Exists(ddlPath))
|
||||||
|
{
|
||||||
|
throw new FileNotFoundException($"Test DDL not found at '{ddlPath}'.", ddlPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
var ddl = await File.ReadAllTextAsync(ddlPath);
|
||||||
|
await using var conn = new NpgsqlConnection(cs);
|
||||||
|
await conn.OpenAsync();
|
||||||
|
await using (var apply = new NpgsqlCommand(ddl, conn))
|
||||||
|
{
|
||||||
|
await apply.ExecuteNonQueryAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
await using (var truncate = new NpgsqlCommand("TRUNCATE player_hotbar_loadout;", conn))
|
||||||
|
{
|
||||||
|
await truncate.ExecuteNonQueryAsync();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -128,7 +128,7 @@ public sealed class PostgresPositionStateIntegrationTests(PostgresIntegrationHar
|
||||||
await apply.ExecuteNonQueryAsync();
|
await apply.ExecuteNonQueryAsync();
|
||||||
}
|
}
|
||||||
|
|
||||||
await using (var truncate = new NpgsqlCommand("TRUNCATE player_position;", conn))
|
await using (var truncate = new NpgsqlCommand("TRUNCATE player_position CASCADE;", conn))
|
||||||
{
|
{
|
||||||
await truncate.ExecuteNonQueryAsync();
|
await truncate.ExecuteNonQueryAsync();
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ using Microsoft.AspNetCore.Mvc.Testing;
|
||||||
using Microsoft.AspNetCore.TestHost;
|
using Microsoft.AspNetCore.TestHost;
|
||||||
using Microsoft.Extensions.DependencyInjection;
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
using Microsoft.Extensions.Hosting;
|
using Microsoft.Extensions.Hosting;
|
||||||
|
using NeonSprawl.Server.Game.AbilityInput;
|
||||||
using NeonSprawl.Server.Game.PositionState;
|
using NeonSprawl.Server.Game.PositionState;
|
||||||
using Npgsql;
|
using Npgsql;
|
||||||
|
|
||||||
|
|
@ -19,6 +20,7 @@ public sealed class InMemoryWebApplicationFactory : WebApplicationFactory<Progra
|
||||||
{
|
{
|
||||||
var d = services[i];
|
var d = services[i];
|
||||||
if (d.ServiceType == typeof(IPositionStateStore) ||
|
if (d.ServiceType == typeof(IPositionStateStore) ||
|
||||||
|
d.ServiceType == typeof(IPlayerHotbarLoadoutStore) ||
|
||||||
d.ServiceType == typeof(NpgsqlDataSource) ||
|
d.ServiceType == typeof(NpgsqlDataSource) ||
|
||||||
(d.ServiceType == typeof(IHostedService) &&
|
(d.ServiceType == typeof(IHostedService) &&
|
||||||
(d.ImplementationType == typeof(PostgresDevPlayerSeedHostedService) ||
|
(d.ImplementationType == typeof(PostgresDevPlayerSeedHostedService) ||
|
||||||
|
|
@ -29,6 +31,7 @@ public sealed class InMemoryWebApplicationFactory : WebApplicationFactory<Progra
|
||||||
}
|
}
|
||||||
|
|
||||||
services.AddSingleton<IPositionStateStore, InMemoryPositionStateStore>();
|
services.AddSingleton<IPositionStateStore, InMemoryPositionStateStore>();
|
||||||
|
services.AddSingleton<IPlayerHotbarLoadoutStore, InMemoryPlayerHotbarLoadoutStore>();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,128 @@
|
||||||
|
using NeonSprawl.Server.Game.PositionState;
|
||||||
|
|
||||||
|
namespace NeonSprawl.Server.Game.AbilityInput;
|
||||||
|
|
||||||
|
/// <summary>Maps hotbar loadout read/update APIs (NEO-29).</summary>
|
||||||
|
public static class HotbarLoadoutApi
|
||||||
|
{
|
||||||
|
public const string ReasonSlotOutOfBounds = "slot_out_of_bounds";
|
||||||
|
public const string ReasonUnknownAbility = "unknown_ability";
|
||||||
|
public const string ReasonDuplicateSlot = "duplicate_slot";
|
||||||
|
|
||||||
|
public static WebApplication MapHotbarLoadoutApi(this WebApplication app)
|
||||||
|
{
|
||||||
|
app.MapGet(
|
||||||
|
"/game/players/{id}/hotbar-loadout",
|
||||||
|
(string id, IPositionStateStore positions, IPlayerHotbarLoadoutStore store) =>
|
||||||
|
{
|
||||||
|
if (!positions.TryGetPosition(id, out _))
|
||||||
|
{
|
||||||
|
return Results.NotFound();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!store.TryGetBindings(id, out var bindings))
|
||||||
|
{
|
||||||
|
return Results.NotFound();
|
||||||
|
}
|
||||||
|
|
||||||
|
return Results.Json(BuildLoadoutResponse(id, bindings));
|
||||||
|
});
|
||||||
|
|
||||||
|
app.MapPost(
|
||||||
|
"/game/players/{id}/hotbar-loadout",
|
||||||
|
(string id, HotbarLoadoutUpdateRequest? body, IPositionStateStore positions, IPlayerHotbarLoadoutStore store) =>
|
||||||
|
{
|
||||||
|
if (body is null || body.SchemaVersion != HotbarLoadoutUpdateRequest.CurrentSchemaVersion)
|
||||||
|
{
|
||||||
|
return Results.BadRequest();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!positions.TryGetPosition(id, out _))
|
||||||
|
{
|
||||||
|
return Results.NotFound();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!store.TryGetBindings(id, out var before))
|
||||||
|
{
|
||||||
|
return Results.NotFound();
|
||||||
|
}
|
||||||
|
|
||||||
|
var updates = body.Slots ?? [];
|
||||||
|
var parsedUpdates = new List<HotbarSlotBinding>(updates.Count);
|
||||||
|
var seenSlots = new HashSet<int>();
|
||||||
|
foreach (var update in updates)
|
||||||
|
{
|
||||||
|
if (update.SlotIndex < 0 || update.SlotIndex >= HotbarLoadoutResponse.SlotCountV1)
|
||||||
|
{
|
||||||
|
return Results.Json(
|
||||||
|
new HotbarLoadoutUpdateResponse
|
||||||
|
{
|
||||||
|
Updated = false,
|
||||||
|
ReasonCode = ReasonSlotOutOfBounds,
|
||||||
|
Loadout = BuildLoadoutResponse(id, before),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!seenSlots.Add(update.SlotIndex))
|
||||||
|
{
|
||||||
|
return Results.Json(
|
||||||
|
new HotbarLoadoutUpdateResponse
|
||||||
|
{
|
||||||
|
Updated = false,
|
||||||
|
ReasonCode = ReasonDuplicateSlot,
|
||||||
|
Loadout = BuildLoadoutResponse(id, before),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (string.IsNullOrWhiteSpace(update.AbilityId) ||
|
||||||
|
!PrototypeAbilityRegistry.TryNormalizeKnown(update.AbilityId, out var normalizedAbilityId))
|
||||||
|
{
|
||||||
|
return Results.Json(
|
||||||
|
new HotbarLoadoutUpdateResponse
|
||||||
|
{
|
||||||
|
Updated = false,
|
||||||
|
ReasonCode = ReasonUnknownAbility,
|
||||||
|
Loadout = BuildLoadoutResponse(id, before),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
parsedUpdates.Add(new HotbarSlotBinding(update.SlotIndex, normalizedAbilityId));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!store.TryUpsertBindings(id, parsedUpdates, out var after))
|
||||||
|
{
|
||||||
|
return Results.NotFound();
|
||||||
|
}
|
||||||
|
|
||||||
|
return Results.Json(
|
||||||
|
new HotbarLoadoutUpdateResponse
|
||||||
|
{
|
||||||
|
Updated = true,
|
||||||
|
Loadout = BuildLoadoutResponse(id, after),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
return app;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static HotbarLoadoutResponse BuildLoadoutResponse(string playerId, IReadOnlyDictionary<int, string> bindings)
|
||||||
|
{
|
||||||
|
var slots = new List<HotbarSlotStateJson>(HotbarLoadoutResponse.SlotCountV1);
|
||||||
|
for (var i = 0; i < HotbarLoadoutResponse.SlotCountV1; i++)
|
||||||
|
{
|
||||||
|
bindings.TryGetValue(i, out var abilityId);
|
||||||
|
slots.Add(
|
||||||
|
new HotbarSlotStateJson
|
||||||
|
{
|
||||||
|
SlotIndex = i,
|
||||||
|
AbilityId = abilityId,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return new HotbarLoadoutResponse
|
||||||
|
{
|
||||||
|
PlayerId = playerId.Trim(),
|
||||||
|
Slots = slots,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,80 @@
|
||||||
|
using System.Text.Json.Serialization;
|
||||||
|
|
||||||
|
namespace NeonSprawl.Server.Game.AbilityInput;
|
||||||
|
|
||||||
|
/// <summary>Canonical hotbar loadout payload returned by loadout APIs (NEO-29).</summary>
|
||||||
|
public sealed class HotbarLoadoutResponse
|
||||||
|
{
|
||||||
|
public const int CurrentSchemaVersion = 1;
|
||||||
|
public const int SlotCountV1 = 8;
|
||||||
|
|
||||||
|
[JsonPropertyName("schemaVersion")]
|
||||||
|
public int SchemaVersion { get; init; } = CurrentSchemaVersion;
|
||||||
|
|
||||||
|
[JsonPropertyName("playerId")]
|
||||||
|
public required string PlayerId { get; init; }
|
||||||
|
|
||||||
|
[JsonPropertyName("slotCount")]
|
||||||
|
public int SlotCount { get; init; } = SlotCountV1;
|
||||||
|
|
||||||
|
/// <summary>Always includes v1 slots 0..7 in ascending order.</summary>
|
||||||
|
[JsonPropertyName("slots")]
|
||||||
|
public required IReadOnlyList<HotbarSlotStateJson> Slots { get; init; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>POST body for hotbar loadout updates.</summary>
|
||||||
|
public sealed class HotbarLoadoutUpdateRequest
|
||||||
|
{
|
||||||
|
public const int CurrentSchemaVersion = 1;
|
||||||
|
|
||||||
|
[JsonPropertyName("schemaVersion")]
|
||||||
|
public int SchemaVersion { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Requested slot bindings to apply. Slot entries are upserts; unspecified slots keep previous values.
|
||||||
|
/// </summary>
|
||||||
|
[JsonPropertyName("slots")]
|
||||||
|
public IReadOnlyList<HotbarSlotBindingJson>? Slots { get; init; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>POST response for hotbar updates (applied or denied).</summary>
|
||||||
|
public sealed class HotbarLoadoutUpdateResponse
|
||||||
|
{
|
||||||
|
public const int CurrentSchemaVersion = 1;
|
||||||
|
|
||||||
|
[JsonPropertyName("schemaVersion")]
|
||||||
|
public int SchemaVersion { get; init; } = CurrentSchemaVersion;
|
||||||
|
|
||||||
|
[JsonPropertyName("updated")]
|
||||||
|
public bool Updated { get; init; }
|
||||||
|
|
||||||
|
[JsonPropertyName("loadout")]
|
||||||
|
public required HotbarLoadoutResponse Loadout { get; init; }
|
||||||
|
|
||||||
|
/// <summary>Required on deny responses; omitted when <see cref="Updated"/> is true.</summary>
|
||||||
|
[JsonPropertyName("reasonCode")]
|
||||||
|
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
||||||
|
public string? ReasonCode { get; init; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Wire shape for an individual slot state in GET/POST responses.</summary>
|
||||||
|
public sealed class HotbarSlotStateJson
|
||||||
|
{
|
||||||
|
[JsonPropertyName("slotIndex")]
|
||||||
|
public required int SlotIndex { get; init; }
|
||||||
|
|
||||||
|
/// <summary>Canonical lowercase ability id or null when the slot is unbound.</summary>
|
||||||
|
[JsonPropertyName("abilityId")]
|
||||||
|
[JsonIgnore(Condition = JsonIgnoreCondition.Never)]
|
||||||
|
public string? AbilityId { get; init; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Wire shape for an individual slot update in POST requests.</summary>
|
||||||
|
public sealed class HotbarSlotBindingJson
|
||||||
|
{
|
||||||
|
[JsonPropertyName("slotIndex")]
|
||||||
|
public int SlotIndex { get; init; }
|
||||||
|
|
||||||
|
[JsonPropertyName("abilityId")]
|
||||||
|
public string? AbilityId { get; init; }
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,20 @@
|
||||||
|
namespace NeonSprawl.Server.Game.AbilityInput;
|
||||||
|
|
||||||
|
/// <summary>Registers hotbar loadout persistence: PostgreSQL when configured, otherwise in-memory fallback (NEO-29).</summary>
|
||||||
|
public static class HotbarLoadoutServiceCollectionExtensions
|
||||||
|
{
|
||||||
|
public static IServiceCollection AddHotbarLoadoutStore(this IServiceCollection services, IConfiguration configuration)
|
||||||
|
{
|
||||||
|
var cs = configuration.GetConnectionString(PositionState.PositionStateServiceCollectionExtensions.NeonSprawlConnectionStringName);
|
||||||
|
if (!string.IsNullOrWhiteSpace(cs))
|
||||||
|
{
|
||||||
|
services.AddSingleton<IPlayerHotbarLoadoutStore, PostgresPlayerHotbarLoadoutStore>();
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
services.AddSingleton<IPlayerHotbarLoadoutStore, InMemoryPlayerHotbarLoadoutStore>();
|
||||||
|
}
|
||||||
|
|
||||||
|
return services;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,11 @@
|
||||||
|
namespace NeonSprawl.Server.Game.AbilityInput;
|
||||||
|
|
||||||
|
/// <summary>Persistence abstraction for per-player hotbar bindings (NEO-29).</summary>
|
||||||
|
public interface IPlayerHotbarLoadoutStore
|
||||||
|
{
|
||||||
|
bool TryGetBindings(string playerId, out IReadOnlyDictionary<int, string> bindings);
|
||||||
|
|
||||||
|
bool TryUpsertBindings(string playerId, IReadOnlyList<HotbarSlotBinding> updates, out IReadOnlyDictionary<int, string> bindings);
|
||||||
|
}
|
||||||
|
|
||||||
|
public readonly record struct HotbarSlotBinding(int SlotIndex, string AbilityId);
|
||||||
|
|
@ -0,0 +1,75 @@
|
||||||
|
using System.Collections.Concurrent;
|
||||||
|
using Microsoft.Extensions.Options;
|
||||||
|
using NeonSprawl.Server.Game.PositionState;
|
||||||
|
|
||||||
|
namespace NeonSprawl.Server.Game.AbilityInput;
|
||||||
|
|
||||||
|
/// <summary>Thread-safe in-memory hotbar loadout store; seeds configured dev player with an empty loadout.</summary>
|
||||||
|
public sealed class InMemoryPlayerHotbarLoadoutStore(IOptions<GamePositionOptions> options) : IPlayerHotbarLoadoutStore
|
||||||
|
{
|
||||||
|
private readonly ConcurrentDictionary<string, ConcurrentDictionary<int, string>> byPlayer =
|
||||||
|
CreateInitialMap(options.Value);
|
||||||
|
|
||||||
|
private static ConcurrentDictionary<string, ConcurrentDictionary<int, string>> CreateInitialMap(GamePositionOptions o)
|
||||||
|
{
|
||||||
|
var id = NormalizePlayerId(o.DevPlayerId);
|
||||||
|
var map = new ConcurrentDictionary<string, ConcurrentDictionary<int, string>>(StringComparer.OrdinalIgnoreCase);
|
||||||
|
map[id] = new ConcurrentDictionary<int, string>();
|
||||||
|
return map;
|
||||||
|
}
|
||||||
|
|
||||||
|
public bool TryGetBindings(string playerId, out IReadOnlyDictionary<int, string> bindings)
|
||||||
|
{
|
||||||
|
var key = NormalizePlayerId(playerId);
|
||||||
|
if (key.Length == 0 || !byPlayer.TryGetValue(key, out var map))
|
||||||
|
{
|
||||||
|
bindings = EmptyBindings();
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
bindings = Clone(map);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
public bool TryUpsertBindings(string playerId, IReadOnlyList<HotbarSlotBinding> updates, out IReadOnlyDictionary<int, string> bindings)
|
||||||
|
{
|
||||||
|
var key = NormalizePlayerId(playerId);
|
||||||
|
if (key.Length == 0 || !byPlayer.TryGetValue(key, out var map))
|
||||||
|
{
|
||||||
|
bindings = EmptyBindings();
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var update in updates)
|
||||||
|
{
|
||||||
|
map[update.SlotIndex] = update.AbilityId;
|
||||||
|
}
|
||||||
|
|
||||||
|
bindings = Clone(map);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static IReadOnlyDictionary<int, string> Clone(ConcurrentDictionary<int, string> source)
|
||||||
|
{
|
||||||
|
var copy = new Dictionary<int, string>(source.Count);
|
||||||
|
foreach (var kv in source)
|
||||||
|
{
|
||||||
|
copy[kv.Key] = kv.Value;
|
||||||
|
}
|
||||||
|
|
||||||
|
return copy;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static IReadOnlyDictionary<int, string> EmptyBindings() => new Dictionary<int, string>();
|
||||||
|
|
||||||
|
private static string NormalizePlayerId(string? playerId)
|
||||||
|
{
|
||||||
|
var t = playerId?.Trim();
|
||||||
|
if (string.IsNullOrEmpty(t))
|
||||||
|
{
|
||||||
|
return string.Empty;
|
||||||
|
}
|
||||||
|
|
||||||
|
return t.ToLowerInvariant();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,37 @@
|
||||||
|
namespace NeonSprawl.Server.Game.AbilityInput;
|
||||||
|
|
||||||
|
/// <summary>Applies NEO-29 hotbar loadout table DDL once per process.</summary>
|
||||||
|
public static class PostgresHotbarLoadoutBootstrap
|
||||||
|
{
|
||||||
|
private static readonly string DdlRelativePath = Path.Combine("db", "migrations", "V002__player_hotbar_loadout.sql");
|
||||||
|
private static readonly object SchemaGate = new();
|
||||||
|
private static int _schemaReady;
|
||||||
|
|
||||||
|
public static void EnsureSchema(Npgsql.NpgsqlDataSource dataSource)
|
||||||
|
{
|
||||||
|
if (System.Threading.Volatile.Read(ref _schemaReady) != 0)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
lock (SchemaGate)
|
||||||
|
{
|
||||||
|
if (System.Threading.Volatile.Read(ref _schemaReady) != 0)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var ddlPath = Path.Combine(AppContext.BaseDirectory, DdlRelativePath);
|
||||||
|
if (!File.Exists(ddlPath))
|
||||||
|
{
|
||||||
|
throw new FileNotFoundException($"NEO-29 DDL not found at '{ddlPath}'.", ddlPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
var ddl = File.ReadAllText(ddlPath);
|
||||||
|
using var conn = dataSource.OpenConnection();
|
||||||
|
using var cmd = new Npgsql.NpgsqlCommand(ddl, conn);
|
||||||
|
cmd.ExecuteNonQuery();
|
||||||
|
System.Threading.Volatile.Write(ref _schemaReady, 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,116 @@
|
||||||
|
namespace NeonSprawl.Server.Game.AbilityInput;
|
||||||
|
|
||||||
|
/// <summary>PostgreSQL-backed hotbar loadout store keyed by normalized player id.</summary>
|
||||||
|
public sealed class PostgresPlayerHotbarLoadoutStore(Npgsql.NpgsqlDataSource dataSource) : IPlayerHotbarLoadoutStore
|
||||||
|
{
|
||||||
|
public bool TryGetBindings(string playerId, out IReadOnlyDictionary<int, string> bindings)
|
||||||
|
{
|
||||||
|
var norm = NormalizePlayerId(playerId);
|
||||||
|
if (norm.Length == 0)
|
||||||
|
{
|
||||||
|
bindings = EmptyBindings();
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
PostgresHotbarLoadoutBootstrap.EnsureSchema(dataSource);
|
||||||
|
using var conn = dataSource.OpenConnection();
|
||||||
|
if (!PlayerExists(conn, norm))
|
||||||
|
{
|
||||||
|
bindings = EmptyBindings();
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
bindings = ReadBindings(conn, norm);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
public bool TryUpsertBindings(string playerId, IReadOnlyList<HotbarSlotBinding> updates, out IReadOnlyDictionary<int, string> bindings)
|
||||||
|
{
|
||||||
|
var norm = NormalizePlayerId(playerId);
|
||||||
|
if (norm.Length == 0)
|
||||||
|
{
|
||||||
|
bindings = EmptyBindings();
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
PostgresHotbarLoadoutBootstrap.EnsureSchema(dataSource);
|
||||||
|
using var conn = dataSource.OpenConnection();
|
||||||
|
using var tx = conn.BeginTransaction();
|
||||||
|
|
||||||
|
if (!PlayerExists(conn, norm, tx))
|
||||||
|
{
|
||||||
|
tx.Rollback();
|
||||||
|
bindings = EmptyBindings();
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var update in updates)
|
||||||
|
{
|
||||||
|
using var cmd = new Npgsql.NpgsqlCommand(
|
||||||
|
"""
|
||||||
|
INSERT INTO player_hotbar_loadout (player_id, slot_index, ability_id)
|
||||||
|
VALUES (@pid, @slot, @ability)
|
||||||
|
ON CONFLICT (player_id, slot_index)
|
||||||
|
DO UPDATE SET ability_id = EXCLUDED.ability_id, updated_at = now();
|
||||||
|
""",
|
||||||
|
conn,
|
||||||
|
tx);
|
||||||
|
cmd.Parameters.AddWithValue("pid", norm);
|
||||||
|
cmd.Parameters.AddWithValue("slot", update.SlotIndex);
|
||||||
|
cmd.Parameters.AddWithValue("ability", update.AbilityId);
|
||||||
|
cmd.ExecuteNonQuery();
|
||||||
|
}
|
||||||
|
|
||||||
|
bindings = ReadBindings(conn, norm, tx);
|
||||||
|
tx.Commit();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static IReadOnlyDictionary<int, string> ReadBindings(
|
||||||
|
Npgsql.NpgsqlConnection conn,
|
||||||
|
string playerIdNormalized,
|
||||||
|
Npgsql.NpgsqlTransaction? tx = null)
|
||||||
|
{
|
||||||
|
var map = new Dictionary<int, string>();
|
||||||
|
using var cmd = new Npgsql.NpgsqlCommand(
|
||||||
|
"""
|
||||||
|
SELECT slot_index, ability_id
|
||||||
|
FROM player_hotbar_loadout
|
||||||
|
WHERE player_id = @pid
|
||||||
|
ORDER BY slot_index;
|
||||||
|
""",
|
||||||
|
conn,
|
||||||
|
tx);
|
||||||
|
cmd.Parameters.AddWithValue("pid", playerIdNormalized);
|
||||||
|
using var reader = cmd.ExecuteReader();
|
||||||
|
while (reader.Read())
|
||||||
|
{
|
||||||
|
map[reader.GetInt32(0)] = reader.GetString(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
return map;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool PlayerExists(Npgsql.NpgsqlConnection conn, string playerIdNormalized, Npgsql.NpgsqlTransaction? tx = null)
|
||||||
|
{
|
||||||
|
using var cmd = new Npgsql.NpgsqlCommand(
|
||||||
|
"SELECT 1 FROM player_position WHERE player_id = @pid LIMIT 1;",
|
||||||
|
conn,
|
||||||
|
tx);
|
||||||
|
cmd.Parameters.AddWithValue("pid", playerIdNormalized);
|
||||||
|
return cmd.ExecuteScalar() is not null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static IReadOnlyDictionary<int, string> EmptyBindings() => new Dictionary<int, string>();
|
||||||
|
|
||||||
|
private static string NormalizePlayerId(string? playerId)
|
||||||
|
{
|
||||||
|
var t = playerId?.Trim();
|
||||||
|
if (string.IsNullOrEmpty(t))
|
||||||
|
{
|
||||||
|
return string.Empty;
|
||||||
|
}
|
||||||
|
|
||||||
|
return t.ToLowerInvariant();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,29 @@
|
||||||
|
namespace NeonSprawl.Server.Game.AbilityInput;
|
||||||
|
|
||||||
|
/// <summary>Prototype ability allowlist for NEO-29 hotbar bindings.</summary>
|
||||||
|
public static class PrototypeAbilityRegistry
|
||||||
|
{
|
||||||
|
public const string PrototypePulse = "prototype_pulse";
|
||||||
|
public const string PrototypeGuard = "prototype_guard";
|
||||||
|
public const string PrototypeDash = "prototype_dash";
|
||||||
|
public const string PrototypeBurst = "prototype_burst";
|
||||||
|
|
||||||
|
private static readonly HashSet<string> Allowed = new(StringComparer.Ordinal)
|
||||||
|
{
|
||||||
|
PrototypePulse,
|
||||||
|
PrototypeGuard,
|
||||||
|
PrototypeDash,
|
||||||
|
PrototypeBurst,
|
||||||
|
};
|
||||||
|
|
||||||
|
public static bool TryNormalizeKnown(string rawAbilityId, out string normalized)
|
||||||
|
{
|
||||||
|
normalized = rawAbilityId.Trim().ToLowerInvariant();
|
||||||
|
if (normalized.Length == 0)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return Allowed.Contains(normalized);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -3,7 +3,7 @@ namespace NeonSprawl.Server.Game.PositionState;
|
||||||
/// <summary>Registers position authority: PostgreSQL when <c>ConnectionStrings:NeonSprawl</c> is set, otherwise in-memory (NS-15 / NS-17).</summary>
|
/// <summary>Registers position authority: PostgreSQL when <c>ConnectionStrings:NeonSprawl</c> is set, otherwise in-memory (NS-15 / NS-17).</summary>
|
||||||
public static class PositionStateServiceCollectionExtensions
|
public static class PositionStateServiceCollectionExtensions
|
||||||
{
|
{
|
||||||
private const string NeonSprawlConnectionStringName = "NeonSprawl";
|
internal const string NeonSprawlConnectionStringName = "NeonSprawl";
|
||||||
|
|
||||||
public static IServiceCollection AddPositionStateStore(this IServiceCollection services, IConfiguration configuration)
|
public static IServiceCollection AddPositionStateStore(this IServiceCollection services, IConfiguration configuration)
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,11 @@
|
||||||
|
using NeonSprawl.Server.Game.AbilityInput;
|
||||||
using NeonSprawl.Server.Game.Interaction;
|
using NeonSprawl.Server.Game.Interaction;
|
||||||
using NeonSprawl.Server.Game.PositionState;
|
using NeonSprawl.Server.Game.PositionState;
|
||||||
using NeonSprawl.Server.Game.Targeting;
|
using NeonSprawl.Server.Game.Targeting;
|
||||||
|
|
||||||
var builder = WebApplication.CreateBuilder(args);
|
var builder = WebApplication.CreateBuilder(args);
|
||||||
builder.Services.AddPositionStateStore(builder.Configuration);
|
builder.Services.AddPositionStateStore(builder.Configuration);
|
||||||
|
builder.Services.AddHotbarLoadoutStore(builder.Configuration);
|
||||||
builder.Services.AddSingleton<IPlayerTargetLockStore, InMemoryPlayerTargetLockStore>();
|
builder.Services.AddSingleton<IPlayerTargetLockStore, InMemoryPlayerTargetLockStore>();
|
||||||
|
|
||||||
var app = builder.Build();
|
var app = builder.Build();
|
||||||
|
|
@ -22,5 +24,6 @@ app.MapPositionStateApi();
|
||||||
app.MapInteractionApi();
|
app.MapInteractionApi();
|
||||||
app.MapInteractablesWorldApi();
|
app.MapInteractablesWorldApi();
|
||||||
app.MapTargetingApi();
|
app.MapTargetingApi();
|
||||||
|
app.MapHotbarLoadoutApi();
|
||||||
|
|
||||||
app.Run();
|
app.Run();
|
||||||
|
|
|
||||||
|
|
@ -201,6 +201,28 @@ curl -s -X POST http://localhost:5253/game/players/dev-local-1/target/select \
|
||||||
|
|
||||||
When **`selectionApplied` is `true`**, **`reasonCode` is omitted** (clients must not require it on success).
|
When **`selectionApplied` is `true`**, **`reasonCode` is omitted** (clients must not require it on success).
|
||||||
|
|
||||||
|
## Hotbar loadout (NEO-29)
|
||||||
|
|
||||||
|
Prototype server-owned hotbar bindings are available at:
|
||||||
|
|
||||||
|
- **`GET /game/players/{id}/hotbar-loadout`** → returns `HotbarLoadoutResponse` v1 with fixed `slotCount` **8** and `slots` array entries (`slotIndex`, nullable `abilityId`) for every slot.
|
||||||
|
- **`POST /game/players/{id}/hotbar-loadout`** with `HotbarLoadoutUpdateRequest` v1 (`schemaVersion`, `slots[]`) upserts one or more slot bindings and returns `HotbarLoadoutUpdateResponse` v1.
|
||||||
|
|
||||||
|
**Prototype policy (kickoff decision):**
|
||||||
|
|
||||||
|
- Scope is **per player id** (same keying strategy as `PositionState` / `TargetState`).
|
||||||
|
- Persistence mirrors NEO-8/NS-17: **Postgres when `ConnectionStrings:NeonSprawl` exists**, otherwise **in-memory fallback**.
|
||||||
|
|
||||||
|
**Validation deny reason codes (POST 200 with `updated=false`):**
|
||||||
|
|
||||||
|
| Code | Meaning |
|
||||||
|
|------|---------|
|
||||||
|
| `slot_out_of_bounds` | `slotIndex` is outside v1 range `0..7`. |
|
||||||
|
| `unknown_ability` | `abilityId` is empty/whitespace or not in the prototype allowlist. |
|
||||||
|
| `duplicate_slot` | Request repeats the same `slotIndex` more than once. |
|
||||||
|
|
||||||
|
Current prototype allowlist: `prototype_pulse`, `prototype_guard`, `prototype_dash`, `prototype_burst`.
|
||||||
|
|
||||||
## Solution
|
## Solution
|
||||||
|
|
||||||
From repo root: `dotnet build NeonSprawl.sln` and `dotnet test NeonSprawl.sln`.
|
From repo root: `dotnet build NeonSprawl.sln` and `dotnet test NeonSprawl.sln`.
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,10 @@
|
||||||
|
-- NEO-29: per-player hotbar slot bindings (prototype, v1).
|
||||||
|
CREATE TABLE IF NOT EXISTS player_hotbar_loadout (
|
||||||
|
player_id TEXT NOT NULL REFERENCES player_position(player_id) ON DELETE CASCADE,
|
||||||
|
slot_index INTEGER NOT NULL,
|
||||||
|
ability_id TEXT NOT NULL,
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
PRIMARY KEY (player_id, slot_index)
|
||||||
|
);
|
||||||
|
|
||||||
|
COMMENT ON TABLE player_hotbar_loadout IS 'Persisted hotbar slot bindings per player (NEO-29).';
|
||||||
Loading…
Reference in New Issue