using NeonSprawl.Server.Game.PositionState; using NeonSprawl.Server.Game.Targeting; using NeonSprawl.Server.Game.World; namespace NeonSprawl.Server.Game.AbilityInput; /// Maps prototype ability cast POST (NEO-31 + NEO-28 target authority). /// /// Epic 1 Slice 3 product telemetry names ability_cast_requested and ability_cast_denied /// (see AbilityCastResponse.reasonCode on denies) — hook sites are marked inline below. /// TODO(E9.M1): emit cataloged events (payload: route player id, slotIndex, abilityId, targetId; on deny include reasonCode). /// HTTP 400/404 here are outside the v1 cast deny contract (no JSON AbilityCastResponse). /// 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"; /// Request target missing, unknown to the prototype registry, or not the server lock (NEO-28). public const string ReasonInvalidTarget = TargetValidity.InvalidTarget; /// Locked prototype target is outside horizontal reach of authoritative position (NEO-28). public const string ReasonOutOfRange = TargetValidity.OutOfRange; public static WebApplication MapAbilityCastApi(this WebApplication app) { app.MapPost( "/game/players/{id}/ability-cast", ( string id, AbilityCastRequest? body, IPositionStateStore positions, IPlayerHotbarLoadoutStore store, IPlayerTargetLockStore locks) => { // NEO-30: not a Slice 3 `ability_cast_denied` site — no AbilityCastResponse body. if (body is null || body.SchemaVersion != AbilityCastRequest.CurrentSchemaVersion) { return Results.BadRequest(); } // NEO-30: not a Slice 3 `ability_cast_denied` site — unknown player for cast prototype. if (!positions.TryGetPosition(id, out var snap)) { return Results.NotFound(); } // NEO-30: not a Slice 3 `ability_cast_denied` site — no persisted loadout row. if (!store.TryGetBindings(id, out var bindings)) { return Results.NotFound(); } if (body.SlotIndex < 0 || body.SlotIndex >= HotbarLoadoutResponse.SlotCountV1) { // NEO-30 telemetry hook site: `ability_cast_denied` + reasonCode (TODO(E9.M1): catalog emit). return Results.Json( new AbilityCastResponse { Accepted = false, ReasonCode = ReasonSlotOutOfBounds, }); } if (!bindings.TryGetValue(body.SlotIndex, out var boundAbility) || string.IsNullOrWhiteSpace(boundAbility)) { // NEO-30 telemetry hook site: `ability_cast_denied` + reasonCode (TODO(E9.M1): catalog emit). return Results.Json( new AbilityCastResponse { Accepted = false, ReasonCode = ReasonSlotUnbound, }); } if (string.IsNullOrWhiteSpace(body.AbilityId) || !PrototypeAbilityRegistry.TryNormalizeKnown(body.AbilityId, out var normalizedRequest)) { // NEO-30 telemetry hook site: `ability_cast_denied` + reasonCode (TODO(E9.M1): catalog emit). return Results.Json( new AbilityCastResponse { Accepted = false, ReasonCode = ReasonUnknownAbility, }); } if (!string.Equals(normalizedRequest, boundAbility, StringComparison.Ordinal)) { // NEO-30 telemetry hook site: `ability_cast_denied` + reasonCode (TODO(E9.M1): catalog emit). return Results.Json( new AbilityCastResponse { Accepted = false, ReasonCode = ReasonLoadoutMismatch, }); } if (string.IsNullOrWhiteSpace(body.TargetId)) { // NEO-30 telemetry hook site: `ability_cast_denied` + reasonCode (TODO(E9.M1): catalog emit). return Results.Json( new AbilityCastResponse { Accepted = false, ReasonCode = ReasonInvalidTarget }); } var lookupKey = body.TargetId.Trim().ToLowerInvariant(); if (!PrototypeTargetRegistry.TryGet(lookupKey, out var entry)) { // NEO-30 telemetry hook site: `ability_cast_denied` + reasonCode (TODO(E9.M1): catalog emit). return Results.Json( new AbilityCastResponse { Accepted = false, ReasonCode = ReasonInvalidTarget }); } var (lockIdLowercase, _) = locks.GetLockState(id); if (string.IsNullOrEmpty(lockIdLowercase) || !string.Equals(lookupKey, lockIdLowercase, StringComparison.Ordinal)) { // NEO-30 telemetry hook site: `ability_cast_denied` + reasonCode (TODO(E9.M1): catalog emit). return Results.Json( new AbilityCastResponse { Accepted = false, ReasonCode = ReasonInvalidTarget }); } if (!HorizontalReach.IsWithinHorizontalRadius(snap.X, snap.Z, entry.X, entry.Z, entry.LockRadius)) { // NEO-30 telemetry hook site: `ability_cast_denied` + reasonCode (TODO(E9.M1): catalog emit). return Results.Json( new AbilityCastResponse { Accepted = false, ReasonCode = ReasonOutOfRange }); } // NEO-30 telemetry hook site: `ability_cast_requested` at authoritative accept (TODO(E9.M1): catalog emit). // Payload notes for E9.M1: player id = route `id`, body.SlotIndex, normalized ability id, body.TargetId (lock-aligned). return Results.Json(new AbilityCastResponse { Accepted = true }); }); return app; } }