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 }