Network 层的迁移与强化已完成,TODO.md 与 MobaSyncMVP.md 给出后续方向

This commit is contained in:
SepComet
2026-03-27 22:14:33 +08:00
parent e361510100
commit 156d72bf4a
57 changed files with 2933 additions and 102 deletions
@@ -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: