Network 层的迁移与强化已完成,TODO.md 与 MobaSyncMVP.md 给出后续方向
This commit is contained in:
parent
e361510100
commit
156d72bf4a
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
@ -7,7 +7,8 @@
|
|||
- 阶段 3 已完成:遗留的 `ReliableUdpTransport` 兼容入口已经移除,项目中不再保留第二个“可靠 UDP”实现名义。
|
||||
- 阶段 4 已完成:网络线程与 Unity 主线程之间已经建立显式分发边界,传输回调不再直接执行业务 handler。
|
||||
- 阶段 5 已完成:共享会话生命周期、心跳职责边界、超时/重连状态已经从业务消息处理里分层出来,并扩展到服务端多会话管理。
|
||||
- 阶段 6 及以后尚未开始:QoS/同步优化、监控指标仍然是后续工作。
|
||||
- 阶段 6 已完成:高频同步已经拆分为显式 delivery policy / sync lane / latest-wins sequencing / 独立 clock sync 状态,相关编辑器测试已经覆盖路由、过期包丢弃、预测缓冲修剪与多会话隔离。
|
||||
- 阶段 7 已开始但未完成:传输 metrics 已补充可读摘要、摘要文本报告和调试开关,但会话级指标、聚合视图和更完整的故障诊断仍需继续完善。
|
||||
|
||||
## 当前真实现状
|
||||
|
||||
|
|
@ -26,13 +27,20 @@
|
|||
- `MessageManager` 负责:
|
||||
- 解析 `Envelope`
|
||||
- 根据 `MessageType` 查找 handler
|
||||
- 通过 `IMessageDeliveryPolicyResolver` 将控制面消息路由到可靠有序 lane,并将 `PlayerInput` / `PlayerState` 路由到高频同步 lane
|
||||
- 对 `PlayerInput` / `PlayerState` 执行基于 tick 的 latest-wins 过滤,丢弃过期同步包
|
||||
- 通过宿主注入的 dispatcher 执行消息分发,而不是在收包线程直接执行 handler
|
||||
- `SessionManager` 负责:
|
||||
- 维护 `Disconnected` / `TransportConnected` / `LoginPending` / `LoggedIn` / `LoginFailed` / `TimedOut` / `ReconnectPending` / `Reconnecting` 状态
|
||||
- 管理心跳发送窗口、心跳超时和重连调度
|
||||
- 记录 RTT、最近 liveness 时间和最近服务器时钟样本
|
||||
- 记录 RTT 与最近 liveness 时间,但不再拥有服务器时钟样本
|
||||
- `ClockSyncState` 负责:
|
||||
- 独立记录最近接受的服务器 tick 样本
|
||||
- 接收心跳响应与权威 `PlayerState` 的时钟样本
|
||||
- 以独立同步策略状态的形式供客户端预测/纠正与服务端多会话观察读取
|
||||
- `MultiSessionManager` 负责:
|
||||
- 按远端 `IPEndPoint` 维护多份 `SessionManager`
|
||||
- 按远端维护独立 `ClockSyncState`
|
||||
- 让服务端按每个远端独立观察登录、超时、断线、重连状态
|
||||
- 提供按远端查询、枚举、移除会话的共享入口
|
||||
- `MainThreadNetworkDispatcher` 负责:
|
||||
|
|
@ -49,8 +57,8 @@
|
|||
- `PlayerInput` 上行
|
||||
- `PlayerState` 下行
|
||||
- 本地预测 / 服务器校正
|
||||
- 当前尚未完成的关键架构问题:
|
||||
- 高频同步消息仍未做 QoS 拆分
|
||||
- 当前已完成但仍需后续迭代优化的部分:
|
||||
- 高频同步消息已经具备 policy/lane 拆分,但第一版 sync lane 仍通过共享抽象接入,后续仍可替换为更激进的底层实现
|
||||
- 网络观测指标还不完整
|
||||
|
||||
## 已完成阶段回顾
|
||||
|
|
@ -103,24 +111,60 @@
|
|||
|
||||
### 阶段 6:同步策略优化
|
||||
|
||||
1. 重新评估 `PlayerInput` 是否必须严格可靠。
|
||||
2. 重新评估 `PlayerState` 是否应使用可靠有序流。
|
||||
3. 调整客户端预测、回滚、纠正策略。
|
||||
4. 把对时逻辑从 `SessionManager` 的心跳窗口里进一步拆分成独立同步策略(如有必要)。
|
||||
已完成结果:
|
||||
|
||||
交付标准:
|
||||
1. 已新增共享 `DeliveryPolicy`、`IMessageDeliveryPolicyResolver`、`TransportMessageLane`,让宿主显式组合可靠控制面与高频同步 lane。
|
||||
2. `PlayerInput` / `PlayerState` 当前默认走 `HighFrequencySync` policy,登录、登出、心跳等控制流继续走可靠 KCP。
|
||||
3. 已新增 `SyncSequenceTracker`,对 `PlayerInput` / `PlayerState` 按 tick 执行 latest-wins 过滤,丢弃过期同步包。
|
||||
4. 已新增 `ClockSyncState`,把服务器 tick 样本所有权从 `SessionManager` 挪出,并让心跳响应与权威状态都能更新该状态。
|
||||
5. 客户端当前通过 `ClientPredictionBuffer` 在收到权威状态后修剪已确认输入,并只重放更新的待确认输入。
|
||||
6. 编辑器测试当前已覆盖:
|
||||
- delivery policy 路由
|
||||
- stale packet rejection
|
||||
- clock sync forwarding
|
||||
- prediction buffer pruning
|
||||
- server multi-session sync isolation
|
||||
|
||||
- 高频同步场景下不会因为旧包阻塞导致位置明显滞后。
|
||||
交付结论:
|
||||
|
||||
- 高频同步场景下已经不再依赖可靠有序交付来保证 `PlayerInput` / `PlayerState` 的处理顺序。
|
||||
- 心跳生命周期与时钟同步当前已经分离,各自的职责边界可以独立演进。
|
||||
|
||||
### 阶段 7:监控与调试工具补齐
|
||||
|
||||
1. 打印会话状态。
|
||||
2. 输出 RTT、发送队列、丢包、重传等指标。
|
||||
3. 提供调试开关,避免正式环境日志过多。
|
||||
当前已完成:
|
||||
|
||||
1. `DefaultTransportMetricsModule` 除 JSON 外,额外输出更易读的 `.summary.txt` 摘要文件。
|
||||
2. metrics JSON 现在包含 `ReadableSummary` 字段,直接给出 headline、session、traffic、error 和 top peer 提示。
|
||||
3. 已新增 metrics 调试选项,可独立控制:
|
||||
- 是否写 JSON 报告
|
||||
- 是否写文本摘要
|
||||
- 是否输出控制台摘要
|
||||
4. 控制台摘要当前会打印更易读的会话、流量、错误和热点 peer 信息,而不是只保留一行紧凑指标。
|
||||
5. KCP metrics 当前已经额外输出会话级诊断字段,包括:
|
||||
- `rx_srtt` / `rx_rto` 派生的 RTT 与重传超时
|
||||
- `ikcp_waitsnd`、发送/接收队列与缓冲深度
|
||||
- 基于 KCP `snd_buf` 的在途重传段统计与累计重传观察值
|
||||
- 每个 peer 的会话生命周期状态聚合
|
||||
6. 共享会话层当前也会把单会话 / 多会话状态快照写入 metrics,包括:
|
||||
- `SharedNetworkRuntime` 的登录、心跳、失败、重连调度状态
|
||||
- `ServerNetworkHost` 按远端 peer 聚合的 `ConnectionState`
|
||||
- 可发送心跳、最近 RTT、下一次重连时间、最近服务器 tick
|
||||
7. metrics 当前会额外落一份 `.diagnosis.txt` 中文诊断文件,直接输出:
|
||||
- 整体稳定性判断
|
||||
- 高延迟 / 发送堆积 / 重传 / 重连风险提示
|
||||
- 共享会话状态总览
|
||||
- 热点 peer 的排障线索
|
||||
|
||||
仍待完成:
|
||||
|
||||
1. 提供更适合直接排障的聚合视图或查看工具,而不是只落文件。
|
||||
2. 继续补更多跨运行阶段的历史视图,例如连续超时、重连抖动、会话恢复成功率。
|
||||
3. 如果后续要做运维化使用,再补按时间窗口聚合的趋势指标,而不只看单次运行快照。
|
||||
|
||||
交付标准:
|
||||
|
||||
- 网络问题可以通过日志和指标定位。
|
||||
- 网络问题可以通过日志和指标快速定位,且常见问题不需要先手动阅读原始 JSON 结构。
|
||||
|
||||
## 推荐新增的结构
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,267 @@
|
|||
# MOBA Hybrid Sync MVP
|
||||
|
||||
## Goal
|
||||
|
||||
Build a minimal hybrid sync model for a MOBA-like game:
|
||||
|
||||
- Client sends only player inputs
|
||||
- Server runs authoritative gameplay logic
|
||||
- Server sends authoritative state and combat results back to clients
|
||||
- Client uses prediction for local control and interpolation/reconciliation for presentation
|
||||
|
||||
This MVP supports only two player inputs:
|
||||
|
||||
- Move
|
||||
- Shoot
|
||||
|
||||
Other core gameplay data such as position and HP remain server-authoritative.
|
||||
|
||||
## Core Model
|
||||
|
||||
### Client Responsibilities
|
||||
|
||||
- Capture local input
|
||||
- Send input messages to server
|
||||
- Predict local movement immediately
|
||||
- Play local shooting presentation immediately if desired
|
||||
- Reconcile local player state when authoritative state arrives
|
||||
- Interpolate remote player movement for smooth display
|
||||
|
||||
### Server Responsibilities
|
||||
|
||||
- Receive all player inputs
|
||||
- Validate and apply movement
|
||||
- Validate shooting requests
|
||||
- Resolve hit, damage, death, and other combat results
|
||||
- Maintain authoritative position, HP, and combat state
|
||||
- Broadcast authoritative state snapshots
|
||||
- Broadcast authoritative combat events
|
||||
|
||||
## Message Plan
|
||||
|
||||
| Message | Direction | Reliability | Frequency | Purpose |
|
||||
|---|---|---|---|---|
|
||||
| `MoveInput` | Client -> Server | Low reliability / latest wins | 10-20 Hz | Report movement input |
|
||||
| `ShootInput` | Client -> Server | Reliable ordered | On demand | Report shoot request |
|
||||
| `PlayerState` | Server -> Client | Low reliability / latest wins | 10-20 Hz | Sync position, rotation, HP |
|
||||
| `CombatEvent` | Server -> Client | Reliable ordered | On demand | Sync hit, damage, death |
|
||||
| `Heartbeat` / `ClockSync` | Both ways | Reliable ordered | 1-2 Hz | Keepalive and server tick sync |
|
||||
|
||||
## Message Definitions
|
||||
|
||||
### MoveInput
|
||||
|
||||
Sent frequently. Old packets can be dropped.
|
||||
|
||||
Suggested fields:
|
||||
|
||||
- `playerId`
|
||||
- `tick`
|
||||
- `moveX`
|
||||
- `moveY`
|
||||
|
||||
Notes:
|
||||
|
||||
- Represents current movement intent
|
||||
- Should be treated as a high-frequency sync message
|
||||
- Latest input matters more than full delivery history
|
||||
|
||||
### ShootInput
|
||||
|
||||
Sent only when player fires.
|
||||
|
||||
Suggested fields:
|
||||
|
||||
- `playerId`
|
||||
- `tick`
|
||||
- `dirX`
|
||||
- `dirY`
|
||||
- optional `targetId`
|
||||
|
||||
Notes:
|
||||
|
||||
- Must be delivered reliably
|
||||
- Server decides whether shooting is valid
|
||||
- Client may play local muzzle flash or firing animation immediately, but not authoritative hit resolution
|
||||
|
||||
### PlayerState
|
||||
|
||||
Sent from server as authoritative snapshot.
|
||||
|
||||
Suggested fields:
|
||||
|
||||
- `playerId`
|
||||
- `tick`
|
||||
- `position`
|
||||
- `rotation`
|
||||
- `hp`
|
||||
- optional `velocity`
|
||||
|
||||
Notes:
|
||||
|
||||
- Used for local reconciliation and remote interpolation
|
||||
- Position and HP are server-authoritative
|
||||
- Older states should be discarded if a newer state already exists
|
||||
|
||||
### CombatEvent
|
||||
|
||||
Sent when authoritative combat logic produces a result.
|
||||
|
||||
Suggested fields:
|
||||
|
||||
- `tick`
|
||||
- `eventType`
|
||||
- `attackerId`
|
||||
- `targetId`
|
||||
- `damage`
|
||||
- optional `hitPosition`
|
||||
|
||||
Typical event types:
|
||||
|
||||
- `Hit`
|
||||
- `DamageApplied`
|
||||
- `Death`
|
||||
- `ShootRejected`
|
||||
|
||||
Notes:
|
||||
|
||||
- Reliable ordered delivery is required
|
||||
- Clients update HP, death state, hit reactions, and combat UI from this message class
|
||||
|
||||
## Authority Rules
|
||||
|
||||
### Client Authoritative
|
||||
|
||||
Only for temporary presentation:
|
||||
|
||||
- Local movement prediction
|
||||
- Local fire animation / local FX preplay
|
||||
|
||||
These are visual conveniences only and must be correctable.
|
||||
|
||||
### Server Authoritative
|
||||
|
||||
Always authoritative for:
|
||||
|
||||
- Final position
|
||||
- HP
|
||||
- Combat resolution
|
||||
- Shoot validation
|
||||
- Hit validation
|
||||
- Death state
|
||||
|
||||
Clients must never be allowed to finalize these outcomes.
|
||||
|
||||
## Client Handling Rules
|
||||
|
||||
### Local Player
|
||||
|
||||
- Send `MoveInput` continuously while movement changes
|
||||
- Apply local predicted movement immediately
|
||||
- Send `ShootInput` when firing
|
||||
- Optionally play local fire effect immediately
|
||||
- When `PlayerState` arrives:
|
||||
- compare authoritative position against predicted position
|
||||
- if error is small, smooth correct
|
||||
- if error is large, snap or fast-correct
|
||||
- When `CombatEvent` arrives:
|
||||
- update HP and combat result using server truth
|
||||
|
||||
### Remote Players
|
||||
|
||||
- Do not predict their gameplay logic
|
||||
- Buffer recent `PlayerState` snapshots
|
||||
- Interpolate between snapshots for smooth rendering
|
||||
- Apply `CombatEvent` immediately
|
||||
|
||||
## Transport Mapping
|
||||
|
||||
This repository already has the right high-level direction:
|
||||
|
||||
- High-frequency sync lane for movement/state-like messages
|
||||
- Reliable lane for guaranteed gameplay events
|
||||
|
||||
Recommended mapping:
|
||||
|
||||
- `MoveInput` -> sync lane
|
||||
- `ShootInput` -> reliable lane
|
||||
- `PlayerState` -> sync lane
|
||||
- `CombatEvent` -> reliable lane
|
||||
|
||||
Important:
|
||||
|
||||
- Do not keep both movement and shooting inside the same `PlayerInput` message if they need different delivery policies
|
||||
- Split them into distinct message types so delivery policy stays explicit
|
||||
|
||||
## Tick and Ordering
|
||||
|
||||
Use `tick` on all gameplay-relevant messages.
|
||||
|
||||
Purposes:
|
||||
|
||||
- detect stale sync messages
|
||||
- support reconciliation
|
||||
- align state snapshots to server simulation
|
||||
- simplify debugging
|
||||
|
||||
Recommended rules:
|
||||
|
||||
- `MoveInput` and `PlayerState` may drop stale packets
|
||||
- `ShootInput` and `CombatEvent` should remain ordered and reliable
|
||||
|
||||
## Recommended MVP Scope
|
||||
|
||||
### Phase 1
|
||||
|
||||
- Implement `MoveInput`
|
||||
- Implement authoritative `PlayerState`
|
||||
- Run local prediction for self
|
||||
- Run interpolation for remote players
|
||||
|
||||
### Phase 2
|
||||
|
||||
- Implement `ShootInput`
|
||||
- Implement authoritative `CombatEvent`
|
||||
- Server resolves hit and damage
|
||||
- Clients update HP and hit feedback from server results
|
||||
|
||||
### Phase 3
|
||||
|
||||
- Add optional projectile authority model if needed
|
||||
- Add more combat event types
|
||||
- Add anti-cheat diagnostics or state hash logging only as auxiliary tooling
|
||||
|
||||
## Non-Goals For MVP
|
||||
|
||||
Do not include these in the first version:
|
||||
|
||||
- Client-side authoritative combat
|
||||
- Client majority voting
|
||||
- Client hash majority recovery
|
||||
- Full deterministic lockstep
|
||||
- Complex rollback netcode
|
||||
- Advanced anti-cheat enforcement
|
||||
|
||||
## Implementation Notes For This Repository
|
||||
|
||||
Based on the current network architecture:
|
||||
|
||||
- Current `PlayerInput` is too broad for this MVP if movement and shooting need different reliability
|
||||
- Prefer adding separate message types for `MoveInput` and `ShootInput`
|
||||
- Keep `PlayerState` as a high-frequency authoritative snapshot
|
||||
- Add a new reliable message type for `CombatEvent`
|
||||
|
||||
If the current transport setup uses only one underlying transport instance, the application layer can still distinguish message policy logically, but true sync/reliable isolation is better when backed by distinct lanes or transport behavior.
|
||||
|
||||
## Summary
|
||||
|
||||
For the MVP:
|
||||
|
||||
- Client sends only `MoveInput` and `ShootInput`
|
||||
- Server owns all gameplay truth
|
||||
- Server sends `PlayerState` and `CombatEvent`
|
||||
- Client predicts local movement
|
||||
- Client interpolates remote movement
|
||||
- Client corrects to server state when divergence appears
|
||||
|
||||
This gives a practical, controllable, and extensible baseline for a small MOBA-style networking model.
|
||||
|
|
@ -0,0 +1,149 @@
|
|||
# Network MVP TODO
|
||||
|
||||
## Goal
|
||||
|
||||
Implement the networking MVP described in [MobaSyncMVP.md](D:/Learn/GameLearn/UnityProjects/NetworkFW/MobaSyncMVP.md):
|
||||
|
||||
- Client sends only movement and shooting inputs
|
||||
- Server is authoritative for gameplay state
|
||||
- Server sends authoritative state and combat events
|
||||
- Client performs local prediction for movement and interpolation/reconciliation for presentation
|
||||
|
||||
## Checklist
|
||||
|
||||
### 1. Split Network Message Types
|
||||
|
||||
- [ ] Add `MoveInput`, `ShootInput`, and `CombatEvent` to [`Assets/Scripts/Network/Defines/MessageType.cs`](D:/Learn/GameLearn/UnityProjects/NetworkFW/Assets/Scripts/Network/Defines/MessageType.cs)
|
||||
- [ ] Add matching protobuf definitions in the source `.proto` file
|
||||
- [ ] Regenerate [`Assets/Scripts/Network/Defines/Message.cs`](D:/Learn/GameLearn/UnityProjects/NetworkFW/Assets/Scripts/Network/Defines/Message.cs)
|
||||
- [ ] Stop using one broad `PlayerInput` message to carry both movement and shooting
|
||||
|
||||
Acceptance:
|
||||
|
||||
- [ ] `MoveInput`, `ShootInput`, and `CombatEvent` can be referenced independently in code
|
||||
- [ ] The project builds successfully after regeneration
|
||||
|
||||
### 2. Update Delivery Policy Mapping
|
||||
|
||||
- [ ] Update [`Assets/Scripts/Network/NetworkApplication/DefaultMessageDeliveryPolicyResolver.cs`](D:/Learn/GameLearn/UnityProjects/NetworkFW/Assets/Scripts/Network/NetworkApplication/DefaultMessageDeliveryPolicyResolver.cs)
|
||||
- [ ] Map `MoveInput` to `HighFrequencySync`
|
||||
- [ ] Map `PlayerState` to `HighFrequencySync`
|
||||
- [ ] Map `ShootInput` to `ReliableOrdered`
|
||||
- [ ] Map `CombatEvent` to `ReliableOrdered`
|
||||
|
||||
Acceptance:
|
||||
|
||||
- [ ] `MessageManager` routes movement/state messages to the sync lane
|
||||
- [ ] `MessageManager` routes shooting/combat-result messages to the reliable lane
|
||||
|
||||
### 3. Update Sequence Filtering For High-Frequency Messages
|
||||
|
||||
- [ ] Modify [`Assets/Scripts/Network/NetworkApplication/SyncSequenceTracker.cs`](D:/Learn/GameLearn/UnityProjects/NetworkFW/Assets/Scripts/Network/NetworkApplication/SyncSequenceTracker.cs)
|
||||
- [ ] Replace `PlayerInput`-based stale filtering with `MoveInput`
|
||||
- [ ] Keep stale filtering for `PlayerState`
|
||||
- [ ] Do not apply stale-drop logic to `ShootInput`
|
||||
- [ ] Do not apply stale-drop logic to `CombatEvent`
|
||||
|
||||
Acceptance:
|
||||
|
||||
- [ ] Older `MoveInput` packets are dropped
|
||||
- [ ] Older `PlayerState` packets are dropped
|
||||
- [ ] `ShootInput` is not silently discarded by sequence filtering
|
||||
|
||||
### 4. Narrow Prediction Buffer To Movement
|
||||
|
||||
- [ ] Modify [`Assets/Scripts/Network/NetworkApplication/ClientPredictionBuffer.cs`](D:/Learn/GameLearn/UnityProjects/NetworkFW/Assets/Scripts/Network/NetworkApplication/ClientPredictionBuffer.cs)
|
||||
- [ ] Store `MoveInput` instead of broad `PlayerInput`
|
||||
- [ ] Continue pruning buffered inputs using authoritative `PlayerState.Tick`
|
||||
- [ ] Keep shooting outside the prediction replay path
|
||||
|
||||
Acceptance:
|
||||
|
||||
- [ ] Local movement prediction still works
|
||||
- [ ] Authoritative `PlayerState` still prunes acknowledged movement inputs
|
||||
- [ ] Shooting does not depend on prediction buffer replay
|
||||
|
||||
### 5. Preserve And Use Dual-Transport Runtime Wiring
|
||||
|
||||
- [ ] Verify [`Assets/Scripts/Network/NetworkApplication/SharedNetworkRuntime.cs`](D:/Learn/GameLearn/UnityProjects/NetworkFW/Assets/Scripts/Network/NetworkApplication/SharedNetworkRuntime.cs) is used with both reliable and sync transports
|
||||
- [ ] Verify [`Assets/Scripts/Network/NetworkHost/ServerNetworkHost.cs`](D:/Learn/GameLearn/UnityProjects/NetworkFW/Assets/Scripts/Network/NetworkHost/ServerNetworkHost.cs) is used with both reliable and sync transports
|
||||
- [ ] Keep the current dual-transport constructor shape for MVP
|
||||
- [ ] Do not expand `ITransport` yet unless MVP proves it is necessary
|
||||
|
||||
Acceptance:
|
||||
|
||||
- [ ] Client runtime can start with two distinct transport instances
|
||||
- [ ] Server host can start with two distinct transport instances
|
||||
- [ ] `MoveInput` / `PlayerState` can flow through the sync transport
|
||||
- [ ] `ShootInput` / `CombatEvent` can flow through the reliable transport
|
||||
|
||||
### 6. Finalize MVP Message Fields
|
||||
|
||||
- [ ] Define `MoveInput` fields: `playerId`, `tick`, `moveX`, `moveY`
|
||||
- [ ] Define `ShootInput` fields: `playerId`, `tick`, `dirX`, `dirY`, optional `targetId`
|
||||
- [ ] Define `PlayerState` fields: `playerId`, `tick`, `position`, `rotation`, `hp`, optional `velocity`
|
||||
- [ ] Define `CombatEvent` fields: `tick`, `eventType`, `attackerId`, `targetId`, `damage`, optional `hitPosition`
|
||||
- [ ] Add `CombatEventType` if needed
|
||||
|
||||
Acceptance:
|
||||
|
||||
- [ ] MVP gameplay data can be expressed without ad hoc payload extensions
|
||||
- [ ] Position, HP, and combat results all have explicit authoritative messages
|
||||
|
||||
### 7. Add Message Routing Tests
|
||||
|
||||
- [ ] Extend [`Assets/Tests/EditMode/Network/MessageManagerTests.cs`](D:/Learn/GameLearn/UnityProjects/NetworkFW/Assets/Tests/EditMode/Network/MessageManagerTests.cs)
|
||||
- [ ] Add `SendMessage_MoveInput_UsesSyncLanePolicy`
|
||||
- [ ] Add `SendMessage_ShootInput_UsesReliableLanePolicy`
|
||||
- [ ] Add `SendMessage_CombatEvent_UsesReliableLanePolicy`
|
||||
- [ ] Add `Receive_StaleMoveInput_IsDropped`
|
||||
- [ ] Add `Receive_ShootInput_IsNotDroppedBySequenceTracker`
|
||||
|
||||
Acceptance:
|
||||
|
||||
- [ ] Lane selection is covered by tests for all new MVP messages
|
||||
- [ ] High-frequency stale-drop behavior is covered by tests
|
||||
|
||||
### 8. Add Sync Strategy Tests
|
||||
|
||||
- [ ] Extend [`Assets/Tests/EditMode/Network/SyncStrategyTests.cs`](D:/Learn/GameLearn/UnityProjects/NetworkFW/Assets/Tests/EditMode/Network/SyncStrategyTests.cs)
|
||||
- [ ] Add `ClientPredictionBuffer_AuthoritativeState_PrunesAcknowledgedMoveInputs`
|
||||
- [ ] Add `ServerNetworkHost_RejectsStaleMoveInputPerPeerWithoutCrossPeerInterference`
|
||||
|
||||
Acceptance:
|
||||
|
||||
- [ ] Prediction buffer still behaves correctly after switching to `MoveInput`
|
||||
- [ ] Multi-session stale filtering remains isolated per peer
|
||||
|
||||
### 9. Wire Dual Transports In The Integration Layer
|
||||
|
||||
- [ ] Update the client integration entry point, likely [`Assets/Scripts/NetworkManager.cs`](D:/Learn/GameLearn/UnityProjects/NetworkFW/Assets/Scripts/NetworkManager.cs)
|
||||
- [ ] Update the server startup integration point
|
||||
- [ ] Instantiate one reliable transport and one sync transport
|
||||
- [ ] Ensure runtime construction uses both transports instead of a single shared instance
|
||||
|
||||
Acceptance:
|
||||
|
||||
- [ ] Runtime uses logical dual-lane routing backed by two transport instances
|
||||
- [ ] Logging or tests confirm movement/state traffic and reliable event traffic are separated
|
||||
|
||||
### 10. Build And Test
|
||||
|
||||
- [ ] Run `dotnet build Network.EditMode.Tests.csproj -v minimal`
|
||||
- [ ] Run `dotnet test Network.EditMode.Tests.csproj --no-build -v minimal`
|
||||
|
||||
Acceptance:
|
||||
|
||||
- [ ] Build succeeds
|
||||
- [ ] Edit-mode network tests succeed
|
||||
- [ ] New MVP regression tests succeed
|
||||
|
||||
## Recommended Order
|
||||
|
||||
1. Split protocol and message types
|
||||
2. Update delivery policy mapping
|
||||
3. Update sequence filtering
|
||||
4. Narrow prediction buffer
|
||||
5. Add and update tests
|
||||
6. Wire dual transports in integration
|
||||
7. Build and run tests
|
||||
|
|
@ -1,23 +1,23 @@
|
|||
## 1. Delivery Policy Infrastructure
|
||||
|
||||
- [ ] 1.1 Introduce shared delivery-policy abstractions and a default message-type map for reliable control traffic versus high-frequency sync traffic.
|
||||
- [ ] 1.2 Extend `SharedNetworkRuntime`, `MessageManager`, and host composition points to route messages through the resolved policy without breaking the shared envelope contract.
|
||||
- [ ] 1.3 Add the first sync-lane backend and any supporting transport adapter types needed to keep client single-session and server multi-session composition explicit.
|
||||
- [x] 1.1 Introduce shared delivery-policy abstractions and a default message-type map for reliable control traffic versus high-frequency sync traffic.
|
||||
- [x] 1.2 Extend `SharedNetworkRuntime`, `MessageManager`, and host composition points to route messages through the resolved policy without breaking the shared envelope contract.
|
||||
- [x] 1.3 Add the first sync-lane backend and any supporting transport adapter types needed to keep client single-session and server multi-session composition explicit.
|
||||
|
||||
## 2. High-Frequency Sync Routing
|
||||
|
||||
- [ ] 2.1 Route `PlayerInput` and `PlayerState` through the high-frequency sync policy while keeping login, logout, heartbeat, and other control messages on reliable KCP.
|
||||
- [ ] 2.2 Implement monotonic ordering tracking for sync streams and reject stale `PlayerInput` / `PlayerState` updates on the receiving side.
|
||||
- [ ] 2.3 Update server-side sync handling so each remote peer maintains independent latest-wins state instead of relying on reliable ordered delivery.
|
||||
- [x] 2.1 Route `PlayerInput` and `PlayerState` through the high-frequency sync policy while keeping login, logout, heartbeat, and other control messages on reliable KCP.
|
||||
- [x] 2.2 Implement monotonic ordering tracking for sync streams and reject stale `PlayerInput` / `PlayerState` updates on the receiving side.
|
||||
- [x] 2.3 Update server-side sync handling so each remote peer maintains independent latest-wins state instead of relying on reliable ordered delivery.
|
||||
|
||||
## 3. Clock Sync And Reconciliation
|
||||
|
||||
- [ ] 3.1 Introduce a dedicated clock-sync strategy/state object and move authoritative server-tick ownership out of `SessionManager`.
|
||||
- [ ] 3.2 Refactor heartbeat and authoritative-state handlers so liveness/RTT updates stay in session lifecycle while clock samples flow through the sync strategy.
|
||||
- [ ] 3.3 Update client prediction and reconciliation code to prune acknowledged inputs, ignore stale authoritative state, and replay only newer pending inputs.
|
||||
- [x] 3.1 Introduce a dedicated clock-sync strategy/state object and move authoritative server-tick ownership out of `SessionManager`.
|
||||
- [x] 3.2 Refactor heartbeat and authoritative-state handlers so liveness/RTT updates stay in session lifecycle while clock samples flow through the sync strategy.
|
||||
- [x] 3.3 Update client prediction and reconciliation code to prune acknowledged inputs, ignore stale authoritative state, and replay only newer pending inputs.
|
||||
|
||||
## 4. Verification And Documentation
|
||||
|
||||
- [ ] 4.1 Add edit mode tests for delivery-policy routing, stale packet rejection, and clock-sync forwarding behavior.
|
||||
- [ ] 4.2 Add regression tests covering client prediction buffer pruning and server multi-session sync isolation under delayed or out-of-order updates.
|
||||
- [ ] 4.3 Update `CodeX-TODO.md` and related networking docs to reflect the phase 6 architecture and completion criteria.
|
||||
- [x] 4.1 Add edit mode tests for delivery-policy routing, stale packet rejection, and clock-sync forwarding behavior.
|
||||
- [x] 4.2 Add regression tests covering client prediction buffer pruning and server multi-session sync isolation under delayed or out-of-order updates.
|
||||
- [x] 4.3 Update `CodeX-TODO.md` and related networking docs to reflect the phase 6 architecture and completion criteria.
|
||||
|
|
@ -47,12 +47,17 @@ The transport SHALL continue driving KCP timers for every active session while i
|
|||
- **THEN** the transport stops receiving new UDP datagrams
|
||||
- **THEN** the transport clears its active KCP session state before shutdown completes
|
||||
### Requirement: KCP is the sole reliable transport implementation
|
||||
The project SHALL expose `KcpTransport` as the only reliable `ITransport` implementation used by runtime networking paths. Reliable business messages, including login, heartbeat, player input, and player state synchronization, MUST continue to flow through KCP-backed sessions rather than any legacy reliable UDP compatibility class.
|
||||
The project SHALL expose `KcpTransport` as the only reliable `ITransport` implementation used by runtime networking paths. Reliable control-plane business messages, including login, logout, heartbeat, and other ordered session-management traffic, MUST continue to flow through KCP-backed sessions, while high-frequency `PlayerInput` and `PlayerState` synchronization MAY use a separate sync lane defined by the sync-strategy capability.
|
||||
|
||||
#### Scenario: Runtime networking uses KCP for reliable delivery
|
||||
- **WHEN** the application constructs the transport used by `MessageManager` for its normal runtime networking path
|
||||
#### Scenario: Runtime networking uses KCP for reliable control delivery
|
||||
- **WHEN** the application constructs the reliable transport used for login and session control traffic
|
||||
- **THEN** that transport instance is `KcpTransport`
|
||||
- **THEN** reliable business payloads are sent and received through KCP session state
|
||||
- **THEN** reliable control payloads are sent and received through KCP session state
|
||||
|
||||
#### Scenario: High-frequency sync is allowed to bypass reliable ordered delivery
|
||||
- **WHEN** the runtime routes `PlayerInput` or `PlayerState` according to the high-frequency sync strategy
|
||||
- **THEN** those messages are not forced to use the reliable ordered KCP lane
|
||||
- **THEN** reliable KCP delivery remains available for control-plane traffic
|
||||
|
||||
### Requirement: Legacy reliable UDP entry points are retired
|
||||
The codebase SHALL NOT keep a directly instantiable `ReliableUdpTransport` entry point that implies a second reliable delivery mechanism. If a non-reliable UDP transport is needed in the future, it MUST use a distinct name and MUST NOT claim reliable semantics.
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
# network-session-lifecycle Specification
|
||||
# network-session-lifecycle Specification
|
||||
|
||||
## Purpose
|
||||
Define the shared session lifecycle model that separates transport connectivity, login state, heartbeat liveness, timeout detection, and reconnect scheduling for client and server hosts.
|
||||
|
|
@ -18,12 +18,12 @@ The shared networking core SHALL expose an explicit session lifecycle model that
|
|||
- **THEN** hosts can react to that state change without conflating it with transport establishment
|
||||
|
||||
### Requirement: Heartbeat is limited to liveness, RTT, and time sync
|
||||
The shared session lifecycle SHALL treat heartbeat traffic as infrastructure input for liveness detection, round-trip-time measurement, and clock synchronization only. Heartbeat processing MUST NOT itself own login success, login failure, or reconnect policy decisions.
|
||||
The shared session lifecycle SHALL treat heartbeat traffic as infrastructure input for liveness detection and round-trip-time measurement only. Clock-synchronization samples MUST be forwarded to a separate sync-strategy component rather than being owned by `SessionManager`, and heartbeat processing MUST NOT itself own login success, login failure, or reconnect policy decisions.
|
||||
|
||||
#### Scenario: Heartbeat updates liveness and RTT only
|
||||
#### Scenario: Heartbeat updates liveness and RTT while forwarding clock samples
|
||||
- **WHEN** a heartbeat response is received for an active session
|
||||
- **THEN** the session manager updates last-seen or timeout bookkeeping and RTT or clock-sync data
|
||||
- **THEN** it does not mark the session logged in solely because the heartbeat succeeded
|
||||
- **THEN** the session manager updates last-seen or timeout bookkeeping and RTT data
|
||||
- **THEN** any server-tick sample is forwarded to the clock-sync strategy without making heartbeat the owner of login state
|
||||
|
||||
#### Scenario: Missing heartbeat triggers timeout state
|
||||
- **WHEN** the configured heartbeat timeout elapses without a required heartbeat or other liveness signal
|
||||
|
|
|
|||
|
|
@ -0,0 +1,52 @@
|
|||
# network-sync-strategy Specification
|
||||
|
||||
## Purpose
|
||||
Define how client and server route high-frequency gameplay synchronization traffic, reject stale updates, reconcile authoritative state, and process clock-sync samples independently of session lifecycle.
|
||||
|
||||
## Requirements
|
||||
### Requirement: Hosts assign delivery policies to synchronization message types
|
||||
The shared networking core SHALL allow hosts to map business message types to delivery policies. `PlayerInput` and `PlayerState` MUST be assignable to a high-frequency sync policy that is independent from the reliable ordered control policy used by login and lifecycle traffic.
|
||||
|
||||
#### Scenario: High-frequency sync messages use a dedicated policy
|
||||
- **WHEN** the client or server sends `PlayerInput` or `PlayerState`
|
||||
- **THEN** the runtime resolves a high-frequency sync delivery policy for that message type
|
||||
- **THEN** the message is sent through the sync lane configured for that policy instead of defaulting to reliable ordered delivery
|
||||
|
||||
#### Scenario: Control traffic keeps reliable delivery
|
||||
- **WHEN** the runtime sends login, logout, heartbeat, or other session-management messages
|
||||
- **THEN** the runtime resolves the reliable ordered control policy
|
||||
- **THEN** those messages continue to use the reliable transport path
|
||||
|
||||
### Requirement: Sequenced sync receivers discard stale gameplay updates
|
||||
The high-frequency sync strategy SHALL tag gameplay synchronization messages with monotonic sequencing information and MUST discard stale `PlayerInput` or `PlayerState` updates that arrive older than the last accepted update for the same peer or entity stream.
|
||||
|
||||
#### Scenario: Older player input is ignored
|
||||
- **WHEN** the server receives a `PlayerInput` update with a tick or sequence older than the latest accepted input for that player
|
||||
- **THEN** the server drops that stale input update
|
||||
- **THEN** the newer accepted input remains authoritative for simulation
|
||||
|
||||
#### Scenario: Older player state does not rewind a client
|
||||
- **WHEN** the client receives a `PlayerState` update with a tick or sequence older than the latest applied authoritative state for that player
|
||||
- **THEN** the client ignores the stale state update
|
||||
- **THEN** visible movement continues from the newer authoritative state without rewinding to older data
|
||||
|
||||
### Requirement: Authoritative correction prunes acknowledged prediction history
|
||||
The client sync strategy SHALL reconcile local prediction against authoritative player-state updates by pruning acknowledged inputs at or before the authoritative tick and only reapplying newer pending inputs.
|
||||
|
||||
#### Scenario: Reconciliation removes already acknowledged inputs
|
||||
- **WHEN** the client accepts an authoritative `PlayerState` update for tick `N`
|
||||
- **THEN** locally buffered predicted inputs with tick less than or equal to `N` are removed from the replay buffer
|
||||
- **THEN** only inputs newer than `N` remain eligible for re-simulation
|
||||
|
||||
### Requirement: Clock synchronization is a separate sync-policy concern
|
||||
The shared networking core SHALL process server-tick or clock-synchronization samples through a dedicated sync-policy component rather than storing clock-sync ownership inside `SessionManager`.
|
||||
|
||||
#### Scenario: Heartbeat response contributes a clock sample without mutating lifecycle
|
||||
- **WHEN** a heartbeat or gameplay sync message carries a server-tick sample
|
||||
- **THEN** the runtime forwards that sample to the clock-sync strategy
|
||||
- **THEN** session lifecycle state remains unchanged except for liveness or RTT bookkeeping
|
||||
|
||||
#### Scenario: Hosts can consume smoothed clock data for prediction
|
||||
- **WHEN** prediction or reconciliation code needs the current server-time estimate
|
||||
- **THEN** it reads that estimate from the clock-sync strategy or state object
|
||||
- **THEN** it does not query `SessionManager` for authoritative clock ownership
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
# shared-network-foundation Specification
|
||||
# shared-network-foundation Specification
|
||||
|
||||
## Purpose
|
||||
Define the shared transport, session-lifecycle, and message-routing foundation that both client and server hosts use without depending on Unity-specific runtime host classes.
|
||||
|
|
@ -31,12 +31,17 @@ The shared message-routing layer SHALL execute received business handlers throug
|
|||
- **THEN** the shared message-routing layer still processes received messages correctly through that host-selected strategy
|
||||
|
||||
### Requirement: Shared core preserves current transport and message contracts
|
||||
The shared client/server foundation SHALL preserve the existing `ITransport` send/receive contract and the envelope-based `MessageManager` routing model so client and server hosts exchange the same business payload format through the same transport abstractions.
|
||||
The shared client/server foundation SHALL preserve the envelope-based business-message contract across client and server hosts while allowing delivery-policy selection behind the shared message-routing layer. Reliable control traffic MUST continue to use the existing `ITransport` contract, and high-frequency sync traffic MUST be composable through a host-agnostic sync strategy without introducing Unity-specific runtime types into the shared networking core.
|
||||
|
||||
#### Scenario: Shared hosts exchange the same envelope format
|
||||
- **WHEN** a client host sends a business message through the shared core to a server host using the shared core
|
||||
- **THEN** the message is encoded using the same envelope contract on the client side
|
||||
- **THEN** the server host decodes and routes it through the shared message-routing layer without a host-specific protocol fork
|
||||
#### Scenario: Shared hosts exchange the same envelope format across delivery lanes
|
||||
- **WHEN** a client host sends a business message through either the reliable control path or the high-frequency sync path
|
||||
- **THEN** the payload is encoded with the same shared envelope and message-type contract
|
||||
- **THEN** the server host decodes and routes it through shared networking logic without a host-specific protocol fork
|
||||
|
||||
#### Scenario: Hosts compose delivery-policy selection without Unity dependencies
|
||||
- **WHEN** a non-Unity server host constructs the runtime networking stack with reliable control traffic and a high-frequency sync lane
|
||||
- **THEN** it uses shared delivery-policy abstractions without depending on Unity frame-loop types
|
||||
- **THEN** the Unity client can use the same abstractions while still supplying its own host-specific dispatch behavior
|
||||
|
||||
### Requirement: Shared runtime owns host-agnostic session lifecycle orchestration
|
||||
The shared network foundation SHALL include host-agnostic session lifecycle orchestration alongside transport startup and message routing. Client and server hosts MUST be able to compose the shared foundation with session orchestration that consumes transport events, login results, and heartbeat signals without depending on Unity-specific runtime types, while supporting both single-session client composition and multi-session server composition.
|
||||
|
|
|
|||
Loading…
Reference in New Issue