ExfilSyncService.cs
1 using GUNRPG.Application.Backend; 2 using GUNRPG.Infrastructure.Persistence; 3 4 namespace GUNRPG.Infrastructure.Backend; 5 6 public sealed class ExfilSyncService : IExfilSyncService 7 { 8 private readonly OfflineStore _offlineStore; 9 private readonly OnlineGameBackend _onlineBackend; 10 11 public ExfilSyncService(OfflineStore offlineStore, OnlineGameBackend onlineBackend) 12 { 13 _offlineStore = offlineStore; 14 _onlineBackend = onlineBackend; 15 } 16 17 /// <inheritdoc /> 18 public async Task<SyncResult> SyncAsync(string operatorId, CancellationToken cancellationToken = default) 19 { 20 var pending = _offlineStore.GetUnsyncedResults(operatorId) 21 .OrderBy(x => x.SequenceNumber) 22 .ToList(); 23 24 Console.WriteLine($"[SYNC] Operator {operatorId}: {pending.Count} unsynced envelope(s) pending."); 25 26 if (pending.Count == 0) 27 { 28 return SyncResult.Ok(0); 29 } 30 31 var latestSynced = _offlineStore.GetLatestSyncedResult(operatorId); 32 OfflineMissionEnvelope? previous = latestSynced; 33 34 // When there is no previously synced envelope, validate the first envelope's 35 // InitialOperatorStateHash against the server's current operator state. 36 // This detects fabricated chains early and provides faster user feedback. 37 if (previous == null) 38 { 39 var serverDto = await _onlineBackend.GetOperatorAsync(operatorId); 40 if (serverDto != null) 41 { 42 var serverHash = OfflineMissionHashing.ComputeOperatorStateHash(serverDto); 43 var firstEnvelope = pending[0]; 44 if (!string.Equals(firstEnvelope.InitialOperatorStateHash, serverHash, StringComparison.Ordinal)) 45 { 46 var reason = $"Initial state hash mismatch for operator {operatorId}: server hash does not match first envelope's initial hash (seq={firstEnvelope.SequenceNumber})."; 47 Console.WriteLine($"[SYNC] FAIL — {reason}"); 48 return SyncResult.Fail(reason, isIntegrityFailure: true); 49 } 50 } 51 } 52 53 int synced = 0; 54 foreach (var envelope in pending) 55 { 56 if (previous != null) 57 { 58 if (envelope.SequenceNumber != previous.SequenceNumber + 1) 59 { 60 var reason = $"Sequence gap for operator {operatorId}: expected {previous.SequenceNumber + 1}, got {envelope.SequenceNumber}."; 61 Console.WriteLine($"[SYNC] FAIL — {reason}"); 62 return SyncResult.Fail(reason, isIntegrityFailure: true); 63 } 64 65 if (!string.Equals(envelope.InitialOperatorStateHash, previous.ResultOperatorStateHash, StringComparison.Ordinal)) 66 { 67 var reason = $"Hash chain mismatch for operator {operatorId} at sequence {envelope.SequenceNumber}."; 68 Console.WriteLine($"[SYNC] FAIL — {reason}"); 69 return SyncResult.Fail(reason, isIntegrityFailure: true); 70 } 71 } 72 73 Console.WriteLine($"[SYNC] Sending envelope seq={envelope.SequenceNumber} seed={envelope.RandomSeed} initialHash={envelope.InitialOperatorStateHash} resultHash={envelope.ResultOperatorStateHash}"); 74 75 var ok = await _onlineBackend.SyncOfflineMission(envelope, cancellationToken); 76 if (!ok) 77 { 78 var reason = $"Server rejected envelope seq={envelope.SequenceNumber} for operator {operatorId}."; 79 Console.WriteLine($"[SYNC] FAIL — {reason}"); 80 return SyncResult.Fail(reason); 81 } 82 83 _offlineStore.MarkResultSynced(envelope.Id); 84 previous = envelope; 85 synced++; 86 } 87 88 Console.WriteLine($"[SYNC] SUCCESS — {synced} envelope(s) synced for operator {operatorId}."); 89 return SyncResult.Ok(synced); 90 } 91 }