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

View File

@ -58,7 +58,8 @@ namespace Network.NetworkApplication
Func<DateTimeOffset> utcNowProvider = null, Func<DateTimeOffset> utcNowProvider = null,
IMessageDeliveryPolicyResolver deliveryPolicyResolver = null, IMessageDeliveryPolicyResolver deliveryPolicyResolver = null,
SyncSequenceTracker syncSequenceTracker = null, SyncSequenceTracker syncSequenceTracker = null,
Func<int, ITransport> transportFactory = null) Func<int, ITransport> transportFactory = null,
ServerAuthoritativeMovementConfiguration authoritativeMovement = null)
{ {
ValidateDualPortConfiguration(reliablePort, syncPort); ValidateDualPortConfiguration(reliablePort, syncPort);
@ -82,7 +83,8 @@ namespace Network.NetworkApplication
utcNowProvider, utcNowProvider,
syncTransport, syncTransport,
deliveryPolicyResolver, deliveryPolicyResolver,
syncSequenceTracker); syncSequenceTracker,
authoritativeMovement);
} }
public static Task<ServerRuntimeHandle> StartServerRuntimeAsync(ServerRuntimeConfiguration configuration) public static Task<ServerRuntimeHandle> StartServerRuntimeAsync(ServerRuntimeConfiguration configuration)

View File

@ -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.");
}
}
}
}

View File

@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 8b8b5d0a5ff84ab696e3b302f92314e1
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View File

@ -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; }
}
}
}

View File

@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 91c6c9377bb94c4f88cc4f37dbed16ab
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View File

@ -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; }
}
}

View File

@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 0f2f1baf3a314f51a8d4b1caea7ee905
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View File

