/ GUNRPG.Tests / CombatSessionServiceTests.cs
CombatSessionServiceTests.cs
1 using GUNRPG.Application.Dtos; 2 using GUNRPG.Application.Requests; 3 using GUNRPG.Application.Mapping; 4 using GUNRPG.Application.Results; 5 using GUNRPG.Application.Sessions; 6 using GUNRPG.Core.Combat; 7 using GUNRPG.Core.Intents; 8 using GUNRPG.Core.Operators; 9 using GUNRPG.Tests.Stubs; 10 11 namespace GUNRPG.Tests; 12 13 public class CombatSessionServiceTests 14 { 15 [Fact] 16 public async Task CreateSession_ReturnsPlanningState() 17 { 18 var service = new CombatSessionService(new InMemoryCombatSessionStore()); 19 20 var state = (await service.CreateSessionAsync(new SessionCreateRequest { PlayerName = "Tester", Seed = 123, StartingDistance = 10 })).Value!; 21 22 Assert.NotNull(state); 23 Assert.Equal(SessionPhase.Planning, state.Phase); 24 Assert.Equal("Tester", state.Player.Name); 25 Assert.True(state.Player.CurrentAmmo > 0); 26 Assert.True(state.Enemy.CurrentAmmo > 0); 27 } 28 29 [Fact] 30 public async Task CreateSession_WithOperatorId_PreservesOperatorId() 31 { 32 var service = new CombatSessionService(new InMemoryCombatSessionStore()); 33 var operatorId = Guid.NewGuid(); 34 35 var state = (await service.CreateSessionAsync(new SessionCreateRequest 36 { 37 OperatorId = operatorId, 38 Seed = 123 39 })).Value!; 40 41 Assert.Equal(operatorId, state.OperatorId); 42 } 43 44 [Fact] 45 public async Task CreateSession_WithEmptyOperatorId_ReturnsValidationError() 46 { 47 var service = new CombatSessionService(new InMemoryCombatSessionStore()); 48 49 var result = await service.CreateSessionAsync(new SessionCreateRequest 50 { 51 OperatorId = Guid.Empty, 52 Seed = 123 53 }); 54 55 Assert.False(result.IsSuccess); 56 Assert.Null(result.Value); 57 } 58 59 [Fact] 60 public async Task SubmitIntents_RecordsWithoutAdvancing() 61 { 62 var store = new InMemoryCombatSessionStore(); 63 var service = new CombatSessionService(store); 64 var session = (await service.CreateSessionAsync(new SessionCreateRequest { Seed = 42 })).Value!; 65 66 var result = await service.SubmitPlayerIntentsAsync(session.Id, new SubmitIntentsRequest 67 { 68 Intents = new IntentDto 69 { 70 Primary = PrimaryAction.Fire 71 } 72 }); 73 74 Assert.True(result.IsSuccess); 75 Assert.NotNull(result.Value); 76 Assert.Equal(SessionPhase.Planning, result.Value!.Phase); 77 } 78 79 [Fact] 80 public async Task Advance_ProgressesCombatTurn() 81 { 82 var store = new InMemoryCombatSessionStore(); 83 var service = new CombatSessionService(store); 84 var session = (await service.CreateSessionAsync(new SessionCreateRequest { Seed = 42 })).Value!; 85 86 // Submit intents first 87 await service.SubmitPlayerIntentsAsync(session.Id, new SubmitIntentsRequest 88 { 89 Intents = new IntentDto 90 { 91 Primary = PrimaryAction.Fire 92 } 93 }); 94 95 // Now advance the turn 96 var advanceResult = await service.AdvanceAsync(session.Id); 97 98 Assert.True(advanceResult.IsSuccess); 99 Assert.NotNull(advanceResult.Value); 100 Assert.Contains(advanceResult.Value!.Phase, new[] { SessionPhase.Planning, SessionPhase.Completed }); 101 } 102 103 [Fact] 104 public async Task Advance_WithoutIntents_IsInvalid() 105 { 106 var service = new CombatSessionService(new InMemoryCombatSessionStore()); 107 var session = (await service.CreateSessionAsync(new SessionCreateRequest { Seed = 5 })).Value!; 108 109 var result = await service.AdvanceAsync(session.Id); 110 111 Assert.False(result.IsSuccess); 112 Assert.Equal("Advance requires recorded intents for both sides", result.ErrorMessage); 113 } 114 115 [Fact] 116 public async Task Snapshot_RoundTripsThroughStore() 117 { 118 var store = new InMemoryCombatSessionStore(); 119 var service = new CombatSessionService(store); 120 var created = (await service.CreateSessionAsync(new SessionCreateRequest { Seed = 11 })).Value!; 121 122 var snapshot = await store.LoadAsync(created.Id); 123 Assert.NotNull(snapshot); 124 125 var rehydrated = SessionMapping.FromSnapshot(snapshot!); 126 Assert.Equal(created.Id, rehydrated.Id); 127 Assert.Equal(SessionPhase.Planning, rehydrated.Phase); 128 Assert.Equal(created.TurnNumber, rehydrated.TurnNumber); 129 } 130 131 [Fact] 132 public async Task ApplyPetAction_MissionUpdatesStress() 133 { 134 var store = new InMemoryCombatSessionStore(); 135 var service = new CombatSessionService(store); 136 var session = (await service.CreateSessionAsync(new SessionCreateRequest { Seed = 7 })).Value!; 137 138 var petStateResult = await service.ApplyPetActionAsync(session.Id, new PetActionRequest 139 { 140 Action = "mission", 141 HitsTaken = 2, 142 OpponentDifficulty = 80 143 }); 144 145 Assert.True(petStateResult.IsSuccess); 146 Assert.NotNull(petStateResult.Value); 147 Assert.True(petStateResult.Value.Stress > 0); 148 } 149 150 [Fact] 151 public async Task Snapshot_RoundTrip_IsIdempotent() 152 { 153 var store = new InMemoryCombatSessionStore(); 154 var service = new CombatSessionService(store); 155 var created = (await service.CreateSessionAsync(new SessionCreateRequest 156 { 157 PlayerName = "TestPlayer", 158 EnemyName = "TestEnemy", 159 Seed = 42, 160 StartingDistance = 20 161 })).Value!; 162 163 // Submit intents to add complexity 164 await service.SubmitPlayerIntentsAsync(created.Id, new SubmitIntentsRequest 165 { 166 Intents = new IntentDto { Primary = PrimaryAction.Fire } 167 }); 168 169 var snapshot1 = await store.LoadAsync(created.Id); 170 Assert.NotNull(snapshot1); 171 172 // Rehydrate from snapshot 173 var rehydrated = SessionMapping.FromSnapshot(snapshot1!); 174 175 // Create second snapshot from rehydrated domain object 176 var snapshot2 = SessionMapping.ToSnapshot(rehydrated); 177 178 // Verify idempotency: snapshot1 should match snapshot2 179 Assert.Equal(snapshot1.Id, snapshot2.Id); 180 Assert.Equal(snapshot1.Phase, snapshot2.Phase); 181 Assert.Equal(snapshot1.TurnNumber, snapshot2.TurnNumber); 182 Assert.Equal(snapshot1.OperatorId, snapshot2.OperatorId); 183 Assert.Equal(snapshot1.EnemyLevel, snapshot2.EnemyLevel); 184 Assert.Equal(snapshot1.Seed, snapshot2.Seed); 185 Assert.Equal(snapshot1.PostCombatResolved, snapshot2.PostCombatResolved); 186 187 // Verify combat state 188 Assert.Equal(snapshot1.Combat.Phase, snapshot2.Combat.Phase); 189 Assert.Equal(snapshot1.Combat.CurrentTimeMs, snapshot2.Combat.CurrentTimeMs); 190 191 // Verify RNG state preservation 192 Assert.Equal(snapshot1.Combat.RandomState.Seed, snapshot2.Combat.RandomState.Seed); 193 Assert.Equal(snapshot1.Combat.RandomState.CallCount, snapshot2.Combat.RandomState.CallCount); 194 195 // Verify pending intents are preserved 196 Assert.NotNull(snapshot1.Combat.PlayerIntents); 197 Assert.NotNull(snapshot2.Combat.PlayerIntents); 198 Assert.Equal(snapshot1.Combat.PlayerIntents.Primary, snapshot2.Combat.PlayerIntents.Primary); 199 200 // Verify operator state 201 Assert.Equal(snapshot1.Player.Id, snapshot2.Player.Id); 202 Assert.Equal(snapshot1.Player.Health, snapshot2.Player.Health); 203 Assert.Equal(snapshot1.Player.CurrentAmmo, snapshot2.Player.CurrentAmmo); 204 Assert.Equal(snapshot1.Enemy.Id, snapshot2.Enemy.Id); 205 } 206 207 [Fact] 208 public async Task Snapshot_RehydrationWithPendingIntents_PreservesIntentData() 209 { 210 var store = new InMemoryCombatSessionStore(); 211 var service = new CombatSessionService(store); 212 var session = (await service.CreateSessionAsync(new SessionCreateRequest { Seed = 123 })).Value!; 213 214 // Submit player intents 215 var submitResult = await service.SubmitPlayerIntentsAsync(session.Id, new SubmitIntentsRequest 216 { 217 Intents = new IntentDto 218 { 219 Primary = PrimaryAction.Reload, 220 Movement = MovementAction.WalkToward, 221 Stance = StanceAction.EnterADS, 222 Cover = CoverAction.EnterPartial, 223 CancelMovement = false 224 } 225 }); 226 227 // Verify submission was accepted 228 if (!submitResult.IsSuccess) 229 { 230 // If submission failed for a valid reason (e.g., combat ended), skip this test 231 return; 232 } 233 234 var snapshot = await store.LoadAsync(session.Id); 235 if (snapshot == null) 236 { 237 return; // Should not happen but handle gracefully 238 } 239 240 // Verify at least one side has intents in snapshot 241 // The service submits both player and enemy intents 242 bool hasIntents = snapshot.Combat.PlayerIntents != null || snapshot.Combat.EnemyIntents != null; 243 Assert.True(hasIntents, "At least one side should have intents after successful submission"); 244 245 // Rehydrate 246 var rehydrated = SessionMapping.FromSnapshot(snapshot); 247 var (playerIntents, enemyIntents) = rehydrated.Combat.GetPendingIntents(); 248 249 // At least one side should have intents after rehydration 250 Assert.True(playerIntents != null || enemyIntents != null, 251 "Pending intents should be preserved after rehydration"); 252 253 // Verify roundtrip consistency 254 var snapshot2 = SessionMapping.ToSnapshot(rehydrated); 255 Assert.Equal(snapshot.Combat.PlayerIntents != null, snapshot2.Combat.PlayerIntents != null); 256 Assert.Equal(snapshot.Combat.EnemyIntents != null, snapshot2.Combat.EnemyIntents != null); 257 } 258 259 [Fact] 260 public async Task Snapshot_RehydrationWithRngState_PreservesDeterminism() 261 { 262 var store = new InMemoryCombatSessionStore(); 263 var service = new CombatSessionService(store); 264 var session = (await service.CreateSessionAsync(new SessionCreateRequest { Seed = 999 })).Value!; 265 266 // Submit and advance to modify RNG state 267 await service.SubmitPlayerIntentsAsync(session.Id, new SubmitIntentsRequest 268 { 269 Intents = new IntentDto { Primary = PrimaryAction.Fire } 270 }); 271 272 var beforeSnapshot = await store.LoadAsync(session.Id); 273 Assert.NotNull(beforeSnapshot); 274 var initialRngSeed = beforeSnapshot!.Combat.RandomState.Seed; 275 var initialCallCount = beforeSnapshot.Combat.RandomState.CallCount; 276 277 // Rehydrate and verify RNG state 278 var rehydrated = SessionMapping.FromSnapshot(beforeSnapshot); 279 var (rngSeed, rngCalls) = rehydrated.Combat.GetRandomState(); 280 281 Assert.Equal(initialRngSeed, rngSeed); 282 Assert.Equal(initialCallCount, rngCalls); 283 284 // Take snapshot after rehydration and verify consistency 285 var afterSnapshot = SessionMapping.ToSnapshot(rehydrated); 286 Assert.Equal(initialRngSeed, afterSnapshot.Combat.RandomState.Seed); 287 Assert.Equal(initialCallCount, afterSnapshot.Combat.RandomState.CallCount); 288 } 289 290 [Fact] 291 public async Task PhaseTransition_Planning_To_Resolving_IsValid() 292 { 293 var store = new InMemoryCombatSessionStore(); 294 var service = new CombatSessionService(store); 295 var session = (await service.CreateSessionAsync(new SessionCreateRequest { Seed = 50 })).Value!; 296 297 Assert.Equal(SessionPhase.Planning, session.Phase); 298 299 // Submit intents and advance 300 await service.SubmitPlayerIntentsAsync(session.Id, new SubmitIntentsRequest 301 { 302 Intents = new IntentDto { Primary = PrimaryAction.Fire } 303 }); 304 305 var advanceResult = await service.AdvanceAsync(session.Id); 306 Assert.True(advanceResult.IsSuccess); 307 308 // Should transition to Planning (next turn) or Completed 309 Assert.Contains(advanceResult.Value!.Phase, new[] { SessionPhase.Planning, SessionPhase.Completed }); 310 } 311 312 [Fact] 313 public async Task PhaseTransition_Completed_CannotAdvance() 314 { 315 var store = new InMemoryCombatSessionStore(); 316 var service = new CombatSessionService(store); 317 var session = (await service.CreateSessionAsync(new SessionCreateRequest { Seed = 100 })).Value!; 318 319 // Advance combat until it completes (simulate multiple rounds) 320 for (int i = 0; i < 50; i++) 321 { 322 var state = await service.GetStateAsync(session.Id); 323 Assert.True(state.IsSuccess, "GetStateAsync should succeed"); 324 325 if (state.Value!.Phase == SessionPhase.Completed) 326 { 327 // Try to advance a completed session 328 var result = await service.AdvanceAsync(session.Id); 329 Assert.False(result.IsSuccess); 330 Assert.Equal(ResultStatus.InvalidState, result.Status); 331 return; 332 } 333 334 await service.SubmitPlayerIntentsAsync(session.Id, new SubmitIntentsRequest 335 { 336 Intents = new IntentDto { Primary = PrimaryAction.Fire } 337 }); 338 339 var advanceResult = await service.AdvanceAsync(session.Id); 340 341 if (!advanceResult.IsSuccess) 342 { 343 Assert.Fail("AdvanceAsync failed before the session reached the Completed phase."); 344 } 345 346 if (advanceResult.Value!.Phase == SessionPhase.Completed) 347 { 348 // On the next loop iteration, the Completed phase will be detected 349 // by GetStateAsync and the negative-advance assertion will run. 350 continue; 351 } 352 } 353 354 // If we exit the loop without ever reaching the Completed phase, 355 // the test has not actually verified that a completed session cannot advance. 356 Assert.Fail("Session did not reach the Completed phase within 50 iterations; test did not verify that a completed session cannot advance."); 357 } 358 359 [Fact] 360 public async Task Advance_WithoutIntents_ReturnsInvalidState() 361 { 362 var store = new InMemoryCombatSessionStore(); 363 var service = new CombatSessionService(store); 364 var session = (await service.CreateSessionAsync(new SessionCreateRequest { Seed = 200 })).Value!; 365 366 var result = await service.AdvanceAsync(session.Id); 367 368 Assert.False(result.IsSuccess); 369 Assert.Equal(ResultStatus.InvalidState, result.Status); 370 Assert.Contains("intents", result.ErrorMessage ?? "", StringComparison.OrdinalIgnoreCase); 371 } 372 373 [Fact] 374 public async Task SubmitIntents_WhenNotInPlanningPhase_IsRejected() 375 { 376 var store = new InMemoryCombatSessionStore(); 377 var service = new CombatSessionService(store); 378 var session = (await service.CreateSessionAsync(new SessionCreateRequest { Seed = 300 })).Value!; 379 380 // Submit initial intents and advance 381 await service.SubmitPlayerIntentsAsync(session.Id, new SubmitIntentsRequest 382 { 383 Intents = new IntentDto { Primary = PrimaryAction.Fire } 384 }); 385 386 // Advance completes the turn and transitions to either Planning (next turn) or Completed 387 var advanceResult = await service.AdvanceAsync(session.Id); 388 Assert.True(advanceResult.IsSuccess, "AdvanceAsync should succeed"); 389 390 var phase = advanceResult.Value!.Phase; 391 392 // If we're in Completed phase, submitting intents should fail 393 if (phase == SessionPhase.Completed) 394 { 395 var submitResult = await service.SubmitPlayerIntentsAsync(session.Id, new SubmitIntentsRequest 396 { 397 Intents = new IntentDto { Primary = PrimaryAction.Reload } 398 }); 399 400 Assert.False(submitResult.IsSuccess, "Submitting intents during Completed phase should be rejected"); 401 Assert.Equal(ResultStatus.InvalidState, submitResult.Status); 402 } 403 // If we're back in Planning, the test doesn't verify the rejection behavior 404 // but that's acceptable as Resolving is never persisted 405 } 406 407 [Fact] 408 public async Task CreateSession_WithProvidedId_PreservesId() 409 { 410 var store = new InMemoryCombatSessionStore(); 411 var service = new CombatSessionService(store); 412 var expectedId = Guid.NewGuid(); 413 414 var result = await service.CreateSessionAsync(new SessionCreateRequest { Id = expectedId, Seed = 42 }); 415 416 Assert.True(result.IsSuccess); 417 Assert.Equal(expectedId, result.Value!.Id); 418 419 var snapshot = await store.LoadAsync(expectedId); 420 Assert.NotNull(snapshot); 421 Assert.Equal(expectedId, snapshot!.Id); 422 } 423 424 [Fact] 425 public async Task CreateSession_WithEmptyGuid_ReturnsValidationError() 426 { 427 var service = new CombatSessionService(new InMemoryCombatSessionStore()); 428 429 var result = await service.CreateSessionAsync(new SessionCreateRequest { Id = Guid.Empty, Seed = 42 }); 430 431 Assert.False(result.IsSuccess); 432 Assert.Equal(ResultStatus.ValidationError, result.Status); 433 } 434 435 [Fact] 436 public async Task CreateSession_WithDuplicateId_ReturnsError() 437 { 438 var store = new InMemoryCombatSessionStore(); 439 var service = new CombatSessionService(store); 440 var id = Guid.NewGuid(); 441 442 var first = await service.CreateSessionAsync(new SessionCreateRequest { Id = id, Seed = 42 }); 443 Assert.True(first.IsSuccess); 444 445 var second = await service.CreateSessionAsync(new SessionCreateRequest { Id = id, Seed = 99 }); 446 Assert.False(second.IsSuccess); 447 Assert.Equal(ResultStatus.InvalidState, second.Status); 448 } 449 450 [Fact] 451 public async Task SubmitIntents_WithOperatorInBaseMode_ReturnsInvalidStateError() 452 { 453 // Arrange 454 var store = new InMemoryCombatSessionStore(); 455 var operatorEventStore = new StubOperatorEventStore(); 456 var operatorId = Guid.NewGuid(); 457 458 // Setup operator in Base mode 459 operatorEventStore.SetupOperatorWithMode(OperatorId.FromGuid(operatorId), OperatorMode.Base); 460 461 var service = new CombatSessionService(store, operatorEventStore); 462 463 // Create a session with this operator 464 var sessionResult = await service.CreateSessionAsync(new SessionCreateRequest 465 { 466 OperatorId = operatorId, 467 Seed = 123 468 }); 469 Assert.True(sessionResult.IsSuccess); 470 var session = sessionResult.Value!; 471 472 // Act - try to submit intents while operator is in Base mode 473 var result = await service.SubmitPlayerIntentsAsync(session.Id, new SubmitIntentsRequest 474 { 475 Intents = new IntentDto { Primary = PrimaryAction.Fire, Movement = MovementAction.WalkToward } 476 }); 477 478 // Assert 479 Assert.False(result.IsSuccess); 480 Assert.Equal(ResultStatus.InvalidState, result.Status); 481 Assert.Contains("Combat actions are only allowed when operator is in Infil mode", result.ErrorMessage); 482 } 483 484 [Fact] 485 public async Task SubmitIntents_WithOperatorInInfilMode_Succeeds() 486 { 487 // Arrange 488 var store = new InMemoryCombatSessionStore(); 489 var operatorEventStore = new StubOperatorEventStore(); 490 var operatorId = Guid.NewGuid(); 491 492 var service = new CombatSessionService(store, operatorEventStore); 493 494 // Create a session with this operator first to get the session ID 495 var sessionResult = await service.CreateSessionAsync(new SessionCreateRequest 496 { 497 OperatorId = operatorId, 498 Seed = 123 499 }); 500 Assert.True(sessionResult.IsSuccess); 501 var session = sessionResult.Value!; 502 503 // Setup operator in Infil mode with the matching active combat session ID 504 operatorEventStore.SetupOperatorWithMode(OperatorId.FromGuid(operatorId), OperatorMode.Infil, activeCombatSessionId: session.Id); 505 506 // Act - submit intents while operator is in Infil mode with matching session 507 var result = await service.SubmitPlayerIntentsAsync(session.Id, new SubmitIntentsRequest 508 { 509 Intents = new IntentDto { Primary = PrimaryAction.Fire, Movement = MovementAction.WalkToward } 510 }); 511 512 // Assert 513 Assert.True(result.IsSuccess); 514 } 515 516 [Fact] 517 public async Task Advance_WithOperatorInBaseMode_ReturnsInvalidStateError() 518 { 519 // Arrange 520 var store = new InMemoryCombatSessionStore(); 521 var operatorEventStore = new StubOperatorEventStore(); 522 var operatorId = Guid.NewGuid(); 523 524 // Setup operator in Base mode 525 operatorEventStore.SetupOperatorWithMode(OperatorId.FromGuid(operatorId), OperatorMode.Base); 526 527 var service = new CombatSessionService(store, operatorEventStore); 528 529 // Create a session and submit intents without validation (simulate session created while in Infil, then operator died) 530 var sessionResult = await service.CreateSessionAsync(new SessionCreateRequest 531 { 532 OperatorId = operatorId, 533 Seed = 123 534 }); 535 Assert.True(sessionResult.IsSuccess); 536 var session = sessionResult.Value!; 537 538 // Change operator to Infil temporarily to submit intents, with matching session ID 539 operatorEventStore.SetupOperatorWithMode(OperatorId.FromGuid(operatorId), OperatorMode.Infil, activeCombatSessionId: session.Id); 540 await service.SubmitPlayerIntentsAsync(session.Id, new SubmitIntentsRequest 541 { 542 Intents = new IntentDto { Primary = PrimaryAction.Fire, Movement = MovementAction.WalkToward } 543 }); 544 545 // Change operator back to Base mode (simulating death) 546 operatorEventStore.SetupOperatorWithMode(OperatorId.FromGuid(operatorId), OperatorMode.Base); 547 548 // Act - try to advance while operator is in Base mode 549 var result = await service.AdvanceAsync(session.Id); 550 551 // Assert 552 Assert.False(result.IsSuccess); 553 Assert.Equal(ResultStatus.InvalidState, result.Status); 554 Assert.Contains("Combat actions are only allowed when operator is in Infil mode", result.ErrorMessage); 555 } 556 557 [Fact] 558 public async Task SubmitIntents_WithNonExistentOperator_ReturnsNotFoundError() 559 { 560 // Arrange 561 var store = new InMemoryCombatSessionStore(); 562 var operatorEventStore = new StubOperatorEventStore(); 563 var operatorId = Guid.NewGuid(); 564 565 // Setup operator as non-existent 566 operatorEventStore.SetupNonExistentOperator(OperatorId.FromGuid(operatorId)); 567 568 var service = new CombatSessionService(store, operatorEventStore); 569 570 // Create a session with this operator 571 var sessionResult = await service.CreateSessionAsync(new SessionCreateRequest 572 { 573 OperatorId = operatorId, 574 Seed = 123 575 }); 576 Assert.True(sessionResult.IsSuccess); 577 var session = sessionResult.Value!; 578 579 // Act - try to submit intents with non-existent operator 580 var result = await service.SubmitPlayerIntentsAsync(session.Id, new SubmitIntentsRequest 581 { 582 Intents = new IntentDto { Primary = PrimaryAction.Fire, Movement = MovementAction.WalkToward } 583 }); 584 585 // Assert 586 Assert.False(result.IsSuccess); 587 Assert.Equal(ResultStatus.NotFound, result.Status); 588 Assert.Contains("Operator not found", result.ErrorMessage); 589 } 590 591 [Fact] 592 public async Task SubmitIntents_WithoutOperatorEventStore_SkipsValidation() 593 { 594 // Arrange - no operator event store provided 595 var store = new InMemoryCombatSessionStore(); 596 var service = new CombatSessionService(store); 597 var operatorId = Guid.NewGuid(); 598 599 // Create a session with this operator 600 var sessionResult = await service.CreateSessionAsync(new SessionCreateRequest 601 { 602 OperatorId = operatorId, 603 Seed = 123 604 }); 605 Assert.True(sessionResult.IsSuccess); 606 var session = sessionResult.Value!; 607 608 // Act - submit intents without operator event store (validation should be skipped) 609 var result = await service.SubmitPlayerIntentsAsync(session.Id, new SubmitIntentsRequest 610 { 611 Intents = new IntentDto { Primary = PrimaryAction.Fire, Movement = MovementAction.WalkToward } 612 }); 613 614 // Assert - succeeds because validation is skipped 615 Assert.True(result.IsSuccess); 616 } 617 618 [Fact] 619 public async Task SubmitIntents_WithMismatchedSessionId_ReturnsInvalidStateError() 620 { 621 // Arrange 622 var store = new InMemoryCombatSessionStore(); 623 var operatorEventStore = new StubOperatorEventStore(); 624 var operatorId = Guid.NewGuid(); 625 626 // Setup operator in Infil mode with a DIFFERENT active combat session ID (tamper scenario) 627 var differentSessionId = Guid.NewGuid(); 628 operatorEventStore.SetupOperatorWithMode(OperatorId.FromGuid(operatorId), OperatorMode.Infil, activeCombatSessionId: differentSessionId); 629 630 var service = new CombatSessionService(store, operatorEventStore); 631 632 // Create a session with this operator 633 var sessionResult = await service.CreateSessionAsync(new SessionCreateRequest 634 { 635 OperatorId = operatorId, 636 Seed = 123 637 }); 638 Assert.True(sessionResult.IsSuccess); 639 var session = sessionResult.Value!; 640 641 // Act - try to submit intents using a session ID that doesn't match operator's active session 642 var result = await service.SubmitPlayerIntentsAsync(session.Id, new SubmitIntentsRequest 643 { 644 Intents = new IntentDto { Primary = PrimaryAction.Fire } 645 }); 646 647 // Assert 648 Assert.False(result.IsSuccess); 649 Assert.Equal(ResultStatus.InvalidState, result.Status); 650 Assert.Contains("active combat session", result.ErrorMessage, StringComparison.OrdinalIgnoreCase); 651 } 652 653 [Fact] 654 public async Task SubmitIntents_WithNoActiveSessionId_ReturnsInvalidStateError() 655 { 656 // Arrange 657 var store = new InMemoryCombatSessionStore(); 658 var operatorEventStore = new StubOperatorEventStore(); 659 var operatorId = Guid.NewGuid(); 660 661 // Setup operator in Infil mode with NO active combat session (e.g., after a victory) 662 operatorEventStore.SetupOperatorWithMode(OperatorId.FromGuid(operatorId), OperatorMode.Infil); 663 664 var service = new CombatSessionService(store, operatorEventStore); 665 666 // Create a session with this operator 667 var sessionResult = await service.CreateSessionAsync(new SessionCreateRequest 668 { 669 OperatorId = operatorId, 670 Seed = 123 671 }); 672 Assert.True(sessionResult.IsSuccess); 673 var session = sessionResult.Value!; 674 675 // Act - try to submit intents when operator has no active combat session 676 var result = await service.SubmitPlayerIntentsAsync(session.Id, new SubmitIntentsRequest 677 { 678 Intents = new IntentDto { Primary = PrimaryAction.Fire } 679 }); 680 681 // Assert 682 Assert.False(result.IsSuccess); 683 Assert.Equal(ResultStatus.InvalidState, result.Status); 684 Assert.Contains("active combat session", result.ErrorMessage, StringComparison.OrdinalIgnoreCase); 685 } 686 687 [Fact] 688 public async Task Advance_WithMismatchedSessionId_ReturnsInvalidStateError() 689 { 690 // Arrange 691 var store = new InMemoryCombatSessionStore(); 692 var operatorEventStore = new StubOperatorEventStore(); 693 var operatorId = Guid.NewGuid(); 694 695 var service = new CombatSessionService(store, operatorEventStore); 696 697 // Create a session and submit intents with the matching session ID 698 var sessionResult = await service.CreateSessionAsync(new SessionCreateRequest 699 { 700 OperatorId = operatorId, 701 Seed = 123 702 }); 703 Assert.True(sessionResult.IsSuccess); 704 var session = sessionResult.Value!; 705 706 // Set up operator in Infil mode with matching session ID for intent submission 707 operatorEventStore.SetupOperatorWithMode(OperatorId.FromGuid(operatorId), OperatorMode.Infil, activeCombatSessionId: session.Id); 708 var submitResult = await service.SubmitPlayerIntentsAsync(session.Id, new SubmitIntentsRequest 709 { 710 Intents = new IntentDto { Primary = PrimaryAction.Fire } 711 }); 712 Assert.True(submitResult.IsSuccess); 713 714 // Now set up operator with a DIFFERENT active session ID (tamper scenario for advance) 715 operatorEventStore.SetupOperatorWithMode(OperatorId.FromGuid(operatorId), OperatorMode.Infil, activeCombatSessionId: Guid.NewGuid()); 716 717 // Act - try to advance using a session ID that doesn't match the operator's active session 718 var result = await service.AdvanceAsync(session.Id); 719 720 // Assert 721 Assert.False(result.IsSuccess); 722 Assert.Equal(ResultStatus.InvalidState, result.Status); 723 Assert.Contains("active combat session", result.ErrorMessage, StringComparison.OrdinalIgnoreCase); 724 } 725 726 [Fact] 727 public async Task Advance_WithNoActiveSessionId_ReturnsInvalidStateError() 728 { 729 // Arrange 730 var store = new InMemoryCombatSessionStore(); 731 var operatorEventStore = new StubOperatorEventStore(); 732 var operatorId = Guid.NewGuid(); 733 734 var service = new CombatSessionService(store, operatorEventStore); 735 736 // Create a session and submit intents with the matching session ID 737 var sessionResult = await service.CreateSessionAsync(new SessionCreateRequest 738 { 739 OperatorId = operatorId, 740 Seed = 123 741 }); 742 Assert.True(sessionResult.IsSuccess); 743 var session = sessionResult.Value!; 744 745 // Set up operator in Infil mode with matching session ID for intent submission 746 operatorEventStore.SetupOperatorWithMode(OperatorId.FromGuid(operatorId), OperatorMode.Infil, activeCombatSessionId: session.Id); 747 var submitResult = await service.SubmitPlayerIntentsAsync(session.Id, new SubmitIntentsRequest 748 { 749 Intents = new IntentDto { Primary = PrimaryAction.Fire } 750 }); 751 Assert.True(submitResult.IsSuccess); 752 753 // Now set up operator in Infil mode with NO active session ID (e.g., after victory cleared it) 754 operatorEventStore.SetupOperatorWithMode(OperatorId.FromGuid(operatorId), OperatorMode.Infil); 755 756 // Act - try to advance when operator has no active combat session 757 var result = await service.AdvanceAsync(session.Id); 758 759 // Assert 760 Assert.False(result.IsSuccess); 761 Assert.Equal(ResultStatus.InvalidState, result.Status); 762 Assert.Contains("active combat session", result.ErrorMessage, StringComparison.OrdinalIgnoreCase); 763 } 764 765 [Fact] 766 public async Task SubmitIntents_WithWrongCallerOperatorId_ReturnsInvalidStateError() 767 { 768 // Arrange 769 var store = new InMemoryCombatSessionStore(); 770 var operatorEventStore = new StubOperatorEventStore(); 771 var ownerOperatorId = Guid.NewGuid(); 772 var attackerOperatorId = Guid.NewGuid(); // different operator trying to tamper 773 774 var service = new CombatSessionService(store, operatorEventStore); 775 776 var sessionResult = await service.CreateSessionAsync(new SessionCreateRequest 777 { 778 OperatorId = ownerOperatorId, 779 Seed = 123 780 }); 781 Assert.True(sessionResult.IsSuccess); 782 var session = sessionResult.Value!; 783 784 // Owner operator is set up in Infil mode with the correct active session 785 operatorEventStore.SetupOperatorWithMode(OperatorId.FromGuid(ownerOperatorId), OperatorMode.Infil, activeCombatSessionId: session.Id); 786 787 // Act - attacker provides their own operatorId but tries to act on owner's session 788 var result = await service.SubmitPlayerIntentsAsync(session.Id, new SubmitIntentsRequest 789 { 790 Intents = new IntentDto { Primary = PrimaryAction.Fire }, 791 OperatorId = attackerOperatorId 792 }); 793 794 // Assert 795 Assert.False(result.IsSuccess); 796 Assert.Equal(ResultStatus.InvalidState, result.Status); 797 Assert.Contains("belong to the specified operator", result.ErrorMessage, StringComparison.OrdinalIgnoreCase); 798 } 799 800 [Fact] 801 public async Task Advance_WithWrongCallerOperatorId_ReturnsInvalidStateError() 802 { 803 // Arrange 804 var store = new InMemoryCombatSessionStore(); 805 var operatorEventStore = new StubOperatorEventStore(); 806 var ownerOperatorId = Guid.NewGuid(); 807 var attackerOperatorId = Guid.NewGuid(); // different operator trying to tamper 808 809 var service = new CombatSessionService(store, operatorEventStore); 810 811 var sessionResult = await service.CreateSessionAsync(new SessionCreateRequest 812 { 813 OperatorId = ownerOperatorId, 814 Seed = 123 815 }); 816 Assert.True(sessionResult.IsSuccess); 817 var session = sessionResult.Value!; 818 819 // Owner operator is set up in Infil mode with the correct active session 820 operatorEventStore.SetupOperatorWithMode(OperatorId.FromGuid(ownerOperatorId), OperatorMode.Infil, activeCombatSessionId: session.Id); 821 var submitResult = await service.SubmitPlayerIntentsAsync(session.Id, new SubmitIntentsRequest 822 { 823 Intents = new IntentDto { Primary = PrimaryAction.Fire }, 824 OperatorId = ownerOperatorId 825 }); 826 Assert.True(submitResult.IsSuccess); 827 828 // Act - attacker provides their own operatorId but tries to advance owner's session 829 var result = await service.AdvanceAsync(session.Id, callerOperatorId: attackerOperatorId); 830 831 // Assert 832 Assert.False(result.IsSuccess); 833 Assert.Equal(ResultStatus.InvalidState, result.Status); 834 Assert.Contains("belong to the specified operator", result.ErrorMessage, StringComparison.OrdinalIgnoreCase); 835 } 836 837 [Fact] 838 public async Task SubmitIntents_WithUpdateHub_PublishesNotification() 839 { 840 var store = new InMemoryCombatSessionStore(); 841 var hub = new CombatSessionUpdateHub(); 842 var service = new CombatSessionService(store, updateHub: hub); 843 var session = (await service.CreateSessionAsync(new SessionCreateRequest { Seed = 42 })).Value!; 844 845 using var cts = new CancellationTokenSource(); 846 var received = new List<Guid>(); 847 var ready = new TaskCompletionSource(); 848 849 var subscribeTask = Task.Run(async () => 850 { 851 try 852 { 853 await using var enumerator = hub.SubscribeAsync(session.Id, cts.Token) 854 .GetAsyncEnumerator(cts.Token); 855 var pending = enumerator.MoveNextAsync(); 856 ready.TrySetResult(); 857 while (await pending) 858 { 859 received.Add(enumerator.Current); 860 cts.Cancel(); 861 pending = enumerator.MoveNextAsync(); 862 } 863 } 864 catch (OperationCanceledException) { } 865 }); 866 867 await ready.Task.WaitAsync(TimeSpan.FromSeconds(2)); 868 869 await service.SubmitPlayerIntentsAsync(session.Id, new SubmitIntentsRequest 870 { 871 Intents = new IntentDto { Primary = PrimaryAction.Fire } 872 }); 873 874 await subscribeTask.WaitAsync(TimeSpan.FromSeconds(2)); 875 876 Assert.Single(received); 877 Assert.Equal(session.Id, received[0]); 878 } 879 880 [Fact] 881 public async Task Advance_WithUpdateHub_PublishesNotification() 882 { 883 var store = new InMemoryCombatSessionStore(); 884 var hub = new CombatSessionUpdateHub(); 885 var service = new CombatSessionService(store, updateHub: hub); 886 var session = (await service.CreateSessionAsync(new SessionCreateRequest { Seed = 42 })).Value!; 887 888 await service.SubmitPlayerIntentsAsync(session.Id, new SubmitIntentsRequest 889 { 890 Intents = new IntentDto { Primary = PrimaryAction.Fire } 891 }); 892 893 using var cts = new CancellationTokenSource(); 894 var received = new List<Guid>(); 895 var ready = new TaskCompletionSource(); 896 897 var subscribeTask = Task.Run(async () => 898 { 899 try 900 { 901 await using var enumerator = hub.SubscribeAsync(session.Id, cts.Token) 902 .GetAsyncEnumerator(cts.Token); 903 var pending = enumerator.MoveNextAsync(); 904 ready.TrySetResult(); 905 while (await pending) 906 { 907 received.Add(enumerator.Current); 908 cts.Cancel(); 909 pending = enumerator.MoveNextAsync(); 910 } 911 } 912 catch (OperationCanceledException) { } 913 }); 914 915 await ready.Task.WaitAsync(TimeSpan.FromSeconds(2)); 916 917 await service.AdvanceAsync(session.Id); 918 919 await subscribeTask.WaitAsync(TimeSpan.FromSeconds(2)); 920 921 Assert.Single(received); 922 Assert.Equal(session.Id, received[0]); 923 } 924 }