/ GUNRPG.Application / Services / OperatorService.cs
OperatorService.cs
  1  using System.Globalization;
  2  using System.Text.Json;
  3  using GUNRPG.Application.Backend;
  4  using GUNRPG.Application.Combat;
  5  using GUNRPG.Application.Dtos;
  6  using GUNRPG.Application.Mapping;
  7  using GUNRPG.Application.Operators;
  8  using GUNRPG.Application.Requests;
  9  using GUNRPG.Application.Results;
 10  using GUNRPG.Application.Sessions;
 11  using GUNRPG.Core.Operators;
 12  
 13  namespace GUNRPG.Application.Services;
 14  
 15  /// <summary>
 16  /// Application service that orchestrates operator operations and exposes UI-agnostic endpoints.
 17  /// </summary>
 18  public sealed class OperatorService
 19  {
 20      private readonly OperatorExfilService _exfilService;
 21      private readonly CombatSessionService _sessionService;
 22      private readonly IOperatorEventStore _eventStore;
 23      private readonly IOfflineSyncHeadStore? _offlineSyncHeadStore;
 24      private readonly IDeterministicCombatEngine? _combatEngine;
 25  
 26      public OperatorService(
 27          OperatorExfilService exfilService,
 28          CombatSessionService sessionService,
 29          IOperatorEventStore eventStore,
 30          IOfflineSyncHeadStore? offlineSyncHeadStore = null,
 31          IDeterministicCombatEngine? combatEngine = null)
 32      {
 33          _exfilService = exfilService;
 34          _sessionService = sessionService;
 35          _eventStore = eventStore;
 36          _offlineSyncHeadStore = offlineSyncHeadStore;
 37          _combatEngine = combatEngine;
 38      }
 39  
 40      public async Task<ServiceResult<OperatorStateDto>> CreateOperatorAsync(OperatorCreateRequest request)
 41      {
 42          if (string.IsNullOrWhiteSpace(request.Name))
 43          {
 44              return ServiceResult<OperatorStateDto>.ValidationError("Operator name cannot be empty");
 45          }
 46  
 47          var result = await _exfilService.CreateOperatorAsync(request.Name, request.AccountId);
 48          if (result.Status != ResultStatus.Success)
 49          {
 50              return ServiceResult<OperatorStateDto>.FromResult(result);
 51          }
 52  
 53          var loadResult = await _exfilService.LoadOperatorAsync(result.Value!);
 54          if (!loadResult.IsSuccess)
 55          {
 56              return ServiceResult<OperatorStateDto>.FromResult(loadResult);
 57          }
 58  
 59          return ServiceResult<OperatorStateDto>.Success(ToDto(loadResult.Value!));
 60      }
 61  
 62      /// <summary>
 63      /// Gets the current state of an operator, including their active combat session if present.
 64      /// If the operator is in Infil mode and the 30-minute timer has expired, the infil is
 65      /// automatically failed before returning state. This enforces the timer server-side so that
 66      /// no client action is required to transition out of a timed-out infil.
 67      /// </summary>
 68      public async Task<ServiceResult<OperatorStateDto>> GetOperatorAsync(Guid operatorId)
 69      {
 70          var loadResult = await _exfilService.LoadOperatorAsync(new OperatorId(operatorId));
 71          if (!loadResult.IsSuccess)
 72          {
 73              return ServiceResult<OperatorStateDto>.FromResult(loadResult);
 74          }
 75  
 76          var aggregate = loadResult.Value!;
 77  
 78          // Server-authoritative timer enforcement: if the operator is in Infil mode and the
 79          // 30-minute window has passed, auto-fail the infil here. This mirrors the same guard
 80          // in StartInfilAsync and ensures that any GET (e.g. RefreshOperator after the client
 81          // shows the ExfilFailed screen) returns Base-mode state, preventing the client from
 82          // re-displaying the ExfilFailed dialog on subsequent screens.
 83          if (aggregate.CurrentMode == OperatorMode.Infil &&
 84              (!aggregate.InfilStartTime.HasValue ||
 85               (DateTimeOffset.UtcNow - aggregate.InfilStartTime.Value).TotalMinutes >= OperatorExfilService.InfilTimerMinutes))
 86          {
 87              var failResult = await _exfilService.FailInfilAsync(new OperatorId(operatorId), "Infil timer expired (30 minutes)");
 88              if (!failResult.IsSuccess)
 89              {
 90                  // If the auto-fail write fails (including due to a concurrent state change),
 91                  // surface the error instead of returning potentially stale Infil state.
 92                  return ServiceResult<OperatorStateDto>.FromResult(failResult);
 93              }
 94  
 95              // Reload so the returned DTO reflects Base mode
 96              loadResult = await _exfilService.LoadOperatorAsync(new OperatorId(operatorId));
 97              if (!loadResult.IsSuccess)
 98                  return ServiceResult<OperatorStateDto>.FromResult(loadResult);
 99              aggregate = loadResult.Value!;
100          }
101  
102          var operatorDto = ToDto(aggregate);
103          
104          // If operator has an active combat session, load and include it
105          if (operatorDto.ActiveCombatSessionId.HasValue)
106          {
107              var sessionResult = await _sessionService.GetStateAsync(operatorDto.ActiveCombatSessionId.Value);
108              if (sessionResult.Status == ResultStatus.Success)
109              {
110                  // Session is active (Created, Planning, or Resolving phase), include it in the response
111                  // Create a new DTO with the active session included
112                  operatorDto = new OperatorStateDto
113                  {
114                      Id = operatorDto.Id,
115                      Name = operatorDto.Name,
116                      TotalXp = operatorDto.TotalXp,
117                      CurrentHealth = operatorDto.CurrentHealth,
118                      MaxHealth = operatorDto.MaxHealth,
119                      EquippedWeaponName = operatorDto.EquippedWeaponName,
120                      UnlockedPerks = operatorDto.UnlockedPerks,
121                      ExfilStreak = operatorDto.ExfilStreak,
122                      IsDead = operatorDto.IsDead,
123                      CurrentMode = operatorDto.CurrentMode,
124                      InfilStartTime = operatorDto.InfilStartTime,
125                      InfilSessionId = operatorDto.InfilSessionId,
126                      ActiveCombatSessionId = operatorDto.ActiveCombatSessionId,
127                      ActiveCombatSession = sessionResult.Value,
128                      LockedLoadout = operatorDto.LockedLoadout,
129                      Pet = operatorDto.Pet
130                  };
131              }
132              else
133              {
134                  // Session lookup failed; clear inconsistent ActiveCombatSessionId in the returned DTO
135                  // This prevents clients from seeing a dangling session reference
136                  operatorDto = new OperatorStateDto
137                  {
138                      Id = operatorDto.Id,
139                      Name = operatorDto.Name,
140                      TotalXp = operatorDto.TotalXp,
141                      CurrentHealth = operatorDto.CurrentHealth,
142                      MaxHealth = operatorDto.MaxHealth,
143                      EquippedWeaponName = operatorDto.EquippedWeaponName,
144                      UnlockedPerks = operatorDto.UnlockedPerks,
145                      ExfilStreak = operatorDto.ExfilStreak,
146                      IsDead = operatorDto.IsDead,
147                      CurrentMode = operatorDto.CurrentMode,
148                      InfilStartTime = operatorDto.InfilStartTime,
149                      InfilSessionId = operatorDto.InfilSessionId,
150                      ActiveCombatSessionId = null, // Clear the dangling reference
151                      ActiveCombatSession = null,
152                      LockedLoadout = operatorDto.LockedLoadout,
153                      Pet = operatorDto.Pet
154                  };
155              }
156          }
157  
158          return ServiceResult<OperatorStateDto>.Success(operatorDto);
159      }
160  
161      /// <summary>
162      /// Cleans up completed combat sessions for an operator.
163      /// If the operator has an ActiveCombatSessionId pointing to a Completed session, this method
164      /// auto-processes the outcome to prevent the operator from getting stuck.
165      /// This should be called before GetOperatorAsync when resuming a saved operator.
166      /// </summary>
167      public async Task<ServiceResult> CleanupCompletedSessionAsync(Guid operatorId)
168      {
169          var loadResult = await _exfilService.LoadOperatorAsync(new OperatorId(operatorId));
170          if (!loadResult.IsSuccess)
171          {
172              return loadResult.Status switch
173              {
174                  ResultStatus.NotFound => ServiceResult.NotFound(loadResult.ErrorMessage),
175                  ResultStatus.InvalidState => ServiceResult.InvalidState(loadResult.ErrorMessage!),
176                  ResultStatus.ValidationError => ServiceResult.ValidationError(loadResult.ErrorMessage!),
177                  _ => ServiceResult.InvalidState(loadResult.ErrorMessage!)
178              };
179          }
180  
181          var aggregate = loadResult.Value!;
182          
183          // If operator has no active combat session, nothing to cleanup
184          if (aggregate.ActiveCombatSessionId == null)
185          {
186              return ServiceResult.Success();
187          }
188  
189          var sessionResult = await _sessionService.GetStateAsync(aggregate.ActiveCombatSessionId.Value);
190          
191          // If session not found, the reference is dangling — clear it so the operator can start a new combat.
192          // This can happen when a session is deleted without the corresponding CombatVictoryEvent being emitted.
193          if (sessionResult.Status != ResultStatus.Success)
194          {
195              var clearResult = await _exfilService.ClearDanglingCombatSessionAsync(new OperatorId(operatorId));
196              if (!clearResult.IsSuccess)
197              {
198                  // Propagate the error so StartCombatSessionAsync knows the state is still inconsistent
199                  return clearResult;
200              }
201              return ServiceResult.Success();
202          }
203  
204          var session = sessionResult.Value!;
205          
206          // Only process if session is completed
207          if (session.Phase != SessionPhase.Completed)
208          {
209              return ServiceResult.Success();
210          }
211  
212          // Get the combat outcome
213          var outcomeResult = await _sessionService.GetCombatOutcomeAsync(aggregate.ActiveCombatSessionId.Value);
214          if (outcomeResult.Status != ResultStatus.Success)
215          {
216              // Failed to get combat outcome; log and return success to avoid blocking the Get operation
217              // The completed session will be included in GetOperatorAsync response for manual recovery
218              return ServiceResult.Success();
219          }
220  
221          // Process the outcome automatically
222          var processResult = await _exfilService.ProcessCombatOutcomeAsync(outcomeResult.Value!, playerConfirmed: true);
223          if (processResult.Status != ResultStatus.Success)
224          {
225              // Processing failed; log and return success to avoid blocking the Get operation
226              // The operator state may be partially updated, but GetOperatorAsync will return current state
227              return ServiceResult.Success();
228          }
229  
230          return ServiceResult.Success();
231      }
232  
233      public async Task<ServiceResult<List<OperatorSummaryDto>>> ListOperatorsAsync(Guid accountId)
234      {
235          if (accountId == Guid.Empty)
236              throw new ArgumentException("Account ID must be non-empty.", nameof(accountId));
237  
238          var operatorIds = await _eventStore.ListOperatorIdsByAccountAsync(accountId);
239          var summaries = new List<OperatorSummaryDto>();
240  
241          foreach (var operatorId in operatorIds)
242          {
243              var loadResult = await _exfilService.LoadOperatorAsync(operatorId);
244              if (loadResult.IsSuccess)
245              {
246                  var aggregate = loadResult.Value!;
247                  summaries.Add(new OperatorSummaryDto
248                  {
249                      Id = aggregate.Id.Value,
250                      Name = aggregate.Name,
251                      CurrentMode = aggregate.CurrentMode.ToString(),
252                      IsDead = aggregate.IsDead,
253                      TotalXp = aggregate.TotalXp,
254                      CurrentHealth = aggregate.CurrentHealth,
255                      MaxHealth = aggregate.MaxHealth
256                  });
257              }
258          }
259  
260          return ServiceResult<List<OperatorSummaryDto>>.Success(summaries);
261      }
262  
263      /// <summary>
264      /// Returns the account ID associated with an operator, or null if the operator has no account.
265      /// </summary>
266      public async Task<Guid?> GetOperatorAccountIdAsync(Guid operatorId)
267      {
268          return await _eventStore.GetOperatorAccountIdAsync(new OperatorId(operatorId));
269      }
270  
271      public async Task<ServiceResult<StartInfilResponse>> StartInfilAsync(Guid operatorId)
272      {
273          // Start the infil - this only transitions the operator to Infil mode
274          // Combat sessions are created separately when the player engages in combat
275          var result = await _exfilService.StartInfilAsync(new OperatorId(operatorId));
276          if (result.Status != ResultStatus.Success)
277          {
278              return ServiceResult<StartInfilResponse>.FromResult(result);
279          }
280  
281          var infilSessionId = result.Value!;
282  
283          // Load the operator to return current state
284          var loadResult = await _exfilService.LoadOperatorAsync(new OperatorId(operatorId));
285          if (!loadResult.IsSuccess)
286          {
287              // Operator load failed after infil was started - fail the infil to keep state consistent
288              var loadErrorDetails = loadResult.ErrorMessage ?? "Unknown error";
289              var failLoadResult = await _exfilService.FailInfilAsync(new OperatorId(operatorId), $"Operator load failed: {loadErrorDetails}");
290              if (failLoadResult.Status != ResultStatus.Success)
291              {
292                  var cleanupError = failLoadResult.ErrorMessage ?? "Unknown error while failing infil";
293                  Console.Error.WriteLine($"Failed to fail infil for operator {operatorId}: {cleanupError}");
294              }
295              return ServiceResult<StartInfilResponse>.FromResult(loadResult);
296          }
297  
298          var operatorAggregate = loadResult.Value!;
299  
300          // Return the infil session ID (used for tracking the overall infil, not a combat session)
301          // Combat sessions will be created when the player engages in combat via the combat endpoint, which calls OperatorExfilService.StartCombatSessionAsync
302          return ServiceResult<StartInfilResponse>.Success(new StartInfilResponse
303          {
304              SessionId = infilSessionId,
305              Operator = ToDto(operatorAggregate)
306          });
307      }
308  
309      public async Task<ServiceResult<OperatorStateDto>> ProcessCombatOutcomeAsync(Guid operatorId, ProcessOutcomeRequest request)
310      {
311          if (request.SessionId == Guid.Empty)
312          {
313              return ServiceResult<OperatorStateDto>.ValidationError("Session ID cannot be empty");
314          }
315  
316          var operatorLoadResult = await _exfilService.LoadOperatorAsync(new OperatorId(operatorId));
317          if (!operatorLoadResult.IsSuccess)
318          {
319              return ServiceResult<OperatorStateDto>.FromResult(operatorLoadResult);
320          }
321  
322          var operatorAggregate = operatorLoadResult.Value!;
323          if (operatorAggregate.ActiveCombatSessionId == null)
324          {
325              return ServiceResult<OperatorStateDto>.InvalidState("No active combat session found for operator");
326          }
327  
328          if (operatorAggregate.ActiveCombatSessionId.Value != request.SessionId)
329          {
330              return ServiceResult<OperatorStateDto>.InvalidState("Combat session does not belong to operator");
331          }
332  
333          // Load the combat session and get the authoritative outcome
334          var outcomeResult = await _sessionService.GetCombatOutcomeAsync(request.SessionId);
335          if (outcomeResult.Status != ResultStatus.Success)
336          {
337              return ServiceResult<OperatorStateDto>.FromResult(outcomeResult);
338          }
339  
340          var outcome = outcomeResult.Value!;
341  
342          var result = await _exfilService.ProcessCombatOutcomeAsync(outcome, playerConfirmed: true);
343          if (result.Status != ResultStatus.Success)
344          {
345              return ServiceResult<OperatorStateDto>.FromResult(result);
346          }
347  
348          var loadResult = await _exfilService.LoadOperatorAsync(new OperatorId(operatorId));
349          if (!loadResult.IsSuccess)
350          {
351              return ServiceResult<OperatorStateDto>.FromResult(loadResult);
352          }
353  
354          return ServiceResult<OperatorStateDto>.Success(ToDto(loadResult.Value!));
355      }
356  
357      public async Task<ServiceResult<Guid>> StartCombatSessionAsync(Guid operatorId)
358      {
359          var operatorKey = new OperatorId(operatorId);
360  
361          // If there is already an active combat session for this infil, reuse it instead of
362          // attempting to create a second one. This keeps combat entry idempotent for web clients
363          // that may retry or race against real-time updates.
364          var preLoadResult = await _exfilService.LoadOperatorAsync(operatorKey);
365          if (!preLoadResult.IsSuccess)
366              return ServiceResult<Guid>.FromResult(preLoadResult);
367  
368          var aggregate = preLoadResult.Value!;
369  
370          if (aggregate.ActiveCombatSessionId.HasValue)
371          {
372              var existingSessionResult = await _sessionService.GetStateAsync(aggregate.ActiveCombatSessionId.Value);
373              if (existingSessionResult.Status == ResultStatus.Success)
374              {
375                  if (existingSessionResult.Value!.Phase != SessionPhase.Completed)
376                      return ServiceResult<Guid>.Success(aggregate.ActiveCombatSessionId.Value);
377              }
378  
379              // Resolve any completed or dangling session reference before attempting to start a new combat.
380              var cleanupResult = await CleanupCompletedSessionAsync(operatorId);
381              if (!cleanupResult.IsSuccess)
382                  return ServiceResult<Guid>.FromResult(cleanupResult);
383  
384              // Reload because cleanup may have processed the combat outcome or cleared a stale reference.
385              var loadResult = await _exfilService.LoadOperatorAsync(operatorKey);
386              if (!loadResult.IsSuccess)
387                  return ServiceResult<Guid>.FromResult(loadResult);
388  
389              aggregate = loadResult.Value!;
390          }
391  
392          // Pre-generate the session ID so we can create the session record BEFORE emitting the
393          // CombatSessionStartedEvent. This removes the race condition where operator SSE subscribers
394          // receive a notification while the session store entry doesn't yet exist.
395          var sessionId = Guid.NewGuid();
396  
397          // Step 1: Create the combat session in the store so it exists when SSE subscribers react.
398          var sessionRequest = new SessionCreateRequest
399          {
400              Id = sessionId,
401              OperatorId = operatorId,
402              PlayerName = aggregate.Name
403          };
404          var sessionResult = await _sessionService.CreateSessionAsync(sessionRequest);
405          if (!sessionResult.IsSuccess)
406              return ServiceResult<Guid>.FromResult(sessionResult);
407  
408          // Step 2: Emit CombatSessionStartedEvent on the operator aggregate (fires operator SSE).
409          // The session record already exists, so any subscriber that immediately fetches operator
410          // state will find a populated ActiveCombatSession.
411          var exfilResult = await _exfilService.StartCombatSessionAsync(new OperatorId(operatorId), sessionId);
412          if (!exfilResult.IsSuccess)
413          {
414              // The session record remains in the database as an audit trail; do not delete it.
415              return exfilResult;
416          }
417  
418          return ServiceResult<Guid>.Success(sessionId);
419      }
420  
421      public async Task<ServiceResult> CompleteInfilAsync(Guid operatorId)
422      {
423          return await _exfilService.CompleteInfilSuccessfullyAsync(new OperatorId(operatorId));
424      }
425  
426      /// <summary>
427      /// Retreats from the active combat session.
428      /// Emits a <see cref="Core.Operators.CombatVictoryEvent"/> to clear <c>ActiveCombatSessionId</c>
429      /// so the operator can enter a new combat or exfil, while the session record is preserved in the
430      /// database for audit purposes.
431      /// </summary>
432      public async Task<ServiceResult> RetreatFromCombatAsync(Guid operatorId)
433      {
434          return await _exfilService.ClearDanglingCombatSessionAsync(new OperatorId(operatorId));
435      }
436  
437      public async Task<ServiceResult<OperatorStateDto>> ChangeLoadoutAsync(Guid operatorId, ChangeLoadoutRequest request)
438      {
439          var result = await _exfilService.ChangeLoadoutAsync(new OperatorId(operatorId), request.WeaponName);
440          if (result.Status != ResultStatus.Success)
441          {
442              return ServiceResult<OperatorStateDto>.FromResult(result);
443          }
444  
445          var loadResult = await _exfilService.LoadOperatorAsync(new OperatorId(operatorId));
446          if (!loadResult.IsSuccess)
447          {
448              return ServiceResult<OperatorStateDto>.FromResult(loadResult);
449          }
450  
451          return ServiceResult<OperatorStateDto>.Success(ToDto(loadResult.Value!));
452      }
453  
454      public async Task<ServiceResult<OperatorStateDto>> TreatWoundsAsync(Guid operatorId, TreatWoundsRequest request)
455      {
456          var result = await _exfilService.TreatWoundsAsync(new OperatorId(operatorId), request.HealthAmount);
457          if (result.Status != ResultStatus.Success)
458          {
459              return ServiceResult<OperatorStateDto>.FromResult(result);
460          }
461  
462          var loadResult = await _exfilService.LoadOperatorAsync(new OperatorId(operatorId));
463          if (!loadResult.IsSuccess)
464          {
465              return ServiceResult<OperatorStateDto>.FromResult(loadResult);
466          }
467  
468          return ServiceResult<OperatorStateDto>.Success(ToDto(loadResult.Value!));
469      }
470  
471      public async Task<ServiceResult<OperatorStateDto>> ApplyXpAsync(Guid operatorId, ApplyXpRequest request)
472      {
473          var result = await _exfilService.ApplyXpAsync(new OperatorId(operatorId), request.XpAmount, request.Reason);
474          if (result.Status != ResultStatus.Success)
475          {
476              return ServiceResult<OperatorStateDto>.FromResult(result);
477          }
478  
479          var loadResult = await _exfilService.LoadOperatorAsync(new OperatorId(operatorId));
480          if (!loadResult.IsSuccess)
481          {
482              return ServiceResult<OperatorStateDto>.FromResult(loadResult);
483          }
484  
485          return ServiceResult<OperatorStateDto>.Success(ToDto(loadResult.Value!));
486      }
487  
488      public async Task<ServiceResult<OperatorStateDto>> UnlockPerkAsync(Guid operatorId, UnlockPerkRequest request)
489      {
490          var result = await _exfilService.UnlockPerkAsync(new OperatorId(operatorId), request.PerkName);
491          if (result.Status != ResultStatus.Success)
492          {
493              return ServiceResult<OperatorStateDto>.FromResult(result);
494          }
495  
496          var loadResult = await _exfilService.LoadOperatorAsync(new OperatorId(operatorId));
497          if (!loadResult.IsSuccess)
498          {
499              return ServiceResult<OperatorStateDto>.FromResult(loadResult);
500          }
501  
502          return ServiceResult<OperatorStateDto>.Success(ToDto(loadResult.Value!));
503      }
504  
505      public async Task<ServiceResult<OperatorStateDto>> ApplyPetActionAsync(Guid operatorId, PetActionRequest request)
506      {
507          var result = await _exfilService.ApplyPetActionAsync(new OperatorId(operatorId), request);
508          if (result.Status != ResultStatus.Success)
509          {
510              return ServiceResult<OperatorStateDto>.FromResult(result);
511          }
512  
513          var loadResult = await _exfilService.LoadOperatorAsync(new OperatorId(operatorId));
514          if (!loadResult.IsSuccess)
515          {
516              return ServiceResult<OperatorStateDto>.FromResult(loadResult);
517          }
518  
519          return ServiceResult<OperatorStateDto>.Success(ToDto(loadResult.Value!));
520      }
521  
522      public async Task<ServiceResult> SyncOfflineMission(OfflineMissionEnvelope envelope)
523      {
524          if (!Guid.TryParse(envelope.OperatorId, out var operatorId))
525              return ServiceResult.ValidationError("Offline mission envelope has invalid operator ID");
526          if (envelope.SequenceNumber <= 0)
527              return ServiceResult.ValidationError("Offline mission envelope sequence must be positive");
528  
529          // When the engine is injected, require InitialSnapshotJson for server-authoritative replay.
530          // Without the engine, fall back to the battle-log-based approach (legacy/test path).
531          if (_combatEngine != null && string.IsNullOrEmpty(envelope.InitialSnapshotJson))
532              return ServiceResult.ValidationError("Offline mission envelope must include an initial snapshot");
533          if (_combatEngine == null && (envelope.FullBattleLog == null || envelope.FullBattleLog.Count == 0))
534              return ServiceResult.ValidationError("Offline mission envelope must include a full battle log");
535  
536          // _offlineSyncHeadStore remains optional for lightweight test construction paths.
537          var previous = _offlineSyncHeadStore == null ? null : await _offlineSyncHeadStore.GetAsync(operatorId);
538          if (previous != null)
539          {
540              if (envelope.SequenceNumber != previous.SequenceNumber + 1)
541                  return ServiceResult.InvalidState("Offline mission envelope sequence is not contiguous");
542              if (!string.Equals(envelope.InitialOperatorStateHash, previous.ResultOperatorStateHash, StringComparison.Ordinal))
543                  return ServiceResult.InvalidState("Offline mission envelope hash chain is broken");
544          }
545          else if (envelope.SequenceNumber != 1)
546          {
547              return ServiceResult.InvalidState("First offline mission envelope must begin at sequence 1");
548          }
549  
550          var currentStateResult = await GetOperatorAsync(operatorId);
551          if (!currentStateResult.IsSuccess)
552          {
553              return currentStateResult.Status switch
554              {
555                  ResultStatus.NotFound => ServiceResult.NotFound(currentStateResult.ErrorMessage),
556                  ResultStatus.InvalidState => ServiceResult.InvalidState(currentStateResult.ErrorMessage!),
557                  ResultStatus.ValidationError => ServiceResult.ValidationError(currentStateResult.ErrorMessage!),
558                  _ => ServiceResult.InvalidState(currentStateResult.ErrorMessage!)
559              };
560          }
561  
562          var currentState = currentStateResult.Value!;
563          if (previous == null)
564          {
565              // First accepted envelope for this operator is anchored to current server operator state.
566              // Later envelopes are anchored to the persisted sync head hash chain.
567              var currentHash = OfflineMissionHashing.ComputeOperatorStateHash(currentState);
568              if (!string.Equals(currentHash, envelope.InitialOperatorStateHash, StringComparison.Ordinal))
569                  return ServiceResult.InvalidState("Offline mission envelope initial state hash mismatch");
570          }
571  
572          var replayedState = ReplayOfflineMission(envelope, _combatEngine, currentState);
573          if (replayedState == null)
574              return ServiceResult.InvalidState("Offline mission envelope initial snapshot could not be deserialized");
575          var replayedHash = OfflineMissionHashing.ComputeOperatorStateHash(replayedState);
576          if (!string.Equals(replayedHash, envelope.ResultOperatorStateHash, StringComparison.Ordinal))
577              return ServiceResult.InvalidState("Offline mission envelope final state hash mismatch");
578  
579          if (_offlineSyncHeadStore != null)
580          {
581              await _offlineSyncHeadStore.UpsertAsync(new OfflineSyncHead
582              {
583                  OperatorId = operatorId,
584                  SequenceNumber = envelope.SequenceNumber,
585                  ResultOperatorStateHash = envelope.ResultOperatorStateHash
586              });
587          }
588          return ServiceResult.Success();
589      }
590  
591      private static readonly JsonSerializerOptions _replayJsonOptions = new(JsonSerializerDefaults.Web);
592  
593      /// <summary>
594      /// Replays an offline mission to produce the authoritative result operator state.
595      /// When <paramref name="engine"/> is provided, deserializes <see cref="OfflineMissionEnvelope.InitialSnapshotJson"/>
596      /// and executes it deterministically. Falls back to a log-based stub when no engine is injected
597      /// (legacy / test paths only).
598      /// </summary>
599      private static OperatorDto? ReplayOfflineMission(
600          OfflineMissionEnvelope envelope,
601          IDeterministicCombatEngine? engine,
602          OperatorStateDto? fallbackState = null)
603      {
604          if (engine != null)
605          {
606              OperatorDto? initialDto;
607              try
608              {
609                  initialDto = JsonSerializer.Deserialize<OperatorDto>(envelope.InitialSnapshotJson, _replayJsonOptions);
610              }
611              catch (JsonException)
612              {
613                  // Malformed InitialSnapshotJson — caller maps null return to InvalidState error.
614                  return null;
615              }
616  
617              if (initialDto == null)
618                  return null;
619  
620              var engineResult = engine.Execute(initialDto, envelope.RandomSeed);
621              return engineResult.ResultOperator;
622          }
623  
624          // Fallback (no engine injected): derive result from battle log and server state.
625          if (fallbackState == null)
626              return null;
627  
628          const int victoryXp = 100, survivalXp = 50;
629          var damageTaken = (envelope.FullBattleLog ?? [])
630              .Where(x => x.EventType == "Damage" && x.Message.Contains($"{fallbackState.Name} took ", StringComparison.Ordinal))
631              .Select(ParseDamageAmount)
632              .Sum();
633          var operatorDied = damageTaken >= fallbackState.CurrentHealth;
634          var enemyDamaged = (envelope.FullBattleLog ?? []).Any(x => x.EventType == "Damage" && !x.Message.Contains($"{fallbackState.Name} took ", StringComparison.Ordinal));
635          var victory = !operatorDied && enemyDamaged;
636          var xpGained = victory ? victoryXp : operatorDied ? 0 : survivalXp;
637  
638          return new OperatorDto
639          {
640              Id = fallbackState.Id.ToString(),
641              Name = fallbackState.Name,
642              TotalXp = fallbackState.TotalXp + xpGained,
643              CurrentHealth = operatorDied ? fallbackState.MaxHealth : Math.Max(1f, fallbackState.CurrentHealth - damageTaken),
644              MaxHealth = fallbackState.MaxHealth,
645              EquippedWeaponName = fallbackState.EquippedWeaponName,
646              UnlockedPerks = fallbackState.UnlockedPerks,
647              ExfilStreak = fallbackState.ExfilStreak,
648              IsDead = false,
649              CurrentMode = operatorDied ? "Base" : "Infil",
650              ActiveCombatSessionId = null,
651              InfilSessionId = operatorDied ? null : fallbackState.InfilSessionId,
652              InfilStartTime = operatorDied ? null : fallbackState.InfilStartTime,
653              LockedLoadout = fallbackState.LockedLoadout,
654              Pet = fallbackState.Pet
655          };
656      }
657  
658      private static float ParseDamageAmount(BattleLogEntryDto entry)
659      {
660          var start = entry.Message.IndexOf(" took ", StringComparison.Ordinal);
661          if (start < 0)
662              return 0f;
663          start += " took ".Length;
664          var end = entry.Message.IndexOf(" damage", start, StringComparison.Ordinal);
665          if (end <= start)
666              return 0f;
667  
668          return float.TryParse(entry.Message[start..end], NumberStyles.Float, CultureInfo.InvariantCulture, out var damage)
669              ? damage
670              : 0f;
671      }
672  
673      private static OperatorStateDto ToDto(OperatorAggregate aggregate)
674      {
675          return new OperatorStateDto
676          {
677              Id = aggregate.Id.Value,
678              Name = aggregate.Name,
679              TotalXp = aggregate.TotalXp,
680              CurrentHealth = aggregate.CurrentHealth,
681              MaxHealth = aggregate.MaxHealth,
682              EquippedWeaponName = aggregate.EquippedWeaponName,
683              UnlockedPerks = aggregate.UnlockedPerks.ToList(),
684              ExfilStreak = aggregate.ExfilStreak,
685              IsDead = aggregate.IsDead,
686              CurrentMode = aggregate.CurrentMode,
687              InfilStartTime = aggregate.InfilStartTime,
688              InfilSessionId = aggregate.InfilSessionId,
689              ActiveCombatSessionId = aggregate.ActiveCombatSessionId,
690              LockedLoadout = aggregate.LockedLoadout,
691              Pet = aggregate.PetState != null ? SessionMapping.ToDto(aggregate.PetState) : null
692          };
693      }
694  }
695  
696  public sealed class StartInfilResponse
697  {
698      public Guid SessionId { get; init; }
699      public OperatorStateDto Operator { get; init; } = null!;
700  }