@ -1,7 +1,8 @@
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.Net; using System.Net;
using System.Threading.Tasks; using System.Threading.Tasks;
using Network.Defines;
using Network.NetworkApplication; using Network.NetworkApplication;
using Network.NetworkTransport; using Network.NetworkTransport;
@ -12,6 +13,7 @@ namespace Network.NetworkHost
private readonly ITransport transport; private readonly ITransport transport;
private readonly ITransport syncTransport; private readonly ITransport syncTransport;
private readonly MessageManager messageManager; private readonly MessageManager messageManager;
private readonly ServerAuthoritativeMovementCoordinator authoritativeMovementCoordinator;
public ServerNetworkHost( public ServerNetworkHost(
ITransport transport, ITransport transport,
@ -20,7 +22,8 @@ namespace Network.NetworkHost
Func<DateTimeOffset> utcNowProvider = null, Func<DateTimeOffset> utcNowProvider = null,
ITransport syncTransport = null, ITransport syncTransport = null,
IMessageDeliveryPolicyResolver deliveryPolicyResolver = null, IMessageDeliveryPolicyResolver deliveryPolicyResolver = null,
SyncSequenceTracker syncSequenceTracker = null) SyncSequenceTracker syncSequenceTracker = null,
ServerAuthoritativeMovementConfiguration authoritativeMovement = null)
{ {
this.transport = transport ?? throw new ArgumentNullException(nameof(transport)); this.transport = transport ?? throw new ArgumentNullException(nameof(transport));
this.syncTransport = syncTransport; this.syncTransport = syncTransport;
@ -37,6 +40,11 @@ namespace Network.NetworkHost
deliveryPolicyResolver ?? new DefaultMessageDeliveryPolicyResolver(), deliveryPolicyResolver ?? new DefaultMessageDeliveryPolicyResolver(),
this.syncTransport, this.syncTransport,
syncSequenceTracker ?? new SyncSequenceTracker()); syncSequenceTracker ?? new SyncSequenceTracker());
authoritativeMovementCoordinator = new ServerAuthoritativeMovementCoordinator(
this,
messageManager,
authoritativeMovement ?? new ServerAuthoritativeMovementConfiguration());
messageManager.RegisterHandler(MessageType.MoveInput, authoritativeMovementCoordinator.HandleMoveInputAsync);
} }
public MessageManager MessageManager => messageManager; public MessageManager MessageManager => messageManager;
@ -45,11 +53,12 @@ namespace Network.NetworkHost
public ITransport SyncTransport => syncTransport; public ITransport SyncTransport => syncTransport;
// Server-side lifecycle entry point: inspect and control per-peer session state here.
public MultiSessionManager SessionCoordinator { get; } public MultiSessionManager SessionCoordinator { get; }
public IReadOnlyList<ManagedNetworkSession> ManagedSessions => SessionCoordinator.Sessions; public IReadOnlyList<ManagedNetworkSession> ManagedSessions => SessionCoordinator.Sessions;
public IReadOnlyList<ServerAuthoritativeMovementState> AuthoritativeMovementStates => authoritativeMovementCoordinator.States;
public event Action<MultiSessionLifecycleEvent> LifecycleChanged public event Action<MultiSessionLifecycleEvent> LifecycleChanged
{ {
add => SessionCoordinator.LifecycleChanged += value; add => SessionCoordinator.LifecycleChanged += value;
@ -77,6 +86,7 @@ namespace Network.NetworkHost
} }
SessionCoordinator.RemoveAllSessions("Transport stopped"); SessionCoordinator.RemoveAllSessions("Transport stopped");
authoritativeMovementCoordinator.Clear();
PublishMetricsSessionSnapshots(); PublishMetricsSessionSnapshots();
} }
@ -91,11 +101,21 @@ namespace Network.NetworkHost
PublishMetricsSessionSnapshots(); PublishMetricsSessionSnapshots();
} }
public void UpdateAuthoritativeMovement(TimeSpan elapsed)
{
authoritativeMovementCoordinator.Update(elapsed);
}
public bool TryGetSession(IPEndPoint remoteEndPoint, out ManagedNetworkSession session) public bool TryGetSession(IPEndPoint remoteEndPoint, out ManagedNetworkSession session)
{ {
return SessionCoordinator.TryGetSession(remoteEndPoint, out 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) public void NotifyLoginStarted(IPEndPoint remoteEndPoint)
{ {
SessionCoordinator.NotifyLoginStarted(remoteEndPoint); SessionCoordinator.NotifyLoginStarted(remoteEndPoint);
@ -151,6 +171,8 @@ namespace Network.NetworkHost
return false; return false;
} }
authoritativeMovementCoordinator.RemoveState(remoteEndPoint);
RecordMetricsSessionSnapshot(transport, "server-host", session, ConnectionState.Disconnected); RecordMetricsSessionSnapshot(transport, "server-host", session, ConnectionState.Disconnected);
if (syncTransport != null && !ReferenceEquals(syncTransport, transport)) if (syncTransport != null && !ReferenceEquals(syncTransport, transport))
{ {

View File

@ -32,6 +32,8 @@ namespace Network.NetworkHost
public Func<int, ITransport> TransportFactory { get; set; } public Func<int, ITransport> TransportFactory { get; set; }
public ServerAuthoritativeMovementConfiguration AuthoritativeMovement { get; set; }
internal void Validate() internal void Validate()
{ {
if (ReliablePort <= 0) if (ReliablePort <= 0)
@ -39,20 +41,20 @@ namespace Network.NetworkHost
throw new ArgumentOutOfRangeException(nameof(ReliablePort), "Reliable port must be positive."); 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) AuthoritativeMovement?.Validate();
{
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));
}
} }
} }
} }

View File

