process TODO.md step 10

This commit is contained in:
SepComet 2026-03-29 12:43:57 +08:00
parent c5fbd8e36d
commit fbc09186f3
21 changed files with 913 additions and 19 deletions

View File

@ -50,6 +50,36 @@ public static class ClientGameplayInputFlow
TargetId = targetId ?? string.Empty TargetId = targetId ?? string.Empty
}; };
} }
public static void SendShootInput(
MessageManager messageManager,
string playerId,
long tick,
Vector3 aimDirection,
string targetId = "")
{
if (messageManager == null)
{
throw new System.ArgumentNullException(nameof(messageManager));
}
SendShootInput(messageManager, CreateShootInput(playerId, tick, aimDirection, targetId));
}
public static void SendShootInput(MessageManager messageManager, ShootInput message)
{
if (messageManager == null)
{
throw new System.ArgumentNullException(nameof(messageManager));
}
if (message == null)
{
throw new System.ArgumentNullException(nameof(message));
}
messageManager.SendMessage(message, MessageType.ShootInput);
}
} }
public class MovementComponent : MonoBehaviour public class MovementComponent : MonoBehaviour

View File

@ -215,12 +215,13 @@ public class NetworkManager : MonoBehaviour
public void SendShootInput(string playerId, Vector3 direction, long tick = 0, string targetId = "") public void SendShootInput(string playerId, Vector3 direction, long tick = 0, string targetId = "")
{ {
SendShootInput(ClientGameplayInputFlow.CreateShootInput(playerId, tick, direction, targetId)); ClientGameplayInputFlow.SendShootInput(_networkRuntime.MessageManager, playerId, tick, direction, targetId);
Debug.Log($"PlayerShootSeq: {_sequence++}");
} }
public void SendShootInput(ShootInput message) public void SendShootInput(ShootInput message)
{ {
_networkRuntime.MessageManager.SendMessage(message, MessageType.ShootInput); ClientGameplayInputFlow.SendShootInput(_networkRuntime.MessageManager, message);
Debug.Log($"PlayerShootSeq: {_sequence++}"); Debug.Log($"PlayerShootSeq: {_sequence++}");
} }

View File

@ -0,0 +1,161 @@
using System.Net;
using Network.Defines;
using Network.NetworkApplication;
using NUnit.Framework;
using UnityEngine;
using Vector3 = UnityEngine.Vector3;
namespace Tests.EditMode.Network
{
public class ClientGameplayFlowTests
{
private static readonly IPEndPoint Sender = new(IPAddress.Loopback, 9300);
[Test]
public void ClientGameplayInputFlow_SendShootInput_UsesDedicatedGameplayPathAndReliableLane()
{
var reliableTransport = new GameplayFlowFakeTransport();
var syncTransport = new GameplayFlowFakeTransport();
var manager = new MessageManager(
reliableTransport,
new MainThreadNetworkDispatcher(),
new DefaultMessageDeliveryPolicyResolver(),
syncTransport);
ClientGameplayInputFlow.SendShootInput(
manager,
"player-1",
17,
new Vector3(3f, 0f, 4f),
"enemy-1");
Assert.That(reliableTransport.SentMessages.Count, Is.EqualTo(1));
Assert.That(syncTransport.SentMessages.Count, Is.EqualTo(0));
var envelope = Envelope.Parser.ParseFrom(reliableTransport.SentMessages[0]);
var shootInput = ShootInput.Parser.ParseFrom(envelope.Payload);
Assert.That((MessageType)envelope.Type, Is.EqualTo(MessageType.ShootInput));
Assert.That(shootInput.PlayerId, Is.EqualTo("player-1"));
Assert.That(shootInput.Tick, Is.EqualTo(17));
Assert.That(shootInput.DirX, Is.EqualTo(0.6f).Within(0.0001f));
Assert.That(shootInput.DirY, Is.EqualTo(0.8f).Within(0.0001f));
Assert.That(shootInput.TargetId, Is.EqualTo("enemy-1"));
}
[Test]
public void SharedNetworkRuntime_CombatEventReceivePath_AppliesAuthoritativeDamageAndDeath()
{
var transport = new GameplayFlowFakeTransport();
var runtime = new SharedNetworkRuntime(transport, new MainThreadNetworkDispatcher());
var harness = new ClientGameplayTestHarness("player-1");
harness.Register(runtime.MessageManager);
transport.EmitReceive(
GameplayFlowTestSupport.BuildEnvelope(
MessageType.PlayerState,
GameplayFlowTestSupport.CreatePlayerState("player-1", 10, Vector3.zero, hp: 100)),
Sender);
transport.EmitReceive(
GameplayFlowTestSupport.BuildEnvelope(
MessageType.CombatEvent,
new CombatEvent
{
Tick = 11,
EventType = CombatEventType.DamageApplied,
AttackerId = "enemy-1",
TargetId = "player-1",
Damage = 35,
HitPosition = new global::Network.Defines.Vector3 { X = 2f, Y = 0f, Z = 1f }
}),
Sender);
transport.EmitReceive(
GameplayFlowTestSupport.BuildEnvelope(
MessageType.CombatEvent,
new CombatEvent
{
Tick = 12,
EventType = CombatEventType.Death,
AttackerId = "enemy-1",
TargetId = "player-1"
}),
Sender);
runtime.DrainPendingMessagesAsync().GetAwaiter().GetResult();
Assert.That(harness.TryGetState("player-1", out var snapshot), Is.True);
Assert.That(snapshot.Hp, Is.EqualTo(0));
Assert.That(harness.TryGetCombatPresentation("player-1", out var combatPresentation), Is.True);
Assert.That(combatPresentation.HasLastEvent, Is.True);
Assert.That(combatPresentation.LastEventType, Is.EqualTo(CombatEventType.Death));
Assert.That(combatPresentation.IsDead, Is.True);
}
[Test]
public void SharedNetworkRuntime_CombatEventReceivePath_RoutesShootRejectedToAttackerDiagnostics()
{
var transport = new GameplayFlowFakeTransport();
var runtime = new SharedNetworkRuntime(transport, new MainThreadNetworkDispatcher());
var harness = new ClientGameplayTestHarness("player-1");
harness.Register(runtime.MessageManager);
transport.EmitReceive(
GameplayFlowTestSupport.BuildEnvelope(
MessageType.PlayerState,
GameplayFlowTestSupport.CreatePlayerState("player-1", 20, Vector3.zero, hp: 90)),
Sender);
transport.EmitReceive(
GameplayFlowTestSupport.BuildEnvelope(
MessageType.CombatEvent,
new CombatEvent
{
Tick = 21,
EventType = CombatEventType.ShootRejected,
AttackerId = "player-1",
TargetId = "enemy-2"
}),
Sender);
runtime.DrainPendingMessagesAsync().GetAwaiter().GetResult();
Assert.That(harness.TryGetState("player-1", out var snapshot), Is.True);
Assert.That(snapshot.Hp, Is.EqualTo(90));
Assert.That(harness.TryGetCombatPresentation("player-1", out var combatPresentation), Is.True);
Assert.That(combatPresentation.LastEventType, Is.EqualTo(CombatEventType.ShootRejected));
Assert.That(combatPresentation.LastDamage, Is.EqualTo(0));
Assert.That(combatPresentation.IsDead, Is.False);
}
[Test]
public void ClientGameplayHarness_RemotePlayerStateFlow_RejectsStaleSnapshots_AndUsesInterpolationOrLatestClamp()
{
var harness = new ClientGameplayTestHarness("local-player");
var firstAccepted = harness.HandlePlayerState(
GameplayFlowTestSupport.CreatePlayerState("remote-player", 10, new Vector3(0f, 0f, 0f), rotation: 0f),
receivedAtSeconds: 0f);
var secondAccepted = harness.HandlePlayerState(
GameplayFlowTestSupport.CreatePlayerState("remote-player", 11, new Vector3(10f, 0f, 0f), rotation: 90f),
receivedAtSeconds: 0.05f);
var staleAccepted = harness.HandlePlayerState(
GameplayFlowTestSupport.CreatePlayerState("remote-player", 9, new Vector3(99f, 0f, 0f), rotation: 180f),
receivedAtSeconds: 0.06f);
var interpolated = harness.SampleRemote("remote-player", 0.125f);
var clamped = harness.SampleRemote("remote-player", 0.35f);
Assert.That(firstAccepted, Is.True);
Assert.That(secondAccepted, Is.True);
Assert.That(staleAccepted, Is.False);
Assert.That(harness.GetBufferedSnapshotCount("remote-player"), Is.EqualTo(1));
Assert.That(harness.GetLatestBufferedTick("remote-player"), Is.EqualTo(11));
Assert.That(interpolated.HasValue, Is.True);
Assert.That(interpolated.UsedInterpolation, Is.True);
Assert.That(interpolated.Position.x, Is.EqualTo(5f).Within(0.001f));
Assert.That(interpolated.Rotation.eulerAngles.y, Is.EqualTo(45f).Within(0.01f));
Assert.That(clamped.HasValue, Is.True);
Assert.That(clamped.UsedInterpolation, Is.False);
Assert.That(clamped.LatestSnapshot.Tick, Is.EqualTo(11));
Assert.That(clamped.Position, Is.EqualTo(new Vector3(10f, 0f, 0f)));
}
}
}

