/ GUNRPG.Infrastructure / Persistence / OfflineStore.cs
OfflineStore.cs
  1  using GUNRPG.Application.Backend;
  2  using LiteDB;
  3  using JsonSerializer = System.Text.Json.JsonSerializer;
  4  
  5  namespace GUNRPG.Infrastructure.Persistence;
  6  
  7  /// <summary>
  8  /// Manages local LiteDB storage for offline mode.
  9  /// Handles infiled operator snapshots and offline mission results.
 10  /// </summary>
 11  public sealed class OfflineStore
 12  {
 13      private readonly ILiteCollection<InfiledOperator> _operators;
 14      private readonly ILiteCollection<OfflineMissionEnvelope> _missionResults;
 15  
 16      public OfflineStore(LiteDatabase database)
 17      {
 18          _operators = database.GetCollection<InfiledOperator>("infiled_operators");
 19          _operators.EnsureIndex(x => x.Id);
 20          _operators.EnsureIndex(x => x.IsActive);
 21  
 22          _missionResults = database.GetCollection<OfflineMissionEnvelope>("offline_mission_results");
 23          _missionResults.EnsureIndex(x => x.OperatorId);
 24          _missionResults.EnsureIndex(x => x.Synced);
 25          _missionResults.EnsureIndex(x => x.SequenceNumber);
 26          _missionResults.EnsureIndex("idx_op_seq", x => new { x.OperatorId, x.SequenceNumber }, true);
 27      }
 28  
 29      /// <summary>
 30      /// Saves an infiled operator snapshot to local storage.
 31      /// Deactivates any previously active infiled operator.
 32      /// </summary>
 33      public void SaveInfiledOperator(OperatorDto operatorDto)
 34      {
 35          // Deactivate any previously active operators
 36          var activeOps = _operators.Find(x => x.IsActive).ToList();
 37          foreach (var op in activeOps)
 38          {
 39              op.IsActive = false;
 40              _operators.Update(op);
 41          }
 42  
 43          var snapshot = new InfiledOperator
 44          {
 45              Id = operatorDto.Id,
 46              SnapshotJson = JsonSerializer.Serialize(operatorDto),
 47              InfiledUtc = DateTime.UtcNow,
 48              IsActive = true
 49          };
 50  
 51          _operators.Upsert(snapshot);
 52      }
 53  
 54      /// <summary>
 55      /// Gets the currently active infiled operator, or null if none exists.
 56      /// </summary>
 57      public InfiledOperator? GetActiveInfiledOperator()
 58      {
 59          return _operators.FindOne(x => x.IsActive);
 60      }
 61  
 62      /// <summary>
 63      /// Checks whether an active infiled operator exists.
 64      /// </summary>
 65      public bool HasActiveInfiledOperator()
 66      {
 67          return _operators.Exists(x => x.IsActive);
 68      }
 69  
 70      /// <summary>
 71      /// Gets the infiled operator by ID, or null if not found.
 72      /// </summary>
 73      public InfiledOperator? GetInfiledOperator(string id)
 74      {
 75          return _operators.FindById(id);
 76      }
 77  
 78      /// <summary>
 79      /// Updates the snapshot JSON of an infiled operator (e.g., after offline mission).
 80      /// </summary>
 81      public void UpdateOperatorSnapshot(string operatorId, OperatorDto updatedDto)
 82      {
 83          var existing = _operators.FindById(operatorId);
 84          if (existing != null)
 85          {
 86              existing.SnapshotJson = JsonSerializer.Serialize(updatedDto);
 87              _operators.Update(existing);
 88          }
 89      }
 90  
 91      /// <summary>
 92      /// Deactivates the infiled operator and removes the snapshot (exfil complete).
 93      /// </summary>
 94      public void RemoveInfiledOperator(string operatorId)
 95      {
 96          _operators.Delete(operatorId);
 97      }
 98  
 99      /// <summary>
100      /// Saves an offline mission result.
101      /// </summary>
102      public void SaveMissionResult(OfflineMissionEnvelope result)
103      {
104          var previous = _missionResults
105              .Find(x => x.OperatorId == result.OperatorId)
106              .OrderByDescending(x => x.SequenceNumber)
107              .FirstOrDefault();
108  
109          var expectedSequence = previous == null ? 1 : previous.SequenceNumber + 1;
110          if (result.SequenceNumber != expectedSequence)
111          {
112              throw new InvalidOperationException(
113                  $"Offline mission sequence mismatch for operator {result.OperatorId}. Expected {expectedSequence}, got {result.SequenceNumber}.");
114          }
115  
116          if (previous != null &&
117              !string.Equals(result.InitialOperatorStateHash, previous.ResultOperatorStateHash, StringComparison.Ordinal))
118          {
119              throw new InvalidOperationException(
120                  $"Offline mission hash chain mismatch for operator {result.OperatorId} at sequence {result.SequenceNumber}.");
121          }
122  
123          _missionResults.Insert(result);
124      }
125  
126      /// <summary>
127      /// Gets all unsynced offline mission results for a given operator.
128      /// </summary>
129      public List<OfflineMissionEnvelope> GetUnsyncedResults(string operatorId)
130      {
131          return _missionResults
132              .Find(x => x.OperatorId == operatorId && !x.Synced)
133              .OrderBy(x => x.SequenceNumber)
134              .ToList();
135      }
136  
137      public OfflineMissionEnvelope? GetLatestSyncedResult(string operatorId)
138      {
139          return _missionResults
140              .Find(x => x.OperatorId == operatorId && x.Synced)
141              .OrderByDescending(x => x.SequenceNumber)
142              .FirstOrDefault();
143      }
144  
145      /// <summary>
146      /// Marks a mission result as synced.
147      /// </summary>
148      public void MarkResultSynced(string resultId)
149      {
150          var result = _missionResults.FindById(resultId);
151          if (result != null)
152          {
153              result.Synced = true;
154              _missionResults.Update(result);
155          }
156      }
157  
158      /// <summary>
159      /// Gets all unsynced results across all operators.
160      /// TODO: Future ExfilSyncService will call this to retrieve all pending results for server reconciliation.
161      /// </summary>
162      public List<OfflineMissionEnvelope> GetAllUnsyncedResults()
163      {
164          return _missionResults
165              .Find(x => !x.Synced)
166              .OrderBy(x => x.SequenceNumber)
167              .ToList();
168      }
169  
170      /// <summary>
171      /// Gets the next sequence number for an operator's offline mission envelope.
172      /// </summary>
173      public long GetNextMissionSequence(string operatorId)
174      {
175          var latest = _missionResults
176              .Find(x => x.OperatorId == operatorId)
177              .OrderByDescending(x => x.SequenceNumber)
178              .FirstOrDefault();
179          return latest == null ? 1 : latest.SequenceNumber + 1;
180      }
181  }