@ -23,7 +23,8 @@ namespace Network.NetworkHost
configuration.UtcNowProvider, configuration.UtcNowProvider,
configuration.DeliveryPolicyResolver, configuration.DeliveryPolicyResolver,
configuration.SyncSequenceTracker, configuration.SyncSequenceTracker,
configuration.TransportFactory); configuration.TransportFactory,
configuration.AuthoritativeMovement);
try try
{ {

View File

@ -23,6 +23,8 @@ namespace Network.NetworkHost
public IReadOnlyList<ManagedNetworkSession> ManagedSessions => host.ManagedSessions; public IReadOnlyList<ManagedNetworkSession> ManagedSessions => host.ManagedSessions;
public IReadOnlyList<ServerAuthoritativeMovementState> AuthoritativeMovementStates => host.AuthoritativeMovementStates;
public event Action<MultiSessionLifecycleEvent> LifecycleChanged public event Action<MultiSessionLifecycleEvent> LifecycleChanged
{ {
add => host.LifecycleChanged += value; add => host.LifecycleChanged += value;
@ -39,11 +41,21 @@ namespace Network.NetworkHost
host.UpdateLifecycle(); host.UpdateLifecycle();
} }
public void UpdateAuthoritativeMovement(TimeSpan elapsed)
{
host.UpdateAuthoritativeMovement(elapsed);
}
public bool TryGetSession(IPEndPoint remoteEndPoint, out ManagedNetworkSession session) public bool TryGetSession(IPEndPoint remoteEndPoint, out ManagedNetworkSession session)
{ {
return host.TryGetSession(remoteEndPoint, out session); return host.TryGetSession(remoteEndPoint, out session);
} }
public bool TryGetAuthoritativeMovementState(IPEndPoint remoteEndPoint, out ServerAuthoritativeMovementState state)
{
return host.TryGetAuthoritativeMovementState(remoteEndPoint, out state);
}
public void Stop() public void Stop()
{ {
if (isStopped) if (isStopped)

View File

@ -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;
}
}
}
}

View File

@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 53ea735df3ee46e7b66c108f8c9c1d55
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

26
TODO.md
View File

@ -26,11 +26,11 @@ Still missing for MVP:
- [ ] Client-side `ShootInput` send path - [ ] Client-side `ShootInput` send path
- [ ] Client-side `CombatEvent` receive/apply path - [ ] Client-side `CombatEvent` receive/apply path
- [x] Server startup path that actually uses `ServerNetworkHost` - [x] Server startup path that actually uses `ServerNetworkHost`
- [ ] Server-authoritative movement/state loop - [x] Server-authoritative movement/state loop
- [ ] Server-authoritative shooting/combat resolution loop - [ ] Server-authoritative shooting/combat resolution loop
- [ ] Full `PlayerState` field application for rotation / HP / velocity - [ ] Full `PlayerState` field application for rotation / HP / velocity
- [ ] Remote-player snapshot buffering and interpolation strategy - [ ] Remote-player snapshot buffering and interpolation strategy
- [ ] Explicit movement-stop handling via zero-input `MoveInput` - [x] Explicit movement-stop handling via zero-input `MoveInput`
- [ ] End-to-end gameplay regression coverage - [ ] End-to-end gameplay regression coverage
- [ ] Re-run build/test in an environment with the required .NET runtime installed - [ ] Re-run build/test in an environment with the required .NET runtime installed
@ -124,18 +124,18 @@ Acceptance:
### 7. Implement Server-Authoritative Movement And State Broadcast ### 7. Implement Server-Authoritative Movement And State Broadcast
- [ ] Register `MoveInput` handling on the server - [x] Register `MoveInput` handling on the server
- [ ] Maintain authoritative per-player movement state on the server - [x] Maintain authoritative per-player movement state on the server
- [ ] Validate and apply move input before mutating authoritative state - [x] Validate and apply move input before mutating authoritative state
- [ ] Use tick-aware stale filtering per peer without cross-peer interference - [x] Use tick-aware stale filtering per peer without cross-peer interference
- [ ] Broadcast authoritative `PlayerState` snapshots on the sync lane at a fixed cadence - [x] Broadcast authoritative `PlayerState` snapshots on the sync lane at a fixed cadence
- [ ] Ensure zero-vector movement input stops authoritative movement - [x] Ensure zero-vector movement input stops authoritative movement
Acceptance: Acceptance:
- [ ] Server owns final position and movement resolution - [x] Server owns final position and movement resolution
- [ ] Clients receive authoritative `PlayerState` snapshots for reconciliation/interpolation - [x] Clients receive authoritative `PlayerState` snapshots for reconciliation/interpolation
- [ ] Movement stop is reflected by server-authoritative state, not just local client visuals - [x] Movement stop is reflected by server-authoritative state, not just local client visuals
### 8. Implement Server-Authoritative Shooting And Combat Resolution ### 8. Implement Server-Authoritative Shooting And Combat Resolution
@ -155,11 +155,11 @@ Acceptance:
### 9. Expand Regression Coverage From Network Layer To Gameplay Flow ### 9. Expand Regression Coverage From Network Layer To Gameplay Flow
- [ ] Extend [`Assets/Tests/EditMode/Network/MessageManagerTests.cs`](./Assets/Tests/EditMode/Network/MessageManagerTests.cs) only as needed for lane policy regressions - [ ] Extend [`Assets/Tests/EditMode/Network/MessageManagerTests.cs`](./Assets/Tests/EditMode/Network/MessageManagerTests.cs) only as needed for lane policy regressions
- [ ] Add tests that cover explicit zero-input movement stop behavior - [x] Add tests that cover explicit zero-input movement stop behavior
- [ ] Add tests for client `ShootInput` send routing - [ ] Add tests for client `ShootInput` send routing
- [ ] Add tests for `CombatEvent` receive/apply behavior - [ ] Add tests for `CombatEvent` receive/apply behavior
- [ ] Add tests for remote `PlayerState` buffering / interpolation decisions where practical - [ ] Add tests for remote `PlayerState` buffering / interpolation decisions where practical
- [ ] Add tests for server-authoritative movement processing - [x] Add tests for server-authoritative movement processing
- [ ] Add tests for server-authoritative shooting/combat result generation - [ ] Add tests for server-authoritative shooting/combat result generation
- [ ] Add at least one end-to-end fake-transport test that covers `MoveInput -> PlayerState` and `ShootInput -> CombatEvent` - [ ] Add at least one end-to-end fake-transport test that covers `MoveInput -> PlayerState` and `ShootInput -> CombatEvent`

