using System.Diagnostics; using System.Security.Cryptography; using FinalFactory.Rendezvous.Contracts; using LiteNetLib; namespace FinalFactory.Rendezvous.Client; public enum RendezvousConnectionState { Punching = 1, Connecting = 2, Connected = 3, Cancelled = 4, TimedOut = 5, Rejected = 6, ManagerStopped = 7, Disposed = 8, } public sealed class RendezvousConnectionCompletedEventArgs : EventArgs { [Obsolete("Completion events now expose a typed Outcome. Construct these arguments only for legacy test doubles.")] public RendezvousConnectionCompletedEventArgs( RendezvousConnectionState state, NetPeer? peer) : this(state, RendezvousCompletionInvariant.FromLegacy(state, peer)) { } internal RendezvousConnectionCompletedEventArgs( RendezvousConnectionState state, RendezvousConnectionOutcome outcome) { RendezvousCompletionInvariant.Validate(state, outcome); State = state; Outcome = outcome; } public RendezvousConnectionState State { get; } public RendezvousConnectionOutcome Outcome { get; } public NetPeer? Peer => Outcome.Peer; } internal static class RendezvousCompletionInvariant { internal static RendezvousConnectionOutcome FromLegacy( RendezvousConnectionState state, NetPeer? peer) => state switch { RendezvousConnectionState.Connected when peer is not null => RendezvousConnectionOutcome.Create( ConnectionOutcomeKind.Connected, RendezvousConnectionOutcomeSource.LocalTraversal, RendezvousConnectionFailureCategory.None, RendezvousConnectionPhase.Complete, TimeSpan.Zero, peer: peer), RendezvousConnectionState.Cancelled => RendezvousConnectionOutcome.Create( ConnectionOutcomeKind.Cancelled, RendezvousConnectionOutcomeSource.Caller, RendezvousConnectionFailureCategory.Lifecycle, RendezvousConnectionPhase.Complete, TimeSpan.Zero), RendezvousConnectionState.TimedOut => RendezvousConnectionOutcome.Create( ConnectionOutcomeKind.DirectConnectTimedOut, RendezvousConnectionOutcomeSource.LocalTraversal, RendezvousConnectionFailureCategory.DirectConnection, RendezvousConnectionPhase.DirectConnection, TimeSpan.Zero), RendezvousConnectionState.Rejected => RendezvousConnectionOutcome.Create( ConnectionOutcomeKind.HostRejected, RendezvousConnectionOutcomeSource.RemoteHost, RendezvousConnectionFailureCategory.Authorization, RendezvousConnectionPhase.Authorization, TimeSpan.Zero), RendezvousConnectionState.ManagerStopped => RendezvousConnectionOutcome.Create( ConnectionOutcomeKind.ManagerStopped, RendezvousConnectionOutcomeSource.Lifecycle, RendezvousConnectionFailureCategory.Lifecycle, RendezvousConnectionPhase.Complete, TimeSpan.Zero), RendezvousConnectionState.Disposed => RendezvousConnectionOutcome.Create( ConnectionOutcomeKind.Disposed, RendezvousConnectionOutcomeSource.Lifecycle, RendezvousConnectionFailureCategory.Lifecycle, RendezvousConnectionPhase.Complete, TimeSpan.Zero), RendezvousConnectionState.Connected => throw new ArgumentNullException( nameof(peer), "A connected completion requires a peer."), _ => throw new ArgumentOutOfRangeException( nameof(state), state, "A completion event requires a terminal connection state."), }; internal static void Validate( RendezvousConnectionState state, RendezvousConnectionOutcome outcome) { if (outcome is null) { throw new ArgumentNullException(nameof(outcome)); } if ((state == RendezvousConnectionState.Connected) != outcome.IsSuccess) { throw new ArgumentException( "The connection state and typed outcome contradict each other.", nameof(outcome)); } } } public sealed class RendezvousCoordinatorOptions { public int MaximumPunchRequests { get; set; } = 5; public int MaximumAttemptChecksPerPoll { get; set; } = 128; public TimeSpan InitialPunchRetryDelay { get; set; } = TimeSpan.FromMilliseconds(200); public TimeSpan MaximumPunchRetryDelay { get; set; } = TimeSpan.FromSeconds(2); public TimeSpan PunchTimeout { get; set; } = TimeSpan.FromSeconds(10); public TimeSpan DirectConnectTimeout { get; set; } = TimeSpan.FromSeconds(5); public TimeSpan ConnectionTicketLifetime { get; set; } = TimeSpan.FromSeconds(20); public double JitterRatio { get; set; } = 0.2; public NetworkEndpoint? DedicatedFallbackOverride { get; set; } internal RendezvousCoordinatorOptions CopyAndValidate() { if (MaximumPunchRequests is < 1 or > 20 || MaximumAttemptChecksPerPoll is < 1 or > 1_024 || InitialPunchRetryDelay < TimeSpan.FromMilliseconds(10) || MaximumPunchRetryDelay < InitialPunchRetryDelay || MaximumPunchRetryDelay > TimeSpan.FromSeconds(10) || PunchTimeout <= TimeSpan.Zero || PunchTimeout > TimeSpan.FromSeconds(30) || DirectConnectTimeout <= TimeSpan.Zero || DirectConnectTimeout > TimeSpan.FromSeconds(30) || ConnectionTicketLifetime <= TimeSpan.Zero || ConnectionTicketLifetime > TimeSpan.FromSeconds(20) || JitterRatio is < 0 or > 1 || DedicatedFallbackOverride is not null && !ContractValidation.IsNetworkEndpointValid(DedicatedFallbackOverride)) { throw new ArgumentOutOfRangeException(nameof(RendezvousCoordinatorOptions)); } return new RendezvousCoordinatorOptions { MaximumPunchRequests = MaximumPunchRequests, MaximumAttemptChecksPerPoll = MaximumAttemptChecksPerPoll, InitialPunchRetryDelay = InitialPunchRetryDelay, MaximumPunchRetryDelay = MaximumPunchRetryDelay, PunchTimeout = PunchTimeout, DirectConnectTimeout = DirectConnectTimeout, ConnectionTicketLifetime = ConnectionTicketLifetime, JitterRatio = JitterRatio, DedicatedFallbackOverride = RendezvousEndpoint.Copy(DedicatedFallbackOverride), }; } } internal interface IRendezvousCoordinatorClock { DateTimeOffset UtcNow { get; } TimeSpan Elapsed { get; } } internal sealed class SystemRendezvousCoordinatorClock : IRendezvousCoordinatorClock { private readonly long _origin = Stopwatch.GetTimestamp(); public DateTimeOffset UtcNow => DateTimeOffset.UtcNow; public TimeSpan Elapsed => TimeSpan.FromSeconds( (Stopwatch.GetTimestamp() - _origin) / (double)Stopwatch.Frequency); } internal static class RendezvousManagerGuard { internal static void Validate( NetManager manager, RendezvousNetListener networkEvents) { networkEvents.ValidateManager(manager); if (!manager.IsRunning) { throw new InvalidOperationException("The caller-owned LiteNetLib manager must be running."); } if (!manager.NatPunchEnabled || manager.UnsyncedEvents || manager.NatPunchModule.UnsyncedEvents) { throw new InvalidOperationException( "The caller-owned manager must enable NAT punching and synchronized event dispatch."); } } } internal sealed class RendezvousPunchRetrySchedule( RendezvousCoordinatorOptions options, IRendezvousCoordinatorClock clock) { public int RequestsSent { get; private set; } public TimeSpan NextRequestAt { get; private set; } = TimeSpan.Zero; public bool IsExhausted => RequestsSent >= options.MaximumPunchRequests; public bool IsDue(TimeSpan elapsed) => elapsed >= NextRequestAt; public void RecordRequest() { int exponent = Math.Min(RequestsSent, 30); RequestsSent++; double milliseconds = Math.Min( options.InitialPunchRetryDelay.TotalMilliseconds * Math.Pow(2, exponent), options.MaximumPunchRetryDelay.TotalMilliseconds); if (options.JitterRatio > 0) { Span random = stackalloc byte[1]; RandomNumberGenerator.Fill(random); double unit = random[0] / 255d; double multiplier = 1 - options.JitterRatio + (2 * options.JitterRatio * unit); milliseconds = Math.Min( milliseconds * multiplier, options.MaximumPunchRetryDelay.TotalMilliseconds); } NextRequestAt = clock.Elapsed + TimeSpan.FromMilliseconds(milliseconds); } }