/ GUNRPG.Infrastructure / Backend / ExfilSyncService.cs
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  }