using NeonSprawl.Server.Game.Items; namespace NeonSprawl.Server.Game.Encounters; /// /// Applies encounter reward-table grants and marks completion idempotently (NEO-105). /// Combat defeat wiring: (NEO-106). /// public static class EncounterCompletionOperations { private static readonly EncounterGrantApplied[] EmptyGrants = []; /// /// When all required targets are defeated and the encounter is not yet completed, applies /// 's reward-table fixed grants then marks completion. /// 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(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 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); }