314 lines
14 KiB
C#
314 lines
14 KiB
C#
using NeonSprawl.Server.Diagnostics;
|
|
using NeonSprawl.Server.Game.Combat;
|
|
using NeonSprawl.Server.Game.Encounters;
|
|
using NeonSprawl.Server.Game.Gigs;
|
|
using NeonSprawl.Server.Game.Items;
|
|
using NeonSprawl.Server.Game.PositionState;
|
|
using NeonSprawl.Server.Game.Npc;
|
|
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>
|
|
/// (on JSON denies, <see cref="AbilityCastResponse.ReasonCode"/> — wire JSON <c>reasonCode</c>) — 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 <c>NEON_SPRAWL_API_LOG=1</c> (see <see cref="Diagnostics.PrototypeApiConsoleLog"/>) for one line per JSON cast response.
|
|
/// HTTP 400/404 here are outside the v1 cast deny contract (no JSON <c>AbilityCastResponse</c>).
|
|
/// NEO-32: JSON deny <c>on_cooldown</c> when the slot is inside the server cooldown window.
|
|
/// NEO-82: after E1 gates pass, invokes <see cref="CombatOperations.TryResolve"/>; per-ability catalog cooldown on successful resolve.
|
|
/// NEO-44: on accept when <see cref="CombatResult.TargetDefeated"/> is true, best-effort gig XP grant via <see cref="CombatDefeatGigXpGrant"/> (not E2.M2 skill XP).
|
|
/// NEO-106: on every successful resolve, best-effort encounter activate/defeat/completion via <see cref="EncounterCombatWiring"/>.
|
|
/// </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;
|
|
|
|
/// <summary>Slot still inside server-authoritative cooldown window (NEO-32).</summary>
|
|
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,
|
|
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,
|
|
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,
|
|
},
|
|
};
|
|
}
|
|
}
|