View File

@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-03-29

View File

@ -0,0 +1,61 @@
## Context
`ServerNetworkHost` already owns transport startup, message draining, and `MultiSessionManager`, but it does not yet register gameplay handlers or retain any authoritative movement model per peer. `MessageManager` can already route `MoveInput` and `PlayerState` across reliable/sync lanes, and `SyncSequenceTracker` already accepts stale filtering rules for high-frequency messages, but the server currently lacks a component that turns accepted `MoveInput` into authoritative state and periodic `PlayerState` output.
This change needs to stay inside the shared networking/server code under `Assets/Scripts/Network/` so the authoritative loop remains host-agnostic and testable in edit-mode tests. The client single-session path and existing dual-transport startup contract must remain intact.
## Goals / Non-Goals
**Goals:**
- Add a server-side movement authority component that registers `MoveInput` handling and owns authoritative per-player movement state.
- Keep stale filtering and last-accepted tick tracking independent for each peer so multi-session traffic cannot interfere across connections.
- Broadcast authoritative `PlayerState` snapshots at a fixed cadence on the existing sync lane contract.
- Make zero-vector `MoveInput` stop authoritative movement instead of relying on client-only visuals.
- Keep the runtime entry point easy to test from fake transports and edit-mode regression tests.
**Non-Goals:**
- Implement shooting, combat resolution, or authoritative HP changes beyond preserving fields needed by `PlayerState` broadcasting.
- Introduce Unity-specific frame-loop dependencies into shared networking code.
- Replace the existing client reconciliation/interpolation logic in this change.
## Decisions
### 1. Introduce a dedicated server-authoritative movement coordinator
The server needs a focused component, owned by the server host/runtime, that accepts decoded `MoveInput`, validates it, mutates authoritative state, and produces broadcast snapshots. Extending `ServerNetworkHost` with orchestration hooks is appropriate because it already owns `MessageManager`, transport lifetime, and access to `MultiSessionManager`, but the movement rules themselves should live in a dedicated authority/coordinator type rather than being spread across `ServerRuntimeHandle` and ad-hoc handlers.
Alternative considered: put movement mutation directly inside `MultiSessionManager`. Rejected because `MultiSessionManager` currently owns generic lifecycle state, not gameplay simulation rules or snapshot broadcast cadence.
### 2. Store authoritative movement state per managed peer
The authoritative state must be keyed per remote peer and include the last accepted movement tick, current position, facing/rotation, current velocity, and current movement intent. That state can be attached alongside `ManagedNetworkSession` ownership or maintained in a peer-keyed store owned by the authority coordinator, but the key requirement is that all stale-input evaluation and movement mutation remain peer-scoped.
Alternative considered: a single global last-move tick tracker. Rejected because it would let one peer's late or advanced traffic affect another peer's acceptance window.
### 3. Reuse the existing message routing and sync lane contract for `PlayerState`
The new authority loop should keep using `MessageManager` and the current delivery policy resolver rather than inventing a separate server broadcast channel. Authoritative snapshots remain ordinary `PlayerState` messages, which preserves the existing client reconciliation path and keeps the sync-lane policy centralized.
Alternative considered: special-case `PlayerState` broadcast outside `MessageManager`. Rejected because it would duplicate lane-selection logic and make regression coverage harder.
### 4. Drive authoritative simulation and broadcast with explicit server ticks/cadence hooks
The server runtime already exposes message draining and lifecycle updates through `ServerRuntimeHandle`. This change should add or define a similarly explicit authority update hook so hosts can advance movement resolution and emit snapshots on a known cadence. The cadence source should be injectable/testable so edit-mode tests can deterministically assert broadcast timing and stale-filter behavior.
Alternative considered: only mutate state when input arrives and broadcast immediately. Rejected because clients need regular authoritative `PlayerState` output for reconciliation and interpolation, including periods where input is zero and state is stable.
## Risks / Trade-offs
- [Risk] Additional server-side state per peer increases lifecycle cleanup complexity. → Mitigation: tie authoritative movement state ownership to the same per-peer registration and removal flow used by `MultiSessionManager`.
- [Risk] Cadence-driven broadcasting can spam unchanged snapshots or create unnecessary test brittleness. → Mitigation: keep cadence configuration explicit and default to a small fixed interval that tests can control.
- [Risk] Validation rules may be underspecified for MVP movement. → Mitigation: keep initial validation narrow and deterministic (peer identity, monotonic tick acceptance, finite vector input, zero-vector stop) and leave richer anti-cheat rules for later changes.
- [Risk] Extending shared server code can accidentally affect the client single-session path. → Mitigation: keep all new authority types behind the server host/runtime path and add regression tests that cover both reliable-only and dual-lane server setups.
## Migration Plan
1. Add the new capability/spec coverage for server-authoritative movement and the per-peer multi-session requirement update.
2. Introduce the server authority coordinator and peer movement state model in shared server code.
3. Register `MoveInput` handling through the server host/runtime composition path and expose an explicit authority update/broadcast cadence hook.
4. Add edit-mode regression tests for per-peer stale filtering, zero-vector stop, and sync-lane `PlayerState` broadcasting.
5. Re-run build/test once the .NET runtime environment is available.
## Open Questions
- Whether authoritative position integration should use a simple fixed-speed MVP model or plug into an existing gameplay movement service outside `Assets/Scripts/Network/`.
- Whether the first implementation should broadcast every cadence tick for all managed peers or suppress unchanged snapshots once client reconciliation coverage is confirmed.

