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, 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); } // 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, }, }; } }