Radicade Public Code

This commit is contained in:
JogmansonReal
2026-10-16 23:33:13 -07:00
commit 55175acdf6
832 changed files with 363261 additions and 0 deletions
+1554
View File
@@ -0,0 +1,1554 @@
using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.AspNetCore.DataProtection;
using Microsoft.AspNetCore.Http.Features;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Diagnostics;
using Microsoft.IdentityModel.Tokens;
using SixLabors.ImageSharp.Web.DependencyInjection;
using System.Diagnostics;
using System.Net;
using System.Net.Http;
using System.Security.Cryptography;
using System.Security.Cryptography.X509Certificates;
using System.Text;
using System.Text.Json;
using RadicadeServer;
using RadicadeServer.API.Notifications;
using RadicadeServer.API.Relationships;
using RadicadeServer.Admin;
using RadicadeServer.Auth;
using RadicadeServer.DB;
using RadicadeServer.Extensions;
using RadicadeServer.RecNet.Accounts.Local;
using RadicadeServer.RecNet.Config;
using RadicadeServer.RecNet.Rooms;
using RadicadeServer.RecNet.Storefront;
using RadicadeServer.RecNet.Enums;
using RadicadeServer.RecNet.Enums.Rooms;
using RadicadeServer.Radicade.Discord;
using RadicadeServer.Discord.Webhooks;
using RadicadeServer.Utils;
const string SplitTestHardOverrides = "";
const string LocalHttpsCertPassword = "radicade-local-dev-cert";
var builder = WebApplication.CreateBuilder(args);
DBConfig.LoadConfiguration(builder.Configuration);
WebhookConfig.Load(builder.Configuration, builder.Environment.ContentRootPath);
builder.Logging.ClearProviders();
builder.Logging.AddSimpleConsole(options =>
{
options.SingleLine = true;
options.TimestampFormat = "yyyy-MM-dd HH:mm:ss ";
});
builder.Logging.AddFilter("Microsoft.EntityFrameworkCore.Database.Command", LogLevel.Warning);
builder.Logging.AddFilter("Microsoft.EntityFrameworkCore.Infrastructure", LogLevel.Warning);
builder.Logging.AddFilter("Microsoft.EntityFrameworkCore.Query", LogLevel.Error);
var listenUrls = builder.Configuration["Radicade:ListenUrls"];
if (!string.IsNullOrWhiteSpace(listenUrls))
{
builder.WebHost.UseUrls(listenUrls.Split(';', StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries));
}
else if (builder.Environment.IsProduction())
{
builder.WebHost.UseUrls("http://0.0.0.0:3030");
}
else
{
builder.WebHost.UseUrls("https://localhost:3030");
}
if (builder.Environment.IsProduction())
{
var missingSecrets = new List<string>();
if (string.IsNullOrWhiteSpace(DBConfig.AdminApiKey)) missingSecrets.Add("Radicade:AdminApiKey");
if (string.IsNullOrWhiteSpace(DBConfig.JwtKey)) missingSecrets.Add("Radicade:JwtKey");
if (string.IsNullOrWhiteSpace(DBConfig.SteamApiKey)) missingSecrets.Add("Radicade:SteamApiKey");
if (missingSecrets.Count > 0)
{
throw new InvalidOperationException(
"Missing required production secrets: " + string.Join(", ", missingSecrets) +
". Set these as environment variables (e.g. Radicade__SteamApiKey) or in a secrets manager " +
"— never commit real values into appsettings.Production.json.");
}
}
else
{
if (string.IsNullOrWhiteSpace(DBConfig.JwtKey))
{
DBConfig.JwtKey = "radicade-local-development-jwt-key-change-before-production";
}
if (string.IsNullOrWhiteSpace(DBConfig.AdminApiKey))
{
DBConfig.AdminApiKey = "radicade-local-development-admin-key";
}
if (string.IsNullOrWhiteSpace(DBConfig.SteamApiKey) &&
string.IsNullOrWhiteSpace(builder.Configuration["Radicade:TrustUnverifiedSteamIdentity"]))
{
DBConfig.TrustUnverifiedSteamIdentity = true;
}
}
if (!Directory.Exists(DBConfig.CDNImages))
Directory.CreateDirectory(DBConfig.CDNImages);
if (!Directory.Exists(DBConfig.CDNStorage))
Directory.CreateDirectory(DBConfig.CDNStorage);
if (!Directory.Exists(DBConfig.CDNVideos))
Directory.CreateDirectory(DBConfig.CDNVideos);
if (!Directory.Exists(DBConfig.CDNCustom))
Directory.CreateDirectory(DBConfig.CDNCustom);
if (!Directory.Exists(DBConfig.PersistentLogs))
Directory.CreateDirectory(DBConfig.PersistentLogs);
var dataProtectionKeys = Path.Join(DBConfig.PublicData, "Persistent", "DataProtection-Keys");
if (!Directory.Exists(dataProtectionKeys))
Directory.CreateDirectory(dataProtectionKeys);
var localHttpsCertPath = Path.Join(DBConfig.PublicData, "Persistent", "localhost-rsa-aes-devcert.pfx");
if (builder.Environment.IsDevelopment())
{
EnsureLocalHttpsCertificate(localHttpsCertPath, LocalHttpsCertPassword);
var localHttpsCertificate = LoadLocalHttpsCertificateFromStore(
InstallLocalHttpsCertificate(localHttpsCertPath, LocalHttpsCertPassword));
builder.WebHost.ConfigureKestrel(options =>
{
options.ConfigureHttpsDefaults(httpsOptions =>
{
httpsOptions.ServerCertificate = localHttpsCertificate;
httpsOptions.SslProtocols = System.Security.Authentication.SslProtocols.Tls12;
});
});
}
DBConfig.DebugKey = Guid.NewGuid().AltGuid() + Guid.NewGuid().AltGuid();
File.WriteAllText(Path.Join(DBConfig.PersistentLogs, "debugkey.txt"), DBConfig.DebugKey);
DBConfig.LoadFiles();
SteamWhitelist.Load();
var connectionString = builder.Configuration.GetConnectionString("DefaultConnection");
if (string.IsNullOrWhiteSpace(connectionString))
throw new InvalidOperationException("Missing ConnectionStrings:DefaultConnection. Set ConnectionStrings__DefaultConnection on the VPS or add it to local appsettings.Development.json.");
builder.Services.AddDbContextFactory<RecDBContext>(
options => options.UseSqlServer(connectionString, sql => { sql.EnableRetryOnFailure(3); })
.ConfigureWarnings(w => w.Ignore(RelationalEventId.PendingModelChangesWarning))
);
var key = Encoding.ASCII.GetBytes(DBConfig.JwtKey);
builder.Services.AddAuthentication(options =>
{
options.DefaultAuthenticateScheme = JwtBearerDefaults.AuthenticationScheme;
options.DefaultChallengeScheme = JwtBearerDefaults.AuthenticationScheme;
})
.AddJwtBearer(options =>
{
options.RequireHttpsMetadata = false;
options.SaveToken = true;
options.TokenValidationParameters = new TokenValidationParameters
{
ValidateIssuerSigningKey = true,
IssuerSigningKey = new SymmetricSecurityKey(key),
ValidateIssuer = false,
ValidateAudience = false,
};
options.Events = new JwtBearerEvents
{
OnMessageReceived = context =>
{
var accessToken = context.Request.Query["access_token"];
var path = context.HttpContext.Request.Path;
if (!string.IsNullOrWhiteSpace(accessToken) &&
RadicadeServer.API.Notifications.NotifyHubRoutes.IsHubPath(path.Value))
{
context.Token = accessToken;
}
return Task.CompletedTask;
},
OnTokenValidated = async context =>
{
var principal = context.Principal;
if (principal == null) return;
var userIdClaim = principal.FindFirst("userId");
var versionClaim = principal.FindFirst("tver");
if (userIdClaim == null || versionClaim == null) return;
if (long.TryParse(userIdClaim.Value, out var userId) &&
int.TryParse(versionClaim.Value, out var tokenVersion) &&
!await TokenRevocation.IsValidAsync(context.HttpContext.RequestServices, userId, tokenVersion))
{
context.Fail("Session revoked. Please sign in again.");
}
},
OnAuthenticationFailed = context =>
{
return Task.CompletedTask;
},
OnChallenge = context =>
{
return Task.CompletedTask;
}
};
});
builder.Services.AddImageSharp(options =>
{
options.OnParseCommandsAsync = context =>
{
var commands = context.Commands;
if (commands.TryGetValue("cropSquare", out var cropValue) &&
cropValue == "1" &&
commands.TryGetValue("width", out var widthValue) &&
int.TryParse(widthValue, out int width))
{
commands["height"] = width.ToString();
commands["rmode"] = "crop";
}
return Task.CompletedTask;
};
});
builder.Services.AddDataProtection()
.PersistKeysToFileSystem(new DirectoryInfo(dataProtectionKeys));
builder.Services.AddControllers().AddJsonOptions(options =>
{
options.JsonSerializerOptions.PropertyNamingPolicy = null;
options.JsonSerializerOptions.WriteIndented = true;
});
builder.Services.Configure<FormOptions>(options =>
{
options.MultipartBodyLengthLimit = 256L * 1024 * 1024;
});
builder.Services.AddSignalR(options => Microsoft.AspNetCore.SignalR.HubOptionsExtensions
.AddFilter<RadicadeServer.API.Notifications.NotifyHubInvocationFilter>(options));
builder.Services.AddSingleton<RadicadeServer.API.Notifications.NotifyHubInvocationFilter>();
builder.Services.AddHttpClient();
builder.Services.AddHostedService<BanStatusMonitor>();
var isDevelopment = builder.Environment.IsDevelopment();
builder.Services.AddCors(options =>
{
options.AddPolicy("AllowAllOrigins", builder =>
builder.AllowAnyOrigin()
.AllowAnyMethod()
.AllowAnyHeader());
options.AddPolicy("AdminPanel", builder =>
{
var adminOrigins = new List<string>();
if (!string.IsNullOrWhiteSpace(DBConfig.AdminPanelUrl))
adminOrigins.Add(DBConfig.AdminPanelUrl);
if (adminOrigins.Count > 0)
{
builder.WithOrigins(adminOrigins.ToArray())
.AllowAnyMethod()
.AllowAnyHeader()
.AllowCredentials();
}
else
{
builder.WithOrigins("http://localhost:0")
.AllowAnyMethod()
.AllowAnyHeader()
.AllowCredentials();
}
if (isDevelopment)
{
builder.SetIsOriginAllowed(origin =>
{
if (!Uri.TryCreate(origin, UriKind.Absolute, out var uri))
return false;
return uri.Host is "localhost" or "127.0.0.1" or "[::1]";
});
}
});
});
var app = builder.Build();
using (var scope = app.Services.CreateScope())
{
var dbContext = scope.ServiceProvider.GetRequiredService<RecDBContext>();
dbContext.Database.Migrate();
await EnsureSubRoomUnityAssetColumn(dbContext);
await EnsureRoomJoinGiftColumns(dbContext);
await EnsureRoomDeveloperOwnedColumn(dbContext);
await EnsurePlayerEventColumns(dbContext);
await EnsureVoiceChatRestrictionTable(dbContext);
await EnsureValidSceneLocations(dbContext);
await EnsureCatalogTables(dbContext);
StageShowdownRoomContent();
await EnsureProfileIdentityColumns(dbContext);
if (DBConfig.TestInfluencerAccountIds.Count > 0)
{
var testProfiles = await dbContext.Profiles
.Where(profile => DBConfig.TestInfluencerAccountIds.Contains(profile.accountId))
.ToListAsync();
foreach (var profile in testProfiles)
{
if (!profile.HasRole("influencer"))
profile.role = string.IsNullOrWhiteSpace(profile.role) ? "influencer" : $"{profile.role},influencer";
}
if (testProfiles.Any(profile => profile.HasRole("influencer")))
await dbContext.SaveChangesAsync();
}
await RemoveBlanketCoOwners(dbContext);
await SyncAdminAccounts(dbContext);
if (!dbContext.Profiles.Any())
{
await dbContext.AddCoach();
await dbContext.AddGameConfigs();
await dbContext.AddServerConfig();
await dbContext.AddConfig();
await dbContext.AddDefaultRooms();
}
if (!await dbContext.Rooms.AnyAsync(room => room.IsDorm))
{
await dbContext.AddDormRoom();
}
await dbContext.NormalizeDormRoomImagesAsync();
await DefaultRooms.EnsureAsync(dbContext);
await FirstTimeUtils.DownloadImages();
LoadingScreenImages.EnsureInstalled();
await EnsureRequiredGameConfigs(dbContext);
await RepairAvatarBootData(dbContext);
await EnsureReferenceGameConfigs(dbContext);
await RepairSplitTestConfigs(dbContext);
await DisableCommunityBoardHowToBuildLink(dbContext);
await RepairPlayerSettingsBootData(dbContext);
await RepairNullProfileImages(dbContext);
StorefrontCatalog.Load();
if (SteamWhitelist.CreatedEmptyConfig)
{
var wlOnly = await dbContext.ServerConfig.AsNoTracking().FirstOrDefaultAsync(x => x.Key == "WhitelistOnly");
var wl = await dbContext.ServerConfig.AsNoTracking().FirstOrDefaultAsync(x => x.Key == "SteamWhitelist");
if (wl != null && !string.IsNullOrWhiteSpace(wl.Value))
{
SteamWhitelist.SetSteamIds(wl.Value.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries));
}
if (wlOnly != null && wlOnly.Value.Equals("true", StringComparison.OrdinalIgnoreCase))
{
SteamWhitelist.Enabled = true;
}
}
await MatchmakingRuntimeState.ResetAsync(dbContext);
MainConfig.ActiveConfig = await dbContext.Config.FirstOrDefaultAsync();
}
static void StageShowdownRoomContent()
{
const string saveName = "Showdown.room";
const string bundleName = "Showdown_0_1.assetbundle";
var candidates = new[]
{
Path.GetFullPath(Path.Combine("..", "RoomImports", "Showdown")),
Path.GetFullPath(Path.Combine("..", "room-imports", "Showdown")),
Path.GetFullPath(Path.Combine("RoomImports", "Showdown")),
Path.GetFullPath(Path.Combine("room-imports", "Showdown")),
};
var sourceDir = candidates.FirstOrDefault(Directory.Exists) ?? candidates[0];
var storage = DBConfig.CDNStorage;
Directory.CreateDirectory(storage);
var saveSource = Path.Combine(sourceDir, saveName);
var bundleSource = Path.Combine(sourceDir, bundleName);
if (!File.Exists(saveSource) || !File.Exists(bundleSource))
{
return;
}
File.Copy(saveSource, Path.Combine(storage, saveName), overwrite: true);
File.Copy(bundleSource, Path.Combine(storage, bundleName), overwrite: true);
}
static async Task SyncAdminAccounts(RecDBContext dbContext)
{
var configured = DBConfig.AdminAccounts;
var existing = await dbContext.AdminUsers.ToListAsync();
if (configured.Count == 0)
{
return;
}
foreach (var account in configured)
{
var user = existing.FirstOrDefault(x => string.Equals(x.Username, account.Username, StringComparison.OrdinalIgnoreCase));
if (user == null)
{
dbContext.AdminUsers.Add(new AdminUser
{
Username = account.Username,
PasswordHash = AdminSecurity.HashPassword(account.Password),
CreatedAt = DateTime.UtcNow
});
await dbContext.SaveChangesAsync();
continue;
}
if (!AdminSecurity.VerifyPassword(user.PasswordHash, account.Password))
{
user.PasswordHash = AdminSecurity.HashPassword(account.Password);
await dbContext.SaveChangesAsync();
await AdminSession.RevokeAllAsync(dbContext, user.Id);
}
else if (AdminSecurity.NeedsRehash(user.PasswordHash))
{
user.PasswordHash = AdminSecurity.HashPassword(account.Password);
await dbContext.SaveChangesAsync();
}
}
var removed = existing
.Where(user => !configured.Any(account => string.Equals(account.Username, user.Username, StringComparison.OrdinalIgnoreCase)))
.ToList();
if (removed.Count == 0) return;
foreach (var user in removed)
{
await AdminSession.RevokeAllAsync(dbContext, user.Id);
}
dbContext.AdminUsers.RemoveRange(removed);
await dbContext.SaveChangesAsync();
}
static async Task RemoveBlanketCoOwners(RecDBContext dbContext)
{
const string marker = "Config.BlanketCoOwnersRemoved";
if (await dbContext.GameConfig.AnyAsync(x => x.Key == marker)) return;
var accounts = new List<long> { 2 };
accounts.AddRange(DBConfig.TestCoOwnerAccountIds);
accounts = accounts.Distinct().ToList();
var rows = await dbContext.RoomRoles
.Where(role => accounts.Contains(role.AccountId) && role.Role == RoleType.CoOwner)
.ToListAsync();
if (rows.Count > 0)
{
dbContext.RoomRoles.RemoveRange(rows);
await dbContext.SaveChangesAsync();
}
dbContext.GameConfig.Add(new ConfigVal(marker, "1"));
await dbContext.SaveChangesAsync();
}
static async Task EnsureProfileIdentityColumns(RecDBContext dbContext)
{
await dbContext.Database.ExecuteSqlRawAsync(@"
IF COL_LENGTH('dbo.Profiles', 'personalPronouns') IS NULL
ALTER TABLE [Profiles] ADD [personalPronouns] int NOT NULL CONSTRAINT [DF_Profiles_personalPronouns] DEFAULT 0;
IF COL_LENGTH('dbo.Profiles', 'identityFlags') IS NULL
ALTER TABLE [Profiles] ADD [identityFlags] int NOT NULL CONSTRAINT [DF_Profiles_identityFlags] DEFAULT 0;
-- Profile.bannerImage / isRecentHistoryVisible. Added by a migration too, but a
-- production database that drifted (see EnsurePlayerEventColumns) would reach the
-- model before the column exists, and every Profiles query would then fail with an
-- invalid-column-name error. Idempotent, so the migration and this repair can coexist.
IF COL_LENGTH('dbo.Profiles', 'bannerImage') IS NULL
ALTER TABLE [Profiles] ADD [bannerImage] nvarchar(max) NULL;
IF COL_LENGTH('dbo.Profiles', 'isRecentHistoryVisible') IS NULL
ALTER TABLE [Profiles] ADD [isRecentHistoryVisible] bit NOT NULL CONSTRAINT [DF_Profiles_isRecentHistoryVisible] DEFAULT 1;");
}
static async Task EnsureSubRoomUnityAssetColumn(RecDBContext dbContext)
{
await dbContext.Database.ExecuteSqlRawAsync(@"
IF COL_LENGTH('dbo.SubRooms', 'UnityAssetId') IS NULL
ALTER TABLE [SubRooms] ADD [UnityAssetId] nvarchar(max) NOT NULL CONSTRAINT [DF_SubRooms_UnityAssetId] DEFAULT N'';");
}
static async Task RepairNullProfileImages(RecDBContext dbContext)
{
var broken = await dbContext.Profiles
.Where(p => p.profileImage == null || p.profileImage == "")
.ToListAsync();
if (broken.Count == 0)
return;
foreach (var profile in broken)
profile.profileImage = "DefaultProfileImage";
await dbContext.SaveChangesAsync();
}
RoomUtils.DbFactory = app.Services.GetRequiredService<IDbContextFactory<RecDBContext>>();
MatchmakingUtils.DbFactory = app.Services.GetRequiredService<IDbContextFactory<RecDBContext>>();
Notify.DbFactory = app.Services.GetRequiredService<IDbContextFactory<RecDBContext>>();
Notify.Context = app.Services.GetRequiredService<Microsoft.AspNetCore.SignalR.IHubContext<NotifyHub>>();
RelationshipsShared.DbFactory = app.Services.GetRequiredService<IDbContextFactory<RecDBContext>>();
PlayerUtils.DbFactory = app.Services.GetRequiredService<IDbContextFactory<RecDBContext>>();
AuthShared.DbFactory = app.Services.GetRequiredService<IDbContextFactory<RecDBContext>>();
app.Use(async (context, next) =>
{
var path = context.Request.Path.Value ?? "";
if (!path.StartsWith("/admin/", StringComparison.OrdinalIgnoreCase) &&
!string.Equals(path, "/admin", StringComparison.OrdinalIgnoreCase))
{
var ip = context.Request.GetClientIp();
if (!string.IsNullOrWhiteSpace(ip))
{
var dbFactory = context.RequestServices.GetRequiredService<IDbContextFactory<RecDBContext>>();
if (await GameBlockMonitor.IsBlockedAsync(dbFactory, ip))
{
context.Response.StatusCode = StatusCodes.Status403Forbidden;
await context.Response.WriteAsJsonAsync(new
{
error = "game_blocked",
message = "This IP address is blocked from connecting to the game servers."
});
return;
}
}
}
await next();
});
app.Use(async (context, next) =>
{
var path = context.Request.Path.Value ?? "";
if (path.Contains("//"))
{
var collapsed = System.Text.RegularExpressions.Regex.Replace(path, "/{2,}", "/");
context.Request.Path = collapsed;
}
await next();
});
app.UseRouting();
app.UseCors("AllowAllOrigins");
app.Use(async (context, next) =>
{
var path = context.Request.Path.Value ?? "";
if (path.StartsWith("/images/", StringComparison.OrdinalIgnoreCase))
{
context.Response.Headers.Append("Access-Control-Allow-Origin", "*");
context.Response.Headers.Append("Access-Control-Allow-Methods", "GET, OPTIONS");
context.Response.Headers.Append("Access-Control-Allow-Headers", "*");
}
await next();
});
app.Use(async (context, next) =>
{
var path = context.Request.Path.Value ?? "";
if (path.StartsWith("/admin/", StringComparison.OrdinalIgnoreCase) &&
!path.StartsWith("/admin/auth", StringComparison.OrdinalIgnoreCase))
{
var existingKey = context.Request.Query["key"].ToString();
if (string.IsNullOrEmpty(existingKey) || !existingKey.IsAdminKey())
{
void Promote()
{
var query = new Dictionary<string, Microsoft.Extensions.Primitives.StringValues>();
foreach (var kv in context.Request.Query)
query[kv.Key] = kv.Value;
query["key"] = DBConfig.AdminApiKey;
context.Request.Query = new QueryCollection(query);
}
if (context.Request.Cookies.TryGetValue(RadicadeServer.Auth.AdminSession.CookieName, out var sessionToken))
{
var dbFactory = context.RequestServices.GetRequiredService<IDbContextFactory<RecDBContext>>();
await using var authDb = await dbFactory.CreateDbContextAsync();
var (valid, _) = await RadicadeServer.Auth.AdminSession.ValidateAsync(authDb, sessionToken);
if (valid) Promote();
}
}
}
await next();
});
app.Use(async (context, next) =>
{
var started = Stopwatch.GetTimestamp();
var originalBody = context.Response.Body;
var countingBody = new CountingStream(originalBody);
context.Response.Body = countingBody;
try
{
await next();
}
catch (OperationCanceledException) when (context.RequestAborted.IsCancellationRequested)
{
Console.WriteLine($"[{DateTime.Now:HH:mm:ss.fff}] {context.Request.Method} {context.Request.Path}{context.Request.QueryString} -> client disconnected mid-response (len={countingBody.BytesWritten})");
}
catch (Exception ex)
{
Console.WriteLine($"[{DateTime.Now:HH:mm:ss.fff}] unhandled {context.Request.Method} {context.Request.Path}{context.Request.QueryString}: {ex}");
if (!context.Response.HasStarted && !context.RequestAborted.IsCancellationRequested)
{
context.Response.StatusCode = StatusCodes.Status500InternalServerError;
await context.Response.WriteAsJsonAsync(new { error = "internal_server_error" });
}
}
finally
{
var elapsed = Stopwatch.GetElapsedTime(started).TotalMilliseconds;
var bodyPreview = countingBody.BytesWritten <= 8
? " body=" + System.Text.Encoding.UTF8.GetString(countingBody.Peek)
: string.Empty;
Console.WriteLine($"[{DateTime.Now:HH:mm:ss.fff}] {context.Request.Method} {context.Request.Path}{context.Request.QueryString} -> {context.Response.StatusCode} ({elapsed:0.0} ms) len={countingBody.BytesWritten} ct={context.Response.ContentType}{bodyPreview}");
}
});
app.UseMiddleware<RadicadeServer.Middleware.FailureEnvelopeMiddleware>();
app.UseAuthentication();
app.UseAuthorization();
app.Use(async (context, next) =>
{
var diagnosticPaths = new HashSet<string>(StringComparer.OrdinalIgnoreCase)
{
"/matchmaking/player",
"/matchmaking/player/login",
"/matchmaking/player/exclusivelogin",
"/leaderboard/GetRanks",
"/leaderboard/GetPlayerRank",
"/leaderboard/GetNearbyScores",
"/leaderboard/leaderboard/GetRanks",
"/leaderboard/leaderboard/GetPlayerRank",
"/leaderboard/leaderboard/GetNearbyScores",
"/econ/api/checklist/v1/current",
"/econ/api/objectives/v1/myprogress",
"/api/players/v2/progression/bulk",
"/accounts/account/bulk",
"/api/playerReputation/v2/bulk",
"/api/relationships/v2/get",
"/api/PlayerReporting/v1/moderationBlockDetails",
"/chat/thread",
"/playersettings",
"/playersettings/v2",
"/playersettings/playersettings",
"/api/settings",
"/api/settings/v2",
"/api/settings/playersettings",
"/api/gameconfigs/v1/all",
"/api/versioncheck/v4"
};
var path = context.Request.Path.Value ?? string.Empty;
if (!diagnosticPaths.Contains(path))
{
await next();
return;
}
var originalBody = context.Response.Body;
await using var capturedBody = new MemoryStream();
context.Response.Body = capturedBody;
try
{
await next();
capturedBody.Position = 0;
using var reader = new StreamReader(
capturedBody,
Encoding.UTF8,
detectEncodingFromByteOrderMarks: false,
leaveOpen: true);
var responseBody = await reader.ReadToEndAsync();
var diagnosticLine =
$"[PlayerDataDebug] {context.Request.Method} " +
$"{context.Request.Path}{context.Request.QueryString} -> " +
$"{context.Response.StatusCode} body={responseBody}";
try
{
var diagnosticLogPath = Path.Join(DBConfig.PersistentLogs, "player-data-debug.log");
await File.AppendAllTextAsync(
diagnosticLogPath,
$"[{DateTime.UtcNow:O}] {diagnosticLine}{Environment.NewLine}");
}
catch
{
}
capturedBody.Position = 0;
await capturedBody.CopyToAsync(originalBody);
}
finally
{
context.Response.Body = originalBody;
}
});
app.Use(async (context, next) =>
{
var leaderboardPath = context.Request.Path.Value ?? string.Empty;
if (leaderboardPath.Contains("leaderboard", StringComparison.OrdinalIgnoreCase)
|| leaderboardPath.Contains("scoreboard", StringComparison.OrdinalIgnoreCase))
{
context.Request.EnableBuffering();
string requestBody;
using (var reader = new StreamReader(
context.Request.Body,
Encoding.UTF8,
detectEncodingFromByteOrderMarks: false,
leaveOpen: true))
{
requestBody = await reader.ReadToEndAsync();
}
context.Request.Body.Position = 0;
}
await next();
});
app.MapControllers();
foreach (var hubPath in RadicadeServer.API.Notifications.NotifyHubRoutes.Paths)
app.MapHub<NotifyHub>(hubPath);
app.MapGet("/diag/build", () => Results.Json(new
{
build = BuildInfo.Tag,
notifyIdFormat = BuildInfo.NotifyIdFormat,
assemblyBuiltUtc = BuildInfo.AssemblyBuiltUtc,
startedUtc = BuildInfo.StartedUtc,
}));
app.MapGet("/diag/notify", async (long account, string? key, string? form) =>
{
if (key is null || !key.IsAdminKey()) return Results.Unauthorized();
return Results.Text(await Notify.ReplayStoredCoachMessageAsync(account, form), "text/plain");
});
app.MapGet("/diag/rewardpush", async (long account, string? key, string? id, IDbContextFactory<RecDBContext> dbFactory) =>
{
if (key is null || !key.IsAdminKey()) return Results.Unauthorized();
await using var db = await dbFactory.CreateDbContextAsync();
return Results.Text(await RewardPushDiag.ReplayPendingRewardSelectionAsync(db, account, id), "text/plain");
});
app.MapGet("/diag/rewards", async (long account, string? key, int? lines, IDbContextFactory<RecDBContext> dbFactory) =>
{
if (key is null || !key.IsAdminKey()) return Results.Unauthorized();
await using var db = await dbFactory.CreateDbContextAsync();
return Results.Text(await RewardDiag.DescribeAsync(db, account, lines ?? 120), "text/plain");
});
app.Lifetime.ApplicationStarted.Register(() =>
Console.WriteLine($"[{DateTime.Now:HH:mm:ss}] [Radicade] started. env={app.Environment.EnvironmentName} url={DBConfig.ServerUrl} build={BuildInfo.Tag} assemblyBuilt={BuildInfo.AssemblyBuiltUtc:u}"));
app.Lifetime.ApplicationStopping.Register(() =>
Console.WriteLine($"[{DateTime.Now:HH:mm:ss}] [Radicade] stopping."));
app.Run();
static void EnsureLocalHttpsCertificate(string certificatePath, string password)
{
if (File.Exists(certificatePath)) return;
var certificateDirectory = Path.GetDirectoryName(certificatePath);
if (!string.IsNullOrWhiteSpace(certificateDirectory) && !Directory.Exists(certificateDirectory))
Directory.CreateDirectory(certificateDirectory);
using var rsa = CreateLocalHttpsKey();
var request = new CertificateRequest(
"CN=localhost",
rsa,
HashAlgorithmName.SHA256,
RSASignaturePadding.Pkcs1);
request.CertificateExtensions.Add(new X509BasicConstraintsExtension(false, false, 0, false));
request.CertificateExtensions.Add(new X509KeyUsageExtension(
X509KeyUsageFlags.DigitalSignature | X509KeyUsageFlags.KeyEncipherment,
false));
var enhancedKeyUsages = new OidCollection
{
new Oid("1.3.6.1.5.5.7.3.1")
};
request.CertificateExtensions.Add(new X509EnhancedKeyUsageExtension(enhancedKeyUsages, false));
request.CertificateExtensions.Add(new X509SubjectKeyIdentifierExtension(request.PublicKey, false));
var subjectAltNames = new SubjectAlternativeNameBuilder();
subjectAltNames.AddDnsName("localhost");
subjectAltNames.AddIpAddress(IPAddress.Loopback);
subjectAltNames.AddIpAddress(IPAddress.IPv6Loopback);
request.CertificateExtensions.Add(subjectAltNames.Build());
using var certificate = request.CreateSelfSigned(
DateTimeOffset.UtcNow.AddDays(-1),
DateTimeOffset.UtcNow.AddYears(5));
File.WriteAllBytes(certificatePath, certificate.Export(X509ContentType.Pfx, password));
}
static X509Certificate2 LoadLocalHttpsCertificate(string certificatePath, string password)
{
return X509CertificateLoader.LoadPkcs12FromFile(
certificatePath,
password,
X509KeyStorageFlags.MachineKeySet |
X509KeyStorageFlags.PersistKeySet |
X509KeyStorageFlags.Exportable);
}
static string InstallLocalHttpsCertificate(string certificatePath, string password)
{
using var certificate = LoadLocalHttpsCertificate(certificatePath, password);
using var store = new X509Store(StoreName.My, StoreLocation.CurrentUser);
store.Open(OpenFlags.ReadWrite);
var existingCertificates = store.Certificates.Find(
X509FindType.FindByThumbprint,
certificate.Thumbprint,
validOnly: false);
if (existingCertificates.Count == 0)
{
store.Add(certificate);
}
return certificate.Thumbprint;
}
static X509Certificate2 LoadLocalHttpsCertificateFromStore(string thumbprint)
{
using var store = new X509Store(StoreName.My, StoreLocation.CurrentUser);
store.Open(OpenFlags.ReadOnly);
var certificates = store.Certificates.Find(
X509FindType.FindByThumbprint,
thumbprint,
validOnly: false);
if (certificates.Count == 0)
throw new InvalidOperationException($"Local HTTPS certificate {thumbprint} was not found in CurrentUser\\My.");
return certificates
.OfType<X509Certificate2>()
.OrderByDescending(certificate => certificate.NotAfter)
.First();
}
static RSA CreateLocalHttpsKey()
{
if (!OperatingSystem.IsWindows())
return RSA.Create(2048);
return new RSACryptoServiceProvider(
2048,
new CspParameters(24, "Microsoft Enhanced RSA and AES Cryptographic Provider")
{
KeyContainerName = $"RadicadeLocalHttps-{Guid.NewGuid():N}",
KeyNumber = (int)KeyNumber.Exchange
})
{
PersistKeyInCsp = false
};
}
static async Task EnsureRequiredGameConfigs(RecDBContext dbContext)
{
await UpsertGameConfigValue(dbContext, "splitTestSoftOverrides", string.Empty);
await UpsertGameConfigValue(dbContext, "splitTestsAvailable", SplitTestConfigs.TestsAvailable);
await UpsertGameConfigValue(dbContext, "splitTestSegmentProbabilities", SplitTestConfigs.SegmentProbabilities);
await UpsertGameConfigValue(dbContext, "splitTestHardOverrides", SplitTestConfigs.HardOverrides);
var probabilitiesRow = await dbContext.GameConfig.FirstOrDefaultAsync(x => x.Key == "splitTestSegmentProbabilities");
if (probabilitiesRow != null && probabilitiesRow.Value == "{}")
probabilitiesRow.Value = SplitTestConfigs.SegmentProbabilities;
await EnsureSplitTestHardOverrides(dbContext);
await UpsertGameConfigValue(dbContext, "Backtrace.versionRegex", ".*");
await UpsertGameConfigValue(dbContext, "Backtrace.messageRegex", ".*");
await UpsertGameConfigValue(dbContext, "Backtrace.stopTimeUTC", "2099-01-01 00:00");
await UpsertGameConfigValue(dbContext, "Growth.FriendFestPromosActive", "false");
await UpsertGameConfigValue(dbContext, "Growth.FriendFestImageName", "FriendFest.jpg");
await UpsertGameConfigValue(dbContext, "Growth.EnableProfilePhoneButton", "false");
await RepairStoreLayoutConfigs(dbContext);
await RepairDebugLogConfigs(dbContext);
await UpsertGameConfigValue(dbContext, "Friends.PostGamePromptUnderFriendCount", "10");
await UpsertGameConfigValue(dbContext, "Friends.SuggestFriendCodeOnFriendsScreenCount", "25");
await UpsertGameConfigValue(dbContext, "MobileNotifications.UseFirebasePushNotifications", "true");
await UpsertGameConfigValue(dbContext, "UGC.CV2.CooldownBeforeSubscriptionCheckSeconds", "240");
await UnblockImportedRooms(dbContext);
await EnableBetaCreativeToolsConfigs(dbContext);
await dbContext.SaveChangesAsync();
}
static async Task EnsureSplitTestHardOverrides(RecDBContext dbContext)
{
var existing = await GameConfigSeeder.FindAsync(dbContext, "splitTestHardOverrides");
if (existing == null)
{
await dbContext.GameConfig.AddAsync(new ConfigVal("splitTestHardOverrides", SplitTestHardOverrides));
return;
}
if (string.IsNullOrWhiteSpace(existing.Value))
{
existing.Value = SplitTestHardOverrides;
return;
}
var entries = existing.Value.Split(';', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
var known = new HashSet<string>(entries.Select(e => e.Split('=', 2)[0]), StringComparer.OrdinalIgnoreCase);
var additions = new List<string>();
foreach (var entry in SplitTestHardOverrides.Split(';', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries))
{
var name = entry.Split('=', 2)[0];
if (known.Add(name))
additions.Add(entry);
}
if (additions.Count > 0)
{
existing.Value = (entries.Concat(additions).ToList()).Aggregate((a, b) => a + ";" + b);
}
}
static async Task RepairStoreLayoutConfigs(RecDBContext dbContext)
{
const string oldLayout = "banner=12;storecampaigns=11;tokenpack=10;allskus=9;hotitem=8;hotinvention=7;newitem=6;mysubscriptioninvention=5;rrplusitem=4;fivestaritem=3;skinsitem=2;hairdyeitem=1;";
const string newLayout = "banner=9;hotitem=7;hotinvention=8;newitem=6;mysubscriptioninvention=5;rrplusitem=4;fivestaritem=3;skinsitem=2;hairdyeitem=1;";
var keys = new[] { "OldStoreLayout", "NewStoreLayout" };
var configs = await dbContext.GameConfig.Where(x => keys.Contains(x.Key)).ToListAsync();
foreach (var config in configs)
{
var correct = config.Key == "OldStoreLayout" ? oldLayout : newLayout;
if (string.Equals(config.Value, correct, StringComparison.Ordinal)) continue;
var value = config.Value ?? string.Empty;
if (string.IsNullOrWhiteSpace(value) ||
string.Equals(value, "true", StringComparison.OrdinalIgnoreCase) ||
string.Equals(value, "false", StringComparison.OrdinalIgnoreCase) ||
!value.StartsWith("banner=", StringComparison.OrdinalIgnoreCase))
{
config.Value = correct;
}
}
await dbContext.SaveChangesAsync();
}
static async Task UnblockImportedRooms(RecDBContext dbContext)
{
const string key = "RoomLoad.BlockedRoomNamesOnLowMemoryDevices";
const string importedRoom = "Showdown";
var config = await dbContext.GameConfig.FirstOrDefaultAsync(x => x.Key == key);
if (config == null || string.IsNullOrWhiteSpace(config.Value) ||
!config.Value.Split(',', StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries)
.Contains(importedRoom, StringComparer.OrdinalIgnoreCase))
return;
config.Value = string.Join(",", config.Value
.Split(',', StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries)
.Where(name => !name.Equals(importedRoom, StringComparison.OrdinalIgnoreCase)));
}
static async Task RepairDebugLogConfigs(RecDBContext dbContext)
{
const string supportedFlags = "Platform|BootSequence";
var flags = await dbContext.GameConfig.FirstOrDefaultAsync(x => x.Key == "Debug.AdditionalTraceFlags");
if (flags != null &&
(flags.Value?.Contains("DissonanceLogs", StringComparison.OrdinalIgnoreCase) == true ||
flags.Value?.Contains("Audio", StringComparison.OrdinalIgnoreCase) == true ||
flags.Value?.Contains("TachyonClient", StringComparison.OrdinalIgnoreCase) == true))
{
flags.Value = supportedFlags;
}
var traceProbability = await dbContext.GameConfig.FirstOrDefaultAsync(x => x.Key == "Debug.TraceProbability");
if (traceProbability != null && traceProbability.Value == "1")
traceProbability.Value = "0";
await dbContext.SaveChangesAsync();
}
static async Task RepairAvatarBootData(RecDBContext dbContext)
{
const string repairedOutfitSelection = "";
const string malformedUnlock = "d0a9262f-5504-46a7-bb10-7507503db58e,95e4cc30-cb68-473d-a395-feadf5b51512,,";
const string repairedUnlock = "d0a9262f-5504-46a7-bb10-7507503db58e,95e4cc30-cb68-473d-a395-feadf5b51512,0440f08f-ef1d-49d8-942b-523056e8bb45,";
string[] malformedOutfitSelections =
[
"d0a9262f-5504-46a7-bb10-7507503db58e,,,,1",
"d0a9262f-5504-46a7-bb10-7507503db58e,95e4cc30-cb68-473d-a395-feadf5b51512,,,1",
"d0a9262f-5504-46a7-bb10-7507503db58e,95e4cc30-cb68-473d-a395-feadf5b51512,0440f08f-ef1d-49d8-942b-523056e8bb45,,1"
];
var repairedAvatars = 0;
foreach (var avatar in await dbContext.Avatars.ToListAsync())
{
var changed = false;
foreach (var malformedOutfitSelection in malformedOutfitSelections)
{
if (!string.IsNullOrWhiteSpace(avatar.Avatar) &&
avatar.Avatar.Contains(malformedOutfitSelection, StringComparison.OrdinalIgnoreCase))
{
avatar.Avatar = avatar.Avatar.Replace(
malformedOutfitSelection,
repairedOutfitSelection,
StringComparison.OrdinalIgnoreCase);
changed = true;
}
if (!string.IsNullOrWhiteSpace(avatar.Outfits) &&
avatar.Outfits.Contains(malformedOutfitSelection, StringComparison.OrdinalIgnoreCase))
{
avatar.Outfits = avatar.Outfits.Replace(
malformedOutfitSelection,
repairedOutfitSelection,
StringComparison.OrdinalIgnoreCase);
changed = true;
}
}
if (changed) repairedAvatars++;
}
var repairedUnlockRows = 0;
foreach (var unlocks in await dbContext.Unlocks.ToListAsync())
{
if (string.IsNullOrWhiteSpace(unlocks.AvatarItems) ||
!unlocks.AvatarItems.Contains(malformedUnlock, StringComparison.OrdinalIgnoreCase))
{
continue;
}
unlocks.AvatarItems = unlocks.AvatarItems.Replace(
malformedUnlock,
repairedUnlock,
StringComparison.OrdinalIgnoreCase);
repairedUnlockRows++;
}
if (repairedAvatars > 0 || repairedUnlockRows > 0)
{
await dbContext.SaveChangesAsync();
}
}
static Task UpsertGameConfigValue(RecDBContext dbContext, string key, string value)
=> GameConfigSeeder.UpsertAsync(dbContext, key, value);
static async Task EnsureReferenceGameConfigs(RecDBContext dbContext)
{
var existingKeys = await dbContext.GameConfig
.Select(x => x.Key)
.ToHashSetAsync(StringComparer.OrdinalIgnoreCase);
foreach (var tracked in dbContext.ChangeTracker.Entries<ConfigVal>())
{
if (!string.IsNullOrWhiteSpace(tracked.Entity.Key))
existingKeys.Add(tracked.Entity.Key);
}
var added = 0;
using var doc = JsonDocument.Parse(DBConfig.PlaceholderConfig);
foreach (var item in doc.RootElement.EnumerateArray())
{
if (!item.TryGetProperty("Key", out var keyElement)) continue;
var key = keyElement.GetString();
if (string.IsNullOrWhiteSpace(key) || !existingKeys.Add(key)) continue;
var value = item.TryGetProperty("Value", out var valueElement)
? valueElement.GetString() ?? string.Empty
: string.Empty;
await dbContext.GameConfig.AddAsync(new ConfigVal(key, value));
added++;
}
if (added > 0)
{
await dbContext.SaveChangesAsync();
}
}
static async Task RepairSplitTestConfigs(RecDBContext dbContext)
{
var desiredValues = new Dictionary<string, string?>
{
["splitTestHardOverrides"] = ReferenceSplitTestValue("splitTestHardOverrides"),
["splitTestsAvailable"] = ReferenceSplitTestValue("splitTestsAvailable"),
["splitTestSegmentProbabilities"] = ReferenceSplitTestValue("splitTestSegmentProbabilities"),
};
var seeded = 0;
foreach (var (key, value) in desiredValues)
{
if (string.IsNullOrWhiteSpace(value)) continue;
var config = await GameConfigSeeder.FindAsync(dbContext, key);
if (config == null)
{
await dbContext.GameConfig.AddAsync(new ConfigVal(key, value));
seeded++;
continue;
}
if (!string.IsNullOrWhiteSpace(config.Value)) continue;
config.Value = value;
seeded++;
}
if (seeded > 0)
{
await dbContext.SaveChangesAsync();
}
}
static async Task DisableCommunityBoardHowToBuildLink(RecDBContext dbContext)
{
const string key = "CommunityBoard.HowToBuildLink";
var config = await GameConfigSeeder.FindAsync(dbContext, key);
if (config == null || string.IsNullOrEmpty(config.Value)) return;
if (config.Value.IndexOf("youtube", StringComparison.OrdinalIgnoreCase) < 0) return;
config.Value = string.Empty;
await dbContext.SaveChangesAsync();
}
static async Task EnableBetaCreativeToolsConfigs(RecDBContext dbContext)
{
var betaFlags = new Dictionary<string, string>
{
["UGC.Room2Enabled"] = "true",
["UGC.AllowBetaInvertedTubeCreation"] = "true",
["UGC.AllowNonBetaInvertedTubeCreation"] = "true",
["UGC.EnableInventionsInBetaRooms"] = "true",
["UGC.Room2PublishingEnabled"] = "true",
["UGC.Room2InventionsEnabled"] = "true",
["UGC.Room2InventionAllowBetaPublishing"] = "true",
["UGC.Room2InventionPublishingEnabled"] = "true",
};
var updated = 0;
foreach (var (key, value) in betaFlags)
{
var existing = await GameConfigSeeder.FindAsync(dbContext, key);
if (existing == null)
{
await dbContext.GameConfig.AddAsync(new ConfigVal(key, value));
updated++;
}
else if (!string.Equals(existing.Value, value, StringComparison.OrdinalIgnoreCase))
{
existing.Value = value;
updated++;
}
}
if (updated > 0)
{
await dbContext.SaveChangesAsync();
}
}
static string? ReferenceSplitTestValue(string key)
{
try
{
var entries = JsonSerializer.Deserialize<List<ConfigVal>>(DBConfig.PlaceholderConfig);
return entries?.FirstOrDefault(x => string.Equals(x.Key, key, StringComparison.Ordinal))?.Value;
}
catch (Exception)
{
return null;
}
}
static async Task RepairPlayerSettingsBootData(RecDBContext dbContext)
{
const string neutralSplitTestAssignments = "1|{}";
var repairedRows = 0;
foreach (var settingsRow in await dbContext.PlayerSettings.ToListAsync())
{
var settings = settingsRow.DeserializeSettings();
var splitAssignments = settings.FirstOrDefault(x =>
string.Equals(x.Key, "SplitTestAssignedSegments", StringComparison.OrdinalIgnoreCase));
if (splitAssignments == null)
{
settings.Add(new Setting
{
Key = "SplitTestAssignedSegments",
Value = neutralSplitTestAssignments
});
repairedRows++;
}
else if (!string.Equals(splitAssignments.Value, neutralSplitTestAssignments, StringComparison.Ordinal))
{
splitAssignments.Value = neutralSplitTestAssignments;
repairedRows++;
}
settingsRow.Settings = settings.ToJson();
}
if (repairedRows > 0)
{
await dbContext.SaveChangesAsync();
}
}
static async Task EnsureRoomDeveloperOwnedColumn(RecDBContext dbContext)
{
await dbContext.Database.ExecuteSqlRawAsync(@"
IF COL_LENGTH('dbo.Rooms', 'IsDeveloperOwned') IS NULL
ALTER TABLE [Rooms] ADD [IsDeveloperOwned] bit NOT NULL CONSTRAINT [DF_Rooms_IsDeveloperOwned] DEFAULT 0;");
}
static async Task EnsureRoomJoinGiftColumns(RecDBContext dbContext)
{
await dbContext.Database.ExecuteSqlRawAsync(@"
IF COL_LENGTH('dbo.Rooms', 'JoinGiftAvatarItem') IS NULL ALTER TABLE [Rooms] ADD [JoinGiftAvatarItem] nvarchar(max) NOT NULL DEFAULT N'';
IF COL_LENGTH('dbo.Rooms', 'JoinGiftAvatarItemType') IS NULL ALTER TABLE [Rooms] ADD [JoinGiftAvatarItemType] int NOT NULL DEFAULT 0;
IF COL_LENGTH('dbo.Rooms', 'JoinGiftConsumableItem') IS NULL ALTER TABLE [Rooms] ADD [JoinGiftConsumableItem] nvarchar(max) NOT NULL DEFAULT N'';
IF COL_LENGTH('dbo.Rooms', 'JoinGiftContext') IS NULL ALTER TABLE [Rooms] ADD [JoinGiftContext] int NOT NULL DEFAULT 2;
IF COL_LENGTH('dbo.Rooms', 'JoinGiftCurrencyAmount') IS NULL ALTER TABLE [Rooms] ADD [JoinGiftCurrencyAmount] int NOT NULL DEFAULT 0;
IF COL_LENGTH('dbo.Rooms', 'JoinGiftCurrencyType') IS NULL ALTER TABLE [Rooms] ADD [JoinGiftCurrencyType] int NOT NULL DEFAULT 1;
IF COL_LENGTH('dbo.Rooms', 'JoinGiftEnabled') IS NULL ALTER TABLE [Rooms] ADD [JoinGiftEnabled] bit NOT NULL DEFAULT 0;
IF COL_LENGTH('dbo.Rooms', 'JoinGiftEquipmentItem') IS NULL ALTER TABLE [Rooms] ADD [JoinGiftEquipmentItem] nvarchar(max) NOT NULL DEFAULT N'';
IF COL_LENGTH('dbo.Rooms', 'JoinGiftEquipmentModificationGuid') IS NULL ALTER TABLE [Rooms] ADD [JoinGiftEquipmentModificationGuid] nvarchar(max) NOT NULL DEFAULT N'';
IF COL_LENGTH('dbo.Rooms', 'JoinGiftFromPlayerId') IS NULL ALTER TABLE [Rooms] ADD [JoinGiftFromPlayerId] bigint NOT NULL DEFAULT 0;
IF COL_LENGTH('dbo.Rooms', 'JoinGiftMessage') IS NULL ALTER TABLE [Rooms] ADD [JoinGiftMessage] nvarchar(max) NOT NULL DEFAULT N'Thanks for visiting!';
IF COL_LENGTH('dbo.Rooms', 'JoinGiftOncePerPlayer') IS NULL ALTER TABLE [Rooms] ADD [JoinGiftOncePerPlayer] bit NOT NULL DEFAULT 1;
IF COL_LENGTH('dbo.Rooms', 'JoinGiftRarity') IS NULL ALTER TABLE [Rooms] ADD [JoinGiftRarity] int NOT NULL DEFAULT 0;
IF COL_LENGTH('dbo.Rooms', 'JoinGiftTitle') IS NULL ALTER TABLE [Rooms] ADD [JoinGiftTitle] nvarchar(max) NOT NULL DEFAULT N'Room Visit Gift';
IF COL_LENGTH('dbo.Rooms', 'JoinGiftVersion') IS NULL ALTER TABLE [Rooms] ADD [JoinGiftVersion] int NOT NULL DEFAULT 1;
IF COL_LENGTH('dbo.Rooms', 'JoinGiftXp') IS NULL ALTER TABLE [Rooms] ADD [JoinGiftXp] int NOT NULL DEFAULT 0;
");
// A room's join gift boxes, one row each. The Rooms row above still describes the first box,
// so a room saved before this table existed keeps handing out what it always did.
await dbContext.Database.ExecuteSqlRawAsync(@"
IF OBJECT_ID('dbo.RoomJoinGifts', 'U') IS NULL
BEGIN
CREATE TABLE [RoomJoinGifts] (
[RoomJoinGiftId] bigint NOT NULL IDENTITY(1,1),
[RoomId] bigint NOT NULL,
[SortOrder] int NOT NULL DEFAULT 0,
[Version] int NOT NULL DEFAULT 1,
[Enabled] bit NOT NULL DEFAULT 1,
[OncePerPlayer] bit NOT NULL DEFAULT 1,
[Title] nvarchar(max) NOT NULL DEFAULT N'Room Visit Gift',
[Message] nvarchar(max) NOT NULL DEFAULT N'Thanks for visiting!',
[AvatarItem] nvarchar(max) NOT NULL DEFAULT N'',
[AvatarItemType] int NOT NULL DEFAULT 0,
[EquipmentItem] nvarchar(max) NOT NULL DEFAULT N'',
[EquipmentModificationGuid] nvarchar(max) NOT NULL DEFAULT N'',
[ConsumableItem] nvarchar(max) NOT NULL DEFAULT N'',
[CurrencyType] int NOT NULL DEFAULT 1,
[CurrencyAmount] int NOT NULL DEFAULT 0,
[Xp] int NOT NULL DEFAULT 0,
[Context] int NOT NULL DEFAULT 2,
[Rarity] int NOT NULL DEFAULT 0,
[FromPlayerId] bigint NOT NULL DEFAULT 0,
CONSTRAINT [PK_RoomJoinGifts] PRIMARY KEY ([RoomJoinGiftId])
);
END
IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE name = N'IX_RoomJoinGifts_RoomId' AND object_id = OBJECT_ID(N'dbo.RoomJoinGifts'))
CREATE INDEX [IX_RoomJoinGifts_RoomId] ON [RoomJoinGifts] ([RoomId]);
");
}
static async Task EnsurePlayerEventColumns(RecDBContext dbContext)
{
await dbContext.Database.ExecuteSqlRawAsync(@"
IF OBJECT_ID('dbo.PlayerEvents', 'U') IS NULL
BEGIN
CREATE TABLE [PlayerEvents] (
[PlayerEventId] bigint NOT NULL IDENTITY(1,1),
[Name] nvarchar(max) NOT NULL,
[Description] nvarchar(max) NOT NULL,
[ImageName] nvarchar(max) NOT NULL,
[StartTime] datetime2 NOT NULL,
[EndTime] datetime2 NOT NULL,
[CreatorPlayerId] bigint NOT NULL,
[AttendeeCount] int NOT NULL,
[RoomId] bigint NOT NULL,
[Accessibility] int NOT NULL,
[CreatedAt] datetime2 NOT NULL,
[SubRoomId] bigint NULL,
[ModerationState] int NOT NULL DEFAULT 0,
[ClubId] bigint NULL,
[IsMultiInstance] bit NOT NULL DEFAULT 0,
[SupportMultiInstanceRoomChat] bit NOT NULL DEFAULT 0,
[DefaultBroadcastPermissions] int NOT NULL DEFAULT 0,
[CanRequestBroadcastPermissions] int NOT NULL DEFAULT 0,
[BroadcastingRoomInstanceId] bigint NULL,
[TagsJson] nvarchar(max) NOT NULL DEFAULT N'[]',
CONSTRAINT [PK_PlayerEvents] PRIMARY KEY ([PlayerEventId])
);
END
IF OBJECT_ID('dbo.PlayerEventResponses', 'U') IS NULL
BEGIN
CREATE TABLE [PlayerEventResponses] (
[PlayerEventResponseId] bigint NOT NULL IDENTITY(1,1),
[PlayerEventId] bigint NOT NULL,
[PlayerId] bigint NOT NULL,
[Type] int NOT NULL,
[CreatedAt] datetime2 NOT NULL,
CONSTRAINT [PK_PlayerEventResponses] PRIMARY KEY ([PlayerEventResponseId])
);
END
IF COL_LENGTH('dbo.PlayerEvents', 'SubRoomId') IS NULL ALTER TABLE [PlayerEvents] ADD [SubRoomId] bigint NULL;
IF COL_LENGTH('dbo.PlayerEvents', 'ModerationState') IS NULL ALTER TABLE [PlayerEvents] ADD [ModerationState] int NOT NULL DEFAULT 0;
IF COL_LENGTH('dbo.PlayerEvents', 'ClubId') IS NULL ALTER TABLE [PlayerEvents] ADD [ClubId] bigint NULL;
IF COL_LENGTH('dbo.PlayerEvents', 'IsMultiInstance') IS NULL ALTER TABLE [PlayerEvents] ADD [IsMultiInstance] bit NOT NULL DEFAULT 0;
IF COL_LENGTH('dbo.PlayerEvents', 'SupportMultiInstanceRoomChat') IS NULL ALTER TABLE [PlayerEvents] ADD [SupportMultiInstanceRoomChat] bit NOT NULL DEFAULT 0;
IF COL_LENGTH('dbo.PlayerEvents', 'DefaultBroadcastPermissions') IS NULL ALTER TABLE [PlayerEvents] ADD [DefaultBroadcastPermissions] int NOT NULL DEFAULT 0;
IF COL_LENGTH('dbo.PlayerEvents', 'CanRequestBroadcastPermissions') IS NULL ALTER TABLE [PlayerEvents] ADD [CanRequestBroadcastPermissions] int NOT NULL DEFAULT 0;
IF COL_LENGTH('dbo.PlayerEvents', 'BroadcastingRoomInstanceId') IS NULL ALTER TABLE [PlayerEvents] ADD [BroadcastingRoomInstanceId] bigint NULL;
IF COL_LENGTH('dbo.PlayerEvents', 'TagsJson') IS NULL ALTER TABLE [PlayerEvents] ADD [TagsJson] nvarchar(max) NOT NULL DEFAULT N'[]';
IF NOT EXISTS (SELECT 1 FROM __EFMigrationsHistory WHERE MigrationId = '20260805082102_FixModelChanges')
INSERT INTO __EFMigrationsHistory (MigrationId, ProductVersion) VALUES ('20260805082102_FixModelChanges', '9.0.11');
IF NOT EXISTS (SELECT 1 FROM __EFMigrationsHistory WHERE MigrationId = '20260806004625_PlayerEventResponses')
INSERT INTO __EFMigrationsHistory (MigrationId, ProductVersion) VALUES ('20260806004625_PlayerEventResponses', '9.0.11');
IF NOT EXISTS (SELECT 1 FROM __EFMigrationsHistory WHERE MigrationId = '20260814120000_AddPlayerEventModerationFields')
INSERT INTO __EFMigrationsHistory (MigrationId, ProductVersion) VALUES ('20260814120000_AddPlayerEventModerationFields', '9.0.11');
IF NOT EXISTS (SELECT 1 FROM __EFMigrationsHistory WHERE MigrationId = '20260926120000_AddPlayerEventClubMetadata')
INSERT INTO __EFMigrationsHistory (MigrationId, ProductVersion) VALUES ('20260926120000_AddPlayerEventClubMetadata', '9.0.11');
IF NOT EXISTS (SELECT 1 FROM __EFMigrationsHistory WHERE MigrationId = '20261008120000_AddPlayerEventBroadcastingRoomInstance')
INSERT INTO __EFMigrationsHistory (MigrationId, ProductVersion) VALUES ('20261008120000_AddPlayerEventBroadcastingRoomInstance', '9.0.11');
");
}
static async Task EnsureVoiceChatRestrictionTable(RecDBContext dbContext)
{
await dbContext.Database.ExecuteSqlRawAsync(@"
IF OBJECT_ID('dbo.VoiceChatRestrictions', 'U') IS NULL
BEGIN
CREATE TABLE [VoiceChatRestrictions] (
[Id] bigint NOT NULL IDENTITY(1,1),
[PlayerId] bigint NOT NULL,
[Reason] nvarchar(max) NOT NULL DEFAULT N'',
[Category] int NOT NULL DEFAULT 0,
[StartedAt] datetime2 NOT NULL,
[ExpiresAt] datetime2 NOT NULL,
[IssuedBy] nvarchar(max) NOT NULL DEFAULT N'Admin Panel',
[IssuedByUsername] nvarchar(max) NULL,
[ModeratorNote] nvarchar(max) NULL,
[ClearedAt] datetime2 NULL,
[ClearedBy] nvarchar(max) NULL,
CONSTRAINT [PK_VoiceChatRestrictions] PRIMARY KEY ([Id])
);
CREATE INDEX [IX_VoiceChatRestrictions_PlayerId] ON [VoiceChatRestrictions] ([PlayerId]);
END
");
}
static async Task EnsureValidSceneLocations(RecDBContext dbContext)
{
var subRooms = await dbContext.SubRooms.ToListAsync();
if (subRooms.Count == 0) return;
var roomIds = subRooms.Select(s => s.RoomId).Distinct().ToList();
var rooms = await dbContext.Rooms
.Where(r => roomIds.Contains(r.RoomId))
.ToDictionaryAsync(r => r.RoomId);
var byRoom = subRooms
.GroupBy(s => s.RoomId)
.ToDictionary(g => g.Key, g => g.ToList());
int repaired = 0;
foreach (var subRoom in subRooms)
{
if (SceneLocations.IsValid(subRoom.UnitySceneId)) continue;
bool isDorm = rooms.TryGetValue(subRoom.RoomId, out var room) && room.IsDorm;
var siblings = byRoom.TryGetValue(subRoom.RoomId, out var list)
? list.Select(s => s.UnitySceneId)
: Enumerable.Empty<string?>();
subRoom.UnitySceneId = SceneLocations.ResolveFallback(siblings, isDorm);
repaired++;
}
if (repaired > 0)
{
await dbContext.SaveChangesAsync();
}
}
static async Task EnsureCatalogTables(RecDBContext dbContext)
{
await dbContext.Database.ExecuteSqlRawAsync(@"
IF OBJECT_ID('dbo.ItemSets', 'U') IS NULL
BEGIN
CREATE TABLE [ItemSets] (
[ItemSetId] bigint NOT NULL IDENTITY(1,1),
[FriendlyName] nvarchar(max) NOT NULL DEFAULT N'',
CONSTRAINT [PK_ItemSets] PRIMARY KEY ([ItemSetId])
);
END
IF OBJECT_ID('dbo.Tags', 'U') IS NULL
BEGIN
CREATE TABLE [Tags] (
[TagId] bigint NOT NULL IDENTITY(1,1),
[Tag] nvarchar(max) NOT NULL DEFAULT N'',
[Description] nvarchar(max) NOT NULL DEFAULT N'',
CONSTRAINT [PK_Tags] PRIMARY KEY ([TagId])
);
END
");
}
internal sealed class CountingStream : Stream
{
private readonly Stream _inner;
private long _bytes;
private readonly System.Collections.Generic.List<byte> _captured = new();
public long BytesWritten => _bytes;
public byte[] Peek => _captured.ToArray();
public CountingStream(Stream inner) => _inner = inner;
public override bool CanRead => _inner.CanRead;
public override bool CanSeek => _inner.CanSeek;
public override bool CanWrite => _inner.CanWrite;
public override long Length => _inner.Length;
public override long Position { get => _inner.Position; set => _inner.Position = value; }
public override void Flush() => _inner.Flush();
public override Task FlushAsync(CancellationToken ct) => _inner.FlushAsync(ct);
public override int Read(byte[] buffer, int offset, int count) => _inner.Read(buffer, offset, count);
public override long Seek(long offset, SeekOrigin origin) => _inner.Seek(offset, origin);
public override void SetLength(long value) => _inner.SetLength(value);
public override void Write(byte[] buffer, int offset, int count)
{
_inner.Write(buffer, offset, count);
_bytes += count;
if (_captured.Count < 8)
{
var take = Math.Min(count, 8 - _captured.Count);
for (var i = 0; i < take; i++) _captured.Add(buffer[offset + i]);
}
}
public override async Task WriteAsync(byte[] buffer, int offset, int count, CancellationToken ct)
{
await _inner.WriteAsync(buffer, offset, count, ct);
_bytes += count;
if (_captured.Count < 8)
{
var take = Math.Min(count, 8 - _captured.Count);
for (var i = 0; i < take; i++) _captured.Add(buffer[offset + i]);
}
}
}