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, }; } }