Network 层的迁移与强化已完成,TODO.md 与 MobaSyncMVP.md 给出后续方向
This commit is contained in:
@@ -1,5 +1,6 @@
|
||||
using System.Collections.Generic;
|
||||
using Network.Defines;
|
||||
using Network.NetworkApplication;
|
||||
using UnityEngine;
|
||||
using Vector3 = UnityEngine.Vector3;
|
||||
|
||||
@@ -19,7 +20,7 @@ public class MovementComponent : MonoBehaviour
|
||||
public long Tick { get; private set; } = 0;
|
||||
private long _startTickOffset = 0;
|
||||
private long _currentTickOffset = 0;
|
||||
private readonly List<PlayerInput> _inputBuffer = new List<PlayerInput>();
|
||||
private readonly ClientPredictionBuffer _predictionBuffer = new ClientPredictionBuffer();
|
||||
|
||||
private Vector3 _serverPos;
|
||||
private Vector3 _currentPos;
|
||||
@@ -72,9 +73,9 @@ public class MovementComponent : MonoBehaviour
|
||||
}
|
||||
|
||||
Simulate(_cachedInput);
|
||||
if (_cachedInput != null && (_inputBuffer.Count == 0 || _inputBuffer[_inputBuffer.Count - 1].Tick != _cachedInput.Tick))
|
||||
if (_cachedInput != null)
|
||||
{
|
||||
_inputBuffer.Add(_cachedInput);
|
||||
_predictionBuffer.Record(_cachedInput);
|
||||
}
|
||||
}
|
||||
else
|
||||
@@ -86,10 +87,15 @@ public class MovementComponent : MonoBehaviour
|
||||
|
||||
private void Reconcile(PlayerState state)
|
||||
{
|
||||
if (!_predictionBuffer.TryApplyAuthoritativeState(state, out var replayInputs))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_serverPosition = state.Position.ToVector3();
|
||||
_rigid.position = Vector3.Lerp(_rigid.position, _serverPosition, _lerpRate);
|
||||
_rigid.velocity = Vector3.zero;
|
||||
_inputBuffer.RemoveAll(i => i.Tick <= state.Tick);
|
||||
ReplayPendingInputs(replayInputs);
|
||||
}
|
||||
|
||||
private PlayerInput CaptureInput()
|
||||
@@ -118,11 +124,23 @@ public class MovementComponent : MonoBehaviour
|
||||
{
|
||||
if (_isControlled)
|
||||
{
|
||||
if (_predictionBuffer.LastAuthoritativeTick.HasValue &&
|
||||
state.Tick <= _predictionBuffer.LastAuthoritativeTick.Value)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_lastServerState = state;
|
||||
_hasServerState = true;
|
||||
}
|
||||
else
|
||||
{
|
||||
if (_lastServerState != null && state.Tick < _lastServerState.Tick)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_lastServerState = state;
|
||||
_serverPos = state.Position.ToVector3();
|
||||
_currentPos = _rigid.position;
|
||||
_lerpTime = 0f;
|
||||
@@ -146,4 +164,17 @@ public class MovementComponent : MonoBehaviour
|
||||
_sendInterval = 0.048f;
|
||||
}
|
||||
}
|
||||
|
||||
private void ReplayPendingInputs(IReadOnlyList<PlayerInput> replayInputs)
|
||||
{
|
||||
foreach (var replayInput in replayInputs)
|
||||
{
|
||||
_rigid.position += _speed * replayInput.Input.ToVector3() * _sendInterval;
|
||||
}
|
||||
|
||||
if (_isControlled)
|
||||
{
|
||||
MainUI.Instance.OnClientPosChanged(_rigid.position);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,5 +10,5 @@
|
||||
"autoReferenced": true,
|
||||
"defineConstraints": [],
|
||||
"versionDefines": [],
|
||||
"noEngineReferences": false
|
||||
}
|
||||
"noEngineReferences": true
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using Network.Defines;
|
||||
|
||||
namespace Network.NetworkApplication
|
||||
{
|
||||
public sealed class ClientPredictionBuffer
|
||||
{
|
||||
private readonly List<PlayerInput> pendingInputs = new();
|
||||
|
||||
public long? LastAuthoritativeTick { get; private set; }
|
||||
|
||||
public IReadOnlyList<PlayerInput> PendingInputs => pendingInputs;
|
||||
|
||||
public void Record(PlayerInput input)
|
||||
{
|
||||
if (input == null)
|
||||
{
|
||||
throw new ArgumentNullException(nameof(input));
|
||||
}
|
||||
|
||||
if (pendingInputs.Count > 0 && pendingInputs[^1].Tick >= input.Tick)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
pendingInputs.Add(input);
|
||||
}
|
||||
|
||||
public bool TryApplyAuthoritativeState(PlayerState state, out IReadOnlyList<PlayerInput> replayInputs)
|
||||
{
|
||||
if (state == null)
|
||||
{
|
||||
throw new ArgumentNullException(nameof(state));
|
||||
}
|
||||
|
||||
if (LastAuthoritativeTick.HasValue && state.Tick <= LastAuthoritativeTick.Value)
|
||||
{
|
||||
replayInputs = Array.Empty<PlayerInput>();
|
||||
return false;
|
||||
}
|
||||
|
||||
LastAuthoritativeTick = state.Tick;
|
||||
pendingInputs.RemoveAll(input => input.Tick <= state.Tick);
|
||||
replayInputs = pendingInputs.ToArray();
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 0835bc8e8b5c4f14eab1d4c429ec6238
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,35 @@
|
||||
using System;
|
||||
|
||||
namespace Network.NetworkApplication
|
||||
{
|
||||
public sealed class ClockSyncState
|
||||
{
|
||||
private readonly Func<DateTimeOffset> utcNowProvider;
|
||||
|
||||
public ClockSyncState(Func<DateTimeOffset> utcNowProvider = null)
|
||||
{
|
||||
this.utcNowProvider = utcNowProvider ?? (() => DateTimeOffset.UtcNow);
|
||||
}
|
||||
|
||||
public long? CurrentServerTick { get; private set; }
|
||||
|
||||
public DateTimeOffset? LastSampleReceivedAtUtc { get; private set; }
|
||||
|
||||
public bool ObserveSample(long? serverTick)
|
||||
{
|
||||
if (!serverTick.HasValue)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (CurrentServerTick.HasValue && serverTick.Value < CurrentServerTick.Value)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
CurrentServerTick = serverTick.Value;
|
||||
LastSampleReceivedAtUtc = utcNowProvider();
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 95a64b5509806e94aac0803ca2a9823f
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,22 @@
|
||||
using System.Collections.Generic;
|
||||
using Network.Defines;
|
||||
|
||||
namespace Network.NetworkApplication
|
||||
{
|
||||
public sealed class DefaultMessageDeliveryPolicyResolver : IMessageDeliveryPolicyResolver
|
||||
{
|
||||
private static readonly IReadOnlyDictionary<MessageType, DeliveryPolicy> DefaultPolicies =
|
||||
new Dictionary<MessageType, DeliveryPolicy>
|
||||
{
|
||||
{ MessageType.PlayerInput, DeliveryPolicy.HighFrequencySync },
|
||||
{ MessageType.PlayerState, DeliveryPolicy.HighFrequencySync }
|
||||
};
|
||||
|
||||
public DeliveryPolicy Resolve(MessageType messageType)
|
||||
{
|
||||
return DefaultPolicies.TryGetValue(messageType, out var policy)
|
||||
? policy
|
||||
: DeliveryPolicy.ReliableOrdered;
|
||||
}
|
||||
}
|
||||
}
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 8b39164ada6225c4db48ec637634ba86
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,8 @@
|
||||
namespace Network.NetworkApplication
|
||||
{
|
||||
public enum DeliveryPolicy
|
||||
{
|
||||
ReliableOrdered = 0,
|
||||
HighFrequencySync = 1
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 2e26e6d78859ced41a9e1241414e5953
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,9 @@
|
||||
using Network.Defines;
|
||||
|
||||
namespace Network.NetworkApplication
|
||||
{
|
||||
public interface IMessageDeliveryPolicyResolver
|
||||
{
|
||||
DeliveryPolicy Resolve(MessageType messageType);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 7732b6dea776deb43b39d6cb42874efc
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,16 @@
|
||||
using System;
|
||||
using System.Net;
|
||||
|
||||
namespace Network.NetworkApplication
|
||||
{
|
||||
public interface INetworkMessageLane
|
||||
{
|
||||
event Action<byte[], IPEndPoint> Received;
|
||||
|
||||
void Send(byte[] data);
|
||||
|
||||
void SendTo(byte[] data, IPEndPoint target);
|
||||
|
||||
void SendToAll(byte[] data);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 910fad1550952a748bcbded789d8ae87
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -5,14 +5,20 @@ namespace Network.NetworkApplication
|
||||
{
|
||||
public sealed class ManagedNetworkSession
|
||||
{
|
||||
public ManagedNetworkSession(IPEndPoint remoteEndPoint, SessionManager sessionManager)
|
||||
public ManagedNetworkSession(
|
||||
IPEndPoint remoteEndPoint,
|
||||
SessionManager sessionManager,
|
||||
ClockSyncState clockSync)
|
||||
{
|
||||
RemoteEndPoint = remoteEndPoint ?? throw new ArgumentNullException(nameof(remoteEndPoint));
|
||||
SessionManager = sessionManager ?? throw new ArgumentNullException(nameof(sessionManager));
|
||||
ClockSync = clockSync ?? throw new ArgumentNullException(nameof(clockSync));
|
||||
}
|
||||
|
||||
public IPEndPoint RemoteEndPoint { get; }
|
||||
|
||||
public SessionManager SessionManager { get; }
|
||||
|
||||
public ClockSyncState ClockSync { get; }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,17 +10,58 @@ namespace Network.NetworkApplication
|
||||
{
|
||||
public class MessageManager
|
||||
{
|
||||
private readonly ITransport transport;
|
||||
private readonly INetworkMessageLane reliableLane;
|
||||
private readonly INetworkMessageLane syncLane;
|
||||
private readonly INetworkMessageDispatcher dispatcher;
|
||||
private readonly IMessageDeliveryPolicyResolver deliveryPolicyResolver;
|
||||
private readonly SyncSequenceTracker syncSequenceTracker;
|
||||
|
||||
private readonly Dictionary<MessageType, Func<byte[], IPEndPoint, Task>> handlers =
|
||||
new();
|
||||
|
||||
public MessageManager(ITransport transport, INetworkMessageDispatcher dispatcher)
|
||||
: this(
|
||||
CreateLane(transport),
|
||||
dispatcher,
|
||||
new DefaultMessageDeliveryPolicyResolver(),
|
||||
null,
|
||||
new SyncSequenceTracker())
|
||||
{
|
||||
this.transport = transport ?? throw new ArgumentNullException(nameof(transport));
|
||||
}
|
||||
|
||||
public MessageManager(
|
||||
ITransport reliableTransport,
|
||||
INetworkMessageDispatcher dispatcher,
|
||||
IMessageDeliveryPolicyResolver deliveryPolicyResolver,
|
||||
ITransport syncTransport = null,
|
||||
SyncSequenceTracker syncSequenceTracker = null)
|
||||
: this(
|
||||
CreateLane(reliableTransport),
|
||||
dispatcher,
|
||||
deliveryPolicyResolver,
|
||||
CreateLaneIfDistinct(reliableTransport, syncTransport),
|
||||
syncSequenceTracker)
|
||||
{
|
||||
}
|
||||
|
||||
public MessageManager(
|
||||
INetworkMessageLane reliableLane,
|
||||
INetworkMessageDispatcher dispatcher,
|
||||
IMessageDeliveryPolicyResolver deliveryPolicyResolver = null,
|
||||
INetworkMessageLane syncLane = null,
|
||||
SyncSequenceTracker syncSequenceTracker = null)
|
||||
{
|
||||
this.reliableLane = reliableLane ?? throw new ArgumentNullException(nameof(reliableLane));
|
||||
this.dispatcher = dispatcher ?? throw new ArgumentNullException(nameof(dispatcher));
|
||||
this.transport.OnReceive += OnTransportReceive;
|
||||
this.deliveryPolicyResolver = deliveryPolicyResolver ?? new DefaultMessageDeliveryPolicyResolver();
|
||||
this.syncLane = syncLane;
|
||||
this.syncSequenceTracker = syncSequenceTracker ?? new SyncSequenceTracker();
|
||||
|
||||
this.reliableLane.Received += OnTransportReceive;
|
||||
if (this.syncLane != null && !ReferenceEquals(this.syncLane, this.reliableLane))
|
||||
{
|
||||
this.syncLane.Received += OnTransportReceive;
|
||||
}
|
||||
}
|
||||
|
||||
public INetworkMessageDispatcher Dispatcher => dispatcher;
|
||||
@@ -71,14 +112,15 @@ namespace Network.NetworkApplication
|
||||
Type = (int)type,
|
||||
Payload = message.ToByteString()
|
||||
};
|
||||
var lane = ResolveLane(type);
|
||||
|
||||
if (target != null)
|
||||
{
|
||||
transport.SendTo(envelope.ToByteArray(), target);
|
||||
lane.SendTo(envelope.ToByteArray(), target);
|
||||
}
|
||||
else
|
||||
{
|
||||
transport.Send(envelope.ToByteArray());
|
||||
lane.Send(envelope.ToByteArray());
|
||||
}
|
||||
|
||||
Console.WriteLine($"[MessageManager] 发送消息:{type} -> {target?.ToString() ?? "default"}");
|
||||
@@ -97,7 +139,7 @@ namespace Network.NetworkApplication
|
||||
Type = (int)type,
|
||||
Payload = message.ToByteString()
|
||||
};
|
||||
transport.SendToAll(envelope.ToByteArray());
|
||||
ResolveLane(type).SendToAll(envelope.ToByteArray());
|
||||
}
|
||||
|
||||
public Task<int> DrainPendingMessagesAsync(int maxMessages = int.MaxValue)
|
||||
@@ -112,10 +154,16 @@ namespace Network.NetworkApplication
|
||||
var envelope = Envelope.Parser.ParseFrom(data);
|
||||
var type = (MessageType)envelope.Type;
|
||||
Console.WriteLine($"[MessageManager] 收到消息:{type} 来自 {sender}");
|
||||
var payload = envelope.Payload.ToByteArray();
|
||||
|
||||
if (!syncSequenceTracker.ShouldAccept(type, payload, sender))
|
||||
{
|
||||
Console.WriteLine($"[MessageManager] 丢弃过期同步消息:{type} 来自 {sender}");
|
||||
return;
|
||||
}
|
||||
|
||||
if (handlers.TryGetValue(type, out var handler))
|
||||
{
|
||||
var payload = envelope.Payload.ToByteArray();
|
||||
dispatcher.Enqueue(() => DispatchAsync(handler, payload, sender, type));
|
||||
}
|
||||
else
|
||||
@@ -144,5 +192,30 @@ namespace Network.NetworkApplication
|
||||
Console.WriteLine($"[MessageManager] Handler 执行错误:{type} -> {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
private INetworkMessageLane ResolveLane(MessageType type)
|
||||
{
|
||||
var policy = deliveryPolicyResolver.Resolve(type);
|
||||
return policy == DeliveryPolicy.HighFrequencySync && syncLane != null
|
||||
? syncLane
|
||||
: reliableLane;
|
||||
}
|
||||
|
||||
private static INetworkMessageLane CreateLane(ITransport transport)
|
||||
{
|
||||
return new TransportMessageLane(transport ?? throw new ArgumentNullException(nameof(transport)));
|
||||
}
|
||||
|
||||
private static INetworkMessageLane CreateLaneIfDistinct(ITransport reliableTransport, ITransport syncTransport)
|
||||
{
|
||||
if (syncTransport == null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return ReferenceEquals(reliableTransport, syncTransport)
|
||||
? null
|
||||
: CreateLane(syncTransport);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -119,7 +119,9 @@ namespace Network.NetworkApplication
|
||||
|
||||
public void NotifyHeartbeatReceived(IPEndPoint remoteEndPoint, long? serverTick = null)
|
||||
{
|
||||
GetOrCreateSession(remoteEndPoint).SessionManager.NotifyHeartbeatReceived(serverTick);
|
||||
var session = GetOrCreateSession(remoteEndPoint);
|
||||
session.SessionManager.NotifyHeartbeatReceived();
|
||||
session.ClockSync.ObserveSample(serverTick);
|
||||
}
|
||||
|
||||
public void NotifyInboundActivity(IPEndPoint remoteEndPoint)
|
||||
@@ -127,6 +129,11 @@ namespace Network.NetworkApplication
|
||||
GetOrCreateSession(remoteEndPoint).SessionManager.NotifyInboundActivity();
|
||||
}
|
||||
|
||||
public void ObserveAuthoritativeState(IPEndPoint remoteEndPoint, long? serverTick)
|
||||
{
|
||||
GetOrCreateSession(remoteEndPoint).ClockSync.ObserveSample(serverTick);
|
||||
}
|
||||
|
||||
public bool RemoveSession(IPEndPoint remoteEndPoint, string reason = null)
|
||||
{
|
||||
SessionRegistration registration;
|
||||
@@ -194,7 +201,8 @@ namespace Network.NetworkApplication
|
||||
}
|
||||
|
||||
var sessionManager = new SessionManager(reconnectPolicy, utcNowProvider);
|
||||
var session = new ManagedNetworkSession(normalizedEndPoint, sessionManager);
|
||||
var clockSync = new ClockSyncState(utcNowProvider);
|
||||
var session = new ManagedNetworkSession(normalizedEndPoint, sessionManager, clockSync);
|
||||
Action<SessionLifecycleEvent> handler = lifecycleEvent =>
|
||||
LifecycleChanged?.Invoke(new MultiSessionLifecycleEvent(session.RemoteEndPoint, session.SessionManager, lifecycleEvent));
|
||||
|
||||
@@ -235,4 +243,3 @@ namespace Network.NetworkApplication
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -32,8 +32,6 @@ namespace Network.NetworkApplication
|
||||
|
||||
public TimeSpan? LastRoundTripTime { get; private set; }
|
||||
|
||||
public long? LastServerTick { get; private set; }
|
||||
|
||||
public string LastFailureReason { get; private set; }
|
||||
|
||||
public bool CanSendHeartbeat => State == ConnectionState.LoggedIn;
|
||||
@@ -104,11 +102,10 @@ namespace Network.NetworkApplication
|
||||
RaiseEvent(SessionEventKind.HeartbeatSent, State, State, lastHeartbeatSentUtc.Value);
|
||||
}
|
||||
|
||||
public void NotifyHeartbeatReceived(long? serverTick = null)
|
||||
public void NotifyHeartbeatReceived()
|
||||
{
|
||||
var now = utcNowProvider();
|
||||
lastLivenessUtc = now;
|
||||
LastServerTick = serverTick;
|
||||
if (lastHeartbeatSentUtc.HasValue)
|
||||
{
|
||||
LastRoundTripTime = now - lastHeartbeatSentUtc.Value;
|
||||
|
||||
@@ -10,19 +10,34 @@ namespace Network.NetworkApplication
|
||||
ITransport transport,
|
||||
INetworkMessageDispatcher dispatcher,
|
||||
SessionReconnectPolicy reconnectPolicy = null,
|
||||
Func<DateTimeOffset> utcNowProvider = null)
|
||||
Func<DateTimeOffset> utcNowProvider = null,
|
||||
ITransport syncTransport = null,
|
||||
IMessageDeliveryPolicyResolver deliveryPolicyResolver = null,
|
||||
SyncSequenceTracker syncSequenceTracker = null,
|
||||
ClockSyncState clockSync = null)
|
||||
{
|
||||
Transport = transport ?? throw new ArgumentNullException(nameof(transport));
|
||||
SyncTransport = syncTransport;
|
||||
SessionManager = new SessionManager(reconnectPolicy, utcNowProvider);
|
||||
MessageManager = new MessageManager(transport, dispatcher ?? throw new ArgumentNullException(nameof(dispatcher)));
|
||||
ClockSync = clockSync ?? new ClockSyncState(utcNowProvider);
|
||||
MessageManager = new MessageManager(
|
||||
transport,
|
||||
dispatcher ?? throw new ArgumentNullException(nameof(dispatcher)),
|
||||
deliveryPolicyResolver ?? new DefaultMessageDeliveryPolicyResolver(),
|
||||
syncTransport,
|
||||
syncSequenceTracker ?? new SyncSequenceTracker());
|
||||
}
|
||||
|
||||
public ITransport Transport { get; }
|
||||
|
||||
public ITransport SyncTransport { get; }
|
||||
|
||||
public MessageManager MessageManager { get; }
|
||||
|
||||
public SessionManager SessionManager { get; }
|
||||
|
||||
public ClockSyncState ClockSync { get; }
|
||||
|
||||
public event Action<SessionLifecycleEvent> LifecycleChanged
|
||||
{
|
||||
add => SessionManager.LifecycleChanged += value;
|
||||
@@ -32,13 +47,26 @@ namespace Network.NetworkApplication
|
||||
public async Task StartAsync()
|
||||
{
|
||||
await Transport.StartAsync();
|
||||
|
||||
if (SyncTransport != null && !ReferenceEquals(SyncTransport, Transport))
|
||||
{
|
||||
await SyncTransport.StartAsync();
|
||||
}
|
||||
|
||||
SessionManager.NotifyTransportConnected();
|
||||
PublishMetricsSessionSnapshot();
|
||||
}
|
||||
|
||||
public void Stop()
|
||||
{
|
||||
Transport.Stop();
|
||||
if (SyncTransport != null && !ReferenceEquals(SyncTransport, Transport))
|
||||
{
|
||||
SyncTransport.Stop();
|
||||
}
|
||||
|
||||
SessionManager.NotifyTransportDisconnected("Transport stopped");
|
||||
PublishMetricsSessionSnapshot();
|
||||
}
|
||||
|
||||
public Task<int> DrainPendingMessagesAsync(int maxMessages = int.MaxValue)
|
||||
@@ -49,36 +77,90 @@ namespace Network.NetworkApplication
|
||||
public void NotifyLoginStarted()
|
||||
{
|
||||
SessionManager.NotifyLoginStarted();
|
||||
PublishMetricsSessionSnapshot();
|
||||
}
|
||||
|
||||
public void NotifyLoginSucceeded()
|
||||
{
|
||||
SessionManager.NotifyLoginSucceeded();
|
||||
PublishMetricsSessionSnapshot();
|
||||
}
|
||||
|
||||
public void NotifyLoginFailed(string reason = null)
|
||||
{
|
||||
SessionManager.NotifyLoginFailed(reason);
|
||||
PublishMetricsSessionSnapshot();
|
||||
}
|
||||
|
||||
public void NotifyHeartbeatSent()
|
||||
{
|
||||
SessionManager.NotifyHeartbeatSent();
|
||||
PublishMetricsSessionSnapshot();
|
||||
}
|
||||
|
||||
public void NotifyHeartbeatReceived(long? serverTick = null)
|
||||
{
|
||||
SessionManager.NotifyHeartbeatReceived(serverTick);
|
||||
SessionManager.NotifyHeartbeatReceived();
|
||||
ClockSync.ObserveSample(serverTick);
|
||||
PublishMetricsSessionSnapshot();
|
||||
}
|
||||
|
||||
public void NotifyInboundActivity()
|
||||
{
|
||||
SessionManager.NotifyInboundActivity();
|
||||
PublishMetricsSessionSnapshot();
|
||||
}
|
||||
|
||||
public void UpdateLifecycle()
|
||||
{
|
||||
SessionManager.Evaluate();
|
||||
PublishMetricsSessionSnapshot();
|
||||
}
|
||||
|
||||
public void ObserveAuthoritativeState(long? serverTick)
|
||||
{
|
||||
ClockSync.ObserveSample(serverTick);
|
||||
PublishMetricsSessionSnapshot();
|
||||
}
|
||||
|
||||
private void PublishMetricsSessionSnapshot()
|
||||
{
|
||||
RecordMetricsSessionSnapshot(Transport, "shared-runtime", SessionManager, ClockSync, remoteEndPoint: null);
|
||||
|
||||
if (SyncTransport != null && !ReferenceEquals(SyncTransport, Transport))
|
||||
{
|
||||
RecordMetricsSessionSnapshot(SyncTransport, "shared-runtime-sync", SessionManager, ClockSync, remoteEndPoint: null);
|
||||
}
|
||||
}
|
||||
|
||||
private static void RecordMetricsSessionSnapshot(
|
||||
ITransport transport,
|
||||
string scope,
|
||||
SessionManager sessionManager,
|
||||
ClockSyncState clockSync,
|
||||
System.Net.IPEndPoint remoteEndPoint)
|
||||
{
|
||||
if (transport is not ITransportMetricsSink metricsSink || sessionManager == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
metricsSink.RecordApplicationSessionSnapshot(new TransportApplicationSessionSnapshot
|
||||
{
|
||||
Scope = scope,
|
||||
RemoteEndPoint = remoteEndPoint?.ToString(),
|
||||
ConnectionState = sessionManager.State.ToString(),
|
||||
CanSendHeartbeat = sessionManager.CanSendHeartbeat,
|
||||
LastRoundTripTimeMs = sessionManager.LastRoundTripTime.HasValue
|
||||
? (long?)System.Math.Max(0d, sessionManager.LastRoundTripTime.Value.TotalMilliseconds)
|
||||
: null,
|
||||
LastFailureReason = sessionManager.LastFailureReason,
|
||||
LastLivenessUtc = sessionManager.LastLivenessUtc,
|
||||
LastHeartbeatSentUtc = sessionManager.LastHeartbeatSentUtc,
|
||||
NextReconnectAtUtc = sessionManager.NextReconnectAtUtc,
|
||||
CurrentServerTick = clockSync?.CurrentServerTick,
|
||||
ObservedAtUtc = DateTimeOffset.UtcNow
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Net;
|
||||
using Network.Defines;
|
||||
|
||||
namespace Network.NetworkApplication
|
||||
{
|
||||
public sealed class SyncSequenceTracker
|
||||
{
|
||||
private readonly object gate = new();
|
||||
private readonly Dictionary<string, long> latestSequenceByStream = new();
|
||||
|
||||
public bool ShouldAccept(MessageType messageType, byte[] payload, IPEndPoint sender)
|
||||
{
|
||||
if (!TryResolveSequence(messageType, payload, sender, out var streamKey, out var sequence))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
lock (gate)
|
||||
{
|
||||
if (latestSequenceByStream.TryGetValue(streamKey, out var latestSequence) &&
|
||||
sequence < latestSequence)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
latestSequenceByStream[streamKey] = sequence;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
private static bool TryResolveSequence(
|
||||
MessageType messageType,
|
||||
byte[] payload,
|
||||
IPEndPoint sender,
|
||||
out string streamKey,
|
||||
out long sequence)
|
||||
{
|
||||
switch (messageType)
|
||||
{
|
||||
case MessageType.PlayerInput:
|
||||
{
|
||||
var input = PlayerInput.Parser.ParseFrom(payload);
|
||||
streamKey = $"input:{Normalize(sender)}:{input.PlayerId}";
|
||||
sequence = input.Tick;
|
||||
return true;
|
||||
}
|
||||
|
||||
case MessageType.PlayerState:
|
||||
{
|
||||
var state = PlayerState.Parser.ParseFrom(payload);
|
||||
streamKey = $"state:{state.PlayerId}";
|
||||
sequence = state.Tick;
|
||||
return true;
|
||||
}
|
||||
|
||||
default:
|
||||
streamKey = null;
|
||||
sequence = 0;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private static string Normalize(IPEndPoint sender)
|
||||
{
|
||||
return sender == null ? "unknown" : sender.ToString();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 377bd3719da11b346926ee82dd56bc45
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,39 @@
|
||||
using System;
|
||||
using System.Net;
|
||||
using Network.NetworkTransport;
|
||||
|
||||
namespace Network.NetworkApplication
|
||||
{
|
||||
public sealed class TransportMessageLane : INetworkMessageLane
|
||||
{
|
||||
private readonly ITransport transport;
|
||||
|
||||
public TransportMessageLane(ITransport transport)
|
||||
{
|
||||
this.transport = transport ?? throw new ArgumentNullException(nameof(transport));
|
||||
this.transport.OnReceive += HandleReceive;
|
||||
}
|
||||
|
||||
public event Action<byte[], IPEndPoint> Received;
|
||||
|
||||
public void Send(byte[] data)
|
||||
{
|
||||
transport.Send(data);
|
||||
}
|
||||
|
||||
public void SendTo(byte[] data, IPEndPoint target)
|
||||
{
|
||||
transport.SendTo(data, target);
|
||||
}
|
||||
|
||||
public void SendToAll(byte[] data)
|
||||
{
|
||||
transport.SendToAll(data);
|
||||
}
|
||||
|
||||
private void HandleReceive(byte[] data, IPEndPoint sender)
|
||||
{
|
||||
Received?.Invoke(data, sender);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 4c7a1935b45cd2e418814d4b6a5c0b45
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -10,24 +10,41 @@ namespace Network.NetworkHost
|
||||
public sealed class ServerNetworkHost
|
||||
{
|
||||
private readonly ITransport transport;
|
||||
private readonly ITransport syncTransport;
|
||||
private readonly MessageManager messageManager;
|
||||
|
||||
public ServerNetworkHost(
|
||||
ITransport transport,
|
||||
INetworkMessageDispatcher dispatcher = null,
|
||||
SessionReconnectPolicy reconnectPolicy = null,
|
||||
Func<DateTimeOffset> utcNowProvider = null)
|
||||
Func<DateTimeOffset> utcNowProvider = null,
|
||||
ITransport syncTransport = null,
|
||||
IMessageDeliveryPolicyResolver deliveryPolicyResolver = null,
|
||||
SyncSequenceTracker syncSequenceTracker = null)
|
||||
{
|
||||
this.transport = transport ?? throw new ArgumentNullException(nameof(transport));
|
||||
this.syncTransport = syncTransport;
|
||||
SessionCoordinator = new MultiSessionManager(reconnectPolicy, utcNowProvider);
|
||||
this.transport.OnReceive += HandleTransportReceive;
|
||||
messageManager = new MessageManager(this.transport, dispatcher ?? new ImmediateNetworkMessageDispatcher());
|
||||
if (this.syncTransport != null && !ReferenceEquals(this.syncTransport, this.transport))
|
||||
{
|
||||
this.syncTransport.OnReceive += HandleTransportReceive;
|
||||
}
|
||||
|
||||
messageManager = new MessageManager(
|
||||
this.transport,
|
||||
dispatcher ?? new ImmediateNetworkMessageDispatcher(),
|
||||
deliveryPolicyResolver ?? new DefaultMessageDeliveryPolicyResolver(),
|
||||
this.syncTransport,
|
||||
syncSequenceTracker ?? new SyncSequenceTracker());
|
||||
}
|
||||
|
||||
public MessageManager MessageManager => messageManager;
|
||||
|
||||
public ITransport Transport => transport;
|
||||
|
||||
public ITransport SyncTransport => syncTransport;
|
||||
|
||||
// Server-side lifecycle entry point: inspect and control per-peer session state here.
|
||||
public MultiSessionManager SessionCoordinator { get; }
|
||||
|
||||
@@ -41,13 +58,26 @@ namespace Network.NetworkHost
|
||||
|
||||
public Task StartAsync()
|
||||
{
|
||||
return transport.StartAsync();
|
||||
var startTask = transport.StartAsync();
|
||||
if (syncTransport == null || ReferenceEquals(syncTransport, transport))
|
||||
{
|
||||
return startTask;
|
||||
}
|
||||
|
||||
return StartWithSyncAsync(startTask);
|
||||
}
|
||||
|
||||
public void Stop()
|
||||
{
|
||||
PublishMetricsSessionSnapshots();
|
||||
transport.Stop();
|
||||
if (syncTransport != null && !ReferenceEquals(syncTransport, transport))
|
||||
{
|
||||
syncTransport.Stop();
|
||||
}
|
||||
|
||||
SessionCoordinator.RemoveAllSessions("Transport stopped");
|
||||
PublishMetricsSessionSnapshots();
|
||||
}
|
||||
|
||||
public Task<int> DrainPendingMessagesAsync(int maxMessages = int.MaxValue)
|
||||
@@ -58,6 +88,7 @@ namespace Network.NetworkHost
|
||||
public void UpdateLifecycle()
|
||||
{
|
||||
SessionCoordinator.UpdateLifecycle();
|
||||
PublishMetricsSessionSnapshots();
|
||||
}
|
||||
|
||||
public bool TryGetSession(IPEndPoint remoteEndPoint, out ManagedNetworkSession session)
|
||||
@@ -68,42 +99,132 @@ namespace Network.NetworkHost
|
||||
public void NotifyLoginStarted(IPEndPoint remoteEndPoint)
|
||||
{
|
||||
SessionCoordinator.NotifyLoginStarted(remoteEndPoint);
|
||||
PublishMetricsSessionSnapshot(remoteEndPoint);
|
||||
}
|
||||
|
||||
public void NotifyLoginSucceeded(IPEndPoint remoteEndPoint)
|
||||
{
|
||||
SessionCoordinator.NotifyLoginSucceeded(remoteEndPoint);
|
||||
PublishMetricsSessionSnapshot(remoteEndPoint);
|
||||
}
|
||||
|
||||
public void NotifyLoginFailed(IPEndPoint remoteEndPoint, string reason = null)
|
||||
{
|
||||
SessionCoordinator.NotifyLoginFailed(remoteEndPoint, reason);
|
||||
PublishMetricsSessionSnapshot(remoteEndPoint);
|
||||
}
|
||||
|
||||
public void NotifyHeartbeatSent(IPEndPoint remoteEndPoint)
|
||||
{
|
||||
SessionCoordinator.NotifyHeartbeatSent(remoteEndPoint);
|
||||
PublishMetricsSessionSnapshot(remoteEndPoint);
|
||||
}
|
||||
|
||||
public void NotifyHeartbeatReceived(IPEndPoint remoteEndPoint, long? serverTick = null)
|
||||
{
|
||||
SessionCoordinator.NotifyHeartbeatReceived(remoteEndPoint, serverTick);
|
||||
PublishMetricsSessionSnapshot(remoteEndPoint);
|
||||
}
|
||||
|
||||
public void ObserveAuthoritativeState(IPEndPoint remoteEndPoint, long? serverTick)
|
||||
{
|
||||
SessionCoordinator.ObserveAuthoritativeState(remoteEndPoint, serverTick);
|
||||
PublishMetricsSessionSnapshot(remoteEndPoint);
|
||||
}
|
||||
|
||||
public void NotifyInboundActivity(IPEndPoint remoteEndPoint)
|
||||
{
|
||||
SessionCoordinator.NotifyInboundActivity(remoteEndPoint);
|
||||
PublishMetricsSessionSnapshot(remoteEndPoint);
|
||||
}
|
||||
|
||||
public bool RemoveSession(IPEndPoint remoteEndPoint, string reason = null)
|
||||
{
|
||||
return SessionCoordinator.RemoveSession(remoteEndPoint, reason);
|
||||
if (!SessionCoordinator.TryGetSession(remoteEndPoint, out var session))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var removed = SessionCoordinator.RemoveSession(remoteEndPoint, reason);
|
||||
if (!removed)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
RecordMetricsSessionSnapshot(transport, "server-host", session, ConnectionState.Disconnected);
|
||||
if (syncTransport != null && !ReferenceEquals(syncTransport, transport))
|
||||
{
|
||||
RecordMetricsSessionSnapshot(syncTransport, "server-host-sync", session, ConnectionState.Disconnected);
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private void HandleTransportReceive(byte[] _, IPEndPoint sender)
|
||||
{
|
||||
SessionCoordinator.ObserveTransportActivity(sender);
|
||||
PublishMetricsSessionSnapshot(sender);
|
||||
}
|
||||
|
||||
private void PublishMetricsSessionSnapshots()
|
||||
{
|
||||
foreach (var session in ManagedSessions)
|
||||
{
|
||||
RecordMetricsSessionSnapshot(transport, "server-host", session);
|
||||
if (syncTransport != null && !ReferenceEquals(syncTransport, transport))
|
||||
{
|
||||
RecordMetricsSessionSnapshot(syncTransport, "server-host-sync", session);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void PublishMetricsSessionSnapshot(IPEndPoint remoteEndPoint)
|
||||
{
|
||||
if (!TryGetSession(remoteEndPoint, out var session))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
RecordMetricsSessionSnapshot(transport, "server-host", session);
|
||||
if (syncTransport != null && !ReferenceEquals(syncTransport, transport))
|
||||
{
|
||||
RecordMetricsSessionSnapshot(syncTransport, "server-host-sync", session);
|
||||
}
|
||||
}
|
||||
|
||||
private static void RecordMetricsSessionSnapshot(
|
||||
ITransport targetTransport,
|
||||
string scope,
|
||||
ManagedNetworkSession session,
|
||||
ConnectionState? overrideState = null)
|
||||
{
|
||||
if (targetTransport is not ITransportMetricsSink metricsSink || session == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
metricsSink.RecordApplicationSessionSnapshot(new TransportApplicationSessionSnapshot
|
||||
{
|
||||
Scope = scope,
|
||||
RemoteEndPoint = session.RemoteEndPoint.ToString(),
|
||||
ConnectionState = (overrideState ?? session.SessionManager.State).ToString(),
|
||||
CanSendHeartbeat = overrideState.HasValue ? overrideState.Value == ConnectionState.LoggedIn : session.SessionManager.CanSendHeartbeat,
|
||||
LastRoundTripTimeMs = session.SessionManager.LastRoundTripTime.HasValue
|
||||
? (long?)Math.Max(0d, session.SessionManager.LastRoundTripTime.Value.TotalMilliseconds)
|
||||
: null,
|
||||
LastFailureReason = session.SessionManager.LastFailureReason,
|
||||
LastLivenessUtc = session.SessionManager.LastLivenessUtc,
|
||||
LastHeartbeatSentUtc = session.SessionManager.LastHeartbeatSentUtc,
|
||||
NextReconnectAtUtc = session.SessionManager.NextReconnectAtUtc,
|
||||
CurrentServerTick = session.ClockSync.CurrentServerTick,
|
||||
ObservedAtUtc = DateTimeOffset.UtcNow
|
||||
});
|
||||
}
|
||||
|
||||
private async Task StartWithSyncAsync(Task transportStartTask)
|
||||
{
|
||||
await transportStartTask;
|
||||
await syncTransport.StartAsync();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Net;
|
||||
using System.Runtime.InteropServices;
|
||||
using kcp;
|
||||
@@ -16,6 +17,9 @@ namespace Network.NetworkTransport
|
||||
private IKCPCB* _kcp;
|
||||
private bool _disposed;
|
||||
private uint _nextUpdateAt;
|
||||
private readonly Dictionary<uint, uint> _observedSegmentXmitBySequence = new();
|
||||
private long _observedRetransmissionSends;
|
||||
private long _observedLossSignals;
|
||||
|
||||
public KcpSession(KcpTransport owner, IPEndPoint remoteEndPoint, uint conv)
|
||||
{
|
||||
@@ -32,6 +36,7 @@ namespace Network.NetworkTransport
|
||||
KCP.ikcp_setmtu(_kcp, DefaultMtu);
|
||||
|
||||
_nextUpdateAt = GetCurrentTimeMilliseconds();
|
||||
_owner.RecordSessionDiagnostics(this, "active");
|
||||
}
|
||||
|
||||
public uint Conv { get; }
|
||||
@@ -68,6 +73,7 @@ namespace Network.NetworkTransport
|
||||
|
||||
LastActivityUtc = DateTime.UtcNow;
|
||||
UpdateNoLock(GetCurrentTimeMilliseconds());
|
||||
_owner.RecordSessionDiagnostics(this, "active");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -100,6 +106,7 @@ namespace Network.NetworkTransport
|
||||
|
||||
LastActivityUtc = DateTime.UtcNow;
|
||||
UpdateNoLock(GetCurrentTimeMilliseconds());
|
||||
_owner.RecordSessionDiagnostics(this, "active");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -139,6 +146,7 @@ namespace Network.NetworkTransport
|
||||
}
|
||||
|
||||
LastActivityUtc = DateTime.UtcNow;
|
||||
_owner.RecordSessionDiagnostics(this, "active");
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -158,6 +166,81 @@ namespace Network.NetworkTransport
|
||||
}
|
||||
|
||||
UpdateNoLock(current);
|
||||
_owner.RecordSessionDiagnostics(this, "active");
|
||||
}
|
||||
}
|
||||
|
||||
public TransportSessionDiagnosticsSnapshot CaptureDiagnostics(string lifecycleState)
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
if (_disposed || _kcp == null)
|
||||
{
|
||||
return new TransportSessionDiagnosticsSnapshot
|
||||
{
|
||||
LifecycleState = lifecycleState,
|
||||
ObservedAtUtc = now,
|
||||
IdleMs = Math.Max(0L, (long)(now - LastActivityUtc).TotalMilliseconds)
|
||||
};
|
||||
}
|
||||
|
||||
var retransmittedSegmentsInFlight = 0;
|
||||
var observedRetransmissionDelta = 0L;
|
||||
var head = &_kcp->snd_buf;
|
||||
|
||||
for (var node = head->next; node != head; node = node->next)
|
||||
{
|
||||
var segment = (IKCPSEG*)node;
|
||||
var currentXmit = Math.Max(0u, segment->xmit);
|
||||
_observedSegmentXmitBySequence.TryGetValue(segment->sn, out var observedXmit);
|
||||
var baselineXmit = observedXmit == 0 ? 1u : observedXmit;
|
||||
if (currentXmit > baselineXmit)
|
||||
{
|
||||
observedRetransmissionDelta += currentXmit - baselineXmit;
|
||||
}
|
||||
|
||||
if (currentXmit > observedXmit)
|
||||
{
|
||||
_observedSegmentXmitBySequence[segment->sn] = currentXmit;
|
||||
}
|
||||
|
||||
if (currentXmit > 1)
|
||||
{
|
||||
retransmittedSegmentsInFlight++;
|
||||
}
|
||||
}
|
||||
|
||||
if (observedRetransmissionDelta > 0)
|
||||
{
|
||||
_observedRetransmissionSends += observedRetransmissionDelta;
|
||||
_observedLossSignals += observedRetransmissionDelta;
|
||||
}
|
||||
|
||||
return new TransportSessionDiagnosticsSnapshot
|
||||
{
|
||||
LifecycleState = lifecycleState,
|
||||
ObservedAtUtc = now,
|
||||
IdleMs = Math.Max(0L, (long)(now - LastActivityUtc).TotalMilliseconds),
|
||||
KcpStateCode = unchecked((int)_kcp->state),
|
||||
SmoothedRttMs = Math.Max(0, _kcp->rx_srtt),
|
||||
RttVarianceMs = Math.Max(0, _kcp->rx_rttval),
|
||||
RetransmissionTimeoutMs = Math.Max(0, _kcp->rx_rto),
|
||||
LocalSendWindow = checked((int)_kcp->snd_wnd),
|
||||
LocalReceiveWindow = checked((int)_kcp->rcv_wnd),
|
||||
RemoteWindow = checked((int)_kcp->rmt_wnd),
|
||||
CongestionWindow = checked((int)_kcp->cwnd),
|
||||
WaitSendCount = Math.Max(0, KCP.ikcp_waitsnd(_kcp)),
|
||||
SendQueueCount = checked((int)_kcp->nsnd_que),
|
||||
SendBufferCount = checked((int)_kcp->nsnd_buf),
|
||||
ReceiveQueueCount = checked((int)_kcp->nrcv_que),
|
||||
ReceiveBufferCount = checked((int)_kcp->nrcv_buf),
|
||||
DeadLinkThreshold = checked((int)_kcp->dead_link),
|
||||
SegmentTransmitCount = _kcp->xmit,
|
||||
RetransmittedSegmentsInFlight = retransmittedSegmentsInFlight,
|
||||
ObservedRetransmissionSends = _observedRetransmissionSends,
|
||||
ObservedLossSignals = _observedLossSignals
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -221,4 +304,4 @@ namespace Network.NetworkTransport
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,7 +10,7 @@ using kcp;
|
||||
|
||||
namespace Network.NetworkTransport
|
||||
{
|
||||
public partial class KcpTransport : ITransport
|
||||
public partial class KcpTransport : ITransport, ITransportMetricsSink
|
||||
{
|
||||
private const uint DefaultConv = 1;
|
||||
private const int DefaultNoDelay = 1;
|
||||
@@ -69,6 +69,11 @@ namespace Network.NetworkTransport
|
||||
return _metricsModule.GetCurrentSnapshot();
|
||||
}
|
||||
|
||||
public void RecordApplicationSessionSnapshot(TransportApplicationSessionSnapshot snapshot)
|
||||
{
|
||||
_metricsModule.RecordApplicationSessionSnapshot(snapshot);
|
||||
}
|
||||
|
||||
public Task StartAsync()
|
||||
{
|
||||
if (_isRunning)
|
||||
@@ -318,6 +323,7 @@ namespace Network.NetworkTransport
|
||||
|
||||
foreach (var session in sessions)
|
||||
{
|
||||
_metricsModule.RecordSessionDiagnostics(session.RemoteEndPoint, session.CaptureDiagnostics("closed"));
|
||||
session.Dispose();
|
||||
_metricsModule.RecordSessionClosed(session.RemoteEndPoint);
|
||||
}
|
||||
@@ -358,5 +364,15 @@ namespace Network.NetworkTransport
|
||||
{
|
||||
_metricsModule.RecordError(stage, remoteEndPoint, detail);
|
||||
}
|
||||
|
||||
private void RecordSessionDiagnostics(KcpSession session, string lifecycleState)
|
||||
{
|
||||
if (session == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_metricsModule.RecordSessionDiagnostics(session.RemoteEndPoint, session.CaptureDiagnostics(lifecycleState));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,225 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Text;
|
||||
|
||||
namespace Network.NetworkTransport
|
||||
{
|
||||
public static class TransportMetricsDiagnosisFormatter
|
||||
{
|
||||
public static string BuildChineseDiagnosis(TransportMetricsSnapshot snapshot)
|
||||
{
|
||||
if (snapshot == null)
|
||||
{
|
||||
throw new ArgumentNullException(nameof(snapshot));
|
||||
}
|
||||
|
||||
var builder = new StringBuilder();
|
||||
builder.AppendLine("Transport Metrics Diagnosis / 传输诊断结论");
|
||||
builder.AppendLine();
|
||||
builder.AppendLine("结论摘要");
|
||||
builder.AppendLine($"- 传输实现:{snapshot.TransportName}");
|
||||
builder.AppendLine($"- 运行模式:{TranslateMode(snapshot.Mode)}");
|
||||
builder.AppendLine($"- 运行时长:{snapshot.DurationMs} 毫秒");
|
||||
builder.AppendLine($"- 总体判断:{BuildOverallAssessment(snapshot)}");
|
||||
builder.AppendLine();
|
||||
builder.AppendLine("关键观察");
|
||||
|
||||
foreach (var finding in BuildFindings(snapshot))
|
||||
{
|
||||
builder.AppendLine($"- {finding}");
|
||||
}
|
||||
|
||||
builder.AppendLine();
|
||||
builder.AppendLine("会话状态");
|
||||
|
||||
foreach (var line in BuildSessionLines(snapshot))
|
||||
{
|
||||
builder.AppendLine($"- {line}");
|
||||
}
|
||||
|
||||
builder.AppendLine();
|
||||
builder.AppendLine("热点对端");
|
||||
|
||||
foreach (var peerLine in BuildPeerLines(snapshot))
|
||||
{
|
||||
builder.AppendLine($"- {peerLine}");
|
||||
}
|
||||
|
||||
return builder.ToString();
|
||||
}
|
||||
|
||||
private static IEnumerable<string> BuildFindings(TransportMetricsSnapshot snapshot)
|
||||
{
|
||||
yield return $"业务消息发送/接收={snapshot.PayloadMessagesSent}/{snapshot.PayloadMessagesReceived},数据报发送/接收={snapshot.DatagramsSent}/{snapshot.DatagramsReceived}。";
|
||||
|
||||
if (snapshot.ApplicationSessionsTracked > 0)
|
||||
{
|
||||
yield return $"共享会话已跟踪 {snapshot.ApplicationSessionsTracked} 个,状态分布:{FormatCounts(snapshot.ApplicationSessionStateCounts)}。";
|
||||
}
|
||||
|
||||
if (snapshot.SessionsWithDiagnostics == 0)
|
||||
{
|
||||
yield return "没有采集到 KCP 会话诊断数据,当前无法判断 RTT、队列堆积和重传情况。";
|
||||
yield break;
|
||||
}
|
||||
|
||||
yield return $"平均 RTT={snapshot.AverageSmoothedRttMs:F1} 毫秒,峰值 RTT={snapshot.PeakSmoothedRttMs} 毫秒。";
|
||||
yield return $"WaitSnd 总量={snapshot.TotalWaitSendCount},发送队列={snapshot.TotalSendQueueCount},发送缓冲={snapshot.TotalSendBufferCount}。";
|
||||
yield return $"在途重传段={snapshot.TotalRetransmittedSegmentsInFlight},累计观察到的重传次数={snapshot.TotalObservedRetransmissionSends}。";
|
||||
|
||||
if (snapshot.TotalWaitSendCount > 0 || snapshot.TotalSendQueueCount > 0 || snapshot.TotalSendBufferCount > 0)
|
||||
{
|
||||
yield return "存在发送侧堆积迹象,建议优先排查发送频率、消息体积和远端处理能力。";
|
||||
}
|
||||
|
||||
if (snapshot.TotalObservedRetransmissionSends > 0 || snapshot.TotalRetransmittedSegmentsInFlight > 0)
|
||||
{
|
||||
yield return "存在重传迹象,建议结合网络抖动、丢包和窗口大小继续排查。";
|
||||
}
|
||||
|
||||
if (snapshot.AverageSmoothedRttMs >= 150 || snapshot.PeakSmoothedRttMs >= 250)
|
||||
{
|
||||
yield return "延迟已偏高,若游戏表现出现卡顿或回滚,优先关注 RTT 抖动。";
|
||||
}
|
||||
|
||||
if (snapshot.SendErrors + snapshot.ReceiveErrors + snapshot.OtherErrors > 0)
|
||||
{
|
||||
yield return $"记录到错误 {snapshot.SendErrors + snapshot.ReceiveErrors + snapshot.OtherErrors} 次,最高频阶段={FindBusiestStage(snapshot)}。";
|
||||
}
|
||||
}
|
||||
|
||||
private static IEnumerable<string> BuildSessionLines(TransportMetricsSnapshot snapshot)
|
||||
{
|
||||
if (snapshot.ApplicationSessionSummaries.Count == 0)
|
||||
{
|
||||
yield return "没有共享会话快照。";
|
||||
yield break;
|
||||
}
|
||||
|
||||
foreach (var session in snapshot.ApplicationSessionSummaries
|
||||
.OrderBy(item => item.Scope, StringComparer.Ordinal)
|
||||
.ThenBy(item => item.RemoteEndPoint, StringComparer.Ordinal))
|
||||
{
|
||||
var target = string.IsNullOrWhiteSpace(session.RemoteEndPoint) ? "default" : session.RemoteEndPoint;
|
||||
var details = new List<string>
|
||||
{
|
||||
$"scope={session.Scope}",
|
||||
$"target={target}",
|
||||
$"state={session.ConnectionState}"
|
||||
};
|
||||
|
||||
if (session.CanSendHeartbeat)
|
||||
{
|
||||
details.Add("heartbeat=ready");
|
||||
}
|
||||
|
||||
if (session.LastRoundTripTimeMs.HasValue)
|
||||
{
|
||||
details.Add($"rtt={session.LastRoundTripTimeMs.Value}ms");
|
||||
}
|
||||
|
||||
if (session.NextReconnectAtUtc.HasValue)
|
||||
{
|
||||
details.Add($"reconnectAt={session.NextReconnectAtUtc.Value:O}");
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(session.LastFailureReason))
|
||||
{
|
||||
details.Add($"reason={session.LastFailureReason}");
|
||||
}
|
||||
|
||||
if (session.CurrentServerTick.HasValue)
|
||||
{
|
||||
details.Add($"serverTick={session.CurrentServerTick.Value}");
|
||||
}
|
||||
|
||||
yield return string.Join(", ", details);
|
||||
}
|
||||
}
|
||||
|
||||
private static IEnumerable<string> BuildPeerLines(TransportMetricsSnapshot snapshot)
|
||||
{
|
||||
var peers = snapshot.PeerSummaries
|
||||
.OrderByDescending(peer => peer.PayloadBytesSent + peer.PayloadBytesReceived)
|
||||
.ThenBy(peer => peer.RemoteEndPoint, StringComparer.Ordinal)
|
||||
.Take(5)
|
||||
.ToList();
|
||||
|
||||
if (peers.Count == 0)
|
||||
{
|
||||
yield return "没有热点对端数据。";
|
||||
yield break;
|
||||
}
|
||||
|
||||
foreach (var peer in peers)
|
||||
{
|
||||
yield return
|
||||
$"{peer.RemoteEndPoint}: payload={peer.PayloadMessagesSent + peer.PayloadMessagesReceived} 条, datagram={peer.DatagramsSent + peer.DatagramsReceived} 个, " +
|
||||
$"waitSnd={peer.SessionDiagnostics.WaitSendCount}, retrans={peer.ObservedRetransmissionSends}, state={peer.SessionLifecycleState}";
|
||||
}
|
||||
}
|
||||
|
||||
private static string BuildOverallAssessment(TransportMetricsSnapshot snapshot)
|
||||
{
|
||||
if (snapshot.SessionsWithDiagnostics == 0 && snapshot.ApplicationSessionsTracked == 0)
|
||||
{
|
||||
return "观测数据不足";
|
||||
}
|
||||
|
||||
var hasErrors = snapshot.SendErrors + snapshot.ReceiveErrors + snapshot.OtherErrors > 0;
|
||||
var hasRetransmissions = snapshot.TotalObservedRetransmissionSends > 0 || snapshot.TotalRetransmittedSegmentsInFlight > 0;
|
||||
var hasBacklog = snapshot.TotalWaitSendCount > 0 || snapshot.TotalSendQueueCount > 0 || snapshot.TotalSendBufferCount > 0;
|
||||
var hasReconnect = snapshot.ApplicationSessionStateCounts.TryGetValue("ReconnectPending", out var pending) && pending > 0;
|
||||
var hasTimeout = snapshot.ApplicationSessionStateCounts.TryGetValue("TimedOut", out var timedOut) && timedOut > 0;
|
||||
var highLatency = snapshot.AverageSmoothedRttMs >= 150 || snapshot.PeakSmoothedRttMs >= 250;
|
||||
|
||||
if (hasTimeout || hasReconnect)
|
||||
{
|
||||
return "已出现会话不稳定迹象";
|
||||
}
|
||||
|
||||
if (hasErrors && (hasRetransmissions || hasBacklog || highLatency))
|
||||
{
|
||||
return "网络质量存在明显风险";
|
||||
}
|
||||
|
||||
if (hasRetransmissions || hasBacklog || highLatency)
|
||||
{
|
||||
return "网络链路存在轻中度异常";
|
||||
}
|
||||
|
||||
return "整体稳定";
|
||||
}
|
||||
|
||||
private static string FindBusiestStage(TransportMetricsSnapshot snapshot)
|
||||
{
|
||||
return snapshot.ErrorCountsByStage.Count == 0
|
||||
? "无"
|
||||
: snapshot.ErrorCountsByStage
|
||||
.OrderByDescending(pair => pair.Value)
|
||||
.ThenBy(pair => pair.Key, StringComparer.Ordinal)
|
||||
.First()
|
||||
.Key;
|
||||
}
|
||||
|
||||
private static string FormatCounts(IReadOnlyDictionary<string, long> counts)
|
||||
{
|
||||
if (counts == null || counts.Count == 0)
|
||||
{
|
||||
return "无";
|
||||
}
|
||||
|
||||
return string.Join(",", counts.OrderBy(pair => pair.Key, StringComparer.Ordinal).Select(pair => $"{pair.Key}={pair.Value}"));
|
||||
}
|
||||
|
||||
private static string TranslateMode(string mode)
|
||||
{
|
||||
return string.Equals(mode, "server", StringComparison.OrdinalIgnoreCase)
|
||||
? "server / 服务端"
|
||||
: string.Equals(mode, "client", StringComparison.OrdinalIgnoreCase)
|
||||
? "client / 客户端"
|
||||
: mode ?? "unknown / 未知";
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 71d7507d35817ed418a2e8194de6455e
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -14,6 +14,8 @@ namespace Network.NetworkTransport
|
||||
void BeginRun(TransportRunDescriptor descriptor);
|
||||
void RecordSessionOpened(IPEndPoint remoteEndPoint);
|
||||
void RecordSessionClosed(IPEndPoint remoteEndPoint);
|
||||
void RecordSessionDiagnostics(IPEndPoint remoteEndPoint, TransportSessionDiagnosticsSnapshot diagnostics);
|
||||
void RecordApplicationSessionSnapshot(TransportApplicationSessionSnapshot snapshot);
|
||||
void RecordPayloadSent(IPEndPoint remoteEndPoint, int bytes);
|
||||
void RecordPayloadReceived(IPEndPoint remoteEndPoint, int bytes);
|
||||
void RecordDatagramSent(IPEndPoint remoteEndPoint, int bytes);
|
||||
@@ -23,6 +25,11 @@ namespace Network.NetworkTransport
|
||||
TransportMetricsSnapshot CompleteRun();
|
||||
}
|
||||
|
||||
public interface ITransportMetricsSink
|
||||
{
|
||||
void RecordApplicationSessionSnapshot(TransportApplicationSessionSnapshot snapshot);
|
||||
}
|
||||
|
||||
public sealed class TransportRunDescriptor
|
||||
{
|
||||
public TransportRunDescriptor(string transportName, bool isServer, IPEndPoint defaultRemoteEndPoint = null)
|
||||
@@ -55,23 +62,98 @@ namespace Network.NetworkTransport
|
||||
[DataMember(Order = 6)] public DateTimeOffset? CompletedAtUtc { get; set; }
|
||||
[DataMember(Order = 7)] public long DurationMs { get; set; }
|
||||
[DataMember(Order = 8)] public string ReportPath { get; set; }
|
||||
[DataMember(Order = 9)] public int ActiveSessions { get; set; }
|
||||
[DataMember(Order = 10)] public int PeakActiveSessions { get; set; }
|
||||
[DataMember(Order = 11)] public long SessionsCreated { get; set; }
|
||||
[DataMember(Order = 12)] public long SessionsClosed { get; set; }
|
||||
[DataMember(Order = 13)] public long PayloadMessagesSent { get; set; }
|
||||
[DataMember(Order = 14)] public long PayloadBytesSent { get; set; }
|
||||
[DataMember(Order = 15)] public long PayloadMessagesReceived { get; set; }
|
||||
[DataMember(Order = 16)] public long PayloadBytesReceived { get; set; }
|
||||
[DataMember(Order = 17)] public long DatagramsSent { get; set; }
|
||||
[DataMember(Order = 18)] public long DatagramBytesSent { get; set; }
|
||||
[DataMember(Order = 19)] public long DatagramsReceived { get; set; }
|
||||
[DataMember(Order = 20)] public long DatagramBytesReceived { get; set; }
|
||||
[DataMember(Order = 21)] public long SendErrors { get; set; }
|
||||
[DataMember(Order = 22)] public long ReceiveErrors { get; set; }
|
||||
[DataMember(Order = 23)] public long OtherErrors { get; set; }
|
||||
[DataMember(Order = 24)] public Dictionary<string, long> ErrorCountsByStage { get; set; } = new(StringComparer.Ordinal);
|
||||
[DataMember(Order = 25)] public List<TransportPeerMetricsSnapshot> PeerSummaries { get; set; } = new();
|
||||
[DataMember(Order = 9)] public string SummaryPath { get; set; }
|
||||
[DataMember(Order = 10)] public TransportMetricsReadableSummary ReadableSummary { get; set; } = new();
|
||||
[DataMember(Order = 11)] public int ActiveSessions { get; set; }
|
||||
[DataMember(Order = 12)] public int PeakActiveSessions { get; set; }
|
||||
[DataMember(Order = 13)] public long SessionsCreated { get; set; }
|
||||
[DataMember(Order = 14)] public long SessionsClosed { get; set; }
|
||||
[DataMember(Order = 15)] public long PayloadMessagesSent { get; set; }
|
||||
[DataMember(Order = 16)] public long PayloadBytesSent { get; set; }
|
||||
[DataMember(Order = 17)] public long PayloadMessagesReceived { get; set; }
|
||||
[DataMember(Order = 18)] public long PayloadBytesReceived { get; set; }
|
||||
[DataMember(Order = 19)] public long DatagramsSent { get; set; }
|
||||
[DataMember(Order = 20)] public long DatagramBytesSent { get; set; }
|
||||
[DataMember(Order = 21)] public long DatagramsReceived { get; set; }
|
||||
[DataMember(Order = 22)] public long DatagramBytesReceived { get; set; }
|
||||
[DataMember(Order = 23)] public long SendErrors { get; set; }
|
||||
[DataMember(Order = 24)] public long ReceiveErrors { get; set; }
|
||||
[DataMember(Order = 25)] public long OtherErrors { get; set; }
|
||||
[DataMember(Order = 26)] public int SessionsWithDiagnostics { get; set; }
|
||||
[DataMember(Order = 27)] public double AverageSmoothedRttMs { get; set; }
|
||||
[DataMember(Order = 28)] public int PeakSmoothedRttMs { get; set; }
|
||||
[DataMember(Order = 29)] public double AverageRetransmissionTimeoutMs { get; set; }
|
||||
[DataMember(Order = 30)] public int PeakRetransmissionTimeoutMs { get; set; }
|
||||
[DataMember(Order = 31)] public long TotalWaitSendCount { get; set; }
|
||||
[DataMember(Order = 32)] public long PeakWaitSendCount { get; set; }
|
||||
[DataMember(Order = 33)] public long TotalSendQueueCount { get; set; }
|
||||
[DataMember(Order = 34)] public long TotalSendBufferCount { get; set; }
|
||||
[DataMember(Order = 35)] public long TotalReceiveQueueCount { get; set; }
|
||||
[DataMember(Order = 36)] public long TotalReceiveBufferCount { get; set; }
|
||||
[DataMember(Order = 37)] public long TotalRetransmittedSegmentsInFlight { get; set; }
|
||||
[DataMember(Order = 38)] public long PeakRetransmittedSegmentsInFlight { get; set; }
|
||||
[DataMember(Order = 39)] public long TotalObservedRetransmissionSends { get; set; }
|
||||
[DataMember(Order = 40)] public long TotalObservedLossSignals { get; set; }
|
||||
[DataMember(Order = 41)] public Dictionary<string, long> SessionStateCounts { get; set; } = new(StringComparer.Ordinal);
|
||||
[DataMember(Order = 42)] public int ApplicationSessionsTracked { get; set; }
|
||||
[DataMember(Order = 43)] public Dictionary<string, long> ApplicationSessionStateCounts { get; set; } = new(StringComparer.Ordinal);
|
||||
[DataMember(Order = 44)] public List<TransportApplicationSessionSnapshot> ApplicationSessionSummaries { get; set; } = new();
|
||||
[DataMember(Order = 45)] public Dictionary<string, long> ErrorCountsByStage { get; set; } = new(StringComparer.Ordinal);
|
||||
[DataMember(Order = 46)] public List<TransportPeerMetricsSnapshot> PeerSummaries { get; set; } = new();
|
||||
}
|
||||
|
||||
[DataContract]
|
||||
public sealed class TransportMetricsReadableSummary
|
||||
{
|
||||
[DataMember(Order = 1)] public string Headline { get; set; }
|
||||
[DataMember(Order = 2)] public string SessionSummary { get; set; }
|
||||
[DataMember(Order = 3)] public string TrafficSummary { get; set; }
|
||||
[DataMember(Order = 4)] public string ErrorSummary { get; set; }
|
||||
[DataMember(Order = 5)] public string LifecycleSummary { get; set; }
|
||||
[DataMember(Order = 6)] public string HealthSummary { get; set; }
|
||||
[DataMember(Order = 7)] public List<string> TopPeerHighlights { get; set; } = new();
|
||||
}
|
||||
|
||||
[DataContract]
|
||||
public sealed class TransportApplicationSessionSnapshot
|
||||
{
|
||||
[DataMember(Order = 1)] public string Scope { get; set; }
|
||||
[DataMember(Order = 2)] public string RemoteEndPoint { get; set; }
|
||||
[DataMember(Order = 3)] public string ConnectionState { get; set; }
|
||||
[DataMember(Order = 4)] public bool CanSendHeartbeat { get; set; }
|
||||
[DataMember(Order = 5)] public long? LastRoundTripTimeMs { get; set; }
|
||||
[DataMember(Order = 6)] public string LastFailureReason { get; set; }
|
||||
[DataMember(Order = 7)] public DateTimeOffset? LastLivenessUtc { get; set; }
|
||||
[DataMember(Order = 8)] public DateTimeOffset? LastHeartbeatSentUtc { get; set; }
|
||||
[DataMember(Order = 9)] public DateTimeOffset? NextReconnectAtUtc { get; set; }
|
||||
[DataMember(Order = 10)] public long? CurrentServerTick { get; set; }
|
||||
[DataMember(Order = 11)] public DateTimeOffset? ObservedAtUtc { get; set; }
|
||||
}
|
||||
|
||||
[DataContract]
|
||||
public sealed class TransportSessionDiagnosticsSnapshot
|
||||
{
|
||||
[DataMember(Order = 1)] public string LifecycleState { get; set; }
|
||||
[DataMember(Order = 2)] public DateTimeOffset? ObservedAtUtc { get; set; }
|
||||
[DataMember(Order = 3)] public long IdleMs { get; set; }
|
||||
[DataMember(Order = 4)] public int KcpStateCode { get; set; }
|
||||
[DataMember(Order = 5)] public int SmoothedRttMs { get; set; }
|
||||
[DataMember(Order = 6)] public int RttVarianceMs { get; set; }
|
||||
[DataMember(Order = 7)] public int RetransmissionTimeoutMs { get; set; }
|
||||
[DataMember(Order = 8)] public int LocalSendWindow { get; set; }
|
||||
[DataMember(Order = 9)] public int LocalReceiveWindow { get; set; }
|
||||
[DataMember(Order = 10)] public int RemoteWindow { get; set; }
|
||||
[DataMember(Order = 11)] public int CongestionWindow { get; set; }
|
||||
[DataMember(Order = 12)] public int WaitSendCount { get; set; }
|
||||
[DataMember(Order = 13)] public int SendQueueCount { get; set; }
|
||||
[DataMember(Order = 14)] public int SendBufferCount { get; set; }
|
||||
[DataMember(Order = 15)] public int ReceiveQueueCount { get; set; }
|
||||
[DataMember(Order = 16)] public int ReceiveBufferCount { get; set; }
|
||||
[DataMember(Order = 17)] public int DeadLinkThreshold { get; set; }
|
||||
[DataMember(Order = 18)] public long SegmentTransmitCount { get; set; }
|
||||
[DataMember(Order = 19)] public int RetransmittedSegmentsInFlight { get; set; }
|
||||
[DataMember(Order = 20)] public long ObservedRetransmissionSends { get; set; }
|
||||
[DataMember(Order = 21)] public long ObservedLossSignals { get; set; }
|
||||
}
|
||||
|
||||
[DataContract]
|
||||
@@ -90,7 +172,15 @@ namespace Network.NetworkTransport
|
||||
[DataMember(Order = 11)] public long DatagramBytesSent { get; set; }
|
||||
[DataMember(Order = 12)] public long DatagramsReceived { get; set; }
|
||||
[DataMember(Order = 13)] public long DatagramBytesReceived { get; set; }
|
||||
[DataMember(Order = 14)] public Dictionary<string, long> ErrorCountsByStage { get; set; } = new(StringComparer.Ordinal);
|
||||
[DataMember(Order = 14)] public string SessionLifecycleState { get; set; }
|
||||
[DataMember(Order = 15)] public TransportSessionDiagnosticsSnapshot SessionDiagnostics { get; set; } = new();
|
||||
[DataMember(Order = 16)] public int PeakSmoothedRttMs { get; set; }
|
||||
[DataMember(Order = 17)] public int PeakRetransmissionTimeoutMs { get; set; }
|
||||
[DataMember(Order = 18)] public int PeakWaitSendCount { get; set; }
|
||||
[DataMember(Order = 19)] public int PeakRetransmittedSegmentsInFlight { get; set; }
|
||||
[DataMember(Order = 20)] public long ObservedRetransmissionSends { get; set; }
|
||||
[DataMember(Order = 21)] public long ObservedLossSignals { get; set; }
|
||||
[DataMember(Order = 22)] public Dictionary<string, long> ErrorCountsByStage { get; set; } = new(StringComparer.Ordinal);
|
||||
}
|
||||
|
||||
public sealed class DefaultTransportMetricsModule : ITransportMetricsModule
|
||||
@@ -99,7 +189,14 @@ namespace Network.NetworkTransport
|
||||
private readonly Func<DateTimeOffset> utcNowProvider;
|
||||
private readonly TextWriter consoleWriter;
|
||||
private readonly string reportDirectory;
|
||||
private readonly bool writeJsonReport;
|
||||
private readonly bool writeTextSummaryReport;
|
||||
private readonly bool writeDiagnosisReport;
|
||||
private readonly bool emitConsoleSummary;
|
||||
private readonly int maxPeerSummariesInTextReport;
|
||||
private readonly int maxPeerSummariesInConsole;
|
||||
private readonly Dictionary<string, PeerAccumulator> peers = new(StringComparer.Ordinal);
|
||||
private readonly Dictionary<string, TransportApplicationSessionSnapshot> applicationSessions = new(StringComparer.Ordinal);
|
||||
private readonly Dictionary<string, long> errorCountsByStage = new(StringComparer.Ordinal);
|
||||
|
||||
private string runId;
|
||||
@@ -129,12 +226,32 @@ namespace Network.NetworkTransport
|
||||
private TransportMetricsSnapshot completedSnapshot;
|
||||
|
||||
public DefaultTransportMetricsModule(string reportDirectory = null, Func<DateTimeOffset> utcNowProvider = null, TextWriter consoleWriter = null)
|
||||
: this(
|
||||
new TransportMetricsOptions
|
||||
{
|
||||
ReportDirectory = reportDirectory,
|
||||
ConsoleWriter = consoleWriter
|
||||
},
|
||||
utcNowProvider)
|
||||
{
|
||||
}
|
||||
|
||||
public DefaultTransportMetricsModule(TransportMetricsOptions options, Func<DateTimeOffset> utcNowProvider = null)
|
||||
{
|
||||
options ??= TransportMetricsOptions.Default;
|
||||
reportDirectory = options.ReportDirectory;
|
||||
this.reportDirectory = string.IsNullOrWhiteSpace(reportDirectory)
|
||||
? Path.Combine(Directory.GetCurrentDirectory(), "Logs", "transport-metrics")
|
||||
: reportDirectory;
|
||||
this.utcNowProvider = utcNowProvider ?? (() => DateTimeOffset.UtcNow);
|
||||
consoleWriter = options.ConsoleWriter;
|
||||
this.consoleWriter = consoleWriter ?? Console.Out;
|
||||
writeJsonReport = options.WriteJsonReport;
|
||||
writeTextSummaryReport = options.WriteTextSummaryReport;
|
||||
writeDiagnosisReport = options.WriteDiagnosisReport;
|
||||
emitConsoleSummary = options.EmitConsoleSummary;
|
||||
maxPeerSummariesInTextReport = Math.Max(0, options.MaxPeerSummariesInTextReport);
|
||||
maxPeerSummariesInConsole = Math.Max(0, options.MaxPeerSummariesInConsole);
|
||||
}
|
||||
|
||||
public void BeginRun(TransportRunDescriptor descriptor)
|
||||
@@ -147,6 +264,7 @@ namespace Network.NetworkTransport
|
||||
lock (gate)
|
||||
{
|
||||
peers.Clear();
|
||||
applicationSessions.Clear();
|
||||
errorCountsByStage.Clear();
|
||||
runId = Guid.NewGuid().ToString("N");
|
||||
transportName = descriptor.TransportName;
|
||||
@@ -182,6 +300,7 @@ namespace Network.NetworkTransport
|
||||
activeSessions++;
|
||||
peakActiveSessions = Math.Max(peakActiveSessions, activeSessions);
|
||||
peer.SessionOpens++;
|
||||
peer.SessionLifecycleState = "active";
|
||||
});
|
||||
|
||||
public void RecordSessionClosed(IPEndPoint remoteEndPoint) => Update(remoteEndPoint, peer =>
|
||||
@@ -189,8 +308,33 @@ namespace Network.NetworkTransport
|
||||
sessionsClosed++;
|
||||
activeSessions = Math.Max(0, activeSessions - 1);
|
||||
peer.SessionCloses++;
|
||||
peer.SessionLifecycleState = "closed";
|
||||
});
|
||||
|
||||
public void RecordSessionDiagnostics(IPEndPoint remoteEndPoint, TransportSessionDiagnosticsSnapshot diagnostics) => Update(remoteEndPoint, peer =>
|
||||
{
|
||||
if (diagnostics == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
peer.RecordDiagnostics(diagnostics);
|
||||
});
|
||||
|
||||
public void RecordApplicationSessionSnapshot(TransportApplicationSessionSnapshot snapshot)
|
||||
{
|
||||
lock (gate)
|
||||
{
|
||||
if (!hasRun || snapshot == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var key = BuildApplicationSessionKey(snapshot.Scope, snapshot.RemoteEndPoint);
|
||||
applicationSessions[key] = Clone(snapshot);
|
||||
}
|
||||
}
|
||||
|
||||
public void RecordPayloadSent(IPEndPoint remoteEndPoint, int bytes) => Update(remoteEndPoint, peer =>
|
||||
{
|
||||
payloadMessagesSent++;
|
||||
@@ -286,8 +430,14 @@ namespace Network.NetworkTransport
|
||||
completed = true;
|
||||
completedSnapshot = BuildSnapshot();
|
||||
completedSnapshot.ReportPath = WriteJsonReport(completedSnapshot);
|
||||
completedSnapshot.SummaryPath = WriteTextSummaryReport(completedSnapshot);
|
||||
WriteDiagnosisReport(completedSnapshot);
|
||||
reportPath = completedSnapshot.ReportPath;
|
||||
consoleWriter.WriteLine($"[TransportMetrics] {completedSnapshot.TransportName} mode={completedSnapshot.Mode} run={completedSnapshot.RunId} durationMs={completedSnapshot.DurationMs} peak={completedSnapshot.PeakActiveSessions} payloadTx={completedSnapshot.PayloadMessagesSent}/{completedSnapshot.PayloadBytesSent}B payloadRx={completedSnapshot.PayloadMessagesReceived}/{completedSnapshot.PayloadBytesReceived}B datagramTx={completedSnapshot.DatagramsSent}/{completedSnapshot.DatagramBytesSent}B datagramRx={completedSnapshot.DatagramsReceived}/{completedSnapshot.DatagramBytesReceived}B errors={completedSnapshot.SendErrors + completedSnapshot.ReceiveErrors + completedSnapshot.OtherErrors} report={completedSnapshot.ReportPath ?? "none"}");
|
||||
if (emitConsoleSummary)
|
||||
{
|
||||
WriteConsoleSummary(completedSnapshot);
|
||||
}
|
||||
|
||||
return completedSnapshot;
|
||||
}
|
||||
}
|
||||
@@ -310,6 +460,26 @@ namespace Network.NetworkTransport
|
||||
private TransportMetricsSnapshot BuildSnapshot()
|
||||
{
|
||||
var end = completedAtUtc ?? utcNowProvider();
|
||||
var peerSnapshots = peers.Values.Select(peer => peer.ToSnapshot()).OrderBy(peer => peer.RemoteEndPoint, StringComparer.Ordinal).ToList();
|
||||
var peersWithDiagnostics = peerSnapshots.Where(peer => peer.SessionDiagnostics?.ObservedAtUtc != null).ToList();
|
||||
var averageSmoothedRttMs = peersWithDiagnostics.Count == 0
|
||||
? 0d
|
||||
: peersWithDiagnostics.Average(peer => peer.SessionDiagnostics.SmoothedRttMs);
|
||||
var averageRetransmissionTimeoutMs = peersWithDiagnostics.Count == 0
|
||||
? 0d
|
||||
: peersWithDiagnostics.Average(peer => peer.SessionDiagnostics.RetransmissionTimeoutMs);
|
||||
var sessionStateCounts = peerSnapshots
|
||||
.GroupBy(peer => string.IsNullOrWhiteSpace(peer.SessionLifecycleState) ? "unknown" : peer.SessionLifecycleState, StringComparer.Ordinal)
|
||||
.ToDictionary(group => group.Key, group => (long)group.Count(), StringComparer.Ordinal);
|
||||
var applicationSessionSnapshots = applicationSessions.Values
|
||||
.Select(Clone)
|
||||
.OrderBy(snapshot => snapshot.Scope, StringComparer.Ordinal)
|
||||
.ThenBy(snapshot => snapshot.RemoteEndPoint, StringComparer.Ordinal)
|
||||
.ToList();
|
||||
var applicationSessionStateCounts = applicationSessionSnapshots
|
||||
.GroupBy(snapshot => string.IsNullOrWhiteSpace(snapshot.ConnectionState) ? "unknown" : snapshot.ConnectionState, StringComparer.Ordinal)
|
||||
.ToDictionary(group => group.Key, group => (long)group.Count(), StringComparer.Ordinal);
|
||||
|
||||
return new TransportMetricsSnapshot
|
||||
{
|
||||
RunId = runId,
|
||||
@@ -320,6 +490,8 @@ namespace Network.NetworkTransport
|
||||
CompletedAtUtc = completedAtUtc,
|
||||
DurationMs = startedAtUtc.HasValue ? Math.Max(0L, (long)(end - startedAtUtc.Value).TotalMilliseconds) : 0L,
|
||||
ReportPath = reportPath,
|
||||
SummaryPath = null,
|
||||
ReadableSummary = BuildReadableSummary(),
|
||||
ActiveSessions = activeSessions,
|
||||
PeakActiveSessions = peakActiveSessions,
|
||||
SessionsCreated = sessionsCreated,
|
||||
@@ -335,13 +507,107 @@ namespace Network.NetworkTransport
|
||||
SendErrors = sendErrors,
|
||||
ReceiveErrors = receiveErrors,
|
||||
OtherErrors = otherErrors,
|
||||
SessionsWithDiagnostics = peersWithDiagnostics.Count,
|
||||
AverageSmoothedRttMs = averageSmoothedRttMs,
|
||||
PeakSmoothedRttMs = peerSnapshots.Count == 0 ? 0 : peerSnapshots.Max(peer => peer.PeakSmoothedRttMs),
|
||||
AverageRetransmissionTimeoutMs = averageRetransmissionTimeoutMs,
|
||||
PeakRetransmissionTimeoutMs = peerSnapshots.Count == 0 ? 0 : peerSnapshots.Max(peer => peer.PeakRetransmissionTimeoutMs),
|
||||
TotalWaitSendCount = peerSnapshots.Sum(peer => (long)peer.SessionDiagnostics.WaitSendCount),
|
||||
PeakWaitSendCount = peerSnapshots.Count == 0 ? 0 : peerSnapshots.Max(peer => (long)peer.PeakWaitSendCount),
|
||||
TotalSendQueueCount = peerSnapshots.Sum(peer => (long)peer.SessionDiagnostics.SendQueueCount),
|
||||
TotalSendBufferCount = peerSnapshots.Sum(peer => (long)peer.SessionDiagnostics.SendBufferCount),
|
||||
TotalReceiveQueueCount = peerSnapshots.Sum(peer => (long)peer.SessionDiagnostics.ReceiveQueueCount),
|
||||
TotalReceiveBufferCount = peerSnapshots.Sum(peer => (long)peer.SessionDiagnostics.ReceiveBufferCount),
|
||||
TotalRetransmittedSegmentsInFlight = peerSnapshots.Sum(peer => (long)peer.SessionDiagnostics.RetransmittedSegmentsInFlight),
|
||||
PeakRetransmittedSegmentsInFlight = peerSnapshots.Count == 0 ? 0 : peerSnapshots.Max(peer => (long)peer.PeakRetransmittedSegmentsInFlight),
|
||||
TotalObservedRetransmissionSends = peerSnapshots.Sum(peer => peer.ObservedRetransmissionSends),
|
||||
TotalObservedLossSignals = peerSnapshots.Sum(peer => peer.ObservedLossSignals),
|
||||
SessionStateCounts = sessionStateCounts,
|
||||
ApplicationSessionsTracked = applicationSessionSnapshots.Count,
|
||||
ApplicationSessionStateCounts = applicationSessionStateCounts,
|
||||
ApplicationSessionSummaries = applicationSessionSnapshots,
|
||||
ErrorCountsByStage = errorCountsByStage.ToDictionary(pair => pair.Key, pair => pair.Value, StringComparer.Ordinal),
|
||||
PeerSummaries = peers.Values.Select(peer => peer.ToSnapshot()).OrderBy(peer => peer.RemoteEndPoint, StringComparer.Ordinal).ToList()
|
||||
PeerSummaries = peerSnapshots
|
||||
};
|
||||
}
|
||||
|
||||
private TransportMetricsReadableSummary BuildReadableSummary()
|
||||
{
|
||||
var topPeers = peers.Values
|
||||
.OrderByDescending(peer => peer.PayloadBytesSent + peer.PayloadBytesReceived)
|
||||
.ThenBy(peer => peer.RemoteEndPoint, StringComparer.Ordinal)
|
||||
.Take(Math.Max(maxPeerSummariesInTextReport, maxPeerSummariesInConsole))
|
||||
.Select(peer => $"{peer.RemoteEndPoint}: payload={peer.PayloadMessagesSent + peer.PayloadMessagesReceived} msgs, datagram={peer.DatagramsSent + peer.DatagramsReceived} packets, errors={peer.ErrorCountsByStage.Values.Sum()}")
|
||||
.ToList();
|
||||
|
||||
var totalErrors = sendErrors + receiveErrors + otherErrors;
|
||||
var busiestErrorStage = errorCountsByStage.Count == 0
|
||||
? "none"
|
||||
: errorCountsByStage.OrderByDescending(pair => pair.Value).ThenBy(pair => pair.Key, StringComparer.Ordinal).First().Key;
|
||||
|
||||
return new TransportMetricsReadableSummary
|
||||
{
|
||||
Headline = $"{transportName} {mode} run {runId} finished in {Math.Max(0L, startedAtUtc.HasValue ? (long)((completedAtUtc ?? utcNowProvider()) - startedAtUtc.Value).TotalMilliseconds : 0L)} ms.",
|
||||
SessionSummary = $"Sessions active={activeSessions}, peak={peakActiveSessions}, opened={sessionsCreated}, closed={sessionsClosed}, peers={peers.Count}.",
|
||||
TrafficSummary = $"Payload tx/rx={payloadMessagesSent}/{payloadMessagesReceived} msgs ({payloadBytesSent}/{payloadBytesReceived} B), datagram tx/rx={datagramsSent}/{datagramsReceived} ({datagramBytesSent}/{datagramBytesReceived} B).",
|
||||
ErrorSummary = totalErrors == 0
|
||||
? "No transport errors were recorded."
|
||||
: $"Errors total={totalErrors}, send={sendErrors}, receive={receiveErrors}, other={otherErrors}, busiestStage={busiestErrorStage}.",
|
||||
LifecycleSummary = BuildLifecycleSummary(),
|
||||
HealthSummary = BuildHealthSummary(),
|
||||
TopPeerHighlights = topPeers
|
||||
};
|
||||
}
|
||||
|
||||
private string BuildLifecycleSummary()
|
||||
{
|
||||
if (applicationSessions.Count == 0)
|
||||
{
|
||||
return "No shared session lifecycle snapshots were captured.";
|
||||
}
|
||||
|
||||
var stateSummary = string.Join(
|
||||
", ",
|
||||
applicationSessions.Values
|
||||
.GroupBy(snapshot => string.IsNullOrWhiteSpace(snapshot.ConnectionState) ? "unknown" : snapshot.ConnectionState, StringComparer.Ordinal)
|
||||
.OrderBy(group => group.Key, StringComparer.Ordinal)
|
||||
.Select(group => $"{group.Key}={group.Count()}"));
|
||||
|
||||
var heartbeatReady = applicationSessions.Values.Count(snapshot => snapshot.CanSendHeartbeat);
|
||||
var pendingReconnects = applicationSessions.Values.Count(snapshot => snapshot.NextReconnectAtUtc.HasValue);
|
||||
return $"Lifecycle tracked={applicationSessions.Count}, heartbeatReady={heartbeatReady}, reconnectPending={pendingReconnects}, states={stateSummary}.";
|
||||
}
|
||||
|
||||
private string BuildHealthSummary()
|
||||
{
|
||||
var diagnosticsPeers = peers.Values.Where(peer => peer.SessionDiagnostics?.ObservedAtUtc != null).ToList();
|
||||
if (diagnosticsPeers.Count == 0)
|
||||
{
|
||||
return "No KCP session diagnostics were captured.";
|
||||
}
|
||||
|
||||
var averageRtt = diagnosticsPeers.Average(peer => peer.SessionDiagnostics.SmoothedRttMs);
|
||||
var peakRtt = diagnosticsPeers.Max(peer => peer.PeakSmoothedRttMs);
|
||||
var totalWaitSend = diagnosticsPeers.Sum(peer => peer.SessionDiagnostics.WaitSendCount);
|
||||
var totalRetransmittedSegments = diagnosticsPeers.Sum(peer => peer.SessionDiagnostics.RetransmittedSegmentsInFlight);
|
||||
var totalObservedRetransmissions = diagnosticsPeers.Sum(peer => peer.ObservedRetransmissionSends);
|
||||
var stateSummary = string.Join(
|
||||
", ",
|
||||
diagnosticsPeers
|
||||
.GroupBy(peer => string.IsNullOrWhiteSpace(peer.SessionLifecycleState) ? "unknown" : peer.SessionLifecycleState, StringComparer.Ordinal)
|
||||
.OrderBy(group => group.Key, StringComparer.Ordinal)
|
||||
.Select(group => $"{group.Key}={group.Count()}"));
|
||||
|
||||
return $"Health avgRtt={averageRtt:F1} ms, peakRtt={peakRtt} ms, waitSnd={totalWaitSend}, retransInFlight={totalRetransmittedSegments}, observedRetransmissions={totalObservedRetransmissions}, states={stateSummary}.";
|
||||
}
|
||||
|
||||
private string WriteJsonReport(TransportMetricsSnapshot snapshot)
|
||||
{
|
||||
if (!writeJsonReport)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
Directory.CreateDirectory(reportDirectory);
|
||||
@@ -361,6 +627,282 @@ namespace Network.NetworkTransport
|
||||
}
|
||||
}
|
||||
|
||||
private string WriteTextSummaryReport(TransportMetricsSnapshot snapshot)
|
||||
{
|
||||
if (!writeTextSummaryReport)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
Directory.CreateDirectory(reportDirectory);
|
||||
var timestamp = (snapshot.CompletedAtUtc ?? utcNowProvider()).ToString("yyyyMMdd-HHmmssfff");
|
||||
var filePath = Path.Combine(reportDirectory, $"{snapshot.Mode}-{snapshot.TransportName}-{timestamp}-{snapshot.RunId}.summary.txt");
|
||||
File.WriteAllText(filePath, BuildReadableSummaryText(snapshot), new UTF8Encoding(false));
|
||||
return filePath;
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
consoleWriter.WriteLine($"[TransportMetrics] Failed to write summary: {exception.Message}");
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private string WriteDiagnosisReport(TransportMetricsSnapshot snapshot)
|
||||
{
|
||||
if (!writeDiagnosisReport)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
Directory.CreateDirectory(reportDirectory);
|
||||
var timestamp = (snapshot.CompletedAtUtc ?? utcNowProvider()).ToString("yyyyMMdd-HHmmssfff");
|
||||
var filePath = Path.Combine(reportDirectory, $"{snapshot.Mode}-{snapshot.TransportName}-{timestamp}-{snapshot.RunId}.diagnosis.txt");
|
||||
File.WriteAllText(filePath, TransportMetricsDiagnosisFormatter.BuildChineseDiagnosis(snapshot), new UTF8Encoding(false));
|
||||
return filePath;
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
consoleWriter.WriteLine($"[TransportMetrics] Failed to write diagnosis: {exception.Message}");
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private void WriteConsoleSummary(TransportMetricsSnapshot snapshot)
|
||||
{
|
||||
consoleWriter.WriteLine("[TransportMetrics] English Summary");
|
||||
consoleWriter.WriteLine($"[TransportMetrics] Run: {snapshot.RunId}");
|
||||
consoleWriter.WriteLine($"[TransportMetrics] Transport: {snapshot.TransportName}");
|
||||
consoleWriter.WriteLine($"[TransportMetrics] Mode: {snapshot.Mode}");
|
||||
consoleWriter.WriteLine($"[TransportMetrics] StartedAtUtc: {snapshot.StartedAtUtc}");
|
||||
consoleWriter.WriteLine($"[TransportMetrics] CompletedAtUtc: {snapshot.CompletedAtUtc}");
|
||||
consoleWriter.WriteLine($"[TransportMetrics] DurationMs: {snapshot.DurationMs}");
|
||||
consoleWriter.WriteLine($"[TransportMetrics] JsonReport: {snapshot.ReportPath ?? "none"}");
|
||||
consoleWriter.WriteLine($"[TransportMetrics] SummaryReport: {snapshot.SummaryPath ?? "none"}");
|
||||
consoleWriter.WriteLine($"[TransportMetrics] {snapshot.ReadableSummary.Headline}");
|
||||
consoleWriter.WriteLine($"[TransportMetrics] {snapshot.ReadableSummary.SessionSummary}");
|
||||
consoleWriter.WriteLine($"[TransportMetrics] {snapshot.ReadableSummary.TrafficSummary}");
|
||||
consoleWriter.WriteLine($"[TransportMetrics] {snapshot.ReadableSummary.ErrorSummary}");
|
||||
consoleWriter.WriteLine($"[TransportMetrics] {snapshot.ReadableSummary.LifecycleSummary}");
|
||||
consoleWriter.WriteLine($"[TransportMetrics] {snapshot.ReadableSummary.HealthSummary}");
|
||||
consoleWriter.WriteLine("[TransportMetrics] Top Peers:");
|
||||
|
||||
foreach (var line in snapshot.ReadableSummary.TopPeerHighlights.Take(maxPeerSummariesInConsole))
|
||||
{
|
||||
consoleWriter.WriteLine($"[TransportMetrics] Peer: {line}");
|
||||
}
|
||||
|
||||
if (snapshot.ReadableSummary.TopPeerHighlights.Count == 0)
|
||||
{
|
||||
consoleWriter.WriteLine("[TransportMetrics] Peer: none");
|
||||
}
|
||||
|
||||
consoleWriter.WriteLine("[TransportMetrics] Chinese Summary");
|
||||
consoleWriter.WriteLine($"[TransportMetrics] 运行ID: {snapshot.RunId}");
|
||||
consoleWriter.WriteLine($"[TransportMetrics] 传输实现: {snapshot.TransportName}");
|
||||
consoleWriter.WriteLine($"[TransportMetrics] 运行模式: {TranslateMode(snapshot.Mode)}");
|
||||
consoleWriter.WriteLine($"[TransportMetrics] 开始时间(UTC): {snapshot.StartedAtUtc}");
|
||||
consoleWriter.WriteLine($"[TransportMetrics] 结束时间(UTC): {snapshot.CompletedAtUtc}");
|
||||
consoleWriter.WriteLine($"[TransportMetrics] 总耗时(毫秒): {snapshot.DurationMs}");
|
||||
consoleWriter.WriteLine($"[TransportMetrics] Json报告: {snapshot.ReportPath ?? "无"}");
|
||||
consoleWriter.WriteLine($"[TransportMetrics] 摘要报告: {snapshot.SummaryPath ?? "无"}");
|
||||
consoleWriter.WriteLine($"[TransportMetrics] {BuildChineseHeadline(snapshot)}");
|
||||
consoleWriter.WriteLine($"[TransportMetrics] {BuildChineseSessionSummary(snapshot)}");
|
||||
consoleWriter.WriteLine($"[TransportMetrics] {BuildChineseTrafficSummary(snapshot)}");
|
||||
consoleWriter.WriteLine($"[TransportMetrics] {BuildChineseErrorSummary(snapshot)}");
|
||||
consoleWriter.WriteLine($"[TransportMetrics] {BuildChineseLifecycleSummary(snapshot)}");
|
||||
consoleWriter.WriteLine($"[TransportMetrics] {BuildChineseHealthSummary(snapshot)}");
|
||||
consoleWriter.WriteLine("[TransportMetrics] 重点对端:");
|
||||
|
||||
foreach (var peer in snapshot.PeerSummaries
|
||||
.OrderByDescending(item => item.PayloadBytesSent + item.PayloadBytesReceived)
|
||||
.ThenBy(item => item.RemoteEndPoint, StringComparer.Ordinal)
|
||||
.Take(maxPeerSummariesInConsole))
|
||||
{
|
||||
consoleWriter.WriteLine($"[TransportMetrics] 对端: {peer.RemoteEndPoint}: 业务消息={peer.PayloadMessagesSent + peer.PayloadMessagesReceived} 条, 数据报={peer.DatagramsSent + peer.DatagramsReceived} 个, 错误={peer.ErrorCountsByStage.Values.Sum()}");
|
||||
}
|
||||
|
||||
if (snapshot.PeerSummaries.Count == 0)
|
||||
{
|
||||
consoleWriter.WriteLine("[TransportMetrics] 对端: 无");
|
||||
}
|
||||
}
|
||||
|
||||
private string BuildReadableSummaryText(TransportMetricsSnapshot snapshot)
|
||||
{
|
||||
var builder = new StringBuilder();
|
||||
builder.AppendLine("Transport Metrics Summary");
|
||||
builder.AppendLine();
|
||||
builder.AppendLine("English Summary");
|
||||
builder.AppendLine();
|
||||
builder.AppendLine($"Run: {snapshot.RunId}");
|
||||
builder.AppendLine($"Transport: {snapshot.TransportName}");
|
||||
builder.AppendLine($"Mode: {snapshot.Mode}");
|
||||
builder.AppendLine($"StartedAtUtc: {snapshot.StartedAtUtc}");
|
||||
builder.AppendLine($"CompletedAtUtc: {snapshot.CompletedAtUtc}");
|
||||
builder.AppendLine($"DurationMs: {snapshot.DurationMs}");
|
||||
builder.AppendLine();
|
||||
builder.AppendLine(snapshot.ReadableSummary.Headline);
|
||||
builder.AppendLine(snapshot.ReadableSummary.SessionSummary);
|
||||
builder.AppendLine(snapshot.ReadableSummary.TrafficSummary);
|
||||
builder.AppendLine(snapshot.ReadableSummary.ErrorSummary);
|
||||
builder.AppendLine(snapshot.ReadableSummary.LifecycleSummary);
|
||||
builder.AppendLine(snapshot.ReadableSummary.HealthSummary);
|
||||
builder.AppendLine();
|
||||
builder.AppendLine("Top Peers:");
|
||||
|
||||
foreach (var line in snapshot.ReadableSummary.TopPeerHighlights.Take(maxPeerSummariesInTextReport))
|
||||
{
|
||||
builder.AppendLine($"- {line}");
|
||||
}
|
||||
|
||||
if (snapshot.ReadableSummary.TopPeerHighlights.Count == 0)
|
||||
{
|
||||
builder.AppendLine("- none");
|
||||
}
|
||||
|
||||
builder.AppendLine();
|
||||
builder.AppendLine("Paths:");
|
||||
builder.AppendLine($"- JsonReport: {snapshot.ReportPath ?? "disabled"}");
|
||||
builder.AppendLine($"- SummaryReport: {snapshot.SummaryPath ?? "pending"}");
|
||||
builder.AppendLine();
|
||||
builder.AppendLine("Chinese Summary");
|
||||
builder.AppendLine();
|
||||
builder.AppendLine($"运行ID: {snapshot.RunId}");
|
||||
builder.AppendLine($"传输实现: {snapshot.TransportName}");
|
||||
builder.AppendLine($"运行模式: {TranslateMode(snapshot.Mode)}");
|
||||
builder.AppendLine($"开始时间(UTC): {snapshot.StartedAtUtc}");
|
||||
builder.AppendLine($"结束时间(UTC): {snapshot.CompletedAtUtc}");
|
||||
builder.AppendLine($"总耗时(毫秒): {snapshot.DurationMs}");
|
||||
builder.AppendLine();
|
||||
builder.AppendLine(BuildChineseHeadline(snapshot));
|
||||
builder.AppendLine(BuildChineseSessionSummary(snapshot));
|
||||
builder.AppendLine(BuildChineseTrafficSummary(snapshot));
|
||||
builder.AppendLine(BuildChineseErrorSummary(snapshot));
|
||||
builder.AppendLine(BuildChineseLifecycleSummary(snapshot));
|
||||
builder.AppendLine(BuildChineseHealthSummary(snapshot));
|
||||
builder.AppendLine();
|
||||
builder.AppendLine("重点对端:");
|
||||
|
||||
foreach (var peer in snapshot.PeerSummaries
|
||||
.OrderByDescending(item => item.PayloadBytesSent + item.PayloadBytesReceived)
|
||||
.ThenBy(item => item.RemoteEndPoint, StringComparer.Ordinal)
|
||||
.Take(maxPeerSummariesInTextReport))
|
||||
{
|
||||
builder.AppendLine($"- {peer.RemoteEndPoint}: 业务消息={peer.PayloadMessagesSent + peer.PayloadMessagesReceived} 条, 数据报={peer.DatagramsSent + peer.DatagramsReceived} 个, 错误={peer.ErrorCountsByStage.Values.Sum()}");
|
||||
}
|
||||
|
||||
if (snapshot.PeerSummaries.Count == 0)
|
||||
{
|
||||
builder.AppendLine("- 无");
|
||||
}
|
||||
|
||||
builder.AppendLine();
|
||||
builder.AppendLine("文件路径:");
|
||||
builder.AppendLine($"- Json报告: {snapshot.ReportPath ?? "已禁用"}");
|
||||
builder.AppendLine($"- 摘要报告: {snapshot.SummaryPath ?? "待写入"}");
|
||||
return builder.ToString();
|
||||
}
|
||||
|
||||
private static string TranslateMode(string mode)
|
||||
{
|
||||
return string.Equals(mode, "server", StringComparison.OrdinalIgnoreCase)
|
||||
? "server / 服务端"
|
||||
: string.Equals(mode, "client", StringComparison.OrdinalIgnoreCase)
|
||||
? "client / 客户端"
|
||||
: mode ?? "unknown / 未知";
|
||||
}
|
||||
|
||||
private static string BuildChineseHeadline(TransportMetricsSnapshot snapshot)
|
||||
{
|
||||
return $"{snapshot.TransportName} {TranslateMode(snapshot.Mode)} 运行已完成,总耗时 {snapshot.DurationMs} 毫秒。";
|
||||
}
|
||||
|
||||
private static string BuildChineseSessionSummary(TransportMetricsSnapshot snapshot)
|
||||
{
|
||||
return $"会话统计:当前活跃={snapshot.ActiveSessions},峰值={snapshot.PeakActiveSessions},建立={snapshot.SessionsCreated},关闭={snapshot.SessionsClosed},对端数={snapshot.PeerSummaries.Count}。";
|
||||
}
|
||||
|
||||
private static string BuildChineseTrafficSummary(TransportMetricsSnapshot snapshot)
|
||||
{
|
||||
return $"流量统计:业务消息发送/接收={snapshot.PayloadMessagesSent}/{snapshot.PayloadMessagesReceived} 条({snapshot.PayloadBytesSent}/{snapshot.PayloadBytesReceived} B),数据报发送/接收={snapshot.DatagramsSent}/{snapshot.DatagramsReceived} 个({snapshot.DatagramBytesSent}/{snapshot.DatagramBytesReceived} B)。";
|
||||
}
|
||||
|
||||
private static string BuildChineseErrorSummary(TransportMetricsSnapshot snapshot)
|
||||
{
|
||||
var totalErrors = snapshot.SendErrors + snapshot.ReceiveErrors + snapshot.OtherErrors;
|
||||
if (totalErrors == 0)
|
||||
{
|
||||
return "错误统计:未记录到传输错误。";
|
||||
}
|
||||
|
||||
var busiestErrorStage = snapshot.ErrorCountsByStage.Count == 0
|
||||
? "无"
|
||||
: snapshot.ErrorCountsByStage.OrderByDescending(pair => pair.Value).ThenBy(pair => pair.Key, StringComparer.Ordinal).First().Key;
|
||||
return $"错误统计:总数={totalErrors},发送={snapshot.SendErrors},接收={snapshot.ReceiveErrors},其他={snapshot.OtherErrors},最高频阶段={busiestErrorStage}。";
|
||||
}
|
||||
|
||||
private static string BuildChineseLifecycleSummary(TransportMetricsSnapshot snapshot)
|
||||
{
|
||||
if (snapshot.ApplicationSessionsTracked == 0)
|
||||
{
|
||||
return "生命周期摘要:未捕获到共享会话状态快照。";
|
||||
}
|
||||
|
||||
var states = snapshot.ApplicationSessionStateCounts.Count == 0
|
||||
? "无"
|
||||
: string.Join(",", snapshot.ApplicationSessionStateCounts.OrderBy(pair => pair.Key, StringComparer.Ordinal).Select(pair => $"{pair.Key}={pair.Value}"));
|
||||
var heartbeatReady = snapshot.ApplicationSessionSummaries.Count(session => session.CanSendHeartbeat);
|
||||
var reconnectPending = snapshot.ApplicationSessionSummaries.Count(session => session.NextReconnectAtUtc.HasValue);
|
||||
return $"生命周期摘要:已跟踪={snapshot.ApplicationSessionsTracked},可发送心跳={heartbeatReady},等待重连={reconnectPending},状态分布={states}。";
|
||||
}
|
||||
|
||||
private static string BuildChineseHealthSummary(TransportMetricsSnapshot snapshot)
|
||||
{
|
||||
if (snapshot.SessionsWithDiagnostics == 0)
|
||||
{
|
||||
return "健康摘要:未捕获到 KCP 会话诊断数据。";
|
||||
}
|
||||
|
||||
var states = snapshot.SessionStateCounts.Count == 0
|
||||
? "无"
|
||||
: string.Join(",", snapshot.SessionStateCounts.OrderBy(pair => pair.Key, StringComparer.Ordinal).Select(pair => $"{pair.Key}={pair.Value}"));
|
||||
return $"健康摘要:平均 RTT={snapshot.AverageSmoothedRttMs:F1} 毫秒,峰值 RTT={snapshot.PeakSmoothedRttMs} 毫秒,WaitSnd={snapshot.TotalWaitSendCount},在途重传段={snapshot.TotalRetransmittedSegmentsInFlight},累计观察到的重传次数={snapshot.TotalObservedRetransmissionSends},状态分布={states}。";
|
||||
}
|
||||
|
||||
private static string BuildApplicationSessionKey(string scope, string remoteEndPoint)
|
||||
{
|
||||
var normalizedScope = string.IsNullOrWhiteSpace(scope) ? "default" : scope;
|
||||
var normalizedRemote = string.IsNullOrWhiteSpace(remoteEndPoint) ? "default" : remoteEndPoint;
|
||||
return normalizedScope + "|" + normalizedRemote;
|
||||
}
|
||||
|
||||
private static TransportApplicationSessionSnapshot Clone(TransportApplicationSessionSnapshot snapshot)
|
||||
{
|
||||
if (snapshot == null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return new TransportApplicationSessionSnapshot
|
||||
{
|
||||
Scope = snapshot.Scope,
|
||||
RemoteEndPoint = snapshot.RemoteEndPoint,
|
||||
ConnectionState = snapshot.ConnectionState,
|
||||
CanSendHeartbeat = snapshot.CanSendHeartbeat,
|
||||
LastRoundTripTimeMs = snapshot.LastRoundTripTimeMs,
|
||||
LastFailureReason = snapshot.LastFailureReason,
|
||||
LastLivenessUtc = snapshot.LastLivenessUtc,
|
||||
LastHeartbeatSentUtc = snapshot.LastHeartbeatSentUtc,
|
||||
NextReconnectAtUtc = snapshot.NextReconnectAtUtc,
|
||||
CurrentServerTick = snapshot.CurrentServerTick,
|
||||
ObservedAtUtc = snapshot.ObservedAtUtc
|
||||
};
|
||||
}
|
||||
|
||||
private PeerAccumulator GetOrCreatePeer(IPEndPoint remoteEndPoint)
|
||||
{
|
||||
var key = FormatEndPoint(remoteEndPoint) ?? "unknown";
|
||||
@@ -485,8 +1027,30 @@ namespace Network.NetworkTransport
|
||||
public long DatagramBytesSent { get; set; }
|
||||
public long DatagramsReceived { get; set; }
|
||||
public long DatagramBytesReceived { get; set; }
|
||||
public string SessionLifecycleState { get; set; } = "active";
|
||||
public TransportSessionDiagnosticsSnapshot SessionDiagnostics { get; private set; } = new();
|
||||
public int PeakSmoothedRttMs { get; private set; }
|
||||
public int PeakRetransmissionTimeoutMs { get; private set; }
|
||||
public int PeakWaitSendCount { get; private set; }
|
||||
public int PeakRetransmittedSegmentsInFlight { get; private set; }
|
||||
public long ObservedRetransmissionSends { get; private set; }
|
||||
public long ObservedLossSignals { get; private set; }
|
||||
public Dictionary<string, long> ErrorCountsByStage { get; } = new(StringComparer.Ordinal);
|
||||
|
||||
public void RecordDiagnostics(TransportSessionDiagnosticsSnapshot diagnostics)
|
||||
{
|
||||
SessionLifecycleState = string.IsNullOrWhiteSpace(diagnostics.LifecycleState)
|
||||
? SessionLifecycleState
|
||||
: diagnostics.LifecycleState;
|
||||
SessionDiagnostics = diagnostics;
|
||||
PeakSmoothedRttMs = Math.Max(PeakSmoothedRttMs, diagnostics.SmoothedRttMs);
|
||||
PeakRetransmissionTimeoutMs = Math.Max(PeakRetransmissionTimeoutMs, diagnostics.RetransmissionTimeoutMs);
|
||||
PeakWaitSendCount = Math.Max(PeakWaitSendCount, diagnostics.WaitSendCount);
|
||||
PeakRetransmittedSegmentsInFlight = Math.Max(PeakRetransmittedSegmentsInFlight, diagnostics.RetransmittedSegmentsInFlight);
|
||||
ObservedRetransmissionSends = Math.Max(ObservedRetransmissionSends, diagnostics.ObservedRetransmissionSends);
|
||||
ObservedLossSignals = Math.Max(ObservedLossSignals, diagnostics.ObservedLossSignals);
|
||||
}
|
||||
|
||||
public TransportPeerMetricsSnapshot ToSnapshot()
|
||||
{
|
||||
return new TransportPeerMetricsSnapshot
|
||||
@@ -504,6 +1068,14 @@ namespace Network.NetworkTransport
|
||||
DatagramBytesSent = DatagramBytesSent,
|
||||
DatagramsReceived = DatagramsReceived,
|
||||
DatagramBytesReceived = DatagramBytesReceived,
|
||||
SessionLifecycleState = SessionLifecycleState,
|
||||
SessionDiagnostics = SessionDiagnostics,
|
||||
PeakSmoothedRttMs = PeakSmoothedRttMs,
|
||||
PeakRetransmissionTimeoutMs = PeakRetransmissionTimeoutMs,
|
||||
PeakWaitSendCount = PeakWaitSendCount,
|
||||
PeakRetransmittedSegmentsInFlight = PeakRetransmittedSegmentsInFlight,
|
||||
ObservedRetransmissionSends = ObservedRetransmissionSends,
|
||||
ObservedLossSignals = ObservedLossSignals,
|
||||
ErrorCountsByStage = ErrorCountsByStage.ToDictionary(pair => pair.Key, pair => pair.Value, StringComparer.Ordinal)
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
|
||||
namespace Network.NetworkTransport
|
||||
{
|
||||
public sealed class TransportMetricsOptions
|
||||
{
|
||||
public static TransportMetricsOptions Default { get; } = new();
|
||||
|
||||
public string ReportDirectory { get; set; }
|
||||
|
||||
public TextWriter ConsoleWriter { get; set; }
|
||||
|
||||
public bool WriteJsonReport { get; set; } = true;
|
||||
|
||||
public bool WriteTextSummaryReport { get; set; } = true;
|
||||
|
||||
public bool WriteDiagnosisReport { get; set; } = true;
|
||||
|
||||
public bool EmitConsoleSummary { get; set; } = true;
|
||||
|
||||
public int MaxPeerSummariesInTextReport { get; set; } = 5;
|
||||
|
||||
public int MaxPeerSummariesInConsole { get; set; } = 3;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 34979a7bd532d8e4b8f80a2f9fcfdea2
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,44 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
|
||||
namespace Network.NetworkTransport
|
||||
{
|
||||
public static class TransportMetricsReportLocator
|
||||
{
|
||||
public static string GetDefaultReportDirectory()
|
||||
{
|
||||
return Path.Combine(Directory.GetCurrentDirectory(), "Logs", "transport-metrics");
|
||||
}
|
||||
|
||||
public static string TryGetLatestDiagnosisPath(string reportDirectory = null)
|
||||
{
|
||||
var directory = string.IsNullOrWhiteSpace(reportDirectory)
|
||||
? GetDefaultReportDirectory()
|
||||
: reportDirectory;
|
||||
|
||||
if (!Directory.Exists(directory))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return new DirectoryInfo(directory)
|
||||
.GetFiles("*.diagnosis.txt", SearchOption.TopDirectoryOnly)
|
||||
.OrderByDescending(file => file.LastWriteTimeUtc)
|
||||
.ThenByDescending(file => file.Name, StringComparer.Ordinal)
|
||||
.Select(file => file.FullName)
|
||||
.FirstOrDefault();
|
||||
}
|
||||
|
||||
public static string ReadLatestDiagnosisText(string reportDirectory = null)
|
||||
{
|
||||
var path = TryGetLatestDiagnosisPath(reportDirectory);
|
||||
if (string.IsNullOrWhiteSpace(path) || !File.Exists(path))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return File.ReadAllText(path);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 2858757643e325e409331d4c4df1f956
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -17,10 +17,12 @@ public class NetworkManager : MonoBehaviour
|
||||
private uint _sequence = 0;
|
||||
private Task _networkDrainTask = Task.CompletedTask;
|
||||
[SerializeField] private GameObject _wrongWindow;
|
||||
[SerializeField] private bool _enableNetworkDiagnosticsOverlay = true;
|
||||
|
||||
private void Awake()
|
||||
{
|
||||
Instance = this;
|
||||
EnsureDiagnosticsOverlay();
|
||||
StartCoroutine(InitNetwork());
|
||||
}
|
||||
|
||||
@@ -80,6 +82,19 @@ public class NetworkManager : MonoBehaviour
|
||||
}
|
||||
}
|
||||
|
||||
private void EnsureDiagnosticsOverlay()
|
||||
{
|
||||
if (!_enableNetworkDiagnosticsOverlay)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (GetComponent<NetworkDiagnosticsOverlay>() == null)
|
||||
{
|
||||
gameObject.AddComponent<NetworkDiagnosticsOverlay>();
|
||||
}
|
||||
}
|
||||
|
||||
private IEnumerator Heartbeat()
|
||||
{
|
||||
while (true)
|
||||
@@ -110,6 +125,7 @@ public class NetworkManager : MonoBehaviour
|
||||
{
|
||||
var response = LoginResponse.Parser.ParseFrom(data);
|
||||
_networkRuntime.NotifyInboundActivity();
|
||||
_networkRuntime.ClockSync.ObserveSample(response.ServerTick);
|
||||
_serverPoint = sender;
|
||||
if (response.Result)
|
||||
{
|
||||
@@ -128,7 +144,15 @@ public class NetworkManager : MonoBehaviour
|
||||
{
|
||||
_networkRuntime.NotifyInboundActivity();
|
||||
var message = PlayerState.Parser.ParseFrom(data);
|
||||
_networkRuntime.ObserveAuthoritativeState(message.Tick);
|
||||
MasterManager.Instance.MovePlayer(message.PlayerId, message);
|
||||
var player = MasterManager.Instance.GetCurrentPlayer();
|
||||
var currentServerTick = _networkRuntime.ClockSync.CurrentServerTick;
|
||||
if (player != null && currentServerTick.HasValue)
|
||||
{
|
||||
player.SyncTick(currentServerTick.Value);
|
||||
}
|
||||
|
||||
Debug.Log($"收到PlayerState::PlayerID={message.PlayerId},Position=" + message.Position.ToVector3().ToString());
|
||||
}
|
||||
|
||||
@@ -137,9 +161,10 @@ public class NetworkManager : MonoBehaviour
|
||||
var response = HeartbeatResponse.Parser.ParseFrom(data);
|
||||
_networkRuntime.NotifyHeartbeatReceived(response.ServerTick);
|
||||
var player = MasterManager.Instance.GetCurrentPlayer();
|
||||
if (player != null)
|
||||
var currentServerTick = _networkRuntime.ClockSync.CurrentServerTick;
|
||||
if (player != null && currentServerTick.HasValue)
|
||||
{
|
||||
player.SyncTick(response.ServerTick);
|
||||
player.SyncTick(currentServerTick.Value);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
using System;
|
||||
using Network.NetworkTransport;
|
||||
using UnityEngine;
|
||||
|
||||
public sealed class NetworkDiagnosticsOverlay : MonoBehaviour
|
||||
{
|
||||
[SerializeField] private KeyCode _toggleKey = KeyCode.F3;
|
||||
[SerializeField] private bool _visible = true;
|
||||
[SerializeField] private float _refreshIntervalSeconds = 1f;
|
||||
[SerializeField] private Vector2 _panelSize = new Vector2(560f, 420f);
|
||||
[SerializeField] private Vector2 _panelOffset = new Vector2(16f, 16f);
|
||||
|
||||
private readonly GUIStyle _panelStyle = new GUIStyle();
|
||||
private readonly GUIStyle _headerStyle = new GUIStyle();
|
||||
private readonly GUIStyle _bodyStyle = new GUIStyle();
|
||||
private Vector2 _scrollPosition;
|
||||
private string _diagnosisText = "暂无诊断报告。";
|
||||
private string _reportPath = "未找到";
|
||||
private float _nextRefreshAt;
|
||||
|
||||
private void Awake()
|
||||
{
|
||||
ConfigureStyles();
|
||||
RefreshDiagnosis();
|
||||
}
|
||||
|
||||
private void Update()
|
||||
{
|
||||
if (Input.GetKeyDown(_toggleKey))
|
||||
{
|
||||
_visible = !_visible;
|
||||
}
|
||||
|
||||
if (Time.unscaledTime >= _nextRefreshAt)
|
||||
{
|
||||
RefreshDiagnosis();
|
||||
}
|
||||
}
|
||||
|
||||
private void OnGUI()
|
||||
{
|
||||
if (!_visible)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var area = new Rect(_panelOffset.x, _panelOffset.y, _panelSize.x, _panelSize.y);
|
||||
GUILayout.BeginArea(area, GUIContent.none, _panelStyle);
|
||||
GUILayout.Label("Network Diagnosis", _headerStyle);
|
||||
GUILayout.Label("网络诊断面板", _headerStyle);
|
||||
GUILayout.Space(6f);
|
||||
GUILayout.Label("Toggle: F3", _bodyStyle);
|
||||
GUILayout.Label("最新报告: " + _reportPath, _bodyStyle);
|
||||
GUILayout.Space(8f);
|
||||
_scrollPosition = GUILayout.BeginScrollView(_scrollPosition);
|
||||
GUILayout.Label(_diagnosisText, _bodyStyle);
|
||||
GUILayout.EndScrollView();
|
||||
GUILayout.EndArea();
|
||||
}
|
||||
|
||||
private void ConfigureStyles()
|
||||
{
|
||||
_panelStyle.normal.background = MakeBackground(new Color(0.08f, 0.11f, 0.15f, 0.92f));
|
||||
_panelStyle.padding = new RectOffset(14, 14, 12, 12);
|
||||
|
||||
_headerStyle.fontSize = 16;
|
||||
_headerStyle.fontStyle = FontStyle.Bold;
|
||||
_headerStyle.normal.textColor = new Color(0.92f, 0.96f, 0.98f);
|
||||
_headerStyle.wordWrap = false;
|
||||
|
||||
_bodyStyle.fontSize = 13;
|
||||
_bodyStyle.normal.textColor = new Color(0.82f, 0.88f, 0.92f);
|
||||
_bodyStyle.wordWrap = true;
|
||||
_bodyStyle.richText = false;
|
||||
}
|
||||
|
||||
private void RefreshDiagnosis()
|
||||
{
|
||||
_nextRefreshAt = Time.unscaledTime + Mathf.Max(0.25f, _refreshIntervalSeconds);
|
||||
try
|
||||
{
|
||||
var path = TransportMetricsReportLocator.TryGetLatestDiagnosisPath();
|
||||
_reportPath = string.IsNullOrWhiteSpace(path) ? "未找到" : path;
|
||||
_diagnosisText = TransportMetricsReportLocator.ReadLatestDiagnosisText() ?? "暂无诊断报告。";
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
_reportPath = "读取失败";
|
||||
_diagnosisText = "读取诊断报告失败:\n" + exception.Message;
|
||||
}
|
||||
}
|
||||
|
||||
private static Texture2D MakeBackground(Color color)
|
||||
{
|
||||
var texture = new Texture2D(1, 1, TextureFormat.RGBA32, false);
|
||||
texture.SetPixel(0, 0, color);
|
||||
texture.Apply();
|
||||
return texture;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: cfecb17543914304ea29c85780a92b77
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -194,6 +194,30 @@ namespace Tests.EditMode.Network
|
||||
|
||||
module.BeginRun(new TransportRunDescriptor(nameof(KcpTransport), isServer: false, defaultRemoteEndPoint: remote));
|
||||
module.RecordSessionOpened(remote);
|
||||
module.RecordSessionDiagnostics(remote, new TransportSessionDiagnosticsSnapshot
|
||||
{
|
||||
LifecycleState = "active",
|
||||
ObservedAtUtc = DateTimeOffset.UtcNow,
|
||||
SmoothedRttMs = 18,
|
||||
RetransmissionTimeoutMs = 45,
|
||||
WaitSendCount = 3,
|
||||
SendQueueCount = 2,
|
||||
SendBufferCount = 1,
|
||||
ReceiveQueueCount = 0,
|
||||
ReceiveBufferCount = 0,
|
||||
RetransmittedSegmentsInFlight = 1,
|
||||
ObservedRetransmissionSends = 4,
|
||||
ObservedLossSignals = 4
|
||||
});
|
||||
module.RecordApplicationSessionSnapshot(new TransportApplicationSessionSnapshot
|
||||
{
|
||||
Scope = "shared-runtime",
|
||||
ConnectionState = "LoggedIn",
|
||||
CanSendHeartbeat = true,
|
||||
LastRoundTripTimeMs = 18,
|
||||
CurrentServerTick = 321,
|
||||
ObservedAtUtc = DateTimeOffset.UtcNow
|
||||
});
|
||||
module.RecordPayloadSent(remote, 64);
|
||||
module.RecordDatagramSent(remote, 96);
|
||||
module.RecordError("socket-send", remote, "simulated");
|
||||
@@ -202,17 +226,47 @@ namespace Tests.EditMode.Network
|
||||
var first = module.CompleteRun();
|
||||
var second = module.CompleteRun();
|
||||
var reportFiles = Directory.GetFiles(reportDirectory, "*.json");
|
||||
var summaryFiles = Directory.GetFiles(reportDirectory, "*.summary.txt");
|
||||
var diagnosisFiles = Directory.GetFiles(reportDirectory, "*.diagnosis.txt");
|
||||
var reportText = File.ReadAllText(reportFiles[0]);
|
||||
var summaryText = File.ReadAllText(summaryFiles[0]);
|
||||
var diagnosisText = File.ReadAllText(diagnosisFiles[0]);
|
||||
|
||||
Assert.That(reportFiles, Has.Length.EqualTo(1));
|
||||
Assert.That(summaryFiles, Has.Length.EqualTo(1));
|
||||
Assert.That(diagnosisFiles, Has.Length.EqualTo(1));
|
||||
Assert.That(first.ReportPath, Is.EqualTo(reportFiles[0]));
|
||||
Assert.That(first.SummaryPath, Is.EqualTo(summaryFiles[0]));
|
||||
Assert.That(second.ReportPath, Is.EqualTo(first.ReportPath));
|
||||
Assert.That(first.SessionsCreated, Is.EqualTo(1));
|
||||
Assert.That(first.SessionsClosed, Is.EqualTo(1));
|
||||
Assert.That(first.SessionsWithDiagnostics, Is.EqualTo(1));
|
||||
Assert.That(first.AverageSmoothedRttMs, Is.EqualTo(18).Within(0.01));
|
||||
Assert.That(first.TotalObservedRetransmissionSends, Is.EqualTo(4));
|
||||
Assert.That(first.TotalObservedLossSignals, Is.EqualTo(4));
|
||||
Assert.That(first.SessionStateCounts["closed"], Is.EqualTo(1));
|
||||
Assert.That(first.ApplicationSessionsTracked, Is.EqualTo(1));
|
||||
Assert.That(first.ApplicationSessionStateCounts["LoggedIn"], Is.EqualTo(1));
|
||||
Assert.That(first.ErrorCountsByStage["socket-send"], Is.EqualTo(1));
|
||||
Assert.That(first.ReadableSummary.Headline, Does.Contain("finished"));
|
||||
Assert.That(first.ReadableSummary.LifecycleSummary, Does.Contain("states=LoggedIn=1"));
|
||||
Assert.That(first.ReadableSummary.HealthSummary, Does.Contain("avgRtt=18.0 ms"));
|
||||
Assert.That(first.ReadableSummary.HealthSummary, Does.Contain("observedRetransmissions=4"));
|
||||
Assert.That(reportText, Does.Contain(Environment.NewLine));
|
||||
Assert.That(reportText, Does.Contain(" \"RunId\""));
|
||||
Assert.That(consoleWriter.ToString(), Does.Contain("[TransportMetrics] KcpTransport"));
|
||||
Assert.That(reportText, Does.Contain(" \"ReadableSummary\""));
|
||||
Assert.That(summaryText, Does.Contain("Transport Metrics Summary"));
|
||||
Assert.That(summaryText, Does.Contain("English Summary"));
|
||||
Assert.That(summaryText, Does.Contain("Chinese Summary"));
|
||||
Assert.That(summaryText, Does.Contain("Top Peers:"));
|
||||
Assert.That(summaryText, Does.Contain("states=LoggedIn=1"));
|
||||
Assert.That(summaryText, Does.Contain("avgRtt=18.0 ms"));
|
||||
Assert.That(diagnosisText, Does.Contain("传输诊断结论"));
|
||||
Assert.That(diagnosisText, Does.Contain("网络质量存在明显风险"));
|
||||
Assert.That(diagnosisText, Does.Contain("共享会话已跟踪 1 个"));
|
||||
Assert.That(summaryText, Does.Contain("重点对端:"));
|
||||
Assert.That(consoleWriter.ToString(), Does.Contain("[TransportMetrics] English Summary"));
|
||||
Assert.That(consoleWriter.ToString(), Does.Contain("[TransportMetrics] Chinese Summary"));
|
||||
}
|
||||
finally
|
||||
{
|
||||
@@ -268,6 +322,11 @@ namespace Tests.EditMode.Network
|
||||
Assert.That(liveSnapshot.PeerSummaries, Has.Count.EqualTo(2));
|
||||
Assert.That(liveSnapshot.PeerSummaries.Sum(peer => peer.PayloadMessagesReceived), Is.EqualTo(2));
|
||||
Assert.That(liveSnapshot.PeerSummaries.Select(peer => peer.RemoteEndPoint).Distinct().Count(), Is.EqualTo(2));
|
||||
Assert.That(liveSnapshot.SessionsWithDiagnostics, Is.EqualTo(2));
|
||||
Assert.That(liveSnapshot.PeerSummaries.All(peer => peer.SessionDiagnostics.ObservedAtUtc.HasValue), Is.True);
|
||||
Assert.That(liveSnapshot.PeerSummaries.All(peer => peer.SessionLifecycleState == "active"), Is.True);
|
||||
Assert.That(liveSnapshot.TotalSendQueueCount, Is.GreaterThanOrEqualTo(0));
|
||||
Assert.That(liveSnapshot.TotalObservedRetransmissionSends, Is.GreaterThanOrEqualTo(0));
|
||||
|
||||
server.Stop();
|
||||
clientA.Stop();
|
||||
@@ -275,9 +334,14 @@ namespace Tests.EditMode.Network
|
||||
|
||||
var completedSnapshot = server.GetMetricsSnapshot();
|
||||
Assert.That(completedSnapshot.ReportPath, Is.Not.Null.And.Not.Empty);
|
||||
Assert.That(completedSnapshot.SummaryPath, Is.Not.Null.And.Not.Empty);
|
||||
Assert.That(Directory.GetFiles(serverReportDirectory, "*.json"), Has.Length.EqualTo(1));
|
||||
Assert.That(Directory.GetFiles(serverReportDirectory, "*.summary.txt"), Has.Length.EqualTo(1));
|
||||
Assert.That(Directory.GetFiles(serverReportDirectory, "*.diagnosis.txt"), Has.Length.EqualTo(1));
|
||||
Assert.That(completedSnapshot.ActiveSessions, Is.EqualTo(0));
|
||||
Assert.That(completedSnapshot.SessionsClosed, Is.EqualTo(2));
|
||||
Assert.That(completedSnapshot.SessionStateCounts["closed"], Is.EqualTo(2));
|
||||
Assert.That(completedSnapshot.ReadableSummary.HealthSummary, Does.Contain("states=closed=2"));
|
||||
}
|
||||
finally
|
||||
{
|
||||
@@ -289,6 +353,136 @@ namespace Tests.EditMode.Network
|
||||
DeleteDirectory(clientBReportDirectory);
|
||||
}
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void DefaultTransportMetricsModule_DisabledReports_SkipsFilesAndConsole()
|
||||
{
|
||||
var reportDirectory = CreateReportDirectory();
|
||||
var consoleWriter = new StringWriter();
|
||||
var options = new TransportMetricsOptions
|
||||
{
|
||||
ReportDirectory = reportDirectory,
|
||||
ConsoleWriter = consoleWriter,
|
||||
WriteJsonReport = false,
|
||||
WriteTextSummaryReport = false,
|
||||
WriteDiagnosisReport = false,
|
||||
EmitConsoleSummary = false
|
||||
};
|
||||
|
||||
try
|
||||
{
|
||||
var module = new DefaultTransportMetricsModule(options);
|
||||
var remote = new IPEndPoint(IPAddress.Loopback, 5001);
|
||||
|
||||
module.BeginRun(new TransportRunDescriptor(nameof(KcpTransport), isServer: true, defaultRemoteEndPoint: remote));
|
||||
module.RecordPayloadReceived(remote, 32);
|
||||
var snapshot = module.CompleteRun();
|
||||
|
||||
Assert.That(snapshot.ReportPath, Is.Null);
|
||||
Assert.That(snapshot.SummaryPath, Is.Null);
|
||||
Assert.That(Directory.Exists(reportDirectory), Is.False);
|
||||
Assert.That(consoleWriter.ToString(), Is.Empty);
|
||||
}
|
||||
finally
|
||||
{
|
||||
DeleteDirectory(reportDirectory);
|
||||
}
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void TransportMetricsDiagnosisFormatter_HighlightsReconnectAndBacklogRisks()
|
||||
{
|
||||
var snapshot = new TransportMetricsSnapshot
|
||||
{
|
||||
TransportName = nameof(KcpTransport),
|
||||
Mode = "server",
|
||||
DurationMs = 2400,
|
||||
AverageSmoothedRttMs = 188.5,
|
||||
PeakSmoothedRttMs = 320,
|
||||
TotalWaitSendCount = 9,
|
||||
TotalSendQueueCount = 4,
|
||||
TotalSendBufferCount = 2,
|
||||
TotalRetransmittedSegmentsInFlight = 3,
|
||||
TotalObservedRetransmissionSends = 7,
|
||||
SendErrors = 1,
|
||||
ErrorCountsByStage = new Dictionary<string, long>(StringComparer.Ordinal)
|
||||
{
|
||||
["socket-send"] = 1
|
||||
},
|
||||
ApplicationSessionsTracked = 2,
|
||||
ApplicationSessionStateCounts = new Dictionary<string, long>(StringComparer.Ordinal)
|
||||
{
|
||||
["LoggedIn"] = 1,
|
||||
["ReconnectPending"] = 1
|
||||
},
|
||||
ApplicationSessionSummaries = new List<TransportApplicationSessionSnapshot>
|
||||
{
|
||||
new()
|
||||
{
|
||||
Scope = "server-host",
|
||||
RemoteEndPoint = "127.0.0.1:5001",
|
||||
ConnectionState = "ReconnectPending",
|
||||
NextReconnectAtUtc = DateTimeOffset.UtcNow.AddSeconds(2)
|
||||
},
|
||||
new()
|
||||
{
|
||||
Scope = "server-host",
|
||||
RemoteEndPoint = "127.0.0.1:5002",
|
||||
ConnectionState = "LoggedIn",
|
||||
CanSendHeartbeat = true,
|
||||
LastRoundTripTimeMs = 188
|
||||
}
|
||||
},
|
||||
SessionsWithDiagnostics = 2,
|
||||
PeerSummaries = new List<TransportPeerMetricsSnapshot>
|
||||
{
|
||||
new()
|
||||
{
|
||||
RemoteEndPoint = "127.0.0.1:5001",
|
||||
SessionLifecycleState = "active",
|
||||
ObservedRetransmissionSends = 7,
|
||||
SessionDiagnostics = new TransportSessionDiagnosticsSnapshot
|
||||
{
|
||||
WaitSendCount = 9
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
var diagnosis = TransportMetricsDiagnosisFormatter.BuildChineseDiagnosis(snapshot);
|
||||
|
||||
Assert.That(diagnosis, Does.Contain("已出现会话不稳定迹象"));
|
||||
Assert.That(diagnosis, Does.Contain("存在发送侧堆积迹象"));
|
||||
Assert.That(diagnosis, Does.Contain("存在重传迹象"));
|
||||
Assert.That(diagnosis, Does.Contain("ReconnectPending=1"));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void TransportMetricsReportLocator_ReturnsMostRecentDiagnosisFile()
|
||||
{
|
||||
var reportDirectory = CreateReportDirectory();
|
||||
|
||||
try
|
||||
{
|
||||
Directory.CreateDirectory(reportDirectory);
|
||||
var olderPath = Path.Combine(reportDirectory, "older.diagnosis.txt");
|
||||
var newerPath = Path.Combine(reportDirectory, "newer.diagnosis.txt");
|
||||
File.WriteAllText(olderPath, "older");
|
||||
File.WriteAllText(newerPath, "newer");
|
||||
File.SetLastWriteTimeUtc(olderPath, new DateTime(2026, 3, 27, 0, 0, 0, DateTimeKind.Utc));
|
||||
File.SetLastWriteTimeUtc(newerPath, new DateTime(2026, 3, 27, 0, 0, 5, DateTimeKind.Utc));
|
||||
|
||||
var latestPath = TransportMetricsReportLocator.TryGetLatestDiagnosisPath(reportDirectory);
|
||||
var latestText = TransportMetricsReportLocator.ReadLatestDiagnosisText(reportDirectory);
|
||||
|
||||
Assert.That(latestPath, Is.EqualTo(newerPath));
|
||||
Assert.That(latestText, Is.EqualTo("newer"));
|
||||
}
|
||||
finally
|
||||
{
|
||||
DeleteDirectory(reportDirectory);
|
||||
}
|
||||
}
|
||||
private static async Task<T> WaitFor<T>(Task<T> task, string failureMessage)
|
||||
{
|
||||
var completedTask = await Task.WhenAny(task, Task.Delay(DefaultTimeoutMs));
|
||||
|
||||
@@ -54,6 +54,32 @@ namespace Tests.EditMode.Network
|
||||
Assert.That(envelope.Payload.ToByteArray(), Is.EqualTo(message.ToByteArray()));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void SendMessage_PlayerInput_UsesSyncLanePolicy()
|
||||
{
|
||||
var reliableTransport = new FakeTransport();
|
||||
var syncTransport = new FakeTransport();
|
||||
var manager = new MessageManager(
|
||||
reliableTransport,
|
||||
new MainThreadNetworkDispatcher(),
|
||||
new DefaultMessageDeliveryPolicyResolver(),
|
||||
syncTransport);
|
||||
var message = new PlayerInput
|
||||
{
|
||||
PlayerId = "player-1",
|
||||
Tick = 12
|
||||
};
|
||||
|
||||
manager.SendMessage(message, MessageType.PlayerInput);
|
||||
|
||||
Assert.That(reliableTransport.SendCallCount, Is.EqualTo(0));
|
||||
Assert.That(syncTransport.SendCallCount, Is.EqualTo(1));
|
||||
|
||||
var envelope = Envelope.Parser.ParseFrom(syncTransport.LastSentData);
|
||||
Assert.That(envelope.Type, Is.EqualTo((int)MessageType.PlayerInput));
|
||||
Assert.That(PlayerInput.Parser.ParseFrom(envelope.Payload).Tick, Is.EqualTo(12));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void BroadcastMessage_UsesBroadcastSend()
|
||||
{
|
||||
@@ -158,6 +184,31 @@ namespace Tests.EditMode.Network
|
||||
Assert.That(handledSpeeds, Is.EqualTo(new[] { 1, 2 }));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Receive_StalePlayerState_IsDropped()
|
||||
{
|
||||
var transport = new FakeTransport();
|
||||
var manager = new MessageManager(transport, new MainThreadNetworkDispatcher());
|
||||
var handledTicks = new List<long>();
|
||||
|
||||
manager.RegisterHandler(MessageType.PlayerState, (payload, sender) =>
|
||||
{
|
||||
handledTicks.Add(PlayerState.Parser.ParseFrom(payload).Tick);
|
||||
});
|
||||
|
||||
transport.EmitReceive(
|
||||
BuildEnvelope(MessageType.PlayerState, new PlayerState { PlayerId = "player-1", Tick = 8 }),
|
||||
Sender);
|
||||
manager.DrainPendingMessagesAsync().GetAwaiter().GetResult();
|
||||
|
||||
transport.EmitReceive(
|
||||
BuildEnvelope(MessageType.PlayerState, new PlayerState { PlayerId = "player-1", Tick = 6 }),
|
||||
Sender);
|
||||
manager.DrainPendingMessagesAsync().GetAwaiter().GetResult();
|
||||
|
||||
Assert.That(handledTicks, Is.EqualTo(new long[] { 8 }));
|
||||
}
|
||||
|
||||
private static byte[] BuildEnvelope(MessageType type, IMessage payload)
|
||||
{
|
||||
return new Envelope
|
||||
|
||||
@@ -72,7 +72,7 @@ namespace Tests.EditMode.Network
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void HeartbeatResponse_UpdatesRttAndServerTick_WithoutChangingLoggedInState()
|
||||
public void HeartbeatResponse_UpdatesRttAndClockSync_WithoutChangingLoggedInState()
|
||||
{
|
||||
var clock = new MutableClock(new DateTimeOffset(2026, 3, 27, 0, 0, 0, TimeSpan.Zero));
|
||||
var transport = new FakeTransport();
|
||||
@@ -88,7 +88,30 @@ namespace Tests.EditMode.Network
|
||||
|
||||
Assert.That(runtime.SessionManager.State, Is.EqualTo(ConnectionState.LoggedIn));
|
||||
Assert.That(runtime.SessionManager.LastRoundTripTime, Is.EqualTo(TimeSpan.FromMilliseconds(120)));
|
||||
Assert.That(runtime.SessionManager.LastServerTick, Is.EqualTo(321));
|
||||
Assert.That(runtime.ClockSync.CurrentServerTick, Is.EqualTo(321));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void SharedNetworkRuntime_PublishesLifecycleSnapshotsToMetricsSink()
|
||||
{
|
||||
var clock = new MutableClock(new DateTimeOffset(2026, 3, 27, 0, 0, 0, TimeSpan.Zero));
|
||||
var transport = new FakeTransport();
|
||||
var runtime = new SharedNetworkRuntime(transport, new ImmediateNetworkMessageDispatcher(), utcNowProvider: clock.UtcNow);
|
||||
|
||||
runtime.StartAsync().GetAwaiter().GetResult();
|
||||
runtime.NotifyLoginStarted();
|
||||
runtime.NotifyLoginSucceeded();
|
||||
runtime.NotifyHeartbeatSent();
|
||||
clock.Advance(TimeSpan.FromMilliseconds(80));
|
||||
runtime.NotifyHeartbeatReceived(456);
|
||||
|
||||
Assert.That(transport.ApplicationSnapshots, Is.Not.Empty);
|
||||
var latest = transport.ApplicationSnapshots[^1];
|
||||
Assert.That(latest.Scope, Is.EqualTo("shared-runtime"));
|
||||
Assert.That(latest.ConnectionState, Is.EqualTo(ConnectionState.LoggedIn.ToString()));
|
||||
Assert.That(latest.CanSendHeartbeat, Is.True);
|
||||
Assert.That(latest.LastRoundTripTimeMs, Is.EqualTo(80));
|
||||
Assert.That(latest.CurrentServerTick, Is.EqualTo(456));
|
||||
}
|
||||
|
||||
[Test]
|
||||
@@ -122,7 +145,44 @@ namespace Tests.EditMode.Network
|
||||
Assert.That(host.TryGetSession(peerB, out var sessionB), Is.True);
|
||||
Assert.That(sessionA.SessionManager.State, Is.EqualTo(ConnectionState.ReconnectPending));
|
||||
Assert.That(sessionB.SessionManager.State, Is.EqualTo(ConnectionState.LoggedIn));
|
||||
Assert.That(sessionB.SessionManager.LastServerTick, Is.EqualTo(99));
|
||||
Assert.That(sessionB.ClockSync.CurrentServerTick, Is.EqualTo(99));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void ServerNetworkHost_PublishesLifecycleSnapshotsToMetricsSink()
|
||||
{
|
||||
var clock = new MutableClock(new DateTimeOffset(2026, 3, 27, 0, 0, 0, TimeSpan.Zero));
|
||||
var policy = new SessionReconnectPolicy(
|
||||
heartbeatInterval: TimeSpan.FromSeconds(2),
|
||||
heartbeatTimeout: TimeSpan.FromSeconds(5),
|
||||
reconnectDelay: TimeSpan.FromSeconds(3),
|
||||
autoReconnect: true);
|
||||
var transport = new FakeTransport();
|
||||
var host = new ServerNetworkHost(transport, reconnectPolicy: policy, utcNowProvider: clock.UtcNow);
|
||||
var peerA = new IPEndPoint(IPAddress.Parse("127.0.0.1"), 5001);
|
||||
var peerB = new IPEndPoint(IPAddress.Parse("127.0.0.1"), 5002);
|
||||
|
||||
host.StartAsync().GetAwaiter().GetResult();
|
||||
transport.EmitReceive(CreateEnvelope(MessageType.Heartbeat), peerA);
|
||||
transport.EmitReceive(CreateEnvelope(MessageType.Heartbeat), peerB);
|
||||
host.NotifyLoginStarted(peerA);
|
||||
host.NotifyLoginSucceeded(peerA);
|
||||
host.NotifyLoginStarted(peerB);
|
||||
host.NotifyLoginSucceeded(peerB);
|
||||
|
||||
clock.Advance(TimeSpan.FromSeconds(6));
|
||||
host.NotifyHeartbeatReceived(peerB, 999);
|
||||
host.UpdateLifecycle();
|
||||
|
||||
Assert.That(transport.ApplicationSnapshots.Count, Is.GreaterThanOrEqualTo(2));
|
||||
var peerASnapshot = transport.ApplicationSnapshots.FindLast(snapshot => snapshot.RemoteEndPoint == peerA.ToString());
|
||||
var peerBSnapshot = transport.ApplicationSnapshots.FindLast(snapshot => snapshot.RemoteEndPoint == peerB.ToString());
|
||||
Assert.That(peerASnapshot, Is.Not.Null);
|
||||
Assert.That(peerBSnapshot, Is.Not.Null);
|
||||
Assert.That(peerASnapshot.Scope, Is.EqualTo("server-host"));
|
||||
Assert.That(peerASnapshot.ConnectionState, Is.EqualTo(ConnectionState.ReconnectPending.ToString()));
|
||||
Assert.That(peerBSnapshot.ConnectionState, Is.EqualTo(ConnectionState.LoggedIn.ToString()));
|
||||
Assert.That(peerBSnapshot.CurrentServerTick, Is.EqualTo(999));
|
||||
}
|
||||
|
||||
[Test]
|
||||
@@ -192,8 +252,10 @@ namespace Tests.EditMode.Network
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class FakeTransport : ITransport
|
||||
private sealed class FakeTransport : ITransport, ITransportMetricsSink
|
||||
{
|
||||
public List<TransportApplicationSessionSnapshot> ApplicationSnapshots { get; } = new();
|
||||
|
||||
public event Action<byte[], IPEndPoint> OnReceive;
|
||||
|
||||
public Task StartAsync()
|
||||
@@ -217,6 +279,24 @@ namespace Tests.EditMode.Network
|
||||
{
|
||||
}
|
||||
|
||||
public void RecordApplicationSessionSnapshot(TransportApplicationSessionSnapshot snapshot)
|
||||
{
|
||||
ApplicationSnapshots.Add(new TransportApplicationSessionSnapshot
|
||||
{
|
||||
Scope = snapshot.Scope,
|
||||
RemoteEndPoint = snapshot.RemoteEndPoint,
|
||||
ConnectionState = snapshot.ConnectionState,
|
||||
CanSendHeartbeat = snapshot.CanSendHeartbeat,
|
||||
LastRoundTripTimeMs = snapshot.LastRoundTripTimeMs,
|
||||
LastFailureReason = snapshot.LastFailureReason,
|
||||
LastLivenessUtc = snapshot.LastLivenessUtc,
|
||||
LastHeartbeatSentUtc = snapshot.LastHeartbeatSentUtc,
|
||||
NextReconnectAtUtc = snapshot.NextReconnectAtUtc,
|
||||
CurrentServerTick = snapshot.CurrentServerTick,
|
||||
ObservedAtUtc = snapshot.ObservedAtUtc
|
||||
});
|
||||
}
|
||||
|
||||
public void EmitReceive(byte[] data, IPEndPoint sender)
|
||||
{
|
||||
OnReceive?.Invoke(data, sender);
|
||||
|
||||
@@ -75,6 +75,32 @@ namespace Tests.EditMode.Network
|
||||
Assert.That(transport.LastSendTarget, Is.EqualTo(Sender));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void SharedNetworkRuntime_RoutesSyncMessagesThroughConfiguredSyncLane()
|
||||
{
|
||||
var reliableTransport = new FakeTransport();
|
||||
var syncTransport = new FakeTransport();
|
||||
var runtime = new SharedNetworkRuntime(
|
||||
reliableTransport,
|
||||
new ImmediateNetworkMessageDispatcher(),
|
||||
syncTransport: syncTransport);
|
||||
var message = new PlayerInput
|
||||
{
|
||||
PlayerId = "shared-player",
|
||||
Tick = 33
|
||||
};
|
||||
|
||||
runtime.MessageManager.SendMessage(message, MessageType.PlayerInput);
|
||||
|
||||
Assert.That(reliableTransport.SendCallCount, Is.EqualTo(0));
|
||||
Assert.That(syncTransport.SendCallCount, Is.EqualTo(1));
|
||||
Assert.That(syncTransport.LastSentData, Is.Not.Null);
|
||||
|
||||
var envelope = Envelope.Parser.ParseFrom(syncTransport.LastSentData);
|
||||
Assert.That(envelope.Type, Is.EqualTo((int)MessageType.PlayerInput));
|
||||
Assert.That(PlayerInput.Parser.ParseFrom(envelope.Payload).Tick, Is.EqualTo(33));
|
||||
}
|
||||
|
||||
private static byte[] BuildEnvelope(MessageType type, IMessage payload)
|
||||
{
|
||||
return new Envelope
|
||||
@@ -86,10 +112,14 @@ namespace Tests.EditMode.Network
|
||||
|
||||
private sealed class FakeTransport : ITransport
|
||||
{
|
||||
public byte[] LastSentData { get; private set; }
|
||||
|
||||
public byte[] LastSendToData { get; private set; }
|
||||
|
||||
public IPEndPoint LastSendTarget { get; private set; }
|
||||
|
||||
public int SendCallCount { get; private set; }
|
||||
|
||||
public event Action<byte[], IPEndPoint> OnReceive;
|
||||
|
||||
public Task StartAsync()
|
||||
@@ -103,6 +133,8 @@ namespace Tests.EditMode.Network
|
||||
|
||||
public void Send(byte[] data)
|
||||
{
|
||||
SendCallCount++;
|
||||
LastSentData = Copy(data);
|
||||
}
|
||||
|
||||
public void SendTo(byte[] data, IPEndPoint target)
|
||||
|
||||
@@ -0,0 +1,152 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Net;
|
||||
using Google.Protobuf;
|
||||
using Network.Defines;
|
||||
using Network.NetworkApplication;
|
||||
using Network.NetworkHost;
|
||||
using Network.NetworkTransport;
|
||||
using NUnit.Framework;
|
||||
|
||||
namespace Tests.EditMode.Network
|
||||
{
|
||||
public class SyncStrategyTests
|
||||
{
|
||||
[Test]
|
||||
public void ClientPredictionBuffer_AuthoritativeState_PrunesAcknowledgedInputs()
|
||||
{
|
||||
var buffer = new ClientPredictionBuffer();
|
||||
buffer.Record(new PlayerInput { PlayerId = "player-1", Tick = 10 });
|
||||
buffer.Record(new PlayerInput { PlayerId = "player-1", Tick = 11 });
|
||||
buffer.Record(new PlayerInput { PlayerId = "player-1", Tick = 12 });
|
||||
|
||||
var accepted = buffer.TryApplyAuthoritativeState(
|
||||
new PlayerState { PlayerId = "player-1", Tick = 11 },
|
||||
out var replayInputs);
|
||||
|
||||
Assert.That(accepted, Is.True);
|
||||
Assert.That(buffer.LastAuthoritativeTick, Is.EqualTo(11));
|
||||
Assert.That(replayInputs.Count, Is.EqualTo(1));
|
||||
Assert.That(replayInputs[0].Tick, Is.EqualTo(12));
|
||||
Assert.That(buffer.PendingInputs.Count, Is.EqualTo(1));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void ClientPredictionBuffer_StaleAuthoritativeState_IsIgnored()
|
||||
{
|
||||
var buffer = new ClientPredictionBuffer();
|
||||
buffer.Record(new PlayerInput { PlayerId = "player-1", Tick = 10 });
|
||||
buffer.TryApplyAuthoritativeState(new PlayerState { PlayerId = "player-1", Tick = 10 }, out _);
|
||||
|
||||
var accepted = buffer.TryApplyAuthoritativeState(
|
||||
new PlayerState { PlayerId = "player-1", Tick = 9 },
|
||||
out var replayInputs);
|
||||
|
||||
Assert.That(accepted, Is.False);
|
||||
Assert.That(replayInputs, Is.Empty);
|
||||
Assert.That(buffer.LastAuthoritativeTick, Is.EqualTo(10));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void ClockSyncState_RejectsOlderSamples()
|
||||
{
|
||||
var clockSync = new ClockSyncState();
|
||||
|
||||
var acceptedFirst = clockSync.ObserveSample(42);
|
||||
var acceptedSecond = clockSync.ObserveSample(41);
|
||||
|
||||
Assert.That(acceptedFirst, Is.True);
|
||||
Assert.That(acceptedSecond, Is.False);
|
||||
Assert.That(clockSync.CurrentServerTick, Is.EqualTo(42));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void SharedNetworkRuntime_AuthoritativeStateUpdatesClockWithoutChangingLifecycle()
|
||||
{
|
||||
var transport = new FakeTransport();
|
||||
var runtime = new SharedNetworkRuntime(transport, new ImmediateNetworkMessageDispatcher());
|
||||
|
||||
runtime.StartAsync().GetAwaiter().GetResult();
|
||||
runtime.NotifyLoginStarted();
|
||||
runtime.NotifyLoginSucceeded();
|
||||
runtime.ObserveAuthoritativeState(88);
|
||||
|
||||
Assert.That(runtime.SessionManager.State, Is.EqualTo(ConnectionState.LoggedIn));
|
||||
Assert.That(runtime.ClockSync.CurrentServerTick, Is.EqualTo(88));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void ServerNetworkHost_RejectsStaleInputPerPeerWithoutCrossPeerInterference()
|
||||
{
|
||||
var transport = new FakeTransport();
|
||||
var host = new ServerNetworkHost(transport);
|
||||
var peerA = new IPEndPoint(IPAddress.Parse("127.0.0.1"), 5001);
|
||||
var peerB = new IPEndPoint(IPAddress.Parse("127.0.0.1"), 5002);
|
||||
var handledTicksByPeer = new Dictionary<string, List<long>>();
|
||||
|
||||
host.MessageManager.RegisterHandler(MessageType.PlayerInput, (payload, sender) =>
|
||||
{
|
||||
var key = sender.ToString();
|
||||
if (!handledTicksByPeer.TryGetValue(key, out var ticks))
|
||||
{
|
||||
ticks = new List<long>();
|
||||
handledTicksByPeer.Add(key, ticks);
|
||||
}
|
||||
|
||||
ticks.Add(PlayerInput.Parser.ParseFrom(payload).Tick);
|
||||
});
|
||||
|
||||
transport.EmitReceive(
|
||||
BuildEnvelope(MessageType.PlayerInput, new PlayerInput { PlayerId = "player-a", Tick = 5 }),
|
||||
peerA);
|
||||
transport.EmitReceive(
|
||||
BuildEnvelope(MessageType.PlayerInput, new PlayerInput { PlayerId = "player-a", Tick = 4 }),
|
||||
peerA);
|
||||
transport.EmitReceive(
|
||||
BuildEnvelope(MessageType.PlayerInput, new PlayerInput { PlayerId = "player-b", Tick = 4 }),
|
||||
peerB);
|
||||
|
||||
Assert.That(handledTicksByPeer[peerA.ToString()], Is.EqualTo(new long[] { 5 }));
|
||||
Assert.That(handledTicksByPeer[peerB.ToString()], Is.EqualTo(new long[] { 4 }));
|
||||
}
|
||||
|
||||
private static byte[] BuildEnvelope(MessageType type, IMessage payload)
|
||||
{
|
||||
return new Envelope
|
||||
{
|
||||
Type = (int)type,
|
||||
Payload = payload.ToByteString()
|
||||
}.ToByteArray();
|
||||
}
|
||||
|
||||
private sealed class FakeTransport : ITransport
|
||||
{
|
||||
public event System.Action<byte[], IPEndPoint> OnReceive;
|
||||
|
||||
public System.Threading.Tasks.Task StartAsync()
|
||||
{
|
||||
return System.Threading.Tasks.Task.CompletedTask;
|
||||
}
|
||||
|
||||
public void Stop()
|
||||
{
|
||||
}
|
||||
|
||||
public void Send(byte[] data)
|
||||
{
|
||||
}
|
||||
|
||||
public void SendTo(byte[] data, IPEndPoint target)
|
||||
{
|
||||
}
|
||||
|
||||
public void SendToAll(byte[] data)
|
||||
{
|
||||
}
|
||||
|
||||
public void EmitReceive(byte[] data, IPEndPoint sender)
|
||||
{
|
||||
OnReceive?.Invoke(data, sender);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 32abf5cf5893ca9479bc617ccfedfab0
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
Reference in New Issue
Block a user