254 lines
10 KiB
C#
254 lines
10 KiB
C#
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
|
||
{
|
||
}
|