View File

@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 6a4fcc49ae3441f6bc9f251af0f1f067
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View File

@ -0,0 +1,139 @@
using System;
using System.Collections.Generic;
using System.Net;
using Network.Defines;
using Network.NetworkApplication;
using Network.NetworkHost;
using NUnit.Framework;
using UnityEngine;
using Vector3 = UnityEngine.Vector3;
namespace Tests.EditMode.Network
{
public class GameplayFlowRoundTripTests
{
private static readonly IPEndPoint ClientPeer = new(IPAddress.Loopback, 9401);
private static readonly IPEndPoint RemotePeer = new(IPAddress.Loopback, 9402);
private static readonly IPEndPoint ServerSender = new(IPAddress.Loopback, 9000);
[Test]
public void FakeTransportRoundTrip_MoveInputAndShootInput_ProduceAuthoritativePlayerStateAndCombatEvent()
{
var clientReliableTransport = new GameplayFlowFakeTransport();
var clientSyncTransport = new GameplayFlowFakeTransport();
var clientRuntime = new SharedNetworkRuntime(
clientReliableTransport,
new MainThreadNetworkDispatcher(),
syncTransport: clientSyncTransport);
var clientHarness = new ClientGameplayTestHarness("player-a");
clientHarness.Register(clientRuntime.MessageManager);
var serverTransports = new Dictionary<int, GameplayFlowFakeTransport>();
var configuration = new ServerRuntimeConfiguration(9000)
{
SyncPort = 9001,
Dispatcher = new MainThreadNetworkDispatcher(),
TransportFactory = port => CreateTransport(serverTransports, port),
AuthoritativeMovement = new ServerAuthoritativeMovementConfiguration
{
MoveSpeed = 10f,
BroadcastInterval = TimeSpan.FromMilliseconds(50),
DefaultHp = 100
},
AuthoritativeCombat = new ServerAuthoritativeCombatConfiguration
{
DamagePerShot = 30
}
};
clientRuntime.StartAsync().GetAwaiter().GetResult();
using var serverRuntime = ServerRuntimeEntryPoint.StartAsync(configuration).GetAwaiter().GetResult();
serverTransports[9001].EmitReceive(
GameplayFlowTestSupport.BuildEnvelope(
MessageType.MoveInput,
new MoveInput
{
PlayerId = "player-b",
Tick = 1,
MoveX = 0f,
MoveY = 0f
}),
RemotePeer);
serverRuntime.DrainPendingMessagesAsync().GetAwaiter().GetResult();
serverTransports[9001].ClearOutgoing();
serverTransports[9000].ClearOutgoing();
clientRuntime.MessageManager.SendMessage(
new MoveInput
{
PlayerId = "player-a",
Tick = 1,
MoveX = 1f,
MoveY = 0f
},
MessageType.MoveInput);
ClientGameplayInputFlow.SendShootInput(
clientRuntime.MessageManager,
"player-a",
2,
Vector3.right,
"player-b");
TransferSentMessages(clientSyncTransport, serverTransports[9001], ClientPeer);
TransferSentMessages(clientReliableTransport, serverTransports[9000], ClientPeer);
serverRuntime.DrainPendingMessagesAsync().GetAwaiter().GetResult();
serverRuntime.UpdateAuthoritativeMovement(TimeSpan.FromMilliseconds(50));
TransferBroadcastMessages(serverTransports[9000], clientReliableTransport, ServerSender);
clientRuntime.DrainPendingMessagesAsync().GetAwaiter().GetResult();
TransferBroadcastMessages(serverTransports[9001], clientSyncTransport, ServerSender);
clientRuntime.DrainPendingMessagesAsync().GetAwaiter().GetResult();
Assert.That(serverRuntime.TryGetAuthoritativeMovementState(ClientPeer, out var localServerState), Is.True);
Assert.That(localServerState.PlayerId, Is.EqualTo("player-a"));
Assert.That(localServerState.PositionX, Is.EqualTo(0.5f).Within(0.0001f));
Assert.That(serverRuntime.TryGetAuthoritativeCombatState(RemotePeer, out var remoteCombatState), Is.True);
Assert.That(remoteCombatState.PlayerId, Is.EqualTo("player-b"));
Assert.That(remoteCombatState.Hp, Is.EqualTo(70));
Assert.That(clientHarness.TryGetState("player-a", out var localClientState), Is.True);
Assert.That(localClientState.Tick, Is.EqualTo(1));
Assert.That(localClientState.Position.x, Is.EqualTo(0.5f).Within(0.0001f));
Assert.That(clientHarness.TryGetState("player-b", out var remoteClientState), Is.True);
Assert.That(remoteClientState.Hp, Is.EqualTo(70));
Assert.That(clientHarness.TryGetCombatPresentation("player-b", out var remoteCombatPresentation), Is.True);
Assert.That(remoteCombatPresentation.LastEventType, Is.EqualTo(CombatEventType.DamageApplied));
Assert.That(remoteCombatPresentation.LastDamage, Is.EqualTo(30));
Assert.That(remoteCombatPresentation.IsDead, Is.False);
}
private static GameplayFlowFakeTransport CreateTransport(IDictionary<int, GameplayFlowFakeTransport> serverTransports, int port)
{
var transport = new GameplayFlowFakeTransport();
serverTransports.Add(port, transport);
return transport;
}
private static void TransferSentMessages(GameplayFlowFakeTransport source, GameplayFlowFakeTransport destination, IPEndPoint sender)
{
foreach (var payload in source.SentMessages)
{
destination.EmitReceive(payload, sender);
}
source.ClearOutgoing();
}
private static void TransferBroadcastMessages(GameplayFlowFakeTransport source, GameplayFlowFakeTransport destination, IPEndPoint sender)
{
foreach (var payload in source.BroadcastMessages)
{
destination.EmitReceive(payload, sender);
}
source.ClearOutgoing();
}
}
}

