VanguardReMaster/server/src/Vanguard.Server/Program.cs

254 lines
10 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

using System.Text;
using System.Text.Json.Serialization;
using System.Threading.RateLimiting;
using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.AspNetCore.RateLimiting;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Options;
using Microsoft.IdentityModel.Tokens;
using Orleans.Configuration;
using Orleans.Hosting;
using StackExchange.Redis;
using Vanguard.Content;
using Vanguard.Rules.Cards;
using Vanguard.Rules.Decks;
using Vanguard.Server.Api;
using Vanguard.Server.Auth;
using Vanguard.Server.Configuration;
using Vanguard.Server.Matches;
using Vanguard.Server.Matchmaking;
using Vanguard.Server.Meta;
using Vanguard.Server.Profiles;
using Vanguard.Server.Realtime;
using Vanguard.Server.Storage;
using Vanguard.Server.Tickets;
var builder = WebApplication.CreateBuilder(args);
builder.Services.Configure<GameOptions>(builder.Configuration.GetSection("Game"));
builder.Services.Configure<ContentOptions>(builder.Configuration.GetSection("Content"));
builder.Services.Configure<StorageOptions>(builder.Configuration.GetSection("Storage"));
builder.Services.Configure<RedisOptions>(builder.Configuration.GetSection("Redis"));
builder.Services.Configure<MetaOptions>(builder.Configuration.GetSection("Meta"));
builder.Services.Configure<JwtOptions>(builder.Configuration.GetSection("Jwt"));
builder.Services.Configure<OrleansOptions>(builder.Configuration.GetSection(OrleansOptions.SectionName));
var orleansOptions = builder.Configuration
.GetSection(OrleansOptions.SectionName)
.Get<OrleansOptions>() ?? new OrleansOptions();
if (orleansOptions.Enabled)
{
builder.Host.UseOrleans(silo =>
{
// Один silo на инстанс сервера. Для нескольких реплик нужен настоящий
// кластер (AdoNet/Redis clustering), но контракт грана уже готов.
silo.UseLocalhostClustering(orleansOptions.SiloPort, orleansOptions.GatewayPort);
silo.Configure<GrainCollectionOptions>(options =>
options.CollectionAge = TimeSpan.FromHours(12));
});
}
builder.Services.AddSingleton(sp => sp.GetRequiredService<IOptions<GameOptions>>().Value);
builder.Services.AddSingleton(sp => sp.GetRequiredService<IOptions<ContentOptions>>().Value);
builder.Services.AddSingleton(sp => sp.GetRequiredService<IOptions<StorageOptions>>().Value);
builder.Services.AddSingleton(sp => sp.GetRequiredService<IOptions<RedisOptions>>().Value);
builder.Services.AddSingleton(sp => sp.GetRequiredService<IOptions<MetaOptions>>().Value);
builder.Services.AddSingleton(sp => sp.GetRequiredService<IOptions<JwtOptions>>().Value);
builder.Services.AddSingleton(TimeProvider.System);
builder.Services.ConfigureHttpJsonOptions(options =>
options.SerializerOptions.Converters.Add(new JsonStringEnumConverter()));
builder.Services.AddResponseCompression(options => options.EnableForHttps = true);
var contentOptions = builder.Configuration.GetSection("Content").Get<ContentOptions>() ?? new ContentOptions();
var setsDirectory = ContentPaths.Resolve(contentOptions.Path);
var contentDirectory = Path.GetDirectoryName(setsDirectory)!;
builder.Services.AddSingleton(sp =>
{
var content = sp.GetRequiredService<ContentOptions>();
return CatalogLoader.LoadDirectory(ContentPaths.Resolve(content.Path));
});
builder.Services.AddSingleton(TrialDeckCatalog.Load(Path.Combine(contentDirectory, "trial-decks.yaml")));
builder.Services.AddSingleton(PackOdds.Load(Path.Combine(contentDirectory, "pack_odds.yaml")));
var storage = builder.Configuration.GetSection("Storage").Get<StorageOptions>() ?? new StorageOptions();
var storageProvider = storage.Provider?.Trim() ?? "Memory";
if (storageProvider.Equals("Postgres", StringComparison.OrdinalIgnoreCase))
{
if (string.IsNullOrWhiteSpace(storage.ConnectionString))
throw new InvalidOperationException("Storage:ConnectionString обязателен для Provider=Postgres.");
builder.Services.AddDbContextFactory<MatchDbContext>(options =>
options.UseNpgsql(storage.ConnectionString));
builder.Services.AddSingleton<IMatchStore, EfMatchStore>();
}
else if (storageProvider.Equals("Memory", StringComparison.OrdinalIgnoreCase))
{
builder.Services.AddDbContextFactory<MatchDbContext>(options =>
options.UseInMemoryDatabase("vanguard"));
builder.Services.AddSingleton<IMatchStore, EfMatchStore>();
}
else if (storageProvider.Equals("None", StringComparison.OrdinalIgnoreCase))
{
// мета-API всё равно работает в памяти, а матчи — без сохранения
builder.Services.AddDbContextFactory<MatchDbContext>(options =>
options.UseInMemoryDatabase("vanguard-dev"));
builder.Services.AddSingleton<IMatchStore, NullMatchStore>();
}
else
{
throw new InvalidOperationException(
$"Неизвестный Storage:Provider «{storageProvider}». Ожидается None, Memory или Postgres.");
}
var redis = builder.Configuration.GetSection("Redis").Get<RedisOptions>() ?? new RedisOptions();
if (!string.IsNullOrWhiteSpace(redis.Configuration))
{
builder.Services.AddSingleton<IConnectionMultiplexer>(_ =>
{
var configuration = ConfigurationOptions.Parse(redis.Configuration);
configuration.AbortOnConnectFail = false;
return ConnectionMultiplexer.Connect(configuration);
});
builder.Services.AddSingleton<ITicketStore, RedisTicketStore>();
builder.Services.AddSingleton<IQueueStore, RedisQueueStore>();
}
else
{
builder.Services.AddSingleton<ITicketStore, InMemoryTicketStore>();
builder.Services.AddSingleton<IQueueStore, InMemoryQueueStore>();
}
var jwt = builder.Configuration.GetSection("Jwt").Get<JwtOptions>() ?? new JwtOptions();
if (jwt.SigningKey.Length < 32)
throw new InvalidOperationException("Jwt:SigningKey должен быть не короче 32 символов.");
builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
.AddJwtBearer(options =>
{
options.TokenValidationParameters = new TokenValidationParameters
{
ValidateIssuer = true,
ValidIssuer = jwt.Issuer,
ValidateAudience = true,
ValidAudience = jwt.Audience,
ValidateIssuerSigningKey = true,
IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(jwt.SigningKey)),
ValidateLifetime = true,
ClockSkew = TimeSpan.FromSeconds(30),
NameClaimType = System.Security.Claims.ClaimTypes.Name,
};
});
builder.Services.AddAuthorization();
builder.Services.AddRateLimiter(options =>
{
options.RejectionStatusCode = StatusCodes.Status429TooManyRequests;
options.AddFixedWindowLimiter("auth", limiter =>
{
limiter.PermitLimit = 60;
limiter.Window = TimeSpan.FromMinutes(1);
limiter.QueueLimit = 0;
});
options.AddFixedWindowLimiter("mutating", limiter =>
{
limiter.PermitLimit = 300;
limiter.Window = TimeSpan.FromMinutes(1);
limiter.QueueLimit = 0;
});
options.GlobalLimiter = PartitionedRateLimiter.Create<HttpContext, string>(context =>
{
if (HttpMethods.IsGet(context.Request.Method) || HttpMethods.IsHead(context.Request.Method))
return RateLimitPartition.GetNoLimiter("read");
var key = context.Connection.RemoteIpAddress?.ToString() ?? "local";
return RateLimitPartition.GetFixedWindowLimiter(key, _ => new FixedWindowRateLimiterOptions
{
PermitLimit = 300,
Window = TimeSpan.FromMinutes(1),
QueueLimit = 0,
});
});
});
builder.Services.AddSingleton<GameTicketService>();
builder.Services.AddSingleton<IMatchConnectionRegistry, MatchConnectionRegistry>();
builder.Services.AddSingleton<MatchGrainRegistry>();
builder.Services.AddSingleton<MatchRecoveryService>();
builder.Services.AddSingleton<MatchmakingService>();
builder.Services.AddSingleton<BotMatchService>();
builder.Services.AddSingleton<EconomyService>();
builder.Services.AddSingleton<CollectionService>();
builder.Services.AddSingleton<DeckService>();
builder.Services.AddSingleton<TrialDeckService>();
builder.Services.AddSingleton<BoosterService>();
builder.Services.AddSingleton<ReplayService>();
builder.Services.AddSingleton<MatchHistoryService>();
builder.Services.AddSingleton<MetaProfileService>();
builder.Services.AddSingleton<IProfileService>(sp => sp.GetRequiredService<MetaProfileService>());
builder.Services.AddSingleton<IMatchResultSink>(sp => sp.GetRequiredService<MetaProfileService>());
builder.Services.AddSingleton<TokenService>();
builder.Services.AddSingleton<AuthService>();
builder.Services.AddHostedService<MatchTicker>();
builder.Services.AddHostedService<MatchmakingWorker>();
var app = builder.Build();
var catalog = app.Services.GetRequiredService<CardCatalog>();
foreach (var deck in app.Services.GetRequiredService<TrialDeckCatalog>().Decks)
{
var violations = DeckValidator.Validate(catalog, deck.ToDeck());
if (violations.Count > 0)
{
throw new InvalidOperationException(
$"Триал-дека {deck.Id}: " + string.Join("; ", violations.Select(item => item.Message)));
}
}
var dbFactory = app.Services.GetRequiredService<IDbContextFactory<MatchDbContext>>();
await using (var db = await dbFactory.CreateDbContextAsync())
{
if (db.Database.IsRelational())
await db.Database.MigrateAsync();
else
await db.Database.EnsureCreatedAsync();
}
// незавершённые матчи поднимаем до того, как сервер начнёт принимать подключения
await app.Services.GetRequiredService<MatchRecoveryService>().RecoverAsync();
await app.Services.GetRequiredService<MatchmakingService>().LoadQueuedAsync();
app.UseResponseCompression();
app.UseRateLimiter();
app.UseAuthentication();
app.UseAuthorization();
app.UseWebSockets(new WebSocketOptions
{
KeepAliveInterval = TimeSpan.FromSeconds(30),
});
app.MapAuthEndpoints();
app.MapMetaEndpoints();
app.MapGameApi();
app.Map("/game", GameWebSocketEndpoint.HandleAsync);
app.Run();
/// <summary>Точка входа доступна интеграционным тестам через WebApplicationFactory.</summary>
public partial class Program
{
}