/ 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 }