View File

@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: bbe6dfdb34f64d4e895f57f7191b9407
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View File

@ -0,0 +1,230 @@
using System;
using System.Collections.Generic;
using System.Net;
using System.Threading.Tasks;
using Google.Protobuf;
using Network.Defines;
using Network.NetworkApplication;
using Network.NetworkTransport;
using UnityEngine;
using Vector3 = UnityEngine.Vector3;
namespace Tests.EditMode.Network
{
internal sealed class ClientGameplayTestHarness
{
private readonly string localPlayerId;
private readonly Dictionary<string, ClientAuthoritativePlayerState> states = new();
private readonly Dictionary<string, RemotePlayerSnapshotInterpolator> remoteInterpolators = new();
public ClientGameplayTestHarness(string localPlayerId)
{
this.localPlayerId = localPlayerId ?? string.Empty;
}
public void Register(MessageManager messageManager)
{
messageManager.RegisterHandler(MessageType.PlayerState, (payload, sender) =>
{
HandlePlayerState(PlayerState.Parser.ParseFrom(payload), receivedAtSeconds: 0f);
});
messageManager.RegisterHandler(MessageType.CombatEvent, (payload, sender) =>
{
HandleCombatEvent(CombatEvent.Parser.ParseFrom(payload));
});
}
public bool HandlePlayerState(PlayerState state, float receivedAtSeconds)
{
var owner = GetOrCreateOwner(state.PlayerId);
var accepted = owner.TryAccept(state, out var snapshot);
if (!accepted)
{
return false;
}
if (!IsLocalPlayer(state.PlayerId))
{
GetOrCreateInterpolator(state.PlayerId).TryAddSnapshot(snapshot, receivedAtSeconds);
}
return true;
}
public bool HandleCombatEvent(CombatEvent combatEvent)
{
if (!ClientCombatEventRouting.TryGetAffectedPlayerId(combatEvent, out var playerId))
{
return false;
}
var owner = GetOrCreateOwner(playerId);
return owner.TryApplyCombatEvent(combatEvent, playerId, out _, out _);
}
public bool TryGetState(string playerId, out ClientAuthoritativePlayerStateSnapshot snapshot)
{
if (states.TryGetValue(playerId, out var owner) && owner.Current != null)
{
snapshot = owner.Current;
return true;
}
snapshot = null;
return false;
}
public bool TryGetCombatPresentation(string playerId, out ClientCombatPresentationSnapshot snapshot)
{
if (states.TryGetValue(playerId, out var owner))
{
snapshot = owner.CombatPresentation;
return true;
}
snapshot = ClientCombatPresentationSnapshot.Empty;
return false;
}
public int GetBufferedSnapshotCount(string playerId)
{
return remoteInterpolators.TryGetValue(playerId, out var interpolator)
? interpolator.BufferedSnapshotCount
: 0;
}
public long GetLatestBufferedTick(string playerId)
{
return remoteInterpolators.TryGetValue(playerId, out var interpolator)
? interpolator.LatestBufferedTick
: -1;
}
public RemotePlayerInterpolationSample SampleRemote(string playerId, float nowSeconds)
{
return GetOrCreateInterpolator(playerId).Sample(nowSeconds);
}
private bool IsLocalPlayer(string playerId)
{
return string.Equals(playerId, localPlayerId, StringComparison.Ordinal);
}
private ClientAuthoritativePlayerState GetOrCreateOwner(string playerId)
{
if (!states.TryGetValue(playerId, out var owner))
{
owner = new ClientAuthoritativePlayerState();
states.Add(playerId, owner);
}
return owner;
}
private RemotePlayerSnapshotInterpolator GetOrCreateInterpolator(string playerId)
{
if (!remoteInterpolators.TryGetValue(playerId, out var interpolator))
{
interpolator = new RemotePlayerSnapshotInterpolator();
remoteInterpolators.Add(playerId, interpolator);
}
return interpolator;
}
}
internal sealed class GameplayFlowFakeTransport : ITransport
{
private readonly List<byte[]> sentMessages = new();
private readonly List<byte[]> targetMessages = new();
private readonly List<(byte[] Data, IPEndPoint Target)> targetedSends = new();
private readonly List<byte[]> broadcastMessages = new();
public event Action<byte[], IPEndPoint> OnReceive;
public IReadOnlyList<byte[]> SentMessages => sentMessages;
public IReadOnlyList<byte[]> TargetMessages => targetMessages;
public IReadOnlyList<(byte[] Data, IPEndPoint Target)> TargetedSends => targetedSends;
public IReadOnlyList<byte[]> BroadcastMessages => broadcastMessages;
public Task StartAsync()
{
return Task.CompletedTask;
}
public void Stop()
{
}
public void Send(byte[] data)
{
sentMessages.Add(Copy(data));
}
public void SendTo(byte[] data, IPEndPoint target)
{
var copy = Copy(data);
targetMessages.Add(copy);
targetedSends.Add((copy, target));
}
public void SendToAll(byte[] data)
{
broadcastMessages.Add(Copy(data));
}
public void EmitReceive(byte[] data, IPEndPoint sender)
{
OnReceive?.Invoke(Copy(data), sender);
}
public void ClearOutgoing()
{
sentMessages.Clear();
targetMessages.Clear();
targetedSends.Clear();
broadcastMessages.Clear();
}
private static byte[] Copy(byte[] data)
{
if (data == null)
{
return null;
}
var copy = new byte[data.Length];
Array.Copy(data, copy, data.Length);
return copy;
}
}
internal static class GameplayFlowTestSupport
{
public static byte[] BuildEnvelope(MessageType type, IMessage payload)
{
return new Envelope
{
Type = (int)type,
Payload = payload.ToByteString()
}.ToByteArray();
}
public static PlayerState CreatePlayerState(string playerId, long tick, Vector3 position, int hp = 100, float rotation = 0f, Vector3? velocity = null)
{
var resolvedVelocity = velocity ?? Vector3.zero;
return new PlayerState
{
PlayerId = playerId,
Tick = tick,
Position = new global::Network.Defines.Vector3 { X = position.x, Y = position.y, Z = position.z },
Velocity = new global::Network.Defines.Vector3 { X = resolvedVelocity.x, Y = resolvedVelocity.y, Z = resolvedVelocity.z },
Rotation = rotation,
Hp = hp
};
}
}
}