View File

@ -0,0 +1,22 @@
## Why
The networking layer can now start a real server runtime, but the server still does not own player movement or produce authoritative `PlayerState` snapshots. Until that loop exists, client reconciliation and remote interpolation remain disconnected from actual server truth, so the MVP still relies on local visuals instead of authoritative simulation.
## What Changes
- Add a concrete server-authoritative movement capability that accepts `MoveInput`, validates it per peer, updates authoritative movement state, and emits `PlayerState` snapshots on a fixed sync cadence.
- Introduce explicit server-side movement ownership for position, velocity, rotation, and last accepted movement tick so zero-vector input can stop movement through server truth.
- Keep stale-input filtering peer-scoped so one client's out-of-order `MoveInput` packets cannot suppress another client's movement updates.
- Define the server broadcast contract for authoritative `PlayerState` snapshots so clients can reconcile the local player and interpolate remote players from server output.
## Capabilities
### New Capabilities
- `server-authoritative-movement`: Server-side handling of `MoveInput`, authoritative movement state mutation, and fixed-cadence `PlayerState` broadcast.
### Modified Capabilities
- `multi-session-lifecycle`: Server multi-session coordination also tracks authoritative movement state and stale-input evaluation independently for each managed peer.
## Impact
Affected areas include the shared server host/runtime under `Assets/Scripts/Network/`, server-side gameplay state ownership, authoritative `PlayerState` broadcast wiring, and edit-mode regression coverage for multi-peer movement handling.

