Network 层的迁移与强化已完成,TODO.md 与 MobaSyncMVP.md 给出后续方向
This commit is contained in:
@@ -194,6 +194,30 @@ namespace Tests.EditMode.Network
|
||||
|
||||
module.BeginRun(new TransportRunDescriptor(nameof(KcpTransport), isServer: false, defaultRemoteEndPoint: remote));
|
||||
module.RecordSessionOpened(remote);
|
||||
module.RecordSessionDiagnostics(remote, new TransportSessionDiagnosticsSnapshot
|
||||
{
|
||||
LifecycleState = "active",
|
||||
ObservedAtUtc = DateTimeOffset.UtcNow,
|
||||
SmoothedRttMs = 18,
|
||||
RetransmissionTimeoutMs = 45,
|
||||
WaitSendCount = 3,
|
||||
SendQueueCount = 2,
|
||||
SendBufferCount = 1,
|
||||
ReceiveQueueCount = 0,
|
||||
ReceiveBufferCount = 0,
|
||||
RetransmittedSegmentsInFlight = 1,
|
||||
ObservedRetransmissionSends = 4,
|
||||
ObservedLossSignals = 4
|
||||
});
|
||||
module.RecordApplicationSessionSnapshot(new TransportApplicationSessionSnapshot
|
||||
{
|
||||
Scope = "shared-runtime",
|
||||
ConnectionState = "LoggedIn",
|
||||
CanSendHeartbeat = true,
|
||||
LastRoundTripTimeMs = 18,
|
||||
CurrentServerTick = 321,
|
||||
ObservedAtUtc = DateTimeOffset.UtcNow
|
||||
});
|
||||
module.RecordPayloadSent(remote, 64);
|
||||
module.RecordDatagramSent(remote, 96);
|
||||
module.RecordError("socket-send", remote, "simulated");
|
||||
@@ -202,17 +226,47 @@ namespace Tests.EditMode.Network
|
||||
var first = module.CompleteRun();
|
||||
var second = module.CompleteRun();
|
||||
var reportFiles = Directory.GetFiles(reportDirectory, "*.json");
|
||||
var summaryFiles = Directory.GetFiles(reportDirectory, "*.summary.txt");
|
||||
var diagnosisFiles = Directory.GetFiles(reportDirectory, "*.diagnosis.txt");
|
||||
var reportText = File.ReadAllText(reportFiles[0]);
|
||||
var summaryText = File.ReadAllText(summaryFiles[0]);
|
||||
var diagnosisText = File.ReadAllText(diagnosisFiles[0]);
|
||||
|
||||
Assert.That(reportFiles, Has.Length.EqualTo(1));
|
||||
Assert.That(summaryFiles, Has.Length.EqualTo(1));
|
||||
Assert.That(diagnosisFiles, Has.Length.EqualTo(1));
|
||||
Assert.That(first.ReportPath, Is.EqualTo(reportFiles[0]));
|
||||
Assert.That(first.SummaryPath, Is.EqualTo(summaryFiles[0]));
|
||||
Assert.That(second.ReportPath, Is.EqualTo(first.ReportPath));
|
||||
Assert.That(first.SessionsCreated, Is.EqualTo(1));
|
||||
Assert.That(first.SessionsClosed, Is.EqualTo(1));
|
||||
Assert.That(first.SessionsWithDiagnostics, Is.EqualTo(1));
|
||||
Assert.That(first.AverageSmoothedRttMs, Is.EqualTo(18).Within(0.01));
|
||||
Assert.That(first.TotalObservedRetransmissionSends, Is.EqualTo(4));
|
||||
Assert.That(first.TotalObservedLossSignals, Is.EqualTo(4));
|
||||
Assert.That(first.SessionStateCounts["closed"], Is.EqualTo(1));
|
||||
Assert.That(first.ApplicationSessionsTracked, Is.EqualTo(1));
|
||||
Assert.That(first.ApplicationSessionStateCounts["LoggedIn"], Is.EqualTo(1));
|
||||
Assert.That(first.ErrorCountsByStage["socket-send"], Is.EqualTo(1));
|
||||
Assert.That(first.ReadableSummary.Headline, Does.Contain("finished"));
|
||||
Assert.That(first.ReadableSummary.LifecycleSummary, Does.Contain("states=LoggedIn=1"));
|
||||
Assert.That(first.ReadableSummary.HealthSummary, Does.Contain("avgRtt=18.0 ms"));
|
||||
Assert.That(first.ReadableSummary.HealthSummary, Does.Contain("observedRetransmissions=4"));
|
||||
Assert.That(reportText, Does.Contain(Environment.NewLine));
|
||||
Assert.That(reportText, Does.Contain(" \"RunId\""));
|
||||
Assert.That(consoleWriter.ToString(), Does.Contain("[TransportMetrics] KcpTransport"));
|
||||
Assert.That(reportText, Does.Contain(" \"ReadableSummary\""));
|
||||
Assert.That(summaryText, Does.Contain("Transport Metrics Summary"));
|
||||
Assert.That(summaryText, Does.Contain("English Summary"));
|
||||
Assert.That(summaryText, Does.Contain("Chinese Summary"));
|
||||
Assert.That(summaryText, Does.Contain("Top Peers:"));
|
||||
Assert.That(summaryText, Does.Contain("states=LoggedIn=1"));
|
||||
Assert.That(summaryText, Does.Contain("avgRtt=18.0 ms"));
|
||||
Assert.That(diagnosisText, Does.Contain("传输诊断结论"));
|
||||
Assert.That(diagnosisText, Does.Contain("网络质量存在明显风险"));
|
||||
Assert.That(diagnosisText, Does.Contain("共享会话已跟踪 1 个"));
|
||||
Assert.That(summaryText, Does.Contain("重点对端:"));
|
||||
Assert.That(consoleWriter.ToString(), Does.Contain("[TransportMetrics] English Summary"));
|
||||
Assert.That(consoleWriter.ToString(), Does.Contain("[TransportMetrics] Chinese Summary"));
|
||||
}
|
||||
finally
|
||||
{
|
||||
@@ -268,6 +322,11 @@ namespace Tests.EditMode.Network
|
||||
Assert.That(liveSnapshot.PeerSummaries, Has.Count.EqualTo(2));
|
||||
Assert.That(liveSnapshot.PeerSummaries.Sum(peer => peer.PayloadMessagesReceived), Is.EqualTo(2));
|
||||
Assert.That(liveSnapshot.PeerSummaries.Select(peer => peer.RemoteEndPoint).Distinct().Count(), Is.EqualTo(2));
|
||||
Assert.That(liveSnapshot.SessionsWithDiagnostics, Is.EqualTo(2));
|
||||
Assert.That(liveSnapshot.PeerSummaries.All(peer => peer.SessionDiagnostics.ObservedAtUtc.HasValue), Is.True);
|
||||
Assert.That(liveSnapshot.PeerSummaries.All(peer => peer.SessionLifecycleState == "active"), Is.True);
|
||||
Assert.That(liveSnapshot.TotalSendQueueCount, Is.GreaterThanOrEqualTo(0));
|
||||
Assert.That(liveSnapshot.TotalObservedRetransmissionSends, Is.GreaterThanOrEqualTo(0));
|
||||
|
||||
server.Stop();
|
||||
clientA.Stop();
|
||||
@@ -275,9 +334,14 @@ namespace Tests.EditMode.Network
|
||||
|
||||
var completedSnapshot = server.GetMetricsSnapshot();
|
||||
Assert.That(completedSnapshot.ReportPath, Is.Not.Null.And.Not.Empty);
|
||||
Assert.That(completedSnapshot.SummaryPath, Is.Not.Null.And.Not.Empty);
|
||||
Assert.That(Directory.GetFiles(serverReportDirectory, "*.json"), Has.Length.EqualTo(1));
|
||||
Assert.That(Directory.GetFiles(serverReportDirectory, "*.summary.txt"), Has.Length.EqualTo(1));
|
||||
Assert.That(Directory.GetFiles(serverReportDirectory, "*.diagnosis.txt"), Has.Length.EqualTo(1));
|
||||
Assert.That(completedSnapshot.ActiveSessions, Is.EqualTo(0));
|
||||
Assert.That(completedSnapshot.SessionsClosed, Is.EqualTo(2));
|
||||
Assert.That(completedSnapshot.SessionStateCounts["closed"], Is.EqualTo(2));
|
||||
Assert.That(completedSnapshot.ReadableSummary.HealthSummary, Does.Contain("states=closed=2"));
|
||||
}
|
||||
finally
|
||||
{
|
||||
@@ -289,6 +353,136 @@ namespace Tests.EditMode.Network
|
||||
DeleteDirectory(clientBReportDirectory);
|
||||
}
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void DefaultTransportMetricsModule_DisabledReports_SkipsFilesAndConsole()
|
||||
{
|
||||
var reportDirectory = CreateReportDirectory();
|
||||
var consoleWriter = new StringWriter();
|
||||
var options = new TransportMetricsOptions
|
||||
{
|
||||
ReportDirectory = reportDirectory,
|
||||
ConsoleWriter = consoleWriter,
|
||||
WriteJsonReport = false,
|
||||
WriteTextSummaryReport = false,
|
||||
WriteDiagnosisReport = false,
|
||||
EmitConsoleSummary = false
|
||||
};
|
||||
|
||||
try
|
||||
{
|
||||
var module = new DefaultTransportMetricsModule(options);
|
||||
var remote = new IPEndPoint(IPAddress.Loopback, 5001);
|
||||
|
||||
module.BeginRun(new TransportRunDescriptor(nameof(KcpTransport), isServer: true, defaultRemoteEndPoint: remote));
|
||||
module.RecordPayloadReceived(remote, 32);
|
||||
var snapshot = module.CompleteRun();
|
||||
|
||||
Assert.That(snapshot.ReportPath, Is.Null);
|
||||
Assert.That(snapshot.SummaryPath, Is.Null);
|
||||
Assert.That(Directory.Exists(reportDirectory), Is.False);
|
||||
Assert.That(consoleWriter.ToString(), Is.Empty);
|
||||
}
|
||||
finally
|
||||
{
|
||||
DeleteDirectory(reportDirectory);
|
||||
}
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void TransportMetricsDiagnosisFormatter_HighlightsReconnectAndBacklogRisks()
|
||||
{
|
||||
var snapshot = new TransportMetricsSnapshot
|
||||
{
|
||||
TransportName = nameof(KcpTransport),
|
||||
Mode = "server",
|
||||
DurationMs = 2400,
|
||||
AverageSmoothedRttMs = 188.5,
|
||||
PeakSmoothedRttMs = 320,
|
||||
TotalWaitSendCount = 9,
|
||||
TotalSendQueueCount = 4,
|
||||
TotalSendBufferCount = 2,
|
||||
TotalRetransmittedSegmentsInFlight = 3,
|
||||
TotalObservedRetransmissionSends = 7,
|
||||
SendErrors = 1,
|
||||
ErrorCountsByStage = new Dictionary<string, long>(StringComparer.Ordinal)
|
||||
{
|
||||
["socket-send"] = 1
|
||||
},
|
||||
ApplicationSessionsTracked = 2,
|
||||
ApplicationSessionStateCounts = new Dictionary<string, long>(StringComparer.Ordinal)
|
||||
{
|
||||
["LoggedIn"] = 1,
|
||||
["ReconnectPending"] = 1
|
||||
},
|
||||
ApplicationSessionSummaries = new List<TransportApplicationSessionSnapshot>
|
||||
{
|
||||
new()
|
||||
{
|
||||
Scope = "server-host",
|
||||
RemoteEndPoint = "127.0.0.1:5001",
|
||||
ConnectionState = "ReconnectPending",
|
||||
NextReconnectAtUtc = DateTimeOffset.UtcNow.AddSeconds(2)
|
||||
},
|
||||
new()
|
||||
{
|
||||
Scope = "server-host",
|
||||
RemoteEndPoint = "127.0.0.1:5002",
|
||||
ConnectionState = "LoggedIn",
|
||||
CanSendHeartbeat = true,
|
||||
LastRoundTripTimeMs = 188
|
||||
}
|
||||
},
|
||||
SessionsWithDiagnostics = 2,
|
||||
PeerSummaries = new List<TransportPeerMetricsSnapshot>
|
||||
{
|
||||
new()
|
||||
{
|
||||
RemoteEndPoint = "127.0.0.1:5001",
|
||||
SessionLifecycleState = "active",
|
||||
ObservedRetransmissionSends = 7,
|
||||
SessionDiagnostics = new TransportSessionDiagnosticsSnapshot
|
||||
{
|
||||
WaitSendCount = 9
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
var diagnosis = TransportMetricsDiagnosisFormatter.BuildChineseDiagnosis(snapshot);
|
||||
|
||||
Assert.That(diagnosis, Does.Contain("已出现会话不稳定迹象"));
|
||||
Assert.That(diagnosis, Does.Contain("存在发送侧堆积迹象"));
|
||||
Assert.That(diagnosis, Does.Contain("存在重传迹象"));
|
||||
Assert.That(diagnosis, Does.Contain("ReconnectPending=1"));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void TransportMetricsReportLocator_ReturnsMostRecentDiagnosisFile()
|
||||
{
|
||||
var reportDirectory = CreateReportDirectory();
|
||||
|
||||
try
|
||||
{
|
||||
Directory.CreateDirectory(reportDirectory);
|
||||
var olderPath = Path.Combine(reportDirectory, "older.diagnosis.txt");
|
||||
var newerPath = Path.Combine(reportDirectory, "newer.diagnosis.txt");
|
||||
File.WriteAllText(olderPath, "older");
|
||||
File.WriteAllText(newerPath, "newer");
|
||||
File.SetLastWriteTimeUtc(olderPath, new DateTime(2026, 3, 27, 0, 0, 0, DateTimeKind.Utc));
|
||||
File.SetLastWriteTimeUtc(newerPath, new DateTime(2026, 3, 27, 0, 0, 5, DateTimeKind.Utc));
|
||||
|
||||
var latestPath = TransportMetricsReportLocator.TryGetLatestDiagnosisPath(reportDirectory);
|
||||
var latestText = TransportMetricsReportLocator.ReadLatestDiagnosisText(reportDirectory);
|
||||
|
||||
Assert.That(latestPath, Is.EqualTo(newerPath));
|
||||
Assert.That(latestText, Is.EqualTo("newer"));
|
||||
}
|
||||
finally
|
||||
{
|
||||
DeleteDirectory(reportDirectory);
|
||||
}
|
||||
}
|
||||
private static async Task<T> WaitFor<T>(Task<T> task, string failureMessage)
|
||||
{
|
||||
var completedTask = await Task.WhenAny(task, Task.Delay(DefaultTimeoutMs));
|
||||
|
||||
@@ -54,6 +54,32 @@ namespace Tests.EditMode.Network
|
||||
Assert.That(envelope.Payload.ToByteArray(), Is.EqualTo(message.ToByteArray()));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void SendMessage_PlayerInput_UsesSyncLanePolicy()
|
||||
{
|
||||
var reliableTransport = new FakeTransport();
|
||||
var syncTransport = new FakeTransport();
|
||||
var manager = new MessageManager(
|
||||
reliableTransport,
|
||||
new MainThreadNetworkDispatcher(),
|
||||
new DefaultMessageDeliveryPolicyResolver(),
|
||||
syncTransport);
|
||||
var message = new PlayerInput
|
||||
{
|
||||
PlayerId = "player-1",
|
||||
Tick = 12
|
||||
};
|
||||
|
||||
manager.SendMessage(message, MessageType.PlayerInput);
|
||||
|
||||
Assert.That(reliableTransport.SendCallCount, Is.EqualTo(0));
|
||||
Assert.That(syncTransport.SendCallCount, Is.EqualTo(1));
|
||||
|
||||
var envelope = Envelope.Parser.ParseFrom(syncTransport.LastSentData);
|
||||
Assert.That(envelope.Type, Is.EqualTo((int)MessageType.PlayerInput));
|
||||
Assert.That(PlayerInput.Parser.ParseFrom(envelope.Payload).Tick, Is.EqualTo(12));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void BroadcastMessage_UsesBroadcastSend()
|
||||
{
|
||||
@@ -158,6 +184,31 @@ namespace Tests.EditMode.Network
|
||||
Assert.That(handledSpeeds, Is.EqualTo(new[] { 1, 2 }));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Receive_StalePlayerState_IsDropped()
|
||||
{
|
||||
var transport = new FakeTransport();
|
||||
var manager = new MessageManager(transport, new MainThreadNetworkDispatcher());
|
||||
var handledTicks = new List<long>();
|
||||
|
||||
manager.RegisterHandler(MessageType.PlayerState, (payload, sender) =>
|
||||
{
|
||||
handledTicks.Add(PlayerState.Parser.ParseFrom(payload).Tick);
|
||||
});
|
||||
|
||||
transport.EmitReceive(
|
||||
BuildEnvelope(MessageType.PlayerState, new PlayerState { PlayerId = "player-1", Tick = 8 }),
|
||||
Sender);
|
||||
manager.DrainPendingMessagesAsync().GetAwaiter().GetResult();
|
||||
|
||||
transport.EmitReceive(
|
||||
BuildEnvelope(MessageType.PlayerState, new PlayerState { PlayerId = "player-1", Tick = 6 }),
|
||||
Sender);
|
||||
manager.DrainPendingMessagesAsync().GetAwaiter().GetResult();
|
||||
|
||||
Assert.That(handledTicks, Is.EqualTo(new long[] { 8 }));
|
||||
}
|
||||
|
||||
private static byte[] BuildEnvelope(MessageType type, IMessage payload)
|
||||
{
|
||||
return new Envelope
|
||||
|
||||
@@ -72,7 +72,7 @@ namespace Tests.EditMode.Network
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void HeartbeatResponse_UpdatesRttAndServerTick_WithoutChangingLoggedInState()
|
||||
public void HeartbeatResponse_UpdatesRttAndClockSync_WithoutChangingLoggedInState()
|
||||
{
|
||||
var clock = new MutableClock(new DateTimeOffset(2026, 3, 27, 0, 0, 0, TimeSpan.Zero));
|
||||
var transport = new FakeTransport();
|
||||
@@ -88,7 +88,30 @@ namespace Tests.EditMode.Network
|
||||
|
||||
Assert.That(runtime.SessionManager.State, Is.EqualTo(ConnectionState.LoggedIn));
|
||||
Assert.That(runtime.SessionManager.LastRoundTripTime, Is.EqualTo(TimeSpan.FromMilliseconds(120)));
|
||||
Assert.That(runtime.SessionManager.LastServerTick, Is.EqualTo(321));
|
||||
Assert.That(runtime.ClockSync.CurrentServerTick, Is.EqualTo(321));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void SharedNetworkRuntime_PublishesLifecycleSnapshotsToMetricsSink()
|
||||
{
|
||||
var clock = new MutableClock(new DateTimeOffset(2026, 3, 27, 0, 0, 0, TimeSpan.Zero));
|
||||
var transport = new FakeTransport();
|
||||
var runtime = new SharedNetworkRuntime(transport, new ImmediateNetworkMessageDispatcher(), utcNowProvider: clock.UtcNow);
|
||||
|
||||
runtime.StartAsync().GetAwaiter().GetResult();
|
||||
runtime.NotifyLoginStarted();
|
||||
runtime.NotifyLoginSucceeded();
|
||||
runtime.NotifyHeartbeatSent();
|
||||
clock.Advance(TimeSpan.FromMilliseconds(80));
|
||||
runtime.NotifyHeartbeatReceived(456);
|
||||
|
||||
Assert.That(transport.ApplicationSnapshots, Is.Not.Empty);
|
||||
var latest = transport.ApplicationSnapshots[^1];
|
||||
Assert.That(latest.Scope, Is.EqualTo("shared-runtime"));
|
||||
Assert.That(latest.ConnectionState, Is.EqualTo(ConnectionState.LoggedIn.ToString()));
|
||||
Assert.That(latest.CanSendHeartbeat, Is.True);
|
||||
Assert.That(latest.LastRoundTripTimeMs, Is.EqualTo(80));
|
||||
Assert.That(latest.CurrentServerTick, Is.EqualTo(456));
|
||||
}
|
||||
|
||||
[Test]
|
||||
@@ -122,7 +145,44 @@ namespace Tests.EditMode.Network
|
||||
Assert.That(host.TryGetSession(peerB, out var sessionB), Is.True);
|
||||
Assert.That(sessionA.SessionManager.State, Is.EqualTo(ConnectionState.ReconnectPending));
|
||||
Assert.That(sessionB.SessionManager.State, Is.EqualTo(ConnectionState.LoggedIn));
|
||||
Assert.That(sessionB.SessionManager.LastServerTick, Is.EqualTo(99));
|
||||
Assert.That(sessionB.ClockSync.CurrentServerTick, Is.EqualTo(99));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void ServerNetworkHost_PublishesLifecycleSnapshotsToMetricsSink()
|
||||
{
|
||||
var clock = new MutableClock(new DateTimeOffset(2026, 3, 27, 0, 0, 0, TimeSpan.Zero));
|
||||
var policy = new SessionReconnectPolicy(
|
||||
heartbeatInterval: TimeSpan.FromSeconds(2),
|
||||
heartbeatTimeout: TimeSpan.FromSeconds(5),
|
||||
reconnectDelay: TimeSpan.FromSeconds(3),
|
||||
autoReconnect: true);
|
||||
var transport = new FakeTransport();
|
||||
var host = new ServerNetworkHost(transport, reconnectPolicy: policy, utcNowProvider: clock.UtcNow);
|
||||
var peerA = new IPEndPoint(IPAddress.Parse("127.0.0.1"), 5001);
|
||||
var peerB = new IPEndPoint(IPAddress.Parse("127.0.0.1"), 5002);
|
||||
|
||||
host.StartAsync().GetAwaiter().GetResult();
|
||||
transport.EmitReceive(CreateEnvelope(MessageType.Heartbeat), peerA);
|
||||
transport.EmitReceive(CreateEnvelope(MessageType.Heartbeat), peerB);
|
||||
host.NotifyLoginStarted(peerA);
|
||||
host.NotifyLoginSucceeded(peerA);
|
||||
host.NotifyLoginStarted(peerB);
|
||||
host.NotifyLoginSucceeded(peerB);
|
||||
|
||||
clock.Advance(TimeSpan.FromSeconds(6));
|
||||
host.NotifyHeartbeatReceived(peerB, 999);
|
||||
host.UpdateLifecycle();
|
||||
|
||||
Assert.That(transport.ApplicationSnapshots.Count, Is.GreaterThanOrEqualTo(2));
|
||||
var peerASnapshot = transport.ApplicationSnapshots.FindLast(snapshot => snapshot.RemoteEndPoint == peerA.ToString());
|
||||
var peerBSnapshot = transport.ApplicationSnapshots.FindLast(snapshot => snapshot.RemoteEndPoint == peerB.ToString());
|
||||
Assert.That(peerASnapshot, Is.Not.Null);
|
||||
Assert.That(peerBSnapshot, Is.Not.Null);
|
||||
Assert.That(peerASnapshot.Scope, Is.EqualTo("server-host"));
|
||||
Assert.That(peerASnapshot.ConnectionState, Is.EqualTo(ConnectionState.ReconnectPending.ToString()));
|
||||
Assert.That(peerBSnapshot.ConnectionState, Is.EqualTo(ConnectionState.LoggedIn.ToString()));
|
||||
Assert.That(peerBSnapshot.CurrentServerTick, Is.EqualTo(999));
|
||||
}
|
||||
|
||||
[Test]
|
||||
@@ -192,8 +252,10 @@ namespace Tests.EditMode.Network
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class FakeTransport : ITransport
|
||||
private sealed class FakeTransport : ITransport, ITransportMetricsSink
|
||||
{
|
||||
public List<TransportApplicationSessionSnapshot> ApplicationSnapshots { get; } = new();
|
||||
|
||||
public event Action<byte[], IPEndPoint> OnReceive;
|
||||
|
||||
public Task StartAsync()
|
||||
@@ -217,6 +279,24 @@ namespace Tests.EditMode.Network
|
||||
{
|
||||
}
|
||||
|
||||
public void RecordApplicationSessionSnapshot(TransportApplicationSessionSnapshot snapshot)
|
||||
{
|
||||
ApplicationSnapshots.Add(new TransportApplicationSessionSnapshot
|
||||
{
|
||||
Scope = snapshot.Scope,
|
||||
RemoteEndPoint = snapshot.RemoteEndPoint,
|
||||
ConnectionState = snapshot.ConnectionState,
|
||||
CanSendHeartbeat = snapshot.CanSendHeartbeat,
|
||||
LastRoundTripTimeMs = snapshot.LastRoundTripTimeMs,
|
||||
LastFailureReason = snapshot.LastFailureReason,
|
||||
LastLivenessUtc = snapshot.LastLivenessUtc,
|
||||
LastHeartbeatSentUtc = snapshot.LastHeartbeatSentUtc,
|
||||
NextReconnectAtUtc = snapshot.NextReconnectAtUtc,
|
||||
CurrentServerTick = snapshot.CurrentServerTick,
|
||||
ObservedAtUtc = snapshot.ObservedAtUtc
|
||||
});
|
||||
}
|
||||
|
||||
public void EmitReceive(byte[] data, IPEndPoint sender)
|
||||
{
|
||||
OnReceive?.Invoke(data, sender);
|
||||
|
||||
@@ -75,6 +75,32 @@ namespace Tests.EditMode.Network
|
||||
Assert.That(transport.LastSendTarget, Is.EqualTo(Sender));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void SharedNetworkRuntime_RoutesSyncMessagesThroughConfiguredSyncLane()
|
||||
{
|
||||
var reliableTransport = new FakeTransport();
|
||||
var syncTransport = new FakeTransport();
|
||||
var runtime = new SharedNetworkRuntime(
|
||||
reliableTransport,
|
||||
new ImmediateNetworkMessageDispatcher(),
|
||||
syncTransport: syncTransport);
|
||||
var message = new PlayerInput
|
||||
{
|
||||
PlayerId = "shared-player",
|
||||
Tick = 33
|
||||
};
|
||||
|
||||
runtime.MessageManager.SendMessage(message, MessageType.PlayerInput);
|
||||
|
||||
Assert.That(reliableTransport.SendCallCount, Is.EqualTo(0));
|
||||
Assert.That(syncTransport.SendCallCount, Is.EqualTo(1));
|
||||
Assert.That(syncTransport.LastSentData, Is.Not.Null);
|
||||
|
||||
var envelope = Envelope.Parser.ParseFrom(syncTransport.LastSentData);
|
||||
Assert.That(envelope.Type, Is.EqualTo((int)MessageType.PlayerInput));
|
||||
Assert.That(PlayerInput.Parser.ParseFrom(envelope.Payload).Tick, Is.EqualTo(33));
|
||||
}
|
||||
|
||||
private static byte[] BuildEnvelope(MessageType type, IMessage payload)
|
||||
{
|
||||
return new Envelope
|
||||
@@ -86,10 +112,14 @@ namespace Tests.EditMode.Network
|
||||
|
||||
private sealed class FakeTransport : ITransport
|
||||
{
|
||||
public byte[] LastSentData { get; private set; }
|
||||
|
||||
public byte[] LastSendToData { get; private set; }
|
||||
|
||||
public IPEndPoint LastSendTarget { get; private set; }
|
||||
|
||||
public int SendCallCount { get; private set; }
|
||||
|
||||
public event Action<byte[], IPEndPoint> OnReceive;
|
||||
|
||||
public Task StartAsync()
|
||||
@@ -103,6 +133,8 @@ namespace Tests.EditMode.Network
|
||||
|
||||
public void Send(byte[] data)
|
||||
{
|
||||
SendCallCount++;
|
||||
LastSentData = Copy(data);
|
||||
}
|
||||
|
||||
public void SendTo(byte[] data, IPEndPoint target)
|
||||
|
||||
@@ -0,0 +1,152 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Net;
|
||||
using Google.Protobuf;
|
||||
using Network.Defines;
|
||||
using Network.NetworkApplication;
|
||||
using Network.NetworkHost;
|
||||
using Network.NetworkTransport;
|
||||
using NUnit.Framework;
|
||||
|
||||
namespace Tests.EditMode.Network
|
||||
{
|
||||
public class SyncStrategyTests
|
||||
{
|
||||
[Test]
|
||||
public void ClientPredictionBuffer_AuthoritativeState_PrunesAcknowledgedInputs()
|
||||
{
|
||||
var buffer = new ClientPredictionBuffer();
|
||||
buffer.Record(new PlayerInput { PlayerId = "player-1", Tick = 10 });
|
||||
buffer.Record(new PlayerInput { PlayerId = "player-1", Tick = 11 });
|
||||
buffer.Record(new PlayerInput { PlayerId = "player-1", Tick = 12 });
|
||||
|
||||
var accepted = buffer.TryApplyAuthoritativeState(
|
||||
new PlayerState { PlayerId = "player-1", Tick = 11 },
|
||||
out var replayInputs);
|
||||
|
||||
Assert.That(accepted, Is.True);
|
||||
Assert.That(buffer.LastAuthoritativeTick, Is.EqualTo(11));
|
||||
Assert.That(replayInputs.Count, Is.EqualTo(1));
|
||||
Assert.That(replayInputs[0].Tick, Is.EqualTo(12));
|
||||
Assert.That(buffer.PendingInputs.Count, Is.EqualTo(1));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void ClientPredictionBuffer_StaleAuthoritativeState_IsIgnored()
|
||||
{
|
||||
var buffer = new ClientPredictionBuffer();
|
||||
buffer.Record(new PlayerInput { PlayerId = "player-1", Tick = 10 });
|
||||
buffer.TryApplyAuthoritativeState(new PlayerState { PlayerId = "player-1", Tick = 10 }, out _);
|
||||
|
||||
var accepted = buffer.TryApplyAuthoritativeState(
|
||||
new PlayerState { PlayerId = "player-1", Tick = 9 },
|
||||
out var replayInputs);
|
||||
|
||||
Assert.That(accepted, Is.False);
|
||||
Assert.That(replayInputs, Is.Empty);
|
||||
Assert.That(buffer.LastAuthoritativeTick, Is.EqualTo(10));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void ClockSyncState_RejectsOlderSamples()
|
||||
{
|
||||
var clockSync = new ClockSyncState();
|
||||
|
||||
var acceptedFirst = clockSync.ObserveSample(42);
|
||||
var acceptedSecond = clockSync.ObserveSample(41);
|
||||
|
||||
Assert.That(acceptedFirst, Is.True);
|
||||
Assert.That(acceptedSecond, Is.False);
|
||||
Assert.That(clockSync.CurrentServerTick, Is.EqualTo(42));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void SharedNetworkRuntime_AuthoritativeStateUpdatesClockWithoutChangingLifecycle()
|
||||
{
|
||||
var transport = new FakeTransport();
|
||||
var runtime = new SharedNetworkRuntime(transport, new ImmediateNetworkMessageDispatcher());
|
||||
|
||||
runtime.StartAsync().GetAwaiter().GetResult();
|
||||
runtime.NotifyLoginStarted();
|
||||
runtime.NotifyLoginSucceeded();
|
||||
runtime.ObserveAuthoritativeState(88);
|
||||
|
||||
Assert.That(runtime.SessionManager.State, Is.EqualTo(ConnectionState.LoggedIn));
|
||||
Assert.That(runtime.ClockSync.CurrentServerTick, Is.EqualTo(88));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void ServerNetworkHost_RejectsStaleInputPerPeerWithoutCrossPeerInterference()
|
||||
{
|
||||
var transport = new FakeTransport();
|
||||
var host = new ServerNetworkHost(transport);
|
||||
var peerA = new IPEndPoint(IPAddress.Parse("127.0.0.1"), 5001);
|
||||
var peerB = new IPEndPoint(IPAddress.Parse("127.0.0.1"), 5002);
|
||||
var handledTicksByPeer = new Dictionary<string, List<long>>();
|
||||
|
||||
host.MessageManager.RegisterHandler(MessageType.PlayerInput, (payload, sender) =>
|
||||
{
|
||||
var key = sender.ToString();
|
||||
if (!handledTicksByPeer.TryGetValue(key, out var ticks))
|
||||
{
|
||||
ticks = new List<long>();
|
||||
handledTicksByPeer.Add(key, ticks);
|
||||
}
|
||||
|
||||
ticks.Add(PlayerInput.Parser.ParseFrom(payload).Tick);
|
||||
});
|
||||
|
||||
transport.EmitReceive(
|
||||
BuildEnvelope(MessageType.PlayerInput, new PlayerInput { PlayerId = "player-a", Tick = 5 }),
|
||||
peerA);
|
||||
transport.EmitReceive(
|
||||
BuildEnvelope(MessageType.PlayerInput, new PlayerInput { PlayerId = "player-a", Tick = 4 }),
|
||||
peerA);
|
||||
transport.EmitReceive(
|
||||
BuildEnvelope(MessageType.PlayerInput, new PlayerInput { PlayerId = "player-b", Tick = 4 }),
|
||||
peerB);
|
||||
|
||||
Assert.That(handledTicksByPeer[peerA.ToString()], Is.EqualTo(new long[] { 5 }));
|
||||
Assert.That(handledTicksByPeer[peerB.ToString()], Is.EqualTo(new long[] { 4 }));
|
||||
}
|
||||
|
||||
private static byte[] BuildEnvelope(MessageType type, IMessage payload)
|
||||
{
|
||||
return new Envelope
|
||||
{
|
||||
Type = (int)type,
|
||||
Payload = payload.ToByteString()
|
||||
}.ToByteArray();
|
||||
}
|
||||
|
||||
private sealed class FakeTransport : ITransport
|
||||
{
|
||||
public event System.Action<byte[], IPEndPoint> OnReceive;
|
||||
|
||||
public System.Threading.Tasks.Task StartAsync()
|
||||
{
|
||||
return System.Threading.Tasks.Task.CompletedTask;
|
||||
}
|
||||
|
||||
public void Stop()
|
||||
{
|
||||
}
|
||||
|
||||
public void Send(byte[] data)
|
||||
{
|
||||
}
|
||||
|
||||
public void SendTo(byte[] data, IPEndPoint target)
|
||||
{
|
||||
}
|
||||
|
||||
public void SendToAll(byte[] data)
|
||||
{
|
||||
}
|
||||
|
||||
public void EmitReceive(byte[] data, IPEndPoint sender)
|
||||
{
|
||||
OnReceive?.Invoke(data, sender);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 32abf5cf5893ca9479bc617ccfedfab0
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
Reference in New Issue
Block a user