View File

@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: c3db9ca1d45945ed9f5fd1b14922ca21
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

40
TODO.md
View File

@ -31,8 +31,8 @@ Still missing for MVP:
- [ ] Full `PlayerState` field application for rotation / HP / velocity - [ ] Full `PlayerState` field application for rotation / HP / velocity
- [ ] Remote-player snapshot buffering and interpolation strategy - [ ] Remote-player snapshot buffering and interpolation strategy
- [x] Explicit movement-stop handling via zero-input `MoveInput` - [x] Explicit movement-stop handling via zero-input `MoveInput`
- [ ] End-to-end gameplay regression coverage - [x] End-to-end gameplay regression coverage
- [ ] Re-run build/test in an environment with the required .NET runtime installed - [x] Re-run build/test in an environment with the required .NET runtime installed
## Checklist ## Checklist
@ -154,33 +154,39 @@ Acceptance:
### 9. Expand Regression Coverage From Network Layer To Gameplay Flow ### 9. Expand Regression Coverage From Network Layer To Gameplay Flow
- [ ] Extend [`Assets/Tests/EditMode/Network/MessageManagerTests.cs`](./Assets/Tests/EditMode/Network/MessageManagerTests.cs) only as needed for lane policy regressions - [x] Extend [`Assets/Tests/EditMode/Network/MessageManagerTests.cs`](./Assets/Tests/EditMode/Network/MessageManagerTests.cs) only as needed for lane policy regressions
- [x] Add tests that cover explicit zero-input movement stop behavior - [x] Add tests that cover explicit zero-input movement stop behavior
- [ ] Add tests for client `ShootInput` send routing - [x] Add tests for client `ShootInput` send routing
- [ ] Add tests for `CombatEvent` receive/apply behavior - [x] Add tests for `CombatEvent` receive/apply behavior
- [ ] Add tests for remote `PlayerState` buffering / interpolation decisions where practical - [x] Add tests for remote `PlayerState` buffering / interpolation decisions where practical
- [x] Add tests for server-authoritative movement processing - [x] Add tests for server-authoritative movement processing
- [x] 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` - [x] Add at least one end-to-end fake-transport test that covers `MoveInput -> PlayerState` and `ShootInput -> CombatEvent`
Acceptance: Acceptance:
- [ ] MVP gameplay flow is covered beyond transport-only assertions - [x] MVP gameplay flow is covered beyond transport-only assertions
- [ ] Both client single-session and server multi-session behaviors remain protected - [x] Both client single-session and server multi-session behaviors remain protected
- [ ] Regression tests fail if movement/combat authority accidentally drifts back to the client - [x] Regression tests fail if movement/combat authority accidentally drifts back to the client
### 10. Re-Verify Build And Test ### 10. Re-Verify Build And Test
- [ ] Install or use an environment that contains the required .NET runtime for this repository - [x] Install or use an environment that contains the required .NET runtime for this repository
- [ ] Run `dotnet build Network.EditMode.Tests.csproj -v minimal` - [x] Run `dotnet build Network.EditMode.Tests.csproj -v minimal`
- [ ] Run `dotnet test Network.EditMode.Tests.csproj --no-build -v minimal` - [x] Run `dotnet test Network.EditMode.Tests.csproj --no-build -v minimal`
- [ ] Record the actual result after the environment issue is resolved - [x] Record the actual result after the environment issue is resolved
Acceptance: Acceptance:
- [ ] Build succeeds in a runnable local environment - [x] Build succeeds in a runnable local environment
- [ ] Edit-mode network tests succeed - [x] Edit-mode network tests succeed
- [ ] New MVP gameplay regression tests succeed - [x] New MVP gameplay regression tests succeed
Recorded result:
- [x] Verified on 2026-03-29 in a local environment with .NET SDK 10.0.201 installed
- [x] `dotnet build Network.EditMode.Tests.csproj -v minimal` succeeded with 4 non-fatal MSB3277 warning groups about `System.Net.Http` and `System.Security.Cryptography.Algorithms` assembly-version conflicts in Unity dependencies
- [x] `dotnet test Network.EditMode.Tests.csproj --no-build -v minimal` succeeded, covering the edit-mode network and MVP gameplay regression suite
## Recommended Order ## Recommended Order

View File

@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-03-29

View File

@ -0,0 +1,53 @@
## Context
The repository already has solid shared-network regression coverage for message typing, lane policy, stale filtering, and recent server-authoritative movement/combat behavior. What remains thin is the gameplay-flow layer that joins client input send paths, client receive/apply paths, remote snapshot decisions, and at least one fake-transport round trip across the server authority loop. The main constraint is to improve confidence without turning edit-mode tests into Unity-scene integration tests or changing shared networking contracts just to satisfy test code.
## Goals / Non-Goals
**Goals:**
- Add gameplay-flow regression tests that protect the MVP contract from client input through authoritative server outputs and client-side application.
- Keep low-level routing assertions in focused tests while moving broader gameplay expectations into runtime-level regression fixtures.
- Reuse existing fake transports, host/runtime entry points, and explicit runtime handles where possible.
- Introduce only minimal test seams when existing runtime surfaces cannot expose the state needed for stable assertions.
**Non-Goals:**
- Rework production gameplay architecture beyond what is minimally required for observability in tests.
- Add Unity play-mode or scene-driven end-to-end automation.
- Change message definitions, delivery policy rules, or authority ownership as part of this testing change.
## Decisions
### Decision: Keep regression coverage layered instead of expanding `MessageManagerTests`
`MessageManagerTests` should remain responsible for lane-policy and message-routing invariants only. Gameplay send/receive/application assertions belong in higher-level edit-mode tests that exercise `NetworkManager`, authoritative client state application, interpolation buffers, and server runtime handles together.
Alternative considered: continue adding gameplay assertions into `MessageManagerTests`.
- Rejected because it would blur transport-policy failures with gameplay-flow failures and make the tests harder to maintain.
### Decision: Prefer existing fake transports and runtime surfaces, add only narrow observability seams
The test suite should keep using fake transports and explicit runtime handles to drive inputs and inspect outputs. If a client-side flow cannot be asserted without reaching into private state, add a narrow seam such as an inspectable snapshot buffer view or combat-application callback surface rather than introducing Unity-only dependencies into shared code.
Alternative considered: add broad test-only hooks or mock-heavy abstractions around the whole networking layer.
- Rejected because it would distort the production architecture and create maintenance burden unrelated to MVP behavior.
### Decision: Add one explicit fake-transport gameplay round-trip that spans client and server responsibilities
Beyond isolated unit-style tests, the suite should include at least one end-to-end fake-transport regression that proves `MoveInput -> PlayerState` and `ShootInput -> CombatEvent` still flow through the MVP authority model. This test should stay deterministic, edit-mode friendly, and limited to the shared/client runtime surfaces already used in the repository.
Alternative considered: rely only on separate client tests and separate server tests.
- Rejected because it would leave the handoff between client send paths and authoritative server outputs unprotected.
## Risks / Trade-offs
- [Risk] Gameplay-flow tests may become brittle if they assert too many incidental details. → Mitigation: assert stable observable outcomes such as lane choice, accepted state updates, interpolation decisions, and authoritative event types rather than internal call order unless the order is part of the contract.
- [Risk] End-to-end fake-transport tests may need extra setup and slow the suite. → Mitigation: keep the number of full-flow tests small and reuse focused fixtures/helpers.
- [Risk] Client observability needs may tempt test-only architecture changes. → Mitigation: require every new seam to be narrow, production-safe, and useful for diagnostics as well as tests.
## Migration Plan
1. Add or extend edit-mode fixtures for client send/receive/application flow and remote snapshot buffering.
2. Add one deterministic fake-transport end-to-end gameplay test that drives both movement and combat authority.
3. Leave existing lane-policy tests in place, trimming or extending `MessageManagerTests` only where delivery-policy regressions specifically need coverage.
4. Update TODO/OpenSpec task tracking after the regression suite protects the MVP gameplay loop.
## Open Questions
- None. The remaining work is implementation detail inside the agreed MVP gameplay surfaces.

View File

@ -0,0 +1,22 @@
## Why
The MVP networking flow now has concrete client input, client authoritative-state application, remote interpolation, and server-authoritative movement/combat behavior, but regression coverage still skews toward transport and isolated network-layer rules. Step 9 is needed now so future changes cannot silently push gameplay authority, routing, or presentation flow back toward client-side guesswork.
## What Changes
- Add a dedicated gameplay-flow regression coverage capability for edit-mode network tests.
- Define regression expectations for client `ShootInput` send routing, client `CombatEvent` receive/apply flow, remote `PlayerState` buffering/interpolation decisions, and fake-transport end-to-end gameplay message flow.
- Keep `MessageManagerTests` focused on lane-policy regressions only, with broader gameplay assertions moving into higher-level flow tests.
- Require coverage for both client single-session behavior and server multi-session authority paths where the MVP gameplay loop crosses that boundary.
## Capabilities
### New Capabilities
- `gameplay-flow-regression-coverage`: Define the required regression coverage that protects the MVP gameplay loop from client input through authoritative server state/combat results and client-side application.
### Modified Capabilities
- None.
## Impact
Affected areas include `Assets/Tests/EditMode/Network/`, lightweight fake-transport/runtime test fixtures, and any minimal runtime/test seams needed to observe client gameplay flow without changing shared networking contracts. This change should not alter production transport policy or MVP message definitions.

View File

@ -0,0 +1,31 @@
## ADDED Requirements
### Requirement: Gameplay-flow regressions cover client gameplay send and receive paths
The edit-mode regression suite SHALL cover the MVP client gameplay flow above the raw transport router, including `ShootInput` send routing and authoritative `CombatEvent` receive/apply behavior. Lane-policy assertions that belong to `MessageManager` MAY remain in `MessageManagerTests`, but gameplay-flow assertions MUST live in tests that exercise the client runtime or player-facing application path.
#### Scenario: Client fire intent regression proves dedicated `ShootInput` routing
- **WHEN** the controlled client gameplay path is exercised in an edit-mode regression test for a fire action
- **THEN** the test observes a `ShootInput` payload sent through the dedicated client shooting path
- **THEN** any lane-policy assertion in that flow remains limited to confirming the MVP reliable-lane contract rather than replacing broader gameplay-flow coverage
#### Scenario: Authoritative combat event regression proves client-side application
- **WHEN** an edit-mode regression test delivers an authoritative `CombatEvent` into the client gameplay receive path
- **THEN** the relevant player-owned authoritative state, presentation model, or diagnostics surface reflects the authoritative hit, damage, death, or rejection result
- **THEN** the test proves the outcome is applied from server truth rather than speculative local combat logic
### Requirement: Gameplay-flow regressions cover remote authoritative snapshot decisions
The edit-mode regression suite SHALL cover the client path that buffers and consumes remote authoritative `PlayerState` snapshots, including stale rejection and interpolation/clamp behavior where practical.
#### Scenario: Remote interpolation regression proves buffering and stale rejection
- **WHEN** an edit-mode regression test feeds ordered and stale remote `PlayerState` snapshots into the client remote-player path
- **THEN** the test observes that newer authoritative snapshots enter the remote buffer while stale snapshots do not replace newer accepted state
- **THEN** the test verifies the resulting interpolation or latest-snapshot clamp decision matches the MVP remote-presentation rules
### Requirement: Gameplay-flow regressions include a fake-transport authoritative round trip
The edit-mode regression suite SHALL include at least one deterministic fake-transport test that spans client send behavior, server-authoritative processing, and outgoing authoritative results. That round-trip regression MUST cover `MoveInput -> PlayerState` and `ShootInput -> CombatEvent` within the same MVP gameplay-flow suite.
#### Scenario: Fake-transport round trip preserves server authority across movement and combat
- **WHEN** an edit-mode regression test drives gameplay input through fake client/server transports and advances the server authority loop
- **THEN** the authoritative server path emits `PlayerState` snapshots in response to movement input
- **THEN** the authoritative server path emits `CombatEvent` results in response to shooting input
- **THEN** the combined test protects both client single-session input flow and server multi-session authoritative behavior from regression

View File

@ -0,0 +1,17 @@
## 1. Client Gameplay Flow Coverage
- [x] 1.1 Add or extend edit-mode client tests that prove fire intent sends `ShootInput` through the dedicated gameplay path and only retain `MessageManagerTests` assertions needed for reliable-lane policy regressions.
- [x] 1.2 Add edit-mode regression tests that deliver authoritative `CombatEvent` messages through the client receive path and verify player-owned authoritative state, presentation, or diagnostics apply hit/damage/death/rejection results from server truth.
- [x] 1.3 Add any minimal production-safe observability seams needed for the client gameplay tests without introducing Unity dependencies into shared networking code.
## 2. Snapshot And End-To-End Coverage
- [x] 2.1 Add edit-mode regression tests that cover remote `PlayerState` buffering, stale snapshot rejection, and interpolation-versus-clamp decisions where practical.
- [x] 2.2 Add at least one deterministic fake-transport gameplay-flow test that drives `MoveInput -> PlayerState` through the authoritative server path.
- [x] 2.3 Extend that fake-transport gameplay-flow coverage to also drive `ShootInput -> CombatEvent` and verify the combined flow protects client single-session input behavior plus server multi-session authority.
## 3. Tracking And Verification
- [x] 3.1 Update `TODO.md` to mark the gameplay-flow regression coverage items completed or narrowed to any remaining follow-up work.
- [x] 3.2 Keep the new regression suite organized under `Assets/Tests/EditMode/Network/` with names and fixtures that separate lane-policy tests from higher-level gameplay-flow tests.
- [x] 3.3 Run `dotnet build Network.EditMode.Tests.csproj -v minimal` and `dotnet test Network.EditMode.Tests.csproj --no-build -v minimal`, then record the actual result in the implementation summary.

View File

@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-03-29

View File

@ -0,0 +1,40 @@
## Context
The repository's MVP networking and gameplay-flow work is already implemented and covered by edit-mode tests, but TODO step 10 remains open until the build and test commands are re-run in an environment that actually contains the required .NET runtime. The change is intentionally narrow: it does not introduce new gameplay behavior, only a final verification pass and a recorded outcome in project tracking.
## Goals / Non-Goals
**Goals:**
- Define a repeatable verification path for the repository's edit-mode build and test commands.
- Re-run the existing CLI commands in a runnable local environment instead of leaving the result inferred from partial or blocked attempts.
- Record the actual outcome, including any remaining warnings, in the TODO and change tracking.
**Non-Goals:**
- Introducing new runtime, gameplay, or transport behavior.
- Expanding the regression suite beyond what step 9 already added.
- Solving unrelated SDK or editor installation issues outside what is minimally needed to run the verification commands.
## Decisions
### Decision: Treat this as a verification-only change
This change stays focused on environment readiness, command execution, and result recording. That keeps the scope aligned with TODO step 10 and avoids reopening already-implemented gameplay work.
Alternative considered: Roll environment fixes and additional code cleanup into the same change. Rejected because it would blur whether a failure came from verification setup or from new functional modifications.
### Decision: Verify the exact documented commands
The source of truth remains the repository commands already documented in `AGENTS.md` and `TODO.md`:
- `dotnet build Network.EditMode.Tests.csproj -v minimal`
- `dotnet test Network.EditMode.Tests.csproj --no-build -v minimal`
Alternative considered: Use ad hoc command variants or Unity editor-driven test execution. Rejected because the TODO explicitly calls for these CLI verification steps.
### Decision: Record warnings separately from pass/fail status
If build and test succeed but still emit known Unity dependency warnings, the recorded result should preserve that nuance instead of flattening everything into a generic success line.
Alternative considered: Ignore warnings once commands pass. Rejected because the TODO asks for the actual result, not a simplified interpretation.
## Risks / Trade-offs
- [Environment drift] -> The runtime available on the current machine may differ from prior attempts. Mitigation: record the actual command outcome from the environment used for this change.
- [Over-scoping] -> Verification-only work can accidentally turn into general cleanup. Mitigation: limit edits to tracking/docs unless command failures expose a clear regression that must be fixed to complete step 10.
- [False confidence] -> A successful CLI run does not prove every Unity editor path. Mitigation: keep the scope explicit: this change verifies the documented build/test path, not all editor execution modes.

View File

@ -0,0 +1,21 @@
## Why
The MVP networking work is already implemented, but the TODO still requires a final build-and-test verification in a runnable local environment. This change closes that gap by making the verification step explicit, repeatable, and recorded against the current gameplay regression suite.
## What Changes
- Define a small verification capability for running the repository's edit-mode build and test commands in an environment with the required .NET runtime.
- Record the actual build and test result for the current MVP networking and gameplay-flow regression suite.
- Update project tracking so TODO step 10 reflects the completed verification state and any remaining environment caveats.
## Capabilities
### New Capabilities
- `build-test-verification`: Defines the required local environment assumptions, commands, and recorded result for final MVP build/test verification.
### Modified Capabilities
- None.
## Impact
Affected areas include OpenSpec tracking under `openspec/`, the root `TODO.md`, and the CLI verification path driven by `dotnet build Network.EditMode.Tests.csproj -v minimal` and `dotnet test Network.EditMode.Tests.csproj --no-build -v minimal`.

View File

@ -0,0 +1,22 @@
## ADDED Requirements
### Requirement: Runnable CLI verification environment
The repository SHALL define step 10 completion in terms of a local environment that can execute the documented `dotnet build` and `dotnet test` commands for `Network.EditMode.Tests.csproj` without failing due to a missing required .NET runtime.
#### Scenario: Environment is suitable for verification
- **WHEN** a maintainer performs the final MVP verification pass
- **THEN** the environment used for that pass MUST contain the runtime needed to execute the documented CLI build and test commands
- **AND** the verification record MUST distinguish environment readiness issues from actual build or test failures
### Requirement: Build and test commands are re-run and recorded
The repository SHALL re-run the documented edit-mode CLI verification commands and record the actual outcome for the current MVP networking codebase.
#### Scenario: Build and test both succeed
- **WHEN** `dotnet build Network.EditMode.Tests.csproj -v minimal` succeeds and `dotnet test Network.EditMode.Tests.csproj --no-build -v minimal` succeeds
- **THEN** project tracking MUST mark the build/test verification step complete
- **AND** the recorded result MUST state that the edit-mode network test suite passed in the runnable environment
#### Scenario: Verification succeeds with warnings
- **WHEN** the documented build and test commands succeed but emit non-fatal warnings
- **THEN** the recorded result MUST preserve the warnings as part of the verification summary
- **AND** the step MUST still be considered complete because the commands passed

View File

@ -0,0 +1,21 @@
## 1. Verification Environment
- [x] 1.1 Confirm or switch to a local environment that contains the required .NET runtime for `Network.EditMode.Tests.csproj`.
- [x] 1.2 Re-check the documented verification commands and any required environment variables before execution.
## 2. CLI Verification
- [x] 2.1 Run `dotnet build Network.EditMode.Tests.csproj -v minimal` in the runnable environment.
- [x] 2.2 Run `dotnet test Network.EditMode.Tests.csproj --no-build -v minimal` in the same runnable environment.
- [x] 2.3 Capture the actual pass/fail outcome and any remaining non-fatal warnings from both commands.
## 3. Tracking Update
- [x] 3.1 Update `TODO.md` step 10 and acceptance items to reflect the real verification result.
- [x] 3.2 Update this change's implementation tracking with the recorded verification summary so archive-ready state is explicit.
## Verification Summary
- Environment used: local machine with .NET SDK 10.0.201 and the repository's Unity project files available.
- `dotnet build Network.EditMode.Tests.csproj -v minimal`: succeeded with 4 non-fatal MSB3277 warning groups related to `System.Net.Http` and `System.Security.Cryptography.Algorithms` Unity dependency conflicts.
- `dotnet test Network.EditMode.Tests.csproj --no-build -v minimal`: succeeded for the edit-mode network and MVP gameplay regression suite.

View File

@ -0,0 +1,28 @@
# build-test-verification Specification
## Purpose
Define the runnable local environment and recorded CLI verification result required to close the final MVP build/test verification step.
## Requirements
### Requirement: Runnable CLI verification environment
The repository SHALL define step 10 completion in terms of a local environment that can execute the documented `dotnet build` and `dotnet test` commands for `Network.EditMode.Tests.csproj` without failing due to a missing required .NET runtime.
#### Scenario: Environment is suitable for verification
- **WHEN** a maintainer performs the final MVP verification pass
- **THEN** the environment used for that pass MUST contain the runtime needed to execute the documented CLI build and test commands
- **AND** the verification record MUST distinguish environment readiness issues from actual build or test failures
### Requirement: Build and test commands are re-run and recorded
The repository SHALL re-run the documented edit-mode CLI verification commands and record the actual outcome for the current MVP networking codebase.
#### Scenario: Build and test both succeed
- **WHEN** `dotnet build Network.EditMode.Tests.csproj -v minimal` succeeds and `dotnet test Network.EditMode.Tests.csproj --no-build -v minimal` succeeds
- **THEN** project tracking MUST mark the build/test verification step complete
- **AND** the recorded result MUST state that the edit-mode network test suite passed in the runnable environment
#### Scenario: Verification succeeds with warnings
- **WHEN** the documented build and test commands succeed but emit non-fatal warnings
- **THEN** the recorded result MUST preserve the warnings as part of the verification summary
- **AND** the step MUST still be considered complete because the commands passed

View File

@ -0,0 +1,35 @@
# gameplay-flow-regression-coverage Specification
## Purpose
Define the required edit-mode regression coverage that protects the MVP gameplay flow from client gameplay input through authoritative server outputs and client-side application.
## Requirements
### Requirement: Gameplay-flow regressions cover client gameplay send and receive paths
The edit-mode regression suite SHALL cover the MVP client gameplay flow above the raw transport router, including `ShootInput` send routing and authoritative `CombatEvent` receive/apply behavior. Lane-policy assertions that belong to `MessageManager` MAY remain in `MessageManagerTests`, but gameplay-flow assertions MUST live in tests that exercise the client runtime or player-facing application path.
#### Scenario: Client fire intent regression proves dedicated `ShootInput` routing
- **WHEN** the controlled client gameplay path is exercised in an edit-mode regression test for a fire action
- **THEN** the test observes a `ShootInput` payload sent through the dedicated client shooting path
- **THEN** any lane-policy assertion in that flow remains limited to confirming the MVP reliable-lane contract rather than replacing broader gameplay-flow coverage
#### Scenario: Authoritative combat event regression proves client-side application
- **WHEN** an edit-mode regression test delivers an authoritative `CombatEvent` into the client gameplay receive path
- **THEN** the relevant player-owned authoritative state, presentation model, or diagnostics surface reflects the authoritative hit, damage, death, or rejection result
- **THEN** the test proves the outcome is applied from server truth rather than speculative local combat logic
### Requirement: Gameplay-flow regressions cover remote authoritative snapshot decisions
The edit-mode regression suite SHALL cover the client path that buffers and consumes remote authoritative `PlayerState` snapshots, including stale rejection and interpolation/clamp behavior where practical.
#### Scenario: Remote interpolation regression proves buffering and stale rejection
- **WHEN** an edit-mode regression test feeds ordered and stale remote `PlayerState` snapshots into the client remote-player path
- **THEN** the test observes that newer authoritative snapshots enter the remote buffer while stale snapshots do not replace newer accepted state
- **THEN** the test verifies the resulting interpolation or latest-snapshot clamp decision matches the MVP remote-presentation rules
### Requirement: Gameplay-flow regressions include a fake-transport authoritative round trip
The edit-mode regression suite SHALL include at least one deterministic fake-transport test that spans client send behavior, server-authoritative processing, and outgoing authoritative results. That round-trip regression MUST cover `MoveInput -> PlayerState` and `ShootInput -> CombatEvent` within the same MVP gameplay-flow suite.
#### Scenario: Fake-transport round trip preserves server authority across movement and combat
- **WHEN** an edit-mode regression test drives gameplay input through fake client/server transports and advances the server authority loop
- **THEN** the authoritative server path emits `PlayerState` snapshots in response to movement input
- **THEN** the authoritative server path emits `CombatEvent` results in response to shooting input
- **THEN** the combined test protects both client single-session input flow and server multi-session authoritative behavior from regression