using NeonSprawl.Server.Diagnostics; using NeonSprawl.Server.Game.Combat; using NeonSprawl.Server.Game.Encounters; using NeonSprawl.Server.Game.Factions; using NeonSprawl.Server.Game.Gigs; using NeonSprawl.Server.Game.Items; using NeonSprawl.Server.Game.Mastery; using NeonSprawl.Server.Game.PositionState; using NeonSprawl.Server.Game.Npc; using NeonSprawl.Server.Game.Quests; using NeonSprawl.Server.Game.Rewards; using NeonSprawl.Server.Game.Skills; 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). /// NEO-106: on every successful resolve, best-effort encounter activate/defeat/completion via . /// 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, IThreatStateStore threatStore, INpcBehaviorDefinitionRegistry behaviorRegistry, INpcRuntimeStateStore npcRuntimeStore, IPlayerGigProgressionStore gigStore, IEncounterDefinitionRegistry encounterRegistry, IRewardTableDefinitionRegistry rewardTableRegistry, IEncounterProgressStore encounterProgressStore, IEncounterCompletionStore encounterCompletionStore, IEncounterCompleteEventStore encounterCompleteEventStore, IItemDefinitionRegistry itemRegistry, IPlayerInventoryStore inventoryStore, IQuestDefinitionRegistry questRegistry, IPlayerQuestStateStore questProgressStore, ISkillDefinitionRegistry skillRegistry, IPlayerSkillProgressionStore skillProgressionStore, ISkillLevelCurve levelCurve, PerkUnlockEngine perkUnlockEngine, IPlayerPerkStateStore perkStore, IRewardDeliveryStore deliveryStore, IFactionStandingStore standingStore, IReputationDeltaStore auditStore, 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 (!PrototypeNpcRegistry.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); } if (combatResult.DamageDealt > 0) { AggroOperations.TryClearOnLeashForPlayer( id, positions, behaviorRegistry, threatStore); AggroOperations.TryAcquire(lookupKey, id, threatStore); } cooldowns.StartCooldown( id, body.SlotIndex, now, TimeSpan.FromSeconds(definition.CooldownSeconds)); if (combatResult.TargetDefeated) { NpcRuntimeOperations.TryStopOnTargetDefeat( lookupKey, healthStore, threatStore, npcRuntimeStore); CombatDefeatGigXpGrant.GrantOnCombatDefeat( id.Trim(), gigStore, loggerFactory.CreateLogger(nameof(CombatDefeatGigXpGrant))); } EncounterCombatWiring.TryProcessCastOutcome( id.Trim(), lookupKey, combatResult.DamageDealt, combatResult.TargetDefeated, encounterRegistry, rewardTableRegistry, encounterProgressStore, encounterCompletionStore, encounterCompleteEventStore, itemRegistry, inventoryStore, questRegistry, questProgressStore, skillRegistry, skillProgressionStore, levelCurve, perkUnlockEngine, perkStore, deliveryStore, standingStore, auditStore, clock, loggerFactory.CreateLogger(nameof(EncounterCombatWiring))); // 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, }, }; } }