/ GUNRPG.Tests / OperatorModeTests.cs
OperatorModeTests.cs
1 using GUNRPG.Application.Operators; 2 using GUNRPG.Core.Operators; 3 using GUNRPG.Infrastructure.Persistence; 4 using LiteDB; 5 using Xunit; 6 7 namespace GUNRPG.Tests; 8 9 /// <summary> 10 /// Tests for Operator Mode enforcement and Infil/Exfil workflow. 11 /// Validates the boundary between Base mode (operator management) and Infil mode (combat). 12 /// </summary> 13 public class OperatorModeTests : IDisposable 14 { 15 private readonly LiteDatabase _database; 16 private readonly IOperatorEventStore _eventStore; 17 private readonly OperatorExfilService _service; 18 19 public OperatorModeTests() 20 { 21 var mapper = new BsonMapper(); 22 mapper.Entity<OperatorEventDocument>().Id(x => x.Id); 23 _database = new LiteDatabase(":memory:", mapper); 24 _eventStore = new LiteDbOperatorEventStore(_database); 25 _service = new OperatorExfilService(_eventStore); 26 } 27 28 public void Dispose() 29 { 30 _database?.Dispose(); 31 } 32 33 [Fact] 34 public async Task NewOperator_StartsInBaseMode() 35 { 36 // Arrange 37 var createResult = await _service.CreateOperatorAsync("TestOp"); 38 var operatorId = createResult.Value!; 39 40 // Act 41 var loadResult = await _service.LoadOperatorAsync(operatorId); 42 43 // Assert 44 Assert.True(loadResult.IsSuccess); 45 var aggregate = loadResult.Value!; 46 Assert.Equal(OperatorMode.Base, aggregate.CurrentMode); 47 Assert.Null(aggregate.InfilStartTime); 48 Assert.Null(aggregate.ActiveCombatSessionId); 49 } 50 51 [Fact] 52 public async Task StartInfil_TransitionsToInfilMode() 53 { 54 // Arrange 55 var createResult = await _service.CreateOperatorAsync("TestOp"); 56 var operatorId = createResult.Value!; 57 58 // Act 59 var infilResult = await _service.StartInfilAsync(operatorId); 60 61 // Assert 62 Assert.True(infilResult.IsSuccess); 63 var infilSessionId = infilResult.Value; 64 Assert.NotEqual(Guid.Empty, infilSessionId); 65 66 var loadResult = await _service.LoadOperatorAsync(operatorId); 67 var aggregate = loadResult.Value!; 68 Assert.Equal(OperatorMode.Infil, aggregate.CurrentMode); 69 Assert.NotNull(aggregate.InfilStartTime); 70 Assert.Equal(infilSessionId, aggregate.InfilSessionId); // InfilSessionId is set, not ActiveCombatSessionId 71 Assert.Null(aggregate.ActiveCombatSessionId); // No active combat session until player engages 72 } 73 74 [Fact] 75 public async Task StartInfil_WhenAlreadyInInfil_Fails() 76 { 77 // Arrange 78 var createResult = await _service.CreateOperatorAsync("TestOp"); 79 var operatorId = createResult.Value!; 80 await _service.StartInfilAsync(operatorId); 81 82 // Act 83 var secondInfilResult = await _service.StartInfilAsync(operatorId); 84 85 // Assert 86 Assert.False(secondInfilResult.IsSuccess); 87 Assert.Contains("already in Infil mode", secondInfilResult.ErrorMessage); 88 } 89 90 [Fact] 91 public async Task ChangeLoadout_InBaseMode_Succeeds() 92 { 93 // Arrange 94 var createResult = await _service.CreateOperatorAsync("TestOp"); 95 var operatorId = createResult.Value!; 96 97 // Act 98 var result = await _service.ChangeLoadoutAsync(operatorId, "SOKOL 545"); 99 100 // Assert 101 Assert.True(result.IsSuccess); 102 103 var loadResult = await _service.LoadOperatorAsync(operatorId); 104 Assert.Equal("SOKOL 545", loadResult.Value!.EquippedWeaponName); 105 } 106 107 [Fact] 108 public async Task ChangeLoadout_InInfilMode_Fails() 109 { 110 // Arrange 111 var createResult = await _service.CreateOperatorAsync("TestOp"); 112 var operatorId = createResult.Value!; 113 await _service.StartInfilAsync(operatorId); 114 115 // Act 116 var result = await _service.ChangeLoadoutAsync(operatorId, "SOKOL 545"); 117 118 // Assert 119 Assert.False(result.IsSuccess); 120 Assert.Contains("loadout is locked", result.ErrorMessage); 121 } 122 123 [Fact] 124 public async Task TreatWounds_InBaseMode_Succeeds() 125 { 126 // Arrange 127 var createResult = await _service.CreateOperatorAsync("TestOp"); 128 var operatorId = createResult.Value!; 129 130 // Act 131 var result = await _service.TreatWoundsAsync(operatorId, 10f); 132 133 // Assert 134 Assert.True(result.IsSuccess); 135 } 136 137 [Fact] 138 public async Task TreatWounds_InInfilMode_Fails() 139 { 140 // Arrange 141 var createResult = await _service.CreateOperatorAsync("TestOp"); 142 var operatorId = createResult.Value!; 143 await _service.StartInfilAsync(operatorId); 144 145 // Act 146 var result = await _service.TreatWoundsAsync(operatorId, 10f); 147 148 // Assert 149 Assert.False(result.IsSuccess); 150 Assert.Contains("Cannot treat wounds while in Infil mode", result.ErrorMessage); 151 } 152 153 [Fact] 154 public async Task UnlockPerk_InBaseMode_Succeeds() 155 { 156 // Arrange 157 var createResult = await _service.CreateOperatorAsync("TestOp"); 158 var operatorId = createResult.Value!; 159 160 // Act 161 var result = await _service.UnlockPerkAsync(operatorId, "TestPerk"); 162 163 // Assert 164 Assert.True(result.IsSuccess); 165 } 166 167 [Fact] 168 public async Task UnlockPerk_InInfilMode_Fails() 169 { 170 // Arrange 171 var createResult = await _service.CreateOperatorAsync("TestOp"); 172 var operatorId = createResult.Value!; 173 await _service.StartInfilAsync(operatorId); 174 175 // Act 176 var result = await _service.UnlockPerkAsync(operatorId, "TestPerk"); 177 178 // Assert 179 Assert.False(result.IsSuccess); 180 Assert.Contains("Cannot unlock perk while in Infil mode", result.ErrorMessage); 181 } 182 183 [Fact] 184 public async Task ApplyXp_InBaseMode_Succeeds() 185 { 186 // Arrange 187 var createResult = await _service.CreateOperatorAsync("TestOp"); 188 var operatorId = createResult.Value!; 189 190 // Act 191 var result = await _service.ApplyXpAsync(operatorId, 100, "Test"); 192 193 // Assert 194 Assert.True(result.IsSuccess); 195 } 196 197 [Fact] 198 public async Task ApplyXp_InInfilMode_Fails() 199 { 200 // Arrange 201 var createResult = await _service.CreateOperatorAsync("TestOp"); 202 var operatorId = createResult.Value!; 203 await _service.StartInfilAsync(operatorId); 204 205 // Act 206 var result = await _service.ApplyXpAsync(operatorId, 100, "Test"); 207 208 // Assert 209 Assert.False(result.IsSuccess); 210 Assert.Contains("Cannot apply XP while in Infil mode", result.ErrorMessage); 211 } 212 213 [Fact] 214 public async Task FailInfil_ResetsStreakAndReturnsToBase() 215 { 216 // Arrange 217 var createResult = await _service.CreateOperatorAsync("TestOp"); 218 var operatorId = createResult.Value!; 219 220 // Start infil first 221 await _service.StartInfilAsync(operatorId); 222 223 // Act 224 var failResult = await _service.FailInfilAsync(operatorId, "Timeout"); 225 226 // Assert 227 Assert.True(failResult.IsSuccess); 228 229 var loadResult = await _service.LoadOperatorAsync(operatorId); 230 var aggregate = loadResult.Value!; 231 Assert.Equal(OperatorMode.Base, aggregate.CurrentMode); 232 Assert.Equal(0, aggregate.ExfilStreak); 233 Assert.Null(aggregate.InfilStartTime); 234 Assert.Null(aggregate.ActiveCombatSessionId); 235 } 236 237 [Fact] 238 public async Task FailInfil_WhenInBaseMode_Fails() 239 { 240 // Arrange 241 var createResult = await _service.CreateOperatorAsync("TestOp"); 242 var operatorId = createResult.Value!; 243 244 // Act 245 var result = await _service.FailInfilAsync(operatorId, "Timeout"); 246 247 // Assert 248 Assert.False(result.IsSuccess); 249 Assert.Contains("not in Infil mode", result.ErrorMessage); 250 } 251 252 [Fact] 253 public async Task StartInfil_LocksLoadout() 254 { 255 // Arrange 256 var createResult = await _service.CreateOperatorAsync("TestOp"); 257 var operatorId = createResult.Value!; 258 await _service.ChangeLoadoutAsync(operatorId, "SOKOL 545"); 259 260 // Act 261 await _service.StartInfilAsync(operatorId); 262 263 // Assert 264 var loadResult = await _service.LoadOperatorAsync(operatorId); 265 var aggregate = loadResult.Value!; 266 Assert.Equal("SOKOL 545", aggregate.LockedLoadout); 267 } 268 269 [Fact] 270 public async Task InfilSuccess_PreservesLoadout() 271 { 272 // Arrange 273 var createResult = await _service.CreateOperatorAsync("TestOp"); 274 var operatorId = createResult.Value!; 275 await _service.ChangeLoadoutAsync(operatorId, "SOKOL 545"); 276 var infilResult = await _service.StartInfilAsync(operatorId); 277 Assert.True(infilResult.IsSuccess); 278 279 // Act - Complete exfil which should preserve loadout 280 var exfilResult = await _service.CompleteExfilAsync(operatorId); 281 282 // Assert 283 Assert.True(exfilResult.IsSuccess); 284 285 // After CompleteExfilAsync, operator is still in Infil mode (legacy behavior) 286 // ProcessCombatOutcomeAsync handles the full workflow including mode transition 287 var loadResult = await _service.LoadOperatorAsync(operatorId); 288 var aggregate = loadResult.Value!; 289 Assert.Equal("SOKOL 545", aggregate.EquippedWeaponName); // Loadout preserved 290 Assert.Equal(OperatorMode.Infil, aggregate.CurrentMode); // Still in Infil (not ended yet) 291 Assert.Equal(0, aggregate.ExfilStreak); // CompleteExfilAsync does not increment streak (only infil completion does) 292 } 293 294 [Fact] 295 public async Task InfilFailure_ClearsLoadout() 296 { 297 // Arrange 298 var createResult = await _service.CreateOperatorAsync("TestOp"); 299 var operatorId = createResult.Value!; 300 await _service.ChangeLoadoutAsync(operatorId, "SOKOL 545"); 301 await _service.StartInfilAsync(operatorId); 302 303 // Act 304 await _service.FailInfilAsync(operatorId, "Timeout"); 305 306 // Assert 307 var loadResult = await _service.LoadOperatorAsync(operatorId); 308 var aggregate = loadResult.Value!; 309 Assert.Equal(string.Empty, aggregate.EquippedWeaponName); // Loadout cleared on failure 310 Assert.Equal(OperatorMode.Base, aggregate.CurrentMode); 311 } 312 313 [Fact] 314 public async Task ProcessCombatOutcome_WhenNotInInfil_Fails() 315 { 316 // Arrange 317 var mapper = new BsonMapper(); 318 mapper.Entity<OperatorEventDocument>().Id(x => x.Id); 319 using var db = new LiteDatabase(":memory:", mapper); 320 var eventStore = new LiteDbOperatorEventStore(db); 321 var service = new OperatorExfilService(eventStore); 322 323 var createResult = await service.CreateOperatorAsync("TestOp"); 324 var operatorId = createResult.Value!; 325 326 var outcome = new GUNRPG.Application.Combat.CombatOutcome( 327 Guid.NewGuid(), 328 operatorId, 329 operatorDied: false, 330 xpGained: 100, 331 gearLost: Array.Empty<GUNRPG.Core.Equipment.GearId>(), 332 isVictory: true); 333 334 // Act 335 var result = await service.ProcessCombatOutcomeAsync(outcome, playerConfirmed: true); 336 337 // Assert 338 Assert.False(result.IsSuccess); 339 Assert.Contains("not in Infil mode", result.ErrorMessage); 340 } 341 342 [Fact] 343 public async Task ProcessCombatOutcome_Victory_StaysInInfilMode() 344 { 345 // Arrange 346 var mapper = new BsonMapper(); 347 mapper.Entity<OperatorEventDocument>().Id(x => x.Id); 348 using var db = new LiteDatabase(":memory:", mapper); 349 var eventStore = new LiteDbOperatorEventStore(db); 350 var service = new OperatorExfilService(eventStore); 351 352 var createResult = await service.CreateOperatorAsync("TestOp"); 353 var operatorId = createResult.Value!; 354 var infilResult = await service.StartInfilAsync(operatorId); 355 var sessionId = infilResult.Value; // Use the actual session ID 356 357 var outcome = new GUNRPG.Application.Combat.CombatOutcome( 358 sessionId, // Use the session ID returned by StartInfilAsync 359 operatorId, 360 operatorDied: false, 361 xpGained: 100, 362 gearLost: Array.Empty<GUNRPG.Core.Equipment.GearId>(), 363 isVictory: true); 364 365 // Act 366 var result = await service.ProcessCombatOutcomeAsync(outcome, playerConfirmed: true); 367 368 // Assert 369 Assert.True(result.IsSuccess); 370 371 var loadResult = await service.LoadOperatorAsync(operatorId); 372 var aggregate = loadResult.Value!; 373 Assert.Equal(OperatorMode.Infil, aggregate.CurrentMode); // Should stay in Infil mode 374 Assert.Equal(0, aggregate.ExfilStreak); // Streak NOT incremented by combat victory (only on infil completion) 375 Assert.Equal(100, aggregate.TotalXp); 376 Assert.Null(aggregate.ActiveCombatSessionId); // Should clear ActiveSessionId after victory to prevent auto-resume 377 } 378 379 [Fact] 380 public async Task ProcessCombatOutcome_Death_ReturnsToBaseModeAndResetsStreak() 381 { 382 // Arrange 383 var mapper = new BsonMapper(); 384 mapper.Entity<OperatorEventDocument>().Id(x => x.Id); 385 using var db = new LiteDatabase(":memory:", mapper); 386 var eventStore = new LiteDbOperatorEventStore(db); 387 var service = new OperatorExfilService(eventStore); 388 389 var createResult = await service.CreateOperatorAsync("TestOp"); 390 var operatorId = createResult.Value!; 391 var infilResult = await service.StartInfilAsync(operatorId); 392 var sessionId = infilResult.Value; // Use the actual session ID 393 394 var outcome = new GUNRPG.Application.Combat.CombatOutcome( 395 sessionId, // Use the session ID returned by StartInfilAsync 396 operatorId, 397 operatorDied: true, 398 xpGained: 0, 399 gearLost: Array.Empty<GUNRPG.Core.Equipment.GearId>(), 400 isVictory: false); 401 402 // Act 403 var result = await service.ProcessCombatOutcomeAsync(outcome, playerConfirmed: true); 404 405 // Assert 406 Assert.True(result.IsSuccess); 407 408 var loadResult = await service.LoadOperatorAsync(operatorId); 409 var aggregate = loadResult.Value!; 410 Assert.Equal(OperatorMode.Base, aggregate.CurrentMode); 411 Assert.Equal(0, aggregate.ExfilStreak); 412 Assert.False(aggregate.IsDead); // Operator respawns after death 413 Assert.Equal(aggregate.MaxHealth, aggregate.CurrentHealth); // Health restored after respawn 414 } 415 416 [Fact] 417 public async Task IsInfilTimedOut_AfterThirtyMinutes_ReturnsTrue() 418 { 419 // Arrange 420 var mapper = new BsonMapper(); 421 mapper.Entity<OperatorEventDocument>().Id(x => x.Id); 422 using var db = new LiteDatabase(":memory:", mapper); 423 var eventStore = new LiteDbOperatorEventStore(db); 424 var service = new OperatorExfilService(eventStore); 425 426 var createResult = await service.CreateOperatorAsync("TestOp"); 427 var operatorId = createResult.Value!; 428 429 // Load to get the correct previous hash 430 var loadResult = await service.LoadOperatorAsync(operatorId); 431 var aggregate = loadResult.Value!; 432 var previousHash = aggregate.GetLastEventHash(); 433 434 // Start infil with a timestamp 31 minutes ago 435 var oldTimestamp = DateTimeOffset.UtcNow.AddMinutes(-31); 436 var infilEvent = new InfilStartedEvent( 437 operatorId, 438 1, 439 Guid.NewGuid(), 440 "TestLoadout", 441 oldTimestamp, 442 previousHash); 443 await eventStore.AppendEventAsync(infilEvent); 444 445 // Act 446 var result = await service.IsInfilTimedOutAsync(operatorId); 447 448 // Assert 449 Assert.True(result.IsSuccess); 450 Assert.True(result.Value); 451 } 452 453 [Fact] 454 public async Task IsInfilTimedOut_BeforeThirtyMinutes_ReturnsFalse() 455 { 456 // Arrange 457 var createResult = await _service.CreateOperatorAsync("TestOp"); 458 var operatorId = createResult.Value!; 459 await _service.StartInfilAsync(operatorId); 460 461 // Act 462 var result = await _service.IsInfilTimedOutAsync(operatorId); 463 464 // Assert 465 Assert.True(result.IsSuccess); 466 Assert.False(result.Value); 467 } 468 469 [Fact] 470 public async Task ProcessCombatOutcome_NonVictorySurvival_ReturnsToBaseMode() 471 { 472 // Arrange 473 var mapper = new BsonMapper(); 474 mapper.Entity<OperatorEventDocument>().Id(x => x.Id); 475 using var db = new LiteDatabase(":memory:", mapper); 476 var eventStore = new LiteDbOperatorEventStore(db); 477 var service = new OperatorExfilService(eventStore); 478 479 var createResult = await service.CreateOperatorAsync("TestOp"); 480 var operatorId = createResult.Value!; 481 var infilResult = await service.StartInfilAsync(operatorId); 482 var sessionId = infilResult.Value; 483 484 // Operator survived but did not win 485 var outcome = new GUNRPG.Application.Combat.CombatOutcome( 486 sessionId, 487 operatorId, 488 operatorDied: false, 489 xpGained: 0, 490 gearLost: Array.Empty<GUNRPG.Core.Equipment.GearId>(), 491 isVictory: false); 492 493 // Act 494 var result = await service.ProcessCombatOutcomeAsync(outcome, playerConfirmed: true); 495 496 // Assert 497 Assert.True(result.IsSuccess); 498 499 var loadResult = await service.LoadOperatorAsync(operatorId); 500 var aggregate = loadResult.Value!; 501 Assert.Equal(OperatorMode.Base, aggregate.CurrentMode); 502 Assert.Equal(0, aggregate.ExfilStreak); 503 Assert.Null(aggregate.ActiveCombatSessionId); 504 } 505 }