process TODO.md step7

This commit is contained in:
SepComet
2026-03-29 10:28:10 +08:00
parent 4826bf15a5
commit ef01760924
22 changed files with 941 additions and 31 deletions
@@ -58,7 +58,8 @@ namespace Network.NetworkApplication
Func<DateTimeOffset> utcNowProvider = null,
IMessageDeliveryPolicyResolver deliveryPolicyResolver = null,
SyncSequenceTracker syncSequenceTracker = null,
Func<int, ITransport> transportFactory = null)
Func<int, ITransport> transportFactory = null,
ServerAuthoritativeMovementConfiguration authoritativeMovement = null)
{
ValidateDualPortConfiguration(reliablePort, syncPort);
@@ -82,7 +83,8 @@ namespace Network.NetworkApplication
utcNowProvider,
syncTransport,
deliveryPolicyResolver,
syncSequenceTracker);
syncSequenceTracker,
authoritativeMovement);
}
public static Task<ServerRuntimeHandle> StartServerRuntimeAsync(ServerRuntimeConfiguration configuration)
@@ -0,0 +1,26 @@
using System;
namespace Network.NetworkHost
{
public sealed class ServerAuthoritativeMovementConfiguration
{
public float MoveSpeed { get; set; } = 5f;
public TimeSpan BroadcastInterval { get; set; } = TimeSpan.FromMilliseconds(50);
public int DefaultHp { get; set; } = 100;
internal void Validate()
{
if (float.IsNaN(MoveSpeed) || float.IsInfinity(MoveSpeed) || MoveSpeed < 0f)
{
throw new ArgumentOutOfRangeException(nameof(MoveSpeed), "Move speed must be finite and non-negative.");
}
if (BroadcastInterval <= TimeSpan.Zero)
{
throw new ArgumentOutOfRangeException(nameof(BroadcastInterval), "Broadcast interval must be positive.");
}
}
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 8b8b5d0a5ff84ab696e3b302f92314e1
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,301 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Net;
using System.Threading.Tasks;
using Network.Defines;
using Network.NetworkApplication;
namespace Network.NetworkHost
{
internal sealed class ServerAuthoritativeMovementCoordinator
{
private readonly object gate = new();
private readonly MessageManager messageManager;
private readonly ServerNetworkHost host;
private readonly ServerAuthoritativeMovementConfiguration configuration;
private readonly Dictionary<string, ServerAuthoritativeMovementState> statesByPeer = new();
private long nextBroadcastTick = 1;
private TimeSpan accumulatedBroadcastTime;
public ServerAuthoritativeMovementCoordinator(
ServerNetworkHost host,
MessageManager messageManager,
ServerAuthoritativeMovementConfiguration configuration)
{
this.host = host ?? throw new ArgumentNullException(nameof(host));
this.messageManager = messageManager ?? throw new ArgumentNullException(nameof(messageManager));
this.configuration = configuration ?? throw new ArgumentNullException(nameof(configuration));
}
public IReadOnlyList<ServerAuthoritativeMovementState> States
{
get
{
lock (gate)
{
return statesByPeer.Values
.Select(CloneState)
.ToArray();
}
}
}
public Task HandleMoveInputAsync(byte[] payload, IPEndPoint sender)
{
if (payload == null || sender == null)
{
return Task.CompletedTask;
}
MoveInput input;
try
{
input = MoveInput.Parser.ParseFrom(payload);
}
catch
{
return Task.CompletedTask;
}
if (string.IsNullOrWhiteSpace(input.PlayerId) ||
!IsFinite(input.MoveX) ||
!IsFinite(input.MoveY))
{
return Task.CompletedTask;
}
var normalizedSender = Normalize(sender);
var key = normalizedSender.ToString();
lock (gate)
{
if (statesByPeer.TryGetValue(key, out var existingState))
{
if (!string.Equals(existingState.PlayerId, input.PlayerId, StringComparison.Ordinal) ||
input.Tick <= existingState.LastAcceptedMoveTick)
{
return Task.CompletedTask;
}
ApplyInput(existingState, input);
return Task.CompletedTask;
}
var state = new ServerAuthoritativeMovementState(
normalizedSender,
input.PlayerId,
configuration.DefaultHp);
ApplyInput(state, input);
statesByPeer.Add(key, state);
return Task.CompletedTask;
}
}
public void Update(TimeSpan elapsed)
{
if (elapsed < TimeSpan.Zero)
{
throw new ArgumentOutOfRangeException(nameof(elapsed), "Elapsed time must be non-negative.");
}
List<PendingBroadcast> pendingBroadcasts = null;
lock (gate)
{
foreach (var state in statesByPeer.Values)
{
IntegrateState(state, elapsed);
}
accumulatedBroadcastTime += elapsed;
while (accumulatedBroadcastTime >= configuration.BroadcastInterval)
{
accumulatedBroadcastTime -= configuration.BroadcastInterval;
pendingBroadcasts ??= new List<PendingBroadcast>();
foreach (var state in statesByPeer.Values)
{
state.LastBroadcastTick = nextBroadcastTick;
pendingBroadcasts.Add(new PendingBroadcast(
state.RemoteEndPoint,
BuildPlayerState(state, nextBroadcastTick)));
}
nextBroadcastTick++;
}
}
if (pendingBroadcasts == null)
{
return;
}
foreach (var pendingBroadcast in pendingBroadcasts)
{
messageManager.BroadcastMessage(pendingBroadcast.PlayerState, MessageType.PlayerState);
host.ObserveAuthoritativeState(pendingBroadcast.RemoteEndPoint, pendingBroadcast.PlayerState.Tick);
}
}
public bool TryGetState(IPEndPoint remoteEndPoint, out ServerAuthoritativeMovementState state)
{
var key = Normalize(remoteEndPoint).ToString();
lock (gate)
{
if (statesByPeer.TryGetValue(key, out state))
{
state = CloneState(state);
return true;
}
}
state = null;
return false;
}
public void RemoveState(IPEndPoint remoteEndPoint)
{
var key = Normalize(remoteEndPoint).ToString();
lock (gate)
{
statesByPeer.Remove(key);
}
}
public void Clear()
{
lock (gate)
{
statesByPeer.Clear();
accumulatedBroadcastTime = TimeSpan.Zero;
}
}
private static bool IsFinite(float value)
{
return !float.IsNaN(value) && !float.IsInfinity(value);
}
private static IPEndPoint Normalize(IPEndPoint remoteEndPoint)
{
if (remoteEndPoint == null)
{
throw new ArgumentNullException(nameof(remoteEndPoint));
}
return new IPEndPoint(remoteEndPoint.Address, remoteEndPoint.Port);
}
private static ServerAuthoritativeMovementState CloneState(ServerAuthoritativeMovementState state)
{
return new ServerAuthoritativeMovementState(state.RemoteEndPoint, state.PlayerId, state.Hp)
{
LastAcceptedMoveTick = state.LastAcceptedMoveTick,
LastBroadcastTick = state.LastBroadcastTick,
PositionX = state.PositionX,
PositionY = state.PositionY,
PositionZ = state.PositionZ,
VelocityX = state.VelocityX,
VelocityY = state.VelocityY,
VelocityZ = state.VelocityZ,
Rotation = state.Rotation,
InputX = state.InputX,
InputY = state.InputY
};
}
private static void ApplyInput(ServerAuthoritativeMovementState state, MoveInput input)
{
state.LastAcceptedMoveTick = input.Tick;
state.InputX = input.MoveX;
state.InputY = input.MoveY;
if (input.MoveX == 0f && input.MoveY == 0f)
{
state.VelocityX = 0f;
state.VelocityY = 0f;
state.VelocityZ = 0f;
return;
}
var length = MathF.Sqrt((input.MoveX * input.MoveX) + (input.MoveY * input.MoveY));
if (length <= 0f)
{
state.VelocityX = 0f;
state.VelocityY = 0f;
state.VelocityZ = 0f;
return;
}
state.Rotation = MathF.Atan2(input.MoveY, input.MoveX) * (180f / MathF.PI);
}
private void IntegrateState(ServerAuthoritativeMovementState state, TimeSpan elapsed)
{
if (state.InputX == 0f && state.InputY == 0f)
{
state.VelocityX = 0f;
state.VelocityY = 0f;
state.VelocityZ = 0f;
return;
}
var length = MathF.Sqrt((state.InputX * state.InputX) + (state.InputY * state.InputY));
if (length <= 0f)
{
state.VelocityX = 0f;
state.VelocityY = 0f;
state.VelocityZ = 0f;
return;
}
var normalizedX = state.InputX / length;
var normalizedY = state.InputY / length;
state.VelocityX = normalizedX * configuration.MoveSpeed;
state.VelocityY = 0f;
state.VelocityZ = normalizedY * configuration.MoveSpeed;
var deltaSeconds = (float)elapsed.TotalSeconds;
state.PositionX += state.VelocityX * deltaSeconds;
state.PositionY += state.VelocityY * deltaSeconds;
state.PositionZ += state.VelocityZ * deltaSeconds;
}
private static PlayerState BuildPlayerState(ServerAuthoritativeMovementState state, long tick)
{
return new PlayerState
{
PlayerId = state.PlayerId,
Tick = tick,
Position = new Vector3
{
X = state.PositionX,
Y = state.PositionY,
Z = state.PositionZ
},
Velocity = new Vector3
{
X = state.VelocityX,
Y = state.VelocityY,
Z = state.VelocityZ
},
Rotation = state.Rotation,
Hp = state.Hp
};
}
private sealed class PendingBroadcast
{
public PendingBroadcast(IPEndPoint remoteEndPoint, PlayerState playerState)
{
RemoteEndPoint = remoteEndPoint;
PlayerState = playerState;
}
public IPEndPoint RemoteEndPoint { get; }
public PlayerState PlayerState { get; }
}
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 91c6c9377bb94c4f88cc4f37dbed16ab
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,43 @@
using System;
using System.Net;
namespace Network.NetworkHost
{
public sealed class ServerAuthoritativeMovementState
{
public ServerAuthoritativeMovementState(IPEndPoint remoteEndPoint, string playerId, int hp)
{
RemoteEndPoint = remoteEndPoint ?? throw new ArgumentNullException(nameof(remoteEndPoint));
PlayerId = playerId ?? throw new ArgumentNullException(nameof(playerId));
Hp = hp;
}
public IPEndPoint RemoteEndPoint { get; }
public string PlayerId { get; }
public long LastAcceptedMoveTick { get; internal set; }
public long LastBroadcastTick { get; internal set; }
public float PositionX { get; internal set; }
public float PositionY { get; internal set; }
public float PositionZ { get; internal set; }
public float VelocityX { get; internal set; }
public float VelocityY { get; internal set; }
public float VelocityZ { get; internal set; }
public float Rotation { get; internal set; }
public int Hp { get; internal set; }
public float InputX { get; internal set; }
public float InputY { get; internal set; }
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 0f2f1baf3a314f51a8d4b1caea7ee905
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -1,7 +1,8 @@
using System;
using System;
using System.Collections.Generic;
using System.Net;
using System.Threading.Tasks;
using Network.Defines;
using Network.NetworkApplication;
using Network.NetworkTransport;
@@ -12,6 +13,7 @@ namespace Network.NetworkHost
private readonly ITransport transport;
private readonly ITransport syncTransport;
private readonly MessageManager messageManager;
private readonly ServerAuthoritativeMovementCoordinator authoritativeMovementCoordinator;
public ServerNetworkHost(
ITransport transport,
@@ -20,7 +22,8 @@ namespace Network.NetworkHost
Func<DateTimeOffset> utcNowProvider = null,
ITransport syncTransport = null,
IMessageDeliveryPolicyResolver deliveryPolicyResolver = null,
SyncSequenceTracker syncSequenceTracker = null)
SyncSequenceTracker syncSequenceTracker = null,
ServerAuthoritativeMovementConfiguration authoritativeMovement = null)
{
this.transport = transport ?? throw new ArgumentNullException(nameof(transport));
this.syncTransport = syncTransport;
@@ -37,6 +40,11 @@ namespace Network.NetworkHost
deliveryPolicyResolver ?? new DefaultMessageDeliveryPolicyResolver(),
this.syncTransport,
syncSequenceTracker ?? new SyncSequenceTracker());
authoritativeMovementCoordinator = new ServerAuthoritativeMovementCoordinator(
this,
messageManager,
authoritativeMovement ?? new ServerAuthoritativeMovementConfiguration());
messageManager.RegisterHandler(MessageType.MoveInput, authoritativeMovementCoordinator.HandleMoveInputAsync);
}
public MessageManager MessageManager => messageManager;
@@ -45,11 +53,12 @@ namespace Network.NetworkHost
public ITransport SyncTransport => syncTransport;
// Server-side lifecycle entry point: inspect and control per-peer session state here.
public MultiSessionManager SessionCoordinator { get; }
public IReadOnlyList<ManagedNetworkSession> ManagedSessions => SessionCoordinator.Sessions;
public IReadOnlyList<ServerAuthoritativeMovementState> AuthoritativeMovementStates => authoritativeMovementCoordinator.States;
public event Action<MultiSessionLifecycleEvent> LifecycleChanged
{
add => SessionCoordinator.LifecycleChanged += value;
@@ -77,6 +86,7 @@ namespace Network.NetworkHost
}
SessionCoordinator.RemoveAllSessions("Transport stopped");
authoritativeMovementCoordinator.Clear();
PublishMetricsSessionSnapshots();
}
@@ -91,11 +101,21 @@ namespace Network.NetworkHost
PublishMetricsSessionSnapshots();
}
public void UpdateAuthoritativeMovement(TimeSpan elapsed)
{
authoritativeMovementCoordinator.Update(elapsed);
}
public bool TryGetSession(IPEndPoint remoteEndPoint, out ManagedNetworkSession session)
{
return SessionCoordinator.TryGetSession(remoteEndPoint, out session);
}
public bool TryGetAuthoritativeMovementState(IPEndPoint remoteEndPoint, out ServerAuthoritativeMovementState state)
{
return authoritativeMovementCoordinator.TryGetState(remoteEndPoint, out state);
}
public void NotifyLoginStarted(IPEndPoint remoteEndPoint)
{
SessionCoordinator.NotifyLoginStarted(remoteEndPoint);
@@ -151,6 +171,8 @@ namespace Network.NetworkHost
return false;
}
authoritativeMovementCoordinator.RemoveState(remoteEndPoint);
RecordMetricsSessionSnapshot(transport, "server-host", session, ConnectionState.Disconnected);
if (syncTransport != null && !ReferenceEquals(syncTransport, transport))
{
@@ -32,6 +32,8 @@ namespace Network.NetworkHost
public Func<int, ITransport> TransportFactory { get; set; }
public ServerAuthoritativeMovementConfiguration AuthoritativeMovement { get; set; }
internal void Validate()
{
if (ReliablePort <= 0)
@@ -39,20 +41,20 @@ namespace Network.NetworkHost
throw new ArgumentOutOfRangeException(nameof(ReliablePort), "Reliable port must be positive.");
}
if (!SyncPort.HasValue)
if (SyncPort.HasValue)
{
return;
if (SyncPort.Value <= 0)
{
throw new ArgumentOutOfRangeException(nameof(SyncPort), "Sync port must be positive.");
}
if (SyncPort.Value == ReliablePort)
{
throw new ArgumentException("Sync port must differ from reliable port.", nameof(SyncPort));
}
}
if (SyncPort.Value <= 0)
{
throw new ArgumentOutOfRangeException(nameof(SyncPort), "Sync port must be positive.");
}
if (SyncPort.Value == ReliablePort)
{
throw new ArgumentException("Sync port must differ from reliable port.", nameof(SyncPort));
}
AuthoritativeMovement?.Validate();
}
}
}
@@ -23,7 +23,8 @@ namespace Network.NetworkHost
configuration.UtcNowProvider,
configuration.DeliveryPolicyResolver,
configuration.SyncSequenceTracker,
configuration.TransportFactory);
configuration.TransportFactory,
configuration.AuthoritativeMovement);
try
{
@@ -23,6 +23,8 @@ namespace Network.NetworkHost
public IReadOnlyList<ManagedNetworkSession> ManagedSessions => host.ManagedSessions;
public IReadOnlyList<ServerAuthoritativeMovementState> AuthoritativeMovementStates => host.AuthoritativeMovementStates;
public event Action<MultiSessionLifecycleEvent> LifecycleChanged
{
add => host.LifecycleChanged += value;
@@ -39,11 +41,21 @@ namespace Network.NetworkHost
host.UpdateLifecycle();
}
public void UpdateAuthoritativeMovement(TimeSpan elapsed)
{
host.UpdateAuthoritativeMovement(elapsed);
}
public bool TryGetSession(IPEndPoint remoteEndPoint, out ManagedNetworkSession session)
{
return host.TryGetSession(remoteEndPoint, out session);
}
public bool TryGetAuthoritativeMovementState(IPEndPoint remoteEndPoint, out ServerAuthoritativeMovementState state)
{
return host.TryGetAuthoritativeMovementState(remoteEndPoint, out state);
}
public void Stop()
{
if (isStopped)
@@ -0,0 +1,247 @@
using System;
using System.Collections.Generic;
using System.Net;
using System.Threading.Tasks;
using Google.Protobuf;
using Network.Defines;
using Network.NetworkApplication;
using Network.NetworkHost;
using Network.NetworkTransport;
using NUnit.Framework;
namespace Tests.EditMode.Network
{
public class ServerAuthoritativeMovementTests
{
private static readonly IPEndPoint PeerA = new(IPAddress.Loopback, 9101);
private static readonly IPEndPoint PeerB = new(IPAddress.Loopback, 9102);
[Test]
public void UpdateAuthoritativeMovement_AcceptsLatestTickPerPeer_AndKeepsStaleFilteringIndependent()
{
var createdTransports = new Dictionary<int, FakeTransport>();
var configuration = new ServerRuntimeConfiguration(9000)
{
Dispatcher = new MainThreadNetworkDispatcher(),
TransportFactory = port => CreateTransport(createdTransports, port),
AuthoritativeMovement = new ServerAuthoritativeMovementConfiguration
{
MoveSpeed = 4f,
BroadcastInterval = TimeSpan.FromMilliseconds(50)
}
};
using var runtime = ServerRuntimeEntryPoint.StartAsync(configuration).GetAwaiter().GetResult();
createdTransports[9000].EmitReceive(BuildEnvelope(MessageType.MoveInput, new MoveInput
{
PlayerId = "player-a",
Tick = 10,
MoveX = 1f,
MoveY = 0f
}), PeerA);
createdTransports[9000].EmitReceive(BuildEnvelope(MessageType.MoveInput, new MoveInput
{
PlayerId = "player-a",
Tick = 8,
MoveX = 0f,
MoveY = 1f
}), PeerA);
createdTransports[9000].EmitReceive(BuildEnvelope(MessageType.MoveInput, new MoveInput
{
PlayerId = "player-b",
Tick = 3,
MoveX = 0f,
MoveY = 1f
}), PeerB);
runtime.DrainPendingMessagesAsync().GetAwaiter().GetResult();
runtime.UpdateAuthoritativeMovement(TimeSpan.FromMilliseconds(50));
Assert.That(runtime.TryGetAuthoritativeMovementState(PeerA, out var stateA), Is.True);
Assert.That(runtime.TryGetAuthoritativeMovementState(PeerB, out var stateB), Is.True);
Assert.That(stateA.PlayerId, Is.EqualTo("player-a"));
Assert.That(stateA.LastAcceptedMoveTick, Is.EqualTo(10));
Assert.That(stateA.PositionX, Is.EqualTo(0.2f).Within(0.0001f));
Assert.That(stateA.PositionZ, Is.EqualTo(0f).Within(0.0001f));
Assert.That(stateB.PlayerId, Is.EqualTo("player-b"));
Assert.That(stateB.LastAcceptedMoveTick, Is.EqualTo(3));
Assert.That(stateB.PositionX, Is.EqualTo(0f).Within(0.0001f));
Assert.That(stateB.PositionZ, Is.EqualTo(0.2f).Within(0.0001f));
Assert.That(createdTransports[9000].BroadcastMessages.Count, Is.EqualTo(2));
}
[Test]
public void UpdateAuthoritativeMovement_BroadcastsPlayerStateOnSyncLane_AndZeroVectorStopsMovement()
{
var createdTransports = new Dictionary<int, FakeTransport>();
var configuration = new ServerRuntimeConfiguration(9000)
{
SyncPort = 9001,
Dispatcher = new MainThreadNetworkDispatcher(),
TransportFactory = port => CreateTransport(createdTransports, port),
AuthoritativeMovement = new ServerAuthoritativeMovementConfiguration
{
MoveSpeed = 10f,
BroadcastInterval = TimeSpan.FromMilliseconds(100)
}
};
using var runtime = ServerRuntimeEntryPoint.StartAsync(configuration).GetAwaiter().GetResult();
createdTransports[9001].EmitReceive(BuildEnvelope(MessageType.MoveInput, new MoveInput
{
PlayerId = "player-a",
Tick = 1,
MoveX = 1f,
MoveY = 0f
}), PeerA);
runtime.DrainPendingMessagesAsync().GetAwaiter().GetResult();
runtime.UpdateAuthoritativeMovement(TimeSpan.FromMilliseconds(90));
Assert.That(createdTransports[9001].BroadcastMessages.Count, Is.EqualTo(0));
runtime.UpdateAuthoritativeMovement(TimeSpan.FromMilliseconds(10));
Assert.That(createdTransports[9000].BroadcastMessages.Count, Is.EqualTo(0));
Assert.That(createdTransports[9001].BroadcastMessages.Count, Is.EqualTo(1));
var firstBroadcast = ParsePlayerState(createdTransports[9001].BroadcastMessages[0]);
Assert.That(firstBroadcast.PlayerId, Is.EqualTo("player-a"));
Assert.That(firstBroadcast.Tick, Is.EqualTo(1));
Assert.That(firstBroadcast.Position.X, Is.EqualTo(1f).Within(0.0001f));
Assert.That(firstBroadcast.Velocity.X, Is.EqualTo(10f).Within(0.0001f));
Assert.That(firstBroadcast.Velocity.Z, Is.EqualTo(0f).Within(0.0001f));
createdTransports[9001].EmitReceive(BuildEnvelope(MessageType.MoveInput, new MoveInput
{
PlayerId = "player-a",
Tick = 2,
MoveX = 0f,
MoveY = 0f
}), PeerA);
runtime.DrainPendingMessagesAsync().GetAwaiter().GetResult();
runtime.UpdateAuthoritativeMovement(TimeSpan.FromMilliseconds(100));
Assert.That(runtime.TryGetAuthoritativeMovementState(PeerA, out var state), Is.True);
Assert.That(state.LastAcceptedMoveTick, Is.EqualTo(2));
Assert.That(state.VelocityX, Is.EqualTo(0f).Within(0.0001f));
Assert.That(state.VelocityZ, Is.EqualTo(0f).Within(0.0001f));
Assert.That(createdTransports[9001].BroadcastMessages.Count, Is.EqualTo(2));
var secondBroadcast = ParsePlayerState(createdTransports[9001].BroadcastMessages[1]);
Assert.That(secondBroadcast.Tick, Is.EqualTo(2));
Assert.That(secondBroadcast.Position.X, Is.EqualTo(1f).Within(0.0001f));
Assert.That(secondBroadcast.Velocity.X, Is.EqualTo(0f).Within(0.0001f));
Assert.That(secondBroadcast.Velocity.Z, Is.EqualTo(0f).Within(0.0001f));
}
[Test]
public void UpdateAuthoritativeMovement_UsesReliableLaneWhenSyncTransportIsUnavailable()
{
var createdTransports = new Dictionary<int, FakeTransport>();
var configuration = new ServerRuntimeConfiguration(9000)
{
Dispatcher = new MainThreadNetworkDispatcher(),
TransportFactory = port => CreateTransport(createdTransports, port),
AuthoritativeMovement = new ServerAuthoritativeMovementConfiguration
{
MoveSpeed = 6f,
BroadcastInterval = TimeSpan.FromMilliseconds(50)
}
};
using var runtime = ServerRuntimeEntryPoint.StartAsync(configuration).GetAwaiter().GetResult();
createdTransports[9000].EmitReceive(BuildEnvelope(MessageType.MoveInput, new MoveInput
{
PlayerId = "player-a",
Tick = 5,
MoveX = 0f,
MoveY = -1f
}), PeerA);
runtime.DrainPendingMessagesAsync().GetAwaiter().GetResult();
runtime.UpdateAuthoritativeMovement(TimeSpan.FromMilliseconds(50));
Assert.That(createdTransports[9000].BroadcastMessages.Count, Is.EqualTo(1));
var broadcast = ParsePlayerState(createdTransports[9000].BroadcastMessages[0]);
Assert.That(broadcast.Tick, Is.EqualTo(1));
Assert.That(broadcast.Position.X, Is.EqualTo(0f).Within(0.0001f));
Assert.That(broadcast.Position.Z, Is.EqualTo(-0.3f).Within(0.0001f));
Assert.That(broadcast.Velocity.Z, Is.EqualTo(-6f).Within(0.0001f));
}
private static FakeTransport CreateTransport(IDictionary<int, FakeTransport> createdTransports, int port)
{
var transport = new FakeTransport();
createdTransports.Add(port, transport);
return transport;
}
private static byte[] BuildEnvelope(MessageType type, IMessage payload)
{
return new Envelope
{
Type = (int)type,
Payload = payload.ToByteString()
}.ToByteArray();
}
private static PlayerState ParsePlayerState(byte[] envelopeBytes)
{
var envelope = Envelope.Parser.ParseFrom(envelopeBytes);
Assert.That((MessageType)envelope.Type, Is.EqualTo(MessageType.PlayerState));
return PlayerState.Parser.ParseFrom(envelope.Payload);
}
private sealed class FakeTransport : ITransport
{
public List<byte[]> BroadcastMessages { get; } = new();
public event Action<byte[], IPEndPoint> OnReceive;
public Task StartAsync()
{
return Task.CompletedTask;
}
public void Stop()
{
}
public void Send(byte[] data)
{
}
public void SendTo(byte[] data, IPEndPoint target)
{
}
public void SendToAll(byte[] data)
{
BroadcastMessages.Add(Copy(data));
}
public void EmitReceive(byte[] data, IPEndPoint sender)
{
OnReceive?.Invoke(Copy(data), sender);
}
private static byte[] Copy(byte[] data)
{
if (data == null)
{
return null;
}
var copy = new byte[data.Length];
Array.Copy(data, copy, data.Length);
return copy;
}
}
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 53ea735df3ee46e7b66c108f8c9c1d55
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant: