using System.Text.Json; using System.Text.Json.Serialization; using Microsoft.EntityFrameworkCore; using SVSim.Database; using SVSim.Database.Repositories.BuildDeck; using SVSim.Database.Repositories.Card; using SVSim.Database.Repositories.Collectibles; using SVSim.Database.Repositories.Deck; using SVSim.Database.Repositories.Globals; using SVSim.Database.Repositories.Pack; using SVSim.Database.Repositories.Story; using SVSim.Database.Repositories.Viewer; using SVSim.Database.Services; using SVSim.Database.Services.Friend; using SVSim.Database.Services.Replay; using SVSim.EmulatedEntrypoint.Configuration; using SVSim.EmulatedEntrypoint.Extensions; using SVSim.EmulatedEntrypoint.Matching; using SVSim.EmulatedEntrypoint.Middlewares; using SVSim.EmulatedEntrypoint.Security.SteamSessionAuthentication; using SVSim.EmulatedEntrypoint.Services; using SVSim.BattleNode.Hosting; namespace SVSim.EmulatedEntrypoint; public class Program { public static void Main(string[] args) { var builder = WebApplication.CreateBuilder(args); // Add services to the container. builder.Services.AddControllers().AddJsonOptions(opt => { // Wire-format congruence: the encrypted msgpack path uses snake_case [Key("...")] // names; the plain-JSON path runs through System.Text.Json. Match them by using // SnakeCaseLower naming policy here so both paths emit identical key names — and // so the translation middleware can hand JSON keys straight through to msgpack // without per-property name remapping. opt.JsonSerializerOptions.PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower; // Production omits null/optional fields entirely; the client uses // `Keys.Contains(name)` as a presence check and calls `.ToInt()` (etc.) on the // value without a null guard. Emitting `"key":null` makes Contains return true and // crashes the client. Drop nulls during serialization so missing == absent. opt.JsonSerializerOptions.DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull; // Format-typed properties serialize to/from the wire deck_format int via the // client's FormatConvertApi mapping. See FormatExtensions.cs. opt.JsonSerializerOptions.Converters.Add(new FormatJsonConverter()); }); // Learn more about configuring Swagger/OpenAPI at https://aka.ms/aspnetcore/swashbuckle builder.Services.AddEndpointsApiExplorer(); builder.Services.AddSwaggerGen(c => { // Disambiguate same-named DTOs across families (e.g. Story.StartRequest vs // BasicPuzzle.StartRequest) by qualifying schema ids with the full type name. c.CustomSchemaIds(t => t.FullName?.Replace("+", ".")); }); builder.Services.AddHttpLogging(opt => { }); builder.Services.Configure(builder.Configuration.GetSection(DeckOptions.SectionName)); #region Database Services builder.Services.AddDbContext(opt => { opt.UseNpgsql(builder.Configuration.GetConnectionString("ApplicationDb")); }); builder.Services.AddTransient(); builder.Services.AddTransient(); builder.Services.AddTransient(); builder.Services.AddTransient(); builder.Services.AddTransient(); builder.Services.AddTransient(); builder.Services.AddTransient(); builder.Services.AddTransient(); builder.Services.AddTransient(); builder.Services.AddTransient(); builder.Services.AddScoped(); builder.Services.AddTransient(); // Scoped (not Singleton) to avoid the singleton-depends-on-scoped-DbContext lifecycle // pitfall. Cost: one indexed single-row query per section per request — trivial. No // in-process cache today; the IGameConfigService interface is shaped to allow one later. builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddSingleton(TimeProvider.System); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddSingleton(); builder.Services.AddScoped(); builder.Services.AddScoped(); // Deck-code mint/resolve uses IMemoryCache for ephemeral (3-min TTL) storage; no DB // row, no migration. Singleton because the cache + RNG seam are process-wide. builder.Services.AddMemoryCache(); builder.Services.AddSingleton(); // Per-process per-viewer tracker for home_dialog_list suppression on /mypage/index. // Restart re-fires once per viewer — documented trade in the design spec. builder.Services.AddSingleton(); #endregion builder.Services.AddBattleNode(opt => { // Matches the prod do_matching wire format: host:port/socket.io/, no scheme prefix. // BestHTTP's SocketManager parses this as the Socket.IO v2 endpoint URL. opt.NodeServerUrl = "localhost:5148/socket.io/"; // Any field in BattleNodeOptions can be overridden via the "BattleNode" section // in appsettings*.json — see appsettings.Development.json for DiagnosticLogging. builder.Configuration.GetSection("BattleNode").Bind(opt); }); // In-process FCFS pair-up for TK2 PvP /do_matching, plus rank-battle's AI-fallback // branch. Singleton: per-mode state is process-wide. Proper queue API is a separate // spec; this is enough to actually pair two viewers polling the same mode end-to-end. builder.Services.AddSingleton(new ModePolicyRegistry(new[] { new ModePolicy("arena_two_pick_battle", PolicyKind.PvpOnly), new ModePolicy("rotation_rank_battle", PolicyKind.PvpFirstThenAiFallback), new ModePolicy("unlimited_rank_battle", PolicyKind.PvpFirstThenAiFallback), })); builder.Services.AddSingleton(); // Single resolver shared by every /do_matching family controller. Owns the // pair-up → matching_state mapping. Singleton: stateless, all deps are singletons too. builder.Services.AddSingleton(); // Phase 3: bot roster used by RankBattleController.AiStart to compose oppo_info. // Transient because BotRoster depends on the transient IGlobalsRepository. builder.Services.AddTransient(); builder.Services.AddTransient(); builder.Services.AddTransient(); builder.Services.AddSingleton(); // Steam ticket validation seam. Production uses Facepunch against real Steam. Local dev // can opt into a no-op validator via Auth:BypassSteamTicket so clients without a real // Steam session (e.g. a second same-machine instance for the two-client PvP smoke) can // authenticate. Gate is config-only and ships false everywhere except Development. if (builder.Configuration.GetValue("Auth:BypassSteamTicket")) { builder.Services.AddSingleton(); } else { builder.Services.AddSingleton(); } builder.Services.AddSingleton(); builder.Services.AddAuthentication() .AddScheme( SteamAuthenticationConstants.SchemeName, opt => { }); var app = builder.Build(); // Update database (skipped for non-relational providers, e.g. InMemory in tests, and // skipped under the "Testing" environment where the test fixture has already called // EnsureCreated against a SQLite in-memory DB — the Postgres migrations would fail there). using (var scope = app.Services.CreateScope()) { var dbContext = scope.ServiceProvider.GetRequiredService(); if (dbContext.Database.IsRelational() && !app.Environment.IsEnvironment("Testing")) { dbContext.UpdateDatabase(); dbContext.EnsureSeedDataAsync().GetAwaiter().GetResult(); } } // HttpLogging captures full request/response per call. In Testing it pipes ~3 GB of // stdout into NUnit's per-test result capture across the suite, which OOMs the trx // serializer. Production keeps it on. if (!app.Environment.IsEnvironment("Testing")) { app.UseHttpLogging(); } // Configure the HTTP request pipeline. if (app.Environment.IsDevelopment()) { app.UseSwagger(); app.UseSwaggerUI(); } //app.UseHttpsRedirection(); app.UseMiddleware(); app.UseMiddleware(); app.UseAuthentication(); app.UseAuthorization(); app.UseBattleNode(); app.MapControllers(); app.Run(); } }