/ GUNRPG.Application / Operators / OperatorExfilService.cs
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&lt;OperatorAggregate&gt; 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  }