neon-sprawl/server/NeonSprawl.Server/Game/AbilityInput/AbilityCastApi.cs

141 lines
6.7 KiB
C#

using NeonSprawl.Server.Game.PositionState;
using NeonSprawl.Server.Game.Targeting;
using NeonSprawl.Server.Game.World;
namespace NeonSprawl.Server.Game.AbilityInput;
/// <summary>Maps prototype ability cast POST (NEO-31 + NEO-28 target authority).</summary>
/// <remarks>
/// Epic 1 Slice 3 product telemetry names <c>ability_cast_requested</c> and <c>ability_cast_denied</c>
/// (see <c>AbilityCastResponse.reasonCode</c> 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 <c>AbilityCastResponse</c>).
/// </remarks>
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";
/// <summary>Request target missing, unknown to the prototype registry, or not the server lock (NEO-28).</summary>
public const string ReasonInvalidTarget = TargetValidity.InvalidTarget;
/// <summary>Locked prototype target is outside horizontal reach of authoritative position (NEO-28).</summary>
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;
}
}