process TODO.md step 10

This commit is contained in:
SepComet
2026-03-29 12:43:57 +08:00
parent c5fbd8e36d
commit fbc09186f3
21 changed files with 913 additions and 19 deletions
@@ -0,0 +1,161 @@
using System.Net;
using Network.Defines;
using Network.NetworkApplication;
using NUnit.Framework;
using UnityEngine;
using Vector3 = UnityEngine.Vector3;
namespace Tests.EditMode.Network
{
public class ClientGameplayFlowTests
{
private static readonly IPEndPoint Sender = new(IPAddress.Loopback, 9300);
[Test]
public void ClientGameplayInputFlow_SendShootInput_UsesDedicatedGameplayPathAndReliableLane()
{
var reliableTransport = new GameplayFlowFakeTransport();
var syncTransport = new GameplayFlowFakeTransport();
var manager = new MessageManager(
reliableTransport,
new MainThreadNetworkDispatcher(),
new DefaultMessageDeliveryPolicyResolver(),
syncTransport);
ClientGameplayInputFlow.SendShootInput(
manager,
"player-1",
17,
new Vector3(3f, 0f, 4f),
"enemy-1");
Assert.That(reliableTransport.SentMessages.Count, Is.EqualTo(1));
Assert.That(syncTransport.SentMessages.Count, Is.EqualTo(0));
var envelope = Envelope.Parser.ParseFrom(reliableTransport.SentMessages[0]);
var shootInput = ShootInput.Parser.ParseFrom(envelope.Payload);
Assert.That((MessageType)envelope.Type, Is.EqualTo(MessageType.ShootInput));
Assert.That(shootInput.PlayerId, Is.EqualTo("player-1"));
Assert.That(shootInput.Tick, Is.EqualTo(17));
Assert.That(shootInput.DirX, Is.EqualTo(0.6f).Within(0.0001f));
Assert.That(shootInput.DirY, Is.EqualTo(0.8f).Within(0.0001f));
Assert.That(shootInput.TargetId, Is.EqualTo("enemy-1"));
}
[Test]
public void SharedNetworkRuntime_CombatEventReceivePath_AppliesAuthoritativeDamageAndDeath()
{
var transport = new GameplayFlowFakeTransport();
var runtime = new SharedNetworkRuntime(transport, new MainThreadNetworkDispatcher());
var harness = new ClientGameplayTestHarness("player-1");
harness.Register(runtime.MessageManager);
transport.EmitReceive(
GameplayFlowTestSupport.BuildEnvelope(
MessageType.PlayerState,
GameplayFlowTestSupport.CreatePlayerState("player-1", 10, Vector3.zero, hp: 100)),
Sender);
transport.EmitReceive(
GameplayFlowTestSupport.BuildEnvelope(
MessageType.CombatEvent,
new CombatEvent
{
Tick = 11,
EventType = CombatEventType.DamageApplied,
AttackerId = "enemy-1",
TargetId = "player-1",
Damage = 35,
HitPosition = new global::Network.Defines.Vector3 { X = 2f, Y = 0f, Z = 1f }
}),
Sender);
transport.EmitReceive(
GameplayFlowTestSupport.BuildEnvelope(
MessageType.CombatEvent,
new CombatEvent
{
Tick = 12,
EventType = CombatEventType.Death,
AttackerId = "enemy-1",
TargetId = "player-1"
}),
Sender);
runtime.DrainPendingMessagesAsync().GetAwaiter().GetResult();
Assert.That(harness.TryGetState("player-1", out var snapshot), Is.True);
Assert.That(snapshot.Hp, Is.EqualTo(0));
Assert.That(harness.TryGetCombatPresentation("player-1", out var combatPresentation), Is.True);
Assert.That(combatPresentation.HasLastEvent, Is.True);
Assert.That(combatPresentation.LastEventType, Is.EqualTo(CombatEventType.Death));
Assert.That(combatPresentation.IsDead, Is.True);
}
[Test]
public void SharedNetworkRuntime_CombatEventReceivePath_RoutesShootRejectedToAttackerDiagnostics()
{
var transport = new GameplayFlowFakeTransport();
var runtime = new SharedNetworkRuntime(transport, new MainThreadNetworkDispatcher());
var harness = new ClientGameplayTestHarness("player-1");
harness.Register(runtime.MessageManager);
transport.EmitReceive(
GameplayFlowTestSupport.BuildEnvelope(
MessageType.PlayerState,
GameplayFlowTestSupport.CreatePlayerState("player-1", 20, Vector3.zero, hp: 90)),
Sender);
transport.EmitReceive(
GameplayFlowTestSupport.BuildEnvelope(
MessageType.CombatEvent,
new CombatEvent
{
Tick = 21,
EventType = CombatEventType.ShootRejected,
AttackerId = "player-1",
TargetId = "enemy-2"
}),
Sender);
runtime.DrainPendingMessagesAsync().GetAwaiter().GetResult();
Assert.That(harness.TryGetState("player-1", out var snapshot), Is.True);
Assert.That(snapshot.Hp, Is.EqualTo(90));
Assert.That(harness.TryGetCombatPresentation("player-1", out var combatPresentation), Is.True);
Assert.That(combatPresentation.LastEventType, Is.EqualTo(CombatEventType.ShootRejected));
Assert.That(combatPresentation.LastDamage, Is.EqualTo(0));
Assert.That(combatPresentation.IsDead, Is.False);
}
[Test]
public void ClientGameplayHarness_RemotePlayerStateFlow_RejectsStaleSnapshots_AndUsesInterpolationOrLatestClamp()
{
var harness = new ClientGameplayTestHarness("local-player");
var firstAccepted = harness.HandlePlayerState(
GameplayFlowTestSupport.CreatePlayerState("remote-player", 10, new Vector3(0f, 0f, 0f), rotation: 0f),
receivedAtSeconds: 0f);
var secondAccepted = harness.HandlePlayerState(
GameplayFlowTestSupport.CreatePlayerState("remote-player", 11, new Vector3(10f, 0f, 0f), rotation: 90f),
receivedAtSeconds: 0.05f);
var staleAccepted = harness.HandlePlayerState(
GameplayFlowTestSupport.CreatePlayerState("remote-player", 9, new Vector3(99f, 0f, 0f), rotation: 180f),
receivedAtSeconds: 0.06f);
var interpolated = harness.SampleRemote("remote-player", 0.125f);
var clamped = harness.SampleRemote("remote-player", 0.35f);
Assert.That(firstAccepted, Is.True);
Assert.That(secondAccepted, Is.True);
Assert.That(staleAccepted, Is.False);
Assert.That(harness.GetBufferedSnapshotCount("remote-player"), Is.EqualTo(1));
Assert.That(harness.GetLatestBufferedTick("remote-player"), Is.EqualTo(11));
Assert.That(interpolated.HasValue, Is.True);
Assert.That(interpolated.UsedInterpolation, Is.True);
Assert.That(interpolated.Position.x, Is.EqualTo(5f).Within(0.001f));
Assert.That(interpolated.Rotation.eulerAngles.y, Is.EqualTo(45f).Within(0.01f));
Assert.That(clamped.HasValue, Is.True);
Assert.That(clamped.UsedInterpolation, Is.False);
Assert.That(clamped.LatestSnapshot.Tick, Is.EqualTo(11));
Assert.That(clamped.Position, Is.EqualTo(new Vector3(10f, 0f, 0f)));
}
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 6a4fcc49ae3441f6bc9f251af0f1f067
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,139 @@
using System;
using System.Collections.Generic;
using System.Net;
using Network.Defines;
using Network.NetworkApplication;
using Network.NetworkHost;
using NUnit.Framework;
using UnityEngine;
using Vector3 = UnityEngine.Vector3;
namespace Tests.EditMode.Network
{
public class GameplayFlowRoundTripTests
{
private static readonly IPEndPoint ClientPeer = new(IPAddress.Loopback, 9401);
private static readonly IPEndPoint RemotePeer = new(IPAddress.Loopback, 9402);
private static readonly IPEndPoint ServerSender = new(IPAddress.Loopback, 9000);
[Test]
public void FakeTransportRoundTrip_MoveInputAndShootInput_ProduceAuthoritativePlayerStateAndCombatEvent()
{
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[9001].EmitReceive(
GameplayFlowTestSupport.BuildEnvelope(
MessageType.MoveInput,
new MoveInput
{
PlayerId = "player-b",
Tick = 1,
MoveX = 0f,
MoveY = 0f
}),
RemotePeer);
serverRuntime.DrainPendingMessagesAsync().GetAwaiter().GetResult();
serverTransports[9001].ClearOutgoing();
serverTransports[9000].ClearOutgoing();
clientRuntime.MessageManager.SendMessage(
new MoveInput
{
PlayerId = "player-a",
Tick = 1,
MoveX = 1f,
MoveY = 0f
},
MessageType.MoveInput);
ClientGameplayInputFlow.SendShootInput(
clientRuntime.MessageManager,
"player-a",
2,
Vector3.right,
"player-b");
TransferSentMessages(clientSyncTransport, serverTransports[9001], ClientPeer);
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 localServerState), Is.True);
Assert.That(localServerState.PlayerId, Is.EqualTo("player-a"));
Assert.That(localServerState.PositionX, Is.EqualTo(0.5f).Within(0.0001f));
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-a", out var localClientState), Is.True);
Assert.That(localClientState.Tick, Is.EqualTo(1));
Assert.That(localClientState.Position.x, Is.EqualTo(0.5f).Within(0.0001f));
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();
serverTransports.Add(port, transport);
return transport;
}
private static void TransferSentMessages(GameplayFlowFakeTransport source, GameplayFlowFakeTransport destination, IPEndPoint sender)
{
foreach (var payload in source.SentMessages)
{
destination.EmitReceive(payload, sender);
}
source.ClearOutgoing();
}
private static void TransferBroadcastMessages(GameplayFlowFakeTransport source, GameplayFlowFakeTransport destination, IPEndPoint sender)
{
foreach (var payload in source.BroadcastMessages)
{
destination.EmitReceive(payload, sender);
}
source.ClearOutgoing();
}
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: bbe6dfdb34f64d4e895f57f7191b9407
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,230 @@
using System;
using System.Collections.Generic;
using System.Net;
using System.Threading.Tasks;
using Google.Protobuf;
using Network.Defines;
using Network.NetworkApplication;
using Network.NetworkTransport;
using UnityEngine;
using Vector3 = UnityEngine.Vector3;
namespace Tests.EditMode.Network
{
internal sealed class ClientGameplayTestHarness
{
private readonly string localPlayerId;
private readonly Dictionary<string, ClientAuthoritativePlayerState> states = new();
private readonly Dictionary<string, RemotePlayerSnapshotInterpolator> remoteInterpolators = new();
public ClientGameplayTestHarness(string localPlayerId)
{
this.localPlayerId = localPlayerId ?? string.Empty;
}
public void Register(MessageManager messageManager)
{
messageManager.RegisterHandler(MessageType.PlayerState, (payload, sender) =>
{
HandlePlayerState(PlayerState.Parser.ParseFrom(payload), receivedAtSeconds: 0f);
});
messageManager.RegisterHandler(MessageType.CombatEvent, (payload, sender) =>
{
HandleCombatEvent(CombatEvent.Parser.ParseFrom(payload));
});
}
public bool HandlePlayerState(PlayerState state, float receivedAtSeconds)
{
var owner = GetOrCreateOwner(state.PlayerId);
var accepted = owner.TryAccept(state, out var snapshot);
if (!accepted)
{
return false;
}
if (!IsLocalPlayer(state.PlayerId))
{
GetOrCreateInterpolator(state.PlayerId).TryAddSnapshot(snapshot, receivedAtSeconds);
}
return true;
}
public bool HandleCombatEvent(CombatEvent combatEvent)
{
if (!ClientCombatEventRouting.TryGetAffectedPlayerId(combatEvent, out var playerId))
{
return false;
}
var owner = GetOrCreateOwner(playerId);
return owner.TryApplyCombatEvent(combatEvent, playerId, out _, out _);
}
public bool TryGetState(string playerId, out ClientAuthoritativePlayerStateSnapshot snapshot)
{
if (states.TryGetValue(playerId, out var owner) && owner.Current != null)
{
snapshot = owner.Current;
return true;
}
snapshot = null;
return false;
}
public bool TryGetCombatPresentation(string playerId, out ClientCombatPresentationSnapshot snapshot)
{
if (states.TryGetValue(playerId, out var owner))
{
snapshot = owner.CombatPresentation;
return true;
}
snapshot = ClientCombatPresentationSnapshot.Empty;
return false;
}
public int GetBufferedSnapshotCount(string playerId)
{
return remoteInterpolators.TryGetValue(playerId, out var interpolator)
? interpolator.BufferedSnapshotCount
: 0;
}
public long GetLatestBufferedTick(string playerId)
{
return remoteInterpolators.TryGetValue(playerId, out var interpolator)
? interpolator.LatestBufferedTick
: -1;
}
public RemotePlayerInterpolationSample SampleRemote(string playerId, float nowSeconds)
{
return GetOrCreateInterpolator(playerId).Sample(nowSeconds);
}
private bool IsLocalPlayer(string playerId)
{
return string.Equals(playerId, localPlayerId, StringComparison.Ordinal);
}
private ClientAuthoritativePlayerState GetOrCreateOwner(string playerId)
{
if (!states.TryGetValue(playerId, out var owner))
{
owner = new ClientAuthoritativePlayerState();
states.Add(playerId, owner);
}
return owner;
}
private RemotePlayerSnapshotInterpolator GetOrCreateInterpolator(string playerId)
{
if (!remoteInterpolators.TryGetValue(playerId, out var interpolator))
{
interpolator = new RemotePlayerSnapshotInterpolator();
remoteInterpolators.Add(playerId, interpolator);
}
return interpolator;
}
}
internal sealed class GameplayFlowFakeTransport : ITransport
{
private readonly List<byte[]> sentMessages = new();
private readonly List<byte[]> targetMessages = new();
private readonly List<(byte[] Data, IPEndPoint Target)> targetedSends = new();
private readonly List<byte[]> broadcastMessages = new();
public event Action<byte[], IPEndPoint> OnReceive;
public IReadOnlyList<byte[]> SentMessages => sentMessages;
public IReadOnlyList<byte[]> TargetMessages => targetMessages;
public IReadOnlyList<(byte[] Data, IPEndPoint Target)> TargetedSends => targetedSends;
public IReadOnlyList<byte[]> BroadcastMessages => broadcastMessages;
public Task StartAsync()
{
return Task.CompletedTask;
}
public void Stop()
{
}
public void Send(byte[] data)
{
sentMessages.Add(Copy(data));
}
public void SendTo(byte[] data, IPEndPoint target)
{
var copy = Copy(data);
targetMessages.Add(copy);
targetedSends.Add((copy, target));
}
public void SendToAll(byte[] data)
{
broadcastMessages.Add(Copy(data));
}
public void EmitReceive(byte[] data, IPEndPoint sender)
{
OnReceive?.Invoke(Copy(data), sender);
}
public void ClearOutgoing()
{
sentMessages.Clear();
targetMessages.Clear();
targetedSends.Clear();
broadcastMessages.Clear();
}
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;
}
}
internal static class GameplayFlowTestSupport
{
public static byte[] BuildEnvelope(MessageType type, IMessage payload)
{
return new Envelope
{
Type = (int)type,
Payload = payload.ToByteString()
}.ToByteArray();
}
public static PlayerState CreatePlayerState(string playerId, long tick, Vector3 position, int hp = 100, float rotation = 0f, Vector3? velocity = null)
{
var resolvedVelocity = velocity ?? Vector3.zero;
return new PlayerState
{
PlayerId = playerId,
Tick = tick,
Position = new global::Network.Defines.Vector3 { X = position.x, Y = position.y, Z = position.z },
Velocity = new global::Network.Defines.Vector3 { X = resolvedVelocity.x, Y = resolvedVelocity.y, Z = resolvedVelocity.z },
Rotation = rotation,
Hp = hp
};
}
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: c3db9ca1d45945ed9f5fd1b14922ca21
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant: