using NeonSprawl.Server.Diagnostics; using NeonSprawl.Server.Game.Combat; 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 /// (on JSON denies, — wire JSON reasonCode) — hook sites are marked inline below. /// TODO(E9.M1): emit cataloged events (payload: route player id, slotIndex, abilityId, targetId; on deny include reasonCode). /// Optional dev stdout: set NEON_SPRAWL_API_LOG=1 (see ) for one line per JSON cast response. /// HTTP 400/404 here are outside the v1 cast deny contract (no JSON AbilityCastResponse). /// NEO-32: JSON deny on_cooldown when the slot is inside the prototype global cooldown window. /// 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; /// Slot still inside server-authoritative cooldown window (NEO-32). public const string ReasonOnCooldown = "on_cooldown"; private static IResult JsonAbilityCast( string id, AbilityCastRequest body, AbilityCastResponse response, string? abilityIdForLog = null) { PrototypeApiConsoleLog.WriteAbilityCastLine( id, body.SlotIndex, abilityIdForLog ?? body.AbilityId, body.TargetId, response.Accepted, response.ReasonCode); return Results.Json(response); } public static WebApplication MapAbilityCastApi(this WebApplication app) { app.MapPost( "/game/players/{id}/ability-cast", ( string id, AbilityCastRequest? body, IPositionStateStore positions, IPlayerHotbarLoadoutStore store, IPlayerTargetLockStore locks, IPlayerAbilityCooldownStore cooldowns, IAbilityDefinitionRegistry abilities, TimeProvider clock) => { // 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 JsonAbilityCast( id, body, 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 JsonAbilityCast( id, body, new AbilityCastResponse { Accepted = false, ReasonCode = ReasonSlotUnbound, }); } if (string.IsNullOrWhiteSpace(body.AbilityId) || !abilities.TryNormalizeKnown(body.AbilityId, out var normalizedRequest)) { // NEO-30 telemetry hook site: `ability_cast_denied` + reasonCode (TODO(E9.M1): catalog emit). return JsonAbilityCast( id, body, 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 JsonAbilityCast( id, body, 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 JsonAbilityCast( id, body, 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 JsonAbilityCast( id, body, 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 JsonAbilityCast( id, body, 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 JsonAbilityCast( id, body, new AbilityCastResponse { Accepted = false, ReasonCode = ReasonOutOfRange }); } var now = clock.GetUtcNow(); if (cooldowns.IsOnCooldown(id, body.SlotIndex, now)) { // NEO-30 telemetry hook site: `ability_cast_denied` + reasonCode (TODO(E9.M1): catalog emit). return JsonAbilityCast( id, body, new AbilityCastResponse { Accepted = false, ReasonCode = ReasonOnCooldown }); } cooldowns.StartCooldown(id, body.SlotIndex, now, AbilityPrototypeCooldown.GlobalDuration); // 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 JsonAbilityCast( id, body, new AbilityCastResponse { Accepted = true }, normalizedRequest); }); return app; } }