using NeonSprawl.Server.Game.PositionState; namespace NeonSprawl.Server.Game.AbilityInput; /// Maps prototype ability cast POST (NEO-31). public static class AbilityCastApi { public const string ReasonSlotOutOfBounds = "slot_out_of_bounds"; public const string ReasonSlotUnbound = "slot_unbound"; public const string ReasonLoadoutMismatch = "loadout_mismatch"; public const string ReasonUnknownAbility = "unknown_ability"; public static WebApplication MapAbilityCastApi(this WebApplication app) { app.MapPost( "/game/players/{id}/ability-cast", (string id, AbilityCastRequest? body, IPositionStateStore positions, IPlayerHotbarLoadoutStore store) => { if (body is null || body.SchemaVersion != AbilityCastRequest.CurrentSchemaVersion) { return Results.BadRequest(); } if (!positions.TryGetPosition(id, out _)) { return Results.NotFound(); } if (!store.TryGetBindings(id, out var bindings)) { return Results.NotFound(); } if (body.SlotIndex < 0 || body.SlotIndex >= HotbarLoadoutResponse.SlotCountV1) { return Results.Json( new AbilityCastResponse { Accepted = false, ReasonCode = ReasonSlotOutOfBounds, }); } if (!bindings.TryGetValue(body.SlotIndex, out var boundAbility) || string.IsNullOrWhiteSpace(boundAbility)) { return Results.Json( new AbilityCastResponse { Accepted = false, ReasonCode = ReasonSlotUnbound, }); } if (string.IsNullOrWhiteSpace(body.AbilityId) || !PrototypeAbilityRegistry.TryNormalizeKnown(body.AbilityId, out var normalizedRequest)) { return Results.Json( new AbilityCastResponse { Accepted = false, ReasonCode = ReasonUnknownAbility, }); } if (!string.Equals(normalizedRequest, boundAbility, StringComparison.Ordinal)) { return Results.Json( new AbilityCastResponse { Accepted = false, ReasonCode = ReasonLoadoutMismatch, }); } // TargetId echoed for NEO-28 / E5.M1; server does not validate against lock store in NEO-31. return Results.Json(new AbilityCastResponse { Accepted = true }); }); return app; } }