using NeonSprawl.Server.Game.Factions; using NeonSprawl.Server.Game.Items; using NeonSprawl.Server.Game.Mastery; using NeonSprawl.Server.Game.Rewards; using NeonSprawl.Server.Game.Skills; namespace NeonSprawl.Server.Game.Contracts; /// /// Server-authoritative contract completion on encounter clear (NEO-149). /// HTTP: E7M4-08 (NEO-151). Telemetry hook sites: E7M4-09 (NEO-152). /// public static class ContractCompletionOperations { /// /// When the player has an active contract bound to , delivers the template /// completion bundle then marks the instance complete and appends an outcome audit row. /// public static ContractCompletionOperationResult TryCompleteOnEncounterClear( string playerId, string encounterId, IContractInstanceStore instanceStore, IContractOutcomeStore outcomeStore, IContractTemplateRegistry templateRegistry, IItemDefinitionRegistry itemRegistry, IPlayerInventoryStore inventoryStore, ISkillDefinitionRegistry skillRegistry, IPlayerSkillProgressionStore skillProgressionStore, ISkillLevelCurve levelCurve, PerkUnlockEngine perkUnlockEngine, IPlayerPerkStateStore perkStore, IRewardDeliveryStore deliveryStore, IFactionStandingStore standingStore, IReputationDeltaStore auditStore, TimeProvider timeProvider) { var normalizedPlayerId = ContractInstanceIds.NormalizePlayerId(playerId); var normalizedEncounterId = NormalizeEncounterId(encounterId); if (normalizedPlayerId.Length == 0 || normalizedEncounterId.Length == 0) { return Deny(ContractCompletionReasonCodes.InvalidIds); } if (!instanceStore.TryGetActiveForPlayer(normalizedPlayerId, out var activeSnapshot)) { return NoOp(ContractCompletionReasonCodes.NoActiveContract); } if (!templateRegistry.TryGetDefinition(activeSnapshot.TemplateId, out var template)) { return Deny(ContractCompletionReasonCodes.UnknownTemplate, activeSnapshot); } if (!EncounterMatchesTemplate(normalizedEncounterId, template.EncounterTemplateId)) { return NoOp(ContractCompletionReasonCodes.EncounterMismatch, activeSnapshot); } if (activeSnapshot.Status == ContractInstanceStatus.Completed) { return Success(activeSnapshot, TryGetExistingOutcome(outcomeStore, activeSnapshot)); } if (!instanceStore.CanWritePlayer(normalizedPlayerId)) { return Deny(ContractCompletionReasonCodes.PlayerNotWritable, activeSnapshot); } var delivery = RewardRouterOperations.TryDeliverContractCompletion( normalizedPlayerId, activeSnapshot.ContractInstanceId, template.CompletionRewardBundle, itemRegistry, inventoryStore, skillRegistry, skillProgressionStore, levelCurve, perkUnlockEngine, perkStore, deliveryStore, standingStore, auditStore, timeProvider); if (!delivery.Success) { return Deny(delivery.ReasonCode ?? RewardDeliveryReasonCodes.InvalidContractInstanceId, activeSnapshot); } var completedAt = timeProvider.GetUtcNow(); if (instanceStore.TryMarkComplete( normalizedPlayerId, activeSnapshot.ContractInstanceId, completedAt, out var completedSnapshot)) { var outcome = AppendOutcome( outcomeStore, completedSnapshot, delivery, completedAt); // --- Telemetry hook site (NEO-152): future E9.M1 catalog event `contract_complete` --- // TODO(E9.M1): catalog emit — first-time completion only; idempotent replay returns below without hook. // Planned payload: playerId, contractInstanceId, templateId, encounterId, completedAt, idempotencyKey. return Success(completedSnapshot, outcome); } if (completedSnapshot?.Status == ContractInstanceStatus.Completed) { return Success(completedSnapshot, TryGetExistingOutcome(outcomeStore, completedSnapshot)); } if (instanceStore.TryGet( normalizedPlayerId, activeSnapshot.ContractInstanceId, out var afterAttempt) && afterAttempt.Status == ContractInstanceStatus.Completed) { return Success(afterAttempt, TryGetExistingOutcome(outcomeStore, afterAttempt)); } if (!instanceStore.CanWritePlayer(normalizedPlayerId)) { return Deny(ContractCompletionReasonCodes.PlayerNotWritable, activeSnapshot); } if (instanceStore.TryGet( normalizedPlayerId, activeSnapshot.ContractInstanceId, out var currentAfterFailedMark)) { return Deny(ContractInstanceReasonCodes.InstanceNotActive, currentAfterFailedMark); } return Deny(ContractInstanceReasonCodes.InstanceNotFound, activeSnapshot); } private static ContractOutcomeRow? AppendOutcome( IContractOutcomeStore outcomeStore, ContractInstanceState snapshot, RewardDeliveryResult delivery, DateTimeOffset recordedAt) { var idempotencyKey = ContractOutcomeIds.MakeIdempotencyKey( snapshot.PlayerId, snapshot.ContractInstanceId); if (idempotencyKey.Length == 0) { return null; } var row = new ContractOutcomeRow( MakeOutcomeRowId(idempotencyKey), snapshot.ContractInstanceId, snapshot.PlayerId, idempotencyKey, delivery.GrantedItems, delivery.GrantedSkillXp, delivery.GrantedReputation, recordedAt); if (outcomeStore.TryAppend(row)) { return row; } return outcomeStore.TryGetByIdempotencyKey(idempotencyKey, out var existing) ? existing : null; } private static ContractOutcomeRow? TryGetExistingOutcome( IContractOutcomeStore outcomeStore, ContractInstanceState snapshot) { var idempotencyKey = ContractOutcomeIds.MakeIdempotencyKey( snapshot.PlayerId, snapshot.ContractInstanceId); return idempotencyKey.Length > 0 && outcomeStore.TryGetByIdempotencyKey(idempotencyKey, out var existing) ? existing : null; } private static string MakeOutcomeRowId(string idempotencyKey) => $"{idempotencyKey}:outcome"; private static bool EncounterMatchesTemplate(string normalizedEncounterId, string templateEncounterId) => string.Equals( normalizedEncounterId, NormalizeEncounterId(templateEncounterId), StringComparison.Ordinal); private static string NormalizeEncounterId(string? encounterId) => encounterId?.Trim() ?? string.Empty; private static ContractCompletionOperationResult NoOp( string reasonCode, ContractInstanceState? snapshot = null) => new(true, reasonCode, snapshot, null); private static ContractCompletionOperationResult Success( ContractInstanceState snapshot, ContractOutcomeRow? outcome) => new(true, null, snapshot, outcome); private static ContractCompletionOperationResult Deny( string reasonCode, ContractInstanceState? snapshot = null) => new(false, reasonCode, snapshot, null); }