OperatorExfilService.cs
1 using GUNRPG.Application.Combat; 2 using GUNRPG.Application.Distributed; 3 using GUNRPG.Application.Dtos; 4 using GUNRPG.Application.Requests; 5 using GUNRPG.Application.Results; 6 using GUNRPG.Core.Operators; 7 using GUNRPG.Core.VirtualPet; 8 9 namespace GUNRPG.Application.Operators; 10 11 /// <summary> 12 /// Application service for operator exfil (out-of-combat) operations. 13 /// This is the ONLY place where operator events may be committed. 14 /// Acts as a transactional boundary between combat (infil) and operator state. 15 /// 16 /// Responsibilities: 17 /// - Load operators by replaying events 18 /// - Validate exfil-only actions 19 /// - Append new operator events 20 /// - Persist events atomically 21 /// - Process combat outcomes 22 /// </summary> 23 public sealed class OperatorExfilService 24 { 25 public const int InfilTimerMinutes = 30; 26 27 private readonly IOperatorEventStore _eventStore; 28 private readonly OperatorEventReplicator? _replicator; 29 private readonly OperatorUpdateHub? _updateHub; 30 31 public OperatorExfilService(IOperatorEventStore eventStore, OperatorEventReplicator? replicator = null, OperatorUpdateHub? updateHub = null) 32 { 33 _eventStore = eventStore ?? throw new ArgumentNullException(nameof(eventStore)); 34 _replicator = replicator; 35 _updateHub = updateHub; 36 } 37 38 /// <summary> 39 /// Creates a new operator and persists the creation event. 40 /// If a non-empty accountId is provided, the operator is associated with that account. 41 /// </summary> 42 public async Task<ServiceResult<OperatorId>> CreateOperatorAsync(string name, Guid accountId = default) 43 { 44 if (string.IsNullOrWhiteSpace(name)) 45 return ServiceResult<OperatorId>.ValidationError("Operator name cannot be empty"); 46 47 var operatorId = OperatorId.NewId(); 48 var createdEvent = new OperatorCreatedEvent(operatorId, name); 49 50 try 51 { 52 await AppendAsync(createdEvent); 53 54 if (accountId != Guid.Empty) 55 await _eventStore.AssociateOperatorWithAccountAsync(operatorId, accountId); 56 57 return ServiceResult<OperatorId>.Success(operatorId); 58 } 59 catch (Exception ex) 60 { 61 return ServiceResult<OperatorId>.InvalidState($"Failed to create operator: {ex.Message}"); 62 } 63 } 64 65 /// <summary> 66 /// Loads an operator by replaying all events from the event store. 67 /// Verifies hash chain integrity during load. 68 /// </summary> 69 public async Task<ServiceResult<OperatorAggregate>> LoadOperatorAsync(OperatorId operatorId) 70 { 71 try 72 { 73 var events = await _eventStore.LoadEventsAsync(operatorId); 74 if (events.Count == 0) 75 return ServiceResult<OperatorAggregate>.NotFound($"Operator {operatorId} not found"); 76 77 var aggregate = OperatorAggregate.FromEvents(events); 78 return ServiceResult<OperatorAggregate>.Success(aggregate); 79 } 80 catch (InvalidOperationException ex) when (ex.Message.Contains("hash") || ex.Message.Contains("chain")) 81 { 82 // Hash chain corruption detected 83 return ServiceResult<OperatorAggregate>.InvalidState($"Operator data corrupted: {ex.Message}"); 84 } 85 catch (Exception ex) 86 { 87 return ServiceResult<OperatorAggregate>.InvalidState($"Failed to load operator: {ex.Message}"); 88 } 89 } 90 91 /// <summary> 92 /// Applies experience points to an operator (exfil-only action). 93 /// </summary> 94 public async Task<ServiceResult> ApplyXpAsync(OperatorId operatorId, long xpAmount, string reason) 95 { 96 if (xpAmount <= 0) 97 return ServiceResult.ValidationError("XP amount must be positive"); 98 99 if (string.IsNullOrWhiteSpace(reason)) 100 return ServiceResult.ValidationError("XP reason cannot be empty"); 101 102 var loadResult = await LoadOperatorAsync(operatorId); 103 if (!loadResult.IsSuccess) 104 return MapLoadResultStatus(loadResult); 105 106 var aggregate = loadResult.Value!; 107 108 // Dead operators cannot gain XP 109 if (aggregate.IsDead) 110 return ServiceResult.InvalidState("Cannot apply XP to dead operator"); 111 112 // Must be in Base mode 113 if (aggregate.CurrentMode != OperatorMode.Base) 114 return ServiceResult.InvalidState("Cannot apply XP while in Infil mode"); 115 116 var previousHash = aggregate.GetLastEventHash(); 117 var sequenceNumber = aggregate.CurrentSequence + 1; 118 119 var xpEvent = new XpGainedEvent( 120 operatorId, 121 sequenceNumber, 122 xpAmount, 123 reason, 124 previousHash); 125 126 try 127 { 128 await AppendAsync(xpEvent); 129 return ServiceResult.Success(); 130 } 131 catch (Exception ex) 132 { 133 return ServiceResult.InvalidState($"Failed to apply XP: {ex.Message}"); 134 } 135 } 136 137 /// <summary> 138 /// Treats wounds (restores health) for an operator (exfil-only action). 139 /// </summary> 140 public async Task<ServiceResult> TreatWoundsAsync(OperatorId operatorId, float healthAmount) 141 { 142 if (healthAmount <= 0) 143 return ServiceResult.ValidationError("Health amount must be positive"); 144 145 var loadResult = await LoadOperatorAsync(operatorId); 146 if (!loadResult.IsSuccess) 147 return MapLoadResultStatus(loadResult); 148 149 var aggregate = loadResult.Value!; 150 151 // Dead operators cannot be healed 152 if (aggregate.IsDead) 153 return ServiceResult.InvalidState("Cannot treat wounds on dead operator"); 154 155 // Must be in Base mode 156 if (aggregate.CurrentMode != OperatorMode.Base) 157 return ServiceResult.InvalidState("Cannot treat wounds while in Infil mode"); 158 159 var previousHash = aggregate.GetLastEventHash(); 160 var sequenceNumber = aggregate.CurrentSequence + 1; 161 162 var woundsEvent = new WoundsTreatedEvent( 163 operatorId, 164 sequenceNumber, 165 healthAmount, 166 previousHash); 167 168 try 169 { 170 await AppendAsync(woundsEvent); 171 return ServiceResult.Success(); 172 } 173 catch (Exception ex) 174 { 175 return ServiceResult.InvalidState($"Failed to treat wounds: {ex.Message}"); 176 } 177 } 178 179 /// <summary> 180 /// Changes an operator's loadout (exfil-only action). 181 /// </summary> 182 public async Task<ServiceResult> ChangeLoadoutAsync(OperatorId operatorId, string weaponName) 183 { 184 if (string.IsNullOrWhiteSpace(weaponName)) 185 return ServiceResult.ValidationError("Weapon name cannot be empty"); 186 187 var loadResult = await LoadOperatorAsync(operatorId); 188 if (!loadResult.IsSuccess) 189 return MapLoadResultStatus(loadResult); 190 191 var aggregate = loadResult.Value!; 192 193 // Dead operators cannot change loadout 194 if (aggregate.IsDead) 195 return ServiceResult.InvalidState("Cannot change loadout for dead operator"); 196 197 // Must be in Base mode - loadout is locked during infil 198 if (aggregate.CurrentMode != OperatorMode.Base) 199 return ServiceResult.InvalidState("Cannot change loadout while in Infil mode - loadout is locked"); 200 201 var previousHash = aggregate.GetLastEventHash(); 202 var sequenceNumber = aggregate.CurrentSequence + 1; 203 204 var loadoutEvent = new LoadoutChangedEvent( 205 operatorId, 206 sequenceNumber, 207 weaponName, 208 previousHash); 209 210 try 211 { 212 await AppendAsync(loadoutEvent); 213 return ServiceResult.Success(); 214 } 215 catch (Exception ex) 216 { 217 return ServiceResult.InvalidState($"Failed to change loadout: {ex.Message}"); 218 } 219 } 220 221 /// <summary> 222 /// Unlocks a perk for an operator (exfil-only action). 223 /// </summary> 224 public async Task<ServiceResult> UnlockPerkAsync(OperatorId operatorId, string perkName) 225 { 226 if (string.IsNullOrWhiteSpace(perkName)) 227 return ServiceResult.ValidationError("Perk name cannot be empty"); 228 229 var loadResult = await LoadOperatorAsync(operatorId); 230 if (!loadResult.IsSuccess) 231 return MapLoadResultStatus(loadResult); 232 233 var aggregate = loadResult.Value!; 234 235 // Dead operators cannot have perks unlocked 236 if (aggregate.IsDead) 237 return ServiceResult.InvalidState("Cannot unlock perk for dead operator"); 238 239 // Must be in Base mode 240 if (aggregate.CurrentMode != OperatorMode.Base) 241 return ServiceResult.InvalidState("Cannot unlock perk while in Infil mode"); 242 243 // Check if perk already unlocked 244 if (aggregate.UnlockedPerks.Contains(perkName)) 245 return ServiceResult.ValidationError($"Perk '{perkName}' already unlocked"); 246 247 var previousHash = aggregate.GetLastEventHash(); 248 var sequenceNumber = aggregate.CurrentSequence + 1; 249 250 var perkEvent = new PerkUnlockedEvent( 251 operatorId, 252 sequenceNumber, 253 perkName, 254 previousHash); 255 256 try 257 { 258 await AppendAsync(perkEvent); 259 return ServiceResult.Success(); 260 } 261 catch (Exception ex) 262 { 263 return ServiceResult.InvalidState($"Failed to unlock perk: {ex.Message}"); 264 } 265 } 266 267 /// <summary> 268 /// Marks an exfil as successful, incrementing the operator's exfil streak. 269 /// DEPRECATED for combat-based exfil: Use ProcessCombatOutcomeAsync instead. 270 /// 271 /// This method is kept for backward compatibility and non-combat exfil scenarios. 272 /// Note: This method does NOT transition the operator back to Base mode. 273 /// For combat exfil, ProcessCombatOutcomeAsync handles the complete workflow including mode transition. 274 /// </summary> 275 public async Task<ServiceResult> CompleteExfilAsync(OperatorId operatorId) 276 { 277 var loadResult = await LoadOperatorAsync(operatorId); 278 if (!loadResult.IsSuccess) 279 return MapLoadResultStatus(loadResult); 280 281 var aggregate = loadResult.Value!; 282 283 // Dead operators cannot complete exfil 284 if (aggregate.IsDead) 285 return ServiceResult.InvalidState("Dead operators cannot complete exfil"); 286 287 // Must be in Infil mode to complete exfil 288 if (aggregate.CurrentMode != OperatorMode.Infil) 289 return ServiceResult.InvalidState("Cannot complete exfil when not in Infil mode"); 290 291 var previousHash = aggregate.GetLastEventHash(); 292 var sequenceNumber = aggregate.CurrentSequence + 1; 293 294 var exfilEvent = new CombatVictoryEvent( 295 operatorId, 296 sequenceNumber, 297 previousHash); 298 299 try 300 { 301 await AppendAsync(exfilEvent); 302 return ServiceResult.Success(); 303 } 304 catch (Exception ex) 305 { 306 return ServiceResult.InvalidState($"Failed to complete exfil: {ex.Message}"); 307 } 308 } 309 310 /// <summary> 311 /// Marks an exfil as failed, resetting the operator's exfil streak. 312 /// Used when operator retreats, abandons mission, or fails to extract. 313 /// </summary> 314 public async Task<ServiceResult> FailExfilAsync(OperatorId operatorId, string reason) 315 { 316 if (string.IsNullOrWhiteSpace(reason)) 317 return ServiceResult.ValidationError("Exfil failure reason cannot be empty"); 318 319 var loadResult = await LoadOperatorAsync(operatorId); 320 if (!loadResult.IsSuccess) 321 return MapLoadResultStatus(loadResult); 322 323 var aggregate = loadResult.Value!; 324 325 // Dead operators don't need exfil failure recorded (death event already resets streak) 326 if (aggregate.IsDead) 327 return ServiceResult.InvalidState("Dead operators cannot fail exfil - already dead"); 328 329 var previousHash = aggregate.GetLastEventHash(); 330 var sequenceNumber = aggregate.CurrentSequence + 1; 331 332 var exfilEvent = new ExfilFailedEvent( 333 operatorId, 334 sequenceNumber, 335 reason, 336 previousHash); 337 338 try 339 { 340 await AppendAsync(exfilEvent); 341 return ServiceResult.Success(); 342 } 343 catch (Exception ex) 344 { 345 return ServiceResult.InvalidState($"Failed to record exfil failure: {ex.Message}"); 346 } 347 } 348 349 /// <summary> 350 /// Starts an infil operation, transitioning the operator from Base mode to Infil mode. 351 /// Locks the operator's loadout and starts the 30-minute timer. 352 /// Returns the session ID to associate with the combat session. 353 /// </summary> 354 public async Task<ServiceResult<Guid>> StartInfilAsync(OperatorId operatorId) 355 { 356 var loadResult = await LoadOperatorAsync(operatorId); 357 if (!loadResult.IsSuccess) 358 return ServiceResult<Guid>.FromResult(MapLoadResultStatus(loadResult)); 359 360 var aggregate = loadResult.Value!; 361 362 // Dead operators cannot start infil 363 if (aggregate.IsDead) 364 return ServiceResult<Guid>.InvalidState("Dead operators cannot start infil"); 365 366 // If operator is already in Infil mode, check whether the 30-minute timer has expired. 367 // A lapsed timer means the client-side expiry fired but the server was never notified — 368 // auto-fail the stale infil so the player can re-infil without manual intervention. 369 // A missing InfilStartTime indicates data corruption; auto-fail in that case too. 370 if (aggregate.CurrentMode == OperatorMode.Infil) 371 { 372 if (!aggregate.InfilStartTime.HasValue || 373 (DateTimeOffset.UtcNow - aggregate.InfilStartTime.Value).TotalMinutes >= InfilTimerMinutes) 374 { 375 var failResult = await FailInfilAsync(operatorId, "Infil timer expired (30 minutes)"); 376 if (!failResult.IsSuccess) 377 return ServiceResult<Guid>.InvalidState($"Failed to clear expired infil: {failResult.ErrorMessage}"); 378 379 // Reload aggregate with the operator now back in Base mode 380 loadResult = await LoadOperatorAsync(operatorId); 381 if (!loadResult.IsSuccess) 382 return ServiceResult<Guid>.FromResult(MapLoadResultStatus(loadResult)); 383 aggregate = loadResult.Value!; 384 385 // Defensive check: ensure operator is now in Base mode before starting a new infil 386 if (aggregate.CurrentMode != OperatorMode.Base) 387 return ServiceResult<Guid>.InvalidState("Failed to clear expired infil - operator not in Base mode"); 388 } 389 else 390 { 391 return ServiceResult<Guid>.InvalidState("Operator is already in Infil mode"); 392 } 393 } 394 395 var sessionId = Guid.NewGuid(); 396 var infilStartTime = DateTimeOffset.UtcNow; 397 var lockedLoadout = aggregate.EquippedWeaponName; // Lock current loadout 398 399 var previousHash = aggregate.GetLastEventHash(); 400 var sequenceNumber = aggregate.CurrentSequence + 1; 401 402 var infilEvent = new InfilStartedEvent( 403 operatorId, 404 sequenceNumber, 405 sessionId, 406 lockedLoadout, 407 infilStartTime, 408 previousHash); 409 410 try 411 { 412 await AppendAsync(infilEvent); 413 return ServiceResult<Guid>.Success(sessionId); 414 } 415 catch (Exception ex) 416 { 417 return ServiceResult<Guid>.InvalidState($"Failed to start infil: {ex.Message}"); 418 } 419 } 420 421 /// <summary> 422 /// Starts a new combat session during an active infil operation. 423 /// Creates a CombatSessionStartedEvent to update the ActiveCombatSessionId. 424 /// Must be called when operator is in Infil mode without an active combat session. 425 /// </summary> 426 public async Task<ServiceResult<Guid>> StartCombatSessionAsync(OperatorId operatorId, Guid? sessionId = null) 427 { 428 var loadResult = await LoadOperatorAsync(operatorId); 429 if (!loadResult.IsSuccess) 430 return ServiceResult<Guid>.FromResult(MapLoadResultStatus(loadResult)); 431 432 var aggregate = loadResult.Value!; 433 434 // Must be in Infil mode to start a combat session 435 if (aggregate.CurrentMode != OperatorMode.Infil) 436 return ServiceResult<Guid>.InvalidState("Cannot start combat session when not in Infil mode"); 437 438 // Must have an infil session ID 439 if (aggregate.InfilSessionId == null) 440 return ServiceResult<Guid>.InvalidState("Cannot start combat session without an active infil"); 441 442 // Should not have an active combat session 443 if (aggregate.ActiveCombatSessionId != null) 444 return ServiceResult<Guid>.InvalidState("Cannot start new combat session while one is already active"); 445 446 if (sessionId.HasValue && sessionId.Value == Guid.Empty) 447 return ServiceResult<Guid>.InvalidState("Combat session ID cannot be empty"); 448 449 var combatSessionId = sessionId ?? Guid.NewGuid(); 450 451 var previousHash = aggregate.GetLastEventHash(); 452 var sequenceNumber = aggregate.CurrentSequence + 1; 453 454 var combatEvent = new CombatSessionStartedEvent( 455 operatorId, 456 sequenceNumber, 457 combatSessionId, 458 previousHash); 459 460 try 461 { 462 await AppendAsync(combatEvent); 463 return ServiceResult<Guid>.Success(combatSessionId); 464 } 465 catch (Exception ex) 466 { 467 return ServiceResult<Guid>.InvalidState($"Failed to start combat session: {ex.Message}"); 468 } 469 } 470 471 /// <summary> 472 /// Clears a dangling ActiveCombatSessionId by emitting a CombatVictoryEvent. 473 /// Use when the referenced combat session no longer exists in the store but the 474 /// operator aggregate still holds its ID, preventing a new session from being started. 475 /// </summary> 476 public async Task<ServiceResult> ClearDanglingCombatSessionAsync(OperatorId operatorId) 477 { 478 var loadResult = await LoadOperatorAsync(operatorId); 479 if (!loadResult.IsSuccess) 480 return MapLoadResultStatus(loadResult); 481 482 var aggregate = loadResult.Value!; 483 484 if (aggregate.ActiveCombatSessionId == null) 485 return ServiceResult.Success(); 486 487 var previousHash = aggregate.GetLastEventHash(); 488 var sequenceNumber = aggregate.CurrentSequence + 1; 489 490 var clearEvent = new CombatVictoryEvent(operatorId, sequenceNumber, previousHash); 491 492 try 493 { 494 await AppendAsync(clearEvent); 495 return ServiceResult.Success(); 496 } 497 catch (Exception ex) 498 { 499 return ServiceResult.InvalidState($"Failed to clear dangling combat session: {ex.Message}"); 500 } 501 } 502 503 /// <summary> 504 /// Fails an infil operation due to timeout or other reasons. 505 /// Clears the locked loadout (gear loss), resets streak, and returns operator to Base mode. 506 /// </summary> 507 public async Task<ServiceResult> FailInfilAsync(OperatorId operatorId, string reason) 508 { 509 if (string.IsNullOrWhiteSpace(reason)) 510 return ServiceResult.ValidationError("Infil failure reason cannot be empty"); 511 512 var loadResult = await LoadOperatorAsync(operatorId); 513 if (!loadResult.IsSuccess) 514 return MapLoadResultStatus(loadResult); 515 516 var aggregate = loadResult.Value!; 517 518 // Must be in Infil mode to fail infil 519 if (aggregate.CurrentMode != OperatorMode.Infil) 520 return ServiceResult.InvalidState("Cannot fail infil when not in Infil mode"); 521 522 var previousHash = aggregate.GetLastEventHash(); 523 var sequenceNumber = aggregate.CurrentSequence + 1; 524 525 // Build events to append atomically: ExfilFailed + InfilEnded 526 var eventsToAppend = new List<OperatorEvent>(); 527 528 var exfilFailedEvent = new ExfilFailedEvent( 529 operatorId, 530 sequenceNumber, 531 reason, 532 previousHash); 533 eventsToAppend.Add(exfilFailedEvent); 534 535 var infilEndedEvent = new InfilEndedEvent( 536 operatorId, 537 sequenceNumber + 1, 538 wasSuccessful: false, 539 reason: reason, 540 previousHash: exfilFailedEvent.Hash); 541 eventsToAppend.Add(infilEndedEvent); 542 543 try 544 { 545 await AppendBatchAsync(eventsToAppend); 546 return ServiceResult.Success(); 547 } 548 catch (Exception ex) 549 { 550 return ServiceResult.InvalidState($"Failed to fail infil: {ex.Message}"); 551 } 552 } 553 554 /// <summary> 555 /// Successfully completes the infil operation, transitioning the operator back to Base mode. 556 /// This is used when the player chooses to exfil, with or without engaging in combat. 557 /// Operators can exfil at any time during an active infil. 558 /// </summary> 559 public async Task<ServiceResult> CompleteInfilSuccessfullyAsync(OperatorId operatorId) 560 { 561 var loadResult = await LoadOperatorAsync(operatorId); 562 if (!loadResult.IsSuccess) 563 return MapLoadResultStatus(loadResult); 564 565 var aggregate = loadResult.Value!; 566 567 // Must be in Infil mode to complete infil 568 if (aggregate.CurrentMode != OperatorMode.Infil) 569 return ServiceResult.InvalidState("Cannot complete infil when not in Infil mode"); 570 571 var previousHash = aggregate.GetLastEventHash(); 572 var sequenceNumber = aggregate.CurrentSequence + 1; 573 574 // Emit InfilEndedEvent with wasSuccessful: true 575 var infilEndedEvent = new InfilEndedEvent( 576 operatorId, 577 sequenceNumber, 578 wasSuccessful: true, 579 reason: "Operator successfully exfiltrated", 580 previousHash: previousHash); 581 582 try 583 { 584 await AppendAsync(infilEndedEvent); 585 return ServiceResult.Success(); 586 } 587 catch (Exception) 588 { 589 // TODO: Consider logging the exception details server-side for diagnostics 590 return ServiceResult.InvalidState("Failed to complete infil due to an internal error."); 591 } 592 } 593 594 /// <summary> 595 /// Checks if the operator's infil has timed out (exceeded 30 minutes). 596 /// Returns true if the operator is in infil mode and the timer has expired. 597 /// </summary> 598 public async Task<ServiceResult<bool>> IsInfilTimedOutAsync(OperatorId operatorId) 599 { 600 var loadResult = await LoadOperatorAsync(operatorId); 601 if (!loadResult.IsSuccess) 602 return ServiceResult<bool>.FromResult(MapLoadResultStatus(loadResult)); 603 604 var aggregate = loadResult.Value!; 605 606 // Not in infil mode, so can't be timed out 607 if (aggregate.CurrentMode != OperatorMode.Infil) 608 return ServiceResult<bool>.Success(false); 609 610 if (!aggregate.InfilStartTime.HasValue) 611 return ServiceResult<bool>.Success(false); 612 613 var elapsed = DateTimeOffset.UtcNow - aggregate.InfilStartTime.Value; 614 var isTimedOut = elapsed.TotalMinutes >= InfilTimerMinutes; 615 616 return ServiceResult<bool>.Success(isTimedOut); 617 } 618 619 /// <summary> 620 /// Marks an operator as dead. This is permanent and resets the exfil streak. 621 /// Once dead, no further state changes are allowed for this operator. 622 /// </summary> 623 public async Task<ServiceResult> KillOperatorAsync(OperatorId operatorId, string causeOfDeath) 624 { 625 if (string.IsNullOrWhiteSpace(causeOfDeath)) 626 return ServiceResult.ValidationError("Cause of death cannot be empty"); 627 628 var loadResult = await LoadOperatorAsync(operatorId); 629 if (!loadResult.IsSuccess) 630 return MapLoadResultStatus(loadResult); 631 632 var aggregate = loadResult.Value!; 633 634 // Cannot kill an already-dead operator 635 if (aggregate.IsDead) 636 return ServiceResult.InvalidState("Operator is already dead"); 637 638 var previousHash = aggregate.GetLastEventHash(); 639 var sequenceNumber = aggregate.CurrentSequence + 1; 640 641 var deathEvent = new OperatorDiedEvent( 642 operatorId, 643 sequenceNumber, 644 causeOfDeath, 645 previousHash); 646 647 try 648 { 649 await AppendAsync(deathEvent); 650 return ServiceResult.Success(); 651 } 652 catch (Exception ex) 653 { 654 return ServiceResult.InvalidState($"Failed to record operator death: {ex.Message}"); 655 } 656 } 657 658 /// <summary> 659 /// Processes a combat outcome and applies the results to an operator. 660 /// This is the primary boundary between infil (combat) and exfil (operator management). 661 /// 662 /// The outcome is accepted from combat, but the player can review/modify before committing. 663 /// Once committed, this method translates the outcome into operator events atomically. 664 /// 665 /// Exfil Semantics: 666 /// - Operator must be in Infil mode to process combat outcome 667 /// - If operator died: Emit OperatorDied + InfilEnded (failure) events (resets streak, marks IsDead, returns to Base mode) 668 /// - If operator survived and is victorious: Apply XP (if any), emit CombatVictory (clears ActiveCombatSessionId), STAY in Infil mode for next combat 669 /// - If operator survived but retreated/failed: Apply XP (if any), emit ExfilFailed + InfilEnded (resets streak, returns to Base mode) 670 /// - If infil timer expired (30+ minutes), automatically fail the infil 671 /// 672 /// Session ID semantics: 673 /// - InfilSessionId: Persistent ID for the entire infil operation, set when infil starts 674 /// - ActiveCombatSessionId: ID for current combat encounter, cleared after each combat completion 675 /// - CombatVictoryEvent clears ActiveCombatSessionId to enable starting a new combat 676 /// - Use StartCombatSessionAsync to create a new combat session after victory 677 /// </summary> 678 public async Task<ServiceResult> ProcessCombatOutcomeAsync(CombatOutcome outcome, bool playerConfirmed = true) 679 { 680 if (outcome == null) 681 return ServiceResult.ValidationError("Combat outcome cannot be null"); 682 683 if (!playerConfirmed) 684 return ServiceResult.ValidationError("Combat outcome must be confirmed before processing"); 685 686 var loadResult = await LoadOperatorAsync(outcome.OperatorId); 687 if (!loadResult.IsSuccess) 688 return MapLoadResultStatus(loadResult); 689 690 var aggregate = loadResult.Value!; 691 692 // Already dead operators cannot process new outcomes 693 if (aggregate.IsDead) 694 return ServiceResult.InvalidState("Cannot process combat outcome for dead operator"); 695 696 // Must be in Infil mode to process combat outcome 697 if (aggregate.CurrentMode != OperatorMode.Infil) 698 return ServiceResult.InvalidState("Cannot process combat outcome when not in Infil mode"); 699 700 // Note: We don't validate ActiveSessionId matches outcome.SessionId because multiple 701 // combat sessions can occur during a single infil (after victories, operator stays in Infil mode). 702 // The fact that the operator is in Infil mode is the key validation - this ensures they have 703 // an active infil and prevents processing outcomes when in Base mode. 704 705 // Check if infil timer has expired (30 minutes) 706 if (aggregate.InfilStartTime.HasValue) 707 { 708 var elapsed = DateTimeOffset.UtcNow - aggregate.InfilStartTime.Value; 709 if (elapsed.TotalMinutes >= InfilTimerMinutes) 710 { 711 // Timer expired - automatically fail the infil 712 return await FailInfilAsync(outcome.OperatorId, "Infil timer expired (30 minutes)"); 713 } 714 } 715 716 // Build event list to append atomically 717 var eventsToAppend = new List<OperatorEvent>(); 718 var previousHash = aggregate.GetLastEventHash(); 719 var nextSequence = aggregate.CurrentSequence + 1; 720 721 // If operator died in combat, emit death event + infil ended (failure) 722 if (outcome.OperatorDied) 723 { 724 var deathEvent = new OperatorDiedEvent( 725 outcome.OperatorId, 726 nextSequence, 727 "Killed in combat", 728 previousHash); 729 730 eventsToAppend.Add(deathEvent); 731 previousHash = deathEvent.Hash; 732 nextSequence++; 733 734 // End infil as failure (death automatically transitions to Base mode in aggregate) 735 var infilEndedEvent = new InfilEndedEvent( 736 outcome.OperatorId, 737 nextSequence, 738 wasSuccessful: false, 739 reason: "Operator died in combat", 740 previousHash: previousHash); 741 742 eventsToAppend.Add(infilEndedEvent); 743 } 744 else 745 { 746 // Operator survived - emit XP event if earned 747 if (outcome.XpGained > 0) 748 { 749 // Derive XP reason from outcome properties 750 var xpReason = outcome.IsVictory ? "Victory" : "Survival"; 751 752 var xpEvent = new XpGainedEvent( 753 outcome.OperatorId, 754 nextSequence, 755 outcome.XpGained, 756 xpReason, 757 previousHash); 758 759 eventsToAppend.Add(xpEvent); 760 previousHash = xpEvent.Hash; 761 nextSequence++; 762 } 763 764 // If operator achieved victory, emit combat victory event but keep operator in Infil mode 765 // This allows them to continue fighting additional enemies 766 if (outcome.IsVictory) 767 { 768 var exfilEvent = new CombatVictoryEvent( 769 outcome.OperatorId, 770 nextSequence, 771 previousHash); 772 773 eventsToAppend.Add(exfilEvent); 774 // Do NOT emit InfilEndedEvent - operator stays in Infil mode to fight next enemy 775 } 776 else 777 { 778 // Operator survived but didn't win - end infil as failure 779 var exfilFailedEvent = new ExfilFailedEvent( 780 outcome.OperatorId, 781 nextSequence, 782 "Mission failed", 783 previousHash); 784 785 eventsToAppend.Add(exfilFailedEvent); 786 previousHash = exfilFailedEvent.Hash; 787 nextSequence++; 788 789 var infilEndedEvent = new InfilEndedEvent( 790 outcome.OperatorId, 791 nextSequence, 792 wasSuccessful: false, 793 reason: "Mission failed", 794 previousHash: previousHash); 795 796 eventsToAppend.Add(infilEndedEvent); 797 } 798 } 799 800 // Append all events atomically 801 if (eventsToAppend.Count > 0) 802 { 803 try 804 { 805 await AppendBatchAsync(eventsToAppend); 806 } 807 catch (Exception ex) 808 { 809 return ServiceResult.InvalidState($"Failed to process combat outcome: {ex.Message}"); 810 } 811 } 812 813 return ServiceResult.Success(); 814 } 815 816 817 /// <summary> 818 /// Lists all known operator IDs. 819 /// </summary> 820 public async Task<ServiceResult<IReadOnlyList<OperatorId>>> ListOperatorsAsync() 821 { 822 try 823 { 824 var operatorIds = await _eventStore.ListOperatorIdsAsync(); 825 return ServiceResult<IReadOnlyList<OperatorId>>.Success(operatorIds); 826 } 827 catch (Exception ex) 828 { 829 return ServiceResult<IReadOnlyList<OperatorId>>.InvalidState($"Failed to list operators: {ex.Message}"); 830 } 831 } 832 833 /// <summary> 834 /// Checks if an operator exists. 835 /// </summary> 836 public async Task<bool> OperatorExistsAsync(OperatorId operatorId) 837 { 838 return await _eventStore.OperatorExistsAsync(operatorId); 839 } 840 841 /// <summary> 842 /// Applies a pet action to an operator's virtual pet. 843 /// Only allowed in Base mode. Updates pet state via event sourcing. 844 /// </summary> 845 public async Task<ServiceResult> ApplyPetActionAsync( 846 OperatorId operatorId, 847 PetActionRequest request) 848 { 849 var loadResult = await LoadOperatorAsync(operatorId); 850 if (!loadResult.IsSuccess) 851 return MapLoadResultStatus(loadResult); 852 853 var aggregate = loadResult.Value!; 854 855 // Validate operator state 856 if (aggregate.CurrentMode != OperatorMode.Base) 857 { 858 return ServiceResult.ValidationError( 859 "Pet actions can only be performed in Base mode, not during infil"); 860 } 861 862 if (aggregate.IsDead) 863 { 864 return ServiceResult.ValidationError( 865 "Cannot apply pet actions to a dead operator"); 866 } 867 868 if (aggregate.PetState == null) 869 { 870 return ServiceResult.InvalidState( 871 "Operator has no pet state"); 872 } 873 874 // Parse the pet action from the request 875 PetInput petInput; 876 try 877 { 878 petInput = ParsePetInput(request); 879 } 880 catch (ArgumentOutOfRangeException ex) 881 { 882 return ServiceResult.ValidationError(ex.Message); 883 } 884 catch (ArgumentException ex) 885 { 886 return ServiceResult.ValidationError(ex.Message); 887 } 888 889 // Apply the pet action to the aggregate (creates event) 890 try 891 { 892 var evt = aggregate.ApplyPetAction(petInput, DateTimeOffset.UtcNow); 893 await AppendAsync(evt); 894 } 895 catch (InvalidOperationException ex) 896 { 897 return ServiceResult.InvalidState(ex.Message); 898 } 899 900 return ServiceResult.Success(); 901 } 902 903 private static PetInput ParsePetInput(PetActionRequest request) 904 { 905 var action = request.Action?.Trim().ToLowerInvariant() ?? "rest"; 906 return action switch 907 { 908 "rest" => CreateRestInput(request), 909 "eat" => CreateEatInput(request), 910 "drink" => CreateDrinkInput(request), 911 _ => throw new ArgumentException($"Unknown pet action: {request.Action}") 912 }; 913 } 914 915 private static RestInput CreateRestInput(PetActionRequest request) 916 { 917 var hours = request.Hours ?? 1f; 918 919 if (hours <= 0f) 920 { 921 throw new ArgumentOutOfRangeException(nameof(request.Hours), hours, "Rest hours must be greater than zero."); 922 } 923 924 // Optionally cap the maximum rest duration to a reasonable upper bound. 925 if (hours > 24f) 926 { 927 hours = 24f; 928 } 929 930 return new RestInput(TimeSpan.FromHours(hours)); 931 } 932 933 private static EatInput CreateEatInput(PetActionRequest request) 934 { 935 var nutrition = request.Nutrition ?? 30f; 936 937 if (nutrition <= 0f) 938 { 939 throw new ArgumentOutOfRangeException(nameof(request.Nutrition), nutrition, "Nutrition amount must be greater than zero."); 940 } 941 942 // Optionally cap the maximum nutrition amount to prevent unrealistic changes. 943 if (nutrition > 100f) 944 { 945 nutrition = 100f; 946 } 947 948 return new EatInput(nutrition); 949 } 950 951 private static DrinkInput CreateDrinkInput(PetActionRequest request) 952 { 953 var hydration = request.Hydration ?? 30f; 954 955 if (hydration <= 0f) 956 { 957 throw new ArgumentOutOfRangeException(nameof(request.Hydration), hydration, "Hydration amount must be greater than zero."); 958 } 959 960 // Optionally cap the maximum hydration amount to prevent unrealistic changes. 961 if (hydration > 100f) 962 { 963 hydration = 100f; 964 } 965 966 return new DrinkInput(hydration); 967 } 968 969 /// <summary> 970 /// Maps a ServiceResult<OperatorAggregate> to a ServiceResult, 971 /// preserving the original ResultStatus (NotFound/ValidationError/InvalidState). 972 /// </summary> 973 private static ServiceResult MapLoadResultStatus(ServiceResult<OperatorAggregate> loadResult) 974 { 975 return loadResult.Status switch 976 { 977 ResultStatus.NotFound => ServiceResult.NotFound(loadResult.ErrorMessage!), 978 ResultStatus.ValidationError => ServiceResult.ValidationError(loadResult.ErrorMessage!), 979 ResultStatus.InvalidState => ServiceResult.InvalidState(loadResult.ErrorMessage!), 980 _ => ServiceResult.InvalidState(loadResult.ErrorMessage!) 981 }; 982 } 983 984 /// <summary> 985 /// Appends an event to the local store and, if a replicator is configured, 986 /// broadcasts it to all connected peers. Broadcast failures are best-effort and do not 987 /// affect the service result — the local append has already succeeded. 988 /// </summary> 989 private async Task AppendAsync(OperatorEvent evt) 990 { 991 await _eventStore.AppendEventAsync(evt); 992 _updateHub?.Publish(evt); 993 if (_replicator != null) 994 { 995 try { await _replicator.BroadcastAsync(evt); } 996 catch (Exception) { /* best-effort: local append succeeded */ } 997 } 998 } 999 1000 /// <summary> 1001 /// Appends a batch of events to the local store and broadcasts each one. 1002 /// Broadcast failures are best-effort — the batch has already been persisted atomically. 1003 /// </summary> 1004 private async Task AppendBatchAsync(IReadOnlyList<OperatorEvent> events) 1005 { 1006 await _eventStore.AppendEventsAsync(events); 1007 foreach (var evt in events) 1008 { 1009 _updateHub?.Publish(evt); 1010 } 1011 if (_replicator != null) 1012 { 1013 foreach (var evt in events) 1014 { 1015 try { await _replicator.BroadcastAsync(evt); } 1016 catch (Exception) { /* best-effort: local batch already persisted */ } 1017 } 1018 } 1019 } 1020 }