View File

@ -0,0 +1,19 @@
## MODIFIED Requirements
### Requirement: Multi-session hosts can observe and evaluate each managed session
The multi-session lifecycle coordinator SHALL expose per-session lookup or enumeration and MUST evaluate timeout, heartbeat, login, reconnect, and authoritative movement tick rules for each managed session independently using the shared session lifecycle vocabulary. Server-side stale-input acceptance and authoritative movement tracking MUST remain scoped to the peer that produced the traffic.
#### Scenario: Timeout affects only one managed session
- **WHEN** one managed session stops receiving liveness updates while another session continues receiving heartbeat or message activity
- **THEN** the timed-out session transitions through timeout or reconnect states according to policy
- **THEN** the active session remains in its current healthy state
#### Scenario: Host can inspect current managed sessions
- **WHEN** server-side code needs to inspect the current connection state of connected peers
- **THEN** it can look up or enumerate managed sessions through the multi-session coordinator
- **THEN** each entry exposes the shared session lifecycle state for that specific peer
#### Scenario: Movement tick filtering remains peer-scoped
- **WHEN** two managed peers send `MoveInput` traffic with different tick progress or ordering
- **THEN** stale-input acceptance is evaluated independently for each managed peer
- **THEN** one peer's late or advanced movement input does not overwrite or suppress the other's authoritative movement state

View File

@ -0,0 +1,40 @@
## ADDED Requirements
### Requirement: Server registers and validates `MoveInput` per peer
The shared server networking path SHALL register `MoveInput` handling through the server host/runtime composition and SHALL validate inbound movement input against the sending peer before mutating authoritative state. Validation MUST reject stale ticks for that peer, malformed numeric values, and payloads that do not map to the sender's managed movement state.
#### Scenario: Accepted `MoveInput` updates the sender's authoritative movement intent
- **WHEN** a managed peer sends a well-formed `MoveInput` with a tick newer than the last accepted movement tick for that peer
- **THEN** the server accepts the input for that peer only
- **THEN** the sender's authoritative movement intent and last accepted movement tick are updated
#### Scenario: Stale `MoveInput` is rejected without affecting other peers
- **WHEN** one managed peer sends a `MoveInput` whose tick is older than the last accepted movement tick for that same peer
- **THEN** the server rejects that input for that peer
- **THEN** authoritative movement state for other managed peers remains unchanged
### Requirement: Server owns authoritative movement resolution
The shared server networking path SHALL own the final movement state for each managed peer, including position, rotation, velocity, and stop state. Zero-vector movement input MUST stop authoritative movement rather than leaving the peer in its previous moving state.
#### Scenario: Non-zero input advances authoritative movement state
- **WHEN** the server processes an accepted non-zero `MoveInput` for a managed peer during an authority update step
- **THEN** the server updates that peer's authoritative position, rotation, and velocity from server-side movement resolution
- **THEN** the resulting state becomes the source of truth for later `PlayerState` broadcast
#### Scenario: Zero-vector input stops authoritative movement
- **WHEN** the server processes an accepted zero-vector `MoveInput` for a managed peer
- **THEN** the peer's authoritative velocity becomes zero
- **THEN** subsequent authoritative state snapshots reflect that stopped state until a newer movement input is accepted
### Requirement: Server broadcasts authoritative `PlayerState` snapshots on the sync cadence
The shared server networking path SHALL emit authoritative `PlayerState` snapshots for managed peers at a fixed cadence using the existing sync-lane message contract. Each snapshot MUST be derived from the server-owned movement state and include the authoritative tick for client reconciliation and interpolation.
#### Scenario: Authority update step emits sync-lane player snapshots
- **WHEN** the server reaches a configured authority broadcast cadence while one or more managed peers have authoritative movement state
- **THEN** it sends `PlayerState` snapshots using the sync-lane delivery policy when a distinct sync transport exists
- **THEN** each snapshot includes the authoritative position, rotation, velocity, and tick from server-owned state
#### Scenario: Reliable transport remains fallback when no sync transport exists
- **WHEN** the server broadcasts authoritative `PlayerState` snapshots without a dedicated sync transport
- **THEN** the shared routing path still emits `PlayerState` through the existing fallback lane behavior
- **THEN** the authoritative snapshot contract remains unchanged

