diff --git a/Assets/Scripts/Network/NetworkApplication/NetworkIntegrationFactory.cs b/Assets/Scripts/Network/NetworkApplication/NetworkIntegrationFactory.cs index d3a1fb5..96b6816 100644 --- a/Assets/Scripts/Network/NetworkApplication/NetworkIntegrationFactory.cs +++ b/Assets/Scripts/Network/NetworkApplication/NetworkIntegrationFactory.cs @@ -58,7 +58,8 @@ namespace Network.NetworkApplication Func utcNowProvider = null, IMessageDeliveryPolicyResolver deliveryPolicyResolver = null, SyncSequenceTracker syncSequenceTracker = null, - Func transportFactory = null) + Func transportFactory = null, + ServerAuthoritativeMovementConfiguration authoritativeMovement = null) { ValidateDualPortConfiguration(reliablePort, syncPort); @@ -82,7 +83,8 @@ namespace Network.NetworkApplication utcNowProvider, syncTransport, deliveryPolicyResolver, - syncSequenceTracker); + syncSequenceTracker, + authoritativeMovement); } public static Task StartServerRuntimeAsync(ServerRuntimeConfiguration configuration) diff --git a/Assets/Scripts/Network/NetworkHost/ServerAuthoritativeMovementConfiguration.cs b/Assets/Scripts/Network/NetworkHost/ServerAuthoritativeMovementConfiguration.cs new file mode 100644 index 0000000..2c97ee9 --- /dev/null +++ b/Assets/Scripts/Network/NetworkHost/ServerAuthoritativeMovementConfiguration.cs @@ -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."); + } + } + } +} diff --git a/Assets/Scripts/Network/NetworkHost/ServerAuthoritativeMovementConfiguration.cs.meta b/Assets/Scripts/Network/NetworkHost/ServerAuthoritativeMovementConfiguration.cs.meta new file mode 100644 index 0000000..d769ba4 --- /dev/null +++ b/Assets/Scripts/Network/NetworkHost/ServerAuthoritativeMovementConfiguration.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 8b8b5d0a5ff84ab696e3b302f92314e1 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Scripts/Network/NetworkHost/ServerAuthoritativeMovementCoordinator.cs b/Assets/Scripts/Network/NetworkHost/ServerAuthoritativeMovementCoordinator.cs new file mode 100644 index 0000000..7570aa3 --- /dev/null +++ b/Assets/Scripts/Network/NetworkHost/ServerAuthoritativeMovementCoordinator.cs @@ -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 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 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 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(); + 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; } + } + } +} diff --git a/Assets/Scripts/Network/NetworkHost/ServerAuthoritativeMovementCoordinator.cs.meta b/Assets/Scripts/Network/NetworkHost/ServerAuthoritativeMovementCoordinator.cs.meta new file mode 100644 index 0000000..217560d --- /dev/null +++ b/Assets/Scripts/Network/NetworkHost/ServerAuthoritativeMovementCoordinator.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 91c6c9377bb94c4f88cc4f37dbed16ab +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Scripts/Network/NetworkHost/ServerAuthoritativeMovementState.cs b/Assets/Scripts/Network/NetworkHost/ServerAuthoritativeMovementState.cs new file mode 100644 index 0000000..7672e27 --- /dev/null +++ b/Assets/Scripts/Network/NetworkHost/ServerAuthoritativeMovementState.cs @@ -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; } + } +} diff --git a/Assets/Scripts/Network/NetworkHost/ServerAuthoritativeMovementState.cs.meta b/Assets/Scripts/Network/NetworkHost/ServerAuthoritativeMovementState.cs.meta new file mode 100644 index 0000000..ed58ade --- /dev/null +++ b/Assets/Scripts/Network/NetworkHost/ServerAuthoritativeMovementState.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 0f2f1baf3a314f51a8d4b1caea7ee905 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Scripts/Network/NetworkHost/ServerNetworkHost.cs b/Assets/Scripts/Network/NetworkHost/ServerNetworkHost.cs index d4100ca..243759e 100644 --- a/Assets/Scripts/Network/NetworkHost/ServerNetworkHost.cs +++ b/Assets/Scripts/Network/NetworkHost/ServerNetworkHost.cs @@ -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 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 ManagedSessions => SessionCoordinator.Sessions; + public IReadOnlyList AuthoritativeMovementStates => authoritativeMovementCoordinator.States; + public event Action 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)) { diff --git a/Assets/Scripts/Network/NetworkHost/ServerRuntimeConfiguration.cs b/Assets/Scripts/Network/NetworkHost/ServerRuntimeConfiguration.cs index b68120e..d5b33c5 100644 --- a/Assets/Scripts/Network/NetworkHost/ServerRuntimeConfiguration.cs +++ b/Assets/Scripts/Network/NetworkHost/ServerRuntimeConfiguration.cs @@ -32,6 +32,8 @@ namespace Network.NetworkHost public Func 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(); } } } diff --git a/Assets/Scripts/Network/NetworkHost/ServerRuntimeEntryPoint.cs b/Assets/Scripts/Network/NetworkHost/ServerRuntimeEntryPoint.cs index 1155918..d6aed22 100644 --- a/Assets/Scripts/Network/NetworkHost/ServerRuntimeEntryPoint.cs +++ b/Assets/Scripts/Network/NetworkHost/ServerRuntimeEntryPoint.cs @@ -23,7 +23,8 @@ namespace Network.NetworkHost configuration.UtcNowProvider, configuration.DeliveryPolicyResolver, configuration.SyncSequenceTracker, - configuration.TransportFactory); + configuration.TransportFactory, + configuration.AuthoritativeMovement); try { diff --git a/Assets/Scripts/Network/NetworkHost/ServerRuntimeHandle.cs b/Assets/Scripts/Network/NetworkHost/ServerRuntimeHandle.cs index 27d2378..dae8b46 100644 --- a/Assets/Scripts/Network/NetworkHost/ServerRuntimeHandle.cs +++ b/Assets/Scripts/Network/NetworkHost/ServerRuntimeHandle.cs @@ -23,6 +23,8 @@ namespace Network.NetworkHost public IReadOnlyList ManagedSessions => host.ManagedSessions; + public IReadOnlyList AuthoritativeMovementStates => host.AuthoritativeMovementStates; + public event Action 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) diff --git a/Assets/Tests/EditMode/Network/ServerAuthoritativeMovementTests.cs b/Assets/Tests/EditMode/Network/ServerAuthoritativeMovementTests.cs new file mode 100644 index 0000000..d2d76c5 --- /dev/null +++ b/Assets/Tests/EditMode/Network/ServerAuthoritativeMovementTests.cs @@ -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(); + 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(); + 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(); + 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 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 BroadcastMessages { get; } = new(); + + public event Action 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; + } + } + } +} diff --git a/Assets/Tests/EditMode/Network/ServerAuthoritativeMovementTests.cs.meta b/Assets/Tests/EditMode/Network/ServerAuthoritativeMovementTests.cs.meta new file mode 100644 index 0000000..754ea4e --- /dev/null +++ b/Assets/Tests/EditMode/Network/ServerAuthoritativeMovementTests.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 53ea735df3ee46e7b66c108f8c9c1d55 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/TODO.md b/TODO.md index b3babbe..bfa79f6 100644 --- a/TODO.md +++ b/TODO.md @@ -26,11 +26,11 @@ Still missing for MVP: - [ ] Client-side `ShootInput` send path - [ ] Client-side `CombatEvent` receive/apply path - [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 - [ ] Full `PlayerState` field application for rotation / HP / velocity - [ ] 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 - [ ] 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 -- [ ] Register `MoveInput` handling on the server -- [ ] Maintain authoritative per-player movement state on the server -- [ ] Validate and apply move input before mutating authoritative state -- [ ] Use tick-aware stale filtering per peer without cross-peer interference -- [ ] Broadcast authoritative `PlayerState` snapshots on the sync lane at a fixed cadence -- [ ] Ensure zero-vector movement input stops authoritative movement +- [x] Register `MoveInput` handling on the server +- [x] Maintain authoritative per-player movement state on the server +- [x] Validate and apply move input before mutating authoritative state +- [x] Use tick-aware stale filtering per peer without cross-peer interference +- [x] Broadcast authoritative `PlayerState` snapshots on the sync lane at a fixed cadence +- [x] Ensure zero-vector movement input stops authoritative movement Acceptance: -- [ ] Server owns final position and movement resolution -- [ ] Clients receive authoritative `PlayerState` snapshots for reconciliation/interpolation -- [ ] Movement stop is reflected by server-authoritative state, not just local client visuals +- [x] Server owns final position and movement resolution +- [x] Clients receive authoritative `PlayerState` snapshots for reconciliation/interpolation +- [x] Movement stop is reflected by server-authoritative state, not just local client visuals ### 8. Implement Server-Authoritative Shooting And Combat Resolution @@ -155,11 +155,11 @@ Acceptance: ### 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 -- [ ] 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 `CombatEvent` receive/apply behavior - [ ] 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 at least one end-to-end fake-transport test that covers `MoveInput -> PlayerState` and `ShootInput -> CombatEvent` diff --git a/openspec/changes/archive/2026-03-29-implement-server-authoritative-movement-state-broadcast/.openspec.yaml b/openspec/changes/archive/2026-03-29-implement-server-authoritative-movement-state-broadcast/.openspec.yaml new file mode 100644 index 0000000..5e98b74 --- /dev/null +++ b/openspec/changes/archive/2026-03-29-implement-server-authoritative-movement-state-broadcast/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-03-29 diff --git a/openspec/changes/archive/2026-03-29-implement-server-authoritative-movement-state-broadcast/design.md b/openspec/changes/archive/2026-03-29-implement-server-authoritative-movement-state-broadcast/design.md new file mode 100644 index 0000000..843adf8 --- /dev/null +++ b/openspec/changes/archive/2026-03-29-implement-server-authoritative-movement-state-broadcast/design.md @@ -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. diff --git a/openspec/changes/archive/2026-03-29-implement-server-authoritative-movement-state-broadcast/proposal.md b/openspec/changes/archive/2026-03-29-implement-server-authoritative-movement-state-broadcast/proposal.md new file mode 100644 index 0000000..3dccc04 --- /dev/null +++ b/openspec/changes/archive/2026-03-29-implement-server-authoritative-movement-state-broadcast/proposal.md @@ -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. diff --git a/openspec/changes/archive/2026-03-29-implement-server-authoritative-movement-state-broadcast/specs/multi-session-lifecycle/spec.md b/openspec/changes/archive/2026-03-29-implement-server-authoritative-movement-state-broadcast/specs/multi-session-lifecycle/spec.md new file mode 100644 index 0000000..1168855 --- /dev/null +++ b/openspec/changes/archive/2026-03-29-implement-server-authoritative-movement-state-broadcast/specs/multi-session-lifecycle/spec.md @@ -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 diff --git a/openspec/changes/archive/2026-03-29-implement-server-authoritative-movement-state-broadcast/specs/server-authoritative-movement/spec.md b/openspec/changes/archive/2026-03-29-implement-server-authoritative-movement-state-broadcast/specs/server-authoritative-movement/spec.md new file mode 100644 index 0000000..4e23d31 --- /dev/null +++ b/openspec/changes/archive/2026-03-29-implement-server-authoritative-movement-state-broadcast/specs/server-authoritative-movement/spec.md @@ -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 diff --git a/openspec/changes/archive/2026-03-29-implement-server-authoritative-movement-state-broadcast/tasks.md b/openspec/changes/archive/2026-03-29-implement-server-authoritative-movement-state-broadcast/tasks.md new file mode 100644 index 0000000..5e1f540 --- /dev/null +++ b/openspec/changes/archive/2026-03-29-implement-server-authoritative-movement-state-broadcast/tasks.md @@ -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. diff --git a/openspec/specs/multi-session-lifecycle/spec.md b/openspec/specs/multi-session-lifecycle/spec.md index 03b2b2b..4b6ecd4 100644 --- a/openspec/specs/multi-session-lifecycle/spec.md +++ b/openspec/specs/multi-session-lifecycle/spec.md @@ -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 ### 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 - **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** 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 The multi-session lifecycle coordinator SHALL support explicit removal or disconnection handling for one managed session without resetting unrelated sessions that remain active. diff --git a/openspec/specs/server-authoritative-movement/spec.md b/openspec/specs/server-authoritative-movement/spec.md new file mode 100644 index 0000000..01deb7b --- /dev/null +++ b/openspec/specs/server-authoritative-movement/spec.md @@ -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