收口 Client 端,添加服务端约束

This commit is contained in:
SepComet
2026-03-29 15:06:17 +08:00
parent fbc09186f3
commit f6ca6fa6e4
12 changed files with 854 additions and 53 deletions
+26 -6
View File
@@ -29,6 +29,24 @@ public static class ClientGameplayInputFlow
return true;
}
public static bool TryCreateShootInput(
string playerId,
long tick,
bool fireTriggered,
Vector3 aimDirection,
out ShootInput message,
string targetId = "")
{
if (!fireTriggered)
{
message = null;
return false;
}
message = CreateShootInput(playerId, tick, aimDirection, targetId);
return true;
}
public static ShootInput CreateShootInput(string playerId, long tick, Vector3 aimDirection, string targetId = "")
{
var planarDirection = new Vector3(aimDirection.x, 0f, aimDirection.z);
@@ -210,12 +228,14 @@ public class MovementComponent : MonoBehaviour
private ShootInput CaptureShootInput()
{
if (!Input.GetMouseButtonDown(0))
{
return null;
}
return ClientGameplayInputFlow.CreateShootInput(_master.PlayerId, Tick, ResolveAimDirection());
return ClientGameplayInputFlow.TryCreateShootInput(
_master.PlayerId,
Tick,
Input.GetMouseButtonDown(0),
ResolveAimDirection(),
out var shootInput)
? shootInput
: null;
}
private Vector3 ResolveAimDirection()
@@ -165,8 +165,6 @@ namespace Network.NetworkHost
if (input == null ||
string.IsNullOrWhiteSpace(input.PlayerId) ||
string.IsNullOrWhiteSpace(input.TargetId) ||
string.Equals(input.PlayerId, input.TargetId, StringComparison.Ordinal) ||
!IsFinite(input.DirX) ||
!IsFinite(input.DirY))
{
@@ -179,8 +177,13 @@ namespace Network.NetworkHost
return false;
}
if (!movementCoordinator.TryGetState(sender, out attackerState) ||
!string.Equals(attackerState.PlayerId, input.PlayerId, StringComparison.Ordinal) ||
if (!movementCoordinator.TryGetState(sender, out attackerState) &&
!movementCoordinator.EnsureState(sender, input.PlayerId, out attackerState))
{
return false;
}
if (!string.Equals(attackerState.PlayerId, input.PlayerId, StringComparison.Ordinal) ||
attackerState.IsDead ||
attackerState.Hp <= 0 ||
input.Tick <= attackerState.LastAcceptedShootTick)
@@ -188,14 +191,93 @@ namespace Network.NetworkHost
return false;
}
if (!movementCoordinator.TryGetStateByPlayerId(input.TargetId, out targetState) ||
targetState.IsDead ||
targetState.Hp <= 0)
return TryResolveTargetState(input, attackerState, out targetState);
}
private bool TryResolveTargetState(
ShootInput input,
ServerAuthoritativeMovementState attackerState,
out ServerAuthoritativeMovementState targetState)
{
if (!string.IsNullOrWhiteSpace(input.TargetId))
{
if (string.Equals(attackerState.PlayerId, input.TargetId, StringComparison.Ordinal))
{
targetState = null;
return false;
}
if (!movementCoordinator.TryGetStateByPlayerId(input.TargetId, out targetState) ||
targetState.IsDead ||
targetState.Hp <= 0)
{
targetState = null;
return false;
}
return true;
}
return TryResolveTargetStateFromAim(input, attackerState, out targetState);
}
private bool TryResolveTargetStateFromAim(
ShootInput input,
ServerAuthoritativeMovementState attackerState,
out ServerAuthoritativeMovementState targetState)
{
targetState = null;
var aimLength = MathF.Sqrt((input.DirX * input.DirX) + (input.DirY * input.DirY));
if (aimLength <= 0f)
{
return false;
}
return true;
var aimX = input.DirX / aimLength;
var aimY = input.DirY / aimLength;
var bestAlignment = float.NegativeInfinity;
var bestDistanceSquared = float.PositiveInfinity;
foreach (var candidate in movementCoordinator.States)
{
if (candidate == null ||
string.Equals(candidate.PlayerId, attackerState.PlayerId, StringComparison.Ordinal) ||
candidate.IsDead ||
candidate.Hp <= 0)
{
continue;
}
var offsetX = candidate.PositionX - attackerState.PositionX;
var offsetY = candidate.PositionZ - attackerState.PositionZ;
var distanceSquared = (offsetX * offsetX) + (offsetY * offsetY);
if (distanceSquared <= 0f)
{
continue;
}
var inverseDistance = 1f / MathF.Sqrt(distanceSquared);
var alignment = (offsetX * inverseDistance * aimX) + (offsetY * inverseDistance * aimY);
if (alignment <= 0f)
{
continue;
}
if (targetState == null ||
alignment > bestAlignment + 0.0001f ||
(MathF.Abs(alignment - bestAlignment) <= 0.0001f &&
(distanceSquared < bestDistanceSquared - 0.0001f ||
(MathF.Abs(distanceSquared - bestDistanceSquared) <= 0.0001f &&
string.CompareOrdinal(candidate.PlayerId, targetState.PlayerId) < 0))))
{
targetState = candidate;
bestAlignment = alignment;
bestDistanceSquared = distanceSquared;
}
}
return targetState != null;
}
private void BroadcastRejectedShot(ShootInput input)
@@ -41,6 +41,46 @@ namespace Network.NetworkHost
}
}
public bool EnsureState(IPEndPoint remoteEndPoint, string playerId, out ServerAuthoritativeMovementState state)
{
if (remoteEndPoint == null)
{
throw new ArgumentNullException(nameof(remoteEndPoint));
}
if (string.IsNullOrWhiteSpace(playerId))
{
state = null;
return false;
}
var normalizedSender = Normalize(remoteEndPoint);
var key = normalizedSender.ToString();
lock (gate)
{
if (statesByPeer.TryGetValue(key, out var existingState))
{
if (!string.Equals(existingState.PlayerId, playerId, StringComparison.Ordinal))
{
state = null;
return false;
}
state = CloneState(existingState);
return true;
}
var createdState = new ServerAuthoritativeMovementState(
normalizedSender,
playerId,
configuration.DefaultHp);
statesByPeer.Add(key, createdState);
state = CloneState(createdState);
return true;
}
}
public Task HandleMoveInputAsync(byte[] payload, IPEndPoint sender)
{
if (payload == null || sender == null)
@@ -15,6 +15,8 @@ namespace Network.NetworkHost
private readonly MessageManager messageManager;
private readonly ServerAuthoritativeMovementCoordinator authoritativeMovementCoordinator;
private readonly ServerAuthoritativeCombatCoordinator authoritativeCombatCoordinator;
private readonly object playerIdentityGate = new();
private readonly Dictionary<string, string> playerIdsByPeer = new();
public ServerNetworkHost(
ITransport transport,
@@ -97,6 +99,10 @@ namespace Network.NetworkHost
SessionCoordinator.RemoveAllSessions("Transport stopped");
authoritativeMovementCoordinator.Clear();
authoritativeCombatCoordinator.Clear();
lock (playerIdentityGate)
{
playerIdsByPeer.Clear();
}
PublishMetricsSessionSnapshots();
}
@@ -140,12 +146,20 @@ namespace Network.NetworkHost
public void NotifyLoginSucceeded(IPEndPoint remoteEndPoint)
{
SessionCoordinator.NotifyLoginSucceeded(remoteEndPoint);
BootstrapAuthoritativeMovementState(remoteEndPoint);
PublishMetricsSessionSnapshot(remoteEndPoint);
}
public void NotifyLoginSucceeded(IPEndPoint remoteEndPoint, string playerId)
{
RememberPlayerId(remoteEndPoint, playerId);
NotifyLoginSucceeded(remoteEndPoint);
}
public void NotifyLoginFailed(IPEndPoint remoteEndPoint, string reason = null)
{
SessionCoordinator.NotifyLoginFailed(remoteEndPoint, reason);
ForgetPlayerId(remoteEndPoint);
PublishMetricsSessionSnapshot(remoteEndPoint);
}
@@ -188,6 +202,7 @@ namespace Network.NetworkHost
authoritativeMovementCoordinator.RemoveState(remoteEndPoint);
authoritativeCombatCoordinator.RemoveState(remoteEndPoint);
ForgetPlayerId(remoteEndPoint);
RecordMetricsSessionSnapshot(transport, "server-host", session, ConnectionState.Disconnected);
if (syncTransport != null && !ReferenceEquals(syncTransport, transport))
@@ -198,12 +213,111 @@ namespace Network.NetworkHost
return true;
}
private void HandleTransportReceive(byte[] _, IPEndPoint sender)
private void HandleTransportReceive(byte[] data, IPEndPoint sender)
{
SessionCoordinator.ObserveTransportActivity(sender);
ObservePlayerIdentity(data, sender);
PublishMetricsSessionSnapshot(sender);
}
private void BootstrapAuthoritativeMovementState(IPEndPoint remoteEndPoint)
{
if (!TryGetKnownPlayerId(remoteEndPoint, out var playerId))
{
return;
}
authoritativeMovementCoordinator.EnsureState(remoteEndPoint, playerId, out _);
}
private void ObservePlayerIdentity(byte[] data, IPEndPoint sender)
{
if (data == null || sender == null)
{
return;
}
Envelope envelope;
try
{
envelope = Envelope.Parser.ParseFrom(data);
}
catch
{
return;
}
if ((MessageType)envelope.Type != MessageType.LoginRequest)
{
return;
}
LoginRequest request;
try
{
request = LoginRequest.Parser.ParseFrom(envelope.Payload);
}
catch
{
return;
}
RememberPlayerId(sender, request.PlayerId);
}
private void RememberPlayerId(IPEndPoint remoteEndPoint, string playerId)
{
if (remoteEndPoint == null || string.IsNullOrWhiteSpace(playerId))
{
return;
}
var key = Normalize(remoteEndPoint).ToString();
lock (playerIdentityGate)
{
playerIdsByPeer[key] = playerId;
}
}
private bool TryGetKnownPlayerId(IPEndPoint remoteEndPoint, out string playerId)
{
playerId = null;
if (remoteEndPoint == null)
{
return false;
}
var key = Normalize(remoteEndPoint).ToString();
lock (playerIdentityGate)
{
return playerIdsByPeer.TryGetValue(key, out playerId);
}
}
private void ForgetPlayerId(IPEndPoint remoteEndPoint)
{
if (remoteEndPoint == null)
{
return;
}
var key = Normalize(remoteEndPoint).ToString();
lock (playerIdentityGate)
{
playerIdsByPeer.Remove(key);
}
}
private static IPEndPoint Normalize(IPEndPoint remoteEndPoint)
{
if (remoteEndPoint == null)
{
throw new ArgumentNullException(nameof(remoteEndPoint));
}
return new IPEndPoint(remoteEndPoint.Address, remoteEndPoint.Port);
}
private void PublishMetricsSessionSnapshots()
{
foreach (var session in ManagedSessions)
@@ -109,6 +109,133 @@ namespace Tests.EditMode.Network
Assert.That(remoteCombatPresentation.IsDead, Is.False);
}
[Test]
public void FakeTransportRoundTrip_IdleLoggedInPlayer_ReceivesPlayerState_AndCanShootWithoutMoveInput()
{
var clientReliableTransport = new GameplayFlowFakeTransport();
var clientSyncTransport = new GameplayFlowFakeTransport();
var clientRuntime = new SharedNetworkRuntime(
clientReliableTransport,
new MainThreadNetworkDispatcher(),
syncTransport: clientSyncTransport);
var clientHarness = new ClientGameplayTestHarness("player-a");
clientHarness.Register(clientRuntime.MessageManager);
var serverTransports = new Dictionary<int, GameplayFlowFakeTransport>();
var configuration = new ServerRuntimeConfiguration(9000)
{
SyncPort = 9001,
Dispatcher = new MainThreadNetworkDispatcher(),
TransportFactory = port => CreateTransport(serverTransports, port),
AuthoritativeMovement = new ServerAuthoritativeMovementConfiguration
{
MoveSpeed = 10f,
BroadcastInterval = TimeSpan.FromMilliseconds(50),
DefaultHp = 100
},
AuthoritativeCombat = new ServerAuthoritativeCombatConfiguration
{
DamagePerShot = 30
}
};
clientRuntime.StartAsync().GetAwaiter().GetResult();
using var serverRuntime = ServerRuntimeEntryPoint.StartAsync(configuration).GetAwaiter().GetResult();
serverTransports[9000].EmitReceive(
GameplayFlowTestSupport.BuildEnvelope(
MessageType.LoginRequest,
new LoginRequest
{
PlayerId = "player-a",
Speed = 5
}),
ClientPeer);
serverTransports[9000].EmitReceive(
GameplayFlowTestSupport.BuildEnvelope(
MessageType.LoginRequest,
new LoginRequest
{
PlayerId = "player-b",
Speed = 5
}),
RemotePeer);
serverRuntime.Host.NotifyLoginStarted(ClientPeer);
serverRuntime.Host.NotifyLoginSucceeded(ClientPeer);
serverRuntime.Host.NotifyLoginStarted(RemotePeer);
serverRuntime.Host.NotifyLoginSucceeded(RemotePeer);
serverRuntime.UpdateAuthoritativeMovement(TimeSpan.FromMilliseconds(50));
TransferBroadcastMessages(serverTransports[9001], clientSyncTransport, ServerSender);
clientRuntime.DrainPendingMessagesAsync().GetAwaiter().GetResult();
Assert.That(clientHarness.TryGetState("player-a", out var idleLocalState), Is.True);
Assert.That(idleLocalState.Tick, Is.EqualTo(1));
Assert.That(idleLocalState.Position.x, Is.EqualTo(0f).Within(0.0001f));
Assert.That(idleLocalState.Position.z, Is.EqualTo(0f).Within(0.0001f));
Assert.That(idleLocalState.Hp, Is.EqualTo(100));
serverTransports[9001].ClearOutgoing();
serverTransports[9000].ClearOutgoing();
serverTransports[9001].EmitReceive(
GameplayFlowTestSupport.BuildEnvelope(
MessageType.MoveInput,
new MoveInput
{
PlayerId = "player-b",
Tick = 1,
MoveX = 1f,
MoveY = 0f
}),
RemotePeer);
serverRuntime.DrainPendingMessagesAsync().GetAwaiter().GetResult();
serverRuntime.UpdateAuthoritativeMovement(TimeSpan.FromMilliseconds(100));
serverTransports[9001].ClearOutgoing();
serverTransports[9000].ClearOutgoing();
ClientGameplayInputFlow.SendShootInput(
clientRuntime.MessageManager,
"player-a",
3,
Vector3.right);
Assert.That(clientReliableTransport.SentMessages.Count, Is.EqualTo(1));
var outboundEnvelope = Envelope.Parser.ParseFrom(clientReliableTransport.SentMessages[0]);
var outboundShootInput = ShootInput.Parser.ParseFrom(outboundEnvelope.Payload);
Assert.That((MessageType)outboundEnvelope.Type, Is.EqualTo(MessageType.ShootInput));
Assert.That(outboundShootInput.TargetId, Is.EqualTo(string.Empty));
TransferSentMessages(clientReliableTransport, serverTransports[9000], ClientPeer);
serverRuntime.DrainPendingMessagesAsync().GetAwaiter().GetResult();
serverRuntime.UpdateAuthoritativeMovement(TimeSpan.FromMilliseconds(50));
TransferBroadcastMessages(serverTransports[9000], clientReliableTransport, ServerSender);
clientRuntime.DrainPendingMessagesAsync().GetAwaiter().GetResult();
TransferBroadcastMessages(serverTransports[9001], clientSyncTransport, ServerSender);
clientRuntime.DrainPendingMessagesAsync().GetAwaiter().GetResult();
Assert.That(serverRuntime.TryGetAuthoritativeMovementState(ClientPeer, out var localMovementState), Is.True);
Assert.That(localMovementState.PlayerId, Is.EqualTo("player-a"));
Assert.That(localMovementState.LastAcceptedMoveTick, Is.EqualTo(0));
Assert.That(localMovementState.PositionX, Is.EqualTo(0f).Within(0.0001f));
Assert.That(localMovementState.PositionZ, Is.EqualTo(0f).Within(0.0001f));
Assert.That(serverRuntime.TryGetAuthoritativeCombatState(ClientPeer, out var localCombatState), Is.True);
Assert.That(localCombatState.LastAcceptedShootTick, Is.EqualTo(3));
Assert.That(serverRuntime.TryGetAuthoritativeCombatState(RemotePeer, out var remoteCombatState), Is.True);
Assert.That(remoteCombatState.PlayerId, Is.EqualTo("player-b"));
Assert.That(remoteCombatState.Hp, Is.EqualTo(70));
Assert.That(clientHarness.TryGetState("player-b", out var remoteClientState), Is.True);
Assert.That(remoteClientState.Hp, Is.EqualTo(70));
Assert.That(clientHarness.TryGetCombatPresentation("player-b", out var remoteCombatPresentation), Is.True);
Assert.That(remoteCombatPresentation.LastEventType, Is.EqualTo(CombatEventType.DamageApplied));
Assert.That(remoteCombatPresentation.LastDamage, Is.EqualTo(30));
Assert.That(remoteCombatPresentation.IsDead, Is.False);
}
private static GameplayFlowFakeTransport CreateTransport(IDictionary<int, GameplayFlowFakeTransport> serverTransports, int port)
{
var transport = new GameplayFlowFakeTransport();
@@ -138,6 +138,53 @@ namespace Tests.EditMode.Network
Assert.That(secondBroadcast.Velocity.Z, Is.EqualTo(0f).Within(0.0001f));
}
[Test]
public void NotifyLoginSucceeded_CreatesIdleAuthoritativeState_AndBroadcastsPlayerStateWithoutMoveInput()
{
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(50),
DefaultHp = 100
}
};
using var runtime = ServerRuntimeEntryPoint.StartAsync(configuration).GetAwaiter().GetResult();
createdTransports[9000].EmitReceive(BuildEnvelope(MessageType.LoginRequest, new LoginRequest
{
PlayerId = "player-a",
Speed = 5
}), PeerA);
runtime.Host.NotifyLoginStarted(PeerA);
runtime.Host.NotifyLoginSucceeded(PeerA);
runtime.UpdateAuthoritativeMovement(TimeSpan.FromMilliseconds(50));
Assert.That(runtime.TryGetAuthoritativeMovementState(PeerA, out var state), Is.True);
Assert.That(state.PlayerId, Is.EqualTo("player-a"));
Assert.That(state.LastAcceptedMoveTick, Is.EqualTo(0));
Assert.That(state.PositionX, Is.EqualTo(0f).Within(0.0001f));
Assert.That(state.PositionZ, Is.EqualTo(0f).Within(0.0001f));
Assert.That(state.Hp, Is.EqualTo(100));
Assert.That(createdTransports[9001].BroadcastMessages.Count, Is.EqualTo(1));
var broadcast = ParsePlayerState(createdTransports[9001].BroadcastMessages[0]);
Assert.That(broadcast.PlayerId, Is.EqualTo("player-a"));
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(0f).Within(0.0001f));
Assert.That(broadcast.Velocity.X, Is.EqualTo(0f).Within(0.0001f));
Assert.That(broadcast.Velocity.Z, Is.EqualTo(0f).Within(0.0001f));
Assert.That(broadcast.Hp, Is.EqualTo(100));
}
[Test]
public void UpdateAuthoritativeMovement_UsesReliableLaneWhenSyncTransportIsUnavailable()
{
@@ -54,6 +54,33 @@ namespace Tests.EditMode.Network
Assert.That(shootInput.TargetId, Is.EqualTo(string.Empty));
}
[Test]
public void ClientGameplayInputFlow_TryCreateShootInput_LocalFirePathKeepsTargetOptional()
{
var created = ClientGameplayInputFlow.TryCreateShootInput(
"player-1",
21,
true,
new Vector3(2f, 0f, 0f),
out var shootInput);
var ignored = ClientGameplayInputFlow.TryCreateShootInput(
"player-1",
22,
false,
Vector3.forward,
out var ignoredShootInput);
Assert.That(created, Is.True);
Assert.That(shootInput, Is.Not.Null);
Assert.That(shootInput.PlayerId, Is.EqualTo("player-1"));
Assert.That(shootInput.Tick, Is.EqualTo(21));
Assert.That(shootInput.DirX, Is.EqualTo(1f));
Assert.That(shootInput.DirY, Is.EqualTo(0f));
Assert.That(shootInput.TargetId, Is.EqualTo(string.Empty));
Assert.That(ignored, Is.False);
Assert.That(ignoredShootInput, Is.Null);
}
[Test]
public void ClientPredictionBuffer_AuthoritativeState_PrunesAcknowledgedMoveInputs()
{