Files
mochaisvibecoded 42f7f57b72 :3
2026-08-24 17:00:26 -04:00

2435 lines
80 KiB
C#

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<IHubContext<NotificationHub>>());
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<HttpContext, string>(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<NotificationHub>("/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<bool> 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 <extracted-folder> [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 <playerId>");
Console.WriteLine("force-rrplus-all");
Console.WriteLine("give-influencer <playerId1> [playerId2...]");
Console.WriteLine("grant-role <playerIds> <role1> [role2...]");
Console.WriteLine("set-bio <playerId> <text>");
Console.WriteLine("reset-usernamechanges <playerId> [amount]");
Console.WriteLine("set-pfp <playerId> <filename-or-path>");
Console.WriteLine("set-banner <playerId> <filename-or-path>");
Console.WriteLine("set-username <playerId> <name>");
Console.WriteLine("set-display <playerId> <name>");
Console.WriteLine("change-level <playerId> <level>");
Console.WriteLine("change-xp <playerId> <xp>");
Console.WriteLine("add-xp <playerId> <xp>");
Console.WriteLine("set-balance <playerId> <amount>");
Console.WriteLine("add-balance <playerId> <amount>");
Console.WriteLine("set-junior <playerId> <true|false>");
Console.WriteLine("set-rep <playerId> <value>");
Console.WriteLine("reset-password <playerId>");
Console.WriteLine("set-password <playerId> <password>");
Console.WriteLine("ban-player <playerId> [hours]");
Console.WriteLine("unban-player <playerId>");
Console.WriteLine("set-room-state <roomId> <state>");
Console.WriteLine("import-room-dump <folder> [creatorId] - Imports a legacy Rec Room dump");
Console.WriteLine("set-room-access <roomId> <private|public|unlisted>");
Console.WriteLine("clear-visits <playerId>");
Console.WriteLine("clear-favorites <playerId>");
Console.WriteLine("clear-cheers <playerId>");
Console.WriteLine("add-roomrole <playerId> <roomId> <Host|Moderator|CoOwner|TemporaryCoOwner|Creator>");
Console.WriteLine("reset-profile <playerId>");
Console.WriteLine("create-account <username> <accountId> <true|false> <platform> <platformId>");
Console.WriteLine("change-accid <currentAccountId> <newAccountId>");
Console.WriteLine("maintenance <minutes>");
Console.WriteLine("exit");
Console.WriteLine();
}
private static void HandleGiveRRPlus(string arguments)
{
if (!TryParsePositiveLong(arguments, out long playerId))
{
Usage("give-rrplus <playerId>");
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 <playerId1> [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<PlayerRoles>();
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 <playerIds> <role1> [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 <playerId> <roomId> " +
"<Host|Moderator|CoOwner|TemporaryCoOwner|Creator>");
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>();
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 <playerId> <text>",
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 <playerId> [amount]");
return;
}
int amount = 3;
if (values.Length == 2 &&
(!int.TryParse(values[1], out amount) || amount < 0))
{
Usage("reset-usernamechanges <playerId> [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 <playerId> <filename-or-path>",
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 <playerId> <filename-or-path>",
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 <playerId> <name>",
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 <playerId> <name>",
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 <playerId> <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 <playerId> <xp>"
: "change-xp <playerId> <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 <playerId> <amount>"
: "set-balance <playerId> <amount>";
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>();
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 <playerId> <true|false>");
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 <playerId> <value>");
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 <playerId>");
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 <playerId> <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 <playerId> [hours]");
return;
}
int durationHours = 24;
if (values.Length == 2 &&
(!int.TryParse(values[1], out durationHours) ||
durationHours < 1))
{
Usage("ban-player <playerId> [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 <playerId>");
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 <roomId> <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 <roomId> <private|public|unlisted>");
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 <playerId>",
PlayerDataToClear.Favorites => "clear-favorites <playerId>",
_ => "clear-cheers <playerId>"
});
return;
}
if (!TryGetPlayerWithData(playerId, out FullPlayer player))
{
return;
}
switch (dataToClear)
{
case PlayerDataToClear.Visits:
player.Player!.VisitedRooms ??= new List<long>();
player.Player.VisitedRooms.Clear();
player.Player.PlayerExtra ??= new PlayerExtra();
player.Player.PlayerExtra.RoomVisits ??= new List<RoomVisit>();
player.Player.PlayerExtra.RoomVisits.Clear();
break;
case PlayerDataToClear.Favorites:
player.Player!.FavoritedRooms ??= new List<long>();
player.Player.FavoritedRooms.Clear();
break;
case PlayerDataToClear.Cheers:
player.Player!.CheeredRooms ??= new List<long>();
player.Player.CheeredRooms.Clear();
player.Player.ReceivedCheers ??=
new List<PlayerCheerRecord>();
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 <playerId>");
return;
}
if (!TryGetPlayerWithData(playerId, out FullPlayer player))
{
return;
}
player.Player!.PlayerExtra ??= new PlayerExtra();
player.Player.PlayerExtra.AvatarItems ??= new List<string>();
player.Player.PlayerExtra.SavedAvatars ??= new List<SavedOutfit>();
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 <username> <accountId> " +
"<true|false> <platform> <platformId>"
);
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<Setting>();
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 <currentAccountId> <newAccountId>");
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<string, object?>? config =
JsonSerializer.Deserialize<Dictionary<string, object?>>(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 <minutes>");
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<PlayerRoles>();
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<PlayerRoles>();
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<long> 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<PlayerRoles>();
var addedRoles = new List<PlayerRoles>();
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<long> 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();
}
}
}