process TODO.md step8
This commit is contained in:
parent
ef01760924
commit
c5fbd8e36d
|
|
@ -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:
|
||||
22
TODO.md
22
TODO.md
|
|
@ -27,7 +27,7 @@ Still missing for MVP:
|
|||
- [ ] Client-side `CombatEvent` receive/apply path
|
||||
- [x] Server startup path that actually uses `ServerNetworkHost`
|
||||
- [x] Server-authoritative movement/state loop
|
||||
- [ ] Server-authoritative shooting/combat resolution loop
|
||||
- [x] Server-authoritative shooting/combat resolution loop
|
||||
- [ ] Full `PlayerState` field application for rotation / HP / velocity
|
||||
- [ ] Remote-player snapshot buffering and interpolation strategy
|
||||
- [x] Explicit movement-stop handling via zero-input `MoveInput`
|
||||
|
|
@ -139,18 +139,18 @@ Acceptance:
|
|||
|
||||
### 8. Implement Server-Authoritative Shooting And Combat Resolution
|
||||
|
||||
- [ ] Register `ShootInput` handling on the server
|
||||
- [ ] Validate shoot requests before accepting them
|
||||
- [ ] Resolve hit, damage, death, and rejection on the server
|
||||
- [ ] Broadcast `CombatEvent` on the reliable lane
|
||||
- [ ] Reflect authoritative HP changes in subsequent `PlayerState` snapshots
|
||||
- [ ] Keep server combat resolution independent from cosmetic client preplay
|
||||
- [x] Register `ShootInput` handling on the server
|
||||
- [x] Validate shoot requests before accepting them
|
||||
- [x] Resolve hit, damage, death, and rejection on the server
|
||||
- [x] Broadcast `CombatEvent` on the reliable lane
|
||||
- [x] Reflect authoritative HP changes in subsequent `PlayerState` snapshots
|
||||
- [x] Keep server combat resolution independent from cosmetic client preplay
|
||||
|
||||
Acceptance:
|
||||
|
||||
- [ ] Server decides whether shooting is valid
|
||||
- [ ] Server emits authoritative `CombatEvent` for damage/death/rejection
|
||||
- [ ] Clients update combat state from server truth
|
||||
- [x] Server decides whether shooting is valid
|
||||
- [x] Server emits authoritative `CombatEvent` for damage/death/rejection
|
||||
- [x] Clients update combat state from server truth
|
||||
|
||||
### 9. Expand Regression Coverage From Network Layer To Gameplay Flow
|
||||
|
||||
|
|
@ -160,7 +160,7 @@ Acceptance:
|
|||
- [ ] Add tests for `CombatEvent` receive/apply behavior
|
||||
- [ ] Add tests for remote `PlayerState` buffering / interpolation decisions where practical
|
||||
- [x] Add tests for server-authoritative movement processing
|
||||
- [ ] Add tests for server-authoritative shooting/combat result generation
|
||||
- [x] 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`
|
||||
|
||||
Acceptance:
|
||||
|
|
|
|||
|
|
@ -0,0 +1,2 @@
|
|||
schema: spec-driven
|
||||
created: 2026-03-29
|
||||
|
|
@ -0,0 +1,49 @@
|
|||
## Context
|
||||
|
||||
The repository now has a concrete server runtime entry point and a shared `ServerNetworkHost` that already owns per-peer authoritative movement state and fixed-cadence `PlayerState` broadcast. Clients can send `ShootInput` and apply inbound `CombatEvent`, but no shared server path currently accepts `ShootInput` or mutates authoritative HP from combat. The networking layer also has an explicit split between high-frequency sync traffic and reliable gameplay events, so combat resolution needs to fit the existing message contracts instead of introducing another transport abstraction.
|
||||
|
||||
A second constraint is code placement: shared networking code under `Assets/Scripts/Network/` must remain Unity-free. That rules out designs that depend on scene physics or Unity colliders inside the shared server path. The MVP therefore needs a deterministic server combat loop that can be exercised by edit-mode tests and owned entirely by shared host/runtime code.
|
||||
|
||||
## Goals / Non-Goals
|
||||
|
||||
**Goals:**
|
||||
- Add a shared server combat coordinator that registers `ShootInput` on `ServerNetworkHost` and validates each request against the sending peer.
|
||||
- Keep combat ownership server-side: authoritative HP, damage, death, and shoot rejection are resolved in the server host path and never inferred from client presentation.
|
||||
- Reuse existing message contracts by emitting `CombatEvent` on the reliable lane and folding resulting HP into later `PlayerState` snapshots.
|
||||
- Preserve per-peer isolation so one sender's shoot tick history or invalid payload cannot corrupt another managed session's combat state.
|
||||
- Keep the MVP implementation deterministic and easy to regression test from edit-mode tests.
|
||||
|
||||
**Non-Goals:**
|
||||
- Full projectile simulation, lag compensation, or physics-based hit scanning.
|
||||
- Unity-scene integration, VFX timing, or client cosmetic preplay logic.
|
||||
- Advanced anti-cheat policy beyond sender identity validation, stale filtering, and basic target eligibility checks.
|
||||
- Broader gameplay systems such as respawn, inventory, or weapon-specific balance rules.
|
||||
|
||||
## Decisions
|
||||
|
||||
### Decision: Use a dedicated server-authoritative combat coordinator beside movement authority
|
||||
The server already centralizes movement authority inside `ServerAuthoritativeMovementCoordinator`. Combat should follow the same pattern with a focused coordinator that is constructed by `ServerNetworkHost`, owns per-peer combat bookkeeping, and exposes only the minimal inspection/update hooks needed by runtime code and tests.
|
||||
|
||||
This keeps combat logic out of `MessageManager` and avoids overloading `MultiSessionManager` with gameplay concerns. An alternative was to fold shooting directly into `ServerNetworkHost`, but that would make host orchestration responsible for validation rules, state mutation, and message emission simultaneously.
|
||||
|
||||
### Decision: Define the MVP hit model around sender-scoped validation plus explicit target lookup
|
||||
The shared server path will treat `ShootInput` as a request to attack a specific managed peer identified by `targetId`. A request is accepted only when the sender maps to a managed authoritative player state, the `playerId` matches that peer, the tick is newer than the sender's last accepted shoot tick, the aim vector is finite and non-zero, and the target resolves to another managed peer that is still alive.
|
||||
|
||||
This deliberately favors a narrow target-based authority model over scene-geometry hit checks. The alternative was to leave hit validation abstract or physics-driven, but that would either make the spec untestable or force Unity-only dependencies into shared networking code.
|
||||
|
||||
### Decision: Emit one reliable `CombatEvent` per authoritative combat outcome and keep rejection explicit
|
||||
When a valid shot is accepted, the server will apply deterministic combat resolution immediately and emit authoritative `CombatEvent` messages through the existing reliable-lane contract. Accepted shots may produce `Hit`, `DamageApplied`, and `Death` events as needed; invalid shots produce `ShootRejected` instead of being dropped silently.
|
||||
|
||||
Keeping rejection explicit improves observability and aligns with the current client-side `CombatEvent` handling path. An alternative was to let invalid fire requests disappear without a response, but that would make client/server divergence harder to diagnose.
|
||||
|
||||
### Decision: Keep authoritative HP in the shared player state model rather than a separate combat-only snapshot
|
||||
The movement authority work already introduced server-owned per-peer `PlayerState` snapshots. Combat resolution should update the same server-owned state so later `PlayerState` broadcasts naturally include the current authoritative HP alongside position, rotation, velocity, and tick.
|
||||
|
||||
The alternative was a separate combat-state store plus ad hoc synchronization into `PlayerState`, but that creates two competing sources of truth for the same player's HP.
|
||||
|
||||
## Risks / Trade-offs
|
||||
|
||||
- [Risk] The target-id-based MVP hit model is less realistic than spatial hit detection. → Mitigation: document it as an MVP constraint and keep the coordinator boundary narrow so later geometry-aware validation can replace only the acceptance rule.
|
||||
- [Risk] Emitting multiple `CombatEvent` messages for one accepted shot increases event volume on the reliable lane. → Mitigation: keep the event set minimal and reserve it for state-changing outcomes (`Hit`, `DamageApplied`, `Death`, `ShootRejected`).
|
||||
- [Risk] HP updates now come from both movement snapshots and combat resolution paths. → Mitigation: make combat mutate the same authoritative player-state object that movement broadcast already reads.
|
||||
- [Risk] Reliable ordered shooting requests do not need stale filtering as aggressively as sync traffic, but duplicate or out-of-order resends could still replay damage. → Mitigation: keep a per-peer last accepted shoot tick and reject non-increasing ticks for that sender.
|
||||
|
|
@ -0,0 +1,23 @@
|
|||
## Why
|
||||
|
||||
The networking MVP still stops at authoritative movement. Clients can already send `ShootInput` and receive `CombatEvent`, but the shared server path does not yet validate shooting, resolve combat outcomes, or drive authoritative HP changes back into the replicated state model. Completing this closes the main gameplay-authority gap in the MVP and prevents combat truth from drifting back to client-side presentation code.
|
||||
|
||||
## What Changes
|
||||
|
||||
- Add a dedicated shared server-authoritative combat capability that accepts `ShootInput`, validates sender-scoped fire requests, and resolves hit, damage, death, and rejection outcomes on the server.
|
||||
- Broadcast authoritative `CombatEvent` messages on the reliable lane and keep rejection results explicit instead of silently dropping invalid fire requests.
|
||||
- Extend server-owned player state so authoritative HP changes produced by combat resolution are reflected in subsequent `PlayerState` snapshots.
|
||||
- Preserve per-peer isolation for shoot validation and combat bookkeeping in the multi-session server host/runtime path.
|
||||
|
||||
## Capabilities
|
||||
|
||||
### New Capabilities
|
||||
- `server-authoritative-combat`: Defines how the shared server path validates `ShootInput`, resolves authoritative combat outcomes, and emits `CombatEvent` results.
|
||||
|
||||
### Modified Capabilities
|
||||
- `server-authoritative-movement`: Expand authoritative `PlayerState` broadcast requirements so combat-driven HP changes are reflected in later snapshots.
|
||||
- `multi-session-lifecycle`: Clarify that sender-scoped authoritative input validation and combat bookkeeping remain isolated per managed peer.
|
||||
|
||||
## Impact
|
||||
|
||||
Affected areas include `Assets/Scripts/Network/NetworkHost/`, shared message-routing/runtime composition in `Assets/Scripts/Network/NetworkApplication/`, edit-mode regression tests under `Assets/Tests/EditMode/Network/`, and `TODO.md` / OpenSpec change tracking. No transport contract changes are expected; the work builds on the existing `ShootInput`, `CombatEvent`, and `PlayerState` message types.
|
||||
|
|
@ -0,0 +1,24 @@
|
|||
## 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, authoritative movement tick rules, and authoritative combat input rules for each managed session independently using the shared session lifecycle vocabulary. Server-side stale-input acceptance, shoot validation, and authoritative gameplay 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
|
||||
|
||||
#### Scenario: Shoot validation remains peer-scoped
|
||||
- **WHEN** two managed peers send `ShootInput` traffic with different tick progress, target choices, or validation failures
|
||||
- **THEN** acceptance and rejection are evaluated independently for each sending peer
|
||||
- **THEN** one peer's stale or invalid shoot request does not overwrite or suppress the other's authoritative combat state
|
||||
|
|
@ -0,0 +1,40 @@
|
|||
## ADDED Requirements
|
||||
|
||||
### Requirement: Server registers and validates `ShootInput` per peer
|
||||
The shared server networking path SHALL register `ShootInput` handling through the server host/runtime composition and SHALL validate each inbound shooting request against the sending managed peer before applying any authoritative combat result. Validation MUST reject malformed numeric input, missing or mismatched player identity, non-increasing shoot ticks for that sender, zero-direction fire requests, and targets that do not resolve to a living managed peer.
|
||||
|
||||
#### Scenario: Valid `ShootInput` is accepted for the sending peer
|
||||
- **WHEN** a managed peer sends a well-formed `ShootInput` whose `playerId` maps to that sender, whose tick is newer than the sender's last accepted shoot tick, and whose `targetId` resolves to another living managed peer
|
||||
- **THEN** the server accepts the request for that sender only
|
||||
- **THEN** the sender's last accepted shoot tick is updated without changing other peers' combat bookkeeping
|
||||
|
||||
#### Scenario: Invalid `ShootInput` is rejected without mutating authoritative combat state
|
||||
- **WHEN** a managed peer sends a `ShootInput` with malformed direction data, a stale tick, a mismatched `playerId`, or a target that is missing, self-targeted, or already dead
|
||||
- **THEN** the server rejects that request
|
||||
- **THEN** no authoritative damage or death state is applied to any peer from that rejected request
|
||||
|
||||
### Requirement: Server resolves authoritative combat outcomes
|
||||
The shared server networking path SHALL own final combat resolution for accepted shots, including hit acceptance, damage application, authoritative HP mutation, and death determination. Combat resolution MUST update the authoritative server-owned state of both the attacker and target as needed without delegating gameplay truth to client-side prediction or presentation code.
|
||||
|
||||
#### Scenario: Accepted shot applies damage to the authoritative target state
|
||||
- **WHEN** the server accepts a `ShootInput` that targets a living managed peer
|
||||
- **THEN** it resolves the shot as an authoritative hit against that target
|
||||
- **THEN** it reduces the target's authoritative HP according to the configured damage rule before later state snapshots are broadcast
|
||||
|
||||
#### Scenario: Lethal damage marks the authoritative target as dead
|
||||
- **WHEN** an accepted shot reduces a target's authoritative HP to zero or below
|
||||
- **THEN** the server clamps the target's authoritative HP to zero
|
||||
- **THEN** subsequent combat and state broadcast treat that target as dead until a later server-owned lifecycle change resets it
|
||||
|
||||
### Requirement: Server emits reliable authoritative `CombatEvent` results
|
||||
The shared server networking path SHALL emit authoritative combat outcomes through `CombatEvent` messages using the existing reliable-lane delivery contract. Accepted shots MUST produce reliable events for hit and damage application, and lethal results MUST also produce a death event. Rejected shots MUST produce a reliable `ShootRejected` event that identifies the attacker and rejected target context.
|
||||
|
||||
#### Scenario: Accepted shot produces authoritative hit and damage events
|
||||
- **WHEN** the server resolves an accepted shot that damages a living target
|
||||
- **THEN** it broadcasts `CombatEvent` messages on the reliable lane for the authoritative combat result
|
||||
- **THEN** the emitted events identify the attacker, target, damage, and authoritative tick for client-side application
|
||||
|
||||
#### Scenario: Rejected shot produces an authoritative rejection event
|
||||
- **WHEN** the server rejects a `ShootInput` during validation
|
||||
- **THEN** it broadcasts a reliable `CombatEvent` with event type `ShootRejected`
|
||||
- **THEN** clients can observe that rejection without inferring local authoritative damage or hit success
|
||||
|
|
@ -0,0 +1,19 @@
|
|||
## MODIFIED Requirements
|
||||
|
||||
### 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 authoritative player state and include the authoritative tick for client reconciliation and interpolation. Authoritative HP changes produced by server-side combat resolution MUST be reflected in later snapshots for the affected peer.
|
||||
|
||||
#### 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 player 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, HP, and tick from server-owned state
|
||||
|
||||
#### Scenario: Combat-driven HP changes appear in later player snapshots
|
||||
- **WHEN** the server applies authoritative combat damage or death to a managed peer
|
||||
- **THEN** later `PlayerState` snapshots for that peer carry the updated authoritative HP value
|
||||
- **THEN** clients do not need to invent or persist a separate HP truth outside authoritative server snapshots
|
||||
|
||||
#### 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
|
||||
|
|
@ -0,0 +1,17 @@
|
|||
## 1. Authoritative Combat Core
|
||||
|
||||
- [x] 1.1 Add a dedicated server-authoritative combat coordinator and per-peer combat state model under `Assets/Scripts/Network/NetworkHost/`.
|
||||
- [x] 1.2 Register `ShootInput` handling through the server host/runtime composition path and validate sender-scoped shooting payloads before accepting them.
|
||||
- [x] 1.3 Resolve accepted shots against managed peers, mutate authoritative HP/death state, and emit reliable `CombatEvent` results including explicit `ShootRejected` responses.
|
||||
|
||||
## 2. Runtime And State Integration
|
||||
|
||||
- [x] 2.1 Reuse or extend the server-owned authoritative player state model so combat resolution updates the same per-peer state consumed by `PlayerState` broadcast.
|
||||
- [x] 2.2 Expose the minimal runtime/host surface needed for host processes and tests to inspect authoritative combat state and drive any required combat update hooks.
|
||||
- [x] 2.3 Preserve per-peer isolation for shoot tick validation, target lookup, and removal/cleanup when sessions disconnect or the runtime stops.
|
||||
|
||||
## 3. Regression Coverage And Tracking
|
||||
|
||||
- [x] 3.1 Add edit-mode regression tests for accepted versus rejected `ShootInput` handling across multiple peers.
|
||||
- [x] 3.2 Add edit-mode regression tests for authoritative damage/death resolution, reliable `CombatEvent` broadcast, and HP propagation into later `PlayerState` snapshots.
|
||||
- [x] 3.3 Update `TODO.md` and related change tracking/docs to reflect the completed server-authoritative shooting/combat resolution work.
|
||||
|
|
@ -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, 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.
|
||||
The multi-session lifecycle coordinator SHALL expose per-session lookup or enumeration and MUST evaluate timeout, heartbeat, login, reconnect, authoritative movement tick rules, and authoritative combat input rules for each managed session independently using the shared session lifecycle vocabulary. Server-side stale-input acceptance, shoot validation, and authoritative gameplay 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
|
||||
|
|
@ -30,6 +30,11 @@ The multi-session lifecycle coordinator SHALL expose per-session lookup or enume
|
|||
- **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
|
||||
|
||||
#### Scenario: Shoot validation remains peer-scoped
|
||||
- **WHEN** two managed peers send `ShootInput` traffic with different tick progress, target choices, or validation failures
|
||||
- **THEN** acceptance and rejection are evaluated independently for each sending peer
|
||||
- **THEN** one peer's stale or invalid shoot request does not overwrite or suppress the other's authoritative combat 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.
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,44 @@
|
|||
# server-authoritative-combat Specification
|
||||
|
||||
## Purpose
|
||||
Define the shared server-side combat authority contract that accepts `ShootInput`, validates sender-scoped fire requests, resolves authoritative hit and damage outcomes, and emits reliable `CombatEvent` results.
|
||||
|
||||
## Requirements
|
||||
### Requirement: Server registers and validates `ShootInput` per peer
|
||||
The shared server networking path SHALL register `ShootInput` handling through the server host/runtime composition and SHALL validate each inbound shooting request against the sending managed peer before applying any authoritative combat result. Validation MUST reject malformed numeric input, missing or mismatched player identity, non-increasing shoot ticks for that sender, zero-direction fire requests, and targets that do not resolve to a living managed peer.
|
||||
|
||||
#### Scenario: Valid `ShootInput` is accepted for the sending peer
|
||||
- **WHEN** a managed peer sends a well-formed `ShootInput` whose `playerId` maps to that sender, whose tick is newer than the sender's last accepted shoot tick, and whose `targetId` resolves to another living managed peer
|
||||
- **THEN** the server accepts the request for that sender only
|
||||
- **THEN** the sender's last accepted shoot tick is updated without changing other peers' combat bookkeeping
|
||||
|
||||
#### Scenario: Invalid `ShootInput` is rejected without mutating authoritative combat state
|
||||
- **WHEN** a managed peer sends a `ShootInput` with malformed direction data, a stale tick, a mismatched `playerId`, or a target that is missing, self-targeted, or already dead
|
||||
- **THEN** the server rejects that request
|
||||
- **THEN** no authoritative damage or death state is applied to any peer from that rejected request
|
||||
|
||||
### Requirement: Server resolves authoritative combat outcomes
|
||||
The shared server networking path SHALL own final combat resolution for accepted shots, including hit acceptance, damage application, authoritative HP mutation, and death determination. Combat resolution MUST update the authoritative server-owned state of both the attacker and target as needed without delegating gameplay truth to client-side prediction or presentation code.
|
||||
|
||||
#### Scenario: Accepted shot applies damage to the authoritative target state
|
||||
- **WHEN** the server accepts a `ShootInput` that targets a living managed peer
|
||||
- **THEN** it resolves the shot as an authoritative hit against that target
|
||||
- **THEN** it reduces the target's authoritative HP according to the configured damage rule before later state snapshots are broadcast
|
||||
|
||||
#### Scenario: Lethal damage marks the authoritative target as dead
|
||||
- **WHEN** an accepted shot reduces a target's authoritative HP to zero or below
|
||||
- **THEN** the server clamps the target's authoritative HP to zero
|
||||
- **THEN** subsequent combat and state broadcast treat that target as dead until a later server-owned lifecycle change resets it
|
||||
|
||||
### Requirement: Server emits reliable authoritative `CombatEvent` results
|
||||
The shared server networking path SHALL emit authoritative combat outcomes through `CombatEvent` messages using the existing reliable-lane delivery contract. Accepted shots MUST produce reliable events for hit and damage application, and lethal results MUST also produce a death event. Rejected shots MUST produce a reliable `ShootRejected` event that identifies the attacker and rejected target context.
|
||||
|
||||
#### Scenario: Accepted shot produces authoritative hit and damage events
|
||||
- **WHEN** the server resolves an accepted shot that damages a living target
|
||||
- **THEN** it broadcasts `CombatEvent` messages on the reliable lane for the authoritative combat result
|
||||
- **THEN** the emitted events identify the attacker, target, damage, and authoritative tick for client-side application
|
||||
|
||||
#### Scenario: Rejected shot produces an authoritative rejection event
|
||||
- **WHEN** the server rejects a `ShootInput` during validation
|
||||
- **THEN** it broadcasts a reliable `CombatEvent` with event type `ShootRejected`
|
||||
- **THEN** clients can observe that rejection without inferring local authoritative damage or hit success
|
||||
|
|
@ -31,12 +31,17 @@ The shared server networking path SHALL own the final movement state for each ma
|
|||
- **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.
|
||||
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 authoritative player state and include the authoritative tick for client reconciliation and interpolation. Authoritative HP changes produced by server-side combat resolution MUST be reflected in later snapshots for the affected peer.
|
||||
|
||||
#### 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
|
||||
- **WHEN** the server reaches a configured authority broadcast cadence while one or more managed peers have authoritative player 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
|
||||
- **THEN** each snapshot includes the authoritative position, rotation, velocity, HP, and tick from server-owned state
|
||||
|
||||
#### Scenario: Combat-driven HP changes appear in later player snapshots
|
||||
- **WHEN** the server applies authoritative combat damage or death to a managed peer
|
||||
- **THEN** later `PlayerState` snapshots for that peer carry the updated authoritative HP value
|
||||
- **THEN** clients do not need to invent or persist a separate HP truth outside authoritative server snapshots
|
||||
|
||||
#### Scenario: Reliable transport remains fallback when no sync transport exists
|
||||
- **WHEN** the server broadcasts authoritative `PlayerState` snapshots without a dedicated sync transport
|
||||
|
|
|
|||
Loading…
Reference in New Issue