View File

@ -0,0 +1,17 @@
## 1. Server Authority Core
- [x] 1.1 Add a dedicated server-authoritative movement coordinator and per-peer authoritative movement state model under `Assets/Scripts/Network/`.
- [x] 1.2 Register `MoveInput` handling through the server host/runtime composition path and validate sender-scoped movement payloads before accepting them.
- [x] 1.3 Keep stale movement tick acceptance independent per managed peer and ensure zero-vector input clears authoritative movement velocity.
## 2. Authoritative Snapshot Broadcast
- [x] 2.1 Add an explicit server authority update hook that advances authoritative movement resolution on a fixed cadence.
- [x] 2.2 Broadcast authoritative `PlayerState` snapshots through the existing `MessageManager` sync-lane contract, with reliable fallback when no sync transport exists.
- [x] 2.3 Expose the minimal runtime/host surface needed for host processes and tests to drive movement authority updates and inspect authoritative peer state.
## 3. Regression Coverage And Documentation
- [x] 3.1 Add edit-mode regression tests for accepted vs stale `MoveInput` handling across multiple peers.
- [x] 3.2 Add edit-mode regression tests for zero-vector movement stop and fixed-cadence `PlayerState` broadcasting on sync and fallback lanes.
- [x] 3.3 Update `TODO.md` and related change tracking/docs to reflect the completed server-authoritative movement/state broadcast work.

View File

@ -13,7 +13,7 @@ The shared networking core SHALL provide a multi-session lifecycle coordinator f
- **THEN** lifecycle changes for one peer do not overwrite or hide the state of the other peer - **THEN** lifecycle changes for one peer do not overwrite or hide the state of the other peer
### Requirement: Multi-session hosts can observe and evaluate each managed session ### Requirement: Multi-session hosts can observe and evaluate each managed session
The multi-session lifecycle coordinator SHALL expose per-session lookup or enumeration and MUST evaluate timeout, heartbeat, login, and reconnect rules for each managed session independently using the shared session lifecycle vocabulary. The multi-session lifecycle coordinator SHALL expose per-session lookup or enumeration and MUST evaluate timeout, heartbeat, login, reconnect, and authoritative movement tick rules for each managed session independently using the shared session lifecycle vocabulary. Server-side stale-input acceptance and authoritative movement tracking MUST remain scoped to the peer that produced the traffic.
#### Scenario: Timeout affects only one managed session #### Scenario: Timeout affects only one managed session
- **WHEN** one managed session stops receiving liveness updates while another session continues receiving heartbeat or message activity - **WHEN** one managed session stops receiving liveness updates while another session continues receiving heartbeat or message activity
@ -25,6 +25,11 @@ The multi-session lifecycle coordinator SHALL expose per-session lookup or enume
- **THEN** it can look up or enumerate managed sessions through the multi-session coordinator - **THEN** it can look up or enumerate managed sessions through the multi-session coordinator
- **THEN** each entry exposes the shared session lifecycle state for that specific peer - **THEN** each entry exposes the shared session lifecycle state for that specific peer
#### Scenario: Movement tick filtering remains peer-scoped
- **WHEN** two managed peers send `MoveInput` traffic with different tick progress or ordering
- **THEN** stale-input acceptance is evaluated independently for each managed peer
- **THEN** one peer's late or advanced movement input does not overwrite or suppress the other's authoritative movement state
### Requirement: Session removal is explicit and does not corrupt remaining peers ### Requirement: Session removal is explicit and does not corrupt remaining peers
The multi-session lifecycle coordinator SHALL support explicit removal or disconnection handling for one managed session without resetting unrelated sessions that remain active. The multi-session lifecycle coordinator SHALL support explicit removal or disconnection handling for one managed session without resetting unrelated sessions that remain active.

