using System; using System.Collections.Generic; using System.Diagnostics; using System.IO; using System.Linq; using System.Net; using System.Net.Http; using System.Text; using System.Threading; using System.Text.Json; using System.Threading.Tasks; using System.Threading.RateLimiting; using Mocha2023.Classes; using Mocha2023.Classes.DBs; using Mocha2023.Classes.DBs.DBClasses; using Mocha2023.Controllers; using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.RateLimiting; using Microsoft.AspNetCore.SignalR; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; using static Mocha2023.Classes.DBs.DBClasses.PlayerDBClasses; using JsonSerializer = System.Text.Json.JsonSerializer; // Modified by @mechanicalize on Discord. // Original codebase by @nito9999. // Repository: https://github.com/nito9999/Mocha-2023 // Target client: Rec Room 20230406. // Licensed under the MIT License. namespace Mocha2023 { public sealed class DualTextWriter : TextWriter { private readonly TextWriter _console; private readonly StreamWriter _file; private readonly object _sync = new(); public DualTextWriter(TextWriter console, StreamWriter file) { _console = console; _file = file; } public override Encoding Encoding => _console.Encoding; public override void Write(char value) { lock (_sync) { _console.Write(value); _file.Write(value); } } public override void Write(string? value) { lock (_sync) { _console.Write(value); _file.Write(value); _file.Flush(); } } public override void WriteLine(string? value) { lock (_sync) { _console.WriteLine(value); _file.WriteLine($"[{DateTime.UtcNow:yyyy-MM-dd HH:mm:ss} UTC] {value}"); _file.Flush(); } } } internal static class ServerLog { private static readonly object Sync = new(); public static void Info(string message) => Write("INFO", message, ConsoleColor.Cyan); public static void Success(string message) => Write("OK", message, ConsoleColor.Green); public static void Warning(string message) => Write("WARN", message, ConsoleColor.Yellow); public static void Error(string message) => Write("ERROR", message, ConsoleColor.Red); public static void Http( int statusCode, string method, string path, long elapsedMilliseconds, Exception? exception = null) { ConsoleColor color = statusCode switch { >= 500 => ConsoleColor.Red, >= 400 => ConsoleColor.Yellow, >= 300 => ConsoleColor.Cyan, >= 200 => ConsoleColor.Green, _ => ConsoleColor.Gray }; string suffix = exception == null ? string.Empty : $" | {exception.GetType().Name}: {exception.Message}"; WriteRaw( $"[{statusCode}] {method,-7} {path} ({elapsedMilliseconds} ms){suffix}", color ); } private static void Write(string tag, string message, ConsoleColor color) { WriteRaw($"[{tag}] {message}", color); } private static void WriteRaw(string message, ConsoleColor color) { lock (Sync) { ConsoleColor previousColor = Console.ForegroundColor; try { Console.ForegroundColor = color; Console.WriteLine(message); } finally { Console.ForegroundColor = previousColor; } } } } public class Program { private static readonly string ServerUrl = Environment.GetEnvironmentVariable("MOCHA_SERVER_URL") ?? "https://localhost:443"; private static readonly string SERVERSTATWEB = LoadLocalSetting("SERVERSTATWEB"); internal static string LoadLocalSetting(string settingName) { string? environmentValue = Environment.GetEnvironmentVariable(settingName); if (!string.IsNullOrWhiteSpace(environmentValue)) { return environmentValue.Trim(); } string envPath = Path.Combine(Directory.GetCurrentDirectory(), ".env"); if (!File.Exists(envPath)) { return string.Empty; } foreach (string line in File.ReadLines(envPath)) { string trimmedLine = line.Trim(); if (trimmedLine.Length == 0 || trimmedLine.StartsWith('#')) { continue; } int separatorIndex = trimmedLine.IndexOf('='); if (separatorIndex <= 0) { continue; } string key = trimmedLine[..separatorIndex].Trim(); if (string.Equals(key, settingName, StringComparison.OrdinalIgnoreCase)) { return trimmedLine[(separatorIndex + 1)..].Trim().Trim('"', '\''); } } return string.Empty; } private static readonly HttpClient ServerStatusHttpClient = new() { Timeout = TimeSpan.FromSeconds(8) }; private static DateTimeOffset _serverStartedAt; private static int _serverIsOnline; private static int _offlineWebhookSent; public static string dataDir = Path.Combine( Directory.GetCurrentDirectory(), "Data" ); // Set from builder.Environment.WebRootPath in Main - the framework's // own resolved wwwroot path, not a guess based on CWD/BaseDirectory // (which RecNetController.Serve() used to do and got wrong on the // container). public static string wwwRootDir = string.Empty; private static readonly string[] RequiredDirectories = { "DBs", "Images", Path.Combine("Images", "PlayerImages"), Path.Combine("Images", "CustomPFPS"), Path.Combine("Images", "PolaroidImages"), Path.Combine("Images", "RemoteLoadingScreens"), Path.Combine("Images", "EventImages"), Path.Combine("Images", "CommunityBoardUploads"), "CommunityBoardUploads", Path.Combine("CDN", "video"), Path.Combine("CDN", "DataBlobs"), Path.Combine("CDN", "InventionBlobs"), Path.Combine("CDN", "RoomBlobs"), "Imports", "Debug" }; private sealed class ConsoleInputGuard { private readonly TextWriter _promptWriter; private readonly TextWriter _logWriter; public ConsoleInputGuard( TextWriter promptWriter, TextWriter logWriter) { _promptWriter = promptWriter; _logWriter = logWriter; } public string ReadLine() { _promptWriter.Write("> "); string line = Console.ReadLine()?.Trim() ?? string.Empty; if (!string.IsNullOrWhiteSpace(line)) { _logWriter.WriteLine($"[CMD] {line}"); } return line; } } public static async Task Main(string[] args) { TextWriter originalConsole = Console.Out; string logFilePath = InitializeFileLogging(originalConsole); var consoleInput = new ConsoleInputGuard(originalConsole, Console.Out); ServerLog.Info($"Debug log: {logFilePath}"); EnsureDataDirectories(); WebApplication? app = null; bool serverStarted = false; string shutdownReason = "Server stopped normally"; try { WebApplicationBuilder builder = WebApplication.CreateBuilder(args); wwwRootDir = builder.Environment.WebRootPath; ConfigureFrameworkLogging(builder); ConfigureServices(builder); app = builder.Build(); NotiController.Initialize( app.Services.GetRequiredService>()); await ImportBaseRoomsAsync(); int orphanedDormsRemoved = RoomDB.RemoveOrphanedPlayerDorms(); ServerLog.Info($"Orphaned player dorms removed: {orphanedDormsRemoved}"); int dormsCreated = RoomDB.EnsurePlayerDormsForAllPlayers(); ServerLog.Info($"Player dorm ownership: created={dormsCreated}"); EnforceRRPlusForExistingPlayers(); int socialAccountsUpdated = PlayerDB.EnsureSocialDefaultsForAllPlayers(); ServerLog.Info($"Social defaults: updated={socialAccountsUpdated}"); int tokenAccountsUpdated = PlayerDB.EnsureInitialTokensForAllPlayers(); ServerLog.Info( $"Initial token grant: updated={tokenAccountsUpdated}, " + $"amount={PlayerDB.InitialTokenBalance}"); int alpacaOwnershipsRemoved = PlayerDB.ResetAlpacaShirtOwnershipForExistingPlayers(); ServerLog.Info( $"Alpaca Shirt ownership reset: removed={alpacaOwnershipsRemoved}"); ConfigureRequestPipeline(app); app.Urls.Add(ServerUrl); await app.StartAsync(); serverStarted = true; _serverStartedAt = DateTimeOffset.UtcNow; Volatile.Write(ref _serverIsOnline, 1); RegisterShutdownWebhookHandlers(); ServerLog.Success($"Server listening on {ServerUrl}"); ServerLog.Info("Type 'help' to view console commands."); Mocha2023.Cloudflare.StartCloudflared(); await SendServerOnlineWebhookAsync(); await RunConsoleLoopAsync(app, consoleInput); } catch (Exception ex) { shutdownReason = $"Unexpected shutdown: {ex.GetType().Name}"; ServerLog.Error($"Fatal server error: {ex.GetType().Name}: {ex.Message}"); throw; } finally { if (serverStarted) { await SendServerOfflineWebhookOnceAsync(shutdownReason); } if (app != null) { await app.DisposeAsync(); } } } private static bool HasServerStatusWebhook() { return !string.IsNullOrWhiteSpace(SERVERSTATWEB) && !SERVERSTATWEB.Contains( "PASTE_DISCORD_WEBHOOK_HERE", StringComparison.OrdinalIgnoreCase); } private static void RegisterShutdownWebhookHandlers() { Console.CancelKeyPress += (_, _) => { try { SendServerOfflineWebhookOnceAsync("Server stopped with Ctrl+C") .GetAwaiter() .GetResult(); } catch { } }; AppDomain.CurrentDomain.ProcessExit += (_, _) => { try { SendServerOfflineWebhookOnceAsync("Server process exited") .GetAwaiter() .GetResult(); } catch { } }; } private static async Task SendServerOnlineWebhookAsync() { var fields = new object[] { new { name = "Status", value = "🟢 Online", inline = true }, new { name = "Build", value = "Rec Room 2023.04.06", inline = true }, new { name = "Server", value = $"`{ServerUrl}`", inline = false } }; await SendServerStatusWebhookAsync( title: "☕ Mocha is freshly brewed!", description: "The Mocha 2023 server is online and ready for players. " + "The café is officially open!", color: 0xA66A3F, fields: fields, footerText: "Mocha • Freshly brewed for Rec Room 2023" ); } private static async Task SendServerOfflineWebhookOnceAsync(string reason) { if (Volatile.Read(ref _serverIsOnline) == 0 || Interlocked.Exchange(ref _offlineWebhookSent, 1) != 0) { return; } TimeSpan uptime = _serverStartedAt == default ? TimeSpan.Zero : DateTimeOffset.UtcNow - _serverStartedAt; var fields = new object[] { new { name = "Status", value = "🔴 Offline", inline = true }, new { name = "Uptime", value = FormatUptime(uptime), inline = true }, new { name = "Reason", value = reason, inline = false } }; await SendServerStatusWebhookAsync( title: "🫘 Mocha has gone cold", description: "The Mocha 2023 server is offline. " + "The café is closed for now.", color: 0x4E342E, fields: fields, footerText: "Mocha • Coffee break in progress" ); Volatile.Write(ref _serverIsOnline, 0); } private static async Task SendServerStatusWebhookAsync( string title, string description, int color, object[] fields, string footerText) { if (!HasServerStatusWebhook()) { ServerLog.Warning( "SERVERSTATWEB is not configured; status webhook skipped."); return; } var payload = new { username = "Mocha Server Status", embeds = new[] { new { title, description, color, fields, footer = new { text = footerText }, timestamp = DateTimeOffset.UtcNow.ToString("O") } } }; try { string json = JsonSerializer.Serialize(payload); using var content = new StringContent( json, Encoding.UTF8, "application/json"); using HttpResponseMessage response = await ServerStatusHttpClient.PostAsync(SERVERSTATWEB, content); if (response.IsSuccessStatusCode) { ServerLog.Success("Discord server-status webhook sent."); return; } string responseBody = await response.Content.ReadAsStringAsync(); ServerLog.Warning( $"Discord status webhook returned " + $"{(int)response.StatusCode}: {responseBody}"); } catch (Exception ex) { ServerLog.Warning( $"Discord status webhook failed: " + $"{ex.GetType().Name}: {ex.Message}"); } } private static string FormatUptime(TimeSpan uptime) { if (uptime.TotalDays >= 1) { return $"{(int)uptime.TotalDays}d {uptime.Hours}h " + $"{uptime.Minutes}m {uptime.Seconds}s"; } if (uptime.TotalHours >= 1) { return $"{(int)uptime.TotalHours}h " + $"{uptime.Minutes}m {uptime.Seconds}s"; } return $"{uptime.Minutes}m {uptime.Seconds}s"; } private static string InitializeFileLogging(TextWriter originalConsole) { string debugDirectory = Path.Combine(dataDir, "Debug"); Directory.CreateDirectory(debugDirectory); string logFilePath = Path.Combine( debugDirectory, $"console_{DateTime.UtcNow:yyyyMMdd_HHmmss}.log" ); var fileWriter = new StreamWriter(logFilePath, append: true) { AutoFlush = true }; Console.SetOut(new DualTextWriter(originalConsole, fileWriter)); return logFilePath; } private static void EnsureDataDirectories() { Directory.CreateDirectory(dataDir); foreach (string relativePath in RequiredDirectories) { Directory.CreateDirectory(Path.Combine(dataDir, relativePath)); } } private static void ConfigureFrameworkLogging( WebApplicationBuilder builder) { builder.Logging.ClearProviders(); builder.Logging.AddConsole(); builder.Logging.SetMinimumLevel(LogLevel.Warning); builder.Logging.AddFilter("Microsoft", LogLevel.Warning); builder.Logging.AddFilter("Microsoft.Hosting.Lifetime", LogLevel.None); } private static void ConfigureServices(WebApplicationBuilder builder) { builder.WebHost.ConfigureKestrel(options => { options.AddServerHeader = false; options.Limits.MaxRequestBodySize = 4 * 1024 * 1024; options.Limits.MaxRequestHeadersTotalSize = 32 * 1024; options.Limits.MaxRequestHeaderCount = 100; options.Limits.RequestHeadersTimeout = TimeSpan.FromSeconds(15); options.Limits.KeepAliveTimeout = TimeSpan.FromMinutes(2); }); builder.Services .AddControllers() .AddJsonOptions(options => { options.JsonSerializerOptions.PropertyNamingPolicy = null; options.JsonSerializerOptions.MaxDepth = 64; }); builder.Services.AddAuthorization(); builder.Services .AddSignalR(options => { options.KeepAliveInterval = TimeSpan.FromSeconds(15); options.ClientTimeoutInterval = TimeSpan.FromSeconds(45); options.MaximumReceiveMessageSize = 1024 * 1024; options.EnableDetailedErrors = false; }) .AddJsonProtocol(options => { options.PayloadSerializerOptions.PropertyNamingPolicy = null; options.PayloadSerializerOptions.PropertyNameCaseInsensitive = true; }); builder.Services.AddRateLimiter(options => { options.RejectionStatusCode = StatusCodes.Status429TooManyRequests; options.OnRejected = async (context, cancellationToken) => { if (!context.HttpContext.Response.HasStarted) { context.HttpContext.Response.ContentType = "application/json"; await context.HttpContext.Response.WriteAsJsonAsync( new { error = "Too many requests. Try again shortly." }, cancellationToken); } }; options.GlobalLimiter = PartitionedRateLimiter.Create(context => { string clientKey = GetRateLimitClientKey(context); PathString path = context.Request.Path; if (IsAuthenticationAttempt(path)) { return RateLimitPartition.GetFixedWindowLimiter( $"auth:{clientKey}", _ => new FixedWindowRateLimiterOptions { PermitLimit = 30, Window = TimeSpan.FromMinutes(1), QueueLimit = 0, AutoReplenishment = true }); } if (path.StartsWithSegments( "/reports", StringComparison.OrdinalIgnoreCase)) { return RateLimitPartition.GetFixedWindowLimiter( $"crash:{clientKey}", _ => new FixedWindowRateLimiterOptions { PermitLimit = 6, Window = TimeSpan.FromMinutes(10), QueueLimit = 0, AutoReplenishment = true }); } if (IsLargeUpload(context.Request)) { return RateLimitPartition.GetFixedWindowLimiter( $"upload:{clientKey}", _ => new FixedWindowRateLimiterOptions { PermitLimit = 60, Window = TimeSpan.FromMinutes(10), QueueLimit = 0, AutoReplenishment = true }); } return RateLimitPartition.GetNoLimiter($"normal:{clientKey}"); }); }); } private static bool IsAuthenticationAttempt(PathString path) { return path.StartsWithSegments( "/auth/connect/token", StringComparison.OrdinalIgnoreCase) || path.StartsWithSegments( "/auth/cachedlogin", StringComparison.OrdinalIgnoreCase) || path.StartsWithSegments( "/recnet/api/auth/login", StringComparison.OrdinalIgnoreCase) || path.StartsWithSegments( "/recnet/api/auth/register", StringComparison.OrdinalIgnoreCase); } private static bool IsLargeUpload(HttpRequest request) { if (request.Method is not ("POST" or "PUT" or "PATCH")) return false; PathString path = request.Path; return request.ContentLength > 1024 * 1024 || path.StartsWithSegments( "/api/images/v4/uploadsaved", StringComparison.OrdinalIgnoreCase) || path.StartsWithSegments( "/recnet/api/account/profile-image", StringComparison.OrdinalIgnoreCase) || path.StartsWithSegments( "/recnet/api/account/banner-image", StringComparison.OrdinalIgnoreCase) || path.StartsWithSegments( "/recnet/api/admin/events/image", StringComparison.OrdinalIgnoreCase) || path.StartsWithSegments( "/recnet/api/admin/community-board/media", StringComparison.OrdinalIgnoreCase); } private static string GetRateLimitClientKey(HttpContext context) { IPAddress? remoteAddress = context.Connection.RemoteIpAddress; if (remoteAddress != null && IPAddress.IsLoopback(remoteAddress) && context.Request.Headers.TryGetValue( "CF-Connecting-IP", out var forwardedValues) && IPAddress.TryParse( forwardedValues.FirstOrDefault(), out IPAddress? forwardedAddress)) { remoteAddress = forwardedAddress; } return remoteAddress?.MapToIPv6().ToString() ?? "unknown"; } private static async Task ImportBaseRoomsAsync() { string importPath = Path.Combine( dataDir, "Imports", "ImportRooms.json" ); if (!File.Exists(importPath)) { return; } if (RoomDB.Rooms.Count() == 0) { ServerLog.Info("Importing rooms from ImportRooms.json..."); await RoomDB.ImportRooms(importPath); ServerLog.Success("Room import complete."); return; } int repaired = await RoomDB.EnsureCanonicalBaseRooms(importPath); ServerLog.Info($"Base room reconciliation: changed={repaired}"); } private static void EnforceRRPlusForExistingPlayers() { int settingsUpdated = PlayerDB.ForceRRPlusForAllPlayers(); int rolesAdded = ForceRRPlusRoleForAllPlayers(); ServerLog.Info( $"RR+ enforcement: settings={settingsUpdated}, " + $"roles={rolesAdded}, players={PlayerDB.Players.Count()}" ); } private static void ConfigureRequestPipeline(WebApplication app) { app.UseWebSockets(); app.UseWhen( context => context.Request.Path.StartsWithSegments( "/recnet", StringComparison.OrdinalIgnoreCase), recNetApp => recNetApp.Use(async (context, next) => { context.Response.OnStarting(() => { context.Response.Headers["X-Content-Type-Options"] = "nosniff"; context.Response.Headers["X-Frame-Options"] = "DENY"; context.Response.Headers["Cross-Origin-Opener-Policy"] = "same-origin"; context.Response.Headers["Cross-Origin-Resource-Policy"] = "same-origin"; context.Response.Headers["Referrer-Policy"] = "no-referrer"; context.Response.Headers["Permissions-Policy"] = "camera=(), microphone=(), geolocation=()"; context.Response.Headers["Content-Security-Policy"] = "default-src 'self'; " + "script-src 'self' https://static.cloudflareinsights.com; " + "style-src 'self' 'unsafe-inline' https://cdnjs.cloudflare.com https://fonts.googleapis.com; " + "font-src 'self' https://cdnjs.cloudflare.com https://fonts.gstatic.com; " + "img-src 'self' data: blob:; " + "connect-src 'self' ws: wss: https://cloudflareinsights.com; " + "object-src 'none'; base-uri 'self'; " + "form-action 'self'; frame-ancestors 'none'"; if (context.Request.Path.StartsWithSegments( "/recnet/api", StringComparison.OrdinalIgnoreCase)) { context.Response.Headers.CacheControl = "no-store, no-cache, must-revalidate"; context.Response.Headers.Pragma = "no-cache"; } return Task.CompletedTask; }); await next(); })); app.Use(async (context, next) => { Stopwatch stopwatch = Stopwatch.StartNew(); Exception? exception = null; long inboundBytes = DeveloperTelemetry.EstimateInboundBytes(context.Request); DeveloperTelemetry.RecordInboundBytes(inboundBytes); Stream originalResponseBody = context.Response.Body; var countedResponseBody = new CountingResponseStream( originalResponseBody, bytes => DeveloperTelemetry.RecordOutboundBytes(bytes)); context.Response.Body = countedResponseBody; try { await next(); } catch (Exception ex) { exception = ex; if (!context.Response.HasStarted) { context.Response.Clear(); context.Response.StatusCode = StatusCodes.Status500InternalServerError; context.Response.ContentType = "application/json"; await context.Response.WriteAsJsonAsync(new { error = "Internal server error" }); } } finally { stopwatch.Stop(); context.Response.Body = originalResponseBody; string requestPath = $"{context.Request.PathBase}{context.Request.Path}"; DeveloperTelemetry.RecordRequest( context.Response.StatusCode, context.Request.Method, requestPath, stopwatch.ElapsedMilliseconds, inboundBytes, countedResponseBody.BytesWritten); bool isSuccessfulRelationshipPoll = context.Response.StatusCode >= StatusCodes.Status200OK && context.Response.StatusCode < StatusCodes.Status300MultipleChoices && context.Request.Path.Equals( "/api/relationships/v2/get", StringComparison.OrdinalIgnoreCase); if (!isSuccessfulRelationshipPoll) { string path = $"{context.Request.PathBase}" + $"{context.Request.Path}"; ServerLog.Http( context.Response.StatusCode, context.Request.Method, path, stopwatch.ElapsedMilliseconds, exception ); } } }); app.UseDefaultFiles(); app.UseStaticFiles(); app.UseRouting(); app.UseRateLimiter(); app.UseAuthorization(); app.MapHub("/noti/hub/v1"); app.MapControllers(); } private static async Task RunConsoleLoopAsync( WebApplication app, ConsoleInputGuard consoleInput) { while (true) { string line = consoleInput.ReadLine(); if (string.IsNullOrWhiteSpace(line)) { continue; } string[] commandParts = line.Split(' ', 2); string command = commandParts[0].Trim().ToLowerInvariant(); string arguments = commandParts.Length == 2 ? commandParts[1].Trim() : string.Empty; TryLogAdminCommand( command, command == "set-password" ? "(redacted)" : arguments ); try { bool keepRunning = await ExecuteCommandAsync(command, arguments); if (!keepRunning) { break; } } catch (Exception ex) { ServerLog.Error( $"Command '{command}' failed: " + $"{ex.GetType().Name}: {ex.Message}" ); TryLogAdminError(command, arguments, ex); } } ServerLog.Info("Stopping server..."); await app.StopAsync(); ServerLog.Success("Server stopped."); } private static async Task ExecuteCommandAsync( string command, string arguments) { switch (command) { case "help": PrintHelp(); break; case "give-rrplus": HandleGiveRRPlus(arguments); break; case "force-rrplus-all": HandleForceRRPlusAll(); break; case "give-influencer": HandleGiveInfluencer(arguments); break; case "grant-role": case "grant-roles": HandleGrantRoles(arguments); break; case "set-bio": HandleSetBio(arguments); break; case "reset-usernamechanges": HandleResetUsernameChanges(arguments); break; case "set-pfp": HandleSetProfileImage(arguments); break; case "set-banner": HandleSetBannerImage(arguments); break; case "add-roomrole": HandleAddRoomRole(arguments); break; case "set-username": HandleSetUsername(arguments); break; case "set-display": HandleSetDisplayName(arguments); break; case "import-room-dump": { if (string.IsNullOrWhiteSpace(arguments)) { Console.WriteLine("Usage: import-room-dump [creatorAccountId]"); break; } string path = arguments.Trim().Trim('"'); long creatorId = 1; int finalSpace = path.LastIndexOf(' '); if (finalSpace > 0 && long.TryParse(path[(finalSpace + 1)..], out long parsedCreatorId)) { creatorId = parsedCreatorId; path = path[..finalSpace].Trim().Trim('"'); } ShowdownImporter.Import(path, creatorId, replaceExisting: true); break; } case "change-level": HandleChangeLevel(arguments); break; case "change-xp": HandleChangeXp(arguments, add: false); break; case "add-xp": HandleChangeXp(arguments, add: true); break; case "set-balance": HandleBalance(arguments, add: false); break; case "add-balance": HandleBalance(arguments, add: true); break; case "set-junior": HandleSetJunior(arguments); break; case "set-rep": HandleSetReputation(arguments); break; case "reset-password": HandleResetPassword(arguments); break; case "set-password": HandleSetPassword(arguments); break; case "ban-player": HandleBanPlayer(arguments); break; case "unban-player": HandleUnbanPlayer(arguments); break; case "set-room-state": HandleSetRoomState(arguments); break; case "set-room-access": HandleSetRoomAccess(arguments); break; case "clear-visits": HandleClearPlayerData(arguments, PlayerDataToClear.Visits); break; case "clear-favorites": HandleClearPlayerData(arguments, PlayerDataToClear.Favorites); break; case "clear-cheers": HandleClearPlayerData(arguments, PlayerDataToClear.Cheers); break; case "reset-profile": HandleResetProfile(arguments); break; case "create-account": HandleCreateAccount(arguments); break; case "change-accid": HandleChangeAccountId(arguments); break; case "maintenance": await HandleMaintenanceAsync(arguments); break; case "exit": case "quit": return false; default: ServerLog.Warning( $"Unknown command '{command}'. Type 'help'." ); break; } return true; } private static void PrintHelp() { Console.WriteLine(); Console.WriteLine("=== Mocha2023 Admin Commands ==="); Console.WriteLine("help"); Console.WriteLine("give-rrplus "); Console.WriteLine("force-rrplus-all"); Console.WriteLine("give-influencer [playerId2...]"); Console.WriteLine("grant-role [role2...]"); Console.WriteLine("set-bio "); Console.WriteLine("reset-usernamechanges [amount]"); Console.WriteLine("set-pfp "); Console.WriteLine("set-banner "); Console.WriteLine("set-username "); Console.WriteLine("set-display "); Console.WriteLine("change-level "); Console.WriteLine("change-xp "); Console.WriteLine("add-xp "); Console.WriteLine("set-balance "); Console.WriteLine("add-balance "); Console.WriteLine("set-junior "); Console.WriteLine("set-rep "); Console.WriteLine("reset-password "); Console.WriteLine("set-password "); Console.WriteLine("ban-player [hours]"); Console.WriteLine("unban-player "); Console.WriteLine("set-room-state "); Console.WriteLine("import-room-dump [creatorId] - Imports a legacy Rec Room dump"); Console.WriteLine("set-room-access "); Console.WriteLine("clear-visits "); Console.WriteLine("clear-favorites "); Console.WriteLine("clear-cheers "); Console.WriteLine("add-roomrole "); Console.WriteLine("reset-profile "); Console.WriteLine("create-account "); Console.WriteLine("change-accid "); Console.WriteLine("maintenance "); Console.WriteLine("exit"); Console.WriteLine(); } private static void HandleGiveRRPlus(string arguments) { if (!TryParsePositiveLong(arguments, out long playerId)) { Usage("give-rrplus "); return; } if (!ForceRRPlusForPlayer(playerId)) { ServerLog.Error($"Player {playerId} was not found or updated."); return; } ServerLog.Success($"RR+ granted to player {playerId}."); } private static void HandleForceRRPlusAll() { int settingsUpdated = PlayerDB.ForceRRPlusForAllPlayers(); int rolesAdded = ForceRRPlusRoleForAllPlayers(); ServerLog.Success( $"RR+ enforced: settings={settingsUpdated}, " + $"roles={rolesAdded}, players={PlayerDB.Players.Count()}" ); } private static void HandleGiveInfluencer(string arguments) { long[] playerIds = arguments .Split( new[] { ',', ' ' }, StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries ) .Select(value => long.TryParse(value, out long id) ? id : -1) .Where(id => id > 0) .Distinct() .ToArray(); if (playerIds.Length == 0) { Usage("give-influencer [playerId2...]"); return; } foreach (long playerId in playerIds) { FullPlayer? player = PlayerDB.Players.FindById(playerId); if (player == null) { ServerLog.Warning($"Player {playerId} was not found."); continue; } player.PlayerRoles ??= new List(); if (player.PlayerRoles.Contains(PlayerRoles.Influencer)) { ServerLog.Info($"Player {playerId} is already verified."); continue; } player.PlayerRoles.Add(PlayerRoles.Influencer); if (PlayerDB.Players.Update(player)) { ServerLog.Success($"Influencer granted to player {playerId}."); } else { ServerLog.Error($"Failed to update player {playerId}."); } } } private static void HandleGrantRoles(string arguments) { string[] values = arguments.Split(' ', 2); if (values.Length != 2 || string.IsNullOrWhiteSpace(values[0]) || string.IsNullOrWhiteSpace(values[1])) { Usage("grant-role [role2...]"); return; } GrantRoles(values[0], values[1]); } private static void HandleAddRoomRole(string arguments) { string[] values = arguments.Split( ' ', 3, StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); if (values.Length != 3) { Usage( "add-roomrole " + ""); return; } if (!TryParsePositiveLong(values[0], out long playerId)) { ServerLog.Warning( $"Invalid player ID '{values[0]}'."); return; } if (!TryParsePositiveLong(values[1], out long roomId)) { ServerLog.Warning( $"Invalid room ID '{values[1]}'."); return; } if (!Enum.TryParse( values[2], ignoreCase: true, out RoomDBClasses.Role role) || !Enum.IsDefined(typeof(RoomDBClasses.Role), role) || role is RoomDBClasses.Role.None or RoomDBClasses.Role.Banned) { ServerLog.Warning( $"Invalid room role '{values[2]}'. Valid roles: " + "Host, Moderator, CoOwner, TemporaryCoOwner, Creator."); return; } var player = PlayerDB.Players.FindById(playerId); if (player == null) { ServerLog.Warning( $"Player {playerId} was not found."); return; } RoomDBClasses.Room? room = RoomDB.Rooms.FindById(roomId); if (room == null) { ServerLog.Warning( $"Room {roomId} was not found."); return; } room.Roles ??= new List(); RoomDBClasses.Roles? existingRole = room.Roles.FirstOrDefault(entry => entry.AccountId == playerId); if (existingRole == null) { room.Roles.Add(new RoomDBClasses.Roles { AccountId = playerId, Role = role, InvitedRole = RoomDBClasses.Role.None }); } else { existingRole.Role = role; existingRole.InvitedRole = RoomDBClasses.Role.None; } room.UgcVersion = Math.Max(1, room.UgcVersion + 1); if (!RoomDB.Rooms.Update(room)) { ServerLog.Error( $"Failed to give player {playerId} the {role} role " + $"in room {roomId}."); return; } ServerLog.Success( $"Player {playerId} is now {role} in " + $"room {roomId} ({room.Name})."); } private static void HandleSetBio(string arguments) { if (!TrySplitPlayerAndValue( arguments, "set-bio ", out long playerId, out string bio)) { return; } if (!TryGetPlayerWithData(playerId, out FullPlayer player)) { return; } player.Player!.Bio = bio; SavePlayer(player, $"Bio updated for player {playerId}.", notifyProfile: true); } private static void HandleResetUsernameChanges(string arguments) { string[] values = arguments.Split( ' ', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries ); if (values.Length is < 1 or > 2 || !TryParsePositiveLong(values[0], out long playerId)) { Usage("reset-usernamechanges [amount]"); return; } int amount = 3; if (values.Length == 2 && (!int.TryParse(values[1], out amount) || amount < 0)) { Usage("reset-usernamechanges [amount]"); return; } if (!TryGetPlayerWithData(playerId, out FullPlayer player)) { return; } player.Player!.AvailableUsernameChanges = amount; SavePlayer( player, $"Username changes for player {playerId} set to {amount}." ); } private static void HandleSetProfileImage(string arguments) { if (!TrySplitPlayerAndValue( arguments, "set-pfp ", out long playerId, out string sourceInput)) { return; } if (!TryGetPlayerWithData(playerId, out FullPlayer player)) { return; } string? sourcePath = ResolveProfileImageSourcePath(sourceInput); if (sourcePath == null) { ServerLog.Error($"Image file not found: {sourceInput}"); return; } string copiedPath = CopyProfileImageToCustomPfps(sourcePath); player.Player!.ProfileImage = copiedPath; SavePlayer(player, $"Profile image set to {copiedPath}.", notifyProfile: true); } private static void HandleSetBannerImage(string arguments) { if (!TrySplitPlayerAndValue( arguments, "set-banner ", out long playerId, out string sourceInput)) { return; } if (!TryGetPlayerWithData(playerId, out FullPlayer player)) return; string? sourcePath = ResolveProfileImageSourcePath(sourceInput); if (sourcePath == null) { ServerLog.Error($"Image file not found: {sourceInput}"); return; } string copiedPath = CopyProfileImageToCustomPfps(sourcePath); player.Player!.BannerImage = copiedPath; SavePlayer(player, $"Banner image for player {playerId} set to {copiedPath}.", notifyProfile: true); } private static void HandleSetUsername(string arguments) { if (!TrySplitPlayerAndValue( arguments, "set-username ", out long playerId, out string username)) { return; } if (!TryGetPlayerWithData(playerId, out FullPlayer player)) { return; } player.Player!.Username = username; SavePlayer(player, $"Username for player {playerId} set to '{username}'.", notifyProfile: true); } private static void HandleSetDisplayName(string arguments) { if (!TrySplitPlayerAndValue( arguments, "set-display ", out long playerId, out string displayName)) { return; } if (!TryGetPlayerWithData(playerId, out FullPlayer player)) { return; } player.Player!.DisplayName = displayName; SavePlayer( player, $"Display name for player {playerId} set to '{displayName}'.", notifyProfile: true ); } private static void HandleChangeLevel(string arguments) { if (!TryParsePlayerAndInt( arguments, "change-level ", out long playerId, out int level)) { return; } if (!TryGetPlayerWithData(playerId, out FullPlayer player)) { return; } PlayerProgressionDTO? progression = PlayerDB.SetProgression( playerId, level, player.Player!.XP); if (progression == null) { ServerLog.Error( $"Failed to update level for player {playerId}."); return; } ServerLog.Success( $"Level for player {playerId} set to {progression.Level}."); } private static void HandleChangeXp(string arguments, bool add) { string usage = add ? "add-xp " : "change-xp "; if (!TryParsePlayerAndInt( arguments, usage, out long playerId, out int xp)) { return; } if (!TryGetPlayerWithData(playerId, out FullPlayer player)) { return; } int updatedXp = add ? Math.Max(0, player.Player!.XP + xp) : Math.Max(0, xp); PlayerProgressionDTO? progression = PlayerDB.SetProgression( playerId, player.Player!.Level, updatedXp); if (progression == null) { ServerLog.Error( $"Failed to update XP for player {playerId}."); return; } ServerLog.Success( $"XP for player {playerId} is now {progression.XP}."); } private static void HandleBalance(string arguments, bool add) { string usage = add ? "add-balance " : "set-balance "; if (!TryParsePlayerAndInt( arguments, usage, out long playerId, out int amount)) { return; } if (!TryGetPlayerWithData(playerId, out FullPlayer player)) { return; } player.Player!.PlayerExtra ??= new PlayerExtra(); player.Player.PlayerExtra.Currencies ??= new List(); PlayerCurrency? currency = player.Player.PlayerExtra.Currencies .FirstOrDefault(item => item.CurrencyType == CurrencyType.RecCenterTokens); if (currency == null) { currency = new PlayerCurrency { CurrencyType = CurrencyType.RecCenterTokens, BalanceType = BalanceType.NonPurchasedDefault, Balance = Math.Max(0, amount) }; player.Player.PlayerExtra.Currencies.Add(currency); } else { currency.Balance = add ? Math.Max(0, currency.Balance + amount) : Math.Max(0, amount); } SavePlayer( player, $"Token balance for player {playerId} is now {currency.Balance}." ); } private static void HandleSetJunior(string arguments) { string[] values = arguments.Split(' ', 2); if (values.Length != 2 || !TryParsePositiveLong(values[0], out long playerId) || !bool.TryParse(values[1], out bool isJunior)) { Usage("set-junior "); return; } if (!TryGetPlayerWithData(playerId, out FullPlayer player)) { return; } player.Player!.IsJunior = isJunior; SavePlayer( player, $"Junior status for player {playerId} set to {isJunior}." ); } private static void HandleSetReputation(string arguments) { string[] values = arguments.Split(' ', 2); if (values.Length != 2 || !TryParsePositiveLong(values[0], out long playerId) || !double.TryParse(values[1], out double reputation)) { Usage("set-rep "); return; } if (!TryGetPlayerWithData(playerId, out FullPlayer player)) { return; } player.Player!.Reputation ??= new Reputation(); player.Player.Reputation.Noteriety = reputation; SavePlayer( player, $"Reputation for player {playerId} set to {reputation}." ); } private static void HandleResetPassword(string arguments) { if (!TryParsePositiveLong(arguments, out long playerId)) { Usage("reset-password "); return; } FullPlayer? player = PlayerDB.Players.FindById(playerId); if (player == null) { ServerLog.Warning($"Player {playerId} was not found."); return; } player.Password = null; SavePlayer(player, $"Password cleared for player {playerId}."); } private static void HandleSetPassword(string arguments) { if (!TrySplitPlayerAndValue( arguments, "set-password ", out long playerId, out string password)) { return; } if (password.Length < 4 || password.Length > PasswordSecurity.MaxPasswordLength) { ServerLog.Warning("Password must be 4-256 characters."); return; } FullPlayer? player = PlayerDB.Players.FindById(playerId); if (player == null) { ServerLog.Warning($"Player {playerId} was not found."); return; } player.Password = PasswordSecurity.Hash(password); SavePlayer(player, $"Password set for player {playerId}."); } private static void HandleBanPlayer(string arguments) { string[] values = arguments.Split( ' ', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries ); if (values.Length is < 1 or > 2 || !TryParsePositiveLong(values[0], out long playerId)) { Usage("ban-player [hours]"); return; } int durationHours = 24; if (values.Length == 2 && (!int.TryParse(values[1], out durationHours) || durationHours < 1)) { Usage("ban-player [hours]"); return; } if (!TryGetPlayerWithData(playerId, out FullPlayer player)) { return; } if (durationHours > int.MaxValue / 3600) { ServerLog.Warning("Ban duration exceeds the 32-bit duration limit."); return; } PlayerDB.BanPlayer( playerId, durationHours * 3600, "Banned via console command" ); ServerLog.Success($"Player {playerId} banned from gameplay for {durationHours} hour(s)."); } private static void HandleUnbanPlayer(string arguments) { if (!TryParsePositiveLong(arguments, out long playerId)) { Usage("unban-player "); return; } if (!TryGetPlayerWithData(playerId, out FullPlayer player)) { return; } PlayerDB.UnbanPlayer(playerId); ServerLog.Success($"Player {playerId} unbanned from gameplay."); } private static void HandleSetRoomState(string arguments) { string[] values = arguments.Split(' ', 2); if (values.Length != 2 || !TryParsePositiveLong(values[0], out long roomId) || !Enum.TryParse( values[1], ignoreCase: true, out RoomDBClasses.RoomState state)) { Usage("set-room-state "); return; } RoomDBClasses.Room? room = RoomDB.Rooms.FindById(roomId); if (room == null) { ServerLog.Warning($"Room {roomId} was not found."); return; } room.State = state; if (RoomDB.Rooms.Update(room)) { ServerLog.Success($"Room {roomId} state set to {state}."); } else { ServerLog.Error($"Failed to update room {roomId}."); } } private static void HandleSetRoomAccess(string arguments) { string[] values = arguments.Split(' ', 2); if (values.Length != 2 || !TryParsePositiveLong(values[0], out long roomId) || !Enum.TryParse( values[1], ignoreCase: true, out RoomDBClasses.RoomAccessibility accessibility)) { Usage("set-room-access "); return; } RoomDBClasses.Room? room = RoomDB.Rooms.FindById(roomId); if (room == null) { ServerLog.Warning($"Room {roomId} was not found."); return; } room.Accessibility = accessibility; if (RoomDB.Rooms.Update(room)) { ServerLog.Success( $"Room {roomId} access set to {accessibility}." ); } else { ServerLog.Error($"Failed to update room {roomId}."); } } private enum PlayerDataToClear { Visits, Favorites, Cheers } private static void HandleClearPlayerData( string arguments, PlayerDataToClear dataToClear) { if (!TryParsePositiveLong(arguments, out long playerId)) { Usage(dataToClear switch { PlayerDataToClear.Visits => "clear-visits ", PlayerDataToClear.Favorites => "clear-favorites ", _ => "clear-cheers " }); return; } if (!TryGetPlayerWithData(playerId, out FullPlayer player)) { return; } switch (dataToClear) { case PlayerDataToClear.Visits: player.Player!.VisitedRooms ??= new List(); player.Player.VisitedRooms.Clear(); player.Player.PlayerExtra ??= new PlayerExtra(); player.Player.PlayerExtra.RoomVisits ??= new List(); player.Player.PlayerExtra.RoomVisits.Clear(); break; case PlayerDataToClear.Favorites: player.Player!.FavoritedRooms ??= new List(); player.Player.FavoritedRooms.Clear(); break; case PlayerDataToClear.Cheers: player.Player!.CheeredRooms ??= new List(); player.Player.CheeredRooms.Clear(); player.Player.ReceivedCheers ??= new List(); player.Player.ReceivedCheers.Clear(); player.Player.Reputation ??= new Reputation(); player.Player.Reputation.IsCheerful = false; player.Player.Reputation.SelectedCheer = CheerCategory.General; player.Player.Reputation.CheerGeneral = 0; player.Player.Reputation.CheerHelpful = 0; player.Player.Reputation.CheerSportsman = 0; player.Player.Reputation.CheerGreatHost = 0; player.Player.Reputation.CheerCreative = 0; NotificationDB.DeletePlayerCheerMessages(playerId); break; } SavePlayer( player, $"Cleared {dataToClear.ToString().ToLowerInvariant()} " + $"for player {playerId}." ); } private static void HandleResetProfile(string arguments) { if (!TryParsePositiveLong(arguments, out long playerId)) { Usage("reset-profile "); return; } if (!TryGetPlayerWithData(playerId, out FullPlayer player)) { return; } player.Player!.PlayerExtra ??= new PlayerExtra(); player.Player.PlayerExtra.AvatarItems ??= new List(); player.Player.PlayerExtra.SavedAvatars ??= new List(); player.Player.Bio = string.Empty; player.Player.ProfileImage = "DefaultPFP.png"; player.Player.BannerImage = null; player.Player.DisplayName = player.Player.Username; player.Player.PlayerExtra.Avatar = new Avatar(); player.Player.PlayerExtra.AvatarItems.Clear(); player.Player.PlayerExtra.SavedAvatars.Clear(); SavePlayer(player, $"Profile reset for player {playerId}.", notifyProfile: true); } private static void HandleCreateAccount(string arguments) { string[] values = arguments.Split(' ', 5); if (values.Length != 5 || string.IsNullOrWhiteSpace(values[0]) || !TryParsePositiveLong(values[1], out long accountId) || !bool.TryParse(values[2], out bool isJunior) || !Enum.TryParse( values[3], ignoreCase: true, out Platforms platform) || !ulong.TryParse(values[4], out ulong platformId)) { Usage( "create-account " + " " ); return; } if (PlayerDB.Players.FindById(accountId) != null) { ServerLog.Error($"Account {accountId} already exists."); return; } FullPlayer created = PlayerDB.CreateAccount( platform, platformId, isJunior, accountId ); created.Player ??= new Player(); created.Player.Username = values[0].Trim(); created.Player.DisplayName = values[0].Trim(); created.Player.PlayerExtra ??= new PlayerExtra(); created.Player.PlayerExtra.Settings ??= new List(); SavePlayer( created, $"Created account {accountId} as '{created.Player.Username}'." ); } private static void HandleChangeAccountId(string arguments) { string[] values = arguments.Split(' ', 2); if (values.Length != 2 || !TryParsePositiveLong(values[0], out long currentAccountId) || !TryParsePositiveLong(values[1], out long newAccountId)) { Usage("change-accid "); return; } if (currentAccountId == newAccountId) { ServerLog.Warning("The current and new account IDs are equal."); return; } if (PlayerDB.ChangeAccountId(currentAccountId, newAccountId)) { ServerLog.Success( $"Account ID changed from {currentAccountId} " + $"to {newAccountId}." ); } else { ServerLog.Error( $"Could not change account ID {currentAccountId} " + $"to {newAccountId}." ); } } public static async Task SetMaintenanceCountdownAsync(int minutes) { if (minutes is < 0 or > 10_080) throw new ArgumentOutOfRangeException( nameof(minutes), "Maintenance must be between 0 and 10,080 minutes."); string configPath = Path.Combine(dataDir, "APIS", "ConfigV2.json"); if (!File.Exists(configPath)) throw new FileNotFoundException( $"ConfigV2.json was not found at '{configPath}'.", configPath); string json = File.ReadAllText(configPath); Dictionary? config = JsonSerializer.Deserialize>(json); if (config == null) throw new InvalidDataException("ConfigV2.json could not be parsed."); config.Remove("ServerMaintainence"); config["ServerMaintenance"] = new { StartsInMinutes = minutes }; File.WriteAllText( configPath, JsonSerializer.Serialize( config, new JsonSerializerOptions { WriteIndented = true } ) ); await NotiController.BroadcastServerMaintenanceAsync(minutes); } private static async Task HandleMaintenanceAsync(string arguments) { if (!int.TryParse(arguments, out int minutes) || minutes < 0) { Usage("maintenance "); return; } try { await SetMaintenanceCountdownAsync(minutes); } catch (Exception ex) when (ex is ArgumentOutOfRangeException or FileNotFoundException or InvalidDataException or JsonException or IOException) { ServerLog.Error(ex.Message); return; } ServerLog.Success( $"Maintenance countdown set to {minutes} minute(s)." ); } private static bool TrySplitPlayerAndValue( string arguments, string usage, out long playerId, out string value) { playerId = 0; value = string.Empty; string[] values = arguments.Split(' ', 2); if (values.Length != 2 || !TryParsePositiveLong(values[0], out playerId) || string.IsNullOrWhiteSpace(values[1])) { Usage(usage); return false; } value = values[1].Trim(); return true; } private static bool TryParsePlayerAndInt( string arguments, string usage, out long playerId, out int value) { playerId = 0; value = 0; string[] values = arguments.Split(' ', 2); if (values.Length != 2 || !TryParsePositiveLong(values[0], out playerId) || !int.TryParse(values[1], out value)) { Usage(usage); return false; } return true; } private static bool TryParsePositiveLong( string value, out long result) { return long.TryParse(value.Trim(), out result) && result > 0; } private static bool TryGetPlayerWithData( long playerId, out FullPlayer player) { FullPlayer? found = PlayerDB.Players.FindById(playerId); if (found?.Player == null) { ServerLog.Warning($"Player {playerId} was not found."); player = null!; return false; } player = found; return true; } private static void SavePlayer( FullPlayer player, string successMessage, bool notifyProfile = false) { if (PlayerDB.Players.Update(player)) { ServerLog.Success(successMessage); if (notifyProfile) _ = NotiController.NotifyPlayerProfileUpdatedAsync(player.PlayerId); } else { ServerLog.Error($"Failed to save player {player.PlayerId}."); } } private static void Usage(string usage) { ServerLog.Warning($"Usage: {usage}"); } private static void TryLogAdminCommand( string command, string arguments) { try { DiscordLogger.Log( "🛠️ **Admin Command Executed**\n" + $"**Command:** `{command}`\n" + $"**Args:** `{(string.IsNullOrWhiteSpace(arguments) ? "(none)" : arguments)}`" ); } catch (Exception ex) { ServerLog.Warning( $"Discord command log failed: {ex.Message}" ); } } private static void TryLogAdminError( string command, string arguments, Exception exception) { try { DiscordLogger.Log( "❌ **Admin Command Error**\n" + $"**Command:** `{command}`\n" + $"**Args:** `{(string.IsNullOrWhiteSpace(arguments) ? "(none)" : arguments)}`\n" + $"**Error:** `{exception.GetType().Name}: {exception.Message}`" ); } catch { } } private static string? ResolveProfileImageSourcePath(string inputPath) { if (string.IsNullOrWhiteSpace(inputPath)) { return null; } if (Path.IsPathRooted(inputPath) && File.Exists(inputPath)) { return Path.GetFullPath(inputPath); } string[] candidatePaths = { inputPath, Path.Combine(Environment.CurrentDirectory, inputPath), Path.Combine(dataDir, inputPath), Path.Combine(dataDir, "Images", inputPath), Path.Combine(dataDir, "Images", "PlayerImages", inputPath), Path.Combine(dataDir, "Images", "CustomPFPS", inputPath) }; return candidatePaths .Select(Path.GetFullPath) .FirstOrDefault(File.Exists); } private static string CopyProfileImageToCustomPfps(string sourcePath) { string extension = Path.GetExtension(sourcePath); if (string.IsNullOrWhiteSpace(extension)) { extension = ".png"; } string targetFolder = Path.Combine( dataDir, "Images", "CustomPFPS" ); Directory.CreateDirectory(targetFolder); string assetName = $"{Guid.NewGuid():D}{extension}"; string targetPath = Path.Combine(targetFolder, assetName); File.Copy(sourcePath, targetPath, overwrite: true); return Path.Combine("CustomPFPS", assetName) .Replace('\\', '/'); } private static bool ForceRRPlusForPlayer(long playerId) { if (playerId <= 0 || !PlayerDB.SetRRPlus(playerId, true)) { return false; } FullPlayer? player = PlayerDB.Players.FindById(playerId); if (player == null) { return false; } player.PlayerRoles ??= new List(); if (player.PlayerRoles.Contains(PlayerRoles.RRPlus)) { return true; } player.PlayerRoles.Add(PlayerRoles.RRPlus); return PlayerDB.Players.Update(player); } private static int ForceRRPlusRoleForAllPlayers() { int rolesAdded = 0; foreach (FullPlayer player in PlayerDB.Players.FindAll()) { player.PlayerRoles ??= new List(); if (player.PlayerRoles.Contains(PlayerRoles.RRPlus)) { continue; } player.PlayerRoles.Add(PlayerRoles.RRPlus); if (PlayerDB.Players.Update(player)) { rolesAdded++; } } return rolesAdded; } private static void GrantRoles( string playerIdsText, string rolesText) { List playerIds = ParsePlayerIds(playerIdsText); if (playerIds.Count == 0) { ServerLog.Warning("No valid player IDs were supplied."); return; } string[] roleNames = rolesText.Split( ' ', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries ); if (roleNames.Length == 0) { ServerLog.Warning("No roles were supplied."); return; } foreach (long playerId in playerIds) { FullPlayer? player = PlayerDB.Players.FindById(playerId); if (player == null) { ServerLog.Warning($"Player {playerId} was not found."); continue; } player.PlayerRoles ??= new List(); var addedRoles = new List(); foreach (string roleName in roleNames) { if (!RoleUtils.TryParseRole(roleName, out PlayerRoles role)) { ServerLog.Warning( $"Unknown role '{roleName}' for player {playerId}." ); continue; } if (player.PlayerRoles.Contains(role)) { continue; } player.PlayerRoles.Add(role); addedRoles.Add(role); } if (addedRoles.Contains(PlayerRoles.Developer)) { PlayerDB.GrantDeveloperCheerAccess( player, selectDeveloperBadge: true); } if (addedRoles.Count == 0) { ServerLog.Info( $"Player {playerId} already has the requested roles." ); continue; } if (PlayerDB.Players.Update(player)) { ServerLog.Success( $"Added {string.Join(", ", addedRoles)} " + $"to player {playerId}." ); } else { ServerLog.Error($"Failed to update player {playerId}."); } } } private static List ParsePlayerIds(string playerIdsText) { return playerIdsText .Split( new[] { ',', ' ' }, StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries ) .Select(value => long.TryParse(value, out long id) ? id : -1) .Where(id => id > 0) .Distinct() .ToList(); } } }