/ 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 }