View File

@ -0,0 +1,44 @@
# server-authoritative-movement Specification
## Purpose
Define the shared server-side movement authority contract that accepts `MoveInput`, mutates authoritative per-peer movement state, and broadcasts authoritative `PlayerState` snapshots for client reconciliation and interpolation.
## Requirements
### Requirement: Server registers and validates `MoveInput` per peer
The shared server networking path SHALL register `MoveInput` handling through the server host/runtime composition and SHALL validate inbound movement input against the sending peer before mutating authoritative state. Validation MUST reject stale ticks for that peer, malformed numeric values, and payloads that do not map to the sender's managed movement state.
#### Scenario: Accepted `MoveInput` updates the sender's authoritative movement intent
- **WHEN** a managed peer sends a well-formed `MoveInput` with a tick newer than the last accepted movement tick for that peer
- **THEN** the server accepts the input for that peer only
- **THEN** the sender's authoritative movement intent and last accepted movement tick are updated
#### Scenario: Stale `MoveInput` is rejected without affecting other peers
- **WHEN** one managed peer sends a `MoveInput` whose tick is older than the last accepted movement tick for that same peer
- **THEN** the server rejects that input for that peer
- **THEN** authoritative movement state for other managed peers remains unchanged
### Requirement: Server owns authoritative movement resolution
The shared server networking path SHALL own the final movement state for each managed peer, including position, rotation, velocity, and stop state. Zero-vector movement input MUST stop authoritative movement rather than leaving the peer in its previous moving state.
#### Scenario: Non-zero input advances authoritative movement state
- **WHEN** the server processes an accepted non-zero `MoveInput` for a managed peer during an authority update step
- **THEN** the server updates that peer's authoritative position, rotation, and velocity from server-side movement resolution
- **THEN** the resulting state becomes the source of truth for later `PlayerState` broadcast
#### Scenario: Zero-vector input stops authoritative movement
- **WHEN** the server processes an accepted zero-vector `MoveInput` for a managed peer
- **THEN** the peer's authoritative velocity becomes zero
- **THEN** subsequent authoritative state snapshots reflect that stopped state until a newer movement input is accepted
### Requirement: Server broadcasts authoritative `PlayerState` snapshots on the sync cadence
The shared server networking path SHALL emit authoritative `PlayerState` snapshots for managed peers at a fixed cadence using the existing sync-lane message contract. Each snapshot MUST be derived from the server-owned movement state and include the authoritative tick for client reconciliation and interpolation.
#### Scenario: Authority update step emits sync-lane player snapshots
- **WHEN** the server reaches a configured authority broadcast cadence while one or more managed peers have authoritative movement state
- **THEN** it sends `PlayerState` snapshots using the sync-lane delivery policy when a distinct sync transport exists
- **THEN** each snapshot includes the authoritative position, rotation, velocity, and tick from server-owned state
#### Scenario: Reliable transport remains fallback when no sync transport exists
- **WHEN** the server broadcasts authoritative `PlayerState` snapshots without a dedicated sync transport
- **THEN** the shared routing path still emits `PlayerState` through the existing fallback lane behavior
- **THEN** the authoritative snapshot contract remains unchanged