/ GUNRPG.Tests / DistributedAuthorityTests.cs
DistributedAuthorityTests.cs
  1  using GUNRPG.Application.Distributed;
  2  using GUNRPG.Core.Intents;
  3  using GUNRPG.Infrastructure.Distributed;
  4  
  5  namespace GUNRPG.Tests;
  6  
  7  public class DistributedAuthorityTests
  8  {
  9      private static readonly Guid OperatorA = Guid.Parse("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa");
 10      private static readonly IDeterministicGameEngine Engine = new DefaultGameEngine();
 11  
 12      // --- Core Functionality ---
 13  
 14      [Fact]
 15      public async Task SubmitAction_Solo_AppliesImmediately()
 16      {
 17          var nodeId = Guid.NewGuid();
 18          var transport = new InMemoryLockstepTransport(nodeId);
 19          var authority = new DistributedAuthority(nodeId, transport, Engine);
 20  
 21          var action = new PlayerActionDto
 22          {
 23              OperatorId = OperatorA,
 24              Primary = PrimaryAction.Fire
 25          };
 26  
 27          await authority.SubmitActionAsync(action);
 28  
 29          var log = authority.GetActionLog();
 30          Assert.Single(log);
 31          Assert.Equal(0, log[0].SequenceNumber);
 32          Assert.Equal(nodeId, log[0].NodeId);
 33          Assert.Equal(action.ActionId, log[0].Action.ActionId);
 34          Assert.False(string.IsNullOrEmpty(log[0].StateHashAfterApply));
 35      }
 36  
 37      [Fact]
 38      public async Task SubmitAction_Solo_UpdatesGameState()
 39      {
 40          var nodeId = Guid.NewGuid();
 41          var transport = new InMemoryLockstepTransport(nodeId);
 42          var authority = new DistributedAuthority(nodeId, transport, Engine);
 43  
 44          await authority.SubmitActionAsync(new PlayerActionDto
 45          {
 46              OperatorId = OperatorA,
 47              Primary = PrimaryAction.Fire
 48          });
 49  
 50          var state = authority.GetCurrentState();
 51          Assert.Equal(1, state.ActionCount);
 52          Assert.Single(state.Operators);
 53          Assert.Equal(OperatorA, state.Operators[0].OperatorId);
 54          Assert.Equal(10, state.Operators[0].TotalXp); // Fire = 10 XP
 55      }
 56  
 57      [Fact]
 58      public async Task SubmitAction_Solo_SequenceIncreases()
 59      {
 60          var nodeId = Guid.NewGuid();
 61          var transport = new InMemoryLockstepTransport(nodeId);
 62          var authority = new DistributedAuthority(nodeId, transport, Engine);
 63  
 64          await authority.SubmitActionAsync(new PlayerActionDto { OperatorId = OperatorA, Primary = PrimaryAction.Fire });
 65          await authority.SubmitActionAsync(new PlayerActionDto { OperatorId = OperatorA, Primary = PrimaryAction.Reload });
 66  
 67          var log = authority.GetActionLog();
 68          Assert.Equal(2, log.Count);
 69          Assert.Equal(0, log[0].SequenceNumber);
 70          Assert.Equal(1, log[1].SequenceNumber);
 71      }
 72  
 73      // --- Hashing ---
 74  
 75      [Fact]
 76      public async Task GetCurrentStateHash_ReturnsSHA256HexString()
 77      {
 78          var nodeId = Guid.NewGuid();
 79          var transport = new InMemoryLockstepTransport(nodeId);
 80          var authority = new DistributedAuthority(nodeId, transport, Engine);
 81  
 82          await authority.SubmitActionAsync(new PlayerActionDto { OperatorId = OperatorA, Primary = PrimaryAction.Fire });
 83  
 84          var hash = authority.GetCurrentStateHash();
 85          Assert.NotNull(hash);
 86          Assert.Equal(64, hash.Length); // SHA256 = 64 hex chars
 87          Assert.Matches("^[A-F0-9]+$", hash); // Uppercase hex
 88      }
 89  
 90      [Fact]
 91      public async Task StateHash_IdenticalActions_ProduceSameHash()
 92      {
 93          // Two independent nodes applying same actions should produce same hash
 94          var nodeA = Guid.NewGuid();
 95          var nodeB = Guid.NewGuid();
 96          var transportA = new InMemoryLockstepTransport(nodeA);
 97          var transportB = new InMemoryLockstepTransport(nodeB);
 98          var authorityA = new DistributedAuthority(nodeA, transportA, Engine);
 99          var authorityB = new DistributedAuthority(nodeB, transportB, Engine);
100  
101          var action1 = new PlayerActionDto { OperatorId = OperatorA, Primary = PrimaryAction.Fire };
102          var action2 = new PlayerActionDto { OperatorId = OperatorA, Primary = PrimaryAction.Reload };
103  
104          await authorityA.SubmitActionAsync(action1);
105          await authorityA.SubmitActionAsync(action2);
106  
107          await authorityB.SubmitActionAsync(action1);
108          await authorityB.SubmitActionAsync(action2);
109  
110          Assert.Equal(authorityA.GetCurrentStateHash(), authorityB.GetCurrentStateHash());
111      }
112  
113      [Fact]
114      public async Task StateHash_DifferentActions_ProduceDifferentHash()
115      {
116          var nodeA = Guid.NewGuid();
117          var nodeB = Guid.NewGuid();
118          var transportA = new InMemoryLockstepTransport(nodeA);
119          var transportB = new InMemoryLockstepTransport(nodeB);
120          var authorityA = new DistributedAuthority(nodeA, transportA, Engine);
121          var authorityB = new DistributedAuthority(nodeB, transportB, Engine);
122  
123          await authorityA.SubmitActionAsync(new PlayerActionDto { OperatorId = OperatorA, Primary = PrimaryAction.Fire });
124          await authorityB.SubmitActionAsync(new PlayerActionDto { OperatorId = OperatorA, Primary = PrimaryAction.Reload });
125  
126          Assert.NotEqual(authorityA.GetCurrentStateHash(), authorityB.GetCurrentStateHash());
127      }
128  
129      [Fact]
130      public async Task ActionLogEntry_HasStateHashAfterApply()
131      {
132          var nodeId = Guid.NewGuid();
133          var transport = new InMemoryLockstepTransport(nodeId);
134          var authority = new DistributedAuthority(nodeId, transport, Engine);
135  
136          await authority.SubmitActionAsync(new PlayerActionDto { OperatorId = OperatorA, Primary = PrimaryAction.Fire });
137  
138          var entry = authority.GetActionLog()[0];
139          Assert.NotNull(entry.StateHashAfterApply);
140          Assert.Equal(64, entry.StateHashAfterApply.Length);
141          Assert.Equal(authority.GetCurrentStateHash(), entry.StateHashAfterApply);
142      }
143  
144      // --- Two-Node Lockstep ---
145  
146      [Fact]
147      public async Task TwoNodes_ActionReplicates()
148      {
149          var (authorityA, authorityB, _, _) = CreateConnectedPair();
150  
151          var action = new PlayerActionDto { OperatorId = OperatorA, Primary = PrimaryAction.Fire };
152          await authorityA.SubmitActionAsync(action);
153  
154          // Both nodes should have the same state hash after action is applied
155          Assert.Equal(authorityA.GetCurrentStateHash(), authorityB.GetCurrentStateHash());
156      }
157  
158      [Fact]
159      public async Task TwoNodes_ActionLogMatches()
160      {
161          var (authorityA, authorityB, _, _) = CreateConnectedPair();
162  
163          var action = new PlayerActionDto { OperatorId = OperatorA, Primary = PrimaryAction.Fire };
164          await authorityA.SubmitActionAsync(action);
165  
166          var logA = authorityA.GetActionLog();
167          var logB = authorityB.GetActionLog();
168  
169          Assert.Single(logA);
170          Assert.Single(logB);
171          Assert.Equal(logA[0].Action.ActionId, logB[0].Action.ActionId);
172          Assert.Equal(logA[0].StateHashAfterApply, logB[0].StateHashAfterApply);
173          // Both logs should record the originating node (A) as the NodeId
174          Assert.Equal(authorityA.NodeId, logA[0].NodeId);
175          Assert.Equal(authorityA.NodeId, logB[0].NodeId);
176      }
177  
178      [Fact]
179      public async Task TwoNodes_MultipleActions_MatchingState()
180      {
181          var (authorityA, authorityB, _, _) = CreateConnectedPair();
182  
183          await authorityA.SubmitActionAsync(new PlayerActionDto { OperatorId = OperatorA, Primary = PrimaryAction.Fire });
184          await authorityA.SubmitActionAsync(new PlayerActionDto { OperatorId = OperatorA, Primary = PrimaryAction.Reload });
185          await authorityA.SubmitActionAsync(new PlayerActionDto { OperatorId = OperatorA, Primary = PrimaryAction.Fire });
186  
187          Assert.Equal(authorityA.GetCurrentStateHash(), authorityB.GetCurrentStateHash());
188          Assert.Equal(3, authorityA.GetActionLog().Count);
189          Assert.Equal(3, authorityB.GetActionLog().Count);
190      }
191  
192      // --- Desync Detection ---
193  
194      [Fact]
195      public void InitialState_NotDesynced()
196      {
197          var nodeId = Guid.NewGuid();
198          var transport = new InMemoryLockstepTransport(nodeId);
199          var authority = new DistributedAuthority(nodeId, transport, Engine);
200  
201          Assert.False(authority.IsDesynced);
202      }
203  
204      [Fact]
205      public async Task Desynced_RejectsActions()
206      {
207          var nodeId = Guid.NewGuid();
208          var transport = new InMemoryLockstepTransport(nodeId);
209          var authority = new DistributedAuthority(nodeId, transport, Engine);
210  
211          // Submit an action first
212          await authority.SubmitActionAsync(new PlayerActionDto { OperatorId = OperatorA, Primary = PrimaryAction.Fire });
213  
214          // Simulate desync by sending a hash mismatch
215          transport.SimulateIncomingHash(new HashBroadcastMessage
216          {
217              SenderId = Guid.NewGuid(),
218              SequenceNumber = 0,
219              StateHash = "BADBEEF000000000000000000000000000000000000000000000000000000000"
220          });
221  
222          Assert.True(authority.IsDesynced);
223  
224          await Assert.ThrowsAsync<InvalidOperationException>(() =>
225              authority.SubmitActionAsync(new PlayerActionDto { OperatorId = OperatorA, Primary = PrimaryAction.Fire }));
226      }
227  
228      // --- Reconnect Sync ---
229  
230      [Fact]
231      public async Task Reconnect_NodeBSyncsFromNodeA()
232      {
233          var nodeIdA = Guid.NewGuid();
234          var nodeIdB = Guid.NewGuid();
235          var transportA = new InMemoryLockstepTransport(nodeIdA);
236          var transportB = new InMemoryLockstepTransport(nodeIdB);
237          var authorityA = new DistributedAuthority(nodeIdA, transportA, Engine);
238          var authorityB = new DistributedAuthority(nodeIdB, transportB, Engine);
239  
240          // Node A submits actions while solo
241          await authorityA.SubmitActionAsync(new PlayerActionDto { OperatorId = OperatorA, Primary = PrimaryAction.Fire });
242          await authorityA.SubmitActionAsync(new PlayerActionDto { OperatorId = OperatorA, Primary = PrimaryAction.Reload });
243          await authorityA.SubmitActionAsync(new PlayerActionDto { OperatorId = OperatorA, Primary = PrimaryAction.Fire });
244  
245          Assert.Equal(3, authorityA.GetActionLog().Count);
246          Assert.Empty(authorityB.GetActionLog());
247  
248          // Node B connects to Node A - sync should occur
249          transportA.ConnectTo(transportB);
250  
251          // After sync, Node B should have the same log and hash
252          Assert.Equal(3, authorityB.GetActionLog().Count);
253          Assert.Equal(authorityA.GetCurrentStateHash(), authorityB.GetCurrentStateHash());
254          Assert.False(authorityB.IsDesynced);
255      }
256  
257      [Fact]
258      public async Task Reconnect_DisconnectAndReconnect_SyncsCorrectly()
259      {
260          var nodeIdA = Guid.NewGuid();
261          var nodeIdB = Guid.NewGuid();
262          var transportA = new InMemoryLockstepTransport(nodeIdA);
263          var transportB = new InMemoryLockstepTransport(nodeIdB);
264          var authorityA = new DistributedAuthority(nodeIdA, transportA, Engine);
265          var authorityB = new DistributedAuthority(nodeIdB, transportB, Engine);
266  
267          // Connect and submit initial action
268          transportA.ConnectTo(transportB);
269          await authorityA.SubmitActionAsync(new PlayerActionDto { OperatorId = OperatorA, Primary = PrimaryAction.Fire });
270          Assert.Equal(authorityA.GetCurrentStateHash(), authorityB.GetCurrentStateHash());
271  
272          // Disconnect
273          transportA.DisconnectFrom(transportB);
274  
275          // Node A submits more actions while disconnected
276          await authorityA.SubmitActionAsync(new PlayerActionDto { OperatorId = OperatorA, Primary = PrimaryAction.Reload });
277          await authorityA.SubmitActionAsync(new PlayerActionDto { OperatorId = OperatorA, Primary = PrimaryAction.Fire });
278  
279          Assert.Equal(3, authorityA.GetActionLog().Count);
280          Assert.Single(authorityB.GetActionLog());
281  
282          // Reconnect
283          transportA.ConnectTo(transportB);
284  
285          // Node B should sync missing actions
286          Assert.Equal(3, authorityB.GetActionLog().Count);
287          Assert.Equal(authorityA.GetCurrentStateHash(), authorityB.GetCurrentStateHash());
288      }
289  
290      // --- Action Entry Record ---
291  
292      [Fact]
293      public void DistributedActionEntry_IsRecord()
294      {
295          var entry = new DistributedActionEntry
296          {
297              SequenceNumber = 42,
298              NodeId = Guid.NewGuid(),
299              Action = new PlayerActionDto { OperatorId = OperatorA, Primary = PrimaryAction.Fire },
300              StateHashAfterApply = "ABC123"
301          };
302  
303          Assert.Equal(42, entry.SequenceNumber);
304          Assert.Equal(PrimaryAction.Fire, entry.Action.Primary);
305          Assert.Equal("ABC123", entry.StateHashAfterApply);
306      }
307  
308      // --- Message Types ---
309  
310      [Fact]
311      public void ActionBroadcastMessage_HasRequiredFields()
312      {
313          var msg = new ActionBroadcastMessage
314          {
315              SenderId = Guid.NewGuid(),
316              ProposedSequenceNumber = 0,
317              Action = new PlayerActionDto { OperatorId = OperatorA }
318          };
319  
320          Assert.NotEqual(Guid.Empty, msg.SenderId);
321          Assert.Equal(0, msg.ProposedSequenceNumber);
322          Assert.NotNull(msg.Action);
323      }
324  
325      [Fact]
326      public void ActionAckMessage_HasRequiredFields()
327      {
328          var msg = new ActionAckMessage
329          {
330              SenderId = Guid.NewGuid(),
331              AckedActionId = Guid.NewGuid(),
332              SequenceNumber = 5
333          };
334  
335          Assert.NotEqual(Guid.Empty, msg.SenderId);
336          Assert.NotEqual(Guid.Empty, msg.AckedActionId);
337          Assert.Equal(5, msg.SequenceNumber);
338      }
339  
340      [Fact]
341      public void HashBroadcastMessage_HasRequiredFields()
342      {
343          var msg = new HashBroadcastMessage
344          {
345              SenderId = Guid.NewGuid(),
346              SequenceNumber = 3,
347              StateHash = "ABCDEF123456"
348          };
349  
350          Assert.Equal(3, msg.SequenceNumber);
351          Assert.Equal("ABCDEF123456", msg.StateHash);
352      }
353  
354      [Fact]
355      public void LogSyncRequestMessage_HasRequiredFields()
356      {
357          var msg = new LogSyncRequestMessage
358          {
359              SenderId = Guid.NewGuid(),
360              FromSequenceNumber = 10,
361              LatestHash = "HASH123"
362          };
363  
364          Assert.Equal(10, msg.FromSequenceNumber);
365      }
366  
367      [Fact]
368      public void LogSyncResponseMessage_HasRequiredFields()
369      {
370          var msg = new LogSyncResponseMessage
371          {
372              SenderId = Guid.NewGuid(),
373              Entries = new List<DistributedActionEntry>(),
374              FullReplay = true
375          };
376  
377          Assert.Empty(msg.Entries);
378          Assert.True(msg.FullReplay);
379      }
380  
381      // --- Protocol ---
382  
383      [Fact]
384      public void LockstepProtocol_HasCorrectId()
385      {
386          Assert.Equal("/gunrpg/lockstep/1.0.0", LockstepProtocol.Id);
387      }
388  
389      // --- GameStateDto ---
390  
391      [Fact]
392      public async Task GameStateDto_OrdersOperatorsDeterministically()
393      {
394          var nodeId = Guid.NewGuid();
395          var transport = new InMemoryLockstepTransport(nodeId);
396          var authority = new DistributedAuthority(nodeId, transport, Engine);
397  
398          var opB = Guid.Parse("bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb");
399          var opA = Guid.Parse("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa");
400  
401          // Submit action for opB first, then opA
402          await authority.SubmitActionAsync(new PlayerActionDto { OperatorId = opB, Primary = PrimaryAction.Fire });
403          await authority.SubmitActionAsync(new PlayerActionDto { OperatorId = opA, Primary = PrimaryAction.Reload });
404  
405          var state = authority.GetCurrentState();
406          Assert.Equal(2, state.Operators.Count);
407          // Should be ordered by GUID (opA < opB)
408          Assert.Equal(opA, state.Operators[0].OperatorId);
409          Assert.Equal(opB, state.Operators[1].OperatorId);
410      }
411  
412      // --- IGameAuthority interface ---
413  
414      [Fact]
415      public void DistributedAuthority_ImplementsIGameAuthority()
416      {
417          var nodeId = Guid.NewGuid();
418          var transport = new InMemoryLockstepTransport(nodeId);
419          IGameAuthority authority = new DistributedAuthority(nodeId, transport, Engine);
420  
421          Assert.NotEqual(Guid.Empty, authority.NodeId);
422          Assert.False(authority.IsDesynced);
423      }
424  
425      // --- Peer Disconnect ---
426  
427      [Fact]
428      public async Task PeerDisconnect_UnblocksPendingAction()
429      {
430          var (authorityA, _, transportA, transportB) = CreateConnectedPair();
431  
432          // Disconnect B while A is submitting — should not hang
433          transportA.DisconnectFrom(transportB);
434  
435          // Solo mode now: should apply immediately without blocking
436          await authorityA.SubmitActionAsync(new PlayerActionDto { OperatorId = OperatorA, Primary = PrimaryAction.Fire });
437  
438          Assert.Single(authorityA.GetActionLog());
439      }
440  
441      // --- Local vs Distributed Produce Identical Outcomes ---
442  
443      [Fact]
444      public async Task LocalAndDistributed_IdenticalActions_ProduceIdenticalOutcomes()
445      {
446          var nodeId = Guid.NewGuid();
447          var transport = new InMemoryLockstepTransport(nodeId);
448          var distributed = new DistributedAuthority(nodeId, transport, Engine);
449          var local = new LocalGameAuthority(nodeId, Engine);
450  
451          var actions = new[]
452          {
453              new PlayerActionDto { OperatorId = OperatorA, Primary = PrimaryAction.Fire },
454              new PlayerActionDto { OperatorId = OperatorA, Primary = PrimaryAction.Reload },
455              new PlayerActionDto { OperatorId = OperatorA, Primary = PrimaryAction.Fire },
456          };
457  
458          foreach (var action in actions)
459          {
460              await distributed.SubmitActionAsync(action);
461              await local.SubmitActionAsync(action);
462          }
463  
464          Assert.Equal(local.GetCurrentStateHash(), distributed.GetCurrentStateHash());
465          Assert.Equal(local.GetCurrentState().ActionCount, distributed.GetCurrentState().ActionCount);
466          Assert.Equal(local.GetActionLog().Count, distributed.GetActionLog().Count);
467  
468          for (int i = 0; i < local.GetActionLog().Count; i++)
469          {
470              Assert.Equal(
471                  local.GetActionLog()[i].StateHashAfterApply,
472                  distributed.GetActionLog()[i].StateHashAfterApply);
473          }
474      }
475  
476      [Fact]
477      public async Task LocalAndDistributed_MultipleOperators_ProduceSameState()
478      {
479          var opB = Guid.Parse("bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb");
480          var nodeId = Guid.NewGuid();
481          var transport = new InMemoryLockstepTransport(nodeId);
482          var distributed = new DistributedAuthority(nodeId, transport, Engine);
483          var local = new LocalGameAuthority(nodeId, Engine);
484  
485          var actions = new[]
486          {
487              new PlayerActionDto { OperatorId = OperatorA, Primary = PrimaryAction.Fire },
488              new PlayerActionDto { OperatorId = opB, Primary = PrimaryAction.Reload },
489              new PlayerActionDto { OperatorId = OperatorA, Primary = PrimaryAction.Reload },
490              new PlayerActionDto { OperatorId = opB, Primary = PrimaryAction.Fire },
491          };
492  
493          foreach (var action in actions)
494          {
495              await distributed.SubmitActionAsync(action);
496              await local.SubmitActionAsync(action);
497          }
498  
499          Assert.Equal(local.GetCurrentStateHash(), distributed.GetCurrentStateHash());
500  
501          var localState = local.GetCurrentState();
502          var distState = distributed.GetCurrentState();
503          Assert.Equal(localState.Operators.Count, distState.Operators.Count);
504          for (int i = 0; i < localState.Operators.Count; i++)
505          {
506              Assert.Equal(localState.Operators[i].OperatorId, distState.Operators[i].OperatorId);
507              Assert.Equal(localState.Operators[i].TotalXp, distState.Operators[i].TotalXp);
508          }
509      }
510  
511      // --- Log Replay Produces Identical Final State Hash ---
512  
513      [Fact]
514      public async Task DistributedAuthority_LogReplay_YieldsIdenticalStateHash()
515      {
516          // Build a log on authority A
517          var nodeId = Guid.NewGuid();
518          var transport = new InMemoryLockstepTransport(nodeId);
519          var authorityA = new DistributedAuthority(nodeId, transport, Engine);
520  
521          await authorityA.SubmitActionAsync(new PlayerActionDto { OperatorId = OperatorA, Primary = PrimaryAction.Fire });
522          await authorityA.SubmitActionAsync(new PlayerActionDto { OperatorId = OperatorA, Primary = PrimaryAction.Reload });
523          await authorityA.SubmitActionAsync(new PlayerActionDto { OperatorId = OperatorA, Primary = PrimaryAction.Fire });
524  
525          var logA = authorityA.GetActionLog();
526          var hashA = authorityA.GetCurrentStateHash();
527  
528          // Replay the log on a fresh authority B via sync response
529          var nodeIdB = Guid.NewGuid();
530          var transportB = new InMemoryLockstepTransport(nodeIdB);
531          var authorityB = new DistributedAuthority(nodeIdB, transportB, Engine);
532  
533          // Connect B to A — sync should replay the log
534          transportB.ConnectTo(transport);
535  
536          Assert.Equal(hashA, authorityB.GetCurrentStateHash());
537          Assert.Equal(logA.Count, authorityB.GetActionLog().Count);
538      }
539  
540      // --- DefaultGameEngine ---
541  
542      [Fact]
543      public void DefaultGameEngine_Step_AppliesFireAction()
544      {
545          var engine = new DefaultGameEngine();
546          var state = new GameStateDto { ActionCount = 0, Operators = new List<GameStateDto.OperatorSnapshot>() };
547  
548          var result = engine.Step(state, new PlayerActionDto { OperatorId = OperatorA, Primary = PrimaryAction.Fire });
549  
550          Assert.Equal(1, result.ActionCount);
551          Assert.Single(result.Operators);
552          Assert.Equal(10, result.Operators[0].TotalXp);
553      }
554  
555      [Fact]
556      public void DefaultGameEngine_Step_AppliesReloadAction()
557      {
558          var engine = new DefaultGameEngine();
559          var state = new GameStateDto { ActionCount = 0, Operators = new List<GameStateDto.OperatorSnapshot>() };
560  
561          var result = engine.Step(state, new PlayerActionDto { OperatorId = OperatorA, Primary = PrimaryAction.Reload });
562  
563          Assert.Equal(1, result.ActionCount);
564          Assert.Single(result.Operators);
565          Assert.Equal(1, result.Operators[0].TotalXp);
566      }
567  
568      [Fact]
569      public void DefaultGameEngine_Step_IsPure()
570      {
571          var engine = new DefaultGameEngine();
572          var state = new GameStateDto { ActionCount = 0, Operators = new List<GameStateDto.OperatorSnapshot>() };
573          var action = new PlayerActionDto { OperatorId = OperatorA, Primary = PrimaryAction.Fire };
574  
575          var result1 = engine.Step(state, action);
576          var result2 = engine.Step(state, action);
577  
578          Assert.Equal(result1.ActionCount, result2.ActionCount);
579          Assert.Equal(result1.Operators[0].TotalXp, result2.Operators[0].TotalXp);
580          Assert.Equal(0, state.ActionCount); // Original state unchanged
581      }
582  
583      // --- LocalGameAuthority ---
584  
585      [Fact]
586      public async Task LocalGameAuthority_SubmitAction_AppliesImmediately()
587      {
588          var nodeId = Guid.NewGuid();
589          var local = new LocalGameAuthority(nodeId, Engine);
590  
591          await local.SubmitActionAsync(new PlayerActionDto { OperatorId = OperatorA, Primary = PrimaryAction.Fire });
592  
593          Assert.Single(local.GetActionLog());
594          Assert.Equal(1, local.GetCurrentState().ActionCount);
595          Assert.False(local.IsDesynced);
596      }
597  
598      [Fact]
599      public async Task LocalGameAuthority_StateHash_MatchesDistributed()
600      {
601          var nodeId = Guid.NewGuid();
602          var local = new LocalGameAuthority(nodeId, Engine);
603          var transport = new InMemoryLockstepTransport(nodeId);
604          var distributed = new DistributedAuthority(nodeId, transport, Engine);
605  
606          var action = new PlayerActionDto { OperatorId = OperatorA, Primary = PrimaryAction.Fire };
607  
608          await local.SubmitActionAsync(action);
609          await distributed.SubmitActionAsync(action);
610  
611          Assert.Equal(local.GetCurrentStateHash(), distributed.GetCurrentStateHash());
612      }
613  
614      // --- Helpers ---
615  
616      private static (DistributedAuthority authorityA, DistributedAuthority authorityB,
617          InMemoryLockstepTransport transportA, InMemoryLockstepTransport transportB) CreateConnectedPair()
618      {
619          var nodeIdA = Guid.NewGuid();
620          var nodeIdB = Guid.NewGuid();
621          var transportA = new InMemoryLockstepTransport(nodeIdA);
622          var transportB = new InMemoryLockstepTransport(nodeIdB);
623          var authorityA = new DistributedAuthority(nodeIdA, transportA, Engine);
624          var authorityB = new DistributedAuthority(nodeIdB, transportB, Engine);
625          transportA.ConnectTo(transportB);
626          return (authorityA, authorityB, transportA, transportB);
627      }
628  }