diff --git a/client/README.md b/client/README.md index 9833ce1..25a4298 100644 --- a/client/README.md +++ b/client/README.md @@ -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). +## 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) 1. Run the server and client as in NEO-7 / NEO-9; confirm spawn snap at `(-5, 0.9, -5)`. diff --git a/client/scripts/hotbar_loadout_client.gd b/client/scripts/hotbar_loadout_client.gd new file mode 100644 index 0000000..aa7fba7 --- /dev/null +++ b/client/scripts/hotbar_loadout_client.gd @@ -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) diff --git a/client/scripts/hotbar_state.gd b/client/scripts/hotbar_state.gd new file mode 100644 index 0000000..eade655 --- /dev/null +++ b/client/scripts/hotbar_state.gd @@ -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() diff --git a/client/scripts/main.gd b/client/scripts/main.gd index fe24e70..64ea64c 100644 --- a/client/scripts/main.gd +++ b/client/scripts/main.gd @@ -79,6 +79,8 @@ var _dev_obstacle_smoke: Node3D @onready var _target_client: Node = $TargetSelectionClient @onready var _interaction_client: Node = $InteractionRequestClient @onready var _floor: StaticBody3D = $World/NavigationRegion3D/Floor +var _hotbar_state: Node = null +var _hotbar_client: Node = null func _ready() -> void: @@ -116,6 +118,7 @@ func _ready() -> void: _catalog.call("request_catalog") _authority.call("sync_from_server") _target_client.call("request_sync_from_server") + _setup_hotbar_loadout_sync() 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) +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 ## distance computed from the live capsule position. Distances are a **display-only** ## hint — the server remains authoritative for `validity`. If the HUD shows diff --git a/client/test/hotbar_loadout_client_test.gd b/client/test/hotbar_loadout_client_test.gd new file mode 100644 index 0000000..bcb8680 --- /dev/null +++ b/client/test/hotbar_loadout_client_test.gd @@ -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") diff --git a/docs/decomposition/modules/E1_M4_AbilityInputScaffold.md b/docs/decomposition/modules/E1_M4_AbilityInputScaffold.md index bdb9d7c..2278d07 100644 --- a/docs/decomposition/modules/E1_M4_AbilityInputScaffold.md +++ b/docs/decomposition/modules/E1_M4_AbilityInputScaffold.md @@ -42,7 +42,8 @@ Epic 1 **Slice 3** — `ability_cast_requested`, `ability_cast_denied` with reas ## 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. - **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 | |------|--------| -| **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-03** | TBD — Combat accept/deny integration + reason-code UX | | **E1M4-04** | TBD — `CooldownSnapshot` sync + slot presentation | diff --git a/docs/manual-qa/NEO-29.md b/docs/manual-qa/NEO-29.md new file mode 100644 index 0000000..0e200a6 --- /dev/null +++ b/docs/manual-qa/NEO-29.md @@ -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. diff --git a/docs/plans/E1M4-prototype-backlog.md b/docs/plans/E1M4-prototype-backlog.md index 3821286..5b92a0a 100644 --- a/docs/plans/E1M4-prototype-backlog.md +++ b/docs/plans/E1M4-prototype-backlog.md @@ -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. - [ ] 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) diff --git a/docs/plans/NEO-29-implementation-plan.md b/docs/plans/NEO-29-implementation-plan.md new file mode 100644 index 0000000..3320aff --- /dev/null +++ b/docs/plans/NEO-29-implementation-plan.md @@ -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. | diff --git a/server/NeonSprawl.Server.Tests/Game/AbilityInput/HotbarLoadoutApiTests.cs b/server/NeonSprawl.Server.Tests/Game/AbilityInput/HotbarLoadoutApiTests.cs new file mode 100644 index 0000000..e89df5a --- /dev/null +++ b/server/NeonSprawl.Server.Tests/Game/AbilityInput/HotbarLoadoutApiTests.cs @@ -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(); + 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(); + 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("/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(); + 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(); + Assert.NotNull(body); + Assert.False(body!.Updated); + Assert.Equal(HotbarLoadoutApi.ReasonUnknownAbility, body.ReasonCode); + } +} diff --git a/server/NeonSprawl.Server.Tests/Game/AbilityInput/HotbarLoadoutPersistenceIntegrationTests.cs b/server/NeonSprawl.Server.Tests/Game/AbilityInput/HotbarLoadoutPersistenceIntegrationTests.cs new file mode 100644 index 0000000..2811722 --- /dev/null +++ b/server/NeonSprawl.Server.Tests/Game/AbilityInput/HotbarLoadoutPersistenceIntegrationTests.cs @@ -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( + "/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(); + } + } +} diff --git a/server/NeonSprawl.Server.Tests/Game/PositionState/PostgresPositionStateIntegrationTests.cs b/server/NeonSprawl.Server.Tests/Game/PositionState/PostgresPositionStateIntegrationTests.cs index 5878722..cd73678 100644 --- a/server/NeonSprawl.Server.Tests/Game/PositionState/PostgresPositionStateIntegrationTests.cs +++ b/server/NeonSprawl.Server.Tests/Game/PositionState/PostgresPositionStateIntegrationTests.cs @@ -128,7 +128,7 @@ public sealed class PostgresPositionStateIntegrationTests(PostgresIntegrationHar 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(); } diff --git a/server/NeonSprawl.Server.Tests/InMemoryWebApplicationFactory.cs b/server/NeonSprawl.Server.Tests/InMemoryWebApplicationFactory.cs index 210d27a..5d60c87 100644 --- a/server/NeonSprawl.Server.Tests/InMemoryWebApplicationFactory.cs +++ b/server/NeonSprawl.Server.Tests/InMemoryWebApplicationFactory.cs @@ -3,6 +3,7 @@ using Microsoft.AspNetCore.Mvc.Testing; using Microsoft.AspNetCore.TestHost; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; +using NeonSprawl.Server.Game.AbilityInput; using NeonSprawl.Server.Game.PositionState; using Npgsql; @@ -19,6 +20,7 @@ public sealed class InMemoryWebApplicationFactory : WebApplicationFactory(); + services.AddSingleton(); }); } } diff --git a/server/NeonSprawl.Server/Game/AbilityInput/HotbarLoadoutApi.cs b/server/NeonSprawl.Server/Game/AbilityInput/HotbarLoadoutApi.cs new file mode 100644 index 0000000..0e3dec3 --- /dev/null +++ b/server/NeonSprawl.Server/Game/AbilityInput/HotbarLoadoutApi.cs @@ -0,0 +1,128 @@ +using NeonSprawl.Server.Game.PositionState; + +namespace NeonSprawl.Server.Game.AbilityInput; + +/// Maps hotbar loadout read/update APIs (NEO-29). +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(updates.Count); + var seenSlots = new HashSet(); + 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 bindings) + { + var slots = new List(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, + }; + } +} diff --git a/server/NeonSprawl.Server/Game/AbilityInput/HotbarLoadoutDtos.cs b/server/NeonSprawl.Server/Game/AbilityInput/HotbarLoadoutDtos.cs new file mode 100644 index 0000000..0a0e3e2 --- /dev/null +++ b/server/NeonSprawl.Server/Game/AbilityInput/HotbarLoadoutDtos.cs @@ -0,0 +1,80 @@ +using System.Text.Json.Serialization; + +namespace NeonSprawl.Server.Game.AbilityInput; + +/// Canonical hotbar loadout payload returned by loadout APIs (NEO-29). +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; + + /// Always includes v1 slots 0..7 in ascending order. + [JsonPropertyName("slots")] + public required IReadOnlyList Slots { get; init; } +} + +/// POST body for hotbar loadout updates. +public sealed class HotbarLoadoutUpdateRequest +{ + public const int CurrentSchemaVersion = 1; + + [JsonPropertyName("schemaVersion")] + public int SchemaVersion { get; init; } + + /// + /// Requested slot bindings to apply. Slot entries are upserts; unspecified slots keep previous values. + /// + [JsonPropertyName("slots")] + public IReadOnlyList? Slots { get; init; } +} + +/// POST response for hotbar updates (applied or denied). +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; } + + /// Required on deny responses; omitted when is true. + [JsonPropertyName("reasonCode")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? ReasonCode { get; init; } +} + +/// Wire shape for an individual slot state in GET/POST responses. +public sealed class HotbarSlotStateJson +{ + [JsonPropertyName("slotIndex")] + public required int SlotIndex { get; init; } + + /// Canonical lowercase ability id or null when the slot is unbound. + [JsonPropertyName("abilityId")] + [JsonIgnore(Condition = JsonIgnoreCondition.Never)] + public string? AbilityId { get; init; } +} + +/// Wire shape for an individual slot update in POST requests. +public sealed class HotbarSlotBindingJson +{ + [JsonPropertyName("slotIndex")] + public int SlotIndex { get; init; } + + [JsonPropertyName("abilityId")] + public string? AbilityId { get; init; } +} diff --git a/server/NeonSprawl.Server/Game/AbilityInput/HotbarLoadoutServiceCollectionExtensions.cs b/server/NeonSprawl.Server/Game/AbilityInput/HotbarLoadoutServiceCollectionExtensions.cs new file mode 100644 index 0000000..af0e87b --- /dev/null +++ b/server/NeonSprawl.Server/Game/AbilityInput/HotbarLoadoutServiceCollectionExtensions.cs @@ -0,0 +1,20 @@ +namespace NeonSprawl.Server.Game.AbilityInput; + +/// Registers hotbar loadout persistence: PostgreSQL when configured, otherwise in-memory fallback (NEO-29). +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(); + } + else + { + services.AddSingleton(); + } + + return services; + } +} diff --git a/server/NeonSprawl.Server/Game/AbilityInput/IPlayerHotbarLoadoutStore.cs b/server/NeonSprawl.Server/Game/AbilityInput/IPlayerHotbarLoadoutStore.cs new file mode 100644 index 0000000..a1bc441 --- /dev/null +++ b/server/NeonSprawl.Server/Game/AbilityInput/IPlayerHotbarLoadoutStore.cs @@ -0,0 +1,11 @@ +namespace NeonSprawl.Server.Game.AbilityInput; + +/// Persistence abstraction for per-player hotbar bindings (NEO-29). +public interface IPlayerHotbarLoadoutStore +{ + bool TryGetBindings(string playerId, out IReadOnlyDictionary bindings); + + bool TryUpsertBindings(string playerId, IReadOnlyList updates, out IReadOnlyDictionary bindings); +} + +public readonly record struct HotbarSlotBinding(int SlotIndex, string AbilityId); diff --git a/server/NeonSprawl.Server/Game/AbilityInput/InMemoryPlayerHotbarLoadoutStore.cs b/server/NeonSprawl.Server/Game/AbilityInput/InMemoryPlayerHotbarLoadoutStore.cs new file mode 100644 index 0000000..23bdbdf --- /dev/null +++ b/server/NeonSprawl.Server/Game/AbilityInput/InMemoryPlayerHotbarLoadoutStore.cs @@ -0,0 +1,75 @@ +using System.Collections.Concurrent; +using Microsoft.Extensions.Options; +using NeonSprawl.Server.Game.PositionState; + +namespace NeonSprawl.Server.Game.AbilityInput; + +/// Thread-safe in-memory hotbar loadout store; seeds configured dev player with an empty loadout. +public sealed class InMemoryPlayerHotbarLoadoutStore(IOptions options) : IPlayerHotbarLoadoutStore +{ + private readonly ConcurrentDictionary> byPlayer = + CreateInitialMap(options.Value); + + private static ConcurrentDictionary> CreateInitialMap(GamePositionOptions o) + { + var id = NormalizePlayerId(o.DevPlayerId); + var map = new ConcurrentDictionary>(StringComparer.OrdinalIgnoreCase); + map[id] = new ConcurrentDictionary(); + return map; + } + + public bool TryGetBindings(string playerId, out IReadOnlyDictionary 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 updates, out IReadOnlyDictionary 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 Clone(ConcurrentDictionary source) + { + var copy = new Dictionary(source.Count); + foreach (var kv in source) + { + copy[kv.Key] = kv.Value; + } + + return copy; + } + + private static IReadOnlyDictionary EmptyBindings() => new Dictionary(); + + private static string NormalizePlayerId(string? playerId) + { + var t = playerId?.Trim(); + if (string.IsNullOrEmpty(t)) + { + return string.Empty; + } + + return t.ToLowerInvariant(); + } +} diff --git a/server/NeonSprawl.Server/Game/AbilityInput/PostgresHotbarLoadoutBootstrap.cs b/server/NeonSprawl.Server/Game/AbilityInput/PostgresHotbarLoadoutBootstrap.cs new file mode 100644 index 0000000..43ce203 --- /dev/null +++ b/server/NeonSprawl.Server/Game/AbilityInput/PostgresHotbarLoadoutBootstrap.cs @@ -0,0 +1,37 @@ +namespace NeonSprawl.Server.Game.AbilityInput; + +/// Applies NEO-29 hotbar loadout table DDL once per process. +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); + } + } +} diff --git a/server/NeonSprawl.Server/Game/AbilityInput/PostgresPlayerHotbarLoadoutStore.cs b/server/NeonSprawl.Server/Game/AbilityInput/PostgresPlayerHotbarLoadoutStore.cs new file mode 100644 index 0000000..1599521 --- /dev/null +++ b/server/NeonSprawl.Server/Game/AbilityInput/PostgresPlayerHotbarLoadoutStore.cs @@ -0,0 +1,116 @@ +namespace NeonSprawl.Server.Game.AbilityInput; + +/// PostgreSQL-backed hotbar loadout store keyed by normalized player id. +public sealed class PostgresPlayerHotbarLoadoutStore(Npgsql.NpgsqlDataSource dataSource) : IPlayerHotbarLoadoutStore +{ + public bool TryGetBindings(string playerId, out IReadOnlyDictionary 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 updates, out IReadOnlyDictionary 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 ReadBindings( + Npgsql.NpgsqlConnection conn, + string playerIdNormalized, + Npgsql.NpgsqlTransaction? tx = null) + { + var map = new Dictionary(); + 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 EmptyBindings() => new Dictionary(); + + private static string NormalizePlayerId(string? playerId) + { + var t = playerId?.Trim(); + if (string.IsNullOrEmpty(t)) + { + return string.Empty; + } + + return t.ToLowerInvariant(); + } +} diff --git a/server/NeonSprawl.Server/Game/AbilityInput/PrototypeAbilityRegistry.cs b/server/NeonSprawl.Server/Game/AbilityInput/PrototypeAbilityRegistry.cs new file mode 100644 index 0000000..fd1a2ca --- /dev/null +++ b/server/NeonSprawl.Server/Game/AbilityInput/PrototypeAbilityRegistry.cs @@ -0,0 +1,29 @@ +namespace NeonSprawl.Server.Game.AbilityInput; + +/// Prototype ability allowlist for NEO-29 hotbar bindings. +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 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); + } +} diff --git a/server/NeonSprawl.Server/Game/PositionState/PositionStateServiceCollectionExtensions.cs b/server/NeonSprawl.Server/Game/PositionState/PositionStateServiceCollectionExtensions.cs index 0514272..f066a28 100644 --- a/server/NeonSprawl.Server/Game/PositionState/PositionStateServiceCollectionExtensions.cs +++ b/server/NeonSprawl.Server/Game/PositionState/PositionStateServiceCollectionExtensions.cs @@ -3,7 +3,7 @@ namespace NeonSprawl.Server.Game.PositionState; /// Registers position authority: PostgreSQL when ConnectionStrings:NeonSprawl is set, otherwise in-memory (NS-15 / NS-17). public static class PositionStateServiceCollectionExtensions { - private const string NeonSprawlConnectionStringName = "NeonSprawl"; + internal const string NeonSprawlConnectionStringName = "NeonSprawl"; public static IServiceCollection AddPositionStateStore(this IServiceCollection services, IConfiguration configuration) { diff --git a/server/NeonSprawl.Server/Program.cs b/server/NeonSprawl.Server/Program.cs index fe7e2c7..0664a57 100644 --- a/server/NeonSprawl.Server/Program.cs +++ b/server/NeonSprawl.Server/Program.cs @@ -1,9 +1,11 @@ +using NeonSprawl.Server.Game.AbilityInput; using NeonSprawl.Server.Game.Interaction; using NeonSprawl.Server.Game.PositionState; using NeonSprawl.Server.Game.Targeting; var builder = WebApplication.CreateBuilder(args); builder.Services.AddPositionStateStore(builder.Configuration); +builder.Services.AddHotbarLoadoutStore(builder.Configuration); builder.Services.AddSingleton(); var app = builder.Build(); @@ -22,5 +24,6 @@ app.MapPositionStateApi(); app.MapInteractionApi(); app.MapInteractablesWorldApi(); app.MapTargetingApi(); +app.MapHotbarLoadoutApi(); app.Run(); diff --git a/server/README.md b/server/README.md index 05a505c..4ad782f 100644 --- a/server/README.md +++ b/server/README.md @@ -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). +## 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 From repo root: `dotnet build NeonSprawl.sln` and `dotnet test NeonSprawl.sln`. diff --git a/server/db/migrations/V002__player_hotbar_loadout.sql b/server/db/migrations/V002__player_hotbar_loadout.sql new file mode 100644 index 0000000..b1f4bb6 --- /dev/null +++ b/server/db/migrations/V002__player_hotbar_loadout.sql @@ -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).';