/ GUNRPG.Infrastructure / InfrastructureServiceExtensions.cs
InfrastructureServiceExtensions.cs
  1  using System.Text.Json;
  2  using GUNRPG.Application.Backend;
  3  using GUNRPG.Application.Distributed;
  4  using GUNRPG.Application.Operators;
  5  using GUNRPG.Application.Sessions;
  6  using GUNRPG.Infrastructure.Backend;
  7  using GUNRPG.Infrastructure.Distributed;
  8  using GUNRPG.Infrastructure.Persistence;
  9  using LiteDB;
 10  using Microsoft.Extensions.Configuration;
 11  using Microsoft.Extensions.DependencyInjection;
 12  using Microsoft.Extensions.Hosting;
 13  using Microsoft.Extensions.Logging;
 14  using Microsoft.Extensions.Options;
 15  using Nethermind.Libp2p;
 16  using Nethermind.Libp2p.Core;
 17  using Nethermind.Libp2p.Core.Discovery;
 18  using Nethermind.Libp2p.Protocols;
 19  
 20  namespace GUNRPG.Infrastructure;
 21  
 22  /// <summary>
 23  /// Extension methods for registering infrastructure services with dependency injection.
 24  /// </summary>
 25  public static class InfrastructureServiceExtensions
 26  {
 27      /// <summary>
 28      /// Registers the combat session store based on configuration.
 29      /// Defaults to LiteDB. Can be overridden to InMemory via configuration.
 30      /// </summary>
 31      public static IServiceCollection AddCombatSessionStore(
 32          this IServiceCollection services,
 33          IConfiguration configuration)
 34      {
 35          // Bind storage configuration for IOptions<StorageOptions>
 36          services.Configure<StorageOptions>(
 37              configuration.GetSection(StorageOptions.SectionName));
 38  
 39          // Read provider directly from configuration to decide which store to register
 40          var provider = configuration
 41              .GetSection(StorageOptions.SectionName)
 42              .GetValue<string>(nameof(StorageOptions.Provider));
 43  
 44          if (string.Equals(provider, "InMemory", StringComparison.OrdinalIgnoreCase))
 45          {
 46              // In-memory store for testing
 47              services.AddSingleton<ICombatSessionStore, InMemoryCombatSessionStore>();
 48              // Register a no-op operator event store so CombatSessionService can be resolved
 49              // Validation will be skipped when the store is present but returns no events
 50              services.AddSingleton<IOperatorEventStore, InMemoryOperatorEventStore>();
 51              services.AddSingleton<IOfflineSyncHeadStore, InMemoryOfflineSyncHeadStore>();
 52          }
 53          else
 54          {
 55              // LiteDB store (default)
 56              services.AddSingleton<LiteDatabase>(sp =>
 57              {
 58                  var options = sp.GetRequiredService<IOptions<StorageOptions>>().Value;
 59                  
 60                  // Create custom BsonMapper to avoid global state issues
 61                  var mapper = new BsonMapper();
 62                  ConfigureLiteDbMapper(mapper);
 63  
 64                  var (connectionString, databasePath) = NormalizeLiteDbConnectionString(options.LiteDbConnectionString);
 65  
 66                  EnsureLiteDbDirectory(databasePath);
 67                  
 68                  var database = new LiteDatabase(connectionString, mapper);
 69                  
 70                  // Check current schema version before applying migrations
 71                  var currentVersion = LiteDbMigrations.GetDatabaseSchemaVersion(database);
 72                  if (currentVersion < LiteDbMigrations.CurrentSchemaVersion)
 73                  {
 74                      // Apply any pending migrations and update schema version
 75                      LiteDbMigrations.ApplyMigrations(database);
 76                      LiteDbMigrations.SetDatabaseSchemaVersion(database, LiteDbMigrations.CurrentSchemaVersion);
 77                  }
 78                  
 79                  // Register disposal on application shutdown if available
 80                  // LiteDatabase implements IDisposable, so it will be disposed by DI container
 81                  // This registration ensures it happens during graceful shutdown
 82                  var lifetime = sp.GetService<IHostApplicationLifetime>();
 83                  if (lifetime != null)
 84                  {
 85                      lifetime.ApplicationStopping.Register(() =>
 86                      {
 87                          try
 88                          {
 89                              database.Dispose();
 90                          }
 91                          catch
 92                          {
 93                              // Ignore disposal errors during shutdown
 94                          }
 95                      });
 96                  }
 97                  
 98                  return database;
 99              });
100              services.AddSingleton<ILiteDatabase>(sp => sp.GetRequiredService<LiteDatabase>());
101  
102              services.AddSingleton<ICombatSessionStore, LiteDbCombatSessionStore>();
103              services.AddSingleton<IOperatorEventStore, LiteDbOperatorEventStore>();
104              services.AddSingleton<IOfflineSyncHeadStore, LiteDbOfflineSyncHeadStore>();
105              services.AddSingleton<OperatorExfilService>();
106          }
107  
108          return services;
109      }
110  
111      /// <summary>
112      /// Registers offline persistence and backend resolver services used by the game backend.
113      /// </summary>
114      /// <remarks>
115      /// This method registers <see cref="OfflineStore"/> and <see cref="GameBackendResolver"/> for
116      /// client-side DI container usage. It does not register <see cref="IGameBackend"/>,
117      /// concrete backend implementations such as <see cref="OnlineGameBackend"/>, or an
118      /// <see cref="HttpClient"/>; those must be configured by the hosting application.
119      /// <para>
120      /// Note: This should only be used in client contexts (e.g., console client) where a separate
121      /// offline LiteDB file is configured. Do not use in the API host — it would mix offline
122      /// snapshots into the server's persistence store.
123      /// </para>
124      /// </remarks>
125      public static IServiceCollection AddGameBackend(this IServiceCollection services, string offlineDbPath)
126      {
127          var directory = Path.GetDirectoryName(offlineDbPath);
128          if (!string.IsNullOrEmpty(directory))
129          {
130              Directory.CreateDirectory(directory);
131          }
132  
133          services.AddSingleton<LiteDatabase>(sp => new LiteDatabase(offlineDbPath));
134          services.AddSingleton<OfflineStore>(sp => new OfflineStore(sp.GetRequiredService<LiteDatabase>()));
135          services.AddSingleton<GameBackendResolver>();
136  
137          return services;
138      }
139  
140      /// <summary>
141      /// Creates the offline services needed by the console client.
142      /// Centralizes construction of OfflineStore, GameBackendResolver, and IGameBackend
143      /// to avoid scattered new() instantiation in UI code.
144      /// </summary>
145      /// <param name="httpClient">HTTP client for API communication.</param>
146      /// <param name="offlineDbPath">Path to the offline LiteDB file.</param>
147      /// <param name="jsonOptions">JSON serializer options.</param>
148      /// <returns>Tuple of (offlineDb, offlineStore, backendResolver).</returns>
149      public static (LiteDatabase offlineDb, OfflineStore offlineStore, GameBackendResolver backendResolver) CreateConsoleServices(
150          HttpClient httpClient,
151          string offlineDbPath,
152          JsonSerializerOptions? jsonOptions = null)
153      {
154          var directory = Path.GetDirectoryName(offlineDbPath);
155          if (!string.IsNullOrEmpty(directory))
156          {
157              Directory.CreateDirectory(directory);
158          }
159          var offlineDb = new LiteDatabase(offlineDbPath);
160          var offlineStore = new OfflineStore(offlineDb);
161          var resolver = new GameBackendResolver(httpClient, offlineStore, jsonOptions);
162          return (offlineDb, offlineStore, resolver);
163      }
164  
165      /// <summary>
166      /// Configures LiteDB mapper for snapshot types.
167      /// Ensures proper serialization of enums and nested objects.
168      /// </summary>
169      private static void ConfigureLiteDbMapper(BsonMapper mapper)
170      {
171          // LiteDB handles most types automatically, including:
172          // - Primitives (int, float, long, bool, etc.)
173          // - Strings
174          // - DateTime/DateTimeOffset
175          // - Guid
176          // - Enums (serialized as strings by default)
177          // - Nested objects (auto-mapped)
178          // - Nullable types
179          
180          // Explicitly configure enum serialization to use strings for readability
181          mapper.EnumAsInteger = false;
182          
183          // Use Id property as the document key
184          mapper.Entity<CombatSessionSnapshot>()
185              .Id(x => x.Id);
186      }
187  
188      /// <summary>
189      /// Ensures the directory for the LiteDB file exists.
190      /// </summary>
191      private static void EnsureLiteDbDirectory(string databasePath)
192      {
193          if (string.IsNullOrWhiteSpace(databasePath))
194              return;
195  
196          var directory = Path.GetDirectoryName(databasePath);
197          if (!string.IsNullOrEmpty(directory))
198          {
199              Directory.CreateDirectory(directory);
200          }
201      }
202  
203      /// <summary>
204      /// Normalizes LiteDB connection strings by expanding home directories and extracting the database path.
205      /// </summary>
206      private static (string connectionString, string databasePath) NormalizeLiteDbConnectionString(string connectionString)
207      {
208          if (string.IsNullOrWhiteSpace(connectionString))
209              return (connectionString, connectionString);
210  
211          var segments = connectionString.Split(';', StringSplitOptions.RemoveEmptyEntries);
212          for (var i = 0; i < segments.Length; i++)
213          {
214              var segment = segments[i];
215              if (segment.TrimStart().StartsWith("filename", StringComparison.OrdinalIgnoreCase))
216              {
217                  var parts = segment.Split('=', 2);
218                  if (parts.Length == 2 && !string.IsNullOrWhiteSpace(parts[1]))
219                  {
220                      var expandedPath = PathHelpers.ExpandHomePath(parts[1].Trim());
221                      segments[i] = $"{parts[0]}={expandedPath}";
222                      return (string.Join(';', segments), expandedPath);
223                  }
224              }
225          }
226  
227          var expanded = PathHelpers.ExpandHomePath(connectionString);
228          return (expanded, expanded);
229      }
230  
231      /// <summary>
232      /// Registers the libp2p peer service for server-to-server operator event replication.
233      /// <para>
234      /// Starts a libp2p peer that listens for connections from other GUNRPG servers and uses
235      /// mDNS to discover peers on the local network. When peers connect, the
236      /// <see cref="Libp2pLockstepTransport"/> handles the session and fires
237      /// <c>OnPeerConnected</c>, which causes the <c>OperatorEventReplicator</c> to synchronise
238      /// operator events so that operators created on any server are visible from all others.
239      /// </para>
240      /// </summary>
241      /// <param name="services">The service collection to register into.</param>
242      /// <param name="transport">The already-registered lockstep transport singleton.</param>
243      /// <param name="nodeId">The server's persistent node ID (used to derive a stable libp2p identity).</param>
244      public static IServiceCollection AddLibp2pPeer(
245          this IServiceCollection services,
246          Libp2pLockstepTransport transport,
247          Guid nodeId)
248      {
249          // Register libp2p infrastructure with our custom lockstep protocol.
250          services.AddLibp2p(b => b.AddProtocol(transport, true));
251  
252          // Register the hosted service that manages the peer lifecycle.
253          // OperatorEventReplicator is resolved here so DI constructs it (subscribing it to
254          // OnPeerConnected) before StartAsync runs and mDNS can discover the first peer.
255          services.AddSingleton(sp => new LibP2pPeerService(
256              nodeId,
257              sp.GetRequiredService<IPeerFactory>(),
258              sp.GetRequiredService<PeerStore>(),
259              sp.GetRequiredService<MDnsDiscoveryProtocol>(),
260              sp.GetRequiredService<ILogger<LibP2pPeerService>>(),
261              sp.GetRequiredService<OperatorEventReplicator>()));
262          services.AddSingleton<IHostedService>(sp => sp.GetRequiredService<LibP2pPeerService>());
263  
264          return services;
265      }
266  }