173 lines
6.4 KiB
C#
173 lines
6.4 KiB
C#
using NeonSprawl.Server.Game.Items;
|
|
|
|
namespace NeonSprawl.Server.Game.Encounters;
|
|
|
|
/// <summary>
|
|
/// Applies encounter reward-table grants and marks completion idempotently (NEO-105).
|
|
/// Combat defeat wiring: <see cref="AbilityInput.AbilityCastApi"/> (NEO-106).
|
|
/// </summary>
|
|
public static class EncounterCompletionOperations
|
|
{
|
|
private static readonly EncounterGrantApplied[] EmptyGrants = [];
|
|
|
|
/// <summary>
|
|
/// When all required targets are defeated and the encounter is not yet completed, applies
|
|
/// <paramref name="encounterId"/>'s reward-table fixed grants then marks completion.
|
|
/// </summary>
|
|
public static EncounterCompletionResult TryCompleteAndGrant(
|
|
string playerId,
|
|
string encounterId,
|
|
IEncounterDefinitionRegistry encounterRegistry,
|
|
IRewardTableDefinitionRegistry rewardTableRegistry,
|
|
IEncounterProgressStore progressStore,
|
|
IEncounterCompletionStore completionStore,
|
|
IItemDefinitionRegistry itemRegistry,
|
|
IPlayerInventoryStore inventoryStore,
|
|
TimeProvider timeProvider)
|
|
{
|
|
if (!encounterRegistry.TryNormalizeKnown(encounterId, out var normalizedEncounterId) ||
|
|
!encounterRegistry.TryGetDefinition(normalizedEncounterId, out var encounterDefinition))
|
|
{
|
|
return Deny(EncounterCompletionReasonCodes.UnknownEncounter);
|
|
}
|
|
|
|
if (completionStore.IsCompleted(playerId, normalizedEncounterId))
|
|
{
|
|
return Deny(EncounterCompletionReasonCodes.AlreadyCompleted);
|
|
}
|
|
|
|
if (!EncounterProgressOperations.IsAllRequiredTargetsDefeated(
|
|
playerId,
|
|
normalizedEncounterId,
|
|
encounterRegistry,
|
|
progressStore))
|
|
{
|
|
return Deny(EncounterCompletionReasonCodes.NotReady);
|
|
}
|
|
|
|
if (!rewardTableRegistry.TryGetDefinition(encounterDefinition.RewardTableId, out var rewardTable))
|
|
{
|
|
return Deny(EncounterCompletionReasonCodes.UnknownRewardTable);
|
|
}
|
|
|
|
var grants = rewardTable.FixedGrants;
|
|
if (grants.Count == 0)
|
|
{
|
|
return Deny(EncounterCompletionReasonCodes.UnknownRewardTable);
|
|
}
|
|
|
|
if (!inventoryStore.TryGetSnapshot(playerId, out var snapshot))
|
|
{
|
|
return Deny(EncounterCompletionReasonCodes.InventoryStoreMissing);
|
|
}
|
|
|
|
var simulated = CloneSnapshot(snapshot);
|
|
foreach (var grant in grants)
|
|
{
|
|
if (!PlayerInventoryOperations.TrySimulateAddStack(
|
|
ref simulated,
|
|
grant.ItemId,
|
|
grant.Quantity,
|
|
itemRegistry))
|
|
{
|
|
return Deny(EncounterCompletionReasonCodes.InventoryFull);
|
|
}
|
|
}
|
|
|
|
var applied = new List<EncounterGrantApplied>(grants.Count);
|
|
foreach (var grant in grants)
|
|
{
|
|
var addOutcome = PlayerInventoryOperations.TryAddStack(
|
|
playerId,
|
|
grant.ItemId,
|
|
grant.Quantity,
|
|
itemRegistry,
|
|
inventoryStore);
|
|
|
|
if (addOutcome.Kind == PlayerInventoryMutationKind.StoreMissing)
|
|
{
|
|
CompensatingRemoveGrants(playerId, applied, itemRegistry, inventoryStore);
|
|
return Deny(EncounterCompletionReasonCodes.InventoryStoreMissing);
|
|
}
|
|
|
|
if (addOutcome.Kind == PlayerInventoryMutationKind.Denied)
|
|
{
|
|
CompensatingRemoveGrants(playerId, applied, itemRegistry, inventoryStore);
|
|
return Deny(MapInventoryReason(addOutcome.ReasonCode));
|
|
}
|
|
|
|
applied.Add(new EncounterGrantApplied(grant.ItemId, grant.Quantity));
|
|
}
|
|
|
|
var completedAt = timeProvider.GetUtcNow();
|
|
if (!completionStore.TryMarkCompleted(playerId, normalizedEncounterId, completedAt))
|
|
{
|
|
CompensatingRemoveGrants(playerId, applied, itemRegistry, inventoryStore);
|
|
return Deny(EncounterCompletionReasonCodes.AlreadyCompleted);
|
|
}
|
|
|
|
// TODO(E7.M2): quest-credit router consumes EncounterCompleteEvent (NEO-107 expands persistence + hook).
|
|
|
|
var normalizedPlayerId = EncounterProgressIds.NormalizePlayerId(playerId);
|
|
var completeEvent = new EncounterCompleteEvent(
|
|
normalizedPlayerId,
|
|
normalizedEncounterId,
|
|
rewardTable.Id,
|
|
applied,
|
|
completedAt,
|
|
MakeIdempotencyKey(normalizedPlayerId, normalizedEncounterId));
|
|
|
|
return new EncounterCompletionResult(
|
|
Success: true,
|
|
ReasonCode: null,
|
|
GrantsApplied: applied,
|
|
CompleteEvent: completeEvent,
|
|
CompletedAt: completedAt);
|
|
}
|
|
|
|
private static string MapInventoryReason(string? reasonCode) =>
|
|
reasonCode switch
|
|
{
|
|
PlayerInventoryReasonCodes.InventoryFull => EncounterCompletionReasonCodes.InventoryFull,
|
|
PlayerInventoryReasonCodes.InvalidItem => EncounterCompletionReasonCodes.InvalidItem,
|
|
PlayerInventoryReasonCodes.InvalidQuantity => EncounterCompletionReasonCodes.InvalidQuantity,
|
|
_ => reasonCode ?? EncounterCompletionReasonCodes.InventoryFull,
|
|
};
|
|
|
|
private static string MakeIdempotencyKey(string normalizedPlayerId, string normalizedEncounterId) =>
|
|
$"{normalizedPlayerId}:{normalizedEncounterId}";
|
|
|
|
private static PlayerInventorySnapshot CloneSnapshot(PlayerInventorySnapshot snapshot) =>
|
|
new()
|
|
{
|
|
BagSlots = PlayerInventorySnapshot.CloneSlots(snapshot.BagSlots),
|
|
EquipmentSlots = PlayerInventorySnapshot.CloneSlots(snapshot.EquipmentSlots),
|
|
};
|
|
|
|
private static void CompensatingRemoveGrants(
|
|
string playerId,
|
|
IReadOnlyList<EncounterGrantApplied> grants,
|
|
IItemDefinitionRegistry itemRegistry,
|
|
IPlayerInventoryStore inventoryStore)
|
|
{
|
|
for (var i = grants.Count - 1; i >= 0; i--)
|
|
{
|
|
var grant = grants[i];
|
|
_ = PlayerInventoryOperations.TryRemoveStack(
|
|
playerId,
|
|
grant.ItemId,
|
|
grant.Quantity,
|
|
itemRegistry,
|
|
inventoryStore);
|
|
}
|
|
}
|
|
|
|
private static EncounterCompletionResult Deny(string reasonCode) =>
|
|
new(
|
|
Success: false,
|
|
ReasonCode: reasonCode,
|
|
GrantsApplied: EmptyGrants,
|
|
CompleteEvent: null,
|
|
CompletedAt: null);
|
|
}
|