mirror of
https://github.com/JogmansonReal/Radicade.git
synced 2026-10-25 15:09:26 -07:00
1555 lines
52 KiB
C#
1555 lines
52 KiB
C#
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]);
|
|
}
|
|
}
|
|
}
|