process TODO.md step8
This commit is contained in:
@@ -59,7 +59,8 @@ namespace Network.NetworkApplication
|
||||
IMessageDeliveryPolicyResolver deliveryPolicyResolver = null,
|
||||
SyncSequenceTracker syncSequenceTracker = null,
|
||||
Func<int, ITransport> transportFactory = null,
|
||||
ServerAuthoritativeMovementConfiguration authoritativeMovement = null)
|
||||
ServerAuthoritativeMovementConfiguration authoritativeMovement = null,
|
||||
ServerAuthoritativeCombatConfiguration authoritativeCombat = null)
|
||||
{
|
||||
ValidateDualPortConfiguration(reliablePort, syncPort);
|
||||
|
||||
@@ -84,7 +85,8 @@ namespace Network.NetworkApplication
|
||||
syncTransport,
|
||||
deliveryPolicyResolver,
|
||||
syncSequenceTracker,
|
||||
authoritativeMovement);
|
||||
authoritativeMovement,
|
||||
authoritativeCombat);
|
||||
}
|
||||
|
||||
public static Task<ServerRuntimeHandle> StartServerRuntimeAsync(ServerRuntimeConfiguration configuration)
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
using System;
|
||||
|
||||
namespace Network.NetworkHost
|
||||
{
|
||||
public sealed class ServerAuthoritativeCombatConfiguration
|
||||
{
|
||||
public int DamagePerShot { get; set; } = 25;
|
||||
|
||||
internal void Validate()
|
||||
{
|
||||
if (DamagePerShot <= 0)
|
||||
{
|
||||
throw new ArgumentOutOfRangeException(nameof(DamagePerShot), "Damage per shot must be positive.");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 255d10cba50a58345800f75f1bc402d1
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,270 @@
|
||||
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 ServerAuthoritativeCombatCoordinator
|
||||
{
|
||||
private readonly object gate = new();
|
||||
private readonly MessageManager messageManager;
|
||||
private readonly ServerAuthoritativeMovementCoordinator movementCoordinator;
|
||||
private readonly Dictionary<string, ServerAuthoritativeCombatState> statesByPeer = new();
|
||||
private readonly ServerAuthoritativeCombatConfiguration configuration;
|
||||
|
||||
public ServerAuthoritativeCombatCoordinator(
|
||||
MessageManager messageManager,
|
||||
ServerAuthoritativeMovementCoordinator movementCoordinator,
|
||||
ServerAuthoritativeCombatConfiguration configuration)
|
||||
{
|
||||
this.messageManager = messageManager ?? throw new ArgumentNullException(nameof(messageManager));
|
||||
this.movementCoordinator = movementCoordinator ?? throw new ArgumentNullException(nameof(movementCoordinator));
|
||||
this.configuration = configuration ?? throw new ArgumentNullException(nameof(configuration));
|
||||
}
|
||||
|
||||
public IReadOnlyList<ServerAuthoritativeCombatState> States
|
||||
{
|
||||
get
|
||||
{
|
||||
lock (gate)
|
||||
{
|
||||
return statesByPeer.Values
|
||||
.Select(CloneState)
|
||||
.ToArray();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public Task HandleShootInputAsync(byte[] payload, IPEndPoint sender)
|
||||
{
|
||||
if (payload == null || sender == null)
|
||||
{
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
ShootInput input;
|
||||
try
|
||||
{
|
||||
input = ShootInput.Parser.ParseFrom(payload);
|
||||
}
|
||||
catch
|
||||
{
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
if (!TryValidateAcceptedShot(input, sender, out var attackerState, out var targetState))
|
||||
{
|
||||
BroadcastRejectedShot(input);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
movementCoordinator.TryUpdateState(sender, state =>
|
||||
{
|
||||
state.LastAcceptedShootTick = input.Tick;
|
||||
}, out attackerState);
|
||||
|
||||
var wasTargetDead = targetState.IsDead;
|
||||
movementCoordinator.TryUpdateStateByPlayerId(targetState.PlayerId, state =>
|
||||
{
|
||||
state.Hp = Math.Max(0, state.Hp - configuration.DamagePerShot);
|
||||
state.IsDead = state.Hp <= 0;
|
||||
}, out var updatedTargetState);
|
||||
targetState = updatedTargetState;
|
||||
|
||||
UpdateCombatState(attackerState, input.Tick, input.Tick);
|
||||
UpdateCombatState(targetState, targetState.LastAcceptedShootTick, input.Tick);
|
||||
|
||||
var hitPosition = new Vector3
|
||||
{
|
||||
X = targetState.PositionX,
|
||||
Y = targetState.PositionY,
|
||||
Z = targetState.PositionZ
|
||||
};
|
||||
|
||||
messageManager.BroadcastMessage(new CombatEvent
|
||||
{
|
||||
Tick = input.Tick,
|
||||
EventType = CombatEventType.Hit,
|
||||
AttackerId = attackerState.PlayerId,
|
||||
TargetId = targetState.PlayerId,
|
||||
Damage = 0,
|
||||
HitPosition = hitPosition
|
||||
}, MessageType.CombatEvent);
|
||||
|
||||
messageManager.BroadcastMessage(new CombatEvent
|
||||
{
|
||||
Tick = input.Tick,
|
||||
EventType = CombatEventType.DamageApplied,
|
||||
AttackerId = attackerState.PlayerId,
|
||||
TargetId = targetState.PlayerId,
|
||||
Damage = configuration.DamagePerShot,
|
||||
HitPosition = hitPosition
|
||||
}, MessageType.CombatEvent);
|
||||
|
||||
if (!wasTargetDead && targetState.IsDead)
|
||||
{
|
||||
messageManager.BroadcastMessage(new CombatEvent
|
||||
{
|
||||
Tick = input.Tick,
|
||||
EventType = CombatEventType.Death,
|
||||
AttackerId = attackerState.PlayerId,
|
||||
TargetId = targetState.PlayerId,
|
||||
Damage = 0,
|
||||
HitPosition = hitPosition
|
||||
}, MessageType.CombatEvent);
|
||||
}
|
||||
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public bool TryGetState(IPEndPoint remoteEndPoint, out ServerAuthoritativeCombatState state)
|
||||
{
|
||||
var key = Normalize(remoteEndPoint).ToString();
|
||||
lock (gate)
|
||||
{
|
||||
if (statesByPeer.TryGetValue(key, out var existingState))
|
||||
{
|
||||
state = CloneState(existingState);
|
||||
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();
|
||||
}
|
||||
}
|
||||
|
||||
private bool TryValidateAcceptedShot(
|
||||
ShootInput input,
|
||||
IPEndPoint sender,
|
||||
out ServerAuthoritativeMovementState attackerState,
|
||||
out ServerAuthoritativeMovementState targetState)
|
||||
{
|
||||
attackerState = null;
|
||||
targetState = null;
|
||||
|
||||
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))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var lengthSquared = (input.DirX * input.DirX) + (input.DirY * input.DirY);
|
||||
if (lengthSquared <= 0f)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!movementCoordinator.TryGetState(sender, out attackerState) ||
|
||||
!string.Equals(attackerState.PlayerId, input.PlayerId, StringComparison.Ordinal) ||
|
||||
attackerState.IsDead ||
|
||||
attackerState.Hp <= 0 ||
|
||||
input.Tick <= attackerState.LastAcceptedShootTick)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!movementCoordinator.TryGetStateByPlayerId(input.TargetId, out targetState) ||
|
||||
targetState.IsDead ||
|
||||
targetState.Hp <= 0)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private void BroadcastRejectedShot(ShootInput input)
|
||||
{
|
||||
if (input == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
messageManager.BroadcastMessage(new CombatEvent
|
||||
{
|
||||
Tick = input.Tick,
|
||||
EventType = CombatEventType.ShootRejected,
|
||||
AttackerId = input.PlayerId ?? string.Empty,
|
||||
TargetId = input.TargetId ?? string.Empty,
|
||||
Damage = 0,
|
||||
HitPosition = new Vector3()
|
||||
}, MessageType.CombatEvent);
|
||||
}
|
||||
|
||||
private void UpdateCombatState(ServerAuthoritativeMovementState movementState, long acceptedShootTick, long resolvedCombatTick)
|
||||
{
|
||||
if (movementState == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var key = Normalize(movementState.RemoteEndPoint).ToString();
|
||||
lock (gate)
|
||||
{
|
||||
if (!statesByPeer.TryGetValue(key, out var combatState))
|
||||
{
|
||||
combatState = new ServerAuthoritativeCombatState(
|
||||
Normalize(movementState.RemoteEndPoint),
|
||||
movementState.PlayerId,
|
||||
movementState.Hp,
|
||||
movementState.IsDead);
|
||||
statesByPeer.Add(key, combatState);
|
||||
}
|
||||
|
||||
combatState.LastAcceptedShootTick = Math.Max(combatState.LastAcceptedShootTick, acceptedShootTick);
|
||||
combatState.LastResolvedCombatTick = Math.Max(combatState.LastResolvedCombatTick, resolvedCombatTick);
|
||||
combatState.Hp = movementState.Hp;
|
||||
combatState.IsDead = movementState.IsDead;
|
||||
}
|
||||
}
|
||||
|
||||
private static ServerAuthoritativeCombatState CloneState(ServerAuthoritativeCombatState state)
|
||||
{
|
||||
return new ServerAuthoritativeCombatState(state.RemoteEndPoint, state.PlayerId, state.Hp, state.IsDead)
|
||||
{
|
||||
LastAcceptedShootTick = state.LastAcceptedShootTick,
|
||||
LastResolvedCombatTick = state.LastResolvedCombatTick
|
||||
};
|
||||
}
|
||||
|
||||
private static IPEndPoint Normalize(IPEndPoint remoteEndPoint)
|
||||
{
|
||||
if (remoteEndPoint == null)
|
||||
{
|
||||
throw new ArgumentNullException(nameof(remoteEndPoint));
|
||||
}
|
||||
|
||||
return new IPEndPoint(remoteEndPoint.Address, remoteEndPoint.Port);
|
||||
}
|
||||
|
||||
private static bool IsFinite(float value)
|
||||
{
|
||||
return !float.IsNaN(value) && !float.IsInfinity(value);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 443cfc0d6b4759241a79409adc1c4519
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,28 @@
|
||||
using System;
|
||||
using System.Net;
|
||||
|
||||
namespace Network.NetworkHost
|
||||
{
|
||||
public sealed class ServerAuthoritativeCombatState
|
||||
{
|
||||
public ServerAuthoritativeCombatState(IPEndPoint remoteEndPoint, string playerId, int hp, bool isDead)
|
||||
{
|
||||
RemoteEndPoint = remoteEndPoint ?? throw new ArgumentNullException(nameof(remoteEndPoint));
|
||||
PlayerId = playerId ?? throw new ArgumentNullException(nameof(playerId));
|
||||
Hp = hp;
|
||||
IsDead = isDead;
|
||||
}
|
||||
|
||||
public IPEndPoint RemoteEndPoint { get; }
|
||||
|
||||
public string PlayerId { get; }
|
||||
|
||||
public long LastAcceptedShootTick { get; internal set; }
|
||||
|
||||
public long LastResolvedCombatTick { get; internal set; }
|
||||
|
||||
public int Hp { get; internal set; }
|
||||
|
||||
public bool IsDead { get; internal set; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 753945348e8486a46a51b455ce01b95c
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -154,6 +154,86 @@ namespace Network.NetworkHost
|
||||
return false;
|
||||
}
|
||||
|
||||
public bool TryGetStateByPlayerId(string playerId, out ServerAuthoritativeMovementState state)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(playerId))
|
||||
{
|
||||
state = null;
|
||||
return false;
|
||||
}
|
||||
|
||||
lock (gate)
|
||||
{
|
||||
foreach (var candidate in statesByPeer.Values)
|
||||
{
|
||||
if (!string.Equals(candidate.PlayerId, playerId, StringComparison.Ordinal))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
state = CloneState(candidate);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
state = null;
|
||||
return false;
|
||||
}
|
||||
|
||||
public bool TryUpdateState(IPEndPoint remoteEndPoint, Action<ServerAuthoritativeMovementState> updater, out ServerAuthoritativeMovementState state)
|
||||
{
|
||||
if (updater == null)
|
||||
{
|
||||
throw new ArgumentNullException(nameof(updater));
|
||||
}
|
||||
|
||||
var key = Normalize(remoteEndPoint).ToString();
|
||||
lock (gate)
|
||||
{
|
||||
if (!statesByPeer.TryGetValue(key, out var currentState))
|
||||
{
|
||||
state = null;
|
||||
return false;
|
||||
}
|
||||
|
||||
updater(currentState);
|
||||
state = CloneState(currentState);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
public bool TryUpdateStateByPlayerId(string playerId, Action<ServerAuthoritativeMovementState> updater, out ServerAuthoritativeMovementState state)
|
||||
{
|
||||
if (updater == null)
|
||||
{
|
||||
throw new ArgumentNullException(nameof(updater));
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(playerId))
|
||||
{
|
||||
state = null;
|
||||
return false;
|
||||
}
|
||||
|
||||
lock (gate)
|
||||
{
|
||||
foreach (var currentState in statesByPeer.Values)
|
||||
{
|
||||
if (!string.Equals(currentState.PlayerId, playerId, StringComparison.Ordinal))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
updater(currentState);
|
||||
state = CloneState(currentState);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
state = null;
|
||||
return false;
|
||||
}
|
||||
|
||||
public void RemoveState(IPEndPoint remoteEndPoint)
|
||||
{
|
||||
var key = Normalize(remoteEndPoint).ToString();
|
||||
@@ -192,6 +272,7 @@ namespace Network.NetworkHost
|
||||
return new ServerAuthoritativeMovementState(state.RemoteEndPoint, state.PlayerId, state.Hp)
|
||||
{
|
||||
LastAcceptedMoveTick = state.LastAcceptedMoveTick,
|
||||
LastAcceptedShootTick = state.LastAcceptedShootTick,
|
||||
LastBroadcastTick = state.LastBroadcastTick,
|
||||
PositionX = state.PositionX,
|
||||
PositionY = state.PositionY,
|
||||
@@ -200,6 +281,7 @@ namespace Network.NetworkHost
|
||||
VelocityY = state.VelocityY,
|
||||
VelocityZ = state.VelocityZ,
|
||||
Rotation = state.Rotation,
|
||||
IsDead = state.IsDead,
|
||||
InputX = state.InputX,
|
||||
InputY = state.InputY
|
||||
};
|
||||
@@ -233,7 +315,7 @@ namespace Network.NetworkHost
|
||||
|
||||
private void IntegrateState(ServerAuthoritativeMovementState state, TimeSpan elapsed)
|
||||
{
|
||||
if (state.InputX == 0f && state.InputY == 0f)
|
||||
if (state.IsDead || (state.InputX == 0f && state.InputY == 0f))
|
||||
{
|
||||
state.VelocityX = 0f;
|
||||
state.VelocityY = 0f;
|
||||
|
||||
@@ -10,6 +10,7 @@ namespace Network.NetworkHost
|
||||
RemoteEndPoint = remoteEndPoint ?? throw new ArgumentNullException(nameof(remoteEndPoint));
|
||||
PlayerId = playerId ?? throw new ArgumentNullException(nameof(playerId));
|
||||
Hp = hp;
|
||||
IsDead = hp <= 0;
|
||||
}
|
||||
|
||||
public IPEndPoint RemoteEndPoint { get; }
|
||||
@@ -18,6 +19,8 @@ namespace Network.NetworkHost
|
||||
|
||||
public long LastAcceptedMoveTick { get; internal set; }
|
||||
|
||||
public long LastAcceptedShootTick { get; internal set; }
|
||||
|
||||
public long LastBroadcastTick { get; internal set; }
|
||||
|
||||
public float PositionX { get; internal set; }
|
||||
@@ -36,6 +39,8 @@ namespace Network.NetworkHost
|
||||
|
||||
public int Hp { get; internal set; }
|
||||
|
||||
public bool IsDead { get; internal set; }
|
||||
|
||||
public float InputX { get; internal set; }
|
||||
|
||||
public float InputY { get; internal set; }
|
||||
|
||||
@@ -14,6 +14,7 @@ namespace Network.NetworkHost
|
||||
private readonly ITransport syncTransport;
|
||||
private readonly MessageManager messageManager;
|
||||
private readonly ServerAuthoritativeMovementCoordinator authoritativeMovementCoordinator;
|
||||
private readonly ServerAuthoritativeCombatCoordinator authoritativeCombatCoordinator;
|
||||
|
||||
public ServerNetworkHost(
|
||||
ITransport transport,
|
||||
@@ -23,7 +24,8 @@ namespace Network.NetworkHost
|
||||
ITransport syncTransport = null,
|
||||
IMessageDeliveryPolicyResolver deliveryPolicyResolver = null,
|
||||
SyncSequenceTracker syncSequenceTracker = null,
|
||||
ServerAuthoritativeMovementConfiguration authoritativeMovement = null)
|
||||
ServerAuthoritativeMovementConfiguration authoritativeMovement = null,
|
||||
ServerAuthoritativeCombatConfiguration authoritativeCombat = null)
|
||||
{
|
||||
this.transport = transport ?? throw new ArgumentNullException(nameof(transport));
|
||||
this.syncTransport = syncTransport;
|
||||
@@ -44,7 +46,12 @@ namespace Network.NetworkHost
|
||||
this,
|
||||
messageManager,
|
||||
authoritativeMovement ?? new ServerAuthoritativeMovementConfiguration());
|
||||
authoritativeCombatCoordinator = new ServerAuthoritativeCombatCoordinator(
|
||||
messageManager,
|
||||
authoritativeMovementCoordinator,
|
||||
authoritativeCombat ?? new ServerAuthoritativeCombatConfiguration());
|
||||
messageManager.RegisterHandler(MessageType.MoveInput, authoritativeMovementCoordinator.HandleMoveInputAsync);
|
||||
messageManager.RegisterHandler(MessageType.ShootInput, authoritativeCombatCoordinator.HandleShootInputAsync);
|
||||
}
|
||||
|
||||
public MessageManager MessageManager => messageManager;
|
||||
@@ -59,6 +66,8 @@ namespace Network.NetworkHost
|
||||
|
||||
public IReadOnlyList<ServerAuthoritativeMovementState> AuthoritativeMovementStates => authoritativeMovementCoordinator.States;
|
||||
|
||||
public IReadOnlyList<ServerAuthoritativeCombatState> AuthoritativeCombatStates => authoritativeCombatCoordinator.States;
|
||||
|
||||
public event Action<MultiSessionLifecycleEvent> LifecycleChanged
|
||||
{
|
||||
add => SessionCoordinator.LifecycleChanged += value;
|
||||
@@ -87,6 +96,7 @@ namespace Network.NetworkHost
|
||||
|
||||
SessionCoordinator.RemoveAllSessions("Transport stopped");
|
||||
authoritativeMovementCoordinator.Clear();
|
||||
authoritativeCombatCoordinator.Clear();
|
||||
PublishMetricsSessionSnapshots();
|
||||
}
|
||||
|
||||
@@ -116,6 +126,11 @@ namespace Network.NetworkHost
|
||||
return authoritativeMovementCoordinator.TryGetState(remoteEndPoint, out state);
|
||||
}
|
||||
|
||||
public bool TryGetAuthoritativeCombatState(IPEndPoint remoteEndPoint, out ServerAuthoritativeCombatState state)
|
||||
{
|
||||
return authoritativeCombatCoordinator.TryGetState(remoteEndPoint, out state);
|
||||
}
|
||||
|
||||
public void NotifyLoginStarted(IPEndPoint remoteEndPoint)
|
||||
{
|
||||
SessionCoordinator.NotifyLoginStarted(remoteEndPoint);
|
||||
@@ -172,6 +187,7 @@ namespace Network.NetworkHost
|
||||
}
|
||||
|
||||
authoritativeMovementCoordinator.RemoveState(remoteEndPoint);
|
||||
authoritativeCombatCoordinator.RemoveState(remoteEndPoint);
|
||||
|
||||
RecordMetricsSessionSnapshot(transport, "server-host", session, ConnectionState.Disconnected);
|
||||
if (syncTransport != null && !ReferenceEquals(syncTransport, transport))
|
||||
|
||||
@@ -34,6 +34,8 @@ namespace Network.NetworkHost
|
||||
|
||||
public ServerAuthoritativeMovementConfiguration AuthoritativeMovement { get; set; }
|
||||
|
||||
public ServerAuthoritativeCombatConfiguration AuthoritativeCombat { get; set; }
|
||||
|
||||
internal void Validate()
|
||||
{
|
||||
if (ReliablePort <= 0)
|
||||
@@ -55,6 +57,7 @@ namespace Network.NetworkHost
|
||||
}
|
||||
|
||||
AuthoritativeMovement?.Validate();
|
||||
AuthoritativeCombat?.Validate();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -24,7 +24,8 @@ namespace Network.NetworkHost
|
||||
configuration.DeliveryPolicyResolver,
|
||||
configuration.SyncSequenceTracker,
|
||||
configuration.TransportFactory,
|
||||
configuration.AuthoritativeMovement);
|
||||
configuration.AuthoritativeMovement,
|
||||
configuration.AuthoritativeCombat);
|
||||
|
||||
try
|
||||
{
|
||||
|
||||
@@ -25,6 +25,8 @@ namespace Network.NetworkHost
|
||||
|
||||
public IReadOnlyList<ServerAuthoritativeMovementState> AuthoritativeMovementStates => host.AuthoritativeMovementStates;
|
||||
|
||||
public IReadOnlyList<ServerAuthoritativeCombatState> AuthoritativeCombatStates => host.AuthoritativeCombatStates;
|
||||
|
||||
public event Action<MultiSessionLifecycleEvent> LifecycleChanged
|
||||
{
|
||||
add => host.LifecycleChanged += value;
|
||||
@@ -56,6 +58,11 @@ namespace Network.NetworkHost
|
||||
return host.TryGetAuthoritativeMovementState(remoteEndPoint, out state);
|
||||
}
|
||||
|
||||
public bool TryGetAuthoritativeCombatState(IPEndPoint remoteEndPoint, out ServerAuthoritativeCombatState state)
|
||||
{
|
||||
return host.TryGetAuthoritativeCombatState(remoteEndPoint, out state);
|
||||
}
|
||||
|
||||
public void Stop()
|
||||
{
|
||||
if (isStopped)
|
||||
|
||||
@@ -0,0 +1,282 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
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 ServerAuthoritativeCombatTests
|
||||
{
|
||||
private static readonly IPEndPoint PeerA = new(IPAddress.Loopback, 9201);
|
||||
private static readonly IPEndPoint PeerB = new(IPAddress.Loopback, 9202);
|
||||
|
||||
[Test]
|
||||
public void DrainPendingMessages_AcceptsAndRejectsShootInputPerPeer_WithoutCrossPeerInterference()
|
||||
{
|
||||
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),
|
||||
DefaultHp = 100
|
||||
},
|
||||
AuthoritativeCombat = new ServerAuthoritativeCombatConfiguration
|
||||
{
|
||||
DamagePerShot = 30
|
||||
}
|
||||
};
|
||||
|
||||
using var runtime = ServerRuntimeEntryPoint.StartAsync(configuration).GetAwaiter().GetResult();
|
||||
|
||||
PrimePlayer(createdTransports[9000], PeerA, "player-a", 1);
|
||||
PrimePlayer(createdTransports[9000], PeerB, "player-b", 1);
|
||||
runtime.DrainPendingMessagesAsync().GetAwaiter().GetResult();
|
||||
|
||||
createdTransports[9000].EmitReceive(BuildEnvelope(MessageType.ShootInput, new ShootInput
|
||||
{
|
||||
PlayerId = "player-a",
|
||||
Tick = 5,
|
||||
DirX = 1f,
|
||||
DirY = 0f,
|
||||
TargetId = "player-b"
|
||||
}), PeerA);
|
||||
createdTransports[9000].EmitReceive(BuildEnvelope(MessageType.ShootInput, new ShootInput
|
||||
{
|
||||
PlayerId = "player-b",
|
||||
Tick = 2,
|
||||
DirX = 0f,
|
||||
DirY = 0f,
|
||||
TargetId = "player-a"
|
||||
}), PeerB);
|
||||
|
||||
runtime.DrainPendingMessagesAsync().GetAwaiter().GetResult();
|
||||
|
||||
Assert.That(runtime.TryGetAuthoritativeCombatState(PeerA, out var combatStateA), Is.True);
|
||||
Assert.That(combatStateA.PlayerId, Is.EqualTo("player-a"));
|
||||
Assert.That(combatStateA.LastAcceptedShootTick, Is.EqualTo(5));
|
||||
Assert.That(combatStateA.LastResolvedCombatTick, Is.EqualTo(5));
|
||||
Assert.That(combatStateA.Hp, Is.EqualTo(100));
|
||||
Assert.That(combatStateA.IsDead, Is.False);
|
||||
|
||||
Assert.That(runtime.TryGetAuthoritativeCombatState(PeerB, out var combatStateB), Is.True);
|
||||
Assert.That(combatStateB.PlayerId, Is.EqualTo("player-b"));
|
||||
Assert.That(combatStateB.LastAcceptedShootTick, Is.EqualTo(0));
|
||||
Assert.That(combatStateB.LastResolvedCombatTick, Is.EqualTo(5));
|
||||
Assert.That(combatStateB.Hp, Is.EqualTo(70));
|
||||
Assert.That(combatStateB.IsDead, Is.False);
|
||||
|
||||
var events = createdTransports[9000].BroadcastMessages.Select(ParseCombatEvent).ToArray();
|
||||
Assert.That(events.Select(evt => evt.EventType), Is.EqualTo(new[]
|
||||
{
|
||||
CombatEventType.Hit,
|
||||
CombatEventType.DamageApplied,
|
||||
CombatEventType.ShootRejected
|
||||
}));
|
||||
Assert.That(events[1].Damage, Is.EqualTo(30));
|
||||
Assert.That(events[2].AttackerId, Is.EqualTo("player-b"));
|
||||
Assert.That(events[2].TargetId, Is.EqualTo("player-a"));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void DrainPendingMessages_LethalShot_BroadcastsDeath_AndHpCarriesIntoLaterPlayerStateSnapshots()
|
||||
{
|
||||
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 = 5f,
|
||||
BroadcastInterval = TimeSpan.FromMilliseconds(50),
|
||||
DefaultHp = 100
|
||||
},
|
||||
AuthoritativeCombat = new ServerAuthoritativeCombatConfiguration
|
||||
{
|
||||
DamagePerShot = 100
|
||||
}
|
||||
};
|
||||
|
||||
using var runtime = ServerRuntimeEntryPoint.StartAsync(configuration).GetAwaiter().GetResult();
|
||||
|
||||
PrimePlayer(createdTransports[9001], PeerA, "player-a", 1);
|
||||
PrimePlayer(createdTransports[9001], PeerB, "player-b", 1);
|
||||
runtime.DrainPendingMessagesAsync().GetAwaiter().GetResult();
|
||||
|
||||
createdTransports[9000].EmitReceive(BuildEnvelope(MessageType.ShootInput, new ShootInput
|
||||
{
|
||||
PlayerId = "player-a",
|
||||
Tick = 10,
|
||||
DirX = 0f,
|
||||
DirY = 1f,
|
||||
TargetId = "player-b"
|
||||
}), PeerA);
|
||||
|
||||
runtime.DrainPendingMessagesAsync().GetAwaiter().GetResult();
|
||||
|
||||
var combatEvents = createdTransports[9000].BroadcastMessages.Select(ParseCombatEvent).ToArray();
|
||||
Assert.That(combatEvents.Select(evt => evt.EventType), Is.EqualTo(new[]
|
||||
{
|
||||
CombatEventType.Hit,
|
||||
CombatEventType.DamageApplied,
|
||||
CombatEventType.Death
|
||||
}));
|
||||
Assert.That(combatEvents[1].Damage, Is.EqualTo(100));
|
||||
|
||||
runtime.UpdateAuthoritativeMovement(TimeSpan.FromMilliseconds(50));
|
||||
|
||||
Assert.That(runtime.TryGetAuthoritativeMovementState(PeerB, out var targetState), Is.True);
|
||||
Assert.That(targetState.Hp, Is.EqualTo(0));
|
||||
Assert.That(targetState.IsDead, Is.True);
|
||||
Assert.That(targetState.VelocityX, Is.EqualTo(0f).Within(0.0001f));
|
||||
Assert.That(targetState.VelocityZ, Is.EqualTo(0f).Within(0.0001f));
|
||||
|
||||
var playerStates = createdTransports[9001].BroadcastMessages.Select(ParsePlayerState).OrderBy(state => state.PlayerId).ToArray();
|
||||
Assert.That(playerStates.Length, Is.EqualTo(2));
|
||||
Assert.That(playerStates[0].PlayerId, Is.EqualTo("player-a"));
|
||||
Assert.That(playerStates[0].Hp, Is.EqualTo(100));
|
||||
Assert.That(playerStates[1].PlayerId, Is.EqualTo("player-b"));
|
||||
Assert.That(playerStates[1].Hp, Is.EqualTo(0));
|
||||
Assert.That(playerStates[1].Tick, Is.EqualTo(1));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void RemoveSessionAndStop_ClearAuthoritativeCombatStateWithoutResettingOtherPeers()
|
||||
{
|
||||
var createdTransports = new Dictionary<int, FakeTransport>();
|
||||
var configuration = new ServerRuntimeConfiguration(9000)
|
||||
{
|
||||
Dispatcher = new MainThreadNetworkDispatcher(),
|
||||
TransportFactory = port => CreateTransport(createdTransports, port),
|
||||
AuthoritativeMovement = new ServerAuthoritativeMovementConfiguration(),
|
||||
AuthoritativeCombat = new ServerAuthoritativeCombatConfiguration()
|
||||
};
|
||||
|
||||
using var runtime = ServerRuntimeEntryPoint.StartAsync(configuration).GetAwaiter().GetResult();
|
||||
|
||||
PrimePlayer(createdTransports[9000], PeerA, "player-a", 1);
|
||||
PrimePlayer(createdTransports[9000], PeerB, "player-b", 1);
|
||||
runtime.DrainPendingMessagesAsync().GetAwaiter().GetResult();
|
||||
|
||||
createdTransports[9000].EmitReceive(BuildEnvelope(MessageType.ShootInput, new ShootInput
|
||||
{
|
||||
PlayerId = "player-a",
|
||||
Tick = 3,
|
||||
DirX = 1f,
|
||||
DirY = 0f,
|
||||
TargetId = "player-b"
|
||||
}), PeerA);
|
||||
|
||||
runtime.DrainPendingMessagesAsync().GetAwaiter().GetResult();
|
||||
|
||||
Assert.That(runtime.AuthoritativeCombatStates.Count, Is.EqualTo(2));
|
||||
Assert.That(runtime.Host.RemoveSession(PeerA), Is.True);
|
||||
Assert.That(runtime.TryGetAuthoritativeCombatState(PeerA, out _), Is.False);
|
||||
Assert.That(runtime.TryGetAuthoritativeCombatState(PeerB, out var remainingState), Is.True);
|
||||
Assert.That(remainingState.PlayerId, Is.EqualTo("player-b"));
|
||||
|
||||
runtime.Stop();
|
||||
|
||||
Assert.That(runtime.AuthoritativeCombatStates.Count, Is.EqualTo(0));
|
||||
}
|
||||
|
||||
private static void PrimePlayer(FakeTransport transport, IPEndPoint peer, string playerId, long tick)
|
||||
{
|
||||
transport.EmitReceive(BuildEnvelope(MessageType.MoveInput, new MoveInput
|
||||
{
|
||||
PlayerId = playerId,
|
||||
Tick = tick,
|
||||
MoveX = 0f,
|
||||
MoveY = 0f
|
||||
}), peer);
|
||||
}
|
||||
|
||||
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 CombatEvent ParseCombatEvent(byte[] envelopeBytes)
|
||||
{
|
||||
var envelope = Envelope.Parser.ParseFrom(envelopeBytes);
|
||||
Assert.That((MessageType)envelope.Type, Is.EqualTo(MessageType.CombatEvent));
|
||||
return CombatEvent.Parser.ParseFrom(envelope.Payload);
|
||||
}
|
||||
|
||||
private static PlayerState ParsePlayerState(byte[] envelopeBytes)
|
||||
{
|
||||
var envelope = Envelope.Parser.ParseFrom(envelopeBytes);
|
||||
Assert.That((MessageType)envelope.Type, Is.EqualTo(MessageType.PlayerState));
|
||||
return PlayerState.Parser.ParseFrom(envelope.Payload);
|
||||
}
|
||||
|
||||
private sealed class FakeTransport : ITransport
|
||||
{
|
||||
public List<byte[]> BroadcastMessages { get; } = new();
|
||||
|
||||
public event Action<byte[], IPEndPoint> OnReceive;
|
||||
|
||||
public Task StartAsync()
|
||||
{
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public void Stop()
|
||||
{
|
||||
}
|
||||
|
||||
public void Send(byte[] data)
|
||||
{
|
||||
}
|
||||
|
||||
public void SendTo(byte[] data, IPEndPoint target)
|
||||
{
|
||||
}
|
||||
|
||||
public void SendToAll(byte[] data)
|
||||
{
|
||||
BroadcastMessages.Add(Copy(data));
|
||||
}
|
||||
|
||||
public void EmitReceive(byte[] data, IPEndPoint sender)
|
||||
{
|
||||
OnReceive?.Invoke(Copy(data), sender);
|
||||
}
|
||||
|
||||
private static byte[] Copy(byte[] data)
|
||||
{
|
||||
if (data == null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var copy = new byte[data.Length];
|
||||
Array.Copy(data, copy, data.Length);
|
||||
return copy;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 6152174533c74a04cb7d0b327e876a6b
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
Reference in New Issue
Block a user