using NeonSprawl.Server.Diagnostics;
using NeonSprawl.Server.Game.Combat;
using NeonSprawl.Server.Game.Gigs;
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 server cooldown window.
/// NEO-82: after E1 gates pass, invokes ; per-ability catalog cooldown on successful resolve.
/// NEO-44: on accept when is true, best-effort gig XP grant via (not E2.M2 skill XP).
///
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,
ICombatEntityHealthStore healthStore,
IPlayerGigProgressionStore gigStore,
ILoggerFactory loggerFactory,
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 });
}
if (!abilities.TryGetDefinition(normalizedRequest, out var definition))
{
// NEO-30 telemetry hook site: `ability_cast_denied` + reasonCode (TODO(E9.M1): catalog emit).
return JsonAbilityCast(
id,
body,
new AbilityCastResponse
{
Accepted = false,
ReasonCode = ReasonUnknownAbility,
},
normalizedRequest);
}
var combatResult = CombatOperations.TryResolve(
normalizedRequest,
lookupKey,
abilities,
healthStore);
if (!combatResult.Success)
{
// NEO-30 telemetry hook site: `ability_cast_denied` + reasonCode (TODO(E9.M1): catalog emit).
return JsonAbilityCast(
id,
body,
new AbilityCastResponse
{
Accepted = false,
ReasonCode = combatResult.ReasonCode,
},
normalizedRequest);
}
cooldowns.StartCooldown(
id,
body.SlotIndex,
now,
TimeSpan.FromSeconds(definition.CooldownSeconds));
if (combatResult.TargetDefeated)
{
CombatDefeatGigXpGrant.GrantOnCombatDefeat(
id.Trim(),
gigStore,
loggerFactory.CreateLogger(nameof(CombatDefeatGigXpGrant)));
}
// 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,
MapAcceptResponse(normalizedRequest, lookupKey, combatResult),
normalizedRequest);
});
return app;
}
internal static AbilityCastResponse MapAcceptResponse(
string normalizedAbilityId,
string normalizedTargetId,
CombatResult result)
{
return new AbilityCastResponse
{
Accepted = true,
CombatResolution = new CombatResolutionJson
{
AbilityId = normalizedAbilityId,
TargetId = normalizedTargetId,
DamageDealt = result.DamageDealt,
TargetRemainingHp = result.TargetRemainingHp!.Value,
TargetDefeated = result.TargetDefeated,
},
};
}
}