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 }